108 lines
7.5 KiB
JavaScript
108 lines
7.5 KiB
JavaScript
'use strict';
|
|
|
|
const assert = require('node:assert/strict');
|
|
const crypto = require('node:crypto');
|
|
const fs = require('node:fs');
|
|
const os = require('node:os');
|
|
const path = require('node:path');
|
|
const test = require('node:test');
|
|
|
|
const REMOTE_DIR = '/data/ps5_autoloader';
|
|
const digest = value => crypto.createHash('sha256').update(value).digest('hex');
|
|
|
|
function elf(marker, size = 64) {
|
|
const value = Buffer.alloc(size, marker);
|
|
Buffer.from('7f454c46020101', 'hex').copy(value, 0);
|
|
value.writeUInt16LE(0x3e, 18);
|
|
return value;
|
|
}
|
|
|
|
function contractFor({ currentLoader, currentReader, safeLoader, safeReader, manifest }) {
|
|
return {
|
|
schema: 'test.stage.v1', identity: 'safe-test',
|
|
current: {
|
|
manifestSha256: digest(manifest), loaderName: 'old-loader.elf', loaderSha256: digest(currentLoader),
|
|
readerName: 'old-reader.elf', readerSha256: digest(currentReader),
|
|
},
|
|
safe: {
|
|
loaderLocalName: 'safe-loader.elf', loaderRemoteName: 'new-loader.elf', loaderSize: safeLoader.length, loaderSha256: digest(safeLoader),
|
|
readerLocalName: 'safe-reader.elf', readerRemoteName: 'new-reader.elf', readerSize: safeReader.length, readerSha256: digest(safeReader),
|
|
},
|
|
currentManifest: manifest.toString('utf8').trimEnd().split('\n'),
|
|
};
|
|
}
|
|
|
|
class FakeFtp {
|
|
constructor(files, { corruptPostManifest = false } = {}) {
|
|
this.files = new Map(Object.entries(files).map(([name, bytes]) => [`${REMOTE_DIR}/${name}`, Buffer.from(bytes)]));
|
|
this.writes = 0;
|
|
this.corruptPostManifest = corruptPostManifest;
|
|
}
|
|
async list() { return [...this.files.entries()].map(([name, bytes]) => ({ name: path.posix.basename(name), size: bytes.length, isDir: false })); }
|
|
async download(source, target) {
|
|
let bytes = this.files.get(source);
|
|
if (!bytes) throw Object.assign(new Error('missing'), { code: 550 });
|
|
if (this.corruptPostManifest && source.endsWith('/autoload.txt') && this.writes >= 6) bytes = Buffer.from('corrupt\n');
|
|
fs.mkdirSync(path.dirname(target), { recursive: true }); fs.writeFileSync(target, bytes);
|
|
}
|
|
async upload(source, target) { this.writes += 1; this.files.set(target, fs.readFileSync(source)); }
|
|
async rename(source, target) { this.writes += 1; const bytes = this.files.get(source); if (!bytes) throw new Error('missing rename source'); this.files.delete(source); this.files.set(target, bytes); }
|
|
async delete(target) { this.writes += 1; if (!this.files.delete(target)) throw new Error('missing delete target'); }
|
|
}
|
|
|
|
async function fixture(t) {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'chimera-a6-stage-'));
|
|
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
|
const local = path.join(root, 'local'); const temp = path.join(root, 'temp'); const backup = path.join(root, 'backup');
|
|
fs.mkdirSync(local, { recursive: true });
|
|
const currentLoader = elf(1); const currentReader = elf(2); const safeLoader = elf(3, 80); const safeReader = elf(4, 72);
|
|
const manifest = Buffer.from('manager.elf\nold-loader.elf\n!30000\nold-reader.elf\nservice.elf\n');
|
|
fs.writeFileSync(path.join(local, 'safe-loader.elf'), safeLoader); fs.writeFileSync(path.join(local, 'safe-reader.elf'), safeReader);
|
|
const contract = contractFor({ currentLoader, currentReader, safeLoader, safeReader, manifest });
|
|
const files = { 'autoload.txt': manifest, 'old-loader.elf': currentLoader, 'old-reader.elf': currentReader, 'manager.elf': elf(5), 'service.elf': elf(6) };
|
|
return { root, local, temp, backup, currentLoader, currentReader, safeLoader, safeReader, manifest, contract, files };
|
|
}
|
|
|
|
test('A6 safe runtime plan binds current and proposed hashes without remote writes', async t => {
|
|
const f = await fixture(t); const ftp = new FakeFtp(f.files);
|
|
const { createPlan, CONFIRMATION } = await import('../../scripts/autonomous/p15-a6-stage-safe-runtime.mjs');
|
|
const plan = await createPlan({ ftp, localRoot: f.local, temporaryRoot: f.temp, contract: f.contract });
|
|
assert.equal(plan.available, true); assert.equal(plan.confirmation, CONFIRMATION); assert.equal(ftp.writes, 0);
|
|
assert.equal(plan.evidence.currentManifestSha256, digest(f.manifest));
|
|
assert.match(plan.proposed.manifest, /new-loader\.elf/); assert.doesNotMatch(plan.proposed.manifest, /old-loader\.elf/);
|
|
});
|
|
|
|
test('A6 safe runtime apply snapshots all files, atomically replaces only the manifest and preserves old artifacts', async t => {
|
|
const f = await fixture(t); const ftp = new FakeFtp(f.files);
|
|
const { createPlan, applyPlan, CONFIRMATION } = await import('../../scripts/autonomous/p15-a6-stage-safe-runtime.mjs');
|
|
const plan = await createPlan({ ftp, localRoot: f.local, temporaryRoot: f.temp, contract: f.contract });
|
|
const receipt = await applyPlan({ ftp, localRoot: f.local, temporaryRoot: f.temp, backupRoot: f.backup, contract: f.contract, confirmation: CONFIRMATION, planDigest: plan.planDigest, now: () => 1234567890123, nonce: () => 'abc123' });
|
|
assert.equal(receipt.status, 'staged_verified'); assert.equal(receipt.automaticRebootPerformed, false); assert.equal(receipt.launchPerformed, false);
|
|
assert.deepEqual(ftp.files.get(`${REMOTE_DIR}/old-loader.elf`), f.currentLoader);
|
|
assert.deepEqual(ftp.files.get(`${REMOTE_DIR}/old-reader.elf`), f.currentReader);
|
|
assert.deepEqual(ftp.files.get(`${REMOTE_DIR}/new-loader.elf`), f.safeLoader);
|
|
assert.deepEqual(ftp.files.get(`${REMOTE_DIR}/new-reader.elf`), f.safeReader);
|
|
assert.match(ftp.files.get(`${REMOTE_DIR}/autoload.txt`).toString('utf8'), /new-loader\.elf/);
|
|
const snapshot = path.join(f.backup, receipt.snapshotId);
|
|
assert.equal(fs.existsSync(path.join(snapshot, 'meta.json')), true); assert.equal(fs.existsSync(path.join(snapshot, 'receipt.json')), true);
|
|
assert.equal(JSON.parse(fs.readFileSync(path.join(snapshot, 'meta.json'), 'utf8')).files.length, 5);
|
|
});
|
|
|
|
test('A6 safe runtime refuses missing confirmation or stale plan before remote mutation', async t => {
|
|
const f = await fixture(t); const ftp = new FakeFtp(f.files);
|
|
const { createPlan, applyPlan, CONFIRMATION } = await import('../../scripts/autonomous/p15-a6-stage-safe-runtime.mjs');
|
|
const plan = await createPlan({ ftp, localRoot: f.local, temporaryRoot: f.temp, contract: f.contract });
|
|
await assert.rejects(applyPlan({ ftp, localRoot: f.local, temporaryRoot: f.temp, backupRoot: f.backup, contract: f.contract, confirmation: 'wrong', planDigest: plan.planDigest }), { code: 'P15_A6_CONFIRMATION' });
|
|
await assert.rejects(applyPlan({ ftp, localRoot: f.local, temporaryRoot: f.temp, backupRoot: f.backup, contract: f.contract, confirmation: CONFIRMATION, planDigest: '0'.repeat(64) }), { code: 'P15_A6_PLAN_DRIFT' });
|
|
assert.equal(ftp.writes, 0);
|
|
});
|
|
|
|
test('A6 safe runtime post-read failure restores the original manifest and removes published safe files', async t => {
|
|
const f = await fixture(t); const ftp = new FakeFtp(f.files, { corruptPostManifest: true });
|
|
const { createPlan, applyPlan, CONFIRMATION } = await import('../../scripts/autonomous/p15-a6-stage-safe-runtime.mjs');
|
|
const plan = await createPlan({ ftp, localRoot: f.local, temporaryRoot: f.temp, contract: f.contract });
|
|
await assert.rejects(applyPlan({ ftp, localRoot: f.local, temporaryRoot: f.temp, backupRoot: f.backup, contract: f.contract, confirmation: CONFIRMATION, planDigest: plan.planDigest, now: () => 1234567890123, nonce: () => 'rollback' }), error => error.code === 'P15_A6_POST_VERIFY' && error.details.rolledBack === true);
|
|
assert.deepEqual(ftp.files.get(`${REMOTE_DIR}/autoload.txt`), f.manifest);
|
|
assert.equal(ftp.files.has(`${REMOTE_DIR}/new-loader.elf`), false); assert.equal(ftp.files.has(`${REMOTE_DIR}/new-reader.elf`), false);
|
|
});
|