@onekeyfe/hd-transport 1.2.8-alpha.13 → 1.2.8-alpha.130
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.
- package/__tests__/protocol-v2-file-write-pipeline.test.js +478 -0
- package/__tests__/transport-log.test.js +142 -0
- package/dist/constants.d.ts +1 -0
- package/dist/constants.d.ts.map +1 -1
- package/dist/index.d.ts +119 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +244 -72
- package/dist/protocols/v2/session.d.ts +7 -1
- package/dist/protocols/v2/session.d.ts.map +1 -1
- package/dist/types/messages.d.ts +47 -0
- package/dist/types/messages.d.ts.map +1 -1
- package/dist/types/transport.d.ts +6 -0
- package/dist/types/transport.d.ts.map +1 -1
- package/dist/utils/transportLog.d.ts.map +1 -1
- package/messages-protocol-v2.json +138 -1
- package/package.json +2 -2
- package/src/constants.ts +10 -1
- package/src/index.ts +1 -0
- package/src/protocols/v2/session.ts +326 -96
- package/src/types/messages.ts +63 -0
- package/src/types/transport.ts +25 -0
- package/src/utils/transportLog.ts +16 -2
|
@@ -0,0 +1,478 @@
|
|
|
1
|
+
/* eslint-disable @typescript-eslint/no-var-requires */
|
|
2
|
+
const { ProtocolV2, ProtocolV2LinkManager } = require('../src');
|
|
3
|
+
const { parseConfigure } = require('../src/serialization/protobuf/messages');
|
|
4
|
+
const { ProtocolV2Session } = require('../src/protocols/v2/session');
|
|
5
|
+
const protocolV2 = require('../src/protocols/v2');
|
|
6
|
+
const { PROTOCOL_V2_FILE_WRITE_PIPELINE_WINDOW } = require('../src/constants');
|
|
7
|
+
|
|
8
|
+
const schemas = {
|
|
9
|
+
protocolV1: parseConfigure({
|
|
10
|
+
nested: {
|
|
11
|
+
Failure: { fields: { code: { type: 'uint32', id: 1 }, message: { type: 'string', id: 2 } } },
|
|
12
|
+
MessageType: { values: { MessageType_Failure: 3 } },
|
|
13
|
+
},
|
|
14
|
+
}),
|
|
15
|
+
protocolV2: parseConfigure({
|
|
16
|
+
nested: {
|
|
17
|
+
Failure: {
|
|
18
|
+
fields: {
|
|
19
|
+
code: { type: 'uint32', id: 1 },
|
|
20
|
+
subcode: { type: 'uint32', id: 2 },
|
|
21
|
+
message: { type: 'string', id: 3 },
|
|
22
|
+
},
|
|
23
|
+
},
|
|
24
|
+
ButtonRequest: { fields: { code: { type: 'uint32', id: 1 } } },
|
|
25
|
+
Success: { fields: { message: { type: 'string', id: 1 } } },
|
|
26
|
+
FilesystemFile: {
|
|
27
|
+
fields: {
|
|
28
|
+
path: { type: 'string', id: 1 },
|
|
29
|
+
offset: { type: 'uint32', id: 2 },
|
|
30
|
+
total_size: { type: 'uint32', id: 3 },
|
|
31
|
+
data: { type: 'bytes', id: 4 },
|
|
32
|
+
processed_byte: { type: 'uint32', id: 6 },
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
FilesystemFileWrite: {
|
|
36
|
+
fields: {
|
|
37
|
+
file: { type: 'FilesystemFile', id: 1 },
|
|
38
|
+
overwrite: { type: 'bool', id: 2 },
|
|
39
|
+
append: { type: 'bool', id: 3 },
|
|
40
|
+
},
|
|
41
|
+
},
|
|
42
|
+
MessageType: {
|
|
43
|
+
values: {
|
|
44
|
+
MessageType_Success: 60207,
|
|
45
|
+
MessageType_Failure: 60208,
|
|
46
|
+
MessageType_ButtonRequest: 60209,
|
|
47
|
+
MessageType_FilesystemFile: 60803,
|
|
48
|
+
MessageType_FilesystemFileWrite: 60805,
|
|
49
|
+
},
|
|
50
|
+
},
|
|
51
|
+
},
|
|
52
|
+
}),
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const PATH = 'vol0:/application_p1.bin';
|
|
56
|
+
|
|
57
|
+
const chunk = (offset, length, totalSize) => ({
|
|
58
|
+
file: { path: PATH, offset, total_size: totalSize, data: new Uint8Array(length) },
|
|
59
|
+
overwrite: offset === 0,
|
|
60
|
+
append: false,
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
const chunksOf = (totalSize, chunkSize) => {
|
|
64
|
+
const chunks = [];
|
|
65
|
+
for (let offset = 0; offset < totalSize; offset += chunkSize) {
|
|
66
|
+
chunks.push(chunk(offset, Math.min(chunkSize, totalSize - offset), totalSize));
|
|
67
|
+
}
|
|
68
|
+
return chunks;
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
const ackFrame = seq => {
|
|
72
|
+
const ack = new Uint8Array(8);
|
|
73
|
+
ack[0] = 0x5a;
|
|
74
|
+
ack[1] = 8;
|
|
75
|
+
ack[4] = 1;
|
|
76
|
+
ack[5] = 1;
|
|
77
|
+
ack[6] = seq;
|
|
78
|
+
ack[3] = protocolV2.crc8(ack, 3);
|
|
79
|
+
ack[7] = protocolV2.crc8(ack, 7);
|
|
80
|
+
return ack;
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Simulates the bootloader: every written FileWrite frame is answered in order. `reply` may
|
|
85
|
+
* replace or prepend frames for a given request index.
|
|
86
|
+
*/
|
|
87
|
+
const createDevice = ({ reply } = {}) => {
|
|
88
|
+
let responseSeq = 100;
|
|
89
|
+
const queue = [];
|
|
90
|
+
const waiters = [];
|
|
91
|
+
const requests = [];
|
|
92
|
+
let maxInFlight = 0;
|
|
93
|
+
let answered = 0;
|
|
94
|
+
const push = frame => {
|
|
95
|
+
const waiter = waiters.shift();
|
|
96
|
+
if (waiter) waiter(frame);
|
|
97
|
+
else queue.push(frame);
|
|
98
|
+
};
|
|
99
|
+
const encode = (name, message) => {
|
|
100
|
+
responseSeq += 1;
|
|
101
|
+
return ProtocolV2.encodeFrame(schemas, name, message, {
|
|
102
|
+
router: 1,
|
|
103
|
+
packetSrc: 0,
|
|
104
|
+
seq: responseSeq,
|
|
105
|
+
});
|
|
106
|
+
};
|
|
107
|
+
const respond = () => {
|
|
108
|
+
const index = answered;
|
|
109
|
+
answered += 1;
|
|
110
|
+
const request = requests[index];
|
|
111
|
+
const { offset, total_size: totalSize, data } = request.message.file;
|
|
112
|
+
// Decoded protobuf bytes are hex strings.
|
|
113
|
+
const length = data.length / 2;
|
|
114
|
+
const confirmation = encode('FilesystemFile', {
|
|
115
|
+
path: PATH,
|
|
116
|
+
offset,
|
|
117
|
+
total_size: totalSize,
|
|
118
|
+
processed_byte: Number(offset) + length,
|
|
119
|
+
});
|
|
120
|
+
const frames = reply ? reply({ index, request, confirmation, encode }) : [confirmation];
|
|
121
|
+
frames.forEach(push);
|
|
122
|
+
};
|
|
123
|
+
return {
|
|
124
|
+
requests,
|
|
125
|
+
get maxInFlight() {
|
|
126
|
+
return maxInFlight;
|
|
127
|
+
},
|
|
128
|
+
writeFrame: jest.fn(frame => {
|
|
129
|
+
const decoded = ProtocolV2.decodeFrame(schemas, frame);
|
|
130
|
+
requests.push({ seq: frame[6], message: decoded.message });
|
|
131
|
+
maxInFlight = Math.max(maxInFlight, requests.length - answered);
|
|
132
|
+
return Promise.resolve();
|
|
133
|
+
}),
|
|
134
|
+
inject: (name, message) => push(encode(name, message)),
|
|
135
|
+
readFrame: jest.fn(() => {
|
|
136
|
+
while (!queue.length && answered < requests.length) {
|
|
137
|
+
respond();
|
|
138
|
+
}
|
|
139
|
+
const frame = queue.shift();
|
|
140
|
+
if (frame) return Promise.resolve(frame);
|
|
141
|
+
return new Promise(resolve => {
|
|
142
|
+
waiters.push(resolve);
|
|
143
|
+
});
|
|
144
|
+
}),
|
|
145
|
+
};
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
const createSession = (device, options = {}) =>
|
|
149
|
+
new ProtocolV2Session({
|
|
150
|
+
schemas,
|
|
151
|
+
router: 1,
|
|
152
|
+
writeFrame: device.writeFrame,
|
|
153
|
+
readFrame: device.readFrame,
|
|
154
|
+
...options,
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
const runPipeline = (session, chunks, callOptions = {}, onResponse = jest.fn()) => {
|
|
158
|
+
const [first, ...rest] = chunks;
|
|
159
|
+
const next = jest.fn(() => Promise.resolve(rest.shift()));
|
|
160
|
+
return {
|
|
161
|
+
next,
|
|
162
|
+
onResponse,
|
|
163
|
+
result: session.call('FilesystemFileWrite', first, {
|
|
164
|
+
...callOptions,
|
|
165
|
+
fileWritePipeline: { next, onResponse },
|
|
166
|
+
}),
|
|
167
|
+
};
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
const runManagedPipeline = (device, chunks) => {
|
|
171
|
+
const adapter = {
|
|
172
|
+
router: 1,
|
|
173
|
+
generation: 1,
|
|
174
|
+
prepareCall: jest.fn(),
|
|
175
|
+
writeFrame: device.writeFrame,
|
|
176
|
+
readFrame: device.readFrame,
|
|
177
|
+
reset: jest.fn(),
|
|
178
|
+
};
|
|
179
|
+
const manager = new ProtocolV2LinkManager({
|
|
180
|
+
getSchemas: () => schemas,
|
|
181
|
+
classifyError: () => 'link-fatal',
|
|
182
|
+
});
|
|
183
|
+
const [first, ...rest] = chunks;
|
|
184
|
+
return {
|
|
185
|
+
adapter,
|
|
186
|
+
result: manager.call('device-a', () => adapter, 'FilesystemFileWrite', first, {
|
|
187
|
+
fileWritePipeline: { next: () => Promise.resolve(rest.shift()), onResponse: jest.fn() },
|
|
188
|
+
}),
|
|
189
|
+
};
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
describe('Protocol V2 file write pipeline', () => {
|
|
193
|
+
test('keeps two ordered chunks in flight and confirms each one', async () => {
|
|
194
|
+
const device = createDevice();
|
|
195
|
+
const session = createSession(device);
|
|
196
|
+
const { result, onResponse } = runPipeline(session, chunksOf(10, 2));
|
|
197
|
+
|
|
198
|
+
await expect(result).resolves.toMatchObject({
|
|
199
|
+
type: 'FilesystemFile',
|
|
200
|
+
message: { processed_byte: 10 },
|
|
201
|
+
});
|
|
202
|
+
expect(PROTOCOL_V2_FILE_WRITE_PIPELINE_WINDOW).toBe(2);
|
|
203
|
+
expect(device.maxInFlight).toBe(2);
|
|
204
|
+
expect(device.requests.map(request => request.message.file.offset)).toEqual([0, 2, 4, 6, 8]);
|
|
205
|
+
expect(onResponse.mock.calls.map(([response]) => response.message.processed_byte)).toEqual([
|
|
206
|
+
2, 4, 6, 8, 10,
|
|
207
|
+
]);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
test('sends a single-chunk file without asking for more than the source has', async () => {
|
|
211
|
+
const device = createDevice();
|
|
212
|
+
const session = createSession(device);
|
|
213
|
+
const { result, next } = runPipeline(session, chunksOf(3, 4));
|
|
214
|
+
|
|
215
|
+
await expect(result).resolves.toMatchObject({ message: { processed_byte: 3 } });
|
|
216
|
+
expect(device.requests).toHaveLength(1);
|
|
217
|
+
expect(next).toHaveBeenCalledTimes(1);
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
test('skips an ACK of an in-flight chunk', async () => {
|
|
221
|
+
const device = createDevice({
|
|
222
|
+
reply: ({ index, request, confirmation }) =>
|
|
223
|
+
index === 0 ? [ackFrame(request.seq), confirmation] : [confirmation],
|
|
224
|
+
});
|
|
225
|
+
const session = createSession(device);
|
|
226
|
+
const { result } = runPipeline(session, chunksOf(4, 2));
|
|
227
|
+
|
|
228
|
+
await expect(result).resolves.toMatchObject({ message: { processed_byte: 4 } });
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
test('drains in-flight chunks after a device Failure and returns it', async () => {
|
|
232
|
+
const device = createDevice({
|
|
233
|
+
reply: ({ index, confirmation, encode }) =>
|
|
234
|
+
index >= 1
|
|
235
|
+
? [encode('Failure', { code: 9, message: 'Write offset mismatch' })]
|
|
236
|
+
: [confirmation],
|
|
237
|
+
});
|
|
238
|
+
const session = createSession(device);
|
|
239
|
+
const { result, next, onResponse } = runPipeline(session, chunksOf(10, 2));
|
|
240
|
+
|
|
241
|
+
await expect(result).resolves.toEqual({
|
|
242
|
+
type: 'Failure',
|
|
243
|
+
message: expect.objectContaining({ message: 'Write offset mismatch' }),
|
|
244
|
+
});
|
|
245
|
+
// Chunk 0 confirmed, chunk 1 failed; chunk 2 was already in flight and is drained.
|
|
246
|
+
expect(onResponse).toHaveBeenCalledTimes(1);
|
|
247
|
+
expect(device.requests).toHaveLength(3);
|
|
248
|
+
expect(device.readFrame).toHaveBeenCalledTimes(3);
|
|
249
|
+
expect(next).toHaveBeenCalledTimes(2);
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
test('keeps the link after a device Failure', async () => {
|
|
253
|
+
const device = createDevice({
|
|
254
|
+
reply: ({ encode }) => [encode('Failure', { code: 9, message: 'Write offset mismatch' })],
|
|
255
|
+
});
|
|
256
|
+
const { adapter, result } = runManagedPipeline(device, chunksOf(6, 2));
|
|
257
|
+
|
|
258
|
+
await expect(result).resolves.toMatchObject({ type: 'Failure' });
|
|
259
|
+
expect(adapter.reset).not.toHaveBeenCalled();
|
|
260
|
+
expect(adapter.prepareCall).toHaveBeenCalledTimes(1);
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
test('invalidates the link when a confirmation does not match the oldest chunk', async () => {
|
|
264
|
+
const device = createDevice({
|
|
265
|
+
reply: ({ index, confirmation, encode }) =>
|
|
266
|
+
index === 1
|
|
267
|
+
? [encode('FilesystemFile', { path: PATH, offset: 4, total_size: 6, processed_byte: 6 })]
|
|
268
|
+
: [confirmation],
|
|
269
|
+
});
|
|
270
|
+
const { adapter, result } = runManagedPipeline(device, chunksOf(6, 2));
|
|
271
|
+
|
|
272
|
+
await expect(result).rejects.toEqual(
|
|
273
|
+
expect.objectContaining({ name: 'ProtocolV2LinkError', code: 'frame' })
|
|
274
|
+
);
|
|
275
|
+
expect(adapter.reset).toHaveBeenCalled();
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
test('rejects an interactive response inside the pipeline', async () => {
|
|
279
|
+
const device = createDevice({
|
|
280
|
+
reply: ({ encode }) => [encode('ButtonRequest', { code: 1 })],
|
|
281
|
+
});
|
|
282
|
+
const session = createSession(device);
|
|
283
|
+
const { result } = runPipeline(session, chunksOf(4, 2));
|
|
284
|
+
|
|
285
|
+
await expect(result).rejects.toEqual(
|
|
286
|
+
expect.objectContaining({ name: 'ProtocolV2LinkError', code: 'frame' })
|
|
287
|
+
);
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
test('rejects a source chunk that skips an offset before writing it', async () => {
|
|
291
|
+
const device = createDevice();
|
|
292
|
+
const session = createSession(device);
|
|
293
|
+
const { result } = runPipeline(session, [chunk(0, 2, 8), chunk(4, 2, 8)]);
|
|
294
|
+
|
|
295
|
+
await expect(result).rejects.toThrow('chunk out of order: expected offset 2, got 4');
|
|
296
|
+
await expect(result).rejects.toEqual(
|
|
297
|
+
expect.objectContaining({ name: 'ProtocolV2LinkError', code: 'frame' })
|
|
298
|
+
);
|
|
299
|
+
expect(device.requests).toHaveLength(1);
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
test.each([
|
|
303
|
+
['path', { path: 'vol0:/application_p2.bin' }],
|
|
304
|
+
['total size', { total_size: 9 }],
|
|
305
|
+
])('rejects a source chunk with another %s before writing it', async (_field, file) => {
|
|
306
|
+
const device = createDevice();
|
|
307
|
+
const session = createSession(device);
|
|
308
|
+
const second = chunk(2, 2, 8);
|
|
309
|
+
const { result } = runPipeline(session, [
|
|
310
|
+
chunk(0, 2, 8),
|
|
311
|
+
{ ...second, file: { ...second.file, ...file } },
|
|
312
|
+
]);
|
|
313
|
+
|
|
314
|
+
await expect(result).rejects.toThrow('chunk belongs to a different file');
|
|
315
|
+
await expect(result).rejects.toEqual(
|
|
316
|
+
expect.objectContaining({ name: 'ProtocolV2LinkError', code: 'frame' })
|
|
317
|
+
);
|
|
318
|
+
expect(device.requests).toHaveLength(1);
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
test.each([
|
|
322
|
+
['path', { path: 'vol0:/application_p2.bin' }],
|
|
323
|
+
['total size', { total_size: 8 }],
|
|
324
|
+
])('invalidates the link when a confirmation echoes another %s', async (_field, echo) => {
|
|
325
|
+
const device = createDevice({
|
|
326
|
+
reply: ({ index, confirmation, encode }) =>
|
|
327
|
+
index === 0
|
|
328
|
+
? [
|
|
329
|
+
encode('FilesystemFile', {
|
|
330
|
+
path: PATH,
|
|
331
|
+
offset: 0,
|
|
332
|
+
total_size: 6,
|
|
333
|
+
processed_byte: 2,
|
|
334
|
+
...echo,
|
|
335
|
+
}),
|
|
336
|
+
]
|
|
337
|
+
: [confirmation],
|
|
338
|
+
});
|
|
339
|
+
const { adapter, result } = runManagedPipeline(device, chunksOf(6, 2));
|
|
340
|
+
|
|
341
|
+
await expect(result).rejects.toEqual(
|
|
342
|
+
expect.objectContaining({ name: 'ProtocolV2LinkError', code: 'frame' })
|
|
343
|
+
);
|
|
344
|
+
expect(adapter.reset).toHaveBeenCalled();
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
test.each(['readFrame', 'writeFrame'])(
|
|
348
|
+
'passes a %s transport error through unchanged with chunks in flight',
|
|
349
|
+
async method => {
|
|
350
|
+
const device = createDevice();
|
|
351
|
+
const linkLost = new Error('BLE device disconnected');
|
|
352
|
+
const original = device[method].getMockImplementation();
|
|
353
|
+
let calls = 0;
|
|
354
|
+
device[method].mockImplementation((...args) => {
|
|
355
|
+
calls += 1;
|
|
356
|
+
// The second write, or the first read, happens with chunk 0 still in flight.
|
|
357
|
+
return calls === (method === 'writeFrame' ? 2 : 1)
|
|
358
|
+
? Promise.reject(linkLost)
|
|
359
|
+
: original(...args);
|
|
360
|
+
});
|
|
361
|
+
const session = createSession(device);
|
|
362
|
+
const { result } = runPipeline(session, chunksOf(6, 2));
|
|
363
|
+
|
|
364
|
+
await expect(result).rejects.toBe(linkLost);
|
|
365
|
+
expect(device.requests.length).toBeGreaterThan(0);
|
|
366
|
+
}
|
|
367
|
+
);
|
|
368
|
+
|
|
369
|
+
test('drains the in-flight chunk and returns the last confirmation when the source stops', async () => {
|
|
370
|
+
const device = createDevice();
|
|
371
|
+
const session = createSession(device);
|
|
372
|
+
const { result, onResponse } = runPipeline(session, [chunk(0, 2, 6), chunk(2, 2, 6)]);
|
|
373
|
+
|
|
374
|
+
await expect(result).resolves.toMatchObject({
|
|
375
|
+
type: 'FilesystemFile',
|
|
376
|
+
message: { processed_byte: 4 },
|
|
377
|
+
});
|
|
378
|
+
expect(onResponse).toHaveBeenCalledTimes(2);
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
test('detects a lost chunk when the bootloader appends at its own cursor', async () => {
|
|
382
|
+
let cursor = 0;
|
|
383
|
+
const device = createDevice({
|
|
384
|
+
reply: ({ index, request, encode }) => {
|
|
385
|
+
// The third frame never reaches the bootloader, which echoes the request offset but
|
|
386
|
+
// reports its own cursor in processed_byte.
|
|
387
|
+
if (index === 2) return [];
|
|
388
|
+
const { offset, total_size: totalSize, data } = request.message.file;
|
|
389
|
+
cursor += data.length / 2;
|
|
390
|
+
return [
|
|
391
|
+
encode('FilesystemFile', {
|
|
392
|
+
path: PATH,
|
|
393
|
+
offset,
|
|
394
|
+
total_size: totalSize,
|
|
395
|
+
processed_byte: cursor,
|
|
396
|
+
}),
|
|
397
|
+
];
|
|
398
|
+
},
|
|
399
|
+
});
|
|
400
|
+
const session = createSession(device);
|
|
401
|
+
const { result, onResponse } = runPipeline(session, chunksOf(10, 2));
|
|
402
|
+
|
|
403
|
+
await expect(result).rejects.toEqual(
|
|
404
|
+
expect.objectContaining({ name: 'ProtocolV2LinkError', code: 'frame' })
|
|
405
|
+
);
|
|
406
|
+
expect(onResponse.mock.calls.map(([response]) => response.message.processed_byte)).toEqual([
|
|
407
|
+
2, 4,
|
|
408
|
+
]);
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
test('does not let skipped frames extend the wait for a response', async () => {
|
|
412
|
+
const device = createDevice({ reply: () => [] });
|
|
413
|
+
const noise = setInterval(() => device.inject('Success', { message: 'unrelated' }), 10);
|
|
414
|
+
try {
|
|
415
|
+
const session = createSession(device);
|
|
416
|
+
const { result } = runPipeline(session, chunksOf(2, 2), { timeoutMs: 60 });
|
|
417
|
+
|
|
418
|
+
await expect(result).rejects.toThrow('Protocol V2 response timeout after 60ms');
|
|
419
|
+
} finally {
|
|
420
|
+
clearInterval(noise);
|
|
421
|
+
}
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
test('rejects a chunk whose frame exceeds the transport limit', async () => {
|
|
425
|
+
const device = createDevice();
|
|
426
|
+
const session = createSession(device, { maxFrameBytes: 64 });
|
|
427
|
+
const { result } = runPipeline(session, [chunk(0, 128, 128)]);
|
|
428
|
+
|
|
429
|
+
await expect(result).rejects.toThrow('Protocol V2 frame too large for transport');
|
|
430
|
+
await expect(result).rejects.not.toEqual(
|
|
431
|
+
expect.objectContaining({ name: 'ProtocolV2LinkError' })
|
|
432
|
+
);
|
|
433
|
+
expect(device.requests).toHaveLength(0);
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
test('bounds each response wait instead of the whole file', async () => {
|
|
437
|
+
const device = createDevice();
|
|
438
|
+
const read = device.readFrame.getMockImplementation();
|
|
439
|
+
device.readFrame.mockImplementation(
|
|
440
|
+
() =>
|
|
441
|
+
new Promise(resolve => {
|
|
442
|
+
setTimeout(() => resolve(read()), 40);
|
|
443
|
+
})
|
|
444
|
+
);
|
|
445
|
+
const session = createSession(device);
|
|
446
|
+
const { result } = runPipeline(session, chunksOf(8, 2), { timeoutMs: 100 });
|
|
447
|
+
|
|
448
|
+
// Four 40 ms responses outlast the 100 ms timeout, but no single wait does.
|
|
449
|
+
await expect(result).resolves.toMatchObject({ message: { processed_byte: 8 } });
|
|
450
|
+
});
|
|
451
|
+
|
|
452
|
+
test('times out a stalled response with the transport error', async () => {
|
|
453
|
+
const device = createDevice();
|
|
454
|
+
device.readFrame.mockImplementation(() => new Promise(() => {}));
|
|
455
|
+
const createTimeoutError = jest.fn(
|
|
456
|
+
(name, timeoutMs) => new Error(`BLE timeout ${name} ${timeoutMs}`)
|
|
457
|
+
);
|
|
458
|
+
const session = createSession(device, { createTimeoutError });
|
|
459
|
+
const { result } = runPipeline(session, chunksOf(4, 2), { timeoutMs: 50 });
|
|
460
|
+
|
|
461
|
+
await expect(result).rejects.toThrow('BLE timeout FilesystemFileWrite 50');
|
|
462
|
+
expect(device.writeFrame.mock.calls[0][1].signal.aborted).toBe(true);
|
|
463
|
+
});
|
|
464
|
+
|
|
465
|
+
test('stops when the caller rejects a confirmed chunk', async () => {
|
|
466
|
+
const device = createDevice();
|
|
467
|
+
const session = createSession(device);
|
|
468
|
+
const { result } = runPipeline(session, chunksOf(10, 2), {}, () => {
|
|
469
|
+
throw new Error('cancelled');
|
|
470
|
+
});
|
|
471
|
+
|
|
472
|
+
await expect(result).rejects.toThrow('cancelled');
|
|
473
|
+
await expect(result).rejects.toEqual(
|
|
474
|
+
expect.objectContaining({ name: 'ProtocolV2LinkError', code: 'frame' })
|
|
475
|
+
);
|
|
476
|
+
expect(device.requests).toHaveLength(2);
|
|
477
|
+
});
|
|
478
|
+
});
|
|
@@ -82,4 +82,146 @@ describe('transport log sanitization', () => {
|
|
|
82
82
|
data: '[BINARY:128]',
|
|
83
83
|
});
|
|
84
84
|
});
|
|
85
|
+
|
|
86
|
+
test('redacts Zcash viewing keys and fingerprints in protocol and API field names', () => {
|
|
87
|
+
const payload = {
|
|
88
|
+
address: 'synthetic-address-marker',
|
|
89
|
+
ufvk: 'synthetic-viewing-key-marker',
|
|
90
|
+
seed_fingerprint: 'synthetic-fingerprint-marker',
|
|
91
|
+
nested: [{ seedFingerprint: 'synthetic-fingerprint-marker' }],
|
|
92
|
+
};
|
|
93
|
+
expect(getSafeTransportLogPayload(payload, 'ZcashAddress')).toEqual({
|
|
94
|
+
address: '[REDACTED]',
|
|
95
|
+
ufvk: '[REDACTED]',
|
|
96
|
+
seed_fingerprint: '[REDACTED]',
|
|
97
|
+
nested: [{ seedFingerprint: '[REDACTED]' }],
|
|
98
|
+
});
|
|
99
|
+
expect(payload.ufvk).toBe('synthetic-viewing-key-marker');
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test('redacts Monero view keys in protocol and API field names', () => {
|
|
103
|
+
expect(
|
|
104
|
+
getSafeTransportLogPayload(
|
|
105
|
+
{
|
|
106
|
+
address: 'synthetic-address-marker',
|
|
107
|
+
watch_key: 'synthetic-view-key-marker',
|
|
108
|
+
nested: { watchKey: 'synthetic-view-key-marker' },
|
|
109
|
+
},
|
|
110
|
+
'MoneroWatchKey'
|
|
111
|
+
)
|
|
112
|
+
).toEqual({
|
|
113
|
+
address: '[REDACTED]',
|
|
114
|
+
watch_key: '[REDACTED]',
|
|
115
|
+
nested: { watchKey: '[REDACTED]' },
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test.each([
|
|
120
|
+
[
|
|
121
|
+
'MoneroKeyImageExportInitRequest',
|
|
122
|
+
{ num: 1, hash: 'synthetic-hash-marker', subs: [{ account: 0, minor_indices: [2] }] },
|
|
123
|
+
],
|
|
124
|
+
[
|
|
125
|
+
'MoneroKeyImageSyncStepRequest',
|
|
126
|
+
{
|
|
127
|
+
tdis: [
|
|
128
|
+
{
|
|
129
|
+
out_key: 'synthetic-out-key-marker',
|
|
130
|
+
tx_pub_key: 'synthetic-tx-key-marker',
|
|
131
|
+
internal_output_index: 1,
|
|
132
|
+
},
|
|
133
|
+
],
|
|
134
|
+
},
|
|
135
|
+
],
|
|
136
|
+
[
|
|
137
|
+
'MoneroKeyImageSyncStepAck',
|
|
138
|
+
{ kis: [{ iv: 'synthetic-iv-marker', blob: 'synthetic-key-image-marker' }] },
|
|
139
|
+
],
|
|
140
|
+
['MoneroKeyImageSyncFinalAck', { enc_key: 'synthetic-enc-key-marker' }],
|
|
141
|
+
['MoneroGetTxKeyAck', { salt: 'synthetic-salt-marker', tx_keys: 'synthetic-tx-keys-marker' }],
|
|
142
|
+
[
|
|
143
|
+
'MoneroLiveRefreshStepAck',
|
|
144
|
+
{ salt: 'synthetic-salt-marker', key_image: 'synthetic-ki-marker' },
|
|
145
|
+
],
|
|
146
|
+
])('redacts the complete Monero key image message %s', (name, payload) => {
|
|
147
|
+
expect(createTransportCallLog(name, 'V1', payload).request).toBe('[REDACTED]');
|
|
148
|
+
expect(getSafeTransportLogPayload(payload, name)).toBe('[REDACTED]');
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test('redacts Monero key image fields even without protocol message context', () => {
|
|
152
|
+
expect(
|
|
153
|
+
getSafeTransportLogPayload({
|
|
154
|
+
encKey: 'synthetic-enc-key-marker',
|
|
155
|
+
transfers: [
|
|
156
|
+
{
|
|
157
|
+
outKey: 'synthetic-out-key-marker',
|
|
158
|
+
txPubKey: 'synthetic-tx-key-marker',
|
|
159
|
+
additional_tx_pub_keys: ['synthetic-additional-key-marker'],
|
|
160
|
+
internalOutputIndex: 1,
|
|
161
|
+
},
|
|
162
|
+
],
|
|
163
|
+
})
|
|
164
|
+
).toEqual({
|
|
165
|
+
encKey: '[REDACTED]',
|
|
166
|
+
transfers: [
|
|
167
|
+
{
|
|
168
|
+
outKey: '[REDACTED]',
|
|
169
|
+
txPubKey: '[REDACTED]',
|
|
170
|
+
additional_tx_pub_keys: '[REDACTED]',
|
|
171
|
+
internalOutputIndex: 1,
|
|
172
|
+
},
|
|
173
|
+
],
|
|
174
|
+
});
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
test.each([
|
|
178
|
+
'MoneroTransactionInitRequest',
|
|
179
|
+
'MoneroTransactionInitAck',
|
|
180
|
+
'MoneroTransactionSetInputRequest',
|
|
181
|
+
'MoneroTransactionSetInputAck',
|
|
182
|
+
'MoneroTransactionInputViniRequest',
|
|
183
|
+
'MoneroTransactionInputViniAck',
|
|
184
|
+
'MoneroTransactionAllInputsSetRequest',
|
|
185
|
+
'MoneroTransactionAllInputsSetAck',
|
|
186
|
+
'MoneroTransactionSetOutputRequest',
|
|
187
|
+
'MoneroTransactionSetOutputAck',
|
|
188
|
+
'MoneroTransactionAllOutSetRequest',
|
|
189
|
+
'MoneroTransactionAllOutSetAck',
|
|
190
|
+
'MoneroTransactionSignInputRequest',
|
|
191
|
+
'MoneroTransactionSignInputAck',
|
|
192
|
+
'MoneroTransactionFinalRequest',
|
|
193
|
+
'MoneroTransactionFinalAck',
|
|
194
|
+
])('redacts the complete Monero signing message %s', name => {
|
|
195
|
+
const payload = { src_entr: { mask: 'synthetic-mask-marker' }, opening_key: 'synthetic-key' };
|
|
196
|
+
expect(createTransportCallLog(name, 'V1', payload).request).toBe('[REDACTED]');
|
|
197
|
+
expect(getSafeTransportLogPayload(payload, name)).toBe('[REDACTED]');
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
test.each([
|
|
201
|
+
'ZcashSignPczt',
|
|
202
|
+
'ZcashPcztChunkRequest',
|
|
203
|
+
'ZcashPcztChunkAck',
|
|
204
|
+
'ZcashSignedPczt',
|
|
205
|
+
'ZcashSignedPcztChunkRequest',
|
|
206
|
+
])('redacts the complete %s request and response', name => {
|
|
207
|
+
const payload = { data_chunk: 'synthetic-pczt-marker', chunk_length: 64 };
|
|
208
|
+
expect(createTransportCallLog(name, 'V2', payload).request).toBe('[REDACTED]');
|
|
209
|
+
expect(getSafeTransportLogPayload(payload, name)).toBe('[REDACTED]');
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
test('redacts named PCZT fields even without protocol message context', () => {
|
|
213
|
+
expect(
|
|
214
|
+
getSafeTransportLogPayload({
|
|
215
|
+
pczt: 'synthetic-pczt-marker',
|
|
216
|
+
signedPczt: 'synthetic-signed-pczt-marker',
|
|
217
|
+
data_chunk: 'synthetic-chunk-marker',
|
|
218
|
+
nested: { dataChunk: 'synthetic-chunk-marker' },
|
|
219
|
+
})
|
|
220
|
+
).toEqual({
|
|
221
|
+
pczt: '[REDACTED]',
|
|
222
|
+
signedPczt: '[REDACTED]',
|
|
223
|
+
data_chunk: '[REDACTED]',
|
|
224
|
+
nested: { dataChunk: '[REDACTED]' },
|
|
225
|
+
});
|
|
226
|
+
});
|
|
85
227
|
});
|
package/dist/constants.d.ts
CHANGED
|
@@ -10,6 +10,7 @@ export declare const PROTOCOL_V2_BLE_FILE_CHUNK_SIZE = 1800;
|
|
|
10
10
|
export declare const PROTOCOL_V2_BLE_FIRMWARE_FILE_CHUNK_SIZE = 1960;
|
|
11
11
|
export declare const PROTOCOL_V2_BLE_FILE_READ_CHUNK_SIZE = 900;
|
|
12
12
|
export declare const PROTOCOL_V2_BLE_FRAME_MAX_BYTES = 2048;
|
|
13
|
+
export declare const PROTOCOL_V2_FILE_WRITE_PIPELINE_WINDOW = 2;
|
|
13
14
|
export declare const PROTOCOL_V2_FILE_CHUNK_SIZE = 4000;
|
|
14
15
|
export declare const PROTOCOL_V2_CHANNEL_USB = 0;
|
|
15
16
|
export declare const PROTOCOL_V2_CHANNEL_BLE_UART = 1;
|
package/dist/constants.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"constants.d.ts","sourceRoot":"","sources":["../src/constants.ts"],"names":[],"mappings":"AAGA,eAAO,MAAM,qBAAqB,KAAO,CAAC;AAG1C,eAAO,MAAM,uBAAuB,KAAO,CAAC;AAG5C,eAAO,MAAM,8BAA8B,KAAK,CAAC;AAGjD,eAAO,MAAM,2BAA2B,QAAqC,CAAC;AAG9E,eAAO,MAAM,+BAA+B,QAAQ,CAAC;AAGrD,eAAO,MAAM,gCAAgC,QAA0C,CAAC;AAKxF,eAAO,MAAM,2BAA2B,OAAO,CAAC;AAGhD,eAAO,MAAM,kCAAkC,OAAO,CAAC;AAGvD,eAAO,MAAM,+BAA+B,OAAO,CAAC;
|
|
1
|
+
{"version":3,"file":"constants.d.ts","sourceRoot":"","sources":["../src/constants.ts"],"names":[],"mappings":"AAGA,eAAO,MAAM,qBAAqB,KAAO,CAAC;AAG1C,eAAO,MAAM,uBAAuB,KAAO,CAAC;AAG5C,eAAO,MAAM,8BAA8B,KAAK,CAAC;AAGjD,eAAO,MAAM,2BAA2B,QAAqC,CAAC;AAG9E,eAAO,MAAM,+BAA+B,QAAQ,CAAC;AAGrD,eAAO,MAAM,gCAAgC,QAA0C,CAAC;AAKxF,eAAO,MAAM,2BAA2B,OAAO,CAAC;AAGhD,eAAO,MAAM,kCAAkC,OAAO,CAAC;AAGvD,eAAO,MAAM,+BAA+B,OAAO,CAAC;AAOpD,eAAO,MAAM,wCAAwC,OAAO,CAAC;AAG7D,eAAO,MAAM,oCAAoC,MAAM,CAAC;AAGxD,eAAO,MAAM,+BAA+B,OAAO,CAAC;AAQpD,eAAO,MAAM,sCAAsC,IAAI,CAAC;AAGxD,eAAO,MAAM,2BAA2B,OAAqC,CAAC;AAM9E,eAAO,MAAM,uBAAuB,IAAI,CAAC;AACzC,eAAO,MAAM,4BAA4B,IAAI,CAAC;AAC9C,eAAO,MAAM,0BAA0B,IAAI,CAAC;AAG5C,eAAO,MAAM,8BAA8B,IAAI,CAAC;AAQhD,eAAO,MAAM,uCAAuC,QAAgB,CAAC"}
|