This commit is contained in:
+22
-2
@@ -1,5 +1,5 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { lstat, mkdir, mkdtemp, readFile, realpath, rm } from "node:fs/promises";
|
||||
import { cp, lstat, mkdir, mkdtemp, readFile, realpath, rm } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import type Database from "better-sqlite3";
|
||||
@@ -10,6 +10,7 @@ import { writeAudit } from "../audit.js";
|
||||
import {
|
||||
atomicSwitchDirectory,
|
||||
atomicSwitchRelease,
|
||||
applicationUpdateRuntimeHash,
|
||||
compareSemver,
|
||||
createSafeArchive,
|
||||
detectPlatform,
|
||||
@@ -19,6 +20,7 @@ import {
|
||||
isNewerVersion,
|
||||
normalizeReleasePermissions,
|
||||
parseSemver,
|
||||
runtimeHashFromLockfile,
|
||||
selectReleaseAsset,
|
||||
sanitizeAssetName,
|
||||
validateHttpsUrl,
|
||||
@@ -212,9 +214,16 @@ async function resolveRelease(options: UpdateRunOptions, platform: ReturnType<ty
|
||||
if (options.metadataUrl) {
|
||||
const metadataUrl = validateHttpsUrl(options.metadataUrl, options);
|
||||
const release = await fetchReleaseMetadata(metadataUrl, options);
|
||||
let runtimeHash: string | undefined;
|
||||
try {
|
||||
runtimeHash = runtimeHashFromLockfile(await readFile(path.join(options.currentDir, "pnpm-lock.yaml")));
|
||||
} catch {
|
||||
// Fall back to the full archive when the current installation predates
|
||||
// runtime fingerprints or is missing deployment provenance.
|
||||
}
|
||||
let asset = options.assetUrl && !options.requireSignature
|
||||
? { name: sanitizeAssetName(options.assetName ?? path.basename(new URL(options.assetUrl).pathname)), url: validateHttpsUrl(options.assetUrl, { ...options, baseUrl: metadataUrl }).toString(), ...(options.expectedSha256 ? { sha256: options.expectedSha256 } : {}) }
|
||||
: selectReleaseAsset(release, platform);
|
||||
: selectReleaseAsset(release, platform, runtimeHash);
|
||||
if (!asset) throw new Error("没有匹配当前平台的更新文件");
|
||||
const integrity = await attachSidecarHash(release, asset, {
|
||||
allowedHosts: options.allowedHosts ?? [],
|
||||
@@ -314,6 +323,17 @@ export async function runUpdate(options: UpdateRunOptions): Promise<UpdateRunRes
|
||||
if (!archivePath.endsWith(".tar.gz") && !archivePath.endsWith(".tgz") && !archivePath.endsWith(".tar") && !archivePath.endsWith(".zip")) throw new Error("更新文件格式仅支持 tar.gz、tar 或 zip");
|
||||
const stagedDir = path.join(workspace, "payload");
|
||||
await extractSafeArchive(archivePath, stagedDir, options.maxBytes === undefined ? {} : { maxBytes: options.maxBytes });
|
||||
if (applicationUpdateRuntimeHash(resolved.asset.name)) {
|
||||
const currentRelease = await realpath(options.currentDir).catch(() => { throw new Error("当前安装目录无效"); });
|
||||
const currentInfo = await lstat(currentRelease).catch(() => null);
|
||||
if (!currentInfo?.isDirectory() || currentInfo.isSymbolicLink()) throw new Error("当前安装目录无效");
|
||||
for (const entry of ["node_modules", "runtime", "pnpm-lock.yaml"] as const) {
|
||||
const source = path.join(currentRelease, entry);
|
||||
const sourceInfo = await lstat(source).catch(() => null);
|
||||
if (!sourceInfo || sourceInfo.isSymbolicLink()) throw new Error("当前运行时不完整,无法应用轻量更新");
|
||||
await cp(source, path.join(stagedDir, entry), { recursive: sourceInfo.isDirectory(), errorOnExist: true, force: false });
|
||||
}
|
||||
}
|
||||
await normalizeReleasePermissions(stagedDir);
|
||||
const payloadInfo = await lstat(path.join(stagedDir, "dist")).catch(() => null);
|
||||
if (!payloadInfo?.isDirectory() || payloadInfo.isSymbolicLink()) throw new Error("发布包缺少 dist 目录");
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
fetchReleaseText,
|
||||
isNewerVersion,
|
||||
parseSemver,
|
||||
runtimeHashFromLockfile,
|
||||
sanitizeAssetName,
|
||||
selectReleaseAsset,
|
||||
validateHttpsUrl,
|
||||
@@ -204,7 +205,14 @@ export async function checkForUpdate(database: Database.Database, config: AppCon
|
||||
} catch {
|
||||
throw new AppError(502, "UPDATE_CHECK_FAILED", "暂时无法获取最新版本,请稍后重试");
|
||||
}
|
||||
let asset = selectReleaseAsset(metadata, platform);
|
||||
let runtimeHash: string | undefined;
|
||||
try {
|
||||
runtimeHash = runtimeHashFromLockfile(readFileSync(path.join(config.projectRoot, "pnpm-lock.yaml")));
|
||||
} catch {
|
||||
// Legacy or source installations may not contain the lockfile. They stay
|
||||
// on the full release asset instead of risking an incompatible runtime.
|
||||
}
|
||||
let asset = selectReleaseAsset(metadata, platform, runtimeHash);
|
||||
let signatureVerified = false;
|
||||
if (asset) {
|
||||
const integrity = await attachSidecarHash(metadata, asset, {
|
||||
|
||||
+20
-2
@@ -43,6 +43,16 @@ export type ReleaseMetadata = {
|
||||
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;
|
||||
@@ -379,7 +389,7 @@ export async function fetchReleaseBytes(
|
||||
}
|
||||
}
|
||||
|
||||
export function selectReleaseAsset(release: ReleaseMetadata, platform = detectPlatform()): ReleaseAsset | undefined {
|
||||
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()));
|
||||
@@ -398,7 +408,15 @@ export function selectReleaseAsset(release: ReleaseMetadata, platform = detectPl
|
||||
const target = platform.target.toLowerCase();
|
||||
return Number(b.name.toLowerCase().includes(target)) - Number(a.name.toLowerCase().includes(target));
|
||||
});
|
||||
return candidates[0];
|
||||
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;
|
||||
}
|
||||
// Older clients choose the first matching asset. Releases therefore keep
|
||||
// the traditional full archive first, while current clients explicitly
|
||||
// opt into a compatible application-only asset.
|
||||
return candidates.find((asset) => !applicationUpdateRuntimeHash(asset.name));
|
||||
}
|
||||
|
||||
export function sanitizeAssetName(value: string): string {
|
||||
|
||||
Reference in New Issue
Block a user