fix: 修复在线更新暂存链路并增加全局 API 限流备底
- 新增 server/rate-limit.ts:进程内固定窗口限流器,无数据库写入 - server/app.ts 注册全局 preHandler,仅作用于 /api/*,超限返回 429 与 Retry-After - 提取 isApiPath 统一 onSend、preHandler 与 404 的路径判断 - 更新任务冲突判定改用 ACTIVE_UPDATE_CONFLICT_SQL,staged/download 产物不再阻塞新任务 - cancelUpdateJob 调用补上 await,避免结果恒为 pending Promise - server/cli/update.ts 增加特权工作区所有权校验与暂存路径重建逻辑 - 新增 tests/rate-limit.test.ts 与 tests/update-apply-staging.test.ts
This commit is contained in:
+312
-44
@@ -1,5 +1,5 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { lstat, mkdir, mkdtemp, readFile, realpath, rm } from "node:fs/promises";
|
||||
import { copyFile, lstat, mkdir, mkdtemp, readFile, readdir, realpath, rm } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import type Database from "better-sqlite3";
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
selectReleaseAsset,
|
||||
sanitizeAssetName,
|
||||
validateHttpsUrl,
|
||||
verifySha256,
|
||||
type ReleaseAsset,
|
||||
type ReleaseMetadata,
|
||||
type UrlPolicy,
|
||||
@@ -244,35 +245,243 @@ async function resolveRelease(options: UpdateRunOptions, platform: ReturnType<ty
|
||||
return { asset: { name: sanitizeAssetName(options.assetName ?? path.basename(assetUrl.pathname)), url: assetUrl.toString(), ...(options.expectedSha256 ? { sha256: options.expectedSha256 } : {}) }, version: options.version };
|
||||
}
|
||||
|
||||
async function ensurePrivilegedWorkspace(directory: string): Promise<string> {
|
||||
/**
|
||||
* Ownership expectation for a directory consumed by the privileged updater.
|
||||
*
|
||||
* `-1` disables the uid comparison while keeping the symlink and mode checks.
|
||||
* Production always passes a concrete uid (0 for the root-owned
|
||||
* `<installPrefix>/.update-work`), so the check never depends on the effective
|
||||
* uid of the current process and remains runnable from a non-root test.
|
||||
*/
|
||||
export type DirectoryOwnerUid = number;
|
||||
|
||||
/** The staging area owned by the unprivileged web process and the private
|
||||
* root-owned workspace are deliberately separate trust domains. */
|
||||
export class StagedWorkspaceError extends Error {
|
||||
readonly reason: string;
|
||||
constructor(message: string, reason: string) {
|
||||
super(message);
|
||||
this.name = "StagedWorkspaceError";
|
||||
this.reason = reason;
|
||||
}
|
||||
}
|
||||
|
||||
/** Owner uid of an existing path, or -1 when it cannot be inspected. */
|
||||
export async function directoryOwnerUid(targetPath: string): Promise<DirectoryOwnerUid> {
|
||||
const info = await lstat(path.resolve(targetPath)).catch(() => null);
|
||||
return info?.uid ?? -1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a privileged workspace root to its canonical path.
|
||||
*
|
||||
* The root itself may be reached through a symlinked ancestor (for example
|
||||
* `/tmp` on macOS), so only the final component is required to be a real,
|
||||
* non-symlink directory with private permissions and the expected owner.
|
||||
*/
|
||||
async function canonicalizePrivilegedRoot(directory: string, expectedUid: DirectoryOwnerUid, message: string): Promise<string> {
|
||||
const resolved = path.resolve(directory);
|
||||
await mkdir(resolved, { recursive: true, mode: 0o700 });
|
||||
const info = await lstat(resolved).catch(() => null);
|
||||
const uid = typeof process.getuid === "function" ? process.getuid() : -1;
|
||||
if (!info?.isDirectory() || info.isSymbolicLink() || (info.mode & 0o077) !== 0 || info.uid !== 0 || uid !== 0) {
|
||||
throw new Error("更新工作目录必须是 root 拥有且权限为 0700");
|
||||
if (!info?.isDirectory() || info.isSymbolicLink() || (info.mode & 0o077) !== 0 || (expectedUid >= 0 && info.uid !== expectedUid)) {
|
||||
throw new Error(message);
|
||||
}
|
||||
return resolved;
|
||||
const real = await realpath(resolved).catch(() => { throw new Error(message); });
|
||||
const realInfo = await lstat(real).catch(() => null);
|
||||
if (!realInfo?.isDirectory() || realInfo.isSymbolicLink() || (realInfo.mode & 0o077) !== 0 || (expectedUid >= 0 && realInfo.uid !== expectedUid)) {
|
||||
throw new Error(message);
|
||||
}
|
||||
return real;
|
||||
}
|
||||
|
||||
/** Validate a queued staged directory before a root process consumes it. */
|
||||
async function validateStagedWorkspacePath(candidate: string, workspaceRoot: string): Promise<string> {
|
||||
const rootResolved = path.resolve(workspaceRoot);
|
||||
const rootInfo = await lstat(rootResolved).catch(() => null);
|
||||
const uid = typeof process.getuid === "function" ? process.getuid() : -1;
|
||||
if (!rootInfo?.isDirectory() || rootInfo.isSymbolicLink() || (rootInfo.mode & 0o077) !== 0 || rootInfo.uid !== 0 || uid !== 0) {
|
||||
throw new Error("更新工作目录权限无效");
|
||||
}
|
||||
const root = await realpath(rootResolved).catch(() => { throw new Error("更新工作目录无效"); });
|
||||
/** Assert that `candidate` is a real, private, expected-owner directory below `root`. */
|
||||
async function assertStagedDirectory(candidate: string, root: string, expectedUid: DirectoryOwnerUid): Promise<string> {
|
||||
const resolved = path.resolve(candidate);
|
||||
if (resolved === rootResolved || !resolved.startsWith(`${rootResolved}${path.sep}`)) throw new Error("更新暂存路径无效");
|
||||
if (resolved === root || !resolved.startsWith(`${root}${path.sep}`)) throw new StagedWorkspaceError("更新暂存路径无效", "staged_workspace_invalid");
|
||||
const info = await lstat(resolved).catch(() => null);
|
||||
if (!info?.isDirectory() || info.isSymbolicLink() || (info.mode & 0o077) !== 0 || info.uid !== 0) throw new Error("更新暂存目录权限无效");
|
||||
const real = await realpath(resolved).catch(() => { throw new Error("更新暂存目录无效"); });
|
||||
if (real !== resolved || !real.startsWith(`${root}${path.sep}`)) throw new Error("更新暂存路径无效");
|
||||
if (!info?.isDirectory() || info.isSymbolicLink() || (info.mode & 0o077) !== 0) throw new StagedWorkspaceError("更新暂存目录权限无效", "staged_workspace_insecure");
|
||||
if (expectedUid >= 0 && info.uid !== expectedUid) throw new StagedWorkspaceError("更新暂存目录属主无效", "staged_workspace_insecure");
|
||||
const real = await realpath(resolved).catch(() => { throw new StagedWorkspaceError("更新暂存目录无效", "staged_workspace_invalid"); });
|
||||
if (real !== resolved || !real.startsWith(`${root}${path.sep}`)) throw new StagedWorkspaceError("更新暂存路径无效", "staged_workspace_invalid");
|
||||
return real;
|
||||
}
|
||||
|
||||
async function ensurePrivilegedWorkspace(directory: string, expectedUid: DirectoryOwnerUid): Promise<string> {
|
||||
return canonicalizePrivilegedRoot(directory, expectedUid, "更新工作目录必须是 root 拥有且权限为 0700");
|
||||
}
|
||||
|
||||
/**
|
||||
* Rebuild the staged workspace location for `jobId` instead of trusting the
|
||||
* `download_path` column: the runner clears that column whenever it releases
|
||||
* a workspace (`clearTransientJobPath`), and a nulled column cannot be used to
|
||||
* find a payload that is still on disk waiting for the apply step.
|
||||
*
|
||||
* Candidate order:
|
||||
* 1. `<stagingRoot>/update-<jobId>` (web download workspace)
|
||||
* 2. `<stagingRoot>/update-<jobId>-*` (mkdtemp variant)
|
||||
* 3. the recorded `download_path`, but only while it stays inside the root
|
||||
*/
|
||||
export async function locateStagedWorkspace(options: {
|
||||
jobId: string;
|
||||
downloadPath?: string | null | undefined;
|
||||
stagingRoot: string;
|
||||
expectedUid: DirectoryOwnerUid;
|
||||
}): Promise<string> {
|
||||
const root = await canonicalizePrivilegedRoot(options.stagingRoot, options.expectedUid, "更新暂存根目录权限无效");
|
||||
const prefix = `update-${options.jobId}`;
|
||||
const candidates = [path.join(root, prefix)];
|
||||
const entries = await readdir(root, { withFileTypes: true }).catch(() => []);
|
||||
for (const entry of entries.filter((candidate) => candidate.name.startsWith(`${prefix}-`)).sort((a, b) => a.name.localeCompare(b.name))) {
|
||||
candidates.push(path.join(root, entry.name));
|
||||
}
|
||||
const recorded = options.downloadPath?.trim();
|
||||
if (recorded && path.isAbsolute(recorded)) {
|
||||
const resolvedRecorded = path.resolve(recorded);
|
||||
if (resolvedRecorded.startsWith(`${root}${path.sep}`)) candidates.push(resolvedRecorded);
|
||||
// A recorded path outside the staging root is never consumed. Reject it
|
||||
// loudly when it exists so the operator sees the real cause instead of a
|
||||
// generic "re-download" message.
|
||||
else if (await lstat(resolvedRecorded).catch(() => null)) throw new StagedWorkspaceError("更新暂存路径无效", "staged_workspace_invalid");
|
||||
}
|
||||
const seen = new Set<string>();
|
||||
for (const candidate of candidates) {
|
||||
const resolved = path.resolve(candidate);
|
||||
if (seen.has(resolved)) continue;
|
||||
seen.add(resolved);
|
||||
// An existing candidate must satisfy every constraint: skipping it would
|
||||
// hand the root process whatever else happens to sit in the staging area.
|
||||
if (!(await lstat(resolved).catch(() => null))) continue;
|
||||
return assertStagedDirectory(resolved, root, options.expectedUid);
|
||||
}
|
||||
throw new StagedWorkspaceError("暂存目录已不存在,请重新下载", "staged_workspace_missing");
|
||||
}
|
||||
|
||||
/** Copy a verified payload tree without following or preserving symlinks. */
|
||||
async function copyReleaseTree(source: string, target: string): Promise<void> {
|
||||
await mkdir(target, { recursive: false, mode: 0o700 });
|
||||
const entries = await readdir(source, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
const from = path.join(source, entry.name);
|
||||
const to = path.join(target, entry.name);
|
||||
if (entry.isSymbolicLink()) throw new Error("更新暂存内容包含符号链接");
|
||||
if (entry.isDirectory()) await copyReleaseTree(from, to);
|
||||
else if (entry.isFile()) await copyFile(from, to);
|
||||
else throw new Error("更新暂存内容包含不受支持的文件类型");
|
||||
}
|
||||
}
|
||||
|
||||
const STAGED_ARCHIVE_PATTERN = /\.(?:tar\.gz|tgz|tar|zip)$/i;
|
||||
|
||||
async function assertStagedArchiveIntegrity(source: string, expectedSha256: string | null | undefined): Promise<void> {
|
||||
const expected = expectedSha256?.trim().toLowerCase();
|
||||
if (!expected) return;
|
||||
if (!/^[a-f0-9]{64}$/.test(expected)) throw new Error("更新暂存校验值无效");
|
||||
const entries = await readdir(source, { withFileTypes: true }).catch(() => []);
|
||||
const archive = entries
|
||||
.filter((entry) => entry.isFile() && !entry.isSymbolicLink() && STAGED_ARCHIVE_PATTERN.test(entry.name))
|
||||
.sort((a, b) => a.name.localeCompare(b.name))[0];
|
||||
if (!archive) throw new Error("更新暂存归档缺失,无法校验完整性");
|
||||
const archivePath = path.join(source, archive.name);
|
||||
const info = await lstat(archivePath).catch(() => null);
|
||||
if (!info?.isFile() || info.isSymbolicLink()) throw new Error("更新暂存归档无效");
|
||||
if (!(await verifySha256(archivePath, expected))) throw new Error("更新文件 SHA-256 校验失败");
|
||||
}
|
||||
|
||||
/**
|
||||
* Snapshot the web-staged payload into a root-owned workspace before the apply
|
||||
* flow touches it. The copy is what closes the TOCTOU window: the unprivileged
|
||||
* web user keeps write access to its own staging directory, so the privileged
|
||||
* process must never execute content that lives there.
|
||||
*
|
||||
* A copy (not a rename) is required because the data directory and the install
|
||||
* prefix are frequently separate mounts, where `rename` fails with EXDEV.
|
||||
*/
|
||||
export async function preparePrivateApplyWorkspace(options: {
|
||||
jobId: string;
|
||||
source: string;
|
||||
privateRoot: string;
|
||||
expectedUid: DirectoryOwnerUid;
|
||||
expectedSha256?: string | null | undefined;
|
||||
}): Promise<string> {
|
||||
const root = await canonicalizePrivilegedRoot(options.privateRoot, options.expectedUid, "更新工作目录必须是 root 拥有且权限为 0700");
|
||||
const source = path.resolve(options.source);
|
||||
const sourcePayload = path.join(source, "payload");
|
||||
const sourcePayloadInfo = await lstat(sourcePayload).catch(() => null);
|
||||
if (!sourcePayloadInfo?.isDirectory() || sourcePayloadInfo.isSymbolicLink()) throw new Error("更新暂存内容无效");
|
||||
// Second integrity check right before the copy, so a payload swapped after
|
||||
// the download verification is rejected instead of promoted to a release.
|
||||
await assertStagedArchiveIntegrity(source, options.expectedSha256);
|
||||
const target = path.join(root, `apply-${options.jobId}`);
|
||||
const existing = await lstat(target).catch(() => null);
|
||||
if (existing) await rm(target, { recursive: true, force: true }).catch(() => undefined);
|
||||
try {
|
||||
// Create the container explicitly: `copyReleaseTree` intentionally uses a
|
||||
// non-recursive mkdir so a pre-existing/symlinked target can never be
|
||||
// silently reused, and the parent must therefore already exist.
|
||||
await mkdir(target, { recursive: false, mode: 0o700 });
|
||||
await copyReleaseTree(sourcePayload, path.join(target, "payload"));
|
||||
await normalizeReleasePermissions(path.join(target, "payload"));
|
||||
const copied = await lstat(path.join(target, "payload")).catch(() => null);
|
||||
if (!copied?.isDirectory() || copied.isSymbolicLink()) throw new Error("更新暂存内容复制失败");
|
||||
return target;
|
||||
} catch (error) {
|
||||
await rm(target, { recursive: true, force: true }).catch(() => undefined);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/** Reason code attached to failures that must reach the UI verbatim. */
|
||||
function failureReason(error: unknown): string {
|
||||
const reason = (error as { reason?: unknown } | null)?.reason;
|
||||
return typeof reason === "string" && /^[a-z0-9_]{1,64}$/.test(reason) ? reason : "apply_precheck_failed";
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist a real failure reason from the privileged apply path.
|
||||
*
|
||||
* `writeJob`/`updateJob` cannot be used here: their final-state guard
|
||||
* (`WHERE update_jobs.status NOT IN (...)`) protects terminal rows, and it also
|
||||
* makes the runner's own progress writes a no-op once a row is terminal. This
|
||||
* helper issues an independent, guarded UPDATE so the true cause is visible in
|
||||
* the UI instead of the runner's generic health-check message.
|
||||
*/
|
||||
export function failUpdateJobWithReason(
|
||||
sqlite: Database.Database | undefined,
|
||||
jobId: string,
|
||||
message: string,
|
||||
options: { reason?: string } = {},
|
||||
): boolean {
|
||||
if (!sqlite) return false;
|
||||
const safe = safeErrorMessage(message.length ? new Error(message) : new Error("更新失败"));
|
||||
try {
|
||||
return sqlite.transaction(() => {
|
||||
const row = sqlite.prepare("SELECT status, version, request_id AS requestId, admin_id AS adminId FROM update_jobs WHERE id=?").get(jobId) as {
|
||||
status: UpdateJobStatus; version: string; requestId: string | null; adminId: string | null;
|
||||
} | undefined;
|
||||
if (!row || row.status === "completed" || row.status === "cancelled" || row.status === "failed") return false;
|
||||
const now = Date.now();
|
||||
const updated = sqlite.prepare("UPDATE update_jobs SET status='failed', error_message=?, completed_at=COALESCE(completed_at, ?), updated_at=? WHERE id=? AND status=?").run(safe, now, now, jobId, row.status);
|
||||
if (updated.changes !== 1) return false;
|
||||
writeAudit(sqlite, {
|
||||
requestId: row.requestId || randomUUID(),
|
||||
actorAdminId: row.adminId,
|
||||
action: "update.failed",
|
||||
targetType: "update",
|
||||
targetId: jobId,
|
||||
outcome: "failure",
|
||||
before: { status: row.status, version: row.version },
|
||||
after: { status: "failed", version: row.version, reason: options.reason ?? "apply_precheck_failed", error: safe },
|
||||
});
|
||||
return true;
|
||||
})();
|
||||
} catch {
|
||||
// The database may not be open (or the row may not exist) when a request is
|
||||
// rejected during preflight. Losing the diagnostic write must never turn a
|
||||
// clean rejection into a crash.
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function runUpdate(options: UpdateRunOptions): Promise<UpdateRunResult> {
|
||||
const platform = options.platform ?? detectPlatform();
|
||||
const jobId = options.jobId ?? randomUUID();
|
||||
@@ -437,13 +646,17 @@ export async function applyStagedUpdate(options: {
|
||||
maxBytes?: number;
|
||||
dataBackupMaxBytes?: number;
|
||||
workspaceRoot?: string;
|
||||
/** Expected owner of `workspaceRoot`. Defaults to uid 0 (the installer
|
||||
* provisions `<installPrefix>/.update-work` as root-owned 0700). Tests inject
|
||||
* the current user so the check never depends on `process.getuid()`. */
|
||||
workspaceOwnerUid?: DirectoryOwnerUid;
|
||||
}): Promise<void> {
|
||||
const row = options.sqlite.prepare(`SELECT status, operation, version, platform, release_url AS releaseUrl, asset_name AS assetName, asset_url AS assetUrl, expected_sha256 AS expectedSha256, actual_sha256 AS actualSha256, size_bytes AS sizeBytes FROM update_jobs WHERE id=?`).get(options.jobId) as Record<string, unknown> | undefined;
|
||||
if (!row || row.status !== "staged" || row.operation !== "apply") throw new Error("更新任务未处于待应用状态");
|
||||
if (typeof row.version === "string" && row.version !== options.version) throw new Error("更新版本不一致");
|
||||
const stagedPath = options.workspaceRoot
|
||||
? await validateStagedWorkspacePath(options.stagedPath, options.workspaceRoot)
|
||||
: options.stagedPath;
|
||||
? await canonicalizePrivilegedRoot(options.workspaceRoot, options.workspaceOwnerUid ?? 0, "更新工作目录权限无效")
|
||||
: path.resolve(options.stagedPath);
|
||||
const payload = path.join(stagedPath, "payload");
|
||||
const payloadInfo = await lstat(payload).catch(() => null);
|
||||
if (!payloadInfo?.isDirectory() || payloadInfo.isSymbolicLink()) throw new Error("更新暂存内容无效");
|
||||
@@ -481,7 +694,21 @@ function arg(name: string): string | undefined {
|
||||
return index >= 0 ? process.argv[index + 1] : undefined;
|
||||
}
|
||||
|
||||
export async function main(config: AppConfig = loadConfig()): Promise<void> {
|
||||
/**
|
||||
* Ownership expectations the privileged entry point uses for the two trust
|
||||
* domains it consumes. They are injectable so the apply flow can be exercised
|
||||
* end-to-end from a non-root test process: production always uses the defaults
|
||||
* (root-owned `<installPrefix>/.update-work` and the `dataDir` owner for the
|
||||
* unprivileged staging area) and never consults `process.getuid()`.
|
||||
*/
|
||||
export type UpdateMainOverrides = {
|
||||
/** Expected owner of the unprivileged staging root (`config.stagingDir`). */
|
||||
stagingOwnerUid?: DirectoryOwnerUid;
|
||||
/** Expected owner of the root-only private workspace (`config.updateWorkspaceDir`). */
|
||||
workspaceOwnerUid?: DirectoryOwnerUid;
|
||||
};
|
||||
|
||||
export async function main(config: AppConfig = loadConfig(), overrides: UpdateMainOverrides = {}): Promise<void> {
|
||||
const finalizeJobId = arg("--finalize-job");
|
||||
if (finalizeJobId) {
|
||||
const finalStatus = arg("--finalize-status");
|
||||
@@ -518,7 +745,9 @@ export async function main(config: AppConfig = loadConfig()): Promise<void> {
|
||||
const dataBackupArchive = arg("--data-backup") ?? (request ? path.join(path.dirname(config.dataDir), "tallynote-backups", `data-${request.jobId}.tar.gz`) : undefined);
|
||||
const allowedHosts = process.argv.flatMap((value, index) => value === "--allow-host" && process.argv[index + 1] ? [process.argv[index + 1]!] : []);
|
||||
prepareDataDirectories(config);
|
||||
if (request) await ensurePrivilegedWorkspace(stagingDir);
|
||||
const workspaceOwnerUid = overrides.workspaceOwnerUid ?? 0;
|
||||
const stagingOwnerUid = overrides.stagingOwnerUid ?? await directoryOwnerUid(config.dataDir);
|
||||
if (request) await ensurePrivilegedWorkspace(stagingDir, workspaceOwnerUid);
|
||||
else await mkdir(stagingDir, { recursive: true, mode: 0o700 });
|
||||
// The download phase intentionally runs beside the live app so users keep
|
||||
// access while the archive is fetched and staged. SQLite WAL plus the
|
||||
@@ -528,28 +757,67 @@ export async function main(config: AppConfig = loadConfig()): Promise<void> {
|
||||
const database = openDatabase(config);
|
||||
try {
|
||||
if (request?.operation === "apply") {
|
||||
const staged = database.sqlite.prepare("SELECT status, operation, download_path AS downloadPath, version FROM update_jobs WHERE id=?").get(request.jobId) as { status: UpdateJobStatus; operation: "download" | "apply"; downloadPath: string | null; version: string } | undefined;
|
||||
const staged = database.sqlite.prepare("SELECT status, operation, download_path AS downloadPath, version, expected_sha256 AS expectedSha256 FROM update_jobs WHERE id=?").get(request.jobId) as { status: UpdateJobStatus; operation: "download" | "apply"; downloadPath: string | null; version: string; expectedSha256: string | null } | undefined;
|
||||
if (staged?.status === "staged" && staged.operation === "apply") {
|
||||
if (!staged.downloadPath || staged.version !== request.version) throw new Error("更新暂存任务无效");
|
||||
const root = path.resolve(config.updateWorkspaceDir);
|
||||
const candidate = await validateStagedWorkspacePath(staged.downloadPath, root);
|
||||
await applyStagedUpdate({
|
||||
sqlite: database.sqlite,
|
||||
jobId: request.jobId,
|
||||
version: request.version,
|
||||
stagedPath: candidate,
|
||||
currentDir,
|
||||
currentLink: request.currentLink,
|
||||
releasesDir: request.releasesDir,
|
||||
workspaceRoot: root,
|
||||
...(backupArchive ? { backupArchivePath: backupArchive } : {}),
|
||||
...(dataBackupArchive ? { dataBackupArchivePath: dataBackupArchive } : {}),
|
||||
dataBackupSource: config.dataDir,
|
||||
maxBytes: config.updateMaxBytes,
|
||||
dataBackupMaxBytes: config.maxTotalBytes,
|
||||
});
|
||||
console.log(`更新已切换:${request.version}`);
|
||||
return;
|
||||
// `download_path` is intentionally NOT required here. The runner NULLs
|
||||
// that column as soon as it releases a workspace, so it can never be the
|
||||
// source of truth for a payload that still exists on disk. The job id is
|
||||
// the stable key; the column survives only as a last-resort candidate in
|
||||
// `locateStagedWorkspace`.
|
||||
if (staged.version !== request.version) throw new Error("更新暂存任务无效");
|
||||
let privateWorkspace: string | undefined;
|
||||
try {
|
||||
const source = await locateStagedWorkspace({
|
||||
jobId: request.jobId,
|
||||
downloadPath: staged.downloadPath,
|
||||
stagingRoot: config.stagingDir,
|
||||
expectedUid: stagingOwnerUid,
|
||||
});
|
||||
// Snapshot into the root-only workspace before applying. The web user
|
||||
// keeps write access to the staging tree, so content that is executed
|
||||
// by the privileged process must never live there (TOCTOU).
|
||||
privateWorkspace = await preparePrivateApplyWorkspace({
|
||||
jobId: request.jobId,
|
||||
source,
|
||||
privateRoot: config.updateWorkspaceDir,
|
||||
expectedUid: workspaceOwnerUid,
|
||||
expectedSha256: staged.expectedSha256,
|
||||
});
|
||||
await applyStagedUpdate({
|
||||
sqlite: database.sqlite,
|
||||
jobId: request.jobId,
|
||||
version: request.version,
|
||||
stagedPath: privateWorkspace,
|
||||
workspaceRoot: privateWorkspace,
|
||||
workspaceOwnerUid,
|
||||
currentDir,
|
||||
currentLink: request.currentLink,
|
||||
releasesDir: request.releasesDir,
|
||||
...(backupArchive ? { backupArchivePath: backupArchive } : {}),
|
||||
...(dataBackupArchive ? { dataBackupArchivePath: dataBackupArchive } : {}),
|
||||
dataBackupSource: config.dataDir,
|
||||
maxBytes: config.updateMaxBytes,
|
||||
dataBackupMaxBytes: config.maxTotalBytes,
|
||||
});
|
||||
// The private copy has been consumed by the release switch and the
|
||||
// payload is now the live release, so the web-owned source tree is
|
||||
// redundant. Best-effort cleanup must not fail an applied update.
|
||||
await rm(source, { recursive: true, force: true }).catch(() => undefined);
|
||||
console.log(`更新已切换:${request.version}`);
|
||||
return;
|
||||
} catch (error) {
|
||||
// Covers failures raised before `applyStagedUpdate` took ownership of
|
||||
// the private copy, and the "already committed" case where the failing
|
||||
// path deliberately skips its own cleanup.
|
||||
if (privateWorkspace) await rm(privateWorkspace, { recursive: true, force: true }).catch(() => undefined);
|
||||
// The runner can only report its fixed health-check message. Record the
|
||||
// real pre-flight cause so the UI and the audit trail show why the
|
||||
// update was rejected. A terminal row is only reachable through an
|
||||
// independent guarded UPDATE (`writeJob` refuses to mutate terminal
|
||||
// rows), which is exactly what this helper issues.
|
||||
failUpdateJobWithReason(database.sqlite, request.jobId, safeErrorMessage(error), { reason: failureReason(error) });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
if (staged && !(staged.status === "queued" && staged.operation === "apply")) throw new Error("更新任务状态无效");
|
||||
// A direct one-click request starts in queued/apply. Older clients do
|
||||
|
||||
Reference in New Issue
Block a user