import assert from "node:assert/strict"; import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { after, test } from "node:test"; import { assertNoLinksBelow, backup, removeFileAndPrune, sha256, writeAtomic } from "../src/fsx.ts"; import { applyPlan, envForRecord, planForRecord, planInstall, removeInstall } from "../src/installer.ts"; import { readManifest, writeManifest } from "../src/manifest.ts"; import type { InstallRecord, SkillSource } from "../src/types.ts"; // 보안 감사(2026-10-01) 재현 시나리오를 회귀 테스트로 고정한다. 모든 쓰기는 임시 디렉터리 안에서만 한다. const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "designpaca-safety-")); after(async () => { await fs.rm(tmp, { recursive: true, force: true }); }); const skill: SkillSource = { version: "9.9.9", skillMd: ["---", "name: designpaca", 'description: "테스트용"', "---", "", "# 본문", ""].join("\n"), files: new Map([["references/tokens.md", "# 토큰\n"]]), body: "# 본문", description: "테스트용", }; async function fresh(name: string): Promise<{ home: string; cwd: string; outside: string }> { const dir = path.join(tmp, name); const home = path.join(dir, "home"); const cwd = path.join(dir, "proj"); const outside = path.join(dir, "outside"); for (const d of [home, cwd, outside]) await fs.mkdir(d, { recursive: true }); return { home, cwd, outside }; } async function listAll(dir: string): Promise { try { return (await fs.readdir(dir, { recursive: true })).map(String).sort(); } catch { return []; } } /** 디렉터리 링크 — Windows 는 관리자 권한 없이 만들 수 있는 정션을 쓴다 */ async function linkDir(target: string, link: string): Promise { await fs.symlink(target, link, process.platform === "win32" ? "junction" : "dir"); } /** 파일 심링크는 Windows 에서 권한이 없으면 만들 수 없다 — 그때는 false (해당 검증만 생략) */ async function tryLinkFile(target: string, link: string): Promise { try { await fs.symlink(target, link, "file"); return true; } catch { return false; } } test("writeAtomic: 임시 파일을 남기지 않고, 예측 가능한

