feat: 扩展标准 Skill 包兼容性

- 支持单层父目录包装和多 Skill ZIP 解码

- 安全忽略 macOS 元数据并保持路径校验
This commit is contained in:
2026-08-19 22:38:01 +08:00
parent 9612c5bd62
commit c8be163124
6 changed files with 197 additions and 27 deletions

View File

@@ -251,9 +251,10 @@ public class ZipSkillPackageCodec implements AutoCloseable {
throw packageError("UNSUPPORTED_ZIP_ENTRY", rawPath, throw packageError("UNSUPPORTED_ZIP_ENTRY", rawPath,
"Encrypted or unsupported ZIP entries are not allowed."); "Encrypted or unsupported ZIP entries are not allowed.");
} }
boolean ignoredSystemPath = SkillPaths.isIgnoredSystemPath(rawPath);
String pathForValidation = entry.isDirectory() ? stripDirectorySuffix(rawPath) : rawPath; String pathForValidation = entry.isDirectory() ? stripDirectorySuffix(rawPath) : rawPath;
String path = normalizeArchivePath(pathForValidation, limits); String path = normalizeArchivePath(pathForValidation, limits, ignoredSystemPath);
if (entry.isDirectory() || SkillPaths.isIgnoredSystemPath(rawPath)) { if (entry.isDirectory() || ignoredSystemPath) {
continue; continue;
} }
if (!exactPaths.add(path)) { if (!exactPaths.add(path)) {
@@ -322,7 +323,7 @@ public class ZipSkillPackageCodec implements AutoCloseable {
ArchiveFile skillFile = group.files.stream() ArchiveFile skillFile = group.files.stream()
.filter(file -> SkillPaths.SKILL_FILE.equals(file.relativePath)) .filter(file -> SkillPaths.SKILL_FILE.equals(file.relativePath))
.findFirst() .findFirst()
.orElseThrow(() -> packageError("SKILL_FILE_REQUIRED", group.root, .orElseThrow(() -> packageError("SKILL_FILE_REQUIRED", group.archiveRoot,
"Skill directory must contain exactly one SKILL.md.")); "Skill directory must contain exactly one SKILL.md."));
String skillContent = readStrictText(zipFile, skillFile, limits.getMaxTextFileBytes()); String skillContent = readStrictText(zipFile, skillFile, limits.getMaxTextFileBytes());
@@ -331,7 +332,7 @@ public class ZipSkillPackageCodec implements AutoCloseable {
document = SkillFrontmatter.parseDocument(skillContent, limits); document = SkillFrontmatter.parseDocument(skillContent, limits);
} catch (SkillValidationException e) { } catch (SkillValidationException e) {
throw validationPackageError(e, throw validationPackageError(e,
layout == SkillPackageLayout.ROOT_SKILL ? null : group.root); layout == SkillPackageLayout.ROOT_SKILL ? null : group.packageRoot);
} }
String name = scalar(document.getFrontmatter().get("name")); String name = scalar(document.getFrontmatter().get("name"));
@@ -346,7 +347,7 @@ public class ZipSkillPackageCodec implements AutoCloseable {
resources.sort(Comparator.comparing(SkillResource::getPath)); resources.sort(Comparator.comparing(SkillResource::getPath));
Skill skill = new Skill(); Skill skill = new Skill();
skill.setPackageRoot(layout == SkillPackageLayout.ROOT_SKILL ? name : group.root); skill.setPackageRoot(layout == SkillPackageLayout.ROOT_SKILL ? name : group.packageRoot);
skill.setDocument(document); skill.setDocument(document);
skill.setResources(resources); skill.setResources(resources);
return skill; return skill;
@@ -412,19 +413,44 @@ public class ZipSkillPackageCodec implements AutoCloseable {
.map(file -> file.withRelativePath(file.fullPath)) .map(file -> file.withRelativePath(file.fullPath))
.toList(); .toList();
return new ArchiveLayout(SkillPackageLayout.ROOT_SKILL, return new ArchiveLayout(SkillPackageLayout.ROOT_SKILL,
List.of(new ArchiveGroup(null, relativeFiles))); List.of(new ArchiveGroup(null, null, relativeFiles)));
}
List<String> skillRoots = files.stream()
.map(file -> file.fullPath)
.filter(path -> path.endsWith("/" + SkillPaths.SKILL_FILE))
.map(path -> path.substring(0, path.length() - SkillPaths.SKILL_FILE.length() - 1))
.distinct()
.sorted()
.toList();
if (skillRoots.isEmpty()) {
throw packageError("SKILL_FILE_REQUIRED", null,
"Wrapped Skill ZIP must contain at least one Skill directory.");
}
String commonParent = parentPath(skillRoots.get(0));
boolean supportedParent = commonParent.isEmpty() || commonParent.indexOf('/') < 0;
boolean sameParent = skillRoots.stream().allMatch(root -> parentPath(root).equals(commonParent));
if (!supportedParent || !sameParent) {
throw packageError("MIXED_PACKAGE_LAYOUT", null,
"Skill directories must be direct ZIP roots or share one optional parent directory.");
} }
Map<String, List<ArchiveFile>> grouped = new LinkedHashMap<>(); Map<String, List<ArchiveFile>> grouped = new LinkedHashMap<>();
for (ArchiveFile file : files) { for (String root : skillRoots) {
int separator = file.fullPath.indexOf('/'); grouped.put(root, new ArrayList<>());
if (separator < 1 || separator == file.fullPath.length() - 1) {
throw packageError("UNOWNED_ROOT_FILE", file.fullPath,
"Wrapped Skill ZIP root can contain only Skill directories.");
} }
String root = file.fullPath.substring(0, separator); for (ArchiveFile file : files) {
String relativePath = file.fullPath.substring(separator + 1); String owner = skillRoots.stream()
grouped.computeIfAbsent(root, ignored -> new ArrayList<>()) .filter(root -> file.fullPath.startsWith(root + "/"))
.findFirst()
.orElse(null);
if (owner == null) {
throw packageError("UNOWNED_ROOT_FILE", file.fullPath,
"Wrapped Skill ZIP can contain files only inside Skill directories.");
}
String relativePath = file.fullPath.substring(owner.length() + 1);
grouped.get(owner)
.add(file.withRelativePath(relativePath)); .add(file.withRelativePath(relativePath));
} }
List<ArchiveGroup> groups = new ArrayList<>(); List<ArchiveGroup> groups = new ArrayList<>();
@@ -434,16 +460,38 @@ public class ZipSkillPackageCodec implements AutoCloseable {
.count(); .count();
if (skillFileCount != 1) { if (skillFileCount != 1) {
throw packageError("SKILL_FILE_REQUIRED", entry.getKey(), throw packageError("SKILL_FILE_REQUIRED", entry.getKey(),
"Each top-level Skill directory must contain exactly one SKILL.md."); "Each Skill directory must contain exactly one SKILL.md.");
} }
groups.add(new ArchiveGroup(entry.getKey(), entry.getValue())); groups.add(new ArchiveGroup(entry.getKey(), fileName(entry.getKey()), entry.getValue()));
} }
groups.sort(Comparator.comparing(group -> group.root)); groups.sort(Comparator.comparing(group -> group.archiveRoot));
SkillPackageLayout layout = groups.size() == 1 SkillPackageLayout layout = groups.size() == 1
? SkillPackageLayout.SINGLE_DIRECTORY : SkillPackageLayout.MULTI_DIRECTORY; ? SkillPackageLayout.SINGLE_DIRECTORY : SkillPackageLayout.MULTI_DIRECTORY;
return new ArchiveLayout(layout, groups); return new ArchiveLayout(layout, groups);
} }
/**
* 获取归一化归档路径的父目录。
*
* @param path 归一化归档路径
* @return 父目录,顶层路径返回空字符串
*/
private static String parentPath(String path) {
int separator = path.lastIndexOf('/');
return separator < 0 ? "" : path.substring(0, separator);
}
/**
* 获取归一化归档路径的末级目录名。
*
* @param path 归一化归档路径
* @return 末级目录名
*/
private static String fileName(String path) {
int separator = path.lastIndexOf('/');
return separator < 0 ? path : path.substring(separator + 1);
}
/** /**
* 执行 Codec 不可绕过的标准安全校验。 * 执行 Codec 不可绕过的标准安全校验。
* *
@@ -1136,10 +1184,13 @@ public class ZipSkillPackageCodec implements AutoCloseable {
return total; return total;
} }
private static String normalizeArchivePath(String rawPath, SkillPackageLimits limits) { private static String normalizeArchivePath(String rawPath, SkillPackageLimits limits,
boolean ignoredSystemPath) {
String normalized; String normalized;
try { try {
normalized = SkillPaths.normalize(rawPath); normalized = ignoredSystemPath
? SkillPaths.normalizeIgnoredSystemPath(rawPath)
: SkillPaths.normalize(rawPath);
} catch (SkillValidationException e) { } catch (SkillValidationException e) {
throw packageError("UNSAFE_ENTRY_PATH", rawPath, e.getMessage()); throw packageError("UNSAFE_ENTRY_PATH", rawPath, e.getMessage());
} }
@@ -1196,7 +1247,7 @@ public class ZipSkillPackageCodec implements AutoCloseable {
throw packageError("PATH_LENGTH_LIMIT", path, throw packageError("PATH_LENGTH_LIMIT", path,
"Skill path exceeds " + limits.getMaxPathLength() + " characters."); "Skill path exceeds " + limits.getMaxPathLength() + " characters.");
} }
if (SkillPaths.depth(path) > limits.getMaxPathDepth()) { if (path.split("/", -1).length > limits.getMaxPathDepth()) {
throw packageError("PATH_DEPTH_LIMIT", path, throw packageError("PATH_DEPTH_LIMIT", path,
"Skill path exceeds depth " + limits.getMaxPathDepth() + "."); "Skill path exceeds depth " + limits.getMaxPathDepth() + ".");
} }
@@ -1391,7 +1442,7 @@ public class ZipSkillPackageCodec implements AutoCloseable {
} }
} }
private record ArchiveGroup(String root, List<ArchiveFile> files) { private record ArchiveGroup(String archiveRoot, String packageRoot, List<ArchiveFile> files) {
} }
private record ArchiveLayout(SkillPackageLayout layout, List<ArchiveGroup> groups) { private record ArchiveLayout(SkillPackageLayout layout, List<ArchiveGroup> groups) {

View File

@@ -17,7 +17,7 @@ public class Skill implements Serializable {
private List<SkillResource> resources = new ArrayList<>(); private List<SkillResource> resources = new ArrayList<>();
/** /**
* 获取导入包中的顶层目录名;该值不是仓储 ID * 获取 Skill 自身的逻辑目录名;导入包允许在其外再包装一层父目录
* *
* @return 包目录名 * @return 包目录名
*/ */
@@ -26,7 +26,7 @@ public class Skill implements Serializable {
} }
/** /**
* 设置导入包中的顶层目录名。 * 设置 Skill 自身的逻辑目录名。
* *
* @param packageRoot 包目录名 * @param packageRoot 包目录名
*/ */

