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。
*
*
该实例拥有默认内容存储,使用完毕后应调用 {@link #close()} 清理临时内容。
*/
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 自有的默认临时内容存储。
*
* 通过构造函数注入的内容存储由调用方管理,本方法不会关闭它。
*/
@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 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 files = new ArrayList<>();
Set exactPaths = new HashSet<>();
Map collisionPaths = new HashMap<>();
Map descendantPaths = new HashMap<>();
long declaredTotal = 0;
int entryCount = 0;
try (ZipFile zipFile = new ZipFile(archivePath)) {
Enumeration 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 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 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 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 relativeFiles = files.stream()
.map(file -> file.withRelativePath(file.fullPath))
.toList();
return new ArchiveLayout(SkillPackageLayout.ROOT_SKILL,
List.of(new ArchiveGroup(null, null, relativeFiles)));
}
List 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> 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 groups = new ArrayList<>();
for (Map.Entry> 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 prepareOutput(List skills, SkillPackageLimits limits) {
SkillValidationReport report = new SkillValidationReport();
Set roots = new HashSet<>();
List 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 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 paths = new HashSet<>();
Map collisionPaths = new HashMap<>();
Map 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 files,
SkillPackageLimits limits) throws IOException {
OutputWriteResult initialResult = writeArchiveAttempt(archivePath, files, limits, Map.of());
Map storedEntries = findEntriesExceedingCompressionRatio(
archivePath, limits);
if (storedEntries.isEmpty()) {
return initialResult;
}
OutputWriteResult rewrittenResult = writeArchiveAttempt(
archivePath, files, limits, storedEntries);
Map 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 files,
SkillPackageLimits limits,
Map 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 findEntriesExceedingCompressionRatio(
Path archivePath, SkillPackageLimits limits) throws IOException {
Map entries = new LinkedHashMap<>();
try (ZipFile zipFile = new ZipFile(archivePath)) {
Enumeration 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 filePaths,
Map 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 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 files) {
}
private record ArchiveLayout(SkillPackageLayout layout, List groups) {
}
private record DecodeState(SkillPackageLayout layout, List 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 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);
}
}
}