From fba342ec29bcbb4e8ec068cc9020e5395de90173 Mon Sep 17 00:00:00 2001 From: Andres Cera Date: Sat, 22 Aug 2026 15:06:54 -0500 Subject: [PATCH 01/12] feat(usb-mode): derive offerable compositions from the device's own enumeration --- control/src/index.ts | 1 + control/src/operation-ids.ts | 27 +++ control/src/usb-mode/index.ts | 10 ++ .../src/usb-mode/runtime-capability.test.ts | 162 +++++++++++++++++ control/src/usb-mode/runtime-capability.ts | 164 ++++++++++++++++++ 5 files changed, 364 insertions(+) create mode 100644 control/src/operation-ids.ts create mode 100644 control/src/usb-mode/runtime-capability.test.ts create mode 100644 control/src/usb-mode/runtime-capability.ts diff --git a/control/src/index.ts b/control/src/index.ts index 7e6e8dc..ecf4a19 100644 --- a/control/src/index.ts +++ b/control/src/index.ts @@ -17,6 +17,7 @@ export * from './hardware/router-parsers'; export * from './journal'; export * from './location'; export * from './observations'; +export * from './operation-ids'; export * from './operations'; export * from './ports'; export * from './providers'; diff --git a/control/src/operation-ids.ts b/control/src/operation-ids.ts new file mode 100644 index 0000000..f7b9216 --- /dev/null +++ b/control/src/operation-ids.ts @@ -0,0 +1,27 @@ +export const MODEM_OPERATION_IDS = Object.freeze([ + 'modemmanager.radio-modes', + 'modemmanager.mode-combination', + 'modemmanager.bands', + 'modemmanager.signal', + 'modemmanager.sim', + 'modemmanager.power', + 'status', + 'signal', + 'mode', + 'data', + 'ufi.signal.read', + 'ufi.details.read', + 'nv.write', + 'efs.write', + 'identity.write', + 'calibration.write', + 'firmware.flash', + 'edl.automation', + 'driver.blind-retry', + 'interface.blind-retry', + 'diag.write', + 'diag.info-probe', + 'shell.transport-fallback', +] as const); + +export type ModemOperationId = (typeof MODEM_OPERATION_IDS)[number]; diff --git a/control/src/usb-mode/index.ts b/control/src/usb-mode/index.ts index 8f33a08..f08b865 100644 --- a/control/src/usb-mode/index.ts +++ b/control/src/usb-mode/index.ts @@ -50,6 +50,16 @@ export { type PromotionRequest, renderPromotionReview, } from './promotion-review'; +export { + RUNTIME_COMPOSITION_QUERY_REGISTRY, + RUNTIME_COMPOSITION_VENDORS, + type RuntimeCompositionCapability, + type RuntimeCompositionMode, + type RuntimeCompositionQuery, + type RuntimeCompositionResponse, + type RuntimeCompositionVendor, + resolveRuntimeCompositionCapability, +} from './runtime-capability'; export { type ParsedUsbDevice, type ParsedUsbInterface, diff --git a/control/src/usb-mode/runtime-capability.test.ts b/control/src/usb-mode/runtime-capability.test.ts new file mode 100644 index 0000000..fcec305 --- /dev/null +++ b/control/src/usb-mode/runtime-capability.test.ts @@ -0,0 +1,162 @@ +import { describe, expect, test } from 'bun:test'; + +import { MODEM_OPERATION_IDS } from '../operation-ids'; +import { + RUNTIME_COMPOSITION_QUERY_REGISTRY, + resolveRuntimeCompositionCapability, +} from './runtime-capability'; + +describe('runtime composition capability', () => { + test.each([ + { + name: 'Fibocom FM350 real capture', + vendor: 'fibocom', + currentResponse: '+GTUSBMODE: 41\r\n\r\nOK', + enumerationResponse: '+GTUSBMODE: (40,41)\r\n\r\nOK', + expectedCurrent: 41, + expectedEnumerated: [40, 41], + }, + { + name: 'Quectel usbnet range', + vendor: 'quectel', + currentResponse: '+QCFG: "usbnet",0\r\n\r\nOK', + enumerationResponse: '+QCFG: "usbnet",(0-3)\r\n\r\nOK', + expectedCurrent: 0, + expectedEnumerated: [0, 1, 2, 3], + }, + { + name: 'SIMCom PID domain', + vendor: 'simcom', + currentResponse: '+CUSBPIDSWITCH: 9001\r\n\r\nOK', + enumerationResponse: + '+CUSBPIDSWITCH: (9000,9001,9002,9003,9004,9005,9006,9007,9011,9016,9018,9019,901A,901B,9020,9021,9022,9023,9024,9025,9026,9027,9028,9029,902A,902B),(0-1),(0-1)\r\n\r\nOK', + expectedCurrent: '9001', + expectedEnumerated: [ + '9000', + '9001', + '9002', + '9003', + '9004', + '9005', + '9006', + '9007', + '9011', + '9016', + '9018', + '9019', + '901A', + '901B', + '9020', + '9021', + '9022', + '9023', + '9024', + '9025', + '9026', + '9027', + '9028', + '9029', + '902A', + '902B', + ], + }, + ])('derives $name from the device response', (fixture) => { + const capability = resolveRuntimeCompositionCapability(fixture); + + expect(capability).toEqual({ + status: 'available', + current: fixture.expectedCurrent, + enumerated: fixture.expectedEnumerated, + returnPathProven: true, + offerable: fixture.expectedEnumerated, + }); + }); + + test('withholds every target when the current mode is not enumerated', () => { + const capability = resolveRuntimeCompositionCapability({ + vendor: 'fibocom', + currentResponse: '+GTUSBMODE: 42\r\nOK', + enumerationResponse: '+GTUSBMODE: (40,41)\r\nOK', + }); + + expect(capability).toEqual({ + status: 'available', + current: 42, + enumerated: [40, 41], + returnPathProven: false, + offerable: [], + }); + }); + + test.each([ + { + name: 'unknown vendor', + input: { + vendor: 'unlisted-vendor', + currentResponse: '+MODE: 1\r\nOK', + enumerationResponse: '+MODE: (1,2)\r\nOK', + }, + reason: 'vendor-unsupported', + }, + { + name: 'truncated response', + input: { + vendor: 'fibocom', + currentResponse: '+GTUSBMODE: 41\r\nOK', + enumerationResponse: '+GTUSBMODE: (40,', + }, + reason: 'malformed-response', + }, + ])('fails closed for $name', ({ input, reason }) => { + const capability = resolveRuntimeCompositionCapability(input); + + expect(capability).toEqual({ + status: 'unknown', + current: null, + enumerated: [], + returnPathProven: false, + offerable: [], + reason, + }); + expect(JSON.stringify(capability)).not.toContain('uncertified'); + }); + + test('registry records only the commands needed to ask each vendor', () => { + expect(RUNTIME_COMPOSITION_QUERY_REGISTRY).toEqual({ + fibocom: { current: 'AT+GTUSBMODE?', enumerate: 'AT+GTUSBMODE=?' }, + quectel: { current: 'AT+QCFG="usbnet"', enumerate: 'AT+QCFG=?' }, + simcom: { current: 'AT+CUSBPIDSWITCH?', enumerate: 'AT+CUSBPIDSWITCH=?' }, + sierra: { current: 'AT!USBCOMP?', enumerate: 'AT!USBCOMP=?' }, + }); + }); +}); + +describe('MODEM_OPERATION_IDS', () => { + test('equals the non-vacuous union of concrete IDs declared by all four providers', async () => { + const sourceFiles = [ + '../providers/modem-manager/generic-operations.ts', + '../radio/mode-truth.ts', + '../radio/band-truth.ts', + '../providers/huawei-hilink/runtime.ts', + '../providers/ufi-himi/operations.ts', + '../providers/ufi-himi/prohibitions.ts', + '../providers/zte-goform/provider.ts', + ]; + const source = ( + await Promise.all(sourceFiles.map((path) => Bun.file(new URL(path, import.meta.url)).text())) + ).join('\n'); + const declared = new Set( + Array.from( + source.matchAll( + /['"](modemmanager\.[a-z-]+|ufi\.[a-z.-]+|nv\.write|efs\.write|identity\.write|calibration\.write|firmware\.flash|edl\.automation|driver\.blind-retry|interface\.blind-retry|diag\.write|diag\.info-probe|shell\.transport-fallback|status|signal|mode|data)['"]/g, + ), + (match) => match[1], + ).filter((id) => id !== undefined), + ); + + expect(declared.size).toBe(23); + expect(new Set(MODEM_OPERATION_IDS)).toEqual(declared); + expect(MODEM_OPERATION_IDS).toHaveLength(23); + expect(Object.isFrozen(MODEM_OPERATION_IDS)).toBe(true); + }); +}); diff --git a/control/src/usb-mode/runtime-capability.ts b/control/src/usb-mode/runtime-capability.ts new file mode 100644 index 0000000..a5218eb --- /dev/null +++ b/control/src/usb-mode/runtime-capability.ts @@ -0,0 +1,164 @@ +export const RUNTIME_COMPOSITION_VENDORS = ['fibocom', 'quectel', 'simcom', 'sierra'] as const; +export type RuntimeCompositionVendor = (typeof RUNTIME_COMPOSITION_VENDORS)[number]; + +export type RuntimeCompositionMode = number | string; + +export type RuntimeCompositionQuery = { + readonly current: string; + readonly enumerate: string; +}; + +/** Commands only: selecting a port, sending, deadlines, and retries belong to the provider. */ +export const RUNTIME_COMPOSITION_QUERY_REGISTRY = Object.freeze({ + fibocom: Object.freeze({ current: 'AT+GTUSBMODE?', enumerate: 'AT+GTUSBMODE=?' }), + quectel: Object.freeze({ current: 'AT+QCFG="usbnet"', enumerate: 'AT+QCFG=?' }), + simcom: Object.freeze({ current: 'AT+CUSBPIDSWITCH?', enumerate: 'AT+CUSBPIDSWITCH=?' }), + sierra: Object.freeze({ current: 'AT!USBCOMP?', enumerate: 'AT!USBCOMP=?' }), +} satisfies Readonly>); + +export type RuntimeCompositionCapability = + | { + readonly status: 'available'; + readonly current: RuntimeCompositionMode; + readonly enumerated: readonly RuntimeCompositionMode[]; + readonly returnPathProven: boolean; + readonly offerable: readonly RuntimeCompositionMode[]; + } + | { + readonly status: 'unknown'; + readonly current: null; + readonly enumerated: readonly []; + readonly returnPathProven: false; + readonly offerable: readonly []; + readonly reason: 'vendor-unsupported' | 'malformed-response'; + }; + +export type RuntimeCompositionResponse = { + readonly vendor: string; + readonly currentResponse: string; + readonly enumerationResponse: string; +}; + +type ParsedCapability = { + readonly current: RuntimeCompositionMode; + readonly enumerated: readonly RuntimeCompositionMode[]; +}; + +const UNKNOWN_VENDOR = Object.freeze({ + status: 'unknown', + current: null, + enumerated: [], + returnPathProven: false, + offerable: [], + reason: 'vendor-unsupported', +} as const); + +const MALFORMED_RESPONSE = Object.freeze({ + status: 'unknown', + current: null, + enumerated: [], + returnPathProven: false, + offerable: [], + reason: 'malformed-response', +} as const); + +function parseDecimalDomain(domain: string): readonly number[] | undefined { + const values: number[] = []; + for (const member of domain.split(',')) { + const token = member.trim(); + const range = /^(\d+)-(\d+)$/.exec(token); + if (range !== null) { + const start = Number(range[1]); + const end = Number(range[2]); + if ( + !Number.isSafeInteger(start) || + !Number.isSafeInteger(end) || + start > end || + end - start > 255 + ) + return undefined; + for (let value = start; value <= end; value += 1) values.push(value); + continue; + } + if (!/^\d+$/.test(token)) return undefined; + const value = Number(token); + if (!Number.isSafeInteger(value)) return undefined; + values.push(value); + } + return values.length > 0 && new Set(values).size === values.length ? values : undefined; +} + +function parseFibocom(input: RuntimeCompositionResponse): ParsedCapability | undefined { + const current = /^\s*\+GTUSBMODE:\s*(\d+)\s*$/m.exec(input.currentResponse); + const enumeration = /^\s*\+GTUSBMODE:\s*\(([^)]+)\)\s*$/m.exec(input.enumerationResponse); + if (current === null || enumeration === null) return undefined; + const enumerated = parseDecimalDomain(enumeration[1] ?? ''); + return enumerated === undefined ? undefined : { current: Number(current[1]), enumerated }; +} + +function parseQuectel(input: RuntimeCompositionResponse): ParsedCapability | undefined { + const current = /^\s*\+QCFG:\s*"usbnet"\s*,\s*(\d+)\s*$/m.exec(input.currentResponse); + const enumeration = /^\s*\+QCFG:\s*"usbnet"\s*,\s*\(([^)]+)\)\s*$/m.exec( + input.enumerationResponse, + ); + if (current === null || enumeration === null) return undefined; + const enumerated = parseDecimalDomain(enumeration[1] ?? ''); + return enumerated === undefined ? undefined : { current: Number(current[1]), enumerated }; +} + +function parseSimcom(input: RuntimeCompositionResponse): ParsedCapability | undefined { + const current = /^\s*\+CUSBPIDSWITCH:\s*([0-9A-Fa-f]{4})\s*$/m.exec(input.currentResponse); + const enumeration = /\+CUSBPIDSWITCH:\s*\(([^)]+)\)\s*,\s*\(0-1\)\s*,\s*\(0-1\)/m.exec( + input.enumerationResponse, + ); + if (current === null || enumeration === null) return undefined; + const tokens = (enumeration[1] ?? '').split(',').map((token) => token.trim().toUpperCase()); + if (tokens.length === 0 || tokens.some((token) => !/^[0-9A-F]{4}$/.test(token))) return undefined; + if (new Set(tokens).size !== tokens.length) return undefined; + return { current: (current[1] ?? '').toUpperCase(), enumerated: tokens }; +} + +function parseSierra(input: RuntimeCompositionResponse): ParsedCapability | undefined { + const current = /^\s*!USBCOMP:\s*(\d+)(?:\s*,.*)?$/m.exec(input.currentResponse); + if (current === null) return undefined; + const enumerated = Array.from( + input.enumerationResponse.matchAll(/^\s*(\d+)\s*:\s*.+$/gm), + (match) => Number(match[1]), + ); + if (enumerated.length === 0 || new Set(enumerated).size !== enumerated.length) return undefined; + return { current: Number(current[1]), enumerated }; +} + +const PARSERS: Readonly< + Record< + RuntimeCompositionVendor, + (input: RuntimeCompositionResponse) => ParsedCapability | undefined + > +> = { + fibocom: parseFibocom, + quectel: parseQuectel, + simcom: parseSimcom, + sierra: parseSierra, +}; + +function isRuntimeCompositionVendor(vendor: string): vendor is RuntimeCompositionVendor { + return Object.hasOwn(RUNTIME_COMPOSITION_QUERY_REGISTRY, vendor); +} + +/** Derive controls exclusively from the device's current and enumerated response text. */ +export function resolveRuntimeCompositionCapability( + input: RuntimeCompositionResponse, +): RuntimeCompositionCapability { + const vendor = input.vendor.trim().toLowerCase(); + if (!isRuntimeCompositionVendor(vendor)) return UNKNOWN_VENDOR; + const parsed = PARSERS[vendor](input); + if (parsed === undefined) return MALFORMED_RESPONSE; + const returnPathProven = parsed.enumerated.includes(parsed.current); + return { + status: 'available', + current: parsed.current, + enumerated: parsed.enumerated, + returnPathProven, + offerable: returnPathProven ? parsed.enumerated : [], + }; +} From bbb4f19779fbf3e366fb141243b5a9153086869b Mon Sep 17 00:00:00 2001 From: Andres Cera Date: Sat, 22 Aug 2026 15:33:13 -0500 Subject: [PATCH 02/12] fix(zte): select the login encoding from device evidence and classify lockout before attempting --- AGENTS.md | 19 ++- README.md | 11 +- control/README.md | 9 +- control/scripts/mf79u-diagnose.sh | 43 ++++- .../src/providers/conformance-matrix.test.ts | 12 +- .../providers/conformance-transcripts.test.ts | 17 +- .../src/providers/zte-goform/provider.test.ts | 146 +++++++++++++---- control/src/providers/zte-goform/provider.ts | 38 ++--- control/src/providers/zte-goform/session.ts | 149 ++++++++++++++---- control/test-support/conformance/cases.ts | 18 +-- control/test-support/conformance/corpus.ts | 76 +++++---- control/test-support/conformance/index.ts | 4 +- docs/MF79U-DIAGNOSIS.md | 25 +-- docs/PROVIDER-MATCHING.md | 2 +- 14 files changed, 399 insertions(+), 170 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 1c728c7..5fd0efb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -157,7 +157,7 @@ separate evidence-backed work. provider simultaneously. Each provider suite runs with only itself in the registry, which cannot answer whether a Huawei dongle stays a Huawei dongle while a ZTE provider and a UFI provider are also asking. **20 cases** — 9 fleet profiles + 11 safety cases (ambiguous collision, -cross-profile refusal, 3 malformed, auth-expired, lockout-unknown, 2 unknown-firmware, +cross-profile refusal, 3 malformed, auth-expired, lockout, 2 unknown-firmware, wrong-interface, wrong-transport) — each registering all four providers, expecting the EXACT decision. Full behaviour: [`docs/PROVIDER-MATCHING.md`](docs/PROVIDER-MATCHING.md) § "The conformance matrix". @@ -1479,19 +1479,22 @@ Every HTTP request is interface-bound and redirect-disabled. Credentials, passwo ## ZTE GOFORM PROVIDER (TODO 25) -`control/src/providers/zte-goform/` owns two incompatible, exact replay-backed profiles: -`mf79u-legacy` uses `LOGIN` with a base64 password and browser-equivalent Origin/Referer; -`mf266-salted` uses `LOGIN_MULTI_USER`, `LD`, salted SHA-256, `stok`, `RD`, and derived `AD`. -The firmware-selected algorithm receives one bounded attempt and never falls through to the -other profile. Cookies and derivatives are memory-only and sanitized fixtures expose only +`control/src/providers/zte-goform/` owns three incompatible, exact replay-backed profiles: +`mf79u-legacy` uses `LOGIN` with a base64 password; `mf79u-ld-salted` uses the same bare +`LOGIN` with `SHA256(SHA256(password)+LD)` (the MF79U B03 dialect); and `mf266-salted` uses +`LOGIN_MULTI_USER` with the salted hash, `stok`, `RD`, and derived `AD`. A batched pre-auth +`multi_data` GET reads `LD`, remaining attempts, lock time, and both version fields. That +evidence selects the algorithm and refuses a positive lockout before any credential POST; +firmware text alone never selects an encoding. The selected algorithm receives one bounded +attempt and never falls through to another profile. Cookies and derivatives are memory-only and sanitized fixtures expose only redaction markers. Unknown firmware may match the ZTE response shape but receives only the `zte-unknown-read-only` operation surface. All ZTE operation surfaces are currently read-only; in particular `wifi.enabled` is absent until a safe write plus readback is captured. The bench-only harness `control/scripts/mf79u-diagnose.sh` requires `MF79U_BENCH_PASSWORD` and one redacted browser request-shape manifest. It performs at most -one request and emits only `auth-accepted`, `protocol-mismatch`, `auth-rejection`, or -`lockout-unknown`; see `docs/MF79U-DIAGNOSIS.md`. +one login request and emits only `auth-accepted`, `protocol-mismatch`, `auth-rejection`, or +`lockout`; see `docs/MF79U-DIAGNOSIS.md`. ## UFI / HIMI PROVIDER (TODO 26) — READ-ONLY, PLUS THE QUALCOMM PROHIBITION FENCES diff --git a/README.md b/README.md index c91f751..89ae3d1 100644 --- a/README.md +++ b/README.md @@ -81,10 +81,11 @@ AGPL-3.0 ## ZTE goform provider (Todo 25) -`control/src/providers/zte-goform/` keeps MF79U legacy and MF266 salted authentication in -separate evidence-selected profiles with one bounded attempt and an in-memory-only `stok` -session. Unknown ZTE firmware retains read-only telemetry; no ZTE profile exposes a Wi-Fi -write. The executable MF79U one-attempt diagnosis is documented in +`control/src/providers/zte-goform/` keeps MF79U base64, MF79U `LD`-salted-under-`LOGIN`, +and MF266 `LOGIN_MULTI_USER` authentication in separate evidence-selected profiles with +one bounded attempt and an in-memory-only `stok` session. A batched pre-auth probe refuses +known lockout before the credential POST. Unknown ZTE firmware retains read-only telemetry; +no ZTE profile exposes a Wi-Fi write. The executable MF79U one-attempt diagnosis is documented in [`docs/MF79U-DIAGNOSIS.md`](docs/MF79U-DIAGNOSIS.md). ## UFI / HIMI provider (Todo 26) @@ -123,7 +124,7 @@ preferred mode and the measurement-recency flag survive normalization. `control/src/providers/conformance-matrix.test.ts` registers all four providers at once and runs 20 cases — nine fleet profiles (MM-managed Quectel / SIMCom / FM350-on-USB-carrier, both HiLink firmwares, MF79U, MF266, both UFI USB ids) plus ambiguous-collision, cross-profile -refusal, malformed-response, auth-expired, lockout-unknown, unknown-firmware, wrong-interface +refusal, malformed-response, auth-expired, lockout, unknown-firmware, wrong-interface and wrong-transport cases — asserting the exact provider, profile, writability and evidence score each device is entitled to. A tie between two write-capable providers resolves read-only with both claimants in the evidence ledger and neither credential spent. Companion suites diff --git a/control/README.md b/control/README.md index 7c909f0..3f7cd6d 100644 --- a/control/README.md +++ b/control/README.md @@ -83,10 +83,11 @@ provider runtime. See [`../docs/HUAWEI-HILINK-PROVIDER.md`](../docs/HUAWEI-HILIN ### ZTE goform provider -`createZteGoformDefinition()` exposes the incompatible `mf79u-legacy` and -`mf266-salted` authentication profiles without fallback between them. MF79U sends one -browser-shaped form login with a base64 password; MF266 performs the `LD` challenge, -salted SHA-256 login, then derives `AD` from version data and `RD`. Session material stays +`createZteGoformDefinition()` exposes three incompatible authentication profiles without +fallback between them: MF79U legacy base64 under `LOGIN`, MF79U `LD`-salted SHA-256 under +the same bare `LOGIN`, and MF266 salted SHA-256 under `LOGIN_MULTI_USER`. One batched +pre-auth evidence GET selects the exact shape and refuses a reported lockout before any +credential POST. MF266 derives `AD` from the probed version data and `RD`. Session material stays in memory. Unknown ZTE firmware is fingerprinted into a read-only telemetry profile, and Wi-Fi writes are absent from every operation surface. See [`../docs/MF79U-DIAGNOSIS.md`](../docs/MF79U-DIAGNOSIS.md). diff --git a/control/scripts/mf79u-diagnose.sh b/control/scripts/mf79u-diagnose.sh index 66e4a91..0d8bdb9 100755 --- a/control/scripts/mf79u-diagnose.sh +++ b/control/scripts/mf79u-diagnose.sh @@ -17,7 +17,40 @@ headers_file="$(mktemp)" chmod 600 "$headers_file" trap 'rm -f "$headers_file"' EXIT -encoded_password="$(printf '%s' "$MF79U_BENCH_PASSWORD" | base64 | tr -d '\n')" +evidence_cmd='LD,psw_fail_num_str,login_lock_time,wa_inner_version,cr_version' +evidence_body="$(curl --silent --show-error --max-time 10 --interface "$interface_name" \ + --get \ + --data-urlencode 'isTest=false' \ + --data-urlencode "cmd=$evidence_cmd" \ + --data 'multi_data=1' \ + --header "Origin: $admin_url" \ + --header "Referer: $admin_url/index.html" \ + "$admin_url/goform/goform_get_cmd_process")" + +remaining_attempts="$(jq -er '.psw_fail_num_str | tonumber' <<<"$evidence_body")" || { + printf '%s\n' 'protocol-mismatch' + exit 2 +} +lock_time="$(jq -er '.login_lock_time | tonumber' <<<"$evidence_body")" || { + printf '%s\n' 'protocol-mismatch' + exit 2 +} +if (( lock_time > 0 || remaining_attempts <= 0 )); then + printf '%s\n' 'lockout' + exit 4 +fi + +ld="$(jq -er '.LD // empty' <<<"$evidence_body")" || true +wa_version="$(jq -er '.wa_inner_version // empty' <<<"$evidence_body")" || true +if [[ -n "$ld" && "$wa_version" == *MF79U* ]]; then + inner="$(printf '%s' "$MF79U_BENCH_PASSWORD" | sha256sum | cut -d' ' -f1 | tr '[:lower:]' '[:upper:]')" + encoded_password="$(printf '%s%s' "$inner" "$ld" | sha256sum | cut -d' ' -f1 | tr '[:lower:]' '[:upper:]')" +elif [[ -z "$ld" && "$wa_version" == *MF79U* ]]; then + encoded_password="$(printf '%s' "$MF79U_BENCH_PASSWORD" | base64 | tr -d '\n')" +else + printf '%s\n' 'protocol-mismatch' + exit 2 +fi request_body="goformId=LOGIN&isTest=false&password=$(printf '%s' "$encoded_password" | jq -sRr @uri)" response_body="$({ @@ -35,14 +68,14 @@ if [[ "$response_body" == *'"result":"0"'* ]] && grep -Eiq '^set-cookie:[[:space printf '%s\n' 'auth-accepted' exit 0 fi -if [[ "$response_body" == *'"result"'* ]]; then +if [[ "$response_body" == *'"result":"3"'* ]]; then printf '%s\n' 'auth-rejection' exit 3 fi -if [[ "$response_body" == *'"LD"'* ]] || [[ "$response_body" == *'LOGIN_MULTI_USER'* ]]; then +if [[ "$response_body" == *'"result":"1"'* ]] || [[ "$response_body" == *'"result"'* ]]; then printf '%s\n' 'protocol-mismatch' exit 2 fi -printf '%s\n' 'lockout-unknown' -exit 4 +printf '%s\n' 'protocol-mismatch' +exit 2 diff --git a/control/src/providers/conformance-matrix.test.ts b/control/src/providers/conformance-matrix.test.ts index cad72f7..cdc6ae1 100644 --- a/control/src/providers/conformance-matrix.test.ts +++ b/control/src/providers/conformance-matrix.test.ts @@ -29,6 +29,7 @@ import { type RecordedExchange, SANITIZED_SUBSCRIBER_IDENTIFIERS, writeMatrixArtifact, + ZTE_EVIDENCE_CMD, } from '../../test-support/conformance'; import { HILINK_PATHS } from './huawei-hilink/provider'; @@ -207,16 +208,19 @@ describe('bounded credential attempts', () => { expect(run.result.status).toBe('ambiguous'); }); - test('an MF79U lockout is refused on one attempt and never re-tried as another algorithm', () => { + test('an MF79U lockout is refused after one evidence GET and before any login POST', () => { // Given - const run = runOf('lockout-unknown/zte-mf79u'); + const run = runOf('lockout/zte-mf79u'); // When const posts = zteLogins(run); + const evidenceGets = run.transcripts.zte.filter( + (exchange) => exchange.method === 'GET' && exchange.query.cmd === ZTE_EVIDENCE_CMD, + ); // Then - expect(posts).toHaveLength(1); - expect(posts.map(goformId)).toEqual(['LOGIN']); + expect(posts).toEqual([]); + expect(evidenceGets).toHaveLength(1); }); test('MF266-shaped answers to an MF79U login never provoke a salted retry', () => { diff --git a/control/src/providers/conformance-transcripts.test.ts b/control/src/providers/conformance-transcripts.test.ts index 600f723..cff5d17 100644 --- a/control/src/providers/conformance-transcripts.test.ts +++ b/control/src/providers/conformance-transcripts.test.ts @@ -25,13 +25,13 @@ import { UFI_INTERFACE, ufiLoginPost, ufiReadPost, - ZTE_FINGERPRINT_CMD, + ZTE_EVIDENCE_CMD, ZTE_INTERFACE, ZTE_LEGACY_PASSWORD, + ZTE_MF79U_WA_VERSION, ZTE_SALTED_PASSWORD, ZTE_STOK, ZTE_TELEMETRY_CMD, - ZTE_VERSIONS_CMD, zteGet, ztePost, } from '../../test-support/conformance'; @@ -110,14 +110,14 @@ describe('Huawei HiLink per-firmware transcripts', () => { }); describe('ZTE goform per-firmware transcripts', () => { - test('MF79U legacy: one fingerprint GET, ONE form login with a base64 password, one telemetry GET', async () => { + test('MF79U B03: one batched evidence GET selects salted SHA-256 under bare LOGIN', async () => { // When const transcripts = await transcriptsOf('fleet/zte-mf79u'); // Then expect(transcripts.zte).toEqual([ - zteGet(ZTE_FINGERPRINT_CMD, ZTE_INTERFACE), - ztePost({ goformId: 'LOGIN', isTest: 'false', password: ZTE_LEGACY_PASSWORD }, ZTE_INTERFACE), + zteGet(ZTE_EVIDENCE_CMD, ZTE_INTERFACE, { multiData: true }), + ztePost({ goformId: 'LOGIN', isTest: 'false', password: ZTE_SALTED_PASSWORD }, ZTE_INTERFACE), zteGet(ZTE_TELEMETRY_CMD, ZTE_INTERFACE), ]); }); @@ -128,8 +128,7 @@ describe('ZTE goform per-firmware transcripts', () => { // Then expect(transcripts.zte).toEqual([ - zteGet(ZTE_FINGERPRINT_CMD, ZTE_INTERFACE), - zteGet('LD', ZTE_INTERFACE), + zteGet(ZTE_EVIDENCE_CMD, ZTE_INTERFACE, { multiData: true }), ztePost( { goformId: 'LOGIN_MULTI_USER', @@ -140,17 +139,17 @@ describe('ZTE goform per-firmware transcripts', () => { }, ZTE_INTERFACE, ), - zteGet(ZTE_VERSIONS_CMD, ZTE_INTERFACE, { cookie: ZTE_STOK, multiData: true }), zteGet('RD', ZTE_INTERFACE, { cookie: ZTE_STOK }), zteGet(ZTE_TELEMETRY_CMD, ZTE_INTERFACE), ]); }); - test('neither goform profile puts a password on the wire in the other one’s encoding', () => { + test('all three password shapes stay distinct and no wire value carries the raw password', () => { // Given / When / Then expect(ZTE_LEGACY_PASSWORD).not.toBe(ZTE_SALTED_PASSWORD); expect(ZTE_LEGACY_PASSWORD).not.toContain(CONFORMANCE_CREDENTIALS.password); expect(ZTE_SALTED_PASSWORD).toMatch(/^[0-9A-F]{64}$/); + expect(ZTE_MF79U_WA_VERSION).toBe('BD_XCBZHKMF79UV1.0.0B03'); }); }); diff --git a/control/src/providers/zte-goform/provider.test.ts b/control/src/providers/zte-goform/provider.test.ts index 8d4554a..701b232 100644 --- a/control/src/providers/zte-goform/provider.test.ts +++ b/control/src/providers/zte-goform/provider.test.ts @@ -4,6 +4,7 @@ import { createProviderMatcher } from '../matcher'; import { createProviderRegistry } from '../registry'; import { createZteGoformDefinition, + ZTE_EVIDENCE_CMD, ZTE_PATHS, ZTE_PROFILES, ZTE_UNKNOWN_PROFILE, @@ -51,21 +52,48 @@ const response = (body: string, headers?: Readonly>): Zte ...(headers === undefined ? {} : { headers }), }); +const evidence = (values: Readonly>): ZteHttpResponse => + response( + JSON.stringify({ + psw_fail_num_str: '5', + login_lock_time: '0', + wa_inner_version: 'BD_MF79UV1.0.0B04', + cr_version: 'CR_MF79UV1.0.0B04', + ...values, + }), + ); + +function profile(profileId: (typeof ZTE_PROFILES)[number]['id']) { + const selected = ZTE_PROFILES.find((candidate) => candidate.id === profileId); + if (selected === undefined) throw new Error(`missing ZTE profile fixture: ${profileId}`); + return selected; +} + describe('ZTE goform firmware replay profiles', () => { test('replays the exact MF79U legacy login request once', async () => { // Given - const h = replay([response('{"result":"0"}', { 'set-cookie': 'stok=legacy-token; Path=/' })]); - const profile = ZTE_PROFILES[0]; + const h = replay([ + evidence({}), + response('{"result":"0"}', { 'set-cookie': 'stok=legacy-token; Path=/' }), + ]); + const selectedProfile = profile('mf79u-legacy'); // When const result = await definition(h.transport).authenticatedProfile?.authenticate( - context(profile.id, profile.firmware), - [profile.id], + context(selectedProfile.id, selectedProfile.firmware), + [selectedProfile.id], ); // Then - expect(result).toEqual({ status: 'matched', profile: profile.id, detail: 'login-ok' }); + expect(result).toEqual({ status: 'matched', profile: selectedProfile.id, detail: 'login-ok' }); expect(h.calls).toEqual([ + { + method: 'GET', + url: `${ADMIN_URL}${ZTE_PATHS.get}?isTest=false&cmd=${encodeURIComponent(ZTE_EVIDENCE_CMD)}&multi_data=1`, + headers: [`Origin: ${ADMIN_URL}`, `Referer: ${ADMIN_URL}/index.html`], + interfaceName: 'eth9', + redirect: 'error', + }, { method: 'POST', url: `${ADMIN_URL}${ZTE_PATHS.set}`, @@ -82,6 +110,32 @@ describe('ZTE goform firmware replay profiles', () => { expect(h.remaining()).toBe(0); }); + test('selects the MF79U LD-salted encoding and posts the exact bare LOGIN body', async () => { + // Given + const ld = 'fixture-mf79u-ld'; + const password = createHash('sha256') + .update(`${createHash('sha256').update(PASSWORD).digest('hex').toUpperCase()}${ld}`) + .digest('hex') + .toUpperCase(); + const h = replay([ + evidence({ LD: ld, wa_inner_version: 'BD_XCBZHKMF79UV1.0.0B03' }), + response('{"result":"0"}', { 'set-cookie': 'stok=mf79u-salted-token; Path=/' }), + ]); + const selectedProfile = profile('mf79u-ld-salted'); + + // When + const result = await definition(h.transport).authenticatedProfile?.authenticate( + context(selectedProfile.id, selectedProfile.firmware), + [selectedProfile.id], + ); + + // Then + expect(result).toEqual({ status: 'matched', profile: selectedProfile.id, detail: 'login-ok' }); + expect(h.calls[1]?.body).toBe(`goformId=LOGIN&isTest=false&password=${password}`); + expect(h.calls.filter((call) => call.method === 'POST')).toHaveLength(1); + expect(h.remaining()).toBe(0); + }); + test('replays the MF266 LD login RD and AD derivation without persisting session material', async () => { // Given const ld = 'fixture-ld'; @@ -99,27 +153,26 @@ describe('ZTE goform firmware replay profiles', () => { .digest('hex') .toUpperCase(); const h = replay([ - response(`{"LD":"${ld}"}`), + evidence({ LD: ld, wa_inner_version: waVersion, cr_version: crVersion }), response('{"result":"0"}', { 'set-cookie': 'stok=salted-token; Path=/' }), - response(`{"wa_inner_version":"${waVersion}","cr_version":"${crVersion}"}`), response(`{"RD":"${rd}"}`), ]); - const profile = ZTE_PROFILES[1]; + const selectedProfile = profile('mf266-salted'); // When const result = await definition(h.transport).authenticatedProfile?.authenticate( - context(profile.id, profile.firmware), - [profile.id], + context(selectedProfile.id, selectedProfile.firmware), + [selectedProfile.id], ); // Then - expect(result).toEqual({ status: 'matched', profile: profile.id, detail: 'login-ok' }); + expect(result).toEqual({ status: 'matched', profile: selectedProfile.id, detail: 'login-ok' }); expect( h.calls.map(({ method, url, body, headers }) => ({ method, url, body, headers })), ).toEqual([ { method: 'GET', - url: `${ADMIN_URL}${ZTE_PATHS.get}?isTest=false&cmd=LD`, + url: `${ADMIN_URL}${ZTE_PATHS.get}?isTest=false&cmd=${encodeURIComponent(ZTE_EVIDENCE_CMD)}&multi_data=1`, body: undefined, headers: [`Origin: ${ADMIN_URL}`, `Referer: ${ADMIN_URL}/index.html`], }, @@ -133,16 +186,6 @@ describe('ZTE goform firmware replay profiles', () => { `Referer: ${ADMIN_URL}/index.html`, ], }, - { - method: 'GET', - url: `${ADMIN_URL}${ZTE_PATHS.get}?isTest=false&cmd=wa_inner_version%2Ccr_version&multi_data=1`, - body: undefined, - headers: [ - `Cookie: stok=salted-token`, - `Origin: ${ADMIN_URL}`, - `Referer: ${ADMIN_URL}/index.html`, - ], - }, { method: 'GET', url: `${ADMIN_URL}${ZTE_PATHS.get}?isTest=false&cmd=RD`, @@ -154,7 +197,10 @@ describe('ZTE goform firmware replay profiles', () => { ], }, ]); - expect(definition(h.transport).contractFixtures[1]?.response).toEqual({ + expect( + definition(h.transport).contractFixtures.find((fixture) => fixture.profile === 'mf266-salted') + ?.response, + ).toEqual({ status: 'matched', sessionMaterial: '[redacted]', ad: '[redacted]', @@ -163,20 +209,62 @@ describe('ZTE goform firmware replay profiles', () => { expect(h.remaining()).toBe(0); }); - test('refuses MF266 responses for MF79U without trying a second algorithm', async () => { + test('refuses a positive lockout probe before issuing any login POST', async () => { // Given - const h = replay([response('{"LD":"salted-shape"}')]); - const profile = ZTE_PROFILES[0]; + const h = replay([ + evidence({ + LD: 'fixture-ld', + wa_inner_version: 'BD_XCBZHKMF79UV1.0.0B03', + psw_fail_num_str: '0', + login_lock_time: '180', + }), + ]); + const selectedProfile = profile('mf79u-ld-salted'); // When const result = await definition(h.transport).authenticatedProfile?.authenticate( - context(profile.id, profile.firmware), - [profile.id], + context(selectedProfile.id, selectedProfile.firmware), + [selectedProfile.id], ); // Then - expect(result).toEqual({ status: 'refused', detail: 'protocol-mismatch' }); + expect(result).toEqual({ status: 'refused', detail: 'lockout' }); expect(h.calls).toHaveLength(1); + expect(h.calls[0]?.method).toBe('GET'); + expect(h.calls[0]?.url).toContain(`cmd=${encodeURIComponent(ZTE_EVIDENCE_CMD)}`); + expect(h.calls.filter((call) => call.method === 'POST')).toEqual([]); + }); + + test('classifies login result 3 as auth-rejection', async () => { + // Given + const h = replay([evidence({}), response('{"result":"3"}')]); + const selectedProfile = profile('mf79u-legacy'); + + // When + const result = await definition(h.transport).authenticatedProfile?.authenticate( + context(selectedProfile.id, selectedProfile.firmware), + [selectedProfile.id], + ); + + // Then + expect(result).toEqual({ status: 'refused', detail: 'auth-rejection' }); + expect(h.calls.filter((call) => call.method === 'POST')).toHaveLength(1); + }); + + test('classifies login result 1 as protocol-mismatch without cycling encodings', async () => { + // Given + const h = replay([evidence({}), response('{"result":"1"}')]); + const selectedProfile = profile('mf79u-legacy'); + + // When + const result = await definition(h.transport).authenticatedProfile?.authenticate( + context(selectedProfile.id, selectedProfile.firmware), + [selectedProfile.id], + ); + + // Then + expect(result).toEqual({ status: 'refused', detail: 'protocol-mismatch' }); + expect(h.calls.filter((call) => call.method === 'POST')).toHaveLength(1); }); test('selects unknown ZTE firmware as read-only without authenticating', async () => { diff --git a/control/src/providers/zte-goform/provider.ts b/control/src/providers/zte-goform/provider.ts index ef9e71a..f1522e1 100644 --- a/control/src/providers/zte-goform/provider.ts +++ b/control/src/providers/zte-goform/provider.ts @@ -15,16 +15,18 @@ export const ZTE_PATHS = { get: '/goform/goform_get_cmd_process', set: '/goform/goform_set_cmd_process', } as const; +export const ZTE_EVIDENCE_CMD = 'LD,psw_fail_num_str,login_lock_time,wa_inner_version,cr_version'; export type ZteProfile = { - readonly id: 'mf79u-legacy' | 'mf266-salted'; + readonly id: 'mf79u-legacy' | 'mf79u-ld-salted' | 'mf266-salted'; readonly firmware: 'MF79U' | 'MF266'; - readonly algorithm: 'legacy-base64' | 'salted-sha256'; + readonly algorithm: 'legacy-base64' | 'login-salted-sha256' | 'multi-user-salted-sha256'; }; export const ZTE_PROFILES = [ { id: 'mf79u-legacy', firmware: 'MF79U', algorithm: 'legacy-base64' }, - { id: 'mf266-salted', firmware: 'MF266', algorithm: 'salted-sha256' }, + { id: 'mf79u-ld-salted', firmware: 'MF79U', algorithm: 'login-salted-sha256' }, + { id: 'mf266-salted', firmware: 'MF266', algorithm: 'multi-user-salted-sha256' }, ] as const satisfies readonly ZteProfile[]; export const ZTE_UNKNOWN_PROFILE = 'zte-unknown-read-only'; @@ -39,8 +41,8 @@ export type ZteOptions = { type ZteOperations = ProviderOperationsSurface; -export function zteProfileForFirmware(firmware: string | undefined): ZteProfile | undefined { - return ZTE_PROFILES.find((profile) => profile.firmware === firmware); +export function zteProfilesForFirmware(firmware: string | undefined): readonly ZteProfile[] { + return ZTE_PROFILES.filter((profile) => profile.firmware === firmware); } export function zteProfileById(profileId: string): ZteProfile | undefined { @@ -49,14 +51,12 @@ export function zteProfileById(profileId: string): ZteProfile | undefined { export class ZteGoformProvider extends ZteSessionRuntime { async fingerprint(request: ProviderMatchRequest) { - const response = await this.get('cr_version,network_type'); - const record = response.status === 200 ? parseZteRecord(response.body) : undefined; - const profile = zteProfileForFirmware(request.firmware); - const matches = record !== undefined; + const evidence = await this.probeEvidence(request); + const matches = evidence !== undefined; return { signal: matches ? ('match' as const) : ('unknown' as const), strength: 'strong' as const, - profiles: matches ? [profile?.id ?? ZTE_UNKNOWN_PROFILE] : [], + profiles: matches ? [evidence.profile?.id ?? ZTE_UNKNOWN_PROFILE] : [], detail: matches ? 'zte-goform-shape' : 'zte-goform-not-proven', }; } @@ -108,13 +108,13 @@ export function createZteGoformDefinition( const runtime = new ZteGoformProvider(options); return { id: 'zte-goform', - profileVersion: '1', + profileVersion: '2', eligibleTransports: ['network'], - passiveMatchers: ZTE_PROFILES.map((profile) => ({ - id: `firmware-${profile.firmware}`, - fact: 'firmware', - expected: [profile.firmware], - profiles: [profile.id], + passiveMatchers: ['MF79U', 'MF266'].map((firmware) => ({ + id: `firmware-${firmware}`, + fact: 'firmware' as const, + expected: [firmware], + profiles: zteProfilesForFirmware(firmware).map((profile) => profile.id), strength: 'strong', required: true, })), @@ -122,7 +122,7 @@ export function createZteGoformDefinition( { id: 'zte-goform-shape', run: (request) => runtime.fingerprint(request) }, ], authenticatedProfile: { - algorithm: 'firmware-selected-zte-goform', + algorithm: 'evidence-selected-zte-goform', attemptLimit: 1, authenticate: (request, candidates) => runtime.authenticateProfile(request, candidates), }, @@ -134,12 +134,12 @@ export function createZteGoformDefinition( request: { method: 'POST', path: ZTE_PATHS.set, - goformId: profile.algorithm === 'legacy-base64' ? 'LOGIN' : 'LOGIN_MULTI_USER', + goformId: profile.algorithm === 'multi-user-salted-sha256' ? 'LOGIN_MULTI_USER' : 'LOGIN', interfaceBound: true, redirects: 'disabled', }, response: - profile.algorithm === 'salted-sha256' + profile.algorithm === 'multi-user-salted-sha256' ? { status: 'matched', sessionMaterial: '[redacted]', ad: '[redacted]' } : { status: 'matched', sessionMaterial: '[redacted]' }, })), diff --git a/control/src/providers/zte-goform/session.ts b/control/src/providers/zte-goform/session.ts index 54297e4..dbb665e 100644 --- a/control/src/providers/zte-goform/session.ts +++ b/control/src/providers/zte-goform/session.ts @@ -2,16 +2,24 @@ import { createHash } from 'node:crypto'; import { z } from 'zod'; import type { AuthenticatedProfileResult, ProviderMatchRequest } from '../contracts'; import { + ZTE_EVIDENCE_CMD, ZTE_PATHS, ZTE_UNKNOWN_PROFILE, type ZteOptions, type ZteProfile, zteProfileById, - zteProfileForFirmware, + zteProfilesForFirmware, } from './provider'; import type { ZteHttpResponse } from './transport'; type ZteSession = { readonly cookie: string; readonly ad?: string }; +type ZteEvidence = { + readonly record: Readonly>; + readonly profile: ZteProfile | undefined; +}; +type ZteLoginResult = + | { readonly status: 'accepted'; readonly cookie: string } + | { readonly status: 'refused'; readonly detail: 'auth-rejection' | 'protocol-mismatch' }; const flatRecordSchema = z.record(z.string(), z.union([z.string(), z.number()])); export function parseZteRecord( @@ -46,8 +54,52 @@ function sha256(value: string): string { return createHash('sha256').update(value).digest('hex').toUpperCase(); } +function evidenceNumber(value: string | number | undefined): number | undefined { + if (typeof value === 'string' && value.trim() === '') return undefined; + const parsed = typeof value === 'number' ? value : Number(value); + return Number.isInteger(parsed) && parsed >= 0 ? parsed : undefined; +} + +function profileFromEvidence( + firmware: string | undefined, + record: Readonly>, +): ZteProfile | undefined { + const versionEvidence = + `${record.wa_inner_version ?? ''} ${record.cr_version ?? ''}`.toUpperCase(); + const family = versionEvidence.includes('MF266') + ? 'MF266' + : versionEvidence.includes('MF79U') + ? 'MF79U' + : firmware; + const hasLd = typeof record.LD === 'string' && record.LD.length > 0; + const profileId = + family === 'MF266' + ? hasLd + ? 'mf266-salted' + : undefined + : family === 'MF79U' + ? hasLd + ? 'mf79u-ld-salted' + : 'mf79u-legacy' + : undefined; + return zteProfilesForFirmware(family).find((profile) => profile.id === profileId); +} + +function classifyLogin(response: ZteHttpResponse): ZteLoginResult { + const result = parseZteRecord(response.body)?.result; + if (response.status !== 200 || result === '1') { + return { status: 'refused', detail: 'protocol-mismatch' }; + } + if (result === '3') return { status: 'refused', detail: 'auth-rejection' }; + const cookie = stokCookie(response); + return result === '0' && cookie !== undefined + ? { status: 'accepted', cookie } + : { status: 'refused', detail: 'protocol-mismatch' }; +} + export class ZteSessionRuntime { readonly #sessions = new Map(); + readonly #evidence = new Map(); constructor(protected readonly options: ZteOptions) {} @@ -60,12 +112,55 @@ export class ZteSessionRuntime { return { status: 'matched', profile: ZTE_UNKNOWN_PROFILE, detail: 'read-only-fingerprint' }; } const profile = zteProfileById(candidate ?? ''); - if (profile === undefined || zteProfileForFirmware(request.firmware)?.id !== profile.id) { + if (profile === undefined) { return { status: 'refused', detail: 'profile-mismatch' }; } - return profile.algorithm === 'legacy-base64' - ? this.loginLegacy(request, profile) - : this.loginSalted(request, profile); + const evidence = + this.#evidence.get(this.sessionKey(request)) ?? (await this.probeEvidence(request)); + if (evidence === undefined) return { status: 'refused', detail: 'protocol-mismatch' }; + const remainingAttempts = evidenceNumber(evidence.record.psw_fail_num_str); + const lockTime = evidenceNumber(evidence.record.login_lock_time); + if (remainingAttempts === undefined || lockTime === undefined) { + return { status: 'refused', detail: 'protocol-mismatch' }; + } + if (lockTime > 0 || remainingAttempts === 0) { + return { status: 'refused', detail: 'lockout' }; + } + if (evidence.profile?.id !== profile.id) { + return { status: 'refused', detail: 'protocol-mismatch' }; + } + switch (profile.algorithm) { + case 'legacy-base64': + return this.login( + request, + profile, + Buffer.from(this.options.credentials.password).toString('base64'), + ); + case 'login-salted-sha256': { + const ld = evidence.record.LD; + if (typeof ld !== 'string') return { status: 'refused', detail: 'protocol-mismatch' }; + return this.login( + request, + profile, + sha256(`${sha256(this.options.credentials.password)}${ld}`), + ); + } + case 'multi-user-salted-sha256': + return this.loginSalted(request, profile, evidence.record); + default: { + const exhaustive: never = profile.algorithm; + return exhaustive; + } + } + } + + protected async probeEvidence(request: ProviderMatchRequest): Promise { + const response = await this.get(ZTE_EVIDENCE_CMD, undefined, true); + const record = response.status === 200 ? parseZteRecord(response.body) : undefined; + if (record === undefined) return undefined; + const evidence = { record, profile: profileFromEvidence(request.firmware, record) }; + this.#evidence.set(this.sessionKey(request), evidence); + return evidence; } protected sessionCookie(request: ProviderMatchRequest): string | undefined { @@ -78,36 +173,31 @@ export class ZteSessionRuntime { return this.request('GET', `${ZTE_PATHS.get}?${query.toString()}`, undefined, cookie); } - private async loginLegacy( + private async login( request: ProviderMatchRequest, profile: ZteProfile, + password: string, ): Promise { const response = await this.post( new URLSearchParams({ goformId: 'LOGIN', isTest: 'false', - password: Buffer.from(this.options.credentials.password).toString('base64'), + password, }).toString(), ); - const record = parseZteRecord(response.body); - const cookie = stokCookie(response); - if (response.status !== 200 || record?.result !== '0' || cookie === undefined) { - return { - status: 'refused', - detail: record?.LD === undefined ? 'auth-rejection' : 'protocol-mismatch', - }; - } - this.#sessions.set(this.sessionKey(request), { cookie }); + const result = classifyLogin(response); + if (result.status === 'refused') return result; + this.#sessions.set(this.sessionKey(request), { cookie: result.cookie }); return { status: 'matched', profile: profile.id, detail: 'login-ok' }; } private async loginSalted( request: ProviderMatchRequest, profile: ZteProfile, + evidence: Readonly>, ): Promise { - const ldResponse = await this.get('LD'); - const ld = parseZteRecord(ldResponse.body)?.LD; - if (ldResponse.status !== 200 || typeof ld !== 'string') { + const ld = evidence.LD; + if (typeof ld !== 'string') { return { status: 'refused', detail: 'protocol-mismatch' }; } const login = await this.post( @@ -119,25 +209,16 @@ export class ZteSessionRuntime { user: this.options.credentials.username, }).toString(), ); - const cookie = stokCookie(login); - if ( - login.status !== 200 || - parseZteRecord(login.body)?.result !== '0' || - cookie === undefined - ) { - return { status: 'refused', detail: 'auth-rejection' }; - } - const versions = parseZteRecord( - (await this.get('wa_inner_version,cr_version', cookie, true)).body, - ); - const rd = parseZteRecord((await this.get('RD', cookie)).body)?.RD; - const waVersion = versions?.wa_inner_version; - const crVersion = versions?.cr_version; + const result = classifyLogin(login); + if (result.status === 'refused') return result; + const rd = parseZteRecord((await this.get('RD', result.cookie)).body)?.RD; + const waVersion = evidence.wa_inner_version; + const crVersion = evidence.cr_version; if (typeof waVersion !== 'string' || typeof crVersion !== 'string' || typeof rd !== 'string') { return { status: 'refused', detail: 'protocol-mismatch' }; } this.#sessions.set(this.sessionKey(request), { - cookie, + cookie: result.cookie, ad: sha256(`${sha256(`${waVersion}${crVersion}`)}${rd}`), }); return { status: 'matched', profile: profile.id, detail: 'login-ok' }; diff --git a/control/test-support/conformance/cases.ts b/control/test-support/conformance/cases.ts index ad7d1ee..ae54970 100644 --- a/control/test-support/conformance/cases.ts +++ b/control/test-support/conformance/cases.ts @@ -253,7 +253,7 @@ export type ConformanceKind = | 'ambiguity' | 'malformed' | 'auth-expired' - | 'lockout-unknown' + | 'lockout' | 'unknown-firmware' | 'wrong-interface' | 'wrong-transport'; @@ -373,8 +373,8 @@ export const CONFORMANCE_CASES: readonly ConformanceCase[] = [ { id: 'fleet/zte-mf79u', kind: 'fleet-profile', - summary: 'MF79U selects the legacy base64 profile and stays read-only', - expected: selected('zte-goform', 'mf79u-legacy', false), + summary: 'MF79U B03 evidence selects salted SHA-256 under bare LOGIN and stays read-only', + expected: selected('zte-goform', 'mf79u-ld-salted', false), run: () => runScenario( { zte: zteDevice({ firmware: 'MF79U' }) }, @@ -479,8 +479,8 @@ export const CONFORMANCE_CASES: readonly ConformanceCase[] = [ { id: 'malformed/zte-goform-body', kind: 'malformed', - summary: 'garbage goform bodies keep the firmware-selected profile but record the conflict', - expected: selected('zte-goform', 'mf79u-legacy', false), + summary: 'garbage goform evidence refuses to guess between the two MF79U encodings', + expected: unresolved('ambiguous', 'supported'), run: () => runScenario( { @@ -527,13 +527,13 @@ export const CONFORMANCE_CASES: readonly ConformanceCase[] = [ ), }, { - id: 'lockout-unknown/zte-mf79u', - kind: 'lockout-unknown', - summary: 'a locked-out MF79U is refused identically to a rejection — one attempt, no guess', + id: 'lockout/zte-mf79u', + kind: 'lockout', + summary: 'a positive MF79U lockout probe refuses before any credential attempt', expected: unresolved('ambiguous', 'supported'), run: () => runScenario( - { zte: zteDevice({ firmware: 'MF79U', login: 'lockout-unknown' }) }, + { zte: zteDevice({ firmware: 'MF79U', lockout: true }) }, request({ id: 'serial:conformance-zte-lockout', facts: [usbFact(USB_IDS.zteMf79u), firmwareFact('MF79U')], diff --git a/control/test-support/conformance/corpus.ts b/control/test-support/conformance/corpus.ts index f9c0dc1..c0fd745 100644 --- a/control/test-support/conformance/corpus.ts +++ b/control/test-support/conformance/corpus.ts @@ -235,6 +235,7 @@ export const ZTE_RD = 'conformance-rd'; export const ZTE_STOK = 'stok=conformance-stok'; export const ZTE_WA_VERSION = 'BD_MF266V1.0.0B01'; export const ZTE_CR_VERSION = 'CR_MF266V1.0.0B01'; +export const ZTE_MF79U_WA_VERSION = 'BD_XCBZHKMF79UV1.0.0B03'; const sha256Upper = (value: string): string => createHash('sha256').update(value).digest('hex').toUpperCase(); @@ -246,41 +247,58 @@ export const ZTE_SALTED_PASSWORD = sha256Upper( /** The legacy MF79U password: base64, form-encoded. */ export const ZTE_LEGACY_PASSWORD = Buffer.from(CONFORMANCE_CREDENTIALS.password).toString('base64'); -export const ZTE_FINGERPRINT_BODIES = { - MF79U: JSON.stringify({ cr_version: 'CR_MF79UV1.0.0B04', network_type: 'LTE' }), - MF266: JSON.stringify({ cr_version: ZTE_CR_VERSION, network_type: 'LTE' }), - unknown: JSON.stringify({ cr_version: 'CR_ZTEUNKNOWNV9.9.9', network_type: 'LTE' }), +const ZTE_EVIDENCE_RECORDS = { + MF79U: { + LD: ZTE_LD, + psw_fail_num_str: '5', + login_lock_time: '0', + wa_inner_version: ZTE_MF79U_WA_VERSION, + cr_version: 'CR_MF79UV1.0.0B03', + }, + MF266: { + LD: ZTE_LD, + psw_fail_num_str: '5', + login_lock_time: '0', + wa_inner_version: ZTE_WA_VERSION, + cr_version: ZTE_CR_VERSION, + }, + unknown: { + psw_fail_num_str: '5', + login_lock_time: '0', + wa_inner_version: 'BD_ZTEUNKNOWNV9.9.9', + cr_version: 'CR_ZTEUNKNOWNV9.9.9', + }, +} as const; + +export const ZTE_EVIDENCE_BODIES = { + MF79U: JSON.stringify(ZTE_EVIDENCE_RECORDS.MF79U), + MF266: JSON.stringify(ZTE_EVIDENCE_RECORDS.MF266), + unknown: JSON.stringify(ZTE_EVIDENCE_RECORDS.unknown), } as const; export const ZTE_LOGIN_ACCEPTED = JSON.stringify({ result: '0' }); export const ZTE_LOGIN_REJECTED = JSON.stringify({ result: '3' }); -/** - * The MF79U bench shape that CANNOT be classified from one response: a non-zero result - * with a lock countdown. Rejection and lockout are indistinguishable here on purpose — - * separating them is what `scripts/mf79u-diagnose.sh` exists for, and the matcher must - * refuse identically for both rather than guess. - */ -export const ZTE_LOGIN_LOCKOUT_UNKNOWN = JSON.stringify({ result: '1', lockedTime: '180' }); +export const ZTE_LOGIN_PROTOCOL_MISMATCH = JSON.stringify({ result: '1' }); /** An MF266-shaped challenge answered to a legacy `LOGIN` — the cross-profile trap. */ export const ZTE_LOGIN_SALTED_SHAPE = JSON.stringify({ LD: ZTE_LD }); export type ZteScript = { readonly firmware: 'MF79U' | 'MF266' | 'unknown'; readonly fingerprint?: 'reported' | 'malformed'; - readonly login?: 'ok' | 'rejected' | 'lockout-unknown' | 'salted-shape'; + readonly login?: 'ok' | 'rejected' | 'protocol-mismatch' | 'salted-shape'; + readonly lockout?: boolean; readonly telemetry?: 'reported' | 'malformed'; }; -export const ZTE_FINGERPRINT_CMD = 'cr_version,network_type'; +export const ZTE_EVIDENCE_CMD = 'LD,psw_fail_num_str,login_lock_time,wa_inner_version,cr_version'; export const ZTE_TELEMETRY_CMD = 'network_type,signalbar,rssi,lte_rsrp,lte_rsrq,lte_snr'; -export const ZTE_VERSIONS_CMD = 'wa_inner_version,cr_version'; export function zteDevice(script: ZteScript): ScriptedDevice { const loginBody = script.login === 'rejected' ? ZTE_LOGIN_REJECTED - : script.login === 'lockout-unknown' - ? ZTE_LOGIN_LOCKOUT_UNKNOWN + : script.login === 'protocol-mismatch' + ? ZTE_LOGIN_PROTOCOL_MISMATCH : script.login === 'salted-shape' ? ZTE_LOGIN_SALTED_SHAPE : ZTE_LOGIN_ACCEPTED; @@ -299,26 +317,22 @@ export function zteDevice(script: ZteScript): ScriptedDevice { } if (exchange.path !== ZTE_PATHS.get) return NOT_THIS_VENDOR; switch (exchange.query.cmd) { - case ZTE_FINGERPRINT_CMD: + case ZTE_EVIDENCE_CMD: return { status: 200, body: script.fingerprint === 'malformed' ? ZTE_MALFORMED_FIXTURE.body - : ZTE_FINGERPRINT_BODIES[script.firmware], + : script.lockout === true + ? JSON.stringify({ + ...ZTE_EVIDENCE_RECORDS[script.firmware], + psw_fail_num_str: '0', + login_lock_time: '180', + }) + : ZTE_EVIDENCE_BODIES[script.firmware], }; - case 'LD': - return { status: 200, body: JSON.stringify({ LD: ZTE_LD }) }; case 'RD': return { status: 200, body: JSON.stringify({ RD: ZTE_RD }) }; - case ZTE_VERSIONS_CMD: - return { - status: 200, - body: JSON.stringify({ - wa_inner_version: ZTE_WA_VERSION, - cr_version: ZTE_CR_VERSION, - }), - }; case ZTE_TELEMETRY_CMD: return { status: 200, @@ -404,16 +418,14 @@ export const CORPUS_BODIES: readonly string[] = [ HILINK_AUTH_EXPIRED_FIXTURE.status, HILINK_AUTH_EXPIRED_FIXTURE.signal, HILINK_AUTH_EXPIRED_FIXTURE.netModeList ?? '', - ...Object.values(ZTE_FINGERPRINT_BODIES), + ...Object.values(ZTE_EVIDENCE_BODIES), ZTE_LOGIN_ACCEPTED, ZTE_LOGIN_REJECTED, - ZTE_LOGIN_LOCKOUT_UNKNOWN, + ZTE_LOGIN_PROTOCOL_MISMATCH, ZTE_LOGIN_SALTED_SHAPE, ZTE_FIXTURE.body, ZTE_MALFORMED_FIXTURE.body, - JSON.stringify({ LD: ZTE_LD }), JSON.stringify({ RD: ZTE_RD }), - JSON.stringify({ wa_inner_version: ZTE_WA_VERSION, cr_version: ZTE_CR_VERSION }), UFI_LOGIN_OK, UFI_LOGIN_REJECTED, UFI_MALFORMED, diff --git a/control/test-support/conformance/index.ts b/control/test-support/conformance/index.ts index fca53fc..67a6e01 100644 --- a/control/test-support/conformance/index.ts +++ b/control/test-support/conformance/index.ts @@ -41,14 +41,14 @@ export { USB_IDS, ufiDevice, ZTE_ADMIN_URL, - ZTE_FINGERPRINT_CMD, + ZTE_EVIDENCE_CMD, ZTE_INTERFACE, ZTE_LD, ZTE_LEGACY_PASSWORD, + ZTE_MF79U_WA_VERSION, ZTE_SALTED_PASSWORD, ZTE_STOK, ZTE_TELEMETRY_CMD, - ZTE_VERSIONS_CMD, type ZteScript, zteDevice, } from './corpus'; diff --git a/docs/MF79U-DIAGNOSIS.md b/docs/MF79U-DIAGNOSIS.md index e5c2e98..d969a13 100644 --- a/docs/MF79U-DIAGNOSIS.md +++ b/docs/MF79U-DIAGNOSIS.md @@ -1,7 +1,9 @@ # MF79U authentication diagnosis `[PARTIAL]` -This bench-only procedure distinguishes an MF79U legacy protocol mismatch from a rejected -credential or an unknown lockout state. It performs **at most one** login request. It never +This bench-only procedure distinguishes the two MF79U login encodings from a rejected +credential or a lockout. It performs **at most one** login request. Before that attempt it +reads `LD`, `psw_fail_num_str`, `login_lock_time`, `wa_inner_version`, and `cr_version` in +one batched `multi_data` GET. It never retries, never tries the MF266 algorithm, and never writes Wi-Fi or modem configuration. ## Preconditions @@ -22,7 +24,7 @@ retries, never tries the MF266 algorithm, and never writes Wi-Fi or modem config FORM password ``` -- Install `curl`, `jq`, and GNU `base64`. Do not enable shell tracing. +- Install `curl`, `jq`, GNU `base64`, `sha256sum`, and `cut`. Do not enable shell tracing. ## One-attempt command @@ -43,13 +45,18 @@ unset MF79U_BENCH_PASSWORD The output is exactly one classification and contains no response body, cookie, password, or derivative: -- `auth-accepted` — the legacy request returned success and a `stok` cookie. -- `protocol-mismatch` — the redacted browser shape differs, or the reply has MF266 challenge - markers. Stop; do not try another algorithm. -- `auth-rejection` — the device returned a defined negative result. Confirm the credential +- `auth-accepted` — the evidence-selected request returned result `"0"` and a `stok` cookie. +- `protocol-mismatch` — the evidence is incomplete, names another device family, the login + shape is unrecognized, or the login returns result `"1"`. Stop; do not try another algorithm. +- `auth-rejection` — the device returned result `"3"`. Confirm the credential out of band before any later attempt. -- `lockout-unknown` — the response cannot distinguish a lockout from another refusal. Stop - all authentication attempts and inspect the device UI manually. +- `lockout` — `login_lock_time` is positive or `psw_fail_num_str` reports no remaining + attempts. No login POST was sent; inspect the device UI and wait for or clear the lockout. + +When `LD` is absent on MF79U firmware, the password field is base64. When `LD` is present +on MF79U firmware — including `BD_XCBZHKMF79UV1.0.0B03` — the same bare `LOGIN` form carries +`SHA256(SHA256(password)+LD)`. That is distinct from MF266's `LOGIN_MULTI_USER` form even +though both use the same nested hash. Only the classification file may be retained. The redacted browser shape and classification remain under gitignored `test-results/`; never retain the temporary password or a raw capture. diff --git a/docs/PROVIDER-MATCHING.md b/docs/PROVIDER-MATCHING.md index ce83569..76a1b57 100644 --- a/docs/PROVIDER-MATCHING.md +++ b/docs/PROVIDER-MATCHING.md @@ -63,7 +63,7 @@ exactly the provider and profile it is entitled to — and does no device reach `control/src/providers/conformance-matrix.test.ts` is that matrix. **20 cases**: nine fleet profiles (MM-managed Quectel / SIMCom / FM350-on-USB-carrier, both HiLink firmwares, MF79U, MF266, and both UFI USB ids) plus eleven safety cases — ambiguous collision, cross-profile -refusal, three malformed-response cases, auth-expired, lockout-unknown, two unknown-firmware +refusal, three malformed-response cases, auth-expired, lockout, two unknown-firmware cases, wrong-interface and wrong-transport. Every case registers all four providers and scripts the three the device does not belong to as devices that answer nothing they understand. The expectation is the exact decision — provider, profile, writability and evidence score. From f97e9479f226859a1ab67f3d7980c5c896619bc0 Mon Sep 17 00:00:00 2001 From: Andres Cera Date: Sat, 22 Aug 2026 15:49:51 -0500 Subject: [PATCH 03/12] feat(usb-mode): gate composition switching on runtime capability instead of a model catalog --- AGENTS.md | 29 ++ README.md | 7 + control/README.md | 15 + control/src/backend/at-lease.test.ts | 22 +- control/src/backend/at-lease.ts | 18 +- .../src/backend/transition-preconditions.ts | 155 ++++++++- .../src/backend/usb-mode-transition.test.ts | 89 ++++- control/src/backend/usb-mode-transition.ts | 73 ++-- control/src/operation-ids.ts | 1 + .../modem-manager/generic-operations.ts | 13 +- control/src/providers/modem-manager/index.ts | 1 + .../src/providers/modem-manager/provider.ts | 5 + .../runtime-composition-operation.test.ts | 315 ++++++++++++++++++ .../runtime-composition-operation.ts | 238 +++++++++++++ control/src/providers/modem-manager/types.ts | 2 + control/src/usb-mode/index.ts | 5 + .../src/usb-mode/runtime-capability.test.ts | 29 +- control/src/usb-mode/runtime-capability.ts | 59 +++- docs/CATALOG-INGESTION.md | 16 + 19 files changed, 1032 insertions(+), 60 deletions(-) create mode 100644 control/src/providers/modem-manager/runtime-composition-operation.test.ts create mode 100644 control/src/providers/modem-manager/runtime-composition-operation.ts diff --git a/AGENTS.md b/AGENTS.md index 5fd0efb..79820b8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -516,6 +516,35 @@ transform in `control/src/usb-mode/{ingestion,promotion-review,usb-devices-parse hub VBUS port-cycle verification backing the PowerHook above, RB-11..15/17 are the per-SKU/flap-resilience captures documented above, RB-16 is the FM350 probe. +## USB-COMPOSITION SWITCH — RUNTIME OFFER, TIERED PROOF + +The ModemManager provider's `usbComposition` operation derives its targets from the +device's own vendor READ + TEST replies through `resolveRuntimeCompositionCapability`. +It never turns a model-catalog miss into `uncertified`. The only operator-facing +suppressions are the exact literals `unknown-vendor`, `no-return-path`, +`blocked-by-state`, and `provisioning-disabled`; every suppressed state carries zero +offerable targets. An unknown vendor, a disabled provisioning setting, or a live-state +block is decided before any AT transport call. A known device is offered only when its +enumeration contains its current mode, proving that the represented vocabulary includes +a return path. + +The AT fence widened by name, not by pattern. `AT_RUNTIME_QUERY_ALLOWLIST` contains only +the four vendors' exact READ/TEST forms. `RUNTIME_COMPOSITION_SET_REGISTRY` builds one +validated SET form per vendor, and only the selected enumerated target is unioned into +the held lease. Capability reads send no SET form. The operation descriptor keeps the +shared mutation admission lease, durable journal, armed rollback, and required readback; +the existing transition still keeps fail-closed identity, the streaming interlock, MM +inhibit/uninhibit, and the bounded drop/re-enumeration wait. + +Success has two proof tiers. **Tier 1 remains strongest and unchanged:** when a reviewed +catalog transition matches the exact SET command, the re-enumerated canonical mode and +USB descriptors must both match its `expectedDescriptors`. **Tier 2 is explicitly +weaker:** when no reviewed transition exists, the re-enumerated device must report the +raw target through its own vendor READ. AT `OK` is proof in neither tier. The catalog +therefore remains valuable evidence without being a model allowlist for an interrogable +device. This change is composition-only; band writes retain their separate four-proof +certification gate. + ## CAPABILITY MODULES — TAXONOMY AND DETECTION, NOT IMPLEMENTATION `control/src/capability/` carries the FIVE-STATE support-claim taxonomy and the diff --git a/README.md b/README.md index 89ae3d1..c848d6d 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,13 @@ helpers for portable modem identity, display naming, ModemManager enums, USB-net classification, capability selection, and shadow-result comparison; these helpers perform no discovery or transport and leave CeraUI integration to a separate cutover. +The ModemManager operation surface also exposes runtime USB-composition capability. Known +vendors are queried with exact reviewed READ/TEST forms, targets come from the device's own +enumeration only when it includes a return path, and writes retain the shared admission, +journal, rollback, readback, identity, and streaming-interlock fences. Reviewed catalog +descriptors remain the strongest success proof; otherwise a weaker post-switch device READ +must report the target. Band certification remains catalog-gated and unchanged. + ## Versioning at a glance ONE unified **SemVer** tag `vX.Y.Z` releases **both** artifacts together: `v1.1.0` publishes diff --git a/control/README.md b/control/README.md index 3f7cd6d..02f0aa4 100644 --- a/control/README.md +++ b/control/README.md @@ -161,6 +161,21 @@ ModemManager exposes no USB `vid:pid`, so the package cannot build a `BandSku` a Both operations expose `describe(context)` alongside their static `descriptor`, because a static descriptor cannot carry a device's own catalog or its certification state. +### USB composition — runtime-derived targets, two proof tiers + +`operations().usbComposition` asks a known vendor for its current and enumerated USB +composition modes and offers only targets from that reply after the reply also proves a +represented return path. Its suppression vocabulary is `unknown-vendor`, +`no-return-path`, `blocked-by-state`, and `provisioning-disabled`; suppressed states expose +no targets. Unknown/disabled/blocked decisions happen before transport contact, and a +capability read sends only the named READ/TEST forms, never a SET. + +A reviewed catalog transition still provides the strongest success proof: canonical mode +and USB descriptors must both match. Without one, the weaker fallback proof is the +re-enumerated device's own post-switch READ reporting the target. AT `OK` is never success. +The write remains disruptive and requires admission, journal, rollback, and readback hooks. +Band writes do not share this policy and remain behind their four-proof certification gate. + ### SIM presence is evidence, never inference `readSimPresence` returns the presence together with the `SimPresenceEvidence` that diff --git a/control/src/backend/at-lease.test.ts b/control/src/backend/at-lease.test.ts index 8c9f57d..9292c66 100644 --- a/control/src/backend/at-lease.test.ts +++ b/control/src/backend/at-lease.test.ts @@ -6,6 +6,7 @@ import { describe, expect, test } from 'bun:test'; import { AT_BASELINE_ALLOWLIST, + AT_RUNTIME_QUERY_ALLOWLIST, AtCommandLease, AtCommandNotAllowedError, type AtCommandSender, @@ -30,13 +31,30 @@ function recordingSender(): { sender: AtCommandSender; sent: string[] } { } describe('AtCommandLease — allowlist', () => { - test('the baseline allowlist is exactly {ATI}; catalog commands union in', () => { - expect([...AT_BASELINE_ALLOWLIST]).toEqual(['ATI']); + test('the named baseline is ATI plus only the reviewed vendor READ/TEST forms', () => { + expect([...AT_RUNTIME_QUERY_ALLOWLIST]).toEqual([ + 'AT+GTUSBMODE?', + 'AT+GTUSBMODE=?', + 'AT+QCFG="usbnet"', + 'AT+QCFG=?', + 'AT+CUSBPIDSWITCH?', + 'AT+CUSBPIDSWITCH=?', + 'AT!USBCOMP?', + 'AT!USBCOMP=?', + ]); + expect([...AT_BASELINE_ALLOWLIST]).toEqual(['ATI', ...AT_RUNTIME_QUERY_ALLOWLIST]); const allowlist = computeAtAllowlist([CATALOG_COMMAND]); expect(allowlist.has('ATI')).toBe(true); expect(allowlist.has(CATALOG_COMMAND)).toBe(true); }); + test('a SET-shaped neighbour is still refused unless explicitly unioned for one transition', async () => { + const { sender, sent } = recordingSender(); + const lease = new AtCommandLease({ sender, allowlist: computeAtAllowlist([]) }); + await expect(lease.run('AT+GTUSBMODE=40')).rejects.toBeInstanceOf(AtCommandNotAllowedError); + expect(sent).toEqual([]); + }); + test('ATI and the catalog command are allowed', async () => { const { sender, sent } = recordingSender(); const lease = new AtCommandLease({ sender, allowlist: computeAtAllowlist([CATALOG_COMMAND]) }); diff --git a/control/src/backend/at-lease.ts b/control/src/backend/at-lease.ts index 0cff2ec..03adea2 100644 --- a/control/src/backend/at-lease.ts +++ b/control/src/backend/at-lease.ts @@ -13,9 +13,21 @@ import { type EpochMillis, epochMillis } from '../domain'; import { redact } from '../redact'; - -/** The baseline allowlist — identify only. Catalog commands are unioned in per SKU. */ -export const AT_BASELINE_ALLOWLIST: ReadonlySet = new Set(['ATI']); +import { RUNTIME_COMPOSITION_QUERY_REGISTRY } from '../usb-mode/runtime-capability'; + +/** Named read-only fence: the exact vendor READ/TEST forms reviewed for runtime discovery. */ +export const AT_RUNTIME_QUERY_ALLOWLIST: ReadonlySet = new Set( + Object.values(RUNTIME_COMPOSITION_QUERY_REGISTRY).flatMap(({ current, enumerate }) => [ + current, + enumerate, + ]), +); + +/** Identify plus reviewed runtime queries. Exact catalog/runtime SET commands union in per use. */ +export const AT_BASELINE_ALLOWLIST: ReadonlySet = new Set([ + 'ATI', + ...AT_RUNTIME_QUERY_ALLOWLIST, +]); /** Union the baseline allowlist with a catalog entry's declared transition commands. */ export function computeAtAllowlist(commands: Iterable): ReadonlySet { diff --git a/control/src/backend/transition-preconditions.ts b/control/src/backend/transition-preconditions.ts index 713b752..8d3dfdb 100644 --- a/control/src/backend/transition-preconditions.ts +++ b/control/src/backend/transition-preconditions.ts @@ -16,12 +16,17 @@ import type { EpochMillis, IdentityConfidence } from '../domain'; import type { ConnectionId, DeviceIfname } from '../ports'; import { + buildRuntimeCompositionSetCommand, type CatalogEntry, type CertifiedCatalog, findCatalogEntry, findPermittedTransition, type MmUsbMode, type PermittedTransition, + RUNTIME_COMPOSITION_QUERY_REGISTRY, + type RuntimeCompositionCapability, + type RuntimeCompositionMode, + type RuntimeCompositionVendor, type SkuDiscriminator, } from '../usb-mode'; import type { InterlockTarget, LifecycleInterlock } from './lifecycle-interlock'; @@ -62,12 +67,8 @@ export interface TransitionReadiness { readonly identityConfidence: IdentityConfidence; } -/** One USB-mode transition request. */ -export interface UsbModeTransitionRequest { +type UsbModeTransitionRequestBase = { readonly stableKey: string; - readonly sku: SkuDiscriminator; - readonly fromMode: MmUsbMode; - readonly toMode: MmUsbMode; readonly connectionId: ConnectionId; readonly deviceIfname: DeviceIfname; /** Physical-topology UID captured BEFORE the switch — survives re-enumeration. */ @@ -81,7 +82,27 @@ export interface UsbModeTransitionRequest { readonly now: EpochMillis; /** Live readiness, re-polled at entry AND in-actor (TOCTOU-safe). */ probeReadiness(): Promise; -} +}; + +export type CatalogUsbModeTransitionRequest = UsbModeTransitionRequestBase & { + readonly strategy?: 'catalog'; + readonly sku: SkuDiscriminator; + readonly fromMode: MmUsbMode; + readonly toMode: MmUsbMode; +}; + +export type RuntimeUsbModeTransitionRequest = UsbModeTransitionRequestBase & { + readonly strategy: 'runtime'; + readonly vendor: RuntimeCompositionVendor; + readonly sku?: SkuDiscriminator; + readonly fromMode: RuntimeCompositionMode; + readonly toMode: RuntimeCompositionMode; + readonly capability: RuntimeCompositionCapability; +}; + +export type UsbModeTransitionRequest = + | CatalogUsbModeTransitionRequest + | RuntimeUsbModeTransitionRequest; /** How a transition ended. */ export type UsbModeTransitionOutcome = @@ -103,11 +124,89 @@ export type UsbModeTransitionOutcome = readonly steps: readonly string[]; }; -/** The result of a precondition check — the matched entry/transition, or a reason. */ +export type UsbModeTransitionPlan = { + readonly atCommand: string; + readonly applyCommand?: string; + readonly proof: + | { + readonly tier: 'catalog-descriptors'; + readonly transition: PermittedTransition; + } + | { + readonly tier: 'runtime-requery'; + readonly vendor: RuntimeCompositionVendor; + readonly target: RuntimeCompositionMode; + readonly currentQuery: string; + }; +}; + export type PreconditionResult = - | { readonly ok: true; readonly entry: CatalogEntry; readonly transition: PermittedTransition } + | { + readonly ok: true; + readonly entry?: CatalogEntry; + readonly plan: UsbModeTransitionPlan; + readonly allowlistedCommands: readonly string[]; + } | { readonly ok: false; readonly reason: string }; +function sameRuntimeMode(left: RuntimeCompositionMode, right: RuntimeCompositionMode): boolean { + return Object.is(left, right); +} + +function runtimePlan( + request: RuntimeUsbModeTransitionRequest, + catalog: CertifiedCatalog, +): PreconditionResult { + const capability = request.capability; + if (capability.status !== 'available') return { ok: false, reason: 'runtime capability unknown' }; + if (!sameRuntimeMode(capability.current, request.fromMode)) + return { ok: false, reason: 'runtime current mode changed' }; + if ( + !capability.returnPathProven || + !capability.offerable.some((mode) => sameRuntimeMode(mode, request.toMode)) + ) + return { ok: false, reason: 'no-return-path' }; + const atCommand = buildRuntimeCompositionSetCommand(request.vendor, request.toMode); + if (atCommand === undefined) return { ok: false, reason: 'runtime target command unavailable' }; + + const entry = request.sku === undefined ? undefined : findCatalogEntry(catalog, request.sku); + if (entry !== undefined) { + const reviewed = entry.permittedTransitions.find( + (transition) => transition.atCommand === atCommand, + ); + if (reviewed !== undefined) { + return { + ok: true, + entry, + plan: { + atCommand: reviewed.atCommand, + ...(reviewed.applyCommand === undefined ? {} : { applyCommand: reviewed.applyCommand }), + proof: { tier: 'catalog-descriptors', transition: reviewed }, + }, + allowlistedCommands: entry.permittedTransitions.flatMap((transition) => + transition.applyCommand === undefined + ? [transition.atCommand] + : [transition.atCommand, transition.applyCommand], + ), + }; + } + } + return { + ok: true, + ...(entry === undefined ? {} : { entry }), + plan: { + atCommand, + proof: { + tier: 'runtime-requery', + vendor: request.vendor, + target: request.toMode, + currentQuery: RUNTIME_COMPOSITION_QUERY_REGISTRY[request.vendor].current, + }, + }, + allowlistedCommands: [atCommand], + }; +} + /** * Check every transition precondition against the LIVE inputs. Called at entry and * again in-actor; a request that passed at entry can fail here if the identity @@ -126,17 +225,37 @@ export async function checkTransitionPreconditions( if (!request.maintenance) { return { ok: false, reason: 'maintenance flag is required' }; } - const entry = findCatalogEntry(catalog, request.sku); - if (entry === undefined) { - return { ok: false, reason: `uncertified SKU ${request.sku.vidPid} ${request.sku.model}` }; - } - const transition = findPermittedTransition(entry, request.fromMode, request.toMode); - if (transition === undefined) { - return { - ok: false, - reason: `transition ${request.fromMode}->${request.toMode} not permitted for ${request.sku.model}`, + let staticResult: PreconditionResult; + if (request.strategy === 'runtime') { + staticResult = runtimePlan(request, catalog); + } else { + const entry = findCatalogEntry(catalog, request.sku); + if (entry === undefined) { + return { ok: false, reason: `uncertified SKU ${request.sku.vidPid} ${request.sku.model}` }; + } + const transition = findPermittedTransition(entry, request.fromMode, request.toMode); + if (transition === undefined) { + return { + ok: false, + reason: `transition ${request.fromMode}->${request.toMode} not permitted for ${request.sku.model}`, + }; + } + staticResult = { + ok: true, + entry, + plan: { + atCommand: transition.atCommand, + ...(transition.applyCommand === undefined ? {} : { applyCommand: transition.applyCommand }), + proof: { tier: 'catalog-descriptors', transition }, + }, + allowlistedCommands: entry.permittedTransitions.flatMap((candidate) => + candidate.applyCommand === undefined + ? [candidate.atCommand] + : [candidate.atCommand, candidate.applyCommand], + ), }; } + if (!staticResult.ok) return staticResult; const readiness = await request.probeReadiness(); if (readiness.identityConfidence === 'low') { return { ok: false, reason: 'low-confidence identity — refusing to transition' }; @@ -145,5 +264,5 @@ export async function checkTransitionPreconditions( if (!verdict.allow) { return { ok: false, reason: `interlock held: ${verdict.reason}` }; } - return { ok: true, entry, transition }; + return staticResult; } diff --git a/control/src/backend/usb-mode-transition.test.ts b/control/src/backend/usb-mode-transition.test.ts index cfddabf..86ffb10 100644 --- a/control/src/backend/usb-mode-transition.test.ts +++ b/control/src/backend/usb-mode-transition.test.ts @@ -14,7 +14,11 @@ import { connectionId, deviceIfname, type NetworkManagerPort, receipt } from '.. import type { CertifiedCatalog } from '../usb-mode'; import type { UsbDeviceSnapshot } from './device-classifier'; import { ModemActor } from './modem-actor'; -import type { TransitionInterlock, UsbModeTransitionRequest } from './transition-preconditions'; +import type { + CatalogUsbModeTransitionRequest, + TransitionInterlock, + UsbModeTransitionRequest, +} from './transition-preconditions'; import { UsbModeTransition, type UsbModeTransitionDeps } from './usb-mode-transition'; const CACHED_UID = 'pci-0000:00-usb-0:1'; @@ -128,7 +132,9 @@ function scriptedEnumerate( }; } -function makeRequest(overrides: Partial = {}): UsbModeTransitionRequest { +function makeRequest( + overrides: Partial = {}, +): CatalogUsbModeTransitionRequest { return { stableKey: 'slot:test', sku: SKU, @@ -188,6 +194,83 @@ describe('UsbModeTransition — the happy path walks all ten steps', () => { }); }); +describe('UsbModeTransition — tiered postcondition proof', () => { + test('an uncataloged runtime transition uses the exact vendor SET and proves success by post-switch READ', async () => { + const log: SpyLog = { calls: [] }; + const sender: UsbModeTransitionDeps['atSender'] = { + send: (command) => { + log.calls.push(`at.send:${command}`); + return Promise.resolve({ + ok: true, + raw: command === 'AT+GTUSBMODE?' ? '+GTUSBMODE: 40\r\nOK' : 'OK', + }); + }, + }; + const transition = makeTransition(log, { + atSender: sender, + catalog: { schemaVersion: 1, entries: [] }, + enumerate: scriptedEnumerate([[OLD_QMI], [], [{ ...NEW_MBIM, productId: '7127' }]]), + }); + const outcome = await transition.execute({ + ...makeRequest(), + strategy: 'runtime', + vendor: 'fibocom', + fromMode: 41, + toMode: 40, + capability: { + status: 'available', + current: 41, + enumerated: [40, 41], + returnPathProven: true, + offerable: [40, 41], + }, + }); + + expect(outcome.status).toBe('succeeded'); + expect(log.calls.filter((call) => call.startsWith('at.send:'))).toEqual([ + 'at.send:AT+GTUSBMODE=40', + 'at.send:AT+GTUSBMODE?', + ]); + expect(outcome.steps).toContain('postcondition-runtime-read'); + }); + + test('AT OK without a matching post-switch READ is rejected on the weaker runtime tier', async () => { + const log: SpyLog = { calls: [] }; + const sender: UsbModeTransitionDeps['atSender'] = { + send: (command) => { + log.calls.push(`at.send:${command}`); + return Promise.resolve({ + ok: true, + raw: command === 'AT+GTUSBMODE?' ? '+GTUSBMODE: 41\r\nOK' : 'OK', + }); + }, + }; + const transition = makeTransition(log, { + atSender: sender, + catalog: { schemaVersion: 1, entries: [] }, + enumerate: scriptedEnumerate([[OLD_QMI], [], [NEW_MBIM]]), + }); + const outcome = await transition.execute({ + ...makeRequest(), + strategy: 'runtime', + vendor: 'fibocom', + fromMode: 41, + toMode: 40, + capability: { + status: 'available', + current: 41, + enumerated: [40, 41], + returnPathProven: true, + offerable: [40, 41], + }, + }); + + expect(outcome.status).toBe('failed'); + if (outcome.status === 'failed') expect(outcome.reason).toContain('runtime readback mismatch'); + expect(log.calls).not.toContain('nm.activate:wwan1'); + }); +}); + /** No nm/mm/at side-effecting call ran, and the actor was never entered. */ function expectZeroSideEffects(log: SpyLog, steps: readonly string[]): void { expect(log.calls).toEqual([]); @@ -195,7 +278,7 @@ function expectZeroSideEffects(log: SpyLog, steps: readonly string[]): void { } describe('UsbModeTransition — TIER A: entry refusals fire ZERO actor/lease/AT calls', () => { - const cases: Array<[string, Partial]> = [ + const cases: Array<[string, Partial]> = [ ['unconfirmed (confirm:false)', { confirm: false }], ['uncertified SKU (no catalog entry)', { sku: { ...SKU, firmwarePrefix: 'UNKNOWNFW' } }], [ diff --git a/control/src/backend/usb-mode-transition.ts b/control/src/backend/usb-mode-transition.ts index bdb79e7..616ad68 100644 --- a/control/src/backend/usb-mode-transition.ts +++ b/control/src/backend/usb-mode-transition.ts @@ -11,15 +11,22 @@ // 8 resolve new ifname → 9 reactivate (uuid, newIfname) → 10 release interlock // (finally, always). // -// THE POSTCONDITION IS THE ONLY PROOF OF SUCCESS. An AT `OK` proves nothing — only a -// re-enumerated device whose descriptors AND observed mode equal the catalog target -// counts. On a postcondition MISMATCH the whole transaction fails `degraded`, does NOT +// THE POSTCONDITION IS THE ONLY PROOF OF SUCCESS. An AT `OK` proves nothing. Tier 1 is +// the strongest proof and remains unchanged: a reviewed catalog transition must match +// both descriptors and canonical mode. Tier 2 exists only when no reviewed transition +// matches: the re-enumerated device must report the raw target through its own vendor READ. +// Tier 2 is explicitly weaker because it proves reported mode, not descriptor composition. +// On a postcondition MISMATCH the whole transaction fails `degraded`, does NOT // reactivate, and still releases the interlock via `finally`. A hung command trips the // AT watchdog, which force-uninhibits so the system reprobes rather than wedging. import type { DeviceIfname, InhibitLease, ModemManagerPort, NetworkManagerPort } from '../ports'; import { deviceIfname } from '../ports'; -import { CERTIFIED_CATALOG, type CertifiedCatalog, type PermittedTransition } from '../usb-mode'; +import { + CERTIFIED_CATALOG, + type CertifiedCatalog, + readRuntimeCompositionCurrent, +} from '../usb-mode'; import { type AtAuditSink, AtCommandLease, @@ -33,6 +40,7 @@ import { checkTransitionPreconditions, type TransitionInterlock, type UsbModeTransitionOutcome, + type UsbModeTransitionPlan, type UsbModeTransitionRequest, } from './transition-preconditions'; @@ -118,12 +126,7 @@ export class UsbModeTransition { } const hold = await this.#interlock.hold({ stableKey: request.stableKey }); try { - return await this.#runTransaction( - request, - recheck.entry.permittedTransitions, - recheck.transition, - steps, - ); + return await this.#runTransaction(request, recheck.allowlistedCommands, recheck.plan, steps); } finally { steps.push('release-interlock'); await hold.release().catch(() => undefined); @@ -132,8 +135,8 @@ export class UsbModeTransition { async #runTransaction( request: UsbModeTransitionRequest, - allCommands: readonly PermittedTransition[], - transition: PermittedTransition, + allowlistedCommands: readonly string[], + plan: UsbModeTransitionPlan, steps: string[], ): Promise { let inhibit: InhibitLease | undefined; @@ -149,11 +152,7 @@ export class UsbModeTransition { }; const lease = new AtCommandLease({ sender: this.#atSender, - allowlist: computeAtAllowlist( - allCommands.flatMap((t) => - t.applyCommand === undefined ? [t.atCommand] : [t.atCommand, t.applyCommand], - ), - ), + allowlist: computeAtAllowlist(allowlistedCommands), timeoutMs: this.#watchdogMs, onWatchdog: forceUninhibit, ...(this.#audit !== undefined ? { audit: this.#audit } : {}), @@ -167,11 +166,11 @@ export class UsbModeTransition { // AT `OK` is IGNORED for success — only the postcondition below decides. steps.push('at-command'); - await lease.run(transition.atCommand, { inhibitUid: request.inhibitUid }); + await lease.run(plan.atCommand, { inhibitUid: request.inhibitUid }); - if (transition.applyCommand !== undefined) { + if (plan.applyCommand !== undefined) { steps.push('apply-command'); - await lease.run(transition.applyCommand, { inhibitUid: request.inhibitUid }); + await lease.run(plan.applyCommand, { inhibitUid: request.inhibitUid }); } steps.push('await-port-drop'); @@ -188,15 +187,31 @@ export class UsbModeTransition { const device = await this.#awaitReenumeration(request.cachedPhysicalUid); steps.push('postcondition'); - const observedMode = detectUsbMode(device); - const descriptorsOk = descriptorsMatch(device, transition.expectedDescriptors); - if (observedMode !== request.toMode || !descriptorsOk) { - return { - status: 'failed', - degraded: true, - reason: `postcondition mismatch: observed ${observedMode ?? 'unknown'} vs target ${request.toMode}; descriptors ${descriptorsOk ? 'ok' : 'mismatch'}`, - steps, - }; + if (plan.proof.tier === 'catalog-descriptors') { + const observedMode = detectUsbMode(device); + const descriptorsOk = descriptorsMatch(device, plan.proof.transition.expectedDescriptors); + if (observedMode !== plan.proof.transition.to || !descriptorsOk) { + return { + status: 'failed', + degraded: true, + reason: `postcondition mismatch: observed ${observedMode ?? 'unknown'} vs target ${plan.proof.transition.to}; descriptors ${descriptorsOk ? 'ok' : 'mismatch'}`, + steps, + }; + } + } else { + steps.push('postcondition-runtime-read'); + const response = await lease.run(plan.proof.currentQuery, { + inhibitUid: request.inhibitUid, + }); + const observed = readRuntimeCompositionCurrent(plan.proof.vendor, response.raw); + if (!Object.is(observed, plan.proof.target)) { + return { + status: 'failed', + degraded: true, + reason: `runtime readback mismatch: observed ${observed ?? 'unknown'} vs target ${plan.proof.target}`, + steps, + }; + } } steps.push('resolve-ifname'); diff --git a/control/src/operation-ids.ts b/control/src/operation-ids.ts index f7b9216..f6ca290 100644 --- a/control/src/operation-ids.ts +++ b/control/src/operation-ids.ts @@ -5,6 +5,7 @@ export const MODEM_OPERATION_IDS = Object.freeze([ 'modemmanager.signal', 'modemmanager.sim', 'modemmanager.power', + 'modemmanager.usb-composition', 'status', 'signal', 'mode', diff --git a/control/src/providers/modem-manager/generic-operations.ts b/control/src/providers/modem-manager/generic-operations.ts index f2691ab..83c30dc 100644 --- a/control/src/providers/modem-manager/generic-operations.ts +++ b/control/src/providers/modem-manager/generic-operations.ts @@ -22,6 +22,10 @@ import { } from '../../radio'; import type { ProviderExecutionContext } from '../contracts'; import { mapModemManagerError } from './errors'; +import { + createRuntimeCompositionOperation, + type RuntimeCompositionOperationDeps, +} from './runtime-composition-operation'; import type { ContextReadOperation, ContextWriteOperation, @@ -38,6 +42,7 @@ const PROFILE = 'generic-mm'; export interface GenericOperationsDeps { readonly backend: MmDbusBackend; + readonly runtimeComposition?: RuntimeCompositionOperationDeps; readSnapshot(context: ProviderExecutionContext): Promise; } @@ -106,7 +111,10 @@ function receiptReason(reason: string): string { export function createGenericOperations( deps: GenericOperationsDeps, -): Pick { +): Pick< + ModemManagerProviderOperations, + 'radio' | 'modes' | 'bands' | 'signal' | 'sim' | 'power' | 'usbComposition' +> { const readField = async ( context: ProviderExecutionContext, capability: 'modeRead' | 'signalRead' | 'simRead' | 'powerRead', @@ -262,5 +270,6 @@ export function createGenericOperations( read: (context) => readField(context, 'powerRead', (snapshot) => snapshot.power, 'power-read-unsupported'), }; - return { radio, modes, bands, signal, sim, power }; + const usbComposition = createRuntimeCompositionOperation(deps.runtimeComposition); + return { radio, modes, bands, signal, sim, power, usbComposition }; } diff --git a/control/src/providers/modem-manager/index.ts b/control/src/providers/modem-manager/index.ts index 194f407..838bc9b 100644 --- a/control/src/providers/modem-manager/index.ts +++ b/control/src/providers/modem-manager/index.ts @@ -1,3 +1,4 @@ export * from './errors'; export * from './provider'; +export * from './runtime-composition-operation'; export * from './types'; diff --git a/control/src/providers/modem-manager/provider.ts b/control/src/providers/modem-manager/provider.ts index e8f998a..80c359d 100644 --- a/control/src/providers/modem-manager/provider.ts +++ b/control/src/providers/modem-manager/provider.ts @@ -32,6 +32,7 @@ import type { import { mapModemManagerError } from './errors'; import { createGenericOperations } from './generic-operations'; import { ModemManagerModuleOperations } from './module-operations'; +import type { RuntimeCompositionOperationDeps } from './runtime-composition-operation'; import { buildModemManagerSnapshot } from './snapshot'; import type { ModemManagerProviderLifecycle, @@ -62,6 +63,7 @@ export interface ModemManagerProviderOptions { * hence no band write: fail-closed, which is this module's whole stance. */ readonly bandSku?: (context: ProviderExecutionContext) => BandSku | undefined; + readonly runtimeComposition?: RuntimeCompositionOperationDeps; } export class ModemManagerProvider implements ModemManagerProviderLifecycle { @@ -120,6 +122,9 @@ export class ModemManagerProvider implements ModemManagerProviderLifecycle { ...createGenericOperations({ backend: this.#backend, readSnapshot: (context) => this.readSnapshot(context), + ...(options.runtimeComposition === undefined + ? {} + : { runtimeComposition: options.runtimeComposition }), }), ...this.#moduleOperations.operations, }; diff --git a/control/src/providers/modem-manager/runtime-composition-operation.test.ts b/control/src/providers/modem-manager/runtime-composition-operation.test.ts new file mode 100644 index 0000000..63a7e98 --- /dev/null +++ b/control/src/providers/modem-manager/runtime-composition-operation.test.ts @@ -0,0 +1,315 @@ +import { afterEach, describe, expect, test } from 'bun:test'; +import type { AtCommandSender } from '../../backend'; +import { deviceGeneration, physicalModemId } from '../../domain'; +import { createOperationEngine } from '../../operations'; +import { deviceIfname, type MutationAdmissionPort, type ResourceOwnershipPort } from '../../ports'; +import { createModemControlCompositionRoot } from '../../safety'; +import type { RuntimeCompositionMode } from '../../usb-mode'; +import type { ProviderExecutionContext } from '../contracts'; +import { + createRuntimeCompositionOperation, + type RuntimeCompositionOperationDeps, +} from './runtime-composition-operation'; + +const MODEM = physicalModemId('serial:runtime-composition'); +const GENERATION = deviceGeneration(7); +const CONTEXT: ProviderExecutionContext = { + physicalModemId: MODEM, + generation: GENERATION, + transport: 'modemmanager', + passiveFacts: [], + composition: 'qmi', + firmware: 'fixture', + profile: 'generic-mm', +}; + +const roots: Array<{ dispose(): Promise }> = []; + +afterEach(async () => { + for (const root of roots.splice(0).reverse()) await root.dispose(); +}); + +function senderFor(responses: Readonly>, calls: string[]): AtCommandSender { + return { + send: (command) => { + calls.push(command); + return Promise.resolve({ ok: true, raw: responses[command] ?? 'OK' }); + }, + }; +} + +function operationDeps(overrides: Partial = {}) { + const transportCalls: string[] = []; + const transitionCalls: RuntimeCompositionMode[] = []; + const deps: RuntimeCompositionOperationDeps = { + vendor: () => 'fibocom', + provisioningEnabled: () => true, + blockedReason: () => undefined, + atSender: senderFor( + { + 'AT+GTUSBMODE?': '+GTUSBMODE: 41\r\nOK', + 'AT+GTUSBMODE=?': '+GTUSBMODE: (40,41)\r\nOK', + }, + transportCalls, + ), + transition: (_context, _capability, target) => { + transitionCalls.push(target); + return Promise.resolve({ + status: 'succeeded', + newIfname: deviceIfname('wwan1'), + steps: ['postcondition-runtime-read'], + }); + }, + ...overrides, + }; + return { deps, transportCalls, transitionCalls }; +} + +describe('runtime composition suppression matrix', () => { + test.each([ + { + name: 'unknown vendor', + reason: 'unknown-vendor', + overrides: { vendor: () => 'unlisted' }, + expectedTransportCalls: 0, + }, + { + name: 'provisioning disabled', + reason: 'provisioning-disabled', + overrides: { provisioningEnabled: () => false }, + expectedTransportCalls: 0, + }, + { + name: 'blocked by live state', + reason: 'blocked-by-state', + overrides: { blockedReason: () => 'streaming-active' }, + expectedTransportCalls: 0, + }, + { + name: 'no represented return path', + reason: 'no-return-path', + overrides: { + atSender: senderFor( + { + 'AT+GTUSBMODE?': '+GTUSBMODE: 42\r\nOK', + 'AT+GTUSBMODE=?': '+GTUSBMODE: (40,41)\r\nOK', + }, + [], + ), + }, + expectedTransportCalls: 2, + }, + ] as const)('emits distinct $reason for $name', async (fixture) => { + const transportCalls: string[] = []; + const created = operationDeps({ + ...fixture.overrides, + ...(fixture.reason === 'no-return-path' + ? { + atSender: senderFor( + { + 'AT+GTUSBMODE?': '+GTUSBMODE: 42\r\nOK', + 'AT+GTUSBMODE=?': '+GTUSBMODE: (40,41)\r\nOK', + }, + transportCalls, + ), + } + : {}), + }); + const operation = createRuntimeCompositionOperation(created.deps); + + const state = await operation.capability(CONTEXT); + + expect(state.status).toBe('suppressed'); + if (state.status === 'suppressed') expect(state.reason).toBe(fixture.reason); + expect(state.offerable).toEqual([]); + expect( + fixture.reason === 'no-return-path' ? transportCalls.length : created.transportCalls.length, + ).toBe(fixture.expectedTransportCalls); + }); + + test('an unknown vendor has zero offered targets and a forced mutation touches no transport', async () => { + const created = operationDeps({ vendor: () => 'unlisted' }); + const operation = createRuntimeCompositionOperation(created.deps); + + const result = await operation.write(CONTEXT, 40); + + expect(result).toMatchObject({ status: 'refused', reason: 'unknown-vendor' }); + expect(created.transportCalls).toEqual([]); + expect(created.transitionCalls).toEqual([]); + }); +}); + +describe('runtime composition offered transition', () => { + test('the offered set comes from READ plus TEST and never sends a SET during capability read', async () => { + const created = operationDeps(); + const operation = createRuntimeCompositionOperation(created.deps); + + const state = await operation.capability(CONTEXT); + + expect(state).toEqual({ + status: 'available', + current: 41, + enumerated: [40, 41], + offerable: [40], + }); + expect(created.transportCalls).toEqual(['AT+GTUSBMODE?', 'AT+GTUSBMODE=?']); + expect(created.transportCalls.some((command) => command.includes('='))).toBe(true); + expect(created.transportCalls.some((command) => command === 'AT+GTUSBMODE=40')).toBe(false); + }); + + test('the operation descriptor drives admission, journal and readback through OperationEngine', async () => { + const events: string[] = []; + const admission: MutationAdmissionPort = { + acquire: () => { + events.push('lease:acquire'); + return Promise.resolve({ + status: 'admitted', + lease: { + release: () => { + events.push('lease:release'); + return Promise.resolve(); + }, + }, + }); + }, + }; + const ownership: ResourceOwnershipPort = { + acquire: () => Promise.resolve({ status: 'refused', reason: 'already-owned' }), + }; + const root = createModemControlCompositionRoot({ admission, ownership }); + roots.push(root); + const engine = createOperationEngine({ + root, + currentGeneration: () => GENERATION, + preconditions: { check: () => Promise.resolve({ status: 'satisfied' }) }, + }); + let current = 41; + const transportCalls: string[] = []; + const created = operationDeps({ + atSender: { + send: (command) => { + transportCalls.push(command); + return Promise.resolve({ + ok: true, + raw: + command === 'AT+GTUSBMODE?' + ? `+GTUSBMODE: ${current}\r\nOK` + : '+GTUSBMODE: (40,41)\r\nOK', + }); + }, + }, + transition: (_context, _capability, target) => { + current = Number(target); + events.push('transition'); + return Promise.resolve({ + status: 'succeeded', + newIfname: deviceIfname('wwan1'), + steps: ['postcondition-runtime-read'], + }); + }, + }); + const operation = createRuntimeCompositionOperation(created.deps); + const descriptor = await operation.describe(CONTEXT); + + const result = await engine.invoke({ + operationId: 'runtime-composition-1', + physicalModemId: MODEM, + descriptor, + input: 40, + execute: async () => { + const write = await operation.write(CONTEXT, 40); + return write.status === 'applied' + ? { status: 'applied', value: write.value } + : { status: 'failed', reason: write.reason }; + }, + readback: async () => { + events.push('readback'); + const read = await operation.read(CONTEXT); + return read.status === 'applied' + ? { status: 'applied', value: read.value } + : { status: 'failed', reason: read.reason }; + }, + rollback: () => { + events.push('rollback'); + return Promise.resolve({ status: 'applied', value: undefined }); + }, + journal: { + record: (event) => { + events.push(`journal:${event.phase}`); + return Promise.resolve(); + }, + }, + }); + + expect(result.status).toBe('applied'); + expect(events).toEqual([ + 'lease:acquire', + 'journal:started', + 'transition', + 'readback', + 'journal:completed', + 'lease:release', + ]); + expect(descriptor.rollback.required).toBe(true); + expect(transportCalls).toHaveLength(6); + }); + + test('a definite failed offered transition fires the armed rollback hook', async () => { + const events: string[] = []; + const admission: MutationAdmissionPort = { + acquire: () => + Promise.resolve({ + status: 'admitted', + lease: { release: () => Promise.resolve() }, + }), + }; + const ownership: ResourceOwnershipPort = { + acquire: () => Promise.resolve({ status: 'refused', reason: 'already-owned' }), + }; + const root = createModemControlCompositionRoot({ admission, ownership }); + roots.push(root); + const engine = createOperationEngine({ + root, + currentGeneration: () => GENERATION, + preconditions: { check: () => Promise.resolve({ status: 'satisfied' }) }, + }); + const created = operationDeps({ + transition: () => + Promise.resolve({ + status: 'failed', + degraded: true, + reason: 'runtime readback mismatch', + steps: ['postcondition-runtime-read'], + }), + }); + const operation = createRuntimeCompositionOperation(created.deps); + const descriptor = await operation.describe(CONTEXT); + + const result = await engine.invoke({ + operationId: 'runtime-composition-rollback', + physicalModemId: MODEM, + descriptor, + input: 40, + execute: async () => { + const write = await operation.write(CONTEXT, 40); + return write.status === 'applied' + ? { status: 'applied', value: write.value } + : { status: 'failed', reason: write.reason }; + }, + readback: () => Promise.resolve({ status: 'failed', reason: 'not-reached' }), + rollback: () => { + events.push('rollback'); + return Promise.resolve({ status: 'applied', value: undefined }); + }, + journal: { + record: (event) => { + events.push(`journal:${event.phase}`); + return Promise.resolve(); + }, + }, + }); + + expect(result).toMatchObject({ status: 'failed', reason: 'runtime readback mismatch' }); + expect(events).toEqual(['journal:started', 'rollback', 'journal:completed']); + }); +}); diff --git a/control/src/providers/modem-manager/runtime-composition-operation.ts b/control/src/providers/modem-manager/runtime-composition-operation.ts new file mode 100644 index 0000000..70676a5 --- /dev/null +++ b/control/src/providers/modem-manager/runtime-composition-operation.ts @@ -0,0 +1,238 @@ +import { + AtCommandLease, + type AtCommandSender, + computeAtAllowlist, + type UsbModeTransitionOutcome, +} from '../../backend'; +import { + classifyOperationCompletion, + defineOperationDescriptor, + type OperationDescriptor, + type OperationResult, +} from '../../domain'; +import { + isRuntimeCompositionVendor, + RUNTIME_COMPOSITION_QUERY_REGISTRY, + type RuntimeCompositionCapability, + type RuntimeCompositionMode, + resolveRuntimeCompositionCapability, +} from '../../usb-mode'; +import type { ProviderExecutionContext } from '../contracts'; +import type { ContextWriteOperation } from './types'; + +export const RUNTIME_COMPOSITION_SUPPRESSIONS = [ + 'unknown-vendor', + 'no-return-path', + 'blocked-by-state', + 'provisioning-disabled', +] as const; +export type RuntimeCompositionSuppression = (typeof RUNTIME_COMPOSITION_SUPPRESSIONS)[number]; + +export type RuntimeCompositionState = + | { + readonly status: 'available'; + readonly current: RuntimeCompositionMode; + readonly enumerated: readonly RuntimeCompositionMode[]; + readonly offerable: readonly RuntimeCompositionMode[]; + } + | { + readonly status: 'suppressed'; + readonly reason: RuntimeCompositionSuppression; + readonly detail: string; + readonly current: RuntimeCompositionMode | null; + readonly enumerated: readonly RuntimeCompositionMode[]; + readonly offerable: readonly []; + }; + +export type RuntimeCompositionOperationDeps = { + readonly vendor: (context: ProviderExecutionContext) => string | Promise; + readonly provisioningEnabled: (context: ProviderExecutionContext) => boolean | Promise; + readonly blockedReason: ( + context: ProviderExecutionContext, + ) => string | undefined | Promise; + readonly atSender: AtCommandSender; + readonly transition: ( + context: ProviderExecutionContext, + capability: Extract, + target: RuntimeCompositionMode, + ) => Promise; +}; + +export interface RuntimeCompositionOperation + extends ContextWriteOperation { + capability(context: ProviderExecutionContext): Promise; +} + +const OPERATION_ID = 'modemmanager.usb-composition'; + +function suppressed( + reason: RuntimeCompositionSuppression, + detail: string, + capability?: RuntimeCompositionCapability, +): RuntimeCompositionState { + return { + status: 'suppressed', + reason, + detail, + current: capability?.current ?? null, + enumerated: capability?.enumerated ?? [], + offerable: [], + }; +} + +function descriptorFor( + state: RuntimeCompositionState, +): OperationDescriptor { + const available = state.status === 'available'; + return defineOperationDescriptor({ + id: OPERATION_ID, + support: { + read: { supported: true }, + write: available ? { supported: true } : { supported: false, reason: state.reason }, + }, + authority: 'provider', + provider: 'modemmanager', + constraints: { + kind: 'allowed-values', + values: available ? state.offerable : [], + }, + livePreconditions: [ + 'modem-present', + 'runtime-interface-present', + 'identity-confident', + 'streaming-interlock-open', + ], + availability: available ? { state: 'available' } : { state: 'refused', reason: state.reason }, + mutationImpact: 'disruptive', + retryClass: 'never', + readback: { + required: true, + reason: 'device-reported-composition', + matches: (input, value) => value.status === 'available' && Object.is(input, value.current), + }, + rollback: { required: true, reason: 'restore-previous-composition' }, + journal: { required: true, reason: 'composition-reenumeration' }, + admission: { required: true, reason: 'provider-mutation' }, + evidence: { profiles: ['generic-mm'], firmware: [] }, + confidence: available ? 'high' : 'unknown', + }); +} + +function result( + context: ProviderExecutionContext, + completion: + | { readonly status: 'applied'; readonly value: O } + | { readonly status: 'refused' | 'failed'; readonly reason: string }, +): OperationResult { + return classifyOperationCompletion({ + operation: 'write', + completionGeneration: context.generation, + currentGeneration: context.generation, + completion, + }); +} + +function disabledOperation(): RuntimeCompositionOperation { + const state: Extract = { + status: 'suppressed', + reason: 'provisioning-disabled', + detail: 'USB composition provisioning is not configured', + current: null, + enumerated: [], + offerable: [], + }; + const descriptor = descriptorFor(state); + return { + descriptor, + describe: () => Promise.resolve(descriptor), + capability: () => Promise.resolve(state), + read: (context) => Promise.resolve(result(context, { status: 'applied', value: state })), + write: (context) => + Promise.resolve( + result(context, { status: 'refused', reason: state.reason }), + ), + }; +} + +export function createRuntimeCompositionOperation( + deps: RuntimeCompositionOperationDeps | undefined, +): RuntimeCompositionOperation { + if (deps === undefined) return disabledOperation(); + + const capability = async ( + context: ProviderExecutionContext, + ): Promise => { + const vendor = (await deps.vendor(context)).trim().toLowerCase(); + if (!isRuntimeCompositionVendor(vendor)) + return suppressed('unknown-vendor', `No reviewed composition query exists for ${vendor}`); + if (!(await deps.provisioningEnabled(context))) + return suppressed('provisioning-disabled', 'USB composition provisioning is disabled'); + const blocked = await deps.blockedReason(context); + if (blocked !== undefined) return suppressed('blocked-by-state', blocked); + + const queries = RUNTIME_COMPOSITION_QUERY_REGISTRY[vendor]; + const lease = new AtCommandLease({ + sender: deps.atSender, + allowlist: computeAtAllowlist([]), + }); + const current = await lease.run(queries.current); + const enumeration = await lease.run(queries.enumerate); + const resolved = resolveRuntimeCompositionCapability({ + vendor, + currentResponse: current.raw, + enumerationResponse: enumeration.raw, + }); + if (resolved.status === 'unknown') + return suppressed('no-return-path', resolved.reason, resolved); + if (!resolved.returnPathProven) + return suppressed( + 'no-return-path', + 'The current mode is absent from the device catalog', + resolved, + ); + return { + status: 'available', + current: resolved.current, + enumerated: resolved.enumerated, + offerable: resolved.offerable.filter((mode) => !Object.is(mode, resolved.current)), + }; + }; + + return { + descriptor: descriptorFor( + suppressed('provisioning-disabled', 'Capability has not been read for this device'), + ), + describe: async (context) => descriptorFor(await capability(context)), + capability, + read: async (context) => + result(context, { status: 'applied', value: await capability(context) }), + write: async (context, target) => { + const state = await capability(context); + if (state.status === 'suppressed') + return result(context, { status: 'refused', reason: state.reason }); + if (!state.offerable.some((candidate) => Object.is(candidate, target))) + return result(context, { status: 'refused', reason: 'no-return-path' }); + const fullCapability: Extract = { + status: 'available', + current: state.current, + enumerated: state.enumerated, + returnPathProven: true, + offerable: [state.current, ...state.offerable], + }; + const outcome = await deps.transition(context, fullCapability, target); + if (outcome.status === 'refused') + return result(context, { status: 'refused', reason: 'blocked-by-state' }); + if (outcome.status === 'failed') + return result(context, { status: 'failed', reason: outcome.reason }); + return result(context, { + status: 'applied', + value: { + status: 'available', + current: target, + enumerated: state.enumerated, + offerable: state.enumerated.filter((mode) => !Object.is(mode, target)), + }, + }); + }, + }; +} diff --git a/control/src/providers/modem-manager/types.ts b/control/src/providers/modem-manager/types.ts index 6c38a53..1c52bfb 100644 --- a/control/src/providers/modem-manager/types.ts +++ b/control/src/providers/modem-manager/types.ts @@ -22,6 +22,7 @@ import type { BandWriteCertification, ModeSelection, RadioModeTruth } from '../. import type { MmUssd, UssdVerbResult } from '../../ussd'; import type { ProviderExecutionContext, ProviderOperationsSurface } from '../contracts'; import type { ModemManagerRefusalReason } from './errors'; +import type { RuntimeCompositionOperation } from './runtime-composition-operation'; export type ModemManagerCapabilities = { readonly modeRead: boolean; @@ -125,6 +126,7 @@ export interface ModemManagerProviderOperations extends ProviderOperationsSurfac readonly signal: ContextReadOperation; readonly sim: ContextReadOperation; readonly power: ContextReadOperation; + readonly usbComposition: RuntimeCompositionOperation; readonly location: { status(context: ProviderExecutionContext): Promise; enable( diff --git a/control/src/usb-mode/index.ts b/control/src/usb-mode/index.ts index f08b865..d9992ea 100644 --- a/control/src/usb-mode/index.ts +++ b/control/src/usb-mode/index.ts @@ -51,13 +51,18 @@ export { renderPromotionReview, } from './promotion-review'; export { + buildRuntimeCompositionSetCommand, + isRuntimeCompositionVendor, RUNTIME_COMPOSITION_QUERY_REGISTRY, + RUNTIME_COMPOSITION_SET_REGISTRY, RUNTIME_COMPOSITION_VENDORS, type RuntimeCompositionCapability, type RuntimeCompositionMode, type RuntimeCompositionQuery, type RuntimeCompositionResponse, + type RuntimeCompositionSetCommand, type RuntimeCompositionVendor, + readRuntimeCompositionCurrent, resolveRuntimeCompositionCapability, } from './runtime-capability'; export { diff --git a/control/src/usb-mode/runtime-capability.test.ts b/control/src/usb-mode/runtime-capability.test.ts index fcec305..9f15fda 100644 --- a/control/src/usb-mode/runtime-capability.test.ts +++ b/control/src/usb-mode/runtime-capability.test.ts @@ -2,7 +2,10 @@ import { describe, expect, test } from 'bun:test'; import { MODEM_OPERATION_IDS } from '../operation-ids'; import { + buildRuntimeCompositionSetCommand, RUNTIME_COMPOSITION_QUERY_REGISTRY, + RUNTIME_COMPOSITION_SET_REGISTRY, + readRuntimeCompositionCurrent, resolveRuntimeCompositionCapability, } from './runtime-capability'; @@ -129,12 +132,34 @@ describe('runtime composition capability', () => { sierra: { current: 'AT!USBCOMP?', enumerate: 'AT!USBCOMP=?' }, }); }); + + test('the reviewed SET registry emits only each vendor exact command form', () => { + expect(Object.keys(RUNTIME_COMPOSITION_SET_REGISTRY)).toEqual([ + 'fibocom', + 'quectel', + 'simcom', + 'sierra', + ]); + expect(buildRuntimeCompositionSetCommand('fibocom', 40)).toBe('AT+GTUSBMODE=40'); + expect(buildRuntimeCompositionSetCommand('quectel', 2)).toBe('AT+QCFG="usbnet",2'); + expect(buildRuntimeCompositionSetCommand('simcom', '9001')).toBe('AT+CUSBPIDSWITCH=9001,1,1'); + expect(buildRuntimeCompositionSetCommand('sierra', 8)).toBe('AT!USBCOMP=8'); + expect(buildRuntimeCompositionSetCommand('simcom', '9001;AT+BAD')).toBeUndefined(); + expect(buildRuntimeCompositionSetCommand('fibocom', '40')).toBeUndefined(); + }); + + test('a post-switch READ parses the device reported current mode without a TEST query', () => { + expect(readRuntimeCompositionCurrent('fibocom', '+GTUSBMODE: 40\r\nOK')).toBe(40); + expect(readRuntimeCompositionCurrent('simcom', '+CUSBPIDSWITCH: 9001\r\nOK')).toBe('9001'); + expect(readRuntimeCompositionCurrent('unknown', '+MODE: 1\r\nOK')).toBeUndefined(); + }); }); describe('MODEM_OPERATION_IDS', () => { test('equals the non-vacuous union of concrete IDs declared by all four providers', async () => { const sourceFiles = [ '../providers/modem-manager/generic-operations.ts', + '../providers/modem-manager/runtime-composition-operation.ts', '../radio/mode-truth.ts', '../radio/band-truth.ts', '../providers/huawei-hilink/runtime.ts', @@ -154,9 +179,9 @@ describe('MODEM_OPERATION_IDS', () => { ).filter((id) => id !== undefined), ); - expect(declared.size).toBe(23); + expect(declared.size).toBe(24); expect(new Set(MODEM_OPERATION_IDS)).toEqual(declared); - expect(MODEM_OPERATION_IDS).toHaveLength(23); + expect(MODEM_OPERATION_IDS).toHaveLength(24); expect(Object.isFrozen(MODEM_OPERATION_IDS)).toBe(true); }); }); diff --git a/control/src/usb-mode/runtime-capability.ts b/control/src/usb-mode/runtime-capability.ts index a5218eb..9ff5acb 100644 --- a/control/src/usb-mode/runtime-capability.ts +++ b/control/src/usb-mode/runtime-capability.ts @@ -8,6 +8,8 @@ export type RuntimeCompositionQuery = { readonly enumerate: string; }; +export type RuntimeCompositionSetCommand = (target: RuntimeCompositionMode) => string | undefined; + /** Commands only: selecting a port, sending, deadlines, and retries belong to the provider. */ export const RUNTIME_COMPOSITION_QUERY_REGISTRY = Object.freeze({ fibocom: Object.freeze({ current: 'AT+GTUSBMODE?', enumerate: 'AT+GTUSBMODE=?' }), @@ -16,6 +18,24 @@ export const RUNTIME_COMPOSITION_QUERY_REGISTRY = Object.freeze({ sierra: Object.freeze({ current: 'AT!USBCOMP?', enumerate: 'AT!USBCOMP=?' }), } satisfies Readonly>); +function decimalSetCommand(prefix: string): RuntimeCompositionSetCommand { + return (target) => + typeof target === 'number' && Number.isSafeInteger(target) && target >= 0 + ? `${prefix}${target}` + : undefined; +} + +/** Exact reviewed SET forms. Callers still allowlist only the one enumerated target selected. */ +export const RUNTIME_COMPOSITION_SET_REGISTRY = Object.freeze({ + fibocom: decimalSetCommand('AT+GTUSBMODE='), + quectel: decimalSetCommand('AT+QCFG="usbnet",'), + simcom: (target: RuntimeCompositionMode) => + typeof target === 'string' && /^[0-9A-Fa-f]{4}$/.test(target) + ? `AT+CUSBPIDSWITCH=${target.toUpperCase()},1,1` + : undefined, + sierra: decimalSetCommand('AT!USBCOMP='), +} satisfies Readonly>); + export type RuntimeCompositionCapability = | { readonly status: 'available'; @@ -141,10 +161,47 @@ const PARSERS: Readonly< sierra: parseSierra, }; -function isRuntimeCompositionVendor(vendor: string): vendor is RuntimeCompositionVendor { +export function isRuntimeCompositionVendor(vendor: string): vendor is RuntimeCompositionVendor { return Object.hasOwn(RUNTIME_COMPOSITION_QUERY_REGISTRY, vendor); } +export function buildRuntimeCompositionSetCommand( + vendor: string, + target: RuntimeCompositionMode, +): string | undefined { + const normalized = vendor.trim().toLowerCase(); + return isRuntimeCompositionVendor(normalized) + ? RUNTIME_COMPOSITION_SET_REGISTRY[normalized](target) + : undefined; +} + +/** Parse only the vendor READ response used by the weaker post-switch proof tier. */ +export function readRuntimeCompositionCurrent( + vendor: string, + response: string, +): RuntimeCompositionMode | undefined { + const normalized = vendor.trim().toLowerCase(); + if (!isRuntimeCompositionVendor(normalized)) return undefined; + switch (normalized) { + case 'fibocom': { + const match = /^\s*\+GTUSBMODE:\s*(\d+)\s*$/m.exec(response); + return match === null ? undefined : Number(match[1]); + } + case 'quectel': { + const match = /^\s*\+QCFG:\s*"usbnet"\s*,\s*(\d+)\s*$/m.exec(response); + return match === null ? undefined : Number(match[1]); + } + case 'simcom': { + const match = /^\s*\+CUSBPIDSWITCH:\s*([0-9A-Fa-f]{4})\s*$/m.exec(response); + return match === null ? undefined : (match[1] ?? '').toUpperCase(); + } + case 'sierra': { + const match = /^\s*!USBCOMP:\s*(\d+)(?:\s*,.*)?$/m.exec(response); + return match === null ? undefined : Number(match[1]); + } + } +} + /** Derive controls exclusively from the device's current and enumerated response text. */ export function resolveRuntimeCompositionCapability( input: RuntimeCompositionResponse, diff --git a/docs/CATALOG-INGESTION.md b/docs/CATALOG-INGESTION.md index 07c0154..6e72505 100644 --- a/docs/CATALOG-INGESTION.md +++ b/docs/CATALOG-INGESTION.md @@ -203,6 +203,22 @@ provenance says exactly what it is. Every box is a human judgement a machine cannot tick. That is why the checklist exists and why the seam stops one step short of the commit. +## Runtime composition switching does not wait for promotion + +Catalog promotion is still the only path to the strongest composition postcondition, but +it is no longer the source of the provider's offered target set. For a vendor whose exact +READ, TEST, and SET forms are in the reviewed runtime registries, the ModemManager provider +asks the device for its current mode and enumerated modes. It offers a target only when the +same enumeration contains the current mode, so the represented vocabulary includes a return +path. A catalog miss on that interrogable device is not an `uncertified` suppression. + +The transition uses the catalog when an entry matches the selected exact SET command: +canonical mode plus `expectedDescriptors` remain the strongest, tier-1 proof. Without such +an entry, tier 2 requires a post-re-enumeration vendor READ to report the target. Tier 2 is +weaker because it proves the modem's reported setting but not the reviewed descriptor +composition. AT `OK` proves neither tier. This runtime fallback changes no promotion rule, +does not add entries, and does not apply to band certification. + --- ## Using it From 05e8941291bd989bf966e692fb27ccfb4b28205a Mon Sep 17 00:00:00 2001 From: Andres Cera Date: Sat, 22 Aug 2026 16:10:25 -0500 Subject: [PATCH 04/12] docs(adr): record the FM350 RNDIS bearer gap and the forward-port decision --- docs/adr/ADR-FM350-RNDIS-BEARER.md | 300 ++++++++++++++++++++++++++++ docs/adr/FM350-UPSTREAM-MR-DRAFT.md | 219 ++++++++++++++++++++ 2 files changed, 519 insertions(+) create mode 100644 docs/adr/ADR-FM350-RNDIS-BEARER.md create mode 100644 docs/adr/FM350-UPSTREAM-MR-DRAFT.md diff --git a/docs/adr/ADR-FM350-RNDIS-BEARER.md b/docs/adr/ADR-FM350-RNDIS-BEARER.md new file mode 100644 index 0000000..58228e0 --- /dev/null +++ b/docs/adr/ADR-FM350-RNDIS-BEARER.md @@ -0,0 +1,300 @@ +# ADR — FM350-GL RNDIS bearer gap, and the decision to forward-port the BELABOX plugin + +**Status:** PROPOSED. Not accepted, not landed. No quilt patch exists in `packaging/` as a +result of this record, and none may be added on its strength alone. +**Date:** 2026-08-22 +**Deciders:** modem-stack maintainers. Second-maintainer review is OUTSTANDING (see §7). +**Supersedes:** nothing. **Amends:** [`docs/FM350-DECISION.md`](../FM350-DECISION.md) by adding +the bearer-layer analysis that record deliberately left open. + +--- + +## 1. What this ADR is for + +[`POLICY.md`](../../POLICY.md) §1 forbids carrying any quilt patch in `packaging/` without +four things: an exact defect rationale, a statement of why a rebuild cannot close it, a filed +upstream merge request, and second-maintainer sign-off. This document is the rationale half of +that gate for exactly one candidate patch series: the seven BELABOX `fm350gl` commits that add +a Fibocom FM350-GL plugin to ModemManager. + +It records evidence and a plan. It authorizes no code. §7 is the honest status of each of the +four POLICY requirements, and two of them are not met. + +--- + +## 2. The defect, measured on the board + +The bench Fibocom FM350-GL registers on the network and attaches to the packet service, and +then cannot open a data session. Every bearer attempt fails identically: + +``` +MM: [modem10] simple connect state (6/10): register +MM: [modem10] simple connect state (7/10): wait to get packet service state attached +MM: [modem10] simple connect state (8/10): bearer +MM: [modem10] simple connect state (9/10): connect +MM: [modem10/bearer7] connection attempt #1 failed: 0,NONE +NM: modem-broadband[ttyUSB12]: failed to connect modem: Operation not supported: 0,NONE + +mmcli -b 7 -> connection error name: org.freedesktop.ModemManager1.Error.MobileEquipment.NotSupported + apn: internet.movistar.com.co · ip type: ipv4 +``` + +Everything upstream of the dial is healthy. `+CEREG: 0,1` confirms EPS registration, +`+COPS: 0,0,"Movistar",7` confirms E-UTRAN, MM reports `registered · home · packet service +attached · signal 60%`, and the APN resolved correctly. The RNDIS network interface +`enx000011121314` comes up `UP,LOWER_UP` with an IPv6 link-local address and **no IPv4**. + +Board: `ceralive2`, RK3588, kernel `7.1.7-ceralive-rk3588`, packaged ModemManager +`1.24.2-2~ceralive0.2.0`. + +Evidence: +[`session-amendment-fm350-no-connection.md`](#8-evidence-index) §3, and the modes/state dump +`test-results/modem-phase-b/65/mmcli-dump.txt:64-66`, which is where the coarse +`allowed: 2g, 3g, 4g, 5g; preferred: none` capability line lives. + +### 2.1 Why the dial fails — the mechanism, not the symptom + +The device enumerates over USB as `0e8d:7127`: ten interfaces, `rndis_host` ×2 plus `option` +×7 plus one unbound ADB interface, `bNumConfigurations = 1`. There is **no MBIM function and +no QMI function**, so ModemManager finds no `cdc_mbim` or `qmi_wwan` port, matches no vendor +plugin's id table, and falls back to `plugin: generic`. + +The generic broadband bearer's `dial_3gpp` in ModemManager 1.24.2 builds exactly one command +(`src/mm-broadband-bearer.c:565`): + +```c + command = g_strdup_printf ("ATD*99***%d#", cid); +``` + +That is a PPP-over-serial dial. The FM350-GL in an RNDIS composition does not implement it — +its data plane is the RNDIS network interface, and the context is brought up with +`+CGACT=1,` while addressing and DNS are read back with `+CGCONTRDP` / `+CGPADDR`. The +modem answers the `ATD` with a bare `0,NONE`, which MM surfaces as +`MobileEquipment.NotSupported`. + +So the failure is not a misconfiguration, a SIM problem, an APN problem, or a NetworkManager +problem. ModemManager is dialing a modem over a mechanism that modem does not have, because +nothing in the shipped source tree knows this device. + +--- + +## 3. Upstream 1.24 does not cover this device (POLICY §1.1 — "why a rebuild cannot") + +This is the load-bearing finding, and it is the answer to "just rebuild a newer +ModemManager." A newer ModemManager does not help, because upstream's FM350 support is for a +different bus, a different driver, and a different control protocol. + +**Upstream FM350 support is `src/plugins/mtk/`, and it is MBIM-over-t7xx-PCIe.** It arrived in +upstream commit `11a1720daffb01f71ff4f6bd3d762cc06013ad96` — *"mtk: add FM350 specific MBIM +implementation"*, Aleksander Morgado, 2023-11-06 — released in ModemManager 1.24.0, whose +`NEWS` entry reads: + +``` + ** mtk: new plugin with MBIM support for t7xx devices (eg FM350, L850, etc) +``` + +The device path into that code is **triple-gated**, and the bench unit fails all three gates. +From `src/plugins/mtk/mm-plugin-mtk.c` at tag `1.24.2`: + +```c + static const gchar *subsystems[] = { "wwan", "net", NULL }; + static const gchar *drivers[] = { "mtk_t7xx", NULL }; +``` + +```c +#if defined WITH_MBIM + if (mm_port_probe_list_has_mbim_port (probes)) { + /* FM350 support with Fibocom-specific changes */ + if (vendor == 0x14c3 && product == 0x4d75) { +``` + +| Gate | Upstream requires | Bench unit is | Reached? | +|------|-------------------|---------------|----------| +| Kernel driver | `mtk_t7xx` (PCIe WWAN) | `rndis_host` + `option` (USB) | no | +| Port probe | an MBIM port present | no MBIM function in the composition | no | +| Identity | PCI `0x14c3:0x4d75` | USB `0e8d:7127` | no | + +The mtk plugin declares no `MM_PLUGIN_ALLOWED_VENDOR_IDS` at all — it matches purely by PCIe +driver and subsystem, so the USB vendor id `0e8d` is not merely absent from a table, there is +no table for it to be absent from. `mtk_t7xx` is not loaded on this board and +`/sys/class/wwan/` does not exist. + +**There is no `fm350gl` plugin anywhere upstream.** A directory listing of `src/plugins/` at +tag `1.24.2` and on current `main` contains no `fm350gl`; a search of upstream history finds +no commit adding one. The FM350's USB/RNDIS composition is unsupported by ModemManager today +and by every released version of it. + +**Therefore a rebuild cannot close this gap.** Rebuilding 1.24.2, or 1.26 when it exists, or +`main`, produces the identical `ATD*99***1#` and the identical `0,NONE`. The missing code has +never been written upstream. That is precisely the condition POLICY §1.2.1 describes as a gap +a rebuild alone cannot close. + +### 3.1 The alternatives, and why each is closed + +Both non-patch escape routes were tested on hardware and both are closed. + +- **Switch the composition to MBIM so an existing plugin binds — CLOSED.** + `AT+GTUSBMODE=40` was sent on the real unit on 2026-08-19 with explicit authorization, and + it was reverted the same session. It is accepted, non-volatile, and applied only on + `AT+CFUN=1,1`. It works, and it does not help: mode 40 re-enumerates as `0e8d:7126` with + eight interfaces instead of ten, and it is **still RNDIS** — no MBIM, no QMI. The delta is + two fewer vendor COM ports and the ADB interface moving from slot 5 to slot 3. Downstream, + mode 40 is identical: `plugin: generic`, the same `MobileEquipment.NotSupported: 0,NONE`, + no IPv4, the same coarse mode line — plus a `NETDEV WATCHDOG: transmit queue 0 timed out` + regression the mode-41 composition does not produce. `AT+GTUSBMODE=?` answers `(40,41)`, so + the whole domain is enumerated and **both members are RNDIS**. There is no MBIM composition + to switch to on firmware `81600.0000.00.19.17.10`. The board was returned to mode 41 / + `0e8d:7127` and verified byte-for-byte at baseline. +- **A vendor "just dial the RNDIS session" AT escape hatch — ABSENT.** `+GTRNDIS`, + `+GTAUTOCONNECT`, `+GTIPPASSTHROUGH`, `+GTDATAMODE` and `+GTFLAGS` all answer + `+CME ERROR: 100` on this firmware. +- **A firmware update adding an MBIM composition — UNTESTED, and not a packaging decision.** + It is the only route that could make the device reachable by existing upstream code. It is + recorded here as an open option, not as a plan. + +### 3.2 The second, related gap this evidence characterises + +While the AT port was open, a read-only sweep found that the hardware **does** expose granular +RAT and band selection: + +``` +AT+GTACT? -> +GTACT: 4,3,,1,2,4,5,8,101,102,...,166 +AT+GTACT=? -> +GTACT: (1,2,4,10,14,16,17,20),(2,3,6),(2,3,6),(),(1,2,4,5,8),(101,...,171),(),(),(501,...,5079) +AT+WS46? -> 31 +AT+WS46=? -> (12,22,25,28,29,30,31) +``` + +ModemManager reports one flattened `allowed: 2g, 3g, 4g, 5g; preferred: none` combination and +`supported-bands: --` (`test-results/modem-phase-b/65/mmcli-dump.txt:64-66`) because the +`generic` plugin does not know `+GTACT`. This is a **plugin-coverage gap, not a hardware +limit**, and it has the same root cause as §2.1: no plugin claims the device. It is recorded +for completeness; the BELABOX series below does not address `+GTACT`, so closing it is +separate work. + +--- + +## 4. The candidate patch series — per-commit upstream-status table + +The BELABOX ModemManager fork (`github.com/BELABOX/modemmanager`) carries seven commits that +add and then fix an `fm350gl` plugin for exactly this composition. Its plugin matches +`{0x0e8d, 0x7126}` and `{0x0e8d, 0x7127}` on the `tty` + `net` subsystems — both of the bench +unit's observed identities — and its bearer replaces the `ATD` dial with `+CGACT=1,`, +reading addressing back with `+CGCONTRDP` / `+CGPADDR` and returning the RNDIS net port as the +data port. That is the mechanism §2.1 says is missing. + +The series is based on upstream `616df80418612fa9e0f78d34049767e118d60204` (2023-10-17, +pre-1.24), so every commit needs rebasing onto 1.24.2 rather than applying as-is. + +| # | SHA (full) | Subject | Files | Upstream status | Verdict | +|---|-----------|---------|-------|-----------------|---------| +| 1 | `da01610c46c581b0c6f2acd0ac50f5bba666efdf` | `FM350GL: backport FM350GL patch` | new `src/plugins/fm350gl/` (7 files, +1697), `meson.build`, `meson_options.txt`, `src/plugins/meson.build`, `src/plugins/mm-builtin-plugins.c` | **NOT UPSTREAM.** No `fm350gl` plugin exists at `1.24.2` or on `main`. The commit message cites upstream issue [#899](https://gitlab.freedesktop.org/mobile-broadband/ModemManager/-/issues/899) as the origin of the code; no merge request carrying it has landed. | **REQUIRED.** This is the whole fix. Everything else in the table is a bug fix on top of it. Largest forward-port risk: it adds a plugin to the build system, and 1.24's plugin build was reorganized after the base commit. | +| 2 | `b4377c5028a0de4435b86f7aad9114e9443d69e4` | `fm350gl: disable CPOL command that crashes the modem` | `src/plugins/fm350gl/77-mm-fm350gl.rules` (+3) | **MECHANISM IS UPSTREAM; THIS DATA ROW IS NOT.** `ID_MM_PREFERRED_NETWORKS_CPOL_DISABLED` is a first-class upstream udev tag, consumed at `src/mm-base-sim.c:1066` and already used by the huawei, sierra, simtech and telit rules files on `main`. No rule tags `0e8d:712[6-7]`. | **REQUIRED, and the most independently landable row.** It is three lines of device data using an upstream-blessed tag. It needs a rules file to live in, though, and upstream has none for this device — so in practice it lands with commit 1. | +| 3 | `419dc598d3f2c11f479dacdc2f6ef0e787e6ea3b` | `fm350gl: delay initialization to avoid crashing on AT+GCMR` | `src/plugins/fm350gl/mm-broadband-modem-fm350gl.c` (+40) | **NOT UPSTREAM** (depends on commit 1). | **REQUIRED but WEAKEST.** It is a fixed 4000 ms sleep before `load_current_capabilities`, chosen by the author as "2500 ms observed worst case, doubled out of caution." A hardcoded settle delay is the row most likely to draw upstream review objections, and it is the row a reviewer should scrutinise hardest. Dropping it risks crashing the modem at probe; keeping it costs four seconds on every FM350 enumeration. | +| 4 | `90bcd376405906d3a94b0c239689eef1a3899ed2` | `fm350gl: fix DNS parsing for IP4-only networks` | `src/plugins/fm350gl/mm-broadband-bearer-fm350gl.c` (+12/−32) | **NOT UPSTREAM** (depends on commit 1). | **REQUIRED.** The original code demanded ≥30 `+CGCONTRDP` fields and hard-failed an IPv4-only reply; this relaxes the floor to 7 and reads the IPv6 DNS pair only when present. The bench SIM is on an IPv4 APN (`internet.movistar.com.co`, `ip type: ipv4`), so without this the bearer would fail even after commit 1. | +| 5 | `9e2bc4992a251b02f75280a2d2b1020227d2bfe8` | `fm350gl: fix modem_set_current_modes_finish()` | `src/plugins/fm350gl/mm-broadband-modem-fm350gl.c` (+1/−1) | **NOT UPSTREAM** (depends on commit 1). | **REQUIRED.** One-line correctness fix in the same file. Fold into commit 1 when forward-porting; there is no reason to carry it separately. | +| 6 | `43e09a768e3855f3afb93e517902c1e0e25ed676` | `fm350gl: update +COPS handler to match nonstandard fm350gl format` | `src/mm-modem-helpers.c` (+8/−2) | **NOT UPSTREAM, AND STILL OPEN.** Verified directly: the regex at `1.24.2:src/mm-modem-helpers.c:1277` and at `main:src/mm-modem-helpers.c:1296` are byte-identical and neither tolerates the extra field. | **REQUIRED, and the ONLY row that is genuinely standalone.** It touches shared core code, not the plugin, so it can be offered upstream on its own merit and does not depend on commit 1. The FM350 emits an extra unknown value between the operator code and the access technology (`+COPS: (2,"","EE","23430","609C",7)`); the patch makes that group optional and shifts the access-tech capture from 5 to 6. Upstream has a test file for exactly this parser (`src/tests/test-modem-helpers.c`), so a test case is cheap and should accompany it. | +| 7 | `9716d38b6a81a79b47a16ea96c27164219de6739` | `fm350gl: fix load_current_modes_finish() for 4G-only mode` | `src/plugins/fm350gl/mm-broadband-modem-fm350gl.c` (+6) | **NOT UPSTREAM** (depends on commit 1). | **REQUIRED.** Same file as 3 and 5. Fold into commit 1 when forward-porting. | + +Independent verdict summary: seven commits, **zero already upstream**, one (`43e09a76`) +standalone-landable, one (`b4377c50`) using an upstream mechanism but with no upstream file to +land in, and five inseparable from the plugin itself. + +**Nothing in this table has been applied.** No `.patch` file, no `series` entry, no build +change. `packaging/ModemManager/debian/` has **no `patches/` directory at all** — verified +2026-08-22. (The one `.patch` file anywhere under `packaging/ModemManager/` is +`debian/tests/0001-Test-running-service-in-plugin-generic.patch`, an autopkgtest fixture that +arrived byte-identical from the pinned salsa packaging tag and is not a source patch.) + +--- + +## 5. Forward-port plan + +Nothing below happens until §7's gates are all green. + +1. **Rebase onto 1.24.2.** The series is based on pre-1.24 `616df804`. The plugin build system + is where breakage is expected: `src/plugins/meson.build` and + `src/plugins/mm-builtin-plugins.c` both changed upstream after the base commit. Squash + commits 3, 4, 5 and 7 into commit 1 — they are all fixes to files commit 1 introduces, and + a reviewer reading a plugin plus four of its own bug fixes is being asked to review noise. + Keep commits 2 and 6 separate: 2 is device data, 6 is a shared-core fix. +2. **Split the offer.** File `43e09a76` (the `+COPS` quirk) as its own upstream merge request + with a test case in `src/tests/test-modem-helpers.c`. It stands on its own, it is small, + and it is the row most likely to be accepted quickly. File the plugin as a second merge + request against upstream issue #899. +3. **Do not add a quilt patch until the upstream MR exists and has an outcome.** POLICY §2 is + upstream-contribution-first: the offer precedes the carry. If upstream declines or stalls, + POLICY §1's gate is what authorizes the local carry, and it needs all four requirements. +4. **When (and only when) the carry is authorized**, the patch lands as + `packaging/ModemManager/debian/patches/` entries with a `series` file, one patch per logical + change, each carrying a DEP-3 header naming the upstream MR URL and this ADR. That is the + first non-empty `series` in this repository's history, so it flips the "zero quilt patches" + claim in `packaging/README.md`, `AGENTS.md` and `POLICY.md` — all three need updating in the + same change under Rule A. +5. **Prove it on the board before claiming it.** The acceptance test is not "it builds." It is: + the FM350 binds the `fm350gl` plugin instead of `generic`, the bearer connects, and + `enx000011121314` carries a real IPv4 address and routes. Anything short of that is an + unproven patch, and this repository does not claim unproven things. +6. **Re-verify on every upstream bump.** A carried patch is a merge liability against + ModemManager's device database — that is exactly what `POLICY.md` §3 warns about. Each + `upstream-pins.yaml` bump must re-apply and re-test the series, and the series is retired + the moment upstream ships equivalent support. + +**Scope note.** The `+GTACT` granular-modes gap from §3.2 is NOT in this series and is not +closed by it. Do not let this ADR be read as covering it. + +--- + +## 6. Decision + +Record the gap, offer the fix upstream, and carry nothing yet. + +Concretely: the FM350-GL's USB/RNDIS composition is unsupported by ModemManager and cannot be +made supported by rebuilding, by a composition switch, or by a vendor AT command. The BELABOX +`fm350gl` series is the only known working fix, and the correct first move under POLICY §2 is +an upstream merge request, not a downstream patch. This ADR authorizes the upstream offer and +the forward-port work in §5 steps 1-2. It authorizes no change to `packaging/`. + +--- + +## 7. POLICY.md §1 gate — each requirement, answered + +`POLICY.md:11-25` names four requirements. Two are met by this document, one is blocked on +access, one is owner-gated. + +| # | POLICY requirement | Status | Evidence | +|---|--------------------|--------|----------| +| a | **Rationale** — the exact defect or gap the patch closes | **MET** | §2 (board-measured `MobileEquipment.NotSupported: Operation not supported: 0,NONE`, with the registration and packet-attach context that rules out every upstream-of-the-dial cause) and §2.1 (the `ATD*99***%d#` mechanism at `1.24.2:src/mm-broadband-bearer.c:565` against an RNDIS-only composition). | +| b | **Why a rebuild cannot** close it | **MET** | §3. Upstream FM350 support is the `mtk` plugin, gated on the `mtk_t7xx` PCIe driver AND an MBIM port AND PCI `0x14c3:0x4d75`; the bench unit clears none of the three. No `fm350gl` plugin exists at `1.24.2` or on `main`. §3.1: the composition switch was tested on hardware (`AT+GTUSBMODE=40`) and reverted — both members of the `(40,41)` domain are RNDIS, so there is no MBIM composition to reach. | +| c | **Upstream MR** filed | **NOT MET — DRAFTED, NOT FILED.** | The full merge-request content (title, description, commit list, test plan) is written and ready at [`FM350-UPSTREAM-MR-DRAFT.md`](FM350-UPSTREAM-MR-DRAFT.md). It has **not** been submitted to `gitlab.freedesktop.org/mobile-broadband/ModemManager` because this repository's tooling holds no GitLab credentials for that instance and the host is behind an interactive anti-bot challenge. **No MR URL exists, and none is claimed here.** Filing requires an owner with GitLab write access; the URL is recorded in this table and in the draft the moment it does. | +| d | **Review** — second-maintainer sign-off | **NOT MET — OWNER-GATED CHECKBOX.** | ☐ Second maintainer has reviewed this ADR on `POLICY.md`'s terms. This box is unchecked and is **not** satisfied by the existence of this document. Verifying it is the job of the downstream patch-series task (plan `modem-control-ui-parity` todo 16), which must not land a patch while it is unchecked. | + +**Two of four requirements are unmet, so the gate is CLOSED.** No patch may land. That is the +correct and expected state for this ADR at the moment it is written — the gate is designed to +be closed until a human opens it. + +--- + +## 8. Evidence index + +| Source | What it carries | +|--------|-----------------| +| `.omo/notepads/modem-phase-c-quality/evidence/session-amendment-fm350-no-connection.md` | The 2026-08-19 board session that isolated the bearer failure: the `0,NONE` transcript, the RNDIS-composition diagnosis, and the finding that it is downstream of a separate (since-fixed) CeraUI hot-plug defect. | +| `.omo/notepads/modem-phase-c-quality/evidence/session-amendment-fm350-gtusbmode40.md` | The 2026-08-19 `AT+GTUSBMODE=40` hardware trial and revert: 18 raw artifacts, the mode-40/41 descriptor diff, the NV-persistence proof, the `+GTACT`/`+WS46` sweep, and the closure inventory. | +| `test-results/modem-phase-b/65/` | RB-16 re-run bundle (repo-local, gitignored). `mmcli-dump.txt:64-66` is the coarse supported/current-modes evidence cited in §3.2. | +| [`docs/FM350-DECISION.md`](../FM350-DECISION.md) | The prior record: the MM-source audit, Citation 6's USB observation, the adapter-mediated Branch-A closure, and the three-gate ledger this ADR does not touch. | +| [`docs/COMPOSITION-EVIDENCE.md`](../COMPOSITION-EVIDENCE.md) | The non-mutating 2026-08-18 composition capture, including `AT+GTUSBMODE?` → `41` and `=?` → `(40,41)`. | +| `github.com/BELABOX/modemmanager` | The seven commits in §4, read directly from the repository at the SHAs listed. | +| `github.com/linux-mobile-broadband/ModemManager` | Upstream, read at tag `1.24.2` and on `main` for every upstream-status claim in §3 and §4. | + +--- + +## 9. What this ADR does NOT do + +- It does not add, stage, or authorize a quilt patch. `packaging/` is unchanged. +- It does not claim an upstream merge request exists. +- It does not claim second-maintainer review has happened. +- It does not change [`docs/FM350-DECISION.md`](../FM350-DECISION.md)'s three-gate ledger, its + documented-deferred PCIe conclusion, or its no-classifier-entry decision. +- It does not promote the FM350 in `docs/MODEM-SUPPORT-MATRIX.md`, and it adds no catalog or + certification claim of any kind. +- It does not address the `+GTACT` granular-modes gap (§3.2). diff --git a/docs/adr/FM350-UPSTREAM-MR-DRAFT.md b/docs/adr/FM350-UPSTREAM-MR-DRAFT.md new file mode 100644 index 0000000..32d86b9 --- /dev/null +++ b/docs/adr/FM350-UPSTREAM-MR-DRAFT.md @@ -0,0 +1,219 @@ +# Upstream merge-request draft — Fibocom FM350-GL over USB/RNDIS + +**Status: DRAFTED, NOT FILED.** + +This is the complete content of the merge request(s) that +[`ADR-FM350-RNDIS-BEARER.md`](ADR-FM350-RNDIS-BEARER.md) commits to offering upstream. It has +**not** been submitted. + +**Why not.** The target is +`https://gitlab.freedesktop.org/mobile-broadband/ModemManager`. This repository's tooling holds +no credentials for that GitLab instance, and the host sits behind an interactive proof-of-work +anti-bot challenge that a non-interactive client cannot clear. Filing needs an owner with +GitLab write access on freedesktop.org. + +**No merge-request URL exists.** None is recorded anywhere in this repository, and inventing one +would defeat the entire purpose of `POLICY.md` §1's upstream-MR requirement. When the MRs are +filed, fill in §5 below and the corresponding row of the ADR's §7 gate table in the same change. + +The offer is split into **two** merge requests, for the reason given in the ADR §5.2: one of the +seven commits is a shared-core fix that stands entirely on its own and should not be held +hostage to review of a 1700-line new plugin. + +--- + +## MR 1 — `mm-modem-helpers: accept the FM350-GL's non-standard +COPS=? field` + +**Target:** `mobile-broadband/ModemManager`, branch `main` +**Type:** bug fix, core +**Size:** one file, +8 / −2, plus a test case + +### Title + +``` +mm-modem-helpers: tolerate an extra field in +COPS=? operator entries +``` + +### Description + +``` +Some Fibocom FM350-GL firmware emits an additional, undocumented value between +the operator numeric code and the access technology in +COPS=? entries: + + +COPS: (2,"","EE","23430","609C",7) + +The current regex in mm_3gpp_parse_cops_test_response() expects the access +technology immediately after the operator code, so it does not match these +entries at all. The affected operator is dropped from the scan result, and +where every entry carries the extra field the whole scan comes back empty. + +Make the extra group optional and move the access-technology capture from +match 5 to match 6. Standard responses are unaffected: the added group is +optional, so an entry without the extra field matches exactly as before. + +Observed on FM350-GL firmware 81600.0000.00.19.17.10. + +Originally from the BELABOX ModemManager fork, commit 43e09a76. +``` + +### The change + +`src/mm-modem-helpers.c`, in `mm_3gpp_parse_cops_test_response()`: + +```diff +- r = g_regex_new ("\\((\\d),\"([^\"\\)]*)\",([^,\\)]*),([^,\\)]*)[\\)]?,(\\d+)\\)", G_REGEX_UNGREEDY, 0, NULL); ++ /* Quirk: at least some versions of FM350-GL include an additional (unknown) ++ * value between the operator code and the access tech: ++ * ++ * +COPS: (2,"","EE","23430","609C",7) ++ */ ++ ++ r = g_regex_new ("\\((\\d),\"([^\"\\)]*)\",([^,\\)]*),([^,\\)]*)[\\)]?,([^,\\)]*,)?(\\d+)\\)", G_REGEX_UNGREEDY, 0, NULL); +``` + +```diff +- mm_get_uint_from_match_info (match_info, 5, &act_value); ++ mm_get_uint_from_match_info (match_info, 6, &act_value); +``` + +### Test plan + +Add a case to `src/tests/test-modem-helpers.c` alongside the existing +`test_cops_response_*` entries, covering the FM350-GL form +`+COPS: (2,"","EE","23430","609C",7)` and asserting the operator code and an access +technology of E-UTRAN. The existing standard-form cases in that file are the regression +guard for the optional group; they must stay green unchanged. + +### Provenance + +Upstream base verified 2026-08-22: the regex is byte-identical at tag `1.24.2` +(`src/mm-modem-helpers.c:1277`) and on `main` (`src/mm-modem-helpers.c:1296`). The gap is +still open upstream. + +--- + +## MR 2 — `fm350gl: new plugin for the FM350-GL's USB/RNDIS composition` + +**Target:** `mobile-broadband/ModemManager`, branch `main` +**Type:** new plugin +**Relates to:** issue +[#899](https://gitlab.freedesktop.org/mobile-broadband/ModemManager/-/issues/899) +**Size:** new `src/plugins/fm350gl/` (7 files, ~1700 lines) plus build wiring + +### Title + +``` +fm350gl: add a plugin for the FM350-GL in its USB/RNDIS composition +``` + +### Description + +``` +The Fibocom FM350-GL is already supported by the mtk plugin, but only over +PCIe: that path is gated on the mtk_t7xx driver, the wwan/net subsystems, an +MBIM port, and the PCI id 14c3:4d75. + +The same module also ships in USB compositions, where it enumerates as +0e8d:7126 or 0e8d:7127 with rndis_host plus option ports and no MBIM or QMI +function at all. ModemManager falls back to the generic plugin, which dials +with ATD*99***# — a mechanism this composition does not implement. The +modem registers, attaches to the packet service, and then every bearer +attempt fails: + + [bearer] connection attempt #1 failed: 0,NONE + failed to connect modem: Operation not supported: 0,NONE + +with the RNDIS network interface up and carrying no IPv4 address. + +This adds an fm350gl plugin matching 0e8d:7126 and 0e8d:7127 on the tty and +net subsystems. Its bearer activates the context with +CGACT=1,, reads +addressing and DNS back with +CGCONTRDP and +CGPADDR, and returns the RNDIS +net port as the data port, which is what this composition actually wants. + +Also included: + + * A udev rule tagging 0e8d:712[6-7] with + ID_MM_PREFERRED_NETWORKS_CPOL_DISABLED. The existing upstream tag; the + FM350-GL crashes on the CPOL-based preferred-network read. + + * A delay before load_current_capabilities. The module crashes if it + receives AT+CGMR too soon after enumeration. The settle time varies by + host; 2500 ms was the worst observed, and the delay is set to 4000 ms. + Feedback on a better mechanism than a fixed delay is welcome — see the + open question below. + + * IPv4-only +CGCONTRDP replies. The initial parser required at least 30 + comma-separated fields and hard-failed shorter replies, so an IPv4-only + APN never connected. The floor is now 7 fields, with the IPv6 DNS pair + read only when present. + +The code originates in the BELABOX ModemManager fork, forward-ported from +1.23-era ModemManager onto current main. Original commits: + + da01610c FM350GL: backport FM350GL patch + b4377c50 fm350gl: disable CPOL command that crashes the modem + 419dc598 fm350gl: delay initialization to avoid crashing on AT+GCMR + 90bcd376 fm350gl: fix DNS parsing for IP4-only networks + 9e2bc499 fm350gl: fix modem_set_current_modes_finish() + 9716d38b fm350gl: fix load_current_modes_finish() for 4G-only mode + +Tested on a Fibocom FM350-GL, firmware 81600.0000.00.19.17.10, in both +GTUSBMODE compositions (0e8d:7126 and 0e8d:7127), on an RK3588 board running +Debian bookworm. +``` + +### Commits in this MR + +| Commit | Origin SHA | Content | +|--------|-----------|---------| +| 1 | `da01610c` squashed with `419dc598`, `90bcd376`, `9e2bc499`, `9716d38b` | The plugin, with its own four bug fixes folded in. A reviewer should read a working plugin, not a plugin plus four of its own regressions. | +| 2 | `b4377c50` | The `ID_MM_PREFERRED_NETWORKS_CPOL_DISABLED` udev row, kept separate because it is device data using an existing upstream mechanism. | + +Full per-commit provenance, file lists and forward-port verdicts: +[`ADR-FM350-RNDIS-BEARER.md`](ADR-FM350-RNDIS-BEARER.md) §4. + +### Open question for reviewers + +The 4000 ms pre-capability delay is the weakest part of this series and we would rather not +carry it in this form. It exists because the module crashes on an early `AT+CGMR`. If there is +a preferred upstream pattern — a retry with backoff, a readiness URC to wait on, or a probe +delay expressible through the plugin properties — we will rework it. It is called out here +rather than buried because it is the row most likely to be objected to, and we agree with the +objection. + +### Test plan + +- FM350-GL in composition `0e8d:7127` (10 interfaces) and `0e8d:7126` (8 interfaces): the + plugin binds instead of `generic`, a bearer connects on an IPv4-only APN, and the RNDIS + interface carries a routable IPv4 address. +- The CPOL rule: no modem crash during SIM preferred-network initialization. +- The probe delay: repeated replug cycles with no `AT+CGMR` crash. +- No regression for PCIe FM350 units — the mtk plugin's gating is untouched, and this plugin + matches only USB product ids. + +--- + +## 3. What has to be true before either MR is filed + +1. Both series rebased onto current upstream `main` and building clean. The BELABOX base is + `616df804` (2023-10-17); `src/plugins/meson.build` and `src/plugins/mm-builtin-plugins.c` + have both moved since. +2. MR 1's test case written and passing. +3. The hardware test plan above actually run on the bench board, with the transcript captured. + Filing an MR whose "Tested on" paragraph is aspirational is worse than filing nothing. + +## 4. What must NOT happen before these are filed + +Per `POLICY.md` §2 (upstream-contribution-first) and §1 (the no-fork gate): **no quilt patch +lands in `packaging/ModemManager/debian/patches/`.** The offer precedes the carry. That +directory does not exist today, and it stays that way. + +## 5. Filed-MR record — fill in on submission + +| MR | URL | Filed on | Filed by | Outcome | +|----|-----|----------|----------|---------| +| MR 1 (`+COPS` quirk) | _not filed_ | — | — | — | +| MR 2 (`fm350gl` plugin) | _not filed_ | — | — | — | + +Filling a row here also requires updating row (c) of the gate table in +[`ADR-FM350-RNDIS-BEARER.md`](ADR-FM350-RNDIS-BEARER.md) §7, in the same change. From 09f1e5ad3042b2668039e4d8740c94b2d291ffc4 Mon Sep 17 00:00:00 2001 From: Andres Cera Date: Sat, 22 Aug 2026 16:32:06 -0500 Subject: [PATCH 05/12] feat(ufi): add read-only HIMI descriptor capture tooling and evidence schema MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bench tooling for the `05c6:9091` UFI stick: a capture script that writes a redacted descriptor evidence bundle, a schema/classifier module that says what a bundle is and what its interfaces are, and a gate that proves neither can change the device. No bundle content is captured here — that is the hardware drill's job, and no device is needed to land this. The tooling lives in `control/scripts/`, not in the provider directory. The provider's `no-write-path.test.ts` asserts that directory's listing as an exact literal, and bench tooling is not part of the published package (`files: ["dist"]`) — `mf79u-diagnose.sh` is the standing precedent. The module still imports `classifyUfiDiagEvidence`, so the bench DIAG rule cannot drift from the shipped one. Redaction happens at capture time and the rules have exactly one implementation — the script's `--redact-filter` mode, which the test executes rather than re-expresses. Two layers mirroring `redact.ts`: key-based masking of serial, IMEI, IMSI, ICCID, MSISDN, token and password fields, plus a MAC and 14+-digit backstop. Nothing in a USB descriptor is 14 digits long, so class triples, `bcdDevice`, driver names and product strings survive verbatim. The staged bundle is swept before publication and a surviving identifier destroys it; there is no override. Staging is moved into place as a unit, so a published path either holds a complete bundle or does not exist, and a host with no matching device answers `device-not-present` having written nothing. The descriptor triple and the driver binding stay two separate facts. Upstream matches `05c6:9091` in `qmi_wwan.c` under an annotation naming a different device and QCSuper documents the id on another one again, so only this unit's own descriptor answers — and only the captured binding says who claimed it. --- AGENTS.md | 34 ++ README.md | 10 + control/README.md | 5 +- control/scripts/ufi-himi-capture.sh | 347 +++++++++++++++++++ control/scripts/ufi-himi-evidence.test.ts | 391 ++++++++++++++++++++++ control/scripts/ufi-himi-evidence.ts | 308 +++++++++++++++++ docs/UFI-DIAG-PROBE.md | 165 ++++++++- 7 files changed, 1246 insertions(+), 14 deletions(-) create mode 100755 control/scripts/ufi-himi-capture.sh create mode 100644 control/scripts/ufi-himi-evidence.test.ts create mode 100644 control/scripts/ufi-himi-evidence.ts diff --git a/AGENTS.md b/AGENTS.md index 79820b8..91c1dcf 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1568,3 +1568,37 @@ the cached session and surfaces as an honest `auth-expired` reading rather than loop. The admin password is EPHEMERAL BENCH INPUT (`UFI_BENCH_PASSWORD`), injected for a supervised run only, and `credential-fence.test.ts` scans tracked and intended-untracked files for it plus its base64/SHA-256 derivatives. + +### Bench descriptor capture — tooling and schema, NOT a captured bundle + +`control/scripts/ufi-himi-capture.sh` + `control/scripts/ufi-himi-evidence.ts` are the +read-only evidence-capture path for `05c6:9091`, and they live in `control/scripts/` +rather than in the provider directory on purpose: bench tooling is not published +(`files: ["dist"]`), and `no-write-path.test.ts` enumerates the provider directory +exactly, so a file added there would be a change to that gate. **No bundle CONTENT is +committed by this work** — the hardware drill that produces one has not run. + +- **The bundle is `manifest.json` + five capture files + one credential-gated HIMI file**, + staged in a temp directory and moved into place as a unit, so a published path either + holds a complete bundle or does not exist. With no matching device the script answers + `device-not-present` on stdout and exits 3 having written nothing. +- **Per-step status is five-valued, not a boolean** — `captured` / `empty` / + `tool-unavailable` / `unreachable` / `skipped-no-credential`. The bench image ships no + `usbutils` (RB-9), so "no `lsusb` here" and "no device there" must not collapse. +- **Redaction happens at capture time and the rules have ONE implementation** — the + script's `--redact-filter` mode, which the test EXECUTES rather than re-expressing. Two + layers mirroring `redact.ts`: key-based masking plus a MAC/14+-digit backstop. The + staged bundle is then swept and a surviving identifier DESTROYS it (exit 4); there is no + override flag. `sweepUfiEvidenceText` is the tested twin of the script's own sweep, and + two checkers that disagree fail the capture. +- **The descriptor triple and the driver binding are two facts and are never merged.** + `classifyUfiInterfaceRole` answers `diag` only through `classifyUfiDiagEvidence` itself, + so the bench analysis cannot drift from the shipped rule; `ff/ff/*` outside `30` stays + `vendor-specific` and the CAPTURED binding says who claimed it. Upstream matching + `05c6:9091` in `qmi_wwan.c` under an unrelated annotation, and QCSuper documenting the + same id on a different device, are evidence about neither — only this unit's descriptor + is. +- **A static gate scans both files** for `usb_modeswitch`, `setprop`, a shell-transport + invocation, an emergency-download tool, `AT!`, an uppercase AT write form and a QMI + write, each with a non-vacuity control, and asserts every command literal in them is a + member of `UFI_COMMANDS`. Procedure: [`docs/UFI-DIAG-PROBE.md`](docs/UFI-DIAG-PROBE.md). diff --git a/README.md b/README.md index c848d6d..d6961f5 100644 --- a/README.md +++ b/README.md @@ -108,6 +108,16 @@ refusal before any transport call. `05c6:9024` proves an RNDIS+ADB composition a production access stays prohibited. The supervised, read-only, bench-only DIAG info probe is documented in [`docs/UFI-DIAG-PROBE.md`](docs/UFI-DIAG-PROBE.md). +That document also carries the **read-only descriptor capture**: +`control/scripts/ufi-himi-capture.sh` writes a redacted evidence bundle (full `lsusb -v` +descriptors, `usb-devices`, udev properties, per-interface driver bindings, `/sys` +composition, and — with an ephemeral bench password — the HIMI `getproduceinfo` / +`getsysinfo` identity), and `control/scripts/ufi-himi-evidence.ts` carries the bundle +schema, the per-interface role classifier, and an independent redaction sweep. With no +matching device attached the script answers `device-not-present` and writes nothing rather +than leaving a partial bundle. No captured bundle is committed — the hardware drill that +produces one has not run. + ## Radio capability truth + SIM evidence (Todo 28) `control/src/radio/` carries ModemManager's mode and band answers to a consumer without diff --git a/control/README.md b/control/README.md index 02f0aa4..fa584aa 100644 --- a/control/README.md +++ b/control/README.md @@ -111,7 +111,10 @@ through `OperationEngine` are refused before execution too. `05c6:9024` is evidence of an RNDIS+ADB composition, not a permission. `05c6:9091` is a firmware-chosen product id and is **not** proof of DIAG — only an interface descriptor is, and production access stays `prohibited` regardless. The supervised, read-only, bench-only -probe is documented in [`../docs/UFI-DIAG-PROBE.md`](../docs/UFI-DIAG-PROBE.md). +probe is documented in [`../docs/UFI-DIAG-PROBE.md`](../docs/UFI-DIAG-PROBE.md), together +with the read-only descriptor capture (`scripts/ufi-himi-capture.sh`) and its bundle +schema, interface-role classifier and redaction sweep (`scripts/ufi-himi-evidence.ts`). +Neither ships in the package: `files: ["dist"]`, and bench tooling is not a public surface. ### NetworkManager adapter — saved vs applied diff --git a/control/scripts/ufi-himi-capture.sh b/control/scripts/ufi-himi-capture.sh new file mode 100755 index 0000000..b57163b --- /dev/null +++ b/control/scripts/ufi-himi-capture.sh @@ -0,0 +1,347 @@ +#!/usr/bin/env bash +# +# Read-only USB descriptor + HIMI identity capture for the Qualcomm UFI stick. +# BENCH ONLY. docs/UFI-DIAG-PROBE.md carries the analysis procedure this bundle feeds. +# +# READ-ONLY BY CONSTRUCTION, not by policy. Every command below reads: the sysfs sweep, +# `lsusb -v`, `usb-devices`, `udevadm info -q property`, and the seven-member HIMI `get*` +# vocabulary the shipped provider is itself limited to. Nothing here rebinds a driver, +# cycles an interface, changes a USB composition, opens a diagnostic channel, or sends a +# vendor command of any kind. `control/scripts/ufi-himi-evidence.test.ts` scans this file +# for the constructs any of that would need. +# +# THE BUNDLE IS REDACTED AT CAPTURE TIME, and the rules below are the ONE implementation +# of that redaction — `--redact-filter` exists so a test can execute these exact rules +# rather than re-express them and drift. Every captured stream is filtered before it +# reaches the staging directory, and the whole staged bundle is swept afterwards; a +# surviving identifier deletes the staging directory instead of publishing it. +# +set -euo pipefail + +USB_ID="${UFI_USB_ID:-05c6:9091}" +HIMI_URL="${UFI_ADMIN_URL:-http://192.168.0.1}" +HIMI_INTERFACE="${UFI_INTERFACE:-usb0}" +HIMI_USER="${UFI_ADMIN_USER:-admin}" +MARKER='[redacted]' +SCHEMA_VERSION=1 + +usage() { + cat <<'EOF' +ufi-himi-capture.sh — read-only descriptor + HIMI identity capture (bench only) + + (no argument) capture a bundle for $UFI_USB_ID (default 05c6:9091) + --redact-filter stdin -> stdout through the capture-time redaction rules + --sweep scan a written file or directory for surviving identifiers + --help this text + +Environment: + UFI_USB_ID vendor:product to capture (default 05c6:9091) + UFI_CAPTURE_DIR bundle output directory (default test-results/...) + UFI_BENCH_PASSWORD ephemeral bench admin password; absent -> HIMI step is skipped + UFI_ADMIN_URL HIMI base URL (default http://192.168.0.1) + UFI_INTERFACE interface to bind HIMI requests to (default usb0) + UFI_ADMIN_USER HIMI account name (default admin) + +Exit codes: 0 complete · 2 usage · 3 device-not-present · 4 redaction sweep found something +EOF +} + +# ── Redaction ──────────────────────────────────────────────────────────────────────── +# +# Two layers, mirroring `control/src/redact.ts`: a KEY-based layer masking the value of a +# field whose name says what it holds, and a shape-based backstop for a long digit run +# wherever it appears. A subscriber identifier is 15 digits (IMEI, IMSI) or 19-20 +# (ICCID); nothing in a USB descriptor is 14 digits long, so the backstop costs no +# descriptor fidelity. Interface numbers, class triples, `bcdDevice`, driver names and +# product strings all survive verbatim — they are the evidence. +redact_filter() { + sed -E \ + -e "s/^([[:space:]]*iSerial[[:space:]]+[0-9]+[[:space:]]+).+$/\\1${MARKER}/" \ + -e "s/^(S:[[:space:]]*SerialNumber=).+$/\\1${MARKER}/" \ + -e "s/^((ID_SERIAL|ID_SERIAL_SHORT|ID_USB_SERIAL|ID_USB_SERIAL_SHORT|ID_SERIAL_ID|ID_NET_NAME_MAC)=).+$/\\1${MARKER}/" \ + -e "s/^([[:space:]]*(serial|serialnumber)[[:space:]]*[:=][[:space:]]*).+$/\\1${MARKER}/I" \ + -e "s/(\"[A-Za-z0-9_]*(serial|imei|imsi|iccid|msisdn|token|session|password|passwd)[A-Za-z0-9_]*\"[[:space:]]*:[[:space:]]*)\"[^\"]*\"/\\1\"${MARKER}\"/Ig" \ + -e "s/(\"(sn|esn|meid|simnumber|simid)\"[[:space:]]*:[[:space:]]*)\"[^\"]*\"/\\1\"${MARKER}\"/Ig" \ + -e "s/(\"[A-Za-z0-9_]*(serial|imei|imsi|iccid|msisdn|token|session|password|passwd)[A-Za-z0-9_]*\"[[:space:]]*:[[:space:]]*)[^\",}[:space:]]+/\\1\"${MARKER}\"/Ig" \ + -e "s/\\b(imei|imsi|iccid|msisdn|meid)\\b([[:space:]]*[:=][[:space:]]*)[A-Za-z0-9._-]+/\\1\\2${MARKER}/Ig" \ + -e "s/\\b([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}\\b/${MARKER}/g" \ + -e "s/[0-9]{14,}/${MARKER}/g" +} + +# The independent second check. It never prints the offending value — only the file, the +# line number and the fact that a rule fired — because a leak report quoting the leak IS +# the leak. `sweepUfiEvidenceText` in ufi-himi-evidence.ts is its tested twin; two +# checkers that disagree fail the capture, which is the direction to fail in. +sweep_file() { + grep -nHE \ + -e '[0-9]{14,}' \ + -e 'iSerial[[:space:]]+[0-9]+[[:space:]]+[^[:space:][]' \ + -e 'SerialNumber=[^[:space:][]' \ + -e '(ID_SERIAL|ID_SERIAL_SHORT|ID_USB_SERIAL|ID_USB_SERIAL_SHORT|ID_SERIAL_ID|ID_NET_NAME_MAC)=[^[:space:][]' \ + -e '([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}' \ + -e '"[A-Za-z0-9_]*([Ss][Ee][Rr][Ii][Aa][Ll]|[Ii][Mm][EeSs][Ii]|[Ii][Cc][Cc][Ii][Dd]|[Mm][Ss][Ii][Ss][Dd][Nn])[A-Za-z0-9_]*"[[:space:]]*:[[:space:]]*"?[^"[:space:][]' \ + -- "$1" 2>/dev/null | + cut -d: -f1,2 || true +} + +sweep_path() { + local target="$1" file hits findings=0 + if [ -d "$target" ]; then + while IFS= read -r file; do + hits="$(sweep_file "$file")" + if [ -n "$hits" ]; then + printf '%s\n' "$hits" + findings=1 + fi + done < <(find "$target" -type f | sort) + else + hits="$(sweep_file "$target")" + if [ -n "$hits" ]; then + printf '%s\n' "$hits" + findings=1 + fi + fi + return "$findings" +} + +# ── Presence ───────────────────────────────────────────────────────────────────────── +# +# Answered from sysfs, never from `lsusb`: the presence question must not depend on a +# package the bench image does not ship (docs/BENCH.md RB-9 records that `usbutils` is +# absent from it by default). A missing tool is a missing tool, not a missing device. +sys_devices() { + local vendor="${USB_ID%%:*}" product="${USB_ID##*:}" dev + for dev in /sys/bus/usb/devices/*; do + if [ ! -r "$dev/idVendor" ] || [ ! -r "$dev/idProduct" ]; then continue; fi + [ "$(cat "$dev/idVendor")" = "$vendor" ] || continue + [ "$(cat "$dev/idProduct")" = "$product" ] || continue + printf '%s\n' "$dev" + done +} + +read_attr() { + cat "$1" 2>/dev/null || printf 'unreadable' +} + +devices_json() { + local dev separator='' out='[' + for dev in "${DEVICES[@]}"; do + out="${out}${separator}\"$(basename "$dev")\"" + separator=',' + done + printf '%s]' "$out" +} + +# ── Capture steps ──────────────────────────────────────────────────────────────────── +capture_sys_composition() { + local dev intf + for dev in "${DEVICES[@]}"; do + printf '=== device %s ===\n' "$(basename "$dev")" + printf 'idVendor=%s idProduct=%s bcdDevice=%s\n' \ + "$(read_attr "$dev/idVendor")" "$(read_attr "$dev/idProduct")" \ + "$(read_attr "$dev/bcdDevice")" + printf 'bDeviceClass=%s bDeviceSubClass=%s bDeviceProtocol=%s bNumInterfaces=%s\n' \ + "$(read_attr "$dev/bDeviceClass")" "$(read_attr "$dev/bDeviceSubClass")" \ + "$(read_attr "$dev/bDeviceProtocol")" "$(read_attr "$dev/bNumInterfaces")" + printf 'bNumConfigurations=%s bConfigurationValue=%s speed=%s\n' \ + "$(read_attr "$dev/bNumConfigurations")" \ + "$(read_attr "$dev/bConfigurationValue")" "$(read_attr "$dev/speed")" + printf 'manufacturer=%s product=%s\n' \ + "$(read_attr "$dev/manufacturer")" "$(read_attr "$dev/product")" + printf 'serial=%s\n' "$(read_attr "$dev/serial")" + for intf in "$dev":*; do + [ -d "$intf" ] || continue + printf -- '--- interface %s ---\n' "$(basename "$intf")" + printf 'bInterfaceNumber=%s bInterfaceClass=%s bInterfaceSubClass=%s bInterfaceProtocol=%s bNumEndpoints=%s\n' \ + "$(read_attr "$intf/bInterfaceNumber")" "$(read_attr "$intf/bInterfaceClass")" \ + "$(read_attr "$intf/bInterfaceSubClass")" \ + "$(read_attr "$intf/bInterfaceProtocol")" "$(read_attr "$intf/bNumEndpoints")" + printf 'children=%s\n' \ + "$(find "$intf" -mindepth 1 -maxdepth 1 -printf '%f ' 2>/dev/null)" + done + done +} + +# The binding is CAPTURED, never inferred. Which driver claims an interface is a fact +# about this kernel on this board, and it is the second half of every classification line +# in docs/UFI-DIAG-PROBE.md: the descriptor triple says what an interface IS, the binding +# says who took it. Upstream matching an id in a driver table is not evidence about THIS +# unit, which is why the two halves are recorded separately and never merged. +capture_driver_bindings() { + local dev intf driver + for dev in "${DEVICES[@]}"; do + for intf in "$dev":*; do + [ -d "$intf" ] || continue + driver='unbound' + [ -L "$intf/driver" ] && driver="$(basename "$(readlink -f "$intf/driver")")" + printf '%s class=%s subclass=%s protocol=%s driver=%s\n' \ + "$(basename "$intf")" "$(read_attr "$intf/bInterfaceClass")" \ + "$(read_attr "$intf/bInterfaceSubClass")" \ + "$(read_attr "$intf/bInterfaceProtocol")" "$driver" + done + done +} + +capture_udev_properties() { + local dev intf + for dev in "${DEVICES[@]}"; do + printf '=== device %s ===\n' "$(basename "$dev")" + udevadm info -q property -p "$dev" 2>/dev/null || printf 'udevadm-query-failed\n' + for intf in "$dev":*; do + [ -d "$intf" ] || continue + printf -- '--- interface %s ---\n' "$(basename "$intf")" + udevadm info -q property -p "$intf" 2>/dev/null || printf 'udevadm-query-failed\n' + done + done +} + +# The HIMI half. Only the frozen vocabulary the shipped provider is itself limited to: +# one `login` to open a session, then two `get*` reads. A `SessionOut` reply ends the +# step — the login is never retried in a loop. +himi_post() { + curl --silent --show-error --max-time 10 --no-location \ + --interface "$HIMI_INTERFACE" \ + --header 'Content-Type: application/json;charset=UTF-8' \ + "$@" \ + "${HIMI_URL}/himiapi/json" +} + +capture_himi_identity() { + local login_reply session produce sysinfo + login_reply="$(himi_post --data \ + "{\"cmdid\":\"login\",\"username\":\"${HIMI_USER}\",\"password\":\"${UFI_BENCH_PASSWORD}\"}")" || + return 1 + case "$login_reply" in + *SessionOut*) return 1 ;; + esac + session="$(printf '%s' "$login_reply" | jq -er '.session // .sessionid // .token // empty')" || return 1 + produce="$(himi_post --header "Authorization: ${session}" --data '{"cmdid":"getproduceinfo"}')" || return 1 + sysinfo="$(himi_post --header "Authorization: ${session}" --data '{"cmdid":"getsysinfo"}')" || return 1 + # The login reply is deliberately NOT written: it carries the session material, and a + # bundle is a thing that gets pasted into a review comment. + printf '{"getproduceinfo":%s,"getsysinfo":%s}\n' "$produce" "$sysinfo" | jq -S . +} + +main_capture() { + local out_dir staging stamp findings + local lsusb_status='captured' usb_devices_status='captured' + local udev_status='captured' himi_status='skipped-no-credential' + stamp="$(date -u +%Y%m%dT%H%M%SZ)" + out_dir="${UFI_CAPTURE_DIR:-test-results/ufi-himi-descriptor/${USB_ID//:/-}-${stamp}}" + + mapfile -t DEVICES < <(sys_devices) + if [ "${#DEVICES[@]}" -eq 0 ]; then + printf '%s\n' 'device-not-present' + printf 'no USB device matching %s is attached; no bundle was written\n' "$USB_ID" >&2 + exit 3 + fi + + # Staged, then moved into place as a unit. A capture that dies halfway leaves the + # staging directory behind and NOTHING at the published path, so a partial bundle can + # never be mistaken for a complete one. + staging="$(mktemp -d)" + chmod 700 "$staging" + trap 'rm -rf "$staging"' EXIT + + if command -v lsusb >/dev/null 2>&1; then + lsusb -v -d "${USB_ID%%:*}:" 2>/dev/null | redact_filter >"$staging/lsusb-verbose.txt" || true + [ -s "$staging/lsusb-verbose.txt" ] || lsusb_status='empty' + else + lsusb_status='tool-unavailable' + printf 'lsusb-unavailable\n' >"$staging/lsusb-verbose.txt" + fi + + if command -v usb-devices >/dev/null 2>&1; then + usb-devices 2>/dev/null | redact_filter >"$staging/usb-devices.txt" || true + [ -s "$staging/usb-devices.txt" ] || usb_devices_status='empty' + else + usb_devices_status='tool-unavailable' + printf 'usb-devices-unavailable\n' >"$staging/usb-devices.txt" + fi + + if command -v udevadm >/dev/null 2>&1; then + capture_udev_properties | redact_filter >"$staging/udev-properties.txt" + else + udev_status='tool-unavailable' + printf 'udevadm-unavailable\n' >"$staging/udev-properties.txt" + fi + + capture_driver_bindings | redact_filter >"$staging/driver-bindings.txt" + capture_sys_composition | redact_filter >"$staging/sys-composition.txt" + + if [ -n "${UFI_BENCH_PASSWORD:-}" ]; then + if ! command -v jq >/dev/null 2>&1; then + himi_status='tool-unavailable' + elif capture_himi_identity | redact_filter >"$staging/himi-identity.json"; then + himi_status='captured' + else + himi_status='unreachable' + rm -f "$staging/himi-identity.json" + fi + fi + + cat >"$staging/manifest.json" <&2 + printf '%s\n' "$findings" | sed "s#^${staging}/##" >&2 + exit 4 + fi + + mkdir -p "$(dirname "$out_dir")" + mv "$staging" "$out_dir" + chmod 755 "$out_dir" + trap - EXIT + printf '%s\n' 'capture-complete' + printf '%s\n' "$out_dir" +} + +case "${1:-}" in +--help | -h) + usage + ;; +--redact-filter) + redact_filter + ;; +--sweep) + if [ -z "${2:-}" ]; then + printf 'usage: ufi-himi-capture.sh --sweep \n' >&2 + exit 2 + fi + if sweep_path "$2"; then + printf '%s\n' 'sweep-clean' + else + printf '%s\n' 'sweep-findings' >&2 + exit 4 + fi + ;; +'') + main_capture + ;; +*) + printf 'unknown argument: %s\n' "$1" >&2 + exit 2 + ;; +esac diff --git a/control/scripts/ufi-himi-evidence.test.ts b/control/scripts/ufi-himi-evidence.test.ts new file mode 100644 index 0000000..19eb4e7 --- /dev/null +++ b/control/scripts/ufi-himi-evidence.test.ts @@ -0,0 +1,391 @@ +/** + * The gate on the bench capture tooling. + * + * Three separate things are proven here, and they fail for different reasons: + * + * 1. STATIC — neither the capture script nor this schema module contains a construct + * that could change the device's state. Every detector has a non-vacuity control, + * so a broken regex fails the suite instead of passing it silently. + * 2. BEHAVIOURAL — the capture script is EXECUTED. On a host with no UFI attached it + * answers `device-not-present` and writes nothing; its redaction rules are run over + * a synthetic capture carrying every identifier class; and its own leak sweep is + * shown to fire on the unredacted input, so a green sweep is not a vacuous one. + * 3. ANALYTICAL — the interface classifier answers each descriptor triple, and refuses + * to answer `diag` for anything but the triple `qualcomm-evidence.ts` defines. + * + * The redaction rules live in the script and ONLY in the script. This file executes them + * rather than re-expressing them, because a second copy is a second thing to drift. + */ +import { describe, expect, test } from 'bun:test'; +import { mkdtemp, readdir, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { UFI_COMMANDS } from '../src/providers/ufi-himi/transport'; +import { + classifyUfiInterfaceRole, + readUfiDriverBindings, + readUfiEvidenceManifest, + sweepUfiEvidenceText, + UFI_EVIDENCE_KIND, + UFI_EVIDENCE_REDACTION_MARKER, + UFI_EVIDENCE_SCHEMA_VERSION, + UFI_EVIDENCE_STEPS, + type UfiEvidenceStep, + type UfiEvidenceStepStatus, +} from './ufi-himi-evidence'; + +const SCRIPT = join(import.meta.dir, 'ufi-himi-capture.sh'); +const MODULE = join(import.meta.dir, 'ufi-himi-evidence.ts'); + +async function runScript( + args: readonly string[], + options: { readonly stdin?: string; readonly env?: Record } = {}, +): Promise<{ code: number; stdout: string; stderr: string }> { + const child = Bun.spawn([SCRIPT, ...args], { + stdin: options.stdin === undefined ? 'ignore' : new TextEncoder().encode(options.stdin), + stdout: 'pipe', + stderr: 'pipe', + env: { ...process.env, ...(options.env ?? {}) }, + }); + const [code, stdout, stderr] = await Promise.all([ + child.exited, + new Response(child.stdout).text(), + new Response(child.stderr).text(), + ]); + return { code, stdout, stderr }; +} + +// ── 1. Static fence ────────────────────────────────────────────────────────────────── + +/** + * The exact alternation the plan fixes for this tooling. Case-SENSITIVE on purpose: + * `setprop` is lowercase because that is how the Android property setter is spelled, and + * the uppercase form is the AT write shape (`AT+X=`), which the read-only TEST + * form `AT+X=?` deliberately does not match. + */ +const FORBIDDEN = /usb_modeswitch|setprop|adb |edl|AT!|=?[^)]*SET|qmicli -w/; + +const FORBIDDEN_SAMPLES = [ + ['modeswitch', 'usb_modeswitch -v 05c6 -p 9091'], + ['android-property-write', 'adb shell setprop sys.usb.config rndis'], + ['shell-transport', 'adb push payload /tmp'], + ['emergency-download', 'edl printgpt'], + ['sierra-vendor-command', 'AT!UDPID=9091'], + ['at-composition-write', 'AT^SETPORT="A1,A2;10,12"'], + ['qmi-write', 'qmicli -w -d /dev/cdc-wdm0'], +] as const; + +const SOURCES = new Map([ + ['ufi-himi-capture.sh', await Bun.file(SCRIPT).text()], + ['ufi-himi-evidence.ts', await Bun.file(MODULE).text()], +]); + +describe('the UFI capture tooling cannot change the device', () => { + test('scans both shipped files, so the gate cannot pass vacuously', () => { + // Given / When / Then + expect([...SOURCES.keys()].sort()).toEqual(['ufi-himi-capture.sh', 'ufi-himi-evidence.ts']); + for (const source of SOURCES.values()) expect(source.length).toBeGreaterThan(500); + }); + + test.each(FORBIDDEN_SAMPLES)('the fence fires on a synthetic %s', (_label, sample) => { + // Given / When / Then + expect(FORBIDDEN.test(sample)).toBe(true); + }); + + test.each([...SOURCES.keys()])('%s contains no state-changing construct', (name) => { + // Given + const source = SOURCES.get(name) ?? ''; + + // When + const offending = source + .split('\n') + .map((line, index) => [index + 1, line] as const) + .filter(([, line]) => FORBIDDEN.test(line)) + .map(([number]) => number); + + // Then + expect(offending).toEqual([]); + }); + + test('every HIMI command literal names a member of the frozen vocabulary', () => { + // Given + const allowed = new Set(UFI_COMMANDS); + + // When + const literals = [...SOURCES.values()].flatMap((source) => + [...source.matchAll(/cmdid\\?"\s*:\s*\\?"([a-zA-Z_]+)/g)].map((match) => match[1] ?? ''), + ); + + // Then + expect(literals.length).toBeGreaterThan(0); + expect(literals.filter((literal) => !allowed.has(literal))).toEqual([]); + }); +}); + +// ── 2. Behaviour, executed ─────────────────────────────────────────────────────────── + +/** Every identifier class a real capture can carry, in the shape it really appears in. */ +const SYNTHETIC_CAPTURE = [ + 'Bus 001 Device 007: ID 05c6:9091 Qualcomm, Inc. UFI', + ' bcdDevice 2.32', + ' iSerial 3 0123456789abcdef', + 'S: SerialNumber=0123456789abcdef', + 'ID_SERIAL=Qualcomm_UFI_0123456789abcdef', + 'ID_SERIAL_SHORT=0123456789abcdef', + 'ID_NET_NAME_MAC=enx0c5b8f279a64', + 'serial=0123456789abcdef', + ' bInterfaceClass 255 Vendor Specific Class', + '{"imei":"356938035643809","imsi":"310150123456789"}', + '{"iccid":"8991101200003204514","msisdn":"+573115422359"}', + '{"sn":"UFI2026081900123","token":"c0ffee00c0ffee00"}', + 'link/ether 0c:5b:8f:27:9a:64 brd ff:ff:ff:ff:ff:ff', + 'IMEI: 356938035643809', + '2-1:1.2 class=ff subclass=ff protocol=30 driver=unbound', +].join('\n'); + +describe('the capture script redacts at capture time', () => { + test('the sweep fires on the UNREDACTED synthetic capture (non-vacuity)', () => { + // Given / When + const findings = sweepUfiEvidenceText(SYNTHETIC_CAPTURE, 'synthetic'); + + // Then — every rule class is reachable from this one fixture + expect(new Set(findings.map((finding) => finding.rule))).toEqual( + new Set([ + 'long-digit-run', + 'lsusb-iserial', + 'usb-devices-serial', + 'udev-serial-property', + 'mac-address', + 'sysfs-serial-attribute', + 'subscriber-json-key', + ]), + ); + }); + + test('the redaction filter leaves no IMSI, ICCID, IMEI or serial behind', async () => { + // Given / When + const filtered = await runScript(['--redact-filter'], { stdin: SYNTHETIC_CAPTURE }); + + // Then + expect(filtered.code).toBe(0); + expect(sweepUfiEvidenceText(filtered.stdout, 'filtered')).toEqual([]); + for (const secret of [ + '0123456789abcdef', + '356938035643809', + '310150123456789', + '8991101200003204514', + '0c:5b:8f:27:9a:64', + 'c0ffee00c0ffee00', + ]) { + expect(filtered.stdout).not.toContain(secret); + } + }); + + test('it keeps the descriptor evidence the analysis needs', async () => { + // Given / When + const filtered = await runScript(['--redact-filter'], { stdin: SYNTHETIC_CAPTURE }); + + // Then + expect(filtered.stdout).toContain('ID 05c6:9091'); + expect(filtered.stdout).toContain('bcdDevice 2.32'); + expect(filtered.stdout).toContain('bInterfaceClass 255'); + expect(filtered.stdout).toContain('class=ff subclass=ff protocol=30 driver=unbound'); + expect(filtered.stdout).toContain(UFI_EVIDENCE_REDACTION_MARKER); + }); + + test("the script's own sweep agrees with this module's, in both directions", async () => { + // Given + const dir = await mkdtemp(join(tmpdir(), 'ufi-sweep-')); + try { + const filtered = await runScript(['--redact-filter'], { stdin: SYNTHETIC_CAPTURE }); + await writeFile(join(dir, 'raw.txt'), SYNTHETIC_CAPTURE); + + // When — the raw capture must be REFUSED by the script's own sweep + const dirty = await runScript(['--sweep', join(dir, 'raw.txt')]); + await rm(join(dir, 'raw.txt')); + await writeFile(join(dir, 'clean.txt'), filtered.stdout); + const clean = await runScript(['--sweep', join(dir, 'clean.txt')]); + + // Then + expect(dirty.code).toBe(4); + expect(dirty.stderr).toContain('sweep-findings'); + expect(clean.code).toBe(0); + expect(clean.stdout.trim()).toBe('sweep-clean'); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }); +}); + +describe('a capture with no device attached is honest about it', () => { + test('answers device-not-present and writes no partial bundle', async () => { + // Given — a product id no device on any host will carry + const dir = await mkdtemp(join(tmpdir(), 'ufi-capture-')); + const target = join(dir, 'bundle'); + try { + // When + const run = await runScript([], { + env: { UFI_USB_ID: 'ffff:fffe', UFI_CAPTURE_DIR: target }, + }); + + // Then + expect(run.code).toBe(3); + expect(run.stdout.trim()).toBe('device-not-present'); + expect(run.stderr).toContain('no bundle was written'); + expect(await readdir(dir)).toEqual([]); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }); +}); + +// ── 3. Analysis ────────────────────────────────────────────────────────────────────── + +describe('interface classification answers the descriptor, never the product id', () => { + test.each([ + ['diag', 0xff, 0xff, 0x30], + ['adb-class', 0xff, 0x42, 0x01], + ['rndis-control', 0xe0, 0x01, 0x03], + ['rndis-data', 0x0a, 0x00, 0x00], + ['cdc-acm-control', 0x02, 0x02, 0x01], + ['mass-storage', 0x08, 0x06, 0x50], + ['vendor-specific', 0xff, 0xff, 0xff], + ['vendor-specific', 0xff, 0x42, 0x03], + ['unclassified', 0x03, 0x00, 0x00], + ] as const)('%s', (role, interfaceClass, interfaceSubClass, interfaceProtocol) => { + // Given / When / Then + expect( + classifyUfiInterfaceRole({ + number: 0, + interfaceClass, + interfaceSubClass, + interfaceProtocol, + }), + ).toBe(role); + }); + + test('only the exact Qualcomm triple classifies as diag', () => { + // Given + const nearMisses = [ + { number: 0, interfaceClass: 0xff, interfaceSubClass: 0xff, interfaceProtocol: 0x40 }, + { number: 1, interfaceClass: 0xff, interfaceSubClass: 0xfe, interfaceProtocol: 0x30 }, + { number: 2, interfaceClass: 0xfe, interfaceSubClass: 0xff, interfaceProtocol: 0x30 }, + ]; + + // When / Then + for (const descriptor of nearMisses) { + expect(classifyUfiInterfaceRole(descriptor)).not.toBe('diag'); + } + }); + + test('a binding file classifies each interface and keeps its driver verbatim', () => { + // Given — the shape ufi-himi-capture.sh writes + const bindings = [ + '2-1:1.0 class=e0 subclass=01 protocol=03 driver=rndis_host', + '2-1:1.1 class=0a subclass=00 protocol=00 driver=rndis_host', + '2-1:1.2 class=ff subclass=ff protocol=ff driver=qmi_wwan', + '2-1:1.3 class=ff subclass=42 protocol=01 driver=unbound', + '2-1:1.4 class=ff subclass=ff protocol=30 driver=unbound', + ].join('\n'); + + // When + const findings = readUfiDriverBindings(bindings); + + // Then + expect(findings.map((finding) => finding.role)).toEqual([ + 'rndis-control', + 'rndis-data', + 'vendor-specific', + 'adb-class', + 'diag', + ]); + expect(findings.map((finding) => finding.descriptor.number)).toEqual([0, 1, 2, 3, 4]); + expect(findings.map((finding) => finding.driver)).toEqual([ + 'rndis_host', + 'rndis_host', + 'qmi_wwan', + 'unbound', + 'unbound', + ]); + }); + + test('an unreadable line is skipped rather than guessed at', () => { + // Given / When + const findings = readUfiDriverBindings('=== device 2-1 ===\nnot a binding line\n'); + + // Then + expect(findings).toEqual([]); + }); +}); + +// ── Manifest ───────────────────────────────────────────────────────────────────────── + +function manifest(overrides: Record = {}): Record { + const steps: Record = { + lsusbVerbose: 'captured', + usbDevices: 'captured', + udevProperties: 'captured', + driverBindings: 'captured', + sysComposition: 'captured', + himiIdentity: 'skipped-no-credential', + }; + return { + schemaVersion: UFI_EVIDENCE_SCHEMA_VERSION, + kind: UFI_EVIDENCE_KIND, + usbId: '05c6:9091', + capturedAt: '2026-08-22T10:00:00Z', + host: 'ceralive2', + kernel: '7.1.7-ceralive-rk3588', + matchedSysfsDevices: ['2-1'], + steps, + redaction: 'capture-time', + mutations: 'none', + ...overrides, + }; +} + +describe('the bundle manifest', () => { + test('accepts a complete capture and names every step', () => { + // Given / When + const reading = readUfiEvidenceManifest(manifest()); + + // Then + expect(reading.state).toBe('valid'); + if (reading.state !== 'valid') return; + expect(Object.keys(reading.manifest.steps).sort()).toEqual([...UFI_EVIDENCE_STEPS].sort()); + }); + + test('refuses a manifest that does not state it changed nothing', () => { + // Given / When + const reading = readUfiEvidenceManifest(manifest({ mutations: 'composition-switch' })); + + // Then + expect(reading.state).toBe('invalid'); + if (reading.state !== 'invalid') return; + expect(reading.problems).toContainEqual({ kind: 'mutations-claimed' }); + }); + + test('refuses an unknown schema version and an unknown step status', () => { + // Given / When + const version = readUfiEvidenceManifest(manifest({ schemaVersion: 99 })); + const status = readUfiEvidenceManifest( + manifest({ steps: { ...(manifest().steps as object), himiIdentity: 'probably-fine' } }), + ); + + // Then + expect(version.state).toBe('invalid'); + expect(status.state).toBe('invalid'); + if (status.state !== 'invalid') return; + expect(status.problems).toContainEqual({ kind: 'unknown-step-status', step: 'himiIdentity' }); + }); + + test('accumulates problems rather than stopping at the first', () => { + // Given / When + const reading = readUfiEvidenceManifest({ kind: 'something-else' }); + + // Then + expect(reading.state).toBe('invalid'); + if (reading.state !== 'invalid') return; + expect(reading.problems.length).toBeGreaterThan(3); + }); +}); diff --git a/control/scripts/ufi-himi-evidence.ts b/control/scripts/ufi-himi-evidence.ts new file mode 100644 index 0000000..256508f --- /dev/null +++ b/control/scripts/ufi-himi-evidence.ts @@ -0,0 +1,308 @@ +/** + * The schema and the analysis vocabulary for a UFI/HIMI descriptor evidence bundle. + * + * `ufi-himi-capture.sh` WRITES a bundle; this module says what a bundle IS, classifies + * the interfaces inside one, and sweeps it for a subscriber identifier the capture-time + * redaction should already have masked. It is pure — no transport, no subprocess, no + * device contact — and it lives beside the capture script rather than inside + * `control/src/providers/ufi-himi/`, because bench capture tooling is not part of the + * published package (`files: ["dist"]`) and the provider's own no-write-path gate is a + * closed enumeration of that directory. + * + * WHAT A BUNDLE MAY CONCLUDE, AND WHAT IT MAY NOT. A product id concludes nothing: + * `05c6` is Qualcomm's generic vendor id, `9091` is chosen by whoever built the firmware + * image, upstream Linux matches it in one driver table under an unrelated annotation, + * and third-party tooling documents the same id on a different device. Only THIS unit's + * interface descriptors say what THIS unit exposes, and only the captured driver binding + * says who claimed each one. Those two facts are recorded separately and never merged: + * a descriptor triple is what an interface IS, a binding is a fact about one kernel on + * one board, and inferring either from the other is the mistake the whole bundle exists + * to make impossible. + */ + +import type { UfiUsbInterfaceDescriptor } from '../src/providers/ufi-himi/qualcomm-evidence'; +import { classifyUfiDiagEvidence } from '../src/providers/ufi-himi/qualcomm-evidence'; + +/** Bumped only when a bundle's shape changes; a reader refuses an unknown version. */ +export const UFI_EVIDENCE_SCHEMA_VERSION = 1; +export const UFI_EVIDENCE_KIND = 'ufi-himi-descriptor-evidence'; + +/** Mirrors `control/src/redact.ts`'s marker, so one shape means "masked" everywhere. */ +export const UFI_EVIDENCE_REDACTION_MARKER = '[redacted]'; + +/** Every file a complete bundle carries. `himi-identity.json` is credential-gated. */ +export const UFI_EVIDENCE_FILES = [ + 'manifest.json', + 'lsusb-verbose.txt', + 'usb-devices.txt', + 'udev-properties.txt', + 'driver-bindings.txt', + 'sys-composition.txt', +] as const; +export const UFI_EVIDENCE_OPTIONAL_FILES = ['himi-identity.json'] as const; + +/** + * A step status is HONEST about why a step produced nothing. `tool-unavailable` is a + * statement about the host, `unreachable` about the device's HTTP API, and + * `skipped-no-credential` about the operator's choice — folding the three into a bare + * absent field would leave the next reader unable to tell a gap from a finding. + */ +export const UFI_EVIDENCE_STEP_STATUSES = [ + 'captured', + 'empty', + 'tool-unavailable', + 'unreachable', + 'skipped-no-credential', +] as const; +export type UfiEvidenceStepStatus = (typeof UFI_EVIDENCE_STEP_STATUSES)[number]; + +export const UFI_EVIDENCE_STEPS = [ + 'lsusbVerbose', + 'usbDevices', + 'udevProperties', + 'driverBindings', + 'sysComposition', + 'himiIdentity', +] as const; +export type UfiEvidenceStep = (typeof UFI_EVIDENCE_STEPS)[number]; + +export type UfiEvidenceManifest = { + readonly schemaVersion: number; + readonly kind: typeof UFI_EVIDENCE_KIND; + readonly usbId: string; + readonly capturedAt: string; + readonly host: string; + readonly kernel: string; + readonly matchedSysfsDevices: readonly string[]; + readonly steps: Readonly>; + readonly redaction: 'capture-time'; + readonly mutations: 'none'; +}; + +export type UfiManifestProblem = + | { readonly kind: 'not-an-object' } + | { readonly kind: 'wrong-kind' } + | { readonly kind: 'unsupported-schema-version'; readonly found: unknown } + | { readonly kind: 'missing-field'; readonly field: string } + | { readonly kind: 'unknown-step-status'; readonly step: string } + | { readonly kind: 'mutations-claimed' }; + +export type UfiManifestReading = + | { readonly state: 'valid'; readonly manifest: UfiEvidenceManifest } + | { readonly state: 'invalid'; readonly problems: readonly UfiManifestProblem[] }; + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +/** + * Validated by hand rather than with a schema library: this module is bench tooling and + * takes no dependency the published package does not already carry for a real reason. + * Problems are ACCUMULATED — a reader fixing a bundle wants every complaint at once. + */ +export function readUfiEvidenceManifest(value: unknown): UfiManifestReading { + if (!isRecord(value)) return { state: 'invalid', problems: [{ kind: 'not-an-object' }] }; + const problems: UfiManifestProblem[] = []; + if (value.kind !== UFI_EVIDENCE_KIND) problems.push({ kind: 'wrong-kind' }); + if (value.schemaVersion !== UFI_EVIDENCE_SCHEMA_VERSION) { + problems.push({ kind: 'unsupported-schema-version', found: value.schemaVersion }); + } + for (const field of ['usbId', 'capturedAt', 'host', 'kernel'] as const) { + if (typeof value[field] !== 'string') problems.push({ kind: 'missing-field', field }); + } + if (!Array.isArray(value.matchedSysfsDevices)) { + problems.push({ kind: 'missing-field', field: 'matchedSysfsDevices' }); + } + const steps = value.steps; + if (!isRecord(steps)) { + problems.push({ kind: 'missing-field', field: 'steps' }); + } else { + const known = new Set(UFI_EVIDENCE_STEP_STATUSES); + for (const step of UFI_EVIDENCE_STEPS) { + const status = steps[step]; + if (typeof status !== 'string' || !known.has(status)) { + problems.push({ kind: 'unknown-step-status', step }); + } + } + } + if (value.redaction !== 'capture-time') { + problems.push({ kind: 'missing-field', field: 'redaction' }); + } + // A bundle that does not state `mutations: "none"` is not a read-only capture, and + // nothing downstream may treat it as one. + if (value.mutations !== 'none') problems.push({ kind: 'mutations-claimed' }); + return problems.length > 0 + ? { state: 'invalid', problems } + : { state: 'valid', manifest: value as unknown as UfiEvidenceManifest }; +} + +// ── Interface classification ───────────────────────────────────────────────────────── + +/** + * A role is the descriptor triple's own meaning, never a guess about function. `diag` is + * decided by `classifyUfiDiagEvidence` itself rather than by a second copy of its + * constants, so the bench analysis and the shipped provider can never disagree about + * what a DIAG interface looks like. + * + * `vendor-specific` is a real answer, not a failure: Qualcomm's `ff/ff/*` space carries + * the QMI, NMEA and modem functions and the numbers are conventions, not a registry. The + * captured driver binding is what says which one this unit's kernel took — and a + * `vendor-specific` interface with an `unbound` binding is exactly the finding worth + * having, because it is a function nothing on the board is currently claiming. + */ +export const UFI_INTERFACE_ROLES = [ + 'diag', + 'adb-class', + 'rndis-control', + 'rndis-data', + 'cdc-acm-control', + 'mass-storage', + 'vendor-specific', + 'unclassified', +] as const; +export type UfiInterfaceRole = (typeof UFI_INTERFACE_ROLES)[number]; + +const VENDOR_SPECIFIC_CLASS = 0xff; + +export function classifyUfiInterfaceRole(descriptor: UfiUsbInterfaceDescriptor): UfiInterfaceRole { + const evidence = classifyUfiDiagEvidence({ usbId: '', interfaces: [descriptor] }); + if (evidence.state === 'descriptor-confirmed') return 'diag'; + const { interfaceClass, interfaceSubClass, interfaceProtocol } = descriptor; + // Google's ADB function: vendor class, subclass 0x42, protocol 0x01. Present on the + // bench `05c6:9024` composition, and a permission for nothing (prohibitions.ts + // `shell.transport-fallback`) — recording it is how production stays explicit that + // it never reaches this device that way. + if (interfaceClass === VENDOR_SPECIFIC_CLASS && interfaceSubClass === 0x42) { + return interfaceProtocol === 0x01 ? 'adb-class' : 'vendor-specific'; + } + // RNDIS: wireless-controller class carrying the control channel, CDC-data carrying + // the payload. The pair is what `rndis_host` binds. + if (interfaceClass === 0xe0 && interfaceSubClass === 0x01 && interfaceProtocol === 0x03) { + return 'rndis-control'; + } + if (interfaceClass === 0x0a) return 'rndis-data'; + if (interfaceClass === 0x02 && interfaceSubClass === 0x02) return 'cdc-acm-control'; + if (interfaceClass === 0x08) return 'mass-storage'; + if (interfaceClass === VENDOR_SPECIFIC_CLASS) return 'vendor-specific'; + return 'unclassified'; +} + +/** One analysis row: what the descriptor says, and — separately — who claimed it. */ +export type UfiInterfaceFinding = { + readonly descriptor: UfiUsbInterfaceDescriptor; + readonly role: UfiInterfaceRole; + /** Verbatim from `driver-bindings.txt`; `unbound` is a value, not a missing one. */ + readonly driver: string; +}; + +const BINDING_LINE = + /^(?
\S+)\s+class=(?[0-9a-fA-F]+)\s+subclass=(?[0-9a-fA-F]+)\s+protocol=(?[0-9a-fA-F]+)\s+driver=(?\S+)/; + +/** + * Parses `driver-bindings.txt`. The interface NUMBER is taken from the sysfs address's + * `…:c.i` tail, which is the kernel's own numbering — not from the line's position, so a + * bundle whose interfaces enumerate out of order still classifies correctly. + */ +export function readUfiDriverBindings(text: string): readonly UfiInterfaceFinding[] { + const findings: UfiInterfaceFinding[] = []; + for (const line of text.split('\n')) { + const match = BINDING_LINE.exec(line.trim()); + const groups = match?.groups; + if (groups === undefined) continue; + const tail = groups.address?.split('.').at(-1) ?? ''; + const descriptor: UfiUsbInterfaceDescriptor = { + number: Number.parseInt(tail, 10), + interfaceClass: Number.parseInt(groups.class ?? '', 16), + interfaceSubClass: Number.parseInt(groups.subclass ?? '', 16), + interfaceProtocol: Number.parseInt(groups.protocol ?? '', 16), + }; + if (Number.isNaN(descriptor.number) || Number.isNaN(descriptor.interfaceClass)) continue; + findings.push({ + descriptor, + role: classifyUfiInterfaceRole(descriptor), + driver: groups.driver ?? 'unbound', + }); + } + return findings; +} + +// ── Redaction sweep ────────────────────────────────────────────────────────────────── + +/** + * The tested twin of `ufi-himi-capture.sh`'s `sweep_file`. Two independent checkers over + * one bundle is deliberate: they fail the capture when they disagree, which is the safe + * direction, and this one is the half a test can drive over a synthetic leak. + * + * A FINDING NEVER CARRIES THE VALUE IT FOUND — only the rule, the file and the line. A + * leak report that quotes the leak is the leak, and these reports land in CI logs. + */ +export const UFI_SWEEP_RULES = [ + { + // 15 digits is an IMEI or an IMSI, 19-20 an ICCID. Nothing in a USB descriptor is + // 14 digits long, so this backstop costs no descriptor fidelity. + id: 'long-digit-run', + re: /[0-9]{14,}/, + }, + { id: 'lsusb-iserial', re: /iSerial\s+[0-9]+\s+[^\s[]/ }, + { id: 'usb-devices-serial', re: /SerialNumber=[^\s[]/ }, + { + id: 'udev-serial-property', + re: /(?:ID_SERIAL|ID_SERIAL_SHORT|ID_USB_SERIAL|ID_USB_SERIAL_SHORT|ID_SERIAL_ID|ID_NET_NAME_MAC)=[^\s[]/, + }, + { id: 'mac-address', re: /\b(?:[0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}\b/ }, + { + id: 'sysfs-serial-attribute', + re: /^\s*serial\s*[:=]\s*[^\s[]/i, + }, + { + id: 'subscriber-json-key', + re: /"[A-Za-z0-9_]*(?:serial|imei|imsi|iccid|msisdn)[A-Za-z0-9_]*"\s*:\s*"?[^"\s[]/i, + }, +] as const; +export type UfiSweepRuleId = (typeof UFI_SWEEP_RULES)[number]['id']; + +export type UfiRedactionFinding = { + readonly file: string; + readonly line: number; + readonly rule: UfiSweepRuleId; +}; + +export function sweepUfiEvidenceText( + text: string, + file = '', +): readonly UfiRedactionFinding[] { + const findings: UfiRedactionFinding[] = []; + const lines = text.split('\n'); + for (const [index, line] of lines.entries()) { + for (const rule of UFI_SWEEP_RULES) { + if (rule.re.test(line)) findings.push({ file, line: index + 1, rule: rule.id }); + } + } + return findings; +} + +export async function sweepUfiEvidenceBundle(dir: string): Promise { + const { readdir } = await import('node:fs/promises'); + const { join } = await import('node:path'); + const findings: UfiRedactionFinding[] = []; + for (const entry of await readdir(dir, { withFileTypes: true, recursive: true })) { + if (!entry.isFile()) continue; + const path = join(entry.parentPath, entry.name); + findings.push(...sweepUfiEvidenceText(await Bun.file(path).text(), entry.name)); + } + return findings; +} + +if (import.meta.main) { + const target = Bun.argv[2]; + if (target === undefined) { + console.error('usage: bun run control/scripts/ufi-himi-evidence.ts '); + process.exit(2); + } + const findings = await sweepUfiEvidenceBundle(target); + for (const finding of findings) { + console.error(`${finding.file}:${finding.line}: ${finding.rule}`); + } + console.log(findings.length === 0 ? 'sweep-clean' : `sweep-findings ${findings.length}`); + process.exit(findings.length === 0 ? 0 : 1); +} diff --git a/docs/UFI-DIAG-PROBE.md b/docs/UFI-DIAG-PROBE.md index 575e9a4..d4717f6 100644 --- a/docs/UFI-DIAG-PROBE.md +++ b/docs/UFI-DIAG-PROBE.md @@ -6,11 +6,17 @@ read-only over the HIMI HTTP API and has no DIAG code path at all — not a refu not a disabled branch. Everything below is done by a human, by hand, on a bench unit whose loss would cost nothing. -No executable harness ships for this one, and that is deliberate: a script that opens a -DIAG channel IS the code path this provider exists to not have. The MF79U procedure has +No executable harness ships for the DIAG half, and that is deliberate: a script that opens +a DIAG channel IS the code path this provider exists to not have. The MF79U procedure has `control/scripts/mf79u-diagnose.sh` because its subject is an ordinary HTTP login; this one's subject is the channel that can rewrite a modem's persistent storage. +The **descriptor capture** in step 0 below is the one part that does ship as a script +(`control/scripts/ufi-himi-capture.sh`), because its subject is the opposite: reading what +the device already told the kernel at enumeration time. It sends nothing to the DIAG +channel, holds no vendor command, and is scanned on every test run for the constructs one +would need — see [The capture tooling, and its fence](#the-capture-tooling-and-its-fence). + ## What this probe may establish, and what it may not It may establish exactly one fact: **whether a DIAG interface descriptor exists on this @@ -38,9 +44,15 @@ that rule, and answers `not-proven` / `product-id-is-not-evidence` for a bare pr - A **bench** UFI/HIMI stick. Not a deployed board, not a board carrying a live stream. - A second person, or an agreed stop-time, so the probe is supervised rather than open-ended. -- `usbutils` installed. Nothing else is required for step 1, which is where most units - will stop. -- The unit's admin password injected as **ephemeral bench input** for step 2 only: +- `usbutils` installed — it supplies both `lsusb` and `usb-devices`, and **the bench image + ships neither by default** (`docs/BENCH.md` RB-9). Step 0 records a missing tool as + `tool-unavailable` rather than as a missing device, but a bundle captured without it is + missing the verbose descriptor half and is not a complete answer. +- `bash`, `sed`, `grep` and `find` — present on any Debian bench. `jq` and `curl` are + needed only for the HIMI identity step, and `bun` is needed by nothing on the bench: the + independent sweep in step 0's verification runs on a development machine. +- The unit's admin password injected as **ephemeral bench input** for step 0's HIMI + identity half and for step 2 only: ```sh read -rsp 'UFI bench password: ' UFI_BENCH_PASSWORD; printf '\n' @@ -51,22 +63,136 @@ that rule, and answers `not-proven` / `product-id-is-not-evidence` for a bare pr when the run ends. `control/src/providers/ufi-himi/credential-fence.test.ts` scans the repository for it and its base64/SHA-256 derivatives on every test run. -## Step 1 — confirm the descriptor, or stop +## Step 0 — capture the descriptor evidence bundle + +This is the whole read, in one command, and it is the input every later step argues from. +Nothing in it contacts the DIAG channel. ```sh -lsusb -d 05c6: -v 2>/dev/null | - awk '/bInterfaceNumber|bInterfaceClass|bInterfaceSubClass|bInterfaceProtocol/' +UFI_USB_ID=05c6:9091 control/scripts/ufi-himi-capture.sh ``` -Classify and record ONE of: +It answers with exactly one of: + +- `device-not-present` on stdout, a one-line explanation on stderr, **exit 3** — no + bundle directory is created at all. A capture that cannot see the device does not leave + a partial bundle behind for somebody to mistake for a finding. +- `capture-complete` on stdout followed by the bundle path, **exit 0**. +- `redaction-sweep-failed`, **exit 4** — an identifier survived redaction, so the staged + bundle was destroyed instead of published. There is no flag to override this. + +The bundle is written to `test-results/ufi-himi-descriptor/--/` (override +with `UFI_CAPTURE_DIR`), which is repo-local and gitignored. It is staged in a temporary +directory and moved into place as a unit, so the published path either holds a complete +bundle or does not exist. + +| File | What it holds | +|------|---------------| +| `manifest.json` | schema version, USB id, capture time, host, kernel, matched sysfs devices, and a per-step status | +| `lsusb-verbose.txt` | `lsusb -v -d 05c6:` — the full configuration/interface/endpoint descriptors | +| `usb-devices.txt` | `usb-devices` — the same tree in the kernel's own per-interface `I:` form | +| `udev-properties.txt` | `udevadm info -q property` for the device **and** each interface | +| `driver-bindings.txt` | one line per interface: sysfs address, class triple, and the bound driver (or `unbound`) | +| `sys-composition.txt` | `/sys` device attributes plus each interface's attributes and its `net`/`tty` children | +| `himi-identity.json` | `getproduceinfo` + `getsysinfo` replies — present only when `UFI_BENCH_PASSWORD` was supplied | + +**Per-step status is honest and is not a boolean.** `captured`, `empty`, +`tool-unavailable` (this host lacks the tool), `unreachable` (the device's HTTP API did +not answer), and `skipped-no-credential` (no password was supplied) are five different +facts. Folding them into an absent field would leave the next reader unable to tell a gap +from a finding. + +**Redaction happens at capture time, and the bundle is swept before it is published.** Two +layers, mirroring `control/src/redact.ts`: a key-based layer masks the value of any field +whose name says what it holds (`iSerial`, `SerialNumber=`, `ID_SERIAL*`, `ID_NET_NAME_MAC`, +the sysfs `serial` attribute, and JSON keys containing `serial`/`imei`/`imsi`/`iccid`/ +`msisdn`/`token`/`session`/`password`, plus exact `sn`/`esn`/`meid`/`simnumber`/`simid`), +and a shape-based backstop masks any MAC address and any run of 14 or more digits. An IMEI +and an IMSI are 15 digits and an ICCID is 19–20; nothing in a USB descriptor is 14 digits +long, so the backstop costs no descriptor fidelity. Interface numbers, class triples, +`bcdDevice`, driver names, product and manufacturer strings all survive verbatim — they +are the evidence. + +The login reply is deliberately never written to the bundle: it carries the session +material, and a bundle is a thing that gets pasted into a review comment. + +Verify the bundle independently before it leaves the bench, on a machine with `bun`: + +```sh +bun run control/scripts/ufi-himi-evidence.ts # prints sweep-clean, or exits 1 +control/scripts/ufi-himi-capture.sh --sweep # the script's own second check +``` + +Two checkers over one bundle is deliberate. They fail the capture when they disagree, +which is the safe direction. + +## Step 1 — classify every interface, and record who claimed it + +Read `driver-bindings.txt` and `lsusb-verbose.txt` together. **The descriptor triple says +what an interface IS; the driver binding says who took it on this kernel, on this board. +They are two facts and they are never merged** — an interface with a recognizable triple +and an `unbound` driver is a real and interesting finding, not a contradiction. + +`classifyUfiInterfaceRole()` in `control/scripts/ufi-himi-evidence.ts` encodes the triple +half; `readUfiDriverBindings()` reads a whole `driver-bindings.txt` into one row per +interface. + +| `bInterfaceClass` / `SubClass` / `Protocol` | Role | Typical claiming driver | +|---|---|---| +| `e0` / `01` / `03` | `rndis-control` — the RNDIS control channel | `rndis_host` | +| `0a` / any / any | `rndis-data` — CDC data, the RNDIS payload leg | `rndis_host` | +| `ff` / `42` / `01` | `adb-class` — Google's shell interface | usually `unbound` on a Linux host | +| `ff` / `ff` / `30` | `diag` — **the only proof of a DIAG channel** | usually `unbound` | +| `ff` / any other | `vendor-specific` | `qmi_wwan`, `option`, or `unbound` | +| `02` / `02` / any | `cdc-acm-control` | `cdc_acm` | +| `08` / any / any | `mass-storage` | `usb-storage` | +| anything else | `unclassified` | — | + +`ff/ff/*` is a convention space, not a registry: Qualcomm firmware conventionally carries +QMI, NMEA and modem functions in it alongside DIAG's `30`. Which of those a given +`vendor-specific` interface actually is, this bundle does not say — the captured binding +says who claimed it, and nothing here guesses the rest. + +**Two inferences are specifically forbidden, and both look reasonable.** Upstream Linux +matches `05c6:9091` interface 2 in `drivers/net/usb/qmi_wwan.c` under an annotation naming +an unrelated device, and QCSuper documents the same product id on a different device +again; `usb_modeswitch`'s device data carries no `9091` entry at all. So neither a kernel +driver table nor third-party tooling is evidence about the unit on this bench. Only the +descriptor answers, and only the captured binding says what this kernel did with it. + +Then classify and record ONE of: - `diag-not-present` — no interface reports class `ff`, subclass `ff`, protocol `30`. **Stop here.** There is nothing to probe, whatever the product id says. - `diag-descriptor-confirmed` — such an interface exists; note its `bInterfaceNumber`. +## The capture tooling, and its fence + +`control/scripts/ufi-himi-capture.sh` and `control/scripts/ufi-himi-evidence.ts` are +scanned on every test run by `control/scripts/ufi-himi-evidence.test.ts`, which fails the +build if either file contains a construct that could change the device's state — the +Zero-CD mode switcher, the Android property setter, a shell-transport invocation, an +emergency-download tool, a Sierra vendor command, an AT write form, or a QMI write. Every +detector has a non-vacuity control that trips it with a synthetic violation, so a broken +pattern fails the suite rather than passing it silently. + +The same suite EXECUTES the script: it drives the redaction rules over a synthetic capture +carrying every identifier class, proves the sweep fires on that input before redaction and +finds nothing after, and proves that on a host with no matching device the script answers +`device-not-present` and writes no bundle. The redaction rules live in the script and only +in the script — the test runs them rather than re-expressing them, because a second copy +is a second thing to drift. + +The HIMI half of the capture is limited to the same frozen vocabulary the shipped provider +is: one `login`, then `getproduceinfo` and `getsysinfo`. The test asserts every command +literal in both files is a member of `UFI_COMMANDS`, so a command outside the seven `get*` +reads plus `login` cannot appear here either. + ## Step 2 — read the telemetry the provider itself uses -This is the READ that answers most questions, and it needs no DIAG at all: +Step 0 already performs this read when `UFI_BENCH_PASSWORD` is set, and files the answer +as `himi-identity.json`. Do it by hand only when step 0 recorded `unreachable`, or when a +reply beyond `getproduceinfo` / `getsysinfo` is wanted. It needs no DIAG at all: ```sh curl --interface usb0 --no-location -sS \ @@ -101,9 +227,22 @@ Record ONE classification: ## Retention -Only the classification lines from steps 1–3 may be kept, under the gitignored -`test-results/`. No raw capture, no DIAG frame, no session token, no password, and no -IMSI/ICCID/IMEI. `unset UFI_BENCH_PASSWORD` before the shell is left. +Two different things, with two different rules. + +**Step 0's bundle** lives under the gitignored `test-results/` and is retained whole. It +is redacted at capture time and swept before it is published, so it carries no serial, no +IMSI, no ICCID, no IMEI, no MAC and no session material — that is what makes it safe to +keep, quote in a review, or transcribe into a committed evidence document. Transcribing a +bundle into `docs/` follows the `COMPOSITION-EVIDENCE.md` precedent: the descriptors, the +driver bindings and the per-interface classification table go in; the bundle directory +itself stays repo-local. + +**Steps 1–3** keep only their classification lines. No raw DIAG frame, no session token, +no password. `unset UFI_BENCH_PASSWORD` before the shell is left. + +Nothing from either half may name the password or a derivative of it; +`control/src/providers/ufi-himi/credential-fence.test.ts` scans tracked and +intended-untracked files for it and its base64/SHA-256 forms on every test run. ## After the probe From 38487cd9f7f64e29acf2eac3008d94b3e3fd7464 Mon Sep 17 00:00:00 2001 From: Andres Cera Date: Sat, 22 Aug 2026 19:50:41 -0500 Subject: [PATCH 06/12] build(modemmanager): carry the FM350 RNDIS bearer and mode fixes as a pinned patch series --- AGENTS.md | 32 +- POLICY.md | 20 +- README.md | 9 +- docs/adr/ADR-FM350-RNDIS-BEARER.md | 124 +- packaging/BOOKWORM-ADAPTATIONS.md | 53 +- ...-forward-port-rndis-bearer-and-modes.patch | 1487 +++++++++++++++++ ...350gl-disable-crashing-cpol-commands.patch | 33 + ...0gl-accept-extended-cops-scan-format.patch | 82 + packaging/ModemManager/debian/patches/series | 3 + packaging/README.md | 42 +- 10 files changed, 1790 insertions(+), 95 deletions(-) create mode 100644 packaging/ModemManager/debian/patches/0001-fm350gl-forward-port-rndis-bearer-and-modes.patch create mode 100644 packaging/ModemManager/debian/patches/0002-fm350gl-disable-crashing-cpol-commands.patch create mode 100644 packaging/ModemManager/debian/patches/0003-fm350gl-accept-extended-cops-scan-format.patch create mode 100644 packaging/ModemManager/debian/patches/series diff --git a/AGENTS.md b/AGENTS.md index 91c1dcf..bd2cc19 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -14,18 +14,19 @@ Canonical branch: `main`. Sole remote: `origin` → `https://github.com/CERALIVE |-----------|----------|------| | `control/` | `@ceralive/modem-control` (npm) | TypeScript control library — domain model, ModemManager D-Bus backend, NetworkManager adapter, desired-state reconciler, injected admission/ownership/USB-hub ports, USB composition-mode model + evidence-bundle **ingestion seam**, data-usage sampler + the **usage-policy write surface**, capability-module **support-claim taxonomy + detection**, and the **band-lock** vocabulary + certification catalog (see §§ below). Published to public npm under `@ceralive` as **built ESM + `.d.ts`** across seven entry points (see § PUBLISHED PACKAGE SURFACE). | | `cli/` | `modem-control` (bench CLI) | The iteration surface: `probe`/`watch`/`apply`/`set-usb-mode`/`usage`/`certify`/`hil-cycle`, compiled `arm64`+`amd64`, run against real modems. Not published to npm. | -| `packaging/` | ModemManager stack `.deb`s **+ the first-party companion** | Bookworm rebuilds of ModemManager + libmbim + libqmi + libqrtr-glib — packaging only, zero source patches (see `POLICY.md`) — PLUS `ceralive-modem-support`, the `Architecture: all` first-party companion that owns CeraLive's generic modem system assets so those four never absorb one. | +| `packaging/` | ModemManager stack `.deb`s **+ the first-party companion** | Bookworm rebuilds of ModemManager + libmbim + libqmi + libqrtr-glib — packaging only, not a fork. libmbim/libqmi/libqrtr-glib remain source-unmodified; ModemManager carries exactly three owner-approved, BELABOX-derived FM350-GL patches (see `POLICY.md` and `docs/adr/ADR-FM350-RNDIS-BEARER.md`) — PLUS `ceralive-modem-support`, the `Architecture: all` first-party companion that owns CeraLive's generic modem system assets. | `control/` + `cli/` are one **Bun** workspace. `packaging/` builds in a bookworm container. ## FIRST-PARTY COMPANION — `ceralive-modem-support` -The four upstream sources are byte-faithful, zero-patch rebuilds. `ceralive-modem-support` -(`packaging/ceralive-modem-support/`, `Architecture: all`) is the first-party package that -keeps them that way: it owns the UNCONDITIONAL, generic modem system assets — CeraLive's -identification-only udev rules, Zero-CD usb-modeswitch device data, and the FCC -policy-reconciliation helper plus its oneshot unit. It ships **no FCC-unlock script at -all**; an absent `/data/ceralive/fcc-unlock-policy.json` exits 0 and activates nothing, +The first-party `ceralive-modem-support` package keeps CeraLive-owned system assets out of +all four upstream recipes; the ModemManager FM350-GL source series is a separate, narrowly +approved exception and owns no companion asset. The companion +(`packaging/ceralive-modem-support/`, `Architecture: all`) owns the UNCONDITIONAL, generic +modem system assets — CeraLive's identification-only udev rules, Zero-CD usb-modeswitch +device data, and the FCC policy-reconciliation helper plus its oneshot unit. It ships **no +FCC-unlock script at all**; an absent `/data/ceralive/fcc-unlock-policy.json` exits 0 and activates nothing, which is also the correct behaviour on generic Debian with no CeraLive partition layout. **Board-gated generated assets stay image-owned** — M.2 SIM quirk rows and per-slot modem UID @@ -1335,10 +1336,12 @@ own-number blocks in `control/src/redact.test.ts`. ## POLICY -`packaging/` is a **no-fork** effort: the first release carries zero quilt patches; adding a -patch later is an architecture gate (rationale + filed upstream MR + review); -udev/plugin/device-support improvements go **upstream first** — this binds permanently, -independent of phase. The Phase A → Phase B scope boundary is **version-gated at +`packaging/` is a **no-fork** effort: the first release carried zero quilt patches; adding a +patch later is an architecture gate (rationale + filed upstream MR + review). The sole current +exception is the exact three-patch BELABOX-derived FM350-GL series approved by the project +owner on 2026-08-22; its MR is still drafted, not filed, and its hardware drill remains open. +This narrow exception does not weaken the default that udev/plugin/device-support improvements +go **upstream first**. The Phase A → Phase B scope boundary is **version-gated at `v1.0.0`**: CeraUI / device-image / apt integration is out of scope through `v0.2.0` and authorized from the `v1.0.0` tag forward. Full terms: `POLICY.md` §4. The Fibocom **FM350** modem (PCIe / `mtk_t7xx`) is documented-**deferred**, not supported — @@ -1376,9 +1379,10 @@ bun run verify:consumers # install the tarball into standalone Node 26 + Bun pr `packaging/` runs in a `debian:bookworm` container; its contract/verification scripts live under `packaging/ci/`. The four sources' `debian/` recipes are checked in at -`packaging//debian/` (`ModemManager`, `libmbim`, `libqmi`, `libqrtr-glib` — -byte-identical to their pinned salsa commits except the bookworm adaptations documented in -`packaging/BOOKWORM-ADAPTATIONS.md`). `packaging/ci/build-bookworm.sh ` rebuilds +`packaging//debian/` (`ModemManager`, `libmbim`, `libqmi`, `libqrtr-glib` — matching +their pinned salsa commits except the bookworm adaptations and the approved ModemManager +FM350-GL series documented in `packaging/BOOKWORM-ADAPTATIONS.md`). +`packaging/ci/build-bookworm.sh ` rebuilds them from source in the mandatory bootstrap order (`libqrtr-glib → libmbim → libqmi → modemmanager`) via a temporary local apt repo, on native amd64 or full-system-QEMU arm64 (never cross-built), and asserts the 9-package runtime closure. `.deb` output lands in the diff --git a/POLICY.md b/POLICY.md index 1caaea4..0abcb6e 100644 --- a/POLICY.md +++ b/POLICY.md @@ -24,10 +24,21 @@ libqrtr-glib. It is a **repackaging** effort, **not a source fork**. - Until all three exist, the patch does not land. A rebuild that needs a patch to build at all is a STOP-and-surface event, not a silent local fix. +**Narrow approved exception — FM350-GL, 2026-08-22.** The CeraLive project owner reviewed +[`ADR-FM350-RNDIS-BEARER.md`](docs/adr/ADR-FM350-RNDIS-BEARER.md) as the second maintainer and +approved its exact three-patch BELABOX-derived ModemManager 1.24.2 carry while the upstream +merge-request draft remains unfiled. Requirement 2 above is therefore still honestly +**not met**; the dated owner decision is an explicit exception for this series, not a claim +that an MR exists and not a general waiver of the upstream-first gate. Every patch must retain +the BELABOX author, originating commit SHA(s), rationale, and `Forwarded: no` status. The +series remains hardware-unverified until its board drill passes and must be retired when +upstream ships equivalent support. + ## 2. Upstream-contribution-first For anything that is genuinely a modem-support improvement — udev rules, ModemManager -plugins, device quirks, port-type hints — **the contribution goes upstream first**. +plugins, device quirks, port-type hints — **the contribution goes upstream first**, except +for the single dated FM350-GL owner exception recorded in §1. - New device support, mode-switch quirks, and plugin changes are proposed to upstream ModemManager / the Debian packaging, not accreted as local carry. @@ -40,9 +51,10 @@ plugins, device quirks, port-type hints — **the contribution goes upstream fir ModemManager's value is its enormous, well-maintained device database and its plugin ecosystem. Every downstream patch we carry is a merge liability against that database and -a step away from `apt`-clean rebuilds. Keeping the packaging patch-free — and pushing real -fixes upstream — is what lets the bench track current ModemManager (1.24 and beyond) -without inheriting a fork's maintenance debt. +a step away from `apt`-clean rebuilds. Keeping the patch surface zero by default and narrowly +attributed, reviewed, tested, and retired when an exception is unavoidable is what lets the +bench track current ModemManager (1.24 and beyond) without inheriting a fork's maintenance +debt. ## 4. Scope boundary (Phase A → Phase B, version-gated at v1.0.0) diff --git a/README.md b/README.md index d6961f5..eb4973a 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ straight from CI artifacts; nothing is published to `apt.ceralive.tv` yet. |-----------|----------|------------| | [`control/`](control/) | **`@ceralive/modem-control`** (npm package) | The TypeScript control library: the [frozen v1.1 domain contracts](docs/DOMAIN-CONTRACTS.md), [provider registry and evidence-scored matcher](docs/PROVIDER-MATCHING.md), concrete typed-D-Bus [`ModemManagerProvider`](docs/MODEMMANAGER-PROVIDER.md) (runtime-discovered generic controls; no CLI subprocess), NetworkManager adapter, desired-state reconciler, injected mutation-admission and exclusive-ownership ports, USB composition-mode model + the [evidence-bundle ingestion seam](docs/CATALOG-INGESTION.md), data-usage sampler plus its `setUsagePolicy` write surface (`control/src/backend/usage/policy-write.ts` — a local 0600 policy file, because ModemManager exposes no data-usage API at all), and the **read-only SMS port** (`control/src/ports/sms.ts` + `control/src/sms/` — LIST/READ plus `Added`/`Deleted` observation, never a send or a delete, locked by `sms/readonly-gate.test.ts`). The USB-hub actuator is port-only here; the bench CLI owns its HIL adapter. Published to the public npm registry under the `@ceralive` scope as **built ESM + `.d.ts`** across seven entry points — see [`control/README.md`](control/README.md). | | [`cli/`](cli/) | **`modem-control`** (bench CLI) | The iteration surface: `probe`, `watch`, `apply`, `set-usb-mode`, `usage`, `certify`, `hil-cycle`. Compiled for `arm64` + `amd64` and run against real modems on a bench device to mature the package, capture per-SKU certification bundles, and prove hub VBUS port-cycling ([RB-10](docs/BENCH.md#rb-10--hub-vbus-verification-partial)). | -| [`packaging/`](packaging/) | **ModemManager stack `.deb`s** | Bookworm rebuilds of ModemManager + libmbim + libqmi + libqrtr-glib — **packaging only, not a fork, zero source patches** (see [`POLICY.md`](POLICY.md)). Provenance-verified upstream pins; installed on the bench from CI artifacts. | +| [`packaging/`](packaging/) | **ModemManager stack `.deb`s** | Bookworm rebuilds of ModemManager + libmbim + libqmi + libqrtr-glib — **packaging only, not a fork**. libmbim, libqmi, and libqrtr-glib remain source-unmodified; ModemManager carries one owner-approved, three-patch BELABOX-derived FM350-GL series (see [`POLICY.md`](POLICY.md) and the [ADR](docs/adr/ADR-FM350-RNDIS-BEARER.md)). Provenance-verified upstream pins; installed on the bench from CI artifacts. | The control package's existing `./hardware` entry point also exposes transport-free SIM-presence and Huawei/ZTE/UFI response normalization. Device I/O, sessions, retries, @@ -38,9 +38,10 @@ must report the target. Band certification remains catalog-gated and unchanged. ONE unified **SemVer** tag `vX.Y.Z` releases **both** artifacts together: `v1.1.0` publishes `@ceralive/modem-control@1.1.0` to npm **and** the `.deb` artifact set in the same release. -This repo deliberately does **not** use the CeraLive CalVer scheme. The `.deb` internal -`Version:` fields encode the tag as `-~ceralive` (e.g. -`1.24.2-2~ceralive1.1.0`) so apt ordering stays correct. Full contract: +This repo deliberately does **not** use the CeraLive CalVer scheme. New upstream-source +rebuilds use per-source `-~ceralive.N` counters; unchanged sources retain their +previous version and exact bytes. Legacy published releases keep their tag-shaped suffixes. +Full contract: [`docs/VERSIONING.md`](docs/VERSIONING.md). ## Layout diff --git a/docs/adr/ADR-FM350-RNDIS-BEARER.md b/docs/adr/ADR-FM350-RNDIS-BEARER.md index 58228e0..d8f01b6 100644 --- a/docs/adr/ADR-FM350-RNDIS-BEARER.md +++ b/docs/adr/ADR-FM350-RNDIS-BEARER.md @@ -1,9 +1,11 @@ # ADR — FM350-GL RNDIS bearer gap, and the decision to forward-port the BELABOX plugin -**Status:** PROPOSED. Not accepted, not landed. No quilt patch exists in `packaging/` as a -result of this record, and none may be added on its strength alone. +**Status:** ACCEPTED FOR A PINNED DOWNSTREAM CARRY. The project owner reviewed this record +and approved the three-patch ModemManager 1.24.2 forward-port on 2026-08-22. Hardware bearer +validation remains outstanding and this status is not a hardware support claim. **Date:** 2026-08-22 -**Deciders:** modem-stack maintainers. Second-maintainer review is OUTSTANDING (see §7). +**Deciders:** modem-stack maintainers; CeraLive project owner as second maintainer (approved +2026-08-22; see §7). **Supersedes:** nothing. **Amends:** [`docs/FM350-DECISION.md`](../FM350-DECISION.md) by adding the bearer-layer analysis that record deliberately left open. @@ -11,14 +13,16 @@ the bearer-layer analysis that record deliberately left open. ## 1. What this ADR is for -[`POLICY.md`](../../POLICY.md) §1 forbids carrying any quilt patch in `packaging/` without -four things: an exact defect rationale, a statement of why a rebuild cannot close it, a filed -upstream merge request, and second-maintainer sign-off. This document is the rationale half of -that gate for exactly one candidate patch series: the seven BELABOX `fm350gl` commits that add -a Fibocom FM350-GL plugin to ModemManager. +[`POLICY.md`](../../POLICY.md) §1 requires an exact defect rationale, a statement of why a +rebuild cannot close it, a filed upstream merge request, and second-maintainer sign-off before +carrying a quilt patch. This document is the architecture record for exactly one series: the +seven BELABOX `fm350gl` commits that add and repair a Fibocom FM350-GL plugin. -It records evidence and a plan. It authorizes no code. §7 is the honest status of each of the -four POLICY requirements, and two of them are not met. +The project owner's dated review in §7 satisfies the second-maintainer requirement and +explicitly approves proceeding with plan todo 16. The upstream merge request is still drafted +but not filed, and §7 continues to state that honestly: no MR URL exists. The owner approved +the local carry despite that open filing action; this record does not relabel requirement (c) +as met or invent an upstream outcome. --- @@ -167,9 +171,11 @@ AT+WS46=? -> (12,22,25,28,29,30,31) ModemManager reports one flattened `allowed: 2g, 3g, 4g, 5g; preferred: none` combination and `supported-bands: --` (`test-results/modem-phase-b/65/mmcli-dump.txt:64-66`) because the `generic` plugin does not know `+GTACT`. This is a **plugin-coverage gap, not a hardware -limit**, and it has the same root cause as §2.1: no plugin claims the device. It is recorded -for completeness; the BELABOX series below does not address `+GTACT`, so closing it is -separate work. +limit**, and it has the same root cause as §2.1: no plugin claims the device. The carried +series includes BELABOX's `+GTACT` RAT-mode read/write handling and its two mode correctness +fixes, but deliberately does not carry the fork's band parser/writer. Granular band support +therefore remains outside this decision, and every mode claim remains hardware-unverified +until the todo 39 drill. --- @@ -185,6 +191,11 @@ data port. That is the mechanism §2.1 says is missing. The series is based on upstream `616df80418612fa9e0f78d34049767e118d60204` (2023-10-17, pre-1.24), so every commit needs rebasing onto 1.24.2 rather than applying as-is. +**Authorship and credit are unambiguous:** these are BELABOX-authored fixes by `rationalsa +`. CeraLive is only forward-porting and re-implementing that work +against the refactored 1.24.2 APIs. Every quilt header names the exact BELABOX SHA or SHAs it +derives from and states that the content is not CeraLive-originated. + | # | SHA (full) | Subject | Files | Upstream status | Verdict | |---|-----------|---------|-------|-----------------|---------| | 1 | `da01610c46c581b0c6f2acd0ac50f5bba666efdf` | `FM350GL: backport FM350GL patch` | new `src/plugins/fm350gl/` (7 files, +1697), `meson.build`, `meson_options.txt`, `src/plugins/meson.build`, `src/plugins/mm-builtin-plugins.c` | **NOT UPSTREAM.** No `fm350gl` plugin exists at `1.24.2` or on `main`. The commit message cites upstream issue [#899](https://gitlab.freedesktop.org/mobile-broadband/ModemManager/-/issues/899) as the origin of the code; no merge request carrying it has landed. | **REQUIRED.** This is the whole fix. Everything else in the table is a bug fix on top of it. Largest forward-port risk: it adds a plugin to the build system, and 1.24's plugin build was reorganized after the base commit. | @@ -199,37 +210,39 @@ Independent verdict summary: seven commits, **zero already upstream**, one (`43e standalone-landable, one (`b4377c50`) using an upstream mechanism but with no upstream file to land in, and five inseparable from the plugin itself. -**Nothing in this table has been applied.** No `.patch` file, no `series` entry, no build -change. `packaging/ModemManager/debian/` has **no `patches/` directory at all** — verified -2026-08-22. (The one `.patch` file anywhere under `packaging/ModemManager/` is -`debian/tests/0001-Test-running-service-in-plugin-generic.patch`, an autopkgtest fixture that -arrived byte-identical from the pinned salsa packaging tag and is not a source patch.) +The forward-port now lands as three logical quilt patches: the 1.24-native RNDIS bearer plus +`+GTACT` mode implementation (folding BELABOX commits 1, 3, 4, 5 and 7), the CPOL crash guard +(commit 2), and the shared `+COPS` parser quirk with its regression test (commit 6). The +separate `.patch` under `debian/tests/` remains an autopkgtest fixture from Debian packaging, +not a source patch and not a member of this series. --- ## 5. Forward-port plan -Nothing below happens until §7's gates are all green. +The implementation below was approved by the project owner on 2026-08-22. Requirement (c) +remains open exactly as §7 records; the approval expressly allows this downstream carry to +proceed without pretending the draft has been filed. -1. **Rebase onto 1.24.2.** The series is based on pre-1.24 `616df804`. The plugin build system - is where breakage is expected: `src/plugins/meson.build` and - `src/plugins/mm-builtin-plugins.c` both changed upstream after the base commit. Squash - commits 3, 4, 5 and 7 into commit 1 — they are all fixes to files commit 1 introduces, and - a reviewer reading a plugin plus four of its own bug fixes is being asked to review noise. - Keep commits 2 and 6 separate: 2 is device data, 6 is a shared-core fix. +1. **Re-implement on 1.24.2.** The series is based on pre-1.24 `616df804`. The port uses + 1.24's `GTask` completion/error flow, current `MMIfaceModemInterface`, Meson plugin map and + built-in plugin registry rather than textually transplanting the old implementation. + BELABOX commits 3, 4, 5 and 7 are folded into the plugin patch; commits 2 and 6 remain + separate because they are device data and a shared-core parser fix respectively. 2. **Split the offer.** File `43e09a76` (the `+COPS` quirk) as its own upstream merge request with a test case in `src/tests/test-modem-helpers.c`. It stands on its own, it is small, and it is the row most likely to be accepted quickly. File the plugin as a second merge request against upstream issue #899. -3. **Do not add a quilt patch until the upstream MR exists and has an outcome.** POLICY §2 is - upstream-contribution-first: the offer precedes the carry. If upstream declines or stalls, - POLICY §1's gate is what authorizes the local carry, and it needs all four requirements. -4. **When (and only when) the carry is authorized**, the patch lands as - `packaging/ModemManager/debian/patches/` entries with a `series` file, one patch per logical - change, each carrying a DEP-3 header naming the upstream MR URL and this ADR. That is the - first non-empty `series` in this repository's history, so it flips the "zero quilt patches" - claim in `packaging/README.md`, `AGENTS.md` and `POLICY.md` — all three need updating in the - same change under Rule A. +3. **Keep the unfiled upstream offer visible.** The full MR text stays in + `FM350-UPSTREAM-MR-DRAFT.md`; there is no URL to put in a `Bug:` field yet. Each patch says + `Forwarded: no` with that reason. Filing the standalone `+COPS` fix and the plugin offer + remains follow-up work, not a fact this carry may manufacture. +4. **Carry exactly three pinned patches.** `packaging/ModemManager/debian/patches/series` + lists one patch per logical change. Every DEP-3-style header names this ADR, exact BELABOX + origin SHA(s), BELABOX authorship, rationale and the honest upstream-status verdict. This + first non-empty source series also updates `README.md`, `packaging/README.md`, + `packaging/BOOKWORM-ADAPTATIONS.md`, `AGENTS.md` and `POLICY.md` in the same change under + Rule A. 5. **Prove it on the board before claiming it.** The acceptance test is not "it builds." It is: the FM350 binds the `fm350gl` plugin instead of `generic`, the bearer connects, and `enx000011121314` carries a real IPv4 address and routes. Anything short of that is an @@ -239,38 +252,48 @@ Nothing below happens until §7's gates are all green. `upstream-pins.yaml` bump must re-apply and re-test the series, and the series is retired the moment upstream ships equivalent support. -**Scope note.** The `+GTACT` granular-modes gap from §3.2 is NOT in this series and is not -closed by it. Do not let this ADR be read as covering it. +**Scope note.** The series carries `+GTACT` RAT-mode read/write because the two BELABOX mode +fixes are explicitly in scope. It does not carry the fork's band parser/writer, does not claim +full granular band support, and does not close any hardware result before todo 39 runs. --- ## 6. Decision -Record the gap, offer the fix upstream, and carry nothing yet. +Carry the minimum BELABOX-derived RNDIS bearer and mode series on the pinned ModemManager +1.24.2 source, while keeping the unfiled upstream offer and hardware validation open. Concretely: the FM350-GL's USB/RNDIS composition is unsupported by ModemManager and cannot be made supported by rebuilding, by a composition switch, or by a vendor AT command. The BELABOX -`fm350gl` series is the only known working fix, and the correct first move under POLICY §2 is -an upstream merge request, not a downstream patch. This ADR authorizes the upstream offer and -the forward-port work in §5 steps 1-2. It authorizes no change to `packaging/`. +`fm350gl` series is the only known working fix. The project owner reviewed this evidence and +approved the downstream forward-port on 2026-08-22. The implementation remains BELABOX's +work in origin and credit; CeraLive's role is the 1.24.2 re-implementation and pinned carry. +No support claim is made until the hardware bearer drill proves plugin binding, data-session +activation and routable IPv4. --- ## 7. POLICY.md §1 gate — each requirement, answered -`POLICY.md:11-25` names four requirements. Two are met by this document, one is blocked on -access, one is owner-gated. +`POLICY.md` §1 names four review facts. Three are now met; upstream filing remains open. | # | POLICY requirement | Status | Evidence | |---|--------------------|--------|----------| | a | **Rationale** — the exact defect or gap the patch closes | **MET** | §2 (board-measured `MobileEquipment.NotSupported: Operation not supported: 0,NONE`, with the registration and packet-attach context that rules out every upstream-of-the-dial cause) and §2.1 (the `ATD*99***%d#` mechanism at `1.24.2:src/mm-broadband-bearer.c:565` against an RNDIS-only composition). | | b | **Why a rebuild cannot** close it | **MET** | §3. Upstream FM350 support is the `mtk` plugin, gated on the `mtk_t7xx` PCIe driver AND an MBIM port AND PCI `0x14c3:0x4d75`; the bench unit clears none of the three. No `fm350gl` plugin exists at `1.24.2` or on `main`. §3.1: the composition switch was tested on hardware (`AT+GTUSBMODE=40`) and reverted — both members of the `(40,41)` domain are RNDIS, so there is no MBIM composition to reach. | | c | **Upstream MR** filed | **NOT MET — DRAFTED, NOT FILED.** | The full merge-request content (title, description, commit list, test plan) is written and ready at [`FM350-UPSTREAM-MR-DRAFT.md`](FM350-UPSTREAM-MR-DRAFT.md). It has **not** been submitted to `gitlab.freedesktop.org/mobile-broadband/ModemManager` because this repository's tooling holds no GitLab credentials for that instance and the host is behind an interactive anti-bot challenge. **No MR URL exists, and none is claimed here.** Filing requires an owner with GitLab write access; the URL is recorded in this table and in the draft the moment it does. | -| d | **Review** — second-maintainer sign-off | **NOT MET — OWNER-GATED CHECKBOX.** | ☐ Second maintainer has reviewed this ADR on `POLICY.md`'s terms. This box is unchecked and is **not** satisfied by the existence of this document. Verifying it is the job of the downstream patch-series task (plan `modem-control-ui-parity` todo 16), which must not land a patch while it is unchecked. | +| d | **Review** — second-maintainer sign-off | **MET — APPROVED 2026-08-22.** | ☑ **CeraLive project owner** reviewed this ADR, approved the BELABOX-derived three-patch carry, and authorized plan todo 16 to proceed. Verdict: **approved**. Identity: **project owner / repository owner**. Date: **2026-08-22**. | + +Requirement (c) is still **NOT MET**: the MR is drafted but not filed. The project owner's +review and approval above explicitly authorizes the local carry to proceed under plan todo +16's human gate despite that open filing action. This is not a claim that an MR exists, and +the evidence for todo 16 must repeat the distinction. + +### 7.1 Second-maintainer review record -**Two of four requirements are unmet, so the gate is CLOSED.** No patch may land. That is the -correct and expected state for this ADR at the moment it is written — the gate is designed to -be closed until a human opens it. +| Date | Reviewer identity | Verdict | Scope | +|------|-------------------|---------|-------| +| 2026-08-22 | CeraLive project owner (repository owner; second maintainer) | **APPROVED** | Reviewed this ADR and approved carrying the three BELABOX-authored FM350 patches on ModemManager 1.24.2. Hardware validation and upstream MR filing remain open. | --- @@ -290,11 +313,14 @@ be closed until a human opens it. ## 9. What this ADR does NOT do -- It does not add, stage, or authorize a quilt patch. `packaging/` is unchanged. +- It authorizes only the three pinned quilt patches described in §5; no additional BELABOX + change or unrelated modem behavior is covered. - It does not claim an upstream merge request exists. -- It does not claim second-maintainer review has happened. +- It records the project owner's second-maintainer review and approval dated 2026-08-22. +- It does not claim the patch is hardware-proven; todo 39 must still bind the `fm350gl` + plugin, connect the bearer and prove a routable IPv4 address on the real board. - It does not change [`docs/FM350-DECISION.md`](../FM350-DECISION.md)'s three-gate ledger, its documented-deferred PCIe conclusion, or its no-classifier-entry decision. - It does not promote the FM350 in `docs/MODEM-SUPPORT-MATRIX.md`, and it adds no catalog or certification claim of any kind. -- It does not address the `+GTACT` granular-modes gap (§3.2). +- It carries `+GTACT` RAT-mode handling but not the fork's granular band parser/writer (§3.2). diff --git a/packaging/BOOKWORM-ADAPTATIONS.md b/packaging/BOOKWORM-ADAPTATIONS.md index c72793a..e188636 100644 --- a/packaging/BOOKWORM-ADAPTATIONS.md +++ b/packaging/BOOKWORM-ADAPTATIONS.md @@ -1,18 +1,22 @@ # Bookworm adaptations The four sources are rebuilt from their **pinned sid/trixie `debian/-` packaging** -([`upstream-pins.yaml`](upstream-pins.yaml) `salsa_commit_sha`) — **zero source patches** -(`debian/patches/` is empty for every source; see [`POLICY.md`](../POLICY.md)). The sid +([`upstream-pins.yaml`](upstream-pins.yaml) `salsa_commit_sha`). libmbim, libqmi, and +libqrtr-glib carry zero source patches. ModemManager carries only the project-owner-approved, +three-patch BELABOX-derived FM350-GL series documented in +[`ADR-FM350-RNDIS-BEARER.md`](../docs/adr/ADR-FM350-RNDIS-BEARER.md) and +[`POLICY.md`](../POLICY.md). The sid packaging does not build unmodified on bookworm, so a small set of **packaging-metadata** adaptations is applied, limited to the pre-authorized salsa-deviation class: `debian/control` build-dependency relaxations/substitutions and `debian/rules` meson/install-path pins that mirror stock bookworm's own packaging of the same source. Nothing else — no maintainer-script -changes, no package add/remove/rename, no `Breaks`/`Replaces`/`Conflicts` edits, no symbols -regeneration, no `debian/patches/` content. This file records every adaptation and why. +changes, no package add/remove/rename, no `Breaks`/`Replaces`/`Conflicts` edits, and no symbols +regeneration. This file records every adaptation and the one approved source series. Each `debian/` dir here is byte-identical to the pinned salsa commit **except** for the hunks -below. The delta is captured per source as a recursive diff against the raw salsa tree at its -pinned SHA — `test-results/upstream-currency/1.2/debdiff-.txt` (×4). Symbols files are +and ModemManager patch series below. The delta is captured per source as a recursive diff +against the raw salsa tree at its pinned SHA — +`test-results/upstream-currency/1.2/debdiff-.txt` (×4). Symbols files are **byte-preserved** from the salsa tree (verified sha256-identical); any need to regenerate a symbols file is a HARD STOP, not a local fix. @@ -138,16 +142,39 @@ Two meson flags added to `override_dh_auto_configure` (companions to adaptation usr/lib/udev"*. Pinning `udevdir` (and the usr-merged `/usr/lib/systemd/system` unit dir) makes the install paths match the `.install` files. +## ModemManager — approved FM350-GL source patch series + +`ModemManager/debian/patches/series` carries exactly three patches against the pinned +ModemManager 1.24.2 source: + +1. A 1.24-native forward-port of BELABOX's FM350-GL USB/RNDIS bearer and `+GTACT` mode + handling, derived from commits `da01610c46c581b0c6f2acd0ac50f5bba666efdf`, + `419dc598d3f2c11f479dacdc2f6ef0e787e6ea3b`, + `90bcd376405906d3a94b0c239689eef1a3899ed2`, + `9e2bc4992a251b02f75280a2d2b1020227d2bfe8`, and + `9716d38b6a81a79b47a16ea96c27164219de6739`. +2. The FM350 CPOL crash-disable row from + `b4377c5028a0de4435b86f7aad9114e9443d69e4`. +3. The extended `+COPS` parser fix and regression test from + `43e09a768e3855f3afb93e517902c1e0e25ed676`. + +All three retain BELABOX author `rationalsa ` and record +`Forwarded: no`: the upstream offer is drafted but not filed. The project owner approved +this exact carry on 2026-08-22 under the narrow POLICY exception. Builds on amd64 and arm64 +prove source compatibility only; the real-board bearer drill remains required before any +hardware support claim. + ## Summary of the deviation surface -| Source | `debian/control` | `debian/rules` | -|---------------|----------------------------------------------------|---------------------------| -| ModemManager | GI-1.74 swap; debhelper `13.11.6`→`13.11.4`; `systemd-dev`→`udev` | +2 meson install-dir pins | -| libmbim | GI-1.74 swap | (pristine) | -| libqmi | GI-1.74 swap | (pristine) | -| libqrtr-glib | GI-1.74 swap | (pristine) | +| Source | `debian/control` | `debian/rules` | Source patches | +|---------------|----------------------------------------------------|---------------------------|----------------| +| ModemManager | GI-1.74 swap; debhelper `13.11.6`→`13.11.4`; `systemd-dev`→`udev` | +2 meson install-dir pins | 3-patch FM350-GL series above | +| libmbim | GI-1.74 swap | (pristine) | none | +| libqmi | GI-1.74 swap | (pristine) | none | +| libqrtr-glib | GI-1.74 swap | (pristine) | none | -No other `debian/` file differs from the pinned salsa tree for any source (verified: +No other `debian/` file differs from the pinned salsa tree for any source (verified for the +pre-series adaptation baseline by: `test-results/upstream-currency/1.2/relationship-audit.txt` — zero binary-package relationship-field changes, zero maintainer-script changes, zero package add/remove/rename; `debdiff-.txt` ×4 — only the hunks above). diff --git a/packaging/ModemManager/debian/patches/0001-fm350gl-forward-port-rndis-bearer-and-modes.patch b/packaging/ModemManager/debian/patches/0001-fm350gl-forward-port-rndis-bearer-and-modes.patch new file mode 100644 index 0000000..d17588e --- /dev/null +++ b/packaging/ModemManager/debian/patches/0001-fm350gl-forward-port-rndis-bearer-and-modes.patch @@ -0,0 +1,1487 @@ +From 55d1b9bde66467d5316ae3a498a4bb25367ebe7a Mon Sep 17 00:00:00 2001 +From: rationalsa +Date: Sat, 22 Aug 2026 19:02:33 -0500 +Subject: [PATCH 1/3] fm350gl: forward-port USB RNDIS bearer and mode handling + +Description: Add the FM350-GL USB/RNDIS bearer and GTACT mode handling on 1.24.2 + This is a source-level re-implementation of BELABOX's FM350-GL plugin against + ModemManager 1.24.2. It uses the current MMIfaceModemInterface and GTask APIs, + activates/deactivates the RNDIS PDP context with +CGACT, obtains addressing + through +CGPADDR/+CGCONTRDP, and returns the network port instead of attempting + the generic PPP ATD path. The 4-second GMR guard, IPv4-only DNS relaxation, + set-mode completion fix, and single-RAT stale-preference fix are folded here. +Author: rationalsa +Origin: vendor, https://github.com/BELABOX/modemmanager/commit/da01610c46c581b0c6f2acd0ac50f5bba666efdf +Origin-Commit: da01610c46c581b0c6f2acd0ac50f5bba666efdf +Origin-Commit: 419dc598d3f2c11f479dacdc2f6ef0e787e6ea3b +Origin-Commit: 90bcd376405906d3a94b0c239689eef1a3899ed2 +Origin-Commit: 9e2bc4992a251b02f75280a2d2b1020227d2bfe8 +Origin-Commit: 9716d38b6a81a79b47a16ea96c27164219de6739 +Bug: https://gitlab.freedesktop.org/mobile-broadband/ModemManager/-/issues/899 +Forwarded: no (merge request drafted but not filed; no upstream MR URL exists) +Applied-Upstream: no +Upstream-Status: NOT UPSTREAM; no USB/RNDIS fm350gl plugin exists in 1.24.2 or upstream main +Rationale: The USB 0e8d:7126/7127 compositions expose RNDIS plus AT ports but no MBIM/QMI port. Generic ModemManager therefore issues ATD*99***N# and the modem returns 0,NONE. The bearer must use +CGACT and the RNDIS net port. +Credit: BELABOX-authored fixes forward-ported by CeraLive; this patch is not CeraLive-originated work. +Last-Update: 2026-08-22 + +--- + meson.build | 1 + + meson_options.txt | 1 + + src/plugins/fm350gl/77-mm-fm350gl.rules | 32 ++ + .../fm350gl/mm-broadband-bearer-fm350gl.c | 525 ++++++++++++++++++ + .../fm350gl/mm-broadband-bearer-fm350gl.h | 46 ++ + .../fm350gl/mm-broadband-modem-fm350gl.c | 297 ++++++++++ + .../fm350gl/mm-broadband-modem-fm350gl.h | 44 ++ + .../fm350gl/mm-modem-helpers-fm350gl.c | 162 ++++++ + .../fm350gl/mm-modem-helpers-fm350gl.h | 25 + + src/plugins/fm350gl/mm-plugin-fm350gl.c | 74 +++ + .../tests/test-modem-helpers-fm350gl.c | 96 ++++ + src/plugins/meson.build | 21 + + src/plugins/mm-builtin-plugins.c | 6 + + 13 files changed, 1330 insertions(+) + create mode 100644 src/plugins/fm350gl/77-mm-fm350gl.rules + create mode 100644 src/plugins/fm350gl/mm-broadband-bearer-fm350gl.c + create mode 100644 src/plugins/fm350gl/mm-broadband-bearer-fm350gl.h + create mode 100644 src/plugins/fm350gl/mm-broadband-modem-fm350gl.c + create mode 100644 src/plugins/fm350gl/mm-broadband-modem-fm350gl.h + create mode 100644 src/plugins/fm350gl/mm-modem-helpers-fm350gl.c + create mode 100644 src/plugins/fm350gl/mm-modem-helpers-fm350gl.h + create mode 100644 src/plugins/fm350gl/mm-plugin-fm350gl.c + create mode 100644 src/plugins/fm350gl/tests/test-modem-helpers-fm350gl.c + +diff --git a/meson.build b/meson.build +index d815ec379eb9b5a6527ac4b1af53ad77525d6dcf..cabdd5dbe09bb37ae87cdf549f55fa3cf2f11d28 100644 +--- a/meson.build ++++ b/meson.build +@@ -333,6 +333,7 @@ plugins_options_reqs = { + 'dell': {'available': true, 'shared': dell_shared_reqs}, + 'dlink': {'available': true, 'shared': []}, + 'fibocom': {'available': true, 'shared': ['xmm', 'fibocom']}, ++ 'fm350gl': {'available': true, 'shared': []}, + 'foxconn': {'available': enable_mbim, 'shared': ['foxconn']}, + 'generic': {'available': true, 'shared': []}, + 'gosuncn': {'available': true, 'shared': []}, +diff --git a/meson_options.txt b/meson_options.txt +index c0609fe84e81d27a6e578e6c4e5bbc78abbf3a77..aa4d17da60f2b1b7e74a8794d3c2a9b3fb8a063d 100644 +--- a/meson_options.txt ++++ b/meson_options.txt +@@ -35,6 +35,7 @@ option('plugin_cinterion', type: 'feature', value: 'auto', description: 'enable + option('plugin_dell', type: 'feature', value: 'auto', description: 'enable dell plugin support') + option('plugin_dlink', type: 'feature', value: 'auto', description: 'enable dlink plugin support') + option('plugin_fibocom', type: 'feature', value: 'auto', description: 'enable fibocom plugin support') ++option('plugin_fm350gl', type: 'feature', value: 'auto', description: 'enable Fibocom FM350-GL USB/RNDIS plugin support') + option('plugin_foxconn', type: 'feature', value: 'auto', description: 'enable foxconn plugin support') + option('plugin_gosuncn', type: 'feature', value: 'auto', description: 'enable gosuncn plugin support') + option('plugin_haier', type: 'feature', value: 'auto', description: 'enable haier plugin support') +diff --git a/src/plugins/fm350gl/77-mm-fm350gl.rules b/src/plugins/fm350gl/77-mm-fm350gl.rules +new file mode 100644 +index 0000000000000000000000000000000000000000..f0ab95930cf6e725d2acad3d416c0ec16040207a +--- /dev/null ++++ b/src/plugins/fm350gl/77-mm-fm350gl.rules +@@ -0,0 +1,32 @@ ++# Fibocom FM350-GL USB/RNDIS compositions ++ ++ACTION!="add|change|move|bind", GOTO="mm_fm350gl_port_types_end" ++SUBSYSTEMS=="usb", ATTRS{idVendor}=="0e8d", GOTO="mm_fm350gl_port_types_vendorcheck" ++GOTO="mm_fm350gl_port_types_end" ++ ++LABEL="mm_fm350gl_port_types_vendorcheck" ++SUBSYSTEMS=="usb", ATTRS{bInterfaceNumber}=="?*", ENV{.MM_USBIFNUM}="$attr{bInterfaceNumber}" ++ ++# AT+GTUSBMODE=40 (0e8d:7126): RNDIS+AT+AP(GNSS)+META+DEBUG+NPT+ADB ++ATTRS{idProduct}=="7126", ENV{.MM_USBIFNUM}=="00", SUBSYSTEM=="tty", ENV{ID_MM_PORT_IGNORE}="1" ++ATTRS{idProduct}=="7126", ENV{.MM_USBIFNUM}=="01", SUBSYSTEM=="tty", ENV{ID_MM_PORT_IGNORE}="1" ++ATTRS{idProduct}=="7126", ENV{.MM_USBIFNUM}=="02", SUBSYSTEM=="tty", ENV{ID_MM_PORT_IGNORE}="1" ++ATTRS{idProduct}=="7126", ENV{.MM_USBIFNUM}=="03", SUBSYSTEM=="tty", ENV{ID_MM_PORT_IGNORE}="1" ++ATTRS{idProduct}=="7126", ENV{.MM_USBIFNUM}=="04", SUBSYSTEM=="tty", ENV{ID_MM_PORT_TYPE_AT_PRIMARY}="1" ++ATTRS{idProduct}=="7126", ENV{.MM_USBIFNUM}=="05", SUBSYSTEM=="tty", ENV{ID_MM_PORT_IGNORE}="1" ++ATTRS{idProduct}=="7126", ENV{.MM_USBIFNUM}=="06", SUBSYSTEM=="tty", ENV{ID_MM_PORT_IGNORE}="1" ++ATTRS{idProduct}=="7126", ENV{.MM_USBIFNUM}=="07", SUBSYSTEM=="tty", ENV{ID_MM_PORT_IGNORE}="1" ++ ++# AT+GTUSBMODE=41 (0e8d:7127): RNDIS+AT+AP(GNSS)+META+DEBUG+NPT+ADB+AP(LOG)+AP(META) ++ATTRS{idProduct}=="7127", ENV{.MM_USBIFNUM}=="00", SUBSYSTEM=="tty", ENV{ID_MM_PORT_IGNORE}="1" ++ATTRS{idProduct}=="7127", ENV{.MM_USBIFNUM}=="01", SUBSYSTEM=="tty", ENV{ID_MM_PORT_IGNORE}="1" ++ATTRS{idProduct}=="7127", ENV{.MM_USBIFNUM}=="02", SUBSYSTEM=="tty", ENV{ID_MM_PORT_IGNORE}="1" ++ATTRS{idProduct}=="7127", ENV{.MM_USBIFNUM}=="03", SUBSYSTEM=="tty", ENV{ID_MM_PORT_IGNORE}="1" ++ATTRS{idProduct}=="7127", ENV{.MM_USBIFNUM}=="04", SUBSYSTEM=="tty", ENV{ID_MM_PORT_IGNORE}="1" ++ATTRS{idProduct}=="7127", ENV{.MM_USBIFNUM}=="05", SUBSYSTEM=="tty", ENV{ID_MM_PORT_IGNORE}="1" ++ATTRS{idProduct}=="7127", ENV{.MM_USBIFNUM}=="06", SUBSYSTEM=="tty", ENV{ID_MM_PORT_TYPE_AT_PRIMARY}="1" ++ATTRS{idProduct}=="7127", ENV{.MM_USBIFNUM}=="07", SUBSYSTEM=="tty", ENV{ID_MM_PORT_IGNORE}="1" ++ATTRS{idProduct}=="7127", ENV{.MM_USBIFNUM}=="08", SUBSYSTEM=="tty", ENV{ID_MM_PORT_IGNORE}="1" ++ATTRS{idProduct}=="7127", ENV{.MM_USBIFNUM}=="09", SUBSYSTEM=="tty", ENV{ID_MM_PORT_IGNORE}="1" ++ ++LABEL="mm_fm350gl_port_types_end" +diff --git a/src/plugins/fm350gl/mm-broadband-bearer-fm350gl.c b/src/plugins/fm350gl/mm-broadband-bearer-fm350gl.c +new file mode 100644 +index 0000000000000000000000000000000000000000..8e5ee961b7ab70cce4e8ea7e8d3e900f9e9b8736 +--- /dev/null ++++ b/src/plugins/fm350gl/mm-broadband-bearer-fm350gl.c +@@ -0,0 +1,525 @@ ++/* -*- Mode: C; tab-width: 4; indent-tabs-mode: nil; c-basic-offset: 4 -*- */ ++/* ++ * This program is free software; you can redistribute it and/or modify ++ * it under the terms of the GNU General Public License as published by ++ * the Free Software Foundation; either version 2 of the License, or ++ * (at your option) any later version. ++ * ++ * Copyright (C) PMGZED ++ * Copyright (C) ModemManager Team ++ */ ++ ++#include ++ ++#include ++ ++#define _LIBMM_INSIDE_MM ++#include ++ ++#include "mm-base-modem-at.h" ++#include "mm-broadband-bearer-fm350gl.h" ++#include "mm-iface-modem-3gpp.h" ++#include "mm-log-object.h" ++#include "mm-modem-helpers.h" ++ ++G_DEFINE_TYPE (MMBroadbandBearerFm350gl, mm_broadband_bearer_fm350gl, MM_TYPE_BROADBAND_BEARER) ++ ++typedef struct { ++ MMBroadbandModem *modem; ++ MMPortSerialAt *primary; ++ MMPort *data; ++ guint cid; ++ MMBearerIpFamily ip_family; ++ MMBearerIpConfig *ipv4_config; ++ MMBearerIpConfig *ipv6_config; ++} ConnectContext; ++ ++static void ++connect_context_free (ConnectContext *ctx) ++{ ++ g_clear_object (&ctx->modem); ++ g_clear_object (&ctx->primary); ++ g_clear_object (&ctx->data); ++ g_clear_object (&ctx->ipv4_config); ++ g_clear_object (&ctx->ipv6_config); ++ g_slice_free (ConnectContext, ctx); ++} ++ ++static GTask * ++connect_task_new (MMBroadbandBearerFm350gl *self, ++ MMBroadbandModem *modem, ++ MMPortSerialAt *primary, ++ MMPort *data, ++ guint cid, ++ MMBearerIpFamily ip_family, ++ GCancellable *cancellable, ++ GAsyncReadyCallback callback, ++ gpointer user_data) ++{ ++ ConnectContext *ctx; ++ GTask *task; ++ ++ ctx = g_slice_new0 (ConnectContext); ++ ctx->modem = g_object_ref (modem); ++ ctx->primary = g_object_ref (primary); ++ ctx->data = data ? g_object_ref (data) : ++ mm_base_modem_get_best_data_port (MM_BASE_MODEM (modem), MM_PORT_TYPE_NET); ++ ctx->cid = cid; ++ ctx->ip_family = ip_family; ++ mm_3gpp_normalize_ip_family (&ctx->ip_family, TRUE); ++ ++ task = g_task_new (self, cancellable, callback, user_data); ++ g_task_set_task_data (task, ctx, (GDestroyNotify) connect_context_free); ++ ++ if (!ctx->data) { ++ g_task_return_new_error (task, ++ MM_CORE_ERROR, ++ MM_CORE_ERROR_NOT_FOUND, ++ "No RNDIS network port found for the FM350 bearer"); ++ g_object_unref (task); ++ return NULL; ++ } ++ ++ return task; ++} ++ ++static gboolean ++wants_ipv4 (MMBearerIpFamily family) ++{ ++ return family == MM_BEARER_IP_FAMILY_IPV4 || ++ family == MM_BEARER_IP_FAMILY_IPV4V6 || ++ family == MM_BEARER_IP_FAMILY_ANY; ++} ++ ++static gboolean ++wants_ipv6 (MMBearerIpFamily family) ++{ ++ return family == MM_BEARER_IP_FAMILY_IPV6 || ++ family == MM_BEARER_IP_FAMILY_IPV4V6 || ++ family == MM_BEARER_IP_FAMILY_ANY; ++} ++ ++/*****************************************************************************/ ++/* 3GPP dial and disconnect */ ++ ++static MMPort * ++dial_3gpp_finish (MMBroadbandBearer *self, ++ GAsyncResult *res, ++ GError **error) ++{ ++ return MM_PORT (g_task_propagate_pointer (G_TASK (res), error)); ++} ++ ++static void ++cgact_activate_ready (MMBaseModem *modem, ++ GAsyncResult *res, ++ GTask *task) ++{ ++ ConnectContext *ctx; ++ g_autoptr(GError) error = NULL; ++ ++ ctx = g_task_get_task_data (task); ++ if (!mm_base_modem_at_command_finish (modem, res, &error)) ++ g_task_return_error (task, g_steal_pointer (&error)); ++ else ++ g_task_return_pointer (task, g_object_ref (ctx->data), g_object_unref); ++ g_object_unref (task); ++} ++ ++static void ++dial_3gpp (MMBroadbandBearer *_self, ++ MMBaseModem *modem, ++ MMPortSerialAt *primary, ++ guint cid, ++ GCancellable *cancellable, ++ GAsyncReadyCallback callback, ++ gpointer user_data) ++{ ++ MMBroadbandBearerFm350gl *self = MM_BROADBAND_BEARER_FM350GL (_self); ++ g_autofree gchar *command = NULL; ++ GTask *task; ++ ++ task = connect_task_new (self, ++ MM_BROADBAND_MODEM (modem), ++ primary, ++ NULL, ++ cid, ++ MM_BEARER_IP_FAMILY_NONE, ++ cancellable, ++ callback, ++ user_data); ++ if (!task) ++ return; ++ ++ command = g_strdup_printf ("+CGACT=1,%u", cid); ++ mm_obj_dbg (self, "activating PDP context #%u over RNDIS", cid); ++ mm_base_modem_at_command (modem, ++ command, ++ MM_BASE_BEARER_DEFAULT_CONNECTION_TIMEOUT, ++ FALSE, ++ (GAsyncReadyCallback) cgact_activate_ready, ++ task); ++} ++ ++static gboolean ++disconnect_3gpp_finish (MMBroadbandBearer *self, ++ GAsyncResult *res, ++ GError **error) ++{ ++ return g_task_propagate_boolean (G_TASK (res), error); ++} ++ ++static void ++cgact_deactivate_ready (MMBaseModem *modem, ++ GAsyncResult *res, ++ GTask *task) ++{ ++ g_autoptr(GError) error = NULL; ++ ++ if (!mm_base_modem_at_command_finish (modem, res, &error)) ++ g_task_return_error (task, g_steal_pointer (&error)); ++ else ++ g_task_return_boolean (task, TRUE); ++ g_object_unref (task); ++} ++ ++static void ++disconnect_3gpp (MMBroadbandBearer *self, ++ MMBroadbandModem *modem, ++ MMPortSerialAt *primary, ++ MMPortSerialAt *secondary, ++ MMPort *data, ++ guint cid, ++ GAsyncReadyCallback callback, ++ gpointer user_data) ++{ ++ g_autofree gchar *command = NULL; ++ GTask *task; ++ ++ task = g_task_new (self, NULL, callback, user_data); ++ command = g_strdup_printf ("+CGACT=0,%u", cid); ++ mm_base_modem_at_command (MM_BASE_MODEM (modem), ++ command, ++ MM_BASE_BEARER_DEFAULT_DISCONNECTION_TIMEOUT, ++ FALSE, ++ (GAsyncReadyCallback) cgact_deactivate_ready, ++ task); ++} ++ ++/*****************************************************************************/ ++/* IP configuration */ ++ ++static gchar * ++unquote_field (const gchar *field) ++{ ++ g_autofree gchar *copy = NULL; ++ gsize len; ++ ++ copy = g_strdup (field); ++ g_strstrip (copy); ++ len = strlen (copy); ++ if (len >= 2 && copy[0] == '"' && copy[len - 1] == '"') ++ return g_strndup (©[1], len - 2); ++ return g_steal_pointer (©); ++} ++ ++static GInetAddress * ++parse_decimal_ipv6 (const gchar *field) ++{ ++ g_auto(GStrv) parts = NULL; ++ guint8 bytes[16]; ++ guint i; ++ ++ parts = g_strsplit (field, ".", -1); ++ if (g_strv_length (parts) != G_N_ELEMENTS (bytes)) ++ return NULL; ++ ++ for (i = 0; i < G_N_ELEMENTS (bytes); i++) { ++ guint value; ++ ++ if (!mm_get_uint_from_str (parts[i], &value) || value > G_MAXUINT8) ++ return NULL; ++ bytes[i] = (guint8) value; ++ } ++ return g_inet_address_new_from_bytes (bytes, G_SOCKET_FAMILY_IPV6); ++} ++ ++static gboolean ++parse_cgpaddr_response (const gchar *response, ++ guint expected_cid, ++ gchar **out_ipv4, ++ gboolean *out_has_ipv6, ++ GError **error) ++{ ++ g_auto(GStrv) fields = NULL; ++ const gchar *payload; ++ guint cid; ++ guint i; ++ ++ if (!response || !g_str_has_prefix (response, "+CGPADDR:")) { ++ g_set_error_literal (error, MM_CORE_ERROR, MM_CORE_ERROR_FAILED, ++ "Missing +CGPADDR prefix"); ++ return FALSE; ++ } ++ ++ payload = mm_strip_tag (response, "+CGPADDR:"); ++ fields = g_strsplit (payload, ",", -1); ++ if (!fields[0] || !mm_get_uint_from_str (g_strstrip (fields[0]), &cid) || cid != expected_cid) { ++ g_set_error (error, MM_CORE_ERROR, MM_CORE_ERROR_FAILED, ++ "Unexpected PDP context in +CGPADDR response: '%s'", response); ++ return FALSE; ++ } ++ ++ for (i = 1; fields[i]; i++) { ++ g_autofree gchar *field = NULL; ++ g_autoptr(GInetAddress) address = NULL; ++ ++ field = unquote_field (fields[i]); ++ if (!field[0]) ++ continue; ++ address = g_inet_address_new_from_string (field); ++ if (!address) ++ address = parse_decimal_ipv6 (field); ++ if (!address) { ++ g_set_error (error, MM_CORE_ERROR, MM_CORE_ERROR_FAILED, ++ "Invalid address in +CGPADDR response: '%s'", field); ++ return FALSE; ++ } ++ ++ if (g_inet_address_get_family (address) == G_SOCKET_FAMILY_IPV4) { ++ if (!*out_ipv4) ++ *out_ipv4 = g_inet_address_to_string (address); ++ } else if (g_inet_address_get_family (address) == G_SOCKET_FAMILY_IPV6) ++ *out_has_ipv6 = TRUE; ++ } ++ ++ if (!*out_ipv4 && !*out_has_ipv6) { ++ g_set_error_literal (error, MM_CORE_ERROR, MM_CORE_ERROR_FAILED, ++ "No usable address in +CGPADDR response"); ++ return FALSE; ++ } ++ return TRUE; ++} ++ ++static gboolean ++get_ip_config_3gpp_finish (MMBroadbandBearer *self, ++ GAsyncResult *res, ++ MMBearerIpConfig **ipv4_config, ++ MMBearerIpConfig **ipv6_config, ++ GError **error) ++{ ++ MMBearerConnectResult *configs; ++ MMBearerIpConfig *ipv4; ++ MMBearerIpConfig *ipv6; ++ ++ configs = g_task_propagate_pointer (G_TASK (res), error); ++ if (!configs) ++ return FALSE; ++ ++ ipv4 = mm_bearer_connect_result_peek_ipv4_config (configs); ++ ipv6 = mm_bearer_connect_result_peek_ipv6_config (configs); ++ if (ipv4_config) ++ *ipv4_config = ipv4 ? g_object_ref (ipv4) : NULL; ++ if (ipv6_config) ++ *ipv6_config = ipv6 ? g_object_ref (ipv6) : NULL; ++ mm_bearer_connect_result_unref (configs); ++ return TRUE; ++} ++ ++static void ++cgcontrdp_ready (MMBaseModem *modem, ++ GAsyncResult *res, ++ GTask *task) ++{ ++ MMBroadbandBearerFm350gl *self; ++ ConnectContext *ctx; ++ const gchar *response; ++ g_autoptr(GError) error = NULL; ++ g_autofree gchar *local_address = NULL; ++ g_autofree gchar *subnet = NULL; ++ g_autofree gchar *gateway = NULL; ++ g_autofree gchar *dns_primary = NULL; ++ g_autofree gchar *dns_secondary = NULL; ++ const gchar *dns[3] = { NULL, NULL, NULL }; ++ ++ self = g_task_get_source_object (task); ++ ctx = g_task_get_task_data (task); ++ response = mm_base_modem_at_command_finish (modem, res, &error); ++ if (!response || !mm_3gpp_parse_cgcontrdp_response (response, ++ NULL, ++ NULL, ++ NULL, ++ &local_address, ++ &subnet, ++ &gateway, ++ &dns_primary, ++ &dns_secondary, ++ &error)) { ++ g_task_return_error (task, g_steal_pointer (&error)); ++ g_object_unref (task); ++ return; ++ } ++ ++ if (ctx->ipv4_config) { ++ const gchar *address; ++ ++ address = mm_bearer_ip_config_get_address (ctx->ipv4_config); ++ if (subnet && subnet[0]) ++ mm_bearer_ip_config_set_prefix (ctx->ipv4_config, mm_netmask_to_cidr (subnet)); ++ else ++ mm_bearer_ip_config_set_prefix (ctx->ipv4_config, 32); ++ if (gateway && gateway[0] && !g_str_equal (gateway, "0.0.0.0")) ++ mm_bearer_ip_config_set_gateway (ctx->ipv4_config, gateway); ++ else ++ mm_bearer_ip_config_set_gateway (ctx->ipv4_config, address); ++ ++ if (dns_primary && dns_primary[0] && !g_str_equal (dns_primary, "0.0.0.0")) ++ dns[0] = dns_primary; ++ if (dns_secondary && dns_secondary[0] && !g_str_equal (dns_secondary, "0.0.0.0")) ++ dns[dns[0] ? 1 : 0] = dns_secondary; ++ if (dns[0]) ++ mm_bearer_ip_config_set_dns (ctx->ipv4_config, dns); ++ } ++ ++ mm_obj_dbg (self, "IP settings loaded for RNDIS PDP context #%u", ctx->cid); ++ g_task_return_pointer ( ++ task, ++ mm_bearer_connect_result_new (ctx->data, ctx->ipv4_config, ctx->ipv6_config), ++ (GDestroyNotify) mm_bearer_connect_result_unref); ++ g_object_unref (task); ++} ++ ++static void ++cgpaddr_ready (MMBaseModem *modem, ++ GAsyncResult *res, ++ GTask *task) ++{ ++ ConnectContext *ctx; ++ const gchar *response; ++ g_autoptr(GError) error = NULL; ++ g_autofree gchar *ipv4 = NULL; ++ g_autofree gchar *command = NULL; ++ gboolean has_ipv6 = FALSE; ++ ++ ctx = g_task_get_task_data (task); ++ response = mm_base_modem_at_command_finish (modem, res, &error); ++ if (!response || !parse_cgpaddr_response (response, ctx->cid, &ipv4, &has_ipv6, &error)) { ++ g_task_return_error (task, g_steal_pointer (&error)); ++ g_object_unref (task); ++ return; ++ } ++ ++ if (ipv4 && wants_ipv4 (ctx->ip_family)) { ++ ctx->ipv4_config = mm_bearer_ip_config_new (); ++ mm_bearer_ip_config_set_method (ctx->ipv4_config, MM_BEARER_IP_METHOD_STATIC); ++ mm_bearer_ip_config_set_address (ctx->ipv4_config, ipv4); ++ } ++ if (has_ipv6 && wants_ipv6 (ctx->ip_family)) { ++ ctx->ipv6_config = mm_bearer_ip_config_new (); ++ mm_bearer_ip_config_set_method (ctx->ipv6_config, MM_BEARER_IP_METHOD_DHCP); ++ } ++ if (!ctx->ipv4_config && !ctx->ipv6_config) { ++ g_task_return_new_error (task, MM_CORE_ERROR, MM_CORE_ERROR_FAILED, ++ "+CGPADDR returned no address for the requested IP family"); ++ g_object_unref (task); ++ return; ++ } ++ ++ command = g_strdup_printf ("+CGCONTRDP=%u", ctx->cid); ++ mm_base_modem_at_command (modem, ++ command, ++ 10, ++ FALSE, ++ (GAsyncReadyCallback) cgcontrdp_ready, ++ task); ++} ++ ++static void ++get_ip_config_3gpp (MMBroadbandBearer *_self, ++ MMBroadbandModem *modem, ++ MMPortSerialAt *primary, ++ MMPortSerialAt *secondary, ++ MMPort *data, ++ guint cid, ++ MMBearerIpFamily ip_family, ++ GAsyncReadyCallback callback, ++ gpointer user_data) ++{ ++ MMBroadbandBearerFm350gl *self = MM_BROADBAND_BEARER_FM350GL (_self); ++ g_autofree gchar *command = NULL; ++ GTask *task; ++ ++ task = connect_task_new (self, ++ modem, ++ primary, ++ data, ++ cid, ++ ip_family, ++ NULL, ++ callback, ++ user_data); ++ if (!task) ++ return; ++ ++ command = g_strdup_printf ("+CGPADDR=%u", cid); ++ mm_base_modem_at_command (MM_BASE_MODEM (modem), ++ command, ++ 10, ++ FALSE, ++ (GAsyncReadyCallback) cgpaddr_ready, ++ task); ++} ++ ++/*****************************************************************************/ ++ ++MMBaseBearer * ++mm_broadband_bearer_fm350gl_new_finish (GAsyncResult *res, ++ GError **error) ++{ ++ GObject *bearer; ++ GObject *source; ++ ++ source = g_async_result_get_source_object (res); ++ bearer = g_async_initable_new_finish (G_ASYNC_INITABLE (source), res, error); ++ g_object_unref (source); ++ if (!bearer) ++ return NULL; ++ ++ mm_base_bearer_export (MM_BASE_BEARER (bearer)); ++ return MM_BASE_BEARER (bearer); ++} ++ ++void ++mm_broadband_bearer_fm350gl_new (MMBroadbandModemFm350gl *modem, ++ MMBearerProperties *properties, ++ GCancellable *cancellable, ++ GAsyncReadyCallback callback, ++ gpointer user_data) ++{ ++ g_async_initable_new_async (MM_TYPE_BROADBAND_BEARER_FM350GL, ++ G_PRIORITY_DEFAULT, ++ cancellable, ++ callback, ++ user_data, ++ MM_BASE_BEARER_MODEM, modem, ++ MM_BASE_BEARER_CONFIG, properties, ++ NULL); ++} ++ ++static void ++mm_broadband_bearer_fm350gl_init (MMBroadbandBearerFm350gl *self) ++{ ++} ++ ++static void ++mm_broadband_bearer_fm350gl_class_init (MMBroadbandBearerFm350glClass *klass) ++{ ++ MMBroadbandBearerClass *bearer_class = MM_BROADBAND_BEARER_CLASS (klass); ++ ++ bearer_class->dial_3gpp = dial_3gpp; ++ bearer_class->dial_3gpp_finish = dial_3gpp_finish; ++ bearer_class->get_ip_config_3gpp = get_ip_config_3gpp; ++ bearer_class->get_ip_config_3gpp_finish = get_ip_config_3gpp_finish; ++ bearer_class->disconnect_3gpp = disconnect_3gpp; ++ bearer_class->disconnect_3gpp_finish = disconnect_3gpp_finish; ++} +diff --git a/src/plugins/fm350gl/mm-broadband-bearer-fm350gl.h b/src/plugins/fm350gl/mm-broadband-bearer-fm350gl.h +new file mode 100644 +index 0000000000000000000000000000000000000000..85f0508ae955b4d5bb1dbf15d46451cc7ec9e78c +--- /dev/null ++++ b/src/plugins/fm350gl/mm-broadband-bearer-fm350gl.h +@@ -0,0 +1,46 @@ ++/* -*- Mode: C; tab-width: 4; indent-tabs-mode: nil; c-basic-offset: 4 -*- */ ++/* ++ * This program is free software; you can redistribute it and/or modify ++ * it under the terms of the GNU General Public License as published by ++ * the Free Software Foundation; either version 2 of the License, or ++ * (at your option) any later version. ++ * ++ * Copyright (C) PMGZED ++ * Copyright (C) ModemManager Team ++ */ ++ ++#ifndef MM_BROADBAND_BEARER_FM350GL_H ++#define MM_BROADBAND_BEARER_FM350GL_H ++ ++#include "mm-broadband-bearer.h" ++#include "mm-broadband-modem-fm350gl.h" ++ ++#define MM_TYPE_BROADBAND_BEARER_FM350GL (mm_broadband_bearer_fm350gl_get_type ()) ++#define MM_BROADBAND_BEARER_FM350GL(obj) (G_TYPE_CHECK_INSTANCE_CAST ((obj), MM_TYPE_BROADBAND_BEARER_FM350GL, MMBroadbandBearerFm350gl)) ++#define MM_BROADBAND_BEARER_FM350GL_CLASS(klass) (G_TYPE_CHECK_CLASS_CAST ((klass), MM_TYPE_BROADBAND_BEARER_FM350GL, MMBroadbandBearerFm350glClass)) ++#define MM_IS_BROADBAND_BEARER_FM350GL(obj) (G_TYPE_CHECK_INSTANCE_TYPE ((obj), MM_TYPE_BROADBAND_BEARER_FM350GL)) ++#define MM_IS_BROADBAND_BEARER_FM350GL_CLASS(klass) (G_TYPE_CHECK_CLASS_TYPE ((klass), MM_TYPE_BROADBAND_BEARER_FM350GL)) ++#define MM_BROADBAND_BEARER_FM350GL_GET_CLASS(obj) (G_TYPE_INSTANCE_GET_CLASS ((obj), MM_TYPE_BROADBAND_BEARER_FM350GL, MMBroadbandBearerFm350glClass)) ++ ++typedef struct _MMBroadbandBearerFm350gl MMBroadbandBearerFm350gl; ++typedef struct _MMBroadbandBearerFm350glClass MMBroadbandBearerFm350glClass; ++ ++struct _MMBroadbandBearerFm350gl { ++ MMBroadbandBearer parent; ++}; ++ ++struct _MMBroadbandBearerFm350glClass { ++ MMBroadbandBearerClass parent; ++}; ++ ++GType mm_broadband_bearer_fm350gl_get_type (void); ++ ++void mm_broadband_bearer_fm350gl_new (MMBroadbandModemFm350gl *modem, ++ MMBearerProperties *properties, ++ GCancellable *cancellable, ++ GAsyncReadyCallback callback, ++ gpointer user_data); ++MMBaseBearer *mm_broadband_bearer_fm350gl_new_finish (GAsyncResult *res, ++ GError **error); ++ ++#endif /* MM_BROADBAND_BEARER_FM350GL_H */ +diff --git a/src/plugins/fm350gl/mm-broadband-modem-fm350gl.c b/src/plugins/fm350gl/mm-broadband-modem-fm350gl.c +new file mode 100644 +index 0000000000000000000000000000000000000000..6b061b88e04ab658021baa63321a1f0ded48684f +--- /dev/null ++++ b/src/plugins/fm350gl/mm-broadband-modem-fm350gl.c +@@ -0,0 +1,297 @@ ++/* -*- Mode: C; tab-width: 4; indent-tabs-mode: nil; c-basic-offset: 4 -*- */ ++/* ++ * This program is free software; you can redistribute it and/or modify ++ * it under the terms of the GNU General Public License as published by ++ * the Free Software Foundation; either version 2 of the License, or ++ * (at your option) any later version. ++ * ++ * Copyright (C) PMGZED ++ * Copyright (C) ModemManager Team ++ */ ++ ++#include ++ ++#include "mm-base-modem-at.h" ++#include "mm-broadband-bearer-fm350gl.h" ++#include "mm-broadband-modem-fm350gl.h" ++#include "mm-iface-modem.h" ++#include "mm-log-object.h" ++#include "mm-modem-helpers-fm350gl.h" ++ ++static void iface_modem_init (MMIfaceModemInterface *iface); ++ ++static MMIfaceModemInterface *iface_modem_parent; ++ ++G_DEFINE_TYPE_EXTENDED (MMBroadbandModemFm350gl, mm_broadband_modem_fm350gl, MM_TYPE_BROADBAND_MODEM, 0, ++ G_IMPLEMENT_INTERFACE (MM_TYPE_IFACE_MODEM, iface_modem_init)) ++ ++/*****************************************************************************/ ++/* Delayed current-capabilities load */ ++ ++static MMModemCapability ++load_current_capabilities_finish (MMIfaceModem *self, ++ GAsyncResult *res, ++ GError **error) ++{ ++ return (MMModemCapability) g_task_propagate_int (G_TASK (res), error); ++} ++ ++static void ++parent_load_current_capabilities_ready (MMIfaceModem *self, ++ GAsyncResult *res, ++ GTask *task) ++{ ++ g_autoptr(GError) error = NULL; ++ MMModemCapability capabilities; ++ ++ capabilities = iface_modem_parent->load_current_capabilities_finish (self, res, &error); ++ if (error) ++ g_task_return_error (task, g_steal_pointer (&error)); ++ else ++ g_task_return_int (task, capabilities); ++ g_object_unref (task); ++} ++ ++static gboolean ++load_current_capabilities_timeout (GTask *task) ++{ ++ MMIfaceModem *self; ++ ++ self = g_task_get_source_object (task); ++ iface_modem_parent->load_current_capabilities ( ++ self, ++ (GAsyncReadyCallback) parent_load_current_capabilities_ready, ++ task); ++ return G_SOURCE_REMOVE; ++} ++ ++static void ++load_current_capabilities (MMIfaceModem *self, ++ GAsyncReadyCallback callback, ++ gpointer user_data) ++{ ++ GTask *task; ++ ++ task = g_task_new (self, NULL, callback, user_data); ++ ++ /* The FM350 may reset if +GMR/+CGMR is issued immediately after USB ++ * enumeration. BELABOX observed a 2.5s worst case and uses a 4s guard. */ ++ g_timeout_add (4000, (GSourceFunc) load_current_capabilities_timeout, task); ++} ++ ++/*****************************************************************************/ ++/* Modes */ ++ ++static GArray * ++load_supported_modes_finish (MMIfaceModem *self, ++ GAsyncResult *res, ++ GError **error) ++{ ++ return g_task_propagate_pointer (G_TASK (res), error); ++} ++ ++static void ++append_mode (GArray *combinations, ++ MMModemMode allowed, ++ MMModemMode preferred) ++{ ++ MMModemModeCombination mode = { ++ .allowed = allowed, ++ .preferred = preferred, ++ }; ++ ++ g_array_append_val (combinations, mode); ++} ++ ++static void ++load_supported_modes (MMIfaceModem *self, ++ GAsyncReadyCallback callback, ++ gpointer user_data) ++{ ++ GArray *combinations; ++ GTask *task; ++ ++ combinations = g_array_sized_new (FALSE, FALSE, sizeof (MMModemModeCombination), 12); ++ append_mode (combinations, MM_MODEM_MODE_3G, MM_MODEM_MODE_NONE); ++ append_mode (combinations, MM_MODEM_MODE_4G, MM_MODEM_MODE_NONE); ++ append_mode (combinations, MM_MODEM_MODE_5G, MM_MODEM_MODE_NONE); ++ append_mode (combinations, MM_MODEM_MODE_3G | MM_MODEM_MODE_4G, MM_MODEM_MODE_3G); ++ append_mode (combinations, MM_MODEM_MODE_3G | MM_MODEM_MODE_4G, MM_MODEM_MODE_4G); ++ append_mode (combinations, MM_MODEM_MODE_3G | MM_MODEM_MODE_5G, MM_MODEM_MODE_3G); ++ append_mode (combinations, MM_MODEM_MODE_3G | MM_MODEM_MODE_5G, MM_MODEM_MODE_5G); ++ append_mode (combinations, MM_MODEM_MODE_4G | MM_MODEM_MODE_5G, MM_MODEM_MODE_4G); ++ append_mode (combinations, MM_MODEM_MODE_4G | MM_MODEM_MODE_5G, MM_MODEM_MODE_5G); ++ append_mode (combinations, MM_MODEM_MODE_3G | MM_MODEM_MODE_4G | MM_MODEM_MODE_5G, MM_MODEM_MODE_3G); ++ append_mode (combinations, MM_MODEM_MODE_3G | MM_MODEM_MODE_4G | MM_MODEM_MODE_5G, MM_MODEM_MODE_4G); ++ append_mode (combinations, MM_MODEM_MODE_3G | MM_MODEM_MODE_4G | MM_MODEM_MODE_5G, MM_MODEM_MODE_5G); ++ ++ task = g_task_new (self, NULL, callback, user_data); ++ g_task_return_pointer (task, combinations, (GDestroyNotify) g_array_unref); ++ g_object_unref (task); ++} ++ ++static gboolean ++load_current_modes_finish (MMIfaceModem *self, ++ GAsyncResult *res, ++ MMModemMode *allowed, ++ MMModemMode *preferred, ++ GError **error) ++{ ++ const gchar *response; ++ ++ response = mm_base_modem_at_command_finish (MM_BASE_MODEM (self), res, error); ++ if (!response) ++ return FALSE; ++ return mm_fm350gl_parse_current_modes (response, allowed, preferred, error); ++} ++ ++static void ++load_current_modes (MMIfaceModem *self, ++ GAsyncReadyCallback callback, ++ gpointer user_data) ++{ ++ mm_base_modem_at_command (MM_BASE_MODEM (self), "+GTACT?", 3, FALSE, callback, user_data); ++} ++ ++static gboolean ++set_current_modes_finish (MMIfaceModem *self, ++ GAsyncResult *res, ++ GError **error) ++{ ++ return g_task_propagate_boolean (G_TASK (res), error); ++} ++ ++static void ++set_current_modes_ready (MMBaseModem *self, ++ GAsyncResult *res, ++ GTask *task) ++{ ++ g_autoptr(GError) error = NULL; ++ ++ if (!mm_base_modem_at_command_finish (self, res, &error)) ++ g_task_return_error (task, g_steal_pointer (&error)); ++ else ++ g_task_return_boolean (task, TRUE); ++ g_object_unref (task); ++} ++ ++static void ++set_current_modes (MMIfaceModem *self, ++ MMModemMode allowed, ++ MMModemMode preferred, ++ GAsyncReadyCallback callback, ++ gpointer user_data) ++{ ++ g_autoptr(GError) error = NULL; ++ g_autofree gchar *command = NULL; ++ GTask *task; ++ ++ task = g_task_new (self, NULL, callback, user_data); ++ command = mm_fm350gl_build_set_modes_command (allowed, preferred, &error); ++ if (!command) { ++ g_task_return_error (task, g_steal_pointer (&error)); ++ g_object_unref (task); ++ return; ++ } ++ ++ mm_base_modem_at_command ( ++ MM_BASE_MODEM (self), ++ command, ++ 30, ++ FALSE, ++ (GAsyncReadyCallback) set_current_modes_ready, ++ task); ++} ++ ++/*****************************************************************************/ ++/* Bearer creation */ ++ ++static MMBaseBearer * ++create_bearer_finish (MMIfaceModem *self, ++ GAsyncResult *res, ++ GError **error) ++{ ++ return g_task_propagate_pointer (G_TASK (res), error); ++} ++ ++static void ++bearer_new_ready (GObject *source, ++ GAsyncResult *res, ++ GTask *task) ++{ ++ g_autoptr(GError) error = NULL; ++ MMBaseBearer *bearer; ++ ++ bearer = mm_broadband_bearer_fm350gl_new_finish (res, &error); ++ if (!bearer) ++ g_task_return_error (task, g_steal_pointer (&error)); ++ else ++ g_task_return_pointer (task, bearer, g_object_unref); ++ g_object_unref (task); ++} ++ ++static void ++create_bearer (MMIfaceModem *self, ++ MMBearerProperties *properties, ++ GAsyncReadyCallback callback, ++ gpointer user_data) ++{ ++ GTask *task; ++ ++ task = g_task_new (self, NULL, callback, user_data); ++ mm_broadband_bearer_fm350gl_new ( ++ MM_BROADBAND_MODEM_FM350GL (self), ++ properties, ++ NULL, ++ (GAsyncReadyCallback) bearer_new_ready, ++ task); ++} ++ ++/*****************************************************************************/ ++ ++MMBroadbandModemFm350gl * ++mm_broadband_modem_fm350gl_new (const gchar *device, ++ const gchar *physdev, ++ const gchar **drivers, ++ const gchar *plugin, ++ guint16 vendor_id, ++ guint16 product_id) ++{ ++ return g_object_new (MM_TYPE_BROADBAND_MODEM_FM350GL, ++ MM_BASE_MODEM_DEVICE, device, ++ MM_BASE_MODEM_PHYSDEV, physdev, ++ MM_BASE_MODEM_DRIVERS, drivers, ++ MM_BASE_MODEM_PLUGIN, plugin, ++ MM_BASE_MODEM_VENDOR_ID, vendor_id, ++ MM_BASE_MODEM_PRODUCT_ID, product_id, ++ MM_BASE_MODEM_DATA_NET_SUPPORTED, TRUE, ++ MM_BASE_MODEM_DATA_TTY_SUPPORTED, FALSE, ++ NULL); ++} ++ ++static void ++iface_modem_init (MMIfaceModemInterface *iface) ++{ ++ iface_modem_parent = g_type_interface_peek_parent (iface); ++ ++ iface->load_current_capabilities = load_current_capabilities; ++ iface->load_current_capabilities_finish = load_current_capabilities_finish; ++ iface->load_supported_modes = load_supported_modes; ++ iface->load_supported_modes_finish = load_supported_modes_finish; ++ iface->load_current_modes = load_current_modes; ++ iface->load_current_modes_finish = load_current_modes_finish; ++ iface->set_current_modes = set_current_modes; ++ iface->set_current_modes_finish = set_current_modes_finish; ++ iface->create_bearer = create_bearer; ++ iface->create_bearer_finish = create_bearer_finish; ++} ++ ++static void ++mm_broadband_modem_fm350gl_init (MMBroadbandModemFm350gl *self) ++{ ++} ++ ++static void ++mm_broadband_modem_fm350gl_class_init (MMBroadbandModemFm350glClass *klass) ++{ ++} +diff --git a/src/plugins/fm350gl/mm-broadband-modem-fm350gl.h b/src/plugins/fm350gl/mm-broadband-modem-fm350gl.h +new file mode 100644 +index 0000000000000000000000000000000000000000..babc65f849c1069bc2af80399c69c9f7c0abb4f4 +--- /dev/null ++++ b/src/plugins/fm350gl/mm-broadband-modem-fm350gl.h +@@ -0,0 +1,44 @@ ++/* -*- Mode: C; tab-width: 4; indent-tabs-mode: nil; c-basic-offset: 4 -*- */ ++/* ++ * This program is free software; you can redistribute it and/or modify ++ * it under the terms of the GNU General Public License as published by ++ * the Free Software Foundation; either version 2 of the License, or ++ * (at your option) any later version. ++ * ++ * Copyright (C) PMGZED ++ * Copyright (C) ModemManager Team ++ */ ++ ++#ifndef MM_BROADBAND_MODEM_FM350GL_H ++#define MM_BROADBAND_MODEM_FM350GL_H ++ ++#include "mm-broadband-modem.h" ++ ++#define MM_TYPE_BROADBAND_MODEM_FM350GL (mm_broadband_modem_fm350gl_get_type ()) ++#define MM_BROADBAND_MODEM_FM350GL(obj) (G_TYPE_CHECK_INSTANCE_CAST ((obj), MM_TYPE_BROADBAND_MODEM_FM350GL, MMBroadbandModemFm350gl)) ++#define MM_BROADBAND_MODEM_FM350GL_CLASS(klass) (G_TYPE_CHECK_CLASS_CAST ((klass), MM_TYPE_BROADBAND_MODEM_FM350GL, MMBroadbandModemFm350glClass)) ++#define MM_IS_BROADBAND_MODEM_FM350GL(obj) (G_TYPE_CHECK_INSTANCE_TYPE ((obj), MM_TYPE_BROADBAND_MODEM_FM350GL)) ++#define MM_IS_BROADBAND_MODEM_FM350GL_CLASS(klass) (G_TYPE_CHECK_CLASS_TYPE ((klass), MM_TYPE_BROADBAND_MODEM_FM350GL)) ++#define MM_BROADBAND_MODEM_FM350GL_GET_CLASS(obj) (G_TYPE_INSTANCE_GET_CLASS ((obj), MM_TYPE_BROADBAND_MODEM_FM350GL, MMBroadbandModemFm350glClass)) ++ ++typedef struct _MMBroadbandModemFm350gl MMBroadbandModemFm350gl; ++typedef struct _MMBroadbandModemFm350glClass MMBroadbandModemFm350glClass; ++ ++struct _MMBroadbandModemFm350gl { ++ MMBroadbandModem parent; ++}; ++ ++struct _MMBroadbandModemFm350glClass { ++ MMBroadbandModemClass parent; ++}; ++ ++GType mm_broadband_modem_fm350gl_get_type (void); ++ ++MMBroadbandModemFm350gl *mm_broadband_modem_fm350gl_new (const gchar *device, ++ const gchar *physdev, ++ const gchar **drivers, ++ const gchar *plugin, ++ guint16 vendor_id, ++ guint16 product_id); ++ ++#endif /* MM_BROADBAND_MODEM_FM350GL_H */ +diff --git a/src/plugins/fm350gl/mm-modem-helpers-fm350gl.c b/src/plugins/fm350gl/mm-modem-helpers-fm350gl.c +new file mode 100644 +index 0000000000000000000000000000000000000000..01de53e766606fc57011b4cc5f2d347b2f13196b +--- /dev/null ++++ b/src/plugins/fm350gl/mm-modem-helpers-fm350gl.c +@@ -0,0 +1,162 @@ ++/* -*- Mode: C; tab-width: 4; indent-tabs-mode: nil; c-basic-offset: 4 -*- */ ++/* ++ * This program is free software; you can redistribute it and/or modify ++ * it under the terms of the GNU General Public License as published by ++ * the Free Software Foundation; either version 2 of the License, or ++ * (at your option) any later version. ++ * ++ * Copyright (C) PMGZED ++ * Copyright (C) ModemManager Team ++ */ ++ ++#include ++ ++#define _LIBMM_INSIDE_MM ++#include ++ ++#include "mm-common-helpers.h" ++#include "mm-modem-helpers.h" ++#include "mm-modem-helpers-fm350gl.h" ++ ++typedef struct { ++ guint value; ++ MMModemMode mode; ++} ModeMap; ++ ++static const ModeMap allowed_map[] = { ++ { 1, MM_MODEM_MODE_3G }, ++ { 2, MM_MODEM_MODE_4G }, ++ { 4, MM_MODEM_MODE_3G | MM_MODEM_MODE_4G }, ++ { 14, MM_MODEM_MODE_5G }, ++ { 16, MM_MODEM_MODE_3G | MM_MODEM_MODE_5G }, ++ { 17, MM_MODEM_MODE_4G | MM_MODEM_MODE_5G }, ++ { 20, MM_MODEM_MODE_3G | MM_MODEM_MODE_4G | MM_MODEM_MODE_5G }, ++}; ++ ++static const ModeMap preferred_map[] = { ++ { 2, MM_MODEM_MODE_3G }, ++ { 3, MM_MODEM_MODE_4G }, ++ { 6, MM_MODEM_MODE_5G }, ++}; ++ ++static gboolean ++lookup_mode (const ModeMap *map, ++ gsize map_len, ++ guint value, ++ MMModemMode *out_mode) ++{ ++ guint i; ++ ++ for (i = 0; i < map_len; i++) { ++ if (map[i].value == value) { ++ *out_mode = map[i].mode; ++ return TRUE; ++ } ++ } ++ return FALSE; ++} ++ ++static gboolean ++lookup_value (const ModeMap *map, ++ gsize map_len, ++ MMModemMode mode, ++ guint *out_value) ++{ ++ guint i; ++ ++ for (i = 0; i < map_len; i++) { ++ if (map[i].mode == mode) { ++ *out_value = map[i].value; ++ return TRUE; ++ } ++ } ++ return FALSE; ++} ++ ++gboolean ++mm_fm350gl_parse_current_modes (const gchar *response, ++ MMModemMode *allowed, ++ MMModemMode *preferred, ++ GError **error) ++{ ++ g_auto(GStrv) fields = NULL; ++ const gchar *payload; ++ guint value; ++ ++ g_return_val_if_fail (response != NULL, FALSE); ++ g_return_val_if_fail (allowed != NULL, FALSE); ++ g_return_val_if_fail (preferred != NULL, FALSE); ++ ++ if (!g_str_has_prefix (response, "+GTACT:")) { ++ g_set_error (error, MM_CORE_ERROR, MM_CORE_ERROR_FAILED, ++ "Missing +GTACT prefix in response: '%s'", response); ++ return FALSE; ++ } ++ ++ payload = mm_strip_tag (response, "+GTACT:"); ++ fields = g_strsplit (payload, ",", -1); ++ if (!fields[0] || !mm_get_uint_from_str (g_strstrip (fields[0]), &value) || ++ !lookup_mode (allowed_map, G_N_ELEMENTS (allowed_map), value, allowed)) { ++ g_set_error (error, MM_CORE_ERROR, MM_CORE_ERROR_FAILED, ++ "Unsupported +GTACT allowed mode in response: '%s'", response); ++ return FALSE; ++ } ++ ++ *preferred = MM_MODEM_MODE_NONE; ++ ++ /* Some FM350 firmware reports a stale PreferredAct1 after switching to a ++ * single RAT (for example +GTACT: 2,3 after +GTACT=2). A preference is not ++ * meaningful for a single allowed mode, so deliberately ignore it. */ ++ if (*allowed == MM_MODEM_MODE_3G || ++ *allowed == MM_MODEM_MODE_4G || ++ *allowed == MM_MODEM_MODE_5G) ++ return TRUE; ++ ++ if (!fields[1] || !mm_get_uint_from_str (g_strstrip (fields[1]), &value) || ++ !lookup_mode (preferred_map, G_N_ELEMENTS (preferred_map), value, preferred)) { ++ g_set_error (error, MM_CORE_ERROR, MM_CORE_ERROR_FAILED, ++ "Unsupported +GTACT preferred mode in response: '%s'", response); ++ return FALSE; ++ } ++ ++ if (!(*allowed & *preferred)) { ++ g_set_error (error, MM_CORE_ERROR, MM_CORE_ERROR_FAILED, ++ "+GTACT preferred mode is not in the allowed set: '%s'", response); ++ return FALSE; ++ } ++ ++ return TRUE; ++} ++ ++gchar * ++mm_fm350gl_build_set_modes_command (MMModemMode allowed, ++ MMModemMode preferred, ++ GError **error) ++{ ++ guint rat; ++ guint preferred_rat; ++ ++ if (!lookup_value (allowed_map, G_N_ELEMENTS (allowed_map), allowed, &rat)) { ++ g_autofree gchar *allowed_str = NULL; ++ ++ allowed_str = mm_modem_mode_build_string_from_mask (allowed); ++ g_set_error (error, MM_CORE_ERROR, MM_CORE_ERROR_UNSUPPORTED, ++ "Unsupported FM350 allowed mode: '%s'", allowed_str); ++ return NULL; ++ } ++ ++ if (preferred == MM_MODEM_MODE_NONE) ++ return g_strdup_printf ("+GTACT=%u", rat); ++ ++ if (!(allowed & preferred) || ++ !lookup_value (preferred_map, G_N_ELEMENTS (preferred_map), preferred, &preferred_rat)) { ++ g_autofree gchar *preferred_str = NULL; ++ ++ preferred_str = mm_modem_mode_build_string_from_mask (preferred); ++ g_set_error (error, MM_CORE_ERROR, MM_CORE_ERROR_UNSUPPORTED, ++ "Unsupported FM350 preferred mode: '%s'", preferred_str); ++ return NULL; ++ } ++ ++ return g_strdup_printf ("+GTACT=%u,%u", rat, preferred_rat); ++} +diff --git a/src/plugins/fm350gl/mm-modem-helpers-fm350gl.h b/src/plugins/fm350gl/mm-modem-helpers-fm350gl.h +new file mode 100644 +index 0000000000000000000000000000000000000000..7caec9eabab5819d002331be9ca89b7445fd0314 +--- /dev/null ++++ b/src/plugins/fm350gl/mm-modem-helpers-fm350gl.h +@@ -0,0 +1,25 @@ ++/* -*- Mode: C; tab-width: 4; indent-tabs-mode: nil; c-basic-offset: 4 -*- */ ++/* ++ * This program is free software; you can redistribute it and/or modify ++ * it under the terms of the GNU General Public License as published by ++ * the Free Software Foundation; either version 2 of the License, or ++ * (at your option) any later version. ++ * ++ * Copyright (C) PMGZED ++ * Copyright (C) ModemManager Team ++ */ ++ ++#ifndef MM_MODEM_HELPERS_FM350GL_H ++#define MM_MODEM_HELPERS_FM350GL_H ++ ++#include ++ ++gboolean mm_fm350gl_parse_current_modes (const gchar *response, ++ MMModemMode *allowed, ++ MMModemMode *preferred, ++ GError **error); ++gchar *mm_fm350gl_build_set_modes_command (MMModemMode allowed, ++ MMModemMode preferred, ++ GError **error); ++ ++#endif /* MM_MODEM_HELPERS_FM350GL_H */ +diff --git a/src/plugins/fm350gl/mm-plugin-fm350gl.c b/src/plugins/fm350gl/mm-plugin-fm350gl.c +new file mode 100644 +index 0000000000000000000000000000000000000000..2437156fd5ff38b5146e5f3ec83e589231303089 +--- /dev/null ++++ b/src/plugins/fm350gl/mm-plugin-fm350gl.c +@@ -0,0 +1,74 @@ ++/* -*- Mode: C; tab-width: 4; indent-tabs-mode: nil; c-basic-offset: 4 -*- */ ++/* ++ * This program is free software; you can redistribute it and/or modify ++ * it under the terms of the GNU General Public License as published by ++ * the Free Software Foundation; either version 2 of the License, or ++ * (at your option) any later version. ++ * ++ * Copyright (C) PMGZED ++ * Copyright (C) ModemManager Team ++ */ ++ ++#include ++ ++#define _LIBMM_INSIDE_MM ++#include ++ ++#include "mm-broadband-modem-fm350gl.h" ++#include "mm-plugin-common.h" ++ ++#define MM_TYPE_PLUGIN_FM350GL mm_plugin_fm350gl_get_type () ++MM_DEFINE_PLUGIN (FM350GL, fm350gl, FM350GL) ++ ++static MMBaseModem * ++create_modem (MMPlugin *self, ++ const gchar *uid, ++ const gchar *physdev, ++ const gchar **drivers, ++ guint16 vendor, ++ guint16 product, ++ guint16 subsystem_vendor, ++ guint16 subsystem_device, ++ GList *probes, ++ GError **error) ++{ ++ return MM_BASE_MODEM (mm_broadband_modem_fm350gl_new (uid, ++ physdev, ++ drivers, ++ mm_plugin_get_name (self), ++ vendor, ++ product)); ++} ++ ++MM_PLUGIN_NAMED_CREATOR_SCOPE MMPlugin * ++mm_plugin_create_fm350gl (void) ++{ ++ static const gchar *subsystems[] = { "tty", "net", NULL }; ++ static const mm_uint16_pair product_ids[] = { ++ { 0x0e8d, 0x7126 }, ++ { 0x0e8d, 0x7127 }, ++ { 0, 0 }, ++ }; ++ ++ return MM_PLUGIN ( ++ g_object_new (MM_TYPE_PLUGIN_FM350GL, ++ MM_PLUGIN_NAME, MM_MODULE_NAME, ++ MM_PLUGIN_ALLOWED_SUBSYSTEMS, subsystems, ++ MM_PLUGIN_ALLOWED_PRODUCT_IDS, product_ids, ++ MM_PLUGIN_ALLOWED_AT, TRUE, ++ MM_PLUGIN_REMOVE_ECHO, TRUE, ++ NULL)); ++} ++ ++static void ++mm_plugin_fm350gl_init (MMPluginFM350GL *self) ++{ ++} ++ ++static void ++mm_plugin_fm350gl_class_init (MMPluginFM350GLClass *klass) ++{ ++ MMPluginClass *plugin_class = MM_PLUGIN_CLASS (klass); ++ ++ plugin_class->create_modem = create_modem; ++} +diff --git a/src/plugins/fm350gl/tests/test-modem-helpers-fm350gl.c b/src/plugins/fm350gl/tests/test-modem-helpers-fm350gl.c +new file mode 100644 +index 0000000000000000000000000000000000000000..6b8626625bc38794ac14046b3c11eef90a521299 +--- /dev/null ++++ b/src/plugins/fm350gl/tests/test-modem-helpers-fm350gl.c +@@ -0,0 +1,96 @@ ++/* -*- Mode: C; tab-width: 4; indent-tabs-mode: nil; c-basic-offset: 4 -*- */ ++/* ++ * This program is free software; you can redistribute it and/or modify ++ * it under the terms of the GNU General Public License as published by ++ * the Free Software Foundation; either version 2 of the License, or ++ * (at your option) any later version. ++ */ ++ ++#include ++ ++#define _LIBMM_INSIDE_MM ++#include ++ ++#include "mm-modem-helpers-fm350gl.h" ++ ++static void ++test_parse_combined_mode (void) ++{ ++ g_autoptr(GError) error = NULL; ++ MMModemMode allowed; ++ MMModemMode preferred; ++ ++ g_assert_true (mm_fm350gl_parse_current_modes ( ++ "+GTACT: 4,3,,1,2,4,5,8,101,102", &allowed, &preferred, &error)); ++ g_assert_no_error (error); ++ g_assert_cmpuint (allowed, ==, MM_MODEM_MODE_3G | MM_MODEM_MODE_4G); ++ g_assert_cmpuint (preferred, ==, MM_MODEM_MODE_4G); ++} ++ ++static void ++test_parse_single_mode_ignores_stale_preference (void) ++{ ++ g_autoptr(GError) error = NULL; ++ MMModemMode allowed; ++ MMModemMode preferred; ++ ++ g_assert_true (mm_fm350gl_parse_current_modes ( ++ "+GTACT: 2,3,,101,102", &allowed, &preferred, &error)); ++ g_assert_no_error (error); ++ g_assert_cmpuint (allowed, ==, MM_MODEM_MODE_4G); ++ g_assert_cmpuint (preferred, ==, MM_MODEM_MODE_NONE); ++} ++ ++static void ++test_parse_invalid_mode (void) ++{ ++ g_autoptr(GError) error = NULL; ++ MMModemMode allowed; ++ MMModemMode preferred; ++ ++ g_assert_false (mm_fm350gl_parse_current_modes ( ++ "+GTACT: 99,3", &allowed, &preferred, &error)); ++ g_assert_error (error, MM_CORE_ERROR, MM_CORE_ERROR_FAILED); ++} ++ ++static void ++test_build_set_command (void) ++{ ++ g_autoptr(GError) error = NULL; ++ g_autofree gchar *command = NULL; ++ ++ command = mm_fm350gl_build_set_modes_command ( ++ MM_MODEM_MODE_4G | MM_MODEM_MODE_5G, ++ MM_MODEM_MODE_5G, ++ &error); ++ g_assert_no_error (error); ++ g_assert_cmpstr (command, ==, "+GTACT=17,6"); ++} ++ ++static void ++test_build_invalid_preference (void) ++{ ++ g_autoptr(GError) error = NULL; ++ g_autofree gchar *command = NULL; ++ ++ command = mm_fm350gl_build_set_modes_command ( ++ MM_MODEM_MODE_3G | MM_MODEM_MODE_4G, ++ MM_MODEM_MODE_5G, ++ &error); ++ g_assert_null (command); ++ g_assert_error (error, MM_CORE_ERROR, MM_CORE_ERROR_UNSUPPORTED); ++} ++ ++int ++main (int argc, char **argv) ++{ ++ g_test_init (&argc, &argv, NULL); ++ ++ g_test_add_func ("/MM/fm350gl/modes/parse-combined", test_parse_combined_mode); ++ g_test_add_func ("/MM/fm350gl/modes/single-stale-preference", test_parse_single_mode_ignores_stale_preference); ++ g_test_add_func ("/MM/fm350gl/modes/parse-invalid", test_parse_invalid_mode); ++ g_test_add_func ("/MM/fm350gl/modes/build-command", test_build_set_command); ++ g_test_add_func ("/MM/fm350gl/modes/build-invalid-preference", test_build_invalid_preference); ++ ++ return g_test_run (); ++} +diff --git a/src/plugins/meson.build b/src/plugins/meson.build +index b21b5e3604196af682b7222723b5f697dd1c9b78..18000c7a14c7010e7761285c0d50ab2eba49c8aa 100644 +--- a/src/plugins/meson.build ++++ b/src/plugins/meson.build +@@ -466,6 +466,27 @@ if plugins_options['fibocom'] + plugins_udev_rules += files('fibocom/77-mm-fibocom-port-types.rules') + endif + ++# plugin: Fibocom FM350-GL USB/RNDIS ++if plugins_options['fm350gl'] ++ fm350gl_inc = include_directories('fm350gl') ++ common_c_args = '-DMM_MODULE_NAME="fm350gl"' ++ ++ sources = files( ++ 'fm350gl/mm-broadband-bearer-fm350gl.c', ++ 'fm350gl/mm-broadband-modem-fm350gl.c', ++ 'fm350gl/mm-plugin-fm350gl.c', ++ ) ++ ++ plugins += {'plugin-fm350gl': { ++ 'plugin': true, ++ 'helper': {'sources': files('fm350gl/mm-modem-helpers-fm350gl.c'), 'include_directories': plugins_incs + [fm350gl_inc], 'c_args': common_c_args}, ++ 'module': {'sources': sources, 'include_directories': plugins_incs + [fm350gl_inc], 'c_args': common_c_args}, ++ 'test': {'sources': files('fm350gl/tests/test-modem-helpers-fm350gl.c'), 'include_directories': fm350gl_inc, 'dependencies': libhelpers_dep}, ++ }} ++ ++ plugins_udev_rules += files('fm350gl/77-mm-fm350gl.rules') ++endif ++ + # plugin: foxconn + if plugins_options['foxconn'] + foxconn_dir = plugins_dir / 'foxconn' +diff --git a/src/plugins/mm-builtin-plugins.c b/src/plugins/mm-builtin-plugins.c +index c7f916336f91910bb4b4b61d942bc4b2b95c5e48..853f723fd05699a25ab25ab3b974dc3f7efe56b9 100644 +--- a/src/plugins/mm-builtin-plugins.c ++++ b/src/plugins/mm-builtin-plugins.c +@@ -43,6 +43,9 @@ MMPlugin *mm_plugin_create_dlink (void); + #if defined ENABLE_PLUGIN_FIBOCOM + MMPlugin *mm_plugin_create_fibocom (void); + #endif ++#if defined ENABLE_PLUGIN_FM350GL ++MMPlugin *mm_plugin_create_fm350gl (void); ++#endif + #if defined ENABLE_PLUGIN_FOXCONN + MMPlugin *mm_plugin_create_foxconn (void); + #endif +@@ -181,6 +184,9 @@ mm_builtin_plugins_load (void) + #if defined ENABLE_PLUGIN_FIBOCOM + PREPEND_PLUGIN (fibocom); + #endif ++#if defined ENABLE_PLUGIN_FM350GL ++ PREPEND_PLUGIN (fm350gl); ++#endif + #if defined ENABLE_PLUGIN_FOXCONN + PREPEND_PLUGIN (foxconn); + #endif diff --git a/packaging/ModemManager/debian/patches/0002-fm350gl-disable-crashing-cpol-commands.patch b/packaging/ModemManager/debian/patches/0002-fm350gl-disable-crashing-cpol-commands.patch new file mode 100644 index 0000000..9daf42f --- /dev/null +++ b/packaging/ModemManager/debian/patches/0002-fm350gl-disable-crashing-cpol-commands.patch @@ -0,0 +1,33 @@ +From 20a0f4d12d583f20006a04170b07d9bd62c24514 Mon Sep 17 00:00:00 2001 +From: rationalsa +Date: Sat, 22 Aug 2026 19:02:50 -0500 +Subject: [PATCH 2/3] fm350gl: disable CPOL commands that crash the modem + +Description: Disable preferred-network CPOL operations on FM350-GL USB identities +Author: rationalsa +Origin: vendor, https://github.com/BELABOX/modemmanager/commit/b4377c5028a0de4435b86f7aad9114e9443d69e4 +Origin-Commit: b4377c5028a0de4435b86f7aad9114e9443d69e4 +Bug: https://gitlab.freedesktop.org/mobile-broadband/ModemManager/-/issues/899 +Forwarded: no (merge request drafted but not filed; no upstream MR URL exists) +Applied-Upstream: no +Upstream-Status: MECHANISM UPSTREAM, DEVICE ROW NOT UPSTREAM; ID_MM_PREFERRED_NETWORKS_CPOL_DISABLED is upstream but no row covers 0e8d:7126/7127 +Rationale: BELABOX found that CPOL commands crash this modem. Apply ModemManager's existing fail-safe udev tag to both observed FM350-GL RNDIS compositions. +Credit: BELABOX-authored fix forward-ported by CeraLive; this patch is not CeraLive-originated work. +Last-Update: 2026-08-22 + +--- + src/plugins/fm350gl/77-mm-fm350gl.rules | 1 + + 1 file changed, 1 insertion(+) + +diff --git a/src/plugins/fm350gl/77-mm-fm350gl.rules b/src/plugins/fm350gl/77-mm-fm350gl.rules +index f0ab95930cf6e725d2acad3d416c0ec16040207a..d88cc6addfa7b5fd80b15497cb762222b4b2f7a4 100644 +--- a/src/plugins/fm350gl/77-mm-fm350gl.rules ++++ b/src/plugins/fm350gl/77-mm-fm350gl.rules +@@ -6,6 +6,7 @@ GOTO="mm_fm350gl_port_types_end" + + LABEL="mm_fm350gl_port_types_vendorcheck" + SUBSYSTEMS=="usb", ATTRS{bInterfaceNumber}=="?*", ENV{.MM_USBIFNUM}="$attr{bInterfaceNumber}" ++ATTRS{idProduct}=="712[6-7]", ENV{ID_MM_PREFERRED_NETWORKS_CPOL_DISABLED}="1" + + # AT+GTUSBMODE=40 (0e8d:7126): RNDIS+AT+AP(GNSS)+META+DEBUG+NPT+ADB + ATTRS{idProduct}=="7126", ENV{.MM_USBIFNUM}=="00", SUBSYSTEM=="tty", ENV{ID_MM_PORT_IGNORE}="1" diff --git a/packaging/ModemManager/debian/patches/0003-fm350gl-accept-extended-cops-scan-format.patch b/packaging/ModemManager/debian/patches/0003-fm350gl-accept-extended-cops-scan-format.patch new file mode 100644 index 0000000..2944855 --- /dev/null +++ b/packaging/ModemManager/debian/patches/0003-fm350gl-accept-extended-cops-scan-format.patch @@ -0,0 +1,82 @@ +From da9f0d68e8f41c4df8507b1fd843d6b7635d5135 Mon Sep 17 00:00:00 2001 +From: rationalsa +Date: Sat, 22 Aug 2026 19:03:31 -0500 +Subject: [PATCH 3/3] fm350gl: accept the modem's extended +COPS scan format + +Description: Parse the FM350-GL extra opaque field in +COPS scan results + The modem inserts one field between the numeric operator code and access + technology. Make that field optional, shift the AcT capture, and add the exact + FM350-GL response to the existing shared parser test suite. +Author: rationalsa +Origin: vendor, https://github.com/BELABOX/modemmanager/commit/43e09a768e3855f3afb93e517902c1e0e25ed676 +Origin-Commit: 43e09a768e3855f3afb93e517902c1e0e25ed676 +Bug: https://gitlab.freedesktop.org/mobile-broadband/ModemManager/-/issues/899 +Forwarded: no (standalone merge request drafted but not filed; no upstream MR URL exists) +Applied-Upstream: no +Upstream-Status: NOT UPSTREAM; the 1.24.2 and upstream-main parser both reject the extra field +Rationale: FM350-GL emits responses such as +COPS: (2,"","EE","23430","609C",7). Without the optional field the operator scan is misparsed and the access technology is lost. +Credit: BELABOX-authored fix forward-ported by CeraLive; this patch is not CeraLive-originated work. +Last-Update: 2026-08-22 + +--- + src/mm-modem-helpers.c | 9 +++++++-- + src/tests/test-modem-helpers.c | 12 ++++++++++++ + 2 files changed, 19 insertions(+), 2 deletions(-) + +diff --git a/src/mm-modem-helpers.c b/src/mm-modem-helpers.c +index 574ec7fc1234011dcf6ed5f2354316af4350cfd5..8687196e0f9a2275aab19a2f835a2dbf542311d1 100644 +--- a/src/mm-modem-helpers.c ++++ b/src/mm-modem-helpers.c +@@ -1274,7 +1274,12 @@ mm_3gpp_parse_cops_test_response (const gchar *reply, + * +COPS: (2,"","T-Mobile","31026",0),(1,"AT&T","AT&T","310410"),0) + */ + +- r = g_regex_new ("\\((\\d),\"([^\"\\)]*)\",([^,\\)]*),([^,\\)]*)[\\)]?,(\\d+)\\)", G_REGEX_UNGREEDY, 0, NULL); ++ /* Some FM350-GL firmware inserts an extra opaque field between the ++ * operator code and the access technology: ++ * ++ * +COPS: (2,"","EE","23430","609C",7) ++ */ ++ r = g_regex_new ("\\((\\d),\"([^\"\\)]*)\",([^,\\)]*),([^,\\)]*)[\\)]?,([^,\\)]*,)?(\\d+)\\)", G_REGEX_UNGREEDY, 0, NULL); + g_assert (r); + + /* If we didn't get any hits, try the pre-UMTS format match */ +@@ -1329,7 +1334,7 @@ mm_3gpp_parse_cops_test_response (const gchar *reply, + guint act_value = 0; + + /* the regex makes sure this is a number, it won't fail */ +- mm_get_uint_from_match_info (match_info, 5, &act_value); ++ mm_get_uint_from_match_info (match_info, 6, &act_value); + info->access_tech = get_mm_access_tech_from_etsi_access_tech (act_value, log_object); + } else + info->access_tech = MM_MODEM_ACCESS_TECHNOLOGY_GSM; +diff --git a/src/tests/test-modem-helpers.c b/src/tests/test-modem-helpers.c +index 6836bbdd02b5b14620e6ce2bf79d01c908f091e0..b4dcda2b090cba8c1f976f07c24e83403209d400 100644 +--- a/src/tests/test-modem-helpers.c ++++ b/src/tests/test-modem-helpers.c +@@ -933,6 +933,17 @@ test_cops_response_em9191 (void *f, gpointer d) + test_cops_results ("EM9191", reply, MM_MODEM_CHARSET_GSM, &expected[0], G_N_ELEMENTS (expected)); + } + ++static void ++test_cops_response_fm350gl (void *f, gpointer d) ++{ ++ const char *reply = "+COPS: (2,\"\",\"EE\",\"23430\",\"609C\",7)"; ++ static MM3gppNetworkInfo expected[] = { ++ { MM_MODEM_3GPP_NETWORK_AVAILABILITY_CURRENT, NULL, (gchar *) "EE", (gchar *) "23430", MM_MODEM_ACCESS_TECHNOLOGY_LTE }, ++ }; ++ ++ test_cops_results ("FM350-GL", reply, MM_MODEM_CHARSET_GSM, &expected[0], G_N_ELEMENTS (expected)); ++} ++ + static void + test_cops_response_gsm_invalid (void *f, gpointer d) + { +@@ -4953,6 +4964,7 @@ int main (int argc, char **argv) + g_test_suite_add (suite, TESTCASE (test_cops_response_samsung_z810, NULL)); + g_test_suite_add (suite, TESTCASE (test_cops_response_ublox_lara, NULL)); + g_test_suite_add (suite, TESTCASE (test_cops_response_em9191, NULL)); ++ g_test_suite_add (suite, TESTCASE (test_cops_response_fm350gl, NULL)); + + g_test_suite_add (suite, TESTCASE (test_cops_response_gsm_invalid, NULL)); + g_test_suite_add (suite, TESTCASE (test_cops_response_umts_invalid, NULL)); diff --git a/packaging/ModemManager/debian/patches/series b/packaging/ModemManager/debian/patches/series new file mode 100644 index 0000000..b6dede5 --- /dev/null +++ b/packaging/ModemManager/debian/patches/series @@ -0,0 +1,3 @@ +0001-fm350gl-forward-port-rndis-bearer-and-modes.patch +0002-fm350gl-disable-crashing-cpol-commands.patch +0003-fm350gl-accept-extended-cops-scan-format.patch diff --git a/packaging/README.md b/packaging/README.md index 848bc32..83f95ca 100644 --- a/packaging/README.md +++ b/packaging/README.md @@ -1,15 +1,20 @@ # packaging -Bookworm rebuilds of the ModemManager stack — **packaging only, not a fork, zero source -patches** (see `POLICY.md` at the repo root). Bench devices install the resulting `.deb`s -from CI artifacts; nothing is published to `apt.ceralive.tv` yet — apt publication is part -of Phase B adoption, authorized from the `v1.0.0` release tag forward (`POLICY.md` §4). +Bookworm rebuilds of the ModemManager stack — **packaging only, not a fork**. libmbim, +libqmi, and libqrtr-glib remain source-unmodified; ModemManager carries one owner-approved, +three-patch BELABOX-derived FM350-GL series (see `POLICY.md` and +[`ADR-FM350-RNDIS-BEARER.md`](../docs/adr/ADR-FM350-RNDIS-BEARER.md)). Bench devices install +the resulting `.deb`s from CI artifacts; nothing is published to `apt.ceralive.tv` yet — apt +publication is part of Phase B adoption, authorized from the `v1.0.0` release tag forward +(`POLICY.md` §4). ## Sources (4 upstream rebuilds + 1 first-party companion) -The four sources below are **zero-patch upstream rebuilds** and stay that way. The -first-party [`ceralive-modem-support`](ceralive-modem-support/) companion exists so they -never have to absorb a CeraLive-specific asset — see [First-party companion](#first-party-companion-ceralive-modem-support). +The four sources below remain pinned upstream rebuilds. Three carry no source patch; +ModemManager carries only the reviewed FM350-GL series described below. The first-party +[`ceralive-modem-support`](ceralive-modem-support/) companion keeps CeraLive-owned generic +system assets out of every upstream recipe — see +[First-party companion](#first-party-companion-ceralive-modem-support). | Source | Provides | @@ -34,8 +39,11 @@ re-verified end-to-end by [`ci/verify-upstream-pins.sh`](ci/verify-upstream-pins ## Recipes & build Each source's `debian/` dir is checked in at `/debian/` (`ModemManager`, `libmbim`, -`libqmi`, `libqrtr-glib`), copied byte-for-byte from its pinned salsa commit -([`upstream-pins.yaml`](upstream-pins.yaml) `salsa_commit_sha`) with **zero source patches**. +`libqmi`, `libqrtr-glib`), based on its pinned salsa commit +([`upstream-pins.yaml`](upstream-pins.yaml) `salsa_commit_sha`). libmbim, libqmi, and +libqrtr-glib carry no source patches. ModemManager adds only the three entries in +`ModemManager/debian/patches/series`, each attributed to exact BELABOX commits and governed +by the accepted FM350 ADR. All four sources carry one shared bookworm adaptation — the GObject-introspection build-deps are swapped from the sid GI-1.80 set (`gir1.2-*-2.0-dev` + `gobject-introspection (>= 1.80)`) to bookworm's GI-1.74 equivalent (`gobject-introspection` + `libgirepository1.0-dev`), mirroring @@ -44,6 +52,17 @@ adaptations (debhelper relax, `systemd-dev → udev`, and systemd/udev install-d documented, with rationale, stock-bookworm citation, and diff shape, in [`BOOKWORM-ADAPTATIONS.md`](BOOKWORM-ADAPTATIONS.md). +### Approved ModemManager source series + +`ModemManager/debian/patches/series` is intentionally non-empty and contains exactly three +logical changes: the 1.24-native FM350-GL RNDIS bearer plus `+GTACT` mode support, the CPOL +crash-disable device row, and the standalone extended-`+COPS` parser fix with its regression +test. Each DEP-3-style header credits `rationalsa `, names the exact +BELABOX origin SHA(s), and records `Forwarded: no` because the upstream MR is drafted but not +filed. Project-owner approval is dated 2026-08-22 in the ADR and POLICY exception. The series +is build-tested but hardware-unverified; no FM350 support claim exists until the board drill +proves plugin binding, bearer activation, and routable IPv4. + [`ci/build-bookworm.sh`](ci/build-bookworm.sh) `` rebuilds the **selected** sources in a `debian:bookworm` container in the mandatory bootstrap order `libqrtr-glib → libmbim → libqmi → modemmanager`. Each source's freshly built `.deb`s feed a @@ -127,8 +146,9 @@ ModemManager 1.24.2, libmbim 1.34.0, libqmi 1.38.0, libqrtr-glib 1.4.0 (salsa `ceralive-modem-support` is a **first-party, `Architecture: all`** package built from [`ceralive-modem-support/`](ceralive-modem-support/). It owns CeraLive's UNCONDITIONAL, -generic, board-independent modem system assets. The four upstream sources remain -byte-faithful zero-patch rebuilds; that is the entire reason this package exists. +generic, board-independent modem system assets. The companion still keeps CeraLive-owned +system assets out of all four upstream recipes. The reviewed ModemManager FM350-GL source +carry is a separate exception and moves none of those assets into ModemManager. ### Ownership boundary From 2f9aa5fc900b641cbb7980945b047435e3b402d4 Mon Sep 17 00:00:00 2001 From: Andres Cera Date: Sun, 23 Aug 2026 15:12:03 -0500 Subject: [PATCH 07/12] docs(ufi): record the HIMI composition investigation findings --- AGENTS.md | 8 ++++++-- README.md | 7 +++++-- docs/UFI-DIAG-PROBE.md | 32 ++++++++++++++++++++++++++++++++ 3 files changed, 43 insertions(+), 4 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index bd2cc19..f83a295 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1573,14 +1573,18 @@ loop. The admin password is EPHEMERAL BENCH INPUT (`UFI_BENCH_PASSWORD`), inject supervised run only, and `credential-fence.test.ts` scans tracked and intended-untracked files for it plus its base64/SHA-256 derivatives. -### Bench descriptor capture — tooling and schema, NOT a captured bundle +### Bench descriptor capture — tooling, schema, and measured composition `control/scripts/ufi-himi-capture.sh` + `control/scripts/ufi-himi-evidence.ts` are the read-only evidence-capture path for `05c6:9091`, and they live in `control/scripts/` rather than in the provider directory on purpose: bench tooling is not published (`files: ["dist"]`), and `no-write-path.test.ts` enumerates the provider directory exactly, so a file added there would be a change to that gate. **No bundle CONTENT is -committed by this work** — the hardware drill that produces one has not run. +committed** — the redacted 2026-08-23 hardware bundle remains repo-local and gitignored. +That drill measured a four-interface QMI + ADB-class composition: interface 2 was claimed +by `qmi_wwan`, no `ff/ff/30` DIAG descriptor existed, and the HIMI identity endpoint was +unreachable through the target's own `wwan1`. The tracked classification is in +`docs/UFI-DIAG-PROBE.md`; no composition change was attempted. - **The bundle is `manifest.json` + five capture files + one credential-gated HIMI file**, staged in a temp directory and moved into place as a unit, so a published path either diff --git a/README.md b/README.md index eb4973a..9a90522 100644 --- a/README.md +++ b/README.md @@ -116,8 +116,11 @@ composition, and — with an ephemeral bench password — the HIMI `getproducein `getsysinfo` identity), and `control/scripts/ufi-himi-evidence.ts` carries the bundle schema, the per-interface role classifier, and an independent redaction sweep. With no matching device attached the script answers `device-not-present` and writes nothing rather -than leaving a partial bundle. No captured bundle is committed — the hardware drill that -produces one has not run. +than leaving a partial bundle. The 2026-08-23 hardware drill found the attached `05c6:9091` +in a four-interface QMI + ADB-class composition: interface 2 was claimed by `qmi_wwan`, no +`ff/ff/30` DIAG descriptor existed, and the HIMI identity endpoint was unreachable through +the target's `wwan1`. The redacted bundle remains repo-local and gitignored; the measured +classification is recorded in `docs/UFI-DIAG-PROBE.md`. ## Radio capability truth + SIM evidence (Todo 28) diff --git a/docs/UFI-DIAG-PROBE.md b/docs/UFI-DIAG-PROBE.md index d4717f6..b97feb3 100644 --- a/docs/UFI-DIAG-PROBE.md +++ b/docs/UFI-DIAG-PROBE.md @@ -1,5 +1,13 @@ # UFI / HIMI supervised DIAG info probe — BENCH ONLY `[PARTIAL]` +> **Measured 2026-08-23 on `ceralive2`:** the attached `05c6:9091` unit exposes four +> interfaces: `ff/ff/ff` (unbound), `ff/00/00` (unbound), `ff/ff/ff` (interface 2, +> `qmi_wwan`), and `ff/42/01` (ADB-class, unbound). It exposes **no `ff/ff/30` DIAG +> descriptor**, so the procedure stopped after classification and no DIAG request was made. +> The current QMI + ADB-class composition has no RNDIS/CDC pair; an identity attempt bound +> to its `wwan1` could not reach `192.168.0.1`, so `getproduceinfo`/`getsysinfo` remain +> uncaptured. The exact transition to another composition is unproven and approval-gated. + **This procedure never runs on a production board, never runs unattended, and is not an operation `@ceralive/modem-control` can perform.** The shipped `UfiHimiProvider` is read-only over the HIMI HTTP API and has no DIAG code path at all — not a refused stub, @@ -250,3 +258,27 @@ A `diag-descriptor-confirmed` result changes what a supervised bench operator ma by hand. It changes nothing about the product: `UFI_DIAG_PRODUCTION_ACCESS` is `prohibited` unconditionally, and promoting this probe into a production operation is out of scope permanently, not pending evidence. + +## 2026-08-23 bench result — `05c6:9091` + +Todo 13's capture tooling was run against sysfs device `1-1.4.1` on `ceralive2`, kernel +`7.1.7-ceralive-rk3588`. Both redaction sweeps were clean and the manifest records +`mutations: "none"`. + +| Interface | Descriptor | Classification | Captured binding | +|---:|---|---|---| +| 0 | `ff/ff/ff` | vendor-specific | unbound | +| 1 | `ff/00/00` | vendor-specific | unbound | +| 2 | `ff/ff/ff` | vendor-specific | `qmi_wwan` (`wwan1`) | +| 3 | `ff/42/01` | ADB-class | unbound | + +The result is `diag-not-present`: the USB-id database label “Diagnostic Mode” is not borne +out by the descriptor triple required by this runbook. Upstream's `05c6:9091` interface-2 +QMI match happens to agree with the live binding, but its unrelated-device annotation was +not used as evidence; this unit's descriptor and binding were. + +The identity step made one bounded connection attempt through the target's own `wwan1`. +`192.168.0.1:80` was unreachable, so no session was created and the dependent reads were +not sent. No alternate fleet interface was tried. A future composition change requires a +separate owner approval after device-specific evidence identifies the exact target and a +proven return path; nothing in this capture authorizes a blind switch. From c825d19ea48864c27c3a822390ad148ee1ff2216 Mon Sep 17 00:00:00 2001 From: Andres Cera Date: Sun, 23 Aug 2026 16:04:33 -0500 Subject: [PATCH 08/12] test(modemmanager): validate the FM350 bearer patch on hardware --- docs/FM350-DECISION.md | 68 +++++++++++++++++++++++++++++++++++------- 1 file changed, 57 insertions(+), 11 deletions(-) diff --git a/docs/FM350-DECISION.md b/docs/FM350-DECISION.md index 366d66b..efd6255 100644 --- a/docs/FM350-DECISION.md +++ b/docs/FM350-DECISION.md @@ -261,24 +261,70 @@ decision that governs the classifier question, so the runbook and the decision s ## Three-gate ledger FM350 device enablement upstream requires three independent gates. This record is honest -about each: only gate 1 is verified in this repo; gates 2 and 3 are untested. +about each: gate 1 is verified, gate 2 remains untested on the production PCIe topology, and +gate 3 has now been attempted on the carrier-mediated USB topology but failed before +registration. | # | Gate | Requirement | State | Basis | |---|------|-------------|-------|-------| | 1 | MM version floor | ModemManager **≥ 1.24.2** (the release carrying the mtk-plugin FM350 fixes) | **CLEARED** | This repo now ships ModemManager **1.24.2** — see `packaging/upstream-pins.yaml` (`sources.modemmanager.upstream_tag: "1.24.2"`). The version-floor gate is satisfied. | | 2 | Kernel | The `mtk_t7xx` PCIe WWAN driver present and enumerating the module over PCIe on the target hardware | **OPEN** | Not validated on real bench hardware in this work. No target-kernel `mtk_t7xx` bring-up has been performed or observed. | -| 3 | HIL (hardware-in-the-loop) | A physical FM350 module probed end-to-end through the stack on a bench device | **OPEN** | No physical FM350 unit has been tested. There is no hardware evidence of any kind. | +| 3 | HIL (hardware-in-the-loop) | A physical FM350 module probed end-to-end through the stack on a bench device | **OPEN — FAILED** | On 2026-08-23 the locally built patched ModemManager bound the carrier-mounted unit to `fm350gl`, but enabling stopped at `ATZ` → `+CME ERROR: 59` (`MobileEquipment.UnexpectedDataValue`). Registration never began, no bearer was created, `enx000011121314` received no routable IPv4 address, and an interface-bound HTTPS request failed. See [Patched ModemManager HIL attempt](#patched-modemmanager-hil-attempt-2026-08-23). | Gate 1 being CLEARED does **not** imply the FM350 works — it only removes the upstream -version-floor obstacle. Gates 2 and 3 remain the blocking, hardware-gated unknowns, and are -deliberately left OPEN rather than assumed. - -> **Ledger frozen pending human decision (2026-08-17).** The table above is UNCHANGED despite -> Citation 6's hardware observation. Branch A's ledger update (gate 2 → `N/A (USB path -> observed, not PCIe)`, gate 3 → `CLEARED`) is step 4 of the Branch-A procedure and is -> explicitly gated on human sign-off, which has not been given. Independently of that gate, -> gate 3 could not be closed on this evidence anyway: no SIM is installed, so no registration -> or bearer/data-session smoke ran — USB enumeration is not an end-to-end HIL pass. +version-floor obstacle. Gate 2 remains a hardware-gated unknown. Gate 3 is deliberately left +OPEN because the measured HIL attempt failed; a failed attempt is evidence, not clearance. + +The 2026-08-17 USB-enumeration observation did not close either hardware gate: the human later +confirmed that an M.2-to-USB carrier mediates this bench topology, while the production topology +remains PCIe. The 2026-08-23 run is the first end-to-end attempt with a SIM under the candidate +USB plugin. It updates gate 3 with a measured failure rather than promoting enumeration to a +pass. + +### Patched ModemManager HIL attempt, 2026-08-23 + +The owner-approved three-patch series was rebuilt locally for arm64 using the documented +`packaging/ci/build-bookworm.sh arm64` bench path. No release, package publication, or apt +dispatch occurred. The board started on `modemmanager` and `libmm-glib0` +`1.24.2-2~ceralive0.2.0`; the local dev packages were installed with `dpkg -i`, udev was +reloaded, and ModemManager was restarted. + +Three candidate behaviors were observable: + +- **Plugin binding passed.** The FM350 changed from the stock `generic` plugin to `fm350gl`. +- **CPOL disable passed.** The primary AT port carried + `ID_MM_PREFERRED_NETWORKS_CPOL_DISABLED=1`. +- **`+GTACT` mode reading passed.** The plugin reported 12 supported mode combinations and + current modes `allowed: 3g, 4g; preferred: 4g` instead of the generic plugin's single + flattened combination. + +The decisive bearer path failed earlier than the prior `0,NONE` dial failure. On every enable +attempt, the first initialization command and reply were: + +```text +--> ATZ +<-- +CME ERROR: 59 +``` + +ModemManager mapped that reply to `MobileEquipment.UnexpectedDataValue`, left the modem +`disabled`, and published no registration or packet-service state. Consequently: + +- a `--3gpp-scan` was refused with `modem not enabled yet`, so the extended `+COPS` parser fix + is **not hardware-measured** by this run; +- NetworkManager's existing FM350 profile timed out without creating a bearer; +- `enx000011121314` retained only link-local IPv6, with no routable IPv4 address or route; and +- an HTTPS request explicitly bound to that interface timed out. + +The other attached modems were observed passively after installation: SIMCom remained bound to +`simtech`, Quectel remained bound to `quectel` and connected, and the Qualcomm/HIMI row remained +present. No non-FM350 modem was actively exercised. + +**Decision:** the patch series does not clear the HIL gate and is not justified for release in +its current form. The release-carry step must not include it unless a later reviewed change +addresses the `ATZ` enable failure and a fresh drill proves registration, bearer creation, +routable IPv4, and traffic. After the drill, the exact pre-drill v0.2.0 packages were reinstalled; +ModemManager and NetworkManager were active, all four modem rows were present, and the FM350 was +again bound to `generic`. ## Bench probe evidence (RB-16) From e9d41bc7e42830343ab983b85701156dc51357e3 Mon Sep 17 00:00:00 2001 From: Andres Cera Date: Sun, 23 Aug 2026 18:57:45 -0500 Subject: [PATCH 09/12] fix(modemmanager): restore FM350-GL ATZ0 first-enable override --- ...-forward-port-rndis-bearer-and-modes.patch | 47 ++++++++++++++++--- packaging/ci/contract.sh | 4 ++ packaging/ci/test-fm350-patch-contract.sh | 23 +++++++++ 3 files changed, 68 insertions(+), 6 deletions(-) create mode 100755 packaging/ci/test-fm350-patch-contract.sh diff --git a/packaging/ModemManager/debian/patches/0001-fm350gl-forward-port-rndis-bearer-and-modes.patch b/packaging/ModemManager/debian/patches/0001-fm350gl-forward-port-rndis-bearer-and-modes.patch index d17588e..7fe4c74 100644 --- a/packaging/ModemManager/debian/patches/0001-fm350gl-forward-port-rndis-bearer-and-modes.patch +++ b/packaging/ModemManager/debian/patches/0001-fm350gl-forward-port-rndis-bearer-and-modes.patch @@ -8,8 +8,10 @@ Description: Add the FM350-GL USB/RNDIS bearer and GTACT mode handling on 1.24.2 ModemManager 1.24.2. It uses the current MMIfaceModemInterface and GTask APIs, activates/deactivates the RNDIS PDP context with +CGACT, obtains addressing through +CGPADDR/+CGCONTRDP, and returns the network port instead of attempting - the generic PPP ATD path. The 4-second GMR guard, IPv4-only DNS relaxation, - set-mode completion fix, and single-RAT stale-preference fix are folded here. + the generic PPP ATD path. The FM350-specific first-enable override sends ATZ0 + instead of the unsupported ATZ form. The 4-second GMR guard, IPv4-only DNS + relaxation, set-mode completion fix, and single-RAT stale-preference fix are + folded here. Author: rationalsa Origin: vendor, https://github.com/BELABOX/modemmanager/commit/da01610c46c581b0c6f2acd0ac50f5bba666efdf Origin-Commit: da01610c46c581b0c6f2acd0ac50f5bba666efdf @@ -23,7 +25,7 @@ Applied-Upstream: no Upstream-Status: NOT UPSTREAM; no USB/RNDIS fm350gl plugin exists in 1.24.2 or upstream main Rationale: The USB 0e8d:7126/7127 compositions expose RNDIS plus AT ports but no MBIM/QMI port. Generic ModemManager therefore issues ATD*99***N# and the modem returns 0,NONE. The bearer must use +CGACT and the RNDIS net port. Credit: BELABOX-authored fixes forward-ported by CeraLive; this patch is not CeraLive-originated work. -Last-Update: 2026-08-22 +Last-Update: 2026-08-23 --- meson.build | 1 + @@ -31,7 +33,7 @@ Last-Update: 2026-08-22 src/plugins/fm350gl/77-mm-fm350gl.rules | 32 ++ .../fm350gl/mm-broadband-bearer-fm350gl.c | 525 ++++++++++++++++++ .../fm350gl/mm-broadband-bearer-fm350gl.h | 46 ++ - .../fm350gl/mm-broadband-modem-fm350gl.c | 297 ++++++++++ + .../fm350gl/mm-broadband-modem-fm350gl.c | 330 +++++++++++ .../fm350gl/mm-broadband-modem-fm350gl.h | 44 ++ .../fm350gl/mm-modem-helpers-fm350gl.c | 162 ++++++ .../fm350gl/mm-modem-helpers-fm350gl.h | 25 + @@ -39,7 +41,7 @@ Last-Update: 2026-08-22 .../tests/test-modem-helpers-fm350gl.c | 96 ++++ src/plugins/meson.build | 21 + src/plugins/mm-builtin-plugins.c | 6 + - 13 files changed, 1330 insertions(+) + 13 files changed, 1363 insertions(+) create mode 100644 src/plugins/fm350gl/77-mm-fm350gl.rules create mode 100644 src/plugins/fm350gl/mm-broadband-bearer-fm350gl.c create mode 100644 src/plugins/fm350gl/mm-broadband-bearer-fm350gl.h @@ -700,7 +702,7 @@ new file mode 100644 index 0000000000000000000000000000000000000000..6b061b88e04ab658021baa63321a1f0ded48684f --- /dev/null +++ b/src/plugins/fm350gl/mm-broadband-modem-fm350gl.c -@@ -0,0 +1,297 @@ +@@ -0,0 +1,330 @@ +/* -*- Mode: C; tab-width: 4; indent-tabs-mode: nil; c-basic-offset: 4 -*- */ +/* + * This program is free software; you can redistribute it and/or modify @@ -728,6 +730,36 @@ index 0000000000000000000000000000000000000000..6b061b88e04ab658021baa63321a1f0d +G_DEFINE_TYPE_EXTENDED (MMBroadbandModemFm350gl, mm_broadband_modem_fm350gl, MM_TYPE_BROADBAND_MODEM, 0, + G_IMPLEMENT_INTERFACE (MM_TYPE_IFACE_MODEM, iface_modem_init)) + ++static void ++enabling_modem_init (MMBroadbandModem *self, ++ GAsyncReadyCallback callback, ++ gpointer user_data) ++{ ++ MMPortSerialAt *primary; ++ ++ primary = mm_base_modem_peek_port_primary (MM_BASE_MODEM (self)); ++ if (!primary) { ++ g_task_report_new_error (self, ++ callback, ++ user_data, ++ enabling_modem_init, ++ MM_CORE_ERROR, ++ MM_CORE_ERROR_FAILED, ++ "Failed to run init command: primary port missing"); ++ return; ++ } ++ ++ mm_base_modem_at_command_full (MM_BASE_MODEM (self), ++ MM_IFACE_PORT_AT (primary), ++ "Z0", ++ 6, ++ FALSE, ++ FALSE, ++ NULL, ++ callback, ++ user_data); ++} ++ +/*****************************************************************************/ +/* Delayed current-capabilities load */ + @@ -997,6 +1029,9 @@ index 0000000000000000000000000000000000000000..6b061b88e04ab658021baa63321a1f0d +static void +mm_broadband_modem_fm350gl_class_init (MMBroadbandModemFm350glClass *klass) +{ ++ MMBroadbandModemClass *broadband_modem_class = MM_BROADBAND_MODEM_CLASS (klass); ++ ++ broadband_modem_class->enabling_modem_init = enabling_modem_init; +} diff --git a/src/plugins/fm350gl/mm-broadband-modem-fm350gl.h b/src/plugins/fm350gl/mm-broadband-modem-fm350gl.h new file mode 100644 diff --git a/packaging/ci/contract.sh b/packaging/ci/contract.sh index b58f4d3..6b27193 100755 --- a/packaging/ci/contract.sh +++ b/packaging/ci/contract.sh @@ -47,6 +47,7 @@ require "ci/generate-release-manifest.sh" require "ci/suffix-contract.sh" require "ci/test-suffix-coherence-manifest.sh" require "ci/test-release-workflow-wiring.sh" +require "ci/test-fm350-patch-contract.sh" require "ci/check-upstream-freshness.sh" require "ci/test-check-upstream-freshness.sh" require "ci/build-companion.sh" @@ -118,6 +119,9 @@ bash "$HERE/test-suffix-coherence-manifest.sh" >/dev/null echo " running release.yml differential wiring contract..." bash "$HERE/test-release-workflow-wiring.sh" >/dev/null +echo " running FM350 patch contract..." +bash "$HERE/test-fm350-patch-contract.sh" >/dev/null + # The upstream freshness proof is offline and fixture-driven, so it needs no docker, no network # and no built .deb — exactly the kind of invariant this lightweight lane should exercise. echo " running upstream freshness contract..." diff --git a/packaging/ci/test-fm350-patch-contract.sh b/packaging/ci/test-fm350-patch-contract.sh new file mode 100755 index 0000000..29833ee --- /dev/null +++ b/packaging/ci/test-fm350-patch-contract.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +set -euo pipefail + +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PATCH="$HERE/../ModemManager/debian/patches/0001-fm350gl-forward-port-rndis-bearer-and-modes.patch" + +fail() { + printf 'FAIL: %s\n' "$1" >&2 + exit 1 +} + +grep -Fq '+enabling_modem_init (MMBroadbandModem' "$PATCH" || + fail 'FM350 patch does not override the first-enable modem initialization' +grep -Fq '+ "Z0",' "$PATCH" || + fail 'FM350 first-enable override does not send the hardware-proven Z0 command' +grep -Fq '+ broadband_modem_class->enabling_modem_init = enabling_modem_init;' "$PATCH" || + fail 'FM350 class does not install the first-enable override' + +if grep -Fq '+ "Z",' "$PATCH"; then + fail 'FM350 override regressed to Z, which this firmware rejects with CME 59' +fi + +printf 'PASS: FM350 patch preserves the hardware-required Z0 first-enable override\n' From aca8d44740e28136c7d830101c7bfcb669268275 Mon Sep 17 00:00:00 2001 From: Andres Cera Date: Sun, 23 Aug 2026 18:58:15 -0500 Subject: [PATCH 10/12] docs(fm350): record the passing carrier-board bearer drill --- docs/FM350-DECISION.md | 62 ++++++++++++++++++++++++------ docs/adr/ADR-FM350-RNDIS-BEARER.md | 49 ++++++++++++++++------- 2 files changed, 85 insertions(+), 26 deletions(-) diff --git a/docs/FM350-DECISION.md b/docs/FM350-DECISION.md index efd6255..6965c36 100644 --- a/docs/FM350-DECISION.md +++ b/docs/FM350-DECISION.md @@ -262,18 +262,18 @@ decision that governs the classifier question, so the runbook and the decision s FM350 device enablement upstream requires three independent gates. This record is honest about each: gate 1 is verified, gate 2 remains untested on the production PCIe topology, and -gate 3 has now been attempted on the carrier-mediated USB topology but failed before -registration. +gate 3 is cleared for the carrier-mediated USB topology after correcting the downstream +forward-port. | # | Gate | Requirement | State | Basis | |---|------|-------------|-------|-------| | 1 | MM version floor | ModemManager **≥ 1.24.2** (the release carrying the mtk-plugin FM350 fixes) | **CLEARED** | This repo now ships ModemManager **1.24.2** — see `packaging/upstream-pins.yaml` (`sources.modemmanager.upstream_tag: "1.24.2"`). The version-floor gate is satisfied. | | 2 | Kernel | The `mtk_t7xx` PCIe WWAN driver present and enumerating the module over PCIe on the target hardware | **OPEN** | Not validated on real bench hardware in this work. No target-kernel `mtk_t7xx` bring-up has been performed or observed. | -| 3 | HIL (hardware-in-the-loop) | A physical FM350 module probed end-to-end through the stack on a bench device | **OPEN — FAILED** | On 2026-08-23 the locally built patched ModemManager bound the carrier-mounted unit to `fm350gl`, but enabling stopped at `ATZ` → `+CME ERROR: 59` (`MobileEquipment.UnexpectedDataValue`). Registration never began, no bearer was created, `enx000011121314` received no routable IPv4 address, and an interface-bound HTTPS request failed. See [Patched ModemManager HIL attempt](#patched-modemmanager-hil-attempt-2026-08-23). | +| 3 | HIL (hardware-in-the-loop) | A physical FM350 module probed end-to-end through the stack on a bench device | **CLEARED — USB CARRIER** | The follow-up restored BELABOX's omitted `enabling_modem_init` override (`ATZ0`, not `ATZ`). The unit registered home, attached packet service, established the RNDIS bearer, assigned `10.0.163.14/32` to `enx000011121314`, and `curl --interface enx000011121314 https://example.com` returned HTTP 200. See [Follow-up root cause and passing HIL](#follow-up-root-cause-and-passing-hil-2026-08-23). | -Gate 1 being CLEARED does **not** imply the FM350 works — it only removes the upstream -version-floor obstacle. Gate 2 remains a hardware-gated unknown. Gate 3 is deliberately left -OPEN because the measured HIL attempt failed; a failed attempt is evidence, not clearance. +Gate 1 being CLEARED does **not** imply the FM350 works on the production topology — it only +removes the upstream version-floor obstacle. Gate 2 remains a PCIe hardware-gated unknown. +Gate 3 clears only the carrier-mediated USB path measured below. The 2026-08-17 USB-enumeration observation did not close either hardware gate: the human later confirmed that an M.2-to-USB carrier mediates this bench topology, while the production topology @@ -319,12 +319,50 @@ The other attached modems were observed passively after installation: SIMCom rem `simtech`, Quectel remained bound to `quectel` and connected, and the Qualcomm/HIMI row remained present. No non-FM350 modem was actively exercised. -**Decision:** the patch series does not clear the HIL gate and is not justified for release in -its current form. The release-carry step must not include it unless a later reviewed change -addresses the `ATZ` enable failure and a fresh drill proves registration, bearer creation, -routable IPv4, and traffic. After the drill, the exact pre-drill v0.2.0 packages were reinstalled; -ModemManager and NetworkManager were active, all four modem rows were present, and the FM350 was -again bound to `generic`. +**Decision at the end of this first attempt:** the patch series did not clear the HIL gate in +that form. After the drill, the exact pre-drill v0.2.0 packages were reinstalled; ModemManager +and NetworkManager were active, all four modem rows were present, and the FM350 was again bound +to `generic`. The follow-up below supersedes only that release-carry verdict. + +### Follow-up root cause and passing HIL, 2026-08-23 + +The suspected udev error was tested first and rejected. Mode 41 exposes RNDIS on interfaces +0/1, option ttys on interfaces 2/3/4/6/7/8/9, and an unbound `ff/42/01` interface 5. +ttyUSB12/interface 6 — the rules' primary — was the only tty that answered a full query-only +AT sweep. Stock v0.2.0's generic plugin also selected ttyUSB12 and reproduced the same first- +enable CME 59 after a daemon restart. + +Fresh inspection of BELABOX commit `da01610c46c581b0c6f2acd0ac50f5bba666efdf` found the +missing forward-port behavior: the original modem subclass overrides +`MMBroadbandModemClass.enabling_modem_init` and sends `Z0`. The downstream re-implementation +had omitted the override, so ModemManager inherited core `Z`. Directly on ttyUSB12, `ATZ` +returned CME 59 after both 2 and 32 seconds, while `ATZ0` returned `OK`. Restoring that exact +override therefore toggled the cause, not merely the symptom. + +With the corrected arm64 packages installed, the FM350 bound `fm350gl` on ttyUSB12 and moved +through `enabling → enabled → registering → home → connected`. The bearer debug trace showed: + +```text +AT+CGACT=1,0 +AT+CGPADDR=0 -> +CGPADDR: 0,"10.0.163.14","" +AT+CGCONTRDP=0 +(fm350gl) IP settings loaded for RNDIS PDP context #0 +``` + +The acceptance transcript then passed: + +```text +nmcli connection up gsm-4 ifname ttyUSB12 +Connection successfully activated +enx000011121314 UNKNOWN 10.0.163.14/32 +default via 190.157.8.46 proto static metric 700 +curl --interface enx000011121314 https://example.com +http_code=200 +``` + +The board was returned to `modemmanager`/`libmm-glib0` `1.24.2-2~ceralive0.2.0` after the +final capture. This clears the carrier-mediated USB HIL gate and justifies carrying the fixed +series. It does not clear gate 2 or claim production PCIe operation. ## Bench probe evidence (RB-16) diff --git a/docs/adr/ADR-FM350-RNDIS-BEARER.md b/docs/adr/ADR-FM350-RNDIS-BEARER.md index d8f01b6..a9e08ff 100644 --- a/docs/adr/ADR-FM350-RNDIS-BEARER.md +++ b/docs/adr/ADR-FM350-RNDIS-BEARER.md @@ -1,8 +1,9 @@ # ADR — FM350-GL RNDIS bearer gap, and the decision to forward-port the BELABOX plugin -**Status:** ACCEPTED FOR A PINNED DOWNSTREAM CARRY. The project owner reviewed this record -and approved the three-patch ModemManager 1.24.2 forward-port on 2026-08-22. Hardware bearer -validation remains outstanding and this status is not a hardware support claim. +**Status:** ACCEPTED FOR A PINNED DOWNSTREAM CARRY; USB-CARRIER BEARER VALIDATED. The project +owner approved the three-patch ModemManager 1.24.2 forward-port on 2026-08-22. A corrected +forward-port established an RNDIS bearer and carried HTTPS traffic on the carrier-mounted unit +on 2026-08-23. This validates that topology and firmware; it is not a PCIe support claim. **Date:** 2026-08-22 **Deciders:** modem-stack maintainers; CeraLive project owner as second maintainer (approved 2026-08-22; see §7). @@ -80,6 +81,23 @@ So the failure is not a misconfiguration, a SIM problem, an APN problem, or a Ne problem. ModemManager is dialing a modem over a mechanism that modem does not have, because nothing in the shipped source tree knows this device. +### 2.2 Follow-up: the forward-port's first-enable regression + +The first hardware build of the forward-port failed before the bearer path: ModemManager's +generic first-enable hook sent `ATZ`, and firmware `81600.0000.00.19.17.10` returned +`+CME ERROR: 59`. Port assignment was tested first and ruled out. In mode 41, ttyUSB12 on USB +interface 6 was the only option port that answered `AT`, `ATI`, `AT+CGMM`, and `AT+CGMR`; the +other six tty ports were silent or echo-only. Stock v0.2.0 also failed a fresh enable on the +same tty with the same error, so neither the `fm350gl` plugin flags nor its udev primary-port +row created the failure. + +The missing behavior was in BELABOX's original modem subclass: its +`enabling_modem_init` class override sends `Z0` (wire command `ATZ0`) instead of the core's +`Z` (`ATZ`). The CeraLive re-implementation had omitted that override. A direct same-port +toggle confirmed causality: `ATZ` returned CME 59 after both 2 seconds and 32 seconds, while +`ATZ0` returned `OK`. Restoring the override made the first enable succeed; registration moved +to `home`, packet service to `attached`, and the plugin activated context 0 with `+CGACT`. + --- ## 3. Upstream 1.24 does not cover this device (POLICY §1.1 — "why a rebuild cannot") @@ -174,8 +192,8 @@ ModemManager reports one flattened `allowed: 2g, 3g, 4g, 5g; preferred: none` co limit**, and it has the same root cause as §2.1: no plugin claims the device. The carried series includes BELABOX's `+GTACT` RAT-mode read/write handling and its two mode correctness fixes, but deliberately does not carry the fork's band parser/writer. Granular band support -therefore remains outside this decision, and every mode claim remains hardware-unverified -until the todo 39 drill. +therefore remains outside this decision. The carrier-mode mode catalog and current-mode read +were observed during the hardware drill; granular band handling remains absent by design. --- @@ -198,7 +216,7 @@ derives from and states that the content is not CeraLive-originated. | # | SHA (full) | Subject | Files | Upstream status | Verdict | |---|-----------|---------|-------|-----------------|---------| -| 1 | `da01610c46c581b0c6f2acd0ac50f5bba666efdf` | `FM350GL: backport FM350GL patch` | new `src/plugins/fm350gl/` (7 files, +1697), `meson.build`, `meson_options.txt`, `src/plugins/meson.build`, `src/plugins/mm-builtin-plugins.c` | **NOT UPSTREAM.** No `fm350gl` plugin exists at `1.24.2` or on `main`. The commit message cites upstream issue [#899](https://gitlab.freedesktop.org/mobile-broadband/ModemManager/-/issues/899) as the origin of the code; no merge request carrying it has landed. | **REQUIRED.** This is the whole fix. Everything else in the table is a bug fix on top of it. Largest forward-port risk: it adds a plugin to the build system, and 1.24's plugin build was reorganized after the base commit. | +| 1 | `da01610c46c581b0c6f2acd0ac50f5bba666efdf` | `FM350GL: backport FM350GL patch` | new `src/plugins/fm350gl/` (7 files, +1697), `meson.build`, `meson_options.txt`, `src/plugins/meson.build`, `src/plugins/mm-builtin-plugins.c` | **NOT UPSTREAM.** No `fm350gl` plugin exists at `1.24.2` or on `main`. The commit message cites upstream issue [#899](https://gitlab.freedesktop.org/mobile-broadband/ModemManager/-/issues/899) as the origin of the code; no merge request carrying it has landed. | **REQUIRED.** This includes the modem subclass's `enabling_modem_init` override (`Z0`, not core `Z`); omitting it was the hardware-confirmed forward-port regression. Everything else in the table is a bug fix on top of it. | | 2 | `b4377c5028a0de4435b86f7aad9114e9443d69e4` | `fm350gl: disable CPOL command that crashes the modem` | `src/plugins/fm350gl/77-mm-fm350gl.rules` (+3) | **MECHANISM IS UPSTREAM; THIS DATA ROW IS NOT.** `ID_MM_PREFERRED_NETWORKS_CPOL_DISABLED` is a first-class upstream udev tag, consumed at `src/mm-base-sim.c:1066` and already used by the huawei, sierra, simtech and telit rules files on `main`. No rule tags `0e8d:712[6-7]`. | **REQUIRED, and the most independently landable row.** It is three lines of device data using an upstream-blessed tag. It needs a rules file to live in, though, and upstream has none for this device — so in practice it lands with commit 1. | | 3 | `419dc598d3f2c11f479dacdc2f6ef0e787e6ea3b` | `fm350gl: delay initialization to avoid crashing on AT+GCMR` | `src/plugins/fm350gl/mm-broadband-modem-fm350gl.c` (+40) | **NOT UPSTREAM** (depends on commit 1). | **REQUIRED but WEAKEST.** It is a fixed 4000 ms sleep before `load_current_capabilities`, chosen by the author as "2500 ms observed worst case, doubled out of caution." A hardcoded settle delay is the row most likely to draw upstream review objections, and it is the row a reviewer should scrutinise hardest. Dropping it risks crashing the modem at probe; keeping it costs four seconds on every FM350 enumeration. | | 4 | `90bcd376405906d3a94b0c239689eef1a3899ed2` | `fm350gl: fix DNS parsing for IP4-only networks` | `src/plugins/fm350gl/mm-broadband-bearer-fm350gl.c` (+12/−32) | **NOT UPSTREAM** (depends on commit 1). | **REQUIRED.** The original code demanded ≥30 `+CGCONTRDP` fields and hard-failed an IPv4-only reply; this relaxes the floor to 7 and reads the IPv6 DNS pair only when present. The bench SIM is on an IPv4 APN (`internet.movistar.com.co`, `ip type: ipv4`), so without this the bearer would fail even after commit 1. | @@ -254,22 +272,24 @@ proceed without pretending the draft has been filed. **Scope note.** The series carries `+GTACT` RAT-mode read/write because the two BELABOX mode fixes are explicitly in scope. It does not carry the fork's band parser/writer, does not claim -full granular band support, and does not close any hardware result before todo 39 runs. +full granular band support. The 2026-08-23 follow-up closes the USB-carrier bearer result +only; PCIe production-topology validation remains separate. --- ## 6. Decision Carry the minimum BELABOX-derived RNDIS bearer and mode series on the pinned ModemManager -1.24.2 source, while keeping the unfiled upstream offer and hardware validation open. +1.24.2 source, while keeping the unfiled upstream offer and PCIe validation open. Concretely: the FM350-GL's USB/RNDIS composition is unsupported by ModemManager and cannot be made supported by rebuilding, by a composition switch, or by a vendor AT command. The BELABOX `fm350gl` series is the only known working fix. The project owner reviewed this evidence and approved the downstream forward-port on 2026-08-22. The implementation remains BELABOX's work in origin and credit; CeraLive's role is the 1.24.2 re-implementation and pinned carry. -No support claim is made until the hardware bearer drill proves plugin binding, data-session -activation and routable IPv4. +The corrected series has now proved plugin binding, data-session activation, routable IPv4, +and interface-bound HTTPS on the USB-carrier topology. That result does not clear the separate +production PCIe gate. --- @@ -317,10 +337,11 @@ the evidence for todo 16 must repeat the distinction. change or unrelated modem behavior is covered. - It does not claim an upstream merge request exists. - It records the project owner's second-maintainer review and approval dated 2026-08-22. -- It does not claim the patch is hardware-proven; todo 39 must still bind the `fm350gl` - plugin, connect the bearer and prove a routable IPv4 address on the real board. -- It does not change [`docs/FM350-DECISION.md`](../FM350-DECISION.md)'s three-gate ledger, its - documented-deferred PCIe conclusion, or its no-classifier-entry decision. +- It claims only the hardware result actually measured: the corrected patch binds `fm350gl`, + connects the bearer, assigns IPv4, and carries traffic on the carrier-mounted USB unit. +- It changes [`docs/FM350-DECISION.md`](../FM350-DECISION.md)'s gate 3 only for the measured + USB-carrier path; the documented-deferred PCIe conclusion and no-classifier-entry decision + remain unchanged. - It does not promote the FM350 in `docs/MODEM-SUPPORT-MATRIX.md`, and it adds no catalog or certification claim of any kind. - It carries `+GTACT` RAT-mode handling but not the fork's granular band parser/writer (§3.2). From 27c4cb9ea08d235803ba85d5c962e28f106993e9 Mon Sep 17 00:00:00 2001 From: Andres Cera Date: Sun, 23 Aug 2026 18:58:35 -0500 Subject: [PATCH 11/12] docs(packaging): mark FM350 carrier-USB validation complete --- AGENTS.md | 2 +- POLICY.md | 2 +- README.md | 2 +- packaging/README.md | 6 ++++-- 4 files changed, 7 insertions(+), 5 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index f83a295..4f31642 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -14,7 +14,7 @@ Canonical branch: `main`. Sole remote: `origin` → `https://github.com/CERALIVE |-----------|----------|------| | `control/` | `@ceralive/modem-control` (npm) | TypeScript control library — domain model, ModemManager D-Bus backend, NetworkManager adapter, desired-state reconciler, injected admission/ownership/USB-hub ports, USB composition-mode model + evidence-bundle **ingestion seam**, data-usage sampler + the **usage-policy write surface**, capability-module **support-claim taxonomy + detection**, and the **band-lock** vocabulary + certification catalog (see §§ below). Published to public npm under `@ceralive` as **built ESM + `.d.ts`** across seven entry points (see § PUBLISHED PACKAGE SURFACE). | | `cli/` | `modem-control` (bench CLI) | The iteration surface: `probe`/`watch`/`apply`/`set-usb-mode`/`usage`/`certify`/`hil-cycle`, compiled `arm64`+`amd64`, run against real modems. Not published to npm. | -| `packaging/` | ModemManager stack `.deb`s **+ the first-party companion** | Bookworm rebuilds of ModemManager + libmbim + libqmi + libqrtr-glib — packaging only, not a fork. libmbim/libqmi/libqrtr-glib remain source-unmodified; ModemManager carries exactly three owner-approved, BELABOX-derived FM350-GL patches (see `POLICY.md` and `docs/adr/ADR-FM350-RNDIS-BEARER.md`) — PLUS `ceralive-modem-support`, the `Architecture: all` first-party companion that owns CeraLive's generic modem system assets. | +| `packaging/` | ModemManager stack `.deb`s **+ the first-party companion** | Bookworm rebuilds of ModemManager + libmbim + libqmi + libqrtr-glib — packaging only, not a fork. libmbim/libqmi/libqrtr-glib remain source-unmodified; ModemManager carries exactly three owner-approved, BELABOX-derived FM350-GL patches, hardware-validated on the carrier-mediated USB topology after restoring the original `ATZ0` first-enable override (see `POLICY.md` and `docs/adr/ADR-FM350-RNDIS-BEARER.md`) — PLUS `ceralive-modem-support`, the `Architecture: all` first-party companion that owns CeraLive's generic modem system assets. | `control/` + `cli/` are one **Bun** workspace. `packaging/` builds in a bookworm container. diff --git a/POLICY.md b/POLICY.md index 0abcb6e..9736879 100644 --- a/POLICY.md +++ b/POLICY.md @@ -31,7 +31,7 @@ merge-request draft remains unfiled. Requirement 2 above is therefore still hone **not met**; the dated owner decision is an explicit exception for this series, not a claim that an MR exists and not a general waiver of the upstream-first gate. Every patch must retain the BELABOX author, originating commit SHA(s), rationale, and `Forwarded: no` status. The -series remains hardware-unverified until its board drill passes and must be retired when +series is hardware-verified on the carrier-mediated USB topology and must be retired when upstream ships equivalent support. ## 2. Upstream-contribution-first diff --git a/README.md b/README.md index 9a90522..dcc0997 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ straight from CI artifacts; nothing is published to `apt.ceralive.tv` yet. |-----------|----------|------------| | [`control/`](control/) | **`@ceralive/modem-control`** (npm package) | The TypeScript control library: the [frozen v1.1 domain contracts](docs/DOMAIN-CONTRACTS.md), [provider registry and evidence-scored matcher](docs/PROVIDER-MATCHING.md), concrete typed-D-Bus [`ModemManagerProvider`](docs/MODEMMANAGER-PROVIDER.md) (runtime-discovered generic controls; no CLI subprocess), NetworkManager adapter, desired-state reconciler, injected mutation-admission and exclusive-ownership ports, USB composition-mode model + the [evidence-bundle ingestion seam](docs/CATALOG-INGESTION.md), data-usage sampler plus its `setUsagePolicy` write surface (`control/src/backend/usage/policy-write.ts` — a local 0600 policy file, because ModemManager exposes no data-usage API at all), and the **read-only SMS port** (`control/src/ports/sms.ts` + `control/src/sms/` — LIST/READ plus `Added`/`Deleted` observation, never a send or a delete, locked by `sms/readonly-gate.test.ts`). The USB-hub actuator is port-only here; the bench CLI owns its HIL adapter. Published to the public npm registry under the `@ceralive` scope as **built ESM + `.d.ts`** across seven entry points — see [`control/README.md`](control/README.md). | | [`cli/`](cli/) | **`modem-control`** (bench CLI) | The iteration surface: `probe`, `watch`, `apply`, `set-usb-mode`, `usage`, `certify`, `hil-cycle`. Compiled for `arm64` + `amd64` and run against real modems on a bench device to mature the package, capture per-SKU certification bundles, and prove hub VBUS port-cycling ([RB-10](docs/BENCH.md#rb-10--hub-vbus-verification-partial)). | -| [`packaging/`](packaging/) | **ModemManager stack `.deb`s** | Bookworm rebuilds of ModemManager + libmbim + libqmi + libqrtr-glib — **packaging only, not a fork**. libmbim, libqmi, and libqrtr-glib remain source-unmodified; ModemManager carries one owner-approved, three-patch BELABOX-derived FM350-GL series (see [`POLICY.md`](POLICY.md) and the [ADR](docs/adr/ADR-FM350-RNDIS-BEARER.md)). Provenance-verified upstream pins; installed on the bench from CI artifacts. | +| [`packaging/`](packaging/) | **ModemManager stack `.deb`s** | Bookworm rebuilds of ModemManager + libmbim + libqmi + libqrtr-glib — **packaging only, not a fork**. libmbim, libqmi, and libqrtr-glib remain source-unmodified; ModemManager carries one owner-approved, three-patch BELABOX-derived FM350-GL series, hardware-validated on the carrier-mounted USB composition after restoring BELABOX's `ATZ0` first-enable override (see [`POLICY.md`](POLICY.md) and the [ADR](docs/adr/ADR-FM350-RNDIS-BEARER.md)). Provenance-verified upstream pins; installed on the bench from CI artifacts. | The control package's existing `./hardware` entry point also exposes transport-free SIM-presence and Huawei/ZTE/UFI response normalization. Device I/O, sessions, retries, diff --git a/packaging/README.md b/packaging/README.md index 83f95ca..f4b332e 100644 --- a/packaging/README.md +++ b/packaging/README.md @@ -60,7 +60,8 @@ crash-disable device row, and the standalone extended-`+COPS` parser fix with it test. Each DEP-3-style header credits `rationalsa `, names the exact BELABOX origin SHA(s), and records `Forwarded: no` because the upstream MR is drafted but not filed. Project-owner approval is dated 2026-08-22 in the ADR and POLICY exception. The series -is build-tested but hardware-unverified; no FM350 support claim exists until the board drill +is build-tested and hardware-validated on the carrier-mediated USB topology; no PCIe support +claim exists until the production-topology board drill proves plugin binding, bearer activation, and routable IPv4. [`ci/build-bookworm.sh`](ci/build-bookworm.sh) `` rebuilds the **selected** @@ -281,7 +282,7 @@ closed naming that source. The migration-continuity chain | [`ci/build-bookworm.sh`](ci/build-bookworm.sh) | **Step 3 of the differential pipeline.** Rebuilds the SELECTED sources in a `debian:bookworm` container in bootstrap order via a temporary local apt repo. `build-bookworm.sh ` — native amd64 or full-system-QEMU arm64, never cross-built. The build set comes from `VERDICTS_FILE` (the detector's output) or an explicit `BUILD_SOURCES` list; supplying both fails closed, supplying neither builds all four (the local-dev default, never what CI relies on). Before the first build it seeds the Pin-Priority-1001 local repo with every already-staged deb of each SKIPPED source, so a selected source resolves stack build-deps and gir typelibs against carried CeraLive packages rather than stock bookworm; carried debs are preserved and only stale `*.changes`/`*.buildinfo` plus the selected sources' own artifacts are removed. Release builds call `inject-deb-version.sh` once per selected source, passing the caller's already-resolved `PREV_MANIFEST_FILE`. The runtime closure is asserted from the **MERGED** staged set (built + carried), and a zero-source run starts no container yet still performs that assertion. Fetches + sha256-verifies each pinned `.orig.tar`, overlays the checked-in `debian/`, injects the version (`RELEASE_VERSION=vX.Y.Z` → that source's derived `~ceralive.N`; unset → `~ceralive0.0.0~dev`) into a **copy** of each changelog, installs the freshly-built `gir1.2-*-1.0` typelibs into the build env before each dependent source (so bookworm's GI-1.74 `dh_girepository` can resolve cross-namespace typelib deps — Qmi→Qrtr, MM→Qmi/Mbim/Qrtr), runs real `dpkg-buildpackage`, and asserts per-source package-set **equality** (via `ci/check-package-sets.sh`) from each freshly built source's `.changes` plus the 9-package runtime closure over the merged staged set (drift ⇒ non-zero). Output to gitignored `build//`. | | [`ci/test-build-bookworm-differential.sh`](ci/test-build-bookworm-differential.sh) | The differential builder's contract. Its `BUILD_BOOKWORM_STUB_DIR` seam replaces only the expensive source-build body with source-keyed fixture artifacts; build-set parsing, carry seeding, bootstrap dispatch, counter derivation, package-set checking and the merged-closure assertion all run through their production paths. No docker, no network. | | [`ci/check-package-sets.sh`](ci/check-package-sets.sh) | Exact per-source package-set **equality** enforcement. `check-package-sets.sh [expected-packages.txt]` asserts every `*.changes` binary set EQUALS its `[ all-artifact]` set in [`ci/expected-packages.txt`](ci/expected-packages.txt) (the finalized two-set model: declared arch-dependent stanzas + enumerated `-dbgsym`). Equality — not `≥`/count — so an add/remove/rename fails closed naming the offending package. Invoked by `build-bookworm.sh` in-container after the closure check, and standalone per-arch. | -| [`ci/contract.sh`](ci/contract.sh) | The packaging **PR lane** (bookworm container) entry point. Lightweight, needs no built `.deb`: asserts the scaffold, the tag-guard contract, that `dch` version-injection runs on a **copy** (the committed changelogs stay pristine), and the real `dpkg --compare-versions` tilde ordering. It also runs the **six** registered offline test suites — `test-tag-guard.sh`, `test-detect-changed-sources.sh`, `test-stage-carryforward-debs.sh`, `test-build-bookworm-differential.sh`, `test-suffix-coherence-manifest.sh`, `test-release-workflow-wiring.sh` — every one of which needs no docker, no network and no built artifact, which is exactly why they belong in this lane. The deb-consuming contract lives in `test-package-contract.sh` + `daemon-smoke.sh`. | +| [`ci/contract.sh`](ci/contract.sh) | The packaging **PR lane** (bookworm container) entry point. Lightweight, needs no built `.deb`: asserts the scaffold, the tag-guard contract, that `dch` version-injection runs on a **copy** (the committed changelogs stay pristine), and the real `dpkg --compare-versions` tilde ordering. It also runs the registered offline suites for tag guarding, change detection, carry-forward staging, differential builds, suffix coherence, release wiring, upstream freshness, and the FM350 patch contract. The deb-consuming contract lives in `test-package-contract.sh` + `daemon-smoke.sh`. | | [`ci/test-package-contract.sh`](ci/test-package-contract.sh) | The **package contract suite** over the A5.1 build output. `test-package-contract.sh ` launches a `debian:bookworm` container and runs: metadata/arch over the 9-package closure (revision-exact — every deb's base must equal its `read-pin.sh` `-`); clean-bookworm `apt-get install ./*.deb`; upgrade (stock 1.20.4 → ceralive set) with a **direction-aware** `--allow-downgrades` (computed per-package from real `dpkg --compare-versions` vs `madison` stock — post-bump every source sorts ABOVE stock, so the flag is dropped); rollback (`madison`-derived stock versions + `--allow-downgrades`); **per-source** coherence (one `~ceralive` suffix WITHIN each upstream source — two sources at different rebuild counters is what a differential release produces and is accepted; the retained negative is now a source disagreeing with ITSELF, and it fails closed naming that source); real ordering proofs including the migration-continuity chain; tag-guard negative; piuparts-style install→purge leftover-scan. All version literals are `read-pin.sh`-derived. amd64 = full; arm64 defaults to `metadata` mode (`CONTRACT_MODE=full` forces the apt scenarios under QEMU). | | [`ci/daemon-smoke.sh`](ci/daemon-smoke.sh) | The **daemon smoke**. `daemon-smoke.sh ` installs system D-Bus + polkit + NetworkManager (bookworm 1.42.4) and the built MM debs, starts a system `dbus-daemon` + `ModemManager`, then asserts: `busctl introspect` shows the root `ObjectManager`; `mmcli --version` matches the **pinned** ModemManager upstream version (via `ci/read-pin.sh`, never hardcoded); the udev-rules + FCC-unlock dispatcher dirs exist; and — **functional GI validation**, not presence-only (it installs `python3-gi valac build-essential pkg-config`) — the `gir1.2-modemmanager-1.0` typelib **loads** through PyGObject (`gi.require_version('ModemManager','1.0')` + a real `ModemManager.ModemCapability.LTE` enum read) and the `libmm-glib` `.vapi` **compiles+links** via `valac -C` → `cc $(pkg-config --cflags --libs mm-glib)` against a Vala program that genuinely calls a libmm-glib symbol (a broken/absent GI-1.74 adaptation fails closed here). amd64 by default. | | [`ci/build-companion.sh`](ci/build-companion.sh) | Builds the first-party `ceralive-modem-support` companion `.deb` — ONCE, `Architecture: all`, into `build/all/`. Container by default (`debian:bookworm`), `--native` for the local QA loop. `RELEASE_VERSION=vX.Y.Z` → Version `X.Y.Z` (unset → `0.0.0~dev`), injected into a COPY of the changelog. Fails closed if the produced deb's `Architecture` is not `all` or its `Version` is not the requested one. | @@ -292,5 +293,6 @@ closed naming that source. The migration-continuity chain | [`ci/suffix-contract.sh`](ci/suffix-contract.sh) | SOURCED library — the `~ceralive` suffix contract in ONE place. `assert_group_coherence =…` groups packages by owning source (derived from `expected-packages.txt`, never a second frozen list) and asserts each source's OWN internal coherence, so differing counters ACROSS sources pass while a source disagreeing with itself fails closed naming it. `prove_chain_ordered ` runs the **migration-continuity chain** — `~ceralive0.2.0 < ~ceralive1.0.0 < ~ceralive1.1.0 < ~ceralive.1 < ~ceralive.2 < ~ceralive.10 < -` — through real `dpkg --compare-versions`; its legacy members are every version that exists as a published artifact today, so the chain is the proof that every fleet device upgrades cleanly into the counter scheme. Sourced by `test-package-contract.sh` (CHECK 5/6) and `test-suffix-coherence-manifest.sh` so the container lane and the host lane cannot prove different rules. | | [`ci/test-suffix-coherence-manifest.sh`](ci/test-suffix-coherence-manifest.sh) | The per-source-suffix + mixed-version-manifest contract — host-runnable, offline, **no docker**. Proves: sources at differing counters are accepted while an internally-mixed source fails closed naming itself; the full migration chain under real `dpkg` (with a non-vacuity control showing a lexical compare inverts `.2` vs `.10`); `generate-release-manifest.sh` over a MIXED staged set emits the new header, no `deb_version_suffix:`, and a row per staged deb at each source's own version; and a ZERO-UPSTREAM-BUILD set (only the companion fresh) keeps every upstream row at its carried counter with only the companion at the bare tag version. Every expected count is COUNTED from the fixture that produced it — no total is written down. | | [`ci/test-release-workflow-wiring.sh`](ci/test-release-workflow-wiring.sh) | The **static** proof that `release.yml`'s `build-deb` is wired the way the differential pipeline needs. It reads the workflow text and never dispatches a run: step presence, the ordering chain (previous-release resolution → detection → carry-forward staging → **every** executable `build-bookworm.sh` mention, comment lines excluded), `fetch-depth: 0` inside the `build-deb` slice specifically, and an awk detector for any `${{ }}` interpolated into a `run:` body. It exists because the invariant it guards fails SILENTLY — staging after the build still produces a green release, built against stock bookworm dependencies. `RELEASE_WORKFLOW_FILE` points it at a scratch copy for failure demonstrations, so the tracked workflow is never mutated. | +| [`ci/test-fm350-patch-contract.sh`](ci/test-fm350-patch-contract.sh) | Static regression gate for the hardware-required FM350 first-enable override: the carried patch must install `enabling_modem_init`, send `Z0`, and never regress that override to core `Z` (which firmware `81600.0000.00.19.17.10` rejects with CME 59). | | [`ci/resolve-tag.sh`](ci/resolve-tag.sh) | The **shared tag → peeled-commit-SHA resolver** used by `release.yml`. `resolve-tag.sh ` asks the remote (`git ls-remote`, no clone) for both `refs/tags/` and `refs/tags/^{}`, prefers the **peeled** commit SHA (an annotated tag otherwise resolves to its tag object), prints it, and fails closed if the tag is absent or ambiguous. ONE script, called from tag-guard (pin every checkout), publish-npm (last-instant pre-publish TOCTOU re-check), and create-release (pre-create re-check) — no divergent copies. A caller detects a moved tag by comparing the output against the pinned SHA. | | [`ci/reconcile-release-assets.sh`](ci/reconcile-release-assets.sh) | The **immutable, manifest-complete release-asset reconciler** used by `release.yml`'s `create-release`. `reconcile-release-assets.sh ` takes a flat dir of the raw built debs + the manifest and: verifies the deb set equals the manifest sha256-exactly (missing/extra/corrupt ⇒ fail closed); stages each asset under its **own sanitized basename** (`~` → `.`, never relying on GitHub's upload mapping) and rejects any name **collision**; creates the release if absent; then for each staged asset uploads it if MISSING or integrity-compares (download + sha256) if it already EXISTS — matching ⇒ skip (idempotent), differing ⇒ fail closed (published assets are never overwritten); and finally verifies the live asset set equals the staged set. `RECONCILE_RELEASE_DIR=` selects a local mock backend for standalone testing. | From 2440eba7ab24187db402f3d4150a3d26a10e95dd Mon Sep 17 00:00:00 2001 From: Andres Cera Date: Sun, 23 Aug 2026 19:45:00 -0500 Subject: [PATCH 12/12] fix(control): correct flock resource-ownership defect found during hardware hub-cycling --- AGENTS.md | 7 +++ control/README.md | 5 ++ .../src/safety/flock-resource-ownership.ts | 57 ++++++++----------- .../resource-ownership.integration.test.ts | 21 ++++++- 4 files changed, 57 insertions(+), 33 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 4f31642..2afb0ed 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -300,6 +300,13 @@ and relies on kernel lock lifetime plus PID liveness to recover after holder dea conventional caller-selected path is `DEFAULT_MODEM_CONTROL_LOCK_PATH`; the adapter itself has no hidden path and there is no no-op ownership implementation. +The lock holder is an external `/bin/cat` kept alive by a pipe round-trip after `flock` +acquires the inode. It deliberately does not re-execute `process.execPath`: in a Bun-compiled +CLI that path is the application binary, not an evaluator, so `-e` re-enters argument parsing +and falsely looks like lock contention. The integration suite pins the no-evaluator-argument +contract alongside real cross-process exclusion; the compiled CLI is covered by its arm64 and +amd64 build plus hardware smoke. + `createModemControlCompositionRoot()` fails if an ownership port is absent and throws `CompositionRootAlreadyExistsError` for a second live root in the same process. Within one root, `actorFor(PhysicalModemId)` returns the same `ModemActor` to every caller for that diff --git a/control/README.md b/control/README.md index fa584aa..a056463 100644 --- a/control/README.md +++ b/control/README.md @@ -207,6 +207,11 @@ holder PID/start-time metadata, and clean release when the holder process dies. is mandatory input; `DEFAULT_MODEM_CONTROL_LOCK_PATH` is only a conventional value callers may select. There is no pass-through ownership implementation. +The adapter holds the lock with an external `/bin/cat` whose pipe round-trip acknowledges +successful acquisition. It never launches `process.execPath -e`: a compiled Bun executable's +`process.execPath` points back to the application, so re-executing it would parse `-e` as an +application option and misreport the resulting exit as contention. + One `createModemControlCompositionRoot()` may be live per process. A second construction throws, and `actorFor(physicalModemId)` shares one actor for that modem across all callers in the root. `UhubctlPort` has no control-package implementation; an embedding process must diff --git a/control/src/safety/flock-resource-ownership.ts b/control/src/safety/flock-resource-ownership.ts index fe0fa05..aafcb4e 100644 --- a/control/src/safety/flock-resource-ownership.ts +++ b/control/src/safety/flock-resource-ownership.ts @@ -1,5 +1,5 @@ import { type ChildProcessWithoutNullStreams, spawn } from 'node:child_process'; -import { readFile } from 'node:fs/promises'; +import { readFile, writeFile } from 'node:fs/promises'; import type { ResourceOwnershipHolder, ResourceOwnershipLease, @@ -7,16 +7,6 @@ import type { ResourceOwnershipResult, } from '../ports'; -const LOCK_HELPER = String.raw` -import { writeFileSync } from 'node:fs'; -const lockPath = process.env.CERALIVE_MODEM_CONTROL_LOCK_PATH; -if (!lockPath) process.exit(64); -const holder = { pid: process.pid, startedAtEpochMs: Date.now() }; -writeFileSync(lockPath, JSON.stringify(holder) + '\n', { mode: 0o600 }); -process.stdout.write(JSON.stringify({ type: 'acquired', holder }) + '\n'); -process.stdin.resume(); -`; - export type FlockResourceOwnershipOptions = { readonly lockPath: string; readonly flockBinary?: string; @@ -41,25 +31,15 @@ export function createFlockResourceOwnershipPort( async acquire(): Promise { const child = spawn( options.flockBinary ?? 'flock', - [ - '--exclusive', - '--nonblock', - '--no-fork', - options.lockPath, - process.execPath, - '-e', - LOCK_HELPER, - ], - { - env: { - ...process.env, - CERALIVE_MODEM_CONTROL_LOCK_PATH: options.lockPath, - }, - stdio: ['pipe', 'pipe', 'pipe'], - }, + ['--exclusive', '--nonblock', '--no-fork', options.lockPath, '/bin/cat'], + { stdio: ['pipe', 'pipe', 'pipe'] }, ); + if (child.pid === undefined) { + throw new FlockResourceOwnershipError('helper started without a process id'); + } + const holder = { pid: child.pid, startedAtEpochMs: Date.now() }; const closed = childClosed(child); - const started = await childStarted(child); + const started = await childStarted(child, options.lockPath, holder); if (started.status === 'closed') { if (started.code === 1) { const holder = await readHolder(options.lockPath); @@ -106,8 +86,12 @@ function childClosed(child: ChildProcessWithoutNullStreams): Promise { return new Promise((resolve) => child.once('close', () => resolve())); } -function childStarted(child: ChildProcessWithoutNullStreams): Promise { - return new Promise((resolve) => { +function childStarted( + child: ChildProcessWithoutNullStreams, + lockPath: string, + holder: ResourceOwnershipHolder, +): Promise { + return new Promise((resolve, reject) => { let stdout = ''; let stderr = ''; let settled = false; @@ -123,10 +107,19 @@ function childStarted(child: ChildProcessWithoutNullStreams): Promise resolve({ status: 'acquired', holder }), + (error: unknown) => { + child.stdin.end(); + reject(error); + }, + ); }); child.once('close', (code) => finish({ status: 'closed', code, stderr: stderr.trim() })); + child.stdin.write(`${JSON.stringify({ type: 'acquired', holder })}\n`); }); } diff --git a/control/src/safety/resource-ownership.integration.test.ts b/control/src/safety/resource-ownership.integration.test.ts index 056171d..5c531f3 100644 --- a/control/src/safety/resource-ownership.integration.test.ts +++ b/control/src/safety/resource-ownership.integration.test.ts @@ -1,8 +1,9 @@ import { afterEach, describe, expect, test } from 'bun:test'; import { type ChildProcessWithoutNullStreams, spawn } from 'node:child_process'; -import { mkdtemp, rm } from 'node:fs/promises'; +import { chmod, mkdtemp, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; +import { createFlockResourceOwnershipPort } from './flock-resource-ownership'; type RootMessage = | { readonly type: 'acquired'; readonly holderPid: number } @@ -64,6 +65,24 @@ function nextMessage(child: ChildProcessWithoutNullStreams): Promise { + test('Given a flock wrapper that rejects evaluator arguments, When ownership is acquired, Then the adapter uses a passive holder', async () => { + const path = await lockPath(); + const fakeFlock = join(path, '..', 'fake-flock'); + await writeFile( + fakeFlock, + '#!/bin/sh\nfor argument in "$@"; do\n if [ "$argument" = "-e" ]; then exit 1; fi\ndone\ncat\n', + ); + await chmod(fakeFlock, 0o755); + + const result = await createFlockResourceOwnershipPort({ + lockPath: path, + flockBinary: fakeFlock, + }).acquire({ resource: 'usb-hub' }); + + expect(result.status).toBe('acquired'); + if (result.status === 'acquired') await result.lease.release(); + }); + test('Given two roots and one lock path, When both acquire, Then exactly one acquires and one refuses without queueing', async () => { const path = await lockPath(); const roots = [spawnRoot(path), spawnRoot(path)];