import { createHash, randomUUID } from "node:crypto"; import { lstat, mkdir, mkdtemp, readFile, readlink, rm, symlink, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; import { loadConfig, prepareDataDirectories, type AppConfig } from "../server/config.js"; import { openDatabase } from "../server/db/index.js"; import { main } from "../server/cli/update.js"; import { createSafeArchive, detectPlatform } from "../server/update.js"; /** * Link-level coverage for the two-process update hand-off: * the unprivileged web process stages a verified payload into * `/staging/update-`, then the privileged CLI (`main`) picks it * up from a staged/apply DB row and switches the release. * * Ownership expectations are injected through `UpdateMainOverrides` because the * suite runs as a non-root developer on macOS. Production defaults stay * untouched: they never consult `process.getuid()`. */ const CURRENT_UID = process.getuid?.() ?? 0; const NEW_VERSION = "9.9.9"; const METADATA_URL = "https://updates.example/latest"; const TRACKED_ENV = [ "TALLYNOTE_DATA_DIR", "TALLYNOTE_INSTALL_PREFIX", "TALLYNOTE_PUBLIC_ORIGIN", "TALLYNOTE_COOKIE_SECURE", "TALLYNOTE_UPDATE_STRATEGY", "TALLYNOTE_UPDATE_METADATA_URL", "TALLYNOTE_UPDATE_ALLOWED_HOSTS", "TALLYNOTE_UPDATE_REQUIRE_SIGNATURE", ] as const; const baselineEnv = new Map(TRACKED_ENV.map((key) => [key, process.env[key]])); const baselineArgv = [...process.argv]; afterEach(() => { for (const key of TRACKED_ENV) { const value = baselineEnv.get(key); if (value === undefined) delete process.env[key]; else process.env[key] = value; } process.argv.splice(0, process.argv.length, ...baselineArgv); }); type ApplyFixture = { root: string; config: AppConfig; jobId: string; stagedDir: string; digest: string; assetName: string; }; type FixtureOptions = { /** Shape of `/update-`: a real staged tree, a symlink * masquerading as one, or nothing at all. */ stagedWorkspace?: "directory" | "symlink" | "absent"; /** Whether the staged archive that `assertStagedArchiveIntegrity` hashes. */ withArchive?: boolean; /** Value written to `update_jobs.download_path`. The runner NULLs this column * when it releases a workspace, so `null` is the post-runner production state. */ downloadPath?: "null" | "stale" | "outside-staging-root"; /** Whether the staged/apply row exists at all. */ withDatabaseRow?: boolean; }; /** Build the exact on-disk state the web download step leaves behind before a * privileged apply runs: release layout, staged workspace, staged/apply row and * the request file the CLI is invoked with. */ async function setupApplyFixture(options: FixtureOptions = {}): Promise { const root = await mkdtemp(path.join(tmpdir(), "tallynote-apply-staging-")); const dataDir = path.join(root, "data"); const installPrefix = path.join(root, "install"); process.env.TALLYNOTE_DATA_DIR = dataDir; process.env.TALLYNOTE_INSTALL_PREFIX = installPrefix; process.env.TALLYNOTE_PUBLIC_ORIGIN = "http://127.0.0.1:3998"; process.env.TALLYNOTE_COOKIE_SECURE = "false"; process.env.TALLYNOTE_UPDATE_STRATEGY = "systemd"; process.env.TALLYNOTE_UPDATE_METADATA_URL = METADATA_URL; process.env.TALLYNOTE_UPDATE_ALLOWED_HOSTS = "updates.example"; process.env.TALLYNOTE_UPDATE_REQUIRE_SIGNATURE = "false"; const config = loadConfig(); prepareDataDirectories(config); // Installer layout with a live current release so `atomicSwitchRelease` has a // real previous target to report. await mkdir(config.releasesDir, { recursive: true, mode: 0o755 }); const previousRelease = path.join(config.releasesDir, config.appVersion); await mkdir(path.join(previousRelease, "dist"), { recursive: true, mode: 0o755 }); await writeFile(path.join(previousRelease, "dist", "marker"), "old"); await symlink(previousRelease, config.currentLink); const assetName = `tallynote-${NEW_VERSION}-${detectPlatform().target}.tar.gz`; const source = path.join(root, "release-source"); await mkdir(path.join(source, "dist"), { recursive: true, mode: 0o700 }); await writeFile(path.join(source, "dist", "marker"), "new"); const archive = path.join(root, "release.tar.gz"); await createSafeArchive(source, archive); const bytes = await readFile(archive); const digest = createHash("sha256").update(bytes).digest("hex"); const jobId = randomUUID(); const stagedDir = path.join(config.stagingDir, `update-${jobId}`); const stagedWorkspace = options.stagedWorkspace ?? "directory"; if (stagedWorkspace === "directory") { await mkdir(path.join(stagedDir, "payload", "dist"), { recursive: true, mode: 0o700 }); await writeFile(path.join(stagedDir, "payload", "dist", "marker"), "new"); if (options.withArchive !== false) await writeFile(path.join(stagedDir, "release.tar.gz"), bytes, { mode: 0o600 }); } else if (stagedWorkspace === "symlink") { // A symlinked workspace is the classic "swap the staged tree after the web // process verified it" attack, and must never be followed by root. const decoy = path.join(root, "decoy-workspace"); await mkdir(path.join(decoy, "payload", "dist"), { recursive: true, mode: 0o700 }); await writeFile(path.join(decoy, "payload", "dist", "marker"), "attacker"); await symlink(decoy, stagedDir); } let recordedDownloadPath: string | null = null; if (options.downloadPath === "stale") recordedDownloadPath = path.join(root, "stale-workspace"); if (options.downloadPath === "outside-staging-root") { recordedDownloadPath = path.join(root, "outside-workspace"); await mkdir(path.join(recordedDownloadPath, "payload", "dist"), { recursive: true, mode: 0o700 }); } if (options.withDatabaseRow !== false) { const database = openDatabase(config); try { const now = Date.now(); database.sqlite.prepare(` INSERT INTO update_jobs(id, operation, status, version, platform, asset_name, asset_url, expected_sha256, download_path, created_at, updated_at, requested_at) VALUES (?, 'apply', 'staged', ?, ?, ?, ?, ?, ?, ?, ?, ?) `).run(jobId, NEW_VERSION, detectPlatform().target, assetName, `https://updates.example/${assetName}`, digest, recordedDownloadPath, now, now, now); } finally { database.sqlite.close(); } } await writeFile(config.updateRequestPath, JSON.stringify({ jobId, operation: "apply", version: NEW_VERSION, metadataUrl: config.updateMetadataUrl, assetUrl: `https://updates.example/${assetName}`, assetName, expectedSha256: digest, requestedAt: Date.now(), currentLink: config.currentLink, releasesDir: config.releasesDir, dataDir: config.dataDir, }), { mode: 0o600 }); return { root, config, jobId, stagedDir, digest, assetName }; } /** Invoke the privileged entry point the way the runner does: through the * request file, which is the only path that reaches the staged apply branch. */ async function runMain(fixture: ApplyFixture, overrides: { stagingOwnerUid?: number; workspaceOwnerUid?: number } = {}): Promise { process.argv.push("--request-file", fixture.config.updateRequestPath); await main(fixture.config, { stagingOwnerUid: overrides.stagingOwnerUid ?? CURRENT_UID, workspaceOwnerUid: overrides.workspaceOwnerUid ?? CURRENT_UID, }); } function readJob(config: AppConfig, jobId: string): { status: string; operation: string; errorMessage: string | null; downloadPath: string | null } | undefined { const database = openDatabase(config); try { return database.sqlite.prepare("SELECT status, operation, error_message AS errorMessage, download_path AS downloadPath FROM update_jobs WHERE id=?").get(jobId) as { status: string; operation: string; errorMessage: string | null; downloadPath: string | null } | undefined; } finally { database.sqlite.close(); } } /** The audit row written by `failUpdateJobWithReason`, which carries the real * machine-readable reason the UI renders instead of the runner's health text. */ function readFailureAudit(config: AppConfig, jobId: string): { action: string; outcome: string; afterJson: string } | undefined { const database = openDatabase(config); try { return database.sqlite.prepare("SELECT action, outcome, after_json AS afterJson FROM audit_events WHERE target_id=? ORDER BY id DESC LIMIT 1").get(jobId) as { action: string; outcome: string; afterJson: string } | undefined; } finally { database.sqlite.close(); } } describe("web 暂存 → CLI apply 链路", () => { it("场景 1:staged 行 + 暂存工作区存在时切换 current 到新 release", async () => { const fixture = await setupApplyFixture(); try { await runMain(fixture); const link = await lstat(fixture.config.currentLink); expect(link.isSymbolicLink()).toBe(true); expect(await readlink(fixture.config.currentLink)).toBe(path.join(fixture.config.releasesDir, NEW_VERSION)); expect((await lstat(path.join(fixture.config.releasesDir, NEW_VERSION))).isDirectory()).toBe(true); expect(await readFile(path.join(fixture.config.releasesDir, NEW_VERSION, "dist", "marker"), "utf8")).toBe("new"); // The web-owned staging tree is consumed and the row leaves the staged state. expect(await lstat(fixture.stagedDir).catch(() => null)).toBeNull(); expect(readJob(fixture.config, fixture.jobId)).toMatchObject({ status: "applying", operation: "apply" }); } finally { await rm(fixture.root, { recursive: true, force: true }); } }); it("场景 2:download_path 为 NULL 时仍按 jobId 重建暂存工作区", async () => { const fixture = await setupApplyFixture({ downloadPath: "null" }); try { // Precondition: the runner already cleared the transient column. expect(readJob(fixture.config, fixture.jobId)?.downloadPath).toBeNull(); await runMain(fixture); expect(await readlink(fixture.config.currentLink)).toBe(path.join(fixture.config.releasesDir, NEW_VERSION)); expect(await readFile(path.join(fixture.config.releasesDir, NEW_VERSION, "dist", "marker"), "utf8")).toBe("new"); } finally { await rm(fixture.root, { recursive: true, force: true }); } }); it("场景 2b:download_path 指向已消失的陈旧路径时仍回退到 jobId 候选", async () => { const fixture = await setupApplyFixture({ downloadPath: "stale" }); try { expect(readJob(fixture.config, fixture.jobId)?.downloadPath).toBe(path.join(fixture.root, "stale-workspace")); await runMain(fixture); expect(await readlink(fixture.config.currentLink)).toBe(path.join(fixture.config.releasesDir, NEW_VERSION)); } finally { await rm(fixture.root, { recursive: true, force: true }); } }); it("场景 3:候选暂存目录不存在时拒绝并把行置为 failed", async () => { const fixture = await setupApplyFixture({ stagedWorkspace: "absent" }); try { await expect(runMain(fixture)).rejects.toThrow(/暂存目录已不存在/); // No release may be published from a workspace that was never staged. expect(await readlink(fixture.config.currentLink)).toBe(path.join(fixture.config.releasesDir, fixture.config.appVersion)); expect(await lstat(path.join(fixture.config.releasesDir, NEW_VERSION)).catch(() => null)).toBeNull(); const job = readJob(fixture.config, fixture.jobId); expect(job?.status).toBe("failed"); expect(job?.errorMessage).toBe("暂存目录已不存在,请重新下载"); expect(readFailureAudit(fixture.config, fixture.jobId)).toMatchObject({ action: "update.failed", outcome: "failure" }); expect(JSON.parse(readFailureAudit(fixture.config, fixture.jobId)!.afterJson)).toMatchObject({ reason: "staged_workspace_missing" }); } finally { await rm(fixture.root, { recursive: true, force: true }); } }); it("场景 4a:暂存工作区是符号链接时拒绝执行", async () => { const fixture = await setupApplyFixture({ stagedWorkspace: "symlink" }); try { await expect(runMain(fixture)).rejects.toThrow(/更新暂存目录权限无效/); // The decoy payload must never be promoted to a release. expect(await readlink(fixture.config.currentLink)).toBe(path.join(fixture.config.releasesDir, fixture.config.appVersion)); expect(await lstat(path.join(fixture.config.releasesDir, NEW_VERSION)).catch(() => null)).toBeNull(); expect(readJob(fixture.config, fixture.jobId)?.status).toBe("failed"); expect(readJob(fixture.config, fixture.jobId)?.errorMessage).toBe("更新暂存目录权限无效"); expect(JSON.parse(readFailureAudit(fixture.config, fixture.jobId)!.afterJson)).toMatchObject({ reason: "staged_workspace_insecure" }); } finally { await rm(fixture.root, { recursive: true, force: true }); } }); it("场景 4b:记录路径位于 stagingDir 之外时拒绝,即使暂存目录缺失", async () => { const fixture = await setupApplyFixture({ stagedWorkspace: "absent", downloadPath: "outside-staging-root" }); try { await expect(runMain(fixture)).rejects.toThrow(/更新暂存路径无效/); expect(await lstat(path.join(fixture.config.releasesDir, NEW_VERSION)).catch(() => null)).toBeNull(); expect(readJob(fixture.config, fixture.jobId)?.status).toBe("failed"); expect(JSON.parse(readFailureAudit(fixture.config, fixture.jobId)!.afterJson)).toMatchObject({ reason: "staged_workspace_invalid" }); } finally { await rm(fixture.root, { recursive: true, force: true }); } }); it("场景 5:暂存区属主与期望 uid 不符时拒绝", async () => { const fixture = await setupApplyFixture(); try { await expect(runMain(fixture, { stagingOwnerUid: CURRENT_UID + 1 })).rejects.toThrow(/更新暂存根目录权限无效/); expect(await readlink(fixture.config.currentLink)).toBe(path.join(fixture.config.releasesDir, fixture.config.appVersion)); const job = readJob(fixture.config, fixture.jobId); expect(job?.status).toBe("failed"); expect(job?.errorMessage).toBe("更新暂存根目录权限无效"); expect(JSON.parse(readFailureAudit(fixture.config, fixture.jobId)!.afterJson)).toMatchObject({ reason: "apply_precheck_failed" }); } finally { await rm(fixture.root, { recursive: true, force: true }); } }); it("场景 5b:工作区属主与期望 uid 不符时在 preflight 阶段拒绝", async () => { const fixture = await setupApplyFixture(); try { await expect(runMain(fixture, { workspaceOwnerUid: CURRENT_UID + 1 })).rejects.toThrow(/更新工作目录必须是 root 拥有且权限为 0700/); expect(await lstat(path.join(fixture.config.releasesDir, NEW_VERSION)).catch(() => null)).toBeNull(); expect(await readlink(fixture.config.currentLink)).toBe(path.join(fixture.config.releasesDir, fixture.config.appVersion)); // The preflight rejection happens before the database is opened, so the // row is left staged for the runner to finalize. Recorded as observed // behavior, not asserted as a requirement. expect(readJob(fixture.config, fixture.jobId)?.status).toBe("staged"); } finally { await rm(fixture.root, { recursive: true, force: true }); } }); });