From c48d9a9da637cffcca6e443a9b8d4c7c4cd30d38 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=99=88=E5=AD=90=E9=BB=98?= <925456043@qq.com> Date: Mon, 27 Jul 2026 18:53:24 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=8C=E5=96=84=E6=A0=87=E5=87=86=20?= =?UTF-8?q?Skill=20=E5=8C=85=E5=BA=95=E5=BA=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 标准化 SKILL.md、资源模型、校验规则与安全限额 - 支持流式内容存储和单、多 Skill ZIP 双向编解码 --- README.md | 1 + easy-agents-skill/README.md | 72 + easy-agents-skill/pom.xml | 8 + .../skill/codec/SkillPackageCodec.java | 45 +- .../skill/codec/SkillPackageReadMode.java | 18 + .../skill/codec/SkillPackageReadOptions.java | 69 + .../skill/codec/SkillPackageReadResult.java | 43 + .../skill/codec/SkillPackageWriteOptions.java | 38 + .../skill/codec/SkillPackageWriteResult.java | 61 + .../skill/codec/ZipSkillPackageCodec.java | 1709 +++++++++++++++-- .../exception/SkillPackageException.java | 64 +- .../exception/SkillValidationException.java | 81 +- .../skill/factory/SkillFactory.java | 66 +- .../com/easyagents/skill/model/Skill.java | 89 +- .../easyagents/skill/model/SkillAsset.java | 5 +- .../easyagents/skill/model/SkillDocument.java | 207 ++ .../easyagents/skill/model/SkillMetadata.java | 65 +- .../easyagents/skill/model/SkillPackage.java | 71 + .../skill/model/SkillPackageLayout.java | 16 + .../skill/model/SkillPackageLimits.java | 274 +++ .../skill/model/SkillReference.java | 5 +- .../easyagents/skill/model/SkillResource.java | 175 ++ .../skill/model/SkillResourceKind.java | 22 + .../easyagents/skill/model/SkillScript.java | 5 +- .../skill/model/SkillSourceLocation.java | 40 + .../memory/InMemorySkillRepository.java | 40 +- .../skill/store/SkillContentStage.java | 76 + .../skill/store/SkillContentStore.java | 168 +- .../file/TemporaryFileSkillContentStore.java | 633 ++++++ .../memory/InMemorySkillContentStore.java | 156 +- .../skill/util/SkillFrontmatter.java | 322 +++- .../easyagents/skill/util/SkillHashes.java | 32 +- .../com/easyagents/skill/util/SkillPaths.java | 48 +- .../easyagents/skill/util/SkillResources.java | 187 ++ .../com/easyagents/skill/util/SkillUtf8.java | 56 + .../validation/SkillValidationIssue.java | 79 + .../skill/validation/SkillValidationMode.java | 17 + .../validation/SkillValidationReport.java | 89 + .../validation/SkillValidationSeverity.java | 16 + .../skill/validation/SkillValidator.java | 49 + .../defaults/DefaultSkillValidator.java | 570 +++++- .../skill/codec/ZipSkillPackageCodecTest.java | 1422 ++++++++++++-- .../skill/model/SkillMetadataTest.java | 50 + .../skill/model/SkillPackageLimitsTest.java | 30 + .../memory/InMemorySkillRepositoryTest.java | 35 + .../TemporaryFileSkillContentStoreTest.java | 275 +++ .../memory/InMemorySkillContentStoreTest.java | 37 + .../skill/util/SkillFrontmatterTest.java | 199 ++ .../easyagents/skill/util/SkillPathsTest.java | 35 + .../skill/util/SkillResourcesTest.java | 22 + .../validation/SkillValidationReportTest.java | 37 + .../defaults/DefaultSkillValidatorTest.java | 532 +++-- pom.xml | 14 + 53 files changed, 7809 insertions(+), 666 deletions(-) create mode 100644 easy-agents-skill/README.md create mode 100644 easy-agents-skill/src/main/java/com/easyagents/skill/codec/SkillPackageReadMode.java create mode 100644 easy-agents-skill/src/main/java/com/easyagents/skill/codec/SkillPackageReadOptions.java create mode 100644 easy-agents-skill/src/main/java/com/easyagents/skill/codec/SkillPackageReadResult.java create mode 100644 easy-agents-skill/src/main/java/com/easyagents/skill/codec/SkillPackageWriteOptions.java create mode 100644 easy-agents-skill/src/main/java/com/easyagents/skill/codec/SkillPackageWriteResult.java create mode 100644 easy-agents-skill/src/main/java/com/easyagents/skill/model/SkillDocument.java create mode 100644 easy-agents-skill/src/main/java/com/easyagents/skill/model/SkillPackage.java create mode 100644 easy-agents-skill/src/main/java/com/easyagents/skill/model/SkillPackageLayout.java create mode 100644 easy-agents-skill/src/main/java/com/easyagents/skill/model/SkillPackageLimits.java create mode 100644 easy-agents-skill/src/main/java/com/easyagents/skill/model/SkillResource.java create mode 100644 easy-agents-skill/src/main/java/com/easyagents/skill/model/SkillResourceKind.java create mode 100644 easy-agents-skill/src/main/java/com/easyagents/skill/model/SkillSourceLocation.java create mode 100644 easy-agents-skill/src/main/java/com/easyagents/skill/store/SkillContentStage.java create mode 100644 easy-agents-skill/src/main/java/com/easyagents/skill/store/file/TemporaryFileSkillContentStore.java create mode 100644 easy-agents-skill/src/main/java/com/easyagents/skill/util/SkillResources.java create mode 100644 easy-agents-skill/src/main/java/com/easyagents/skill/util/SkillUtf8.java create mode 100644 easy-agents-skill/src/main/java/com/easyagents/skill/validation/SkillValidationIssue.java create mode 100644 easy-agents-skill/src/main/java/com/easyagents/skill/validation/SkillValidationMode.java create mode 100644 easy-agents-skill/src/main/java/com/easyagents/skill/validation/SkillValidationReport.java create mode 100644 easy-agents-skill/src/main/java/com/easyagents/skill/validation/SkillValidationSeverity.java create mode 100644 easy-agents-skill/src/test/java/com/easyagents/skill/model/SkillMetadataTest.java create mode 100644 easy-agents-skill/src/test/java/com/easyagents/skill/model/SkillPackageLimitsTest.java create mode 100644 easy-agents-skill/src/test/java/com/easyagents/skill/store/file/TemporaryFileSkillContentStoreTest.java create mode 100644 easy-agents-skill/src/test/java/com/easyagents/skill/util/SkillFrontmatterTest.java create mode 100644 easy-agents-skill/src/test/java/com/easyagents/skill/util/SkillPathsTest.java create mode 100644 easy-agents-skill/src/test/java/com/easyagents/skill/util/SkillResourcesTest.java create mode 100644 easy-agents-skill/src/test/java/com/easyagents/skill/validation/SkillValidationReportTest.java diff --git a/README.md b/README.md index 35968ab..be229fa 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,7 @@ Easy-Agents 是一个轻量、可扩展的 Java AI 应用开发框架,覆盖 - `easy-agents-search-engine`:检索引擎实现。 - `easy-agents-tool`:工具调用能力。 - `easy-agents-mcp`:MCP 集成。 +- `easy-agents-skill`:标准 Agent Skills 包模型、安全校验、资源存储与 ZIP 双向编解码。 - `easy-agents-flow`:流程编排核心引擎。 - `easy-agents-support`:Flow 与 Easy-Agents 适配模块。 - `easy-agents-spring-boot-starter`:Spring Boot 自动配置支持。 diff --git a/easy-agents-skill/README.md b/easy-agents-skill/README.md new file mode 100644 index 0000000..08f0cd8 --- /dev/null +++ b/easy-agents-skill/README.md @@ -0,0 +1,72 @@ +# Easy-Agents Skill + +`easy-agents-skill` 提供标准 Agent Skills 包的领域模型、安全校验、通用资源存储,以及 ZIP 双向编解码能力。模块只负责 Skill 定义和包处理,不执行 `scripts/`,也不绑定具体智能体 Runtime。 + +## 标准包结构 + +Codec 支持根目录单 Skill、单目录 Skill 和多目录 Skill 三种输入布局。标准输出使用 `name/SKILL.md`,并保留以下可移植资源: + +- `references/` +- `scripts/` +- `assets/` +- `examples/` +- 其他安全相对路径资源 + +`SKILL.md` 使用 YAML frontmatter 与 Markdown 正文。未知字段、嵌套 Map/List、布尔值和数字会保留;语义校验通过结构化 issue 返回路径、行列、错误码与修复建议。 + +## 推荐调用方式 + +无参 `ZipSkillPackageCodec` 使用实例级临时文件存储,适合一次性导入导出。它拥有临时目录,必须关闭: + +```java +try (ZipSkillPackageCodec codec = new ZipSkillPackageCodec()) { + SkillPackageReadResult result = codec.decode( + inputStream, + SkillPackageReadOptions.defaults()); + SkillPackage skillPackage = result.getSkillPackage(); +} +``` + +生产系统需要让二进制资源跨请求存活时,应注入持久化的 `SkillContentStore`。注入存储的生命周期由调用方负责,关闭 Codec 不会关闭外部存储: + +```java +ZipSkillPackageCodec codec = new ZipSkillPackageCodec(contentStore); +SkillPackageReadResult result = codec.decode(inputStream, readOptions); +codec.encode(result.getSkillPackage(), outputStream, writeOptions); +``` + +成功解码的二进制资源通过 `contentRef` 引用已提交内容。业务侧丢弃包或删除资源时,应按持久化策略调用 `release`;复制引用时调用 `retain`。`REPORT_ONLY` 模式会回滚暂存内容,只用于检查诊断,不应持久化其资源引用。 + +## 校验上下文 + +校验通过 `SkillValidationMode` 区分两个明确上下文: + +- `DRAFT_IMPORT`:ZIP 导入和兼容预检使用;历史下划线名称保留为 warning,允许先进入草稿修复。 +- `STANDARD`:正式新建、发布校验和标准 ZIP 导出使用;下划线名称等互操作问题作为 error。 + +`SkillFactory.createStrict`、`SkillFactory.createWithResourcesStrict`、`DefaultSkillValidator.validate` 与 `ZipSkillPackageCodec.encode` 执行 `STANDARD` 校验。为保持旧调用兼容,`SkillFactory.create` 仍可构建导入草稿,原有 `validateReport(skill)` 与 `validateReport(skill, limits)` 继续使用 `DRAFT_IMPORT`;新调用方需要显式上下文时使用三参数 `validateReport`。 + +## 安全边界 + +默认 Codec 对读写两端执行统一限制: + +- 严格 UTF-8 文本和 ZIP entry 名称 +- Zip Slip、符号链接、路径大小写/Unicode 冲突与层级冲突防护 +- entry 数量、路径长度/深度、单文件、总解压大小、压缩包大小和压缩比限制 +- CRC、声明大小与实际流量复核 +- 安全 YAML 构造、重复 key、alias、深度和 code point 限制 +- stage / commit / rollback,失败时清理暂存内容 + +限额通过 `SkillPackageLimits` 配置,并由 `SkillPackageReadOptions`、`SkillPackageWriteOptions` 传入单次操作。 + +## 从旧接口迁移 + +`importZip(InputStream)` 为兼容入口,现已废弃。新调用方应使用 `decode`,以获得: + +- 包布局 `SkillPackageLayout` +- 标准化 `SkillPackage` +- 包哈希 +- 聚合校验报告 +- `STRICT` 或 `REPORT_ONLY` 读取模式 + +写出统一使用 `encode`。自定义校验器是附加业务校验,不能替代 Codec 内置的标准安全校验。 diff --git a/easy-agents-skill/pom.xml b/easy-agents-skill/pom.xml index 8dbe3b6..ec7ff75 100644 --- a/easy-agents-skill/pom.xml +++ b/easy-agents-skill/pom.xml @@ -18,6 +18,14 @@ + + org.yaml + snakeyaml + + + org.apache.commons + commons-compress + junit junit diff --git a/easy-agents-skill/src/main/java/com/easyagents/skill/codec/SkillPackageCodec.java b/easy-agents-skill/src/main/java/com/easyagents/skill/codec/SkillPackageCodec.java index 7936b0e..6da742d 100644 --- a/easy-agents-skill/src/main/java/com/easyagents/skill/codec/SkillPackageCodec.java +++ b/easy-agents-skill/src/main/java/com/easyagents/skill/codec/SkillPackageCodec.java @@ -1,20 +1,59 @@ package com.easyagents.skill.codec; +import com.easyagents.skill.exception.SkillPackageException; import com.easyagents.skill.model.Skill; +import com.easyagents.skill.model.SkillPackage; +import com.easyagents.skill.model.SkillPackageLayout; +import com.easyagents.skill.validation.SkillValidationReport; import java.io.InputStream; +import java.io.OutputStream; import java.util.List; /** - * Skill 包导入接口。 + * Skill 包双向流式编解码接口。 */ public interface SkillPackageCodec { /** - * 从 zip 输入流导入 Skill。 + * 从 ZIP 输入流导入 Skill。 * - * @param inputStream zip 输入流 + * @param inputStream ZIP 输入流 * @return Skill 列表 + * @throws SkillPackageException ZIP 结构、内容或安全校验失败 + * @deprecated 请使用 {@link #decode(InputStream, SkillPackageReadOptions)} 获取包形态、hash 和诊断。 */ + @Deprecated List importZip(InputStream inputStream); + + /** + * 解码 Skill ZIP。 + * + *

兼容默认实现委托旧导入接口;正式 Codec 应覆盖。

