@onekeyfe/hd-core 1.2.0-alpha.97 → 1.2.0-alpha.99

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (103) hide show
  1. package/__tests__/check-all-firmware-release-protocol-v2.test.ts +411 -86
  2. package/__tests__/check-firmware-release-protocol-v2.test.ts +199 -0
  3. package/__tests__/firmware-memory-host.test.ts +126 -0
  4. package/__tests__/firmware-update/device-update-bootloader.test.ts +14 -2
  5. package/__tests__/firmware-update/firmware-host-binding.test.ts +9 -0
  6. package/__tests__/firmware-update/firmware-prepared-host-digest.test.ts +573 -0
  7. package/__tests__/firmware-update/firmware-prepared-resource-archive.test.ts +150 -0
  8. package/__tests__/firmware-update/firmware-update-bootloader-poll.test.ts +65 -2
  9. package/__tests__/firmware-update/firmware-update-plan-bootloader-identity.test.ts +80 -0
  10. package/__tests__/firmware-update/firmware-update-plan.test.ts +293 -71
  11. package/__tests__/firmware-update/firmware-update-prepared-plan.test.ts +53 -1
  12. package/__tests__/firmware-update/firmware-update-prepared-resource-executors.test.ts +326 -0
  13. package/__tests__/firmware-update/firmware-update-v2-download-before-boot.test.ts +2 -2
  14. package/__tests__/protocol-v2-firmware-targets.test.ts +16 -0
  15. package/__tests__/protocol-v2-resources.test.ts +148 -232
  16. package/__tests__/protocol-v2.test.ts +1326 -510
  17. package/dist/api/CheckAllFirmwareRelease.d.ts +2 -3
  18. package/dist/api/CheckAllFirmwareRelease.d.ts.map +1 -1
  19. package/dist/api/CheckBLEFirmwareRelease.d.ts +3 -2
  20. package/dist/api/CheckBLEFirmwareRelease.d.ts.map +1 -1
  21. package/dist/api/CheckBootloaderRelease.d.ts +2 -1
  22. package/dist/api/CheckBootloaderRelease.d.ts.map +1 -1
  23. package/dist/api/CheckFirmwareRelease.d.ts +3 -2
  24. package/dist/api/CheckFirmwareRelease.d.ts.map +1 -1
  25. package/dist/api/FirmwareUpdateV2.d.ts +1 -0
  26. package/dist/api/FirmwareUpdateV2.d.ts.map +1 -1
  27. package/dist/api/FirmwareUpdateV3.d.ts.map +1 -1
  28. package/dist/api/FirmwareUpdateV4.d.ts +11 -9
  29. package/dist/api/FirmwareUpdateV4.d.ts.map +1 -1
  30. package/dist/api/UploadPortfolio.d.ts.map +1 -1
  31. package/dist/api/device/DeviceUpdateBootloader.d.ts.map +1 -1
  32. package/dist/api/firmware/FirmwareHostBinding.d.ts +1 -1
  33. package/dist/api/firmware/FirmwareHostBinding.d.ts.map +1 -1
  34. package/dist/api/firmware/FirmwareMemoryHost.d.ts +22 -0
  35. package/dist/api/firmware/FirmwareMemoryHost.d.ts.map +1 -0
  36. package/dist/api/firmware/FirmwarePreparedResourceArchive.d.ts +11 -0
  37. package/dist/api/firmware/FirmwarePreparedResourceArchive.d.ts.map +1 -0
  38. package/dist/api/firmware/FirmwareUpdateBaseMethod.d.ts.map +1 -1
  39. package/dist/api/firmware/FirmwareUpdatePlan.d.ts +48 -1
  40. package/dist/api/firmware/FirmwareUpdatePlan.d.ts.map +1 -1
  41. package/dist/api/firmware/FirmwareUpdatePreparedPlan.d.ts +7 -1
  42. package/dist/api/firmware/FirmwareUpdatePreparedPlan.d.ts.map +1 -1
  43. package/dist/api/firmware/getBinary.d.ts +0 -1
  44. package/dist/api/firmware/getBinary.d.ts.map +1 -1
  45. package/dist/api/firmware/protocolV2Release.d.ts +33 -0
  46. package/dist/api/firmware/protocolV2Release.d.ts.map +1 -0
  47. package/dist/data-manager/DataManager.d.ts +4 -3
  48. package/dist/data-manager/DataManager.d.ts.map +1 -1
  49. package/dist/index.d.ts +108 -114
  50. package/dist/index.d.ts.map +1 -1
  51. package/dist/index.js +1825 -1006
  52. package/dist/protocols/protocol-v2/resources.d.ts +10 -28
  53. package/dist/protocols/protocol-v2/resources.d.ts.map +1 -1
  54. package/dist/types/api/checkAllFirmwareRelease.d.ts +20 -8
  55. package/dist/types/api/checkAllFirmwareRelease.d.ts.map +1 -1
  56. package/dist/types/api/checkBLEFirmwareRelease.d.ts +2 -13
  57. package/dist/types/api/checkBLEFirmwareRelease.d.ts.map +1 -1
  58. package/dist/types/api/checkBootloaderRelease.d.ts +2 -6
  59. package/dist/types/api/checkBootloaderRelease.d.ts.map +1 -1
  60. package/dist/types/api/checkFirmwareRelease.d.ts +2 -13
  61. package/dist/types/api/checkFirmwareRelease.d.ts.map +1 -1
  62. package/dist/types/api/checkFirmwareTypeAvailable.d.ts +2 -2
  63. package/dist/types/api/checkFirmwareTypeAvailable.d.ts.map +1 -1
  64. package/dist/types/api/deviceUpdateBootloader.d.ts.map +1 -1
  65. package/dist/types/api/export.d.ts +1 -1
  66. package/dist/types/api/export.d.ts.map +1 -1
  67. package/dist/types/api/firmwareUpdate.d.ts +4 -12
  68. package/dist/types/api/firmwareUpdate.d.ts.map +1 -1
  69. package/dist/types/api/firmwareUpdatePlan.d.ts +2 -2
  70. package/dist/types/api/firmwareUpdatePlan.d.ts.map +1 -1
  71. package/dist/types/settings.d.ts +35 -36
  72. package/dist/types/settings.d.ts.map +1 -1
  73. package/package.json +4 -4
  74. package/src/api/CheckAllFirmwareRelease.ts +113 -80
  75. package/src/api/CheckBLEFirmwareRelease.ts +37 -5
  76. package/src/api/CheckBootloaderRelease.ts +35 -3
  77. package/src/api/CheckFirmwareRelease.ts +35 -7
  78. package/src/api/FirmwareUpdateV2.ts +231 -138
  79. package/src/api/FirmwareUpdateV3.ts +101 -56
  80. package/src/api/FirmwareUpdateV4.ts +936 -408
  81. package/src/api/UploadPortfolio.ts +18 -0
  82. package/src/api/device/DeviceUpdateBootloader.ts +37 -11
  83. package/src/api/firmware/FirmwareHostBinding.ts +26 -3
  84. package/src/api/firmware/FirmwareMemoryHost.ts +143 -0
  85. package/src/api/firmware/FirmwarePreparedResourceArchive.ts +207 -0
  86. package/src/api/firmware/FirmwareUpdateBaseMethod.ts +122 -73
  87. package/src/api/firmware/FirmwareUpdatePlan.ts +295 -122
  88. package/src/api/firmware/FirmwareUpdatePreparedPlan.ts +49 -12
  89. package/src/api/firmware/protocolV2Release.ts +168 -0
  90. package/src/data-manager/DataManager.ts +35 -19
  91. package/src/index.ts +10 -0
  92. package/src/protocols/protocol-v2/resources.ts +157 -341
  93. package/src/types/api/checkAllFirmwareRelease.ts +27 -12
  94. package/src/types/api/checkBLEFirmwareRelease.ts +4 -13
  95. package/src/types/api/checkBootloaderRelease.ts +2 -6
  96. package/src/types/api/checkFirmwareRelease.ts +2 -13
  97. package/src/types/api/checkFirmwareTypeAvailable.ts +2 -2
  98. package/src/types/api/deviceUpdateBootloader.ts +1 -0
  99. package/src/types/api/export.ts +6 -1
  100. package/src/types/api/firmwareUpdate.ts +12 -17
  101. package/src/types/api/firmwareUpdatePlan.ts +2 -2
  102. package/src/types/settings.ts +35 -61
  103. package/src/utils/deviceFeaturesUtils.ts +2 -2
@@ -1,11 +1,13 @@
1
1
  import { EFirmwareType, ERRORS, HardwareErrorCode } from '@onekeyfe/hd-shared';
2
+ import { sha256 } from '@noble/hashes/sha256';
3
+ import { bytesToHex } from '@noble/hashes/utils';
4
+ import JSZip from 'jszip';
2
5
  import {
3
6
  DeviceRebootType,
4
7
  DeviceSessionPinType,
5
8
  DeviceSettingsPage,
6
9
  DeviceType,
7
10
  } from '@onekeyfe/hd-transport';
8
- import { sha256 } from '@noble/hashes/sha256';
9
11
 
10
12
  import * as firmwareBinaryApi from '../src/api/firmware/getBinary';
11
13
  import DnxGetAddress from '../src/api/dynex/DnxGetAddress';
@@ -79,7 +81,6 @@ import {
79
81
  requestProtocolV2ProtocolInfo,
80
82
  supportsProtocolV2Message,
81
83
  } from '../src/protocols/protocol-v2/features';
