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