- Added a new Archify skill, enabling users to create polished architecture, workflow, sequence, data-flow, and lifecycle diagrams. - Implemented comprehensive functionality including rendering, validation, and delivery of diagrams in various formats. - Integrated a user-friendly command-line interface for generating and previewing diagrams. - Developed supporting files including package.json, LICENSE, and SKILL.md for documentation and licensing. - Added unit tests to ensure reliability and functionality of the new skill. These changes enhance the application by providing a structured approach to visualizing system architecture and workflows, improving user experience and data representation.
1668 lines
55 KiB
JavaScript
1668 lines
55 KiB
JavaScript
#!/usr/bin/env node
|
|
|
|
import crypto from 'node:crypto';
|
|
import { constants as fsConstants } from 'node:fs';
|
|
import fs from 'node:fs/promises';
|
|
import os from 'node:os';
|
|
import path from 'node:path';
|
|
import { performance } from 'node:perf_hooks';
|
|
import { fileURLToPath } from 'node:url';
|
|
|
|
import {
|
|
DEFAULT_MANIFEST_URL,
|
|
SKILL_ID,
|
|
UpdateContractError,
|
|
compareSemver,
|
|
isStableCoreVersion,
|
|
parseSemver,
|
|
validateLocalRelease,
|
|
validateReleaseNotesUrl,
|
|
validateStableUpdateManifest,
|
|
} from './update-contract.mjs';
|
|
|
|
const OPERATION_STATE_FILE = 'state.json';
|
|
const OPERATION_OWNER_FILE = 'owner.json';
|
|
const ACTIVE_CLAIM_DIRECTORY = 'active-claim';
|
|
const OPERATION_NAME = /^(reserved|pending|committed|fenced|cancelled)-(\d{1,20})$/;
|
|
const MAX_OPERATION_GENERATION = (10n ** 20n) - 1n;
|
|
const CHECK_TTL_MS = 72 * 60 * 60 * 1_000;
|
|
const MAX_CACHE_HORIZON_MS = Math.ceil(CHECK_TTL_MS * 1.2);
|
|
const FIRST_FAILURE_DELAY_MS = 6 * 60 * 60 * 1_000;
|
|
const LATER_FAILURE_DELAY_MS = 24 * 60 * 60 * 1_000;
|
|
const DEFAULT_TIMEOUT_MS = 1_000;
|
|
const MAX_RESPONSE_BYTES = 32 * 1_024;
|
|
const MAX_LOCAL_RELEASE_BYTES = 4 * 1_024;
|
|
const MAX_CACHE_STATE_BYTES = 64 * 1_024;
|
|
const MAX_CLAIM_OWNER_BYTES = 1_024;
|
|
const LONGEST_ISO_TIMESTAMP = '+275760-09-13T00:00:00.000Z';
|
|
const LOCK_STALE_MS = 30_000;
|
|
const ACK_LOCK_WAIT_MS = 1_200;
|
|
const LOCK_RETRY_DELAY_MS = 20;
|
|
const DIGEST_PATTERN = /^sha256:[a-f0-9]{64}$/;
|
|
const scriptDirectory = path.dirname(fileURLToPath(import.meta.url));
|
|
const defaultReleasePath = path.resolve(scriptDirectory, '..', 'skill-release.json');
|
|
|
|
class FileIdentityChangedError extends Error {}
|
|
|
|
function silent(reason) {
|
|
return { status: 'silent', reason };
|
|
}
|
|
|
|
function isPlainObject(value) {
|
|
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
|
}
|
|
|
|
function hasOnlyKeys(value, allowed) {
|
|
return Object.keys(value).every((key) => allowed.has(key));
|
|
}
|
|
|
|
function validateManifest(value) {
|
|
const manifest = validateStableUpdateManifest(value);
|
|
return {
|
|
version: manifest.version,
|
|
targetDigest: `sha256:${manifest.artifact.sha256}`,
|
|
severity: manifest.severity,
|
|
releaseNotes: manifest.releaseNotes,
|
|
};
|
|
}
|
|
|
|
function validateCachedCandidate(value) {
|
|
if (!isPlainObject(value)
|
|
|| !hasOnlyKeys(value, new Set([
|
|
'version', 'targetDigest', 'severity', 'releaseNotes',
|
|
]))) return null;
|
|
try {
|
|
if (!isStableCoreVersion(value.version)) return null;
|
|
if (!DIGEST_PATTERN.test(value.targetDigest)
|
|
|| !['normal', 'security'].includes(value.severity)) return null;
|
|
validateReleaseNotesUrl(value.releaseNotes, value.version);
|
|
return { ...value };
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function emptyState(installedVersion = null) {
|
|
return {
|
|
schemaVersion: 1,
|
|
skillId: SKILL_ID,
|
|
installedVersion,
|
|
check: {
|
|
nextCheckAt: null,
|
|
consecutiveFailures: 0,
|
|
},
|
|
notification: {
|
|
offeredDigests: [],
|
|
acknowledgedDigests: [],
|
|
},
|
|
};
|
|
}
|
|
|
|
function encodeBoundedState(state) {
|
|
const encoded = Buffer.from(`${JSON.stringify(state)}\n`, 'utf8');
|
|
return encoded.length <= MAX_CACHE_STATE_BYTES ? encoded : null;
|
|
}
|
|
|
|
function stateAfterAcknowledgement(state, targetDigest) {
|
|
const alreadyAcknowledged = state.notification.acknowledgedDigests.includes(targetDigest);
|
|
return {
|
|
...state,
|
|
notification: {
|
|
offeredDigests: state.notification.offeredDigests
|
|
.filter((digest) => digest !== targetDigest),
|
|
acknowledgedDigests: alreadyAcknowledged
|
|
? [...state.notification.acknowledgedDigests]
|
|
: [...state.notification.acknowledgedDigests, targetDigest],
|
|
},
|
|
};
|
|
}
|
|
|
|
function stateWithFailureCheck(state, nextCheckAt, consecutiveFailures, withdrawCandidate = false) {
|
|
const failedState = {
|
|
...state,
|
|
check: { nextCheckAt, consecutiveFailures },
|
|
};
|
|
if (withdrawCandidate) delete failedState.candidate;
|
|
return failedState;
|
|
}
|
|
|
|
function encodeRecoverableState(state) {
|
|
const encoded = encodeBoundedState(state);
|
|
if (!encoded) return null;
|
|
if (!encodeBoundedState(stateWithFailureCheck(state, LONGEST_ISO_TIMESTAMP, 2))) return null;
|
|
let acknowledgementState = state;
|
|
for (const targetDigest of state.notification.offeredDigests) {
|
|
acknowledgementState = stateAfterAcknowledgement(acknowledgementState, targetDigest);
|
|
if (!encodeBoundedState(acknowledgementState)
|
|
|| !encodeBoundedState(stateWithFailureCheck(
|
|
acknowledgementState,
|
|
LONGEST_ISO_TIMESTAMP,
|
|
2,
|
|
))) return null;
|
|
}
|
|
return encoded;
|
|
}
|
|
|
|
function versionCacheDirectory(cacheDirectory, installedVersion) {
|
|
const partition = crypto.createHash('sha256').update(installedVersion).digest('hex').slice(0, 24);
|
|
return path.join(cacheDirectory, `version-${partition}`);
|
|
}
|
|
|
|
function normalizeState(value, installedVersion) {
|
|
const effectiveInstalledVersion = installedVersion ?? value?.installedVersion ?? null;
|
|
if (!isPlainObject(value) || value.schemaVersion !== 1 || value.skillId !== SKILL_ID
|
|
|| (installedVersion !== null && value.installedVersion !== installedVersion)
|
|
|| (value.installedVersion !== null && typeof value.installedVersion !== 'string')
|
|
|| !isPlainObject(value.check)
|
|
|| !(value.check.nextCheckAt === null || typeof value.check.nextCheckAt === 'string')
|
|
|| !Number.isSafeInteger(value.check.consecutiveFailures)
|
|
|| value.check.consecutiveFailures < 0
|
|
|| !isPlainObject(value.notification)) return null;
|
|
if (effectiveInstalledVersion !== null) {
|
|
try {
|
|
parseSemver(effectiveInstalledVersion);
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
const state = emptyState(effectiveInstalledVersion);
|
|
state.check.nextCheckAt = value.check.nextCheckAt;
|
|
state.check.consecutiveFailures = value.check.consecutiveFailures;
|
|
const { offeredDigests, acknowledgedDigests } = value.notification;
|
|
if (!Array.isArray(offeredDigests)
|
|
|| offeredDigests.some((digest) => typeof digest !== 'string' || !DIGEST_PATTERN.test(digest))) {
|
|
return null;
|
|
}
|
|
state.notification.offeredDigests = [...new Set(offeredDigests)];
|
|
|
|
if (!Array.isArray(acknowledgedDigests)
|
|
|| acknowledgedDigests.some((digest) => typeof digest !== 'string' || !DIGEST_PATTERN.test(digest))) {
|
|
return null;
|
|
}
|
|
state.notification.acknowledgedDigests = [...new Set(acknowledgedDigests)];
|
|
const hasCandidate = Object.hasOwn(value, 'candidate');
|
|
const candidate = validateCachedCandidate(value.candidate);
|
|
if (hasCandidate && !candidate) return null;
|
|
if (candidate) {
|
|
if (effectiveInstalledVersion !== null
|
|
&& compareSemver(candidate.version, effectiveInstalledVersion) > 0
|
|
&& !state.notification.offeredDigests.includes(candidate.targetDigest)
|
|
&& !state.notification.acknowledgedDigests.includes(candidate.targetDigest)) return null;
|
|
state.candidate = candidate;
|
|
}
|
|
return encodeRecoverableState(state) ? state : null;
|
|
}
|
|
|
|
function isSameFile(left, right) {
|
|
if (left.dev !== right.dev || left.ino !== right.ino) return false;
|
|
if (left.ino !== 0n) return true;
|
|
if (left.birthtimeNs === 0n || right.birthtimeNs === 0n) return false;
|
|
return left.birthtimeNs === right.birthtimeNs
|
|
&& left.mode === right.mode;
|
|
}
|
|
|
|
function assertBoundedRegularFile(metadata, maxBytes) {
|
|
if (!metadata.isFile()
|
|
|| metadata.isSymbolicLink()
|
|
|| metadata.size > BigInt(maxBytes)) {
|
|
throw new Error('unsafe JSON file');
|
|
}
|
|
}
|
|
|
|
async function readJsonFile(target, maxBytes, expectedMetadata = null) {
|
|
const pathMetadata = await fs.lstat(target, { bigint: true });
|
|
if (expectedMetadata && !isSameFile(expectedMetadata, pathMetadata)) {
|
|
throw new FileIdentityChangedError('JSON file identity changed');
|
|
}
|
|
assertBoundedRegularFile(pathMetadata, maxBytes);
|
|
const flags = fsConstants.O_RDONLY
|
|
| (fsConstants.O_NOFOLLOW ?? 0)
|
|
| (fsConstants.O_NONBLOCK ?? 0);
|
|
const handle = await fs.open(target, flags);
|
|
try {
|
|
const handleMetadata = await handle.stat({ bigint: true });
|
|
const currentPathMetadata = await fs.lstat(target, { bigint: true });
|
|
assertBoundedRegularFile(handleMetadata, maxBytes);
|
|
assertBoundedRegularFile(currentPathMetadata, maxBytes);
|
|
if (!isSameFile(pathMetadata, handleMetadata)
|
|
|| !isSameFile(currentPathMetadata, handleMetadata)) {
|
|
throw new FileIdentityChangedError('JSON file changed while opening');
|
|
}
|
|
|
|
const chunks = [];
|
|
let size = 0;
|
|
while (size <= maxBytes) {
|
|
const chunk = Buffer.allocUnsafe(Math.min(8 * 1_024, (maxBytes + 1) - size));
|
|
const { bytesRead } = await handle.read(chunk, 0, chunk.length, size);
|
|
if (bytesRead === 0) break;
|
|
chunks.push(chunk.subarray(0, bytesRead));
|
|
size += bytesRead;
|
|
}
|
|
if (size > maxBytes) throw new Error('JSON file is too large');
|
|
return JSON.parse(Buffer.concat(chunks, size).toString('utf8'));
|
|
} finally {
|
|
await handle.close();
|
|
}
|
|
}
|
|
|
|
function isWithinDirectory(directory, target) {
|
|
const relative = path.relative(directory, target);
|
|
return relative === ''
|
|
|| (!path.isAbsolute(relative) && relative !== '..' && !relative.startsWith(`..${path.sep}`));
|
|
}
|
|
|
|
async function canonicalizeTrustedDirectoryPrefix(target) {
|
|
const absoluteTarget = path.resolve(target);
|
|
const trustedDirectories = [...new Set([os.homedir(), os.tmpdir()].map((directory) => (
|
|
path.resolve(directory)
|
|
)))].sort((left, right) => right.length - left.length);
|
|
for (const trustedDirectory of trustedDirectories) {
|
|
if (!isWithinDirectory(trustedDirectory, absoluteTarget)) continue;
|
|
try {
|
|
const canonicalDirectory = await fs.realpath(trustedDirectory);
|
|
return path.resolve(canonicalDirectory, path.relative(trustedDirectory, absoluteTarget));
|
|
} catch {
|
|
// Fall back to validating the absolute path from its filesystem root.
|
|
}
|
|
}
|
|
return target;
|
|
}
|
|
|
|
function assertSafeDirectory(metadata) {
|
|
if (!metadata.isDirectory() || metadata.isSymbolicLink()) {
|
|
throw new Error('unsafe cache directory');
|
|
}
|
|
}
|
|
|
|
class CachePathChangedError extends Error {}
|
|
class CacheOperationRaceError extends Error {}
|
|
class OperationFencedError extends Error {}
|
|
|
|
const preparedCacheDirectories = new Map();
|
|
|
|
function invalidateCacheToken(token, message, cause) {
|
|
token.trusted = false;
|
|
if (cause instanceof CachePathChangedError) return cause;
|
|
const error = new CachePathChangedError(message);
|
|
if (cause) error.cause = cause;
|
|
return error;
|
|
}
|
|
|
|
async function verifyDirectorySnapshots(token, snapshots, description) {
|
|
if (!token.trusted) throw new CachePathChangedError('cache directory is no longer trusted');
|
|
try {
|
|
for (const snapshot of snapshots) {
|
|
const metadata = await fs.lstat(snapshot.directory, { bigint: true });
|
|
assertSafeDirectory(metadata);
|
|
if (!isSameFile(snapshot.metadata, metadata)) {
|
|
throw new Error(`${description} changed`);
|
|
}
|
|
}
|
|
} catch (error) {
|
|
throw invalidateCacheToken(token, `${description} changed`, error);
|
|
}
|
|
}
|
|
|
|
async function verifyCacheToken(token) {
|
|
await verifyDirectorySnapshots(token, token.ancestorSnapshots, 'cache directory');
|
|
}
|
|
|
|
async function guardedCacheRead(token, read) {
|
|
await verifyCacheToken(token);
|
|
let result;
|
|
try {
|
|
result = await read();
|
|
} catch (error) {
|
|
await verifyCacheToken(token);
|
|
throw error;
|
|
}
|
|
await verifyCacheToken(token);
|
|
return result;
|
|
}
|
|
|
|
function cacheTokenFor(cacheDirectory) {
|
|
const token = preparedCacheDirectories.get(path.resolve(cacheDirectory));
|
|
if (!token || !token.trusted) {
|
|
throw new CachePathChangedError('cache directory has not been prepared safely');
|
|
}
|
|
return token;
|
|
}
|
|
|
|
function resolveCacheTarget(token, target) {
|
|
const resolved = path.resolve(target);
|
|
if (resolved === token.directory || !isWithinDirectory(token.directory, resolved)) {
|
|
throw invalidateCacheToken(token, 'cache mutation escaped its prepared directory');
|
|
}
|
|
return resolved;
|
|
}
|
|
|
|
async function captureMutationParentSnapshots(token, targets) {
|
|
await verifyCacheToken(token);
|
|
const snapshots = new Map();
|
|
try {
|
|
for (const target of targets) {
|
|
const parent = path.dirname(resolveCacheTarget(token, target));
|
|
const relative = path.relative(token.directory, parent);
|
|
let current = token.directory;
|
|
for (const component of relative.split(path.sep).filter(Boolean)) {
|
|
current = path.join(current, component);
|
|
if (snapshots.has(current)) continue;
|
|
const metadata = await fs.lstat(current, { bigint: true });
|
|
assertSafeDirectory(metadata);
|
|
snapshots.set(current, { directory: current, metadata });
|
|
}
|
|
}
|
|
} catch (error) {
|
|
throw invalidateCacheToken(token, 'cache mutation parent changed', error);
|
|
}
|
|
const captured = [...snapshots.values()];
|
|
await verifyMutationParentSnapshots(token, captured);
|
|
await verifyCacheToken(token);
|
|
return captured;
|
|
}
|
|
|
|
async function verifyMutationParentSnapshots(token, snapshots) {
|
|
try {
|
|
for (const snapshot of snapshots) {
|
|
const metadata = await fs.lstat(snapshot.directory, { bigint: true });
|
|
assertSafeDirectory(metadata);
|
|
if (!isSameFile(snapshot.metadata, metadata)) {
|
|
throw new Error('cache mutation parent changed');
|
|
}
|
|
}
|
|
} catch (error) {
|
|
const raced = new CacheOperationRaceError('cache mutation parent changed');
|
|
raced.cause = error;
|
|
throw raced;
|
|
}
|
|
}
|
|
|
|
async function verifyMutationContext(token, parentSnapshots) {
|
|
await verifyCacheToken(token);
|
|
await verifyMutationParentSnapshots(token, parentSnapshots);
|
|
await verifyCacheToken(token);
|
|
}
|
|
|
|
function assertSafeCacheEntry(metadata) {
|
|
if (metadata.isSymbolicLink() || (!metadata.isDirectory() && !metadata.isFile())) {
|
|
throw new Error('unsafe cache entry');
|
|
}
|
|
}
|
|
|
|
function assertSafeRegularFile(metadata) {
|
|
if (!metadata.isFile() || metadata.isSymbolicLink()) {
|
|
throw new Error('unsafe cache file');
|
|
}
|
|
}
|
|
|
|
function assertRenameableCacheEntry(metadata) {
|
|
if (!metadata.isDirectory() && !metadata.isFile() && !metadata.isSymbolicLink()) {
|
|
throw new Error('unsafe cache rename entry');
|
|
}
|
|
}
|
|
|
|
async function stableCacheEntry(
|
|
token,
|
|
target,
|
|
validate,
|
|
expectedMetadata = null,
|
|
missingIsOperationRace = false,
|
|
) {
|
|
const resolved = resolveCacheTarget(token, target);
|
|
try {
|
|
const first = await fs.lstat(resolved, { bigint: true });
|
|
validate(first);
|
|
if (expectedMetadata && !isSameFile(expectedMetadata, first)) {
|
|
throw new Error('cache entry identity changed');
|
|
}
|
|
await verifyCacheToken(token);
|
|
const second = await fs.lstat(resolved, { bigint: true });
|
|
validate(second);
|
|
if (!isSameFile(first, second)
|
|
|| (expectedMetadata && !isSameFile(expectedMetadata, second))) {
|
|
throw new Error('cache entry identity changed');
|
|
}
|
|
return second;
|
|
} catch (error) {
|
|
if (missingIsOperationRace && error?.code === 'ENOENT') {
|
|
await verifyCacheToken(token);
|
|
throw error;
|
|
}
|
|
throw invalidateCacheToken(token, 'cache mutation produced an untrusted object', error);
|
|
}
|
|
}
|
|
|
|
async function guardedCacheEntryRead(
|
|
token,
|
|
target,
|
|
validate,
|
|
read,
|
|
expectedMetadata = null,
|
|
) {
|
|
const metadata = await stableCacheEntry(
|
|
token,
|
|
target,
|
|
validate,
|
|
expectedMetadata,
|
|
true,
|
|
);
|
|
let result;
|
|
try {
|
|
result = await read(metadata);
|
|
} catch (error) {
|
|
await stableCacheEntry(token, target, validate, metadata, true);
|
|
throw error;
|
|
}
|
|
await stableCacheEntry(token, target, validate, metadata, true);
|
|
return result;
|
|
}
|
|
|
|
async function stablePreparedDirectory(token, target) {
|
|
try {
|
|
const first = await fs.lstat(target, { bigint: true });
|
|
assertSafeDirectory(first);
|
|
await verifyCacheToken(token);
|
|
const second = await fs.lstat(target, { bigint: true });
|
|
assertSafeDirectory(second);
|
|
if (!isSameFile(first, second)) throw new Error('prepared directory identity changed');
|
|
return second;
|
|
} catch (error) {
|
|
throw invalidateCacheToken(token, 'prepared directory could not be verified', error);
|
|
}
|
|
}
|
|
|
|
async function assertCacheTargetAbsent(token, target) {
|
|
const resolved = resolveCacheTarget(token, target);
|
|
try {
|
|
await fs.lstat(resolved, { bigint: true });
|
|
} catch (error) {
|
|
if (error?.code === 'ENOENT') return;
|
|
throw invalidateCacheToken(token, 'cache mutation target could not be verified', error);
|
|
}
|
|
throw invalidateCacheToken(token, 'cache mutation left an unexpected object');
|
|
}
|
|
|
|
async function guardedCacheMutation(token, parentSnapshots, {
|
|
mutate,
|
|
verifyBefore = null,
|
|
verifyAfter,
|
|
}) {
|
|
await verifyMutationContext(token, parentSnapshots);
|
|
if (verifyBefore) await verifyBefore();
|
|
let result;
|
|
try {
|
|
result = await mutate();
|
|
} catch (error) {
|
|
await verifyMutationContext(token, parentSnapshots);
|
|
throw error;
|
|
}
|
|
try {
|
|
await verifyMutationContext(token, parentSnapshots);
|
|
const verified = await verifyAfter(result);
|
|
await verifyMutationContext(token, parentSnapshots);
|
|
return verified ?? result;
|
|
} catch (error) {
|
|
if (error instanceof CacheOperationRaceError) throw error;
|
|
throw invalidateCacheToken(token, 'cache mutation could not be verified', error);
|
|
}
|
|
}
|
|
|
|
async function cacheMkdirWithToken(token, target, options, parentSnapshots = null) {
|
|
const resolved = path.resolve(target);
|
|
const parents = parentSnapshots ?? await captureMutationParentSnapshots(token, [resolved]);
|
|
return guardedCacheMutation(token, parents, {
|
|
mutate: () => fs.mkdir(resolved, options),
|
|
verifyAfter: () => (parentSnapshots === null
|
|
? stableCacheEntry(token, resolved, assertSafeDirectory)
|
|
: stablePreparedDirectory(token, resolved)),
|
|
});
|
|
}
|
|
|
|
async function cacheMkdir(cacheDirectory, target, options) {
|
|
return cacheMkdirWithToken(cacheTokenFor(cacheDirectory), target, options);
|
|
}
|
|
|
|
async function cacheWriteFile(cacheDirectory, target, data, options) {
|
|
if (options?.flag !== 'wx') {
|
|
throw new Error('cache files must be created exclusively');
|
|
}
|
|
const token = cacheTokenFor(cacheDirectory);
|
|
const resolved = resolveCacheTarget(token, target);
|
|
const parents = await captureMutationParentSnapshots(token, [resolved]);
|
|
return guardedCacheMutation(token, parents, {
|
|
mutate: async () => {
|
|
const flags = fsConstants.O_WRONLY
|
|
| fsConstants.O_CREAT
|
|
| fsConstants.O_EXCL
|
|
| (fsConstants.O_NOFOLLOW ?? 0);
|
|
const handle = await fs.open(resolved, flags, options.mode);
|
|
try {
|
|
const openedMetadata = await handle.stat({ bigint: true });
|
|
if (!openedMetadata.isFile() || openedMetadata.isSymbolicLink()) {
|
|
throw new Error('cache write did not open a regular file');
|
|
}
|
|
await handle.writeFile(data, options.encoding ? { encoding: options.encoding } : undefined);
|
|
const writtenMetadata = await handle.stat({ bigint: true });
|
|
const expectedBytes = Buffer.isBuffer(data)
|
|
? data.length
|
|
: Buffer.byteLength(data, options.encoding || 'utf8');
|
|
if (!isSameFile(openedMetadata, writtenMetadata)
|
|
|| writtenMetadata.size !== BigInt(expectedBytes)) {
|
|
throw new Error('cache write did not preserve the opened file identity and size');
|
|
}
|
|
return writtenMetadata;
|
|
} finally {
|
|
await handle.close();
|
|
}
|
|
},
|
|
verifyAfter: (writtenMetadata) => stableCacheEntry(token, resolved, (metadata) => {
|
|
if (!metadata.isFile() || metadata.isSymbolicLink()) {
|
|
throw new Error('cache write did not create a regular file');
|
|
}
|
|
}, writtenMetadata),
|
|
});
|
|
}
|
|
|
|
async function cacheRename(
|
|
cacheDirectory,
|
|
source,
|
|
destination,
|
|
validate,
|
|
expectedSourceMetadata = null,
|
|
) {
|
|
if (typeof validate !== 'function') {
|
|
throw new TypeError('cache rename requires an explicit entry validator');
|
|
}
|
|
const token = cacheTokenFor(cacheDirectory);
|
|
const resolvedSource = resolveCacheTarget(token, source);
|
|
const resolvedDestination = resolveCacheTarget(token, destination);
|
|
const parents = await captureMutationParentSnapshots(
|
|
token,
|
|
[resolvedSource, resolvedDestination],
|
|
);
|
|
const sourceMetadata = await stableCacheEntry(
|
|
token,
|
|
resolvedSource,
|
|
validate,
|
|
expectedSourceMetadata,
|
|
true,
|
|
);
|
|
return guardedCacheMutation(token, parents, {
|
|
verifyBefore: () => stableCacheEntry(
|
|
token,
|
|
resolvedSource,
|
|
validate,
|
|
sourceMetadata,
|
|
true,
|
|
),
|
|
mutate: () => fs.rename(resolvedSource, resolvedDestination),
|
|
verifyAfter: async () => {
|
|
await assertCacheTargetAbsent(token, resolvedSource);
|
|
return stableCacheEntry(
|
|
token,
|
|
resolvedDestination,
|
|
validate,
|
|
sourceMetadata,
|
|
);
|
|
},
|
|
});
|
|
}
|
|
|
|
async function cacheRm(cacheDirectory, target, options) {
|
|
if (options?.recursive) {
|
|
throw new Error('recursive cache removal is not allowed');
|
|
}
|
|
const token = cacheTokenFor(cacheDirectory);
|
|
const resolved = resolveCacheTarget(token, target);
|
|
const parents = await captureMutationParentSnapshots(token, [resolved]);
|
|
let initialMetadata;
|
|
try {
|
|
initialMetadata = await fs.lstat(resolved, { bigint: true });
|
|
} catch (error) {
|
|
if (error?.code === 'ENOENT' && options?.force) {
|
|
await verifyMutationContext(token, parents);
|
|
return;
|
|
}
|
|
throw error;
|
|
}
|
|
try {
|
|
assertSafeCacheEntry(initialMetadata);
|
|
} catch (error) {
|
|
throw invalidateCacheToken(token, 'cache removal target is unsafe', error);
|
|
}
|
|
const targetMetadata = await stableCacheEntry(
|
|
token,
|
|
resolved,
|
|
assertSafeCacheEntry,
|
|
initialMetadata,
|
|
);
|
|
return guardedCacheMutation(token, parents, {
|
|
verifyBefore: () => stableCacheEntry(
|
|
token,
|
|
resolved,
|
|
assertSafeCacheEntry,
|
|
targetMetadata,
|
|
),
|
|
mutate: () => fs.rm(resolved, options),
|
|
verifyAfter: () => assertCacheTargetAbsent(token, resolved),
|
|
});
|
|
}
|
|
|
|
async function prepareCacheDirectory(cacheDirectory) {
|
|
const absoluteTarget = path.resolve(cacheDirectory);
|
|
const target = await canonicalizeTrustedDirectoryPrefix(absoluteTarget);
|
|
const parsed = path.parse(target);
|
|
const components = target.slice(parsed.root.length).split(path.sep).filter(Boolean);
|
|
let current = parsed.root;
|
|
const token = {
|
|
directory: target,
|
|
ancestorSnapshots: [],
|
|
trusted: true,
|
|
};
|
|
|
|
for (const component of components) {
|
|
current = path.join(current, component);
|
|
let metadata;
|
|
try {
|
|
metadata = await fs.lstat(current, { bigint: true });
|
|
} catch (error) {
|
|
if (error?.code !== 'ENOENT') throw error;
|
|
try {
|
|
await cacheMkdirWithToken(token, current, { mode: 0o700 }, []);
|
|
} catch (mkdirError) {
|
|
if (mkdirError?.code !== 'EEXIST') throw mkdirError;
|
|
}
|
|
metadata = await fs.lstat(current, { bigint: true });
|
|
}
|
|
assertSafeDirectory(metadata);
|
|
token.ancestorSnapshots.push({ directory: current, metadata });
|
|
}
|
|
|
|
await verifyCacheToken(token);
|
|
preparedCacheDirectories.set(target, token);
|
|
return target;
|
|
}
|
|
|
|
function operationName(kind, generation) {
|
|
return `${kind}-${generation.toString().padStart(20, '0')}`;
|
|
}
|
|
|
|
function parseOperationName(name) {
|
|
const match = OPERATION_NAME.exec(name);
|
|
if (!match) return null;
|
|
return { kind: match[1], generation: BigInt(match[2]) };
|
|
}
|
|
|
|
async function listOperations(cacheDirectory, token = cacheTokenFor(cacheDirectory)) {
|
|
return guardedCacheRead(token, async () => {
|
|
try {
|
|
const entries = await fs.readdir(cacheDirectory, { withFileTypes: true });
|
|
return entries.flatMap((entry) => {
|
|
const parsed = parseOperationName(entry.name);
|
|
if (!parsed) return [];
|
|
if (!entry.isDirectory() || entry.isSymbolicLink()) {
|
|
throw invalidateCacheToken(
|
|
token,
|
|
`cache operation ${entry.name} is not a safe directory`,
|
|
);
|
|
}
|
|
return [{ ...parsed, name: entry.name, directory: path.join(cacheDirectory, entry.name) }];
|
|
});
|
|
} catch (error) {
|
|
if (error?.code === 'ENOENT') return [];
|
|
throw error;
|
|
}
|
|
});
|
|
}
|
|
|
|
async function readState(cacheDirectory, installedVersion = null) {
|
|
const token = cacheTokenFor(cacheDirectory);
|
|
return guardedCacheRead(token, async () => {
|
|
const operations = (await listOperations(cacheDirectory, token))
|
|
.filter((operation) => operation.kind === 'committed')
|
|
.sort((left, right) => (left.generation > right.generation ? -1 : 1));
|
|
for (const operation of operations) {
|
|
try {
|
|
const state = await guardedCacheEntryRead(
|
|
token,
|
|
operation.directory,
|
|
assertSafeDirectory,
|
|
async () => normalizeState(
|
|
await readJsonFile(
|
|
path.join(operation.directory, OPERATION_STATE_FILE),
|
|
MAX_CACHE_STATE_BYTES,
|
|
),
|
|
installedVersion,
|
|
),
|
|
);
|
|
if (state) return state;
|
|
} catch (error) {
|
|
if (error instanceof FileIdentityChangedError
|
|
|| error instanceof CachePathChangedError
|
|
|| error instanceof CacheOperationRaceError) throw error;
|
|
// An incomplete or corrupt generation is ignored in favor of the previous commit.
|
|
}
|
|
}
|
|
return emptyState(installedVersion);
|
|
});
|
|
}
|
|
|
|
async function operationIsActive(
|
|
cacheDirectory,
|
|
operation,
|
|
token = cacheTokenFor(cacheDirectory),
|
|
) {
|
|
try {
|
|
return await guardedCacheEntryRead(
|
|
token,
|
|
operation.directory,
|
|
assertSafeDirectory,
|
|
async (directoryMetadata) => {
|
|
try {
|
|
const ownerMetadata = await fs.lstat(
|
|
path.join(operation.directory, OPERATION_OWNER_FILE),
|
|
{ bigint: true },
|
|
);
|
|
if (!ownerMetadata.isFile() || ownerMetadata.isSymbolicLink()) return false;
|
|
return metadataIsWithinLease(ownerMetadata);
|
|
} catch (error) {
|
|
if (error?.code !== 'ENOENT') throw error;
|
|
return metadataIsWithinLease(directoryMetadata);
|
|
}
|
|
},
|
|
);
|
|
} catch (error) {
|
|
if (error?.code === 'ENOENT') return false;
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
function validateClaimOwner(value) {
|
|
if (!isPlainObject(value)
|
|
|| !hasOnlyKeys(value, new Set(['pid', 'token', 'generation']))
|
|
|| !Number.isSafeInteger(value.pid)
|
|
|| value.pid <= 0
|
|
|| typeof value.token !== 'string'
|
|
|| !/^[a-f0-9]{32}$/.test(value.token)
|
|
|| typeof value.generation !== 'string'
|
|
|| !/^[1-9]\d*$/.test(value.generation)) return null;
|
|
return { ...value, generation: BigInt(value.generation) };
|
|
}
|
|
|
|
function metadataIsWithinLease(metadata) {
|
|
if (typeof metadata.mtimeNs === 'bigint') {
|
|
const ageNs = (BigInt(Date.now()) * 1_000_000n) - metadata.mtimeNs;
|
|
const absoluteAgeNs = ageNs < 0n ? -ageNs : ageNs;
|
|
return absoluteAgeNs <= BigInt(LOCK_STALE_MS) * 1_000_000n;
|
|
}
|
|
return Math.abs(Date.now() - metadata.mtimeMs) <= LOCK_STALE_MS;
|
|
}
|
|
|
|
function corruptClaimStatus(directory, directoryMetadata, ownerMetadata = directoryMetadata) {
|
|
if (metadataIsWithinLease(ownerMetadata)) {
|
|
return { state: 'busy', directory, metadata: directoryMetadata, owner: null };
|
|
}
|
|
const fingerprint = crypto.createHash('sha256')
|
|
.update(JSON.stringify({
|
|
directory: {
|
|
dev: directoryMetadata.dev.toString(),
|
|
ino: directoryMetadata.ino.toString(),
|
|
mode: directoryMetadata.mode.toString(),
|
|
birthtimeNs: directoryMetadata.birthtimeNs.toString(),
|
|
},
|
|
}))
|
|
.digest('hex')
|
|
.slice(0, 24);
|
|
return {
|
|
state: 'releasable',
|
|
directory,
|
|
metadata: directoryMetadata,
|
|
key: `corrupt-${fingerprint}`,
|
|
owner: null,
|
|
};
|
|
}
|
|
|
|
async function inspectActiveClaim(cacheDirectory) {
|
|
const token = cacheTokenFor(cacheDirectory);
|
|
return guardedCacheRead(token, async () => {
|
|
const directory = path.join(cacheDirectory, ACTIVE_CLAIM_DIRECTORY);
|
|
let directoryMetadata;
|
|
try {
|
|
directoryMetadata = await fs.lstat(directory, { bigint: true });
|
|
} catch (error) {
|
|
if (error?.code === 'ENOENT') return { state: 'absent', directory };
|
|
throw error;
|
|
}
|
|
if (!directoryMetadata.isDirectory() || directoryMetadata.isSymbolicLink()) {
|
|
return corruptClaimStatus(directory, directoryMetadata);
|
|
}
|
|
return guardedCacheEntryRead(
|
|
token,
|
|
directory,
|
|
assertSafeDirectory,
|
|
async (stableDirectoryMetadata) => {
|
|
const ownerPath = path.join(directory, OPERATION_OWNER_FILE);
|
|
let ownerMetadata = stableDirectoryMetadata;
|
|
let owner = null;
|
|
try {
|
|
ownerMetadata = await fs.lstat(ownerPath, { bigint: true });
|
|
if (!ownerMetadata.isFile()
|
|
|| ownerMetadata.isSymbolicLink()
|
|
|| ownerMetadata.size > BigInt(MAX_CLAIM_OWNER_BYTES)) {
|
|
return corruptClaimStatus(directory, stableDirectoryMetadata, ownerMetadata);
|
|
}
|
|
const ownerSource = await readJsonFile(
|
|
ownerPath,
|
|
MAX_CLAIM_OWNER_BYTES,
|
|
ownerMetadata,
|
|
);
|
|
owner = validateClaimOwner(ownerSource);
|
|
} catch (error) {
|
|
if (error instanceof FileIdentityChangedError) throw error;
|
|
// A malformed claim is recoverable only after the same hard lease as a crashed owner.
|
|
}
|
|
|
|
if (owner) {
|
|
const operations = await listOperations(cacheDirectory, token);
|
|
if (operations.some((operation) => operation.generation === owner.generation
|
|
&& ['committed', 'fenced', 'cancelled'].includes(operation.kind))) {
|
|
return {
|
|
state: 'releasable',
|
|
directory,
|
|
metadata: stableDirectoryMetadata,
|
|
key: owner.generation.toString(),
|
|
owner,
|
|
};
|
|
}
|
|
const pending = operations.find((operation) => operation.kind === 'pending'
|
|
&& operation.generation === owner.generation);
|
|
if (pending) {
|
|
if (await operationIsActive(cacheDirectory, pending, token)) {
|
|
return {
|
|
state: 'busy',
|
|
directory,
|
|
metadata: stableDirectoryMetadata,
|
|
owner,
|
|
};
|
|
}
|
|
return {
|
|
state: 'releasable',
|
|
directory,
|
|
metadata: stableDirectoryMetadata,
|
|
key: owner.generation.toString(),
|
|
owner,
|
|
};
|
|
}
|
|
}
|
|
|
|
return corruptClaimStatus(directory, stableDirectoryMetadata, ownerMetadata);
|
|
},
|
|
directoryMetadata,
|
|
);
|
|
});
|
|
}
|
|
|
|
async function retireActiveClaim(cacheDirectory, claim) {
|
|
const retirement = path.join(cacheDirectory, `retired-claim-${claim.key}`);
|
|
await cacheMkdir(cacheDirectory, retirement, { mode: 0o700 }).catch((error) => {
|
|
if (error?.code !== 'EEXIST') throw error;
|
|
});
|
|
try {
|
|
const retirementMetadata = await fs.lstat(retirement);
|
|
if (!retirementMetadata.isDirectory() || retirementMetadata.isSymbolicLink()) return false;
|
|
} catch (error) {
|
|
if (error instanceof CacheOperationRaceError) return false;
|
|
if (error?.code === 'ENOENT') return false;
|
|
throw error;
|
|
}
|
|
try {
|
|
const metadata = await fs.lstat(claim.directory);
|
|
if (metadata.isDirectory() && !metadata.isSymbolicLink()) {
|
|
await cacheWriteFile(
|
|
cacheDirectory,
|
|
path.join(claim.directory, '.retirement-guard'),
|
|
`${claim.key}\n`,
|
|
{ encoding: 'utf8', flag: 'wx', mode: 0o600 },
|
|
).catch((error) => {
|
|
if (error?.code !== 'EEXIST') throw error;
|
|
});
|
|
}
|
|
} catch (error) {
|
|
if (error instanceof CacheOperationRaceError) return false;
|
|
if (error?.code === 'ENOENT') return false;
|
|
throw error;
|
|
}
|
|
try {
|
|
await cacheRename(
|
|
cacheDirectory,
|
|
claim.directory,
|
|
path.join(retirement, 'active'),
|
|
assertRenameableCacheEntry,
|
|
claim.metadata,
|
|
);
|
|
return true;
|
|
} catch (error) {
|
|
if (error instanceof CacheOperationRaceError) return false;
|
|
if (['ENOENT', 'EEXIST', 'ENOTEMPTY', 'EPERM'].includes(error?.code)) return false;
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async function prepareOperationClaim(cacheDirectory, operation) {
|
|
const directory = path.join(cacheDirectory, operationName('claim', operation.generation));
|
|
await cacheMkdir(cacheDirectory, directory, { mode: 0o700 });
|
|
await cacheWriteFile(
|
|
cacheDirectory,
|
|
path.join(directory, OPERATION_OWNER_FILE),
|
|
`${JSON.stringify({
|
|
pid: process.pid,
|
|
token: operation.token,
|
|
generation: operation.generation.toString(),
|
|
})}\n`,
|
|
{ encoding: 'utf8', flag: 'wx', mode: 0o600 },
|
|
);
|
|
operation.preparedClaimDirectory = directory;
|
|
}
|
|
|
|
async function discardPreparedClaim(cacheDirectory, operation) {
|
|
if (!operation.preparedClaimDirectory) return;
|
|
const discarded = path.join(
|
|
cacheDirectory,
|
|
`discarded-claim-${operation.generation.toString().padStart(20, '0')}-${operation.token}`,
|
|
);
|
|
try {
|
|
await cacheRename(
|
|
cacheDirectory,
|
|
operation.preparedClaimDirectory,
|
|
discarded,
|
|
assertSafeDirectory,
|
|
);
|
|
} catch (error) {
|
|
if (error?.code !== 'ENOENT') throw error;
|
|
}
|
|
delete operation.preparedClaimDirectory;
|
|
}
|
|
|
|
async function promoteOperationClaim(cacheDirectory, operation) {
|
|
await prepareOperationClaim(cacheDirectory, operation);
|
|
const activeDirectory = path.join(cacheDirectory, ACTIVE_CLAIM_DIRECTORY);
|
|
for (let attempt = 0; attempt < 8; attempt += 1) {
|
|
if (await operationIsSuperseded(cacheDirectory, operation)) {
|
|
await discardPreparedClaim(cacheDirectory, operation);
|
|
return false;
|
|
}
|
|
const active = await inspectActiveClaim(cacheDirectory);
|
|
if (active.state === 'busy') {
|
|
await discardPreparedClaim(cacheDirectory, operation);
|
|
return false;
|
|
}
|
|
if (active.state === 'releasable') {
|
|
if (active.owner?.generation > operation.generation) {
|
|
await discardPreparedClaim(cacheDirectory, operation);
|
|
return false;
|
|
}
|
|
await retireActiveClaim(cacheDirectory, active);
|
|
continue;
|
|
}
|
|
try {
|
|
await cacheRename(
|
|
cacheDirectory,
|
|
operation.preparedClaimDirectory,
|
|
activeDirectory,
|
|
assertSafeDirectory,
|
|
);
|
|
delete operation.preparedClaimDirectory;
|
|
if (!await operationIsActive(cacheDirectory, operation)) return false;
|
|
return true;
|
|
} catch (promotionError) {
|
|
const raced = await inspectActiveClaim(cacheDirectory);
|
|
if (raced.state === 'absent') {
|
|
if (['EEXIST', 'ENOTEMPTY', 'EPERM'].includes(promotionError?.code)) continue;
|
|
await discardPreparedClaim(cacheDirectory, operation);
|
|
throw promotionError;
|
|
}
|
|
continue;
|
|
}
|
|
}
|
|
await discardPreparedClaim(cacheDirectory, operation);
|
|
return false;
|
|
}
|
|
|
|
async function operationOwnsActiveClaim(cacheDirectory, operation) {
|
|
const active = await inspectActiveClaim(cacheDirectory);
|
|
return active.state === 'busy'
|
|
&& active.owner?.generation === operation.generation
|
|
&& active.owner.token === operation.token;
|
|
}
|
|
|
|
async function hasActivePendingOperation(cacheDirectory) {
|
|
const token = cacheTokenFor(cacheDirectory);
|
|
return guardedCacheRead(token, async () => {
|
|
const pending = (await listOperations(cacheDirectory, token))
|
|
.filter((operation) => operation.kind === 'pending');
|
|
return (await Promise.all(pending.map(
|
|
(operation) => operationIsActive(cacheDirectory, operation, token),
|
|
))).some(Boolean);
|
|
});
|
|
}
|
|
|
|
async function reserveOperation(cacheDirectory) {
|
|
cacheDirectory = await prepareCacheDirectory(cacheDirectory);
|
|
const operations = await listOperations(cacheDirectory);
|
|
const occupied = new Set(operations.map((operation) => operation.generation.toString()));
|
|
let generation = 1n;
|
|
while (true) {
|
|
while (occupied.has(generation.toString())) generation += 1n;
|
|
if (generation > MAX_OPERATION_GENERATION) throw new Error('operation generation space exhausted');
|
|
const reservation = path.join(cacheDirectory, operationName('reserved', generation));
|
|
try {
|
|
await cacheMkdir(cacheDirectory, reservation, { mode: 0o700 });
|
|
} catch (error) {
|
|
if (error?.code !== 'EEXIST') throw error;
|
|
occupied.add(generation.toString());
|
|
generation += 1n;
|
|
continue;
|
|
}
|
|
const directory = path.join(cacheDirectory, operationName('pending', generation));
|
|
try {
|
|
await cacheMkdir(cacheDirectory, directory, { mode: 0o700 });
|
|
const operation = {
|
|
generation,
|
|
token: crypto.randomBytes(16).toString('hex'),
|
|
directory,
|
|
};
|
|
try {
|
|
await cacheWriteFile(
|
|
cacheDirectory,
|
|
path.join(directory, OPERATION_OWNER_FILE),
|
|
`${JSON.stringify({ pid: process.pid, token: operation.token })}\n`,
|
|
{ encoding: 'utf8', flag: 'wx', mode: 0o600 },
|
|
);
|
|
} catch (error) {
|
|
if (await operationWasFenced(cacheDirectory, operation)) {
|
|
const fencedError = new OperationFencedError('operation was fenced while reserving');
|
|
fencedError.cause = error;
|
|
throw fencedError;
|
|
}
|
|
await cacheRename(
|
|
cacheDirectory,
|
|
directory,
|
|
path.join(cacheDirectory, operationName('cancelled', generation)),
|
|
assertSafeDirectory,
|
|
).catch(() => {});
|
|
throw error;
|
|
}
|
|
return operation;
|
|
} catch (error) {
|
|
if (error?.code !== 'EEXIST') throw error;
|
|
occupied.add(generation.toString());
|
|
generation += 1n;
|
|
}
|
|
}
|
|
}
|
|
|
|
async function operationIsSuperseded(cacheDirectory, operation) {
|
|
return (await listOperations(cacheDirectory))
|
|
.some((candidate) => candidate.generation > operation.generation);
|
|
}
|
|
|
|
async function operationWasFenced(cacheDirectory, operation) {
|
|
const token = cacheTokenFor(cacheDirectory);
|
|
return guardedCacheRead(token, async () => {
|
|
try {
|
|
await fs.lstat(operation.directory, { bigint: true });
|
|
return false;
|
|
} catch (error) {
|
|
if (error?.code !== 'ENOENT') throw error;
|
|
}
|
|
const fencedDirectory = path.join(
|
|
cacheDirectory,
|
|
operationName('fenced', operation.generation),
|
|
);
|
|
try {
|
|
await stableCacheEntry(
|
|
token,
|
|
fencedDirectory,
|
|
assertSafeDirectory,
|
|
null,
|
|
true,
|
|
);
|
|
return true;
|
|
} catch (error) {
|
|
if (error?.code === 'ENOENT') return false;
|
|
throw error;
|
|
}
|
|
});
|
|
}
|
|
|
|
async function transitionOperation(cacheDirectory, operation, kind) {
|
|
const destination = path.join(cacheDirectory, operationName(kind, operation.generation));
|
|
await cacheRename(cacheDirectory, operation.directory, destination, assertSafeDirectory);
|
|
}
|
|
|
|
async function cancelOperation(cacheDirectory, operation) {
|
|
try {
|
|
await transitionOperation(cacheDirectory, operation, 'cancelled');
|
|
} catch (error) {
|
|
// A higher generation may already have fenced this unique pending directory.
|
|
if (error?.code !== 'ENOENT') throw error;
|
|
}
|
|
}
|
|
|
|
async function acquireOperation(cacheDirectory) {
|
|
cacheDirectory = await prepareCacheDirectory(cacheDirectory);
|
|
if ((await inspectActiveClaim(cacheDirectory)).state === 'busy') return null;
|
|
if (await hasActivePendingOperation(cacheDirectory)) return null;
|
|
let operation;
|
|
try {
|
|
operation = await reserveOperation(cacheDirectory);
|
|
} catch (error) {
|
|
if (error instanceof OperationFencedError) return null;
|
|
throw error;
|
|
}
|
|
if (!await operationIsSuperseded(cacheDirectory, operation)
|
|
&& await promoteOperationClaim(cacheDirectory, operation)) return operation;
|
|
await cancelOperation(cacheDirectory, operation);
|
|
return null;
|
|
}
|
|
|
|
async function fenceLowerOperations(cacheDirectory, operation) {
|
|
const lower = (await listOperations(cacheDirectory))
|
|
.filter((candidate) => candidate.kind === 'pending'
|
|
&& candidate.generation < operation.generation)
|
|
.sort((left, right) => (left.generation < right.generation ? -1 : 1));
|
|
for (const candidate of lower) {
|
|
try {
|
|
await cacheRename(
|
|
cacheDirectory,
|
|
candidate.directory,
|
|
path.join(cacheDirectory, operationName('fenced', candidate.generation)),
|
|
assertSafeDirectory,
|
|
);
|
|
} catch (error) {
|
|
if (error?.code !== 'ENOENT') throw error;
|
|
}
|
|
}
|
|
}
|
|
|
|
async function commitOperation(cacheDirectory, operation, installedVersion, mutate) {
|
|
if (!await operationOwnsActiveClaim(cacheDirectory, operation)) {
|
|
await cancelOperation(cacheDirectory, operation);
|
|
return { fenced: true, result: silent('check-in-progress') };
|
|
}
|
|
await fenceLowerOperations(cacheDirectory, operation);
|
|
if (!await operationOwnsActiveClaim(cacheDirectory, operation)) {
|
|
await cancelOperation(cacheDirectory, operation);
|
|
return { fenced: true, result: silent('check-in-progress') };
|
|
}
|
|
const state = await readState(cacheDirectory, installedVersion);
|
|
const decision = mutate(state);
|
|
if (!decision.state) {
|
|
await cancelOperation(cacheDirectory, operation);
|
|
return { fenced: false, result: decision.result };
|
|
}
|
|
let committedState = decision.state;
|
|
let committedResult = decision.result;
|
|
let encodedState = encodeRecoverableState(committedState);
|
|
if (!encodedState && decision.capacityFallback) {
|
|
committedState = decision.capacityFallback.state;
|
|
committedResult = decision.capacityFallback.result;
|
|
encodedState = encodeRecoverableState(committedState);
|
|
}
|
|
if (!encodedState) {
|
|
await cancelOperation(cacheDirectory, operation);
|
|
return { fenced: false, result: silent('cache-unavailable') };
|
|
}
|
|
const temporary = path.join(
|
|
operation.directory,
|
|
`.state.${process.pid}.${crypto.randomBytes(8).toString('hex')}.tmp`,
|
|
);
|
|
try {
|
|
await cacheWriteFile(cacheDirectory, temporary, encodedState, {
|
|
flag: 'wx',
|
|
mode: 0o600,
|
|
});
|
|
await cacheRename(
|
|
cacheDirectory,
|
|
temporary,
|
|
path.join(operation.directory, OPERATION_STATE_FILE),
|
|
assertSafeRegularFile,
|
|
);
|
|
await transitionOperation(cacheDirectory, operation, 'committed');
|
|
return { fenced: false, result: committedResult };
|
|
} catch (error) {
|
|
if (await operationWasFenced(cacheDirectory, operation)) {
|
|
return { fenced: true, result: silent('check-in-progress') };
|
|
}
|
|
await cacheRm(cacheDirectory, temporary, { force: true }).catch(() => {});
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
function nextSuccessfulCheck(nowMs, random) {
|
|
const boundedRandom = Math.min(1, Math.max(0, Number(random()) || 0));
|
|
const multiplier = 0.8 + (boundedRandom * 0.4);
|
|
return new Date(nowMs + Math.round(CHECK_TTL_MS * multiplier)).toISOString();
|
|
}
|
|
|
|
function nextFailedCheck(nowMs, failures) {
|
|
const delay = failures <= 1 ? FIRST_FAILURE_DELAY_MS : LATER_FAILURE_DELAY_MS;
|
|
return new Date(nowMs + delay).toISOString();
|
|
}
|
|
|
|
function stateAfterFailedCheck(state, nowMs, withdrawCandidate = false) {
|
|
const failures = Math.min(state.check.consecutiveFailures + 1, 2);
|
|
return stateWithFailureCheck(
|
|
state,
|
|
nextFailedCheck(nowMs, failures),
|
|
failures,
|
|
withdrawCandidate,
|
|
);
|
|
}
|
|
|
|
function eventKeyForDigest(targetDigest) {
|
|
return `${SKILL_ID}@${targetDigest}`;
|
|
}
|
|
|
|
function digestForEventKey(eventKey) {
|
|
const prefix = `${SKILL_ID}@`;
|
|
if (typeof eventKey !== 'string' || !eventKey.startsWith(prefix)) return null;
|
|
const digest = eventKey.slice(prefix.length);
|
|
return DIGEST_PATTERN.test(digest) ? digest : null;
|
|
}
|
|
|
|
function notification(localRelease, candidate) {
|
|
return {
|
|
status: 'update_available',
|
|
eventKey: eventKeyForDigest(candidate.targetDigest),
|
|
installedVersion: localRelease.version,
|
|
latestVersion: candidate.version,
|
|
targetDigest: candidate.targetDigest,
|
|
severity: candidate.severity,
|
|
summary: `Archify ${candidate.version} is available; see the official release notes for details.`,
|
|
releaseNotes: candidate.releaseNotes,
|
|
};
|
|
}
|
|
|
|
function resultForCandidate(localRelease, state) {
|
|
if (!state.candidate) return silent('cache-valid');
|
|
const comparison = compareSemver(state.candidate.version, localRelease.version);
|
|
if (comparison <= 0) return silent('current');
|
|
if (state.notification.acknowledgedDigests.includes(state.candidate.targetDigest)) {
|
|
return silent('already-notified');
|
|
}
|
|
return notification(localRelease, state.candidate);
|
|
}
|
|
|
|
async function readBoundedBody(response) {
|
|
const contentLength = Number(response.headers.get('content-length'));
|
|
if (Number.isFinite(contentLength) && contentLength > MAX_RESPONSE_BYTES) {
|
|
await cancelResponseBody(response);
|
|
throw new UpdateContractError('manifest response is too large');
|
|
}
|
|
if (!response.body || typeof response.body.getReader !== 'function') {
|
|
throw new UpdateContractError('manifest response has no bounded stream');
|
|
}
|
|
const reader = response.body.getReader();
|
|
const chunks = [];
|
|
let size = 0;
|
|
try {
|
|
while (true) {
|
|
const { done, value } = await reader.read();
|
|
if (done) break;
|
|
size += value.byteLength;
|
|
if (size > MAX_RESPONSE_BYTES) {
|
|
await reader.cancel().catch(() => {});
|
|
throw new UpdateContractError('manifest response is too large');
|
|
}
|
|
chunks.push(value);
|
|
}
|
|
} finally {
|
|
reader.releaseLock();
|
|
}
|
|
const joined = new Uint8Array(size);
|
|
let offset = 0;
|
|
for (const chunk of chunks) {
|
|
joined.set(chunk, offset);
|
|
offset += chunk.byteLength;
|
|
}
|
|
try {
|
|
return new TextDecoder('utf-8', { fatal: true }).decode(joined);
|
|
} catch {
|
|
throw new UpdateContractError('manifest is not valid UTF-8');
|
|
}
|
|
}
|
|
|
|
async function cancelResponseBody(response) {
|
|
try {
|
|
if (response.body && typeof response.body.cancel === 'function') {
|
|
await response.body.cancel();
|
|
}
|
|
} catch {
|
|
// Network cleanup is best-effort; the original response classification wins.
|
|
}
|
|
}
|
|
|
|
async function fetchCandidate({ fetchImpl, manifestUrl, timeoutMs }) {
|
|
if (manifestUrl !== DEFAULT_MANIFEST_URL) throw new UpdateContractError('unexpected manifest URL');
|
|
const controller = new AbortController();
|
|
let timer;
|
|
const timeout = new Promise((_resolve, reject) => {
|
|
timer = setTimeout(() => {
|
|
controller.abort();
|
|
reject(new Error('update check timed out'));
|
|
}, timeoutMs);
|
|
});
|
|
const request = (async () => {
|
|
const headers = { accept: 'application/json' };
|
|
const response = await fetchImpl(manifestUrl, {
|
|
method: 'GET',
|
|
headers,
|
|
redirect: 'error',
|
|
signal: controller.signal,
|
|
});
|
|
if (response.status !== 200) {
|
|
await cancelResponseBody(response);
|
|
throw new Error(`manifest returned HTTP ${response.status}`);
|
|
}
|
|
const mediaType = response.headers.get('content-type')?.split(';', 1)[0].trim().toLowerCase();
|
|
if (mediaType !== 'application/json') {
|
|
await cancelResponseBody(response);
|
|
throw new UpdateContractError('manifest is not JSON');
|
|
}
|
|
const source = await readBoundedBody(response);
|
|
let decoded;
|
|
try {
|
|
decoded = JSON.parse(source);
|
|
} catch {
|
|
throw new UpdateContractError('manifest is not valid bounded JSON');
|
|
}
|
|
return { candidate: validateManifest(decoded) };
|
|
})();
|
|
try {
|
|
return await Promise.race([request, timeout]);
|
|
} finally {
|
|
clearTimeout(timer);
|
|
}
|
|
}
|
|
|
|
function defaultCacheDirectory() {
|
|
const homeDirectory = os.homedir();
|
|
if (process.platform === 'win32') {
|
|
const localData = process.env.LOCALAPPDATA;
|
|
return path.join(localData && path.isAbsolute(localData) ? localData : path.join(homeDirectory, 'AppData', 'Local'), 'archify-skill');
|
|
}
|
|
if (process.platform === 'darwin') return path.join(homeDirectory, 'Library', 'Caches', 'archify-skill');
|
|
const xdgCache = process.env.XDG_CACHE_HOME;
|
|
return path.join(xdgCache && path.isAbsolute(xdgCache) ? xdgCache : path.join(homeDirectory, '.cache'), 'archify-skill');
|
|
}
|
|
|
|
function freshStateResult(localRelease, state, nowMs) {
|
|
const nextCheckAt = Date.parse(state.check.nextCheckAt || '');
|
|
if (Number.isFinite(nextCheckAt) && nextCheckAt > nowMs
|
|
&& nextCheckAt <= nowMs + MAX_CACHE_HORIZON_MS) {
|
|
return resultForCandidate(localRelease, state);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
async function resultAfterLosingOperation(cacheDirectory, operation, localRelease) {
|
|
await cancelOperation(cacheDirectory, operation);
|
|
const cached = await readState(cacheDirectory, localRelease.version);
|
|
if (cached.candidate
|
|
&& cached.notification.offeredDigests.includes(cached.candidate.targetDigest)) {
|
|
return resultForCandidate(localRelease, cached);
|
|
}
|
|
return silent('check-in-progress');
|
|
}
|
|
|
|
async function waitUntilRetry(deadline, monotonicNow) {
|
|
let remaining;
|
|
try {
|
|
remaining = deadline - Number(monotonicNow());
|
|
} catch {
|
|
return false;
|
|
}
|
|
if (!Number.isFinite(remaining) || remaining <= 0) return false;
|
|
await new Promise((resolve) => setTimeout(
|
|
resolve,
|
|
Math.min(LOCK_RETRY_DELAY_MS, remaining),
|
|
));
|
|
return true;
|
|
}
|
|
|
|
export async function checkForUpdate({
|
|
releasePath = defaultReleasePath,
|
|
cacheDirectory = defaultCacheDirectory(),
|
|
fetchImpl = globalThis.fetch,
|
|
now = Date.now,
|
|
random = Math.random,
|
|
timeoutMs = DEFAULT_TIMEOUT_MS,
|
|
} = {}) {
|
|
if (typeof fetchImpl !== 'function') return silent('runtime-unavailable');
|
|
let localRelease;
|
|
try {
|
|
localRelease = validateLocalRelease(await readJsonFile(releasePath, MAX_LOCAL_RELEASE_BYTES));
|
|
} catch {
|
|
return silent('invalid-local-release');
|
|
}
|
|
|
|
const nowMs = Number(now());
|
|
if (!Number.isFinite(nowMs)) return silent('invalid-clock');
|
|
let stateDirectory = versionCacheDirectory(cacheDirectory, localRelease.version);
|
|
let state;
|
|
try {
|
|
stateDirectory = await prepareCacheDirectory(stateDirectory);
|
|
state = await readState(stateDirectory, localRelease.version);
|
|
} catch {
|
|
return silent('cache-unavailable');
|
|
}
|
|
const cachedResult = freshStateResult(localRelease, state, nowMs);
|
|
if (cachedResult) return cachedResult;
|
|
|
|
let operation;
|
|
try {
|
|
operation = await acquireOperation(stateDirectory);
|
|
} catch {
|
|
return silent('cache-unavailable');
|
|
}
|
|
if (!operation) {
|
|
try {
|
|
const cached = await readState(stateDirectory, localRelease.version);
|
|
if (cached.candidate
|
|
&& cached.notification.offeredDigests.includes(cached.candidate.targetDigest)) {
|
|
return resultForCandidate(localRelease, cached);
|
|
}
|
|
} catch {
|
|
return silent('cache-unavailable');
|
|
}
|
|
return silent('check-in-progress');
|
|
}
|
|
|
|
try {
|
|
state = await readState(stateDirectory, localRelease.version);
|
|
const racedResult = freshStateResult(localRelease, state, nowMs);
|
|
if (racedResult) {
|
|
await cancelOperation(stateDirectory, operation);
|
|
return racedResult;
|
|
}
|
|
|
|
if (!await operationOwnsActiveClaim(stateDirectory, operation)) {
|
|
return await resultAfterLosingOperation(stateDirectory, operation, localRelease);
|
|
}
|
|
|
|
await verifyCacheToken(cacheTokenFor(stateDirectory));
|
|
if (!await operationOwnsActiveClaim(stateDirectory, operation)) {
|
|
return await resultAfterLosingOperation(stateDirectory, operation, localRelease);
|
|
}
|
|
let fetched;
|
|
try {
|
|
fetched = await fetchCandidate({
|
|
fetchImpl,
|
|
manifestUrl: localRelease.updateManifestUrl,
|
|
timeoutMs,
|
|
});
|
|
} catch (error) {
|
|
const reason = error instanceof UpdateContractError ? 'invalid-manifest' : 'check-failed';
|
|
try {
|
|
const committed = await commitOperation(
|
|
stateDirectory,
|
|
operation,
|
|
localRelease.version,
|
|
(current) => ({
|
|
state: stateAfterFailedCheck(current, nowMs),
|
|
result: silent(reason),
|
|
}),
|
|
);
|
|
return committed.result;
|
|
} catch {
|
|
await cancelOperation(stateDirectory, operation).catch(() => {});
|
|
return silent('cache-unavailable');
|
|
}
|
|
}
|
|
|
|
try {
|
|
const committed = await commitOperation(
|
|
stateDirectory,
|
|
operation,
|
|
localRelease.version,
|
|
(current) => {
|
|
const next = {
|
|
...current,
|
|
candidate: fetched.candidate,
|
|
check: {
|
|
nextCheckAt: nextSuccessfulCheck(nowMs, random),
|
|
consecutiveFailures: 0,
|
|
},
|
|
notification: {
|
|
offeredDigests: [...current.notification.offeredDigests],
|
|
acknowledgedDigests: [...current.notification.acknowledgedDigests],
|
|
},
|
|
};
|
|
const result = resultForCandidate(localRelease, next);
|
|
if (result.status === 'update_available') {
|
|
if (!next.notification.offeredDigests.includes(next.candidate.targetDigest)) {
|
|
next.notification.offeredDigests.push(next.candidate.targetDigest);
|
|
}
|
|
}
|
|
return {
|
|
state: next,
|
|
result,
|
|
capacityFallback: {
|
|
state: stateAfterFailedCheck(current, nowMs, true),
|
|
result: silent('cache-unavailable'),
|
|
},
|
|
};
|
|
},
|
|
);
|
|
return committed.result;
|
|
} catch {
|
|
await cancelOperation(stateDirectory, operation).catch(() => {});
|
|
return silent('cache-unavailable');
|
|
}
|
|
} catch {
|
|
await cancelOperation(stateDirectory, operation).catch(() => {});
|
|
return silent('cache-unavailable');
|
|
}
|
|
}
|
|
|
|
export async function acknowledgeUpdate({
|
|
releasePath = defaultReleasePath,
|
|
cacheDirectory = defaultCacheDirectory(),
|
|
eventKey,
|
|
monotonicNow = () => performance.now(),
|
|
} = {}) {
|
|
const targetDigest = digestForEventKey(eventKey);
|
|
if (!targetDigest || typeof monotonicNow !== 'function') {
|
|
return silent('invalid-acknowledgement');
|
|
}
|
|
let localRelease;
|
|
try {
|
|
localRelease = validateLocalRelease(await readJsonFile(releasePath, MAX_LOCAL_RELEASE_BYTES));
|
|
} catch {
|
|
return silent('invalid-local-release');
|
|
}
|
|
let stateDirectory = versionCacheDirectory(cacheDirectory, localRelease.version);
|
|
try {
|
|
stateDirectory = await prepareCacheDirectory(stateDirectory);
|
|
} catch {
|
|
return silent('cache-unavailable');
|
|
}
|
|
let deadline;
|
|
try {
|
|
deadline = Number(monotonicNow()) + ACK_LOCK_WAIT_MS;
|
|
} catch {
|
|
return silent('invalid-acknowledgement');
|
|
}
|
|
if (!Number.isFinite(deadline)) return silent('invalid-acknowledgement');
|
|
while (true) {
|
|
let operation;
|
|
try {
|
|
operation = await acquireOperation(stateDirectory);
|
|
} catch {
|
|
return silent('cache-unavailable');
|
|
}
|
|
if (!operation) {
|
|
if (!await waitUntilRetry(deadline, monotonicNow)) return silent('check-in-progress');
|
|
continue;
|
|
}
|
|
try {
|
|
const committed = await commitOperation(
|
|
stateDirectory,
|
|
operation,
|
|
localRelease.version,
|
|
(state) => {
|
|
const wasOffered = state.notification.offeredDigests.includes(targetDigest);
|
|
const wasAcknowledged = state.notification.acknowledgedDigests.includes(targetDigest);
|
|
if (!wasOffered && !wasAcknowledged) {
|
|
return { state: null, result: silent('invalid-acknowledgement') };
|
|
}
|
|
return {
|
|
state: stateAfterAcknowledgement(state, targetDigest),
|
|
result: { status: 'acknowledged', eventKey },
|
|
};
|
|
},
|
|
);
|
|
if (!committed.fenced) return committed.result;
|
|
if (!await waitUntilRetry(deadline, monotonicNow)) return silent('check-in-progress');
|
|
} catch {
|
|
await cancelOperation(stateDirectory, operation).catch(() => {});
|
|
return silent('cache-unavailable');
|
|
}
|
|
}
|
|
}
|
|
|
|
async function runCli() {
|
|
if (process.env.ARCHIFY_UPDATE_CHECK_DISABLED === '1') return silent('disabled');
|
|
const argumentsList = process.argv.slice(2);
|
|
if (argumentsList.length === 0) return checkForUpdate();
|
|
if (argumentsList.length === 2 && argumentsList[0] === '--ack') {
|
|
return acknowledgeUpdate({ eventKey: argumentsList[1] });
|
|
}
|
|
return silent('invalid-arguments');
|
|
}
|
|
|
|
async function isMainModule() {
|
|
if (!process.argv[1]) return false;
|
|
try {
|
|
const [entryPath, modulePath] = await Promise.all([
|
|
fs.realpath(path.resolve(process.argv[1])),
|
|
fs.realpath(fileURLToPath(import.meta.url)),
|
|
]);
|
|
return entryPath === modulePath;
|
|
} catch {
|
|
return path.resolve(process.argv[1]) === path.resolve(fileURLToPath(import.meta.url));
|
|
}
|
|
}
|
|
|
|
if (await isMainModule()) {
|
|
let result;
|
|
try {
|
|
result = await runCli();
|
|
} catch {
|
|
result = silent('check-failed');
|
|
}
|
|
process.stdout.write(`${JSON.stringify(result)}\n`);
|
|
}
|