295 lines
16 KiB
TypeScript
295 lines
16 KiB
TypeScript
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.8-${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.8", 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.8", 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 });
|
|
}
|
|
});
|
|
});
|