import { createHash, randomUUID } from "node:crypto"; import { createReadStream, createWriteStream } from "node:fs"; import { chmod, mkdir, open, readdir, rename, lstat, readlink, symlink, rm } from "node:fs/promises"; import path from "node:path"; import { Readable, Transform } from "node:stream"; import { finished, pipeline } from "node:stream/promises"; import { createGzip, createGunzip } from "node:zlib"; import yauzl from "yauzl"; /** A small semver implementation so update checks do not depend on a runtime package. */ export type SemVer = { major: number; minor: number; patch: number; prerelease: string[]; build: string[]; }; export type UpdatePlatform = { os: string; arch: string; target: string; aliases: string[]; platform: string; architecture: string; }; export type ReleaseAsset = { name: string; url: string; sha256?: string; size?: number; }; export type ReleaseMetadata = { version: string; tagName?: string; releaseName?: string; publishedAt?: string; /** Plain-text release notes, bounded to keep API/cache payloads small. */ notes?: string; releaseUrl?: string; assets: ReleaseAsset[]; }; const APPLICATION_UPDATE_ASSET = /\.update-([a-f0-9]{64})\.tar\.gz$/i; export function applicationUpdateRuntimeHash(assetName: string): string | undefined { return APPLICATION_UPDATE_ASSET.exec(assetName)?.[1]?.toLowerCase(); } export function runtimeHashFromLockfile(lockfile: string | Buffer): string { return createHash("sha256").update(lockfile).digest("hex"); } export type UrlPolicy = { /** Host names or HTTPS URLs which are allowed for requests. */ allowedHosts?: readonly string[] | undefined; /** When allowedHosts is omitted, requests are constrained to this URL's host. */ baseUrl?: string | URL | undefined; maxRedirects?: number | undefined; /** Maximum time allowed for one metadata/sidecar/archive request. */ timeoutMs?: number | undefined; }; /** Release an unread response body before following a redirect or returning * an error. Undici keeps the underlying connection associated with a body * until it is consumed or cancelled; leaving it open can exhaust sockets when * an update feed repeatedly returns errors or oversized responses. */ async function cancelResponseBody(response: Response): Promise { try { await response.body?.cancel(); } catch { // The body may already be consumed/closed. Cancellation is best effort. } } function invalidVersion(): never { throw new Error("更新版本号无效"); } export function parseSemver(value: string): SemVer { const input = value.trim().replace(/^v/i, ""); const match = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?$/.exec(input); if (!match) return invalidVersion(); const prerelease = match[4] ? match[4].split(".") : []; const build = match[5] ? match[5].split(".") : []; if (prerelease.some((part) => /^0\d+$/.test(part))) return invalidVersion(); const major = Number(match[1]); const minor = Number(match[2]); const patch = Number(match[3]); if (![major, minor, patch].every((part) => Number.isSafeInteger(part))) return invalidVersion(); return { major, minor, patch, prerelease, build }; } export function compareSemver(left: string | SemVer, right: string | SemVer): number { const a = typeof left === "string" ? parseSemver(left) : left; const b = typeof right === "string" ? parseSemver(right) : right; for (const key of ["major", "minor", "patch"] as const) { if (a[key] !== b[key]) return a[key] > b[key] ? 1 : -1; } if (a.prerelease.length === 0 && b.prerelease.length > 0) return 1; if (a.prerelease.length > 0 && b.prerelease.length === 0) return -1; for (let i = 0; i < Math.max(a.prerelease.length, b.prerelease.length); i += 1) { const x = a.prerelease[i]; const y = b.prerelease[i]; if (x === undefined) return -1; if (y === undefined) return 1; if (x === y) continue; const xn = /^\d+$/.test(x); const yn = /^\d+$/.test(y); if (xn && yn) { if (x.length !== y.length) return x.length > y.length ? 1 : -1; return x > y ? 1 : -1; } if (xn !== yn) return xn ? -1 : 1; return x > y ? 1 : -1; } return 0; } export function isNewerVersion(current: string, candidate: string): boolean { return compareSemver(candidate, current) > 0; } export function detectPlatform(platform = process.platform, architecture = process.arch): UpdatePlatform { const os = platform === "win32" ? "windows" : platform; const arch = ({ amd64: "x64", x86_64: "x64", aarch64: "arm64" } as Record)[architecture] ?? architecture; const target = `${os}-${arch}`; return { os, arch, target, aliases: [target, `${os}_${arch}`, `${platform}-${architecture}`, `${platform}_${architecture}`, os, platform], platform: os, architecture: arch, }; } function hostFromEntry(entry: string): string { try { const parsed = new URL(entry.includes("://") ? entry : `https://${entry}`); if (entry.includes("://") && parsed.protocol !== "https:") throw new Error("scheme"); return parsed.hostname.toLowerCase(); } catch { throw new Error("更新地址白名单无效"); } } export function validateHttpsUrl(value: string | URL, policy: UrlPolicy = {}): URL { let parsed: URL; try { parsed = new URL(value.toString()); } catch { throw new Error("更新地址无效"); } if (parsed.protocol !== "https:") throw new Error("更新地址必须使用 HTTPS"); if (parsed.username || parsed.password) throw new Error("更新地址不允许携带凭据"); const configured = policy.allowedHosts?.map(hostFromEntry); const allowed = configured && configured.length > 0 ? configured : policy.baseUrl ? [hostFromEntry(policy.baseUrl.toString())] : [parsed.hostname.toLowerCase()]; if (!allowed.includes(parsed.hostname.toLowerCase())) throw new Error("更新地址主机不在允许列表中"); return parsed; } function metadataError(): Error { return new Error("更新发布信息不可用"); } const DEFAULT_METADATA_MAX_BYTES = 2 * 1024 * 1024; /** Maximum time allowed for one update HTTP request, including its body. */ export const DEFAULT_UPDATE_TIMEOUT_MS = 30_000; export const RELEASE_NOTES_MAX_BYTES = 64 * 1024; type UpdateFetchOptions = { fetchImpl?: typeof fetch | undefined; maxBytes?: number | undefined; timeoutMs?: number | undefined; }; function updateTimeoutMs(options: UpdateFetchOptions): number { if (options.timeoutMs !== undefined) { if (!Number.isSafeInteger(options.timeoutMs) || options.timeoutMs <= 0) throw new Error("更新请求超时配置无效"); return options.timeoutMs; } const configuredSeconds = process.env.TALLYNOTE_UPDATE_TIMEOUT_SECONDS; if (configuredSeconds !== undefined && configuredSeconds.trim() !== "") { const seconds = Number(configuredSeconds); if (!Number.isSafeInteger(seconds) || seconds <= 0) throw new Error("TALLYNOTE_UPDATE_TIMEOUT_SECONDS 必须是大于 0 的整数"); return seconds * 1000; } return DEFAULT_UPDATE_TIMEOUT_MS; } function beginUpdateRequest(options: UpdateFetchOptions): { signal: AbortSignal; clear: () => void } { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), updateTimeoutMs(options)); timer.unref?.(); return { signal: controller.signal, clear: () => clearTimeout(timer) }; } function releaseNotesText(value: unknown): string | undefined { if (typeof value !== "string" || value.length === 0) return undefined; // Gitea exposes both Markdown (body/body_html) and releaseNotes depending on // endpoint/version. Keep the browser contract text-only and bounded. const text = value .replace(//gi, "\n") .replace(/<\/p\s*>/gi, "\n\n") .replace(/<[^>]*>/g, "") .replace(/ /gi, " ") .replace(/&/gi, "&") .replace(/</gi, "<") .replace(/>/gi, ">") .replace(/"/gi, '"') .replace(/'/gi, "'") .replace(/\r\n?/g, "\n") .trim(); const bytes = Buffer.from(text, "utf8"); if (bytes.length <= RELEASE_NOTES_MAX_BYTES) return text; return bytes.subarray(0, RELEASE_NOTES_MAX_BYTES).toString("utf8").replace(/\uFFFD$/u, "") + "\n[内容已截断]"; } function releaseNameText(value: unknown): string | undefined { if (typeof value !== "string") return undefined; const text = value.replace(/[\u0000-\u001f\u007f]/g, " ").trim(); return text.length > 0 ? text.slice(0, 200) : undefined; } /** Gitea installations behind a reverse proxy sometimes emit internal HTTP * asset URLs. Rebind those URLs to the already trusted HTTPS release origin, * while continuing to reject arbitrary HTTPS hosts and credentials. */ function releaseResourceUrl(value: string, current: URL, options: UrlPolicy): string { let candidate: URL; try { candidate = new URL(value, current); } catch { throw new Error("更新地址无效"); } if (candidate.username || candidate.password) throw new Error("更新地址不允许携带凭据"); try { return validateHttpsUrl(candidate, { ...options, baseUrl: current }).toString(); } catch { if (candidate.protocol !== "http:") throw new Error("更新地址必须使用 HTTPS"); const rebound = new URL(current); rebound.pathname = candidate.pathname; rebound.search = candidate.search; rebound.hash = ""; return validateHttpsUrl(rebound, { ...options, baseUrl: current }).toString(); } } /** Read a fetch body without ever buffering more than the caller's bound. */ async function readBoundedResponse(response: Response, maxBytes: number, tooLargeMessage: string, signal?: AbortSignal): Promise { if (!Number.isSafeInteger(maxBytes) || maxBytes <= 0) throw new Error("响应大小限制无效"); const contentLength = response.headers.get("content-length"); if (contentLength !== null) { const declared = Number(contentLength); if (Number.isFinite(declared) && declared > maxBytes) { await cancelResponseBody(response); throw new Error(tooLargeMessage); } } if (!response.body) return Buffer.alloc(0); const reader = response.body.getReader(); const chunks: Buffer[] = []; let total = 0; let onAbort: (() => void) | undefined; const abort = signal ? new Promise((_, reject) => { onAbort = () => reject(new Error("更新请求超时")); if (signal.aborted) onAbort(); else signal.addEventListener("abort", onAbort, { once: true }); }) : undefined; try { for (;;) { const result = await (abort ? Promise.race([reader.read(), abort]) : reader.read()); if (result.done) break; const chunk = Buffer.from(result.value); if (chunk.length > maxBytes - total) { await reader.cancel().catch(() => undefined); throw new Error(tooLargeMessage); } total += chunk.length; chunks.push(chunk); } } catch (error) { await reader.cancel().catch(() => undefined); throw error; } finally { if (signal && onAbort) signal.removeEventListener("abort", onAbort); reader.releaseLock(); } return Buffer.concat(chunks, total); } export async function fetchReleaseMetadata( metadataUrl: string | URL, options: UrlPolicy & UpdateFetchOptions = {}, ): Promise { const fetchImpl = options.fetchImpl ?? fetch; let current = validateHttpsUrl(metadataUrl, options); const maxRedirects = options.maxRedirects ?? 3; let response: Response; for (let redirects = 0; ; redirects += 1) { const request = beginUpdateRequest(options); try { response = await fetchImpl(current, { method: "GET", redirect: "manual", headers: { accept: "application/json" }, signal: request.signal }); } catch { request.clear(); throw metadataError(); } if (response.status < 300 || response.status >= 400) { try { if (response.status < 200 || response.status >= 300) { await cancelResponseBody(response); throw metadataError(); } const maxBytes = Math.min(options.maxBytes ?? DEFAULT_METADATA_MAX_BYTES, DEFAULT_METADATA_MAX_BYTES); const body = await readBoundedResponse(response, maxBytes, "更新发布信息过大", request.signal); const payload: unknown = JSON.parse(body.toString("utf8")); if (!payload || typeof payload !== "object") throw metadataError(); const item = payload as Record; const rawVersion = typeof item.version === "string" ? item.version : typeof item.tag_name === "string" ? item.tag_name : typeof item.tagName === "string" ? item.tagName : undefined; if (!rawVersion) throw metadataError(); const version = parseSemver(rawVersion); if (typeof item.tag_name === "string" && compareSemver(version, item.tag_name) !== 0) throw metadataError(); const assetsRaw = Array.isArray(item.assets) ? item.assets : []; const assets: ReleaseAsset[] = []; for (const raw of assetsRaw) { if (!raw || typeof raw !== "object") continue; const asset = raw as Record; const name = typeof asset.name === "string" ? asset.name : undefined; const url = typeof asset.url === "string" ? asset.url : typeof asset.browser_download_url === "string" ? asset.browser_download_url : undefined; if (!name || !url) continue; let sha256: string | undefined; const digest = typeof asset.sha256 === "string" ? asset.sha256 : typeof asset.digest === "string" ? asset.digest : undefined; if (digest) { const candidate = digest.replace(/^sha256:/i, "").toLowerCase(); if (/^[a-f0-9]{64}$/.test(candidate)) sha256 = candidate; } assets.push({ name, url: releaseResourceUrl(url, current, options), ...(sha256 ? { sha256 } : {}), ...(typeof asset.size === "number" && Number.isSafeInteger(asset.size) && asset.size >= 0 ? { size: asset.size } : {}) }); } const notes = releaseNotesText(item.body ?? item.releaseNotes ?? item.release_notes ?? item.body_html); const releaseName = releaseNameText(item.name ?? item.releaseName); let releaseUrl: string | undefined; if (typeof item.html_url === "string" || typeof item.url === "string") { try { releaseUrl = releaseResourceUrl(typeof item.html_url === "string" ? item.html_url : item.url as string, current, options); } catch { /* optional */ } } return { version: `${version.major}.${version.minor}.${version.patch}${version.prerelease.length ? `-${version.prerelease.join(".")}` : ""}${version.build.length ? `+${version.build.join(".")}` : ""}`, ...(typeof item.tag_name === "string" ? { tagName: item.tag_name } : {}), ...(releaseName ? { releaseName } : {}), ...(typeof item.published_at === "string" ? { publishedAt: item.published_at } : {}), ...(notes ? { notes } : {}), ...(releaseUrl ? { releaseUrl } : {}), assets, }; } catch { throw metadataError(); } finally { request.clear(); } } await cancelResponseBody(response); request.clear(); if (redirects >= maxRedirects) throw metadataError(); const location = response.headers.get("location"); if (!location) throw metadataError(); current = validateHttpsUrl(new URL(location, current), options.baseUrl ? options : { ...options, baseUrl: current }); } } /** Fetch a small text sidecar (for example SHA256SUMS) with the same * redirect, HTTPS and host policy used for release metadata. */ export async function fetchReleaseText( textUrl: string | URL, options: UrlPolicy & UpdateFetchOptions = {}, ): Promise { const fetchImpl = options.fetchImpl ?? fetch; let current = validateHttpsUrl(textUrl, options); const redirectPolicy: UrlPolicy & { fetchImpl?: typeof fetch | undefined; maxBytes?: number | undefined } = options.allowedHosts?.length || options.baseUrl ? options : { ...options, baseUrl: current }; const maxRedirects = options.maxRedirects ?? 3; let response: Response; for (let redirects = 0; ; redirects += 1) { const request = beginUpdateRequest(options); try { response = await fetchImpl(current, { method: "GET", redirect: "manual", signal: request.signal }); } catch { request.clear(); throw new Error("更新校验文件下载失败"); } if (response.status < 300 || response.status >= 400) { if (response.status < 200 || response.status >= 300) { await cancelResponseBody(response); request.clear(); throw new Error("更新校验文件下载失败"); } const declared = Number(response.headers.get("content-length") ?? 0); const maxBytes = options.maxBytes ?? 1024 * 1024; if (declared > maxBytes) { await cancelResponseBody(response); request.clear(); throw new Error("更新校验文件过大"); } try { return (await readBoundedResponse(response, maxBytes, "更新校验文件过大", request.signal)).toString("utf8"); } catch (error) { if (error instanceof Error && error.message === "更新校验文件过大") throw error; throw new Error("更新校验文件下载失败"); } finally { request.clear(); } } await cancelResponseBody(response); request.clear(); if (redirects >= maxRedirects) throw new Error("更新校验文件下载失败"); const location = response.headers.get("location"); if (!location) throw new Error("更新校验文件下载失败"); current = validateHttpsUrl(new URL(location, current), redirectPolicy); } } /** Fetch a bounded binary sidecar (for example an Ed25519 detached * signature). Text decoding would corrupt arbitrary signature bytes, so keep * this separate from fetchReleaseText. */ export async function fetchReleaseBytes( bytesUrl: string | URL, options: UrlPolicy & UpdateFetchOptions = {}, ): Promise { const fetchImpl = options.fetchImpl ?? fetch; let current = validateHttpsUrl(bytesUrl, options); const redirectPolicy: UrlPolicy & { fetchImpl?: typeof fetch | undefined; maxBytes?: number | undefined } = options.allowedHosts?.length || options.baseUrl ? options : { ...options, baseUrl: current }; const maxRedirects = options.maxRedirects ?? 3; let response: Response; for (let redirects = 0; ; redirects += 1) { const request = beginUpdateRequest(options); try { response = await fetchImpl(current, { method: "GET", redirect: "manual", signal: request.signal }); } catch { request.clear(); throw new Error("更新签名下载失败"); } if (response.status < 300 || response.status >= 400) { if (response.status < 200 || response.status >= 300) { await cancelResponseBody(response); request.clear(); throw new Error("更新签名下载失败"); } const declared = Number(response.headers.get("content-length") ?? 0); const maxBytes = options.maxBytes ?? 64 * 1024; if (declared > maxBytes) { await cancelResponseBody(response); request.clear(); throw new Error("更新签名文件过大"); } try { return await readBoundedResponse(response, maxBytes, "更新签名文件过大", request.signal); } catch (error) { if (error instanceof Error && error.message === "更新签名文件过大") throw error; throw new Error("更新签名下载失败"); } finally { request.clear(); } } await cancelResponseBody(response); request.clear(); if (redirects >= maxRedirects) throw new Error("更新签名下载失败"); const location = response.headers.get("location"); if (!location) throw new Error("更新签名下载失败"); current = validateHttpsUrl(new URL(location, current), redirectPolicy); } } export function selectReleaseAsset(release: ReleaseMetadata, platform = detectPlatform(), runtimeHash?: string): ReleaseAsset | undefined { const platformCandidates = release.assets.filter((asset) => { const name = asset.name.toLowerCase(); return platform.aliases.filter((alias) => alias.toLowerCase().includes(platform.arch.toLowerCase())).some((alias) => name.includes(alias.toLowerCase())); }); const candidates = platformCandidates.length > 0 ? platformCandidates : (() => { // A generic single-platform archive is useful for small private feeds, // but never let an explicitly named foreign architecture through. if (release.assets.length !== 1) return []; const name = release.assets[0]!.name.toLowerCase(); const knownArchitecture = /(?:^|[-_.])(x64|amd64|x86_64|arm64|aarch64|armv7|armhf|i386|i686|ia32)(?:[-_.]|$)/.test(name); return name.includes(platform.os.toLowerCase()) && !knownArchitecture ? [release.assets[0]!] : []; })(); candidates.sort((a, b) => { const target = platform.target.toLowerCase(); return Number(b.name.toLowerCase().includes(target)) - Number(a.name.toLowerCase().includes(target)); }); const normalizedRuntimeHash = runtimeHash?.trim().toLowerCase(); if (normalizedRuntimeHash && /^[a-f0-9]{64}$/.test(normalizedRuntimeHash)) { const applicationUpdate = candidates.find((asset) => applicationUpdateRuntimeHash(asset.name) === normalizedRuntimeHash); if (applicationUpdate) return applicationUpdate; } return candidates.find((asset) => !applicationUpdateRuntimeHash(asset.name)); } export function sanitizeAssetName(value: string): string { const normalized = value.normalize("NFKC").replaceAll("\\", "/"); const name = path.posix.basename(normalized); if (!name || name === "." || name === ".." || name !== normalized || name.includes("\0") || name.length > 200 || /[\u0000-\u001f\u007f]/.test(name)) throw new Error("更新文件名无效"); return name; } export async function sha256File(filePath: string): Promise { const hash = createHash("sha256"); await pipeline(createReadStream(filePath), new Transform({ transform(chunk, _encoding, callback) { hash.update(chunk); callback(null, chunk); } }), new Transform({ transform(_chunk, _encoding, callback) { callback(); } })); return hash.digest("hex"); } export async function verifySha256(filePath: string, expected: string): Promise { const normalized = expected.trim().toLowerCase(); if (!/^[a-f0-9]{64}$/.test(normalized)) throw new Error("SHA-256 校验值无效"); return (await sha256File(filePath)) === normalized; } export async function downloadReleaseAsset( url: string | URL, destination: string, options: UrlPolicy & UpdateFetchOptions & { onProgress?: ((downloadedBytes: number, totalBytes: number | null) => void) | undefined } = {}, ): Promise<{ size: number; sha256: string }> { const fetchImpl = options.fetchImpl ?? fetch; let current = validateHttpsUrl(url, options); const redirectPolicy: UrlPolicy & { fetchImpl?: typeof fetch | undefined; maxBytes?: number | undefined } = options.allowedHosts?.length || options.baseUrl ? options : { ...options, baseUrl: current }; const maxRedirects = options.maxRedirects ?? 3; let response: Response; for (let redirects = 0; ; redirects += 1) { const request = beginUpdateRequest(options); try { response = await fetchImpl(current, { method: "GET", redirect: "manual", signal: request.signal }); } catch { request.clear(); throw new Error("更新文件下载失败"); } if (response.status < 300 || response.status >= 400) { request.clear(); break; } await cancelResponseBody(response); request.clear(); if (redirects >= maxRedirects) throw new Error("更新文件下载失败"); const location = response.headers.get("location"); if (!location) throw new Error("更新文件下载失败"); current = validateHttpsUrl(new URL(location, current), redirectPolicy); } if (response.status < 200 || response.status >= 300 || !response.body) { await cancelResponseBody(response); throw new Error("更新文件下载失败"); } const declared = Number(response.headers.get("content-length") ?? 0); const totalBytes = Number.isSafeInteger(declared) && declared > 0 ? declared : null; const maxBytes = options.maxBytes ?? 512 * 1024 * 1024; if (declared > maxBytes) { await cancelResponseBody(response); throw new Error("更新文件超过大小限制"); } await mkdir(path.dirname(destination), { recursive: true, mode: 0o700 }); const temporary = `${destination}.part-${randomUUID()}`; let size = 0; const hash = createHash("sha256"); const meter = new Transform({ transform(chunk: Buffer, _encoding, callback) { size += chunk.length; options.onProgress?.(size, totalBytes); if (size > maxBytes) return callback(new Error("更新文件超过大小限制")); hash.update(chunk); callback(null, chunk); } }); const request = beginUpdateRequest(options); try { const source = Readable.fromWeb(response.body as import("node:stream/web").ReadableStream, { signal: request.signal }); await pipeline(source, meter, createWriteStream(temporary, { flags: "wx", mode: 0o600 })); const fd = await open(temporary, "r"); await fd.sync(); await fd.close(); await rename(temporary, destination); } catch (error) { await import("node:fs/promises").then(({ rm }) => rm(temporary, { force: true })).catch(() => undefined); throw error instanceof Error && error.message.startsWith("更新文件") ? error : new Error("更新文件下载失败"); } finally { request.clear(); } return { size, sha256: hash.digest("hex") }; } function tarField(value: string, length: number): Buffer { const output = Buffer.alloc(length, 0); Buffer.from(value, "utf8").copy(output, 0, 0, length); return output; } function tarOctal(value: number, length: number): Buffer { const text = value.toString(8).padStart(length - 1, "0").slice(-(length - 1)); return Buffer.from(`${text}\0`, "ascii"); } function tarHeader(name: string, size: number, mode: number, directory: boolean): Buffer { let nameField = name; let prefixField = ""; if (Buffer.byteLength(name) > 100) { const slash = name.lastIndexOf("/"); if (slash <= 0 || Buffer.byteLength(name.slice(0, slash)) > 155 || Buffer.byteLength(name.slice(slash + 1)) > 100) throw new Error("归档路径过长"); prefixField = name.slice(0, slash); nameField = name.slice(slash + 1); } const header = Buffer.alloc(512, 0); tarField(nameField, 100).copy(header, 0); tarOctal(mode & 0o777, 8).copy(header, 100); tarOctal(0, 8).copy(header, 108); tarOctal(0, 8).copy(header, 116); tarOctal(size, 12).copy(header, 124); tarOctal(Math.floor(Date.now() / 1000), 12).copy(header, 136); Buffer.from(" ", "ascii").copy(header, 148); header[156] = directory ? 0x35 : 0x30; tarField("ustar\0", 6).copy(header, 257); tarField("00", 2).copy(header, 263); tarField(prefixField, 155).copy(header, 345); let checksum = 0; for (const byte of header) checksum += byte; tarOctal(checksum, 8).copy(header, 148); return header; } export type SafeArchiveOptions = { maxEntries?: number; maxBytes?: number; }; async function writeArchiveChunk(stream: Transform, chunk: Buffer): Promise { if (stream.write(chunk)) return; await new Promise((resolve, reject) => { const onDrain = () => { cleanup(); resolve(); }; const onError = (error: Error) => { cleanup(); reject(error); }; const cleanup = () => { stream.off("drain", onDrain); stream.off("error", onError); }; stream.once("drain", onDrain); stream.once("error", onError); }); } export async function createSafeArchive(sourceDir: string, archivePath: string, options: SafeArchiveOptions = {}): Promise { const root = path.resolve(sourceDir); const archiveResolved = path.resolve(archivePath); if (archiveResolved === root || archiveResolved.startsWith(`${root}${path.sep}`)) throw new Error("归档目标不能位于源目录内"); const maxEntries = options.maxEntries ?? 100_000; const maxBytes = options.maxBytes ?? 2 * 1024 * 1024 * 1024; if (!Number.isSafeInteger(maxEntries) || maxEntries <= 0 || !Number.isSafeInteger(maxBytes) || maxBytes <= 0) throw new Error("归档限制无效"); let entries = 0; let total = 0; const archiveParent = path.resolve(path.dirname(archiveResolved)); const archiveInfo = await lstat(archiveResolved).catch(() => null); if (archiveInfo?.isSymbolicLink() || (archiveInfo && !archiveInfo.isFile())) throw new Error("归档目标文件无效"); await mkdir(archiveParent, { recursive: true, mode: 0o700 }); await assertPrivateDirectory(archiveParent); const temporary = `${archiveResolved}.part-${randomUUID()}`; let gzip: Transform | undefined; let output: ReturnType | undefined; let renamed = false; const walk = async (directory: string, prefix: string): Promise => { const directoryEntries = await readdir(directory, { withFileTypes: true }); directoryEntries.sort((a, b) => a.name.localeCompare(b.name)); for (const entry of directoryEntries) { const target = path.join(directory, entry.name); const relative = prefix ? `${prefix}/${entry.name}` : entry.name; const info = await lstat(target); if (info.isSymbolicLink()) throw new Error("归档不允许符号链接"); entries += 1; if (entries > maxEntries) throw new Error("归档条目过多"); if (info.isDirectory()) { await assertPrivateDirectory(target); await writeArchiveChunk(gzip!, tarHeader(`${relative}/`, 0, 0o700, true)); await walk(target, relative); } else if (info.isFile()) { const handle = await open(target, "r"); try { const current = await handle.stat(); if (!current.isFile() || !Number.isSafeInteger(current.size) || current.size < 0) throw new Error("归档源文件无效"); if (current.size > maxBytes - total) throw new Error("归档超过大小限制"); total += current.size; await writeArchiveChunk(gzip!, tarHeader(relative, current.size, 0o600, false)); let position = 0; while (position < current.size) { const chunk = Buffer.allocUnsafe(Math.min(64 * 1024, current.size - position)); const result = await handle.read(chunk, 0, chunk.length, position); if (result.bytesRead <= 0) throw new Error("归档源文件读取失败"); position += result.bytesRead; await writeArchiveChunk(gzip!, chunk.subarray(0, result.bytesRead)); } const remainder = current.size % 512; if (remainder) await writeArchiveChunk(gzip!, Buffer.alloc(512 - remainder)); } finally { await handle.close().catch(() => undefined); } } else { throw new Error("归档包含不受支持的文件类型"); } } }; const info = await lstat(root); if (!info.isDirectory()) throw new Error("归档源目录无效"); await assertPrivateDirectory(root); try { output = createWriteStream(temporary, { flags: "wx", mode: 0o600 }); gzip = createGzip({ level: 6 }); gzip.pipe(output); await walk(root, ""); await writeArchiveChunk(gzip, Buffer.alloc(1024)); gzip.end(); await finished(output); const handle = await open(temporary, "r"); await handle.sync(); await handle.close(); await chmod(temporary, 0o600); await rename(temporary, archiveResolved); renamed = true; } finally { if (gzip && !gzip.destroyed) gzip.destroy(); if (output && !output.destroyed) output.destroy(); if (!renamed) await rm(temporary, { force: true }).catch(() => undefined); } } function safeArchiveEntry(entryName: string): string { const name = entryName.replaceAll("\\", "/"); if (!name || name.startsWith("/") || /^[A-Za-z]:\//.test(name) || name.includes("\0")) throw new Error("归档包含不安全路径"); const normalized = path.posix.normalize(name); // GNU/BSD tar commonly emits a harmless `./` root directory entry. if (normalized === "." || normalized === "./") return ""; if (normalized === ".." || normalized.startsWith("../") || normalized.includes("/../")) throw new Error("归档包含不安全路径"); return normalized.replace(/\/$/, ""); } async function assertPrivateDirectory(directory: string): Promise { const info = await lstat(directory).catch(() => null); if (!info || info.isSymbolicLink() || !info.isDirectory()) throw new Error("归档目标目录无效"); // A sticky world-writable parent such as /tmp is acceptable for a freshly // created mkdtemp workspace. Non-sticky group/other writable directories // are not: a local user could replace a path between validation and use. if ((info.mode & 0o022) !== 0 && (info.mode & 0o1000) === 0) throw new Error("归档目标目录权限过宽"); } async function ensureArchiveParent(root: string, target: string): Promise { await assertPrivateDirectory(root); const relative = path.relative(root, path.dirname(target)); let current = root; for (const component of relative ? relative.split(path.sep) : []) { current = path.join(current, component); const info = await lstat(current).catch(() => null); if (info?.isSymbolicLink() || (info && !info.isDirectory())) throw new Error("归档目标目录无效"); if (!info) { await mkdir(current, { mode: 0o700 }); await chmod(current, 0o700); } else { await assertPrivateDirectory(current); } } } async function extractSafeZip(archivePath: string, destinationDir: string, options: { maxEntries?: number; maxBytes?: number }): Promise { const maxEntries = options.maxEntries ?? 100_000; const maxBytes = options.maxBytes ?? 512 * 1024 * 1024; const root = path.resolve(destinationDir); const rootInfo = await lstat(root).catch(() => null); if (rootInfo?.isSymbolicLink() || (rootInfo && !rootInfo.isDirectory())) throw new Error("归档目标目录无效"); await mkdir(root, { recursive: true, mode: 0o700 }); await new Promise((resolve, reject) => { yauzl.open(archivePath, { lazyEntries: true, validateEntrySizes: true }, (error, zip) => { if (error || !zip) return reject(new Error("归档结构无效")); let entries = 0; let total = 0; let settled = false; const fail = (reason: unknown) => { if (!settled) { settled = true; zip.close(); reject(reason instanceof Error ? reason : new Error("归档结构无效")); } }; zip.on("error", fail); zip.on("entry", (entry) => { if (settled) return; entries += 1; if (entries > maxEntries) return fail(new Error("归档条目过多")); let name: string; try { name = safeArchiveEntry(entry.fileName); } catch (reason) { return fail(reason); } const mode = (entry.externalFileAttributes >>> 16) & 0xffff; if ((mode & 0o170000) === 0o120000) return fail(new Error("归档不允许符号链接")); const target = path.resolve(root, name); if (name && !target.startsWith(`${root}${path.sep}`)) return fail(new Error("归档包含不安全路径")); const directory = entry.fileName.endsWith("/") || (mode & 0o170000) === 0o040000; if (directory) { if (!Number.isSafeInteger(entry.uncompressedSize) || entry.uncompressedSize !== 0) return fail(new Error("归档目录条目结构无效")); const prepare = name ? ensureArchiveParent(root, target) : Promise.resolve(); prepare.then(async () => { const existing = await lstat(target).catch(() => null); if (existing?.isSymbolicLink() || (existing && !existing.isDirectory())) throw new Error("归档目标目录无效"); if (!existing) await mkdir(target, { mode: 0o700 }); zip.readEntry(); }).catch(fail); return; } if (!Number.isSafeInteger(entry.uncompressedSize) || entry.uncompressedSize < 0 || entry.uncompressedSize > maxBytes - total) return fail(new Error("归档超过大小限制")); total += entry.uncompressedSize; if (!name) return fail(new Error("归档文件名无效")); ensureArchiveParent(root, target).then(() => new Promise((resolveEntry, rejectEntry) => { zip.openReadStream(entry, (streamError, stream) => { if (streamError || !stream) return rejectEntry(new Error("归档结构无效")); pipeline(stream, createWriteStream(target, { mode: 0o600, flags: "wx" })).then(resolveEntry).catch(rejectEntry); }); })).then(() => { zip.readEntry(); }).catch(fail); }); zip.readEntry(); zip.once("end", () => { if (!settled) { settled = true; resolve(); } }); }); }); } /** * Read an archive incrementally. The previous implementation read the whole * gzip and then called gunzipSync, which let a tiny gzip bomb allocate an * unbounded amount of memory before the expanded-size limit was checked. */ class ArchiveStreamReader { private readonly iterator: AsyncIterator; private buffered = Buffer.alloc(0) as Buffer; private done = false; constructor(private readonly stream: Readable) { this.iterator = stream[Symbol.asyncIterator](); } private async fill(minimum: number): Promise { while (!this.done && this.buffered.length < minimum) { const next = await this.iterator.next(); if (next.done) { this.done = true; break; } const chunk = Buffer.isBuffer(next.value) ? next.value : Buffer.from(next.value); if (chunk.length === 0) continue; this.buffered = this.buffered.length === 0 ? chunk : Buffer.concat([this.buffered, chunk]); } } async read(length: number): Promise { if (!Number.isSafeInteger(length) || length < 0) throw new Error("归档读取长度无效"); if (length === 0) return Buffer.alloc(0); await this.fill(length); if (this.buffered.length === 0 && this.done) return null; if (this.buffered.length < length) throw new Error("归档结构无效"); const result = this.buffered.subarray(0, length); this.buffered = this.buffered.subarray(length); return result; } async discard(length: number): Promise { let remaining = length; while (remaining > 0) { const chunk = await this.read(Math.min(remaining, 64 * 1024)); if (!chunk) throw new Error("归档结构无效"); remaining -= chunk.length; } } async copyToFile(length: number, target: string): Promise { const handle = await open(target, "wx", 0o600); let complete = false; try { let remaining = length; while (remaining > 0) { const chunk = await this.read(Math.min(remaining, 64 * 1024)); if (!chunk) throw new Error("归档结构无效"); let written = 0; while (written < chunk.length) { const result = await handle.write(chunk, written, chunk.length - written); if (result.bytesWritten <= 0) throw new Error("归档写入失败"); written += result.bytesWritten; } remaining -= chunk.length; } await handle.sync(); complete = true; } finally { await handle.close().catch(() => undefined); if (!complete) await rm(target, { force: true }).catch(() => undefined); } } } function parsePaxPath(payload: Buffer): string | undefined { let offset = 0; let pathValue: string | undefined; while (offset < payload.length) { const space = payload.indexOf(0x20, offset); if (space <= offset) throw new Error("归档扩展头无效"); const lengthText = payload.subarray(offset, space).toString("ascii"); if (!/^\d+$/.test(lengthText)) throw new Error("归档扩展头无效"); const length = Number(lengthText); if (!Number.isSafeInteger(length) || length <= space - offset + 2 || offset + length > payload.length) throw new Error("归档扩展头无效"); const record = payload.subarray(offset, offset + length); if (record[record.length - 1] !== 0x0a) throw new Error("归档扩展头无效"); const equals = record.indexOf(0x3d, space - offset + 1); if (equals < 0) throw new Error("归档扩展头无效"); const key = record.subarray(space - offset + 1, equals).toString("utf8"); if (key === "path") pathValue = record.subarray(equals + 1, record.length - 1).toString("utf8"); offset += length; } return pathValue; } async function readTarMetadata(reader: ArchiveStreamReader, size: number, maxBytes: number): Promise { // Extended headers only carry names and metadata. A small hard cap keeps a // malformed header from turning into another allocation vector. if (size > Math.min(maxBytes, 4 * 1024 * 1024)) throw new Error("归档扩展头过大"); const payload = await reader.read(size); if (!payload) throw new Error("归档结构无效"); await reader.discard((512 - (size % 512)) % 512); return payload; } async function extractSafeTar(stream: Readable, destinationDir: string, options: { maxEntries?: number; maxBytes?: number }): Promise { const maxEntries = options.maxEntries ?? 100_000; const maxBytes = options.maxBytes ?? 512 * 1024 * 1024; const root = path.resolve(destinationDir); const rootInfo = await lstat(root).catch(() => null); if (rootInfo?.isSymbolicLink() || (rootInfo && !rootInfo.isDirectory())) throw new Error("归档目标目录无效"); await mkdir(root, { recursive: true, mode: 0o700 }); const reader = new ArchiveStreamReader(stream); let entries = 0; let total = 0; let globalPath: string | undefined; let pendingPath: string | undefined; let terminated = false; try { while (true) { const header = await reader.read(512); if (!header) throw new Error("归档结构无效"); if (header.every((value) => value === 0)) { terminated = true; break; } const storedChecksum = Number.parseInt(header.subarray(148, 156).toString("ascii").replace(/[\0 ]/g, ""), 8); let checksum = 0; for (let index = 0; index < header.length; index += 1) checksum += index >= 148 && index < 156 ? 0x20 : header[index]!; if (!Number.isFinite(storedChecksum) || checksum !== storedChecksum) throw new Error("归档校验失败"); entries += 1; if (entries > maxEntries) throw new Error("归档条目过多"); const sizeText = header.subarray(124, 136).toString("ascii").replace(/\0.*$/, "").trim(); const size = sizeText ? Number.parseInt(sizeText, 8) : 0; if (!Number.isSafeInteger(size) || size < 0) throw new Error("归档结构无效"); const type = header[156]; if (type === 0x78 || type === 0x67 || type === 0x4c || type === 0x4b) { total += size; if (!Number.isSafeInteger(total) || total > maxBytes) throw new Error("归档超过大小限制"); const payload = await readTarMetadata(reader, size, maxBytes); if (type === 0x4c) { const end = payload.indexOf(0); pendingPath = payload.subarray(0, end < 0 ? payload.length : end).toString("utf8"); } else if (type === 0x4b) { // Hard/symbolic links are intentionally unsupported. Reject the // GNU long-link record instead of carrying it into a later entry. throw new Error("归档不允许链接"); } else { const extendedPath = parsePaxPath(payload); if (type === 0x67) globalPath = extendedPath; else if (extendedPath !== undefined) pendingPath = extendedPath; } continue; } const namePart = header.subarray(0, 100).toString("utf8").replace(/\0.*$/, ""); const prefixPart = header.subarray(345, 500).toString("utf8").replace(/\0.*$/, ""); const rawName = pendingPath ?? globalPath ?? (prefixPart ? `${prefixPart}/${namePart}` : namePart); pendingPath = undefined; const name = safeArchiveEntry(rawName); const target = path.resolve(root, name); if (name && !target.startsWith(`${root}${path.sep}`)) throw new Error("归档包含不安全路径"); if (type === 0x35) { if (size !== 0) throw new Error("归档目录条目结构无效"); if (name) await ensureArchiveParent(root, target); const existing = await lstat(target).catch(() => null); if (existing?.isSymbolicLink() || (existing && !existing.isDirectory())) throw new Error("归档目标目录无效"); if (!existing) await mkdir(target, { mode: 0o700 }); } else if (type === 0x30 || type === 0) { if (!name) throw new Error("归档文件名无效"); await ensureArchiveParent(root, target); const parent = await lstat(path.dirname(target)); if (!parent.isDirectory()) throw new Error("归档目标目录无效"); if (size > maxBytes - total) throw new Error("归档超过大小限制"); total += size; await reader.copyToFile(size, target); } else { throw new Error("归档包含不受支持的文件类型"); } await reader.discard((512 - (size % 512)) % 512); } } finally { stream.destroy(); } if (!terminated) throw new Error("归档结构无效"); } async function readArchivePrefix(archivePath: string, length: number): Promise { const handle = await open(archivePath, "r"); try { const buffer = Buffer.alloc(length); const result = await handle.read(buffer, 0, length, 0); return buffer.subarray(0, result.bytesRead); } finally { await handle.close().catch(() => undefined); } } /** Make a staged release readable by the unprivileged systemd service. */ export async function normalizeReleasePermissions(rootPath: string): Promise { const root = path.resolve(rootPath); const rootInfo = await lstat(root).catch(() => null); if (!rootInfo?.isDirectory() || rootInfo.isSymbolicLink()) throw new Error("发布目录无效"); const walk = async (directory: string): Promise => { await chmod(directory, 0o755); const entries = await readdir(directory, { withFileTypes: true }); for (const entry of entries) { const target = path.join(directory, entry.name); if (entry.isSymbolicLink()) throw new Error("发布包不允许符号链接"); if (entry.isDirectory()) { await walk(target); } else if (entry.isFile()) { const relative = path.relative(root, target).split(path.sep).join("/"); const executable = relative.startsWith("bin/") || relative.startsWith("scripts/"); await chmod(target, executable ? 0o755 : 0o644); } else { throw new Error("发布包包含不受支持的文件类型"); } } }; await walk(root); } export async function extractSafeArchive(archivePath: string, destinationDir: string, options: { maxEntries?: number; maxBytes?: number } = {}): Promise { const archiveInfo = await lstat(archivePath).catch(() => null); if (!archiveInfo?.isFile() || archiveInfo.isSymbolicLink()) throw new Error("归档文件无效"); const destinationInfo = await lstat(destinationDir).catch(() => null); const prefix = await readArchivePrefix(archivePath, 512); try { if (prefix.subarray(0, 4).equals(Buffer.from([0x50, 0x4b, 0x03, 0x04]))) { await extractSafeZip(archivePath, destinationDir, options); return; } if (prefix.subarray(0, 2).equals(Buffer.from([0x1f, 0x8b]))) { await extractSafeTar(createReadStream(archivePath).pipe(createGunzip()), destinationDir, options); return; } if (prefix.subarray(257, 262).toString("ascii") !== "ustar") throw new Error("归档格式无效"); await extractSafeTar(createReadStream(archivePath), destinationDir, options); } catch (error) { // The updater normally uses a disposable workspace. Keep the public helper // equally tidy when it created the destination itself. if (!destinationInfo) await rm(destinationDir, { recursive: true, force: true }).catch(() => undefined); throw error; } } export const archiveDirectory = createSafeArchive; export const backupDirectory = createSafeArchive; export async function atomicSwitchDirectory(stagedDir: string, currentDir: string, backupDir?: string): Promise { const staged = path.resolve(stagedDir); const current = path.resolve(currentDir); if (staged === current) throw new Error("更新目录无效"); const stagedInfo = await lstat(staged).catch(() => null); if (!stagedInfo?.isDirectory() || stagedInfo.isSymbolicLink()) throw new Error("更新暂存目录无效"); const currentInfo = await lstat(current).catch(() => null); if (currentInfo && (!currentInfo.isDirectory() || currentInfo.isSymbolicLink())) throw new Error("当前安装目录无效"); const backup = backupDir ? path.resolve(backupDir) : path.join(path.dirname(current), `.backup-${Date.now()}-${randomUUID()}`); if (await lstat(backup).catch(() => null)) throw new Error("备份目录已存在"); const backupParent = path.resolve(path.dirname(backup)); await mkdir(backupParent, { recursive: true, mode: 0o700 }); await assertPrivateDirectory(backupParent); await assertPrivateDirectory(path.dirname(current)); if (currentInfo) await rename(current, backup); try { await rename(staged, current); } catch (error) { if (currentInfo) { try { await rename(backup, current); } catch { throw new Error("更新目录切换失败,旧版本恢复失败"); } } throw new Error("更新目录切换失败"); } return currentInfo ? backup : undefined; } /** Atomically publish a release in the installer layout (`current` symlink). * The old release is intentionally retained for rollback; only the link is * replaced, so the active data directory is never moved or overwritten. */ export async function atomicSwitchRelease( stagedDir: string, currentLink: string, releasesDir: string, version: string, ): Promise<{ previousTarget?: string; publishedTarget: string }> { const staged = path.resolve(stagedDir); const link = path.resolve(currentLink); const releases = path.resolve(releasesDir); const parsed = parseSemver(version); const normalizedVersion = `${parsed.major}.${parsed.minor}.${parsed.patch}${parsed.prerelease.length ? `-${parsed.prerelease.join(".")}` : ""}${parsed.build.length ? `+${parsed.build.join(".")}` : ""}`; const target = path.join(releases, normalizedVersion); if (!target.startsWith(`${releases}${path.sep}`)) throw new Error("更新版本目录无效"); const stagedInfo = await lstat(staged).catch(() => null); if (!stagedInfo?.isDirectory() || stagedInfo.isSymbolicLink()) throw new Error("更新暂存目录无效"); const releasesInfo = await lstat(releases).catch(() => null); if (releasesInfo?.isSymbolicLink() || (releasesInfo && !releasesInfo.isDirectory())) throw new Error("发布目录无效"); await mkdir(releases, { recursive: true, mode: 0o755 }); await assertPrivateDirectory(releases); await assertPrivateDirectory(path.dirname(releases)); if (await lstat(target).catch(() => null)) throw new Error("该版本已经安装"); const currentInfo = await lstat(link).catch(() => null); if (currentInfo && !currentInfo.isSymbolicLink()) throw new Error("当前安装目录无效"); await assertPrivateDirectory(path.dirname(link)); let previousTarget: string | undefined; if (currentInfo?.isSymbolicLink()) { const raw = await readlink(link); const resolvedPrevious = path.resolve(path.dirname(link), raw); if (!resolvedPrevious.startsWith(`${releases}${path.sep}`)) throw new Error("当前发布链接无效"); previousTarget = path.relative(path.dirname(link), resolvedPrevious) || "."; } await rename(staged, target); const temporaryLink = path.join(path.dirname(link), `.current-${process.pid}-${randomUUID()}.tmp`); let linkCommitted = false; try { await symlink(target, temporaryLink); await rename(temporaryLink, link); linkCommitted = true; const parent = await open(path.dirname(link), "r"); try { await parent.sync(); } finally { await parent.close(); } } catch (error) { await rm(temporaryLink, { force: true }).catch(() => undefined); if (linkCommitted) { // The link may already be visible when the directory fsync fails. Put // the old link back before removing the new target; otherwise a crash // recovery path could leave `current` dangling. try { if (previousTarget) { const rollbackLink = path.join(path.dirname(link), `.current-rollback-${process.pid}-${randomUUID()}.tmp`); const previousAbsolute = path.resolve(path.dirname(link), previousTarget); await symlink(previousAbsolute, rollbackLink); await rename(rollbackLink, link); } else { await rm(link, { force: true }); } } catch { // Never delete a target which may still be referenced by `current`. throw new Error("更新目录切换失败,旧版本恢复失败"); } } await rm(target, { recursive: true, force: true }).catch(() => undefined); throw error instanceof Error && error.message === "当前发布链接无效" ? error : new Error("更新目录切换失败"); } return { ...(previousTarget ? { previousTarget } : {}), publishedTarget: target }; } // Compatibility aliases for callers that prefer verb-oriented names. export const getReleaseMetadata = fetchReleaseMetadata; export const compareVersions = compareSemver; export const getCurrentPlatform = detectPlatform; export const downloadFile = downloadReleaseAsset; export const verifyFileSha256 = verifySha256; export const safeExtractArchive = extractSafeArchive; export const switchDirectoryAtomically = atomicSwitchDirectory;