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,
"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<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<>();
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<ArchiveGroup> 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<ArchiveFile> files) {
private record ArchiveGroup(String archiveRoot, String packageRoot, List<ArchiveFile> files) {
}
private record ArchiveLayout(SkillPackageLayout layout, List<ArchiveGroup> groups) {