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

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 (98) hide show
  1. package/__tests__/check-all-firmware-release-protocol-v2.test.ts +404 -84
  2. package/__tests__/check-firmware-release-protocol-v2.test.ts +199 -0
  3. package/__tests__/firmware-memory-host.test.ts +112 -0
  4. package/__tests__/firmware-update/device-update-bootloader.test.ts +13 -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-plan.test.ts +232 -70
  9. package/__tests__/firmware-update/firmware-update-prepared-plan.test.ts +53 -1
  10. package/__tests__/firmware-update/firmware-update-prepared-resource-executors.test.ts +326 -0
  11. package/__tests__/firmware-update/firmware-update-v2-download-before-boot.test.ts +2 -2
  12. package/__tests__/protocol-v2-resources.test.ts +148 -232
  13. package/__tests__/protocol-v2.test.ts +1236 -516
  14. package/dist/api/CheckAllFirmwareRelease.d.ts +2 -3
  15. package/dist/api/CheckAllFirmwareRelease.d.ts.map +1 -1
  16. package/dist/api/CheckBLEFirmwareRelease.d.ts +3 -2
  17. package/dist/api/CheckBLEFirmwareRelease.d.ts.map +1 -1
  18. package/dist/api/CheckBootloaderRelease.d.ts +2 -1
  19. package/dist/api/CheckBootloaderRelease.d.ts.map +1 -1
  20. package/dist/api/CheckFirmwareRelease.d.ts +3 -2
  21. package/dist/api/CheckFirmwareRelease.d.ts.map +1 -1
  22. package/dist/api/FirmwareUpdateV2.d.ts +1 -0
  23. package/dist/api/FirmwareUpdateV2.d.ts.map +1 -1
  24. package/dist/api/FirmwareUpdateV3.d.ts.map +1 -1
  25. package/dist/api/FirmwareUpdateV4.d.ts +8 -8
  26. package/dist/api/FirmwareUpdateV4.d.ts.map +1 -1
  27. package/dist/api/UploadPortfolio.d.ts.map +1 -1
  28. package/dist/api/device/DeviceUpdateBootloader.d.ts.map +1 -1
  29. package/dist/api/firmware/FirmwareHostBinding.d.ts +1 -1
  30. package/dist/api/firmware/FirmwareHostBinding.d.ts.map +1 -1
  31. package/dist/api/firmware/FirmwareMemoryHost.d.ts +22 -0
  32. package/dist/api/firmware/FirmwareMemoryHost.d.ts.map +1 -0
  33. package/dist/api/firmware/FirmwarePreparedResourceArchive.d.ts +11 -0
  34. package/dist/api/firmware/FirmwarePreparedResourceArchive.d.ts.map +1 -0
  35. package/dist/api/firmware/FirmwareUpdatePlan.d.ts +48 -1
  36. package/dist/api/firmware/FirmwareUpdatePlan.d.ts.map +1 -1
  37. package/dist/api/firmware/FirmwareUpdatePreparedPlan.d.ts +6 -1
  38. package/dist/api/firmware/FirmwareUpdatePreparedPlan.d.ts.map +1 -1
  39. package/dist/api/firmware/getBinary.d.ts +0 -1
  40. package/dist/api/firmware/getBinary.d.ts.map +1 -1
  41. package/dist/api/firmware/protocolV2Release.d.ts +33 -0
  42. package/dist/api/firmware/protocolV2Release.d.ts.map +1 -0
  43. package/dist/data-manager/DataManager.d.ts +4 -3
  44. package/dist/data-manager/DataManager.d.ts.map +1 -1
  45. package/dist/index.d.ts +108 -114
  46. package/dist/index.d.ts.map +1 -1
  47. package/dist/index.js +1633 -967
  48. package/dist/protocols/protocol-v2/resources.d.ts +10 -28
  49. package/dist/protocols/protocol-v2/resources.d.ts.map +1 -1
  50. package/dist/types/api/checkAllFirmwareRelease.d.ts +20 -8
  51. package/dist/types/api/checkAllFirmwareRelease.d.ts.map +1 -1
  52. package/dist/types/api/checkBLEFirmwareRelease.d.ts +2 -13
  53. package/dist/types/api/checkBLEFirmwareRelease.d.ts.map +1 -1
  54. package/dist/types/api/checkBootloaderRelease.d.ts +2 -6
  55. package/dist/types/api/checkBootloaderRelease.d.ts.map +1 -1
  56. package/dist/types/api/checkFirmwareRelease.d.ts +2 -13
  57. package/dist/types/api/checkFirmwareRelease.d.ts.map +1 -1
  58. package/dist/types/api/checkFirmwareTypeAvailable.d.ts +2 -2
  59. package/dist/types/api/checkFirmwareTypeAvailable.d.ts.map +1 -1
  60. package/dist/types/api/deviceUpdateBootloader.d.ts.map +1 -1
  61. package/dist/types/api/export.d.ts +1 -1
  62. package/dist/types/api/export.d.ts.map +1 -1
  63. package/dist/types/api/firmwareUpdate.d.ts +4 -12
  64. package/dist/types/api/firmwareUpdate.d.ts.map +1 -1
  65. package/dist/types/api/firmwareUpdatePlan.d.ts +2 -2
  66. package/dist/types/api/firmwareUpdatePlan.d.ts.map +1 -1
  67. package/dist/types/settings.d.ts +35 -36
  68. package/dist/types/settings.d.ts.map +1 -1
  69. package/package.json +4 -4
  70. package/src/api/CheckAllFirmwareRelease.ts +108 -80
  71. package/src/api/CheckBLEFirmwareRelease.ts +37 -5
  72. package/src/api/CheckBootloaderRelease.ts +35 -3
  73. package/src/api/CheckFirmwareRelease.ts +35 -7
  74. package/src/api/FirmwareUpdateV2.ts +108 -73
  75. package/src/api/FirmwareUpdateV3.ts +101 -56
  76. package/src/api/FirmwareUpdateV4.ts +885 -403
  77. package/src/api/UploadPortfolio.ts +18 -0
  78. package/src/api/device/DeviceUpdateBootloader.ts +37 -11
  79. package/src/api/firmware/FirmwareHostBinding.ts +16 -3
  80. package/src/api/firmware/FirmwareMemoryHost.ts +142 -0
  81. package/src/api/firmware/FirmwarePreparedResourceArchive.ts +207 -0
  82. package/src/api/firmware/FirmwareUpdatePlan.ts +282 -122
  83. package/src/api/firmware/FirmwareUpdatePreparedPlan.ts +27 -3
  84. package/src/api/firmware/protocolV2Release.ts +168 -0
  85. package/src/data-manager/DataManager.ts +35 -19
  86. package/src/index.ts +10 -0
  87. package/src/protocols/protocol-v2/resources.ts +157 -341
  88. package/src/types/api/checkAllFirmwareRelease.ts +27 -12
  89. package/src/types/api/checkBLEFirmwareRelease.ts +4 -13
  90. package/src/types/api/checkBootloaderRelease.ts +2 -6
  91. package/src/types/api/checkFirmwareRelease.ts +2 -13
  92. package/src/types/api/checkFirmwareTypeAvailable.ts +2 -2
  93. package/src/types/api/deviceUpdateBootloader.ts +1 -0
  94. package/src/types/api/export.ts +6 -1
  95. package/src/types/api/firmwareUpdate.ts +12 -17
  96. package/src/types/api/firmwareUpdatePlan.ts +2 -2
  97. package/src/types/settings.ts +35 -61
  98. 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,7 @@ 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('requires the external host to prepare manifest resources before bootloader entry', async () => {
3971
4037
  const method = new FirmwareUpdateV4({
3972
4038
  id: 1,
3973
4039
  payload: {
@@ -3987,55 +4053,21 @@ describe('Protocol V2 firmware update targets', () => {
3987
4053
  bootloaderMode: false,
3988
4054
  capabilities: [],
3989
4055
  },
4056
+ getCurrentDeviceType: () => 'pro2',
3990
4057
  isBootloader: () => false,
3991
4058
  isRomloader: () => false,
3992
4059
  });
3993
4060
  (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
- });
4061
+ (method as any).enterProtocolV2BootloaderMode = jest.fn();
4029
4062
  method.postTipMessage = jest.fn();
4030
4063
 
4031
- await method.run();
4032
-
4033
- expect(order).toEqual([
4034
- 'prepare-startup-resources',
4035
- 'enter-bootloader',
4036
- 'prepare-resources',
4037
- 'execute-update',
4038
- ]);
4064
+ await expect(method.run()).rejects.toMatchObject({
4065
+ errorCode: HardwareErrorCode.RuntimeError,
4066
+ params: expect.objectContaining({
4067
+ firmwareUpdateCode: 'FirmwareArtifactsNotPrepared',
4068
+ }),
4069
+ });
4070
+ expect((method as any).enterProtocolV2BootloaderMode).not.toHaveBeenCalled();
4039
4071
  });
4040
4072
 
4041
4073
  test('reboots Protocol V2 normal-mode device to bootloader before transfer', async () => {
@@ -4204,9 +4236,6 @@ describe('Protocol V2 firmware update targets', () => {
4204
4236
  },
4205
4237
  });
4206
4238
  (method as any).captureProtocolV2PhysicalIdentity = jest.fn().mockResolvedValue(undefined);
4207
- const prepareResourceBundles = jest
4208
- .spyOn(method as any, 'prepareProtocolV2ResourceBundles')
4209
- .mockResolvedValue(undefined);
4210
4239
  (method as any).protocolV2Reboot = jest.fn();
4211
4240
  (method as any).executeProtocolV2Update = jest.fn().mockResolvedValue(undefined);
4212
4241
  (method as any).exitProtocolV2BootloaderToNormal = jest.fn().mockResolvedValue(undefined);
@@ -4219,7 +4248,6 @@ describe('Protocol V2 firmware update targets', () => {
4219
4248
 
4220
4249
  await method.run();
4221
4250
 
4222
- expect(prepareResourceBundles).not.toHaveBeenCalled();
4223
4251
  expect((method as any).protocolV2Reboot).not.toHaveBeenCalled();
4224
4252
  expect((method as any).executeProtocolV2Update).toHaveBeenCalledWith({
4225
4253
  bootloaderBinary: null,
@@ -5478,6 +5506,9 @@ describe('Protocol V2 firmware update targets', () => {
5478
5506
  return Number(params.processedSize ?? 0) + Number(params.source.size);
5479
5507
  });
5480
5508
  (method as any).enterProtocolV2BootloaderMode = jest.fn().mockResolvedValue(undefined);
5509
+ (method as any).ensureProtocolV2BootResourceStagingIsEmpty = jest
5510
+ .fn()
5511
+ .mockResolvedValue(undefined);
5481
5512
  (method as any).exitProtocolV2BootloaderToNormal = jest.fn().mockResolvedValue(undefined);
5482
5513
  (method as any).waitForProtocolV2FinalFeatures = jest.fn().mockResolvedValue({});
5483
5514
  (method as any).completeProtocolV2FinalVerification = jest.fn().mockResolvedValue({});
@@ -5551,6 +5582,9 @@ describe('Protocol V2 firmware update targets', () => {
5551
5582
  Promise.resolve(Number(params.processedSize ?? 0) + Number(params.source.size))
5552
5583
  );
5553
5584
  (method as any).enterProtocolV2BootloaderMode = jest.fn().mockResolvedValue(undefined);
5585
+ (method as any).ensureProtocolV2BootResourceStagingIsEmpty = jest
5586
+ .fn()
5587
+ .mockResolvedValue(undefined);
5554
5588
  (method as any).exitProtocolV2BootloaderToNormal = jest.fn().mockResolvedValue(undefined);
5555
5589
  (method as any).completeProtocolV2FinalVerification = jest.fn().mockResolvedValue({});
5556
5590
  (method as any).verifyProtocolV2StagedFile = jest.fn().mockResolvedValue(undefined);
@@ -5558,21 +5592,33 @@ describe('Protocol V2 firmware update targets', () => {
5558
5592
  (method as any).waitForProtocolV2FirmwareUpdateComplete = jest
5559
5593
  .fn()
5560
5594
  .mockResolvedValue(undefined);
5595
+ (method as any).isProtocolV2ResourceBundleUpToDate = jest.fn().mockResolvedValue(false);
5561
5596
 
5562
- await (method as any).executeProtocolV2Update({
5563
- resourceBundles: [
5597
+ await (method as any).executeProtocolV2SourceUpdate({
5598
+ resourceSources: [
5564
5599
  {
5565
5600
  name: 'images.okpkg',
5566
- binary: new Uint8Array([1, 2]).buffer,
5601
+ source: {
5602
+ size: 2,
5603
+ readAt: jest.fn(),
5604
+ close: jest.fn(),
5605
+ },
5567
5606
  devicePath: 'vol0:/bundles/images/images.okpkg',
5607
+ version: [1, 2, 3],
5608
+ payloadHash: '11'.repeat(64),
5609
+ headerHash: '22'.repeat(64),
5568
5610
  },
5569
5611
  ],
5570
- bootloaderBinary: null,
5571
- fwBinaryMap: [
5612
+ installSources: [
5572
5613
  {
5573
5614
  fileName: 'application_p1.bin',
5574
- binary: new Uint8Array([3]).buffer,
5615
+ source: {
5616
+ size: 1,
5617
+ readAt: jest.fn(),
5618
+ close: jest.fn(),
5619
+ },
5575
5620
  targetId: 4,
5621
+ kind: 'firmware',
5576
5622
  },
5577
5623
  ],
5578
5624
  });
@@ -5589,6 +5635,100 @@ describe('Protocol V2 firmware update targets', () => {
5589
5635
  2,
5590
5636
  expect.objectContaining({ processedSize: 0, totalSize: 1 })
5591
5637
  );
5638
+ expect((method as any).isProtocolV2ResourceBundleUpToDate).toHaveBeenCalledWith(
5639
+ expect.objectContaining({
5640
+ version: [1, 2, 3],
5641
+ payloadHash: '11'.repeat(64),
5642
+ headerHash: '22'.repeat(64),
5643
+ })
5644
+ );
5645
+ });
5646
+
5647
+ test('installs and verifies bootloader before syncing manifest resources', () => {
5648
+ const method = new FirmwareUpdateV4({
5649
+ id: 1,
5650
+ payload: { method: 'firmwareUpdateV4' },
5651
+ });
5652
+ const phases = (method as any).buildProtocolV2ExecutionPhases({
5653
+ installSources: [{ kind: 'bootloader' }, { kind: 'component' }],
5654
+ resourceSources: [{ kind: 'resource' }],
5655
+ });
5656
+
5657
+ expect(phases.map((phase: { kind: string }) => phase.kind)).toEqual([
5658
+ 'bootloader-install',
5659
+ 'bootloader-verify',
5660
+ 'resource-sync',
5661
+ 'component-install',
5662
+ 'final-verify',
5663
+ ]);
5664
+ });
5665
+
5666
+ test('stages the mounted boot resource before any firmware install phase', () => {
5667
+ const method = new FirmwareUpdateV4({
5668
+ id: 1,
5669
+ payload: { method: 'firmwareUpdateV4' },
5670
+ });
5671
+ const bootResource = {
5672
+ devicePath: 'vol0:/loaders/bootloader/boot_resource.okpkg',
5673
+ };
5674
+ const phases = (method as any).buildProtocolV2ExecutionPhases({
5675
+ installSources: [{ kind: 'bootloader' }, { kind: 'component' }],
5676
+ resourceSources: [bootResource, { devicePath: 'vol0:/resource/images/images.okpkg' }],
5677
+ });
5678
+
5679
+ expect(phases.map((phase: { kind: string }) => phase.kind)).toEqual([
5680
+ 'resource-sync',
5681
+ 'bootloader-install',
5682
+ 'bootloader-verify',
5683
+ 'resource-sync',
5684
+ 'component-install',
5685
+ 'final-verify',
5686
+ ]);
5687
+ expect(phases[0].resourceSources).toEqual([bootResource]);
5688
+ });
5689
+
5690
+ test('removes stale boot resource staging before a component-only reboot', async () => {
5691
+ const method = new FirmwareUpdateV4({
5692
+ id: 1,
5693
+ payload: { method: 'firmwareUpdateV4' },
5694
+ });
5695
+ const events: string[] = [];
5696
+ let stagingExists = true;
5697
+ const typedCall = jest.fn((requestType: string) => {
5698
+ if (requestType === 'FilesystemPathInfoQuery') {
5699
+ return Promise.resolve({
5700
+ type: 'FilesystemPathInfo',
5701
+ message: { exist: stagingExists, directory: false, size: stagingExists ? 3 : 0 },
5702
+ });
5703
+ }
5704
+ if (requestType === 'FilesystemFileDelete') {
5705
+ events.push('delete-stale-staging');
5706
+ stagingExists = false;
5707
+ return Promise.resolve({ type: 'Success', message: {} });
5708
+ }
5709
+ throw new Error(`Unexpected request: ${requestType}`);
5710
+ });
5711
+ (method as any).device = stubDevice({ getCommands: () => ({ typedCall }) });
5712
+ (method as any).enterProtocolV2BootloaderMode = jest.fn().mockResolvedValue(undefined);
5713
+ (method as any).executeProtocolV2TransferPhase = jest.fn().mockImplementation(() => {
5714
+ events.push('transfer-component');
5715
+ return Promise.resolve();
5716
+ });
5717
+ (method as any).exitProtocolV2BootloaderToNormal = jest.fn().mockImplementation(() => {
5718
+ events.push('reboot-normal');
5719
+ return Promise.resolve();
5720
+ });
5721
+ (method as any).completeProtocolV2FinalVerification = jest.fn().mockResolvedValue({});
5722
+
5723
+ await (method as any).executeProtocolV2SourceUpdate({
5724
+ installSources: [{ kind: 'firmware' }],
5725
+ resourceSources: [],
5726
+ });
5727
+
5728
+ expect(events).toEqual(['delete-stale-staging', 'transfer-component', 'reboot-normal']);
5729
+ expect(typedCall).toHaveBeenNthCalledWith(2, 'FilesystemFileDelete', 'Success', {
5730
+ path: 'vol0:/loaders/bootloader/boot_resource.okpkg.staging',
5731
+ });
5592
5732
  });
5593
5733
 
5594
5734
  test('does not request installation when the staged file size does not match', async () => {
@@ -5603,6 +5743,9 @@ describe('Protocol V2 firmware update targets', () => {
5603
5743
  method.postProgressMessage = jest.fn();
5604
5744
  (method as any).protocolV2SourceUpdateProcess = jest.fn().mockResolvedValue(3);
5605
5745
  (method as any).enterProtocolV2BootloaderMode = jest.fn().mockResolvedValue(undefined);
5746
+ (method as any).ensureProtocolV2BootResourceStagingIsEmpty = jest
5747
+ .fn()
5748
+ .mockResolvedValue(undefined);
5606
5749
  (method as any).device = stubDevice({
5607
5750
  getCommands: () => ({
5608
5751
  typedCall: jest.fn().mockResolvedValue({
@@ -5654,6 +5797,9 @@ describe('Protocol V2 firmware update targets', () => {
5654
5797
  Promise.resolve(Number(params.processedSize ?? 0) + Number(params.source.size))
5655
5798
  );
5656
5799
  (method as any).enterProtocolV2BootloaderMode = jest.fn().mockResolvedValue(undefined);
5800
+ (method as any).ensureProtocolV2BootResourceStagingIsEmpty = jest
5801
+ .fn()
5802
+ .mockResolvedValue(undefined);
5657
5803
  (method as any).exitProtocolV2BootloaderToNormal = jest.fn().mockResolvedValue(undefined);
5658
5804
  (method as any).completeProtocolV2FinalVerification = jest.fn().mockResolvedValue({});
5659
5805
  (method as any).verifyProtocolV2StagedFile = jest.fn().mockResolvedValue(undefined);
@@ -5733,6 +5879,14 @@ describe('Protocol V2 firmware update targets', () => {
5733
5879
  binary: binaries.get(url) ?? new Uint8Array([0]).buffer,
5734
5880
  })
5735
5881
  );
5882
+ const componentIntegrity = (url: string) => {
5883
+ const binary = binaries.get(url);
5884
+ if (!binary) throw new Error(`Missing test firmware binary: ${url}`);
5885
+ return {
5886
+ expectedSize: binary.byteLength,
5887
+ fingerprint: bytesToHex(sha256(new Uint8Array(binary))),
5888
+ };
5889
+ };
5736
5890
  const getFirmwareLatestReleaseSpy = jest
5737
5891
  .spyOn(DataManager, 'getFirmwareLatestRelease')
5738
5892
  .mockReturnValue({
@@ -5757,23 +5911,43 @@ describe('Protocol V2 firmware update targets', () => {
5757
5911
  bootloader: {
5758
5912
  target: 'BOOTLOADER',
5759
5913
  url: 'https://example.com/bootloader.pp.bin',
5914
+ ...componentIntegrity('https://example.com/bootloader.pp.bin'),
5760
5915
  },
5761
5916
  applicationP1: {
5762
5917
  target: 'APPLICATION_P1',
5763
5918
  url: 'https://example.com/applicationP1.pp.bin',
5919
+ ...componentIntegrity('https://example.com/applicationP1.pp.bin'),
5764
5920
  },
5765
5921
  applicationP2: {
5766
5922
  target: 'APPLICATION_P2',
5767
5923
  url: 'https://example.com/applicationP2.pp.bin',
5924
+ ...componentIntegrity('https://example.com/applicationP2.pp.bin'),
5768
5925
  },
5769
5926
  coprocessor: {
5770
5927
  target: 'COPROCESSOR',
5771
5928
  url: 'https://example.com/coprocessor.pp.bin',
5929
+ ...componentIntegrity('https://example.com/coprocessor.pp.bin'),
5930
+ },
5931
+ se01: {
5932
+ target: 'SE01',
5933
+ url: 'https://example.com/se01.pp.bin',
5934
+ ...componentIntegrity('https://example.com/se01.pp.bin'),
5935
+ },
5936
+ se02: {
5937
+ target: 'SE02',
5938
+ url: 'https://example.com/se02.pp.bin',
5939
+ ...componentIntegrity('https://example.com/se02.pp.bin'),
5940
+ },
5941
+ se03: {
5942
+ target: 'SE03',
5943
+ url: 'https://example.com/se03.pp.bin',
5944
+ ...componentIntegrity('https://example.com/se03.pp.bin'),
5945
+ },
5946
+ se04: {
5947
+ target: 'SE04',
5948
+ url: 'https://example.com/se04.pp.bin',
5949
+ ...componentIntegrity('https://example.com/se04.pp.bin'),
5772
5950
  },
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
5951
  },
5778
5952
  fingerprint: '',
5779
5953
  changelog: {
@@ -5814,6 +5988,84 @@ describe('Protocol V2 firmware update targets', () => {
5814
5988
  getFirmwareLatestReleaseSpy.mockRestore();
5815
5989
  });
5816
5990
 
5991
+ test('downloads only firmware targets that are missing from an explicit mixed payload', async () => {
5992
+ const explicitApplicationBinary = new Uint8Array([1]).buffer;
5993
+ const remoteCoprocessorBinary = new Uint8Array([2]).buffer;
5994
+ const method = new FirmwareUpdateV4({
5995
+ id: 1,
5996
+ payload: {
5997
+ method: 'firmwareUpdateV4',
5998
+ platform: 'web',
5999
+ targetsToUpdate: ['app_v1', 'coprocessor'],
6000
+ },
6001
+ });
6002
+ method.init();
6003
+
6004
+ const getSysResourceBinarySpy = jest
6005
+ .spyOn(firmwareBinaryApi, 'getSysResourceBinary')
6006
+ .mockResolvedValue({ binary: remoteCoprocessorBinary });
6007
+ const getFirmwareLatestReleaseSpy = jest
6008
+ .spyOn(DataManager, 'getFirmwareLatestRelease')
6009
+ .mockReturnValue({
6010
+ required: false,
6011
+ version: [1, 0, 0],
6012
+ url: 'https://example.com/applicationP1.pp.bin',
6013
+ installOrder: ['applicationP1', 'coprocessor'],
6014
+ components: {
6015
+ applicationP1: {
6016
+ target: 'APPLICATION_P1',
6017
+ url: 'https://example.com/applicationP1.pp.bin',
6018
+ expectedSize: explicitApplicationBinary.byteLength,
6019
+ fingerprint: bytesToHex(sha256(new Uint8Array(explicitApplicationBinary))),
6020
+ },
6021
+ coprocessor: {
6022
+ target: 'COPROCESSOR',
6023
+ url: 'https://example.com/coprocessor.pp.bin',
6024
+ expectedSize: remoteCoprocessorBinary.byteLength,
6025
+ fingerprint: bytesToHex(sha256(new Uint8Array(remoteCoprocessorBinary))),
6026
+ },
6027
+ },
6028
+ fingerprint: '',
6029
+ changelog: {
6030
+ 'zh-CN': '',
6031
+ 'en-US': '',
6032
+ },
6033
+ });
6034
+
6035
+ const remoteBinaries = await (method as any).prepareRemoteProtocolV2Binaries(
6036
+ 'universal',
6037
+ { deviceType: 'pro2', firmwareVersion: '0.0.0' },
6038
+ [
6039
+ {
6040
+ fileName: 'application_p1.bin',
6041
+ binary: explicitApplicationBinary,
6042
+ targetId: 4,
6043
+ kind: 'firmware',
6044
+ },
6045
+ ]
6046
+ );
6047
+
6048
+ expect(getSysResourceBinarySpy).toHaveBeenCalledTimes(1);
6049
+ expect(getSysResourceBinarySpy).toHaveBeenCalledWith('https://example.com/coprocessor.pp.bin');
6050
+ expect(remoteBinaries.installItems).toEqual([
6051
+ {
6052
+ fileName: 'application_p1.bin',
6053
+ binary: explicitApplicationBinary,
6054
+ targetId: 4,
6055
+ kind: 'firmware',
6056
+ },
6057
+ {
6058
+ fileName: 'coprocessor.bin',
6059
+ binary: remoteCoprocessorBinary,
6060
+ targetId: 6,
6061
+ kind: 'firmware',
6062
+ },
6063
+ ]);
6064
+
6065
+ getSysResourceBinarySpy.mockRestore();
6066
+ getFirmwareLatestReleaseSpy.mockRestore();
6067
+ });
6068
+
5817
6069
  test('ignores an unsupported remote ROMLOADER component when installing selected targets', async () => {
5818
6070
  const method = new FirmwareUpdateV4({
5819
6071
  id: 1,
@@ -5978,211 +6230,759 @@ describe('Protocol V2 firmware update targets', () => {
5978
6230
  });
5979
6231
  });
5980
6232
 
5981
- test('rejects prepared RESC bundle paths before opening an artifact source', async () => {
6233
+ test('maps a prepared resource archive manifest to artifact-backed resource sources', async () => {
5982
6234
  const method = new FirmwareUpdateV4({
5983
6235
  id: 1,
5984
6236
  payload: {
5985
6237
  method: 'firmwareUpdateV4',
5986
6238
  },
5987
6239
  });
5988
- const openPreparedSource = jest.fn();
5989
- (method as any).params = {
5990
- targetsToUpdate: ['resource'],
5991
- resourceBundleArtifacts: [
6240
+ const imagesBinary = createProtocolV2OkppBinary();
6241
+ const bootBinary = createProtocolV2OkppBinary({ payloadHashByte: 0x33 });
6242
+ const imagesSha256 = bytesToHex(sha256(new Uint8Array(imagesBinary)));
6243
+ const bootSha256 = bytesToHex(sha256(new Uint8Array(bootBinary)));
6244
+ const manifest = {
6245
+ schema: 1,
6246
+ artifact_name: 'pro2-resource',
6247
+ release_name: 'test-release',
6248
+ variant: 'resource',
6249
+ commit: 'abc123',
6250
+ short_sha: 'abc123',
6251
+ timestamp_utc: '2026-08-09T00:00:00Z',
6252
+ core_version: '1.0.0',
6253
+ key_set: 'production',
6254
+ device_root: 'vol0:',
6255
+ restore_mode: 'bootloader_update',
6256
+ trees: [{ path: 'bundles', device: 'pro2' }],
6257
+ files: [
5992
6258
  {
5993
- name: 'images',
5994
- artifact: {
5995
- artifactRef: `fw:${'a'.repeat(64)}`,
5996
- size: 1,
5997
- sha256: 'a'.repeat(64),
5998
- },
6259
+ archive_path: 'bundles/images/images.okpkg',
6260
+ original_name: 'images.okpkg',
6261
+ device_path: 'vol0:/bundles/images/images.okpkg',
6262
+ size: imagesBinary.byteLength,
6263
+ sha256: imagesSha256,
6264
+ signed: true,
6265
+ sig_algo: 'ed25519',
6266
+ payload_version: '1.2.3',
6267
+ },
6268
+ {
6269
+ archive_path: 'loaders/bootloader/boot_resource.okpkg',
6270
+ original_name: 'boot_resource.okpkg',
6271
+ device_path: 'vol0:/loaders/bootloader/boot_resource.okpkg',
6272
+ size: bootBinary.byteLength,
6273
+ sha256: bootSha256,
6274
+ signed: true,
6275
+ sig_algo: 'ed25519',
6276
+ payload_version: '1.2.3',
5999
6277
  },
6000
6278
  ],
6001
6279
  };
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: [
6280
+ const manifestBinary = new TextEncoder().encode(JSON.stringify(manifest)).buffer;
6281
+ const zip = new JSZip();
6282
+ zip.file('manifest.json', manifestBinary);
6283
+ zip.file('bundles/images/images.okpkg', imagesBinary);
6284
+ zip.file('loaders/bootloader/boot_resource.okpkg', bootBinary);
6285
+ const archiveBinary = await zip.generateAsync({ type: 'arraybuffer' });
6286
+ const preparedEntries = [
6287
+ {
6288
+ entryName: 'manifest.json',
6289
+ artifact: {
6290
+ artifactRef: 'manifest',
6291
+ size: manifestBinary.byteLength,
6292
+ sha256: bytesToHex(sha256(new Uint8Array(manifestBinary))),
6293
+ },
6294
+ },
6295
+ {
6296
+ entryName: 'bundles/images/images.okpkg',
6297
+ artifact: {
6298
+ artifactRef: 'images',
6299
+ size: imagesBinary.byteLength,
6300
+ sha256: imagesSha256,
6301
+ },
6302
+ },
6303
+ {
6304
+ entryName: 'loaders/bootloader/boot_resource.okpkg',
6305
+ artifact: {
6306
+ artifactRef: 'boot',
6307
+ size: bootBinary.byteLength,
6308
+ sha256: bootSha256,
6309
+ },
6310
+ },
6311
+ ];
6312
+ const artifacts = new Map([['archive', archiveBinary]]);
6313
+ (method as any).params = {
6314
+ targetsToUpdate: ['resource'],
6315
+ preparedPlan: {
6316
+ artifacts: [
6010
6317
  {
6011
- name: 'images',
6012
- url: 'https://example.com/images.okpkg',
6013
- devicePath: 'vol0:/resource/../images.okpkg',
6318
+ artifactId: 'resource:archive',
6319
+ role: 'resourceBundle',
6320
+ target: 'resource',
6321
+ container: 'zip',
6322
+ artifact: {
6323
+ artifactRef: 'archive',
6324
+ size: archiveBinary.byteLength,
6325
+ sha256: bytesToHex(sha256(new Uint8Array(archiveBinary))),
6326
+ },
6327
+ materializedEntries: preparedEntries,
6014
6328
  },
6015
6329
  ],
6016
- fingerprint: '',
6017
- changelog: {
6018
- 'zh-CN': '',
6019
- 'en-US': '',
6020
- },
6021
- });
6330
+ },
6331
+ };
6332
+ (method as any).openProtocolV2PreparedSource = jest.fn(async artifact => {
6333
+ const binary = artifacts.get(artifact.artifactRef);
6334
+ if (!binary) throw new Error(`Unknown artifact: ${artifact.artifactRef}`);
6335
+ return openFirmwareByteSource({ binary });
6336
+ });
6022
6337
 
6023
- await expect(
6024
- (method as any).prepareProtocolV2ResourceSources('universal', {
6025
- deviceType: 'pro2',
6026
- })
6027
- ).rejects.toThrow('resourceBundles[].devicePath');
6028
- expect(openPreparedSource).not.toHaveBeenCalled();
6338
+ const sources = await (method as any).prepareProtocolV2ResourceArchiveSources();
6029
6339
 
6030
- getFirmwareLatestReleaseSpy.mockRestore();
6340
+ expect(sources).toHaveLength(2);
6341
+ expect(sources).toEqual([
6342
+ expect.objectContaining({
6343
+ name: 'images.okpkg',
6344
+ devicePath: 'vol0:/bundles/images/images.okpkg',
6345
+ version: [1, 2, 3],
6346
+ }),
6347
+ expect.objectContaining({
6348
+ name: 'boot_resource.okpkg',
6349
+ devicePath: 'vol0:/loaders/bootloader/boot_resource.okpkg',
6350
+ version: [1, 2, 3],
6351
+ }),
6352
+ ]);
6353
+ await Promise.all(sources.map(source => source.source.close()));
6354
+
6355
+ preparedEntries[1].artifact.sha256 = 'f'.repeat(64);
6356
+ await expect((method as any).prepareProtocolV2ResourceArchiveSources()).rejects.toThrow(
6357
+ 'entries do not match the approved archive'
6358
+ );
6031
6359
  });
6032
6360
 
6033
- test('stops a remote update before reboot when the latest config cannot be refreshed', async () => {
6034
- const method = new FirmwareUpdateV4({
6035
- id: 1,
6036
- payload: {
6037
- method: 'firmwareUpdateV4',
6038
- platform: 'web',
6361
+ test('binds a prepared resource archive to the selected host generation', () => {
6362
+ const planWithoutDigest = {
6363
+ schemaVersion: 2 as const,
6364
+ executor: 'v4' as const,
6365
+ deviceIdentity: 'PRO2-SERIAL',
6366
+ deviceModel: 'pro2',
6367
+ firmwareType: EFirmwareType.Universal,
6368
+ platform: 'web' as const,
6369
+ targetsToUpdate: ['resource' as const],
6370
+ artifacts: [
6371
+ {
6372
+ artifactId: 'resource:archive',
6373
+ role: 'resourceBundle' as const,
6374
+ target: 'resource' as const,
6375
+ url: 'https://example.com/resource.zip',
6376
+ container: 'zip' as const,
6377
+ logicalName: 'protocol-v2-resource-archive',
6378
+ expectedSize: 3,
6379
+ expectedSha256: 'a'.repeat(64),
6380
+ },
6381
+ ],
6382
+ };
6383
+ const plan = {
6384
+ ...planWithoutDigest,
6385
+ planDigest: digestFirmwareUpdateContract(planWithoutDigest),
6386
+ };
6387
+ const preparedPlan = prepareFirmwareUpdatePlan({
6388
+ plan,
6389
+ leaseRef: 'resource-archive-test',
6390
+ artifacts: [
6391
+ {
6392
+ artifactId: 'resource:archive',
6393
+ artifact: {
6394
+ artifactRef: 'resource-archive',
6395
+ size: 3,
6396
+ sha256: 'a'.repeat(64),
6397
+ },
6398
+ materializedEntries: [
6399
+ {
6400
+ entryName: 'manifest.json',
6401
+ artifact: {
6402
+ artifactRef: 'resource-manifest',
6403
+ size: 2,
6404
+ sha256: 'b'.repeat(64),
6405
+ },
6406
+ },
6407
+ ],
6408
+ },
6409
+ ],
6410
+ });
6411
+ const mismatchedHostBindingGeneration = registerFirmwareUpdateHostBinding({
6412
+ preparedPlanDigest: 'c'.repeat(64),
6413
+ artifactReader: {
6414
+ open: jest.fn(),
6415
+ read: jest.fn(),
6416
+ close: jest.fn(),
6039
6417
  },
6040
6418
  });
6041
- method.init();
6042
- (method as any).captureProtocolV2PhysicalIdentity = jest.fn().mockResolvedValue(undefined);
6043
- (method as any).device = stubDevice({
6044
- originalDescriptor: { protocolType: 'V2' },
6045
- features: { deviceType: 'pro2', firmwareVersion: '0.0.0', capabilities: [] },
6046
- });
6047
- forceReloadDataSpy.mockRejectedValue(
6048
- ERRORS.TypedError(HardwareErrorCode.NetworkError, 'latest config unavailable')
6049
- );
6050
- (method as any).prepareRemoteProtocolV2Binaries = jest.fn();
6051
- (method as any).enterProtocolV2BootloaderMode = jest.fn();
6052
- method.postTipMessage = jest.fn();
6419
+ try {
6420
+ const mismatchedMethod = new FirmwareUpdateV4({
6421
+ id: 1,
6422
+ payload: {
6423
+ method: 'firmwareUpdateV4',
6424
+ platform: 'web',
6425
+ targetsToUpdate: ['resource'],
6426
+ preparedPlan,
6427
+ hostBindingGeneration: mismatchedHostBindingGeneration,
6428
+ },
6429
+ });
6053
6430
 
6054
- await expect(method.run()).rejects.toMatchObject({
6055
- errorCode: HardwareErrorCode.NetworkError,
6056
- message: 'latest config unavailable',
6431
+ expect(() => mismatchedMethod.init()).toThrow('does not match the prepared plan');
6432
+ } finally {
6433
+ unregisterFirmwareUpdateHostBinding(mismatchedHostBindingGeneration);
6434
+ }
6435
+ const hostBindingGeneration = registerFirmwareUpdateHostBinding({
6436
+ preparedPlanDigest: preparedPlan.preparedPlanDigest,
6437
+ artifactReader: {
6438
+ open: jest.fn(),
6439
+ read: jest.fn(),
6440
+ close: jest.fn(),
6441
+ },
6057
6442
  });
6443
+ try {
6444
+ const method = new FirmwareUpdateV4({
6445
+ id: 1,
6446
+ payload: {
6447
+ method: 'firmwareUpdateV4',
6448
+ platform: 'web',
6449
+ targetsToUpdate: ['resource'],
6450
+ preparedPlan,
6451
+ hostBindingGeneration,
6452
+ },
6453
+ });
6058
6454
 
6059
- expect((method as any).prepareRemoteProtocolV2Binaries).not.toHaveBeenCalled();
6060
- expect((method as any).enterProtocolV2BootloaderMode).not.toHaveBeenCalled();
6455
+ expect(() => method.init()).not.toThrow();
6456
+ } finally {
6457
+ unregisterFirmwareUpdateHostBinding(hostBindingGeneration);
6458
+ }
6061
6459
  });
6062
6460
 
6063
- test('keeps explicit Protocol V2 binaries isolated from remote component auto-fill', async () => {
6064
- const method = new FirmwareUpdateV4({
6065
- id: 1,
6066
- payload: {
6067
- method: 'firmwareUpdateV4',
6068
- platform: 'web',
6069
- coprocessorBinary: new Uint8Array([1]).buffer,
6461
+ test('executes a prepared component plan without duplicated component inputs', async () => {
6462
+ const componentBinary = new Uint8Array([1, 2, 3]).buffer;
6463
+ const componentArtifact = {
6464
+ artifactRef: 'prepared-bootloader',
6465
+ size: componentBinary.byteLength,
6466
+ sha256: 'a'.repeat(64),
6467
+ };
6468
+ const planWithoutDigest = {
6469
+ schemaVersion: 2 as const,
6470
+ executor: 'v4' as const,
6471
+ deviceIdentity: 'PRO2-SERIAL',
6472
+ deviceModel: 'pro2',
6473
+ firmwareType: EFirmwareType.Universal,
6474
+ platform: 'web' as const,
6475
+ targetsToUpdate: ['boot' as const],
6476
+ artifacts: [
6477
+ {
6478
+ artifactId: 'component:boot',
6479
+ role: 'component' as const,
6480
+ target: 'boot' as const,
6481
+ url: 'https://example.com/bootloader.bin',
6482
+ container: 'raw' as const,
6483
+ logicalName: 'bootloader',
6484
+ expectedSize: componentArtifact.size,
6485
+ expectedSha256: componentArtifact.sha256,
6486
+ targetVersion: '1.2.3',
6487
+ },
6488
+ ],
6489
+ };
6490
+ const preparedPlan = prepareFirmwareUpdatePlan({
6491
+ plan: {
6492
+ ...planWithoutDigest,
6493
+ planDigest: digestFirmwareUpdateContract(planWithoutDigest),
6070
6494
  },
6495
+ leaseRef: 'prepared-component-test',
6496
+ artifacts: [{ artifactId: 'component:boot', artifact: componentArtifact }],
6071
6497
  });
6072
- method.init();
6073
- (method as any).captureProtocolV2PhysicalIdentity = jest.fn().mockResolvedValue(undefined);
6074
-
6075
- (method as any).device = stubDevice({
6076
- originalDescriptor: { protocolType: 'V2' },
6077
- features: { deviceType: 'pro2', firmwareVersion: '0.0.0', capabilities: [] },
6078
- });
6079
- (method as any).prepareRemoteProtocolV2Binaries = jest.fn();
6080
- (method as any).enterProtocolV2BootloaderMode = jest.fn().mockResolvedValue(true);
6081
- (method as any).executeProtocolV2Update = jest.fn().mockResolvedValue(undefined);
6082
- (method as any).exitProtocolV2BootloaderToNormal = jest.fn().mockResolvedValue(undefined);
6083
- (method as any).waitForProtocolV2FinalFeatures = jest.fn().mockResolvedValue({
6084
- bootloaderVersion: '1.0.0',
6085
- bleVersion: '0.0.0',
6086
- firmwareVersion: '1.0.0',
6087
- });
6088
- method.postTipMessage = jest.fn();
6089
-
6090
- await method.run();
6091
-
6092
- 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 }],
6498
+ const artifactReader = {
6499
+ open: jest.fn(() =>
6500
+ Promise.resolve({
6501
+ readerId: 'prepared-component-reader',
6502
+ size: componentBinary.byteLength,
6503
+ })
6504
+ ),
6505
+ read: jest.fn(({ offset, length }: { offset: number; length: number }) => {
6506
+ const data = componentBinary.slice(offset, offset + length);
6507
+ return Promise.resolve({
6508
+ data,
6509
+ bytesRead: data.byteLength,
6510
+ eof: offset + length === componentBinary.byteLength,
6511
+ });
6512
+ }),
6513
+ close: jest.fn(() => Promise.resolve()),
6514
+ };
6515
+ const hostBindingGeneration = registerFirmwareUpdateHostBinding({
6516
+ preparedPlanDigest: preparedPlan.preparedPlanDigest,
6517
+ artifactReader,
6096
6518
  });
6097
- });
6519
+ const releaseSpy = jest
6520
+ .spyOn(DataManager, 'getFirmwareLatestRelease')
6521
+ .mockReturnValue(undefined);
6098
6522
 
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;
6523
+ try {
6103
6524
  const method = new FirmwareUpdateV4({
6104
6525
  id: 1,
6105
6526
  payload: {
6106
6527
  method: 'firmwareUpdateV4',
6107
6528
  platform: 'web',
6108
- targetsToUpdate: [target],
6109
- resourceFiles: [
6529
+ preparedPlan,
6530
+ hostBindingGeneration,
6531
+ },
6532
+ });
6533
+
6534
+ expect(() => method.init()).not.toThrow();
6535
+ expect((method as any).params.targetsToUpdate).toEqual(['boot']);
6536
+ expect((method as any).params.expectedTargetVersions).toEqual({ boot: '1.2.3' });
6537
+ expect((method as any).params.componentArtifacts).toBeUndefined();
6538
+ (method as any).postTipMessage = jest.fn();
6539
+ (method as any).prepareProtocolV2ResourceSources = jest.fn().mockResolvedValue([]);
6540
+ (method as any).executeProtocolV2SourceUpdate = jest
6541
+ .fn()
6542
+ .mockResolvedValue('prepared-result');
6543
+
6544
+ await expect(
6545
+ (method as any).runProtocolV2PreparedArtifacts({} as Features, EFirmwareType.Universal)
6546
+ ).resolves.toBe('prepared-result');
6547
+ expect(releaseSpy).not.toHaveBeenCalled();
6548
+ expect(artifactReader.open).toHaveBeenCalledWith({ artifactRef: 'prepared-bootloader' });
6549
+ expect((method as any).executeProtocolV2SourceUpdate).toHaveBeenCalledWith({
6550
+ installSources: [
6551
+ expect.objectContaining({
6552
+ fileName: 'bootloader.bin',
6553
+ kind: 'bootloader',
6554
+ }),
6555
+ ],
6556
+ resourceSources: [],
6557
+ });
6558
+ } finally {
6559
+ releaseSpy.mockRestore();
6560
+ unregisterFirmwareUpdateHostBinding(hostBindingGeneration);
6561
+ }
6562
+ });
6563
+
6564
+ test('executes a resource-only prepared plan without a live firmware release', async () => {
6565
+ const planWithoutDigest = {
6566
+ schemaVersion: 2 as const,
6567
+ executor: 'v4' as const,
6568
+ deviceIdentity: 'PRO2-SERIAL',
6569
+ deviceModel: 'pro2',
6570
+ firmwareType: EFirmwareType.Universal,
6571
+ platform: 'web' as const,
6572
+ targetsToUpdate: ['resource' as const],
6573
+ artifacts: [
6574
+ {
6575
+ artifactId: 'resource:archive',
6576
+ role: 'resourceBundle' as const,
6577
+ target: 'resource' as const,
6578
+ url: 'https://example.com/resource.zip',
6579
+ container: 'zip' as const,
6580
+ logicalName: 'protocol-v2-resource-archive',
6581
+ expectedSize: 3,
6582
+ expectedSha256: 'a'.repeat(64),
6583
+ },
6584
+ ],
6585
+ };
6586
+ const preparedPlan = prepareFirmwareUpdatePlan({
6587
+ plan: {
6588
+ ...planWithoutDigest,
6589
+ planDigest: digestFirmwareUpdateContract(planWithoutDigest),
6590
+ },
6591
+ leaseRef: 'prepared-resource-only-test',
6592
+ artifacts: [
6593
+ {
6594
+ artifactId: 'resource:archive',
6595
+ artifact: {
6596
+ artifactRef: 'prepared-resource-archive',
6597
+ size: 3,
6598
+ sha256: 'a'.repeat(64),
6599
+ },
6600
+ materializedEntries: [
6110
6601
  {
6111
- binary: resourceBundle,
6112
- devicePath: ' VOL0:/resource/images/images.okpkg ',
6602
+ entryName: 'manifest.json',
6603
+ artifact: {
6604
+ artifactRef: 'prepared-resource-manifest',
6605
+ size: 2,
6606
+ sha256: 'b'.repeat(64),
6607
+ },
6113
6608
  },
6114
6609
  ],
6115
6610
  },
6116
- });
6117
- method.init();
6118
- (method as any).captureProtocolV2PhysicalIdentity = jest.fn().mockResolvedValue(undefined);
6119
- const bootResourcesSpy = jest.spyOn(DataManager, 'getProtocolV2BootResources');
6611
+ ],
6612
+ });
6613
+ const hostBindingGeneration = registerFirmwareUpdateHostBinding({
6614
+ preparedPlanDigest: preparedPlan.preparedPlanDigest,
6615
+ artifactReader: {
6616
+ open: jest.fn(),
6617
+ read: jest.fn(),
6618
+ close: jest.fn(),
6619
+ },
6620
+ });
6621
+ const releaseSpy = jest
6622
+ .spyOn(DataManager, 'getFirmwareLatestRelease')
6623
+ .mockReturnValue(undefined);
6120
6624
 
6121
- (method as any).device = stubDevice({
6122
- originalDescriptor: { protocolType: 'V2' },
6123
- features: { deviceType: 'pro2', firmwareVersion: '0.0.0', capabilities: [] },
6625
+ try {
6626
+ const method = new FirmwareUpdateV4({
6627
+ id: 1,
6628
+ payload: {
6629
+ method: 'firmwareUpdateV4',
6630
+ platform: 'web',
6631
+ preparedPlan,
6632
+ hostBindingGeneration,
6633
+ },
6124
6634
  });
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',
6635
+
6636
+ expect(() => method.init()).not.toThrow();
6637
+ (method as any).postTipMessage = jest.fn();
6638
+ const resourceSources = [{ kind: 'resource' }];
6639
+ (method as any).prepareProtocolV2ResourceSources = jest
6640
+ .fn()
6641
+ .mockResolvedValue(resourceSources);
6642
+ (method as any).executeProtocolV2SourceUpdate = jest
6643
+ .fn()
6644
+ .mockResolvedValue('resource-result');
6645
+
6646
+ await expect(
6647
+ (method as any).runProtocolV2PreparedArtifacts({} as Features, EFirmwareType.Universal)
6648
+ ).resolves.toBe('resource-result');
6649
+ expect(releaseSpy).not.toHaveBeenCalled();
6650
+ expect((method as any).executeProtocolV2SourceUpdate).toHaveBeenCalledWith({
6651
+ installSources: [],
6652
+ resourceSources,
6133
6653
  });
6134
- method.postTipMessage = jest.fn();
6654
+ } finally {
6655
+ releaseSpy.mockRestore();
6656
+ unregisterFirmwareUpdateHostBinding(hostBindingGeneration);
6657
+ }
6658
+ });
6135
6659
 
6136
- await method.run();
6660
+ test('converts a local resource ZIP into a local PreparedPlan without matching a remote release', async () => {
6661
+ const imagesBinary = new Uint8Array([1, 2, 3]).buffer;
6662
+ const bootResourceBinary = new Uint8Array([4, 5, 6]).buffer;
6663
+ const manifest = {
6664
+ schema: 1,
6665
+ artifact_name: 'pro2-resource',
6666
+ release_name: 'local-development',
6667
+ variant: 'resource',
6668
+ commit: 'local',
6669
+ short_sha: 'local',
6670
+ timestamp_utc: '2026-08-09T00:00:00Z',
6671
+ core_version: '1.0.0',
6672
+ key_set: 'development',
6673
+ device_root: 'vol0:',
6674
+ restore_mode: 'bootloader_update',
6675
+ trees: [{ path: 'bundles', device: 'pro2' }],
6676
+ files: [
6677
+ {
6678
+ archive_path: 'bundles/images/images.okpkg',
6679
+ original_name: 'images.okpkg',
6680
+ device_path: 'vol0:/bundles/images/images.okpkg',
6681
+ size: imagesBinary.byteLength,
6682
+ sha256: bytesToHex(sha256(new Uint8Array(imagesBinary))),
6683
+ signed: true,
6684
+ sig_algo: 'ed25519',
6685
+ payload_version: '1.0.0',
6686
+ },
6687
+ {
6688
+ archive_path: 'loaders/bootloader/boot_resource.okpkg',
6689
+ original_name: 'boot_resource.okpkg',
6690
+ device_path: 'vol0:/loaders/bootloader/boot_resource.okpkg',
6691
+ size: bootResourceBinary.byteLength,
6692
+ sha256: bytesToHex(sha256(new Uint8Array(bootResourceBinary))),
6693
+ signed: true,
6694
+ sig_algo: 'ed25519',
6695
+ payload_version: '1.0.0',
6696
+ },
6697
+ ],
6698
+ };
6699
+ const zip = new JSZip();
6700
+ zip.file('manifest.json', JSON.stringify(manifest));
6701
+ zip.file('bundles/images/images.okpkg', imagesBinary);
6702
+ zip.file('loaders/bootloader/boot_resource.okpkg', bootResourceBinary);
6703
+ const resourceArchiveBinary = await zip.generateAsync({ type: 'arraybuffer' });
6704
+ const applicationP1Binary = new Uint8Array([7, 8, 9]).buffer;
6705
+ const method = new FirmwareUpdateV4({
6706
+ id: 1,
6707
+ payload: {
6708
+ method: 'firmwareUpdateV4',
6709
+ platform: 'web',
6710
+ targetsToUpdate: ['app_v1'],
6711
+ applicationP1Binary,
6712
+ resourceArchiveBinary,
6713
+ },
6714
+ });
6715
+ method.init();
6716
+ expect((method as any).params.targetsToUpdate).toEqual(['app_v1', 'resource']);
6717
+ (method as any).captureProtocolV2PhysicalIdentity = jest.fn().mockResolvedValue(undefined);
6718
+ (method as any).postTipMessage = jest.fn();
6719
+ (method as any).device = stubDevice({
6720
+ originalDescriptor: { protocolType: 'V2' },
6721
+ features: {
6722
+ deviceType: 'pro2',
6723
+ serialNo: 'pro2-device-id',
6724
+ firmwareVersion: '1.0.0',
6725
+ capabilities: [],
6726
+ },
6727
+ getCurrentDeviceType: () => 'pro2',
6728
+ });
6729
+ (method as any).protocolV2ExpectedSerialNumber = 'pro2-device-id';
6730
+ (method as any).executeProtocolV2SourceUpdate = jest
6731
+ .fn()
6732
+ .mockResolvedValue('local-resource-result');
6733
+ const releaseSpy = jest.spyOn(DataManager, 'getFirmwareLatestRelease');
6734
+ const reloadSpy = jest.spyOn(DataManager, 'forceReloadData');
6137
6735
 
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(
6736
+ try {
6737
+ await expect(method.run()).resolves.toBe('local-resource-result');
6738
+ expect(releaseSpy).not.toHaveBeenCalled();
6739
+ expect(reloadSpy).not.toHaveBeenCalled();
6740
+ expect((method as any).params.preparedPlan).toMatchObject({
6741
+ networkPolicy: 'forbid',
6742
+ executor: 'v4',
6743
+ targetsToUpdate: ['app_v1', 'resource'],
6744
+ });
6745
+ expect((method as any).executeProtocolV2SourceUpdate).toHaveBeenCalledWith(
6142
6746
  expect.objectContaining({
6143
- resourceBundles: [
6144
- {
6747
+ installSources: [
6748
+ expect.objectContaining({
6749
+ fileName: 'application_p1.bin',
6750
+ targetId: 4,
6751
+ }),
6752
+ ],
6753
+ resourceSources: [
6754
+ expect.objectContaining({
6145
6755
  name: 'images.okpkg',
6146
- binary: resourceBundle,
6147
- devicePath: 'vol0:/resource/images/images.okpkg',
6148
- },
6756
+ devicePath: 'vol0:/bundles/images/images.okpkg',
6757
+ }),
6758
+ expect.objectContaining({
6759
+ name: 'boot_resource.okpkg',
6760
+ devicePath: 'vol0:/loaders/bootloader/boot_resource.okpkg',
6761
+ }),
6149
6762
  ],
6150
6763
  })
6151
6764
  );
6765
+ } finally {
6766
+ releaseSpy.mockRestore();
6767
+ reloadSpy.mockRestore();
6152
6768
  }
6153
- );
6769
+ });
6154
6770
 
6155
- test('rejects manual RESC bundle paths before bootloader entry', async () => {
6771
+ test('rejects a local resource ZIP whose file hash does not match its manifest', async () => {
6772
+ const resourceBinary = new Uint8Array([1, 2, 3]).buffer;
6773
+ const bootResourceBinary = new Uint8Array([4, 5, 6]).buffer;
6774
+ const zip = new JSZip();
6775
+ zip.file(
6776
+ 'manifest.json',
6777
+ JSON.stringify({
6778
+ schema: 1,
6779
+ artifact_name: 'pro2-resource',
6780
+ release_name: 'local-development',
6781
+ variant: 'resource',
6782
+ commit: 'local',
6783
+ short_sha: 'local',
6784
+ timestamp_utc: '2026-08-09T00:00:00Z',
6785
+ core_version: '1.0.0',
6786
+ key_set: 'development',
6787
+ device_root: 'vol0:',
6788
+ restore_mode: 'bootloader_update',
6789
+ trees: [{ path: 'bundles', device: 'pro2' }],
6790
+ files: [
6791
+ {
6792
+ archive_path: 'bundles/images/images.okpkg',
6793
+ original_name: 'images.okpkg',
6794
+ device_path: 'vol0:/bundles/images/images.okpkg',
6795
+ size: resourceBinary.byteLength,
6796
+ sha256: '0'.repeat(64),
6797
+ signed: true,
6798
+ sig_algo: 'ed25519',
6799
+ payload_version: '1.0.0',
6800
+ },
6801
+ {
6802
+ archive_path: 'loaders/bootloader/boot_resource.okpkg',
6803
+ original_name: 'boot_resource.okpkg',
6804
+ device_path: 'vol0:/loaders/bootloader/boot_resource.okpkg',
6805
+ size: bootResourceBinary.byteLength,
6806
+ sha256: bytesToHex(sha256(new Uint8Array(bootResourceBinary))),
6807
+ signed: true,
6808
+ sig_algo: 'ed25519',
6809
+ payload_version: '1.0.0',
6810
+ },
6811
+ ],
6812
+ })
6813
+ );
6814
+ zip.file('bundles/images/images.okpkg', resourceBinary);
6815
+ zip.file('loaders/bootloader/boot_resource.okpkg', bootResourceBinary);
6156
6816
  const method = new FirmwareUpdateV4({
6157
6817
  id: 1,
6158
6818
  payload: {
6159
6819
  method: 'firmwareUpdateV4',
6160
6820
  platform: 'web',
6161
- resourceFiles: [
6162
- {
6163
- binary: new Uint8Array([1, 2, 3]).buffer,
6164
- devicePath: 'vol2:/resource/images/images.okpkg',
6821
+ targetsToUpdate: ['resource'],
6822
+ },
6823
+ });
6824
+ method.init();
6825
+
6826
+ await expect(
6827
+ (method as any).prepareProtocolV2LocalResourceArchive(
6828
+ await zip.generateAsync({ type: 'arraybuffer' })
6829
+ )
6830
+ ).rejects.toThrow(
6831
+ 'Protocol V2 local resource file does not match manifest: bundles/images/images.okpkg'
6832
+ );
6833
+ });
6834
+
6835
+ test('rejects an oversized ZIP entry before allocating its decompressed bytes', async () => {
6836
+ const extractEntry = jest.fn();
6837
+ const loadSpy = jest.spyOn(JSZip, 'loadAsync').mockResolvedValue({
6838
+ files: {
6839
+ 'manifest.json': {
6840
+ name: 'manifest.json',
6841
+ dir: false,
6842
+ _data: {
6843
+ compressedSize: 1,
6844
+ uncompressedSize: 2 * 1024 * 1024,
6165
6845
  },
6166
- ],
6846
+ async: extractEntry,
6847
+ },
6848
+ },
6849
+ } as unknown as JSZip);
6850
+ const method = new FirmwareUpdateV4({
6851
+ id: 1,
6852
+ payload: {
6853
+ method: 'firmwareUpdateV4',
6854
+ platform: 'web',
6855
+ targetsToUpdate: ['resource'],
6167
6856
  },
6168
6857
  });
6169
6858
  method.init();
6859
+
6860
+ try {
6861
+ await expect(
6862
+ (method as any).prepareProtocolV2LocalResourceArchive(new Uint8Array([1]).buffer)
6863
+ ).rejects.toThrow('declared size exceeds the allowed limit');
6864
+ expect(extractEntry).not.toHaveBeenCalled();
6865
+ } finally {
6866
+ loadSpy.mockRestore();
6867
+ }
6868
+ });
6869
+
6870
+ test('normalizes the deprecated boot_resources target to resource', () => {
6871
+ const method = new FirmwareUpdateV4({
6872
+ id: 1,
6873
+ payload: {
6874
+ method: 'firmwareUpdateV4',
6875
+ platform: 'web',
6876
+ targetsToUpdate: ['boot_resources'],
6877
+ },
6878
+ });
6879
+
6880
+ method.init();
6881
+ expect((method as any).params.targetsToUpdate).toEqual(['resource']);
6882
+ });
6883
+
6884
+ test('keeps the legacy componentArtifacts and artifactReader path without a prepared Plan', async () => {
6885
+ const artifactReader = {
6886
+ open: jest.fn(),
6887
+ read: jest.fn(),
6888
+ close: jest.fn(),
6889
+ };
6890
+ const method = new FirmwareUpdateV4({
6891
+ id: 1,
6892
+ payload: {
6893
+ method: 'firmwareUpdateV4',
6894
+ platform: 'web',
6895
+ targetsToUpdate: ['boot'],
6896
+ artifactReader,
6897
+ componentArtifacts: {
6898
+ boot: {
6899
+ artifactRef: 'legacy-bootloader',
6900
+ size: 3,
6901
+ sha256: 'a'.repeat(64),
6902
+ },
6903
+ },
6904
+ },
6905
+ });
6906
+
6907
+ expect(() => method.init()).not.toThrow();
6170
6908
  (method as any).captureProtocolV2PhysicalIdentity = jest.fn().mockResolvedValue(undefined);
6909
+ (method as any).device = stubDevice({
6910
+ originalDescriptor: { protocolType: 'V2' },
6911
+ features: { deviceType: 'pro2', firmwareVersion: '1.0.0', capabilities: [] },
6912
+ });
6913
+ (method as any).runProtocolV2PreparedArtifacts = jest.fn().mockResolvedValue('legacy-result');
6171
6914
 
6915
+ await expect(method.run()).resolves.toBe('legacy-result');
6916
+ expect((method as any).runProtocolV2PreparedArtifacts).toHaveBeenCalledTimes(1);
6917
+ });
6918
+
6919
+ test('stops a remote update before reboot when the latest config cannot be refreshed', async () => {
6920
+ const method = new FirmwareUpdateV4({
6921
+ id: 1,
6922
+ payload: {
6923
+ method: 'firmwareUpdateV4',
6924
+ platform: 'web',
6925
+ },
6926
+ });
6927
+ method.init();
6928
+ (method as any).captureProtocolV2PhysicalIdentity = jest.fn().mockResolvedValue(undefined);
6172
6929
  (method as any).device = stubDevice({
6173
6930
  originalDescriptor: { protocolType: 'V2' },
6174
6931
  features: { deviceType: 'pro2', firmwareVersion: '0.0.0', capabilities: [] },
6175
6932
  });
6933
+ forceReloadDataSpy.mockRejectedValue(
6934
+ ERRORS.TypedError(HardwareErrorCode.NetworkError, 'latest config unavailable')
6935
+ );
6176
6936
  (method as any).prepareRemoteProtocolV2Binaries = jest.fn();
6177
6937
  (method as any).enterProtocolV2BootloaderMode = jest.fn();
6178
6938
  method.postTipMessage = jest.fn();
6179
6939
 
6180
- await expect(method.run()).rejects.toThrow('resourceFiles[0].devicePath');
6940
+ await expect(method.run()).rejects.toMatchObject({
6941
+ errorCode: HardwareErrorCode.NetworkError,
6942
+ message: 'latest config unavailable',
6943
+ });
6944
+
6181
6945
  expect((method as any).prepareRemoteProtocolV2Binaries).not.toHaveBeenCalled();
6182
6946
  expect((method as any).enterProtocolV2BootloaderMode).not.toHaveBeenCalled();
6183
6947
  });
6184
6948
 
6185
- test('syncs resource bundles without sending a firmware install request', async () => {
6949
+ test('keeps explicit Protocol V2 binaries isolated from remote component auto-fill', async () => {
6950
+ const method = new FirmwareUpdateV4({
6951
+ id: 1,
6952
+ payload: {
6953
+ method: 'firmwareUpdateV4',
6954
+ platform: 'web',
6955
+ coprocessorBinary: new Uint8Array([1]).buffer,
6956
+ },
6957
+ });
6958
+ method.init();
6959
+ (method as any).captureProtocolV2PhysicalIdentity = jest.fn().mockResolvedValue(undefined);
6960
+
6961
+ (method as any).device = stubDevice({
6962
+ originalDescriptor: { protocolType: 'V2' },
6963
+ features: { deviceType: 'pro2', firmwareVersion: '0.0.0', capabilities: [] },
6964
+ });
6965
+ (method as any).prepareRemoteProtocolV2Binaries = jest.fn();
6966
+ (method as any).enterProtocolV2BootloaderMode = jest.fn().mockResolvedValue(true);
6967
+ (method as any).executeProtocolV2Update = jest.fn().mockResolvedValue(undefined);
6968
+ (method as any).exitProtocolV2BootloaderToNormal = jest.fn().mockResolvedValue(undefined);
6969
+ (method as any).waitForProtocolV2FinalFeatures = jest.fn().mockResolvedValue({
6970
+ bootloaderVersion: '1.0.0',
6971
+ bleVersion: '0.0.0',
6972
+ firmwareVersion: '1.0.0',
6973
+ });
6974
+ method.postTipMessage = jest.fn();
6975
+
6976
+ await method.run();
6977
+
6978
+ expect((method as any).prepareRemoteProtocolV2Binaries).not.toHaveBeenCalled();
6979
+ expect((method as any).executeProtocolV2Update).toHaveBeenCalledWith({
6980
+ bootloaderBinary: null,
6981
+ fwBinaryMap: [{ fileName: 'coprocessor.bin', binary: expect.anything(), targetId: 6 }],
6982
+ });
6983
+ });
6984
+
6985
+ test('syncs prepared resource sources without sending a firmware install request', async () => {
6186
6986
  const method = new FirmwareUpdateV4({
6187
6987
  id: 1,
6188
6988
  payload: {
@@ -6194,28 +6994,118 @@ describe('Protocol V2 firmware update targets', () => {
6194
6994
  method.postProgressMessage = jest.fn();
6195
6995
  (method as any).protocolV2SourceUpdateProcess = jest.fn().mockResolvedValue(3);
6196
6996
  (method as any).enterProtocolV2BootloaderMode = jest.fn().mockResolvedValue(undefined);
6997
+ (method as any).ensureProtocolV2BootResourceStagingIsEmpty = jest
6998
+ .fn()
6999
+ .mockResolvedValue(undefined);
6197
7000
  (method as any).completeProtocolV2FinalVerification = jest.fn().mockResolvedValue({});
6198
7001
  (method as any).verifyProtocolV2StagedFile = jest.fn().mockResolvedValue(undefined);
6199
7002
  (method as any).protocolV2StartFirmwareUpdate = jest.fn();
6200
7003
  (method as any).waitForProtocolV2FirmwareUpdateComplete = jest.fn();
6201
7004
 
6202
- await (method as any).executeProtocolV2Update({
6203
- resourceBundles: [
7005
+ await (method as any).executeProtocolV2SourceUpdate({
7006
+ installSources: [],
7007
+ resourceSources: [
6204
7008
  {
6205
7009
  name: 'images.okpkg',
6206
- binary: new Uint8Array([1, 2, 3]).buffer,
7010
+ source: {
7011
+ size: 3,
7012
+ readAt: jest.fn(),
7013
+ close: jest.fn(),
7014
+ },
6207
7015
  devicePath: 'vol0:/resource/images/images.okpkg',
6208
7016
  },
6209
7017
  ],
6210
- bootloaderBinary: null,
6211
- fwBinaryMap: [],
6212
7018
  });
6213
7019
 
6214
7020
  expect((method as any).protocolV2SourceUpdateProcess).toHaveBeenCalledTimes(1);
7021
+ expect((method as any).protocolV2SourceUpdateProcess).toHaveBeenCalledWith(
7022
+ expect.objectContaining({ filePath: 'vol0:/resource/images/images.okpkg' })
7023
+ );
7024
+ expect((method as any).verifyProtocolV2StagedFile).toHaveBeenCalledWith(
7025
+ 'vol0:/resource/images/images.okpkg',
7026
+ 3
7027
+ );
6215
7028
  expect((method as any).protocolV2StartFirmwareUpdate).not.toHaveBeenCalled();
6216
7029
  expect((method as any).waitForProtocolV2FirmwareUpdateComplete).not.toHaveBeenCalled();
6217
7030
  });
6218
7031
 
7032
+ test('writes the mounted boot resource package through its staging path', async () => {
7033
+ const method = new FirmwareUpdateV4({
7034
+ id: 1,
7035
+ payload: {
7036
+ method: 'firmwareUpdateV4',
7037
+ },
7038
+ });
7039
+
7040
+ method.postTipMessage = jest.fn();
7041
+ method.postProgressMessage = jest.fn();
7042
+ (method as any).protocolV2SourceUpdateProcess = jest.fn().mockResolvedValue(3);
7043
+ (method as any).enterProtocolV2BootloaderMode = jest.fn().mockResolvedValue(undefined);
7044
+ (method as any).completeProtocolV2FinalVerification = jest.fn().mockResolvedValue({});
7045
+ (method as any).verifyProtocolV2StagedFile = jest.fn().mockResolvedValue(undefined);
7046
+
7047
+ await (method as any).executeProtocolV2SourceUpdate({
7048
+ installSources: [],
7049
+ resourceSources: [
7050
+ {
7051
+ name: 'boot_resource.okpkg',
7052
+ source: {
7053
+ size: 3,
7054
+ readAt: jest.fn(),
7055
+ close: jest.fn(),
7056
+ },
7057
+ devicePath: 'vol0:/loaders/bootloader/boot_resource.okpkg',
7058
+ },
7059
+ ],
7060
+ });
7061
+
7062
+ const stagingPath = 'vol0:/loaders/bootloader/boot_resource.okpkg.staging';
7063
+ expect((method as any).protocolV2SourceUpdateProcess).toHaveBeenCalledWith(
7064
+ expect.objectContaining({ filePath: stagingPath })
7065
+ );
7066
+ expect((method as any).verifyProtocolV2StagedFile).toHaveBeenCalledWith(stagingPath, 3);
7067
+ });
7068
+
7069
+ test('rewrites the mounted boot resource staging file even when the final file is current', async () => {
7070
+ const method = new FirmwareUpdateV4({
7071
+ id: 1,
7072
+ payload: {
7073
+ method: 'firmwareUpdateV4',
7074
+ },
7075
+ });
7076
+
7077
+ method.postTipMessage = jest.fn();
7078
+ method.postProgressMessage = jest.fn();
7079
+ (method as any).isProtocolV2ResourceBundleUpToDate = jest.fn().mockResolvedValue(true);
7080
+ (method as any).protocolV2SourceUpdateProcess = jest.fn().mockResolvedValue(3);
7081
+ (method as any).verifyProtocolV2StagedFile = jest.fn().mockResolvedValue(undefined);
7082
+
7083
+ const resource = {
7084
+ name: 'boot_resource.okpkg',
7085
+ source: {
7086
+ size: 3,
7087
+ readAt: jest.fn(),
7088
+ close: jest.fn(),
7089
+ },
7090
+ devicePath: 'vol0:/loaders/bootloader/boot_resource.okpkg',
7091
+ version: [1, 0, 0],
7092
+ payloadHash: '11'.repeat(64),
7093
+ headerHash: '22'.repeat(64),
7094
+ };
7095
+
7096
+ await (method as any).executeProtocolV2TransferPhase({
7097
+ installSources: [],
7098
+ resourceSources: [resource],
7099
+ });
7100
+
7101
+ const stagingPath = 'vol0:/loaders/bootloader/boot_resource.okpkg.staging';
7102
+ expect((method as any).isProtocolV2ResourceBundleUpToDate).not.toHaveBeenCalled();
7103
+ expect((method as any).protocolV2SourceUpdateProcess).toHaveBeenCalledWith(
7104
+ expect.objectContaining({ source: resource.source, filePath: stagingPath, totalSize: 3 })
7105
+ );
7106
+ expect((method as any).verifyProtocolV2StagedFile).toHaveBeenCalledWith(stagingPath, 3);
7107
+ });
7108
+
6219
7109
  test('uses absolute processed_byte offsets and disables append for firmware file writes', async () => {
6220
7110
  const method = new FirmwareUpdateV4({
6221
7111
  id: 1,
@@ -6919,64 +7809,6 @@ describe('Protocol V2 firmware update method', () => {
6919
7809
  });
6920
7810
 
6921
7811
  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
7812
  test('keeps the host plan identity as the V4 reconnect anchor', () => {
6981
7813
  const method = new FirmwareUpdateV4({
6982
7814
  id: 1,
@@ -7043,7 +7875,7 @@ describe('Protocol V2 firmware reconnect identity', () => {
7043
7875
  );
7044
7876
  });
7045
7877
 
7046
- test('rejects firmware update startup when DeviceInfo has no physical serial', async () => {
7878
+ test('uses the connection route when DeviceInfo has no physical serial', async () => {
7047
7879
  const method = new FirmwareUpdateV4({
7048
7880
  id: 1,
7049
7881
  payload: { method: 'firmwareUpdateV4' },
@@ -7053,12 +7885,98 @@ describe('Protocol V2 firmware reconnect identity', () => {
7053
7885
  message: { protocol_version: 1, hw: {} },
7054
7886
  });
7055
7887
  (method as any).device = stubDevice({
7888
+ originalDescriptor: { path: '' },
7889
+ getConnectId: () => '000000000000',
7056
7890
  getCommands: () => ({ typedCall }),
7057
7891
  });
7058
7892
 
7059
- await expect((method as any).captureProtocolV2PhysicalIdentity()).rejects.toMatchObject({
7060
- errorCode: HardwareErrorCode.DeviceNotFound,
7893
+ await expect((method as any).captureProtocolV2PhysicalIdentity()).resolves.toBeUndefined();
7894
+ expect((method as any).protocolV2ExpectedSerialNumber).toBeUndefined();
7895
+ expect((method as any).protocolV2ExpectedPath).toBe('000000000000');
7896
+ });
7897
+
7898
+ test('executes a serial-less Pro2 prepared plan when the live model matches', async () => {
7899
+ const planWithoutDigest = {
7900
+ schemaVersion: 2 as const,
7901
+ executor: 'v4' as const,
7902
+ deviceIdentity: 'unavailable',
7903
+ deviceModel: 'pro2',
7904
+ firmwareType: EFirmwareType.Universal,
7905
+ platform: 'web' as const,
7906
+ targetsToUpdate: ['boot' as const],
7907
+ artifacts: [
7908
+ {
7909
+ artifactId: 'component:boot',
7910
+ role: 'component' as const,
7911
+ target: 'boot' as const,
7912
+ url: 'https://example.com/bootloader.bin',
7913
+ container: 'raw' as const,
7914
+ logicalName: 'bootloader',
7915
+ expectedSize: 1,
7916
+ expectedSha256: 'a'.repeat(64),
7917
+ },
7918
+ ],
7919
+ };
7920
+ const preparedPlan = prepareFirmwareUpdatePlan({
7921
+ plan: {
7922
+ ...planWithoutDigest,
7923
+ planDigest: digestFirmwareUpdateContract(planWithoutDigest),
7924
+ },
7925
+ leaseRef: 'serial-less-v4-executor-test',
7926
+ artifacts: [
7927
+ {
7928
+ artifactId: 'component:boot',
7929
+ artifact: {
7930
+ artifactRef: 'serial-less-bootloader',
7931
+ size: 1,
7932
+ sha256: 'a'.repeat(64),
7933
+ },
7934
+ },
7935
+ ],
7936
+ });
7937
+ const hostBindingGeneration = registerFirmwareUpdateHostBinding({
7938
+ preparedPlanDigest: preparedPlan.preparedPlanDigest,
7939
+ artifactReader: {
7940
+ open: jest.fn(),
7941
+ read: jest.fn(),
7942
+ close: jest.fn(),
7943
+ },
7061
7944
  });
7945
+ try {
7946
+ const method = new FirmwareUpdateV4({
7947
+ id: 1,
7948
+ payload: {
7949
+ method: 'firmwareUpdateV4',
7950
+ platform: 'web',
7951
+ preparedPlan,
7952
+ hostBindingGeneration,
7953
+ },
7954
+ });
7955
+ method.init();
7956
+ const typedCall = jest.fn().mockResolvedValue({
7957
+ type: 'DeviceInfo',
7958
+ message: { protocol_version: 1, hw: {} },
7959
+ });
7960
+ (method as any).device = stubDevice({
7961
+ originalDescriptor: { protocolType: 'V2', path: 'serial-less-pro2' },
7962
+ features: {
7963
+ deviceType: 'pro2',
7964
+ firmwareVersion: '1.0.0',
7965
+ capabilities: [],
7966
+ },
7967
+ getCommands: () => ({ typedCall }),
7968
+ });
7969
+ (method as any).runProtocolV2PreparedArtifacts = jest
7970
+ .fn()
7971
+ .mockResolvedValue('serial-less-result');
7972
+
7973
+ await expect(method.run()).resolves.toBe('serial-less-result');
7974
+ expect((method as any).runProtocolV2PreparedArtifacts).toHaveBeenCalled();
7975
+ expect((method as any).protocolV2ExpectedSerialNumber).toBeUndefined();
7976
+ expect((method as any).protocolV2ExpectedPath).toBe('serial-less-pro2');
7977
+ } finally {
7978
+ unregisterFirmwareUpdateHostBinding(hostBindingGeneration);
7979
+ }
7062
7980
  });
7063
7981
 
7064
7982
  test('uses the BLE read limit when reading an existing firmware bundle header', async () => {
@@ -7091,292 +8009,94 @@ describe('Protocol V2 firmware reconnect identity', () => {
7091
8009
  expect(Math.max(...readChunkLengths)).toBe(900);
7092
8010
  });
7093
8011
 
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 () => {
8012
+ test('skips resource transfer when installed okpkg hashes match the downloaded package', async () => {
7238
8013
  const method = new FirmwareUpdateV4({
7239
8014
  id: 1,
7240
8015
  payload: {
7241
8016
  method: 'firmwareUpdateV4',
7242
8017
  platform: 'web',
7243
- targetsToUpdate: ['boot_resources'],
7244
8018
  },
7245
8019
  });
7246
8020
  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') {
8021
+ const installedBinary = new Uint8Array(createProtocolV2OkppBinary());
8022
+ const typedCall = jest.fn((requestType: string, _responseType: string, request: any) => {
8023
+ if (requestType === 'FilesystemPathInfoQuery') {
7273
8024
  return Promise.resolve({
7274
- message: { exist: true, directory: false, size: bytes.byteLength },
8025
+ message: { exist: true, directory: false, size: installedBinary.byteLength },
7275
8026
  });
7276
8027
  }
7277
- if (name === 'FilesystemFileRead') {
7278
- const offset = Number(payload.file.offset);
8028
+ if (requestType === 'FilesystemFileRead') {
7279
8029
  return Promise.resolve({
7280
- message: { data: bytes.slice(offset, offset + Number(payload.chunk_len)) },
8030
+ message: {
8031
+ data: installedBinary.slice(
8032
+ Number(request.file.offset),
8033
+ Number(request.file.offset) + Number(request.chunk_len)
8034
+ ),
8035
+ },
7281
8036
  });
7282
8037
  }
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'] },
8038
+ throw new Error(`Unexpected request: ${requestType}`);
7288
8039
  });
7289
- method.init();
7290
8040
  (method as any).device = stubDevice({
7291
- getCurrentDeviceType: () => 'pro2',
7292
8041
  getCommands: () => ({ typedCall }),
7293
8042
  });
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
- });
8043
+ method.postTipMessage = jest.fn();
8044
+ method.postProgressMessage = jest.fn();
8045
+ (method as any).protocolV2SourceUpdateProcess = jest.fn();
7319
8046
 
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: [
8047
+ await (method as any).executeProtocolV2TransferPhase({
8048
+ installSources: [],
8049
+ resourceSources: [
7328
8050
  {
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,
8051
+ name: 'images.okpkg',
8052
+ source: { size: installedBinary.byteLength },
8053
+ devicePath: 'vol0:/bundles/images/images.okpkg',
8054
+ version: [1, 2, 3],
8055
+ payloadHash: '11'.repeat(64),
8056
+ headerHash: '22'.repeat(64),
7334
8057
  },
7335
8058
  ],
7336
- };
7337
- const method = new FirmwareUpdateV4({
7338
- id: 1,
7339
- payload: {
7340
- method: 'firmwareUpdateV4',
7341
- platform: 'web',
7342
- targetsToUpdate: ['resource'],
7343
- },
7344
8059
  });
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
8060
 
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
- ]);
8061
+ expect(typedCall).toHaveBeenCalledWith('FilesystemPathInfoQuery', 'FilesystemPathInfo', {
8062
+ path: 'vol0:/bundles/images/images.okpkg',
8063
+ });
8064
+ expect((method as any).protocolV2SourceUpdateProcess).not.toHaveBeenCalled();
7357
8065
  });
7358
8066
 
7359
- test('rejects a manually supplied resource file when its manifest hash does not match', () => {
8067
+ test('updates a resource when the installed file size differs despite matching headers', async () => {
7360
8068
  const method = new FirmwareUpdateV4({
7361
8069
  id: 1,
7362
8070
  payload: {
7363
8071
  method: 'firmwareUpdateV4',
7364
8072
  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
8073
  },
7374
8074
  });
7375
8075
  method.init();
8076
+ const installedBinary = new Uint8Array(createProtocolV2OkppBinary());
8077
+ const typedCall = jest.fn((requestType: string) => {
8078
+ if (requestType === 'FilesystemPathInfoQuery') {
8079
+ return Promise.resolve({
8080
+ message: { exist: true, directory: false, size: installedBinary.byteLength + 1 },
8081
+ });
8082
+ }
8083
+ throw new Error(`Unexpected request: ${requestType}`);
8084
+ });
8085
+ (method as any).device = stubDevice({
8086
+ getCommands: () => ({ typedCall }),
8087
+ });
7376
8088
 
7377
- expect(() => (method as any).prepareExplicitProtocolV2ResourceFiles()).toThrow(
7378
- 'SHA-256 mismatch'
7379
- );
8089
+ await expect(
8090
+ (method as any).isProtocolV2ResourceBundleUpToDate({
8091
+ name: 'images.okpkg',
8092
+ source: { size: installedBinary.byteLength },
8093
+ devicePath: 'vol0:/bundles/images/images.okpkg',
8094
+ version: [1, 2, 3],
8095
+ payloadHash: '11'.repeat(64),
8096
+ headerHash: '22'.repeat(64),
8097
+ })
8098
+ ).resolves.toBe(false);
8099
+ expect(typedCall).toHaveBeenCalledTimes(1);
7380
8100
  });
7381
8101
  });
7382
8102