Files
TallyNote/server/update.ts
T
Qiufeng ed0b492461
TallyNote release / linux-x64 (push) Successful in 7m45s
fix: make online updates recoverable
2026-09-05 14:56:15 +08:00

1151 lines
53 KiB
TypeScript

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<void> {
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<string, string>)[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(/<br\s*\/?>/gi, "\n")
.replace(/<\/p\s*>/gi, "\n\n")
.replace(/<[^>]*>/g, "")
.replace(/&nbsp;/gi, " ")
.replace(/&amp;/gi, "&")
.replace(/&lt;/gi, "<")
.replace(/&gt;/gi, ">")
.replace(/&quot;/gi, '"')
.replace(/&#39;/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<Buffer> {
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<never>((_, 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<ReleaseMetadata> {
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<string, unknown>;
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<string, unknown>;
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<string> {
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<Buffer> {
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<string> {
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<boolean> {
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<void> {
if (stream.write(chunk)) return;
await new Promise<void>((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<void> {
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<typeof createWriteStream> | undefined;
let renamed = false;
const walk = async (directory: string, prefix: string): Promise<void> => {
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<void> {
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<void> {
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<void> {
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<void>((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<void>((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<Buffer | Uint8Array>;
private buffered = Buffer.alloc(0) as Buffer<ArrayBufferLike>;
private done = false;
constructor(private readonly stream: Readable) {
this.iterator = stream[Symbol.asyncIterator]();
}
private async fill(minimum: number): Promise<void> {
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<Buffer | null> {
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<void> {
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<void> {
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<Buffer> {
// 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<void> {
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<Buffer> {
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<void> {
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<void> => {
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/") || relative.startsWith("runtime/bin/");
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<void> {
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<string | undefined> {
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;