diff --git a/docs/design/pro2-resource-update/README.md b/docs/design/pro2-resource-update/README.md index 0961d7879..cdc32be54 100644 --- a/docs/design/pro2-resource-update/README.md +++ b/docs/design/pro2-resource-update/README.md @@ -26,6 +26,15 @@ vol0:/loaders/bootloader/boot_resource.okpkg.staging A ZIP cannot contain two packages that declare the same device path. ZIP entry names are only used for logs and display; they do not derive the device path. +## Verification result + +When resources are requested, `firmwareUpdateV4` returns `resourceVerification: "header-verified"` +after successful completion. Unchanged files must match the target header; written files are read +back in loader mode and must match size, version, payload-hash and header-hash fields, including +forced updates and staging files. Resource write or read-back failures carry +`params.resourceVerification: "failed"`. Absence of the result field is not verification evidence. +This verifies package headers, not a fresh hash of the entire on-device payload or runtime mounting. + ## Data flow ```mermaid diff --git a/packages/core/__tests__/protocol-v2.test.ts b/packages/core/__tests__/protocol-v2.test.ts index 074c20873..e6006b89f 100644 --- a/packages/core/__tests__/protocol-v2.test.ts +++ b/packages/core/__tests__/protocol-v2.test.ts @@ -7256,7 +7256,10 @@ describe('Protocol V2 firmware update targets', () => { (method as any).waitForProtocolV2FirmwareUpdateComplete = jest .fn() .mockResolvedValue(undefined); - (method as any).isProtocolV2ResourceBundleUpToDate = jest.fn().mockResolvedValue(false); + (method as any).isProtocolV2ResourceBundleUpToDate = jest + .fn() + .mockResolvedValueOnce(false) + .mockResolvedValue(true); await (method as any).executeProtocolV2SourceUpdate({ resourceSources: [ @@ -8618,6 +8621,10 @@ describe('Protocol V2 firmware update targets', () => { method.postTipMessage = jest.fn(); method.postProgressMessage = jest.fn(); + (method as any).isProtocolV2ResourceBundleUpToDate = jest + .fn() + .mockResolvedValueOnce(false) + .mockResolvedValue(true); (method as any).protocolV2SourceUpdateProcess = jest.fn().mockResolvedValue(3); (method as any).enterProtocolV2BootloaderMode = jest.fn().mockResolvedValue(undefined); (method as any).ensureProtocolV2BootResourceStagingIsEmpty = jest @@ -8682,6 +8689,10 @@ describe('Protocol V2 firmware update targets', () => { method.postTipMessage = jest.fn(); method.postProgressMessage = jest.fn(); + (method as any).isProtocolV2ResourceBundleUpToDate = jest + .fn() + .mockResolvedValueOnce(false) + .mockResolvedValue(true); (method as any).protocolV2SourceUpdateProcess = jest.fn().mockResolvedValue(3); (method as any).enterProtocolV2BootloaderMode = jest.fn().mockResolvedValue(undefined); (method as any).ensureProtocolV2BootResourceStagingIsEmpty = jest @@ -9060,7 +9071,10 @@ describe('Protocol V2 firmware update targets', () => { method.postMessage = jest.fn(); method.postTipMessage = jest.fn(); method.postProgressMessage = jest.fn(); - (method as any).isProtocolV2ResourceBundleUpToDate = jest.fn().mockResolvedValue(false); + (method as any).isProtocolV2ResourceBundleUpToDate = jest + .fn() + .mockResolvedValueOnce(false) + .mockResolvedValue(true); (method as any).verifyProtocolV2StagedFile = jest.fn().mockResolvedValue(undefined); (method as any).protocolV2StartFirmwareUpdate = jest.fn().mockResolvedValue(undefined); (method as any).waitForProtocolV2FirmwareUpdateComplete = jest @@ -10110,6 +10124,72 @@ describe('Protocol V2 firmware reconnect identity', () => { expect((method as any).protocolV2SourceUpdateProcess).not.toHaveBeenCalled(); }); + test.each([true, false])( + 'requires matching resource read-back after writing (matches=%s)', + async matches => { + const method = new FirmwareUpdateV4({ + id: 1, + payload: { method: 'firmwareUpdateV4', platform: 'web', forcedUpdateRes: true }, + }); + method.init(); + const installed = new Uint8Array(createProtocolV2OkppBinary()); + const expected = installed.slice(); + expected.fill(0x33, 0x200, 0x240); + expected.fill(0x44, 0x240, 0x280); + let written = false; + const typedCall = jest.fn( + (name: string, _type: string, request: { file: { offset: number }; chunk_len: number }) => { + const data = written && matches ? expected : installed; + if (name === 'FilesystemPathInfoQuery') { + return Promise.resolve({ message: { exist: true, size: data.length } }); + } + if (name === 'FilesystemFileRead') { + return Promise.resolve({ + message: { + data: data.slice(request.file.offset, request.file.offset + request.chunk_len), + }, + }); + } + throw new Error(`Unexpected request: ${name}`); + } + ); + (method as any).device = stubDevice({ getCommands: () => ({ typedCall }) }); + method.postTipMessage = jest.fn(); + method.postProgressMessage = jest.fn(); + (method as any).enterProtocolV2BootloaderMode = jest.fn(); + (method as any).ensureProtocolV2BootResourceStagingIsEmpty = jest.fn(); + const complete = jest.fn().mockResolvedValue({ firmwareVersion: '1.0.1' }); + (method as any).completeProtocolV2FinalVerification = complete; + (method as any).protocolV2SourceUpdateProcess = jest.fn(() => { + written = true; + return Promise.resolve(expected.length); + }); + const result = (method as any).executeProtocolV2SourceUpdate({ + installSources: [], + resourceSources: [ + { + name: 'bootloader_params.okpkg', + source: { size: expected.length }, + devicePath: 'vol0:/loaders/bootloader_params.okpkg.staging', + version: [1, 2, 3], + payloadHash: '33'.repeat(64), + headerHash: '44'.repeat(64), + }, + ], + }); + if (matches) { + await expect(result).resolves.toMatchObject({ resourceVerification: 'header-verified' }); + } else { + await expect(result).rejects.toMatchObject({ params: { resourceVerification: 'failed' } }); + expect(complete).not.toHaveBeenCalled(); + } + expect(typedCall).toHaveBeenCalledWith('FilesystemPathInfoQuery', 'FilesystemPathInfo', { + path: 'vol0:/loaders/bootloader_params.okpkg.staging', + }); + expect(typedCall.mock.calls.some(([name]) => name === 'FilesystemFileRead')).toBe(true); + } + ); + test('updates a resource when the installed file size differs despite matching headers', async () => { const method = new FirmwareUpdateV4({ id: 1, diff --git a/packages/core/src/api/FirmwareUpdateV4.ts b/packages/core/src/api/FirmwareUpdateV4.ts index 3cec964d2..f55747f8f 100644 --- a/packages/core/src/api/FirmwareUpdateV4.ts +++ b/packages/core/src/api/FirmwareUpdateV4.ts @@ -1838,19 +1838,22 @@ export default class FirmwareUpdateV4 extends FirmwareUpdateBaseMethod + >, + verifyWrittenFile = false ): Promise { - if (this.params?.forcedUpdateRes) return false; + if (!verifyWrittenFile && this.params?.forcedUpdateRes) return false; if (!bundle.payloadHash || !bundle.headerHash) return false; try { const header = await this.readProtocolV2DeviceFileHeader( - this.getProtocolV2ResourceComparePath(bundle.devicePath), + verifyWrittenFile + ? bundle.devicePath + : this.getProtocolV2ResourceComparePath(bundle.devicePath), bundle.source.size ); if (!header) return false; @@ -2040,7 +2043,11 @@ export default class FirmwareUpdateV4 extends FirmwareUpdateBaseMethod 0 ? { resourceVerification: 'header-verified' as const } : {}), + }; } private async ensureProtocolV2BootResourceStagingIsEmpty() { @@ -2130,14 +2137,33 @@ export default class FirmwareUpdateV4 extends FirmwareUpdateBaseMethod;