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