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 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); } } }