Bound self-host candidates to clean commits

This commit is contained in:
thePR0M3TH3AN committed 2026-09-27 19:36:42 -04:00
1 parent 6374b87f73
commit c535edf72c
5 files changed
+116 -14

No files matched your search

+3 -1
View File
@@ -322,7 +322,9 @@ deployment provider, so configuration alone cannot trigger an external action.
TORCH source checkouts can stage a self-host candidate without changing the
active version. Candidate build runs the complete local acceptance, lint, and
syntax gates before writing its receipt:
syntax gates before writing its receipt. Git checkout staging is bound to a
clean exact commit, copies tracked source plus its local dependency tree,
excludes unrelated untracked files, and refuses escaping symbolic links:
```bash
node bin/torch.mjs candidate plan --source . --json
+7
View File
@@ -1336,6 +1336,13 @@ Candidate releases MUST run side by side with stable using separate state,
socket, lock, port, and fixture namespaces. Candidate tests MUST not control
the live development fleet.
For a Git source checkout, candidate staging MUST bind the artifact to the
exact clean tracked commit. It may include the installed dependency tree needed
to execute acceptance, but MUST exclude arbitrary untracked files and reject
modified tracked input. Artifact-local relative dependency links are permitted;
links that escape the candidate root are refused. Non-Git inputs are treated as
already-prepared release artifacts and every contained entry is inventoried.
Candidate acceptance includes:
- initialize and analyze a fixture repository;
@@ -121,7 +121,7 @@ test_integrity_note:
- id: SCN-candidate-isolation
given: an inactive candidate in a namespace separate from project and stable runtime state
when: acceptance is incomplete, complete, or the accepted files are later modified
then: incomplete staging is removed, successful staging stays inactive, read-only status writes nothing, and stale validation blocks activation
then: incomplete staging is removed, successful staging stays inactive, read-only status writes nothing, stale validation blocks activation, Git staging binds to a clean exact commit, local dependencies remain runnable, and unrelated untracked files never enter the artifact
- id: SCN-atomic-upgrade-rollback
given: accepted forward-and-rollback-compatible versions installed side by side
when: activation is interrupted after the atomic pointer swap and the service restarts
+72 -11
View File
@@ -1,9 +1,10 @@
import { createHash, randomUUID } from 'node:crypto';
import { execFileSync } from 'node:child_process';
import {
cpSync, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, readlinkSync,
renameSync, rmSync, symlinkSync, writeFileSync,
} from 'node:fs';
import { basename, dirname, join, relative, resolve, sep } from 'node:path';
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
import { TorchError } from '../kernel/errors.mjs';
import { torchDataHome } from '../kernel/paths.mjs';
@@ -55,22 +56,33 @@ function inside(parent, child) {
return resolve(child).startsWith(root);
}
function inventory(root, { excludeValidation = false } = {}) {
function artifactPath(root, path) {
return relative(root, path).split(sep).join('/');
}
function inventory(root, { excludeValidation = false, include = () => true } = {}) {
const records = [];
const visit = (directory) => {
for (const entry of readdirSync(directory, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
if (entry.name === '.git') continue;
const path = join(directory, entry.name);
const name = relative(root, path);
const name = artifactPath(root, path);
if (excludeValidation && name === '.torch-validation.json') continue;
if (!include(name, entry)) continue;
const stat = lstatSync(path);
if (stat.isSymbolicLink()) {
throw new TorchError(`Candidate artifacts may not contain symbolic links: ${name}`, {
code: 'CANDIDATE_SYMLINK_REFUSED', details: { path: name },
});
const link = readlinkSync(path);
const target = resolve(dirname(path), link);
if (isAbsolute(link) || !inside(root, target) || !existsSync(target)) {
throw new TorchError(`Candidate symbolic link must resolve inside the artifact: ${name}`, {
code: 'CANDIDATE_SYMLINK_REFUSED', details: { path: name, target: link },
});
}
records.push({ path: name, bytes: Buffer.byteLength(link), type: 'symlink', link });
}
if (stat.isDirectory()) visit(path);
else if (stat.isFile()) records.push({ path: name, bytes: stat.size });
else if (stat.isFile()) records.push({ path: name, bytes: stat.size, type: 'file' });
else if (stat.isSymbolicLink()) { /* recorded above */ }
else throw new TorchError(`Unsupported candidate artifact entry: ${name}`, {
code: 'CANDIDATE_ENTRY_REFUSED', details: { path: name },
});
@@ -83,11 +95,43 @@ function inventory(root, { excludeValidation = false } = {}) {
function treeHash(root, records) {
const digest = createHash('sha256');
for (const record of records) {
digest.update(record.path).update('\0').update(readFileSync(join(root, record.path))).update('\0');
digest.update(record.path).update('\0').update(record.type).update('\0');
digest.update(record.type === 'symlink' ? record.link : readFileSync(join(root, record.path))).update('\0');
}
return digest.digest('hex');
}
function gitSourceDescriptor(root) {
let topLevel;
try {
topLevel = execFileSync('git', ['-C', root, 'rev-parse', '--show-toplevel'], {
encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'],
}).trim();
} catch { return null; }
if (resolve(topLevel) !== resolve(root)) return null;
const dirty = execFileSync('git', ['-C', root, 'status', '--porcelain=v1', '--untracked-files=no'], {
encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'],
}).trim();
if (dirty) throw new TorchError('Candidate source has modified tracked files', {
code: 'CANDIDATE_SOURCE_DIRTY', details: dirty.split('\n'),
});
const commit = execFileSync('git', ['-C', root, 'rev-parse', 'HEAD'], {
encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'],
}).trim();
const tracked = new Set(execFileSync('git', ['-C', root, 'ls-files', '-z'], {
encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'],
}).split('\0').filter(Boolean));
const include = (name, entry) => name === 'node_modules'
|| name.startsWith('node_modules/')
|| tracked.has(name)
|| (entry.isDirectory() && [...tracked].some((path) => path.startsWith(`${name}/`)));
return { mode: 'git-commit', commit, include };
}
function sourceDescriptor(root) {
return gitSourceDescriptor(root) ?? { mode: 'artifact', commit: null, include: () => true };
}
function validateReleaseMetadata(root, requestedVersion) {
const packagePath = join(root, 'package.json');
const releasePath = join(root, 'torch-release.json');
@@ -152,7 +196,8 @@ export class VersionService {
});
}
const metadata = validateReleaseMetadata(sourceRoot, version);
const records = inventory(sourceRoot);
const descriptor = sourceDescriptor(sourceRoot);
const records = inventory(sourceRoot, { include: descriptor.include });
if (records.some((record) => record.path === '.torch-validation.json')) {
throw new TorchError('Candidate source contains TORCH-owned validation state', {
code: 'CANDIDATE_RESERVED_FILE', details: { path: '.torch-validation.json' },
@@ -163,6 +208,7 @@ export class VersionService {
action: 'candidate-install', source: sourceRoot, destination, version: metadata.version,
digest: treeHash(sourceRoot, records), files: records.length,
bytes: records.reduce((total, record) => total + record.bytes, 0),
sourceMode: descriptor.mode, sourceCommit: descriptor.commit,
compatibility: metadata.release.state,
conflicts: existsSync(destination) ? [{ code: 'VERSION_ALREADY_INSTALLED', version: metadata.version }] : [],
mutationPerformed: false,
@@ -184,9 +230,23 @@ export class VersionService {
mkdirSync(this.versionsRoot, { recursive: true });
const staging = join(this.versionsRoot, `.candidate-${plan.version}-${randomUUID()}`);
try {
const descriptor = sourceDescriptor(plan.source);
if (descriptor.mode !== plan.sourceMode || descriptor.commit !== plan.sourceCommit) {
throw new TorchError('Candidate source identity changed after planning', {
code: 'CANDIDATE_SOURCE_CHANGED',
details: {
planned: { mode: plan.sourceMode, commit: plan.sourceCommit },
actual: { mode: descriptor.mode, commit: descriptor.commit },
},
});
}
cpSync(plan.source, staging, {
recursive: true, errorOnExist: true, force: false,
filter: (path) => basename(path) !== '.git',
recursive: true, errorOnExist: true, force: false, verbatimSymlinks: true,
filter: (path) => {
if (resolve(path) === resolve(plan.source)) return true;
const name = artifactPath(plan.source, path);
return basename(path) !== '.git' && descriptor.include(name, lstatSync(path));
},
});
validateReleaseMetadata(staging, plan.version);
const copiedRecords = inventory(staging);
@@ -206,6 +266,7 @@ export class VersionService {
atomicJson(join(staging, '.torch-validation.json'), {
schema: 'torch.dev/candidate-validation/v1alpha1', version: plan.version,
digest: plan.digest, validatedAt: this.now().toISOString(),
source: { mode: plan.sourceMode, commit: plan.sourceCommit },
scenarios: CANDIDATE_ACCEPTANCE_SCENARIOS, evidence: acceptance.evidence ?? [], passed: true,
});
renameSync(staging, plan.destination);
+33 -1
View File
@@ -1,5 +1,8 @@
import assert from 'node:assert/strict';
import { existsSync, mkdtempSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { execFileSync } from 'node:child_process';
import {
existsSync, mkdtempSync, mkdirSync, readFileSync, symlinkSync, writeFileSync,
} from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import test from 'node:test';
@@ -60,6 +63,35 @@ test('SCN-candidate-isolation: staging requires complete acceptance and never ch
() => service.activate('1.0.0'),
(error) => error.code === 'CANDIDATE_VALIDATION_STALE',
);
const checkout = candidate(root, '1.1.0', 'bounded');
mkdirSync(join(checkout, 'node_modules', 'fixture-dependency'), { recursive: true });
mkdirSync(join(checkout, 'node_modules', '.bin'), { recursive: true });
writeFileSync(join(checkout, 'node_modules', 'fixture-dependency', 'index.js'), 'export const dependency = true;\n');
symlinkSync('../fixture-dependency/index.js', join(checkout, 'node_modules', '.bin', 'fixture-dependency'));
writeFileSync(join(checkout, '.gitignore'), 'node_modules/\nlocal-secret.env\n');
execFileSync('git', ['init', '-b', 'main', checkout]);
execFileSync('git', ['-C', checkout, 'config', 'user.email', '[email protected]']);
execFileSync('git', ['-C', checkout, 'config', 'user.name', 'TORCH Test']);
execFileSync('git', ['-C', checkout, 'add', '.']);
execFileSync('git', ['-C', checkout, 'commit', '-m', 'candidate source']);
writeFileSync(join(checkout, 'local-secret.env'), 'DO_NOT_COPY=true\n');
const boundedPlan = service.planCandidate({ source: checkout });
assert.equal(boundedPlan.sourceMode, 'git-commit');
assert.match(boundedPlan.sourceCommit, /^[0-9a-f]{40}$/);
const bounded = service.installCandidate({ source: checkout, validate: accepted });
assert.equal(existsSync(join(bounded.destination, 'local-secret.env')), false);
assert.equal(existsSync(join(bounded.destination, 'node_modules', '.bin', 'fixture-dependency')), true);
assert.deepEqual(JSON.parse(readFileSync(bounded.validation, 'utf8')).source, {
mode: 'git-commit', commit: boundedPlan.sourceCommit,
});
writeFileSync(join(checkout, 'bin', 'torch.mjs'), 'export const marker = "dirty";\n');
assert.throws(
() => service.planCandidate({ source: checkout }),
(error) => error.code === 'CANDIDATE_SOURCE_DIRTY',
);
});
test('SCN-atomic-upgrade-rollback: a crash after pointer swap reconciles and rollback restores the exact prior version', () => {