feat: 扩展标准 Skill 包兼容性
- 支持单层父目录包装和多 Skill ZIP 解码 - 安全忽略 macOS 元数据并保持路径校验
This commit is contained in:
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user