From 66fb89dc48d430f5e9654bc3bf3d1bd66121c1ed Mon Sep 17 00:00:00 2001 From: xiose Date: Thu, 30 Apr 2026 10:17:28 +0800 Subject: [PATCH] feat(publish): support SKILL.md in subdirectory with warning for ignored files When SKILL.md is found in a single subdirectory (e.g. my-skill/SKILL.md), promote that directory's contents to root and discard files outside it. Discarded files are reported as warnings through the existing confirm flow. --- .../support/SkillPackageArchiveExtractor.java | 52 ++++++++++++++++ .../SkillPackageArchiveExtractorTest.java | 61 +++++++++++++++++++ 2 files changed, 113 insertions(+) diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/support/SkillPackageArchiveExtractor.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/support/SkillPackageArchiveExtractor.java index 5d98e93a..fad1eb72 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/support/SkillPackageArchiveExtractor.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/support/SkillPackageArchiveExtractor.java @@ -18,6 +18,8 @@ import java.util.zip.ZipInputStream; @Component public class SkillPackageArchiveExtractor { + public record ExtractionResult(List entries, List warnings) {} + private final long maxTotalPackageSize; private final long maxSingleFileSize; private final int maxFileCount; @@ -76,6 +78,11 @@ public class SkillPackageArchiveExtractor { return stripSingleRootDirectory(entries); } + public ExtractionResult extractWithWarnings(MultipartFile file) throws IOException { + List entries = extract(file); + return promoteSingleSkillMdDirectory(entries); + } + /** * If all file paths share a single root directory prefix (e.g., "my-skill/xxx"), * strip that prefix. Otherwise return entries unchanged. @@ -107,6 +114,51 @@ public class SkillPackageArchiveExtractor { .toList(); } + static ExtractionResult promoteSingleSkillMdDirectory(List entries) { + boolean hasRootSkillMd = entries.stream() + .anyMatch(e -> SkillPackagePolicy.SKILL_MD_PATH.equals(e.path())); + if (hasRootSkillMd) { + return new ExtractionResult(entries, List.of()); + } + + Set skillMdDirs = new HashSet<>(); + for (PackageEntry entry : entries) { + int slashIndex = entry.path().indexOf('/'); + if (slashIndex > 0) { + String relativePath = entry.path().substring(slashIndex + 1); + if (SkillPackagePolicy.SKILL_MD_PATH.equals(relativePath)) { + skillMdDirs.add(entry.path().substring(0, slashIndex)); + } + } + } + + if (skillMdDirs.isEmpty()) { + return new ExtractionResult(entries, List.of()); + } + if (skillMdDirs.size() > 1) { + throw new IllegalArgumentException( + "Ambiguous package: SKILL.md found in multiple directories: " + skillMdDirs); + } + + String prefix = skillMdDirs.iterator().next() + "/"; + List promoted = new ArrayList<>(); + List warnings = new ArrayList<>(); + + for (PackageEntry entry : entries) { + if (entry.path().startsWith(prefix)) { + promoted.add(new PackageEntry( + entry.path().substring(prefix.length()), + entry.content(), + entry.size(), + entry.contentType())); + } else { + warnings.add("Ignored file outside skill directory: " + entry.path()); + } + } + + return new ExtractionResult(promoted, warnings); + } + private byte[] readEntry(ZipInputStream zis, String path) throws IOException { ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); byte[] buffer = new byte[8192]; diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/support/SkillPackageArchiveExtractorTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/support/SkillPackageArchiveExtractorTest.java index 403be311..0807773c 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/support/SkillPackageArchiveExtractorTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/support/SkillPackageArchiveExtractorTest.java @@ -126,6 +126,67 @@ class SkillPackageArchiveExtractorTest { assertTrue(entries.stream().anyMatch(e -> e.path().equals("dir-b/other.md"))); } + @Test + void promotesSkillMdFromSubdirectoryAndDiscardsRootFiles() throws Exception { + byte[] zipBytes = createZip(Map.of( + "my-skill/SKILL.md", "---\nname: test\n---\n".getBytes(), + "my-skill/README.md", "# readme".getBytes(), + "other.txt", "stray file".getBytes() + )); + MockMultipartFile file = new MockMultipartFile("file", "test.zip", "application/zip", zipBytes); + + SkillPackageArchiveExtractor.ExtractionResult result = extractor.extractWithWarnings(file); + + assertEquals(2, result.entries().size()); + assertTrue(result.entries().stream().anyMatch(e -> e.path().equals("SKILL.md"))); + assertTrue(result.entries().stream().anyMatch(e -> e.path().equals("README.md"))); + assertTrue(result.warnings().stream().anyMatch(w -> w.contains("other.txt"))); + } + + @Test + void rejectsAmbiguousMultipleSkillMdInSubdirectories() throws Exception { + byte[] zipBytes = createZip(Map.of( + "dir1/SKILL.md", "---\nname: a\n---\n".getBytes(), + "dir2/SKILL.md", "---\nname: b\n---\n".getBytes() + )); + MockMultipartFile file = new MockMultipartFile("file", "test.zip", "application/zip", zipBytes); + + IllegalArgumentException error = assertThrows(IllegalArgumentException.class, + () -> extractor.extractWithWarnings(file)); + assertTrue(error.getMessage().contains("Ambiguous")); + } + + @Test + void noPromotionWhenSkillMdAtRoot() throws Exception { + byte[] zipBytes = createZip(Map.of( + "SKILL.md", "---\nname: test\n---\n".getBytes(), + "sub/file.txt", "content".getBytes() + )); + MockMultipartFile file = new MockMultipartFile("file", "test.zip", "application/zip", zipBytes); + + SkillPackageArchiveExtractor.ExtractionResult result = extractor.extractWithWarnings(file); + + assertEquals(2, result.entries().size()); + assertTrue(result.warnings().isEmpty()); + } + + @Test + void promotesSubdirectoryPreservingNestedPaths() throws Exception { + byte[] zipBytes = createZip(Map.of( + "my-skill/SKILL.md", "---\nname: test\n---\n".getBytes(), + "my-skill/sub/deep.md", "nested".getBytes(), + "stray.txt", "ignored".getBytes() + )); + MockMultipartFile file = new MockMultipartFile("file", "test.zip", "application/zip", zipBytes); + + SkillPackageArchiveExtractor.ExtractionResult result = extractor.extractWithWarnings(file); + + assertEquals(2, result.entries().size()); + assertTrue(result.entries().stream().anyMatch(e -> e.path().equals("SKILL.md"))); + assertTrue(result.entries().stream().anyMatch(e -> e.path().equals("sub/deep.md"))); + assertTrue(result.warnings().stream().anyMatch(w -> w.contains("stray.txt"))); + } + private byte[] createZip(String entryName, String content) throws Exception { return createZip(entryName, content.getBytes(StandardCharsets.UTF_8)); }