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())) {
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user