import { execFileSync } from "node:child_process"; import { existsSync } from "node:fs"; import { chmod, readdir, readFile, rm, stat, symlink, unlink, utimes, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { createTempTree, findCrossDeviceBase, type TempTree } from "@umt/testkit"; import { afterEach, describe, expect, it } from "vitest"; import { checkRoot, copyWithVerify, crossDeviceMove, diskUsage, FsError, ffprobe, filesMatchSha256, moveSafe, resetSha256Probe, scanIncremental, sha256File, sha256Full, sha256Stats, statSafe, sweepStalePartials, } from "./index.ts"; const trees: TempTree[] = []; async function tree(...a: Parameters) { const t = await createTempTree(...a); trees.push(t); return t; } afterEach(async () => { await Promise.all(trees.splice(0).map((t) => t.cleanup())); }); const xdevBase = findCrossDeviceBase(); const isRoot = process.getuid?.() === 0; const hasFfmpeg = (() => { try { execFileSync("ffmpeg", ["-version"], { stdio: "ignore" }); return true; } catch { return false; } })(); describe("copyWithVerify", () => { it.each(["size", "sha256", "sample"] as const)("%s 校验后原子提交,保留 mtime,无残留临时文件", async (hash) => { const t = await tree({ "src/a.mkv": 3 * 1024 * 1024 + 17, "dst/": null }); const r = await copyWithVerify(t.path("src/a.mkv"), t.path("dst/a.mkv"), { hash, sampleChunkBytes: 4096 }); expect(r.bytes).toBe(3 * 1024 * 1024 + 17); expect(r.verifiedBy).toBe(hash); if (hash === "sha256") { expect(r.sha256).toMatch(/^[0-9a-f]{64}$/); expect(["sha256sum", "stream"]).toContain(r.sha256Via); } expect((await readFile(t.path("dst/a.mkv"))).equals(await readFile(t.path("src/a.mkv")))).toBe(true); expect(Math.round((await stat(t.path("dst/a.mkv"))).mtimeMs)).toBe( Math.round((await stat(t.path("src/a.mkv"))).mtimeMs), ); expect(await sweepStalePartials(t.path("dst"), -1)).toEqual([]); }); it("目标已存在 → EEXIST,不覆盖", async () => { const t = await tree({ "a.mkv": "new", "b.mkv": "old" }); const e = await copyWithVerify(t.path("a.mkv"), t.path("b.mkv")).catch((x) => x); expect(e).toBeInstanceOf(FsError); expect(e.code).toBe("EEXIST"); expect(await readFile(t.path("b.mkv"), "utf8")).toBe("old"); }); it("父目录不存在默认不创建;中止信号清理临时文件", async () => { const t = await tree({ "a.mkv": 1024 * 1024 }); expect((await copyWithVerify(t.path("a.mkv"), t.path("lib/x/a.mkv")).catch((e) => e)).code).toBe("ENOENT"); const ac = new AbortController(); ac.abort(); await t.write("lib/.keep", ""); const e = await copyWithVerify(t.path("a.mkv"), t.path("lib/a.mkv"), { signal: ac.signal }).catch((x) => x); expect(e.code).toBe("ABORTED"); expect(await sweepStalePartials(t.path("lib"), -1)).toEqual([]); await expect(stat(t.path("lib/a.mkv"))).rejects.toThrow(); }); it("残留临时文件清理只删过期的 .uhd-partial", async () => { const t = await tree({ "lib/.x.mkv.1.abc.uhd-partial": "x", "lib/keep.mkv": "k" }); const removed = await sweepStalePartials(t.path("lib"), -1); expect(removed).toEqual([t.path("lib/.x.mkv.1.abc.uhd-partial")]); expect(await sweepStalePartials(t.path("lib"), 60_000)).toEqual([]); }); }); describe("全量 sha256(子进程优先,回退进程内)", () => { const hasSha256sum = (() => { try { execFileSync("sha256sum", ["--version"], { stdio: "ignore" }); return true; } catch { return false; } })(); it("与进程内流式结果一致;记录所用方式与计数", async () => { resetSha256Probe(); const t = await tree({ "a.bin": 2 * 1024 * 1024 + 5, "b.bin": 2 * 1024 * 1024 + 5 }); const before = sha256Stats().counts; const r = await sha256Full(t.path("a.bin")); expect(r.digest).toBe(await sha256File(t.path("a.bin"))); expect(r.via).toBe(hasSha256sum ? "sha256sum" : "stream"); expect(sha256Stats().counts[r.via]).toBe(before[r.via] + 1); // 同尺寸不同内容:不一致 const m = await filesMatchSha256(t.path("a.bin"), t.path("b.bin")); expect(m.match).toBe(false); expect(m.via).toBe(r.via); }); it("文件不存在 → FsError", async () => { resetSha256Probe(); await expect(sha256Full("/nonexistent/umt-x.bin")).rejects.toBeInstanceOf(Error); }); }); describe("moveSafe / crossDeviceMove", () => { it("同设备移动;目标存在返回 exists 且不覆盖", async () => { const t = await tree({ "a.mkv": "A", "b.mkv": "B", "c.mkv": "C" }); expect(await moveSafe(t.path("a.mkv"), t.path("moved.mkv"))).toEqual({ status: "moved" }); await expect(stat(t.path("a.mkv"))).rejects.toThrow(); expect(await moveSafe(t.path("b.mkv"), t.path("c.mkv"))).toEqual({ status: "exists" }); expect(await readFile(t.path("c.mkv"), "utf8")).toBe("C"); const e = await moveSafe(t.path("nope.mkv"), t.path("x.mkv")).catch((x) => x); expect(e.code).toBe("ENOENT"); }); it("link 成功但 unlink 前中断(同 inode)→ 续跑补完删除", async () => { const t = await tree({ "a.mkv": "A" }); const { link } = await import("node:fs/promises"); await link(t.path("a.mkv"), t.path("b.mkv")); expect(await moveSafe(t.path("a.mkv"), t.path("b.mkv"))).toEqual({ status: "moved" }); await expect(stat(t.path("a.mkv"))).rejects.toThrow(); }); it.skipIf(!xdevBase)("跨设备:moveSafe 返回 exdev 不降级;crossDeviceMove 三步完成", async () => { // xdevBase 为 null 时跳过:本机找不到与 tmpdir 不同设备的可写目录 const a = await tree({ "a.mkv": 256 * 1024 }); const b = await tree({ "lib/": null }, { base: xdevBase! }); expect(await moveSafe(a.path("a.mkv"), b.path("lib/a.mkv"))).toEqual({ status: "exdev" }); await expect(stat(a.path("a.mkv"))).resolves.toBeTruthy(); const r = await crossDeviceMove(a.path("a.mkv"), b.path("lib/a.mkv"), { hash: "sha256" }); expect(r.status).toBe("completed"); expect(r.steps.map((s) => `${s.step}:${s.status}`)).toEqual(["copy:done", "verify:done", "delete_source:done"]); await expect(stat(a.path("a.mkv"))).rejects.toThrow(); }); it("crossDeviceMove 可重入:目标已一致则跳过复制;源已删且上次已验证 → completed;目标不同 → failed 不删源", async () => { const t = await tree({ "src/a.mkv": "same", "dst/a.mkv": "same", "src/b.mkv": "bbbb", "dst/b.mkv": "cccc" }); const r1 = await crossDeviceMove(t.path("src/a.mkv"), t.path("dst/a.mkv"), { hash: "sha256" }); expect(r1.steps.map((s) => `${s.step}:${s.status}`)).toEqual(["copy:skipped", "verify:done", "delete_source:done"]); const r2 = await crossDeviceMove(t.path("src/a.mkv"), t.path("dst/a.mkv"), { previous: r1.steps }); expect(r2.status).toBe("completed"); const r3 = await crossDeviceMove(t.path("src/b.mkv"), t.path("dst/b.mkv"), { hash: "sha256" }); expect(r3.status).toBe("failed"); expect(await readFile(t.path("src/b.mkv"), "utf8")).toBe("bbbb"); const r4 = await crossDeviceMove(t.path("src/none.mkv"), t.path("dst/b.mkv")); expect(r4.status).toBe("attention"); // 默认 hash=size 时,已存在的同尺寸异内容目标也必须判为不一致,不得删源 const r5 = await crossDeviceMove(t.path("src/b.mkv"), t.path("dst/b.mkv")); expect(r5.status).toBe("failed"); expect(await readFile(t.path("src/b.mkv"), "utf8")).toBe("bbbb"); }); it.skipIf(isRoot)("删源失败 → attention(以 root 运行时跳过:root 无视目录权限)", async () => { const t = await tree({ "src/a.mkv": "A", "dst/": null }); await chmod(t.path("src"), 0o555); try { const r = await crossDeviceMove(t.path("src/a.mkv"), t.path("dst/a.mkv")); expect(r.status).toBe("attention"); expect(r.steps.at(-1)).toMatchObject({ step: "delete_source", status: "failed" }); } finally { await chmod(t.path("src"), 0o755); } }); }); describe("statSafe / checkRoot / diskUsage", () => { it("区分正常、缺失、根离线", async () => { const t = await tree({ "movies/a.mkv": "A" }, { sentinel: true }); const ok = await statSafe(t.path("movies/a.mkv"), { root: t.root }); expect(ok.kind).toBe("ok"); if (ok.kind === "ok") expect(ok.facts.size).toBe(1); expect((await statSafe(t.path("movies/b.mkv"), { root: t.root })).kind).toBe("missing"); expect(await checkRoot(t.root)).toMatchObject({ kind: "online" }); await unlink(t.path(".uhd-root")); expect(await statSafe(t.path("movies/b.mkv"), { root: t.root })).toMatchObject({ kind: "root_offline", reason: "sentinel_missing", }); expect(await checkRoot(join(t.root, "gone"))).toMatchObject({ kind: "offline", reason: "root_missing" }); }); it.skipIf(isRoot)("权限拒绝 → permission_denied(root 运行时跳过)", async () => { const t = await tree({ "locked/a.mkv": "A" }); await chmod(t.path("locked"), 0o000); try { expect((await statSafe(t.path("locked/a.mkv"))).kind).toBe("permission_denied"); } finally { await chmod(t.path("locked"), 0o755); } }); it("磁盘用量:statfs 成功给出比例,路径不存在返回 unknown", async () => { const t = await tree(); const u = await diskUsage(t.root); expect(u.status).toBe("ok"); if (u.status === "ok") { expect(u.totalBytes).toBeGreaterThan(0); expect(u.usedRatio).toBeGreaterThanOrEqual(0); expect(u.usedRatio).toBeLessThanOrEqual(1); } expect(await diskUsage(join(t.root, "nope"))).toMatchObject({ status: "unknown", code: "ENOENT" }); }); }); describe("scanIncremental", () => { it("按 I/O 上限分批,游标续扫覆盖全部且不重复", async () => { const spec: Record = {}; for (const d of ["A", "B", "C"]) for (let i = 0; i < 4; i++) spec[`${d}/s${i}/f${i}.mkv`] = `${d}${i}`; spec["B/.x.uhd-partial"] = "p"; const t = await tree(spec, { sentinel: true }); const seen: string[] = []; let cursor: string | null = null; let batches = 0; do { const r = await scanIncremental(t.root, { cursor, maxStats: 5 }); expect(r.statCount).toBeLessThanOrEqual(5); seen.push(...r.entries.map((e) => e.relPath)); cursor = r.cursor; batches++; if (r.status === "complete") break; expect(r.status).toBe("partial"); } while (batches < 10); expect(batches).toBe(3); expect(seen).toEqual([...seen].sort()); expect(new Set(seen).size).toBe(12); expect(seen).not.toContain("B/.x.uhd-partial"); }); it("标注 new/changed/unchanged;根离线不扫描;符号链接不跟随", async () => { const t = await tree({ "a.mkv": "A", "b.mkv": "B", "c.mkv": "C" }, { sentinel: true }); await symlink(t.path("a.mkv"), t.path("d.mkv")); const first = await scanIncremental(t.root); const prev = new Map(first.entries.map((e) => [e.relPath, e])); await writeFile(t.path("b.mkv"), "BBBB"); const second = await scanIncremental(t.root, { previous: (p) => prev.get(p) }); const byPath = Object.fromEntries(second.entries.map((e) => [e.relPath, e])); expect(byPath["a.mkv"]!.change).toBe("unchanged"); expect(byPath["b.mkv"]!.change).toBe("changed"); expect(byPath["d.mkv"]!.isSymlink).toBe(true); await rm(t.path(".uhd-root")); const off = await scanIncremental(t.root); expect(off.status).toBe("root_offline"); expect(off.entries).toEqual([]); }); }); describe("ffprobe", () => { it("可执行文件不存在 / 非媒体文件 → unknown,不抛", async () => { const t = await tree({ "a.txt": "hello" }); expect(await ffprobe(t.path("a.txt"), { bin: "/nonexistent/ffprobe" })).toEqual({ status: "unknown", reason: "ffprobe_unavailable", }); const r = await ffprobe(t.path("a.txt")); expect(r.status).toBe("unknown"); }); it.skipIf(!hasFfmpeg)("真实小样片:读出分辨率与音轨(本机无 ffmpeg 时跳过)", async () => { const t = await tree(); const out = t.path("s.mkv"); execFileSync("ffmpeg", [ "-v", "error", "-f", "lavfi", "-i", "testsrc=size=320x240:rate=5", "-f", "lavfi", "-i", "sine=frequency=440", "-t", "1", "-c:v", "mpeg4", "-c:a", "ac3", "-metadata:s:a:0", "language=jpn", out, ]); const r = await ffprobe(out); expect(r.status).toBe("ok"); if (r.status === "ok") { expect(r.video[0]).toMatchObject({ codec: "mpeg4", width: 320, height: 240 }); expect(r.video[0]!.hdr).toBeUndefined(); expect(r.audio[0]).toMatchObject({ codec: "ac3", language: "jpn" }); } }); }); describe("copyWithVerify:onExisting / 写前清 partial / 比对异常不冒充 EEXIST", () => { it("replace:新文件写临时→校验→原子替换,旧文件移入回收目录(不裸覆盖)", async () => { const t = await tree({ "src/a.mkv": "NEW-CONTENT", "lib/a.mkv": "OLD" }); const r = await copyWithVerify(t.path("src/a.mkv"), t.path("lib/a.mkv"), { hash: "sha256", onExisting: "replace", replaceOld: { mode: "trash", trashDir: t.path("lib/.umt-trash") }, }); expect(r.outcome).toBe("committed"); expect(await readFile(t.path("lib/a.mkv"), "utf8")).toBe("NEW-CONTENT"); expect(r.replaced?.disposition).toBe("trash"); expect(r.replaced?.size).toBe(3); expect(await readFile(r.replaced!.path!, "utf8")).toBe("OLD"); expect(r.replaced!.path!.startsWith(t.path("lib/.umt-trash/replaced-"))).toBe(true); // 目标目录只剩新文件与回收目录,没有留底/临时残留 expect((await readdir(t.path("lib"))).sort()).toEqual([".umt-trash", "a.mkv"]); }); it("replace + delete:旧文件删除;replace 不替换目录/软链;skip 不写;conflict 仍 EEXIST", async () => { const t = await tree({ "src/a.mkv": "NEW", "lib/a.mkv": "OLD", "lib/d.mkv/": null, "lib/b.mkv": "KEEP" }); const r = await copyWithVerify(t.path("src/a.mkv"), t.path("lib/a.mkv"), { onExisting: "replace", replaceOld: { mode: "delete" }, }); expect(r.replaced).toMatchObject({ disposition: "deleted", size: 3 }); expect(await readFile(t.path("lib/a.mkv"), "utf8")).toBe("NEW"); expect((await readdir(t.path("lib"))).sort()).toEqual(["a.mkv", "b.mkv", "d.mkv"]); const dir = await copyWithVerify(t.path("src/a.mkv"), t.path("lib/d.mkv"), { onExisting: "replace" }).catch( (e) => e, ); expect(dir.code).toBe("EEXIST"); await symlink(t.path("nowhere"), t.path("lib/s.mkv")); const sl = await copyWithVerify(t.path("src/a.mkv"), t.path("lib/s.mkv"), { onExisting: "replace" }).catch( (e) => e, ); expect(sl.code).toBe("EEXIST"); const sk = await copyWithVerify(t.path("src/a.mkv"), t.path("lib/b.mkv"), { onExisting: "skip" }); expect(sk.outcome).toBe("skipped_existing"); expect(await readFile(t.path("lib/b.mkv"), "utf8")).toBe("KEEP"); const cf = await copyWithVerify(t.path("src/a.mkv"), t.path("lib/b.mkv")).catch((e) => e); expect(cf.code).toBe("EEXIST"); }); it("写前清理本目录 >24h 的残留 partial,新鲜的保留;可关闭", async () => { const t = await tree({ "src/a.mkv": 1024, "lib/": null }); const stale = await t.write("lib/.x.mkv.1.abc.uhd-partial", "half"); const fresh = await t.write("lib/.y.mkv.1.def.uhd-partial", "half"); const old = new Date(Date.now() - 25 * 3600_000); await utimes(stale, old, old); const r = await copyWithVerify(t.path("src/a.mkv"), t.path("lib/a.mkv")); expect(r.sweptPartials).toEqual([stale]); expect(existsSync(stale)).toBe(false); expect(existsSync(fresh)).toBe(true); await utimes(fresh, old, old); const r2 = await copyWithVerify(t.path("src/a.mkv"), t.path("lib/b.mkv"), { sweepPartialsOlderThanMs: null }); expect(r2.sweptPartials).toBeUndefined(); expect(existsSync(fresh)).toBe(true); }); it("filesMatchSha256 读失败抛 FsError(不返回 match=false,调用方不会误报 EEXIST)", async () => { const t = await tree({ "a.mkv": 4096 }); const e = await filesMatchSha256(t.path("a.mkv"), t.path("gone.mkv")).catch((x) => x); expect(e).toBeInstanceOf(FsError); expect(e.code).toBe("ENOENT"); }); it.skipIf(isRoot)("filesMatchSha256 无读权限 → EACCES/IO,不是 EEXIST", async () => { const t = await tree({ "a.mkv": 4096, "b.mkv": 4096 }); await chmod(t.path("b.mkv"), 0o000); const e = await filesMatchSha256(t.path("a.mkv"), t.path("b.mkv")).catch((x) => x); await chmod(t.path("b.mkv"), 0o644); expect(e).toBeInstanceOf(FsError); expect(["EACCES", "IO"]).toContain(e.code); }); });