diff --git a/docs/07-skill-protocol.md b/docs/07-skill-protocol.md index 6e9db3cc..4ee36a08 100644 --- a/docs/07-skill-protocol.md +++ b/docs/07-skill-protocol.md @@ -66,7 +66,7 @@ my-skill/ ``` 校验规则: -- 根目录必须包含 `SKILL.md` +- 根目录必须包含规范入口文件 `SKILL.md`;上传时服务端兼容 `skill.md`、`Skill.md` 等大小写变体,并在内部归一化为 `SKILL.md` - 文件类型白名单:`.md`, `.txt`, `.json`, `.yaml`, `.yml`, `.js`, `.cjs`, `.mjs`, `.ts`, `.py`, `.sh`, `.png`, `.jpg`, `.svg` - 单文件大小限制:1MB(可配置) - 总包大小限制:10MB(可配置) diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/support/MultipartPackageExtractor.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/support/MultipartPackageExtractor.java index 0a9fc793..e1a0a57c 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/support/MultipartPackageExtractor.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/support/MultipartPackageExtractor.java @@ -4,6 +4,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.iflytek.skillhub.config.SkillPublishProperties; import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; import com.iflytek.skillhub.domain.skill.validation.PackageEntry; +import com.iflytek.skillhub.domain.skill.validation.SkillPackagePolicy; import org.springframework.stereotype.Component; import org.springframework.web.multipart.MultipartFile; @@ -110,7 +111,7 @@ public class MultipartPackageExtractor { throw new DomainBadRequestException("error.skill.publish.package.invalid", "Unsafe package path: " + path); } - return path; + return SkillPackagePolicy.canonicalizeSkillMdPath(path); } private String determineContentType(String filename) { diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/support/ZipPackageExtractor.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/support/ZipPackageExtractor.java index 2beaec70..a23e8cc5 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/support/ZipPackageExtractor.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/support/ZipPackageExtractor.java @@ -3,6 +3,7 @@ package com.iflytek.skillhub.controller.support; import com.iflytek.skillhub.config.SkillPublishProperties; import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; import com.iflytek.skillhub.domain.skill.validation.PackageEntry; +import com.iflytek.skillhub.domain.skill.validation.SkillPackagePolicy; import org.springframework.stereotype.Component; import org.springframework.web.multipart.MultipartFile; @@ -112,7 +113,7 @@ public class ZipPackageExtractor { throw new DomainBadRequestException("error.skill.publish.package.invalid", "Unsafe package path: " + path); } - return normalizedPath; + return SkillPackagePolicy.canonicalizeSkillMdPath(normalizedPath); } catch (InvalidPathException ex) { throw new DomainBadRequestException("error.skill.publish.package.invalid", "Invalid package path: " + path); diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/support/MultipartPackageExtractorTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/support/MultipartPackageExtractorTest.java new file mode 100644 index 00000000..f3be9120 --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/support/MultipartPackageExtractorTest.java @@ -0,0 +1,35 @@ +package com.iflytek.skillhub.controller.support; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.iflytek.skillhub.config.SkillPublishProperties; +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockMultipartFile; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class MultipartPackageExtractorTest { + + @Test + void extractCanonicalizesCaseInsensitiveSkillMd() throws Exception { + MultipartPackageExtractor extractor = new MultipartPackageExtractor( + new SkillPublishProperties(), + new ObjectMapper() + ); + MockMultipartFile skillMd = new MockMultipartFile( + "files", + "skill.md", + "text/markdown", + "---\nname: test\n---\n".getBytes() + ); + + MultipartPackageExtractor.ExtractedPackage extracted = extractor.extract( + new MockMultipartFile[] {skillMd}, + "{\"namespace\":\"global\",\"slug\":\"test\"}" + ); + + assertEquals(1, extracted.entries().size()); + assertTrue(extracted.entries().stream().anyMatch(e -> e.path().equals("SKILL.md"))); + assertTrue(extracted.entries().stream().noneMatch(e -> e.path().equals("skill.md"))); + } +} 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 ba5cc2f7..c849fc4c 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 @@ -85,6 +85,21 @@ class SkillPackageArchiveExtractorTest { assertTrue(entries.stream().anyMatch(e -> e.path().equals("config.json"))); } + @Test + void canonicalizesCaseInsensitiveSkillMdAtRoot() throws Exception { + byte[] zipBytes = createZip(Map.of( + "skill.md", "---\nname: test\n---\n".getBytes(), + "README.md", "# readme".getBytes() + )); + MockMultipartFile file = new MockMultipartFile("file", "test.zip", "application/zip", zipBytes); + + SkillPackageArchiveExtractor.ExtractionResult result = extractor.extractWithWarnings(file); + + assertTrue(result.entries().stream().anyMatch(e -> e.path().equals("SKILL.md"))); + assertTrue(result.entries().stream().noneMatch(e -> e.path().equals("skill.md"))); + assertTrue(result.warnings().isEmpty()); + } + @Test void doesNotStripWhenMultipleRootEntries() throws Exception { byte[] zipBytes = createZip(Map.of( @@ -144,6 +159,23 @@ class SkillPackageArchiveExtractorTest { assertTrue(result.warnings().stream().anyMatch(w -> w.contains("other.txt"))); } + @Test + void promotesCaseInsensitiveSkillMdFromSubdirectory() 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( diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/support/ZipPackageExtractorTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/support/ZipPackageExtractorTest.java new file mode 100644 index 00000000..02f26214 --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/support/ZipPackageExtractorTest.java @@ -0,0 +1,47 @@ +package com.iflytek.skillhub.controller.support; + +import com.iflytek.skillhub.config.SkillPublishProperties; +import com.iflytek.skillhub.domain.skill.validation.PackageEntry; +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockMultipartFile; + +import java.io.ByteArrayOutputStream; +import java.util.List; +import java.util.Map; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class ZipPackageExtractorTest { + + @Test + void extractCanonicalizesCaseInsensitiveSkillMd() throws Exception { + ZipPackageExtractor extractor = new ZipPackageExtractor(new SkillPublishProperties()); + byte[] zipBytes = createZip(Map.of( + "skill.md", "---\nname: test\n---\n".getBytes(), + "README.md", "# readme".getBytes() + )); + MockMultipartFile file = new MockMultipartFile("file", "test.zip", "application/zip", zipBytes); + + List entries = extractor.extract(file); + + assertEquals(2, entries.size()); + assertTrue(entries.stream().anyMatch(e -> e.path().equals("SKILL.md"))); + assertTrue(entries.stream().noneMatch(e -> e.path().equals("skill.md"))); + } + + private byte[] createZip(Map entries) throws Exception { + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + try (ZipOutputStream zos = new ZipOutputStream(baos)) { + for (Map.Entry e : entries.entrySet()) { + ZipEntry entry = new ZipEntry(e.getKey()); + zos.putNextEntry(entry); + zos.write(e.getValue()); + zos.closeEntry(); + } + } + return baos.toByteArray(); + } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/validation/SkillPackagePolicy.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/validation/SkillPackagePolicy.java index eab8d2e2..ee3880ae 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/validation/SkillPackagePolicy.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/validation/SkillPackagePolicy.java @@ -64,7 +64,19 @@ public final class SkillPackagePolicy { throw new IllegalArgumentException("Package entry path must be normalized: " + rawPath); } - return canonical; + return canonicalizeSkillMdPath(canonical); + } + + public static String canonicalizeSkillMdPath(String normalizedPath) { + int slashIndex = normalizedPath.lastIndexOf('/'); + String fileName = slashIndex >= 0 ? normalizedPath.substring(slashIndex + 1) : normalizedPath; + if (!SKILL_MD_PATH.equalsIgnoreCase(fileName)) { + return normalizedPath; + } + if (slashIndex < 0) { + return SKILL_MD_PATH; + } + return normalizedPath.substring(0, slashIndex + 1) + SKILL_MD_PATH; } public static boolean hasAllowedExtension(String path) { diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/validation/SkillPackageValidatorTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/validation/SkillPackageValidatorTest.java index d473a361..c2b6b63c 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/validation/SkillPackageValidatorTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/validation/SkillPackageValidatorTest.java @@ -40,6 +40,35 @@ class SkillPackageValidatorTest { assertTrue(result.errors().isEmpty()); } + @Test + void normalizesSkillMdFilenameCase() { + assertEquals("SKILL.md", SkillPackagePolicy.normalizeEntryPath("skill.md")); + assertEquals("SKILL.md", SkillPackagePolicy.normalizeEntryPath("Skill.MD")); + assertEquals("nested/SKILL.md", SkillPackagePolicy.normalizeEntryPath("nested/skill.md")); + } + + @Test + void acceptsSkillMdFilenameWithDifferentCase() { + String skillMdContent = """ + --- + name: test-skill + description: A test skill + version: 1.0.0 + --- + # Test Skill + """; + + List entries = List.of( + new PackageEntry("skill.md", skillMdContent.getBytes(), skillMdContent.length(), "text/markdown"), + new PackageEntry("README.md", "readme".getBytes(), 6, "text/markdown") + ); + + ValidationResult result = validator.validate(entries); + + assertTrue(result.passed()); + assertTrue(result.errors().isEmpty()); + } + @Test void testMissingSkillMd() { List entries = List.of( diff --git a/web/src/docs/skill.md b/web/src/docs/skill.md index 18ea1e88..b8ed8bcf 100644 --- a/web/src/docs/skill.md +++ b/web/src/docs/skill.md @@ -138,7 +138,8 @@ If a request fails with `403`, check: ## Skill Package Contract -SkillHub expects OpenSkills-style packages with `SKILL.md` as the entry point. +SkillHub expects OpenSkills-style packages with canonical `SKILL.md` as the entry point. Uploads +accept filename case variants such as `skill.md` and normalize them to `SKILL.md`. ## Publishing Guidance