import { afterEach, describe, expect, it } from "vitest"; import { mkdir, readlink, symlink, writeFile, readFile, stat, readdir } from "node:fs/promises"; import { mkdtemp, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; import { createHash, generateKeyPairSync, sign } from "node:crypto"; import { atomicSwitchRelease, createSafeArchive, detectPlatform, downloadReleaseAsset, fetchReleaseMetadata, fetchReleaseText, extractSafeArchive, isNewerVersion, normalizeReleasePermissions, sanitizeAssetName, selectReleaseAsset, validateHttpsUrl, } from "../server/update.js"; import { runUpdate } from "../server/cli/update.js"; import { validateUpdateRequest } from "../server/cli/update.js"; import { checkForUpdate, 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_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.2.0", assets: [ { name: "tallynote-1.2.0-linux-arm64-glibc.tar.gz", url: "https://updates.example/arm" }, { name: "tallynote-1.2.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.2.0", assets: [{ name: "tallynote-1.2.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("验证 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.2.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.2.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("流式解包在展开大小上限前拒绝高压缩比归档,并修正发布树权限", 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.1.3-${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.1.3", 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.1.3", 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 }); } }); });