import { afterEach, describe, expect, it } from "vitest"; import { mkdir, readlink, symlink, writeFile, readFile, stat, readdir, utimes } from "node:fs/promises"; import { mkdtemp, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; import { createHash, generateKeyPairSync, randomUUID, sign } from "node:crypto"; import { atomicSwitchRelease, applicationUpdateRuntimeHash, createSafeArchive, detectPlatform, downloadReleaseAsset, fetchReleaseMetadata, fetchReleaseText, extractSafeArchive, isNewerVersion, normalizeReleasePermissions, sanitizeAssetName, selectReleaseAsset, runtimeHashFromLockfile, validateHttpsUrl, } from "../server/update.js"; import { finalizeUpdateJob, runUpdate } from "../server/cli/update.js"; import { validateUpdateRequest } from "../server/cli/update.js"; import { checkForUpdate, ORPHANED_UPDATE_TIMEOUT_MS, reconcileOrphanedUpdateJobs, verifyReleaseSignature } from "../server/update-service.js"; import { loadConfig, prepareDataDirectories } from "../server/config.js"; import { openDatabase } from "../server/db/index.js"; const envKeys = ["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", "TALLYNOTE_UPDATE_PUBLIC_KEY"]; const originalFetch = globalThis.fetch; afterEach(() => { globalThis.fetch = originalFetch; for (const key of envKeys) delete process.env[key]; }); describe("更新安全工具", () => { it("严格比较 SemVer、平台和 HTTPS 白名单", () => { expect(isNewerVersion("1.0.0", "1.1.0")).toBe(true); expect(isNewerVersion("1.0.0", "1.0.0-beta.1")).toBe(false); expect(detectPlatform("linux", "x86_64").target).toBe("linux-x64"); const release = { version: "1.3.0", assets: [ { name: "tallynote-1.3.0-linux-arm64-glibc.tar.gz", url: "https://updates.example/arm" }, { name: "tallynote-1.3.0-linux-x64-glibc.tar.gz", url: "https://updates.example/x64" }, ], }; expect(selectReleaseAsset(release, detectPlatform("linux", "x86_64"))?.name).toContain("linux-x64"); expect(selectReleaseAsset({ version: "1.3.0", assets: [{ name: "tallynote-1.3.0-linux-arm64-glibc.tar.gz", url: "https://updates.example/arm" }] }, detectPlatform("linux", "x86_64"))).toBeUndefined(); expect(() => validateHttpsUrl("http://updates.example/x64", { allowedHosts: ["updates.example"] })).toThrow(); expect(() => sanitizeAssetName("../release.tar.gz")).toThrow(); }); it("优先选择运行时匹配的轻量更新包,并对旧客户端保留完整包回退", () => { const runtimeHash = runtimeHashFromLockfile("lockfile-v1\n"); const full = { name: "tallynote-1.3.0-linux-x64-glibc.tar.gz", url: "https://updates.example/full" }; const app = { name: `tallynote-1.3.0-linux-x64-glibc.update-${runtimeHash}.tar.gz`, url: "https://updates.example/app" }; const release = { version: "1.3.0", assets: [full, app] }; expect(applicationUpdateRuntimeHash(app.name)).toBe(runtimeHash); expect(selectReleaseAsset(release, detectPlatform("linux", "x86_64"), runtimeHash)).toEqual(app); expect(selectReleaseAsset(release, detectPlatform("linux", "x86_64"))).toEqual(full); expect(applicationUpdateRuntimeHash(full.name)).toBeUndefined(); }); it("验证 SHA256SUMS 的 Ed25519 detached signature", () => { const { publicKey, privateKey } = generateKeyPairSync("ed25519"); const payload = "a".repeat(64) + " tallynote.tar.gz\n"; const signature = sign(null, Buffer.from(payload), privateKey).toString("base64"); const pem = publicKey.export({ type: "spki", format: "pem" }).toString(); expect(verifyReleaseSignature(payload, signature, pem)).toBe(true); expect(verifyReleaseSignature(payload, sign(null, Buffer.from(payload), privateKey), pem)).toBe(true); expect(verifyReleaseSignature(payload + "tampered", signature, pem)).toBe(false); }); it("拒绝把队列文件重定向到另一更新源", () => { process.env.TALLYNOTE_DATA_DIR = "/tmp/tallynote-request-test"; process.env.TALLYNOTE_PUBLIC_ORIGIN = "http://127.0.0.1:3997"; process.env.TALLYNOTE_COOKIE_SECURE = "false"; process.env.TALLYNOTE_UPDATE_STRATEGY = "systemd"; process.env.TALLYNOTE_UPDATE_METADATA_URL = "https://updates.example/latest"; process.env.TALLYNOTE_UPDATE_ALLOWED_HOSTS = "updates.example"; process.env.TALLYNOTE_UPDATE_REQUIRE_SIGNATURE = "true"; const config = loadConfig(); const base = { jobId: "00000000-0000-4000-8000-000000000001", version: "1.1.0", assetUrl: "https://updates.example/app.tar.gz", assetName: "app.tar.gz", expectedSha256: "a".repeat(64), requestedAt: Date.now(), currentLink: config.currentLink, releasesDir: config.releasesDir, dataDir: config.dataDir, }; expect(() => validateUpdateRequest({ ...base, metadataUrl: "https://evil.example/latest" }, config)).toThrow(/请求源|主机/); expect(() => validateUpdateRequest({ ...base, metadataUrl: "https://updates.example/latest", requestedAt: Date.now() - 2 * 24 * 60 * 60 * 1000 }, config)).toThrow(/过期/); }); it("读取 metadata 和 SHA256 sidecar 时限制重定向主机", async () => { const digest = "a".repeat(64); globalThis.fetch = (async (input: string | URL) => { const url = input.toString(); if (url.endsWith("/latest")) { return new Response(JSON.stringify({ tag_name: "v1.3.0", assets: [{ name: "SHA256SUMS", browser_download_url: "https://updates.example/SHA256SUMS" }, { name: "app-linux-x64.tar.gz", browser_download_url: "https://updates.example/app-linux-x64.tar.gz" }] }), { status: 200, headers: { "content-type": "application/json" } }); } return new Response(`${digest} app-linux-x64.tar.gz\n`, { status: 200 }); }) as typeof fetch; const metadata = await fetchReleaseMetadata("https://updates.example/latest", { allowedHosts: ["updates.example"] }); expect(metadata.version).toBe("1.3.0"); expect((await fetchReleaseText("https://updates.example/SHA256SUMS", { allowedHosts: ["updates.example"] })).trim()).toContain(digest); }); it("对没有 Content-Length 的 metadata 和 sidecar 响应执行流式大小限制", async () => { const oversized = "x".repeat(2 * 1024 * 1024 + 1); globalThis.fetch = (async (input: string | URL) => { const url = input.toString(); return url.endsWith("/latest") ? new Response(oversized, { status: 200 }) : new Response(oversized, { status: 200 }); }) as typeof fetch; await expect(fetchReleaseMetadata("https://updates.example/latest", { allowedHosts: ["updates.example"] })).rejects.toThrow("更新发布信息不可用"); await expect(fetchReleaseText("https://updates.example/SHA256SUMS", { allowedHosts: ["updates.example"], maxBytes: 1024 })).rejects.toThrow("更新校验文件过大"); }); it("不会把 SHA256SUMS.sig 误当成摘要清单", async () => { const dataDir = await mkdtemp(path.join(tmpdir(), "tallynote-update-sidecar-order-")); process.env.TALLYNOTE_DATA_DIR = dataDir; 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 = "https://updates.example/latest"; process.env.TALLYNOTE_UPDATE_ALLOWED_HOSTS = "updates.example"; process.env.TALLYNOTE_UPDATE_REQUIRE_SIGNATURE = "false"; const config = loadConfig(); prepareDataDirectories(config); const database = openDatabase(config); const digest = "e".repeat(64); const assetName = `tallynote-1.2.1-${detectPlatform().target}-glibc.tar.gz`; globalThis.fetch = (async (input: string | URL) => input.toString().endsWith("SHA256SUMS.sig") ? new Response("not-a-digest") : input.toString().endsWith("SHA256SUMS") ? new Response(`${digest} ${assetName}\n`) : new Response(JSON.stringify({ tag_name: "v1.2.1", assets: [{ name: "SHA256SUMS.sig", browser_download_url: "https://updates.example/SHA256SUMS.sig" }, { name: "SHA256SUMS", browser_download_url: "https://updates.example/SHA256SUMS" }, { name: assetName, browser_download_url: `https://updates.example/${assetName}` }] }))); try { const result = await checkForUpdate(database.sqlite, config); expect(result.latest).toMatchObject({ compatible: true, integrityReady: true }); } finally { database.sqlite.close(); await rm(dataDir, { recursive: true, force: true }); } }); it("下载流限制大小并返回摘要", async () => { const bytes = Buffer.from("release-bytes"); const destinationRoot = await mkdtemp(path.join(tmpdir(), "tallynote-update-download-")); try { globalThis.fetch = (async () => new Response(bytes, { status: 200, headers: { "content-length": String(bytes.length) } })) as typeof fetch; const result = await downloadReleaseAsset("https://updates.example/release.tar.gz", path.join(destinationRoot, "release.tar.gz"), { allowedHosts: ["updates.example"], maxBytes: 1024 }); expect(result.size).toBe(bytes.length); expect(result.sha256).toBe(createHash("sha256").update(bytes).digest("hex")); } finally { await rm(destinationRoot, { recursive: true, force: true }); } }); it("原子切换 current 符号链接并保留旧版本", async () => { const root = await mkdtemp(path.join(tmpdir(), "tallynote-update-switch-")); try { const releases = path.join(root, "releases"); const current = path.join(root, "current"); const old = path.join(releases, "1.0.0"); const staged = path.join(root, "staged"); await mkdir(path.join(old, "dist"), { recursive: true }); await writeFile(path.join(old, "dist", "marker"), "old"); await mkdir(path.join(staged, "dist"), { recursive: true }); await writeFile(path.join(staged, "dist", "marker"), "new"); await symlink(old, current); const result = await atomicSwitchRelease(staged, current, releases, "1.1.0"); expect(await readlink(current)).toBe(path.join(releases, "1.1.0")); expect(result.previousTarget).toBe(path.relative(root, old)); } finally { await rm(root, { recursive: true, force: true }); } }); it("runUpdate 校验摘要、解包并原子替换目录", async () => { const root = await mkdtemp(path.join(tmpdir(), "tallynote-update-run-")); try { const source = path.join(root, "source"); const current = path.join(root, "current"); const staging = path.join(root, "staging"); const backup = path.join(root, "backups", "old.tar.gz"); await mkdir(path.join(source, "dist"), { recursive: true }); await writeFile(path.join(source, "dist", "marker"), "new"); await mkdir(path.join(current, "dist"), { recursive: true }); await writeFile(path.join(current, "dist", "marker"), "old"); 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 fetchImpl = (async () => new Response(bytes, { status: 200, headers: { "content-length": String(bytes.length) } })) as typeof fetch; const result = await runUpdate({ assetUrl: "https://updates.example/release.tar.gz", assetName: "release.tar.gz", version: "1.1.0", expectedSha256: digest, currentVersion: "1.0.0", currentDir: current, stagingDir: staging, backupArchivePath: backup, allowedHosts: ["updates.example"], fetchImpl, }); expect(result.version).toBe("1.1.0"); expect(await readFile(path.join(current, "dist", "marker"), "utf8")).toBe("new"); expect((await stat(backup)).size).toBeGreaterThan(0); } finally { await rm(root, { recursive: true, force: true }); } }); it("更新器支持旧客户端创建的 queued/apply 直接更新请求", async () => { const root = await mkdtemp(path.join(tmpdir(), "tallynote-update-direct-")); const previousFetch = globalThis.fetch; let database: ReturnType | undefined; try { 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 = "https://updates.example/latest"; process.env.TALLYNOTE_UPDATE_ALLOWED_HOSTS = "updates.example"; process.env.TALLYNOTE_UPDATE_REQUIRE_SIGNATURE = "false"; const config = loadConfig(); prepareDataDirectories(config); await mkdir(config.releasesDir, { recursive: true, mode: 0o755 }); const oldRelease = path.join(config.releasesDir, config.appVersion); await mkdir(path.join(oldRelease, "dist"), { recursive: true, mode: 0o755 }); await writeFile(path.join(oldRelease, "dist", "marker"), "old"); await symlink(oldRelease, config.currentLink); const source = path.join(root, "source"); await mkdir(path.join(source, "dist"), { recursive: true, mode: 0o755 }); 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(); database = openDatabase(config); const now = Date.now(); const assetName = `tallynote-1.3.0-${detectPlatform().target}.tar.gz`; database.sqlite.prepare(` INSERT INTO update_jobs(id, operation, status, version, platform, asset_url, expected_sha256, created_at, updated_at, requested_at) VALUES (?, 'apply', 'queued', '1.3.0', ?, ?, ?, ?, ?, ?) `).run(jobId, detectPlatform().target, "https://updates.example/" + assetName, digest, now, now, now); globalThis.fetch = (async (input: string | URL) => { const url = input.toString(); if (url.endsWith("/latest")) return new Response(JSON.stringify({ tag_name: "v1.3.0", assets: [{ name: assetName, browser_download_url: `https://updates.example/${assetName}` }, { name: "SHA256SUMS", browser_download_url: "https://updates.example/SHA256SUMS" }] })); if (url.endsWith("SHA256SUMS")) return new Response(`${digest} ${assetName}\n`); return new Response(bytes, { headers: { "content-length": String(bytes.length) } }); }) as typeof fetch; await runUpdate({ metadataUrl: config.updateMetadataUrl, version: "1.3.0", currentVersion: config.appVersion, currentDir: config.currentLink, stagingDir: path.join(root, "staging"), currentLink: config.currentLink, releasesDir: config.releasesDir, allowedHosts: config.updateAllowedHosts, maxBytes: config.updateMaxBytes, dataBackupMaxBytes: config.maxTotalBytes, deferCompletion: true, operation: "apply", jobId, sqlite: database.sqlite, fetchImpl: globalThis.fetch, }); expect(await readFile(path.join(config.currentLink, "dist", "marker"), "utf8")).toBe("new"); const row = database.sqlite.prepare("SELECT operation, status FROM update_jobs WHERE id=?").get(jobId); expect(row).toEqual({ operation: "apply", status: "applying" }); finalizeUpdateJob(database.sqlite, jobId, "failed", "健康检查失败(自定义)"); expect(database.sqlite.prepare("SELECT status, error_message AS errorMessage FROM update_jobs WHERE id=?").get(jobId)).toEqual({ status: "failed", errorMessage: "健康检查失败(自定义)" }); finalizeUpdateJob(database.sqlite, jobId, "failed", "第二次 finalize 不应覆盖原消息"); expect(database.sqlite.prepare("SELECT status, error_message AS errorMessage FROM update_jobs WHERE id=?").get(jobId)).toEqual({ status: "failed", errorMessage: "健康检查失败(自定义)" }); expect(database.sqlite.prepare("SELECT COUNT(*) AS count FROM audit_events WHERE action='update.failed' AND target_id=?").get(jobId)).toEqual({ count: 1 }); const defaultJobId = randomUUID(); database.sqlite.prepare(` INSERT INTO update_jobs(id, operation, status, version, platform, asset_url, created_at, updated_at) VALUES (?, 'apply', 'applying', '1.3.0', ?, ?, ?, ?) `).run(defaultJobId, detectPlatform().target, "https://updates.example/" + assetName, now, now); finalizeUpdateJob(database.sqlite, defaultJobId, "failed", ""); expect(database.sqlite.prepare("SELECT error_message AS errorMessage FROM update_jobs WHERE id=?").get(defaultJobId)).toEqual({ errorMessage: "新版本健康检查失败,已恢复上一版本" }); const completedJobId = randomUUID(); database.sqlite.prepare(` INSERT INTO update_jobs(id, operation, status, version, platform, asset_url, created_at, updated_at) VALUES (?, 'apply', 'completed', '1.3.0', ?, ?, ?, ?) `).run(completedJobId, detectPlatform().target, "https://updates.example/" + assetName, now, now); finalizeUpdateJob(database.sqlite, completedJobId, "completed"); expect(database.sqlite.prepare("SELECT COUNT(*) AS count FROM audit_events WHERE action='update.completed' AND target_id=?").get(completedJobId)).toEqual({ count: 0 }); expect(() => finalizeUpdateJob(database.sqlite, completedJobId, "failed", "不能降级已完成任务")).toThrow("更新任务状态不允许完成"); expect(database.sqlite.prepare("SELECT status FROM update_jobs WHERE id=?").get(completedJobId)).toEqual({ status: "completed" }); } finally { globalThis.fetch = previousFetch; if (database) database.sqlite.close(); await rm(root, { recursive: true, force: true }); } }); it("在请求和恢复标记丢失后收敛孤儿任务,但保留 staged 下载", async () => { const root = await mkdtemp(path.join(tmpdir(), "tallynote-update-reconcile-")); let database: ReturnType | undefined; try { 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 = "https://updates.example/latest"; process.env.TALLYNOTE_UPDATE_ALLOWED_HOSTS = "updates.example"; process.env.TALLYNOTE_UPDATE_REQUIRE_SIGNATURE = "false"; const config = loadConfig(); prepareDataDirectories(config); await mkdir(path.join(config.releasesDir, config.appVersion, "dist"), { recursive: true }); await symlink(path.join(config.releasesDir, config.appVersion), config.currentLink); database = openDatabase(config); const staleAt = Date.now() - ORPHANED_UPDATE_TIMEOUT_MS - 1; const insert = database.sqlite.prepare(` INSERT INTO update_jobs(id, operation, status, version, platform, asset_url, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?) `); const queuedId = randomUUID(); const applyingId = randomUUID(); const stagedId = randomUUID(); const stagedApplyId = randomUUID(); insert.run(queuedId, "apply", "queued", "1.3.0", "linux-x64", "https://updates.example/queued.tar.gz", staleAt, staleAt); insert.run(applyingId, "apply", "applying", config.appVersion, "linux-x64", "https://updates.example/applying.tar.gz", staleAt, staleAt); insert.run(stagedId, "download", "staged", "1.3.0", "linux-x64", "https://updates.example/staged.tar.gz", staleAt, staleAt); insert.run(stagedApplyId, "apply", "staged", "1.3.0", "linux-x64", "https://updates.example/staged-apply.tar.gz", staleAt, staleAt); const now = Date.now(); expect(reconcileOrphanedUpdateJobs(database.sqlite, config, now)).toBe(3); expect(database.sqlite.prepare("SELECT status FROM update_jobs WHERE id=?").get(queuedId)).toEqual({ status: "failed" }); expect(database.sqlite.prepare("SELECT status FROM update_jobs WHERE id=?").get(applyingId)).toEqual({ status: "completed" }); expect(database.sqlite.prepare("SELECT status, operation FROM update_jobs WHERE id=?").get(stagedId)).toEqual({ status: "staged", operation: "download" }); expect(database.sqlite.prepare("SELECT status, operation FROM update_jobs WHERE id=?").get(stagedApplyId)).toEqual({ status: "staged", operation: "download" }); } finally { if (database) database.sqlite.close(); await rm(root, { recursive: true, force: true }); } }); it("下载心跳有效时不回收任务或删除仍在使用的请求文件", async () => { const root = await mkdtemp(path.join(tmpdir(), "tallynote-update-heartbeat-")); let database: ReturnType | undefined; try { 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 = "https://updates.example/latest"; process.env.TALLYNOTE_UPDATE_ALLOWED_HOSTS = "updates.example"; process.env.TALLYNOTE_UPDATE_REQUIRE_SIGNATURE = "false"; const config = loadConfig(); prepareDataDirectories(config); await mkdir(path.join(config.releasesDir, config.appVersion, "dist"), { recursive: true }); await symlink(path.join(config.releasesDir, config.appVersion), config.currentLink); database = openDatabase(config); const staleAt = Date.now() - ORPHANED_UPDATE_TIMEOUT_MS - 1; const jobId = randomUUID(); database.sqlite.prepare(` INSERT INTO update_jobs(id, operation, status, version, platform, asset_url, created_at, updated_at) VALUES (?, 'download', 'downloading', '1.3.0', 'linux-x64', ?, ?, ?) `).run(jobId, "https://updates.example/download.tar.gz", staleAt, staleAt); await writeFile(config.updateRequestPath, JSON.stringify({ jobId, operation: "download" })); const statePath = path.join(config.installPrefix, ".update-state"); await writeFile(statePath, `job_id=${jobId}\nold_target=${path.join(config.releasesDir, config.appVersion)}\nphase=download\n`); const now = Date.now(); await utimes(config.updateRequestPath, new Date(staleAt), new Date(staleAt)); await utimes(statePath, new Date(now), new Date(now)); expect(reconcileOrphanedUpdateJobs(database.sqlite, config, now)).toBe(0); expect(database.sqlite.prepare("SELECT status FROM update_jobs WHERE id=?").get(jobId)).toEqual({ status: "downloading" }); expect(await stat(config.updateRequestPath)).toBeTruthy(); const expiredNow = now + ORPHANED_UPDATE_TIMEOUT_MS + 1; await utimes(statePath, new Date(staleAt), new Date(staleAt)); expect(reconcileOrphanedUpdateJobs(database.sqlite, config, expiredNow)).toBe(1); expect(database.sqlite.prepare("SELECT status FROM update_jobs WHERE id=?").get(jobId)).toEqual({ status: "failed" }); await expect(stat(config.updateRequestPath)).rejects.toThrow(); } finally { if (database) database.sqlite.close(); await rm(root, { recursive: true, force: true }); } }); it("队列任务有匹配请求标记时保留到租约过期,过期后才回收", async () => { const root = await mkdtemp(path.join(tmpdir(), "tallynote-update-queued-marker-")); let database: ReturnType | undefined; try { 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 = "https://updates.example/latest"; process.env.TALLYNOTE_UPDATE_ALLOWED_HOSTS = "updates.example"; process.env.TALLYNOTE_UPDATE_REQUIRE_SIGNATURE = "false"; const config = loadConfig(); prepareDataDirectories(config); database = openDatabase(config); const staleAt = Date.now() - ORPHANED_UPDATE_TIMEOUT_MS - 1; const jobId = randomUUID(); database.sqlite.prepare(` INSERT INTO update_jobs(id, operation, status, version, platform, asset_url, created_at, updated_at) VALUES (?, 'apply', 'queued', '1.3.0', 'linux-x64', ?, ?, ?) `).run(jobId, "https://updates.example/queued.tar.gz", staleAt, staleAt); await writeFile(config.updateRequestPath, JSON.stringify({ jobId, operation: "apply" })); const now = Date.now(); await utimes(config.updateRequestPath, new Date(now), new Date(now)); // The DB row is old, but the request marker is fresh and names this // exact job. Keep it queued while systemd has a chance to consume it. expect(reconcileOrphanedUpdateJobs(database.sqlite, config, now)).toBe(0); expect(database.sqlite.prepare("SELECT status FROM update_jobs WHERE id=?").get(jobId)).toEqual({ status: "queued" }); await expect(stat(config.updateRequestPath)).resolves.toBeTruthy(); const expiredNow = now + ORPHANED_UPDATE_TIMEOUT_MS + 1; expect(reconcileOrphanedUpdateJobs(database.sqlite, config, expiredNow)).toBe(1); expect(database.sqlite.prepare("SELECT status FROM update_jobs WHERE id=?").get(jobId)).toEqual({ status: "failed" }); await expect(stat(config.updateRequestPath)).rejects.toThrow(); } finally { if (database) database.sqlite.close(); await rm(root, { recursive: true, force: true }); } }); it("流式解包在展开大小上限前拒绝高压缩比归档,并修正发布树权限", async () => { const root = await mkdtemp(path.join(tmpdir(), "tallynote-update-stream-")); try { const source = path.join(root, "source"); const destination = path.join(root, "destination"); await mkdir(path.join(source, "dist", "server"), { recursive: true }); await mkdir(path.join(source, "bin"), { recursive: true }); await mkdir(path.join(source, "scripts"), { recursive: true }); await mkdir(path.join(source, "runtime", "bin"), { recursive: true }); await writeFile(path.join(source, "dist", "server", "large.js"), Buffer.alloc(2 * 1024 * 1024, 0x41)); await writeFile(path.join(source, "bin", "tallynote"), "#!/bin/sh\n"); await writeFile(path.join(source, "scripts", "runner.sh"), "#!/bin/sh\n"); await writeFile(path.join(source, "runtime", "bin", "node"), "node"); const archive = path.join(root, "release.tar.gz"); await createSafeArchive(source, archive); expect((await stat(archive)).size).toBeLessThan(64 * 1024); await expect(extractSafeArchive(archive, destination, { maxBytes: 1024 * 1024 })).rejects.toThrow(/大小限制/); expect(await stat(destination).catch(() => null)).toBeNull(); await extractSafeArchive(archive, destination, { maxBytes: 4 * 1024 * 1024 }); await normalizeReleasePermissions(destination); expect((await stat(path.join(destination, "dist"))).mode & 0o777).toBe(0o755); expect((await stat(path.join(destination, "dist", "server", "large.js"))).mode & 0o777).toBe(0o644); expect((await stat(path.join(destination, "bin", "tallynote"))).mode & 0o777).toBe(0o755); expect((await stat(path.join(destination, "scripts", "runner.sh"))).mode & 0o777).toBe(0o755); expect((await stat(path.join(destination, "runtime", "bin", "node"))).mode & 0o777).toBe(0o755); } finally { await rm(root, { recursive: true, force: true }); } }); it("流式创建备份遵守大小上限并清理失败的临时文件", async () => { const root = await mkdtemp(path.join(tmpdir(), "tallynote-update-archive-")); try { const source = path.join(root, "source"); const archive = path.join(root, "backup.tar.gz"); await mkdir(source, { recursive: true }); await writeFile(path.join(source, "large.bin"), Buffer.alloc(128 * 1024, 0x42)); await expect(createSafeArchive(source, archive, { maxBytes: 1024 })).rejects.toThrow(/大小限制/); expect(await stat(archive).catch(() => null)).toBeNull(); expect((await readdir(root)).filter((name) => name.includes(".part-")).length).toBe(0); await createSafeArchive(source, archive, { maxBytes: 256 * 1024 }); expect((await stat(archive)).size).toBeGreaterThan(0); } finally { await rm(root, { recursive: true, force: true }); } }); }); describe("更新元数据缓存", () => { it("选择当前平台资产并要求 SHA256 sidecar", async () => { const dataDir = await mkdtemp(path.join(tmpdir(), "tallynote-update-cache-")); process.env.TALLYNOTE_DATA_DIR = dataDir; process.env.TALLYNOTE_PUBLIC_ORIGIN = "http://127.0.0.1:3996"; process.env.TALLYNOTE_COOKIE_SECURE = "false"; process.env.TALLYNOTE_UPDATE_STRATEGY = "systemd"; process.env.TALLYNOTE_UPDATE_METADATA_URL = "https://updates.example/latest"; process.env.TALLYNOTE_UPDATE_ALLOWED_HOSTS = "updates.example"; process.env.TALLYNOTE_UPDATE_REQUIRE_SIGNATURE = "true"; const { publicKey, privateKey } = generateKeyPairSync("ed25519"); const publicPem = publicKey.export({ type: "spki", format: "pem" }).toString(); process.env.TALLYNOTE_UPDATE_PUBLIC_KEY = publicPem; const config = loadConfig(); prepareDataDirectories(config); const database = openDatabase(config); const digest = "b".repeat(64); const platformAsset = `tallynote-1.3.0-${detectPlatform().target}-glibc.tar.gz`; const sums = `${digest} ${platformAsset}\n`; const signature = sign(null, Buffer.from(sums), privateKey); globalThis.fetch = (async (input: string | URL) => input.toString().endsWith("SHA256SUMS.sig") ? new Response(signature) : input.toString().endsWith("SHA256SUMS") ? new Response(sums) : new Response(JSON.stringify({ tag_name: "v1.3.0", published_at: "2026-08-28T00:00:00Z", assets: [{ name: "SHA256SUMS", browser_download_url: "https://updates.example/SHA256SUMS" }, { name: "SHA256SUMS.sig", browser_download_url: "https://updates.example/SHA256SUMS.sig" }, { name: platformAsset, browser_download_url: `https://updates.example/${platformAsset}` }] }), { status: 200 })) as typeof fetch; try { const result = await checkForUpdate(database.sqlite, config); expect(result.latest).toMatchObject({ version: "1.3.0", compatible: true, integrityReady: true, signatureReady: true, isNewer: true }); const cached = database.sqlite.prepare("SELECT value FROM system_settings WHERE key='update.release.v1'").get() as { value: string }; expect(JSON.parse(cached.value).asset.sha256).toBe(digest); } finally { database.sqlite.close(); await rm(dataDir, { recursive: true, force: true }); } }); });