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