fix(publish): accept case-insensitive SKILL.md uploads

This commit is contained in:
XiaoSeS 2026-06-11 16:34:13 +08:00
parent d58c934d64
commit a88b09e51b
9 changed files with 163 additions and 5 deletions

View file

@ -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可配置

View file

@ -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) {

View file

@ -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);

View file

@ -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")));
}
}

View file

@ -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(

View file

@ -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<PackageEntry> 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<String, byte[]> entries) throws Exception {
ByteArrayOutputStream baos = new ByteArrayOutputStream();
try (ZipOutputStream zos = new ZipOutputStream(baos)) {
for (Map.Entry<String, byte[]> e : entries.entrySet()) {
ZipEntry entry = new ZipEntry(e.getKey());
zos.putNextEntry(entry);
zos.write(e.getValue());
zos.closeEntry();
}
}
return baos.toByteArray();
}
}

View file

@ -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) {

View file

@ -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<PackageEntry> 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<PackageEntry> entries = List.of(

View file

@ -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