Files
Easy-Agents/easy-agents-skill/src/main/java/com/easyagents/skill/codec/ZipSkillPackageCodec.java
陈子默 c8be163124 feat: 扩展标准 Skill 包兼容性
- 支持单层父目录包装和多 Skill ZIP 解码

- 安全忽略 macOS 元数据并保持路径校验
2026-08-19 22:38:01 +08:00

1651 lines
71 KiB
Java
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package com.easyagents.skill.codec;
import com.easyagents.skill.exception.SkillPackageException;
import com.easyagents.skill.exception.SkillValidationException;
import com.easyagents.skill.model.Skill;
import com.easyagents.skill.model.SkillDocument;
import com.easyagents.skill.model.SkillPackage;
import com.easyagents.skill.model.SkillPackageLayout;
import com.easyagents.skill.model.SkillPackageLimits;
import com.easyagents.skill.model.SkillResource;
import com.easyagents.skill.model.SkillResourceKind;
import com.easyagents.skill.store.SkillContentStage;
import com.easyagents.skill.store.SkillContentStore;
import com.easyagents.skill.store.file.TemporaryFileSkillContentStore;
import com.easyagents.skill.util.SkillFrontmatter;
import com.easyagents.skill.util.SkillHashes;
import com.easyagents.skill.util.SkillPaths;
import com.easyagents.skill.util.SkillResources;
import com.easyagents.skill.util.SkillUtf8;
import com.easyagents.skill.validation.SkillValidationIssue;
import com.easyagents.skill.validation.SkillValidationMode;
import com.easyagents.skill.validation.SkillValidationReport;
import com.easyagents.skill.validation.SkillValidationSeverity;
import com.easyagents.skill.validation.defaults.DefaultSkillValidator;
import org.apache.commons.compress.archivers.zip.ZipArchiveEntry;
import org.apache.commons.compress.archivers.zip.ZipFile;
import java.io.ByteArrayOutputStream;
import java.io.EOFException;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.URLConnection;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.nio.charset.CharacterCodingException;
import java.nio.charset.CodingErrorAction;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;
import java.nio.channels.SeekableByteChannel;
import java.security.DigestOutputStream;
import java.security.MessageDigest;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.Enumeration;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.zip.ZipEntry;
import java.util.zip.CRC32;
import java.util.zip.CheckedInputStream;
import java.util.zip.ZipOutputStream;
/**
* 标准 Skill ZIP 的安全双向流式 Codec。
*/
public class ZipSkillPackageCodec implements AutoCloseable {
private static final String DEFAULT_MEDIA_TYPE = "application/octet-stream";
private static final int CENTRAL_DIRECTORY_HEADER_SIZE = 46;
private static final int CENTRAL_DIRECTORY_SIGNATURE = 0x02014B50;
private static final int END_OF_CENTRAL_DIRECTORY_MAX_SIZE = 65_557;
private static final int END_OF_CENTRAL_DIRECTORY_MIN_SIZE = 22;
private static final int END_OF_CENTRAL_DIRECTORY_SIGNATURE = 0x06054B50;
private static final long STABLE_ZIP_TIMESTAMP = 0L;
private static final int ZIP64_END_OF_CENTRAL_DIRECTORY_MIN_SIZE = 56;
private static final int ZIP64_END_OF_CENTRAL_DIRECTORY_SIGNATURE = 0x06064B50;
private static final int ZIP64_END_OF_CENTRAL_DIRECTORY_LOCATOR_SIZE = 20;
private static final int ZIP64_END_OF_CENTRAL_DIRECTORY_LOCATOR_SIGNATURE = 0x07064B50;
private static final long UINT32_MAX = 0xFFFF_FFFFL;
private static final int UINT16_MAX = 0xFFFF;
private final SkillContentStore contentStore;
private final boolean ownsContentStore;
/**
* 创建使用临时文件内容存储的 ZIP Codec。
*
* <p>该实例拥有默认内容存储,使用完毕后应调用 {@link #close()} 清理临时内容。</p>
*/
public ZipSkillPackageCodec() {
this(new TemporaryFileSkillContentStore(), true);
}
/**
* 创建 ZIP Codec。
*
* @param contentStore 二进制内容存储
*/
public ZipSkillPackageCodec(SkillContentStore contentStore) {
this(contentStore, false);
}
private ZipSkillPackageCodec(SkillContentStore contentStore, boolean ownsContentStore) {
if (contentStore == null) {
throw new SkillPackageException("Skill content store is required.");
}
this.contentStore = contentStore;
this.ownsContentStore = ownsContentStore;
}
/**
* 关闭 Codec 自有的默认临时内容存储。
*
* <p>通过构造函数注入的内容存储由调用方管理,本方法不会关闭它。</p>
*/
@Override
public void close() {
if (ownsContentStore) {
((TemporaryFileSkillContentStore) contentStore).close();
}
}
/**
* 安全解码 Skill ZIP。
*
* @param inputStream ZIP 输入流
* @param options 读取选项
* @return 解码结果
*/
public SkillPackageReadResult decode(InputStream inputStream, SkillPackageReadOptions options) {
if (inputStream == null) {
throw packageError("ZIP_INPUT_REQUIRED", null, "ZIP input stream is required.");
}
SkillPackageReadOptions effectiveOptions = options == null
? SkillPackageReadOptions.defaults() : options;
SkillPackageLimits limits = effectiveOptions.getLimits();
Path temporaryZip = null;
StageTracker stages = new StageTracker(contentStore);
try {
temporaryZip = Files.createTempFile("easy-agents-skill-", ".zip");
InputCopyResult copyResult = copyInput(inputStream, temporaryZip,
limits.getMaxCompressedPackageBytes());
validateZipSignature(temporaryZip);
DecodeState state = readArchive(temporaryZip, limits, stages);
SkillValidationReport report = new SkillValidationReport();
for (Skill skill : state.skills) {
String packageRoot = state.layout == SkillPackageLayout.ROOT_SKILL
? null : skill.getPackageRoot();
report.merge(prefixValidationPaths(
validateForCodec(skill, limits, SkillValidationMode.DRAFT_IMPORT),
packageRoot));
}
if (effectiveOptions.getMode() == SkillPackageReadMode.REPORT_ONLY) {
stages.rollbackAll();
return new SkillPackageReadResult(
new SkillPackage(state.layout, state.skills), report, copyResult.hash);
}
if (report.hasErrors()) {
throw new SkillPackageException("Skill package validation failed.", report);
}
stages.commitAll();
return new SkillPackageReadResult(
new SkillPackage(state.layout, state.skills), report, copyResult.hash);
} catch (SkillPackageException e) {
stages.cleanupAfterFailure(e);
throw e;
} catch (SkillValidationException e) {
stages.cleanupAfterFailure(e);
throw validationPackageError(e);
} catch (IOException e) {
stages.cleanupAfterFailure(e);
throw new SkillPackageException("ZIP_IO_ERROR", null,
"Failed to read Skill ZIP package.", e);
} catch (RuntimeException e) {
stages.cleanupAfterFailure(e);
throw new SkillPackageException("SKILL_CONTENT_STORE_ERROR", null,
"Failed to persist Skill package content.", e);
} finally {
deleteQuietly(temporaryZip);
}
}
/**
* 将 Skill 包稳定编码为标准 ZIP。
*
* @param skillPackage Skill 包
* @param outputStream 输出流,不由本方法关闭
* @param options 写出选项
* @return 编码结果
*/
public SkillPackageWriteResult encode(SkillPackage skillPackage, OutputStream outputStream,
SkillPackageWriteOptions options) {
if (skillPackage == null || skillPackage.getSkills() == null || skillPackage.getSkills().isEmpty()) {
throw packageError("EMPTY_SKILL_PACKAGE", null, "Skill package must contain at least one Skill.");
}
if (outputStream == null) {
throw packageError("ZIP_OUTPUT_REQUIRED", null, "ZIP output stream is required.");
}
SkillPackageWriteOptions effectiveOptions = options == null
? SkillPackageWriteOptions.defaults() : options;
SkillPackageLimits limits = effectiveOptions.getLimits();
List<OutputFile> files = prepareOutput(skillPackage.getSkills(), limits);
Path temporaryZip = null;
try {
temporaryZip = Files.createTempFile("easy-agents-skill-export-", ".zip");
OutputWriteResult writeResult = writeArchive(temporaryZip, files, limits);
try (InputStream input = Files.newInputStream(temporaryZip)) {
transfer(input, outputStream, limits.getMaxCompressedPackageBytes(), null);
}
outputStream.flush();
SkillPackageLayout outputLayout = skillPackage.getSkills().size() > 1
? SkillPackageLayout.MULTI_DIRECTORY : SkillPackageLayout.SINGLE_DIRECTORY;
return new SkillPackageWriteResult(
writeResult.hash, writeResult.size, files.size(), outputLayout);
} catch (SkillPackageException e) {
throw e;
} catch (IOException e) {
if (hasCause(e, CompressedSizeLimitException.class)) {
throw packageError("COMPRESSED_SIZE_LIMIT", null,
"Compressed Skill ZIP exceeds " + limits.getMaxCompressedPackageBytes() + " bytes.");
}
throw new SkillPackageException("ZIP_IO_ERROR", null,
"Failed to write Skill ZIP package.", e);
} finally {
deleteQuietly(temporaryZip);
}
}
private DecodeState readArchive(Path archivePath, SkillPackageLimits limits, StageTracker stages)
throws IOException {
enforceCentralDirectoryEntryLimit(archivePath, limits.getMaxEntryCount());
List<ArchiveFile> files = new ArrayList<>();
Set<String> exactPaths = new HashSet<>();
Map<String, String> collisionPaths = new HashMap<>();
Map<String, String> descendantPaths = new HashMap<>();
long declaredTotal = 0;
int entryCount = 0;
try (ZipFile zipFile = new ZipFile(archivePath)) {
Enumeration<ZipArchiveEntry> entries = zipFile.getEntriesInPhysicalOrder();
while (entries.hasMoreElements()) {
ZipArchiveEntry entry = entries.nextElement();
String rawPath = strictUtf8EntryName(entry);
entryCount++;
if (entryCount > limits.getMaxEntryCount()) {
throw packageError("ENTRY_COUNT_LIMIT", rawPath,
"Skill ZIP exceeds " + limits.getMaxEntryCount() + " entries.");
}
if (entry.isUnixSymlink()) {
throw packageError("SYMLINK_ENTRY", rawPath,
"Symbolic link entries are not allowed in Skill packages.");
}
if (!zipFile.canReadEntryData(entry)) {
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, ignoredSystemPath);
if (entry.isDirectory() || ignoredSystemPath) {
continue;
}
if (!exactPaths.add(path)) {
throw packageError("DUPLICATE_ENTRY", path, "Duplicate ZIP entry: " + path);
}
String collisionKey = SkillPaths.collisionKey(path);
String conflictingPath = collisionPaths.get(collisionKey);
if (conflictingPath != null) {
throw packageError("PATH_COLLISION", path,
"ZIP entry conflicts with " + conflictingPath + " after Unicode/case normalization.");
}
String hierarchyConflict = findHierarchyConflict(
collisionKey, collisionPaths, descendantPaths);
if (hierarchyConflict != null) {
throw packageError("PATH_HIERARCHY_CONFLICT", path,
"ZIP entry conflicts with file " + hierarchyConflict
+ " in the same path hierarchy.");
}
collisionPaths.put(collisionKey, path);
indexDescendantPath(collisionKey, path, descendantPaths);
long size = entry.getSize();
long compressedSize = entry.getCompressedSize();
if (size < 0 || compressedSize < 0) {
throw packageError("UNKNOWN_ENTRY_SIZE", path,
"ZIP entry sizes must be present in the central directory.");
}
if (entry.getCrc() < 0) {
throw packageError("UNKNOWN_ENTRY_CRC", path,
"ZIP entry CRC must be present in the central directory.");
}
checkCompressionRatio(path, size, compressedSize, limits);
try {
declaredTotal = Math.addExact(declaredTotal, size);
} catch (ArithmeticException e) {
throw packageError("TOTAL_SIZE_LIMIT", path, "Skill ZIP uncompressed size overflow.");
}
if (declaredTotal > limits.getMaxTotalUncompressedBytes()) {
throw packageError("TOTAL_SIZE_LIMIT", path,
"Skill ZIP exceeds " + limits.getMaxTotalUncompressedBytes()
+ " uncompressed bytes.");
}
files.add(new ArchiveFile(entry, path));
}
if (files.isEmpty()) {
throw packageError("EMPTY_SKILL_PACKAGE", null,
"ZIP package must contain at least one Skill.");
}
ArchiveLayout archiveLayout = determineLayout(files);
List<Skill> skills = new ArrayList<>();
for (ArchiveGroup group : archiveLayout.groups) {
skills.add(readSkill(zipFile, group, archiveLayout.layout, limits, stages));
}
return new DecodeState(archiveLayout.layout, skills);
} catch (SkillPackageException e) {
throw e;
} catch (IOException e) {
throw new SkillPackageException("INVALID_ZIP", null,
"Skill package ZIP structure or entry data is invalid.", e);
}
}
private Skill readSkill(ZipFile zipFile, ArchiveGroup group, SkillPackageLayout layout,
SkillPackageLimits limits, StageTracker stages) throws IOException {
ArchiveFile skillFile = group.files.stream()
.filter(file -> SkillPaths.SKILL_FILE.equals(file.relativePath))
.findFirst()
.orElseThrow(() -> packageError("SKILL_FILE_REQUIRED", group.archiveRoot,
"Skill directory must contain exactly one SKILL.md."));
String skillContent = readStrictText(zipFile, skillFile, limits.getMaxTextFileBytes());
SkillDocument document;
try {
document = SkillFrontmatter.parseDocument(skillContent, limits);
} catch (SkillValidationException e) {
throw validationPackageError(e,
layout == SkillPackageLayout.ROOT_SKILL ? null : group.packageRoot);
}
String name = scalar(document.getFrontmatter().get("name"));
List<SkillResource> resources = new ArrayList<>();
for (ArchiveFile file : group.files) {
if (SkillPaths.SKILL_FILE.equals(file.relativePath)) {
continue;
}
SkillResource resource = readResource(zipFile, file, limits, stages);
resources.add(resource);
}
resources.sort(Comparator.comparing(SkillResource::getPath));
Skill skill = new Skill();
skill.setPackageRoot(layout == SkillPackageLayout.ROOT_SKILL ? name : group.packageRoot);
skill.setDocument(document);
skill.setResources(resources);
return skill;
}
private SkillResource readResource(ZipFile zipFile, ArchiveFile file, SkillPackageLimits limits,
StageTracker stages) throws IOException {
SkillResourceKind kind = SkillResources.classify(file.relativePath);
String mediaType = detectMediaType(file.relativePath);
boolean text = SkillResources.isText(file.relativePath, mediaType);
long singleLimit = text ? limits.getMaxTextFileBytes() : limits.getMaxBinaryFileBytes();
if (file.entry.getSize() > singleLimit) {
throw packageError(text ? "TEXT_FILE_SIZE_LIMIT" : "BINARY_FILE_SIZE_LIMIT", file.fullPath,
"Skill resource exceeds " + singleLimit + " bytes.");
}
SkillResource resource = new SkillResource();
resource.setPath(file.relativePath);
resource.setKind(kind);
resource.setMediaType(mediaType);
if (text) {
String content = readStrictText(zipFile, file, singleLimit);
byte[] bytes = strictUtf8Bytes(content, file.fullPath);
resource.setTextContent(content);
resource.setContentHash(SkillHashes.sha256Hex(bytes));
resource.setSize(bytes.length);
} else {
CRC32 crc = new CRC32();
try (InputStream input = zipFile.getInputStream(file.entry);
CheckedInputStream checkedInput = new CheckedInputStream(input, crc)) {
SkillContentStage stage = contentStore.stage(checkedInput, singleLimit);
if (stage.getSize() != file.entry.getSize()) {
contentStore.rollback(stage);
throw packageError("ENTRY_SIZE_MISMATCH", file.fullPath,
"ZIP entry size changed while reading.");
}
try {
verifyCrc(file.entry, crc.getValue(), file.fullPath);
} catch (RuntimeException e) {
contentStore.rollback(stage);
throw e;
}
resource.setContentRef(stage.getContentRef());
resource.setContentHash(stage.getContentHash());
resource.setSize(stage.getSize());
stages.add(stage, resource, file.fullPath);
}
}
return resource;
}
private static ArchiveLayout determineLayout(List<ArchiveFile> files) {
boolean hasRootSkill = files.stream().anyMatch(file -> SkillPaths.SKILL_FILE.equals(file.fullPath));
if (hasRootSkill) {
boolean containsNestedSkill = files.stream().anyMatch(file ->
!SkillPaths.SKILL_FILE.equals(file.fullPath)
&& file.fullPath.endsWith("/" + SkillPaths.SKILL_FILE));
if (containsNestedSkill) {
throw packageError("MIXED_PACKAGE_LAYOUT", null,
"Root SKILL.md cannot be mixed with wrapped Skill directories.");
}
List<ArchiveFile> relativeFiles = files.stream()
.map(file -> file.withRelativePath(file.fullPath))
.toList();
return new ArchiveLayout(SkillPackageLayout.ROOT_SKILL,
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) {
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 can contain files only inside Skill directories.");
}
String relativePath = file.fullPath.substring(owner.length() + 1);
grouped.get(owner)
.add(file.withRelativePath(relativePath));
}
List<ArchiveGroup> groups = new ArrayList<>();
for (Map.Entry<String, List<ArchiveFile>> entry : grouped.entrySet()) {
long skillFileCount = entry.getValue().stream()
.filter(file -> SkillPaths.SKILL_FILE.equals(file.relativePath))
.count();
if (skillFileCount != 1) {
throw packageError("SKILL_FILE_REQUIRED", entry.getKey(),
"Each Skill directory must contain exactly one SKILL.md.");
}
groups.add(new ArchiveGroup(entry.getKey(), fileName(entry.getKey()), entry.getValue()));
}
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 不可绕过的标准安全校验。
*
* @param skill Skill 聚合
* @param limits 当前读写操作限额
* @param mode 标准校验模式
* @return 结构化报告
*/
private SkillValidationReport validateForCodec(Skill skill, SkillPackageLimits limits,
SkillValidationMode mode) {
return new DefaultSkillValidator(limits).validateReport(skill, limits, mode);
}
private List<OutputFile> prepareOutput(List<Skill> skills, SkillPackageLimits limits) {
SkillValidationReport report = new SkillValidationReport();
Set<String> roots = new HashSet<>();
List<OutputFile> files = new ArrayList<>();
long totalSize = 0;
for (Skill skill : skills) {
SkillValidationReport skillReport = validateForCodec(
skill, limits, SkillValidationMode.STANDARD);
String diagnosticRoot = null;
if (skills.size() > 1 && skill != null) {
diagnosticRoot = skill.getPackageRoot();
if (diagnosticRoot == null || diagnosticRoot.isBlank()) {
diagnosticRoot = skill.getName();
}
}
report.merge(prefixValidationPaths(skillReport, diagnosticRoot));
if (skill == null || skillReport.hasErrors()) {
continue;
}
if (skill.getName() == null
|| !skill.getName().matches("[a-z0-9]+(?:-[a-z0-9]+)*")) {
report.add(new SkillValidationIssue("NON_CANONICAL_EXPORT_NAME",
SkillValidationSeverity.ERROR, SkillPaths.SKILL_FILE, null, null,
"Standard export requires a canonical hyphenated Skill name.",
"Rename the Skill before export."));
continue;
}
String collisionRoot = skill.getName().toLowerCase(Locale.ROOT);
if (!roots.add(collisionRoot)) {
report.add(new SkillValidationIssue("DUPLICATE_SKILL_NAME", SkillValidationSeverity.ERROR,
SkillPaths.SKILL_FILE, null, null,
"Skill package contains duplicate names: " + skill.getName(), null));
continue;
}
String skillContent = skill.getSkillContent();
String skillPath = skill.getName() + "/" + SkillPaths.SKILL_FILE;
validateOutputPath(skillPath, limits);
long skillSize = utf8Size(skillContent, skillPath);
if (skillSize > limits.getMaxTextFileBytes()) {
report.add(new SkillValidationIssue("TEXT_FILE_SIZE_LIMIT", SkillValidationSeverity.ERROR,
skillPath, null, null,
"SKILL.md exceeds the text file size limit.", null));
}
files.add(OutputFile.text(skillPath, skillContent));
totalSize = checkedOutputTotal(totalSize, skillSize, limits, skillPath);
List<SkillResource> resources = new ArrayList<>(skill.getResources());
resources.sort(Comparator.comparing(SkillResource::getPath));
for (SkillResource resource : resources) {
String path = skill.getName() + "/" + SkillPaths.normalize(resource.getPath());
validateOutputPath(path, limits);
if (resource.isText()) {
String textContent = resource.getTextContent();
long textSize = utf8Size(textContent, path);
if (textSize > limits.getMaxTextFileBytes()) {
report.add(new SkillValidationIssue("TEXT_FILE_SIZE_LIMIT",
SkillValidationSeverity.ERROR, path, null, null,
"Text Skill resource exceeds the text file size limit.", null));
continue;
}
files.add(OutputFile.text(path, textContent));
totalSize = checkedOutputTotal(totalSize, textSize, limits, path);
} else {
if (resource.getSize() > limits.getMaxBinaryFileBytes()) {
report.add(new SkillValidationIssue("BINARY_FILE_SIZE_LIMIT",
SkillValidationSeverity.ERROR, path, null, null,
"Binary Skill resource exceeds the binary file size limit.", null));
continue;
}
if (!contentExists(resource.getContentRef(), path)) {
report.add(new SkillValidationIssue("CONTENT_NOT_FOUND", SkillValidationSeverity.ERROR,
path, null, null, "Skill resource content does not exist.", null));
continue;
}
files.add(OutputFile.binary(path, resource));
totalSize = checkedOutputTotal(totalSize, resource.getSize(), limits, path);
}
}
}
if (files.size() > limits.getMaxEntryCount()) {
report.add(new SkillValidationIssue("ENTRY_COUNT_LIMIT", SkillValidationSeverity.ERROR,
null, null, null, "Skill export exceeds the entry count limit.", null));
}
if (report.hasErrors()) {
throw new SkillPackageException("Skill package cannot be exported.", report);
}
files.sort(Comparator.comparing(file -> file.path));
Set<String> paths = new HashSet<>();
Map<String, String> collisionPaths = new HashMap<>();
Map<String, String> descendantPaths = new HashMap<>();
for (OutputFile file : files) {
String collisionKey = SkillPaths.collisionKey(file.path);
String collision = collisionPaths.get(collisionKey);
if (!paths.add(file.path) || collision != null) {
throw packageError("PATH_COLLISION", file.path,
"Skill export contains duplicate or case-conflicting paths.");
}
String hierarchyConflict = findHierarchyConflict(
collisionKey, collisionPaths, descendantPaths);
if (hierarchyConflict != null) {
throw packageError("PATH_HIERARCHY_CONFLICT", file.path,
"Skill export path conflicts with file " + hierarchyConflict
+ " in the same path hierarchy.");
}
collisionPaths.put(collisionKey, file.path);
indexDescendantPath(collisionKey, file.path, descendantPaths);
}
return files;
}
private OutputWriteResult writeArchive(Path archivePath, List<OutputFile> files,
SkillPackageLimits limits) throws IOException {
OutputWriteResult initialResult = writeArchiveAttempt(archivePath, files, limits, Map.of());
Map<String, StoredEntryMetadata> storedEntries = findEntriesExceedingCompressionRatio(
archivePath, limits);
if (storedEntries.isEmpty()) {
return initialResult;
}
OutputWriteResult rewrittenResult = writeArchiveAttempt(
archivePath, files, limits, storedEntries);
Map<String, StoredEntryMetadata> remainingViolations = findEntriesExceedingCompressionRatio(
archivePath, limits);
if (!remainingViolations.isEmpty()) {
String path = remainingViolations.keySet().iterator().next();
throw packageError("COMPRESSION_RATIO_LIMIT", path,
"Encoded Skill ZIP cannot satisfy the configured compression ratio limit.");
}
return rewrittenResult;
}
/**
* 按指定 entry 存储策略写出一次稳定 ZIP。
*
* @param archivePath 临时归档路径
* @param files 输出文件计划
* @param limits 当前写出限额
* @param storedEntries 需要使用 STORED 的 entry 元数据
* @return 本次写出结果
* @throws IOException 写出失败
*/
private OutputWriteResult writeArchiveAttempt(Path archivePath, List<OutputFile> files,
SkillPackageLimits limits,
Map<String, StoredEntryMetadata> storedEntries)
throws IOException {
MessageDigest digest = SkillHashes.newSha256Digest();
LimitedOutputStream limitedOutput = new LimitedOutputStream(
Files.newOutputStream(archivePath, StandardOpenOption.TRUNCATE_EXISTING),
limits.getMaxCompressedPackageBytes());
try (DigestOutputStream digestOutput = new DigestOutputStream(limitedOutput, digest);
ZipOutputStream zipOutput = new ZipOutputStream(digestOutput, StandardCharsets.UTF_8)) {
for (OutputFile file : files) {
ZipEntry entry = new ZipEntry(file.path);
entry.setTime(STABLE_ZIP_TIMESTAMP);
StoredEntryMetadata storedMetadata = storedEntries.get(file.path);
if (storedMetadata != null) {
entry.setMethod(ZipEntry.STORED);
entry.setSize(storedMetadata.size);
entry.setCompressedSize(storedMetadata.size);
entry.setCrc(storedMetadata.crc);
}
zipOutput.putNextEntry(entry);
if (file.text != null) {
writeTextResource(zipOutput, file.text, file.path);
} else {
writeBinaryResource(zipOutput, file.resource, limits, file.path);
}
zipOutput.closeEntry();
}
zipOutput.finish();
}
return new OutputWriteResult(SkillHashes.toHex(digest.digest()), limitedOutput.getCount());
}
/**
* 找出会在同一限额下被解码器判定为高压缩比的 entry。
*
* @param archivePath 已完成的临时归档
* @param limits 当前写出限额
* @return 需要改用 STORED 的 entry 元数据
* @throws IOException 归档检查失败
*/
private static Map<String, StoredEntryMetadata> findEntriesExceedingCompressionRatio(
Path archivePath, SkillPackageLimits limits) throws IOException {
Map<String, StoredEntryMetadata> entries = new LinkedHashMap<>();
try (ZipFile zipFile = new ZipFile(archivePath)) {
Enumeration<ZipArchiveEntry> archiveEntries = zipFile.getEntriesInPhysicalOrder();
while (archiveEntries.hasMoreElements()) {
ZipArchiveEntry entry = archiveEntries.nextElement();
long size = entry.getSize();
long compressedSize = entry.getCompressedSize();
if (size > 0 && (compressedSize == 0
|| ((double) size / compressedSize) > limits.getMaxCompressionRatio())) {
entries.put(entry.getName(), new StoredEntryMetadata(size, entry.getCrc()));
}
}
}
return entries;
}
/**
* 将单个文本严格编码到当前 ZIP entry不在输出计划中保留 UTF-8 byte 数组。
*
* @param output 当前 ZIP 输出流
* @param text 文本内容
* @param path 包内文件路径
* @throws IOException 写出失败
*/
private void writeTextResource(OutputStream output, String text, String path) throws IOException {
output.write(strictUtf8Bytes(text, path));
}
/**
* 计算单个文本文件的 UTF-8 字节数;结果只用于边界校验,不在输出计划中保留 byte 数组。
*
* @param text 文本内容
* @param path 包内文件路径
* @return UTF-8 字节数
*/
private long utf8Size(String text, String path) {
try {
return SkillUtf8.byteLength(text);
} catch (CharacterCodingException e) {
throw new SkillPackageException("INVALID_UTF8", path,
"Text Skill resource must be losslessly encodable as UTF-8.", e);
}
}
private void writeBinaryResource(OutputStream output, SkillResource resource,
SkillPackageLimits limits, String path) throws IOException {
long maxBytes = Math.min(limits.getMaxBinaryFileBytes(), resource.getSize());
MessageDigest digest = SkillHashes.newSha256Digest();
long count;
try (InputStream input = contentStore.open(resource.getContentRef())) {
count = transfer(input, output, maxBytes, digest);
} catch (SkillPackageException | IOException e) {
throw e;
} catch (RuntimeException e) {
throw new SkillPackageException("CONTENT_STORE_ERROR", path,
"Failed to open binary Skill resource content.", e);
}
if (count != resource.getSize()) {
throw packageError("RESOURCE_SIZE_MISMATCH", path,
"Binary resource size does not match stored content.");
}
String actualHash = SkillHashes.toHex(digest.digest());
if (!actualHash.equals(resource.getContentHash())) {
throw packageError("RESOURCE_HASH_MISMATCH", path,
"Binary resource hash does not match stored content.");
}
}
private boolean contentExists(String contentRef, String path) {
try {
return contentStore.exists(contentRef);
} catch (RuntimeException e) {
throw new SkillPackageException("CONTENT_STORE_ERROR", path,
"Failed to inspect binary Skill resource content.", e);
}
}
private static String readStrictText(ZipFile zipFile, ArchiveFile file, long limit) throws IOException {
if (file.entry.getSize() > limit) {
throw packageError("TEXT_FILE_SIZE_LIMIT", file.fullPath,
"Text resource exceeds " + limit + " bytes.");
}
byte[] bytes;
try (InputStream input = zipFile.getInputStream(file.entry)) {
bytes = readBytes(input, limit, file.fullPath);
}
if (bytes.length != file.entry.getSize()) {
throw packageError("ENTRY_SIZE_MISMATCH", file.fullPath,
"ZIP entry size changed while reading.");
}
CRC32 crc = new CRC32();
crc.update(bytes);
verifyCrc(file.entry, crc.getValue(), file.fullPath);
try {
return StandardCharsets.UTF_8.newDecoder()
.onMalformedInput(CodingErrorAction.REPORT)
.onUnmappableCharacter(CodingErrorAction.REPORT)
.decode(ByteBuffer.wrap(bytes))
.toString();
} catch (CharacterCodingException e) {
throw new SkillPackageException("INVALID_UTF8", file.fullPath,
"Text Skill resource must be valid UTF-8.", e);
}
}
private static byte[] readBytes(InputStream input, long limit, String path) throws IOException {
ByteArrayOutputStream output = new ByteArrayOutputStream((int) Math.min(limit, 8_192));
transfer(input, output, limit, null, path);
return output.toByteArray();
}
/**
* 将已识别为文本的内容严格编码为 UTF-8 字节。
*
* @param text 文本内容
* @param path 包内文件路径
* @return UTF-8 字节
* @throws SkillPackageException 文本包含非法 UTF-16 code unit
*/
private static byte[] strictUtf8Bytes(String text, String path) {
try {
return SkillUtf8.encode(text);
} catch (CharacterCodingException e) {
throw new SkillPackageException("INVALID_UTF8", path,
"Text Skill resource must be losslessly encodable as UTF-8.", e);
}
}
private static InputCopyResult copyInput(InputStream input, Path target, long limit) throws IOException {
MessageDigest digest = SkillHashes.newSha256Digest();
long count = 0;
try (OutputStream fileOutput = Files.newOutputStream(target, StandardOpenOption.TRUNCATE_EXISTING);
DigestOutputStream digestOutput = new DigestOutputStream(fileOutput, digest)) {
byte[] buffer = new byte[16 * 1024];
int read;
while ((read = input.read(buffer)) >= 0) {
if (read == 0) {
continue;
}
if (count > limit - read) {
throw packageError("COMPRESSED_SIZE_LIMIT", null,
"Compressed Skill ZIP exceeds " + limit + " bytes.");
}
digestOutput.write(buffer, 0, read);
count += read;
}
}
if (count == 0) {
throw packageError("EMPTY_ZIP", null, "Skill ZIP input cannot be empty.");
}
return new InputCopyResult(SkillHashes.toHex(digest.digest()), count);
}
private static void validateZipSignature(Path archivePath) throws IOException {
byte[] signature = new byte[4];
try (InputStream input = Files.newInputStream(archivePath)) {
if (input.read(signature) != signature.length) {
throw packageError("INVALID_ZIP", null, "Skill package is not a valid ZIP archive.");
}
}
boolean localFile = signature[0] == 0x50 && signature[1] == 0x4B
&& signature[2] == 0x03 && signature[3] == 0x04;
boolean emptyArchive = signature[0] == 0x50 && signature[1] == 0x4B
&& signature[2] == 0x05 && signature[3] == 0x06;
if (!localFile && !emptyArchive) {
throw packageError("INVALID_ZIP", null,
"Skill package must start with a standard ZIP signature.");
}
}
/**
* 在 Commons Compress 构造所有 entry 对象前,以常量内存扫描中央目录并执行条目数限制。
*
* @param archivePath ZIP 临时文件
* @param maxEntryCount 最大条目数
* @throws SkillPackageException 中央目录非法或条目数超限
*/
private static void enforceCentralDirectoryEntryLimit(Path archivePath, int maxEntryCount) {
try (SeekableByteChannel channel = Files.newByteChannel(archivePath, StandardOpenOption.READ)) {
CentralDirectoryDescriptor descriptor = readCentralDirectoryDescriptor(channel);
if (descriptor.entryCount > maxEntryCount) {
throw packageError("ENTRY_COUNT_LIMIT", null,
"Skill ZIP exceeds " + maxEntryCount + " entries.");
}
long centralDirectoryEnd = Math.addExact(
descriptor.offset, descriptor.size);
if (descriptor.offset < 0 || descriptor.size < 0
|| centralDirectoryEnd != descriptor.endRecordOffset
|| centralDirectoryEnd > channel.size()) {
throw invalidZip("ZIP central directory bounds are invalid.");
}
long position = descriptor.offset;
long actualEntryCount = 0;
while (position < centralDirectoryEnd) {
if (centralDirectoryEnd - position < CENTRAL_DIRECTORY_HEADER_SIZE) {
throw invalidZip("ZIP central directory is truncated.");
}
ByteBuffer header = readAt(channel, position, CENTRAL_DIRECTORY_HEADER_SIZE);
if (header.getInt(0) != CENTRAL_DIRECTORY_SIGNATURE) {
throw invalidZip("ZIP central directory contains an invalid entry header.");
}
long variableSize = (long) unsignedShort(header, 28)
+ unsignedShort(header, 30)
+ unsignedShort(header, 32);
long nextPosition = Math.addExact(position,
Math.addExact((long) CENTRAL_DIRECTORY_HEADER_SIZE, variableSize));
if (nextPosition > centralDirectoryEnd) {
throw invalidZip("ZIP central directory entry exceeds its declared bounds.");
}
actualEntryCount++;
if (actualEntryCount > maxEntryCount) {
throw packageError("ENTRY_COUNT_LIMIT", null,
"Skill ZIP exceeds " + maxEntryCount + " entries.");
}
position = nextPosition;
}
if (actualEntryCount != descriptor.entryCount) {
throw invalidZip("ZIP central directory entry count is inconsistent.");
}
} catch (SkillPackageException exception) {
throw exception;
} catch (IOException | ArithmeticException exception) {
throw new SkillPackageException("INVALID_ZIP", null,
"Skill package ZIP central directory is invalid.", exception);
}
}
/**
* 从 EOCD 或 ZIP64 EOCD 读取受信前所需的中央目录边界信息。
*
* @param channel ZIP 文件通道
* @return 中央目录描述
* @throws IOException 文件读取失败
*/
private static CentralDirectoryDescriptor readCentralDirectoryDescriptor(
SeekableByteChannel channel) throws IOException {
long archiveSize = channel.size();
if (archiveSize < END_OF_CENTRAL_DIRECTORY_MIN_SIZE) {
throw invalidZip("ZIP end-of-central-directory record is missing.");
}
int tailSize = (int) Math.min(archiveSize, END_OF_CENTRAL_DIRECTORY_MAX_SIZE);
long tailOffset = archiveSize - tailSize;
ByteBuffer tail = readAt(channel, tailOffset, tailSize);
int eocdIndex = findEndOfCentralDirectory(tail);
if (eocdIndex < 0) {
throw invalidZip("ZIP end-of-central-directory record is missing or malformed.");
}
long eocdOffset = tailOffset + eocdIndex;
int diskNumber = unsignedShort(tail, eocdIndex + 4);
int centralDirectoryDisk = unsignedShort(tail, eocdIndex + 6);
int entriesOnDisk = unsignedShort(tail, eocdIndex + 8);
int totalEntries = unsignedShort(tail, eocdIndex + 10);
long centralDirectorySize = unsignedInt(tail, eocdIndex + 12);
long centralDirectoryOffset = unsignedInt(tail, eocdIndex + 16);
boolean zip64 = diskNumber == UINT16_MAX
|| centralDirectoryDisk == UINT16_MAX
|| entriesOnDisk == UINT16_MAX
|| totalEntries == UINT16_MAX
|| centralDirectorySize == UINT32_MAX
|| centralDirectoryOffset == UINT32_MAX;
if (!zip64) {
if (diskNumber != 0 || centralDirectoryDisk != 0 || entriesOnDisk != totalEntries) {
throw packageError("MULTI_DISK_ZIP", null,
"Multi-disk ZIP packages are not supported.");
}
return new CentralDirectoryDescriptor(
centralDirectoryOffset, centralDirectorySize, totalEntries, eocdOffset);
}
return readZip64CentralDirectoryDescriptor(channel, eocdOffset,
diskNumber, centralDirectoryDisk, entriesOnDisk, totalEntries,
centralDirectorySize, centralDirectoryOffset);
}
/**
* 读取并交叉校验 ZIP64 EOCD 与定位器。
*
* @param channel ZIP 文件通道
* @param eocdOffset 传统 EOCD 偏移
* @param diskNumber 传统 EOCD 磁盘号
* @param centralDirectoryDisk 传统 EOCD 中央目录磁盘号
* @param entriesOnDisk 传统 EOCD 当前磁盘条目数
* @param totalEntries 传统 EOCD 总条目数
* @param centralDirectorySize 传统 EOCD 中央目录大小
* @param centralDirectoryOffset 传统 EOCD 中央目录偏移
* @return ZIP64 中央目录描述
* @throws IOException 文件读取失败
*/
private static CentralDirectoryDescriptor readZip64CentralDirectoryDescriptor(
SeekableByteChannel channel,
long eocdOffset,
int diskNumber,
int centralDirectoryDisk,
int entriesOnDisk,
int totalEntries,
long centralDirectorySize,
long centralDirectoryOffset) throws IOException {
long locatorOffset = eocdOffset - ZIP64_END_OF_CENTRAL_DIRECTORY_LOCATOR_SIZE;
if (locatorOffset < 0) {
throw invalidZip("ZIP64 end-of-central-directory locator is missing.");
}
ByteBuffer locator = readAt(
channel, locatorOffset, ZIP64_END_OF_CENTRAL_DIRECTORY_LOCATOR_SIZE);
if (locator.getInt(0) != ZIP64_END_OF_CENTRAL_DIRECTORY_LOCATOR_SIGNATURE) {
throw invalidZip("ZIP64 end-of-central-directory locator is missing.");
}
long locatorDisk = unsignedInt(locator, 4);
long zip64Offset = unsignedLong(locator, 8);
long totalDisks = unsignedInt(locator, 16);
if (locatorDisk != 0 || totalDisks != 1) {
throw packageError("MULTI_DISK_ZIP", null,
"Multi-disk ZIP packages are not supported.");
}
ByteBuffer zip64 = readAt(
channel, zip64Offset, ZIP64_END_OF_CENTRAL_DIRECTORY_MIN_SIZE);
if (zip64.getInt(0) != ZIP64_END_OF_CENTRAL_DIRECTORY_SIGNATURE) {
throw invalidZip("ZIP64 end-of-central-directory record is missing.");
}
long zip64RecordSize = unsignedLong(zip64, 4);
if (zip64RecordSize < 44) {
throw invalidZip("ZIP64 end-of-central-directory record is too short.");
}
long zip64RecordEnd = Math.addExact(zip64Offset, Math.addExact(12L, zip64RecordSize));
if (zip64RecordEnd != locatorOffset) {
throw invalidZip("ZIP64 end-of-central-directory bounds are invalid.");
}
long zip64DiskNumber = unsignedInt(zip64, 16);
long zip64CentralDirectoryDisk = unsignedInt(zip64, 20);
long zip64EntriesOnDisk = unsignedLong(zip64, 24);
long zip64TotalEntries = unsignedLong(zip64, 32);
long zip64CentralDirectorySize = unsignedLong(zip64, 40);
long zip64CentralDirectoryOffset = unsignedLong(zip64, 48);
if (zip64DiskNumber != 0 || zip64CentralDirectoryDisk != 0
|| zip64EntriesOnDisk != zip64TotalEntries) {
throw packageError("MULTI_DISK_ZIP", null,
"Multi-disk ZIP packages are not supported.");
}
validateLegacyZip64Value(diskNumber, UINT16_MAX, zip64DiskNumber,
"ZIP disk number is inconsistent with ZIP64 metadata.");
validateLegacyZip64Value(centralDirectoryDisk, UINT16_MAX,
zip64CentralDirectoryDisk,
"ZIP central-directory disk is inconsistent with ZIP64 metadata.");
validateLegacyZip64Value(entriesOnDisk, UINT16_MAX, zip64EntriesOnDisk,
"ZIP entry count is inconsistent with ZIP64 metadata.");
validateLegacyZip64Value(totalEntries, UINT16_MAX, zip64TotalEntries,
"ZIP entry count is inconsistent with ZIP64 metadata.");
validateLegacyZip64Value(centralDirectorySize, UINT32_MAX,
zip64CentralDirectorySize,
"ZIP central-directory size is inconsistent with ZIP64 metadata.");
validateLegacyZip64Value(centralDirectoryOffset, UINT32_MAX,
zip64CentralDirectoryOffset,
"ZIP central-directory offset is inconsistent with ZIP64 metadata.");
return new CentralDirectoryDescriptor(zip64CentralDirectoryOffset,
zip64CentralDirectorySize, zip64TotalEntries, zip64Offset);
}
/**
* 校验传统 EOCD 中未使用哨兵值的字段与 ZIP64 元数据一致。
*
* @param legacyValue 传统字段值
* @param sentinel ZIP64 哨兵值
* @param zip64Value ZIP64 字段值
* @param message 不一致时的错误消息
*/
private static void validateLegacyZip64Value(long legacyValue, long sentinel,
long zip64Value, String message) {
if (legacyValue != sentinel && legacyValue != zip64Value) {
throw invalidZip(message);
}
}
/**
* 在文件尾缓冲区中定位与注释长度一致的 EOCD。
*
* @param tail ZIP 文件尾缓冲区
* @return EOCD 相对偏移,未找到时返回 -1
*/
private static int findEndOfCentralDirectory(ByteBuffer tail) {
for (int index = tail.limit() - END_OF_CENTRAL_DIRECTORY_MIN_SIZE; index >= 0; index--) {
if (tail.getInt(index) == END_OF_CENTRAL_DIRECTORY_SIGNATURE) {
int commentLength = unsignedShort(tail, index + 20);
if (index + END_OF_CENTRAL_DIRECTORY_MIN_SIZE + commentLength == tail.limit()) {
return index;
}
}
}
return -1;
}
/**
* 从通道指定位置完整读取固定长度数据,并按小端序返回。
*
* @param channel 文件通道
* @param position 起始偏移
* @param length 读取长度
* @return 已翻转的小端缓冲区
* @throws IOException 读取失败或文件截断
*/
private static ByteBuffer readAt(SeekableByteChannel channel, long position, int length)
throws IOException {
if (position < 0 || length < 0 || position > channel.size() - length) {
throw new EOFException("ZIP record exceeds archive bounds.");
}
ByteBuffer buffer = ByteBuffer.allocate(length).order(ByteOrder.LITTLE_ENDIAN);
channel.position(position);
while (buffer.hasRemaining()) {
int read = channel.read(buffer);
if (read < 0) {
throw new EOFException("ZIP record is truncated.");
}
}
buffer.flip();
return buffer;
}
/**
* 读取无符号 16 位小端整数。
*
* @param buffer 小端缓冲区
* @param offset 字段偏移
* @return 无符号值
*/
private static int unsignedShort(ByteBuffer buffer, int offset) {
return Short.toUnsignedInt(buffer.getShort(offset));
}
/**
* 读取无符号 32 位小端整数。
*
* @param buffer 小端缓冲区
* @param offset 字段偏移
* @return 无符号值
*/
private static long unsignedInt(ByteBuffer buffer, int offset) {
return Integer.toUnsignedLong(buffer.getInt(offset));
}
/**
* 读取当前实现可安全表示的无符号 64 位小端整数。
*
* @param buffer 小端缓冲区
* @param offset 字段偏移
* @return 非负 long 值
*/
private static long unsignedLong(ByteBuffer buffer, int offset) {
long value = buffer.getLong(offset);
if (value < 0) {
throw invalidZip("ZIP64 value exceeds the supported range.");
}
return value;
}
/**
* 创建稳定的非法 ZIP 异常。
*
* @param message 错误消息
* @return 非法 ZIP 异常
*/
private static SkillPackageException invalidZip(String message) {
return packageError("INVALID_ZIP", null, message);
}
private static long transfer(InputStream input, OutputStream output, long limit, MessageDigest digest)
throws IOException {
return transfer(input, output, limit, digest, null);
}
private static long transfer(InputStream input, OutputStream output, long limit,
MessageDigest digest, String path) throws IOException {
byte[] buffer = new byte[16 * 1024];
long total = 0;
int read;
while ((read = input.read(buffer)) >= 0) {
if (read == 0) {
continue;
}
if (total > limit - read) {
throw packageError("SIZE_LIMIT", path, "Skill content exceeds " + limit + " bytes.");
}
output.write(buffer, 0, read);
if (digest != null) {
digest.update(buffer, 0, read);
}
total += read;
}
return total;
}
private static String normalizeArchivePath(String rawPath, SkillPackageLimits limits,
boolean ignoredSystemPath) {
String normalized;
try {
normalized = ignoredSystemPath
? SkillPaths.normalizeIgnoredSystemPath(rawPath)
: SkillPaths.normalize(rawPath);
} catch (SkillValidationException e) {
throw packageError("UNSAFE_ENTRY_PATH", rawPath, e.getMessage());
}
validateOutputPath(normalized, limits);
return normalized;
}
/**
* 严格校验并返回 ZIP entry 的 UTF-8 文件名。
*
* @param entry ZIP entry
* @return 唯一的 UTF-8 文件名
* @throws SkillPackageException 原始文件名字节非法或存在歧义
*/
private static String strictUtf8EntryName(ZipArchiveEntry entry) {
byte[] rawName = entry.getRawName();
if (rawName == null) {
throw packageError("INVALID_UTF8_ENTRY_NAME", entry.getName(),
"ZIP entry name bytes are required.");
}
try {
String decodedName = StandardCharsets.UTF_8.newDecoder()
.onMalformedInput(CodingErrorAction.REPORT)
.onUnmappableCharacter(CodingErrorAction.REPORT)
.decode(ByteBuffer.wrap(rawName))
.toString();
if (!decodedName.equals(entry.getName())) {
throw packageError("INVALID_UTF8_ENTRY_NAME", entry.getName(),
"ZIP entry name must have one unambiguous UTF-8 representation.");
}
return decodedName;
} catch (CharacterCodingException e) {
throw new SkillPackageException("INVALID_UTF8_ENTRY_NAME", entry.getName(),
"ZIP entry name must be valid UTF-8.", e);
}
}
private static String stripDirectorySuffix(String path) {
int end = path == null ? 0 : path.length();
while (end > 0 && (path.charAt(end - 1) == '/' || path.charAt(end - 1) == '\\')) {
end--;
}
return path == null ? null : path.substring(0, end);
}
private static void validateOutputPath(String path, SkillPackageLimits limits) {
try {
SkillUtf8.encode(path);
} catch (CharacterCodingException e) {
throw new SkillPackageException("INVALID_UTF8_PATH", path,
"Skill output path must be losslessly encodable as UTF-8.", e);
}
if (path.length() > limits.getMaxPathLength()) {
throw packageError("PATH_LENGTH_LIMIT", path,
"Skill path exceeds " + limits.getMaxPathLength() + " characters.");
}
if (path.split("/", -1).length > limits.getMaxPathDepth()) {
throw packageError("PATH_DEPTH_LIMIT", path,
"Skill path exceeds depth " + limits.getMaxPathDepth() + ".");
}
}
/**
* 查找当前文件路径与已索引文件之间的祖先或后代冲突。
*
* @param pathKey 当前路径冲突键
* @param filePaths 已索引的文件路径
* @param descendantPaths 已索引路径对应的首个后代文件
* @return 冲突文件路径,不存在时返回 null
*/
private static String findHierarchyConflict(String pathKey, Map<String, String> filePaths,
Map<String, String> descendantPaths) {
String descendant = descendantPaths.get(pathKey);
if (descendant != null) {
return descendant;
}
int separator = pathKey.indexOf('/');
while (separator >= 0) {
String ancestor = filePaths.get(pathKey.substring(0, separator));
if (ancestor != null) {
return ancestor;
}
separator = pathKey.indexOf('/', separator + 1);
}
return null;
}
/**
* 为文件路径的每个祖先建立后代索引。
*
* @param pathKey 文件路径冲突键
* @param path 原始文件路径
* @param descendantPaths 后代索引
*/
private static void indexDescendantPath(String pathKey, String path,
Map<String, String> descendantPaths) {
int separator = pathKey.indexOf('/');
while (separator >= 0) {
descendantPaths.putIfAbsent(pathKey.substring(0, separator), path);
separator = pathKey.indexOf('/', separator + 1);
}
}
private static void checkCompressionRatio(String path, long size, long compressedSize,
SkillPackageLimits limits) {
if (size == 0) {
return;
}
if (compressedSize == 0 || ((double) size / compressedSize) > limits.getMaxCompressionRatio()) {
throw packageError("COMPRESSION_RATIO_LIMIT", path,
"ZIP entry compression ratio exceeds " + limits.getMaxCompressionRatio() + ":1.");
}
}
private static void verifyCrc(ZipArchiveEntry entry, long actualCrc, String path) {
if (entry.getCrc() != actualCrc) {
throw packageError("CRC_MISMATCH", path,
"ZIP entry CRC does not match decompressed content.");
}
}
private static long checkedOutputTotal(long current, long size, SkillPackageLimits limits, String path) {
if (size < 0) {
throw packageError("NEGATIVE_RESOURCE_SIZE", path, "Skill resource size cannot be negative.");
}
long total;
try {
total = Math.addExact(current, size);
} catch (ArithmeticException e) {
throw packageError("TOTAL_SIZE_LIMIT", path, "Skill package size overflow.");
}
if (total > limits.getMaxTotalUncompressedBytes()) {
throw packageError("TOTAL_SIZE_LIMIT", path,
"Skill package exceeds the total uncompressed size limit.");
}
return total;
}
private static String detectMediaType(String path) {
String lowerPath = path.toLowerCase(Locale.ROOT);
if (lowerPath.endsWith(".md") || lowerPath.endsWith(".markdown")) {
return "text/markdown";
}
if (lowerPath.endsWith(".yaml") || lowerPath.endsWith(".yml")) {
return "application/yaml";
}
if (lowerPath.endsWith(".json")) {
return "application/json";
}
if (lowerPath.endsWith(".py")) {
return "text/x-python";
}
if (lowerPath.endsWith(".js") || lowerPath.endsWith(".mjs") || lowerPath.endsWith(".cjs")) {
return "text/javascript";
}
if (lowerPath.endsWith(".sh") || lowerPath.endsWith(".bash") || lowerPath.endsWith(".zsh")) {
return "text/x-shellscript";
}
String detected = URLConnection.guessContentTypeFromName(path);
return detected == null ? DEFAULT_MEDIA_TYPE : detected;
}
private static String scalar(Object value) {
return value instanceof String text ? text : null;
}
private static SkillPackageException validationPackageError(SkillValidationException exception) {
return validationPackageError(exception, null);
}
/**
* 将文档校验异常转换为包异常,并按需补齐包装 Skill 根目录。
*
* @param exception 文档校验异常
* @param packageRoot 包装 Skill 根目录,根直压时为空
* @return 带完整结构化报告的包异常
*/
private static SkillPackageException validationPackageError(
SkillValidationException exception, String packageRoot) {
SkillValidationReport report = exception.getReport();
if (report == null) {
report = new SkillValidationReport().add(new SkillValidationIssue(
exception.getCode(), SkillValidationSeverity.ERROR, exception.getPath(),
exception.getLine(), exception.getColumn(), exception.getMessage(), null));
}
return new SkillPackageException("Invalid Skill document.",
prefixValidationPaths(report, packageRoot));
}
/**
* 为包装布局中的校验问题补齐 Skill 根目录,使批量预览可唯一定位文件。
*
* @param report 原始 Skill 相对路径报告
* @param packageRoot 包装 Skill 根目录,根直压时为空
* @return 路径补齐后的报告
*/
private static SkillValidationReport prefixValidationPaths(
SkillValidationReport report, String packageRoot) {
if (report == null || packageRoot == null || packageRoot.isBlank()) {
return report;
}
SkillValidationReport prefixed = new SkillValidationReport();
for (SkillValidationIssue issue : report.getIssues()) {
String path = issue.getPath();
if (path != null && !path.isBlank()
&& !path.equals(packageRoot) && !path.startsWith(packageRoot + "/")) {
path = packageRoot + "/" + path;
}
prefixed.add(new SkillValidationIssue(issue.getCode(), issue.getSeverity(), path,
issue.getLine(), issue.getColumn(), issue.getMessage(), issue.getSuggestion()));
}
return prefixed;
}
private static SkillPackageException packageError(String code, String path, String message) {
return new SkillPackageException(code, path, message);
}
private static boolean hasCause(Throwable throwable, Class<? extends Throwable> type) {
Throwable current = throwable;
while (current != null) {
if (type.isInstance(current)) {
return true;
}
current = current.getCause();
}
return false;
}
private static void deleteQuietly(Path path) {
if (path == null) {
return;
}
try {
Files.deleteIfExists(path);
} catch (IOException ignored) {
// 临时文件由操作系统兜底清理;主异常优先返回给调用方。
}
}
private record ArchiveFile(ZipArchiveEntry entry, String fullPath, String relativePath) {
private ArchiveFile(ZipArchiveEntry entry, String fullPath) {
this(entry, fullPath, fullPath);
}
private ArchiveFile withRelativePath(String value) {
return new ArchiveFile(entry, fullPath, value);
}
}
private record ArchiveGroup(String archiveRoot, String packageRoot, List<ArchiveFile> files) {
}
private record ArchiveLayout(SkillPackageLayout layout, List<ArchiveGroup> groups) {
}
private record DecodeState(SkillPackageLayout layout, List<Skill> skills) {
}
/**
* 预扫描得到的中央目录边界与条目数。
*
* @param offset 中央目录偏移
* @param size 中央目录大小
* @param entryCount 声明条目数
* @param endRecordOffset 紧随中央目录的 EOCD 或 ZIP64 EOCD 偏移
*/
private record CentralDirectoryDescriptor(long offset, long size, long entryCount,
long endRecordOffset) {
}
private record InputCopyResult(String hash, long size) {
}
private record OutputWriteResult(String hash, long size) {
}
/**
* STORED entry 写出所需的中央目录元数据。
*
* @param size 未压缩字节数
* @param crc CRC-32
*/
private record StoredEntryMetadata(long size, long crc) {
}
private static final class OutputFile {
private final String path;
private final String text;
private final SkillResource resource;
private OutputFile(String path, String text, SkillResource resource) {
this.path = path;
this.text = text;
this.resource = resource;
}
private static OutputFile text(String path, String text) {
return new OutputFile(path, text, null);
}
private static OutputFile binary(String path, SkillResource resource) {
return new OutputFile(path, null, resource);
}
}
private static final class StageTracker {
private final SkillContentStore contentStore;
private final List<StageRecord> records = new ArrayList<>();
private boolean finalized;
private StageTracker(SkillContentStore contentStore) {
this.contentStore = contentStore;
}
private void add(SkillContentStage stage, SkillResource resource, String path) {
records.add(new StageRecord(stage, resource, path));
}
private void commitAll() {
for (StageRecord record : records) {
String contentRef = contentStore.commit(record.stage);
record.committedRef = contentRef;
if (!record.stage.getContentRef().equals(contentRef)) {
throw packageError("CONTENT_REF_MISMATCH", record.path,
"Content store returned a different reference during commit.");
}
record.resource.setContentRef(contentRef);
}
finalized = true;
}
private void rollbackAll() {
if (finalized) {
return;
}
SkillPackageException rollbackFailure = null;
for (int index = records.size() - 1; index >= 0; index--) {
StageRecord record = records.get(index);
if (record.cleaned) {
continue;
}
try {
contentStore.rollback(record.stage);
record.cleaned = true;
} catch (RuntimeException cleanupError) {
if (rollbackFailure == null) {
rollbackFailure = new SkillPackageException(
"SKILL_CONTENT_ROLLBACK_ERROR", record.path,
"Failed to rollback staged Skill package content.", cleanupError);
} else {
rollbackFailure.addSuppressed(cleanupError);
}
}
}
if (rollbackFailure != null) {
throw rollbackFailure;
}
finalized = true;
}
private void cleanupAfterFailure(Throwable primary) {
if (finalized) {
return;
}
for (int index = records.size() - 1; index >= 0; index--) {
StageRecord record = records.get(index);
if (record.cleaned) {
continue;
}
try {
if (record.committedRef == null) {
contentStore.rollback(record.stage);
} else {
contentStore.release(record.committedRef);
}
record.cleaned = true;
} catch (RuntimeException cleanupError) {
primary.addSuppressed(cleanupError);
}
}
finalized = records.stream().allMatch(record -> record.cleaned);
}
}
private static final class StageRecord {
private final SkillContentStage stage;
private final SkillResource resource;
private final String path;
private String committedRef;
private boolean cleaned;
private StageRecord(SkillContentStage stage, SkillResource resource, String path) {
this.stage = stage;
this.resource = resource;
this.path = path;
}
}
private static final class LimitedOutputStream extends OutputStream {
private final OutputStream delegate;
private final long limit;
private long count;
private LimitedOutputStream(OutputStream delegate, long limit) {
this.delegate = delegate;
this.limit = limit;
}
@Override
public void write(int value) throws IOException {
ensureCapacity(1);
delegate.write(value);
count++;
}
@Override
public void write(byte[] bytes, int offset, int length) throws IOException {
ensureCapacity(length);
delegate.write(bytes, offset, length);
count += length;
}
@Override
public void flush() throws IOException {
delegate.flush();
}
@Override
public void close() throws IOException {
delegate.close();
}
private long getCount() {
return count;
}
private void ensureCapacity(int length) throws IOException {
if (count > limit - length) {
throw new CompressedSizeLimitException(
"Compressed Skill ZIP exceeds " + limit + " bytes.");
}
}
}
private static final class CompressedSizeLimitException extends IOException {
private CompressedSizeLimitException(String message) {
super(message);
}
}
}