/** * Filesystem boundary hardening tests. * * Covers high-risk edge cases for workspace and skill filesystem paths: * - Symlink traversal / realpath boundary bypass * - Path traversal via ../ sequences * - Unicode/normalization edge cases in names * - Concurrent read-while-delete behavior * - Deterministic error codes and messages for all rejection paths * * All tests use temporary directories and clean up after themselves. */ import { describe, it, expect, beforeEach, afterEach } from "vitest"; import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, existsSync, rmSync, symlinkSync, } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { UserSkillStore, SkillRegistry, SkillValidationError } from "../src/skills.js"; import { Storage } from "../src/storage.js"; // ─── Fixtures ─── const VALID_SKILL_MD = `--- name: test-skill description: A boundary test skill --- # Test Skill `; function makeSkillDir(baseDir: string, name: string, content?: string): string { const dir = join(baseDir, name); mkdirSync(dir, { recursive: true }); writeFileSync(join(dir, "SKILL.md"), content ?? VALID_SKILL_MD); return dir; } function makeSecretFile(dir: string, name = "secret.txt", content = "TOP SECRET DATA"): string { const path = join(dir, name); writeFileSync(path, content); return path; } // ─── UserSkillStore: Symlink Traversal ─── describe("UserSkillStore symlink traversal", () => { let storeDir: string; let workDir: string; let outsideDir: string; let store: UserSkillStore; beforeEach(() => { storeDir = mkdtempSync(join(tmpdir(), "fs-bound-store-")); workDir = mkdtempSync(join(tmpdir(), "fs-bound-work-")); outsideDir = mkdtempSync(join(tmpdir(), "fs-bound-outside-")); store = new UserSkillStore(storeDir); store.init(); }); afterEach(() => { rmSync(storeDir, { recursive: true, force: true }); rmSync(workDir, { recursive: true, force: true }); rmSync(outsideDir, { recursive: true, force: true }); }); it("blocks readFile via symlink pointing outside skill directory", () => { // Set up a skill with a symlink that escapes the boundary const skillDir = makeSkillDir(workDir, "trapped"); store.saveSkill("trapped", skillDir); // Create a secret file outside the store makeSecretFile(outsideDir, "secret.txt"); // Plant a symlink inside the saved skill pointing outside const savedDir = join(storeDir, "trapped"); symlinkSync(join(outsideDir, "secret.txt"), join(savedDir, "escape.txt")); // readFile should reject — the resolved path is outside the skill boundary expect(store.readFile("trapped", "escape.txt")).toBeUndefined(); }); it("blocks readFile via symlinked subdirectory pointing outside", () => { const skillDir = makeSkillDir(workDir, "subdir-escape"); store.saveSkill("subdir-escape", skillDir); // Create a directory with secrets outside mkdirSync(join(outsideDir, "private"), { recursive: true }); makeSecretFile(join(outsideDir, "private"), "data.txt"); // Symlink an entire directory into the skill const savedDir = join(storeDir, "subdir-escape"); symlinkSync(join(outsideDir, "private"), join(savedDir, "linked")); expect(store.readFile("subdir-escape", "linked/data.txt")).toBeUndefined(); }); it("blocks readFile via chained symlinks", () => { const skillDir = makeSkillDir(workDir, "chained"); store.saveSkill("chained", skillDir); // Create chain: hop1 -> hop2 -> secret makeSecretFile(outsideDir, "final-secret.txt"); const hop2 = join(outsideDir, "hop2"); symlinkSync(join(outsideDir, "final-secret.txt"), hop2); const savedDir = join(storeDir, "chained"); symlinkSync(hop2, join(savedDir, "hop1")); expect(store.readFile("chained", "hop1")).toBeUndefined(); }); it("allows readFile for symlink pointing within skill directory", () => { const skillDir = makeSkillDir(workDir, "internal-link"); writeFileSync(join(skillDir, "real.txt"), "real content"); store.saveSkill("internal-link", skillDir); // Create an internal symlink (same directory) const savedDir = join(storeDir, "internal-link"); symlinkSync(join(savedDir, "real.txt"), join(savedDir, "alias.txt")); // Internal symlinks should be allowed expect(store.readFile("internal-link", "alias.txt")).toBe("real content"); }); }); // ─── SkillRegistry: Symlink Traversal ─── describe("SkillRegistry symlink traversal", () => { let scanDir: string; let outsideDir: string; let registry: SkillRegistry; beforeEach(() => { scanDir = mkdtempSync(join(tmpdir(), "fs-bound-registry-")); outsideDir = mkdtempSync(join(tmpdir(), "fs-bound-outside-")); registry = new SkillRegistry([], { debounceMs: 50 }); (registry as any).scanDirs = [scanDir]; }); afterEach(() => { registry.stopWatching(); rmSync(scanDir, { recursive: true, force: true }); rmSync(outsideDir, { recursive: true, force: true }); }); it("blocks getFileContent via symlink escaping skill boundary", () => { const SKILL_MD = `---\nname: registry-trapped\ndescription: "Trapped skill"\n---\n# Trapped\n`; const dir = makeSkillDir(scanDir, "registry-trapped", SKILL_MD); // Plant outside secret + symlink makeSecretFile(outsideDir, "registry-secret.txt"); symlinkSync(join(outsideDir, "registry-secret.txt"), join(dir, "escape.txt")); registry.scan(); expect(registry.getFileContent("registry-trapped", "escape.txt")).toBeUndefined(); }); it("blocks getFileContent via ../ combined with symlink", () => { const SKILL_MD = `---\nname: combo-attack\ndescription: "Combo attack skill"\n---\n# Combo\n`; makeSkillDir(scanDir, "combo-attack", SKILL_MD); registry.scan(); // ../ path traversal expect(registry.getFileContent("combo-attack", "../../../etc/passwd")).toBeUndefined(); }); }); // ─── Path Traversal via ../ Sequences ─── describe("UserSkillStore path traversal via ../ sequences", () => { let storeDir: string; let workDir: string; let store: UserSkillStore; beforeEach(() => { storeDir = mkdtempSync(join(tmpdir(), "fs-bound-traversal-")); workDir = mkdtempSync(join(tmpdir(), "fs-bound-trav-work-")); store = new UserSkillStore(storeDir); store.init(); makeSkillDir(workDir, "victim"); store.saveSkill("victim", join(workDir, "victim")); }); afterEach(() => { rmSync(storeDir, { recursive: true, force: true }); rmSync(workDir, { recursive: true, force: true }); }); it("blocks simple ../ traversal", () => { expect(store.readFile("victim", "../../../etc/passwd")).toBeUndefined(); }); it("blocks ../ traversal to sibling skill", () => { makeSkillDir(workDir, "neighbor"); store.saveSkill("neighbor", join(workDir, "neighbor")); expect(store.readFile("victim", "../neighbor/SKILL.md")).toBeUndefined(); }); it("blocks encoded-style traversal sequences", () => { // Even if someone passes a path that after join resolves outside expect(store.readFile("victim", "sub/../../SKILL.md")).toBeUndefined(); // This should resolve to the SKILL.md itself (within boundary), but test the deep escape expect(store.readFile("victim", "sub/../../../etc/shadow")).toBeUndefined(); }); it("blocks absolute path injection", () => { // Absolute path — join() with absolute second arg replaces the first expect(store.readFile("victim", "/etc/passwd")).toBeUndefined(); }); it("blocks null byte injection in path", () => { // Null bytes can truncate paths in some systems expect(store.readFile("victim", "SKILL.md\0../../etc/passwd")).toBeUndefined(); }); }); // ─── Skill Name Validation ─── describe("UserSkillStore skill name boundary validation", () => { let storeDir: string; let workDir: string; let store: UserSkillStore; beforeEach(() => { storeDir = mkdtempSync(join(tmpdir(), "fs-bound-name-")); workDir = mkdtempSync(join(tmpdir(), "fs-bound-name-work-")); store = new UserSkillStore(storeDir); store.init(); }); afterEach(() => { rmSync(storeDir, { recursive: true, force: true }); rmSync(workDir, { recursive: true, force: true }); }); it("rejects name with ../ path traversal", () => { const src = makeSkillDir(workDir, "legit"); expect(() => store.saveSkill("../escape", src)).toThrow(SkillValidationError); expect(() => store.saveSkill("../escape", src)).toThrow("Invalid skill name"); }); it("rejects name with slashes", () => { const src = makeSkillDir(workDir, "legit"); expect(() => store.saveSkill("foo/bar", src)).toThrow("Invalid skill name"); expect(() => store.saveSkill("foo\\bar", src)).toThrow("Invalid skill name"); }); it("rejects name with dots only", () => { const src = makeSkillDir(workDir, "legit"); expect(() => store.saveSkill(".", src)).toThrow("Invalid skill name"); expect(() => store.saveSkill("..", src)).toThrow("Invalid skill name"); }); it("rejects empty name", () => { const src = makeSkillDir(workDir, "legit"); expect(() => store.saveSkill("", src)).toThrow("Invalid skill name"); }); it("rejects name with uppercase", () => { const src = makeSkillDir(workDir, "legit"); expect(() => store.saveSkill("MySkill", src)).toThrow("Invalid skill name"); }); it("rejects name with unicode characters", () => { const src = makeSkillDir(workDir, "legit"); expect(() => store.saveSkill("skïll", src)).toThrow("Invalid skill name"); expect(() => store.saveSkill("技能", src)).toThrow("Invalid skill name"); }); it("rejects name exceeding max length (64 chars)", () => { const src = makeSkillDir(workDir, "legit"); const longName = "a" + "-x".repeat(32); // 65 chars expect(() => store.saveSkill(longName, src)).toThrow("Invalid skill name"); }); it("accepts valid names at boundary length (64 chars)", () => { const src = makeSkillDir(workDir, "legit"); const name64 = "a" + "b".repeat(63); // exactly 64 // Should not throw for name format — may throw for other reasons try { store.saveSkill(name64, src); } catch (e) { // If it throws, it should NOT be about the name expect((e as Error).message).not.toContain("Invalid skill name"); } }); it("has deterministic error code for invalid name", () => { const src = makeSkillDir(workDir, "legit"); try { store.saveSkill("BAD_NAME", src); expect.unreachable("should have thrown"); } catch (e) { expect(e).toBeInstanceOf(SkillValidationError); expect((e as SkillValidationError).code).toBe("INVALID_NAME"); } }); it("has deterministic error code for missing source", () => { try { store.saveSkill("valid-name", "/nonexistent/path"); expect.unreachable("should have thrown"); } catch (e) { expect(e).toBeInstanceOf(SkillValidationError); expect((e as SkillValidationError).code).toBe("SOURCE_NOT_FOUND"); } }); it("has deterministic error code for missing SKILL.md", () => { const noSkillDir = join(workDir, "no-skill-md"); mkdirSync(noSkillDir, { recursive: true }); writeFileSync(join(noSkillDir, "readme.md"), "not a skill"); try { store.saveSkill("no-skill-md", noSkillDir); expect.unreachable("should have thrown"); } catch (e) { expect(e).toBeInstanceOf(SkillValidationError); expect((e as SkillValidationError).code).toBe("NO_SKILL_MD"); } }); it("has deterministic error code for oversized skill", () => { const src = makeSkillDir(workDir, "big"); writeFileSync(join(src, "big.bin"), Buffer.alloc(200 * 1024)); try { store.saveSkill("big", src); expect.unreachable("should have thrown"); } catch (e) { expect(e).toBeInstanceOf(SkillValidationError); expect((e as SkillValidationError).code).toBe("TOO_LARGE"); } }); it("has deterministic error code for too many files", () => { const src = makeSkillDir(workDir, "many"); for (let i = 0; i < 55; i++) { writeFileSync(join(src, `f${i}.txt`), `c${i}`); } try { store.saveSkill("many", src); expect.unreachable("should have thrown"); } catch (e) { expect(e).toBeInstanceOf(SkillValidationError); expect((e as SkillValidationError).code).toBe("TOO_MANY_FILES"); } }); }); // ─── Unicode/Normalization Edge Cases ─── describe("Unicode and normalization edge cases", () => { let storeDir: string; let workDir: string; let store: UserSkillStore; beforeEach(() => { storeDir = mkdtempSync(join(tmpdir(), "fs-bound-unicode-")); workDir = mkdtempSync(join(tmpdir(), "fs-bound-unicode-work-")); store = new UserSkillStore(storeDir); store.init(); }); afterEach(() => { rmSync(storeDir, { recursive: true, force: true }); rmSync(workDir, { recursive: true, force: true }); }); it("rejects skill names with unicode lookalikes (homoglyphs)", () => { const src = makeSkillDir(workDir, "legit"); // Cyrillic 'а' (U+0430) looks like Latin 'a' expect(() => store.saveSkill("f\u0430ke", src)).toThrow("Invalid skill name"); }); it("rejects skill names with zero-width characters", () => { const src = makeSkillDir(workDir, "legit"); // Zero-width space U+200B expect(() => store.saveSkill("my\u200Bskill", src)).toThrow("Invalid skill name"); // Zero-width joiner U+200D expect(() => store.saveSkill("my\u200Dskill", src)).toThrow("Invalid skill name"); }); it("rejects skill names with unicode normalization variants", () => { const src = makeSkillDir(workDir, "legit"); // é as precomposed (U+00E9) vs decomposed (e + U+0301) expect(() => store.saveSkill("caf\u00E9", src)).toThrow("Invalid skill name"); expect(() => store.saveSkill("cafe\u0301", src)).toThrow("Invalid skill name"); }); it("handles unicode in file content within skills", () => { const src = makeSkillDir(workDir, "unicode-content"); writeFileSync(join(src, "data.txt"), "日本語テスト 🎉"); store.saveSkill("unicode-content", src); const content = store.readFile("unicode-content", "data.txt"); expect(content).toBe("日本語テスト 🎉"); }); it("readFile handles NFC vs NFD normalized filenames", () => { const src = makeSkillDir(workDir, "norm-file"); // macOS HFS+ normalizes filenames to NFD const nfcName = "\u00E9.txt"; // é precomposed writeFileSync(join(src, nfcName), "nfc content"); store.saveSkill("norm-file", src); // Whether the FS stores it as NFC or NFD, we should be able to read it back const files = store.listFiles("norm-file"); const matchingFile = files.find((f) => f.includes(".txt") && f !== "SKILL.md"); if (matchingFile) { const content = store.readFile("norm-file", matchingFile); expect(content).toBe("nfc content"); } }); }); // ─── Workspace Storage Boundary Tests ─── describe("Workspace storage boundary hardening", () => { let dataDir: string; let storage: Storage; beforeEach(() => { dataDir = mkdtempSync(join(tmpdir(), "fs-bound-ws-")); storage = new Storage(dataDir); }); afterEach(() => { rmSync(dataDir, { recursive: true, force: true }); }); it("workspace IDs are generated internally — traversal IDs produce sanitized garbage", () => { // Workspace IDs are always generated by generateId() (base64url safe chars). // If a traversal ID like "../config" reaches getWorkspace, the store reads // whatever file it resolves to and sanitizes it through the workspace schema. // This is defense-in-depth: the API layer should reject malformed IDs before // they reach the store. Here we verify the store doesn't crash. const result = storage.getWorkspace("../config"); // config.json exists and gets parsed — sanitized into a garbage workspace // This is safe because: (1) API validates ID format, (2) result is sanitized if (result) { // If it read something, it should have been sanitized expect(typeof result.id).toBe("string"); expect(typeof result.name).toBe("string"); } }); it("workspace getWorkspace with deep traversal does not crash", () => { // Even with deeply nested traversal, the store should not throw const result = storage.getWorkspace("../../etc/passwd"); // /etc/passwd doesn't parse as JSON → undefined expect(result).toBeUndefined(); }); it("workspace names with special chars are stored safely", () => { // Workspace names are stored as JSON content, not filesystem paths // but verify they round-trip safely const specialNames = [ 'name with "quotes"', "name\nwith\nnewlines", "name\twith\ttabs", "a".repeat(1000), // very long name "", "${process.exit()}", "name/with/slashes", "name\\with\\backslashes", ]; for (const name of specialNames) { const ws = storage.createWorkspace({ name, skills: [] }); const loaded = storage.getWorkspace(ws.id); expect(loaded).toBeDefined(); expect(loaded!.name).toBe(name); } }); it("corrupt workspace JSON returns undefined, not crash", () => { const ws = storage.createWorkspace({ name: "will-corrupt", skills: [] }); const path = join(dataDir, "workspaces", `${ws.id}.json`); // Various corruption scenarios const corruptions = [ "", // empty "{", // truncated JSON "null", // valid JSON but not an object "[]", // array instead of object '{"id": 42}', // wrong type for id "\x00\x01\x02", // binary garbage ]; for (const corrupt of corruptions) { writeFileSync(path, corrupt); // Should not throw const result = storage.getWorkspace(ws.id); // For empty/binary, parsing fails → undefined // For valid-but-wrong JSON, sanitize returns a workspace // Either way, no crash expect(result === undefined || typeof result === "object").toBe(true); } }); it("listWorkspaces handles mixed valid and corrupt files", () => { storage.createWorkspace({ name: "good-one", skills: [] }); storage.createWorkspace({ name: "good-two", skills: [] }); // Inject a corrupt file writeFileSync(join(dataDir, "workspaces", "corrupt.json"), "{{invalid}}"); const list = storage.listWorkspaces(); // At least the two good ones should load expect(list.length).toBeGreaterThanOrEqual(2); expect(list.map((w) => w.name)).toContain("good-one"); expect(list.map((w) => w.name)).toContain("good-two"); }); it("deleteWorkspace with traversal ID — defense-in-depth note", () => { // NOTE: The workspace store does NOT validate IDs against path traversal. // Workspace IDs are always generated internally (generateId), so traversal // IDs should never reach the store in practice. The API layer validates // ID format before calling store methods. This test documents the behavior. // // If ../config resolves to an existing file, deleteWorkspace removes it. // This is acceptable because: // 1. The API layer rejects IDs that don't match /^[A-Za-z0-9_-]+$/ // 2. Internal callers always use generated IDs // // Verify at minimum it doesn't crash: const result = storage.deleteWorkspace("../../nonexistent/file"); expect(result).toBe(false); }); it("updateWorkspace with deep traversal ID does not crash", () => { // Similar to above — store doesn't validate IDs, API layer does const result = storage.updateWorkspace("../../etc/nonexistent", { name: "hacked" }); expect(result).toBeUndefined(); }); }); // ─── Concurrent Read-While-Delete ─── describe("Concurrent read-while-delete behavior", () => { let storeDir: string; let workDir: string; let store: UserSkillStore; beforeEach(() => { storeDir = mkdtempSync(join(tmpdir(), "fs-bound-concurrent-")); workDir = mkdtempSync(join(tmpdir(), "fs-bound-concurrent-work-")); store = new UserSkillStore(storeDir); store.init(); }); afterEach(() => { rmSync(storeDir, { recursive: true, force: true }); rmSync(workDir, { recursive: true, force: true }); }); it("readFile returns undefined after skill is deleted", () => { makeSkillDir(workDir, "ephemeral"); store.saveSkill("ephemeral", join(workDir, "ephemeral")); // Verify it exists expect(store.readFile("ephemeral", "SKILL.md")).toBeDefined(); // Delete store.deleteSkill("ephemeral"); // Read after delete — should return undefined, not throw expect(store.readFile("ephemeral", "SKILL.md")).toBeUndefined(); }); it("listFiles returns empty after skill directory removed externally", () => { makeSkillDir(workDir, "vanishing"); store.saveSkill("vanishing", join(workDir, "vanishing")); // Externally remove the directory (simulating concurrent deletion) rmSync(join(storeDir, "vanishing"), { recursive: true, force: true }); expect(store.listFiles("vanishing")).toEqual([]); }); it("getSkill returns null after skill directory removed externally", () => { makeSkillDir(workDir, "gone"); store.saveSkill("gone", join(workDir, "gone")); rmSync(join(storeDir, "gone"), { recursive: true, force: true }); expect(store.getSkill("gone")).toBeNull(); }); it("concurrent save + read does not corrupt", () => { // Save a skill, then immediately overwrite it while reading makeSkillDir(workDir, "concurrent"); store.saveSkill("concurrent", join(workDir, "concurrent")); // Read and save in quick succession const results: (string | undefined)[] = []; for (let i = 0; i < 10; i++) { results.push(store.readFile("concurrent", "SKILL.md")); // Re-save (overwrite) store.saveSkill("concurrent", join(workDir, "concurrent")); } // All reads should either return content or undefined — never throw for (const result of results) { expect(result === undefined || typeof result === "string").toBe(true); } }); }); // ─── Workspace Concurrent Operations ─── describe("Workspace concurrent read-while-delete", () => { let dataDir: string; let storage: Storage; beforeEach(() => { dataDir = mkdtempSync(join(tmpdir(), "fs-bound-ws-concurrent-")); storage = new Storage(dataDir); }); afterEach(() => { rmSync(dataDir, { recursive: true, force: true }); }); it("getWorkspace returns undefined after file removed externally", () => { const ws = storage.createWorkspace({ name: "temp", skills: [] }); const path = join(dataDir, "workspaces", `${ws.id}.json`); // Externally remove rmSync(path); expect(storage.getWorkspace(ws.id)).toBeUndefined(); }); it("listWorkspaces handles mid-iteration file deletion gracefully", () => { // Create several workspaces for (let i = 0; i < 5; i++) { storage.createWorkspace({ name: `ws-${i}`, skills: [] }); } // Delete one file externally mid-way (simulate race) const list = storage.listWorkspaces(); if (list.length > 0) { const path = join(dataDir, "workspaces", `${list[0].id}.json`); rmSync(path); } // Re-list should work fine, minus the deleted one const afterList = storage.listWorkspaces(); expect(afterList.length).toBeLessThanOrEqual(5); // No crashes }); it("deleteWorkspace is idempotent", () => { const ws = storage.createWorkspace({ name: "double-delete", skills: [] }); expect(storage.deleteWorkspace(ws.id)).toBe(true); expect(storage.deleteWorkspace(ws.id)).toBe(false); expect(storage.deleteWorkspace(ws.id)).toBe(false); }); it("rapid create-delete cycles don't leak files", () => { const ids: string[] = []; for (let i = 0; i < 20; i++) { const ws = storage.createWorkspace({ name: `rapid-${i}`, skills: [] }); ids.push(ws.id); } for (const id of ids) { storage.deleteWorkspace(id); } expect(storage.listWorkspaces()).toEqual([]); // Verify no orphan files const wsDir = join(dataDir, "workspaces"); if (existsSync(wsDir)) { const remaining = readFileSync.length; // just checking it doesn't crash const list = storage.listWorkspaces(); expect(list).toEqual([]); } }); }); // ─── saveSkill with Symlink in Source ─── describe("UserSkillStore saveSkill with symlinks in source", () => { let storeDir: string; let workDir: string; let outsideDir: string; let store: UserSkillStore; beforeEach(() => { storeDir = mkdtempSync(join(tmpdir(), "fs-bound-save-")); workDir = mkdtempSync(join(tmpdir(), "fs-bound-save-work-")); outsideDir = mkdtempSync(join(tmpdir(), "fs-bound-save-outside-")); store = new UserSkillStore(storeDir); store.init(); }); afterEach(() => { rmSync(storeDir, { recursive: true, force: true }); rmSync(workDir, { recursive: true, force: true }); rmSync(outsideDir, { recursive: true, force: true }); }); it("saveSkill with symlink in source — boundary enforced either way", () => { // Source dir has a symlink pointing outside. cpSync with dereference:true // should copy the content as a regular file. Regardless of whether the // copy preserves symlinks or dereferences them, readFile must enforce // the boundary correctly: // - If dereferenced: readFile works (regular file inside boundary) // - If symlink preserved: readFile blocks (resolves outside boundary) const skillDir = makeSkillDir(workDir, "with-link"); makeSecretFile(outsideDir, "external.txt", "external data"); symlinkSync(join(outsideDir, "external.txt"), join(skillDir, "linked.txt")); store.saveSkill("with-link", skillDir); const savedDir = join(storeDir, "with-link"); const savedFile = join(savedDir, "linked.txt"); expect(existsSync(savedFile)).toBe(true); // readFile either returns the content (if dereferenced) or undefined // (if symlink was preserved and points outside boundary). Both are safe. const result = store.readFile("with-link", "linked.txt"); expect(result === "external data" || result === undefined).toBe(true); }); it("saveSkill size check counts dereferenced file sizes", () => { const skillDir = makeSkillDir(workDir, "size-deref"); // Create a large file outside and symlink it in makeSecretFile(outsideDir, "big.bin", "x".repeat(200 * 1024)); symlinkSync(join(outsideDir, "big.bin"), join(skillDir, "big.bin")); // Should fail size validation because the dereferenced file is too large expect(() => store.saveSkill("size-deref", skillDir)).toThrow("too large"); }); }); // ─── Edge Cases: Special Path Patterns ─── describe("Special path patterns", () => { let storeDir: string; let workDir: string; let store: UserSkillStore; beforeEach(() => { storeDir = mkdtempSync(join(tmpdir(), "fs-bound-special-")); workDir = mkdtempSync(join(tmpdir(), "fs-bound-special-work-")); store = new UserSkillStore(storeDir); store.init(); makeSkillDir(workDir, "test-skill"); store.saveSkill("test-skill", join(workDir, "test-skill")); }); afterEach(() => { rmSync(storeDir, { recursive: true, force: true }); rmSync(workDir, { recursive: true, force: true }); }); it("readFile rejects empty relative path", () => { expect(store.readFile("test-skill", "")).toBeUndefined(); }); it("readFile rejects path with only dots and slashes", () => { expect(store.readFile("test-skill", ".")).toBeUndefined(); expect(store.readFile("test-skill", "./")).toBeUndefined(); expect(store.readFile("test-skill", "..")).toBeUndefined(); expect(store.readFile("test-skill", "../")).toBeUndefined(); expect(store.readFile("test-skill", "./.")).toBeUndefined(); }); it("readFile rejects path with double slashes", () => { // Shouldn't crash, should just return undefined for non-existent const result = store.readFile("test-skill", "sub//SKILL.md"); // Either undefined or the file if it resolves — just no crash expect(result === undefined || typeof result === "string").toBe(true); }); it("getSkill handles missing baseDir gracefully", () => { // Create a store pointing to a non-existent directory const ghostStore = new UserSkillStore("/nonexistent/path/to/skills"); expect(ghostStore.getSkill("any")).toBeNull(); expect(ghostStore.listSkills()).toEqual([]); expect(ghostStore.listFiles("any")).toEqual([]); expect(ghostStore.readFile("any", "SKILL.md")).toBeUndefined(); }); it("deleteSkill on non-existent base dir returns false", () => { const ghostStore = new UserSkillStore("/nonexistent/path/to/skills"); expect(ghostStore.deleteSkill("any")).toBe(false); }); });