'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'); } });