@ceralive/modem-control 0.1.0 → 1.0.0
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/package.json +1 -1
- package/src/backend/device-classifier.ts +8 -0
- package/src/backend/features.test.ts +4 -0
- package/src/backend/index.ts +15 -0
- package/src/backend/uhubctl-power-hook.test.ts +274 -0
- package/src/backend/uhubctl-power-hook.ts +377 -0
- package/src/backend/usage/index.ts +23 -0
- package/src/backend/usage/policy-store.test.ts +164 -0
- package/src/backend/usage/policy-store.ts +216 -0
- package/src/backend/usage/policy-write.test.ts +198 -0
- package/src/backend/usage/policy-write.ts +207 -0
- package/src/backend/usage/sampler.test.ts +108 -0
- package/src/backend/usage/sampler.ts +57 -3
- package/src/ports/forbidden-surface.test.ts +174 -13
- package/src/transport/calls.ts +113 -0
- package/src/transport/characterization.test.ts +260 -0
- package/src/transport/no-library-leak.test.ts +13 -2
- package/src/transport/signals.ts +150 -0
- package/src/transport/test-support/fake-service.ts +27 -2
- package/src/transport/transport.ts +24 -213
- package/src/usb-mode/index.ts +32 -1
- package/src/usb-mode/ingestion.test.ts +268 -0
- package/src/usb-mode/ingestion.ts +297 -0
- package/src/usb-mode/promotion-review.ts +117 -0
- package/src/usb-mode/usb-devices-parse.ts +196 -0
|
@@ -1,29 +1,26 @@
|
|
|
1
1
|
// The D-Bus transport seam implementation.
|
|
2
2
|
//
|
|
3
|
-
// Wraps `@httptoolkit/dbus-native` behind the `DbusTransport` interface
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
// subscription
|
|
7
|
-
// listener count
|
|
3
|
+
// Wraps `@httptoolkit/dbus-native` behind the `DbusTransport` interface. This module owns the
|
|
4
|
+
// connection lifecycle — handshake, a reconnect loop that re-issues every match rule after a
|
|
5
|
+
// bus restart, and teardown — and delegates method-call dispatch to `./calls` and signal
|
|
6
|
+
// subscription/match-rule tracking to `./signals`. Its single `message` listener fans out to
|
|
7
|
+
// the signal registry, so subscribing never grows the listener count (the 100-cycle leak check).
|
|
8
8
|
|
|
9
9
|
import { EventEmitter } from 'node:events';
|
|
10
|
-
import {
|
|
10
|
+
import { CallDispatcher, DEFAULT_CALL_TIMEOUT_MS } from './calls';
|
|
11
11
|
import {
|
|
12
12
|
type CreateClientOptions,
|
|
13
13
|
createClient,
|
|
14
|
-
messageType,
|
|
15
14
|
type RawBus,
|
|
16
15
|
type RawMessage,
|
|
17
|
-
type ReplyContext,
|
|
18
16
|
} from './dbus-native';
|
|
19
17
|
import { DisconnectedError, TransportError } from './errors';
|
|
18
|
+
import { SignalRegistry } from './signals';
|
|
20
19
|
import type {
|
|
21
20
|
DbusTransport,
|
|
22
21
|
DbusTransportOptions,
|
|
23
|
-
DbusValue,
|
|
24
22
|
MethodCall,
|
|
25
23
|
MethodReply,
|
|
26
|
-
SignalEvent,
|
|
27
24
|
SignalListener,
|
|
28
25
|
SignalSpec,
|
|
29
26
|
Subscription,
|
|
@@ -39,19 +36,6 @@ interface ResolvedReconnect {
|
|
|
39
36
|
readonly maxAttempts: number;
|
|
40
37
|
}
|
|
41
38
|
|
|
42
|
-
interface SubscriptionRecord {
|
|
43
|
-
readonly id: number;
|
|
44
|
-
readonly spec: SignalSpec;
|
|
45
|
-
readonly listener: SignalListener;
|
|
46
|
-
readonly rule: string;
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
interface PendingCall {
|
|
50
|
-
settle(): void;
|
|
51
|
-
reject(error: unknown): void;
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
const DEFAULT_CALL_TIMEOUT_MS = 30_000;
|
|
55
39
|
// Bound a single connect/auth attempt so a stalled handshake cannot freeze the reconnect
|
|
56
40
|
// loop. A local unix-socket D-Bus connect completes in milliseconds; 2s is ample headroom
|
|
57
41
|
// while keeping reconnect responsive after a bus restart.
|
|
@@ -65,46 +49,19 @@ const DEFAULT_RECONNECT: ResolvedReconnect = {
|
|
|
65
49
|
|
|
66
50
|
const sleep = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms));
|
|
67
51
|
|
|
68
|
-
function buildMatchRule(spec: SignalSpec): string {
|
|
69
|
-
const parts = [`type='signal'`, `interface='${spec.interface}'`, `member='${spec.member}'`];
|
|
70
|
-
if (spec.path !== undefined) {
|
|
71
|
-
parts.push(`path='${spec.path}'`);
|
|
72
|
-
}
|
|
73
|
-
if (spec.sender !== undefined) {
|
|
74
|
-
parts.push(`sender='${spec.sender}'`);
|
|
75
|
-
}
|
|
76
|
-
return parts.join(',');
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
function signalMatches(spec: SignalSpec, message: RawMessage): boolean {
|
|
80
|
-
if (message.interface !== spec.interface || message.member !== spec.member) {
|
|
81
|
-
return false;
|
|
82
|
-
}
|
|
83
|
-
if (spec.path !== undefined && message.path !== spec.path) {
|
|
84
|
-
return false;
|
|
85
|
-
}
|
|
86
|
-
if (spec.sender !== undefined && message.sender !== spec.sender) {
|
|
87
|
-
return false;
|
|
88
|
-
}
|
|
89
|
-
return true;
|
|
90
|
-
}
|
|
91
|
-
|
|
92
52
|
class DbusTransportImpl implements DbusTransport {
|
|
93
53
|
readonly #options: DbusTransportOptions;
|
|
94
54
|
readonly #reconnect: ResolvedReconnect;
|
|
95
|
-
readonly #callTimeoutMs: number;
|
|
96
55
|
readonly #emitter = new EventEmitter();
|
|
97
|
-
readonly #
|
|
98
|
-
readonly #
|
|
99
|
-
readonly #pending = new Set<PendingCall>();
|
|
56
|
+
readonly #calls: CallDispatcher;
|
|
57
|
+
readonly #signals: SignalRegistry;
|
|
100
58
|
|
|
101
59
|
#bus: RawBus | null = null;
|
|
102
60
|
#state: State = 'idle';
|
|
103
61
|
#closing = false;
|
|
104
|
-
#nextSubId = 1;
|
|
105
62
|
|
|
106
63
|
// Bound once so the same references can be detached from a dead connection.
|
|
107
|
-
readonly #onMessage = (message: RawMessage): void => this.#
|
|
64
|
+
readonly #onMessage = (message: RawMessage): void => this.#signals.dispatch(message);
|
|
108
65
|
readonly #onConnectionError = (cause: unknown): void =>
|
|
109
66
|
this.#handleDrop(cause instanceof Error ? cause : new DisconnectedError(String(cause)));
|
|
110
67
|
readonly #onConnectionEnd = (): void =>
|
|
@@ -112,7 +69,12 @@ class DbusTransportImpl implements DbusTransport {
|
|
|
112
69
|
|
|
113
70
|
constructor(options: DbusTransportOptions) {
|
|
114
71
|
this.#options = options;
|
|
115
|
-
this.#
|
|
72
|
+
this.#calls = new CallDispatcher(options.callTimeoutMs ?? DEFAULT_CALL_TIMEOUT_MS);
|
|
73
|
+
this.#signals = new SignalRegistry({
|
|
74
|
+
currentBus: () => this.#bus,
|
|
75
|
+
isConnected: () => this.#state === 'connected',
|
|
76
|
+
emitError: (error) => this.#emitter.emit('error', error),
|
|
77
|
+
});
|
|
116
78
|
this.#reconnect = {
|
|
117
79
|
enabled: options.reconnect?.enabled ?? DEFAULT_RECONNECT.enabled,
|
|
118
80
|
initialDelayMs: options.reconnect?.initialDelayMs ?? DEFAULT_RECONNECT.initialDelayMs,
|
|
@@ -144,113 +106,23 @@ class DbusTransportImpl implements DbusTransport {
|
|
|
144
106
|
this.#state = 'closed';
|
|
145
107
|
const bus = this.#bus;
|
|
146
108
|
this.#bus = null;
|
|
147
|
-
this.#
|
|
109
|
+
this.#calls.rejectAll(new DisconnectedError('transport closed'));
|
|
148
110
|
if (bus) {
|
|
149
111
|
this.#quiesce(bus);
|
|
150
112
|
await bus.disconnect().catch(() => undefined);
|
|
151
113
|
}
|
|
152
114
|
}
|
|
153
115
|
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
if (this.#state !== 'connected' || bus === null) {
|
|
157
|
-
throw new DisconnectedError('cannot call method: transport not connected');
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
const signature = call.signature ?? '';
|
|
161
|
-
const args = call.args ?? [];
|
|
162
|
-
const message: RawMessage = {
|
|
163
|
-
type: messageType.methodCall,
|
|
164
|
-
destination: call.destination,
|
|
165
|
-
path: call.path,
|
|
166
|
-
interface: call.interface,
|
|
167
|
-
member: call.member,
|
|
168
|
-
};
|
|
169
|
-
if (signature.length > 0) {
|
|
170
|
-
// Throws UnsupportedSignatureError / BigIntRequiredError before anything hits
|
|
171
|
-
// the wire.
|
|
172
|
-
message.signature = signature;
|
|
173
|
-
message.body = encodeBody(signature, args);
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
const timeoutMs = call.timeoutMs ?? this.#callTimeoutMs;
|
|
177
|
-
const pendingSet = this.#pending;
|
|
178
|
-
return new Promise<MethodReply>((resolve, reject) => {
|
|
179
|
-
let done = false;
|
|
180
|
-
const pending: PendingCall = {
|
|
181
|
-
settle: finish,
|
|
182
|
-
reject: (error) => {
|
|
183
|
-
finish();
|
|
184
|
-
reject(error);
|
|
185
|
-
},
|
|
186
|
-
};
|
|
187
|
-
|
|
188
|
-
function finish(): void {
|
|
189
|
-
if (done) {
|
|
190
|
-
return;
|
|
191
|
-
}
|
|
192
|
-
done = true;
|
|
193
|
-
clearTimeout(timer);
|
|
194
|
-
pendingSet.delete(pending);
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
const timer = setTimeout(() => {
|
|
198
|
-
finish();
|
|
199
|
-
reject(
|
|
200
|
-
new TransportError(
|
|
201
|
-
`Method call ${call.interface}.${call.member} timed out after ${timeoutMs}ms`,
|
|
202
|
-
),
|
|
203
|
-
);
|
|
204
|
-
}, timeoutMs);
|
|
205
|
-
|
|
206
|
-
pendingSet.add(pending);
|
|
207
|
-
|
|
208
|
-
bus.invoke(
|
|
209
|
-
message,
|
|
210
|
-
function reply(this: ReplyContext, error: unknown, ...body: unknown[]): void {
|
|
211
|
-
if (done) {
|
|
212
|
-
// Reply arrived after timeout/disconnect already settled the promise — ignore.
|
|
213
|
-
return;
|
|
214
|
-
}
|
|
215
|
-
finish();
|
|
216
|
-
if (error) {
|
|
217
|
-
reject(error instanceof Error ? error : new TransportError(String(error)));
|
|
218
|
-
return;
|
|
219
|
-
}
|
|
220
|
-
try {
|
|
221
|
-
const replySignature = this.signature ?? '';
|
|
222
|
-
const decoded: DbusValue[] =
|
|
223
|
-
replySignature.length > 0 ? decodeBody(replySignature, body) : [];
|
|
224
|
-
resolve({ signature: replySignature, body: decoded });
|
|
225
|
-
} catch (decodeError) {
|
|
226
|
-
reject(decodeError);
|
|
227
|
-
}
|
|
228
|
-
},
|
|
229
|
-
);
|
|
230
|
-
});
|
|
116
|
+
callMethod(call: MethodCall): Promise<MethodReply> {
|
|
117
|
+
return this.#calls.call(this.#bus, this.#state === 'connected', call);
|
|
231
118
|
}
|
|
232
119
|
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
const id = this.#nextSubId++;
|
|
236
|
-
this.#subscriptions.set(id, { id, spec, listener, rule });
|
|
237
|
-
await this.#addMatchRule(rule);
|
|
238
|
-
|
|
239
|
-
let removed = false;
|
|
240
|
-
return {
|
|
241
|
-
unsubscribe: async (): Promise<void> => {
|
|
242
|
-
if (removed) {
|
|
243
|
-
return;
|
|
244
|
-
}
|
|
245
|
-
removed = true;
|
|
246
|
-
this.#subscriptions.delete(id);
|
|
247
|
-
await this.#removeMatchRule(rule);
|
|
248
|
-
},
|
|
249
|
-
};
|
|
120
|
+
subscribeSignal(spec: SignalSpec, listener: SignalListener): Promise<Subscription> {
|
|
121
|
+
return this.#signals.subscribe(spec, listener);
|
|
250
122
|
}
|
|
251
123
|
|
|
252
124
|
subscriptionCount(): number {
|
|
253
|
-
return this.#
|
|
125
|
+
return this.#signals.count();
|
|
254
126
|
}
|
|
255
127
|
|
|
256
128
|
on(event: TransportEvent, handler: (payload?: unknown) => void): void {
|
|
@@ -300,9 +172,7 @@ class DbusTransportImpl implements DbusTransport {
|
|
|
300
172
|
bus.connection.on('end', this.#onConnectionEnd);
|
|
301
173
|
|
|
302
174
|
// Re-issue every live match rule so a reconnect resubscribes transparently.
|
|
303
|
-
|
|
304
|
-
await bus.addMatch(rule);
|
|
305
|
-
}
|
|
175
|
+
await this.#signals.reissueRules(bus);
|
|
306
176
|
|
|
307
177
|
this.#bus = bus;
|
|
308
178
|
this.#state = 'connected';
|
|
@@ -319,58 +189,6 @@ class DbusTransportImpl implements DbusTransport {
|
|
|
319
189
|
}
|
|
320
190
|
}
|
|
321
191
|
|
|
322
|
-
#dispatchSignal(message: RawMessage): void {
|
|
323
|
-
if (message.type !== messageType.signal) {
|
|
324
|
-
return;
|
|
325
|
-
}
|
|
326
|
-
for (const record of this.#subscriptions.values()) {
|
|
327
|
-
if (!signalMatches(record.spec, message)) {
|
|
328
|
-
continue;
|
|
329
|
-
}
|
|
330
|
-
let body: DbusValue[];
|
|
331
|
-
try {
|
|
332
|
-
const signature = message.signature ?? '';
|
|
333
|
-
body = signature.length > 0 ? decodeBody(signature, message.body ?? []) : [];
|
|
334
|
-
} catch (error) {
|
|
335
|
-
this.#emitter.emit('error', error);
|
|
336
|
-
continue;
|
|
337
|
-
}
|
|
338
|
-
const event: SignalEvent = {
|
|
339
|
-
path: message.path ?? '',
|
|
340
|
-
interface: message.interface ?? '',
|
|
341
|
-
member: message.member ?? '',
|
|
342
|
-
sender: message.sender,
|
|
343
|
-
signature: message.signature ?? '',
|
|
344
|
-
body,
|
|
345
|
-
};
|
|
346
|
-
try {
|
|
347
|
-
record.listener(event);
|
|
348
|
-
} catch (error) {
|
|
349
|
-
this.#emitter.emit('error', error);
|
|
350
|
-
}
|
|
351
|
-
}
|
|
352
|
-
}
|
|
353
|
-
|
|
354
|
-
async #addMatchRule(rule: string): Promise<void> {
|
|
355
|
-
const current = this.#matchRuleRefcount.get(rule) ?? 0;
|
|
356
|
-
this.#matchRuleRefcount.set(rule, current + 1);
|
|
357
|
-
if (current === 0 && this.#state === 'connected' && this.#bus) {
|
|
358
|
-
await this.#bus.addMatch(rule);
|
|
359
|
-
}
|
|
360
|
-
}
|
|
361
|
-
|
|
362
|
-
async #removeMatchRule(rule: string): Promise<void> {
|
|
363
|
-
const current = this.#matchRuleRefcount.get(rule) ?? 0;
|
|
364
|
-
if (current <= 1) {
|
|
365
|
-
this.#matchRuleRefcount.delete(rule);
|
|
366
|
-
if (current === 1 && this.#state === 'connected' && this.#bus) {
|
|
367
|
-
await this.#bus.removeMatch(rule).catch(() => undefined);
|
|
368
|
-
}
|
|
369
|
-
} else {
|
|
370
|
-
this.#matchRuleRefcount.set(rule, current - 1);
|
|
371
|
-
}
|
|
372
|
-
}
|
|
373
|
-
|
|
374
192
|
#detachHandlers(bus: RawBus): void {
|
|
375
193
|
bus.connection.removeListener('message', this.#onMessage as (...args: unknown[]) => void);
|
|
376
194
|
bus.connection.removeListener('error', this.#onConnectionError);
|
|
@@ -385,13 +203,6 @@ class DbusTransportImpl implements DbusTransport {
|
|
|
385
203
|
bus.connection.on('error', () => undefined);
|
|
386
204
|
}
|
|
387
205
|
|
|
388
|
-
#rejectPending(cause: unknown): void {
|
|
389
|
-
for (const pending of this.#pending) {
|
|
390
|
-
pending.reject(cause);
|
|
391
|
-
}
|
|
392
|
-
this.#pending.clear();
|
|
393
|
-
}
|
|
394
|
-
|
|
395
206
|
#handleDrop(cause: unknown): void {
|
|
396
207
|
if (this.#closing) {
|
|
397
208
|
return;
|
|
@@ -404,7 +215,7 @@ class DbusTransportImpl implements DbusTransport {
|
|
|
404
215
|
this.#quiesce(this.#bus);
|
|
405
216
|
}
|
|
406
217
|
this.#bus = null;
|
|
407
|
-
this.#
|
|
218
|
+
this.#calls.rejectAll(cause);
|
|
408
219
|
this.#emitter.emit('disconnected', cause);
|
|
409
220
|
if (this.#reconnect.enabled) {
|
|
410
221
|
void this.#reconnectLoop();
|
package/src/usb-mode/index.ts
CHANGED
|
@@ -1,8 +1,13 @@
|
|
|
1
|
-
// The certified USB-mode catalog — schema, data, and
|
|
1
|
+
// The certified USB-mode catalog — schema, data, lookups, and the evidence-bundle
|
|
2
|
+
// ingestion seam.
|
|
2
3
|
//
|
|
3
4
|
// A6.1's bench CLI (`set-usb-mode`) and A6.2's `certify` tool both consume this: the
|
|
4
5
|
// CLI looks up the permitted transition for a target mode, `certify` validates a
|
|
5
6
|
// candidate entry against the schema before a human commits it.
|
|
7
|
+
//
|
|
8
|
+
// The ingestion seam (`./ingestion`, `./promotion-review`) is the documented path from a
|
|
9
|
+
// captured `certify` bundle to a reviewed catalog commit — see `docs/CATALOG-INGESTION.md`.
|
|
10
|
+
// It refuses a `synthetic: true` bundle for catalog promotion, by construction.
|
|
6
11
|
|
|
7
12
|
export {
|
|
8
13
|
CERTIFIED_CATALOG,
|
|
@@ -25,3 +30,29 @@ export {
|
|
|
25
30
|
permittedTransitionSchema,
|
|
26
31
|
type SkuDiscriminator,
|
|
27
32
|
} from './catalog-schema';
|
|
33
|
+
export {
|
|
34
|
+
buildCatalogEntryCandidate,
|
|
35
|
+
buildClassifierFixture,
|
|
36
|
+
type CatalogClaim,
|
|
37
|
+
CLAIMABLE_CANONICAL_MODES,
|
|
38
|
+
type ClassifierFixture,
|
|
39
|
+
type EvidenceBundleView,
|
|
40
|
+
evidenceBundleViewSchema,
|
|
41
|
+
type FixtureProvenance,
|
|
42
|
+
type IngestionOutcome,
|
|
43
|
+
type IngestionRefusal,
|
|
44
|
+
type IngestionRefusalReason,
|
|
45
|
+
type IngestionRequest,
|
|
46
|
+
parseIngestionRequest,
|
|
47
|
+
} from './ingestion';
|
|
48
|
+
export {
|
|
49
|
+
type PromotionContext,
|
|
50
|
+
type PromotionRequest,
|
|
51
|
+
renderPromotionReview,
|
|
52
|
+
} from './promotion-review';
|
|
53
|
+
export {
|
|
54
|
+
type ParsedUsbDevice,
|
|
55
|
+
type ParsedUsbInterface,
|
|
56
|
+
parseUsbDevices,
|
|
57
|
+
selectUniqueDevice,
|
|
58
|
+
} from './usb-devices-parse';
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
// The ingestion seam's contract: a bundle round-trips into a schema-valid catalog entry
|
|
2
|
+
// and a real-shaped classifier fixture, the sha256 links the two, and a synthetic bundle
|
|
3
|
+
// is REFUSED for catalog promotion with a typed reason rather than silently accepted.
|
|
4
|
+
|
|
5
|
+
import { describe, expect, test } from 'bun:test';
|
|
6
|
+
import { classifyDevice, detectUsbMode } from '../backend/device-classifier';
|
|
7
|
+
import { catalogEntrySchema } from './catalog-schema';
|
|
8
|
+
import {
|
|
9
|
+
buildCatalogEntryCandidate,
|
|
10
|
+
buildClassifierFixture,
|
|
11
|
+
type IngestionRequest,
|
|
12
|
+
parseIngestionRequest,
|
|
13
|
+
} from './ingestion';
|
|
14
|
+
import { renderPromotionReview } from './promotion-review';
|
|
15
|
+
import { parseUsbDevices, selectUniqueDevice } from './usb-devices-parse';
|
|
16
|
+
|
|
17
|
+
const SHA = 'a'.repeat(64);
|
|
18
|
+
|
|
19
|
+
/** Verbatim-shaped `usb-devices` output for a QMI stick plus one unrelated hub. */
|
|
20
|
+
const USB_DEVICES = `
|
|
21
|
+
T: Bus=04 Lev=01 Prnt=01 Port=00 Cnt=01 Dev#= 2 Spd=5000 MxCh= 4
|
|
22
|
+
D: Ver= 3.20 Cls=09(hub ) Sub=00 Prot=03 MxPS= 9 #Cfgs= 1
|
|
23
|
+
P: Vendor=0bda ProdID=0411 Rev=01.01
|
|
24
|
+
S: Manufacturer=Generic
|
|
25
|
+
S: Product=USB3.2 Hub
|
|
26
|
+
I: If#= 0 Alt= 0 #EPs= 1 Cls=09(hub ) Sub=00 Prot=00 Driver=hub
|
|
27
|
+
|
|
28
|
+
T: Bus=04 Lev=03 Prnt=04 Port=03 Cnt=01 Dev#= 7 Spd=480 MxCh= 0
|
|
29
|
+
D: Ver= 2.00 Cls=00(>ifc ) Sub=00 Prot=00 MxPS=64 #Cfgs= 1
|
|
30
|
+
P: Vendor=2c7c ProdID=0125 Rev=03.18
|
|
31
|
+
S: Manufacturer=Quectel
|
|
32
|
+
S: Product=SYNTHETIC-BENCH-STICK
|
|
33
|
+
I: If#= 2 Alt= 0 #EPs= 3 Cls=ff(vend.) Sub=00 Prot=00 Driver=option
|
|
34
|
+
I: If#= 4 Alt= 0 #EPs= 3 Cls=ff(vend.) Sub=ff Prot=ff Driver=qmi_wwan
|
|
35
|
+
`;
|
|
36
|
+
|
|
37
|
+
function bundle(overrides: Record<string, unknown> = {}): Record<string, unknown> {
|
|
38
|
+
return {
|
|
39
|
+
schemaVersion: 1,
|
|
40
|
+
synthetic: false,
|
|
41
|
+
capturedAtMs: 1_760_000_000_000,
|
|
42
|
+
slot: 'Modem/2',
|
|
43
|
+
sku: {
|
|
44
|
+
vidPid: '2c7c:0125',
|
|
45
|
+
model: 'CERALIVE-SYNTHETIC-TEST-SKU',
|
|
46
|
+
firmwarePrefix: 'SYNTHETICFW01',
|
|
47
|
+
},
|
|
48
|
+
usb: {
|
|
49
|
+
usbDevices: USB_DEVICES,
|
|
50
|
+
udevProperties: {
|
|
51
|
+
ID_PATH: 'platform-xhci-hcd.0.auto-usb-0:1.4.4',
|
|
52
|
+
ID_VENDOR_ID: '2c7c',
|
|
53
|
+
ID_MODEL_ID: '0125',
|
|
54
|
+
INTERFACE: 'wwan0',
|
|
55
|
+
},
|
|
56
|
+
},
|
|
57
|
+
// Fields the real bundle carries and the ingestion VIEW deliberately ignores.
|
|
58
|
+
usbExtra: { lsusb: 'Device Descriptor:' },
|
|
59
|
+
modemManager: { mmcliKeyfile: {}, managedObjects: {}, signalWindow: [] },
|
|
60
|
+
...overrides,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const request = (overrides: Record<string, unknown> = {}, sha = SHA): IngestionRequest => ({
|
|
65
|
+
bundle: bundle(overrides),
|
|
66
|
+
bundleSha256: sha,
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
const TRANSITION = {
|
|
70
|
+
from: 'qmi',
|
|
71
|
+
to: 'mbim',
|
|
72
|
+
atCommand: 'AT+QCFG="usbnet",2',
|
|
73
|
+
expectedResponse: 'OK',
|
|
74
|
+
expectsPortDrop: true,
|
|
75
|
+
afterDescriptors: {
|
|
76
|
+
deviceClass: 0,
|
|
77
|
+
interfaces: [
|
|
78
|
+
{ interfaceClass: 2, interfaceSubClass: 14, interfaceProtocol: 0 },
|
|
79
|
+
{ interfaceClass: 10, interfaceSubClass: 0, interfaceProtocol: 2 },
|
|
80
|
+
],
|
|
81
|
+
},
|
|
82
|
+
timeline: [{ event: 'command-sent', atMs: 1 }],
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
describe('parseUsbDevices — the descriptor source a base bundle actually carries', () => {
|
|
86
|
+
test('parses every device, its bDeviceClass, and each interface driver', () => {
|
|
87
|
+
const devices = parseUsbDevices(USB_DEVICES);
|
|
88
|
+
expect(devices).toHaveLength(2);
|
|
89
|
+
const stick = devices[1];
|
|
90
|
+
expect(stick?.vidPid).toBe('2c7c:0125');
|
|
91
|
+
expect(stick?.bDeviceClass).toBe(0);
|
|
92
|
+
expect(stick?.product).toBe('SYNTHETIC-BENCH-STICK');
|
|
93
|
+
expect(stick?.interfaces).toEqual([
|
|
94
|
+
{ interfaceClass: 0xff, interfaceSubClass: 0x00, interfaceProtocol: 0x00, driver: 'option' },
|
|
95
|
+
{
|
|
96
|
+
interfaceClass: 0xff,
|
|
97
|
+
interfaceSubClass: 0xff,
|
|
98
|
+
interfaceProtocol: 0xff,
|
|
99
|
+
driver: 'qmi_wwan',
|
|
100
|
+
},
|
|
101
|
+
]);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test('a block with no P: line yields no record — identity is never invented', () => {
|
|
105
|
+
expect(parseUsbDevices('T: Bus=01\nI: If#= 0 Cls=ff Sub=ff Prot=ff Driver=x')).toEqual([]);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test('a duplicate VID:PID is AMBIGUOUS, not first-wins (the Huawei HiLink pair)', () => {
|
|
109
|
+
const pair = `${USB_DEVICES}\nT: Bus=01 Lev=01\nD: Cls=00\nP: Vendor=2c7c ProdID=0125 Rev=03.18\nI: If#= 0 Cls=ff Sub=ff Prot=ff Driver=qmi_wwan\n`;
|
|
110
|
+
const selected = selectUniqueDevice(parseUsbDevices(pair), '2c7c:0125');
|
|
111
|
+
expect(selected).toEqual({ ambiguousMatches: 2 });
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
describe('buildClassifierFixture — the real udev shape, not a hand-typed approximation', () => {
|
|
116
|
+
test('produces a snapshot the REAL classifier classifies correctly', () => {
|
|
117
|
+
const outcome = buildClassifierFixture(request());
|
|
118
|
+
expect(outcome.ok).toBe(true);
|
|
119
|
+
if (!outcome.ok) {
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
const { snapshot, provenance } = outcome.value;
|
|
123
|
+
expect(snapshot.vendorId).toBe('2c7c');
|
|
124
|
+
expect(snapshot.productId).toBe('0125');
|
|
125
|
+
expect(snapshot.model).toBe('CERALIVE-SYNTHETIC-TEST-SKU');
|
|
126
|
+
expect(snapshot.firmwareRevision).toBe('SYNTHETICFW01');
|
|
127
|
+
expect(snapshot.physicalUid).toBe('platform-xhci-hcd.0.auto-usb-0:1.4.4');
|
|
128
|
+
expect(snapshot.ifname).toBe('wwan0');
|
|
129
|
+
// The whole point of deriving from real capture text: the fixture must survive
|
|
130
|
+
// the production classifier, not merely typecheck.
|
|
131
|
+
expect(classifyDevice(snapshot).deviceClass).toBe('mm-managed');
|
|
132
|
+
expect(detectUsbMode(snapshot)).toBe('qmi');
|
|
133
|
+
expect(provenance.bundleSha256).toBe(SHA);
|
|
134
|
+
expect(provenance.synthetic).toBe(false);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test('a SYNTHETIC bundle still yields a fixture, stamped synthetic in provenance', () => {
|
|
138
|
+
const outcome = buildClassifierFixture(request({ synthetic: true }));
|
|
139
|
+
expect(outcome.ok).toBe(true);
|
|
140
|
+
if (outcome.ok) {
|
|
141
|
+
expect(outcome.value.provenance.synthetic).toBe(true);
|
|
142
|
+
}
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test('refuses a bundle with no sku (blocker B2 shape) rather than inventing one', () => {
|
|
146
|
+
const outcome = buildClassifierFixture(request({ sku: undefined }));
|
|
147
|
+
expect(outcome).toMatchObject({ ok: false, reason: 'sku-missing' });
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test('refuses when the SKU is absent from the usb-devices capture', () => {
|
|
151
|
+
const outcome = buildClassifierFixture(
|
|
152
|
+
request({ sku: { ...(bundle().sku as object), vidPid: '1199:9071' } }),
|
|
153
|
+
);
|
|
154
|
+
expect(outcome).toMatchObject({ ok: false, reason: 'device-not-in-capture' });
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
describe('buildCatalogEntryCandidate — schema round-trip and sha linkage', () => {
|
|
159
|
+
test('a stage-1 bundle yields an entry with NO permitted transitions', () => {
|
|
160
|
+
const outcome = buildCatalogEntryCandidate(request(), { canonicalMode: 'qmi' });
|
|
161
|
+
expect(outcome.ok).toBe(true);
|
|
162
|
+
if (!outcome.ok) {
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
expect(outcome.value.permittedTransitions).toEqual([]);
|
|
166
|
+
// Round-trip through the AUTHORITATIVE schema, not the builder's own view.
|
|
167
|
+
expect(catalogEntrySchema.parse(outcome.value)).toEqual(outcome.value);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
test('a stage-2 bundle links the transition to THIS bundle sha256', () => {
|
|
171
|
+
const sha = 'b'.repeat(64);
|
|
172
|
+
const outcome = buildCatalogEntryCandidate(
|
|
173
|
+
{ bundle: bundle({ transition: TRANSITION }), bundleSha256: sha },
|
|
174
|
+
{ canonicalMode: 'qmi' },
|
|
175
|
+
);
|
|
176
|
+
expect(outcome.ok).toBe(true);
|
|
177
|
+
if (!outcome.ok) {
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
const [transition] = outcome.value.permittedTransitions;
|
|
181
|
+
expect(transition?.evidenceBundleSha256).toBe(sha);
|
|
182
|
+
expect(transition?.expectedDescriptors).toEqual(TRANSITION.afterDescriptors);
|
|
183
|
+
expect(transition?.atCommand).toBe('AT+QCFG="usbnet",2');
|
|
184
|
+
expect(catalogEntrySchema.parse(outcome.value)).toEqual(outcome.value);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
test('REFUSES a synthetic:true bundle for catalog promotion — typed, not silent', () => {
|
|
188
|
+
const outcome = buildCatalogEntryCandidate(request({ synthetic: true }), {
|
|
189
|
+
canonicalMode: 'qmi',
|
|
190
|
+
});
|
|
191
|
+
expect(outcome.ok).toBe(false);
|
|
192
|
+
if (outcome.ok) {
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
expect(outcome.reason).toBe('synthetic-bundle');
|
|
196
|
+
expect(outcome.detail).toContain('synthetic:true');
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
test('refuses a claimed mode that contradicts the captured transition.from', () => {
|
|
200
|
+
const outcome = buildCatalogEntryCandidate(
|
|
201
|
+
{ bundle: bundle({ transition: TRANSITION }), bundleSha256: SHA },
|
|
202
|
+
{ canonicalMode: 'mbim' },
|
|
203
|
+
);
|
|
204
|
+
expect(outcome).toMatchObject({ ok: false, reason: 'transition-mode-mismatch' });
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
test('refuses a router-mode SKU that carries a transition (schema invariant)', () => {
|
|
208
|
+
const outcome = buildCatalogEntryCandidate(
|
|
209
|
+
{ bundle: bundle({ transition: TRANSITION }), bundleSha256: SHA },
|
|
210
|
+
{ canonicalMode: 'router-ethernet' },
|
|
211
|
+
);
|
|
212
|
+
// The mode cross-check fires first; either refusal is correct, neither is an accept.
|
|
213
|
+
expect(outcome.ok).toBe(false);
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
test('accepts a router-mode SKU with no transitions (the RB-15 shape)', () => {
|
|
217
|
+
const outcome = buildCatalogEntryCandidate(request(), { canonicalMode: 'router-ethernet' });
|
|
218
|
+
expect(outcome.ok).toBe(true);
|
|
219
|
+
if (outcome.ok) {
|
|
220
|
+
expect(outcome.value.canonicalMode).toBe('router-ethernet');
|
|
221
|
+
expect(outcome.value.permittedTransitions).toEqual([]);
|
|
222
|
+
}
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
test('refuses a malformed sha256 before reading the bundle at all', () => {
|
|
226
|
+
expect(parseIngestionRequest({ bundle: bundle(), bundleSha256: 'nope' })).toMatchObject({
|
|
227
|
+
ok: false,
|
|
228
|
+
reason: 'sha256-malformed',
|
|
229
|
+
});
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
test('refuses a bundle that fails the view schema', () => {
|
|
233
|
+
expect(
|
|
234
|
+
parseIngestionRequest({ bundle: { schemaVersion: 2 }, bundleSha256: SHA }),
|
|
235
|
+
).toMatchObject({ ok: false, reason: 'bundle-malformed' });
|
|
236
|
+
});
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
describe('renderPromotionReview — the review artifact, including for refusals', () => {
|
|
240
|
+
test('renders the entry, the fixture, and a checklist on success', () => {
|
|
241
|
+
const req = request();
|
|
242
|
+
const comment = renderPromotionReview({
|
|
243
|
+
context: { runbook: 'RB-11', evidencePath: 'test-results/modem-phase-b/08/x/bundle.json' },
|
|
244
|
+
entry: buildCatalogEntryCandidate(req, { canonicalMode: 'qmi' }),
|
|
245
|
+
fixture: buildClassifierFixture(req),
|
|
246
|
+
});
|
|
247
|
+
expect(comment).toContain('Proposed `certified-catalog.json` entry');
|
|
248
|
+
expect(comment).toContain('Proposed classifier fixture');
|
|
249
|
+
expect(comment).toContain('Reviewer checklist');
|
|
250
|
+
expect(comment).toContain('RB-11');
|
|
251
|
+
expect(comment).toContain('**This comment promotes');
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
test('a refused promotion renders the refusal and NO checklist', () => {
|
|
255
|
+
const req = request({ synthetic: true });
|
|
256
|
+
const comment = renderPromotionReview({
|
|
257
|
+
context: { runbook: 'RB-11', evidencePath: 'x.json' },
|
|
258
|
+
entry: buildCatalogEntryCandidate(req, { canonicalMode: 'qmi' }),
|
|
259
|
+
fixture: buildClassifierFixture(req),
|
|
260
|
+
});
|
|
261
|
+
expect(comment).toContain('Catalog entry — REFUSED');
|
|
262
|
+
expect(comment).toContain('`synthetic-bundle`');
|
|
263
|
+
expect(comment).toContain('### No checklist');
|
|
264
|
+
expect(comment).not.toContain('Reviewer checklist');
|
|
265
|
+
// The fixture half still renders — synthetic fixtures are legitimate test data.
|
|
266
|
+
expect(comment).toContain('Derived from a **synthetic** bundle');
|
|
267
|
+
});
|
|
268
|
+
});
|