feat: 完善标准 Skill 包底座

- 标准化 SKILL.md、资源模型、校验规则与安全限额

- 支持流式内容存储和单、多 Skill ZIP 双向编解码
This commit is contained in:
2026-07-27 18:53:24 +08:00
parent 6fa93bd671
commit c48d9a9da6
53 changed files with 7809 additions and 666 deletions

View File

@@ -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<String, Object> nested = new LinkedHashMap<>();
nested.put("items", new java.util.ArrayList<>(List.of("a")));
SkillMetadata metadata = new SkillMetadata(Map.of("nested", nested));
Map<String, Object> read = metadata.getValues();
((Map<String, Object>) 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));
}
}

View File

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

View File

@@ -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");

View File

@@ -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<Class<?>> 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<Path> 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;
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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