diff --git a/easy-agents-skill/src/main/java/com/easyagents/skill/codec/ZipSkillPackageCodec.java b/easy-agents-skill/src/main/java/com/easyagents/skill/codec/ZipSkillPackageCodec.java index 8f14d13..33bc0f3 100644 --- a/easy-agents-skill/src/main/java/com/easyagents/skill/codec/ZipSkillPackageCodec.java +++ b/easy-agents-skill/src/main/java/com/easyagents/skill/codec/ZipSkillPackageCodec.java @@ -251,9 +251,10 @@ public class ZipSkillPackageCodec implements AutoCloseable { throw packageError("UNSUPPORTED_ZIP_ENTRY", rawPath, "Encrypted or unsupported ZIP entries are not allowed."); } + boolean ignoredSystemPath = SkillPaths.isIgnoredSystemPath(rawPath); String pathForValidation = entry.isDirectory() ? stripDirectorySuffix(rawPath) : rawPath; - String path = normalizeArchivePath(pathForValidation, limits); - if (entry.isDirectory() || SkillPaths.isIgnoredSystemPath(rawPath)) { + String path = normalizeArchivePath(pathForValidation, limits, ignoredSystemPath); + if (entry.isDirectory() || ignoredSystemPath) { continue; } if (!exactPaths.add(path)) { @@ -322,7 +323,7 @@ public class ZipSkillPackageCodec implements AutoCloseable { ArchiveFile skillFile = group.files.stream() .filter(file -> SkillPaths.SKILL_FILE.equals(file.relativePath)) .findFirst() - .orElseThrow(() -> packageError("SKILL_FILE_REQUIRED", group.root, + .orElseThrow(() -> packageError("SKILL_FILE_REQUIRED", group.archiveRoot, "Skill directory must contain exactly one SKILL.md.")); String skillContent = readStrictText(zipFile, skillFile, limits.getMaxTextFileBytes()); @@ -331,7 +332,7 @@ public class ZipSkillPackageCodec implements AutoCloseable { document = SkillFrontmatter.parseDocument(skillContent, limits); } catch (SkillValidationException 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")); @@ -346,7 +347,7 @@ public class ZipSkillPackageCodec implements AutoCloseable { resources.sort(Comparator.comparing(SkillResource::getPath)); 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.setResources(resources); return skill; @@ -412,19 +413,44 @@ public class ZipSkillPackageCodec implements AutoCloseable { .map(file -> file.withRelativePath(file.fullPath)) .toList(); return new ArchiveLayout(SkillPackageLayout.ROOT_SKILL, - List.of(new ArchiveGroup(null, relativeFiles))); + List.of(new ArchiveGroup(null, null, relativeFiles))); + } + + List 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> grouped = new LinkedHashMap<>(); + for (String root : skillRoots) { + grouped.put(root, new ArrayList<>()); + } for (ArchiveFile file : files) { - int separator = file.fullPath.indexOf('/'); - if (separator < 1 || separator == file.fullPath.length() - 1) { + String owner = skillRoots.stream() + .filter(root -> file.fullPath.startsWith(root + "/")) + .findFirst() + .orElse(null); + if (owner == null) { throw packageError("UNOWNED_ROOT_FILE", file.fullPath, - "Wrapped Skill ZIP root can contain only Skill directories."); + "Wrapped Skill ZIP can contain files only inside Skill directories."); } - String root = file.fullPath.substring(0, separator); - String relativePath = file.fullPath.substring(separator + 1); - grouped.computeIfAbsent(root, ignored -> new ArrayList<>()) + String relativePath = file.fullPath.substring(owner.length() + 1); + grouped.get(owner) .add(file.withRelativePath(relativePath)); } List groups = new ArrayList<>(); @@ -434,16 +460,38 @@ public class ZipSkillPackageCodec implements AutoCloseable { .count(); if (skillFileCount != 1) { 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.SINGLE_DIRECTORY : SkillPackageLayout.MULTI_DIRECTORY; 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 不可绕过的标准安全校验。 * @@ -1136,10 +1184,13 @@ public class ZipSkillPackageCodec implements AutoCloseable { return total; } - private static String normalizeArchivePath(String rawPath, SkillPackageLimits limits) { + private static String normalizeArchivePath(String rawPath, SkillPackageLimits limits, + boolean ignoredSystemPath) { String normalized; try { - normalized = SkillPaths.normalize(rawPath); + normalized = ignoredSystemPath + ? SkillPaths.normalizeIgnoredSystemPath(rawPath) + : SkillPaths.normalize(rawPath); } catch (SkillValidationException e) { throw packageError("UNSAFE_ENTRY_PATH", rawPath, e.getMessage()); } @@ -1196,7 +1247,7 @@ public class ZipSkillPackageCodec implements AutoCloseable { throw packageError("PATH_LENGTH_LIMIT", path, "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, "Skill path exceeds depth " + limits.getMaxPathDepth() + "."); } @@ -1391,7 +1442,7 @@ public class ZipSkillPackageCodec implements AutoCloseable { } } - private record ArchiveGroup(String root, List files) { + private record ArchiveGroup(String archiveRoot, String packageRoot, List files) { } private record ArchiveLayout(SkillPackageLayout layout, List groups) { diff --git a/easy-agents-skill/src/main/java/com/easyagents/skill/model/Skill.java b/easy-agents-skill/src/main/java/com/easyagents/skill/model/Skill.java index 94145a3..a7696c1 100644 --- a/easy-agents-skill/src/main/java/com/easyagents/skill/model/Skill.java +++ b/easy-agents-skill/src/main/java/com/easyagents/skill/model/Skill.java @@ -17,7 +17,7 @@ public class Skill implements Serializable { private List resources = new ArrayList<>(); /** - * 获取导入包中的顶层目录名;该值不是仓储 ID。 + * 获取 Skill 自身的逻辑目录名;导入包允许在其外再包装一层父目录。 * * @return 包目录名 */ @@ -26,7 +26,7 @@ public class Skill implements Serializable { } /** - * 设置导入包中的顶层目录名。 + * 设置 Skill 自身的逻辑目录名。 * * @param packageRoot 包目录名 */ diff --git a/easy-agents-skill/src/main/java/com/easyagents/skill/model/SkillPackageLayout.java b/easy-agents-skill/src/main/java/com/easyagents/skill/model/SkillPackageLayout.java index 07f58a1..0e8090f 100644 --- a/easy-agents-skill/src/main/java/com/easyagents/skill/model/SkillPackageLayout.java +++ b/easy-agents-skill/src/main/java/com/easyagents/skill/model/SkillPackageLayout.java @@ -8,9 +8,9 @@ public enum SkillPackageLayout { /** 根目录直接包含 SKILL.md 的单 Skill 包。 */ ROOT_SKILL, - /** 一个顶层目录包装的单 Skill 包。 */ + /** 一个 Skill 目录组成的单 Skill 包,可再由单层父目录包装。 */ SINGLE_DIRECTORY, - /** 多个顶层 Skill 目录组成的批量包。 */ + /** 多个 Skill 目录组成的批量包,可共享一个单层父目录。 */ MULTI_DIRECTORY } diff --git a/easy-agents-skill/src/main/java/com/easyagents/skill/util/SkillPaths.java b/easy-agents-skill/src/main/java/com/easyagents/skill/util/SkillPaths.java index 6fe92e4..74cd1f7 100644 --- a/easy-agents-skill/src/main/java/com/easyagents/skill/util/SkillPaths.java +++ b/easy-agents-skill/src/main/java/com/easyagents/skill/util/SkillPaths.java @@ -45,6 +45,32 @@ public final class SkillPaths { * @return 规范化后的路径 */ 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) { throw new SkillValidationException("Skill path is required."); } @@ -63,7 +89,7 @@ public final class SkillPaths { if (segment.isEmpty() || ".".equals(segment) || "..".equals(segment)) { 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); } if (!segment.equals(segment.strip())) { diff --git a/easy-agents-skill/src/test/java/com/easyagents/skill/codec/ZipSkillPackageCodecTest.java b/easy-agents-skill/src/test/java/com/easyagents/skill/codec/ZipSkillPackageCodecTest.java index ee4beaa..48d6f25 100644 --- a/easy-agents-skill/src/test/java/com/easyagents/skill/codec/ZipSkillPackageCodecTest.java +++ b/easy-agents-skill/src/test/java/com/easyagents/skill/codec/ZipSkillPackageCodecTest.java @@ -110,6 +110,33 @@ public class ZipSkillPackageCodecTest { 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()); } + /** + * 多个标准 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 中保留。 */ @@ -268,7 +339,10 @@ public class ZipSkillPackageCodecTest { */ @Test 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; } - private static byte[] symlinkZip() { + private static byte[] symlinkZip(String path) { try { ByteArrayOutputStream bytes = new ByteArrayOutputStream(); try (ZipArchiveOutputStream output = new ZipArchiveOutputStream(bytes)) { @@ -1131,7 +1205,7 @@ public class ZipSkillPackageCodecTest { output.write(utf8(skillMd("skill-a"))); output.closeArchiveEntry(); - ZipArchiveEntry symlink = new ZipArchiveEntry("skill-a/assets/link"); + ZipArchiveEntry symlink = new ZipArchiveEntry(path); symlink.setUnixMode(UnixStat.LINK_FLAG | 0777); output.putArchiveEntry(symlink); output.write(utf8("target")); diff --git a/easy-agents-skill/src/test/java/com/easyagents/skill/util/SkillPathsTest.java b/easy-agents-skill/src/test/java/com/easyagents/skill/util/SkillPathsTest.java index f2039d7..b85472b 100644 --- a/easy-agents-skill/src/test/java/com/easyagents/skill/util/SkillPathsTest.java +++ b/easy-agents-skill/src/test/java/com/easyagents/skill/util/SkillPathsTest.java @@ -32,4 +32,23 @@ public class SkillPathsTest { public void normalizeUnicodeToNfc() { 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"); + } }