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) {
|
||||
|
||||
@@ -17,7 +17,7 @@ public class Skill implements Serializable {
|
||||
private List<SkillResource> resources = new ArrayList<>();
|
||||
|
||||
/**
|
||||
* 获取导入包中的顶层目录名;该值不是仓储 ID。
|
||||
* 获取 Skill 自身的逻辑目录名;导入包允许在其外再包装一层父目录。
|
||||
*
|
||||
* @return 包目录名
|
||||
*/
|
||||
@@ -26,7 +26,7 @@ public class Skill implements Serializable {
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置导入包中的顶层目录名。
|
||||
* 设置 Skill 自身的逻辑目录名。
|
||||
*
|
||||
* @param packageRoot 包目录名
|
||||
*/
|
||||
|
||||
@@ -8,9 +8,9 @@ public enum SkillPackageLayout {
|
||||
/** 根目录直接包含 SKILL.md 的单 Skill 包。 */
|
||||
ROOT_SKILL,
|
||||
|
||||
/** 一个顶层目录包装的单 Skill 包。 */
|
||||
/** 一个 Skill 目录组成的单 Skill 包,可再由单层父目录包装。 */
|
||||
SINGLE_DIRECTORY,
|
||||
|
||||
/** 多个顶层 Skill 目录组成的批量包。 */
|
||||
/** 多个 Skill 目录组成的批量包,可共享一个单层父目录。 */
|
||||
MULTI_DIRECTORY
|
||||
}
|
||||
|
||||
@@ -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())) {
|
||||
|
||||
Reference in New Issue
Block a user