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