..tmp 를 쓰지 않는다", async () => { const { outside, cwd } = await fresh("atomic"); const victim = path.join(outside, "bashrc"); await fs.writeFile(victim, "원래 내용\n", "utf8"); const p = path.join(cwd, "AGENTS.md"); // 예전 구현이 쓰던 이름에 링크를 심어둬도 따라가지 않는다 await tryLinkFile(victim, `${p}.${process.pid}.tmp`); await writeAtomic(p, "새 내용\n"); assert.equal(await fs.readFile(p, "utf8"), "새 내용\n"); assert.equal(await fs.readFile(victim, "utf8"), "원래 내용\n"); const leftovers = (await fs.readdir(cwd)).filter((n) => n.endsWith(".tmp") && !n.includes(String(process.pid))); assert.deepEqual(leftovers, []); }); test("backup: 기존 .orig 를 덮지 않고 .orig.1 로 회전한다", async () => { const { cwd } = await fresh("backup"); const p = path.join(cwd, "SKILL.md"); await fs.writeFile(p, "첫 수정\n", "utf8"); assert.equal(await backup(p), `${p}.orig`); await fs.writeFile(p, "두 번째 수정\n", "utf8"); assert.equal(await backup(p), `${p}.orig.1`); assert.equal(await fs.readFile(`${p}.orig`, "utf8"), "첫 수정\n"); assert.equal(await fs.readFile(`${p}.orig.1`, "utf8"), "두 번째 수정\n"); }); test("removeFileAndPrune: 기준 밖 파일은 지우지 않는다 (접두사가 같은 형제 디렉터리 포함)", async () => { const { cwd } = await fresh("prune"); const root = path.join(cwd, "a", "b"); const sibling = path.join(cwd, "a", "bc"); await fs.mkdir(root, { recursive: true }); await fs.mkdir(sibling, { recursive: true }); const victim = path.join(sibling, "important.txt"); await fs.writeFile(victim, "x", "utf8"); await assert.rejects(() => removeFileAndPrune(victim, root)); await fs.access(victim); }); test("assertNoLinksBelow: 기준 아래의 정션·심링크를 거부한다", async () => { const { cwd, outside } = await fresh("links"); await linkDir(outside, path.join(cwd, ".claude")); await assert.rejects(() => assertNoLinksBelow(cwd, path.join(cwd, ".claude", "skills", "x", "SKILL.md"))); await assert.rejects(() => assertNoLinksBelow(cwd, path.join(cwd, "..", "escape.md"))); await assertNoLinksBelow(cwd, path.join(cwd, ".cursor", "skills", "x", "SKILL.md")); }); test("project 설치는 정션으로 연결된 .claude 를 따라 저장소 밖에 쓰지 않는다", async () => { const { home, cwd, outside } = await fresh("junction-install"); await linkDir(outside, path.join(cwd, ".claude")); const plan = await planInstall("claude-code", "project", skill, { home, cwd }); await assert.rejects(() => applyPlan(plan, skill.version, { env: { home } }), /링크/); assert.deepEqual(await listAll(outside), []); assert.equal((await readManifest(home)).installs.length, 0); }); test("심링크인 AGENTS.md 에는 주입하지 않는다 (링크 대상 내용을 저장소로 끌어오지 않는다)", async () => { const { home, cwd, outside } = await fresh("symlink-agents"); const secret = path.join(outside, "secret.env"); await fs.writeFile(secret, "SECRET=abc\n", "utf8"); if (!(await tryLinkFile(secret, path.join(cwd, "AGENTS.md")))) return; // 파일 심링크 권한 없음 const plan = await planInstall("agents-md", "project", skill, { home, cwd }); await assert.rejects(() => applyPlan(plan, skill.version, { env: { home } }), /링크/); assert.equal(await fs.readFile(secret, "utf8"), "SECRET=abc\n"); assert.ok((await fs.lstat(path.join(cwd, "AGENTS.md"))).isSymbolicLink()); // 계획 전체를 먼저 검사하므로 .designpaca/ 도 만들어지지 않는다 await assert.rejects(() => fs.access(path.join(cwd, ".designpaca"))); }); test("설치 기록 없이 이미 있는 다른 내용의 파일은 덮기 전에 .orig 로 남긴다", async () => { const { home, cwd } = await fresh("no-record"); const root = path.join(cwd, ".claude", "skills", "designpaca"); await fs.mkdir(root, { recursive: true }); await fs.writeFile(path.join(root, "SKILL.md"), "user own skill\n", "utf8"); const plan = await planInstall("claude-code", "project", skill, { home, cwd }); const res = await applyPlan(plan, skill.version, { env: { home } }); assert.deepEqual(res.backedUp, [path.join(root, "SKILL.md.orig")]); assert.equal(await fs.readFile(path.join(root, "SKILL.md.orig"), "utf8"), "user own skill\n"); await removeInstall(res.record, { env: { home } }); }); test("envForRecord: 기록 root 에서 프로젝트·홈을 역산하고, 어댑터가 만들 수 없는 root 는 거부한다", async () => { const { home, cwd } = await fresh("env-record"); const plan = await planInstall("agents-md", "project", skill, { home, cwd }); const rec: InstallRecord = { target: "agents-md", scope: "project", root: plan.root, version: "0.0.1", installedAt: "", files: [], }; assert.deepEqual(await envForRecord(rec), { cwd }); const userPlan = await planInstall("claude-code", "user", skill, { home, cwd }); assert.deepEqual(await envForRecord({ ...rec, target: "claude-code", scope: "user", root: userPlan.root }), { home, }); await assert.rejects(() => envForRecord({ ...rec, root: path.join(cwd, "somewhere") })); const blocked = await planForRecord({ ...rec, root: path.join(cwd, "somewhere") }, skill); assert.ok(blocked.blocked); // 기록 root 그대로 다시 계획된다 — cwd 가 어디든 상관없다 const again = await planForRecord(rec, skill); assert.equal(again.root, plan.root); }); test("변조된 매니페스트로 uninstall 이 root 밖 파일을 지우지 않는다", async () => { const { home, cwd, outside } = await fresh("tampered"); const victim = path.join(outside, "sub", "important.txt"); await fs.mkdir(path.dirname(victim), { recursive: true }); await fs.writeFile(victim, "중요\n", "utf8"); // (1) 어댑터가 만들 수 없는 root const bogus: InstallRecord = { target: "claude-code", scope: "project", root: outside, version: "0.0.1", installedAt: "", files: [{ path: victim, sha256: sha256("중요\n") }], }; // (2) 정상 root 지만 파일 경로가 root 밖 / 주입 문서가 아닌 마커 파일 const plan = await planInstall("claude-code", "project", skill, { home, cwd }); const sneaky: InstallRecord = { ...bogus, root: plan.root, files: [ { path: victim, sha256: sha256("중요\n") }, { path: path.join(outside, "x.md"), sha256: "", marker: "designpaca" }, ], }; await writeManifest({ schema: 1, installs: [bogus, sneaky] }, home); for (const rec of [bogus, sneaky]) { const res = await removeInstall(rec, { env: { home }, force: true }); assert.equal(res.removed.length, 0); assert.equal(res.refused.length, rec.files.length); } await fs.access(victim); // 거부한 파일은 기록에 남긴다(조용히 사라지지 않는다) assert.equal((await readManifest(home)).installs.length, 2); }); test("손상된 매니페스트는 덮기 전에 .corrupt-* 로 보존한다", async () => { const { home } = await fresh("corrupt"); const file = path.join(home, ".designpaca", "manifest.json"); await fs.mkdir(path.dirname(file), { recursive: true }); await fs.writeFile(file, "{ 깨진 json", "utf8"); assert.equal((await readManifest(home)).installs.length, 0); await writeManifest({ schema: 1, installs: [] }, home); const kept = (await fs.readdir(path.dirname(file))).filter((n) => n.startsWith("manifest.json.corrupt-")); assert.equal(kept.length, 1); });