61 lines
6.0 KiB
JavaScript
61 lines
6.0 KiB
JavaScript
'use strict';
|
|
|
|
const assert = require('node:assert/strict');
|
|
const crypto = require('crypto');
|
|
const fs = require('fs');
|
|
const os = require('os');
|
|
const path = require('path');
|
|
const test = require('node:test');
|
|
const { createPatchBundleService } = require('../../src/services/patch-bundle-service');
|
|
|
|
const digest = value => crypto.createHash('sha256').update(value).digest('hex');
|
|
function fixture() {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'chimera-patch-')); const source = path.join(root, 'source'); const bundle = path.join(root, 'bundle'); const staging = path.join(root, 'staging');
|
|
fs.mkdirSync(path.join(source, 'game'), { recursive: true }); fs.mkdirSync(path.join(bundle, 'files'), { recursive: true });
|
|
fs.writeFileSync(path.join(source, 'game', 'eboot.bin'), 'ORIGINAL'); fs.writeFileSync(path.join(source, 'game', 'other.bin'), 'SECOND'); fs.writeFileSync(path.join(bundle, 'files', 'eboot.bin'), 'PATCHED'); fs.writeFileSync(path.join(bundle, 'files', 'other.bin'), 'CHANGED');
|
|
const operation = (overrides = {}) => ({ id: 'replace-eboot', type: 'replace-file', sourcePath: 'game/eboot.bin', sourceSha256: digest('ORIGINAL'), replacementPath: 'files/eboot.bin', replacementSha256: digest('PATCHED'), outputSha256: digest('PATCHED'), ...overrides });
|
|
const manifest = overrides => ({ schemaVersion: 1, bundleId: 'safe-backport', version: '1.2.0', revision: 'rev-7', applicability: { titleId: 'CUSA12345', version: { min: '1.00', max: '1.99' }, firmware: { min: '9.00', max: '9.60' }, loaders: ['etahen'], storage: ['usb'], formats: ['folder'] }, operations: [operation()], ...overrides });
|
|
const service = createPatchBundleService({ sourceRoots: [source], bundleRoots: [bundle], stagingRoot: staging, now: () => 1_721_600_000_000 });
|
|
const context = { titleId: 'CUSA12345', version: '1.50', firmware: '9.60', loader: 'etaHEN', storage: 'usb', format: 'folder' };
|
|
return { root, source, bundle, staging, service, operation, manifest, context, close: () => fs.rmSync(root, { recursive: true, force: true }) };
|
|
}
|
|
|
|
test('matching source applies verified replacement only to staging and records provenance', async t => {
|
|
const f = fixture(); t.after(f.close);
|
|
const result = await f.service.apply({ manifest: f.manifest(), sourceRoot: f.source, bundleRoot: f.bundle, context: f.context, applicationId: 'apply-ok' });
|
|
assert.equal(fs.readFileSync(path.join(f.source, 'game', 'eboot.bin'), 'utf8'), 'ORIGINAL');
|
|
assert.equal(fs.readFileSync(path.join(result.outputRoot, 'game', 'eboot.bin'), 'utf8'), 'PATCHED');
|
|
assert.equal(result.outputsVerified, true); assert.equal(result.originalUntouched, true); assert.match(result.manifestSha256, /^[a-f0-9]{64}$/);
|
|
const stored = JSON.parse(fs.readFileSync(path.join(result.outputRoot, '.chimera', 'patch-manifest.json'), 'utf8'));
|
|
assert.equal(stored.applicability.titleId, 'CUSA12345'); assert.equal(stored.operations[0].sourceSha256, digest('ORIGINAL')); assert.equal(stored.operations[0].outputSha256, digest('PATCHED'));
|
|
});
|
|
|
|
test('source mismatch and partial failure publish no output and never touch originals', async t => {
|
|
const f = fixture(); t.after(f.close);
|
|
await assert.rejects(f.service.apply({ manifest: f.manifest({ operations: [f.operation({ sourceSha256: digest('WRONG') })] }), sourceRoot: f.source, bundleRoot: f.bundle, context: f.context, applicationId: 'bad-source' }), error => error.code === 'PATCH_SOURCE_MISMATCH');
|
|
const second = f.operation({ id: 'replace-other', sourcePath: 'game/other.bin', sourceSha256: digest('WRONG'), replacementPath: 'files/other.bin', replacementSha256: digest('CHANGED'), outputSha256: digest('CHANGED') });
|
|
await assert.rejects(f.service.apply({ manifest: f.manifest({ operations: [f.operation(), second] }), sourceRoot: f.source, bundleRoot: f.bundle, context: f.context, applicationId: 'partial' }), error => error.code === 'PATCH_SOURCE_MISMATCH');
|
|
assert.equal(fs.existsSync(path.join(f.staging, 'bad-source')), false); assert.equal(fs.existsSync(path.join(f.staging, 'partial')), false);
|
|
assert.equal(fs.readFileSync(path.join(f.source, 'game', 'eboot.bin'), 'utf8'), 'ORIGINAL'); assert.equal(fs.readFileSync(path.join(f.source, 'game', 'other.bin'), 'utf8'), 'SECOND');
|
|
assert.equal(JSON.parse(fs.readFileSync(path.join(f.staging, 'bad-source.failed.json'), 'utf8')).code, 'PATCH_SOURCE_MISMATCH');
|
|
});
|
|
|
|
test('rollback preflights hashes and atomically restores verified staging outputs', async t => {
|
|
const f = fixture(); t.after(f.close);
|
|
const applied = await f.service.apply({ manifest: f.manifest(), sourceRoot: f.source, bundleRoot: f.bundle, context: f.context, applicationId: 'rollback-ok' });
|
|
const result = await f.service.rollback('rollback-ok');
|
|
assert.equal(result.state, 'rolled-back'); assert.equal(result.outputsVerified, true);
|
|
assert.equal(fs.readFileSync(path.join(applied.outputRoot, 'game', 'eboot.bin'), 'utf8'), 'ORIGINAL');
|
|
assert.equal(JSON.parse(fs.readFileSync(path.join(applied.outputRoot, '.chimera', 'patch-rollback-result.json'), 'utf8')).outputsVerified, true);
|
|
});
|
|
|
|
test('path traversal, symlinks, inapplicable context and executable fields fail closed', async t => {
|
|
const f = fixture(); t.after(f.close);
|
|
assert.throws(() => f.service.validateManifest(f.manifest({ operations: [f.operation({ sourcePath: '../outside.bin' })] })), error => error.code === 'PATCH_PATH');
|
|
assert.throws(() => f.service.validateManifest({ ...f.manifest(), script: 'rm -rf /' }), error => error.code === 'PATCH_EXECUTABLE_FIELD');
|
|
await assert.rejects(f.service.apply({ manifest: f.manifest(), sourceRoot: f.source, bundleRoot: f.bundle, context: { ...f.context, firmware: '10.50' }, applicationId: 'wrong-context' }), error => error.code === 'PATCH_APPLICABILITY');
|
|
const outside = path.join(f.root, 'outside.bin'); fs.writeFileSync(outside, 'OUTSIDE');
|
|
try { fs.symlinkSync(outside, path.join(f.source, 'game', 'link.bin')); } catch (_) { return; }
|
|
await assert.rejects(f.service.apply({ manifest: f.manifest(), sourceRoot: f.source, bundleRoot: f.bundle, context: f.context, applicationId: 'symlink' }), error => error.code === 'PATCH_PATH');
|
|
});
|