|
| 1 | +import { afterEach, beforeEach, describe, expect, test } from "vitest"; |
| 2 | +import { mkdir, readFile, writeFile, rm, access } from "fs/promises"; |
| 3 | +import { join } from "path"; |
| 4 | +import { tmpdir } from "os"; |
| 5 | + |
| 6 | +import { installSkillFiles, symlinkSkill } from "../utils/installer.js"; |
| 7 | +import { isSafeSkillName, assertSkillNameInRoot } from "../utils/skill-name.js"; |
| 8 | + |
| 9 | +let tempDir: string; |
| 10 | + |
| 11 | +async function exists(path: string): Promise<boolean> { |
| 12 | + try { |
| 13 | + await access(path); |
| 14 | + return true; |
| 15 | + } catch { |
| 16 | + return false; |
| 17 | + } |
| 18 | +} |
| 19 | + |
| 20 | +beforeEach(async () => { |
| 21 | + tempDir = join(tmpdir(), `ctx7-installer-test-${Date.now()}-${Math.random()}`); |
| 22 | + await mkdir(tempDir, { recursive: true }); |
| 23 | +}); |
| 24 | + |
| 25 | +afterEach(async () => { |
| 26 | + await rm(tempDir, { recursive: true, force: true }); |
| 27 | +}); |
| 28 | + |
| 29 | +describe("isSafeSkillName", () => { |
| 30 | + test("accepts typical skill names", () => { |
| 31 | + for (const name of [ |
| 32 | + "pdf", |
| 33 | + "find-docs", |
| 34 | + "skill_one", |
| 35 | + "skill.v2", |
| 36 | + "a1", |
| 37 | + "abc123", |
| 38 | + "PDF", |
| 39 | + "Find-Docs", |
| 40 | + "MySkill", |
| 41 | + ]) { |
| 42 | + expect(isSafeSkillName(name)).toBe(true); |
| 43 | + } |
| 44 | + }); |
| 45 | + |
| 46 | + test("rejects path traversal and separators", () => { |
| 47 | + for (const name of [ |
| 48 | + "..", |
| 49 | + ".", |
| 50 | + "../evil", |
| 51 | + "..\\evil", |
| 52 | + "a/b", |
| 53 | + "a\\b", |
| 54 | + "/abs", |
| 55 | + "C:\\drive", |
| 56 | + "with space", |
| 57 | + "", |
| 58 | + ".hidden", |
| 59 | + "name\0", |
| 60 | + "name\nwith-newline", |
| 61 | + ]) { |
| 62 | + expect(isSafeSkillName(name)).toBe(false); |
| 63 | + } |
| 64 | + }); |
| 65 | + |
| 66 | + test("rejects names longer than 128 chars", () => { |
| 67 | + expect(isSafeSkillName("a".repeat(128))).toBe(true); |
| 68 | + expect(isSafeSkillName("a".repeat(129))).toBe(false); |
| 69 | + }); |
| 70 | +}); |
| 71 | + |
| 72 | +describe("assertSkillNameInRoot", () => { |
| 73 | + test("returns resolved path for safe name", () => { |
| 74 | + const result = assertSkillNameInRoot("/tmp/skills", "pdf"); |
| 75 | + expect(result).toBe("/tmp/skills/pdf"); |
| 76 | + }); |
| 77 | + |
| 78 | + test("throws on traversal", () => { |
| 79 | + expect(() => assertSkillNameInRoot("/tmp/skills", "..")).toThrow(); |
| 80 | + expect(() => assertSkillNameInRoot("/tmp/skills", "../evil")).toThrow(); |
| 81 | + }); |
| 82 | +}); |
| 83 | + |
| 84 | +describe("installSkillFiles", () => { |
| 85 | + test("writes files inside the skill directory", async () => { |
| 86 | + const skillsRoot = join(tempDir, "skills"); |
| 87 | + await mkdir(skillsRoot, { recursive: true }); |
| 88 | + |
| 89 | + await installSkillFiles("good", [{ path: "SKILL.md", content: "hello" }], skillsRoot); |
| 90 | + |
| 91 | + const written = await readFile(join(skillsRoot, "good", "SKILL.md"), "utf8"); |
| 92 | + expect(written).toBe("hello"); |
| 93 | + }); |
| 94 | + |
| 95 | + test("rejects skill name '..' and does not write outside skills root", async () => { |
| 96 | + const skillsRoot = join(tempDir, ".claude", "skills"); |
| 97 | + await mkdir(skillsRoot, { recursive: true }); |
| 98 | + |
| 99 | + const settingsPath = join(tempDir, ".claude", "settings.json"); |
| 100 | + |
| 101 | + await expect( |
| 102 | + installSkillFiles("..", [{ path: "settings.json", content: '{"hooks":{}}' }], skillsRoot) |
| 103 | + ).rejects.toThrow(); |
| 104 | + |
| 105 | + expect(await exists(settingsPath)).toBe(false); |
| 106 | + }); |
| 107 | + |
| 108 | + test("rejects skill names with path separators", async () => { |
| 109 | + const skillsRoot = join(tempDir, "skills"); |
| 110 | + await mkdir(skillsRoot, { recursive: true }); |
| 111 | + |
| 112 | + for (const bad of ["../evil", "a/b", "..\\evil"]) { |
| 113 | + await expect( |
| 114 | + installSkillFiles(bad, [{ path: "SKILL.md", content: "x" }], skillsRoot) |
| 115 | + ).rejects.toThrow(); |
| 116 | + } |
| 117 | + }); |
| 118 | + |
| 119 | + test("still rejects traversal in file.path", async () => { |
| 120 | + const skillsRoot = join(tempDir, "skills"); |
| 121 | + await mkdir(skillsRoot, { recursive: true }); |
| 122 | + |
| 123 | + await expect( |
| 124 | + installSkillFiles("good", [{ path: "../escape.txt", content: "x" }], skillsRoot) |
| 125 | + ).rejects.toThrow(/outside/); |
| 126 | + }); |
| 127 | +}); |
| 128 | + |
| 129 | +describe("symlinkSkill", () => { |
| 130 | + test("creates symlink under skills root for safe name", async () => { |
| 131 | + const skillsRoot = join(tempDir, "linked"); |
| 132 | + await mkdir(skillsRoot, { recursive: true }); |
| 133 | + |
| 134 | + const source = join(tempDir, "source"); |
| 135 | + await mkdir(source, { recursive: true }); |
| 136 | + await writeFile(join(source, "marker"), "ok"); |
| 137 | + |
| 138 | + await symlinkSkill("good", source, skillsRoot); |
| 139 | + |
| 140 | + const linkedMarker = await readFile(join(skillsRoot, "good", "marker"), "utf8"); |
| 141 | + expect(linkedMarker).toBe("ok"); |
| 142 | + }); |
| 143 | + |
| 144 | + test("does not rm parent when skill name is '..'", async () => { |
| 145 | + const skillsRoot = join(tempDir, ".cursor", "skills"); |
| 146 | + await mkdir(skillsRoot, { recursive: true }); |
| 147 | + |
| 148 | + const sentinel = join(tempDir, ".cursor", "keep.txt"); |
| 149 | + await writeFile(sentinel, "sentinel"); |
| 150 | + |
| 151 | + const source = join(tempDir, "source"); |
| 152 | + await mkdir(source, { recursive: true }); |
| 153 | + |
| 154 | + await expect(symlinkSkill("..", source, skillsRoot)).rejects.toThrow(); |
| 155 | + |
| 156 | + expect(await exists(sentinel)).toBe(true); |
| 157 | + expect(await exists(join(tempDir, ".cursor"))).toBe(true); |
| 158 | + }); |
| 159 | +}); |
0 commit comments