View File

@@ -8,9 +8,9 @@ public enum SkillPackageLayout {
/** 根目录直接包含 SKILL.md 的单 Skill 包。 */ /** 根目录直接包含 SKILL.md 的单 Skill 包。 */
ROOT_SKILL, ROOT_SKILL,
/** 一个顶层目录包装的单 Skill 包。 */ /** 一个 Skill 目录组成的单 Skill 包,可再由单层父目录包装。 */
SINGLE_DIRECTORY, SINGLE_DIRECTORY,
/** 多个顶层 Skill 目录组成的批量包。 */ /** 多个 Skill 目录组成的批量包,可共享一个单层父目录。 */
MULTI_DIRECTORY MULTI_DIRECTORY
} }

View File

@@ -45,6 +45,32 @@ public final class SkillPaths {
* @return 规范化后的路径 * @return 规范化后的路径
*/ */
public static String normalize(String path) { public static String normalize(String path) {
return normalize(path, false);
}
/**
* 规范化已识别的系统元数据路径,允许其中保留隐藏路径段供导入器安全忽略。
*
* @param path 原始系统元数据路径
* @return 规范化后的系统元数据路径
* @throws SkillValidationException 路径不属于可忽略系统元数据或包含不安全路径段时抛出
*/
public static String normalizeIgnoredSystemPath(String path) {
if (!isIgnoredSystemPath(path)) {
throw new SkillValidationException("Skill path is not ignored system metadata: " + path);
}
return normalize(path, true);
}
/**
* 规范化逻辑路径并按用途控制隐藏路径段。
*
* @param path 原始路径
* @param allowHiddenSegments 是否允许隐藏路径段
* @return 规范化后的路径
* @throws SkillValidationException 路径非法时抛出
*/
private static String normalize(String path, boolean allowHiddenSegments) {
if (path == null) { if (path == null) {
throw new SkillValidationException("Skill path is required."); throw new SkillValidationException("Skill path is required.");
} }
@@ -63,7 +89,7 @@ public final class SkillPaths {
if (segment.isEmpty() || ".".equals(segment) || "..".equals(segment)) { if (segment.isEmpty() || ".".equals(segment) || "..".equals(segment)) {
throw new SkillValidationException("Unsafe skill path is not allowed: " + path); throw new SkillValidationException("Unsafe skill path is not allowed: " + path);
} }
if (segment.startsWith(".")) { if (!allowHiddenSegments && segment.startsWith(".")) {
throw new SkillValidationException("Hidden skill path is not allowed: " + path); throw new SkillValidationException("Hidden skill path is not allowed: " + path);
} }
if (!segment.equals(segment.strip())) { if (!segment.equals(segment.strip())) {

View File

@@ -110,6 +110,33 @@ public class ZipSkillPackageCodecTest {
Assert.assertEquals("root-skill", result.getSkillPackage().getSkills().get(0).getPackageRoot()); Assert.assertEquals("root-skill", result.getSkillPackage().getSkills().get(0).getPackageRoot());
} }
/**
* macOS Finder 生成的 AppleDouble 与目录元数据不会影响根目录或单目录 Skill 导入。
*/
@Test
public void ignoreMacOsMetadataForSingleSkillPackages() {
SkillPackageReadResult rootResult = decode(new ZipSkillPackageCodec(), zip(files(
"SKILL.md", utf8(skillMd("root-skill")),
"references/a.md", utf8("# A"),
"__MACOSX/._SKILL.md", utf8("metadata"),
"__MACOSX/references/._a.md", utf8("metadata"),
".DS_Store", utf8("metadata")
)));
Assert.assertEquals(SkillPackageLayout.ROOT_SKILL, rootResult.getSkillPackage().getLayout());
Assert.assertEquals(1, rootResult.getSkillPackage().getSkills().get(0).getResources().size());
SkillPackageReadResult wrappedResult = decode(new ZipSkillPackageCodec(), zip(files(
"docx/SKILL.md", utf8(skillMd("docx")),
"docx/references/a.md", utf8("# A"),
"__MACOSX/docx/._SKILL.md", utf8("metadata"),
"__MACOSX/docx/references/._a.md", utf8("metadata"),
"docx/.DS_Store", utf8("metadata")
)));
Assert.assertEquals(SkillPackageLayout.SINGLE_DIRECTORY,
wrappedResult.getSkillPackage().getLayout());
Assert.assertEquals(1, wrappedResult.getSkillPackage().getSkills().get(0).getResources().size());
}
/** /**
* 多目录包按稳定目录顺序导入。 * 多目录包按稳定目录顺序导入。
*/ */
@@ -133,6 +160,50 @@ public class ZipSkillPackageCodecTest {
.getSkillPackage().getSkills().stream().map(Skill::getName).toList()); .getSkillPackage().getSkills().stream().map(Skill::getName).toList());
} }
/**
* 多个标准 Skill 目录被同一个父目录包装后仍按独立 Skill 解码。
*/
@Test
public void decodeMultipleSkillsWithParentDirectory() {
SkillPackageReadResult result = decode(new ZipSkillPackageCodec(), zip(files(
"skill-bundle/skill-b/SKILL.md", utf8(skillMd("skill-b")),
"skill-bundle/skill-a/SKILL.md", utf8(skillMd("skill-a")),
"skill-bundle/skill-a/references/a.md", utf8("# A"),
"__MACOSX/skill-bundle/skill-a/._SKILL.md", utf8("metadata"),
"skill-bundle/.DS_Store", utf8("metadata")
)));
Assert.assertEquals(SkillPackageLayout.MULTI_DIRECTORY, result.getSkillPackage().getLayout());
Assert.assertEquals(List.of("skill-a", "skill-b"), result.getSkillPackage().getSkills().stream()
.map(Skill::getName).toList());
Assert.assertEquals(List.of("skill-a", "skill-b"), result.getSkillPackage().getSkills().stream()
.map(Skill::getPackageRoot).toList());
Assert.assertEquals(1, result.getSkillPackage().getSkills().get(0).getResources().size());
}
/**
* 父目录包装布局中的散落文件不归属于任何 Skill 时拒绝。
*/
@Test
public void rejectUnownedFileInParentDirectory() {
assertPackageCode("UNOWNED_ROOT_FILE", zip(files(
"skill-bundle/skill-a/SKILL.md", utf8(skillMd("skill-a")),
"skill-bundle/skill-b/SKILL.md", utf8(skillMd("skill-b")),
"skill-bundle/README.md", utf8("orphan")
)), SkillPackageReadOptions.defaults());
}
/**
* Skill 目录来自不同层级或不同父目录时拒绝,避免产生含糊归属。
*/
@Test
public void rejectMixedSkillDirectoryParents() {
assertPackageCode("MIXED_PACKAGE_LAYOUT", zip(files(
"skill-a/SKILL.md", utf8(skillMd("skill-a")),
"skill-bundle/skill-b/SKILL.md", utf8(skillMd("skill-b"))
)), SkillPackageReadOptions.defaults());
}
/** /**
* 嵌套 frontmatter 与未知字段在 Codec 中保留。 * 嵌套 frontmatter 与未知字段在 Codec 中保留。
*/ */
@@ -268,7 +339,10 @@ public class ZipSkillPackageCodecTest {
*/ */
@Test @Test
public void rejectUnixSymlink() { public void rejectUnixSymlink() {
assertPackageCode("SYMLINK_ENTRY", symlinkZip(), SkillPackageReadOptions.defaults()); assertPackageCode("SYMLINK_ENTRY", symlinkZip("skill-a/assets/link"),
SkillPackageReadOptions.defaults());
assertPackageCode("SYMLINK_ENTRY", symlinkZip("__MACOSX/._link"),
SkillPackageReadOptions.defaults());
} }
/** /**
@@ -1121,7 +1195,7 @@ public class ZipSkillPackageCodecTest {
return truncated; return truncated;
} }
private static byte[] symlinkZip() { private static byte[] symlinkZip(String path) {
try { try {
ByteArrayOutputStream bytes = new ByteArrayOutputStream(); ByteArrayOutputStream bytes = new ByteArrayOutputStream();
try (ZipArchiveOutputStream output = new ZipArchiveOutputStream(bytes)) { try (ZipArchiveOutputStream output = new ZipArchiveOutputStream(bytes)) {
@@ -1131,7 +1205,7 @@ public class ZipSkillPackageCodecTest {
output.write(utf8(skillMd("skill-a"))); output.write(utf8(skillMd("skill-a")));
output.closeArchiveEntry(); output.closeArchiveEntry();
ZipArchiveEntry symlink = new ZipArchiveEntry("skill-a/assets/link"); ZipArchiveEntry symlink = new ZipArchiveEntry(path);
symlink.setUnixMode(UnixStat.LINK_FLAG | 0777); symlink.setUnixMode(UnixStat.LINK_FLAG | 0777);
output.putArchiveEntry(symlink); output.putArchiveEntry(symlink);
output.write(utf8("target")); output.write(utf8("target"));

View File

@@ -32,4 +32,23 @@ public class SkillPathsTest {
public void normalizeUnicodeToNfc() { public void normalizeUnicodeToNfc() {
Assert.assertEquals("references/\u00e9.md", SkillPaths.normalize("references/e\u0301.md")); Assert.assertEquals("references/\u00e9.md", SkillPaths.normalize("references/e\u0301.md"));
} }
/**
* 已识别的 macOS 系统元数据允许隐藏文件名,但仍执行路径规范化。
*/
@Test
public void normalizeIgnoredMacOsMetadata() {
Assert.assertEquals("__MACOSX/._docx-js.md",
SkillPaths.normalizeIgnoredSystemPath("__MACOSX/._docx-js.md"));
Assert.assertEquals("docx/.DS_Store",
SkillPaths.normalizeIgnoredSystemPath("docx/.DS_Store"));
}
/**
* 普通隐藏文件不能借用系统元数据规范化入口。
*/
@Test(expected = SkillValidationException.class)
public void rejectNonSystemHiddenPathThroughIgnoredNormalizer() {
SkillPaths.normalizeIgnoredSystemPath("docx/.env");
}
} }