Initial curated chimera source from a8ae5b1db07fb49926c94585e2718e5130330974
This commit is contained in:
commit
c737a37f71
531 files changed
+119270
No files matched your search
@@ -0,0 +1,340 @@
|
||||
'use strict';
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('fs');
|
||||
const http = require('http');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const { SettingsStore } = require('../../src/persistence/settings-store');
|
||||
const { SecretsStore } = require('../../src/security/secrets-store');
|
||||
const { CommandRegistry } = require('../../src/core/command-registry');
|
||||
const { commandErrorCode, createAppServer } = require('../../src/server/app-server');
|
||||
|
||||
test('command failures expose stable machine-readable availability codes', () => {
|
||||
assert.equal(commandErrorCode(new Error('PS5-profiel ontbreekt.')), 'NOT_CONFIGURED');
|
||||
assert.equal(commandErrorCode(new Error('Configureer eerst het IP-adres van je PS5.')), 'NOT_CONFIGURED');
|
||||
assert.equal(commandErrorCode(Object.assign(new Error('connect failed'), { code: 'ECONNREFUSED' })), 'UNAVAILABLE');
|
||||
assert.equal(commandErrorCode(new Error('Upstream HTTP 429')), 'UPSTREAM_UNAVAILABLE');
|
||||
assert.equal(commandErrorCode(new Error('Ongeldige invoer.')), 'COMMAND_FAILED');
|
||||
});
|
||||
|
||||
test('first-run, login session and CSRF protection work end to end', async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'porkfolio-app-'));
|
||||
const settings = new SettingsStore(path.join(root, 'config', 'settings.json'));
|
||||
const secrets = new SecretsStore({ filePath: path.join(root, 'secrets', 'secrets.json'), keyFile: path.join(root, 'secrets', 'key') });
|
||||
const registry = new CommandRegistry({ platform: 'server' });
|
||||
let operationsStarted = 0;
|
||||
registry.maintenance = { startOperations: () => { operationsStarted += 1; } };
|
||||
registry.register({ name: 'test:read', classification: 'read-only', handler: () => ({ value: 42 }) });
|
||||
registry.register({ name: 'test:write', classification: 'mutating', handler: input => input });
|
||||
registry.register({ name: 'test:large', classification: 'mutating', maxBodyBytes: 512 * 1024, handler: input => ({ length: input.payload.length }) });
|
||||
const logger = { info() {}, warn() {}, error() {}, audit() {} };
|
||||
process.env.PORKFOLIO_ALLOWED_HOSTS = '127.0.0.1';
|
||||
const app = createAppServer({
|
||||
appRoot: path.resolve(__dirname, '../..'), paths: { root, cache: path.join(root, 'cache') }, settings, secrets, registry, logger,
|
||||
ftp: { disconnect: async () => {} }, capabilities: { features: {} }, version: '1.0.9',
|
||||
});
|
||||
const address = await app.listen(0, '127.0.0.1');
|
||||
const base = `http://127.0.0.1:${address.port}`;
|
||||
const headers = { Host: `127.0.0.1:${address.port}`, Origin: base, 'Content-Type': 'application/json' };
|
||||
try {
|
||||
const live = await fetch(`${base}/health/live`, { headers });
|
||||
assert.equal(live.status, 200);
|
||||
const liveBody = await live.json();
|
||||
assert.equal(liveBody.status, 'live');
|
||||
assert.equal(typeof liveBody.build.id, 'string');
|
||||
assert.equal(typeof liveBody.build.revision, 'string');
|
||||
const initialSetupStatus = await fetch(`${base}/api/setup/status`, { headers }).then(response => response.json());
|
||||
assert.equal(initialSetupStatus.defaultLanguage, 'en');
|
||||
const setup = await fetch(`${base}/api/setup`, { method: 'POST', headers, body: JSON.stringify({ username: 'beheerder', password: ['veilig', 'wachtwoord', '123'].join('-'), allowedRoots: [root], safetyConfirmed: true, language: 'nl' }) });
|
||||
assert.equal(setup.status, 201);
|
||||
assert.equal(operationsStarted, 1);
|
||||
const cookie = setup.headers.get('set-cookie').split(';')[0];
|
||||
const auth = await fetch(`${base}/api/auth/status`, { headers: { ...headers, Cookie: cookie } });
|
||||
const authBody = await auth.json();
|
||||
assert.equal(authBody.authenticated, true);
|
||||
const shell = await fetch(`${base}/`, { headers: { ...headers, Cookie: cookie } });
|
||||
assert.equal(shell.status, 200);
|
||||
assert.match(shell.headers.get('content-security-policy'), /script-src 'self'(?:;|$)/);
|
||||
assert.doesNotMatch(shell.headers.get('content-security-policy'), /script-src[^;]*unsafe-inline/);
|
||||
const sessionList = await fetch(`${base}/api/auth/sessions`, { headers: { ...headers, Cookie: cookie } }).then(response => response.json());
|
||||
assert.equal(sessionList.sessions.length, 1);
|
||||
assert.equal(sessionList.sessions[0].current, true);
|
||||
assert.equal(sessionList.sessions[0].username, 'beheerder');
|
||||
const commandCatalog = await fetch(`${base}/api/commands/catalog`, { headers: { ...headers, Cookie: cookie } }).then(response => response.json());
|
||||
assert.equal(commandCatalog.schema, 1);
|
||||
assert.deepEqual(commandCatalog.commands.map(command => command.name), ['test:large', 'test:read', 'test:write']);
|
||||
assert.equal(commandCatalog.commands.find(command => command.name === 'test:read').classification, 'read-only');
|
||||
assert.equal(commandCatalog.commands.find(command => command.name === 'test:large').maxBodyBytes, 512 * 1024);
|
||||
const rendererScript = await fetch(`${base}/modules/state.js`, { headers: { ...headers, Cookie: cookie } });
|
||||
assert.equal(rendererScript.status, 200);
|
||||
assert.equal(rendererScript.headers.get('cache-control'), 'private, no-cache');
|
||||
assert.ok(rendererScript.headers.get('etag'));
|
||||
const notModified = await fetch(`${base}/modules/state.js`, { headers: { ...headers, Cookie: cookie, 'If-None-Match': rendererScript.headers.get('etag') } });
|
||||
assert.equal(notModified.status, 304);
|
||||
const denied = await fetch(`${base}/api/commands/test%3Awrite`, { method: 'POST', headers: { ...headers, Cookie: cookie }, body: JSON.stringify({ args: [{ ok: true }] }) });
|
||||
assert.equal(denied.status, 403);
|
||||
assert.equal(denied.headers.get('cache-control'), 'no-store');
|
||||
const readOnly = await fetch(`${base}/api/commands/test%3Aread`, { method: 'POST', headers: { ...headers, Cookie: cookie }, body: JSON.stringify({ args: [] }) });
|
||||
assert.equal(readOnly.status, 200);
|
||||
assert.equal(readOnly.headers.get('cache-control'), 'no-store');
|
||||
const allowed = await fetch(`${base}/api/commands/test%3Awrite`, { method: 'POST', headers: { ...headers, Cookie: cookie, 'X-CSRF-Token': authBody.csrfToken }, body: JSON.stringify({ args: [{ ok: true }] }) });
|
||||
assert.equal(allowed.status, 200);
|
||||
assert.equal(allowed.headers.get('cache-control'), 'no-store');
|
||||
const payload = 'x'.repeat(300 * 1024);
|
||||
const defaultLimited = await fetch(`${base}/api/commands/test%3Awrite`, { method: 'POST', headers: { ...headers, Cookie: cookie, 'X-CSRF-Token': authBody.csrfToken }, body: JSON.stringify({ args: [{ payload }] }) });
|
||||
assert.equal(defaultLimited.status, 413);
|
||||
const explicitlyAllowed = await fetch(`${base}/api/commands/test%3Alarge`, { method: 'POST', headers: { ...headers, Cookie: cookie, 'X-CSRF-Token': authBody.csrfToken }, body: JSON.stringify({ args: [{ payload }] }) });
|
||||
assert.equal(explicitlyAllowed.status, 200);
|
||||
assert.equal((await explicitlyAllowed.json()).result.length, payload.length);
|
||||
} finally { await app.close(); }
|
||||
});
|
||||
|
||||
test('metrics require a separate constant-time Bearer token', async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'chimera-metrics-'));
|
||||
const settings = new SettingsStore(path.join(root, 'config', 'settings.json'));
|
||||
const secrets = new SecretsStore({ filePath: path.join(root, 'secrets', 'secrets.json'), keyFile: path.join(root, 'secrets', 'key') });
|
||||
secrets.set('monitoring.token', 'a'.repeat(43));
|
||||
const registry = new CommandRegistry({ platform: 'server' });
|
||||
registry.register({ name: 'monitoring:prometheus', classification: 'read-only', permission: 'public', handler: () => 'chimera_up 1\n' });
|
||||
const logger = { info() {}, warn() {}, error() {}, audit() {} };
|
||||
const previousAllowedHosts = process.env.CHIMERA_ALLOWED_HOSTS;
|
||||
process.env.CHIMERA_ALLOWED_HOSTS = '127.0.0.1';
|
||||
const app = createAppServer({
|
||||
appRoot: path.resolve(__dirname, '../..'), paths: { root, cache: path.join(root, 'cache') }, settings, secrets, registry, logger,
|
||||
ftp: { disconnect: async () => {} }, capabilities: { features: {} }, version: '1.0.9',
|
||||
});
|
||||
const address = await app.listen(0, '127.0.0.1');
|
||||
const base = `http://127.0.0.1:${address.port}`;
|
||||
try {
|
||||
const denied = await fetch(`${base}/metrics`, { headers: { Host: `127.0.0.1:${address.port}`, Authorization: 'Bearer wrong' } });
|
||||
assert.equal(denied.status, 401);
|
||||
const allowed = await fetch(`${base}/metrics`, { headers: { Host: `127.0.0.1:${address.port}`, Authorization: `Bearer ${'a'.repeat(43)}` } });
|
||||
assert.equal(allowed.status, 200);
|
||||
assert.match(allowed.headers.get('content-type'), /version=0\.0\.4/);
|
||||
assert.equal(await allowed.text(), 'chimera_up 1\n');
|
||||
} finally {
|
||||
await app.close();
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
if (previousAllowedHosts === undefined) delete process.env.CHIMERA_ALLOWED_HOSTS;
|
||||
else process.env.CHIMERA_ALLOWED_HOSTS = previousAllowedHosts;
|
||||
}
|
||||
});
|
||||
|
||||
test('authenticated Payload Manager proxy rewrites and isolates the PS5 web UI', { concurrency: false }, async () => {
|
||||
const payloadServer = http.createServer((req, res) => {
|
||||
if (req.url === '/') {
|
||||
res.writeHead(200, { 'Content-Type': 'text/html' });
|
||||
res.end('<!doctype html><html><link rel="icon" href="/favicon.svg"><script>fetch(`/list_payloads`)</script></html>');
|
||||
return;
|
||||
}
|
||||
if (req.url === '/list_payloads') {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ payloads: ['/data/payloads/etaHEN.bin'] }));
|
||||
return;
|
||||
}
|
||||
res.writeHead(404).end();
|
||||
});
|
||||
await new Promise(resolve => payloadServer.listen(0, '127.0.0.1', resolve));
|
||||
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'chimera-payload-proxy-'));
|
||||
const settings = new SettingsStore(path.join(root, 'config', 'settings.json'));
|
||||
settings.set('ftp.host', '127.0.0.1');
|
||||
settings.set('payloadManager.port', payloadServer.address().port);
|
||||
const secrets = new SecretsStore({ filePath: path.join(root, 'secrets', 'secrets.json'), keyFile: path.join(root, 'secrets', 'key') });
|
||||
const registry = new CommandRegistry({ platform: 'server' });
|
||||
const logger = { info() {}, warn() {}, error() {}, audit() {} };
|
||||
const previousAllowedHosts = process.env.CHIMERA_ALLOWED_HOSTS;
|
||||
process.env.CHIMERA_ALLOWED_HOSTS = '127.0.0.1';
|
||||
const app = createAppServer({
|
||||
appRoot: path.resolve(__dirname, '../..'), paths: { root, cache: path.join(root, 'cache') }, settings, secrets, registry, logger,
|
||||
ftp: { disconnect: async () => {} }, capabilities: { features: {} }, version: '1.0.9',
|
||||
});
|
||||
const address = await app.listen(0, '127.0.0.1');
|
||||
const base = `http://127.0.0.1:${address.port}`;
|
||||
const headers = { Host: `127.0.0.1:${address.port}`, Origin: base, 'Content-Type': 'application/json' };
|
||||
try {
|
||||
const setup = await fetch(`${base}/api/setup`, { method: 'POST', headers, body: JSON.stringify({ username: 'beheerder', password: ['veilig', 'wachtwoord', '123'].join('-'), allowedRoots: [root], safetyConfirmed: true }) });
|
||||
assert.equal(setup.status, 201);
|
||||
const cookie = setup.headers.get('set-cookie').split(';')[0];
|
||||
const entry = await fetch(`${base}/ps5-proxy/payload-manager/`, { redirect: 'manual', headers: { ...headers, Cookie: cookie } });
|
||||
assert.equal(entry.status, 302);
|
||||
const tokenPath = entry.headers.get('location');
|
||||
assert.match(tokenPath, /^\/ps5-proxy\/payload-manager\/t\/[A-Za-z0-9_-]{32,128}\/$/);
|
||||
|
||||
const page = await fetch(`${base}${tokenPath}`, { headers: { Host: `127.0.0.1:${address.port}` } });
|
||||
assert.equal(page.status, 200);
|
||||
assert.equal(page.headers.get('x-frame-options'), 'SAMEORIGIN');
|
||||
assert.match(page.headers.get('content-security-policy'), /frame-ancestors 'self'/);
|
||||
const html = await page.text();
|
||||
assert.match(html, new RegExp(`${tokenPath.replace(/\/$/, '')}/list_payloads`));
|
||||
|
||||
const listing = await fetch(`${base}${tokenPath}list_payloads`, { headers: { Host: `127.0.0.1:${address.port}`, Origin: 'null' } });
|
||||
assert.equal(listing.status, 200);
|
||||
assert.equal(listing.headers.get('access-control-allow-origin'), 'null');
|
||||
assert.deepEqual(await listing.json(), { payloads: ['/data/payloads/etaHEN.bin'] });
|
||||
|
||||
const blocked = await fetch(`${base}${tokenPath}shutdown`, { headers: { Host: `127.0.0.1:${address.port}`, Origin: 'null' } });
|
||||
assert.equal(blocked.status, 404);
|
||||
} finally {
|
||||
await app.close();
|
||||
await new Promise(resolve => payloadServer.close(resolve));
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
if (previousAllowedHosts === undefined) delete process.env.CHIMERA_ALLOWED_HOSTS;
|
||||
else process.env.CHIMERA_ALLOWED_HOSTS = previousAllowedHosts;
|
||||
}
|
||||
});
|
||||
|
||||
test('public Payload Manager repository is CORS-readable without exposing an authenticated command bridge', async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'chimera-public-payloads-'));
|
||||
const settings = new SettingsStore(path.join(root, 'config', 'settings.json'));
|
||||
const secrets = new SecretsStore({ filePath: path.join(root, 'secrets', 'secrets.json'), keyFile: path.join(root, 'secrets', 'key') });
|
||||
const registry = new CommandRegistry({ platform: 'server' });
|
||||
registry.register({ name: 'ecosystem:payload-manager:repository', classification: 'read-only', permission: 'public', handler: () => ({ name: 'Chimera Verified Payloads', payloads: [{ name: 'ELF Loader', filename: 'elfldr.elf', url: 'https://example.test/elfldr.elf', checksum: 'a'.repeat(64) }] }) });
|
||||
const logger = { info() {}, warn() {}, error() {}, audit() {} };
|
||||
const previousAllowedHosts = process.env.CHIMERA_ALLOWED_HOSTS;
|
||||
process.env.CHIMERA_ALLOWED_HOSTS = '127.0.0.1';
|
||||
const app = createAppServer({
|
||||
appRoot: path.resolve(__dirname, '../..'), paths: { root, cache: path.join(root, 'cache') }, settings, secrets, registry, logger,
|
||||
ftp: { disconnect: async () => {} }, capabilities: { features: {} }, version: '1.0.9',
|
||||
});
|
||||
const address = await app.listen(0, '127.0.0.1');
|
||||
const base = `http://127.0.0.1:${address.port}`;
|
||||
try {
|
||||
const response = await fetch(`${base}/payloads.json`, { headers: { Host: `127.0.0.1:${address.port}`, Origin: 'http://ps5.local' } });
|
||||
assert.equal(response.status, 200);
|
||||
assert.equal(response.headers.get('access-control-allow-origin'), '*');
|
||||
assert.equal(response.headers.get('cross-origin-resource-policy'), 'cross-origin');
|
||||
assert.match(response.headers.get('cache-control'), /max-age=300/);
|
||||
const repository = await response.json();
|
||||
assert.equal(repository.payloads[0].checksum, 'a'.repeat(64));
|
||||
|
||||
const command = await fetch(`${base}/api/commands/ecosystem%3Apayload-manager%3Arepository`, { method: 'POST', headers: { Host: `127.0.0.1:${address.port}`, Origin: base, 'Content-Type': 'application/json' }, body: JSON.stringify({ args: [] }) });
|
||||
assert.equal(command.status, 401);
|
||||
} finally {
|
||||
await app.close();
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
if (previousAllowedHosts === undefined) delete process.env.CHIMERA_ALLOWED_HOSTS;
|
||||
else process.env.CHIMERA_ALLOWED_HOSTS = previousAllowedHosts;
|
||||
}
|
||||
});
|
||||
|
||||
test('compiled Chimera Beacon artifact is public, CORS-readable and supports byte ranges', async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'chimera-public-beacon-'));
|
||||
const appRoot = path.join(root, 'app');
|
||||
fs.mkdirSync(path.join(appRoot, 'build', 'payloads'), { recursive: true });
|
||||
const payload = Buffer.from([0x7f, 0x45, 0x4c, 0x46, 1, 2, 3, 4]);
|
||||
fs.writeFileSync(path.join(appRoot, 'build', 'payloads', 'chimera-beacon.elf'), payload);
|
||||
const settings = new SettingsStore(path.join(root, 'config', 'settings.json'));
|
||||
const secrets = new SecretsStore({ filePath: path.join(root, 'secrets', 'secrets.json'), keyFile: path.join(root, 'secrets', 'key') });
|
||||
const registry = new CommandRegistry({ platform: 'server' });
|
||||
const logger = { info() {}, warn() {}, error() {}, audit() {} };
|
||||
const previousAllowedHosts = process.env.CHIMERA_ALLOWED_HOSTS;
|
||||
process.env.CHIMERA_ALLOWED_HOSTS = '127.0.0.1';
|
||||
const app = createAppServer({ appRoot, paths: { root, cache: path.join(root, 'cache') }, settings, secrets, registry, logger, ftp: { disconnect: async () => {} }, capabilities: { features: {} }, version: '1.0.9' });
|
||||
const address = await app.listen(0, '127.0.0.1');
|
||||
try {
|
||||
const response = await fetch(`http://127.0.0.1:${address.port}/payloads/chimera-beacon.elf`, { headers: { Host: `127.0.0.1:${address.port}`, Range: 'bytes=0-3' } });
|
||||
assert.equal(response.status, 206);
|
||||
assert.equal(response.headers.get('access-control-allow-origin'), '*');
|
||||
assert.equal(response.headers.get('cross-origin-resource-policy'), 'cross-origin');
|
||||
assert.equal(response.headers.get('content-type'), 'application/octet-stream');
|
||||
assert.deepEqual(Buffer.from(await response.arrayBuffer()), payload.subarray(0, 4));
|
||||
} finally {
|
||||
await app.close();
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
if (previousAllowedHosts === undefined) delete process.env.CHIMERA_ALLOWED_HOSTS;
|
||||
else process.env.CHIMERA_ALLOWED_HOSTS = previousAllowedHosts;
|
||||
}
|
||||
});
|
||||
|
||||
test('trusted reverse proxy enforces HTTPS, redacts setup details and serves byte ranges', { concurrency: false }, async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'chimera-https-'));
|
||||
const settings = new SettingsStore(path.join(root, 'config', 'settings.json'));
|
||||
settings.set('admin.username', 'beheerder');
|
||||
settings.set('admin.passwordHash', 'configured');
|
||||
const secrets = new SecretsStore({ filePath: path.join(root, 'secrets', 'secrets.json'), keyFile: path.join(root, 'secrets', 'key') });
|
||||
const registry = new CommandRegistry({ platform: 'server' });
|
||||
const logger = { info() {}, warn() {}, error() {}, audit() {} };
|
||||
const previous = {
|
||||
allowedHosts: process.env.CHIMERA_ALLOWED_HOSTS,
|
||||
publicOrigin: process.env.CHIMERA_PUBLIC_ORIGIN,
|
||||
trustProxy: process.env.CHIMERA_TRUST_PROXY,
|
||||
cookieSecure: process.env.CHIMERA_COOKIE_SECURE,
|
||||
};
|
||||
process.env.CHIMERA_ALLOWED_HOSTS = '127.0.0.1,chimera';
|
||||
process.env.CHIMERA_PUBLIC_ORIGIN = 'https://chimera.test:18443';
|
||||
process.env.CHIMERA_TRUST_PROXY = '1';
|
||||
process.env.CHIMERA_COOKIE_SECURE = '1';
|
||||
const app = createAppServer({
|
||||
appRoot: path.resolve(__dirname, '../..'), paths: { root, cache: path.join(root, 'cache') }, settings, secrets, registry, logger,
|
||||
ftp: { disconnect: async () => {} }, capabilities: { features: {} }, version: '1.0.9',
|
||||
});
|
||||
const address = await app.listen(0, '127.0.0.1');
|
||||
const base = `http://127.0.0.1:${address.port}`;
|
||||
const proxied = { Host: 'chimera.test:18443', 'X-Forwarded-Proto': 'https', 'X-Forwarded-For': '192.168.1.171' };
|
||||
try {
|
||||
const direct = await fetch(`${base}/server-login.html`, { redirect: 'manual', headers: { Host: `127.0.0.1:${address.port}` } });
|
||||
assert.equal(direct.status, 308);
|
||||
assert.equal(direct.headers.get('location'), 'https://chimera.test:18443/server-login.html');
|
||||
|
||||
const login = await fetch(`${base}/server-login.html`, { headers: proxied });
|
||||
assert.equal(login.status, 200);
|
||||
assert.match(login.headers.get('strict-transport-security'), /max-age=31536000/);
|
||||
assert.equal(login.headers.get('x-frame-options'), 'DENY');
|
||||
|
||||
const status = await fetch(`${base}/api/setup/status`, { headers: proxied }).then(response => response.json());
|
||||
assert.deepEqual(status, { configured: true, defaultLanguage: 'en' });
|
||||
|
||||
const icon = await fetch(`${base}/build/icon.png`, { headers: { ...proxied, Range: 'bytes=0-9' } });
|
||||
assert.equal(icon.status, 206);
|
||||
assert.equal(icon.headers.get('content-length'), '10');
|
||||
assert.match(icon.headers.get('content-range'), /^bytes 0-9\//);
|
||||
assert.equal((await icon.arrayBuffer()).byteLength, 10);
|
||||
} finally {
|
||||
await app.close();
|
||||
for (const [key, value] of Object.entries({ CHIMERA_ALLOWED_HOSTS: previous.allowedHosts, CHIMERA_PUBLIC_ORIGIN: previous.publicOrigin, CHIMERA_TRUST_PROXY: previous.trustProxy, CHIMERA_COOKIE_SECURE: previous.cookieSecure })) {
|
||||
if (value === undefined) delete process.env[key]; else process.env[key] = value;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('Remote Play worker endpoint bypasses browser sessions only through its dedicated authenticator', { concurrency: false }, async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'chimera-remote-worker-http-'));
|
||||
const settings = new SettingsStore(path.join(root, 'config', 'settings.json'));
|
||||
const secrets = new SecretsStore({ filePath: path.join(root, 'secrets', 'secrets.json'), keyFile: path.join(root, 'secrets', 'key') });
|
||||
const registry = new CommandRegistry({ platform: 'server' });
|
||||
const calls = [];
|
||||
registry.remotePlay = {
|
||||
prefix: '/api/remote-play-worker/v1',
|
||||
handleWorkerRequest(pathname, body) {
|
||||
calls.push({ pathname, body });
|
||||
if (body.token !== 'signed-fixture') throw Object.assign(new Error('Worker-HMAC is ongeldig.'), { statusCode: 401 });
|
||||
return { ok: true, workerId: 'rpw-fixture' };
|
||||
},
|
||||
};
|
||||
const logger = { info() {}, warn() {}, error() {}, audit() {} };
|
||||
const previousAllowedHosts = process.env.CHIMERA_ALLOWED_HOSTS;
|
||||
process.env.CHIMERA_ALLOWED_HOSTS = '127.0.0.1';
|
||||
const app = createAppServer({ appRoot: path.resolve(__dirname, '../..'), paths: { root, cache: path.join(root, 'cache') }, settings, secrets, registry, logger, ftp: { disconnect: async () => {} }, capabilities: { features: {} }, version: '1.0.9' });
|
||||
const address = await app.listen(0, '127.0.0.1');
|
||||
const base = `http://127.0.0.1:${address.port}`;
|
||||
const headers = { Host: `127.0.0.1:${address.port}`, 'Content-Type': 'application/json' };
|
||||
try {
|
||||
const denied = await fetch(`${base}/api/remote-play-worker/v1/poll`, { method: 'POST', headers, body: JSON.stringify({ token: 'wrong' }) });
|
||||
assert.equal(denied.status, 401);
|
||||
assert.equal(denied.headers.get('cache-control'), 'no-store');
|
||||
const accepted = await fetch(`${base}/api/remote-play-worker/v1/poll`, { method: 'POST', headers, body: JSON.stringify({ token: 'signed-fixture' }) });
|
||||
assert.equal(accepted.status, 200);
|
||||
assert.equal(accepted.headers.get('cache-control'), 'no-store');
|
||||
assert.deepEqual(await accepted.json(), { ok: true, workerId: 'rpw-fixture' });
|
||||
const normalApi = await fetch(`${base}/api/capabilities`, { headers });
|
||||
assert.equal(normalApi.status, 401);
|
||||
assert.equal(calls.length, 2);
|
||||
} finally {
|
||||
await app.close();
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
if (previousAllowedHosts === undefined) delete process.env.CHIMERA_ALLOWED_HOSTS;
|
||||
else process.env.CHIMERA_ALLOWED_HOSTS = previousAllowedHosts;
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
'use strict';
|
||||
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const test = require('node:test');
|
||||
const { createLogger } = require('../../src/platform/logger');
|
||||
const { createAuditLogService, normalizeEntry } = require('../../src/services/audit-log-service');
|
||||
|
||||
test('audit center filters command events and exports redacted authenticated data', () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'chimera-audit-'));
|
||||
try {
|
||||
const logger = createLogger(root, { maxBytes: 64 * 1024, maxFiles: 2, consoleOutput: false });
|
||||
logger.audit('command', { command: 'recovery:restore', classification: 'destructive', ok: true, requestIp: '192.0.2.4', password: 'must-not-appear' });
|
||||
logger.audit('command', { command: 'settings:set', classification: 'sensitive', ok: false, error: 'geweigerd' });
|
||||
logger.info('server ready Authorization: should-never-leak');
|
||||
const service = createAuditLogService({ logger });
|
||||
const result = service.query({ level: 'audit', classification: 'destructive', result: 'success', search: 'recovery', limit: 10 });
|
||||
assert.equal(result.entries.length, 1);
|
||||
assert.equal(result.entries[0].command, 'recovery:restore');
|
||||
assert.equal(result.entries[0].values[0].password, '[REDACTED]');
|
||||
assert.equal(result.summary.destructive, 1);
|
||||
const exported = service.exportAudit({ level: 'audit' }).toString('utf8');
|
||||
assert.match(exported, /recovery:restore/);
|
||||
assert.doesNotMatch(exported, /must-not-appear/);
|
||||
assert.doesNotMatch(fs.readFileSync(logger.file, 'utf8'), /should-never-leak/);
|
||||
assert.equal(service.health().exportAuthenticated, true);
|
||||
logger.info({ oversized: 'z'.repeat(100_000) });
|
||||
assert.ok(fs.statSync(logger.file).size < 64 * 1024);
|
||||
} finally { fs.rmSync(root, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
test('logger rotates bounded files and audit center rejects invalid filters', () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'chimera-audit-rotate-'));
|
||||
try {
|
||||
const logger = createLogger(root, { maxBytes: 64 * 1024, maxFiles: 2, consoleOutput: false });
|
||||
for (let index = 0; index < 180; index += 1) logger.audit('command', { command: `test:${index}`, classification: 'mutating', ok: true, note: 'x'.repeat(500) });
|
||||
assert.equal(fs.existsSync(`${logger.file}.1`), true);
|
||||
assert.equal(fs.existsSync(`${logger.file}.3`), false);
|
||||
const service = createAuditLogService({ logger, maxReadBytes: 256 * 1024 });
|
||||
assert.throws(() => service.query({ classification: 'root-shell' }), /Ongeldige auditclassificatie/);
|
||||
assert.ok(service.query({ limit: 500 }).entries.length > 0);
|
||||
} finally { fs.rmSync(root, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
test('audit normalizer drops malformed timestamps and bounds untrusted fields', () => {
|
||||
assert.equal(normalizeEntry({ time: 'invalid', level: 'audit', values: [] }), null);
|
||||
const entry = normalizeEntry({ time: '2026-07-16T00:00:00Z', level: 'audit', values: [{ event: 'command', command: `x\n${'y'.repeat(300)}`, classification: 'destructive', ok: false }] });
|
||||
assert.equal(entry.command.includes('\n'), false);
|
||||
assert.equal(entry.command.length, 160);
|
||||
assert.equal(entry.ok, false);
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
'use strict';
|
||||
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const { createBackupReplicationService } = require('../../src/services/backup-replication-service');
|
||||
|
||||
test('backup replication copies core files and verifies every hash', async t => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'chimera-replication-'));
|
||||
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
const backups = path.join(root, 'source', 'backups');
|
||||
const target = path.join(root, 'target');
|
||||
const tmp = path.join(root, 'tmp');
|
||||
fs.mkdirSync(path.join(backups, 'save-vault'), { recursive: true });
|
||||
fs.mkdirSync(target, { recursive: true });
|
||||
fs.mkdirSync(tmp, { recursive: true });
|
||||
fs.writeFileSync(path.join(backups, 'save-vault', 'PPSA00001.zip'), 'encrypted-save-data');
|
||||
const service = createBackupReplicationService({ paths: { backups, tmp }, targetRoot: target, gameBackupsRoot: path.join(root, 'games') });
|
||||
|
||||
const result = await service.replicate({ label: 'Veilige test' });
|
||||
assert.equal(result.phase, 'complete');
|
||||
assert.equal(result.copiedFiles, 1);
|
||||
assert.equal(service.list()[0].label, 'Veilige test');
|
||||
const verified = await service.verify({ id: result.id });
|
||||
assert.equal(verified.ok, true);
|
||||
assert.equal(verified.files, 1);
|
||||
const drill = await service.restoreDrill({ id: result.id });
|
||||
assert.equal(drill.ok, true);
|
||||
assert.equal(drill.ps5Modified, false);
|
||||
assert.equal(drill.temporaryTargetRemoved, true);
|
||||
assert.equal(fs.readdirSync(path.join(tmp, 'restore-drills')).length, 0);
|
||||
const protection = service.status().protection;
|
||||
assert.equal(protection.threeTwoOneSatisfied, false);
|
||||
assert.equal(protection.copiesObserved, 2);
|
||||
assert.equal(protection.immutableObserved, false);
|
||||
assert.ok(protection.lastVerifiedAt);
|
||||
assert.ok(protection.lastRestoreDrillAt);
|
||||
assert.ok(Number.isFinite(protection.lastRestoreDrillDurationMs));
|
||||
assert.ok(service.list()[0].lastRestoreDrillAt);
|
||||
|
||||
const copied = path.join(target, result.id, 'data', 'chimera-core', 'save-vault', 'PPSA00001.zip');
|
||||
fs.appendFileSync(copied, 'tampered');
|
||||
await assert.rejects(() => service.verify({ id: result.id }), /Bestandsgrootte|Hash wijkt af/);
|
||||
});
|
||||
|
||||
test('replication retention keeps at least two valid snapshots and exposes immutable-target truthfully', async t => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'chimera-replication-retention-'));
|
||||
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
const backups = path.join(root, 'backups'); const target = path.join(root, 'target'); const tmp = path.join(root, 'tmp');
|
||||
for (const directory of [backups, target, tmp]) fs.mkdirSync(directory, { recursive: true });
|
||||
fs.writeFileSync(path.join(backups, 'settings.json'), '{}');
|
||||
const values = new Map([['backup.replicationRetentionEnabled', true], ['backup.replicationRetention', 2], ['backup.replicationMaxAgeDays', 180]]);
|
||||
const settings = { get: (key, fallback) => values.has(key) ? values.get(key) : fallback, set: (key, value) => values.set(key, value) };
|
||||
const service = createBackupReplicationService({ paths: { backups, tmp }, settings, targetRoot: target, gameBackupsRoot: path.join(root, 'games') });
|
||||
await service.replicate({ label: 'one' });
|
||||
await new Promise(resolve => setTimeout(resolve, 5));
|
||||
await service.replicate({ label: 'two' });
|
||||
await new Promise(resolve => setTimeout(resolve, 5));
|
||||
const third = await service.replicate({ label: 'three' });
|
||||
assert.equal(third.retention.removed, 1);
|
||||
assert.equal(service.list().length, 2);
|
||||
assert.equal(service.status().targetContract.immutable, false);
|
||||
assert.equal(service.retentionPreview().retainedMinimum, 2);
|
||||
});
|
||||
|
||||
test('backup replication refuses a target nested in its source', () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'chimera-replication-unsafe-'));
|
||||
try {
|
||||
const backups = path.join(root, 'backups');
|
||||
const target = path.join(backups, 'replication');
|
||||
fs.mkdirSync(target, { recursive: true });
|
||||
const service = createBackupReplicationService({ paths: { backups }, targetRoot: target, gameBackupsRoot: path.join(root, 'games') });
|
||||
const status = service.status();
|
||||
assert.equal(status.available, false);
|
||||
assert.match(status.error, /mogen niet in elkaar liggen/);
|
||||
} finally {
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
'use strict';
|
||||
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { readBuildInfo } = require('../../src/platform/build-info');
|
||||
|
||||
test('build info accepts bounded provenance and normalizes timestamps', () => {
|
||||
const build = readBuildInfo({
|
||||
CHIMERA_BUILD_ID: '1fb4db70edfb',
|
||||
CHIMERA_SOURCE_REVISION: '1fb4db70edfb9fd55d1d4ceabc9800079adadda8',
|
||||
CHIMERA_SOURCE_TREE: '7fb4db70edfb9fd55d1d4ceabc9800079adadda1',
|
||||
CHIMERA_BUILD_DATE: '2026-07-15T10:00:00Z',
|
||||
});
|
||||
assert.deepEqual(build, {
|
||||
id: '1fb4db70edfb',
|
||||
revision: '1fb4db70edfb9fd55d1d4ceabc9800079adadda8',
|
||||
tree: '7fb4db70edfb9fd55d1d4ceabc9800079adadda1',
|
||||
treeDigest: 'eb35c4caa0a978facf90b329ad5f9d4bbe132fc91bdd20efd4b7404ed6eeeae0',
|
||||
builtAt: '2026-07-15T10:00:00.000Z',
|
||||
});
|
||||
assert.equal(Object.isFrozen(build), true);
|
||||
});
|
||||
|
||||
test('build info rejects injected or malformed values', () => {
|
||||
assert.deepEqual(readBuildInfo({
|
||||
CHIMERA_BUILD_ID: 'release;cat /run/secrets/key',
|
||||
CHIMERA_SOURCE_REVISION: 'not-a-commit',
|
||||
CHIMERA_SOURCE_TREE: 'not-a-tree',
|
||||
CHIMERA_BUILD_DATE: 'not-a-date',
|
||||
}), { id: 'development', revision: 'development', tree: null, treeDigest: null, builtAt: null });
|
||||
});
|
||||
|
||||
test('build info derives the tree from the governed build id for legacy deploy controllers', () => {
|
||||
const tree = '7fb4db70edfb9fd55d1d4ceabc9800079adadda1';
|
||||
const build = readBuildInfo({
|
||||
CHIMERA_BUILD_ID: `1fb4db70edfb-tree-${tree}`,
|
||||
CHIMERA_SOURCE_REVISION: '1fb4db70edfb9fd55d1d4ceabc9800079adadda8',
|
||||
});
|
||||
assert.equal(build.tree, tree);
|
||||
assert.equal(build.treeDigest, 'eb35c4caa0a978facf90b329ad5f9d4bbe132fc91bdd20efd4b7404ed6eeeae0');
|
||||
});
|
||||
@@ -0,0 +1,409 @@
|
||||
'use strict';
|
||||
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
|
||||
const modulePathCache = new Map();
|
||||
let activeTransferState = null;
|
||||
|
||||
function mockModule(resolvedPath, exportsValue) {
|
||||
const current = require.cache[resolvedPath];
|
||||
modulePathCache.set(resolvedPath, current);
|
||||
require.cache[resolvedPath] = {
|
||||
id: resolvedPath,
|
||||
filename: resolvedPath,
|
||||
loaded: true,
|
||||
exports: exportsValue,
|
||||
};
|
||||
}
|
||||
|
||||
function restoreModules() {
|
||||
for (const [resolvedPath, original] of modulePathCache) {
|
||||
if (original === undefined) delete require.cache[resolvedPath];
|
||||
else require.cache[resolvedPath] = original;
|
||||
}
|
||||
modulePathCache.clear();
|
||||
}
|
||||
|
||||
function installRegistryMocks(rootDir) {
|
||||
const createOperationResult = () => ({
|
||||
status: 'ok',
|
||||
services: [],
|
||||
metadata: {},
|
||||
scans: [],
|
||||
health: {},
|
||||
});
|
||||
|
||||
mockModule(path.resolve(rootDir, 'src/platform/data-paths.js'), {
|
||||
mountedRoots: () => [],
|
||||
normalizeAllowedRoots: (roots, fallback) => (Array.isArray(roots) && roots.length ? roots : [fallback]),
|
||||
resolveAllowedPath: (candidate, _roots, options = {}) => {
|
||||
if (!candidate) return options.fallbackRoot || '';
|
||||
return path.isAbsolute(candidate) ? candidate : path.join(options.fallbackRoot || '', candidate);
|
||||
},
|
||||
});
|
||||
mockModule(path.resolve(rootDir, 'src/hashing.js'), {
|
||||
hashFile: async () => Buffer.from('fake-hash'),
|
||||
});
|
||||
mockModule(path.resolve(rootDir, 'src/services/elf-arsenal-client.js'), {
|
||||
createElfArsenalClient: () => ({}),
|
||||
});
|
||||
mockModule(path.resolve(rootDir, 'src/services/local-library-scanner.js'), {
|
||||
scanLocalLibraries: async () => ({ games: [], added: 0, total: 0 }),
|
||||
});
|
||||
mockModule(path.resolve(rootDir, 'src/services/transfer-service.js'), {
|
||||
createTransferService: ({ broadcast, getCredentials }) => {
|
||||
const executors = new Map();
|
||||
const state = {
|
||||
jobs: [],
|
||||
jobsById: new Map(),
|
||||
registerExecutor: (kind, factory) => {
|
||||
executors.set(kind, factory);
|
||||
},
|
||||
enqueue: (type, options = {}, runner) => {
|
||||
const jobId = `job-${state.jobs.length + 1}`;
|
||||
let status = 'queued';
|
||||
const progress = {};
|
||||
const throwIfCancelled = () => {
|
||||
if (status === 'cancelled') {
|
||||
throw new Error('Taak geannuleerd');
|
||||
}
|
||||
};
|
||||
const context = {
|
||||
progress: () => {},
|
||||
throwIfCancelled,
|
||||
client: {
|
||||
uploadFile: async () => ({ success: true }),
|
||||
uploadDirectory: async () => ({ success: true }),
|
||||
downloadFile: async () => ({ success: true }),
|
||||
},
|
||||
};
|
||||
const job = {
|
||||
jobId,
|
||||
id: jobId,
|
||||
type,
|
||||
progress,
|
||||
status,
|
||||
promise: Promise.resolve(),
|
||||
deduplicated: false,
|
||||
};
|
||||
const execute = async () => {
|
||||
status = 'active';
|
||||
if (runner) {
|
||||
await runner(context);
|
||||
} else {
|
||||
const executor = executors.get(String((options.descriptor || {}).kind || type));
|
||||
if (!executor) throw new Error(`No executor for ${options.descriptor?.kind || type}`);
|
||||
await executor(options.descriptor || {})(context);
|
||||
}
|
||||
return { success: true, remotePath: options.remotePath };
|
||||
};
|
||||
const credentials = getCredentials?.();
|
||||
if (credentials) {
|
||||
context.credentials = credentials;
|
||||
}
|
||||
const resolved = execute()
|
||||
.then(result => { status = 'done'; broadcast?.('transfer:update', state.state()); return result; })
|
||||
.catch(error => {
|
||||
status = 'failed';
|
||||
throw error;
|
||||
});
|
||||
job.promise = resolved;
|
||||
job.status = status;
|
||||
state.jobs.push(job);
|
||||
state.jobsById.set(jobId, job);
|
||||
broadcast?.('transfer:update', state.state());
|
||||
return job;
|
||||
},
|
||||
pause: () => {},
|
||||
resume: () => {},
|
||||
clearDone: () => {
|
||||
state.jobs = [];
|
||||
state.jobsById = new Map();
|
||||
},
|
||||
setConcurrent: () => 4,
|
||||
setPolicy: options => options,
|
||||
retry: () => false,
|
||||
cancel: () => false,
|
||||
state: () => ({ jobs: state.jobs.slice(), policy: { bandwidthLimitSupported: false } }),
|
||||
close: () => {},
|
||||
};
|
||||
activeTransferState = state;
|
||||
return state;
|
||||
},
|
||||
});
|
||||
mockModule(path.resolve(rootDir, 'src/services/conversion-service.js'), {
|
||||
createConversionService: () => ({}),
|
||||
});
|
||||
mockModule(path.resolve(rootDir, 'src/services/operations-service.js'), {
|
||||
createOperationsService: () => new Proxy({}, {
|
||||
get: (_target, prop) => {
|
||||
if (prop === 'start' || prop === 'stop') return () => {};
|
||||
return () => createOperationResult();
|
||||
},
|
||||
}),
|
||||
});
|
||||
mockModule(path.resolve(rootDir, 'src/services/ecosystem-service.js'), {
|
||||
createEcosystemService: () => ({
|
||||
stop: () => {},
|
||||
payloads: {},
|
||||
listRemoteSources: () => [],
|
||||
classification: {},
|
||||
downloads: () => [],
|
||||
}),
|
||||
});
|
||||
mockModule(path.resolve(rootDir, 'src/services/ecosystem-intelligence-service.js'), {
|
||||
createEcosystemIntelligenceService: () => ({ latestHealth: () => ({}) }),
|
||||
});
|
||||
mockModule(path.resolve(rootDir, 'src/services/game-update-service.js'), {
|
||||
createGameUpdateService: () => ({}),
|
||||
});
|
||||
mockModule(path.resolve(rootDir, 'src/services/ps5debug-readonly-client.js'), {
|
||||
createPs5DebugReadonlyClient: () => ({}),
|
||||
});
|
||||
mockModule(path.resolve(rootDir, 'src/services/remote-play-service.js'), {
|
||||
createRemotePlayService: () => ({}),
|
||||
});
|
||||
mockModule(path.resolve(rootDir, 'src/services/backup-replication-service.js'), {
|
||||
createBackupReplicationService: () => ({}),
|
||||
});
|
||||
mockModule(path.resolve(rootDir, 'src/services/payload-runtime-service.js'), {
|
||||
createPayloadRuntimeService: () => ({
|
||||
watchdog: () => ({}),
|
||||
updatePlan: () => ({}),
|
||||
refreshRepositories: () => ({}),
|
||||
installUpdate: () => ({}),
|
||||
}),
|
||||
});
|
||||
mockModule(path.resolve(rootDir, 'src/services/chimera-beacon-client.js'), {
|
||||
createChimeraBeaconClient: () => ({}),
|
||||
});
|
||||
mockModule(path.resolve(rootDir, 'src/services/console-profile-service.js'), {
|
||||
createConsoleProfileService: () => {
|
||||
const secrets = new Map();
|
||||
return {
|
||||
activeId: () => 'console-1',
|
||||
getSecret: key => secrets.get(String(key)),
|
||||
setSecret: (key, value) => { secrets.set(String(key), value); },
|
||||
syncActiveFromLegacy: () => {},
|
||||
list: () => [],
|
||||
comparison: () => ({ local: [], remote: [] }),
|
||||
save: () => ({}),
|
||||
activate: () => ({}),
|
||||
remove: () => ({}),
|
||||
storageTopology: () => ({ roots: [], games: [] }),
|
||||
};
|
||||
},
|
||||
});
|
||||
mockModule(path.resolve(rootDir, 'src/services/companion-worker-service.js'), {
|
||||
createCompanionWorkerService: () => ({}),
|
||||
});
|
||||
mockModule(path.resolve(rootDir, 'src/services/audit-log-service.js'), {
|
||||
createAuditLogService: () => ({
|
||||
query: () => [],
|
||||
health: () => ({ ok: true }),
|
||||
exportAudit: () => Buffer.from('[]'),
|
||||
}),
|
||||
normalizeEntry: () => ({}),
|
||||
LEVELS: {},
|
||||
CLASSIFICATIONS: {},
|
||||
});
|
||||
mockModule(path.resolve(rootDir, 'src/services/ps5-klog-service.js'), {
|
||||
createPs5KlogService: () => ({ start: () => {}, stop: () => {} }),
|
||||
});
|
||||
mockModule(path.resolve(rootDir, 'src/security/totp.js'), {
|
||||
createTotpService: () => ({}),
|
||||
});
|
||||
mockModule(path.resolve(rootDir, 'src/services/notification-service.js'), {
|
||||
createNotificationService: () => ({ emit: async () => ({}) }),
|
||||
});
|
||||
mockModule(path.resolve(rootDir, 'src/services/source-classification-service.js'), {
|
||||
createSourceClassificationService: () => ({}),
|
||||
});
|
||||
mockModule(path.resolve(rootDir, 'src/services/game-metadata-view.js'), {
|
||||
sanitizeOverride: value => value,
|
||||
annotateRegionalVariants: value => value,
|
||||
});
|
||||
mockModule(path.resolve(rootDir, 'src/security/read-only-sql.js'), {
|
||||
assertReadOnlySql: value => value,
|
||||
});
|
||||
mockModule(path.resolve(rootDir, 'src/server/register-functional-modules.js'), {
|
||||
registerFunctionalModules: () => ({
|
||||
metadata: {
|
||||
queue: () => {},
|
||||
queueDue: () => [],
|
||||
status: () => ({}),
|
||||
sync: () => [],
|
||||
snapshot: () => ({}),
|
||||
},
|
||||
payloadCatalog: {
|
||||
sync: async () => ({ count: 0 }),
|
||||
seed: () => ({ count: 0 }),
|
||||
snapshot: () => ({}),
|
||||
},
|
||||
payloadManager: {},
|
||||
cheatsSync: async () => ({}),
|
||||
}),
|
||||
});
|
||||
mockModule(path.resolve(rootDir, 'src/services/ezremote-server-client.js'), {
|
||||
createEzRemoteServerClient: () => ({
|
||||
probe: () => ({ status: 'mock' }),
|
||||
downloads: () => [],
|
||||
waitFor: () => ({}),
|
||||
ensureDownload: async () => ({ completed: true, download: { transferred: 123, size: 123, path: '' } }),
|
||||
}),
|
||||
buildDownloadRequest: input => input,
|
||||
DEFAULT_DESTINATION_ROOTS: ['/games/ps4', '/games/ps5'],
|
||||
});
|
||||
mockModule(path.resolve(rootDir, 'src/services/console-scan-reconciler.js'), {
|
||||
reconcileConsoleScan: () => ({ applied: true, upserted: 0, cleared: 0 }),
|
||||
});
|
||||
}
|
||||
|
||||
function withMockedRegistry(callback) {
|
||||
const rootDir = path.resolve(__dirname, '../..');
|
||||
let originalCreateRegistry;
|
||||
const registryPath = path.resolve(rootDir, 'src/server/create-registry.js');
|
||||
let active = true;
|
||||
const cleanup = () => {
|
||||
if (!active) return;
|
||||
active = false;
|
||||
restoreModules();
|
||||
delete require.cache[registryPath];
|
||||
};
|
||||
|
||||
try {
|
||||
installRegistryMocks(rootDir);
|
||||
delete require.cache[registryPath];
|
||||
originalCreateRegistry = require(registryPath);
|
||||
const result = callback(originalCreateRegistry.createRegistry);
|
||||
if (result && typeof result.then === 'function') {
|
||||
return result.finally(cleanup);
|
||||
}
|
||||
cleanup();
|
||||
return result;
|
||||
} finally {
|
||||
if (active === false) return;
|
||||
cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
function createDbWithTracking() {
|
||||
const updates = [];
|
||||
const games = new Map();
|
||||
const db = new Proxy({
|
||||
__updates: updates,
|
||||
updateGame: (gameId, fields) => {
|
||||
updates.push({ gameId, fields });
|
||||
const current = games.get(String(gameId)) || { game_id: String(gameId), ftp_path: '' };
|
||||
games.set(String(gameId), { ...current, ...fields });
|
||||
},
|
||||
getGame: gameId => games.get(String(gameId)) || null,
|
||||
upsertGame: game => { if (game?.game_id) games.set(String(game.game_id), { ...(games.get(String(game.game_id)) || {}), ...game }); },
|
||||
setGamePorked: () => {},
|
||||
listBackporkFolders: () => [],
|
||||
listBackups: () => [],
|
||||
upsertGameMinimal: () => {},
|
||||
setGameFirmwareLabel: () => {},
|
||||
upsertBackup: () => {},
|
||||
pruneGameFirmwareForFolder: () => {},
|
||||
pruneOrphanScanGames: () => {},
|
||||
syncBackedUpFlags: () => {},
|
||||
beginBulkUpdate: () => {},
|
||||
endBulkUpdate: () => {},
|
||||
}, {
|
||||
get(target, property) {
|
||||
return Reflect.get(target, property) || (() => {});
|
||||
},
|
||||
});
|
||||
return { db, updates };
|
||||
}
|
||||
|
||||
function waitForJob(transferState, jobId) {
|
||||
const job = transferState.jobsById.get(jobId);
|
||||
if (!job) throw new Error(`Missing job ${jobId}`);
|
||||
return job.promise;
|
||||
}
|
||||
|
||||
test('FTP upload queue completion does not set installed', async () => {
|
||||
await withMockedRegistry(async (createRegistry) => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'chimera-ch0003-upload-'));
|
||||
try {
|
||||
const localFile = path.join(root, 'game.pkg');
|
||||
fs.writeFileSync(localFile, 'payload');
|
||||
const { db, updates } = createDbWithTracking();
|
||||
db.upsertGame({ game_id: 'PPSA12345', ftp_path: '/existing/path', installed: 1 });
|
||||
const settings = { get: () => '127.0.0.1', set: () => {} };
|
||||
const secrets = { get: () => '', has: () => false };
|
||||
const ftp = { isConnected: () => true, hasCredentials: () => true };
|
||||
const paths = { root, cache: root, backups: root, tmp: root, config: root };
|
||||
const logger = { info() {}, warn() {}, error() {}, audit() {} };
|
||||
const registry = createRegistry({
|
||||
db,
|
||||
ftp,
|
||||
settings,
|
||||
secrets,
|
||||
paths,
|
||||
logger,
|
||||
capabilities: { features: {} },
|
||||
broadcast: () => {},
|
||||
appRoot: root,
|
||||
});
|
||||
|
||||
const upload = registry.commands.get('ftp:upload');
|
||||
assert.ok(upload);
|
||||
const result = upload.handler({ localPath: localFile, remotePath: '/mnt/sandbox' });
|
||||
await waitForJob(activeTransferState, result.jobId);
|
||||
assert.equal(result.success, true);
|
||||
assert.equal(updates.length, 0, 'no db.updateGame calls expected after upload completion');
|
||||
} finally {
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
activeTransferState = null;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test('Backpork queue completion writes ftp_path but never flips installed', async () => {
|
||||
await withMockedRegistry(async (createRegistry) => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'chimera-ch0003-pork-'));
|
||||
try {
|
||||
const backporkRoot = path.join(root, 'backporks');
|
||||
fs.mkdirSync(backporkRoot, { recursive: true });
|
||||
const gameFolder = path.join(backporkRoot, 'PPSA54321-pack');
|
||||
fs.mkdirSync(gameFolder, { recursive: true });
|
||||
const { db, updates } = createDbWithTracking();
|
||||
db.upsertGame({ game_id: 'PPSA54321', ftp_path: '/mnt/sandbox/pfsmnt/PPSA54321', installed: 1 });
|
||||
const settings = { get: () => '127.0.0.1', set: () => {} };
|
||||
const secrets = { get: () => '', has: () => false };
|
||||
const ftp = { isConnected: () => true, hasCredentials: () => true };
|
||||
const paths = { root, cache: root, backups: root, tmp: root, config: root };
|
||||
const logger = { info() {}, warn() {}, error() {}, audit() {} };
|
||||
const registry = createRegistry({
|
||||
db,
|
||||
ftp,
|
||||
settings,
|
||||
secrets,
|
||||
paths,
|
||||
logger,
|
||||
capabilities: { features: {} },
|
||||
broadcast: () => {},
|
||||
appRoot: root,
|
||||
});
|
||||
|
||||
const pork = registry.commands.get('backporks:pork');
|
||||
assert.ok(pork);
|
||||
const queued = pork.handler({ game_id: 'PPSA54321', folder_name: '11.50.0', folder_path: backporkRoot });
|
||||
await waitForJob(activeTransferState, queued.jobId);
|
||||
assert.equal(updates.length >= 1, true);
|
||||
assert.equal(updates.some(item => item.fields && Object.prototype.hasOwnProperty.call(item.fields, 'ftp_path')), true);
|
||||
const hasInstalled = updates.some(item => item.fields && Object.prototype.hasOwnProperty.call(item.fields, 'installed'));
|
||||
assert.equal(hasInstalled, false, 'backpork completion must not set installed');
|
||||
} finally {
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
activeTransferState = null;
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
'use strict';
|
||||
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const { createV3LibraryService } = require('../../src/services/v3-library-service');
|
||||
const libraryModel = require('../../renderer/modules/library-model');
|
||||
|
||||
function fixtureDb() {
|
||||
const rows = {
|
||||
titles: [
|
||||
{ v3_title_pk: 1, v3_platform: 'ps5', v3_external_title_id: 'PPSA00001', v3_name: 'Alpha', game_id: 'PPSA00001', title: 'Alpha', installed: 0, backed_up: 0 },
|
||||
{ v3_title_pk: 2, v3_platform: 'ps4', v3_external_title_id: 'CUSA00002', v3_name: 'Beta', game_id: 'CUSA00002', title: 'Beta', installed: 1, backed_up: 1 },
|
||||
],
|
||||
copies: [{ copy_id: 10, title_id: 1, relative_path: 'Alpha', copy_type: 'folder', volume_id: 20, stable_volume_key: 'backup-a', device_id: 30, stable_fingerprint: 'server-a', display_name: 'Backup A', copy_availability_state: 'available' }],
|
||||
installs: [{ install_id: 11, title_id: 1, console_profile_id: 'ps5-a', remote_location_key: '/user/app/PPSA00001', installed_version: '01.00', install_state: 'observed', volume_id: 21, stable_volume_key: 'internal', device_id: 31, stable_fingerprint: 'ps5-a', display_name: 'PS5 Internal', device_scope: 'console' }],
|
||||
availability: [{ title_id: 1, local_copy_count: 1, console_install_count: 1, confirmed_install_count: 1, derived_state: 'installed' }, { title_id: 2, local_copy_count: 0, console_install_count: 0, confirmed_install_count: 0, derived_state: 'unknown' }],
|
||||
protection: [{ title_id: 1, local_copy_count: 1, distinct_copy_device_count: 1, protection_healthy: 0 }, { title_id: 2, local_copy_count: 0, distinct_copy_device_count: 0, protection_healthy: 0 }],
|
||||
aggregates: [{ game_id: 'PPSA00001', backup_count: 1, backup_size: 42, backpork_count: 0 }],
|
||||
};
|
||||
return { runQuery(sql) {
|
||||
let selectedTitles = rows.titles;
|
||||
if (sql.includes("LIKE '%alpha%'")) selectedTitles = selectedTitles.filter(row => row.v3_external_title_id === 'PPSA00001');
|
||||
if (sql.includes("LOWER(t.platform) = 'ps4'")) selectedTitles = selectedTitles.filter(row => row.v3_platform === 'ps4');
|
||||
if (sql.includes('SELECT COUNT(*) AS total FROM titles')) return [{ total: selectedTitles.length }];
|
||||
if (sql.includes('FROM titles t')) { const limit = Number(sql.match(/LIMIT (\d+)/)?.[1] || selectedTitles.length); const offset = Number(sql.match(/OFFSET (\d+)/)?.[1] || 0); return selectedTitles.slice(offset, offset + limit); }
|
||||
if (sql.includes('FROM local_copies')) return rows.copies;
|
||||
if (sql.includes('FROM console_installs ci')) return rows.installs;
|
||||
if (sql.includes('v_title_availability')) return rows.availability;
|
||||
if (sql.includes('v_protection_health')) return rows.protection;
|
||||
if (sql.includes('LEFT JOIN (SELECT game_id')) return rows.aggregates;
|
||||
throw new Error(`Unexpected query: ${sql}`);
|
||||
} };
|
||||
}
|
||||
|
||||
test('v3 library selector returns nested title, copy, install, volume and device models', () => {
|
||||
const service = createV3LibraryService(fixtureDb());
|
||||
const packet = service.list({ search: 'alpha', limit: 1 });
|
||||
assert.equal(packet.total, 1);
|
||||
assert.equal(packet.items[0].localCopies[0].volume.stableVolumeKey, 'backup-a');
|
||||
assert.equal(packet.items[0].localCopies[0].device.displayName, 'Backup A');
|
||||
assert.equal(packet.items[0].consoleInstalls[0].installState, 'observed');
|
||||
assert.equal(packet.items[0].availability.state, 'installed');
|
||||
assert.equal(Object.hasOwn(packet.items[0], 'installed'), false);
|
||||
assert.equal(Object.hasOwn(packet.items[0], 'backed_up'), false);
|
||||
const firstPage = service.list({ limit: 1 });
|
||||
assert.equal(firstPage.total, 2);
|
||||
assert.equal(firstPage.hasMore, true);
|
||||
assert.equal(service.list({ limit: 1, offset: 1 }).items[0].game_id, 'CUSA00002');
|
||||
assert.deepEqual(service.list({ platform: 'ps4' }).items.map(item => item.game_id), ['CUSA00002']);
|
||||
});
|
||||
|
||||
test('legacy compatibility fields are derived from v3 evidence', () => {
|
||||
const games = createV3LibraryService(fixtureDb()).listLegacyGames({});
|
||||
assert.deepEqual(games.map(game => [game.installed, game.backed_up]), [[1, 1], [0, 0]]);
|
||||
});
|
||||
|
||||
test('renderer model consumes nested derived states', () => {
|
||||
const title = { installed: 0, backed_up: 0, availability: { confirmedInstallCount: 1 }, protection: { localCopyCount: 0 } };
|
||||
assert.equal(libraryModel.isInstalled(title), true);
|
||||
assert.equal(libraryModel.hasLocalCopy(title), false);
|
||||
assert.equal(libraryModel.needsProtection(title), true);
|
||||
});
|
||||
|
||||
test('electron, preload, web shim and server registry expose the v3 command', () => {
|
||||
const root = path.resolve(__dirname, '../..');
|
||||
const read = file => fs.readFileSync(path.join(root, file), 'utf8');
|
||||
assert.match(read('main.js'), /ipcMain\.handle\('library:titles:list'/);
|
||||
assert.match(read('preload.js'), /listLibraryTitles:.*library:titles:list/);
|
||||
assert.match(read('renderer/pork-web-shim.js'), /listLibraryTitles:.*library:titles:list/);
|
||||
assert.match(read('src/server/create-registry.js'), /name:\s*'library:titles:list'/);
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
'use strict';
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const { createSemanticEvidenceService } = require('../../src/services/semantic-evidence-service');
|
||||
test('manual evidence requires reason and bounded expiry', () => {
|
||||
const writes = []; const now = 1700000000000;
|
||||
const db = { runQuery: sql => sql.includes('FROM titles') ? [{ id: 7, title_id: 'PPSA00001' }] : [], runMutatingSqlWithParams: (sql, params) => writes.push(params) };
|
||||
const service = createSemanticEvidenceService(db, { now: () => now });
|
||||
assert.throws(() => service.record({ externalTitleId: 'PPSA00001', kind: 'version', value: '1.00', reason: 'short', expiresAt: now + 1000 }), /reason/);
|
||||
assert.throws(() => service.record({ externalTitleId: 'PPSA00001', kind: 'version', value: '1.00', reason: 'Verified manually', expiresAt: now + 31 * 86400000 }), /30 days/);
|
||||
assert.equal(service.record({ externalTitleId: 'PPSA00001', kind: 'version', value: '1.00', reason: 'Verified from settings screen', expiresAt: now + 86400000 }).advisoryOnly, true);
|
||||
assert.equal(writes.length, 1);
|
||||
});
|
||||
test('production callers omit legacy semantic writes', () => {
|
||||
const root = path.resolve(__dirname, '../..'); const read = file => fs.readFileSync(path.join(root, file), 'utf8');
|
||||
assert.match(read('src/database.js'), /Legacy semantic fields are read-only/);
|
||||
assert.doesNotMatch(['main.js','src/server/create-registry.js','src/ipc/backpork-gen.js'].map(read).join('\n'), /db\.updateGame\([^\n]+\{\s*(?:installed|backed_up)\s*:/);
|
||||
assert.doesNotMatch(read('renderer/modules/manage.js'), /fields:\s*\{\s*(?:installed|backed_up)\s*:/);
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
'use strict';
|
||||
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const database = require('../../src/database');
|
||||
|
||||
test('P1 migration gate has a report and no foreign-key violations', async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'chimera-p1-gate-'));
|
||||
database.configure({ dataDir: root, logger: { info() {}, warn() {}, error() {} } });
|
||||
try {
|
||||
await database.initialize();
|
||||
assert.deepEqual(database.checkForeignKeys(), []);
|
||||
const reportRow = database.runQuery("SELECT report_json FROM migration_reports WHERE migration_name = 'legacy-to-v3'")[0];
|
||||
assert.ok(reportRow?.report_json);
|
||||
const report = JSON.parse(reportRow.report_json);
|
||||
assert.equal(report.legacyGames, 0);
|
||||
assert.deepEqual(report.legacyUnverifiedInstalls, []);
|
||||
assert.deepEqual(report.ambiguousVersions, []);
|
||||
} finally {
|
||||
database.close();
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
'use strict';
|
||||
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const test = require('node:test');
|
||||
const workspace = require('../../renderer/modules/title-workspace');
|
||||
|
||||
const root = path.join(__dirname, '..', '..');
|
||||
|
||||
test('one canonical title projects multiple copies and confirmed installs as distinct locations', () => {
|
||||
const model = workspace.project({
|
||||
id: 'title-1',
|
||||
localCopies: [
|
||||
{ id: 'copy-a', volume: { label: 'Archive A', device: { availabilityState: 'available' } }, version: { value: '1.00' } },
|
||||
{ id: 'copy-b', volume: { label: 'Archive B', device: { availabilityState: 'available' } }, version: { value: '1.02' } }
|
||||
],
|
||||
consoleInstalls: [
|
||||
{ id: 'install-a', consoleName: 'Living room PS5', presenceState: 'present', version: { value: '1.02' } },
|
||||
{ id: 'install-b', consoleName: 'Lab PS5', presenceState: 'unknown' }
|
||||
],
|
||||
protection: { state: 'healthy' }
|
||||
});
|
||||
|
||||
assert.equal(model.locations.length, 4);
|
||||
assert.equal(model.localCopies.length, 2);
|
||||
assert.equal(model.consoleInstalls.length, 2);
|
||||
assert.equal(model.installed, true);
|
||||
assert.deepEqual(model.badges.map(item => item.code), ['installed', 'local-copy', 'protected']);
|
||||
});
|
||||
|
||||
test('missing drive badge is derived from device evidence without erasing the copy', () => {
|
||||
const model = workspace.project({
|
||||
id: 'title-2',
|
||||
localCopies: [{ id: 'copy-a', volume: { label: 'USB Archive', device: { availabilityState: 'disconnected' } } }],
|
||||
consoleInstalls: []
|
||||
});
|
||||
|
||||
assert.equal(model.locations.length, 1);
|
||||
assert.equal(model.locations[0].missing, true);
|
||||
assert.equal(model.missingDrive, true);
|
||||
assert.ok(model.badges.some(item => item.code === 'missing-drive'));
|
||||
assert.ok(model.badges.some(item => item.code === 'needs-protection'));
|
||||
});
|
||||
|
||||
test('title detail exposes the required accessible tabs and responsive layout', () => {
|
||||
const html = fs.readFileSync(path.join(root, 'renderer', 'index.html'), 'utf8');
|
||||
const css = fs.readFileSync(path.join(root, 'renderer', 'styles', 'title-workspace.css'), 'utf8');
|
||||
const stateSource = fs.readFileSync(path.join(root, 'renderer', 'modules', 'state.js'), 'utf8');
|
||||
const requiredTabs = ['copies', 'packages', 'compatibility', 'saves', 'trophies', 'media', 'activity'];
|
||||
|
||||
for (const tab of requiredTabs) {
|
||||
assert.match(html, new RegExp(`role="tab"[^>]+aria-controls="mpanel-${tab}"[^>]+data-mtab="${tab}"`));
|
||||
assert.match(html, new RegExp(`id="mpanel-${tab}"[^>]+role="tabpanel"`));
|
||||
}
|
||||
assert.match(html, /data-mtab="info"[^>]*>Overview/);
|
||||
assert.match(html, /data-mtab="patches"[^>]*>Updates/);
|
||||
assert.match(css, /@media\s*\(max-width:\s*680px\)/);
|
||||
assert.match(css, /grid-template-columns:\s*1fr/);
|
||||
assert.match(stateSource, /ArrowLeft/);
|
||||
assert.match(stateSource, /ArrowRight/);
|
||||
assert.match(stateSource, /Home/);
|
||||
assert.match(stateSource, /End/);
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
'use strict';
|
||||
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const test = require('node:test');
|
||||
const model = require('../../renderer/modules/protection-model');
|
||||
|
||||
const available = (id, deviceId, extra = {}) => model.normalizeCopy(
|
||||
{ game_id: 'PPSA00001', name: 'Policy Test' },
|
||||
{ id, deviceId, volumeId: `volume-${id}`, relativePath: `copy-${id}`, availabilityState: 'available', integrityState: 'verified', ...extra },
|
||||
id
|
||||
);
|
||||
|
||||
test('protection policy matrix requires healthy copies on distinct devices', () => {
|
||||
const matrix = [
|
||||
{ copies: [], status: 'unprotected', healthy: false },
|
||||
{ copies: [available(1, 'A')], status: 'needs-diversity', healthy: false },
|
||||
{ copies: [available(1, 'A'), available(2, 'B')], status: 'healthy', healthy: true },
|
||||
{ copies: [available(1, 'A'), available(2, 'B', { integrityState: 'failed' })], status: 'needs-diversity', healthy: false }
|
||||
];
|
||||
matrix.forEach(entry => {
|
||||
const result = model.evaluate(entry.copies, 2);
|
||||
assert.equal(result.status, entry.status);
|
||||
assert.equal(result.healthy, entry.healthy);
|
||||
});
|
||||
});
|
||||
|
||||
test('two copies on the same device do not satisfy diversity and are not called healthy duplicates', () => {
|
||||
const result = model.evaluate([available(1, 'A'), available(2, 'A')], 2);
|
||||
assert.equal(result.copyCount, 2);
|
||||
assert.equal(result.distinctDeviceCount, 1);
|
||||
assert.equal(result.sameDeviceExcess, 1);
|
||||
assert.equal(result.status, 'needs-diversity');
|
||||
assert.equal(result.healthy, false);
|
||||
});
|
||||
|
||||
test('missing copy remains visible and exact restore action targets its concrete copy id', () => {
|
||||
const rows = model.flatten([{
|
||||
game_id: 'PPSA00001',
|
||||
name: 'Policy Test',
|
||||
localCopies: [
|
||||
{ id: 41, deviceId: 'A', volumeId: 'primary', relativePath: 'PPSA00001', availabilityState: 'available', integrityState: 'verified' },
|
||||
{ id: 42, deviceId: 'B', volumeId: 'usb', relativePath: 'PPSA00001', availabilityState: 'unavailable', integrityState: 'unknown' }
|
||||
]
|
||||
}]);
|
||||
const renderer = fs.readFileSync(path.join(__dirname, '..', '..', 'renderer', 'modules', 'backups.js'), 'utf8');
|
||||
|
||||
assert.equal(rows.length, 2);
|
||||
assert.equal(rows[1].missing, true);
|
||||
assert.equal(rows[1].policy.missingCopyCount, 1);
|
||||
assert.equal(rows[1].restoreTarget.copyId, '42');
|
||||
assert.match(renderer, /data-restore-copy-id=/);
|
||||
assert.match(renderer, /state\.protectionRestoreTarget = copy\.restoreTarget/);
|
||||
assert.match(renderer, /No restore has started/);
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
'use strict';
|
||||
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const test = require('node:test');
|
||||
const database = require('../../src/database');
|
||||
const { createStorageInventoryService } = require('../../src/services/storage-inventory-service');
|
||||
|
||||
async function fixture(t) {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'chimera-storage-inventory-'));
|
||||
database.configure({ dataDir: root, logger: { info() {}, warn() {}, error() {} } });
|
||||
await database.initialize();
|
||||
t.after(() => {
|
||||
database.close();
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
return createStorageInventoryService({ db: database, now: () => 1_721_584_800_000 });
|
||||
}
|
||||
|
||||
test('disconnect and reconnect preserve stable device and volume identity', async t => {
|
||||
const inventory = await fixture(t);
|
||||
const snapshot = {
|
||||
scope: 'server', adapterId: 'local-statfs', source: 'node-statfs', observedAt: 1000, completeCoverage: true,
|
||||
devices: [{ stableFingerprint: 'disk-serial-a', displayName: 'Archive A', deviceType: 'usb', volumes: [{ stableVolumeKey: 'uuid-a', mountPath: '/mnt/a', availabilityState: 'available', capabilities: { canCopy: true, canMount: true } }] }]
|
||||
};
|
||||
const first = inventory.observe(snapshot);
|
||||
const deviceId = first.devices[0].id;
|
||||
const volumeId = first.devices[0].volumes[0].id;
|
||||
const disconnected = inventory.observe({ ...snapshot, observedAt: 2000, devices: [] });
|
||||
assert.equal(disconnected.devices[0].availabilityState, 'unavailable');
|
||||
assert.equal(disconnected.devices[0].volumes[0].availabilityState, 'unavailable');
|
||||
|
||||
const reconnected = inventory.observe({ ...snapshot, observedAt: 3000, devices: [{ ...snapshot.devices[0], volumes: [{ ...snapshot.devices[0].volumes[0], mountPath: '/media/archive-a' }] }] });
|
||||
assert.equal(reconnected.devices[0].id, deviceId);
|
||||
assert.equal(reconnected.devices[0].volumes[0].id, volumeId);
|
||||
assert.equal(reconnected.devices[0].volumes[0].mountPath, '/media/archive-a');
|
||||
assert.equal(reconnected.devices[0].availabilityState, 'available');
|
||||
});
|
||||
|
||||
test('unknown console capacity stays null and has no invented provenance', async t => {
|
||||
const inventory = await fixture(t);
|
||||
const result = inventory.observe({
|
||||
scope: 'console', adapterId: 'sentinel', source: 'chimera-sentinel-0.2', observedAt: 4000, completeCoverage: true,
|
||||
devices: [{ stableFingerprint: 'ps5-internal-a', displayName: 'PS5 Internal', deviceType: 'internal', volumes: [{ stableVolumeKey: 'internal', mountPath: '/user', capabilities: { canPlay: true } }] }]
|
||||
});
|
||||
const volume = result.devices[0].volumes[0];
|
||||
assert.equal(volume.capacityBytes, null);
|
||||
assert.equal(volume.freeBytes, null);
|
||||
assert.deepEqual(volume.capacityEvidence, { source: null, observedAt: null });
|
||||
assert.equal(volume.capabilities.canPlay, true);
|
||||
assert.equal(volume.capabilities.canInstall, null);
|
||||
assert.equal(volume.capabilityEvidence.source, 'chimera-sentinel-0.2');
|
||||
});
|
||||
|
||||
test('multiple USB devices remain separate and capacity carries source and time', async t => {
|
||||
const inventory = await fixture(t);
|
||||
const result = inventory.observe({
|
||||
scope: 'console', adapterId: 'sentinel', source: 'chimera-sentinel-0.2', observedAt: 5000, completeCoverage: true,
|
||||
devices: [
|
||||
{ stableFingerprint: 'usb-serial-a', displayName: 'USB A', deviceType: 'usb', volumes: [{ stableVolumeKey: 'usb-a', mountPath: '/mnt/usb0', capacityBytes: 1000, freeBytes: 400, capacitySource: 'sentinel-statfs' }] },
|
||||
{ stableFingerprint: 'usb-serial-b', displayName: 'USB B', deviceType: 'usb', volumes: [{ stableVolumeKey: 'usb-b', mountPath: '/mnt/usb1', capacityBytes: 2000, freeBytes: 1500, capacitySource: 'sentinel-statfs' }] }
|
||||
]
|
||||
});
|
||||
assert.equal(result.devices.length, 2);
|
||||
assert.deepEqual(result.devices.map(device => device.stableFingerprint), ['usb-serial-a', 'usb-serial-b']);
|
||||
assert.deepEqual(result.devices.map(device => device.volumes[0].capacityEvidence), [
|
||||
{ source: 'sentinel-statfs', observedAt: 5000 },
|
||||
{ source: 'sentinel-statfs', observedAt: 5000 }
|
||||
]);
|
||||
});
|
||||
|
||||
test('incomplete coverage never marks an unobserved device unavailable', async t => {
|
||||
const inventory = await fixture(t);
|
||||
const base = { scope: 'server', adapterId: 'local-statfs', source: 'node-statfs', observedAt: 6000, completeCoverage: true, devices: [{ stableFingerprint: 'disk-a', volumes: [{ stableVolumeKey: 'vol-a', mountPath: '/a' }] }] };
|
||||
inventory.observe(base);
|
||||
const result = inventory.observe({ ...base, observedAt: 7000, completeCoverage: false, devices: [] });
|
||||
assert.equal(result.devices[0].availabilityState, 'available');
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
'use strict';
|
||||
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const test = require('node:test');
|
||||
const { createInstallDestinationService } = require('../../src/services/install-destination-service');
|
||||
|
||||
const now = 10_000_000;
|
||||
function volume(overrides = {}) {
|
||||
return {
|
||||
id: 10,
|
||||
stableVolumeKey: 'internal',
|
||||
mountPath: '/mnt/sandbox/pfsmnt',
|
||||
capacityBytes: 1_000,
|
||||
freeBytes: 600,
|
||||
capacityEvidence: { source: 'sentinel', observedAt: now - 1_000 },
|
||||
capabilities: { canInstall: true },
|
||||
capabilityEvidence: { source: 'sentinel', observedAt: now - 1_000 },
|
||||
availabilityState: 'available',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function service(current) {
|
||||
return createInstallDestinationService({
|
||||
now: () => now,
|
||||
staleAfterMs: 60_000,
|
||||
inventory: { list: () => ({ devices: [{ id: 1, displayName: 'PS5', deviceType: 'internal', availabilityState: 'available', volumes: [current.value] }] }) },
|
||||
});
|
||||
}
|
||||
|
||||
test('capacity policy reports sufficient and insufficient projected space', () => {
|
||||
const current = { value: volume() };
|
||||
const selector = service(current);
|
||||
const sufficient = selector.listDestinations({ requiredBytes: 500, titleId: 'PPSA00001' }).destinations[0];
|
||||
assert.equal(sufficient.eligible, true);
|
||||
assert.equal(sufficient.projectedFreeBytes, 100);
|
||||
const insufficient = selector.listDestinations({ requiredBytes: 700, titleId: 'PPSA00001' }).destinations[0];
|
||||
assert.equal(insufficient.eligible, false);
|
||||
assert.ok(insufficient.reasons.includes('insufficient-capacity'));
|
||||
});
|
||||
|
||||
test('offline volume and disabled install capability explain ineligibility', () => {
|
||||
const current = { value: volume({ availabilityState: 'unavailable', capabilities: { canInstall: false } }) };
|
||||
const result = service(current).listDestinations({ requiredBytes: 100 }).destinations[0];
|
||||
assert.ok(result.reasons.includes('offline-volume'));
|
||||
assert.ok(result.reasons.includes('install-capability-disabled'));
|
||||
});
|
||||
|
||||
test('stale telemetry blocks normal destination selection', () => {
|
||||
const current = { value: volume({ capacityEvidence: { source: 'sentinel', observedAt: now - 120_000 } }) };
|
||||
const result = service(current).listDestinations({ requiredBytes: 100 }).destinations[0];
|
||||
assert.equal(result.eligible, false);
|
||||
assert.ok(result.reasons.includes('stale-capacity-telemetry'));
|
||||
});
|
||||
|
||||
test('execution resolves the selected volume again and rejects changed evidence', () => {
|
||||
const current = { value: volume() };
|
||||
const selector = service(current);
|
||||
assert.equal(selector.resolveForExecution({ volumeId: 10, requiredBytes: 500, titleId: 'PPSA00001' }).suggestedRemotePath, '/mnt/sandbox/pfsmnt/PPSA00001-app');
|
||||
current.value = volume({ freeBytes: 100 });
|
||||
assert.throws(() => selector.resolveForExecution({ volumeId: 10, requiredBytes: 500, titleId: 'PPSA00001' }), /no longer eligible.*insufficient-capacity/i);
|
||||
});
|
||||
|
||||
test('normal UI submits volume id and keeps raw path behind Expert details', () => {
|
||||
const root = path.join(__dirname, '..', '..');
|
||||
const html = fs.readFileSync(path.join(root, 'renderer', 'index.html'), 'utf8');
|
||||
const modal = fs.readFileSync(path.join(root, 'renderer', 'modules', 'modal.js'), 'utf8');
|
||||
const registry = fs.readFileSync(path.join(root, 'src', 'server', 'create-registry.js'), 'utf8');
|
||||
assert.match(html, /<select id="install-destination-volume"/);
|
||||
assert.match(html, /<details id="install-expert-destination"[^>]+data-expert-only="true"/);
|
||||
assert.match(modal, /destinationVolumeId: destination\.destinationVolumeId/);
|
||||
assert.match(registry, /resolveForExecution\(\{ volumeId: input\.destinationVolumeId, requiredBytes, titleId: id \}\)/);
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
'use strict';
|
||||
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const test = require('node:test');
|
||||
const database = require('../../src/database');
|
||||
const { createStorageInventoryService } = require('../../src/services/storage-inventory-service');
|
||||
|
||||
async function fixture(t) {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'chimera-drive-locator-'));
|
||||
database.configure({ dataDir: root, logger: { info() {}, warn() {}, error() {} } });
|
||||
await database.initialize();
|
||||
t.after(() => { database.close(); fs.rmSync(root, { recursive: true, force: true }); });
|
||||
return createStorageInventoryService({ db: database, now: () => 10_000 });
|
||||
}
|
||||
|
||||
const snapshot = (devices, overrides = {}) => ({ scope: 'server', adapterId: 'usb-monitor', source: 'udev', observedAt: 1_000, completeCoverage: true, devices, ...overrides });
|
||||
const drive = (fingerprint, mountPath, displayName = fingerprint) => ({ stableFingerprint: fingerprint, displayName, deviceType: 'usb', volumes: [{ stableVolumeKey: `uuid-${fingerprint}`, mountPath, capabilities: { canCopy: true } }] });
|
||||
|
||||
test('friendly label persists across disconnect and reconnect observations', async t => {
|
||||
const service = await fixture(t);
|
||||
const first = service.observe(snapshot([drive('serial-a', '/mnt/usb', 'Vendor Disk')]));
|
||||
const id = first.devices[0].id;
|
||||
service.setFriendlyLabel({ deviceId: id, label: 'Blue Archive' });
|
||||
service.observe(snapshot([], { observedAt: 2_000 }));
|
||||
const reconnected = service.observe(snapshot([drive('serial-a', '/media/usb', 'Renamed by OS')], { observedAt: 3_000 }));
|
||||
assert.equal(reconnected.devices.length, 1);
|
||||
assert.equal(reconnected.devices[0].id, id);
|
||||
assert.equal(reconnected.devices[0].displayName, 'Blue Archive');
|
||||
assert.equal(reconnected.devices[0].observedDisplayName, 'Renamed by OS');
|
||||
assert.equal(reconnected.devices[0].volumes[0].mountPath, '/media/usb');
|
||||
});
|
||||
|
||||
test('same mountpoint with a different fingerprint remains a different device', async t => {
|
||||
const service = await fixture(t);
|
||||
const result = service.observe(snapshot([drive('serial-a', '/mnt/usb'), drive('serial-b', '/mnt/usb')]));
|
||||
assert.equal(result.devices.length, 2);
|
||||
assert.notEqual(result.devices[0].id, result.devices[1].id);
|
||||
assert.deepEqual(new Set(result.devices.map(item => item.stableFingerprint)), new Set(['serial-a', 'serial-b']));
|
||||
});
|
||||
|
||||
test('locator exposes contained titles, required drive label and last-seen evidence', async t => {
|
||||
const service = await fixture(t);
|
||||
const inventory = service.observe(snapshot([drive('serial-a', '/mnt/usb')], { observedAt: 4_000 }));
|
||||
const device = inventory.devices[0];
|
||||
const volume = device.volumes[0];
|
||||
service.setFriendlyLabel({ deviceId: device.id, label: 'Blue Archive' });
|
||||
database.runMutatingSqlWithParams("INSERT INTO titles (platform, title_id, name, sort_name) VALUES ('ps5', 'PPSA00001', 'Locator Game', 'Locator Game')", {});
|
||||
const titleId = Number(database.runQuery('SELECT last_insert_rowid() AS id')[0].id);
|
||||
database.runMutatingSqlWithParams(`INSERT INTO local_copies (title_id, volume_id, relative_path, last_seen_at, availability_state) VALUES (@titleId, @volumeId, 'PPSA00001', 4000, 'available')`, { titleId, volumeId: volume.id });
|
||||
const locator = service.locateTitles({ titleId: 'PPSA00001' });
|
||||
assert.equal(locator.records.length, 1);
|
||||
assert.equal(locator.records[0].deviceLabel, 'Blue Archive');
|
||||
assert.equal(locator.records[0].deviceLastSeenAt, 4_000);
|
||||
assert.equal(locator.records[0].titleName, 'Locator Game');
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
'use strict';
|
||||
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const test = require('node:test');
|
||||
const database = require('../../src/database');
|
||||
const { createStorageInventoryService } = require('../../src/services/storage-inventory-service');
|
||||
const { createGuidedGameMoveService } = require('../../src/services/guided-game-move-service');
|
||||
|
||||
async function fixture(t) {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'chimera-guided-move-'));
|
||||
database.configure({ dataDir: root, logger: { info() {}, warn() {}, error() {} } });
|
||||
await database.initialize();
|
||||
t.after(() => { database.close(); fs.rmSync(root, { recursive: true, force: true }); });
|
||||
const observedAt = Date.now();
|
||||
const storage = createStorageInventoryService({ db: database, now: () => observedAt });
|
||||
const inventory = storage.observe({ scope: 'console', adapterId: 'sentinel', source: 'sentinel-test', observedAt, completeCoverage: true, devices: [
|
||||
{ stableFingerprint: 'internal', displayName: 'Internal', volumes: [{ stableVolumeKey: 'internal', mountPath: '/user/app', capacityBytes: 2000, freeBytes: 1000, capabilities: { canInstall: true } }] },
|
||||
{ stableFingerprint: 'm2', displayName: 'M.2 SSD', volumes: [{ stableVolumeKey: 'm2', mountPath: '/mnt/m2', capacityBytes: 3000, freeBytes: 2000, capabilities: { canInstall: true } }] },
|
||||
] });
|
||||
const sourceVolumeId = inventory.devices.find(item => item.stableFingerprint === 'internal').volumes[0].id;
|
||||
const destinationVolumeId = inventory.devices.find(item => item.stableFingerprint === 'm2').volumes[0].id;
|
||||
database.runMutatingSqlWithParams("INSERT INTO titles (platform, title_id, name, sort_name) VALUES ('ps5', 'PPSA00001', 'Move Test', 'Move Test')", {});
|
||||
const titleDbId = Number(database.runQuery('SELECT last_insert_rowid() AS id')[0].id);
|
||||
database.runMutatingSqlWithParams(`INSERT INTO console_installs (title_id, console_profile_id, volume_id, remote_location_key, install_state, last_seen_at) VALUES (@titleId, 'ps5', @volumeId, 'source', 'observed', @seenAt)`, { titleId: titleDbId, volumeId: sourceVolumeId, seenAt: observedAt });
|
||||
return { service: createGuidedGameMoveService({ db: database, now: () => observedAt, uuid: () => 'plan-1' }), titleDbId, sourceVolumeId, destinationVolumeId, observedAt };
|
||||
}
|
||||
|
||||
test('guided plan validates source, destination, capacity and returns exact manual steps', async t => {
|
||||
const f = await fixture(t);
|
||||
const plan = f.service.create({ titleId: 'PPSA00001', sourceVolumeId: f.sourceVolumeId, destinationVolumeId: f.destinationVolumeId, requiredBytes: 500 });
|
||||
assert.equal(plan.status, 'planned');
|
||||
assert.equal(plan.beforeSnapshot.length, 1);
|
||||
assert.ok(plan.steps.some(step => step.includes('Settings > Storage > Games and Apps')));
|
||||
assert.ok(plan.steps.some(step => step.includes('Do not delete the source')));
|
||||
assert.equal(plan.sourceDeletionInitiated, false);
|
||||
assert.throws(() => f.service.create({ titleId: 'PPSA00001', sourceVolumeId: f.sourceVolumeId, destinationVolumeId: f.destinationVolumeId, requiredBytes: 2500 }), /no longer eligible.*insufficient-capacity/i);
|
||||
});
|
||||
|
||||
test('after-scan reconciliation links snapshots and reports the location diff', async t => {
|
||||
const f = await fixture(t);
|
||||
f.service.create({ titleId: 'PPSA00001', sourceVolumeId: f.sourceVolumeId, destinationVolumeId: f.destinationVolumeId, requiredBytes: 500 });
|
||||
database.runMutatingSqlWithParams(`INSERT INTO console_installs (title_id, console_profile_id, volume_id, remote_location_key, install_state, last_seen_at) VALUES (@titleId, 'ps5', @volumeId, 'destination', 'observed', @seenAt)`, { titleId: f.titleDbId, volumeId: f.destinationVolumeId, seenAt: f.observedAt + 1 });
|
||||
const result = f.service.reconcile('plan-1');
|
||||
assert.equal(result.status, 'completed');
|
||||
assert.equal(result.diff.destinationObserved, true);
|
||||
assert.equal(result.diff.sourceStillObserved, true);
|
||||
assert.equal(result.diff.sourceDeletionInitiated, false);
|
||||
assert.equal(result.afterSnapshot.length, 2);
|
||||
});
|
||||
|
||||
test('cancel changes only plan state and exposes no delete operation', async t => {
|
||||
const f = await fixture(t);
|
||||
f.service.create({ titleId: 'PPSA00001', sourceVolumeId: f.sourceVolumeId, destinationVolumeId: f.destinationVolumeId, requiredBytes: 500 });
|
||||
const cancelled = f.service.cancel('plan-1');
|
||||
assert.equal(cancelled.status, 'cancelled');
|
||||
assert.equal(cancelled.sourceDeletionInitiated, false);
|
||||
assert.equal(typeof f.service.delete, 'undefined');
|
||||
assert.throws(() => f.service.reconcile('plan-1'), /cancelled/i);
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
'use strict';
|
||||
|
||||
const assert = require('node:assert/strict');
|
||||
const test = require('node:test');
|
||||
const { createGameMoveAdapterService, createMemoryJournalStore, unavailableLiveProvider } = require('../../src/services/game-move-adapter-service');
|
||||
|
||||
function provider(overrides = {}) {
|
||||
const calls = { copies: [], deletes: [] };
|
||||
const base = {
|
||||
probe: () => ({ provider: 'simulator', version: '1.0.0', live: false, available: true, capabilities: { copyFirst: true, resume: true, verifyHash: true, gameActiveGuard: true, freeSpaceGuard: true, deleteSource: true } }),
|
||||
isGameActive: async () => false,
|
||||
freeBytes: async () => 10_000,
|
||||
copy: async input => { calls.copies.push(input.offset); input.onProgress(input.totalBytes); return { bytesCopied: input.totalBytes }; },
|
||||
hashSource: async () => 'abc',
|
||||
hashDestination: async () => 'abc',
|
||||
deleteSource: async input => { calls.deletes.push(input); },
|
||||
...overrides,
|
||||
};
|
||||
return { adapter: base, calls };
|
||||
}
|
||||
|
||||
test('successful copy verifies before a separately confirmed source delete', async () => {
|
||||
const fake = provider();
|
||||
const service = createGameMoveAdapterService({ provider: fake.adapter, uuid: () => 'journal-1', now: () => 100 });
|
||||
const verified = await service.start({ titleId: 'PPSA00001', sourceId: 'internal', destinationId: 'm2', totalBytes: 1000, expectedHash: 'abc' });
|
||||
assert.equal(verified.status, 'verified');
|
||||
assert.equal(verified.deleteEligible, true);
|
||||
assert.equal(fake.calls.deletes.length, 0);
|
||||
await assert.rejects(service.deleteSource('journal-1', 'wrong'), /Exact confirmation/);
|
||||
const completed = await service.deleteSource('journal-1', 'DELETE SOURCE journal-1');
|
||||
assert.equal(completed.status, 'completed');
|
||||
assert.equal(completed.sourceDeleted, true);
|
||||
assert.equal(fake.calls.deletes.length, 1);
|
||||
});
|
||||
|
||||
test('interrupted copy resumes from journal offset', async () => {
|
||||
let attempts = 0;
|
||||
const fake = provider({ copy: async input => {
|
||||
fake.calls.copies.push(input.offset);
|
||||
attempts += 1;
|
||||
if (attempts === 1) { const error = new Error('simulated interruption'); error.bytesCopied = 400; throw error; }
|
||||
input.onProgress(input.totalBytes); return { bytesCopied: input.totalBytes };
|
||||
} });
|
||||
const service = createGameMoveAdapterService({ provider: fake.adapter, journals: createMemoryJournalStore(), uuid: () => 'journal-2' });
|
||||
const interrupted = await service.start({ titleId: 'PPSA00001', sourceId: 'internal', destinationId: 'usb', totalBytes: 1000 });
|
||||
assert.equal(interrupted.status, 'interrupted');
|
||||
assert.equal(interrupted.bytesCopied, 400);
|
||||
const resumed = await service.resume('journal-2');
|
||||
assert.equal(resumed.status, 'verified');
|
||||
assert.deepEqual(fake.calls.copies, [0, 400]);
|
||||
});
|
||||
|
||||
test('hash mismatch never becomes delete eligible', async () => {
|
||||
const fake = provider({ hashDestination: async () => 'different' });
|
||||
const service = createGameMoveAdapterService({ provider: fake.adapter, uuid: () => 'journal-3' });
|
||||
const result = await service.start({ titleId: 'PPSA00001', sourceId: 'internal', destinationId: 'm2', totalBytes: 1000 });
|
||||
assert.equal(result.status, 'needs_attention');
|
||||
assert.equal(result.error, 'hash-mismatch');
|
||||
assert.equal(result.deleteEligible, false);
|
||||
await assert.rejects(service.deleteSource('journal-3', 'DELETE SOURCE journal-3'), /unavailable until copy verification/);
|
||||
});
|
||||
|
||||
test('guards stop active games and insufficient free space before copying', async () => {
|
||||
const active = provider({ isGameActive: async () => true });
|
||||
const activeResult = await createGameMoveAdapterService({ provider: active.adapter }).start({ titleId: 'PPSA00001', sourceId: 'a', destinationId: 'b', totalBytes: 100 });
|
||||
assert.equal(activeResult.error, 'game-active');
|
||||
assert.equal(active.calls.copies.length, 0);
|
||||
const full = provider({ freeBytes: async () => 10 });
|
||||
const fullResult = await createGameMoveAdapterService({ provider: full.adapter }).start({ titleId: 'PPSA00001', sourceId: 'a', destinationId: 'b', totalBytes: 100 });
|
||||
assert.equal(fullResult.error, 'insufficient-free-space');
|
||||
assert.equal(full.calls.copies.length, 0);
|
||||
});
|
||||
|
||||
test('capability mismatch and default live provider are visibly unavailable', async () => {
|
||||
const unavailable = createGameMoveAdapterService({ provider: unavailableLiveProvider() });
|
||||
assert.equal(unavailable.probe().available, false);
|
||||
assert.equal(unavailable.probe().compatible, false);
|
||||
await assert.rejects(unavailable.start({ titleId: 'PPSA00001', sourceId: 'a', destinationId: 'b', totalBytes: 100 }), /unavailable or capability mismatch/i);
|
||||
const mismatch = provider({ probe: () => ({ provider: 'old-adapter', version: '0.1', available: true, capabilities: { copyFirst: true } }) });
|
||||
await assert.rejects(createGameMoveAdapterService({ provider: mismatch.adapter }).start({ titleId: 'PPSA00001', sourceId: 'a', destinationId: 'b', totalBytes: 100 }), /capability mismatch/i);
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
'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 { createAppServer } = require('../../src/server/app-server');
|
||||
|
||||
test('streaming import routes require auth and CSRF and return resumable offsets', async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'chimera-upload-http-'));
|
||||
fs.mkdirSync(path.join(root, 'cache'), { recursive: true });
|
||||
const values = new Map();
|
||||
const settings = { get: (key, fallback) => values.has(key) ? values.get(key) : fallback, set: (key, value) => values.set(key, value) };
|
||||
const secretValues = new Map();
|
||||
const secrets = { get: key => secretValues.get(key), set: (key, value) => secretValues.set(key, value), delete: key => secretValues.delete(key) };
|
||||
const registry = { describe: () => [], invoke: async () => ({}), isMutating: () => false, requestBodyLimit: () => 1024 };
|
||||
const logger = { info() {}, warn() {}, error() {}, audit() {} };
|
||||
const app = createAppServer({ appRoot: path.resolve(__dirname, '../..'), paths: { root, cache: path.join(root, 'cache') }, settings, secrets, registry, logger, ftp: { disconnect: async () => {} }, capabilities: { features: {} }, version: 'test' });
|
||||
const address = await app.listen(0, '127.0.0.1');
|
||||
const base = `http://127.0.0.1:${address.port}`;
|
||||
const common = { Host: `127.0.0.1:${address.port}`, Origin: base, 'Content-Type': 'application/json' };
|
||||
try {
|
||||
const denied = await fetch(`${base}/api/import/uploads`, { method: 'POST', headers: common, body: JSON.stringify({ files: [{ relativePath: 'game.pkg', size: 4 }] }) });
|
||||
assert.equal(denied.status, 401);
|
||||
const setup = await fetch(`${base}/api/setup`, { method: 'POST', headers: common, body: JSON.stringify({ username: 'uploader', password: 'secure-password-123', allowedRoots: [root], safetyConfirmed: true }) });
|
||||
assert.equal(setup.status, 201);
|
||||
const cookie = setup.headers.get('set-cookie').split(';')[0];
|
||||
const csrf = (await setup.json()).csrfToken;
|
||||
const missingCsrf = await fetch(`${base}/api/import/uploads`, { method: 'POST', headers: { ...common, Cookie: cookie }, body: JSON.stringify({ files: [{ relativePath: 'game.pkg', size: 4 }] }) });
|
||||
assert.equal(missingCsrf.status, 403);
|
||||
const createdResponse = await fetch(`${base}/api/import/uploads`, { method: 'POST', headers: { ...common, Cookie: cookie, 'X-CSRF-Token': csrf }, body: JSON.stringify({ files: [{ relativePath: 'folder/game.pkg', size: 4 }] }) });
|
||||
assert.equal(createdResponse.status, 201);
|
||||
const created = (await createdResponse.json()).result;
|
||||
assert.equal(JSON.stringify(created).includes(root), false);
|
||||
const chunkUrl = `${base}/api/import/uploads/${created.id}/files/${created.files[0].id}`;
|
||||
const chunk = Buffer.from('data');
|
||||
const chunkHeaders = { Host: common.Host, Origin: base, Cookie: cookie, 'X-CSRF-Token': csrf, 'Upload-Offset': '0', 'X-Chunk-SHA256': crypto.createHash('sha256').update(chunk).digest('hex'), 'Content-Type': 'application/octet-stream' };
|
||||
const deniedChunk = await fetch(chunkUrl, { method: 'PUT', headers: { ...chunkHeaders, 'X-CSRF-Token': '' }, body: chunk });
|
||||
assert.equal(deniedChunk.status, 403);
|
||||
const uploaded = await fetch(chunkUrl, { method: 'PUT', headers: chunkHeaders, body: chunk });
|
||||
assert.equal(uploaded.status, 200);
|
||||
assert.equal(uploaded.headers.get('upload-offset'), '4');
|
||||
assert.equal((await uploaded.json()).result.state, 'uploaded');
|
||||
} finally {
|
||||
await app.close();
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
'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 { Readable } = require('stream');
|
||||
const test = require('node:test');
|
||||
const { createImportUploadService, sanitizeRelativePath } = require('../../src/services/import-upload-service');
|
||||
|
||||
const digest = value => crypto.createHash('sha256').update(value).digest('hex');
|
||||
|
||||
test('folder upload sanitizes paths, resumes exact offsets and survives service restart', async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'chimera-upload-'));
|
||||
try {
|
||||
const service = createImportUploadService({ root, limits: { maxChunkBytes: 8 } });
|
||||
const created = service.create({ owner: 'alice', files: [{ relativePath: 'Game/base.pkg', size: 6, sha256: digest('abcdef') }] });
|
||||
assert.equal(Object.hasOwn(created, 'owner'), false);
|
||||
assert.equal(JSON.stringify(created).includes(root), false);
|
||||
const file = created.files[0];
|
||||
const first = await service.writeChunk({ id: created.id, fileId: file.id, owner: 'alice', offset: 0, length: 3, chunkSha256: digest('abc'), stream: Readable.from('abc') });
|
||||
assert.equal(first.files[0].offset, 3);
|
||||
|
||||
const restarted = createImportUploadService({ root, limits: { maxChunkBytes: 8 } });
|
||||
assert.equal(restarted.get(created.id, 'alice').files[0].offset, 3);
|
||||
await assert.rejects(
|
||||
restarted.writeChunk({ id: created.id, fileId: file.id, owner: 'alice', offset: 2, length: 3, chunkSha256: digest('def'), stream: Readable.from('def') }),
|
||||
error => error.code === 'UPLOAD_OFFSET' && error.expectedOffset === 3,
|
||||
);
|
||||
const completed = await restarted.writeChunk({ id: created.id, fileId: file.id, owner: 'alice', offset: 3, length: 3, chunkSha256: digest('def'), stream: Readable.from('def') });
|
||||
assert.equal(completed.state, 'uploaded');
|
||||
assert.equal(completed.bytesReceived, 6);
|
||||
} finally { fs.rmSync(root, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
test('hash failures roll back, quotas are enforced and hostile paths are rejected', async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'chimera-upload-policy-'));
|
||||
try {
|
||||
const service = createImportUploadService({ root, limits: { maxFiles: 2, maxFileBytes: 8, maxSessionBytes: 10, maxUserBytes: 12, maxGlobalBytes: 14, maxChunkBytes: 8 } });
|
||||
for (const value of ['../escape.pkg', '/absolute.pkg', 'C:\\escape.pkg', 'folder//file.pkg', 'folder/CON']) assert.throws(() => sanitizeRelativePath(value));
|
||||
const created = service.create({ owner: 'alice', files: [{ relativePath: 'safe/file.pkg', size: 4 }] });
|
||||
const file = created.files[0];
|
||||
await assert.rejects(
|
||||
service.writeChunk({ id: created.id, fileId: file.id, owner: 'alice', offset: 0, length: 4, chunkSha256: digest('nope'), stream: Readable.from('data') }),
|
||||
error => error.code === 'UPLOAD_HASH',
|
||||
);
|
||||
assert.equal(service.get(created.id, 'alice').files[0].offset, 0);
|
||||
assert.throws(() => service.create({ owner: 'alice', files: [{ relativePath: 'too-large.pkg', size: 9 }] }), error => error.code === 'UPLOAD_QUOTA');
|
||||
assert.throws(() => service.create({ owner: 'alice', files: [{ relativePath: 'a.pkg', size: 5 }, { relativePath: 'A.pkg', size: 5 }] }), error => error.code === 'UPLOAD_COLLISION');
|
||||
service.create({ owner: 'alice', files: [{ relativePath: 'second.pkg', size: 8 }] });
|
||||
assert.throws(() => service.create({ owner: 'alice', files: [{ relativePath: 'over-user.pkg', size: 1 }] }), error => error.code === 'UPLOAD_QUOTA');
|
||||
assert.throws(() => service.get(created.id, 'bob'), error => error.code === 'UPLOAD_NOT_FOUND');
|
||||
} finally { fs.rmSync(root, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
test('cancel and expiry remove partial content without exposing or mutating a library', async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'chimera-upload-lifecycle-'));
|
||||
let clock = 1000;
|
||||
const events = [];
|
||||
try {
|
||||
const service = createImportUploadService({ root, now: () => clock, emit: (channel, payload) => events.push({ channel, payload }), limits: { ttlMs: 10, maxChunkBytes: 8 } });
|
||||
const cancelled = service.create({ owner: 'alice', files: [{ relativePath: 'cancel.pkg', size: 4 }] });
|
||||
await service.writeChunk({ id: cancelled.id, fileId: cancelled.files[0].id, owner: 'alice', offset: 0, length: 2, chunkSha256: digest('ab'), stream: Readable.from('ab') });
|
||||
assert.equal(service.cancel(cancelled.id, 'alice').state, 'cancelled');
|
||||
const expiring = service.create({ owner: 'alice', files: [{ relativePath: 'expire.pkg', size: 2 }] });
|
||||
clock = 1011;
|
||||
assert.equal(service.expire(), 1);
|
||||
assert.equal(service.get(expiring.id, 'alice').state, 'expired');
|
||||
assert.equal(fs.readdirSync(root).some(name => name === 'library'), false);
|
||||
assert.deepEqual(events.map(event => event.channel), ['import:upload:created', 'import:upload:progress', 'import:upload:cancelled', 'import:upload:created', 'import:upload:expired']);
|
||||
} finally { fs.rmSync(root, { recursive: true, force: true }); }
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
'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 { Readable } = require('stream');
|
||||
const test = require('node:test');
|
||||
const { createImportUploadService } = require('../../src/services/import-upload-service');
|
||||
const { createImportStagingService } = require('../../src/services/import-staging-service');
|
||||
|
||||
const hash = value => crypto.createHash('sha256').update(value).digest('hex');
|
||||
|
||||
async function uploadedSession(root, content = 'payload') {
|
||||
const uploads = createImportUploadService({ root: path.join(root, 'uploads'), limits: { maxChunkBytes: 1024 } });
|
||||
const created = uploads.create({ owner: 'alice', files: [{ relativePath: 'base/game.pkg', size: Buffer.byteLength(content), sha256: hash(content) }] });
|
||||
await uploads.writeChunk({ id: created.id, fileId: created.files[0].id, owner: 'alice', offset: 0, length: Buffer.byteLength(content), chunkSha256: hash(content), stream: Readable.from(content) });
|
||||
return { uploads, created };
|
||||
}
|
||||
|
||||
test('staging leaves the managed library unchanged until validated atomic finalization', async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'chimera-staging-'));
|
||||
const managed = path.join(root, 'managed');
|
||||
fs.mkdirSync(managed);
|
||||
try {
|
||||
const { uploads, created } = await uploadedSession(root);
|
||||
const service = createImportStagingService({ root: path.join(root, 'staging'), uploadStore: uploads, managedRoots: () => [{ id: 'primary', path: managed }] });
|
||||
const job = await service.createFromUpload({ uploadSessionId: created.id, owner: 'alice' });
|
||||
assert.equal(job.state, 'quarantined');
|
||||
assert.equal(job.quarantine.code, 'awaiting-validation');
|
||||
assert.deepEqual(fs.readdirSync(managed), []);
|
||||
assert.throws(() => service.markReady(job.id, 'alice', { inspectionPassed: true, userValidated: false }), error => error.code === 'IMPORT_VALIDATION');
|
||||
assert.equal(service.markReady(job.id, 'alice', { inspectionPassed: true, userValidated: true }).state, 'ready');
|
||||
const finalized = await service.finalize(job.id, 'alice', { managedRootId: 'primary', relativePath: 'PS5-Games/Test [PPSA12345]' });
|
||||
assert.equal(finalized.state, 'finalized');
|
||||
assert.equal(fs.readFileSync(path.join(managed, 'PS5-Games', 'Test [PPSA12345]', 'base', 'game.pkg'), 'utf8'), 'payload');
|
||||
assert.equal((await service.finalize(job.id, 'alice', { managedRootId: 'primary', relativePath: 'PS5-Games/Test [PPSA12345]' })).state, 'finalized');
|
||||
assert.equal(JSON.stringify(finalized).includes(managed), false);
|
||||
} finally { fs.rmSync(root, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
test('interrupted temporary rename resumes and collision never overwrites an existing target', async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'chimera-staging-resume-'));
|
||||
const managed = path.join(root, 'managed');
|
||||
fs.mkdirSync(managed);
|
||||
let interrupt = true;
|
||||
try {
|
||||
const firstUpload = await uploadedSession(path.join(root, 'first'));
|
||||
const service = createImportStagingService({
|
||||
root: path.join(root, 'staging'), uploadStore: firstUpload.uploads, managedRoots: () => [{ id: 'primary', path: managed }],
|
||||
hooks: { afterTemporaryRename() { if (interrupt) { interrupt = false; throw new Error('simulated crash'); } } },
|
||||
});
|
||||
const first = await service.createFromUpload({ uploadSessionId: firstUpload.created.id, owner: 'alice' });
|
||||
service.markReady(first.id, 'alice', { inspectionPassed: true, userValidated: true });
|
||||
await assert.rejects(service.finalize(first.id, 'alice', { managedRootId: 'primary', relativePath: 'Games/Resume' }), error => error.code === 'IMPORT_INTERRUPTED');
|
||||
assert.equal(service.get(first.id, 'alice').state, 'finalizing');
|
||||
await assert.rejects(service.finalize(first.id, 'alice', { managedRootId: 'primary', relativePath: 'Games/Changed' }), error => error.code === 'IMPORT_DESTINATION_FROZEN');
|
||||
assert.equal((await service.finalize(first.id, 'alice', { managedRootId: 'primary', relativePath: 'Games/Resume' })).state, 'finalized');
|
||||
|
||||
const secondUpload = await uploadedSession(path.join(root, 'second'), 'other');
|
||||
const collisionService = createImportStagingService({ root: path.join(root, 'staging-two'), uploadStore: secondUpload.uploads, managedRoots: () => [{ id: 'primary', path: managed }] });
|
||||
const second = await collisionService.createFromUpload({ uploadSessionId: secondUpload.created.id, owner: 'alice' });
|
||||
collisionService.markReady(second.id, 'alice', { inspectionPassed: true, userValidated: true });
|
||||
fs.mkdirSync(path.join(managed, 'Games', 'Existing'), { recursive: true });
|
||||
fs.writeFileSync(path.join(managed, 'Games', 'Existing', 'keep.txt'), 'keep');
|
||||
await assert.rejects(collisionService.finalize(second.id, 'alice', { managedRootId: 'primary', relativePath: 'Games/Existing' }), error => error.code === 'IMPORT_COLLISION');
|
||||
assert.equal(collisionService.get(second.id, 'alice').quarantine.code, 'collision');
|
||||
assert.equal(fs.readFileSync(path.join(managed, 'Games', 'Existing', 'keep.txt'), 'utf8'), 'keep');
|
||||
} finally { fs.rmSync(root, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
test('disk-full staging and cancel preserve diagnostic journals while removing payload bytes', async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'chimera-staging-cleanup-'));
|
||||
const managed = path.join(root, 'managed');
|
||||
fs.mkdirSync(managed);
|
||||
try {
|
||||
const { uploads, created } = await uploadedSession(root);
|
||||
const diskFull = createImportStagingService({
|
||||
root: path.join(root, 'staging'), uploadStore: uploads, managedRoots: () => [{ id: 'primary', path: managed }],
|
||||
hooks: { beforeCopy() { const error = new Error('no space'); error.code = 'ENOSPC'; throw error; } },
|
||||
});
|
||||
const job = await diskFull.createFromUpload({ uploadSessionId: created.id, owner: 'alice' });
|
||||
assert.equal(job.state, 'quarantined');
|
||||
assert.equal(job.quarantine.code, 'disk-full');
|
||||
const cancelled = diskFull.cancel(job.id, 'alice');
|
||||
assert.equal(cancelled.state, 'cancelled');
|
||||
assert.equal(cancelled.diagnostics.some(item => item.code === 'disk-full'), true);
|
||||
assert.equal(cancelled.diagnostics.some(item => item.code === 'cancelled'), true);
|
||||
assert.equal(fs.existsSync(path.join(root, 'staging', job.id, 'payload')), false);
|
||||
assert.equal(fs.existsSync(path.join(root, 'staging', job.id, 'job.json')), true);
|
||||
assert.deepEqual(fs.readdirSync(managed), []);
|
||||
} finally { fs.rmSync(root, { recursive: true, force: true }); }
|
||||
});
|
||||
@@ -0,0 +1,101 @@
|
||||
'use strict';
|
||||
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const test = require('node:test');
|
||||
const JSZip = require('jszip');
|
||||
const { createArchiveWorkerService, detectArchiveSets } = require('../../src/services/archive-worker-service');
|
||||
|
||||
async function zipFile(filename, entries, options = {}) {
|
||||
const zip = new JSZip();
|
||||
for (const entry of entries) zip.file(entry.name, entry.content || '', entry.options || {});
|
||||
fs.mkdirSync(path.dirname(filename), { recursive: true });
|
||||
fs.writeFileSync(filename, await zip.generateAsync({ type: 'nodebuffer', platform: options.platform || 'DOS', compression: options.compression || 'STORE' }));
|
||||
}
|
||||
|
||||
function fixtureStore(payloadPath, components) {
|
||||
return { openForInspection: () => ({ id: 'job', state: 'quarantined', payloadPath, components }) };
|
||||
}
|
||||
|
||||
test('capabilities and multipart detection report missing dependencies and parts truthfully', () => {
|
||||
const worker = createArchiveWorkerService({ stagingStore: fixtureStore('unused', []) });
|
||||
const capabilities = worker.capabilities();
|
||||
assert.equal(capabilities.zip.available, true);
|
||||
assert.equal(capabilities.rar.available, false);
|
||||
assert.equal(capabilities.sevenZip.available, false);
|
||||
const sets = detectArchiveSets([
|
||||
{ relativePath: 'set/game.part1.rar' }, { relativePath: 'set/game.part3.rar' },
|
||||
{ relativePath: 'other/archive.7z.001' }, { relativePath: 'plain.zip' },
|
||||
{ relativePath: 'classic/data.rar' }, { relativePath: 'classic/data.r00' }, { relativePath: 'classic/data.r02' },
|
||||
]);
|
||||
assert.deepEqual(sets.find(set => set.key === 'set/game.rar').missingParts, [2]);
|
||||
assert.equal(sets.find(set => set.key === 'other/archive.7z').multipart, true);
|
||||
assert.deepEqual(sets.find(set => set.key === 'classic/data.rar').missingParts, [3]);
|
||||
assert.equal(sets.find(set => !set.multipart && set.format === 'zip').parts[0].relativePath, 'plain.zip');
|
||||
});
|
||||
|
||||
test('hostile ZIP matrix rejects traversal, links, entry count, depth and expansion limits', async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'chimera-archive-hostile-'));
|
||||
try {
|
||||
const cases = [
|
||||
{ name: 'traversal.zip', entries: [{ name: '../escape.txt', content: 'x' }], code: 'ARCHIVE_PATH' },
|
||||
{ name: 'absolute.zip', entries: [{ name: '/escape.txt', content: 'x' }], code: 'ARCHIVE_PATH' },
|
||||
{ name: 'link.zip', entries: [{ name: 'link', content: '../target', options: { unixPermissions: 0o120777 } }], platform: 'UNIX', code: 'ARCHIVE_LINK' },
|
||||
];
|
||||
for (const item of cases) {
|
||||
const filename = path.join(root, item.name);
|
||||
await zipFile(filename, item.entries, { platform: item.platform });
|
||||
const worker = createArchiveWorkerService({ stagingStore: fixtureStore(root, [{ relativePath: item.name, size: fs.statSync(filename).size }]) });
|
||||
const inspected = await worker.inspectJob('job', 'alice');
|
||||
assert.equal(inspected.archives[0].state, 'rejected');
|
||||
assert.equal(inspected.archives[0].code, item.code);
|
||||
}
|
||||
|
||||
await zipFile(path.join(root, 'many.zip'), [{ name: 'a', content: 'a' }, { name: 'b', content: 'b' }]);
|
||||
const many = await createArchiveWorkerService({ stagingStore: fixtureStore(root, [{ relativePath: 'many.zip' }]), limits: { maxEntries: 1 } }).inspectJob('job', 'alice');
|
||||
assert.equal(many.archives[0].code, 'ARCHIVE_LIMIT');
|
||||
await zipFile(path.join(root, 'deep.zip'), [{ name: 'a/b/c/file', content: 'x' }]);
|
||||
const deep = await createArchiveWorkerService({ stagingStore: fixtureStore(root, [{ relativePath: 'deep.zip' }]), limits: { maxDepth: 3 } }).inspectJob('job', 'alice');
|
||||
assert.equal(deep.archives[0].code, 'ARCHIVE_LIMIT');
|
||||
await zipFile(path.join(root, 'bomb.zip'), [{ name: 'large.txt', content: 'A'.repeat(10000) }], { compression: 'DEFLATE' });
|
||||
const bomb = await createArchiveWorkerService({ stagingStore: fixtureStore(root, [{ relativePath: 'bomb.zip' }]), limits: { maxExpansionRatio: 2 } }).inspectJob('job', 'alice');
|
||||
assert.equal(bomb.archives[0].code, 'ARCHIVE_LIMIT');
|
||||
} finally { fs.rmSync(root, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
test('corrupt and encrypted ZIPs are classified without partial extraction', async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'chimera-archive-invalid-'));
|
||||
try {
|
||||
fs.writeFileSync(path.join(root, 'corrupt.zip'), 'not-a-zip');
|
||||
await zipFile(path.join(root, 'encrypted.zip'), [{ name: 'file.txt', content: 'secret' }]);
|
||||
const encrypted = fs.readFileSync(path.join(root, 'encrypted.zip'));
|
||||
encrypted.writeUInt16LE(encrypted.readUInt16LE(6) | 1, 6);
|
||||
const central = encrypted.indexOf(Buffer.from([0x50, 0x4b, 0x01, 0x02]));
|
||||
encrypted.writeUInt16LE(encrypted.readUInt16LE(central + 8) | 1, central + 8);
|
||||
fs.writeFileSync(path.join(root, 'encrypted.zip'), encrypted);
|
||||
const worker = createArchiveWorkerService({ stagingStore: fixtureStore(root, [{ relativePath: 'corrupt.zip' }, { relativePath: 'encrypted.zip' }]) });
|
||||
const result = await worker.inspectJob('job', 'alice');
|
||||
assert.equal(result.archives.find(item => item.parts[0].relativePath === 'corrupt.zip').state, 'corrupt');
|
||||
assert.equal(result.archives.find(item => item.parts[0].relativePath === 'encrypted.zip').state, 'encrypted');
|
||||
} finally { fs.rmSync(root, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
test('interrupted ZIP extraction resumes verified entries and retains the original archive', async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'chimera-archive-resume-'));
|
||||
try {
|
||||
const filename = path.join(root, 'bundle.zip');
|
||||
await zipFile(filename, [{ name: 'one.txt', content: 'one' }, { name: 'two.txt', content: 'two' }]);
|
||||
const store = fixtureStore(root, [{ relativePath: 'bundle.zip', size: fs.statSync(filename).size }]);
|
||||
let checks = 0;
|
||||
const interrupted = createArchiveWorkerService({ stagingStore: store, isCancelled: () => ++checks > 1 });
|
||||
await assert.rejects(interrupted.extract({ jobId: 'job', owner: 'alice', relativePath: 'bundle.zip' }), error => error.code === 'ARCHIVE_CANCELLED');
|
||||
const resumed = createArchiveWorkerService({ stagingStore: store });
|
||||
const result = await resumed.extract({ jobId: 'job', owner: 'alice', relativePath: 'bundle.zip' });
|
||||
assert.equal(result.state, 'complete');
|
||||
assert.equal(result.resumedEntries, 1);
|
||||
assert.equal(result.originalRetained, true);
|
||||
assert.equal(fs.readFileSync(filename).length, fs.statSync(filename).size);
|
||||
} finally { fs.rmSync(root, { recursive: true, force: true }); }
|
||||
});
|
||||
@@ -0,0 +1,100 @@
|
||||
'use strict';
|
||||
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const test = require('node:test');
|
||||
const { createImportClassifierService } = require('../../src/services/import-classifier-service');
|
||||
|
||||
function store(root, relativePaths) {
|
||||
let saved = null;
|
||||
for (const relativePath of relativePaths) {
|
||||
const target = path.join(root, ...relativePath.split('/'));
|
||||
fs.mkdirSync(path.dirname(target), { recursive: true });
|
||||
fs.writeFileSync(target, relativePath);
|
||||
}
|
||||
return {
|
||||
openForInspection: () => ({ id: 'job', state: 'quarantined', payloadPath: root, components: [] }),
|
||||
saveClassification: (_id, _owner, value) => { saved = structuredClone(value); return structuredClone(saved); },
|
||||
getClassification: () => structuredClone(saved),
|
||||
};
|
||||
}
|
||||
|
||||
const evidence = (value, source = 'pkg-param-sfo', confidence = 'high') => ({ value, source, confidence });
|
||||
|
||||
test('known packages receive provenance and validated base-update-DLC order', async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'chimera-classifier-known-'));
|
||||
try {
|
||||
const staging = store(root, ['base.pkg', 'update.pkg', 'dlc.pkg', 'install-notes.txt']);
|
||||
const metadata = {
|
||||
'base.pkg': { titleId: evidence('CUSA12345'), contentId: evidence('UP0001-CUSA12345_00-BASEPACKAGE000001'), version: evidence('1.00'), category: 'base', source: 'pkg-param-sfo', confidence: 'high' },
|
||||
'update.pkg': { titleId: evidence('CUSA12345'), contentId: evidence('UP0001-CUSA12345_00-UPDATEPACKAGE001'), version: evidence('1.02'), category: 'update', source: 'pkg-param-sfo', confidence: 'high' },
|
||||
'dlc.pkg': { titleId: evidence('CUSA12345'), contentId: evidence('UP0001-CUSA12345_00-DLCPACKAGE000001'), version: evidence('1.00'), category: 'dlc', source: 'pkg-param-sfo', confidence: 'high' },
|
||||
};
|
||||
const classifier = createImportClassifierService({ stagingStore: staging, packageInspector: (_file, relative) => metadata[relative], now: () => 1000 });
|
||||
const result = await classifier.classify('job', 'alice');
|
||||
assert.equal(result.status, 'classified');
|
||||
assert.equal(result.title.id.value, 'CUSA12345');
|
||||
assert.deepEqual(result.recipeProposal.installOrder.map(item => item.componentType), ['base', 'update', 'dlc']);
|
||||
assert.equal(result.components.every(component => component.provenance.length > 0), true);
|
||||
assert.equal(result.notes[0].execution, 'never');
|
||||
assert.equal(result.recipeProposal.notesExecutable, false);
|
||||
assert.deepEqual(result.recipeProposal.arbitraryCommands, []);
|
||||
} finally { fs.rmSync(root, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
test('ambiguous filename-only evidence remains review-required', async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'chimera-classifier-ambiguous-'));
|
||||
try {
|
||||
const staging = store(root, ['CUSA11111-or-CUSA22222-update-v1.02.pkg']);
|
||||
const classifier = createImportClassifierService({ stagingStore: staging });
|
||||
const result = await classifier.classify('job', 'alice');
|
||||
assert.equal(result.status, 'review-required');
|
||||
assert.equal(result.title.id.value, null);
|
||||
assert.equal(result.recipeProposal.installOrder.length, 0);
|
||||
} finally { fs.rmSync(root, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
test('ambiguous package versions remain review-required despite a strong title ID', async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'chimera-classifier-version-'));
|
||||
try {
|
||||
const staging = store(root, ['base.pkg']);
|
||||
const classifier = createImportClassifierService({ stagingStore: staging, packageInspector: () => ({
|
||||
titleId: evidence('CUSA11111'), contentId: evidence('UP0001-CUSA11111_00-BASEPACKAGE000001'), version: evidence(null), category: 'base', candidates: { versions: ['1.00', '1.01'] },
|
||||
}) });
|
||||
const result = await classifier.classify('job', 'alice');
|
||||
assert.equal(result.status, 'review-required');
|
||||
assert.equal(result.issues.some(issue => issue.code === 'ambiguous-version'), true);
|
||||
assert.equal(result.components[0].validated, false);
|
||||
assert.deepEqual(result.recipeProposal.installOrder, []);
|
||||
} finally { fs.rmSync(root, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
test('mismatched component and content IDs block validation and ordering', async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'chimera-classifier-mismatch-'));
|
||||
try {
|
||||
const staging = store(root, ['base.pkg', 'update.pkg']);
|
||||
const classifier = createImportClassifierService({ stagingStore: staging, packageInspector: (_file, relative) => relative === 'base.pkg'
|
||||
? { titleId: evidence('PPSA11111'), contentId: evidence('UP0001-PPSA11111_00-BASEPACKAGE000001'), version: evidence('1.00'), category: 'base' }
|
||||
: { titleId: evidence('PPSA22222'), contentId: evidence('UP0001-PPSA33333_00-UPDATEPACKAGE001'), version: evidence('1.01'), category: 'update' } });
|
||||
const result = await classifier.classify('job', 'alice');
|
||||
assert.equal(result.status, 'review-required');
|
||||
assert.equal(result.issues.some(issue => issue.code === 'multiple-title-ids'), true);
|
||||
assert.equal(result.issues.some(issue => issue.code === 'content-title-mismatch'), true);
|
||||
assert.equal(result.components.find(component => component.relativePath === 'update.pkg').validated, false);
|
||||
} finally { fs.rmSync(root, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
test('packages discovered inside an extracted archive folder are classified', async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'chimera-classifier-archive-'));
|
||||
try {
|
||||
const relative = '.inspection/archive-id/game/base.pkg';
|
||||
const staging = store(root, [relative]);
|
||||
const classifier = createImportClassifierService({ stagingStore: staging, packageInspector: () => ({ titleId: evidence('CUSA54321'), contentId: evidence('EP0001-CUSA54321_00-BASEPACKAGE000001'), version: evidence('1.00'), category: 'base' }) });
|
||||
const result = await classifier.classify('job', 'alice');
|
||||
assert.equal(result.components[0].relativePath, relative);
|
||||
assert.equal(result.components[0].validated, true);
|
||||
assert.equal(result.status, 'classified');
|
||||
} finally { fs.rmSync(root, { recursive: true, force: true }); }
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
'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 { buildManifest, createPortableManifestService, inventoryFromManifest, previewNormalizedLayout, validateManifest } = require('../../src/services/portable-manifest-service');
|
||||
const manifestSchema = require('../../docs/schemas/chimera-manifest.schema.json');
|
||||
|
||||
const evidence = (value, source = 'pkg-param-sfo', confidence = 'high') => ({ value, source, confidence, observedAt: 1000 });
|
||||
const component = (id, relativePath, type, version = '1.00') => ({ id, relativePath, size: 4, sha256: crypto.createHash('sha256').update(relativePath).digest('hex'), type: evidence(type), titleId: evidence('CUSA12345'), contentId: evidence('UP0001-CUSA12345_00-ABCDEFGHIJKLMNOP'), version: evidence(version), platform: 'ps4', validated: true, provenance: [{ source: 'staging-sha256', confidence: 'verified', observedAt: 1000 }] });
|
||||
function classification(components) {
|
||||
return { schema: 1, jobId: 'job-id', status: 'classified', classifiedAt: 1000, title: { id: evidence('CUSA12345'), platform: 'ps4', observedIds: ['CUSA12345'] }, components, issues: [], notes: [{ relativePath: 'notes.txt', size: 4, sha256: crypto.createHash('sha256').update('notes').digest('hex'), provenance: 'uploaded', execution: 'never' }], recipeProposal: { installOrder: components.map((item, index) => ({ order: index + 1, componentId: item.id, componentType: item.type.value, action: 'proposal-only' })), arbitraryCommands: [], notesExecutable: false } };
|
||||
}
|
||||
|
||||
test('portable manifest schema exposes the versioned required contract', () => {
|
||||
assert.equal(manifestSchema.$schema, 'https://json-schema.org/draft/2020-12/schema');
|
||||
assert.deepEqual(manifestSchema.required, ['schemaVersion', 'createdAt', 'import', 'title', 'destination', 'components', 'notes', 'recipe']);
|
||||
});
|
||||
|
||||
test('manifest samples validate and preserve original names, hashes and provenance', () => {
|
||||
const source = classification([component('a'.repeat(24), 'Original Folder/Base Game.pkg', 'base'), component('b'.repeat(24), 'Updates/Patch.pkg', 'update', '1.02')]);
|
||||
const manifest = buildManifest(source, { titleName: 'Test: Game?' }, 2000);
|
||||
assert.deepEqual(validateManifest(manifest), { valid: true, errors: [] });
|
||||
assert.equal(manifest.title.name, 'Test Game');
|
||||
assert.equal(manifest.components[0].originalRelativePath, 'Original Folder/Base Game.pkg');
|
||||
assert.equal(manifest.components[0].sha256, source.components[0].sha256);
|
||||
assert.deepEqual(manifest.components[0].provenance, source.components[0].provenance);
|
||||
assert.equal(manifest.destination.relativePath, 'PS4-Games/Test Game [CUSA12345]');
|
||||
});
|
||||
|
||||
test('manifest JSON round trip rebuilds essential inventory without physical paths', () => {
|
||||
const manifest = buildManifest(classification([component('c'.repeat(24), 'base.pkg', 'base')]), { titleName: 'Round Trip' }, 2000);
|
||||
const restored = JSON.parse(JSON.stringify(manifest));
|
||||
const inventory = inventoryFromManifest(restored);
|
||||
assert.equal(inventory.title.titleId, 'CUSA12345');
|
||||
assert.equal(inventory.title.metadataSource, 'portable-manifest');
|
||||
assert.equal(inventory.localCopy.componentCount, 1);
|
||||
assert.equal(inventory.packages[0].sha256, manifest.components[0].sha256);
|
||||
assert.equal(JSON.stringify(inventory).includes('C:\\'), false);
|
||||
});
|
||||
|
||||
test('normalized preview is deterministic and resolves case collisions without path escapes', () => {
|
||||
const source = classification([component('d'.repeat(24), 'one/Game.pkg', 'base'), component('e'.repeat(24), 'two/game.pkg', 'base')]);
|
||||
const forward = previewNormalizedLayout(source, { titleName: '../Unsafe / Name' });
|
||||
const reverse = previewNormalizedLayout({ ...source, components: [...source.components].reverse() }, { titleName: '../Unsafe / Name' });
|
||||
assert.deepEqual(forward, reverse);
|
||||
assert.notEqual(forward.components[0].normalizedRelativePath.toLowerCase(), forward.components[1].normalizedRelativePath.toLowerCase());
|
||||
assert.equal(forward.destination.includes('..'), false);
|
||||
const invalid = buildManifest(classification([component('f'.repeat(24), 'base.pkg', 'base')]), {}, 2000);
|
||||
invalid.components[0].normalizedRelativePath = '../escape.pkg';
|
||||
assert.equal(validateManifest(invalid).valid, false);
|
||||
});
|
||||
|
||||
test('service writes .chimera manifest atomically and records its digest', () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'chimera-manifest-'));
|
||||
let summary = null;
|
||||
try {
|
||||
const value = classification([component('1'.repeat(24), 'base.pkg', 'base')]);
|
||||
const stagingStore = { getClassification: () => value, openForInspection: () => ({ payloadPath: root }), saveManifest: (_id, _owner, _manifest, sha256) => { summary = sha256; } };
|
||||
const service = createPortableManifestService({ stagingStore, now: () => 2000 });
|
||||
const result = service.create('job', 'alice', { titleName: 'Persisted' });
|
||||
const content = fs.readFileSync(path.join(root, '.chimera', 'manifest.json'), 'utf8');
|
||||
assert.equal(crypto.createHash('sha256').update(content).digest('hex'), result.sha256);
|
||||
assert.equal(summary, result.sha256);
|
||||
} finally { fs.rmSync(root, { recursive: true, force: true }); }
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
'use strict';
|
||||
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const test = require('node:test');
|
||||
const { deriveResumeState, nextFocusIndex, normalizeFileSelection, validateWizardStep } = require('../../renderer/modules/import-wizard');
|
||||
const { buildManifest, validateManifest } = require('../../src/services/portable-manifest-service');
|
||||
|
||||
const evidence = (value, source = 'test', confidence = 'high') => ({ value, source, confidence, observedAt: 1 });
|
||||
function component(id, type, relativePath) { return { id, type: evidence(type), relativePath, size: 4, sha256: id[0].repeat(64), platform: 'ps4', titleId: evidence('CUSA12345'), contentId: evidence(`UP0001-CUSA12345_00-${id.slice(0, 16).toUpperCase()}`), version: evidence('1.00'), provenance: [{ source: 'staging-sha256', confidence: 'verified', observedAt: 1 }] }; }
|
||||
function classification() {
|
||||
const components = [component('a'.repeat(24), 'base', 'base.pkg'), component('b'.repeat(24), 'patch_bundle', 'patches/fix.zip'), component('c'.repeat(24), 'image', 'art/cover.png')];
|
||||
return { status: 'classified', jobId: 'job', title: { id: evidence('CUSA12345'), platform: 'ps4' }, components, notes: [], recipeProposal: { installOrder: components.map((item, index) => ({ order: index + 1, componentId: item.id, componentType: item.type.value, action: 'proposal-only' })) } };
|
||||
}
|
||||
|
||||
test('folder and multi-file selections normalize relative names and reject empty files', () => {
|
||||
const selected = normalizeFileSelection([{ name: 'base.pkg', webkitRelativePath: 'Game/base.pkg', size: 4 }, { name: 'empty.txt', size: 0 }]);
|
||||
assert.deepEqual(selected.map(item => item.relativePath), ['Game/base.pkg']);
|
||||
});
|
||||
|
||||
test('resume progress uses durable server offsets', () => {
|
||||
assert.deepEqual(deriveResumeState({ state: 'uploading', files: [{ size: 8, offset: 4 }, { size: 2, bytesReceived: 2 }] }), { resumable: true, expected: 10, received: 6, percent: 60 });
|
||||
assert.equal(deriveResumeState({ state: 'uploaded', files: [] }).resumable, false);
|
||||
});
|
||||
|
||||
test('keyboard focus wraps in both directions and validation errors are explicit', () => {
|
||||
assert.equal(nextFocusIndex(2, 3, false), 0);
|
||||
assert.equal(nextFocusIndex(0, 3, true), 2);
|
||||
assert.equal(validateWizardStep({ step: 0, selection: [], session: null }), 'importWizard.error.selection');
|
||||
assert.equal(validateWizardStep({ step: 3, classification: { status: 'classified' }, destinationId: '' }), 'importWizard.error.destination');
|
||||
assert.equal(validateWizardStep({ step: 4, classification: { status: 'classified' }, destinationId: 'library', confirmed: false }), 'importWizard.error.confirm');
|
||||
});
|
||||
|
||||
test('editable order, non-executable notes, patch/APR and artwork selections persist in the manifest', () => {
|
||||
const source = classification();
|
||||
const order = source.components.map(item => item.id).reverse();
|
||||
const manifest = buildManifest(source, { titleName: 'Wizard', componentOrder: order, installNotes: 'Review first; never execute.', patchComponentIds: [source.components[1].id], artworkComponentIds: [source.components[2].id] }, 2);
|
||||
assert.deepEqual(manifest.recipe.installOrder.map(item => item.componentId), order);
|
||||
assert.equal(manifest.recipe.installNotes, 'Review first; never execute.');
|
||||
assert.deepEqual(manifest.recipe.attachments.patches, [{ componentId: source.components[1].id, type: 'patch_bundle' }]);
|
||||
assert.deepEqual(manifest.recipe.attachments.artwork, [{ componentId: source.components[2].id, type: 'image' }]);
|
||||
assert.equal(manifest.recipe.notesExecutable, false);
|
||||
assert.deepEqual(validateManifest(manifest), { valid: true, errors: [] });
|
||||
assert.throws(() => buildManifest(source, { componentOrder: [order[0], order[0], order[2]] }), error => error.code === 'MANIFEST_ORDER');
|
||||
});
|
||||
|
||||
test('wizard is core-loaded with responsive styling and complete English/Dutch keys', () => {
|
||||
const root = path.resolve(__dirname, '../..');
|
||||
const index = fs.readFileSync(path.join(root, 'renderer/index.html'), 'utf8');
|
||||
const bootstrap = fs.readFileSync(path.join(root, 'renderer/bootstrap.js'), 'utf8');
|
||||
const css = fs.readFileSync(path.join(root, 'renderer/styles/import-wizard.css'), 'utf8');
|
||||
const source = fs.readFileSync(path.join(root, 'renderer/modules/import-wizard.js'), 'utf8');
|
||||
const en = JSON.parse(fs.readFileSync(path.join(root, 'build/lang/en.json'), 'utf8'));
|
||||
const nl = JSON.parse(fs.readFileSync(path.join(root, 'build/lang/nl.json'), 'utf8'));
|
||||
assert.doesNotMatch(index, /styles\/import-wizard\.css/);
|
||||
assert.doesNotMatch(bootstrap, /modules\/import-wizard\.js/);
|
||||
assert.match(index, /type="module" src="modules\/import-wizard\.js"/);
|
||||
assert.match(source, /styles\/import-wizard\.css/);
|
||||
assert.match(css, /@media\(max-width:640px\)/);
|
||||
assert.match(source, /role=\"dialog\" aria-modal=\"true\"/);
|
||||
for (const key of ['importWizard.open', 'importWizard.resume', 'importWizard.reviewRequired', 'importWizard.finalizeAction']) { assert.equal(typeof en.strings[key], 'string'); assert.equal(typeof nl.strings[key], 'string'); }
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
'use strict';
|
||||
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const test = require('node:test');
|
||||
const initSqlJs = require('sql.js');
|
||||
const database = require('../../src/database');
|
||||
const { createV3Repositories } = require('../../src/services/v3-repositories');
|
||||
|
||||
function withDatabase(run) {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'chimera-package-sets-'));
|
||||
database.configure({ dataDir: root, logger: { info() {}, warn() {}, error() {} } });
|
||||
return database.initialize().then(run).finally(() => { database.close(); fs.rmSync(root, { recursive: true, force: true }); });
|
||||
}
|
||||
function seedTitle() {
|
||||
database.runMutatingSqlWithParams("INSERT INTO titles (platform,title_id,name,sort_name) VALUES ('ps4','CUSA12345','Package Test','Package Test')");
|
||||
return Number(database.runQuery('SELECT last_insert_rowid() AS id')[0].id);
|
||||
}
|
||||
const evidence = value => ({ value, source: 'pkg-param-sfo', confidence: 'high', observedAt: 1 });
|
||||
function manifest(name, suffix) {
|
||||
const base = `${suffix}`.repeat(24).slice(0, 24);
|
||||
const update = `${suffix === 'a' ? 'b' : 'c'}`.repeat(24);
|
||||
return { schemaVersion: 1, createdAt: 1, import: { jobId: `job-${suffix}`, source: 'managed-import', classificationStatus: 'classified' }, title: { platform: 'ps4', titleId: 'CUSA12345', name, provenance: evidence('CUSA12345') }, destination: { relativePath: `PS4-Games/${name} [CUSA12345]`, template: 'PS4-Games/<Title> [<TITLE_ID>]' }, components: [
|
||||
{ id: base, type: 'base', required: true, originalRelativePath: `${name}/base.pkg`, originalName: 'base.pkg', normalizedRelativePath: 'base/base.pkg', size: 4, sha256: '1'.repeat(64), platform: 'ps4', titleId: evidence('CUSA12345'), contentId: evidence('UP0001-CUSA12345_00-BASEPACKAGE000001'), version: evidence('1.00'), provenance: [{ source: 'staging-sha256', confidence: 'verified', observedAt: 1 }] },
|
||||
{ id: update, type: 'update', required: false, originalRelativePath: `${name}/update.pkg`, originalName: 'update.pkg', normalizedRelativePath: 'updates/v1.01/update.pkg', size: 5, sha256: '2'.repeat(64), platform: 'ps4', titleId: evidence('CUSA12345'), contentId: evidence('UP0001-CUSA12345_00-UPDATEPACKAGE001'), version: evidence('1.01'), provenance: [{ source: 'staging-sha256', confidence: 'verified', observedAt: 1 }] },
|
||||
], notes: [], recipe: { schema: 1, execution: 'disabled-until-validated', installOrder: [{ order: 1, componentId: base, componentType: 'base', action: 'proposal-only' }, { order: 2, componentId: update, componentType: 'update', action: 'proposal-only' }], installNotes: '', attachments: { patches: [], artwork: [] }, arbitraryCommands: [], notesExecutable: false } };
|
||||
}
|
||||
|
||||
test('migration creates constrained package set, component and dependency schemas', () => withDatabase(async () => {
|
||||
const tables = database.runQuery("SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'package_%'").map(row => row.name);
|
||||
assert.deepEqual(tables.sort(), ['package_component_dependencies', 'package_components', 'package_sets']);
|
||||
const SQL = await initSqlJs();
|
||||
const snapshot = new SQL.Database(database.exportDatabaseBuffer());
|
||||
const refs = table => snapshot.exec(`PRAGMA foreign_key_list('${table}')`)[0]?.values?.map(row => row[2]) || [];
|
||||
const componentRefs = refs('package_components');
|
||||
const dependencyRefs = refs('package_component_dependencies');
|
||||
assert.ok(componentRefs.includes('package_sets'));
|
||||
assert.ok(dependencyRefs.includes('package_components'));
|
||||
snapshot.close();
|
||||
assert.throws(() => database.runMutatingSqlWithParams("INSERT INTO package_components (package_set_id,component_key,component_type,original_relative_path,normalized_relative_path,size_bytes,sha256) VALUES (999,'x','shell','x','x',0,@hash)", { hash: '0'.repeat(64) }));
|
||||
}));
|
||||
|
||||
test('multiple variants per title persist complete component evidence and dependencies', () => withDatabase(() => {
|
||||
const titleId = seedTitle();
|
||||
const repository = createV3Repositories(database).packageSets;
|
||||
const firstManifest = manifest('Retail', 'a');
|
||||
const secondManifest = manifest('Modded', 'd');
|
||||
repository.importManifest(titleId, firstManifest, { setKey: 'retail-us', variantKey: 'retail', region: 'US', dependencies: [{ componentId: firstManifest.components[1].id, dependsOnComponentId: firstManifest.components[0].id, kind: 'requires' }] });
|
||||
repository.importManifest(titleId, secondManifest, { setKey: 'modded-eu', variantKey: 'modded', region: 'EU' });
|
||||
const sets = repository.listByTitle(titleId);
|
||||
assert.equal(sets.length, 2);
|
||||
assert.deepEqual(sets.map(item => item.variantKey), ['retail', 'modded']);
|
||||
assert.equal(sets[0].components[0].version, '1.00');
|
||||
assert.equal(sets[0].components[0].region, 'US');
|
||||
assert.equal(sets[0].components[0].sha256, '1'.repeat(64));
|
||||
assert.equal(sets[0].components[0].required, true);
|
||||
assert.equal(sets[0].components[0].provenance[0].confidence, 'verified');
|
||||
assert.deepEqual(sets[0].dependencies, [{ componentId: firstManifest.components[1].id, dependsOnComponentId: firstManifest.components[0].id, kind: 'requires' }]);
|
||||
}));
|
||||
|
||||
test('portable manifest maps losslessly while normalized rows remain queryable', () => withDatabase(() => {
|
||||
const titleId = seedTitle();
|
||||
const repository = createV3Repositories(database).packageSets;
|
||||
const source = manifest('Round Trip', 'e');
|
||||
const saved = repository.importManifest(titleId, source, { setKey: 'round-trip', variantKey: 'disc' });
|
||||
assert.deepEqual(repository.manifestRoundTrip(saved.id), source);
|
||||
assert.deepEqual(saved.manifest, source);
|
||||
assert.equal(saved.manifestSchemaVersion, 1);
|
||||
assert.match(saved.manifestSha256, /^[0-9a-f]{64}$/);
|
||||
assert.equal(saved.components[1].normalizedRelativePath, source.components[1].normalizedRelativePath);
|
||||
assert.equal(saved.components[1].contentId, source.components[1].contentId.value);
|
||||
}));
|
||||
@@ -0,0 +1,60 @@
|
||||
'use strict';
|
||||
|
||||
const assert = require('node:assert/strict');
|
||||
const test = require('node:test');
|
||||
const { createInstallRecipeService } = require('../../src/services/install-recipe-service');
|
||||
|
||||
const component = (componentId, type, options = {}) => ({ componentId, type, required: options.required ?? true, version: options.version || '1.00', contentId: options.contentId || `UP0001-CUSA12345_00-${componentId.toUpperCase().padEnd(16, 'X')}`, region: options.region || 'US', metadata: { platform: 'ps4', titleId: { value: 'CUSA12345' } } });
|
||||
function packageSet() {
|
||||
return { id: 7, platform: 'ps4', region: 'US', components: [component('base', 'base'), component('update', 'update', { version: '1.02' }), component('dlc', 'dlc', { required: false })], dependencies: [{ componentId: 'update', dependsOnComponentId: 'base', kind: 'requires' }] };
|
||||
}
|
||||
const step = (id, componentId, action = 'install-package', options = {}) => ({ id, componentId, action, required: options.required ?? true, dependsOn: options.dependsOn || [], destinationCapabilities: options.destinationCapabilities || [], orderHint: options.orderHint });
|
||||
const recipe = steps => ({ schemaVersion: 1, recipeVersion: 3, packageSetId: 7, notes: 'Read-only operator guidance.', steps });
|
||||
const capabilities = { canInstall: true, canCopy: true, canMount: true, canRead: true, stagingPatch: true };
|
||||
|
||||
test('DAG validation produces deterministic base-before-update order and frozen job version', () => {
|
||||
const service = createInstallRecipeService({ now: () => 1000 });
|
||||
const input = recipe([step('update-step', 'update', 'install-package', { orderHint: 1 }), step('base-step', 'base', 'install-package', { orderHint: 2 })]);
|
||||
const first = service.validate(input, packageSet(), { destinationCapabilities: capabilities });
|
||||
const second = service.validate(input, packageSet(), { destinationCapabilities: capabilities });
|
||||
assert.deepEqual(first.orderedSteps.map(item => item.id), ['base-step', 'update-step']);
|
||||
assert.deepEqual(first.orderedSteps, second.orderedSteps);
|
||||
const frozen = service.freezeForJob('job-1', input, packageSet(), { destinationCapabilities: capabilities, expectedRecipeVersion: 3 });
|
||||
assert.equal(frozen.recipeVersion, 3);
|
||||
assert.match(frozen.recipeSha256, /^[0-9a-f]{64}$/);
|
||||
assert.equal(Object.isFrozen(frozen.recipe.orderedSteps), true);
|
||||
assert.throws(() => service.freezeForJob('job-1', input, packageSet(), { destinationCapabilities: capabilities, expectedRecipeVersion: 2 }), error => error.code === 'RECIPE_VERSION_CHANGED');
|
||||
});
|
||||
|
||||
test('cycles and missing components/dependencies are rejected', () => {
|
||||
const service = createInstallRecipeService();
|
||||
const cyclic = recipe([step('base-step', 'base', 'install-package', { dependsOn: ['update-step'] }), step('update-step', 'update', 'install-package', { dependsOn: ['base-step'] })]);
|
||||
assert.throws(() => service.validate(cyclic, packageSet(), { destinationCapabilities: capabilities }), error => error.code === 'RECIPE_CYCLE');
|
||||
assert.throws(() => service.validate(recipe([step('base-step', 'missing')]), packageSet(), { destinationCapabilities: capabilities }), error => error.code === 'RECIPE_COMPONENT_MISSING');
|
||||
assert.throws(() => service.validate(recipe([step('base-step', 'base'), step('update-step', 'update', 'install-package', { dependsOn: ['gone'] })]), packageSet(), { destinationCapabilities: capabilities }), error => error.code === 'RECIPE_DEPENDENCY_MISSING');
|
||||
});
|
||||
|
||||
test('base/update/DLC content, region, platform and destination mismatches fail closed', () => {
|
||||
const service = createInstallRecipeService();
|
||||
const mismatched = packageSet(); mismatched.components[1].contentId = 'UP0001-CUSA99999_00-UPDATEPACKAGE001';
|
||||
assert.throws(() => service.validate(recipe([step('base-step', 'base'), step('update-step', 'update')]), mismatched, { destinationCapabilities: capabilities }), error => error.code === 'RECIPE_CONTENT_MISMATCH');
|
||||
const wrongRegion = packageSet(); wrongRegion.components[1].region = 'EU';
|
||||
assert.throws(() => service.validate(recipe([step('base-step', 'base'), step('update-step', 'update')]), wrongRegion, { destinationCapabilities: capabilities }), error => error.code === 'RECIPE_REGION_MISMATCH');
|
||||
assert.throws(() => service.validate(recipe([step('base-step', 'base'), step('update-step', 'update')]), packageSet(), { destinationCapabilities: { ...capabilities, canInstall: false } }), error => error.code === 'RECIPE_DESTINATION_CAPABILITY');
|
||||
});
|
||||
|
||||
test('required components cannot be omitted while optional DLC may be absent', () => {
|
||||
const service = createInstallRecipeService();
|
||||
const valid = service.validate(recipe([step('base-step', 'base'), step('update-step', 'update')]), packageSet(), { destinationCapabilities: capabilities });
|
||||
assert.equal(valid.orderedSteps.length, 2);
|
||||
assert.throws(() => service.validate(recipe([step('base-step', 'base')]), packageSet(), { destinationCapabilities: capabilities }), error => error.code === 'RECIPE_REQUIRED_COMPONENT');
|
||||
assert.throws(() => service.validate(recipe([step('base-step', 'base', 'install-package', { required: false }), step('update-step', 'update')]), packageSet(), { destinationCapabilities: capabilities }), error => error.code === 'RECIPE_REQUIRED_COMPONENT');
|
||||
});
|
||||
|
||||
test('hostile schema fields, shell actions and raw destinations are never accepted', () => {
|
||||
const service = createInstallRecipeService();
|
||||
const base = recipe([step('base-step', 'base'), step('update-step', 'update')]);
|
||||
assert.throws(() => service.validate({ ...base, command: 'rm -rf /' }, packageSet(), { destinationCapabilities: capabilities }), error => error.code === 'RECIPE_SCHEMA');
|
||||
assert.throws(() => service.validate({ ...base, steps: [{ ...base.steps[0], shell: 'echo owned' }, base.steps[1]] }, packageSet(), { destinationCapabilities: capabilities }), error => error.code === 'RECIPE_SCHEMA');
|
||||
assert.throws(() => service.validate({ ...base, steps: [{ ...base.steps[0], action: 'shell', destination: '/mnt/raw' }, base.steps[1]] }, packageSet(), { destinationCapabilities: capabilities }), error => error.code === 'RECIPE_SCHEMA');
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
'use strict';
|
||||
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const test = require('node:test');
|
||||
const database = require('../../src/database');
|
||||
const { createInstallRecipeRepository } = require('../../src/services/install-recipe-repository');
|
||||
const { createV3Repositories } = require('../../src/services/v3-repositories');
|
||||
const { draftFromPackageSet, editorError, moveItem } = require('../../src/renderer/recipe-editor');
|
||||
|
||||
function withDatabase(run) { const root = fs.mkdtempSync(path.join(os.tmpdir(), 'chimera-recipe-editor-')); database.configure({ dataDir: root, logger: { info() {}, warn() {}, error() {} } }); return database.initialize().then(run).finally(() => { database.close(); fs.rmSync(root, { recursive: true, force: true }); }); }
|
||||
function packageSet() { return { id: 1, titleId: 1, setKey: 'retail', label: 'Retail US', variantKey: 'retail-us', platform: 'ps4', region: 'US', components: [{ componentId: 'base', type: 'base', required: true, version: '1.00', contentId: 'UP0001-CUSA12345_00-BASEPACKAGE000001', region: 'US', metadata: { platform: 'ps4', titleId: { value: 'CUSA12345' } }, adapterHint: 'ps4-package' }, { componentId: 'update', type: 'update', required: true, version: '1.02', contentId: 'UP0001-CUSA12345_00-UPDATEPACKAGE001', region: 'US', metadata: { platform: 'ps4', titleId: { value: 'CUSA12345' } }, adapterHint: 'ps4-package' }], dependencies: [{ componentId: 'update', dependsOnComponentId: 'base', kind: 'requires' }] }; }
|
||||
|
||||
test('keyboard move alternative and actionable UI error projection are deterministic', () => {
|
||||
assert.deepEqual(moveItem(['base', 'update'], 1, -1), ['update', 'base']);
|
||||
assert.deepEqual(moveItem(['base', 'update'], 0, -1), ['base', 'update']);
|
||||
assert.deepEqual(editorError(Object.assign(new Error('Cycle between base and update'), { code: 'RECIPE_CYCLE' })), { titleKey: 'recipeEditor.errorTitle', message: 'Cycle between base and update', code: 'RECIPE_CYCLE' });
|
||||
const draft = draftFromPackageSet(packageSet());
|
||||
assert.deepEqual(draft[1].dependsOn, ['step-1']);
|
||||
});
|
||||
|
||||
test('saving creates immutable incrementing recipe versions and rejects stale editors', () => withDatabase(() => {
|
||||
database.runMutatingSqlWithParams("INSERT INTO titles (id,platform,title_id,name,sort_name) VALUES (1,'ps4','CUSA12345','Recipe','Recipe')");
|
||||
const set = packageSet();
|
||||
createV3Repositories(database).packageSets.savePackageSet({ ...set, components: set.components.map((item, index) => ({ ...item, originalRelativePath: `${item.componentId}.pkg`, normalizedRelativePath: `${item.componentId}.pkg`, size: 4, sha256: String(index + 1).repeat(64), provenance: [{ source: 'test' }] })) });
|
||||
const savedSet = createV3Repositories(database).packageSets.listByTitle(1)[0];
|
||||
const repository = createInstallRecipeRepository({ db: database, now: () => 10 });
|
||||
const steps = draftFromPackageSet(savedSet);
|
||||
const first = repository.saveVersion({ packageSet: savedSet, recipeKey: 'safe', label: 'Safe', notes: 'Read first', steps, expectedVersion: 0 });
|
||||
const second = repository.saveVersion({ packageSet: savedSet, recipeKey: 'safe', label: 'Safe', notes: 'Read again', steps, expectedVersion: 1 });
|
||||
assert.equal(first.version, 1); assert.equal(second.version, 2); assert.notEqual(first.recipeSha256, second.recipeSha256);
|
||||
assert.equal(repository.listByTitle(1).length, 2);
|
||||
assert.throws(() => repository.saveVersion({ packageSet: savedSet, recipeKey: 'safe', notes: 'stale', steps, expectedVersion: 1 }), error => error.code === 'RECIPE_VERSION_CONFLICT');
|
||||
assert.equal(database.runQuery('SELECT COUNT(*) AS c FROM install_recipe_steps')[0].c, 4);
|
||||
}));
|
||||
|
||||
test('editor exposes drag and keyboard controls, live errors, version selection and expert disclosure', () => {
|
||||
const root = path.resolve(__dirname, '../..');
|
||||
const source = fs.readFileSync(path.join(root, 'src/renderer/recipe-editor.js'), 'utf8');
|
||||
const css = fs.readFileSync(path.join(root, 'src/renderer/recipe-editor.css'), 'utf8');
|
||||
assert.match(source, /draggable=\"true\"/); assert.match(source, /data-step-up/); assert.match(source, /aria-live=\"polite\"/); assert.match(source, /data-recipe-select/); assert.match(source, /data-recipe-version/); assert.match(source, /recipe-expert/);
|
||||
assert.match(css, /body:not\(\.chimera-expert-mode\) \.recipe-expert/); assert.match(css, /@media\(max-width:650px\)/);
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
'use strict';
|
||||
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const test = require('node:test');
|
||||
const { createInstallOrchestratorService } = require('../../src/services/install-orchestrator-service');
|
||||
|
||||
const component = (componentId, type, size, hash, required = true) => ({ componentId, type, size, sha256: hash.repeat(64), required, version: type === 'update' ? '1.02' : '1.00', contentId: `UP0001-CUSA12345_00-${componentId.toUpperCase().padEnd(16, 'X')}`, region: 'US', metadata: { platform: 'ps4', titleId: { value: 'CUSA12345' } } });
|
||||
const packageSet = () => ({ id: 1, platform: 'ps4', region: 'US', components: [component('base', 'base', 4, '1'), component('update', 'update', 2, '2')], dependencies: [{ componentId: 'update', dependsOnComponentId: 'base', kind: 'requires' }] });
|
||||
const recipe = () => ({ schemaVersion: 1, recipeVersion: 1, packageSetId: 1, notes: 'simulator only', steps: [{ id: 'base', componentId: 'base', action: 'install-package', required: true, dependsOn: [], destinationCapabilities: [] }, { id: 'update', componentId: 'update', action: 'install-package', required: true, dependsOn: ['base'], destinationCapabilities: [] }] });
|
||||
const target = () => ({ consoleProfileId: 'simulator', volumeId: 'internal', freeBytes: 100, destinationCapabilities: { canInstall: true, canCopy: true, canMount: true, canRead: true, stagingPatch: true } });
|
||||
function fixtures(root, overrides = {}) {
|
||||
const executions = [];
|
||||
const adapter = { probe: async () => ({ available: true, version: 'sim-1' }), execute: async input => { executions.push(input.component.componentId); return { staged: input.component.componentId }; }, verify: async input => ({ verified: true, observed: input.component.componentId }) };
|
||||
return { storageFile: path.join(root, 'jobs.json'), adapters: { 'install-package': adapter }, artifactStore: { verify: async item => ({ verified: true, sha256: item.sha256, size: item.size }) }, recovery: { prepare: async ({ jobId }) => ({ id: `recovery-${jobId}`, retained: true }) }, finalVerification: { verify: async () => ({ verified: true, scanId: 'scan-1' }) }, audit() {}, uuid: () => 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', executions, ...overrides };
|
||||
}
|
||||
|
||||
test('restart preserves completed verified children and resumes only remaining work', async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'chimera-orchestrator-resume-'));
|
||||
let interrupted = false;
|
||||
try {
|
||||
const firstFixtures = fixtures(root, { hooks: { afterChildVerified: async (_job, child) => { if (child.stepId === 'base') { interrupted = true; throw Object.assign(new Error('power loss'), { code: 'PROCESS_INTERRUPTED' }); } } } });
|
||||
const first = createInstallOrchestratorService(firstFixtures);
|
||||
const created = await first.create({ recipe: recipe(), packageSet: packageSet(), target: target(), expectedRecipeVersion: 1, idempotencyKey: 'install-one' });
|
||||
await assert.rejects(() => first.run(created.job.id), error => error.code === 'PROCESS_INTERRUPTED');
|
||||
assert.equal(interrupted, true);
|
||||
const secondFixtures = fixtures(root);
|
||||
const second = createInstallOrchestratorService(secondFixtures);
|
||||
assert.equal(second.get(created.job.id).status, 'interrupted');
|
||||
assert.equal(second.get(created.job.id).children[0].status, 'completed');
|
||||
second.resume(created.job.id);
|
||||
const completed = await second.run(created.job.id);
|
||||
assert.equal(completed.status, 'completed');
|
||||
assert.deepEqual(secondFixtures.executions, ['update']);
|
||||
} finally { fs.rmSync(root, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
test('child failure blocks dependents and preserves recovery evidence', async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'chimera-orchestrator-fail-'));
|
||||
try {
|
||||
const values = fixtures(root); values.adapters['install-package'].execute = async input => { if (input.component.componentId === 'base') throw new Error('simulated adapter failure'); return {}; };
|
||||
const service = createInstallOrchestratorService(values);
|
||||
const created = await service.create({ recipe: recipe(), packageSet: packageSet(), target: target(), expectedRecipeVersion: 1, idempotencyKey: 'install-fail' });
|
||||
const failed = await service.run(created.job.id);
|
||||
assert.equal(failed.status, 'failed');
|
||||
assert.equal(failed.children[0].status, 'failed');
|
||||
assert.equal(failed.children[1].status, 'blocked');
|
||||
assert.match(failed.recovery.id, /^recovery-/);
|
||||
} finally { fs.rmSync(root, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
test('idempotency deduplicates parents and final success waits for verification', async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'chimera-orchestrator-idem-'));
|
||||
let finalChecks = 0;
|
||||
try {
|
||||
const values = fixtures(root, { finalVerification: { verify: async () => { finalChecks += 1; return { verified: false }; } } });
|
||||
const service = createInstallOrchestratorService(values);
|
||||
const input = { recipe: recipe(), packageSet: packageSet(), target: target(), expectedRecipeVersion: 1, idempotencyKey: 'same-operation' };
|
||||
const first = await service.create(input); const second = await service.create(input);
|
||||
assert.equal(second.deduplicated, true); assert.equal(second.job.id, first.job.id);
|
||||
const result = await service.run(first.job.id);
|
||||
assert.equal(result.status, 'needs_attention'); assert.equal(finalChecks, 1); assert.equal(values.executions.length, 2);
|
||||
} finally { fs.rmSync(root, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
test('preflight blocks bad hashes/space/services and cancel keeps recovery', async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'chimera-orchestrator-preflight-'));
|
||||
try {
|
||||
await assert.rejects(() => createInstallOrchestratorService(fixtures(root, { artifactStore: { verify: async item => ({ verified: true, sha256: '0'.repeat(64), size: item.size }) } })).create({ recipe: recipe(), packageSet: packageSet(), target: target(), expectedRecipeVersion: 1, idempotencyKey: 'bad-hash' }), error => error.code === 'INSTALL_ARTIFACT_INVALID');
|
||||
await assert.rejects(() => createInstallOrchestratorService(fixtures(root)).create({ recipe: recipe(), packageSet: packageSet(), target: { ...target(), freeBytes: 1 }, expectedRecipeVersion: 1, idempotencyKey: 'no-space' }), error => error.code === 'INSTALL_SPACE');
|
||||
const service = createInstallOrchestratorService(fixtures(root));
|
||||
const created = await service.create({ recipe: recipe(), packageSet: packageSet(), target: target(), expectedRecipeVersion: 1, idempotencyKey: 'cancel-safe' });
|
||||
const cancelled = await service.cancel(created.job.id);
|
||||
assert.equal(cancelled.status, 'cancelled'); assert.equal(cancelled.recovery.retained, true); assert.equal(cancelled.children.every(child => child.status === 'blocked'), true);
|
||||
} finally { fs.rmSync(root, { recursive: true, force: true }); }
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
'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 { PINNED_ETAHEN, createPs4PkgInstallerAdapter, validateOrder } = require('../../src/services/ps4-pkg-installer-adapter');
|
||||
|
||||
const evidence = () => ({ ...PINNED_ETAHEN });
|
||||
const component = (type = 'base', data = 'pkg') => ({ componentId: type, type, size: Buffer.byteLength(data), sha256: crypto.createHash('sha256').update(data).digest('hex') });
|
||||
function adapter(overrides = {}) {
|
||||
let postScanVerified = overrides.postScanVerified ?? true;
|
||||
const calls = [];
|
||||
const value = createPs4PkgInstallerAdapter({ mode: overrides.mode || 'dpi', versionEvidenceProvider: overrides.versionEvidenceProvider || evidence, transport: overrides.transport || { probe: async () => ({ reachable: true }), handoff: async input => { calls.push(input); return { res: '0' }; } }, urlPolicy: overrides.urlPolicy || (async input => ({ allowed: true, url: input.url })), artifactServer: overrides.artifactServer, allowedRoots: overrides.allowedRoots || [], postScan: { verify: async () => ({ verified: postScanVerified, scanId: 'scan-1' }) }, timeoutMs: overrides.timeoutMs || 50 });
|
||||
return { value, calls, setPostScan(value) { postScanVerified = value; } };
|
||||
}
|
||||
|
||||
test('version-pinned fake DPI accepts handoff but only post-scan returns installed evidence', async () => {
|
||||
const fixture = adapter();
|
||||
const probe = await fixture.value.probe();
|
||||
assert.equal(probe.available, true); assert.equal(probe.version, '2.5B'); assert.equal(probe.capabilities.installedEvidenceFromResponse, false);
|
||||
const item = component();
|
||||
const output = await fixture.value.execute({ component: item, handoff: { url: 'https://packages.example/base.pkg' } });
|
||||
assert.deepEqual({ handoffAccepted: output.handoffAccepted, installed: output.installed, requiresPostScan: output.requiresPostScan }, { handoffAccepted: true, installed: false, requiresPostScan: true });
|
||||
fixture.setPostScan(false); assert.equal((await fixture.value.verify({ component: item, target: {}, outputEvidence: output })).verified, false);
|
||||
fixture.setPostScan(true); const verified = await fixture.value.verify({ component: item, target: {}, outputEvidence: output }); assert.equal(verified.verified, true); assert.equal(verified.source, 'post-install-scan');
|
||||
});
|
||||
|
||||
test('base, update, DLC and unlocker order is deterministic and invalid order fails', () => {
|
||||
assert.deepEqual(validateOrder(['base', 'update', 'dlc', 'unlocker'].map(type => ({ componentId: type, type }))), ['base', 'update', 'dlc', 'unlocker']);
|
||||
assert.throws(() => validateOrder([{ componentId: 'update', type: 'update' }, { componentId: 'base', type: 'base' }]), error => error.code === 'PKG_INSTALL_ORDER');
|
||||
assert.throws(() => validateOrder([{ componentId: 'base1', type: 'base' }, { componentId: 'base2', type: 'base' }]), error => error.code === 'PKG_INSTALL_ORDER');
|
||||
});
|
||||
|
||||
test('file handoff enforces containment, exact size/hash and hash-bound URL policy', async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'chimera-pkg-adapter-'));
|
||||
const outside = fs.mkdtempSync(path.join(os.tmpdir(), 'chimera-pkg-outside-'));
|
||||
try {
|
||||
const filePath = path.join(root, 'base.pkg'); fs.writeFileSync(filePath, 'pkg');
|
||||
const fixture = adapter({ allowedRoots: [root], artifactServer: { expose: async input => ({ url: `https://packages.example/${input.sha256}.pkg` }) }, urlPolicy: async input => ({ allowed: input.url.includes(input.sha256), url: input.url }) });
|
||||
const output = await fixture.value.execute({ component: component(), handoff: { filePath } }); assert.equal(output.handoffAccepted, true);
|
||||
const outsidePath = path.join(outside, 'base.pkg'); fs.writeFileSync(outsidePath, 'pkg');
|
||||
await assert.rejects(() => fixture.value.execute({ component: component(), handoff: { filePath: outsidePath } }), error => error.code === 'PKG_HANDOFF_CONTAINMENT');
|
||||
await assert.rejects(() => fixture.value.execute({ component: { ...component(), sha256: '0'.repeat(64) }, handoff: { filePath } }), error => error.code === 'PKG_HANDOFF_HASH');
|
||||
const linkedDirectory = path.join(root, 'linked-outside');
|
||||
fs.symlinkSync(outside, linkedDirectory, process.platform === 'win32' ? 'junction' : 'dir');
|
||||
let exposed = 0;
|
||||
const hostile = adapter({ allowedRoots: [root], artifactServer: { expose: async () => { exposed += 1; return { url: 'https://packages.example/unsafe.pkg' }; } } });
|
||||
await assert.rejects(() => hostile.value.execute({ component: component(), handoff: { filePath: path.join(linkedDirectory, 'base.pkg') } }), error => error.code === 'PKG_HANDOFF_CONTAINMENT');
|
||||
assert.equal(exposed, 0);
|
||||
} finally { fs.rmSync(root, { recursive: true, force: true }); fs.rmSync(outside, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
test('unsupported version, DPIv2, malformed service, failure and timeout fail safely', async () => {
|
||||
assert.equal((await adapter({ versionEvidenceProvider: async () => ({ ...PINNED_ETAHEN, version: 'future' }) }).value.probe()).available, false);
|
||||
assert.equal((await adapter({ mode: 'dpi-v2' }).value.probe()).reason, 'protocol-unverified');
|
||||
await assert.rejects(() => adapter({ transport: { probe: async () => ({ reachable: true }), handoff: async () => ({ ok: true }) } }).value.execute({ component: component(), handoff: { url: 'https://packages.example/base.pkg' } }), error => error.code === 'PKG_INSTALL_RESPONSE');
|
||||
await assert.rejects(() => adapter({ transport: { probe: async () => ({ reachable: false, error: 'offline' }), handoff: async () => ({ res: 0 }) } }).value.execute({ component: component(), handoff: { url: 'https://packages.example/base.pkg' } }), error => error.code === 'PKG_INSTALL_UNAVAILABLE');
|
||||
await assert.rejects(() => adapter({ timeoutMs: 5, transport: { probe: async () => ({ reachable: true }), handoff: () => new Promise(() => {}) } }).value.execute({ component: component(), handoff: { url: 'https://packages.example/base.pkg' } }), error => error.code === 'PKG_INSTALL_TIMEOUT');
|
||||
});
|
||||
@@ -0,0 +1,87 @@
|
||||
'use strict';
|
||||
|
||||
const assert = require('node:assert/strict');
|
||||
const test = require('node:test');
|
||||
const {
|
||||
SHADOWMOUNT_PLUS_PROFILE,
|
||||
createPs5ContentAdapterService,
|
||||
createPs5ContentSimulator,
|
||||
unavailableLiveAdapter,
|
||||
} = require('../../src/services/ps5-content-adapter-service');
|
||||
|
||||
const DIGEST = 'ab'.repeat(32);
|
||||
const artifact = kind => ({ kind, sourceRef: `library:item-${kind}`, fileName: kind === 'image' ? 'PPSA12345.ffpkg' : 'PPSA12345', sha256: DIGEST, size: 4096 });
|
||||
const volumeResolver = { resolve: async id => ({ id, adapterId: 'ShadowMountPlus', label: 'USB games', consoleRoots: ['/mnt/usb3'] }) };
|
||||
|
||||
test('simulator preserves transfer, stage, mount, register and verification as distinct states', async () => {
|
||||
const simulator = createPs5ContentSimulator();
|
||||
const service = createPs5ContentAdapterService({ adapter: simulator.adapter, volumeResolver, uuid: () => 'run-image', now: (() => { let n = 0; return () => ++n; })() });
|
||||
const result = await service.start({ titleId: 'PPSA12345', volumeId: 'usb-games', artifact: artifact('image') });
|
||||
assert.equal(result.state, 'verified');
|
||||
assert.deepEqual(result.history.map(item => item.state), ['planned', 'transferring', 'transferred', 'staging', 'staged', 'mounting', 'mounted', 'registering', 'registered', 'verifying', 'verified']);
|
||||
assert.deepEqual(simulator.calls, ['transfer', 'stage', 'mount', 'register', 'postMountVerify']);
|
||||
assert.equal(result.paths.transferPath, '/mnt/usb3/.chimera-transfer/PPSA12345/PPSA12345.ffpkg');
|
||||
assert.equal(result.paths.stagedPath, '/mnt/usb3/etaHEN/games/PPSA12345/PPSA12345.ffpkg');
|
||||
assert.equal(result.paths.mountPath, '/mnt/shadowmnt/PPSA12345_simulated');
|
||||
assert.equal('installed' in result, false);
|
||||
});
|
||||
|
||||
test('folder simulator uses the selected semantic volume and still requires post-mount verification', async () => {
|
||||
const simulator = createPs5ContentSimulator();
|
||||
const result = await createPs5ContentAdapterService({ adapter: simulator.adapter, volumeResolver }).start({ titleId: 'PPSA54321', volumeId: 'portable-library', artifact: artifact('folder') });
|
||||
assert.equal(result.state, 'verified');
|
||||
assert.match(result.paths.stagedPath, /^\/mnt\/usb3\/etaHEN\/games\/PPSA54321\//);
|
||||
assert.equal(result.paths.mountPath, result.paths.stagedPath);
|
||||
assert.ok(result.verificationEvidence);
|
||||
});
|
||||
|
||||
test('unavailable, unpinned and incomplete capability providers fail closed before mutation', async () => {
|
||||
const unavailable = createPs5ContentAdapterService({ adapter: unavailableLiveAdapter(), volumeResolver });
|
||||
assert.equal(unavailable.probe().compatible, false);
|
||||
await assert.rejects(unavailable.start({ titleId: 'PPSA12345', volumeId: 'usb-games', artifact: artifact('image') }), /unavailable or capability mismatch/i);
|
||||
|
||||
for (const probe of [
|
||||
{ version: '1.6test12' },
|
||||
{ revision: '00'.repeat(20) },
|
||||
{ capabilities: { semanticVolumePaths: true, transfer: true } },
|
||||
]) {
|
||||
const simulator = createPs5ContentSimulator({ probe });
|
||||
const service = createPs5ContentAdapterService({ adapter: simulator.adapter, volumeResolver });
|
||||
assert.equal(service.probe().compatible, false);
|
||||
await assert.rejects(service.start({ titleId: 'PPSA12345', volumeId: 'usb-games', artifact: artifact('image') }), new RegExp(SHADOWMOUNT_PLUS_PROFILE.id));
|
||||
assert.deepEqual(simulator.calls, []);
|
||||
}
|
||||
});
|
||||
|
||||
test('adapter-derived paths cannot escape the selected volume or use traversal', async () => {
|
||||
for (const derived of [
|
||||
{ transferPath: '/mnt/usb3/../system/PPSA12345.ffpkg', stagedPath: '/mnt/usb3/games/PPSA12345.ffpkg' },
|
||||
{ transferPath: '/mnt/usb3/incoming/PPSA12345.ffpkg', stagedPath: '/data/etaHEN/games/PPSA12345.ffpkg' },
|
||||
{ transferPath: 'relative/PPSA12345.ffpkg', stagedPath: '/mnt/usb3/games/PPSA12345.ffpkg' },
|
||||
]) {
|
||||
const simulator = createPs5ContentSimulator({ overrides: { derivePaths: () => derived } });
|
||||
await assert.rejects(
|
||||
createPs5ContentAdapterService({ adapter: simulator.adapter, volumeResolver }).start({ titleId: 'PPSA12345', volumeId: 'usb-games', artifact: artifact('image') }),
|
||||
/invalid|unsafe|outside/i,
|
||||
);
|
||||
assert.deepEqual(simulator.calls, []);
|
||||
}
|
||||
});
|
||||
|
||||
test('a timed-out adapter step stops before mount, register and verification', async () => {
|
||||
const simulator = createPs5ContentSimulator({ delayStep: 'stage', delayMs: 30 });
|
||||
const service = createPs5ContentAdapterService({ adapter: simulator.adapter, volumeResolver, timeoutMs: 5, uuid: () => 'run-timeout' });
|
||||
const result = await service.start({ titleId: 'PPSA12345', volumeId: 'usb-games', artifact: artifact('image') });
|
||||
assert.equal(result.state, 'needs_attention');
|
||||
assert.equal(result.failedStep, 'stage');
|
||||
assert.equal(result.error, 'timeout:stage');
|
||||
assert.deepEqual(simulator.calls, ['transfer', 'stage']);
|
||||
});
|
||||
|
||||
test('registration handoff alone never passes without exact post-mount evidence', async () => {
|
||||
const simulator = createPs5ContentSimulator({ overrides: { verifyPostMount: async input => ({ mounted: true, registered: false, titleId: input.titleId, mountPath: input.mountPath }) } });
|
||||
const result = await createPs5ContentAdapterService({ adapter: simulator.adapter, volumeResolver }).start({ titleId: 'PPSA12345', volumeId: 'usb-games', artifact: artifact('image') });
|
||||
assert.equal(result.state, 'needs_attention');
|
||||
assert.equal(result.failedStep, 'verifying');
|
||||
assert.match(result.error, /did not prove/);
|
||||
});
|
||||
@@ -0,0 +1,107 @@
|
||||
'use strict';
|
||||
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const test = require('node:test');
|
||||
const { createInstallOrchestratorService } = require('../../src/services/install-orchestrator-service');
|
||||
const { createPostInstallVerificationService } = require('../../src/services/post-install-verification-service');
|
||||
|
||||
const HASH = 'ab'.repeat(32);
|
||||
const job = () => ({
|
||||
id: 'job-verify',
|
||||
recipeSnapshot: { recipeSha256: 'cd'.repeat(32), recipe: { orderedSteps: [{ id: 'base-step', componentId: 'base', required: true }, { id: 'update-step', componentId: 'update', required: true }] } },
|
||||
packageSet: { components: [
|
||||
{ componentId: 'base', type: 'base', version: '1.00', metadata: { titleId: { value: 'CUSA12345' } } },
|
||||
{ componentId: 'update', type: 'update', version: '1.02', metadata: { titleId: { value: 'CUSA12345' } } },
|
||||
] },
|
||||
target: { consoleProfileId: 'living-room', volumeId: 'console-m2' },
|
||||
});
|
||||
|
||||
function scannerWith(response, queries = []) {
|
||||
return { query: async query => { queries.push(query); return structuredClone(response); } };
|
||||
}
|
||||
|
||||
const scan = installs => ({ available: true, coverageComplete: true, scanRunId: 77, completedAt: 1234, installs });
|
||||
const observed = overrides => ({ titleId: 'CUSA12345', version: '1.02', volumeId: 'console-m2', installState: 'observed', location: '/mnt/ext0/CUSA12345', evidence: { provider: 'ftp', scanRunId: 77 }, ...overrides });
|
||||
|
||||
test('confirmed evidence is recipe-specific and is the only result marked installed', async () => {
|
||||
const queries = [];
|
||||
const service = createPostInstallVerificationService({ scanner: scannerWith(scan([observed()]), queries) });
|
||||
const result = await service.verify({ job: job() });
|
||||
assert.equal(result.verified, true);
|
||||
assert.equal(result.installState, 'installed');
|
||||
assert.equal(result.outcome, 'confirmed');
|
||||
assert.deepEqual(queries[0].titles, [{ titleId: 'CUSA12345', version: '1.02' }]);
|
||||
assert.equal(queries[0].recipeSha256, 'cd'.repeat(32));
|
||||
assert.equal(result.matches[0].observed.evidence.scanRunId, 77);
|
||||
});
|
||||
|
||||
test('missing, wrong-version and wrong-volume scans retain exact needs-attention evidence', async () => {
|
||||
const scenarios = [
|
||||
{ installs: [], kind: 'missing' },
|
||||
{ installs: [observed({ version: '1.01' })], kind: 'wrong_version' },
|
||||
{ installs: [observed({ volumeId: 'console-usb' })], kind: 'wrong_volume' },
|
||||
];
|
||||
for (const scenario of scenarios) {
|
||||
const result = await createPostInstallVerificationService({ scanner: scannerWith(scan(scenario.installs)) }).verify({ job: job() });
|
||||
assert.equal(result.verified, false);
|
||||
assert.equal(result.installState, 'needs_attention');
|
||||
assert.equal(result.mismatches[0].kind, scenario.kind);
|
||||
assert.ok(result.mismatches[0].observed);
|
||||
assert.match(result.evidenceDigest, /^[a-f0-9]{64}$/);
|
||||
}
|
||||
});
|
||||
|
||||
test('unavailable or incomplete scan coverage never confirms installation', async () => {
|
||||
for (const response of [{ available: false, reason: 'console offline' }, { available: true, coverageComplete: false, installs: [observed()] }]) {
|
||||
const result = await createPostInstallVerificationService({ scanner: scannerWith(response) }).verify({ job: job() });
|
||||
assert.equal(result.verified, false);
|
||||
assert.equal(result.outcome, 'scan_unavailable');
|
||||
assert.equal(result.mismatches[0].kind, 'scan_unavailable');
|
||||
assert.notEqual(result.installState, 'installed');
|
||||
}
|
||||
});
|
||||
|
||||
test('rerun from needs-attention repeats only final reconciliation and is idempotent after confirmation', async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'chimera-ch0407-'));
|
||||
let response = scan([]);
|
||||
let executions = 0;
|
||||
let scans = 0;
|
||||
try {
|
||||
const finalVerification = createPostInstallVerificationService({ scanner: { query: async () => { scans += 1; return structuredClone(response); } } });
|
||||
const adapter = { probe: async () => ({ available: true, version: 'sim' }), execute: async () => { executions += 1; return { accepted: true }; }, verify: async () => ({ verified: true }) };
|
||||
const service = createInstallOrchestratorService({
|
||||
storageFile: path.join(root, 'journal.json'),
|
||||
adapters: { 'install-package': adapter },
|
||||
artifactStore: { verify: async component => ({ verified: true, sha256: component.sha256, size: component.size }) },
|
||||
recovery: { prepare: async () => ({ id: 'recovery-1', retained: true }) },
|
||||
finalVerification,
|
||||
uuid: () => 'job-rerun',
|
||||
});
|
||||
const packageSet = { id: 7, platform: 'ps4', region: 'US', components: [
|
||||
{ componentId: 'base', type: 'base', required: true, version: '1.00', sha256: HASH, size: 10, contentId: 'UP0001-CUSA12345_00-BASEPACKAGE000001', region: 'US', metadata: { platform: 'ps4', titleId: { value: 'CUSA12345' } } },
|
||||
{ componentId: 'update', type: 'update', required: true, version: '1.02', sha256: HASH, size: 5, contentId: 'UP0001-CUSA12345_00-UPDATEPACKAGE001', region: 'US', metadata: { platform: 'ps4', titleId: { value: 'CUSA12345' } } },
|
||||
], dependencies: [{ componentId: 'update', dependsOnComponentId: 'base', kind: 'requires' }] };
|
||||
const recipe = { schemaVersion: 1, recipeVersion: 1, packageSetId: 7, notes: '', steps: [
|
||||
{ id: 'base-step', componentId: 'base', action: 'install-package', required: true, dependsOn: [], destinationCapabilities: [] },
|
||||
{ id: 'update-step', componentId: 'update', action: 'install-package', required: true, dependsOn: ['base-step'], destinationCapabilities: [] },
|
||||
] };
|
||||
const target = { freeBytes: 1000, volumeId: 'console-m2', consoleProfileId: 'living-room', destinationCapabilities: { canInstall: true, canCopy: true, canMount: true, canRead: true, stagingPatch: true } };
|
||||
const created = await service.create({ recipe, packageSet, target, expectedRecipeVersion: 1, idempotencyKey: 'verify-rerun' });
|
||||
const first = await service.run(created.job.id);
|
||||
assert.equal(first.status, 'needs_attention');
|
||||
assert.equal(first.finalEvidence.mismatches[0].kind, 'missing');
|
||||
response = scan([observed()]);
|
||||
const second = await service.run(created.job.id);
|
||||
assert.equal(second.status, 'completed');
|
||||
assert.equal(second.finalEvidence.installState, 'installed');
|
||||
const third = await service.run(created.job.id);
|
||||
assert.deepEqual(third, second);
|
||||
assert.equal(executions, 2);
|
||||
assert.equal(scans, 2);
|
||||
} finally {
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
'use strict';
|
||||
|
||||
const assert = require('node:assert/strict');
|
||||
const test = require('node:test');
|
||||
const { evaluateOfficialUpdate } = require('../../src/services/game-update-service');
|
||||
|
||||
const update = overrides => ({ version: '1.02', region: 'US', contentId: 'UP0001-CUSA12345_00-GAME000000000001', requiredFirmware: '9.00', source: 'sony-official', observedAt: 200, ...overrides });
|
||||
const install = overrides => ({ id: 1, installedVersion: '1.01', installState: 'observed', consoleProfileId: 'living-room', volumeId: 'm2', volumeLabel: 'Console M.2', versionSource: 'ftp-scan', versionConfidence: 'high', versionObservedAt: 100, region: 'US', contentId: 'UP0001-CUSA12345_00-GAME000000000001', consoleFirmware: '9.60', ...overrides });
|
||||
|
||||
test('version matrix never shows an update for equal, newer or unknown installed versions', () => {
|
||||
const equal = evaluateOfficialUpdate({ update: update(), installs: [install({ installedVersion: '1.02' })] });
|
||||
const newer = evaluateOfficialUpdate({ update: update(), installs: [install({ installedVersion: '1.03' })] });
|
||||
const unknown = evaluateOfficialUpdate({ update: update(), installs: [install({ installedVersion: 'unknown' })] });
|
||||
assert.equal(equal.updateAvailable, false); assert.equal(equal.locations[0].status, 'current');
|
||||
assert.equal(newer.updateAvailable, false); assert.equal(newer.locations[0].status, 'newer-than-official');
|
||||
assert.equal(unknown.updateAvailable, false); assert.equal(unknown.locations[0].status, 'unknown-version');
|
||||
});
|
||||
|
||||
test('multiple console locations are evaluated explicitly without collapsing versions', () => {
|
||||
const result = evaluateOfficialUpdate({ update: update(), installs: [
|
||||
install({ id: 1, installedVersion: '1.02', volumeId: 'm2', volumeLabel: 'Console M.2' }),
|
||||
install({ id: 2, installedVersion: '1.01', volumeId: 'usb-3', volumeLabel: 'Travel USB' }),
|
||||
] });
|
||||
assert.equal(result.updateAvailable, true);
|
||||
assert.equal(result.locations.length, 2);
|
||||
assert.deepEqual(result.locations.map(item => [item.volumeId, item.status]), [['m2', 'current'], ['usb-3', 'update-available']]);
|
||||
assert.equal(result.installedVersion, '');
|
||||
});
|
||||
|
||||
test('unknown provenance or evidence time cannot produce a false update', () => {
|
||||
for (const candidate of [install({ versionSource: '' }), install({ versionSource: 'legacy' }), install({ versionObservedAt: 0 })]) {
|
||||
const result = evaluateOfficialUpdate({ update: update(), installs: [candidate] });
|
||||
assert.equal(result.updateAvailable, false);
|
||||
assert.equal(result.locations[0].status, 'unknown-provenance');
|
||||
assert.equal(result.locations[0].source, candidate.versionSource);
|
||||
assert.equal(result.locations[0].observedAt, candidate.versionObservedAt);
|
||||
}
|
||||
});
|
||||
|
||||
test('region, content and firmware incompatibility are checked per location', () => {
|
||||
const scenarios = [
|
||||
install({ region: 'EU' }),
|
||||
install({ contentId: 'EP0001-CUSA12345_00-GAME000000000001' }),
|
||||
install({ consoleFirmware: '8.50' }),
|
||||
];
|
||||
for (const candidate of scenarios) {
|
||||
const result = evaluateOfficialUpdate({ update: update(), installs: [candidate] });
|
||||
assert.equal(result.updateAvailable, false);
|
||||
assert.equal(result.locations[0].status, 'incompatible');
|
||||
assert.ok(result.locations[0].compatibility.checks.some(check => check.status === 'incompatible'));
|
||||
}
|
||||
const missingFirmware = evaluateOfficialUpdate({ update: update(), installs: [install({ consoleFirmware: '' })] });
|
||||
assert.equal(missingFirmware.updateAvailable, false);
|
||||
assert.equal(missingFirmware.locations[0].status, 'compatibility-unknown');
|
||||
});
|
||||
|
||||
test('compatible older confirmed evidence exposes source, version and observation time', () => {
|
||||
const result = evaluateOfficialUpdate({ update: update(), installs: [install()] });
|
||||
assert.equal(result.updateAvailable, true);
|
||||
assert.equal(result.locations[0].version, '1.01');
|
||||
assert.equal(result.locations[0].source, 'ftp-scan');
|
||||
assert.equal(result.locations[0].observedAt, 100);
|
||||
assert.deepEqual(result.sourceEvidence, { source: 'sony-official', observedAt: 200 });
|
||||
});
|
||||
@@ -0,0 +1,82 @@
|
||||
'use strict';
|
||||
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const test = require('node:test');
|
||||
const { createUpdateLifecycleService } = require('../../src/services/update-lifecycle-service');
|
||||
|
||||
const SHA = 'ab'.repeat(32);
|
||||
function fixture(root, overrides = {}) {
|
||||
const calls = [];
|
||||
let scan = { available: true, coverageComplete: true, scanRunId: 9, installs: [{ titleId: 'CUSA12345', version: '1.02', volumeId: 'm2', installState: 'observed' }] };
|
||||
const values = {
|
||||
storageFile: path.join(root, 'updates.json'),
|
||||
downloader: { probe: async () => ({ available: true, verifiedHash: true }), download: async () => { calls.push('download'); return { verified: true, sha256: SHA, bytes: 100, managedPath: 'updates/CUSA12345.pkg' }; } },
|
||||
stager: { probe: async () => ({ available: true, capabilities: { hashBoundStage: true } }), stage: async input => { calls.push('stage'); return { staged: true, sha256: input.downloadEvidence.sha256, stagingRef: 'console-staging-1' }; } },
|
||||
installers: { 'ps4-dpi': { probe: async () => ({ available: true, id: 'ps4-dpi', platform: 'ps4', capabilities: { installRequest: true, idempotency: true, postScanRequired: true } }), requestInstall: async () => { calls.push('install'); return { accepted: true, handoffId: 'dpi-1', installed: false }; } } },
|
||||
scanner: { query: async () => { calls.push('scan'); return structuredClone(scan); } },
|
||||
preflight: { check: async () => ({ ok: true, checks: ['hash', 'space', 'service'] }) },
|
||||
uuid: () => 'update-job-1',
|
||||
...overrides,
|
||||
};
|
||||
return { values, calls, setScan: value => { scan = value; } };
|
||||
}
|
||||
function input() { return { idempotencyKey: 'official-CUSA12345-1.02-m2', titleId: 'CUSA12345', expectedVersion: '1.02', platform: 'ps4', requiredAdapter: 'ps4-dpi', expectedSha256: SHA, target: { consoleProfileId: 'living-room', volumeId: 'm2' }, officialEvidence: { source: 'sony-official', observedAt: 1 } }; }
|
||||
|
||||
test('download-only retains exact hash evidence without staging or installing', async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'chimera-update-download-'));
|
||||
try {
|
||||
const f = fixture(root); const service = createUpdateLifecycleService(f.values); const created = service.create(input());
|
||||
const downloaded = await service.download(created.job.id);
|
||||
assert.equal(downloaded.status, 'downloaded');
|
||||
assert.equal(downloaded.downloadEvidence.sha256, SHA);
|
||||
assert.equal(downloaded.downloadEvidence.bytes, 100);
|
||||
assert.deepEqual(f.calls, ['download']);
|
||||
} finally { fs.rmSync(root, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
test('stage-only remains distinct and binds staging evidence to the download hash', async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'chimera-update-stage-'));
|
||||
try {
|
||||
const f = fixture(root); const service = createUpdateLifecycleService(f.values); const id = service.create(input()).job.id;
|
||||
await service.download(id); const staged = await service.stage(id);
|
||||
assert.equal(staged.status, 'staged');
|
||||
assert.equal(staged.stageEvidence.sha256, staged.downloadEvidence.sha256);
|
||||
assert.deepEqual(f.calls, ['download', 'stage']);
|
||||
} finally { fs.rmSync(root, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
test('install failure remains needs-attention and retry reuses the idempotency contract', async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'chimera-update-install-fail-'));
|
||||
let attempts = 0;
|
||||
try {
|
||||
const f = fixture(root); f.values.installers['ps4-dpi'].requestInstall = async input => { f.calls.push(input.idempotencyKey); attempts += 1; if (attempts === 1) throw new Error('simulated installer failure'); return { accepted: true }; };
|
||||
const service = createUpdateLifecycleService(f.values); const id = service.create(input()).job.id;
|
||||
await service.download(id); await service.stage(id);
|
||||
const failed = await service.requestInstall(id);
|
||||
assert.equal(failed.status, 'needs_attention'); assert.equal(failed.failedPhase, 'install');
|
||||
const retried = await service.retry(id);
|
||||
assert.equal(retried.status, 'verifying');
|
||||
assert.deepEqual(f.calls.slice(-2), ['official-CUSA12345-1.02-m2:install', 'official-CUSA12345-1.02-m2:install']);
|
||||
} finally { fs.rmSync(root, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
test('restart preserves verifying state and only exact post-scan version evidence becomes verified', async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'chimera-update-verify-'));
|
||||
try {
|
||||
const firstFixture = fixture(root); const first = createUpdateLifecycleService(firstFixture.values); const id = first.create(input()).job.id;
|
||||
await first.download(id); await first.stage(id); const requested = await first.requestInstall(id);
|
||||
assert.equal(requested.status, 'verifying');
|
||||
const secondFixture = fixture(root); secondFixture.setScan({ available: true, coverageComplete: true, scanRunId: 10, installs: [{ titleId: 'CUSA12345', version: '1.01', volumeId: 'm2', installState: 'observed' }] });
|
||||
const restored = createUpdateLifecycleService(secondFixture.values);
|
||||
assert.equal(restored.get(id).status, 'verifying');
|
||||
const mismatch = await restored.verify(id);
|
||||
assert.equal(mismatch.status, 'needs_attention'); assert.equal(mismatch.finalEvidence.installs[0].version, '1.01');
|
||||
secondFixture.setScan({ available: true, coverageComplete: true, scanRunId: 11, installs: [{ titleId: 'CUSA12345', version: '1.02', volumeId: 'm2', installState: 'observed' }] });
|
||||
const verified = await restored.retry(id);
|
||||
assert.equal(verified.status, 'verified'); assert.equal(verified.finalEvidence.scanRunId, 11);
|
||||
assert.deepEqual(secondFixture.calls, ['scan', 'scan']);
|
||||
} finally { fs.rmSync(root, { recursive: true, force: true }); }
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
'use strict';
|
||||
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const test = require('node:test');
|
||||
const database = require('../../src/database');
|
||||
const { createV3Repositories } = require('../../src/services/v3-repositories');
|
||||
const { createInstallRecipeRepository } = require('../../src/services/install-recipe-repository');
|
||||
const contract = require('../../renderer/modules/navigation-routes');
|
||||
const { inferredPermission } = require('../../src/core/command-registry');
|
||||
|
||||
function withDatabase(run) {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'chimera-package-library-'));
|
||||
database.configure({ dataDir: root, logger: { info() {}, warn() {}, error() {} } });
|
||||
return database.initialize().then(run).finally(() => { database.close(); fs.rmSync(root, { recursive: true, force: true }); });
|
||||
}
|
||||
|
||||
test('package catalog preserves title, sets, component versions/hashes and recipes', () => withDatabase(() => {
|
||||
database.runMutatingSqlWithParams("INSERT INTO titles (id,platform,title_id,name,sort_name) VALUES (1,'ps4','CUSA12345','Package Library','Package Library')");
|
||||
const packageSet = { id: 0, titleId: 1, setKey: 'retail-us', label: 'Retail US', variantKey: 'retail', platform: 'ps4', region: 'US', source: 'managed-import', observedAt: 1, confidence: 'high', components: [
|
||||
{ componentId: 'base', type: 'base', required: true, originalRelativePath: 'base.pkg', normalizedRelativePath: 'base/base.pkg', size: 4, sha256: '1'.repeat(64), version: '1.00', contentId: 'UP0001-CUSA12345_00-BASEPACKAGE000001', region: 'US', order: 1, metadata: { platform: 'ps4', titleId: { value: 'CUSA12345' } }, provenance: [{ source: 'test' }] },
|
||||
{ componentId: 'update', type: 'update', required: true, originalRelativePath: 'update.pkg', normalizedRelativePath: 'updates/1.02/update.pkg', size: 5, sha256: '2'.repeat(64), version: '1.02', contentId: 'UP0001-CUSA12345_00-UPDATEPACKAGE001', region: 'US', order: 2, metadata: { platform: 'ps4', titleId: { value: 'CUSA12345' } }, provenance: [{ source: 'test' }] },
|
||||
], dependencies: [{ componentId: 'update', dependsOnComponentId: 'base', kind: 'requires' }] };
|
||||
createV3Repositories(database).packageSets.savePackageSet(packageSet);
|
||||
const saved = createV3Repositories(database).packageSets.listByTitle(1)[0];
|
||||
createInstallRecipeRepository({ db: database, now: () => 10 }).saveVersion({ packageSet: saved, recipeKey: 'safe', label: 'Safe install', notes: 'Read only notes', expectedVersion: 0, steps: [
|
||||
{ id: 'base-step', componentId: 'base', action: 'install-package', required: true, dependsOn: [], destinationCapabilities: ['canInstall'] },
|
||||
{ id: 'update-step', componentId: 'update', action: 'install-package', required: true, dependsOn: ['base-step'], destinationCapabilities: ['canInstall'] },
|
||||
] });
|
||||
const sets = createV3Repositories(database).packageSets.listCatalog();
|
||||
const recipes = createInstallRecipeRepository({ db: database }).listByTitle(1);
|
||||
assert.equal(sets[0].title.externalTitleId, 'CUSA12345');
|
||||
assert.deepEqual(sets[0].components.map(item => [item.type, item.version, item.sha256]), [['base', '1.00', '1'.repeat(64)], ['update', '1.02', '2'.repeat(64)]]);
|
||||
assert.equal(recipes[0].label, 'Safe install');
|
||||
assert.match(recipes[0].recipeSha256, /^[a-f0-9]{64}$/);
|
||||
}));
|
||||
|
||||
test('Library Packages is normal while raw Package Services remains Expert-only', () => {
|
||||
const library = contract.routes.find(route => route.id === 'library/packages');
|
||||
const services = contract.routes.find(route => route.id === 'tools/package-services');
|
||||
assert.equal(library.page, 'packages'); assert.equal(library.expert, undefined);
|
||||
assert.equal(services.page, 'operations'); assert.equal(services.panel, 'packages'); assert.equal(services.expert, true);
|
||||
assert.equal(inferredPermission('package-catalog:list'), 'authenticated');
|
||||
assert.equal(inferredPermission('packages:status'), 'expert');
|
||||
});
|
||||
|
||||
test('normal package module is inventory-only and contains no raw service or handoff calls', () => {
|
||||
const source = fs.readFileSync(path.join(__dirname, '../../renderer/modules/packages.js'), 'utf8');
|
||||
assert.match(source, /packageCatalog\(\)/);
|
||||
assert.match(source, /component\.sha256/);
|
||||
assert.match(source, /set\.recipes/);
|
||||
assert.doesNotMatch(source, /packagesStatus|packagesInstallUrl|DPI|Void|install-url/i);
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
'use strict';
|
||||
|
||||
const assert = require('node:assert/strict');
|
||||
const test = require('node:test');
|
||||
const initSqlJs = require('sql.js');
|
||||
const migration = require('../../src/database/migrations/0011-create-compatibility-findings');
|
||||
const { createCompatibilityProviderService, createSqliteCompatibilityRepository } = require('../../src/services/compatibility-provider-service');
|
||||
|
||||
async function fixture(options = {}) {
|
||||
const SQL = await initSqlJs({ locateFile: file => require.resolve(`sql.js/dist/${file}`) });
|
||||
const db = new SQL.Database();
|
||||
db.run('PRAGMA foreign_keys = ON');
|
||||
migration.up(db);
|
||||
const repository = createSqliteCompatibilityRepository({ db });
|
||||
const service = createCompatibilityProviderService({ repository, providers: options.providers || [{ id: 'alpha', sourceUrl: 'https://compat.example.org/alpha.json' }], fetchDocument: options.fetchDocument, now: options.now, limits: options.limits });
|
||||
return { db, repository, service };
|
||||
}
|
||||
|
||||
const finding = overrides => ({ key: 'game-base', titleId: 'PPSA12345', version: { min: '1.00', max: '2.00' }, firmware: { min: '8.00', max: '10.01' }, loaders: ['etahen'], storage: ['usb'], formats: ['ffpkg'], state: 'compatible', severity: 'info', explanation: 'Validated data-only compatibility evidence.', confidence: 'high', ...overrides });
|
||||
|
||||
test('valid provider snapshots store bounded provenance and applicability', async t => {
|
||||
const payload = { schemaVersion: 1, revision: 'alpha-2026.07', findings: [finding()] };
|
||||
const f = await fixture({ fetchDocument: async () => Buffer.from(JSON.stringify(payload)), now: () => 1_721_600_000_000 });
|
||||
t.after(() => f.db.close());
|
||||
const snapshot = await f.service.refresh('alpha');
|
||||
assert.match(snapshot.evidenceSha256, /^[a-f0-9]{64}$/);
|
||||
assert.equal(snapshot.revision, 'alpha-2026.07');
|
||||
assert.equal(snapshot.sourceUrl, 'https://compat.example.org/alpha.json');
|
||||
assert.equal(snapshot.fetchedAt, 1_721_600_000_000);
|
||||
const merged = f.service.merge({ titleId: 'PPSA12345', version: '1.50', firmware: '9.60', loader: 'etaHEN', storage: 'usb', format: 'ffpkg' });
|
||||
assert.equal(merged.findings.length, 1);
|
||||
assert.equal(merged.findings[0].confidence, 'high');
|
||||
assert.equal(merged.findings[0].revision, 'alpha-2026.07');
|
||||
assert.equal(f.service.merge({ titleId: 'PPSA12345', version: '3.00', firmware: '9.60', loader: 'etaHEN', storage: 'usb', format: 'ffpkg' }).findings.length, 0);
|
||||
});
|
||||
|
||||
test('hostile providers, executable fields, private sources and limits fail closed', async t => {
|
||||
await assert.rejects(fixture({ providers: [{ id: 'lan', sourceUrl: 'https://192.168.1.5/findings.json' }] }), /public HTTPS/);
|
||||
const f = await fixture({ fetchDocument: async () => ({ schemaVersion: 1, revision: 'hostile', findings: [{ ...finding(), action: { command: 'run' } }] }) });
|
||||
t.after(() => f.db.close());
|
||||
await assert.rejects(f.service.refresh('alpha'), error => error.code === 'COMPATIBILITY_EXECUTABLE_FIELD');
|
||||
assert.equal(f.repository.latest('alpha'), null);
|
||||
const limited = await fixture({ fetchDocument: async () => ({ schemaVersion: 1, revision: 'large', findings: [finding(), finding({ key: 'second' })] }), limits: { findings: 1 } });
|
||||
t.after(() => limited.db.close());
|
||||
await assert.rejects(limited.service.refresh('alpha'), error => error.code === 'COMPATIBILITY_LIMIT');
|
||||
});
|
||||
|
||||
test('conflicting providers remain visible and offline refresh returns explicit stale cache', async t => {
|
||||
let clock = 1_721_600_000_000;
|
||||
let offline = false;
|
||||
const documents = {
|
||||
alpha: { schemaVersion: 1, revision: 'a1', findings: [finding()] },
|
||||
beta: { schemaVersion: 1, revision: 'b7', findings: [finding({ state: 'incompatible', severity: 'critical', explanation: 'Conflicting lab result.' })] },
|
||||
};
|
||||
const f = await fixture({
|
||||
providers: [{ id: 'alpha', sourceUrl: 'https://compat.example.org/alpha.json' }, { id: 'beta', sourceUrl: 'https://compat.example.net/beta.json' }],
|
||||
fetchDocument: async provider => { if (offline) throw new Error('network offline'); return documents[provider.id]; },
|
||||
now: () => clock, limits: { maxAgeMs: 1_000 },
|
||||
});
|
||||
t.after(() => f.db.close());
|
||||
await f.service.refresh('alpha');
|
||||
await f.service.refresh('beta');
|
||||
const merged = f.service.merge({ titleId: 'PPSA12345', version: '1.50', firmware: '9.60', loader: 'etaHEN', storage: 'usb', format: 'ffpkg' });
|
||||
assert.deepEqual(merged.conflicts[0].states, ['compatible', 'incompatible']);
|
||||
assert.equal(merged.findings.every(item => item.conflictGroup === merged.conflicts[0].conflictGroup), true);
|
||||
assert.equal(merged.resolvedAutomatically, false);
|
||||
clock += 2_000;
|
||||
offline = true;
|
||||
const cached = await f.service.refresh('alpha');
|
||||
assert.equal(cached.offline, true);
|
||||
assert.equal(cached.stale, true);
|
||||
assert.equal(cached.revision, 'a1');
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
'use strict';
|
||||
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const test = require('node:test');
|
||||
const { createCompatibilityGuidanceService } = require('../../src/services/compatibility-guidance-service');
|
||||
const { createInstallDestinationService } = require('../../src/services/install-destination-service');
|
||||
const renderer = require('../../renderer/modules/compatibility-guidance');
|
||||
|
||||
const provenance = { key: 'rule', severity: 'info', confidence: 'medium', explanation: 'Evidence rationale.', providerId: 'alpha', revision: 'r7', sourceUrl: 'https://compat.example.org/data.json', evidenceSha256: 'a'.repeat(64), fetchedAt: 1_721_600_000_000, conflictGroup: null };
|
||||
const serviceFor = (findings, conflicts = []) => createCompatibilityGuidanceService({ compatibility: { merge: () => ({ findings, conflicts }) } });
|
||||
|
||||
test('compatible, incompatible, workaround, conflict and unknown remain distinct UI states', () => {
|
||||
const cases = [
|
||||
['compatible', [{ ...provenance, state: 'compatible' }], []],
|
||||
['incompatible', [{ ...provenance, state: 'incompatible', severity: 'warning' }], []],
|
||||
['workaround', [{ ...provenance, state: 'workaround', explanation: 'Use a verified staging copy.' }], []],
|
||||
['conflict', [{ ...provenance, state: 'compatible', conflictGroup: 'group' }, { ...provenance, key: 'rule-2', state: 'incompatible', conflictGroup: 'group' }], [{ conflictGroup: 'group', states: ['compatible', 'incompatible'], providers: ['alpha', 'beta'] }]],
|
||||
['unknown', [], []],
|
||||
];
|
||||
for (const [state, findings, conflicts] of cases) {
|
||||
const model = serviceFor(findings, conflicts).evaluate({ titleId: 'PPSA12345' });
|
||||
assert.equal(model.state, state);
|
||||
assert.match(renderer.markup(model), new RegExp(`data-compatibility-state="${state}"`));
|
||||
}
|
||||
const html = renderer.markup(serviceFor(cases[0][1]).evaluate({ titleId: 'PPSA12345' }));
|
||||
assert.match(html, /alpha \/ r7 \/ medium confidence/);
|
||||
assert.match(html, /https:\/\/compat\.example\.org\/data\.json/);
|
||||
});
|
||||
|
||||
test('only critical high-confidence unconflicted incompatibility blocks destinations', () => {
|
||||
const blockedGuidance = serviceFor([{ ...provenance, state: 'incompatible', severity: 'critical', confidence: 'high' }]);
|
||||
const informationalGuidance = serviceFor([{ ...provenance, state: 'incompatible', severity: 'warning', confidence: 'high' }]);
|
||||
const inventory = { list: () => ({ devices: [{ id: 'device', displayName: 'USB', deviceType: 'usb', availabilityState: 'available', volumes: [{ id: 'volume', stableVolumeKey: 'games', availabilityState: 'available', mountPath: '/mnt/usb', freeBytes: 10_000, capacityBytes: 20_000, capabilities: { canInstall: true }, capacityEvidence: { observedAt: 10_000 }, capabilityEvidence: { observedAt: 10_000 } }] }] }) };
|
||||
const blocked = createInstallDestinationService({ inventory, compatibility: blockedGuidance, now: () => 10_001 }).listDestinations({ titleId: 'PPSA12345', requiredBytes: 100 }).destinations[0];
|
||||
assert.equal(blocked.eligible, false);
|
||||
assert.ok(blocked.reasons.includes('compatibility-incompatible'));
|
||||
const allowed = createInstallDestinationService({ inventory, compatibility: informationalGuidance, now: () => 10_001 }).listDestinations({ titleId: 'PPSA12345', requiredBytes: 100 }).destinations[0];
|
||||
assert.equal(allowed.eligible, true);
|
||||
assert.equal(allowed.compatibility.state, 'incompatible');
|
||||
});
|
||||
|
||||
test('title, destination and recipe surfaces all expose shared rationale inspection', () => {
|
||||
const read = file => fs.readFileSync(path.join(__dirname, '../..', file), 'utf8');
|
||||
assert.match(read('renderer/modules/title-workspace.js'), /chimeraCompatibilityGuidance\?\.load/);
|
||||
assert.match(read('renderer/modules/install-destination.js'), /chimeraCompatibilityGuidance\?\.markup/);
|
||||
assert.match(read('src/renderer/recipe-editor.js'), /recipe-compatibility-guidance[^]*chimeraCompatibilityGuidance\?\.load/);
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
'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');
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
'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 { createAprAmprAdapterService, PINNED_UPSTREAM } = require('../../src/services/apr-ampr-adapter-service');
|
||||
|
||||
const digest = value => crypto.createHash('sha256').update(value).digest('hex');
|
||||
function fixture(options = {}) {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'chimera-apr-')); const source = path.join(root, 'source'); const artifacts = path.join(root, 'artifacts'); const workspace = path.join(root, 'workspace'); const staging = path.join(root, 'staging');
|
||||
fs.mkdirSync(path.join(source, 'sce_module'), { recursive: true }); fs.mkdirSync(artifacts, { recursive: true }); fs.mkdirSync(workspace, { recursive: true });
|
||||
fs.writeFileSync(path.join(source, 'sce_module', 'libSceAmpr.sprx'), 'OLD-AMPR'); fs.writeFileSync(path.join(source, 'ampr_emu.index'), 'OLD-INDEX'); fs.writeFileSync(path.join(source, 'asset.bin'), 'ASSET');
|
||||
const artifactPath = path.join(artifacts, 'libSceAmpr.sprx'); fs.writeFileSync(artifactPath, 'PINNED-AMPR');
|
||||
const patchService = createPatchBundleService({ sourceRoots: [source], bundleRoots: [workspace], stagingRoot: staging, now: () => 1_721_600_000_000 });
|
||||
const adapter = createAprAmprAdapterService({ patchService, sourceRoots: [source, staging], artifact: { path: artifactPath, sha256: digest('PINNED-AMPR'), version: '0.2.5.3' }, workspaceRoot: workspace, supportedFirmwares: ['9.60'], simulationMode: true, now: () => 1_721_600_000_000, hooks: options.hooks });
|
||||
const input = { sourceRoot: source, titleId: 'PPSA12345', version: '1.00', firmware: '9.60', loader: 'etaHEN', kstuffVersion: '1.07', storage: 'usb', format: 'folder', applicationId: 'apr-stage' };
|
||||
return { root, source, staging, adapter, input, close: () => fs.rmSync(root, { recursive: true, force: true }) };
|
||||
}
|
||||
|
||||
test('simulated supported workflow detects requirement evidence and pins capabilities', async t => {
|
||||
const f = fixture(); t.after(f.close);
|
||||
const check = f.adapter.preflight(f.input); assert.equal(check.supported, true); assert.equal(check.detection.required, true); assert.match(check.detection.evidence.sha256, /^[a-f0-9]{64}$/);
|
||||
assert.equal(check.adapter.adapterVersion, '1.0.3'); assert.equal(check.adapter.gameCompressorRevision, PINNED_UPSTREAM.gameCompressorRevision); assert.equal(check.adapter.capabilities.liveConsoleMutation, false); assert.equal(check.mountSettingsProposal.applied, false);
|
||||
const staged = await f.adapter.stage(f.input); assert.equal(staged.state, 'verified'); assert.equal(staged.index.state, 'current'); assert.deepEqual(staged.lifecycle, ['detected', 'preflight-passed', 'index-rebuilding', 'staged', 'verified']);
|
||||
assert.equal(fs.readFileSync(path.join(f.source, 'sce_module', 'libSceAmpr.sprx'), 'utf8'), 'OLD-AMPR');
|
||||
});
|
||||
|
||||
test('unsupported firmware, format and internal index bootstrap explain exact reasons', t => {
|
||||
const f = fixture(); t.after(f.close);
|
||||
const check = f.adapter.preflight({ ...f.input, firmware: '10.50', format: 'exfat' });
|
||||
assert.equal(check.supported, false); assert.ok(check.reasons.some(item => item.code === 'APR_FIRMWARE_UNSUPPORTED')); assert.ok(check.reasons.some(item => item.code === 'APR_FORMAT_UNSUPPORTED'));
|
||||
fs.unlinkSync(path.join(f.source, 'ampr_emu.index'));
|
||||
const internal = f.adapter.preflight({ ...f.input, storage: 'internal' }); assert.ok(internal.reasons.some(item => item.code === 'APR_LOCATION_UNSUPPORTED'));
|
||||
});
|
||||
|
||||
test('index rebuild is deterministic, verified and source mismatch fails in staging', async t => {
|
||||
const good = fixture(); t.after(good.close); const staged = await good.adapter.stage(good.input); const document = JSON.parse(fs.readFileSync(path.join(staged.outputRoot, 'ampr_emu.index'), 'utf8'));
|
||||
assert.equal(document.format, 'chimera-ampr-index-simulator-v1'); assert.equal(document.consoleCompatible, false); assert.equal(document.entries[0].path, 'asset.bin');
|
||||
const bad = fixture({ hooks: { beforeApply: ({ input }) => fs.writeFileSync(path.join(input.sourceRoot, 'sce_module', 'libSceAmpr.sprx'), 'CHANGED-AFTER-PREFLIGHT') } }); t.after(bad.close);
|
||||
await assert.rejects(bad.adapter.stage({ ...bad.input, applicationId: 'source-race' }), error => error.code === 'PATCH_SOURCE_MISMATCH');
|
||||
assert.equal(fs.existsSync(path.join(bad.staging, 'source-race')), false);
|
||||
});
|
||||
|
||||
test('adapter rollback restores original staged module and index with verified lifecycle', async t => {
|
||||
const f = fixture(); t.after(f.close); const staged = await f.adapter.stage(f.input); const result = await f.adapter.rollback(f.input.applicationId);
|
||||
assert.equal(result.state, 'rolled-back'); assert.equal(result.outputsVerified, true); assert.deepEqual(result.lifecycle, ['rollback-preflight', 'rollback-staged', 'rolled-back']);
|
||||
assert.equal(fs.readFileSync(path.join(staged.outputRoot, 'sce_module', 'libSceAmpr.sprx'), 'utf8'), 'OLD-AMPR'); assert.equal(fs.readFileSync(path.join(staged.outputRoot, 'ampr_emu.index'), 'utf8'), 'OLD-INDEX');
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
'use strict';
|
||||
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const test = require('node:test');
|
||||
const { createMediaCacheService } = require('../../src/services/media-cache-service');
|
||||
|
||||
function fixture() {
|
||||
const parent = fs.mkdtempSync(path.join(os.tmpdir(), 'chimera-media-cache-'));
|
||||
let clock = 1_800_000_000_000;
|
||||
const service = createMediaCacheService({ root: path.join(parent, 'cache'), indexPath: path.join(parent, 'index.json'), now: () => clock, defaultTtlMs: 100 });
|
||||
return { parent, service, tick: value => { clock += value; }, close: () => fs.rmSync(parent, { recursive: true, force: true }) };
|
||||
}
|
||||
|
||||
const remote = locator => ({ type: 'remote', provider: 'ps5-ftp', locator });
|
||||
|
||||
test('cache inventory preserves distinct provenance and exposes size/count/age metrics', async () => {
|
||||
const f = fixture();
|
||||
try {
|
||||
await f.service.ensureCached({ key: 'remote:a', relativePath: 'a.jpg', origin: remote('/user/av_contents/photo/a.jpg'), fetchTo: target => fs.writeFileSync(target, 'cached') });
|
||||
fs.writeFileSync(path.join(f.parent, 'cache', 'generated.jpg'), 'frame');
|
||||
f.service.registerManaged({ key: 'generated:a', relativePath: 'generated.jpg', kind: 'generated', origin: { type: 'generated', provider: 'ffmpeg-frame', locator: '/user/av_contents/video/a.webm' } });
|
||||
f.service.registerImported({ key: 'import:a', bytes: 99, origin: { type: 'imported', provider: 'managed-import', locator: 'manifest:item-a' } });
|
||||
f.tick(25);
|
||||
const status = f.service.status();
|
||||
assert.equal(status.metrics.count, 3);
|
||||
assert.equal(status.metrics.byKind.cached.count, 1);
|
||||
assert.equal(status.metrics.byKind.generated.count, 1);
|
||||
assert.equal(status.metrics.byKind.imported.count, 1);
|
||||
assert.equal(status.metrics.byOrigin.remote.count, 1);
|
||||
assert.equal(status.metrics.oldestAgeMs, 25);
|
||||
assert.equal(status.entries.find(entry => entry.kind === 'imported').pinned, true);
|
||||
} finally { f.close(); }
|
||||
});
|
||||
|
||||
test('TTL/LRU retention and granular clear preserve pinned cache and imported originals', async () => {
|
||||
const f = fixture();
|
||||
try {
|
||||
await f.service.ensureCached({ key: 'old', relativePath: 'old.jpg', origin: remote('/old.jpg'), ttlMs: 10, fetchTo: target => fs.writeFileSync(target, 'old') });
|
||||
const pinned = await f.service.ensureCached({ key: 'pinned', relativePath: 'pinned.jpg', origin: remote('/pinned.jpg'), ttlMs: 10, fetchTo: target => fs.writeFileSync(target, 'pinned') });
|
||||
f.service.setPinned(pinned.id, true);
|
||||
f.service.registerImported({ key: 'original', bytes: 500, origin: { type: 'imported', provider: 'managed-import', locator: 'portable:original' } });
|
||||
f.tick(20);
|
||||
const expired = f.service.clear({ expiredOnly: true, originType: 'remote' });
|
||||
assert.equal(expired.removed, 1);
|
||||
assert.equal(fs.existsSync(path.join(f.parent, 'cache', 'old.jpg')), false);
|
||||
assert.equal(fs.existsSync(path.join(f.parent, 'cache', 'pinned.jpg')), true);
|
||||
const all = f.service.clear({});
|
||||
assert.equal(all.removed, 0);
|
||||
assert.deepEqual(f.service.status().entries.map(entry => entry.kind).sort(), ['cached', 'imported']);
|
||||
} finally { f.close(); }
|
||||
});
|
||||
|
||||
test('interrupted fetch leaves no published or partial asset and keeps prior index intact', async () => {
|
||||
const f = fixture();
|
||||
try {
|
||||
await f.service.ensureCached({ key: 'good', relativePath: 'good.jpg', origin: remote('/good.jpg'), fetchTo: target => fs.writeFileSync(target, 'good') });
|
||||
await assert.rejects(
|
||||
() => f.service.ensureCached({ key: 'broken', relativePath: 'broken.jpg', origin: remote('/broken.jpg'), fetchTo: target => { fs.writeFileSync(target, 'partial'); throw new Error('connection lost'); } }),
|
||||
/connection lost/
|
||||
);
|
||||
assert.equal(fs.existsSync(path.join(f.parent, 'cache', 'broken.jpg')), false);
|
||||
assert.equal(fs.existsSync(path.join(f.parent, 'cache', 'good.jpg')), true);
|
||||
assert.deepEqual(f.service.status().entries.map(entry => entry.origin.locator), ['/good.jpg']);
|
||||
assert.equal(fs.readdirSync(path.join(f.parent, 'cache')).some(name => name.endsWith('.partial')), false);
|
||||
} finally { f.close(); }
|
||||
});
|
||||
|
||||
test('managed paths cannot escape the cache root or use symlinks', () => {
|
||||
const f = fixture();
|
||||
try {
|
||||
assert.throws(() => f.service.registerManaged({ key: 'escape', relativePath: '../outside.jpg', origin: remote('/outside.jpg') }), /inside the managed root/);
|
||||
} finally { f.close(); }
|
||||
});
|
||||
@@ -0,0 +1,109 @@
|
||||
'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 { createImportUploadService } = require('../../src/services/import-upload-service');
|
||||
const { createMediaImportService, detectMedia, normalizeCrop } = require('../../src/services/media-import-service');
|
||||
const { createAppServer } = require('../../src/server/app-server');
|
||||
|
||||
function png(width = 600, height = 900) {
|
||||
const bytes = Buffer.alloc(32);
|
||||
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]).copy(bytes);
|
||||
bytes.write('IHDR', 12, 'ascii');
|
||||
bytes.writeUInt32BE(width, 16);
|
||||
bytes.writeUInt32BE(height, 20);
|
||||
return bytes;
|
||||
}
|
||||
|
||||
test('image/video validation and crop bounds fail closed', () => {
|
||||
assert.equal(detectMedia(png()).mime, 'image/png');
|
||||
assert.equal(detectMedia(Buffer.from([0x1a, 0x45, 0xdf, 0xa3, 0, 0, 0, 0, 0, 0, 0, 0])).mime, 'video/webm');
|
||||
assert.equal(detectMedia(Buffer.from('not media bytes')), null);
|
||||
assert.deepEqual(normalizeCrop({ x: 0.1, y: 0, width: 0.8, height: 1 }), { x: 0.1, y: 0, width: 0.8, height: 1 });
|
||||
assert.throws(() => normalizeCrop({ x: 0.5, y: 0, width: 0.6, height: 1 }), /bounds/);
|
||||
});
|
||||
|
||||
test('streamed import preserves provenance, title activity data and recoverable provider cover', async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'chimera-media-import-'));
|
||||
const rows = new Map([['PPSA12345', { game_id: 'PPSA12345', prospero_cover_url: 'https://provider.example/cover.jpg', metadata_override: null }]]);
|
||||
const db = { getGame: id => rows.get(id), updateGame: (id, fields) => rows.set(id, { ...rows.get(id), ...fields }) };
|
||||
const uploads = createImportUploadService({ root: path.join(root, 'uploads'), limits: { maxFileBytes: 1024, maxSessionBytes: 1024 } });
|
||||
const media = createMediaImportService({ root: path.join(root, 'media'), db, now: () => 1_800_000_000_000 });
|
||||
try {
|
||||
const bytes = png();
|
||||
const session = uploads.create({ owner: 'alice', files: [{ relativePath: 'custom-cover.png', size: bytes.length }] });
|
||||
await uploads.writeChunk({ id: session.id, fileId: session.files[0].id, owner: 'alice', offset: 0, length: bytes.length, chunkSha256: crypto.createHash('sha256').update(bytes).digest('hex'), stream: require('stream').Readable.from(bytes) });
|
||||
const asset = await media.finalizeFromUpload({ uploadStore: uploads, sessionId: session.id, fileId: session.files[0].id, owner: 'alice', titleId: 'PPSA12345', kind: 'cover', crop: { x: 0, y: 0, width: 1, height: 1 }, preferred: true });
|
||||
assert.equal(asset.provenance.type, 'imported');
|
||||
assert.equal(asset.provenance.importedBy, 'alice');
|
||||
assert.equal(asset.preferred, true);
|
||||
assert.match(JSON.parse(rows.get('PPSA12345').metadata_override).coverUrl, /^\/api\/imported-media\//);
|
||||
const linked = media.list('PPSA12345');
|
||||
assert.equal(linked.items[0].titleId, 'PPSA12345');
|
||||
assert.equal(linked.providerArtwork.coverUrl, 'https://provider.example/cover.jpg');
|
||||
assert.equal(linked.providerArtwork.recoverable, true);
|
||||
media.setPreferred({ titleId: 'PPSA12345', assetId: null });
|
||||
assert.equal(rows.get('PPSA12345').metadata_override, null);
|
||||
assert.equal(fs.existsSync(media.resolvePublicPath(asset.url.replace('/api/imported-media/', ''))), true);
|
||||
} finally { fs.rmSync(root, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
test('upload ownership and quota apply before media finalization', async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'chimera-media-quota-'));
|
||||
const uploads = createImportUploadService({ root: path.join(root, 'uploads'), limits: { maxFileBytes: 16, maxSessionBytes: 16 } });
|
||||
const media = createMediaImportService({ root: path.join(root, 'media'), db: { getGame: () => ({ game_id: 'CUSA00001' }) } });
|
||||
try {
|
||||
assert.throws(() => uploads.create({ owner: 'alice', files: [{ relativePath: 'large.png', size: 17 }] }), error => error.code === 'UPLOAD_QUOTA');
|
||||
const session = uploads.create({ owner: 'alice', files: [{ relativePath: 'small.png', size: 12 }] });
|
||||
assert.throws(() => uploads.openUploaded(session.id, 'bob'), error => error.code === 'UPLOAD_NOT_FOUND');
|
||||
await assert.rejects(() => media.finalizeFromUpload({ uploadStore: uploads, sessionId: session.id, fileId: session.files[0].id, owner: 'alice', titleId: 'CUSA00001', kind: 'cover' }), error => error.code === 'UPLOAD_INCOMPLETE');
|
||||
} finally { fs.rmSync(root, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
test('media finalize and imported asset routes enforce session and CSRF boundaries', async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'chimera-media-http-'));
|
||||
const cache = path.join(root, 'cache');
|
||||
fs.mkdirSync(cache, { recursive: true });
|
||||
const values = new Map();
|
||||
const secretValues = new Map();
|
||||
const settings = { get: (key, fallback) => values.has(key) ? values.get(key) : fallback, set: (key, value) => values.set(key, value) };
|
||||
const secrets = { get: key => secretValues.get(key), set: (key, value) => secretValues.set(key, value), delete: key => secretValues.delete(key) };
|
||||
const row = { game_id: 'PPSA12345', prospero_cover_url: 'https://provider.example/cover.jpg', metadata_override: null };
|
||||
const db = { getGame: id => id === row.game_id ? row : null, updateGame: (_id, fields) => Object.assign(row, fields) };
|
||||
const mediaImports = createMediaImportService({ root: path.join(root, 'media-imports'), db });
|
||||
const registry = { mediaImports, describe: () => [], invoke: async () => ({}), isMutating: () => false, requestBodyLimit: () => 1024 };
|
||||
const app = createAppServer({ appRoot: path.resolve(__dirname, '../..'), paths: { root, cache }, settings, secrets, registry, logger: { info() {}, warn() {}, error() {}, audit() {} }, ftp: { disconnect: async () => {} }, capabilities: { features: {} }, version: 'test' });
|
||||
const address = await app.listen(0, '127.0.0.1');
|
||||
const base = `http://127.0.0.1:${address.port}`;
|
||||
const common = { Host: `127.0.0.1:${address.port}`, Origin: base, 'Content-Type': 'application/json' };
|
||||
try {
|
||||
const denied = await fetch(`${base}/api/media/imports/finalize`, { method: 'POST', headers: common, body: '{}' });
|
||||
assert.equal(denied.status, 401);
|
||||
const setup = await fetch(`${base}/api/setup`, { method: 'POST', headers: common, body: JSON.stringify({ username: 'artist', password: 'secure-password-123', allowedRoots: [root], safetyConfirmed: true }) });
|
||||
const cookie = setup.headers.get('set-cookie').split(';')[0];
|
||||
const csrf = (await setup.json()).csrfToken;
|
||||
const bytes = png();
|
||||
const createdResponse = await fetch(`${base}/api/import/uploads`, { method: 'POST', headers: { ...common, Cookie: cookie, 'X-CSRF-Token': csrf }, body: JSON.stringify({ files: [{ relativePath: 'cover.png', size: bytes.length }] }) });
|
||||
const created = (await createdResponse.json()).result;
|
||||
const chunkHeaders = { Host: common.Host, Origin: base, Cookie: cookie, 'X-CSRF-Token': csrf, 'Upload-Offset': '0', 'X-Chunk-SHA256': crypto.createHash('sha256').update(bytes).digest('hex'), 'Content-Type': 'application/octet-stream' };
|
||||
await fetch(`${base}/api/import/uploads/${created.id}/files/${created.files[0].id}`, { method: 'PUT', headers: chunkHeaders, body: bytes });
|
||||
const body = JSON.stringify({ sessionId: created.id, fileId: created.files[0].id, titleId: 'PPSA12345', kind: 'cover', preferred: true, crop: { x: 0, y: 0, width: 1, height: 1 } });
|
||||
const missingCsrf = await fetch(`${base}/api/media/imports/finalize`, { method: 'POST', headers: { ...common, Cookie: cookie }, body });
|
||||
assert.equal(missingCsrf.status, 403);
|
||||
const finalized = await fetch(`${base}/api/media/imports/finalize`, { method: 'POST', headers: { ...common, Cookie: cookie, 'X-CSRF-Token': csrf }, body });
|
||||
assert.equal(finalized.status, 201);
|
||||
const asset = (await finalized.json()).result;
|
||||
const unauthenticatedAsset = await fetch(`${base}${asset.url}`);
|
||||
assert.equal(unauthenticatedAsset.status, 401);
|
||||
const served = await fetch(`${base}${asset.url}`, { headers: { Cookie: cookie } });
|
||||
assert.equal(served.status, 200);
|
||||
assert.deepEqual(Buffer.from(await served.arrayBuffer()), bytes);
|
||||
} finally {
|
||||
await app.close();
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
'use strict';
|
||||
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const test = require('node:test');
|
||||
const contract = require('../../renderer/modules/navigation-routes');
|
||||
const { CommandRegistry, inferredPermission } = require('../../src/core/command-registry');
|
||||
|
||||
test('raw live, files, package, mount, SQL, diagnostics and system-action routes are Expert-only', () => {
|
||||
const expertRoutes = ['console/live', 'console/files', 'tools/package-services', 'library/mounts', 'tools/database', 'tools/diagnostics', 'console/power'];
|
||||
for (const id of expertRoutes) assert.equal(contract.routes.find(route => route.id === id)?.expert, true, `${id} must be Expert-only`);
|
||||
assert.equal(contract.routes.find(route => route.id === 'console/status')?.expert, undefined);
|
||||
assert.equal(contract.routes.find(route => route.id === 'library/games')?.expert, undefined);
|
||||
assert.equal(contract.routes.find(route => route.id === 'library/packages')?.expert, undefined);
|
||||
});
|
||||
|
||||
test('deep links are gated and expert mode requires explicit session activation', () => {
|
||||
const source = fs.readFileSync(path.join(__dirname, '../../renderer/modules/navigation.js'), 'utf8');
|
||||
assert.match(source, /route\.expert && !expertMode[^]*defaultRoute/);
|
||||
assert.match(source, /let expertMode = false/);
|
||||
assert.doesNotMatch(source, /localStorage\.(?:getItem|setItem)\('chimera\.expertMode'/);
|
||||
assert.match(source, /sessionExpertMode\(expertMode, expertMode \? 'ENABLE EXPERT MODE' : undefined\)/);
|
||||
assert.match(source, /!expertMode && routeById\.get\(currentRouteId\)\?\.expert[^]*history: 'replace'/);
|
||||
});
|
||||
|
||||
test('server permissions reject Expert commands independently from route hiding', async () => {
|
||||
const registry = new CommandRegistry({ platform: 'server' });
|
||||
registry.register({ name: 'database:query', classification: 'read-only', handler: () => 'rows' });
|
||||
registry.register({ name: 'shadowmount:status', classification: 'read-only', handler: () => 'mounts' });
|
||||
registry.register({ name: 'system-state:command', classification: 'destructive', confirmation: 'CONFIRM POWER', handler: () => 'powered' });
|
||||
registry.register({ name: 'console:status', classification: 'read-only', handler: () => 'simple' });
|
||||
const standard = { session: { id: 'session-1', expertMode: false } };
|
||||
await assert.rejects(registry.invoke('database:query', null, standard), error => error.statusCode === 403 && error.code === 'EXPERT_MODE_REQUIRED');
|
||||
await assert.rejects(registry.invoke('shadowmount:status', null, standard), error => error.statusCode === 403);
|
||||
assert.equal(await registry.invoke('console:status', null, standard), 'simple');
|
||||
const expert = { session: { id: 'session-1', expertMode: true } };
|
||||
assert.equal(await registry.invoke('database:query', null, expert), 'rows');
|
||||
assert.equal(await registry.invoke('system-state:command', { confirmation: 'CONFIRM POWER' }, expert), 'powered');
|
||||
});
|
||||
|
||||
test('Expert command families have explicit inferred permission coverage', () => {
|
||||
for (const name of ['database:read', 'shadowmount:status', 'packages:list', 'diagnostics:bundle', 'monitoring:prometheus', 'ecosystem:files:list', 'ecosystem:live:status', 'system:service-matrix', 'system-state:deploy', 'power:shutdown']) {
|
||||
assert.equal(inferredPermission(name), 'expert', name);
|
||||
}
|
||||
for (const name of ['library:list', 'console:status', 'transfer:state']) assert.equal(inferredPermission(name), 'authenticated', name);
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
'use strict';
|
||||
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const test = require('node:test');
|
||||
const { createConsoleProfileService } = require('../../src/services/console-profile-service');
|
||||
|
||||
class Settings {
|
||||
constructor(values = {}) { this.values = values; }
|
||||
get(key, fallback) { return Object.prototype.hasOwnProperty.call(this.values, key) ? this.values[key] : fallback; }
|
||||
set(key, value) { this.values[key] = value; }
|
||||
}
|
||||
const secrets = { has: () => false, get: () => '', set() {} };
|
||||
|
||||
test('identity evidence is bounded, masked and exposed read-only without a mutation contract', () => {
|
||||
const settings = new Settings({ 'console.identityEvidence': { localUsername: ' Local\u0000User ', accountId: 'abcdef1234567890', psnOnlineId: 'Player_One', linked: true, source: 'sentinel', observedAt: 1_800_000_000_000 } });
|
||||
const service = createConsoleProfileService({ settings, secrets, uuid: () => 'profile-a' });
|
||||
const identity = service.identity();
|
||||
assert.equal(identity.local.username, 'Local User');
|
||||
assert.equal(identity.local.accountIdMasked, '************7890');
|
||||
assert.equal(identity.linkedPsn.onlineId, 'Player_One');
|
||||
assert.equal(identity.readOnly, true);
|
||||
assert.equal(identity.mutationAvailable, false);
|
||||
assert.match(identity.officialOnlineIdGuidance, /^https:\/\/www\.playstation\.com\/support\//);
|
||||
service.save({ id: 'profile-a', identity: { psnOnlineId: 'InjectedName', linked: true } });
|
||||
assert.equal(service.identity().linkedPsn.onlineId, 'Player_One');
|
||||
});
|
||||
|
||||
test('invalid identity fields fail closed instead of leaking unsafe values', () => {
|
||||
const settings = new Settings({ 'console.identityEvidence': { localUsername: '<script>\nname', accountId: 'not-an-id', psnOnlineId: 'invalid online id!', linked: true, source: 'shell', observedAt: -1 } });
|
||||
const identity = createConsoleProfileService({ settings, secrets, uuid: () => 'profile-b' }).identity();
|
||||
assert.equal(identity.local.username, '<script> name');
|
||||
assert.equal(identity.local.accountIdMasked, null);
|
||||
assert.equal(identity.linkedPsn.onlineId, null);
|
||||
assert.equal(identity.linkedPsn.linked, false);
|
||||
assert.deepEqual(identity.provenance, { source: 'unknown', observedAt: null });
|
||||
});
|
||||
|
||||
test('Settings navigator exposes the target IA and accessible read-only identity surface', () => {
|
||||
const root = path.resolve(__dirname, '../..');
|
||||
const navigation = fs.readFileSync(path.join(root, 'renderer/modules/navigation.js'), 'utf8');
|
||||
const html = fs.readFileSync(path.join(root, 'renderer/index.html'), 'utf8');
|
||||
for (const category of ['setup', 'console', 'storage', 'automation', 'integrations', 'security', 'advanced']) assert.match(navigation, new RegExp(`\\['${category}'`));
|
||||
assert.doesNotMatch(navigation, /\['all', 'All'\]/);
|
||||
assert.match(navigation, /let activeCategory = 'setup'/);
|
||||
assert.match(html, /id="settings-console-identity-card"/);
|
||||
assert.match(html, /data-i18n="settings\.identity\.readOnly"/);
|
||||
assert.match(html, /playstation\.com\/support\/account\/change-psn-online-id/);
|
||||
assert.doesNotMatch(html, /id="settings-identity-[^"]+"[^>]*type="text"/);
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
'use strict';
|
||||
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const test = require('node:test');
|
||||
|
||||
const root = path.resolve(__dirname, '../..');
|
||||
|
||||
test('A-060 applies deep Settings focus before revealing the destination page', () => {
|
||||
const source = fs.readFileSync(path.join(root, 'renderer/modules/navigation.js'), 'utf8');
|
||||
const focus = source.indexOf('applyRouteFocus(route);', source.indexOf('async function navigate'));
|
||||
const reveal = source.indexOf('activatePage(route.page);', source.indexOf('async function navigate'));
|
||||
assert.ok(focus >= 0 && reveal >= 0 && focus < reveal, 'route focus must be applied before page activation');
|
||||
assert.match(source, /applyReliabilityFocus\(route\.focus\)/);
|
||||
assert.match(source, /loadOperationsReliability\(\{ focus: route\.focus \}\)/);
|
||||
|
||||
const operations = fs.readFileSync(path.join(root, 'renderer/modules/operations.js'), 'utf8');
|
||||
assert.match(operations, /if \(focus === 'security'\)[\s\S]*renderSessions\(await window\.pork\.authSessions\(\)\);[\s\S]*return;/);
|
||||
});
|
||||
|
||||
test('P7 acceptance contracts remain mapped to concrete automated evidence', () => {
|
||||
const cacheTests = fs.readFileSync(path.join(root, 'test/server/ch-0701-media-cache.test.js'), 'utf8');
|
||||
const importTests = fs.readFileSync(path.join(root, 'test/server/ch-0702-media-import.test.js'), 'utf8');
|
||||
const disclosureTests = fs.readFileSync(path.join(root, 'test/server/ch-0703-expert-navigation.test.js'), 'utf8');
|
||||
assert.match(importTests, /recoverable provider cover/);
|
||||
assert.match(cacheTests, /preserve pinned cache and imported originals/);
|
||||
assert.match(disclosureTests, /Expert mode off|Expert-only|Expert commands/i);
|
||||
});
|
||||
@@ -0,0 +1,131 @@
|
||||
'use strict';
|
||||
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const test = require('node:test');
|
||||
const initSqlJs = require('sql.js');
|
||||
const migration = require('../../src/database/migrations/0012-create-trophy-psn-snapshots');
|
||||
const { SecretsStore } = require('../../src/security/secrets-store');
|
||||
const {
|
||||
createAuthorizedPsnTrophyProvider,
|
||||
createJsonTrophySnapshotProvider,
|
||||
createSqliteTrophySnapshotRepository,
|
||||
parseTrophySnapshot,
|
||||
} = require('../../src/services/trophy-snapshot-service');
|
||||
|
||||
function sample(overrides = {}) {
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
provider: { id: 'user-export', version: '2.4.0' },
|
||||
account: { accountId: '1234567890123456', onlineId: 'Player_One' },
|
||||
context: { region: 'BE', language: 'nl-BE' },
|
||||
fetchedAt: '2026-07-20T12:00:00.000Z',
|
||||
warnings: ['export is read-only'],
|
||||
titles: [{
|
||||
npCommunicationId: 'NPWR12345_00', titleId: 'PPSA12345', contentId: 'EP0001-PPSA12345_00-GAME000000000001', name: 'Example Game', platform: 'PS5', service: 'trophy2',
|
||||
groups: [{ groupId: 'default', name: 'Base Game' }],
|
||||
trophies: [
|
||||
{ groupId: 'default', trophyId: 2, name: 'Second', grade: 'silver', hidden: false, earned: false, progress: 20 },
|
||||
{ groupId: 'default', trophyId: 1, name: 'First', grade: 'gold', hidden: false, earned: true, earnedDateTime: '2026-01-02T03:04:05Z', progress: 100 },
|
||||
],
|
||||
}],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
async function repository() {
|
||||
const SQL = await initSqlJs();
|
||||
const db = new SQL.Database();
|
||||
migration.up(db);
|
||||
return { db, repository: createSqliteTrophySnapshotRepository({ db }) };
|
||||
}
|
||||
|
||||
test('JSON import validates, normalizes and stores canonical immutable proof', async t => {
|
||||
const { db, repository: repo } = await repository();
|
||||
t.after(() => db.close());
|
||||
const provider = createJsonTrophySnapshotProvider({ repository: repo, now: () => 1800000000000 });
|
||||
const first = provider.authenticateOrImport({ ...sample(), signature: 'signature-a' }, { filename: 'proof.json' });
|
||||
const reordered = sample({ warnings: ['export is read-only'], signature: 'signature-b' });
|
||||
reordered.titles[0].trophies.reverse();
|
||||
const second = provider.authenticateOrImport(JSON.stringify(reordered), { filename: 'other.json' });
|
||||
assert.equal(first.id, second.id);
|
||||
assert.equal(first.canonicalSha256, second.canonicalSha256);
|
||||
assert.deepEqual(first.snapshot.titles[0].trophies.map(item => item.trophyId), [1, 2]);
|
||||
assert.equal(repo.list().length, 1);
|
||||
assert.throws(() => db.run('UPDATE trophy_psn_snapshots SET online_id = ?', ['Changed']), /immutable/);
|
||||
const publicModel = provider.sanitizePublicModel(first);
|
||||
assert.equal(publicModel.account.accountId.endsWith('3456'), true);
|
||||
assert.equal(publicModel.account.accountId.includes('123456789012'), false);
|
||||
assert.equal(publicModel.readOnly, true);
|
||||
});
|
||||
|
||||
test('invalid JSON, unknown fields, invalid timestamps and duplicate identities fail closed', () => {
|
||||
assert.throws(() => parseTrophySnapshot('{'), error => error.code === 'TROPHY_SCHEMA');
|
||||
assert.throws(() => parseTrophySnapshot({ ...sample(), writeEndpoint: 'https://example.invalid' }), error => error.code === 'TROPHY_SCHEMA');
|
||||
const missingTime = sample();
|
||||
delete missingTime.titles[0].trophies[1].earnedDateTime;
|
||||
assert.throws(() => parseTrophySnapshot(missingTime), error => error.code === 'TROPHY_SCHEMA');
|
||||
const duplicate = sample();
|
||||
duplicate.titles[0].trophies.push({ ...duplicate.titles[0].trophies[0] });
|
||||
assert.throws(() => parseTrophySnapshot(duplicate), error => error.code === 'TROPHY_SCHEMA');
|
||||
});
|
||||
|
||||
test('authorized provider encrypts and redacts its token and revoke retains proof', async t => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'chimera-trophy-secrets-'));
|
||||
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
const { db, repository: repo } = await repository();
|
||||
t.after(() => db.close());
|
||||
const secrets = new SecretsStore({ filePath: path.join(root, 'secrets.json'), keyFile: path.join(root, 'secret.key') });
|
||||
const token = 'read-only-token-that-must-never-leak';
|
||||
const provider = createAuthorizedPsnTrophyProvider({ repository: repo, secrets, fetchPage: async () => ({ titles: [], nextCursor: null }), now: () => 1800000000000 });
|
||||
const auth = provider.authenticateOrImport({ accessToken: token });
|
||||
assert.equal(auth.authenticated, true);
|
||||
assert.equal(fs.readFileSync(path.join(root, 'secrets.json'), 'utf8').includes(token), false);
|
||||
assert.equal(JSON.stringify(provider.sanitizePublicModel()).includes(token), false);
|
||||
assert.equal(provider.revoke().snapshotsRetained, true);
|
||||
assert.equal(secrets.has('trophy.psn.readonly.access-token'), false);
|
||||
});
|
||||
|
||||
test('authorized provider paginates with bounded GET requests and rate limiting', async t => {
|
||||
const { db, repository: repo } = await repository();
|
||||
t.after(() => db.close());
|
||||
const values = new Map([['trophy.psn.readonly.access-token', 'read-only-token-123456']]);
|
||||
const secrets = { set: (key, value) => value ? values.set(key, value) : values.delete(key), get: key => values.get(key) || '', has: key => values.has(key) };
|
||||
let clock = 1800000000000;
|
||||
const calls = [];
|
||||
const pages = [
|
||||
{ titles: [sample().titles[0]], nextCursor: 'page-2' },
|
||||
{ titles: [{ ...sample().titles[0], npCommunicationId: 'NPWR54321_00', titleId: 'PPSA54321' }], nextCursor: null },
|
||||
];
|
||||
const provider = createAuthorizedPsnTrophyProvider({
|
||||
repository: repo,
|
||||
secrets,
|
||||
now: () => clock,
|
||||
sleep: async ms => { clock += ms; },
|
||||
limits: { minIntervalMs: 100, pageSize: 1, pages: 3 },
|
||||
fetchPage: async request => { calls.push({ method: request.method, cursor: request.cursor, at: clock, token: request.accessToken }); return pages.shift(); },
|
||||
});
|
||||
const stored = await provider.fetchSnapshot({ accountId: '1234567890123456', onlineId: 'Player_One', region: 'BE', language: 'nl-BE' });
|
||||
assert.equal(stored.titleCount, 2);
|
||||
assert.deepEqual(calls.map(call => call.method), ['GET', 'GET']);
|
||||
assert.ok(calls[1].at - calls[0].at >= 100);
|
||||
assert.equal(JSON.stringify(provider.sanitizePublicModel(stored)).includes(calls[0].token), false);
|
||||
|
||||
const looping = createAuthorizedPsnTrophyProvider({ repository: repo, secrets, now: () => clock, sleep: async ms => { clock += ms; }, limits: { pages: 2, minIntervalMs: 0 }, fetchPage: async ({ cursor }) => ({ titles: [], nextCursor: cursor ? 'third' : 'second' }) });
|
||||
await assert.rejects(() => looping.fetchSnapshot({ accountId: '1234567890123456', onlineId: 'Player_One', region: 'BE', language: 'nl-BE' }), error => error.code === 'TROPHY_LIMIT');
|
||||
});
|
||||
|
||||
test('snapshot providers expose only read-only remote trophy contracts', () => {
|
||||
const schema = require('../../schemas/trophy-psn-proof.schema.json');
|
||||
assert.equal(schema.properties.schemaVersion.const, 1);
|
||||
assert.equal(schema.additionalProperties, false);
|
||||
const source = fs.readFileSync(path.resolve(__dirname, '../../src/services/trophy-snapshot-service.js'), 'utf8');
|
||||
assert.match(source, /capabilities:\s*CAPABILITIES/);
|
||||
assert.doesNotMatch(source, /method:\s*['"](?:POST|PUT|PATCH|DELETE)['"]/i);
|
||||
assert.doesNotMatch(source, /troph(?:y|ies).{0,40}(?:sync|unlock|write)/i);
|
||||
const importer = fs.readFileSync(path.resolve(__dirname, '../../src/services/psn-trophy-history-import-service.js'), 'utf8');
|
||||
assert.doesNotMatch(importer, /method:\s*['"](?:POST|PUT|PATCH|DELETE)['"]/i);
|
||||
assert.doesNotMatch(importer, /troph(?:y|ies).{0,40}(?:sync|unlock|write)/i);
|
||||
});
|
||||
@@ -0,0 +1,104 @@
|
||||
'use strict';
|
||||
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const test = require('node:test');
|
||||
const initSqlJs = require('sql.js');
|
||||
const migration = require('../../src/database/migrations/0013-create-trophy-local-snapshots');
|
||||
const {
|
||||
createSimulatedLocalTrophyProvider,
|
||||
createSqliteLocalTrophyRepository,
|
||||
createUnsupportedLocalTrophyProvider,
|
||||
parseLocalTrophySnapshot,
|
||||
} = require('../../src/services/local-trophy-inventory-service');
|
||||
|
||||
function sample(status = 'complete') {
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
provider: { id: 'simulated-local-reader', version: '1.0.0', firmware: '9.60', capabilities: ['profile-identity', 'list-title-sets', 'list-earned-trophies', 'earned-timestamps'] },
|
||||
profile: { profileId: 'profile-12345678', localUserId: 'user-87654321', accountId: '1234567890123456' },
|
||||
coverage: { status, completedScopes: status === 'inaccessible' ? [] : ['profile', 'NPWR12345_00'], failedScopes: status === 'complete' ? [] : ['NPWR54321_00'] },
|
||||
observedAt: '2026-07-21T12:00:00.000Z',
|
||||
titles: [{
|
||||
npCommunicationId: 'NPWR12345_00', titleId: 'PPSA12345', platform: 'PS5', service: 'trophy2',
|
||||
trophies: [
|
||||
{ groupId: 'default', trophyId: 2, earned: false },
|
||||
{ groupId: 'default', trophyId: 1, earned: true, earnedDateTime: '2026-01-02T03:04:05Z', definitionHash: 'a'.repeat(64) },
|
||||
],
|
||||
}],
|
||||
};
|
||||
}
|
||||
|
||||
async function setup() {
|
||||
const SQL = await initSqlJs();
|
||||
const db = new SQL.Database();
|
||||
migration.up(db);
|
||||
return { db, repository: createSqliteLocalTrophyRepository({ db }) };
|
||||
}
|
||||
|
||||
test('complete local snapshot preserves exact IDs and alone authorizes absence conclusions', async t => {
|
||||
const { db, repository } = await setup();
|
||||
t.after(() => db.close());
|
||||
const provider = createSimulatedLocalTrophyProvider({ repository, now: () => 1800000000000 });
|
||||
const record = provider.fetchSnapshot(sample('complete'));
|
||||
assert.equal(record.snapshot.profile.profileId, 'profile-12345678');
|
||||
assert.equal(record.snapshot.titles[0].npCommunicationId, 'NPWR12345_00');
|
||||
assert.deepEqual(record.snapshot.titles[0].trophies.map(item => item.trophyId), [1, 2]);
|
||||
assert.equal(record.snapshot.coverage.canConcludeAbsence, true);
|
||||
assert.equal(record.snapshot.provider.firmware, '9.60');
|
||||
assert.deepEqual(record.snapshot.provider.capabilities, ['earned-timestamps', 'list-earned-trophies', 'list-title-sets', 'profile-identity']);
|
||||
assert.match(record.rawSha256, /^[a-f0-9]{64}$/);
|
||||
assert.throws(() => db.run('UPDATE trophy_local_snapshots SET firmware = ?', ['10.00']), /immutable/);
|
||||
});
|
||||
|
||||
test('partial and inaccessible snapshots can never authorize trophy absence', async t => {
|
||||
const { db, repository } = await setup();
|
||||
t.after(() => db.close());
|
||||
let now = 1800000000000;
|
||||
const provider = createSimulatedLocalTrophyProvider({ repository, now: () => now++ });
|
||||
const partial = provider.fetchSnapshot(sample('partial'));
|
||||
const inaccessible = provider.fetchSnapshot(sample('inaccessible'));
|
||||
assert.equal(partial.snapshot.coverage.canConcludeAbsence, false);
|
||||
assert.equal(inaccessible.snapshot.coverage.canConcludeAbsence, false);
|
||||
assert.deepEqual(partial.snapshot.coverage.failedScopes, ['NPWR54321_00']);
|
||||
const invalid = sample('complete');
|
||||
invalid.coverage.failedScopes = ['unreadable-title'];
|
||||
assert.throws(() => parseLocalTrophySnapshot(invalid), error => error.code === 'LOCAL_TROPHY_SCHEMA');
|
||||
});
|
||||
|
||||
test('unsupported live provider is explicit and exposes no mutation capability', () => {
|
||||
const provider = createUnsupportedLocalTrophyProvider({ firmware: '10.00', reason: 'Pinned read-only adapter is unavailable for this firmware.' });
|
||||
assert.deepEqual(Object.keys(provider).sort(), ['capabilities', 'fetchSnapshot', 'id', 'sanitizePublicModel', 'status']);
|
||||
assert.deepEqual(provider.capabilities, []);
|
||||
assert.equal(provider.status().available, false);
|
||||
assert.equal(provider.status().readOnly, true);
|
||||
assert.match(provider.status().reason, /unavailable/);
|
||||
assert.throws(() => provider.fetchSnapshot(), error => error.code === 'LOCAL_TROPHY_UNSUPPORTED');
|
||||
});
|
||||
|
||||
test('public local model masks profile identities and invalid evidence fails closed', async t => {
|
||||
const { db, repository } = await setup();
|
||||
t.after(() => db.close());
|
||||
const provider = createSimulatedLocalTrophyProvider({ repository, now: () => 1800000000000 });
|
||||
const publicModel = provider.sanitizePublicModel(provider.fetchSnapshot(sample()));
|
||||
assert.equal(JSON.stringify(publicModel).includes('1234567890123456'), false);
|
||||
assert.equal(JSON.stringify(publicModel).includes('profile-12345678'), false);
|
||||
assert.equal(publicModel.coverage.canConcludeAbsence, true);
|
||||
assert.equal(publicModel.readOnly, true);
|
||||
|
||||
const noTimestamp = sample();
|
||||
delete noTimestamp.titles[0].trophies[1].earnedDateTime;
|
||||
assert.throws(() => parseLocalTrophySnapshot(noTimestamp), error => error.code === 'LOCAL_TROPHY_SCHEMA');
|
||||
const hostile = sample();
|
||||
hostile.provider.capabilities.push('write-trophies');
|
||||
assert.throws(() => parseLocalTrophySnapshot(hostile), error => error.code === 'LOCAL_TROPHY_SCHEMA');
|
||||
});
|
||||
|
||||
test('schema and source contain only read-only local provider surfaces', () => {
|
||||
const schema = require('../../schemas/trophy-local-inventory.schema.json');
|
||||
assert.equal(schema.additionalProperties, false);
|
||||
const source = fs.readFileSync(path.resolve(__dirname, '../../src/services/local-trophy-inventory-service.js'), 'utf8');
|
||||
assert.doesNotMatch(source, /(?:unlock|reconstruct|applyTrophy|writeTrophy|deleteTrophy)/i);
|
||||
assert.match(source, /canConcludeAbsence:\s*coverageStatus === 'complete'/);
|
||||
});
|
||||
@@ -0,0 +1,122 @@
|
||||
'use strict';
|
||||
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const test = require('node:test');
|
||||
const initSqlJs = require('sql.js');
|
||||
const migration12 = require('../../src/database/migrations/0012-create-trophy-psn-snapshots');
|
||||
const migration13 = require('../../src/database/migrations/0013-create-trophy-local-snapshots');
|
||||
const migration14 = require('../../src/database/migrations/0014-create-trophy-account-mappings');
|
||||
const { createJsonTrophySnapshotProvider, createSqliteTrophySnapshotRepository } = require('../../src/services/trophy-snapshot-service');
|
||||
const { createSimulatedLocalTrophyProvider, createSqliteLocalTrophyRepository } = require('../../src/services/local-trophy-inventory-service');
|
||||
const { DIFF_STATES, createSqliteTrophyMappingRepository, createTrophyDiffService } = require('../../src/services/trophy-mapping-diff-service');
|
||||
|
||||
const HASH_A = 'a'.repeat(64);
|
||||
const HASH_B = 'b'.repeat(64);
|
||||
const TIME = '2026-01-02T03:04:05.000Z';
|
||||
|
||||
function psnPayload(name = 'English title') {
|
||||
return {
|
||||
schemaVersion: 1, provider: { id: 'json-proof', version: '1' }, account: { accountId: '1234567890123456', onlineId: 'Player_One' }, context: { region: 'BE', language: 'en-BE' }, fetchedAt: '2026-07-20T12:00:00Z',
|
||||
titles: [{ npCommunicationId: 'NPWR12345_00', titleId: 'PPSA12345', name, platform: 'PS5', service: 'trophy2', groups: [{ groupId: 'default', name: 'Base' }], trophies: [
|
||||
{ groupId: 'default', trophyId: 1, name: 'Same', grade: 'gold', hidden: false, earned: true, earnedDateTime: TIME, progress: 100, definitionHash: HASH_A },
|
||||
{ groupId: 'default', trophyId: 2, name: 'Source earned', grade: 'silver', hidden: false, earned: true, earnedDateTime: TIME, progress: 100, definitionHash: HASH_A },
|
||||
{ groupId: 'default', trophyId: 3, name: 'Local earned', grade: 'bronze', hidden: false, earned: false, progress: 0, definitionHash: HASH_A },
|
||||
{ groupId: 'default', trophyId: 4, name: 'Metadata differs', grade: 'silver', hidden: false, earned: true, earnedDateTime: TIME, progress: 100, definitionHash: HASH_A },
|
||||
{ groupId: 'default', trophyId: 5, name: 'Neither', grade: 'bronze', hidden: false, earned: false, progress: 0, definitionHash: HASH_A },
|
||||
{ groupId: 'default', trophyId: 7, name: 'Missing exact local ID', grade: 'gold', hidden: false, earned: true, earnedDateTime: TIME, progress: 100, definitionHash: HASH_A },
|
||||
] }],
|
||||
};
|
||||
}
|
||||
|
||||
function localPayload(status = 'complete') {
|
||||
return {
|
||||
schemaVersion: 1, provider: { id: 'local-simulator', version: '1', firmware: '9.60', capabilities: ['profile-identity', 'list-title-sets', 'list-earned-trophies', 'earned-timestamps'] },
|
||||
profile: { profileId: 'profile-1234', localUserId: 'user-1234', accountId: '1234567890123456' },
|
||||
coverage: { status, completedScopes: ['NPWR12345_00'], failedScopes: status === 'complete' ? [] : ['NPWR99999_00'] }, observedAt: '2026-07-21T12:00:00Z',
|
||||
titles: [{ npCommunicationId: 'NPWR12345_00', titleId: 'PPSA12345', platform: 'PS5', service: 'trophy2', trophies: [
|
||||
{ groupId: 'default', trophyId: 1, earned: true, earnedDateTime: TIME, definitionHash: HASH_A },
|
||||
{ groupId: 'default', trophyId: 2, earned: false, definitionHash: HASH_A },
|
||||
{ groupId: 'default', trophyId: 3, earned: true, earnedDateTime: TIME, definitionHash: HASH_A },
|
||||
{ groupId: 'default', trophyId: 4, earned: true, earnedDateTime: '2026-01-03T03:04:05Z', definitionHash: HASH_B },
|
||||
{ groupId: 'default', trophyId: 5, earned: false, definitionHash: HASH_A },
|
||||
{ groupId: 'default', trophyId: 6, earned: true, earnedDateTime: TIME, definitionHash: HASH_A },
|
||||
] }],
|
||||
};
|
||||
}
|
||||
|
||||
async function setup() {
|
||||
const SQL = await initSqlJs();
|
||||
const db = new SQL.Database();
|
||||
migration12.up(db); migration13.up(db); migration14.up(db);
|
||||
let now = 1800000000000;
|
||||
const psnProvider = createJsonTrophySnapshotProvider({ repository: createSqliteTrophySnapshotRepository({ db }), now: () => now++ });
|
||||
const localProvider = createSimulatedLocalTrophyProvider({ repository: createSqliteLocalTrophyRepository({ db }), now: () => now++ });
|
||||
const mappings = createSqliteTrophyMappingRepository({ db, now: () => now++, idFactory: () => 'mapping-1' });
|
||||
const mapping = mappings.create({ psnAccountId: '1234567890123456', localProfileId: 'profile-1234', selectedByUser: true, confirmation: 'MAP TROPHY ACCOUNT', reason: 'User selected the matching local profile.' });
|
||||
return { db, psnProvider, localProvider, mappings, mapping, diff: createTrophyDiffService({ mappingRepository: mappings }) };
|
||||
}
|
||||
|
||||
test('exact account/title/trophy mapping produces every evidence diff state', async t => {
|
||||
const env = await setup(); t.after(() => env.db.close());
|
||||
const psn = env.psnProvider.authenticateOrImport(psnPayload());
|
||||
const local = env.localProvider.fetchSnapshot(localPayload('complete'));
|
||||
const result = env.diff.compare({ psnSnapshot: psn, localSnapshot: local, mappingId: env.mapping.id });
|
||||
const byId = Object.fromEntries(result.items.map(item => [item.trophyId, item.state]));
|
||||
assert.deepEqual(byId, { 1: 'both', 2: 'psn_only', 3: 'local_only', 4: 'metadata_mismatch', 5: 'both', 6: 'local_only', 7: 'unmappable' });
|
||||
assert.equal(result.mappingExact, true);
|
||||
assert.equal(result.readOnly, true);
|
||||
assert.deepEqual(new Set(Object.keys(result.counts)), new Set(DIFF_STATES));
|
||||
|
||||
const partial = env.localProvider.fetchSnapshot(localPayload('partial'));
|
||||
const partialResult = env.diff.compare({ psnSnapshot: psn, localSnapshot: partial, mappingId: env.mapping.id });
|
||||
assert.equal(partialResult.items.find(item => item.trophyId === 2).state, 'unknown');
|
||||
});
|
||||
|
||||
test('missing, mismatched and ambiguous exact identities never fall back to names or position', async t => {
|
||||
const env = await setup(); t.after(() => env.db.close());
|
||||
const psn = env.psnProvider.authenticateOrImport(psnPayload());
|
||||
const local = env.localProvider.fetchSnapshot(localPayload());
|
||||
const missing = env.diff.compare({ psnSnapshot: psn, localSnapshot: local, mappingId: 'missing' });
|
||||
assert.equal(missing.mappingReason, 'mapping_missing');
|
||||
assert.ok(missing.items.every(item => item.state === 'unmappable'));
|
||||
|
||||
const ambiguous = { ...psn, snapshot: JSON.parse(JSON.stringify(psn.snapshot)) };
|
||||
ambiguous.snapshot.titles[0].trophies.push({ ...ambiguous.snapshot.titles[0].trophies[0] });
|
||||
assert.throws(() => env.diff.compare({ psnSnapshot: ambiguous, localSnapshot: local, mappingId: env.mapping.id }), error => error.code === 'TROPHY_DIFF_AMBIGUOUS');
|
||||
});
|
||||
|
||||
test('localized names are diagnostic only and do not change exact diff states', async t => {
|
||||
const env = await setup(); t.after(() => env.db.close());
|
||||
const local = env.localProvider.fetchSnapshot(localPayload());
|
||||
const english = env.diff.compare({ psnSnapshot: env.psnProvider.authenticateOrImport(psnPayload('English title')), localSnapshot: local, mappingId: env.mapping.id });
|
||||
const localizedPayload = psnPayload('Titre francais');
|
||||
localizedPayload.titles[0].trophies.forEach(item => { item.name = `Nom ${item.trophyId}`; });
|
||||
const localized = env.diff.compare({ psnSnapshot: env.psnProvider.authenticateOrImport(localizedPayload), localSnapshot: local, mappingId: env.mapping.id });
|
||||
assert.deepEqual(english.items.map(item => [item.trophyId, item.state]), localized.items.map(item => [item.trophyId, item.state]));
|
||||
});
|
||||
|
||||
test('mapping revocation changes state fingerprint and invalidates exact comparison', async t => {
|
||||
const env = await setup(); t.after(() => env.db.close());
|
||||
const psn = env.psnProvider.authenticateOrImport(psnPayload());
|
||||
const local = env.localProvider.fetchSnapshot(localPayload());
|
||||
const before = env.diff.compare({ psnSnapshot: psn, localSnapshot: local, mappingId: env.mapping.id });
|
||||
assert.throws(() => env.mappings.revoke(env.mapping.id, { confirmation: 'wrong', reason: 'test' }), error => error.code === 'TROPHY_MAPPING_CONFIRMATION');
|
||||
const revoked = env.mappings.revoke(env.mapping.id, { confirmation: 'REVOKE TROPHY MAPPING', reason: 'Local profile selection changed.' });
|
||||
const after = env.diff.compare({ psnSnapshot: psn, localSnapshot: local, mappingId: env.mapping.id });
|
||||
assert.equal(revoked.active, false);
|
||||
assert.notEqual(before.mappingStateHash, after.mappingStateHash);
|
||||
assert.notEqual(before.contextHash, after.contextHash);
|
||||
assert.equal(after.mappingReason, 'mapping_revoked');
|
||||
assert.ok(after.items.every(item => item.state === 'unmappable'));
|
||||
});
|
||||
|
||||
test('mapping implementation compares exact IDs and keeps names diagnostic-only', () => {
|
||||
const source = fs.readFileSync(path.resolve(__dirname, '../../src/services/trophy-mapping-diff-service.js'), 'utf8');
|
||||
assert.match(source, /npCommunicationId/);
|
||||
assert.match(source, /groupId/);
|
||||
assert.match(source, /trophyId/);
|
||||
assert.doesNotMatch(source, /\.name\s*===|localeCompare\([^)]*\.name/);
|
||||
assert.match(source, /diagnosticName/);
|
||||
});
|
||||
@@ -0,0 +1,107 @@
|
||||
'use strict';
|
||||
|
||||
const assert = require('node:assert/strict');
|
||||
const test = require('node:test');
|
||||
const initSqlJs = require('sql.js');
|
||||
const migration = require('../../src/database/migrations/0015-create-trophy-reconstruction-plans');
|
||||
const { REQUIRED_CAPABILITIES, createSqliteTrophyPlanRepository, createTrophyReconstructionPlanService } = require('../../src/services/trophy-reconstruction-plan-service');
|
||||
|
||||
const PSN_HASH = 'a'.repeat(64);
|
||||
const LOCAL_HASH = 'b'.repeat(64);
|
||||
const MAP_HASH = 'c'.repeat(64);
|
||||
const CONTEXT_HASH = 'd'.repeat(64);
|
||||
const EARNED_AT = '2026-01-02T03:04:05.000Z';
|
||||
|
||||
function evidence() {
|
||||
const psnSnapshot = { id: 'psn-proof', canonicalSha256: PSN_HASH, immutable: true, snapshot: { account: { accountId: '1234567890123456' } } };
|
||||
const localSnapshot = { id: 'local-proof', canonicalSha256: LOCAL_HASH, immutable: true, snapshot: { provider: { firmware: '9.60' }, coverage: { status: 'complete', canConcludeAbsence: true } } };
|
||||
const item = { state: 'psn_only', npCommunicationId: 'NPWR12345_00', platform: 'PS5', service: 'trophy2', groupId: 'default', trophyId: 2, sourceEarned: true, localEarned: false, sourceEarnedDateTime: EARNED_AT, exactAccountMapping: true, exactTitleMapping: true, exactTrophyMapping: true };
|
||||
const diff = { readOnly: true, psnSnapshotId: psnSnapshot.id, localSnapshotId: localSnapshot.id, mappingId: 'mapping-1', mappingRevision: 1, mappingExact: true, mappingStateHash: MAP_HASH, contextHash: CONTEXT_HASH, items: [item] };
|
||||
const adapter = { id: 'simulated-local-adapter', version: '1.0.0', timestampPolicy: 'preserve_exact', capabilities: [...REQUIRED_CAPABILITIES], firmwares: ['9.60'], services: ['trophy2'] };
|
||||
const selections = [{ npCommunicationId: item.npCommunicationId, platform: item.platform, service: item.service, groupId: item.groupId, trophyId: item.trophyId }];
|
||||
return { psnSnapshot, localSnapshot, diff, adapter, selections };
|
||||
}
|
||||
|
||||
async function setup(nowRef = { value: 1800000000000 }) {
|
||||
const SQL = await initSqlJs();
|
||||
const db = new SQL.Database();
|
||||
migration.up(db);
|
||||
const repository = createSqliteTrophyPlanRepository({ db });
|
||||
return { db, repository, service: createTrophyReconstructionPlanService({ repository, now: () => nowRef.value }), nowRef };
|
||||
}
|
||||
|
||||
test('eligible PSN-earned exact evidence creates one immutable idempotent plan', async t => {
|
||||
const env = await setup(); t.after(() => env.db.close());
|
||||
const input = evidence();
|
||||
const first = env.service.create(input);
|
||||
env.nowRef.value += 1000;
|
||||
const second = env.service.create(input);
|
||||
assert.equal(first.id, second.id);
|
||||
assert.equal(first.idempotencyKey, second.idempotencyKey);
|
||||
assert.equal(env.repository.list().length, 1);
|
||||
assert.deepEqual(first.psnSnapshot, { id: 'psn-proof', sha256: PSN_HASH });
|
||||
assert.deepEqual(first.localSnapshot, { id: 'local-proof', sha256: LOCAL_HASH });
|
||||
assert.equal(first.items[0].sourceEarned, true);
|
||||
assert.equal(first.items[0].sourceEarnedDateTime, EARNED_AT);
|
||||
assert.deepEqual(first.requiredBackup, { status: 'required', artifactRequired: true });
|
||||
assert.throws(() => env.db.run('UPDATE trophy_reconstruction_plans SET adapter_version = ?', ['2']), /immutable/);
|
||||
});
|
||||
|
||||
test('eligibility matrix blocks unearned, wrong-state, incomplete and incapable inputs', async t => {
|
||||
const env = await setup(); t.after(() => env.db.close());
|
||||
const cases = [
|
||||
['STATE_NOT_PSN_ONLY', input => { input.diff.items[0].state = 'both'; }],
|
||||
['SOURCE_NOT_EARNED', input => { input.diff.items[0].sourceEarned = false; }],
|
||||
['ACCOUNT_MAPPING_NOT_EXACT', input => { input.diff.mappingExact = false; }],
|
||||
['LOCAL_COVERAGE_INCOMPLETE', input => { input.localSnapshot.snapshot.coverage = { status: 'partial', canConcludeAbsence: false }; }],
|
||||
['ADAPTER_CAPABILITY_MISSING:verified-backup', input => { input.adapter.capabilities = input.adapter.capabilities.filter(item => item !== 'verified-backup'); }],
|
||||
['ADAPTER_FIRMWARE_UNSUPPORTED', input => { input.adapter.firmwares = ['10.00']; }],
|
||||
['ADAPTER_SERVICE_UNSUPPORTED:trophy2', input => { input.adapter.services = ['trophy']; }],
|
||||
];
|
||||
for (const [reason, mutate] of cases) {
|
||||
const input = evidence(); mutate(input);
|
||||
const assessment = env.service.assess(input);
|
||||
assert.equal(assessment.eligible, false, reason);
|
||||
assert.ok(assessment.reasons.some(item => item === reason || item.startsWith(`${reason}:`)), `${reason}: ${assessment.reasons.join(',')}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('arbitrary, duplicate and unearned trophy selections are rejected', async t => {
|
||||
const env = await setup(); t.after(() => env.db.close());
|
||||
const arbitrary = evidence(); arbitrary.selections[0].trophyId = 999;
|
||||
assert.throws(() => env.service.create(arbitrary), error => error.code === 'TROPHY_PLAN_INELIGIBLE' && error.reasons.some(reason => reason.startsWith('ARBITRARY_TROPHY_ID:')));
|
||||
const duplicate = evidence(); duplicate.selections.push({ ...duplicate.selections[0] });
|
||||
assert.throws(() => env.service.create(duplicate), error => error.code === 'TROPHY_PLAN_INELIGIBLE' && error.reasons.includes('DUPLICATE_SELECTION'));
|
||||
const unearned = evidence(); unearned.diff.items[0].sourceEarned = false;
|
||||
assert.throws(() => env.service.create(unearned), error => error.code === 'TROPHY_PLAN_INELIGIBLE' && error.reasons.some(reason => reason.startsWith('SOURCE_NOT_EARNED:')));
|
||||
});
|
||||
|
||||
test('snapshot, mapping, adapter and expiry changes invalidate frozen plans', async t => {
|
||||
const env = await setup(); t.after(() => env.db.close());
|
||||
const input = evidence();
|
||||
const plan = env.service.create({ ...input, ttlMs: 60000 });
|
||||
assert.equal(env.service.validate(plan, input).valid, true);
|
||||
|
||||
const sourceChanged = evidence(); sourceChanged.psnSnapshot = { ...sourceChanged.psnSnapshot, canonicalSha256: 'e'.repeat(64) };
|
||||
assert.ok(env.service.validate(plan, sourceChanged).reasons.includes('SOURCE_SNAPSHOT_CHANGED'));
|
||||
const localChanged = evidence(); localChanged.localSnapshot = { ...localChanged.localSnapshot, canonicalSha256: 'f'.repeat(64) };
|
||||
assert.ok(env.service.validate(plan, localChanged).reasons.includes('LOCAL_SNAPSHOT_CHANGED'));
|
||||
const mappingChanged = evidence(); mappingChanged.diff = { ...mappingChanged.diff, mappingStateHash: 'e'.repeat(64), contextHash: 'f'.repeat(64), mappingRevision: 2 };
|
||||
assert.ok(env.service.validate(plan, mappingChanged).reasons.includes('MAPPING_OR_DIFF_CHANGED'));
|
||||
const adapterChanged = evidence(); adapterChanged.adapter = { ...adapterChanged.adapter, version: '2.0.0' };
|
||||
assert.ok(env.service.validate(plan, adapterChanged).reasons.includes('ADAPTER_CHANGED'));
|
||||
env.nowRef.value = plan.expiresAt;
|
||||
assert.ok(env.service.validate(plan, evidence()).reasons.includes('PLAN_EXPIRED'));
|
||||
});
|
||||
|
||||
test('only preserve_exact timestamp policy can create a plan', async t => {
|
||||
const env = await setup(); t.after(() => env.db.close());
|
||||
for (const policy of ['preserve_date_only', 'current_time_only', 'unsupported']) {
|
||||
const input = evidence(); input.adapter.timestampPolicy = policy;
|
||||
const assessment = env.service.assess(input);
|
||||
assert.equal(assessment.eligible, false);
|
||||
assert.ok(assessment.reasons.includes('TIMESTAMP_POLICY_UNSUPPORTED'));
|
||||
}
|
||||
const exact = env.service.assess(evidence());
|
||||
assert.equal(exact.eligible, true);
|
||||
});
|
||||
@@ -0,0 +1,198 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const test = require('node:test');
|
||||
const initSqlJs = require('sql.js');
|
||||
|
||||
const migration = require('../../src/database/migrations/0016-create-trophy-reconstruction-journals');
|
||||
const {
|
||||
ACTION_CAPABILITIES,
|
||||
createSqliteTrophyActionRepository,
|
||||
createTrophyBackupService,
|
||||
createTrophyReconstructionActionService,
|
||||
createTrophyReconstructionSimulator,
|
||||
createUnsupportedLiveTrophyAdapter,
|
||||
} = require('../../src/services/trophy-reconstruction-action-service');
|
||||
|
||||
const FUTURE = '2099-01-01T00:00:00.000Z';
|
||||
|
||||
function item(trophyId, earnedDateTime = `2026-01-${String(trophyId).padStart(2, '0')}T03:04:05.000Z`) {
|
||||
return {
|
||||
npCommunicationId: 'NPWR12345_00',
|
||||
platform: 'PS5',
|
||||
service: 'trophy2',
|
||||
groupId: 'default',
|
||||
trophyId,
|
||||
sourceEarned: true,
|
||||
sourceEarnedDateTime: earnedDateTime,
|
||||
expectedLocalPreState: { earned: false, earnedDateTime: null },
|
||||
};
|
||||
}
|
||||
|
||||
function plan(items = [item(2)]) {
|
||||
return {
|
||||
id: `trophy-plan-${'a'.repeat(64)}`,
|
||||
planSha256: 'a'.repeat(64),
|
||||
idempotencyKey: 'b'.repeat(64),
|
||||
immutable: true,
|
||||
createdAt: '2026-07-22T00:00:00.000Z',
|
||||
expiresAt: FUTURE,
|
||||
adapter: {
|
||||
id: 'simulated-local-adapter',
|
||||
version: '1.0.0',
|
||||
timestampPolicy: 'preserve_exact',
|
||||
capabilities: [...ACTION_CAPABILITIES],
|
||||
firmwares: ['9.60'],
|
||||
services: ['trophy2'],
|
||||
},
|
||||
target: { firmware: '9.60', service: 'trophy2' },
|
||||
items,
|
||||
requiredBackup: { status: 'required', artifactRequired: true },
|
||||
};
|
||||
}
|
||||
|
||||
function initialState(items) {
|
||||
return items.map((entry) => ({
|
||||
npCommunicationId: entry.npCommunicationId,
|
||||
platform: entry.platform,
|
||||
service: entry.service,
|
||||
groupId: entry.groupId,
|
||||
trophyId: entry.trophyId,
|
||||
earned: false,
|
||||
earnedDateTime: null,
|
||||
}));
|
||||
}
|
||||
|
||||
async function harness(t, options = {}) {
|
||||
const SQL = await initSqlJs();
|
||||
const db = new SQL.Database();
|
||||
migration.up(db);
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'chimera-ch0905-'));
|
||||
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
|
||||
let sequence = 0;
|
||||
const now = () => 1784721600000;
|
||||
const repository = createSqliteTrophyActionRepository({
|
||||
db,
|
||||
now,
|
||||
idFactory: () => `run-${++sequence}`,
|
||||
});
|
||||
const backupService = createTrophyBackupService({
|
||||
backupRoot: root,
|
||||
repository,
|
||||
now,
|
||||
idFactory: () => `backup-${++sequence}`,
|
||||
});
|
||||
const selectedPlan = options.plan || plan();
|
||||
const adapter = createTrophyReconstructionSimulator({
|
||||
initialItems: initialState(selectedPlan.items),
|
||||
...options.simulator,
|
||||
});
|
||||
const service = createTrophyReconstructionActionService({
|
||||
repository,
|
||||
backupService,
|
||||
now,
|
||||
planValidator: async () => ({ valid: true, reasons: [] }),
|
||||
});
|
||||
return { adapter, backupService, db, repository, selectedPlan, service };
|
||||
}
|
||||
|
||||
function confirmation(selectedPlan) {
|
||||
return `RECONSTRUCT LOCAL TROPHIES ${selectedPlan.items.length}`;
|
||||
}
|
||||
|
||||
test('verified backup is mandatory and backup failure prevents all mutation', async (t) => {
|
||||
const context = await harness(t, { simulator: { backupFailure: true } });
|
||||
await assert.rejects(
|
||||
context.service.execute({ plan: context.selectedPlan, adapter: context.adapter, confirmation: confirmation(context.selectedPlan) }),
|
||||
(error) => error.code === 'TROPHY_BACKUP_FAILED',
|
||||
);
|
||||
assert.equal(context.adapter.metrics().applyCalls, 0);
|
||||
assert.deepEqual(context.repository.listRuns(context.selectedPlan.id), []);
|
||||
});
|
||||
|
||||
test('successful apply uses frozen items and rerun is idempotent', async (t) => {
|
||||
const context = await harness(t);
|
||||
const first = await context.service.execute({
|
||||
plan: context.selectedPlan,
|
||||
adapter: context.adapter,
|
||||
confirmation: confirmation(context.selectedPlan),
|
||||
});
|
||||
assert.equal(first.status, 'completed');
|
||||
assert.equal(first.postVerification.verified, true);
|
||||
assert.equal(context.adapter.metrics().applyCalls, context.selectedPlan.items.length);
|
||||
|
||||
const storedBackup = await context.backupService.load(first.backupId);
|
||||
assert.equal(storedBackup.verified, true);
|
||||
assert.throws(
|
||||
() => context.db.run("UPDATE trophy_reconstruction_backups SET manifest_json = '{}'"),
|
||||
/immutable/i,
|
||||
);
|
||||
|
||||
const second = await context.service.execute({
|
||||
plan: context.selectedPlan,
|
||||
adapter: context.adapter,
|
||||
confirmation: confirmation(context.selectedPlan),
|
||||
});
|
||||
assert.equal(second.idempotent, true);
|
||||
assert.equal(second.runId, first.runId);
|
||||
assert.equal(context.adapter.metrics().applyCalls, context.selectedPlan.items.length);
|
||||
});
|
||||
|
||||
test('mid-action failure journals items and restores the verified backup', async (t) => {
|
||||
const selectedPlan = plan([item(2), item(3)]);
|
||||
const context = await harness(t, {
|
||||
plan: selectedPlan,
|
||||
simulator: { failAtItem: 'NPWR12345_00:PS5:trophy2:default:3' },
|
||||
});
|
||||
await assert.rejects(
|
||||
context.service.execute({ plan: selectedPlan, adapter: context.adapter, confirmation: confirmation(selectedPlan) }),
|
||||
(error) => error.code === 'TROPHY_ACTION_FAILED' && error.rollbackStatus === 'rolled_back',
|
||||
);
|
||||
assert.ok(context.adapter.state().every(([, entry]) => entry.earned === false));
|
||||
const [run] = context.repository.listRuns(selectedPlan.id);
|
||||
assert.equal(run.status, 'rolled_back');
|
||||
const journal = run.items;
|
||||
assert.equal(journal[0].status, 'verified');
|
||||
assert.equal(journal[1].status, 'failed');
|
||||
});
|
||||
|
||||
test('post-scan mismatch triggers rollback instead of reporting success', async (t) => {
|
||||
const context = await harness(t, { simulator: { postScanMismatch: true } });
|
||||
await assert.rejects(
|
||||
context.service.execute({ plan: context.selectedPlan, adapter: context.adapter, confirmation: confirmation(context.selectedPlan) }),
|
||||
(error) => error.code === 'TROPHY_ACTION_FAILED' && error.rollbackStatus === 'rolled_back',
|
||||
);
|
||||
assert.ok(context.adapter.state().every(([, entry]) => entry.earned === false));
|
||||
});
|
||||
|
||||
test('unsupported exact timestamp capability is rejected before backup or mutation', async (t) => {
|
||||
const context = await harness(t, { simulator: { timestampPolicy: 'current_time_only' } });
|
||||
await assert.rejects(
|
||||
context.service.execute({ plan: context.selectedPlan, adapter: context.adapter, confirmation: confirmation(context.selectedPlan) }),
|
||||
(error) => error.code === 'TROPHY_TIMESTAMP_UNSUPPORTED',
|
||||
);
|
||||
assert.equal(context.adapter.metrics().applyCalls, 0);
|
||||
assert.deepEqual(context.repository.listRuns(context.selectedPlan.id), []);
|
||||
});
|
||||
|
||||
test('action service has no network or PSN provider dependency and live binding is explicitly unavailable', () => {
|
||||
const source = fs.readFileSync(
|
||||
path.join(__dirname, '../../src/services/trophy-reconstruction-action-service.js'),
|
||||
'utf8',
|
||||
);
|
||||
assert.doesNotMatch(source, /node:https?|node-fetch|axios|\bfetch\s*\(/);
|
||||
assert.doesNotMatch(source, /psn-provider|psn-client/i);
|
||||
|
||||
const live = createUnsupportedLiveTrophyAdapter();
|
||||
assert.equal(live.status().available, false);
|
||||
assert.match(live.status().reason, /unsupported|version-pinned/i);
|
||||
|
||||
const status = fs.readFileSync(
|
||||
path.join(__dirname, '../../docs/autonomous/CH-0905_TROPHY_ADAPTER_STATUS.md'),
|
||||
'utf8',
|
||||
);
|
||||
assert.match(status, /unsupported/i);
|
||||
assert.match(status, /version-pinned/i);
|
||||
});
|
||||
@@ -0,0 +1,135 @@
|
||||
'use strict';
|
||||
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const test = require('node:test');
|
||||
const { DIFF_STATES, buildTrophyMarkup, canExecute, normalizeOverview } = require('../../src/renderer/trophies');
|
||||
|
||||
const root = path.resolve(__dirname, '../..');
|
||||
const frozenPlan = {
|
||||
id: 'plan-0906', status: 'ready', confirmationPhrase: 'RECONSTRUCT LOCAL TROPHIES 2',
|
||||
backup: { status: 'verified', verified: true },
|
||||
adapter: { id: 'simulated-local-adapter', version: '1.0.0', available: true },
|
||||
timestampPolicy: 'preserve_exact',
|
||||
items: [
|
||||
{ title: 'Example', identity: 'NPWR12345_00/default/2', eligible: true, reasons: [] },
|
||||
{ title: 'Blocked', identity: 'NPWR12345_00/default/3', eligible: false, reasons: ['Local scan coverage is partial.'] },
|
||||
],
|
||||
};
|
||||
const overview = {
|
||||
source: { provider: 'json-import', fetchedAt: '2026-07-22T00:00:00.000Z', accountIdentity: 'je***@example' },
|
||||
local: { profileIdentity: 'local-***-7', coverage: 'complete', differs: true },
|
||||
diffs: DIFF_STATES.map((state, index) => ({ state, title: `Title ${index}`, identity: `NPWR${index}` })),
|
||||
plan: frozenPlan,
|
||||
};
|
||||
|
||||
test('normal mode renders source and all read-only diff states without Expert controls', () => {
|
||||
const markup = buildTrophyMarkup(overview, { expert: false });
|
||||
assert.match(markup, /Read-only evidence/);
|
||||
assert.match(markup, /Local profile differs/);
|
||||
for (const state of DIFF_STATES) assert.match(markup, new RegExp(`data-diff-state="${state}"`));
|
||||
assert.doesNotMatch(markup, /trophy-confirmation|trophy-reconstruct-submit|Frozen plan items/);
|
||||
});
|
||||
|
||||
test('Expert mode shows exact eligible and ineligible reasons plus safety state', () => {
|
||||
const markup = buildTrophyMarkup(overview, { expert: true });
|
||||
assert.match(markup, /data-expert-only="true"/);
|
||||
assert.match(markup, /NPWR12345_00\/default\/2/);
|
||||
assert.match(markup, /Local scan coverage is partial/);
|
||||
assert.match(markup, /Verified backup/);
|
||||
assert.match(markup, /simulated-local-adapter 1\.0\.0/);
|
||||
assert.match(markup, /preserve_exact/);
|
||||
assert.match(markup, /Local PS5 only - never written to PSN/);
|
||||
});
|
||||
|
||||
test('exact confirmation and every safety gate are required', () => {
|
||||
const safe = { ...overview, plan: { ...frozenPlan, items: [frozenPlan.items[0]] } };
|
||||
assert.equal(canExecute(safe, { expert: false, confirmation: frozenPlan.confirmationPhrase }), false);
|
||||
assert.equal(canExecute(safe, { expert: true, confirmation: 'RECONSTRUCT' }), false);
|
||||
assert.equal(canExecute(safe, { expert: true, confirmation: frozenPlan.confirmationPhrase }), true);
|
||||
assert.equal(canExecute({ ...safe, plan: { ...safe.plan, backup: { status: 'required', verified: false } } }, { expert: true, confirmation: frozenPlan.confirmationPhrase }), false);
|
||||
assert.equal(canExecute({ ...safe, plan: { ...safe.plan, timestampPolicy: 'current_time_only' } }, { expert: true, confirmation: frozenPlan.confirmationPhrase }), false);
|
||||
assert.equal(canExecute(overview, { expert: true, confirmation: frozenPlan.confirmationPhrase }), false);
|
||||
});
|
||||
|
||||
test('UI has no arbitrary trophy ID or unlock-all input and uses local reconstruction language', () => {
|
||||
const markup = buildTrophyMarkup(overview, { expert: true });
|
||||
assert.doesNotMatch(markup, /name="(?:trophyId|groupId|npCommunicationId)"/i);
|
||||
assert.doesNotMatch(markup, /unlock[- ]?all|sync trophies/i);
|
||||
assert.match(markup, /Reconstruct locally from PSN snapshot/);
|
||||
assert.equal((markup.match(/<input/g) || []).length, 4);
|
||||
assert.match(markup, /id="trophy-reveal-hidden" type="checkbox"/);
|
||||
assert.match(markup, /name="confirmation"/);
|
||||
});
|
||||
|
||||
test('historical import UI supports PSN-ID preview, selective import and exact-date messaging', () => {
|
||||
const markup = buildTrophyMarkup(overview, { historyStatus: { configured: true }, historyPreview: { onlineId: 'Nuklear_Rabbit', games: [{ key: 'NPWR1|trophy2', name: 'Native PS5 Game', platform: 'PS5', earned: 4, progress: 50, lastUpdatedDateTime: '2024-01-02T03:04:05Z' }] }, historySelected: new Set(['NPWR1|trophy2']) });
|
||||
assert.match(markup, /id="trophy-history-online-id"/);
|
||||
assert.match(markup, /Native PS5 Game/);
|
||||
assert.match(markup, /Import selection \(1\)/);
|
||||
assert.match(markup, /Import all games/);
|
||||
assert.match(markup, /preserves earned dates exactly/);
|
||||
});
|
||||
|
||||
test('desktop UI offers automated Sony login while retaining manual fallback', () => {
|
||||
const markup = buildTrophyMarkup(overview, { historyStatus: { configured: false }, historyBrowserAuthAvailable: true });
|
||||
assert.match(markup, /Sign in securely with Sony/); assert.match(markup, /temporary isolated sign-in window/);
|
||||
assert.match(markup, /NPSSO token/);
|
||||
});
|
||||
|
||||
test('historical snapshot UI displays PS5 earned state while concealing hidden names by default', () => {
|
||||
const snapshot = { id: `psn-${'a'.repeat(64)}`, account: { onlineId: 'Player_One' }, titleCount: 1, fetchedAt: '2026-08-11T12:00:00Z', titles: [{ name: 'Native PS5 Game', platform: 'PS5', trophies: [{ trophyId: 7, name: 'Secret ending', grade: 'gold', hidden: true, earned: true, earnedDateTime: '2024-02-03T04:05:06.789Z' }] }] };
|
||||
const concealed = buildTrophyMarkup(overview, { historySnapshots: [snapshot], historyDetail: snapshot });
|
||||
assert.match(concealed, /Native PS5 Game/); assert.match(concealed, /Hidden trophy/); assert.doesNotMatch(concealed, /Secret ending/);
|
||||
const revealed = buildTrophyMarkup(overview, { historySnapshots: [snapshot], historyDetail: snapshot, revealHidden: true });
|
||||
assert.match(revealed, /Secret ending/); assert.match(revealed, /2024/);
|
||||
});
|
||||
|
||||
test('progression UI works without PSN history and clearly separates preview from live apply', () => {
|
||||
const catalogs = { available: true, games: [{ catalogKey: 'np:11111111111111111111111111111111', catalogSha256: 'a'.repeat(64), revision: 'one', game: { title: 'Local Definition', trophyCount: 2 }, trophies: [
|
||||
{ id: 0, groupId: 0, grade: 'bronze', hidden: false, name: 'First', description: 'Begin', earned: false },
|
||||
{ id: 1, groupId: 0, grade: 'platinum', hidden: false, name: 'Platinum', description: 'All', earned: false },
|
||||
] }] };
|
||||
const plan = { confirmationPhrase: 'APPLY LOCAL PROGRESSION 2 Local Definition', items: [
|
||||
{ trophyId: 0, grade: 'bronze', name: 'First', proposedEarnedDateTime: '2021-01-01T01:00:00.000Z', timestampKind: 'synthetic-estimate', confidence: { level: 'estimated' } },
|
||||
{ trophyId: 1, grade: 'platinum', name: 'Platinum', proposedEarnedDateTime: '2021-01-03T01:00:00.000Z', timestampKind: 'synthetic-estimate', confidence: { level: 'estimated' } },
|
||||
] };
|
||||
const markup = buildTrophyMarkup(overview, { progressionState: { catalogs, catalogKey: catalogs.games[0].catalogKey, selected: new Set([0, 1]), mode: 'synthetic_unlock', strategy: 'balanced', startDateTime: '2021-01-01T00:00', endDateTime: '2021-01-04T00:00', minimumIntervalMinutes: 30, plan } });
|
||||
assert.match(markup, /Progression timeline planner/);
|
||||
assert.match(markup, /without a PSN import/);
|
||||
assert.match(markup, /Synthetic estimate — not original PSN history/);
|
||||
assert.match(markup, /Live PS5 apply remains unavailable/);
|
||||
assert.match(markup, /id="trophy-progression-create"/);
|
||||
assert.doesNotMatch(markup, /id="trophy-progression-apply"/);
|
||||
const timeline = markup.match(/<div class="trophy-progression-preview">[\s\S]*?<ol>([\s\S]*?)<\/ol>/);
|
||||
assert.ok(timeline);
|
||||
assert.match(timeline[1], /<strong>First<\/strong>[\s\S]*<strong>Platinum<\/strong>/);
|
||||
});
|
||||
|
||||
test('trophy UI preserves accessibility and English/Dutch i18n contracts', () => {
|
||||
const markup = buildTrophyMarkup(normalizeOverview(overview), { expert: true });
|
||||
assert.match(markup, /aria-labelledby="trophy-evidence-title"/);
|
||||
assert.match(markup, /aria-live="assertive"/);
|
||||
assert.match(markup, /aria-describedby="trophy-confirm-help"/);
|
||||
assert.match(markup, /<label for="trophy-confirmation">/);
|
||||
const en = JSON.parse(fs.readFileSync(path.join(root, 'build/lang/en.json'), 'utf8')).strings;
|
||||
const nl = JSON.parse(fs.readFileSync(path.join(root, 'build/lang/nl.json'), 'utf8')).strings;
|
||||
const keys = Object.keys(en).filter(key => key.startsWith('trophies.') || key.startsWith('navigation.routes.protection.trophies'));
|
||||
assert.ok(keys.length >= 40);
|
||||
for (const key of keys) assert.equal(typeof nl[key], 'string', `Dutch translation missing: ${key}`);
|
||||
});
|
||||
|
||||
test('route and bootstrap expose the normal trophy overview page', () => {
|
||||
const routes = require('../../renderer/modules/navigation-routes');
|
||||
const route = routes.routes.find(item => item.id === 'protection/trophies');
|
||||
assert.equal(route.page, 'trophies');
|
||||
assert.notEqual(route.expert, true);
|
||||
const bootstrap = fs.readFileSync(path.join(root, 'renderer/bootstrap.js'), 'utf8');
|
||||
const html = fs.readFileSync(path.join(root, 'renderer/index.html'), 'utf8');
|
||||
assert.match(bootstrap, /trophies: \['\.\.\/src\/renderer\/trophies\.js'\]/);
|
||||
assert.match(html, /id="page-trophies"/);
|
||||
assert.match(fs.readFileSync(path.join(root, 'src/renderer/trophies.js'), 'utf8'), /page\.id = 'page-trophies'/);
|
||||
assert.doesNotMatch(html, /styles\/trophies\.css/);
|
||||
assert.match(fs.readFileSync(path.join(root, 'src/renderer/trophies.js'), 'utf8'), /styles\/trophies\.css/);
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
'use strict';
|
||||
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const test = require('node:test');
|
||||
const initSqlJs = require('sql.js');
|
||||
const migration = require('../../src/database/migrations/0017-create-ps5-system-backup-inventory');
|
||||
const { createExperimentalBarAdapter, createSqliteSystemBackupRepository, createSystemBackupInventoryService } = require('../../src/services/system-backup-recovery-lab-service');
|
||||
|
||||
async function harness(t) {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'chimera-ch0907-')); t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
const SQL = await initSqlJs(); const db = new SQL.Database(); migration.up(db); let id = 0;
|
||||
const repository = createSqliteSystemBackupRepository({ db, now: () => 1784721600000 + id, idFactory: () => `artifact-${++id}` });
|
||||
return { db, root, repository, service: createSystemBackupInventoryService({ repository, allowedRoots: [root] }) };
|
||||
}
|
||||
|
||||
function writeCandidate(root, name, byte = 0x5a) { const file = path.join(root, name); fs.writeFileSync(file, Buffer.alloc(4096, byte)); return file; }
|
||||
const readyMetadata = { label: 'Original PS5 backup', backupSystemSoftwareVersion: '9.60', targetSystemSoftwareVersion: '10.00', usbFilesystem: 'exFAT', accountBound: true, targetAccountConfirmed: true, destructiveRestoreAcknowledged: true };
|
||||
|
||||
test('register opens and hashes the original artifact read-only without changing bytes or metadata', async t => {
|
||||
const context = await harness(t); const file = writeCandidate(context.root, 'original.bar'); const before = fs.statSync(file); const bytes = fs.readFileSync(file);
|
||||
const record = await context.service.register({ filePath: file, metadata: readyMetadata }); const after = fs.statSync(file);
|
||||
assert.equal(record.readOnlyVerified, true); assert.equal(record.sizeBytes, bytes.length); assert.match(record.sha256, /^[a-f0-9]{64}$/);
|
||||
assert.deepEqual(fs.readFileSync(file), bytes); assert.equal(after.size, before.size); assert.equal(after.mtimeMs, before.mtimeMs);
|
||||
assert.equal(record.integrityStatus, 'candidate'); assert.equal(record.immutable, true);
|
||||
assert.throws(() => context.db.run("UPDATE ps5_system_backup_artifacts SET display_name = 'changed'"), /immutable/i);
|
||||
});
|
||||
|
||||
test('same content is registered as a duplicate without collapsing provenance', async t => {
|
||||
const context = await harness(t); const first = await context.service.register({ filePath: writeCandidate(context.root, 'first.bar'), metadata: readyMetadata });
|
||||
const second = await context.service.register({ filePath: writeCandidate(context.root, 'copy.backup'), metadata: readyMetadata });
|
||||
assert.notEqual(second.id, first.id); assert.equal(second.sha256, first.sha256); assert.equal(second.duplicateOf, first.id); assert.equal(context.service.list().length, 2);
|
||||
});
|
||||
|
||||
test('unsupported and corrupt artifacts are recorded but never reported restore-ready', async t => {
|
||||
const context = await harness(t); const unsupported = await context.service.register({ filePath: writeCandidate(context.root, 'notes.txt'), metadata: readyMetadata });
|
||||
const corruptPath = path.join(context.root, 'empty.bar'); fs.writeFileSync(corruptPath, Buffer.alloc(0)); const corrupt = await context.service.register({ filePath: corruptPath, metadata: readyMetadata });
|
||||
assert.equal(unsupported.integrityStatus, 'unsupported'); assert.equal(unsupported.officialChecklist.status, 'blocked');
|
||||
assert.equal(corrupt.integrityStatus, 'corrupt'); assert.equal(corrupt.officialChecklist.status, 'blocked');
|
||||
});
|
||||
|
||||
test('official checklist gates filesystem, firmware, account and destructive warning', async t => {
|
||||
const context = await harness(t); const ready = await context.service.register({ filePath: writeCandidate(context.root, 'ready.bar'), metadata: readyMetadata });
|
||||
assert.equal(ready.officialChecklist.status, 'ready'); assert.match(ready.officialChecklist.officialRoute, /Restore Your PS5/);
|
||||
const blocked = await context.service.register({ filePath: writeCandidate(context.root, 'older-target.bar', 0x61), metadata: { ...readyMetadata, targetSystemSoftwareVersion: '9.00', usbFilesystem: 'ntfs', targetAccountConfirmed: false } });
|
||||
assert.equal(blocked.officialChecklist.status, 'blocked');
|
||||
assert.ok(blocked.officialChecklist.checks.some(check => check.id === 'system-software' && check.state === 'blocked'));
|
||||
assert.ok(blocked.officialChecklist.checks.some(check => check.id === 'usb-filesystem' && check.state === 'blocked'));
|
||||
assert.equal(ready.claims.trophyContents, 'excluded-by-official-guidance'); assert.equal(ready.claims.directTrophyRecovery, false); assert.equal(ready.claims.directSaveRecovery, false);
|
||||
});
|
||||
|
||||
test('managed root aliases are canonicalized before checking the resolved artifact', async t => {
|
||||
const context = await harness(t);
|
||||
const physical = path.join(context.root, 'physical'); fs.mkdirSync(physical);
|
||||
const alias = path.join(context.root, 'managed-alias'); fs.symlinkSync(physical, alias, 'junction');
|
||||
const file = writeCandidate(physical, 'original.bar'); const before = fs.statSync(file);
|
||||
const service = createSystemBackupInventoryService({ repository: context.repository, allowedRoots: [alias, path.join(context.root, 'unavailable-root')] });
|
||||
const record = await service.register({ filePath: path.join(alias, 'original.bar') });
|
||||
assert.equal(record.sourcePath, await fs.promises.realpath(file));
|
||||
assert.equal(record.sizeBytes, before.size); assert.equal(fs.statSync(file).mtimeMs, before.mtimeMs);
|
||||
assert.equal(record.readOnlyVerified, true);
|
||||
await assert.rejects(service.register({ filePath: file }), error => error.code === 'RECOVERY_LAB_PATH');
|
||||
});
|
||||
|
||||
test('an internal directory link cannot escape its selected managed root', async t => {
|
||||
const context = await harness(t);
|
||||
const managed = path.join(context.root, 'managed'); fs.mkdirSync(managed);
|
||||
const outside = path.join(context.root, 'outside'); fs.mkdirSync(outside);
|
||||
writeCandidate(outside, 'outside.bar');
|
||||
fs.symlinkSync(outside, path.join(managed, 'escape'), 'junction');
|
||||
const service = createSystemBackupInventoryService({ repository: context.repository, allowedRoots: [managed, outside] });
|
||||
await assert.rejects(service.register({ filePath: path.join(managed, 'escape', 'outside.bar') }), error => error.code === 'RECOVERY_LAB_PATH');
|
||||
assert.equal(context.repository.list().length, 0);
|
||||
});
|
||||
|
||||
test('experimental BAR adapter is capability-based WIP and fails closed', async () => {
|
||||
const adapter = createExperimentalBarAdapter(); const status = adapter.status();
|
||||
assert.equal(status.state, 'wip'); assert.equal(status.available, false); assert.deepEqual(status.capabilities, ['read-header', 'compare-sha256']);
|
||||
assert.ok(status.limitations.includes('no-restore')); assert.equal(adapter.assess({ integrityStatus: 'candidate' }).candidate, true);
|
||||
await assert.rejects(adapter.extract({}), error => error.code === 'RECOVERY_LAB_WIP');
|
||||
await assert.rejects(adapter.restore({}), error => error.code === 'RECOVERY_LAB_WIP');
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
'use strict';
|
||||
|
||||
const assert = require('node:assert/strict');
|
||||
const test = require('node:test');
|
||||
const initSqlJs = require('sql.js');
|
||||
const migration = require('../../src/database/migrations/0018-create-save-recovery-plans');
|
||||
const { createSaveRecoveryPlanService, createSaveRecoverySimulator, createSqliteSaveRecoveryRepository, createUnsupportedDirectSaveExtractionProvider } = require('../../src/services/save-recovery-plan-service');
|
||||
const crypto = require('node:crypto');
|
||||
const sha256 = bytes => crypto.createHash('sha256').update(bytes).digest('hex');
|
||||
const SOURCE = Buffer.from('encrypted-byte-verifiable-save');
|
||||
const TARGET = Buffer.from('existing-target-save');
|
||||
const SOURCE_ACCOUNT = '0011223344556677';
|
||||
const TARGET_ACCOUNT = '8899aabbccddeeff';
|
||||
|
||||
async function harness() {
|
||||
const SQL = await initSqlJs(); const db = new SQL.Database(); migration.up(db); let id = 0;
|
||||
const repository = createSqliteSaveRecoveryRepository({ db, now: () => Date.now(), idFactory: () => `test-${++id}` });
|
||||
const service = createSaveRecoveryPlanService({ repository, now: () => Date.now(), idFactory: () => `plan-${++id}` });
|
||||
const artifact = { id: 'vault-1', titleId: 'PPSA03016', accountId: SOURCE_ACCOUNT, sha256: sha256(SOURCE), sizeBytes: SOURCE.length, format: 'garlic-encrypted-ps5-title' };
|
||||
const adapter = createSaveRecoverySimulator({ sourceBytes: SOURCE, targetBytes: TARGET, sourceAccountId: SOURCE_ACCOUNT, targetAccountId: TARGET_ACCOUNT });
|
||||
return { adapter, artifact, db, repository, service };
|
||||
}
|
||||
|
||||
function makePlan(context, overrides = {}) { return context.service.createPlan({ sourceArtifact: context.artifact, sourceAccountId: SOURCE_ACCOUNT, targetAccountId: TARGET_ACCOUNT, adapterStatus: context.adapter.status(), ...overrides }); }
|
||||
|
||||
test('dry run freezes explicit accounts, hashes and separate backup-gated steps without executing', async () => {
|
||||
const context = await harness(); const plan = makePlan(context);
|
||||
assert.equal(plan.immutable, true); assert.match(plan.planSha256, /^[a-f0-9]{64}$/); assert.equal(plan.sourceAccountId, SOURCE_ACCOUNT); assert.equal(plan.targetAccountId, TARGET_ACCOUNT);
|
||||
assert.deepEqual(plan.steps.map(step => step.id), ['verify-source','backup-target','export-encrypted','resign','import-encrypted','export-target','verify-target']);
|
||||
assert.ok(plan.steps.filter(step => step.requiresBackup).every(step => step.backupStepId === 'backup-target'));
|
||||
assert.equal(plan.automaticExecution, false); assert.equal(plan.destructiveRestore, false); assert.equal(context.adapter.metrics().mutations, 0);
|
||||
await assert.rejects(context.service.execute({ planId: plan.id, adapter: context.adapter, confirmation: plan.confirmationPhrase }), error => error.code === 'SAVE_RECOVERY_CONFIRMATION');
|
||||
});
|
||||
|
||||
test('source hash mismatch blocks before backup or target mutation', async () => {
|
||||
const context = await harness(); const plan = makePlan(context);
|
||||
const adapter = createSaveRecoverySimulator({ sourceBytes: SOURCE, targetBytes: TARGET, sourceAccountId: SOURCE_ACCOUNT, targetAccountId: TARGET_ACCOUNT, sourceHashMismatch: true });
|
||||
await assert.rejects(context.service.execute({ planId: plan.id, adapter, confirmation: plan.confirmationPhrase, manualStart: true }), error => error.code === 'SAVE_RECOVERY_HASH_MISMATCH' && error.rollbackStatus === 'not-required');
|
||||
assert.equal(adapter.metrics().mutations, 0); assert.deepEqual(adapter.metrics().target, TARGET);
|
||||
});
|
||||
|
||||
test('explicit source and transformed target account mismatches are rejected', async () => {
|
||||
const context = await harness();
|
||||
assert.throws(() => makePlan(context, { sourceAccountId: TARGET_ACCOUNT }), error => error.code === 'SAVE_RECOVERY_ACCOUNT_MISMATCH');
|
||||
const plan = makePlan(context); const adapter = createSaveRecoverySimulator({ sourceBytes: SOURCE, targetBytes: TARGET, sourceAccountId: SOURCE_ACCOUNT, targetAccountId: TARGET_ACCOUNT, accountMismatch: true });
|
||||
await assert.rejects(context.service.execute({ planId: plan.id, adapter, confirmation: plan.confirmationPhrase, manualStart: true }), error => error.code === 'SAVE_RECOVERY_ACCOUNT_MISMATCH');
|
||||
assert.equal(adapter.metrics().mutations, 0);
|
||||
});
|
||||
|
||||
test('failure after import restores and byte-verifies the pre-action target backup', async () => {
|
||||
const context = await harness(); const plan = makePlan(context);
|
||||
const adapter = createSaveRecoverySimulator({ sourceBytes: SOURCE, targetBytes: TARGET, sourceAccountId: SOURCE_ACCOUNT, targetAccountId: TARGET_ACCOUNT, failAt: 'export-target' });
|
||||
await assert.rejects(context.service.execute({ planId: plan.id, adapter, confirmation: plan.confirmationPhrase, manualStart: true }), error => error.rollbackStatus === 'rolled-back');
|
||||
assert.equal(adapter.metrics().mutations, 1); assert.deepEqual(adapter.metrics().target, TARGET);
|
||||
});
|
||||
|
||||
test('supported flow journals before/after hashes and idempotent rerun does not mutate', async () => {
|
||||
const context = await harness(); const plan = makePlan(context);
|
||||
const first = await context.service.execute({ planId: plan.id, adapter: context.adapter, confirmation: plan.confirmationPhrase, manualStart: true });
|
||||
assert.equal(first.status, 'completed'); assert.equal(first.steps.length, 7); assert.ok(first.steps.every(step => step.status === 'completed'));
|
||||
assert.match(first.steps.find(step => step.id === 'backup-target').outputSha256, /^[a-f0-9]{64}$/); assert.match(first.steps.find(step => step.id === 'verify-target').outputSha256, /^[a-f0-9]{64}$/);
|
||||
const second = await context.service.execute({ planId: plan.id, adapter: context.adapter, confirmation: plan.confirmationPhrase, manualStart: true });
|
||||
assert.equal(second.idempotent, true); assert.equal(context.adapter.metrics().mutations, 1);
|
||||
assert.throws(() => context.db.run("UPDATE save_recovery_plans SET title_id='PPSA99999'"), /immutable/i);
|
||||
});
|
||||
|
||||
test('unsupported direct extraction provider remains unavailable and cannot create a plan', async () => {
|
||||
const context = await harness(); const provider = createUnsupportedDirectSaveExtractionProvider();
|
||||
assert.equal(provider.status().available, false); assert.deepEqual(provider.status().capabilities, []);
|
||||
await assert.rejects(provider.extract({}), error => error.code === 'SAVE_RECOVERY_PROVIDER_UNSUPPORTED');
|
||||
assert.throws(() => context.service.createPlan({ sourceArtifact: { ...context.artifact, format: 'opaque-ps5-backup-candidate' }, sourceAccountId: SOURCE_ACCOUNT, targetAccountId: TARGET_ACCOUNT, adapterStatus: context.adapter.status() }), error => error.code === 'SAVE_RECOVERY_PROVIDER_UNSUPPORTED');
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
'use strict';
|
||||
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const test = require('node:test');
|
||||
const root = path.resolve(__dirname, '../..');
|
||||
const read = relative => fs.readFileSync(path.join(root, relative), 'utf8');
|
||||
const { createUnsupportedLiveTrophyAdapter, createTrophyReconstructionSimulator } = require('../../src/services/trophy-reconstruction-action-service');
|
||||
const { createExperimentalBarAdapter } = require('../../src/services/system-backup-recovery-lab-service');
|
||||
const { createUnsupportedDirectSaveExtractionProvider } = require('../../src/services/save-recovery-plan-service');
|
||||
|
||||
test('A-050 through A-059 map to concrete P9 automated evidence', () => {
|
||||
const evidence = {
|
||||
'A-050': ['test/server/ch-0903-trophy-mapping-diff.test.js', /psn_only/],
|
||||
'A-051': ['test/server/ch-0903-trophy-mapping-diff.test.js', /local_only/],
|
||||
'A-052': ['test/server/ch-0904-trophy-reconstruction-plan.test.js', /unearned/i],
|
||||
'A-053': ['test/server/ch-0903-trophy-mapping-diff.test.js', /unmappable/],
|
||||
'A-054': ['test/server/ch-0903-trophy-mapping-diff.test.js', /ambiguous|diagnostic/i],
|
||||
'A-055': ['test/server/ch-0905-trophy-action.test.js', /unsupported exact timestamp capability/i],
|
||||
'A-056': ['test/server/ch-0905-trophy-action.test.js', /backup failure/i],
|
||||
'A-057': ['test/server/ch-0905-trophy-action.test.js', /mid-action failure/i],
|
||||
'A-058': ['test/server/ch-0905-trophy-action.test.js', /no network or PSN provider/i],
|
||||
'A-059': ['test/server/ch-0906-trophy-ui.test.js', /arbitrary trophy ID or unlock-all/i],
|
||||
};
|
||||
assert.deepEqual(Object.keys(evidence), Array.from({ length: 10 }, (_, index) => `A-05${index}`));
|
||||
for (const [acceptance, [file, pattern]] of Object.entries(evidence)) assert.match(read(file), pattern, `${acceptance} evidence missing`);
|
||||
});
|
||||
|
||||
test('trophy apply dependency graph has no network or PSN provider and source provider is GET-only', () => {
|
||||
const action = read('src/services/trophy-reconstruction-action-service.js');
|
||||
assert.doesNotMatch(action, /node:https?|node-fetch|axios|\bfetch\s*\(|psn-provider|psn-client/i);
|
||||
const source = read('src/services/trophy-snapshot-service.js');
|
||||
assert.doesNotMatch(source, /method\s*:\s*['"](?:POST|PUT|PATCH|DELETE)['"]/i);
|
||||
assert.doesNotMatch(source, /unlock[-_ ]?all|syncTroph|writeTroph|deleteTroph/i);
|
||||
const registries = `${read('src/server/create-registry.js')}\n${read('src/server/register-functional-modules.js')}`;
|
||||
assert.match(registries, /name:\s*'trophy:history-import:preview'.+classification:\s*'read-only'/);
|
||||
assert.match(registries, /name:\s*'trophy:history-import:apply'.+classification:\s*'mutating'/);
|
||||
assert.doesNotMatch(registries, /name\s*:\s*['"][^'"]*troph[^'"]*(?:sync|psn-write|write-psn)[^'"]*['"]/i);
|
||||
});
|
||||
|
||||
test('all unproven live P9 actions remain unavailable and tests use simulators', () => {
|
||||
const liveTrophy = createUnsupportedLiveTrophyAdapter().status();
|
||||
assert.equal(liveTrophy.available, false); assert.equal(liveTrophy.simulated, false);
|
||||
const simulator = createTrophyReconstructionSimulator().status();
|
||||
assert.equal(simulator.available, true); assert.equal(simulator.simulated, true);
|
||||
const bar = createExperimentalBarAdapter().status();
|
||||
assert.equal(bar.available, false); assert.equal(bar.state, 'wip'); assert.ok(bar.limitations.includes('no-restore'));
|
||||
const directSave = createUnsupportedDirectSaveExtractionProvider().status();
|
||||
assert.equal(directSave.available, false); assert.deepEqual(directSave.capabilities, []);
|
||||
for (const file of ['test/server/ch-0905-trophy-action.test.js','test/server/ch-0907-system-backup-recovery-lab.test.js','test/server/ch-0908-save-recovery-plan.test.js']) {
|
||||
const source = read(file); assert.doesNotMatch(source, /(?:192\.168\.|10\.\d+\.|172\.(?:1[6-9]|2\d|3[01])\.)/); assert.doesNotMatch(source, /ssh|ftp:\/\//i);
|
||||
}
|
||||
});
|
||||
|
||||
test('P9 security boundaries retain immutable evidence, exact confirmation and rollback', () => {
|
||||
assert.match(read('src/database/migrations/0012-create-trophy-psn-snapshots.js'), /immutable/i);
|
||||
assert.match(read('src/database/migrations/0016-create-trophy-reconstruction-journals.js'), /immutable/i);
|
||||
assert.match(read('src/services/trophy-reconstruction-action-service.js'), /RECONSTRUCT LOCAL TROPHIES/);
|
||||
assert.match(read('src/services/save-recovery-plan-service.js'), /manualStart !== true/);
|
||||
assert.match(read('src/services/save-recovery-plan-service.js'), /restoreBackup/);
|
||||
assert.match(read('src/services/system-backup-recovery-lab-service.js'), /promises\.open\(filePath, 'r'\)/);
|
||||
});
|
||||
@@ -0,0 +1,181 @@
|
||||
'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 { createLiveTrophyCatalogService, normalizeCatalog,
|
||||
normalizeInstalledCatalog: normalizeInstalledServiceCatalog } = require('../../src/services/live-trophy-catalog-service');
|
||||
const { normalizeActiveCatalog, activeCatalogMarkup, normalizeInstalledCatalog,
|
||||
installedCatalogMarkup, normalizeTrophyLibrary, trophyLibraryMarkup } = require('../../src/renderer/trophies');
|
||||
|
||||
function adapterResult() {
|
||||
return { status: 'ok', mappingRemoved: true, acquisition: { readOnly: true, persisted: false },
|
||||
entry: { game: { title: 'Example', trophyCount: 2 }, trophies: [
|
||||
{ id: 0, grade: 'bronze', hidden: false, name: 'Start', description: 'Begin', observedUnlocked: true },
|
||||
{ id: 1, grade: 'gold', hidden: true, name: 'Finish', description: 'End', observedUnlocked: false },
|
||||
] } };
|
||||
}
|
||||
|
||||
test('normalizes only the bounded UI-safe active catalog', () => {
|
||||
const value = normalizeCatalog(adapterResult());
|
||||
assert.deepEqual(value.counts, { total: 2, unlocked: 1, locked: 1 });
|
||||
assert.equal(value.trophies[1].name, 'Finish'); assert.equal(value.trophies[0].unlocked, true);
|
||||
assert.equal(value.entry, undefined); assert.equal(value.catalogKey, undefined);
|
||||
assert.deepEqual(value.platform, { family: 'legacy', runsOn: 'ps5', nativePs5: false });
|
||||
assert.deepEqual(value.capabilities, { liveInventory: true, exactOneUnlock: false });
|
||||
});
|
||||
|
||||
test('hash-bound service invokes one fixed adapter and returns a read-only catalog', async t => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'chimera-live-trophy-'));
|
||||
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
fs.mkdirSync(path.join(root, 'host'), { recursive: true }); fs.mkdirSync(path.join(root, 'artifacts'));
|
||||
fs.writeFileSync(path.join(root, 'host', 'live-active-catalog-cli.mjs'), 'fixture');
|
||||
const elf = Buffer.from('resident'); fs.writeFileSync(path.join(root, 'artifacts', 'chimera-ps5-legacy-query-resident.elf'), elf);
|
||||
const calls = [];
|
||||
const service = createLiveTrophyCatalogService({ appRoot: root,
|
||||
settings: { get: key => key === 'ftp.host' ? 'console.test' : '' },
|
||||
secrets: { get: key => key === 'trophy.releaseKey' ? '00112233445566778899aabbccddeeff' : '' },
|
||||
adapterRoot: root, expectedElfSha256: crypto.createHash('sha256').update(elf).digest('hex'),
|
||||
runAdapter: async input => { calls.push(input); return JSON.stringify(adapterResult()); } });
|
||||
assert.equal(service.status().available, true);
|
||||
const catalog = await service.activeCatalog();
|
||||
assert.equal(catalog.readOnly, true); assert.equal(catalog.persisted, false); assert.equal(calls.length, 1);
|
||||
assert.equal(catalog.platform.nativePs5, false); assert.equal(catalog.capabilities.exactOneUnlock, false);
|
||||
assert.equal(calls[0].host, 'console.test'); assert.equal(calls[0].releaseKey.length, 32);
|
||||
});
|
||||
|
||||
test('active catalog retries one transient base-map failure and no other failure', async t => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'chimera-live-trophy-retry-'));
|
||||
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
fs.mkdirSync(path.join(root, 'host'), { recursive: true }); fs.mkdirSync(path.join(root, 'artifacts'));
|
||||
fs.writeFileSync(path.join(root, 'host', 'live-active-catalog-cli.mjs'), 'fixture');
|
||||
const elf = Buffer.from('resident'); fs.writeFileSync(path.join(root, 'artifacts', 'chimera-ps5-legacy-query-resident.elf'), elf);
|
||||
const options = { appRoot: root, adapterRoot: root,
|
||||
settings: { get: () => 'console.test' }, secrets: { get: () => '00112233445566778899aabbccddeeff' },
|
||||
expectedElfSha256: crypto.createHash('sha256').update(elf).digest('hex') };
|
||||
let attempts = 0;
|
||||
const recovered = createLiveTrophyCatalogService({ ...options, runAdapter: async () => {
|
||||
attempts += 1;
|
||||
if (attempts === 1) throw new Error('Live trophy adapter failed: LEGACY_QUERY_LOAD_PS5DEBUG_LOAD_BASE_MAP_FAILED');
|
||||
return JSON.stringify(adapterResult());
|
||||
} });
|
||||
assert.equal((await recovered.activeCatalog()).available, true); assert.equal(attempts, 2);
|
||||
attempts = 0;
|
||||
const refused = createLiveTrophyCatalogService({ ...options, runAdapter: async () => {
|
||||
attempts += 1; throw new Error('Live trophy adapter failed: LEGACY_QUERY_GAME_MISSING');
|
||||
} });
|
||||
await assert.rejects(refused.activeCatalog(), /LEGACY_QUERY_GAME_MISSING/); assert.equal(attempts, 1);
|
||||
});
|
||||
|
||||
test('production binding is read-only and exposes no arbitrary command or trophy mutation route', () => {
|
||||
const service = fs.readFileSync(path.join(__dirname, '../../src/services/live-trophy-catalog-service.js'), 'utf8');
|
||||
const registry = fs.readFileSync(path.join(__dirname, '../../src/server/register-functional-modules.js'), 'utf8');
|
||||
assert.match(registry, /name: 'trophy:active-catalog'.+classification: 'read-only'/);
|
||||
assert.match(registry, /name: 'trophy:installed-catalog'.+classification: 'read-only'/);
|
||||
assert.match(registry, /name: 'trophy:unlock-one'.+classification: 'sensitive'/);
|
||||
assert.doesNotMatch(service, /shell:\s*true|\bexec\(|upload|writeFile|unlockAll|syncTroph/i);
|
||||
assert.match(service, /FTP_PORT=1337/); assert.match(service, /DEBUG_PORT=764/);
|
||||
const web = fs.readFileSync(path.join(__dirname, '../../renderer/pork-web-shim.js'), 'utf8');
|
||||
for (const method of ['trophyActiveCatalog', 'trophyInstalledCatalog', 'trophyImportedCatalog',
|
||||
'trophyImportDefinition', 'trophyUnlockOne', 'trophyHistoryImportStatus', 'trophyHistoryPreview',
|
||||
'trophyHistoryApply']) assert.match(web, new RegExp(method));
|
||||
});
|
||||
|
||||
test('active catalog renderer shows state and conceals locked hidden definitions', () => {
|
||||
const catalog = normalizeActiveCatalog({ ...normalizeCatalog(adapterResult()),
|
||||
capabilities: { liveInventory: true, exactOneUnlock: true } });
|
||||
const markup = activeCatalogMarkup(catalog, (_key, fallback) => fallback);
|
||||
assert.match(markup, /Example/); assert.match(markup, /1\/2/);
|
||||
assert.match(markup, /Legacy \/ PS4 trophy API running on PS5/);
|
||||
assert.match(markup, /Start/); assert.match(markup, /Locked/);
|
||||
assert.match(markup, /Hidden trophy/); assert.doesNotMatch(markup, /Finish/);
|
||||
assert.doesNotMatch(markup, /End|data-unlock-id="1"/); assert.doesNotMatch(markup, /name="trophyId"/);
|
||||
const revealed = activeCatalogMarkup(catalog, (_key, fallback) => fallback, true);
|
||||
assert.match(revealed, /Finish/); assert.match(revealed, /End/);
|
||||
assert.match(revealed, /data-unlock-id="1"/); assert.doesNotMatch(revealed, /name="trophyId"/);
|
||||
});
|
||||
|
||||
test('premium trophy library merges game definitions with exact live progress', () => {
|
||||
const active = normalizeActiveCatalog({ ...normalizeCatalog(adapterResult()),
|
||||
capabilities: { liveInventory: true, exactOneUnlock: true } });
|
||||
const installed = normalizeInstalledCatalog({ available: true, readOnly: true, count: 2,
|
||||
games: [
|
||||
{ game: { title: 'Example', trophyCount: 2 }, trophies: adapterResult().entry.trophies.map(({ observedUnlocked, ...item }) => item) },
|
||||
{ game: { title: 'Library Game', trophyCount: 1 }, trophies: [{ id: 0, grade: 'silver', hidden: false, name: 'First step', description: 'Begin' }] },
|
||||
] });
|
||||
const library = normalizeTrophyLibrary({ activeCatalog: active, installedCatalog: installed });
|
||||
assert.equal(library.games.length, 2);
|
||||
assert.equal(library.games[0].title, 'Example');
|
||||
assert.equal(library.games[0].coverage, 'live');
|
||||
assert.deepEqual(library.games[0].counts, { total: 2, earned: 1, locked: 1 });
|
||||
assert.equal(library.games[0].trophies[1].earned, false);
|
||||
assert.equal(library.games[0].trophies[1].unlockEligible, true);
|
||||
assert.equal(library.games[1].coverage, 'definitions');
|
||||
assert.equal(library.games[1].trophies[0].earned, null);
|
||||
});
|
||||
|
||||
test('premium trophy library renders game cards, filters and catalog-derived unlock actions', () => {
|
||||
const active = normalizeActiveCatalog({ ...normalizeCatalog(adapterResult()),
|
||||
capabilities: { liveInventory: true, exactOneUnlock: true } });
|
||||
const installed = normalizeInstalledCatalog({ available: true, readOnly: true, count: 1,
|
||||
games: [{ game: { title: 'Example', trophyCount: 2 }, trophies: adapterResult().entry.trophies.map(({ observedUnlocked, ...item }) => item) }] });
|
||||
const library = normalizeTrophyLibrary({ activeCatalog: active, installedCatalog: installed });
|
||||
const markup = trophyLibraryMarkup(library, { selectedGameKey: library.games[0].key, revealHidden: true }, (_key, fallback) => fallback);
|
||||
assert.match(markup, /trophy-library-grid/);
|
||||
assert.match(markup, /trophy-library-game-card/);
|
||||
assert.match(markup, /1 of 2 earned/);
|
||||
assert.match(markup, /data-library-status="locked"/);
|
||||
assert.match(markup, /data-library-grade="gold"/);
|
||||
assert.match(markup, /data-library-unlock-id="1"/);
|
||||
assert.doesNotMatch(markup, /name="trophyId"|unlockAll/);
|
||||
const stylesheet = fs.readFileSync(path.join(__dirname, '../../renderer/styles/trophies-library.css'), 'utf8');
|
||||
assert.match(stylesheet, /\.trophy-library-shell/);
|
||||
assert.match(stylesheet, /scroll-snap-type:x mandatory/);
|
||||
assert.match(stylesheet, /prefers-reduced-motion:reduce/);
|
||||
});
|
||||
|
||||
test('normalizes a bounded installed definition catalog without identifiers', () => {
|
||||
const value = normalizeInstalledServiceCatalog({ status: 'ok', readOnly: true, persisted: false,
|
||||
identifiersReturned: false, count: 1, entries: [{ game: { title: 'Example', trophyCount: 2 },
|
||||
trophies: adapterResult().entry.trophies.map(({ observedUnlocked, ...item }) => item) }] });
|
||||
assert.equal(value.count, 1); assert.equal(value.games[0].game.title, 'Example');
|
||||
assert.equal(JSON.stringify(value).includes('catalogKey'), false);
|
||||
});
|
||||
|
||||
test('installed catalog renderer exposes every game and conceals hidden definitions', () => {
|
||||
const catalog = normalizeInstalledCatalog({ available: true, readOnly: true, count: 1,
|
||||
games: [{ game: { title: 'Library Game', trophyCount: 1 }, trophies: [
|
||||
{ id: 0, grade: 'gold', hidden: true, name: 'Secret', description: 'Spoiler' }
|
||||
] }] });
|
||||
const markup = installedCatalogMarkup(catalog, (_key, fallback) => fallback);
|
||||
assert.match(markup, /Library Game/); assert.match(markup, /Hidden trophy/);
|
||||
assert.doesNotMatch(markup, /Secret|Spoiler/);
|
||||
const revealed = installedCatalogMarkup(catalog, (_key, fallback) => fallback, true);
|
||||
assert.match(revealed, /Secret/); assert.match(revealed, /Spoiler/);
|
||||
});
|
||||
|
||||
test('single unlock requires exact confirmation and independently verifies the result', async t => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'chimera-unlock-trophy-'));
|
||||
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
fs.mkdirSync(path.join(root, 'host'), { recursive: true }); fs.mkdirSync(path.join(root, 'artifacts'));
|
||||
const activeCli = Buffer.from('active'), unlockCli = Buffer.from('unlock'), activeElf = Buffer.from('reader'), unlockElf = Buffer.from('writer');
|
||||
fs.writeFileSync(path.join(root, 'host', 'live-active-catalog-cli.mjs'), activeCli);
|
||||
fs.writeFileSync(path.join(root, 'host', 'legacy-unlock-ps5debug-cli.mjs'), unlockCli);
|
||||
fs.writeFileSync(path.join(root, 'artifacts', 'chimera-ps5-legacy-query-resident.elf'), activeElf);
|
||||
fs.writeFileSync(path.join(root, 'artifacts', 'chimera-ps5-legacy-unlock-resident.elf'), unlockElf);
|
||||
let unlocked = false; const calls = [];
|
||||
const service = createLiveTrophyCatalogService({ appRoot: root, adapterRoot: root,
|
||||
settings: { get: () => 'console.test' }, secrets: { get: () => '00112233445566778899aabbccddeeff' },
|
||||
expectedElfSha256: crypto.createHash('sha256').update(activeElf).digest('hex'),
|
||||
expectedUnlockElfSha256: crypto.createHash('sha256').update(unlockElf).digest('hex'),
|
||||
expectedUnlockCliSha256: crypto.createHash('sha256').update(unlockCli).digest('hex'),
|
||||
runAdapter: async input => { calls.push(input); if (input.consent === 'CONSENT_LEGACY_UNLOCK_ONE_EXECUTE') { unlocked = true; return JSON.stringify({ status: 'ok', trophyId: 1, unlockedAfter: true, mappingRemoved: true, ownedHandleDestroyed: true, psnContacted: false }); }
|
||||
const value = adapterResult(); if (unlocked) value.entry.trophies[1].observedUnlocked = true; return JSON.stringify(value); } });
|
||||
await assert.rejects(service.unlockOne({ trophyId: 1, confirmation: 'wrong' }), /Exact confirmation/);
|
||||
const result = await service.unlockOne({ trophyId: 1, confirmation: 'UNLOCK 1 Example' });
|
||||
assert.equal(result.status, 'completed'); assert.equal(result.counts.unlocked, 2);
|
||||
assert.equal(calls.filter(call => call.consent === 'CONSENT_LEGACY_UNLOCK_ONE_EXECUTE').length, 1);
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
'use strict';
|
||||
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const test = require('node:test');
|
||||
const { createImportedTrophyDefinitionService, parsePackage } = require('../../src/services/imported-trophy-definition-service');
|
||||
const { importedCatalogMarkup } = require('../../src/renderer/trophies');
|
||||
|
||||
function fixture(title = 'Historical Game') { return { schema: 'chimera-trophy-definition', schemaVersion: 1,
|
||||
catalogKey: 'np:00112233445566778899aabbccddeeff', revision: '3-example',
|
||||
game: { title, description: 'Imported locally', trophyCount: 1 }, trophies: [
|
||||
{ id: 0, grade: 'bronze', groupId: -1, hidden: false, name: 'First', description: 'Begin' }
|
||||
] }; }
|
||||
|
||||
test('strict package parser rejects unknown fields and non-sequential trophies', () => {
|
||||
assert.equal(parsePackage(fixture()).game.title, 'Historical Game');
|
||||
assert.throws(() => parsePackage({ ...fixture(), extra: true }), /shape/);
|
||||
const invalid = fixture(); invalid.trophies[0].id = 2;
|
||||
assert.throws(() => parsePackage(invalid), /row/);
|
||||
});
|
||||
|
||||
test('imports idempotently and survives a service restart', t => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'chimera-trophy-definitions-'));
|
||||
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
const first = createImportedTrophyDefinitionService({ root });
|
||||
const imported = first.importPackage(fixture());
|
||||
assert.equal(imported.trophyCount, 1); assert.match(imported.digest, /^[a-f0-9]{64}$/);
|
||||
first.importPackage(fixture());
|
||||
assert.equal(fs.readdirSync(root).length, 1);
|
||||
const second = createImportedTrophyDefinitionService({ root });
|
||||
assert.equal(second.list().games[0].game.title, 'Historical Game');
|
||||
assert.equal(second.list().persisted, true);
|
||||
});
|
||||
|
||||
test('renderer and command registry expose a local-only import path', () => {
|
||||
const markup = importedCatalogMarkup({ available: true, count: 1, games: [fixture()] }, (_key, fallback) => fallback);
|
||||
assert.match(markup, /Historical Game/); assert.match(markup, /trophy-definition-json/);
|
||||
const registry = fs.readFileSync(path.join(__dirname, '../../src/server/register-functional-modules.js'), 'utf8');
|
||||
const preload = fs.readFileSync(path.join(__dirname, '../../preload.js'), 'utf8');
|
||||
assert.match(registry, /trophy:imported-catalog:import'.+classification: 'mutating'/);
|
||||
assert.match(registry, /maxBodyBytes: 1024 \* 1024/);
|
||||
assert.match(preload, /trophyImportDefinition/);
|
||||
});
|
||||
@@ -0,0 +1,124 @@
|
||||
'use strict';
|
||||
|
||||
const assert = require('node:assert/strict');
|
||||
const crypto = require('node:crypto');
|
||||
const test = require('node:test');
|
||||
|
||||
const {
|
||||
CONSENT,
|
||||
canonicalCatalogDigest,
|
||||
createLegacyDetachedTrophyPreflightService,
|
||||
normalizeDetachedPreflight,
|
||||
} = require('../../src/services/legacy-detached-trophy-preflight-service');
|
||||
|
||||
const game = {
|
||||
title: 'Detached test game',
|
||||
trophies: [
|
||||
{ id: 0, grade: 'bronze', name: 'First', description: 'First trophy', hidden: false },
|
||||
{ id: 1, grade: 'gold', name: 'Second', description: 'Second trophy', hidden: true },
|
||||
],
|
||||
};
|
||||
|
||||
function digest(value) {
|
||||
return crypto.createHash('sha256').update(value).digest('hex');
|
||||
}
|
||||
|
||||
function readyResult(catalogSha256) {
|
||||
return {
|
||||
status: 'ready',
|
||||
readOnly: true,
|
||||
persisted: false,
|
||||
identifiersReturned: false,
|
||||
pointersReturned: false,
|
||||
catalogSha256,
|
||||
targetInactive: true,
|
||||
cleanupComplete: true,
|
||||
metadata: { paramSfo: true, npbind: true, trophyPackage: true, npTitleAuth: true },
|
||||
context: { created: true, registered: true, stateRead: true, destroyed: true },
|
||||
handle: { created: true, destroyed: true },
|
||||
sideEffects: { unlockCalls: 0, storageWrites: 0, mounts: 0, psnRequests: 0, filesPersisted: 0 },
|
||||
};
|
||||
}
|
||||
|
||||
test('detached preflight is fail-closed until both native artifacts are pinned', () => {
|
||||
const service = createLegacyDetachedTrophyPreflightService();
|
||||
assert.deepEqual(service.status(), {
|
||||
available: false,
|
||||
readOnly: true,
|
||||
reasonCode: 'NATIVE_ADAPTER_NOT_PINNED',
|
||||
});
|
||||
});
|
||||
|
||||
test('detached preflight is bound to an exact catalog digest and read-only consent', async () => {
|
||||
const cliPath = 'C:\\fixture\\preflight-cli.mjs';
|
||||
const elfPath = 'C:\\fixture\\preflight.elf';
|
||||
const files = new Map([[cliPath, Buffer.from('cli')], [elfPath, Buffer.from('elf')]]);
|
||||
const calls = [];
|
||||
const catalogSha256 = canonicalCatalogDigest(game);
|
||||
const service = createLegacyDetachedTrophyPreflightService({
|
||||
cliPath,
|
||||
elfPath,
|
||||
expectedCliSha256: digest(files.get(cliPath)),
|
||||
expectedElfSha256: digest(files.get(elfPath)),
|
||||
fs: {
|
||||
existsSync: (candidate) => files.has(candidate),
|
||||
readFileSync: (candidate) => files.get(candidate),
|
||||
},
|
||||
installedCatalog: async () => ({ games: [game] }),
|
||||
runAdapter: async (request) => {
|
||||
calls.push(request);
|
||||
return readyResult(catalogSha256);
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(service.status().available, true);
|
||||
const result = await service.preflight({ catalogSha256 });
|
||||
assert.equal(result.ready, true);
|
||||
assert.equal(result.cleanupComplete, true);
|
||||
assert.deepEqual(result.sideEffects, {
|
||||
unlockCalls: 0,
|
||||
storageWrites: 0,
|
||||
mounts: 0,
|
||||
psnRequests: 0,
|
||||
filesPersisted: 0,
|
||||
});
|
||||
assert.equal(calls.length, 1);
|
||||
assert.equal(calls[0].consent, CONSENT);
|
||||
assert.equal(calls[0].catalogSha256, catalogSha256);
|
||||
assert.equal(Object.hasOwn(calls[0], 'titleId'), false);
|
||||
assert.equal(Object.hasOwn(calls[0], 'trophyId'), false);
|
||||
});
|
||||
|
||||
test('detached preflight rejects a stale or invented selection digest', async () => {
|
||||
const cliPath = 'cli';
|
||||
const elfPath = 'elf';
|
||||
const files = new Map([[cliPath, Buffer.from('cli')], [elfPath, Buffer.from('elf')]]);
|
||||
const service = createLegacyDetachedTrophyPreflightService({
|
||||
cliPath,
|
||||
elfPath,
|
||||
expectedCliSha256: digest(files.get(cliPath)),
|
||||
expectedElfSha256: digest(files.get(elfPath)),
|
||||
fs: { existsSync: (candidate) => files.has(candidate), readFileSync: (candidate) => files.get(candidate) },
|
||||
installedCatalog: async () => ({ games: [game] }),
|
||||
runAdapter: async () => { throw new Error('must not run'); },
|
||||
});
|
||||
await assert.rejects(service.preflight({ catalogSha256: '0'.repeat(64) }), /no longer matches/i);
|
||||
});
|
||||
|
||||
test('detached preflight rejects writes, unlocks and incomplete cleanup', () => {
|
||||
const catalogSha256 = canonicalCatalogDigest(game);
|
||||
const wroteStorage = readyResult(catalogSha256);
|
||||
wroteStorage.sideEffects.storageWrites = 1;
|
||||
assert.throws(() => normalizeDetachedPreflight(wroteStorage, catalogSha256), /read-only contract/i);
|
||||
|
||||
const leaked = readyResult(catalogSha256);
|
||||
leaked.context.destroyed = false;
|
||||
assert.throws(() => normalizeDetachedPreflight(leaked, catalogSha256), /leaked a context|every phase/i);
|
||||
|
||||
const wrongCatalog = readyResult('f'.repeat(64));
|
||||
assert.throws(() => normalizeDetachedPreflight(wrongCatalog, catalogSha256), /catalog binding/i);
|
||||
|
||||
const leakedIdentifier = readyResult(catalogSha256);
|
||||
leakedIdentifier.titleId = 'CUSA00000';
|
||||
assert.throws(() => normalizeDetachedPreflight(leakedIdentifier, catalogSha256), /forbidden top-level data/i);
|
||||
});
|
||||
@@ -0,0 +1,154 @@
|
||||
'use strict';
|
||||
|
||||
const assert = require('node:assert/strict');
|
||||
const test = require('node:test');
|
||||
|
||||
const { canonicalCatalogDigest } = require('../../src/services/legacy-detached-trophy-preflight-service');
|
||||
const {
|
||||
CONSENT,
|
||||
canonicalTrophyDigest,
|
||||
confirmationPhrase,
|
||||
createInactiveLegacyTrophyUnlockService,
|
||||
} = require('../../src/services/inactive-legacy-trophy-unlock-service');
|
||||
|
||||
const game = {
|
||||
title: 'Inactive fixture game',
|
||||
trophies: [
|
||||
{ id: 0, grade: 'bronze', name: 'Locked', description: 'Target', hidden: false, earned: false },
|
||||
{ id: 1, grade: 'silver', name: 'Earned', description: 'Existing', hidden: false, earned: true },
|
||||
],
|
||||
};
|
||||
const catalogSha256 = canonicalCatalogDigest(game);
|
||||
const trophySha256 = canonicalTrophyDigest(catalogSha256, game.trophies[0]);
|
||||
const backupSha256 = 'b'.repeat(64);
|
||||
|
||||
function dependencies(overrides = {}) {
|
||||
const calls = [];
|
||||
return {
|
||||
calls,
|
||||
values: {
|
||||
installedCatalog: async () => ({ games: [game] }),
|
||||
mutationStatus: async () => ({ available: true }),
|
||||
detachedPreflight: async (input) => {
|
||||
calls.push(['preflight', input]);
|
||||
return { ready: true, cleanupComplete: true, targetInactive: true };
|
||||
},
|
||||
backup: async (input) => {
|
||||
calls.push(['backup', input]);
|
||||
return { complete: true, sha256: backupSha256 };
|
||||
},
|
||||
dryRun: async (input) => {
|
||||
calls.push(['dry-run', input]);
|
||||
return { ready: true, writes: 0, unlockCalls: 0 };
|
||||
},
|
||||
unlockAdapter: async (input) => {
|
||||
calls.push(['unlock', input]);
|
||||
return {
|
||||
status: 'unlocked',
|
||||
catalogSha256,
|
||||
trophySha256,
|
||||
targetInactive: true,
|
||||
unlockCalls: 1,
|
||||
psnRequests: 0,
|
||||
contextDestroyed: true,
|
||||
handleDestroyed: true,
|
||||
filesPersisted: 0,
|
||||
};
|
||||
},
|
||||
postScan: async (input) => {
|
||||
calls.push(['post-scan', input]);
|
||||
return { selectedUnlocked: true, changedTrophies: 1, otherChanges: 0 };
|
||||
},
|
||||
rollback: async (input) => {
|
||||
calls.push(['rollback', input]);
|
||||
return { verified: true };
|
||||
},
|
||||
audit: async (input) => calls.push(['audit', input]),
|
||||
...overrides,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function input() {
|
||||
return {
|
||||
catalogSha256,
|
||||
trophySha256,
|
||||
confirmation: confirmationPhrase(catalogSha256, trophySha256),
|
||||
};
|
||||
}
|
||||
|
||||
test('inactive unlock is unavailable until a native mutation adapter is proven', async () => {
|
||||
const service = createInactiveLegacyTrophyUnlockService({
|
||||
installedCatalog: async () => ({ games: [game] }),
|
||||
});
|
||||
assert.deepEqual(await service.status(), {
|
||||
available: false,
|
||||
exactOne: true,
|
||||
inactiveOnly: true,
|
||||
backupRequired: true,
|
||||
dryRunRequired: true,
|
||||
rollbackRequired: true,
|
||||
reasonCode: 'NATIVE_MUTATION_ADAPTER_UNAVAILABLE',
|
||||
});
|
||||
await assert.rejects(service.unlockOne(input()), /unavailable/i);
|
||||
});
|
||||
|
||||
test('inactive unlock resolves an opaque exact-one selection and verifies the full recovery flow', async () => {
|
||||
const fixture = dependencies();
|
||||
const service = createInactiveLegacyTrophyUnlockService(fixture.values);
|
||||
const result = await service.unlockOne(input());
|
||||
|
||||
assert.equal(result.status, 'unlocked');
|
||||
assert.equal(result.postScanVerified, true);
|
||||
assert.equal(result.backupSha256, backupSha256);
|
||||
assert.deepEqual(fixture.calls.map(([name]) => name), [
|
||||
'preflight', 'backup', 'dry-run', 'unlock', 'post-scan', 'audit',
|
||||
]);
|
||||
const adapterInput = fixture.calls.find(([name]) => name === 'unlock')[1];
|
||||
assert.equal(adapterInput.consent, CONSENT);
|
||||
assert.equal(adapterInput.trophyId, 0);
|
||||
assert.equal(Object.hasOwn(adapterInput, 'titleId'), false);
|
||||
assert.equal(Object.hasOwn(adapterInput, 'npCommunicationId'), false);
|
||||
});
|
||||
|
||||
test('inactive unlock rejects free ids, stale digests, earned trophies and wrong confirmation', async () => {
|
||||
const fixture = dependencies();
|
||||
const service = createInactiveLegacyTrophyUnlockService(fixture.values);
|
||||
await assert.rejects(service.unlockOne({ ...input(), trophyId: 99 }), /forbidden input field/i);
|
||||
await assert.rejects(service.unlockOne({ ...input(), trophySha256: '0'.repeat(64) }), /stale or ambiguous/i);
|
||||
await assert.rejects(service.unlockOne({ ...input(), confirmation: 'yes' }), /exact inactive trophy confirmation/i);
|
||||
|
||||
const earnedSha256 = canonicalTrophyDigest(catalogSha256, game.trophies[1]);
|
||||
await assert.rejects(service.unlockOne({
|
||||
catalogSha256,
|
||||
trophySha256: earnedSha256,
|
||||
confirmation: confirmationPhrase(catalogSha256, earnedSha256),
|
||||
}), /already unlocked/i);
|
||||
assert.equal(fixture.calls.length, 0);
|
||||
});
|
||||
|
||||
test('inactive unlock rolls back when exact-one post-scan proof fails', async () => {
|
||||
const fixture = dependencies({
|
||||
postScan: async () => ({ selectedUnlocked: true, changedTrophies: 2, otherChanges: 1 }),
|
||||
});
|
||||
const service = createInactiveLegacyTrophyUnlockService(fixture.values);
|
||||
await assert.rejects(service.unlockOne(input()), /exact-one trophy mutation/i);
|
||||
assert.equal(fixture.calls.some(([name]) => name === 'rollback'), true);
|
||||
const audit = fixture.calls.find(([name]) => name === 'audit')[1];
|
||||
assert.equal(audit.outcome, 'rolled-back');
|
||||
});
|
||||
|
||||
test('inactive unlock rejects an unverifiable backup or side-effecting dry-run before mutation', async () => {
|
||||
const noBackup = dependencies({ backup: async () => ({ complete: false }) });
|
||||
await assert.rejects(
|
||||
createInactiveLegacyTrophyUnlockService(noBackup.values).unlockOne(input()),
|
||||
/verified trophy backup/i,
|
||||
);
|
||||
|
||||
const dirtyDryRun = dependencies({ dryRun: async () => ({ ready: true, writes: 1, unlockCalls: 0 }) });
|
||||
await assert.rejects(
|
||||
createInactiveLegacyTrophyUnlockService(dirtyDryRun.values).unlockOne(input()),
|
||||
/dry-run did not pass/i,
|
||||
);
|
||||
assert.equal(dirtyDryRun.calls.some(([name]) => name === 'unlock'), false);
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
'use strict';
|
||||
|
||||
const assert = require('node:assert/strict');
|
||||
const crypto = require('node:crypto');
|
||||
const test = require('node:test');
|
||||
|
||||
const { CONSENT } = require('../../src/services/inactive-legacy-trophy-unlock-service');
|
||||
const { createLegacyDetachedTrophyMutationAdapter } = require('../../src/services/legacy-detached-trophy-mutation-adapter');
|
||||
|
||||
function sha256(value) {
|
||||
return crypto.createHash('sha256').update(value).digest('hex');
|
||||
}
|
||||
|
||||
test('native inactive mutation adapter is fail-closed without artifact pins', () => {
|
||||
assert.deepEqual(createLegacyDetachedTrophyMutationAdapter().status(), {
|
||||
available: false,
|
||||
reasonCode: 'NATIVE_MUTATION_ADAPTER_NOT_PINNED',
|
||||
});
|
||||
});
|
||||
|
||||
test('native inactive mutation adapter verifies both artifacts and forwards an exact-one request', async () => {
|
||||
const cliPath = 'cli';
|
||||
const elfPath = 'elf';
|
||||
const cli = Buffer.from('cli');
|
||||
const elf = Buffer.from('elf');
|
||||
const files = new Map([[cliPath, cli], [elfPath, elf]]);
|
||||
const catalogSha256 = 'a'.repeat(64);
|
||||
const trophySha256 = 'b'.repeat(64);
|
||||
const backupSha256 = 'c'.repeat(64);
|
||||
let forwarded;
|
||||
const adapter = createLegacyDetachedTrophyMutationAdapter({
|
||||
cliPath,
|
||||
elfPath,
|
||||
expectedCliSha256: sha256(cli),
|
||||
expectedElfSha256: sha256(elf),
|
||||
fs: { existsSync: (candidate) => files.has(candidate), readFileSync: (candidate) => files.get(candidate) },
|
||||
runAdapter: async (request) => {
|
||||
forwarded = request;
|
||||
return {
|
||||
status: 'unlocked',
|
||||
catalogSha256,
|
||||
trophySha256,
|
||||
targetInactive: true,
|
||||
unlockCalls: 1,
|
||||
psnRequests: 0,
|
||||
contextDestroyed: true,
|
||||
handleDestroyed: true,
|
||||
filesPersisted: 0,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
const result = await adapter.unlockOne({
|
||||
consent: CONSENT,
|
||||
catalogSha256,
|
||||
trophySha256,
|
||||
trophyId: 7,
|
||||
backupSha256,
|
||||
});
|
||||
assert.equal(result.status, 'unlocked');
|
||||
assert.equal(forwarded.trophyId, 7);
|
||||
assert.equal(forwarded.expectedElfSha256, sha256(elf));
|
||||
assert.equal(Object.hasOwn(forwarded, 'titleId'), false);
|
||||
});
|
||||
|
||||
test('native inactive mutation adapter rejects a hash mismatch before execution', async () => {
|
||||
let ran = false;
|
||||
const files = new Map([['cli', Buffer.from('cli')], ['elf', Buffer.from('elf')]]);
|
||||
const adapter = createLegacyDetachedTrophyMutationAdapter({
|
||||
cliPath: 'cli',
|
||||
elfPath: 'elf',
|
||||
expectedCliSha256: '0'.repeat(64),
|
||||
expectedElfSha256: sha256(files.get('elf')),
|
||||
fs: { existsSync: (candidate) => files.has(candidate), readFileSync: (candidate) => files.get(candidate) },
|
||||
runAdapter: async () => { ran = true; },
|
||||
});
|
||||
assert.equal(adapter.status().reasonCode, 'NATIVE_MUTATION_ADAPTER_HASH_MISMATCH');
|
||||
await assert.rejects(adapter.unlockOne({}), /unavailable/i);
|
||||
assert.equal(ran, false);
|
||||
});
|
||||
|
||||
test('native inactive mutation adapter rejects caller-supplied title metadata', async () => {
|
||||
const files = new Map([['cli', Buffer.from('cli')], ['elf', Buffer.from('elf')]]);
|
||||
const adapter = createLegacyDetachedTrophyMutationAdapter({
|
||||
cliPath: 'cli',
|
||||
elfPath: 'elf',
|
||||
expectedCliSha256: sha256(files.get('cli')),
|
||||
expectedElfSha256: sha256(files.get('elf')),
|
||||
fs: { existsSync: (candidate) => files.has(candidate), readFileSync: (candidate) => files.get(candidate) },
|
||||
runAdapter: async () => { throw new Error('must not run'); },
|
||||
});
|
||||
await assert.rejects(adapter.unlockOne({ titleId: 'CUSA00000' }), /forbidden field/i);
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
'use strict';
|
||||
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { createPsnTrophyHistoryImportService, titlePlatform } = require('../../src/services/psn-trophy-history-import-service');
|
||||
|
||||
function fixture({ missingTimestamp = false } = {}) {
|
||||
const stored = new Map(); const saved = [];
|
||||
const secrets = { set(key, value) { value ? stored.set(key, String(value)) : stored.delete(key); }, get: key => stored.get(key) || '', has: key => stored.has(key) };
|
||||
const psnApi = {
|
||||
async exchangeNpssoForAccessCode() { return 'access-code'; },
|
||||
async exchangeAccessCodeForAuthTokens() { return { accessToken: 'access-token', refreshToken: 'refresh-token', expiresIn: 3600 }; },
|
||||
async exchangeRefreshTokenForAuthTokens() { return { accessToken: 'new-access-token', refreshToken: 'new-refresh-token', expiresIn: 3600 }; },
|
||||
async makeUniversalSearch() { return { domainResponses: [{ results: [{ socialMetadata: { accountId: '1234567890123456789', onlineId: 'Nuklear_Rabbit', country: 'BE', language: 'nl-BE' } }] }] }; },
|
||||
async getUserTitles(_auth, _account, options) {
|
||||
if (options.offset) return { trophyTitles: [], totalItemCount: 2 };
|
||||
return { totalItemCount: 2, trophyTitles: [
|
||||
{ npCommunicationId: 'NPWR10001_00', npServiceName: 'trophy', trophyTitleName: 'Legacy Game', trophyTitlePlatform: 'PS4', progress: 50, earnedTrophies: { bronze: 1 }, lastUpdatedDateTime: '2017-03-04T05:06:07Z' },
|
||||
{ npCommunicationId: 'NPWR20002_00', npServiceName: 'trophy2', trophyTitleName: 'Native Game', trophyTitlePlatform: 'PS5', progress: 100, earnedTrophies: { gold: 1 }, lastUpdatedDateTime: '2024-02-03T04:05:06Z' },
|
||||
] };
|
||||
},
|
||||
async getTitleTrophyGroups(_auth, communicationId) { return { trophyGroups: [{ trophyGroupId: 'default', trophyGroupName: communicationId }] }; },
|
||||
async getTitleTrophies(_auth, communicationId) { return { totalItemCount: 1, trophies: [{ trophyId: communicationId.includes('20002') ? 2 : 1, trophyGroupId: 'default', trophyName: `${communicationId} trophy`, trophyDetail: 'Original detail', trophyType: communicationId.includes('20002') ? 'gold' : 'bronze', trophyHidden: communicationId.includes('20002') }] }; },
|
||||
async getUserTrophiesEarnedForTitle(_auth, _account, communicationId) { return { totalItemCount: 1, trophies: [{ trophyId: communicationId.includes('20002') ? 2 : 1, earned: true, earnedDateTime: missingTimestamp ? undefined : (communicationId.includes('20002') ? '2024-02-03T04:05:06.789Z' : '2017-03-04T05:06:07.123Z') }] }; },
|
||||
};
|
||||
const repository = { save(parsed, provenance) { const record = { id: `psn-${parsed.canonicalSha256}`, canonicalSha256: parsed.canonicalSha256, importedAt: provenance.importedAt, titleCount: parsed.titleCount, trophyCount: parsed.trophyCount, snapshot: parsed.snapshot, provenance }; saved.push(record); return record; }, get(id) { return saved.find(record => record.id === id) || null; }, list() { return [...saved].reverse(); } };
|
||||
return { service: createPsnTrophyHistoryImportService({ repository, secrets, psnApi, now: () => 1_800_000_000_000, minIntervalMs: 0 }), saved, secrets };
|
||||
}
|
||||
|
||||
test('PSN history importer previews all platforms and imports only the frozen selection', async () => {
|
||||
const { service, saved, secrets } = fixture();
|
||||
const auth = await service.authenticate({ npsso: 'a'.repeat(64) });
|
||||
assert.equal(auth.configured, true);
|
||||
assert.equal(secrets.get('trophy.psn.history.access-token'), 'access-token');
|
||||
|
||||
const preview = await service.preview({ onlineId: 'Nuklear_Rabbit' });
|
||||
assert.deepEqual(preview.games.map(game => game.platform), ['PS4', 'PS5']);
|
||||
assert.equal(preview.games[1].service, 'trophy2');
|
||||
|
||||
const result = await service.importSelection({ previewId: preview.previewId, selection: { mode: 'selected', keys: [preview.games[1].key] } });
|
||||
assert.equal(result.games.length, 1);
|
||||
assert.equal(result.games[0].platform, 'PS5');
|
||||
assert.equal(saved[0].snapshot.titles[0].service, 'trophy2');
|
||||
assert.equal(saved[0].snapshot.titles[0].trophies[0].hidden, true);
|
||||
assert.equal(saved[0].snapshot.titles[0].trophies[0].earnedDateTime, '2024-02-03T04:05:06.789Z');
|
||||
assert.equal(saved[0].provenance.kind, 'provider-fetch');
|
||||
assert.match(result.snapshot.account.accountId, /\*+6789$/);
|
||||
assert.equal(service.listSnapshots().length, 1);
|
||||
const detail = service.getSnapshot({ id: result.snapshot.id });
|
||||
assert.equal(detail.titles[0].trophies[0].earnedDateTime, '2024-02-03T04:05:06.789Z');
|
||||
assert.equal(JSON.stringify(detail).includes('1234567890123456789'), false);
|
||||
});
|
||||
|
||||
test('mass import retains original timestamps for legacy and Trophy2 sets', async () => {
|
||||
const { service, saved } = fixture();
|
||||
await service.authenticate({ npsso: 'b'.repeat(64) });
|
||||
const preview = await service.preview({ onlineId: 'Nuklear_Rabbit' });
|
||||
await service.importSelection({ previewId: preview.previewId, selection: { mode: 'all' } });
|
||||
assert.deepEqual(saved[0].snapshot.titles.map(title => title.platform), ['PS4', 'PS5']);
|
||||
assert.deepEqual(saved[0].snapshot.titles.map(title => title.trophies[0].earnedDateTime), ['2017-03-04T05:06:07.123Z', '2024-02-03T04:05:06.789Z']);
|
||||
});
|
||||
|
||||
test('earned trophy without an original timestamp rejects the complete import', async () => {
|
||||
const { service, saved } = fixture({ missingTimestamp: true });
|
||||
await service.authenticate({ npsso: 'c'.repeat(64) });
|
||||
const preview = await service.preview({ onlineId: 'Nuklear_Rabbit' });
|
||||
await assert.rejects(() => service.importSelection({ previewId: preview.previewId, selection: { mode: 'selected', keys: [preview.games[0].key] } }), error => error.code === 'TROPHY_IMPORT_TIMESTAMP');
|
||||
assert.equal(saved.length, 0);
|
||||
});
|
||||
|
||||
test('platform mapping keeps native Trophy2 distinct and accepts historical systems', () => {
|
||||
assert.equal(titlePlatform({ npServiceName: 'trophy2', trophyTitlePlatform: 'PS5' }), 'PS5');
|
||||
assert.equal(titlePlatform({ npServiceName: 'trophy', trophyTitlePlatform: 'PS3' }), 'PS3');
|
||||
assert.equal(titlePlatform({ npServiceName: 'trophy', trophyTitlePlatform: 'PSVita' }), 'PSVITA');
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
'use strict';
|
||||
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const database = require('../../src/database');
|
||||
const { createSqliteTrophySnapshotRepository, parseTrophySnapshot } = require('../../src/services/trophy-snapshot-service');
|
||||
|
||||
test('production database adapter stores imported history immutably with parameterized reads', async t => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'chimera-trophy-history-'));
|
||||
t.after(() => { database.close(); fs.rmSync(root, { recursive: true, force: true }); });
|
||||
database.configure({ dataDir: root, logger: { info() {}, warn() {}, error() {} } });
|
||||
await database.initialize();
|
||||
const repository = createSqliteTrophySnapshotRepository({ db: database });
|
||||
const parsed = parseTrophySnapshot({ schemaVersion: 1, provider: { id: 'authorized-psn-readonly', version: 'psn-api-2.18.0' }, account: { accountId: '1234567890', onlineId: 'Player_One' }, context: { region: 'BE', language: 'nl-BE' }, fetchedAt: '2026-08-11T12:00:00.000Z', warnings: [], titles: [{ npCommunicationId: 'NPWR20002_00', titleId: '', contentId: '', name: 'Native Game', platform: 'PS5', service: 'trophy2', groups: [{ groupId: 'default', name: 'Base' }], trophies: [{ groupId: 'default', trophyId: 2, name: 'Exact', grade: 'gold', hidden: true, earned: true, earnedDateTime: '2024-02-03T04:05:06.789Z', progress: 100, definitionHash: null }] }] });
|
||||
const saved = repository.save(parsed, { kind: 'provider-fetch', importedAt: 1_800_000_000_000, requestCount: 4 });
|
||||
assert.equal(saved.snapshot.titles[0].platform, 'PS5');
|
||||
assert.equal(saved.snapshot.titles[0].trophies[0].earnedDateTime, '2024-02-03T04:05:06.789Z');
|
||||
assert.equal(repository.get(saved.id).canonicalSha256, saved.canonicalSha256);
|
||||
assert.throws(() => database.runMutatingSqlWithParams('UPDATE trophy_psn_snapshots SET online_id=@id', { id: 'Changed' }), /immutable/);
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
'use strict';
|
||||
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { EventEmitter } = require('node:events');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const { allowedSonyUrl, createSonyBrowserAuthentication } = require('../../src/ipc/trophy-history');
|
||||
|
||||
class FakeWindow extends EventEmitter {
|
||||
constructor(options) {
|
||||
super(); this.options = options; this.destroyed = false; this.shown = false;
|
||||
const contents = new EventEmitter();
|
||||
contents.session = { fetch: async () => ({ ok: true, json: async () => ({ npsso: 'n'.repeat(64) }) }), clearStorageData: async () => { this.storageCleared = true; }, clearCache: async () => { this.cacheCleared = true; } };
|
||||
contents.setWindowOpenHandler = handler => { this.openHandler = handler; };
|
||||
this.webContents = contents;
|
||||
FakeWindow.latest = this;
|
||||
}
|
||||
loadURL(url) { this.url = url; queueMicrotask(() => this.webContents.emit('did-finish-load')); return Promise.resolve(); }
|
||||
show() { this.shown = true; }
|
||||
isDestroyed() { return this.destroyed; }
|
||||
destroy() { this.destroyed = true; this.emit('closed'); }
|
||||
}
|
||||
|
||||
test('Sony browser authentication uses an ephemeral hardened window and clears it after token exchange', async () => {
|
||||
let received = null;
|
||||
const authenticate = createSonyBrowserAuthentication({ BrowserWindow: FakeWindow, parent: {}, timeoutMs: 1000, randomBytes: () => Buffer.alloc(12, 7), authenticate: async input => { received = input; return { configured: true }; } });
|
||||
const result = await authenticate();
|
||||
assert.equal(result.configured, true); assert.equal(result.temporaryBrowserDataCleared, true);
|
||||
assert.equal(received.npsso, 'n'.repeat(64));
|
||||
assert.equal(FakeWindow.latest.options.webPreferences.partition.startsWith('persist:'), false);
|
||||
assert.equal(FakeWindow.latest.options.webPreferences.nodeIntegration, false);
|
||||
assert.equal(FakeWindow.latest.options.webPreferences.contextIsolation, true);
|
||||
assert.equal(FakeWindow.latest.storageCleared, true); assert.equal(FakeWindow.latest.cacheCleared, true);
|
||||
});
|
||||
|
||||
test('browser authentication allowlists only HTTPS Sony and PlayStation origins', () => {
|
||||
assert.equal(allowedSonyUrl('https://ca.account.sony.com/api/v1/ssocookie'), true);
|
||||
assert.equal(allowedSonyUrl('https://www.playstation.com/'), true);
|
||||
assert.equal(allowedSonyUrl('http://www.playstation.com/'), false);
|
||||
assert.equal(allowedSonyUrl('https://playstation.com.attacker.example/'), false);
|
||||
});
|
||||
|
||||
test('desktop registration exposes automated authentication but blocks it from the web bridge', () => {
|
||||
const root = path.resolve(__dirname, '../..');
|
||||
const main = fs.readFileSync(path.join(root, 'main.js'), 'utf8');
|
||||
const preload = fs.readFileSync(path.join(root, 'preload.js'), 'utf8');
|
||||
assert.match(main, /_registerTrophyHistory\(ipcMain/);
|
||||
assert.match(main, /WEB_BLOCKED_CHANNELS[\s\S]*trophy:history-import:browser-authenticate/);
|
||||
assert.match(preload, /trophyHistoryBrowserAuthenticate/);
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
'use strict';
|
||||
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const test = require('node:test');
|
||||
const { createV3LibraryService } = require('../../src/services/v3-library-service');
|
||||
const { runBenchmark } = require('../../scripts/check-v3-query-performance');
|
||||
|
||||
function mockDatabase() {
|
||||
const queries = [];
|
||||
return { queries, runQuery(sql) {
|
||||
queries.push(sql);
|
||||
if (/SELECT COUNT\(\*\) AS total FROM titles/.test(sql)) return [{ total: 5000 }];
|
||||
if (/FROM titles t LEFT JOIN games g/.test(sql)) {
|
||||
const limit = Number(sql.match(/LIMIT (\d+)/)?.[1] || 0);
|
||||
return Array.from({ length: limit }, (_, index) => ({ v3_title_pk: index + 1, v3_external_title_id: `PPSA${String(index + 1).padStart(5, '0')}`, v3_platform: 'ps5', v3_name: `Title ${index + 1}`, v3_sort_name: `Title ${index + 1}`, v3_metadata_json: '{}', v3_metadata_source: 'fixture', v3_metadata_seen_at: 1 }));
|
||||
}
|
||||
return [];
|
||||
} };
|
||||
}
|
||||
|
||||
test('v3 main view pushes bounds and filters into SQL with constant query count', () => {
|
||||
const db = mockDatabase(); const page = createV3LibraryService(db).list({ limit: 10_000, offset: 40, search: "title' safe", platform: 'ps5', installed: true, backed_up: true });
|
||||
assert.equal(page.limit, 200); assert.equal(page.offset, 40); assert.equal(page.items.length, 200); assert.equal(page.total, 5000); assert.equal(page.hasMore, true);
|
||||
assert.equal(db.queries.length, 7, 'title page must use one count, one page and five bounded bulk queries');
|
||||
assert.match(db.queries[0], /LOWER\(/); assert.match(db.queries[0], /EXISTS \(SELECT 1 FROM console_installs/); assert.match(db.queries[0], /EXISTS \(SELECT 1 FROM local_copies/);
|
||||
for (const query of db.queries.filter(sql => /\n\s*FROM (?:local_copies|console_installs)|FROM v_(?:title_availability|protection_health)/.test(sql))) {
|
||||
assert.match(query, /title_id IN \(/, 'child materialization must be limited to current page IDs');
|
||||
assert.ok((query.match(/\d+/g) || []).length <= 210, 'bounded title ID fan-out exceeded');
|
||||
}
|
||||
});
|
||||
|
||||
test('trophy and media views expose explicit bounded pagination contracts', () => {
|
||||
const psn = fs.readFileSync(path.join(__dirname, '../../src/services/trophy-snapshot-service.js'), 'utf8');
|
||||
const local = fs.readFileSync(path.join(__dirname, '../../src/services/local-trophy-inventory-service.js'), 'utf8');
|
||||
const media = fs.readFileSync(path.join(__dirname, '../../src/server/register-functional-modules.js'), 'utf8');
|
||||
assert.match(psn, /Math\.min\(100[\s\S]*LIMIT @limit OFFSET @offset/);
|
||||
assert.match(local, /Math\.min\(100[\s\S]*LIMIT @limit OFFSET @offset/);
|
||||
assert.match(media, /Math\.min\(500[\s\S]*pageItems = sourceItems\.slice\(offset, offset \+ limit\)/);
|
||||
});
|
||||
|
||||
test('large v3 fixture uses required indexes within the query budget', async () => {
|
||||
const result = await runBenchmark({ titleCount: 5000, maxMs: 2000 });
|
||||
assert.equal(result.pageSize, 200); assert.match(result.plans.titles, /idx_titles_listing_sort/i);
|
||||
assert.match(result.plans.copies, /idx_local_copies_title_state_id/i); assert.match(result.plans.installs, /idx_console_installs_title_state_id/i);
|
||||
});
|
||||
@@ -0,0 +1,302 @@
|
||||
'use strict';
|
||||
|
||||
const test = require('node:test');
|
||||
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 initSqlJs = require('sql.js');
|
||||
const database = require('../../src/database');
|
||||
|
||||
const MIGRATION_DIR = path.join(__dirname, '../../src/database/migrations');
|
||||
const EXPECTED_VERSIONS = Array.from({ length: 20 }, (_, index) => index + 1);
|
||||
const quietLogger = { info() {}, warn() {}, error() {} };
|
||||
|
||||
function resultRows(result) {
|
||||
if (!result?.length) return [];
|
||||
const [{ columns, values }] = result;
|
||||
return values.map((valuesRow) => Object.fromEntries(columns.map((column, index) => [column, valuesRow[index]])));
|
||||
}
|
||||
|
||||
function scalar(db, sql) {
|
||||
return db.exec(sql)?.[0]?.values?.[0]?.[0] ?? null;
|
||||
}
|
||||
|
||||
function migrationEntries(throughVersion) {
|
||||
return fs.readdirSync(MIGRATION_DIR)
|
||||
.filter((name) => /^\d{4}-.+\.js$/.test(name))
|
||||
.sort()
|
||||
.map((name) => ({
|
||||
name,
|
||||
path: path.join(MIGRATION_DIR, name),
|
||||
migration: require(path.join(MIGRATION_DIR, name)),
|
||||
}))
|
||||
.filter(({ migration }) => migration.version <= throughVersion);
|
||||
}
|
||||
|
||||
function recordAppliedMigrations(db, entries) {
|
||||
const columns = resultRows(db.exec('PRAGMA table_info(schema_migrations)')).map((column) => column.name);
|
||||
for (const entry of entries) {
|
||||
const valuesByColumn = {
|
||||
version: entry.migration.version,
|
||||
checksum: crypto.createHash('sha256').update(fs.readFileSync(entry.path)).digest('hex'),
|
||||
description: entry.migration.description,
|
||||
status: 'applied',
|
||||
applied_at: new Date(1700000000000 + entry.migration.version * 1000).toISOString(),
|
||||
error: null,
|
||||
snapshot_path: null,
|
||||
recovery_instructions: null,
|
||||
row_count_delta_json: '{}',
|
||||
};
|
||||
const selected = columns.filter((column) => Object.hasOwn(valuesByColumn, column));
|
||||
db.run(
|
||||
`INSERT OR REPLACE INTO schema_migrations (${selected.join(', ')}) VALUES (${selected.map(() => '?').join(', ')})`,
|
||||
selected.map((column) => valuesByColumn[column]),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function seedLegacyGame(db, { gameId, title, version = '1.00', backupPath = '/srv/backups/game.pkg' }) {
|
||||
db.run(`
|
||||
INSERT INTO games (game_id, title, content_id, size, version, installed, backed_up, ftp_path)
|
||||
VALUES (?, ?, ?, 4096, ?, 1, 1, ?)
|
||||
`, [gameId, title, gameId, version, `/mnt/console/${gameId}`]);
|
||||
db.run('INSERT INTO backups (game_id, backup_path) VALUES (?, ?)', [gameId, backupPath]);
|
||||
}
|
||||
|
||||
function insertAdaptiveTrophyEvidence(db) {
|
||||
const columns = resultRows(db.exec('PRAGMA table_info(trophy_psn_snapshots)'));
|
||||
const overrides = {
|
||||
snapshot_id: 'fixture-psn-snapshot',
|
||||
snapshot_key: 'fixture-psn-snapshot',
|
||||
account_id: 'fixture-account',
|
||||
account_ref: 'fixture-account',
|
||||
online_id: 'fixture-user',
|
||||
source: 'psn-read-only',
|
||||
source_type: 'psn-read-only',
|
||||
source_ref: 'fixture-export',
|
||||
captured_at: 1700000000000,
|
||||
observed_at: 1700000000000,
|
||||
imported_at: 1700000000000,
|
||||
evidence_hash: 'a'.repeat(64),
|
||||
payload_hash: 'b'.repeat(64),
|
||||
canonical_sha256: 'c'.repeat(64),
|
||||
payload_json: '{"titles":[]}',
|
||||
snapshot_json: '{"titles":[]}',
|
||||
trophy_count: 1,
|
||||
title_count: 1,
|
||||
confidence: 'high',
|
||||
provenance: 'migration-rehearsal',
|
||||
status: 'verified',
|
||||
};
|
||||
const selected = [];
|
||||
const values = [];
|
||||
for (const column of columns) {
|
||||
if (column.pk && /INT/i.test(column.type || '')) continue;
|
||||
if (Object.hasOwn(overrides, column.name)) {
|
||||
selected.push(column.name);
|
||||
values.push(overrides[column.name]);
|
||||
continue;
|
||||
}
|
||||
if (column.notnull && column.dflt_value === null) {
|
||||
selected.push(column.name);
|
||||
values.push(/INT|REAL|NUM/i.test(column.type || '') ? 1 : `fixture-${column.name}`);
|
||||
}
|
||||
}
|
||||
db.run(
|
||||
`INSERT INTO trophy_psn_snapshots (${selected.join(', ')}) VALUES (${selected.map(() => '?').join(', ')})`,
|
||||
values,
|
||||
);
|
||||
}
|
||||
|
||||
async function buildVersionedDatabase(root, throughVersion, { beforeVersion, afterMigrations } = {}) {
|
||||
const SQL = await initSqlJs();
|
||||
const db = new SQL.Database();
|
||||
db.run('PRAGMA foreign_keys = ON');
|
||||
const entries = migrationEntries(throughVersion);
|
||||
for (const entry of entries) {
|
||||
try {
|
||||
beforeVersion?.(entry.migration.version, db);
|
||||
db.run('BEGIN IMMEDIATE');
|
||||
entry.migration.up(db);
|
||||
db.run('COMMIT');
|
||||
} catch (error) {
|
||||
try { db.run('ROLLBACK'); } catch {}
|
||||
throw new Error(`Direct migration fixture failed at version ${entry.migration.version}: ${error.message || error}`, { cause: error });
|
||||
}
|
||||
}
|
||||
try {
|
||||
afterMigrations?.(db);
|
||||
} catch (error) {
|
||||
throw new Error(`Post-migration fixture failed at version ${throughVersion}: ${error.message || error}`, { cause: error });
|
||||
}
|
||||
recordAppliedMigrations(db, entries);
|
||||
fs.writeFileSync(path.join(root, 'chimera.db'), Buffer.from(db.export()));
|
||||
db.close();
|
||||
}
|
||||
|
||||
async function buildAmbiguousLegacyDatabase(root) {
|
||||
const SQL = await initSqlJs();
|
||||
const db = new SQL.Database();
|
||||
db.run(`
|
||||
CREATE TABLE games (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
title TEXT NOT NULL DEFAULT '',
|
||||
game_id TEXT NOT NULL,
|
||||
content_id TEXT DEFAULT '',
|
||||
size INTEGER DEFAULT 0,
|
||||
version TEXT DEFAULT '',
|
||||
installed INTEGER DEFAULT 0,
|
||||
backed_up INTEGER DEFAULT 0,
|
||||
ftp_path TEXT DEFAULT '',
|
||||
created_at TEXT DEFAULT (datetime('now'))
|
||||
)
|
||||
`);
|
||||
db.run('CREATE TABLE backups (id INTEGER PRIMARY KEY AUTOINCREMENT, game_id TEXT NOT NULL, backup_path TEXT NOT NULL UNIQUE)');
|
||||
db.run(`
|
||||
INSERT INTO games (game_id, title, content_id, size, version, installed)
|
||||
VALUES ('CUSA10002', 'Ambiguous Fixture', 'CUSA10002', 1, '1.00', 0),
|
||||
('CUSA10002', 'Ambiguous Fixture', 'CUSA10002', 1, '1.01', 0)
|
||||
`);
|
||||
fs.writeFileSync(path.join(root, 'chimera.db'), Buffer.from(db.export()));
|
||||
db.close();
|
||||
}
|
||||
|
||||
async function openRoot(root) {
|
||||
database.configure({ dataDir: root, logger: quietLogger });
|
||||
return database.initialize();
|
||||
}
|
||||
|
||||
async function closeAndRemove(root, { closeDatabase = true } = {}) {
|
||||
database.setTestMigrationFailureVersion(null);
|
||||
if (closeDatabase) {
|
||||
try {
|
||||
await database.close();
|
||||
} catch (error) {
|
||||
throw new Error(`Cleanup database close failed: ${error.message || error}`, { cause: error });
|
||||
}
|
||||
}
|
||||
try {
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
} catch (error) {
|
||||
throw new Error(`Cleanup fixture removal failed: ${error.message || error}`, { cause: error });
|
||||
}
|
||||
}
|
||||
|
||||
function assertAllMigrationsApplied() {
|
||||
const rows = database.runQuery('SELECT version, status FROM schema_migrations ORDER BY version');
|
||||
assert.deepEqual(rows.map((row) => row.version), EXPECTED_VERSIONS);
|
||||
assert.ok(rows.every((row) => row.status === 'applied'));
|
||||
}
|
||||
|
||||
function assertForeignKeysClean() {
|
||||
assert.deepEqual(database.checkForeignKeys(), []);
|
||||
}
|
||||
|
||||
test('CH-1002 rehearses legacy, v11 and v16 migration and recovery paths', async (t) => {
|
||||
await t.test('ambiguous legacy schema migrates without inventing version certainty', async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'chimera-ch1002-legacy-'));
|
||||
try {
|
||||
await buildAmbiguousLegacyDatabase(root);
|
||||
await openRoot(root);
|
||||
assertAllMigrationsApplied();
|
||||
const report = JSON.parse(database.runQuery("SELECT report_json FROM migration_reports WHERE migration_name = 'legacy-to-v3'")[0].report_json);
|
||||
assert.deepEqual(report.ambiguousVersions, [{ game_id: 'CUSA10002', versions: ['1.00', '1.01'] }]);
|
||||
assert.equal(database.runQuery("SELECT COUNT(*) AS count FROM titles WHERE title_id = 'CUSA10002'")[0].count, 1);
|
||||
assertForeignKeysClean();
|
||||
} finally {
|
||||
await closeAndRemove(root);
|
||||
}
|
||||
});
|
||||
|
||||
await t.test('v11 install, update and backup evidence survives through v20', async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'chimera-ch1002-v11-'));
|
||||
try {
|
||||
await buildVersionedDatabase(root, 11, {
|
||||
beforeVersion(version, db) {
|
||||
if (version === 4) seedLegacyGame(db, {
|
||||
gameId: 'CUSA10011',
|
||||
title: 'Version Eleven Fixture',
|
||||
backupPath: '/srv/backups/CUSA10011/game.pkg',
|
||||
});
|
||||
},
|
||||
afterMigrations(db) {
|
||||
const titleId = scalar(db, "SELECT id FROM titles WHERE title_id = 'CUSA10011'");
|
||||
db.run(`
|
||||
INSERT INTO version_evidence (subject_type, subject_id, semantic_version, raw_version, version_source, observed_at, confidence)
|
||||
VALUES ('title', ?, '1.02', '01.020.000', 'fixture-update-manifest', 1700000000000, 'high')
|
||||
`, [titleId]);
|
||||
},
|
||||
});
|
||||
await openRoot(root);
|
||||
assertAllMigrationsApplied();
|
||||
assert.equal(database.runQuery("SELECT COUNT(*) AS count FROM console_installs ci JOIN titles t ON t.id = ci.title_id WHERE t.title_id = 'CUSA10011'")[0].count, 1);
|
||||
assert.equal(database.runQuery("SELECT COUNT(*) AS count FROM local_copies lc JOIN titles t ON t.id = lc.title_id WHERE t.title_id = 'CUSA10011'")[0].count, 1);
|
||||
assert.equal(database.runQuery("SELECT semantic_version FROM version_evidence ve JOIN titles t ON t.id = ve.subject_id WHERE ve.subject_type = 'title' AND t.title_id = 'CUSA10011'")[0].semantic_version, '1.02');
|
||||
assertForeignKeysClean();
|
||||
} finally {
|
||||
await closeAndRemove(root);
|
||||
}
|
||||
});
|
||||
|
||||
await t.test('v16 trophy evidence survives forced failure, snapshot rollback and idempotent rerun', async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'chimera-ch1002-v16-'));
|
||||
let stage = 'build-v16-copy';
|
||||
try {
|
||||
await buildVersionedDatabase(root, 16, {
|
||||
beforeVersion(version, db) {
|
||||
if (version === 4) seedLegacyGame(db, {
|
||||
gameId: 'PPSA10016',
|
||||
title: 'Version Sixteen Fixture',
|
||||
backupPath: '/srv/backups/PPSA10016/game.pkg',
|
||||
});
|
||||
},
|
||||
afterMigrations(db) {
|
||||
insertAdaptiveTrophyEvidence(db);
|
||||
},
|
||||
});
|
||||
|
||||
stage = 'forced-version-18-failure';
|
||||
database.setTestMigrationFailureVersion(18);
|
||||
await assert.rejects(() => openRoot(root), /Test-gedwongen migratiefout voor versie 18/);
|
||||
assert.equal(database.runQuery('SELECT COUNT(*) AS count FROM trophy_psn_snapshots')[0].count, 1);
|
||||
assert.equal(database.runQuery("SELECT COUNT(*) AS count FROM local_copies lc JOIN titles t ON t.id = lc.title_id WHERE t.title_id = 'PPSA10016'")[0].count, 1);
|
||||
const failed = database.runQuery('SELECT status, snapshot_path, recovery_instructions FROM schema_migrations WHERE version = 18')[0];
|
||||
assert.equal(failed.status, 'failed');
|
||||
assert.ok(failed.snapshot_path);
|
||||
assert.ok(failed.recovery_instructions);
|
||||
assertForeignKeysClean();
|
||||
|
||||
stage = 'recovery-retry';
|
||||
database.setTestMigrationFailureVersion(null);
|
||||
await database.initialize();
|
||||
assertAllMigrationsApplied();
|
||||
const semanticBefore = database.runQuery(`
|
||||
SELECT
|
||||
(SELECT COUNT(*) FROM trophy_psn_snapshots) AS trophies,
|
||||
(SELECT COUNT(*) FROM local_copies) AS backups,
|
||||
(SELECT COUNT(*) FROM schema_migrations WHERE status = 'applied') AS migrations
|
||||
`)[0];
|
||||
assert.deepEqual(semanticBefore, { trophies: 1, backups: 1, migrations: 20 });
|
||||
assertForeignKeysClean();
|
||||
|
||||
stage = 'idempotent-reopen';
|
||||
await database.close();
|
||||
await openRoot(root);
|
||||
const semanticAfter = database.runQuery(`
|
||||
SELECT
|
||||
(SELECT COUNT(*) FROM trophy_psn_snapshots) AS trophies,
|
||||
(SELECT COUNT(*) FROM local_copies) AS backups,
|
||||
(SELECT COUNT(*) FROM schema_migrations WHERE status = 'applied') AS migrations
|
||||
`)[0];
|
||||
assert.deepEqual(semanticAfter, semanticBefore);
|
||||
assertAllMigrationsApplied();
|
||||
assertForeignKeysClean();
|
||||
} catch (error) {
|
||||
throw new Error(`CH-1002 v16 stage ${stage} failed: ${error.message || error}`, { cause: error });
|
||||
} finally {
|
||||
await closeAndRemove(root, { closeDatabase: stage !== 'build-v16-copy' });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,165 @@
|
||||
'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 { createPs4PackageBackupService } = require('../../src/services/ps4-package-backup-service');
|
||||
const { PINNED_PS4_BUILDER, createPs4PkgBuilderProvider } = require('../../src/services/ps4-pkg-builder-provider');
|
||||
const database = require('../../src/database');
|
||||
const { registerPs4PackageManifest } = require('../../src/services/ps4-package-registration-service');
|
||||
|
||||
function sfo(values) {
|
||||
const entries = Object.entries(values);
|
||||
const keys = Buffer.from(`${entries.map(([key]) => key).join('\0')}\0`);
|
||||
const encoded = entries.map(([, value]) => Buffer.from(`${value}\0`));
|
||||
const data = Buffer.concat(encoded);
|
||||
const table = Buffer.alloc(entries.length * 16);
|
||||
let keyOffset = 0;
|
||||
let dataOffset = 0;
|
||||
entries.forEach(([key], index) => {
|
||||
const value = encoded[index];
|
||||
table.writeUInt16LE(keyOffset, index * 16);
|
||||
table.writeUInt16LE(0x0204, index * 16 + 2);
|
||||
table.writeUInt32LE(value.length, index * 16 + 4);
|
||||
table.writeUInt32LE(value.length, index * 16 + 8);
|
||||
table.writeUInt32LE(dataOffset, index * 16 + 12);
|
||||
keyOffset += Buffer.byteLength(key) + 1;
|
||||
dataOffset += value.length;
|
||||
});
|
||||
const header = Buffer.alloc(20);
|
||||
header.writeUInt32LE(0x46535000, 0);
|
||||
header.writeUInt32LE(0x00000101, 4);
|
||||
header.writeUInt32LE(20 + table.length, 8);
|
||||
header.writeUInt32LE(20 + table.length + keys.length, 12);
|
||||
header.writeUInt32LE(entries.length, 16);
|
||||
return Buffer.concat([header, table, keys, data]);
|
||||
}
|
||||
|
||||
function writeDump(root, name, { titleId = 'CUSA12345', category = 'gd', version = '01.00', keystone = category === 'gd' } = {}) {
|
||||
const folder = path.join(root, name);
|
||||
fs.mkdirSync(path.join(folder, 'sce_sys'), { recursive: true });
|
||||
fs.writeFileSync(path.join(folder, 'eboot.bin'), 'verified executable');
|
||||
fs.writeFileSync(path.join(folder, 'sce_sys', 'param.sfo'), sfo({
|
||||
TITLE: 'Test Game', TITLE_ID: titleId, CATEGORY: category, APP_VER: version,
|
||||
CONTENT_ID: `UP0001-${titleId}_00-TESTGAME00000001`,
|
||||
}));
|
||||
if (keystone) fs.writeFileSync(path.join(folder, 'sce_sys', 'keystone'), 'key');
|
||||
return folder;
|
||||
}
|
||||
|
||||
function fixture(t, overrides = {}) {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'chimera-ps4-package-'));
|
||||
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
const libraryRoot = path.join(root, 'library');
|
||||
const outputRoot = path.join(root, 'packages');
|
||||
fs.mkdirSync(libraryRoot, { recursive: true });
|
||||
const registrations = [];
|
||||
const provider = overrides.provider || {
|
||||
probe: async () => ({ available: true, ...PINNED_PS4_BUILDER }),
|
||||
build: async input => {
|
||||
fs.mkdirSync(path.dirname(input.outputPath), { recursive: true });
|
||||
fs.writeFileSync(input.outputPath, `pkg:${input.component.type}:${input.component.titleId}`);
|
||||
return { provider: PINNED_PS4_BUILDER, outputPath: input.outputPath };
|
||||
},
|
||||
};
|
||||
const service = createPs4PackageBackupService({
|
||||
allowedRoots: () => [libraryRoot, outputRoot], outputRoot, stateFile: path.join(root, 'state', 'jobs.json'),
|
||||
provider, registrar: async manifest => { registrations.push(manifest); return { packageSetId: 77 }; },
|
||||
idFactory: overrides.idFactory || (() => 'job-1'), now: overrides.now || (() => 1_700_000_000_000),
|
||||
});
|
||||
return { root, libraryRoot, outputRoot, registrations, service };
|
||||
}
|
||||
|
||||
test('validates base and patch dumps from SFO evidence rather than folder names', async t => {
|
||||
const f = fixture(t);
|
||||
const base = writeDump(f.libraryRoot, 'anything', { category: 'gd' });
|
||||
const patch = writeDump(f.libraryRoot, 'also-not-trusted', { category: 'gp', version: '01.23', keystone: false });
|
||||
const baseResult = await f.service.inspect({ sourcePath: base, componentType: 'base', expectedTitleId: 'CUSA12345' });
|
||||
const patchResult = await f.service.inspect({ sourcePath: patch, componentType: 'update', expectedTitleId: 'CUSA12345' });
|
||||
assert.equal(baseResult.ready, true);
|
||||
assert.deepEqual({ titleId: patchResult.titleId, version: patchResult.version, category: patchResult.category }, { titleId: 'CUSA12345', version: '01.23', category: 'gp' });
|
||||
});
|
||||
|
||||
test('rejects incomplete, mismatched and symlink-escaped dumps', async t => {
|
||||
const f = fixture(t);
|
||||
const missingKeystone = writeDump(f.libraryRoot, 'missing-key', { keystone: false });
|
||||
await assert.rejects(() => f.service.inspect({ sourcePath: missingKeystone, componentType: 'base' }), error => error.code === 'PS4_DUMP_KEYSTONE_REQUIRED');
|
||||
const wrong = writeDump(f.libraryRoot, 'wrong-title');
|
||||
await assert.rejects(() => f.service.inspect({ sourcePath: wrong, componentType: 'base', expectedTitleId: 'CUSA99999' }), error => error.code === 'PS4_DUMP_TITLE_MISMATCH');
|
||||
const outside = path.join(f.root, 'outside'); fs.mkdirSync(outside); fs.writeFileSync(path.join(outside, 'payload.bin'), 'x');
|
||||
const linked = writeDump(f.libraryRoot, 'linked');
|
||||
fs.symlinkSync(outside, path.join(linked, 'escape'), process.platform === 'win32' ? 'junction' : 'dir');
|
||||
await assert.rejects(() => f.service.inspect({ sourcePath: linked, componentType: 'base' }), error => error.code === 'PS4_DUMP_SYMLINK');
|
||||
});
|
||||
|
||||
test('builds a deterministic ordered package set, hashes outputs and registers only after verification', async t => {
|
||||
const f = fixture(t);
|
||||
const base = writeDump(f.libraryRoot, 'base');
|
||||
const update = writeDump(f.libraryRoot, 'update', { category: 'gp', version: '01.10', keystone: false });
|
||||
const created = await f.service.create({ expectedTitleId: 'CUSA12345', sources: [{ sourcePath: update, componentType: 'update' }, { sourcePath: base, componentType: 'base' }] });
|
||||
assert.equal(created.status, 'queued');
|
||||
const completed = await f.service.run(created.id);
|
||||
assert.equal(completed.status, 'completed');
|
||||
assert.deepEqual(completed.children.map(item => item.type), ['base', 'update']);
|
||||
assert.equal(f.registrations.length, 1);
|
||||
assert.deepEqual(f.registrations[0].recipe.installOrder.map(item => item.componentId), ['base', 'update']);
|
||||
for (const component of f.registrations[0].components) {
|
||||
assert.match(component.sha256, /^[a-f0-9]{64}$/);
|
||||
assert.equal(component.sha256, crypto.createHash('sha256').update(fs.readFileSync(component.absolutePath)).digest('hex'));
|
||||
}
|
||||
});
|
||||
|
||||
test('active jobs become interrupted after restart and require explicit resume', async t => {
|
||||
const f = fixture(t);
|
||||
const base = writeDump(f.libraryRoot, 'base');
|
||||
const job = await f.service.create({ sources: [{ sourcePath: base, componentType: 'base' }] });
|
||||
const stored = JSON.parse(fs.readFileSync(path.join(f.root, 'state', 'jobs.json'), 'utf8'));
|
||||
stored[0].status = 'building'; stored[0].children[0].status = 'building';
|
||||
fs.writeFileSync(path.join(f.root, 'state', 'jobs.json'), JSON.stringify(stored));
|
||||
const restarted = createPs4PackageBackupService({ allowedRoots: () => [f.libraryRoot, f.outputRoot], outputRoot: f.outputRoot, stateFile: path.join(f.root, 'state', 'jobs.json'), provider: { probe: async () => ({ available: true }), build: async () => ({}) }, registrar: async () => ({}) });
|
||||
assert.equal(restarted.get(job.id).status, 'interrupted');
|
||||
assert.equal(restarted.resume(job.id).status, 'queued');
|
||||
});
|
||||
|
||||
test('creates a short-lived hash-bound package handoff without exposing arbitrary files', async t => {
|
||||
const f = fixture(t);
|
||||
const pkg = path.join(f.outputRoot, 'verified.pkg'); fs.writeFileSync(pkg, 'verified package');
|
||||
const sha256 = crypto.createHash('sha256').update('verified package').digest('hex');
|
||||
const exposed = await f.service.expose({ filePath: pkg, sha256, size: 16, origin: 'https://chimera.example.test' });
|
||||
assert.match(exposed.url, /^https:\/\/chimera\.example\.test\/ps4-package\/[A-Za-z0-9_-]{43}\/verified\.pkg$/);
|
||||
assert.equal(f.service.resolvePackageHandoff(new URL(exposed.url).pathname).filePath, fs.realpathSync(pkg));
|
||||
await assert.rejects(() => f.service.expose({ filePath: pkg, sha256: '0'.repeat(64), size: 16, origin: 'https://chimera.example.test' }), error => error.code === 'PS4_PACKAGE_HANDOFF_HASH');
|
||||
await assert.rejects(() => f.service.expose({ filePath: pkg, sha256, size: 16, origin: 'http://chimera.example.test' }), error => error.code === 'PS4_PACKAGE_ORIGIN');
|
||||
});
|
||||
|
||||
test('pinned provider invokes only the fixed executable without a shell or caller-controlled arguments', async t => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'chimera-ps4-builder-'));
|
||||
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
const executable = path.join(root, PINNED_PS4_BUILDER.executable);
|
||||
fs.writeFileSync(executable, 'tool');
|
||||
const projectPath = path.join(root, 'work', 'project.gp4'); const outputPath = path.join(root, 'work', 'out.pkg'); const tempPath = path.join(root, 'work', 'tmp');
|
||||
fs.mkdirSync(path.dirname(projectPath), { recursive: true }); fs.writeFileSync(projectPath, '<psproject />');
|
||||
const calls = [];
|
||||
const sourcePath = path.join(root, 'source'); fs.mkdirSync(sourcePath);
|
||||
const provider = createPs4PkgBuilderProvider({ toolRoot: root, allowedWorkRoots: [path.join(root, 'work')], allowedSourceRoots: [sourcePath], runner: async input => { calls.push(input); fs.writeFileSync(outputPath, 'pkg'); return { code: 0, output: 'ok' }; } });
|
||||
assert.equal((await provider.probe()).available, true);
|
||||
await provider.build({ projectPath, outputPath, tempPath, sourcePath, component: { type: 'base', titleId: 'CUSA12345' } });
|
||||
assert.equal(calls[0].command, fs.realpathSync(executable));
|
||||
assert.equal(calls[0].shell, false);
|
||||
assert.equal(calls[0].cwd, fs.realpathSync(sourcePath));
|
||||
assert.deepEqual(calls[0].args.slice(0, 5), ['img_create', '--no_progress_bar', '--oformat', 'pkg', '--tmp_path']);
|
||||
await assert.rejects(() => provider.build({ projectPath: path.join(fixture(t).root, 'evil.gp4'), outputPath, tempPath, sourcePath, component: { type: 'base' } }), error => error.code === 'PS4_BUILDER_PATH');
|
||||
});
|
||||
|
||||
test('registers verified outputs as a package set and available local copy without marking a console install', async t => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'chimera-ps4-registration-'));
|
||||
database.configure({ dataDir: path.join(root, 'db'), logger: { info() {}, warn() {}, error() {} } }); await database.initialize(); t.after(() => { database.close(); fs.rmSync(root, { recursive: true, force: true }); });
|
||||
const outputRoot = path.join(root, 'vault'); const folder = path.join(outputRoot, 'CUSA12345', 'job-1'); fs.mkdirSync(folder, { recursive: true });
|
||||
const pkg = path.join(folder, 'base.pkg'); fs.writeFileSync(pkg, 'pkg');
|
||||
const manifest = { schemaVersion: 1, kind: 'chimera.ps4-package-backup.v1', createdAt: 100, title: { platform: 'ps4', titleId: 'CUSA12345', name: 'Test Game' }, components: [{ id: 'base', type: 'base', version: '01.00', contentId: 'UP0001-CUSA12345_00-TESTGAME00000001', size: 3, sha256: crypto.createHash('sha256').update('pkg').digest('hex'), absolutePath: pkg, originalRelativePath: 'base.pkg', normalizedRelativePath: 'base/base.pkg', required: true, order: 1, adapterHint: 'ps4-package', provenance: [] }], dependencies: [], recipe: { installOrder: [{ componentId: 'base', order: 1 }] } };
|
||||
const registered = registerPs4PackageManifest({ db: database, outputRoot, manifest });
|
||||
assert.ok(registered.packageSetId); assert.equal(database.runQuery('SELECT COUNT(*) AS count FROM local_copies')[0].count, 1); assert.equal(database.runQuery('SELECT COUNT(*) AS count FROM console_installs')[0].count, 0);
|
||||
});
|
||||
@@ -0,0 +1,542 @@
|
||||
'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 database = require('../../src/database');
|
||||
const {
|
||||
createPs5NativePackageService,
|
||||
inspectPs5ContainerFile,
|
||||
} = require('../../src/services/ps5-native-package-service');
|
||||
const {
|
||||
PINNED_PS5_BUILDER,
|
||||
createPs5NativePkgBuilderProvider,
|
||||
runProcess,
|
||||
} = require('../../src/services/ps5-native-pkg-builder-provider');
|
||||
const { registerPs5NativePackageManifest } = require('../../src/services/ps5-native-package-registration-service');
|
||||
|
||||
function semanticCapabilities(overrides = {}) {
|
||||
return { backupToDebugFpkg: true, dataDlcToDebugFpkg: true, updateToDebugFpkg: false, backupInspection: true, packageInspection: true, packageValidation: true, launchReadiness: true, licenseFreeBuild: true, finalizedFihOutput: true, ...overrides };
|
||||
}
|
||||
function semanticProbe(overrides = {}) { return { available: true, ...PINNED_PS5_BUILDER, capabilities: semanticCapabilities(), ...overrides }; }
|
||||
function acceptedValidation() { return { accepted: true, validator: 'ProsperoPkgValidator', launchReady: true, unresolvedModules: [] }; }
|
||||
|
||||
function writeBackup(root, name = 'source', overrides = {}) {
|
||||
const folder = path.join(root, name);
|
||||
fs.mkdirSync(path.join(folder, 'sce_sys'), { recursive: true });
|
||||
fs.writeFileSync(path.join(folder, 'eboot.bin'), Buffer.concat([Buffer.from([0x7f, 0x45, 0x4c, 0x46]), Buffer.alloc(128)]));
|
||||
fs.writeFileSync(path.join(folder, 'sce_sys', 'param.json'), JSON.stringify({
|
||||
titleId: 'PPSA12345',
|
||||
contentId: 'UP0001-PPSA12345_00-CHIMERATEST00001',
|
||||
contentVersion: '01.00',
|
||||
localizedParameters: { enUS: { titleName: 'Chimera PS5 Test' }, defaultLanguage: 'en-US' },
|
||||
applicationDrmType: 'standard',
|
||||
...overrides,
|
||||
}));
|
||||
return folder;
|
||||
}
|
||||
|
||||
function writeDlcBackup(root, name = 'dlc-source', overrides = {}) {
|
||||
const folder = path.join(root, name);
|
||||
fs.mkdirSync(path.join(folder, 'sce_sys'), { recursive: true });
|
||||
fs.mkdirSync(path.join(folder, 'data'), { recursive: true });
|
||||
fs.writeFileSync(path.join(folder, 'data', 'owned-content.bin'), 'owned dlc fixture');
|
||||
fs.writeFileSync(path.join(folder, 'sce_sys', 'param.json'), JSON.stringify({
|
||||
titleId: 'PPSA12345',
|
||||
contentId: 'UP0001-PPSA12345_00-CHIMERADLC000001',
|
||||
contentVersion: '01.01',
|
||||
localizedParameters: { enUS: { titleName: 'Chimera PS5 Test DLC' }, defaultLanguage: 'en-US' },
|
||||
applicationDrmType: 'free',
|
||||
...overrides,
|
||||
}));
|
||||
return folder;
|
||||
}
|
||||
|
||||
function writeFih(filename, contentId = 'UP0001-PPSA12345_00-CHIMERATEST00001') {
|
||||
const fih = Buffer.alloc(0x10000); const pfs = Buffer.alloc(0x10000); const cnt = Buffer.alloc(0x2900);
|
||||
Buffer.from([0x7f, 0x46, 0x49, 0x48]).copy(fih); fih[4] = 1; fih.writeUInt16LE(3, 6); fih.writeUInt32LE(1, 8);
|
||||
fih.writeBigUInt64LE(0x10000n, 0x10); fih.writeBigUInt64LE(0x10000n, 0x18); fih.writeBigUInt64LE(0x20000n, 0x58); fih.writeUInt32LE(1, 0xfc);
|
||||
const gameDigest = crypto.createHash('sha3-256').update(pfs).digest(); for (const offset of [0x30, 0x70, 0xd0]) gameDigest.copy(fih, offset);
|
||||
Buffer.from([0x7f, 0x43, 0x4e, 0x54]).copy(cnt); cnt.writeUInt32BE(1, 0x10); cnt.writeUInt16BE(1, 0x14); cnt.writeUInt16BE(1, 0x16); cnt.writeUInt32BE(0x2000, 0x18); cnt.writeBigUInt64BE(0x2000n, 0x20); cnt.writeBigUInt64BE(0x900n, 0x28);
|
||||
Buffer.from(contentId, 'ascii').copy(cnt, 0x40, 0, 0x30); cnt.writeUInt32BE(0x20, 0x2000); cnt.writeUInt32BE(0x2100, 0x2010); cnt.writeUInt32BE(0x800, 0x2014);
|
||||
for (const [record, offset] of [0x000, 0x180, 0x300, 0x480, 0x600].entries()) crypto.createHash('sha256').update(`${contentId}:${record}`).digest().copy(cnt, 0x2100 + offset);
|
||||
crypto.createHash('sha3-256').update(cnt.subarray(0, 0xfe0)).digest().copy(cnt, 0xfe0);
|
||||
fs.writeFileSync(filename, Buffer.concat([fih, pfs, cnt]));
|
||||
}
|
||||
|
||||
function fixture(t, overrides = {}) {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'chimera-ps5-pkg-'));
|
||||
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
const libraryRoot = path.join(root, 'library');
|
||||
const outputRoot = path.join(root, 'vault');
|
||||
fs.mkdirSync(libraryRoot, { recursive: true });
|
||||
const registrations = [];
|
||||
const builds = [];
|
||||
const provider = overrides.provider || {
|
||||
probe: async () => semanticProbe(),
|
||||
build: async input => { builds.push(input); fs.mkdirSync(path.dirname(input.outputPath), { recursive: true }); writeFih(input.outputPath, input.contentId); return { provider: PINNED_PS5_BUILDER }; },
|
||||
validatePackage: async () => acceptedValidation(),
|
||||
};
|
||||
const service = createPs5NativePackageService({
|
||||
allowedRoots: () => [libraryRoot, outputRoot], outputRoot,
|
||||
stateFile: path.join(root, 'state', 'jobs.json'), provider,
|
||||
registrar: async manifest => { registrations.push(manifest); return { packageSetId: 91 }; },
|
||||
idFactory: overrides.idFactory || (() => 'ps5-job-1'), now: overrides.now || (() => 1_800_000_000_000),
|
||||
});
|
||||
return { root, libraryRoot, outputRoot, registrations, builds, service };
|
||||
}
|
||||
|
||||
test('validates a decrypted PS5 backup from param.json and executable evidence', async t => {
|
||||
const f = fixture(t);
|
||||
const source = writeBackup(f.libraryRoot);
|
||||
const result = await f.service.inspectBackup({ sourcePath: source, expectedTitleId: 'PPSA12345' });
|
||||
assert.equal(result.ready, true);
|
||||
assert.equal(result.titleId, 'PPSA12345');
|
||||
assert.equal(result.executable.format, 'raw-elf');
|
||||
assert.equal(result.contentId, 'UP0001-PPSA12345_00-CHIMERATEST00001');
|
||||
});
|
||||
|
||||
test('blocks opaque executables and identity mismatch while rejecting unsafe paths', async t => {
|
||||
const f = fixture(t);
|
||||
const opaque = writeBackup(f.libraryRoot, 'opaque');
|
||||
fs.writeFileSync(path.join(opaque, 'eboot.bin'), 'not decrypted');
|
||||
const opaqueResult = await f.service.inspectBackup({ sourcePath: opaque });
|
||||
assert.equal(opaqueResult.state, 'blocked');
|
||||
assert.ok(opaqueResult.issues.some(item => item.code === 'PS5_FPKG_MODULE_UNRESOLVED'));
|
||||
const wrong = writeBackup(f.libraryRoot, 'wrong');
|
||||
const mismatch = await f.service.inspectBackup({ sourcePath: wrong, expectedTitleId: 'PPSA99999' });
|
||||
assert.equal(mismatch.state, 'blocked'); assert.ok(mismatch.issues.some(item => item.code === 'PS5_FPKG_TITLE_MISMATCH'));
|
||||
const linked = writeBackup(f.libraryRoot, 'linked');
|
||||
const outside = path.join(f.root, 'outside'); fs.mkdirSync(outside); fs.writeFileSync(path.join(outside, 'x'), 'x');
|
||||
fs.symlinkSync(outside, path.join(linked, 'escape'), process.platform === 'win32' ? 'junction' : 'dir');
|
||||
await assert.rejects(() => f.service.inspectBackup({ sourcePath: linked }), error => error.code === 'PS5_FPKG_SOURCE_UNSAFE');
|
||||
await assert.rejects(() => f.service.inspectBackup({ sourcePath: outside }), error => error.code === 'PS5_BACKUP_CONTAINMENT');
|
||||
});
|
||||
|
||||
test('accepts signed modules only when a matching decrypted raw-ELF substitute exists', async t => {
|
||||
const f = fixture(t); const source = writeBackup(f.libraryRoot, 'signed-with-decrypted');
|
||||
fs.writeFileSync(path.join(source, 'eboot.bin'), Buffer.concat([Buffer.from([0x54, 0x14, 0xf5, 0xee]), Buffer.alloc(128)]));
|
||||
fs.mkdirSync(path.join(source, 'decrypted'), { recursive: true });
|
||||
fs.writeFileSync(path.join(source, 'decrypted', 'eboot.bin'), Buffer.concat([Buffer.from([0x7f, 0x45, 0x4c, 0x46]), Buffer.alloc(128)]));
|
||||
const result = await f.service.inspectBackup({ sourcePath: source });
|
||||
assert.equal(result.executable.format, 'self-with-decrypted-substitutes');
|
||||
assert.equal(result.executable.substitutedModuleCount, 1);
|
||||
});
|
||||
|
||||
test('accepts data DLC without eboot but refuses update and entitlement-only package routes', async t => {
|
||||
const f = fixture(t); const dlc = writeDlcBackup(f.libraryRoot);
|
||||
const inspected = await f.service.inspectBackup({ sourcePath: dlc, expectedTitleId: 'PPSA12345', componentType: 'dlc' });
|
||||
assert.equal(inspected.componentType, 'dlc');
|
||||
assert.equal(inspected.executable.moduleCount, 0);
|
||||
const update = await f.service.inspectBackup({ sourcePath: dlc, componentType: 'update' });
|
||||
assert.equal(update.state, 'unsupported'); assert.equal(update.ready, false);
|
||||
await assert.rejects(() => f.service.inspectBackup({ sourcePath: dlc, componentType: 'dlc-entitlement' }), error => error.code === 'PS5_FPKG_SOURCE_NOT_READY');
|
||||
});
|
||||
|
||||
test('distinguishes genuine PS5 FIH/CNT containers from FFPKG mount images and renamed files', t => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'chimera-ps5-format-')); t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
const fih = path.join(root, 'game.pkg'); writeFih(fih);
|
||||
const cnt = path.join(root, 'metadata.pkg'); fs.writeFileSync(cnt, Buffer.concat([Buffer.from([0x7f, 0x43, 0x4e, 0x54]), Buffer.alloc(64)]));
|
||||
const image = path.join(root, 'game.ffpkg'); fs.writeFileSync(image, Buffer.alloc(128));
|
||||
const renamed = path.join(root, 'renamed.pkg'); fs.writeFileSync(renamed, Buffer.alloc(128));
|
||||
const inspectedFih = inspectPs5ContainerFile(fih);
|
||||
assert.deepEqual(inspectedFih.format, 'ps5-fih-debug-package');
|
||||
assert.equal(inspectedFih.validation.installTrust.status, 'unproven');
|
||||
assert.equal(inspectedFih.validation.installTrust.basis, 'structural-only');
|
||||
assert.ok(inspectedFih.validation.warnings.some(item => item.code === 'APPINST_TRUST_UNPROVEN'));
|
||||
assert.deepEqual(inspectPs5ContainerFile(cnt).format, 'ps5-cnt-container');
|
||||
assert.equal(inspectPs5ContainerFile(image).format, 'ufs-mount-image');
|
||||
assert.equal(inspectPs5ContainerFile(image).nativeInstallable, false);
|
||||
assert.equal(inspectPs5ContainerFile(renamed).format, 'unknown-pkg');
|
||||
assert.equal(inspectPs5ContainerFile(renamed).nativeInstallable, false);
|
||||
});
|
||||
|
||||
test('accepts the upstream stock FIH image-count and opaque image-key profile without claiming launch compatibility', t => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'chimera-ps5-stock-profile-')); t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
const stock = path.join(root, 'stock.pkg'); writeFih(stock, 'IV9999-FAKE00000_00-HOMEBREWLOADER01');
|
||||
const bytes = fs.readFileSync(stock);
|
||||
bytes.writeUInt32LE(0, 0xfc);
|
||||
for (let index = 0; index < 0x800; index++) bytes[0x22100 + index] = ((index * 73 + 19) % 255) + 1;
|
||||
crypto.createHash('sha3-256').update(bytes.subarray(0x20000, 0x20fe0)).digest().copy(bytes, 0x20fe0);
|
||||
fs.writeFileSync(stock, bytes);
|
||||
|
||||
const inspected = inspectPs5ContainerFile(stock);
|
||||
assert.equal(inspected.structurallyValid, true);
|
||||
assert.equal(inspected.validation.validationLevel, 'fih-cnt-structural-v3');
|
||||
assert.equal(inspected.validation.imageCount, 0);
|
||||
assert.equal(inspected.validation.imageKey.profile, 'opaque-filled-0x800');
|
||||
assert.equal(inspected.validation.routeCompatibility.status, 'unproven');
|
||||
assert.ok(inspected.validation.warnings.some(item => item.code === 'FIH_IMAGE_COUNT_STOCK_PROFILE'));
|
||||
assert.ok(inspected.validation.warnings.some(item => item.code === 'CNT_IMAGE_KEY_OPAQUE_PROFILE'));
|
||||
});
|
||||
|
||||
test('fails closed when FIH geometry, CNT sealing or image-key records are corrupted', t => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'chimera-ps5-structural-')); t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
const geometry = path.join(root, 'geometry.pkg'); writeFih(geometry);
|
||||
const geometryFd = fs.openSync(geometry, 'r+'); fs.writeSync(geometryFd, Buffer.from([0x01]), 0, 1, 0x58); fs.closeSync(geometryFd);
|
||||
let inspected = inspectPs5ContainerFile(geometry);
|
||||
assert.equal(inspected.nativeInstallable, false);
|
||||
assert.ok(inspected.validation.errors.some(item => item.code === 'FIH_CNT_GEOMETRY'));
|
||||
|
||||
const seal = path.join(root, 'seal.pkg'); writeFih(seal);
|
||||
const sealFd = fs.openSync(seal, 'r+'); fs.writeSync(sealFd, Buffer.from([0xff]), 0, 1, 0x20000 + 0x100); fs.closeSync(sealFd);
|
||||
inspected = inspectPs5ContainerFile(seal);
|
||||
assert.ok(inspected.validation.errors.some(item => item.code === 'CNT_HEADER_SEAL'));
|
||||
|
||||
const key = path.join(root, 'key.pkg'); writeFih(key);
|
||||
const keyFd = fs.openSync(key, 'r+'); fs.writeSync(keyFd, Buffer.alloc(0x800), 0, 0x800, 0x22100); fs.closeSync(keyFd);
|
||||
inspected = inspectPs5ContainerFile(key);
|
||||
assert.ok(inspected.validation.errors.some(item => item.code === 'CNT_IMAGE_KEY_PROFILE'));
|
||||
|
||||
const sparseKey = path.join(root, 'sparse-key.pkg'); writeFih(sparseKey);
|
||||
const sparseKeyBytes = fs.readFileSync(sparseKey); sparseKeyBytes.fill(0, 0x22100, 0x22900); sparseKeyBytes[0x22100] = 1;
|
||||
crypto.createHash('sha3-256').update(sparseKeyBytes.subarray(0x20000, 0x20fe0)).digest().copy(sparseKeyBytes, 0x20fe0); fs.writeFileSync(sparseKey, sparseKeyBytes);
|
||||
inspected = inspectPs5ContainerFile(sparseKey);
|
||||
assert.ok(inspected.validation.errors.some(item => item.code === 'CNT_IMAGE_KEY_PROFILE'));
|
||||
});
|
||||
|
||||
test('builds, validates and hashes a real FIH package before registration', async t => {
|
||||
const f = fixture(t);
|
||||
const source = writeBackup(f.libraryRoot);
|
||||
const created = await f.service.create({ sourcePath: source, expectedTitleId: 'PPSA12345' });
|
||||
assert.equal(created.status, 'package_ready');
|
||||
const complete = await f.service.run(created.id);
|
||||
assert.equal(complete.status, 'artifact_registered');
|
||||
assert.equal(complete.container.format, 'ps5-fih-debug-package');
|
||||
assert.match(complete.sha256, /^[a-f0-9]{64}$/);
|
||||
assert.equal(f.registrations.length, 1);
|
||||
assert.equal(f.registrations[0].components[0].adapterHint, 'ps5-fpkg-finalized-fih');
|
||||
assert.equal(f.registrations[0].components[0].sha256, crypto.createHash('sha256').update(fs.readFileSync(complete.outputPath)).digest('hex'));
|
||||
});
|
||||
|
||||
test('builds one ordered native package set from base plus data DLC components', async t => {
|
||||
const f = fixture(t); const base = writeBackup(f.libraryRoot, 'base'); const dlc = writeDlcBackup(f.libraryRoot, 'dlc');
|
||||
const created = await f.service.create({ expectedTitleId: 'PPSA12345', sources: [{ sourcePath: base, componentType: 'base' }, { sourcePath: dlc, componentType: 'dlc' }] });
|
||||
const complete = await f.service.run(created.id);
|
||||
assert.equal(complete.status, 'artifact_registered');
|
||||
assert.deepEqual(f.builds.map(item => item.componentType), ['base', 'dlc']);
|
||||
assert.deepEqual(f.registrations[0].components.map(item => item.type), ['base', 'dlc']);
|
||||
assert.deepEqual(f.registrations[0].recipe.installOrder.map(item => item.order), [1, 3]);
|
||||
assert.deepEqual(f.registrations[0].dependencies, [{ componentId: f.registrations[0].components[1].id, requires: 'base' }]);
|
||||
assert.equal(f.registrations[0].components.every(item => item.container?.format === 'ps5-fih-debug-package'), true);
|
||||
});
|
||||
|
||||
test('a component identity change between planning and build fails closed', async t => {
|
||||
const f = fixture(t); const base = writeBackup(f.libraryRoot, 'base'); const dlc = writeDlcBackup(f.libraryRoot, 'dlc');
|
||||
const created = await f.service.create({ sources: [{ sourcePath: base, componentType: 'base' }, { sourcePath: dlc, componentType: 'dlc' }] });
|
||||
const paramPath = path.join(dlc, 'sce_sys', 'param.json'); const param = JSON.parse(fs.readFileSync(paramPath, 'utf8')); param.contentId = 'UP0001-PPSA12345_00-CHIMERADLC000002'; fs.writeFileSync(paramPath, JSON.stringify(param));
|
||||
await assert.rejects(() => f.service.run(created.id), error => error.code === 'PS5_FPKG_SOURCE_CHANGED');
|
||||
assert.equal(f.registrations.length, 0);
|
||||
});
|
||||
|
||||
test('a same-size payload mutation between planning and build fails closed', async t => {
|
||||
const f = fixture(t); const base = writeBackup(f.libraryRoot, 'base'); const dlc = writeDlcBackup(f.libraryRoot, 'dlc');
|
||||
const created = await f.service.create({ sources: [{ sourcePath: base, componentType: 'base' }, { sourcePath: dlc, componentType: 'dlc' }] });
|
||||
const payloadPath = path.join(dlc, 'data', 'owned-content.bin'); const original = fs.readFileSync(payloadPath);
|
||||
fs.writeFileSync(payloadPath, Buffer.alloc(original.length, 0x78));
|
||||
await assert.rejects(() => f.service.run(created.id), error => error.code === 'PS5_FPKG_SOURCE_CHANGED');
|
||||
assert.equal(f.builds.length, 0);
|
||||
assert.equal(f.registrations.length, 0);
|
||||
});
|
||||
|
||||
test('exposes only hash-bound native FIH packages through a short-lived HTTPS handoff', async t => {
|
||||
const f = fixture(t); const pkg = path.join(f.outputRoot, 'verified.pkg'); writeFih(pkg); const bytes = fs.readFileSync(pkg); const sha256 = crypto.createHash('sha256').update(bytes).digest('hex');
|
||||
const exposed = await f.service.expose({ filePath: pkg, sha256, size: bytes.length, origin: 'https://chimera.example.test' });
|
||||
assert.match(exposed.url, /^https:\/\/chimera\.example\.test\/ps5-native-package\/[A-Za-z0-9_-]{43}\/verified\.pkg$/);
|
||||
assert.equal(f.service.resolvePackageHandoff(new URL(exposed.url).pathname).filePath, fs.realpathSync(pkg));
|
||||
await assert.rejects(() => f.service.expose({ filePath: pkg, sha256: '0'.repeat(64), size: bytes.length, origin: 'https://chimera.example.test' }), error => error.code === 'PS5_PACKAGE_HANDOFF_HASH');
|
||||
await assert.rejects(() => f.service.expose({ filePath: pkg, sha256, size: bytes.length, origin: 'http://chimera.example.test' }), error => error.code === 'PS5_PACKAGE_ORIGIN');
|
||||
});
|
||||
|
||||
test('active jobs become interrupted and require an explicit resume', async t => {
|
||||
const f = fixture(t);
|
||||
const source = writeBackup(f.libraryRoot);
|
||||
const created = await f.service.create({ sourcePath: source });
|
||||
const stateFile = path.join(f.root, 'state', 'jobs.json');
|
||||
const stored = JSON.parse(fs.readFileSync(stateFile, 'utf8')); stored.jobs[0].status = 'building'; fs.writeFileSync(stateFile, JSON.stringify(stored));
|
||||
const restarted = createPs5NativePackageService({ allowedRoots: () => [f.libraryRoot, f.outputRoot], outputRoot: f.outputRoot, stateFile, provider: f.service.__provider || { probe: async () => ({ available: true }), build: async () => ({}) }, registrar: async () => ({ packageSetId: 91 }) });
|
||||
assert.equal(restarted.get(created.id).status, 'interrupted');
|
||||
assert.equal(restarted.resume(created.id).status, 'package_ready');
|
||||
});
|
||||
|
||||
test('requires a real registrar and never marks an unregistered artifact completed', async t => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'chimera-ps5-registrar-')); t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
const libraryRoot = path.join(root, 'library'); const outputRoot = path.join(root, 'vault'); fs.mkdirSync(libraryRoot);
|
||||
const provider = { probe: async () => semanticProbe(), build: async input => writeFih(input.outputPath, input.contentId), validatePackage: async () => acceptedValidation() };
|
||||
assert.throws(() => createPs5NativePackageService({ allowedRoots: () => [libraryRoot, outputRoot], outputRoot, stateFile: path.join(root, 'missing.json'), provider }), /registrar/i);
|
||||
let registration = {}; let builds = 0;
|
||||
provider.build = async input => { builds++; fs.mkdirSync(path.dirname(input.outputPath), { recursive: true }); writeFih(input.outputPath, input.contentId); };
|
||||
const service = createPs5NativePackageService({ allowedRoots: () => [libraryRoot, outputRoot], outputRoot, stateFile: path.join(root, 'jobs.json'), provider, registrar: async () => registration, idFactory: () => 'registration-job' });
|
||||
const source = writeBackup(libraryRoot);
|
||||
const created = await service.create({ sourcePath: source });
|
||||
await assert.rejects(() => service.run(created.id), error => error.code === 'PS5_PACKAGE_REGISTRATION_INCOMPLETE');
|
||||
assert.equal(service.get(created.id).status, 'registration_pending');
|
||||
registration = { packageSetId: 123 };
|
||||
service.resume(created.id);
|
||||
const completed = await service.run(created.id);
|
||||
assert.equal(completed.status, 'artifact_registered');
|
||||
assert.equal(completed.registration.packageSetId, 123);
|
||||
assert.equal(builds, 1);
|
||||
});
|
||||
|
||||
test('rejects stale concurrent writers instead of clobbering job state', async t => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'chimera-ps5-cas-')); t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
const libraryRoot = path.join(root, 'library'); const outputRoot = path.join(root, 'vault'); const stateFile = path.join(root, 'jobs.json'); fs.mkdirSync(libraryRoot);
|
||||
const provider = { probe: async () => ({ available: true }), build: async () => ({}) }; const options = { allowedRoots: () => [libraryRoot, outputRoot], outputRoot, stateFile, provider, registrar: async () => ({ packageSetId: 1 }) };
|
||||
const first = createPs5NativePackageService({ ...options, idFactory: () => 'first' });
|
||||
const second = createPs5NativePackageService({ ...options, idFactory: () => 'second' });
|
||||
const source = writeBackup(libraryRoot);
|
||||
await first.create({ sourcePath: source });
|
||||
await assert.rejects(() => second.create({ sourcePath: source }), error => error.code === 'PS5_PACKAGE_STATE_CONFLICT');
|
||||
assert.deepEqual(JSON.parse(fs.readFileSync(stateFile, 'utf8')).jobs.map(job => job.id), ['first']);
|
||||
});
|
||||
|
||||
test('fails closed without overwriting corrupted durable job state', t => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'chimera-ps5-corrupt-state-')); t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
const outputRoot = path.join(root, 'vault'); const stateFile = path.join(root, 'jobs.json'); const original = '{broken-json'; fs.writeFileSync(stateFile, original);
|
||||
const provider = { probe: async () => ({ available: true }), build: async () => ({}) };
|
||||
assert.throws(() => createPs5NativePackageService({ allowedRoots: () => [root], outputRoot, stateFile, provider, registrar: async () => ({ packageSetId: 1 }) }), error => error.code === 'PS5_PACKAGE_STATE_CORRUPT');
|
||||
assert.equal(fs.readFileSync(stateFile, 'utf8'), original);
|
||||
});
|
||||
|
||||
test('resume reuses a hash-verified component checkpoint instead of rebuilding it', async t => {
|
||||
const calls = []; let failDlc = true;
|
||||
const provider = {
|
||||
probe: async () => semanticProbe(),
|
||||
build: async input => { calls.push(input.componentType); if (input.componentType === 'dlc' && failDlc) throw new Error('simulated DLC failure'); fs.mkdirSync(path.dirname(input.outputPath), { recursive: true }); writeFih(input.outputPath, input.contentId); },
|
||||
validatePackage: async () => acceptedValidation(),
|
||||
};
|
||||
const f = fixture(t, { provider }); const base = writeBackup(f.libraryRoot, 'base'); const dlc = writeDlcBackup(f.libraryRoot, 'dlc');
|
||||
const created = await f.service.create({ sources: [{ sourcePath: base, componentType: 'base' }, { sourcePath: dlc, componentType: 'dlc' }] });
|
||||
await assert.rejects(() => f.service.run(created.id), /simulated DLC failure/);
|
||||
failDlc = false; f.service.resume(created.id);
|
||||
const complete = await f.service.run(created.id);
|
||||
assert.equal(complete.status, 'artifact_registered');
|
||||
assert.deepEqual(calls, ['base', 'dlc', 'dlc']);
|
||||
});
|
||||
|
||||
test('start acknowledges immediately and runs the durable build asynchronously', async t => {
|
||||
let releaseBuild; const gate = new Promise(resolve => { releaseBuild = resolve; });
|
||||
const provider = {
|
||||
probe: async () => semanticProbe(),
|
||||
build: async input => { await gate; fs.mkdirSync(path.dirname(input.outputPath), { recursive: true }); writeFih(input.outputPath, input.contentId); },
|
||||
validatePackage: async () => acceptedValidation(),
|
||||
};
|
||||
const f = fixture(t, { provider }); const created = await f.service.create({ sourcePath: writeBackup(f.libraryRoot) });
|
||||
const accepted = f.service.start(created.id);
|
||||
assert.equal(accepted.accepted, true); assert.equal(accepted.status, 'package_ready');
|
||||
assert.equal(f.service.start(created.id).alreadyRunning, true);
|
||||
releaseBuild();
|
||||
for (let attempt = 0; attempt < 100 && f.service.get(created.id).status !== 'artifact_registered'; attempt++) await new Promise(resolve => setTimeout(resolve, 10));
|
||||
assert.equal(f.service.get(created.id).status, 'artifact_registered');
|
||||
});
|
||||
|
||||
test('service enforces the builder-advertised source ceiling before build', async t => {
|
||||
let builds = 0;
|
||||
const provider = {
|
||||
probe: async () => semanticProbe({ capabilities: semanticCapabilities({ maxSourceBytes: 1 }) }),
|
||||
build: async () => { builds++; },
|
||||
validatePackage: async () => acceptedValidation(),
|
||||
};
|
||||
const f = fixture(t, { provider });
|
||||
const created = await f.service.create({ sourcePath: writeBackup(f.libraryRoot) });
|
||||
await assert.rejects(() => f.service.run(created.id), error => error.code === 'PS5_BUILDER_SOURCE_LIMIT' && error.details.maxSourceBytes === 1);
|
||||
assert.equal(builds, 0);
|
||||
});
|
||||
|
||||
test('pinned bridge uses a fixed no-shell JSON request protocol and rejects hostile paths', async t => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'chimera-ps5-builder-')); t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
const toolRoot = path.join(root, 'tool'); const workRoot = path.join(root, 'work'); const sourceRoot = path.join(root, 'source');
|
||||
fs.mkdirSync(toolRoot); fs.mkdirSync(workRoot); fs.mkdirSync(sourceRoot);
|
||||
const executable = path.join(toolRoot, PINNED_PS5_BUILDER.executable); fs.writeFileSync(executable, 'bridge');
|
||||
const outputPath = path.join(workRoot, 'out.pkg'); const calls = [];
|
||||
const runner = async input => {
|
||||
calls.push(input);
|
||||
if (input.args[0] === 'probe') return { output: JSON.stringify(semanticProbe()) };
|
||||
writeFih(outputPath); fs.writeFileSync(input.args[4], JSON.stringify({ ok: true, outputPath })); return { output: 'ok' };
|
||||
};
|
||||
const provider = createPs5NativePkgBuilderProvider({ toolRoot, allowedWorkRoots: [workRoot], allowedSourceRoots: [sourceRoot], runner });
|
||||
assert.equal((await provider.probe()).available, true);
|
||||
await provider.build({ sourcePath: sourceRoot, sourceSize: 0, outputPath, workPath: path.join(workRoot, 'job'), titleId: 'PPSA12345', contentId: 'UP0001-PPSA12345_00-CHIMERATEST00001' });
|
||||
assert.equal(calls.at(-1).command, fs.realpathSync(executable));
|
||||
assert.equal(calls.at(-1).shell, false);
|
||||
assert.equal(calls.at(-1).args[0], 'build-backup');
|
||||
assert.equal(calls.at(-1).args[1], '--request');
|
||||
assert.equal(calls.at(-1).args[3], '--result');
|
||||
assert.equal(JSON.parse(fs.readFileSync(calls.at(-1).args[2], 'utf8')).componentType, 'base');
|
||||
await assert.rejects(() => provider.build({ sourcePath: sourceRoot, sourceSize: 0, outputPath, workPath: path.join(workRoot, 'update'), titleId: 'PPSA12345', contentId: 'UP0001-PPSA12345_00-CHIMERATEST00001', componentType: 'update' }), error => error.code === 'PS5_BUILDER_COMPONENT');
|
||||
await assert.rejects(() => provider.build({ sourcePath: fspOutside(root), sourceSize: 0, outputPath, workPath: path.join(workRoot, 'evil'), titleId: 'PPSA12345', contentId: 'UP0001-PPSA12345_00-CHIMERATEST00001' }), error => error.code === 'PS5_BUILDER_PATH');
|
||||
});
|
||||
|
||||
test('pinned bridge rejects a runtime that does not prove license-free base conversion', async t => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'chimera-ps5-builder-license-free-'));
|
||||
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
const toolRoot = path.join(root, 'tool');
|
||||
fs.mkdirSync(toolRoot);
|
||||
fs.writeFileSync(path.join(toolRoot, PINNED_PS5_BUILDER.executable), 'bridge');
|
||||
const runner = async () => ({ output: JSON.stringify({
|
||||
...PINNED_PS5_BUILDER,
|
||||
capabilities: {
|
||||
...semanticCapabilities({ licenseFreeBuild: false }),
|
||||
},
|
||||
}) });
|
||||
const provider = createPs5NativePkgBuilderProvider({ toolRoot, runner });
|
||||
const probe = await provider.probe();
|
||||
assert.equal(probe.available, false);
|
||||
assert.equal(probe.reason, 'capability-mismatch');
|
||||
});
|
||||
|
||||
test('pinned bridge persists bounded diagnostics when the native process fails', async t => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'chimera-ps5-builder-failure-')); t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
const toolRoot = path.join(root, 'tool'); const workRoot = path.join(root, 'work'); const sourceRoot = path.join(root, 'source');
|
||||
fs.mkdirSync(toolRoot); fs.mkdirSync(workRoot); fs.mkdirSync(sourceRoot);
|
||||
fs.writeFileSync(path.join(toolRoot, PINNED_PS5_BUILDER.executable), 'bridge');
|
||||
const runner = async input => {
|
||||
if (input.args[0] === 'probe') return { output: JSON.stringify(semanticProbe()) };
|
||||
throw Object.assign(new Error('PS5 packagebridge stopte met code 1.'), { code: 'PS5_BUILDER_FAILED', details: { code: 1, output: 'System.InvalidOperationException: exact native failure' } });
|
||||
};
|
||||
const provider = createPs5NativePkgBuilderProvider({ toolRoot, allowedWorkRoots: [workRoot], allowedSourceRoots: [sourceRoot], runner });
|
||||
const workPath = path.join(workRoot, 'job');
|
||||
await assert.rejects(() => provider.build({ sourcePath: sourceRoot, sourceSize: 0, outputPath: path.join(workRoot, 'out.pkg'), workPath, titleId: 'PPSA12345', contentId: 'UP0001-PPSA12345_00-CHIMERATEST00001' }), error => {
|
||||
assert.equal(error.code, 'PS5_BUILDER_FAILED');
|
||||
assert.match(error.details.output, /exact native failure/);
|
||||
assert.equal(error.details.exitCode, 1);
|
||||
return true;
|
||||
});
|
||||
const diagnostic = JSON.parse(fs.readFileSync(path.join(workPath, 'build-failure.json'), 'utf8'));
|
||||
assert.equal(diagnostic.code, 'PS5_BUILDER_FAILED');
|
||||
assert.equal(diagnostic.exitCode, 1);
|
||||
assert.match(diagnostic.output, /exact native failure/);
|
||||
});
|
||||
|
||||
test('native bridge process logging is capped while retaining a bounded output tail', async t => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'chimera-ps5-builder-log-')); t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
const logPath = path.join(root, 'build.log');
|
||||
const result = await runProcess({ command: process.execPath, args: ['-e', `process.stdout.write('x'.repeat(${9 * 1024 * 1024}))`], cwd: root, logPath });
|
||||
assert.equal(result.code, 0);
|
||||
assert.equal(result.output.length, 64_000);
|
||||
assert.ok(fs.statSync(logPath).size <= 8 * 1024 * 1024);
|
||||
});
|
||||
|
||||
test('failed package jobs retain structured provider diagnostics', async t => {
|
||||
const provider = {
|
||||
probe: async () => semanticProbe(),
|
||||
build: async () => { throw Object.assign(new Error('bridge failed'), { code: 'PS5_BUILDER_FAILED', details: { exitCode: 1, output: 'precise failure', diagnosticPath: '/safe/work/build-failure.json' } }); },
|
||||
validatePackage: async () => acceptedValidation(),
|
||||
};
|
||||
const f = fixture(t, { provider }); const source = writeBackup(f.libraryRoot);
|
||||
const created = await f.service.create({ sourcePath: source });
|
||||
await assert.rejects(() => f.service.run(created.id), /bridge failed/);
|
||||
const failed = f.service.get(created.id);
|
||||
assert.equal(failed.status, 'failed');
|
||||
assert.deepEqual(failed.errorDetails, { code: 'PS5_BUILDER_FAILED', exitCode: 1, output: 'precise failure', diagnosticPath: '/safe/work/build-failure.json' });
|
||||
});
|
||||
|
||||
test('bridge binds data DLC explicitly and exposes no update or entitlement-only build mode', () => {
|
||||
const source = fs.readFileSync(path.join(__dirname, '../../tools/chimera-prospero-bridge/Program.cs'), 'utf8');
|
||||
assert.match(source, /chimera-prospero-bridge-v4/);
|
||||
assert.match(source, /ProsperoBackupConverter/);
|
||||
assert.match(source, /ProsperoPkgValidator\.Validate/);
|
||||
assert.match(source, /build-backup/);
|
||||
assert.match(source, /build-data-dlc/);
|
||||
assert.match(source, /inspect-backup/);
|
||||
assert.match(source, /inspect-package/);
|
||||
assert.match(source, /validate-package/);
|
||||
assert.match(source, /licenseFreeBuild = true/);
|
||||
assert.match(source, /backupBuildApi = "ProsperoPackageBuilder"/);
|
||||
assert.match(source, /LicenseFree = true, FakeSignSelfModules = false/);
|
||||
assert.match(source, /ProsperoFself\.MakeFself/);
|
||||
assert.doesNotMatch(source, /patchSet/);
|
||||
assert.doesNotMatch(source, /wideNapsU2cBase|stockImageKeyRsa3072|chimera-license-free-base-v16/);
|
||||
assert.match(source, /ProsperoPackageMode\.AdditionalContentData/);
|
||||
assert.doesNotMatch(source, /ProsperoPackageMode\.AdditionalContentNoData/);
|
||||
assert.doesNotMatch(source, /ProsperoPackageMode\.(Update|Patch)/);
|
||||
});
|
||||
|
||||
test('builder v4 request contains semantic inputs without legacy implementation tuning', async t => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'chimera-ps5-builder-tuning-')); t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
const toolRoot = path.join(root, 'tool'); const workRoot = path.join(root, 'work'); const sourceRoot = path.join(root, 'source');
|
||||
fs.mkdirSync(toolRoot); fs.mkdirSync(workRoot); fs.mkdirSync(sourceRoot);
|
||||
fs.writeFileSync(path.join(toolRoot, PINNED_PS5_BUILDER.executable), 'bridge');
|
||||
const outputPath = path.join(workRoot, 'out.pkg'); const calls = [];
|
||||
const runner = async input => {
|
||||
calls.push(input);
|
||||
if (input.args[0] === 'probe') return { output: JSON.stringify(semanticProbe()) };
|
||||
writeFih(outputPath);
|
||||
fs.writeFileSync(input.args[4], JSON.stringify({ ok: true, outputPath }));
|
||||
return { output: 'ok' };
|
||||
};
|
||||
const provider = createPs5NativePkgBuilderProvider({
|
||||
toolRoot,
|
||||
allowedWorkRoots: [workRoot],
|
||||
allowedSourceRoots: [sourceRoot],
|
||||
runner,
|
||||
});
|
||||
await provider.build({ sourcePath: sourceRoot, sourceSize: 0, outputPath, workPath: path.join(workRoot, 'job'), titleId: 'PPSA12345', contentId: 'UP0001-PPSA12345_00-CHIMERATEST00001' });
|
||||
const buildCall = calls.at(-1);
|
||||
const request = JSON.parse(fs.readFileSync(buildCall.args[2], 'utf8'));
|
||||
assert.equal(request.operation, 'build-backup');
|
||||
assert.equal(request.abi, 'chimera-prospero-bridge-v4');
|
||||
assert.equal(request.tuning, undefined);
|
||||
assert.equal(buildCall.env.CHIMERA_PROSPERO_COMPRESSION_WORKERS, undefined);
|
||||
assert.match(buildCall.env.CHIMERA_PROSPERO_ALLOWED_SOURCE_ROOTS, /source/);
|
||||
});
|
||||
|
||||
test('Docker builds the exact upstream revision with only the pinned streaming patch', () => {
|
||||
const root = path.resolve(__dirname, '../..');
|
||||
const dockerfile = fs.readFileSync(path.join(root, 'Dockerfile'), 'utf8');
|
||||
assert.match(dockerfile, /9084c0e88cd8d7cd9c0b353b8b6b89f7d1cc4275/);
|
||||
assert.match(dockerfile, /git .* status --porcelain/);
|
||||
assert.match(dockerfile, /CHIMERA_UPSTREAM_PROVENANCE\.json/);
|
||||
assert.match(dockerfile, /0001-stream-large-package-assembly\.patch/);
|
||||
assert.doesNotMatch(dockerfile, /v16-patches|chimera-license-free-base-v16/);
|
||||
assert.equal(fs.existsSync(path.join(root, 'tools/chimera-prospero-bridge/legacy/v16-patches/0001-parallel-fast-store.patch')), true);
|
||||
});
|
||||
|
||||
test('builder rejects insufficient workspace before starting the bridge', async t => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'chimera-ps5-builder-capacity-')); t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
const toolRoot = path.join(root, 'tool'); const workRoot = path.join(root, 'work'); const sourceRoot = path.join(root, 'source');
|
||||
fs.mkdirSync(toolRoot); fs.mkdirSync(workRoot); fs.mkdirSync(sourceRoot);
|
||||
fs.writeFileSync(path.join(toolRoot, PINNED_PS5_BUILDER.executable), 'bridge');
|
||||
let buildRuns = 0;
|
||||
const runner = async input => {
|
||||
if (input.args[0] === 'probe') return { output: JSON.stringify(semanticProbe()) };
|
||||
buildRuns++; return { output: '' };
|
||||
};
|
||||
const provider = createPs5NativePkgBuilderProvider({ toolRoot, allowedWorkRoots: [workRoot], allowedSourceRoots: [sourceRoot], runner });
|
||||
await assert.rejects(() => provider.build({ sourcePath: sourceRoot, sourceSize: 10_000_000_000_000, outputPath: path.join(workRoot, 'out.pkg'), workPath: path.join(workRoot, 'job'), titleId: 'PPSA12345', contentId: 'UP0001-PPSA12345_00-CHIMERATEST00001' }), error => error.code === 'PS5_BUILDER_DISK');
|
||||
assert.equal(buildRuns, 0);
|
||||
});
|
||||
|
||||
function fspOutside(root) { const outside = path.join(root, 'not-allowed'); fs.mkdirSync(outside, { recursive: true }); return outside; }
|
||||
|
||||
test('registers a verified native package as a local package set without console-install evidence', async t => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'chimera-ps5-registration-'));
|
||||
database.configure({ dataDir: path.join(root, 'db'), logger: { info() {}, warn() {}, error() {} } }); await database.initialize();
|
||||
t.after(() => { database.close(); fs.rmSync(root, { recursive: true, force: true }); });
|
||||
const outputRoot = path.join(root, 'vault'); const folder = path.join(outputRoot, 'PPSA12345', 'job'); fs.mkdirSync(folder, { recursive: true });
|
||||
const pkg = path.join(folder, 'PPSA12345-base.pkg'); writeFih(pkg); const bytes = fs.readFileSync(pkg);
|
||||
const manifest = { schemaVersion: 1, kind: 'chimera.ps5-native-package.v1', createdAt: 100, title: { platform: 'ps5', titleId: 'PPSA12345', name: 'PS5 Test' }, packageFormat: { container: 'FIH', debug: true, retail: false }, components: [{ id: 'base', type: 'base', version: '01.00', contentId: 'UP0001-PPSA12345_00-CHIMERATEST00001', size: bytes.length, sha256: crypto.createHash('sha256').update(bytes).digest('hex'), absolutePath: pkg, required: true, order: 1, adapterHint: 'ps5-native-debug-package', provenance: [] }], dependencies: [], recipe: { installOrder: [{ componentId: 'base', order: 1 }] } };
|
||||
const result = registerPs5NativePackageManifest({ db: database, outputRoot, manifest });
|
||||
assert.ok(result.packageSetId);
|
||||
assert.equal(database.runQuery('SELECT COUNT(*) AS count FROM local_copies')[0].count, 1);
|
||||
assert.equal(database.runQuery('SELECT COUNT(*) AS count FROM console_installs')[0].count, 0);
|
||||
});
|
||||
|
||||
test('rolls back title and package registration atomically when a component is invalid', async t => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'chimera-ps5-registration-rollback-'));
|
||||
database.configure({ dataDir: path.join(root, 'db'), logger: { info() {}, warn() {}, error() {} } }); await database.initialize();
|
||||
t.after(() => { database.close(); fs.rmSync(root, { recursive: true, force: true }); });
|
||||
const outputRoot = path.join(root, 'vault'); const folder = path.join(outputRoot, 'PPSA12345', 'job'); fs.mkdirSync(folder, { recursive: true });
|
||||
const pkg = path.join(folder, 'invalid.pkg'); writeFih(pkg); const size = fs.statSync(pkg).size;
|
||||
const beforeGames = database.runQuery('SELECT COUNT(*) AS count FROM games')[0].count;
|
||||
const beforeTitles = database.runQuery('SELECT COUNT(*) AS count FROM titles')[0].count;
|
||||
const manifest = { schemaVersion: 1, kind: 'chimera.ps5-native-package.v1', createdAt: 100, title: { platform: 'ps5', titleId: 'PPSA12345', name: 'Rollback Test' }, packageFormat: { container: 'FIH', debug: true, retail: false }, components: [{ id: 'base', type: 'base', version: '01.00', contentId: 'UP0001-PPSA12345_00-CHIMERATEST00001', size, sha256: 'not-a-hash', absolutePath: pkg, required: true, order: 1 }], dependencies: [], recipe: { installOrder: [{ componentId: 'base', order: 1 }] } };
|
||||
assert.throws(() => registerPs5NativePackageManifest({ db: database, outputRoot, manifest }), error => error.code === 'PS5_PACKAGE_MANIFEST');
|
||||
assert.equal(database.runQuery('SELECT COUNT(*) AS count FROM games')[0].count, beforeGames);
|
||||
assert.equal(database.runQuery('SELECT COUNT(*) AS count FROM titles')[0].count, beforeTitles);
|
||||
assert.equal(database.runQuery('SELECT COUNT(*) AS count FROM package_sets')[0].count, 0);
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
'use strict';
|
||||
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const test = require('node:test');
|
||||
const database = require('../../src/database');
|
||||
const { createSqliteTrophyProgressionPlanRepository } = require('../../src/services/trophy-progression-planner-service');
|
||||
|
||||
test('production database stores immutable synthetic progression plans', async t => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'chimera-progression-'));
|
||||
t.after(() => { database.close(); fs.rmSync(root, { recursive: true, force: true }); });
|
||||
database.configure({ dataDir: root, logger: { info() {}, warn() {}, error() {} } });
|
||||
await database.initialize();
|
||||
const repository = createSqliteTrophyProgressionPlanRepository({ db: database });
|
||||
const plan = {
|
||||
id: `trophy-progression-${'a'.repeat(64)}`, planSha256: 'a'.repeat(64), schemaVersion: 1,
|
||||
mode: 'synthetic_unlock', synthetic: true, catalog: { key: 'np:11111111111111111111111111111111', revision: 'one', sha256: 'b'.repeat(64), title: 'Fixture' },
|
||||
createdAt: 1_800_000_000_000, items: [{ trophyId: 1, proposedEarnedDateTime: '2026-01-01T00:00:00.000Z' }],
|
||||
};
|
||||
const saved = repository.save(plan);
|
||||
assert.equal(saved.id, plan.id);
|
||||
assert.equal(repository.get(plan.id).catalog.title, 'Fixture');
|
||||
assert.equal(repository.list({ limit: 10 }).length, 1);
|
||||
assert.throws(() => database.runMutatingSqlWithParams('UPDATE trophy_progression_plans SET mode=@mode', { mode: 'timestamp_edit' }), /immutable/);
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
'use strict';
|
||||
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const test = require('node:test');
|
||||
const { createImportedTrophyDefinitionService } = require('../../src/services/imported-trophy-definition-service');
|
||||
const { createMemoryTrophyProgressionPlanRepository, createTrophyProgressionPlanner } = require('../../src/services/trophy-progression-planner-service');
|
||||
|
||||
const root = path.resolve(__dirname, '../..');
|
||||
const read = file => fs.readFileSync(path.join(root, file), 'utf8');
|
||||
|
||||
test('desktop and web expose planning commands but no live progression apply command', () => {
|
||||
const main = read('main.js'); const preload = read('preload.js'); const web = read('renderer/pork-web-shim.js');
|
||||
const server = read('src/server/register-functional-modules.js');
|
||||
assert.match(main, /_registerTrophyProgression\(ipcMain, \{ app, db \}\)/);
|
||||
assert.match(read('src/ipc/trophy-progression.js'), /trophy:imported-catalog:import/);
|
||||
for (const channel of ['status', 'catalogs', 'create', 'plans', 'plan']) {
|
||||
assert.match(preload, new RegExp(`trophy:progression:${channel}`));
|
||||
assert.match(web, new RegExp(`trophy:progression:${channel}`));
|
||||
assert.match(server, new RegExp(`trophy:progression:${channel}`));
|
||||
}
|
||||
assert.doesNotMatch(preload, /trophy:progression:apply/);
|
||||
assert.doesNotMatch(web, /trophy:progression:apply/);
|
||||
assert.doesNotMatch(server, /name: 'trophy:progression:apply'/);
|
||||
});
|
||||
|
||||
test('progression planning persists locally and remains separated from PSN providers', () => {
|
||||
const ipc = read('src/ipc/trophy-progression.js');
|
||||
assert.match(ipc, /createImportedTrophyDefinitionService/);
|
||||
assert.match(ipc, /createSqliteTrophyProgressionPlanRepository/);
|
||||
assert.doesNotMatch(ipc, /psn-api|node-fetch|https?:|trophyHistory/);
|
||||
const service = read('src/services/trophy-progression-planner-service.js');
|
||||
assert.match(service, /psnWriteCapability: false/);
|
||||
assert.match(service, /liveApply: \{ available: false/);
|
||||
});
|
||||
|
||||
test('a local definition package can produce a progression preview without PSN import', async t => {
|
||||
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'chimera-progression-catalog-'));
|
||||
t.after(() => fs.rmSync(directory, { recursive: true, force: true }));
|
||||
const catalogs = createImportedTrophyDefinitionService({ root: directory });
|
||||
const catalogKey = 'np:22222222222222222222222222222222';
|
||||
catalogs.importPackage({ schema: 'chimera-trophy-definition', schemaVersion: 1, catalogKey, revision: 'fixture',
|
||||
game: { title: 'Offline Fixture', description: '', trophyCount: 2 }, trophies: [
|
||||
{ id: 0, grade: 'bronze', groupId: 0, hidden: false, name: 'Start', description: 'Begin the story.' },
|
||||
{ id: 1, grade: 'platinum', groupId: 0, hidden: false, name: 'Done', description: 'Earn all trophies.' },
|
||||
] });
|
||||
const planner = createTrophyProgressionPlanner({ repository: createMemoryTrophyProgressionPlanRepository(), catalogProvider: catalogs, now: () => Date.parse('2026-08-11T12:00:00Z') });
|
||||
const plan = await planner.create({ catalogKey, mode: 'synthetic_unlock', trophyIds: [0, 1], strategy: 'balanced', minimumIntervalMinutes: 30,
|
||||
startDateTime: '2021-01-01T00:00:00Z', endDateTime: '2021-01-03T00:00:00Z' });
|
||||
assert.equal(plan.provenance.psnImportRequired, false);
|
||||
assert.deepEqual(plan.items.map(item => item.trophyId), [0, 1]);
|
||||
});
|
||||
@@ -0,0 +1,99 @@
|
||||
'use strict';
|
||||
|
||||
const assert = require('node:assert/strict');
|
||||
const test = require('node:test');
|
||||
const {
|
||||
createMemoryTrophyProgressionPlanRepository,
|
||||
createTrophyProgressionPlanner,
|
||||
} = require('../../src/services/trophy-progression-planner-service');
|
||||
|
||||
const NOW = Date.parse('2026-08-11T12:00:00.000Z');
|
||||
const catalog = Object.freeze({
|
||||
immutable: true,
|
||||
catalogKey: 'np:11111111111111111111111111111111',
|
||||
revision: 'fixture-1',
|
||||
canonicalSha256: 'a'.repeat(64),
|
||||
game: { title: 'Progression Fixture', releaseDate: '2020-01-10T00:00:00.000Z' },
|
||||
trophies: [
|
||||
{ id: 0, groupId: 0, grade: 'bronze', name: 'First Steps', description: 'Complete the tutorial.', earned: false, earnedDateTime: null },
|
||||
{ id: 1, groupId: 0, grade: 'silver', name: 'Chapter Complete', description: 'Complete chapter five.', earned: false, earnedDateTime: null },
|
||||
{ id: 2, groupId: 0, grade: 'gold', name: 'Collector', description: 'Collect all relics.', earned: false, earnedDateTime: null },
|
||||
{ id: 3, groupId: 0, grade: 'platinum', name: 'Platinum', description: 'Earn every trophy.', earned: false, earnedDateTime: null },
|
||||
],
|
||||
});
|
||||
|
||||
function planner(value = catalog) {
|
||||
return createTrophyProgressionPlanner({
|
||||
repository: createMemoryTrophyProgressionPlanRepository(),
|
||||
catalogProvider: { resolve: async input => input.catalogKey === value.catalogKey ? value : null },
|
||||
now: () => NOW,
|
||||
});
|
||||
}
|
||||
|
||||
const baseInput = {
|
||||
catalogKey: catalog.catalogKey,
|
||||
mode: 'synthetic_unlock',
|
||||
trophyIds: [3, 2, 0, 1],
|
||||
startDateTime: '2021-02-01T18:00:00.000Z',
|
||||
endDateTime: '2021-02-08T22:00:00.000Z',
|
||||
minimumIntervalMinutes: 30,
|
||||
strategy: 'balanced',
|
||||
};
|
||||
|
||||
test('creates a deterministic import-optional synthetic timeline with platinum last', async () => {
|
||||
const service = planner();
|
||||
const first = await service.create(baseInput);
|
||||
const second = await service.create(baseInput);
|
||||
assert.equal(first.id, second.id);
|
||||
assert.equal(first.provenance.psnImportRequired, false);
|
||||
assert.equal(first.synthetic, true);
|
||||
assert.equal(first.liveApply.available, false);
|
||||
assert.deepEqual(first.items.map(item => item.trophyId), [0, 1, 2, 3]);
|
||||
assert.equal(first.items.at(-1).grade, 'platinum');
|
||||
assert.ok(first.items.every(item => item.timestampKind === 'synthetic-estimate'));
|
||||
const times = first.items.map(item => Date.parse(item.proposedEarnedDateTime));
|
||||
assert.ok(times.every((time, index) => index === 0 || time > times[index - 1]));
|
||||
assert.ok(times[0] >= Date.parse(baseInput.startDateTime));
|
||||
assert.ok(times.at(-1) <= Date.parse(baseInput.endDateTime));
|
||||
assert.ok(times.at(-1) <= NOW);
|
||||
});
|
||||
|
||||
test('rejects arbitrary trophy IDs, future windows and pre-release history', async () => {
|
||||
const service = planner();
|
||||
await assert.rejects(service.create({ ...baseInput, trophyIds: [99] }), error => error.code === 'TROPHY_PROGRESSION_SELECTION');
|
||||
await assert.rejects(service.create({ ...baseInput, endDateTime: '2026-08-12T00:00:00.000Z' }), error => error.code === 'TROPHY_PROGRESSION_FUTURE');
|
||||
await assert.rejects(service.create({ ...baseInput, startDateTime: '2019-01-01T00:00:00.000Z' }), error => error.code === 'TROPHY_PROGRESSION_RELEASE');
|
||||
});
|
||||
|
||||
test('timestamp edit mode freezes original earned values and changes only explicit selections', async () => {
|
||||
const earnedCatalog = Object.freeze({ ...catalog, canonicalSha256: 'b'.repeat(64), trophies: catalog.trophies.map((item, index) => Object.freeze({
|
||||
...item, earned: index < 3, earnedDateTime: index < 3 ? `2021-02-0${index + 2}T20:00:00.000Z` : null,
|
||||
})) });
|
||||
const service = planner(earnedCatalog);
|
||||
const plan = await service.create({ ...baseInput, mode: 'timestamp_edit', trophyIds: [1], endDateTime: '2021-02-06T22:00:00.000Z' });
|
||||
assert.equal(plan.items.length, 1);
|
||||
assert.equal(plan.items[0].trophyId, 1);
|
||||
assert.equal(plan.items[0].originalEarnedDateTime, '2021-02-03T20:00:00.000Z');
|
||||
assert.notEqual(plan.items[0].proposedEarnedDateTime, plan.items[0].originalEarnedDateTime);
|
||||
assert.equal(earnedCatalog.trophies[1].earnedDateTime, '2021-02-03T20:00:00.000Z');
|
||||
assert.match(plan.confirmationPhrase, /^EDIT LOCAL TROPHY TIMES 1 /);
|
||||
});
|
||||
|
||||
test('keeps unselected earned trophies as hard anchors and rejects contradictory anchors', async () => {
|
||||
const anchored = { ...catalog, canonicalSha256: 'c'.repeat(64), trophies: catalog.trophies.map(item => ({ ...item })) };
|
||||
anchored.trophies[1].earned = true;
|
||||
anchored.trophies[1].earnedDateTime = '2021-02-04T20:00:00.000Z';
|
||||
const plan = await planner(anchored).create({ ...baseInput, trophyIds: [0, 2, 3] });
|
||||
const byId = new Map(plan.items.map(item => [item.trophyId, Date.parse(item.proposedEarnedDateTime)]));
|
||||
assert.ok(byId.get(0) < Date.parse(anchored.trophies[1].earnedDateTime));
|
||||
assert.ok(byId.get(2) > Date.parse(anchored.trophies[1].earnedDateTime));
|
||||
|
||||
anchored.canonicalSha256 = 'd'.repeat(64);
|
||||
anchored.trophies[0].earned = true;
|
||||
anchored.trophies[0].earnedDateTime = '2021-02-05T20:00:00.000Z';
|
||||
await assert.rejects(planner(anchored).create({ ...baseInput, trophyIds: [2, 3] }), error => error.code === 'TROPHY_PROGRESSION_ANCHORS');
|
||||
});
|
||||
|
||||
test('refuses timestamp edits without an exact original local timestamp', async () => {
|
||||
await assert.rejects(planner().create({ ...baseInput, mode: 'timestamp_edit', trophyIds: [0] }), error => error.code === 'TROPHY_PROGRESSION_ORIGINAL');
|
||||
});
|
||||
@@ -0,0 +1,207 @@
|
||||
'use strict';
|
||||
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const test = require('node:test');
|
||||
const {
|
||||
createPs5ConsoleFtpProvider,
|
||||
createPs5ConsoleStagingService,
|
||||
} = require('../../src/services/ps5-console-staging-service');
|
||||
|
||||
function fixture(t, overrides = {}) {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'chimera-ps5-stage-'));
|
||||
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
const files = new Map([
|
||||
['/mnt/sandbox/pfsmnt/PPSA12345-app0/sce_sys/param.json', Buffer.from('{"titleId":"PPSA12345"}')],
|
||||
['/mnt/sandbox/pfsmnt/PPSA12345-app0/eboot.bin', Buffer.from([0x7f, 0x45, 0x4c, 0x46, 1])],
|
||||
['/mnt/sandbox/pfsmnt/PPSA12345-patch0/sce_sys/param.json', Buffer.from('{"titleId":"PPSA12345"}')],
|
||||
['/mnt/addcont/PPSA12345/UP0001-PPSA12345_00-DLC0000000000001/sce_sys/param.json', Buffer.from('{"titleId":"PPSA12345"}')],
|
||||
]);
|
||||
const directories = new Map([
|
||||
['/mnt/sandbox/pfsmnt/', [
|
||||
{ name: 'PPSA12345-app0', isDir: true },
|
||||
{ name: 'PPSA12345-patch0', isDir: true },
|
||||
{ name: 'PPSA12345-app0-patch0-union', isDir: true },
|
||||
{ name: 'PPSA99999-app0', isDir: true },
|
||||
]],
|
||||
['/mnt/sandbox/pfsmnt/PPSA12345-app0', [{ name: 'sce_sys', isDir: true }, { name: 'eboot.bin', isDir: false, size: 5 }]],
|
||||
['/mnt/sandbox/pfsmnt/PPSA12345-app0/sce_sys', [{ name: 'param.json', isDir: false, size: files.get('/mnt/sandbox/pfsmnt/PPSA12345-app0/sce_sys/param.json').length }]],
|
||||
['/mnt/sandbox/pfsmnt/PPSA12345-patch0', [{ name: 'sce_sys', isDir: true }]],
|
||||
['/mnt/sandbox/pfsmnt/PPSA12345-patch0/sce_sys', [{ name: 'param.json', isDir: false, size: files.get('/mnt/sandbox/pfsmnt/PPSA12345-patch0/sce_sys/param.json').length }]],
|
||||
['/mnt/addcont/PPSA12345/UP0001-PPSA12345_00-DLC0000000000001', [{ name: 'sce_sys', isDir: true }]],
|
||||
['/mnt/addcont/PPSA12345/UP0001-PPSA12345_00-DLC0000000000001/sce_sys', [{ name: 'param.json', isDir: false, size: files.get('/mnt/addcont/PPSA12345/UP0001-PPSA12345_00-DLC0000000000001/sce_sys/param.json').length }]],
|
||||
]);
|
||||
const starts = [];
|
||||
let failOnce = overrides.failOnce === true;
|
||||
const provider = {
|
||||
probe: async () => ({ available: true, readOnly: true, recursiveInventory: true, rangeDownload: true, selfDecryption: 'server-default-unverified' }),
|
||||
list: async remotePath => {
|
||||
if (overrides.list) return overrides.list(remotePath, directories);
|
||||
if (!directories.has(remotePath)) throw new Error(`missing directory ${remotePath}`);
|
||||
return directories.get(remotePath);
|
||||
},
|
||||
listSupplementalComponents: async titleId => titleId === 'PPSA12345' ? [{ type: 'dlc', remotePath: '/mnt/addcont/PPSA12345/UP0001-PPSA12345_00-DLC0000000000001', contentId: 'UP0001-PPSA12345_00-DLC0000000000001' }] : [],
|
||||
download: async ({ remotePath, localPath, startAt = 0 }) => {
|
||||
starts.push({ remotePath, startAt });
|
||||
const bytes = files.get(remotePath);
|
||||
if (!bytes) throw new Error(`missing file ${remotePath}`);
|
||||
fs.mkdirSync(path.dirname(localPath), { recursive: true });
|
||||
if (failOnce && remotePath.endsWith('/eboot.bin')) {
|
||||
failOnce = false;
|
||||
fs.appendFileSync(localPath, bytes.subarray(startAt, startAt + 2));
|
||||
throw new Error('connection dropped');
|
||||
}
|
||||
fs.appendFileSync(localPath, bytes.subarray(startAt));
|
||||
},
|
||||
};
|
||||
const service = createPs5ConsoleStagingService({
|
||||
stagingRoot: path.join(root, 'staging'),
|
||||
stateFile: path.join(root, 'state', 'jobs.json'),
|
||||
provider,
|
||||
idFactory: () => 'stage-job-1',
|
||||
now: () => 1_800_000_000_000,
|
||||
});
|
||||
return { root, files, directories, provider, service, starts };
|
||||
}
|
||||
|
||||
test('discovers only title-bound base/patch roots and trusted DLC descriptors', async t => {
|
||||
const f = fixture(t);
|
||||
const discovery = await f.service.discover({ titleId: 'PPSA12345' });
|
||||
assert.deepEqual(discovery.components.map(item => item.type), ['base', 'update', 'dlc']);
|
||||
assert.equal(discovery.components.some(item => item.remotePath.includes('union')), false);
|
||||
assert.equal(discovery.components.some(item => item.remotePath.includes('PPSA99999')), false);
|
||||
await assert.rejects(() => f.service.discover({ titleId: '../PPSA12345' }), error => error.code === 'PS5_STAGE_TITLE_ID');
|
||||
});
|
||||
|
||||
test('discovers an exact active title sandbox when legacy pfsmnt roots are empty', async t => {
|
||||
const f = fixture(t, {
|
||||
list: async (remotePath, directories) => {
|
||||
if (remotePath === '/mnt/sandbox/pfsmnt/') return [];
|
||||
if (remotePath === '/mnt/sandbox/') return [
|
||||
{ name: 'PPSA12345_000', isDir: true },
|
||||
{ name: 'PPSA12345_000-copy', isDir: true },
|
||||
{ name: 'PPSA99999_000', isDir: true },
|
||||
];
|
||||
if (remotePath === '/mnt/sandbox/PPSA12345_000') return [
|
||||
{ name: 'app0', isDir: true },
|
||||
{ name: 'app0-copy', isDir: true },
|
||||
{ name: 'temp0', isDir: true },
|
||||
];
|
||||
if (remotePath === '/mnt/sandbox/PPSA12345_000/app0') return [
|
||||
{ name: 'sce_sys', isDir: true },
|
||||
{ name: 'eboot.bin', isDir: false, size: 5 },
|
||||
];
|
||||
if (remotePath === '/mnt/sandbox/PPSA12345_000/app0/sce_sys') return [
|
||||
{ name: 'param.json', isDir: false, size: f.files.get('/mnt/sandbox/pfsmnt/PPSA12345-app0/sce_sys/param.json').length },
|
||||
];
|
||||
return directories.get(remotePath);
|
||||
},
|
||||
});
|
||||
f.files.set('/mnt/sandbox/PPSA12345_000/app0/eboot.bin', f.files.get('/mnt/sandbox/pfsmnt/PPSA12345-app0/eboot.bin'));
|
||||
f.files.set('/mnt/sandbox/PPSA12345_000/app0/sce_sys/param.json', f.files.get('/mnt/sandbox/pfsmnt/PPSA12345-app0/sce_sys/param.json'));
|
||||
const discovery = await f.service.discover({ titleId: 'PPSA12345' });
|
||||
const base = discovery.components.find(item => item.type === 'base');
|
||||
assert.equal(base.remotePath, '/mnt/sandbox/PPSA12345_000/app0');
|
||||
assert.equal(base.sourceKind, 'active-sandbox-app0');
|
||||
assert.equal(base.sourceSemantics, 'active-mounted-title-view');
|
||||
assert.equal(discovery.components.some(item => item.remotePath.includes('copy')), false);
|
||||
|
||||
const completed = await f.service.run(f.service.create({ discoveryId: discovery.id, componentIds: ['base'] }).id);
|
||||
assert.equal(completed.manifest.components[0].source.semantics, 'active-mounted-title-view');
|
||||
});
|
||||
|
||||
test('an incomplete host FTP binding keeps staging unavailable without crashing registry startup', async () => {
|
||||
const provider = createPs5ConsoleFtpProvider({ isConnected: () => true });
|
||||
const probe = await provider.probe();
|
||||
assert.equal(probe.available, false);
|
||||
assert.equal(probe.recursiveInventory, false);
|
||||
await assert.rejects(() => provider.list('/mnt/sandbox/pfsmnt/'), error => error.code === 'PS5_STAGE_PROVIDER_UNAVAILABLE');
|
||||
});
|
||||
|
||||
test('rejects hostile names, case collisions and unbounded inventories', async t => {
|
||||
const hostile = fixture(t, { list: async (remotePath, directories) => remotePath.endsWith('-app0') ? [{ name: '..', isDir: false, size: 1 }] : directories.get(remotePath) });
|
||||
const discovery = await hostile.service.discover({ titleId: 'PPSA12345' });
|
||||
const created = hostile.service.create({ discoveryId: discovery.id, componentIds: ['base'] });
|
||||
await assert.rejects(() => hostile.service.run(created.id), error => error.code === 'PS5_STAGE_REMOTE_NAME');
|
||||
|
||||
const collision = fixture(t, { list: async (remotePath, directories) => remotePath.endsWith('-app0') ? [{ name: 'Data.bin', isDir: false, size: 1 }, { name: 'data.bin', isDir: false, size: 1 }] : directories.get(remotePath) });
|
||||
const second = await collision.service.discover({ titleId: 'PPSA12345' });
|
||||
await assert.rejects(() => collision.service.run(collision.service.create({ discoveryId: second.id, componentIds: ['base'] }).id), error => error.code === 'PS5_STAGE_CASE_COLLISION');
|
||||
|
||||
const bounded = fixture(t, { list: async (remotePath, directories) => remotePath.endsWith('-app0') ? [{ name: 'large.bin', isDir: false, size: 20 }] : directories.get(remotePath) });
|
||||
const third = await bounded.service.discover({ titleId: 'PPSA12345' });
|
||||
const limited = createPs5ConsoleStagingService({ stagingRoot: path.join(bounded.root, 'limited'), stateFile: path.join(bounded.root, 'limited.json'), provider: bounded.provider, limits: { maxBytes: 10 }, idFactory: () => 'limited' });
|
||||
const limitedDiscovery = await limited.discover({ titleId: 'PPSA12345' });
|
||||
await assert.rejects(() => limited.run(limited.create({ discoveryId: limitedDiscovery.id, componentIds: ['base'] }).id), error => error.code === 'PS5_STAGE_LIMIT');
|
||||
assert.ok(third.id);
|
||||
});
|
||||
|
||||
test('keeps interrupted data partial, resumes by range and publishes only after hashes match', async t => {
|
||||
const f = fixture(t, { failOnce: true });
|
||||
const discovery = await f.service.discover({ titleId: 'PPSA12345' });
|
||||
const created = f.service.create({ discoveryId: discovery.id, componentIds: ['base'] });
|
||||
await assert.rejects(() => f.service.run(created.id), /connection dropped/);
|
||||
assert.equal(f.service.get(created.id).status, 'interrupted');
|
||||
assert.equal(fs.existsSync(path.join(f.root, 'staging', 'PPSA12345', created.id, 'staged')), false);
|
||||
f.service.resume(created.id);
|
||||
const completed = await f.service.run(created.id);
|
||||
assert.equal(completed.status, 'staged');
|
||||
assert.equal(completed.manifest.components[0].files.length, 2);
|
||||
assert.ok(completed.manifest.components[0].files.every(file => /^[a-f0-9]{64}$/.test(file.sha256)));
|
||||
assert.ok(f.starts.some(item => item.remotePath.endsWith('/eboot.bin') && item.startAt === 2));
|
||||
assert.equal(fs.existsSync(completed.stagedPath), true);
|
||||
});
|
||||
|
||||
test('fails closed on exact-size mismatch and never publishes incomplete output', async t => {
|
||||
const f = fixture(t);
|
||||
f.provider.download = async ({ localPath }) => { fs.mkdirSync(path.dirname(localPath), { recursive: true }); fs.appendFileSync(localPath, 'x'); };
|
||||
const discovery = await f.service.discover({ titleId: 'PPSA12345' });
|
||||
const created = f.service.create({ discoveryId: discovery.id, componentIds: ['base'] });
|
||||
await assert.rejects(() => f.service.run(created.id), error => error.code === 'PS5_STAGE_SIZE');
|
||||
assert.equal(f.service.get(created.id).status, 'failed');
|
||||
assert.equal(fs.existsSync(path.join(f.root, 'staging', 'PPSA12345', created.id, 'staged')), false);
|
||||
});
|
||||
|
||||
test('keeps component order deterministic and blocks unsupported update package builds', async t => {
|
||||
const f = fixture(t);
|
||||
const discovery = await f.service.discover({ titleId: 'PPSA12345' });
|
||||
const created = f.service.create({ discoveryId: discovery.id, componentIds: discovery.components.map(item => item.id) });
|
||||
const completed = await f.service.run(created.id);
|
||||
assert.deepEqual(completed.manifest.components.map(item => item.type), ['base', 'update', 'dlc']);
|
||||
assert.deepEqual(completed.manifest.recipe.installOrder.map(item => item.order), [1, 2, 3]);
|
||||
assert.equal(completed.manifest.components.find(item => item.type === 'update').packageBuild.supported, false);
|
||||
assert.equal(completed.manifest.components.find(item => item.type === 'base').packageBuild.supported, true);
|
||||
assert.equal(completed.manifest.components.find(item => item.type === 'dlc').packageBuild.supported, true);
|
||||
assert.equal(completed.manifest.components.find(item => item.type === 'dlc').packageBuild.mode, 'data-only');
|
||||
});
|
||||
|
||||
test('exposes the staging contract across server, Electron, web and translated premium UI', () => {
|
||||
const root = path.join(__dirname, '..', '..');
|
||||
const read = relative => fs.readFileSync(path.join(root, relative), 'utf8');
|
||||
for (const command of ['probe', 'discover', 'create', 'list', 'get', 'run', 'resume']) {
|
||||
assert.match(read('src/server/create-registry.js'), new RegExp(`ps5-console-staging:${command}`));
|
||||
assert.match(read('main.js'), new RegExp(`ps5-console-staging:${command}`));
|
||||
}
|
||||
for (const bridge of ['ps5ConsoleStagingDiscover', 'ps5ConsoleStagingCreate', 'ps5ConsoleStagingRun', 'ps5ConsoleStagingResume']) {
|
||||
assert.match(read('preload.js'), new RegExp(bridge));
|
||||
assert.match(read('renderer/pork-web-shim.js'), new RegExp(bridge));
|
||||
}
|
||||
const html = read('renderer/index.html');
|
||||
const css = read('renderer/styles/ps5-native-packages.css');
|
||||
const modal = read('renderer/modules/modal.js');
|
||||
assert.match(html, /ps5-staging-illustration/);
|
||||
assert.match(html, /ps5-staging-components/);
|
||||
assert.match(css, /prefers-reduced-motion/);
|
||||
assert.match(modal, /ps5ConsoleStagingDiscover/);
|
||||
assert.match(modal, /data-ps5-stage-resume/);
|
||||
assert.match(modal, /ps5SourcesFromStagedJob/);
|
||||
assert.match(modal, /packageBuild\?\.supported/);
|
||||
const en = JSON.parse(read('build/lang/en.json')).strings;
|
||||
const nl = JSON.parse(read('build/lang/nl.json')).strings;
|
||||
for (const key of ['ps5NativePackages.stage.title', 'ps5NativePackages.stage.limit', 'ps5NativePackages.stage.completed']) {
|
||||
assert.equal(typeof en[key], 'string');
|
||||
assert.equal(typeof nl[key], 'string');
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,160 @@
|
||||
'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 xavatar = require('../../src/xavatarElectronModule');
|
||||
const {
|
||||
PINNED_PS5_PROFILE_PROVIDER,
|
||||
createPs5UploadProfileProvider,
|
||||
} = require('../../src/services/ps5upload-profile-provider');
|
||||
const { createPs5ProfileManagementService } = require('../../src/services/ps5-profile-management-service');
|
||||
|
||||
const png = label => Buffer.concat([Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), Buffer.from(label)]);
|
||||
const digest = value => crypto.createHash('sha256').update(value).digest('hex');
|
||||
|
||||
function fixture(t, overrides = {}) {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'chimera-ps5-profile-'));
|
||||
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
let username = 'User1';
|
||||
let avatar = png('before');
|
||||
let generation = 1;
|
||||
const calls = [];
|
||||
const provider = overrides.provider || {
|
||||
probe: async () => ({ available: true, ...PINNED_PS5_PROFILE_PROVIDER, capabilities: { profileInventory: true, localUsername: true, homeScreenNameSync: true, avatarRead: true, avatarApply: true, avatarClear: false } }),
|
||||
listUsers: async () => ({ observedAt: 1_900_000_000_000 + generation, foregroundUid: 0x10000001, users: [{ uid: 0x10000001, uidHex: '0x10000001', username, accountId: 'must-not-escape', homePath: '/user/home/10000001' }] }),
|
||||
currentAvatar: async ({ uid }) => ({ uid, bytes: avatar ? Buffer.from(avatar) : null, sha256: avatar ? digest(avatar) : null }),
|
||||
renameUser: async input => { calls.push({ op: 'rename', ...input }); username = input.name; generation += 1; return { ok: true, homeScreenNameSynced: true }; },
|
||||
applyAvatar: async input => { calls.push({ op: 'avatar', uid: input.uid, bytes: Buffer.from(input.imageBytes) }); avatar = Buffer.from(input.imageBytes); generation += 1; return { ok: true, filesCopied: 11, currentAvatarSha256: digest(avatar) }; },
|
||||
};
|
||||
const service = createPs5ProfileManagementService({
|
||||
provider,
|
||||
stateFile: path.join(root, 'state.json'),
|
||||
recoveryRoot: path.join(root, 'recovery'),
|
||||
idFactory: (() => { let n = 0; return () => `profile-op-${++n}`; })(),
|
||||
now: () => 1_900_000_000_000 + generation,
|
||||
});
|
||||
return { calls, provider, root, service, setAvatar(value) { avatar = value; }, setUsername(value) { username = value; generation += 1; } };
|
||||
}
|
||||
|
||||
async function avatarArchive() {
|
||||
const rgba = Buffer.alloc(32 * 32 * 4, 0xff);
|
||||
return (await xavatar.convertFromRGBA(rgba, 32, 32, { filename: 'profile' })).buffer;
|
||||
}
|
||||
|
||||
test('inventory exposes only stable local-user identity and current avatar evidence', async t => {
|
||||
const f = fixture(t);
|
||||
const result = await f.service.inspect();
|
||||
assert.equal(result.available, true);
|
||||
assert.deepEqual(result.users[0], { uid: 0x10000001, uidHex: '0x10000001', username: 'User1', foreground: true, avatar: { available: true, sha256: digest(png('before')) } });
|
||||
assert.equal(Object.hasOwn(result.users[0], 'accountId'), false);
|
||||
assert.equal(JSON.stringify(result).includes('/user/home'), false);
|
||||
assert.equal(result.semantics.accountIdentityMutable, false);
|
||||
});
|
||||
|
||||
test('rename planning validates uid and PS5 16-character local display names', async t => {
|
||||
const f = fixture(t);
|
||||
await assert.rejects(() => f.service.prepareRename({ uid: 7, name: 'New name' }), error => error.code === 'PS5_PROFILE_USER_UNKNOWN');
|
||||
for (const name of ['', ' '.repeat(3), '12345678901234567', 'bad\nname']) {
|
||||
await assert.rejects(() => f.service.prepareRename({ uid: 0x10000001, name }), error => error.code === 'PS5_PROFILE_NAME');
|
||||
}
|
||||
const plan = await f.service.prepareRename({ uid: 0x10000001, name: 'Chimera' });
|
||||
assert.equal(plan.status, 'prepared');
|
||||
assert.equal(plan.confirmation, 'RENAME PS5 USER 0x10000001');
|
||||
assert.equal(plan.snapshot.username, 'User1');
|
||||
assert.equal(Object.hasOwn(plan, 'accountId'), false);
|
||||
});
|
||||
|
||||
test('stale user evidence blocks rename before a provider mutation', async t => {
|
||||
const f = fixture(t);
|
||||
const plan = await f.service.prepareRename({ uid: 0x10000001, name: 'Chimera' });
|
||||
f.setUsername('ChangedElsewhere');
|
||||
await assert.rejects(() => f.service.apply({ id: plan.id, confirmation: plan.confirmation }), error => error.code === 'PS5_PROFILE_STALE');
|
||||
assert.equal(f.calls.length, 0);
|
||||
});
|
||||
|
||||
test('rename requires exact confirmation, verifies post-read and can roll back', async t => {
|
||||
const f = fixture(t);
|
||||
const plan = await f.service.prepareRename({ uid: 0x10000001, name: 'Chimera' });
|
||||
await assert.rejects(() => f.service.apply({ id: plan.id, confirmation: 'RENAME' }), error => error.code === 'PS5_PROFILE_CONFIRMATION');
|
||||
const applied = await f.service.apply({ id: plan.id, confirmation: plan.confirmation });
|
||||
assert.equal(applied.status, 'verified');
|
||||
assert.equal(applied.postRead.username, 'Chimera');
|
||||
assert.equal(applied.rebootRequired, true);
|
||||
assert.deepEqual(Object.keys(f.calls[0]).sort(), ['name', 'op', 'uid']);
|
||||
const rolledBack = await f.service.rollback({ id: plan.id, confirmation: 'ROLLBACK PS5 PROFILE 0x10000001' });
|
||||
assert.equal(rolledBack.status, 'rolled_back');
|
||||
assert.equal(rolledBack.postRead.username, 'User1');
|
||||
});
|
||||
|
||||
test('avatar planning accepts only a bounded canonical xavatar archive and requires recovery', async t => {
|
||||
const f = fixture(t); const archive = await avatarArchive();
|
||||
const plan = await f.service.prepareAvatar({ uid: 0x10000001, archive });
|
||||
assert.equal(plan.status, 'prepared');
|
||||
assert.equal(plan.confirmation, 'APPLY PS5 AVATAR 0x10000001');
|
||||
assert.match(plan.desired.avatarSha256, /^[a-f0-9]{64}$/);
|
||||
assert.ok(fs.existsSync(plan.snapshot.avatarBackupPath));
|
||||
await assert.rejects(() => f.service.prepareAvatar({ uid: 0x10000001, archive: Buffer.alloc(10) }), error => error.code === 'PS5_PROFILE_AVATAR_ARCHIVE');
|
||||
|
||||
const recoveryFilesBeforeFailure = fs.readdirSync(path.join(f.root, 'recovery')).sort();
|
||||
f.setAvatar(null);
|
||||
await assert.rejects(() => f.service.prepareAvatar({ uid: 0x10000001, archive }), error => error.code === 'PS5_PROFILE_RECOVERY_UNAVAILABLE');
|
||||
assert.deepEqual(fs.readdirSync(path.join(f.root, 'recovery')).sort(), recoveryFilesBeforeFailure);
|
||||
});
|
||||
|
||||
test('avatar apply verifies provider evidence and rollback restores the previous image', async t => {
|
||||
const f = fixture(t); const before = png('before'); const plan = await f.service.prepareAvatar({ uid: 0x10000001, archive: await avatarArchive() });
|
||||
const applied = await f.service.apply({ id: plan.id, confirmation: plan.confirmation });
|
||||
assert.equal(applied.status, 'verified');
|
||||
assert.notEqual(applied.postRead.avatar.sha256, digest(before));
|
||||
assert.equal(f.calls[0].op, 'avatar');
|
||||
const rolledBack = await f.service.rollback({ id: plan.id, confirmation: 'ROLLBACK PS5 PROFILE 0x10000001' });
|
||||
assert.equal(rolledBack.status, 'rolled_back');
|
||||
assert.equal(rolledBack.postRead.avatar.sha256, digest(before));
|
||||
});
|
||||
|
||||
test('pinned PS5Upload provider is loopback-only and calls only profile routes', async t => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'chimera-profile-provider-')); t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
const calls = [];
|
||||
const fetch = async (url, options = {}) => {
|
||||
calls.push({ url: String(url), options });
|
||||
if (String(url).endsWith('/api/version')) return { ok: true, json: async () => ({ version: PINNED_PS5_PROFILE_PROVIDER.version }) };
|
||||
if (String(url).includes('/api/profile/info')) return { ok: true, json: async () => ({ ok: true, uid: 0x10000001, users: [{ uid: 0x10000001, uid_hex: '0x10000001', username: 'User1' }], slots: [{ id: 'secret' }] }) };
|
||||
if (String(url).includes('/api/profile/avatar/current')) return { ok: true, json: async () => ({ data_url: `data:image/png;base64,${png('current').toString('base64')}` }) };
|
||||
if (String(url).endsWith('/api/profile/local-username')) return { ok: true, json: async () => ({ ok: true }) };
|
||||
return { ok: true, json: async () => ({ uid: 0x10000001, username: 'Chimera', files_copied: 11 }) };
|
||||
};
|
||||
assert.throws(() => createPs5UploadProfileProvider({ engineOrigin: 'http://192.168.1.8:19113', consoleHost: '192.168.1.9', sharedRoot: root, fetch }), error => error.code === 'PS5_PROFILE_ENGINE_ORIGIN');
|
||||
assert.throws(() => createPs5UploadProfileProvider({ engineOrigin: 'http://127.0.0.1:19113', consoleHost: '192.168.1.9', fetch }), error => error.code === 'PS5_PROFILE_SHARED_ROOT');
|
||||
const provider = createPs5UploadProfileProvider({ engineOrigin: 'http://127.0.0.1:19113', consoleHost: '192.168.1.9', sharedRoot: root, fetch });
|
||||
assert.equal((await provider.probe()).available, true);
|
||||
assert.equal((await provider.listUsers()).users[0].username, 'User1');
|
||||
assert.equal((await provider.currentAvatar({ uid: 0x10000001 })).bytes.subarray(0, 8).equals(png('').subarray(0, 8)), true);
|
||||
await provider.renameUser({ uid: 0x10000001, name: 'Chimera' });
|
||||
await provider.applyAvatar({ uid: 0x10000001, username: 'Chimera', imageBytes: png('next') });
|
||||
assert.equal(calls.every(call => new URL(call.url).hostname === '127.0.0.1'), true);
|
||||
assert.equal(calls.some(call => ['/api/profile/activate', '/api/profile/clear-slot', '/api/profile/username'].includes(new URL(call.url).pathname)), false);
|
||||
assert.equal(calls.some(call => new URL(call.url).pathname === '/api/profile/local-username'), true);
|
||||
});
|
||||
|
||||
test('server, Electron, web and UI expose only guarded local profile operations', () => {
|
||||
const registry = fs.readFileSync(path.join(__dirname, '../../src/server/create-registry.js'), 'utf8');
|
||||
const main = fs.readFileSync(path.join(__dirname, '../../main.js'), 'utf8');
|
||||
const preload = fs.readFileSync(path.join(__dirname, '../../preload.js'), 'utf8');
|
||||
const web = fs.readFileSync(path.join(__dirname, '../../renderer/pork-web-shim.js'), 'utf8');
|
||||
const html = fs.readFileSync(path.join(__dirname, '../../renderer/index.html'), 'utf8');
|
||||
const renderer = fs.readFileSync(path.join(__dirname, '../../renderer/modules/xavatar.js'), 'utf8');
|
||||
for (const command of ['ps5-profile:inspect', 'ps5-profile:prepare-rename', 'ps5-profile:prepare-avatar', 'ps5-profile:apply', 'ps5-profile:rollback', 'ps5-profile:list-operations']) {
|
||||
assert.match(registry, new RegExp(command)); assert.match(main, new RegExp(command)); assert.match(preload, new RegExp(command)); assert.match(web, new RegExp(command));
|
||||
}
|
||||
assert.match(html, /ps5-profile-workspace/);
|
||||
assert.match(html, /PS5Upload 5\.4\.19/);
|
||||
assert.match(renderer, /loadPs5LocalProfiles/);
|
||||
assert.match(renderer, /result\.version \|\| '5\.4\.19'/);
|
||||
assert.doesNotMatch(renderer, /PS5Upload 5\.2\.1/);
|
||||
assert.match(renderer, /ROLLBACK PS5 PROFILE/);
|
||||
assert.doesNotMatch(renderer, /account.?id/i);
|
||||
});
|
||||
@@ -0,0 +1,311 @@
|
||||
'use strict';
|
||||
|
||||
const assert = require('node:assert/strict');
|
||||
const crypto = require('node:crypto');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const test = require('node:test');
|
||||
const {
|
||||
LINUX_LISTENER_PROCESS_EVIDENCE,
|
||||
LINUX_PROCESS_EVIDENCE,
|
||||
createPidfdProcessTerminator,
|
||||
createLinuxListenerProcessInspector,
|
||||
createLinuxProcessInspector,
|
||||
parseProcNetListeners,
|
||||
sameLinuxListenerProcess,
|
||||
sameLinuxProcessIdentity,
|
||||
verifyLinuxProcessGuard,
|
||||
} = require('../../src/services/linux-listener-process');
|
||||
|
||||
const ENGINE_BYTES = Buffer.from('exact-engine');
|
||||
const ENGINE_SHA256 = crypto.createHash('sha256').update(ENGINE_BYTES).digest('hex');
|
||||
const BOOT_ID = '11111111-2222-4333-8444-555555555555';
|
||||
const SELF_NAMESPACES = Object.freeze({
|
||||
net: 'net:[4026533001]',
|
||||
pid: 'pid:[4026533002]',
|
||||
mnt: 'mnt:[4026533003]',
|
||||
});
|
||||
|
||||
function procStat(pid = 4242, startTimeTicks = '123456') {
|
||||
const afterCommand = ['S', ...Array(18).fill('0'), String(startTimeTicks), ...Array(20).fill('0')];
|
||||
return `${pid} (engine worker) ${afterCommand.join(' ')}`;
|
||||
}
|
||||
|
||||
function tcpLine({ address = '00000000', port = '4AA9', state = '0A', inode = '555', uid = '10001' } = {}) {
|
||||
return ` 0: ${address}:${port} 00000000:0000 ${state} 00000000:00000000 00:00000000 00000000 ${uid} 0 ${inode} 1 0000000000000000 100 0 0 10 0`;
|
||||
}
|
||||
|
||||
function fakeProcfs(overrides = {}) {
|
||||
const enginePath = path.resolve('fixtures/ps5upload-engine-static');
|
||||
const pid = String(overrides.pid || 4242);
|
||||
const listenerInode = String(overrides.listenerInode || '555');
|
||||
const pids = overrides.pids || [pid];
|
||||
const ownerPids = overrides.ownerPids || [pid];
|
||||
const selfNamespaces = { ...SELF_NAMESPACES, ...(overrides.selfNamespaces || {}) };
|
||||
const targetNamespaces = { ...SELF_NAMESPACES, ...(overrides.targetNamespaces || {}) };
|
||||
const selfCgroup = overrides.selfCgroup || '0::/docker/chimera\n';
|
||||
const targetCgroup = overrides.targetCgroup || selfCgroup;
|
||||
const tcp = overrides.tcp ?? `sl local_address rem_address st tx_queue tr tm->when retrnsmt uid timeout inode\n${tcpLine({
|
||||
address: overrides.address,
|
||||
state: overrides.state,
|
||||
inode: listenerInode,
|
||||
})}\n`;
|
||||
const tcp6 = overrides.tcp6 ?? 'sl local_address rem_address st tx_queue tr tm->when retrnsmt uid timeout inode\n';
|
||||
const fdTargets = overrides.fdTargets || Object.fromEntries(ownerPids.map(owner => [`/proc/${owner}/fd/7`, `socket:[${listenerInode}]`]));
|
||||
const reads = new Map([
|
||||
['/proc/net/tcp', tcp],
|
||||
['/proc/net/tcp6', tcp6],
|
||||
['/proc/sys/kernel/random/boot_id', `${overrides.bootId || BOOT_ID}\n`],
|
||||
['/proc/self/cgroup', selfCgroup],
|
||||
[`/proc/${pid}/cgroup`, targetCgroup],
|
||||
[`/proc/${pid}/stat`, overrides.stat ?? procStat(Number(pid), overrides.startTimeTicks || '123456')],
|
||||
[`/proc/${pid}/cmdline`, overrides.cmdline ?? `${enginePath}\0`],
|
||||
]);
|
||||
const links = new Map([
|
||||
['/proc/self/ns/net', selfNamespaces.net],
|
||||
['/proc/self/ns/pid', selfNamespaces.pid],
|
||||
['/proc/self/ns/mnt', selfNamespaces.mnt],
|
||||
[`/proc/${pid}/ns/net`, targetNamespaces.net],
|
||||
[`/proc/${pid}/ns/pid`, targetNamespaces.pid],
|
||||
[`/proc/${pid}/ns/mnt`, targetNamespaces.mnt],
|
||||
[`/proc/${pid}/exe`, overrides.exeLink || enginePath],
|
||||
...Object.entries(fdTargets),
|
||||
]);
|
||||
const fsImpl = {
|
||||
readFileSync(filePath) {
|
||||
const key = String(filePath);
|
||||
if (!reads.has(key)) throw Object.assign(new Error(`missing ${key}`), { code: 'ENOENT' });
|
||||
return reads.get(key);
|
||||
},
|
||||
readlinkSync(filePath) {
|
||||
const key = String(filePath);
|
||||
if (!links.has(key)) throw Object.assign(new Error(`missing ${key}`), { code: 'ENOENT' });
|
||||
return links.get(key);
|
||||
},
|
||||
readdirSync(filePath, options) {
|
||||
const key = String(filePath);
|
||||
if (key === '/proc' && options?.withFileTypes) return pids.map(name => ({ name, isDirectory: () => true }));
|
||||
const owner = /^\/proc\/([0-9]+)\/fd$/.exec(key)?.[1];
|
||||
if (owner) return ownerPids.includes(owner) ? ['7'] : [];
|
||||
throw Object.assign(new Error(`missing ${key}`), { code: 'ENOENT' });
|
||||
},
|
||||
realpathSync(filePath) {
|
||||
const key = String(filePath);
|
||||
if (key === enginePath || key === `/proc/${pid}/exe`) return overrides.exeRealPath || enginePath;
|
||||
throw Object.assign(new Error(`missing ${key}`), { code: 'ENOENT' });
|
||||
},
|
||||
statSync(filePath, options) {
|
||||
const key = String(filePath);
|
||||
if (key === `/proc/${pid}`) return { uid: overrides.uid ?? 10001 };
|
||||
if (key === enginePath || key === `/proc/${pid}/exe`) {
|
||||
return options?.bigint
|
||||
? { dev: BigInt(overrides.exeDev || 11), ino: BigInt(overrides.exeIno || 22) }
|
||||
: { dev: Number(overrides.exeDev || 11), ino: Number(overrides.exeIno || 22) };
|
||||
}
|
||||
throw Object.assign(new Error(`missing ${key}`), { code: 'ENOENT' });
|
||||
},
|
||||
};
|
||||
const processInspectorOptions = {
|
||||
fsImpl,
|
||||
platform: 'linux',
|
||||
currentUid: () => 10001,
|
||||
hashFile: () => overrides.sha256 || ENGINE_SHA256,
|
||||
procRoot: '/proc',
|
||||
};
|
||||
return {
|
||||
enginePath,
|
||||
inspect: createLinuxListenerProcessInspector(processInspectorOptions),
|
||||
inspectProcess: createLinuxProcessInspector(processInspectorOptions),
|
||||
};
|
||||
}
|
||||
|
||||
test('proc-net parser keeps exact listener address, port and inode evidence', () => {
|
||||
const parsed = parseProcNetListeners(`header\n${tcpLine()}\n${tcpLine({ port: '4AAA', inode: '556' })}\n${tcpLine({ state: '01', inode: '557' })}\n`, 19113, 'ipv4');
|
||||
assert.deepEqual(parsed, [{ family: 'ipv4', addressHex: '00000000', port: 19113, inode: '555' }]);
|
||||
});
|
||||
|
||||
test('exact upstream wildcard listener is bound to one same-namespace immutable process', async () => {
|
||||
const { inspect, enginePath } = fakeProcfs();
|
||||
const result = await inspect({ host: '127.0.0.1', port: 19113, enginePath, expectedSha256: ENGINE_SHA256 });
|
||||
assert.deepEqual(result, {
|
||||
verified: true,
|
||||
bootId: BOOT_ID,
|
||||
pid: 4242,
|
||||
startTimeTicks: '123456',
|
||||
socketInode: '555',
|
||||
exePath: enginePath,
|
||||
exeDev: '11',
|
||||
exeIno: '22',
|
||||
sha256: ENGINE_SHA256,
|
||||
uid: 10001,
|
||||
namespaces: SELF_NAMESPACES,
|
||||
cgroup: '0::/docker/chimera',
|
||||
evidence: LINUX_LISTENER_PROCESS_EVIDENCE,
|
||||
});
|
||||
});
|
||||
|
||||
test('exact spawned PID identity is provable before the engine opens a listener', async () => {
|
||||
const { inspectProcess, enginePath } = fakeProcfs({ tcp: 'header\n' });
|
||||
const result = await inspectProcess({ pid: 4242, enginePath, expectedSha256: ENGINE_SHA256 });
|
||||
assert.deepEqual(result, {
|
||||
verified: true,
|
||||
bootId: BOOT_ID,
|
||||
pid: 4242,
|
||||
startTimeTicks: '123456',
|
||||
exePath: enginePath,
|
||||
exeDev: '11',
|
||||
exeIno: '22',
|
||||
sha256: ENGINE_SHA256,
|
||||
uid: 10001,
|
||||
namespaces: SELF_NAMESPACES,
|
||||
cgroup: '0::/docker/chimera',
|
||||
evidence: LINUX_PROCESS_EVIDENCE,
|
||||
});
|
||||
});
|
||||
|
||||
test('listener inspection rejects address, ownership and namespace ambiguity', async t => {
|
||||
const cases = [
|
||||
['loopback bind differs from the pinned upstream contract', { address: '0100007F' }, 'listener-bind-mismatch'],
|
||||
['IPv6 wildcard differs from the pinned upstream contract', { tcp: 'header\n', tcp6: `header\n${tcpLine({ address: '00000000000000000000000000000000' })}\n` }, 'listener-bind-mismatch'],
|
||||
['multiple listener inodes are ambiguous', { tcp6: `header\n${tcpLine({ address: '00000000000000000000000000000000', inode: '556' })}\n` }, 'listener-count-ambiguous'],
|
||||
['multiple owning processes are ambiguous', { pids: ['4242', '5252'], ownerPids: ['4242', '5252'] }, 'listener-owner-ambiguous'],
|
||||
['different network namespace is rejected', { targetNamespaces: { net: 'net:[999]' } }, 'listener-owner-namespace-mismatch'],
|
||||
['different cgroup is rejected', { targetCgroup: '0::/docker/other\n' }, 'listener-owner-cgroup-mismatch'],
|
||||
];
|
||||
for (const [name, options, reason] of cases) await t.test(name, async () => {
|
||||
const { inspect, enginePath } = fakeProcfs(options);
|
||||
const result = await inspect({ host: '127.0.0.1', port: 19113, enginePath, expectedSha256: ENGINE_SHA256 });
|
||||
assert.equal(result.verified, false);
|
||||
assert.equal(result.reason, reason);
|
||||
});
|
||||
});
|
||||
|
||||
test('listener inspection rejects mutable or mismatched process identity', async t => {
|
||||
const cases = [
|
||||
['uid mismatch', { uid: 10002 }, 'listener-owner-uid-mismatch'],
|
||||
['deleted executable', { exeLink: `${path.resolve('fixtures/ps5upload-engine-static')} (deleted)` }, 'listener-executable-deleted'],
|
||||
['command line mismatch', { cmdline: `${path.resolve('fixtures/other-engine')}\0` }, 'listener-command-line-mismatch'],
|
||||
['malformed start time', { stat: '4242 (engine worker) S 0 0' }, 'listener-start-time-unavailable'],
|
||||
['executable hash mismatch', { sha256: 'f'.repeat(64) }, 'listener-executable-hash-mismatch'],
|
||||
];
|
||||
for (const [name, options, reason] of cases) await t.test(name, async () => {
|
||||
const { inspect, enginePath } = fakeProcfs(options);
|
||||
const result = await inspect({ host: '127.0.0.1', port: 19113, enginePath, expectedSha256: ENGINE_SHA256 });
|
||||
assert.equal(result.verified, false);
|
||||
assert.equal(result.reason, reason);
|
||||
});
|
||||
});
|
||||
|
||||
test('full process identity comparison detects every termination-relevant field drift', () => {
|
||||
const base = {
|
||||
bootId: BOOT_ID, pid: 4242, startTimeTicks: '123456', socketInode: '555', exePath: '/engine',
|
||||
exeDev: '11', exeIno: '22', sha256: ENGINE_SHA256, uid: 10001, namespaces: SELF_NAMESPACES,
|
||||
cgroup: '0::/docker/chimera', evidence: LINUX_LISTENER_PROCESS_EVIDENCE,
|
||||
};
|
||||
assert.equal(sameLinuxListenerProcess(base, { ...base }), true);
|
||||
assert.equal(sameLinuxProcessIdentity(base, { ...base, socketInode: '999', evidence: LINUX_PROCESS_EVIDENCE }), true);
|
||||
for (const [field, value] of [['bootId', 'other'], ['pid', 5252], ['startTimeTicks', '9'], ['socketInode', '8'], ['exePath', '/other'], ['exeDev', '9'], ['exeIno', '9'], ['sha256', 'a'.repeat(64)], ['uid', 10002], ['cgroup', 'other'], ['evidence', 'other']]) {
|
||||
assert.equal(sameLinuxListenerProcess(base, { ...base, [field]: value }), false, field);
|
||||
}
|
||||
assert.equal(sameLinuxListenerProcess(base, { ...base, namespaces: { ...SELF_NAMESPACES, net: 'net:[9]' } }), false);
|
||||
});
|
||||
|
||||
test('pidfd terminator passes the exact sampled generation and accepts only exit proof', async () => {
|
||||
const calls = [];
|
||||
const terminate = createPidfdProcessTerminator({
|
||||
platform: 'linux',
|
||||
guardPath: '/usr/local/bin/chimera-process-guard',
|
||||
verifyGuard: () => ({ verified: true }),
|
||||
execFileImpl(command, args, options, callback) {
|
||||
calls.push({ command, args, options });
|
||||
callback(null, JSON.stringify({ terminated: true, identityGone: true, forced: false }), '');
|
||||
},
|
||||
});
|
||||
const result = await terminate({
|
||||
bootId: BOOT_ID, pid: 4242, startTimeTicks: '123456', socketInode: '555', exePath: '/engine',
|
||||
exeDev: '11', exeIno: '22', sha256: ENGINE_SHA256, uid: 10001, namespaces: SELF_NAMESPACES,
|
||||
cgroup: '0::/docker/chimera', evidence: LINUX_LISTENER_PROCESS_EVIDENCE,
|
||||
});
|
||||
assert.deepEqual(result, { terminated: true, identityGone: true, forced: false });
|
||||
assert.equal(calls.length, 1);
|
||||
assert.equal(calls[0].command, '/usr/local/bin/chimera-process-guard');
|
||||
assert.deepEqual(calls[0].args, [
|
||||
'--terminate', '4242', '123456', '11', '22', '10001',
|
||||
'4026533001', '4026533002', '4026533003', '1500',
|
||||
]);
|
||||
assert.equal(calls[0].options.shell, false);
|
||||
assert.deepEqual(await terminate({
|
||||
bootId: BOOT_ID, pid: 4242, startTimeTicks: '123456', exePath: '/engine',
|
||||
exeDev: '11', exeIno: '22', sha256: ENGINE_SHA256, uid: 10001, namespaces: SELF_NAMESPACES,
|
||||
cgroup: '0::/docker/chimera', evidence: LINUX_PROCESS_EVIDENCE,
|
||||
}), { terminated: true, identityGone: true, forced: false });
|
||||
assert.equal(calls.length, 2);
|
||||
assert.deepEqual(calls[1].args, calls[0].args);
|
||||
});
|
||||
|
||||
test('pidfd terminator fails closed on helper error or incomplete exit proof', async t => {
|
||||
const identity = {
|
||||
pid: 4242, startTimeTicks: '123456', exeDev: '11', exeIno: '22', uid: 10001,
|
||||
namespaces: SELF_NAMESPACES, evidence: LINUX_LISTENER_PROCESS_EVIDENCE,
|
||||
};
|
||||
await t.test('helper error', async () => {
|
||||
const terminate = createPidfdProcessTerminator({
|
||||
platform: 'linux', verifyGuard: () => ({ verified: true }),
|
||||
execFileImpl(_command, _args, _options, callback) { callback(new Error('blocked'), '', ''); },
|
||||
});
|
||||
assert.deepEqual(await terminate(identity), { terminated: false, identityGone: false, reason: 'pidfd-helper-failed' });
|
||||
});
|
||||
await t.test('incomplete proof', async () => {
|
||||
const terminate = createPidfdProcessTerminator({
|
||||
platform: 'linux', verifyGuard: () => ({ verified: true }),
|
||||
execFileImpl(_command, _args, _options, callback) {
|
||||
callback(null, JSON.stringify({ terminated: true, identityGone: false }), '');
|
||||
},
|
||||
});
|
||||
assert.deepEqual(await terminate(identity), { terminated: false, identityGone: false, reason: 'pidfd-exit-unproven' });
|
||||
});
|
||||
});
|
||||
|
||||
test('pidfd terminator rejects an unbounded timeout before any helper execution', () => {
|
||||
for (const gracefulTimeoutMs of [-1, 0, 99, 10_001, 1.5, Number.NaN]) {
|
||||
assert.throws(
|
||||
() => createPidfdProcessTerminator({ gracefulTimeoutMs }),
|
||||
/graceful timeout/i,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('process guard verifier rejects links, non-root ownership and writable binaries', () => {
|
||||
function verify({ symbolic = false, uid = 0, mode = 0o100555 } = {}) {
|
||||
return verifyLinuxProcessGuard({
|
||||
platform: 'linux',
|
||||
guardPath: '/usr/local/bin/chimera-process-guard',
|
||||
fsImpl: {
|
||||
lstatSync: () => ({ isFile: () => !symbolic, isSymbolicLink: () => symbolic }),
|
||||
statSync: () => ({ isFile: () => true, uid, mode }),
|
||||
accessSync() {},
|
||||
},
|
||||
});
|
||||
}
|
||||
assert.equal(verify({ symbolic: true }).reason, 'process-guard-symlink-unsafe');
|
||||
assert.equal(verify({ uid: 10001 }).reason, 'process-guard-permissions-unsafe');
|
||||
assert.equal(verify({ mode: 0o100575 }).reason, 'process-guard-permissions-unsafe');
|
||||
assert.equal(verify().verified, true);
|
||||
});
|
||||
|
||||
test('container process guard uses one pidfd for validated graceful termination only', () => {
|
||||
const source = fs.readFileSync('tools/chimera-process-guard.c', 'utf8');
|
||||
const dockerfile = fs.readFileSync('Dockerfile', 'utf8');
|
||||
assert.match(source, /SYS_pidfd_open/);
|
||||
assert.match(source, /SYS_pidfd_send_signal/);
|
||||
assert.match(source, /PR_SET_PDEATHSIG/);
|
||||
assert.match(source, /pidfd_send\(pid_fd, SIGTERM\)[\s\S]*wait_for_exit/);
|
||||
assert.doesNotMatch(source, /pidfd_send\(pid_fd, SIGKILL\)/);
|
||||
assert.match(source, /start_time_ticks/);
|
||||
assert.match(source, /st_dev[\s\S]*st_ino/);
|
||||
assert.match(source, /ns\/net[\s\S]*ns\/pid[\s\S]*ns\/mnt/);
|
||||
assert.match(source, /reopened_process_fd/);
|
||||
assert.match(source, /CLOCK_MONOTONIC/);
|
||||
assert.match(dockerfile, /chimera-process-guard\.c/);
|
||||
assert.match(dockerfile, /chmod 0555 \/usr\/local\/bin\/chimera-process-guard/);
|
||||
});
|
||||
@@ -0,0 +1,748 @@
|
||||
'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 { EventEmitter } = require('node:events');
|
||||
const { PS5UPLOAD_RUNTIME, createPs5UploadRuntimeService } = require('../../src/services/ps5upload-runtime-service');
|
||||
const { LINUX_LISTENER_PROCESS_EVIDENCE, LINUX_PROCESS_EVIDENCE } = require('../../src/services/linux-listener-process');
|
||||
|
||||
const digest = value => crypto.createHash('sha256').update(value).digest('hex');
|
||||
const TEST_BOOT_ID = '11111111-2222-4333-8444-555555555555';
|
||||
const TEST_NAMESPACES = Object.freeze({ net: 'net:[4001]', pid: 'pid:[4002]', mnt: 'mnt:[4003]' });
|
||||
|
||||
function listenerIdentity(input, overrides = {}) {
|
||||
return {
|
||||
verified: true,
|
||||
bootId: TEST_BOOT_ID,
|
||||
pid: 4242,
|
||||
startTimeTicks: '123456',
|
||||
socketInode: '555',
|
||||
exePath: input.enginePath,
|
||||
exeDev: '11',
|
||||
exeIno: '22',
|
||||
sha256: input.expectedSha256,
|
||||
uid: 10001,
|
||||
namespaces: TEST_NAMESPACES,
|
||||
cgroup: '0::/docker/chimera',
|
||||
evidence: LINUX_LISTENER_PROCESS_EVIDENCE,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function processIdentity(input, overrides = {}) {
|
||||
const { socketInode: _socketInode, ...identity } = listenerIdentity(input, overrides);
|
||||
return { ...identity, evidence: LINUX_PROCESS_EVIDENCE };
|
||||
}
|
||||
|
||||
test('A5 production pin selects the clean static 5.4.19 engine proven on the deployed host', () => {
|
||||
assert.equal(PS5UPLOAD_RUNTIME.version, '5.4.19');
|
||||
assert.equal(PS5UPLOAD_RUNTIME.revision, 'ec0e5c4e95520f2dc26c226a909dc5a95e385f41');
|
||||
assert.equal(PS5UPLOAD_RUNTIME.enginePath, '/payloads/ps5upload/5.4.19/ps5upload-engine-static');
|
||||
assert.equal(PS5UPLOAD_RUNTIME.engineSha256, 'd4055f43a57b36d1d2dec3e70ec03f73c9c47bc311d2b2f87145509ed53c8ba5');
|
||||
});
|
||||
|
||||
function fixture(t, overrides = {}) {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'chimera-ps5upload-runtime-'));
|
||||
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
const engine = Buffer.from('engine-5.4.19');
|
||||
const payload = Buffer.from('elf-5.4.19');
|
||||
const dpi = Buffer.from('dpi-5.4.19');
|
||||
const enginePath = path.join(root, 'ps5upload-engine');
|
||||
const payloadPath = path.join(root, 'ps5upload.elf');
|
||||
const dpiPath = path.join(root, 'ezremote-dpi.elf');
|
||||
fs.writeFileSync(enginePath, engine, { mode: 0o750 });
|
||||
fs.writeFileSync(payloadPath, payload, { mode: 0o640 });
|
||||
fs.writeFileSync(dpiPath, dpi, { mode: 0o640 });
|
||||
let payloadGeneration = 1;
|
||||
const customFetch = overrides.fetch;
|
||||
const customTcpSend = overrides.tcpSend;
|
||||
const remainingOverrides = { ...overrides };
|
||||
delete remainingOverrides.fetch;
|
||||
delete remainingOverrides.tcpSend;
|
||||
if (remainingOverrides.processPlatform === 'linux' && !remainingOverrides.inspectSpawnedProcess) {
|
||||
remainingOverrides.inspectSpawnedProcess = async input => processIdentity(input);
|
||||
}
|
||||
return createPs5UploadRuntimeService({
|
||||
enginePath,
|
||||
payloadPath,
|
||||
dpiPath,
|
||||
expectedEngineSha256: digest(engine),
|
||||
expectedPayloadSha256: digest(payload),
|
||||
expectedDpiSha256: digest(dpi),
|
||||
consoleHost: () => '192.0.2.55',
|
||||
fetch: customFetch || (async url => {
|
||||
const href = String(url);
|
||||
if (href.endsWith('/api/version')) return { ok: true, status: 200, json: async () => ({ version: '5.4.19' }) };
|
||||
if (href.includes('/api/ps5/status')) return { ok: true, status: 200, json: async () => ({ version: '5.4.19', instance_id: String(payloadGeneration), started_at_unix: payloadGeneration }) };
|
||||
return { ok: true, status: 200, json: async () => ({ ok: true, users: [] }) };
|
||||
}),
|
||||
tcpProbe: async () => true,
|
||||
enginePortProbe: async () => false,
|
||||
tcpSend: async input => {
|
||||
payloadGeneration += 1;
|
||||
return customTcpSend ? customTcpSend(input) : { ok: true, bytes: input.bytes.length };
|
||||
},
|
||||
delay: async () => {},
|
||||
processPlatform: 'win32',
|
||||
inspectExistingEngine: async input => listenerIdentity(input),
|
||||
terminateProvenProcess: async () => ({ terminated: true, identityGone: true, forced: false }),
|
||||
...remainingOverrides,
|
||||
});
|
||||
}
|
||||
|
||||
test('runtime preflight proves exact engine and payload artifacts before reporting ready', async t => {
|
||||
const runtime = fixture(t);
|
||||
const result = await runtime.preflight();
|
||||
assert.equal(result.ready, true);
|
||||
assert.equal(result.engine.version, '5.4.19');
|
||||
assert.equal(result.payload.verified, true);
|
||||
assert.equal(result.loader.reachable, true);
|
||||
assert.equal(result.loader.port, 9021);
|
||||
});
|
||||
|
||||
test('runtime blocks hash drift and never offers the loader action', async t => {
|
||||
const runtime = fixture(t, { expectedPayloadSha256: '0'.repeat(64) });
|
||||
const result = await runtime.preflight();
|
||||
assert.equal(result.ready, false);
|
||||
assert.equal(result.payload.reason, 'sha256-mismatch');
|
||||
await assert.rejects(() => runtime.startPayload(), error => error.code === 'PS5UPLOAD_PREFLIGHT_BLOCKED');
|
||||
});
|
||||
|
||||
test('engine start uses a fixed executable without shell and keeps stdin open', async t => {
|
||||
const calls = [];
|
||||
const child = new EventEmitter();
|
||||
child.stdin = { destroyed: false, end() { this.destroyed = true; child.emit('exit', 0, null); } };
|
||||
child.stderr = new EventEmitter();
|
||||
child.kill = () => true;
|
||||
const runtime = fixture(t, {
|
||||
fetch: async () => { throw new Error('not listening yet'); },
|
||||
spawn: (file, args, options) => { calls.push({ file, args, options }); return child; },
|
||||
waitForEngine: async () => ({ available: true, version: '5.4.19' }),
|
||||
});
|
||||
const result = await runtime.startEngine();
|
||||
assert.equal(result.available, true);
|
||||
assert.equal(calls.length, 1);
|
||||
assert.deepEqual(calls[0].args, []);
|
||||
assert.equal(calls[0].options.shell, false);
|
||||
assert.equal(calls[0].options.env.PS5_ADDR, '192.0.2.55:9113');
|
||||
assert.equal(calls[0].options.env.PS5UPLOAD_ENGINE_PORT, '19113');
|
||||
await runtime.stopEngine();
|
||||
assert.equal(child.stdin.destroyed, true);
|
||||
});
|
||||
|
||||
test('Linux engine start goes through the fixed parent-death process guard', async t => {
|
||||
const calls = [];
|
||||
const guardPath = path.join(os.tmpdir(), 'chimera-process-guard-test');
|
||||
let running = false;
|
||||
const child = new EventEmitter();
|
||||
child.pid = 4242;
|
||||
child.stdin = { end() { running = false; child.emit('exit', 0, null); } };
|
||||
child.stderr = new EventEmitter();
|
||||
child.kill = () => true;
|
||||
const runtime = fixture(t, {
|
||||
fetch: async url => {
|
||||
if (!running || !String(url).endsWith('/api/version')) throw new Error('not listening');
|
||||
return { ok: true, status: 200, json: async () => ({ version: '5.4.19' }) };
|
||||
},
|
||||
processPlatform: 'linux',
|
||||
processGuardPath: guardPath,
|
||||
verifyProcessGuard: () => ({ verified: true, path: guardPath }),
|
||||
spawn: (file, args, options) => { calls.push({ file, args, options }); running = true; return child; },
|
||||
waitForEngine: async () => ({ available: true, version: '5.4.19' }),
|
||||
enginePortProbe: async () => running,
|
||||
});
|
||||
|
||||
await runtime.startEngine();
|
||||
assert.equal(calls.length, 1);
|
||||
assert.equal(calls[0].file, guardPath);
|
||||
assert.equal(calls[0].args[0], '--pdeathsig-exec');
|
||||
assert.equal(path.basename(calls[0].args[1]), 'ps5upload-engine');
|
||||
assert.equal(calls[0].options.shell, false);
|
||||
await runtime.stopEngine();
|
||||
});
|
||||
|
||||
test('Linux start rejects a matching endpoint unless its listener PID is the spawned child', async t => {
|
||||
const guardPath = path.join(os.tmpdir(), 'chimera-process-guard-test');
|
||||
let running = false;
|
||||
const child = new EventEmitter();
|
||||
child.pid = 4242;
|
||||
child.stdin = { end() { running = false; child.emit('exit', 0, null); } };
|
||||
child.stderr = new EventEmitter();
|
||||
child.kill = () => true;
|
||||
const runtime = fixture(t, {
|
||||
fetch: async url => {
|
||||
if (!running || !String(url).endsWith('/api/version')) throw new Error('not listening');
|
||||
return { ok: true, status: 200, json: async () => ({ version: '5.4.19' }) };
|
||||
},
|
||||
processPlatform: 'linux',
|
||||
processGuardPath: guardPath,
|
||||
verifyProcessGuard: () => ({ verified: true, path: guardPath }),
|
||||
spawn: () => { running = true; return child; },
|
||||
waitForEngine: async () => ({ available: true, version: '5.4.19' }),
|
||||
inspectExistingEngine: async input => listenerIdentity(input, { pid: 5252 }),
|
||||
enginePortProbe: async () => running,
|
||||
});
|
||||
|
||||
await assert.rejects(
|
||||
() => runtime.startEngine(),
|
||||
error => error.code === 'PS5UPLOAD_ENGINE_START_FAILED'
|
||||
&& error.details?.reason === 'spawned-listener-unproven'
|
||||
&& error.details?.cleanup?.stopped === true,
|
||||
);
|
||||
});
|
||||
|
||||
test('Linux spawned fallback revalidates and terminates through pidfd without numeric child kill', async t => {
|
||||
const guardPath = path.join(os.tmpdir(), 'chimera-process-guard-test');
|
||||
let running = false;
|
||||
let childKills = 0;
|
||||
const terminations = [];
|
||||
const child = new EventEmitter();
|
||||
child.pid = 4242;
|
||||
child.stdin = { end() {} };
|
||||
child.stderr = new EventEmitter();
|
||||
child.kill = () => { childKills += 1; return true; };
|
||||
const runtime = fixture(t, {
|
||||
fetch: async url => {
|
||||
if (!running || !String(url).endsWith('/api/version')) throw new Error('not listening');
|
||||
return { ok: true, status: 200, json: async () => ({ version: '5.4.19' }) };
|
||||
},
|
||||
processPlatform: 'linux',
|
||||
processGuardPath: guardPath,
|
||||
verifyProcessGuard: () => ({ verified: true, path: guardPath }),
|
||||
spawn: () => { running = true; return child; },
|
||||
waitForEngine: async () => ({ available: true, version: '5.4.19' }),
|
||||
inspectExistingEngine: async input => listenerIdentity(input),
|
||||
terminateProvenProcess: async identity => {
|
||||
terminations.push(identity);
|
||||
running = false;
|
||||
return { terminated: true, identityGone: true, forced: false };
|
||||
},
|
||||
enginePortProbe: async () => running,
|
||||
});
|
||||
|
||||
await runtime.startEngine();
|
||||
assert.deepEqual(await runtime.stopEngine(), {
|
||||
stopped: true,
|
||||
endpointReleased: true,
|
||||
identityGone: true,
|
||||
forced: false,
|
||||
});
|
||||
assert.equal(terminations.length, 1);
|
||||
assert.equal(terminations[0].pid, 4242);
|
||||
assert.equal(childKills, 0);
|
||||
});
|
||||
|
||||
test('Linux spawned ownership can be re-attested after a transient read-only sample drift', async t => {
|
||||
const guardPath = path.join(os.tmpdir(), 'chimera-process-guard-test');
|
||||
let running = false;
|
||||
let inspections = 0;
|
||||
const child = new EventEmitter();
|
||||
child.pid = 4242;
|
||||
child.stdin = { end() { running = false; child.emit('exit', 0, null); } };
|
||||
child.stderr = new EventEmitter();
|
||||
child.kill = () => true;
|
||||
const runtime = fixture(t, {
|
||||
fetch: async url => {
|
||||
if (!running || !String(url).endsWith('/api/version')) throw new Error('not listening');
|
||||
return { ok: true, status: 200, json: async () => ({ version: '5.4.19' }) };
|
||||
},
|
||||
processPlatform: 'linux',
|
||||
processGuardPath: guardPath,
|
||||
verifyProcessGuard: () => ({ verified: true, path: guardPath }),
|
||||
spawn: () => { running = true; return child; },
|
||||
waitForEngine: async () => ({ available: true, version: '5.4.19' }),
|
||||
inspectExistingEngine: async input => {
|
||||
inspections += 1;
|
||||
return listenerIdentity(input, { pid: inspections === 3 ? 5252 : 4242 });
|
||||
},
|
||||
enginePortProbe: async () => running,
|
||||
});
|
||||
|
||||
await runtime.startEngine();
|
||||
assert.equal((await runtime.status()).engine.processOwned, false);
|
||||
const recovered = await runtime.status();
|
||||
assert.equal(recovered.engine.processOwned, true);
|
||||
assert.equal(recovered.engine.processOwnership, 'spawned');
|
||||
await runtime.stopEngine();
|
||||
});
|
||||
|
||||
test('engine stop waits for the shared endpoint to be released before allowing a replacement runtime', async t => {
|
||||
let stopping = false;
|
||||
let started = false;
|
||||
let portProbes = 0;
|
||||
let waits = 0;
|
||||
const child = new EventEmitter();
|
||||
child.stdin = { end() { stopping = true; child.emit('exit', 0, null); } };
|
||||
child.stderr = new EventEmitter();
|
||||
child.kill = () => true;
|
||||
const runtime = fixture(t, {
|
||||
fetch: async url => {
|
||||
if (!String(url).endsWith('/api/version') || !stopping) throw new Error('not listening');
|
||||
throw new Error('HTTP endpoint released');
|
||||
},
|
||||
spawn: () => { started = true; return child; },
|
||||
waitForEngine: async () => ({ available: true, version: '5.4.19' }),
|
||||
enginePortProbe: async () => { if (!started) return false; portProbes += 1; return portProbes < 3; },
|
||||
delay: async () => { waits += 1; },
|
||||
});
|
||||
await runtime.startEngine();
|
||||
const stopped = await runtime.stopEngine();
|
||||
assert.deepEqual(stopped, { stopped: true, endpointReleased: true, identityGone: true, forced: false });
|
||||
assert.equal(portProbes, 3);
|
||||
assert.equal(waits, 2);
|
||||
});
|
||||
|
||||
test('not-owned stop reports success only after proving the shared endpoint is already released', async t => {
|
||||
const released = fixture(t, {
|
||||
fetch: async () => { throw new Error('endpoint unavailable'); },
|
||||
enginePortProbe: async () => false,
|
||||
});
|
||||
assert.deepEqual(await released.stopEngine(), {
|
||||
stopped: true,
|
||||
endpointReleased: true,
|
||||
identityGone: true,
|
||||
alreadyStopped: true,
|
||||
});
|
||||
|
||||
const occupied = fixture(t, { enginePortProbe: async () => true });
|
||||
assert.deepEqual(await occupied.stopEngine(), {
|
||||
stopped: false,
|
||||
endpointReleased: false,
|
||||
identityGone: false,
|
||||
reason: 'not-owned',
|
||||
});
|
||||
});
|
||||
|
||||
test('engine stop fails closed without signaling again when a replacement endpoint remains occupied', async t => {
|
||||
let kills = 0;
|
||||
let portProbes = 0;
|
||||
let started = false;
|
||||
const child = new EventEmitter();
|
||||
child.stdin = { end() { child.emit('exit', 0, null); } };
|
||||
child.stderr = new EventEmitter();
|
||||
child.kill = () => { kills += 1; return true; };
|
||||
const runtime = fixture(t, {
|
||||
fetch: async () => { throw new Error('HTTP endpoint unavailable'); },
|
||||
spawn: () => { started = true; return child; },
|
||||
waitForEngine: async () => ({ available: true, version: '5.4.19' }),
|
||||
enginePortProbe: async () => { if (!started) return false; portProbes += 1; return true; },
|
||||
delay: async () => {},
|
||||
});
|
||||
await runtime.startEngine();
|
||||
const stopped = await runtime.stopEngine();
|
||||
assert.deepEqual(stopped, { stopped: false, endpointReleased: false, identityGone: true, reason: 'engine-endpoint-still-active' });
|
||||
assert.equal(portProbes, 20);
|
||||
assert.equal(kills, 0);
|
||||
});
|
||||
|
||||
test('failed spawned stop retains the child identity so a later safe retry can finish', async t => {
|
||||
let allowExit = false;
|
||||
const child = new EventEmitter();
|
||||
child.stdin = { end() { if (allowExit) child.emit('exit', 0, null); } };
|
||||
child.stderr = new EventEmitter();
|
||||
child.kill = () => { if (allowExit) child.emit('exit', 0, 'SIGTERM'); return true; };
|
||||
const runtime = fixture(t, {
|
||||
fetch: async () => { throw new Error('endpoint unavailable'); },
|
||||
spawn: () => child,
|
||||
waitForEngine: async () => ({ available: true, version: '5.4.19' }),
|
||||
enginePortProbe: async () => false,
|
||||
});
|
||||
|
||||
await runtime.startEngine();
|
||||
assert.deepEqual(await runtime.stopEngine(), {
|
||||
stopped: false,
|
||||
endpointReleased: false,
|
||||
identityGone: false,
|
||||
reason: 'engine-process-still-active',
|
||||
});
|
||||
allowExit = true;
|
||||
assert.deepEqual(await runtime.stopEngine(), {
|
||||
stopped: true,
|
||||
endpointReleased: true,
|
||||
identityGone: true,
|
||||
forced: false,
|
||||
});
|
||||
});
|
||||
|
||||
test('failed engine readiness performs the same proven bounded cleanup', async t => {
|
||||
const child = new EventEmitter();
|
||||
child.stdin = { end() { child.emit('exit', 0, null); } };
|
||||
child.stderr = new EventEmitter();
|
||||
child.kill = () => true;
|
||||
const runtime = fixture(t, {
|
||||
fetch: async () => { throw new Error('endpoint unavailable'); },
|
||||
spawn: () => child,
|
||||
waitForEngine: async () => ({ available: false, version: '', reason: 'unreachable' }),
|
||||
enginePortProbe: async () => false,
|
||||
});
|
||||
|
||||
await assert.rejects(
|
||||
() => runtime.startEngine(),
|
||||
error => error.code === 'PS5UPLOAD_ENGINE_START_FAILED'
|
||||
&& error.details?.cleanup?.stopped === true
|
||||
&& error.details.cleanup.endpointReleased === true
|
||||
&& error.details.cleanup.identityGone === true,
|
||||
);
|
||||
});
|
||||
|
||||
test('Linux failed readiness re-attests the spawned listener before proven pidfd cleanup', async t => {
|
||||
const guardPath = path.join(os.tmpdir(), 'chimera-process-guard-test');
|
||||
let running = false;
|
||||
let childKills = 0;
|
||||
const terminations = [];
|
||||
const child = new EventEmitter();
|
||||
child.pid = 4242;
|
||||
child.stdin = { end() {} };
|
||||
child.stderr = new EventEmitter();
|
||||
child.kill = () => { childKills += 1; return true; };
|
||||
const runtime = fixture(t, {
|
||||
fetch: async url => {
|
||||
if (!running || !String(url).endsWith('/api/version')) throw new Error('not listening');
|
||||
return { ok: true, status: 200, json: async () => ({ version: 'wrong-profile' }) };
|
||||
},
|
||||
processPlatform: 'linux',
|
||||
processGuardPath: guardPath,
|
||||
verifyProcessGuard: () => ({ verified: true, path: guardPath }),
|
||||
spawn: () => { running = true; return child; },
|
||||
waitForEngine: async () => ({ available: false, version: 'wrong-profile', reason: 'runtime-profile-mismatch' }),
|
||||
inspectExistingEngine: async input => listenerIdentity(input),
|
||||
inspectSpawnedProcess: async input => processIdentity(input),
|
||||
terminateProvenProcess: async identity => {
|
||||
terminations.push(identity);
|
||||
running = false;
|
||||
return { terminated: true, identityGone: true, forced: false };
|
||||
},
|
||||
enginePortProbe: async () => running,
|
||||
});
|
||||
|
||||
await assert.rejects(
|
||||
() => runtime.startEngine(),
|
||||
error => error.code === 'PS5UPLOAD_ENGINE_START_FAILED'
|
||||
&& error.details?.cleanup?.stopped === true
|
||||
&& error.details.cleanup.endpointReleased === true
|
||||
&& error.details.cleanup.identityGone === true,
|
||||
);
|
||||
assert.equal(terminations.length, 1);
|
||||
assert.equal(terminations[0].pid, 4242);
|
||||
assert.equal(childKills, 0);
|
||||
});
|
||||
|
||||
test('Linux failed readiness terminates an exact spawned PID that never opens a listener', async t => {
|
||||
const guardPath = path.join(os.tmpdir(), 'chimera-process-guard-test');
|
||||
let childKills = 0;
|
||||
const terminations = [];
|
||||
const child = new EventEmitter();
|
||||
child.pid = 4242;
|
||||
child.stdin = { end() {} };
|
||||
child.stderr = new EventEmitter();
|
||||
child.kill = () => { childKills += 1; return true; };
|
||||
const runtime = fixture(t, {
|
||||
fetch: async () => { throw new Error('listener never opened'); },
|
||||
processPlatform: 'linux',
|
||||
processGuardPath: guardPath,
|
||||
verifyProcessGuard: () => ({ verified: true, path: guardPath }),
|
||||
spawn: () => child,
|
||||
waitForEngine: async () => ({ available: false, version: '', reason: 'unreachable' }),
|
||||
inspectExistingEngine: async () => ({ verified: false, reason: 'listener-not-found' }),
|
||||
inspectSpawnedProcess: async input => processIdentity(input),
|
||||
terminateProvenProcess: async identity => {
|
||||
terminations.push(identity);
|
||||
return { terminated: true, identityGone: true, forced: false };
|
||||
},
|
||||
enginePortProbe: async () => false,
|
||||
});
|
||||
|
||||
await assert.rejects(
|
||||
() => runtime.startEngine(),
|
||||
error => error.code === 'PS5UPLOAD_ENGINE_START_FAILED'
|
||||
&& error.details?.cleanup?.stopped === true
|
||||
&& error.details.cleanup.endpointReleased === true
|
||||
&& error.details.cleanup.identityGone === true,
|
||||
);
|
||||
assert.equal(terminations.length, 1);
|
||||
assert.equal(terminations[0].pid, 4242);
|
||||
assert.equal(terminations[0].evidence, LINUX_PROCESS_EVIDENCE);
|
||||
assert.equal(childKills, 0);
|
||||
});
|
||||
|
||||
test('payload start sends only the pinned ELF and reports management readiness without user data', async t => {
|
||||
const sends = [];
|
||||
const inspections = [];
|
||||
let spawnCalls = 0;
|
||||
const runtime = fixture(t, {
|
||||
inspectExistingEngine: async input => { inspections.push(input); return listenerIdentity(input); },
|
||||
spawn: () => { spawnCalls += 1; throw new Error('must adopt before payload start'); },
|
||||
tcpSend: async input => { sends.push(input); return { ok: true, bytes: input.bytes.length }; },
|
||||
});
|
||||
const result = await runtime.startPayload();
|
||||
const status = await runtime.status();
|
||||
assert.equal(sends.length, 1);
|
||||
assert.equal(spawnCalls, 0);
|
||||
assert.equal(inspections.length >= 4, true);
|
||||
assert.equal(inspections.every(input => input.host === '127.0.0.1' && input.port === 19113), true);
|
||||
assert.equal(status.engine.processOwned, true);
|
||||
assert.equal(status.engine.processOwnership, 'adopted');
|
||||
assert.equal(sends[0].host, '192.0.2.55');
|
||||
assert.equal(sends[0].port, 9021);
|
||||
assert.equal(sends[0].bytes.toString(), 'elf-5.4.19');
|
||||
assert.deepEqual(result, { sent: true, bytes: 10, managementReady: true, managementPort: 9114 });
|
||||
});
|
||||
|
||||
test('payload start ignores a stale ready daemon until the new management instance is observable', async t => {
|
||||
let sent = false;
|
||||
let postSendStatusReads = 0;
|
||||
const runtime = fixture(t, {
|
||||
fetch: async url => {
|
||||
const href = String(url);
|
||||
if (href.endsWith('/api/version')) return { ok: true, status: 200, json: async () => ({ version: '5.4.19' }) };
|
||||
if (href.includes('/api/ps5/status')) {
|
||||
if (sent) postSendStatusReads += 1;
|
||||
const transitioned = sent && postSendStatusReads >= 3;
|
||||
return { ok: true, status: 200, json: async () => ({ version: '5.4.19', instance_id: transitioned ? '2' : '1', started_at_unix: transitioned ? 2 : 1 }) };
|
||||
}
|
||||
return { ok: true, status: 200, json: async () => ({ ok: true, users: [] }) };
|
||||
},
|
||||
tcpSend: async input => { sent = true; return { ok: true, bytes: input.bytes.length }; },
|
||||
});
|
||||
const result = await runtime.startPayload();
|
||||
assert.equal(result.managementReady, true);
|
||||
assert.equal(postSendStatusReads, 3);
|
||||
const identity = await runtime.runtimeIdentity();
|
||||
assert.equal(identity.status, 'unproven');
|
||||
assert.equal(identity.payload.evidence, 'loader-input-hash-and-instance-transition');
|
||||
});
|
||||
|
||||
test('payload start does not attest or report readiness when the old instance never transitions', async t => {
|
||||
let statusReads = 0;
|
||||
const runtime = fixture(t, {
|
||||
fetch: async url => {
|
||||
const href = String(url);
|
||||
if (href.endsWith('/api/version')) return { ok: true, status: 200, json: async () => ({ version: '5.4.19' }) };
|
||||
if (href.includes('/api/ps5/status')) {
|
||||
statusReads += 1;
|
||||
return { ok: true, status: 200, json: async () => ({ version: '5.4.19', instance_id: '1', started_at_unix: 1 }) };
|
||||
}
|
||||
return { ok: true, status: 200, json: async () => ({ ok: true, users: [] }) };
|
||||
},
|
||||
});
|
||||
const result = await runtime.startPayload();
|
||||
assert.equal(result.managementReady, false);
|
||||
assert.equal(statusReads, 13);
|
||||
assert.equal((await runtime.runtimeIdentity()).status, 'unproven');
|
||||
});
|
||||
|
||||
test('DPI fallback sends only the pinned standalone daemon and waits for port 9040', async t => {
|
||||
const probes = []; const sends = [];
|
||||
const runtime = fixture(t, {
|
||||
tcpProbe: async input => { probes.push(input); return input.port === 9021 || probes.filter(item => item.port === 9040).length > 1; },
|
||||
tcpSend: async input => { sends.push(input); return { ok: true, bytes: input.bytes.length }; },
|
||||
});
|
||||
const result = await runtime.ensureDpi();
|
||||
assert.equal(result.ready, true);
|
||||
assert.equal(result.port, 9040);
|
||||
assert.equal(result.alreadyRunning, false);
|
||||
assert.equal(sends.length, 1);
|
||||
assert.equal(sends[0].port, 9021);
|
||||
assert.equal(sends[0].bytes.toString(), 'dpi-5.4.19');
|
||||
assert.equal(probes.some(item => item.port === 9040), true);
|
||||
});
|
||||
|
||||
test('missing console host blocks engine and payload setup', async t => {
|
||||
const runtime = fixture(t, { consoleHost: () => '' });
|
||||
await assert.rejects(() => runtime.startEngine(), error => error.code === 'PS5UPLOAD_CONSOLE_HOST');
|
||||
const result = await runtime.preflight();
|
||||
assert.equal(result.ready, false);
|
||||
assert.equal(result.console.reason, 'host-missing');
|
||||
});
|
||||
|
||||
test('A5 runtime identity stays unproven until Chimera owns the exact engine and payload transition', async t => {
|
||||
const child = new EventEmitter();
|
||||
child.stdin = { end() {} }; child.stderr = new EventEmitter(); child.kill = () => true;
|
||||
let engineReady = false; let payloadLoaded = false;
|
||||
const runtime = fixture(t, {
|
||||
fetch: async url => {
|
||||
const href = String(url);
|
||||
if (href.endsWith('/api/version')) {
|
||||
if (!engineReady) throw new Error('not listening');
|
||||
return { ok: true, status: 200, json: async () => ({ version: '5.4.19' }) };
|
||||
}
|
||||
if (href.includes('/api/ps5/status')) {
|
||||
return { ok: true, status: 200, json: async () => ({ version: '5.4.19', instance_id: payloadLoaded ? '2' : '1', started_at_unix: payloadLoaded ? 2 : 1 }) };
|
||||
}
|
||||
return { ok: true, status: 200, json: async () => ({ ok: true, users: [] }) };
|
||||
},
|
||||
spawn: () => { engineReady = true; return child; },
|
||||
waitForEngine: async () => ({ available: true, version: '5.4.19' }),
|
||||
tcpProbe: async input => input.port !== 9040,
|
||||
tcpSend: async input => { payloadLoaded = true; return { ok: true, bytes: input.bytes.length }; },
|
||||
});
|
||||
assert.equal((await runtime.runtimeIdentity()).status, 'unproven');
|
||||
await runtime.startEngine();
|
||||
assert.equal((await runtime.runtimeIdentity()).status, 'unproven');
|
||||
await runtime.startPayload();
|
||||
const exact = await runtime.runtimeIdentity();
|
||||
assert.equal(exact.status, 'exact');
|
||||
assert.equal(exact.mutationAllowed, true);
|
||||
assert.equal(exact.engine.evidence, 'chimera-owned-process-artifact');
|
||||
assert.equal(exact.payload.evidence, 'loader-input-hash-and-instance-transition');
|
||||
});
|
||||
|
||||
test('an exact same-namespace listener can be re-attested after a server restart', async t => {
|
||||
let spawnCalls = 0;
|
||||
const runtime = fixture(t, {
|
||||
spawn: () => { spawnCalls += 1; throw new Error('must not spawn over an exact existing listener'); },
|
||||
tcpProbe: async input => input.port !== 9040,
|
||||
inspectExistingEngine: async input => listenerIdentity(input),
|
||||
});
|
||||
|
||||
const adopted = await runtime.startEngine();
|
||||
assert.equal(adopted.available, true);
|
||||
assert.equal(adopted.processOwned, true);
|
||||
assert.equal(adopted.adopted, true);
|
||||
assert.equal(spawnCalls, 0);
|
||||
|
||||
const beforePayload = await runtime.runtimeIdentity();
|
||||
assert.equal(beforePayload.status, 'unproven');
|
||||
assert.equal(beforePayload.engine.evidence, LINUX_LISTENER_PROCESS_EVIDENCE);
|
||||
await runtime.startPayload();
|
||||
const exact = await runtime.runtimeIdentity();
|
||||
assert.equal(exact.status, 'exact');
|
||||
assert.equal(exact.mutationAllowed, true);
|
||||
assert.equal(exact.engine.evidence, LINUX_LISTENER_PROCESS_EVIDENCE);
|
||||
});
|
||||
|
||||
test('an existing endpoint is never adopted without exact local process provenance', async t => {
|
||||
let spawnCalls = 0;
|
||||
const runtime = fixture(t, {
|
||||
spawn: () => { spawnCalls += 1; throw new Error('must not spawn over an unowned endpoint'); },
|
||||
inspectExistingEngine: async () => ({ verified: false, reason: 'listener-owner-not-found' }),
|
||||
});
|
||||
|
||||
await assert.rejects(
|
||||
() => runtime.startEngine(),
|
||||
error => error.code === 'PS5UPLOAD_ENGINE_UNOWNED'
|
||||
&& error.details?.reason === 'listener-owner-not-found',
|
||||
);
|
||||
assert.equal(spawnCalls, 0);
|
||||
assert.equal((await runtime.runtimeIdentity()).status, 'unproven');
|
||||
});
|
||||
|
||||
test('a reachable wrong-lane profile is never inspected, signaled or spawned over', async t => {
|
||||
let inspections = 0;
|
||||
let spawnCalls = 0;
|
||||
let terminations = 0;
|
||||
const runtime = fixture(t, {
|
||||
fetch: async url => {
|
||||
if (String(url).endsWith('/api/version')) {
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ version: '5.4.19', diagnostic_identity: 'chimera-p15-shellui-observe-v1' }),
|
||||
};
|
||||
}
|
||||
throw new Error('unexpected request');
|
||||
},
|
||||
inspectExistingEngine: async input => { inspections += 1; return listenerIdentity(input); },
|
||||
spawn: () => { spawnCalls += 1; throw new Error('must not spawn over wrong lane'); },
|
||||
terminateProvenProcess: async () => { terminations += 1; return { terminated: true, identityGone: true }; },
|
||||
});
|
||||
|
||||
await assert.rejects(
|
||||
() => runtime.startEngine(),
|
||||
error => error.code === 'PS5UPLOAD_ENGINE_UNOWNED'
|
||||
&& error.details?.reason === 'runtime-profile-mismatch',
|
||||
);
|
||||
assert.equal(inspections, 0);
|
||||
assert.equal(spawnCalls, 0);
|
||||
assert.equal(terminations, 0);
|
||||
});
|
||||
|
||||
test('an adopted engine is revalidated before a bounded graceful stop', async t => {
|
||||
let terminated = false;
|
||||
const terminations = [];
|
||||
const runtime = fixture(t, {
|
||||
fetch: async url => {
|
||||
const href = String(url);
|
||||
if (href.endsWith('/api/version')) {
|
||||
if (terminated) throw new Error('released');
|
||||
return { ok: true, status: 200, json: async () => ({ version: '5.4.19' }) };
|
||||
}
|
||||
if (href.includes('/api/ps5/status')) return { ok: true, status: 200, json: async () => ({ version: '5.4.19', instance_id: '1', started_at_unix: 1 }) };
|
||||
return { ok: true, status: 200, json: async () => ({ ok: true, users: [] }) };
|
||||
},
|
||||
inspectExistingEngine: async input => listenerIdentity(input),
|
||||
terminateProvenProcess: async identity => {
|
||||
terminations.push(identity);
|
||||
terminated = true;
|
||||
return { terminated: true, identityGone: true, forced: false };
|
||||
},
|
||||
enginePortProbe: async () => !terminated,
|
||||
});
|
||||
|
||||
await runtime.startEngine();
|
||||
const stopped = await runtime.stopEngine();
|
||||
assert.deepEqual(stopped, { stopped: true, endpointReleased: true, identityGone: true, forced: false, adopted: true });
|
||||
assert.equal(terminations.length, 1);
|
||||
assert.equal(terminations[0].pid, 4242);
|
||||
});
|
||||
|
||||
test('adopted process identity drift fails closed without signaling any process', async t => {
|
||||
let inspections = 0;
|
||||
const terminations = [];
|
||||
const runtime = fixture(t, {
|
||||
inspectExistingEngine: async input => {
|
||||
inspections += 1;
|
||||
return listenerIdentity(input, { pid: inspections <= 2 ? 4242 : 5252 });
|
||||
},
|
||||
terminateProvenProcess: async identity => { terminations.push(identity); return { terminated: true, identityGone: true }; },
|
||||
});
|
||||
|
||||
await runtime.startEngine();
|
||||
const stopped = await runtime.stopEngine();
|
||||
assert.deepEqual(stopped, { stopped: false, endpointReleased: false, identityGone: false, reason: 'adopted-process-identity-drift' });
|
||||
assert.deepEqual(terminations, []);
|
||||
});
|
||||
|
||||
test('status clears adopted ownership and attestation immediately on identity drift', async t => {
|
||||
let inspections = 0;
|
||||
const runtime = fixture(t, {
|
||||
inspectExistingEngine: async input => {
|
||||
inspections += 1;
|
||||
return listenerIdentity(input, { startTimeTicks: inspections <= 2 ? '123456' : '654321' });
|
||||
},
|
||||
});
|
||||
|
||||
await runtime.startEngine();
|
||||
const status = await runtime.status();
|
||||
assert.equal(status.engine.available, true);
|
||||
assert.equal(status.engine.processOwned, false);
|
||||
assert.equal(status.engine.processOwnership, null);
|
||||
const identity = await runtime.runtimeIdentity();
|
||||
assert.equal(identity.status, 'unproven');
|
||||
assert.equal(identity.mutationAllowed, false);
|
||||
});
|
||||
|
||||
test('adopted helper failure remains fail-closed and keeps ownership available for retry', async t => {
|
||||
let terminations = 0;
|
||||
const runtime = fixture(t, {
|
||||
terminateProvenProcess: async () => {
|
||||
terminations += 1;
|
||||
return { terminated: false, identityGone: false, reason: 'pidfd-exit-timeout' };
|
||||
},
|
||||
});
|
||||
|
||||
await runtime.startEngine();
|
||||
const stopped = await runtime.stopEngine();
|
||||
assert.deepEqual(stopped, {
|
||||
stopped: false,
|
||||
endpointReleased: false,
|
||||
identityGone: false,
|
||||
reason: 'pidfd-exit-timeout',
|
||||
});
|
||||
assert.equal(terminations, 1);
|
||||
assert.equal((await runtime.status()).engine.processOwned, true);
|
||||
});
|
||||
@@ -0,0 +1,545 @@
|
||||
'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 {
|
||||
createPs5UploadPackageInstallerProvider,
|
||||
PINNED_PS5_PACKAGE_INSTALLER,
|
||||
} = require('../../src/services/ps5upload-package-installer-provider');
|
||||
const { createPs5NativePackageInstallService } = require('../../src/services/ps5-native-package-install-service');
|
||||
|
||||
function fixture() {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'chimera-ps5-fpkg-'));
|
||||
const filePath = path.join(root, 'EP9000-PPSA01234_00-CHIMERATEST00001.pkg');
|
||||
const bytes = Buffer.concat([Buffer.from([0x7f, 0x46, 0x49, 0x48]), Buffer.alloc(64, 0x5a)]);
|
||||
fs.writeFileSync(filePath, bytes);
|
||||
return {
|
||||
root,
|
||||
filePath,
|
||||
component: {
|
||||
componentId: 'base', type: 'base', size: bytes.length,
|
||||
sha256: crypto.createHash('sha256').update(bytes).digest('hex'),
|
||||
contentId: 'EP9000-PPSA01234_00-CHIMERATEST00001',
|
||||
metadata: { absolutePath: filePath, titleId: { value: 'PPSA01234' } },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function response(body, status = 200) {
|
||||
return { ok: status >= 200 && status < 300, status, json: async () => body, text: async () => typeof body === 'string' ? body : JSON.stringify(body) };
|
||||
}
|
||||
|
||||
function activeRuntimeProcesses() {
|
||||
return response({ processes: [
|
||||
{ pid: 107, name: 'kstuff.elf', comm: 'kstuff.elf', kind: 'payload' },
|
||||
{ pid: 108, name: 'etaHEN Utility Daemon', comm: 'etaHEN Utility Daemon', kind: 'system' },
|
||||
], truncated: false });
|
||||
}
|
||||
|
||||
function exactRuntimeIdentity() {
|
||||
return {
|
||||
observed: {
|
||||
engine: { revision: PINNED_PS5_PACKAGE_INSTALLER.revision, sha256: PINNED_PS5_PACKAGE_INSTALLER.artifacts.engineLinuxX64Sha256, evidence: 'running-process-bytes' },
|
||||
payload: { revision: PINNED_PS5_PACKAGE_INSTALLER.revision, sha256: PINNED_PS5_PACKAGE_INSTALLER.artifacts.payloadSha256, evidence: 'loader-input-and-instance-transition' },
|
||||
dpi: { active: false },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('PS5 FPKG provider pins 5.4.19, accepts only loopback and installs a hash-bound staged FIH .pkg', async t => {
|
||||
const item = fixture(); t.after(() => fs.rmSync(item.root, { recursive: true, force: true }));
|
||||
const calls = [];
|
||||
const fetch = async (url, options = {}) => {
|
||||
calls.push({ url: String(url), options, body: options.body ? JSON.parse(options.body) : null });
|
||||
if (String(url).endsWith('/api/version')) return response({ version: '5.4.19' });
|
||||
if (String(url).includes('/api/ps5/status')) return response({ version: '5.4.19', ucred_elevated: true });
|
||||
if (String(url).includes('/api/ps5/readiness')) return response({ ready: true });
|
||||
if (String(url).includes('/api/ps5/process/list')) return activeRuntimeProcesses();
|
||||
return response({ session_id: '7846dc5a-415c-4d72-bf1b-97fd08a887b4', err_code: 0, package_type: 'PS5GD', register_path: 'bgft', launchable: null });
|
||||
};
|
||||
const provider = createPs5UploadPackageInstallerProvider({ engineOrigin: 'http://127.0.0.1:19113', consoleHost: 'ps5.local', packageRoot: item.root, getRuntimeIdentity: exactRuntimeIdentity, fetch });
|
||||
const stagedPath = `/user/data/ps5upload/pkg_library/${item.component.contentId}.pkg`;
|
||||
const result = await provider.start({ component: item.component, stagedPath });
|
||||
assert.equal(PINNED_PS5_PACKAGE_INSTALLER.version, '5.4.19');
|
||||
assert.equal(result.installed, false);
|
||||
assert.equal(result.status, 'installing');
|
||||
assert.deepEqual(calls.find(call => call.body?.package_type_override).body, {
|
||||
ps5_addr: 'ps5.local:9114', path: null, split_root: null,
|
||||
package_type_override: 'PS5GD', local_ps5_path: stagedPath,
|
||||
content_id: item.component.contentId, expected_size: item.component.size,
|
||||
package_fingerprint: null, delete_staging: false, serve_only: false,
|
||||
});
|
||||
assert.equal(fs.existsSync(item.filePath), true);
|
||||
assert.throws(() => createPs5UploadPackageInstallerProvider({ engineOrigin: 'http://192.168.1.10:19113', consoleHost: 'ps5.local', packageRoot: item.root, fetch }), error => error.code === 'PS5_PKG_ENGINE_ORIGIN');
|
||||
});
|
||||
|
||||
test('PS5 FPKG provider follows the pinned standalone-DPI fallback and restores the main payload', async t => {
|
||||
const item = fixture(); t.after(() => fs.rmSync(item.root, { recursive: true, force: true }));
|
||||
const calls = []; let dpiEnsures = 0; let restores = 0;
|
||||
const provider = createPs5UploadPackageInstallerProvider({
|
||||
engineOrigin: 'http://127.0.0.1:19113', consoleHost: 'ps5.local', packageRoot: item.root,
|
||||
getRuntimeIdentity: exactRuntimeIdentity,
|
||||
ensureDpi: async () => { dpiEnsures += 1; return { ready: true }; },
|
||||
restorePayload: async () => { restores += 1; return { managementReady: true }; },
|
||||
fetch: async (url, options = {}) => {
|
||||
calls.push({ url: String(url), body: options.body ? JSON.parse(options.body) : null });
|
||||
if (String(url).endsWith('/api/version')) return response({ version: '5.4.19' });
|
||||
if (String(url).includes('/api/ps5/status')) return response({ version: '5.4.19', ucred_elevated: true });
|
||||
if (String(url).includes('/api/ps5/readiness')) return response({ ready: true });
|
||||
if (String(url).includes('/api/ps5/process/list')) return activeRuntimeProcesses();
|
||||
if (String(url).endsWith('/api/pkg/install/start')) return response({ error: 'payload PKG_INSTALL failed: frame rejected' }, 502);
|
||||
if (String(url).endsWith('/api/pkg/dpi-install')) return response({ ok: true, rc: 0, init_failed: false });
|
||||
return response({});
|
||||
},
|
||||
});
|
||||
const result = await provider.start({ component: item.component, stagedPath: `/user/data/ps5upload/pkg_library/${item.component.contentId}.pkg` });
|
||||
assert.equal(dpiEnsures, 1);
|
||||
assert.equal(restores, 1);
|
||||
assert.equal(result.status, 'accepted_unverified');
|
||||
assert.equal(result.route, 'dpi-staged');
|
||||
assert.equal(result.installed, false);
|
||||
assert.equal(result.postScanRequired, true);
|
||||
assert.deepEqual(calls.find(call => call.url.endsWith('/api/pkg/dpi-install')).body, { ps5_addr: 'ps5.local:9114', local_ps5_path: `/user/data/ps5upload/pkg_library/${item.component.contentId}.pkg` });
|
||||
});
|
||||
|
||||
test('PS5 FPKG provider prefers a verified active etaHEN DPI v2 stream and skips AppInst plus payload swapping', async t => {
|
||||
const item = fixture(); t.after(() => fs.rmSync(item.root, { recursive: true, force: true }));
|
||||
let primaryStarts = 0; let dpiEnsures = 0; let restores = 0; let directInstalls = 0;
|
||||
const dpiV2Installer = {
|
||||
probe: async () => ({ available: true, protocol: 'etahen-dpi-v2-url-v1' }),
|
||||
install: async artifact => {
|
||||
directInstalls += 1;
|
||||
assert.equal(artifact.filePath, item.filePath);
|
||||
assert.equal(artifact.sha256, item.component.sha256);
|
||||
return { accepted: true, installed: false, postScanRequired: true, response: 'SUCCESS: queued', responseStatus: 200, responseSha256: 'b'.repeat(64) };
|
||||
},
|
||||
};
|
||||
const provider = createPs5UploadPackageInstallerProvider({
|
||||
engineOrigin: 'http://127.0.0.1:19113', consoleHost: 'ps5.local', packageRoot: item.root,
|
||||
getRuntimeIdentity: exactRuntimeIdentity,
|
||||
getEtaHenConfig: async () => ({ values: { PS5Debug: '1', DPI: '0', DPI_v2: '1' } }),
|
||||
dpiV2Installer,
|
||||
ensureDpi: async () => { dpiEnsures += 1; },
|
||||
restorePayload: async () => { restores += 1; },
|
||||
fetch: async url => {
|
||||
if (String(url).endsWith('/api/version')) return response({ version: '5.4.19' });
|
||||
if (String(url).includes('/api/ps5/status')) return response({ version: '5.4.19', ucred_elevated: true });
|
||||
if (String(url).includes('/api/ps5/readiness')) return response({ ready: true });
|
||||
if (String(url).includes('/api/ps5/process/list')) return activeRuntimeProcesses();
|
||||
if (String(url).endsWith('/api/pkg/install/start')) primaryStarts += 1;
|
||||
return response({ session_id: 'should-not-start', err_code: 0 });
|
||||
},
|
||||
});
|
||||
const result = await provider.start({ component: item.component, stagedPath: `/user/data/ps5upload/pkg_library/${item.component.contentId}.pkg` });
|
||||
assert.equal(result.status, 'accepted_unverified');
|
||||
assert.equal(result.route, 'etahen-dpi-v2-url');
|
||||
assert.equal(result.installed, false);
|
||||
assert.equal(result.postScanRequired, true);
|
||||
assert.equal(result.providerEvidence.response, 'SUCCESS: queued');
|
||||
assert.equal(directInstalls, 1);
|
||||
assert.equal(primaryStarts, 0);
|
||||
assert.equal(dpiEnsures, 0);
|
||||
assert.equal(restores, 0);
|
||||
});
|
||||
|
||||
test('PS5 FPKG direct etaHEN DPI v2 preflight does not require PS5Upload engine or management payload', async t => {
|
||||
const item = fixture(); t.after(() => fs.rmSync(item.root, { recursive: true, force: true }));
|
||||
let managementCalls = 0;
|
||||
const dpiV2Installer = {
|
||||
probe: async () => ({ available: true, protocol: 'etahen-dpi-v2-url-v1' }),
|
||||
install: async () => ({ accepted: true, installed: false, postScanRequired: true }),
|
||||
};
|
||||
const provider = createPs5UploadPackageInstallerProvider({
|
||||
engineOrigin: 'http://127.0.0.1:19113', consoleHost: 'ps5.local', packageRoot: item.root,
|
||||
getEtaHenConfig: async () => ({ values: { PS5Debug: '1', DPI: '0', DPI_v2: '1' } }),
|
||||
dpiV2Installer,
|
||||
fetch: async url => {
|
||||
if (String(url).endsWith('/api/version')) return response({ error: 'engine offline' }, 503);
|
||||
managementCalls += 1;
|
||||
return response({ error: 'management offline' }, 503);
|
||||
},
|
||||
});
|
||||
const readiness = await provider.preflightInstall({ components: [item.component] });
|
||||
assert.equal(readiness.ready, true);
|
||||
assert.equal(readiness.installStrategy, 'etahen-dpi-v2-url');
|
||||
assert.equal(readiness.dpiV2.available, true);
|
||||
assert.deepEqual(readiness.capabilities, {
|
||||
artifactReady: true,
|
||||
etaHenDpiV2Configured: true,
|
||||
etaHenDpiV2IdentityReady: true,
|
||||
});
|
||||
assert.equal(readiness.runtimeIdentity, null);
|
||||
assert.equal(readiness.runtimeIdentityRequired, false);
|
||||
assert.equal(managementCalls, 0);
|
||||
const result = await provider.start({ component: item.component, stagedPath: `/user/data/ps5upload/pkg_library/${item.component.contentId}.pkg` });
|
||||
assert.equal(result.route, 'etahen-dpi-v2-url');
|
||||
});
|
||||
|
||||
test('PS5 FPKG provider preserves the upstream error body when no DPI lifecycle is bound', async t => {
|
||||
const item = fixture(); t.after(() => fs.rmSync(item.root, { recursive: true, force: true }));
|
||||
const provider = createPs5UploadPackageInstallerProvider({
|
||||
engineOrigin: 'http://127.0.0.1:19113', consoleHost: 'ps5.local', packageRoot: item.root,
|
||||
getRuntimeIdentity: exactRuntimeIdentity,
|
||||
fetch: async url => {
|
||||
if (String(url).endsWith('/api/version')) return response({ version: '5.4.19' });
|
||||
if (String(url).includes('/api/ps5/status')) return response({ version: '5.4.19', ucred_elevated: true });
|
||||
if (String(url).includes('/api/ps5/readiness')) return response({ ready: true });
|
||||
if (String(url).includes('/api/ps5/process/list')) return activeRuntimeProcesses();
|
||||
return response({ error: 'payload PKG_INSTALL failed: unknown frame' }, 502);
|
||||
},
|
||||
});
|
||||
await assert.rejects(
|
||||
() => provider.start({ component: item.component, stagedPath: `/user/data/ps5upload/pkg_library/${item.component.contentId}.pkg` }),
|
||||
error => error.code === 'PS5_PKG_PROVIDER_HTTP' && /unknown frame/.test(error.message) && /unknown frame/.test(error.details?.upstream || ''),
|
||||
);
|
||||
});
|
||||
|
||||
test('PS5 FPKG provider rejects old pseudo formats, wrong hashes, traversal and unsupported updates', async t => {
|
||||
const item = fixture(); t.after(() => fs.rmSync(item.root, { recursive: true, force: true }));
|
||||
const provider = createPs5UploadPackageInstallerProvider({ engineOrigin: 'http://localhost:19113', consoleHost: 'ps5.local', packageRoot: item.root, fetch: async () => response({ version: '5.4.19' }) });
|
||||
const wrongExtension = { ...item.component, metadata: { ...item.component.metadata, absolutePath: path.join(item.root, 'legacy.ffpkg') } };
|
||||
fs.copyFileSync(item.filePath, wrongExtension.metadata.absolutePath);
|
||||
const stagedPath = `/user/data/ps5upload/pkg_library/${item.component.contentId}.pkg`;
|
||||
await assert.rejects(() => provider.start({ component: wrongExtension, stagedPath }), error => error.code === 'PS5_PKG_EXTENSION');
|
||||
await assert.rejects(() => provider.start({ component: { ...item.component, sha256: '0'.repeat(64) }, stagedPath }), error => error.code === 'PS5_FPKG_ARTIFACT_HASH_DRIFT');
|
||||
await assert.rejects(() => provider.start({ component: { ...item.component, type: 'update' }, stagedPath }), error => error.code === 'PS5_PKG_COMPONENT_TYPE');
|
||||
const outside = path.join(os.tmpdir(), `outside-${crypto.randomUUID()}.pkg`); fs.copyFileSync(item.filePath, outside); t.after(() => fs.rmSync(outside, { force: true }));
|
||||
await assert.rejects(() => provider.start({ component: { ...item.component, metadata: { ...item.component.metadata, absolutePath: outside } }, stagedPath }), error => error.code === 'PS5_PKG_PATH');
|
||||
await assert.rejects(() => provider.start({ component: item.component, stagedPath: '/data/arbitrary.pkg' }), error => error.code === 'PS5_PKG_STAGING_PATH');
|
||||
});
|
||||
|
||||
test('PS5 FPKG provider maps DLC to PS5AC and never equates acceptance with installation', async t => {
|
||||
const item = fixture(); t.after(() => fs.rmSync(item.root, { recursive: true, force: true }));
|
||||
let statusBody = { session_id: 's1', phase: 'done', downloaded: 68, total: 68, err_code: 0, accepted_unverified: true, stalled: false, launchable: true };
|
||||
const calls = [];
|
||||
const fetch = async (url, options = {}) => {
|
||||
calls.push({ url: String(url), body: options.body ? JSON.parse(options.body) : null });
|
||||
if (String(url).endsWith('/api/version')) return response({ version: '5.4.19' });
|
||||
if (String(url).includes('/api/ps5/status')) return response({ version: '5.4.19', ucred_elevated: true });
|
||||
if (String(url).includes('/api/ps5/readiness')) return response({ ready: true });
|
||||
if (String(url).includes('/api/ps5/process/list')) return activeRuntimeProcesses();
|
||||
if (String(url).includes('/api/pkg/install/status')) return response(statusBody);
|
||||
return response({ session_id: 's1', err_code: 0, package_type: 'PS5AC' });
|
||||
};
|
||||
const provider = createPs5UploadPackageInstallerProvider({ engineOrigin: 'http://[::1]:19113', consoleHost: '10.0.0.20', packageRoot: item.root, getRuntimeIdentity: exactRuntimeIdentity, fetch });
|
||||
const started = await provider.start({ component: { ...item.component, type: 'dlc' }, stagedPath: `/user/data/ps5upload/pkg_library/${item.component.contentId}.pkg` });
|
||||
assert.equal(calls.find(call => call.body?.package_type_override).body.package_type_override, 'PS5AC');
|
||||
assert.equal(started.installed, false);
|
||||
assert.equal((await provider.status({ sessionId: 's1' })).providerVerified, false);
|
||||
statusBody = { ...statusBody, accepted_unverified: false };
|
||||
const complete = await provider.status({ sessionId: 's1' });
|
||||
assert.equal(complete.providerVerified, true);
|
||||
assert.equal(complete.installed, false);
|
||||
assert.equal(complete.postScanRequired, true);
|
||||
});
|
||||
|
||||
test('PS5 FPKG provider fails fast on Sony AppInst rejection and preserves route evidence', async t => {
|
||||
const item = fixture(); t.after(() => fs.rmSync(item.root, { recursive: true, force: true }));
|
||||
const provider = createPs5UploadPackageInstallerProvider({
|
||||
engineOrigin: 'http://127.0.0.1:19113', consoleHost: 'ps5.local', packageRoot: item.root,
|
||||
fetch: async url => {
|
||||
if (String(url).endsWith('/api/version')) return response({ version: '5.4.19' });
|
||||
if (String(url).includes('/api/pkg/install/status')) return response({
|
||||
session_id: 's-appinst', phase: 'install', downloaded: 0, total: item.component.size,
|
||||
err_code: 0, appinst_err: 0x80B2116F, shellui_err: 0, register_path: 'shellui-rpc',
|
||||
intdebug_avail: false, kernel_rw: true, installed_bytes: item.component.size - 32_188,
|
||||
accepted_unverified: false, stalled: false, launchable: null,
|
||||
});
|
||||
return response({ ready: true });
|
||||
},
|
||||
});
|
||||
const snapshot = await provider.status({ sessionId: 's-appinst' });
|
||||
assert.equal(snapshot.phase, 'error');
|
||||
assert.equal(snapshot.errCode, 0x80B2116F);
|
||||
assert.match(snapshot.errMessage, /0x80B2116F/);
|
||||
assert.equal(snapshot.terminalReason, 'sony-appinst-rejected');
|
||||
assert.equal(snapshot.route, 'shellui-rpc');
|
||||
assert.equal(snapshot.intdebugAvailable, false);
|
||||
assert.equal(snapshot.kernelRw, true);
|
||||
assert.equal(snapshot.appInstError, 0x80B2116F);
|
||||
assert.equal(snapshot.shellUiError, 0);
|
||||
assert.equal(snapshot.providerVerified, false);
|
||||
});
|
||||
|
||||
test('PS5 FPKG preflight records etaHEN PS5Debug as diagnostic evidence without treating it as package trust', async t => {
|
||||
const item = fixture(); t.after(() => fs.rmSync(item.root, { recursive: true, force: true }));
|
||||
let config = { PS5Debug: '0', DPI: '0', DPI_v2: '0' };
|
||||
let mutations = 0;
|
||||
const provider = createPs5UploadPackageInstallerProvider({
|
||||
engineOrigin: 'http://127.0.0.1:19113', consoleHost: 'ps5.local', packageRoot: item.root,
|
||||
getEtaHenConfig: async () => ({ values: config }),
|
||||
fetch: async (url, options = {}) => {
|
||||
if (options.method === 'POST') mutations += 1;
|
||||
if (String(url).endsWith('/api/version')) return response({ version: '5.4.19' });
|
||||
if (String(url).includes('/api/ps5/status')) return response({ version: '5.4.19', ucred_elevated: true, instance_id: '7673539444651544000', started_at_unix: 1786635128, startup_reason: 1 });
|
||||
if (String(url).includes('/api/ps5/readiness')) return response({ ready: true });
|
||||
if (String(url).includes('/api/ps5/process/list')) return activeRuntimeProcesses();
|
||||
return response({});
|
||||
},
|
||||
});
|
||||
const diagnostic = await provider.preflight({ components: [item.component] });
|
||||
assert.equal(diagnostic.ready, true);
|
||||
assert.equal(diagnostic.runtime.ps5Debug, false);
|
||||
assert.equal(diagnostic.runtime.required, false);
|
||||
assert.equal(diagnostic.runtime.reason, 'ps5debug-disabled-diagnostic-only');
|
||||
assert.equal(diagnostic.console.instanceId, '7673539444651544000');
|
||||
assert.equal(diagnostic.console.startedAtUnix, 1786635128);
|
||||
assert.equal(diagnostic.console.startupReason, 1);
|
||||
assert.equal(mutations, 0);
|
||||
|
||||
config = { ...config, PS5Debug: '1' };
|
||||
const ready = await provider.preflight({ components: [item.component] });
|
||||
assert.equal(ready.ready, true);
|
||||
assert.equal(ready.runtime.ps5Debug, true);
|
||||
assert.equal(ready.runtime.verified, true);
|
||||
});
|
||||
|
||||
test('PS5 FPKG install job persists terminal provider diagnostics instead of waiting for generic done', async t => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'chimera-ps5-fpkg-terminal-')); t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
const provider = {
|
||||
preflight: async () => ({ ready: true }),
|
||||
start: async () => ({ status: 'installing', sessionId: 's-terminal', errCode: 0 }),
|
||||
status: async () => ({
|
||||
sessionId: 's-terminal', phase: 'error', downloaded: 0, total: 68, installedBytes: 36,
|
||||
errCode: 0x80B2116F, errMessage: 'Sony AppInst rejected the package (0x80B2116F).',
|
||||
terminalReason: 'sony-appinst-rejected', route: 'shellui-rpc', appInstError: 0x80B2116F,
|
||||
shellUiError: 0, intdebugAvailable: false, kernelRw: true, providerVerified: false,
|
||||
acceptedUnverified: false, stalled: false, launchable: null,
|
||||
}),
|
||||
cancel: async () => ({ cancelled: true }),
|
||||
};
|
||||
const service = createPs5NativePackageInstallService({ provider, stager: async () => ({ stagedPath: '/user/data/ps5upload/pkg_library/test.pkg' }), stateFile: path.join(root, 'jobs.json'), pollMs: 5 });
|
||||
const operation = await service.start({ titleId: 'PPSA01234', packageSet: { id: 7, components: [{ componentId: 'base', type: 'base', order: 1, sha256: 'a'.repeat(64), size: 68 }] } });
|
||||
let current = operation;
|
||||
for (let attempt = 0; attempt < 30 && current.status !== 'blocked'; attempt += 1) {
|
||||
await new Promise(resolve => setTimeout(resolve, 10)); current = service.get(operation.id);
|
||||
}
|
||||
assert.equal(current.status, 'blocked');
|
||||
assert.equal(current.error.code, 'PS5_PKG_INSTALL_REJECTED');
|
||||
assert.equal(current.error.terminalReason, 'sony-appinst-rejected');
|
||||
assert.equal(current.error.route, 'shellui-rpc');
|
||||
assert.equal(current.error.appInstError, 0x80B2116F);
|
||||
assert.equal(current.error.intdebugAvailable, false);
|
||||
assert.equal(current.components[0].status, 'rejected');
|
||||
assert.equal(current.installed, false);
|
||||
});
|
||||
|
||||
test('PS5 FPKG install job performs one deferred DPI fallback after a terminal AppInst rejection', async t => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'chimera-ps5-fpkg-deferred-dpi-')); t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
let fallbackCalls = 0;
|
||||
const provider = {
|
||||
preflight: async () => ({ ready: true }),
|
||||
start: async () => ({ status: 'installing', sessionId: 's-primary', errCode: 0 }),
|
||||
status: async () => ({
|
||||
sessionId: 's-primary', phase: 'error', reportedPhase: 'install', downloaded: 0, total: 68, installedBytes: 0,
|
||||
errCode: 0x80B2116F, errMessage: 'Sony AppInst rejected the package (0x80B2116F).',
|
||||
terminalReason: 'sony-appinst-rejected', route: 'shellui-rpc', appInstError: 0x80B2116F,
|
||||
shellUiError: 0, intdebugAvailable: false, kernelRw: true, providerVerified: false,
|
||||
acceptedUnverified: false, stalled: false, launchable: null,
|
||||
}),
|
||||
fallback: async ({ stagedPath, primaryEvidence }) => {
|
||||
fallbackCalls += 1;
|
||||
assert.equal(stagedPath, '/user/data/ps5upload/pkg_library/test.pkg');
|
||||
assert.equal(primaryEvidence.appInstError, 0x80B2116F);
|
||||
return { status: 'accepted_unverified', sessionId: 'dpi-deferred', errCode: 0, route: 'dpi-staged', acceptedUnverified: true, providerVerified: false };
|
||||
},
|
||||
cancel: async () => ({ cancelled: true }),
|
||||
};
|
||||
const service = createPs5NativePackageInstallService({ provider, stager: async () => ({ stagedPath: '/user/data/ps5upload/pkg_library/test.pkg' }), stateFile: path.join(root, 'jobs.json'), pollMs: 5 });
|
||||
const operation = await service.start({ titleId: 'PPSA99099', packageSet: { id: 5, components: [{ componentId: 'base', type: 'base', order: 1, sha256: 'c'.repeat(64), size: 68 }] } });
|
||||
let current = operation;
|
||||
for (let attempt = 0; attempt < 30 && current.status !== 'awaiting_post_scan'; attempt += 1) {
|
||||
await new Promise(resolve => setTimeout(resolve, 10)); current = service.get(operation.id);
|
||||
}
|
||||
assert.equal(current.status, 'awaiting_post_scan');
|
||||
assert.equal(current.installed, false);
|
||||
assert.equal(current.components[0].dpiFallbackAttempted, true);
|
||||
assert.equal(current.components[0].primaryRejection.route, 'shellui-rpc');
|
||||
assert.equal(current.components[0].route, 'dpi-staged');
|
||||
assert.equal(fallbackCalls, 1);
|
||||
});
|
||||
|
||||
test('PS5 FPKG install job treats DPI acceptance as unproven until the independent post-scan', async t => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'chimera-ps5-fpkg-dpi-')); t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
const provider = {
|
||||
preflight: async () => ({ ready: true }),
|
||||
start: async () => ({ status: 'accepted_unverified', sessionId: 'dpi-test', errCode: 0, route: 'dpi-staged', acceptedUnverified: true, providerVerified: false }),
|
||||
status: async () => { throw new Error('DPI acceptance must not use the main-payload status poll'); },
|
||||
cancel: async () => ({ cancelled: true }),
|
||||
};
|
||||
const service = createPs5NativePackageInstallService({ provider, stager: async () => ({ stagedPath: '/user/data/ps5upload/pkg_library/test.pkg' }), stateFile: path.join(root, 'jobs.json'), pollMs: 5 });
|
||||
const operation = await service.start({ titleId: 'PPSA99099', packageSet: { id: 5, components: [{ componentId: 'base', type: 'base', order: 1, sha256: 'a'.repeat(64), size: 68 }] } });
|
||||
let current = operation;
|
||||
for (let attempt = 0; attempt < 30 && current.status !== 'awaiting_post_scan'; attempt += 1) {
|
||||
await new Promise(resolve => setTimeout(resolve, 10)); current = service.get(operation.id);
|
||||
}
|
||||
assert.equal(current.status, 'awaiting_post_scan');
|
||||
assert.equal(current.installed, false);
|
||||
assert.equal(current.components[0].status, 'accepted_unverified');
|
||||
assert.equal(current.components[0].providerVerified, false);
|
||||
assert.equal(current.components[0].route, 'dpi-staged');
|
||||
});
|
||||
|
||||
test('PS5 FPKG direct etaHEN URL handoff skips redundant FTP staging and persists bounded provider evidence', async t => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'chimera-ps5-fpkg-direct-url-')); t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
let stages = 0; let stagedPathSeen = 'unset';
|
||||
const provider = {
|
||||
preflight: async () => ({ ready: true, runtime: { dpiV2: true }, installStrategy: 'etahen-dpi-v2-url' }),
|
||||
start: async ({ stagedPath }) => {
|
||||
stagedPathSeen = stagedPath;
|
||||
return { status: 'accepted_unverified', sessionId: 'dpi-url', errCode: 0, route: 'etahen-dpi-v2-url', acceptedUnverified: true, providerVerified: false, providerEvidence: { responseStatus: 200, response: 'SUCCESS: queued', responseSha256: 'c'.repeat(64) } };
|
||||
},
|
||||
status: async () => { throw new Error('URL handoff must not poll the management payload'); }, cancel: async () => ({ cancelled: true }),
|
||||
};
|
||||
const service = createPs5NativePackageInstallService({ provider, stager: async () => { stages += 1; return { stagedPath: '/should/not/be/used.pkg' }; }, stateFile: path.join(root, 'jobs.json') });
|
||||
const operation = await service.start({ titleId: 'PPSA99099', packageSet: { id: 9, components: [{ componentId: 'base', type: 'base', order: 1, sha256: 'd'.repeat(64), size: 68 }] } });
|
||||
let current = operation;
|
||||
for (let attempt = 0; attempt < 30 && current.status !== 'awaiting_post_scan'; attempt += 1) { await new Promise(resolve => setTimeout(resolve, 10)); current = service.get(operation.id); }
|
||||
assert.equal(current.status, 'awaiting_post_scan');
|
||||
assert.equal(stages, 0);
|
||||
assert.equal(stagedPathSeen, null);
|
||||
assert.equal(current.components[0].providerEvidence.response, 'SUCCESS: queued');
|
||||
assert.equal(current.installed, false);
|
||||
});
|
||||
|
||||
test('PS5 FPKG install job blocks an identical hash after proven AppInst rejection before staging', async t => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'chimera-ps5-fpkg-known-rejection-')); t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
const stateFile = path.join(root, 'jobs.json');
|
||||
const sha256 = 'eafb572e94b72b4b714f8f45a3316abb957fdca4a23ef5c44741c9deb5a16d0c';
|
||||
const readiness = { ready: true, engine: { actualVersion: '5.2.1' }, runtime: { ps5Debug: false, dpi: false, dpiV2: false }, console: { runtimeVersion: '5.2.1', kernel: '9.60', ucredElevated: true, instanceId: '7673539444651544000', startedAtUnix: 1786635128, startupReason: 1 } };
|
||||
const runtimeFingerprint = crypto.createHash('sha256').update(JSON.stringify({ engineVersion: '5.2.1', ps5Debug: false, dpi: false, dpiV2: false, managementVersion: '5.2.1', kernel: '9.60', ucredElevated: true, managementInstanceId: '7673539444651544000', managementStartedAtUnix: 1786635128, managementStartupReason: 1, installStrategy: 'primary-then-deferred-dpi-v1' })).digest('hex');
|
||||
fs.writeFileSync(stateFile, JSON.stringify([{
|
||||
id: 'previous-rejection', packageSetId: 4, titleId: 'PPSA17330', status: 'blocked', installed: false,
|
||||
createdAt: 1, updatedAt: 2, runtimeFingerprint,
|
||||
components: [{ componentId: 'base', type: 'base', order: 0, sha256, size: 1358617285, installed: false }],
|
||||
error: { code: 'PS5_PKG_INSTALL_REJECTED', terminalReason: 'sony-appinst-rejected', appInstError: 0x80B2116F, route: 'shellui-rpc' },
|
||||
}]));
|
||||
let preflights = 0; let stages = 0;
|
||||
const provider = {
|
||||
preflight: async () => { preflights += 1; return readiness; },
|
||||
start: async () => ({ status: 'installing', sessionId: 'should-not-start', errCode: 0 }),
|
||||
status: async () => ({ phase: 'install' }), cancel: async () => ({ cancelled: true }),
|
||||
};
|
||||
const service = createPs5NativePackageInstallService({ provider, stager: async () => { stages += 1; return { stagedPath: '/user/data/ps5upload/pkg_library/test.pkg' }; }, stateFile });
|
||||
await assert.rejects(
|
||||
() => service.start({ titleId: 'PPSA17330', packageSet: { id: 4, components: [{ componentId: 'base', type: 'base', order: 1, sha256, size: 1358617285 }] } }),
|
||||
error => error.code === 'PS5_PKG_INSTALL_KNOWN_REJECTED'
|
||||
&& error.details?.previousOperationId === 'previous-rejection'
|
||||
&& error.details?.appInstError === 0x80B2116F,
|
||||
);
|
||||
assert.equal(preflights, 1);
|
||||
assert.equal(stages, 0);
|
||||
});
|
||||
|
||||
test('PS5 FPKG install job permits one retry after a new management instance and blocks a repeat in that instance', async t => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'chimera-ps5-fpkg-runtime-retry-')); t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
const stateFile = path.join(root, 'jobs.json'); const sha256 = 'b'.repeat(64);
|
||||
const previousRuntimeFingerprint = crypto.createHash('sha256').update(JSON.stringify({ engineVersion: '5.2.1', ps5Debug: true, dpi: true, dpiV2: false, managementVersion: '5.2.1', kernel: '9.60', ucredElevated: true, managementInstanceId: '111', managementStartedAtUnix: 1786635000, managementStartupReason: 1, installStrategy: 'primary-then-deferred-dpi-v1' })).digest('hex');
|
||||
fs.writeFileSync(stateFile, JSON.stringify([{
|
||||
id: 'previous-instance-rejection', packageSetId: 5, titleId: 'PPSA99099', status: 'blocked', installed: false,
|
||||
createdAt: 1, updatedAt: 2, runtimeFingerprint: previousRuntimeFingerprint, components: [{ componentId: 'base', type: 'base', order: 0, sha256, size: 68, installed: false }],
|
||||
error: { code: 'PS5_PKG_INSTALL_REJECTED', terminalReason: 'sony-appinst-rejected', appInstError: 0x80B2116F, route: 'shellui-rpc' },
|
||||
}]));
|
||||
let stages = 0;
|
||||
const provider = {
|
||||
preflight: async () => ({ ready: true, engine: { actualVersion: '5.2.1' }, runtime: { ps5Debug: true, dpi: true, dpiV2: false }, console: { runtimeVersion: '5.2.1', kernel: '9.60', ucredElevated: true, instanceId: '222', startedAtUnix: 1786635128, startupReason: 1 } }),
|
||||
start: async () => ({ status: 'rejected', sessionId: 'runtime-retry', errCode: 0x80B2116F, errMessage: 'rejected', terminalReason: 'sony-appinst-rejected', route: 'shellui-rpc', appInstError: 0x80B2116F }),
|
||||
status: async () => ({ phase: 'error' }), cancel: async () => ({ cancelled: true }),
|
||||
};
|
||||
const service = createPs5NativePackageInstallService({ provider, stager: async () => { stages += 1; return { stagedPath: '/user/data/ps5upload/pkg_library/test.pkg' }; }, stateFile, pollMs: 5 });
|
||||
const operation = await service.start({ titleId: 'PPSA99099', packageSet: { id: 5, components: [{ componentId: 'base', type: 'base', order: 1, sha256, size: 68 }] } });
|
||||
assert.match(operation.runtimeFingerprint, /^[a-f0-9]{64}$/);
|
||||
let current = operation;
|
||||
for (let attempt = 0; attempt < 20 && current.status !== 'blocked'; attempt += 1) {
|
||||
await new Promise(resolve => setTimeout(resolve, 5)); current = service.get(operation.id);
|
||||
}
|
||||
assert.equal(current.status, 'blocked');
|
||||
assert.equal(stages, 1);
|
||||
await assert.rejects(
|
||||
() => service.start({ titleId: 'PPSA99099', packageSet: { id: 5, components: [{ componentId: 'base', type: 'base', order: 1, sha256, size: 68 }] } }),
|
||||
error => error.code === 'PS5_PKG_INSTALL_KNOWN_REJECTED' && error.details?.previousOperationId === operation.id,
|
||||
);
|
||||
assert.equal(stages, 1);
|
||||
});
|
||||
|
||||
test('PS5 FPKG provider blocks a non-pinned engine before mutation', async t => {
|
||||
const item = fixture(); t.after(() => fs.rmSync(item.root, { recursive: true, force: true }));
|
||||
let mutations = 0;
|
||||
const provider = createPs5UploadPackageInstallerProvider({ engineOrigin: 'http://127.0.0.1:19113', consoleHost: 'ps5.local', packageRoot: item.root, fetch: async (_url, options = {}) => { if (options.method === 'POST') mutations += 1; return response({ version: '5.1.0' }); } });
|
||||
await assert.rejects(() => provider.start({ component: item.component, stagedPath: `/user/data/ps5upload/pkg_library/${item.component.contentId}.pkg` }), error => error.code === 'PS5_PKG_ENGINE_VERSION');
|
||||
assert.equal(mutations, 0);
|
||||
});
|
||||
|
||||
test('PS5 FPKG provider requires live management readiness before install start', async t => {
|
||||
const item = fixture(); t.after(() => fs.rmSync(item.root, { recursive: true, force: true }));
|
||||
let installStarts = 0;
|
||||
const provider = createPs5UploadPackageInstallerProvider({ engineOrigin: 'http://127.0.0.1:19113', consoleHost: 'ps5.local', packageRoot: item.root, fetch: async (url, options = {}) => {
|
||||
if (String(url).endsWith('/api/version')) return response({ version: '5.4.19' });
|
||||
if (String(url).includes('/api/ps5/readiness')) return response({ ready: false, detail: 'console busy' });
|
||||
if (options.method === 'POST') installStarts += 1;
|
||||
return response({});
|
||||
} });
|
||||
await assert.rejects(() => provider.start({ component: item.component, stagedPath: `/user/data/ps5upload/pkg_library/${item.component.contentId}.pkg` }), error => error.code === 'PS5_PKG_CONSOLE_NOT_READY');
|
||||
assert.equal(installStarts, 0);
|
||||
});
|
||||
|
||||
test('PS5 FPKG install job sequences base before DLC and stops at the independent post-scan gate', async t => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'chimera-ps5-fpkg-job-')); t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
const started = [];
|
||||
const provider = {
|
||||
preflight: async ({ components }) => ({ ready: components.length === 2 }),
|
||||
start: async ({ component }) => { started.push(component.type); return { status: 'installing', sessionId: `session-${component.type}`, errCode: 0 }; },
|
||||
status: async ({ sessionId }) => ({ sessionId, phase: 'done', downloaded: 100, total: 100, installedBytes: 100, errCode: 0, errMessage: '', detail: '', providerVerified: true, acceptedUnverified: false, stalled: false, launchable: true }),
|
||||
cancel: async () => ({ cancelled: true }),
|
||||
};
|
||||
const service = createPs5NativePackageInstallService({ provider, stager: async ({ component }) => ({ stagedPath: `/user/data/ps5upload/pkg_library/${component.type}.pkg` }), stateFile: path.join(root, 'jobs.json'), pollMs: 5 });
|
||||
const operation = await service.start({ titleId: 'PPSA01234', packageSet: { id: 7, components: [{ componentId: 'dlc', type: 'dlc', order: 20, sha256: 'b'.repeat(64), size: 2 }, { componentId: 'base', type: 'base', order: 10, sha256: 'a'.repeat(64), size: 1 }] } });
|
||||
let current = operation;
|
||||
for (let attempt = 0; attempt < 30 && current.status !== 'awaiting_post_scan'; attempt += 1) {
|
||||
await new Promise(resolve => setTimeout(resolve, 10)); current = service.get(operation.id);
|
||||
}
|
||||
assert.deepEqual(started, ['base', 'dlc']);
|
||||
assert.equal(current.status, 'awaiting_post_scan');
|
||||
assert.equal(current.installed, false);
|
||||
assert.equal(current.components.every(component => component.providerVerified && component.installed === false), true);
|
||||
const completed = service.markPostScan(operation.id, { source: 'post-install-console-scan' });
|
||||
assert.equal(completed.status, 'installed_observed');
|
||||
assert.equal(completed.installed, true);
|
||||
assert.equal(completed.installedContentVerified, false);
|
||||
assert.equal(completed.launchVerified, false);
|
||||
assert.equal(completed.productionRestorable, false);
|
||||
});
|
||||
|
||||
test('server, Electron, web and PS5 workspace expose the guarded .pkg install lifecycle', () => {
|
||||
const root = path.resolve(__dirname, '../..');
|
||||
const registry = fs.readFileSync(path.join(root, 'src/server/create-registry.js'), 'utf8');
|
||||
const preload = fs.readFileSync(path.join(root, 'preload.js'), 'utf8');
|
||||
const web = fs.readFileSync(path.join(root, 'renderer/pork-web-shim.js'), 'utf8');
|
||||
const modal = fs.readFileSync(path.join(root, 'renderer/modules/modal.js'), 'utf8');
|
||||
const html = fs.readFileSync(path.join(root, 'renderer/index.html'), 'utf8');
|
||||
const dockerfile = fs.readFileSync(path.join(root, 'Dockerfile'), 'utf8');
|
||||
for (const command of ['ps5-native-packages:restore-plan', 'ps5-native-packages:restore', 'ps5-native-packages:restore-status', 'ps5-native-packages:restore-cancel', 'ps5-native-packages:restore-verify']) assert.match(registry, new RegExp(command));
|
||||
assert.match(registry, /confirmation: 'INSTALL PS5 DEBUG PACKAGE'/);
|
||||
assert.match(registry, /\^\[A-Za-z0-9_-\]\{1,64\}\$/);
|
||||
assert.match(registry, /await hashFile\(localPath\) !== component\.sha256/);
|
||||
assert.match(registry, /PS5_FPKG_ARTIFACT_HASH_DRIFT/);
|
||||
assert.match(registry, /ci\.last_seen_at AS observed_at/);
|
||||
assert.doesNotMatch(registry, /ci\.observed_at/);
|
||||
assert.match(preload, /ps5NativePackageRestoreStatus/);
|
||||
assert.match(web, /ps5NativePackageRestoreStatus/);
|
||||
assert.match(web, /ps5NativePackageRestoreList/);
|
||||
assert.match(modal, /monitorPs5NativePackageInstall/);
|
||||
assert.match(modal, /item\.titleId === 'FAKE00000'/);
|
||||
assert.match(modal, /INSTALL PS5 DEBUG PACKAGE/);
|
||||
assert.match(modal, /operationId: ps5NativeInstallOperationId/);
|
||||
assert.match(modal, /ps5NativePackageSetOptionLabel/);
|
||||
assert.match(html, /id="ps5-native-package-cancel"[^>]*disabled/);
|
||||
assert.match(dockerfile, /LIBPROSPEROPKG_COMMIT=9084c0e88cd8d7cd9c0b353b8b6b89f7d1cc4275/);
|
||||
assert.match(dockerfile, /--self-contained true/);
|
||||
assert.match(dockerfile, /\/out\/chimera-prospero-bridge probe --json/);
|
||||
assert.match(registry, /ps5\.nativePackageBuilderPath', ''\) \|\| '\/opt\/chimera-prospero-bridge'/);
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
'use strict';
|
||||
|
||||
const assert = require('node:assert/strict');
|
||||
const test = require('node:test');
|
||||
const { comparePs5PackageSets } = require('../../src/services/ps5-package-comparison-service');
|
||||
|
||||
function packageSet(id, sha256, overrides = {}) {
|
||||
return {
|
||||
id,
|
||||
platform: 'ps5',
|
||||
label: `package ${id}`,
|
||||
manifest: { packageFormat: { container: 'FIH', debug: true, retail: false }, title: { titleId: 'PPSA17330' } },
|
||||
components: [{
|
||||
componentId: 'base', type: 'base', size: 1024, sha256,
|
||||
version: '01.00',
|
||||
metadata: {
|
||||
contentId: { value: 'EP0001-PPSA17330_00-RCTCLASSIC000001' },
|
||||
container: { value: 'FIH' },
|
||||
},
|
||||
}],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test('compares exact component identities without claiming AppInst trust', () => {
|
||||
const digest = 'a'.repeat(64);
|
||||
const result = comparePs5PackageSets({
|
||||
candidate: packageSet(4, digest),
|
||||
reference: packageSet(3, digest),
|
||||
operations: [],
|
||||
});
|
||||
|
||||
assert.equal(result.identity.sameArtifacts, true);
|
||||
assert.equal(result.differences.length, 0);
|
||||
assert.equal(result.referenceTrust.status, 'unproven');
|
||||
assert.equal(result.referenceTrust.installable, false);
|
||||
assert.equal(result.verdict, 'no-material-difference');
|
||||
});
|
||||
|
||||
test('reports material component changes and durable AppInst outcomes', () => {
|
||||
const result = comparePs5PackageSets({
|
||||
candidate: packageSet(4, 'b'.repeat(64), { components: [{ componentId: 'base', type: 'base', size: 2048, sha256: 'b'.repeat(64), version: '01.01', metadata: { contentId: { value: 'EP0001-PPSA17330_00-RCTCLASSIC000001' }, container: { value: 'FIH' } } }] }),
|
||||
reference: packageSet(3, 'a'.repeat(64)),
|
||||
operations: [
|
||||
{ id: 'accepted', packageSetId: 3, status: 'completed', installed: true, evidence: { source: 'post-install-console-scan', observed_at: 123 } },
|
||||
{ id: 'rejected', packageSetId: 4, status: 'blocked', installed: false, error: { code: 'PS5_PKG_INSTALL_REJECTED', terminalReason: 'sony-appinst-rejected', appInstError: 0x80B2116F, route: 'shellui-rpc' } },
|
||||
],
|
||||
});
|
||||
|
||||
assert.equal(result.identity.sameArtifacts, false);
|
||||
assert.deepEqual(result.differences.map(item => item.fields), [['sha256', 'size', 'version']]);
|
||||
assert.equal(result.referenceTrust.status, 'post-scan-proven');
|
||||
assert.equal(result.referenceTrust.installable, true);
|
||||
assert.equal(result.candidateTrust.status, 'appinst-rejected');
|
||||
assert.equal(result.candidateTrust.appInstError, 0x80B2116F);
|
||||
assert.equal(result.verdict, 'material-difference-from-proven-reference');
|
||||
});
|
||||
|
||||
test('rejects cross-title and non-FIH comparisons', () => {
|
||||
assert.throws(() => comparePs5PackageSets({
|
||||
candidate: packageSet(4, 'b'.repeat(64)),
|
||||
reference: packageSet(3, 'a'.repeat(64), { manifest: { packageFormat: { container: 'PKG' }, title: { titleId: 'PPSA99999' } } }),
|
||||
}), error => error.code === 'PS5_PKG_COMPARISON_SCOPE');
|
||||
});
|
||||
|
||||
test('compares read-only FIH profiles without exposing managed paths', () => {
|
||||
const candidate = packageSet(4, 'b'.repeat(64)); candidate.components[0].metadata.absolutePath = 'C:/vault/candidate.pkg';
|
||||
const reference = packageSet(3, 'a'.repeat(64)); reference.components[0].metadata.absolutePath = 'C:/vault/reference.pkg';
|
||||
const result = comparePs5PackageSets({ candidate, reference, inspectPackage: filePath => ({
|
||||
format: 'ps5-fih-debug-package',
|
||||
validation: { validationLevel: 'fih-cnt-structural-v3', contentId: 'EP0001-PPSA17330_00-RCTCLASSIC000001', entryCount: filePath.includes('candidate') ? 18 : 17, geometry: { pfsOffset: 65536, pfsSize: 131072, cntOffset: 196608 }, imageKey: { size: 2048, profile: 'experimental-kstuff-rsa2048-native-stride', rsaRecordCount: 5, nativeSlotStride: 384 }, routeCompatibility: { status: 'unproven', basis: 'mailbox-route-not-established' }, installTrust: { status: 'unproven', basis: 'structural-only' } },
|
||||
}) });
|
||||
|
||||
assert.deepEqual(result.differences[0].fields, ['sha256', 'structure']);
|
||||
assert.equal(result.candidate.components[0].structure.entryCount, 18);
|
||||
assert.equal(result.candidate.components[0].structure.routeCompatibility.status, 'unproven');
|
||||
assert.equal(JSON.stringify(result).includes('C:/vault'), false);
|
||||
});
|
||||
|
||||
test('comparison UI and read-only bridges are present', () => {
|
||||
const fs = require('node:fs');
|
||||
const html = fs.readFileSync('renderer/index.html', 'utf8');
|
||||
const modal = fs.readFileSync('renderer/modules/modal.js', 'utf8');
|
||||
const preload = fs.readFileSync('preload.js', 'utf8');
|
||||
const shim = fs.readFileSync('renderer/pork-web-shim.js', 'utf8');
|
||||
const registry = fs.readFileSync('src/server/create-registry.js', 'utf8');
|
||||
const css = fs.readFileSync('renderer/styles/ps5-native-packages.css', 'utf8');
|
||||
for (const id of ['ps5-package-comparison-candidate', 'ps5-package-comparison-reference', 'ps5-package-comparison-run', 'ps5-package-comparison-result']) assert.match(`${html}\n${modal}`, new RegExp(`id="${id}"`));
|
||||
assert.match(modal, /ps5PackageComparison/);
|
||||
assert.match(preload, /ps5PackageComparison/);
|
||||
assert.match(shim, /ps5PackageComparison/);
|
||||
assert.match(registry, /ps5-native-packages:compare[\s\S]*classification: 'read-only'/);
|
||||
assert.match(css, /\.ps5-package-comparison-controls select \{ width: 100%; min-width: 0; \}/);
|
||||
});
|
||||
@@ -0,0 +1,98 @@
|
||||
'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 { Readable } = require('node:stream');
|
||||
const { createPs5UpstreamControlPackageService, CONTROL } = require('../../src/services/ps5-upstream-control-package-service');
|
||||
|
||||
function fixture() {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'chimera-ps5-control-'));
|
||||
const bytes = Buffer.from('pinned-open-source-control');
|
||||
const registrations = [];
|
||||
const service = createPs5UpstreamControlPackageService({
|
||||
outputRoot: root,
|
||||
stateFile: path.join(root, 'state.json'),
|
||||
descriptor: { ...CONTROL, size: bytes.length, sha256: crypto.createHash('sha256').update(bytes).digest('hex') },
|
||||
fetchImpl: async url => ({ ok: url === CONTROL.url, status: 200, body: Readable.toWeb(Readable.from(bytes)) }),
|
||||
inspectPackage: () => ({ format: 'ps5-fih-debug-package', validation: { structurallyValid: true, validationLevel: 'fih-cnt-structural-v3', contentId: CONTROL.contentId, imageCount: 0, imageKey: { profile: 'opaque-filled-0x800' } } }),
|
||||
inspectInstallGeometry: async () => ({ packageSize: 17, mountImageSize: 17, installedAppPkgSize: 17, provenance: 'signed-image:pfsimage.xml' }),
|
||||
registrar: async manifest => { registrations.push(manifest); return { packageSetId: 99, titleId: 42 }; },
|
||||
});
|
||||
return { root, bytes, registrations, service };
|
||||
}
|
||||
|
||||
test('imports only the exact hash-pinned upstream control atomically', async t => {
|
||||
const f = fixture(); t.after(() => fs.rmSync(f.root, { recursive: true, force: true }));
|
||||
const result = await f.service.importPackage();
|
||||
assert.equal(result.status, 'ready');
|
||||
assert.equal(result.installTrust.status, 'unproven');
|
||||
assert.equal(result.installAuthorized, false);
|
||||
assert.equal(result.packageSetId, 99);
|
||||
assert.deepEqual(fs.readFileSync(result.absolutePath), f.bytes);
|
||||
assert.equal(f.registrations[0].controlProfile, 'upstream-stock-9084c0e');
|
||||
assert.equal(f.registrations[0].components[0].sha256, crypto.createHash('sha256').update(f.bytes).digest('hex'));
|
||||
assert.equal(fs.readdirSync(path.dirname(result.absolutePath)).some(name => name.endsWith('.tmp')), false);
|
||||
const proven = await f.service.recordInstallEvidence({ operationId: 'control-op-1', source: 'post-install-console-scan', observed_at: Date.now() });
|
||||
assert.equal(proven.installTrust.status, 'post-scan-proven');
|
||||
assert.equal(proven.installAuthorized, false);
|
||||
assert.equal((await f.service.status()).installTrust.operationId, 'control-op-1');
|
||||
assert.equal((await f.service.status()).currentArtifactVerified, true);
|
||||
});
|
||||
|
||||
test('status never reports ready when only the historical state remains', async t => {
|
||||
const f = fixture(); t.after(() => fs.rmSync(f.root, { recursive: true, force: true }));
|
||||
await f.service.importPackage();
|
||||
fs.unlinkSync(f.service.targetPath());
|
||||
const status = await f.service.status();
|
||||
assert.equal(status.status, 'missing');
|
||||
assert.equal(status.currentArtifactVerified, false);
|
||||
assert.equal(status.historicalRegistration.status, 'ready');
|
||||
});
|
||||
|
||||
test('rejects hash drift and unexpected structural profiles without publishing', async t => {
|
||||
const f = fixture(); t.after(() => fs.rmSync(f.root, { recursive: true, force: true }));
|
||||
const badHash = createPs5UpstreamControlPackageService({
|
||||
outputRoot: f.root, stateFile: path.join(f.root, 'bad-state.json'), descriptor: { ...CONTROL, size: f.bytes.length, sha256: '0'.repeat(64) },
|
||||
fetchImpl: async () => ({ ok: true, status: 200, body: Readable.toWeb(Readable.from(f.bytes)) }), inspectPackage: () => ({}), registrar: async () => ({}),
|
||||
});
|
||||
await assert.rejects(badHash.importPackage(), error => error.code === 'PS5_CONTROL_HASH');
|
||||
assert.equal(fs.existsSync(badHash.targetPath()), false);
|
||||
|
||||
const badProfile = createPs5UpstreamControlPackageService({
|
||||
outputRoot: f.root, stateFile: path.join(f.root, 'profile-state.json'), descriptor: { ...CONTROL, size: f.bytes.length, sha256: crypto.createHash('sha256').update(f.bytes).digest('hex') },
|
||||
fetchImpl: async () => ({ ok: true, status: 200, body: Readable.toWeb(Readable.from(f.bytes)) }), inspectPackage: () => ({ format: 'ps5-fih-debug-package', validation: { contentId: CONTROL.contentId, errors: [{ code: 'FIH_MAGIC' }] } }), registrar: async () => ({}),
|
||||
});
|
||||
await assert.rejects(badProfile.importPackage(), error => error.code === 'PS5_CONTROL_PROFILE');
|
||||
assert.equal(fs.existsSync(badProfile.targetPath()), false);
|
||||
});
|
||||
|
||||
test('control routes remain separate and require an exact destructive confirmation', () => {
|
||||
const registry = fs.readFileSync('src/server/create-registry.js', 'utf8');
|
||||
assert.match(registry, /ps5-native-packages:control-status[\s\S]*classification: 'read-only'/);
|
||||
assert.match(registry, /ps5-native-packages:control-import[\s\S]*classification: 'mutating'/);
|
||||
assert.match(registry, /ps5-native-packages:control-restore[\s\S]*classification: 'destructive'[\s\S]*INSTALL PS5 UPSTREAM CONTROL PACKAGE/);
|
||||
assert.match(registry, /ps5-native-packages:control-restore-verify[\s\S]*classification: 'mutating'/);
|
||||
assert.match(registry, /PPSA99099[\s\S]*post-install-console-scan/);
|
||||
});
|
||||
|
||||
test('control UI and both bridges expose import, exact-confirmed restore and post-scan verification', () => {
|
||||
const preload = fs.readFileSync('preload.js', 'utf8');
|
||||
const web = fs.readFileSync('renderer/pork-web-shim.js', 'utf8');
|
||||
const main = fs.readFileSync('main.js', 'utf8');
|
||||
const modal = fs.readFileSync('renderer/modules/modal.js', 'utf8');
|
||||
const surface = `${fs.readFileSync('renderer/index.html', 'utf8')}\n${modal}`;
|
||||
const css = fs.readFileSync('renderer/styles/ps5-native-packages.css', 'utf8');
|
||||
for (const source of [preload, web]) {
|
||||
assert.match(source, /ps5UpstreamControl/);
|
||||
assert.match(source, /ps5-native-packages:control-/);
|
||||
}
|
||||
assert.match(main, /PS5_CONTROL_SERVER_REQUIRED/);
|
||||
assert.match(surface, /ps5-upstream-control-import/);
|
||||
assert.match(modal, /INSTALL PS5 UPSTREAM CONTROL PACKAGE/);
|
||||
assert.match(modal, /ps5UpstreamControl\('restore-verify'/);
|
||||
assert.match(css, /ps5-upstream-control/);
|
||||
});
|
||||
@@ -0,0 +1,105 @@
|
||||
'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 { Readable } = require('node:stream');
|
||||
const database = require('../../src/database');
|
||||
const { registerPs5NativePackageManifest } = require('../../src/services/ps5-native-package-registration-service');
|
||||
const { createPs5LaunchReferencePackageService, LAUNCH_REFERENCE } = require('../../src/services/ps5-launch-reference-package-service');
|
||||
|
||||
function fixture() {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'chimera-ps5-launch-reference-'));
|
||||
const bytes = Buffer.from('pinned-websrv-launch-reference');
|
||||
const registrations = [];
|
||||
const service = createPs5LaunchReferencePackageService({
|
||||
outputRoot: root,
|
||||
stateFile: path.join(root, 'state.json'),
|
||||
descriptor: { ...LAUNCH_REFERENCE, size: bytes.length, sha256: crypto.createHash('sha256').update(bytes).digest('hex') },
|
||||
fetchImpl: async url => ({ ok: url === LAUNCH_REFERENCE.url, status: 200, body: Readable.toWeb(Readable.from(bytes)) }),
|
||||
inspectPackage: () => ({ format: 'ps5-fih-debug-package', validation: { structurallyValid: true, validationLevel: 'fih-cnt-structural-v3', contentId: LAUNCH_REFERENCE.contentId, imageCount: 0, imageKey: { profile: 'opaque-filled-0x800' } } }),
|
||||
registrar: async manifest => { registrations.push(manifest); return { packageSetId: 1110, titleId: 43 }; },
|
||||
});
|
||||
return { root, bytes, registrations, service };
|
||||
}
|
||||
|
||||
test('imports only the exact hash-pinned official websrv launch reference', async t => {
|
||||
const f = fixture(); t.after(() => fs.rmSync(f.root, { recursive: true, force: true }));
|
||||
const result = await f.service.importPackage();
|
||||
assert.equal(result.status, 'ready');
|
||||
assert.equal(result.installTrust.status, 'unproven');
|
||||
assert.equal(result.launchVerified, false);
|
||||
assert.equal(result.installAuthorized, false);
|
||||
assert.deepEqual(fs.readFileSync(result.absolutePath), f.bytes);
|
||||
assert.equal(f.registrations[0].launchReferenceProfile, 'websrv-launcher-1afd476');
|
||||
assert.equal(f.registrations[0].components[0].provenance[0].provider, 'ps5-payload-dev/websrv');
|
||||
const proven = f.service.recordInstallEvidence({ operationId: 'launcher-op-1', source: 'post-install-console-scan', observed_at: Date.now() });
|
||||
assert.equal(proven.installTrust.status, 'post-scan-proven');
|
||||
assert.equal(proven.launchVerified, false);
|
||||
assert.equal(proven.nextAction, 'manual-launch-required');
|
||||
});
|
||||
|
||||
test('rejects launch-reference hash drift and profile drift without publishing', async t => {
|
||||
const f = fixture(); t.after(() => fs.rmSync(f.root, { recursive: true, force: true }));
|
||||
const badHash = createPs5LaunchReferencePackageService({ outputRoot: f.root, stateFile: path.join(f.root, 'bad-hash.json'), descriptor: { ...LAUNCH_REFERENCE, size: f.bytes.length, sha256: '0'.repeat(64) }, fetchImpl: async () => ({ ok: true, status: 200, body: Readable.toWeb(Readable.from(f.bytes)) }), inspectPackage: () => ({}), registrar: async () => ({}) });
|
||||
await assert.rejects(badHash.importPackage(), error => error.code === 'PS5_LAUNCH_REFERENCE_HASH');
|
||||
assert.equal(fs.existsSync(badHash.targetPath()), false);
|
||||
const badProfile = createPs5LaunchReferencePackageService({ outputRoot: f.root, stateFile: path.join(f.root, 'bad-profile.json'), descriptor: { ...LAUNCH_REFERENCE, size: f.bytes.length, sha256: crypto.createHash('sha256').update(f.bytes).digest('hex') }, fetchImpl: async () => ({ ok: true, status: 200, body: Readable.toWeb(Readable.from(f.bytes)) }), inspectPackage: () => ({ format: 'ps5-fih-debug-package', validation: { contentId: LAUNCH_REFERENCE.contentId, imageCount: 1, imageKey: { profile: 'experimental-kstuff-rsa2048-native-stride' } } }), registrar: async () => ({}) });
|
||||
await assert.rejects(badProfile.importPackage(), error => error.code === 'PS5_LAUNCH_REFERENCE_PROFILE');
|
||||
assert.equal(fs.existsSync(badProfile.targetPath()), false);
|
||||
});
|
||||
|
||||
test('launch-reference routes use a separate exact confirmation and never infer launch from installation', () => {
|
||||
const registry = fs.readFileSync('src/server/create-registry.js', 'utf8');
|
||||
assert.match(registry, /ps5-native-packages:launch-reference-status[\s\S]*classification: 'read-only'/);
|
||||
assert.match(registry, /ps5-native-packages:launch-reference-import[\s\S]*classification: 'mutating'/);
|
||||
assert.match(registry, /ps5-native-packages:launch-reference-restore[\s\S]*classification: 'destructive'[\s\S]*INSTALL PS5 LAUNCH REFERENCE/);
|
||||
assert.match(registry, /ps5-native-packages:launch-reference-restore-verify[\s\S]*launchVerified: false[\s\S]*manual-launch-required/);
|
||||
});
|
||||
|
||||
test('launch-reference descriptor is pinned to the inspected official artifact', () => {
|
||||
assert.equal(LAUNCH_REFERENCE.revision, '1afd476c5044d68df4e45dc773769e5de3b2cdd6');
|
||||
assert.equal(LAUNCH_REFERENCE.size, 18_168_015);
|
||||
assert.equal(LAUNCH_REFERENCE.sha256, 'c737b79be8c47b89b2e4e38ca404cdad6d30c0ff659aaf25c7e9ea1368341434');
|
||||
assert.equal(LAUNCH_REFERENCE.contentId, 'IV9999-FAKE00000_00-HOMEBREWLOADER01');
|
||||
});
|
||||
|
||||
test('launch-reference UI and both bridges expose the guarded three-step flow', () => {
|
||||
const preload = fs.readFileSync('preload.js', 'utf8');
|
||||
const web = fs.readFileSync('renderer/pork-web-shim.js', 'utf8');
|
||||
const main = fs.readFileSync('main.js', 'utf8');
|
||||
const modal = fs.readFileSync('renderer/modules/modal.js', 'utf8');
|
||||
const html = fs.readFileSync('renderer/index.html', 'utf8');
|
||||
const css = fs.readFileSync('renderer/styles/ps5-native-packages.css', 'utf8');
|
||||
for (const source of [preload, web]) {
|
||||
assert.match(source, /ps5LaunchReference/);
|
||||
assert.match(source, /ps5-native-packages:launch-reference-/);
|
||||
}
|
||||
assert.match(main, /PS5_LAUNCH_REFERENCE_SERVER_REQUIRED/);
|
||||
assert.match(html, /ps5-launch-reference-import/);
|
||||
assert.match(modal, /INSTALL PS5 LAUNCH REFERENCE/);
|
||||
assert.match(modal, /ps5LaunchReference\('restore-verify'/);
|
||||
assert.match(modal, /launch is not yet proven/);
|
||||
assert.match(css, /ps5-launch-reference/);
|
||||
});
|
||||
|
||||
test('registers the validated FAKE homebrew title as PS5 without loosening global title inference', async t => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'chimera-ps5-launch-title-'));
|
||||
database.configure({ dataDir: path.join(root, 'db'), logger: { info() {}, warn() {}, error() {} } });
|
||||
await database.initialize();
|
||||
t.after(() => { database.close(); fs.rmSync(root, { recursive: true, force: true }); });
|
||||
const outputRoot = path.join(root, 'vault'); const folder = path.join(outputRoot, 'launch-reference'); fs.mkdirSync(folder, { recursive: true });
|
||||
const pkg = path.join(folder, LAUNCH_REFERENCE.filename); const bytes = Buffer.from('validated-launch-reference'); fs.writeFileSync(pkg, bytes);
|
||||
const manifest = {
|
||||
schemaVersion: 1, kind: 'chimera.ps5-native-package.v1', launchReferenceProfile: 'websrv-launcher-1afd476', createdAt: Date.now(),
|
||||
title: { platform: 'ps5', titleId: LAUNCH_REFERENCE.titleId, name: LAUNCH_REFERENCE.title }, packageFormat: { container: 'FIH', debug: true, retail: false },
|
||||
components: [{ id: 'base', type: 'base', version: LAUNCH_REFERENCE.version, contentId: LAUNCH_REFERENCE.contentId, size: bytes.length, sha256: crypto.createHash('sha256').update(bytes).digest('hex'), absolutePath: pkg, required: true, order: 1, provenance: [] }], dependencies: [], recipe: { installOrder: [{ componentId: 'base', order: 1 }] },
|
||||
};
|
||||
const result = registerPs5NativePackageManifest({ db: database, outputRoot, manifest, source: 'official-upstream-release-artifact', variantKey: 'websrv-launch-reference' });
|
||||
assert.ok(result.packageSetId);
|
||||
assert.equal(database.runQuery("SELECT platform FROM titles WHERE title_id='FAKE00000'")[0].platform, 'ps5');
|
||||
assert.equal(database.runQuery("SELECT COUNT(*) AS count FROM titles WHERE title_id='FAKE00000'")[0].count, 1);
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
'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 { createEtaHenDpiV2PackageInstaller } = require('../../src/services/etahen-dpi-v2-package-installer');
|
||||
|
||||
function response(body, status = 200, headers = {}) {
|
||||
return {
|
||||
ok: status >= 200 && status < 300,
|
||||
status,
|
||||
headers: { get: name => headers[String(name).toLowerCase()] || null },
|
||||
text: async () => String(body),
|
||||
};
|
||||
}
|
||||
|
||||
test('etaHEN DPI v2 adapter proves the WebUI identity and submits a tokenized package URL', async t => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'chimera-dpi-v2-'));
|
||||
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
const filePath = path.join(root, 'IV9999-FAKE00000_00-HOMEBREWLOADER01.pkg');
|
||||
const bytes = Buffer.concat([Buffer.from([0x7f, 0x46, 0x49, 0x48]), Buffer.alloc(128, 0x4a)]);
|
||||
fs.writeFileSync(filePath, bytes);
|
||||
const calls = []; const packageUrl = 'https://chimera.example.test/ps5-native-package/token/sample.pkg';
|
||||
const fetch = async (url, options = {}) => {
|
||||
calls.push({ url: String(url), options, body: options.body?.getBuffer?.().toString('utf8') || '' });
|
||||
if (!options.method) return response('<title>etaHEN DPIv2</title><form action="/upload" method="post" enctype="multipart/form-data">');
|
||||
return response('SUCCESS: Installation started');
|
||||
};
|
||||
const adapter = createEtaHenDpiV2PackageInstaller({
|
||||
consoleHost: 'ps5.local', packageRoot: root, fetch,
|
||||
createPackageHandoff: async artifact => ({ url: packageUrl, sha256: artifact.sha256, size: artifact.size, expiresAt: Date.now() + 60_000 }),
|
||||
});
|
||||
const probe = await adapter.probe();
|
||||
assert.equal(probe.available, true);
|
||||
assert.equal(probe.protocol, 'etahen-dpi-v2-url-v1');
|
||||
const result = await adapter.install({ filePath, size: bytes.length, sha256: crypto.createHash('sha256').update(bytes).digest('hex') });
|
||||
assert.equal(result.accepted, true);
|
||||
assert.equal(result.installed, false);
|
||||
assert.equal(result.postScanRequired, true);
|
||||
assert.equal(result.response, 'SUCCESS: Installation started');
|
||||
const upload = calls.find(call => call.options.method === 'POST');
|
||||
assert.equal(upload.url, 'http://ps5.local:12800/upload');
|
||||
assert.match(upload.options.headers['content-type'], /^multipart\/form-data; boundary=/);
|
||||
assert.match(upload.body, /name="file"/);
|
||||
assert.match(upload.body, /name="url"/);
|
||||
assert.match(upload.body, new RegExp(packageUrl.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')));
|
||||
assert.equal(upload.body.includes(bytes.toString('hex')), false);
|
||||
});
|
||||
|
||||
test('etaHEN DPI v2 adapter fails closed on identity drift, path escape, hash drift and ambiguous response', async t => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'chimera-dpi-v2-negative-'));
|
||||
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
const filePath = path.join(root, 'sample.pkg');
|
||||
const bytes = Buffer.concat([Buffer.from([0x7f, 0x46, 0x49, 0x48]), Buffer.alloc(32)]);
|
||||
fs.writeFileSync(filePath, bytes);
|
||||
const hash = crypto.createHash('sha256').update(bytes).digest('hex');
|
||||
const drift = createEtaHenDpiV2PackageInstaller({ consoleHost: 'ps5.local', packageRoot: root, fetch: async () => response('<html>other service</html>') });
|
||||
assert.equal((await drift.probe()).available, false);
|
||||
await assert.rejects(() => drift.install({ filePath, size: bytes.length, sha256: hash }), error => error.code === 'ETAHEN_DPI_V2_IDENTITY');
|
||||
|
||||
const outside = path.join(os.tmpdir(), `outside-${crypto.randomUUID()}.pkg`);
|
||||
fs.writeFileSync(outside, bytes); t.after(() => fs.rmSync(outside, { force: true }));
|
||||
const ambiguous = createEtaHenDpiV2PackageInstaller({ consoleHost: 'ps5.local', packageRoot: root, createPackageHandoff: async () => ({ url: 'https://chimera.example.test/pkg' }), fetch: async (_url, options = {}) => response(options.method ? 'processing' : '<title>etaHEN DPIv2</title><form action="/upload" enctype="multipart/form-data">') });
|
||||
await assert.rejects(() => ambiguous.install({ filePath: outside, size: bytes.length, sha256: hash }), error => error.code === 'ETAHEN_DPI_V2_PATH');
|
||||
await assert.rejects(() => ambiguous.install({ filePath, size: bytes.length, sha256: '0'.repeat(64) }), error => error.code === 'ETAHEN_DPI_V2_HASH');
|
||||
await assert.rejects(() => ambiguous.install({ filePath, size: bytes.length, sha256: hash }), error => error.code === 'ETAHEN_DPI_V2_RESPONSE');
|
||||
});
|
||||
|
||||
test('etaHEN DPI v2 adapter fails closed without a valid tokenized handoff URL', async t => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'chimera-dpi-v2-handoff-'));
|
||||
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
const filePath = path.join(root, 'sample.pkg');
|
||||
const bytes = Buffer.concat([Buffer.from([0x7f, 0x46, 0x49, 0x48]), Buffer.alloc(32)]);
|
||||
fs.writeFileSync(filePath, bytes); const sha256 = crypto.createHash('sha256').update(bytes).digest('hex');
|
||||
const fetch = async () => response('<title>etaHEN DPIv2</title><form action="/upload" enctype="multipart/form-data">');
|
||||
const missing = createEtaHenDpiV2PackageInstaller({ consoleHost: 'ps5.local', packageRoot: root, fetch });
|
||||
await assert.rejects(() => missing.install({ filePath, size: bytes.length, sha256 }), error => error.code === 'ETAHEN_DPI_V2_HANDOFF');
|
||||
const unsafe = createEtaHenDpiV2PackageInstaller({ consoleHost: 'ps5.local', packageRoot: root, fetch, createPackageHandoff: async () => ({ url: 'file:///tmp/sample.pkg' }) });
|
||||
await assert.rejects(() => unsafe.install({ filePath, size: bytes.length, sha256 }), error => error.code === 'ETAHEN_DPI_V2_HANDOFF');
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
'use strict';
|
||||
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const {
|
||||
createRuntimeIdentity,
|
||||
matchCompatibility,
|
||||
capabilityState,
|
||||
validateJob,
|
||||
reconcileRuntimeLoss,
|
||||
newRuntimeEpoch,
|
||||
} = require('../../src/services/etahen-link-contract');
|
||||
|
||||
const HASH = 'a'.repeat(64);
|
||||
const identity = createRuntimeIdentity({ version: '2.5B', binarySha256: HASH, firmware: '8.20', runtimeEpoch: 'epoch_12345678' });
|
||||
|
||||
test('unknown etaHEN build exposes only safe baseline capabilities', () => {
|
||||
const match = matchCompatibility(identity, []);
|
||||
assert.equal(match.level, 'detected-unverified');
|
||||
assert.ok(match.capabilities.includes('console.inventory.read'));
|
||||
assert.ok(!match.capabilities.includes('package.install.etahen-dpi-v1'));
|
||||
});
|
||||
|
||||
test('verified profile requires exact version, firmware and binary hash', () => {
|
||||
const profile = { id: 'etahen-2.5b-820', etahenVersion: '2.5B', binarySha256: HASH, firmwares: ['8.20'], capabilities: ['console.inventory.read', 'package.install.etahen-dpi-v1'] };
|
||||
assert.equal(matchCompatibility(identity, [profile]).level, 'verified');
|
||||
assert.equal(matchCompatibility({ ...identity, binarySha256: 'b'.repeat(64) }, [profile]).level, 'detected-unverified');
|
||||
});
|
||||
|
||||
test('availability, authorization and health remain independent', () => {
|
||||
const compatibility = { level: 'verified', capabilities: ['package.install.etahen-dpi-v1'] };
|
||||
assert.deepEqual(capabilityState('package.install.etahen-dpi-v1', { compatibility, authorized: [], healthy: ['package.install.etahen-dpi-v1'] }), {
|
||||
name: 'package.install.etahen-dpi-v1', risk: 'mutating', available: true, authorized: false, healthy: true, compatibility: 'verified',
|
||||
});
|
||||
});
|
||||
|
||||
test('mutating job needs a healthy capability and fresh runtime-bound confirmation', () => {
|
||||
const capabilities = [capabilityState('package.install.etahen-dpi-v1', {
|
||||
compatibility: { level: 'verified', capabilities: ['package.install.etahen-dpi-v1'] },
|
||||
authorized: ['package.install.etahen-dpi-v1'], healthy: ['package.install.etahen-dpi-v1'],
|
||||
})];
|
||||
assert.throws(() => validateJob({ jobId: 'job_12345678', type: 'package.install', runtimeEpoch: identity.runtimeEpoch, input: {} }, { runtimeEpoch: identity.runtimeEpoch, capabilities }), /authorization/i);
|
||||
assert.equal(validateJob({ jobId: 'job_12345678', type: 'package.install', runtimeEpoch: identity.runtimeEpoch, confirmationEpoch: identity.runtimeEpoch, input: { artifactId: 'artifact_1' } }, { runtimeEpoch: identity.runtimeEpoch, capabilities }).risk, 'mutating');
|
||||
});
|
||||
|
||||
test('raw command, path, URL and ELF fields are rejected recursively', () => {
|
||||
const capabilities = [capabilityState('console.inventory.read', { compatibility: { level: 'verified', capabilities: ['console.inventory.read'] }, healthy: ['console.inventory.read'] })];
|
||||
for (const input of [{ command: 'raw' }, { nested: { path: '/data' } }, { url: 'http://host' }, { elf: 'payload' }]) {
|
||||
assert.throws(() => validateJob({ jobId: 'job_12345678', type: 'inventory.scan', runtimeEpoch: identity.runtimeEpoch, input }, { runtimeEpoch: identity.runtimeEpoch, capabilities }), /Forbidden/);
|
||||
}
|
||||
});
|
||||
|
||||
test('runtime loss invalidates confirmation and makes active mutations outcome-unknown', () => {
|
||||
assert.deepEqual(reconcileRuntimeLoss({ id: 'j1', status: 'applying', confirmationEpoch: identity.runtimeEpoch }), { id: 'j1', status: 'outcome_unknown', confirmationEpoch: null });
|
||||
assert.deepEqual(reconcileRuntimeLoss({ id: 'j2', status: 'confirmed', confirmationEpoch: identity.runtimeEpoch }), { id: 'j2', status: 'paused', confirmationEpoch: null });
|
||||
assert.match(newRuntimeEpoch('fixed-seed'), /^[a-f0-9]{32}$/);
|
||||
});
|
||||
@@ -0,0 +1,100 @@
|
||||
'use strict';
|
||||
|
||||
const test = require('node:test');
|
||||
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 { canonical, createPairingService, pairingProof, signEnvelope, createEnvelopeVerifier, redact, createStagingService } = require('../../src/services/etahen-link-agent');
|
||||
|
||||
test('pairing requires expiring proof-of-possession and cannot be replayed', () => {
|
||||
let clock = 1000; let seed = 0;
|
||||
const randomBytes = size => Buffer.alloc(size, ++seed);
|
||||
const service = createPairingService({ now: () => clock, randomBytes, ttlMs: 100 });
|
||||
const challenge = service.begin({ consoleId: 'console_12345678', runtimeEpoch: 'runtime_12345678' });
|
||||
assert.ok(challenge.code.length >= 22);
|
||||
const peer = { peerId: 'server_12345678', peerPublicKeySha256: 'a'.repeat(64) };
|
||||
assert.throws(() => service.confirm({ challengeId: challenge.challengeId, ...peer, proof: 'b'.repeat(64) }), /proof/i);
|
||||
const paired = service.confirm({ challengeId: challenge.challengeId, ...peer, proof: pairingProof(challenge, peer) });
|
||||
assert.equal(paired.consoleId, 'console_12345678'); assert.equal(paired.sessionKey.length, 64);
|
||||
assert.equal(service.peer(peer.peerId).sessionKey, undefined);
|
||||
assert.throws(() => service.confirm({ challengeId: challenge.challengeId, ...peer, proof: pairingProof(challenge, peer) }), /unavailable/i);
|
||||
clock = 2000; const expired = service.begin({ consoleId: 'console_12345678', runtimeEpoch: 'runtime_12345678' }); clock += 101;
|
||||
assert.throws(() => service.confirm({ challengeId: expired.challengeId, ...peer, proof: pairingProof(expired, peer) }), /unavailable/i);
|
||||
});
|
||||
|
||||
test('signed envelopes bind console, runtime, expiry and one-time nonce', () => {
|
||||
const key = 'c'.repeat(64); const now = 100000;
|
||||
const base = { jobId: 'job_12345678', nonce: 'nonce_12345678', consoleId: 'console_12345678', runtimeEpoch: 'runtime_12345678', issuedAt: now, expiresAt: now + 1000, job: { type: 'inventory.scan', input: {} } };
|
||||
const verify = createEnvelopeVerifier({ consoleId: base.consoleId, runtimeEpoch: base.runtimeEpoch, sessionKey: key, now: () => now });
|
||||
const signed = signEnvelope(base, key); assert.equal(verify(signed).jobId, base.jobId);
|
||||
assert.throws(() => verify(signed), /already used/);
|
||||
const wrong = signEnvelope({ ...base, nonce: 'nonce_22345678', consoleId: 'console_87654321' }, key);
|
||||
assert.throws(() => verify(wrong), /another console/);
|
||||
});
|
||||
|
||||
test('staging is resumable, hash-bound, atomic and never installed evidence', t => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'chimera-etahen-agent-')); t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
const bytes = Buffer.from('bounded staged artifact'); const sha256 = crypto.createHash('sha256').update(bytes).digest('hex');
|
||||
const staging = createStagingService({ root }); const session = staging.create({ artifactId: 'artifact_12345678', size: bytes.length, sha256 });
|
||||
const first = bytes.subarray(0, 7); staging.append({ sessionId: session.sessionId, offset: 0, bytes: first, chunkSha256: crypto.createHash('sha256').update(first).digest('hex') });
|
||||
assert.throws(() => staging.append({ sessionId: session.sessionId, offset: 0, bytes: Buffer.from('x'), chunkSha256: crypto.createHash('sha256').update('x').digest('hex') }), /chunk/);
|
||||
const second = bytes.subarray(7); staging.append({ sessionId: session.sessionId, offset: 7, bytes: second, chunkSha256: crypto.createHash('sha256').update(second).digest('hex') });
|
||||
const result = staging.finalize({ sessionId: session.sessionId }); assert.equal(result.status, 'staged'); assert.equal(result.installed, false); assert.deepEqual(fs.readFileSync(result.finalPath), bytes);
|
||||
});
|
||||
|
||||
test('recovery trusts file evidence, not stale persisted offset', t => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'chimera-etahen-recover-')); t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
const staging = createStagingService({ root }); const session = staging.create({ artifactId: 'artifact_87654321', size: 10, sha256: 'a'.repeat(64) });
|
||||
fs.appendFileSync(session.partialPath, Buffer.from('abc'));
|
||||
assert.deepEqual(staging.recover().find(item => item.sessionId === session.sessionId), { ...session, offset: 3, status: 'interrupted' });
|
||||
const resumed = staging.resume({ sessionId: session.sessionId });
|
||||
assert.equal(resumed.status, 'receiving'); assert.equal(resumed.offset, 3);
|
||||
const remainder = Buffer.from('defghij');
|
||||
staging.append({ sessionId: session.sessionId, offset: 3, bytes: remainder, chunkSha256: crypto.createHash('sha256').update(remainder).digest('hex') });
|
||||
});
|
||||
|
||||
test('recovery closes the crash window after atomic publication', t => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'chimera-etahen-publish-recover-')); t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
const bytes = Buffer.from('published before state persisted'); const sha256 = crypto.createHash('sha256').update(bytes).digest('hex');
|
||||
const staging = createStagingService({ root, now: () => 12345 });
|
||||
const session = staging.create({ artifactId: 'artifact_publish1', size: bytes.length, sha256 });
|
||||
staging.append({ sessionId: session.sessionId, offset: 0, bytes, chunkSha256: sha256 });
|
||||
fs.renameSync(session.partialPath, session.finalPath);
|
||||
const recovered = staging.recover().find(item => item.sessionId === session.sessionId);
|
||||
assert.equal(recovered.status, 'staged'); assert.equal(recovered.offset, bytes.length); assert.equal(recovered.stagedAt, 12345);
|
||||
});
|
||||
|
||||
test('audit redaction removes nested secrets without altering evidence fields', () => {
|
||||
assert.deepEqual(redact({ jobId: 'job_1', auth: { token: 'secret', sessionKey: 'key' }, result: 'staged' }), { jobId: 'job_1', auth: { token: '[REDACTED]', sessionKey: '[REDACTED]' }, result: 'staged' });
|
||||
});
|
||||
|
||||
test('canonical envelopes reject cycles, excessive nesting and non-JSON values', () => {
|
||||
const cyclic = {}; cyclic.self = cyclic;
|
||||
assert.throws(() => canonical(cyclic), /cycle/i);
|
||||
let deep = {}; let cursor = deep;
|
||||
for (let index = 0; index < 34; index += 1) { cursor.child = {}; cursor = cursor.child; }
|
||||
assert.throws(() => canonical(deep), /nesting/i);
|
||||
assert.throws(() => canonical({ value: undefined }), /non-JSON/i);
|
||||
});
|
||||
|
||||
test('staging rejects oversized chunks, external state and symlink swaps', t => {
|
||||
const parent = fs.mkdtempSync(path.join(os.tmpdir(), 'chimera-etahen-hostile-')); t.after(() => fs.rmSync(parent, { recursive: true, force: true }));
|
||||
const root = path.join(parent, 'staging'); fs.mkdirSync(root);
|
||||
assert.throws(() => createStagingService({ root, stateFile: path.join(parent, 'outside.json') }), /managed root/);
|
||||
const service = createStagingService({ root, maxChunkBytes: 2 }); const session = service.create({ artifactId: 'artifact_hostile1', size: 3, sha256: 'a'.repeat(64) });
|
||||
assert.throws(() => service.append({ sessionId: session.sessionId, offset: 0, bytes: Buffer.from('abc'), chunkSha256: crypto.createHash('sha256').update('abc').digest('hex') }), /configured bound/);
|
||||
fs.rmSync(session.partialPath);
|
||||
try {
|
||||
fs.symlinkSync(path.join(parent, 'outside.bin'), session.partialPath);
|
||||
assert.throws(() => service.append({ sessionId: session.sessionId, offset: 0, bytes: Buffer.from('a'), chunkSha256: crypto.createHash('sha256').update('a').digest('hex') }), /escaped/);
|
||||
} catch (error) {
|
||||
if (error.code !== 'EPERM') throw error;
|
||||
fs.writeFileSync(session.partialPath, Buffer.alloc(0));
|
||||
const statePath = path.join(root, 'sessions.json'); const persisted = JSON.parse(fs.readFileSync(statePath, 'utf8'));
|
||||
persisted.sessions[0].partialPath = path.join(parent, 'outside.bin'); fs.writeFileSync(statePath, JSON.stringify(persisted));
|
||||
const reloaded = createStagingService({ root, maxChunkBytes: 2 });
|
||||
assert.throws(() => reloaded.append({ sessionId: session.sessionId, offset: 0, bytes: Buffer.from('a'), chunkSha256: crypto.createHash('sha256').update('a').digest('hex') }), /identity/);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
'use strict';
|
||||
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const database = require('../../src/database');
|
||||
const { createEtahenLinkInventoryService } = require('../../src/services/etahen-link-inventory-service');
|
||||
|
||||
function withDatabase(fn) {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'chimera-link-inventory-'));
|
||||
database.configure({ dataDir: root, logger: { info() {}, warn() {}, error() {} } });
|
||||
return database.initialize().then(() => fn(database)).finally(() => { database.close(); fs.rmSync(root, { recursive: true, force: true }); });
|
||||
}
|
||||
|
||||
function snapshot({ scanId = 'scan_12345678', coverage = 'complete', titles = [], deviceAvailability = 'available', runtimeState = 'online', startedAt = 1000, completedAt = 1100 } = {}) {
|
||||
return {
|
||||
consoleId: 'console_12345678', consoleProfileId: 'profile_12345678',
|
||||
runtime: runtimeState === 'unknown' ? { state: 'unknown' } : { state: runtimeState, runtimeEpoch: 'runtime_12345678', etaHenVersion: '2.5b', etaHenBuildSha256: 'a'.repeat(64), firmware: '10.01' },
|
||||
scan: { scanId, startedAt, completedAt, outcome: 'completed' },
|
||||
devices: [{ deviceKey: 'device_internal1', displayName: 'Internal SSD', type: 'internal', availability: deviceAvailability, volumes: [{ volumeKey: 'volume_games001', kind: 'games', availability: deviceAvailability, coverage, capacityBytes: 100000, freeBytes: 50000, titles }] }],
|
||||
};
|
||||
}
|
||||
|
||||
function syntheticContentId() {
|
||||
return ['UP0000', 'PPSA12345_00', 'TEST000000000001'].join('-');
|
||||
}
|
||||
|
||||
function syntheticLocationKey() {
|
||||
return ['slot', '12345678'].join('_');
|
||||
}
|
||||
|
||||
const title = { titleId: 'PPSA12345', contentId: syntheticContentId(), name: 'Link Test', version: '1.00', locationKey: syntheticLocationKey() };
|
||||
|
||||
test('Link inventory stores console evidence separately and exposes runtime uncertainty', () => withDatabase(db => {
|
||||
const service = createEtahenLinkInventoryService({ db, now: () => 2000 });
|
||||
assert.equal(service.status().runtime.state, 'unknown');
|
||||
const result = service.apply(snapshot({ titles: [title] }));
|
||||
assert.equal(result.observed, 1); assert.equal(result.absent, 0); assert.equal(result.runtime.state, 'online');
|
||||
assert.equal(db.runQuery('SELECT COUNT(*) AS count FROM console_installs')[0].count, 1);
|
||||
assert.equal(db.runQuery('SELECT COUNT(*) AS count FROM local_copies')[0].count, 0);
|
||||
assert.match(db.runQuery('SELECT remote_location_key FROM console_installs')[0].remote_location_key, /^etahen:/);
|
||||
assert.equal(service.setRuntime({ consoleId: 'console_12345678', state: 'unknown' }).state, 'unknown');
|
||||
}));
|
||||
|
||||
test('only complete volume coverage marks a previously observed installation absent', () => withDatabase(db => {
|
||||
const service = createEtahenLinkInventoryService({ db });
|
||||
service.apply(snapshot({ scanId: 'scan_first001', titles: [title] }));
|
||||
service.apply(snapshot({ scanId: 'scan_partial1', coverage: 'partial', titles: [] }));
|
||||
assert.equal(db.runQuery('SELECT install_state FROM console_installs')[0].install_state, 'observed');
|
||||
service.apply(snapshot({ scanId: 'scan_complete2', coverage: 'complete', titles: [] }));
|
||||
assert.equal(db.runQuery('SELECT install_state FROM console_installs')[0].install_state, 'absent');
|
||||
}));
|
||||
|
||||
test('disconnected devices retain identity and reconnect restores observed evidence', () => withDatabase(db => {
|
||||
const service = createEtahenLinkInventoryService({ db });
|
||||
service.apply(snapshot({ scanId: 'scan_online001', titles: [title] }));
|
||||
const disconnected = service.apply(snapshot({ scanId: 'scan_offline01', coverage: 'unknown', titles: [], deviceAvailability: 'unavailable', runtimeState: 'offline', startedAt: 1200, completedAt: 1300 }));
|
||||
assert.equal(disconnected.devices[0].availability, 'unavailable');
|
||||
assert.equal(db.runQuery('SELECT COUNT(*) AS count FROM storage_devices')[0].count, 1);
|
||||
assert.equal(db.runQuery('SELECT install_state FROM console_installs')[0].install_state, 'device_unavailable');
|
||||
service.apply(snapshot({ scanId: 'scan_reconnect1', titles: [title], startedAt: 1400, completedAt: 1500 }));
|
||||
assert.equal(db.runQuery('SELECT install_state FROM console_installs')[0].install_state, 'observed');
|
||||
}));
|
||||
|
||||
test('semantic inventory rejects raw paths, duplicate identities and unscoped snapshots', () => withDatabase(db => {
|
||||
const service = createEtahenLinkInventoryService({ db });
|
||||
assert.throws(() => service.apply({ ...snapshot(), path: '/mnt/data' }), /field/i);
|
||||
assert.throws(() => service.apply({ ...snapshot(), RawPath: '/mnt/data' }), /field/i);
|
||||
assert.throws(() => service.apply({ ...snapshot(), consoleProfileId: '' }), /profile/i);
|
||||
const duplicate = snapshot(); duplicate.devices.push(duplicate.devices[0]);
|
||||
assert.throws(() => service.apply(duplicate), /duplicate/i);
|
||||
}));
|
||||
|
||||
test('desktop and server expose the same shared read-only status contract', () => {
|
||||
const mainSource = fs.readFileSync(path.join(__dirname, '../../main.js'), 'utf8');
|
||||
const serverSource = fs.readFileSync(path.join(__dirname, '../../src/server/create-registry.js'), 'utf8');
|
||||
const preloadSource = fs.readFileSync(path.join(__dirname, '../../preload.js'), 'utf8');
|
||||
for (const source of [mainSource, serverSource]) {
|
||||
assert.match(source, /createEtahenLinkInventoryService/);
|
||||
assert.match(source, /etahen-link:inventory:status/);
|
||||
}
|
||||
assert.match(serverSource, /name:\s*'etahen-link:inventory:status',\s*classification:\s*'read-only'/);
|
||||
assert.match(preloadSource, /etahenLinkInventoryStatus:\s*\(\)\s*=>\s*ipcRenderer\.invoke\('etahen-link:inventory:status'\)/);
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
'use strict';
|
||||
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const { createModel, exactConfirmation } = require('../../renderer/modules/etahen-link-ux');
|
||||
|
||||
test('health model distinguishes etaHEN, agent and Chimera uncertainty', () => {
|
||||
const unknown = createModel({ runtime: { state: 'unknown' } });
|
||||
assert.equal(unknown.etaHen.state, 'unknown'); assert.equal(unknown.agent.state, 'unknown'); assert.equal(unknown.chimera.state, 'unknown');
|
||||
const online = createModel({ runtime: { state: 'online', identity: { version: '2.5b' } }, agent: { state: 'offline', paired: true }, lastInventory: { completedAt: 123 } });
|
||||
assert.equal(online.etaHen.state, 'online'); assert.equal(online.agent.state, 'offline'); assert.equal(online.chimera.state, 'online');
|
||||
});
|
||||
|
||||
test('staged remains distinct from installed and recovery dominates presentation', () => {
|
||||
const staged = createModel({ job: { state: 'staged', stagedCount: 2, installedCount: 99 } });
|
||||
assert.equal(staged.transfer.stagedCount, 2); assert.equal(staged.transfer.installedCount, 0); assert.equal(staged.recoveryRequired, false);
|
||||
const recovery = createModel({ job: { state: 'recovery_required' } });
|
||||
assert.equal(recovery.recoveryRequired, true); assert.equal(recovery.approvalRequired, false);
|
||||
});
|
||||
|
||||
test('mutating confirmation requires local intent, recovery and exact identities', () => {
|
||||
const input = { action: 'INSTALL', target: 'artifact_12345678', runtimeEpoch: 'runtime_12345678', localIntent: true, recoveryAvailable: true };
|
||||
assert.equal(exactConfirmation(input), 'CONFIRM INSTALL artifact_12345678 runtime_12345678');
|
||||
assert.throws(() => exactConfirmation({ ...input, localIntent: false }), /local console intent/i);
|
||||
assert.throws(() => exactConfirmation({ ...input, recoveryAvailable: false }), /recovery/i);
|
||||
});
|
||||
|
||||
test('rendering contract is keyboard-labelled, responsive and reduced-motion safe', () => {
|
||||
const moduleSource = fs.readFileSync(path.join(__dirname, '../../renderer/modules/etahen-link-ux.js'), 'utf8');
|
||||
const css = fs.readFileSync(path.join(__dirname, '../../renderer/styles/etahen-link.css'), 'utf8');
|
||||
assert.match(moduleSource, /aria-labelledby="etahen-link-title"/); assert.match(moduleSource, /role="status" aria-live="polite"/); assert.match(moduleSource, /event\.key === 'Enter'/);
|
||||
assert.match(css, /@media \(max-width: 700px\)/); assert.match(css, /prefers-reduced-motion: reduce/); assert.match(css, /focus-visible/);
|
||||
});
|
||||
|
||||
test('English and Dutch Link catalogs have exact non-empty key parity', () => {
|
||||
const en = JSON.parse(fs.readFileSync(path.join(__dirname, '../../build/lang/en.json'), 'utf8')).strings;
|
||||
const nl = JSON.parse(fs.readFileSync(path.join(__dirname, '../../build/lang/nl.json'), 'utf8')).strings;
|
||||
const keys = Object.keys(en).filter(key => key.startsWith('etahenLink.')).sort();
|
||||
assert.ok(keys.length >= 12); assert.deepEqual(keys, Object.keys(nl).filter(key => key.startsWith('etahenLink.')).sort());
|
||||
for (const key of keys) { assert.ok(en[key].trim()); assert.ok(nl[key].trim()); }
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
'use strict';
|
||||
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { parseDpiResponse, createShimSimulator, createPinnedShim, createGuardedInstaller } = require('../../src/services/etahen-link-shim-installer');
|
||||
|
||||
const manifest = { shimId: 'shim_12345678', abiVersion: 'chimera-etahen-link-1', etahenVersion: '2.5B', etahenRevision: '5'.repeat(40), etahenBinarySha256: 'a'.repeat(64), shimBinarySha256: 'b'.repeat(64), firmwares: ['10.01'] };
|
||||
const runtime = { version: '2.5B', binarySha256: 'a'.repeat(64), firmware: '10.01', runtimeEpoch: 'runtime_12345678' };
|
||||
function setup(response, options = {}) { const broker = createShimSimulator({ manifest, response }); const shim = createPinnedShim({ manifest, broker }); return { broker, shim, installer: createGuardedInstaller({ shim, now: () => 5000, ...options }) }; }
|
||||
function proposal(installer) { return installer.propose({ consoleId: 'console_12345678', consoleProfileId: 'profile_12345678', titleId: 'PPSA12345', expectedVersion: '1.00', runtime, artifact: { artifactId: 'artifact_12345678', ticketId: 'ticket_12345678', contentId: 'UP0000-PPSA12345_00-TEST000000000001', size: 100, sha256: 'c'.repeat(64), status: 'staged', installed: false }, recovery: { verified: true, receiptId: 'recovery_12345678' } }); }
|
||||
|
||||
test('shim accepts only exact pinned ABI, etaHEN build and firmware', () => {
|
||||
const { shim } = setup('{"res":"0"}'); assert.equal(shim.probe(runtime).available, true);
|
||||
assert.equal(shim.probe({ ...runtime, binarySha256: 'd'.repeat(64) }).available, false);
|
||||
assert.throws(() => createPinnedShim({ manifest: { ...manifest, etahenRevision: '5061a85' }, broker: createShimSimulator({ manifest }) }), /full etaHEN revision/i);
|
||||
assert.throws(() => createPinnedShim({ manifest: { ...manifest, abiVersion: 'unknown-abi' }, broker: createShimSimulator({ manifest }) }), /ABI/i);
|
||||
});
|
||||
|
||||
test('DPI parser rejects hostile, oversized and ambiguous responses', () => {
|
||||
assert.deepEqual(parseDpiResponse('{"res":"0"}'), { acknowledged: true, resultCode: '0', installed: false, postScanRequired: true });
|
||||
for (const value of ['not json', '{"res":0}', '{"res":"0","extra":true}', 'x'.repeat(257)]) assert.throws(() => parseDpiResponse(value));
|
||||
});
|
||||
|
||||
test('broker receives only semantic ticket data and acknowledgement is not installation evidence', () => {
|
||||
const checkpoints = []; const audits = []; const { broker, installer } = setup('{"res":"0"}', { checkpoint: value => checkpoints.push(value.status), audit: value => audits.push(value.type) }); const plan = proposal(installer);
|
||||
const confirmed = installer.confirm(plan, { runtimeEpoch: runtime.runtimeEpoch, confirmation: plan.confirmation, localIntent: true });
|
||||
const applied = installer.apply(confirmed, { runtimeEpoch: runtime.runtimeEpoch });
|
||||
assert.equal(applied.status, 'acknowledged'); assert.equal(applied.installed, false); assert.equal(applied.postScanRequired, true);
|
||||
const request = broker.requests()[0]; assert.equal(request.operation, 'dpi.install-ticket'); assert.equal('url' in request, false); assert.equal('host' in request, false); assert.equal('port' in request, false);
|
||||
assert.deepEqual(checkpoints, ['applying', 'acknowledged']); assert.deepEqual(audits, ['etahen.install.preflighted', 'etahen.install.confirmed', 'etahen.install.acknowledgement']);
|
||||
});
|
||||
|
||||
test('stale confirmation and runtime loss fail closed', () => {
|
||||
const { installer } = setup('{"res":"0"}'); const plan = proposal(installer);
|
||||
assert.throws(() => installer.confirm(plan, { runtimeEpoch: 'runtime_stale001', confirmation: plan.confirmation, localIntent: true }), /fresh exact/i);
|
||||
const confirmed = installer.confirm(plan, { runtimeEpoch: runtime.runtimeEpoch, confirmation: plan.confirmation, localIntent: true });
|
||||
const applied = installer.apply(confirmed, { runtimeEpoch: runtime.runtimeEpoch });
|
||||
assert.equal(installer.runtimeLost(applied).status, 'outcome_unknown'); assert.equal(installer.runtimeLost(applied).installed, false);
|
||||
});
|
||||
|
||||
test('only a fresh complete matching post-scan creates installed evidence', () => {
|
||||
const { installer } = setup('{"res":"0"}'); const plan = proposal(installer); const applied = installer.apply(installer.confirm(plan, { runtimeEpoch: runtime.runtimeEpoch, confirmation: plan.confirmation, localIntent: true }), { runtimeEpoch: runtime.runtimeEpoch });
|
||||
const base = { consoleId: 'console_12345678', consoleProfileId: 'profile_12345678', titleId: 'PPSA12345', installState: 'observed', installedVersion: '1.00', observedAt: 6000, scanRunId: 'scanrun_12345678' };
|
||||
assert.equal(installer.verify(applied, { ...base, coverage: 'partial' }).installed, false);
|
||||
const verified = installer.verify(applied, { ...base, coverage: 'complete' }); assert.equal(verified.status, 'verified'); assert.equal(verified.installed, true); assert.equal(verified.installEvidence.source, 'complete-console-post-scan');
|
||||
});
|
||||
|
||||
test('raw network and execution fields never cross the shim contract', () => {
|
||||
const { shim, installer } = setup('{"res":"0"}');
|
||||
assert.throws(() => shim.submitArtifact({ ticketId: 'ticket_12345678', artifactId: 'artifact_12345678', size: 1, sha256: 'c'.repeat(64), url: 'http://example.invalid' }, runtime), /forbidden/i);
|
||||
assert.throws(() => installer.propose({ runtime, host: 'console.local' }), /forbidden/i);
|
||||
});
|
||||
@@ -0,0 +1,144 @@
|
||||
'use strict';
|
||||
|
||||
const test = require('node:test');
|
||||
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 { signEnvelope, createEnvelopeVerifier } = require('../../src/services/etahen-link-agent');
|
||||
const { createEtahenLinkOrchestrator, createFileJobJournal } = require('../../src/services/etahen-link-orchestrator');
|
||||
const { createShimSimulator, createPinnedShim, createGuardedInstaller } = require('../../src/services/etahen-link-shim-installer');
|
||||
|
||||
const consoleId = 'console_12345678'; const runtimeEpoch = 'runtime_12345678'; const sessionKey = 'a'.repeat(64); const journalKey = 'e'.repeat(64); const now = 100000;
|
||||
function capabilities() { return [{ name: 'console.inventory.read', available: true, authorized: true, healthy: true }, { name: 'artifact.hash.sha256', available: true, authorized: true, healthy: true }, { name: 'package.install.etahen-dpi-v1', available: true, authorized: true, healthy: true }]; }
|
||||
function envelope(job, nonce = 'nonce_12345678') { return signEnvelope({ jobId: job.jobId, nonce, consoleId, runtimeEpoch, issuedAt: now, expiresAt: now + 1000, job }, sessionKey); }
|
||||
function harness(t, handlers = {}, options = {}) {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'chimera-link-orchestrator-')); t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
const journal = createFileJobJournal({ file: path.join(root, 'jobs.json'), integrityKey: journalKey });
|
||||
const verifyEnvelope = createEnvelopeVerifier({ consoleId, runtimeEpoch, sessionKey, now: () => now });
|
||||
return { root, journal, orchestrator: createEtahenLinkOrchestrator({ consoleId, runtimeEpoch, capabilities: capabilities(), verifyEnvelope, journal, handlers, now: () => now, ...options }) };
|
||||
}
|
||||
|
||||
test('signed envelope still requires semantic capability validation before dispatch', t => {
|
||||
let calls = 0; const { orchestrator, journal } = harness(t, { 'inventory.scan': () => { calls += 1; return { observed: 1 }; } });
|
||||
const goodJob = { jobId: 'job_good12345', type: 'inventory.scan', runtimeEpoch, input: {} };
|
||||
assert.equal(orchestrator.dispatch(envelope(goodJob)).status, 'completed'); assert.equal(calls, 1);
|
||||
const forbidden = { jobId: 'job_bad123456', type: 'inventory.scan', runtimeEpoch, input: { path: '/mnt/data' } };
|
||||
assert.throws(() => orchestrator.dispatch(envelope(forbidden, 'nonce_22345678')), /forbidden/i); assert.equal(calls, 1); assert.equal(journal.get(forbidden.jobId), null);
|
||||
const unknown = { jobId: 'job_unknown12', type: 'inventory.scan', runtimeEpoch, input: { sessionKey: 'secret' } };
|
||||
assert.throws(() => orchestrator.dispatch(envelope(unknown, 'nonce_32345678')), /unknown typed job field/i); assert.equal(journal.get(unknown.jobId), null);
|
||||
});
|
||||
|
||||
test('duplicate delivery returns the durable receipt without repeating effects', t => {
|
||||
let calls = 0; const { orchestrator } = harness(t, { 'artifact.hash': () => { calls += 1; return { sha256: 'b'.repeat(64) }; } });
|
||||
const job = { jobId: 'job_duplicate1', type: 'artifact.hash', runtimeEpoch, input: { artifactId: 'artifact_12345678' } };
|
||||
const first = orchestrator.dispatch(envelope(job)); const second = orchestrator.dispatch(envelope(job, 'nonce_22345678'));
|
||||
assert.deepEqual(second, first); assert.equal(calls, 1);
|
||||
});
|
||||
|
||||
test('job ID reuse with changed semantic payload fails closed', t => {
|
||||
let calls = 0; const { orchestrator } = harness(t, { 'artifact.hash': () => { calls += 1; return { ok: true }; } });
|
||||
const first = { jobId: 'job_reused1234', type: 'artifact.hash', runtimeEpoch, input: { artifactId: 'artifact_12345678' } };
|
||||
const changed = { ...first, input: { artifactId: 'artifact_87654321' } };
|
||||
orchestrator.dispatch(envelope(first)); assert.throws(() => orchestrator.dispatch(envelope(changed, 'nonce_22345678')), /different semantic payload/i); assert.equal(calls, 1);
|
||||
});
|
||||
|
||||
test('restart recovery marks applying mutations outcome unknown and reads resume safely', t => {
|
||||
const { root, journal } = harness(t); const job = { jobId: 'job_recover123', type: 'package.install', runtimeEpoch, confirmationEpoch: runtimeEpoch, input: { artifactId: 'artifact_12345678' } };
|
||||
journal.begin(job, crypto.createHash('sha256').update(JSON.stringify(job)).digest('hex')); journal.transition(job.jobId, 'applying', { installed: false });
|
||||
const reopened = createFileJobJournal({ file: path.join(root, 'jobs.json'), integrityKey: journalKey }); const recovered = reopened.recover(runtimeEpoch);
|
||||
assert.equal(recovered[0].status, 'outcome_unknown'); assert.equal(recovered[0].result.installed, false); assert.equal(recovered[0].postScanRequired, true); assert.equal(recovered[0].job.confirmationEpoch, null);
|
||||
});
|
||||
|
||||
test('runtime epoch change pauses safe work and invalidates confirmations', t => {
|
||||
const { root, journal } = harness(t); const job = { jobId: 'job_paused1234', type: 'package.install', runtimeEpoch, confirmationEpoch: runtimeEpoch, input: {} };
|
||||
journal.begin(job, 'c'.repeat(64)); journal.transition(job.jobId, 'preflighted', {}); journal.transition(job.jobId, 'confirmed', {});
|
||||
const reopened = createFileJobJournal({ file: path.join(root, 'jobs.json'), integrityKey: journalKey }); const recovered = reopened.recover('runtime_87654321');
|
||||
assert.equal(recovered[0].status, 'paused'); assert.equal(recovered[0].job.confirmationEpoch, null);
|
||||
});
|
||||
|
||||
test('staging and acknowledgement receipts can never claim installed', t => {
|
||||
const { orchestrator } = harness(t, { 'artifact.hash': () => ({ status: 'staged', acknowledged: true, installed: true }) });
|
||||
const job = { jobId: 'job_staged1234', type: 'artifact.hash', runtimeEpoch, input: { artifactId: 'artifact_12345678' } };
|
||||
const receipt = orchestrator.dispatch(envelope(job)); assert.equal(receipt.result.status, 'staged'); assert.equal(receipt.result.installed, false);
|
||||
});
|
||||
|
||||
test('journal target state is authoritative for installed truth and terminal receipts are immutable', t => {
|
||||
const { journal } = harness(t); const job = { jobId: 'job_truthbound1', type: 'artifact.hash', runtimeEpoch, input: { artifactId: 'artifact_12345678' } };
|
||||
journal.begin(job, '8'.repeat(64)); const completed = journal.transition(job.jobId, 'completed', { status: 'verified', installed: true });
|
||||
assert.equal(completed.status, 'completed'); assert.equal(completed.result.installed, false);
|
||||
assert.throws(() => journal.transition(job.jobId, 'completed', { status: 'verified', installed: true }), error => error?.code === 'ETAHEN_JOURNAL_TRANSITION');
|
||||
});
|
||||
|
||||
test('mutating dispatch checkpoints before acknowledgement and installs only after post-scan', t => {
|
||||
let calls = 0;
|
||||
const verifyInstallation = (_receipt, scan) => scan.coverage === 'complete'
|
||||
? { status: 'verified', installed: true, scanRunId: scan.scanRunId }
|
||||
: { status: 'recovery_required', installed: false };
|
||||
const { orchestrator } = harness(t, { 'package.install': () => { calls += 1; return { status: 'acknowledged', acknowledged: true, installed: true, postScanRequired: true }; } }, { verifyInstallation });
|
||||
const job = { jobId: 'job_install1234', type: 'package.install', runtimeEpoch, confirmationEpoch: runtimeEpoch, input: { artifactId: 'artifact_12345678', titleId: 'PPSA12345' } };
|
||||
const acknowledged = orchestrator.dispatch(envelope(job)); assert.equal(acknowledged.status, 'acknowledged'); assert.equal(acknowledged.result.installed, false); assert.deepEqual(acknowledged.history.map(item => item.status), ['accepted', 'applying', 'acknowledged']);
|
||||
assert.deepEqual(orchestrator.dispatch(envelope(job, 'nonce_22345678')), acknowledged); assert.equal(calls, 1);
|
||||
const verified = orchestrator.applyPostScan(job.jobId, { coverage: 'complete', scanRunId: 'scanrun_12345678' });
|
||||
assert.equal(verified.status, 'verified'); assert.equal(verified.result.installed, true); assert.equal(verified.result.postScanRequired, false);
|
||||
});
|
||||
|
||||
test('hostile journal paths and corrupt state fail closed', t => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'chimera-link-hostile-journal-')); t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
assert.throws(() => createFileJobJournal({ file: path.join(root, 'unkeyed.json'), root }), /256-bit key/i);
|
||||
assert.throws(() => createFileJobJournal({ file: path.join(root, '..', 'outside.json'), root, integrityKey: journalKey }), /managed root/i);
|
||||
const file = path.join(root, 'jobs.json'); fs.writeFileSync(file, '{broken'); assert.throws(() => createFileJobJournal({ file, root, integrityKey: journalKey }), /journal/i);
|
||||
fs.writeFileSync(file, JSON.stringify({ schemaVersion: 2, jobs: [{ jobId: 'job_duplicate1', semanticHash: 'a'.repeat(64), job: { jobId: 'job_duplicate1', runtimeEpoch }, revision: 1, history: [{ status: 'accepted', at: 1 }] }, { jobId: 'job_duplicate1', semanticHash: 'a'.repeat(64), job: { jobId: 'job_duplicate1', runtimeEpoch }, revision: 1, history: [{ status: 'accepted', at: 1 }] }] }));
|
||||
assert.throws(() => createFileJobJournal({ file, root, integrityKey: journalKey }), /entry/i);
|
||||
});
|
||||
|
||||
test('journal integrity detects persisted record tampering', t => {
|
||||
const { root, journal } = harness(t); const job = { jobId: 'job_tamper1234', type: 'artifact.hash', runtimeEpoch, input: { artifactId: 'artifact_12345678' } };
|
||||
journal.begin(job, 'd'.repeat(64)); const file = path.join(root, 'jobs.json'); const state = JSON.parse(fs.readFileSync(file, 'utf8'));
|
||||
state.jobs[0].status = 'completed'; fs.writeFileSync(file, JSON.stringify(state));
|
||||
assert.throws(() => createFileJobJournal({ file, root, integrityKey: journalKey }), /entry/i);
|
||||
});
|
||||
|
||||
test('journal integrity key is required again after restart', t => {
|
||||
const { root, journal } = harness(t); const job = { jobId: 'job_keycheck12', type: 'artifact.hash', runtimeEpoch, input: { artifactId: 'artifact_12345678' } };
|
||||
journal.begin(job, 'f'.repeat(64)); const file = path.join(root, 'jobs.json');
|
||||
assert.throws(() => createFileJobJournal({ file, root, integrityKey: '1'.repeat(64) }), /different integrity key/i);
|
||||
assert.equal(createFileJobJournal({ file, root, integrityKey: journalKey }).get(job.jobId).status, 'accepted');
|
||||
});
|
||||
|
||||
test('completed receipts survive a keyed journal restart unchanged', t => {
|
||||
const { root, orchestrator } = harness(t, { 'artifact.hash': () => ({ sha256: 'b'.repeat(64) }) });
|
||||
const job = { jobId: 'job_restartdone', type: 'artifact.hash', runtimeEpoch, input: { artifactId: 'artifact_12345678' } };
|
||||
const completed = orchestrator.dispatch(envelope(job));
|
||||
const reopened = createFileJobJournal({ file: path.join(root, 'jobs.json'), root, integrityKey: journalKey });
|
||||
assert.deepEqual(reopened.get(job.jobId), completed); assert.equal(reopened.recover(runtimeEpoch)[0].status, 'completed');
|
||||
});
|
||||
|
||||
test('a mutating handler cannot self-assert verified installation', t => {
|
||||
const { orchestrator, journal } = harness(t, { 'package.install': () => ({ status: 'verified', acknowledged: true, installed: true }) });
|
||||
const job = { jobId: 'job_selfverify', type: 'package.install', runtimeEpoch, confirmationEpoch: runtimeEpoch, input: { artifactId: 'artifact_12345678', titleId: 'PPSA12345' } };
|
||||
assert.throws(() => orchestrator.dispatch(envelope(job)), /explicit acknowledgement/i);
|
||||
assert.equal(journal.get(job.jobId).status, 'outcome_unknown'); assert.equal(orchestrator.recover()[0].status, 'outcome_unknown');
|
||||
});
|
||||
|
||||
test('thenable handlers fail closed instead of persisting a false completion', t => {
|
||||
const { orchestrator, journal } = harness(t, { 'artifact.hash': async () => ({ sha256: 'b'.repeat(64) }) });
|
||||
const job = { jobId: 'job_async12345', type: 'artifact.hash', runtimeEpoch, input: { artifactId: 'artifact_12345678' } };
|
||||
assert.throws(() => orchestrator.dispatch(envelope(job)), /asynchronous handlers/i); assert.equal(journal.get(job.jobId).status, 'failed');
|
||||
});
|
||||
|
||||
test('real guarded installer composes from signed job through complete post-scan', t => {
|
||||
const manifest = { shimId: 'shim_12345678', abiVersion: 'chimera-etahen-link-1', etahenVersion: '2.5B', etahenRevision: '5'.repeat(40), etahenBinarySha256: 'b'.repeat(64), shimBinarySha256: 'c'.repeat(64), firmwares: ['10.01'] };
|
||||
const runtime = { version: '2.5B', binarySha256: 'b'.repeat(64), firmware: '10.01', runtimeEpoch };
|
||||
const broker = createShimSimulator({ manifest, response: '{"res":"0"}' }); const shim = createPinnedShim({ manifest, broker }); const installer = createGuardedInstaller({ shim, now: () => now });
|
||||
const handler = semantic => {
|
||||
const plan = installer.propose({ consoleId, consoleProfileId: 'profile_12345678', titleId: semantic.input.titleId, expectedVersion: '1.00', runtime, artifact: { artifactId: semantic.input.artifactId, ticketId: 'ticket_12345678', contentId: 'UP0000-PPSA12345_00-TEST000000000001', size: 100, sha256: 'd'.repeat(64), status: 'staged', installed: false }, recovery: { verified: true, receiptId: 'recovery_12345678' } });
|
||||
return installer.apply(installer.confirm(plan, { runtimeEpoch, confirmation: plan.confirmation, localIntent: true }), { runtimeEpoch });
|
||||
};
|
||||
const { orchestrator } = harness(t, { 'package.install': handler }, { verifyInstallation: (receipt, scan) => installer.verify(receipt, scan) });
|
||||
const job = { jobId: 'job_realflow12', type: 'package.install', runtimeEpoch, confirmationEpoch: runtimeEpoch, input: { artifactId: 'artifact_12345678', titleId: 'PPSA12345', expectedVersion: '1.00' } };
|
||||
const acknowledged = orchestrator.dispatch(envelope(job)); assert.equal(acknowledged.status, 'acknowledged'); assert.equal(acknowledged.result.installed, false); assert.equal(broker.requests().length, 1);
|
||||
const verified = orchestrator.applyPostScan(job.jobId, { coverage: 'complete', consoleId, consoleProfileId: 'profile_12345678', titleId: 'PPSA12345', installState: 'observed', installedVersion: '1.00', observedAt: now, scanRunId: 'scanrun_12345678' });
|
||||
assert.equal(verified.status, 'verified'); assert.equal(verified.result.installed, true); assert.equal(broker.requests().length, 1);
|
||||
});
|
||||
@@ -0,0 +1,616 @@
|
||||
'use strict';
|
||||
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const { SecretsStore } = require('../../src/security/secrets-store');
|
||||
const { canonical, signEnvelope, createEnvelopeVerifier } = require('../../src/services/etahen-link-agent');
|
||||
const { createFileJobJournal, createEtahenLinkOrchestrator } = require('../../src/services/etahen-link-orchestrator');
|
||||
const {
|
||||
createEtahenLinkKeyring,
|
||||
createEtahenLinkAuditLedger,
|
||||
createEtahenLinkRecoverySupervisor,
|
||||
openManagedEtahenLinkJournal,
|
||||
} = require('../../src/services/etahen-link-operations');
|
||||
|
||||
const runtimeEpoch = 'runtime_12345678';
|
||||
function fixture(t, options = {}) {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'chimera-link-operations-'));
|
||||
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
let clock = options.now ?? 1_000_000;
|
||||
const secrets = new SecretsStore({ filePath: path.join(root, 'secrets', 'secrets.enc.json'), keyFile: path.join(root, 'secrets', 'master.key') });
|
||||
const audits = [];
|
||||
const keyring = createEtahenLinkKeyring({
|
||||
secrets,
|
||||
stateFile: path.join(root, 'link', 'keyring.json'),
|
||||
root: path.join(root, 'link'),
|
||||
now: () => clock,
|
||||
randomBytes: size => Buffer.alloc(size, options.randomByte ?? 0x11),
|
||||
audit: (event, details) => audits.push({ event, details }),
|
||||
});
|
||||
return { root, secrets, keyring, audits, now: () => clock, advance: ms => { clock += ms; } };
|
||||
}
|
||||
function job(id = 'job_rotate1234') { return { jobId: id, type: 'package.install', runtimeEpoch, confirmationEpoch: runtimeEpoch, input: { artifactId: 'artifact_12345678' } }; }
|
||||
|
||||
test('keyring bootstraps encrypted material and exposes identifiers only', t => {
|
||||
const f = fixture(t); const status = f.keyring.status();
|
||||
assert.match(status.activeKeyId, /^key_[a-f0-9]{24}$/); assert.equal(status.pending, null); assert.deepEqual(status.previousKeyIds, []);
|
||||
assert.doesNotMatch(JSON.stringify(status), /1111111111111111/);
|
||||
const publicState = fs.readFileSync(path.join(f.root, 'link', 'keyring.json'), 'utf8');
|
||||
assert.doesNotMatch(publicState, /1111111111111111/); assert.doesNotMatch(publicState, /journal\.[A-Za-z0-9_-]+/);
|
||||
assert.match(fs.readFileSync(path.join(f.root, 'secrets', 'secrets.enc.json'), 'utf8'), /"data"/);
|
||||
});
|
||||
|
||||
test('staged rotation atomically rekeys a journal without changing jobs', t => {
|
||||
const f = fixture(t); const opened = openManagedEtahenLinkJournal({ file: path.join(f.root, 'link', 'jobs.json'), root: path.join(f.root, 'link'), keyring: f.keyring, now: f.now });
|
||||
opened.journal.begin(job(), 'a'.repeat(64)); const before = opened.journal.get('job_rotate1234'); const oldKeyId = opened.journal.integrity().keyId;
|
||||
const pending = f.keyring.prepareRotation({ reason: 'scheduled-maintenance' });
|
||||
const rotated = f.keyring.commitRotation(opened.journal);
|
||||
assert.equal(rotated.activeKeyId, pending.keyId); assert.notEqual(rotated.activeKeyId, oldKeyId);
|
||||
const after = opened.journal.get('job_rotate1234'); assert.deepEqual({ ...after, recordHash: null }, { ...before, recordHash: null }); assert.notEqual(after.recordHash, before.recordHash);
|
||||
assert.equal(openManagedEtahenLinkJournal({ file: path.join(f.root, 'link', 'jobs.json'), root: path.join(f.root, 'link'), keyring: f.keyring, now: f.now }).journal.integrity().keyId, pending.keyId);
|
||||
assert.throws(() => f.keyring.resolveJournalKey(oldKeyId), /retired/i);
|
||||
});
|
||||
|
||||
test('rotation fails before retiring a key shared by multiple durable journals', t => {
|
||||
const f = fixture(t); const directory = path.join(f.root, 'link');
|
||||
const first = openManagedEtahenLinkJournal({ file: path.join(directory, 'jobs-one.json'), root: directory, keyring: f.keyring, now: f.now }).journal;
|
||||
const second = openManagedEtahenLinkJournal({ file: path.join(directory, 'jobs-two.json'), root: directory, keyring: f.keyring, now: f.now }).journal;
|
||||
first.begin(job('job_multijrnl1'), '1'.repeat(64)); second.begin(job('job_multijrnl2'), '2'.repeat(64));
|
||||
assert.throws(() => f.keyring.prepareRotation({ reason: 'must-rekey-all-journals' }), error => error?.code === 'ETAHEN_KEYRING_JOURNAL'); assert.equal(f.keyring.status().pending, null);
|
||||
assert.equal(openManagedEtahenLinkJournal({ file: path.join(directory, 'jobs-one.json'), root: directory, keyring: f.keyring, now: f.now }).journal.get('job_multijrnl1').status, 'accepted');
|
||||
assert.equal(openManagedEtahenLinkJournal({ file: path.join(directory, 'jobs-two.json'), root: directory, keyring: f.keyring, now: f.now }).journal.get('job_multijrnl2').status, 'accepted');
|
||||
});
|
||||
|
||||
test('rotation target binding prevents an unrelated journal from aborting an in-flight rekey', t => {
|
||||
const f = fixture(t); const directory = path.join(f.root, 'link'); const firstFile = path.join(directory, 'jobs-target.json'); const secondFile = path.join(directory, 'jobs-racer.json');
|
||||
const first = openManagedEtahenLinkJournal({ file: firstFile, root: directory, keyring: f.keyring, now: f.now }).journal; first.begin(job('job_targetbind'), '3'.repeat(64));
|
||||
const pending = f.keyring.prepareRotation({ reason: 'bind-rekey-target' });
|
||||
const hooked = { integrity: () => first.integrity(), rotateIntegrityKey(descriptor) { const result = first.rotateIntegrityKey(descriptor); assert.throws(() => openManagedEtahenLinkJournal({ file: secondFile, root: directory, keyring: f.keyring, now: f.now }), error => error?.code === 'ETAHEN_KEYRING_JOURNAL'); return result; } };
|
||||
const committed = f.keyring.commitRotation(hooked); assert.equal(committed.activeKeyId, pending.keyId); assert.equal(committed.pending, null);
|
||||
assert.equal(openManagedEtahenLinkJournal({ file: firstFile, root: directory, keyring: f.keyring, now: f.now }).journal.get('job_targetbind').status, 'accepted');
|
||||
});
|
||||
|
||||
test('a durable rotation binding cannot be retargeted before its first journal write', t => {
|
||||
const f = fixture(t); const activeKeyId = f.keyring.status().activeKeyId; const firstId = `journal_${'a'.repeat(24)}`; const secondId = `journal_${'b'.repeat(24)}`;
|
||||
f.keyring.prepareRotation({ reason: 'bind-empty-target' });
|
||||
const interrupted = { integrity: () => ({ keyId: activeKeyId, commitmentId: firstId }), rotateIntegrityKey() { throw Object.assign(new Error('simulated rekey interruption'), { code: 'REKEY_INTERRUPTED' }); } };
|
||||
assert.throws(() => f.keyring.commitRotation(interrupted), error => error?.code === 'REKEY_INTERRUPTED');
|
||||
const bound = f.keyring.status().pending; assert.equal(bound.phase, 'rekeying'); assert.equal(bound.targetJournalId, firstId);
|
||||
const unrelated = { integrity: () => ({ keyId: activeKeyId, commitmentId: secondId }), rotateIntegrityKey() { throw new Error('must not run'); } };
|
||||
assert.throws(() => f.keyring.commitRotation(unrelated), error => error?.code === 'ETAHEN_KEYRING_JOURNAL'); assert.equal(f.keyring.status().pending.targetJournalId, firstId);
|
||||
assert.equal(f.keyring.reconcileJournalKey(activeKeyId, firstId).outcome, 'pending-aborted');
|
||||
});
|
||||
|
||||
test('empty managed journals register durably and block unsafe single-target rotation', t => {
|
||||
const f = fixture(t); const directory = path.join(f.root, 'link'); const firstFile = path.join(directory, 'jobs-empty-a.json'); const secondFile = path.join(directory, 'jobs-empty-b.json');
|
||||
const first = openManagedEtahenLinkJournal({ file: firstFile, root: directory, keyring: f.keyring, now: f.now }).journal;
|
||||
const second = openManagedEtahenLinkJournal({ file: secondFile, root: directory, keyring: f.keyring, now: f.now }).journal;
|
||||
assert.equal(f.keyring.journalAnchor(first.integrity().commitmentId).generation, 0); assert.equal(f.keyring.journalAnchor(second.integrity().commitmentId).generation, 0);
|
||||
assert.throws(() => f.keyring.prepareRotation({ reason: 'must-not-strand-empty-writer' }), error => error?.code === 'ETAHEN_KEYRING_JOURNAL');
|
||||
assert.equal(second.begin(job('job_emptywriter'), '4'.repeat(64)).status, 'accepted');
|
||||
assert.equal(openManagedEtahenLinkJournal({ file: secondFile, root: directory, keyring: f.keyring, now: f.now }).journal.get('job_emptywriter').status, 'accepted');
|
||||
});
|
||||
|
||||
test('journal and audit commitment caps reject before corrupting keyring state', t => {
|
||||
const f = fixture(t); const directory = path.join(f.root, 'link');
|
||||
for (let index = 0; index < 32; index += 1) openManagedEtahenLinkJournal({ file: path.join(directory, `jobs-cap-${index}.json`), root: directory, keyring: f.keyring, now: f.now });
|
||||
assert.throws(() => openManagedEtahenLinkJournal({ file: path.join(directory, 'jobs-cap-overflow.json'), root: directory, keyring: f.keyring, now: f.now }), error => error?.code === 'ETAHEN_KEYRING_RETENTION');
|
||||
assert.doesNotThrow(() => createEtahenLinkKeyring({ secrets: f.secrets, stateFile: path.join(directory, 'keyring.json'), root: directory, now: f.now, randomBytes: size => Buffer.alloc(size, 0x34) }));
|
||||
for (let index = 0; index < 32; index += 1) createEtahenLinkAuditLedger({ file: path.join(directory, `audit-cap-${index}.json`), root: directory, keyring: f.keyring, now: f.now }).append('etahen.link.audit.cap', { index, outcome: 'allowed' });
|
||||
const overflow = createEtahenLinkAuditLedger({ file: path.join(directory, 'audit-cap-overflow.json'), root: directory, keyring: f.keyring, now: f.now });
|
||||
assert.throws(() => overflow.append('etahen.link.audit.cap', { index: 32, outcome: 'denied' }), error => error?.code === 'ETAHEN_KEYRING_RETENTION');
|
||||
const publicState = JSON.parse(fs.readFileSync(path.join(directory, 'keyring.json'), 'utf8')); assert.equal(Object.keys(publicState.journalAnchors).length, 32); assert.equal(Object.keys(publicState.auditAnchors).length, 32);
|
||||
assert.doesNotThrow(() => createEtahenLinkKeyring({ secrets: f.secrets, stateFile: path.join(directory, 'keyring.json'), root: directory, now: f.now, randomBytes: size => Buffer.alloc(size, 0x35) }));
|
||||
});
|
||||
|
||||
test('failed rotation persistence does not advance in-memory key state', t => {
|
||||
const f = fixture(t); const file = path.join(f.root, 'link', 'keyring.json'); const initialBytes = fs.statSync(file).size;
|
||||
const constrained = createEtahenLinkKeyring({ secrets: f.secrets, stateFile: file, root: path.dirname(file), now: f.now, randomBytes: size => Buffer.alloc(size, 0x22), maxBytes: initialBytes + 8 });
|
||||
assert.throws(() => constrained.prepareRotation({ reason: 'quota-interruption' }), error => error?.code === 'ETAHEN_OPERATIONS_QUOTA');
|
||||
assert.equal(constrained.status().pending, null);
|
||||
const reopened = createEtahenLinkKeyring({ secrets: f.secrets, stateFile: file, root: path.dirname(file), now: f.now, randomBytes: size => Buffer.alloc(size, 0x33) });
|
||||
assert.equal(reopened.status().pending, null); assert.equal(reopened.status().activeKeyId, constrained.status().activeKeyId);
|
||||
});
|
||||
|
||||
test('stale keyring writers cannot overwrite newer authenticated state', t => {
|
||||
const f = fixture(t); const file = path.join(f.root, 'link', 'keyring.json');
|
||||
const stale = createEtahenLinkKeyring({ secrets: f.secrets, stateFile: file, root: path.dirname(file), now: f.now, randomBytes: size => Buffer.alloc(size, 0x44) });
|
||||
const prepared = f.keyring.prepareRotation({ reason: 'authoritative-rotation' });
|
||||
assert.throws(() => stale.prepareRotation({ reason: 'stale-rotation' }), error => error?.code === 'ETAHEN_KEYRING_CONCURRENT');
|
||||
const reopened = createEtahenLinkKeyring({ secrets: f.secrets, stateFile: file, root: path.dirname(file), now: f.now, randomBytes: size => Buffer.alloc(size, 0x55) });
|
||||
assert.equal(reopened.status().pending.keyId, prepared.keyId); assert.equal(reopened.status().pending.reason, 'authoritative-rotation');
|
||||
});
|
||||
|
||||
test('key rotation audit intent survives a sink interruption and replays idempotently', t => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'chimera-link-key-audit-')); t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
const secretOptions = { filePath: path.join(root, 'secrets', 'secrets.enc.json'), keyFile: path.join(root, 'secrets', 'master.key') }; const stateFile = path.join(root, 'link', 'keyring.json');
|
||||
const events = []; const dedupe = new Set(); let interruptPrepared = true;
|
||||
const audit = (event, details, options = {}) => {
|
||||
if (event === 'etahen.link.keyring.rotation-prepared' && interruptPrepared) { interruptPrepared = false; throw Object.assign(new Error('audit unavailable'), { code: 'AUDIT_DOWN' }); }
|
||||
if (options.dedupeKey && dedupe.has(options.dedupeKey)) return; if (options.dedupeKey) dedupe.add(options.dedupeKey); events.push({ event, details, options });
|
||||
};
|
||||
const first = createEtahenLinkKeyring({ secrets: new SecretsStore(secretOptions), stateFile, root: path.dirname(stateFile), now: () => 1_000_000, randomBytes: size => Buffer.alloc(size, 0x61), audit });
|
||||
assert.throws(() => first.prepareRotation({ reason: 'durable-audit-replay' }), error => error?.code === 'AUDIT_DOWN'); assert.ok(first.status().pending);
|
||||
const reopened = createEtahenLinkKeyring({ secrets: new SecretsStore(secretOptions), stateFile, root: path.dirname(stateFile), now: () => 1_000_000, randomBytes: size => Buffer.alloc(size, 0x62), audit });
|
||||
assert.equal(reopened.status().pending.reason, 'durable-audit-replay');
|
||||
createEtahenLinkKeyring({ secrets: new SecretsStore(secretOptions), stateFile, root: path.dirname(stateFile), now: () => 1_000_000, randomBytes: size => Buffer.alloc(size, 0x63), audit });
|
||||
assert.equal(events.filter(item => item.event === 'etahen.link.keyring.rotation-prepared').length, 1);
|
||||
});
|
||||
|
||||
test('stale independent secret stores cannot erase a committed pending key', t => {
|
||||
const f = fixture(t); const file = path.join(f.root, 'link', 'keyring.json');
|
||||
const secretOptions = { filePath: path.join(f.root, 'secrets', 'secrets.enc.json'), keyFile: path.join(f.root, 'secrets', 'master.key') };
|
||||
const staleSecrets = new SecretsStore(secretOptions);
|
||||
const stale = createEtahenLinkKeyring({ secrets: staleSecrets, stateFile: file, root: path.dirname(file), now: f.now, randomBytes: size => Buffer.alloc(size, 0x66) });
|
||||
const prepared = f.keyring.prepareRotation({ reason: 'retain-pending-secret' });
|
||||
assert.throws(() => stale.prepareRotation({ reason: 'stale-secret-store' }), error => error?.code === 'ETAHEN_KEYRING_CONCURRENT');
|
||||
const reloadedSecrets = new SecretsStore(secretOptions);
|
||||
assert.equal(reloadedSecrets.has(`etahen.link.journal.${prepared.keyId}`), true);
|
||||
const reopened = createEtahenLinkKeyring({ secrets: reloadedSecrets, stateFile: file, root: path.dirname(file), now: f.now, randomBytes: size => Buffer.alloc(size, 0x77) });
|
||||
assert.equal(reopened.resolvePendingKey(prepared.keyId).keyId, prepared.keyId);
|
||||
});
|
||||
|
||||
test('concurrent bootstrap cannot overwrite the winning encrypted key material', t => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'chimera-link-bootstrap-race-')); t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
const stateFile = path.join(root, 'link', 'keyring.json'); const secretOptions = { filePath: path.join(root, 'secrets', 'secrets.enc.json'), keyFile: path.join(root, 'secrets', 'master.key') };
|
||||
const staleSecrets = new SecretsStore(secretOptions); const winningSecrets = new SecretsStore(secretOptions); let winner; let triggered = false;
|
||||
const hookedSecrets = {
|
||||
get: name => staleSecrets.get(name), set: (name, value) => staleSecrets.set(name, value),
|
||||
has(name) {
|
||||
if (!triggered) { triggered = true; winner = createEtahenLinkKeyring({ secrets: winningSecrets, stateFile, root: path.dirname(stateFile), now: () => 1_000_000, randomBytes: size => Buffer.alloc(size, 0xb1) }); }
|
||||
return staleSecrets.has(name);
|
||||
},
|
||||
};
|
||||
assert.throws(() => createEtahenLinkKeyring({ secrets: hookedSecrets, stateFile, root: path.dirname(stateFile), now: () => 1_000_000, randomBytes: size => Buffer.alloc(size, 0xb2) }), error => error?.code === 'ETAHEN_KEYRING_CONCURRENT');
|
||||
const reopened = createEtahenLinkKeyring({ secrets: new SecretsStore(secretOptions), stateFile, root: path.dirname(stateFile), now: () => 1_000_000, randomBytes: size => Buffer.alloc(size, 0xb3) });
|
||||
assert.equal(reopened.status().activeKeyId, winner.status().activeKeyId); assert.equal(reopened.activeKey().keyId, winner.status().activeKeyId);
|
||||
});
|
||||
|
||||
test('orphaned writer locks are reclaimed without bypassing malformed locks', t => {
|
||||
const f = fixture(t); const directory = path.join(f.root, 'link'); const keyringFile = path.join(directory, 'keyring.json');
|
||||
const orphan = file => fs.writeFileSync(`${file}.lock`, JSON.stringify({ schemaVersion: 1, pid: process.pid, threadId: require('node:worker_threads').threadId, token: require('node:crypto').randomUUID(), createdAt: Date.now() }));
|
||||
orphan(keyringFile); assert.doesNotThrow(() => f.keyring.prepareRotation({ reason: 'orphan-lock-recovery' }));
|
||||
|
||||
const journalFile = path.join(directory, 'jobs.json'); const journal = openManagedEtahenLinkJournal({ file: journalFile, root: directory, keyring: f.keyring, now: f.now }).journal;
|
||||
orphan(journalFile); assert.doesNotThrow(() => journal.begin(job('job_lockreclaim'), '9'.repeat(64)));
|
||||
|
||||
const auditFile = path.join(directory, 'audit.json'); const ledger = createEtahenLinkAuditLedger({ file: auditFile, root: directory, keyring: f.keyring, now: f.now });
|
||||
orphan(auditFile); assert.doesNotThrow(() => ledger.append('etahen.link.lock.reclaimed', { outcome: 'allowed' }));
|
||||
|
||||
fs.writeFileSync(`${auditFile}.lock`, '{"schemaVersion":1,"pid":"tampered"}');
|
||||
assert.throws(() => ledger.append('etahen.link.lock.blocked', { outcome: 'denied' }), error => error?.code === 'ETAHEN_AUDIT_BUSY');
|
||||
fs.rmSync(`${auditFile}.lock`);
|
||||
if (process.ppid > 0 && process.ppid !== process.pid) {
|
||||
fs.writeFileSync(`${auditFile}.lock`, JSON.stringify({ schemaVersion: 1, pid: process.ppid, threadId: 0, token: require('node:crypto').randomUUID(), createdAt: Date.now() }));
|
||||
assert.throws(() => ledger.append('etahen.link.lock.active', { outcome: 'denied' }), error => error?.code === 'ETAHEN_AUDIT_BUSY');
|
||||
}
|
||||
});
|
||||
|
||||
test('failed orphan verification restores a canonical fail-closed lock', t => {
|
||||
const f = fixture(t); const file = path.join(f.root, 'link', 'keyring.json');
|
||||
fs.writeFileSync(`${file}.lock`, JSON.stringify({ schemaVersion: 1, pid: process.pid, threadId: require('node:worker_threads').threadId, token: require('node:crypto').randomUUID(), createdAt: Date.now() }));
|
||||
const originalRead = fs.readFileSync;
|
||||
fs.readFileSync = function injectedRead(target, ...args) { if (String(target).includes('.lock.reclaim.')) throw Object.assign(new Error('simulated lock read failure'), { code: 'EIO' }); return originalRead.call(this, target, ...args); };
|
||||
try { assert.throws(() => f.keyring.prepareRotation({ reason: 'must-stay-fail-closed' }), error => error?.code === 'ETAHEN_KEYRING_BUSY'); }
|
||||
finally { fs.readFileSync = originalRead; }
|
||||
assert.equal(fs.existsSync(`${file}.lock`), true); fs.rmSync(`${file}.lock`);
|
||||
});
|
||||
|
||||
test('lock release failure preserves committed journal state and forces reopen', t => {
|
||||
const f = fixture(t); const file = path.join(f.root, 'link', 'jobs-release.json'); const options = { file, root: path.dirname(file), keyring: f.keyring, now: f.now }; const journal = openManagedEtahenLinkJournal(options).journal;
|
||||
const originalUnlink = fs.unlinkSync; let interrupt = true;
|
||||
fs.unlinkSync = function injectedUnlink(target, ...args) { if (interrupt && String(target) === `${file}.lock`) { interrupt = false; throw Object.assign(new Error('simulated release failure'), { code: 'EACCES' }); } return originalUnlink.call(this, target, ...args); };
|
||||
try { assert.throws(() => journal.begin(job('job_releaseerr'), '5'.repeat(64)), error => error?.code === 'EACCES'); }
|
||||
finally { fs.unlinkSync = originalUnlink; }
|
||||
assert.throws(() => journal.get('job_releaseerr'), error => error?.code === 'ETAHEN_JOURNAL_RESTART_REQUIRED'); fs.rmSync(`${file}.lock`);
|
||||
const reopened = openManagedEtahenLinkJournal(options).journal; assert.equal(reopened.get('job_releaseerr').status, 'accepted'); assert.equal(reopened.list().length, 1);
|
||||
});
|
||||
|
||||
test('restart reconciles both rotation crash windows deterministically', t => {
|
||||
const f = fixture(t); const file = path.join(f.root, 'link', 'jobs.json'); const opened = openManagedEtahenLinkJournal({ file, root: path.dirname(file), keyring: f.keyring, now: f.now });
|
||||
const oldKeyId = opened.journal.integrity().keyId; const pending = f.keyring.prepareRotation({ reason: 'crash-window' });
|
||||
assert.throws(() => f.keyring.reconcileJournalKey(pending.keyId), error => error?.code === 'ETAHEN_KEYRING_JOURNAL');
|
||||
assert.equal(f.keyring.reconcileJournalKey(oldKeyId).outcome, 'pending-aborted'); assert.equal(f.keyring.status().pending, null);
|
||||
const second = f.keyring.prepareRotation({ reason: 'crash-after-rekey' });
|
||||
const interrupted = { integrity: () => opened.journal.integrity(), rotateIntegrityKey(descriptor) { opened.journal.rotateIntegrityKey(descriptor); throw Object.assign(new Error('simulated crash after rekey'), { code: 'REKEY_COMMITTED' }); } };
|
||||
assert.throws(() => f.keyring.commitRotation(interrupted), error => error?.code === 'REKEY_COMMITTED'); assert.equal(f.keyring.status().pending.phase, 'rekeying');
|
||||
const restarted = createEtahenLinkKeyring({ secrets: f.secrets, stateFile: path.join(f.root, 'link', 'keyring.json'), root: path.join(f.root, 'link'), now: f.now, randomBytes: size => Buffer.alloc(size, 0x33) });
|
||||
const commitmentId = opened.journal.integrity().commitmentId;
|
||||
assert.throws(() => restarted.reconcileJournalKey(oldKeyId, commitmentId), error => error?.code === 'ETAHEN_KEYRING_JOURNAL'); assert.equal(restarted.status().pending.keyId, second.keyId);
|
||||
assert.equal(restarted.reconcileJournalKey(second.keyId, commitmentId).outcome, 'pending-committed'); assert.equal(restarted.status().activeKeyId, second.keyId);
|
||||
});
|
||||
|
||||
test('an interrupted pending-key cleanup cannot roll back the committed abort', t => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'chimera-link-abort-cleanup-')); t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
const secretOptions = { filePath: path.join(root, 'secrets', 'secrets.enc.json'), keyFile: path.join(root, 'secrets', 'master.key') }; const store = new SecretsStore(secretOptions); let rejectDelete = false;
|
||||
const secrets = { get: name => store.get(name), has: name => store.has(name), set(name, value) { if (rejectDelete && name.startsWith('etahen.link.journal.') && value === '') throw Object.assign(new Error('cleanup unavailable'), { code: 'CLEANUP_DOWN' }); return store.set(name, value); } };
|
||||
const stateFile = path.join(root, 'link', 'keyring.json'); const keyring = createEtahenLinkKeyring({ secrets, stateFile, root: path.dirname(stateFile), now: () => 1_000_000, randomBytes: size => Buffer.alloc(size, 0x71) });
|
||||
const activeKeyId = keyring.status().activeKeyId; const pending = keyring.prepareRotation({ reason: 'abort-cleanup-crash' }); rejectDelete = true;
|
||||
assert.throws(() => keyring.reconcileJournalKey(activeKeyId), error => error?.code === 'CLEANUP_DOWN'); assert.equal(keyring.status().pending, null);
|
||||
const reopened = createEtahenLinkKeyring({ secrets: new SecretsStore(secretOptions), stateFile, root: path.dirname(stateFile), now: () => 1_000_000 });
|
||||
assert.equal(reopened.status().pending, null); assert.equal(reopened.status().activeKeyId, activeKeyId); assert.throws(() => reopened.resolveJournalKey(pending.keyId), /unknown/i);
|
||||
});
|
||||
|
||||
test('stale managed journal writers cannot erase durable replay evidence', t => {
|
||||
const f = fixture(t); const file = path.join(f.root, 'link', 'jobs.json'); const options = { file, root: path.dirname(file), keyring: f.keyring, now: f.now };
|
||||
const current = openManagedEtahenLinkJournal(options).journal; const stale = openManagedEtahenLinkJournal(options).journal;
|
||||
current.begin(job('job_current123'), 'a'.repeat(64));
|
||||
assert.throws(() => stale.begin(job('job_stale12345'), 'b'.repeat(64)), error => error?.code === 'ETAHEN_JOURNAL_CONCURRENT');
|
||||
const reopened = openManagedEtahenLinkJournal(options).journal;
|
||||
assert.deepEqual(reopened.list().map(item => item.jobId), ['job_current123']);
|
||||
});
|
||||
|
||||
test('journal rejects non-object audit details without mutating reopenable state', t => {
|
||||
const f = fixture(t); const file = path.join(f.root, 'link', 'jobs-audit-shape.json'); const options = { file, root: path.dirname(file), keyring: f.keyring, now: f.now }; const journal = openManagedEtahenLinkJournal(options).journal;
|
||||
assert.throws(() => journal.recordAudit({ event: 'etahen.link.audit.invalid', dedupeKey: 'invalid_primitive', details: 'primitive' }), error => error?.code === 'ETAHEN_JOURNAL_AUDIT');
|
||||
assert.throws(() => journal.begin(job('job_auditshape1'), 'a'.repeat(64), { event: 'etahen.link.audit.invalid', dedupeKey: 'invalid_array', details: [] }), error => error?.code === 'ETAHEN_JOURNAL_AUDIT');
|
||||
journal.begin(job('job_auditshape2'), 'b'.repeat(64));
|
||||
assert.throws(() => journal.transition('job_auditshape2', 'completed', { installed: false }, { event: 'etahen.link.audit.invalid', dedupeKey: 'invalid_transition', details: 42 }), error => error?.code === 'ETAHEN_JOURNAL_AUDIT');
|
||||
assert.equal(journal.get('job_auditshape2').status, 'accepted'); assert.equal(journal.auditEvidence().length, 0);
|
||||
const reopened = openManagedEtahenLinkJournal(options).journal; assert.equal(reopened.get('job_auditshape2').status, 'accepted'); assert.equal(reopened.auditEvidence().length, 0);
|
||||
});
|
||||
|
||||
test('journal validates the durable job identity before accepting it', t => {
|
||||
const f = fixture(t); const file = path.join(f.root, 'link', 'jobs-identity-shape.json'); const options = { file, root: path.dirname(file), keyring: f.keyring, now: f.now }; const journal = openManagedEtahenLinkJournal(options).journal;
|
||||
assert.throws(() => journal.begin({ ...job('job_badepoch1'), runtimeEpoch: 'x' }, 'c'.repeat(64)), error => error?.code === 'ETAHEN_JOURNAL_JOB');
|
||||
const deceptive = job('job_deceptive1'); Object.defineProperty(deceptive, 'toJSON', { value: () => ({ ...deceptive, jobId: 'job_changed123' }) });
|
||||
assert.throws(() => journal.begin(deceptive, 'd'.repeat(64)), error => error?.code === 'ETAHEN_JOURNAL_JOB'); assert.equal(journal.list().length, 0);
|
||||
journal.begin(job('job_identityok'), 'e'.repeat(64));
|
||||
const reopened = openManagedEtahenLinkJournal(options).journal; assert.equal(reopened.get('job_identityok').job.jobId, 'job_identityok'); assert.equal(reopened.list().length, 1);
|
||||
});
|
||||
|
||||
test('managed journal commitment detects valid-record truncation and deletion', t => {
|
||||
const first = fixture(t); const firstFile = path.join(first.root, 'link', 'jobs.json'); const firstOptions = { file: firstFile, root: path.dirname(firstFile), keyring: first.keyring, now: first.now };
|
||||
const journal = openManagedEtahenLinkJournal(firstOptions).journal; journal.begin(job('job_retained123'), 'a'.repeat(64)); journal.begin(job('job_removed123'), 'b'.repeat(64));
|
||||
const truncated = JSON.parse(fs.readFileSync(firstFile, 'utf8')); truncated.jobs.pop(); fs.writeFileSync(firstFile, JSON.stringify(truncated));
|
||||
assert.throws(() => openManagedEtahenLinkJournal(firstOptions), error => ['ETAHEN_JOURNAL_CORRUPT', 'ETAHEN_JOURNAL_ANCHOR'].includes(error?.code));
|
||||
|
||||
const second = fixture(t); const secondFile = path.join(second.root, 'link', 'jobs.json'); const secondOptions = { file: secondFile, root: path.dirname(secondFile), keyring: second.keyring, now: second.now };
|
||||
openManagedEtahenLinkJournal(secondOptions).journal.begin(job('job_deleted123'), 'c'.repeat(64)); fs.rmSync(secondFile);
|
||||
assert.throws(() => openManagedEtahenLinkJournal(secondOptions), error => error?.code === 'ETAHEN_JOURNAL_ANCHOR');
|
||||
});
|
||||
|
||||
test('combined public journal rollback and encrypted commitment deletion fails closed', t => {
|
||||
const f = fixture(t); const directory = path.join(f.root, 'link'); const file = path.join(directory, 'jobs.json'); const keyringFile = path.join(directory, 'keyring.json'); const options = { file, root: directory, keyring: f.keyring, now: f.now };
|
||||
const journal = openManagedEtahenLinkJournal(options).journal; journal.begin(job('job_first12345'), 'a'.repeat(64)); const oldJournal = fs.readFileSync(file); const oldKeyring = fs.readFileSync(keyringFile);
|
||||
journal.begin(job('job_second1234'), 'b'.repeat(64)); fs.writeFileSync(file, oldJournal); fs.writeFileSync(keyringFile, oldKeyring);
|
||||
const anchorId = `journal_${require('node:crypto').createHash('sha256').update(path.resolve(file)).digest('hex').slice(0, 24)}`;
|
||||
f.secrets.set(`etahen.link.journal-anchor.${anchorId}`, '');
|
||||
const rolledBackKeyring = createEtahenLinkKeyring({ secrets: f.secrets, stateFile: keyringFile, root: directory, now: f.now, randomBytes: size => Buffer.alloc(size, 0xab) });
|
||||
assert.throws(() => openManagedEtahenLinkJournal({ ...options, keyring: rolledBackKeyring }), error => error?.code === 'ETAHEN_JOURNAL_ANCHOR');
|
||||
});
|
||||
|
||||
test('newer encrypted journal commitment rejects public keyring and file rollback', t => {
|
||||
const f = fixture(t); const directory = path.join(f.root, 'link'); const file = path.join(directory, 'jobs.json'); const keyringFile = path.join(directory, 'keyring.json'); const options = { file, root: directory, keyring: f.keyring, now: f.now };
|
||||
const journal = openManagedEtahenLinkJournal(options).journal; journal.begin(job('job_first67890'), 'a'.repeat(64)); const oldJournal = fs.readFileSync(file); const oldKeyring = fs.readFileSync(keyringFile);
|
||||
journal.begin(job('job_second6789'), 'b'.repeat(64)); fs.writeFileSync(file, oldJournal); fs.writeFileSync(keyringFile, oldKeyring);
|
||||
const rolledBackKeyring = createEtahenLinkKeyring({ secrets: f.secrets, stateFile: keyringFile, root: directory, now: f.now, randomBytes: size => Buffer.alloc(size, 0xac) });
|
||||
assert.throws(() => openManagedEtahenLinkJournal({ ...options, keyring: rolledBackKeyring }), error => error?.code === 'ETAHEN_JOURNAL_ANCHOR');
|
||||
});
|
||||
|
||||
test('staged encrypted journal commitment reconciles before keyring commit', t => {
|
||||
const f = fixture(t); const directory = path.join(f.root, 'link'); const file = path.join(directory, 'jobs.json'); const keyringFile = path.join(directory, 'keyring.json'); const options = { file, root: directory, keyring: f.keyring, now: f.now };
|
||||
const journal = openManagedEtahenLinkJournal(options).journal; journal.begin(job('job_first24680'), 'a'.repeat(64)); const oldKeyring = fs.readFileSync(keyringFile);
|
||||
journal.begin(job('job_second2468'), 'b'.repeat(64)); fs.writeFileSync(keyringFile, oldKeyring);
|
||||
const interruptedKeyring = createEtahenLinkKeyring({ secrets: f.secrets, stateFile: keyringFile, root: directory, now: f.now, randomBytes: size => Buffer.alloc(size, 0xad) });
|
||||
const reopened = openManagedEtahenLinkJournal({ ...options, keyring: interruptedKeyring }).journal;
|
||||
assert.deepEqual(reopened.list().map(item => item.jobId), ['job_first24680', 'job_second2468']); assert.equal(reopened.integrity().generation, 2);
|
||||
});
|
||||
|
||||
test('staged empty-journal registration reconciles after a mirror-only crash', t => {
|
||||
const f = fixture(t); const directory = path.join(f.root, 'link'); const file = path.join(directory, 'jobs-empty-crash.json'); let mirrorWritten = false; let interruptRegistry = true;
|
||||
const interruptedSecrets = {
|
||||
has: name => f.secrets.has(name), get: name => f.secrets.get(name),
|
||||
set(name, value) {
|
||||
if (String(name).startsWith('etahen.link.journal-anchor.')) { f.secrets.set(name, value); mirrorWritten = true; return; }
|
||||
if (name === 'etahen.link.commitment-registry' && mirrorWritten && interruptRegistry) { interruptRegistry = false; throw Object.assign(new Error('simulated registry interruption'), { code: 'REGISTRY_INTERRUPTED' }); }
|
||||
f.secrets.set(name, value);
|
||||
},
|
||||
};
|
||||
const interrupted = createEtahenLinkKeyring({ secrets: interruptedSecrets, stateFile: path.join(directory, 'keyring.json'), root: directory, now: f.now, randomBytes: size => Buffer.alloc(size, 0x36) });
|
||||
assert.throws(() => openManagedEtahenLinkJournal({ file, root: directory, keyring: interrupted, now: f.now }), error => error?.code === 'REGISTRY_INTERRUPTED'); assert.equal(fs.existsSync(file), false);
|
||||
const restarted = createEtahenLinkKeyring({ secrets: f.secrets, stateFile: path.join(directory, 'keyring.json'), root: directory, now: f.now, randomBytes: size => Buffer.alloc(size, 0x37) });
|
||||
const journal = openManagedEtahenLinkJournal({ file, root: directory, keyring: restarted, now: f.now }).journal; assert.equal(restarted.journalAnchor(journal.integrity().commitmentId).pending, false);
|
||||
journal.begin(job('job_emptycrash'), '7'.repeat(64)); assert.equal(openManagedEtahenLinkJournal({ file, root: directory, keyring: restarted, now: f.now }).journal.get('job_emptycrash').status, 'accepted');
|
||||
});
|
||||
|
||||
test('managed open adopts a valid legacy schema-two journal before upgrading it', t => {
|
||||
const f = fixture(t); const directory = path.join(f.root, 'link'); const file = path.join(directory, 'jobs-legacy-two.json'); const descriptor = f.keyring.activeKey();
|
||||
const legacyWriter = createFileJobJournal({ file, root: directory, integrityKey: descriptor.key, integrityKeyId: descriptor.keyId, now: f.now }); legacyWriter.begin(job('job_legacytwo1'), '8'.repeat(64));
|
||||
const legacy = JSON.parse(fs.readFileSync(file, 'utf8')); legacy.schemaVersion = 2; delete legacy.integrity; fs.writeFileSync(file, JSON.stringify(legacy, null, 2));
|
||||
const managed = openManagedEtahenLinkJournal({ file, root: directory, keyring: f.keyring, now: f.now }).journal; assert.equal(managed.get('job_legacytwo1').status, 'accepted'); assert.equal(managed.integrity().generation, 0);
|
||||
managed.begin(job('job_legacytwo2'), '9'.repeat(64)); const reopened = openManagedEtahenLinkJournal({ file, root: directory, keyring: f.keyring, now: f.now }).journal;
|
||||
assert.deepEqual(reopened.list().map(item => item.jobId), ['job_legacytwo1', 'job_legacytwo2']); assert.equal(reopened.integrity().generation, 1);
|
||||
});
|
||||
|
||||
test('missing, wrong and retired journal keys fail closed', t => {
|
||||
const f = fixture(t); const old = f.keyring.status().activeKeyId;
|
||||
assert.throws(() => f.keyring.resolveJournalKey('key_' + 'f'.repeat(24)), /unknown/i);
|
||||
const journal = openManagedEtahenLinkJournal({ file: path.join(f.root, 'link', 'jobs.json'), root: path.join(f.root, 'link'), keyring: f.keyring, now: f.now }).journal;
|
||||
const pending = f.keyring.prepareRotation({ reason: 'retire-old' });
|
||||
f.keyring.commitRotation(journal); assert.equal(f.keyring.status().activeKeyId, pending.keyId);
|
||||
assert.throws(() => f.keyring.resolveJournalKey(old), /retired/i);
|
||||
f.secrets.set(`etahen.link.journal.${pending.keyId}`, ''); assert.throws(() => f.keyring.resolveJournalKey(pending.keyId), /missing/i);
|
||||
});
|
||||
|
||||
test('tampered or symlinked keyring metadata cannot select keys', t => {
|
||||
const f = fixture(t); const file = path.join(f.root, 'link', 'keyring.json'); const state = JSON.parse(fs.readFileSync(file, 'utf8'));
|
||||
state.active.keyId = 'key_' + 'f'.repeat(24); fs.writeFileSync(file, JSON.stringify(state));
|
||||
assert.throws(() => createEtahenLinkKeyring({ secrets: f.secrets, stateFile: file, root: path.dirname(file) }), /keyring/i);
|
||||
if (process.platform !== 'win32') {
|
||||
fs.rmSync(file); fs.symlinkSync(path.join(f.root, 'secrets', 'secrets.enc.json'), file);
|
||||
assert.throws(() => createEtahenLinkKeyring({ secrets: f.secrets, stateFile: file, root: path.dirname(file) }), /symlink/i);
|
||||
}
|
||||
});
|
||||
|
||||
test('missing public keyring state recovers from the registry while a missing root fails closed', t => {
|
||||
const first = fixture(t); const firstFile = path.join(first.root, 'link', 'keyring.json'); const activeKeyId = first.keyring.status().activeKeyId; fs.rmSync(firstFile);
|
||||
const recovered = createEtahenLinkKeyring({ secrets: first.secrets, stateFile: firstFile, root: path.dirname(firstFile) });
|
||||
assert.equal(recovered.status().activeKeyId, activeKeyId); assert.equal(fs.existsSync(firstFile), true);
|
||||
|
||||
const second = fixture(t); const secondFile = path.join(second.root, 'link', 'keyring.json'); second.secrets.set('etahen.link.keyring.root', '');
|
||||
assert.throws(() => createEtahenLinkKeyring({ secrets: second.secrets, stateFile: secondFile, root: path.dirname(secondFile) }), error => error?.code === 'ETAHEN_KEYRING_ROOT');
|
||||
assert.equal(second.secrets.has('etahen.link.keyring.root'), false);
|
||||
});
|
||||
|
||||
test('legacy registry recovery preserves its authenticated shape across restarts', t => {
|
||||
const f = fixture(t); const stateFile = path.join(f.root, 'link', 'keyring.json'); const legacy = JSON.parse(fs.readFileSync(stateFile, 'utf8'));
|
||||
delete legacy.auditAnchors; delete legacy.journalAnchors; delete legacy.auditEvidence; const digestValue = { ...legacy }; delete digestValue.stateHash;
|
||||
legacy.stateHash = require('node:crypto').createHmac('sha256', Buffer.from(f.secrets.get('etahen.link.keyring.root'), 'hex')).update(canonical(digestValue)).digest('hex');
|
||||
f.secrets.set('etahen.link.commitment-registry', JSON.stringify({ schemaVersion: 1, previousStateHash: '0'.repeat(64), state: legacy })); fs.rmSync(stateFile);
|
||||
const first = createEtahenLinkKeyring({ secrets: f.secrets, stateFile, root: path.dirname(stateFile), now: f.now }); assert.equal(first.status().activeKeyId, legacy.active.keyId);
|
||||
const second = createEtahenLinkKeyring({ secrets: f.secrets, stateFile, root: path.dirname(stateFile), now: f.now }); assert.equal(second.status().activeKeyId, legacy.active.keyId);
|
||||
});
|
||||
|
||||
test('encrypted keyring registry is mandatory and detects genesis rollback', t => {
|
||||
const missing = fixture(t); const missingFile = path.join(missing.root, 'link', 'keyring.json'); missing.secrets.set('etahen.link.commitment-registry', '');
|
||||
assert.throws(() => createEtahenLinkKeyring({ secrets: missing.secrets, stateFile: missingFile, root: path.dirname(missingFile), now: missing.now }), error => error?.code === 'ETAHEN_KEYRING_REGISTRY');
|
||||
|
||||
const f = fixture(t); const directory = path.join(f.root, 'link'); const keyringFile = path.join(directory, 'keyring.json'); const bootstrapState = fs.readFileSync(keyringFile);
|
||||
const jobFile = path.join(directory, 'jobs.json'); openManagedEtahenLinkJournal({ file: jobFile, root: directory, keyring: f.keyring, now: f.now }).journal.begin(job('job_genesis123'), 'e'.repeat(64));
|
||||
const auditFile = path.join(directory, 'audit.json'); createEtahenLinkAuditLedger({ file: auditFile, root: directory, keyring: f.keyring, now: f.now }).append('etahen.link.genesis', { outcome: 'allowed' });
|
||||
fs.writeFileSync(keyringFile, bootstrapState); fs.rmSync(jobFile); fs.rmSync(auditFile);
|
||||
const journalAnchorId = `journal_${require('node:crypto').createHash('sha256').update(path.resolve(jobFile)).digest('hex').slice(0, 24)}`;
|
||||
const auditAnchorId = `audit_${require('node:crypto').createHash('sha256').update(path.resolve(auditFile)).digest('hex').slice(0, 24)}`;
|
||||
f.secrets.set(`etahen.link.journal-anchor.${journalAnchorId}`, ''); f.secrets.set(`etahen.link.audit-anchor.${auditAnchorId}`, '');
|
||||
assert.throws(() => createEtahenLinkKeyring({ secrets: f.secrets, stateFile: keyringFile, root: directory, now: f.now }), error => error?.code === 'ETAHEN_KEYRING_REGISTRY');
|
||||
});
|
||||
|
||||
test('authenticated audit ledger redacts secrets and deduplicates decisions', t => {
|
||||
const f = fixture(t); const ledger = createEtahenLinkAuditLedger({ file: path.join(f.root, 'link', 'audit.json'), root: path.join(f.root, 'link'), keyring: f.keyring, now: f.now });
|
||||
const first = ledger.append('etahen.link.dispatch.denied', { jobId: 'job_audit1234', outcome: 'denied', token: 'raw-token', reason: 'bad-signature' }, { dedupeKey: 'deny_job_audit1234_r1' });
|
||||
const duplicate = ledger.append('etahen.link.dispatch.denied', { jobId: 'job_audit1234', outcome: 'denied' }, { dedupeKey: 'deny_job_audit1234_r1' });
|
||||
assert.deepEqual(duplicate, first); assert.equal(ledger.list().length, 1); assert.equal(first.details.token, '[REDACTED]');
|
||||
assert.doesNotMatch(fs.readFileSync(path.join(f.root, 'link', 'audit.json'), 'utf8'), /raw-token/);
|
||||
});
|
||||
|
||||
test('public audit identifiers are derived from redacted details', t => {
|
||||
const first = fixture(t); const second = fixture(t);
|
||||
const firstEntry = createEtahenLinkAuditLedger({ file: path.join(first.root, 'link', 'audit.json'), root: path.join(first.root, 'link'), keyring: first.keyring, now: first.now }).append('etahen.link.secret.test', { outcome: 'denied', token: 'short-secret-one' });
|
||||
const secondEntry = createEtahenLinkAuditLedger({ file: path.join(second.root, 'link', 'audit.json'), root: path.join(second.root, 'link'), keyring: second.keyring, now: second.now }).append('etahen.link.secret.test', { outcome: 'denied', token: 'short-secret-two' });
|
||||
assert.equal(firstEntry.eventId, secondEntry.eventId); assert.deepEqual(firstEntry.details, secondEntry.details);
|
||||
});
|
||||
|
||||
test('audit ledger detects record tampering and tail truncation', t => {
|
||||
const f = fixture(t); const file = path.join(f.root, 'link', 'audit.json'); const options = { file, root: path.dirname(file), keyring: f.keyring, now: f.now };
|
||||
const ledger = createEtahenLinkAuditLedger(options); ledger.append('etahen.link.one', { outcome: 'allowed' }); ledger.append('etahen.link.two', { outcome: 'denied' });
|
||||
const tampered = JSON.parse(fs.readFileSync(file, 'utf8')); tampered.entries[0].details.outcome = 'allowed-after-tamper'; fs.writeFileSync(file, JSON.stringify(tampered));
|
||||
assert.throws(() => createEtahenLinkAuditLedger(options), /audit/i);
|
||||
const f2 = fixture(t); const file2 = path.join(f2.root, 'link', 'audit.json');
|
||||
const options2 = { file: file2, root: path.dirname(file2), keyring: f2.keyring, now: f2.now };
|
||||
const fresh = createEtahenLinkAuditLedger(options2); fresh.append('etahen.link.one', { outcome: 'allowed' }); fresh.append('etahen.link.two', { outcome: 'denied' });
|
||||
const truncated = JSON.parse(fs.readFileSync(file2, 'utf8')); truncated.entries.pop(); truncated.entryCount = truncated.entries.length; truncated.headHash = truncated.entries.at(-1).recordHash; fs.writeFileSync(file2, JSON.stringify(truncated));
|
||||
assert.throws(() => createEtahenLinkAuditLedger(options2), /audit/i);
|
||||
});
|
||||
|
||||
test('audit ledger detects reordered records and sequence gaps', t => {
|
||||
const f = fixture(t); const file = path.join(f.root, 'link', 'audit.json'); const options = { file, root: path.dirname(file), keyring: f.keyring, now: f.now };
|
||||
const ledger = createEtahenLinkAuditLedger(options); ledger.append('etahen.link.one', { outcome: 'allowed' }); ledger.append('etahen.link.two', { outcome: 'denied' });
|
||||
const reordered = JSON.parse(fs.readFileSync(file, 'utf8')); reordered.entries.reverse(); fs.writeFileSync(file, JSON.stringify(reordered));
|
||||
assert.throws(() => createEtahenLinkAuditLedger(options), /audit/i);
|
||||
const f2 = fixture(t); const file2 = path.join(f2.root, 'link', 'audit.json');
|
||||
const options2 = { file: file2, root: path.dirname(file2), keyring: f2.keyring, now: f2.now };
|
||||
const fresh = createEtahenLinkAuditLedger(options2); fresh.append('etahen.link.one', { outcome: 'allowed' }); fresh.append('etahen.link.two', { outcome: 'denied' });
|
||||
const gap = JSON.parse(fs.readFileSync(file2, 'utf8')); gap.entries[1].sequence = 3; fs.writeFileSync(file2, JSON.stringify(gap));
|
||||
assert.throws(() => createEtahenLinkAuditLedger(options2), /audit/i);
|
||||
});
|
||||
|
||||
test('encrypted audit anchor detects complete ledger deletion', t => {
|
||||
const f = fixture(t); const file = path.join(f.root, 'link', 'audit.json'); const options = { file, root: path.dirname(file), keyring: f.keyring, now: f.now };
|
||||
createEtahenLinkAuditLedger(options).append('etahen.link.retained', { outcome: 'allowed' }); fs.rmSync(file);
|
||||
assert.throws(() => createEtahenLinkAuditLedger(options), /missing.*anchor/i);
|
||||
});
|
||||
|
||||
test('deleting the encrypted anchor mirror cannot hide ledger rollback', t => {
|
||||
const f = fixture(t); const file = path.join(f.root, 'link', 'audit.json'); const options = { file, root: path.dirname(file), keyring: f.keyring, now: f.now };
|
||||
const ledger = createEtahenLinkAuditLedger(options); ledger.append('etahen.link.first', { outcome: 'allowed' }); const oldLedger = fs.readFileSync(file);
|
||||
ledger.append('etahen.link.second', { outcome: 'allowed' });
|
||||
const anchorId = `audit_${require('node:crypto').createHash('sha256').update(path.resolve(file)).digest('hex').slice(0, 24)}`;
|
||||
f.secrets.set(`etahen.link.audit-anchor.${anchorId}`, ''); fs.writeFileSync(file, oldLedger);
|
||||
assert.throws(() => createEtahenLinkAuditLedger(options), /anchor/i);
|
||||
});
|
||||
|
||||
test('newer encrypted anchor rejects authenticated keyring and ledger rollback', t => {
|
||||
const f = fixture(t); const directory = path.join(f.root, 'link'); const file = path.join(directory, 'audit.json'); const keyringFile = path.join(directory, 'keyring.json');
|
||||
const ledger = createEtahenLinkAuditLedger({ file, root: directory, keyring: f.keyring, now: f.now });
|
||||
ledger.append('etahen.link.first', { outcome: 'allowed' }); const oldLedger = fs.readFileSync(file); const oldKeyring = fs.readFileSync(keyringFile);
|
||||
ledger.append('etahen.link.second', { outcome: 'allowed' }); fs.writeFileSync(file, oldLedger); fs.writeFileSync(keyringFile, oldKeyring);
|
||||
const rolledBackKeyring = createEtahenLinkKeyring({ secrets: f.secrets, stateFile: keyringFile, root: directory, now: f.now, randomBytes: size => Buffer.alloc(size, 0x88) });
|
||||
assert.throws(() => createEtahenLinkAuditLedger({ file, root: directory, keyring: rolledBackKeyring, now: f.now }), error => error?.code === 'ETAHEN_AUDIT_ANCHOR');
|
||||
});
|
||||
|
||||
test('combined public rollback and encrypted anchor deletion fails closed', t => {
|
||||
const f = fixture(t); const directory = path.join(f.root, 'link'); const file = path.join(directory, 'audit.json'); const keyringFile = path.join(directory, 'keyring.json');
|
||||
const ledger = createEtahenLinkAuditLedger({ file, root: directory, keyring: f.keyring, now: f.now });
|
||||
ledger.append('etahen.link.first', { outcome: 'allowed' }); const oldLedger = fs.readFileSync(file); const oldKeyring = fs.readFileSync(keyringFile);
|
||||
ledger.append('etahen.link.second', { outcome: 'allowed' }); fs.writeFileSync(file, oldLedger); fs.writeFileSync(keyringFile, oldKeyring);
|
||||
const anchorId = `audit_${require('node:crypto').createHash('sha256').update(path.resolve(file)).digest('hex').slice(0, 24)}`;
|
||||
f.secrets.set(`etahen.link.audit-anchor.${anchorId}`, '');
|
||||
const rolledBackKeyring = createEtahenLinkKeyring({ secrets: f.secrets, stateFile: keyringFile, root: directory, now: f.now, randomBytes: size => Buffer.alloc(size, 0x99) });
|
||||
assert.throws(() => createEtahenLinkAuditLedger({ file, root: directory, keyring: rolledBackKeyring, now: f.now }), error => error?.code === 'ETAHEN_AUDIT_ANCHOR');
|
||||
});
|
||||
|
||||
test('staged encrypted anchor reconciles a crash before public commitment', t => {
|
||||
const f = fixture(t); const directory = path.join(f.root, 'link'); const file = path.join(directory, 'audit.json'); const keyringFile = path.join(directory, 'keyring.json');
|
||||
const ledger = createEtahenLinkAuditLedger({ file, root: directory, keyring: f.keyring, now: f.now });
|
||||
ledger.append('etahen.link.first', { outcome: 'allowed' }); const oldKeyring = fs.readFileSync(keyringFile);
|
||||
ledger.append('etahen.link.second', { outcome: 'allowed' }); fs.writeFileSync(keyringFile, oldKeyring);
|
||||
const interruptedKeyring = createEtahenLinkKeyring({ secrets: f.secrets, stateFile: keyringFile, root: directory, now: f.now, randomBytes: size => Buffer.alloc(size, 0xaa) });
|
||||
const reopened = createEtahenLinkAuditLedger({ file, root: directory, keyring: interruptedKeyring, now: f.now });
|
||||
assert.equal(reopened.verify().entries, 2); assert.equal(reopened.list()[1].event, 'etahen.link.second');
|
||||
});
|
||||
|
||||
test('stale audit writers fail before forking anchored evidence', t => {
|
||||
const f = fixture(t); const file = path.join(f.root, 'link', 'audit.json'); const options = { file, root: path.dirname(file), keyring: f.keyring, now: f.now };
|
||||
const current = createEtahenLinkAuditLedger(options); const stale = createEtahenLinkAuditLedger(options);
|
||||
current.append('etahen.link.current', { outcome: 'allowed' });
|
||||
assert.throws(() => stale.append('etahen.link.stale', { outcome: 'allowed' }), error => error?.code === 'ETAHEN_AUDIT_CONCURRENT');
|
||||
const reopened = createEtahenLinkAuditLedger(options);
|
||||
assert.equal(reopened.verify().entries, 1); assert.equal(reopened.list()[0].event, 'etahen.link.current');
|
||||
});
|
||||
|
||||
test('restart reconciles the single audit write-before-anchor crash window', t => {
|
||||
const f = fixture(t); const file = path.join(f.root, 'link', 'audit.json'); let interrupted = true;
|
||||
const flakyKeyring = {
|
||||
...f.keyring,
|
||||
writeAuditAnchor(anchorId, value) {
|
||||
if (interrupted) { interrupted = false; throw Object.assign(new Error('simulated anchor interruption'), { code: 'SIMULATED_ANCHOR_STOP' }); }
|
||||
return f.keyring.writeAuditAnchor(anchorId, value);
|
||||
},
|
||||
};
|
||||
const interruptedLedger = createEtahenLinkAuditLedger({ file, root: path.dirname(file), keyring: flakyKeyring, now: f.now });
|
||||
assert.throws(() => interruptedLedger.append('etahen.link.before-anchor', { outcome: 'checkpointed' }), /anchor interruption/i);
|
||||
assert.throws(() => interruptedLedger.append('etahen.link.must-reopen', { outcome: 'blocked' }), error => error?.code === 'ETAHEN_AUDIT_RESTART_REQUIRED');
|
||||
const reopened = createEtahenLinkAuditLedger({ file, root: path.dirname(file), keyring: f.keyring, now: f.now });
|
||||
assert.equal(reopened.verify().entries, 1); assert.equal(reopened.list()[0].event, 'etahen.link.before-anchor');
|
||||
});
|
||||
|
||||
test('audit history remains verifiable across journal key rotation', t => {
|
||||
const f = fixture(t); const journal = openManagedEtahenLinkJournal({ file: path.join(f.root, 'link', 'jobs.json'), root: path.join(f.root, 'link'), keyring: f.keyring, now: f.now }).journal;
|
||||
const auditFile = path.join(f.root, 'link', 'audit.json'); const ledger = createEtahenLinkAuditLedger({ file: auditFile, root: path.dirname(auditFile), keyring: f.keyring, now: f.now });
|
||||
const oldKeyId = ledger.append('etahen.link.before', { outcome: 'allowed' }).keyId;
|
||||
f.keyring.prepareRotation({ reason: 'audit-continuity' }); f.keyring.commitRotation(journal);
|
||||
const newKeyId = ledger.append('etahen.link.after', { outcome: 'allowed' }).keyId;
|
||||
assert.notEqual(newKeyId, oldKeyId); assert.equal(createEtahenLinkAuditLedger({ file: auditFile, root: path.dirname(auditFile), keyring: f.keyring, now: f.now }).verify().entries, 2);
|
||||
});
|
||||
|
||||
test('recovery supervisor emits bounded idempotent alerts without retrying or installing', t => {
|
||||
const f = fixture(t); const journal = openManagedEtahenLinkJournal({ file: path.join(f.root, 'link', 'jobs.json'), root: path.join(f.root, 'link'), keyring: f.keyring, now: f.now }).journal;
|
||||
journal.begin(job('job_unknown123'), 'b'.repeat(64)); journal.transition('job_unknown123', 'applying', { installed: false }); journal.recover(runtimeEpoch);
|
||||
const calls = []; const supervisor = createEtahenLinkRecoverySupervisor({ journal, now: f.now, alertAfterMs: 1000, escalateAfterMs: 5000, maxAlerts: 10, audit: (event, details, options) => { calls.push({ event, details, options }); return { event }; } });
|
||||
assert.equal(supervisor.inspect().alerts.length, 0); f.advance(1000);
|
||||
const attention = supervisor.inspect(); assert.equal(attention.alerts[0].severity, 'attention'); assert.equal(attention.alerts[0].installed, false);
|
||||
supervisor.inspect(); f.advance(4000); const expired = supervisor.inspect(); assert.equal(expired.alerts[0].severity, 'expired');
|
||||
assert.equal(journal.get('job_unknown123').status, 'outcome_unknown'); assert.equal(journal.get('job_unknown123').result.installed, false);
|
||||
assert.deepEqual(calls.map(item => item.options.dedupeKey), ['recovery_job_unknown123_attention_r3', 'recovery_job_unknown123_attention_r3', 'recovery_job_unknown123_expired_r3']);
|
||||
});
|
||||
|
||||
test('repeated recovery does not revise already paused work or advance its journal', t => {
|
||||
const f = fixture(t); const journal = openManagedEtahenLinkJournal({ file: path.join(f.root, 'link', 'jobs.json'), root: path.join(f.root, 'link'), keyring: f.keyring, now: f.now }).journal;
|
||||
journal.begin(job('job_pauseonce1'), '7'.repeat(64)); const first = journal.recover(runtimeEpoch)[0]; const generation = journal.integrity().generation; const second = journal.recover(runtimeEpoch)[0];
|
||||
assert.equal(first.status, 'paused'); assert.equal(second.revision, first.revision); assert.equal(journal.integrity().generation, generation);
|
||||
});
|
||||
|
||||
test('recovery supervision is bounded and validates timing policy', t => {
|
||||
const f = fixture(t); const fakeJournal = { list: () => Array.from({ length: 5 }, (_, index) => ({ jobId: `job_bound${index}0000`, status: 'outcome_unknown', revision: 2, updatedAt: 1, result: { installed: false }, history: [{ status: 'outcome_unknown', at: 1 }] })) };
|
||||
assert.throws(() => createEtahenLinkRecoverySupervisor({ journal: fakeJournal, alertAfterMs: 5000, escalateAfterMs: 1000 }), /timing policy/i);
|
||||
const report = createEtahenLinkRecoverySupervisor({ journal: fakeJournal, now: () => 10000, alertAfterMs: 1, escalateAfterMs: 2, maxAlerts: 2 }).inspect();
|
||||
assert.equal(report.alerts.length, 2); assert.equal(report.truncated, true); assert.equal(report.totalAmbiguous, 5);
|
||||
});
|
||||
|
||||
test('recovery supervision preserves epoch-zero transition timestamps', () => {
|
||||
const fakeJournal = { list: () => [{ jobId: 'job_epochzero1', status: 'outcome_unknown', revision: 2, updatedAt: 0, result: { installed: false }, history: [{ status: 'accepted', at: 0 }, { status: 'outcome_unknown', at: 0 }] }] };
|
||||
const report = createEtahenLinkRecoverySupervisor({ journal: fakeJournal, now: () => 10_000, alertAfterMs: 1_000, escalateAfterMs: 5_000 }).inspect();
|
||||
assert.equal(report.alerts[0].enteredAt, 0); assert.equal(report.alerts[0].ageMs, 10_000); assert.equal(report.alerts[0].severity, 'expired');
|
||||
});
|
||||
|
||||
test('orchestrator mirrors denials, mutation checkpoints and recovery into authenticated audit', t => {
|
||||
const f = fixture(t); const journal = openManagedEtahenLinkJournal({ file: path.join(f.root, 'link', 'jobs.json'), root: path.join(f.root, 'link'), keyring: f.keyring, now: f.now }).journal;
|
||||
const ledger = createEtahenLinkAuditLedger({ file: path.join(f.root, 'link', 'audit.json'), root: path.join(f.root, 'link'), keyring: f.keyring, now: f.now });
|
||||
const consoleId = 'console_12345678'; const sessionKey = 'a'.repeat(64); const capabilities = [{ name: 'package.install.etahen-dpi-v1', available: true, authorized: true, healthy: true }];
|
||||
const verifier = createEnvelopeVerifier({ consoleId, runtimeEpoch, sessionKey, now: f.now });
|
||||
const orchestrator = createEtahenLinkOrchestrator({ consoleId, runtimeEpoch, capabilities, verifyEnvelope: verifier, journal, handlers: { 'package.install': () => { throw Object.assign(new Error('simulated stop'), { code: 'SIMULATED_STOP' }); } }, audit: (event, details, options) => ledger.append(event, details, options), now: f.now });
|
||||
const deniedJob = { jobId: 'job_denied1234', type: 'package.install', runtimeEpoch, confirmationEpoch: runtimeEpoch, input: { artifactId: 'artifact_12345678', token: 'must-not-leak' } };
|
||||
const envelopeFor = (value, nonce) => signEnvelope({ jobId: value.jobId, nonce, consoleId, runtimeEpoch, issuedAt: f.now(), expiresAt: f.now() + 1000, job: value }, sessionKey);
|
||||
assert.throws(() => orchestrator.dispatch(envelopeFor(deniedJob, 'nonce_denied123')), /unknown typed job field/i);
|
||||
const activeJob = { jobId: 'job_audited1234', type: 'package.install', runtimeEpoch, confirmationEpoch: runtimeEpoch, input: { artifactId: 'artifact_12345678' } };
|
||||
assert.throws(() => orchestrator.dispatch(envelopeFor(activeJob, 'nonce_active123')), /simulated stop/i); orchestrator.recover();
|
||||
const events = ledger.list(); assert.deepEqual(events.map(item => item.event), ['etahen.link.dispatch.denied', 'etahen.link.dispatch.accepted', 'etahen.link.mutation.applying', 'etahen.link.dispatch.failed']);
|
||||
assert.doesNotMatch(JSON.stringify(events), /must-not-leak|aaaaaaaaaaaaaaaa/); assert.equal(journal.get(activeJob.jobId).status, 'outcome_unknown');
|
||||
});
|
||||
|
||||
test('rejected asynchronous post-scan evidence is authenticated and remains uninstalled', t => {
|
||||
const f = fixture(t); const directory = path.join(f.root, 'link'); const journal = openManagedEtahenLinkJournal({ file: path.join(directory, 'jobs.json'), root: directory, keyring: f.keyring, now: f.now }).journal;
|
||||
const ledger = createEtahenLinkAuditLedger({ file: path.join(directory, 'audit.json'), root: directory, keyring: f.keyring, now: f.now });
|
||||
const consoleId = 'console_12345678'; const sessionKey = 'b'.repeat(64); const capabilities = [{ name: 'package.install.etahen-dpi-v1', available: true, authorized: true, healthy: true }];
|
||||
const verifier = createEnvelopeVerifier({ consoleId, runtimeEpoch, sessionKey, now: f.now });
|
||||
const orchestrator = createEtahenLinkOrchestrator({ consoleId, runtimeEpoch, capabilities, verifyEnvelope: verifier, journal, handlers: { 'package.install': () => ({ status: 'acknowledged', acknowledged: true, installed: false, postScanRequired: true }) }, verifyInstallation: () => Promise.resolve({ status: 'verified', installed: true }), audit: (event, details, options) => ledger.append(event, details, options), now: f.now });
|
||||
const activeJob = { jobId: 'job_asyncscan12', type: 'package.install', runtimeEpoch, confirmationEpoch: runtimeEpoch, input: { artifactId: 'artifact_12345678' } };
|
||||
const envelope = signEnvelope({ jobId: activeJob.jobId, nonce: 'nonce_asyncscan1', consoleId, runtimeEpoch, issuedAt: f.now(), expiresAt: f.now() + 1000, job: activeJob }, sessionKey);
|
||||
assert.equal(orchestrator.dispatch(envelope).status, 'acknowledged');
|
||||
assert.throws(() => orchestrator.applyPostScan(activeJob.jobId, {}), error => error?.code === 'ETAHEN_POST_SCAN_ASYNC');
|
||||
assert.equal(journal.get(activeJob.jobId).status, 'outcome_unknown'); assert.equal(journal.get(activeJob.jobId).result.installed, false);
|
||||
assert.deepEqual(ledger.list().slice(-2).map(item => item.event), ['etahen.link.post-scan.started', 'etahen.link.post-scan.failed']);
|
||||
});
|
||||
|
||||
test('verified install audit evidence survives a sink interruption without replaying the mutation', t => {
|
||||
const f = fixture(t); const directory = path.join(f.root, 'link'); const journal = openManagedEtahenLinkJournal({ file: path.join(directory, 'jobs.json'), root: directory, keyring: f.keyring, now: f.now }).journal;
|
||||
const ledger = createEtahenLinkAuditLedger({ file: path.join(directory, 'audit.json'), root: directory, keyring: f.keyring, now: f.now });
|
||||
const consoleId = 'console_12345678'; const sessionKey = 'c'.repeat(64); const capabilities = [{ name: 'package.install.etahen-dpi-v1', available: true, authorized: true, healthy: true }];
|
||||
const verifier = createEnvelopeVerifier({ consoleId, runtimeEpoch, sessionKey, now: f.now }); let handlerCalls = 0; let verifierCalls = 0; let interruptVerified = true;
|
||||
const orchestrator = createEtahenLinkOrchestrator({ consoleId, runtimeEpoch, capabilities, verifyEnvelope: verifier, journal, handlers: { 'package.install': () => { handlerCalls += 1; return { status: 'acknowledged', acknowledged: true, installed: false, postScanRequired: true }; } }, verifyInstallation: () => { verifierCalls += 1; return { status: 'verified', installed: true, scanRunId: 'scanrun_audit123' }; }, audit: (event, details, options) => {
|
||||
if (event === 'etahen.link.post-scan.verified' && interruptVerified) { interruptVerified = false; throw Object.assign(new Error('audit unavailable'), { code: 'AUDIT_DOWN' }); }
|
||||
return ledger.append(event, details, options);
|
||||
}, now: f.now });
|
||||
const activeJob = { jobId: 'job_auditverify', type: 'package.install', runtimeEpoch, confirmationEpoch: runtimeEpoch, input: { artifactId: 'artifact_12345678' } };
|
||||
const envelope = signEnvelope({ jobId: activeJob.jobId, nonce: 'nonce_auditverify', consoleId, runtimeEpoch, issuedAt: f.now(), expiresAt: f.now() + 1000, job: activeJob }, sessionKey);
|
||||
assert.equal(orchestrator.dispatch(envelope).status, 'acknowledged');
|
||||
assert.throws(() => orchestrator.applyPostScan(activeJob.jobId, { coverage: 'complete' }), error => error?.code === 'AUDIT_DOWN');
|
||||
assert.equal(journal.get(activeJob.jobId).status, 'verified'); assert.equal(journal.get(activeJob.jobId).result.installed, true);
|
||||
const reopenedJournal = openManagedEtahenLinkJournal({ file: path.join(directory, 'jobs.json'), root: directory, keyring: f.keyring, now: f.now }).journal;
|
||||
const restarted = createEtahenLinkOrchestrator({ consoleId, runtimeEpoch, capabilities, verifyEnvelope: verifier, journal: reopenedJournal, handlers: { 'package.install': () => { handlerCalls += 1; throw new Error('must not replay'); } }, verifyInstallation: () => { verifierCalls += 1; throw new Error('must not rescan'); }, audit: (event, details, options) => ledger.append(event, details, options), now: f.now });
|
||||
assert.doesNotThrow(() => restarted.reconcileAudit()); assert.doesNotThrow(() => restarted.reconcileAudit());
|
||||
assert.equal(ledger.list().filter(item => item.event === 'etahen.link.post-scan.verified').length, 1); assert.equal(handlerCalls, 1); assert.equal(verifierCalls, 1);
|
||||
});
|
||||
|
||||
test('recovery audit retries idempotently after an audit sink interruption', t => {
|
||||
const f = fixture(t); const directory = path.join(f.root, 'link'); const journal = openManagedEtahenLinkJournal({ file: path.join(directory, 'jobs.json'), root: directory, keyring: f.keyring, now: f.now }).journal;
|
||||
const ledger = createEtahenLinkAuditLedger({ file: path.join(directory, 'audit.json'), root: directory, keyring: f.keyring, now: f.now });
|
||||
journal.begin(job('job_reaudit123'), 'd'.repeat(64)); journal.transition('job_reaudit123', 'applying', { installed: false, postScanRequired: true });
|
||||
let interrupted = true;
|
||||
const orchestrator = createEtahenLinkOrchestrator({ consoleId: 'console_12345678', runtimeEpoch, capabilities: [], verifyEnvelope: value => value, journal, handlers: {}, audit: (event, details, options) => {
|
||||
if (event === 'etahen.link.runtime.recovered' && interrupted) { interrupted = false; throw Object.assign(new Error('audit unavailable'), { code: 'AUDIT_DOWN' }); }
|
||||
return ledger.append(event, details, options);
|
||||
}, now: f.now });
|
||||
assert.throws(() => orchestrator.recover(), error => error?.code === 'AUDIT_DOWN'); assert.equal(journal.get('job_reaudit123').status, 'outcome_unknown');
|
||||
assert.doesNotThrow(() => orchestrator.recover()); assert.doesNotThrow(() => orchestrator.recover());
|
||||
const recovered = ledger.list().filter(item => item.event === 'etahen.link.runtime.recovered');
|
||||
assert.equal(recovered.length, 1); assert.equal(recovered[0].details.fromStatus, 'applying'); assert.equal(recovered[0].details.status, 'outcome_unknown');
|
||||
});
|
||||
|
||||
test('denial reason survives audit outage after nonce consumption', t => {
|
||||
const f = fixture(t); const directory = path.join(f.root, 'link'); const journal = openManagedEtahenLinkJournal({ file: path.join(directory, 'jobs.json'), root: directory, keyring: f.keyring, now: f.now }).journal;
|
||||
const ledger = createEtahenLinkAuditLedger({ file: path.join(directory, 'audit.json'), root: directory, keyring: f.keyring, now: f.now });
|
||||
const consoleId = 'console_12345678'; const sessionKey = 'd'.repeat(64); const verifier = createEnvelopeVerifier({ consoleId, runtimeEpoch, sessionKey, now: f.now }); let interrupt = true;
|
||||
const orchestrator = createEtahenLinkOrchestrator({ consoleId, runtimeEpoch, capabilities: [{ name: 'console.inventory.read', available: true, authorized: true, healthy: true }], verifyEnvelope: verifier, journal, handlers: {}, audit: (event, details, options) => {
|
||||
if (event === 'etahen.link.dispatch.denied' && interrupt) { interrupt = false; throw Object.assign(new Error('audit unavailable'), { code: 'AUDIT_DOWN' }); }
|
||||
return ledger.append(event, details, options);
|
||||
}, now: f.now });
|
||||
const invalidJob = { jobId: 'job_denyretain1', type: 'inventory.scan', runtimeEpoch, input: { path: '/forbidden' } };
|
||||
const envelope = signEnvelope({ jobId: invalidJob.jobId, nonce: 'nonce_denyretain', consoleId, runtimeEpoch, issuedAt: f.now(), expiresAt: f.now() + 1000, job: invalidJob }, sessionKey);
|
||||
assert.throws(() => orchestrator.dispatch(envelope), error => error?.code === 'AUDIT_DOWN'); assert.equal(journal.list().length, 0);
|
||||
assert.doesNotThrow(() => orchestrator.reconcileAudit());
|
||||
const denial = ledger.list().find(item => item.event === 'etahen.link.dispatch.denied'); assert.equal(denial.details.reasonId, 'ETAHEN_LINK_FORBIDDEN_FIELD'); assert.equal(denial.details.installed, false);
|
||||
});
|
||||
|
||||
test('backwards clocks fail before corrupting journal or keyring state', t => {
|
||||
let clock = 100; const root = fs.mkdtempSync(path.join(os.tmpdir(), 'chimera-link-clock-')); t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
const secrets = new SecretsStore({ filePath: path.join(root, 'secrets', 'secrets.enc.json'), keyFile: path.join(root, 'secrets', 'master.key') });
|
||||
const keyring = createEtahenLinkKeyring({ secrets, stateFile: path.join(root, 'link', 'keyring.json'), root: path.join(root, 'link'), now: () => clock, randomBytes: size => Buffer.alloc(size, 0x81) });
|
||||
clock = 90; assert.throws(() => keyring.prepareRotation({ reason: 'clock-must-not-rewind' }), error => error?.code === 'ETAHEN_KEYRING_TIME'); assert.equal(keyring.status().pending, null);
|
||||
clock = 100; const journal = openManagedEtahenLinkJournal({ file: path.join(root, 'link', 'jobs.json'), root: path.join(root, 'link'), keyring, now: () => clock }).journal; journal.begin(job('job_clockbound'), '6'.repeat(64));
|
||||
clock = 50; assert.throws(() => journal.transition('job_clockbound', 'applying', { installed: false }), error => error?.code === 'ETAHEN_JOURNAL_TIME'); assert.equal(journal.get('job_clockbound').status, 'accepted');
|
||||
});
|
||||
@@ -0,0 +1,199 @@
|
||||
'use strict';
|
||||
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const test = require('node:test');
|
||||
const {
|
||||
A8_DISTINCT_CONTROL,
|
||||
PINNED_PS5_BUILDER,
|
||||
createPs5NativePkgBuilderProvider,
|
||||
} = require('../../src/services/ps5-native-pkg-builder-provider');
|
||||
|
||||
const CLEAN_CAPABILITIES = Object.freeze({
|
||||
backupToDebugFpkg: true,
|
||||
dataDlcToDebugFpkg: true,
|
||||
updateToDebugFpkg: false,
|
||||
backupInspection: true,
|
||||
packageInspection: true,
|
||||
packageValidation: true,
|
||||
launchReadiness: true,
|
||||
licenseFreeBuild: false,
|
||||
distinctTitleControlBuild: true,
|
||||
finalizedFihOutput: true,
|
||||
});
|
||||
|
||||
function fixture() {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'chimera-clean-fpkg-builder-'));
|
||||
const toolRoot = path.join(root, 'tool');
|
||||
const sourceRoot = path.join(root, 'source');
|
||||
const workRoot = path.join(root, 'work');
|
||||
fs.mkdirSync(toolRoot, { recursive: true });
|
||||
fs.mkdirSync(path.join(sourceRoot, 'sce_sys'), { recursive: true });
|
||||
fs.mkdirSync(workRoot, { recursive: true });
|
||||
fs.writeFileSync(path.join(sourceRoot, 'sce_sys', 'param.json'), '{}');
|
||||
fs.writeFileSync(path.join(sourceRoot, 'eboot.bin'), Buffer.from([0x7f, 0x45, 0x4c, 0x46]));
|
||||
const executable = path.join(toolRoot, PINNED_PS5_BUILDER.executable);
|
||||
fs.writeFileSync(executable, 'fixture');
|
||||
return { root, toolRoot, sourceRoot, workRoot };
|
||||
}
|
||||
|
||||
function cleanProbe(overrides = {}) {
|
||||
return {
|
||||
product: 'LibProsperoPkg',
|
||||
version: '2.6.0',
|
||||
revision: '9084c0e88cd8d7cd9c0b353b8b6b89f7d1cc4275',
|
||||
abi: 'chimera-prospero-bridge-v4',
|
||||
patched: true,
|
||||
patch: 'chimera-stream-large-package-assembly-u2c24-v2',
|
||||
capabilities: CLEAN_CAPABILITIES,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test('production builder pin is the exact streaming semantic bridge-v4 baseline', () => {
|
||||
assert.equal(PINNED_PS5_BUILDER.abi, 'chimera-prospero-bridge-v4');
|
||||
assert.equal(PINNED_PS5_BUILDER.version, '2.6.0');
|
||||
assert.equal(PINNED_PS5_BUILDER.patched, true);
|
||||
assert.equal(PINNED_PS5_BUILDER.patch, 'chimera-stream-large-package-assembly-u2c24-v2');
|
||||
assert.equal(PINNED_PS5_BUILDER.patchSha256, '65e04ca3462ef4e619d296d3656c3658a6083283102a38bfb69d095f369db135');
|
||||
assert.equal('patchSet' in PINNED_PS5_BUILDER, false);
|
||||
});
|
||||
|
||||
test('probe recognizes the exact clean revision but blocks its unproven license-free converter', async t => {
|
||||
const f = fixture();
|
||||
t.after(() => fs.rmSync(f.root, { recursive: true, force: true }));
|
||||
let report = cleanProbe();
|
||||
const provider = createPs5NativePkgBuilderProvider({
|
||||
toolRoot: f.toolRoot,
|
||||
allowedWorkRoots: [f.workRoot],
|
||||
allowedSourceRoots: [f.sourceRoot],
|
||||
runner: async () => ({ code: 0, output: JSON.stringify(report) }),
|
||||
});
|
||||
let status = await provider.probe();
|
||||
assert.equal(status.pinned, true);
|
||||
assert.equal(status.clean, true);
|
||||
assert.equal(status.available, false);
|
||||
assert.equal(status.controlAvailable, true);
|
||||
assert.equal(status.reason, 'capability-mismatch');
|
||||
report = cleanProbe({ patch: 'unexpected-patch' });
|
||||
assert.equal((await provider.probe()).available, false);
|
||||
report = cleanProbe({ revision: '0'.repeat(40) });
|
||||
assert.equal((await provider.probe()).available, false);
|
||||
report = cleanProbe({ capabilities: { ...CLEAN_CAPABILITIES, licenseFreeBuild: true } });
|
||||
status = await provider.probe();
|
||||
assert.equal(status.available, true);
|
||||
});
|
||||
|
||||
test('distinct control build uses one fixed non-colliding identity and exact upstream assets', async t => {
|
||||
const f = fixture();
|
||||
t.after(() => fs.rmSync(f.root, { recursive: true, force: true }));
|
||||
fs.writeFileSync(path.join(f.sourceRoot, 'eboot.bin'), Buffer.from('fixture-eboot'));
|
||||
fs.writeFileSync(path.join(f.sourceRoot, 'sce_sys', 'icon0.png'), Buffer.from('fixture-icon'));
|
||||
const descriptor = {
|
||||
...A8_DISTINCT_CONTROL,
|
||||
assets: {
|
||||
ebootSha256: require('node:crypto').createHash('sha256').update('fixture-eboot').digest('hex'),
|
||||
iconSha256: require('node:crypto').createHash('sha256').update('fixture-icon').digest('hex'),
|
||||
},
|
||||
};
|
||||
const outputPath = path.join(f.workRoot, 'control.pkg');
|
||||
const workPath = path.join(f.workRoot, 'control-work');
|
||||
let request;
|
||||
const runner = async ({ args }) => {
|
||||
if (args[0] === 'probe') return { code: 0, output: JSON.stringify(cleanProbe()) };
|
||||
assert.deepEqual(args.slice(0, 2), ['build-control', '--request']);
|
||||
request = JSON.parse(fs.readFileSync(args[2], 'utf8'));
|
||||
fs.writeFileSync(outputPath, Buffer.concat([Buffer.from([0x7f, 0x46, 0x49, 0x48]), Buffer.alloc(64)]));
|
||||
fs.writeFileSync(args[4], JSON.stringify({ ok: true, outputPath, licenseFree: true, fixedIdentity: true }));
|
||||
return { code: 0, output: '' };
|
||||
};
|
||||
const provider = createPs5NativePkgBuilderProvider({
|
||||
toolRoot: f.toolRoot, allowedWorkRoots: [f.workRoot], allowedSourceRoots: [f.sourceRoot], runner,
|
||||
distinctControl: descriptor,
|
||||
});
|
||||
const result = await provider.buildDistinctControl({ sourcePath: f.sourceRoot, outputPath, workPath });
|
||||
assert.equal(request.abi, 'chimera-prospero-bridge-v4');
|
||||
assert.equal(request.operation, 'build-control');
|
||||
assert.equal(request.titleId, A8_DISTINCT_CONTROL.titleId);
|
||||
assert.equal(request.contentId, A8_DISTINCT_CONTROL.contentId);
|
||||
assert.equal(request.componentType, 'base');
|
||||
assert.equal(request.sourceAssets.ebootSha256, descriptor.assets.ebootSha256);
|
||||
assert.equal(request.sourceAssets.iconSha256, descriptor.assets.iconSha256);
|
||||
assert.equal(result.report.licenseFree, true);
|
||||
});
|
||||
|
||||
test('distinct control build rejects source drift and caller-selected identity before the bridge', async t => {
|
||||
const f = fixture();
|
||||
t.after(() => fs.rmSync(f.root, { recursive: true, force: true }));
|
||||
const provider = createPs5NativePkgBuilderProvider({
|
||||
toolRoot: f.toolRoot, allowedWorkRoots: [f.workRoot], allowedSourceRoots: [f.sourceRoot],
|
||||
runner: async () => ({ code: 0, output: JSON.stringify(cleanProbe()) }),
|
||||
});
|
||||
await assert.rejects(
|
||||
() => provider.buildDistinctControl({ sourcePath: f.sourceRoot, outputPath: path.join(f.workRoot, 'control.pkg'), workPath: path.join(f.workRoot, 'work'), titleId: 'PPSA12345' }),
|
||||
error => error.code === 'PS5_A8_CONTROL_IDENTITY_FIXED',
|
||||
);
|
||||
await assert.rejects(
|
||||
() => provider.buildDistinctControl({ sourcePath: f.sourceRoot, outputPath: path.join(f.workRoot, 'control.pkg'), workPath: path.join(f.workRoot, 'work') }),
|
||||
error => error.code === 'PS5_A8_CONTROL_SOURCE_DRIFT',
|
||||
);
|
||||
});
|
||||
|
||||
test('build invokes only build-backup with an immutable semantic v4 request when all clean capabilities are proven', async t => {
|
||||
const f = fixture();
|
||||
t.after(() => fs.rmSync(f.root, { recursive: true, force: true }));
|
||||
const outputPath = path.join(f.workRoot, 'artifact.pkg');
|
||||
const buildWork = path.join(f.workRoot, 'job');
|
||||
let request;
|
||||
let reportOverride = {};
|
||||
const runner = async ({ args }) => {
|
||||
if (args[0] === 'probe') return { code: 0, output: JSON.stringify(cleanProbe({ capabilities: { ...CLEAN_CAPABILITIES, licenseFreeBuild: true, backupBuildApi: 'ProsperoPackageBuilder' } })) };
|
||||
assert.deepEqual(args.slice(0, 2), ['build-backup', '--request']);
|
||||
request = JSON.parse(fs.readFileSync(args[2], 'utf8'));
|
||||
fs.writeFileSync(outputPath, Buffer.concat([Buffer.from([0x7f, 0x46, 0x49, 0x48]), Buffer.alloc(64)]));
|
||||
fs.writeFileSync(args[4], JSON.stringify({ ok: true, outputPath, buildApi: 'ProsperoPackageBuilder', licenseFree: true, validation: { accepted: true }, unresolvedModules: [], launchReadiness: { isLaunchReady: true }, ...reportOverride }));
|
||||
return { code: 0, output: '' };
|
||||
};
|
||||
const provider = createPs5NativePkgBuilderProvider({ toolRoot: f.toolRoot, allowedWorkRoots: [f.workRoot], allowedSourceRoots: [f.sourceRoot], runner });
|
||||
await provider.build({
|
||||
componentType: 'base', sourcePath: f.sourceRoot, sourceSize: 6,
|
||||
outputPath, workPath: buildWork, titleId: 'PPSA12345',
|
||||
contentId: 'UP0001-PPSA12345_00-CHIMERAFPKG00001', version: '01.00', title: 'Fixture',
|
||||
});
|
||||
assert.equal(request.abi, 'chimera-prospero-bridge-v4');
|
||||
assert.equal(request.operation, 'build-backup');
|
||||
assert.equal(request.componentType, 'base');
|
||||
assert.equal('licenseFree' in request, false);
|
||||
assert.equal('fakeSignSelfModules' in request, false);
|
||||
assert.equal('tuning' in request, false);
|
||||
for (const invalid of [{ licenseFree: false }, { buildApi: 'ProsperoBackupConverter' }, { outputPath: path.join(f.workRoot, 'other.pkg') }, { validation: { accepted: false } }, { unresolvedModules: ['eboot.bin'] }]) {
|
||||
reportOverride = invalid;
|
||||
await assert.rejects(provider.build({ componentType: 'base', sourcePath: f.sourceRoot, sourceSize: 6, outputPath, workPath: buildWork, titleId: 'PPSA12345', contentId: 'UP0001-PPSA12345_00-CHIMERAFPKG00001' }), { code: 'PS5_BUILDER_OUTPUT' });
|
||||
}
|
||||
});
|
||||
|
||||
test('Docker production stage applies only the pinned streaming patch, never the historical V16 series', () => {
|
||||
const docker = fs.readFileSync('Dockerfile', 'utf8');
|
||||
assert.match(docker, /ARG LIBPROSPEROPKG_COMMIT=9084c0e88cd8d7cd9c0b353b8b6b89f7d1cc4275/);
|
||||
assert.match(docker, /patches\/0001-stream-large-package-assembly\.patch/);
|
||||
assert.match(docker, /git -C \/src\/LibProsperoPkg apply/);
|
||||
const streamingPatch = fs.readFileSync('tools/chimera-prospero-bridge/patches/0001-stream-large-package-assembly.patch', 'utf8');
|
||||
assert.match(streamingPatch, /BuildFromFsTreeToFile/);
|
||||
assert.match(streamingPatch, /BuildForPackageFile/);
|
||||
assert.match(streamingPatch, /ProsperoOuterPfsAddressingPlan/);
|
||||
assert.match(streamingPatch, /WriteU2cBase24\(entry\.AsSpan\(4, 3\), nextBase\)/);
|
||||
assert.match(streamingPatch, /value is < 0 or > 0xFFFFFF/);
|
||||
assert.doesNotMatch(streamingPatch, /MountImageKey|RsaPkcs1EncryptKey|PfsGenEncKey|PfsGenSignKey/);
|
||||
assert.doesNotMatch(docker, /CHIMERA_PATCH_SET|chimera-license-free-base-v16/);
|
||||
assert.match(docker, /LibProsperoPkg-9084c0e-streaming/);
|
||||
});
|
||||
|
||||
test('historical patches remain documented outside the production path', () => {
|
||||
const legacyRoot = 'tools/chimera-prospero-bridge/legacy/v16-patches';
|
||||
assert.equal(fs.existsSync(path.join(legacyRoot, '0001-parallel-fast-store.patch')), true);
|
||||
const notice = fs.readFileSync(path.join(legacyRoot, 'README.md'), 'utf8');
|
||||
assert.match(notice, /historical research only/i);
|
||||
assert.match(notice, /not used by the production builder/i);
|
||||
});
|
||||
@@ -0,0 +1,143 @@
|
||||
'use strict';
|
||||
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const test = require('node:test');
|
||||
const {
|
||||
createPs5FpkgReadinessService,
|
||||
combinePreservationAndReadiness,
|
||||
} = require('../../src/services/ps5-fpkg-readiness-service');
|
||||
|
||||
const TITLE_ID = 'PPSA12345';
|
||||
const CONTENT_ID = 'UP0001-PPSA12345_00-CHIMERAFPKG00001';
|
||||
const ELF = Buffer.concat([Buffer.from([0x7f, 0x45, 0x4c, 0x46]), Buffer.alloc(32, 0x11)]);
|
||||
const SELF = Buffer.concat([Buffer.from([0x54, 0x14, 0xf5, 0xee]), Buffer.alloc(32, 0x22)]);
|
||||
|
||||
function fixture({ param = true, eboot = ELF } = {}) {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'chimera-fpkg-readiness-'));
|
||||
if (param) {
|
||||
fs.mkdirSync(path.join(root, 'sce_sys'), { recursive: true });
|
||||
fs.writeFileSync(path.join(root, 'sce_sys', 'param.json'), JSON.stringify({ titleId: TITLE_ID, contentId: CONTENT_ID }));
|
||||
}
|
||||
if (eboot) fs.writeFileSync(path.join(root, 'eboot.bin'), eboot);
|
||||
const service = createPs5FpkgReadinessService({ allowedRoots: [root] });
|
||||
return { root, service };
|
||||
}
|
||||
|
||||
test('raw ELF base is ready with immutable tree and module provenance', async t => {
|
||||
const f = fixture(); t.after(() => fs.rmSync(f.root, { recursive: true, force: true }));
|
||||
const result = await f.service.analyze({ sourcePath: f.root, componentType: 'base', expectedTitleId: TITLE_ID, expectedContentId: CONTENT_ID });
|
||||
assert.equal(result.schema, 'chimera.ps5-fpkg-readiness.v1');
|
||||
assert.equal(result.state, 'ready');
|
||||
assert.equal(result.ready, true);
|
||||
assert.match(result.sourceTreeSha256, /^[a-f0-9]{64}$/);
|
||||
assert.equal(result.modules.total, 1);
|
||||
assert.equal(result.modules.plaintext, 1);
|
||||
assert.equal(result.modules.unresolved, 0);
|
||||
assert.equal(result.moduleEvidence[0].provenance, 'raw-elf-from-source');
|
||||
});
|
||||
|
||||
test('provider-decrypted stream claims become evidence only after local ELF reinspection', async t => {
|
||||
const f = fixture(); t.after(() => fs.rmSync(f.root, { recursive: true, force: true }));
|
||||
const service = createPs5FpkgReadinessService({
|
||||
allowedRoots: [f.root],
|
||||
providerEvidence: { files: { 'eboot.bin': { decryptionVerified: true, provider: 'pinned-capture-adapter' } } },
|
||||
});
|
||||
const result = await service.analyze({ sourcePath: f.root, componentType: 'base' });
|
||||
assert.equal(result.moduleEvidence[0].provenance, 'provider-decrypted-stream');
|
||||
fs.writeFileSync(path.join(f.root, 'eboot.bin'), SELF);
|
||||
const blocked = await service.analyze({ sourcePath: f.root, componentType: 'base' });
|
||||
assert.equal(blocked.moduleEvidence[0].provenance, 'signed-unresolved');
|
||||
assert.equal(blocked.ready, false);
|
||||
});
|
||||
|
||||
test('SELF with a matching decrypted ELF counterpart is ready and substituted', async t => {
|
||||
const f = fixture({ eboot: SELF }); t.after(() => fs.rmSync(f.root, { recursive: true, force: true }));
|
||||
fs.mkdirSync(path.join(f.root, 'decrypted'), { recursive: true });
|
||||
fs.writeFileSync(path.join(f.root, 'decrypted', 'eboot.bin'), ELF);
|
||||
const result = await f.service.analyze({ sourcePath: f.root, componentType: 'base' });
|
||||
assert.equal(result.state, 'ready');
|
||||
assert.equal(result.modules.substitutable, 1);
|
||||
assert.equal(result.moduleEvidence[0].provenance, 'decrypted-mirror-substitute');
|
||||
assert.match(result.moduleEvidence[0].counterpartSha256, /^[a-f0-9]{64}$/);
|
||||
});
|
||||
|
||||
test('opaque executable wrapper with an exact decrypted ELF mirror is ready and substituted', async t => {
|
||||
const opaque = Buffer.concat([Buffer.from([0x01, 0x02, 0x03, 0x04]), Buffer.alloc(32, 0x55)]);
|
||||
const f = fixture({ eboot: opaque }); t.after(() => fs.rmSync(f.root, { recursive: true, force: true }));
|
||||
fs.mkdirSync(path.join(f.root, 'decrypted'), { recursive: true });
|
||||
fs.writeFileSync(path.join(f.root, 'decrypted', 'eboot.bin'), ELF);
|
||||
const result = await f.service.analyze({ sourcePath: f.root, componentType: 'base' });
|
||||
assert.equal(result.state, 'ready');
|
||||
assert.equal(result.modules.substitutable, 1);
|
||||
assert.equal(result.moduleEvidence[0].format, 'opaque-executable');
|
||||
assert.equal(result.moduleEvidence[0].provenance, 'decrypted-mirror-substitute');
|
||||
});
|
||||
|
||||
test('SELF without suitable decrypted input blocks fPKG but not preservation', async t => {
|
||||
const f = fixture({ eboot: SELF }); t.after(() => fs.rmSync(f.root, { recursive: true, force: true }));
|
||||
const readiness = await f.service.analyze({ sourcePath: f.root, componentType: 'base' });
|
||||
assert.equal(readiness.state, 'blocked');
|
||||
assert.equal(readiness.modules.unresolved, 1);
|
||||
assert.equal(readiness.issues.some(issue => issue.code === 'PS5_FPKG_MODULE_UNRESOLVED'), true);
|
||||
const combined = combinePreservationAndReadiness({ complete: true, verified: true }, readiness);
|
||||
assert.equal(combined.preservation.complete, true);
|
||||
assert.equal(combined.preservation.verified, true);
|
||||
assert.equal(combined.fpkgReadiness.state, 'blocked');
|
||||
assert.equal(combined.backupFailed, false);
|
||||
});
|
||||
|
||||
test('missing eboot, missing param.json, param.sfo and identity mismatch remain distinct blockers', async t => {
|
||||
const noEboot = fixture({ eboot: null }); t.after(() => fs.rmSync(noEboot.root, { recursive: true, force: true }));
|
||||
assert.equal((await noEboot.service.analyze({ sourcePath: noEboot.root, componentType: 'base' })).issues.some(item => item.code === 'PS5_FPKG_EBOOT_REQUIRED'), true);
|
||||
|
||||
const noParam = fixture({ param: false }); t.after(() => fs.rmSync(noParam.root, { recursive: true, force: true }));
|
||||
fs.mkdirSync(path.join(noParam.root, 'sce_sys'), { recursive: true });
|
||||
fs.writeFileSync(path.join(noParam.root, 'sce_sys', 'param.sfo'), 'legacy');
|
||||
const missing = await noParam.service.analyze({ sourcePath: noParam.root, componentType: 'base' });
|
||||
assert.equal(missing.issues.some(item => item.code === 'PS5_FPKG_PARAM_REQUIRED'), true);
|
||||
assert.equal(missing.issues.some(item => item.code === 'PS5_FPKG_PARAM_SFO_UNSUPPORTED'), true);
|
||||
|
||||
const mismatch = fixture(); t.after(() => fs.rmSync(mismatch.root, { recursive: true, force: true }));
|
||||
const identity = await mismatch.service.analyze({ sourcePath: mismatch.root, componentType: 'base', expectedTitleId: 'PPSA99999' });
|
||||
assert.equal(identity.issues.some(item => item.code === 'PS5_FPKG_TITLE_MISMATCH'), true);
|
||||
});
|
||||
|
||||
test('updates are preserved but unsupported for native fPKG conversion', async t => {
|
||||
const f = fixture(); t.after(() => fs.rmSync(f.root, { recursive: true, force: true }));
|
||||
const result = await f.service.analyze({ sourcePath: f.root, componentType: 'update' });
|
||||
assert.equal(result.state, 'unsupported');
|
||||
assert.equal(result.ready, false);
|
||||
assert.equal(result.issues[0].code, 'PS5_FPKG_UPDATE_UNSUPPORTED');
|
||||
});
|
||||
|
||||
test('symlinks are rejected and same-size source mutation invalidates prior evidence', async t => {
|
||||
const f = fixture(); t.after(() => fs.rmSync(f.root, { recursive: true, force: true }));
|
||||
const first = await f.service.analyze({ sourcePath: f.root, componentType: 'base' });
|
||||
fs.writeFileSync(path.join(f.root, 'eboot.bin'), Buffer.concat([Buffer.from([0x7f, 0x45, 0x4c, 0x46]), Buffer.alloc(32, 0x44)]));
|
||||
await assert.rejects(() => f.service.assertUnchanged(first), error => error.code === 'PS5_FPKG_SOURCE_CHANGED');
|
||||
|
||||
if (process.platform !== 'win32') {
|
||||
fs.symlinkSync('/tmp', path.join(f.root, 'escape'));
|
||||
await assert.rejects(() => f.service.analyze({ sourcePath: f.root, componentType: 'base' }), error => error.code === 'PS5_FPKG_SOURCE_UNSAFE');
|
||||
}
|
||||
});
|
||||
|
||||
test('unsafe expected identity and paths fail closed instead of becoming readiness issues', async t => {
|
||||
const f = fixture(); t.after(() => fs.rmSync(f.root, { recursive: true, force: true }));
|
||||
await assert.rejects(() => f.service.analyze({ sourcePath: path.join(f.root, '..'), componentType: 'base' }), error => error.code === 'PS5_FPKG_SOURCE_UNSAFE');
|
||||
await assert.rejects(() => f.service.analyze({ sourcePath: f.root, componentType: 'base', expectedContentId: '../PPSA12345' }), error => error.code === 'PS5_FPKG_IDENTITY_INVALID');
|
||||
});
|
||||
|
||||
test('console staging integrates local readiness without treating provider defaults as decryption proof', () => {
|
||||
const staging = fs.readFileSync('src/services/ps5-console-staging-service.js', 'utf8');
|
||||
const registry = fs.readFileSync('src/server/create-registry.js', 'utf8');
|
||||
assert.match(staging, /selfDecryption: 'unproven-local-reinspection-required'/);
|
||||
assert.match(staging, /await readinessService\.analyze/);
|
||||
assert.match(staging, /preservation = \{ complete: true, verified: true/);
|
||||
assert.match(staging, /executableProvenance: 'locally-reinspected-after-download'/);
|
||||
assert.match(registry, /createPs5FpkgReadinessService/);
|
||||
assert.match(registry, /readinessService: ps5FpkgReadiness/);
|
||||
});
|
||||
@@ -0,0 +1,109 @@
|
||||
'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 { validatePs5FpkgCandidate } = require('../../src/services/ps5-fih-validator');
|
||||
const { createPs5NativePackageService } = require('../../src/services/ps5-native-package-service');
|
||||
const { PINNED_PS5_BUILDER } = require('../../src/services/ps5-native-pkg-builder-provider');
|
||||
const { validatePs5NativePackageManifest } = require('../../src/services/ps5-native-package-registration-service');
|
||||
|
||||
const TITLE_ID = 'PPSA12345';
|
||||
const CONTENT_ID = 'UP0001-PPSA12345_00-CHIMERATEST00001';
|
||||
|
||||
function writeFih(filename, contentId = CONTENT_ID) {
|
||||
const fih = Buffer.alloc(0x10000); const pfs = Buffer.alloc(0x10000); const cnt = Buffer.alloc(0x2900);
|
||||
Buffer.from([0x7f, 0x46, 0x49, 0x48]).copy(fih); fih[4] = 1; fih.writeUInt16LE(3, 6); fih.writeUInt32LE(1, 8);
|
||||
fih.writeBigUInt64LE(0x10000n, 0x10); fih.writeBigUInt64LE(0x10000n, 0x18); fih.writeBigUInt64LE(0x20000n, 0x58); fih.writeUInt32LE(1, 0xfc);
|
||||
const gameDigest = crypto.createHash('sha3-256').update(pfs).digest(); for (const offset of [0x30, 0x70, 0xd0]) gameDigest.copy(fih, offset);
|
||||
Buffer.from([0x7f, 0x43, 0x4e, 0x54]).copy(cnt); cnt.writeUInt32BE(1, 0x10); cnt.writeUInt16BE(1, 0x14); cnt.writeUInt16BE(1, 0x16); cnt.writeUInt32BE(0x2000, 0x18); cnt.writeBigUInt64BE(0x2000n, 0x20); cnt.writeBigUInt64BE(0x900n, 0x28);
|
||||
Buffer.from(contentId, 'ascii').copy(cnt, 0x40, 0, 0x30); cnt.writeUInt32BE(0x20, 0x2000); cnt.writeUInt32BE(0x2100, 0x2010); cnt.writeUInt32BE(0x800, 0x2014);
|
||||
for (const [record, offset] of [0x000, 0x180, 0x300, 0x480, 0x600].entries()) crypto.createHash('sha256').update(`${contentId}:${record}`).digest().copy(cnt, 0x2100 + offset);
|
||||
crypto.createHash('sha3-256').update(cnt.subarray(0, 0xfe0)).digest().copy(cnt, 0xfe0);
|
||||
fs.mkdirSync(path.dirname(filename), { recursive: true }); fs.writeFileSync(filename, Buffer.concat([fih, pfs, cnt]));
|
||||
}
|
||||
|
||||
function writeBackup(root) {
|
||||
const source = path.join(root, 'source'); fs.mkdirSync(path.join(source, 'sce_sys'), { recursive: true });
|
||||
fs.writeFileSync(path.join(source, 'eboot.bin'), Buffer.concat([Buffer.from([0x7f, 0x45, 0x4c, 0x46]), Buffer.alloc(64)]));
|
||||
fs.writeFileSync(path.join(source, 'sce_sys', 'param.json'), JSON.stringify({ titleId: TITLE_ID, contentId: CONTENT_ID, contentVersion: '01.00', titleName: 'Artifact test' }));
|
||||
return source;
|
||||
}
|
||||
|
||||
function upstream(accepted = true) { return { accepted, validator: 'ProsperoPkgValidator', launchReady: true, unresolvedModules: [] }; }
|
||||
|
||||
test('admits only literal finalized FIH .pkg candidates with matching upstream identity', t => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'chimera-fpkg-format-')); t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
const fih = path.join(root, 'game.pkg'); writeFih(fih);
|
||||
assert.equal(validatePs5FpkgCandidate(fih, { expectedContentId: CONTENT_ID, upstreamValidation: upstream() }).accepted, true);
|
||||
for (const [name, bytes] of [
|
||||
['cnt.pkg', Buffer.concat([Buffer.from([0x7f, 0x43, 0x4e, 0x54]), Buffer.alloc(32)])],
|
||||
['mount.ffpkg', Buffer.concat([Buffer.from([0x7f, 0x46, 0x49, 0x48]), Buffer.alloc(32)])],
|
||||
['renamed.pkg', Buffer.from('ffpkg/UFS image renamed as pkg')],
|
||||
['unknown.pkg', Buffer.from('unknown package')],
|
||||
]) {
|
||||
const candidate = path.join(root, name); fs.writeFileSync(candidate, bytes);
|
||||
assert.throws(() => validatePs5FpkgCandidate(candidate, { expectedContentId: CONTENT_ID, upstreamValidation: upstream() }), error => error.code === 'PS5_FPKG_OUTPUT_NOT_FIH');
|
||||
}
|
||||
assert.throws(() => validatePs5FpkgCandidate(fih, { expectedContentId: 'UP0001-PPSA99999_00-CHIMERATEST00001', upstreamValidation: upstream() }), error => error.code === 'PS5_FPKG_TITLE_MISMATCH');
|
||||
assert.throws(() => validatePs5FpkgCandidate(fih, { expectedContentId: CONTENT_ID, upstreamValidation: upstream(false) }), error => error.code === 'PS5_FPKG_VALIDATION_FAILED');
|
||||
});
|
||||
|
||||
test('direct builder registers truthful manifest v2 provenance without install or launch claims', async t => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'chimera-fpkg-manifest-')); t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
const libraryRoot = path.join(root, 'library'); const outputRoot = path.join(root, 'vault'); fs.mkdirSync(libraryRoot);
|
||||
const registrations = [];
|
||||
const provider = {
|
||||
probe: async () => ({ available: true, ...PINNED_PS5_BUILDER, capabilities: { backupBuildApi: 'ProsperoPackageBuilder', backupToDebugFpkg: true, dataDlcToDebugFpkg: true, updateToDebugFpkg: false, packageValidation: true, licenseFreeBuild: true, finalizedFihOutput: true } }),
|
||||
build: async input => { writeFih(input.outputPath, input.contentId); return { provider: PINNED_PS5_BUILDER }; },
|
||||
validatePackage: async () => upstream(),
|
||||
};
|
||||
const service = createPs5NativePackageService({ allowedRoots: () => [libraryRoot, outputRoot], outputRoot, stateFile: path.join(root, 'jobs.json'), provider, registrar: async manifest => { registrations.push(manifest); return { packageSetId: 1303 }; }, idFactory: () => 'artifact-job', now: () => 1_800_000_000_000 });
|
||||
const created = await service.create({ sourcePath: writeBackup(libraryRoot) });
|
||||
assert.equal(created.status, 'package_ready');
|
||||
const result = await service.run(created.id);
|
||||
assert.equal(result.status, 'artifact_registered');
|
||||
assert.equal(result.launchVerified, false); assert.equal(result.productionRestorable, false);
|
||||
const manifest = registrations[0];
|
||||
assert.equal(validatePs5NativePackageManifest(manifest).schemaVersion, 2);
|
||||
assert.equal(manifest.builder.api, 'ProsperoPackageBuilder');
|
||||
const historical = structuredClone(manifest); historical.builder.api = 'ProsperoBackupConverter';
|
||||
assert.equal(validatePs5NativePackageManifest(historical).schemaVersion, 2);
|
||||
const invalid = structuredClone(manifest); invalid.builder.api = 'arbitrary-builder';
|
||||
assert.throws(() => validatePs5NativePackageManifest(invalid));
|
||||
assert.deepEqual(manifest.packageFormat, { family: 'ps5-fpkg', extension: '.pkg', container: 'FIH', magic: '7f464948', debug: true, licenseFree: true, retail: false });
|
||||
assert.equal(manifest.builder.bridgeAbi, 'chimera-prospero-bridge-v4'); assert.equal(manifest.builder.patched, true);
|
||||
assert.equal(manifest.builder.patch, 'chimera-stream-large-package-assembly-u2c24-v2');
|
||||
assert.equal(manifest.evidence.consoleInstall.status, 'unproven'); assert.equal(manifest.evidence.consoleLaunch.status, 'unproven');
|
||||
assert.equal(manifest.launchVerified, false); assert.equal(manifest.productionRestorable, false);
|
||||
});
|
||||
|
||||
test('registration interruption preserves the artifact and hash drift blocks retry', async t => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'chimera-fpkg-drift-')); t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
const libraryRoot = path.join(root, 'library'); const outputRoot = path.join(root, 'vault'); fs.mkdirSync(libraryRoot);
|
||||
let builds = 0;
|
||||
const provider = {
|
||||
probe: async () => ({ available: true, ...PINNED_PS5_BUILDER, capabilities: { backupToDebugFpkg: true, dataDlcToDebugFpkg: true, updateToDebugFpkg: false, packageValidation: true, licenseFreeBuild: true, finalizedFihOutput: true } }),
|
||||
build: async input => { builds++; writeFih(input.outputPath, input.contentId); }, validatePackage: async () => upstream(),
|
||||
};
|
||||
const service = createPs5NativePackageService({ allowedRoots: () => [libraryRoot, outputRoot], outputRoot, stateFile: path.join(root, 'jobs.json'), provider, registrar: async () => ({}), idFactory: () => 'drift-job' });
|
||||
const created = await service.create({ sourcePath: writeBackup(libraryRoot) });
|
||||
await assert.rejects(() => service.run(created.id), error => error.code === 'PS5_PACKAGE_REGISTRATION_INCOMPLETE');
|
||||
const pending = service.get(created.id); assert.equal(pending.status, 'registration_pending');
|
||||
const fd = fs.openSync(pending.manifest.components[0].absolutePath, 'r+'); fs.writeSync(fd, Buffer.from([0x01]), 0, 1, 0x10010); fs.closeSync(fd);
|
||||
await assert.rejects(() => service.run(created.id), error => error.code === 'PS5_FPKG_ARTIFACT_HASH_DRIFT');
|
||||
assert.equal(builds, 1); assert.equal(service.get(created.id).status, 'registration_pending');
|
||||
});
|
||||
|
||||
test('blocked fPKG analysis remains a safe preservation result', async t => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'chimera-fpkg-blocked-')); t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
const libraryRoot = path.join(root, 'library'); const outputRoot = path.join(root, 'vault'); fs.mkdirSync(libraryRoot);
|
||||
const source = writeBackup(libraryRoot); fs.writeFileSync(path.join(source, 'eboot.bin'), Buffer.concat([Buffer.from([0x54, 0x14, 0xf5, 0xee]), Buffer.alloc(64)]));
|
||||
const service = createPs5NativePackageService({ allowedRoots: () => [libraryRoot, outputRoot], outputRoot, stateFile: path.join(root, 'jobs.json'), provider: { probe: async () => ({}), build: async () => ({}) }, registrar: async () => ({ packageSetId: 1 }) });
|
||||
const result = await service.create({ sourcePath: source });
|
||||
assert.equal(result.status, 'package_blocked'); assert.notEqual(result.status, 'failed');
|
||||
assert.equal(result.components[0].preservation.complete, true); assert.equal(result.components[0].fpkgReadiness.state, 'blocked');
|
||||
});
|
||||
@@ -0,0 +1,489 @@
|
||||
'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 { createPs5NativePackageInstallService } = require('../../src/services/ps5-native-package-install-service');
|
||||
const { createPs5UploadPackageInstallerProvider, PINNED_PS5_PACKAGE_INSTALLER } = require('../../src/services/ps5upload-package-installer-provider');
|
||||
|
||||
function response(body, ok = true, status = 200) {
|
||||
return { ok, status, json: async () => body, text: async () => JSON.stringify(body) };
|
||||
}
|
||||
|
||||
function packageFixture() {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'chimera-ch1304-'));
|
||||
const bytes = Buffer.concat([Buffer.from([0x7f, 0x46, 0x49, 0x48]), Buffer.alloc(64, 0x44)]);
|
||||
const filePath = path.join(root, 'EP9000-PPSA01234_00-CHIMERATEST00001.pkg');
|
||||
fs.writeFileSync(filePath, bytes);
|
||||
return {
|
||||
root,
|
||||
component: {
|
||||
componentId: 'base', type: 'base', order: 1, size: bytes.length,
|
||||
sha256: crypto.createHash('sha256').update(bytes).digest('hex'),
|
||||
contentId: 'EP9000-PPSA01234_00-CHIMERATEST00001',
|
||||
metadata: { absolutePath: filePath, titleId: { value: 'PPSA01234' } },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function exactRuntimeIdentity() {
|
||||
return {
|
||||
observed: {
|
||||
engine: { revision: PINNED_PS5_PACKAGE_INSTALLER.revision, sha256: PINNED_PS5_PACKAGE_INSTALLER.artifacts.engineLinuxX64Sha256, evidence: 'running-process-bytes' },
|
||||
payload: { revision: PINNED_PS5_PACKAGE_INSTALLER.revision, sha256: PINNED_PS5_PACKAGE_INSTALLER.artifacts.payloadSha256, evidence: 'loader-input-and-instance-transition' },
|
||||
dpi: { active: false },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function readyLaunchEvidence(overrides = {}) {
|
||||
return {
|
||||
ready: true,
|
||||
capabilities: {
|
||||
debugPackageRuntime: true,
|
||||
fpkgEnableActive: true,
|
||||
kernelPatchReady: true,
|
||||
packageInstallerReady: true,
|
||||
launchServiceReady: true,
|
||||
processObservationReady: true,
|
||||
syslogObservationReady: true,
|
||||
foregroundClean: true,
|
||||
},
|
||||
logWatermark: { schema: 'chimera.ps5-syslog-watermark.v1', sha256: '0'.repeat(64), bytes: 0, tail: '' },
|
||||
runtimeFingerprint: 'f'.repeat(64),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
async function installedOperation(t, providerOverrides = {}, serviceOverrides = {}) {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'chimera-ch1304-state-'));
|
||||
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
const component = { componentId: 'base', type: 'base', order: 1, sha256: 'a'.repeat(64), size: 68 };
|
||||
const provider = {
|
||||
preflightInstall: async () => ({ ready: true }),
|
||||
install: async () => ({ status: 'accepted_unverified', sessionId: 'install-1', acceptedUnverified: true }),
|
||||
installStatus: async () => ({ phase: 'done', providerVerified: true }),
|
||||
cancel: async () => ({ cancelled: true }),
|
||||
preflightLaunch: async () => readyLaunchEvidence(),
|
||||
registerIfRequired: async () => ({ required: false, registered: true, basis: 'independent-post-scan' }),
|
||||
launch: async () => ({ accepted: true, route: '/api/ps5/app/launch' }),
|
||||
observeLaunch: async () => ({ observed: false, processes: [] }),
|
||||
...providerOverrides,
|
||||
};
|
||||
const service = createPs5NativePackageInstallService({
|
||||
provider,
|
||||
stager: async () => ({ stagedPath: '/user/data/ps5upload/pkg_library/test.pkg' }),
|
||||
stateFile: path.join(root, 'operations.json'),
|
||||
...serviceOverrides,
|
||||
});
|
||||
const started = await service.start({ titleId: 'PPSA01234', packageSet: { id: 7, components: [component] } });
|
||||
for (let attempt = 0; attempt < 40 && service.get(started.id).status !== 'awaiting_post_scan'; attempt += 1) await new Promise(resolve => setTimeout(resolve, 5));
|
||||
const installed = service.markPostScan(started.id, { source: 'post-install-console-scan', titleId: 'PPSA01234' });
|
||||
return { root, provider, service, installed };
|
||||
}
|
||||
|
||||
test('post-scan stops at installed_observed and cannot qualify a restore artifact', async t => {
|
||||
const { installed } = await installedOperation(t);
|
||||
assert.equal(installed.status, 'installed_observed');
|
||||
assert.equal(installed.installed, true);
|
||||
assert.equal(installed.installedContentVerified, false);
|
||||
assert.equal(installed.launchVerified, false);
|
||||
assert.equal(installed.productionRestorable, false);
|
||||
assert.equal(installed.launchQualificationRequired, true);
|
||||
});
|
||||
|
||||
test('registration-only evidence never proves installed payload completeness', async t => {
|
||||
let registrations = 0;
|
||||
let launches = 0;
|
||||
const { installed, service } = await installedOperation(t, {
|
||||
registerIfRequired: async input => {
|
||||
registrations += 1;
|
||||
assert.deepEqual(input, { titleId: 'PPSA01234', installedObserved: true });
|
||||
return { required: false, registered: true, titleId: 'PPSA01234', basis: 'sony-managed-installed-package' };
|
||||
},
|
||||
launch: async () => { launches += 1; return { accepted: true, route: '/api/ps5/app/launch' }; },
|
||||
});
|
||||
const launched = await service.startLaunchQualification(installed.id, { confirmation: 'LAUNCH VERIFY PPSA01234' });
|
||||
assert.equal(registrations, 1);
|
||||
assert.equal(launches, 1);
|
||||
assert.equal(launched.launchEvidence.registration.required, false);
|
||||
assert.equal(launched.launchEvidence.registration.basis, 'sony-managed-installed-package');
|
||||
assert.equal(launched.installedContentVerified, false);
|
||||
assert.equal(launched.productionRestorable, false);
|
||||
});
|
||||
|
||||
test('installed-package registration contract has no DRM-patch or caller-selected source path', async t => {
|
||||
const { installed, service } = await installedOperation(t);
|
||||
await assert.rejects(
|
||||
() => service.startLaunchQualification(installed.id, { confirmation: 'LAUNCH VERIFY PPSA01234', patchDrmType: true }),
|
||||
error => error.code === 'PS5_FPKG_PROVIDER_INPUT',
|
||||
);
|
||||
assert.equal(service.get(installed.id).status, 'installed_observed');
|
||||
});
|
||||
|
||||
test('launch requires exact title-bound confirmation and all semantic runtime capabilities', async t => {
|
||||
const { installed, service } = await installedOperation(t, {
|
||||
preflightLaunch: async () => readyLaunchEvidence({ capabilities: { ...readyLaunchEvidence().capabilities, fpkgEnableActive: false } }),
|
||||
});
|
||||
await assert.rejects(() => service.startLaunchQualification(installed.id, { confirmation: 'LAUNCH VERIFY OTHER00000' }), error => error.code === 'PS5_FPKG_LAUNCH_CONFIRMATION');
|
||||
await assert.rejects(() => service.startLaunchQualification(installed.id, { confirmation: 'LAUNCH VERIFY PPSA01234' }), error => error.code === 'PS5_FPKG_LAUNCH_RUNTIME_NOT_READY');
|
||||
assert.equal(service.get(installed.id).status, 'installed_observed');
|
||||
});
|
||||
|
||||
test('launch acknowledgement remains unverified until stable matching process evidence', async t => {
|
||||
let clock = 10_000;
|
||||
const observations = [
|
||||
{ observed: true, process: { pid: 42, name: 'eboot.bin', titleId: 'PPSA01234' }, rawEvidence: { processes: 1 } },
|
||||
{ observed: true, process: { pid: 42, name: 'eboot.bin', titleId: 'PPSA01234' }, rawEvidence: { processes: 1 } },
|
||||
];
|
||||
const { installed, service } = await installedOperation(t, { observeLaunch: async () => observations.shift() }, { now: () => clock, launchStabilityMs: 5_000 });
|
||||
const acknowledged = await service.startLaunchQualification(installed.id, { confirmation: 'LAUNCH VERIFY PPSA01234' });
|
||||
assert.equal(acknowledged.status, 'launching');
|
||||
assert.equal(acknowledged.launchVerified, false);
|
||||
assert.equal(acknowledged.productionRestorable, false);
|
||||
const first = await service.pollLaunchQualification(installed.id);
|
||||
assert.equal(first.status, 'launching');
|
||||
clock += 5_000;
|
||||
const verified = await service.pollLaunchQualification(installed.id);
|
||||
assert.equal(verified.status, 'launch_verified');
|
||||
assert.equal(verified.launchVerified, true);
|
||||
assert.equal(verified.productionRestorable, true);
|
||||
assert.equal(verified.launchEvidence.process.pid, 42);
|
||||
assert.equal(verified.launchEvidence.process.titleId, 'PPSA01234');
|
||||
assert.equal(verified.launchEvidence.observationCount, 2);
|
||||
assert.deepEqual(verified.launchEvidence.boundaries, { verifyImage: 'passed', pfsMount: 'passed', executable: 'reached', process: 'stable', basis: 'stable-exact-title-process' });
|
||||
assert.match(verified.launchEvidence.runtimeFingerprint, /^[a-f0-9]{64}$/);
|
||||
});
|
||||
|
||||
test('matching process which disappears before stability is launch_failed', async t => {
|
||||
let clock = 1_000;
|
||||
const observations = [
|
||||
{ observed: true, process: { pid: 77, name: 'eboot.bin', titleId: 'PPSA01234' } },
|
||||
{ observed: false, processes: [] },
|
||||
];
|
||||
const { installed, service } = await installedOperation(t, { observeLaunch: async () => observations.shift() }, { now: () => clock, launchStabilityMs: 10_000 });
|
||||
await service.startLaunchQualification(installed.id, { confirmation: 'LAUNCH VERIFY PPSA01234' });
|
||||
await service.pollLaunchQualification(installed.id);
|
||||
clock += 1_000;
|
||||
const failed = await service.pollLaunchQualification(installed.id);
|
||||
assert.equal(failed.status, 'launch_failed');
|
||||
assert.equal(failed.error.code, 'PS5_FPKG_PROCESS_EXITED_EARLY');
|
||||
assert.equal(failed.productionRestorable, false);
|
||||
});
|
||||
|
||||
for (const [failureBoundary, code] of [['verifyImage', 'PS5_FPKG_VERIFY_IMAGE_FAILED'], ['PfsMountGameData_PPR() = 0x80020060', 'PS5_FPKG_PFS_MOUNT_FAILED']]) {
|
||||
test(`${failureBoundary} evidence is retained and normalized`, async t => {
|
||||
const { installed, service } = await installedOperation(t, { observeLaunch: async () => ({ observed: false, failureBoundary, rawEvidence: { klog: failureBoundary } }) });
|
||||
await service.startLaunchQualification(installed.id, { confirmation: 'LAUNCH VERIFY PPSA01234' });
|
||||
const failed = await service.pollLaunchQualification(installed.id);
|
||||
assert.equal(failed.status, 'launch_failed');
|
||||
assert.equal(failed.error.code, code);
|
||||
assert.match(JSON.stringify(failed.launchEvidence.rawEvidence), new RegExp(failureBoundary.replace(/[()]/g, '\\$&')));
|
||||
});
|
||||
}
|
||||
|
||||
test('provider-normalized ShellUI launch-flow failure reaches the durable launch state', async t => {
|
||||
const { installed, service } = await installedOperation(t, {
|
||||
observeLaunch: async () => ({
|
||||
observed: false,
|
||||
failureBoundary: 'ShellUI launch flow',
|
||||
failureCode: 'PS5_FPKG_SHELLUI_LAUNCH_FLOW_FAILED',
|
||||
rawEvidence: { currentWindow: 'LaunchFlowError.PPSA01234' },
|
||||
}),
|
||||
});
|
||||
await service.startLaunchQualification(installed.id, { confirmation: 'LAUNCH VERIFY PPSA01234' });
|
||||
const failed = await service.pollLaunchQualification(installed.id);
|
||||
assert.equal(failed.status, 'launch_failed');
|
||||
assert.equal(failed.error.code, 'PS5_FPKG_SHELLUI_LAUNCH_FLOW_FAILED');
|
||||
assert.equal(failed.launchVerified, false);
|
||||
assert.equal(failed.productionRestorable, false);
|
||||
});
|
||||
|
||||
test('normalized launch failure survives when the raw process evidence exceeds the retention preview', async t => {
|
||||
const normalizedEvent = { category: 'launch_flow_error', titleId: 'PPSA01234', code: 'LaunchFlowError.PPSA01234' };
|
||||
const { installed, service } = await installedOperation(t, {
|
||||
observeLaunch: async () => ({
|
||||
observed: false,
|
||||
failureBoundary: 'ShellUI launch flow',
|
||||
failureCode: 'PS5_FPKG_SHELLUI_LAUNCH_FLOW_FAILED',
|
||||
normalizedEvents: [normalizedEvent],
|
||||
truncated: false,
|
||||
syslog: { continuity: 'exact-watermark', currentWindow: 'LaunchFlowError.PPSA01234', currentSha256: 'a'.repeat(64) },
|
||||
rawEvidence: { processes: Array.from({ length: 800 }, (_, pid) => ({ pid: pid + 1, name: `process-${pid}-${'x'.repeat(80)}` })), currentWindow: 'LaunchFlowError.PPSA01234' },
|
||||
}),
|
||||
});
|
||||
await service.startLaunchQualification(installed.id, { confirmation: 'LAUNCH VERIFY PPSA01234' });
|
||||
const failed = await service.pollLaunchQualification(installed.id);
|
||||
const retained = failed.launchEvidence.rawEvidence[0];
|
||||
assert.equal(retained.evidence.truncated, true);
|
||||
assert.equal(retained.summary.failureCode, 'PS5_FPKG_SHELLUI_LAUNCH_FLOW_FAILED');
|
||||
assert.equal(retained.summary.failureBoundary, 'ShellUI launch flow');
|
||||
assert.deepEqual(retained.summary.normalizedEvents, [normalizedEvent]);
|
||||
assert.equal(retained.summary.syslog.continuity, 'exact-watermark');
|
||||
assert.equal(retained.summary.syslog.currentWindowBytes, Buffer.byteLength('LaunchFlowError.PPSA01234'));
|
||||
assert.equal(failed.error.details.summary.failureCode, 'PS5_FPKG_SHELLUI_LAUNCH_FLOW_FAILED');
|
||||
});
|
||||
|
||||
test('accepted instrumented LNC result followed by ShellUI failure is classified after the Sony call', async t => {
|
||||
const diagnostic = {
|
||||
schema: 'ps5upload.shellui-launch-observation.v1',
|
||||
diagnosticIdentity: 'chimera-p15-shellui-observe-v1',
|
||||
attempts: [{ index: 1, callEntered: true, sonyReturnObserved: true, rawSigned32: 0, wrapperRc: 0 }],
|
||||
terminalAttemptIndex: 1,
|
||||
terminalWrapperRc: 0,
|
||||
providerAccepted: true,
|
||||
providerNormalizedResult: 'accepted',
|
||||
};
|
||||
const { installed, service } = await installedOperation(t, {
|
||||
launch: async () => ({ accepted: true, diagnostic }),
|
||||
observeLaunch: async () => ({
|
||||
observed: false,
|
||||
failureBoundary: 'ShellUI launch flow',
|
||||
failureCode: 'PS5_FPKG_SHELLUI_LAUNCH_FLOW_FAILED',
|
||||
rawEvidence: { currentWindow: 'LaunchFlowError.PPSA01234' },
|
||||
}),
|
||||
});
|
||||
await service.startLaunchQualification(installed.id, { confirmation: 'LAUNCH VERIFY PPSA01234' });
|
||||
const failed = await service.pollLaunchQualification(installed.id);
|
||||
assert.equal(failed.status, 'launch_failed');
|
||||
assert.equal(failed.error.code, 'PS5_FPKG_SHELLUI_POST_LNC_FAILED');
|
||||
assert.equal(failed.launchEvidence.request.diagnostic.terminalWrapperRc, 0);
|
||||
assert.equal(failed.launchVerified, false);
|
||||
assert.equal(failed.productionRestorable, false);
|
||||
});
|
||||
|
||||
test('structured diagnostic rejection is persisted before launch_failed and never replayed', async t => {
|
||||
let launches = 0;
|
||||
const diagnostic = {
|
||||
schema: 'ps5upload.shellui-launch-observation.v1',
|
||||
diagnosticIdentity: 'chimera-p15-shellui-observe-v1',
|
||||
operationId: '123e4567-e89b-42d3-a456-426614174000',
|
||||
titleId: 'PPSA01234',
|
||||
attempts: [{ index: 1, wrapperRc: 27, wrapperHex: '0x0000001b' }],
|
||||
internalRetryOccurred: false,
|
||||
terminalAttemptIndex: 1,
|
||||
terminalWrapperRc: 27,
|
||||
terminalWrapperHex: '0x0000001b',
|
||||
providerAccepted: false,
|
||||
providerNormalizedResult: 'sony_rejected',
|
||||
};
|
||||
const { installed, service } = await installedOperation(t, {
|
||||
launch: async ({ operationId }) => {
|
||||
launches += 1;
|
||||
return { accepted: false, titleId: 'PPSA01234', operationId, diagnostic };
|
||||
},
|
||||
});
|
||||
await assert.rejects(
|
||||
() => service.startLaunchQualification(installed.id, { confirmation: 'LAUNCH VERIFY PPSA01234' }),
|
||||
error => error.code === 'PS5_FPKG_LAUNCH_REQUEST_REJECTED',
|
||||
);
|
||||
const failed = service.get(installed.id);
|
||||
assert.equal(failed.status, 'launch_failed');
|
||||
assert.equal(failed.launchEvidence.request.diagnostic.terminalWrapperRc, 27);
|
||||
assert.equal(failed.launchEvidence.requestedAt > 0, true);
|
||||
await assert.rejects(() => service.startLaunchQualification(installed.id, { confirmation: 'LAUNCH VERIFY PPSA01234' }), error => error.code === 'PS5_FPKG_LAUNCH_STATE');
|
||||
assert.equal(launches, 1);
|
||||
});
|
||||
|
||||
test('restart during launch persists interrupted outcome_unknown and never replays launch', async t => {
|
||||
let launches = 0;
|
||||
const { root, installed, service, provider } = await installedOperation(t, { launch: async () => { launches += 1; return { accepted: true }; } });
|
||||
await service.startLaunchQualification(installed.id, { confirmation: 'LAUNCH VERIFY PPSA01234' });
|
||||
const restarted = createPs5NativePackageInstallService({ provider, stager: async () => ({}), stateFile: path.join(root, 'operations.json') });
|
||||
const restored = restarted.get(installed.id);
|
||||
assert.equal(restored.status, 'interrupted');
|
||||
assert.equal(restored.launchOutcome, 'outcome_unknown');
|
||||
assert.equal(restored.launchVerified, false);
|
||||
assert.equal(launches, 1);
|
||||
});
|
||||
|
||||
test('concurrent launch starts reserve before preflight and produce exactly one provider request', async t => {
|
||||
let releasePreflight;
|
||||
let launches = 0;
|
||||
const deferred = new Promise(resolve => { releasePreflight = resolve; });
|
||||
const { installed, service } = await installedOperation(t, {
|
||||
preflightLaunch: async () => deferred,
|
||||
launch: async () => { launches += 1; return { accepted: true }; },
|
||||
});
|
||||
const first = service.startLaunchQualification(installed.id, { confirmation: 'LAUNCH VERIFY PPSA01234' });
|
||||
await Promise.resolve();
|
||||
await assert.rejects(
|
||||
() => service.startLaunchQualification(installed.id, { confirmation: 'LAUNCH VERIFY PPSA01234' }),
|
||||
error => error.code === 'PS5_FPKG_LAUNCH_STATE',
|
||||
);
|
||||
releasePreflight(readyLaunchEvidence());
|
||||
const result = await first;
|
||||
assert.equal(result.status, 'launching');
|
||||
assert.equal(launches, 1);
|
||||
});
|
||||
|
||||
test('corrupt persisted operation state fails closed and preserves its bytes', () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'chimera-ch1304-corrupt-state-'));
|
||||
const stateFile = path.join(root, 'operations.json');
|
||||
const corrupt = Buffer.from('{"operations":');
|
||||
const provider = {
|
||||
preflightInstall: async () => ({ ready: true }),
|
||||
install: async () => ({}),
|
||||
installStatus: async () => ({}),
|
||||
};
|
||||
try {
|
||||
fs.writeFileSync(stateFile, corrupt);
|
||||
assert.throws(
|
||||
() => createPs5NativePackageInstallService({ provider, stager: async () => ({}), stateFile }),
|
||||
error => error.code === 'PS5_PKG_OPERATION_STATE_CORRUPT',
|
||||
);
|
||||
assert.deepEqual(fs.readFileSync(stateFile), corrupt);
|
||||
} finally { fs.rmSync(root, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
test('legacy installed operation rehydrates immutable package identity without claiming content verification', async t => {
|
||||
let launches = 0;
|
||||
const { root, installed, provider } = await installedOperation(t, {
|
||||
launch: async () => { launches += 1; return { accepted: true }; },
|
||||
});
|
||||
const stateFile = path.join(root, 'operations.json');
|
||||
const persisted = JSON.parse(fs.readFileSync(stateFile, 'utf8'));
|
||||
const legacy = persisted.find(item => item.id === installed.id);
|
||||
delete legacy.packageIdentity;
|
||||
delete legacy.packageIdentitySha256;
|
||||
fs.writeFileSync(stateFile, JSON.stringify(persisted, null, 2));
|
||||
|
||||
const restarted = createPs5NativePackageInstallService({ provider, stager: async () => ({}), stateFile });
|
||||
const migrated = restarted.get(installed.id);
|
||||
assert.match(migrated.packageIdentity, /^base:base:[a-f0-9]{64}:68$/);
|
||||
assert.match(migrated.packageIdentitySha256, /^[a-f0-9]{64}$/);
|
||||
assert.equal(migrated.packageIdentityProvenance, 'derived-persisted-component-manifest-v1');
|
||||
assert.equal(migrated.installedContentVerified, false);
|
||||
assert.equal(migrated.launchVerified, false);
|
||||
assert.equal(migrated.productionRestorable, false);
|
||||
|
||||
const launched = await restarted.startLaunchQualification(installed.id, { confirmation: 'LAUNCH VERIFY PPSA01234' });
|
||||
assert.equal(launched.status, 'launching');
|
||||
assert.equal(launches, 1);
|
||||
});
|
||||
|
||||
test('partial legacy package identity remains fail-closed', async t => {
|
||||
const { root, installed, provider } = await installedOperation(t);
|
||||
const stateFile = path.join(root, 'operations.json');
|
||||
const persisted = JSON.parse(fs.readFileSync(stateFile, 'utf8'));
|
||||
const legacy = persisted.find(item => item.id === installed.id);
|
||||
delete legacy.packageIdentitySha256;
|
||||
fs.writeFileSync(stateFile, JSON.stringify(persisted, null, 2));
|
||||
|
||||
const restarted = createPs5NativePackageInstallService({ provider, stager: async () => ({}), stateFile });
|
||||
await assert.rejects(
|
||||
() => restarted.startLaunchQualification(installed.id, { confirmation: 'LAUNCH VERIFY PPSA01234' }),
|
||||
error => error.code === 'PS5_FPKG_ARTIFACT_HASH_DRIFT',
|
||||
);
|
||||
});
|
||||
|
||||
test('ambiguous launch transport failure persists outcome_unknown and cannot be replayed', async t => {
|
||||
let launches = 0;
|
||||
const { installed, service } = await installedOperation(t, {
|
||||
launch: async () => { launches += 1; throw Object.assign(new Error('connection reset'), { code: 'ECONNRESET' }); },
|
||||
});
|
||||
await assert.rejects(
|
||||
() => service.startLaunchQualification(installed.id, { confirmation: 'LAUNCH VERIFY PPSA01234' }),
|
||||
error => error.code === 'PS5_FPKG_LAUNCH_OUTCOME_UNKNOWN',
|
||||
);
|
||||
const unknown = service.get(installed.id);
|
||||
assert.equal(unknown.status, 'interrupted');
|
||||
assert.equal(unknown.launchOutcome, 'outcome_unknown');
|
||||
assert.equal(unknown.launchVerified, false);
|
||||
assert.equal(unknown.productionRestorable, false);
|
||||
assert.equal(unknown.error.code, 'PS5_FPKG_LAUNCH_OUTCOME_UNKNOWN');
|
||||
await assert.rejects(
|
||||
() => service.startLaunchQualification(installed.id, { confirmation: 'LAUNCH VERIFY PPSA01234' }),
|
||||
error => error.code === 'PS5_FPKG_LAUNCH_STATE',
|
||||
);
|
||||
assert.equal(launches, 1);
|
||||
});
|
||||
|
||||
test('provider pin, routes and runtime evidence are exact and caller cannot select host or title indirectly', async t => {
|
||||
const item = packageFixture(); t.after(() => fs.rmSync(item.root, { recursive: true, force: true }));
|
||||
const requests = [];
|
||||
let processRequests = 0;
|
||||
const provider = createPs5UploadPackageInstallerProvider({
|
||||
engineOrigin: 'http://127.0.0.1:19113', consoleHost: 'ps5.local', packageRoot: item.root,
|
||||
getRuntimeIdentity: exactRuntimeIdentity,
|
||||
fetch: async (url, options = {}) => {
|
||||
requests.push({ url: String(url), body: options.body ? JSON.parse(options.body) : null });
|
||||
if (String(url).endsWith('/api/version')) return response({ version: '5.4.19' });
|
||||
if (String(url).includes('/api/ps5/status')) return response({ version: '5.4.19', ucred_elevated: true });
|
||||
if (String(url).includes('/api/ps5/readiness')) return response({ ready: true });
|
||||
if (String(url).includes('/api/ps5/process/list')) {
|
||||
processRequests += 1;
|
||||
return response({ processes: [
|
||||
{ pid: 107, name: 'kstuff.elf' },
|
||||
{ pid: 108, name: 'etaHEN Utility Daemon' },
|
||||
...(processRequests > 1 ? [{ pid: 91, name: 'eboot.bin', title_id: 'PPSA01234', kind: 'app' }] : []),
|
||||
], truncated: false });
|
||||
}
|
||||
if (String(url).includes('/api/ps5/syslog/tail')) return response({ text: '' });
|
||||
if (String(url).includes('/api/ps5/app/launch')) return response({ ok: true });
|
||||
return response({});
|
||||
},
|
||||
});
|
||||
assert.equal(PINNED_PS5_PACKAGE_INSTALLER.version, '5.4.19');
|
||||
assert.equal(PINNED_PS5_PACKAGE_INSTALLER.revision, 'ec0e5c4e95520f2dc26c226a909dc5a95e385f41');
|
||||
const preflight = await provider.preflightLaunch({ titleId: 'PPSA01234', components: [item.component] });
|
||||
assert.equal(preflight.ready, true);
|
||||
assert.deepEqual(Object.values(preflight.capabilities), [true, true, true, true, true, true, true, true, true]);
|
||||
const launched = await provider.launch({ titleId: 'PPSA01234' });
|
||||
assert.equal(launched.accepted, true);
|
||||
assert.equal(launched.acknowledgementConclusive, false);
|
||||
assert.equal(launched.clientAutomaticRetry, false);
|
||||
assert.equal(launched.upstreamInternalRetryPossible, true);
|
||||
const observed = await provider.observeLaunch({ titleId: 'PPSA01234' });
|
||||
assert.equal(observed.process.pid, 91);
|
||||
const launchRequest = requests.find(entry => entry.url.includes('/api/ps5/app/launch'));
|
||||
assert.deepEqual(launchRequest.body, { addr: 'ps5.local:9114', title_id: 'PPSA01234' });
|
||||
await assert.rejects(() => provider.launch({ titleId: 'PPSA01234', host: 'attacker.invalid' }), error => error.code === 'PS5_FPKG_PROVIDER_INPUT');
|
||||
await assert.rejects(() => provider.launch({ titleId: '../bad' }), error => error.code === 'PS5_FPKG_IDENTITY_INVALID');
|
||||
});
|
||||
|
||||
test('install preflight fails closed when direct fPKG runtime evidence is absent', async t => {
|
||||
const item = packageFixture(); t.after(() => fs.rmSync(item.root, { recursive: true, force: true }));
|
||||
const provider = createPs5UploadPackageInstallerProvider({
|
||||
engineOrigin: 'http://127.0.0.1:19113', consoleHost: 'ps5.local', packageRoot: item.root,
|
||||
fetch: async url => {
|
||||
if (String(url).endsWith('/api/version')) return response({ version: '5.4.19' });
|
||||
if (String(url).includes('/api/ps5/status')) return response({ ucred_elevated: true, kernel_rw: true });
|
||||
if (String(url).includes('/api/ps5/readiness')) return response({ ready: true });
|
||||
return response({});
|
||||
},
|
||||
});
|
||||
const readiness = await provider.preflightInstall({ components: [item.component] });
|
||||
assert.equal(readiness.ready, false);
|
||||
assert.equal(readiness.errorCode, 'PS5_FPKG_INSTALL_RUNTIME_NOT_READY');
|
||||
assert.equal(readiness.capabilities.fpkgEnableActive, false);
|
||||
assert.equal(readiness.capabilities.debugPackageRuntime, false);
|
||||
});
|
||||
|
||||
test('artifact byte drift is rejected before a launch request', async t => {
|
||||
const item = packageFixture(); t.after(() => fs.rmSync(item.root, { recursive: true, force: true }));
|
||||
let launches = 0;
|
||||
const provider = createPs5UploadPackageInstallerProvider({
|
||||
engineOrigin: 'http://127.0.0.1:19113', consoleHost: 'ps5.local', packageRoot: item.root,
|
||||
fetch: async url => {
|
||||
if (String(url).endsWith('/api/version')) return response({ version: '5.4.19' });
|
||||
if (String(url).includes('/api/ps5/status')) return response({ intdebug_avail: true, fpkg_enable_active: true, kernel_rw: true, ucred_elevated: true, launch_service_ready: true });
|
||||
if (String(url).includes('/api/ps5/readiness')) return response({ ready: true });
|
||||
if (String(url).includes('/api/ps5/process/list')) return response({ processes: [], truncated: false });
|
||||
launches += 1; return response({ ok: true });
|
||||
},
|
||||
});
|
||||
fs.writeFileSync(item.component.metadata.absolutePath, Buffer.concat([Buffer.from([0x7f, 0x46, 0x49, 0x48]), Buffer.alloc(64, 0x45)]));
|
||||
await assert.rejects(() => provider.preflightLaunch({ titleId: 'PPSA01234', components: [item.component] }), error => error.code === 'PS5_FPKG_ARTIFACT_HASH_DRIFT');
|
||||
assert.equal(launches, 0);
|
||||
});
|
||||
|
||||
test('registry exposes launch qualification as a separate exact-confirmed lifecycle', () => {
|
||||
const registry = fs.readFileSync(path.resolve(__dirname, '../../src/server/create-registry.js'), 'utf8');
|
||||
for (const command of ['ps5-native-packages:restore-launch-plan', 'ps5-native-packages:restore-launch', 'ps5-native-packages:restore-launch-status']) assert.match(registry, new RegExp(command));
|
||||
assert.match(registry, /confirmation: input => `LAUNCH VERIFY \$\{ps5NativePackageInstalls\.get\(input\?\.operationId\)\.titleId\}`/);
|
||||
assert.match(registry, /launchVerified: false, productionRestorable: false, nextAction: 'launch-qualification-required'/);
|
||||
});
|
||||
@@ -0,0 +1,112 @@
|
||||
'use strict';
|
||||
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const test = require('node:test');
|
||||
const vm = require('node:vm');
|
||||
|
||||
const root = path.resolve(__dirname, '../..');
|
||||
const read = file => fs.readFileSync(path.join(root, file), 'utf8');
|
||||
|
||||
test('PS5 product surface is an evidence-first fPKG backup and restore workflow', () => {
|
||||
const html = read('renderer/index.html');
|
||||
assert.match(html, /PS5 fPKG Backup & Restore/);
|
||||
for (const phase of ['Capture', 'Analyze', 'Build fPKG', 'Verify', 'Restore', 'Launch verify']) assert.match(html, new RegExp(`>${phase}<`));
|
||||
assert.match(html, /PS5 fPKG means a finalized FIH \.pkg/);
|
||||
assert.match(html, /\.ffpkg remains a separate UFS mount image and is never a fallback/);
|
||||
assert.doesNotMatch(html, /PS5 Native PKG Lab|Build genuine debug PKG set|Guarded experimental restore/);
|
||||
});
|
||||
|
||||
test('backup safety readiness and restore qualification are separate accessible live evidence', () => {
|
||||
const html = read('renderer/index.html');
|
||||
for (const id of ['ps5-native-preservation-evidence', 'ps5-native-fpkg-readiness-evidence', 'ps5-native-restore-evidence']) assert.match(html, new RegExp(`id="${id}"`));
|
||||
assert.match(html, /class="ps5-fpkg-evidence-grid"[^>]*aria-label=/);
|
||||
assert.match(html, /id="ps5-native-package-readiness"[^>]*role="status"[^>]*aria-live="polite"/);
|
||||
assert.match(html, /id="ps5-native-package-launch"[^>]*disabled/);
|
||||
assert.match(html, /data-i18n-aria-label="ps5NativePackages\.phase\.aria"/);
|
||||
});
|
||||
|
||||
test('renderer presents installed pending failed and process-qualified states without conflation', () => {
|
||||
const modal = read('renderer/modules/modal.js');
|
||||
for (const state of ['installed_observed', 'launching', 'launch_failed', 'launch_verified', 'outcome_unknown']) assert.match(modal, new RegExp(state));
|
||||
assert.match(modal, /Installed · launch verification pending/);
|
||||
assert.match(modal, /Installed — launch failed\. Not qualified for restore/);
|
||||
assert.match(modal, /Installed · Launch verified · Qualified for restore/);
|
||||
assert.match(modal, /ps5NativePackageRestoreLaunchPlan/);
|
||||
assert.match(modal, /showExactConfirmation[\s\S]*plan\.confirmation/);
|
||||
assert.match(modal, /ps5NativePackageRestoreLaunchStatus/);
|
||||
assert.doesNotMatch(modal, /operation\.status === 'completed'[^\n]*ps5NativePackages\.verify\.completed/);
|
||||
});
|
||||
|
||||
test('launch qualification selects durable launch evidence instead of the newest install attempt', () => {
|
||||
const modal = read('renderer/modules/modal.js');
|
||||
assert.match(modal, /function selectPs5RestoreQualificationOperation\(installs, titleId\)/);
|
||||
assert.match(modal, /\['installed_observed', 'launch_test_pending', 'launching', 'launch_verified', 'launch_failed'\]\.includes\(item\.status\)/);
|
||||
assert.match(modal, /item\.status === 'interrupted' && item\.launchOutcome === 'outcome_unknown'/);
|
||||
assert.match(modal, /Number\(b\.updatedAt \|\| b\.createdAt \|\| 0\) - Number\(a\.updatedAt \|\| a\.createdAt \|\| 0\)/);
|
||||
assert.match(modal, /renderPs5RestoreQualification\(qualificationOperation\)/);
|
||||
assert.doesNotMatch(modal, /renderPs5RestoreQualification\(titleInstalls\[0\]/);
|
||||
|
||||
const source = modal.match(/function selectPs5RestoreQualificationOperation\(installs, titleId\) \{[\s\S]*?\n\}/)?.[0];
|
||||
assert.ok(source);
|
||||
const select = vm.runInNewContext(`(${source})`);
|
||||
const selected = select([
|
||||
{ id: 'newer-install', titleId: 'FAKE00000', status: 'completed', createdAt: 300, updatedAt: 310 },
|
||||
{ id: 'launch-ready', titleId: 'FAKE00000', status: 'installed_observed', createdAt: 200, updatedAt: 400 },
|
||||
{ id: 'other-title', titleId: 'PPSA99099', status: 'launch_verified', createdAt: 500, updatedAt: 500 },
|
||||
], 'fake00000');
|
||||
assert.equal(selected.id, 'launch-ready');
|
||||
});
|
||||
|
||||
test('the exact pinned FAKE00000 launch reference can reach the guarded native workspace', () => {
|
||||
const modal = read('renderer/modules/modal.js');
|
||||
assert.match(modal, /function isPs5NativeWorkspaceTitle\(gameId\)/);
|
||||
assert.match(modal, /=== 'FAKE00000'/);
|
||||
assert.match(modal, /btn-modal-ps5-native-packages'\)\.hidden = !isPs5NativeWorkspaceTitle\(game_id\)/);
|
||||
assert.match(modal, /refreshPs5NativePackageWorkspace\(\)[\s\S]*!isPs5NativeWorkspaceTitle\(game\.game_id\)/);
|
||||
assert.match(modal, /openPs5NativePackageWorkspace\(\)[\s\S]*!isPs5NativeWorkspaceTitle\(game\.game_id\)/);
|
||||
assert.doesNotMatch(modal, /\^\(PPSA\|FAKE\)/, 'do not widen arbitrary PS5 title validation');
|
||||
});
|
||||
|
||||
test('Electron and web bridges expose only semantic launch qualification calls', () => {
|
||||
const preload = read('preload.js');
|
||||
const web = read('renderer/pork-web-shim.js');
|
||||
const main = read('main.js');
|
||||
for (const source of [preload, web]) {
|
||||
assert.match(source, /ps5NativePackageRestoreLaunchPlan/);
|
||||
assert.match(source, /ps5NativePackageRestoreLaunchStatus/);
|
||||
assert.match(source, /ps5-native-packages:restore-launch/);
|
||||
}
|
||||
for (const command of ['restore-launch-plan', 'restore-launch', 'restore-launch-status']) assert.match(main, new RegExp(`ps5-native-packages:${command}`));
|
||||
assert.doesNotMatch(modalUnsafeSurface(web), /callerHost|remotePath|shellCommand/);
|
||||
});
|
||||
|
||||
function modalUnsafeSurface(source) {
|
||||
const start = source.indexOf('ps5NativePackageRestoreLaunchPlan');
|
||||
return start < 0 ? '' : source.slice(start, start + 700);
|
||||
}
|
||||
|
||||
test('new visible fPKG copy has English and Dutch catalog parity', () => {
|
||||
const en = JSON.parse(read('build/lang/en.json')).strings;
|
||||
const nl = JSON.parse(read('build/lang/nl.json')).strings;
|
||||
const keys = [
|
||||
'ps5NativePackages.title', 'ps5NativePackages.phase.capture', 'ps5NativePackages.phase.analyze',
|
||||
'ps5NativePackages.phase.launch', 'ps5NativePackages.evidence.backup', 'ps5NativePackages.evidence.readiness',
|
||||
'ps5NativePackages.evidence.restore', 'ps5NativePackages.evidence.backupSafe',
|
||||
'ps5NativePackages.evidence.buildBlocked', 'ps5NativePackages.launch.action',
|
||||
'ps5NativePackages.launch.pending', 'ps5NativePackages.launch.failed',
|
||||
'ps5NativePackages.launch.qualified', 'ps5NativePackages.launch.boundary',
|
||||
];
|
||||
for (const key of keys) {
|
||||
assert.equal(typeof en[key], 'string', `missing English ${key}`);
|
||||
assert.equal(typeof nl[key], 'string', `missing Dutch ${key}`);
|
||||
assert.notEqual(en[key], key);
|
||||
assert.notEqual(nl[key], key);
|
||||
}
|
||||
});
|
||||
|
||||
test('clean upstream UI no longer exposes legacy V16 builder tuning', () => {
|
||||
const settings = read('renderer/modules/settings.js');
|
||||
assert.doesNotMatch(settings, /ps5-native-package-compression-workers|ps5-native-package-fast-store-threshold/);
|
||||
});
|
||||
@@ -0,0 +1,97 @@
|
||||
'use strict';
|
||||
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const { pathToFileURL } = require('node:url');
|
||||
const test = require('node:test');
|
||||
|
||||
const scriptPath = path.resolve(__dirname, '../../scripts/autonomous/verify-ps5-fpkg-hardware-evidence.mjs');
|
||||
const scriptUrl = pathToFileURL(scriptPath).href;
|
||||
const repoRoot = path.resolve(__dirname, '../..');
|
||||
|
||||
function productionFiles(entry) {
|
||||
const absolute = path.join(repoRoot, entry);
|
||||
if (!fs.existsSync(absolute)) return [];
|
||||
if (fs.statSync(absolute).isFile()) return [absolute];
|
||||
return fs.readdirSync(absolute, { withFileTypes: true }).flatMap(item => {
|
||||
if (['node_modules', 'test', 'temp'].includes(item.name)) return [];
|
||||
return productionFiles(path.relative(repoRoot, path.join(absolute, item.name)));
|
||||
});
|
||||
}
|
||||
|
||||
function validBundle() {
|
||||
const capabilities = {
|
||||
debugPackageRuntime: true, fpkgEnableActive: true, kernelPatchReady: true,
|
||||
packageInstallerReady: true, launchServiceReady: true, processObservationReady: true,
|
||||
};
|
||||
return {
|
||||
schema: 'chimera.ps5-fpkg-hardware-acceptance.v1',
|
||||
titleId: 'PPSA17330',
|
||||
source: { treeDigestAlgorithm: 'sha256-tree-v1', treeSha256: 'a'.repeat(64), immutable: true, zeroUnresolvedModules: true, staticLaunchReadiness: true },
|
||||
builder: { product: 'LibProsperoPkg', version: '2.6.0', revision: '9084c0e88cd8d7cd9c0b353b8b6b89f7d1cc4275', bridgeAbi: 'chimera-prospero-bridge-v4', patched: false, bridgeSha256: 'b'.repeat(64) },
|
||||
artifact: { extension: '.pkg', magic: '7f464948', container: 'FIH', debug: true, retail: false, sha256: 'c'.repeat(64), installedSha256: 'c'.repeat(64), structuralValidationAccepted: true },
|
||||
controls: { sourceUnchanged: true, installRouteRecorded: true, runtimeRecorded: true, consoleConfigurationRecorded: true, simultaneousChangesExplained: true },
|
||||
operation: {
|
||||
status: 'launch_verified', titleId: 'PPSA17330', installed: true, launchVerified: true, productionRestorable: true,
|
||||
packageIdentitySha256: 'd'.repeat(64), evidence: { source: 'post-install-console-scan', titleId: 'PPSA17330' },
|
||||
launchEvidence: {
|
||||
titleId: 'PPSA17330', packageIdentitySha256: 'd'.repeat(64), runtimeFingerprint: 'e'.repeat(64),
|
||||
runtime: { capabilities }, request: { accepted: true }, observationCount: 2,
|
||||
firstObservedAt: 1000, lastObservedAt: 12000,
|
||||
process: { pid: 42, name: 'eboot.bin', titleId: 'PPSA17330' },
|
||||
boundaries: { verifyImage: 'passed', pfsMount: 'passed', executable: 'reached', process: 'stable', basis: 'stable-exact-title-process' },
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('hardware evidence gate accepts only a complete launch_verified bundle', async () => {
|
||||
const { validateHardwareEvidence } = await import(scriptUrl);
|
||||
const report = validateHardwareEvidence(validBundle());
|
||||
assert.equal(report.accepted, true);
|
||||
assert.equal(report.checks.every(item => item.pass), true);
|
||||
assert.equal(report.productionRestorable, true);
|
||||
});
|
||||
|
||||
test('only launch_verified can make productionRestorable true in code and manifests', () => {
|
||||
const files = ['main.js', 'preload.js', 'src', 'renderer']
|
||||
.flatMap(productionFiles)
|
||||
.filter(file => /\.(?:c?js|mjs)$/.test(file));
|
||||
const assignments = files.flatMap(file => {
|
||||
const source = fs.readFileSync(file, 'utf8');
|
||||
return [...source.matchAll(/productionRestorable\s*:\s*true/g)].map(match => ({ file, source, index: match.index }));
|
||||
});
|
||||
assert.equal(assignments.length, 1, assignments.map(item => path.relative(repoRoot, item.file)).join(', '));
|
||||
assert.equal(path.basename(assignments[0].file), 'ps5-native-package-install-service.js');
|
||||
const qualifyingUpdate = assignments[0].source.slice(Math.max(0, assignments[0].index - 120), assignments[0].index + 80);
|
||||
assert.match(qualifyingUpdate, /status:\s*'launch_verified'[\s\S]*launchVerified:\s*true[\s\S]*productionRestorable:\s*true/);
|
||||
|
||||
const schema = JSON.parse(fs.readFileSync(path.join(repoRoot, 'schemas/ps5-native-package-manifest.schema.json'), 'utf8'));
|
||||
assert.deepEqual(schema.properties.launchVerified, { const: false });
|
||||
assert.deepEqual(schema.properties.productionRestorable, { const: false });
|
||||
});
|
||||
|
||||
test('hardware evidence gate rejects install-only and acknowledgement-only bundles', async () => {
|
||||
const { validateHardwareEvidence } = await import(scriptUrl);
|
||||
for (const mutate of [
|
||||
bundle => { bundle.operation.status = 'installed_observed'; bundle.operation.launchVerified = false; bundle.operation.productionRestorable = false; },
|
||||
bundle => { bundle.operation.launchEvidence.observationCount = 0; bundle.operation.launchEvidence.process = null; },
|
||||
bundle => { bundle.artifact.installedSha256 = 'f'.repeat(64); },
|
||||
bundle => { bundle.operation.launchEvidence.runtime.capabilities.fpkgEnableActive = false; },
|
||||
]) {
|
||||
const bundle = validBundle(); mutate(bundle);
|
||||
assert.equal(validateHardwareEvidence(bundle).accepted, false);
|
||||
}
|
||||
});
|
||||
|
||||
test('hardware evidence command is read-only and emits a machine-readable report', async t => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'chimera-hw-gate-')); t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
const bundlePath = path.join(root, 'evidence.json'); fs.writeFileSync(bundlePath, JSON.stringify(validBundle()));
|
||||
const { spawnSync } = require('node:child_process');
|
||||
const result = spawnSync(process.execPath, [scriptPath, bundlePath], { encoding: 'utf8' });
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
assert.equal(JSON.parse(result.stdout).accepted, true);
|
||||
assert.equal(fs.readdirSync(root).length, 1);
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
async function queueLib() {
|
||||
return import('../../scripts/autonomous/lib.mjs');
|
||||
}
|
||||
|
||||
test('evidenced external blockers satisfy the legacy dependency policy', async () => {
|
||||
const { dependenciesSatisfied } = await queueLib();
|
||||
const dependency = {
|
||||
id: 'CH-0001',
|
||||
status: 'blocked_external',
|
||||
notes: 'Concrete external evidence keeps host-complete follow-up work available.',
|
||||
};
|
||||
|
||||
assert.equal(dependenciesSatisfied({ dependencies: [dependency.id] }, new Map([[dependency.id, dependency]])), true);
|
||||
});
|
||||
|
||||
test('done_only dependency policy stops P15 follow-up after a blocked qualification gate', async () => {
|
||||
const { dependenciesSatisfied, selectNextTask } = await queueLib();
|
||||
const qualification = {
|
||||
id: 'CH-1401',
|
||||
status: 'blocked_external',
|
||||
notes: 'The owned-hardware reference controls did not reach an exact title process.',
|
||||
dependencies: [],
|
||||
};
|
||||
const adapter = {
|
||||
id: 'CH-1402',
|
||||
status: 'pending',
|
||||
dependencies: [qualification.id],
|
||||
dependency_policy: 'done_only',
|
||||
};
|
||||
const byId = new Map([[qualification.id, qualification], [adapter.id, adapter]]);
|
||||
|
||||
assert.equal(dependenciesSatisfied(adapter, byId), false);
|
||||
assert.equal(selectNextTask({ tasks: [qualification, adapter] }), null);
|
||||
|
||||
qualification.status = 'done';
|
||||
assert.equal(dependenciesSatisfied(adapter, byId), true);
|
||||
assert.equal(selectNextTask({ tasks: [qualification, adapter] }), adapter);
|
||||
});
|
||||
@@ -0,0 +1,341 @@
|
||||
'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 { EventEmitter } = require('node:events');
|
||||
const {
|
||||
A8_DISTINCT_CONTROL,
|
||||
A10_TRANSITION_CONTROL,
|
||||
} = require('../../src/services/ps5-native-pkg-builder-provider');
|
||||
const { createPs5UploadRuntimeService } = require('../../src/services/ps5upload-runtime-service');
|
||||
const { createPs5FpkgDistinctControlPackageService } = require('../../src/services/ps5-fpkg-distinct-control-package-service');
|
||||
const { createP15A10RuntimeStageService, CONFIRMATIONS } = require('../../src/services/ps5-p15-a10-runtime-stage-service');
|
||||
const { P15_A10_RUNTIME_PROFILE } = require('../../src/services/ps5-p15-a10-runtime-profile');
|
||||
const { IDENTITY, SCHEMA, BASE_REVISION } = require('../../src/services/ps5-shellui-transition-observation');
|
||||
|
||||
const digest = value => crypto.createHash('sha256').update(value).digest('hex');
|
||||
const fileDigest = filename => crypto.createHash('sha256').update(fs.readFileSync(filename)).digest('hex');
|
||||
|
||||
test('A10 control has one fresh immutable identity and exact frozen distribution artifact', () => {
|
||||
assert.equal(A10_TRANSITION_CONTROL.titleId, 'CHIM00003');
|
||||
assert.equal(A10_TRANSITION_CONTROL.contentId, 'UP9000-CHIM00003_00-A10CONTROL000003');
|
||||
assert.equal(A10_TRANSITION_CONTROL.confirmation, 'INSTALL PS5 A10 CONTROL CHIM00003');
|
||||
assert.notEqual(A10_TRANSITION_CONTROL.titleId, A8_DISTINCT_CONTROL.titleId);
|
||||
assert.equal(A10_TRANSITION_CONTROL.contentVersion, A8_DISTINCT_CONTROL.contentVersion);
|
||||
assert.deepEqual(A10_TRANSITION_CONTROL.assets, A8_DISTINCT_CONTROL.assets);
|
||||
assert.match(A10_TRANSITION_CONTROL.selectedArtifact.sha256, /^[a-f0-9]{64}$/);
|
||||
assert.ok(A10_TRANSITION_CONTROL.selectedArtifact.size > 1_000_000);
|
||||
const artifact = path.resolve('assets', 'ps5-controls', 'a10', A10_TRANSITION_CONTROL.selectedArtifact.filename);
|
||||
assert.equal(fs.statSync(artifact).size, A10_TRANSITION_CONTROL.selectedArtifact.size);
|
||||
assert.equal(fileDigest(artifact), A10_TRANSITION_CONTROL.selectedArtifact.sha256);
|
||||
});
|
||||
|
||||
test('A10 materialization requires the dedicated bridge capability and preserves unproven console state', async t => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'chimera-a10-control-'));
|
||||
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
let registered = null;
|
||||
const service = createPs5FpkgDistinctControlPackageService({
|
||||
outputRoot: path.join(root, 'output'),
|
||||
stateFile: path.join(root, 'state.json'),
|
||||
selectedArtifactPath: path.resolve('assets', 'ps5-controls', 'a10', A10_TRANSITION_CONTROL.selectedArtifact.filename),
|
||||
descriptor: A10_TRANSITION_CONTROL,
|
||||
builder: {
|
||||
probe: async () => ({ pinned: true, clean: true, controlAvailable: false, transitionControlAvailable: true, product: 'LibProsperoPkg', version: '2.6.0', revision: A10_TRANSITION_CONTROL.upstreamRevision, abi: 'chimera-prospero-bridge-v4', patched: false }),
|
||||
buildDistinctControl: async () => { throw new Error('frozen selection must not rebuild'); },
|
||||
validatePackage: async () => ({ accepted: true }),
|
||||
inspectPackage: async () => ({ type: 'FullDebug', contentId: A10_TRANSITION_CONTROL.contentId }),
|
||||
},
|
||||
assessMetadata: () => ({ qualified: true, status: 'metadata_qualified' }),
|
||||
registrar: async manifest => { registered = manifest; return { packageSetId: 73, titleId: 91 }; },
|
||||
});
|
||||
const result = await service.prepareSelectedAndRegister();
|
||||
assert.equal(result.status, 'ready');
|
||||
assert.equal(result.installed, false);
|
||||
assert.equal(result.launchVerified, false);
|
||||
assert.equal(result.productionRestorable, false);
|
||||
assert.equal(registered.controlProfile, A10_TRANSITION_CONTROL.controlProfile);
|
||||
assert.equal(registered.evidence.consoleInstall.status, 'unproven');
|
||||
assert.equal(registered.evidence.consoleLaunch.status, 'unproven');
|
||||
});
|
||||
|
||||
function diagnosticRuntime(t, { versionIdentity = IDENTITY } = {}) {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'chimera-a10-runtime-'));
|
||||
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
const engine = Buffer.from('a10-engine');
|
||||
const payload = Buffer.from('a10-payload');
|
||||
const dpi = Buffer.from('production-dpi');
|
||||
const enginePath = path.join(root, 'engine');
|
||||
const payloadPath = path.join(root, 'payload.elf');
|
||||
const dpiPath = path.join(root, 'dpi.elf');
|
||||
fs.writeFileSync(enginePath, engine, { mode: 0o750 });
|
||||
fs.writeFileSync(payloadPath, payload);
|
||||
fs.writeFileSync(dpiPath, dpi);
|
||||
const child = new EventEmitter();
|
||||
child.stdin = { end() {} }; child.stderr = new EventEmitter(); child.kill = () => true;
|
||||
let engineOwned = false; let generation = 1;
|
||||
const diagnostic = {
|
||||
identity: IDENTITY,
|
||||
baseRevision: BASE_REVISION,
|
||||
observationSchema: SCHEMA,
|
||||
sourceSha256: '1'.repeat(64),
|
||||
diffSha256: '2'.repeat(64),
|
||||
};
|
||||
const runtime = createPs5UploadRuntimeService({
|
||||
enginePath, payloadPath, dpiPath,
|
||||
expectedEngineSha256: digest(engine), expectedPayloadSha256: digest(payload), expectedDpiSha256: digest(dpi),
|
||||
diagnostic,
|
||||
consoleHost: () => '192.0.2.55',
|
||||
fetch: async url => {
|
||||
const href = String(url);
|
||||
if (href.endsWith('/api/version')) {
|
||||
if (!engineOwned) throw new Error('not listening');
|
||||
return { ok: true, status: 200, json: async () => ({ version: '5.4.19', diagnostic_identity: versionIdentity, base_revision: BASE_REVISION, observation_schema: SCHEMA }) };
|
||||
}
|
||||
if (href.includes('/api/ps5/status')) return { ok: true, status: 200, json: async () => ({ version: '5.4.19', instance_id: String(generation), started_at_unix: generation }) };
|
||||
return { ok: true, status: 200, json: async () => ({ ok: true, users: [] }) };
|
||||
},
|
||||
spawn: () => { engineOwned = true; return child; },
|
||||
waitForEngine: async () => ({ available: true, version: '5.4.19', diagnosticIdentity: versionIdentity, baseRevision: BASE_REVISION, observationSchema: SCHEMA }),
|
||||
tcpProbe: async input => input.port !== 9040,
|
||||
enginePortProbe: async () => false,
|
||||
tcpSend: async input => { generation += 1; return { ok: true, bytes: input.bytes.length }; },
|
||||
delay: async () => {},
|
||||
processPlatform: 'win32',
|
||||
});
|
||||
return { runtime, diagnostic };
|
||||
}
|
||||
|
||||
test('A10 runtime becomes exact only after exact diagnostic engine and payload transition', async t => {
|
||||
const { runtime } = diagnosticRuntime(t);
|
||||
await runtime.startEngine();
|
||||
await runtime.startPayload();
|
||||
const identity = await runtime.runtimeIdentity();
|
||||
assert.equal(identity.lane, 'diagnostic');
|
||||
assert.equal(identity.status, 'exact');
|
||||
assert.equal(identity.engine.diagnosticIdentity, IDENTITY);
|
||||
assert.equal(identity.payload.diagnosticIdentity, IDENTITY);
|
||||
assert.equal(identity.expected.diagnostic.identity, IDENTITY);
|
||||
});
|
||||
|
||||
test('diagnostic runtime rejects a same-version engine with the wrong derivative identity', async t => {
|
||||
const { runtime } = diagnosticRuntime(t, { versionIdentity: 'chimera-p15-shellui-observe-v1' });
|
||||
await assert.rejects(() => runtime.startEngine(), error => error.code === 'PS5UPLOAD_ENGINE_START_FAILED');
|
||||
const identity = await runtime.runtimeIdentity();
|
||||
assert.equal(identity.status, 'unproven');
|
||||
assert.equal(identity.mutationAllowed, false);
|
||||
});
|
||||
|
||||
function stageFixture({
|
||||
diagnosticStartError = null,
|
||||
diagnosticStartGate = null,
|
||||
diagnosticStartProven = true,
|
||||
productionIdentityExact = true,
|
||||
productionStopResult = { stopped: true, endpointReleased: true, identityGone: true },
|
||||
diagnosticStopResult = { stopped: true, endpointReleased: true, identityGone: true },
|
||||
} = {}) {
|
||||
const calls = [];
|
||||
let currentTime = 1_000;
|
||||
let productionActive = true; let diagnosticActive = false; let payloadExact = false;
|
||||
const productionRuntime = {
|
||||
status: async () => ({ engine: { verified: true, available: productionActive, processOwned: productionActive, sha256: 'a'.repeat(64) }, payload: { verified: true, sha256: 'b'.repeat(64) } }),
|
||||
runtimeIdentity: async () => ({
|
||||
schema: 'chimera.ps5upload-runtime-identity.v1',
|
||||
lane: 'production',
|
||||
status: productionActive && productionIdentityExact ? 'exact' : 'unproven',
|
||||
mutationAllowed: productionActive && productionIdentityExact,
|
||||
engine: { observedSha256: 'a'.repeat(64) },
|
||||
payload: { observedSha256: 'b'.repeat(64) },
|
||||
}),
|
||||
stopEngine: async () => { calls.push('production-stop'); productionActive = false; return productionStopResult; },
|
||||
startEngine: async () => { calls.push('production-start'); productionActive = true; return { available: true, processOwned: true }; },
|
||||
};
|
||||
const diagnosticRuntime = {
|
||||
status: async () => ({ engine: { verified: true, available: diagnosticActive, processOwned: diagnosticActive && diagnosticStartProven, sha256: P15_A10_RUNTIME_PROFILE.engine.sha256 }, payload: { verified: true, sha256: P15_A10_RUNTIME_PROFILE.payload.sha256 } }),
|
||||
startEngine: async () => { calls.push('diagnostic-start'); if (diagnosticStartGate) await diagnosticStartGate; if (diagnosticStartError) throw diagnosticStartError; diagnosticActive = true; return { available: true, processOwned: true }; },
|
||||
stopEngine: async () => { calls.push('diagnostic-stop'); diagnosticActive = false; return diagnosticStopResult; },
|
||||
startPayload: async () => { calls.push('payload-start'); payloadExact = true; return { sent: true, managementReady: true }; },
|
||||
runtimeIdentity: async () => ({
|
||||
schema: 'chimera.ps5upload-runtime-identity.v1',
|
||||
lane: 'diagnostic',
|
||||
status: payloadExact ? 'exact' : 'unproven',
|
||||
mutationAllowed: payloadExact,
|
||||
expected: { diagnostic: { identity: P15_A10_RUNTIME_PROFILE.identity } },
|
||||
engine: { observedSha256: P15_A10_RUNTIME_PROFILE.engine.sha256 },
|
||||
payload: { observedSha256: P15_A10_RUNTIME_PROFILE.payload.sha256 },
|
||||
}),
|
||||
};
|
||||
return {
|
||||
calls,
|
||||
advance: milliseconds => { currentTime += milliseconds; },
|
||||
service: createP15A10RuntimeStageService({ productionRuntime, diagnosticRuntime, now: () => currentTime }),
|
||||
};
|
||||
}
|
||||
|
||||
test('A10 runtime stage is actor-bound, single-use and never starts the PS5 payload implicitly', async () => {
|
||||
const { calls, service } = stageFixture();
|
||||
const plan = await service.plan('session-a');
|
||||
assert.equal(plan.liveActionAuthorized, false);
|
||||
assert.equal(plan.payloadStartAuthorized, false);
|
||||
await assert.rejects(() => service.stage({ planDigest: plan.planDigest, confirmation: CONFIRMATIONS.stage }, 'session-b'), error => error.code === 'P15_A10_RUNTIME_ACTOR_MISMATCH');
|
||||
const staged = await service.stage({ planDigest: plan.planDigest, confirmation: CONFIRMATIONS.stage }, 'session-a');
|
||||
assert.equal(staged.runtimeState, 'diagnostic_engine_active');
|
||||
assert.deepEqual(calls, ['production-stop', 'diagnostic-start']);
|
||||
await assert.rejects(() => service.stage({ planDigest: plan.planDigest, confirmation: CONFIRMATIONS.stage }, 'session-a'), error => error.code === 'P15_A10_RUNTIME_PLAN_REQUIRED');
|
||||
assert.equal(calls.includes('payload-start'), false);
|
||||
const status = await service.status();
|
||||
assert.equal(status.transitionEvidence.valid, false);
|
||||
assert.equal(status.transitionEvidence.production.identity.status, 'exact');
|
||||
assert.equal(status.transitionEvidence.diagnostic, null);
|
||||
});
|
||||
|
||||
test('A10 runtime stage rejects an unproven production identity before the controlled handoff', async () => {
|
||||
const { calls, service } = stageFixture({ productionIdentityExact: false });
|
||||
await assert.rejects(() => service.plan('session-a'), error => error.code === 'P15_A10_PRODUCTION_RUNTIME_IDENTITY_UNPROVEN');
|
||||
assert.deepEqual(calls, []);
|
||||
});
|
||||
|
||||
test('A10 diagnostic start failure restores the production engine before surfacing the failure', async () => {
|
||||
const { calls, service } = stageFixture({ diagnosticStartError: new Error('bind failed') });
|
||||
const plan = await service.plan('session-a');
|
||||
await assert.rejects(() => service.stage({ planDigest: plan.planDigest, confirmation: CONFIRMATIONS.stage }, 'session-a'), error => error.code === 'P15_A10_RUNTIME_STAGE_FAILED' && error.details.productionRestored === true);
|
||||
assert.deepEqual(calls, ['production-stop', 'diagnostic-start', 'diagnostic-stop', 'production-start']);
|
||||
});
|
||||
|
||||
test('A10 unproven diagnostic start is stopped before production recovery', async () => {
|
||||
const { calls, service } = stageFixture({ diagnosticStartProven: false });
|
||||
const plan = await service.plan('session-a');
|
||||
await assert.rejects(
|
||||
() => service.stage({ planDigest: plan.planDigest, confirmation: CONFIRMATIONS.stage }, 'session-a'),
|
||||
error => error.code === 'P15_A10_RUNTIME_STAGE_FAILED'
|
||||
&& error.details?.cause === 'P15_A10_DIAGNOSTIC_RUNTIME_START_UNPROVEN'
|
||||
&& error.details?.productionRestored === true,
|
||||
);
|
||||
assert.deepEqual(calls, ['production-stop', 'diagnostic-start', 'diagnostic-stop', 'production-start']);
|
||||
});
|
||||
|
||||
test('A10 failed diagnostic start never restores production without complete diagnostic stop proof', async () => {
|
||||
const { calls, service } = stageFixture({
|
||||
diagnosticStartProven: false,
|
||||
diagnosticStopResult: { stopped: true, endpointReleased: false, identityGone: true },
|
||||
});
|
||||
const plan = await service.plan('session-a');
|
||||
await assert.rejects(
|
||||
() => service.stage({ planDigest: plan.planDigest, confirmation: CONFIRMATIONS.stage }, 'session-a'),
|
||||
error => error.code === 'P15_A10_RUNTIME_STAGE_FAILED'
|
||||
&& error.details?.diagnosticStopped === false
|
||||
&& error.details?.productionRestored === false,
|
||||
);
|
||||
assert.deepEqual(calls, ['production-stop', 'diagnostic-start', 'diagnostic-stop']);
|
||||
});
|
||||
|
||||
test('A10 staging never starts a replacement on incomplete production stop proof', async () => {
|
||||
const { calls, service } = stageFixture({
|
||||
productionStopResult: { stopped: true, endpointReleased: true, identityGone: false },
|
||||
});
|
||||
const plan = await service.plan('session-a');
|
||||
await assert.rejects(
|
||||
() => service.stage({ planDigest: plan.planDigest, confirmation: CONFIRMATIONS.stage }, 'session-a'),
|
||||
error => error.code === 'P15_A10_RUNTIME_STAGE_FAILED'
|
||||
&& error.details?.cause === 'P15_A10_PRODUCTION_RUNTIME_STOP_FAILED',
|
||||
);
|
||||
assert.deepEqual(calls, ['production-stop']);
|
||||
});
|
||||
|
||||
test('A10 runtime planning and staging reject overlapping mutations', async () => {
|
||||
let releaseDiagnostic;
|
||||
const diagnosticStartGate = new Promise(resolve => { releaseDiagnostic = resolve; });
|
||||
const { service } = stageFixture({ diagnosticStartGate });
|
||||
const firstPlan = service.plan('session-a');
|
||||
await assert.rejects(() => service.plan('session-a'), error => error.code === 'P15_A10_RUNTIME_BUSY');
|
||||
const plan = await firstPlan;
|
||||
const firstStage = service.stage({ planDigest: plan.planDigest, confirmation: CONFIRMATIONS.stage }, 'session-a');
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
await assert.rejects(() => service.stage({ planDigest: plan.planDigest, confirmation: CONFIRMATIONS.stage }, 'session-a'), error => error.code === 'P15_A10_RUNTIME_BUSY');
|
||||
releaseDiagnostic();
|
||||
await firstStage;
|
||||
});
|
||||
|
||||
test('A10 payload and production restore stay behind separate service operations', async () => {
|
||||
const { calls, service } = stageFixture();
|
||||
const plan = await service.plan('session-a');
|
||||
await service.stage({ planDigest: plan.planDigest, confirmation: CONFIRMATIONS.stage }, 'session-a');
|
||||
const loaded = await service.startPayload({ confirmation: CONFIRMATIONS.payload });
|
||||
assert.equal(loaded.identity.status, 'exact');
|
||||
const diagnosticStatus = await service.status();
|
||||
assert.equal(diagnosticStatus.transitionEvidence.valid, true);
|
||||
assert.equal(diagnosticStatus.transitionEvidence.production.identity.lane, 'production');
|
||||
assert.equal(diagnosticStatus.transitionEvidence.diagnostic.identity.lane, 'diagnostic');
|
||||
assert.equal(diagnosticStatus.transitionEvidence.profileIdentity, P15_A10_RUNTIME_PROFILE.identity);
|
||||
const restored = await service.restore({ confirmation: CONFIRMATIONS.restore });
|
||||
assert.equal(restored.runtimeState, 'production_engine_active');
|
||||
assert.equal((await service.status()).transitionEvidence, null);
|
||||
assert.deepEqual(calls, ['production-stop', 'diagnostic-start', 'payload-start', 'diagnostic-stop', 'production-start']);
|
||||
});
|
||||
|
||||
test('A10 restore never starts production on incomplete diagnostic stop proof', async () => {
|
||||
const { calls, service } = stageFixture({
|
||||
diagnosticStopResult: { stopped: true, endpointReleased: false, identityGone: true },
|
||||
});
|
||||
const plan = await service.plan('session-a');
|
||||
await service.stage({ planDigest: plan.planDigest, confirmation: CONFIRMATIONS.stage }, 'session-a');
|
||||
await assert.rejects(
|
||||
() => service.restore({ confirmation: CONFIRMATIONS.restore }),
|
||||
error => error.code === 'P15_A10_DIAGNOSTIC_RUNTIME_STOP_FAILED',
|
||||
);
|
||||
assert.deepEqual(calls, ['production-stop', 'diagnostic-start', 'diagnostic-stop']);
|
||||
});
|
||||
|
||||
test('A10 payload start fails closed before console contact when transition evidence expired', async () => {
|
||||
const { advance, calls, service } = stageFixture();
|
||||
const plan = await service.plan('session-a');
|
||||
await service.stage({ planDigest: plan.planDigest, confirmation: CONFIRMATIONS.stage }, 'session-a');
|
||||
advance(120_000);
|
||||
await assert.rejects(() => service.startPayload({ confirmation: CONFIRMATIONS.payload }), error => error.code === 'P15_A10_TRANSITION_EVIDENCE_EXPIRED');
|
||||
assert.equal(calls.includes('payload-start'), false);
|
||||
assert.equal((await service.status()).transitionEvidence, null);
|
||||
});
|
||||
|
||||
test('server and Docker expose A10 only through separate guarded product lanes', () => {
|
||||
const registry = fs.readFileSync('src/server/create-registry.js', 'utf8');
|
||||
const server = fs.readFileSync('server.js', 'utf8');
|
||||
const docker = fs.readFileSync('Dockerfile', 'utf8');
|
||||
const preload = fs.readFileSync('preload.js', 'utf8');
|
||||
const web = fs.readFileSync('renderer/pork-web-shim.js', 'utf8');
|
||||
const modal = fs.readFileSync('renderer/modules/modal.js', 'utf8');
|
||||
const main = fs.readFileSync('main.js', 'utf8');
|
||||
const english = JSON.parse(fs.readFileSync('build/lang/en.json', 'utf8')).strings;
|
||||
const dutch = JSON.parse(fs.readFileSync('build/lang/nl.json', 'utf8')).strings;
|
||||
assert.match(registry, /ps5-native-packages:a10-transition-control-status[\s\S]*classification: 'read-only'/);
|
||||
assert.match(registry, /ps5-native-packages:a10-transition-control-install[\s\S]*classification: 'destructive'/);
|
||||
assert.match(registry, /ps5-p15-a10:runtime-stage[\s\S]*classification: 'destructive'/);
|
||||
assert.match(registry, /P15_SHELLUI_TRANSITION_DIAGNOSTIC/);
|
||||
assert.match(registry, /ps5P15A10RuntimeStage\.status\(\)/);
|
||||
assert.match(registry, /transitionEvidence\?\.valid === true/);
|
||||
assert.match(registry, /transitionEvidence\.profileIdentity === P15_A10_RUNTIME_PROFILE\.identity/);
|
||||
assert.match(server, /ps5P15A10Runtime\?\.stopEngine/);
|
||||
assert.match(server, /ps5ProfileRuntime\?\.stopEngine/);
|
||||
assert.match(preload, /ps5A10TransitionControl/);
|
||||
assert.match(preload, /ps5P15A10/);
|
||||
assert.match(web, /ps5A10TransitionControl/);
|
||||
assert.match(web, /ps5P15A10/);
|
||||
assert.match(main, /INSTALL PS5 A10 CONTROL CHIM00003/);
|
||||
assert.match(modal, /ps5A10TransitionControl\('install-plan'\)/);
|
||||
assert.match(modal, /ps5P15A10\('runtime-stage'/);
|
||||
assert.match(modal, /ps5P15A10\('payload-start'/);
|
||||
assert.match(modal, /ps5P15A10\('runtime-restore'/);
|
||||
assert.match(modal, /const runtimeEligible = \['installed_observed', 'launch_failed', 'launch_verified'\]\.includes\(op\)/);
|
||||
assert.match(modal, /ui\('stage'\)\.disabled = !runtimeEligible \|\| active/);
|
||||
assert.match(modal, /const launchEligible = op === 'installed_observed'/);
|
||||
assert.match(modal, /ui\('launch'\)\.disabled = !launchEligible \|\| !exact/);
|
||||
for (const key of ['title', 'detail', 'stage', 'payload', 'launch', 'restore', 'payloadBoundary', 'restoreBoundary']) {
|
||||
assert.equal(typeof english[`ps5NativePackages.a10Control.${key}`], 'string');
|
||||
assert.equal(typeof dutch[`ps5NativePackages.a10Control.${key}`], 'string');
|
||||
}
|
||||
assert.match(docker, new RegExp(A10_TRANSITION_CONTROL.selectedArtifact.sha256));
|
||||
assert.match(docker, /ps5-diagnostics[\\/]a10[\\/]ps5upload-engine/);
|
||||
assert.match(docker, /ps5-diagnostics[\\/]a10[\\/]ps5upload\.elf/);
|
||||
});
|
||||
@@ -0,0 +1,193 @@
|
||||
'use strict';
|
||||
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const test = require('node:test');
|
||||
const {
|
||||
BASE_REVISION,
|
||||
IDENTITY,
|
||||
SAMPLE_OFFSETS_MS,
|
||||
SCHEMA,
|
||||
createPs5ShellUiTransitionHostPlan,
|
||||
normalizeShellUiTransitionObservation,
|
||||
} = require('../../src/services/ps5-shellui-transition-observation');
|
||||
const {
|
||||
P15_SHELLUI_TRANSITION_DIAGNOSTIC,
|
||||
createPs5UploadPackageInstallerProvider,
|
||||
} = require('../../src/services/ps5upload-package-installer-provider');
|
||||
|
||||
const OPERATION_ID = '123e4567-e89b-42d3-a456-426614174000';
|
||||
const TITLE_ID = 'CHIM00003';
|
||||
|
||||
function attempt() {
|
||||
return {
|
||||
index: 1,
|
||||
path: 'shellui_rpc',
|
||||
call_entered: true,
|
||||
dispatched: true,
|
||||
sony_return_observed: true,
|
||||
raw_rax_u64_hex: '0x0000000000000000',
|
||||
raw_low32_hex: '0x00000000',
|
||||
raw_signed32: 0,
|
||||
timed_out: false,
|
||||
tracee_lost: false,
|
||||
wrapper_rc: 0,
|
||||
wrapper_hex: '0x00000000',
|
||||
branch_taken: 'accepted',
|
||||
};
|
||||
}
|
||||
|
||||
function observation({ preAppId = 0, appIds = [0, 0, 0, 0, 0, 0, 0], shellUiPids = [58, 58, 58, 58, 58, 58, 58], complete = true } = {}) {
|
||||
return {
|
||||
schema: SCHEMA,
|
||||
diagnostic_identity: IDENTITY,
|
||||
base_revision: BASE_REVISION,
|
||||
operation_id: OPERATION_ID,
|
||||
title_id: TITLE_ID,
|
||||
shellui: { pid_before: 58, pid_after: 58, same_process: true, init_rc: 0, ready: true },
|
||||
foreground_user_present: true,
|
||||
attempts: [attempt()],
|
||||
internal_retry_occurred: false,
|
||||
retry_delay_ms: 0,
|
||||
terminal_attempt_index: 1,
|
||||
terminal_wrapper_rc: 0,
|
||||
terminal_wrapper_hex: '0x00000000',
|
||||
provider_normalized_result: 'accepted',
|
||||
provider_accepted: true,
|
||||
application_transition: {
|
||||
pre_running_big_app_id: preAppId,
|
||||
samples: SAMPLE_OFFSETS_MS.map((offset, index) => ({
|
||||
scheduled_offset_ms: offset,
|
||||
observed_after_ms: offset,
|
||||
running_big_app_id: appIds[index],
|
||||
shellui_pid: shellUiPids[index],
|
||||
process_inventory_complete: complete,
|
||||
target_pid: null,
|
||||
})),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('accepted LNC without big-app allocation is normalized as the first downstream boundary', () => {
|
||||
const result = normalizeShellUiTransitionObservation(observation(), { operationId: OPERATION_ID, titleId: TITLE_ID });
|
||||
assert.equal(result.providerAccepted, true);
|
||||
assert.equal(result.applicationTransition.classification, 'accepted_lnc_without_big_app_transition');
|
||||
assert.equal(result.applicationTransition.bigAppTransitionObserved, false);
|
||||
assert.equal(result.applicationTransition.exactTargetProcessObserved, false);
|
||||
assert.equal(result.applicationTransition.outcomeQualified, true);
|
||||
assert.equal(result.applicationTransition.timingQualified, true);
|
||||
assert.deepEqual(result.applicationTransition.sampleOffsetsMs, SAMPLE_OFFSETS_MS);
|
||||
});
|
||||
|
||||
test('Sony no-big-app sentinel -1 is idle while other negative values stay fail-closed', () => {
|
||||
const idle = normalizeShellUiTransitionObservation(observation({ preAppId: -1, appIds: [-1, -1, -1, -1, -1, -1, -1] }), { operationId: OPERATION_ID, titleId: TITLE_ID });
|
||||
assert.equal(idle.applicationTransition.cleanBaseline, true);
|
||||
assert.equal(idle.applicationTransition.bigAppSampleDomainQualified, true);
|
||||
assert.equal(idle.applicationTransition.classification, 'accepted_lnc_without_big_app_transition');
|
||||
assert.equal(idle.applicationTransition.outcomeQualified, true);
|
||||
assert.equal(idle.applicationTransition.baselineIdleSentinel, -1);
|
||||
|
||||
const ambiguous = normalizeShellUiTransitionObservation(observation({ preAppId: -2, appIds: [-2, -2, -2, -2, -2, -2, -2] }), { operationId: OPERATION_ID, titleId: TITLE_ID });
|
||||
assert.equal(ambiguous.applicationTransition.cleanBaseline, false);
|
||||
assert.equal(ambiguous.applicationTransition.bigAppSampleDomainQualified, false);
|
||||
assert.equal(ambiguous.applicationTransition.classification, 'outcome_unknown');
|
||||
});
|
||||
|
||||
test('new positive running-big-app id is retained but never promoted to exact executable proof', () => {
|
||||
const result = normalizeShellUiTransitionObservation(observation({ appIds: [0, 0, 0, 41, 41, 41, 41] }), { operationId: OPERATION_ID, titleId: TITLE_ID });
|
||||
assert.equal(result.applicationTransition.classification, 'big_app_transition_observed_unattributed');
|
||||
assert.equal(result.applicationTransition.bigAppTransitionObserved, true);
|
||||
assert.equal(result.applicationTransition.firstPositiveOffsetMs, 250);
|
||||
assert.equal(result.applicationTransition.exactTargetProcessObserved, false);
|
||||
assert.equal(result.applicationTransition.launchVerified, false);
|
||||
assert.equal(result.applicationTransition.productionRestorable, false);
|
||||
});
|
||||
|
||||
test('pre-existing big app, incomplete inventory, ShellUI drift and hostile sample shapes fail closed', () => {
|
||||
for (const input of [
|
||||
observation({ preAppId: 7, appIds: [7, 7, 7, 7, 7, 7, 7] }),
|
||||
observation({ complete: false }),
|
||||
observation({ shellUiPids: [58, 58, 58, 59, 59, 59, 59] }),
|
||||
]) {
|
||||
const result = normalizeShellUiTransitionObservation(input, { operationId: OPERATION_ID, titleId: TITLE_ID });
|
||||
assert.equal(result.applicationTransition.classification, 'outcome_unknown');
|
||||
assert.equal(result.applicationTransition.outcomeQualified, false);
|
||||
}
|
||||
const missing = observation(); missing.application_transition.samples.pop();
|
||||
assert.throws(() => normalizeShellUiTransitionObservation(missing, { operationId: OPERATION_ID, titleId: TITLE_ID }), error => error.code === 'PS5_FPKG_TRANSITION_OBSERVATION_INVALID');
|
||||
const extra = observation(); extra.application_transition.samples[0].raw_pointer = '0xdeadbeef';
|
||||
assert.throws(() => normalizeShellUiTransitionObservation(extra, { operationId: OPERATION_ID, titleId: TITLE_ID }), error => error.code === 'PS5_FPKG_TRANSITION_OBSERVATION_INVALID');
|
||||
const slow = observation(); slow.application_transition.samples.at(-1).observed_after_ms = 5001;
|
||||
const slowResult = normalizeShellUiTransitionObservation(slow, { operationId: OPERATION_ID, titleId: TITLE_ID });
|
||||
assert.equal(slowResult.applicationTransition.classification, 'outcome_unknown');
|
||||
assert.equal(slowResult.applicationTransition.timingQualified, false);
|
||||
});
|
||||
|
||||
test('host plan is digest-bound, observation-only and forbids every consumed title', () => {
|
||||
const plan = createPs5ShellUiTransitionHostPlan({
|
||||
engine: { path: 'build/diagnostics/ps5upload-p15-a10/ps5upload-engine', size: 123, sha256: 'a'.repeat(64) },
|
||||
payload: { path: 'build/diagnostics/ps5upload-p15-a10/ps5upload.elf', size: 456, sha256: 'b'.repeat(64) },
|
||||
source: { revision: BASE_REVISION, sha256: 'c'.repeat(64), diffSha256: 'd'.repeat(64) },
|
||||
previousResult: { titleId: 'CHIM00002', installedObserved: true, launchRequests: 1, clientRetries: 0, terminalCode: 'PS5_FPKG_SHELLUI_LAUNCH_FLOW_FAILED' },
|
||||
});
|
||||
assert.match(plan.planDigest, /^[a-f0-9]{64}$/);
|
||||
assert.equal(plan.liveActionAuthorized, false);
|
||||
assert.equal(plan.requiresFreshDistinctTitle, true);
|
||||
assert.deepEqual(plan.consumedTitleIds, ['FAKE00000', 'CHIM00001', 'CHIM00002']);
|
||||
assert.equal(plan.runtime.identity, IDENTITY);
|
||||
assert.deepEqual(plan.runtime.sampleOffsetsMs, SAMPLE_OFFSETS_MS);
|
||||
assert.equal(Object.isFrozen(plan), true);
|
||||
assert.throws(() => createPs5ShellUiTransitionHostPlan({
|
||||
engine: { path: 'x', size: 1, sha256: 'a'.repeat(64) }, payload: { path: 'y', size: 1, sha256: 'b'.repeat(64) },
|
||||
source: { revision: BASE_REVISION, sha256: 'c'.repeat(64), diffSha256: 'd'.repeat(64) },
|
||||
previousResult: { titleId: 'CHIM00002', installedObserved: false, launchRequests: 1, clientRetries: 0, terminalCode: 'PS5_FPKG_SHELLUI_LAUNCH_FLOW_FAILED' },
|
||||
}), error => error.code === 'PS5_FPKG_TRANSITION_PLAN_PRECONDITION');
|
||||
});
|
||||
|
||||
function response(body, status = 200) {
|
||||
const serialized = JSON.stringify(body);
|
||||
return { ok: status >= 200 && status < 300, status, headers: { get: name => name === 'content-length' ? String(Buffer.byteLength(serialized)) : null }, json: async () => body, text: async () => serialized };
|
||||
}
|
||||
|
||||
test('provider accepts only the exact A10 diagnostic identity and returns correlated transition evidence', async t => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'chimera-a10-provider-'));
|
||||
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
const pkg = path.join(root, 'control.pkg');
|
||||
const bytes = Buffer.concat([Buffer.from([0x7f, 0x46, 0x49, 0x48]), Buffer.alloc(64, 0x41)]);
|
||||
fs.writeFileSync(pkg, bytes);
|
||||
const component = { type: 'base', componentId: 'base', size: bytes.length, sha256: require('node:crypto').createHash('sha256').update(bytes).digest('hex'), contentId: `UP9000-${TITLE_ID}_00-A10CONTROL000003`, metadata: { absolutePath: pkg, titleId: { value: TITLE_ID } } };
|
||||
const provider = createPs5UploadPackageInstallerProvider({
|
||||
engineOrigin: 'http://127.0.0.1:19113', consoleHost: 'ps5.local', packageRoot: root,
|
||||
launchDiagnostic: P15_SHELLUI_TRANSITION_DIAGNOSTIC,
|
||||
getRuntimeIdentity: async () => ({
|
||||
schema: 'chimera.ps5upload-runtime-identity.v1',
|
||||
expected: { version: '5.4.19', revision: BASE_REVISION, engineSha256: 'e'.repeat(64), payloadSha256: 'f'.repeat(64), dpiSha256: null, diagnostic: { identity: IDENTITY, baseRevision: BASE_REVISION, sourceSha256: '1'.repeat(64), diffSha256: '2'.repeat(64) } },
|
||||
engine: { version: '5.4.19', revision: BASE_REVISION, observedSha256: 'e'.repeat(64), evidence: 'test', diagnosticIdentity: IDENTITY },
|
||||
payload: { version: '5.4.19', revision: BASE_REVISION, observedSha256: 'f'.repeat(64), evidence: 'test', diagnosticIdentity: IDENTITY },
|
||||
dpi: { active: false },
|
||||
}),
|
||||
fetch: async (url, options = {}) => {
|
||||
const value = String(url);
|
||||
if (value.endsWith('/api/version')) return response({ version: '5.4.19', diagnostic_identity: IDENTITY, base_revision: BASE_REVISION, observation_schema: SCHEMA });
|
||||
if (value.includes('/api/ps5/status')) return response({ version: '5.4.19', ucred_elevated: true });
|
||||
if (value.includes('/api/ps5/readiness')) return response({ ready: true });
|
||||
if (value.includes('/api/ps5/process/list')) return response({ processes: [{ pid: 7, name: 'kstuff.elf' }, { pid: 8, name: 'etaHEN' }], truncated: false });
|
||||
if (value.includes('/api/ps5/syslog/tail')) return response({ text: '' });
|
||||
if (value.includes('/api/ps5/app/launch')) {
|
||||
const body = JSON.parse(options.body);
|
||||
assert.equal(body.operation_id, OPERATION_ID);
|
||||
return response({ ok: true, launch_observation: observation() });
|
||||
}
|
||||
return response({});
|
||||
},
|
||||
});
|
||||
const preflight = await provider.preflightLaunch({ titleId: TITLE_ID, components: [component] });
|
||||
assert.equal(preflight.ready, true);
|
||||
assert.equal(preflight.runtime.diagnostic.identity, IDENTITY);
|
||||
const launched = await provider.launch({ titleId: TITLE_ID, operationId: OPERATION_ID });
|
||||
assert.equal(launched.accepted, true);
|
||||
assert.equal(launched.diagnostic.applicationTransition.classification, 'accepted_lnc_without_big_app_transition');
|
||||
assert.equal(launched.launchVerified, false);
|
||||
});
|
||||
@@ -0,0 +1,209 @@
|
||||
'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 { createPs5NativePackageInstallService } = require('../../src/services/ps5-native-package-install-service');
|
||||
|
||||
function fixture(t, overrides = {}) {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'chimera-p15-a11-evidence-'));
|
||||
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
const stateFile = path.join(root, 'operations.json');
|
||||
const diagnostic = {
|
||||
schema: 'ps5upload.shellui-launch-observation.v1',
|
||||
diagnosticIdentity: 'chimera-p15-shellui-transition-observe-v1',
|
||||
shellUi: { pidBefore: 110, pidAfter: 110, sameProcess: true, initRc: 0, ready: true },
|
||||
attempts: [{ index: 1, callEntered: true, sonyReturnObserved: true, rawSigned32: 0, wrapperRc: 0 }],
|
||||
terminalAttemptIndex: 1,
|
||||
terminalWrapperRc: 0,
|
||||
providerAccepted: true,
|
||||
providerNormalizedResult: 'accepted',
|
||||
applicationTransition: {
|
||||
schema: 'chimera.ps5-shellui-application-transition.v1', preRunningBigAppId: -1,
|
||||
samples: [0, 50, 100, 250, 500, 1000, 2000].map(offset => ({ scheduledOffsetMs: offset, observedAfterMs: offset, runningBigAppId: -1, shellUiPid: 110, processInventoryComplete: true, targetPid: null })),
|
||||
sampleOffsetsMs: [0, 50, 100, 250, 500, 1000, 2000], cleanBaseline: false, shellUiStable: true,
|
||||
inventoryComplete: true, timingQualified: true, bigAppTransitionObserved: false, firstPositiveOffsetMs: null,
|
||||
exactTargetProcessObserved: false, outcomeQualified: false, classification: 'outcome_unknown',
|
||||
launchVerified: false, productionRestorable: false,
|
||||
},
|
||||
};
|
||||
const operation = {
|
||||
id: 'a10-operation-0001', titleId: 'CHIM00003', packageSetId: 10,
|
||||
status: 'launch_failed', launchOutcome: 'failed', launchVerified: false,
|
||||
productionRestorable: false, installed: true, installedContentVerified: false,
|
||||
packageIdentitySha256: '5'.repeat(64), createdAt: 100, updatedAt: 200,
|
||||
launchStartedAt: 120, launchFailedAt: 180,
|
||||
components: [{ type: 'base', componentId: 'base', sha256: 'a'.repeat(64), size: 68, source: { metadata: { absolutePath: '/private/staging/control.pkg' } } }],
|
||||
error: { code: 'PS5_FPKG_SHELLUI_POST_LNC_FAILED', message: 'Failed before stable execution.' },
|
||||
launchEvidence: {
|
||||
schema: 'chimera.ps5-fpkg-launch-evidence.v2', titleId: 'CHIM00003',
|
||||
packageIdentitySha256: '5'.repeat(64), runtimeFingerprint: 'f'.repeat(64),
|
||||
runtime: { ready: true, capabilities: { foregroundClean: true } },
|
||||
logWatermark: { sha256: '0'.repeat(64), bytes: 0 },
|
||||
registration: { required: false, registered: true },
|
||||
request: { accepted: true, diagnostic }, requestedAt: 130, preflightCompletedAt: 125,
|
||||
firstObservedAt: null, observationCount: 0, process: null,
|
||||
rawEvidence: [{
|
||||
observedAt: 150,
|
||||
summary: {
|
||||
observed: false, process: null, failureBoundary: 'ShellUI launch flow',
|
||||
failureCode: 'PS5_FPKG_SHELLUI_LAUNCH_FLOW_FAILED', normalizedEvents: [],
|
||||
processListTruncated: false,
|
||||
syslog: { continuity: 'exact-watermark', currentSha256: 'b'.repeat(64), currentWindowBytes: 29, currentWindowSha256: 'c'.repeat(64) },
|
||||
},
|
||||
evidence: { truncated: true, sha256: 'd'.repeat(64), preview: '{"currentWindow":"LaunchFlowError.CHIM00003"}' },
|
||||
}],
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
fs.writeFileSync(stateFile, JSON.stringify([operation], null, 2));
|
||||
let providerCalls = 0;
|
||||
const provider = {
|
||||
preflightInstall: async () => { providerCalls += 1; return {}; },
|
||||
install: async () => { providerCalls += 1; return {}; },
|
||||
installStatus: async () => { providerCalls += 1; return {}; },
|
||||
preflightLaunch: async () => { providerCalls += 1; return {}; },
|
||||
launch: async () => { providerCalls += 1; return {}; },
|
||||
observeLaunch: async () => { providerCalls += 1; return {}; },
|
||||
};
|
||||
const service = createPs5NativePackageInstallService({ provider, stateFile });
|
||||
return { root, stateFile, operation, service, providerCalls: () => providerCalls };
|
||||
}
|
||||
|
||||
test('retained A10 evidence export is lossless for the bounded durable record and classifies the post-LNC boundary', t => {
|
||||
const f = fixture(t);
|
||||
const before = fs.readFileSync(f.stateFile);
|
||||
const beforeStat = fs.statSync(f.stateFile);
|
||||
const result = f.service.retainedLaunchEvidence('a10-operation-0001');
|
||||
|
||||
assert.equal(result.schema, 'chimera.ps5-fpkg-retained-launch-export.v1');
|
||||
assert.equal(result.operation.titleId, 'CHIM00003');
|
||||
assert.equal(result.operation.launchVerified, false);
|
||||
assert.equal(result.operation.productionRestorable, false);
|
||||
assert.equal(result.analysis.requestAccepted, true);
|
||||
assert.equal(result.analysis.terminalSonyResultAccepted, true);
|
||||
assert.equal(result.analysis.exactTitleProcessObservationCount, 0);
|
||||
assert.equal(result.analysis.stableExactTitleProcess, false);
|
||||
assert.equal(result.analysis.firstUnprovenBoundary, 'after-accepted-shellui-lnc-before-big-app-allocation');
|
||||
assert.equal(result.analysis.applicationTransition.retainedClassification, 'outcome_unknown');
|
||||
assert.equal(result.analysis.applicationTransition.retrospectiveClassification, 'accepted_lnc_without_big_app_transition');
|
||||
assert.equal(result.analysis.applicationTransition.classificationChanged, true);
|
||||
assert.equal(result.analysis.applicationTransition.baselineIdleSentinel, -1);
|
||||
assert.equal(result.analysis.applicationTransition.outcomeQualified, true);
|
||||
assert.equal(result.analysis.applicationTransition.launchVerified, false);
|
||||
assert.equal(result.analysis.applicationTransition.productionRestorable, false);
|
||||
assert.equal(result.analysis.applicationTransition.sourceRecordMutated, false);
|
||||
assert.equal(result.retention.rawSourceComplete, false);
|
||||
assert.equal(result.retention.losslessForRetainedRecord, true);
|
||||
assert.equal(result.retention.observationsRetained, 1);
|
||||
assert.deepEqual(result.launchEvidence, f.operation.launchEvidence);
|
||||
assert.equal(result.launchEvidence.request.diagnostic.attempts[0].rawSigned32, 0);
|
||||
assert.equal(result.launchEvidence.request.diagnostic.attempts[0].wrapperRc, 0);
|
||||
assert.equal(result.launchEvidence.rawEvidence[0].evidence.preview, '{"currentWindow":"LaunchFlowError.CHIM00003"}');
|
||||
assert.equal(result.integrity.algorithm, 'sha256');
|
||||
assert.equal(result.integrity.retainedLaunchEvidenceSha256, crypto.createHash('sha256').update(JSON.stringify(f.operation.launchEvidence)).digest('hex'));
|
||||
assert.equal(result.integrity.retainedBytes, Buffer.byteLength(JSON.stringify(f.operation.launchEvidence)));
|
||||
assert.equal(JSON.stringify(result).includes('/private/staging/control.pkg'), false);
|
||||
assert.equal(f.providerCalls(), 0);
|
||||
assert.deepEqual(fs.readFileSync(f.stateFile), before);
|
||||
assert.equal(fs.statSync(f.stateFile).mtimeMs, beforeStat.mtimeMs);
|
||||
});
|
||||
|
||||
test('retrospective transition analysis is absent for legacy evidence and explicit for malformed retained evidence', t => {
|
||||
const legacy = fixture(t, { launchEvidence: {
|
||||
schema: 'chimera.ps5-fpkg-launch-evidence.v2',
|
||||
request: { accepted: true, diagnostic: { providerAccepted: true } },
|
||||
rawEvidence: [],
|
||||
} });
|
||||
assert.equal(legacy.service.retainedLaunchEvidence('a10-operation-0001').analysis.applicationTransition, null);
|
||||
|
||||
const malformed = fixture(t, { launchEvidence: {
|
||||
schema: 'chimera.ps5-fpkg-launch-evidence.v2',
|
||||
request: { accepted: true, diagnostic: {
|
||||
providerAccepted: true,
|
||||
applicationTransition: { schema: 'unknown', samples: [] },
|
||||
} },
|
||||
rawEvidence: [],
|
||||
} });
|
||||
const result = malformed.service.retainedLaunchEvidence('a10-operation-0001').analysis.applicationTransition;
|
||||
assert.equal(result.retrospectiveClassification, 'invalid_retained_transition');
|
||||
assert.equal(result.classificationChanged, false);
|
||||
assert.equal(result.launchVerified, false);
|
||||
assert.equal(result.productionRestorable, false);
|
||||
assert.equal(result.sourceRecordMutated, false);
|
||||
assert.equal(result.errorCode, 'PS5_FPKG_TRANSITION_OBSERVATION_INVALID');
|
||||
|
||||
const nonMonotone = fixture(t);
|
||||
const persisted = JSON.parse(fs.readFileSync(nonMonotone.stateFile, 'utf8'));
|
||||
persisted[0].launchEvidence.request.diagnostic.applicationTransition.samples[0].observedAfterMs = 1000;
|
||||
fs.writeFileSync(nonMonotone.stateFile, JSON.stringify(persisted, null, 2));
|
||||
const restarted = createPs5NativePackageInstallService({
|
||||
stateFile: nonMonotone.stateFile,
|
||||
provider: { preflightInstall: async () => ({}), install: async () => ({}), installStatus: async () => ({}) },
|
||||
});
|
||||
assert.equal(restarted.retainedLaunchEvidence('a10-operation-0001').analysis.applicationTransition.retrospectiveClassification, 'invalid_retained_transition');
|
||||
});
|
||||
|
||||
test('retained evidence export is a deep clone and survives service restart without replay', t => {
|
||||
const f = fixture(t);
|
||||
const first = f.service.retainedLaunchEvidence('a10-operation-0001');
|
||||
first.launchEvidence.request.diagnostic.attempts[0].wrapperRc = 99;
|
||||
first.analysis.firstUnprovenBoundary = 'tampered';
|
||||
assert.equal(f.service.get('a10-operation-0001').launchEvidence.request.diagnostic.attempts[0].wrapperRc, 0);
|
||||
|
||||
const restarted = createPs5NativePackageInstallService({
|
||||
stateFile: f.stateFile,
|
||||
provider: { preflightInstall: async () => ({}), install: async () => ({}), installStatus: async () => ({}) },
|
||||
});
|
||||
const second = restarted.retainedLaunchEvidence('a10-operation-0001');
|
||||
assert.equal(second.launchEvidence.request.diagnostic.attempts[0].wrapperRc, 0);
|
||||
assert.equal(second.integrity.retainedLaunchEvidenceSha256, first.integrity.retainedLaunchEvidenceSha256);
|
||||
assert.equal(f.providerCalls(), 0);
|
||||
});
|
||||
|
||||
test('retained evidence export redacts secret-bearing legacy fields and discloses the loss', t => {
|
||||
const f = fixture(t, { launchEvidence: {
|
||||
schema: 'chimera.ps5-fpkg-launch-evidence.v2',
|
||||
titleId: 'CHIM00003',
|
||||
request: { accepted: false, authorization: 'Bearer super-secret', diagnostic: { token: 'query-secret' } },
|
||||
rawEvidence: [{ observedAt: 150, summary: { observed: false }, evidence: { log: 'Authorization: Bearer header-secret https://user:pass@example.invalid/path?token=url-secret' } }],
|
||||
} });
|
||||
const result = f.service.retainedLaunchEvidence('a10-operation-0001');
|
||||
const serialized = JSON.stringify(result);
|
||||
assert.doesNotMatch(serialized, /super-secret|query-secret|header-secret|user:pass|url-secret/);
|
||||
assert.equal(result.retention.losslessForRetainedRecord, false);
|
||||
assert.deepEqual(result.retention.redactedPaths.sort(), ['launchEvidence.rawEvidence.0.evidence.log', 'launchEvidence.request.authorization', 'launchEvidence.request.diagnostic.token']);
|
||||
assert.match(result.integrity.retainedLaunchEvidenceSha256, /^[a-f0-9]{64}$/);
|
||||
});
|
||||
|
||||
test('retained evidence export fails typed for unavailable and oversized durable evidence', t => {
|
||||
const noEvidence = fixture(t, { status: 'installed_observed', launchEvidence: null });
|
||||
assert.throws(() => noEvidence.service.retainedLaunchEvidence('missing'), error => error.code === 'PS5_PKG_INSTALL_NOT_FOUND');
|
||||
assert.throws(() => noEvidence.service.retainedLaunchEvidence('a10-operation-0001'), error => error.code === 'PS5_FPKG_LAUNCH_EVIDENCE_UNAVAILABLE');
|
||||
|
||||
const oversized = fixture(t, { launchEvidence: { schema: 'legacy', rawEvidence: [{ evidence: { preview: 'x'.repeat(600 * 1024) } }] } });
|
||||
assert.throws(() => oversized.service.retainedLaunchEvidence('a10-operation-0001'), error => error.code === 'PS5_FPKG_LAUNCH_EVIDENCE_OVERSIZE');
|
||||
});
|
||||
|
||||
test('A11 evidence bridge is read-only, exact-operation scoped and never polls launch status', () => {
|
||||
const registry = fs.readFileSync(path.resolve(__dirname, '../../src/server/create-registry.js'), 'utf8');
|
||||
const preload = fs.readFileSync(path.resolve(__dirname, '../../preload.js'), 'utf8');
|
||||
const main = fs.readFileSync(path.resolve(__dirname, '../../main.js'), 'utf8');
|
||||
const web = fs.readFileSync(path.resolve(__dirname, '../../renderer/pork-web-shim.js'), 'utf8');
|
||||
const evidenceBridge = fs.readFileSync(path.resolve(__dirname, '../../renderer/pork-evidence-bridge.js'), 'utf8');
|
||||
const modal = fs.readFileSync(path.resolve(__dirname, '../../renderer/modules/modal.js'), 'utf8');
|
||||
assert.match(registry, /name: 'ps5-native-packages:retained-launch-evidence', classification: 'read-only'/);
|
||||
assert.match(registry, /retainedLaunchEvidence\(input\.operationId\)/);
|
||||
for (const source of [preload, main, `${web}\n${evidenceBridge}`]) assert.match(source, /ps5-native-packages(?::|%3A)retained-launch-evidence/);
|
||||
assert.match(preload, /URL\.revokeObjectURL\(url\)/);
|
||||
assert.match(modal, /ps5NativePackageRetainedLaunchEvidence\(ps5A10ControlOperationId\)/);
|
||||
assert.match(modal, /window\.pork\.download \|\| window\.xavatarAPI\?\.download/);
|
||||
assert.match(modal, /op === 'interrupted' \? 'launchInterrupted'/);
|
||||
assert.match(modal, /s\.dataset\.operationId !== id/);
|
||||
assert.match(modal, /chimera:language-changed/);
|
||||
assert.doesNotMatch(modal, /status\.textContent = ps5A10Text\('evidenceDownloaded'\)/);
|
||||
assert.doesNotMatch(modal, /retainedLaunchEvidence[\s\S]{0,200}RestoreLaunchStatus/);
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
'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 { parseKstuffRouteSelectionSnapshot } = require('../../src/services/ps5-fpkg-route-selection-trace');
|
||||
|
||||
const sha = value => crypto.createHash('sha256').update(value).digest('hex');
|
||||
const lines = sample => [
|
||||
'schema=chimera.p15-a7-route-selection-metrics.v1', 'status=attached', `sample=${sample}`,
|
||||
'route nmount_dispatch=0 nmount_trap=0 nmount_emu=0',
|
||||
'dbgarm attempt=0 read_fail=0 pcb_ptr_fail=0 pcb_state_fail=0 stack_fail=0 pcb_set_fail=0 write_fail=0 armed=0 rollback_restore_fail=0 finish=0 finish_restore_fail=0',
|
||||
'mailbox total=0 fself=0 fpkg=0 npdrm=0 unhandled=0', 'fpkg verify=0 verify_emu=0', '',
|
||||
].join('\n');
|
||||
|
||||
test('A7 active preflight embeds the same strict semantic parser used by the product', async () => {
|
||||
const mod = await import('../../scripts/autonomous/p15-a7-active-preflight.mjs');
|
||||
const runtimeFingerprint = 'a'.repeat(64);
|
||||
const options = { sequence: 7, sampledAt: 1234, runtimeFingerprint };
|
||||
assert.deepEqual(
|
||||
mod.parseEmbeddedKstuffRouteSelectionSnapshot(lines(7), options),
|
||||
parseKstuffRouteSelectionSnapshot(lines(7), options),
|
||||
);
|
||||
const unsafe = `${lines(7)}unexpected=raw-kernel-data\n`;
|
||||
assert.throws(() => mod.parseEmbeddedKstuffRouteSelectionSnapshot(unsafe, options), {
|
||||
code: 'PS5_FPKG_ROUTE_SELECTION_TRACE_UNSAFE_LINE',
|
||||
});
|
||||
});
|
||||
|
||||
// PS5Upload's identity must remain independent of kstuff execution identity.
|
||||
for (const payloadIdentityCurrentBootExact of [false, true]) {
|
||||
test(`A7 file hashes and advancing counters cannot attest kstuff execution (management payload attested: ${payloadIdentityCurrentBootExact})`, async t => {
|
||||
const mod = await import('../../scripts/autonomous/p15-a7-active-preflight.mjs');
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'chimera-a7-active-'));
|
||||
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
const manifest = Buffer.from(`\n\npldmgr_v0.4.2.elf\n${mod.EXPECTED.loaderName}\n!30000\n${mod.EXPECTED.readerName}\n!5000\netaHEN-2.6B.bin\nftpsrv.elf\nnanodns.elf\n`);
|
||||
assert.equal(sha(manifest), mod.EXPECTED.manifestSha256);
|
||||
const loader = Buffer.alloc(mod.EXPECTED.loaderSize); const reader = Buffer.alloc(mod.EXPECTED.readerSize);
|
||||
const reads = new Map([
|
||||
[`${mod.REMOTE_DIR}/autoload.txt`, manifest], [`${mod.REMOTE_DIR}/${mod.EXPECTED.loaderName}`, loader],
|
||||
[`${mod.REMOTE_DIR}/${mod.EXPECTED.readerName}`, reader], [mod.METRICS_PATH, Buffer.from(lines(4))],
|
||||
]);
|
||||
// Matching file bytes do not prove which kstuff runtime executed this boot.
|
||||
const expected = { ...mod.EXPECTED, loaderSha256: sha(loader), readerSha256: sha(reader) };
|
||||
let metricsRead = 0;
|
||||
const result = await mod.qualifyActiveRuntime({
|
||||
ftp: {
|
||||
list: async () => ['autoload.txt', mod.EXPECTED.loaderName, mod.EXPECTED.readerName, mod.EXPECTED.currentLoaderName, mod.EXPECTED.currentReaderName].map(name => ({ name })),
|
||||
download: async (source, target) => { const bytes = source === mod.METRICS_PATH ? Buffer.from(lines(++metricsRead + 3)) : reads.get(source); fs.writeFileSync(target, bytes); },
|
||||
},
|
||||
parseSnapshot: parseKstuffRouteSelectionSnapshot,
|
||||
managementSnapshot: async () => ({ engineExact: true, managementReady: true, processCoverageComplete: true, foregroundClean: true, payloadIdentityCurrentBootExact }),
|
||||
wait: async () => {}, now: (() => { let value = 1000; return () => value += 11000; })(), temporaryRoot: root, expected,
|
||||
});
|
||||
assert.equal(result.status, 'blocked');
|
||||
assert.equal(result.activeRuntimeExact, false);
|
||||
assert.equal(result.activeTransitionProven, false);
|
||||
assert.equal(result.runtime.identityStatus, 'unproven');
|
||||
assert.equal(result.runtime.traceEligible, false);
|
||||
assert.equal(result.runtime.identitySource, 'expected-build-inputs');
|
||||
assert.equal(result.evidenceContract.status, 'unavailable');
|
||||
assert.equal(result.evidenceContract.reason, 'kstuff_current_boot_execution_receipt_unavailable');
|
||||
assert.equal(result.autoload.loaderSha256, sha(loader));
|
||||
assert.equal(result.autoload.readerSha256, sha(reader));
|
||||
assert.equal(result.autoload.manifestSha256, sha(manifest));
|
||||
assert.equal(result.premetrics.continuous, true);
|
||||
assert.equal(result.launchEligible, false);
|
||||
assert.equal(result.launchRequests, 0);
|
||||
assert.equal(result.payloadLoadPerformed, false);
|
||||
assert.equal(result.consoleMutationPerformed, false);
|
||||
});
|
||||
}
|
||||
|
||||
test('A7 active preflight fails closed on stale semantic samples', async t => {
|
||||
const mod = await import('../../scripts/autonomous/p15-a7-active-preflight.mjs');
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'chimera-a7-stale-'));
|
||||
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
const manifest = Buffer.from(`\n\npldmgr_v0.4.2.elf\n${mod.EXPECTED.loaderName}\n!30000\n${mod.EXPECTED.readerName}\n!5000\netaHEN-2.6B.bin\nftpsrv.elf\nnanodns.elf\n`);
|
||||
const loader = Buffer.alloc(mod.EXPECTED.loaderSize); const reader = Buffer.alloc(mod.EXPECTED.readerSize);
|
||||
const expected = { ...mod.EXPECTED, loaderSha256: sha(loader), readerSha256: sha(reader) };
|
||||
await assert.rejects(mod.qualifyActiveRuntime({
|
||||
ftp: {
|
||||
list: async () => ['autoload.txt', mod.EXPECTED.loaderName, mod.EXPECTED.readerName, mod.EXPECTED.currentLoaderName, mod.EXPECTED.currentReaderName].map(name => ({ name })),
|
||||
download: async (source, target) => fs.writeFileSync(target, source.endsWith('autoload.txt') ? manifest : (source === mod.METRICS_PATH ? Buffer.from(lines(8)) : source.endsWith(mod.EXPECTED.loaderName) ? loader : reader)),
|
||||
}, parseSnapshot: parseKstuffRouteSelectionSnapshot, managementSnapshot: async () => ({}), wait: async () => {}, temporaryRoot: root, expected,
|
||||
}), { code: 'P15_A7_ACTIVE_CONTINUITY' });
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
'use strict';
|
||||
|
||||
const assert = require('node:assert/strict');
|
||||
const crypto = require('node:crypto');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const test = require('node:test');
|
||||
|
||||
const ROOT = path.resolve(__dirname, '../..');
|
||||
const PATCH = path.join(ROOT, 'tools', 'ps5-kstuff-diagnostics', 'route-selection-observability.patch');
|
||||
const READER = path.join(ROOT, 'tools', 'ps5-kstuff-diagnostics', 'route-selection-reader.c');
|
||||
const MAKEFILE = path.join(ROOT, 'tools', 'ps5-kstuff-diagnostics', 'route-selection-reader.mk');
|
||||
const BUILD_SCRIPT = path.join(ROOT, 'scripts', 'autonomous', 'p15-a7-build-route-selection.sh');
|
||||
const DOCKERFILE = path.join(ROOT, 'tools', 'ps5-kstuff-diagnostics', 'Dockerfile.route-selection');
|
||||
const BUILD_EVIDENCE = path.join(ROOT, 'docs', 'autonomous', 'evidence', 'P15_A7_ROUTE_SELECTION_BUILD_2026-08-25.json');
|
||||
const PLAN = path.join(ROOT, 'docs', 'autonomous', 'evidence', 'P15_A7_ROUTE_SELECTION_PLAN.json');
|
||||
|
||||
function sha256Text(file) {
|
||||
const canonical = fs.readFileSync(file, 'utf8').replace(/\r\n/g, '\n');
|
||||
return crypto.createHash('sha256').update(canonical, 'utf8').digest('hex');
|
||||
}
|
||||
|
||||
test('A7 runtime patch changes observability plumbing only', () => {
|
||||
const patch = fs.readFileSync(PATCH, 'utf8');
|
||||
for (const file of ['uelf/shared_area.h', 'uelf/log.h', 'uelf/log.c', 'uelf/utils.c']) {
|
||||
assert.match(patch, new RegExp(`diff --git a/ps5-kstuff/${file.replaceAll('/', '\\/')}`));
|
||||
}
|
||||
for (const forbiddenFile of ['fpkg.c', 'pfs_crypto.c', 'fakekeys.c', 'mailbox.c', 'fself.c', 'npdrm.c']) {
|
||||
assert.doesNotMatch(patch, new RegExp(`diff --git a/ps5-kstuff/uelf/${forbiddenFile.replace('.', '\\.')}`));
|
||||
}
|
||||
const additions = patch.split(/\r?\n/).filter(line => /^\+(?!\+\+)/.test(line)).join('\n');
|
||||
for (const forbidden of ['DMEM', 'copy_to_kernel', 'key_data', 'eekpfs', 'crypt_seed', 'mailboxBuffer', 'log_word', 'log_msg']) {
|
||||
assert.doesNotMatch(additions, new RegExp(forbidden, 'i'));
|
||||
}
|
||||
assert.match(additions, /debug_register_arm_successes/);
|
||||
assert.match(additions, /debug_register_finish_restore_failures/);
|
||||
});
|
||||
|
||||
test('A7 reader exports only bounded counters and scrubs the source snapshot', () => {
|
||||
const source = fs.readFileSync(READER, 'utf8');
|
||||
assert.match(source, /schema=chimera\.p15-a7-route-selection-metrics\.v1/);
|
||||
assert.match(source, /volatile_scrub\(&snapshot, sizeof\(snapshot\)\)/);
|
||||
assert.match(source, /route nmount_dispatch=/);
|
||||
assert.match(source, /dbgarm attempt=/);
|
||||
assert.match(source, /mailbox total=/);
|
||||
assert.match(source, /fpkg verify=/);
|
||||
for (const forbidden of ['word_log', 'msg_log', 'ioctl_com_table', 'key_data', 'mailbox-capture', 'decrypt_regs']) {
|
||||
assert.doesNotMatch(source, new RegExp(forbidden, 'i'));
|
||||
}
|
||||
});
|
||||
|
||||
test('A7 toolchain pins the loader dependency and artifact builds remain offline', () => {
|
||||
const dockerfile = fs.readFileSync(DOCKERFILE, 'utf8');
|
||||
const evidence = JSON.parse(fs.readFileSync(BUILD_EVIDENCE, 'utf8'));
|
||||
assert.match(dockerfile, /releases\/download\/v0\.39\/ps5-payload-dev\.tar\.gz/);
|
||||
assert.match(dockerfile, /14ac4113523ed61bc1d42a0d0b7b8981b2bc6aac35a68949c169b0ed0aaf5cdb/);
|
||||
assert.doesNotMatch(dockerfile, /releases\/latest/);
|
||||
assert.equal(evidence.toolchain.networkDuringArtifactBuild, 'none');
|
||||
assert.equal(evidence.toolchain.containerRootFilesystem, 'read-only');
|
||||
assert.equal(evidence.reproducibility.runs, 2);
|
||||
assert.equal(evidence.reproducibility.exactMatch, true);
|
||||
assert.equal(evidence.artifacts.loader.embeddedPayloadOccurrences, 1);
|
||||
assert.equal(evidence.artifacts.loader.embeddedPayloadExactMatch, true);
|
||||
});
|
||||
|
||||
test('A7 plan is host-qualified but grants no live action', () => {
|
||||
const plan = JSON.parse(fs.readFileSync(PLAN, 'utf8'));
|
||||
assert.equal(plan.status, 'host_qualified_live_blocked');
|
||||
assert.equal(plan.liveBudget.used, 0);
|
||||
assert.equal(plan.liveBudget.stagingAllowedByThisArtifact, false);
|
||||
assert.equal(plan.liveBudget.loadingAllowedByThisArtifact, false);
|
||||
assert.equal(plan.liveBudget.launchAllowedByThisArtifact, false);
|
||||
assert.equal(plan.gates.explicitExactConfirmationRecorded, false);
|
||||
});
|
||||
|
||||
test('A7 committed evidence records both host build runs without requiring ignored binaries', () => {
|
||||
const evidence = JSON.parse(fs.readFileSync(BUILD_EVIDENCE, 'utf8'));
|
||||
assert.equal(evidence.retention.artifactsCommitted, false);
|
||||
assert.equal(evidence.retention.repositoryCiReopensHostArtifacts, false);
|
||||
assert.equal(evidence.retention.artifactIntegritySource, 'recorded-host-qualification-hashes');
|
||||
assert.equal(sha256Text(PATCH), evidence.source.observabilityPatch.sha256);
|
||||
assert.equal(sha256Text(READER), evidence.reader.sourceSha256);
|
||||
assert.equal(sha256Text(MAKEFILE), evidence.reader.makefileSha256);
|
||||
assert.equal(sha256Text(BUILD_SCRIPT), evidence.toolchain.buildScriptSha256);
|
||||
assert.equal(sha256Text(DOCKERFILE), evidence.toolchain.dockerfileSha256);
|
||||
for (const artifact of Object.values(evidence.artifacts)) {
|
||||
assert.ok(Number.isSafeInteger(artifact.size) && artifact.size > 0);
|
||||
assert.match(artifact.sha256, /^[a-f0-9]{64}$/);
|
||||
}
|
||||
assert.equal(evidence.artifacts.loader.embeddedPayloadOffset, 1214114);
|
||||
assert.equal(evidence.artifacts.loader.embeddedPayloadOccurrences, 1);
|
||||
assert.equal(evidence.artifacts.loader.embeddedPayloadExactMatch, true);
|
||||
});
|
||||
@@ -0,0 +1,173 @@
|
||||
'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);
|
||||
value.writeUInt16LE(0x3e, 18);
|
||||
return value;
|
||||
}
|
||||
|
||||
class FakeFtp {
|
||||
constructor(files, corrupt = false) {
|
||||
this.files = new Map(Object.entries(files).map(([name, bytes]) => [`${REMOTE_DIR}/${name}`, Buffer.from(bytes)]));
|
||||
this.writes = 0;
|
||||
this.corrupt = corrupt;
|
||||
}
|
||||
async list() {
|
||||
return [...this.files].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 new Error('missing');
|
||||
if (this.corrupt && source.endsWith('/autoload.txt') && this.writes >= 7) 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');
|
||||
this.files.delete(source);
|
||||
this.files.set(target, bytes);
|
||||
}
|
||||
async delete(target) { this.writes += 1; if (!this.files.delete(target)) throw new Error('missing'); }
|
||||
}
|
||||
|
||||
async function fixture(t) {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'chimera-a7-route-stage-'));
|
||||
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
const local = path.join(root, 'local');
|
||||
const temporary = path.join(root, 'temp');
|
||||
const backup = path.join(root, 'backup');
|
||||
fs.mkdirSync(local);
|
||||
const oldLoader = elf(1, 80);
|
||||
const oldReader = elf(2, 72);
|
||||
const newLoader = elf(3, 96);
|
||||
const newReader = elf(4, 76);
|
||||
const currentLines = ['', '', 'manager.elf', 'old-loader.elf', '!30000', 'old-reader.elf', '!5000', 'service.elf'];
|
||||
const manifest = Buffer.from(`${currentLines.join('\n')}\n`);
|
||||
fs.writeFileSync(path.join(local, 'new-loader.elf'), newLoader);
|
||||
fs.writeFileSync(path.join(local, 'new-reader.elf'), newReader);
|
||||
const contract = {
|
||||
schema: 'test.a7.stage.v1',
|
||||
identity: 'a7-stage-test',
|
||||
current: {
|
||||
manifestSha256: digest(manifest), loaderName: 'old-loader.elf', loaderSize: oldLoader.length,
|
||||
loaderSha256: digest(oldLoader), readerName: 'old-reader.elf', readerSize: oldReader.length, readerSha256: digest(oldReader),
|
||||
},
|
||||
next: {
|
||||
loaderLocalName: 'new-loader.elf', loaderRemoteName: 'a7-loader.elf', loaderSize: newLoader.length, loaderSha256: digest(newLoader),
|
||||
readerLocalName: 'new-reader.elf', readerRemoteName: 'a7-reader.elf', readerSize: newReader.length, readerSha256: digest(newReader),
|
||||
},
|
||||
currentManifest: currentLines,
|
||||
};
|
||||
const files = {
|
||||
'autoload.txt': manifest, 'old-loader.elf': oldLoader, 'old-reader.elf': oldReader,
|
||||
'manager.elf': elf(5), 'service.elf': elf(6),
|
||||
};
|
||||
return { local, temporary, backup, oldLoader, oldReader, newLoader, newReader, manifest, contract, files };
|
||||
}
|
||||
|
||||
test('A7 stage plan is read-only and binds both exact replacements', async t => {
|
||||
const f = await fixture(t);
|
||||
const ftp = new FakeFtp(f.files);
|
||||
const { createPlan } = await import('../../scripts/autonomous/p15-a7-stage-route-selection.mjs');
|
||||
const plan = await createPlan({ ftp, localRoot: f.local, temporaryRoot: f.temporary, contract: f.contract });
|
||||
assert.equal(ftp.writes, 0);
|
||||
assert.match(plan.proposed.manifest, /a7-loader\.elf/);
|
||||
assert.match(plan.proposed.manifest, /a7-reader\.elf/);
|
||||
assert.doesNotMatch(plan.proposed.manifest, /old-loader\.elf|old-reader\.elf/);
|
||||
assert.equal(plan.automaticRebootPerformed, false);
|
||||
assert.equal(plan.launchPerformed, false);
|
||||
});
|
||||
|
||||
test('A7 stage apply snapshots every file and preserves the A6 artifacts', async t => {
|
||||
const f = await fixture(t);
|
||||
const ftp = new FakeFtp(f.files);
|
||||
const { createPlan, applyPlan, CONFIRMATION } = await import('../../scripts/autonomous/p15-a7-stage-route-selection.mjs');
|
||||
const plan = await createPlan({ ftp, localRoot: f.local, temporaryRoot: f.temporary, contract: f.contract });
|
||||
const receipt = await applyPlan({
|
||||
ftp, localRoot: f.local, temporaryRoot: f.temporary, backupRoot: f.backup, contract: f.contract,
|
||||
confirmation: CONFIRMATION, planDigest: plan.planDigest, now: () => 1234567890123, nonce: () => 'a7stage1',
|
||||
});
|
||||
assert.equal(receipt.status, 'staged_verified');
|
||||
assert.equal(receipt.launchPerformed, false);
|
||||
assert.deepEqual(ftp.files.get(`${REMOTE_DIR}/old-loader.elf`), f.oldLoader);
|
||||
assert.deepEqual(ftp.files.get(`${REMOTE_DIR}/old-reader.elf`), f.oldReader);
|
||||
assert.deepEqual(ftp.files.get(`${REMOTE_DIR}/a7-loader.elf`), f.newLoader);
|
||||
assert.deepEqual(ftp.files.get(`${REMOTE_DIR}/a7-reader.elf`), f.newReader);
|
||||
assert.equal(JSON.parse(fs.readFileSync(path.join(f.backup, receipt.snapshotId, 'meta.json'))).files.length, 5);
|
||||
});
|
||||
|
||||
test('A7 independent staged readback verifies exact new and preserved old artifacts without writes', async t => {
|
||||
const f = await fixture(t);
|
||||
const ftp = new FakeFtp(f.files);
|
||||
const { createPlan, applyPlan, verifyStaged, CONFIRMATION } = await import('../../scripts/autonomous/p15-a7-stage-route-selection.mjs');
|
||||
const plan = await createPlan({ ftp, localRoot: f.local, temporaryRoot: f.temporary, contract: f.contract });
|
||||
await applyPlan({
|
||||
ftp, localRoot: f.local, temporaryRoot: f.temporary, backupRoot: f.backup, contract: f.contract,
|
||||
confirmation: CONFIRMATION, planDigest: plan.planDigest, now: () => 1234567890123, nonce: () => 'readback',
|
||||
});
|
||||
const writesBefore = ftp.writes;
|
||||
const result = await verifyStaged({ ftp, temporaryRoot: f.temporary, contract: f.contract });
|
||||
assert.equal(result.status, 'verified');
|
||||
assert.equal(result.manifest.exactContent, true);
|
||||
assert.equal(result.oldRuntimeArtifactsPreserved, true);
|
||||
assert.equal(result.consoleMutationPerformed, false);
|
||||
assert.equal(ftp.writes, writesBefore);
|
||||
});
|
||||
|
||||
test('A7 independent staged readback fails closed on drift and unfinished publication files', async t => {
|
||||
const f = await fixture(t);
|
||||
const ftp = new FakeFtp({
|
||||
...f.files,
|
||||
'a7-loader.elf': f.newLoader,
|
||||
'a7-reader.elf': f.newReader,
|
||||
});
|
||||
const { verifyStaged } = await import('../../scripts/autonomous/p15-a7-stage-route-selection.mjs');
|
||||
await assert.rejects(verifyStaged({ ftp, temporaryRoot: f.temporary, contract: f.contract }), { code: 'P15_A7_STAGE_READBACK_DRIFT' });
|
||||
ftp.files.set(`${REMOTE_DIR}/.chimera-unfinished.tmp`, Buffer.from('partial'));
|
||||
await assert.rejects(verifyStaged({ ftp, temporaryRoot: f.temporary, contract: f.contract }), { code: 'P15_A7_STAGE_READBACK_TEMPORARY' });
|
||||
});
|
||||
|
||||
test('A7 stage refuses missing confirmation and stale digest before writes', async t => {
|
||||
const f = await fixture(t);
|
||||
const ftp = new FakeFtp(f.files);
|
||||
const { createPlan, applyPlan, CONFIRMATION } = await import('../../scripts/autonomous/p15-a7-stage-route-selection.mjs');
|
||||
const plan = await createPlan({ ftp, localRoot: f.local, temporaryRoot: f.temporary, contract: f.contract });
|
||||
await assert.rejects(applyPlan({
|
||||
ftp, localRoot: f.local, temporaryRoot: f.temporary, backupRoot: f.backup, contract: f.contract,
|
||||
confirmation: 'wrong', planDigest: plan.planDigest,
|
||||
}), { code: 'P15_A7_STAGE_CONFIRMATION' });
|
||||
await assert.rejects(applyPlan({
|
||||
ftp, localRoot: f.local, temporaryRoot: f.temporary, backupRoot: f.backup, contract: f.contract,
|
||||
confirmation: CONFIRMATION, planDigest: '0'.repeat(64),
|
||||
}), { code: 'P15_A7_STAGE_PLAN_DRIFT' });
|
||||
assert.equal(ftp.writes, 0);
|
||||
});
|
||||
|
||||
test('A7 stage post-read failure restores the manifest and removes both A7 targets', async t => {
|
||||
const f = await fixture(t);
|
||||
const ftp = new FakeFtp(f.files, true);
|
||||
const { createPlan, applyPlan, CONFIRMATION } = await import('../../scripts/autonomous/p15-a7-stage-route-selection.mjs');
|
||||
const plan = await createPlan({ ftp, localRoot: f.local, temporaryRoot: f.temporary, contract: f.contract });
|
||||
await assert.rejects(applyPlan({
|
||||
ftp, localRoot: f.local, temporaryRoot: f.temporary, backupRoot: f.backup, contract: f.contract,
|
||||
confirmation: CONFIRMATION, planDigest: plan.planDigest, now: () => 1234567890123, nonce: () => 'rollback',
|
||||
}), error => error.code === 'P15_A7_STAGE_POST_VERIFY' && error.details.rolledBack === true);
|
||||
assert.deepEqual(ftp.files.get(`${REMOTE_DIR}/autoload.txt`), f.manifest);
|
||||
assert.equal(ftp.files.has(`${REMOTE_DIR}/a7-loader.elf`), false);
|
||||
assert.equal(ftp.files.has(`${REMOTE_DIR}/a7-reader.elf`), false);
|
||||
assert.deepEqual(ftp.files.get(`${REMOTE_DIR}/old-loader.elf`), f.oldLoader);
|
||||
assert.deepEqual(ftp.files.get(`${REMOTE_DIR}/old-reader.elf`), f.oldReader);
|
||||
});
|
||||
@@ -0,0 +1,251 @@
|
||||
'use strict';
|
||||
|
||||
const assert = require('node:assert/strict');
|
||||
const test = require('node:test');
|
||||
const {
|
||||
createPs5FpkgRouteSelectionTrace,
|
||||
parseKstuffRouteSelectionSnapshot,
|
||||
} = require('../../src/services/ps5-fpkg-route-selection-trace');
|
||||
|
||||
const OPERATION_ID = '123e4567-e89b-42d3-a456-426614174000';
|
||||
const FINGERPRINT = 'a'.repeat(64);
|
||||
|
||||
function runtime(overrides = {}) {
|
||||
return {
|
||||
product: 'EchoStretch/kstuff-lite',
|
||||
version: '1.10',
|
||||
revision: '3b2ab4a16ba43c8ab38daf7ec83cc297014e0e47',
|
||||
payloadSha256: '1'.repeat(64),
|
||||
loaderSha256: '2'.repeat(64),
|
||||
readerSha256: '3'.repeat(64),
|
||||
observabilityProfile: 'chimera-p15-a7-route-selection-v1',
|
||||
identityStatus: 'exact',
|
||||
traceEligible: true,
|
||||
firmware: '9.60',
|
||||
fingerprint: FINGERPRINT,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function metrics(overrides = {}) {
|
||||
return {
|
||||
nmountDispatch: 0,
|
||||
nmountTrap: 0,
|
||||
nmountEmulated: 0,
|
||||
debugRegisterArmAttempts: 0,
|
||||
debugRegisterReadFailures: 0,
|
||||
debugRegisterPcbPointerFailures: 0,
|
||||
debugRegisterPcbStateFailures: 0,
|
||||
debugRegisterStackPushFailures: 0,
|
||||
debugRegisterPcbSetFailures: 0,
|
||||
debugRegisterWriteFailures: 0,
|
||||
debugRegisterArmSuccesses: 0,
|
||||
debugRegisterRollbackRestoreFailures: 0,
|
||||
debugRegisterFinishSuccesses: 0,
|
||||
debugRegisterFinishRestoreFailures: 0,
|
||||
mailboxTotal: 0,
|
||||
mailboxFself: 0,
|
||||
mailboxFpkg: 0,
|
||||
mailboxNpdrm: 0,
|
||||
mailboxUnhandled: 0,
|
||||
verifySuperBlockMailbox: 0,
|
||||
verifySuperBlockEmulated: 0,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function snapshot(sequence, values) {
|
||||
return {
|
||||
sequence,
|
||||
sampledAt: 1000 + sequence,
|
||||
runtimeFingerprint: FINGERPRINT,
|
||||
metrics: metrics(values),
|
||||
};
|
||||
}
|
||||
|
||||
function trace(postMetrics, overrides = {}) {
|
||||
return createPs5FpkgRouteSelectionTrace({
|
||||
operationId: OPERATION_ID,
|
||||
titleId: 'FAKE00000',
|
||||
runtime: runtime(),
|
||||
pre: snapshot(10, metrics()),
|
||||
post: snapshot(11, postMetrics),
|
||||
launchRequests: 1,
|
||||
retryPerformed: false,
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
test('A7 parser accepts only the route-selection semantic schema', () => {
|
||||
const parsed = parseKstuffRouteSelectionSnapshot([
|
||||
'schema=chimera.p15-a7-route-selection-metrics.v1',
|
||||
'status=attached',
|
||||
'sample=8',
|
||||
'route nmount_dispatch=7 nmount_trap=3 nmount_emu=2',
|
||||
'dbgarm attempt=7 read_fail=1 pcb_ptr_fail=0 pcb_state_fail=0 stack_fail=0 pcb_set_fail=0 write_fail=0 armed=6 rollback_restore_fail=0 finish=6 finish_restore_fail=0',
|
||||
'mailbox total=5 fself=1 fpkg=2 npdrm=1 unhandled=1',
|
||||
'fpkg verify=2 verify_emu=2',
|
||||
].join('\n'), { sequence: 8, sampledAt: 1008, runtimeFingerprint: FINGERPRINT });
|
||||
|
||||
assert.equal(parsed.metrics.nmountDispatch, 7);
|
||||
assert.equal(parsed.metrics.debugRegisterReadFailures, 1);
|
||||
assert.equal(parsed.metrics.debugRegisterArmSuccesses, 6);
|
||||
assert.equal(parsed.metrics.mailboxUnhandled, 1);
|
||||
assert.equal(parsed.metrics.verifySuperBlockMailbox, 2);
|
||||
});
|
||||
|
||||
test('A7 parser rejects raw values, unknown fields, duplicates and sample drift', () => {
|
||||
const base = [
|
||||
'schema=chimera.p15-a7-route-selection-metrics.v1',
|
||||
'status=attached',
|
||||
'sample=8',
|
||||
'route nmount_dispatch=1 nmount_trap=0 nmount_emu=0',
|
||||
'dbgarm attempt=1 read_fail=0 pcb_ptr_fail=0 pcb_state_fail=0 stack_fail=0 pcb_set_fail=0 write_fail=0 armed=1 rollback_restore_fail=0 finish=1 finish_restore_fail=0',
|
||||
'mailbox total=0 fself=0 fpkg=0 npdrm=0 unhandled=0',
|
||||
'fpkg verify=0 verify_emu=0',
|
||||
];
|
||||
for (const unsafe of [
|
||||
[...base, 'request_ptr=0xffff800012345678'],
|
||||
base.map(line => line.startsWith('dbgarm ') ? `${line} dr0=1234` : line),
|
||||
[...base, base[3]],
|
||||
base.map(line => line === 'sample=8' ? 'sample=9' : line),
|
||||
]) {
|
||||
assert.throws(
|
||||
() => parseKstuffRouteSelectionSnapshot(unsafe.join('\n'), { sequence: 8, sampledAt: 1008, runtimeFingerprint: FINGERPRINT }),
|
||||
error => error.code === 'PS5_FPKG_ROUTE_SELECTION_TRACE_UNSAFE_LINE',
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('A7 identifies an exact debug-register arming preflight failure', () => {
|
||||
const result = trace(metrics({
|
||||
nmountDispatch: 1,
|
||||
debugRegisterArmAttempts: 1,
|
||||
debugRegisterReadFailures: 1,
|
||||
}));
|
||||
assert.equal(result.status, 'observed');
|
||||
assert.equal(result.firstFailedStage, 'debug_register_read_failed');
|
||||
assert.equal(result.normalizedFailure, 'PS5_FPKG_DEBUG_REGISTER_ARM_FAILED');
|
||||
assert.equal(result.route.debugRegisterArmed, false);
|
||||
assert.equal(result.route.mailboxTargetReached, false);
|
||||
});
|
||||
|
||||
test('A7 distinguishes successful arming from a mailbox target never invoked', () => {
|
||||
const result = trace(metrics({
|
||||
nmountDispatch: 1,
|
||||
debugRegisterArmAttempts: 1,
|
||||
debugRegisterArmSuccesses: 1,
|
||||
debugRegisterFinishSuccesses: 1,
|
||||
}));
|
||||
assert.equal(result.firstFailedStage, 'mailbox_target_not_invoked_during_nmount');
|
||||
assert.equal(result.normalizedFailure, 'PS5_FPKG_MAILBOX_TARGET_NOT_REACHED');
|
||||
assert.equal(result.route.debugRegisterArmed, true);
|
||||
assert.equal(result.route.mailboxTargetReached, false);
|
||||
assert.equal(result.recovery.debugRegistersRestored, true);
|
||||
});
|
||||
|
||||
test('A7 distinguishes an unrecognized mailbox route from no mailbox call', () => {
|
||||
const result = trace(metrics({
|
||||
nmountDispatch: 1,
|
||||
nmountTrap: 1,
|
||||
debugRegisterArmAttempts: 1,
|
||||
debugRegisterArmSuccesses: 1,
|
||||
debugRegisterFinishSuccesses: 1,
|
||||
mailboxTotal: 1,
|
||||
mailboxUnhandled: 1,
|
||||
}));
|
||||
assert.equal(result.firstFailedStage, 'mailbox_route_unrecognized_for_fpkg');
|
||||
assert.equal(result.normalizedFailure, 'PS5_FPKG_MAILBOX_ROUTE_UNRECOGNIZED');
|
||||
assert.equal(result.route.mailboxTargetReached, true);
|
||||
assert.equal(result.route.fpkgMailboxRecognized, false);
|
||||
});
|
||||
|
||||
test('A7 records when the route reaches verifySuperBlock without claiming a fix', () => {
|
||||
const result = trace(metrics({
|
||||
nmountDispatch: 1,
|
||||
nmountTrap: 1,
|
||||
nmountEmulated: 1,
|
||||
debugRegisterArmAttempts: 1,
|
||||
debugRegisterArmSuccesses: 1,
|
||||
debugRegisterFinishSuccesses: 1,
|
||||
mailboxTotal: 1,
|
||||
mailboxFpkg: 1,
|
||||
verifySuperBlockMailbox: 1,
|
||||
verifySuperBlockEmulated: 1,
|
||||
}));
|
||||
assert.equal(result.firstFailedStage, null);
|
||||
assert.equal(result.normalizedFailure, null);
|
||||
assert.equal(result.route.verifySuperBlockReached, true);
|
||||
assert.equal(result.route.verifySuperBlockEmulated, true);
|
||||
assert.equal(result.productionRestorable, false);
|
||||
});
|
||||
|
||||
test('A7 fails closed on ambiguous dispatch count, counter reset and interruption', () => {
|
||||
const ambiguous = trace(metrics({ nmountDispatch: 2, debugRegisterArmAttempts: 2 }));
|
||||
assert.equal(ambiguous.status, 'outcome_unknown');
|
||||
assert.equal(ambiguous.outcomeReason, 'launch_scope_ambiguous');
|
||||
|
||||
const reset = createPs5FpkgRouteSelectionTrace({
|
||||
operationId: OPERATION_ID,
|
||||
titleId: 'FAKE00000',
|
||||
runtime: runtime(),
|
||||
pre: snapshot(10, metrics({ nmountDispatch: 4 })),
|
||||
post: snapshot(11, metrics({ nmountDispatch: 3 })),
|
||||
launchRequests: 1,
|
||||
});
|
||||
assert.equal(reset.status, 'outcome_unknown');
|
||||
assert.equal(reset.metricContinuity, 'counter-reset-or-drift');
|
||||
|
||||
const interrupted = createPs5FpkgRouteSelectionTrace({
|
||||
operationId: OPERATION_ID,
|
||||
titleId: 'FAKE00000',
|
||||
runtime: runtime(),
|
||||
pre: snapshot(10, metrics()),
|
||||
post: null,
|
||||
launchRequests: 1,
|
||||
interrupted: true,
|
||||
});
|
||||
assert.equal(interrupted.status, 'outcome_unknown');
|
||||
assert.equal(interrupted.outcomeReason, 'interrupted');
|
||||
});
|
||||
|
||||
test('A7 fails closed when debug-register completion is missing or contradictory', () => {
|
||||
const unfinished = trace(metrics({
|
||||
nmountDispatch: 1,
|
||||
debugRegisterArmAttempts: 1,
|
||||
debugRegisterArmSuccesses: 1,
|
||||
}));
|
||||
assert.equal(unfinished.status, 'outcome_unknown');
|
||||
assert.equal(unfinished.outcomeReason, 'debug_register_finalization_missing');
|
||||
|
||||
const conflicting = trace(metrics({
|
||||
nmountDispatch: 1,
|
||||
debugRegisterArmAttempts: 1,
|
||||
debugRegisterReadFailures: 1,
|
||||
debugRegisterArmSuccesses: 1,
|
||||
debugRegisterFinishSuccesses: 1,
|
||||
}));
|
||||
assert.equal(conflicting.status, 'outcome_unknown');
|
||||
assert.equal(conflicting.outcomeReason, 'arming_outcome_ambiguous');
|
||||
|
||||
const rollbackRestoreFailure = trace(metrics({
|
||||
nmountDispatch: 1,
|
||||
debugRegisterArmAttempts: 1,
|
||||
debugRegisterWriteFailures: 1,
|
||||
debugRegisterRollbackRestoreFailures: 1,
|
||||
}));
|
||||
assert.equal(rollbackRestoreFailure.status, 'outcome_unknown');
|
||||
assert.equal(rollbackRestoreFailure.outcomeReason, 'debug_register_restore_failed');
|
||||
});
|
||||
|
||||
test('A7 evidence never serializes register, pointer, mailbox-buffer or key material', () => {
|
||||
const result = trace(metrics({
|
||||
nmountDispatch: 1,
|
||||
debugRegisterArmAttempts: 1,
|
||||
debugRegisterWriteFailures: 1,
|
||||
}));
|
||||
const serialized = JSON.stringify(result);
|
||||
for (const forbidden of ['dr0', 'requestPointer', 'mailboxBuffer', 'registerValues', 'keyBytes', 'dmem', 'eekpfs']) {
|
||||
assert.doesNotMatch(serialized, new RegExp(forbidden, 'i'));
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,108 @@
|
||||
'use strict';
|
||||
|
||||
const assert = require('node:assert/strict');
|
||||
const crypto = require('node:crypto');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const test = require('node:test');
|
||||
|
||||
const ROOT = path.resolve(__dirname, '../..');
|
||||
const PATCH = path.join(ROOT, 'tools', 'ps5-kstuff-diagnostics', 'completion-observability.patch');
|
||||
const READER = path.join(ROOT, 'tools', 'ps5-kstuff-diagnostics', 'completion-reader.c');
|
||||
const BUILD = path.join(ROOT, 'scripts', 'autonomous', 'p15-a8-build-completion.sh');
|
||||
const EVIDENCE = path.join(ROOT, 'docs', 'autonomous', 'evidence', 'P15_A8_HOST_QUALIFICATION_2026-08-26.json');
|
||||
const EXPERIMENT = path.join(ROOT, 'docs', 'autonomous', 'evidence', 'P15_A8_CONTROL_REFERENCE_HOST_PLAN_2026-08-26.json');
|
||||
const CONTROL_ASSESSMENT = path.join(ROOT, 'docs', 'autonomous', 'evidence', 'P15_A8_CONTROL_CANDIDATE_ASSESSMENT_2026-08-26.json');
|
||||
|
||||
function sha256Text(file) {
|
||||
const canonical = fs.readFileSync(file, 'utf8').replace(/\r\n/g, '\n');
|
||||
return crypto.createHash('sha256').update(canonical, 'utf8').digest('hex');
|
||||
}
|
||||
|
||||
test('A8 carries explicit nmount attribution from arm into the utils trap', () => {
|
||||
const source = fs.readFileSync(PATCH, 'utf8');
|
||||
assert.match(source, /start_syscall_with_dbgregs_tagged/);
|
||||
assert.match(source, /KSTUFF_SYSCALL_NMOUNT/);
|
||||
assert.match(source, /KSTUFF_DBGREGS_TAGGED_TRAP/);
|
||||
assert.match(source, /observe_tagged_syscall_debug_register/);
|
||||
assert.match(source, /debug_register_tagged_finish_entries/);
|
||||
assert.match(source, /debug_register_tagged_finish_peek_failures/);
|
||||
|
||||
const additions = source.split(/\r?\n/).filter(line => /^\+(?!\+\+)/.test(line)).join('\n');
|
||||
for (const forbidden of ['key_data', 'crypt_seed', 'eekpfs', 'mailboxBuffer', 'requestPointer', 'registerValues']) {
|
||||
assert.doesNotMatch(additions, new RegExp(forbidden, 'i'));
|
||||
}
|
||||
});
|
||||
|
||||
test('A8 leaves authentication and package semantics untouched', () => {
|
||||
const source = fs.readFileSync(PATCH, 'utf8');
|
||||
for (const forbiddenFile of ['pfs_crypto.c', 'fakekeys.c', 'mailbox.c', 'fself.c', 'npdrm.c']) {
|
||||
assert.doesNotMatch(source, new RegExp(`diff --git a/ps5-kstuff/uelf/${forbiddenFile.replace('.', '\\.')}`));
|
||||
}
|
||||
assert.match(source, /diff --git a\/ps5-kstuff\/uelf\/fpkg\.c/);
|
||||
assert.match(source, /handle_fpkg_syscall\(uint64_t\* regs, enum kstuff_syscall_tag tag\)/);
|
||||
});
|
||||
|
||||
test('A8 reader emits only bounded semantic completion counters', () => {
|
||||
const source = fs.readFileSync(READER, 'utf8');
|
||||
assert.match(source, /schema=chimera\.p15-a8-completion-metrics\.v1/);
|
||||
assert.match(source, /completion tag=nmount/);
|
||||
assert.match(source, /finish_entry=/);
|
||||
assert.match(source, /finish_peek_fail=/);
|
||||
assert.match(source, /volatile_scrub\(&snapshot, sizeof\(snapshot\)\)/);
|
||||
for (const forbidden of ['word_log', 'msg_log', 'ioctl_com_table', 'key_data', 'dr0', 'mailbox-capture']) {
|
||||
assert.doesNotMatch(source, new RegExp(forbidden, 'i'));
|
||||
}
|
||||
});
|
||||
|
||||
test('A8 build is an offline incremental build over the immutable A7 patch', () => {
|
||||
const source = fs.readFileSync(BUILD, 'utf8');
|
||||
const a7 = source.indexOf('route-selection-observability.patch');
|
||||
const a8 = source.indexOf('completion-observability.patch');
|
||||
assert.ok(a7 >= 0 && a8 > a7);
|
||||
assert.match(source, /expected_revision=3b2ab4a16ba43c8ab38daf7ec83cc297014e0e47/);
|
||||
assert.match(source, /--network none/);
|
||||
assert.doesNotMatch(source, /curl|wget|releases\/latest/i);
|
||||
});
|
||||
|
||||
test('A8 host evidence binds exact sources and grants no live action', () => {
|
||||
const evidence = JSON.parse(fs.readFileSync(EVIDENCE, 'utf8'));
|
||||
const plan = JSON.parse(fs.readFileSync(EXPERIMENT, 'utf8'));
|
||||
for (const item of evidence.patchStack) assert.equal(sha256Text(path.join(ROOT, item.path)), item.sha256);
|
||||
assert.equal(sha256Text(path.join(ROOT, evidence.reader.source)), evidence.reader.sourceSha256);
|
||||
assert.equal(sha256Text(path.join(ROOT, evidence.reader.makefile)), evidence.reader.makefileSha256);
|
||||
assert.equal(sha256Text(path.join(ROOT, evidence.build.script)), evidence.build.scriptSha256);
|
||||
assert.equal(sha256Text(path.join(ROOT, 'src/services/ps5-fpkg-completion-trace.js')), evidence.hostContracts.completionTraceSourceSha256);
|
||||
assert.equal(sha256Text(path.join(ROOT, 'src/services/ps5-fpkg-a8-experiment-ledger.js')), evidence.hostContracts.experimentLedgerSourceSha256);
|
||||
assert.equal(sha256Text(path.join(ROOT, 'src/services/ps5-fpkg-a8-control-qualification.js')), evidence.hostContracts.controlQualificationSourceSha256);
|
||||
assert.equal(sha256Text(path.join(ROOT, 'src/services/ps5-fpkg-control-candidate-service.js')), evidence.hostContracts.controlCandidateServiceSourceSha256);
|
||||
assert.equal(sha256Text(path.join(ROOT, 'src/services/ps5-fpkg-compatibility-profile-service.js')), evidence.hostContracts.durableLedgerSourceSha256);
|
||||
assert.match(evidence.hostContracts.launchServiceSourceSha256, /^[a-f0-9]{64}$/);
|
||||
const currentLaunchService = fs.readFileSync(path.join(ROOT, 'src/services/ps5-native-package-install-service.js'), 'utf8');
|
||||
assert.match(currentLaunchService, /operation\.status !== 'installed_observed'[\s\S]{0,300}PS5_FPKG_LAUNCH_STATE/);
|
||||
const durableReservation = currentLaunchService.indexOf("status: 'launch_test_pending'");
|
||||
const providerLaunch = currentLaunchService.indexOf('await provider.launch', durableReservation);
|
||||
assert.ok(durableReservation >= 0 && providerLaunch > durableReservation);
|
||||
assert.match(currentLaunchService, /productionRestorable: operation\.productionRestorable === true/);
|
||||
assert.equal(evidence.build.runs, 2);
|
||||
assert.equal(evidence.build.exactByteMatch, true);
|
||||
assert.equal(Object.values(evidence.liveActions).some(Boolean), false);
|
||||
assert.equal(plan.grantsLiveAction, false);
|
||||
assert.equal(plan.control.titleId, null);
|
||||
assert.equal(plan.reference.a7Ledger.reusable, false);
|
||||
assert.equal(plan.reference.a8Authorization, false);
|
||||
const assessment = JSON.parse(fs.readFileSync(CONTROL_ASSESSMENT, 'utf8'));
|
||||
assert.equal(assessment.status, 'no-full-fpkg-control-qualified');
|
||||
assert.equal(assessment.existingEvidencePromoted, false);
|
||||
assert.equal(assessment.grantsLiveAction, false);
|
||||
assert.equal(assessment.candidates.find(item => item.titleId === 'PPSA02774').state, 'rejected_as_existing_evidence');
|
||||
});
|
||||
|
||||
test('A8 cached control discovery is a read-only command and never starts a scan', () => {
|
||||
const registry = fs.readFileSync(path.join(ROOT, 'src/server/create-registry.js'), 'utf8');
|
||||
const service = fs.readFileSync(path.join(ROOT, 'src/services/ps5-fpkg-control-candidate-service.js'), 'utf8');
|
||||
assert.match(registry, /name: 'ps5-native-packages:a8-control-candidates', classification: 'read-only'/);
|
||||
assert.match(service, /source: 'cached-only-no-console-scan'/);
|
||||
assert.match(service, /consoleContacted: false, scanStarted: false, grantsLiveAction: false/);
|
||||
assert.doesNotMatch(service, /runConsoleScan|preflightLaunch\(|\.launch\(|\.install\(|fetch\(/);
|
||||
});
|
||||
@@ -0,0 +1,179 @@
|
||||
'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);
|
||||
value.writeUInt16LE(0x3e, 18);
|
||||
return value;
|
||||
}
|
||||
|
||||
class FakeFtp {
|
||||
constructor(files, corrupt = false) {
|
||||
this.files = new Map(Object.entries(files).map(([name, bytes]) => [`${REMOTE_DIR}/${name}`, Buffer.from(bytes)]));
|
||||
this.writes = 0;
|
||||
this.corrupt = corrupt;
|
||||
}
|
||||
|
||||
async list() {
|
||||
return [...this.files].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 new Error('missing');
|
||||
if (this.corrupt && source.endsWith('/autoload.txt') && this.writes >= 7) 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');
|
||||
this.files.delete(source);
|
||||
this.files.set(target, bytes);
|
||||
}
|
||||
async delete(target) { this.writes += 1; if (!this.files.delete(target)) throw new Error('missing'); }
|
||||
}
|
||||
|
||||
async function fixture(t) {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'chimera-a8-completion-stage-'));
|
||||
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
const local = path.join(root, 'local');
|
||||
const temporary = path.join(root, 'temp');
|
||||
const backup = path.join(root, 'backup');
|
||||
fs.mkdirSync(local);
|
||||
const oldLoader = elf(1, 80);
|
||||
const oldReader = elf(2, 72);
|
||||
const newLoader = elf(3, 96);
|
||||
const newReader = elf(4, 76);
|
||||
const currentLines = ['', '', 'manager.elf', 'a7-loader.elf', '!30000', 'a7-reader.elf', '!5000', 'service.elf'];
|
||||
const manifest = Buffer.from(`${currentLines.join('\n')}\n`);
|
||||
fs.writeFileSync(path.join(local, 'new-loader.elf'), newLoader);
|
||||
fs.writeFileSync(path.join(local, 'new-reader.elf'), newReader);
|
||||
const contract = {
|
||||
schema: 'test.a8.stage.v1',
|
||||
identity: 'a8-stage-test',
|
||||
current: {
|
||||
manifestSha256: digest(manifest), loaderName: 'a7-loader.elf', loaderSize: oldLoader.length,
|
||||
loaderSha256: digest(oldLoader), readerName: 'a7-reader.elf', readerSize: oldReader.length,
|
||||
readerSha256: digest(oldReader),
|
||||
},
|
||||
next: {
|
||||
loaderLocalName: 'new-loader.elf', loaderRemoteName: 'a8-loader.elf', loaderSize: newLoader.length,
|
||||
loaderSha256: digest(newLoader), readerLocalName: 'new-reader.elf', readerRemoteName: 'a8-reader.elf',
|
||||
readerSize: newReader.length, readerSha256: digest(newReader),
|
||||
},
|
||||
currentManifest: currentLines,
|
||||
};
|
||||
const files = {
|
||||
'autoload.txt': manifest, 'a7-loader.elf': oldLoader, 'a7-reader.elf': oldReader,
|
||||
'manager.elf': elf(5), 'service.elf': elf(6),
|
||||
};
|
||||
return { local, temporary, backup, oldLoader, oldReader, newLoader, newReader, manifest, contract, files };
|
||||
}
|
||||
|
||||
test('A8 plan is read-only and binds the exact call-bound runtime pair', async t => {
|
||||
const f = await fixture(t);
|
||||
const ftp = new FakeFtp(f.files);
|
||||
const { createPlan, CONFIRMATION } = await import('../../scripts/autonomous/p15-a8-stage-completion.mjs');
|
||||
const plan = await createPlan({ ftp, localRoot: f.local, temporaryRoot: f.temporary, contract: f.contract });
|
||||
assert.equal(CONFIRMATION, 'STAGE CHIMERA P15 A8 COMPLETION RUNTIME');
|
||||
assert.equal(ftp.writes, 0);
|
||||
assert.match(plan.proposed.manifest, /a8-loader\.elf/);
|
||||
assert.match(plan.proposed.manifest, /a8-reader\.elf/);
|
||||
assert.doesNotMatch(plan.proposed.manifest, /a7-loader\.elf|a7-reader\.elf/);
|
||||
assert.equal(plan.consoleMutationPerformed, false);
|
||||
assert.equal(plan.payloadLoadPerformed, false);
|
||||
assert.equal(plan.launchPerformed, false);
|
||||
});
|
||||
|
||||
test('A8 default contract advances only the qualified A7 runtime and exact reproducible artifacts', async () => {
|
||||
const { DEFAULT_CONTRACT } = await import('../../scripts/autonomous/p15-a8-stage-completion.mjs');
|
||||
const currentManifest = Buffer.from(`${DEFAULT_CONTRACT.currentManifest.join('\n')}\n`);
|
||||
assert.equal(digest(currentManifest), DEFAULT_CONTRACT.current.manifestSha256);
|
||||
assert.equal(DEFAULT_CONTRACT.current.loaderName, 'kstuff-p15-a7-route-selection.elf');
|
||||
assert.equal(DEFAULT_CONTRACT.current.readerName, 'kstuff-p15-a7-route-reader.elf');
|
||||
assert.equal(DEFAULT_CONTRACT.next.loaderSha256, '9c1640cd61fdaa99f510f7eea78fe14ed526fc2ced8a78bef80c8bc03fed692f');
|
||||
assert.equal(DEFAULT_CONTRACT.next.readerSha256, 'a2aedb429d2beb3e8bdb273329013db767c674c19d52854e5114dffa78f21699');
|
||||
assert.equal(DEFAULT_CONTRACT.recoverySchema, 'chimera.p15-a8-completion-recovery.v1');
|
||||
});
|
||||
|
||||
test('A8 apply snapshots the full autoload directory and preserves A7 rollback bytes', async t => {
|
||||
const f = await fixture(t);
|
||||
const ftp = new FakeFtp(f.files);
|
||||
const { createPlan, applyPlan, CONFIRMATION } = await import('../../scripts/autonomous/p15-a8-stage-completion.mjs');
|
||||
const plan = await createPlan({ ftp, localRoot: f.local, temporaryRoot: f.temporary, contract: f.contract });
|
||||
const receipt = await applyPlan({
|
||||
ftp, localRoot: f.local, temporaryRoot: f.temporary, backupRoot: f.backup, contract: f.contract,
|
||||
confirmation: CONFIRMATION, planDigest: plan.planDigest, now: () => 1234567890123, nonce: () => 'a8stage1',
|
||||
});
|
||||
assert.equal(receipt.status, 'staged_verified');
|
||||
assert.equal(receipt.consoleMutationPerformed, true);
|
||||
assert.equal(receipt.automaticRebootPerformed, false);
|
||||
assert.equal(receipt.payloadLoadPerformed, false);
|
||||
assert.equal(receipt.launchPerformed, false);
|
||||
assert.deepEqual(ftp.files.get(`${REMOTE_DIR}/a7-loader.elf`), f.oldLoader);
|
||||
assert.deepEqual(ftp.files.get(`${REMOTE_DIR}/a7-reader.elf`), f.oldReader);
|
||||
assert.deepEqual(ftp.files.get(`${REMOTE_DIR}/a8-loader.elf`), f.newLoader);
|
||||
assert.deepEqual(ftp.files.get(`${REMOTE_DIR}/a8-reader.elf`), f.newReader);
|
||||
assert.equal(JSON.parse(fs.readFileSync(path.join(f.backup, receipt.snapshotId, 'meta.json'))).files.length, 5);
|
||||
});
|
||||
|
||||
test('A8 independent readback is read-only and verifies new plus preserved runtime bytes', async t => {
|
||||
const f = await fixture(t);
|
||||
const ftp = new FakeFtp(f.files);
|
||||
const { createPlan, applyPlan, verifyStaged, CONFIRMATION } = await import('../../scripts/autonomous/p15-a8-stage-completion.mjs');
|
||||
const plan = await createPlan({ ftp, localRoot: f.local, temporaryRoot: f.temporary, contract: f.contract });
|
||||
await applyPlan({
|
||||
ftp, localRoot: f.local, temporaryRoot: f.temporary, backupRoot: f.backup, contract: f.contract,
|
||||
confirmation: CONFIRMATION, planDigest: plan.planDigest, now: () => 1234567890123, nonce: () => 'readback',
|
||||
});
|
||||
const writesBefore = ftp.writes;
|
||||
const result = await verifyStaged({ ftp, temporaryRoot: f.temporary, contract: f.contract });
|
||||
assert.equal(result.status, 'verified');
|
||||
assert.equal(result.oldRuntimeArtifactsPreserved, true);
|
||||
assert.equal(result.consoleMutationPerformed, false);
|
||||
assert.equal(ftp.writes, writesBefore);
|
||||
});
|
||||
|
||||
test('A8 refuses confirmation or plan drift before any write', async t => {
|
||||
const f = await fixture(t);
|
||||
const ftp = new FakeFtp(f.files);
|
||||
const { createPlan, applyPlan, CONFIRMATION } = await import('../../scripts/autonomous/p15-a8-stage-completion.mjs');
|
||||
const plan = await createPlan({ ftp, localRoot: f.local, temporaryRoot: f.temporary, contract: f.contract });
|
||||
await assert.rejects(applyPlan({
|
||||
ftp, localRoot: f.local, temporaryRoot: f.temporary, backupRoot: f.backup, contract: f.contract,
|
||||
confirmation: 'wrong', planDigest: plan.planDigest,
|
||||
}), { code: 'P15_A8_STAGE_CONFIRMATION' });
|
||||
await assert.rejects(applyPlan({
|
||||
ftp, localRoot: f.local, temporaryRoot: f.temporary, backupRoot: f.backup, contract: f.contract,
|
||||
confirmation: CONFIRMATION, planDigest: '0'.repeat(64),
|
||||
}), { code: 'P15_A8_STAGE_PLAN_DRIFT' });
|
||||
assert.equal(ftp.writes, 0);
|
||||
});
|
||||
|
||||
test('A8 post-publication drift restores A7 manifest and removes A8 targets', async t => {
|
||||
const f = await fixture(t);
|
||||
const ftp = new FakeFtp(f.files, true);
|
||||
const { createPlan, applyPlan, CONFIRMATION } = await import('../../scripts/autonomous/p15-a8-stage-completion.mjs');
|
||||
const plan = await createPlan({ ftp, localRoot: f.local, temporaryRoot: f.temporary, contract: f.contract });
|
||||
await assert.rejects(applyPlan({
|
||||
ftp, localRoot: f.local, temporaryRoot: f.temporary, backupRoot: f.backup, contract: f.contract,
|
||||
confirmation: CONFIRMATION, planDigest: plan.planDigest, now: () => 1234567890123, nonce: () => 'rollback',
|
||||
}), error => error.code === 'P15_A8_STAGE_POST_VERIFY' && error.details.rolledBack === true);
|
||||
assert.deepEqual(ftp.files.get(`${REMOTE_DIR}/autoload.txt`), f.manifest);
|
||||
assert.equal(ftp.files.has(`${REMOTE_DIR}/a8-loader.elf`), false);
|
||||
assert.equal(ftp.files.has(`${REMOTE_DIR}/a8-reader.elf`), false);
|
||||
});
|
||||
@@ -0,0 +1,183 @@
|
||||
'use strict';
|
||||
|
||||
const assert = require('node:assert/strict');
|
||||
const test = require('node:test');
|
||||
const {
|
||||
createPs5FpkgCompletionTrace,
|
||||
parseKstuffCompletionSnapshot,
|
||||
} = require('../../src/services/ps5-fpkg-completion-trace');
|
||||
|
||||
const OPERATION_ID = '123e4567-e89b-42d3-a456-426614174000';
|
||||
const FINGERPRINT = 'a'.repeat(64);
|
||||
|
||||
function runtime(overrides = {}) {
|
||||
return {
|
||||
product: 'EchoStretch/kstuff-lite',
|
||||
version: '1.10',
|
||||
revision: '3b2ab4a16ba43c8ab38daf7ec83cc297014e0e47',
|
||||
payloadSha256: '1'.repeat(64),
|
||||
loaderSha256: '2'.repeat(64),
|
||||
readerSha256: '3'.repeat(64),
|
||||
observabilityPatchSha256: '4'.repeat(64),
|
||||
observabilityProfile: 'chimera-p15-a8-call-bound-completion-v1',
|
||||
identityStatus: 'exact',
|
||||
traceEligible: true,
|
||||
firmware: '9.60',
|
||||
fingerprint: FINGERPRINT,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function metrics(overrides = {}) {
|
||||
return {
|
||||
nmountDispatch: 0,
|
||||
taggedArmAttempts: 0,
|
||||
taggedReadFailures: 0,
|
||||
taggedPcbPointerFailures: 0,
|
||||
taggedPcbStateFailures: 0,
|
||||
taggedStackPushFailures: 0,
|
||||
taggedPcbSetFailures: 0,
|
||||
taggedWriteFailures: 0,
|
||||
taggedArmSuccesses: 0,
|
||||
taggedRollbackRestoreFailures: 0,
|
||||
taggedFinishEntries: 0,
|
||||
taggedFinishPeekFailures: 0,
|
||||
taggedFinishRestoreFailures: 0,
|
||||
taggedFinishSuccesses: 0,
|
||||
mailboxTotal: 0,
|
||||
mailboxFpkg: 0,
|
||||
mailboxUnhandled: 0,
|
||||
verifySuperBlockMailbox: 0,
|
||||
verifySuperBlockEmulated: 0,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function snapshot(sequence, values) {
|
||||
return { sequence, sampledAt: 1000 + sequence, runtimeFingerprint: FINGERPRINT, metrics: metrics(values) };
|
||||
}
|
||||
|
||||
function trace(postMetrics, overrides = {}) {
|
||||
const post = snapshot(11, postMetrics);
|
||||
return createPs5FpkgCompletionTrace({
|
||||
operationId: OPERATION_ID,
|
||||
titleId: 'FAKE00000',
|
||||
runtime: runtime(),
|
||||
pre: snapshot(10, metrics()),
|
||||
post,
|
||||
postStable: snapshot(12, postMetrics),
|
||||
postStableConfirm: snapshot(13, postMetrics),
|
||||
launchRequests: 1,
|
||||
retryPerformed: false,
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
test('A8 strict parser accepts the exact semantic completion schema', () => {
|
||||
const parsed = parseKstuffCompletionSnapshot([
|
||||
'schema=chimera.p15-a8-completion-metrics.v1',
|
||||
'status=attached',
|
||||
'sample=8',
|
||||
'route nmount_dispatch=7',
|
||||
'completion tag=nmount attempt=7 read_fail=0 pcb_ptr_fail=0 pcb_state_fail=0 stack_fail=0 pcb_set_fail=0 write_fail=0 armed=7 rollback_restore_fail=0 finish_entry=7 finish_peek_fail=1 finish_restore_fail=0 finish=6',
|
||||
'mailbox total=4 fpkg=3 unhandled=1',
|
||||
'fpkg verify=3 verify_emu=3',
|
||||
].join('\n'), { sequence: 8, sampledAt: 1008, runtimeFingerprint: FINGERPRINT });
|
||||
assert.equal(parsed.metrics.taggedArmSuccesses, 7);
|
||||
assert.equal(parsed.metrics.taggedFinishEntries, 7);
|
||||
assert.equal(parsed.metrics.taggedFinishPeekFailures, 1);
|
||||
});
|
||||
|
||||
test('A8 parser rejects raw data, unknown tags, duplicate fields and sample drift', () => {
|
||||
const base = [
|
||||
'schema=chimera.p15-a8-completion-metrics.v1', 'status=attached', 'sample=8',
|
||||
'route nmount_dispatch=1',
|
||||
'completion tag=nmount attempt=1 read_fail=0 pcb_ptr_fail=0 pcb_state_fail=0 stack_fail=0 pcb_set_fail=0 write_fail=0 armed=1 rollback_restore_fail=0 finish_entry=1 finish_peek_fail=0 finish_restore_fail=0 finish=1',
|
||||
'mailbox total=0 fpkg=0 unhandled=0', 'fpkg verify=0 verify_emu=0',
|
||||
];
|
||||
for (const unsafe of [
|
||||
[...base, 'request_ptr=0xffff800012345678'],
|
||||
base.map(line => line.startsWith('completion ') ? line.replace('tag=nmount', 'tag=ioctl') : line),
|
||||
base.map(line => line.startsWith('completion ') ? `${line} finish=1` : line),
|
||||
base.map(line => line === 'sample=8' ? 'sample=9' : line),
|
||||
]) {
|
||||
assert.throws(
|
||||
() => parseKstuffCompletionSnapshot(unsafe.join('\n'), { sequence: 8, sampledAt: 1008, runtimeFingerprint: FINGERPRINT }),
|
||||
error => error.code === 'PS5_FPKG_COMPLETION_TRACE_UNSAFE_LINE',
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('A8 proves a balanced call-bound arm and successful finalization', () => {
|
||||
const result = trace(metrics({
|
||||
nmountDispatch: 1, taggedArmAttempts: 1, taggedArmSuccesses: 1,
|
||||
taggedFinishEntries: 1, taggedFinishSuccesses: 1,
|
||||
}));
|
||||
assert.equal(result.status, 'observed');
|
||||
assert.equal(result.lifecycle.attribution, 'call-bound-nmount');
|
||||
assert.equal(result.lifecycle.unresolvedArms, 0);
|
||||
assert.equal(result.recovery.debugRegistersRestored, true);
|
||||
assert.equal(result.normalizedFailure, 'PS5_FPKG_MAILBOX_TARGET_NOT_REACHED');
|
||||
});
|
||||
|
||||
test('A8 makes a post-arm stack peek failure terminal and visible', () => {
|
||||
const result = trace(metrics({
|
||||
nmountDispatch: 1, taggedArmAttempts: 1, taggedArmSuccesses: 1,
|
||||
taggedFinishEntries: 1, taggedFinishPeekFailures: 1,
|
||||
}));
|
||||
assert.equal(result.status, 'observed');
|
||||
assert.equal(result.firstFailedStage, 'debug_register_finish_stack_peek_failed');
|
||||
assert.equal(result.normalizedFailure, 'PS5_FPKG_DEBUG_REGISTER_FINISH_PEEK_FAILED');
|
||||
assert.equal(result.lifecycle.unresolvedArms, 0);
|
||||
assert.equal(result.recovery.debugRegistersRestored, false);
|
||||
});
|
||||
|
||||
test('A8 qualifies a completion that settles after the immediate post sample', () => {
|
||||
const complete = metrics({
|
||||
nmountDispatch: 1, taggedArmAttempts: 1, taggedArmSuccesses: 1,
|
||||
taggedFinishEntries: 1, taggedFinishSuccesses: 1,
|
||||
});
|
||||
const result = trace(complete, {
|
||||
post: snapshot(11, metrics({ nmountDispatch: 1, taggedArmAttempts: 1, taggedArmSuccesses: 1 })),
|
||||
postStable: snapshot(12, complete),
|
||||
postStableConfirm: snapshot(13, complete),
|
||||
});
|
||||
assert.equal(result.status, 'observed');
|
||||
assert.equal(result.postArmSettled, true);
|
||||
assert.equal(result.lifecycle.unresolvedArms, 0);
|
||||
});
|
||||
|
||||
test('A8 fails closed on missing or contradictory call-bound terminal events', () => {
|
||||
const missing = trace(metrics({ nmountDispatch: 1, taggedArmAttempts: 1, taggedArmSuccesses: 1 }));
|
||||
assert.equal(missing.status, 'outcome_unknown');
|
||||
assert.equal(missing.outcomeReason, 'call_bound_finalization_missing');
|
||||
assert.equal(missing.lifecycle.unresolvedArms, 1);
|
||||
|
||||
const contradictory = trace(metrics({
|
||||
nmountDispatch: 1, taggedArmAttempts: 1, taggedArmSuccesses: 1,
|
||||
taggedFinishEntries: 1, taggedFinishPeekFailures: 1, taggedFinishSuccesses: 1,
|
||||
}));
|
||||
assert.equal(contradictory.status, 'outcome_unknown');
|
||||
assert.equal(contradictory.outcomeReason, 'finish_accounting_invariant_failed');
|
||||
});
|
||||
|
||||
test('A8 never upgrades the immutable A7 five-count mismatch into a kernel failure claim', () => {
|
||||
const result = trace(metrics({
|
||||
nmountDispatch: 85, taggedArmAttempts: 85, taggedArmSuccesses: 85,
|
||||
taggedFinishEntries: 80, taggedFinishSuccesses: 80,
|
||||
}));
|
||||
assert.equal(result.status, 'outcome_unknown');
|
||||
assert.equal(result.outcomeReason, 'launch_scope_ambiguous');
|
||||
assert.equal(result.legacyA7InferenceAllowed, false);
|
||||
});
|
||||
|
||||
test('A8 does not attribute unrelated global mailbox activity to the nmount', () => {
|
||||
const result = trace(metrics({
|
||||
nmountDispatch: 1, taggedArmAttempts: 1, taggedArmSuccesses: 1,
|
||||
taggedFinishEntries: 1, taggedFinishSuccesses: 1,
|
||||
mailboxTotal: 1, mailboxUnhandled: 1,
|
||||
}));
|
||||
assert.equal(result.status, 'outcome_unknown');
|
||||
assert.equal(result.outcomeReason, 'mailbox_scope_ambiguous');
|
||||
assert.equal(result.normalizedFailure, null);
|
||||
});
|
||||
@@ -0,0 +1,139 @@
|
||||
'use strict';
|
||||
|
||||
const assert = require('node:assert/strict');
|
||||
const test = require('node:test');
|
||||
const { createPs5FpkgControlCandidateService } = require('../../src/services/ps5-fpkg-control-candidate-service');
|
||||
|
||||
const HASH = character => character.repeat(64);
|
||||
|
||||
function launchVerifiedOperation(overrides = {}) {
|
||||
return {
|
||||
id: 'operation-qualified', titleId: 'PPSA01234', packageSetId: 12,
|
||||
status: 'launch_verified', launchVerified: true, productionRestorable: true,
|
||||
packageIdentitySha256: HASH('1'), installedObservedAt: 1_500, updatedAt: 12_200,
|
||||
evidence: { source: 'post-install-console-scan', scanRunId: 'scan-qualified' },
|
||||
components: [{ componentId: 'base', type: 'base', sha256: HASH('2'), size: 4096 }],
|
||||
launchEvidence: {
|
||||
schema: 'chimera.ps5-fpkg-launch-evidence.v2', runtimeFingerprint: HASH('3'),
|
||||
preflightCompletedAt: 1_000, requestedAt: 2_000,
|
||||
runtime: {
|
||||
ready: true,
|
||||
capabilities: {
|
||||
debugPackageRuntime: true, fpkgEnableActive: true, kernelPatchReady: true,
|
||||
packageInstallerReady: true, launchServiceReady: true,
|
||||
processObservationReady: true, syslogObservationReady: true, foregroundClean: true,
|
||||
},
|
||||
runtime: { foregroundGameProcesses: [] },
|
||||
artifacts: [{ componentId: 'base', titleId: 'PPSA01234', sha256: HASH('2'), size: 4096 }],
|
||||
},
|
||||
request: { accepted: true, titleId: 'PPSA01234', route: '/api/ps5/app/launch', clientAutomaticRetry: false },
|
||||
boundaries: { verifyImage: 'passed', pfsMount: 'passed', executable: 'reached', process: 'stable', basis: 'stable-exact-title-process' },
|
||||
rawEvidence: [
|
||||
{ observedAt: 2_100, summary: { observed: true, processListTruncated: false, process: { titleId: 'PPSA01234', pid: 321 } } },
|
||||
{ observedAt: 12_100, summary: { observed: true, processListTruncated: false, process: { titleId: 'PPSA01234', pid: 321 } } },
|
||||
],
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function installedObservedOperation(overrides = {}) {
|
||||
return {
|
||||
id: 'operation-installed', titleId: 'PPSA05678', packageSetId: 56,
|
||||
status: 'installed_observed', packageIdentitySha256: HASH('5'),
|
||||
installedObservedAt: 1_400, updatedAt: 1_450,
|
||||
evidence: { source: 'post-install-console-scan', scanRunId: 'scan-installed' },
|
||||
components: [{ componentId: 'base', type: 'base', sha256: HASH('6'), size: 4096 }],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function databaseFixture() {
|
||||
const calls = [];
|
||||
return {
|
||||
calls,
|
||||
runQuery(sql) {
|
||||
calls.push(sql);
|
||||
if (sql.includes('FROM console_installs')) return [
|
||||
{ title_id: 'PPSA01234', name: 'Working homebrew', installed_version: '1.00', install_state: 'observed', format: 'ps5-fih-debug-package', last_seen_at: 1_500 },
|
||||
{ title_id: 'PPSA05678', name: 'Installed unknown', installed_version: '1.00', install_state: 'observed', format: 'ps5-fih-debug-package', last_seen_at: 1_400 },
|
||||
];
|
||||
if (sql.includes('FROM package_sets')) return [
|
||||
{ package_set_id: 90, title_id: 'PPSA09999', name: 'Local candidate', platform: 'ps5', manifest_json: JSON.stringify({ packageFormat: { container: 'FIH' } }), component_id: 900, component_type: 'base', size_bytes: 8192, sha256: HASH('4'), metadata_json: JSON.stringify({ absolutePath: 'C:\\private\\candidate.pkg' }) },
|
||||
];
|
||||
throw new Error(`Unexpected query: ${sql}`);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('cached candidate report ranks qualified, installed and install-preflight lanes without a scan', () => {
|
||||
const db = databaseFixture();
|
||||
const service = createPs5FpkgControlCandidateService({
|
||||
db, listOperations: () => [launchVerifiedOperation(), installedObservedOperation()], now: () => 20_000,
|
||||
});
|
||||
const report = service.listCached();
|
||||
assert.equal(report.schema, 'chimera.p15-a8-control-candidates.v1');
|
||||
assert.equal(report.source, 'cached-only-no-console-scan');
|
||||
assert.equal(report.counts.qualifiedFullRoute, 1);
|
||||
assert.equal(report.counts.installedNeedsQualification, 1);
|
||||
assert.equal(report.counts.installPreflightRequired, 1);
|
||||
assert.equal(report.candidates[0].titleId, 'PPSA01234');
|
||||
assert.equal(report.candidates[0].state, 'qualified_full_fpkg_route');
|
||||
assert.match(report.candidates[0].qualificationDigest, /^[a-f0-9]{64}$/);
|
||||
assert.equal(report.candidates.find(item => item.titleId === 'PPSA05678').state, 'installed_launch_qualification_required');
|
||||
assert.equal(report.candidates.find(item => item.titleId === 'PPSA09999').state, 'install_preflight_required');
|
||||
assert.equal(report.grantsLiveAction, false);
|
||||
assert.equal(JSON.stringify(report).includes('C:\\private'), false);
|
||||
assert.equal(db.calls.length, 2);
|
||||
});
|
||||
|
||||
test('same-title console presence without artifact-bound install evidence is rejected', () => {
|
||||
const db = databaseFixture();
|
||||
const report = createPs5FpkgControlCandidateService({
|
||||
db, listOperations: () => [launchVerifiedOperation()], now: () => 20_000,
|
||||
}).listCached();
|
||||
const candidate = report.candidates.find(item => item.titleId === 'PPSA05678');
|
||||
assert.equal(candidate.installed, true);
|
||||
assert.equal(candidate.state, 'installed_launch_evidence_rejected');
|
||||
assert.equal(candidate.reasonCode, 'P15_A8_CONTROL_INSTALLED_ARTIFACT_IDENTITY_UNPROVEN');
|
||||
assert.equal(candidate.nextAction, 'select-distinct-title-or-establish-artifact-bound-install-evidence');
|
||||
assert.equal(report.counts.installedNeedsQualification, 0);
|
||||
assert.equal(report.counts.rejected, 1);
|
||||
});
|
||||
|
||||
test('post-scan evidence must identify the same cached console observation', () => {
|
||||
const db = databaseFixture();
|
||||
const stale = installedObservedOperation({ installedObservedAt: 1_399 });
|
||||
const report = createPs5FpkgControlCandidateService({
|
||||
db, listOperations: () => [launchVerifiedOperation(), stale], now: () => 20_000,
|
||||
}).listCached();
|
||||
const candidate = report.candidates.find(item => item.titleId === 'PPSA05678');
|
||||
assert.equal(candidate.state, 'installed_launch_evidence_rejected');
|
||||
assert.equal(candidate.reasonCode, 'P15_A8_CONTROL_INSTALLED_ARTIFACT_IDENTITY_UNPROVEN');
|
||||
});
|
||||
|
||||
test('launch success flags do not qualify mismatched or incomplete artifact evidence', () => {
|
||||
const db = databaseFixture();
|
||||
const mismatched = launchVerifiedOperation();
|
||||
mismatched.launchEvidence.runtime.artifacts[0].sha256 = HASH('9');
|
||||
const report = createPs5FpkgControlCandidateService({
|
||||
db, listOperations: () => [mismatched], now: () => 20_000,
|
||||
}).listCached();
|
||||
const candidate = report.candidates.find(item => item.titleId === 'PPSA01234');
|
||||
assert.equal(candidate.state, 'installed_launch_evidence_rejected');
|
||||
assert.equal(candidate.reasonCode, 'P15_A8_CONTROL_ARTIFACT_EVIDENCE_MISMATCH');
|
||||
assert.equal(candidate.qualificationDigest, null);
|
||||
});
|
||||
|
||||
test('candidate report rejects corrupt cached rows and never invents installed state', () => {
|
||||
const db = {
|
||||
runQuery(sql) {
|
||||
if (sql.includes('FROM console_installs')) return [{ title_id: 'not-a-title', install_state: 'observed' }];
|
||||
if (sql.includes('FROM package_sets')) return [{ package_set_id: 1, title_id: 'PPSA12345', platform: 'ps5', manifest_json: '{broken' }];
|
||||
return [];
|
||||
},
|
||||
};
|
||||
const report = createPs5FpkgControlCandidateService({ db, listOperations: () => [] }).listCached();
|
||||
assert.deepEqual(report.candidates, []);
|
||||
assert.deepEqual(report.counts, { qualifiedFullRoute: 0, installedNeedsQualification: 0, installPreflightRequired: 0, rejected: 0 });
|
||||
});
|
||||
@@ -0,0 +1,122 @@
|
||||
'use strict';
|
||||
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const test = require('node:test');
|
||||
const { assessPs5FpkgControlMetadata, validateParam } = require('../../src/services/ps5-fpkg-control-metadata-assessment');
|
||||
const { A8_DISTINCT_CONTROL } = require('../../src/services/ps5-native-pkg-builder-provider');
|
||||
|
||||
const TITLE_ID = A8_DISTINCT_CONTROL.titleId;
|
||||
const CONTENT_ID = A8_DISTINCT_CONTROL.contentId;
|
||||
|
||||
function metadata(overrides = {}) {
|
||||
return {
|
||||
applicationCategoryType: 0,
|
||||
applicationDrmType: 'free',
|
||||
contentBadgeType: 2,
|
||||
contentId: CONTENT_ID,
|
||||
contentVersion: '01.000.000',
|
||||
localizedParameters: { defaultLanguage: 'en-US', 'en-US': { titleName: 'Chimera metadata control' } },
|
||||
masterVersion: '01.00',
|
||||
requiredSystemSoftwareVersion: '0x0200000000000000',
|
||||
sdkVersion: '0x0200000000000000',
|
||||
titleId: TITLE_ID,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function fihFixture(t, param = metadata(), mutate = null) {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'chimera-control-metadata-'));
|
||||
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
const raw = Buffer.from(JSON.stringify(param));
|
||||
const cntOffset = 0x100;
|
||||
const entryTableOffset = 0x520;
|
||||
const dataOffset = 0x600;
|
||||
const bytes = Buffer.alloc(cntOffset + dataOffset + raw.length);
|
||||
Buffer.from([0x7f, 0x46, 0x49, 0x48]).copy(bytes);
|
||||
bytes.writeBigUInt64LE(BigInt(cntOffset), 0x58);
|
||||
Buffer.from([0x7f, 0x43, 0x4e, 0x54]).copy(bytes, cntOffset);
|
||||
bytes.writeUInt32BE(1, cntOffset + 0x10);
|
||||
bytes.writeUInt32BE(entryTableOffset, cntOffset + 0x18);
|
||||
const entry = cntOffset + entryTableOffset;
|
||||
bytes.writeUInt32BE(0x2000, entry);
|
||||
bytes.writeUInt32BE(dataOffset, entry + 0x10);
|
||||
bytes.writeUInt32BE(raw.length, entry + 0x14);
|
||||
raw.copy(bytes, cntOffset + dataOffset);
|
||||
if (mutate) mutate(bytes, { cntOffset, entry });
|
||||
const filename = path.join(root, 'control.pkg');
|
||||
fs.writeFileSync(filename, bytes);
|
||||
return filename;
|
||||
}
|
||||
|
||||
test('canonical distinct-control metadata qualifies without console or launch inference', t => {
|
||||
const filename = fihFixture(t);
|
||||
const result = assessPs5FpkgControlMetadata({ sourcePath: filename, expected: { titleId: TITLE_ID, contentId: CONTENT_ID } });
|
||||
assert.equal(result.status, 'metadata_qualified');
|
||||
assert.equal(result.qualified, true);
|
||||
assert.deepEqual(result.issues, []);
|
||||
assert.equal(result.consoleContacted, false);
|
||||
assert.equal(result.consoleMutationPerformed, false);
|
||||
assert.equal(result.installedInferred, false);
|
||||
assert.equal(result.launchInferred, false);
|
||||
});
|
||||
|
||||
test('frozen CHIM00001 artifact is rejected for the actual two-component contentVersion', () => {
|
||||
const filename = path.resolve('assets/ps5-controls/a8/UP9000-CHIM00001_00-A8CONTROL0000001-A0100-V0100.pkg');
|
||||
const result = assessPs5FpkgControlMetadata({
|
||||
sourcePath: filename,
|
||||
expected: { titleId: 'CHIM00001', contentId: 'UP9000-CHIM00001_00-A8CONTROL0000001' },
|
||||
});
|
||||
assert.equal(result.status, 'metadata_rejected');
|
||||
assert.equal(result.qualified, false);
|
||||
assert.equal(result.metadata.contentVersion, '01.00');
|
||||
assert.deepEqual(result.issues.find(item => item.field === 'contentVersion'), {
|
||||
field: 'contentVersion', expected: 'MM.mmm.ppp', actual: '01.00',
|
||||
});
|
||||
});
|
||||
|
||||
test('frozen CHIM00002 canonical control passes the bounded launch-metadata contract', () => {
|
||||
const filename = path.resolve('assets', 'ps5-controls', 'a9', A8_DISTINCT_CONTROL.selectedArtifact.filename);
|
||||
const result = assessPs5FpkgControlMetadata({
|
||||
sourcePath: filename,
|
||||
expected: { titleId: A8_DISTINCT_CONTROL.titleId, contentId: A8_DISTINCT_CONTROL.contentId },
|
||||
});
|
||||
assert.equal(result.status, 'metadata_qualified');
|
||||
assert.equal(result.qualified, true);
|
||||
assert.equal(result.metadata.titleId, 'CHIM00002');
|
||||
assert.equal(result.metadata.contentVersion, '01.000.000');
|
||||
assert.equal(result.metadata.masterVersion, '01.00');
|
||||
assert.equal(result.metadata.applicationDrmType, 'free');
|
||||
assert.deepEqual(result.issues, []);
|
||||
assert.equal(result.consoleContacted, false);
|
||||
assert.equal(result.installedInferred, false);
|
||||
assert.equal(result.launchInferred, false);
|
||||
});
|
||||
|
||||
test('metadata qualification rejects identity, launch fields and incomplete localization independently', () => {
|
||||
const issues = validateParam(metadata({
|
||||
titleId: 'WRONG0001', contentId: 'wrong', masterVersion: '1', applicationCategoryType: 1,
|
||||
applicationDrmType: 'paid', contentBadgeType: 0, requiredSystemSoftwareVersion: '0x2', sdkVersion: null,
|
||||
localizedParameters: { defaultLanguage: 'en-US', 'en-US': {} },
|
||||
}), { titleId: TITLE_ID, contentId: CONTENT_ID });
|
||||
for (const field of [
|
||||
'titleId', 'contentId', 'masterVersion', 'applicationCategoryType', 'applicationDrmType',
|
||||
'contentBadgeType', 'requiredSystemSoftwareVersion', 'sdkVersion', 'localizedParameters',
|
||||
]) assert.ok(issues.some(item => item.field === field), `missing ${field} issue`);
|
||||
});
|
||||
|
||||
test('metadata reader fails closed on encrypted, duplicate and out-of-range param entries', t => {
|
||||
const encrypted = fihFixture(t, metadata(), (bytes, { entry }) => bytes.writeUInt32BE(0x80000000, entry + 0x08));
|
||||
assert.throws(() => assessPs5FpkgControlMetadata({ sourcePath: encrypted }), { code: 'PS5_FPKG_CONTROL_METADATA_PARAM_ENCRYPTED' });
|
||||
|
||||
const duplicate = fihFixture(t, metadata(), (bytes, { cntOffset, entry }) => {
|
||||
bytes.writeUInt32BE(2, cntOffset + 0x10);
|
||||
bytes.copy(bytes, entry + 0x20, entry, entry + 0x20);
|
||||
});
|
||||
assert.throws(() => assessPs5FpkgControlMetadata({ sourcePath: duplicate }), { code: 'PS5_FPKG_CONTROL_METADATA_PARAM_COUNT' });
|
||||
|
||||
const outOfRange = fihFixture(t, metadata(), (bytes, { entry }) => bytes.writeUInt32BE(0xffffffff, entry + 0x14));
|
||||
assert.throws(() => assessPs5FpkgControlMetadata({ sourcePath: outOfRange }), { code: 'PS5_FPKG_CONTROL_METADATA_PARAM_SIZE' });
|
||||
});
|
||||
Loaded 100 of 531 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user