mirror of
https://github.com/iflytek/skillhub.git
synced 2026-08-27 11:14:59 +00:00
fix(publish): accept case-insensitive SKILL.md uploads
This commit is contained in:
parent
d58c934d64
commit
a88b09e51b
9 changed files with 163 additions and 5 deletions
|
|
@ -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(可配置)
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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")));
|
||||
}
|
||||
}
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue