@onekeyfe/hd-core 1.2.0-alpha.46 → 1.2.0-alpha.48

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.
@@ -5,6 +5,7 @@ import CheckAllFirmwareRelease, {
5
5
  } from '../src/api/CheckAllFirmwareRelease';
6
6
  import { DataManager } from '../src/data-manager';
7
7
  import { createCoreApi } from '../src/inject';
8
+ import { PROTOCOL_V2_RESOURCE_DEVICE_PATHS } from '../src/protocols/protocol-v2/resources';
8
9
 
9
10
  import type { CoreApi } from '../src/types/api';
10
11
  import type { DeviceStateVersions, IFirmwareReleaseInfo } from '../src/types';
@@ -72,7 +73,74 @@ const release: IFirmwareReleaseInfo = {
72
73
  ],
73
74
  };
74
75
 
76
+ const stableResources = ['images', 'animation', 'wallpaper', 'translations', 'roobert', 'noto'].map(
77
+ (type, index) => ({
78
+ type,
79
+ url: `https://example.com/${type}.okpkg`,
80
+ size: 0x52a0 + index + 1,
81
+ fileHash: 'a'.repeat(64),
82
+ headerHash: index.toString(16).padStart(128, '0'),
83
+ })
84
+ );
85
+
86
+ const createFilesystemTypedCall = (missingAll = false) => {
87
+ const resourceByPath = new Map(
88
+ stableResources.map(resource => [
89
+ PROTOCOL_V2_RESOURCE_DEVICE_PATHS[
90
+ resource.type as keyof typeof PROTOCOL_V2_RESOURCE_DEVICE_PATHS
91
+ ],
92
+ resource,
93
+ ])
94
+ );
95
+ return jest.fn((requestType: string, _responseType: string, payload: Record<string, any>) => {
96
+ if (requestType === 'ResourceInventoryGet') {
97
+ throw new Error('ResourceInventoryGet is unavailable on released firmware');
98
+ }
99
+ if (requestType === 'FilesystemPathInfoQuery') {
100
+ const resource = resourceByPath.get(payload.path);
101
+ return {
102
+ message: {
103
+ exist: Boolean(resource) && !missingAll,
104
+ directory: false,
105
+ size: missingAll ? 0 : resource?.size,
106
+ },
107
+ };
108
+ }
109
+ if (requestType === 'FilesystemFileRead') {
110
+ const resource = resourceByPath.get(payload.file.path);
111
+ if (!resource || missingAll) throw new Error('missing resource');
112
+ const header = new Uint8Array(0x52a0);
113
+ const view = new DataView(header.buffer);
114
+ 'OKPP'.split('').forEach((char, index) => {
115
+ header[index] = char.charCodeAt(0);
116
+ });
117
+ 'RESC'.split('').forEach((char, index) => {
118
+ header[0x08 + index] = char.charCodeAt(0);
119
+ });
120
+ view.setUint32(0x0c, header.byteLength, true);
121
+ for (let index = 0; index < resource.headerHash.length / 2; index++) {
122
+ header[0x240 + index] = Number.parseInt(
123
+ resource.headerHash.slice(index * 2, index * 2 + 2),
124
+ 16
125
+ );
126
+ }
127
+ const offset = Number(payload.file.offset);
128
+ const chunkLength = Number(payload.chunk_len);
129
+ return {
130
+ message: {
131
+ data: header.slice(offset, offset + chunkLength),
132
+ },
133
+ };
134
+ }
135
+ throw new Error(`Unexpected request: ${requestType}`);
136
+ });
137
+ };
138
+
75
139
  describe('checkAllFirmwareRelease Protocol V2 support', () => {
140
+ afterEach(() => {
141
+ jest.restoreAllMocks();
142
+ });
143
+
76
144
  test('builds ordered firmwareUpdateV4 targets from recommended component versions', () => {
77
145
  const result = buildProtocolV2FirmwareRelease({
78
146
  currentVersions,
@@ -240,6 +308,7 @@ describe('checkAllFirmwareRelease Protocol V2 support', () => {
240
308
  status: { mode: 'normal' },
241
309
  versions: currentVersions,
242
310
  });
311
+ const typedCall = createFilesystemTypedCall(true);
243
312
  method.device = {
244
313
  isProtocolV2: () => true,
245
314
  features: {
@@ -247,20 +316,10 @@ describe('checkAllFirmwareRelease Protocol V2 support', () => {
247
316
  firmwareVersion: '1.0.0',
248
317
  },
249
318
  getDeviceState,
250
- getCommands: () => ({ typedCall: jest.fn().mockResolvedValue({ message: { items: [] } }) }),
319
+ getCommands: () => ({ typedCall }),
251
320
  } as unknown as CheckAllFirmwareRelease['device'];
252
321
  jest.spyOn(DataManager, 'getFirmwareLatestRelease').mockReturnValue(release);
253
- jest.spyOn(DataManager, 'getProtocolV2Resources').mockReturnValue(
254
- ['images', 'animation', 'wallpaper', 'translations', 'roobert', 'noto'].map(
255
- (type, index) => ({
256
- type,
257
- url: `https://example.com/${type}.okpkg`,
258
- size: index + 1,
259
- fileHash: 'a'.repeat(64),
260
- headerHash: index.toString(16).padStart(128, '0'),
261
- })
262
- ) as any
263
- );
322
+ jest.spyOn(DataManager, 'getProtocolV2Resources').mockReturnValue(stableResources as any);
264
323
 
265
324
  await expect(method.run()).resolves.toMatchObject({
266
325
  protocol: 'V2',
@@ -272,9 +331,45 @@ describe('checkAllFirmwareRelease Protocol V2 support', () => {
272
331
  status: 'required',
273
332
  },
274
333
  });
334
+ expect(typedCall.mock.calls.some(call => call[0] === 'ResourceInventoryGet')).toBe(false);
275
335
  expect(method.getSupportedProtocols()).toEqual(['V1', 'V2']);
276
336
  });
277
337
 
338
+ test.each(['bootloader', 'romloader'] as const)(
339
+ 'uses filesystem inventory in %s mode instead of forcing all resources',
340
+ async mode => {
341
+ const method = new CheckAllFirmwareRelease({
342
+ id: 1,
343
+ payload: {
344
+ method: 'checkAllFirmwareRelease',
345
+ firmwareType: EFirmwareType.Universal,
346
+ },
347
+ });
348
+ method.init();
349
+ const typedCall = createFilesystemTypedCall();
350
+ method.device = {
351
+ isProtocolV2: () => true,
352
+ features: { deviceType: 'pro2', firmwareVersion: '1.0.0' },
353
+ getDeviceState: jest.fn().mockResolvedValue({
354
+ identity: { deviceType: 'pro2', firmwareType: EFirmwareType.Universal },
355
+ status: { mode },
356
+ versions: currentVersions,
357
+ }),
358
+ getCommands: () => ({ typedCall }),
359
+ } as unknown as CheckAllFirmwareRelease['device'];
360
+ jest.spyOn(DataManager, 'getFirmwareLatestRelease').mockReturnValue(release);
361
+ jest.spyOn(DataManager, 'getProtocolV2Resources').mockReturnValue(stableResources as any);
362
+
363
+ await expect(method.run()).resolves.toMatchObject({
364
+ protocol: 'V2',
365
+ resourceStatus: 'valid',
366
+ targetsToUpdate: ['boot', 'app_v1'],
367
+ });
368
+ expect(typedCall.mock.calls.some(call => call[0] === 'ResourceInventoryGet')).toBe(false);
369
+ expect(typedCall.mock.calls.some(call => call[0] === 'FilesystemFileRead')).toBe(true);
370
+ }
371
+ );
372
+
278
373
  test('continues forwarding the existing public method', async () => {
279
374
  const call = jest.fn().mockResolvedValue({ success: true, payload: {} });
280
375
  const api = createCoreApi(call as CoreApi['call']) as CoreApi;
@@ -7,12 +7,16 @@ import {
7
7
  PROTOCOL_V2_RESOURCE_TYPES,
8
8
  buildProtocolV2ResourceUpdatePlan,
9
9
  isProtocolV2ResourceFileValid,
10
- parseProtocolV2ResourceInventory,
11
10
  parseProtocolV2Resources,
12
- requestProtocolV2ResourceInventory,
11
+ readProtocolV2ResourceInventory,
13
12
  } from '../src/protocols/protocol-v2/resources';
14
13
 
15
- import type { ConnectSettings, IProtocolV2Resource, RemoteConfigResponse } from '../src/types';
14
+ import type {
15
+ ConnectSettings,
16
+ IProtocolV2BootResources,
17
+ IProtocolV2Resource,
18
+ RemoteConfigResponse,
19
+ } from '../src/types';
16
20
 
17
21
  jest.mock('axios');
18
22
  jest.mock('../src/data/config', () => ({
@@ -23,6 +27,24 @@ jest.mock('../src/data/config', () => ({
23
27
  const bytesToHex = (bytes: Uint8Array) =>
24
28
  Array.from(bytes, byte => byte.toString(16).padStart(2, '0')).join('');
25
29
 
30
+ const PROTOCOL_V2_OKPP_HEADER_SIZE = 0x52a0;
31
+
32
+ const createResourceHeader = (headerHash: string) => {
33
+ const header = new Uint8Array(PROTOCOL_V2_OKPP_HEADER_SIZE);
34
+ const view = new DataView(header.buffer);
35
+ 'OKPP'.split('').forEach((char, index) => {
36
+ header[index] = char.charCodeAt(0);
37
+ });
38
+ 'RESC'.split('').forEach((char, index) => {
39
+ header[0x08 + index] = char.charCodeAt(0);
40
+ });
41
+ view.setUint32(0x0c, header.byteLength, true);
42
+ for (let index = 0; index < headerHash.length / 2; index++) {
43
+ header[0x240 + index] = Number.parseInt(headerHash.slice(index * 2, index * 2 + 2), 16);
44
+ }
45
+ return header;
46
+ };
47
+
26
48
  const resources: IProtocolV2Resource[] = PROTOCOL_V2_RESOURCE_TYPES.map((type, index) => ({
27
49
  type,
28
50
  url: `https://example.com/${type}.okpkg`,
@@ -31,6 +53,16 @@ const resources: IProtocolV2Resource[] = PROTOCOL_V2_RESOURCE_TYPES.map((type, i
31
53
  headerHash: index.toString(16).padStart(128, '0'),
32
54
  }));
33
55
 
56
+ const bootResources: IProtocolV2BootResources = {
57
+ required: false,
58
+ target: 'CRATE',
59
+ url: 'https://example.com/boot-resources.crate.okpkg',
60
+ size: 1234,
61
+ fileHash: 'ab'.repeat(32),
62
+ payloadHash: 'cd'.repeat(64),
63
+ headerHash: 'ef'.repeat(64),
64
+ };
65
+
34
66
  const createSettings = (configFetcher: ConnectSettings['configFetcher']): ConnectSettings =>
35
67
  ({
36
68
  env: 'node',
@@ -47,7 +79,7 @@ const createRemoteConfig = (): RemoteConfigResponse =>
47
79
  mini: { firmware: [], ble: [] },
48
80
  touch: { firmware: [], ble: [] },
49
81
  pro: { firmware: [], ble: [] },
50
- pro2: { firmware: [], ble: [], resources: { stable: resources } },
82
+ pro2: { firmware: [], ble: [], resources: { stable: resources, boot: bootResources } },
51
83
  bridge: {},
52
84
  } as unknown as RemoteConfigResponse);
53
85
 
@@ -58,9 +90,13 @@ describe('Pro2 resource configuration', () => {
58
90
  });
59
91
 
60
92
  test('accepts exactly six resources and normalizes their deterministic order', () => {
61
- const parsed = parseProtocolV2Resources({ stable: [...resources].reverse() });
93
+ const parsed = parseProtocolV2Resources({
94
+ stable: [...resources].reverse(),
95
+ boot: bootResources,
96
+ });
62
97
 
63
98
  expect(parsed?.stable.map(item => item.type)).toEqual(PROTOCOL_V2_RESOURCE_TYPES);
99
+ expect(parsed?.boot).toEqual(bootResources);
64
100
  expect(PROTOCOL_V2_RESOURCE_DEVICE_PATHS.translations).toBe(
65
101
  'vol0:/bundles/translations/translations.okpkg'
66
102
  );
@@ -82,6 +118,21 @@ describe('Pro2 resource configuration', () => {
82
118
  ).toThrow('headerHash');
83
119
  });
84
120
 
121
+ test('requires boot resources to remain optional and use a CRATE package', () => {
122
+ expect(() =>
123
+ parseProtocolV2Resources({
124
+ stable: resources,
125
+ boot: { ...bootResources, required: true },
126
+ })
127
+ ).toThrow('required flag');
128
+ expect(() =>
129
+ parseProtocolV2Resources({
130
+ stable: resources,
131
+ boot: { ...bootResources, target: 'RESC' },
132
+ })
133
+ ).toThrow('expected CRATE');
134
+ });
135
+
85
136
  test('downloads nothing when all resource identities match', () => {
86
137
  const inventory = resources.map(({ type, size, headerHash }) => ({ type, size, headerHash }));
87
138
 
@@ -90,50 +141,62 @@ describe('Pro2 resource configuration', () => {
90
141
  ).toEqual({ status: 'valid', resources: [] });
91
142
  });
92
143
 
93
- test('normalizes the success-only ResourceInventory RPC response', async () => {
94
- const items = resources.slice(0, 2).map((resource, index) => ({
95
- type: index === 0 ? 'IMAGES' : 1,
96
- size: resource.size,
97
- header_hash: resource.headerHash,
144
+ test('builds inventory from existing filesystem calls without ResourceInventoryGet', async () => {
145
+ const installedResources = resources.map((resource, index) => ({
146
+ ...resource,
147
+ size: PROTOCOL_V2_OKPP_HEADER_SIZE + index + 1,
98
148
  }));
99
- const typedCall = jest.fn().mockResolvedValue({ message: { items } });
100
-
101
- await expect(
102
- requestProtocolV2ResourceInventory({ commands: { typedCall } as any })
103
- ).resolves.toEqual([
104
- {
105
- type: 'images',
106
- size: resources[0].size,
107
- headerHash: resources[0].headerHash,
108
- },
109
- {
110
- type: 'animation',
111
- size: resources[1].size,
112
- headerHash: resources[1].headerHash,
113
- },
114
- ]);
115
- expect(typedCall).toHaveBeenCalledWith(
116
- 'ResourceInventoryGet',
117
- 'ResourceInventory',
118
- {},
119
- { timeoutMs: 5000 }
149
+ const resourceByPath = new Map(
150
+ installedResources.map(resource => [
151
+ PROTOCOL_V2_RESOURCE_DEVICE_PATHS[resource.type],
152
+ resource,
153
+ ])
154
+ );
155
+ const typedCall = jest.fn(
156
+ (requestType: string, _responseType: string, payload: Record<string, any>) => {
157
+ if (requestType === 'ResourceInventoryGet') {
158
+ throw new Error('ResourceInventoryGet is unavailable on released firmware');
159
+ }
160
+ if (requestType === 'FilesystemPathInfoQuery') {
161
+ const resource = resourceByPath.get(payload.path);
162
+ return {
163
+ message: {
164
+ exist: Boolean(resource),
165
+ directory: false,
166
+ size: resource?.size ?? 0,
167
+ },
168
+ };
169
+ }
170
+ if (requestType === 'FilesystemFileRead') {
171
+ const resource = resourceByPath.get(payload.file.path);
172
+ if (!resource) throw new Error('missing resource');
173
+ const header = createResourceHeader(resource.headerHash);
174
+ const offset = Number(payload.file.offset);
175
+ const chunkLength = Number(payload.chunk_len);
176
+ return {
177
+ message: {
178
+ data: header.slice(offset, offset + chunkLength),
179
+ },
180
+ };
181
+ }
182
+ throw new Error(`Unexpected request: ${requestType}`);
183
+ }
120
184
  );
121
- });
122
185
 
123
- test('rejects malformed or duplicate inventory identities', () => {
124
- expect(() =>
125
- parseProtocolV2ResourceInventory({
126
- items: [
127
- { type: 'IMAGES', size: 1, header_hash: 'a'.repeat(128) },
128
- { type: 'IMAGES', size: 1, header_hash: 'b'.repeat(128) },
129
- ],
130
- })
131
- ).toThrow('duplicate resource type');
132
- expect(() =>
133
- parseProtocolV2ResourceInventory({
134
- items: [{ type: 'IMAGES', size: 1, header_hash: 'bad' }],
186
+ await expect(
187
+ readProtocolV2ResourceInventory({
188
+ commands: { typedCall },
189
+ resources: installedResources,
190
+ chunkSize: 4000,
135
191
  })
136
- ).toThrow('inventory headerHash');
192
+ ).resolves.toEqual(
193
+ installedResources.map(({ type, size, headerHash }) => ({ type, size, headerHash }))
194
+ );
195
+ expect(typedCall.mock.calls.some(call => call[0] === 'ResourceInventoryGet')).toBe(false);
196
+ expect(typedCall.mock.calls.filter(call => call[0] === 'FilesystemPathInfoQuery')).toHaveLength(
197
+ 6
198
+ );
199
+ expect(typedCall.mock.calls.some(call => call[0] === 'FilesystemFileRead')).toBe(true);
137
200
  });
138
201
 
139
202
  test('selects only the changed or missing resource in application mode', () => {
@@ -165,6 +228,22 @@ describe('Pro2 resource configuration', () => {
165
228
  ).toHaveLength(6);
166
229
  });
167
230
 
231
+ test('uses a filesystem inventory for incremental recovery mode updates', () => {
232
+ const inventory = resources.slice(1).map(({ type, size, headerHash }) => ({
233
+ type,
234
+ size,
235
+ headerHash,
236
+ }));
237
+
238
+ expect(
239
+ buildProtocolV2ResourceUpdatePlan({
240
+ resources,
241
+ inventory,
242
+ mode: 'bootloader-recovery',
243
+ }).resources.map(resource => resource.type)
244
+ ).toEqual(['images']);
245
+ });
246
+
168
247
  test('verifies both full file size and SHA-256 before transfer', () => {
169
248
  const bytes = new Uint8Array([1, 2, 3]);
170
249
  const binary = bytes.buffer;
@@ -186,6 +265,7 @@ describe('Pro2 resource configuration', () => {
186
265
  expect.stringMatching(/^https:\/\/data\.onekey\.so\/pre-config\.json\?noCache=/)
187
266
  );
188
267
  expect(DataManager.getProtocolV2Resources()).toEqual(resources);
268
+ expect(DataManager.getProtocolV2BootResources()).toEqual(bootResources);
189
269
  });
190
270
 
191
271
  test('does not advance the cache timestamp when refresh fails', async () => {
@@ -5,6 +5,7 @@ import {
5
5
  DeviceSettingsPage,
6
6
  DeviceType,
7
7
  } from '@onekeyfe/hd-transport';
8
+ import { sha256 } from '@noble/hashes/sha256';
8
9
 
9
10
  import * as firmwareBinaryApi from '../src/api/firmware/getBinary';
10
11
  import DnxGetAddress from '../src/api/dynex/DnxGetAddress';
@@ -75,6 +76,7 @@ import {
75
76
  requestProtocolV2ProtocolInfo,
76
77
  supportsProtocolV2Message,
77
78
  } from '../src/protocols/protocol-v2/features';
79
+ import { PROTOCOL_V2_RESOURCE_DEVICE_PATHS } from '../src/protocols/protocol-v2/resources';
78
80
  import {
79
81
  getProtocolV2WalletSession,
80
82
  refreshProtocolV2DeviceStatus,
@@ -5788,6 +5790,64 @@ describe('Protocol V2 firmware update method', () => {
5788
5790
  });
5789
5791
 
5790
5792
  describe('Protocol V2 firmware reconnect identity', () => {
5793
+ const createResourceFilesystemTypedCall = (
5794
+ stable: Array<{ type: string; size: number; headerHash: string }>,
5795
+ missingTypes: string[] = []
5796
+ ) => {
5797
+ const missing = new Set(missingTypes);
5798
+ const resourceByPath = new Map(
5799
+ stable.map(resource => [
5800
+ PROTOCOL_V2_RESOURCE_DEVICE_PATHS[
5801
+ resource.type as keyof typeof PROTOCOL_V2_RESOURCE_DEVICE_PATHS
5802
+ ],
5803
+ resource,
5804
+ ])
5805
+ );
5806
+ return jest.fn((requestType: string, _responseType: string, payload: Record<string, any>) => {
5807
+ if (requestType === 'ResourceInventoryGet') {
5808
+ throw new Error('ResourceInventoryGet is unavailable on released firmware');
5809
+ }
5810
+ if (requestType === 'FilesystemPathInfoQuery') {
5811
+ const resource = resourceByPath.get(payload.path);
5812
+ const exists = Boolean(resource && !missing.has(resource.type));
5813
+ return {
5814
+ message: {
5815
+ exist: exists,
5816
+ directory: false,
5817
+ size: exists ? resource?.size : 0,
5818
+ },
5819
+ };
5820
+ }
5821
+ if (requestType === 'FilesystemFileRead') {
5822
+ const resource = resourceByPath.get(payload.file.path);
5823
+ if (!resource || missing.has(resource.type)) throw new Error('missing resource');
5824
+ const header = new Uint8Array(0x52a0);
5825
+ const view = new DataView(header.buffer);
5826
+ 'OKPP'.split('').forEach((char, index) => {
5827
+ header[index] = char.charCodeAt(0);
5828
+ });
5829
+ 'RESC'.split('').forEach((char, index) => {
5830
+ header[0x08 + index] = char.charCodeAt(0);
5831
+ });
5832
+ view.setUint32(0x0c, header.byteLength, true);
5833
+ for (let index = 0; index < resource.headerHash.length / 2; index++) {
5834
+ header[0x240 + index] = Number.parseInt(
5835
+ resource.headerHash.slice(index * 2, index * 2 + 2),
5836
+ 16
5837
+ );
5838
+ }
5839
+ const offset = Number(payload.file.offset);
5840
+ const chunkLength = Number(payload.chunk_len);
5841
+ return {
5842
+ message: {
5843
+ data: header.slice(offset, offset + chunkLength),
5844
+ },
5845
+ };
5846
+ }
5847
+ throw new Error(`Unexpected request: ${requestType}`);
5848
+ });
5849
+ };
5850
+
5791
5851
  test('rejects a different physical serial before firmware transfer resumes', () => {
5792
5852
  expect(() => assertProtocolV2ReconnectIdentity('expected-serial', 'other-serial')).toThrow(
5793
5853
  'identity mismatch'
@@ -5850,7 +5910,7 @@ describe('Protocol V2 firmware reconnect identity', () => {
5850
5910
  expect((method as any).protocolV2ExpectedSerialNumber).toBeUndefined();
5851
5911
  });
5852
5912
 
5853
- test('uses ResourceInventory instead of host-side FileRead for resource comparison', async () => {
5913
+ test('uses filesystem reads instead of ResourceInventoryGet for resource comparison', async () => {
5854
5914
  const method = new FirmwareUpdateV4({
5855
5915
  id: 1,
5856
5916
  payload: {
@@ -5864,20 +5924,12 @@ describe('Protocol V2 firmware reconnect identity', () => {
5864
5924
  (type, index) => ({
5865
5925
  type,
5866
5926
  url: `https://example.com/${type}.okpkg`,
5867
- size: index + 1,
5927
+ size: 0x52a0 + index + 1,
5868
5928
  fileHash: 'a'.repeat(64),
5869
5929
  headerHash: index.toString(16).padStart(128, '0'),
5870
5930
  })
5871
5931
  );
5872
- const typedCall = jest.fn().mockResolvedValue({
5873
- message: {
5874
- items: stable.map(item => ({
5875
- type: item.type.toUpperCase(),
5876
- size: item.size,
5877
- header_hash: item.headerHash,
5878
- })),
5879
- },
5880
- });
5932
+ const typedCall = createResourceFilesystemTypedCall(stable);
5881
5933
  (method as any).device = stubDevice({
5882
5934
  getCommands: () => ({ typedCall }),
5883
5935
  });
@@ -5887,13 +5939,8 @@ describe('Protocol V2 firmware reconnect identity', () => {
5887
5939
  (method as any).downloadProtocolV2Resource = jest.fn();
5888
5940
 
5889
5941
  await expect((method as any).prepareProtocolV2ResourceBundles(false)).resolves.toEqual([]);
5890
- expect(typedCall).toHaveBeenCalledWith(
5891
- 'ResourceInventoryGet',
5892
- 'ResourceInventory',
5893
- {},
5894
- { timeoutMs: 5000 }
5895
- );
5896
- expect(typedCall.mock.calls.some(call => call[0] === 'FilesystemFileRead')).toBe(false);
5942
+ expect(typedCall.mock.calls.some(call => call[0] === 'ResourceInventoryGet')).toBe(false);
5943
+ expect(typedCall.mock.calls.some(call => call[0] === 'FilesystemFileRead')).toBe(true);
5897
5944
  expect((method as any).downloadProtocolV2Resource).not.toHaveBeenCalled();
5898
5945
  resourcesSpy.mockRestore();
5899
5946
  });
@@ -5908,20 +5955,12 @@ describe('Protocol V2 firmware reconnect identity', () => {
5908
5955
  (type, index) => ({
5909
5956
  type,
5910
5957
  url: `https://example.com/${type}.okpkg`,
5911
- size: index + 1,
5958
+ size: 0x52a0 + index + 1,
5912
5959
  fileHash: 'a'.repeat(64),
5913
5960
  headerHash: index.toString(16).padStart(128, '0'),
5914
5961
  })
5915
5962
  );
5916
- const typedCall = jest.fn().mockResolvedValue({
5917
- message: {
5918
- items: stable.slice(1).map(item => ({
5919
- type: item.type.toUpperCase(),
5920
- size: item.size,
5921
- header_hash: item.headerHash,
5922
- })),
5923
- },
5924
- });
5963
+ const typedCall = createResourceFilesystemTypedCall(stable, ['images']);
5925
5964
  (method as any).device = stubDevice({ getCommands: () => ({ typedCall }) });
5926
5965
  const resourcesSpy = jest
5927
5966
  .spyOn(DataManager, 'getProtocolV2Resources')
@@ -5941,7 +5980,7 @@ describe('Protocol V2 firmware reconnect identity', () => {
5941
5980
  resourcesSpy.mockRestore();
5942
5981
  });
5943
5982
 
5944
- test('downloads all six resources in Bootloader recovery without inventory RPC', async () => {
5983
+ test('uses filesystem inventory for incremental Bootloader recovery', async () => {
5945
5984
  const method = new FirmwareUpdateV4({
5946
5985
  id: 1,
5947
5986
  payload: { method: 'firmwareUpdateV4', platform: 'web', targetsToUpdate: ['resource'] },
@@ -5951,12 +5990,12 @@ describe('Protocol V2 firmware reconnect identity', () => {
5951
5990
  (type, index) => ({
5952
5991
  type,
5953
5992
  url: `https://example.com/${type}.okpkg`,
5954
- size: index + 1,
5993
+ size: 0x52a0 + index + 1,
5955
5994
  fileHash: 'a'.repeat(64),
5956
5995
  headerHash: index.toString(16).padStart(128, '0'),
5957
5996
  })
5958
5997
  );
5959
- const typedCall = jest.fn();
5998
+ const typedCall = createResourceFilesystemTypedCall(stable, ['images']);
5960
5999
  (method as any).device = stubDevice({ getCommands: () => ({ typedCall }) });
5961
6000
  const resourcesSpy = jest
5962
6001
  .spyOn(DataManager, 'getProtocolV2Resources')
@@ -5971,11 +6010,106 @@ describe('Protocol V2 firmware reconnect identity', () => {
5971
6010
 
5972
6011
  const bundles = await (method as any).prepareProtocolV2ResourceBundles(true);
5973
6012
 
5974
- expect(bundles).toHaveLength(6);
5975
- expect(typedCall).not.toHaveBeenCalled();
5976
- expect((method as any).downloadProtocolV2Resource).toHaveBeenCalledTimes(6);
6013
+ expect(bundles).toHaveLength(1);
6014
+ expect(typedCall.mock.calls.some(call => call[0] === 'ResourceInventoryGet')).toBe(false);
6015
+ expect(typedCall.mock.calls.some(call => call[0] === 'FilesystemPathInfoQuery')).toBe(true);
6016
+ expect((method as any).downloadProtocolV2Resource).toHaveBeenCalledWith(stable[0]);
5977
6017
  resourcesSpy.mockRestore();
5978
6018
  });
6019
+
6020
+ const buildBootResourcesHeader = ({
6021
+ type = 'CRAT',
6022
+ payloadHash = 'ab'.repeat(64),
6023
+ headerHash = 'cd'.repeat(64),
6024
+ }: {
6025
+ type?: string;
6026
+ payloadHash?: string;
6027
+ headerHash?: string;
6028
+ } = {}) => {
6029
+ const header = new Uint8Array(0x52a0);
6030
+ const view = new DataView(header.buffer);
6031
+ 'OKPP'.split('').forEach((char, index) => {
6032
+ header[index] = char.charCodeAt(0);
6033
+ });
6034
+ type.split('').forEach((char, index) => {
6035
+ header[0x08 + index] = char.charCodeAt(0);
6036
+ });
6037
+ view.setUint32(0x0c, header.byteLength, true);
6038
+ const writeHex = (offset: number, hex: string) => {
6039
+ for (let index = 0; index < hex.length / 2; index++) {
6040
+ header[offset + index] = Number.parseInt(hex.slice(index * 2, index * 2 + 2), 16);
6041
+ }
6042
+ };
6043
+ writeHex(0x200, payloadHash);
6044
+ writeHex(0x240, headerHash);
6045
+ return header;
6046
+ };
6047
+
6048
+ test('does not resolve boot resources unless the optional target is selected', async () => {
6049
+ const method = new FirmwareUpdateV4({
6050
+ id: 1,
6051
+ payload: { method: 'firmwareUpdateV4', platform: 'web' },
6052
+ });
6053
+ method.init();
6054
+ const configSpy = jest.spyOn(DataManager, 'getProtocolV2BootResources');
6055
+ const downloadSpy = jest.spyOn(firmwareBinaryApi, 'getSysResourceBinary');
6056
+
6057
+ await expect((method as any).prepareProtocolV2BootResources()).resolves.toBeUndefined();
6058
+
6059
+ expect(configSpy).not.toHaveBeenCalled();
6060
+ expect(downloadSpy).not.toHaveBeenCalled();
6061
+ });
6062
+
6063
+ test('downloads and maps the selected boot resources CRATE target', async () => {
6064
+ const payloadHash = 'ab'.repeat(64);
6065
+ const headerHash = 'cd'.repeat(64);
6066
+ const bytes = buildBootResourcesHeader({ payloadHash, headerHash });
6067
+ const binary = bytes.buffer as ArrayBuffer;
6068
+ const fileHash = Array.from(sha256(bytes), byte => byte.toString(16).padStart(2, '0')).join('');
6069
+ const resource = {
6070
+ required: false as const,
6071
+ target: 'CRATE' as const,
6072
+ url: 'https://example.com/boot-resources.crate.okpkg',
6073
+ size: bytes.byteLength,
6074
+ fileHash,
6075
+ payloadHash,
6076
+ headerHash,
6077
+ };
6078
+ const method = new FirmwareUpdateV4({
6079
+ id: 1,
6080
+ payload: {
6081
+ method: 'firmwareUpdateV4',
6082
+ platform: 'web',
6083
+ targetsToUpdate: ['boot_resources'],
6084
+ },
6085
+ });
6086
+ method.init();
6087
+ jest.spyOn(DataManager, 'getProtocolV2BootResources').mockReturnValue(resource);
6088
+ jest.spyOn(firmwareBinaryApi, 'getSysResourceBinary').mockResolvedValue({ binary });
6089
+
6090
+ await expect((method as any).prepareProtocolV2BootResources()).resolves.toEqual({
6091
+ fileName: 'boot_resources.crate.okpkg',
6092
+ binary,
6093
+ targetId: 1,
6094
+ kind: 'boot_resources',
6095
+ });
6096
+ });
6097
+
6098
+ test('rejects a non-CRATE manual boot resources package', async () => {
6099
+ const method = new FirmwareUpdateV4({
6100
+ id: 1,
6101
+ payload: {
6102
+ method: 'firmwareUpdateV4',
6103
+ platform: 'web',
6104
+ bootResourcesBinary: buildBootResourcesHeader({ type: 'RESC' }).buffer,
6105
+ },
6106
+ });
6107
+ method.init();
6108
+
6109
+ await expect((method as any).prepareProtocolV2BootResources()).rejects.toThrow(
6110
+ 'Invalid Pro2 boot resources CRATE header'
6111
+ );
6112
+ });
5979
6113
  });
5980
6114
 
5981
6115
  describe('Protocol V2 explicit USB device selection', () => {