Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions docs/design/pro2-resource-update/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
84 changes: 82 additions & 2 deletions packages/core/__tests__/protocol-v2.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
52 changes: 39 additions & 13 deletions packages/core/src/api/FirmwareUpdateV4.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1838,19 +1838,22 @@ export default class FirmwareUpdateV4 extends FirmwareUpdateBaseMethod<FirmwareU
}
}

/** Compare the downloaded and installed okpkg headers before transferring a resource. */
/** Compare package headers before transfer or read back the written resource. */
private async isProtocolV2ResourceBundleUpToDate(
bundle: Pick<
ProtocolV2ResourceBundleSource,
'name' | 'source' | 'devicePath' | 'version' | 'payloadHash' | 'headerHash'
>
>,
verifyWrittenFile = false
): Promise<boolean> {
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;
Expand Down Expand Up @@ -2040,7 +2043,11 @@ export default class FirmwareUpdateV4 extends FirmwareUpdateBaseMethod<FirmwareU
resourceSources,
});
}
return this.completeProtocolV2FinalVerification();
const versions = await this.completeProtocolV2FinalVerification();
return {
...versions,
...(resourceSources.length > 0 ? { resourceVerification: 'header-verified' as const } : {}),
};
}

private async ensureProtocolV2BootResourceStagingIsEmpty() {
Expand Down Expand Up @@ -2130,14 +2137,33 @@ export default class FirmwareUpdateV4 extends FirmwareUpdateBaseMethod<FirmwareU
// The bootloader keeps its live resource package mounted. FatFs rejects
// replacing an open file, so early boot promotes this staging file before mounting it.
const writePath = resource.devicePath;
processedSize = await this.protocolV2SourceUpdateProcess({
source: resource.source,
filePath: writePath,
processedSize,
totalSize,
transferStartedAt,
});
await this.verifyProtocolV2StagedFile(writePath, resource.source.size);
try {
processedSize = await this.protocolV2SourceUpdateProcess({
source: resource.source,
filePath: writePath,
processedSize,
totalSize,
transferStartedAt,
});
await this.verifyProtocolV2StagedFile(writePath, resource.source.size);
// Read the written file, including bootloader staging, even for forced updates.
if (!(await this.isProtocolV2ResourceBundleUpToDate(resource, true))) {
throw ERRORS.TypedError(
HardwareErrorCode.EmmcFileWriteFirmwareError,
'Protocol V2 written resource header does not match the update package'
);
}
} catch (error) {
const failure =
error instanceof HardwareError
? error
: ERRORS.TypedError(
HardwareErrorCode.EmmcFileWriteFirmwareError,
getProtocolV2UnknownErrorText(error)
);
failure.params = { ...failure.params, resourceVerification: 'failed' };
throw failure;
}
if (isProtocolV2BootResourcePackagePath(resource.devicePath)) {
this.protocolV2BootResourceStagingSafe = true;
}
Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/types/api/firmwareUpdate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -194,4 +194,6 @@ export declare function firmwareUpdateV4(
bleVersion: string;
firmwareVersion: string;
bootloaderVersion: string;
/** Requested resource headers match; does not attest payload read-back or runtime mounting. */
resourceVerification?: 'header-verified';
}>;
Loading