993 lines
46 KiB
TypeScript
993 lines
46 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;
|
|
publishedAt?: string;
|
|
assets: ReleaseAsset[];
|
|
};
|
|
|
|
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;
|
|
};
|
|
|
|
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;
|
|
|
|
/** Read a fetch body without ever buffering more than the caller's bound. */
|
|
async function readBoundedResponse(response: Response, maxBytes: number, tooLargeMessage: string): 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) throw new Error(tooLargeMessage);
|
|
}
|
|
if (!response.body) return Buffer.alloc(0);
|
|
const reader = response.body.getReader();
|
|
const chunks: Buffer[] = [];
|
|
let total = 0;
|
|
try {
|
|
for (;;) {
|
|
const result = await 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);
|
|
}
|
|
} finally {
|
|
reader.releaseLock();
|
|
}
|
|
return Buffer.concat(chunks, total);
|
|
}
|
|
|
|
export async function fetchReleaseMetadata(
|
|
metadataUrl: string | URL,
|
|
options: UrlPolicy & { fetchImpl?: typeof fetch | undefined; maxBytes?: number | undefined } = {},
|
|
): 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) {
|
|
try {
|
|
response = await fetchImpl(current, { method: "GET", redirect: "manual", headers: { accept: "application/json" } });
|
|
} catch {
|
|
throw metadataError();
|
|
}
|
|
if (response.status < 300 || response.status >= 400) break;
|
|
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 });
|
|
}
|
|
if (response.status < 200 || response.status >= 300) throw metadataError();
|
|
let payload: unknown;
|
|
try {
|
|
const maxBytes = Math.min(options.maxBytes ?? DEFAULT_METADATA_MAX_BYTES, DEFAULT_METADATA_MAX_BYTES);
|
|
const body = await readBoundedResponse(response, maxBytes, "更新发布信息过大");
|
|
payload = JSON.parse(body.toString("utf8"));
|
|
} catch { throw metadataError(); }
|
|
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") {
|
|
try {
|
|
if (compareSemver(version, item.tag_name) !== 0) throw metadataError();
|
|
} catch {
|
|
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: validateHttpsUrl(url, { ...options, baseUrl: current }).toString(), ...(sha256 ? { sha256 } : {}), ...(typeof asset.size === "number" && Number.isSafeInteger(asset.size) && asset.size >= 0 ? { size: asset.size } : {}) });
|
|
}
|
|
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 } : {}),
|
|
...(typeof item.published_at === "string" ? { publishedAt: item.published_at } : {}),
|
|
assets,
|
|
};
|
|
}
|
|
|
|
/** 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 & { fetchImpl?: typeof fetch | undefined; maxBytes?: number | undefined } = {},
|
|
): 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) {
|
|
try {
|
|
response = await fetchImpl(current, { method: "GET", redirect: "manual" });
|
|
} catch {
|
|
throw new Error("更新校验文件下载失败");
|
|
}
|
|
if (response.status < 300 || response.status >= 400) break;
|
|
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) throw new Error("更新校验文件下载失败");
|
|
const declared = Number(response.headers.get("content-length") ?? 0);
|
|
const maxBytes = options.maxBytes ?? 1024 * 1024;
|
|
if (declared > maxBytes) throw new Error("更新校验文件过大");
|
|
try {
|
|
return (await readBoundedResponse(response, maxBytes, "更新校验文件过大")).toString("utf8");
|
|
} catch (error) {
|
|
if (error instanceof Error && error.message === "更新校验文件过大") throw error;
|
|
throw new Error("更新校验文件下载失败");
|
|
}
|
|
}
|
|
|
|
/** 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 & { fetchImpl?: typeof fetch | undefined; maxBytes?: number | undefined } = {},
|
|
): 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) {
|
|
try {
|
|
response = await fetchImpl(current, { method: "GET", redirect: "manual" });
|
|
} catch {
|
|
throw new Error("更新签名下载失败");
|
|
}
|
|
if (response.status < 300 || response.status >= 400) break;
|
|
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) throw new Error("更新签名下载失败");
|
|
const declared = Number(response.headers.get("content-length") ?? 0);
|
|
const maxBytes = options.maxBytes ?? 64 * 1024;
|
|
if (declared > maxBytes) throw new Error("更新签名文件过大");
|
|
try {
|
|
return await readBoundedResponse(response, maxBytes, "更新签名文件过大");
|
|
} catch (error) {
|
|
if (error instanceof Error && error.message === "更新签名文件过大") throw error;
|
|
throw new Error("更新签名下载失败");
|
|
}
|
|
}
|
|
|
|
export function selectReleaseAsset(release: ReleaseMetadata, platform = detectPlatform()): 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));
|
|
});
|
|
return candidates[0];
|
|
}
|
|
|
|
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 & { fetchImpl?: typeof fetch | undefined; maxBytes?: number | 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) {
|
|
try {
|
|
response = await fetchImpl(current, { method: "GET", redirect: "manual" });
|
|
} catch {
|
|
throw new Error("更新文件下载失败");
|
|
}
|
|
if (response.status < 300 || response.status >= 400) break;
|
|
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) throw new Error("更新文件下载失败");
|
|
const declared = Number(response.headers.get("content-length") ?? 0);
|
|
const maxBytes = options.maxBytes ?? 512 * 1024 * 1024;
|
|
if (declared > maxBytes) 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;
|
|
if (size > maxBytes) return callback(new Error("更新文件超过大小限制"));
|
|
hash.update(chunk);
|
|
callback(null, chunk);
|
|
} });
|
|
try {
|
|
await pipeline(Readable.fromWeb(response.body as import("node:stream/web").ReadableStream), 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("更新文件下载失败");
|
|
}
|
|
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;
|