82
- import { PROTOCOL_V2_RESOURCE_DEVICE_PATHS } from '../src/protocols/protocol-v2/resources';
83
84
  import {
84
85
  getProtocolV2WalletSession,
85
86
  refreshProtocolV2DeviceStatus,
@@ -102,6 +103,12 @@ import {
102
103
  } from '../src/utils';
103
104
  import { getDeviceFirmwareVersion } from '../src/utils/deviceVersionUtils';
104
105
  import { openFirmwareByteSource } from '../src/api/firmware/FirmwareArtifactSource';
106
+ import {
107
+ registerFirmwareUpdateHostBinding,
108
+ unregisterFirmwareUpdateHostBinding,
109
+ } from '../src/api/firmware/FirmwareHostBinding';
110
+ import { prepareFirmwareUpdatePlan } from '../src/api/firmware/FirmwareUpdatePreparedPlan';
111
+ import { digestFirmwareUpdateContract } from '../src/api/firmware/FirmwareUpdatePlan';
105
112
  import {
106
113
  getPassphraseState,
107
114
  getPassphraseStateWithRefreshDeviceInfo,
@@ -116,6 +123,26 @@ jest.mock('../src/data/config', () => ({
116
123
  DEFAULT_DOMAIN: 'https://jssdk.onekey.so/1.0.0/',
117
124
  }));
118
125
 
126
+ const createProtocolV2OkppBinary = ({
127
+ version = [1, 2, 3],
128
+ payloadHashByte = 0x11,
129
+ headerHashByte = 0x22,
130
+ }: {
131
+ version?: [number, number, number];
132
+ payloadHashByte?: number;
133
+ headerHashByte?: number;
134
+ } = {}) => {
135
+ const bytes = new Uint8Array(0x52a0);
136
+ bytes.set([0x4f, 0x4b, 0x50, 0x50], 0);
137
+ bytes.set([0x52, 0x45, 0x53, 0x43], 0x08);
138
+ const view = new DataView(bytes.buffer);
139
+ view.setUint32(0x0c, 0x52a0, true);
140
+ view.setUint32(0x10, version[0] * 0x10000 + version[1] * 0x100 + version[2], true);
141
+ bytes.fill(payloadHashByte, 0x200, 0x240);
142
+ bytes.fill(headerHashByte, 0x240, 0x280);
143
+ return bytes.buffer;
144
+ };
145
+
119
146
  const createWalletSessionTypedCall = (
120
147
  implementation = jest.fn(),
121
148
  status: Partial<DeviceStatus> = { passphrase_enabled: true }
@@ -227,6 +254,17 @@ describe('DeviceUploadWallpaper', () => {
227
254
  });
228
255
 
229
256
  describe('UploadPortfolio', () => {
257
+ const portfolioProtocolInfo = {
258
+ version: 2,
259
+ supported_messages: [60805, 61400],
260
+ };
261
+
262
+ const stubPortfolioDevice = <T extends Record<string, any>>(device: T) =>
263
+ stubDevice({
264
+ ...device,
265
+ ensureProtocolV2RuntimeContext: jest.fn().mockResolvedValue(portfolioProtocolInfo),
266
+ });
267
+
230
268
  test('writes and applies the portfolio while the device is locked without unlocking', async () => {
231
269
  const packageBytes = new Uint8Array([1, 2, 3]);
232
270
  const typedCall = jest
@@ -234,7 +272,7 @@ describe('UploadPortfolio', () => {
234
272
  .mockResolvedValueOnce({ message: { processed_byte: 3 } })
235
273
  .mockResolvedValueOnce({ message: { message: 'Portfolio updated' } });
236
274
  const unlockDevice = jest.fn().mockResolvedValue(undefined);
237
- const device = stubDevice({
275
+ const device = stubPortfolioDevice({
238
276
  features: { unlocked: false },
239
277
  commands: { typedCall },
240
278
  isProtocolV2: () => true,
@@ -279,7 +317,7 @@ describe('UploadPortfolio', () => {
279
317
  packageBytes,
280
318
  },
281
319
  });
282
- (method as any).device = stubDevice({ commands: { typedCall } });
320
+ (method as any).device = stubPortfolioDevice({ commands: { typedCall } });
283
321
  method.postMessage = jest.fn();
284
322
 
285
323
  method.init();
@@ -327,7 +365,7 @@ describe('UploadPortfolio', () => {
327
365
  packageBytes: new Uint8Array([1]),
328
366
  },
329
367
  });
330
- (method as any).device = stubDevice({ commands: { typedCall } });
368
+ (method as any).device = stubPortfolioDevice({ commands: { typedCall } });
331
369
 
332
370
  method.init();
333
371
 
@@ -350,7 +388,7 @@ describe('UploadPortfolio', () => {
350
388
  },
351
389
  });
352
390
  method.abortSignal = abortController.signal;
353
- (method as any).device = stubDevice({ commands: { typedCall } });
391
+ (method as any).device = stubPortfolioDevice({ commands: { typedCall } });
354
392
 
355
393
  method.init();
356
394
 
@@ -360,6 +398,31 @@ describe('UploadPortfolio', () => {
360
398
  expect(typedCall).toHaveBeenCalledTimes(1);
361
399
  expect(typedCall).not.toHaveBeenCalledWith('PortfolioUpdate', 'Success', {});
362
400
  });
401
+
402
+ test('rejects unsupported firmware before staging the package', async () => {
403
+ const typedCall = jest.fn();
404
+ const method = new UploadPortfolio({
405
+ id: 1,
406
+ payload: {
407
+ method: 'uploadPortfolio',
408
+ packageBytes: new Uint8Array([1]),
409
+ },
410
+ });
411
+ (method as any).device = stubDevice({
412
+ commands: { typedCall },
413
+ ensureProtocolV2RuntimeContext: jest.fn().mockResolvedValue({
414
+ version: 2,
415
+ supported_messages: [60805],
416
+ }),
417
+ });
418
+
419
+ method.init();
420
+
421
+ await expect(method.run()).rejects.toMatchObject({
422
+ errorCode: HardwareErrorCode.DeviceNotSupportMethod,
423
+ });
424
+ expect(typedCall).not.toHaveBeenCalled();
425
+ });
363
426
  });
364
427
 
365
428
  const descriptor = {
@@ -2724,9 +2787,12 @@ describe('Protocol V2 feature adapter', () => {
2724
2787
  expect(checkedTypes).toEqual(['pro2', 'model_pro2']);
2725
2788
  });
2726
2789
 
2727
- test('uses firmware-v1 as the Pro2 remote firmware config field', () => {
2790
+ test.each([
2791
+ ['Pro2', DeviceType.PRO2],
2792
+ ['Neo', DeviceType.NEO],
2793
+ ] as const)('uses firmware-v1 as the %s remote firmware config field', (_name, deviceType) => {
2728
2794
  const features = normalizeProtocolV2Features({ ...descriptor, protocolType: 'V2' } as any, {
2729
- hw: { Device_type: DeviceType.PRO2 },
2795
+ hw: { Device_type: deviceType },
2730
2796
  });
2731
2797
 
2732
2798
  expect(
@@ -3967,7 +4033,72 @@ describe('Protocol V2 firmware update targets', () => {
3967
4033
  });
3968
4034
  });
3969
4035
 
3970
- test('enters Protocol V2 bootloader before reading and downloading remote resources', async () => {
4036
+ test('filters unrequested binaries from a direct local Protocol V2 update', async () => {
4037
+ const applicationP1Binary = new Uint8Array([1, 2, 3]).buffer;
4038
+ const method = new FirmwareUpdateV4({
4039
+ id: 1,
4040
+ payload: {
4041
+ method: 'firmwareUpdateV4',
4042
+ platform: 'web',
4043
+ targetsToUpdate: ['app_v1'],
4044
+ applicationP1Binary,
4045
+ bootloaderBinary: new Uint8Array([4, 5, 6]).buffer,
4046
+ },
4047
+ });
4048
+ method.init();
4049
+ (method as any).captureProtocolV2PhysicalIdentity = jest.fn().mockResolvedValue(undefined);
4050
+ (method as any).device = stubDevice({
4051
+ originalDescriptor: { protocolType: 'V2' },
4052
+ features: {
4053
+ deviceType: 'pro2',
4054
+ firmwareVersion: '1.0.0',
4055
+ capabilities: [],
4056
+ },
4057
+ getCurrentDeviceType: () => 'pro2',
4058
+ });
4059
+ (method as any).executeProtocolV2Update = jest.fn().mockResolvedValue('local-result');
4060
+ method.postTipMessage = jest.fn();
4061
+
4062
+ await expect(method.run()).resolves.toBe('local-result');
4063
+ expect((method as any).executeProtocolV2Update).toHaveBeenCalledWith({
4064
+ bootloaderBinary: null,
4065
+ fwBinaryMap: [
4066
+ {
4067
+ fileName: 'application_p1.bin',
4068
+ binary: applicationP1Binary,
4069
+ targetId: 4,
4070
+ },
4071
+ ],
4072
+ });
4073
+ });
4074
+
4075
+ test('keeps all local binaries when no target list was supplied', () => {
4076
+ const method = new FirmwareUpdateV4({
4077
+ id: 1,
4078
+ payload: {
4079
+ method: 'firmwareUpdateV4',
4080
+ platform: 'web',
4081
+ applicationP1Binary: new Uint8Array([1]).buffer,
4082
+ bootloaderBinary: new Uint8Array([2]).buffer,
4083
+ resourceArchiveBinary: new Uint8Array([3]).buffer,
4084
+ },
4085
+ });
4086
+ method.init();
4087
+
4088
+ const availableInstallItems = (method as any).buildProtocolV2InstallItems({
4089
+ bootloaderBinary: (method as any).prepareBootloaderBinary(),
4090
+ fwBinaryMap: (method as any).collectExplicitTargetBinaries(),
4091
+ });
4092
+
4093
+ expect((method as any).params.targetsToUpdate).toEqual(['resource']);
4094
+ expect(
4095
+ (method as any)
4096
+ .filterProtocolV2LocalInstallItems(availableInstallItems)
4097
+ .map((item: { targetId: number }) => item.targetId)
4098
+ ).toEqual([3, 4]);
4099
+ });
4100
+
4101
+ test('requires the external host to prepare manifest resources before bootloader entry', async () => {
3971
4102
  const method = new FirmwareUpdateV4({
3972
4103
  id: 1,
3973
4104
  payload: {
@@ -3987,55 +4118,21 @@ describe('Protocol V2 firmware update targets', () => {
3987
4118
  bootloaderMode: false,
3988
4119
  capabilities: [],
3989
4120
  },
4121
+ getCurrentDeviceType: () => 'pro2',
3990
4122
  isBootloader: () => false,
3991
4123
  isRomloader: () => false,
3992
4124
  });
3993
4125
  (method as any).captureProtocolV2PhysicalIdentity = jest.fn().mockResolvedValue(undefined);
3994
- (method as any).prepareRemoteProtocolV2Binaries = jest.fn().mockResolvedValue({
3995
- bootloaderBinary: null,
3996
- fwBinaryMap: [],
3997
- installItems: [],
3998
- });
3999
-
4000
- const order: string[] = [];
4001
- (method as any).prepareProtocolV2BootResources = jest.fn().mockImplementation(() => {
4002
- order.push('prepare-startup-resources');
4003
- return Promise.resolve([]);
4004
- });
4005
- (method as any).enterProtocolV2BootloaderMode = jest.fn().mockImplementation(() => {
4006
- order.push('enter-bootloader');
4007
- return Promise.resolve(true);
4008
- });
4009
- (method as any).prepareProtocolV2ResourceBundles = jest.fn().mockImplementation(() => {
4010
- order.push('prepare-resources');
4011
- return Promise.resolve([
4012
- {
4013
- name: 'images.okpkg',
4014
- binary: new Uint8Array([1]).buffer,
4015
- devicePath: 'vol0:/bundles/images/images.okpkg',
4016
- },
4017
- ]);
4018
- });
4019
- (method as any).executeProtocolV2Update = jest.fn().mockImplementation(() => {
4020
- order.push('execute-update');
4021
- return Promise.resolve();
4022
- });
4023
- (method as any).exitProtocolV2BootloaderToNormal = jest.fn().mockResolvedValue(undefined);
4024
- (method as any).waitForProtocolV2FinalFeatures = jest.fn().mockResolvedValue({
4025
- bootloaderVersion: '1.0.0',
4026
- bleVersion: '1.0.0',
4027
- firmwareVersion: '1.0.0',
4028
- });
4126
+ (method as any).enterProtocolV2BootloaderMode = jest.fn();
4029
4127
  method.postTipMessage = jest.fn();
4030
4128
 
4031
- await method.run();
4032
-
4033
- expect(order).toEqual([
4034
- 'prepare-startup-resources',
4035
- 'enter-bootloader',
4036
- 'prepare-resources',
4037
- 'execute-update',
4038
- ]);
4129
+ await expect(method.run()).rejects.toMatchObject({
4130
+ errorCode: HardwareErrorCode.RuntimeError,
4131
+ params: expect.objectContaining({
4132
+ firmwareUpdateCode: 'FirmwareArtifactsNotPrepared',
4133
+ }),
4134
+ });
4135
+ expect((method as any).enterProtocolV2BootloaderMode).not.toHaveBeenCalled();
4039
4136
  });
4040
4137
 
4041
4138
  test('reboots Protocol V2 normal-mode device to bootloader before transfer', async () => {
@@ -4204,9 +4301,6 @@ describe('Protocol V2 firmware update targets', () => {
4204
4301
  },
4205
4302
  });
4206
4303
  (method as any).captureProtocolV2PhysicalIdentity = jest.fn().mockResolvedValue(undefined);
4207
- const prepareResourceBundles = jest
4208
- .spyOn(method as any, 'prepareProtocolV2ResourceBundles')
4209
- .mockResolvedValue(undefined);
4210
4304
  (method as any).protocolV2Reboot = jest.fn();
4211
4305
  (method as any).executeProtocolV2Update = jest.fn().mockResolvedValue(undefined);
4212
4306
  (method as any).exitProtocolV2BootloaderToNormal = jest.fn().mockResolvedValue(undefined);
@@ -4219,7 +4313,6 @@ describe('Protocol V2 firmware update targets', () => {
4219
4313
 
4220
4314
  await method.run();
4221
4315
 
4222
- expect(prepareResourceBundles).not.toHaveBeenCalled();
4223
4316
  expect((method as any).protocolV2Reboot).not.toHaveBeenCalled();
4224
4317
  expect((method as any).executeProtocolV2Update).toHaveBeenCalledWith({
4225
4318
  bootloaderBinary: null,
@@ -5478,6 +5571,9 @@ describe('Protocol V2 firmware update targets', () => {
5478
5571
  return Number(params.processedSize ?? 0) + Number(params.source.size);
5479
5572
  });
5480
5573
  (method as any).enterProtocolV2BootloaderMode = jest.fn().mockResolvedValue(undefined);
5574
+ (method as any).ensureProtocolV2BootResourceStagingIsEmpty = jest
5575
+ .fn()
5576
+ .mockResolvedValue(undefined);
5481
5577
  (method as any).exitProtocolV2BootloaderToNormal = jest.fn().mockResolvedValue(undefined);
5482
5578
  (method as any).waitForProtocolV2FinalFeatures = jest.fn().mockResolvedValue({});
5483
5579
  (method as any).completeProtocolV2FinalVerification = jest.fn().mockResolvedValue({});
@@ -5551,6 +5647,9 @@ describe('Protocol V2 firmware update targets', () => {
5551
5647
  Promise.resolve(Number(params.processedSize ?? 0) + Number(params.source.size))
5552
5648
  );
5553
5649
  (method as any).enterProtocolV2BootloaderMode = jest.fn().mockResolvedValue(undefined);
5650
+ (method as any).ensureProtocolV2BootResourceStagingIsEmpty = jest
5651
+ .fn()
5652
+ .mockResolvedValue(undefined);
5554
5653
  (method as any).exitProtocolV2BootloaderToNormal = jest.fn().mockResolvedValue(undefined);
5555
5654
  (method as any).completeProtocolV2FinalVerification = jest.fn().mockResolvedValue({});
5556
5655
  (method as any).verifyProtocolV2StagedFile = jest.fn().mockResolvedValue(undefined);
@@ -5558,21 +5657,33 @@ describe('Protocol V2 firmware update targets', () => {
5558
5657
  (method as any).waitForProtocolV2FirmwareUpdateComplete = jest
5559
5658
  .fn()
5560
5659
  .mockResolvedValue(undefined);
5660
+ (method as any).isProtocolV2ResourceBundleUpToDate = jest.fn().mockResolvedValue(false);
5561
5661
 
5562
- await (method as any).executeProtocolV2Update({
5563
- resourceBundles: [
5662
+ await (method as any).executeProtocolV2SourceUpdate({
5663
+ resourceSources: [
5564
5664
  {
5565
5665
  name: 'images.okpkg',
5566
- binary: new Uint8Array([1, 2]).buffer,
5666
+ source: {
5667
+ size: 2,
5668
+ readAt: jest.fn(),
5669
+ close: jest.fn(),
5670
+ },
5567
5671
  devicePath: 'vol0:/bundles/images/images.okpkg',
5672
+ version: [1, 2, 3],
5673
+ payloadHash: '11'.repeat(64),
5674
+ headerHash: '22'.repeat(64),
5568
5675
  },
5569
5676
  ],
5570
- bootloaderBinary: null,
5571
- fwBinaryMap: [
5677
+ installSources: [
5572
5678
  {
5573
5679
  fileName: 'application_p1.bin',
5574
- binary: new Uint8Array([3]).buffer,
5680
+ source: {
5681
+ size: 1,
5682
+ readAt: jest.fn(),
5683
+ close: jest.fn(),
5684
+ },
5575
5685
  targetId: 4,
5686
+ kind: 'firmware',
5576
5687
  },
5577
5688
  ],
5578
5689
  });
@@ -5589,6 +5700,100 @@ describe('Protocol V2 firmware update targets', () => {
5589
5700
  2,
5590
5701
  expect.objectContaining({ processedSize: 0, totalSize: 1 })
5591
5702
  );
5703
+ expect((method as any).isProtocolV2ResourceBundleUpToDate).toHaveBeenCalledWith(
5704
+ expect.objectContaining({
5705
+ version: [1, 2, 3],
5706
+ payloadHash: '11'.repeat(64),
5707
+ headerHash: '22'.repeat(64),
5708
+ })
5709
+ );
5710
+ });
5711
+
5712
+ test('installs and verifies bootloader before syncing manifest resources', () => {
5713
+ const method = new FirmwareUpdateV4({
5714
+ id: 1,
5715
+ payload: { method: 'firmwareUpdateV4' },
5716
+ });
5717
+ const phases = (method as any).buildProtocolV2ExecutionPhases({
5718
+ installSources: [{ kind: 'bootloader' }, { kind: 'component' }],
5719
+ resourceSources: [{ kind: 'resource' }],
5720
+ });
5721
+
5722
+ expect(phases.map((phase: { kind: string }) => phase.kind)).toEqual([
5723
+ 'bootloader-install',
5724
+ 'bootloader-verify',
5725
+ 'resource-sync',
5726
+ 'component-install',
5727
+ 'final-verify',
5728
+ ]);
5729
+ });
5730
+
5731
+ test('stages the mounted boot resource before any firmware install phase', () => {
5732
+ const method = new FirmwareUpdateV4({
5733
+ id: 1,
5734
+ payload: { method: 'firmwareUpdateV4' },
5735
+ });
5736
+ const bootResource = {
5737
+ devicePath: 'vol0:/loaders/bootloader/boot_resource.okpkg',
5738
+ };
5739
+ const phases = (method as any).buildProtocolV2ExecutionPhases({
5740
+ installSources: [{ kind: 'bootloader' }, { kind: 'component' }],
5741
+ resourceSources: [bootResource, { devicePath: 'vol0:/resource/images/images.okpkg' }],
5742
+ });
5743
+
5744
+ expect(phases.map((phase: { kind: string }) => phase.kind)).toEqual([
5745
+ 'resource-sync',
5746
+ 'bootloader-install',
5747
+ 'bootloader-verify',
5748
+ 'resource-sync',
5749
+ 'component-install',
5750
+ 'final-verify',
5751
+ ]);
5752
+ expect(phases[0].resourceSources).toEqual([bootResource]);
5753
+ });
5754
+
5755
+ test('removes stale boot resource staging before a component-only reboot', async () => {
5756
+ const method = new FirmwareUpdateV4({
5757
+ id: 1,
5758
+ payload: { method: 'firmwareUpdateV4' },
5759
+ });
5760
+ const events: string[] = [];
5761
+ let stagingExists = true;
5762
+ const typedCall = jest.fn((requestType: string) => {
5763
+ if (requestType === 'FilesystemPathInfoQuery') {
5764
+ return Promise.resolve({
5765
+ type: 'FilesystemPathInfo',
5766
+ message: { exist: stagingExists, directory: false, size: stagingExists ? 3 : 0 },
5767
+ });
5768
+ }
5769
+ if (requestType === 'FilesystemFileDelete') {
5770
+ events.push('delete-stale-staging');
5771
+ stagingExists = false;
5772
+ return Promise.resolve({ type: 'Success', message: {} });
5773
+ }
5774
+ throw new Error(`Unexpected request: ${requestType}`);
5775
+ });
5776
+ (method as any).device = stubDevice({ getCommands: () => ({ typedCall }) });
5777
+ (method as any).enterProtocolV2BootloaderMode = jest.fn().mockResolvedValue(undefined);
5778
+ (method as any).executeProtocolV2TransferPhase = jest.fn().mockImplementation(() => {
5779
+ events.push('transfer-component');
5780
+ return Promise.resolve();
5781
+ });
5782
+ (method as any).exitProtocolV2BootloaderToNormal = jest.fn().mockImplementation(() => {
5783
+ events.push('reboot-normal');
5784
+ return Promise.resolve();
5785
+ });
5786
+ (method as any).completeProtocolV2FinalVerification = jest.fn().mockResolvedValue({});
5787
+
5788
+ await (method as any).executeProtocolV2SourceUpdate({
5789
+ installSources: [{ kind: 'firmware' }],
5790
+ resourceSources: [],
5791
+ });
5792
+
5793
+ expect(events).toEqual(['delete-stale-staging', 'transfer-component', 'reboot-normal']);
5794
+ expect(typedCall).toHaveBeenNthCalledWith(2, 'FilesystemFileDelete', 'Success', {
5795
+ path: 'vol0:/loaders/bootloader/boot_resource.okpkg.staging',
5796
+ });
5592
5797
  });
5593
5798
 
5594
5799
  test('does not request installation when the staged file size does not match', async () => {
@@ -5603,6 +5808,9 @@ describe('Protocol V2 firmware update targets', () => {
5603
5808
  method.postProgressMessage = jest.fn();
5604
5809
  (method as any).protocolV2SourceUpdateProcess = jest.fn().mockResolvedValue(3);
5605
5810
  (method as any).enterProtocolV2BootloaderMode = jest.fn().mockResolvedValue(undefined);
5811
+ (method as any).ensureProtocolV2BootResourceStagingIsEmpty = jest
5812
+ .fn()
5813
+ .mockResolvedValue(undefined);
5606
5814
  (method as any).device = stubDevice({
5607
5815
  getCommands: () => ({
5608
5816
  typedCall: jest.fn().mockResolvedValue({
@@ -5654,6 +5862,9 @@ describe('Protocol V2 firmware update targets', () => {
5654
5862
  Promise.resolve(Number(params.processedSize ?? 0) + Number(params.source.size))
5655
5863
  );
5656
5864
  (method as any).enterProtocolV2BootloaderMode = jest.fn().mockResolvedValue(undefined);
5865
+ (method as any).ensureProtocolV2BootResourceStagingIsEmpty = jest
5866
+ .fn()
5867
+ .mockResolvedValue(undefined);
5657
5868
  (method as any).exitProtocolV2BootloaderToNormal = jest.fn().mockResolvedValue(undefined);
5658
5869
  (method as any).completeProtocolV2FinalVerification = jest.fn().mockResolvedValue({});
5659
5870
  (method as any).verifyProtocolV2StagedFile = jest.fn().mockResolvedValue(undefined);
@@ -5733,6 +5944,14 @@ describe('Protocol V2 firmware update targets', () => {
5733
5944
  binary: binaries.get(url) ?? new Uint8Array([0]).buffer,
5734
5945
  })
5735
5946
  );
5947
+ const componentIntegrity = (url: string) => {
5948
+ const binary = binaries.get(url);
5949
+ if (!binary) throw new Error(`Missing test firmware binary: ${url}`);
5950
+ return {
5951
+ expectedSize: binary.byteLength,
5952
+ fingerprint: bytesToHex(sha256(new Uint8Array(binary))),
5953
+ };
5954
+ };
5736
5955
  const getFirmwareLatestReleaseSpy = jest
5737
5956
  .spyOn(DataManager, 'getFirmwareLatestRelease')
5738
5957
  .mockReturnValue({
@@ -5757,23 +5976,43 @@ describe('Protocol V2 firmware update targets', () => {
5757
5976
  bootloader: {
5758
5977
  target: 'BOOTLOADER',
5759
5978
  url: 'https://example.com/bootloader.pp.bin',
5979
+ ...componentIntegrity('https://example.com/bootloader.pp.bin'),
5760
5980
  },
5761
5981
  applicationP1: {
5762
5982
  target: 'APPLICATION_P1',
5763
5983
  url: 'https://example.com/applicationP1.pp.bin',
5984
+ ...componentIntegrity('https://example.com/applicationP1.pp.bin'),
5764
5985
  },
5765
5986
  applicationP2: {
5766
5987
  target: 'APPLICATION_P2',
5767
5988
  url: 'https://example.com/applicationP2.pp.bin',
5989
+ ...componentIntegrity('https://example.com/applicationP2.pp.bin'),
5768
5990
  },
5769
5991
  coprocessor: {
5770
5992
  target: 'COPROCESSOR',
5771
5993
  url: 'https://example.com/coprocessor.pp.bin',
5994
+ ...componentIntegrity('https://example.com/coprocessor.pp.bin'),
5995
+ },
5996
+ se01: {
5997
+ target: 'SE01',
5998
+ url: 'https://example.com/se01.pp.bin',
5999
+ ...componentIntegrity('https://example.com/se01.pp.bin'),
6000
+ },
6001
+ se02: {
6002
+ target: 'SE02',
6003
+ url: 'https://example.com/se02.pp.bin',
6004
+ ...componentIntegrity('https://example.com/se02.pp.bin'),
6005
+ },
6006
+ se03: {
6007
+ target: 'SE03',
6008
+ url: 'https://example.com/se03.pp.bin',
6009
+ ...componentIntegrity('https://example.com/se03.pp.bin'),
6010
+ },
6011
+ se04: {
6012
+ target: 'SE04',
6013
+ url: 'https://example.com/se04.pp.bin',
6014
+ ...componentIntegrity('https://example.com/se04.pp.bin'),
5772
6015
  },
5773
- se01: { target: 'SE01', url: 'https://example.com/se01.pp.bin' },
5774
- se02: { target: 'SE02', url: 'https://example.com/se02.pp.bin' },
5775
- se03: { target: 'SE03', url: 'https://example.com/se03.pp.bin' },
5776
- se04: { target: 'SE04', url: 'https://example.com/se04.pp.bin' },
5777
6016
  },
5778
6017
  fingerprint: '',
5779
6018
  changelog: {
@@ -5814,6 +6053,84 @@ describe('Protocol V2 firmware update targets', () => {
5814
6053
  getFirmwareLatestReleaseSpy.mockRestore();
5815
6054
  });
5816
6055
 
6056
+ test('downloads only firmware targets that are missing from an explicit mixed payload', async () => {
6057
+ const explicitApplicationBinary = new Uint8Array([1]).buffer;
6058
+ const remoteCoprocessorBinary = new Uint8Array([2]).buffer;
6059
+ const method = new FirmwareUpdateV4({
6060
+ id: 1,
6061
+ payload: {
6062
+ method: 'firmwareUpdateV4',
6063
+ platform: 'web',
6064
+ targetsToUpdate: ['app_v1', 'coprocessor'],
6065
+ },
6066
+ });
6067
+ method.init();
6068
+
6069
+ const getSysResourceBinarySpy = jest
6070
+ .spyOn(firmwareBinaryApi, 'getSysResourceBinary')
6071
+ .mockResolvedValue({ binary: remoteCoprocessorBinary });
6072
+ const getFirmwareLatestReleaseSpy = jest
6073
+ .spyOn(DataManager, 'getFirmwareLatestRelease')
6074
+ .mockReturnValue({
6075
+ required: false,
6076
+ version: [1, 0, 0],
6077
+ url: 'https://example.com/applicationP1.pp.bin',
6078
+ installOrder: ['applicationP1', 'coprocessor'],
6079
+ components: {
6080
+ applicationP1: {
6081
+ target: 'APPLICATION_P1',
6082
+ url: 'https://example.com/applicationP1.pp.bin',
6083
+ expectedSize: explicitApplicationBinary.byteLength,
6084
+ fingerprint: bytesToHex(sha256(new Uint8Array(explicitApplicationBinary))),
6085
+ },
6086
+ coprocessor: {
6087
+ target: 'COPROCESSOR',
6088
+ url: 'https://example.com/coprocessor.pp.bin',
6089
+ expectedSize: remoteCoprocessorBinary.byteLength,
6090
+ fingerprint: bytesToHex(sha256(new Uint8Array(remoteCoprocessorBinary))),
6091
+ },
6092
+ },
6093
+ fingerprint: '',
6094
+ changelog: {
6095
+ 'zh-CN': '',
6096
+ 'en-US': '',
6097
+ },
6098
+ });
6099
+
6100
+ const remoteBinaries = await (method as any).prepareRemoteProtocolV2Binaries(
6101
+ 'universal',
6102
+ { deviceType: 'pro2', firmwareVersion: '0.0.0' },
6103
+ [
6104
+ {
6105
+ fileName: 'application_p1.bin',
6106
+ binary: explicitApplicationBinary,
6107
+ targetId: 4,
6108
+ kind: 'firmware',
6109
+ },
6110
+ ]
6111
+ );
6112
+
6113
+ expect(getSysResourceBinarySpy).toHaveBeenCalledTimes(1);
6114
+ expect(getSysResourceBinarySpy).toHaveBeenCalledWith('https://example.com/coprocessor.pp.bin');
6115
+ expect(remoteBinaries.installItems).toEqual([
6116
+ {
6117
+ fileName: 'application_p1.bin',
6118
+ binary: explicitApplicationBinary,
6119
+ targetId: 4,
6120
+ kind: 'firmware',
6121
+ },
6122
+ {
6123
+ fileName: 'coprocessor.bin',
6124
+ binary: remoteCoprocessorBinary,
6125
+ targetId: 6,
6126
+ kind: 'firmware',
6127
+ },
6128
+ ]);
6129
+
6130
+ getSysResourceBinarySpy.mockRestore();
6131
+ getFirmwareLatestReleaseSpy.mockRestore();
6132
+ });
6133
+
5817
6134
  test('ignores an unsupported remote ROMLOADER component when installing selected targets', async () => {
5818
6135
  const method = new FirmwareUpdateV4({
5819
6136
  id: 1,
@@ -5978,56 +6295,721 @@ describe('Protocol V2 firmware update targets', () => {
5978
6295
  });
5979
6296
  });
5980
6297
 
5981
- test('rejects prepared RESC bundle paths before opening an artifact source', async () => {
6298
+ test('maps a prepared resource archive manifest to artifact-backed resource sources', async () => {
5982
6299
  const method = new FirmwareUpdateV4({
5983
6300
  id: 1,
5984
6301
  payload: {
5985
6302
  method: 'firmwareUpdateV4',
5986
6303
  },
5987
6304
  });
5988
- const openPreparedSource = jest.fn();
5989
- (method as any).params = {
5990
- targetsToUpdate: ['resource'],
5991
- resourceBundleArtifacts: [
6305
+ const imagesBinary = createProtocolV2OkppBinary();
6306
+ const bootBinary = createProtocolV2OkppBinary({ payloadHashByte: 0x33 });
6307
+ const imagesSha256 = bytesToHex(sha256(new Uint8Array(imagesBinary)));
6308
+ const bootSha256 = bytesToHex(sha256(new Uint8Array(bootBinary)));
6309
+ const manifest = {
6310
+ schema: 1,
6311
+ artifact_name: 'pro2-resource',
6312
+ release_name: 'test-release',
6313
+ variant: 'resource',
6314
+ commit: 'abc123',
6315
+ short_sha: 'abc123',
6316
+ timestamp_utc: '2026-08-09T00:00:00Z',
6317
+ core_version: '1.0.0',
6318
+ key_set: 'production',
6319
+ device_root: 'vol0:',
6320
+ restore_mode: 'bootloader_update',
6321
+ trees: [{ path: 'bundles', device: 'pro2' }],
6322
+ files: [
5992
6323
  {
5993
- name: 'images',
5994
- artifact: {
5995
- artifactRef: `fw:${'a'.repeat(64)}`,
5996
- size: 1,
5997
- sha256: 'a'.repeat(64),
5998
- },
6324
+ archive_path: 'bundles/images/images.okpkg',
6325
+ original_name: 'images.okpkg',
6326
+ device_path: 'vol0:/bundles/images/images.okpkg',
6327
+ size: imagesBinary.byteLength,
6328
+ sha256: imagesSha256,
6329
+ signed: true,
6330
+ sig_algo: 'ed25519',
6331
+ payload_version: '1.2.3',
6332
+ },
6333
+ {
6334
+ archive_path: 'loaders/bootloader/boot_resource.okpkg',
6335
+ original_name: 'boot_resource.okpkg',
6336
+ device_path: 'vol0:/loaders/bootloader/boot_resource.okpkg',
6337
+ size: bootBinary.byteLength,
6338
+ sha256: bootSha256,
6339
+ signed: true,
6340
+ sig_algo: 'ed25519',
6341
+ payload_version: '1.2.3',
5999
6342
  },
6000
6343
  ],
6001
6344
  };
6002
- (method as any).openProtocolV2PreparedSource = openPreparedSource;
6003
- const getFirmwareLatestReleaseSpy = jest
6004
- .spyOn(DataManager, 'getFirmwareLatestRelease')
6005
- .mockReturnValue({
6006
- required: false,
6007
- version: [1, 0, 0],
6008
- url: '',
6009
- resourceBundles: [
6010
- {
6011
- name: 'images',
6012
- url: 'https://example.com/images.okpkg',
6013
- devicePath: 'vol0:/resource/../images.okpkg',
6014
- },
6015
- ],
6016
- fingerprint: '',
6017
- changelog: {
6018
- 'zh-CN': '',
6019
- 'en-US': '',
6345
+ const manifestBinary = new TextEncoder().encode(JSON.stringify(manifest)).buffer;
6346
+ const zip = new JSZip();
6347
+ zip.file('manifest.json', manifestBinary);
6348
+ zip.file('bundles/images/images.okpkg', imagesBinary);
6349
+ zip.file('loaders/bootloader/boot_resource.okpkg', bootBinary);
6350
+ const archiveBinary = await zip.generateAsync({ type: 'arraybuffer' });
6351
+ const preparedEntries = [
6352
+ {
6353
+ entryName: 'manifest.json',
6354
+ artifact: {
6355
+ artifactRef: 'manifest',
6356
+ size: manifestBinary.byteLength,
6357
+ sha256: bytesToHex(sha256(new Uint8Array(manifestBinary))),
6020
6358
  },
6021
- });
6022
-
6023
- await expect(
6024
- (method as any).prepareProtocolV2ResourceSources('universal', {
6359
+ },
6360
+ {
6361
+ entryName: 'bundles/images/images.okpkg',
6362
+ artifact: {
6363
+ artifactRef: 'images',
6364
+ size: imagesBinary.byteLength,
6365
+ sha256: imagesSha256,
6366
+ },
6367
+ },
6368
+ {
6369
+ entryName: 'loaders/bootloader/boot_resource.okpkg',
6370
+ artifact: {
6371
+ artifactRef: 'boot',
6372
+ size: bootBinary.byteLength,
6373
+ sha256: bootSha256,
6374
+ },
6375
+ },
6376
+ ];
6377
+ const artifacts = new Map([['archive', archiveBinary]]);
6378
+ (method as any).params = {
6379
+ targetsToUpdate: ['resource'],
6380
+ preparedPlan: {
6381
+ artifacts: [
6382
+ {
6383
+ artifactId: 'resource:archive',
6384
+ role: 'resourceBundle',
6385
+ target: 'resource',
6386
+ container: 'zip',
6387
+ artifact: {
6388
+ artifactRef: 'archive',
6389
+ size: archiveBinary.byteLength,
6390
+ sha256: bytesToHex(sha256(new Uint8Array(archiveBinary))),
6391
+ },
6392
+ materializedEntries: preparedEntries,
6393
+ },
6394
+ ],
6395
+ },
6396
+ };
6397
+ (method as any).openProtocolV2PreparedSource = jest.fn(async artifact => {
6398
+ const binary = artifacts.get(artifact.artifactRef);
6399
+ if (!binary) throw new Error(`Unknown artifact: ${artifact.artifactRef}`);
6400
+ return openFirmwareByteSource({ binary });
6401
+ });
6402
+
6403
+ const sources = await (method as any).prepareProtocolV2ResourceArchiveSources();
6404
+
6405
+ expect(sources).toHaveLength(2);
6406
+ expect(sources).toEqual([
6407
+ expect.objectContaining({
6408
+ name: 'images.okpkg',
6409
+ devicePath: 'vol0:/bundles/images/images.okpkg',
6410
+ version: [1, 2, 3],
6411
+ }),
6412
+ expect.objectContaining({
6413
+ name: 'boot_resource.okpkg',
6414
+ devicePath: 'vol0:/loaders/bootloader/boot_resource.okpkg',
6415
+ version: [1, 2, 3],
6416
+ }),
6417
+ ]);
6418
+ await Promise.all(sources.map(source => source.source.close()));
6419
+
6420
+ preparedEntries[1].artifact.sha256 = 'f'.repeat(64);
6421
+ await expect((method as any).prepareProtocolV2ResourceArchiveSources()).rejects.toThrow(
6422
+ 'entries do not match the approved archive'
6423
+ );
6424
+ });
6425
+
6426
+ test('binds a prepared resource archive to the selected host generation', () => {
6427
+ const planWithoutDigest = {
6428
+ schemaVersion: 2 as const,
6429
+ executor: 'v4' as const,
6430
+ deviceIdentity: 'PRO2-SERIAL',
6431
+ deviceModel: 'pro2',
6432
+ firmwareType: EFirmwareType.Universal,
6433
+ platform: 'web' as const,
6434
+ targetsToUpdate: ['resource' as const],
6435
+ artifacts: [
6436
+ {
6437
+ artifactId: 'resource:archive',
6438
+ role: 'resourceBundle' as const,
6439
+ target: 'resource' as const,
6440
+ url: 'https://example.com/resource.zip',
6441
+ container: 'zip' as const,
6442
+ logicalName: 'protocol-v2-resource-archive',
6443
+ expectedSize: 3,
6444
+ expectedSha256: 'a'.repeat(64),
6445
+ },
6446
+ ],
6447
+ };
6448
+ const plan = {
6449
+ ...planWithoutDigest,
6450
+ planDigest: digestFirmwareUpdateContract(planWithoutDigest),
6451
+ };
6452
+ const preparedPlan = prepareFirmwareUpdatePlan({
6453
+ plan,
6454
+ leaseRef: 'resource-archive-test',
6455
+ artifacts: [
6456
+ {
6457
+ artifactId: 'resource:archive',
6458
+ artifact: {
6459
+ artifactRef: 'resource-archive',
6460
+ size: 3,
6461
+ sha256: 'a'.repeat(64),
6462
+ },
6463
+ materializedEntries: [
6464
+ {
6465
+ entryName: 'manifest.json',
6466
+ artifact: {
6467
+ artifactRef: 'resource-manifest',
6468
+ size: 2,
6469
+ sha256: 'b'.repeat(64),
6470
+ },
6471
+ },
6472
+ ],
6473
+ },
6474
+ ],
6475
+ });
6476
+ const mismatchedHostBindingGeneration = registerFirmwareUpdateHostBinding({
6477
+ preparedPlanDigest: 'c'.repeat(64),
6478
+ artifactReader: {
6479
+ open: jest.fn(),
6480
+ read: jest.fn(),
6481
+ close: jest.fn(),
6482
+ },
6483
+ });
6484
+ try {
6485
+ const mismatchedMethod = new FirmwareUpdateV4({
6486
+ id: 1,
6487
+ payload: {
6488
+ method: 'firmwareUpdateV4',
6489
+ platform: 'web',
6490
+ targetsToUpdate: ['resource'],
6491
+ preparedPlan,
6492
+ hostBindingGeneration: mismatchedHostBindingGeneration,
6493
+ },
6494
+ });
6495
+
6496
+ expect(() => mismatchedMethod.init()).toThrow('does not match the prepared plan');
6497
+ } finally {
6498
+ unregisterFirmwareUpdateHostBinding(mismatchedHostBindingGeneration);
6499
+ }
6500
+ const hostBindingGeneration = registerFirmwareUpdateHostBinding({
6501
+ preparedPlanDigest: preparedPlan.preparedPlanDigest,
6502
+ artifactReader: {
6503
+ open: jest.fn(),
6504
+ read: jest.fn(),
6505
+ close: jest.fn(),
6506
+ },
6507
+ });
6508
+ try {
6509
+ const method = new FirmwareUpdateV4({
6510
+ id: 1,
6511
+ payload: {
6512
+ method: 'firmwareUpdateV4',
6513
+ platform: 'web',
6514
+ targetsToUpdate: ['resource'],
6515
+ preparedPlan,
6516
+ hostBindingGeneration,
6517
+ },
6518
+ });
6519
+
6520
+ expect(() => method.init()).not.toThrow();
6521
+ } finally {
6522
+ unregisterFirmwareUpdateHostBinding(hostBindingGeneration);
6523
+ }
6524
+ });
6525
+
6526
+ test('executes a prepared component plan without duplicated component inputs', async () => {
6527
+ const componentBinary = new Uint8Array([1, 2, 3]).buffer;
6528
+ const componentArtifact = {
6529
+ artifactRef: 'prepared-bootloader',
6530
+ size: componentBinary.byteLength,
6531
+ sha256: 'a'.repeat(64),
6532
+ };
6533
+ const planWithoutDigest = {
6534
+ schemaVersion: 2 as const,
6535
+ executor: 'v4' as const,
6536
+ deviceIdentity: 'PRO2-SERIAL',
6537
+ deviceModel: 'pro2',
6538
+ firmwareType: EFirmwareType.Universal,
6539
+ platform: 'web' as const,
6540
+ targetsToUpdate: ['boot' as const],
6541
+ artifacts: [
6542
+ {
6543
+ artifactId: 'component:boot',
6544
+ role: 'component' as const,
6545
+ target: 'boot' as const,
6546
+ url: 'https://example.com/bootloader.bin',
6547
+ container: 'raw' as const,
6548
+ logicalName: 'bootloader',
6549
+ expectedSize: componentArtifact.size,
6550
+ expectedSha256: componentArtifact.sha256,
6551
+ targetVersion: '1.2.3',
6552
+ },
6553
+ ],
6554
+ };
6555
+ const preparedPlan = prepareFirmwareUpdatePlan({
6556
+ plan: {
6557
+ ...planWithoutDigest,
6558
+ planDigest: digestFirmwareUpdateContract(planWithoutDigest),
6559
+ },
6560
+ leaseRef: 'prepared-component-test',
6561
+ artifacts: [{ artifactId: 'component:boot', artifact: componentArtifact }],
6562
+ });
6563
+ const artifactReader = {
6564
+ open: jest.fn(() =>
6565
+ Promise.resolve({
6566
+ readerId: 'prepared-component-reader',
6567
+ size: componentBinary.byteLength,
6568
+ })
6569
+ ),
6570
+ read: jest.fn(({ offset, length }: { offset: number; length: number }) => {
6571
+ const data = componentBinary.slice(offset, offset + length);
6572
+ return Promise.resolve({
6573
+ data,
6574
+ bytesRead: data.byteLength,
6575
+ eof: offset + length === componentBinary.byteLength,
6576
+ });
6577
+ }),
6578
+ close: jest.fn(() => Promise.resolve()),
6579
+ };
6580
+ const hostBindingGeneration = registerFirmwareUpdateHostBinding({
6581
+ preparedPlanDigest: preparedPlan.preparedPlanDigest,
6582
+ artifactReader,
6583
+ });
6584
+ const releaseSpy = jest
6585
+ .spyOn(DataManager, 'getFirmwareLatestRelease')
6586
+ .mockReturnValue(undefined);
6587
+
6588
+ try {
6589
+ const method = new FirmwareUpdateV4({
6590
+ id: 1,
6591
+ payload: {
6592
+ method: 'firmwareUpdateV4',
6593
+ platform: 'web',
6594
+ preparedPlan,
6595
+ hostBindingGeneration,
6596
+ },
6597
+ });
6598
+
6599
+ expect(() => method.init()).not.toThrow();
6600
+ expect((method as any).params.targetsToUpdate).toEqual(['boot']);
6601
+ expect((method as any).params.expectedTargetVersions).toEqual({ boot: '1.2.3' });
6602
+ expect((method as any).params.componentArtifacts).toBeUndefined();
6603
+ (method as any).postTipMessage = jest.fn();
6604
+ (method as any).prepareProtocolV2ResourceSources = jest.fn().mockResolvedValue([]);
6605
+ (method as any).executeProtocolV2SourceUpdate = jest
6606
+ .fn()
6607
+ .mockResolvedValue('prepared-result');
6608
+
6609
+ await expect(
6610
+ (method as any).runProtocolV2PreparedArtifacts({} as Features, EFirmwareType.Universal)
6611
+ ).resolves.toBe('prepared-result');
6612
+ expect(releaseSpy).not.toHaveBeenCalled();
6613
+ expect(artifactReader.open).toHaveBeenCalledWith({ artifactRef: 'prepared-bootloader' });
6614
+ expect((method as any).executeProtocolV2SourceUpdate).toHaveBeenCalledWith({
6615
+ installSources: [
6616
+ expect.objectContaining({
6617
+ fileName: 'bootloader.bin',
6618
+ kind: 'bootloader',
6619
+ }),
6620
+ ],
6621
+ resourceSources: [],
6622
+ });
6623
+ } finally {
6624
+ releaseSpy.mockRestore();
6625
+ unregisterFirmwareUpdateHostBinding(hostBindingGeneration);
6626
+ }
6627
+ });
6628
+
6629
+ test('executes a resource-only prepared plan without a live firmware release', async () => {
6630
+ const planWithoutDigest = {
6631
+ schemaVersion: 2 as const,
6632
+ executor: 'v4' as const,
6633
+ deviceIdentity: 'PRO2-SERIAL',
6634
+ deviceModel: 'pro2',
6635
+ firmwareType: EFirmwareType.Universal,
6636
+ platform: 'web' as const,
6637
+ targetsToUpdate: ['resource' as const],
6638
+ artifacts: [
6639
+ {
6640
+ artifactId: 'resource:archive',
6641
+ role: 'resourceBundle' as const,
6642
+ target: 'resource' as const,
6643
+ url: 'https://example.com/resource.zip',
6644
+ container: 'zip' as const,
6645
+ logicalName: 'protocol-v2-resource-archive',
6646
+ expectedSize: 3,
6647
+ expectedSha256: 'a'.repeat(64),
6648
+ },
6649
+ ],
6650
+ };
6651
+ const preparedPlan = prepareFirmwareUpdatePlan({
6652
+ plan: {
6653
+ ...planWithoutDigest,
6654
+ planDigest: digestFirmwareUpdateContract(planWithoutDigest),
6655
+ },
6656
+ leaseRef: 'prepared-resource-only-test',
6657
+ artifacts: [
6658
+ {
6659
+ artifactId: 'resource:archive',
6660
+ artifact: {
6661
+ artifactRef: 'prepared-resource-archive',
6662
+ size: 3,
6663
+ sha256: 'a'.repeat(64),
6664
+ },
6665
+ materializedEntries: [
6666
+ {
6667
+ entryName: 'manifest.json',
6668
+ artifact: {
6669
+ artifactRef: 'prepared-resource-manifest',
6670
+ size: 2,
6671
+ sha256: 'b'.repeat(64),
6672
+ },
6673
+ },
6674
+ ],
6675
+ },
6676
+ ],
6677
+ });
6678
+ const hostBindingGeneration = registerFirmwareUpdateHostBinding({
6679
+ preparedPlanDigest: preparedPlan.preparedPlanDigest,
6680
+ artifactReader: {
6681
+ open: jest.fn(),
6682
+ read: jest.fn(),
6683
+ close: jest.fn(),
6684
+ },
6685
+ });
6686
+ const releaseSpy = jest
6687
+ .spyOn(DataManager, 'getFirmwareLatestRelease')
6688
+ .mockReturnValue(undefined);
6689
+
6690
+ try {
6691
+ const method = new FirmwareUpdateV4({
6692
+ id: 1,
6693
+ payload: {
6694
+ method: 'firmwareUpdateV4',
6695
+ platform: 'web',
6696
+ preparedPlan,
6697
+ hostBindingGeneration,
6698
+ },
6699
+ });
6700
+
6701
+ expect(() => method.init()).not.toThrow();
6702
+ (method as any).postTipMessage = jest.fn();
6703
+ const resourceSources = [{ kind: 'resource' }];
6704
+ (method as any).prepareProtocolV2ResourceSources = jest
6705
+ .fn()
6706
+ .mockResolvedValue(resourceSources);
6707
+ (method as any).executeProtocolV2SourceUpdate = jest
6708
+ .fn()
6709
+ .mockResolvedValue('resource-result');
6710
+
6711
+ await expect(
6712
+ (method as any).runProtocolV2PreparedArtifacts({} as Features, EFirmwareType.Universal)
6713
+ ).resolves.toBe('resource-result');
6714
+ expect(releaseSpy).not.toHaveBeenCalled();
6715
+ expect((method as any).executeProtocolV2SourceUpdate).toHaveBeenCalledWith({
6716
+ installSources: [],
6717
+ resourceSources,
6718
+ });
6719
+ } finally {
6720
+ releaseSpy.mockRestore();
6721
+ unregisterFirmwareUpdateHostBinding(hostBindingGeneration);
6722
+ }
6723
+ });
6724
+
6725
+ test('converts a local resource ZIP into a local PreparedPlan without matching a remote release', async () => {
6726
+ const imagesBinary = new Uint8Array([1, 2, 3]).buffer;
6727
+ const bootResourceBinary = new Uint8Array([4, 5, 6]).buffer;
6728
+ const manifest = {
6729
+ schema: 1,
6730
+ artifact_name: 'pro2-resource',
6731
+ release_name: 'local-development',
6732
+ variant: 'resource',
6733
+ commit: 'local',
6734
+ short_sha: 'local',
6735
+ timestamp_utc: '2026-08-09T00:00:00Z',
6736
+ core_version: '1.0.0',
6737
+ key_set: 'development',
6738
+ device_root: 'vol0:',
6739
+ restore_mode: 'bootloader_update',
6740
+ trees: [{ path: 'bundles', device: 'pro2' }],
6741
+ files: [
6742
+ {
6743
+ archive_path: 'bundles/images/images.okpkg',
6744
+ original_name: 'images.okpkg',
6745
+ device_path: 'vol0:/bundles/images/images.okpkg',
6746
+ size: imagesBinary.byteLength,
6747
+ sha256: bytesToHex(sha256(new Uint8Array(imagesBinary))),
6748
+ signed: true,
6749
+ sig_algo: 'ed25519',
6750
+ payload_version: '1.0.0',
6751
+ },
6752
+ {
6753
+ archive_path: 'loaders/bootloader/boot_resource.okpkg',
6754
+ original_name: 'boot_resource.okpkg',
6755
+ device_path: 'vol0:/loaders/bootloader/boot_resource.okpkg',
6756
+ size: bootResourceBinary.byteLength,
6757
+ sha256: bytesToHex(sha256(new Uint8Array(bootResourceBinary))),
6758
+ signed: true,
6759
+ sig_algo: 'ed25519',
6760
+ payload_version: '1.0.0',
6761
+ },
6762
+ ],
6763
+ };
6764
+ const zip = new JSZip();
6765
+ zip.file('manifest.json', JSON.stringify(manifest));
6766
+ zip.file('bundles/images/images.okpkg', imagesBinary);
6767
+ zip.file('loaders/bootloader/boot_resource.okpkg', bootResourceBinary);
6768
+ const resourceArchiveBinary = await zip.generateAsync({ type: 'arraybuffer' });
6769
+ const applicationP1Binary = new Uint8Array([7, 8, 9]).buffer;
6770
+ const bootloaderBinary = new Uint8Array([10, 11, 12]).buffer;
6771
+ const method = new FirmwareUpdateV4({
6772
+ id: 1,
6773
+ payload: {
6774
+ method: 'firmwareUpdateV4',
6775
+ platform: 'web',
6776
+ targetsToUpdate: ['app_v1'],
6777
+ applicationP1Binary,
6778
+ bootloaderBinary,
6779
+ resourceArchiveBinary,
6780
+ },
6781
+ });
6782
+ method.init();
6783
+ expect((method as any).params.targetsToUpdate).toEqual(['app_v1', 'resource']);
6784
+ (method as any).captureProtocolV2PhysicalIdentity = jest.fn().mockResolvedValue(undefined);
6785
+ (method as any).postTipMessage = jest.fn();
6786
+ (method as any).device = stubDevice({
6787
+ originalDescriptor: { protocolType: 'V2' },
6788
+ features: {
6025
6789
  deviceType: 'pro2',
6790
+ serialNo: 'pro2-device-id',
6791
+ firmwareVersion: '1.0.0',
6792
+ capabilities: [],
6793
+ },
6794
+ getCurrentDeviceType: () => 'pro2',
6795
+ });
6796
+ (method as any).protocolV2ExpectedSerialNumber = 'pro2-device-id';
6797
+ (method as any).executeProtocolV2SourceUpdate = jest
6798
+ .fn()
6799
+ .mockResolvedValue('local-resource-result');
6800
+ const releaseSpy = jest.spyOn(DataManager, 'getFirmwareLatestRelease');
6801
+ const reloadSpy = jest.spyOn(DataManager, 'forceReloadData');
6802
+
6803
+ try {
6804
+ await expect(method.run()).resolves.toBe('local-resource-result');
6805
+ expect(releaseSpy).not.toHaveBeenCalled();
6806
+ expect(reloadSpy).not.toHaveBeenCalled();
6807
+ expect((method as any).params.preparedPlan).toMatchObject({
6808
+ networkPolicy: 'forbid',
6809
+ executor: 'v4',
6810
+ targetsToUpdate: ['app_v1', 'resource'],
6811
+ });
6812
+ expect(
6813
+ (method as any).params.preparedPlan.artifacts.some(
6814
+ (artifact: { target: string }) => artifact.target === 'boot'
6815
+ )
6816
+ ).toBe(false);
6817
+ expect((method as any).executeProtocolV2SourceUpdate).toHaveBeenCalledWith(
6818
+ expect.objectContaining({
6819
+ installSources: [
6820
+ expect.objectContaining({
6821
+ fileName: 'application_p1.bin',
6822
+ targetId: 4,
6823
+ }),
6824
+ ],
6825
+ resourceSources: [
6826
+ expect.objectContaining({
6827
+ name: 'images.okpkg',
6828
+ devicePath: 'vol0:/bundles/images/images.okpkg',
6829
+ }),
6830
+ expect.objectContaining({
6831
+ name: 'boot_resource.okpkg',
6832
+ devicePath: 'vol0:/loaders/bootloader/boot_resource.okpkg',
6833
+ }),
6834
+ ],
6835
+ })
6836
+ );
6837
+ } finally {
6838
+ releaseSpy.mockRestore();
6839
+ reloadSpy.mockRestore();
6840
+ }
6841
+ });
6842
+
6843
+ test('rejects a local resource ZIP whose file hash does not match its manifest', async () => {
6844
+ const resourceBinary = new Uint8Array([1, 2, 3]).buffer;
6845
+ const bootResourceBinary = new Uint8Array([4, 5, 6]).buffer;
6846
+ const zip = new JSZip();
6847
+ zip.file(
6848
+ 'manifest.json',
6849
+ JSON.stringify({
6850
+ schema: 1,
6851
+ artifact_name: 'pro2-resource',
6852
+ release_name: 'local-development',
6853
+ variant: 'resource',
6854
+ commit: 'local',
6855
+ short_sha: 'local',
6856
+ timestamp_utc: '2026-08-09T00:00:00Z',
6857
+ core_version: '1.0.0',
6858
+ key_set: 'development',
6859
+ device_root: 'vol0:',
6860
+ restore_mode: 'bootloader_update',
6861
+ trees: [{ path: 'bundles', device: 'pro2' }],
6862
+ files: [
6863
+ {
6864
+ archive_path: 'bundles/images/images.okpkg',
6865
+ original_name: 'images.okpkg',
6866
+ device_path: 'vol0:/bundles/images/images.okpkg',
6867
+ size: resourceBinary.byteLength,
6868
+ sha256: '0'.repeat(64),
6869
+ signed: true,
6870
+ sig_algo: 'ed25519',
6871
+ payload_version: '1.0.0',
6872
+ },
6873
+ {
6874
+ archive_path: 'loaders/bootloader/boot_resource.okpkg',
6875
+ original_name: 'boot_resource.okpkg',
6876
+ device_path: 'vol0:/loaders/bootloader/boot_resource.okpkg',
6877
+ size: bootResourceBinary.byteLength,
6878
+ sha256: bytesToHex(sha256(new Uint8Array(bootResourceBinary))),
6879
+ signed: true,
6880
+ sig_algo: 'ed25519',
6881
+ payload_version: '1.0.0',
6882
+ },
6883
+ ],
6026
6884
  })
6027
- ).rejects.toThrow('resourceBundles[].devicePath');
6028
- expect(openPreparedSource).not.toHaveBeenCalled();
6885
+ );
6886
+ zip.file('bundles/images/images.okpkg', resourceBinary);
6887
+ zip.file('loaders/bootloader/boot_resource.okpkg', bootResourceBinary);
6888
+ const method = new FirmwareUpdateV4({
6889
+ id: 1,
6890
+ payload: {
6891
+ method: 'firmwareUpdateV4',
6892
+ platform: 'web',
6893
+ targetsToUpdate: ['resource'],
6894
+ },
6895
+ });
6896
+ method.init();
6029
6897
 
6030
- getFirmwareLatestReleaseSpy.mockRestore();
6898
+ await expect(
6899
+ (method as any).prepareProtocolV2LocalResourceArchive(
6900
+ await zip.generateAsync({ type: 'arraybuffer' })
6901
+ )
6902
+ ).rejects.toThrow(
6903
+ 'Protocol V2 local resource file does not match manifest: bundles/images/images.okpkg'
6904
+ );
6905
+ });
6906
+
6907
+ test('maps malformed resource ZIPs to a typed firmware preparation error', async () => {
6908
+ const loadSpy = jest.spyOn(JSZip, 'loadAsync').mockRejectedValue(new Error('corrupt ZIP'));
6909
+ const method = new FirmwareUpdateV4({
6910
+ id: 1,
6911
+ payload: {
6912
+ method: 'firmwareUpdateV4',
6913
+ platform: 'web',
6914
+ targetsToUpdate: ['resource'],
6915
+ },
6916
+ });
6917
+ method.init();
6918
+
6919
+ try {
6920
+ await expect(
6921
+ (method as any).prepareProtocolV2LocalResourceArchive(new Uint8Array([1]).buffer)
6922
+ ).rejects.toMatchObject({
6923
+ errorCode: HardwareErrorCode.RuntimeError,
6924
+ params: { firmwareUpdateCode: 'FirmwareArtifactsNotPrepared' },
6925
+ });
6926
+ } finally {
6927
+ loadSpy.mockRestore();
6928
+ }
6929
+ });
6930
+
6931
+ test('rejects an oversized ZIP entry before allocating its decompressed bytes', async () => {
6932
+ const extractEntry = jest.fn();
6933
+ const loadSpy = jest.spyOn(JSZip, 'loadAsync').mockResolvedValue({
6934
+ files: {
6935
+ 'manifest.json': {
6936
+ name: 'manifest.json',
6937
+ dir: false,
6938
+ _data: {
6939
+ compressedSize: 1,
6940
+ uncompressedSize: 2 * 1024 * 1024,
6941
+ },
6942
+ async: extractEntry,
6943
+ },
6944
+ },
6945
+ } as unknown as JSZip);
6946
+ const method = new FirmwareUpdateV4({
6947
+ id: 1,
6948
+ payload: {
6949
+ method: 'firmwareUpdateV4',
6950
+ platform: 'web',
6951
+ targetsToUpdate: ['resource'],
6952
+ },
6953
+ });
6954
+ method.init();
6955
+
6956
+ try {
6957
+ await expect(
6958
+ (method as any).prepareProtocolV2LocalResourceArchive(new Uint8Array([1]).buffer)
6959
+ ).rejects.toThrow('declared size exceeds the allowed limit');
6960
+ expect(extractEntry).not.toHaveBeenCalled();
6961
+ } finally {
6962
+ loadSpy.mockRestore();
6963
+ }
6964
+ });
6965
+
6966
+ test('normalizes the deprecated boot_resources target to resource', () => {
6967
+ const method = new FirmwareUpdateV4({
6968
+ id: 1,
6969
+ payload: {
6970
+ method: 'firmwareUpdateV4',
6971
+ platform: 'web',
6972
+ targetsToUpdate: ['boot_resources'],
6973
+ },
6974
+ });
6975
+
6976
+ method.init();
6977
+ expect((method as any).params.targetsToUpdate).toEqual(['resource']);
6978
+ });
6979
+
6980
+ test('keeps the legacy componentArtifacts and artifactReader path without a prepared Plan', async () => {
6981
+ const artifactReader = {
6982
+ open: jest.fn(),
6983
+ read: jest.fn(),
6984
+ close: jest.fn(),
6985
+ };
6986
+ const method = new FirmwareUpdateV4({
6987
+ id: 1,
6988
+ payload: {
6989
+ method: 'firmwareUpdateV4',
6990
+ platform: 'web',
6991
+ targetsToUpdate: ['boot'],
6992
+ artifactReader,
6993
+ componentArtifacts: {
6994
+ boot: {
6995
+ artifactRef: 'legacy-bootloader',
6996
+ size: 3,
6997
+ sha256: 'a'.repeat(64),
6998
+ },
6999
+ },
7000
+ },
7001
+ });
7002
+
7003
+ expect(() => method.init()).not.toThrow();
7004
+ (method as any).captureProtocolV2PhysicalIdentity = jest.fn().mockResolvedValue(undefined);
7005
+ (method as any).device = stubDevice({
7006
+ originalDescriptor: { protocolType: 'V2' },
7007
+ features: { deviceType: 'pro2', firmwareVersion: '1.0.0', capabilities: [] },
7008
+ });
7009
+ (method as any).runProtocolV2PreparedArtifacts = jest.fn().mockResolvedValue('legacy-result');
7010
+
7011
+ await expect(method.run()).resolves.toBe('legacy-result');
7012
+ expect((method as any).runProtocolV2PreparedArtifacts).toHaveBeenCalledTimes(1);
6031
7013
  });
6032
7014
 
6033
7015
  test('stops a remote update before reboot when the latest config cannot be refreshed', async () => {
@@ -6090,99 +7072,13 @@ describe('Protocol V2 firmware update targets', () => {
6090
7072
  await method.run();
6091
7073
 
6092
7074
  expect((method as any).prepareRemoteProtocolV2Binaries).not.toHaveBeenCalled();
6093
- expect((method as any).executeProtocolV2Update).toHaveBeenCalledWith({
6094
- bootloaderBinary: null,
6095
- fwBinaryMap: [{ fileName: 'coprocessor.bin', binary: expect.anything(), targetId: 6 }],
6096
- });
6097
- });
6098
-
6099
- test.each(['resource', 'boot_resources'] as const)(
6100
- 'treats manual resource files as authoritative payload for %s',
6101
- async target => {
6102
- const resourceBundle = new Uint8Array([1, 2, 3]).buffer;
6103
- const method = new FirmwareUpdateV4({
6104
- id: 1,
6105
- payload: {
6106
- method: 'firmwareUpdateV4',
6107
- platform: 'web',
6108
- targetsToUpdate: [target],
6109
- resourceFiles: [
6110
- {
6111
- binary: resourceBundle,
6112
- devicePath: ' VOL0:/resource/images/images.okpkg ',
6113
- },
6114
- ],
6115
- },
6116
- });
6117
- method.init();
6118
- (method as any).captureProtocolV2PhysicalIdentity = jest.fn().mockResolvedValue(undefined);
6119
- const bootResourcesSpy = jest.spyOn(DataManager, 'getProtocolV2BootResources');
6120
-
6121
- (method as any).device = stubDevice({
6122
- originalDescriptor: { protocolType: 'V2' },
6123
- features: { deviceType: 'pro2', firmwareVersion: '0.0.0', capabilities: [] },
6124
- });
6125
- (method as any).prepareRemoteProtocolV2Binaries = jest.fn();
6126
- (method as any).enterProtocolV2BootloaderMode = jest.fn().mockResolvedValue(true);
6127
- (method as any).executeProtocolV2Update = jest.fn().mockResolvedValue(undefined);
6128
- (method as any).exitProtocolV2BootloaderToNormal = jest.fn().mockResolvedValue(undefined);
6129
- (method as any).waitForProtocolV2FinalFeatures = jest.fn().mockResolvedValue({
6130
- bootloaderVersion: '1.0.0',
6131
- bleVersion: '0.0.0',
6132
- firmwareVersion: '1.0.0',
6133
- });
6134
- method.postTipMessage = jest.fn();
6135
-
6136
- await method.run();
6137
-
6138
- expect(forceReloadDataSpy).not.toHaveBeenCalled();
6139
- expect(bootResourcesSpy).not.toHaveBeenCalled();
6140
- expect((method as any).prepareRemoteProtocolV2Binaries).not.toHaveBeenCalled();
6141
- expect((method as any).executeProtocolV2Update).toHaveBeenCalledWith(
6142
- expect.objectContaining({
6143
- resourceBundles: [
6144
- {
6145
- name: 'images.okpkg',
6146
- binary: resourceBundle,
6147
- devicePath: 'vol0:/resource/images/images.okpkg',
6148
- },
6149
- ],
6150
- })
6151
- );
6152
- }
6153
- );
6154
-
6155
- test('rejects manual RESC bundle paths before bootloader entry', async () => {
6156
- const method = new FirmwareUpdateV4({
6157
- id: 1,
6158
- payload: {
6159
- method: 'firmwareUpdateV4',
6160
- platform: 'web',
6161
- resourceFiles: [
6162
- {
6163
- binary: new Uint8Array([1, 2, 3]).buffer,
6164
- devicePath: 'vol2:/resource/images/images.okpkg',
6165
- },
6166
- ],
6167
- },
6168
- });
6169
- method.init();
6170
- (method as any).captureProtocolV2PhysicalIdentity = jest.fn().mockResolvedValue(undefined);
6171
-
6172
- (method as any).device = stubDevice({
6173
- originalDescriptor: { protocolType: 'V2' },
6174
- features: { deviceType: 'pro2', firmwareVersion: '0.0.0', capabilities: [] },
6175
- });
6176
- (method as any).prepareRemoteProtocolV2Binaries = jest.fn();
6177
- (method as any).enterProtocolV2BootloaderMode = jest.fn();
6178
- method.postTipMessage = jest.fn();
6179
-
6180
- await expect(method.run()).rejects.toThrow('resourceFiles[0].devicePath');
6181
- expect((method as any).prepareRemoteProtocolV2Binaries).not.toHaveBeenCalled();
6182
- expect((method as any).enterProtocolV2BootloaderMode).not.toHaveBeenCalled();
7075
+ expect((method as any).executeProtocolV2Update).toHaveBeenCalledWith({
7076
+ bootloaderBinary: null,
7077
+ fwBinaryMap: [{ fileName: 'coprocessor.bin', binary: expect.anything(), targetId: 6 }],
7078
+ });
6183
7079
  });
6184
7080
 
6185
- test('syncs resource bundles without sending a firmware install request', async () => {
7081
+ test('syncs prepared resource sources without sending a firmware install request', async () => {
6186
7082
  const method = new FirmwareUpdateV4({
6187
7083
  id: 1,
6188
7084
  payload: {
@@ -6194,28 +7090,118 @@ describe('Protocol V2 firmware update targets', () => {
6194
7090
  method.postProgressMessage = jest.fn();
6195
7091
  (method as any).protocolV2SourceUpdateProcess = jest.fn().mockResolvedValue(3);
6196
7092
  (method as any).enterProtocolV2BootloaderMode = jest.fn().mockResolvedValue(undefined);
7093
+ (method as any).ensureProtocolV2BootResourceStagingIsEmpty = jest
7094
+ .fn()
7095
+ .mockResolvedValue(undefined);
6197
7096
  (method as any).completeProtocolV2FinalVerification = jest.fn().mockResolvedValue({});
6198
7097
  (method as any).verifyProtocolV2StagedFile = jest.fn().mockResolvedValue(undefined);
6199
7098
  (method as any).protocolV2StartFirmwareUpdate = jest.fn();
6200
7099
  (method as any).waitForProtocolV2FirmwareUpdateComplete = jest.fn();
6201
7100
 
6202
- await (method as any).executeProtocolV2Update({
6203
- resourceBundles: [
7101
+ await (method as any).executeProtocolV2SourceUpdate({
7102
+ installSources: [],
7103
+ resourceSources: [
6204
7104
  {
6205
7105
  name: 'images.okpkg',
6206
- binary: new Uint8Array([1, 2, 3]).buffer,
7106
+ source: {
7107
+ size: 3,
7108
+ readAt: jest.fn(),
7109
+ close: jest.fn(),
7110
+ },
6207
7111
  devicePath: 'vol0:/resource/images/images.okpkg',
6208
7112
  },
6209
7113
  ],
6210
- bootloaderBinary: null,
6211
- fwBinaryMap: [],
6212
7114
  });
6213
7115
 
6214
7116
  expect((method as any).protocolV2SourceUpdateProcess).toHaveBeenCalledTimes(1);
7117
+ expect((method as any).protocolV2SourceUpdateProcess).toHaveBeenCalledWith(
7118
+ expect.objectContaining({ filePath: 'vol0:/resource/images/images.okpkg' })
7119
+ );
7120
+ expect((method as any).verifyProtocolV2StagedFile).toHaveBeenCalledWith(
7121
+ 'vol0:/resource/images/images.okpkg',
7122
+ 3
7123
+ );
6215
7124
  expect((method as any).protocolV2StartFirmwareUpdate).not.toHaveBeenCalled();
6216
7125
  expect((method as any).waitForProtocolV2FirmwareUpdateComplete).not.toHaveBeenCalled();
6217
7126
  });
6218
7127
 
7128
+ test('writes the mounted boot resource package through its staging path', async () => {
7129
+ const method = new FirmwareUpdateV4({
7130
+ id: 1,
7131
+ payload: {
7132
+ method: 'firmwareUpdateV4',
7133
+ },
7134
+ });
7135
+
7136
+ method.postTipMessage = jest.fn();
7137
+ method.postProgressMessage = jest.fn();
7138
+ (method as any).protocolV2SourceUpdateProcess = jest.fn().mockResolvedValue(3);
7139
+ (method as any).enterProtocolV2BootloaderMode = jest.fn().mockResolvedValue(undefined);
7140
+ (method as any).completeProtocolV2FinalVerification = jest.fn().mockResolvedValue({});
7141
+ (method as any).verifyProtocolV2StagedFile = jest.fn().mockResolvedValue(undefined);
7142
+
7143
+ await (method as any).executeProtocolV2SourceUpdate({
7144
+ installSources: [],
7145
+ resourceSources: [
7146
+ {
7147
+ name: 'boot_resource.okpkg',
7148
+ source: {
7149
+ size: 3,
7150
+ readAt: jest.fn(),
7151
+ close: jest.fn(),
7152
+ },
7153
+ devicePath: 'vol0:/loaders/bootloader/boot_resource.okpkg',
7154
+ },
7155
+ ],
7156
+ });
7157
+
7158
+ const stagingPath = 'vol0:/loaders/bootloader/boot_resource.okpkg.staging';
7159
+ expect((method as any).protocolV2SourceUpdateProcess).toHaveBeenCalledWith(
7160
+ expect.objectContaining({ filePath: stagingPath })
7161
+ );
7162
+ expect((method as any).verifyProtocolV2StagedFile).toHaveBeenCalledWith(stagingPath, 3);
7163
+ });
7164
+
7165
+ test('rewrites the mounted boot resource staging file even when the final file is current', async () => {
7166
+ const method = new FirmwareUpdateV4({
7167
+ id: 1,
7168
+ payload: {
7169
+ method: 'firmwareUpdateV4',
7170
+ },
7171
+ });
7172
+
7173
+ method.postTipMessage = jest.fn();
7174
+ method.postProgressMessage = jest.fn();
7175
+ (method as any).isProtocolV2ResourceBundleUpToDate = jest.fn().mockResolvedValue(true);
7176
+ (method as any).protocolV2SourceUpdateProcess = jest.fn().mockResolvedValue(3);
7177
+ (method as any).verifyProtocolV2StagedFile = jest.fn().mockResolvedValue(undefined);
7178
+
7179
+ const resource = {
7180
+ name: 'boot_resource.okpkg',
7181
+ source: {
7182
+ size: 3,
7183
+ readAt: jest.fn(),
7184
+ close: jest.fn(),
7185
+ },
7186
+ devicePath: 'vol0:/loaders/bootloader/boot_resource.okpkg',
7187
+ version: [1, 0, 0],
7188
+ payloadHash: '11'.repeat(64),
7189
+ headerHash: '22'.repeat(64),
7190
+ };
7191
+
7192
+ await (method as any).executeProtocolV2TransferPhase({
7193
+ installSources: [],
7194
+ resourceSources: [resource],
7195
+ });
7196
+
7197
+ const stagingPath = 'vol0:/loaders/bootloader/boot_resource.okpkg.staging';
7198
+ expect((method as any).isProtocolV2ResourceBundleUpToDate).not.toHaveBeenCalled();
7199
+ expect((method as any).protocolV2SourceUpdateProcess).toHaveBeenCalledWith(
7200
+ expect.objectContaining({ source: resource.source, filePath: stagingPath, totalSize: 3 })
7201
+ );
7202
+ expect((method as any).verifyProtocolV2StagedFile).toHaveBeenCalledWith(stagingPath, 3);
7203
+ });
7204
+
6219
7205
  test('uses absolute processed_byte offsets and disables append for firmware file writes', async () => {
6220
7206
  const method = new FirmwareUpdateV4({
6221
7207
  id: 1,
@@ -6919,64 +7905,6 @@ describe('Protocol V2 firmware update method', () => {
6919
7905
  });
6920
7906
 
6921
7907
  describe('Protocol V2 firmware reconnect identity', () => {
6922
- const createResourceFilesystemTypedCall = (
6923
- stable: Array<{ type: string; size: number; headerHash: string }>,
6924
- missingTypes: string[] = []
6925
- ) => {
6926
- const missing = new Set(missingTypes);
6927
- const resourceByPath = new Map(
6928
- stable.map(resource => [
6929
- PROTOCOL_V2_RESOURCE_DEVICE_PATHS[
6930
- resource.type as keyof typeof PROTOCOL_V2_RESOURCE_DEVICE_PATHS
6931
- ],
6932
- resource,
6933
- ])
6934
- );
6935
- return jest.fn((requestType: string, _responseType: string, payload: Record<string, any>) => {
6936
- if (requestType === 'ResourceInventoryGet') {
6937
- throw new Error('ResourceInventoryGet is unavailable on released firmware');
6938
- }
6939
- if (requestType === 'FilesystemPathInfoQuery') {
6940
- const resource = resourceByPath.get(payload.path);
6941
- const exists = Boolean(resource && !missing.has(resource.type));
6942
- return {
6943
- message: {
6944
- exist: exists,
6945
- directory: false,
6946
- size: exists ? resource?.size : 0,
6947
- },
6948
- };
6949
- }
6950
- if (requestType === 'FilesystemFileRead') {
6951
- const resource = resourceByPath.get(payload.file.path);
6952
- if (!resource || missing.has(resource.type)) throw new Error('missing resource');
6953
- const header = new Uint8Array(0x52a0);
6954
- const view = new DataView(header.buffer);
6955
- 'OKPP'.split('').forEach((char, index) => {
6956
- header[index] = char.charCodeAt(0);
6957
- });
6958
- 'RESC'.split('').forEach((char, index) => {
6959
- header[0x08 + index] = char.charCodeAt(0);
6960
- });
6961
- view.setUint32(0x0c, header.byteLength, true);
6962
- for (let index = 0; index < resource.headerHash.length / 2; index++) {
6963
- header[0x240 + index] = Number.parseInt(
6964
- resource.headerHash.slice(index * 2, index * 2 + 2),
6965
- 16
6966
- );
6967
- }
6968
- const offset = Number(payload.file.offset);
6969
- const chunkLength = Number(payload.chunk_len);
6970
- return {
6971
- message: {
6972
- data: header.slice(offset, offset + chunkLength),
6973
- },
6974
- };
6975
- }
6976
- throw new Error(`Unexpected request: ${requestType}`);
6977
- });
6978
- };
6979
-
6980
7908
  test('keeps the host plan identity as the V4 reconnect anchor', () => {
6981
7909
  const method = new FirmwareUpdateV4({
6982
7910
  id: 1,
@@ -7043,7 +7971,7 @@ describe('Protocol V2 firmware reconnect identity', () => {
7043
7971
  );
7044
7972
  });
7045
7973
 
7046
- test('rejects firmware update startup when DeviceInfo has no physical serial', async () => {
7974
+ test('uses the connection route when DeviceInfo has no physical serial', async () => {
7047
7975
  const method = new FirmwareUpdateV4({
7048
7976
  id: 1,
7049
7977
  payload: { method: 'firmwareUpdateV4' },
@@ -7053,12 +7981,98 @@ describe('Protocol V2 firmware reconnect identity', () => {
7053
7981
  message: { protocol_version: 1, hw: {} },
7054
7982
  });
7055
7983
  (method as any).device = stubDevice({
7984
+ originalDescriptor: { path: '' },
7985
+ getConnectId: () => '000000000000',
7056
7986
  getCommands: () => ({ typedCall }),
7057
7987
  });
7058
7988
 
7059
- await expect((method as any).captureProtocolV2PhysicalIdentity()).rejects.toMatchObject({
7060
- errorCode: HardwareErrorCode.DeviceNotFound,
7989
+ await expect((method as any).captureProtocolV2PhysicalIdentity()).resolves.toBeUndefined();
7990
+ expect((method as any).protocolV2ExpectedSerialNumber).toBeUndefined();
7991
+ expect((method as any).protocolV2ExpectedPath).toBe('000000000000');
7992
+ });
7993
+
7994
+ test('executes a serial-less Pro2 prepared plan when the live model matches', async () => {
7995
+ const planWithoutDigest = {
7996
+ schemaVersion: 2 as const,
7997
+ executor: 'v4' as const,
7998
+ deviceIdentity: 'unavailable',
7999
+ deviceModel: 'pro2',
8000
+ firmwareType: EFirmwareType.Universal,
8001
+ platform: 'web' as const,
8002
+ targetsToUpdate: ['boot' as const],
8003
+ artifacts: [
8004
+ {
8005
+ artifactId: 'component:boot',
8006
+ role: 'component' as const,
8007
+ target: 'boot' as const,
8008
+ url: 'https://example.com/bootloader.bin',
8009
+ container: 'raw' as const,
8010
+ logicalName: 'bootloader',
8011
+ expectedSize: 1,
8012
+ expectedSha256: 'a'.repeat(64),
8013
+ },
8014
+ ],
8015
+ };
8016
+ const preparedPlan = prepareFirmwareUpdatePlan({
8017
+ plan: {
8018
+ ...planWithoutDigest,
8019
+ planDigest: digestFirmwareUpdateContract(planWithoutDigest),
8020
+ },
8021
+ leaseRef: 'serial-less-v4-executor-test',
8022
+ artifacts: [
8023
+ {
8024
+ artifactId: 'component:boot',
8025
+ artifact: {
8026
+ artifactRef: 'serial-less-bootloader',
8027
+ size: 1,
8028
+ sha256: 'a'.repeat(64),
8029
+ },
8030
+ },
8031
+ ],
8032
+ });
8033
+ const hostBindingGeneration = registerFirmwareUpdateHostBinding({
8034
+ preparedPlanDigest: preparedPlan.preparedPlanDigest,
8035
+ artifactReader: {
8036
+ open: jest.fn(),
8037
+ read: jest.fn(),
8038
+ close: jest.fn(),
8039
+ },
7061
8040
  });
8041
+ try {
8042
+ const method = new FirmwareUpdateV4({
8043
+ id: 1,
8044
+ payload: {
8045
+ method: 'firmwareUpdateV4',
8046
+ platform: 'web',
8047
+ preparedPlan,
8048
+ hostBindingGeneration,
8049
+ },
8050
+ });
8051
+ method.init();
8052
+ const typedCall = jest.fn().mockResolvedValue({
8053
+ type: 'DeviceInfo',
8054
+ message: { protocol_version: 1, hw: {} },
8055
+ });
8056
+ (method as any).device = stubDevice({
8057
+ originalDescriptor: { protocolType: 'V2', path: 'serial-less-pro2' },
8058
+ features: {
8059
+ deviceType: 'pro2',
8060
+ firmwareVersion: '1.0.0',
8061
+ capabilities: [],
8062
+ },
8063
+ getCommands: () => ({ typedCall }),
8064
+ });
8065
+ (method as any).runProtocolV2PreparedArtifacts = jest
8066
+ .fn()
8067
+ .mockResolvedValue('serial-less-result');
8068
+
8069
+ await expect(method.run()).resolves.toBe('serial-less-result');
8070
+ expect((method as any).runProtocolV2PreparedArtifacts).toHaveBeenCalled();
8071
+ expect((method as any).protocolV2ExpectedSerialNumber).toBeUndefined();
8072
+ expect((method as any).protocolV2ExpectedPath).toBe('serial-less-pro2');
8073
+ } finally {
8074
+ unregisterFirmwareUpdateHostBinding(hostBindingGeneration);
8075
+ }
7062
8076
  });
7063
8077
 
7064
8078
  test('uses the BLE read limit when reading an existing firmware bundle header', async () => {
@@ -7091,292 +8105,94 @@ describe('Protocol V2 firmware reconnect identity', () => {
7091
8105
  expect(Math.max(...readChunkLengths)).toBe(900);
7092
8106
  });
7093
8107
 
7094
- test('uses filesystem reads instead of ResourceInventoryGet for resource comparison', async () => {
7095
- const method = new FirmwareUpdateV4({
7096
- id: 1,
7097
- payload: {
7098
- method: 'firmwareUpdateV4',
7099
- platform: 'web',
7100
- targetsToUpdate: ['resource'],
7101
- },
7102
- });
7103
- method.init();
7104
- const stable = ['images', 'animation', 'wallpaper', 'translations', 'roobert', 'noto'].map(
7105
- (type, index) => ({
7106
- type,
7107
- url: `https://example.com/${type}.okpkg`,
7108
- size: 0x52a0 + index + 1,
7109
- fileHash: 'a'.repeat(64),
7110
- headerHash: index.toString(16).padStart(128, '0'),
7111
- })
7112
- );
7113
- const typedCall = createResourceFilesystemTypedCall(stable);
7114
- (method as any).device = stubDevice({
7115
- getCommands: () => ({ typedCall }),
7116
- getCurrentDeviceType: () => 'pro2',
7117
- });
7118
- const resourcesSpy = jest
7119
- .spyOn(DataManager, 'getProtocolV2Resources')
7120
- .mockReturnValue(stable as any);
7121
- (method as any).downloadProtocolV2Resource = jest.fn();
7122
-
7123
- await expect((method as any).prepareProtocolV2ResourceBundles()).resolves.toEqual([]);
7124
- expect(typedCall.mock.calls.some(call => call[0] === 'ResourceInventoryGet')).toBe(false);
7125
- expect(typedCall.mock.calls.some(call => call[0] === 'FilesystemFileRead')).toBe(true);
7126
- expect((method as any).downloadProtocolV2Resource).not.toHaveBeenCalled();
7127
- resourcesSpy.mockRestore();
7128
- });
7129
-
7130
- test('downloads only changed resources from loader inventory', async () => {
7131
- const method = new FirmwareUpdateV4({
7132
- id: 1,
7133
- payload: { method: 'firmwareUpdateV4', platform: 'web', targetsToUpdate: ['resource'] },
7134
- });
7135
- method.init();
7136
- const stable = ['images', 'animation', 'wallpaper', 'translations', 'roobert', 'noto'].map(
7137
- (type, index) => ({
7138
- type,
7139
- url: `https://example.com/${type}.okpkg`,
7140
- size: 0x52a0 + index + 1,
7141
- fileHash: 'a'.repeat(64),
7142
- headerHash: index.toString(16).padStart(128, '0'),
7143
- })
7144
- );
7145
- const typedCall = createResourceFilesystemTypedCall(stable, ['images']);
7146
- (method as any).device = stubDevice({
7147
- getCommands: () => ({ typedCall }),
7148
- getCurrentDeviceType: () => 'pro2',
7149
- });
7150
- const resourcesSpy = jest
7151
- .spyOn(DataManager, 'getProtocolV2Resources')
7152
- .mockReturnValue(stable as any);
7153
- (method as any).downloadProtocolV2Resource = jest.fn(resource =>
7154
- Promise.resolve({
7155
- name: `${resource.type}.okpkg`,
7156
- binary: new ArrayBuffer(resource.size),
7157
- devicePath: `vol0:/${resource.type}.okpkg`,
7158
- })
7159
- );
7160
-
7161
- const bundles = await (method as any).prepareProtocolV2ResourceBundles();
7162
-
7163
- expect(bundles).toHaveLength(1);
7164
- expect((method as any).downloadProtocolV2Resource).toHaveBeenCalledWith(stable[0]);
7165
- resourcesSpy.mockRestore();
7166
- });
7167
-
7168
- test('uses filesystem inventory for incremental Bootloader recovery', async () => {
7169
- const method = new FirmwareUpdateV4({
7170
- id: 1,
7171
- payload: { method: 'firmwareUpdateV4', platform: 'web', targetsToUpdate: ['resource'] },
7172
- });
7173
- method.init();
7174
- const stable = ['images', 'animation', 'wallpaper', 'translations', 'roobert', 'noto'].map(
7175
- (type, index) => ({
7176
- type,
7177
- url: `https://example.com/${type}.okpkg`,
7178
- size: 0x52a0 + index + 1,
7179
- fileHash: 'a'.repeat(64),
7180
- headerHash: index.toString(16).padStart(128, '0'),
7181
- })
7182
- );
7183
- const typedCall = createResourceFilesystemTypedCall(stable, ['images']);
7184
- (method as any).device = stubDevice({
7185
- getCommands: () => ({ typedCall }),
7186
- getCurrentDeviceType: () => 'pro2',
7187
- });
7188
- const resourcesSpy = jest
7189
- .spyOn(DataManager, 'getProtocolV2Resources')
7190
- .mockReturnValue(stable as any);
7191
- (method as any).downloadProtocolV2Resource = jest.fn(resource =>
7192
- Promise.resolve({
7193
- name: `${resource.type}.okpkg`,
7194
- binary: new ArrayBuffer(resource.size),
7195
- devicePath: `vol0:/${resource.type}.okpkg`,
7196
- })
7197
- );
7198
-
7199
- const bundles = await (method as any).prepareProtocolV2ResourceBundles();
7200
-
7201
- expect(bundles).toHaveLength(1);
7202
- expect(typedCall.mock.calls.some(call => call[0] === 'ResourceInventoryGet')).toBe(false);
7203
- expect(typedCall.mock.calls.some(call => call[0] === 'FilesystemPathInfoQuery')).toBe(true);
7204
- expect((method as any).downloadProtocolV2Resource).toHaveBeenCalledWith(stable[0]);
7205
- resourcesSpy.mockRestore();
7206
- });
7207
-
7208
- test('does not resolve boot resources when no resource target is selected', async () => {
7209
- const method = new FirmwareUpdateV4({
7210
- id: 1,
7211
- payload: { method: 'firmwareUpdateV4', platform: 'web' },
7212
- });
7213
- method.init();
7214
- const configSpy = jest.spyOn(DataManager, 'getProtocolV2BootResources');
7215
- const downloadSpy = jest.spyOn(firmwareBinaryApi, 'getSysResourceBinary');
7216
-
7217
- await expect((method as any).prepareProtocolV2BootResources()).resolves.toBeUndefined();
7218
-
7219
- expect(configSpy).not.toHaveBeenCalled();
7220
- expect(downloadSpy).not.toHaveBeenCalled();
7221
- });
7222
-
7223
- test('continues a normal resource update when boot resources are not configured', async () => {
7224
- const method = new FirmwareUpdateV4({
7225
- id: 1,
7226
- payload: { method: 'firmwareUpdateV4', platform: 'web', targetsToUpdate: ['resource'] },
7227
- });
7228
- method.init();
7229
- (method as any).device = stubDevice({ getCurrentDeviceType: () => 'pro2' });
7230
- jest.spyOn(DataManager, 'getProtocolV2BootResources').mockReturnValue(undefined);
7231
- const downloadSpy = jest.spyOn(firmwareBinaryApi, 'getSysResourceBinary');
7232
-
7233
- await expect((method as any).prepareProtocolV2BootResources()).resolves.toBeUndefined();
7234
- expect(downloadSpy).not.toHaveBeenCalled();
7235
- });
7236
-
7237
- test('requires boot configuration for an explicit boot_resources target', async () => {
8108
+ test('skips resource transfer when installed okpkg hashes match the downloaded package', async () => {
7238
8109
  const method = new FirmwareUpdateV4({
7239
8110
  id: 1,
7240
8111
  payload: {
7241
8112
  method: 'firmwareUpdateV4',
7242
8113
  platform: 'web',
7243
- targetsToUpdate: ['boot_resources'],
7244
8114
  },
7245
8115
  });
7246
8116
  method.init();
7247
- (method as any).device = stubDevice({ getCurrentDeviceType: () => 'pro2' });
7248
- jest.spyOn(DataManager, 'getProtocolV2BootResources').mockReturnValue(undefined);
7249
-
7250
- await expect((method as any).prepareProtocolV2BootResources()).rejects.toThrow(
7251
- 'Missing Protocol V2 boot resources configuration'
7252
- );
7253
- });
7254
-
7255
- test('skips downloading a boot resource when the installed SHA-256 matches', async () => {
7256
- const bytes = new Uint8Array([1, 2, 3, 4]);
7257
- const fileHash = Array.from(sha256(bytes), byte => byte.toString(16).padStart(2, '0')).join('');
7258
- const resource = {
7259
- required: false as const,
7260
- target: 'RES' as const,
7261
- files: [
7262
- {
7263
- name: 'bootloader_crest.bin',
7264
- url: 'https://example.com/bootloader_crest.bin',
7265
- devicePath: 'vol0:/assets/loaders/boot.staging/graphics/bootloader_crest.bin',
7266
- size: bytes.byteLength,
7267
- fileHash,
7268
- },
7269
- ],
7270
- };
7271
- const typedCall = jest.fn((name: string, _response: string, payload: any) => {
7272
- if (name === 'FilesystemPathInfoQuery') {
8117
+ const installedBinary = new Uint8Array(createProtocolV2OkppBinary());
8118
+ const typedCall = jest.fn((requestType: string, _responseType: string, request: any) => {
8119
+ if (requestType === 'FilesystemPathInfoQuery') {
7273
8120
  return Promise.resolve({
7274
- message: { exist: true, directory: false, size: bytes.byteLength },
8121
+ message: { exist: true, directory: false, size: installedBinary.byteLength },
7275
8122
  });
7276
8123
  }
7277
- if (name === 'FilesystemFileRead') {
7278
- const offset = Number(payload.file.offset);
8124
+ if (requestType === 'FilesystemFileRead') {
7279
8125
  return Promise.resolve({
7280
- message: { data: bytes.slice(offset, offset + Number(payload.chunk_len)) },
8126
+ message: {
8127
+ data: installedBinary.slice(
8128
+ Number(request.file.offset),
8129
+ Number(request.file.offset) + Number(request.chunk_len)
8130
+ ),
8131
+ },
7281
8132
  });
7282
8133
  }
7283
- return Promise.reject(new Error(`Unexpected request: ${name}`));
7284
- });
7285
- const method = new FirmwareUpdateV4({
7286
- id: 1,
7287
- payload: { method: 'firmwareUpdateV4', platform: 'web', targetsToUpdate: ['resource'] },
8134
+ throw new Error(`Unexpected request: ${requestType}`);
7288
8135
  });
7289
- method.init();
7290
8136
  (method as any).device = stubDevice({
7291
- getCurrentDeviceType: () => 'pro2',
7292
8137
  getCommands: () => ({ typedCall }),
7293
8138
  });
7294
- jest.spyOn(DataManager, 'getProtocolV2BootResources').mockReturnValue(resource);
7295
- const downloadSpy = jest.spyOn(firmwareBinaryApi, 'getSysResourceBinary');
7296
-
7297
- await expect((method as any).prepareProtocolV2BootResources()).resolves.toEqual([]);
7298
- expect(typedCall.mock.calls.map(call => call[0])).toEqual([
7299
- 'FilesystemPathInfoQuery',
7300
- 'FilesystemFileRead',
7301
- ]);
7302
- expect(downloadSpy).not.toHaveBeenCalled();
7303
- });
7304
-
7305
- test('rejects duplicate device paths across explicit and remote resource sources', () => {
7306
- const method = new FirmwareUpdateV4({
7307
- id: 1,
7308
- payload: { method: 'firmwareUpdateV4' },
7309
- });
7310
- const devicePath = 'vol0:/assets/shared.bin';
7311
-
7312
- expect(() =>
7313
- (method as any).mergeProtocolV2ResourceBundles(
7314
- [{ name: 'local.bin', binary: new ArrayBuffer(1), devicePath }],
7315
- [{ name: 'remote.bin', binary: new ArrayBuffer(1), devicePath }]
7316
- )
7317
- ).toThrow(`Duplicate Protocol V2 resource devicePath: ${devicePath}`);
7318
- });
8139
+ method.postTipMessage = jest.fn();
8140
+ method.postProgressMessage = jest.fn();
8141
+ (method as any).protocolV2SourceUpdateProcess = jest.fn();
7319
8142
 
7320
- test('includes startup resources in the complete resource target', async () => {
7321
- const bytes = new Uint8Array([1, 2, 3, 4]);
7322
- const binary = bytes.buffer as ArrayBuffer;
7323
- const fileHash = Array.from(sha256(bytes), byte => byte.toString(16).padStart(2, '0')).join('');
7324
- const resource = {
7325
- required: false as const,
7326
- target: 'RES' as const,
7327
- files: [
8143
+ await (method as any).executeProtocolV2TransferPhase({
8144
+ installSources: [],
8145
+ resourceSources: [
7328
8146
  {
7329
- name: 'bootloader_crest.bin',
7330
- url: 'https://example.com/bootloader_crest.bin',
7331
- devicePath: 'vol0:/assets/loaders/boot.staging/graphics/bootloader_crest.bin',
7332
- size: bytes.byteLength,
7333
- fileHash,
8147
+ name: 'images.okpkg',
8148
+ source: { size: installedBinary.byteLength },
8149
+ devicePath: 'vol0:/bundles/images/images.okpkg',
8150
+ version: [1, 2, 3],
8151
+ payloadHash: '11'.repeat(64),
8152
+ headerHash: '22'.repeat(64),
7334
8153
  },
7335
8154
  ],
7336
- };
7337
- const method = new FirmwareUpdateV4({
7338
- id: 1,
7339
- payload: {
7340
- method: 'firmwareUpdateV4',
7341
- platform: 'web',
7342
- targetsToUpdate: ['resource'],
7343
- },
7344
8155
  });
7345
- method.init();
7346
- (method as any).device = stubDevice({ getCurrentDeviceType: () => 'pro2' });
7347
- jest.spyOn(DataManager, 'getProtocolV2BootResources').mockReturnValue(resource);
7348
- jest.spyOn(firmwareBinaryApi, 'getSysResourceBinary').mockResolvedValue({ binary });
7349
8156
 
7350
- await expect((method as any).prepareProtocolV2BootResources()).resolves.toEqual([
7351
- {
7352
- name: 'bootloader_crest.bin',
7353
- binary,
7354
- devicePath: 'vol0:/assets/loaders/boot.staging/graphics/bootloader_crest.bin',
7355
- },
7356
- ]);
8157
+ expect(typedCall).toHaveBeenCalledWith('FilesystemPathInfoQuery', 'FilesystemPathInfo', {
8158
+ path: 'vol0:/bundles/images/images.okpkg',
8159
+ });
8160
+ expect((method as any).protocolV2SourceUpdateProcess).not.toHaveBeenCalled();
7357
8161
  });
7358
8162
 
7359
- test('rejects a manually supplied resource file when its manifest hash does not match', () => {
8163
+ test('updates a resource when the installed file size differs despite matching headers', async () => {
7360
8164
  const method = new FirmwareUpdateV4({
7361
8165
  id: 1,
7362
8166
  payload: {
7363
8167
  method: 'firmwareUpdateV4',
7364
8168
  platform: 'web',
7365
- resourceFiles: [
7366
- {
7367
- binary: new Uint8Array([1, 2, 3]).buffer,
7368
- devicePath: 'vol0:/assets/loaders/boot.staging/graphics/bootloader_crest.bin',
7369
- size: 3,
7370
- fileHash: '00'.repeat(32),
7371
- },
7372
- ],
7373
8169
  },
7374
8170
  });
7375
8171
  method.init();
8172
+ const installedBinary = new Uint8Array(createProtocolV2OkppBinary());
8173
+ const typedCall = jest.fn((requestType: string) => {
8174
+ if (requestType === 'FilesystemPathInfoQuery') {
8175
+ return Promise.resolve({
8176
+ message: { exist: true, directory: false, size: installedBinary.byteLength + 1 },
8177
+ });
8178
+ }
8179
+ throw new Error(`Unexpected request: ${requestType}`);
8180
+ });
8181
+ (method as any).device = stubDevice({
8182
+ getCommands: () => ({ typedCall }),
8183
+ });
7376
8184
 
7377
- expect(() => (method as any).prepareExplicitProtocolV2ResourceFiles()).toThrow(
7378
- 'SHA-256 mismatch'
7379
- );
8185
+ await expect(
8186
+ (method as any).isProtocolV2ResourceBundleUpToDate({
8187
+ name: 'images.okpkg',
8188
+ source: { size: installedBinary.byteLength },
8189
+ devicePath: 'vol0:/bundles/images/images.okpkg',
8190
+ version: [1, 2, 3],
8191
+ payloadHash: '11'.repeat(64),
8192
+ headerHash: '22'.repeat(64),
8193
+ })
8194
+ ).resolves.toBe(false);
8195
+ expect(typedCall).toHaveBeenCalledTimes(1);
7380
8196
  });
7381
8197
  });
7382
8198