mirror of
https://github.com/EvoMap/evolver.git
synced 2026-09-18 21:47:53 +08:00
2297 lines
108 KiB
JavaScript
2297 lines
108 KiB
JavaScript
import { createHash, randomUUID } from 'node:crypto';
|
|
import { execFileSync } from 'node:child_process';
|
|
import { closeSync, constants, existsSync, fchmodSync, fstatSync, fsyncSync, linkSync, lstatSync, mkdirSync, openSync, readSync, renameSync, rmSync, writeFileSync, } from 'node:fs';
|
|
import { basename, dirname, isAbsolute, join, parse, posix, resolve, win32 } from 'node:path';
|
|
import { bootstrap as coreBootstrap, util } from '@evomap/evolver-core';
|
|
export const BOOTSTRAP_JOURNAL_FILE = coreBootstrap.LIFECYCLE_BOOTSTRAP_JOURNAL_FILE;
|
|
export const BOOTSTRAP_LOCK_FILE = coreBootstrap.LIFECYCLE_BOOTSTRAP_OWNER_LOCK_FILE;
|
|
export const BOOTSTRAP_SUCCESS_FILE = coreBootstrap.LIFECYCLE_BOOTSTRAP_SUCCESS_FILE;
|
|
const BOOTSTRAP_MANUAL_TRANSITION_FILE = coreBootstrap.LIFECYCLE_BOOTSTRAP_MANUAL_TRANSITION_FILE;
|
|
const BOOTSTRAP_JOURNAL_SCHEMA = 'evolver.lifecycle-bootstrap-transaction.v1';
|
|
export const LEGACY_BOOTSTRAP_REMOVAL_OPERATION = 'legacy-v907-remove';
|
|
const LEGACY_BOOTSTRAP_ABSENT_MANAGER_BINDING = 'legacy-v907-absent';
|
|
const BOOTSTRAP_REGISTRATION_INTENT_FILE = coreBootstrap.LIFECYCLE_BOOTSTRAP_REGISTRATION_INTENT_FILE;
|
|
const JOURNAL_STAGES = new Set([
|
|
'prepared', 'installing', 'installed', 'activating', 'activated', 'committing',
|
|
'committed', 'rollback_pending', 'rolled_back',
|
|
]);
|
|
const TARGETS = new Set(['launchd', 'systemd', 'windows']);
|
|
const SHA256_RE = /^[0-9a-f]{64}$/;
|
|
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
const MAX_JOURNAL_BYTES = 128 * 1024;
|
|
const MAX_MARKER_BYTES = 128 * 1024;
|
|
const MAX_READINESS_BYTES = 16 * 1024;
|
|
const MAX_ARTIFACT_BYTES = 512 * 1024 * 1024;
|
|
const HOST_WINDOWS_SYSTEM_ROOT = process.env['SystemRoot']?.trim() || 'C:\\Windows';
|
|
function isErrno(error, code) {
|
|
return typeof error === 'object' && error !== null && error.code === code;
|
|
}
|
|
function filesystemEntryPresent(path) {
|
|
try {
|
|
lstatSync(path);
|
|
return true;
|
|
}
|
|
catch (error) {
|
|
if (isErrno(error, 'ENOENT'))
|
|
return false;
|
|
throw error;
|
|
}
|
|
}
|
|
function boundedText(value, maxLength) {
|
|
return typeof value === 'string'
|
|
&& value.length > 0
|
|
&& value.length <= maxLength
|
|
&& !containsControlCharacter(value);
|
|
}
|
|
function containsControlCharacter(value) {
|
|
for (let index = 0; index < value.length; index += 1) {
|
|
const code = value.charCodeAt(index);
|
|
if (code <= 0x1f || code === 0x7f)
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
function trustedWindowsSystemExecutable(name) {
|
|
if (!win32.isAbsolute(HOST_WINDOWS_SYSTEM_ROOT) || /[\r\n\0]/.test(HOST_WINDOWS_SYSTEM_ROOT)) {
|
|
throw new Error('Windows SystemRoot is not an absolute trusted path');
|
|
}
|
|
return win32.join(HOST_WINDOWS_SYSTEM_ROOT, 'System32', name);
|
|
}
|
|
let bootstrapWindowsAclTrustForTest;
|
|
/** Test-only ACL dependency seam. Production callers never configure this override. */
|
|
export function _setBootstrapWindowsAclTrustForTest(assertion) {
|
|
bootstrapWindowsAclTrustForTest = assertion;
|
|
}
|
|
function assertWindowsAclChecksTrusted(checks) {
|
|
if (process.platform !== 'win32')
|
|
return;
|
|
if (bootstrapWindowsAclTrustForTest) {
|
|
bootstrapWindowsAclTrustForTest(checks);
|
|
return;
|
|
}
|
|
if (checks.length === 0) {
|
|
throw new Error('bootstrap Windows ACL chain is not trusted: <empty>');
|
|
}
|
|
const encodedChecks = checks.map((check) => (`${Buffer.from(check.path, 'utf8').toString('base64')}|${check.parentOnly ? '1' : '0'}|${check.ownerCurrentOnly ? '1' : '0'}`)).join('\n');
|
|
if (encodedChecks.length > 24 * 1024) {
|
|
throw new Error(`bootstrap Windows ACL chain is not trusted: ${checks[0]?.path ?? '<empty>'}`);
|
|
}
|
|
const script = [
|
|
"$ErrorActionPreference = 'Stop'",
|
|
'trap { exit 24 }',
|
|
'$checks = @()',
|
|
'foreach ($row in $env:EVOLVER_BOOTSTRAP_ACL_CHECKS.Split([char]10, [System.StringSplitOptions]::RemoveEmptyEntries)) {',
|
|
" $parts = $row.Split('|')",
|
|
" if ($parts.Count -ne 3 -or ($parts[1] -ne '0' -and $parts[1] -ne '1') -or ($parts[2] -ne '0' -and $parts[2] -ne '1')) { exit 24 }",
|
|
' try { $path = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($parts[0])) } catch { exit 24 }',
|
|
" $checks += [pscustomobject]@{ Path = $path; ParentOnly = ($parts[1] -eq '1'); OwnerCurrentOnly = ($parts[2] -eq '1') }",
|
|
'}',
|
|
'$userSid = [System.Security.Principal.WindowsIdentity]::GetCurrent().User.Value',
|
|
"$trustedInstaller = 'S-1-5-80-956008885-3418522649-1831038044-1853292631-2271478464'",
|
|
"$trustedOwners = @($userSid, 'S-1-5-18', 'S-1-5-32-544', $trustedInstaller)",
|
|
"$trustedWriters = @($userSid, 'S-1-5-18', 'S-1-5-32-544', $trustedInstaller, 'S-1-3-0', 'S-1-3-4')",
|
|
'$parentDanger = [System.Security.AccessControl.FileSystemRights]::Delete -bor [System.Security.AccessControl.FileSystemRights]::DeleteSubdirectoriesAndFiles -bor [System.Security.AccessControl.FileSystemRights]::ChangePermissions -bor [System.Security.AccessControl.FileSystemRights]::TakeOwnership',
|
|
'$contentDanger = [System.Security.AccessControl.FileSystemRights]::WriteData -bor [System.Security.AccessControl.FileSystemRights]::AppendData -bor [System.Security.AccessControl.FileSystemRights]::CreateFiles -bor [System.Security.AccessControl.FileSystemRights]::CreateDirectories',
|
|
'foreach ($check in $checks) {',
|
|
' try { $attributes = [System.IO.File]::GetAttributes($check.Path) } catch { exit 24 }',
|
|
' try {',
|
|
' $acl = if (($attributes -band [System.IO.FileAttributes]::Directory) -ne 0) {',
|
|
' [System.IO.Directory]::GetAccessControl($check.Path)',
|
|
' } else {',
|
|
' [System.IO.File]::GetAccessControl($check.Path)',
|
|
' }',
|
|
' } catch { exit 24 }',
|
|
' $owner = $acl.GetOwner([System.Security.Principal.SecurityIdentifier]).Value',
|
|
' if (($check.OwnerCurrentOnly -and $owner -ne $userSid) -or (-not $check.OwnerCurrentOnly -and $trustedOwners -notcontains $owner)) { exit 21 }',
|
|
' $danger = if ($check.ParentOnly) { $parentDanger } else { $parentDanger -bor $contentDanger }',
|
|
' $rules = @($acl.GetAccessRules($true, $true, [System.Security.Principal.SecurityIdentifier]))',
|
|
' foreach ($rule in $rules) {',
|
|
' if ($rule.AccessControlType -ne [System.Security.AccessControl.AccessControlType]::Allow) { continue }',
|
|
' if (($rule.PropagationFlags -band [System.Security.AccessControl.PropagationFlags]::InheritOnly) -ne 0) { continue }',
|
|
' if (($rule.FileSystemRights -band $danger) -eq 0) { continue }',
|
|
' if (-not ($rule.IdentityReference -is [System.Security.Principal.SecurityIdentifier])) { exit 22 }',
|
|
' $sid = $rule.IdentityReference.Value',
|
|
' if ($trustedWriters -notcontains $sid) { exit 23 }',
|
|
' }',
|
|
'}',
|
|
].join('\n');
|
|
try {
|
|
execFileSync(trustedWindowsSystemExecutable('WindowsPowerShell\\v1.0\\powershell.exe'), ['-NoProfile', '-NonInteractive', '-Command', script], {
|
|
env: {
|
|
SystemRoot: HOST_WINDOWS_SYSTEM_ROOT,
|
|
WINDIR: HOST_WINDOWS_SYSTEM_ROOT,
|
|
EVOLVER_BOOTSTRAP_ACL_CHECKS: encodedChecks,
|
|
},
|
|
shell: false,
|
|
stdio: 'ignore', timeout: 10_000, windowsHide: true,
|
|
});
|
|
}
|
|
catch (error) {
|
|
const status = typeof error === 'object' && error !== null
|
|
? error.status
|
|
: undefined;
|
|
const reason = status === 21
|
|
? 'owner_untrusted'
|
|
: status === 22
|
|
? 'identity_unresolved'
|
|
: status === 23
|
|
? 'writer_untrusted'
|
|
: status === 24
|
|
? 'input_or_acl_read_failed'
|
|
: 'process_failed';
|
|
throw new Error(`bootstrap Windows ACL chain is not trusted (${reason}): ${checks[0]?.path ?? '<empty>'}`, { cause: error });
|
|
}
|
|
}
|
|
function assertWindowsAclTrusted(path, ownerCurrentOnly = false) {
|
|
assertWindowsAclChecksTrusted([{ path, parentOnly: false, ownerCurrentOnly }]);
|
|
}
|
|
function hardenWindowsOwnedFile(path, expected) {
|
|
if (process.platform !== 'win32' || bootstrapWindowsAclTrustForTest)
|
|
return;
|
|
const assertIdentity = () => {
|
|
const current = lstatSync(path, { bigint: true });
|
|
if (!current.isFile() || current.isSymbolicLink() || current.nlink !== 1n
|
|
|| current.dev !== expected.dev || current.ino !== expected.ino || current.size !== expected.size) {
|
|
throw new Error(`bootstrap Windows owned file changed before hardening: ${path}`);
|
|
}
|
|
};
|
|
assertIdentity();
|
|
// Only normalize a file we just created in a trusted directory. An unsafe
|
|
// existing owner or writer must be rejected before changing its permissions.
|
|
assertWindowsAclTrusted(path);
|
|
assertIdentity();
|
|
const encodedPath = Buffer.from(path, 'utf8').toString('base64');
|
|
if (encodedPath.length > 8 * 1024) {
|
|
throw new Error(`bootstrap Windows owned file hardening failed: ${path}`);
|
|
}
|
|
const script = [
|
|
"$ErrorActionPreference = 'Stop'",
|
|
'trap { exit 31 }',
|
|
'try { $target = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($env:EVOLVER_BOOTSTRAP_OWNED_FILE)) } catch { exit 31 }',
|
|
'try { $attributes = [System.IO.File]::GetAttributes($target) } catch { exit 31 }',
|
|
'if (($attributes -band [System.IO.FileAttributes]::Directory) -ne 0 -or ($attributes -band [System.IO.FileAttributes]::ReparsePoint) -ne 0) { exit 31 }',
|
|
'$sid = [System.Security.Principal.WindowsIdentity]::GetCurrent().User',
|
|
'try { $acl = [System.IO.File]::GetAccessControl($target) } catch { exit 31 }',
|
|
'$acl.SetOwner($sid)',
|
|
'$acl.SetAccessRuleProtection($true, $false)',
|
|
'$rules = @($acl.GetAccessRules($true, $true, [System.Security.Principal.SecurityIdentifier]))',
|
|
'foreach ($rule in $rules) { [void]$acl.RemoveAccessRuleSpecific($rule) }',
|
|
'$access = [System.Security.AccessControl.FileSystemAccessRule]::new($sid, [System.Security.AccessControl.FileSystemRights]::FullControl, [System.Security.AccessControl.AccessControlType]::Allow)',
|
|
'[void]$acl.AddAccessRule($access)',
|
|
'try { [System.IO.File]::SetAccessControl($target, $acl); $verified = [System.IO.File]::GetAccessControl($target) } catch { exit 31 }',
|
|
'$verifiedRules = @($verified.GetAccessRules($true, $true, [System.Security.Principal.SecurityIdentifier]))',
|
|
'if (-not $verified.AreAccessRulesProtected -or $verified.GetOwner([System.Security.Principal.SecurityIdentifier]).Value -ne $sid.Value -or $verifiedRules.Count -ne 1) { exit 31 }',
|
|
'$verifiedRule = $verifiedRules[0]',
|
|
'if ($verifiedRule.IdentityReference.Value -ne $sid.Value -or $verifiedRule.AccessControlType -ne [System.Security.AccessControl.AccessControlType]::Allow -or $verifiedRule.FileSystemRights -ne [System.Security.AccessControl.FileSystemRights]::FullControl -or $verifiedRule.IsInherited) { exit 31 }',
|
|
].join('\n');
|
|
try {
|
|
execFileSync(trustedWindowsSystemExecutable('WindowsPowerShell\\v1.0\\powershell.exe'), ['-NoProfile', '-NonInteractive', '-Command', script], {
|
|
env: {
|
|
SystemRoot: HOST_WINDOWS_SYSTEM_ROOT,
|
|
WINDIR: HOST_WINDOWS_SYSTEM_ROOT,
|
|
EVOLVER_BOOTSTRAP_OWNED_FILE: encodedPath,
|
|
},
|
|
shell: false,
|
|
stdio: 'ignore',
|
|
timeout: 10_000,
|
|
windowsHide: true,
|
|
});
|
|
}
|
|
catch (error) {
|
|
throw new Error(`bootstrap Windows owned file hardening failed: ${path}`, { cause: error });
|
|
}
|
|
assertIdentity();
|
|
}
|
|
function assertWindowsAclChainTrusted(path) {
|
|
const checks = [];
|
|
let current = resolve(path);
|
|
const root = parse(current).root;
|
|
for (;;) {
|
|
checks.push({ path: current, parentOnly: checks.length > 0 });
|
|
if (current === root)
|
|
break;
|
|
current = dirname(current);
|
|
}
|
|
assertWindowsAclChecksTrusted(checks);
|
|
}
|
|
function assertSecureStateDirectory(stateDir) {
|
|
const stat = lstatSync(stateDir);
|
|
if (stat.isSymbolicLink() || !stat.isDirectory()) {
|
|
throw new Error(`bootstrap state directory is not a trusted directory: ${stateDir}`);
|
|
}
|
|
if (process.platform === 'win32') {
|
|
assertWindowsAclChainTrusted(stateDir);
|
|
return;
|
|
}
|
|
const uid = typeof process.getuid === 'function' ? process.getuid() : undefined;
|
|
if (uid !== undefined && stat.uid !== uid) {
|
|
throw new Error(`bootstrap state directory has an unexpected owner: ${stateDir}`);
|
|
}
|
|
if ((stat.mode & 0o077) !== 0) {
|
|
throw new Error(`bootstrap state directory must be owner-only: ${stateDir}`);
|
|
}
|
|
assertTrustedPosixDirectoryChain(dirname(resolve(stateDir)), process.platform, uid, 'bootstrap state directory parent', true);
|
|
}
|
|
function effectivePosixUid(platform, uid) {
|
|
if (uid !== undefined)
|
|
return uid;
|
|
if (platform === process.platform && typeof process.getuid === 'function')
|
|
return process.getuid();
|
|
return undefined;
|
|
}
|
|
function assertTrustedPosixDirectoryChain(directory, platform, uid, label, initialPrivateUserAnchor = false) {
|
|
const trustedUid = effectivePosixUid(platform, uid);
|
|
if (trustedUid === undefined) {
|
|
throw new Error(`${label} owner cannot be verified`);
|
|
}
|
|
let current = resolve(directory);
|
|
const root = parse(current).root;
|
|
let privateUserAnchor = initialPrivateUserAnchor;
|
|
for (;;) {
|
|
try {
|
|
const stat = lstatSync(current);
|
|
if (stat.isSymbolicLink() || !stat.isDirectory()) {
|
|
throw new Error(`${label} is not a trusted directory: ${current}`);
|
|
}
|
|
if (stat.uid !== trustedUid && stat.uid !== 0) {
|
|
throw new Error(`${label} has an unexpected owner: ${current}`);
|
|
}
|
|
const writableByOthers = (stat.mode & 0o022) !== 0;
|
|
const trustedStickyAncestor = privateUserAnchor
|
|
&& stat.uid === 0
|
|
&& (stat.mode & 0o1000) !== 0;
|
|
if (writableByOthers && !trustedStickyAncestor) {
|
|
throw new Error(`${label} is group/world writable: ${current}`);
|
|
}
|
|
if (stat.uid === trustedUid && (stat.mode & 0o077) === 0) {
|
|
privateUserAnchor = true;
|
|
}
|
|
}
|
|
catch (error) {
|
|
if (!isErrno(error, 'ENOENT'))
|
|
throw error;
|
|
}
|
|
if (current === root)
|
|
break;
|
|
current = dirname(current);
|
|
}
|
|
}
|
|
function readBoundedRegularText(path, maxBytes, label, options = {}) {
|
|
const platform = options.platform ?? process.platform;
|
|
const uid = platform === 'win32' || typeof process.getuid !== 'function'
|
|
? undefined
|
|
: BigInt(process.getuid());
|
|
const before = lstatSync(path, { bigint: true });
|
|
if (before.isSymbolicLink() || !before.isFile() || before.size > BigInt(maxBytes)
|
|
|| (options.requireOwnerOnly === true && before.nlink !== 1n)
|
|
|| before.dev <= 0n || before.ino <= 0n) {
|
|
throw new Error(`${label} is not a bounded regular file: ${path}`);
|
|
}
|
|
if (options.requireOwnerOnly === true && platform !== 'win32') {
|
|
if ((uid !== undefined && before.uid !== uid) || (before.mode & 63n) !== 0n) {
|
|
throw new Error(`${label} is not owner-only: ${path}`);
|
|
}
|
|
}
|
|
if (platform === 'win32') {
|
|
(options.assertWindowsAcl ?? assertWindowsAclTrusted)(path);
|
|
}
|
|
options.afterAclCheck?.();
|
|
const descriptor = openSync(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
|
|
try {
|
|
const opened = fstatSync(descriptor, { bigint: true });
|
|
if (!opened.isFile() || opened.dev !== before.dev || opened.ino !== before.ino
|
|
|| (options.requireOwnerOnly === true && opened.nlink !== 1n)
|
|
|| (options.requireOwnerOnly === true && platform !== 'win32'
|
|
&& ((uid !== undefined && opened.uid !== uid) || (opened.mode & 63n) !== 0n))
|
|
|| opened.dev <= 0n || opened.ino <= 0n || opened.size > BigInt(maxBytes)
|
|
|| opened.size !== before.size || opened.mtimeNs !== before.mtimeNs
|
|
|| opened.ctimeNs !== before.ctimeNs) {
|
|
throw new Error(`${label} changed while opening: ${path}`);
|
|
}
|
|
const bytes = Buffer.alloc(maxBytes + 1);
|
|
let offset = 0;
|
|
while (offset < bytes.length) {
|
|
const count = readSync(descriptor, bytes, offset, bytes.length - offset, null);
|
|
if (count === 0)
|
|
break;
|
|
offset += count;
|
|
}
|
|
if (offset > maxBytes) {
|
|
throw new Error(`${label} is not a bounded regular file: ${path}`);
|
|
}
|
|
const value = bytes.subarray(0, offset).toString('utf8');
|
|
const after = fstatSync(descriptor, { bigint: true });
|
|
if (after.dev !== opened.dev || after.ino !== opened.ino || after.size !== opened.size
|
|
|| after.mtimeNs !== opened.mtimeNs || after.ctimeNs !== opened.ctimeNs
|
|
|| (options.requireOwnerOnly === true && after.nlink !== 1n)
|
|
|| (options.requireOwnerOnly === true && platform !== 'win32'
|
|
&& ((uid !== undefined && after.uid !== uid) || (after.mode & 63n) !== 0n))) {
|
|
throw new Error(`${label} changed while reading: ${path}`);
|
|
}
|
|
options.afterRead?.();
|
|
if (platform === 'win32') {
|
|
(options.assertWindowsAcl ?? assertWindowsAclTrusted)(path);
|
|
}
|
|
const settled = lstatSync(path, { bigint: true });
|
|
if (!settled.isFile() || settled.isSymbolicLink()
|
|
|| settled.dev !== opened.dev || settled.ino !== opened.ino || settled.size !== opened.size
|
|
|| settled.mtimeNs !== opened.mtimeNs || settled.ctimeNs !== opened.ctimeNs
|
|
|| (options.requireOwnerOnly === true && settled.nlink !== 1n)
|
|
|| (options.requireOwnerOnly === true && platform !== 'win32'
|
|
&& ((uid !== undefined && settled.uid !== uid) || (settled.mode & 63n) !== 0n))) {
|
|
throw new Error(`${label} changed after reading: ${path}`);
|
|
}
|
|
return value;
|
|
}
|
|
finally {
|
|
closeSync(descriptor);
|
|
}
|
|
}
|
|
export function _readBoundedRegularTextForTest(path, maxBytes, label, options) {
|
|
return readBoundedRegularText(path, maxBytes, label, options);
|
|
}
|
|
function syncDirectory(path) {
|
|
let descriptor;
|
|
try {
|
|
descriptor = openSync(path, constants.O_RDONLY);
|
|
fsyncSync(descriptor);
|
|
}
|
|
catch (error) {
|
|
if (isErrno(error, 'EINVAL'))
|
|
return;
|
|
if (process.platform === 'win32' && (isErrno(error, 'EPERM') || isErrno(error, 'EACCES')))
|
|
return;
|
|
throw error;
|
|
}
|
|
finally {
|
|
if (descriptor !== undefined)
|
|
closeSync(descriptor);
|
|
}
|
|
}
|
|
export function writeDurableText(path, content, mode = 0o600) {
|
|
const directory = dirname(path);
|
|
mkdirSync(directory, { recursive: true, mode: 0o700 });
|
|
const temporary = join(directory, `.${basename(path)}.${process.pid}.${randomUUID()}.tmp`);
|
|
let descriptor;
|
|
try {
|
|
descriptor = openSync(temporary, 'wx', mode);
|
|
writeFileSync(descriptor, content, { encoding: 'utf8' });
|
|
fchmodSync(descriptor, mode);
|
|
fsyncSync(descriptor);
|
|
if (process.platform === 'win32') {
|
|
hardenWindowsOwnedFile(temporary, fstatSync(descriptor, { bigint: true }));
|
|
}
|
|
closeSync(descriptor);
|
|
descriptor = undefined;
|
|
renameSync(temporary, path);
|
|
syncDirectory(directory);
|
|
}
|
|
finally {
|
|
if (descriptor !== undefined)
|
|
closeSync(descriptor);
|
|
rmSync(temporary, { force: true });
|
|
}
|
|
}
|
|
function bootstrapArtifactOwnershipReceiptPath(path, claimPath) {
|
|
const canonicalPath = resolve(path);
|
|
const canonicalClaimPath = resolve(claimPath);
|
|
const prefix = `.${basename(canonicalPath)}.bootstrap-`;
|
|
const claimName = basename(canonicalClaimPath);
|
|
if (dirname(canonicalClaimPath) !== dirname(canonicalPath)
|
|
|| !claimName.startsWith(prefix)
|
|
|| !claimName.endsWith('.claim')) {
|
|
throw new Error('bootstrap artifact claim does not encode a transaction owner');
|
|
}
|
|
const transactionId = claimName.slice(prefix.length, -'.claim'.length);
|
|
if (!UUID_RE.test(transactionId)
|
|
|| bootstrapArtifactClaimPath(canonicalPath, transactionId) !== canonicalClaimPath) {
|
|
throw new Error('bootstrap artifact claim does not encode a transaction owner');
|
|
}
|
|
return bootstrapArtifactRollbackPath(canonicalPath, transactionId);
|
|
}
|
|
function writeDurableExclusive(path, content, mode, publication) {
|
|
const directory = dirname(path);
|
|
mkdirSync(directory, { recursive: true, mode: 0o700 });
|
|
const claimPath = publication?.claimPath;
|
|
if (claimPath && dirname(resolve(claimPath)) !== resolve(directory)) {
|
|
throw new Error('bootstrap artifact claim must share the artifact directory');
|
|
}
|
|
const ownershipReceipt = claimPath
|
|
? bootstrapArtifactOwnershipReceiptPath(path, claimPath)
|
|
: undefined;
|
|
// The transaction-namespaced receipt is the staging name. It is populated and
|
|
// fsynced before an atomic hard-link publishes the canonical claim name, so a
|
|
// process death can never expose a zero or partially written claim.
|
|
const temporary = ownershipReceipt
|
|
?? join(directory, `.${basename(path)}.${process.pid}.${randomUUID()}.tmp`);
|
|
let descriptor;
|
|
let stagingCreated = false;
|
|
let claimPublished = false;
|
|
try {
|
|
descriptor = openSync(temporary, 'wx', mode);
|
|
stagingCreated = true;
|
|
if (typeof content === 'string')
|
|
writeFileSync(descriptor, content, { encoding: 'utf8' });
|
|
else
|
|
writeFileSync(descriptor, content);
|
|
fchmodSync(descriptor, mode);
|
|
fsyncSync(descriptor);
|
|
const opened = fstatSync(descriptor, { bigint: true });
|
|
if (process.platform === 'win32') {
|
|
hardenWindowsOwnedFile(temporary, opened);
|
|
}
|
|
if (publication && claimPath && ownershipReceipt) {
|
|
syncDirectory(directory);
|
|
const receipt = lstatSync(ownershipReceipt, { bigint: true });
|
|
if (!opened.isFile() || opened.dev <= 0n || opened.ino <= 0n
|
|
|| receipt.dev !== opened.dev || receipt.ino !== opened.ino) {
|
|
throw new Error('bootstrap artifact ownership receipt does not bind the staged claim');
|
|
}
|
|
linkSync(ownershipReceipt, claimPath);
|
|
syncDirectory(directory);
|
|
claimPublished = true;
|
|
const claim = lstatSync(claimPath, { bigint: true });
|
|
if (claim.dev !== opened.dev || claim.ino !== opened.ino) {
|
|
throw new Error('bootstrap artifact claim does not bind the staged ownership receipt');
|
|
}
|
|
publication.onPublished(path, claimPath);
|
|
}
|
|
closeSync(descriptor);
|
|
descriptor = undefined;
|
|
linkSync(claimPath ?? temporary, path);
|
|
syncDirectory(directory);
|
|
publication?.onPublished(path, claimPath);
|
|
if (ownershipReceipt) {
|
|
rmSync(ownershipReceipt, { force: true });
|
|
syncDirectory(directory);
|
|
}
|
|
if (claimPath)
|
|
rmSync(claimPath, { force: true });
|
|
else
|
|
rmSync(temporary, { force: true });
|
|
syncDirectory(directory);
|
|
}
|
|
finally {
|
|
if (descriptor !== undefined)
|
|
closeSync(descriptor);
|
|
if (!publication) {
|
|
rmSync(temporary, { force: true });
|
|
}
|
|
else if (stagingCreated && !claimPublished) {
|
|
// A synchronous failure before claim publication cannot require restart
|
|
// recovery; the current process still owns this unpublished staging name.
|
|
rmSync(temporary, { force: true });
|
|
syncDirectory(directory);
|
|
}
|
|
}
|
|
}
|
|
export function writeDurableTextExclusive(path, content, mode = 0o600, publication) {
|
|
writeDurableExclusive(path, content, mode, publication);
|
|
}
|
|
export function writeDurableBytesExclusive(path, content, mode = 0o600, publication) {
|
|
writeDurableExclusive(path, content, mode, publication);
|
|
}
|
|
function writeDurableJson(path, value, mode = 0o600) {
|
|
writeDurableText(path, `${JSON.stringify(value)}\n`, mode);
|
|
}
|
|
export function writeDurableJsonExclusive(path, value, mode = 0o600) {
|
|
writeDurableTextExclusive(path, `${JSON.stringify(value)}\n`, mode);
|
|
}
|
|
export function removeDurableFile(path) {
|
|
try {
|
|
rmSync(path, { force: true });
|
|
syncDirectory(dirname(path));
|
|
}
|
|
catch (error) {
|
|
if (!isErrno(error, 'ENOENT'))
|
|
throw error;
|
|
}
|
|
}
|
|
export function acquireBootstrapOwnerLock(stateDir, options = {}) {
|
|
if (!existsSync(stateDir)) {
|
|
const uid = typeof process.getuid === 'function' ? process.getuid() : undefined;
|
|
assertTrustedArtifactParent(join(stateDir, '.bootstrap-owner'), process.platform, uid);
|
|
}
|
|
mkdirSync(stateDir, { recursive: true, mode: 0o700 });
|
|
assertSecureStateDirectory(stateDir);
|
|
const path = join(stateDir, BOOTSTRAP_LOCK_FILE);
|
|
const owner = util.acquireLock(path, { maxTries: options.maxTries ?? 2_400, waitMs: options.waitMs ?? 100 });
|
|
return bootstrapLockHandle(path, owner);
|
|
}
|
|
export function acquireBootstrapReadinessLock(stateDir, options = {}) {
|
|
assertSecureStateDirectory(stateDir);
|
|
const path = join(stateDir, coreBootstrap.LIFECYCLE_BOOTSTRAP_READINESS_LOCK_FILE);
|
|
const owner = util.acquireLock(path, { maxTries: options.maxTries ?? 500, waitMs: options.waitMs ?? 10 });
|
|
return bootstrapLockHandle(path, owner);
|
|
}
|
|
function bootstrapLockHandle(path, owner) {
|
|
const expectedBytes = Buffer.from(`${JSON.stringify({
|
|
v: 2,
|
|
pid: owner.pid,
|
|
token: owner.token,
|
|
processStart: owner.processStartIdentity,
|
|
})}\n`, 'utf8');
|
|
let receipt;
|
|
try {
|
|
if (process.platform === 'win32') {
|
|
const published = readBootstrapArtifactFile(path, util.MAX_LOCK_OWNER_BYTES);
|
|
if (!published.bytes.equals(expectedBytes)) {
|
|
throw new Error('bootstrap owner lock acquisition did not publish the exact owner');
|
|
}
|
|
hardenWindowsOwnedFile(path, {
|
|
dev: BigInt(published.identity.device),
|
|
ino: BigInt(published.identity.inode),
|
|
size: BigInt(published.identity.size),
|
|
});
|
|
}
|
|
receipt = readBootstrapArtifactFile(path, util.MAX_LOCK_OWNER_BYTES, { role: 'owned' });
|
|
if (!receipt.bytes.equals(expectedBytes)) {
|
|
throw new Error('bootstrap owner lock acquisition did not publish the exact owner');
|
|
}
|
|
}
|
|
catch (error) {
|
|
const released = util.releaseLock(path);
|
|
if (!released.released) {
|
|
throw new Error(`bootstrap owner lock initialization failed; release failed: ${released.reason}`, { cause: error });
|
|
}
|
|
throw error;
|
|
}
|
|
let released = false;
|
|
const assertOwned = () => {
|
|
if (released) {
|
|
throw new Error('bootstrap owner lock assertion failed: not_owned');
|
|
}
|
|
let current;
|
|
try {
|
|
current = readBootstrapArtifactFile(path, util.MAX_LOCK_OWNER_BYTES, { role: 'owned' });
|
|
}
|
|
catch {
|
|
throw new Error('bootstrap owner lock assertion failed: ownership_changed');
|
|
}
|
|
if (!current.bytes.equals(expectedBytes)
|
|
|| current.identity.size !== receipt.identity.size
|
|
|| current.identity.sha256 !== receipt.identity.sha256
|
|
|| current.identity.device !== receipt.identity.device
|
|
|| current.identity.inode !== receipt.identity.inode) {
|
|
throw new Error('bootstrap owner lock assertion failed: ownership_changed');
|
|
}
|
|
};
|
|
return {
|
|
path,
|
|
owner,
|
|
assertOwned,
|
|
release: () => {
|
|
if (released)
|
|
return;
|
|
try {
|
|
assertOwned();
|
|
}
|
|
catch {
|
|
throw new Error('bootstrap owner lock release failed: ownership_changed');
|
|
}
|
|
const result = util.releaseLock(path);
|
|
if (!result.released
|
|
|| (result.reason !== 'released' && result.reason !== 'released_with_cleanup_error')) {
|
|
throw new Error(`bootstrap owner lock release failed: ${result.reason}`);
|
|
}
|
|
released = true;
|
|
},
|
|
};
|
|
}
|
|
export function createBootstrapJournal(input) {
|
|
const now = input.now ?? Date.now();
|
|
if (!Number.isSafeInteger(input.owner.pid) || input.owner.pid <= 0
|
|
|| !UUID_RE.test(input.owner.token)
|
|
|| !parseProcessStartIdentity(input.owner.processStartIdentity)) {
|
|
throw new Error('bootstrap transaction owner must match a validated file-lock owner');
|
|
}
|
|
const uniquePaths = [...new Set(input.artifactPaths.map((path) => resolve(path)))];
|
|
if (uniquePaths.length === 0 || uniquePaths.length > 32) {
|
|
throw new Error('bootstrap transaction must declare between 1 and 32 artifacts');
|
|
}
|
|
const transactionId = input.transactionId ?? randomUUID();
|
|
if (!UUID_RE.test(transactionId))
|
|
throw new Error('bootstrap transaction id must be a UUID');
|
|
const managerArtifactPath = resolve(input.managerArtifactPath);
|
|
if (!uniquePaths.includes(managerArtifactPath)) {
|
|
throw new Error('bootstrap manager binding must reference an owned artifact');
|
|
}
|
|
const namespacePaths = uniquePaths.flatMap((path) => [
|
|
path,
|
|
bootstrapArtifactClaimPath(path, transactionId),
|
|
bootstrapArtifactRollbackPath(path, transactionId),
|
|
]);
|
|
const namespaceKeys = namespacePaths.map((path) => bootstrapPathKey(path, input.target));
|
|
if (new Set(namespaceKeys).size !== namespaceKeys.length) {
|
|
throw new Error('bootstrap transaction artifact namespace overlaps');
|
|
}
|
|
return {
|
|
schema: BOOTSTRAP_JOURNAL_SCHEMA,
|
|
transactionId,
|
|
owner: { ...input.owner, processStartIdentity: { ...input.owner.processStartIdentity }, acquiredAt: new Date(now).toISOString() },
|
|
target: input.target,
|
|
service: input.service,
|
|
managerBefore: 'absent',
|
|
managerBinding: { artifactPath: managerArtifactPath, kind: 'transaction' },
|
|
stage: 'prepared',
|
|
deadlineMs: input.deadlineMs,
|
|
artifacts: uniquePaths.map((path) => {
|
|
const identity = input.artifactIdentities[path];
|
|
if (!identity || !validArtifactIdentity(identity, false)) {
|
|
throw new Error(`bootstrap transaction is missing an expected artifact identity: ${path}`);
|
|
}
|
|
return {
|
|
path,
|
|
claimPath: bootstrapArtifactClaimPath(path, transactionId),
|
|
rollbackPath: bootstrapArtifactRollbackPath(path, transactionId),
|
|
before: 'absent',
|
|
identity: { ...identity },
|
|
};
|
|
}),
|
|
updatedAt: new Date(now).toISOString(),
|
|
};
|
|
}
|
|
export function bootstrapJournalFromMarker(marker, owner, deadlineMs, now = Date.now()) {
|
|
if (!Number.isSafeInteger(owner.pid) || owner.pid <= 0
|
|
|| !UUID_RE.test(owner.token)
|
|
|| !parseProcessStartIdentity(owner.processStartIdentity)) {
|
|
throw new Error('bootstrap marker recovery owner must match a validated file-lock owner');
|
|
}
|
|
return {
|
|
schema: BOOTSTRAP_JOURNAL_SCHEMA,
|
|
transactionId: marker.transactionId,
|
|
owner: { ...owner, processStartIdentity: { ...owner.processStartIdentity }, acquiredAt: new Date(now).toISOString() },
|
|
target: marker.target,
|
|
service: marker.service,
|
|
managerBefore: 'absent',
|
|
managerBinding: {
|
|
artifactPath: resolve(marker.managerArtifactPath),
|
|
kind: marker.managerBindingKind ?? 'transaction',
|
|
},
|
|
stage: 'activated',
|
|
deadlineMs,
|
|
artifacts: marker.artifacts.map((artifact) => ({
|
|
path: resolve(artifact.path),
|
|
claimPath: bootstrapArtifactClaimPath(artifact.path, marker.transactionId),
|
|
rollbackPath: bootstrapArtifactRollbackPath(artifact.path, marker.transactionId),
|
|
before: 'absent',
|
|
identity: {
|
|
size: artifact.size,
|
|
sha256: artifact.sha256,
|
|
...(artifact.device ? { device: artifact.device } : {}),
|
|
...(artifact.inode ? { inode: artifact.inode } : {}),
|
|
},
|
|
})),
|
|
...(marker.preservedArtifacts && marker.preservedArtifacts.length > 0
|
|
? { preservedArtifacts: marker.preservedArtifacts.map((artifact) => ({ ...artifact })) }
|
|
: {}),
|
|
successMarkerIdentity: bootstrapArtifactIdentityForBytes(Buffer.from(`${JSON.stringify(marker)}\n`, 'utf8')),
|
|
activationStarted: true,
|
|
updatedAt: new Date(now).toISOString(),
|
|
};
|
|
}
|
|
export function bootstrapJournalManagerArtifactPath(journal) {
|
|
if (!('artifactPath' in journal.managerBinding)) {
|
|
throw new Error('bootstrap recovery journal records an absent manager without an artifact binding');
|
|
}
|
|
return journal.managerBinding.artifactPath;
|
|
}
|
|
export function createLegacyBootstrapRemovalJournal(input) {
|
|
const now = input.now ?? Date.now();
|
|
const transactionId = randomUUID();
|
|
if (!Number.isSafeInteger(input.owner.pid) || input.owner.pid <= 0
|
|
|| !UUID_RE.test(input.owner.token)
|
|
|| !parseProcessStartIdentity(input.owner.processStartIdentity)
|
|
|| input.artifacts.length === 0) {
|
|
throw new Error('legacy bootstrap removal receipt is invalid');
|
|
}
|
|
const artifacts = input.artifacts.map((artifact) => ({
|
|
path: resolve(artifact.path),
|
|
claimPath: bootstrapArtifactClaimPath(artifact.path, transactionId),
|
|
rollbackPath: bootstrapArtifactRollbackPath(artifact.path, transactionId),
|
|
before: 'legacy_owned',
|
|
identity: {
|
|
size: artifact.size,
|
|
sha256: artifact.sha256,
|
|
...(artifact.device ? { device: artifact.device } : {}),
|
|
...(artifact.inode ? { inode: artifact.inode } : {}),
|
|
},
|
|
}));
|
|
const managerArtifactPath = input.managerArtifactPath === undefined
|
|
? undefined
|
|
: resolve(input.managerArtifactPath);
|
|
if (input.managerState === 'absent') {
|
|
if (managerArtifactPath !== undefined) {
|
|
throw new Error('absent legacy manager cannot declare an artifact binding');
|
|
}
|
|
}
|
|
else if (!managerArtifactPath
|
|
|| !artifacts.some((artifact) => artifact.path === managerArtifactPath)) {
|
|
throw new Error('legacy bootstrap removal manager artifact is not owned');
|
|
}
|
|
const journal = {
|
|
schema: BOOTSTRAP_JOURNAL_SCHEMA,
|
|
transactionId,
|
|
owner: {
|
|
...input.owner,
|
|
processStartIdentity: { ...input.owner.processStartIdentity },
|
|
acquiredAt: new Date(now).toISOString(),
|
|
},
|
|
target: input.target,
|
|
service: input.service,
|
|
managerBefore: input.managerState,
|
|
managerBinding: input.managerState === 'absent'
|
|
? { kind: LEGACY_BOOTSTRAP_ABSENT_MANAGER_BINDING, state: 'absent' }
|
|
: {
|
|
artifactPath: managerArtifactPath,
|
|
kind: coreBootstrap.LIFECYCLE_BOOTSTRAP_LEGACY_BINDING,
|
|
},
|
|
operation: LEGACY_BOOTSTRAP_REMOVAL_OPERATION,
|
|
stage: 'prepared',
|
|
deadlineMs: input.deadlineMs,
|
|
artifacts,
|
|
...(input.preservedArtifacts && input.preservedArtifacts.length > 0
|
|
? { preservedArtifacts: input.preservedArtifacts.map((artifact) => ({ ...artifact, path: resolve(artifact.path) })) }
|
|
: {}),
|
|
activationStarted: input.managerState !== 'absent',
|
|
artifactsRestored: false,
|
|
terminalAction: 'remove_committed',
|
|
updatedAt: new Date(now).toISOString(),
|
|
};
|
|
if (!parseBootstrapJournal(journal)) {
|
|
throw new Error('legacy bootstrap removal receipt is invalid');
|
|
}
|
|
return journal;
|
|
}
|
|
export function bootstrapArtifactClaimPath(path, transactionId) {
|
|
return join(dirname(resolve(path)), `.${basename(path)}.bootstrap-${transactionId}.claim`);
|
|
}
|
|
export function bootstrapArtifactRollbackPath(path, transactionId) {
|
|
return join(dirname(resolve(path)), `.${basename(path)}.bootstrap-${transactionId}.rollback`);
|
|
}
|
|
function bootstrapArtifactStagingCleanupPath(artifact) {
|
|
return `${artifact.rollbackPath}.staging-cleanup`;
|
|
}
|
|
export function updateBootstrapJournal(journal, patch, now = Date.now()) {
|
|
const artifacts = patch.artifactsRestored === true || patch.stage === 'rolled_back'
|
|
? journal.artifacts.map((artifact) => {
|
|
const { claimOwnership: _claimOwnership, ...restoredArtifact } = artifact;
|
|
return restoredArtifact;
|
|
})
|
|
: patch.stage === 'installing'
|
|
? journal.artifacts.map((artifact) => (artifact.before === 'absent'
|
|
&& artifact.identity?.device === undefined
|
|
&& artifact.claimOwnership === undefined
|
|
? { ...artifact, claimOwnership: { phase: 'armed' } }
|
|
: artifact))
|
|
: journal.artifacts;
|
|
return { ...journal, ...patch, artifacts, updatedAt: new Date(now).toISOString() };
|
|
}
|
|
export function bootstrapJournalPath(stateDir) {
|
|
return join(stateDir, BOOTSTRAP_JOURNAL_FILE);
|
|
}
|
|
export function bootstrapMarkerPath(stateDir) {
|
|
return join(stateDir, BOOTSTRAP_SUCCESS_FILE);
|
|
}
|
|
export function bootstrapReadinessPath(stateDir) {
|
|
return join(stateDir, coreBootstrap.LIFECYCLE_BOOTSTRAP_READINESS_FILE);
|
|
}
|
|
export function bootstrapManualTransitionPath(stateDir) {
|
|
return join(stateDir, BOOTSTRAP_MANUAL_TRANSITION_FILE);
|
|
}
|
|
function bootstrapRegistrationIntentPath(stateDir) {
|
|
return join(stateDir, BOOTSTRAP_REGISTRATION_INTENT_FILE);
|
|
}
|
|
/**
|
|
* Validate the parent's active registration token while the caller owns bootstrap-owner.lock.
|
|
* Absence, a terminal receipt, or any malformed/untrusted presentation blocks before mutation.
|
|
*/
|
|
export function assertActiveBootstrapRegistrationIntentToken(stateDir, tokenValue) {
|
|
const token = coreBootstrap.parseLifecycleBootstrapRegistrationToken(tokenValue);
|
|
if (!token)
|
|
throw new Error('bootstrap registration token is invalid');
|
|
assertSecureStateDirectory(stateDir);
|
|
const path = bootstrapRegistrationIntentPath(stateDir);
|
|
let parsed;
|
|
try {
|
|
parsed = coreBootstrap.parseLifecycleBootstrapRegistrationIntentJson(readBoundedRegularText(path, coreBootstrap.MAX_LIFECYCLE_BOOTSTRAP_REGISTRATION_INTENT_BYTES, 'bootstrap registration intent', { requireOwnerOnly: true }));
|
|
}
|
|
catch (error) {
|
|
throw new Error('bootstrap registration intent is unavailable or untrusted', { cause: error });
|
|
}
|
|
if (!parsed || parsed.state !== 'registering' || parsed.owner.token !== token) {
|
|
throw new Error('bootstrap registration intent does not authorize this child');
|
|
}
|
|
return parsed;
|
|
}
|
|
export function readBootstrapManualTransition(stateDir) {
|
|
const path = bootstrapManualTransitionPath(stateDir);
|
|
try {
|
|
assertSecureStateDirectory(stateDir);
|
|
const transition = coreBootstrap.parseLifecycleBootstrapManualTransitionJson(readBoundedRegularText(path, coreBootstrap.MAX_LIFECYCLE_BOOTSTRAP_MANUAL_TRANSITION_BYTES, 'bootstrap manual-transition tombstone', { requireOwnerOnly: true }));
|
|
if (!transition) {
|
|
throw new Error(`bootstrap manual-transition tombstone is corrupt: ${path}`);
|
|
}
|
|
return transition;
|
|
}
|
|
catch (error) {
|
|
if (isErrno(error, 'ENOENT'))
|
|
return undefined;
|
|
throw error;
|
|
}
|
|
}
|
|
function sameBootstrapManualTransitionSource(transition, source) {
|
|
return transition.removedTransactionId === source.transactionId
|
|
&& transition.target === source.target
|
|
&& transition.service === source.service;
|
|
}
|
|
/**
|
|
* Persist the operator's remove -> explicit install handoff before any committed
|
|
* manager or artifact mutation. A matching tombstone is an idempotent retry.
|
|
*/
|
|
export function ensureBootstrapManualTransition(stateDir, source, now = Date.now()) {
|
|
assertSecureStateDirectory(stateDir);
|
|
const existing = readBootstrapManualTransition(stateDir);
|
|
if (existing) {
|
|
if (!sameBootstrapManualTransitionSource(existing, source)) {
|
|
throw new Error('bootstrap manual-transition tombstone belongs to another committed service');
|
|
}
|
|
return existing;
|
|
}
|
|
const transition = {
|
|
schema: coreBootstrap.LIFECYCLE_BOOTSTRAP_MANUAL_TRANSITION_SCHEMA,
|
|
transitionId: randomUUID(),
|
|
removedTransactionId: source.transactionId,
|
|
target: source.target,
|
|
service: source.service,
|
|
createdAt: new Date(now).toISOString(),
|
|
};
|
|
if (!coreBootstrap.parseLifecycleBootstrapManualTransition(transition)) {
|
|
throw new Error('bootstrap refused to persist an invalid manual-transition tombstone');
|
|
}
|
|
const path = bootstrapManualTransitionPath(stateDir);
|
|
try {
|
|
writeDurableJsonExclusive(path, transition);
|
|
}
|
|
catch (error) {
|
|
if (!filesystemEntryPresent(path))
|
|
throw error;
|
|
const raced = readBootstrapManualTransition(stateDir);
|
|
if (!raced || !sameBootstrapManualTransitionSource(raced, source))
|
|
throw error;
|
|
return raced;
|
|
}
|
|
const durable = readBootstrapManualTransition(stateDir);
|
|
if (!durable || durable.transitionId !== transition.transitionId) {
|
|
throw new Error('bootstrap manual-transition tombstone publication is unconfirmed');
|
|
}
|
|
return durable;
|
|
}
|
|
export function removeBootstrapManualTransition(stateDir, transitionId) {
|
|
const transition = readBootstrapManualTransition(stateDir);
|
|
if (!transition || transition.transitionId !== transitionId) {
|
|
throw new Error('bootstrap refused to consume a manual-transition tombstone it does not own');
|
|
}
|
|
const path = bootstrapManualTransitionPath(stateDir);
|
|
removeDurableFile(path);
|
|
if (filesystemEntryPresent(path)) {
|
|
throw new Error('bootstrap manual-transition tombstone retirement is unconfirmed');
|
|
}
|
|
}
|
|
function bootstrapCanonicalQuarantinePath(stateDir, kind, transactionId) {
|
|
if (!UUID_RE.test(transactionId))
|
|
throw new Error('bootstrap transaction id must be a UUID');
|
|
return join(resolve(stateDir), `.bootstrap-${kind}.${transactionId}.quarantine`);
|
|
}
|
|
function bootstrapPathKey(path, target) {
|
|
return process.platform === 'win32' || target === 'windows' ? path.toLowerCase() : path;
|
|
}
|
|
function bootstrapArtifactNamespaceIsUnique(artifacts, target) {
|
|
const keys = artifacts.flatMap((artifact) => [
|
|
artifact.path,
|
|
artifact.claimPath,
|
|
artifact.rollbackPath,
|
|
bootstrapArtifactStagingCleanupPath(artifact),
|
|
])
|
|
.map((path) => bootstrapPathKey(path, target));
|
|
return new Set(keys).size === keys.length;
|
|
}
|
|
function protectedBootstrapStatePathKeys(stateDir, target) {
|
|
return new Set([
|
|
bootstrapJournalPath(stateDir),
|
|
bootstrapMarkerPath(stateDir),
|
|
bootstrapReadinessPath(stateDir),
|
|
bootstrapManualTransitionPath(stateDir),
|
|
bootstrapRegistrationIntentPath(stateDir),
|
|
join(stateDir, coreBootstrap.LIFECYCLE_BOOTSTRAP_REGISTRATION_INTENT_TERMINAL_FILE),
|
|
join(stateDir, coreBootstrap.LIFECYCLE_BOOTSTRAP_REGISTRATION_INTENT_CLEARING_FILE),
|
|
join(stateDir, coreBootstrap.LIFECYCLE_BOOTSTRAP_READINESS_LOCK_FILE),
|
|
join(stateDir, BOOTSTRAP_LOCK_FILE),
|
|
].map((path) => bootstrapPathKey(resolve(path), target)));
|
|
}
|
|
function assertBootstrapPathsDoNotOwnProtectedState(stateDir, target, paths, label) {
|
|
const protectedPaths = protectedBootstrapStatePathKeys(stateDir, target);
|
|
if (paths.some((path) => protectedPaths.has(bootstrapPathKey(path, target)))) {
|
|
throw new Error(`${label} owns a protected state path`);
|
|
}
|
|
}
|
|
function assertBootstrapCanonicalQuarantineLayout(stateDir, journal) {
|
|
const entries = journal.canonicalQuarantine ?? [];
|
|
if (entries.length === 0)
|
|
return;
|
|
const kinds = entries.map((entry) => entry.kind);
|
|
if (new Set(kinds).size !== kinds.length) {
|
|
throw new Error('bootstrap canonical quarantine contains duplicate state kinds');
|
|
}
|
|
const artifactNamespace = new Set(journal.artifacts.flatMap((artifact) => [
|
|
artifact.path,
|
|
artifact.claimPath,
|
|
artifact.rollbackPath,
|
|
bootstrapArtifactStagingCleanupPath(artifact),
|
|
]).map((path) => bootstrapPathKey(path, journal.target)));
|
|
for (const entry of entries) {
|
|
const expectedSource = resolve(entry.kind === 'marker'
|
|
? bootstrapMarkerPath(stateDir)
|
|
: bootstrapReadinessPath(stateDir));
|
|
const expectedQuarantine = bootstrapCanonicalQuarantinePath(stateDir, entry.kind, journal.transactionId);
|
|
if (entry.sourcePath !== expectedSource || entry.quarantinePath !== expectedQuarantine) {
|
|
throw new Error('bootstrap canonical quarantine path is outside its durable state namespace');
|
|
}
|
|
if (artifactNamespace.has(bootstrapPathKey(entry.sourcePath, journal.target))
|
|
|| artifactNamespace.has(bootstrapPathKey(entry.quarantinePath, journal.target))) {
|
|
throw new Error('bootstrap artifact namespace overlaps canonical quarantine state');
|
|
}
|
|
}
|
|
}
|
|
export function writeBootstrapJournal(stateDir, journal) {
|
|
if (!parseBootstrapJournal(journal)) {
|
|
throw new Error('bootstrap refused to persist an invalid recovery journal');
|
|
}
|
|
assertBootstrapPathsDoNotOwnProtectedState(stateDir, journal.target, [
|
|
...journal.artifacts.flatMap((artifact) => [
|
|
artifact.path,
|
|
artifact.claimPath,
|
|
artifact.rollbackPath,
|
|
bootstrapArtifactStagingCleanupPath(artifact),
|
|
]),
|
|
...(journal.preservedArtifacts ?? []).map((artifact) => artifact.path),
|
|
], 'bootstrap recovery journal');
|
|
assertBootstrapCanonicalQuarantineLayout(stateDir, journal);
|
|
writeDurableJson(bootstrapJournalPath(stateDir), journal, 0o600);
|
|
}
|
|
export function removeBootstrapJournal(stateDir) {
|
|
removeDurableFile(bootstrapJournalPath(stateDir));
|
|
}
|
|
function parseArtifact(value) {
|
|
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
return undefined;
|
|
const record = value;
|
|
const claimOwnership = parseArtifactClaimOwnership(record['claimOwnership']);
|
|
if ((record['claimOwnership'] !== undefined && claimOwnership === undefined)
|
|
|| !exactRecordKeys(record, [
|
|
'path',
|
|
'claimPath',
|
|
'rollbackPath',
|
|
'before',
|
|
'identity',
|
|
...(record['claimOwnership'] === undefined ? [] : ['claimOwnership']),
|
|
])
|
|
|| !boundedText(record['path'], 4_096) || !isAbsolute(record['path'])
|
|
|| resolve(record['path']) !== record['path']
|
|
|| (record['before'] !== 'absent' && record['before'] !== 'legacy_owned'))
|
|
return undefined;
|
|
if (!boundedText(record['claimPath'], 4_096) || !isAbsolute(record['claimPath'])
|
|
|| resolve(record['claimPath']) !== record['claimPath'])
|
|
return undefined;
|
|
if (!boundedText(record['rollbackPath'], 4_096) || !isAbsolute(record['rollbackPath'])
|
|
|| resolve(record['rollbackPath']) !== record['rollbackPath'])
|
|
return undefined;
|
|
const identity = record['identity'];
|
|
if (!identity || typeof identity !== 'object' || Array.isArray(identity))
|
|
return undefined;
|
|
const identityRecord = identity;
|
|
const identityKeys = identityRecord['device'] === undefined
|
|
? ['size', 'sha256']
|
|
: ['size', 'sha256', 'device', 'inode'];
|
|
if (!exactRecordKeys(identityRecord, identityKeys)
|
|
|| !validArtifactIdentity(identityRecord, true)
|
|
|| (record['before'] === 'legacy_owned'
|
|
&& (typeof identityRecord['device'] !== 'string'
|
|
|| typeof identityRecord['inode'] !== 'string')))
|
|
return undefined;
|
|
return {
|
|
path: record['path'],
|
|
claimPath: record['claimPath'],
|
|
rollbackPath: record['rollbackPath'],
|
|
before: record['before'],
|
|
identity: {
|
|
size: identityRecord['size'],
|
|
sha256: identityRecord['sha256'],
|
|
...(typeof identityRecord['device'] === 'string' ? { device: identityRecord['device'] } : {}),
|
|
...(typeof identityRecord['inode'] === 'string' ? { inode: identityRecord['inode'] } : {}),
|
|
},
|
|
...(claimOwnership ? { claimOwnership } : {}),
|
|
};
|
|
}
|
|
function parseArtifactClaimOwnership(value) {
|
|
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
return undefined;
|
|
const record = value;
|
|
if (record['phase'] === 'armed') {
|
|
return exactRecordKeys(record, ['phase']) ? { phase: 'armed' } : undefined;
|
|
}
|
|
if (record['phase'] !== 'created'
|
|
|| !exactRecordKeys(record, ['phase', 'device', 'inode'])
|
|
|| typeof record['device'] !== 'string'
|
|
|| typeof record['inode'] !== 'string'
|
|
|| !/^[0-9]+$/.test(record['device'])
|
|
|| !/^[0-9]+$/.test(record['inode'])
|
|
|| BigInt(record['device']) <= 0n
|
|
|| BigInt(record['inode']) <= 0n) {
|
|
return undefined;
|
|
}
|
|
return { phase: 'created', device: record['device'], inode: record['inode'] };
|
|
}
|
|
function exactRecordKeys(record, expected) {
|
|
const actual = Object.keys(record).sort();
|
|
const sortedExpected = [...expected].sort();
|
|
return actual.length === sortedExpected.length
|
|
&& actual.every((key, index) => key === sortedExpected[index]);
|
|
}
|
|
function parsePreservedArtifact(value) {
|
|
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
return undefined;
|
|
const record = value;
|
|
if (!exactRecordKeys(record, ['path', 'size', 'sha256', 'device', 'inode'])
|
|
|| !boundedText(record['path'], 4_096) || !isAbsolute(record['path'])
|
|
|| resolve(record['path']) !== record['path']
|
|
|| !validArtifactIdentity(record, true)
|
|
|| typeof record['device'] !== 'string'
|
|
|| typeof record['inode'] !== 'string')
|
|
return undefined;
|
|
return {
|
|
path: record['path'],
|
|
size: record['size'],
|
|
sha256: record['sha256'],
|
|
device: record['device'],
|
|
inode: record['inode'],
|
|
};
|
|
}
|
|
function parseCanonicalQuarantine(value) {
|
|
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
return undefined;
|
|
const record = value;
|
|
if ((record['kind'] !== 'marker' && record['kind'] !== 'readiness')
|
|
|| !boundedText(record['sourcePath'], 4_096) || !isAbsolute(record['sourcePath'])
|
|
|| resolve(record['sourcePath']) !== record['sourcePath']
|
|
|| !boundedText(record['quarantinePath'], 4_096) || !isAbsolute(record['quarantinePath'])
|
|
|| resolve(record['quarantinePath']) !== record['quarantinePath'])
|
|
return undefined;
|
|
const identity = record['identity'];
|
|
if (!identity || typeof identity !== 'object' || Array.isArray(identity))
|
|
return undefined;
|
|
const identityRecord = identity;
|
|
if (!validArtifactIdentity(identityRecord, true)
|
|
|| typeof identityRecord['device'] !== 'string'
|
|
|| typeof identityRecord['inode'] !== 'string')
|
|
return undefined;
|
|
return {
|
|
kind: record['kind'],
|
|
sourcePath: record['sourcePath'],
|
|
quarantinePath: record['quarantinePath'],
|
|
identity: {
|
|
size: identityRecord['size'],
|
|
sha256: identityRecord['sha256'],
|
|
device: identityRecord['device'],
|
|
inode: identityRecord['inode'],
|
|
},
|
|
};
|
|
}
|
|
function validArtifactIdentity(value, allowFileId) {
|
|
const record = value;
|
|
if (!Number.isSafeInteger(record['size']) || record['size'] < 0)
|
|
return false;
|
|
if (typeof record['sha256'] !== 'string' || !SHA256_RE.test(record['sha256']))
|
|
return false;
|
|
if (!allowFileId && (record['device'] !== undefined || record['inode'] !== undefined))
|
|
return false;
|
|
if ((record['device'] === undefined) !== (record['inode'] === undefined))
|
|
return false;
|
|
if (record['device'] !== undefined && (!/^[0-9]+$/.test(String(record['device']))
|
|
|| !/^[0-9]+$/.test(String(record['inode']))
|
|
|| BigInt(String(record['device'])) <= 0n
|
|
|| BigInt(String(record['inode'])) <= 0n))
|
|
return false;
|
|
return true;
|
|
}
|
|
function parseProcessStartIdentity(value) {
|
|
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
return undefined;
|
|
const record = value;
|
|
if (record['source'] === 'linux-proc'
|
|
&& typeof record['bootId'] === 'string'
|
|
&& /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(record['bootId'])
|
|
&& typeof record['startTicks'] === 'string' && /^[1-9]\d*$/.test(record['startTicks'])) {
|
|
return { source: 'linux-proc', bootId: record['bootId'], startTicks: record['startTicks'] };
|
|
}
|
|
if (record['source'] === 'windows-powershell'
|
|
&& typeof record['startTimeTicks'] === 'string' && /^[1-9]\d*$/.test(record['startTimeTicks'])) {
|
|
return { source: 'windows-powershell', startTimeTicks: record['startTimeTicks'] };
|
|
}
|
|
if (record['source'] === 'darwin-ps'
|
|
&& boundedText(record['startTime'], 128)) {
|
|
return { source: 'darwin-ps', startTime: record['startTime'] };
|
|
}
|
|
return undefined;
|
|
}
|
|
export function parseBootstrapJournal(value) {
|
|
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
return undefined;
|
|
const record = value;
|
|
const owner = record['owner'];
|
|
const managerBinding = record['managerBinding'];
|
|
const legacyRemoval = record['operation'] === LEGACY_BOOTSTRAP_REMOVAL_OPERATION;
|
|
if (record['operation'] !== undefined && !legacyRemoval)
|
|
return undefined;
|
|
if (record['schema'] !== BOOTSTRAP_JOURNAL_SCHEMA
|
|
|| typeof record['transactionId'] !== 'string' || !UUID_RE.test(record['transactionId'])
|
|
|| !owner || typeof owner !== 'object' || Array.isArray(owner)
|
|
|| typeof record['target'] !== 'string' || !TARGETS.has(record['target'])
|
|
|| !boundedText(record['service'], 128)
|
|
|| (!legacyRemoval && record['managerBefore'] !== 'absent')
|
|
|| (legacyRemoval && !['absent', 'present', 'disabled'].includes(record['managerBefore']))
|
|
|| !managerBinding || typeof managerBinding !== 'object' || Array.isArray(managerBinding)
|
|
|| typeof record['stage'] !== 'string' || !JOURNAL_STAGES.has(record['stage'])
|
|
|| !Number.isSafeInteger(record['deadlineMs']) || record['deadlineMs'] <= 0
|
|
|| !Array.isArray(record['artifacts']) || record['artifacts'].length === 0 || record['artifacts'].length > 32
|
|
|| typeof record['updatedAt'] !== 'string' || Number.isNaN(Date.parse(record['updatedAt'])))
|
|
return undefined;
|
|
const ownerRecord = owner;
|
|
const managerBindingRecord = managerBinding;
|
|
const processStartIdentity = parseProcessStartIdentity(ownerRecord['processStartIdentity']);
|
|
if (!Number.isSafeInteger(ownerRecord['pid']) || ownerRecord['pid'] <= 0
|
|
|| typeof ownerRecord['token'] !== 'string' || !UUID_RE.test(ownerRecord['token'])
|
|
|| !processStartIdentity
|
|
|| typeof ownerRecord['acquiredAt'] !== 'string' || Number.isNaN(Date.parse(ownerRecord['acquiredAt'])))
|
|
return undefined;
|
|
const absentManagerBinding = managerBindingRecord['kind'] === LEGACY_BOOTSTRAP_ABSENT_MANAGER_BINDING;
|
|
if (absentManagerBinding) {
|
|
if (!legacyRemoval || record['managerBefore'] !== 'absent'
|
|
|| !exactRecordKeys(managerBindingRecord, ['kind', 'state'])
|
|
|| managerBindingRecord['state'] !== 'absent')
|
|
return undefined;
|
|
}
|
|
else {
|
|
if (!boundedText(managerBindingRecord['artifactPath'], 4_096)
|
|
|| !isAbsolute(managerBindingRecord['artifactPath'])
|
|
|| resolve(managerBindingRecord['artifactPath']) !== managerBindingRecord['artifactPath'])
|
|
return undefined;
|
|
if (managerBindingRecord['kind'] !== undefined
|
|
&& managerBindingRecord['kind'] !== 'transaction'
|
|
&& managerBindingRecord['kind'] !== coreBootstrap.LIFECYCLE_BOOTSTRAP_LEGACY_BINDING)
|
|
return undefined;
|
|
}
|
|
const artifacts = record['artifacts'].map(parseArtifact);
|
|
if (artifacts.some((artifact) => artifact === undefined))
|
|
return undefined;
|
|
if (legacyRemoval !== artifacts.every((artifact) => artifact.before === 'legacy_owned'))
|
|
return undefined;
|
|
if (artifacts.some((artifact) => artifact.claimOwnership !== undefined)
|
|
&& (legacyRemoval
|
|
|| (record['stage'] !== 'installing' && record['stage'] !== 'rollback_pending'))) {
|
|
return undefined;
|
|
}
|
|
const preservedValue = record['preservedArtifacts'];
|
|
if (preservedValue !== undefined
|
|
&& (!(legacyRemoval
|
|
|| managerBindingRecord['kind'] === coreBootstrap.LIFECYCLE_BOOTSTRAP_LEGACY_BINDING)
|
|
|| !Array.isArray(preservedValue)
|
|
|| preservedValue.length === 0 || preservedValue.length > 32))
|
|
return undefined;
|
|
const preservedArtifacts = Array.isArray(preservedValue)
|
|
? preservedValue.map(parsePreservedArtifact)
|
|
: [];
|
|
if (preservedArtifacts.some((artifact) => artifact === undefined))
|
|
return undefined;
|
|
const successMarkerIdentity = record['successMarkerIdentity'];
|
|
if (successMarkerIdentity !== undefined
|
|
&& (!successMarkerIdentity || typeof successMarkerIdentity !== 'object'
|
|
|| Array.isArray(successMarkerIdentity)
|
|
|| !exactRecordKeys(successMarkerIdentity, ['size', 'sha256'])
|
|
|| !validArtifactIdentity(successMarkerIdentity, false)))
|
|
return undefined;
|
|
if (!legacyRemoval
|
|
&& managerBindingRecord['kind'] === coreBootstrap.LIFECYCLE_BOOTSTRAP_LEGACY_BINDING
|
|
&& successMarkerIdentity === undefined)
|
|
return undefined;
|
|
const quarantineValue = record['canonicalQuarantine'];
|
|
if (quarantineValue !== undefined
|
|
&& (!Array.isArray(quarantineValue) || quarantineValue.length === 0 || quarantineValue.length > 2))
|
|
return undefined;
|
|
const canonicalQuarantine = Array.isArray(quarantineValue)
|
|
? quarantineValue.map(parseCanonicalQuarantine)
|
|
: [];
|
|
if (canonicalQuarantine.some((entry) => entry === undefined))
|
|
return undefined;
|
|
const quarantineKinds = canonicalQuarantine.map((entry) => entry.kind);
|
|
if (new Set(quarantineKinds).size !== quarantineKinds.length)
|
|
return undefined;
|
|
if (record['managerDetached'] !== undefined && typeof record['managerDetached'] !== 'boolean')
|
|
return undefined;
|
|
if (record['artifactsRestored'] !== undefined && typeof record['artifactsRestored'] !== 'boolean')
|
|
return undefined;
|
|
if (record['activationStarted'] !== undefined && typeof record['activationStarted'] !== 'boolean')
|
|
return undefined;
|
|
if (record['terminalAction'] !== undefined && record['terminalAction'] !== 'remove_committed')
|
|
return undefined;
|
|
if (record['terminalAction'] === 'remove_committed'
|
|
&& record['stage'] !== 'rollback_pending' && record['stage'] !== 'rolled_back'
|
|
&& !(legacyRemoval && record['stage'] === 'prepared'))
|
|
return undefined;
|
|
if (legacyRemoval && record['terminalAction'] !== 'remove_committed')
|
|
return undefined;
|
|
if (legacyRemoval && !['prepared', 'rollback_pending', 'rolled_back'].includes(record['stage'])) {
|
|
return undefined;
|
|
}
|
|
if (record['lastError'] !== undefined && !boundedText(record['lastError'], 512))
|
|
return undefined;
|
|
const paths = artifacts.map((artifact) => artifact.path);
|
|
const pathKeys = paths.map((path) => bootstrapPathKey(path, record['target']));
|
|
if (new Set(pathKeys).size !== pathKeys.length)
|
|
return undefined;
|
|
if (!bootstrapArtifactNamespaceIsUnique(artifacts, record['target']))
|
|
return undefined;
|
|
if (artifacts.some((artifact) => (bootstrapArtifactStagingCleanupPath(artifact).length > 4_096)))
|
|
return undefined;
|
|
if (artifacts.some((artifact) => artifact.claimPath !== bootstrapArtifactClaimPath(artifact.path, record['transactionId'])
|
|
|| artifact.rollbackPath !== bootstrapArtifactRollbackPath(artifact.path, record['transactionId'])))
|
|
return undefined;
|
|
const artifactNamespaceKeys = artifacts.flatMap((artifact) => [
|
|
artifact.path,
|
|
artifact.claimPath,
|
|
artifact.rollbackPath,
|
|
bootstrapArtifactStagingCleanupPath(artifact),
|
|
]).map((path) => bootstrapPathKey(path, record['target']));
|
|
const preservedKeys = preservedArtifacts.map((artifact) => bootstrapPathKey(artifact.path, record['target']));
|
|
if (new Set([...artifactNamespaceKeys, ...preservedKeys]).size
|
|
!== artifactNamespaceKeys.length + preservedKeys.length)
|
|
return undefined;
|
|
if (!absentManagerBinding) {
|
|
const managerKey = bootstrapPathKey(managerBindingRecord['artifactPath'], record['target']);
|
|
if (!pathKeys.includes(managerKey))
|
|
return undefined;
|
|
}
|
|
return record;
|
|
}
|
|
export function readBootstrapJournal(stateDir) {
|
|
const path = bootstrapJournalPath(stateDir);
|
|
try {
|
|
assertSecureStateDirectory(stateDir);
|
|
const parsed = parseBootstrapJournal(JSON.parse(readBoundedRegularText(path, MAX_JOURNAL_BYTES, 'bootstrap recovery journal', { requireOwnerOnly: true })));
|
|
if (!parsed)
|
|
throw new Error(`bootstrap recovery journal is corrupt: ${path}`);
|
|
assertBootstrapPathsDoNotOwnProtectedState(stateDir, parsed.target, [
|
|
...parsed.artifacts.flatMap((artifact) => [
|
|
artifact.path,
|
|
artifact.claimPath,
|
|
artifact.rollbackPath,
|
|
bootstrapArtifactStagingCleanupPath(artifact),
|
|
]),
|
|
...(parsed.preservedArtifacts ?? []).map((artifact) => artifact.path),
|
|
], 'bootstrap recovery journal');
|
|
assertBootstrapCanonicalQuarantineLayout(stateDir, parsed);
|
|
return parsed;
|
|
}
|
|
catch (error) {
|
|
if (isErrno(error, 'ENOENT'))
|
|
return undefined;
|
|
if (error instanceof Error && error.message.startsWith('bootstrap recovery journal is corrupt:'))
|
|
throw error;
|
|
throw new Error(`bootstrap recovery journal is corrupt: ${path}`, { cause: error });
|
|
}
|
|
}
|
|
export function readBootstrapMarker(stateDir) {
|
|
try {
|
|
assertSecureStateDirectory(stateDir);
|
|
const marker = coreBootstrap.parseLifecycleBootstrapMarkerJson(readBoundedRegularText(bootstrapMarkerPath(stateDir), MAX_MARKER_BYTES, 'bootstrap success marker', { requireOwnerOnly: true }));
|
|
if (!marker)
|
|
throw new Error(`bootstrap success marker is corrupt: ${bootstrapMarkerPath(stateDir)}`);
|
|
assertBootstrapPathsDoNotOwnProtectedState(stateDir, marker.target, [
|
|
marker.managerArtifactPath,
|
|
...marker.artifacts.map((artifact) => artifact.path),
|
|
...(marker.preservedArtifacts ?? []).map((artifact) => artifact.path),
|
|
], 'bootstrap success marker');
|
|
return marker;
|
|
}
|
|
catch (error) {
|
|
if (isErrno(error, 'ENOENT'))
|
|
return undefined;
|
|
throw error;
|
|
}
|
|
}
|
|
export function readLegacyBootstrapMarker(stateDir) {
|
|
const path = bootstrapMarkerPath(stateDir);
|
|
try {
|
|
assertSecureStateDirectory(stateDir);
|
|
const raw = readBoundedRegularText(path, MAX_MARKER_BYTES, 'legacy bootstrap success marker', { requireOwnerOnly: true });
|
|
const marker = coreBootstrap.parseLegacyLifecycleBootstrapMarkerJson(raw);
|
|
if (!marker)
|
|
throw new Error(`legacy bootstrap success marker is corrupt: ${path}`);
|
|
assertBootstrapPathsDoNotOwnProtectedState(stateDir, marker.target, marker.files, 'legacy bootstrap success marker');
|
|
const identity = identityFor(path);
|
|
const rawIdentity = bootstrapArtifactIdentityForBytes(Buffer.from(raw, 'utf8'));
|
|
if (identity.size !== rawIdentity.size || identity.sha256 !== rawIdentity.sha256
|
|
|| identity.device === undefined || identity.inode === undefined) {
|
|
throw new Error(`legacy bootstrap success marker changed while reading: ${path}`);
|
|
}
|
|
return {
|
|
marker,
|
|
raw,
|
|
identity: identity,
|
|
};
|
|
}
|
|
catch (error) {
|
|
if (isErrno(error, 'ENOENT'))
|
|
return undefined;
|
|
throw error;
|
|
}
|
|
}
|
|
function sameLegacyBootstrapMarkerRead(left, right) {
|
|
return left.raw === right.raw
|
|
&& left.identity.size === right.identity.size
|
|
&& left.identity.sha256 === right.identity.sha256
|
|
&& left.identity.device === right.identity.device
|
|
&& left.identity.inode === right.identity.inode;
|
|
}
|
|
function sameLegacyMarkerReceipt(legacy, adopted) {
|
|
const ownedPaths = adopted.artifacts.map((artifact) => artifact.path);
|
|
const preservedPaths = (adopted.preservedArtifacts ?? []).map((artifact) => artifact.path);
|
|
const historicalOwnedPaths = ownedPaths.filter((path) => legacy.files.includes(path));
|
|
const derivedOwnedPaths = ownedPaths.filter((path) => !legacy.files.includes(path));
|
|
const historicalPreservedPaths = preservedPaths.filter((path) => legacy.files.includes(path));
|
|
const derivedPreservedPaths = preservedPaths.filter((path) => !legacy.files.includes(path));
|
|
const stateRootProofPath = adopted.legacyStateRootProof?.envFilePath;
|
|
const allowedWindowsDerived = derivedOwnedPaths.every((path) => ['evolver-proxy-task-launcher.vbs', 'evolver-recovery-controller.exe']
|
|
.includes(win32.basename(path).toLowerCase()));
|
|
const legacyPartition = [...historicalOwnedPaths, ...historicalPreservedPaths];
|
|
const expectedHistoricalOwned = legacy.files.filter((path) => !historicalPreservedPaths.includes(path));
|
|
const expectedPreserved = legacy.files.filter((path) => historicalPreservedPaths.includes(path));
|
|
const historicalAutoexec = legacy.files.filter((path) => {
|
|
const name = (legacy.target === 'windows' ? win32 : posix).basename(path).toLowerCase();
|
|
return legacy.target === 'systemd'
|
|
? name === 'evolver-autoexec.service'
|
|
: legacy.target === 'launchd'
|
|
? name === 'com.evomap.evolver-autoexec.plist'
|
|
: /^install-evolver-autoexec-windows(?:-[0-9a-f-]+)?\.ps1$/i.test(name);
|
|
});
|
|
return adopted.managerBindingKind === coreBootstrap.LIFECYCLE_BOOTSTRAP_LEGACY_BINDING
|
|
&& legacy.bootstrappedAt === adopted.bootstrappedAt
|
|
&& legacy.target === adopted.target
|
|
&& legacy.service === adopted.service
|
|
&& adopted.files.length === ownedPaths.length + preservedPaths.length
|
|
&& adopted.files.every((path, index) => path === [...ownedPaths, ...preservedPaths][index])
|
|
&& legacy.files.length === legacyPartition.length
|
|
&& legacy.files.every((path) => legacyPartition.includes(path))
|
|
&& expectedHistoricalOwned.every((path, index) => path === historicalOwnedPaths[index])
|
|
&& expectedPreserved.every((path, index) => path === historicalPreservedPaths[index])
|
|
&& historicalAutoexec.length === historicalPreservedPaths.length
|
|
&& historicalAutoexec.every((path, index) => path === historicalPreservedPaths[index])
|
|
&& new Set(legacyPartition).size === legacyPartition.length
|
|
&& (stateRootProofPath === undefined
|
|
? derivedPreservedPaths.length === 0
|
|
: derivedPreservedPaths.length === 1
|
|
&& derivedPreservedPaths[0] === stateRootProofPath
|
|
&& preservedPaths[preservedPaths.length - 1] === stateRootProofPath)
|
|
&& (derivedOwnedPaths.length === 0
|
|
|| (legacy.target === 'windows' && derivedOwnedPaths.length <= 2 && allowedWindowsDerived));
|
|
}
|
|
/** Adopt an exact legacy marker through the durable canonical-state transaction. */
|
|
export async function adoptLegacyBootstrapMarker(stateDir, expectedLegacy, marker, initialJournal, now = Date.now, hooks = {}) {
|
|
if (marker.managerBindingKind !== coreBootstrap.LIFECYCLE_BOOTSTRAP_LEGACY_BINDING) {
|
|
throw new Error('legacy bootstrap adoption requires an explicit legacy manager binding');
|
|
}
|
|
if (!coreBootstrap.parseLifecycleBootstrapMarker(marker)) {
|
|
throw new Error('legacy bootstrap adoption marker is invalid');
|
|
}
|
|
if (!sameLegacyMarkerReceipt(expectedLegacy.marker, marker)) {
|
|
throw new Error('legacy bootstrap adoption marker does not bind the legacy receipt');
|
|
}
|
|
if (initialJournal.managerBinding.kind === LEGACY_BOOTSTRAP_ABSENT_MANAGER_BINDING) {
|
|
throw new Error('legacy bootstrap adoption requires a present manager binding');
|
|
}
|
|
if (initialJournal.stage !== 'prepared'
|
|
|| initialJournal.activationStarted === true
|
|
|| initialJournal.artifactsRestored !== true
|
|
|| (initialJournal.managerBinding.kind ?? 'transaction') !== marker.managerBindingKind
|
|
|| !initialJournal.successMarkerIdentity
|
|
|| initialJournal.successMarkerIdentity.size !== Buffer.byteLength(`${JSON.stringify(marker)}\n`, 'utf8')
|
|
|| initialJournal.successMarkerIdentity.sha256 !== bootstrapArtifactIdentityForBytes(Buffer.from(`${JSON.stringify(marker)}\n`, 'utf8')).sha256
|
|
|| initialJournal.transactionId !== marker.transactionId
|
|
|| initialJournal.target !== marker.target
|
|
|| initialJournal.service !== marker.service
|
|
|| initialJournal.managerBinding.artifactPath !== resolve(marker.managerArtifactPath)
|
|
|| initialJournal.artifacts.length !== marker.artifacts.length
|
|
|| initialJournal.artifacts.some((artifact, index) => {
|
|
const receipt = marker.artifacts[index];
|
|
return !receipt || !artifact.identity
|
|
|| artifact.path !== resolve(receipt.path)
|
|
|| artifact.identity.size !== receipt.size
|
|
|| artifact.identity.sha256 !== receipt.sha256
|
|
|| artifact.identity.device !== receipt.device
|
|
|| artifact.identity.inode !== receipt.inode;
|
|
})) {
|
|
throw new Error('legacy bootstrap adoption journal does not bind the adopted receipt');
|
|
}
|
|
const current = readLegacyBootstrapMarker(stateDir);
|
|
if (!current || !sameLegacyBootstrapMarkerRead(current, expectedLegacy)) {
|
|
throw new Error('legacy bootstrap success marker changed before adoption');
|
|
}
|
|
let journal = planBootstrapCanonicalQuarantine(stateDir, initialJournal, ['marker']);
|
|
const quarantine = journal.canonicalQuarantine?.[0];
|
|
if (!quarantine
|
|
|| quarantine.kind !== 'marker'
|
|
|| quarantine.identity.size !== expectedLegacy.identity.size
|
|
|| quarantine.identity.sha256 !== expectedLegacy.identity.sha256
|
|
|| quarantine.identity.device !== expectedLegacy.identity.device
|
|
|| quarantine.identity.inode !== expectedLegacy.identity.inode) {
|
|
throw new Error('legacy bootstrap adoption quarantine does not bind the exact legacy marker');
|
|
}
|
|
assertBootstrapTransactionClaimsAbsent(journal);
|
|
hooks.assertOwner?.();
|
|
writeBootstrapJournal(stateDir, journal);
|
|
hooks.beforeQuarantine?.(journal);
|
|
hooks.assertOwner?.();
|
|
applyBootstrapCanonicalQuarantine(stateDir, journal, () => hooks.assertOwner?.());
|
|
hooks.afterQuarantine?.();
|
|
journal = updateBootstrapJournal(journal, { stage: 'committing' }, now());
|
|
hooks.assertOwner?.();
|
|
writeBootstrapJournal(stateDir, journal);
|
|
await hooks.beforePublish?.(journal);
|
|
hooks.assertOwner?.();
|
|
writeDurableJsonExclusive(bootstrapMarkerPath(stateDir), marker, 0o600);
|
|
hooks.afterPublish?.();
|
|
const adopted = readBootstrapMarker(stateDir);
|
|
if (!adopted || JSON.stringify(adopted) !== JSON.stringify(marker)) {
|
|
throw new Error('legacy bootstrap adoption marker publication is ambiguous');
|
|
}
|
|
journal = updateBootstrapJournal(journal, { stage: 'committed' }, now());
|
|
hooks.assertOwner?.();
|
|
writeBootstrapJournal(stateDir, journal);
|
|
hooks.beforeFinalize?.();
|
|
hooks.assertOwner?.();
|
|
finalizeBootstrapCanonicalQuarantine(stateDir, journal, {
|
|
beforeMove: () => hooks.assertOwner?.(),
|
|
afterMove: () => hooks.assertOwner?.(),
|
|
beforeDelete: () => hooks.assertOwner?.(),
|
|
});
|
|
hooks.assertOwner?.();
|
|
removeBootstrapJournal(stateDir);
|
|
}
|
|
export function readBootstrapReadiness(stateDir) {
|
|
const path = bootstrapReadinessPath(stateDir);
|
|
try {
|
|
assertSecureStateDirectory(stateDir);
|
|
const readiness = coreBootstrap.parseLifecycleBootstrapReadinessJson(readBoundedRegularText(path, MAX_READINESS_BYTES, 'bootstrap readiness receipt', { requireOwnerOnly: true }));
|
|
if (!readiness)
|
|
throw new Error(`bootstrap readiness receipt is corrupt: ${path}`);
|
|
return readiness;
|
|
}
|
|
catch (error) {
|
|
if (isErrno(error, 'ENOENT'))
|
|
return undefined;
|
|
throw error;
|
|
}
|
|
}
|
|
export function removeBootstrapReadiness(stateDir, transactionId, beforeMutation = () => { }) {
|
|
beforeMutation();
|
|
const lock = acquireBootstrapReadinessLock(stateDir);
|
|
let operationError;
|
|
try {
|
|
const readiness = readBootstrapReadiness(stateDir);
|
|
if (readiness) {
|
|
if (readiness.transactionId !== transactionId) {
|
|
throw new Error('bootstrap refused to remove readiness owned by another transaction');
|
|
}
|
|
beforeMutation();
|
|
removeDurableFile(bootstrapReadinessPath(stateDir));
|
|
}
|
|
}
|
|
catch (error) {
|
|
operationError = error;
|
|
}
|
|
beforeMutation();
|
|
releaseBootstrapLockAfterOperation(lock, operationError, 'readiness');
|
|
if (operationError !== undefined)
|
|
throw operationError;
|
|
}
|
|
function releaseBootstrapLockAfterOperation(lock, operationError, label) {
|
|
try {
|
|
lock.release();
|
|
}
|
|
catch (releaseError) {
|
|
throw new Error(`bootstrap ${label} lock release failed`, {
|
|
cause: operationError === undefined ? releaseError : new AggregateError([operationError, releaseError]),
|
|
});
|
|
}
|
|
}
|
|
export function assertTrustedArtifactParent(path, platform, uid) {
|
|
const parent = dirname(resolve(path));
|
|
if (platform !== 'win32') {
|
|
assertTrustedPosixDirectoryChain(parent, platform, uid, 'bootstrap artifact parent');
|
|
return;
|
|
}
|
|
let current = parent;
|
|
const root = parse(current).root;
|
|
const windowsChecks = [];
|
|
for (;;) {
|
|
try {
|
|
const stat = lstatSync(current);
|
|
if (platform === 'win32') {
|
|
windowsChecks.push({ path: current, parentOnly: windowsChecks.length > 0 });
|
|
}
|
|
if (stat.isSymbolicLink() || !stat.isDirectory()) {
|
|
throw new Error(`bootstrap artifact parent is not a trusted directory: ${current}`);
|
|
}
|
|
}
|
|
catch (error) {
|
|
if (!isErrno(error, 'ENOENT'))
|
|
throw error;
|
|
}
|
|
if (current === root)
|
|
break;
|
|
current = dirname(current);
|
|
}
|
|
assertWindowsAclChecksTrusted(windowsChecks);
|
|
}
|
|
export function assertPlannedArtifactsAbsent(paths, platform, uid) {
|
|
const normalized = paths.map((path) => resolve(path));
|
|
const keys = normalized.map((path) => platform === 'win32' ? path.toLowerCase() : path);
|
|
if (paths.some((path, index) => !isAbsolute(path) || path !== normalized[index])
|
|
|| new Set(keys).size !== keys.length) {
|
|
throw new Error('bootstrap artifact inventory must contain unique canonical absolute paths');
|
|
}
|
|
for (const path of normalized) {
|
|
assertTrustedArtifactParent(path, platform, uid);
|
|
try {
|
|
lstatSync(path);
|
|
throw new Error(`bootstrap refuses to overwrite pre-existing artifact: ${path}`);
|
|
}
|
|
catch (error) {
|
|
if (!isErrno(error, 'ENOENT'))
|
|
throw error;
|
|
}
|
|
}
|
|
}
|
|
export function assertBootstrapTransactionClaimsAbsent(journal) {
|
|
for (const artifact of journal.artifacts) {
|
|
for (const path of [
|
|
artifact.claimPath,
|
|
artifact.rollbackPath,
|
|
bootstrapArtifactStagingCleanupPath(artifact),
|
|
]) {
|
|
try {
|
|
lstatSync(path);
|
|
throw new Error(`bootstrap transaction path already exists: ${path}`);
|
|
}
|
|
catch (error) {
|
|
if (!isErrno(error, 'ENOENT'))
|
|
throw error;
|
|
}
|
|
}
|
|
}
|
|
for (const entry of journal.canonicalQuarantine ?? []) {
|
|
for (const path of [entry.quarantinePath, bootstrapCanonicalFinalizePath(entry)]) {
|
|
try {
|
|
lstatSync(path);
|
|
throw new Error(`bootstrap transaction path already exists: ${path}`);
|
|
}
|
|
catch (error) {
|
|
if (!isErrno(error, 'ENOENT'))
|
|
throw error;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
function assertTrustedBootstrapArtifactLeaf(stat, platform, uid, path, role) {
|
|
if (role === 'transaction')
|
|
return;
|
|
const ownerTrusted = role === 'owned'
|
|
? uid !== undefined && stat.uid === uid
|
|
: uid !== undefined && (stat.uid === uid || stat.uid === 0n);
|
|
if ((role === 'owned' && stat.nlink !== 1n)
|
|
|| (platform !== 'win32'
|
|
&& (!ownerTrusted || (stat.mode & 18n) !== 0n))) {
|
|
throw new Error(`bootstrap artifact leaf is not exclusively owner-controlled: ${path}`);
|
|
}
|
|
}
|
|
export const _assertTrustedBootstrapArtifactLeafForTest = assertTrustedBootstrapArtifactLeaf;
|
|
export function readBootstrapArtifactFile(path, maxBytes = MAX_ARTIFACT_BYTES, hooks = {}) {
|
|
if (!Number.isSafeInteger(maxBytes) || maxBytes < 1 || maxBytes > MAX_ARTIFACT_BYTES) {
|
|
throw new Error('bootstrap artifact read limit is invalid');
|
|
}
|
|
const trustedUid = process.platform === 'win32' || typeof process.getuid !== 'function'
|
|
? undefined
|
|
: BigInt(process.getuid());
|
|
const role = hooks.role ?? 'transaction';
|
|
const assertTrustedLeaf = (stat) => assertTrustedBootstrapArtifactLeaf(stat, process.platform, trustedUid, path, role);
|
|
if (process.platform === 'win32')
|
|
assertWindowsAclTrusted(path, role === 'owned');
|
|
const before = lstatSync(path, { bigint: true });
|
|
if (before.isSymbolicLink() || !before.isFile() || before.size > BigInt(maxBytes)
|
|
|| before.dev <= 0n || before.ino <= 0n) {
|
|
throw new Error(`bootstrap artifact is not a regular owned file: ${path}`);
|
|
}
|
|
assertTrustedLeaf(before);
|
|
const descriptor = openSync(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
|
|
try {
|
|
const opened = fstatSync(descriptor, { bigint: true });
|
|
if (!opened.isFile() || opened.dev !== before.dev || opened.ino !== before.ino
|
|
|| opened.dev <= 0n || opened.ino <= 0n || opened.size > BigInt(maxBytes)) {
|
|
throw new Error(`bootstrap artifact changed while opening: ${path}`);
|
|
}
|
|
assertTrustedLeaf(opened);
|
|
const hash = createHash('sha256');
|
|
const buffer = Buffer.allocUnsafe(64 * 1024);
|
|
const chunks = [];
|
|
let size = 0;
|
|
for (;;) {
|
|
const count = readSync(descriptor, buffer, 0, buffer.length, null);
|
|
if (count === 0)
|
|
break;
|
|
size += count;
|
|
if (size > maxBytes) {
|
|
throw new Error(`bootstrap artifact exceeds the hashing limit: ${path}`);
|
|
}
|
|
const chunk = Buffer.from(buffer.subarray(0, count));
|
|
chunks.push(chunk);
|
|
hash.update(chunk);
|
|
}
|
|
const after = fstatSync(descriptor, { bigint: true });
|
|
if (after.dev !== opened.dev || after.ino !== opened.ino || after.size !== opened.size
|
|
|| after.mtimeNs !== opened.mtimeNs || after.ctimeNs !== opened.ctimeNs
|
|
|| BigInt(size) !== opened.size) {
|
|
throw new Error(`bootstrap artifact changed while hashing: ${path}`);
|
|
}
|
|
assertTrustedLeaf(after);
|
|
hooks.afterRead?.();
|
|
if (process.platform === 'win32')
|
|
assertWindowsAclTrusted(path, role === 'owned');
|
|
const settled = lstatSync(path, { bigint: true });
|
|
if (!settled.isFile() || settled.isSymbolicLink()
|
|
|| settled.dev !== opened.dev || settled.ino !== opened.ino
|
|
|| settled.size !== opened.size || settled.mtimeNs !== opened.mtimeNs
|
|
|| settled.ctimeNs !== opened.ctimeNs) {
|
|
throw new Error(`bootstrap artifact changed after reading: ${path}`);
|
|
}
|
|
assertTrustedLeaf(settled);
|
|
return {
|
|
bytes: Buffer.concat(chunks, size),
|
|
identity: {
|
|
size,
|
|
sha256: hash.digest('hex'),
|
|
device: String(opened.dev),
|
|
inode: String(opened.ino),
|
|
},
|
|
};
|
|
}
|
|
finally {
|
|
closeSync(descriptor);
|
|
}
|
|
}
|
|
function identityFor(path) {
|
|
return readBootstrapArtifactFile(path).identity;
|
|
}
|
|
export function bootstrapArtifactIdentityForBytes(bytes) {
|
|
return { size: bytes.byteLength, sha256: createHash('sha256').update(bytes).digest('hex') };
|
|
}
|
|
export function bootstrapArtifactContentIdentityForFile(path) {
|
|
const identity = identityFor(path);
|
|
return { size: identity.size, sha256: identity.sha256 };
|
|
}
|
|
export function bootstrapArtifactIdentityForFile(path) {
|
|
const identity = identityFor(path);
|
|
if (identity.device === undefined || identity.inode === undefined) {
|
|
throw new Error(`bootstrap artifact file identity is unavailable: ${path}`);
|
|
}
|
|
return identity;
|
|
}
|
|
function sameFileId(left, right) {
|
|
return left.device !== undefined && left.inode !== undefined
|
|
&& left.device === right.device && left.inode === right.inode;
|
|
}
|
|
function assertExpectedArtifactIdentity(expected, actual, path) {
|
|
if (actual.size !== expected.size || actual.sha256 !== expected.sha256) {
|
|
throw new Error(`bootstrap artifact content does not match its durable plan: ${path}`);
|
|
}
|
|
if (expected.device !== undefined && !sameFileId(expected, actual)) {
|
|
throw new Error(`bootstrap artifact file identity changed: ${path}`);
|
|
}
|
|
}
|
|
function assertCanonicalQuarantineIdentity(path, expected) {
|
|
const actual = identityFor(path);
|
|
assertExpectedArtifactIdentity(expected, actual, path);
|
|
if (!sameFileId(expected, actual)) {
|
|
throw new Error(`bootstrap canonical quarantine file identity changed: ${path}`);
|
|
}
|
|
}
|
|
function bootstrapCanonicalFinalizePath(entry) {
|
|
return `${entry.quarantinePath}.finalizing`;
|
|
}
|
|
function preserveMovedCanonicalState(entry, movedPath, cause) {
|
|
const movedIdentity = identityFor(movedPath);
|
|
if (!filesystemEntryPresent(entry.sourcePath)) {
|
|
try {
|
|
linkSync(movedPath, entry.sourcePath);
|
|
syncDirectory(dirname(entry.sourcePath));
|
|
}
|
|
catch (error) {
|
|
if (!isErrno(error, 'EEXIST'))
|
|
throw error;
|
|
}
|
|
}
|
|
if (filesystemEntryPresent(entry.sourcePath)) {
|
|
const restoredIdentity = identityFor(entry.sourcePath);
|
|
if (sameFileId(restoredIdentity, movedIdentity)) {
|
|
throw new Error(`bootstrap canonical state changed during quarantine and was restored without deleting its preserved inode: ${entry.sourcePath}`, { cause });
|
|
}
|
|
}
|
|
throw new Error(`bootstrap canonical state changed during quarantine; both canonical and preserved paths remain for recovery: ${entry.sourcePath}`, { cause });
|
|
}
|
|
function withBootstrapCanonicalStateLock(stateDir, kind, operation) {
|
|
if (kind === 'marker')
|
|
return operation();
|
|
const lock = acquireBootstrapReadinessLock(stateDir);
|
|
let result;
|
|
let operationError;
|
|
try {
|
|
result = operation();
|
|
}
|
|
catch (error) {
|
|
operationError = error;
|
|
}
|
|
releaseBootstrapLockAfterOperation(lock, operationError, 'readiness');
|
|
if (operationError !== undefined)
|
|
throw operationError;
|
|
return result;
|
|
}
|
|
export function planBootstrapCanonicalQuarantine(stateDir, journal, kinds) {
|
|
if (journal.stage !== 'prepared') {
|
|
throw new Error('bootstrap canonical quarantine must be planned before installation');
|
|
}
|
|
if (journal.canonicalQuarantine !== undefined) {
|
|
throw new Error('bootstrap canonical quarantine was already planned');
|
|
}
|
|
if (new Set(kinds).size !== kinds.length || kinds.length > 2) {
|
|
throw new Error('bootstrap canonical quarantine kinds must be unique');
|
|
}
|
|
if (kinds.length === 0)
|
|
return journal;
|
|
const canonicalQuarantine = kinds.map((kind) => {
|
|
const sourcePath = resolve(kind === 'marker'
|
|
? bootstrapMarkerPath(stateDir)
|
|
: bootstrapReadinessPath(stateDir));
|
|
const quarantinePath = bootstrapCanonicalQuarantinePath(stateDir, kind, journal.transactionId);
|
|
if (filesystemEntryPresent(quarantinePath)) {
|
|
throw new Error(`bootstrap transaction path already exists: ${quarantinePath}`);
|
|
}
|
|
const identity = withBootstrapCanonicalStateLock(stateDir, kind, () => identityFor(sourcePath));
|
|
if (!identity.device || !identity.inode) {
|
|
throw new Error(`bootstrap canonical state has no durable file identity: ${sourcePath}`);
|
|
}
|
|
return {
|
|
kind,
|
|
sourcePath,
|
|
quarantinePath,
|
|
identity: { ...identity, device: identity.device, inode: identity.inode },
|
|
};
|
|
});
|
|
const planned = { ...journal, canonicalQuarantine };
|
|
assertBootstrapCanonicalQuarantineLayout(stateDir, planned);
|
|
return planned;
|
|
}
|
|
export function applyBootstrapCanonicalQuarantine(stateDir, journal, beforeMutation = () => { }) {
|
|
if (journal.stage !== 'prepared') {
|
|
throw new Error('bootstrap canonical quarantine must be applied before installation');
|
|
}
|
|
assertBootstrapCanonicalQuarantineLayout(stateDir, journal);
|
|
for (const entry of journal.canonicalQuarantine ?? []) {
|
|
withBootstrapCanonicalStateLock(stateDir, entry.kind, () => {
|
|
const sourcePresent = filesystemEntryPresent(entry.sourcePath);
|
|
const quarantinePresent = filesystemEntryPresent(entry.quarantinePath);
|
|
const finalizePresent = filesystemEntryPresent(bootstrapCanonicalFinalizePath(entry));
|
|
if (finalizePresent) {
|
|
throw new Error(`bootstrap canonical quarantine has an unfinished finalize path: ${entry.sourcePath}`);
|
|
}
|
|
if (sourcePresent && quarantinePresent) {
|
|
throw new Error(`bootstrap canonical quarantine has both source and destination: ${entry.sourcePath}`);
|
|
}
|
|
if (quarantinePresent) {
|
|
assertCanonicalQuarantineIdentity(entry.quarantinePath, entry.identity);
|
|
return;
|
|
}
|
|
if (!sourcePresent) {
|
|
throw new Error(`bootstrap canonical state disappeared before quarantine: ${entry.sourcePath}`);
|
|
}
|
|
assertCanonicalQuarantineIdentity(entry.sourcePath, entry.identity);
|
|
beforeMutation();
|
|
renameSync(entry.sourcePath, entry.quarantinePath);
|
|
syncDirectory(dirname(entry.sourcePath));
|
|
try {
|
|
assertCanonicalQuarantineIdentity(entry.quarantinePath, entry.identity);
|
|
}
|
|
catch (error) {
|
|
preserveMovedCanonicalState(entry, entry.quarantinePath, error);
|
|
}
|
|
});
|
|
}
|
|
}
|
|
export function restoreBootstrapCanonicalQuarantine(stateDir, journal, hooks = {}) {
|
|
assertBootstrapCanonicalQuarantineLayout(stateDir, journal);
|
|
for (const entry of [...(journal.canonicalQuarantine ?? [])].reverse()) {
|
|
withBootstrapCanonicalStateLock(stateDir, entry.kind, () => {
|
|
const sourcePresent = filesystemEntryPresent(entry.sourcePath);
|
|
const quarantinePresent = filesystemEntryPresent(entry.quarantinePath);
|
|
const finalizePath = bootstrapCanonicalFinalizePath(entry);
|
|
const finalizePresent = filesystemEntryPresent(finalizePath);
|
|
if (quarantinePresent && finalizePresent) {
|
|
throw new Error(`bootstrap canonical quarantine has two recovery paths: ${entry.sourcePath}`);
|
|
}
|
|
const recoveryPath = quarantinePresent ? entry.quarantinePath : finalizePresent ? finalizePath : undefined;
|
|
if (sourcePresent && recoveryPath) {
|
|
throw new Error(`bootstrap canonical quarantine cannot overwrite current state: ${entry.sourcePath}`);
|
|
}
|
|
if (!recoveryPath) {
|
|
if (!sourcePresent) {
|
|
throw new Error(`bootstrap canonical state and quarantine are both missing: ${entry.sourcePath}`);
|
|
}
|
|
assertCanonicalQuarantineIdentity(entry.sourcePath, entry.identity);
|
|
return;
|
|
}
|
|
assertCanonicalQuarantineIdentity(recoveryPath, entry.identity);
|
|
hooks.beforeMove?.(entry);
|
|
renameSync(recoveryPath, entry.sourcePath);
|
|
syncDirectory(dirname(entry.sourcePath));
|
|
hooks.afterMove?.(entry);
|
|
try {
|
|
assertCanonicalQuarantineIdentity(entry.sourcePath, entry.identity);
|
|
}
|
|
catch (error) {
|
|
if (!filesystemEntryPresent(recoveryPath)) {
|
|
try {
|
|
linkSync(entry.sourcePath, recoveryPath);
|
|
syncDirectory(dirname(recoveryPath));
|
|
}
|
|
catch (linkError) {
|
|
if (!isErrno(linkError, 'EEXIST'))
|
|
throw linkError;
|
|
}
|
|
}
|
|
throw new Error(`bootstrap canonical restore preserved an unexpected inode at both recovery paths: ${entry.sourcePath}`, { cause: error });
|
|
}
|
|
});
|
|
}
|
|
}
|
|
export function finalizeBootstrapCanonicalQuarantine(stateDir, journal, hooks = {}) {
|
|
assertBootstrapCanonicalQuarantineLayout(stateDir, journal);
|
|
for (const entry of journal.canonicalQuarantine ?? []) {
|
|
withBootstrapCanonicalStateLock(stateDir, entry.kind, () => {
|
|
const finalizePath = bootstrapCanonicalFinalizePath(entry);
|
|
const quarantinePresent = filesystemEntryPresent(entry.quarantinePath);
|
|
const finalizePresent = filesystemEntryPresent(finalizePath);
|
|
if (quarantinePresent && finalizePresent) {
|
|
throw new Error(`bootstrap canonical quarantine has two finalize paths: ${entry.sourcePath}`);
|
|
}
|
|
if (!quarantinePresent && !finalizePresent)
|
|
return;
|
|
let ownedPath = finalizePresent ? finalizePath : entry.quarantinePath;
|
|
assertCanonicalQuarantineIdentity(ownedPath, entry.identity);
|
|
if (!finalizePresent) {
|
|
hooks.beforeMove?.(entry);
|
|
renameSync(entry.quarantinePath, finalizePath);
|
|
syncDirectory(dirname(entry.quarantinePath));
|
|
ownedPath = finalizePath;
|
|
hooks.afterMove?.(entry);
|
|
try {
|
|
assertCanonicalQuarantineIdentity(ownedPath, entry.identity);
|
|
}
|
|
catch (error) {
|
|
preserveMovedCanonicalState(entry, ownedPath, error);
|
|
}
|
|
}
|
|
hooks.beforeDelete?.(entry);
|
|
assertCanonicalQuarantineIdentity(ownedPath, entry.identity);
|
|
removeDurableFile(ownedPath);
|
|
});
|
|
}
|
|
}
|
|
function identityFromClaim(artifact) {
|
|
const finalIdentity = identityFor(artifact.path);
|
|
const claimIdentity = identityFor(artifact.claimPath);
|
|
if (!sameFileId(finalIdentity, claimIdentity)) {
|
|
throw new Error(`bootstrap artifact claim does not bind the published inode: ${artifact.path}`);
|
|
}
|
|
if (filesystemEntryPresent(artifact.rollbackPath)) {
|
|
const receiptIdentity = identityFor(artifact.rollbackPath);
|
|
if (!sameFileId(claimIdentity, receiptIdentity)) {
|
|
throw new Error(`bootstrap artifact ownership receipt does not bind the published inode: ${artifact.path}`);
|
|
}
|
|
}
|
|
if (artifact.claimOwnership?.phase === 'created'
|
|
&& (artifact.claimOwnership.device !== finalIdentity.device
|
|
|| artifact.claimOwnership.inode !== finalIdentity.inode)) {
|
|
throw new Error(`bootstrap artifact claim ownership changed before publication: ${artifact.path}`);
|
|
}
|
|
if (!artifact.identity)
|
|
throw new Error(`bootstrap artifact has no expected identity: ${artifact.path}`);
|
|
assertExpectedArtifactIdentity(artifact.identity, finalIdentity, artifact.path);
|
|
return finalIdentity;
|
|
}
|
|
export function recordPublishedBootstrapArtifact(journal, path, claimPath, persistClaimOwnership) {
|
|
const canonical = resolve(path);
|
|
let found = false;
|
|
let claimedIdentity;
|
|
const artifacts = journal.artifacts.map((artifact) => {
|
|
if (artifact.path !== canonical)
|
|
return artifact;
|
|
found = true;
|
|
if (artifact.claimPath !== resolve(claimPath)) {
|
|
throw new Error(`bootstrap publisher used an unexpected claim path: ${path}`);
|
|
}
|
|
if (filesystemEntryPresent(artifact.path)) {
|
|
const identity = identityFromClaim(artifact);
|
|
const { claimOwnership: _claimOwnership, ...publishedArtifact } = artifact;
|
|
return { ...publishedArtifact, identity };
|
|
}
|
|
if (journal.stage !== 'installing'
|
|
|| (artifact.claimOwnership?.phase !== 'armed'
|
|
&& artifact.claimOwnership?.phase !== 'created')) {
|
|
throw new Error(`bootstrap publisher created a claim without a durable ownership plan: ${path}`);
|
|
}
|
|
if (!filesystemEntryPresent(artifact.claimPath)
|
|
|| !filesystemEntryPresent(artifact.rollbackPath)) {
|
|
throw new Error(`bootstrap artifact ownership receipt is incomplete: ${path}`);
|
|
}
|
|
const claimIdentity = identityFor(artifact.claimPath);
|
|
const receiptIdentity = identityFor(artifact.rollbackPath);
|
|
if (!sameFileId(claimIdentity, receiptIdentity)) {
|
|
throw new Error(`bootstrap artifact ownership receipt does not bind the opened claim: ${path}`);
|
|
}
|
|
if (artifact.claimOwnership.phase === 'armed') {
|
|
if (!artifact.identity)
|
|
throw new Error(`bootstrap artifact has no expected identity: ${path}`);
|
|
if (claimIdentity.device === undefined || claimIdentity.inode === undefined) {
|
|
throw new Error(`bootstrap artifact claim file identity is unavailable: ${path}`);
|
|
}
|
|
claimedIdentity = {
|
|
expected: artifact.identity,
|
|
actual: claimIdentity,
|
|
path: artifact.claimPath,
|
|
};
|
|
return {
|
|
...artifact,
|
|
claimOwnership: {
|
|
phase: 'created',
|
|
device: claimIdentity.device,
|
|
inode: claimIdentity.inode,
|
|
},
|
|
};
|
|
}
|
|
if (artifact.claimOwnership.device !== claimIdentity.device
|
|
|| artifact.claimOwnership.inode !== claimIdentity.inode) {
|
|
throw new Error(`bootstrap artifact claim ownership changed before publication: ${path}`);
|
|
}
|
|
return artifact;
|
|
});
|
|
if (!found)
|
|
throw new Error(`bootstrap publisher emitted an unplanned artifact: ${path}`);
|
|
const recorded = { ...journal, artifacts };
|
|
if (claimedIdentity) {
|
|
// Persist exact-inode ownership before validating mutable apply bytes. A
|
|
// plan/apply mismatch must remain recoverable without publishing canonical.
|
|
persistClaimOwnership(recorded);
|
|
assertExpectedArtifactIdentity(claimedIdentity.expected, claimedIdentity.actual, claimedIdentity.path);
|
|
}
|
|
return recorded;
|
|
}
|
|
export function captureBootstrapArtifactIdentities(journal, requirePresent = false) {
|
|
return {
|
|
...journal,
|
|
artifacts: journal.artifacts.map((artifact) => {
|
|
if (!artifact.identity)
|
|
throw new Error(`bootstrap artifact has no expected identity: ${artifact.path}`);
|
|
if (!filesystemEntryPresent(artifact.path)) {
|
|
if (filesystemEntryPresent(artifact.claimPath)) {
|
|
const claimIdentity = identityFor(artifact.claimPath);
|
|
assertExpectedArtifactIdentity(artifact.identity, claimIdentity, artifact.path);
|
|
return { ...artifact, identity: claimIdentity };
|
|
}
|
|
if (requirePresent)
|
|
throw new Error(`bootstrap artifact is missing after activation: ${artifact.path}`);
|
|
return artifact;
|
|
}
|
|
const actual = identityFor(artifact.path);
|
|
if (artifact.identity.device === undefined) {
|
|
if (!filesystemEntryPresent(artifact.claimPath)) {
|
|
throw new Error(`bootstrap artifact lacks a durable publisher claim: ${artifact.path}`);
|
|
}
|
|
return { ...artifact, identity: identityFromClaim(artifact) };
|
|
}
|
|
assertExpectedArtifactIdentity(artifact.identity, actual, artifact.path);
|
|
return { ...artifact, identity: actual };
|
|
}),
|
|
};
|
|
}
|
|
export function removeOwnedBootstrapArtifacts(journal, hooks = {}) {
|
|
for (const artifact of [...journal.artifacts].reverse()) {
|
|
if (!artifact.identity)
|
|
throw new Error(`bootstrap artifact has no durable ownership identity: ${artifact.path}`);
|
|
const expectedIdentity = artifact.identity;
|
|
const claimIdentity = filesystemEntryPresent(artifact.claimPath) ? identityFor(artifact.claimPath) : undefined;
|
|
const receiptIdentity = filesystemEntryPresent(artifact.rollbackPath)
|
|
? identityFor(artifact.rollbackPath)
|
|
: undefined;
|
|
const stagingCleanupPath = bootstrapArtifactStagingCleanupPath(artifact);
|
|
const stagingCleanupIdentity = filesystemEntryPresent(stagingCleanupPath)
|
|
? identityFor(stagingCleanupPath)
|
|
: undefined;
|
|
const armedStagingOnly = artifact.claimOwnership?.phase === 'armed'
|
|
&& (stagingCleanupIdentity !== undefined
|
|
|| (receiptIdentity !== undefined
|
|
&& (claimIdentity === undefined || !sameFileId(receiptIdentity, claimIdentity))));
|
|
if (armedStagingOnly) {
|
|
if (receiptIdentity !== undefined && stagingCleanupIdentity !== undefined) {
|
|
throw new Error(`bootstrap artifact staging has two cleanup paths: ${artifact.path}`);
|
|
}
|
|
let ownedPath = stagingCleanupIdentity === undefined
|
|
? artifact.rollbackPath
|
|
: stagingCleanupPath;
|
|
let ownedIdentity = stagingCleanupIdentity ?? receiptIdentity;
|
|
if (stagingCleanupIdentity === undefined) {
|
|
hooks.beforeStagingQuarantine?.(artifact);
|
|
renameSync(artifact.rollbackPath, stagingCleanupPath);
|
|
syncDirectory(dirname(artifact.rollbackPath));
|
|
ownedPath = stagingCleanupPath;
|
|
hooks.afterStagingQuarantine?.(artifact);
|
|
const movedIdentity = identityFor(stagingCleanupPath);
|
|
if (!sameFileId(ownedIdentity, movedIdentity)) {
|
|
try {
|
|
linkSync(stagingCleanupPath, artifact.rollbackPath);
|
|
syncDirectory(dirname(artifact.rollbackPath));
|
|
}
|
|
catch (error) {
|
|
if (!isErrno(error, 'EEXIST'))
|
|
throw error;
|
|
}
|
|
if (filesystemEntryPresent(artifact.rollbackPath)) {
|
|
const restoredIdentity = identityFor(artifact.rollbackPath);
|
|
if (sameFileId(restoredIdentity, movedIdentity)) {
|
|
removeDurableFile(stagingCleanupPath);
|
|
}
|
|
}
|
|
throw new Error(`bootstrap rollback restored a staging inode replaced during cleanup: ${artifact.rollbackPath}`);
|
|
}
|
|
ownedIdentity = movedIdentity;
|
|
}
|
|
hooks.beforeStagingDelete?.(artifact);
|
|
const beforeDelete = identityFor(ownedPath);
|
|
if (!sameFileId(ownedIdentity, beforeDelete)) {
|
|
throw new Error(`bootstrap artifact staging identity changed before cleanup: ${ownedPath}`);
|
|
}
|
|
removeDurableFile(ownedPath);
|
|
continue;
|
|
}
|
|
let durableOwnership;
|
|
if (artifact.claimOwnership?.phase === 'created') {
|
|
// A persisted publisher receipt owns this exact inode independently of
|
|
// content. Plan drift is a rollback trigger, not a reason to orphan it.
|
|
durableOwnership = {
|
|
size: expectedIdentity.size,
|
|
sha256: expectedIdentity.sha256,
|
|
device: artifact.claimOwnership.device,
|
|
inode: artifact.claimOwnership.inode,
|
|
};
|
|
if (claimIdentity && !sameFileId(claimIdentity, durableOwnership)) {
|
|
throw new Error(`bootstrap artifact claim file identity changed: ${artifact.claimPath}`);
|
|
}
|
|
if (receiptIdentity && !sameFileId(receiptIdentity, durableOwnership)) {
|
|
throw new Error(`bootstrap artifact ownership receipt file identity changed: ${artifact.rollbackPath}`);
|
|
}
|
|
if (filesystemEntryPresent(artifact.path)) {
|
|
const finalIdentity = identityFor(artifact.path);
|
|
if (!sameFileId(finalIdentity, durableOwnership)) {
|
|
throw new Error(`bootstrap artifact file identity changed: ${artifact.path}`);
|
|
}
|
|
}
|
|
}
|
|
else if (artifact.claimOwnership?.phase === 'armed') {
|
|
if (!claimIdentity) {
|
|
if (receiptIdentity)
|
|
throw new Error(`bootstrap artifact staging cleanup was not completed: ${artifact.path}`);
|
|
}
|
|
else {
|
|
if (!receiptIdentity) {
|
|
throw new Error(`bootstrap artifact claim has no durable ownership receipt: ${artifact.claimPath}`);
|
|
}
|
|
if (!sameFileId(claimIdentity, receiptIdentity)) {
|
|
throw new Error(`bootstrap artifact ownership receipt does not bind the opened claim: ${artifact.path}`);
|
|
}
|
|
assertExpectedArtifactIdentity(expectedIdentity, claimIdentity, artifact.claimPath);
|
|
durableOwnership = claimIdentity;
|
|
}
|
|
}
|
|
else if (claimIdentity) {
|
|
assertExpectedArtifactIdentity(expectedIdentity, claimIdentity, artifact.claimPath);
|
|
durableOwnership = claimIdentity;
|
|
}
|
|
else if (expectedIdentity.device !== undefined) {
|
|
durableOwnership = expectedIdentity;
|
|
}
|
|
const assertOwned = (identity) => {
|
|
if (durableOwnership) {
|
|
if (artifact.claimOwnership === undefined && claimIdentity === undefined) {
|
|
assertExpectedArtifactIdentity(expectedIdentity, identity, artifact.path);
|
|
return;
|
|
}
|
|
if (!sameFileId(identity, durableOwnership)) {
|
|
throw new Error(`bootstrap rollback claim does not bind the owned artifact: ${artifact.path}`);
|
|
}
|
|
if (artifact.claimOwnership?.phase === 'armed') {
|
|
assertExpectedArtifactIdentity(expectedIdentity, identity, artifact.path);
|
|
}
|
|
return;
|
|
}
|
|
if (expectedIdentity.device === undefined) {
|
|
throw new Error(`bootstrap artifact has no durable publisher claim: ${artifact.path}`);
|
|
}
|
|
assertExpectedArtifactIdentity(expectedIdentity, identity, artifact.path);
|
|
};
|
|
const restoreMovedForeign = (sourcePath, rollbackIdentity, cause) => {
|
|
if (filesystemEntryPresent(sourcePath)) {
|
|
const finalIdentity = identityFor(sourcePath);
|
|
if (!sameFileId(finalIdentity, rollbackIdentity)) {
|
|
throw new Error(`bootstrap rollback preserved an unowned quarantine because the canonical path is occupied: ${sourcePath}`, { cause });
|
|
}
|
|
}
|
|
else {
|
|
try {
|
|
linkSync(artifact.rollbackPath, sourcePath);
|
|
syncDirectory(dirname(sourcePath));
|
|
}
|
|
catch (error) {
|
|
if (!isErrno(error, 'EEXIST'))
|
|
throw error;
|
|
throw new Error(`bootstrap rollback preserved an unowned quarantine because the canonical path could not be restored: ${sourcePath}`, { cause });
|
|
}
|
|
const restoredIdentity = identityFor(sourcePath);
|
|
const currentRollbackIdentity = identityFor(artifact.rollbackPath);
|
|
if (!sameFileId(restoredIdentity, currentRollbackIdentity)
|
|
|| !sameFileId(currentRollbackIdentity, rollbackIdentity)) {
|
|
throw new Error(`bootstrap rollback preserved an unowned quarantine after an inconclusive restore: ${sourcePath}`, { cause });
|
|
}
|
|
}
|
|
removeDurableFile(artifact.rollbackPath);
|
|
throw new Error(`bootstrap rollback restored an unowned artifact moved during cleanup: ${sourcePath}`, { cause });
|
|
};
|
|
const removeOwnedClaim = () => {
|
|
if (!filesystemEntryPresent(artifact.claimPath))
|
|
return;
|
|
if (filesystemEntryPresent(artifact.rollbackPath)) {
|
|
throw new Error(`bootstrap rollback quarantine is occupied before claim cleanup: ${artifact.claimPath}`);
|
|
}
|
|
const beforeMove = identityFor(artifact.claimPath);
|
|
assertOwned(beforeMove);
|
|
hooks.beforeClaimQuarantine?.(artifact);
|
|
renameSync(artifact.claimPath, artifact.rollbackPath);
|
|
syncDirectory(dirname(artifact.claimPath));
|
|
const movedIdentity = identityFor(artifact.rollbackPath);
|
|
try {
|
|
assertOwned(movedIdentity);
|
|
}
|
|
catch (error) {
|
|
restoreMovedForeign(artifact.claimPath, movedIdentity, error);
|
|
}
|
|
if (filesystemEntryPresent(artifact.claimPath)) {
|
|
throw new Error(`bootstrap rollback preserved its claim quarantine because the canonical path was recreated: ${artifact.claimPath}`);
|
|
}
|
|
hooks.beforeClaimQuarantineDelete?.(artifact);
|
|
const beforeDelete = identityFor(artifact.rollbackPath);
|
|
try {
|
|
assertOwned(beforeDelete);
|
|
}
|
|
catch (error) {
|
|
restoreMovedForeign(artifact.claimPath, beforeDelete, error);
|
|
}
|
|
removeDurableFile(artifact.rollbackPath);
|
|
};
|
|
if (filesystemEntryPresent(artifact.rollbackPath)) {
|
|
const rollbackIdentity = identityFor(artifact.rollbackPath);
|
|
try {
|
|
assertOwned(rollbackIdentity);
|
|
}
|
|
catch (error) {
|
|
restoreMovedForeign(artifact.path, rollbackIdentity, error);
|
|
}
|
|
if (filesystemEntryPresent(artifact.path)) {
|
|
const finalIdentity = identityFor(artifact.path);
|
|
if (!sameFileId(finalIdentity, rollbackIdentity)) {
|
|
throw new Error(`bootstrap rollback quarantine does not bind the owned artifact: ${artifact.path}`);
|
|
}
|
|
assertOwned(finalIdentity);
|
|
removeDurableFile(artifact.rollbackPath);
|
|
}
|
|
else {
|
|
hooks.beforeQuarantineDelete?.(artifact);
|
|
const beforeDelete = identityFor(artifact.rollbackPath);
|
|
try {
|
|
assertOwned(beforeDelete);
|
|
}
|
|
catch (error) {
|
|
restoreMovedForeign(artifact.path, beforeDelete, error);
|
|
}
|
|
removeDurableFile(artifact.rollbackPath);
|
|
}
|
|
if (!filesystemEntryPresent(artifact.path)) {
|
|
removeOwnedClaim();
|
|
continue;
|
|
}
|
|
}
|
|
if (!filesystemEntryPresent(artifact.path)) {
|
|
removeOwnedClaim();
|
|
continue;
|
|
}
|
|
const finalIdentity = identityFor(artifact.path);
|
|
assertOwned(finalIdentity);
|
|
if (!filesystemEntryPresent(artifact.claimPath)) {
|
|
linkSync(artifact.path, artifact.claimPath);
|
|
syncDirectory(dirname(artifact.path));
|
|
}
|
|
const durableClaimIdentity = identityFor(artifact.claimPath);
|
|
if (!sameFileId(finalIdentity, durableClaimIdentity)) {
|
|
throw new Error(`bootstrap rollback claim does not bind the owned artifact: ${artifact.path}`);
|
|
}
|
|
hooks.beforeQuarantine?.(artifact);
|
|
renameSync(artifact.path, artifact.rollbackPath);
|
|
syncDirectory(dirname(artifact.path));
|
|
hooks.afterQuarantine?.(artifact);
|
|
const rollbackIdentity = identityFor(artifact.rollbackPath);
|
|
try {
|
|
assertOwned(rollbackIdentity);
|
|
}
|
|
catch (error) {
|
|
restoreMovedForeign(artifact.path, rollbackIdentity, error);
|
|
}
|
|
if (filesystemEntryPresent(artifact.path)) {
|
|
throw new Error(`bootstrap rollback preserved its quarantine because the canonical path was recreated: ${artifact.path}`);
|
|
}
|
|
hooks.beforeQuarantineDelete?.(artifact);
|
|
const beforeDelete = identityFor(artifact.rollbackPath);
|
|
try {
|
|
assertOwned(beforeDelete);
|
|
}
|
|
catch (error) {
|
|
restoreMovedForeign(artifact.path, beforeDelete, error);
|
|
}
|
|
removeDurableFile(artifact.rollbackPath);
|
|
removeOwnedClaim();
|
|
}
|
|
} |