+ * + * @param inputStream ZIP 输入流 + * @param options 读取选项 + * @return 解码结果 + * @throws SkillPackageException ZIP 结构、内容、安全校验或资源存储失败 + */ + default SkillPackageReadResult decode(InputStream inputStream, SkillPackageReadOptions options) { + List skills = importZip(inputStream); + SkillPackageLayout layout = skills.size() > 1 + ? SkillPackageLayout.MULTI_DIRECTORY : SkillPackageLayout.SINGLE_DIRECTORY; + return new SkillPackageReadResult(new SkillPackage(layout, skills), new SkillValidationReport(), null); + } + + /** + * 将 Skill 包编码为标准 ZIP。 + * + * @param skillPackage Skill 包 + * @param outputStream 输出流,不由本方法关闭 + * @param options 写出选项 + * @return 编码结果 + * @throws SkillPackageException Skill 包不合法、资源不可读或 ZIP 写出失败 + */ + default SkillPackageWriteResult encode(SkillPackage skillPackage, OutputStream outputStream, + SkillPackageWriteOptions options) { + throw new SkillPackageException("This SkillPackageCodec does not support encoding."); + } } diff --git a/easy-agents-skill/src/main/java/com/easyagents/skill/codec/SkillPackageReadMode.java b/easy-agents-skill/src/main/java/com/easyagents/skill/codec/SkillPackageReadMode.java new file mode 100644 index 0000000..d1cb073 --- /dev/null +++ b/easy-agents-skill/src/main/java/com/easyagents/skill/codec/SkillPackageReadMode.java @@ -0,0 +1,18 @@ +package com.easyagents.skill.codec; + +/** + * Skill 包读取后的内容处理模式。 + */ +public enum SkillPackageReadMode { + + /** + * 校验通过后提交二进制内容;校验失败时抛出异常并回滚暂存内容。 + */ + COMMIT_ON_VALID, + + /** + * 返回可解析包的完整校验报告并回滚全部暂存内容,不提交二进制内容。 + * 返回资源中的 contentRef 仅表示内容哈希身份,不保证读取结果返回后仍可打开。 + */ + REPORT_ONLY +} diff --git a/easy-agents-skill/src/main/java/com/easyagents/skill/codec/SkillPackageReadOptions.java b/easy-agents-skill/src/main/java/com/easyagents/skill/codec/SkillPackageReadOptions.java new file mode 100644 index 0000000..984e3e2 --- /dev/null +++ b/easy-agents-skill/src/main/java/com/easyagents/skill/codec/SkillPackageReadOptions.java @@ -0,0 +1,69 @@ +package com.easyagents.skill.codec; + +import com.easyagents.skill.model.SkillPackageLimits; + +/** + * Skill 包读取选项。 + */ +public final class SkillPackageReadOptions { + + private final SkillPackageLimits limits; + private final SkillPackageReadMode mode; + + /** + * 使用指定安全限额创建读取选项。 + * + * @param limits 安全限额 + */ + public SkillPackageReadOptions(SkillPackageLimits limits) { + this(limits, SkillPackageReadMode.COMMIT_ON_VALID); + } + + /** + * 使用指定安全限额和内容处理模式创建读取选项。 + * + * @param limits 安全限额 + * @param mode 内容处理模式 + */ + public SkillPackageReadOptions(SkillPackageLimits limits, SkillPackageReadMode mode) { + this.limits = limits == null ? SkillPackageLimits.defaults() : limits; + this.mode = mode == null ? SkillPackageReadMode.COMMIT_ON_VALID : mode; + } + + /** + * 创建默认读取选项。 + * + * @return 默认读取选项 + */ + public static SkillPackageReadOptions defaults() { + return new SkillPackageReadOptions(SkillPackageLimits.defaults()); + } + + /** + * 创建只返回校验报告且不提交内容的读取选项。 + * 二进制资源的 contentRef 仅表示内容哈希身份,不承诺可通过内容存储打开。 + * + * @return 只读预检选项 + */ + public static SkillPackageReadOptions reportOnly() { + return new SkillPackageReadOptions(SkillPackageLimits.defaults(), SkillPackageReadMode.REPORT_ONLY); + } + + /** + * 获取安全限额。 + * + * @return 安全限额 + */ + public SkillPackageLimits getLimits() { + return limits; + } + + /** + * 获取内容处理模式。 + * + * @return 内容处理模式 + */ + public SkillPackageReadMode getMode() { + return mode; + } +} diff --git a/easy-agents-skill/src/main/java/com/easyagents/skill/codec/SkillPackageReadResult.java b/easy-agents-skill/src/main/java/com/easyagents/skill/codec/SkillPackageReadResult.java new file mode 100644 index 0000000..8a74b0f --- /dev/null +++ b/easy-agents-skill/src/main/java/com/easyagents/skill/codec/SkillPackageReadResult.java @@ -0,0 +1,43 @@ +package com.easyagents.skill.codec; + +import com.easyagents.skill.model.SkillPackage; +import com.easyagents.skill.validation.SkillValidationReport; + +/** + * Skill 包解码结果。 + */ +public final class SkillPackageReadResult { + + private final SkillPackage skillPackage; + private final SkillValidationReport validationReport; + private final String packageHash; + + /** + * 创建解码结果。 + * + * @param skillPackage Skill 包 + * @param validationReport 校验报告 + * @param packageHash 输入 ZIP SHA-256 + */ + public SkillPackageReadResult(SkillPackage skillPackage, SkillValidationReport validationReport, + String packageHash) { + this.skillPackage = skillPackage; + this.validationReport = validationReport; + this.packageHash = packageHash; + } + + /** @return Skill 包 */ + public SkillPackage getSkillPackage() { + return skillPackage; + } + + /** @return 结构化校验报告 */ + public SkillValidationReport getValidationReport() { + return validationReport; + } + + /** @return 输入 ZIP SHA-256 */ + public String getPackageHash() { + return packageHash; + } +} diff --git a/easy-agents-skill/src/main/java/com/easyagents/skill/codec/SkillPackageWriteOptions.java b/easy-agents-skill/src/main/java/com/easyagents/skill/codec/SkillPackageWriteOptions.java new file mode 100644 index 0000000..900a711 --- /dev/null +++ b/easy-agents-skill/src/main/java/com/easyagents/skill/codec/SkillPackageWriteOptions.java @@ -0,0 +1,38 @@ +package com.easyagents.skill.codec; + +import com.easyagents.skill.model.SkillPackageLimits; + +/** + * Skill 包写出选项。 + */ +public final class SkillPackageWriteOptions { + + private final SkillPackageLimits limits; + + /** + * 使用指定安全限额创建写出选项。 + * + * @param limits 安全限额 + */ + public SkillPackageWriteOptions(SkillPackageLimits limits) { + this.limits = limits == null ? SkillPackageLimits.defaults() : limits; + } + + /** + * 创建默认写出选项。 + * + * @return 默认写出选项 + */ + public static SkillPackageWriteOptions defaults() { + return new SkillPackageWriteOptions(SkillPackageLimits.defaults()); + } + + /** + * 获取安全限额。 + * + * @return 安全限额 + */ + public SkillPackageLimits getLimits() { + return limits; + } +} diff --git a/easy-agents-skill/src/main/java/com/easyagents/skill/codec/SkillPackageWriteResult.java b/easy-agents-skill/src/main/java/com/easyagents/skill/codec/SkillPackageWriteResult.java new file mode 100644 index 0000000..35e0b96 --- /dev/null +++ b/easy-agents-skill/src/main/java/com/easyagents/skill/codec/SkillPackageWriteResult.java @@ -0,0 +1,61 @@ +package com.easyagents.skill.codec; + +import com.easyagents.skill.model.SkillPackageLayout; + +/** + * Skill 包编码结果。 + */ +public final class SkillPackageWriteResult { + + private final String packageHash; + private final long size; + private final int entryCount; + private final SkillPackageLayout layout; + + /** + * 创建编码结果。 + * + * @param packageHash 输出 ZIP SHA-256 + * @param size 输出字节数 + * @param entryCount 输出 entry 数 + */ + public SkillPackageWriteResult(String packageHash, long size, int entryCount) { + this(packageHash, size, entryCount, SkillPackageLayout.SINGLE_DIRECTORY); + } + + /** + * 创建带包形态的编码结果。 + * + * @param packageHash 输出 ZIP SHA-256 + * @param size 输出字节数 + * @param entryCount 输出 entry 数 + * @param layout 输出包形态 + */ + public SkillPackageWriteResult(String packageHash, long size, int entryCount, + SkillPackageLayout layout) { + this.packageHash = packageHash; + this.size = size; + this.entryCount = entryCount; + this.layout = layout == null ? SkillPackageLayout.SINGLE_DIRECTORY : layout; + } + + /** @return 输出 ZIP SHA-256 */ + public String getPackageHash() { + return packageHash; + } + + /** @return 输出字节数 */ + public long getSize() { + return size; + } + + /** @return 输出 entry 数 */ + public int getEntryCount() { + return entryCount; + } + + /** @return 输出包形态 */ + public SkillPackageLayout getLayout() { + return layout; + } +} diff --git a/easy-agents-skill/src/main/java/com/easyagents/skill/codec/ZipSkillPackageCodec.java b/easy-agents-skill/src/main/java/com/easyagents/skill/codec/ZipSkillPackageCodec.java index 16590af..e7abef7 100644 --- a/easy-agents-skill/src/main/java/com/easyagents/skill/codec/ZipSkillPackageCodec.java +++ b/easy-agents-skill/src/main/java/com/easyagents/skill/codec/ZipSkillPackageCodec.java @@ -2,253 +2,1640 @@ package com.easyagents.skill.codec; import com.easyagents.skill.exception.SkillPackageException; import com.easyagents.skill.exception.SkillValidationException; -import com.easyagents.skill.factory.SkillFactory; -import com.easyagents.skill.model.*; +import com.easyagents.skill.model.Skill; +import com.easyagents.skill.model.SkillDocument; +import com.easyagents.skill.model.SkillMetadata; +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.memory.InMemorySkillContentStore; +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.SkillValidator; 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.util.*; +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.ZipInputStream; +import java.util.zip.CRC32; +import java.util.zip.CheckedInputStream; +import java.util.zip.ZipOutputStream; /** - * 基于 zip 的 Skill 包导入实现。 + * 标准 Skill ZIP 的安全双向流式 Codec。 */ -public class ZipSkillPackageCodec implements SkillPackageCodec { +public class ZipSkillPackageCodec implements SkillPackageCodec, 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 SkillValidator validator; + private final SkillValidator additionalValidator; + private final boolean ownsContentStore; /** - * 创建使用内存内容存储的 zip Skill 包导入器。 + * 创建使用临时文件内容存储的 ZIP Codec。 + * + *

该实例拥有默认内容存储,使用完毕后应调用 {@link #close()} 清理临时内容。

*/ public ZipSkillPackageCodec() { - this(new InMemorySkillContentStore()); + this(new TemporaryFileSkillContentStore(), null, true); } /** - * 创建 zip Skill 包导入器。 + * 创建 ZIP Codec。 * * @param contentStore 二进制内容存储 */ public ZipSkillPackageCodec(SkillContentStore contentStore) { - this(contentStore, new DefaultSkillValidator()); + this(contentStore, null, false); } /** - * 创建 zip Skill 包导入器。 + * 创建 ZIP Codec。 * * @param contentStore 二进制内容存储 * @param validator Skill 聚合校验器 */ public ZipSkillPackageCodec(SkillContentStore contentStore, SkillValidator validator) { + this(contentStore, requireValidator(validator), false); + } + + private ZipSkillPackageCodec(SkillContentStore contentStore, SkillValidator validator, + boolean ownsContentStore) { if (contentStore == null) { throw new SkillPackageException("Skill content store is required."); } + this.contentStore = contentStore; + this.additionalValidator = validator; + this.ownsContentStore = ownsContentStore; + } + + private static SkillValidator requireValidator(SkillValidator validator) { if (validator == null) { throw new SkillPackageException("Skill validator is required."); } - this.contentStore = contentStore; - this.validator = validator; + return validator; } /** - * 从 zip 流导入 Skill 列表。 + * 关闭 Codec 自有的默认临时内容存储。 * - * @param inputStream zip 输入流 - * @return Skill 列表 + *

通过构造函数注入的内容存储由调用方管理,本方法不会关闭它。

*/ @Override + public void close() { + if (ownsContentStore) { + ((TemporaryFileSkillContentStore) contentStore).close(); + } + } + + /** + * 使用兼容入口导入 ZIP。 + * + * @param inputStream ZIP 输入流 + * @return Skill 列表 + * @deprecated 请使用 {@link #decode(InputStream, SkillPackageReadOptions)}。 + */ + @Deprecated + @Override public List importZip(InputStream inputStream) { + return decode(inputStream, SkillPackageReadOptions.defaults()).getSkillPackage().getSkills(); + } + + /** + * 安全解码 Skill ZIP。 + * + * @param inputStream ZIP 输入流 + * @param options 读取选项 + * @return 解码结果 + */ + @Override + public SkillPackageReadResult decode(InputStream inputStream, SkillPackageReadOptions options) { if (inputStream == null) { - throw new SkillPackageException("Zip input stream is required."); + throw packageError("ZIP_INPUT_REQUIRED", null, "ZIP input stream is required."); } - Map builders = new LinkedHashMap<>(); - try (ZipInputStream zipInputStream = new ZipInputStream(inputStream)) { - ZipEntry entry; - while ((entry = zipInputStream.getNextEntry()) != null) { - handleEntry(entry, zipInputStream, builders); - zipInputStream.closeEntry(); + 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) { - throw new SkillPackageException("Failed to import skill zip package.", 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); } - if (builders.isEmpty()) { - throw new SkillPackageException("Zip package must contain at least one skill folder."); - } - List skills = new ArrayList<>(); - for (SkillBuilder builder : builders.values()) { - Skill skill = builder.build(); - validator.validate(skill); - skills.add(skill); - } - for (SkillBuilder builder : builders.values()) { - builder.writeAssets(contentStore); - } - return skills; } - private void handleEntry(ZipEntry entry, ZipInputStream zipInputStream, Map builders) + /** + * 将 Skill 包稳定编码为标准 ZIP。 + * + * @param skillPackage Skill 包 + * @param outputStream 输出流,不由本方法关闭 + * @param options 写出选项 + * @return 编码结果 + */ + @Override + 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 { - String rawPath = entry.getName(); - if (entry.isDirectory() || SkillPaths.isIgnoredSystemPath(rawPath)) { - return; - } - String normalizedPath = SkillPaths.normalize(rawPath); - if (SkillPaths.SKILL_FILE.equals(normalizedPath)) { - throw new SkillPackageException("Zip root cannot directly contain SKILL.md."); - } - int slashIndex = normalizedPath.indexOf('/'); - if (slashIndex < 0) { - throw new SkillPackageException("Zip root can only contain skill folders: " + normalizedPath); - } + 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; - String skillId = normalizedPath.substring(0, slashIndex); - String skillPath = normalizedPath.substring(slashIndex + 1); - if (skillPath.isBlank()) { - return; + 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."); + } + String pathForValidation = entry.isDirectory() ? stripDirectorySuffix(rawPath) : rawPath; + String path = normalizeArchivePath(pathForValidation, limits); + if (entry.isDirectory() || SkillPaths.isIgnoredSystemPath(rawPath)) { + 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); } - SkillBuilder builder = builders.computeIfAbsent(skillId, SkillBuilder::new); - builder.addFile(skillPath, readEntryBytes(zipInputStream)); } - private static byte[] readEntryBytes(InputStream inputStream) throws IOException { - ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); - byte[] buffer = new byte[8192]; - int readLength; - while ((readLength = inputStream.read(buffer)) >= 0) { - outputStream.write(buffer, 0, readLength); + 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.root, + "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.root); } - return outputStream.toByteArray(); + Map frontmatter = document.getFrontmatter().getValues(); + String name = scalar(frontmatter.get("name")); + String description = scalar(frontmatter.get("description")); + + 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.setId(null); + skill.setPackageRoot(layout == SkillPackageLayout.ROOT_SKILL ? name : group.root); + skill.setName(name); + skill.setDescription(description); + skill.setMetadata(new SkillMetadata(frontmatter)); + skill.setDocument(document); + skill.setResources(resources); + SkillResources.refreshLegacyViews(skill); + return skill; } - private static String detectMediaType(String path) { - String mediaType = URLConnection.guessContentTypeFromName(path); - return mediaType == null ? DEFAULT_MEDIA_TYPE : mediaType; + 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, kind, 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 final class SkillBuilder { - - private final String id; - private final Set paths = new HashSet<>(); - private String skillContent; - private final List references = new ArrayList<>(); - private final List scripts = new ArrayList<>(); - private final List assets = new ArrayList<>(); - private final List pendingAssets = new ArrayList<>(); - - private SkillBuilder(String id) { - this.id = id; + 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, relativeFiles))); } - private void addFile(String path, byte[] bytes) { - String normalizedPath = SkillPaths.normalize(path); - if (!paths.add(normalizedPath)) { - throw new SkillPackageException("Duplicate skill file path: " + id + "/" + normalizedPath); - } - if (SkillPaths.SKILL_FILE.equals(normalizedPath)) { - addSkillFile(bytes); - return; - } - - String topDir = SkillPaths.firstSegment(normalizedPath); - if (SkillPaths.REFERENCES_DIR.equals(topDir)) { - addReference(normalizedPath, bytes); - } else if (SkillPaths.SCRIPTS_DIR.equals(topDir)) { - addScript(normalizedPath, bytes); - } else if (SkillPaths.ASSETS_DIR.equals(topDir)) { - addAsset(normalizedPath, bytes); - } else { - throw new SkillPackageException("Unknown skill top-level directory: " + id + "/" + normalizedPath); + Map> grouped = new LinkedHashMap<>(); + for (ArchiveFile file : files) { + int separator = file.fullPath.indexOf('/'); + if (separator < 1 || separator == file.fullPath.length() - 1) { + throw packageError("UNOWNED_ROOT_FILE", file.fullPath, + "Wrapped Skill ZIP root can contain only Skill directories."); } + String root = file.fullPath.substring(0, separator); + String relativePath = file.fullPath.substring(separator + 1); + grouped.computeIfAbsent(root, ignored -> new ArrayList<>()) + .add(file.withRelativePath(relativePath)); } - - private void addSkillFile(byte[] bytes) { - skillContent = new String(bytes, StandardCharsets.UTF_8); - } - - private void addReference(String path, byte[] bytes) { - if (!SkillPaths.hasExtension(path, ".md")) { - throw new SkillPackageException("Skill reference must be a markdown file: " + id + "/" + path); + 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 top-level Skill directory must contain exactly one SKILL.md."); } - String content = new String(bytes, StandardCharsets.UTF_8); - SkillReference reference = new SkillReference(); - reference.setPath(path); - reference.setName(SkillPaths.fileName(path)); - reference.setContent(content); - reference.setContentHash(SkillHashes.sha256Hex(bytes)); - reference.setSize(bytes.length); - references.add(reference); + groups.add(new ArchiveGroup(entry.getKey(), entry.getValue())); } + groups.sort(Comparator.comparing(group -> group.root)); + SkillPackageLayout layout = groups.size() == 1 + ? SkillPackageLayout.SINGLE_DIRECTORY : SkillPackageLayout.MULTI_DIRECTORY; + return new ArchiveLayout(layout, groups); + } - private void addScript(String path, byte[] bytes) { - SkillScriptLanguage language = SkillScriptLanguage.fromPath(path); - if (language == SkillScriptLanguage.UNKNOWN) { - throw new SkillPackageException("Unsupported skill script extension: " + id + "/" + path); + /** + * 执行 Codec 不可绕过的标准安全校验,并追加调用方业务校验。 + * + * @param skill Skill 聚合 + * @param limits 当前读写操作限额 + * @param mode 标准校验模式 + * @return 合并后的结构化报告 + */ + private SkillValidationReport validateForCodec(Skill skill, SkillPackageLimits limits, + SkillValidationMode mode) { + SkillValidationReport report = new DefaultSkillValidator(limits) + .validateReport(skill, limits, mode); + if (additionalValidator != null) { + SkillValidationReport additionalReport = additionalValidator.getClass() == DefaultSkillValidator.class + ? additionalValidator.validateReport(skill, null, mode) + : additionalValidator.validateReport(skill, limits, mode); + report.merge(additionalReport); + } + return report; + } + + 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(); + } } - SkillScript script = new SkillScript(); - script.setPath(path); - script.setLanguage(language); - script.setContent(new String(bytes, StandardCharsets.UTF_8)); - script.setContentHash(SkillHashes.sha256Hex(bytes)); - script.setSize(bytes.length); - scripts.add(script); - } - - private void addAsset(String path, byte[] bytes) { - String contentHash = SkillHashes.sha256Hex(bytes); - SkillAsset asset = new SkillAsset(); - asset.setPath(path); - asset.setName(SkillPaths.fileName(path)); - asset.setMediaType(detectMediaType(path)); - asset.setContentRef("sha256:" + contentHash); - asset.setContentHash(contentHash); - asset.setSize(bytes.length); - assets.add(asset); - pendingAssets.add(new PendingAsset(asset, bytes)); - } - - private Skill build() { - if (skillContent == null) { - throw new SkillPackageException("Skill folder must contain SKILL.md: " + id); + report.merge(prefixValidationPaths(skillReport, diagnosticRoot)); + if (skill == null || skillReport.hasErrors()) { + continue; } - try { - return SkillFactory.create(id, skillContent, references, scripts, assets); - } catch (SkillValidationException e) { - throw new SkillPackageException("Invalid SKILL.md frontmatter: " + id, e); + 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; } - } - private void writeAssets(SkillContentStore contentStore) { - for (PendingAsset pendingAsset : pendingAssets) { - String contentRef = contentStore.put(pendingAsset.bytes); - if (!pendingAsset.asset.getContentRef().equals(contentRef)) { - throw new SkillPackageException("Skill asset content ref does not match store result: " - + id + "/" + pendingAsset.asset.getPath()); + 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 = SkillResources.canonicalResources(skill); + 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 static final class PendingAsset { + 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; + } - private final SkillAsset asset; - private final byte[] bytes; + 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; + } - private PendingAsset(SkillAsset asset, byte[] bytes) { - this.asset = asset; - this.bytes = bytes; + /** + * 按指定 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) { + String normalized; + try { + normalized = 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 (SkillPaths.depth(path) > 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 root, 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--) { + try { + contentStore.rollback(records.get(index).stage); + } catch (RuntimeException cleanupError) { + if (rollbackFailure == null) { + rollbackFailure = new SkillPackageException( + "SKILL_CONTENT_ROLLBACK_ERROR", records.get(index).path, + "Failed to rollback staged Skill package content.", cleanupError); + } else { + rollbackFailure.addSuppressed(cleanupError); + } + } + } + finalized = true; + if (rollbackFailure != null) { + throw rollbackFailure; + } + } + + private void cleanupAfterFailure(Throwable primary) { + if (finalized) { + return; + } + for (int index = records.size() - 1; index >= 0; index--) { + StageRecord record = records.get(index); + try { + if (record.committedRef == null) { + contentStore.rollback(record.stage); + } else { + contentStore.release(record.committedRef); + } + } catch (RuntimeException cleanupError) { + primary.addSuppressed(cleanupError); + } + } + finalized = true; + } + } + + private static final class StageRecord { + + private final SkillContentStage stage; + private final SkillResource resource; + private final String path; + private String committedRef; + + 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); } } } diff --git a/easy-agents-skill/src/main/java/com/easyagents/skill/exception/SkillPackageException.java b/easy-agents-skill/src/main/java/com/easyagents/skill/exception/SkillPackageException.java index ef388ae..0d3b6c1 100644 --- a/easy-agents-skill/src/main/java/com/easyagents/skill/exception/SkillPackageException.java +++ b/easy-agents-skill/src/main/java/com/easyagents/skill/exception/SkillPackageException.java @@ -1,17 +1,23 @@ package com.easyagents.skill.exception; +import com.easyagents.skill.validation.SkillValidationReport; + /** * Skill 包导入导出异常。 */ public class SkillPackageException extends SkillException { + private final String code; + private final String path; + private final SkillValidationReport report; + /** * 创建 Skill 包异常。 * * @param message 异常信息 */ public SkillPackageException(String message) { - super(message); + this("SKILL_PACKAGE_FAILED", null, message, null, null); } /** @@ -21,6 +27,62 @@ public class SkillPackageException extends SkillException { * @param cause 原始异常 */ public SkillPackageException(String message, Throwable cause) { + this("SKILL_PACKAGE_FAILED", null, message, cause, null); + } + + /** + * 创建可定位的 Skill 包异常。 + * + * @param code 稳定错误码 + * @param path 包内路径 + * @param message 异常信息 + */ + public SkillPackageException(String code, String path, String message) { + this(code, path, message, null, null); + } + + /** + * 创建包含根因的可定位 Skill 包异常。 + * + * @param code 稳定错误码 + * @param path 包内路径 + * @param message 异常信息 + * @param cause 原始异常 + */ + public SkillPackageException(String code, String path, String message, Throwable cause) { + this(code, path, message, cause, null); + } + + /** + * 创建包含结构化校验报告的 Skill 包异常。 + * + * @param message 异常信息 + * @param report 结构化校验报告 + */ + public SkillPackageException(String message, SkillValidationReport report) { + this("SKILL_PACKAGE_INVALID", null, message, null, report); + } + + private SkillPackageException(String code, String path, String message, + Throwable cause, SkillValidationReport report) { super(message, cause); + this.code = code; + this.path = path; + this.report = report; + } + + /** @return 稳定错误码 */ + public String getCode() { + return code; + } + + /** @return 包内路径 */ + public String getPath() { + return path; + } + + /** @return 结构化校验报告 */ + public SkillValidationReport getReport() { + return report; } } diff --git a/easy-agents-skill/src/main/java/com/easyagents/skill/exception/SkillValidationException.java b/easy-agents-skill/src/main/java/com/easyagents/skill/exception/SkillValidationException.java index dbf781c..0612859 100644 --- a/easy-agents-skill/src/main/java/com/easyagents/skill/exception/SkillValidationException.java +++ b/easy-agents-skill/src/main/java/com/easyagents/skill/exception/SkillValidationException.java @@ -1,17 +1,25 @@ package com.easyagents.skill.exception; +import com.easyagents.skill.validation.SkillValidationReport; + /** * Skill 校验失败异常。 */ public class SkillValidationException extends SkillException { + private final String code; + private final String path; + private final Integer line; + private final Integer column; + private final SkillValidationReport report; + /** * 创建 Skill 校验异常。 * * @param message 异常信息 */ public SkillValidationException(String message) { - super(message); + this("SKILL_VALIDATION_FAILED", null, null, null, message, null, null); } /** @@ -21,6 +29,77 @@ public class SkillValidationException extends SkillException { * @param cause 原始异常 */ public SkillValidationException(String message, Throwable cause) { + this("SKILL_VALIDATION_FAILED", null, null, null, message, cause, null); + } + + /** + * 创建包含结构化报告的 Skill 校验异常。 + * + * @param message 异常信息 + * @param report 结构化校验报告 + */ + public SkillValidationException(String message, SkillValidationReport report) { + this("SKILL_VALIDATION_FAILED", null, null, null, message, null, report); + } + + /** + * 创建可定位的 Skill 校验异常。 + * + * @param code 稳定错误码 + * @param path 文件路径 + * @param line 一基行号 + * @param column 一基列号 + * @param message 异常信息 + * @param cause 原始异常 + */ + public SkillValidationException(String code, String path, Integer line, Integer column, + String message, Throwable cause) { + this(code, path, line, column, message, cause, null); + } + + /** + * 创建同时携带定位、根因和完整报告的 Skill 校验异常。 + * + * @param code 稳定错误码 + * @param path 文件路径 + * @param line 一基行号 + * @param column 一基列号 + * @param message 异常信息 + * @param cause 原始异常 + * @param report 完整结构化校验报告 + */ + public SkillValidationException(String code, String path, Integer line, Integer column, + String message, Throwable cause, SkillValidationReport report) { super(message, cause); + this.code = code; + this.path = path; + this.line = line; + this.column = column; + this.report = report; + } + + /** @return 稳定错误码 */ + public String getCode() { + return code; + } + + /** @return 文件路径 */ + public String getPath() { + return path; + } + + /** @return 一基行号 */ + public Integer getLine() { + return line; + } + + /** @return 一基列号 */ + public Integer getColumn() { + return column; + } + + /** @return 结构化校验报告 */ + public SkillValidationReport getReport() { + return report; } } diff --git a/easy-agents-skill/src/main/java/com/easyagents/skill/factory/SkillFactory.java b/easy-agents-skill/src/main/java/com/easyagents/skill/factory/SkillFactory.java index 2a899cd..207816e 100644 --- a/easy-agents-skill/src/main/java/com/easyagents/skill/factory/SkillFactory.java +++ b/easy-agents-skill/src/main/java/com/easyagents/skill/factory/SkillFactory.java @@ -2,6 +2,8 @@ package com.easyagents.skill.factory; import com.easyagents.skill.model.*; import com.easyagents.skill.util.SkillFrontmatter; +import com.easyagents.skill.util.SkillResources; +import com.easyagents.skill.validation.defaults.DefaultSkillValidator; import java.util.List; import java.util.Map; @@ -25,6 +27,49 @@ public final class SkillFactory { return create(id, skillContent, null, null, null); } + /** + * 基于 SKILL.md 内容创建并严格校验正式标准 Skill。 + * + * @param id Skill ID + * @param skillContent SKILL.md 原始内容 + * @return 通过正式标准校验的 Skill 聚合 + */ + public static Skill createStrict(String id, String skillContent) { + Skill skill = create(id, skillContent); + new DefaultSkillValidator().validate(skill); + return skill; + } + + /** + * 基于 SKILL.md 内容和通用资源创建 Skill。 + * + * @param id 仓储 ID,可为空 + * @param skillContent SKILL.md 原始内容 + * @param resources 通用资源列表 + * @return Skill 聚合 + */ + public static Skill createWithResources(String id, String skillContent, List resources) { + Skill skill = create(id, skillContent); + skill.setResources(resources); + SkillResources.refreshLegacyViews(skill); + return skill; + } + + /** + * 基于 SKILL.md 和通用资源创建并严格校验正式标准 Skill。 + * + * @param id 仓储 ID,可为空 + * @param skillContent SKILL.md 原始内容 + * @param resources 通用资源列表 + * @return 通过正式标准校验的 Skill 聚合 + */ + public static Skill createWithResourcesStrict(String id, String skillContent, + List resources) { + Skill skill = createWithResources(id, skillContent, resources); + new DefaultSkillValidator().validate(skill); + return skill; + } + /** * 基于 SKILL.md 内容和资源列表创建 Skill。 * @@ -37,16 +82,29 @@ public final class SkillFactory { */ public static Skill create(String id, String skillContent, List references, List scripts, List assets) { - Map values = SkillFrontmatter.parse(skillContent); + SkillDocument document = SkillFrontmatter.parseDocument(skillContent); + Map values = document.getFrontmatter().getValues(); + String name = requiredScalar(values, "name"); + String description = requiredScalar(values, "description"); Skill skill = new Skill(); skill.setId(id); - skill.setName(values.get("name").toString()); - skill.setDescription(values.get("description").toString()); + skill.setName(name); + skill.setDescription(description); skill.setMetadata(new SkillMetadata(values)); - skill.setSkillContent(skillContent); + skill.setDocument(document); skill.setReferences(references); skill.setScripts(scripts); skill.setAssets(assets); + skill.setResources(SkillResources.canonicalResources(skill)); return skill; } + + private static String requiredScalar(Map values, String key) { + Object value = values.get(key); + if (!(value instanceof String text) || text.isBlank()) { + throw new com.easyagents.skill.exception.SkillValidationException( + "SKILL.md frontmatter " + key + " must be a non-blank string."); + } + return text; + } } diff --git a/easy-agents-skill/src/main/java/com/easyagents/skill/model/Skill.java b/easy-agents-skill/src/main/java/com/easyagents/skill/model/Skill.java index e845538..0cd7f9a 100644 --- a/easy-agents-skill/src/main/java/com/easyagents/skill/model/Skill.java +++ b/easy-agents-skill/src/main/java/com/easyagents/skill/model/Skill.java @@ -1,5 +1,7 @@ package com.easyagents.skill.model; +import com.easyagents.skill.util.SkillResources; + import java.io.Serializable; import java.util.ArrayList; import java.util.List; @@ -12,10 +14,14 @@ public class Skill implements Serializable { private static final long serialVersionUID = 1L; private String id; + private String packageRoot; private String name; private String description; private SkillMetadata metadata = new SkillMetadata(); private String skillContent; + private SkillDocument document; + private List resources = new ArrayList<>(); + private boolean resourcesInitialized; private List references = new ArrayList<>(); private List scripts = new ArrayList<>(); private List assets = new ArrayList<>(); @@ -38,6 +44,24 @@ public class Skill implements Serializable { this.id = id; } + /** + * 获取导入包中的顶层目录名;该值不是仓储 ID。 + * + * @return 包目录名 + */ + public String getPackageRoot() { + return packageRoot; + } + + /** + * 设置导入包中的顶层目录名。 + * + * @param packageRoot 包目录名 + */ + public void setPackageRoot(String packageRoot) { + this.packageRoot = packageRoot; + } + /** * 获取名称。 * @@ -98,7 +122,7 @@ public class Skill implements Serializable { * @return SKILL.md 原始内容 */ public String getSkillContent() { - return skillContent; + return document == null ? skillContent : document.render(); } /** @@ -108,6 +132,69 @@ public class Skill implements Serializable { */ public void setSkillContent(String skillContent) { this.skillContent = skillContent; + this.document = null; + } + + /** + * 获取解析后的 SKILL.md 文档。 + * + * @return 解析后的文档 + */ + public SkillDocument getDocument() { + return document; + } + + /** + * 设置解析后的 SKILL.md 文档。 + * + * @param document 解析后的文档 + */ + public void setDocument(SkillDocument document) { + this.document = document; + this.skillContent = document == null ? null : document.render(); + if (document != null) { + java.util.Map values = document.getFrontmatter().getValues(); + this.metadata = new SkillMetadata(values); + this.name = values.get("name") instanceof String text ? text : null; + this.description = values.get("description") instanceof String text ? text : null; + } + } + + /** + * 获取通用资源列表。 + * + * @return 通用资源列表 + */ + public List getResources() { + if (!resourcesInitialized) { + resources = resources == null || resources.isEmpty() + ? new ArrayList<>(SkillResources.fromLegacyViews(this)) + : new ArrayList<>(resources); + resourcesInitialized = true; + } + return resources; + } + + /** + * 设置通用资源列表。 + * + * @param resources 通用资源列表 + */ + public void setResources(List resources) { + this.resources = resources == null ? new ArrayList<>() : new ArrayList<>(resources); + this.resourcesInitialized = true; + } + + /** + * 判断正式通用资源列表是否已被显式初始化。 + * + *

该标记用于区分“尚未迁移的旧资源视图”和“调用方明确设置的空资源列表”, + * 避免删除最后一个正式资源后又从旧兼容视图恢复该资源。

+ * + * @return 已显式设置通用资源列表时为 true + */ + public boolean isResourcesInitialized() { + return resourcesInitialized; } /** diff --git a/easy-agents-skill/src/main/java/com/easyagents/skill/model/SkillAsset.java b/easy-agents-skill/src/main/java/com/easyagents/skill/model/SkillAsset.java index e5bfda1..796d7c7 100644 --- a/easy-agents-skill/src/main/java/com/easyagents/skill/model/SkillAsset.java +++ b/easy-agents-skill/src/main/java/com/easyagents/skill/model/SkillAsset.java @@ -3,8 +3,11 @@ package com.easyagents.skill.model; import java.io.Serializable; /** - * Skill 静态资产。 + * Skill 静态资产兼容视图。 + * + * @deprecated 请使用 {@link SkillResource}。 */ +@Deprecated public class SkillAsset implements Serializable { private static final long serialVersionUID = 1L; diff --git a/easy-agents-skill/src/main/java/com/easyagents/skill/model/SkillDocument.java b/easy-agents-skill/src/main/java/com/easyagents/skill/model/SkillDocument.java new file mode 100644 index 0000000..9ac34ac --- /dev/null +++ b/easy-agents-skill/src/main/java/com/easyagents/skill/model/SkillDocument.java @@ -0,0 +1,207 @@ +package com.easyagents.skill.model; + +import com.easyagents.skill.util.SkillFrontmatter; +import com.easyagents.skill.validation.SkillValidationIssue; + +import java.io.Serial; +import java.io.Serializable; +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * 解析后的 SKILL.md 文档,保留原文并支持确定性重建。 + */ +public class SkillDocument implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + private String rawContent; + private SkillMetadata frontmatter = new SkillMetadata(); + private String markdownBody = ""; + private Map frontmatterLocations = new LinkedHashMap<>(); + private List diagnostics = new ArrayList<>(); + private boolean modified; + + /** + * 创建空文档。 + */ + public SkillDocument() { + } + + /** + * 创建已解析文档。 + * + * @param rawContent 原始完整内容 + * @param frontmatter frontmatter 有序对象 + * @param markdownBody Markdown 正文 + */ + public SkillDocument(String rawContent, Map frontmatter, String markdownBody) { + this(rawContent, frontmatter, markdownBody, Map.of(), List.of()); + } + + /** + * 创建包含源码定位和解析诊断的已解析文档。 + * + * @param rawContent 原始完整内容 + * @param frontmatter frontmatter 有序对象 + * @param markdownBody Markdown 正文 + * @param frontmatterLocations 顶层 frontmatter 字段源码位置 + * @param diagnostics 解析阶段产生的结构化诊断 + */ + public SkillDocument(String rawContent, Map frontmatter, String markdownBody, + Map frontmatterLocations, + List diagnostics) { + this.rawContent = rawContent; + this.frontmatter = new SkillMetadata(frontmatter); + this.markdownBody = markdownBody == null ? "" : markdownBody; + this.frontmatterLocations = frontmatterLocations == null + ? new LinkedHashMap<>() : new LinkedHashMap<>(frontmatterLocations); + this.diagnostics = diagnostics == null ? new ArrayList<>() : new ArrayList<>(diagnostics); + } + + /** + * 获取原始完整内容。 + * + * @return 原始内容 + */ + public String getRawContent() { + return rawContent; + } + + /** + * 设置原始完整内容。 + * + * @param rawContent 原始内容 + */ + public void setRawContent(String rawContent) { + this.rawContent = rawContent; + } + + /** + * 获取 frontmatter 防御性副本;修改文档请使用 {@link #putFrontmatter(String, Object)}、 + * {@link #removeFrontmatter(String)} 或 {@link #setFrontmatter(SkillMetadata)}。 + * + * @return frontmatter 有序对象副本 + */ + public SkillMetadata getFrontmatter() { + return new SkillMetadata(frontmatter.getValues()); + } + + /** + * 设置 frontmatter 并标记文档已修改。 + * + * @param frontmatter frontmatter 有序对象 + */ + public void setFrontmatter(SkillMetadata frontmatter) { + this.frontmatter = frontmatter == null + ? new SkillMetadata() : new SkillMetadata(frontmatter.getValues()); + this.frontmatterLocations.clear(); + this.modified = true; + } + + /** + * 写入一个 frontmatter 字段并标记文档已修改。 + * + * @param key 字段名 + * @param value 字段值 + */ + public void putFrontmatter(String key, Object value) { + frontmatter.put(key, value); + frontmatterLocations.remove(key); + modified = true; + } + + /** + * 删除一个 frontmatter 字段并标记文档已修改。 + * + * @param key 字段名 + * @return 被删除值的防御性副本 + */ + public Object removeFrontmatter(String key) { + Object removed = frontmatter.remove(key); + frontmatterLocations.remove(key); + modified = true; + return removed; + } + + /** + * 获取顶层 frontmatter 字段的源码位置。 + * + * @param key frontmatter 字段名 + * @return 源码位置;字段不存在或已被结构化修改时为空 + */ + public SkillSourceLocation getFrontmatterLocation(String key) { + return key == null ? null : frontmatterLocations.get(key); + } + + /** + * 获取顶层 frontmatter 字段源码位置的不可变副本。 + * + * @return 字段与源码位置映射 + */ + public Map getFrontmatterLocations() { + return Collections.unmodifiableMap(new LinkedHashMap<>(frontmatterLocations)); + } + + /** + * 获取解析阶段结构化诊断的不可变副本。 + * + * @return 解析诊断 + */ + public List getDiagnostics() { + return Collections.unmodifiableList(new ArrayList<>(diagnostics)); + } + + /** + * 获取 Markdown 正文。 + * + * @return Markdown 正文 + */ + public String getMarkdownBody() { + return markdownBody; + } + + /** + * 设置 Markdown 正文并标记文档已修改。 + * + * @param markdownBody Markdown 正文 + */ + public void setMarkdownBody(String markdownBody) { + this.markdownBody = markdownBody == null ? "" : markdownBody; + this.modified = true; + } + + /** + * 判断结构化内容是否被修改。 + * + * @return 已修改时为 true + */ + public boolean isModified() { + return modified; + } + + /** + * 设置修改标记。 + * + * @param modified 修改标记 + */ + public void setModified(boolean modified) { + this.modified = modified; + } + + /** + * 渲染完整 SKILL.md;未修改时优先返回原文。 + * + * @return 完整 SKILL.md + */ + public String render() { + if (!modified && rawContent != null) { + return rawContent; + } + return SkillFrontmatter.serialize(frontmatter.getValues(), markdownBody); + } +} diff --git a/easy-agents-skill/src/main/java/com/easyagents/skill/model/SkillMetadata.java b/easy-agents-skill/src/main/java/com/easyagents/skill/model/SkillMetadata.java index f05e768..722a703 100644 --- a/easy-agents-skill/src/main/java/com/easyagents/skill/model/SkillMetadata.java +++ b/easy-agents-skill/src/main/java/com/easyagents/skill/model/SkillMetadata.java @@ -1,7 +1,14 @@ package com.easyagents.skill.model; +import com.easyagents.skill.exception.SkillValidationException; + import java.io.Serializable; +import java.math.BigDecimal; +import java.math.BigInteger; +import java.util.ArrayList; +import java.util.Date; import java.util.LinkedHashMap; +import java.util.List; import java.util.Map; /** @@ -29,12 +36,12 @@ public class SkillMetadata implements Serializable { } /** - * 获取元数据键值。 + * 获取元数据键值的递归防御性副本。 * - * @return 元数据键值 + * @return 元数据键值副本 */ public Map getValues() { - return values; + return deepCopyMap(values); } /** @@ -43,7 +50,7 @@ public class SkillMetadata implements Serializable { * @param values 元数据键值 */ public void setValues(Map values) { - this.values = values == null ? new LinkedHashMap<>() : new LinkedHashMap<>(values); + this.values = values == null ? new LinkedHashMap<>() : deepCopyMap(values); } /** @@ -53,9 +60,10 @@ public class SkillMetadata implements Serializable { * @param value 元数据值 */ public void put(String key, Object value) { - if (key != null && !key.isBlank()) { - values.put(key, value); + if (key == null || key.isBlank()) { + throw new SkillValidationException("Skill metadata key must be a non-blank string."); } + values.put(key, deepCopyValue(value)); } /** @@ -65,7 +73,17 @@ public class SkillMetadata implements Serializable { * @return 元数据值 */ public Object get(String key) { - return values.get(key); + return deepCopyValue(values.get(key)); + } + + /** + * 删除一个元数据键。 + * + * @param key 元数据键 + * @return 被删除值的防御性副本 + */ + public Object remove(String key) { + return deepCopyValue(values.remove(key)); } /** @@ -76,4 +94,37 @@ public class SkillMetadata implements Serializable { public boolean isEmpty() { return values.isEmpty(); } + + private static LinkedHashMap deepCopyMap(Map source) { + LinkedHashMap target = new LinkedHashMap<>(); + for (Map.Entry entry : source.entrySet()) { + if (!(entry.getKey() instanceof String key) || key.isBlank()) { + throw new SkillValidationException("Skill metadata keys must be non-blank strings."); + } + target.put(key, deepCopyValue(entry.getValue())); + } + return target; + } + + private static Object deepCopyValue(Object value) { + if (value == null || value instanceof String || value instanceof Boolean + || value instanceof Byte || value instanceof Short || value instanceof Integer + || value instanceof Long || value instanceof Float || value instanceof Double + || value instanceof BigInteger || value instanceof BigDecimal || value instanceof Character) { + return value; + } + if (value instanceof Date date) { + return new Date(date.getTime()); + } + if (value instanceof Map map) { + return deepCopyMap(map); + } + if (value instanceof List list) { + List copy = new ArrayList<>(list.size()); + list.forEach(item -> copy.add(deepCopyValue(item))); + return copy; + } + throw new SkillValidationException("Unsupported Skill metadata value type: " + + value.getClass().getName()); + } } diff --git a/easy-agents-skill/src/main/java/com/easyagents/skill/model/SkillPackage.java b/easy-agents-skill/src/main/java/com/easyagents/skill/model/SkillPackage.java new file mode 100644 index 0000000..179d007 --- /dev/null +++ b/easy-agents-skill/src/main/java/com/easyagents/skill/model/SkillPackage.java @@ -0,0 +1,71 @@ +package com.easyagents.skill.model; + +import java.io.Serial; +import java.io.Serializable; +import java.util.ArrayList; +import java.util.List; + +/** + * 可移植 Skill 包聚合。 + */ +public class SkillPackage implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + private SkillPackageLayout layout = SkillPackageLayout.SINGLE_DIRECTORY; + private List skills = new ArrayList<>(); + + /** + * 创建空 Skill 包。 + */ + public SkillPackage() { + } + + /** + * 创建 Skill 包。 + * + * @param layout 包结构形态 + * @param skills Skill 列表 + */ + public SkillPackage(SkillPackageLayout layout, List skills) { + setLayout(layout); + setSkills(skills); + } + + /** + * 获取包结构形态。 + * + * @return 包结构形态 + */ + public SkillPackageLayout getLayout() { + return layout; + } + + /** + * 设置包结构形态。 + * + * @param layout 包结构形态 + */ + public void setLayout(SkillPackageLayout layout) { + this.layout = layout == null ? SkillPackageLayout.SINGLE_DIRECTORY : layout; + } + + /** + * 获取 Skill 列表。 + * + * @return Skill 列表 + */ + public List getSkills() { + return skills; + } + + /** + * 设置 Skill 列表。 + * + * @param skills Skill 列表 + */ + public void setSkills(List skills) { + this.skills = skills == null ? new ArrayList<>() : new ArrayList<>(skills); + } +} diff --git a/easy-agents-skill/src/main/java/com/easyagents/skill/model/SkillPackageLayout.java b/easy-agents-skill/src/main/java/com/easyagents/skill/model/SkillPackageLayout.java new file mode 100644 index 0000000..07f58a1 --- /dev/null +++ b/easy-agents-skill/src/main/java/com/easyagents/skill/model/SkillPackageLayout.java @@ -0,0 +1,16 @@ +package com.easyagents.skill.model; + +/** + * Skill ZIP 的结构形态。 + */ +public enum SkillPackageLayout { + + /** 根目录直接包含 SKILL.md 的单 Skill 包。 */ + ROOT_SKILL, + + /** 一个顶层目录包装的单 Skill 包。 */ + SINGLE_DIRECTORY, + + /** 多个顶层 Skill 目录组成的批量包。 */ + MULTI_DIRECTORY +} diff --git a/easy-agents-skill/src/main/java/com/easyagents/skill/model/SkillPackageLimits.java b/easy-agents-skill/src/main/java/com/easyagents/skill/model/SkillPackageLimits.java new file mode 100644 index 0000000..7e4946b --- /dev/null +++ b/easy-agents-skill/src/main/java/com/easyagents/skill/model/SkillPackageLimits.java @@ -0,0 +1,274 @@ +package com.easyagents.skill.model; + +import com.easyagents.skill.exception.SkillValidationException; + +import java.io.Serial; +import java.io.Serializable; + +/** + * Skill 包读取安全限额;调用方只能在底层硬上限内收紧或调整。 + */ +public final class SkillPackageLimits implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + private static final int HARD_MAX_ENTRY_COUNT = 10_000; + private static final long HARD_MAX_TEXT_FILE_BYTES = 8L * 1024 * 1024; + private static final long HARD_MAX_BINARY_FILE_BYTES = 200L * 1024 * 1024; + private static final long HARD_MAX_TOTAL_BYTES = 512L * 1024 * 1024; + private static final long HARD_MAX_COMPRESSED_BYTES = 512L * 1024 * 1024; + private static final int HARD_MAX_PATH_LENGTH = 1_024; + private static final int HARD_MAX_PATH_DEPTH = 32; + private static final double HARD_MAX_COMPRESSION_RATIO = 200D; + private static final long HARD_MAX_FRONTMATTER_BYTES = 256L * 1024; + private static final int HARD_MAX_YAML_ALIASES = 50; + private static final int HARD_MAX_YAML_DEPTH = 20; + private static final int HARD_MAX_YAML_CODE_POINTS = 262_144; + + private final int maxEntryCount; + private final long maxTextFileBytes; + private final long maxBinaryFileBytes; + private final long maxTotalUncompressedBytes; + private final long maxCompressedPackageBytes; + private final int maxPathLength; + private final int maxPathDepth; + private final double maxCompressionRatio; + private final long maxFrontmatterBytes; + private final int maxYamlAliases; + private final int maxYamlDepth; + private final int maxYamlCodePoints; + + private SkillPackageLimits(Builder builder) { + maxEntryCount = positive(builder.maxEntryCount, HARD_MAX_ENTRY_COUNT, "maxEntryCount"); + maxTextFileBytes = positive(builder.maxTextFileBytes, HARD_MAX_TEXT_FILE_BYTES, "maxTextFileBytes"); + maxBinaryFileBytes = positive(builder.maxBinaryFileBytes, HARD_MAX_BINARY_FILE_BYTES, + "maxBinaryFileBytes"); + maxTotalUncompressedBytes = positive(builder.maxTotalUncompressedBytes, HARD_MAX_TOTAL_BYTES, + "maxTotalUncompressedBytes"); + maxCompressedPackageBytes = positive(builder.maxCompressedPackageBytes, HARD_MAX_COMPRESSED_BYTES, + "maxCompressedPackageBytes"); + maxPathLength = positive(builder.maxPathLength, HARD_MAX_PATH_LENGTH, "maxPathLength"); + maxPathDepth = positive(builder.maxPathDepth, HARD_MAX_PATH_DEPTH, "maxPathDepth"); + maxCompressionRatio = positive(builder.maxCompressionRatio, HARD_MAX_COMPRESSION_RATIO, + "maxCompressionRatio"); + maxFrontmatterBytes = positive(builder.maxFrontmatterBytes, HARD_MAX_FRONTMATTER_BYTES, + "maxFrontmatterBytes"); + maxYamlAliases = nonNegative(builder.maxYamlAliases, HARD_MAX_YAML_ALIASES, "maxYamlAliases"); + maxYamlDepth = positive(builder.maxYamlDepth, HARD_MAX_YAML_DEPTH, "maxYamlDepth"); + maxYamlCodePoints = positive(builder.maxYamlCodePoints, HARD_MAX_YAML_CODE_POINTS, + "maxYamlCodePoints"); + if (maxTextFileBytes > maxTotalUncompressedBytes || maxBinaryFileBytes > maxTotalUncompressedBytes) { + throw new SkillValidationException("Single-file limits cannot exceed the total uncompressed limit."); + } + } + + /** + * 创建默认限额。 + * + * @return 默认限额 + */ + public static SkillPackageLimits defaults() { + return builder().build(); + } + + /** + * 创建限额构建器。 + * + * @return 限额构建器 + */ + public static Builder builder() { + return new Builder(); + } + + /** @return 最大 ZIP entry 数 */ + public int getMaxEntryCount() { + return maxEntryCount; + } + + /** @return 单文本文件最大字节数 */ + public long getMaxTextFileBytes() { + return maxTextFileBytes; + } + + /** @return 单二进制文件最大字节数 */ + public long getMaxBinaryFileBytes() { + return maxBinaryFileBytes; + } + + /** @return 包总未压缩最大字节数 */ + public long getMaxTotalUncompressedBytes() { + return maxTotalUncompressedBytes; + } + + /** @return 输入 ZIP 最大压缩字节数 */ + public long getMaxCompressedPackageBytes() { + return maxCompressedPackageBytes; + } + + /** @return 单路径最大字符数 */ + public int getMaxPathLength() { + return maxPathLength; + } + + /** @return 最大目录深度 */ + public int getMaxPathDepth() { + return maxPathDepth; + } + + /** @return 单 entry 最大压缩比 */ + public double getMaxCompressionRatio() { + return maxCompressionRatio; + } + + /** @return frontmatter 最大字节数 */ + public long getMaxFrontmatterBytes() { + return maxFrontmatterBytes; + } + + /** @return YAML collection 最大 alias 数 */ + public int getMaxYamlAliases() { + return maxYamlAliases; + } + + /** @return YAML 最大嵌套深度 */ + public int getMaxYamlDepth() { + return maxYamlDepth; + } + + /** @return YAML 最大 Unicode code point 数 */ + public int getMaxYamlCodePoints() { + return maxYamlCodePoints; + } + + private static int positive(int value, int hardMax, String name) { + if (value <= 0 || value > hardMax) { + throw new SkillValidationException(name + " must be between 1 and " + hardMax + "."); + } + return value; + } + + private static int nonNegative(int value, int hardMax, String name) { + if (value < 0 || value > hardMax) { + throw new SkillValidationException(name + " must be between 0 and " + hardMax + "."); + } + return value; + } + + private static long positive(long value, long hardMax, String name) { + if (value <= 0 || value > hardMax) { + throw new SkillValidationException(name + " must be between 1 and " + hardMax + "."); + } + return value; + } + + private static double positive(double value, double hardMax, String name) { + if (!Double.isFinite(value) || value <= 0D || value > hardMax) { + throw new SkillValidationException(name + " must be between 0 and " + hardMax + "."); + } + return value; + } + + /** + * Skill 包限额构建器。 + */ + public static final class Builder { + + private int maxEntryCount = 2_000; + private long maxTextFileBytes = 2L * 1024 * 1024; + private long maxBinaryFileBytes = 50L * 1024 * 1024; + private long maxTotalUncompressedBytes = 200L * 1024 * 1024; + private long maxCompressedPackageBytes = 100L * 1024 * 1024; + private int maxPathLength = 512; + private int maxPathDepth = 16; + private double maxCompressionRatio = 100D; + private long maxFrontmatterBytes = 64L * 1024; + private int maxYamlAliases = 10; + private int maxYamlDepth = 10; + private int maxYamlCodePoints = 65_536; + + private Builder() { + } + + /** @param value 最大 entry 数 @return 当前构建器 */ + public Builder maxEntryCount(int value) { + maxEntryCount = value; + return this; + } + + /** @param value 单文本文件最大字节数 @return 当前构建器 */ + public Builder maxTextFileBytes(long value) { + maxTextFileBytes = value; + return this; + } + + /** @param value 单二进制文件最大字节数 @return 当前构建器 */ + public Builder maxBinaryFileBytes(long value) { + maxBinaryFileBytes = value; + return this; + } + + /** @param value 包总未压缩最大字节数 @return 当前构建器 */ + public Builder maxTotalUncompressedBytes(long value) { + maxTotalUncompressedBytes = value; + return this; + } + + /** @param value 输入 ZIP 最大压缩字节数 @return 当前构建器 */ + public Builder maxCompressedPackageBytes(long value) { + maxCompressedPackageBytes = value; + return this; + } + + /** @param value 单路径最大字符数 @return 当前构建器 */ + public Builder maxPathLength(int value) { + maxPathLength = value; + return this; + } + + /** @param value 最大目录深度 @return 当前构建器 */ + public Builder maxPathDepth(int value) { + maxPathDepth = value; + return this; + } + + /** @param value 单 entry 最大压缩比 @return 当前构建器 */ + public Builder maxCompressionRatio(double value) { + maxCompressionRatio = value; + return this; + } + + /** @param value frontmatter 最大字节数 @return 当前构建器 */ + public Builder maxFrontmatterBytes(long value) { + maxFrontmatterBytes = value; + return this; + } + + /** @param value YAML alias 最大数量 @return 当前构建器 */ + public Builder maxYamlAliases(int value) { + maxYamlAliases = value; + return this; + } + + /** @param value YAML 最大嵌套深度 @return 当前构建器 */ + public Builder maxYamlDepth(int value) { + maxYamlDepth = value; + return this; + } + + /** @param value YAML 最大 code point 数 @return 当前构建器 */ + public Builder maxYamlCodePoints(int value) { + maxYamlCodePoints = value; + return this; + } + + /** + * 构建并校验限额。 + * + * @return 不可变限额 + */ + public SkillPackageLimits build() { + return new SkillPackageLimits(this); + } + } +} diff --git a/easy-agents-skill/src/main/java/com/easyagents/skill/model/SkillReference.java b/easy-agents-skill/src/main/java/com/easyagents/skill/model/SkillReference.java index c27af7e..80657d1 100644 --- a/easy-agents-skill/src/main/java/com/easyagents/skill/model/SkillReference.java +++ b/easy-agents-skill/src/main/java/com/easyagents/skill/model/SkillReference.java @@ -3,8 +3,11 @@ package com.easyagents.skill.model; import java.io.Serializable; /** - * Skill Markdown 参考文档。 + * Skill Markdown 参考文档兼容视图。 + * + * @deprecated 请使用 {@link SkillResource}。 */ +@Deprecated public class SkillReference implements Serializable { private static final long serialVersionUID = 1L; diff --git a/easy-agents-skill/src/main/java/com/easyagents/skill/model/SkillResource.java b/easy-agents-skill/src/main/java/com/easyagents/skill/model/SkillResource.java new file mode 100644 index 0000000..cc950fe --- /dev/null +++ b/easy-agents-skill/src/main/java/com/easyagents/skill/model/SkillResource.java @@ -0,0 +1,175 @@ +package com.easyagents.skill.model; + +import java.io.Serial; +import java.io.Serializable; + +/** + * Skill 根目录下的通用安全资源。 + */ +public class SkillResource implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + private String path; + private SkillResourceKind kind = SkillResourceKind.OTHER; + private String mediaType; + private String textContent; + private String contentRef; + private String contentHash; + private long size; + private SkillMetadata metadata = new SkillMetadata(); + + /** + * 获取 Skill 根目录相对路径。 + * + * @return 资源路径 + */ + public String getPath() { + return path; + } + + /** + * 设置 Skill 根目录相对路径。 + * + * @param path 资源路径 + */ + public void setPath(String path) { + this.path = path; + } + + /** + * 获取资源语义类型。 + * + * @return 资源语义类型 + */ + public SkillResourceKind getKind() { + return kind; + } + + /** + * 设置资源语义类型。 + * + * @param kind 资源语义类型 + */ + public void setKind(SkillResourceKind kind) { + this.kind = kind == null ? SkillResourceKind.OTHER : kind; + } + + /** + * 获取媒体类型。 + * + * @return 媒体类型 + */ + public String getMediaType() { + return mediaType; + } + + /** + * 设置媒体类型。 + * + * @param mediaType 媒体类型 + */ + public void setMediaType(String mediaType) { + this.mediaType = mediaType; + } + + /** + * 获取严格 UTF-8 文本内容。 + * + * @return 文本内容,二进制资源返回 null + */ + public String getTextContent() { + return textContent; + } + + /** + * 设置严格 UTF-8 文本内容。 + * + * @param textContent 文本内容 + */ + public void setTextContent(String textContent) { + this.textContent = textContent; + } + + /** + * 获取二进制内容引用。 + * + * @return 内容引用,文本资源通常返回 null + */ + public String getContentRef() { + return contentRef; + } + + /** + * 设置二进制内容引用。 + * + * @param contentRef 内容引用 + */ + public void setContentRef(String contentRef) { + this.contentRef = contentRef; + } + + /** + * 获取内容 SHA-256。 + * + * @return 十六进制 SHA-256 + */ + public String getContentHash() { + return contentHash; + } + + /** + * 设置内容 SHA-256。 + * + * @param contentHash 十六进制 SHA-256 + */ + public void setContentHash(String contentHash) { + this.contentHash = contentHash; + } + + /** + * 获取原始字节大小。 + * + * @return 字节大小 + */ + public long getSize() { + return size; + } + + /** + * 设置原始字节大小。 + * + * @param size 字节大小 + */ + public void setSize(long size) { + this.size = size; + } + + /** + * 获取资源扩展元数据。 + * + * @return 扩展元数据 + */ + public SkillMetadata getMetadata() { + return metadata; + } + + /** + * 设置资源扩展元数据。 + * + * @param metadata 扩展元数据 + */ + public void setMetadata(SkillMetadata metadata) { + this.metadata = metadata == null ? new SkillMetadata() : metadata; + } + + /** + * 判断资源是否以内联文本保存。 + * + * @return 包含文本内容时为 true + */ + public boolean isText() { + return textContent != null; + } +} diff --git a/easy-agents-skill/src/main/java/com/easyagents/skill/model/SkillResourceKind.java b/easy-agents-skill/src/main/java/com/easyagents/skill/model/SkillResourceKind.java new file mode 100644 index 0000000..59ac451 --- /dev/null +++ b/easy-agents-skill/src/main/java/com/easyagents/skill/model/SkillResourceKind.java @@ -0,0 +1,22 @@ +package com.easyagents.skill.model; + +/** + * Skill 资源语义类型。 + */ +public enum SkillResourceKind { + + /** 参考资料。 */ + REFERENCE, + + /** 脚本源码,仅保存且不执行。 */ + SCRIPT, + + /** 静态资产。 */ + ASSET, + + /** 使用示例。 */ + EXAMPLE, + + /** 其他安全资源。 */ + OTHER +} diff --git a/easy-agents-skill/src/main/java/com/easyagents/skill/model/SkillScript.java b/easy-agents-skill/src/main/java/com/easyagents/skill/model/SkillScript.java index edab344..4679906 100644 --- a/easy-agents-skill/src/main/java/com/easyagents/skill/model/SkillScript.java +++ b/easy-agents-skill/src/main/java/com/easyagents/skill/model/SkillScript.java @@ -3,8 +3,11 @@ package com.easyagents.skill.model; import java.io.Serializable; /** - * Skill 脚本源码。 + * Skill 脚本源码兼容视图。 + * + * @deprecated 请使用 {@link SkillResource}。 */ +@Deprecated public class SkillScript implements Serializable { private static final long serialVersionUID = 1L; diff --git a/easy-agents-skill/src/main/java/com/easyagents/skill/model/SkillSourceLocation.java b/easy-agents-skill/src/main/java/com/easyagents/skill/model/SkillSourceLocation.java new file mode 100644 index 0000000..4b0a5a2 --- /dev/null +++ b/easy-agents-skill/src/main/java/com/easyagents/skill/model/SkillSourceLocation.java @@ -0,0 +1,40 @@ +package com.easyagents.skill.model; + +import java.io.Serial; +import java.io.Serializable; + +/** + * SKILL.md 中的一基源码位置。 + */ +public final class SkillSourceLocation implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + private final int line; + private final int column; + + /** + * 创建源码位置。 + * + * @param line 一基行号 + * @param column 一基列号 + */ + public SkillSourceLocation(int line, int column) { + if (line < 1 || column < 1) { + throw new IllegalArgumentException("Skill source location must be one-based."); + } + this.line = line; + this.column = column; + } + + /** @return 一基行号 */ + public int getLine() { + return line; + } + + /** @return 一基列号 */ + public int getColumn() { + return column; + } +} diff --git a/easy-agents-skill/src/main/java/com/easyagents/skill/repository/memory/InMemorySkillRepository.java b/easy-agents-skill/src/main/java/com/easyagents/skill/repository/memory/InMemorySkillRepository.java index 60795d1..867e1b4 100644 --- a/easy-agents-skill/src/main/java/com/easyagents/skill/repository/memory/InMemorySkillRepository.java +++ b/easy-agents-skill/src/main/java/com/easyagents/skill/repository/memory/InMemorySkillRepository.java @@ -92,16 +92,54 @@ public class InMemorySkillRepository implements SkillRepository { private static Skill copySkill(Skill source) { Skill target = new Skill(); target.setId(source.getId()); + target.setPackageRoot(source.getPackageRoot()); target.setName(source.getName()); target.setDescription(source.getDescription()); target.setMetadata(copyMetadata(source.getMetadata())); - target.setSkillContent(source.getSkillContent()); + SkillDocument copiedDocument = copyDocument(source.getDocument()); + if (copiedDocument == null) { + target.setSkillContent(source.getSkillContent()); + } else { + target.setDocument(copiedDocument); + } + target.setResources(copyResources(source.getResources())); target.setReferences(copyReferences(source.getReferences())); target.setScripts(copyScripts(source.getScripts())); target.setAssets(copyAssets(source.getAssets())); return target; } + private static SkillDocument copyDocument(SkillDocument source) { + if (source == null) { + return null; + } + SkillDocument target = new SkillDocument(source.getRawContent(), + source.getFrontmatter().getValues(), source.getMarkdownBody(), + source.getFrontmatterLocations(), source.getDiagnostics()); + target.setModified(source.isModified()); + return target; + } + + private static List copyResources(List sources) { + List targets = new ArrayList<>(); + if (sources == null) { + return targets; + } + for (SkillResource source : sources) { + SkillResource target = new SkillResource(); + target.setPath(source.getPath()); + target.setKind(source.getKind()); + target.setMediaType(source.getMediaType()); + target.setTextContent(source.getTextContent()); + target.setContentRef(source.getContentRef()); + target.setContentHash(source.getContentHash()); + target.setSize(source.getSize()); + target.setMetadata(copyMetadata(source.getMetadata())); + targets.add(target); + } + return targets; + } + private static List copyReferences(List sources) { List targets = new ArrayList<>(); if (sources == null) { diff --git a/easy-agents-skill/src/main/java/com/easyagents/skill/store/SkillContentStage.java b/easy-agents-skill/src/main/java/com/easyagents/skill/store/SkillContentStage.java new file mode 100644 index 0000000..c94e138 --- /dev/null +++ b/easy-agents-skill/src/main/java/com/easyagents/skill/store/SkillContentStage.java @@ -0,0 +1,76 @@ +package com.easyagents.skill.store; + +import java.nio.file.Path; +import java.util.Objects; + +/** + * 尚未提交的 Skill 二进制内容阶段结果。 + */ +public final class SkillContentStage { + + private final String stageId; + private final String contentRef; + private final String contentHash; + private final long size; + private final boolean alreadyCommitted; + private final Path compatibilityPath; + + /** + * 创建内容阶段结果。 + * + * @param stageId 暂存标识 + * @param contentRef 最终内容引用 + * @param contentHash SHA-256 + * @param size 字节大小 + * @param alreadyCommitted 是否由兼容实现提前写入正式存储 + */ + public SkillContentStage(String stageId, String contentRef, String contentHash, + long size, boolean alreadyCommitted) { + this(stageId, contentRef, contentHash, size, alreadyCommitted, null); + } + + private SkillContentStage(String stageId, String contentRef, String contentHash, + long size, boolean alreadyCommitted, Path compatibilityPath) { + this.stageId = Objects.requireNonNull(stageId, "stageId"); + this.contentRef = Objects.requireNonNull(contentRef, "contentRef"); + this.contentHash = Objects.requireNonNull(contentHash, "contentHash"); + this.size = size; + this.alreadyCommitted = alreadyCommitted; + this.compatibilityPath = compatibilityPath; + } + + /** @return 暂存标识 */ + public String getStageId() { + return stageId; + } + + /** @return 最终内容引用 */ + public String getContentRef() { + return contentRef; + } + + /** @return SHA-256 */ + public String getContentHash() { + return contentHash; + } + + /** @return 字节大小 */ + public long getSize() { + return size; + } + + /** @return 已提前提交时为 true */ + public boolean isAlreadyCommitted() { + return alreadyCommitted; + } + + static SkillContentStage compatibility(Path path, String contentHash, long size) { + String contentRef = "sha256:" + contentHash; + return new SkillContentStage(contentRef, contentRef, contentHash, + size, false, path); + } + + Path compatibilityPath() { + return compatibilityPath; + } +} diff --git a/easy-agents-skill/src/main/java/com/easyagents/skill/store/SkillContentStore.java b/easy-agents-skill/src/main/java/com/easyagents/skill/store/SkillContentStore.java index e712c4e..99f39af 100644 --- a/easy-agents-skill/src/main/java/com/easyagents/skill/store/SkillContentStore.java +++ b/easy-agents-skill/src/main/java/com/easyagents/skill/store/SkillContentStore.java @@ -1,9 +1,19 @@ package com.easyagents.skill.store; +import com.easyagents.skill.exception.SkillException; +import com.easyagents.skill.util.SkillHashes; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardOpenOption; +import java.security.DigestOutputStream; +import java.security.MessageDigest; /** - * Skill 二进制内容存储接口。 + * Skill 二进制内容流式存储与引用生命周期接口。 */ public interface SkillContentStore { @@ -15,6 +25,123 @@ public interface SkillContentStore { */ String put(byte[] bytes); + /** + * 流式保存内容并返回内容引用。 + * + *

兼容默认实现仅缓存单个文件;正式持久化实现应覆盖该方法以直接流式写入。

+ * + * @param inputStream 内容流,不由本方法关闭 + * @param maxBytes 最大允许字节数 + * @return 内容引用 + */ + default String put(InputStream inputStream, long maxBytes) { + return put(readBounded(inputStream, maxBytes)); + } + + /** + * 暂存内容,供完成全包校验后统一提交。 + * + *

为兼容旧实现,默认实现会立即写入;正式实现应覆盖并提供真实暂存区。

+ * + * @param inputStream 内容流,不由本方法关闭 + * @param maxBytes 最大允许字节数 + * @return 暂存结果 + */ + default SkillContentStage stage(InputStream inputStream, long maxBytes) { + if (inputStream == null || maxBytes < 0) { + throw new SkillException("Valid Skill content stream and limit are required."); + } + Path temporaryFile = null; + try { + temporaryFile = Files.createTempFile("easy-agents-skill-content-", ".stage"); + MessageDigest digest = SkillHashes.newSha256Digest(); + long total = 0; + try (DigestOutputStream output = new DigestOutputStream( + Files.newOutputStream(temporaryFile, StandardOpenOption.TRUNCATE_EXISTING), digest)) { + byte[] buffer = new byte[16 * 1024]; + int read; + while ((read = inputStream.read(buffer)) >= 0) { + if (read == 0) { + continue; + } + if (total > maxBytes - read) { + throw new SkillException("Skill content exceeds " + maxBytes + " bytes."); + } + output.write(buffer, 0, read); + total += read; + } + } + return SkillContentStage.compatibility( + temporaryFile, SkillHashes.toHex(digest.digest()), total); + } catch (IOException | RuntimeException e) { + deleteTemporaryFile(temporaryFile); + if (e instanceof SkillException skillException) { + throw skillException; + } + throw new SkillException("Failed to stage Skill content stream.", e); + } + } + + /** + * 提交暂存内容。 + * + * @param stage 暂存结果 + * @return 最终内容引用 + */ + default String commit(SkillContentStage stage) { + if (stage == null) { + throw new SkillException("Skill content stage is required."); + } + Path compatibilityPath = stage.compatibilityPath(); + if (compatibilityPath != null) { + try (InputStream input = Files.newInputStream(compatibilityPath)) { + String contentRef = put(input, stage.getSize()); + if (!stage.getContentRef().equals(contentRef)) { + release(contentRef); + throw new SkillException("Skill content store returned a non-hash content reference."); + } + return contentRef; + } catch (IOException e) { + throw new SkillException("Failed to commit staged Skill content.", e); + } finally { + deleteTemporaryFile(compatibilityPath); + } + } + return stage.getContentRef(); + } + + /** + * 回滚尚未提交的内容。 + * + * @param stage 暂存结果 + */ + default void rollback(SkillContentStage stage) { + if (stage != null) { + deleteTemporaryFile(stage.compatibilityPath()); + if (stage.isAlreadyCommitted()) { + release(stage.getContentRef()); + } + } + } + + /** + * 增加正式内容引用计数。 + * + * @param contentRef 内容引用 + */ + default void retain(String contentRef) { + // 旧实现没有引用计数,保留兼容空操作。 + } + + /** + * 释放正式内容引用;引用归零后实现可以删除物理内容。 + * + * @param contentRef 内容引用 + */ + default void release(String contentRef) { + // 旧实现没有引用计数,保留兼容空操作。 + } + /** * 打开内容流。 * @@ -38,4 +165,43 @@ public interface SkillContentStore { * @return 存在时为 true */ boolean exists(String contentRef); + + private static byte[] readBounded(InputStream inputStream, long maxBytes) { + if (inputStream == null) { + throw new SkillException("Skill content input stream is required."); + } + if (maxBytes < 0) { + throw new SkillException("Skill content max bytes cannot be negative."); + } + try { + ByteArrayOutputStream output = new ByteArrayOutputStream((int) Math.min(maxBytes, 8_192)); + byte[] buffer = new byte[8_192]; + long total = 0; + int read; + while ((read = inputStream.read(buffer)) >= 0) { + if (read == 0) { + continue; + } + total += read; + if (total > maxBytes) { + throw new SkillException("Skill content exceeds " + maxBytes + " bytes."); + } + output.write(buffer, 0, read); + } + return output.toByteArray(); + } catch (IOException e) { + throw new SkillException("Failed to read Skill content stream.", e); + } + } + + private static void deleteTemporaryFile(Path path) { + if (path == null) { + return; + } + try { + Files.deleteIfExists(path); + } catch (IOException ignored) { + // 临时文件清理由操作系统兜底,调用方异常语义保持不变。 + } + } } diff --git a/easy-agents-skill/src/main/java/com/easyagents/skill/store/file/TemporaryFileSkillContentStore.java b/easy-agents-skill/src/main/java/com/easyagents/skill/store/file/TemporaryFileSkillContentStore.java new file mode 100644 index 0000000..7a39bdd --- /dev/null +++ b/easy-agents-skill/src/main/java/com/easyagents/skill/store/file/TemporaryFileSkillContentStore.java @@ -0,0 +1,633 @@ +package com.easyagents.skill.store.file; + +import com.easyagents.skill.exception.SkillException; +import com.easyagents.skill.store.SkillContentStage; +import com.easyagents.skill.store.SkillContentStore; +import com.easyagents.skill.util.SkillHashes; + +import java.io.ByteArrayInputStream; +import java.io.FilterInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.lang.ref.Cleaner; +import java.nio.file.AtomicMoveNotSupportedException; +import java.nio.file.FileVisitResult; +import java.nio.file.Files; +import java.nio.file.LinkOption; +import java.nio.file.Path; +import java.nio.file.SimpleFileVisitor; +import java.nio.file.StandardCopyOption; +import java.nio.file.StandardOpenOption; +import java.nio.file.attribute.BasicFileAttributes; +import java.nio.file.attribute.PosixFilePermission; +import java.security.DigestOutputStream; +import java.security.MessageDigest; +import java.util.EnumSet; +import java.util.HashMap; +import java.util.Map; +import java.util.UUID; + +/** + * 基于进程临时目录的流式 Skill 内容存储。 + * + *

内容先写入隔离暂存文件,提交后按 SHA-256 去重,并由引用计数控制物理文件生命周期。 + * 调用方应在不再使用内容引用和已打开的输入流后调用 {@link #close()}。未显式关闭的实例会由 + * JVM Cleaner 尝试兜底清理,但 Cleaner 不应替代正常的生命周期管理。

+ */ +public final class TemporaryFileSkillContentStore implements SkillContentStore, AutoCloseable { + + private static final String DIRECTORY_PREFIX = "easy-agents-skill-content-"; + private static final int COPY_BUFFER_SIZE = 16 * 1024; + private static final Cleaner CLEANER = Cleaner.create(); + + private final Object lifecycleMonitor = new Object(); + private final Path storageDirectory; + private final Path stagedDirectory; + private final Path contentDirectory; + private final Map stagedContents = new HashMap<>(); + private final Map contents = new HashMap<>(); + private final Cleaner.Cleanable cleanable; + private volatile boolean closed; + + /** + * 在系统临时目录中创建独立内容存储。 + */ + public TemporaryFileSkillContentStore() { + this(null); + } + + /** + * 在指定父目录中创建独立内容存储。 + * + *

该实例只拥有新建的子目录,关闭时不会删除传入的父目录。

+ * + * @param parentDirectory 临时存储父目录;为 null 时使用系统临时目录 + */ + public TemporaryFileSkillContentStore(Path parentDirectory) { + this.storageDirectory = createStorageDirectory(parentDirectory); + this.stagedDirectory = storageDirectory.resolve("staged"); + this.contentDirectory = storageDirectory.resolve("content"); + try { + Files.createDirectory(stagedDirectory); + Files.createDirectory(contentDirectory); + } catch (IOException exception) { + deleteDirectoryQuietly(storageDirectory); + throw new SkillException("Failed to initialize temporary Skill content store.", exception); + } + this.cleanable = CLEANER.register(this, new DirectoryCleanup(storageDirectory)); + } + + /** + * 流式保存字节内容并持有一个正式引用。 + * + * @param bytes 内容字节;null 按空内容处理 + * @return SHA-256 内容引用 + */ + @Override + public String put(byte[] bytes) { + byte[] safeBytes = bytes == null ? new byte[0] : bytes; + return put(new ByteArrayInputStream(safeBytes), safeBytes.length); + } + + /** + * 流式保存内容并持有一个正式引用。 + * + * @param inputStream 内容流,不由本方法关闭 + * @param maxBytes 最大允许字节数 + * @return SHA-256 内容引用 + */ + @Override + public String put(InputStream inputStream, long maxBytes) { + SkillContentStage stage = stage(inputStream, maxBytes); + try { + return commit(stage); + } catch (RuntimeException exception) { + try { + rollback(stage); + } catch (RuntimeException cleanupError) { + exception.addSuppressed(cleanupError); + } + throw exception; + } + } + + /** + * 将内容流式写入独立暂存文件并同步计算 SHA-256。 + * + * @param inputStream 内容流,不由本方法关闭 + * @param maxBytes 最大允许字节数 + * @return 暂存结果 + */ + @Override + public SkillContentStage stage(InputStream inputStream, long maxBytes) { + if (inputStream == null || maxBytes < 0) { + throw new SkillException("Valid Skill content stream and limit are required."); + } + String stageId = UUID.randomUUID().toString(); + Path stagedPath = stagedDirectory.resolve(stageId + ".stage"); + ensureOpen(); + try { + MessageDigest digest = SkillHashes.newSha256Digest(); + long size = 0; + try (DigestOutputStream output = new DigestOutputStream( + Files.newOutputStream(stagedPath, StandardOpenOption.CREATE_NEW, + StandardOpenOption.WRITE), digest)) { + byte[] buffer = new byte[COPY_BUFFER_SIZE]; + int length; + while ((length = inputStream.read(buffer)) >= 0) { + if (length == 0) { + continue; + } + if (size > maxBytes - length) { + throw new SkillException("Skill content exceeds " + maxBytes + " bytes."); + } + output.write(buffer, 0, length); + size += length; + } + } + String contentHash = SkillHashes.toHex(digest.digest()); + String contentRef = "sha256:" + contentHash; + StagedContent stagedContent = new StagedContent( + stagedPath, contentRef, contentHash, size); + synchronized (lifecycleMonitor) { + ensureOpenLocked(); + stagedContents.put(stageId, stagedContent); + } + return new SkillContentStage(stageId, contentRef, contentHash, size, false); + } catch (IOException | RuntimeException exception) { + deleteFileQuietly(stagedPath); + if (exception instanceof SkillException skillException) { + throw skillException; + } + throw new SkillException("Failed to stage temporary Skill content.", exception); + } + } + + /** + * 原子地提交暂存内容并增加一份正式引用。 + * + * @param stage 暂存结果 + * @return SHA-256 内容引用 + */ + @Override + public String commit(SkillContentStage stage) { + if (stage == null) { + throw new SkillException("Skill content stage is required."); + } + synchronized (lifecycleMonitor) { + ensureOpenLocked(); + StagedContent stagedContent = stagedContents.get(stage.getStageId()); + validateStage(stage, stagedContent); + validateStagedFile(stagedContent); + + StoredContent existing = contents.get(stagedContent.contentRef); + if (existing != null) { + validateDuplicateContent(stagedContent, existing); + if (existing.references == Long.MAX_VALUE) { + throw new SkillException("Skill content reference count overflow: " + + stagedContent.contentRef); + } + deleteFile(stagedContent.path, "Failed to delete committed Skill stage."); + stagedContents.remove(stage.getStageId()); + existing.references++; + existing.pendingDeletion = false; + return stagedContent.contentRef; + } + + Path targetPath = contentDirectory.resolve(stagedContent.contentHash + ".content"); + if (Files.exists(targetPath, LinkOption.NOFOLLOW_LINKS)) { + throw new SkillException("Unexpected Skill content file already exists: " + + stagedContent.contentRef); + } + moveAtomically(stagedContent.path, targetPath); + contents.put(stagedContent.contentRef, + new StoredContent(targetPath, stagedContent.size)); + stagedContents.remove(stage.getStageId()); + return stagedContent.contentRef; + } + } + + /** + * 删除尚未提交的暂存内容。 + * + * @param stage 暂存结果 + */ + @Override + public void rollback(SkillContentStage stage) { + if (stage == null) { + return; + } + synchronized (lifecycleMonitor) { + if (closed) { + return; + } + StagedContent stagedContent = stagedContents.get(stage.getStageId()); + if (stagedContent == null) { + return; + } + validateStage(stage, stagedContent); + deleteFile(stagedContent.path, "Failed to rollback staged Skill content."); + stagedContents.remove(stage.getStageId()); + } + } + + /** + * 增加一份正式内容引用。 + * + * @param contentRef 内容引用 + */ + @Override + public void retain(String contentRef) { + synchronized (lifecycleMonitor) { + ensureOpenLocked(); + StoredContent content = contents.get(contentRef); + if (content == null || content.references <= 0) { + throw new SkillException("Skill content does not exist: " + contentRef); + } + if (content.references == Long.MAX_VALUE) { + throw new SkillException("Skill content reference count overflow: " + contentRef); + } + content.references++; + } + } + + /** + * 释放一份正式内容引用,最后一个引用释放后删除物理文件。 + * + * @param contentRef 内容引用 + */ + @Override + public void release(String contentRef) { + if (contentRef == null || contentRef.isBlank()) { + return; + } + synchronized (lifecycleMonitor) { + if (closed) { + return; + } + StoredContent content = contents.get(contentRef); + if (content == null) { + return; + } + if (content.references > 0) { + content.references--; + } + if (content.references == 0) { + content.pendingDeletion = true; + deleteReleasedContent(contentRef, content); + } + } + } + + /** + * 打开正式内容的文件流。 + * + *

返回流会持有读取租约,最后一个正式引用释放时会等待已打开的流关闭后再删除文件。

+ * + * @param contentRef 内容引用 + * @return 内容输入流,调用方负责关闭 + */ + @Override + public InputStream open(String contentRef) { + synchronized (lifecycleMonitor) { + ensureOpenLocked(); + StoredContent content = contents.get(contentRef); + if (content == null || content.references <= 0) { + throw new SkillException("Skill content does not exist: " + contentRef); + } + if (content.readers == Integer.MAX_VALUE) { + throw new SkillException("Skill content reader count overflow: " + contentRef); + } + try { + InputStream input = Files.newInputStream(content.path, StandardOpenOption.READ); + content.readers++; + return new LeasedInputStream(input, contentRef, content); + } catch (IOException exception) { + throw new SkillException("Failed to open temporary Skill content: " + contentRef, + exception); + } + } + } + + /** + * 读取正式内容的全部字节。 + * + *

该兼容方法会按接口约定返回一个字节数组;流式调用方应优先使用 {@link #open(String)}。

+ * + * @param contentRef 内容引用 + * @return 内容字节 + */ + @Override + public byte[] readAllBytes(String contentRef) { + try (InputStream input = open(contentRef)) { + return input.readAllBytes(); + } catch (IOException exception) { + throw new SkillException("Failed to read temporary Skill content: " + contentRef, + exception); + } + } + + /** + * 判断正式内容是否仍有有效引用。 + * + * @param contentRef 内容引用 + * @return 存在且至少持有一个引用时为 true + */ + @Override + public boolean exists(String contentRef) { + synchronized (lifecycleMonitor) { + if (closed) { + return false; + } + StoredContent content = contents.get(contentRef); + return content != null && content.references > 0 + && Files.isRegularFile(content.path, LinkOption.NOFOLLOW_LINKS); + } + } + + /** + * 清理全部暂存和正式内容并关闭存储。 + * + *

调用前应先关闭通过 {@link #open(String)} 获取的流。该方法可重复调用。

+ */ + @Override + public void close() { + synchronized (lifecycleMonitor) { + if (closed) { + return; + } + closed = true; + stagedContents.clear(); + contents.clear(); + } + try { + deleteDirectory(storageDirectory); + } catch (IOException exception) { + throw new SkillException("Failed to clean temporary Skill content store.", exception); + } finally { + cleanable.clean(); + } + } + + /** + * 返回该实例拥有的临时目录,供同包生命周期测试使用。 + * + * @return 实例临时目录 + */ + Path storageDirectory() { + return storageDirectory; + } + + private void ensureOpen() { + synchronized (lifecycleMonitor) { + ensureOpenLocked(); + } + } + + private void ensureOpenLocked() { + if (closed) { + throw new SkillException("Temporary Skill content store is closed."); + } + } + + private static void validateStage(SkillContentStage stage, StagedContent content) { + if (content == null) { + throw new SkillException("Skill content stage does not exist: " + stage.getStageId()); + } + if (!content.contentRef.equals(stage.getContentRef()) + || !content.contentHash.equals(stage.getContentHash()) + || content.size != stage.getSize()) { + throw new SkillException("Skill content stage metadata does not match: " + + stage.getStageId()); + } + } + + private static void validateStagedFile(StagedContent content) { + try { + BasicFileAttributes attributes = Files.readAttributes( + content.path, BasicFileAttributes.class, LinkOption.NOFOLLOW_LINKS); + if (!attributes.isRegularFile() || attributes.size() != content.size) { + throw new SkillException("Staged Skill content file changed before commit: " + + content.contentRef); + } + } catch (IOException exception) { + throw new SkillException("Failed to inspect staged Skill content: " + + content.contentRef, exception); + } + } + + private static void validateDuplicateContent(StagedContent stagedContent, + StoredContent existing) { + if (existing.size != stagedContent.size) { + throw new SkillException("SHA-256 collision detected for Skill content: " + + stagedContent.contentRef); + } + try { + if (Files.mismatch(stagedContent.path, existing.path) != -1) { + throw new SkillException("SHA-256 collision detected for Skill content: " + + stagedContent.contentRef); + } + } catch (IOException exception) { + throw new SkillException("Failed to compare duplicate Skill content: " + + stagedContent.contentRef, exception); + } + } + + private static void moveAtomically(Path source, Path target) { + try { + Files.move(source, target, StandardCopyOption.ATOMIC_MOVE); + } catch (AtomicMoveNotSupportedException exception) { + try { + Files.move(source, target); + } catch (IOException fallbackException) { + fallbackException.addSuppressed(exception); + throw new SkillException("Failed to commit staged Skill content.", + fallbackException); + } + } catch (IOException exception) { + throw new SkillException("Failed to commit staged Skill content.", exception); + } + } + + private void deleteReleasedContent(String contentRef, StoredContent content) { + if (content.readers > 0) { + return; + } + deleteFile(content.path, "Failed to delete released Skill content."); + contents.remove(contentRef, content); + } + + private void releaseReader(String contentRef, StoredContent content) throws IOException { + synchronized (lifecycleMonitor) { + if (content.readers > 0) { + content.readers--; + } + if (closed || !content.pendingDeletion || content.readers > 0) { + return; + } + try { + deleteReleasedContent(contentRef, content); + } catch (SkillException exception) { + throw new IOException("Failed to delete released Skill content.", exception); + } + } + } + + private static Path createStorageDirectory(Path parentDirectory) { + Path directory = null; + try { + directory = parentDirectory == null + ? Files.createTempDirectory(DIRECTORY_PREFIX) + : Files.createTempDirectory(parentDirectory, DIRECTORY_PREFIX); + restrictOwnerAccess(directory); + return directory; + } catch (IOException exception) { + deleteDirectoryQuietly(directory); + throw new SkillException("Failed to create temporary Skill content store.", exception); + } + } + + private static void restrictOwnerAccess(Path directory) throws IOException { + try { + Files.setPosixFilePermissions(directory, EnumSet.of( + PosixFilePermission.OWNER_READ, + PosixFilePermission.OWNER_WRITE, + PosixFilePermission.OWNER_EXECUTE)); + } catch (UnsupportedOperationException ignored) { + // 非 POSIX 文件系统使用平台默认的临时目录访问控制。 + } + } + + private static void deleteFile(Path path, String message) { + try { + Files.deleteIfExists(path); + } catch (IOException exception) { + throw new SkillException(message, exception); + } + } + + private static void deleteFileQuietly(Path path) { + try { + Files.deleteIfExists(path); + } catch (IOException ignored) { + // 后续 close 或 Cleaner 会再次清理实例目录。 + } + } + + private static void deleteDirectory(Path directory) throws IOException { + if (!Files.exists(directory, LinkOption.NOFOLLOW_LINKS)) { + return; + } + Files.walkFileTree(directory, new SimpleFileVisitor<>() { + @Override + public FileVisitResult visitFile(Path file, BasicFileAttributes attributes) + throws IOException { + Files.deleteIfExists(file); + return FileVisitResult.CONTINUE; + } + + @Override + public FileVisitResult postVisitDirectory(Path current, IOException exception) + throws IOException { + if (exception != null) { + throw exception; + } + Files.deleteIfExists(current); + return FileVisitResult.CONTINUE; + } + }); + } + + private static void deleteDirectoryQuietly(Path directory) { + if (directory == null) { + return; + } + try { + deleteDirectory(directory); + } catch (IOException ignored) { + // Cleaner 是兜底路径,无法向已回收实例传播清理失败。 + } + } + + private final class LeasedInputStream extends FilterInputStream { + + private final String contentRef; + private final StoredContent content; + private boolean streamClosed; + + private LeasedInputStream(InputStream input, String contentRef, StoredContent content) { + super(input); + this.contentRef = contentRef; + this.content = content; + } + + @Override + public void close() throws IOException { + if (streamClosed) { + return; + } + streamClosed = true; + IOException closeFailure = null; + try { + super.close(); + } catch (IOException exception) { + closeFailure = exception; + } + try { + releaseReader(contentRef, content); + } catch (IOException cleanupFailure) { + if (closeFailure == null) { + closeFailure = cleanupFailure; + } else { + closeFailure.addSuppressed(cleanupFailure); + } + } + if (closeFailure != null) { + throw closeFailure; + } + } + } + + private static final class StagedContent { + + private final Path path; + private final String contentRef; + private final String contentHash; + private final long size; + + private StagedContent(Path path, String contentRef, String contentHash, long size) { + this.path = path; + this.contentRef = contentRef; + this.contentHash = contentHash; + this.size = size; + } + } + + private static final class StoredContent { + + private final Path path; + private final long size; + private long references = 1; + private int readers; + private boolean pendingDeletion; + + private StoredContent(Path path, long size) { + this.path = path; + this.size = size; + } + } + + private static final class DirectoryCleanup implements Runnable { + + private final Path directory; + + private DirectoryCleanup(Path directory) { + this.directory = directory; + } + + @Override + public void run() { + deleteDirectoryQuietly(directory); + } + } +} diff --git a/easy-agents-skill/src/main/java/com/easyagents/skill/store/memory/InMemorySkillContentStore.java b/easy-agents-skill/src/main/java/com/easyagents/skill/store/memory/InMemorySkillContentStore.java index 98632aa..e33f711 100644 --- a/easy-agents-skill/src/main/java/com/easyagents/skill/store/memory/InMemorySkillContentStore.java +++ b/easy-agents-skill/src/main/java/com/easyagents/skill/store/memory/InMemorySkillContentStore.java @@ -1,24 +1,30 @@ package com.easyagents.skill.store.memory; import com.easyagents.skill.exception.SkillException; +import com.easyagents.skill.store.SkillContentStage; import com.easyagents.skill.store.SkillContentStore; import com.easyagents.skill.util.SkillHashes; import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; import java.io.InputStream; import java.util.Arrays; +import java.util.UUID; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentMap; +import java.util.concurrent.atomic.AtomicInteger; /** - * 基于内存的 Skill 二进制内容存储。 + * 基于内存的 Skill 内容存储,支持真实暂存与引用计数,适用于测试和轻量场景。 */ public class InMemorySkillContentStore implements SkillContentStore { - private final ConcurrentMap contents = new ConcurrentHashMap<>(); + private final ConcurrentMap contents = new ConcurrentHashMap<>(); + private final ConcurrentMap stagedContents = new ConcurrentHashMap<>(); /** - * 保存内容并返回内容引用。 + * 保存内容并持有一个引用。 * * @param bytes 内容字节 * @return 内容引用 @@ -27,10 +33,109 @@ public class InMemorySkillContentStore implements SkillContentStore { public String put(byte[] bytes) { byte[] safeBytes = bytes == null ? new byte[0] : Arrays.copyOf(bytes, bytes.length); String contentRef = SkillHashes.sha256Ref(safeBytes); - contents.putIfAbsent(contentRef, safeBytes); + contents.compute(contentRef, (key, current) -> { + if (current == null) { + return new StoredContent(safeBytes); + } + if (!Arrays.equals(current.bytes, safeBytes)) { + throw new SkillException("SHA-256 collision detected for Skill content: " + contentRef); + } + current.references.incrementAndGet(); + return current; + }); return contentRef; } + /** + * 流式保存内容并持有一个引用。 + * + * @param inputStream 内容流 + * @param maxBytes 最大允许字节数 + * @return 内容引用 + */ + @Override + public String put(InputStream inputStream, long maxBytes) { + return put(readBounded(inputStream, maxBytes)); + } + + /** + * 将内容写入独立暂存区。 + * + * @param inputStream 内容流 + * @param maxBytes 最大允许字节数 + * @return 暂存结果 + */ + @Override + public SkillContentStage stage(InputStream inputStream, long maxBytes) { + byte[] bytes = readBounded(inputStream, maxBytes); + String hash = SkillHashes.sha256Hex(bytes); + String stageId = UUID.randomUUID().toString(); + stagedContents.put(stageId, bytes); + return new SkillContentStage(stageId, "sha256:" + hash, hash, bytes.length, false); + } + + /** + * 原子地将暂存内容转为正式引用。 + * + * @param stage 暂存结果 + * @return 正式内容引用 + */ + @Override + public String commit(SkillContentStage stage) { + if (stage == null) { + throw new SkillException("Skill content stage is required."); + } + byte[] bytes = stagedContents.remove(stage.getStageId()); + if (bytes == null) { + throw new SkillException("Skill content stage does not exist: " + stage.getStageId()); + } + String contentRef = put(bytes); + if (!contentRef.equals(stage.getContentRef())) { + release(contentRef); + throw new SkillException("Skill staged content hash changed before commit."); + } + return contentRef; + } + + /** + * 删除暂存内容。 + * + * @param stage 暂存结果 + */ + @Override + public void rollback(SkillContentStage stage) { + if (stage != null) { + stagedContents.remove(stage.getStageId()); + } + } + + /** + * 增加正式内容引用计数。 + * + * @param contentRef 内容引用 + */ + @Override + public void retain(String contentRef) { + contents.compute(contentRef, (key, content) -> { + if (content == null) { + throw new SkillException("Skill content does not exist: " + contentRef); + } + content.references.incrementAndGet(); + return content; + }); + } + + /** + * 释放正式内容引用并在归零时删除内容。 + * + * @param contentRef 内容引用 + */ + @Override + public void release(String contentRef) { + contents.computeIfPresent(contentRef, (key, content) -> + content.references.decrementAndGet() <= 0 ? null : content); + } + /** * 打开内容流。 * @@ -46,15 +151,15 @@ public class InMemorySkillContentStore implements SkillContentStore { * 读取全部内容。 * * @param contentRef 内容引用 - * @return 内容字节 + * @return 内容副本 */ @Override public byte[] readAllBytes(String contentRef) { - byte[] bytes = contents.get(contentRef); - if (bytes == null) { + StoredContent content = contents.get(contentRef); + if (content == null) { throw new SkillException("Skill content does not exist: " + contentRef); } - return Arrays.copyOf(bytes, bytes.length); + return Arrays.copyOf(content.bytes, content.bytes.length); } /** @@ -67,4 +172,39 @@ public class InMemorySkillContentStore implements SkillContentStore { public boolean exists(String contentRef) { return contents.containsKey(contentRef); } + + private static byte[] readBounded(InputStream inputStream, long maxBytes) { + if (inputStream == null || maxBytes < 0) { + throw new SkillException("Valid Skill content stream and limit are required."); + } + try { + ByteArrayOutputStream output = new ByteArrayOutputStream((int) Math.min(maxBytes, 8_192)); + byte[] buffer = new byte[8_192]; + long total = 0; + int read; + while ((read = inputStream.read(buffer)) >= 0) { + if (read == 0) { + continue; + } + total += read; + if (total > maxBytes) { + throw new SkillException("Skill content exceeds " + maxBytes + " bytes."); + } + output.write(buffer, 0, read); + } + return output.toByteArray(); + } catch (IOException e) { + throw new SkillException("Failed to read Skill content stream.", e); + } + } + + private static final class StoredContent { + + private final byte[] bytes; + private final AtomicInteger references = new AtomicInteger(1); + + private StoredContent(byte[] bytes) { + this.bytes = Arrays.copyOf(bytes, bytes.length); + } + } } diff --git a/easy-agents-skill/src/main/java/com/easyagents/skill/util/SkillFrontmatter.java b/easy-agents-skill/src/main/java/com/easyagents/skill/util/SkillFrontmatter.java index 85e748e..f0e0335 100644 --- a/easy-agents-skill/src/main/java/com/easyagents/skill/util/SkillFrontmatter.java +++ b/easy-agents-skill/src/main/java/com/easyagents/skill/util/SkillFrontmatter.java @@ -1,89 +1,293 @@ package com.easyagents.skill.util; import com.easyagents.skill.exception.SkillValidationException; +import com.easyagents.skill.model.SkillDocument; +import com.easyagents.skill.model.SkillPackageLimits; +import com.easyagents.skill.model.SkillSourceLocation; +import org.yaml.snakeyaml.DumperOptions; +import org.yaml.snakeyaml.LoaderOptions; +import org.yaml.snakeyaml.Yaml; +import org.yaml.snakeyaml.constructor.SafeConstructor; +import org.yaml.snakeyaml.error.MarkedYAMLException; +import org.yaml.snakeyaml.error.YAMLException; +import org.yaml.snakeyaml.nodes.MappingNode; +import org.yaml.snakeyaml.nodes.Node; +import org.yaml.snakeyaml.nodes.NodeTuple; +import org.yaml.snakeyaml.nodes.ScalarNode; +import org.yaml.snakeyaml.nodes.Tag; +import org.yaml.snakeyaml.representer.Representer; +import java.io.StringReader; +import java.nio.charset.CharacterCodingException; +import java.util.ArrayList; +import java.util.Date; import java.util.LinkedHashMap; +import java.util.List; import java.util.Map; +import java.util.Set; +import java.util.TimeZone; /** - * SKILL.md frontmatter 解析工具。 + * SKILL.md 安全 frontmatter 解析与确定性序列化工具。 */ public final class SkillFrontmatter { + private static final String DOCUMENT_PATH = "SKILL.md"; + private static final Set ALLOWED_TAGS = Set.of( + Tag.MAP, Tag.SEQ, Tag.STR, Tag.BOOL, Tag.NULL, + Tag.INT, Tag.FLOAT, Tag.TIMESTAMP + ); + private SkillFrontmatter() { } /** - * 解析 SKILL.md 开头的 frontmatter。 + * 解析 SKILL.md frontmatter,并兼容原有必填字段校验行为。 * * @param content SKILL.md 原始内容 - * @return frontmatter 键值 + * @return frontmatter 有序键值 + * @throws SkillValidationException 文档或必填字段不合法 */ public static Map parse(String content) { - if (content == null || content.isBlank()) { - throw new SkillValidationException("SKILL.md content is required."); - } - String[] lines = content.split("\\R", -1); - if (lines.length == 0 || !"---".equals(lines[0])) { - throw new SkillValidationException("SKILL.md must start with frontmatter."); - } - - Map values = new LinkedHashMap<>(); - boolean closed = false; - for (int i = 1; i < lines.length; i++) { - String line = lines[i]; - if ("---".equals(line)) { - closed = true; - break; - } - if (line.isBlank()) { - continue; - } - if (Character.isWhitespace(line.charAt(0))) { - throw new SkillValidationException("Nested frontmatter is not supported."); - } - parseScalarLine(line, values); - } - if (!closed) { - throw new SkillValidationException("SKILL.md frontmatter is not closed."); - } - if (isBlank(values.get("name"))) { - throw new SkillValidationException("SKILL.md frontmatter name is required."); - } - if (isBlank(values.get("description"))) { - throw new SkillValidationException("SKILL.md frontmatter description is required."); - } + SkillDocument document = parseDocument(content, SkillPackageLimits.defaults()); + Map values = document.getFrontmatter().getValues(); + requireCoreField(values, "name"); + requireCoreField(values, "description"); return values; } - private static void parseScalarLine(String line, Map values) { - int colonIndex = line.indexOf(':'); - if (colonIndex <= 0) { - throw new SkillValidationException("Only single-line key: value frontmatter is supported."); - } - String key = line.substring(0, colonIndex).trim(); - String value = stripQuotes(line.substring(colonIndex + 1).trim()); - if (key.isBlank()) { - throw new SkillValidationException("Frontmatter key cannot be blank."); - } - if (value.isBlank()) { - throw new SkillValidationException("Frontmatter value cannot be blank: " + key); - } - values.put(key, value); + /** + * 使用默认安全限额解析完整 SKILL.md。 + * + * @param content SKILL.md 原始内容 + * @return 解析后的文档 + * @throws SkillValidationException 文档语法不合法 + */ + public static SkillDocument parseDocument(String content) { + return parseDocument(content, SkillPackageLimits.defaults()); } - private static String stripQuotes(String value) { - if (value.length() >= 2) { - boolean doubleQuoted = value.startsWith("\"") && value.endsWith("\""); - boolean singleQuoted = value.startsWith("'") && value.endsWith("'"); - if (doubleQuoted || singleQuoted) { - return value.substring(1, value.length() - 1); + /** + * 使用指定安全限额解析完整 SKILL.md。 + * + * @param content SKILL.md 原始内容 + * @param limits 安全限额 + * @return 解析后的文档 + * @throws SkillValidationException 文档语法、类型或限额不合法 + */ + public static SkillDocument parseDocument(String content, SkillPackageLimits limits) { + if (content == null || content.isBlank()) { + throw error("SKILL_CONTENT_REQUIRED", 1, 1, "SKILL.md content is required.", null); + } + try { + SkillUtf8.byteLength(content); + } catch (CharacterCodingException e) { + throw error("INVALID_UTF8", 1, 1, + "SKILL.md must be losslessly encodable as UTF-8.", e); + } + SkillPackageLimits effectiveLimits = limits == null ? SkillPackageLimits.defaults() : limits; + FrontmatterSections sections = split(content); + long frontmatterBytes; + try { + frontmatterBytes = SkillUtf8.byteLength(sections.yaml); + } catch (CharacterCodingException e) { + throw error("INVALID_UTF8", 2, 1, + "SKILL.md frontmatter must be losslessly encodable as UTF-8.", e); + } + if (frontmatterBytes > effectiveLimits.getMaxFrontmatterBytes()) { + throw error("FRONTMATTER_TOO_LARGE", 1, 1, + "SKILL.md frontmatter exceeds " + effectiveLimits.getMaxFrontmatterBytes() + " bytes.", null); + } + + LoaderOptions loaderOptions = new LoaderOptions(); + loaderOptions.setAllowDuplicateKeys(false); + loaderOptions.setWarnOnDuplicateKeys(false); + loaderOptions.setAllowRecursiveKeys(false); + loaderOptions.setMaxAliasesForCollections(effectiveLimits.getMaxYamlAliases()); + loaderOptions.setNestingDepthLimit(effectiveLimits.getMaxYamlDepth()); + loaderOptions.setCodePointLimit(effectiveLimits.getMaxYamlCodePoints()); + loaderOptions.setTagInspector(tag -> isStandardTag(tag)); + + try { + Yaml yaml = new Yaml(new SafeConstructor(loaderOptions)); + Node rootNode = yaml.compose(new StringReader(sections.yaml)); + Object loaded = yaml.load(sections.yaml); + if (!(loaded instanceof Map map)) { + throw error("FRONTMATTER_ROOT_NOT_MAP", 2, 1, + "SKILL.md frontmatter root must be a YAML mapping.", null); } + LinkedHashMap values = copyStringMap(map, 0, effectiveLimits.getMaxYamlDepth()); + return new SkillDocument(content, values, sections.body, + collectTopLevelLocations(rootNode), List.of()); + } catch (SkillValidationException e) { + throw e; + } catch (MarkedYAMLException e) { + int line = e.getProblemMark() == null ? 2 : e.getProblemMark().getLine() + 2; + int column = e.getProblemMark() == null ? 1 : e.getProblemMark().getColumn() + 1; + throw error("INVALID_FRONTMATTER_YAML", line, column, + "Invalid SKILL.md frontmatter: " + safeProblem(e), e); + } catch (YAMLException e) { + throw error("INVALID_FRONTMATTER_YAML", 2, 1, + "Invalid SKILL.md frontmatter: " + safeProblem(e), e); } - return value; } - private static boolean isBlank(Object value) { - return value == null || value.toString().isBlank(); + /** + * 将有序 frontmatter 与 Markdown 正文确定性序列化为 SKILL.md。 + * + * @param values frontmatter 有序键值 + * @param markdownBody Markdown 正文 + * @return 完整 SKILL.md + * @throws SkillValidationException frontmatter 包含不安全类型 + */ + public static String serialize(Map values, String markdownBody) { + LinkedHashMap safeValues = copyStringMap( + values == null ? Map.of() : values, 0, SkillPackageLimits.defaults().getMaxYamlDepth()); + DumperOptions dumperOptions = new DumperOptions(); + dumperOptions.setAllowUnicode(true); + dumperOptions.setDefaultFlowStyle(DumperOptions.FlowStyle.BLOCK); + dumperOptions.setDefaultScalarStyle(DumperOptions.ScalarStyle.PLAIN); + dumperOptions.setIndent(2); + dumperOptions.setIndicatorIndent(0); + dumperOptions.setPrettyFlow(true); + dumperOptions.setSplitLines(false); + dumperOptions.setWidth(4_096); + dumperOptions.setLineBreak(DumperOptions.LineBreak.UNIX); + dumperOptions.setDereferenceAliases(true); + + LoaderOptions loaderOptions = new LoaderOptions(); + loaderOptions.setAllowDuplicateKeys(false); + Representer representer = new Representer(dumperOptions); + representer.setTimeZone(TimeZone.getTimeZone("UTC")); + Yaml yaml = new Yaml(new SafeConstructor(loaderOptions), representer, dumperOptions, loaderOptions); + String yamlContent = yaml.dump(safeValues); + String body = markdownBody == null ? "" : markdownBody; + return "---\n" + yamlContent + "---\n" + body; + } + + private static FrontmatterSections split(String content) { + int yamlStart; + if (content.startsWith("---\n")) { + yamlStart = 4; + } else if (content.startsWith("---\r\n")) { + yamlStart = 5; + } else { + throw error("FRONTMATTER_START_REQUIRED", 1, 1, + "SKILL.md must start with a frontmatter delimiter.", null); + } + + int lineStart = yamlStart; + while (lineStart <= content.length()) { + int lineEnd = content.indexOf('\n', lineStart); + int contentEnd = lineEnd < 0 ? content.length() : lineEnd; + String line = content.substring(lineStart, contentEnd); + if (line.endsWith("\r")) { + line = line.substring(0, line.length() - 1); + } + if ("---".equals(line)) { + int bodyStart = lineEnd < 0 ? content.length() : lineEnd + 1; + return new FrontmatterSections(content.substring(yamlStart, lineStart), + content.substring(bodyStart)); + } + if (lineEnd < 0) { + break; + } + lineStart = lineEnd + 1; + } + throw error("FRONTMATTER_NOT_CLOSED", 1, 1, + "SKILL.md frontmatter is not closed.", null); + } + + private static LinkedHashMap copyStringMap(Map source, int depth, int maxDepth) { + if (depth > maxDepth) { + throw error("YAML_DEPTH_LIMIT", 2, 1, + "SKILL.md frontmatter exceeds the YAML nesting depth limit.", null); + } + LinkedHashMap target = new LinkedHashMap<>(); + for (Map.Entry entry : source.entrySet()) { + if (!(entry.getKey() instanceof String key) || key.isBlank()) { + throw error("INVALID_FRONTMATTER_KEY", 2, 1, + "SKILL.md frontmatter keys must be non-blank strings.", null); + } + target.put(key, copyValue(entry.getValue(), depth + 1, maxDepth)); + } + return target; + } + + private static Object copyValue(Object value, int depth, int maxDepth) { + if (value == null || value instanceof String || value instanceof Boolean || value instanceof Number) { + return value; + } + if (value instanceof Date date) { + return new Date(date.getTime()); + } + if (value instanceof Map map) { + return copyStringMap(map, depth, maxDepth); + } + if (value instanceof List list) { + if (depth > maxDepth) { + throw error("YAML_DEPTH_LIMIT", 2, 1, + "SKILL.md frontmatter exceeds the YAML nesting depth limit.", null); + } + List target = new ArrayList<>(list.size()); + for (Object item : list) { + target.add(copyValue(item, depth + 1, maxDepth)); + } + return target; + } + throw error("UNSUPPORTED_FRONTMATTER_TYPE", 2, 1, + "Unsupported SKILL.md frontmatter value type: " + value.getClass().getName(), null); + } + + private static boolean isStandardTag(Tag tag) { + return tag != null && ALLOWED_TAGS.contains(tag); + } + + /** + * 收集顶层 frontmatter key 在完整 SKILL.md 中的一基位置。 + * + * @param rootNode YAML 根节点 + * @return 按源码顺序排列的字段位置 + */ + private static Map collectTopLevelLocations(Node rootNode) { + LinkedHashMap locations = new LinkedHashMap<>(); + if (!(rootNode instanceof MappingNode mappingNode)) { + return locations; + } + for (NodeTuple tuple : mappingNode.getValue()) { + if (!(tuple.getKeyNode() instanceof ScalarNode scalarNode) + || scalarNode.getStartMark() == null) { + continue; + } + locations.putIfAbsent(scalarNode.getValue(), new SkillSourceLocation( + scalarNode.getStartMark().getLine() + 2, + scalarNode.getStartMark().getColumn() + 1)); + } + return locations; + } + + private static void requireCoreField(Map values, String key) { + Object value = values.get(key); + if (value == null || value.toString().isBlank()) { + throw error("MISSING_" + key.toUpperCase(), 2, 1, + "SKILL.md frontmatter " + key + " is required.", null); + } + } + + private static String safeProblem(Exception exception) { + String message = exception.getMessage(); + if (message == null || message.isBlank()) { + return exception.getClass().getSimpleName(); + } + int lineBreak = message.indexOf('\n'); + return lineBreak < 0 ? message : message.substring(0, lineBreak); + } + + private static SkillValidationException error(String code, Integer line, Integer column, + String message, Throwable cause) { + return new SkillValidationException(code, DOCUMENT_PATH, line, column, message, cause); + } + + private record FrontmatterSections(String yaml, String body) { } } diff --git a/easy-agents-skill/src/main/java/com/easyagents/skill/util/SkillHashes.java b/easy-agents-skill/src/main/java/com/easyagents/skill/util/SkillHashes.java index cefd9e1..ac8013f 100644 --- a/easy-agents-skill/src/main/java/com/easyagents/skill/util/SkillHashes.java +++ b/easy-agents-skill/src/main/java/com/easyagents/skill/util/SkillHashes.java @@ -32,16 +32,34 @@ public final class SkillHashes { * @return 十六进制 hash */ public static String sha256Hex(byte[] bytes) { + return toHex(newSha256Digest().digest(bytes == null ? new byte[0] : bytes)); + } + + /** + * 创建新的 SHA-256 摘要器。 + * + * @return SHA-256 摘要器 + */ + public static MessageDigest newSha256Digest() { try { - MessageDigest digest = MessageDigest.getInstance(SHA_256); - byte[] hashed = digest.digest(bytes == null ? new byte[0] : bytes); - StringBuilder builder = new StringBuilder(hashed.length * 2); - for (byte item : hashed) { - builder.append(String.format("%02x", item)); - } - return builder.toString(); + return MessageDigest.getInstance(SHA_256); } catch (NoSuchAlgorithmException e) { throw new SkillException("SHA-256 algorithm is unavailable.", e); } } + + /** + * 将字节转为小写十六进制文本。 + * + * @param bytes 原始字节 + * @return 小写十六进制文本 + */ + public static String toHex(byte[] bytes) { + StringBuilder builder = new StringBuilder(bytes.length * 2); + for (byte item : bytes) { + builder.append(Character.forDigit((item >>> 4) & 0x0F, 16)); + builder.append(Character.forDigit(item & 0x0F, 16)); + } + return builder.toString(); + } } diff --git a/easy-agents-skill/src/main/java/com/easyagents/skill/util/SkillPaths.java b/easy-agents-skill/src/main/java/com/easyagents/skill/util/SkillPaths.java index a2c61ba..6fe92e4 100644 --- a/easy-agents-skill/src/main/java/com/easyagents/skill/util/SkillPaths.java +++ b/easy-agents-skill/src/main/java/com/easyagents/skill/util/SkillPaths.java @@ -2,6 +2,7 @@ package com.easyagents.skill.util; import com.easyagents.skill.exception.SkillValidationException; +import java.text.Normalizer; import java.util.Locale; /** @@ -29,6 +30,11 @@ public final class SkillPaths { */ public static final String ASSETS_DIR = "assets"; + /** + * example 顶级目录。 + */ + public static final String EXAMPLES_DIR = "examples"; + private SkillPaths() { } @@ -42,17 +48,17 @@ public final class SkillPaths { if (path == null) { throw new SkillValidationException("Skill path is required."); } - String normalized = path.replace('\\', '/').trim(); - while (normalized.startsWith("./")) { - normalized = normalized.substring(2); + if (containsControlCharacter(path)) { + throw new SkillValidationException("Skill path contains a control character."); } + String normalized = Normalizer.normalize(path.replace('\\', '/'), Normalizer.Form.NFC); if (normalized.isEmpty()) { throw new SkillValidationException("Skill path cannot be empty."); } if (normalized.startsWith("/") || normalized.matches("^[A-Za-z]:.*")) { throw new SkillValidationException("Absolute skill path is not allowed: " + path); } - String[] segments = normalized.split("/"); + String[] segments = normalized.split("/", -1); for (String segment : segments) { if (segment.isEmpty() || ".".equals(segment) || "..".equals(segment)) { throw new SkillValidationException("Unsafe skill path is not allowed: " + path); @@ -60,10 +66,33 @@ public final class SkillPaths { if (segment.startsWith(".")) { throw new SkillValidationException("Hidden skill path is not allowed: " + path); } + if (!segment.equals(segment.strip())) { + throw new SkillValidationException("Skill path segments cannot have surrounding whitespace: " + path); + } } return normalized; } + /** + * 生成大小写不敏感的路径冲突键。 + * + * @param path 逻辑路径 + * @return 路径冲突键 + */ + public static String collisionKey(String path) { + return normalize(path).toLowerCase(Locale.ROOT); + } + + /** + * 获取路径段数量。 + * + * @param path 逻辑路径 + * @return 路径深度 + */ + public static int depth(String path) { + return normalize(path).split("/", -1).length; + } + /** * 获取一级目录或文件名。 * @@ -115,4 +144,15 @@ public final class SkillPaths { || normalized.endsWith("/.DS_Store") || ".DS_Store".equals(normalized); } + + private static boolean containsControlCharacter(String path) { + for (int index = 0; index < path.length(); index++) { + char character = path.charAt(index); + if (character <= 0x1F || character == 0x7F + || Character.getType(character) == Character.FORMAT) { + return true; + } + } + return false; + } } diff --git a/easy-agents-skill/src/main/java/com/easyagents/skill/util/SkillResources.java b/easy-agents-skill/src/main/java/com/easyagents/skill/util/SkillResources.java new file mode 100644 index 0000000..67f9ed9 --- /dev/null +++ b/easy-agents-skill/src/main/java/com/easyagents/skill/util/SkillResources.java @@ -0,0 +1,187 @@ +package com.easyagents.skill.util; + +import com.easyagents.skill.model.Skill; +import com.easyagents.skill.model.SkillAsset; +import com.easyagents.skill.model.SkillMetadata; +import com.easyagents.skill.model.SkillReference; +import com.easyagents.skill.model.SkillResource; +import com.easyagents.skill.model.SkillResourceKind; +import com.easyagents.skill.model.SkillScript; +import com.easyagents.skill.model.SkillScriptLanguage; + +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.Set; + +/** + * 通用 Skill 资源与旧资源视图之间的兼容适配工具。 + */ +public final class SkillResources { + + private static final Set TEXT_EXTENSIONS = Set.of( + ".md", ".markdown", ".txt", ".json", ".yaml", ".yml", ".xml", ".csv", ".tsv", + ".htm", ".html", ".css", ".properties", ".toml", ".ini", ".sql", ".java", ".kt", ".kts", + ".py", ".js", ".mjs", ".cjs", ".ts", ".tsx", ".jsx", ".sh", ".bash", ".zsh", + ".rb", ".go", ".rs", ".c", ".h", ".cpp", ".hpp", ".gradle", ".groovy" + ); + + private SkillResources() { + } + + /** + * 根据顶层目录识别资源语义类型。 + * + * @param path Skill 根目录相对路径 + * @return 资源语义类型 + */ + public static SkillResourceKind classify(String path) { + String topDirectory = SkillPaths.firstSegment(path); + return switch (topDirectory) { + case SkillPaths.REFERENCES_DIR -> SkillResourceKind.REFERENCE; + case SkillPaths.SCRIPTS_DIR -> SkillResourceKind.SCRIPT; + case SkillPaths.ASSETS_DIR -> SkillResourceKind.ASSET; + case SkillPaths.EXAMPLES_DIR -> SkillResourceKind.EXAMPLE; + default -> SkillResourceKind.OTHER; + }; + } + + /** + * 判断资源是否应按严格 UTF-8 文本处理。 + * + * @param path 资源路径 + * @param kind 资源语义类型 + * @param mediaType 媒体类型 + * @return 文本资源时为 true + */ + public static boolean isText(String path, SkillResourceKind kind, String mediaType) { + if (kind == SkillResourceKind.SCRIPT) { + return true; + } + if (kind == SkillResourceKind.ASSET) { + return false; + } + if (mediaType != null && (mediaType.startsWith("text/") + || mediaType.contains("json") || mediaType.contains("yaml") + || mediaType.contains("xml") || mediaType.contains("javascript"))) { + return true; + } + String lowerPath = path.toLowerCase(Locale.ROOT); + return TEXT_EXTENSIONS.stream().anyMatch(lowerPath::endsWith); + } + + /** + * 获取 Skill 的正式通用资源;旧模型会被按需转换。 + * + * @param skill Skill 聚合 + * @return 通用资源副本 + */ + public static List canonicalResources(Skill skill) { + if (skill == null) { + return new ArrayList<>(); + } + return new ArrayList<>(skill.getResources()); + } + + /** + * 将尚未迁移的旧 references、scripts、assets 视图转换为正式通用资源。 + * + *

该方法只读取旧视图,不读取或修改正式资源列表,由 {@link Skill#getResources()} + * 在第一次正式访问时完成一次性迁移。

+ * + * @param skill Skill 聚合 + * @return 从旧视图转换得到的通用资源副本 + */ + public static List fromLegacyViews(Skill skill) { + List resources = new ArrayList<>(); + if (skill == null) { + return resources; + } + if (skill.getReferences() != null) { + for (SkillReference reference : skill.getReferences()) { + SkillResource resource = base(reference.getPath(), SkillResourceKind.REFERENCE, + "text/markdown", reference.getContentHash(), reference.getSize(), reference.getMetadata()); + resource.setTextContent(reference.getContent()); + resources.add(resource); + } + } + if (skill.getScripts() != null) { + for (SkillScript script : skill.getScripts()) { + SkillResource resource = base(script.getPath(), SkillResourceKind.SCRIPT, + "text/plain", script.getContentHash(), script.getSize(), script.getMetadata()); + resource.setTextContent(script.getContent()); + resources.add(resource); + } + } + if (skill.getAssets() != null) { + for (SkillAsset asset : skill.getAssets()) { + SkillResource resource = base(asset.getPath(), SkillResourceKind.ASSET, + asset.getMediaType(), asset.getContentHash(), asset.getSize(), asset.getMetadata()); + resource.setContentRef(asset.getContentRef()); + resources.add(resource); + } + } + return resources; + } + + /** + * 依据通用资源刷新旧 references、scripts、assets 兼容视图。 + * + * @param skill Skill 聚合 + */ + public static void refreshLegacyViews(Skill skill) { + List references = new ArrayList<>(); + List scripts = new ArrayList<>(); + List assets = new ArrayList<>(); + for (SkillResource resource : skill.getResources()) { + if (resource.getKind() == SkillResourceKind.REFERENCE && resource.isText()) { + SkillReference reference = new SkillReference(); + reference.setPath(resource.getPath()); + reference.setName(SkillPaths.fileName(resource.getPath())); + reference.setContent(resource.getTextContent()); + reference.setContentHash(resource.getContentHash()); + reference.setSize(resource.getSize()); + reference.setMetadata(copy(resource.getMetadata())); + references.add(reference); + } else if (resource.getKind() == SkillResourceKind.SCRIPT && resource.isText()) { + SkillScript script = new SkillScript(); + script.setPath(resource.getPath()); + script.setLanguage(SkillScriptLanguage.fromPath(resource.getPath())); + script.setContent(resource.getTextContent()); + script.setContentHash(resource.getContentHash()); + script.setSize(resource.getSize()); + script.setMetadata(copy(resource.getMetadata())); + scripts.add(script); + } else if (resource.getKind() == SkillResourceKind.ASSET) { + SkillAsset asset = new SkillAsset(); + asset.setPath(resource.getPath()); + asset.setName(SkillPaths.fileName(resource.getPath())); + asset.setMediaType(resource.getMediaType()); + asset.setContentRef(resource.getContentRef()); + asset.setContentHash(resource.getContentHash()); + asset.setSize(resource.getSize()); + asset.setMetadata(copy(resource.getMetadata())); + assets.add(asset); + } + } + skill.setReferences(references); + skill.setScripts(scripts); + skill.setAssets(assets); + } + + private static SkillResource base(String path, SkillResourceKind kind, String mediaType, + String hash, long size, SkillMetadata metadata) { + SkillResource resource = new SkillResource(); + resource.setPath(path); + resource.setKind(kind); + resource.setMediaType(mediaType); + resource.setContentHash(hash); + resource.setSize(size); + resource.setMetadata(copy(metadata)); + return resource; + } + + private static SkillMetadata copy(SkillMetadata metadata) { + return metadata == null ? new SkillMetadata() : new SkillMetadata(metadata.getValues()); + } +} diff --git a/easy-agents-skill/src/main/java/com/easyagents/skill/util/SkillUtf8.java b/easy-agents-skill/src/main/java/com/easyagents/skill/util/SkillUtf8.java new file mode 100644 index 0000000..60ddda8 --- /dev/null +++ b/easy-agents-skill/src/main/java/com/easyagents/skill/util/SkillUtf8.java @@ -0,0 +1,56 @@ +package com.easyagents.skill.util; + +import java.nio.ByteBuffer; +import java.nio.CharBuffer; +import java.nio.charset.CharacterCodingException; +import java.nio.charset.CharsetEncoder; +import java.nio.charset.CodingErrorAction; +import java.nio.charset.StandardCharsets; +import java.util.Objects; + +/** + * Skill 文本的严格 UTF-8 编码工具。 + */ +public final class SkillUtf8 { + + private SkillUtf8() { + } + + /** + * 严格编码文本,拒绝孤立 surrogate 等非法 UTF-16 输入。 + * + * @param text 待编码文本 + * @return UTF-8 字节 + * @throws CharacterCodingException 文本不能无损编码为 UTF-8 + */ + public static byte[] encode(String text) throws CharacterCodingException { + ByteBuffer encoded = newEncoder().encode(CharBuffer.wrap( + Objects.requireNonNull(text, "text"))); + byte[] bytes = new byte[encoded.remaining()]; + encoded.get(bytes); + return bytes; + } + + /** + * 严格计算文本的 UTF-8 字节数。 + * + * @param text 待计算文本 + * @return UTF-8 字节数 + * @throws CharacterCodingException 文本不能无损编码为 UTF-8 + */ + public static long byteLength(String text) throws CharacterCodingException { + return newEncoder().encode(CharBuffer.wrap( + Objects.requireNonNull(text, "text"))).remaining(); + } + + /** + * 创建拒绝替换非法输入的 UTF-8 编码器。 + * + * @return 严格 UTF-8 编码器 + */ + private static CharsetEncoder newEncoder() { + return StandardCharsets.UTF_8.newEncoder() + .onMalformedInput(CodingErrorAction.REPORT) + .onUnmappableCharacter(CodingErrorAction.REPORT); + } +} diff --git a/easy-agents-skill/src/main/java/com/easyagents/skill/validation/SkillValidationIssue.java b/easy-agents-skill/src/main/java/com/easyagents/skill/validation/SkillValidationIssue.java new file mode 100644 index 0000000..44743c4 --- /dev/null +++ b/easy-agents-skill/src/main/java/com/easyagents/skill/validation/SkillValidationIssue.java @@ -0,0 +1,79 @@ +package com.easyagents.skill.validation; + +import java.io.Serial; +import java.io.Serializable; +import java.util.Objects; + +/** + * 可定位的 Skill 校验问题。 + */ +public final class SkillValidationIssue implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + private final String code; + private final SkillValidationSeverity severity; + private final String path; + private final Integer line; + private final Integer column; + private final String message; + private final String suggestion; + + /** + * 创建 Skill 校验问题。 + * + * @param code 稳定问题码 + * @param severity 严重级别 + * @param path 文件路径 + * @param line 一基行号 + * @param column 一基列号 + * @param message 问题说明 + * @param suggestion 修复建议 + */ + public SkillValidationIssue(String code, SkillValidationSeverity severity, String path, + Integer line, Integer column, String message, String suggestion) { + this.code = Objects.requireNonNull(code, "code"); + this.severity = Objects.requireNonNull(severity, "severity"); + this.path = path; + this.line = line; + this.column = column; + this.message = Objects.requireNonNull(message, "message"); + this.suggestion = suggestion; + } + + /** @return 稳定问题码 */ + public String getCode() { + return code; + } + + /** @return 严重级别 */ + public SkillValidationSeverity getSeverity() { + return severity; + } + + /** @return 文件路径 */ + public String getPath() { + return path; + } + + /** @return 一基行号 */ + public Integer getLine() { + return line; + } + + /** @return 一基列号 */ + public Integer getColumn() { + return column; + } + + /** @return 问题说明 */ + public String getMessage() { + return message; + } + + /** @return 修复建议 */ + public String getSuggestion() { + return suggestion; + } +} diff --git a/easy-agents-skill/src/main/java/com/easyagents/skill/validation/SkillValidationMode.java b/easy-agents-skill/src/main/java/com/easyagents/skill/validation/SkillValidationMode.java new file mode 100644 index 0000000..0e18798 --- /dev/null +++ b/easy-agents-skill/src/main/java/com/easyagents/skill/validation/SkillValidationMode.java @@ -0,0 +1,17 @@ +package com.easyagents.skill.validation; + +/** + * Skill 标准校验模式。 + */ +public enum SkillValidationMode { + + /** + * 兼容历史包进入草稿;可修复的兼容问题以 warning 返回。 + */ + DRAFT_IMPORT, + + /** + * 正式新建、发布与标准导出;所有标准互操作约束均严格执行。 + */ + STANDARD +} diff --git a/easy-agents-skill/src/main/java/com/easyagents/skill/validation/SkillValidationReport.java b/easy-agents-skill/src/main/java/com/easyagents/skill/validation/SkillValidationReport.java new file mode 100644 index 0000000..1951fee --- /dev/null +++ b/easy-agents-skill/src/main/java/com/easyagents/skill/validation/SkillValidationReport.java @@ -0,0 +1,89 @@ +package com.easyagents.skill.validation; + +import com.easyagents.skill.exception.SkillValidationException; + +import java.io.Serial; +import java.io.Serializable; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/** + * Skill 结构化校验报告。 + */ +public final class SkillValidationReport implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + private final List issues = new ArrayList<>(); + + /** + * 添加校验问题。 + * + * @param issue 校验问题 + * @return 当前报告 + */ + public SkillValidationReport add(SkillValidationIssue issue) { + if (issue != null) { + issues.add(issue); + } + return this; + } + + /** + * 合并其他报告。 + * + * @param report 其他报告 + * @return 当前报告 + */ + public SkillValidationReport merge(SkillValidationReport report) { + if (report != null) { + issues.addAll(report.issues); + } + return this; + } + + /** + * 获取不可变问题列表。 + * + * @return 问题列表 + */ + public List getIssues() { + return Collections.unmodifiableList(issues); + } + + /** + * 判断是否存在错误。 + * + * @return 存在 ERROR 时为 true + */ + public boolean hasErrors() { + return issues.stream().anyMatch(issue -> issue.getSeverity() == SkillValidationSeverity.ERROR); + } + + /** + * 判断报告是否为空。 + * + * @return 没有问题时为 true + */ + public boolean isEmpty() { + return issues.isEmpty(); + } + + /** + * 错误存在时抛出包含完整报告的异常。 + * + * @throws SkillValidationException 校验报告包含错误 + */ + public void throwIfInvalid() { + if (hasErrors()) { + SkillValidationIssue first = issues.stream() + .filter(issue -> issue.getSeverity() == SkillValidationSeverity.ERROR) + .findFirst() + .orElseThrow(); + throw new SkillValidationException(first.getCode(), first.getPath(), first.getLine(), + first.getColumn(), first.getMessage(), null, this); + } + } +} diff --git a/easy-agents-skill/src/main/java/com/easyagents/skill/validation/SkillValidationSeverity.java b/easy-agents-skill/src/main/java/com/easyagents/skill/validation/SkillValidationSeverity.java new file mode 100644 index 0000000..030fa1a --- /dev/null +++ b/easy-agents-skill/src/main/java/com/easyagents/skill/validation/SkillValidationSeverity.java @@ -0,0 +1,16 @@ +package com.easyagents.skill.validation; + +/** + * Skill 校验问题严重级别。 + */ +public enum SkillValidationSeverity { + + /** 阻止导入确认、标准导出或发布。 */ + ERROR, + + /** 允许进入草稿但需要用户处理。 */ + WARNING, + + /** 不影响操作的信息提示。 */ + INFO +} diff --git a/easy-agents-skill/src/main/java/com/easyagents/skill/validation/SkillValidator.java b/easy-agents-skill/src/main/java/com/easyagents/skill/validation/SkillValidator.java index b0ef7dc..8654b40 100644 --- a/easy-agents-skill/src/main/java/com/easyagents/skill/validation/SkillValidator.java +++ b/easy-agents-skill/src/main/java/com/easyagents/skill/validation/SkillValidator.java @@ -1,6 +1,8 @@ package com.easyagents.skill.validation; +import com.easyagents.skill.exception.SkillValidationException; import com.easyagents.skill.model.Skill; +import com.easyagents.skill.model.SkillPackageLimits; /** * Skill 校验接口。 @@ -13,4 +15,51 @@ public interface SkillValidator { * @param skill Skill 聚合 */ void validate(Skill skill); + + /** + * 聚合校验 Skill 并返回结构化报告。 + * + *

兼容默认实现会把旧命令式异常转换为单个错误;新实现应覆盖以返回全部问题。

+ * + * @param skill Skill 聚合 + * @return 结构化校验报告 + */ + default SkillValidationReport validateReport(Skill skill) { + SkillValidationReport report = new SkillValidationReport(); + try { + validate(skill); + } catch (SkillValidationException e) { + report.add(new SkillValidationIssue(e.getCode(), SkillValidationSeverity.ERROR, e.getPath(), + e.getLine(), e.getColumn(), e.getMessage(), null)); + } + return report; + } + + /** + * 使用当前 Codec 操作的安全限额聚合校验 Skill。 + * + *

兼容实现默认委托给原有校验入口;需要检查包限额的实现应覆盖本方法。

+ * + * @param skill Skill 聚合 + * @param limits 当前读写操作的安全限额 + * @return 结构化校验报告 + */ + default SkillValidationReport validateReport(Skill skill, SkillPackageLimits limits) { + return validateReport(skill); + } + + /** + * 使用指定标准模式和当前 Codec 安全限额聚合校验 Skill。 + * + *

兼容实现默认忽略模式;需要区分草稿导入与正式标准约束的实现应覆盖本方法。

+ * + * @param skill Skill 聚合 + * @param limits 当前读写操作的安全限额 + * @param mode 标准校验模式 + * @return 结构化校验报告 + */ + default SkillValidationReport validateReport(Skill skill, SkillPackageLimits limits, + SkillValidationMode mode) { + return validateReport(skill, limits); + } } diff --git a/easy-agents-skill/src/main/java/com/easyagents/skill/validation/defaults/DefaultSkillValidator.java b/easy-agents-skill/src/main/java/com/easyagents/skill/validation/defaults/DefaultSkillValidator.java index 06fc481..7cf7ba7 100644 --- a/easy-agents-skill/src/main/java/com/easyagents/skill/validation/defaults/DefaultSkillValidator.java +++ b/easy-agents-skill/src/main/java/com/easyagents/skill/validation/defaults/DefaultSkillValidator.java @@ -1,156 +1,528 @@ package com.easyagents.skill.validation.defaults; import com.easyagents.skill.exception.SkillValidationException; -import com.easyagents.skill.model.*; +import com.easyagents.skill.model.Skill; +import com.easyagents.skill.model.SkillDocument; +import com.easyagents.skill.model.SkillPackageLimits; +import com.easyagents.skill.model.SkillResource; +import com.easyagents.skill.model.SkillResourceKind; +import com.easyagents.skill.model.SkillScriptLanguage; +import com.easyagents.skill.model.SkillSourceLocation; 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.SkillValidator; -import java.nio.charset.StandardCharsets; +import java.nio.charset.CharacterCodingException; +import java.util.HashMap; import java.util.HashSet; import java.util.List; +import java.util.Locale; import java.util.Map; import java.util.Set; +import java.util.regex.Pattern; /** - * 默认 Skill 聚合校验器。 + * 默认 Skill 聚合结构化校验器。 */ public class DefaultSkillValidator implements SkillValidator { + private static final Pattern CANONICAL_NAME = Pattern.compile("[a-z0-9]+(?:-[a-z0-9]+)*"); + private static final Pattern LEGACY_UNDERSCORE_NAME = Pattern.compile("[a-z0-9]+(?:_[a-z0-9]+)+"); + private static final Pattern SHA_256 = Pattern.compile("[a-f0-9]{64}"); + + private final SkillPackageLimits limits; + /** - * 校验 Skill 聚合。 + * 使用默认安全限额创建校验器。 + */ + public DefaultSkillValidator() { + this(SkillPackageLimits.defaults()); + } + + /** + * 使用指定安全限额创建校验器。 + * + * @param limits 安全限额 + */ + public DefaultSkillValidator(SkillPackageLimits limits) { + this.limits = limits == null ? SkillPackageLimits.defaults() : limits; + } + + /** + * 校验 Skill 聚合,存在错误时抛出包含完整报告的异常。 * * @param skill Skill 聚合 + * @throws SkillValidationException 校验失败 */ @Override public void validate(Skill skill) { + validateReport(skill, limits, SkillValidationMode.STANDARD).throwIfInvalid(); + } + + /** + * 聚合校验 Skill。 + * + * @param skill Skill 聚合 + * @return 结构化校验报告 + */ + @Override + public SkillValidationReport validateReport(Skill skill) { + return validateReport(skill, limits); + } + + /** + * 使用当前 Codec 操作的安全限额聚合校验 Skill。 + * + * @param skill Skill 聚合 + * @param operationLimits 当前读写操作的安全限额 + * @return 结构化校验报告 + */ + @Override + public SkillValidationReport validateReport(Skill skill, SkillPackageLimits operationLimits) { + return validateReport(skill, operationLimits, SkillValidationMode.DRAFT_IMPORT); + } + + /** + * 使用指定标准模式和当前操作限额聚合校验 Skill。 + * + * @param skill Skill 聚合 + * @param operationLimits 当前读写操作的安全限额 + * @param mode 标准校验模式 + * @return 结构化校验报告 + */ + @Override + public SkillValidationReport validateReport(Skill skill, SkillPackageLimits operationLimits, + SkillValidationMode mode) { + SkillPackageLimits effectiveLimits = operationLimits == null ? limits : operationLimits; + SkillValidationMode effectiveMode = mode == null + ? SkillValidationMode.DRAFT_IMPORT : mode; + SkillValidationReport report = new SkillValidationReport(); if (skill == null) { - throw new SkillValidationException("Skill is required."); + return report.add(error("SKILL_REQUIRED", null, "Skill is required.", null)); } - requireText(skill.getId(), "Skill id is required."); - requireText(skill.getName(), "Skill name is required."); - requireText(skill.getDescription(), "Skill description is required."); - requireText(skill.getSkillContent(), "SKILL.md content is required."); - validateSkillFrontmatter(skill); - Set paths = new HashSet<>(); - paths.add(SkillPaths.SKILL_FILE); - validateReferences(skill.getReferences(), paths); - validateScripts(skill.getScripts(), paths); - validateAssets(skill.getAssets(), paths); + SkillDocument document = parseDocument(skill, report, effectiveLimits); + validateName(skill, document, report, effectiveMode); + validateDescription(skill, document, report); + if (document != null) { + validateDocument(skill, document, report); + } + List resources = SkillResources.canonicalResources(skill); + validateAggregateLimits(skill, resources, report, effectiveLimits); + validateResources(skill.getName(), resources, report, effectiveLimits); + return report; } - private static void validateReferences(List references, Set paths) { - if (references == null) { + private static void validateName(Skill skill, SkillDocument document, + SkillValidationReport report, SkillValidationMode mode) { + SkillSourceLocation location = sourceLocation(document, "name"); + String name = skill.getName(); + if (isBlank(name)) { + report.add(errorAt("NAME_REQUIRED", SkillPaths.SKILL_FILE, location, "Skill name is required.", + "Set frontmatter name to a portable Skill name.")); return; } - for (SkillReference reference : references) { - if (reference == null) { - throw new SkillValidationException("Skill reference cannot be null."); - } - String path = validateFilePath(reference.getPath(), SkillPaths.REFERENCES_DIR, paths); - if (!SkillPaths.hasExtension(path, ".md")) { - throw new SkillValidationException("Skill reference must be a markdown file: " + path); - } - requireContent(reference.getContent(), "Skill reference content is required: " + path); - validateTextHash(path, reference.getContent(), reference.getContentHash()); - validateSize(reference.getSize(), path); + if (name.length() > 64) { + report.add(errorAt("NAME_TOO_LONG", SkillPaths.SKILL_FILE, location, + "Skill name cannot exceed 64 characters.", "Shorten the frontmatter name.")); + } else if (LEGACY_UNDERSCORE_NAME.matcher(name).matches()) { + SkillValidationSeverity severity = mode == SkillValidationMode.STANDARD + ? SkillValidationSeverity.ERROR : SkillValidationSeverity.WARNING; + report.add(new SkillValidationIssue("NON_CANONICAL_NAME", severity, + SkillPaths.SKILL_FILE, line(location), column(location), + mode == SkillValidationMode.STANDARD + ? "Standard Skill name must use hyphens instead of underscores: " + name + : "Legacy underscore Skill name is accepted only for draft import: " + name, + "Replace underscores with single hyphens before publishing or standard export.")); + } else if (!CANONICAL_NAME.matcher(name).matches()) { + report.add(errorAt("INVALID_NAME", SkillPaths.SKILL_FILE, location, + "Skill name must use lowercase letters, numbers, and single hyphens.", + "Use a name such as data-analysis.")); + } + if (!isBlank(skill.getPackageRoot()) && !name.equals(skill.getPackageRoot())) { + report.add(errorAt("ROOT_NAME_MISMATCH", SkillPaths.SKILL_FILE, location, + "Skill package directory must match frontmatter name.", + "Rename the package directory or update frontmatter name.")); } } - private static void validateScripts(List scripts, Set paths) { - if (scripts == null) { - return; - } - for (SkillScript script : scripts) { - if (script == null) { - throw new SkillValidationException("Skill script cannot be null."); - } - String path = validateFilePath(script.getPath(), SkillPaths.SCRIPTS_DIR, paths); - if (SkillScriptLanguage.fromPath(path) == SkillScriptLanguage.UNKNOWN) { - throw new SkillValidationException("Unsupported skill script extension: " + path); - } - if (script.getLanguage() == SkillScriptLanguage.UNKNOWN - || script.getLanguage() != SkillScriptLanguage.fromPath(path)) { - throw new SkillValidationException("Skill script language does not match path: " + path); - } - requireContent(script.getContent(), "Skill script content is required: " + path); - validateTextHash(path, script.getContent(), script.getContentHash()); - validateSize(script.getSize(), path); + private static void validateDescription(Skill skill, SkillDocument document, + SkillValidationReport report) { + SkillSourceLocation location = sourceLocation(document, "description"); + if (isBlank(skill.getDescription())) { + report.add(errorAt("DESCRIPTION_REQUIRED", SkillPaths.SKILL_FILE, location, + "Skill description is required.", "Describe both the capability and when to use it.")); + } else if (skill.getDescription().length() > 1_024) { + report.add(errorAt("DESCRIPTION_TOO_LONG", SkillPaths.SKILL_FILE, location, + "Skill description cannot exceed 1024 characters.", "Shorten the description.")); } } - private static void validateAssets(List assets, Set paths) { - if (assets == null) { - return; + private static SkillDocument parseDocument(Skill skill, SkillValidationReport report, + SkillPackageLimits limits) { + if (isBlank(skill.getSkillContent())) { + report.add(error("SKILL_CONTENT_REQUIRED", SkillPaths.SKILL_FILE, + "SKILL.md content is required.", null)); + return null; } - for (SkillAsset asset : assets) { - if (asset == null) { - throw new SkillValidationException("Skill asset cannot be null."); - } - String path = validateFilePath(asset.getPath(), SkillPaths.ASSETS_DIR, paths); - requireText(asset.getName(), "Skill asset name is required: " + path); - requireText(asset.getMediaType(), "Skill asset media type is required: " + path); - requireText(asset.getContentRef(), "Skill asset content ref is required: " + path); - requireText(asset.getContentHash(), "Skill asset content hash is required: " + path); - if (!asset.getContentRef().equals("sha256:" + asset.getContentHash())) { - throw new SkillValidationException("Skill asset content ref does not match hash: " + path); - } - validateSize(asset.getSize(), path); + try { + return SkillFrontmatter.parseDocument(skill.getSkillContent(), limits); + } catch (SkillValidationException e) { + report.add(new SkillValidationIssue(e.getCode(), SkillValidationSeverity.ERROR, e.getPath(), + e.getLine(), e.getColumn(), e.getMessage(), "Fix the YAML frontmatter and retry.")); + return null; } } - private static void validateSkillFrontmatter(Skill skill) { - Map values = SkillFrontmatter.parse(skill.getSkillContent()); - if (!skill.getName().equals(values.get("name").toString())) { - throw new SkillValidationException("Skill name must match SKILL.md frontmatter."); - } - if (!skill.getDescription().equals(values.get("description").toString())) { - throw new SkillValidationException("Skill description must match SKILL.md frontmatter."); + private static void validateDocument(Skill skill, SkillDocument document, SkillValidationReport report) { + Map values = document.getFrontmatter().getValues(); + validateCoreString(document, values, "name", skill.getName(), report); + validateCoreString(document, values, "description", skill.getDescription(), report); + validateOptionalString(document, values, "license", null, report); + Object compatibility = values.get("compatibility"); + if (compatibility != null && (!(compatibility instanceof String text) + || text.isBlank() || text.length() > 500)) { + report.add(errorAt("INVALID_COMPATIBILITY", SkillPaths.SKILL_FILE, + sourceLocation(document, "compatibility"), + "Frontmatter compatibility must be a non-blank string of at most 500 characters.", null)); } + validateOptionalString(document, values, "allowed-tools", "INVALID_ALLOWED_TOOLS", report); + validateMetadataField(document, values.get("metadata"), report); if (skill.getMetadata() == null || !skill.getMetadata().getValues().equals(values)) { - throw new SkillValidationException("Skill metadata must match SKILL.md frontmatter."); + report.add(errorAt("METADATA_MISMATCH", SkillPaths.SKILL_FILE, + sourceLocation(document, "name"), + "Skill metadata must match SKILL.md frontmatter.", + "Reparse SKILL.md before saving the aggregate.")); + } + if (document.getMarkdownBody().length() > 30_000) { + report.add(new SkillValidationIssue("LONG_SKILL_BODY", SkillValidationSeverity.WARNING, + SkillPaths.SKILL_FILE, null, null, + "SKILL.md body is large and may reduce progressive-disclosure efficiency.", + "Move detailed material into references/.")); } } - private static String validateFilePath(String path, String expectedTopDir, Set paths) { - String normalized = SkillPaths.normalize(path); - if (!expectedTopDir.equals(SkillPaths.firstSegment(normalized))) { - throw new SkillValidationException("Skill file must be under " + expectedTopDir + "/: " + normalized); - } - if (normalized.indexOf('/') < 0 || normalized.endsWith("/")) { - throw new SkillValidationException("Skill file path must include file name: " + normalized); - } - if (!paths.add(normalized)) { - throw new SkillValidationException("Duplicate skill file path: " + normalized); - } - return normalized; - } - - private static void validateTextHash(String path, String content, String contentHash) { - requireText(contentHash, "Skill file content hash is required: " + path); - String actualHash = SkillHashes.sha256Hex(content.getBytes(StandardCharsets.UTF_8)); - if (!actualHash.equals(contentHash)) { - throw new SkillValidationException("Skill file content hash does not match: " + path); + private static void validateCoreString(SkillDocument document, Map values, + String key, String expected, + SkillValidationReport report) { + Object value = values.get(key); + if (!(value instanceof String text) || text.isBlank()) { + report.add(errorAt(key.toUpperCase(Locale.ROOT) + "_REQUIRED", SkillPaths.SKILL_FILE, + sourceLocation(document, key), + "SKILL.md frontmatter " + key + " must be a non-blank string.", null)); + } else if (!text.equals(expected)) { + report.add(errorAt(key.toUpperCase(Locale.ROOT) + "_MISMATCH", SkillPaths.SKILL_FILE, + sourceLocation(document, key), + "Skill " + key + " must match SKILL.md frontmatter.", null)); } } - private static void validateSize(long size, String path) { - if (size < 0) { - throw new SkillValidationException("Skill file size cannot be negative: " + path); + private static void validateOptionalString(SkillDocument document, Map values, + String key, String code, + SkillValidationReport report) { + Object value = values.get(key); + if (value != null && (!(value instanceof String text) || text.isBlank())) { + report.add(errorAt(code == null ? "INVALID_" + key.toUpperCase(Locale.ROOT) : code, + SkillPaths.SKILL_FILE, sourceLocation(document, key), + "Frontmatter " + key + " must be a non-blank string when provided.", null)); } } - private static void requireText(String value, String message) { - if (value == null || value.isBlank()) { - throw new SkillValidationException(message); + private static void validateMetadataField(SkillDocument document, Object metadata, + SkillValidationReport report) { + if (metadata == null) { + return; + } + if (!(metadata instanceof Map map)) { + report.add(errorAt("INVALID_METADATA", SkillPaths.SKILL_FILE, + sourceLocation(document, "metadata"), + "Frontmatter metadata must be a mapping.", null)); + return; + } + if (map.values().stream().anyMatch(value -> !(value instanceof String))) { + SkillSourceLocation location = sourceLocation(document, "metadata"); + report.add(new SkillValidationIssue("NON_STANDARD_METADATA", + SkillValidationSeverity.WARNING, SkillPaths.SKILL_FILE, + line(location), column(location), + "Nested or non-string metadata is preserved for AgentScope compatibility.", + "Use string values only for strict Agent Skills interoperability.")); } } - private static void requireContent(String value, String message) { - if (value == null) { - throw new SkillValidationException(message); + private static void validateResources(String skillName, List resources, + SkillValidationReport report, + SkillPackageLimits limits) { + Set exactPaths = new HashSet<>(); + Map collisionPaths = new HashMap<>(); + Map descendantPaths = new HashMap<>(); + exactPaths.add(SkillPaths.SKILL_FILE); + collisionPaths.put(SkillPaths.collisionKey(SkillPaths.SKILL_FILE), SkillPaths.SKILL_FILE); + for (SkillResource resource : resources) { + if (resource == null) { + report.add(error("NULL_RESOURCE", null, "Skill resource cannot be null.", null)); + continue; + } + if (isBlank(resource.getPath())) { + report.add(error("RESOURCE_PATH_REQUIRED", null, + "Skill resource path is required.", + "Set a safe relative path inside the Skill package.")); + continue; + } + try { + SkillUtf8.encode(resource.getPath()); + } catch (CharacterCodingException e) { + report.add(error("INVALID_UTF8_PATH", resource.getPath(), + "Skill resource path must be losslessly encodable as UTF-8.", + "Remove malformed UTF-16 surrogate code units from the path.")); + continue; + } + String path; + try { + path = SkillPaths.normalize(resource.getPath()); + } catch (SkillValidationException e) { + report.add(error("UNSAFE_RESOURCE_PATH", resource.getPath(), e.getMessage(), null)); + continue; + } + String portablePath = isBlank(skillName) ? path : skillName + "/" + path; + if (portablePath.length() > limits.getMaxPathLength()) { + report.add(error("PATH_LENGTH_LIMIT", path, + "Skill resource path exceeds the configured length limit.", null)); + } + if (SkillPaths.depth(portablePath) > limits.getMaxPathDepth()) { + report.add(error("PATH_DEPTH_LIMIT", path, + "Skill resource path exceeds the configured depth limit.", null)); + } + if (!path.equals(resource.getPath())) { + report.add(error("NON_NORMALIZED_PATH", path, + "Skill resource path must already be normalized.", null)); + } + if (!exactPaths.add(path)) { + report.add(error("DUPLICATE_RESOURCE_PATH", path, + "Duplicate Skill resource path: " + path, null)); + continue; + } + String collisionKey = SkillPaths.collisionKey(path); + String conflicting = collisionPaths.get(collisionKey); + if (conflicting != null) { + report.add(error("RESOURCE_PATH_COLLISION", path, + "Skill resource path conflicts with " + conflicting + ".", null)); + continue; + } + String hierarchyConflict = findHierarchyConflict(collisionKey, collisionPaths, descendantPaths); + if (hierarchyConflict != null) { + report.add(error("RESOURCE_PATH_HIERARCHY_CONFLICT", path, + "Skill resource path conflicts with file " + hierarchyConflict + + " in the same path hierarchy.", null)); + continue; + } + collisionPaths.put(collisionKey, path); + indexDescendantPath(collisionKey, path, descendantPaths); + SkillResourceKind expectedKind = SkillResources.classify(path); + if (resource.getKind() != expectedKind) { + report.add(error("RESOURCE_KIND_MISMATCH", path, + "Skill resource kind does not match its path.", null)); + } + validateResourceContent(resource, path, report, limits); } } + + private static void validateResourceContent(SkillResource resource, String path, + SkillValidationReport report, SkillPackageLimits limits) { + boolean expectedText = SkillResources.isText(path, resource.getKind(), resource.getMediaType()); + if (expectedText != resource.isText()) { + String code = resource.getKind() == SkillResourceKind.SCRIPT + ? "SCRIPT_TEXT_REQUIRED" : "RESOURCE_CONTENT_MODE_MISMATCH"; + report.add(error(code, path, + expectedText + ? "Resource path and media type require strict UTF-8 text content." + : "Resource path and media type require a binary content reference.", + "Store the resource using the canonical text or binary representation.")); + } + if (resource.getKind() == SkillResourceKind.SCRIPT && resource.isText() && resource.getSize() == 0) { + report.add(new SkillValidationIssue("EMPTY_SCRIPT", SkillValidationSeverity.WARNING, + path, null, null, "Script resource is empty.", + "Add script source or remove the unused file.")); + } + if (resource.getKind() == SkillResourceKind.SCRIPT + && SkillScriptLanguage.fromPath(path) == SkillScriptLanguage.UNKNOWN) { + report.add(new SkillValidationIssue("SCRIPT_LANGUAGE_UNRECOGNIZED", + SkillValidationSeverity.WARNING, path, null, null, + "Script language is not recognized from its extension.", + "Use .py, .js, or .sh for first-class editing and syntax highlighting.")); + } + if (isBlank(resource.getMediaType())) { + report.add(error("MEDIA_TYPE_REQUIRED", path, "Skill resource media type is required.", null)); + } + if (resource.getSize() < 0) { + report.add(error("NEGATIVE_RESOURCE_SIZE", path, + "Skill resource size cannot be negative.", null)); + } + byte[] textBytes = null; + if (resource.isText()) { + try { + textBytes = SkillUtf8.encode(resource.getTextContent()); + } catch (CharacterCodingException e) { + report.add(error("INVALID_UTF8", path, + "Text Skill resource must be losslessly encodable as UTF-8.", + "Remove malformed UTF-16 surrogate code units.")); + return; + } + } + if (isBlank(resource.getContentHash()) || !SHA_256.matcher(resource.getContentHash()).matches()) { + report.add(error("INVALID_RESOURCE_HASH", path, + "Skill resource SHA-256 is required and must be lowercase hexadecimal.", null)); + return; + } + if (resource.isText()) { + if (resource.getSize() > limits.getMaxTextFileBytes()) { + report.add(error("TEXT_FILE_SIZE_LIMIT", path, + "Text Skill resource exceeds the configured size limit.", null)); + } + if (resource.getContentRef() != null) { + report.add(error("AMBIGUOUS_RESOURCE_CONTENT", path, + "Text resources cannot also contain a binary content reference.", null)); + } + if (resource.getSize() != textBytes.length) { + report.add(error("RESOURCE_SIZE_MISMATCH", path, + "Skill resource size does not match UTF-8 content.", null)); + } + if (!resource.getContentHash().equals(SkillHashes.sha256Hex(textBytes))) { + report.add(error("RESOURCE_HASH_MISMATCH", path, + "Skill resource hash does not match text content.", null)); + } + } else { + if (resource.getSize() > limits.getMaxBinaryFileBytes()) { + report.add(error("BINARY_FILE_SIZE_LIMIT", path, + "Binary Skill resource exceeds the configured size limit.", null)); + } + if (isBlank(resource.getContentRef())) { + report.add(error("CONTENT_REF_REQUIRED", path, + "Binary Skill resource content reference is required.", null)); + } else if (!resource.getContentRef().equals("sha256:" + resource.getContentHash())) { + report.add(error("CONTENT_REF_MISMATCH", path, + "Binary Skill resource content reference must match its SHA-256.", null)); + } + } + } + + private static void validateAggregateLimits(Skill skill, List resources, + SkillValidationReport report, SkillPackageLimits limits) { + if (!isBlank(skill.getName())) { + String skillPath = skill.getName() + "/" + SkillPaths.SKILL_FILE; + if (skillPath.length() > limits.getMaxPathLength()) { + report.add(error("PATH_LENGTH_LIMIT", skillPath, + "Skill entry path exceeds the configured length limit.", null)); + } + if (SkillPaths.depth(skillPath) > limits.getMaxPathDepth()) { + report.add(error("PATH_DEPTH_LIMIT", skillPath, + "Skill entry path exceeds the configured depth limit.", null)); + } + } + if (resources.size() + 1 > limits.getMaxEntryCount()) { + report.add(error("ENTRY_COUNT_LIMIT", null, + "Skill contains too many files.", null)); + } + long total = 0; + if (skill.getSkillContent() != null) { + try { + total = SkillUtf8.byteLength(skill.getSkillContent()); + } catch (CharacterCodingException ignored) { + // parseDocument 已返回可定位的 INVALID_UTF8;非法文本不参与后续大小计算。 + } + } + if (total > limits.getMaxTextFileBytes()) { + report.add(error("TEXT_FILE_SIZE_LIMIT", SkillPaths.SKILL_FILE, + "SKILL.md exceeds the configured size limit.", null)); + } + for (SkillResource resource : resources) { + if (resource == null || resource.getSize() < 0) { + continue; + } + try { + total = Math.addExact(total, resource.getSize()); + } catch (ArithmeticException e) { + total = Long.MAX_VALUE; + } + if (total > limits.getMaxTotalUncompressedBytes()) { + report.add(error("TOTAL_SIZE_LIMIT", resource.getPath(), + "Skill resources exceed the configured total size limit.", null)); + break; + } + } + } + + /** + * 查找当前文件路径与已索引文件之间的祖先或后代冲突。 + * + * @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 SkillValidationIssue error(String code, String path, String message, String suggestion) { + return new SkillValidationIssue(code, SkillValidationSeverity.ERROR, path, null, null, + message, suggestion); + } + + private static SkillValidationIssue errorAt(String code, String path, SkillSourceLocation location, + String message, String suggestion) { + return new SkillValidationIssue(code, SkillValidationSeverity.ERROR, path, + line(location), column(location), message, suggestion); + } + + private static SkillSourceLocation sourceLocation(SkillDocument document, String key) { + return document == null ? null : document.getFrontmatterLocation(key); + } + + private static Integer line(SkillSourceLocation location) { + return location == null ? null : location.getLine(); + } + + private static Integer column(SkillSourceLocation location) { + return location == null ? null : location.getColumn(); + } + + private static boolean isBlank(String value) { + return value == null || value.isBlank(); + } } diff --git a/easy-agents-skill/src/test/java/com/easyagents/skill/codec/ZipSkillPackageCodecTest.java b/easy-agents-skill/src/test/java/com/easyagents/skill/codec/ZipSkillPackageCodecTest.java index 50831bc..68b9dee 100644 --- a/easy-agents-skill/src/test/java/com/easyagents/skill/codec/ZipSkillPackageCodecTest.java +++ b/easy-agents-skill/src/test/java/com/easyagents/skill/codec/ZipSkillPackageCodecTest.java @@ -1,240 +1,1044 @@ package com.easyagents.skill.codec; import com.easyagents.skill.exception.SkillPackageException; +import com.easyagents.skill.exception.SkillValidationException; +import com.easyagents.skill.factory.SkillFactory; import com.easyagents.skill.model.Skill; +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.memory.InMemorySkillContentStore; +import com.easyagents.skill.util.SkillHashes; +import com.easyagents.skill.util.SkillPaths; +import com.easyagents.skill.validation.SkillValidator; +import org.apache.commons.compress.archivers.zip.UnixStat; +import org.apache.commons.compress.archivers.zip.ZipArchiveEntry; +import org.apache.commons.compress.archivers.zip.ZipArchiveOutputStream; import org.junit.Assert; import org.junit.Test; import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; import java.io.InputStream; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Arrays; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.zip.ZipEntry; +import java.util.zip.ZipInputStream; import java.util.zip.ZipOutputStream; /** - * ZipSkillPackageCodec 单元测试。 + * ZipSkillPackageCodec 安全边界与 round-trip 测试。 */ public class ZipSkillPackageCodecTest { /** - * 导入包含一个 Skill 文件夹的 zip。 + * 导出计划只保留已有文本源,不能再次持有整包文本 byte 数组。 + * + * @throws ClassNotFoundException 内部输出计划类型缺失 */ @Test - public void importOneSkillFolder() { - List skills = importZip(files( - "skill-a/SKILL.md", skillMd("Skill A", "Desc A"), - "skill-a/references/rules/a.md", "# Rule", - "skill-a/scripts/run.py", "print('ok')", - "skill-a/assets/images/logo.png", "png-data" - )); + public void outputPlanDoesNotRetainTextByteArrays() throws ClassNotFoundException { + Class outputFile = Class.forName(ZipSkillPackageCodec.class.getName() + "$OutputFile"); - Assert.assertEquals(1, skills.size()); - Skill skill = skills.get(0); - Assert.assertEquals("skill-a", skill.getId()); - Assert.assertEquals("Skill A", skill.getName()); - Assert.assertEquals("Desc A", skill.getDescription()); + Assert.assertFalse(Arrays.stream(outputFile.getDeclaredFields()) + .anyMatch(field -> field.getType() == byte[].class)); + } + + /** + * 非 ZIP 输入与带任意前导数据的归档被拒绝。 + */ + @Test + public void rejectInvalidZipSignature() { + assertPackageCode("INVALID_ZIP", utf8("not-a-zip"), SkillPackageReadOptions.defaults()); + byte[] zip = zip(files("skill-a/SKILL.md", utf8(skillMd("skill-a")))); + byte[] withPreamble = new byte[zip.length + 1]; + withPreamble[0] = 1; + System.arraycopy(zip, 0, withPreamble, 1, zip.length); + assertPackageCode("INVALID_ZIP", withPreamble, SkillPackageReadOptions.defaults()); + } + + /** + * 单目录包导入通用资源并保持旧资源视图。 + */ + @Test + public void decodeWrappedSingleSkillWithGenericResources() { + InMemorySkillContentStore store = new InMemorySkillContentStore(); + SkillPackageReadResult result = decode(new ZipSkillPackageCodec(store), zip(files( + "skill-a/SKILL.md", utf8(skillMd("skill-a")), + "skill-a/references/rules.md", utf8("# Rules"), + "skill-a/scripts/run.rb", utf8("puts 'ok'"), + "skill-a/examples/sample.json", utf8("{\"ok\":true}"), + "skill-a/docs/notes.txt", utf8("notes"), + "skill-a/assets/logo.bin", new byte[]{0, 1, 2} + ))); + + Assert.assertEquals(SkillPackageLayout.SINGLE_DIRECTORY, + result.getSkillPackage().getLayout()); + Skill skill = result.getSkillPackage().getSkills().get(0); + Assert.assertNull(skill.getId()); + Assert.assertEquals("skill-a", skill.getPackageRoot()); + Assert.assertEquals(5, skill.getResources().size()); + Assert.assertTrue(skill.getResources().stream().anyMatch(resource -> + resource.getKind() == SkillResourceKind.EXAMPLE)); + Assert.assertTrue(skill.getResources().stream().anyMatch(resource -> + resource.getKind() == SkillResourceKind.OTHER)); Assert.assertEquals(1, skill.getReferences().size()); - Assert.assertEquals("references/rules/a.md", skill.getReferences().get(0).getPath()); Assert.assertEquals(1, skill.getScripts().size()); Assert.assertEquals(1, skill.getAssets().size()); - Assert.assertEquals("assets/images/logo.png", skill.getAssets().get(0).getPath()); + Assert.assertTrue(store.exists(skill.getAssets().get(0).getContentRef())); } /** - * 导入包含多个 Skill 文件夹的 zip。 + * 根目录直压单 Skill 被正确识别。 */ @Test - public void importMultipleSkillFolders() { - List skills = importZip(files( - "skill-a/SKILL.md", skillMd("Skill A", "Desc A"), - "skill-b/SKILL.md", skillMd("Skill B", "Desc B") - )); + public void decodeRootSkillPackage() { + SkillPackageReadResult result = decode(new ZipSkillPackageCodec(), zip(files( + "SKILL.md", utf8(skillMd("root-skill")), + "references/a.md", utf8("# A") + ))); - Assert.assertEquals(2, skills.size()); - Assert.assertEquals("skill-a", skills.get(0).getId()); - Assert.assertEquals("skill-b", skills.get(1).getId()); + Assert.assertEquals(SkillPackageLayout.ROOT_SKILL, result.getSkillPackage().getLayout()); + Assert.assertEquals("root-skill", result.getSkillPackage().getSkills().get(0).getPackageRoot()); } /** - * 拒绝 zip 根目录直接包含 SKILL.md。 - */ - @Test(expected = SkillPackageException.class) - public void rejectRootSkillMd() { - importZip(files("SKILL.md", skillMd("Skill", "Desc"))); - } - - /** - * 拒绝未知顶级目录。 - */ - @Test(expected = SkillPackageException.class) - public void rejectUnknownTopLevelDirectory() { - importZip(files( - "skill-a/SKILL.md", skillMd("Skill A", "Desc A"), - "skill-a/unknown/a.md", "# Unknown" - )); - } - - /** - * 拒绝 references 下的非 md 文件。 - */ - @Test(expected = SkillPackageException.class) - public void rejectNonMarkdownReference() { - importZip(files( - "skill-a/SKILL.md", skillMd("Skill A", "Desc A"), - "skill-a/references/a.txt", "text" - )); - } - - /** - * 拒绝不支持的脚本扩展名。 - */ - @Test(expected = SkillPackageException.class) - public void rejectUnsupportedScript() { - importZip(files( - "skill-a/SKILL.md", skillMd("Skill A", "Desc A"), - "skill-a/scripts/run.rb", "puts 'ok'" - )); - } - - /** - * assets 允许任意扩展名。 + * 多目录包按稳定目录顺序导入。 */ @Test - public void allowArbitraryAssetExtension() { - List skills = importZip(files( - "skill-a/SKILL.md", skillMd("Skill A", "Desc A"), - "skill-a/assets/model.custom", "asset" + public void decodeMultipleSkills() { + ZipSkillPackageCodec codec = new ZipSkillPackageCodec(); + SkillPackageReadResult result = decode(codec, zip(files( + "skill-b/SKILL.md", utf8(skillMd("skill-b")), + "skill-a/SKILL.md", utf8(skillMd("skill-a")) + ))); + + Assert.assertEquals(SkillPackageLayout.MULTI_DIRECTORY, result.getSkillPackage().getLayout()); + Assert.assertEquals(List.of("skill-a", "skill-b"), result.getSkillPackage().getSkills().stream() + .map(Skill::getName).toList()); + + ByteArrayOutputStream output = new ByteArrayOutputStream(); + SkillPackageWriteResult writeResult = codec.encode( + result.getSkillPackage(), output, SkillPackageWriteOptions.defaults()); + Assert.assertEquals(SkillPackageLayout.MULTI_DIRECTORY, writeResult.getLayout()); + Assert.assertEquals(List.of("skill-a", "skill-b"), decode(codec, output.toByteArray()) + .getSkillPackage().getSkills().stream().map(Skill::getName).toList()); + } + + /** + * 嵌套 frontmatter 与未知字段在 Codec 中保留。 + */ + @Test + public void preserveNestedFrontmatter() { + String markdown = "---\nname: nested-skill\ndescription: Nested metadata\n" + + "metadata:\n owner: team-a\n levels:\n - 1\n - 2\n---\n# Nested\n"; + + Skill skill = decode(new ZipSkillPackageCodec(), zip(files( + "nested-skill/SKILL.md", utf8(markdown) + ))).getSkillPackage().getSkills().get(0); + + Assert.assertTrue(skill.getMetadata().get("metadata") instanceof Map); + Assert.assertEquals(markdown, skill.getSkillContent()); + } + + /** + * 根 SKILL.md 与包装 Skill 目录混用时拒绝。 + */ + @Test + public void rejectMixedPackageLayout() { + assertPackageCode("MIXED_PACKAGE_LAYOUT", zip(files( + "SKILL.md", utf8(skillMd("root-skill")), + "other-skill/SKILL.md", utf8(skillMd("other-skill")) + )), SkillPackageReadOptions.defaults()); + } + + /** + * 包装布局中的根散落文件被拒绝。 + */ + @Test + public void rejectUnownedRootFile() { + assertPackageCode("UNOWNED_ROOT_FILE", zip(files( + "skill-a/SKILL.md", utf8(skillMd("skill-a")), + "README.md", utf8("orphan") + )), SkillPackageReadOptions.defaults()); + } + + /** + * 路径穿越被拒绝。 + */ + @Test + public void rejectPathTraversal() { + assertPackageCode("UNSAFE_ENTRY_PATH", zip(files( + "skill-a/SKILL.md", utf8(skillMd("skill-a")), + "skill-a/../escape.txt", utf8("escape") + )), SkillPackageReadOptions.defaults()); + } + + /** + * 大小写冲突路径被拒绝。 + */ + @Test + public void rejectCaseConflictingPaths() { + assertPackageCode("PATH_COLLISION", zip(files( + "skill-a/SKILL.md", utf8(skillMd("skill-a")), + "skill-a/references/A.md", utf8("A"), + "skill-a/references/a.md", utf8("B") + )), SkillPackageReadOptions.defaults()); + } + + /** + * 普通文件不能同时作为另一个文件的祖先路径,且不受 entry 顺序影响。 + */ + @Test + public void rejectFileAndDescendantPathConflictsInBothOrders() { + assertPackageCode("PATH_HIERARCHY_CONFLICT", zip(files( + "skill-a/SKILL.md", utf8(skillMd("skill-a")), + "skill-a/references", utf8("file"), + "skill-a/references/a.md", utf8("descendant") + )), SkillPackageReadOptions.defaults()); + assertPackageCode("PATH_HIERARCHY_CONFLICT", zip(files( + "skill-a/SKILL.md", utf8(skillMd("skill-a")), + "skill-a/references/a.md", utf8("descendant"), + "skill-a/references", utf8("file") + )), SkillPackageReadOptions.defaults()); + } + + /** + * Unicode NFC 等价路径被拒绝。 + */ + @Test + public void rejectUnicodeEquivalentPaths() { + assertPackageCode("DUPLICATE_ENTRY", zip(files( + "skill-a/SKILL.md", utf8(skillMd("skill-a")), + "skill-a/references/\u00e9.md", utf8("A"), + "skill-a/references/e\u0301.md", utf8("B") + )), SkillPackageReadOptions.defaults()); + } + + /** + * 非法 UTF-8 文本被拒绝并指出文件路径。 + */ + @Test + public void rejectInvalidUtf8() { + byte[] bytes = zip(files( + "skill-a/SKILL.md", utf8(skillMd("skill-a")), + "skill-a/references/a.md", new byte[]{(byte) 0xC3, 0x28} + )); + try { + decode(new ZipSkillPackageCodec(), bytes); + Assert.fail("Invalid UTF-8 should fail."); + } catch (SkillPackageException e) { + Assert.assertEquals("INVALID_UTF8", e.getCode()); + Assert.assertEquals("skill-a/references/a.md", e.getPath()); + } + } + + /** + * ZIP entry 原始文件名字节不是严格 UTF-8 时拒绝导入。 + */ + @Test + public void rejectInvalidUtf8EntryName() { + assertPackageCode("INVALID_UTF8_ENTRY_NAME", invalidUtf8EntryNameZip(), + SkillPackageReadOptions.defaults()); + } + + /** + * 中央目录 CRC 与实际内容不一致时拒绝。 + */ + @Test + public void rejectCrcMismatch() { + byte[] original = zip(files( + "skill-a/SKILL.md", utf8(skillMd("skill-a")) )); - Assert.assertEquals(1, skills.get(0).getAssets().size()); - Assert.assertEquals("application/octet-stream", skills.get(0).getAssets().get(0).getMediaType()); + assertPackageCode("CRC_MISMATCH", tamperFirstCentralDirectoryCrc(original), + SkillPackageReadOptions.defaults()); } /** - * 忽略 macOS 系统文件和空目录。 + * Unix 符号链接 entry 被拒绝。 */ @Test - public void ignoreSystemFilesAndEmptyDirectories() { - Map files = files( - "__MACOSX/._x", "ignored", - "skill-a/.DS_Store", "ignored", - "skill-a/SKILL.md", skillMd("Skill A", "Desc A") - ); - List skills = new ZipSkillPackageCodec().importZip(new ByteArrayInputStream(zip(files, "skill-a/references/"))); - - Assert.assertEquals(1, skills.size()); - Assert.assertEquals("skill-a", skills.get(0).getId()); + public void rejectUnixSymlink() { + assertPackageCode("SYMLINK_ENTRY", symlinkZip(), SkillPackageReadOptions.defaults()); } /** - * 嵌套 references 和 assets 路径正常导入。 + * 目录和待忽略系统 entry 也必须先通过路径安全校验。 */ @Test - public void allowNestedManagedDirectories() { - List skills = importZip(files( - "skill-a/SKILL.md", skillMd("Skill A", "Desc A"), - "skill-a/references/rules/a.md", "# Nested", - "skill-a/assets/images/logo.png", "logo" + public void rejectUnsafeDirectoryBeforeIgnoringEntries() { + assertPackageCode("UNSAFE_ENTRY_PATH", zip(files( + "skill-a/SKILL.md", utf8(skillMd("skill-a")) + ), "../../"), SkillPackageReadOptions.defaults()); + assertPackageCode("UNSAFE_ENTRY_PATH", zip(files( + "skill-a/SKILL.md", utf8(skillMd("skill-a")), + "__MACOSX/../../escape", utf8("escape") + )), SkillPackageReadOptions.defaults()); + } + + /** + * entry 数、文本大小和压缩比限额均在读取前生效。 + */ + @Test + public void enforcePackageLimits() { + byte[] twoEntries = zip(files( + "skill-a/SKILL.md", utf8(skillMd("skill-a")), + "skill-a/references/a.md", utf8("A") + )); + assertPackageCode("ENTRY_COUNT_LIMIT", twoEntries, new SkillPackageReadOptions( + SkillPackageLimits.builder().maxEntryCount(1).build())); + + byte[] largeText = zip(files( + "skill-a/SKILL.md", utf8(skillMd("skill-a")), + "skill-a/references/a.md", utf8("x".repeat(300)) + )); + assertPackageCode("TEXT_FILE_SIZE_LIMIT", largeText, new SkillPackageReadOptions( + SkillPackageLimits.builder().maxTextFileBytes(128).build())); + + byte[] compressible = zip(files( + "skill-a/SKILL.md", utf8(skillMd("skill-a")), + "skill-a/references/bomb.md", utf8("a".repeat(20_000)) + )); + assertPackageCode("COMPRESSION_RATIO_LIMIT", compressible, new SkillPackageReadOptions( + SkillPackageLimits.builder().maxCompressionRatio(2D).build())); + } + + /** + * 中央目录预扫描不能信任 EOCD 的较小声明值,且必须在构造 ZipFile 前拒绝超量条目。 + */ + @Test + public void preflightCountsActualCentralDirectoryEntries() { + assertPackageCode("ENTRY_COUNT_LIMIT", syntheticCentralDirectoryZip(3, 1), + new SkillPackageReadOptions( + SkillPackageLimits.builder().maxEntryCount(2).build())); + } + + /** + * 未超限时,EOCD 声明数量与中央目录实际数量不一致必须作为非法 ZIP 拒绝。 + */ + @Test + public void rejectInconsistentCentralDirectoryEntryCount() { + assertPackageCode("INVALID_ZIP", syntheticCentralDirectoryZip(3, 1), + new SkillPackageReadOptions( + SkillPackageLimits.builder().maxEntryCount(4).build())); + } + + /** + * ZIP64 中央目录也必须按实际条目数在 ZipFile 对象化前执行上限。 + */ + @Test + public void preflightCountsActualZip64CentralDirectoryEntries() { + assertPackageCode("ENTRY_COUNT_LIMIT", syntheticZip64CentralDirectoryZip(3, 1), + new SkillPackageReadOptions( + SkillPackageLimits.builder().maxEntryCount(2).build())); + } + + /** + * 中央目录末尾被截断时,即使 EOCD 边界同步缩短也必须拒绝。 + */ + @Test + public void rejectTruncatedCentralDirectory() { + byte[] truncated = truncateLastCentralDirectoryByte( + syntheticCentralDirectoryZip(3, 3)); + + assertPackageCode("INVALID_ZIP", truncated, + new SkillPackageReadOptions( + SkillPackageLimits.builder().maxEntryCount(4).build())); + } + + /** + * 总量、二进制、路径深度、路径长度和压缩输入限制均生效。 + */ + @Test + public void enforceAdditionalPackageLimits() { + byte[] totalSize = zip(files( + "skill-a/SKILL.md", utf8(skillMd("skill-a")), + "skill-a/references/a.md", utf8("x".repeat(120)) + )); + assertPackageCode("TOTAL_SIZE_LIMIT", totalSize, new SkillPackageReadOptions( + SkillPackageLimits.builder() + .maxTextFileBytes(128) + .maxBinaryFileBytes(128) + .maxTotalUncompressedBytes(180) + .build())); + + byte[] binarySize = zip(files( + "skill-a/SKILL.md", utf8(skillMd("skill-a")), + "skill-a/assets/a.bin", new byte[]{1, 2, 3} + )); + assertPackageCode("BINARY_FILE_SIZE_LIMIT", binarySize, new SkillPackageReadOptions( + SkillPackageLimits.builder().maxBinaryFileBytes(2).build())); + + byte[] deepPath = zip(files( + "skill-a/SKILL.md", utf8(skillMd("skill-a")), + "skill-a/references/nested/a.md", utf8("A") + )); + assertPackageCode("PATH_DEPTH_LIMIT", deepPath, new SkillPackageReadOptions( + SkillPackageLimits.builder().maxPathDepth(2).build())); + + byte[] longPath = zip(files( + "skill-a/SKILL.md", utf8(skillMd("skill-a")), + "skill-a/references/this-name-is-far-too-long.md", utf8("A") + )); + assertPackageCode("PATH_LENGTH_LIMIT", longPath, new SkillPackageReadOptions( + SkillPackageLimits.builder().maxPathLength(30).build())); + + byte[] compressedInput = zip(files( + "skill-a/SKILL.md", utf8(skillMd("skill-a")) + )); + assertPackageCode("COMPRESSED_SIZE_LIMIT", compressedInput, new SkillPackageReadOptions( + SkillPackageLimits.builder().maxCompressedPackageBytes(64).build())); + } + + /** + * 后续校验失败时兼容存储也不会提前提交资产。 + */ + @Test + public void rollbackStagedContentOnFailure() { + TrackingContentStore store = new TrackingContentStore(0); + byte[] bytes = zip(files( + "skill-a/SKILL.md", utf8(skillMd("skill-a")), + "skill-a/assets/data.bin", utf8("abc"), + "skill-b/SKILL.md", utf8(skillMd("Invalid Name")) )); - Assert.assertEquals("references/rules/a.md", skills.get(0).getReferences().get(0).getPath()); - Assert.assertEquals("assets/images/logo.png", skills.get(0).getAssets().get(0).getPath()); + try { + decode(new ZipSkillPackageCodec(store), bytes); + Assert.fail("Invalid multi-Skill package should fail."); + } catch (SkillPackageException expected) { + Assert.assertEquals(1, store.stageCalls); + Assert.assertEquals(0, store.stagedCount); + Assert.assertEquals(0, store.committedCount); + } } /** - * 资产字节写入内容存储。 + * 只读预检对有效二进制资源也只返回哈希身份,并回滚暂存内容。 */ @Test - public void storeAssetContent() { + public void reportOnlyRollsBackValidBinaryContentWithoutCommit() { + TrackingContentStore store = new TrackingContentStore(0); + SkillPackageReadResult result = new ZipSkillPackageCodec(store).decode( + new ByteArrayInputStream(zip(files( + "skill-a/SKILL.md", utf8(skillMd("skill-a")), + "skill-a/assets/data.bin", new byte[]{1, 2, 3} + ))), SkillPackageReadOptions.reportOnly()); + + SkillResource resource = result.getSkillPackage().getSkills().get(0).getResources().get(0); + Assert.assertFalse(result.getValidationReport().hasErrors()); + Assert.assertEquals(1, store.stageCalls); + Assert.assertEquals(1, store.rollbackCalls); + Assert.assertEquals(0, store.commitAttempts); + Assert.assertEquals(0, store.stagedCount); + Assert.assertEquals(0, store.committedCount); + Assert.assertFalse(store.exists(resource.getContentRef())); + } + + /** + * 只读预检对可解析的无效包返回完整报告,并回滚二进制暂存内容。 + */ + @Test + public void reportOnlyReturnsValidationErrorsAndRollsBackBinaryContent() { + TrackingContentStore store = new TrackingContentStore(0); + SkillPackageReadResult result = new ZipSkillPackageCodec(store).decode( + new ByteArrayInputStream(zip(files( + "invalid-skill/SKILL.md", utf8(skillMd("Invalid Name")), + "invalid-skill/assets/data.bin", new byte[]{1, 2, 3} + ))), new SkillPackageReadOptions( + SkillPackageLimits.defaults(), SkillPackageReadMode.REPORT_ONLY)); + + Assert.assertTrue(result.getValidationReport().hasErrors()); + Assert.assertTrue(result.getValidationReport().getIssues().stream().anyMatch(issue -> + "INVALID_NAME".equals(issue.getCode()))); + Assert.assertEquals(1, store.stageCalls); + Assert.assertEquals(1, store.rollbackCalls); + Assert.assertEquals(0, store.commitAttempts); + Assert.assertEquals(0, store.stagedCount); + Assert.assertEquals(0, store.committedCount); + } + + /** + * 多 Skill 预检报告为每个相对路径补齐各自根目录。 + */ + @Test + public void reportOnlyPrefixesValidationPathsForMultipleSkills() { + SkillPackageReadResult result = new ZipSkillPackageCodec().decode( + new ByteArrayInputStream(zip(files( + "skill-a/SKILL.md", utf8(invalidDescriptionSkillMd("skill-a")), + "skill-b/SKILL.md", utf8(invalidDescriptionSkillMd("skill-b")) + ))), SkillPackageReadOptions.reportOnly()); + + Assert.assertTrue(result.getValidationReport().getIssues().stream().anyMatch(issue -> + "DESCRIPTION_REQUIRED".equals(issue.getCode()) + && "skill-a/SKILL.md".equals(issue.getPath()))); + Assert.assertTrue(result.getValidationReport().getIssues().stream().anyMatch(issue -> + "DESCRIPTION_REQUIRED".equals(issue.getCode()) + && "skill-b/SKILL.md".equals(issue.getPath()))); + } + + /** + * 多 Skill 标准导出的聚合错误也必须包含各自输出根目录。 + */ + @Test + public void exportPrefixesValidationPathsForMultipleSkills() { + Skill first = SkillFactory.create("first", skillMd("skill-a")); + first.setPackageRoot("skill-a"); + first.setDescription(null); + Skill second = SkillFactory.create("second", skillMd("skill-b")); + second.setPackageRoot("skill-b"); + second.setDescription(null); + ByteArrayOutputStream output = new ByteArrayOutputStream(); + + try { + new ZipSkillPackageCodec().encode( + new SkillPackage(SkillPackageLayout.MULTI_DIRECTORY, + List.of(first, second)), + output, SkillPackageWriteOptions.defaults()); + Assert.fail("Invalid multi-Skill export should fail."); + } catch (SkillPackageException exception) { + Assert.assertTrue(exception.getReport().getIssues().stream().anyMatch(issue -> + "DESCRIPTION_REQUIRED".equals(issue.getCode()) + && "skill-a/SKILL.md".equals(issue.getPath()))); + Assert.assertTrue(exception.getReport().getIssues().stream().anyMatch(issue -> + "DESCRIPTION_REQUIRED".equals(issue.getCode()) + && "skill-b/SKILL.md".equals(issue.getPath()))); + Assert.assertEquals(0, output.size()); + } + } + + /** + * 包装 Skill 的 YAML 解析错误也必须返回完整包路径。 + */ + @Test + public void prefixWrappedSkillPathForMalformedYaml() { + try { + new ZipSkillPackageCodec().decode(new ByteArrayInputStream(zip(files( + "skill-a/SKILL.md", utf8( + "---\nname: skill-a\ndescription: [\n---\n# Invalid\n") + ))), SkillPackageReadOptions.reportOnly()); + Assert.fail("Malformed wrapped SKILL.md should fail."); + } catch (SkillPackageException exception) { + Assert.assertNotNull(exception.getReport()); + Assert.assertTrue(exception.getReport().getIssues().stream().anyMatch(issue -> + "skill-a/SKILL.md".equals(issue.getPath()))); + } + } + + /** + * 只读预检仍拒绝路径穿越等不可解析的结构安全错误。 + */ + @Test + public void reportOnlyStillRejectsStructuralSecurityErrors() { + assertPackageCode("UNSAFE_ENTRY_PATH", zip(files( + "skill-a/SKILL.md", utf8(skillMd("skill-a")), + "skill-a/../escape.txt", utf8("escape") + )), SkillPackageReadOptions.reportOnly()); + } + + /** + * 部分 commit 失败时释放已提交引用并回滚其余暂存内容。 + */ + @Test + public void compensatePartiallyCommittedContent() { + TrackingContentStore store = new TrackingContentStore(2); + byte[] bytes = zip(files( + "skill-a/SKILL.md", utf8(skillMd("skill-a")), + "skill-a/assets/a.bin", utf8("a"), + "skill-a/assets/b.bin", utf8("b") + )); + + try { + decode(new ZipSkillPackageCodec(store), bytes); + Assert.fail("Second commit should fail."); + } catch (SkillPackageException expected) { + Assert.assertEquals(2, store.stageCalls); + Assert.assertEquals(0, store.stagedCount); + Assert.assertEquals(0, store.committedCount); + } + } + + /** + * 全包校验成功后统一提交资产。 + */ + @Test + public void commitStagedContentAfterValidation() { + CountingContentStore store = new CountingContentStore(); + + decode(new ZipSkillPackageCodec(store), zip(files( + "skill-a/SKILL.md", utf8(skillMd("skill-a")), + "skill-a/assets/data.bin", utf8("abc") + ))); + + Assert.assertEquals(1, store.putCount); + } + + /** + * 编码输出稳定,且 decode-encode-decode 语义等价。 + */ + @Test + public void stableEncodeAndSemanticRoundTrip() { InMemorySkillContentStore store = new InMemorySkillContentStore(); - List skills = new ZipSkillPackageCodec(store).importZip(new ByteArrayInputStream(zip(files( - "skill-a/SKILL.md", skillMd("Skill A", "Desc A"), - "skill-a/assets/data.bin", "abc" - )))); + ZipSkillPackageCodec codec = new ZipSkillPackageCodec(store); + SkillPackageReadResult first = decode(codec, zip(files( + "skill-a/SKILL.md", utf8(skillMd("skill-a")), + "skill-a/references/a.md", utf8("# A"), + "skill-a/assets/data.bin", new byte[]{1, 2, 3} + ))); + first.getSkillPackage().getSkills().get(0).setId("internal-repository-id"); - String contentRef = skills.get(0).getAssets().get(0).getContentRef(); - Assert.assertTrue(store.exists(contentRef)); - Assert.assertArrayEquals("abc".getBytes(StandardCharsets.UTF_8), store.readAllBytes(contentRef)); + ByteArrayOutputStream firstZip = new ByteArrayOutputStream(); + SkillPackageWriteResult firstWrite = codec.encode(first.getSkillPackage(), firstZip, + SkillPackageWriteOptions.defaults()); + ByteArrayOutputStream secondZip = new ByteArrayOutputStream(); + SkillPackageWriteResult secondWrite = codec.encode(first.getSkillPackage(), secondZip, + SkillPackageWriteOptions.defaults()); + + Assert.assertArrayEquals(firstZip.toByteArray(), secondZip.toByteArray()); + Assert.assertEquals(firstWrite.getPackageHash(), secondWrite.getPackageHash()); + Assert.assertEquals(SkillPackageLayout.SINGLE_DIRECTORY, firstWrite.getLayout()); + Assert.assertEquals(SkillHashes.sha256Hex(firstZip.toByteArray()), firstWrite.getPackageHash()); + Assert.assertFalse(zipContains(firstZip.toByteArray(), "internal-repository-id")); + + SkillPackageReadResult roundTrip = decode(codec, firstZip.toByteArray()); + Skill original = first.getSkillPackage().getSkills().get(0); + Skill decoded = roundTrip.getSkillPackage().getSkills().get(0); + Assert.assertNull(decoded.getId()); + Assert.assertEquals(original.getMetadata().getValues(), decoded.getMetadata().getValues()); + Assert.assertEquals(original.getSkillContent(), decoded.getSkillContent()); + Assert.assertEquals(original.getResources().stream().map(resource -> resource.getPath()).toList(), + decoded.getResources().stream().map(resource -> resource.getPath()).toList()); + Assert.assertEquals(original.getResources().stream().map(resource -> resource.getContentHash()).toList(), + decoded.getResources().stream().map(resource -> resource.getContentHash()).toList()); + Assert.assertTrue(allEntriesHaveStableTimestamp(firstZip.toByteArray())); } /** - * 导入失败时不写入资产内容。 + * Codec 对高压缩比合法文本自适应使用 STORED,保证同一限额下可回读。 */ @Test - public void failedImportDoesNotWriteAssetContent() { - CountingContentStore store = new CountingContentStore(); + public void highCompressionTextRoundTripsWithSameLimits() { + String content = skillMd("skill-a") + "a".repeat(20_000); + Skill skill = SkillFactory.create("id", content); + SkillPackage skillPackage = new SkillPackage( + SkillPackageLayout.SINGLE_DIRECTORY, List.of(skill)); + SkillPackageLimits limits = SkillPackageLimits.builder() + .maxCompressionRatio(2D) + .build(); + ZipSkillPackageCodec codec = new ZipSkillPackageCodec(); + ByteArrayOutputStream output = new ByteArrayOutputStream(); + + codec.encode(skillPackage, output, new SkillPackageWriteOptions(limits)); + SkillPackageReadResult decoded = codec.decode( + new ByteArrayInputStream(output.toByteArray()), new SkillPackageReadOptions(limits)); + + Assert.assertEquals(ZipEntry.STORED, zipEntryMethod(output.toByteArray(), "skill-a/SKILL.md")); + Assert.assertEquals(content, decoded.getSkillPackage().getSkills().get(0).getSkillContent()); + } + + /** + * 读写 options 的自定义宽松限额必须贯穿默认结构化校验器。 + */ + @Test + public void customOptionsLimitsReachValidatorDuringDecodeAndEncode() { + String longPath = "skill-a/references/" + "a".repeat(520) + ".md"; + SkillPackageLimits limits = SkillPackageLimits.builder() + .maxPathLength(600) + .build(); + ZipSkillPackageCodec codec = new ZipSkillPackageCodec(); + SkillPackageReadResult imported = codec.decode( + new ByteArrayInputStream(zip(files( + "skill-a/SKILL.md", utf8(skillMd("skill-a")), + longPath, utf8("A") + ))), new SkillPackageReadOptions(limits)); + ByteArrayOutputStream output = new ByteArrayOutputStream(); + + codec.encode(imported.getSkillPackage(), output, new SkillPackageWriteOptions(limits)); + SkillPackageReadResult roundTrip = codec.decode( + new ByteArrayInputStream(output.toByteArray()), new SkillPackageReadOptions(limits)); + + Assert.assertEquals(longPath.substring("skill-a/".length()), + roundTrip.getSkillPackage().getSkills().get(0).getResources().get(0).getPath()); + } + + /** + * 写出端必须对自动生成的 Skill 根目录和 SKILL.md 路径应用调用方限额。 + */ + @Test + public void enforceOutputLimitsOnGeneratedSkillEntryPath() { + ZipSkillPackageCodec codec = new ZipSkillPackageCodec(); + Skill skill = SkillFactory.create("id", skillMd("skill-a")); + String skillPath = "skill-a/SKILL.md"; + + assertExportIssue(codec, skill, new SkillPackageWriteOptions( + SkillPackageLimits.builder().maxPathLength(5).build()), + "PATH_LENGTH_LIMIT", skillPath); + assertExportIssue(codec, skill, new SkillPackageWriteOptions( + SkillPackageLimits.builder().maxPathDepth(1).build()), + "PATH_DEPTH_LIMIT", skillPath); + } + + /** + * 删除最后一个正式资源后不得从滞留的旧兼容视图恢复并再次导出。 + */ + @Test + public void emptyCanonicalResourcesDoNotFallBackToLegacyViews() { + String content = "# Rules"; + SkillResource reference = new SkillResource(); + reference.setPath("references/rules.md"); + reference.setKind(SkillResourceKind.REFERENCE); + reference.setMediaType("text/markdown"); + reference.setTextContent(content); + reference.setContentHash(SkillHashes.sha256Hex(utf8(content))); + reference.setSize(utf8(content).length); + Skill skill = SkillFactory.createWithResources( + "id", skillMd("skill-a"), List.of(reference)); + Assert.assertEquals(1, skill.getReferences().size()); + skill.getResources().clear(); + + ZipSkillPackageCodec codec = new ZipSkillPackageCodec(); + ByteArrayOutputStream output = new ByteArrayOutputStream(); + codec.encode(new SkillPackage(SkillPackageLayout.SINGLE_DIRECTORY, List.of(skill)), + output, SkillPackageWriteOptions.defaults()); + Skill decoded = codec.decode(new ByteArrayInputStream(output.toByteArray()), + SkillPackageReadOptions.defaults()).getSkillPackage().getSkills().get(0); + + Assert.assertTrue(decoded.getResources().isEmpty()); + Assert.assertTrue(decoded.getReferences().isEmpty()); + } + + /** + * 注入的默认校验器仍属于附加校验,其更严格限额不得被类型判断或操作选项绕过。 + */ + @Test + public void injectedDefaultValidatorAppliesItsStricterLimits() { + ZipSkillPackageCodec codec = new ZipSkillPackageCodec( + new InMemorySkillContentStore(), + new com.easyagents.skill.validation.defaults.DefaultSkillValidator( + SkillPackageLimits.builder().maxTextFileBytes(128).build())); + Skill skill = SkillFactory.create("id", skillMd("skill-a") + "x".repeat(300)); + + assertExportIssue(codec, skill, SkillPackageWriteOptions.defaults(), + "TEXT_FILE_SIZE_LIMIT", SkillPaths.SKILL_FILE); + } + + /** + * 自定义限额感知校验器必须收到当前 Codec 操作选项。 + */ + @Test + public void customValidatorReceivesOperationLimits() { + SkillPackageLimits limits = SkillPackageLimits.builder().maxPathLength(64).build(); + SkillPackageLimits[] observed = new SkillPackageLimits[1]; + SkillValidator validator = new SkillValidator() { + @Override + public void validate(Skill skill) { + // 限额感知实现通过下方结构化入口完成业务校验。 + } + + @Override + public com.easyagents.skill.validation.SkillValidationReport validateReport( + Skill skill, SkillPackageLimits operationLimits) { + observed[0] = operationLimits; + return new com.easyagents.skill.validation.SkillValidationReport(); + } + }; + ZipSkillPackageCodec codec = new ZipSkillPackageCodec( + new InMemorySkillContentStore(), validator); + + codec.encode(new SkillPackage(SkillPackageLayout.SINGLE_DIRECTORY, + List.of(SkillFactory.create("id", skillMd("skill-a")))), + new ByteArrayOutputStream(), new SkillPackageWriteOptions(limits)); + + Assert.assertSame(limits, observed[0]); + } + + /** + * noop 业务校验器不能绕过 Codec 内建的 YAML 与包大小安全校验。 + */ + @Test + public void noopValidatorCannotBypassMandatoryYamlAndPackageLimits() { + SkillValidator noopValidator = skill -> { + // 业务层不增加规则,Codec 仍必须独立完成标准安全校验。 + }; + ZipSkillPackageCodec codec = new ZipSkillPackageCodec( + new InMemorySkillContentStore(), noopValidator); + Skill malformedYaml = SkillFactory.create("id", skillMd("skill-a")); + malformedYaml.setSkillContent("---\nname: skill-a\ndescription: [\n---\n# Invalid\n"); + assertExportIssue(codec, malformedYaml, SkillPackageWriteOptions.defaults(), + "INVALID_FRONTMATTER_YAML", SkillPaths.SKILL_FILE); + + Skill oversized = SkillFactory.create("id", skillMd("skill-a") + "x".repeat(300)); + SkillPackageLimits limits = SkillPackageLimits.builder() + .maxTextFileBytes(128) + .build(); + assertExportIssue(codec, oversized, new SkillPackageWriteOptions(limits), + "TEXT_FILE_SIZE_LIMIT", SkillPaths.SKILL_FILE); + } + + /** + * Codec 强制标准校验后仍执行调用方注入的业务校验器。 + */ + @Test + public void preserveAdditionalBusinessValidator() { + SkillValidator businessValidator = skill -> { + throw new SkillValidationException("CUSTOM_POLICY", SkillPaths.SKILL_FILE, + null, null, "Custom Skill policy rejected the package.", null); + }; + ZipSkillPackageCodec codec = new ZipSkillPackageCodec( + new InMemorySkillContentStore(), businessValidator); + + assertExportIssue(codec, SkillFactory.create("id", skillMd("skill-a")), + SkillPackageWriteOptions.defaults(), "CUSTOM_POLICY", SkillPaths.SKILL_FILE); + } + + /** + * SKILL.md 与普通文本资源中的孤立 UTF-16 surrogate 均以结构化错误拒绝。 + */ + @Test + public void rejectMalformedUtf16SurrogateDuringExport() { + String isolatedSurrogate = String.valueOf((char) 0xD800); + ZipSkillPackageCodec codec = new ZipSkillPackageCodec(); + Skill invalidDocument = SkillFactory.create("id", skillMd("skill-a")); + invalidDocument.setSkillContent(skillMd("skill-a") + isolatedSurrogate); + assertExportIssue(codec, invalidDocument, SkillPackageWriteOptions.defaults(), + "INVALID_UTF8", SkillPaths.SKILL_FILE); + + Skill invalidResource = SkillFactory.create("id", skillMd("skill-a")); + SkillResource resource = new SkillResource(); + resource.setPath("references/invalid.md"); + resource.setKind(SkillResourceKind.REFERENCE); + resource.setMediaType("text/markdown"); + resource.setTextContent(isolatedSurrogate); + resource.setContentHash("0".repeat(64)); + resource.setSize(1); + invalidResource.setResources(List.of(resource)); + assertExportIssue(codec, invalidResource, SkillPackageWriteOptions.defaults(), + "INVALID_UTF8", "references/invalid.md"); + + Skill invalidPath = SkillFactory.create("id", skillMd("skill-a")); + SkillResource pathResource = new SkillResource(); + String malformedPath = "references/" + isolatedSurrogate + ".md"; + pathResource.setPath(malformedPath); + pathResource.setKind(SkillResourceKind.REFERENCE); + pathResource.setMediaType("text/markdown"); + pathResource.setTextContent("valid"); + pathResource.setContentHash(SkillHashes.sha256Hex(utf8("valid"))); + pathResource.setSize(utf8("valid").length); + invalidPath.setResources(List.of(pathResource)); + assertExportIssue(codec, invalidPath, SkillPackageWriteOptions.defaults(), + "INVALID_UTF8_PATH", malformedPath); + } + + /** + * 缺失资源路径在标准导出前返回结构化报告,且不能写出部分 ZIP。 + */ + @Test + public void rejectMissingResourcePathBeforeExportWritesBytes() { + Skill skill = SkillFactory.create("id", skillMd("skill-a")); + SkillResource resource = new SkillResource(); + resource.setKind(SkillResourceKind.REFERENCE); + resource.setMediaType("text/markdown"); + resource.setTextContent("missing path"); + resource.setContentHash(SkillHashes.sha256Hex(utf8("missing path"))); + resource.setSize(utf8("missing path").length); + skill.setResources(List.of(resource)); + + assertExportIssue(new ZipSkillPackageCodec(), skill, + SkillPackageWriteOptions.defaults(), "RESOURCE_PATH_REQUIRED", null); + } + + /** + * 历史下划线名称可导入草稿,但不能标准导出。 + */ + @Test + public void rejectLegacyNameOnStandardExport() { + ZipSkillPackageCodec codec = new ZipSkillPackageCodec(); + SkillPackage skillPackage = decode(codec, zip(files( + "legacy_skill/SKILL.md", utf8(skillMd("legacy_skill")) + ))).getSkillPackage(); try { - new ZipSkillPackageCodec(store).importZip(new ByteArrayInputStream(zip(files( - "skill-a/SKILL.md", skillMd("Skill A", "Desc A"), - "skill-a/assets/data.bin", "abc", - "skill-a/unknown/a.md", "# Unknown" - )))); - Assert.fail("Import should fail."); - } catch (SkillPackageException expected) { - Assert.assertEquals(0, store.putCount); + codec.encode(skillPackage, new ByteArrayOutputStream(), SkillPackageWriteOptions.defaults()); + Assert.fail("Legacy name should not be exported."); + } catch (SkillPackageException e) { + Assert.assertNotNull(e.getReport()); + Assert.assertTrue(e.getReport().getIssues().stream().anyMatch(issue -> + "NON_CANONICAL_NAME".equals(issue.getCode()) + && issue.getSeverity() + == com.easyagents.skill.validation.SkillValidationSeverity.ERROR)); } } /** - * 多 Skill 包中后续 Skill 失败时,不提前写入前面 Skill 的资产。 + * 目录名与 frontmatter 名称不一致时返回结构化错误。 */ @Test - public void failedMultiSkillImportDoesNotWriteEarlierAssetContent() { - CountingContentStore store = new CountingContentStore(); - + public void rejectRootNameMismatch() { try { - new ZipSkillPackageCodec(store).importZip(new ByteArrayInputStream(zip(files( - "skill-a/SKILL.md", skillMd("Skill A", "Desc A"), - "skill-a/assets/data.bin", "abc", - "skill-b/references/a.md", "# Missing SKILL.md" - )))); - Assert.fail("Import should fail."); - } catch (SkillPackageException expected) { - Assert.assertEquals(0, store.putCount); + decode(new ZipSkillPackageCodec(), zip(files( + "folder-name/SKILL.md", utf8(skillMd("different-name")) + ))); + Assert.fail("Mismatched root should fail."); + } catch (SkillPackageException e) { + Assert.assertNotNull(e.getReport()); + Assert.assertTrue(e.getReport().getIssues().stream().anyMatch(issue -> + "ROOT_NAME_MISMATCH".equals(issue.getCode()))); } } /** - * 拒绝嵌套 frontmatter。 + * 导出先聚合校验错误,不因缺失 SKILL.md 内容触发空指针。 */ - @Test(expected = SkillPackageException.class) - public void rejectNestedFrontmatter() { - importZip(files( - "skill-a/SKILL.md", "---\nname: Skill A\ndescription: Desc A\nconfig:\n mode: strict\n---\n# Skill A\n" - )); + @Test + public void aggregateInvalidSkillBeforePreparingOutput() { + Skill skill = com.easyagents.skill.factory.SkillFactory.create("id", skillMd("skill-a")); + skill.setSkillContent(null); + ByteArrayOutputStream output = new ByteArrayOutputStream(); + + try { + new ZipSkillPackageCodec().encode( + new SkillPackage(SkillPackageLayout.SINGLE_DIRECTORY, List.of(skill)), output, + SkillPackageWriteOptions.defaults()); + Assert.fail("Invalid Skill should fail."); + } catch (SkillPackageException e) { + Assert.assertNotNull(e.getReport()); + Assert.assertTrue(e.getReport().getIssues().stream().anyMatch(issue -> + "SKILL_CONTENT_REQUIRED".equals(issue.getCode()))); + Assert.assertEquals(0, output.size()); + } } - private static List importZip(Map files) { - return new ZipSkillPackageCodec().importZip(new ByteArrayInputStream(zip(files))); + /** + * 普通文本资源导出也执行单文件限制。 + */ + @Test + public void enforceTextLimitDuringExport() { + ZipSkillPackageCodec codec = new ZipSkillPackageCodec(); + SkillPackage skillPackage = decode(codec, zip(files( + "skill-a/SKILL.md", utf8(skillMd("skill-a")), + "skill-a/references/a.md", utf8("x".repeat(300)) + ))).getSkillPackage(); + ByteArrayOutputStream output = new ByteArrayOutputStream(); + + try { + codec.encode(skillPackage, output, new SkillPackageWriteOptions( + SkillPackageLimits.builder().maxTextFileBytes(128).build())); + Assert.fail("Oversized text export should fail."); + } catch (SkillPackageException e) { + Assert.assertTrue(e.getReport().getIssues().stream().anyMatch(issue -> + "TEXT_FILE_SIZE_LIMIT".equals(issue.getCode()))); + Assert.assertEquals(0, output.size()); + } } - private static Map files(String... keyValues) { - Map files = new LinkedHashMap<>(); - for (int i = 0; i < keyValues.length; i += 2) { - files.put(keyValues[i], keyValues[i + 1]); + /** + * 输出压缩包超限返回明确错误码。 + */ + @Test + public void reportCompressedOutputLimit() { + ZipSkillPackageCodec codec = new ZipSkillPackageCodec(); + SkillPackage skillPackage = decode(codec, zip(files( + "skill-a/SKILL.md", utf8(skillMd("skill-a")) + ))).getSkillPackage(); + + try { + codec.encode(skillPackage, new ByteArrayOutputStream(), new SkillPackageWriteOptions( + SkillPackageLimits.builder().maxCompressedPackageBytes(64).build())); + Assert.fail("Compressed output limit should fail."); + } catch (SkillPackageException e) { + Assert.assertEquals("COMPRESSED_SIZE_LIMIT", e.getCode()); + } + } + + /** + * 二进制存储内容在写 ZIP 前完成 size/hash 预检。 + */ + @Test + public void preflightBinaryContentBeforeWritingArchive() { + byte[] expected = utf8("abc"); + String hash = SkillHashes.sha256Hex(expected); + SkillResource resource = new SkillResource(); + resource.setPath("assets/data.bin"); + resource.setKind(SkillResourceKind.ASSET); + resource.setMediaType("application/octet-stream"); + resource.setContentRef("sha256:" + hash); + resource.setContentHash(hash); + resource.setSize(expected.length); + Skill skill = com.easyagents.skill.factory.SkillFactory.create("id", skillMd("skill-a")); + skill.setResources(List.of(resource)); + ByteArrayOutputStream output = new ByteArrayOutputStream(); + + try { + new ZipSkillPackageCodec(new CorruptReadContentStore()).encode( + new SkillPackage(SkillPackageLayout.SINGLE_DIRECTORY, List.of(skill)), output, + SkillPackageWriteOptions.defaults()); + Assert.fail("Corrupt stored content should fail preflight."); + } catch (SkillPackageException e) { + Assert.assertEquals("RESOURCE_HASH_MISMATCH", e.getCode()); + Assert.assertEquals(0, output.size()); + } + } + + private static SkillPackageReadResult decode(ZipSkillPackageCodec codec, byte[] bytes) { + return codec.decode(new ByteArrayInputStream(bytes), SkillPackageReadOptions.defaults()); + } + + private static void assertExportIssue(ZipSkillPackageCodec codec, Skill skill, + SkillPackageWriteOptions options, + String code, String path) { + ByteArrayOutputStream output = new ByteArrayOutputStream(); + try { + codec.encode(new SkillPackage(SkillPackageLayout.SINGLE_DIRECTORY, List.of(skill)), + output, options); + Assert.fail("Skill export should fail with " + code + "."); + } catch (SkillPackageException e) { + Assert.assertNotNull(e.getReport()); + Assert.assertTrue(e.getReport().getIssues().stream().anyMatch(issue -> + code.equals(issue.getCode()) && Objects.equals(path, issue.getPath()))); + Assert.assertEquals(0, output.size()); + } + } + + private static void assertPackageCode(String code, byte[] bytes, SkillPackageReadOptions options) { + try { + new ZipSkillPackageCodec().decode(new ByteArrayInputStream(bytes), options); + Assert.fail("Package should fail with " + code + "."); + } catch (SkillPackageException e) { + Assert.assertEquals(code, e.getCode()); + } + } + + private static Map files(Object... keyValues) { + Map files = new LinkedHashMap<>(); + for (int index = 0; index < keyValues.length; index += 2) { + files.put((String) keyValues[index], (byte[]) keyValues[index + 1]); } return files; } - private static byte[] zip(Map files, String... directories) { + private static byte[] zip(Map files, String... directories) { try { ByteArrayOutputStream bytes = new ByteArrayOutputStream(); - try (ZipOutputStream zipOutputStream = new ZipOutputStream(bytes)) { + try (ZipOutputStream zipOutput = new ZipOutputStream(bytes, StandardCharsets.UTF_8)) { for (String directory : directories) { - zipOutputStream.putNextEntry(new ZipEntry(directory)); - zipOutputStream.closeEntry(); + zipOutput.putNextEntry(new ZipEntry(directory)); + zipOutput.closeEntry(); } - for (Map.Entry file : files.entrySet()) { - zipOutputStream.putNextEntry(new ZipEntry(file.getKey())); - zipOutputStream.write(file.getValue().getBytes(StandardCharsets.UTF_8)); - zipOutputStream.closeEntry(); + for (Map.Entry file : files.entrySet()) { + zipOutput.putNextEntry(new ZipEntry(file.getKey())); + zipOutput.write(file.getValue()); + zipOutput.closeEntry(); } } return bytes.toByteArray(); @@ -243,8 +1047,242 @@ public class ZipSkillPackageCodecTest { } } - private static String skillMd(String name, String description) { - return "---\nname: " + name + "\ndescription: " + description + "\n---\n# " + name + "\n"; + /** + * 构造仅用于中央目录预扫描测试的 ZIP;本地头无有效 entry,旧 ZipFile 路径无法安全读取。 + * + * @param actualEntries 中央目录实际条目数 + * @param declaredEntries EOCD 声明条目数 + * @return 合成 ZIP 字节 + */ + private static byte[] syntheticCentralDirectoryZip(int actualEntries, int declaredEntries) { + int localHeaderSize = 30; + int nameSize = 2; + int centralEntrySize = 46 + nameSize; + int centralSize = actualEntries * centralEntrySize; + ByteBuffer buffer = ByteBuffer.allocate(localHeaderSize + centralSize + 22) + .order(ByteOrder.LITTLE_ENDIAN); + buffer.putInt(0x04034B50); + buffer.position(localHeaderSize); + writeSyntheticCentralDirectoryEntries(buffer, actualEntries, nameSize); + buffer.putInt(0x06054B50); + buffer.putShort((short) 0); + buffer.putShort((short) 0); + buffer.putShort((short) declaredEntries); + buffer.putShort((short) declaredEntries); + buffer.putInt(centralSize); + buffer.putInt(localHeaderSize); + buffer.putShort((short) 0); + return buffer.array(); + } + + /** + * 构造带 ZIP64 EOCD 的中央目录预扫描样本。 + * + * @param actualEntries 中央目录实际条目数 + * @param declaredEntries ZIP64 EOCD 声明条目数 + * @return 合成 ZIP64 字节 + */ + private static byte[] syntheticZip64CentralDirectoryZip( + int actualEntries, int declaredEntries) { + int localHeaderSize = 30; + int nameSize = 2; + int centralEntrySize = 46 + nameSize; + int centralSize = actualEntries * centralEntrySize; + int zip64EndRecordSize = 56; + int zip64LocatorSize = 20; + int endRecordSize = 22; + int zip64EndRecordOffset = localHeaderSize + centralSize; + ByteBuffer buffer = ByteBuffer.allocate(localHeaderSize + centralSize + + zip64EndRecordSize + zip64LocatorSize + endRecordSize) + .order(ByteOrder.LITTLE_ENDIAN); + buffer.putInt(0x04034B50); + buffer.position(localHeaderSize); + writeSyntheticCentralDirectoryEntries(buffer, actualEntries, nameSize); + + buffer.putInt(0x06064B50); + buffer.putLong(44L); + buffer.putShort((short) 45); + buffer.putShort((short) 45); + buffer.putInt(0); + buffer.putInt(0); + buffer.putLong(declaredEntries); + buffer.putLong(declaredEntries); + buffer.putLong(centralSize); + buffer.putLong(localHeaderSize); + + buffer.putInt(0x07064B50); + buffer.putInt(0); + buffer.putLong(zip64EndRecordOffset); + buffer.putInt(1); + + buffer.putInt(0x06054B50); + buffer.putShort((short) 0); + buffer.putShort((short) 0); + buffer.putShort((short) 0xFFFF); + buffer.putShort((short) 0xFFFF); + buffer.putInt(0xFFFFFFFF); + buffer.putInt(0xFFFFFFFF); + buffer.putShort((short) 0); + return buffer.array(); + } + + /** + * 写入只包含最小必要字段的中央目录条目。 + * + * @param buffer 目标小端缓冲区 + * @param entryCount 条目数 + * @param nameSize 文件名长度 + */ + private static void writeSyntheticCentralDirectoryEntries( + ByteBuffer buffer, int entryCount, int nameSize) { + for (int index = 0; index < entryCount; index++) { + buffer.putInt(0x02014B50); + buffer.putShort((short) 20); + buffer.putShort((short) 20); + buffer.position(buffer.position() + 20); + buffer.putShort((short) nameSize); + buffer.putShort((short) 0); + buffer.putShort((short) 0); + buffer.position(buffer.position() + 12); + buffer.put((byte) 'e'); + buffer.put((byte) ('0' + index)); + } + } + + /** + * 删除中央目录最后一个字节并同步 EOCD 的目录大小,模拟结构性截断。 + * + * @param source 无注释的传统 ZIP 样本 + * @return 中央目录被截断的 ZIP 字节 + */ + private static byte[] truncateLastCentralDirectoryByte(byte[] source) { + int endRecordSize = 22; + int endRecordOffset = source.length - endRecordSize; + int removedOffset = endRecordOffset - 1; + ByteBuffer sourceBuffer = ByteBuffer.wrap(source).order(ByteOrder.LITTLE_ENDIAN); + int centralSize = sourceBuffer.getInt(endRecordOffset + 12); + byte[] truncated = new byte[source.length - 1]; + System.arraycopy(source, 0, truncated, 0, removedOffset); + System.arraycopy(source, removedOffset + 1, truncated, removedOffset, + source.length - removedOffset - 1); + ByteBuffer.wrap(truncated).order(ByteOrder.LITTLE_ENDIAN) + .putInt(endRecordOffset - 1 + 12, centralSize - 1); + return truncated; + } + + private static byte[] symlinkZip() { + try { + ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + try (ZipArchiveOutputStream output = new ZipArchiveOutputStream(bytes)) { + ZipArchiveEntry skillEntry = new ZipArchiveEntry("skill-a/SKILL.md"); + skillEntry.setUnixMode(UnixStat.FILE_FLAG | 0644); + output.putArchiveEntry(skillEntry); + output.write(utf8(skillMd("skill-a"))); + output.closeArchiveEntry(); + + ZipArchiveEntry symlink = new ZipArchiveEntry("skill-a/assets/link"); + symlink.setUnixMode(UnixStat.LINK_FLAG | 0777); + output.putArchiveEntry(symlink); + output.write(utf8("target")); + output.closeArchiveEntry(); + output.finish(); + } + return bytes.toByteArray(); + } catch (Exception e) { + throw new IllegalStateException(e); + } + } + + private static byte[] invalidUtf8EntryNameZip() { + try { + ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + try (ZipArchiveOutputStream output = new ZipArchiveOutputStream(bytes)) { + output.setEncoding(StandardCharsets.ISO_8859_1.name()); + output.setUseLanguageEncodingFlag(false); + output.setCreateUnicodeExtraFields(ZipArchiveOutputStream.UnicodeExtraFieldPolicy.NEVER); + + ZipArchiveEntry skillEntry = new ZipArchiveEntry("skill-a/SKILL.md"); + output.putArchiveEntry(skillEntry); + output.write(utf8(skillMd("skill-a"))); + output.closeArchiveEntry(); + + ZipArchiveEntry invalidName = new ZipArchiveEntry("skill-a/references/\u00ff.md"); + output.putArchiveEntry(invalidName); + output.write(utf8("invalid name")); + output.closeArchiveEntry(); + output.finish(); + } + return bytes.toByteArray(); + } catch (Exception e) { + throw new IllegalStateException(e); + } + } + + private static byte[] tamperFirstCentralDirectoryCrc(byte[] source) { + byte[] bytes = Arrays.copyOf(source, source.length); + for (int index = 0; index <= bytes.length - 20; index++) { + if (bytes[index] == 0x50 && bytes[index + 1] == 0x4B + && bytes[index + 2] == 0x01 && bytes[index + 3] == 0x02) { + bytes[index + 16] ^= 0x01; + return bytes; + } + } + throw new IllegalStateException("ZIP central directory was not found."); + } + + private static boolean allEntriesHaveStableTimestamp(byte[] zip) { + List timestamps = new ArrayList<>(); + try (ZipInputStream input = new ZipInputStream(new ByteArrayInputStream(zip), StandardCharsets.UTF_8)) { + ZipEntry entry; + while ((entry = input.getNextEntry()) != null) { + timestamps.add(entry.getTime()); + } + } catch (Exception e) { + throw new IllegalStateException(e); + } + return !timestamps.isEmpty() && timestamps.stream().distinct().count() == 1; + } + + private static int zipEntryMethod(byte[] zip, String path) { + try (ZipInputStream input = new ZipInputStream( + new ByteArrayInputStream(zip), StandardCharsets.UTF_8)) { + ZipEntry entry; + while ((entry = input.getNextEntry()) != null) { + if (path.equals(entry.getName())) { + return entry.getMethod(); + } + } + throw new IllegalStateException("ZIP entry was not found: " + path); + } catch (Exception e) { + throw new IllegalStateException(e); + } + } + + private static boolean zipContains(byte[] zip, String value) { + try (ZipInputStream input = new ZipInputStream(new ByteArrayInputStream(zip), StandardCharsets.UTF_8)) { + ZipEntry entry; + while ((entry = input.getNextEntry()) != null) { + if (entry.getName().contains(value) + || new String(input.readAllBytes(), StandardCharsets.UTF_8).contains(value)) { + return true; + } + } + return false; + } catch (Exception e) { + throw new IllegalStateException(e); + } + } + + private static byte[] utf8(String value) { + return value.getBytes(StandardCharsets.UTF_8); + } + + private static String skillMd(String name) { + return "---\nname: " + name + "\ndescription: Use this Skill for package tests\n---\n# " + name + "\n"; + } + + private static String invalidDescriptionSkillMd(String name) { + return "---\nname: " + name + "\ndescription: ''\n---\n# " + name + "\n"; } private static final class CountingContentStore implements SkillContentStore { @@ -254,7 +1292,7 @@ public class ZipSkillPackageCodecTest { @Override public String put(byte[] bytes) { putCount++; - return "sha256:" + com.easyagents.skill.util.SkillHashes.sha256Hex(bytes); + return SkillHashes.sha256Ref(bytes); } @Override @@ -272,4 +1310,94 @@ public class ZipSkillPackageCodecTest { return false; } } + + private static final class TrackingContentStore implements SkillContentStore { + + private final InMemorySkillContentStore delegate = new InMemorySkillContentStore(); + private final int failOnCommit; + private int stageCalls; + private int stagedCount; + private int committedCount; + private int commitAttempts; + private int rollbackCalls; + + private TrackingContentStore(int failOnCommit) { + this.failOnCommit = failOnCommit; + } + + @Override + public String put(byte[] bytes) { + return delegate.put(bytes); + } + + @Override + public SkillContentStage stage(InputStream inputStream, long maxBytes) { + stageCalls++; + stagedCount++; + return delegate.stage(inputStream, maxBytes); + } + + @Override + public String commit(SkillContentStage stage) { + commitAttempts++; + if (failOnCommit > 0 && commitAttempts == failOnCommit) { + throw new IllegalStateException("simulated commit failure"); + } + String contentRef = delegate.commit(stage); + stagedCount--; + committedCount++; + return contentRef; + } + + @Override + public void rollback(SkillContentStage stage) { + rollbackCalls++; + delegate.rollback(stage); + stagedCount--; + } + + @Override + public void release(String contentRef) { + delegate.release(contentRef); + committedCount--; + } + + @Override + public InputStream open(String contentRef) { + return delegate.open(contentRef); + } + + @Override + public byte[] readAllBytes(String contentRef) { + return delegate.readAllBytes(contentRef); + } + + @Override + public boolean exists(String contentRef) { + return delegate.exists(contentRef); + } + } + + private static final class CorruptReadContentStore implements SkillContentStore { + + @Override + public String put(byte[] bytes) { + return SkillHashes.sha256Ref(bytes); + } + + @Override + public InputStream open(String contentRef) { + return new ByteArrayInputStream(utf8("xyz")); + } + + @Override + public byte[] readAllBytes(String contentRef) { + return utf8("xyz"); + } + + @Override + public boolean exists(String contentRef) { + return true; + } + } } diff --git a/easy-agents-skill/src/test/java/com/easyagents/skill/model/SkillMetadataTest.java b/easy-agents-skill/src/test/java/com/easyagents/skill/model/SkillMetadataTest.java new file mode 100644 index 0000000..d2d232c --- /dev/null +++ b/easy-agents-skill/src/test/java/com/easyagents/skill/model/SkillMetadataTest.java @@ -0,0 +1,50 @@ +package com.easyagents.skill.model; + +import com.easyagents.skill.exception.SkillValidationException; +import org.junit.Assert; +import org.junit.Test; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * SkillMetadata 防御性复制与键约束测试。 + */ +public class SkillMetadataTest { + + /** + * 嵌套 Map/List 在读写边界上执行防御性复制。 + */ + @Test + @SuppressWarnings("unchecked") + public void deeplyCopiesNestedValues() { + LinkedHashMap nested = new LinkedHashMap<>(); + nested.put("items", new java.util.ArrayList<>(List.of("a"))); + SkillMetadata metadata = new SkillMetadata(Map.of("nested", nested)); + + Map read = metadata.getValues(); + ((Map) read.get("nested")).put("changed", true); + + Assert.assertNull(((Map) metadata.get("nested")).get("changed")); + } + + /** + * 空白键写入会显式失败。 + */ + @Test(expected = SkillValidationException.class) + public void rejectBlankKey() { + new SkillMetadata().put(" ", "value"); + } + + /** + * 嵌套非字符串键会显式失败且不会静默转换。 + */ + @Test(expected = SkillValidationException.class) + @SuppressWarnings({"rawtypes", "unchecked"}) + public void rejectNestedNonStringKey() { + Map nested = new LinkedHashMap(); + nested.put(1, "number"); + new SkillMetadata(Map.of("nested", nested)); + } +} diff --git a/easy-agents-skill/src/test/java/com/easyagents/skill/model/SkillPackageLimitsTest.java b/easy-agents-skill/src/test/java/com/easyagents/skill/model/SkillPackageLimitsTest.java new file mode 100644 index 0000000..c57cab0 --- /dev/null +++ b/easy-agents-skill/src/test/java/com/easyagents/skill/model/SkillPackageLimitsTest.java @@ -0,0 +1,30 @@ +package com.easyagents.skill.model; + +import com.easyagents.skill.exception.SkillValidationException; +import org.junit.Test; + +/** + * SkillPackageLimits 硬上限测试。 + */ +public class SkillPackageLimitsTest { + + /** + * 调用方不能关闭或突破底层 entry 硬上限。 + */ + @Test(expected = SkillValidationException.class) + public void rejectEntryCountAboveHardLimit() { + SkillPackageLimits.builder().maxEntryCount(10_001).build(); + } + + /** + * 单文件限制不能大于总未压缩限制。 + */ + @Test(expected = SkillValidationException.class) + public void rejectSingleFileLimitAboveTotalLimit() { + SkillPackageLimits.builder() + .maxTextFileBytes(200) + .maxBinaryFileBytes(200) + .maxTotalUncompressedBytes(100) + .build(); + } +} diff --git a/easy-agents-skill/src/test/java/com/easyagents/skill/repository/memory/InMemorySkillRepositoryTest.java b/easy-agents-skill/src/test/java/com/easyagents/skill/repository/memory/InMemorySkillRepositoryTest.java index fa3a550..b2371a9 100644 --- a/easy-agents-skill/src/test/java/com/easyagents/skill/repository/memory/InMemorySkillRepositoryTest.java +++ b/easy-agents-skill/src/test/java/com/easyagents/skill/repository/memory/InMemorySkillRepositoryTest.java @@ -3,6 +3,7 @@ package com.easyagents.skill.repository.memory; import com.easyagents.skill.model.Skill; import com.easyagents.skill.model.SkillDescriptor; import com.easyagents.skill.model.SkillReference; +import com.easyagents.skill.util.SkillResources; import org.junit.Assert; import org.junit.Test; @@ -66,6 +67,40 @@ public class InMemorySkillRepositoryTest { Assert.assertEquals("# A", reloaded.getReferences().get(0).getContent()); } + /** + * 仓储复制旧资源对象时应先迁移正式资源,不能因空 canonical 列表丢失 reference。 + */ + @Test + public void repositoryCopyMigratesLegacyOnlyResources() { + InMemorySkillRepository repository = new InMemorySkillRepository(); + Skill legacy = skill(); + Assert.assertFalse(legacy.isResourcesInitialized()); + + repository.save(legacy); + Skill loaded = repository.get("skill-a").orElseThrow(); + + Assert.assertTrue(loaded.isResourcesInitialized()); + Assert.assertEquals(1, SkillResources.canonicalResources(loaded).size()); + Assert.assertEquals("references/a.md", loaded.getResources().get(0).getPath()); + } + + /** + * 显式清空正式资源列表后,仓储往返不得从旧兼容视图恢复已删除资源。 + */ + @Test + public void repositoryCopyPreservesExplicitlyEmptyCanonicalResources() { + InMemorySkillRepository repository = new InMemorySkillRepository(); + Skill skill = skill(); + Assert.assertEquals(1, skill.getResources().size()); + skill.getResources().clear(); + + repository.save(skill); + Skill loaded = repository.get("skill-a").orElseThrow(); + + Assert.assertTrue(loaded.isResourcesInitialized()); + Assert.assertTrue(SkillResources.canonicalResources(loaded).isEmpty()); + } + private static Skill skill() { Skill skill = new Skill(); skill.setId("skill-a"); diff --git a/easy-agents-skill/src/test/java/com/easyagents/skill/store/file/TemporaryFileSkillContentStoreTest.java b/easy-agents-skill/src/test/java/com/easyagents/skill/store/file/TemporaryFileSkillContentStoreTest.java new file mode 100644 index 0000000..43b4f38 --- /dev/null +++ b/easy-agents-skill/src/test/java/com/easyagents/skill/store/file/TemporaryFileSkillContentStoreTest.java @@ -0,0 +1,275 @@ +package com.easyagents.skill.store.file; + +import com.easyagents.skill.codec.ZipSkillPackageCodec; +import com.easyagents.skill.exception.SkillException; +import com.easyagents.skill.store.SkillContentStage; +import com.easyagents.skill.store.SkillContentStore; +import com.easyagents.skill.store.memory.InMemorySkillContentStore; +import com.easyagents.skill.util.SkillHashes; +import org.junit.Assert; +import org.junit.Test; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.lang.reflect.Field; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.MessageDigest; +import java.util.ArrayList; +import java.util.List; +import java.util.stream.Stream; + +/** + * {@link TemporaryFileSkillContentStore} 流式存储与生命周期测试。 + */ +public class TemporaryFileSkillContentStoreTest { + + /** + * 暂存、提交、回滚、去重与引用计数均由磁盘文件驱动。 + * + * @throws Exception 文件操作失败时抛出 + */ + @Test + public void stageCommitRollbackAndReferenceCountingAreFileBacked() throws Exception { + Path parent = Files.createTempDirectory("skill-content-store-test-"); + TemporaryFileSkillContentStore store = new TemporaryFileSkillContentStore(parent); + Path storageDirectory = store.storageDirectory(); + byte[] content = "file-backed-content".getBytes(StandardCharsets.UTF_8); + try { + SkillContentStage rolledBack = store.stage( + new ByteArrayInputStream(content), content.length); + Assert.assertFalse(store.exists(rolledBack.getContentRef())); + Assert.assertEquals(1, countRegularFiles(storageDirectory.resolve("staged"))); + + store.rollback(rolledBack); + Assert.assertEquals(0, countRegularFiles(storageDirectory.resolve("staged"))); + + SkillContentStage firstStage = store.stage( + new ByteArrayInputStream(content), content.length); + SkillContentStage secondStage = store.stage( + new ByteArrayInputStream(content), content.length); + String firstRef = store.commit(firstStage); + String secondRef = store.commit(secondStage); + + Assert.assertEquals(firstRef, secondRef); + Assert.assertTrue(store.exists(firstRef)); + Assert.assertEquals(0, countRegularFiles(storageDirectory.resolve("staged"))); + Assert.assertEquals(1, countRegularFiles(storageDirectory.resolve("content"))); + + store.release(firstRef); + Assert.assertTrue(store.exists(secondRef)); + store.retain(secondRef); + store.release(secondRef); + Assert.assertTrue(store.exists(secondRef)); + store.release(secondRef); + + Assert.assertFalse(store.exists(secondRef)); + Assert.assertEquals(0, countRegularFiles(storageDirectory.resolve("content"))); + } finally { + store.close(); + Assert.assertFalse(Files.exists(storageDirectory)); + Assert.assertTrue(Files.exists(parent)); + Files.deleteIfExists(parent); + } + } + + /** + * 已打开的流持有读取租约,最后一个正式引用释放后等待流关闭再删除文件。 + * + * @throws Exception 文件读取失败时抛出 + */ + @Test + public void openStreamDefersPhysicalDeletionUntilReaderCloses() throws Exception { + Path parent = Files.createTempDirectory("skill-content-reader-test-"); + try (TemporaryFileSkillContentStore store = new TemporaryFileSkillContentStore(parent)) { + Path contentDirectory = store.storageDirectory().resolve("content"); + byte[] content = "leased-content".getBytes(StandardCharsets.UTF_8); + String contentRef = store.put(content); + InputStream input = store.open(contentRef); + + store.release(contentRef); + Assert.assertFalse(store.exists(contentRef)); + Assert.assertEquals(1, countRegularFiles(contentDirectory)); + Assert.assertArrayEquals(content, input.readAllBytes()); + + input.close(); + Assert.assertEquals(0, countRegularFiles(contentDirectory)); + + String pendingRef = store.put(content); + InputStream pendingReader = store.open(pendingRef); + store.release(pendingRef); + String revivedRef = store.put(content); + Assert.assertEquals(pendingRef, revivedRef); + + pendingReader.close(); + Assert.assertTrue(store.exists(revivedRef)); + Assert.assertEquals(1, countRegularFiles(contentDirectory)); + store.release(revivedRef); + Assert.assertEquals(0, countRegularFiles(contentDirectory)); + } finally { + Files.deleteIfExists(parent); + } + } + + /** + * 大内容按流写入并受字节上限约束,存储实现及内部状态不持有 byte[] 字段。 + * + * @throws Exception 文件操作失败时抛出 + */ + @Test + public void largeContentIsStreamedWithoutByteArrayState() throws Exception { + assertNoByteArrayFields(TemporaryFileSkillContentStore.class); + long contentSize = 2L * 1024 * 1024 + 37; + Path parent = Files.createTempDirectory("skill-content-stream-test-"); + try (TemporaryFileSkillContentStore store = new TemporaryFileSkillContentStore(parent)) { + SkillContentStage stage = store.stage(new RepeatingInputStream(contentSize), contentSize); + Assert.assertEquals(contentSize, stage.getSize()); + String contentRef = store.commit(stage); + + MessageDigest digest = SkillHashes.newSha256Digest(); + long copied = 0; + try (InputStream input = store.open(contentRef)) { + byte[] buffer = new byte[8192]; + int length; + while ((length = input.read(buffer)) >= 0) { + if (length == 0) { + continue; + } + digest.update(buffer, 0, length); + copied += length; + } + } + Assert.assertEquals(contentSize, copied); + Assert.assertEquals(stage.getContentHash(), SkillHashes.toHex(digest.digest())); + + assertLimitExceeded(store, contentSize - 1); + Assert.assertEquals(0, + countRegularFiles(store.storageDirectory().resolve("staged"))); + store.release(contentRef); + } finally { + Files.deleteIfExists(parent); + } + } + + /** + * 无参 ZIP Codec 使用自有临时文件 Store,注入 Store 仍由调用方管理。 + * + * @throws Exception 反射或文件操作失败时抛出 + */ + @Test + public void defaultCodecOwnsTemporaryStoreWhileInjectedStoreRemainsExternal() + throws Exception { + Field contentStoreField = ZipSkillPackageCodec.class.getDeclaredField("contentStore"); + contentStoreField.setAccessible(true); + + ZipSkillPackageCodec defaultCodec = new ZipSkillPackageCodec(); + SkillContentStore defaultStore = (SkillContentStore) contentStoreField.get(defaultCodec); + Assert.assertTrue(defaultStore instanceof TemporaryFileSkillContentStore); + Assert.assertFalse(defaultStore instanceof InMemorySkillContentStore); + Path defaultDirectory = ((TemporaryFileSkillContentStore) defaultStore).storageDirectory(); + defaultCodec.close(); + Assert.assertFalse(Files.exists(defaultDirectory)); + + Path parent = Files.createTempDirectory("skill-codec-external-store-test-"); + TemporaryFileSkillContentStore injectedStore = new TemporaryFileSkillContentStore(parent); + try { + ZipSkillPackageCodec injectedCodec = new ZipSkillPackageCodec(injectedStore); + injectedCodec.close(); + String contentRef = injectedStore.put("still-open".getBytes(StandardCharsets.UTF_8)); + Assert.assertTrue(injectedStore.exists(contentRef)); + injectedStore.release(contentRef); + } finally { + injectedStore.close(); + Files.deleteIfExists(parent); + } + } + + /** + * 显式关闭后清理暂存文件并拒绝新的写入。 + * + * @throws Exception 文件操作失败时抛出 + */ + @Test + public void closeCleansStagesAndRejectsFurtherOperations() throws Exception { + Path parent = Files.createTempDirectory("skill-content-close-test-"); + TemporaryFileSkillContentStore store = new TemporaryFileSkillContentStore(parent); + Path storageDirectory = store.storageDirectory(); + store.stage(new ByteArrayInputStream(new byte[]{1, 2, 3}), 3); + + store.close(); + store.close(); + + Assert.assertFalse(Files.exists(storageDirectory)); + try { + store.stage(new ByteArrayInputStream(new byte[0]), 0); + Assert.fail("Closed store should reject new stages."); + } catch (SkillException expected) { + Assert.assertTrue(expected.getMessage().contains("closed")); + } finally { + Files.deleteIfExists(parent); + } + } + + private static void assertLimitExceeded(TemporaryFileSkillContentStore store, long maxBytes) { + try { + store.stage(new RepeatingInputStream(maxBytes + 1), maxBytes); + Assert.fail("Content above the limit should be rejected."); + } catch (SkillException expected) { + Assert.assertTrue(expected.getMessage().contains("exceeds")); + } + } + + private static void assertNoByteArrayFields(Class rootType) { + List> types = new ArrayList<>(); + types.add(rootType); + for (int index = 0; index < types.size(); index++) { + Class type = types.get(index); + for (Field field : type.getDeclaredFields()) { + Assert.assertNotEquals(type.getName() + " must not retain byte[] field " + + field.getName(), byte[].class, field.getType()); + } + for (Class nestedType : type.getDeclaredClasses()) { + types.add(nestedType); + } + } + } + + private static long countRegularFiles(Path directory) throws IOException { + try (Stream paths = Files.list(directory)) { + return paths.filter(Files::isRegularFile).count(); + } + } + + private static final class RepeatingInputStream extends InputStream { + + private final long length; + private long position; + + private RepeatingInputStream(long length) { + this.length = length; + } + + @Override + public int read() { + if (position >= length) { + return -1; + } + return (int) (position++ & 0xFF); + } + + @Override + public int read(byte[] bytes, int offset, int requestedLength) { + if (position >= length) { + return -1; + } + int actualLength = (int) Math.min(requestedLength, length - position); + for (int index = 0; index < actualLength; index++) { + bytes[offset + index] = (byte) ((position + index) & 0xFF); + } + position += actualLength; + return actualLength; + } + } +} diff --git a/easy-agents-skill/src/test/java/com/easyagents/skill/store/memory/InMemorySkillContentStoreTest.java b/easy-agents-skill/src/test/java/com/easyagents/skill/store/memory/InMemorySkillContentStoreTest.java index 1050cbe..e5f14d3 100644 --- a/easy-agents-skill/src/test/java/com/easyagents/skill/store/memory/InMemorySkillContentStoreTest.java +++ b/easy-agents-skill/src/test/java/com/easyagents/skill/store/memory/InMemorySkillContentStoreTest.java @@ -1,5 +1,6 @@ package com.easyagents.skill.store.memory; +import com.easyagents.skill.store.SkillContentStage; import org.junit.Assert; import org.junit.Test; @@ -43,4 +44,40 @@ public class InMemorySkillContentStoreTest { Assert.assertArrayEquals(bytes, inputStream.readAllBytes()); } } + + /** + * 暂存内容在 commit 前不可见,rollback 后不残留。 + */ + @Test + public void stageCommitAndRollback() { + InMemorySkillContentStore store = new InMemorySkillContentStore(); + byte[] bytes = "abc".getBytes(StandardCharsets.UTF_8); + + SkillContentStage rolledBack = store.stage(new java.io.ByteArrayInputStream(bytes), bytes.length); + Assert.assertFalse(store.exists(rolledBack.getContentRef())); + store.rollback(rolledBack); + Assert.assertFalse(store.exists(rolledBack.getContentRef())); + + SkillContentStage committed = store.stage(new java.io.ByteArrayInputStream(bytes), bytes.length); + String contentRef = store.commit(committed); + Assert.assertTrue(store.exists(contentRef)); + Assert.assertArrayEquals(bytes, store.readAllBytes(contentRef)); + } + + /** + * 相同内容按引用计数释放,归零后删除。 + */ + @Test + public void releaseDeletesOnlyAfterReferenceCountReachesZero() { + InMemorySkillContentStore store = new InMemorySkillContentStore(); + byte[] bytes = "abc".getBytes(StandardCharsets.UTF_8); + + String first = store.put(bytes); + String second = store.put(bytes); + store.release(first); + + Assert.assertTrue(store.exists(second)); + store.release(second); + Assert.assertFalse(store.exists(second)); + } } diff --git a/easy-agents-skill/src/test/java/com/easyagents/skill/util/SkillFrontmatterTest.java b/easy-agents-skill/src/test/java/com/easyagents/skill/util/SkillFrontmatterTest.java new file mode 100644 index 0000000..764b29e --- /dev/null +++ b/easy-agents-skill/src/test/java/com/easyagents/skill/util/SkillFrontmatterTest.java @@ -0,0 +1,199 @@ +package com.easyagents.skill.util; + +import com.easyagents.skill.exception.SkillValidationException; +import com.easyagents.skill.model.SkillDocument; +import com.easyagents.skill.model.SkillPackageLimits; +import org.junit.Assert; +import org.junit.Test; + +import java.util.List; +import java.util.Map; + +/** + * SkillFrontmatter 安全解析与 round-trip 测试。 + */ +public class SkillFrontmatterTest { + + /** + * 嵌套结构、未知字段和插入顺序在序列化后保持语义。 + */ + @Test + public void nestedMetadataRoundTrips() { + String content = "---\nname: nested-skill\ndescription: Handles nested values\n" + + "enabled: true\ncount: 3\nnullable: null\n" + + "metadata:\n owner: team-a\n tags:\n - one\n - two\n" + + "---\n# Nested\n"; + + SkillDocument first = SkillFrontmatter.parseDocument(content); + String serialized = SkillFrontmatter.serialize(first.getFrontmatter().getValues(), first.getMarkdownBody()); + SkillDocument second = SkillFrontmatter.parseDocument(serialized); + + Assert.assertEquals(first.getFrontmatter().getValues(), second.getFrontmatter().getValues()); + Assert.assertEquals(first.getMarkdownBody(), second.getMarkdownBody()); + Assert.assertEquals(List.of("name", "description", "enabled", "count", "nullable", "metadata"), + second.getFrontmatter().getValues().keySet().stream().toList()); + } + + /** + * 未编辑文档原样返回,结构化编辑后重新生成合法文档。 + */ + @Test + public void preserveRawUntilEdited() { + String content = "---\r\nname: raw-skill\r\ndescription: Keep source\r\n---\r\n# Raw\r\n"; + SkillDocument document = SkillFrontmatter.parseDocument(content); + + Assert.assertEquals(content, document.render()); + + document.putFrontmatter("metadata", Map.of("level", 2)); + Assert.assertNotEquals(content, document.render()); + SkillDocument parsed = SkillFrontmatter.parseDocument(document.render()); + Assert.assertEquals("# Raw\r\n", parsed.getMarkdownBody()); + Assert.assertTrue(parsed.getFrontmatter().get("metadata") instanceof Map); + } + + /** + * 顶层字段保留完整 SKILL.md 中的一基源码位置,结构化修改后移除失效位置。 + */ + @Test + public void retainTopLevelSourceLocationsUntilFieldEdited() { + String content = "---\nname: located-skill\ndescription: Keep positions\n" + + "metadata:\n owner: team-a\n---\n# Located\n"; + + SkillDocument document = SkillFrontmatter.parseDocument(content); + + Assert.assertEquals(2, document.getFrontmatterLocation("name").getLine()); + Assert.assertEquals(1, document.getFrontmatterLocation("name").getColumn()); + Assert.assertEquals(3, document.getFrontmatterLocation("description").getLine()); + Assert.assertEquals(1, document.getFrontmatterLocation("description").getColumn()); + Assert.assertEquals(4, document.getFrontmatterLocation("metadata").getLine()); + Assert.assertEquals(1, document.getFrontmatterLocation("metadata").getColumn()); + Assert.assertTrue(document.getDiagnostics().isEmpty()); + + document.putFrontmatter("description", "Updated description"); + + Assert.assertNull(document.getFrontmatterLocation("description")); + Assert.assertNotNull(document.getFrontmatterLocation("name")); + } + + /** + * 重复 key 被安全构造器拒绝。 + */ + @Test + public void rejectDuplicateKeys() { + assertCode("INVALID_FRONTMATTER_YAML", + "---\nname: duplicate-skill\nname: other\ndescription: Duplicate\n---\n"); + } + + /** + * 自定义危险 tag 被拒绝。 + */ + @Test + public void rejectCustomTag() { + assertCode("INVALID_FRONTMATTER_YAML", + "---\nname: tagged-skill\ndescription: Tagged\nvalue: !java/object test\n---\n"); + } + + /** + * yaml.org 前缀下不在白名单中的 core tag 也被拒绝。 + */ + @Test + public void rejectDisallowedYamlOrgTag() { + assertCode("UNSUPPORTED_FRONTMATTER_TYPE", + "---\nname: binary-skill\ndescription: Binary tag\nvalue: !!binary YWJj\n---\n"); + } + + /** + * 未知 yaml.org 全局 tag 被 TagInspector 拒绝。 + */ + @Test + public void rejectUnknownYamlOrgTag() { + assertCode("INVALID_FRONTMATTER_YAML", + "---\nname: unknown-skill\ndescription: Unknown tag\nvalue: !!unknown test\n---\n"); + } + + /** + * 非 Map 顶层 YAML 被拒绝。 + */ + @Test + public void rejectNonMapRoot() { + assertCode("FRONTMATTER_ROOT_NOT_MAP", "---\n- one\n- two\n---\n"); + } + + /** + * 嵌套 Map 的非字符串 key 被拒绝且不做静默字符串化。 + */ + @Test + public void rejectNestedNonStringKey() { + assertCode("INVALID_FRONTMATTER_KEY", + "---\nname: key-skill\ndescription: Invalid key\nmetadata:\n 1: value\n---\n"); + } + + /** + * 可配置 frontmatter 字节上限生效。 + */ + @Test + public void rejectOversizedFrontmatter() { + SkillPackageLimits limits = SkillPackageLimits.builder().maxFrontmatterBytes(64).build(); + try { + SkillFrontmatter.parseDocument("---\nname: large-skill\ndescription: " + "x".repeat(80) + + "\n---\n", limits); + Assert.fail("Oversized frontmatter should fail."); + } catch (SkillValidationException e) { + Assert.assertEquals("FRONTMATTER_TOO_LARGE", e.getCode()); + } + } + + /** + * YAML collection alias 上限生效。 + */ + @Test + public void rejectAliasLimit() { + String content = "---\nname: alias-skill\ndescription: Alias limit\n" + + "base: &base\n - one\naliases:\n - *base\n - *base\n---\n"; + assertCode("INVALID_FRONTMATTER_YAML", content, + SkillPackageLimits.builder().maxYamlAliases(1).build()); + } + + /** + * YAML 嵌套深度上限生效。 + */ + @Test + public void rejectDepthLimit() { + String content = "---\nname: depth-skill\ndescription: Depth limit\n" + + "metadata:\n one:\n two:\n three: value\n---\n"; + assertCode("INVALID_FRONTMATTER_YAML", content, + SkillPackageLimits.builder().maxYamlDepth(3).build()); + } + + /** + * YAML code point 上限生效。 + */ + @Test + public void rejectCodePointLimit() { + String content = "---\nname: codepoint-skill\ndescription: " + "x".repeat(100) + "\n---\n"; + assertCode("INVALID_FRONTMATTER_YAML", content, + SkillPackageLimits.builder().maxYamlCodePoints(64).build()); + } + + /** + * 未闭合 frontmatter 返回稳定错误码。 + */ + @Test + public void rejectUnclosedFrontmatter() { + assertCode("FRONTMATTER_NOT_CLOSED", + "---\nname: unclosed-skill\ndescription: Unclosed\n"); + } + + private static void assertCode(String code, String content) { + assertCode(code, content, SkillPackageLimits.defaults()); + } + + private static void assertCode(String code, String content, SkillPackageLimits limits) { + try { + SkillFrontmatter.parseDocument(content, limits); + Assert.fail("Invalid frontmatter should fail."); + } catch (SkillValidationException e) { + Assert.assertEquals(code, e.getCode()); + } + } +} diff --git a/easy-agents-skill/src/test/java/com/easyagents/skill/util/SkillPathsTest.java b/easy-agents-skill/src/test/java/com/easyagents/skill/util/SkillPathsTest.java new file mode 100644 index 0000000..f2039d7 --- /dev/null +++ b/easy-agents-skill/src/test/java/com/easyagents/skill/util/SkillPathsTest.java @@ -0,0 +1,35 @@ +package com.easyagents.skill.util; + +import com.easyagents.skill.exception.SkillValidationException; +import org.junit.Assert; +import org.junit.Test; + +/** + * SkillPaths 安全规范化测试。 + */ +public class SkillPathsTest { + + /** + * 点路径段不能被静默剥离。 + */ + @Test(expected = SkillValidationException.class) + public void rejectLeadingDotSegment() { + SkillPaths.normalize("./references/a.md"); + } + + /** + * 双向文本控制字符被拒绝。 + */ + @Test(expected = SkillValidationException.class) + public void rejectBidiFormatCharacter() { + SkillPaths.normalize("references/a\u202Etxt.md"); + } + + /** + * Unicode 路径统一为 NFC。 + */ + @Test + public void normalizeUnicodeToNfc() { + Assert.assertEquals("references/\u00e9.md", SkillPaths.normalize("references/e\u0301.md")); + } +} diff --git a/easy-agents-skill/src/test/java/com/easyagents/skill/util/SkillResourcesTest.java b/easy-agents-skill/src/test/java/com/easyagents/skill/util/SkillResourcesTest.java new file mode 100644 index 0000000..555e83d --- /dev/null +++ b/easy-agents-skill/src/test/java/com/easyagents/skill/util/SkillResourcesTest.java @@ -0,0 +1,22 @@ +package com.easyagents.skill.util; + +import com.easyagents.skill.model.SkillResourceKind; +import org.junit.Assert; +import org.junit.Test; + +/** + * {@link SkillResources} 规范存储表示测试。 + */ +public class SkillResourcesTest { + + /** + * HTML 的短扩展名与长扩展名都应作为严格 UTF-8 文本处理。 + */ + @Test + public void recognizeBothHtmlExtensionsAsText() { + Assert.assertTrue(SkillResources.isText( + "references/page.htm", SkillResourceKind.REFERENCE, "application/octet-stream")); + Assert.assertTrue(SkillResources.isText( + "references/page.html", SkillResourceKind.REFERENCE, "application/octet-stream")); + } +} diff --git a/easy-agents-skill/src/test/java/com/easyagents/skill/validation/SkillValidationReportTest.java b/easy-agents-skill/src/test/java/com/easyagents/skill/validation/SkillValidationReportTest.java new file mode 100644 index 0000000..6e025dc --- /dev/null +++ b/easy-agents-skill/src/test/java/com/easyagents/skill/validation/SkillValidationReportTest.java @@ -0,0 +1,37 @@ +package com.easyagents.skill.validation; + +import com.easyagents.skill.exception.SkillValidationException; +import org.junit.Assert; +import org.junit.Test; + +/** + * {@link SkillValidationReport} 命令式异常契约测试。 + */ +public class SkillValidationReportTest { + + /** + * 验证异常 getter 暴露首个错误定位,同时保留包含全部问题的报告。 + */ + @Test + public void throwIfInvalidKeepsFirstErrorLocationAndCompleteReport() { + SkillValidationReport report = new SkillValidationReport() + .add(new SkillValidationIssue("DRAFT_WARNING", SkillValidationSeverity.WARNING, + "draft/SKILL.md", 1, 1, "草稿警告", null)) + .add(new SkillValidationIssue("FIRST_ERROR", SkillValidationSeverity.ERROR, + "demo/SKILL.md", 4, 7, "首个错误", "修复首个错误")) + .add(new SkillValidationIssue("SECOND_ERROR", SkillValidationSeverity.ERROR, + "demo/scripts/run.sh", 2, 3, "第二个错误", null)); + + try { + report.throwIfInvalid(); + Assert.fail("应抛出 SkillValidationException"); + } catch (SkillValidationException exception) { + Assert.assertEquals("FIRST_ERROR", exception.getCode()); + Assert.assertEquals("demo/SKILL.md", exception.getPath()); + Assert.assertEquals(Integer.valueOf(4), exception.getLine()); + Assert.assertEquals(Integer.valueOf(7), exception.getColumn()); + Assert.assertSame(report, exception.getReport()); + Assert.assertEquals(3, exception.getReport().getIssues().size()); + } + } +} diff --git a/easy-agents-skill/src/test/java/com/easyagents/skill/validation/defaults/DefaultSkillValidatorTest.java b/easy-agents-skill/src/test/java/com/easyagents/skill/validation/defaults/DefaultSkillValidatorTest.java index fdaab0a..7af188f 100644 --- a/easy-agents-skill/src/test/java/com/easyagents/skill/validation/defaults/DefaultSkillValidatorTest.java +++ b/easy-agents-skill/src/test/java/com/easyagents/skill/validation/defaults/DefaultSkillValidatorTest.java @@ -2,8 +2,15 @@ package com.easyagents.skill.validation.defaults; import com.easyagents.skill.exception.SkillValidationException; import com.easyagents.skill.factory.SkillFactory; -import com.easyagents.skill.model.*; +import com.easyagents.skill.model.Skill; +import com.easyagents.skill.model.SkillResource; +import com.easyagents.skill.model.SkillResourceKind; +import com.easyagents.skill.model.SkillPackageLimits; import com.easyagents.skill.util.SkillHashes; +import com.easyagents.skill.validation.SkillValidationReport; +import com.easyagents.skill.validation.SkillValidationMode; +import com.easyagents.skill.validation.SkillValidationSeverity; +import org.junit.Assert; import org.junit.Test; import java.nio.charset.StandardCharsets; @@ -16,178 +23,385 @@ public class DefaultSkillValidatorTest { private final DefaultSkillValidator validator = new DefaultSkillValidator(); /** - * 缺失 SKILL.md 内容时失败。 - */ - @Test(expected = SkillValidationException.class) - public void rejectMissingSkillContent() { - Skill skill = validSkill(); - skill.setSkillContent(null); - - validator.validate(skill); - } - - /** - * 缺失名称时失败。 - */ - @Test(expected = SkillValidationException.class) - public void rejectMissingName() { - Skill skill = validSkill(); - skill.setName(""); - - validator.validate(skill); - } - - /** - * 缺失描述时失败。 - */ - @Test(expected = SkillValidationException.class) - public void rejectMissingDescription() { - Skill skill = validSkill(); - skill.setDescription(""); - - validator.validate(skill); - } - - /** - * 字段名称必须与 SKILL.md frontmatter 一致。 - */ - @Test(expected = SkillValidationException.class) - public void rejectNameMismatchWithFrontmatter() { - Skill skill = validSkill(); - skill.setName("Changed"); - - validator.validate(skill); - } - - /** - * 元数据必须与 SKILL.md frontmatter 一致。 - */ - @Test(expected = SkillValidationException.class) - public void rejectMetadataMismatchWithFrontmatter() { - Skill skill = validSkill(); - skill.getMetadata().put("extra", "x"); - - validator.validate(skill); - } - - /** - * 拒绝嵌套 frontmatter。 - */ - @Test(expected = SkillValidationException.class) - public void rejectNestedFrontmatter() { - Skill skill = validSkill(); - skill.setSkillContent("---\nname: Skill A\ndescription: Desc A\nconfig:\n mode: strict\n---\n# Skill A\n"); - - validator.validate(skill); - } - - /** - * 绝对路径失败。 - */ - @Test(expected = SkillValidationException.class) - public void rejectAbsolutePath() { - Skill skill = validSkill(); - SkillReference reference = reference("/references/a.md", "# A"); - skill.getReferences().add(reference); - - validator.validate(skill); - } - - /** - * ../ 路径失败。 - */ - @Test(expected = SkillValidationException.class) - public void rejectParentPath() { - Skill skill = validSkill(); - skill.getReferences().add(reference("references/../a.md", "# A")); - - validator.validate(skill); - } - - /** - * 重复路径失败。 - */ - @Test(expected = SkillValidationException.class) - public void rejectDuplicatePath() { - Skill skill = validSkill(); - skill.getReferences().add(reference("references/a.md", "# A")); - skill.getReferences().add(reference("references/a.md", "# B")); - - validator.validate(skill); - } - - /** - * 不因为文件大小较大而失败。 - */ - @Test - public void allowLargeRecordedSize() { - Skill skill = validSkill(); - SkillReference reference = reference("references/a.md", "# A"); - reference.setSize(Long.MAX_VALUE); - skill.getReferences().add(reference); - - validator.validate(skill); - } - - /** - * 允许空内容文件,只校验 hash 和 size 记录。 - */ - @Test - public void allowEmptyReferenceAndScriptContent() { - Skill skill = validSkill(); - skill.getReferences().add(reference("references/empty.md", "")); - skill.getScripts().add(script("scripts/empty.sh", "")); - - validator.validate(skill); - } - - /** - * 校验通过完整 Skill。 + * 完整标准 Skill 校验通过。 */ @Test public void validateCompleteSkill() { - Skill skill = validSkill(); - skill.getReferences().add(reference("references/a.md", "# A")); - skill.getScripts().add(script("scripts/run.sh", "echo ok")); - skill.getAssets().add(asset("assets/a.bin", "abc")); + Skill skill = validSkill("skill-a"); + skill.setResources(java.util.List.of( + textResource("references/a.md", SkillResourceKind.REFERENCE, "# A"), + textResource("scripts/run.sh", SkillResourceKind.SCRIPT, "echo ok"), + binaryResource("assets/a.bin", "abc") + )); validator.validate(skill); } - private static Skill validSkill() { - return SkillFactory.create("skill-a", "---\nname: Skill A\ndescription: Desc A\n---\n# Skill A\n"); + /** + * 嵌套 Map、List、布尔和数字 frontmatter 可校验。 + */ + @Test + public void allowNestedFrontmatter() { + String content = "---\nname: nested-skill\ndescription: Handles nested metadata\n" + + "metadata:\n enabled: true\n retries: 3\n tags:\n - alpha\n - beta\n" + + "---\n# Nested\n"; + Skill skill = SkillFactory.create("repository-id", content); + + validator.validate(skill); + + Assert.assertTrue(skill.getMetadata().get("metadata") instanceof java.util.Map); } - private static SkillReference reference(String path, String content) { - SkillReference reference = new SkillReference(); - reference.setPath(path); - reference.setName("a.md"); - reference.setContent(content); - reference.setContentHash(SkillHashes.sha256Hex(content.getBytes(StandardCharsets.UTF_8))); - reference.setSize(content.getBytes(StandardCharsets.UTF_8).length); - return reference; + /** + * 结构化文档重新应用到聚合时同步 name、description 和 metadata。 + */ + @Test + public void applyEditedDocumentToAggregate() { + Skill skill = validSkill("skill-a"); + com.easyagents.skill.model.SkillDocument document = skill.getDocument(); + document.putFrontmatter("description", "Updated description for the Skill"); + + skill.setDocument(document); + + Assert.assertEquals("Updated description for the Skill", skill.getDescription()); + Assert.assertEquals("Updated description for the Skill", skill.getMetadata().get("description")); + validator.validate(skill); } - private static SkillScript script(String path, String content) { - SkillScript script = new SkillScript(); - script.setPath(path); - script.setLanguage(SkillScriptLanguage.fromPath(path)); - script.setContent(content); - script.setContentHash(SkillHashes.sha256Hex(content.getBytes(StandardCharsets.UTF_8))); - script.setSize(content.getBytes(StandardCharsets.UTF_8).length); - return script; + /** + * 历史下划线名称只产生 warning。 + */ + @Test + public void legacyUnderscoreNameProducesWarning() { + Skill skill = validSkill("legacy_skill"); + + SkillValidationReport report = validator.validateReport(skill); + + Assert.assertFalse(report.hasErrors()); + Assert.assertTrue(report.getIssues().stream().anyMatch(issue -> + "NON_CANONICAL_NAME".equals(issue.getCode()) + && issue.getSeverity() == SkillValidationSeverity.WARNING)); } - private static SkillAsset asset(String path, String content) { + /** + * 正式标准模式拒绝仅为草稿导入兼容的下划线名称。 + */ + @Test + public void standardModeRejectsLegacyUnderscoreName() { + Skill skill = validSkill("legacy_skill"); + + SkillValidationReport report = validator.validateReport( + skill, SkillPackageLimits.defaults(), SkillValidationMode.STANDARD); + + Assert.assertTrue(report.getIssues().stream().anyMatch(issue -> + "NON_CANONICAL_NAME".equals(issue.getCode()) + && issue.getSeverity() == SkillValidationSeverity.ERROR)); + } + + /** + * 严格工厂入口执行正式标准名称校验。 + */ + @Test(expected = SkillValidationException.class) + public void strictFactoryRejectsLegacyUnderscoreName() { + SkillFactory.createStrict("repository-id", skillMd("legacy_skill")); + } + + /** + * AgentScope 嵌套 metadata 保真并标记标准兼容 warning。 + */ + @Test + public void nestedMetadataProducesCompatibilityWarning() { + Skill skill = SkillFactory.create("id", "---\nname: metadata-skill\n" + + "description: Nested metadata compatibility\nmetadata:\n provider:\n enabled: true\n" + + "---\n# Metadata\n"); + + SkillValidationReport report = validator.validateReport(skill); + + Assert.assertFalse(report.hasErrors()); + Assert.assertTrue(report.getIssues().stream().anyMatch(issue -> + "NON_STANDARD_METADATA".equals(issue.getCode()))); + } + + /** + * 标准可选字符串字段拒绝空值或非字符串。 + */ + @Test + public void rejectInvalidOptionalStandardFields() { + Skill skill = SkillFactory.create("id", "---\nname: optional-skill\n" + + "description: Invalid optional fields\nlicense: []\ncompatibility: ''\nallowed-tools:\n - Read\n" + + "---\n# Optional\n"); + + SkillValidationReport report = validator.validateReport(skill); + + Assert.assertTrue(report.getIssues().stream().anyMatch(issue -> + "INVALID_LICENSE".equals(issue.getCode()) + && Integer.valueOf(4).equals(issue.getLine()) + && Integer.valueOf(1).equals(issue.getColumn()))); + Assert.assertTrue(report.getIssues().stream().anyMatch(issue -> + "INVALID_COMPATIBILITY".equals(issue.getCode()) + && Integer.valueOf(5).equals(issue.getLine()) + && Integer.valueOf(1).equals(issue.getColumn()))); + Assert.assertTrue(report.getIssues().stream().anyMatch(issue -> + "INVALID_ALLOWED_TOOLS".equals(issue.getCode()) + && Integer.valueOf(6).equals(issue.getLine()) + && Integer.valueOf(1).equals(issue.getColumn()))); + } + + /** + * 非规范名称返回结构化错误。 + */ + @Test + public void rejectInvalidName() { + Skill skill = validSkill("Invalid Name"); + + SkillValidationReport report = validator.validateReport(skill); + + Assert.assertTrue(report.hasErrors()); + Assert.assertTrue(report.getIssues().stream().anyMatch(issue -> "INVALID_NAME".equals(issue.getCode()))); + } + + /** + * 名称与目录不一致时失败。 + */ + @Test(expected = SkillValidationException.class) + public void rejectRootNameMismatch() { + Skill skill = validSkill("skill-a"); + skill.setPackageRoot("other-skill"); + + validator.validate(skill); + } + + /** + * 大小写冲突路径会被拒绝。 + */ + @Test + public void rejectCaseConflictingResourcePaths() { + Skill skill = validSkill("skill-a"); + skill.setResources(java.util.List.of( + textResource("references/A.md", SkillResourceKind.REFERENCE, "A"), + textResource("references/a.md", SkillResourceKind.REFERENCE, "B") + )); + + SkillValidationReport report = validator.validateReport(skill); + + Assert.assertTrue(report.getIssues().stream().anyMatch(issue -> + "RESOURCE_PATH_COLLISION".equals(issue.getCode()))); + } + + /** + * 文件资源不能同时作为另一个资源的祖先路径。 + */ + @Test + public void rejectFileAndDescendantResourcePaths() { + Skill skill = validSkill("skill-a"); + skill.setResources(java.util.List.of( + textResource("references/a.md", SkillResourceKind.REFERENCE, "descendant"), + textResource("references", SkillResourceKind.OTHER, "file") + )); + + SkillValidationReport report = validator.validateReport(skill); + + Assert.assertTrue(report.getIssues().stream().anyMatch(issue -> + "RESOURCE_PATH_HIERARCHY_CONFLICT".equals(issue.getCode()))); + } + + /** + * 路径穿越会被拒绝。 + */ + @Test + public void rejectParentPath() { + Skill skill = validSkill("skill-a"); + skill.setResources(java.util.List.of( + textResource("references/../a.md", SkillResourceKind.REFERENCE, "A"))); + + SkillValidationReport report = validator.validateReport(skill); + + Assert.assertTrue(report.getIssues().stream().anyMatch(issue -> + "UNSAFE_RESOURCE_PATH".equals(issue.getCode()))); + } + + /** + * 缺失资源路径必须形成结构化问题,并继续聚合其他资源错误。 + */ + @Test + public void aggregateMissingPathWithOtherResourceErrors() { + Skill skill = validSkill("skill-a"); + SkillResource missingPath = textResource( + "references/missing.md", SkillResourceKind.REFERENCE, "missing"); + missingPath.setPath(null); + skill.setResources(java.util.List.of( + missingPath, + textResource("references/../escape.md", SkillResourceKind.REFERENCE, "escape"))); + + SkillValidationReport report = validator.validateReport(skill); + + Assert.assertTrue(report.getIssues().stream().anyMatch(issue -> + "RESOURCE_PATH_REQUIRED".equals(issue.getCode()) && issue.getPath() == null)); + Assert.assertTrue(report.getIssues().stream().anyMatch(issue -> + "UNSAFE_RESOURCE_PATH".equals(issue.getCode()))); + } + + /** + * 文本资源 hash 和 size 必须与 UTF-8 内容一致。 + */ + @Test + public void rejectTextHashAndSizeMismatch() { + Skill skill = validSkill("skill-a"); + SkillResource resource = textResource("references/a.md", SkillResourceKind.REFERENCE, "A"); + resource.setContentHash("0".repeat(64)); + resource.setSize(99); + skill.setResources(java.util.List.of(resource)); + + SkillValidationReport report = validator.validateReport(skill); + + Assert.assertTrue(report.getIssues().stream().anyMatch(issue -> + "RESOURCE_HASH_MISMATCH".equals(issue.getCode()))); + Assert.assertTrue(report.getIssues().stream().anyMatch(issue -> + "RESOURCE_SIZE_MISMATCH".equals(issue.getCode()))); + } + + /** + * scripts 目录只接受严格 UTF-8 文本表示,二进制引用必须被拒绝。 + */ + @Test + public void rejectBinaryScriptResource() { + Skill skill = validSkill("skill-a"); + SkillResource script = binaryResource("scripts/run.sh", "echo unsafe"); + script.setKind(SkillResourceKind.SCRIPT); + skill.setResources(java.util.List.of(script)); + + SkillValidationReport report = validator.validateReport(skill); + + Assert.assertTrue(report.getIssues().stream().anyMatch(issue -> + "SCRIPT_TEXT_REQUIRED".equals(issue.getCode()) + && issue.getSeverity() == SkillValidationSeverity.ERROR)); + } + + /** + * 资源的文本或二进制表示必须与统一路径和媒体类型判定一致。 + */ + @Test + public void rejectNonCanonicalResourceContentMode() { + Skill skill = validSkill("skill-a"); + SkillResource textAsset = textResource("assets/readme.txt", SkillResourceKind.ASSET, "text asset"); + skill.setResources(java.util.List.of(textAsset)); + + SkillValidationReport report = validator.validateReport(skill); + + Assert.assertTrue(report.getIssues().stream().anyMatch(issue -> + "RESOURCE_CONTENT_MODE_MISMATCH".equals(issue.getCode()))); + } + + /** + * 空脚本允许保存在草稿中,但应返回可见 warning。 + */ + @Test + public void warnForEmptyScriptResource() { + Skill skill = validSkill("skill-a"); + skill.setResources(java.util.List.of( + textResource("scripts/run.py", SkillResourceKind.SCRIPT, ""))); + + SkillValidationReport report = validator.validateReport(skill); + + Assert.assertFalse(report.hasErrors()); + Assert.assertTrue(report.getIssues().stream().anyMatch(issue -> + "EMPTY_SCRIPT".equals(issue.getCode()) + && issue.getSeverity() == SkillValidationSeverity.WARNING)); + } + + /** + * 未识别脚本语言需要提示,但不能破坏外部标准 Skill 包的资源保真。 + */ + @Test + public void warnForUnrecognizedScriptLanguageExtension() { + Skill skill = validSkill("skill-a"); + skill.setResources(java.util.List.of( + textResource("scripts/run.txt", SkillResourceKind.SCRIPT, "echo ok"))); + + SkillValidationReport report = validator.validateReport(skill); + + Assert.assertFalse(report.hasErrors()); + Assert.assertTrue(report.getIssues().stream().anyMatch(issue -> + "SCRIPT_LANGUAGE_UNRECOGNIZED".equals(issue.getCode()) + && issue.getSeverity() == SkillValidationSeverity.WARNING)); + } + + /** + * 结构化校验器执行资源单文件安全限额。 + */ + @Test + public void reportConfiguredResourceLimit() { + Skill skill = validSkill("skill-a"); + skill.setResources(java.util.List.of( + textResource("references/a.md", SkillResourceKind.REFERENCE, "x".repeat(300)))); + DefaultSkillValidator limitedValidator = new DefaultSkillValidator( + SkillPackageLimits.builder().maxTextFileBytes(128).build()); + + SkillValidationReport report = limitedValidator.validateReport(skill); + + Assert.assertTrue(report.getIssues().stream().anyMatch(issue -> + "TEXT_FILE_SIZE_LIMIT".equals(issue.getCode()))); + } + + /** + * 单次调用传入的限额优先于校验器构造时的默认限额。 + */ + @Test + public void operationLimitsOverrideValidatorDefaults() { + Skill skill = validSkill("skill-a"); + String path = "references/" + "a".repeat(520) + ".md"; + skill.setResources(java.util.List.of( + textResource(path, SkillResourceKind.REFERENCE, "A"))); + SkillPackageLimits operationLimits = SkillPackageLimits.builder() + .maxPathLength(600) + .build(); + + SkillValidationReport report = validator.validateReport(skill, operationLimits); + + Assert.assertFalse(report.hasErrors()); + } + + /** + * 元数据与 SKILL.md 不一致时失败。 + */ + @Test(expected = SkillValidationException.class) + public void rejectMetadataMismatch() { + Skill skill = validSkill("skill-a"); + skill.getMetadata().put("extra", "value"); + + validator.validate(skill); + } + + private static Skill validSkill(String name) { + return SkillFactory.create("repository-id", skillMd(name)); + } + + private static String skillMd(String name) { + return "---\nname: " + name + "\ndescription: Use this Skill for validator tests\n---\n# Skill\n"; + } + + private static SkillResource textResource(String path, SkillResourceKind kind, String content) { + byte[] bytes = content.getBytes(StandardCharsets.UTF_8); + SkillResource resource = new SkillResource(); + resource.setPath(path); + resource.setKind(kind); + resource.setMediaType("text/markdown"); + resource.setTextContent(content); + resource.setContentHash(SkillHashes.sha256Hex(bytes)); + resource.setSize(bytes.length); + return resource; + } + + private static SkillResource binaryResource(String path, String content) { byte[] bytes = content.getBytes(StandardCharsets.UTF_8); String hash = SkillHashes.sha256Hex(bytes); - SkillAsset asset = new SkillAsset(); - asset.setPath(path); - asset.setName("a.bin"); - asset.setMediaType("application/octet-stream"); - asset.setContentRef("sha256:" + hash); - asset.setContentHash(hash); - asset.setSize(bytes.length); - return asset; + SkillResource resource = new SkillResource(); + resource.setPath(path); + resource.setKind(SkillResourceKind.ASSET); + resource.setMediaType("application/octet-stream"); + resource.setContentRef("sha256:" + hash); + resource.setContentHash(hash); + resource.setSize(bytes.length); + return resource; } } diff --git a/pom.xml b/pom.xml index 28b5090..455abe1 100644 --- a/pom.xml +++ b/pom.xml @@ -46,6 +46,8 @@ 1.8.22 1.51.0 1.0.12 + 2.6 + 1.28.0 @@ -114,6 +116,18 @@ ${junit.version} + + org.yaml + snakeyaml + ${snakeyaml.version} + + + + org.apache.commons + commons-compress + ${commons-compress.version} + + com.easyagents