diff --git a/.github/workflows/apt-dispatch-preflight.yml b/.github/workflows/apt-dispatch-preflight.yml new file mode 100644 index 0000000..d4e6c00 --- /dev/null +++ b/.github/workflows/apt-dispatch-preflight.yml @@ -0,0 +1,74 @@ +name: APT dispatch preflight + +# Proves that THIS REPOSITORY can trigger apt-worker's closure publisher, without publishing +# anything. +# +# WHY IT LIVES HERE AND NOT IN AN OPERATOR'S TERMINAL +# An operator running `gh api repos/CERALIVE/apt-worker/dispatches` by hand tests THEIR OWN +# CLI token. It proves nothing about `secrets.CERALIVE_DISPATCH_TOKEN` on CERALIVE/modem-stack, +# which is the credential the release workflow will actually use. This job performs the real +# dispatch with the real repository secret, which is the only thing that answers the question. +# +# WHY IT IS SAFE AGAINST A NOT-YET-EXISTING RELEASE +# It sends `client_payload.preflight=true`. apt-worker's reindex-modem-closure.yml has a +# dedicated preflight branch that forces DRY_RUN regardless of its live default, and exits +# cleanly BEFORE manifest resolution when no tag is supplied — so preflighting ahead of the +# v1.1.0 release can never make the publisher error on an unpublished manifest. +# +# The default tag is therefore EMPTY (resolve-nothing). Supply an already-published tag to +# additionally exercise manifest resolution and validation in dry-run. + +on: + workflow_dispatch: + inputs: + tag: + description: 'An ALREADY-PUBLISHED modem-stack tag to dry-run against. Leave empty to test credentials + reachability only.' + required: false + default: '' + type: string + +permissions: + contents: read + +concurrency: + group: apt-dispatch-preflight + cancel-in-progress: false + +jobs: + preflight: + name: Dispatch a dry-run closure publish with the real repository secret + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Refuse a tag that does not exist as a release + if: inputs.tag != '' + env: + GH_TOKEN: ${{ github.token }} + RELEASE_TAG: ${{ inputs.tag }} + run: | + if ! gh release view "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then + echo "::error::'$RELEASE_TAG' is not a published release of $GITHUB_REPOSITORY." + echo "Preflight must target an EXISTING manifest, or run with an empty tag." + exit 1 + fi + echo "'$RELEASE_TAG' is published — its release-manifest.txt is resolvable." + + - name: Dispatch (preflight, forced dry-run) + env: + GH_TOKEN: ${{ secrets.CERALIVE_DISPATCH_TOKEN }} + RELEASE_TAG: ${{ inputs.tag }} + run: | + if [ -z "${GH_TOKEN:-}" ]; then + echo "::error::CERALIVE_DISPATCH_TOKEN is not provisioned on this repository." + echo "Provision a fine-grained PAT with **Contents: read/write** on CERALIVE/apt-worker." + echo "Note: repository-dispatch is gated on Contents, NOT on 'Actions: write'." + exit 1 + fi + gh api repos/CERALIVE/apt-worker/dispatches \ + -f event_type=apt-modem-closure \ + -F 'client_payload[preflight]=true' \ + -F "client_payload[tag]=$RELEASE_TAG" \ + -F 'client_payload[channel]=stable' + echo "preflight dispatch accepted (tag='${RELEASE_TAG:-}', forced dry-run)." + echo "Confirm the run at https://github.com/CERALIVE/apt-worker/actions/workflows/reindex-modem-closure.yml" diff --git a/.github/workflows/ci-bun.yml b/.github/workflows/ci-bun.yml index f2cfed9..a4fb24d 100644 --- a/.github/workflows/ci-bun.yml +++ b/.github/workflows/ci-bun.yml @@ -42,9 +42,12 @@ jobs: with: bun-version: 1.3.14 + # Node 26 is the CeraLive CI baseline AND the runtime the standalone consumer + # fixture asserts on: `scripts/verify-consumers.ts` refuses anything but 26.x, so + # a green fixture cannot come from an older Node that happened to be on PATH. - uses: actions/setup-node@v7 with: - node-version: 24 + node-version: "26" - name: Cache bun install store uses: actions/cache@v6 @@ -65,3 +68,7 @@ jobs: - name: Test run: bun test + + - name: Standalone consumer fixtures (Node 26 + Bun) against the packed tarball + working-directory: control + run: bun run verify:consumers diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0698cce..72d67c3 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -121,7 +121,7 @@ jobs: - uses: actions/setup-node@v7 with: - node-version: 24 + node-version: "26" - name: Cache bun install store uses: actions/cache@v6 @@ -198,6 +198,22 @@ jobs: - name: Daemon smoke (amd64) run: packaging/ci/daemon-smoke.sh amd64 + # The first-party companion is Architecture: all, so it is built EXACTLY ONCE into + # packaging/build/all/. Building it per-arch would produce two byte-different files + # claiming the same package/version key, which the APT publisher's immutable-key rule + # would (correctly) refuse. It shares nothing with the four zero-patch upstream sources. + - name: "Build the first-party companion .deb (Architecture: all)" + env: + RELEASE_VERSION: ${{ github.event.inputs.tag }} + run: packaging/ci/build-companion.sh + + # Chroot-stage packaging contract for the companion: install / upgrade / downgrade / + # purge, /etc override precedence, the chroot guard, both /etc-override maintscript + # branches, the absent-policy no-op and single-owner. The consumer stage (udevadm test, + # usb_modeswitch -c, unit ordering against a real boot) is bench-gated, not run here. + - name: Companion package contract (clean Debian chroot) + run: packaging/ci/test-companion-chroot.sh + # Manifest-complete per-release manifest: a checksum row for EVERY built deb (both arches), # runtime closure marked; fails closed if the produced set != the frozen all-artifact sets. - name: Generate release manifest @@ -215,6 +231,7 @@ jobs: dist/release-manifest.txt packaging/build/amd64/*.deb packaging/build/arm64/*.deb + packaging/build/all/*.deb if-no-files-found: error publish-npm: @@ -247,7 +264,7 @@ jobs: - uses: actions/setup-node@v7 with: - node-version: 24 + node-version: "26" registry-url: https://registry.npmjs.org/ # OIDC trusted publishing needs npm >= 11.5.1 (house pattern pins 11.18.0). @@ -425,9 +442,22 @@ jobs: # Assemble a FLAT dir of the raw assets (every deb + the manifest). Sanitization, # collision detection, immutable reconciliation, and the final manifest-complete verify # all live in the shared reconcile-release-assets.sh (never inlined here). + # DEDUP/COLLISION RULE for the flat assembly. `find ... -exec cp` flattens by basename, + # so two files sharing one basename would silently overwrite each other and the release + # would ship whichever landed last. The Architecture: all companion is built once, so a + # duplicate basename here means a real defect (a per-arch companion build, or a genuine + # name clash) — it is REFUSED rather than flattened. reconcile-release-assets.sh performs + # its own sanitized-name collision check downstream; this one catches the raw-name case + # before any sanitization can mask it. - name: Assemble flat assets dir (all debs + manifest) run: | mkdir -p staged-assets + dupes="$(find artifact -name '*.deb' -type f -printf '%f\n' | sort | uniq -d)" + if [ -n "$dupes" ]; then + echo "::error::duplicate .deb basenames in the artifact — refusing to flatten:" + printf ' %s\n' $dupes + exit 1 + fi find artifact -name '*.deb' -type f -exec cp {} staged-assets/ \; manifest="$(find artifact -name release-manifest.txt -type f | head -n1)" if [ -z "$manifest" ]; then @@ -442,3 +472,30 @@ jobs: RELEASE_TAG: ${{ github.event.inputs.tag }} GH_TOKEN: ${{ github.token }} run: bash packaging/ci/reconcile-release-assets.sh "$RELEASE_TAG" staged-assets + + # RELEASE -> APT PUBLICATION TRIGGER (chosen path: automatic repository-dispatch). + # apt-worker's reindex-modem-closure.yml treats an `apt-modem-closure` dispatch as a LIVE + # publish, so this step runs LAST — only after the release assets have been reconciled and + # the manifest the publisher will fetch actually exists on the release. The documented + # fallback is a guarded manual run: + # gh workflow run reindex-modem-closure.yml --repo CERALIVE/apt-worker \ + # -f tag= -f channel=stable -f dry_run=false -f live_confirm=I-VERIFIED-LIVE + # + # CERALIVE_DISPATCH_TOKEN must be a fine-grained PAT with **Contents: read/write** on + # CERALIVE/apt-worker. GitHub's repository-dispatch endpoint is gated on Contents, NOT on + # `Actions: write` — a token scoped the way the older runbooks describe returns 403. + - name: Dispatch the APT closure publish to apt-worker + env: + GH_TOKEN: ${{ secrets.CERALIVE_DISPATCH_TOKEN }} + RELEASE_TAG: ${{ github.event.inputs.tag }} + run: | + if [ -z "${GH_TOKEN:-}" ]; then + echo "::error::CERALIVE_DISPATCH_TOKEN is not provisioned on this repository — cannot trigger the APT publish." + echo "Provision it (fine-grained PAT, Contents: read/write on CERALIVE/apt-worker) or run the documented manual fallback." + exit 1 + fi + gh api repos/CERALIVE/apt-worker/dispatches \ + -f event_type=apt-modem-closure \ + -F "client_payload[tag]=$RELEASE_TAG" \ + -F 'client_payload[channel]=stable' + echo "dispatched apt-modem-closure for $RELEASE_TAG" diff --git a/AGENTS.md b/AGENTS.md index 9ec48a9..16e5f05 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -12,12 +12,46 @@ Canonical branch: `main`. Sole remote: `origin` → `https://github.com/CERALIVE | Directory | Artifact | Role | |-----------|----------|------| -| `control/` | `@ceralive/modem-control` (npm) | TypeScript control library — domain model, ModemManager D-Bus backend, NetworkManager adapter, desired-state reconciler, recovery ladder + the `usb-hub-port-cycle` **uhubctl PowerHook** (see § below), USB composition-mode model + evidence-bundle **ingestion seam**, data-usage sampler + the **usage-policy write surface** (see § below). Published to public npm under `@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 | Bookworm rebuilds of ModemManager + libmbim + libqmi + libqrtr-glib — packaging only, zero source patches (see `POLICY.md`). Bench installs from CI artifacts. | +| `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. | `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, +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 +rules consume build-time board facts a generic package cannot know. Do not move them here. + +**The udev basename is load-bearing.** udev resolves rules by BASENAME, and an +`/etc/udev/rules.d` file SHADOWS a same-basename `/usr/lib/udev/rules.d` file completely +while `dpkg -S` keeps naming the package as owner of the `/usr/lib` path — the substitution +is invisible to package tooling. The companion therefore uses the modem-only basename +`60-ceralive-modem.rules`, which no image-owned `/etc` file shares (the image owns +`99-ceralive-hardware.rules` and `78-mm-ceralive-slot-uid.rules`). Never rename it onto an +image-owned basename, and never ship an `/etc` copy from this package. A stale same-basename +`/etc` override is removed on upgrade ONLY when marker AND sha256 identify a known generated +payload; anything unknown or operator-modified is preserved. + +QA is two-stage and the split is deliberate: `packaging/ci/test-companion-chroot.sh` proves +packaging SHAPE in a clean `debian:trixie` container, while the consumer proofs (`udevadm +test` against a real modem, `usb_modeswitch -c`, unit ordering against a real boot journal) +are BENCH-GATED and must not be faked in a container. Full detail: `packaging/README.md`. + +The release manifest emits **`closure_version: 2`**, which adds this one `Architecture: all` +asset to the frozen 9 × 2 closure as a single row with `build_arch` `all`. It is ONE +immutable release asset with TWO index memberships; apt-worker's publisher indexes it into +both per-arch indexes. A per-arch build is forbidden — two byte-different files under one +package/version key break the immutable-key rule. + ## RULE D — SELF-CONTAINED (load-bearing) **This repo builds, tests, and releases standalone in CI. The CeraLive workspace parent @@ -47,6 +81,144 @@ npm and the `.deb` set. `.deb` versions encode the tag as `-~cera (upstream-ordered, apt-safe; injected with `dch --force-bad-version`). Non-tag CI builds use `~ceralive0.0.0~dev`. Full contract: `docs/VERSIONING.md`. +## FROZEN V1.1 DOMAIN CONTRACTS + +`control/src/domain/` additively freezes the provider-neutral v1.1 foundation while the +published v1.0 package facade remains intact. The exact public shapes and safety rules are +documented in [`docs/DOMAIN-CONTRACTS.md`](docs/DOMAIN-CONTRACTS.md). + +- `PhysicalModemId` / `StableKey` use serial → udev `ID_PATH` → a 128-character-bounded + fallback. Their constructors refuse MM object paths, interface names, IP addresses, IMEI, + and subscriber identifiers; none of those runtime or sensitive values can become the new + physical identity. +- `DeviceGeneration` increments on re-enumeration or provider replacement and fences every + async observation/operation completion. `ObservationEnvelope` separately models fresh, + stale, and unavailable data; unavailable carries `value: null`, never an invented value. +- `OperationDescriptor` keeps read and write support independent and records authority, + constraints, preconditions, availability, mutation impact, retry policy, transactional + requirements, evidence, and confidence. `OperationResult` maps stale completions and + timed-out/dropped writes to `unknown-outcome` with mandatory reconciliation. Only explicitly + classified idempotent reads may auto-retry. + +This layer is pure data and functions: no daemon, socket, network endpoint, or CeraUI import. + +## PROVIDER REGISTRY + EVIDENCE MATCHER + +`control/src/providers/` is the provider-neutral registration and selection layer built on the +frozen v1.1 domain contracts. The public contract and scoring details are documented in +[`docs/PROVIDER-MATCHING.md`](docs/PROVIDER-MATCHING.md). + +- `ProviderDefinition` keeps each provider's profile version, declarative passive matchers, + harmless unauthenticated probes, optional single owner-selected authentication algorithm, + normalized `ObservationEnvelope` producer, provider-specific operations object and sanitized + contract fixtures together. `ProviderReadOperations` and `ProviderWriteOperations` are + composable capability subinterfaces; there is no package-wide vendor mega-interface. +- `createProviderMatcher` evaluates every registered provider in stage order: transport → passive + facts → unauthenticated fingerprints → profile rank → at most one auth call → capability reads. + Evidence is scored `unsupported → maybe → likely → supported`; only a unique `supported` + candidate receives its operations object. +- Ties and weak candidates return `ambiguous`, with `provider`, `profile` and `operations` all + `null`, `writable: false`, and the complete evidence/conflict ledger retained. Authentication is + not attempted for tied candidates, so an ambiguous match cannot cycle algorithms or acquire a + write surface. +- Selection is cached only for the same `PhysicalModemId`, `DeviceGeneration`, registry revision, + firmware and composition. Any generation, firmware or composition change runs the full matcher + again. + +This layer registers no concrete provider. Huawei, ZTE, UFI/HIMI and other implementations remain +separate evidence-backed work. + +### CONFORMANCE MATRIX — ALL FOUR PROVIDERS REGISTERED AT ONCE + +`control/src/providers/conformance-matrix.test.ts` is where todo 5's matcher meets every real +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, +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". + +- **The corpus is repo-local and unpublished** (`control/test-support/conformance/`) and REUSES + `observation-fixtures.ts` rather than minting a second payload set that can drift from it. + Sanitization is structural, not a review promise: a 14+ digit run anywhere in the corpus or in + any recorded request FAILS the suite unless it is a declared member of + `SANITIZED_SUBSCRIBER_IDENTIFIERS`, and the detector has a non-vacuity control. +- **`conformance-transcripts.test.ts` asserts the exact wire** per firmware — method, path, + query, form/JSON/XML body, the header ARRAY in order, and the cookie — rebuilt from the + protocol, never read back from the provider. A whole-array `toEqual` pins the request COUNT + too, so an extra login or a stray probe fails even when the decision is unchanged. +- **`conformance-scale.test.ts` is a SOFTWARE UPPER BOUND at 16 concurrently attached modems.** + It is a FIXTURE result: subscriptions stay fleet-wide (4, never per-modem), `Signal.Setup` is + issued once per (epoch, modem) and re-applied to every survivor on a new epoch, an attachment + burst is coalesced, and sixteen concurrent matches each answer about their own modem. **The + hardware-verified figure remains the 8-device bench fleet** — 16 must never be reported as a + bench measurement; a hardware claim comes from todo 42, on a real board. +- **`test-support/conformance/mm-transport.ts` is an in-memory `DbusTransport`** serving the SAME + `fake-mm/object-model.ts` tree, so MM rows run without `dbus-run-session`. `fake-mm/service.ts` + stays the right harness for codec/epoch proof on a real bus; a matrix whose MM rows SKIP where + no session bus exists answers nothing, which is why the matrix uses this one. +- **The UFI fingerprint probe is fenced by USB evidence** (`usbEvidencePermitsProbe`) — this + matrix is what found it. The HIMI fingerprint needs a session, so an unfenced probe spent the + provider's single bounded login against every non-HIMI device in the registry. An ABSENT usb + fact is still probed; a MISMATCHING one is not. + +## PUBLISHED PACKAGE SURFACE — BUILT ESM, SEVEN SUBPATHS, NO SERVICE + +`@ceralive/modem-control` publishes **built output**: `files: ["dist"]`, and every +`exports` target resolves under `./dist/`. It shipped raw TypeScript through `v1.0.0` +(`exports` → `./src/index.ts`, `files: ["src"]`); that is gone. The public surface is +seven specifiers and no more — `.`, `./transport`, `./domain`, `./providers`, +`./capabilities`, `./hardware`, `./testing`. Full consumer-facing detail: +[`control/README.md`](control/README.md). + +- **`control/scripts/entries.ts` is the single source of truth.** The build, the + exports map and the shape gate all read it, so they cannot drift. Adding a row is a + deliberate, permanent widening of the public API; internal barrels (`src/backend`, + `src/ports`, `src/sms`, `src/ussd`, `src/location`, `src/fcc`, `src/redact`) are + reachable only through the root entry and stay unexported on purpose. +- **`./capabilities` maps to `src/capability/` and `./hardware` to `src/band` + + `src/usb-mode`.** The specifier is the contract; the directory layout behind it is not. +- **`./testing` is the PUBLIC contract-fakes surface, and `control/test-support/` is + not.** The fakes are pure data and functions built through the package's own + constructors and classifiers — `fakeOperationResult` routes through the real + `classifyOperationCompletion`, so a consumer's fixture cannot drift from what the + package returns, and `fakeUnavailableObservation` has no overload that could invent a + value. `test-support/` keeps this repo's heavy internals (the MM-faithful fake D-Bus + service on a private session bus, the stateful `nmcli` harness); it lives outside + `src`, is unpublished, and must not become a subpath. +- **`dist/` is a 1:1 `tsc` emit, deliberately NOT a bundle.** `Bun.build --splitting` + emitted an entry whose `export { … }` list named symbols it never imported — Bun's + loader accepts it, Node answers `SyntaxError: Export 'BigIntRequiredError' is not + defined in module`. Bundling without splitting instead gives each subpath its own copy + of the shared modules, which breaks `instanceof DomainError` across two subpaths of + one package. The 1:1 emit has one instance of every module. +- **`scripts/build.ts` rewrites every emitted relative specifier** into `./x.js` or + `./x/index.js`, resolved against the emit itself, because the sources are written for + `moduleResolution: bundler` and `tsc` never rewrites a specifier. The build FAILS if + one extensionless specifier survives. `prepack` runs the build, so no pack can publish + a stale `dist/`. +- **The repo-root `tsconfig.json` `paths` map `@ceralive/modem-control*` back to + `control/src`.** This is DEV-ONLY and load-bearing: without it the workspace `cli` + resolves the package through its exports map to `dist` while `control/test-support` + resolves the same modules relatively, and `Brand`'s `unique symbol` turns every + branded value crossing between them into a hard type error. It also means the + workspace never needs `dist` to exist in order to typecheck or test. The published + package is unaffected — consumers still resolve to `dist`. +- **The built artifact is proven by things that ignore that mapping.** + `control/scripts/tarball-shape.test.ts` packs with `bun pm pack` and runs six rules + over the extracted tarball (no raw source / built output present / every declared + entry exported AND packed / no undeclared subpath / nothing pointing outside `./dist/` + / **no `bin`, systemd unit, shebang or listening-socket construct** — the library-only + proof). Every detector has a non-vacuity test that trips it with a synthetic artifact. + `control/fixtures/` then holds two STANDALONE consumer projects — one Node, one Bun — + which `bun run verify:consumers` installs the real `.tgz` into and imports all seven + specifiers from. The Node fixture refuses to run on anything but Node 26.x, so a green + result cannot come from an older Node on `PATH`. +- **Removing `./testing` cannot pass the gate.** It is a row in `entries.ts` AND a + literal in the shape test's `EXPECTED_SUBPATHS`, so dropping it from `package.json` + fails four tests and dropping it from `entries.ts` too fails a fifth. + ## PROVENANCE PINS (packaging) The four rebuilt sources are pinned in `packaging/upstream-pins.yaml`, re-verified end-to-end @@ -77,33 +249,142 @@ arch-dependent stanzas + enumerated `-dbgsym`) for exact per-source set **equali `packaging/ci/check-package-sets.sh` (add/remove/rename fails closed). Full detail: `packaging/README.md`. -## RECOVERY LADDER — uhubctl POWER HOOK (rung 4) - -`control/src/backend/uhubctl-power-hook.ts` (`createUhubctlPowerHook`) is the first real -`PowerHook` implementation: the `usb-hub-port-cycle` capability backing recovery-ladder -rung 4. It cuts VBUS on one port of a per-port-power-switching (PPPS) USB hub via `uhubctl` -and reports `applied` only once the SAME modem (by udev `ID_PATH`) is observed back on the -bus — a zero exit from `uhubctl` is never treated as success on its own. - -- **Config-mapped, never discovered.** A stable key is cyclable only if an operator wrote - it into an explicit, Zod-validated port-map file: `{ [stableKey]: { hubLocation: string, - port: number } }`. There is no default path and no probing/guessing — `hubLocation` is - regex-pinned to the sysfs bus-port shape so a shell-metacharacter or flag cannot parse - into it. -- **Argv-only, allowlisted, no shell.** The command is built as an argv array - (`['-l', loc, '-p', port, '-a', 'cycle', '-d', '3']`) and every emitted token is - re-checked against an allowlist before the injected runner is called. -- **Bounded + cancellable** (`commandTimeoutMs` + `enumerationTimeoutMs`, plus an optional - `AbortSignal`); **serialised per modem** through the shared `ModemActor`, keyed on the - stable key, so two overlapping cycles on one port cannot interleave power-on/power-off. -- **Disabled by default**, matching the existing recovery-ladder default - (`RECOVERY_DISABLED: { enabled: false }` in `control/src/domain/policy.ts`) — this hook - does not change that default; it is only reachable when an operator opts in. -- The HIL harness (`cli hil-cycle --hub-map `, bench runbook - [`docs/BENCH.md` RB-10](docs/BENCH.md)) orchestrates a full cycle end-to-end: pre-state - capture → PowerHook cycle → USB-disappearance assertion → re-enumeration assertion → MM - re-detection of the same `modem.generic.device` slot UID. See `cli/README.md` for the - exact CLI contract and typed failure reasons. +## MUTATION ADMISSION + EXCLUSIVE OWNERSHIP + +`control/src/ports/mutation-admission.ts` defines `MutationAdmissionPort`. It is an injected +authority only: the package submits an operation id, physical modem id, mutation impact and +the descriptor's frozen `admission` requirement, then preserves the port's typed decision. +It contains no stream state or stream policy. A required admission with no injected port is +`{ status: 'refused', reason: 'admission-port-missing' }`, never an allow-all fallback. + +`control/src/ports/resource-ownership.ts` defines the acquire-or-refuse +`ResourceOwnershipPort` used for file stores, router sessions and USB-hub access. The Linux +default adapter is `createFlockResourceOwnershipPort()` in `control/src/safety/`: it requires +an injected path, uses non-blocking `flock`, records the actual holder PID and start time, +and relies on kernel lock lifetime plus PID liveness to recover after holder death. The +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. + +`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 +physical modem. `ModemManagerInhibitPort` is the narrow MM inhibit/uninhibit contract used by +maintenance transactions. `UhubctlPort` is port-only in the control package: v1.1 ships no +provider, runner, argv builder or concrete `uhubctl` call. The bench CLI owns its existing +HIL-only adapter and acquires the same exclusive lock before using it. + +## DESCRIPTOR-GATED OPERATION ENGINE + +`control/src/operations/operation-engine.ts` executes the frozen `OperationDescriptor` and +`OperationResult` contracts. It takes a `ModemControlCompositionRoot`, so every mutation uses +todo 19's shared actor for its `PhysicalModemId`; live preconditions and admission are checked +inside that actor immediately before execution, never cached before queueing. Reads bypass the +write queue and only failed `idempotent-read` descriptors receive one automatic retry. + +The behavioral uncertainty fence is a per-engine `Set`. A stale-generation +completion or timed-out/dropped write classifies `unknown-outcome` and inserts the modem into +that set. Every later mutation checks the set before calling admission or provider code and is +refused `reconciliation-required`. `OperationEngine.reconcile()` runs on the same actor and +removes the modem only after a successful reconciliation whose generation stayed current. +Required readback, rollback and journal hooks are checked before execution; rollback runs only +after a definite failure/readback mismatch, never after an unknown outcome. Public hook and +reconciliation types are in `control/src/operations/contracts.ts`; the module is exported through +the existing package root and adds no package subpath. + +## TRANSACTION JOURNAL — PATH-PARAMETERIZED, APPEND-ONLY, NEVER SELF-TRUNCATING + +`control/src/journal/` is the durable half of the uncertainty fence above. The operation +engine keeps "which modems need reconciling" in a `Set` on the engine +instance, so a process death drops it and the next mutation proceeds as if nothing were +outstanding. The journal writes the same two facts down. It is exported through the +existing package ROOT entry and adds **no** package subpath (todo 17/18 precedent). + +**THE PATH IS INJECTED AND HAS NO DEFAULT — this package never names `/data`.** +`createFileJournalStore({ path })` REQUIRES the path and substitutes nothing; an empty +path throws `JournalPathError` rather than falling back. Same shape and same reason as +todo 19's `FlockResourceOwnershipOptions.lockPath`: an embedding process owns its +filesystem layout, and a library that guesses one writes to the wrong disk on a device it +has never seen. `journal-path-injection.test.ts` scans this directory's shipped source — +**comments stripped** — for absolute path literals and for `/data` / CeraUI-specific +location tokens, and fails the build on either. Prose naming a path stays legal (the +compat reader has to be able to explain the shape it reads); executable code producing one +does not. The strip is proven non-vacuous both ways. + +**Three durability properties, each pinned by a test that goes red when removed:** + +- **Append-only.** There is no verb that rewrites or truncates. A rewrite is the one + operation that can lose an already-durable fact, and a journal that can lose a fact + answers nothing after a crash. +- **A damaged record never discards its neighbours.** `read()` decodes every line + independently and returns the survivors ALONGSIDE a typed `JournalDamageRecord[]`. + Stopping at the first bad line — what a `for` loop with a throw does naturally — + silently truncates the journal to its first corruption. Breaking this reddens 9 tests. +- **A torn trailing line is closed before the next append.** A process killed mid-write + leaves a final line with no terminator; appending straight onto it would glue the new + entry to the garbage and corrupt a SECOND record that was never in flight. The store + probes the last byte once and emits a leading terminator when needed, so damage stays + confined to the record that actually tore. The damaged bytes are PRESERVED, never + rewritten away (the `fcc/policy-store.ts` fail-safe stance, not the usage store's + rewrite-fresh one). + +**The typed recovery error is `JournalRecoveryError`, raised by `assertJournalIntact`, and +it is deliberately NOT raised by `recover()`.** Recovery must be able to hand back the +survivors even when part of the file is unreadable, so the decision to refuse to proceed +belongs to the caller — after it has seen what did survive. + +**Four dispositions, and only two of them mean "reconcile".** `pending` (a start with no +completion) and `unknown-outcome` (the engine's own classification) both populate +`reconciliationRequired`; `resolved` is a definite ending; `blocked` is a terminal state a +human must clear. `blocked` exists for the compat reader below — folding CeraUI's +`failed`/quarantine states into `resolved` would report an operator-blocked device as +healthy, and folding them into `unknown-outcome` would claim doubt about a known outcome. + +**Neither the operation's INPUT nor its RETURNED VALUE is ever written to disk.** An input +is routinely a PIN, a PUK, or a USSD command carrying a voucher code; a returned value is +routinely a message body or a location fix — every one of them a class `redact.ts` masks +elsewhere. The journal records THAT an operation ran and HOW it ended, never what was sent +or read, and a test greps the written file for both. A caller needing a rollback payload +owns persisting it under its own redaction decision. + +### CeraUI compatibility — a READER, because the two shapes genuinely differ + +`journal/legacy-ceraui.ts` reads the mutation journal CeraUI already writes. The shapes are +not interchangeable and neither is being re-labelled: this package's journal is an +append-only EVENT LOG in one file, CeraUI's is a directory of per-modem LATEST-STATE +snapshot documents, rewritten whole on every transition. The bridge decodes CeraUI's shape +into the SAME `JournalOperationRecord` model, so a consumer enumerates pending and +unknown-outcome work across both **without CeraUI having to change its file format first**. + +- **Nothing here writes.** No rewrite, no repair, no in-place migration, no delete. + CeraUI's own reader leaves an unreadable slot on disk deliberately; a second reader that + tidied up behind it would destroy evidence CeraUI kept on purpose. +- **The directory is injected**, exactly like the native store's path. +- **`armed` maps to `pending`, `executing` maps to `unknown-outcome`.** They are different + facts: `armed` says the pre-state was captured and the write never dispatched, so the + device is untouched; `executing` says it WAS dispatched and no terminal state was + recorded — precisely this package's unknown outcome. Collapsing them either invents + certainty about a dispatched write or manufactures doubt about one that never left. +- **A legacy `unknown-outcome` record carries NO outcome reason.** `JournalOutcome`'s + unknown union is the frozen domain vocabulary and CeraUI's `executing` asserts none of + its three members. The disposition carries the fact; the reason stays unclaimed. +- **`kind` is validated as a non-empty string, NOT against a frozen enum.** CeraUI spreads + its capability-module mutation kinds into the runtime enum, so the vocabulary grows on + CeraUI's release cycle — a frozen copy here would reject a valid file the day a module is + added, and a compatibility reader that fails closed on new-but-valid input is worse than + none. +- **`JournalOperationRecord.physicalModemId` is TEXT, and `origin` says which vocabulary it + holds.** A legacy record carries CeraUI's `stableKey`, which `physicalModemId()` REFUSES + by construction; coercing it would either throw on a valid legacy file or launder a + foreign identity into a branded type that promises the serial/ID_PATH ladder. +- `legacyMutationSlotName(stableKey)` mirrors CeraUI's `.json` slot naming so a + consumer can address one modem without scanning. **Rule-D MIRROR, never a shared + import** — the same relationship the support-claim ladder and the redaction key sets + already have with their CeraUI twins. + +Coverage: `journal/journal-replay.test.ts` (write N, drop the engine with no shutdown, +reconstruct from disk, assert the pending/unknown enumeration), `journal-corruption.test.ts` +(trailing, mid-file, torn, wrong-version and missing-field damage), `legacy-ceraui.test.ts`, +and `journal-path-injection.test.ts`. Fixtures: `control/test-support/journal-fixture.ts`. ## DATA-USAGE POLICY — A LOCAL WRITE, BECAUSE MODEMMANAGER HAS NO SUCH API @@ -176,23 +457,820 @@ transform in `control/src/usb-mode/{ingestion,promotion-review,usb-devices-parse - Per-SKU capture runbooks are `docs/BENCH.md` **RB-11 … RB-15** (RB-16 is the FM350 USB-vs-PCIe probe — its 2026-08-16 bench run found the unit not connected, so `docs/FM350-DECISION.md`'s three-gate ledger stays OPEN with the probe evidence recorded; - RB-17 is modem-flap resilience). All are `[PARTIAL]` — four named blockers - (`usbutils` absent from the board and its archive; the enumerator not populating `ifname`; - no AT transport on the bench; an empty real-SKU catalog) are recorded in `docs/BENCH.md` - § "Per-SKU certification". No SKU is certified and no matrix row is promoted. + RB-17 is modem-flap resilience). All are `[PARTIAL]` — **six** named blockers are recorded + in `docs/BENCH.md` § "Per-SKU certification", re-verified live on 2026-08-18: + **B1 CLEARED** (`usbutils` is now on the board), **B3 downgraded** (`socat` is present, so + a manual query-only AT session works; the CLI's `benchAtSender` still rejects every send), + and **B2 promoted to hardware-proven** — `certify` matches its device by `ifname`, which + the enumerator never populates, so **every real bundle comes out with no `sku` and empty + `udevProperties`** and the ingestion seam refuses it `sku-missing`. Two blockers are new: + **B5**, the shared redactor does not mask `imei` / `equipment-identifier`, so a real + bundle carries every bench modem's IMEI and must not be committed or pasted into a review + comment; and **B6**, `skuOf` reads `firmwarePrefix` from udev `ID_REVISION`, which is the + USB `bcdDevice` rather than the modem firmware revision, so a catalog entry built from a + capture would not actually be firmware-keyed. B2 and B6 are pinned by + `control/src/usb-mode/ingestion.hardware.test.ts`. No SKU is certified and no matrix row + is promoted. +- **Bench composition evidence** for the SIMCom SIM7600G-H and the carrier-mounted Fibocom + FM350-GL is recorded in [`docs/COMPOSITION-EVIDENCE.md`](docs/COMPOSITION-EVIDENCE.md) — + descriptors, driver bindings, firmware revisions, and the read-back state of each vendor's + USB-mode command (`AT+CUSBPIDSWITCH`, `AT+GTUSBMODE`), captured **non-mutatingly** + (bare-execute / READ `?` / TEST `=?` forms only; no SET form was ever sent). It certifies + nothing: the SIMCom's PID→composition mapping is unproven so its target modes stay + UNCERTIFIED and HIDDEN, and the FM350 gains **no** classifier entry for its `0e8d:7127` + carrier id — `docs/FM350-DECISION.md` is unchanged. - The full bench-runbook ladder, RB-1 through RB-17, lives in `docs/BENCH.md`: RB-9 is the fleet-inventory capture (one identity bundle per acquired physical unit), RB-10 is the 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. -## eSIM (investigate-only, implementation deferred) +## CAPABILITY MODULES — TAXONOMY AND DETECTION, NOT IMPLEMENTATION + +`control/src/capability/` carries the FIVE-STATE support-claim taxonomy and the +per-modem capability detection the seven gated capability modules (band-lock, SMS, +5G-pref, FCC-auto-unlock, GPS, USSD, eSIM) resolve against. **No module is +implemented here**, and none may be surfaced or claimed until its own change lands +with its probe and its evidence. + +The taxonomy exists because "supported" was one word doing four jobs — the code +exists, an operator turned it on, the modem advertises it, and somebody proved it +on this firmware. `resolveSupportClaim` answers with the highest rung reached: +`unavailable` (not shipped, or the modem positively lacks it) → `implemented` +(gate OFF, the default everywhere) → `enabled` (gate ON, capability UNKNOWN) → +`capable` (gate ON, modem advertises it — the floor for offering a control) → +`certified` (proven on this exact model+firmware — the ONLY rung a support matrix +may claim). + +**It is a Rule-D MIRROR of CeraUI's `@ceraui/rpc` ladder, never a shared import** +— the same relationship `usb-net-classifier.ts` has with `device-classifier.ts` in +the other direction. The two halves are kept honest by their tests, not by a path. + +`detect.ts` follows `backend/features.ts` exactly: it PROBES the observed surface +rather than matching a version whitelist, never throws, and treats `unknown` as a +first-class result — a property set nobody observed says nothing about the device, +and the ladder stops at `enabled` for it. Two facts are worth knowing before +extending it: + +- **SMS / USSD / GPS are advertised as INTERFACES, not properties**, so + `MmPropertyProbe` alone cannot see them; `ModuleCapabilityProbe` adds the modem + object's interface set. The `Location` interface being exported is separately NOT + a GNSS claim — MM exports it for 3GPP-LAC/CID-only devices too, so a GNSS source + must appear in `Location.Capabilities`. +- **`fcc-auto-unlock` is always `unknown`, deliberately.** FCC unlock is carried out + by a ModemManager PLUGIN keyed on the device, and nothing on the modem's own + D-Bus surface says whether one applies. `absent` would hide the module on + supported hardware; `present` would promise a plugin that may not be installed. + Evidence for it comes from the catalog instead — `control/src/fcc/coverage.ts`, + see the section below. + +### `five-g-pref` — a MODEL, not just a probe [IMPLEMENTED, UNCERTIFIED] + +`control/src/capability/five-g-preference.ts` is pure and total: it maps four +named postures (`5g-only` / `prefer-5g` / `prefer-4g` / `5g-off`) onto the +`(allowed, preferred)` pair `MmMutations.setRadioModes` already writes, and +refuses to name one the modem never advertised. It opens NO new transport — +`setRadioModes` owns the `SetCurrentModes` call, the per-modem serialization and +the quiesce — and a test greps its executable source to keep it that way. + +**Why it exists at all, given `setRadioModes` already writes modes.** That +surface's vocabulary is the ALLOWED SET, and two genuinely different postures +share one: "allow 4G and 5G, prefer 5G" and "allow 4G and 5G, prefer 4G" differ +only in the PREFERRED mode. An operator on a marginal 5G cell wants exactly that +distinction, and an allowed-set selector structurally cannot express it. +`prefer-5g` and `prefer-4g` therefore emit an IDENTICAL allowed set — so nothing +on this path may decide "no write is needed" by diffing allowed sets, and nothing +may confirm a restore by comparing them either. + +Three refusals are load-bearing: + +- **A modem with no 5G is offered NOTHING, `5g-off` included.** "Turn 5G off" on a + radio that has none is a control that cannot change anything, which is worse + than an absent one because it invites an operator to act. +- **A posture the modem cannot express resolves `undefined`, never a neighbour.** + Substituting is how "prefer 4G" on a marginal cell silently becomes 5G-first. +- **A current pair no posture names reads `undefined`, never the nearest one.** + Rounding would show an operator a selection they never made and cannot get back to. + +**SA/NSA is REPORTED unsupported, with a reason.** Checked against MM 1.24.2's own +surface rather than recalled: the only NR-specific member on a modem object is +`Modem3gpp.SetNr5gRegistrationSettings`, whose keys are `mico-mode` and +`drx-cycle` — power-saving registration parameters, not a standalone-vs +non-standalone selector. Vendors expose the selector through their own AT commands +(Quectel `AT+QNWPREFCFG="mode_pref"`, one per vendor after that), which is exactly +the uncertified per-SKU write the evidence gate keeps out. A missing field would +read as "nobody asked"; the stated `not-exposed-by-modemmanager` tells an operator +hunting for an SA toggle why there is none. + +**`detect.ts`'s `five-g-pref` verdict NARROWS when the caller decoded the mode +catalog.** Every ModemManager modem exports `SupportedModes`, including a 4G-only +one, so the property NAME alone resolves `present` on hardware with no 5G. The +optional `ModuleCapabilityProbe.supportedRats` narrows the verdict to whether the +catalog actually names 5GNR; ABSENT it, the property-name answer stands verbatim. +A strict narrowing, never a new way to claim a capability. + +**Status: `implemented-but-uncertified`.** No 5G SIM/plan and no verified 5G +coverage exist at the bench (`docs/BENCH.md` per-SKU blockers; the CeraLive-side +record is todo 2's BLOCKER B3), so the readback/registration/data/fallback drill +on the RM530N-GL has not run. Every claim above is fixture-proven only. + +## PURE ROUTER RESPONSE PARSERS — TRANSPORT STAYS OUT + +`control/src/hardware/router-parsers.ts` owns the pure CeraUI migration seam for +SIM-presence evidence and Huawei HiLink, ZTE goform, and Qualcomm UFI/HIMI response +normalization. It is exported through the existing root and `./hardware` entry points; +no new package subpath exists. Empty/refused/malformed readings remain explicit unknown +states, vendor placeholders are omitted, and HiLink capability refusal preserves the +device code. The module accepts response bodies only: HTTP, authentication, interface +binding, retries, caching, and every write remain outside it. CeraUI keeps its adapters +until the explicit cutover todo; this migration creates no sibling path dependency. + +The same transport-free migration seam also owns the remaining pure CeraUI compatibility +rules: portable USB physical identity/link-id derivation, modem display-name sanitation, +ModemManager enum normalization, USB-network classification and labels, capability-module +selection, and shadow-backend divergence folding. These helpers accept snapshots, primitive +values, or already-normalized records and return deterministic values only. They do not read +udev, invoke ModemManager, persist state, or alter CeraUI; CeraUI keeps its local adapters and +copies until the explicit consumer cutover. + +### `parseZteDetails` MUST stay a superset of the consumer it replaces + +The seam only works if adopting a packaged parser is a no-op for the shipped +consumer. It was not: an overlay of the 1.1 candidate over CeraUI's own tree found +this parser both NARROWER than the reader it is meant to retire and in disagreement +with it about one key name. Both are fixed here, and both are pinned by tests: + +- **`band` and `network_band` are two readings, not one key.** `lte_band` is the + SERVING cell's band and `wan_active_band` is the band the WAN leg is active on; + they disagree the moment carrier aggregation is up. This parser previously folded + all three spellings onto `network_band`, so a consumer rendering the serving band + got the WAN leg's — or nothing. They are now separate and neither falls back to + the other. +- **The carrier composition and the dongle's own counters are carried.** + `lte_ca_{p,s}cell_{arfcn,band,bandwidth}`, `monthly_{tx_bytes,rx_bytes,time}` + + `date_month`, and the five `realtime_*` counters now emit as `pcell_*` / `scell_*` + / `monthly_*` (with `monthly_period`) / `session_*`. The `realtime_*` → `session_*` + rename is deliberate: three of those five are cumulative counters, and the vendor's + own prefix reads as "live rate" for all five. +- **`stated()` drops every vendor placeholder**, not only the single dash — `--`, + `n/a` and `N/A` are unset markers on these firmwares too, and echoing one puts a + value on screen that reads like a reading. This widening applies to + `parseUfiDetails` as well, which shares the helper. + +**`parseUfiDetails` is still NARROWER than CeraUI's UFI reader and takes a different +input shape** (three bodies here, five there — no `status`/`networkMode`). No +consumer probes for it today, so nothing is broken; a future cutover must reconcile +it before pointing CeraUI's UFI path at this one. + +## OBSERVATIONS — NORMALIZATION THAT NARROWS WITHOUT DISCARDING + +`control/src/observations/` sits directly on top of the parsers above and turns a raw +per-vendor payload into ONE `ObservationEnvelope`. It decodes +nothing itself — every value comes from `domain/mm-enums.ts`, +`domain/modem-presentation.ts` or `hardware/router-parsers.ts` — and adds exactly two +things those pure functions cannot carry: **where a value came from**, and **why a value +is missing**. It opens no transport; a provider performs the read, this layer explains the +result. Reachable through the package ROOT entry, deliberately not through a new subpath. + +**FOUR STATES, NOT A VALUE PLUS A FLAG.** `readMetric` answers `fresh` | `stale` | +`unavailable` | `unknown`, and they differ in SHAPE rather than only in label: +`unavailable` and `unknown` carry no `value` field at all, and `unavailable` carries no +metric provenance because no metric was produced. So no consumer can read a value off a +state that has none. + +- **`stale` KEEPS the value.** An aged reading is the last thing the device actually said; + discarding it leaves an operator unable to tell "we lost contact" from "the modem reports + nothing". +- **`unavailable` is terminal on re-evaluation and never becomes `stale`.** It carries no + value, so there is nothing to age — re-classifying it would have to invent one. +- **Staleness is MONOTONIC.** `evaluateFreshness` returns an already-stale envelope + unchanged, so its `since`/`reason` record the FIRST cause. Freshness comes from a new + read, never from re-evaluating an old one. Trigger precedence when several apply: + superseded generation → superseded source epoch → degraded source → TTL expiry; the first + three state that reality overtook the reading, TTL expiry only says nobody looked. +- **A TTL expiry's `since` is `observedAt + ttlMs`, not the evaluation time** — otherwise a + reading that expired an hour ago looks like it just went stale. + +**`unknown` IS NEVER COERCED TO `unsupported`, and the reason class is what enforces it.** +`metricUnknownClass` splits `MetricUnknownReason` into `capability` (only `unsupported` — a +durable claim about the SOURCE) and `read` (`not-reported`, `not-observed`, `malformed`, +`auth-expired`, `refused`, `unreachable` — claims about ONE attempt). A consumer decides +whether to HIDE a control or show it pending by branching on that class, never on the bare +fact that a value is absent. The three distinctions this buys, all pinned by tests: + +| Situation | Reason | Class | +|---|---|---| +| ModemManager exposes no bar scale, only a percentage | `unsupported` | capability | +| The `Sim` interface was never read | `not-observed` | read | +| `Modem.State` was absent from the payload | `not-reported` | read | +| `Sim.EsimStatus` was present but decoded to nothing | `malformed` | read | +| The HiLink session answered `125002` | `auth-expired` | read | + +`metricUnknownReasonFromRouter` is a WIDENING, never a re-classification — every +`RouterSignalUnknownReason` member keeps its exact meaning. + +**A PAYLOAD THAT ARRIVED IS AN OBSERVATION, however little of it could be read.** A refused +HiLink session and an unparseable goform body both produce a FRESH envelope whose metrics +are `unknown` with a reason — not an `unavailable` one. That is not taxonomy for its own +sake: `ObservationEnvelope` pairs `unavailable` with `value: null`, so emitting it for a +payload we did hold would throw the diagnostics block away, and the raw vendor fields with +it. `unavailableObservation` is reserved for the case where there is no payload at all. + +**NOTHING IS DROPPED — retention is structural, not a discipline.** Every normalizer builds +ONE flat `RawFieldRecord` keyed `.` and reads its metrics out +of that same record, so a field a metric consumed is necessarily a field the diagnostics +block already carries. `createObservationDiagnostics` DERIVES `unmapped` (raw keys minus +consumed) rather than accepting it, so a field no metric names lands there automatically +instead of vanishing. A repeated XML tag is kept as `Tag`, `Tag#2`, `Tag#3`; a nested JSON +object keeps its JSON text; `Modem.SimSlots`-style arrays stay arrays. + +**`ObservationDiagnostics.raw` is a REDACTION-CLASS boundary.** The UFI overview endpoint +returns an IMSI and an ICCID, and they ARE retained — normalization does not get to decide +what a diagnostician may need. Anything that logs, serializes or files a diagnostics block +must route it through `redactObservationDiagnostics`, which runs the package's own key-based +`redact`, so the classes masked here are the classes masked everywhere else. Retention and +disclosure are separate decisions; this layer only guarantees the first. Note the recorded +`B5` finding still applies: the shared redactor does not mask `imei` / +`equipment-identifier`, so a caller that puts `Modem.EquipmentIdentifier` in a raw record +owns that exposure. + +**PER-METRIC PROVENANCE, INCLUDING PER-METRIC AUTHORITY.** One normalized observation folds +several provider reads together — HiLink answers `monitoring_status` and `device_signal` +separately, UFI answers three endpoints — so a single envelope-level `observedAt` would be a +claim about a reading no individual metric came from. Authority is per-metric for the same +reason a payload can mix classes: a router's RSRP is a measurement the modem reported and is +`authoritative`, while its bar count is a vendor rendering of that measurement and is +`derived`. This layer has no clock: `observedAt` and `sourceEpoch` are supplied by whoever +performed the read, so a normalizer cannot stamp a payload with a time it did not come from. + +**SIM PRESENCE IS BINARY, and that is the point.** `deriveSimPresence` answers +`present | absent | unknown`; the third member is not a presence, it is the absence of an +answer, so it becomes the metric's `unknown` state with a reason. That is what stops "we +could not tell" from rendering beside "there is no SIM". For the three ROUTER sources SIM +presence is deliberately NOT claimed at all — each vendor reports its own presence code +(`SimStatus`, `simcard_state`, `simstate`) with vendor semantics and no migrated decoder +covers them, so the code stays verbatim in the diagnostics block for the per-vendor +providers to claim later with evidence. Guessing one would be exactly the invented reading +this layer exists to prevent. + +**DESIRED, APPLIED AND OBSERVED ARE THREE THINGS AND STAY THREE THINGS.** +`state-separation.ts` follows NetworkManager's own split: a connection PROFILE is what an +operator asked for, an active connection's BEARER is what was put into force, and the +device's reported state is what the hardware is doing. All three are routinely different at +once, and a merged "current state" blob has to pick one and lose the other two — which makes +"did our write take effect", "is this the network's doing or ours" and "what do we roll back +TO" unanswerable. `ModemStateView` therefore has exactly three slots with three `kind` +discriminants and NO fourth merged field (an effective value is a rendering decision, and +computing one here would bake a policy every consumer would work around). +`describeStateDivergence` returns TWO independent comparisons — `desiredVsApplied` ("did our +write happen") and `appliedVsObserved` ("did it stick") — because one boolean cannot separate +a request that was never carried out from one the network undid a second later, and those +need opposite responses. An unavailable observation compares `indeterminate`, never +`aligned`: "we could not read it" is not evidence that it matches. + +Coverage: `control/src/observations/{observation-states,normalization,state-separation}.test.ts` +against the canonical per-source fixtures in `control/test-support/observation-fixtures.ts` +(ModemManager, HiLink, ZTE goform, UFI/HIMI, plus the auth-expired and unparseable variants). +Each fixture deliberately carries vendor-specific fields the normalized model has no slot for +— `Modem.Ports`, `CurrentNetworkTypeEx`, `wan_lte_ca`, `cputemp` — and the round-trip +assertions are what prove those survive. Later provider work should reuse those fixtures +rather than re-invent them. + +## MODEMMANAGER PROVIDER — TYPED D-BUS, RUNTIME-DISCOVERED GENERIC CONTROLS + +`control/src/providers/modem-manager/` is the concrete `ProviderDefinition` for +`org.freedesktop.ModemManager1`. It composes the existing typed transport and adapters; it does +not introduce a second D-Bus stack. `ObjectManager.GetManagedObjects` supplies normalized +snapshots, while the existing epoch-scoped `MmDbusObserver` supplies signal-driven lifecycle +events and retains rows across daemon loss. A new or unknown model is selected by the live +`Modem` interface and receives mode, signal, SIM, and power reads from the properties it exports. +No model catalog participates in those generic reads. + +The provider reuses `MmDbusBackend`/`MmMutations`, `MmLocation` plus the bounded +`location/fix-state.ts` machine, `createDbusSmsPort`, `MmUssd`, the band codec/certification split, +and FCC coverage. Band reads are generic; band writes remain refused until the embedding process +supplies a `bandSku` resolving to a catalog entry (see § RADIO CAPABILITY TRUTH). Its `modes` +operation surfaces the modem's own `(allowed, preferred)` catalog verbatim. `Location.Setup` still sends `signal_location=false`, SMS remains +read-only, and FCC remains policy/catalog-only. One shared `ModemActor` serializes every composed +adapter for a modem. The provider has no bearer/APN method; NetworkManager remains sole owner. + +`errors.ts` maps typed daemon/transport failures to stable refusal reasons (`unauthorized`, +`unsupported`, `wrong-state`, `busy`, `not-found`, `timed-out`, `disconnected`, `failed`). +`forbidden-subprocess.test.ts` scans every production file in this provider and proves no path can +spawn `mmcli`, `qmicli`, or `mbimcli`; its detector has a non-vacuity control for all three names. +Private-session-bus coverage is `modem-manager-provider.integration.test.ts`. Operation-by-operation +detail (which reads are generic, which writes stay refused, and the exact refusal vocabulary) lives in +[`docs/MODEMMANAGER-PROVIDER.md`](docs/MODEMMANAGER-PROVIDER.md). + +## NETWORKMANAGER ADAPTER — SAVED vs APPLIED, AND NOTHING ELSE + +`control/src/providers/network-manager/` is the thin `NetworkManagerAdapter`: desired +connection profiles, the applied bearer, and the interface that bearer landed on. It is the +**only bearer/APN authority surface in the package**, and it is deliberately narrow — no +radio, band, SIM or power operation appears in it, because those belong to the ModemManager +provider and a second expression of them would make two writers for one resource. It is +exported through the existing `./providers` and root entries; **no eighth package subpath**. + +It COMPOSES rather than replaces the existing NM work: `NmcliNmPort` +(`control/src/backend/nmcli-nm-port.ts` — nine-field GSM write parity, device-exact +activation, atomic Auto-APN transitions, quiesce leases) is unchanged and remains the +`NetworkManagerPort` implementation this adapter is constructed with. + +Six decisions are load-bearing, each pinned by a test that goes red when removed: + +- **Observed state never writes the desired slot.** `observe()` may clear `applied` and + always rewrites `observed`; it touches `desired` on no path. Reality overtaking a write + does not un-ask the operator's question — and if it did, a re-enumeration would erase the + configuration the controller exists to restore. +- **Desired records the REQUEST; applied records NM's READBACK.** Seeding desired from the + readback would make a field NM silently rewrote (`gsm.auto-config` driving the APN is the + real case) structurally unreportable, and asserting applied from the input would make a + silently-rejected write look like it took. +- **`unbound` is a VALUE, not an unavailable observation.** "The device is here and idle" is + a definite divergence from a desired bearer; "the device is gone" is not knowledge at all. + An unavailable observation compares `indeterminate` against everything, which is right for + the second and wrong for the first, so a present-but-idle device produces a FRESH envelope + carrying `unbound` and only a MISSING device produces `unavailable` / `device-absent`. +- **A readout is an ENUMERATION, never a delta.** A device absent from `NmObservationInput.devices` + is GONE. That is the only shape in which re-enumeration is detectable without depending on a + removal event nobody guarantees will arrive. +- **A transitional device state is `pending`, not a loss.** A device in `prepare`/`ip-config` + carrying our connection is coming UP; reporting that as a lost bearer would turn every + ordinary activation into a false alarm. Loss is reported only for the four states that + positively contradict the applied bearer: `interface-absent`, `interface-detached`, + `connection-replaced`, `activation-failed` — and the loss retains `previous`, because the + applied slot has just been cleared precisely because it no longer describes reality. +- **The adapter owns no identity and no credential.** Every slot is keyed by NM's own + connection UUID, never a `PhysicalModemId`, so this can never become a second authority on + which physical modem is which. `NmBearerBinding` also omits `username`/`password`: a state + slot is compared and surfaced in divergence output, and `gsm.password` is the one profile + field redaction masks everywhere else. There is likewise **no delete path** — profile + removal is not in the port-tagged `NmOp` set, so the adapter cannot express it. + +A superseded-generation readout is REFUSED rather than folded late, so a reply about a +previous enumeration cannot clear applied state belonging to the current one. Divergence is +reported through todo 18's `describeStateDivergence` — two independent comparisons, never one +verdict. Coverage is `network-manager-adapter.test.ts`, driven by the stateful `nmcli` +harness in `control/test-support/fake-nm/` (real readback, no bus, no subprocess); its scope +gate scans the module's comment-stripped source for radio/SIM/delete/identity identifiers and +proves the strip non-vacuous in both directions. + +## SMS — LIST / READ AND OBSERVATION, PERMANENTLY + +`control/src/ports/sms.ts` + `control/src/sms/` are the read-only SMS surface: +`SmsObservationPort` is `list()` / `observe()` / `stop()` and nothing else. There is +no verb here that composes, stores, sends, or deletes a message, and none may be +added — sending or deleting is billable, irreversible, and turns a diagnostic read +into real control over the subscriber's account. That is PERMANENT policy, not a +phase limitation; CeraUI has carried the same contract since Phase A. + +**Two grep gates, and they enforce different vocabularies.** +`control/src/sms/readonly-gate.test.ts` scans the whole SMS surface (the port +included) for the D-Bus write verbs (`Messaging.Create` / `Delete`, `Sms.Send` / +`Store`), the mmcli spellings, and the identifiers a hand-rolled write path would +use; it also asserts the only D-Bus METHODS called are `List` + `GetAll` and the +only SIGNALS subscribed are `Added` + `Deleted`. Those two sets are asserted +SEPARATELY because both are spelled `member: 'X'` and only an outgoing `callMethod` +can mutate a device. CeraUI's `tests/modem-sms-readonly-gate.test.ts` is the other +half, extended by this work to cover the D-Bus verbs its mmcli-flag patterns could +not spell. Neither gate may be deleted or narrowed to land a write path. + +**LIST ONCE, then follow the signals.** `createDbusSmsPort` +(`sms/dbus-messaging.ts`) calls `Messaging.List` once and folds `Added`/`Deleted` +from then on. Re-listing on a poll tick is the anti-pattern this port exists to +remove: it costs one method call per stored message per tick and still cannot report +an arrival sooner than the tick it lands on. The ONE re-list is on a transport +RECONNECT, where the events that occurred while the bus was down were never +delivered — and it is published as `resynced`, which the store applies by REPLACING +its rows. Folding a fresh list as a series of `Added` events would keep a message +deleted during the outage forever, and is exactly how a restart comes to duplicate +an inbox it already held. + +**Duplicate suppression is not identity-based, because MM's own duplicate is not +byte-identical.** ModemManager announces a message while it is `receiving` and again +once it is `received`, so `createSmsInboxStore` (`sms/inbox-store.ts`) updates a row +whose content CHANGED and no-ops one that is verbatim. A store that skipped on "have +I seen this id" would keep the empty `receiving` row forever. + +**`sms/mmcli-parse.ts` is a CLI grammar living beside a D-Bus adapter, deliberately.** +`mmcli` is a client of the SAME daemon, and CeraUI has read its inbox through it on +real hardware since Phase A — so owning the grammar here is what makes the port's +output provable against that reader on captured output (`sms/parse.test.ts` pins the +golden values; CeraUI's `modem-sms-port-parity.test.ts` pins the identical ones), and +what lets a consumer move its parsing onto this package without moving its transport +in the same change. Four behaviours in it are load-bearing and each fails silently if +dropped: the octal unescape is per-BYTE then UTF-8 (`\302\241` is two bytes, not two +characters); the service-centre timestamp's HOURS-ONLY offset (`…-05`) is widened to +`-05:00` or every message scores undated and "newest first" degrades to object-index +order, which MM reuses; the list is cut to `SMS_INBOX_CAP` (50) BEFORE any per-message +read; and an absent `modem.messaging.sms` VALUE is an empty inbox while a missing KEY +is drift. + +**Nothing here puts message content into an error, a log, or a receipt.** A body +routinely carries a one-time code and a sender identifies the subscriber, so a +malformed record reports the KEY NAMES it found and nothing else, and the module +never logs at all. `redact.ts`'s `isSmsSensitiveKey` is the value-side class; it is +its own set rather than additions to `SENSITIVE_KEYS` because that set matches leaf +names exactly, so adding `text` / `number` / `sender` would blank a receipt reason, a +slot number, and a signal reading across the package. It mirrors CeraUI's +`helpers/logger.ts` set key-for-key — a Rule-D MIRROR, never a shared import. + +**HONEST STATUS: no live receive has been drilled.** Only the Quectel has a SIM and +it never registers, so no bench modem can receive an SMS (todo-2 BLOCKER B4). Every +claim here is fixture-proven, including the restart-recovery behaviour; what is +missing is the board measurement, not the behaviour. + +## FCC AUTO-UNLOCK — A CATALOG, A POLICY FILE, AND NOTHING ELSE + +`control/src/fcc/` implements NO unlock procedure and ships NO unlock script. It +records which `:` MODELS an operator opted in for, so +`ceralive-fcc-reconcile` can re-derive ModemManager's own admin-tier symlinks from +that record on every boot. The unlocking is ModemManager's dispatcher's job, start +to finish. Full model, matrix and certification status: +[`docs/FCC-UNLOCK-COVERAGE.md`](docs/FCC-UNLOCK-COVERAGE.md). + +- **`:` is the ONLY correct key.** `mm-dispatcher-fcc-unlock.c` builds + exactly `g_strdup_printf("%04x:%04x", vid, pid)` and opens no other name, so a + vendor-only file is never a dispatcher target — it exists only as what the + available tier's `:` symlinks point at. A vendor-keyed rule would also + be wrong twice over: Sierra silicon ships under THREE vendor ids (`1199` its own, + `03f0` HP-branded, `413c` Dell-branded), so keying on the vendor misses two of the + three, and keying on the model misses the OEM rebrands. +- **Three tiers, and the reconciler owns exactly one.** available + (`/usr/share/ModemManager/fcc-unlock.available.d`, ModemManager's, inert) → + enabled-admin (`/etc/ModemManager/fcc-unlock.d`, **ours, opt-in only**) → + enabled-package (`${libdir}/ModemManager/fcc-unlock.d`, a distribution's; CeraLive + writes there never). Writing into the wrong one is SILENT — the link is simply + never opened. +- **The `/data` file is the record; the symlink is derived.** `/etc` rides the + rootfs, which is exactly what a RAUC slot swap REPLACES, so an opt-in written only + as a symlink survives a reboot and not an OTA. `/data/ceralive/fcc-unlock-policy.json` + (0600, atomic temp→chmod→rename) is what persists, and the oneshot re-materializes + the link before ModemManager probes a radio. +- **Coverage is a TRI-STATE, and `unknown` is not `absent`.** + `resolveFccUnlockCoverage` answers `present` (MM ships a procedure), `absent` (the + ids are well-formed and are NOT in the mapping — a positive statement about the + device) or `unknown` (we could not read the ids, a statement about the READ). + Folding the third into the second would hide the module on hardware that may be + covered. +- **The coverage check is part of the WRITE, not a UI nicety.** Persisting `true` + for an uncovered model leaves an enabled toggle that provably cannot act — the + reconciler would skip it forever, silently. `setFccUnlockPolicy` rejects it + `not-covered`. Disabling is deliberately NOT coverage-checked: a fail-closed + opt-OUT is not a thing. +- **Corruption is fail-SAFE and the bytes are KEPT.** Unlike + `backend/usage/policy-store.ts`, which rewrites a fresh file, this store leaves a + damaged policy on disk and simply refuses to act on it. Enabling a + regulatory-unlock procedure is not something to infer from a file we could not + read, and replacing the evidence would make the next diagnosis impossible. +- **The toggle is per MODEL and the UI must say so.** The mechanism is a + `:` symlink, so it applies to EVERY attached device matching it — the + bench's two identical HiLink twins are the shape of the problem. No per-unit + refinement exists without changing ModemManager. +- **Enabling is not retroactive.** The dispatcher runs during modem + INITIALIZATION, so an already-enumerated modem needs a re-probe + (`mmcli -m --disable && --enable`, or a replug). `SetFccUnlockPolicyResult` + carries `changed` precisely so an unchanged write does not cost one. + +Coverage: `control/src/fcc/{coverage,policy}.test.ts` plus +[`packaging/ci/test-fcc-reconcile.sh`](packaging/ci/test-fcc-reconcile.sh) (the +shell reconciler's behaviour on any host) and `packaging/ci/test-companion-chroot.sh` +§ 6 (the same logic from its PACKAGED location after a real `dpkg` install). + +## BAND LOCK — THE ONE MODULE THAT IS STRICTER THAN THE FRAMEWORK FLOOR + +`control/src/band/` is the first of the seven capability modules with real verbs +behind it. Two halves: + +- **`band-names.ts`** — `MMModemBand` ↔ name, both directions. The D-Bus surface + speaks numbers (`SupportedBands` / `CurrentBands` / `SetCurrentBands` are all + `au`); every operator-facing surface speaks the name (`eutran-3`, `any`). ONE + mapping so the two cannot disagree. It reproduces `mm-enums.h`'s actual shape: + the GSM/UTRAN head (1..20) is IRREGULAR — `UTRAN_2` is 12 while `UTRAN_6` is 8 — + so it is an explicit table and can only ever be one, while every later block is + arithmetic by MM's own construction (`EUTRAN_n = 30 + n`, `CDMA_BCn = 128 + n`, + `NGRAN_n = 300 + n`). Deriving those rather than transcribing ~350 constants is + the point: a transcription is where a wrong band number hides, and a wrong band + number locks a radio to a band the network does not operate on. A value this + build does not name round-trips as `band-` — never dropped, never guessed. +- **`certification.ts` + `certified-bands.json`** — the per-SKU proof gate. + +**`encodeBandList` FAILS CLOSED AS A WHOLE.** One unplaceable name rejects the +entire selection rather than silently narrowing it: a partial band set is a +DIFFERENT lock from the one that was asked for, and applying it strands the radio +on bands nobody chose. `decodeBandList` is the mirror — a malformed member is +dropped rather than coerced, and MM's `unknown` (0) is dropped because it means +"the modem did not say", which is not a band an operator can select. + +**There is no reset VERB, and there must not be one.** ModemManager releases a +band lock by setting exactly `MM_MODEM_BAND_ANY` (256); `setCurrentBands(modem, +['any'])` IS the reset. Adding a `resetBands` sibling would be a second way to +express one D-Bus call, and the two would eventually disagree about what "no +lock" means. + +**Band-lock deliberately requires `certified`, not `capable`, to be OFFERED.** +That is a documented DEVIATION from `support-claim.ts`'s framework floor, and the +framework is right in general: hiding an uncertified-but-working control puts +hardware behind a paperwork gate. For this module the paperwork IS the safety +argument — a band the SIM's network does not operate on registers nowhere, and a +modem that does not honour a reset leaves an operator with no way back short of a +replug they may not be able to reach. So `isBandControlCertified` gates the +control, and the catalog demands FOUR separate proofs (`supportedRead`, `set`, +`readback`, `reset`), each `z.literal(true)`, so a half-certified entry cannot be +expressed at all: a reviewer with three of four has an uncertified SKU and the +file says so by OMITTING it. `readback` exists because an accepted-but-ignored +write is indistinguishable from success at the call site. + +**The shipped catalog is EMPTY, and a test pins that.** No fleet modem has been +through the drill: the bench Quectel RM530N-GL's SIM never registers (phase-C +todo 2, blocker B2), so "re-registration proven" cannot be claimed on this bench +today. An entry is added by a human-reviewed commit carrying the transcript, +exactly like `usb-mode/certified-catalog.json`. + +`setCurrentBands` runs QUIESCED through the shared `ModemActor` for the same +reason `setRadioModes` does: it re-registers the radio, so NM must stand down +before the bearer drops underneath it rather than after. + +## RADIO CAPABILITY TRUTH — THE MODEM'S OWN CATALOG, UNEDITED + +`control/src/radio/` is the layer that carries ModemManager's `SupportedModes` / +`CurrentModes` / `SupportedBands` answers to a consumer **without editing them**, and +turns them into operation descriptors. Reachable through the package ROOT entry; +**no eighth subpath** (the todo 17/18/23 precedent). It sits BESIDE `src/band/` rather +than inside it — a mode change is disruptive-but-reversible, a band lock can strand a +radio where nothing registers, and the two safety models must not be merged. + +- **`preferred: 0` is `none`, and `none` is a VALUE.** The bench FM350-GL advertises + exactly one combination whose preferred mask is 0: the modem allows a set of modes and + states NO preference within it. Substituting "the highest allowed mode" shows an + operator a preference the modem never expressed and cannot be returned to. `none` + therefore survives all the way into `descriptor.constraints.values`, and a test asserts + it is not any of the allowed modes. +- **An unfamiliar mode bit stays OFFERED.** It round-trips as `mode-bit-` (the + `band-` discipline from `band-names.ts`), the combination is classified + `unknown-combination`, and the descriptor stays `available`. Hiding it would coerce + `unknown` into `unsupported`, which is the first rule `support-claim.ts` exists to + enforce. +- **Nothing is dropped, structurally.** `decodeSupportedModeCombinations` puts a member + that is not a `(uu)` into `undecodable`, so `combinations.length + undecodable.length` + is the member count the provider sent. A test asserts that identity rather than the + contents. +- **A selection the modem never advertised is REFUSED, never rounded.** Same rule, same + reason as `five-g-preference.ts`: substituting is how "prefer 4G" on a marginal cell + silently becomes 5G-first. +- **`MmMutations.setModeCombination` exists because `setRadioModes` structurally cannot + express `MM_MODEM_MODE_NONE`** — its preferred mask is derived from + `preferenceOrdered[0]`. Both quiesce; both are one `SetCurrentModes` call. +- **Mode and band writes are readback-gated at the CALL PATH, not only in the + descriptor.** `SetCurrentModes` / `SetCurrentBands` returning without an error only + proves the daemon accepted the call; an accepted-but-ignored write looks like success + from the call site, which is exactly the failure the catalog's own `readback` proof + exists to catch. +- **The band-write gate is `band/certification.ts`, wired — not re-implemented.** + `describeBandWriteCertification` reads `findBandCertification` + `offerableBands` and + nothing else. `buildBandWriteDescriptor` then publishes `mutationImpact: 'disruptive'`, + a `band-certification-present` live precondition, a required readback, and + `availability: refused` / `support.write: false` carrying + `band-certification-required`. Because the shipped catalog is EMPTY, that is the + answer for every device on the fleet today. **The gate is deliberately DOUBLED** — the + descriptor is what a consumer reads to decide whether to offer the control, the + provider's own check is what refuses a call made anyway; a gate that exists in only one + of the two is either advisory or invisible. +- **`ModemManagerProviderOptions.isBandControlCertified` is GONE, replaced by `bandSku` + + `bandCertificationCatalog`.** An injected boolean let a caller assert certification + without a catalog row, which the four-proof `z.literal(true)` schema exists to prevent. + MM's `Modem` interface carries `Model` and `Revision` but no USB `vid:pid`, and + `BandSku` needs all three — so the SKU resolver is injected and, with none supplied, + there is no SKU, no entry, and no band write. Fail-closed in every direction. +- **A band reset readback is satisfied by `any` OR by the whole supported set** (MM + reports either after `SetCurrentBands([ANY])`); a NARROWING lock must match exactly, + because a superset is a different lock from the one that was asked for. + +`ContextWriteOperation` gained `describe(context)` for this: a static descriptor cannot +carry a device's own catalog, so "which combinations does THIS modem advertise" and "is +THIS SKU's band lock certified" are read live instead of inferred from a capability flag. + +Coverage: `control/src/radio/{mode-combinations,mode-truth,band-truth}.test.ts` (pure) and +`control/src/providers/modem-manager/capability-truth.test.ts` (the whole path, over the +in-memory MM transport, with FM350 / Quectel / SIMCom / unknown-combination / no-SIM +specs). + +## SIGNAL AND SIM NORMALIZATION — EVIDENCE, NOT INFERENCE + +The observation layer's SIM and signal halves are finalized on top of todo 18. + +- **`absent` is reachable through exactly ONE evidence kind.** + `readSimPresence` (`hardware/router-parsers.ts`) returns the presence AND the + `SimPresenceEvidence` that decided it; only `state-failed-reason` (mmcli's + `sim-missing`) can produce `absent`. `NormalizedSim.presenceEvidence` and + `ModemManagerSimState.presenceEvidence` carry it, so "there is no SIM" and "we could not + tell" — read off the SAME empty fields — are separable by a consumer and by a test. + ModemManager reports `Sim: '/'` while a modem is initializing and while a slot switch is + in flight, so a blank object path proves nothing and stays `unknown`. +- **`decodeStateFailedReason` is what makes absence readable at all.** + `Modem.StateFailedReason` is a `u` on D-Bus, while the migrated presence rule matches + the mmcli STRING — so before this decoder the D-Bus path could never produce the one + fact that proves absence. Both spellings are accepted; an unrecognized number decodes + to `undefined` and proves nothing. +- **`ModemManagerSimState.present` is POSITIVE evidence only.** `true` means the modem + exports an active SIM object path. `false` is NOT a claim of absence — `presence` is. +- **The router sources still claim no presence, and now say WHICH code they left alone.** + `vendor-code-unclaimed` names HiLink's `SimStatus`, goform's `simcard_state` and HIMI's + `simstate`. The field is NAMED in the evidence but deliberately NOT marked `consumed`, + so it stays in the diagnostics block's `unmapped` set, verbatim, for the per-vendor + provider that will one day decode it with evidence. +- **`Modem.CurrentModes` and `Modem.SignalQuality` are retained as their D-Bus STRUCTS.** + The provider used to flatten `(uu)` and `(ub)` to their first member before handing them + to normalization, which dropped the preferred mode and the measurement-recency flag + before the diagnostics block ever saw them. `rawStructMember` / `rawNumberAt` / + `rawBooleanAt` read either shape, so a pre-existing flattened fixture still decodes. +- **`NormalizedSignal.qualityRecent` claims the `(ub)` boolean.** It is a fact about when + the MODEM last measured, which is a different question from the envelope's staleness + (when WE last read). The router APIs have no such flag and answer `unsupported` — a + capability claim, the `bars` / `maxBars` precedent. + +Coverage: `control/src/observations/sim-evidence.test.ts`, whose control case is a modem +with the identical blank fields and NO failure reason, asserting `unknown`. + +## GPS / LOCATION — A LIVE FIX, AND DELIBERATELY NO HISTORY + +`control/src/ports/location.ts` + `control/src/location/` + +`control/src/backend/mm-location.ts` are the gated GNSS module. The port is +`getLocationStatus` / `enableGnss` / `disableGnss` / `readFix` and nothing else, and it +is deliberately NOT part of `ModemManagerPort` — a consumer that only reconciles radio +and SIM state has no business holding a handle that can read a position. + +**The privacy fence is a PRODUCT rule, not a phase limitation.** There is no history +verb, no track verb, no export verb and no upload verb, and none may be added: a fix is +held in memory for a live display and is gone the moment GNSS is switched off or the fix +goes stale. `ports/location-fence.test.ts` fails the build if a member whose name implies +history, tracking, persistence or upload ever appears on the port. + +Four decisions are load-bearing and easy to undo by accident: + +- **`Location.Setup`'s `signal_location` argument is ALWAYS false.** Passing `true` makes + ModemManager broadcast the `Location` property over `PropertiesChanged`, which would put + the operator's coordinates on the system bus for every listener — including this + package's own observer, whose snapshots are logged. The fix is fetched by an explicit + `GetLocation` call instead, so a coordinate only ever exists where somebody asked for one. +- **GNSS runs through `actor.run`, NOT `actor.runQuiesced`.** Quiescing exists to stop + NetworkManager racing a disruptive change and it costs a bearer deactivation; dropping a + link on a bonded device to switch a GPS receiver on would be an absurd trade. + `Location.Setup` touches no bearer, so per-modem serialization is all that is needed. +- **Disable clears ONLY the GNSS bits.** `3gpp-lac-ci` is the existing cell-info module's + source, so blanking the whole mask would silently switch off a neighbouring feature the + operator never touched. For the same reason `3gpp-lac-ci` is absent from `GNSS_SOURCES`, + and the `Location` interface merely being exported is NOT a GNSS claim — MM exports it + for 3GPP-LAC/CID-only devices (the fleet's FM350-GL is exactly one), so a GNSS source + must appear in `Location.Capabilities`. +- **Acquisition is BOUNDED and a fix EXPIRES.** `location/fix-state.ts` is a pure, total + machine with no clock and no I/O — the caller supplies `at` on every event, which is what + makes both bounds testable without waiting. Past `acquireTimeoutMs` (120 s) the state + becomes an honest terminal `no-fix` rather than an endless spinner; a fix older than + `fixTtlMs` (30 s) is dropped. A fix is reachable ONLY through `renderableFix`, which + answers only in the `fix` state, so no code path can render a position the modem has + stopped reporting. + +`gps-nmea` is decoded locally (`location/nmea.ts` — GGA only, checksum-verified) because it +is the one GNSS source every GNSS-capable fleet modem advertises while MM's pre-decoded +`gps-raw` dict is not guaranteed. GGA is the sentence used because it carries fix QUALITY +alongside the position, so "the receiver has not locked on" is decoded rather than inferred. +A `gps-raw` entry that exists but carries no usable coordinate pair is NOT a fix — MM +populates the key as soon as the source is switched on. + +Coordinates are their own redaction class (`latitude` / `longitude` / `altitude` / `lat` / +`lon` / `lng` / `nmea` / `nmeasentences` / `coordinates`), so a fix that reaches a log line, +a receipt or a bundle comes out as the marker. + +**Status: `implemented-but-uncertified`.** Three fleet modems advertise GNSS, so the +capability gate is satisfied, but whether a GNSS antenna is physically attached to the bench +Quectel is unanswered (phase-C todo 2, `needs-user` N1), so the live-fix drill has not run. +The capability, no-fix, disable, expiry and redaction paths are fixture-proven; the +acquire-a-real-fix path is not. + +## USSD — A SESSION PROTOCOL, MODELLED AS ONE + +`control/src/ussd/` is the gated USSD module: a pure session machine (`session.ts`), a +refusal taxonomy with its registration reader (`refusal.ts` + `registration.ts`), the four +D-Bus calls (`calls.ts`), and the adapter that drives them (`mm-ussd.ts`). + +**LEASE-ONLY, never journaled.** A USSD session cannot re-register the radio, so it takes +the per-modem mutation lease and carries no pre-state and no rollback — the split todo 29's +`MutationAdmissionPort` enforces in the TYPE SYSTEM, so this classification is not a +convention that can drift. + +**USSD is a SESSION protocol, not request/response, and that is why the machine exists.** +`Initiate` opens a dialogue the network may hold open pending a `Respond`; a session that is +neither responded to nor cancelled stays open NETWORK-side, consuming a scarce +per-subscriber slot and failing the next `Initiate` with a busy error nobody can see the +cause of. "Which verb is legal right now" is therefore a real question with a real wrong +answer, and answering it inside the D-Bus adapter would make it untestable without a bus. + +Four properties are load-bearing: + +- **Three of the seven states are LOCAL.** `initiating` / `responding` / `cancelling` have + no counterpart in MM's `MMModem3gppUssdSessionState`, because MM has no state for "we + dispatched a call and the reply has not landed". Without them a second `initiate` racing + the first would be judged against `idle` and let through — exactly the double-open the + network answers busy. +- **An illegal verb is REFUSED with a typed reason, never thrown and never ignored**, and + the machine does not move. A refusal at an RPC boundary must name what the caller can do + about it; a throw becomes an opaque failure and a silent no-op becomes a UI that spins. +- **`lte-only-unsupported` is claimed ONLY on a positively PS-only registration.** USSD is a + circuit-switched supplementary service, so a modem attached LTE/5G-SA with no CS domain + and no CSFB can only carry it where the operator deployed USSI (3GPP TS 24.390) — and + where they did not, the modem answers a generic unsupported/failed error indistinguishable + from "this modem has no USSD interface". Reporting that as a device limitation would send + an operator hunting for a firmware fix for a network policy. `registration.ts` derives the + fact rather than guessing: `AccessTechnologies` all packet-only ⇒ no CS domain, EXCEPT + that MM's two CSFB registration states (`*_CSFB_NOT_PREFERRED`, 9 and 10) override it + outright. An unread registration stays `undefined` and can only ever make the refusal LESS + specific. +- **An unanswered session is closed at a bound AND the network release is attempted.** The + machine closes `timed-out` first — that is the operator's answer whether or not the + release lands — and the modem-side `Cancel` is best-effort afterwards, because a modem + that did not answer the dialogue may not answer this either and a timeout must still + terminate. A `closed` machine accepts nothing, so a finished session cannot be + resurrected; the adapter's per-modem map starts each new dialogue from a fresh machine. + +**Carrier text is redacted by FIELD NAME, which is why the fields are named as they are.** +`../redact.ts` is key-based, so `ussdCommand` / `ussdResponse` / `ussdReply` (rather than the +shorter names that read better) ARE the guarantee. BOTH directions are sensitive, not just +the reply: a USSD dialogue is how a subscriber tops up a prepaid line, so the command +routinely carries a voucher code and the reply a balance or a one-time code. +`NetworkNotification` / `NetworkRequest` are MM's own property names for network-initiated +USSD text and are included so a raw property dump cannot leak what the call path masks. +Nothing in `calls.ts` logs, and every error it raises is re-thrown untouched so the +classifier — not a string built around the payload — decides what the caller is told. + +**Status: `implemented-but-uncertified`.** Only the bench Quectel has a SIM and it never +registers, so no bench modem can open a USSD session (phase-C todo 2, BLOCKER B4). The state +machine, the refusal classifier, the timeout path and the redaction are fixture-proven; the +live balance-check drill has not run. + +## eSIM — `blocked` on hardware, not deferred by choice (2026-08-18) `docs/ESIM-DECISION.md` records the full eSIM investigation: SGP.22 profile-binding makes cross-device profile "copying" cryptographically impossible; the workable paths are removable eUICC, carrier reissue, or multi-profile remote switching; `lpac` (external LPA) is assessed but not adopted (AGPL-3.0 core, AT backend is demo-only, needs MM -inhibit-coordination). Implementation is **deferred by user decision (2026-08-13)** — this -doc is the exit artifact, not a task list. No eSIM code exists in this repository. +inhibit-coordination). + +The 2026-08-13 **deferral was reversed** by user decision and eSIM re-entered scope as a +hardware-gated adoption spike. It closed **`blocked`** (§9): **no bench modem exposes an +eUICC** — the only SIM on the entire fleet reports no `eid` under MM 1.24.2, which is +positive evidence of a classic removable UICC, and RM530N-GL eUICC capability is unproven +for that unit. **`blocked` is not a NO-GO**: the spike's three hardware steps could not +start, so no verdict exists and none may be inferred. + +**No eSIM code exists in this repository, and none may be written on the strength of this +record.** No `ceralive-lpac` `.deb`, no closure row, no manifest entry, no apt publication, +no image pin — the release manifest stays at `closure_version: 2` with its frozen matrix. + +The **licensing half of the spike did complete** and binds any future adoption: + +- lpac's program logic (`src/`, `driver/`, `utils/`) is **AGPL-3.0-only** — it may only ever + be spawned as an EXTERNAL process over its CLI, never linked or embedded into + `@ceralive/modem-control`, `cerastream`, or `CeraUI`. +- Redistributing it obliges **Corresponding Source from the same place** (AGPL-3.0 §6(d)). + `apt.ceralive.tv` publishes binary indexes only — a source channel would have to exist + BEFORE any lpac upload. +- AGPL §13 attaches to **modified** versions only, so the rule is *ship unmodified or not at + all* — the same answer `POLICY.md`'s no-fork rule already gives. +- Shipping form on a hypothetical GO: a first-party bookworm rebuild in `packaging/`, pinned + like the other four sources. The stock Debian package (`lpac` 2.3.0-1) exists but is in + testing/unstable only — **not** bookworm, **not** trixie. + +## THE SIM'S OWN NUMBER — READ, REDACTED, NEVER A KEY + +`Modem.OwnNumbers` is the MSISDN the carrier wrote into the SIM. `mapping.ts` +`readOwnNumbers` folds it onto `ModemIdentity.ownNumbers` +(`readonly SubscriberNumber[]`, branded like `SubscriptionId` because it is the +same PII class), and `redact.ts` gains `isOwnNumberSensitiveKey` for it. + +Four decisions carry weight: + +- **ABSENT, EMPTY and BLANK all read as NOT REPORTED.** Most SIMs carry no + MSISDN in their elementary files at all, so an empty `as` is the ordinary + answer — publishing `[]` would invite a consumer to render "no numbers" as a + finding rather than as silence. The key is omitted instead. +- **The array is KEPT, not collapsed to a first element.** MM's property is `as` + and a dual-number SIM is expressible; dropping the tail would be a silent + loss. The bench Quectel RM530N-GL reports exactly one (`+573115422359`), which + is what the fixtures use. +- **It is its OWN redaction class, not an addition to `SENSITIVE_KEYS`.** That + set matches a leaf name, so `number` / `numbers` there would blank a slot + index and a band count package-wide. `msisdn` stays where it already lived, in + the SMS set, and is not duplicated. Mirrors CeraUI's + `isOwnNumberSensitiveKey` (`helpers/logger.ts`) — a Rule-D MIRROR, never a + shared import. +- **It may never bind policy.** `PolicyBindingKey` enumerates its fields, so the + addition cannot leak into a durable key by construction — the same protection + `subscriptionId` already relies on. It IS displayed to an operator behind an + explicit reveal; that is a rendering decision and does not make it loggable. + +Coverage: `control/src/backend/mapping.test.ts` (the read matrix, the +fingerprint bump, and the everything-else-untouched control) plus the three +own-number blocks in `control/src/redact.test.ts`. ## POLICY @@ -213,12 +1291,19 @@ rationale, source cites, and the open gates are recorded in `docs/FM350-DECISION `tsconfig.json` covers both members (`bun run typecheck` → `tsc --noEmit`). - **Biome** via `@ceralive/biome-config` (repo-root `biome.json` extends it). `bun run lint`. - **Bun test** (`bun test`) discovers `*.test.ts` across both members. +- **Node 26** for the standalone consumer fixtures (`control/fixtures/`). Point + `CERALIVE_NODE_BIN` at a Node 26 binary if it is not first on `PATH`. ```sh bun install -bun test # workspace tests +bun test # workspace tests (includes the tarball-shape gate) bun run lint # biome check . bun run typecheck # tsc --noEmit (strict + exactOptionalPropertyTypes) +bun run build # build @ceralive/modem-control into control/dist + +cd control +bun run verify:tarball # pack + assert the published artifact's shape +bun run verify:consumers # install the tarball into standalone Node 26 + Bun projects ``` `packaging/` runs in a `debian:bookworm` container; its contract/verification scripts live @@ -237,7 +1322,10 @@ Follows the CeraLive CI/CD standard (concurrency, trigger hygiene, least privile major action versions, per-manager caches, weekly grouped Dependabot, test-before-publish). - **`.github/workflows/ci-bun.yml`** — paths-filtered PR + push(`main`) lane for - `control/**` and `cli/**`: `bun install` → Biome check → `tsc --noEmit` → `bun test`. + `control/**` and `cli/**`: `bun install` → Biome check → `tsc --noEmit` → `bun test` → + `bun run verify:consumers` (the standalone Node 26 + Bun consumer fixtures against the + packed tarball). Its `node-version` pin is load-bearing rather than incidental: + `control/scripts/verify-consumers.ts` refuses any major but 26. `cancel-in-progress: true`. - **`.github/workflows/ci-packaging.yml`** — paths-filtered PR + push(`main`) container lane for `packaging/**`: runs the packaging contract scripts in `debian:bookworm`. The four @@ -259,7 +1347,9 @@ major action versions, per-manager caches, weekly grouped Dependabot, test-befor (test-before-publish). 3. **build-deb** (needs [tag-guard, test]) — injects `-~ceralive` (non-tag runs `~ceralive0.0.0~dev`) via `packaging/ci/inject-deb-version.sh`, builds both - arches, runs the package contract suite + daemon smoke, generates the manifest-complete + arches, runs the package contract suite + daemon smoke, builds the `Architecture: all` + companion ONCE (`packaging/ci/build-companion.sh`) and runs its clean-chroot contract + (`packaging/ci/test-companion-chroot.sh`), generates the manifest-complete release manifest (`packaging/ci/generate-release-manifest.sh`), and uploads the `.deb` artifacts + manifest. 4. **publish-npm** (needs [tag-guard, build-deb]) — OIDC trusted publishing @@ -271,14 +1361,92 @@ major action versions, per-manager caches, weekly grouped Dependabot, test-befor pre-create moved-tag re-check, downloads the `.deb` + manifest artifact, assembles a flat asset dir, and reconciles it immutably via `packaging/ci/reconcile-release-assets.sh` (manifest-complete, staged sanitized `~`→`.` names, collision-rejected, existing assets - integrity-compared and never overwritten). + integrity-compared and never overwritten). The flat assembly additionally REFUSES a + duplicate RAW basename before any sanitization can mask it. It then **dispatches + `apt-modem-closure` to `CERALIVE/apt-worker`** — last, so the manifest the publisher + fetches already exists on the release. `CERALIVE_DISPATCH_TOKEN` must be a fine-grained + PAT with **`Contents: read/write`** on the target repo; the repository-dispatch endpoint + is gated on Contents, NOT on `Actions: write`. +- **`.github/workflows/apt-dispatch-preflight.yml`** — proves this repository's + `CERALIVE_DISPATCH_TOKEN` can reach apt-worker's publisher without publishing anything. It + sends `client_payload.preflight=true`, which apt-worker forces to DRY_RUN regardless of its + live default; with no tag it exits before manifest resolution, so it is safe to run BEFORE + the release exists. An operator's own `gh api` call would test the operator's CLI token and + prove nothing about the repository secret — which is exactly why this lives here. `cancel-in-progress: false` (never cancel a release/publish mid-run). Action pins track the latest stable **major** (resolved via the `gh api` releases/latest -endpoint); Dependabot keeps them current. JS/TS CI runs on **Node 24**. +endpoint); Dependabot keeps them current. JS/TS CI runs on **Node 26** — the CeraLive CI +baseline, and the runtime the published tarball's consumer fixture asserts on. ## DOCS DISCIPLINE (Rule A) Any change to this repo's behavior or structure updates this `AGENTS.md`, the relevant `README.md`, and `docs/` in the **same** change. Keep the three-artifact map, the versioning contract, and the no-fork policy authoritative. + +## Huawei HiLink provider (Todo 24) + +`control/src/providers/huawei-hilink/` is the concrete network provider for two exact replay-backed firmware profiles: `e3372h-22.200-password-type-3` and `e3372h-22.333-password-type-4`. The evidence matcher first requires the exact firmware and an unauthenticated `SesTokInfo` document, then makes ONE profile-selected login attempt only after `state-login` confirms that profile's password type. It never tries the neighbouring algorithm. Unknown firmware and profile mismatches receive no operations surface. + +Every HTTP request is interface-bound and redirect-disabled. Credentials, password derivatives, cookies and tokens stay in memory and never enter errors or contract fixtures. Status, signal, network-mode and mobile-data capabilities are probed separately; only mode and mobile-data have writes, and Wi-Fi has no operation at all. Writes serialize per physical modem, acquire the non-queueing `router-session` ownership lease, preflight their own capability, and use a newly authenticated session for exact readback before `applied`. A `125002` or HTTP 401/403 during the write refuses `auth-expired` without another login attempt. Pure XML parsing remains centralized in `hardware/router-parsers` through `hardware/hilink-protocol.ts`. + +## 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 +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`. + +## UFI / HIMI PROVIDER (TODO 26) — READ-ONLY, PLUS THE QUALCOMM PROHIBITION FENCES + +`control/src/providers/ufi-himi/` normalizes the Qualcomm UFI/HIMI telemetry the pure +parsers already own (`hardware/router-parsers.ts` → `observations/sources/ufi.ts`) into a +provider that is **read-only by construction, not by policy**. It adds a session and a +transport around those parsers and nothing else. + +**Read-only is structural in three independent places.** HIMI is one endpoint +(`POST /himiapi/json`) with the verb in the body's `cmdid`, so a method restriction would +prove nothing; the command vocabulary is a frozen union instead — seven `get*` reads plus +`login` — and a write command is therefore UNREPRESENTABLE rather than refused. The +operations surface exposes `ProviderReadOperations` entries verbatim (a type with no +`write` member to omit), every descriptor carries +`support.write: {supported:false, reason:'ufi-himi-provider-is-read-only'}`, and a +structural test asserts the only callables reachable from `operations()` are the two +reads and the pure planner. + +**`05c6:9024` is evidence of a COMPOSITION, not a permission** — RNDIS plus an ADB +interface. **`05c6:9091` is a firmware-chosen product id and is NOT proof of DIAG**; only +an interface descriptor (class `ff`, subclass `ff`, protocol `30`) proves a DIAG channel, +which is what `classifyUfiDiagEvidence()` encodes. Production never falls back to ADB, +SSH, telnet or DIAG under any circumstance, and `UFI_DIAG_PRODUCTION_ACCESS` is +`prohibited` unconditionally — a descriptor-confirmed channel raises only what a +SUPERVISED BENCH operator may attempt by hand ([`docs/UFI-DIAG-PROBE.md`](docs/UFI-DIAG-PROBE.md)). + +**`prohibitions.ts` is INERT DATA, and the operations it names have no implementation +anywhere** — not a refused stub, not a disabled branch. NV/EFS/identity/calibration +writes, firmware flashing, EDL automation, blind driver/interface retries, DIAG writes, +the DIAG info probe (bench-supervised only) and shell transport fallback each answer a +typed reason. `planUfiOperation` takes no transport parameter and returns synchronously, +so `transportContacted: false` is a provable literal; a spy-transport test asserts ZERO +calls both there and for the same ids driven through the real `OperationEngine`, where +the inert descriptor refuses on three independent fences (read unsupported, write +unsupported, availability refused, plus an empty allowed-value set). +`no-write-path.test.ts` scans the comment-stripped provider source for the constructs a +write path would need — subprocess, raw socket, shell-fallback binary, DIAG device node, +mutating HTTP verb, write-shaped literal — each with a non-vacuity control. + +Login is bounded to ONE attempt per physical modem per generation; a `SessionOut` drops +the cached session and surfaces as an honest `auth-expired` reading rather than a retry +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. diff --git a/README.md b/README.md index d88f7cd..3b56aef 100644 --- a/README.md +++ b/README.md @@ -15,17 +15,25 @@ straight from CI artifacts; nothing is published to `apt.ceralive.tv` yet. | Directory | Artifact | What it is | |-----------|----------|------------| -| [`control/`](control/) | **`@ceralive/modem-control`** (npm package) | The TypeScript control library: modem domain model, ModemManager D-Bus backend, NetworkManager adapter, desired-state reconciler, recovery ladder + the `usb-hub-port-cycle` **uhubctl PowerHook** (`control/src/backend/uhubctl-power-hook.ts`, config-mapped port map, disabled by default), 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). Published to the public npm registry under the `@ceralive` scope. | +| [`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. | +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, +interface binding, caches, and writes remain outside those pure parsers. Its existing +root, `./domain`, and `./capabilities` surfaces also expose deterministic compatibility +helpers for portable modem identity, display naming, ModemManager enums, USB-network +classification, capability selection, and shadow-result comparison; these helpers perform +no discovery or transport and leave CeraUI integration to a separate cutover. + ## Versioning at a glance -ONE unified **SemVer** tag `vX.Y.Z` releases **both** artifacts together: `v0.2.0` publishes -`@ceralive/modem-control@0.2.0` to npm **and** the `.deb` artifact set in the same release. +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~ceralive0.2.0`) so apt ordering stays correct. Full contract: +`1.24.2-2~ceralive1.1.0`) so apt ordering stays correct. Full contract: [`docs/VERSIONING.md`](docs/VERSIONING.md). ## Layout @@ -35,8 +43,8 @@ modem-stack/ ├── control/ @ceralive/modem-control — TS control library (Bun workspace member) ├── cli/ modem-control bench CLI (Bun workspace member) ├── packaging/ ModemManager-stack .deb rebuilds + provenance/verification CI -├── docs/ BENCH.md runbooks, CATALOG-INGESTION.md, VERSIONING.md, FM350-DECISION.md, -│ ESIM-DECISION.md +├── docs/ BENCH.md runbooks, CATALOG-INGESTION.md, COMPOSITION-EVIDENCE.md, +│ VERSIONING.md, FM350-DECISION.md, ESIM-DECISION.md ├── AGENTS.md AI routing + repo contract (self-contained; see Rule D) └── POLICY.md no-fork gate + upstream-contribution-first policy ``` @@ -48,9 +56,14 @@ Biome via `@ceralive/biome-config`). `packaging/` is built in a bookworm contain ```sh bun install # install the workspace (control + cli) -bun test # run the workspace test suite +bun test # run the workspace test suite (includes the tarball-shape gate) bun run lint # Biome check bun run typecheck # tsc --noEmit (strict, exactOptionalPropertyTypes) +bun run build # build @ceralive/modem-control into control/dist + +cd control +bun run verify:tarball # pack + assert the published artifact's shape +bun run verify:consumers # standalone Node 26 + Bun consumers of the packed tarball ``` Every command runs from the repository root and needs nothing outside this checkout — @@ -59,3 +72,59 @@ the repo is self-contained (see [`AGENTS.md`](AGENTS.md) → Rule D). ## License AGPL-3.0 + +## Huawei HiLink provider (Todo 24) + +`control/src/providers/huawei-hilink/` implements two exact firmware profiles: E3372H `22.200.05.00.1080` with password type 3 and E3372H `22.333.01.00.00` with password type 4. Firmware and `SesTokInfo` evidence select one profile; `state-login` must confirm its password type before one bounded login attempt, and a mismatch never falls through to another algorithm. Requests are interface-bound and redirect-disabled. Mode and data writes are independently capability-gated, acquire `router-session`, serialize by physical modem, and require a newly authenticated readback session before `applied`. Wi-Fi writes are absent. Credentials, derived hashes, cookies, and tokens remain memory-only and never enter errors or contract fixtures. See [`docs/HUAWEI-HILINK-PROVIDER.md`](docs/HUAWEI-HILINK-PROVIDER.md). + +## 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 +[`docs/MF79U-DIAGNOSIS.md`](docs/MF79U-DIAGNOSIS.md). + +## UFI / HIMI provider (Todo 26) + +`control/src/providers/ufi-himi/` is the Qualcomm UFI/HIMI provider: **read-only by +construction**. The HIMI command vocabulary is a frozen union of seven `get*` reads plus +`login`, so a write command cannot be expressed; `operations()` exposes zero write +descriptors; and the prohibited operations (NV/EFS/identity/calibration writes, firmware +flashing, EDL automation, blind driver/interface retries, DIAG writes, shell transport +fallback) are inert table entries with no implementation anywhere, each answering a typed +refusal before any transport call. `05c6:9024` proves an RNDIS+ADB composition and +`05c6:9091` proves nothing at all — only a DIAG interface descriptor does, and even then +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). + +## Radio capability truth + SIM evidence (Todo 28) + +`control/src/radio/` carries ModemManager's mode and band answers to a consumer without +editing them. A combination whose preferred mask is 0 reads `preferred: 'none'` — the +bench Fibocom FM350-GL's actual answer — and survives verbatim into the mode-write +descriptor's allowed values; a mode bit this build cannot name round-trips as +`mode-bit-` and stays **offered** rather than being coerced to unsupported; a catalog +member that is not a `(uu)` pair is retained rather than dropped. Mode and band writes are +readback-gated, and a band write additionally carries `mutationImpact: 'disruptive'` plus +the per-SKU certification gate from `control/src/band/` — whose catalog ships empty, so +band writes are refused on every fleet device today. + +On the observation layer, SIM absence is EXPLICIT evidence: `absent` comes only from +ModemManager's own `StateFailedReason: sim-missing`, never from a blank `Sim` object path +(which MM also reports while a modem initializes and while a slot switch is in flight). +`Modem.CurrentModes` and `Modem.SignalQuality` are retained as their D-Bus structs, so the +preferred mode and the measurement-recency flag survive normalization. + +## Provider-matching conformance matrix (Todo 27) + +`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 +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 +assert the exact sanitized per-firmware HTTP transcript and a **software upper-bound fixture at +16 concurrently attached modems** (a fixture result — the hardware-verified fleet size remains +8). See [`docs/PROVIDER-MATCHING.md`](docs/PROVIDER-MATCHING.md). diff --git a/biome.json b/biome.json index 38ce7be..bb887b2 100644 --- a/biome.json +++ b/biome.json @@ -1,7 +1,7 @@ { - "$schema": "https://biomejs.dev/schemas/2.5.5/schema.json", + "$schema": "https://biomejs.dev/schemas/2.5.8/schema.json", "extends": ["@ceralive/biome-config"], "files": { - "includes": ["**", "!**/node_modules", "!**/dist", "!test-results"] + "includes": ["**", "!**/node_modules", "!**/dist", "!**/test-results"] } } diff --git a/bun.lock b/bun.lock index e33006d..ae0dc2f 100644 --- a/bun.lock +++ b/bun.lock @@ -5,15 +5,15 @@ "": { "name": "modem-stack", "devDependencies": { - "@biomejs/biome": "2.5.5", - "@ceralive/biome-config": "2026.6.2", + "@biomejs/biome": "2.5.8", + "@ceralive/biome-config": "2026.8.0", "@types/bun": "1.3.14", "typescript": "^5", }, }, "cli": { "name": "modem-control-cli", - "version": "0.2.0", + "version": "1.0.0", "bin": { "modem-control": "./src/index.ts", }, @@ -24,7 +24,7 @@ }, "control": { "name": "@ceralive/modem-control", - "version": "0.2.0", + "version": "1.1.0", "dependencies": { "@httptoolkit/dbus-native": "0.1.5", "zod": "4.4.3", @@ -32,35 +32,35 @@ }, }, "packages": { - "@biomejs/biome": ["@biomejs/biome@2.5.5", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.5.5", "@biomejs/cli-darwin-x64": "2.5.5", "@biomejs/cli-linux-arm64": "2.5.5", "@biomejs/cli-linux-arm64-musl": "2.5.5", "@biomejs/cli-linux-x64": "2.5.5", "@biomejs/cli-linux-x64-musl": "2.5.5", "@biomejs/cli-win32-arm64": "2.5.5", "@biomejs/cli-win32-x64": "2.5.5" }, "bin": { "biome": "bin/biome" } }, "sha512-r1S8nFsAG1MY+vJFZALzIvwXAJv6ejDQ0mxP21Tgr9YK3ZFtjrvbBwDdNhx1rUqvccEIeNg20cYCNzl6Cr69pQ=="], + "@biomejs/biome": ["@biomejs/biome@2.5.8", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.5.8", "@biomejs/cli-darwin-x64": "2.5.8", "@biomejs/cli-linux-arm64": "2.5.8", "@biomejs/cli-linux-arm64-musl": "2.5.8", "@biomejs/cli-linux-x64": "2.5.8", "@biomejs/cli-linux-x64-musl": "2.5.8", "@biomejs/cli-win32-arm64": "2.5.8", "@biomejs/cli-win32-x64": "2.5.8" }, "bin": { "biome": "bin/biome" } }, "sha512-aeAeeJB9fSDc7Gq+2GqpQxA0qBj6gj1k2R6L1cYqGePKP/baIq1WX8y6B+D+nRsO5ViQL22K/8IwbqERW0q1nw=="], - "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.5.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-kUrAhXVWUrwmAUnV2iXSK7umxKFysTwvqK+Ty6ptUcLY/7T3SnCAjUowE4uvwaEej6nXZ7hu/dTtbokKdsPeag=="], + "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.5.8", "", { "os": "darwin", "cpu": "arm64" }, "sha512-mk1QON9PHllvvLN5gU3f4rMxeh4syK5p9OvKyWH6/W8ueh04uaC8TUXXByhGufWf/y5mQc03ZLM45zU+cmqMjA=="], - "@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.5.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-DamiYc5bUYZ2uxlfc+RLEPtz1Abb6PO5eTbOkufLpSGwd/7AMQAdxhFYiXmwwkJL8IsT8S7GvdgwDHqaMFAvKw=="], + "@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.5.8", "", { "os": "darwin", "cpu": "x64" }, "sha512-bsGwFMBNyHPyiLSsQcZJxdoRrg1V4JL+d7wEsvUBczlP9U9lwM+7mzQHxI4o1mhBsTmdOBbAb6fHU3Z3snN45w=="], - "@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.5.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-lRKF/pH/1RiYiBKExi3TCZVAtvzEm77aifrvcNiDFrR9WxeAnDUjDnseb6y2XV85mjitLs6SILGm2XG77cHtSQ=="], + "@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.5.8", "", { "os": "linux", "cpu": "arm64" }, "sha512-XmFiA0WPYFC+uiUDC8WRFzAIH9bo7vwQLav38Uoq4ETC+T/+uBi0TsYGJECkugY3r8USl3jc+Ae2/irAF6F2lQ=="], - "@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.5.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-U4WMl/sy/E/Q73vf15VspakLRRs2LDFcCeBxJnQfXzssb88zpV6PJPaQ3ezhQ7H6Ht2/8bvuZeHgJWzmoxllZg=="], + "@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.5.8", "", { "os": "linux", "cpu": "arm64" }, "sha512-VcJNbstduTHx83NGAdhp78/JOcP45BZHXL7yNsfI1uGzdUgegAz2s+mSoT7wK6PBNzLoqG0zDOXaz/RQYVtSiw=="], - "@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.5.5", "", { "os": "linux", "cpu": "x64" }, "sha512-H/O39nJEw/2Zm/fm7hrmxxoF8kK/aU1uCoPp70ruXVbomaAdLpJJnCmL11Q2JotT8QVHH06So04Oq53lCSwSwQ=="], + "@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.5.8", "", { "os": "linux", "cpu": "x64" }, "sha512-S5wcm9OBDvLHodD4PUaN488hCpco9QD/9ZxuYJiw4euWtr/oQvLR72z2ixItH8Wd5BCm6FZaeb+YNvOoM1xHtQ=="], - "@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.5.5", "", { "os": "linux", "cpu": "x64" }, "sha512-m7wC7tjX5Lrmo69dc4md8FeKpPU1NTCY1v7xUoQQ2vadWwNnBS0KZOG8471otFPHrTHihQJAjQPgMObpLvDe6A=="], + "@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.5.8", "", { "os": "linux", "cpu": "x64" }, "sha512-kKmiyokeISRGq2FLwvr+TzsgBusfxaZ0FZNLcOYOpCK/78tRrEjeEBLvq3xLZMpqbANgJdRPI7vZX8ZL37u9/w=="], - "@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.5.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-7BryINPuYypLUAH3o/o5ZdgomJ4zn3EDR0ChZJst7n32S6ZhKbgHXuYydLu+YAnx59ehGFR0z/MG6qnzQi3Yyw=="], + "@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.5.8", "", { "os": "win32", "cpu": "arm64" }, "sha512-nILH0mzm3Hi3iEdd7o7GpB8kBR/mSQwfQG/tyBqyNrY2GFtcgwfV9nV8xLmbtUpMNY/Oi0Ml1XgfR4flOdq+AA=="], - "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.5.5", "", { "os": "win32", "cpu": "x64" }, "sha512-bIBFo+n6MIxdNcVFy5CrurbKiZQiUciK3bt8+O9I4wjFZNTfXLpi+giq47522eXqW5NBc9ulx7dR1SlZKi2J5g=="], + "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.5.8", "", { "os": "win32", "cpu": "x64" }, "sha512-I2czzXTY61f3nFJxXoMDq80t7MivxDEnCjE+8sDKoFfcKMaoQdkqhIFQ3KyY0XLzeSpUBYeNAXgD+iOV/BU0VA=="], - "@ceralive/biome-config": ["@ceralive/biome-config@2026.6.2", "", {}, "sha512-LqDaJufxLOC3Rm38u/ocpUro/ub8llv1q9pcS3W/Ys7gBy+wLjS2TmcyhMjNMRg9c7pCQlPMI6eS8JkhpN+ecw=="], + "@ceralive/biome-config": ["@ceralive/biome-config@2026.8.0", "", {}, "sha512-TSlMbinvdzGriJWQwTwUUVtXXnRDp8OzE3w2848+1jl5I35tzf+rt8J4B3aSQvwhsLHkGqb93UBvD21+hQ8wQQ=="], "@ceralive/modem-control": ["@ceralive/modem-control@workspace:control"], "@httptoolkit/dbus-native": ["@httptoolkit/dbus-native@0.1.5", "", { "dependencies": { "event-stream": "^4.0.0", "fast-xml-parser": "^5.3.6", "long": "^4.0.0", "safe-buffer": "^5.1.1" } }, "sha512-ygpvPvzb6yyhop/wEDouGvIHzXrLvArIAdyPqy87l3YllS8scyeu6a61I75kgp1QK7QZSRT26wpq6+/egiQwcA=="], - "@nodable/entities": ["@nodable/entities@2.2.0", "", {}, "sha512-9uGyhaQavEUMC8AIddIjau4NsnsXhou+j5sBAGojCM1oxmQpVKTWR/9JxABD6UAv12vpIms55fPZKFQEhG6uBg=="], + "@nodable/entities": ["@nodable/entities@3.0.0", "", {}, "sha512-8L9xFeTYKhm49xfIypoe2W5wV1m/3Z58kT+7kR9A8OyFxcPduI4VmxaUMQyKYrRjUoLLSXv6EKKID5Tvj9cUVw=="], "@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="], - "@types/node": ["@types/node@26.1.1", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw=="], + "@types/node": ["@types/node@26.2.0", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-5IviulTZeRNp2vAJ514cc/HUlY5nZ9fCbq9DMyC52BrhFZACo3nI0R7qBxhQmo/d27NFe96ur/b7Wwxklda+kg=="], "anynum": ["anynum@1.0.1", "", {}, "sha512-N6//FLET/tXYNM/F6ABca1oH6fWB+KlTt909Le28WMDBk8oaT4vY17DCrwg2MvmuqUKt3Ni4N5dGJ/EoBgcO6A=="], @@ -70,13 +70,13 @@ "event-stream": ["event-stream@4.0.1", "", { "dependencies": { "duplexer": "^0.1.1", "from": "^0.1.7", "map-stream": "0.0.7", "pause-stream": "^0.0.11", "split": "^1.0.1", "stream-combiner": "^0.2.2", "through": "^2.3.8" } }, "sha512-qACXdu/9VHPBzcyhdOWR5/IahhGMf0roTeZJfzz077GwylcDd90yOHLouhmv7GJ5XzPi6ekaQWd8AvPP2nOvpA=="], - "fast-xml-builder": ["fast-xml-builder@1.3.0", "", { "dependencies": { "path-expression-matcher": "^1.6.2", "xml-naming": "^0.3.0" } }, "sha512-F74cZEdCvuw9P41GAC3rod4X04jjWGM1JPEv/GWSqFTWLsdyMSBMBMlm9Hk3GLBgLBbdBNY8yee0pQh2RBVESQ=="], + "fast-xml-builder": ["fast-xml-builder@1.3.1", "", { "dependencies": { "path-expression-matcher": "^1.6.2", "xml-naming": "^0.3.0" } }, "sha512-pIM/1n3ntFXKYrUZwW7QCK0gAW7XY+wzj1YMIV3tLDvPj/V+zTGJK5e3/4WJfwj0qWw2ElNXiTixda/R+3YSug=="], - "fast-xml-parser": ["fast-xml-parser@5.10.0", "", { "dependencies": { "@nodable/entities": "^2.2.0", "fast-xml-builder": "^1.2.0", "is-unsafe": "^2.0.0", "path-expression-matcher": "^1.6.2", "strnum": "^2.4.1", "xml-naming": "^0.3.0" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-SLhnTEqE5QpJHq/6zl9bsmImEP2adv+y6Wy+cJa7nVTRzQh1OZfCe9k29M5xN74LWnu0xa1zrUrq3KnOKl92Fg=="], + "fast-xml-parser": ["fast-xml-parser@5.11.0", "", { "dependencies": { "@nodable/entities": "^3.0.0", "fast-xml-builder": "^1.2.0", "is-unsafe": "^2.0.0", "path-expression-matcher": "^1.6.2", "strnum": "^2.4.2", "xml-naming": "^0.3.0" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-9IGxMqvqLOnqP+Egi1nqDHKv5k8aZ7r9n558enxcucmyVGEBNPAU+MOg/8jPIS7rO7sSq4gFm1/nHtiaubMruw=="], "from": ["from@0.1.7", "", {}, "sha512-twe20eF1OxVxp/ML/kq2p1uc6KvFK/+vs8WjEbeKmV2He22MKm7YF2ANIt+EOqhJ5L3K/SuuPhk0hWQDjOM23g=="], - "is-unsafe": ["is-unsafe@2.0.0", "", {}, "sha512-2LdV822R+wmI86unXA93WCFpL6g+av8ynWk0nrHyJqGop5VoocYsSLFgN8jrfalT6iGeLNM4KXuVSsULP53kEA=="], + "is-unsafe": ["is-unsafe@2.0.2", "", {}, "sha512-HgbIHPBH0KHHCcjLfGsCvhtPTVxjaAZlXjwdz7/GQC40SjSe4sfQsar8J5VFo8JOSbarkpV0OLG95bbaNd9aAQ=="], "long": ["long@4.0.0", "", {}, "sha512-XsP+KhQif4bjX1kbuSiySJFNAehNxgLb6hPRGJ9QsUr8ajHkuXGdrHmFUTUUXhDwVX2R5bY4JNZEwbUiMhV+MA=="], @@ -94,7 +94,7 @@ "stream-combiner": ["stream-combiner@0.2.2", "", { "dependencies": { "duplexer": "~0.1.1", "through": "~2.3.4" } }, "sha512-6yHMqgLYDzQDcAkL+tjJDC5nSNuNIx0vZtRZeiPh7Saef7VHX9H5Ijn9l2VIol2zaNYlYEX6KyuT/237A58qEQ=="], - "strnum": ["strnum@2.4.1", "", { "dependencies": { "anynum": "^1.0.1" } }, "sha512-M9eUSMT2dCB2cTNPG7UYj6KuK7RJR2SN2+yCV/fTW3xzTCS6EaGZ5pSMgDIjB7r8zSfTGk+dvvn9rTjpVS9Mwg=="], + "strnum": ["strnum@2.4.2", "", { "dependencies": { "anynum": "^1.0.1" } }, "sha512-rDG3Ah4TV0k1hWvLSzkZtMmLN9+eS+h3knq4MP6A42Y3Yh5qGNnOUs1jJkoSr8FG5dsL28c7KgkIBzSEykqtuw=="], "through": ["through@2.3.8", "", {}, "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg=="], diff --git a/cli/README.md b/cli/README.md index 6734da0..b96378a 100644 --- a/cli/README.md +++ b/cli/README.md @@ -15,7 +15,7 @@ to mature the package and (with `certify`) capture per-SKU certification bundles | `certify ` | Captures a redacted, schema-validated certification bundle: `lsusb -v`, `usb-devices`, the slot's udev properties, an `mmcli -K` dump, a redacted `GetManagedObjects`, and a bounded signal window. `--transition ` adds transition evidence (before/after descriptors, the executed AT command, and the port-drop / re-enumeration timeline) shaped to drop straight into an A4.2 catalog entry. Prints `CERTIFY OK: sha256= …` — the sha256 is the value a reviewer records in a catalog entry's `evidenceBundleSha256`. Real captures are marked `synthetic: false`; ICCID / IMSI / EID are masked; a malformed capture exits non-zero with a clear error rather than writing a broken bundle. `--output ` writes the bundle JSON (default stdout). | | `usage` | Prints the data-usage sampler snapshot (per-slot cumulative-cycle bytes; advisory threshold). | | `unlock-pin [slot]` / `unlock-puk [slot]` | Prompts for the PIN / PUK with **terminal echo disabled** (the secret is never printed back) and submits it. | -| `hil-cycle --hub-map ` | The hardware-in-the-loop port-cycle harness (bench runbook **RB-10**). Captures pre-state (the `/sys/bus/usb/devices/*` sweep — there is no `lsusb` on the bench image — plus `mmcli -L`), drives the `usb-hub-port-cycle` PowerHook for the mapped slot, then asserts the modem **left** the USB bus, **came back** at the same udev `ID_PATH` within the deadline, and that ModemManager re-detects the **same** `modem.generic.device` slot UID. Prints `HIL-CYCLE PASS slot= disappeared= reenumerated=`, or `HIL-CYCLE FAIL slot= reason=` with a named reason (`no-vbus-drop`, `reenumeration-timeout`, `mm-slot-mismatch`, `hub-map-slot-unmapped`, …) and a non-zero exit. **A zero exit from `uhubctl` is never the gate** — the modem's disappearance from the bus is. `` is the modem's udev `ID_PATH` (the hub-map key); `--mm-slot ` names the same modem to ModemManager (defaults to ``). Needs `sudo` for `uhubctl`; refuses with zero side effects when the slot is not in the map. | +| `hil-cycle --hub-map ` | The hardware-in-the-loop port-cycle harness (bench runbook **RB-10**). The executable adapter lives in the CLI, not the published control provider set, and first acquires non-blocking exclusive ownership at `/run/ceralive/modem-control.lock`; contention refuses immediately. It captures pre-state (the `/sys/bus/usb/devices/*` sweep — there is no `lsusb` on the bench image — plus `mmcli -L`), drives the mapped port, then asserts the modem **left** the USB bus, **came back** at the same udev `ID_PATH` within the deadline, and that ModemManager re-detects the **same** `modem.generic.device` slot UID. Prints `HIL-CYCLE PASS slot= disappeared= reenumerated=`, or `HIL-CYCLE FAIL slot= reason=` with a named reason and a non-zero exit. **A zero exit from `uhubctl` is never the gate** — the modem's disappearance from the bus is. Needs `sudo` for `uhubctl`; refuses with zero side effects when the slot is not in the map. | ### Global options diff --git a/cli/src/cli.ts b/cli/src/cli.ts index 399ff51..7decc1c 100644 --- a/cli/src/cli.ts +++ b/cli/src/cli.ts @@ -7,12 +7,11 @@ import { parseArgs } from 'node:util'; import { - createUhubctlPowerHook, + createFlockResourceOwnershipPort, createUsbEnumerator, + DEFAULT_MODEM_CONTROL_LOCK_PATH, MM_USB_MODES, type MmUsbMode, - readUhubctlPortMap, - SpawnUhubctlRunner, } from '@ceralive/modem-control'; import { SpawnCommandRunner } from './certify/command-runner'; import { runApply } from './commands/apply'; @@ -27,6 +26,11 @@ import { createStackContext, type GlobalOptions, type StackContext } from './con import { mmcliSlots, sysfsUsbSweep, usbIdPathPoller } from './hil-probe'; import type { CliIo } from './io'; import { readPolicyFile } from './policy-file'; +import { + createUhubctlPowerHook, + readUhubctlPortMap, + SpawnUhubctlRunner, +} from './uhubctl-power-hook'; import { buildRequestResolver, buildUsageInputs, buildUsbModeTransition } from './wiring'; const HELP = `modem-control — bench CLI for the CeraLive modem stack @@ -162,6 +166,13 @@ export async function runCli(argv: readonly string[], io: CliIo): Promise enumerator.enumerate()); + const ownership = await createFlockResourceOwnershipPort({ + lockPath: DEFAULT_MODEM_CONTROL_LOCK_PATH, + }).acquire({ resource: 'usb-hub' }); + if (ownership.status === 'refused') { + io.err('hil-cycle: modem control is owned by another process'); + return 1; + } try { return await runHilCycle( io, @@ -182,6 +193,8 @@ export async function runCli(argv: readonly string[], io: CliIo): Promise-[.…]` (e.g. `1-1`, `2-1.4`), or a bare diff --git a/control/README.md b/control/README.md new file mode 100644 index 0000000..01d33c4 --- /dev/null +++ b/control/README.md @@ -0,0 +1,313 @@ +# `@ceralive/modem-control` + +Cellular modem control for CeraLive: the frozen v1.1 domain contracts, the provider +registry and evidence-scored matcher, the ModemManager D-Bus backend, the +NetworkManager adapter, the desired-state reconciler, the USB composition-mode model, +the data-usage sampler, and the gated capability modules. + +**This package is a LIBRARY.** It ships no `bin`, no systemd unit, no shebang, and +nothing that opens a listening socket — it is imported by a controller, it is not one. +That claim is checked against the real `bun pm pack` output, not against the source +tree; see [Shape gate](#shape-gate). + +## Install + +```sh +npm install @ceralive/modem-control # or: bun add @ceralive/modem-control +``` + +ESM only (`"type": "module"`). Node 26 and Bun 1.3 are the runtimes the published +tarball is exercised against on every CI run. + +## Public entry points + +Seven specifiers, and nothing else. Every other module is internal and reachable only +through the root entry, so an internal reorganisation is not a breaking change. + +| Specifier | What it carries | +|-----------|-----------------| +| `@ceralive/modem-control` | Everything below plus the ports, backend, reconciler, redaction, SMS, USSD, location and FCC modules | +| `@ceralive/modem-control/domain` | Frozen v1.1 contracts: `PhysicalModemId`, `DeviceGeneration`, `ObservationEnvelope`, `OperationDescriptor` / `OperationResult` | +| `@ceralive/modem-control/providers` | `ProviderDefinition`, the registry, and the evidence-scored matcher | +| `@ceralive/modem-control/capabilities` | The five-state support-claim taxonomy and per-modem capability detection | +| `@ceralive/modem-control/hardware` | The per-SKU hardware model: USB composition modes + certified catalog, and the band vocabulary + band-lock certification | +| `@ceralive/modem-control/transport` | The D-Bus transport seam (no underlying-library type is re-exported) | +| `@ceralive/modem-control/testing` | Public **contract fakes** for consumers' own tests | + +The `./hardware` surface also owns the transport-free response parsers migrated +from CeraUI: SIM-presence evidence plus normalized Huawei HiLink, ZTE goform, +and Qualcomm UFI/HIMI signal, detail, and capability reads. They accept response +bodies only; HTTP sessions, interface binding, retries, caches, and writes remain +consumer-owned. + +The root entry also carries the **observation layer** built on those parsers. It turns a raw +per-vendor payload into one `ObservationEnvelope` in which every +metric records which source produced it and when, and in which a missing value carries a +reason that says whether the source *cannot* report it (`unsupported`) or merely *did not* +on this read. `fresh`, `stale`, `unavailable` and `unknown` are four distinct shapes rather +than a value plus a flag: stale keeps its value, unavailable carries none and never ages into +stale, and unknown says which field is missing and why. Nothing the provider sent is +discarded — every provider-native field is retained verbatim in a typed diagnostics block, +with `unmapped` derived rather than declared. Desired, applied and observed state stay in +three separate slots. + +The migration surface is broader than response parsing but remains pure: the root and +existing `./domain`, `./capabilities`, and `./hardware` entries expose portable physical +identity/link-id derivation, modem presentation rules, ModemManager enum decoding, +USB-network classification and labels, capability-module selection, and shadow-backend +divergence folding. Every helper consumes caller-supplied values or snapshots; none discovers +devices, opens a transport, persists state, or performs a modem write. + +### Typed ModemManager provider + +`createModemManagerProvider({ transport })` returns the concrete `ModemManagerProvider` and its +provider-registry `definition`. Matching is based on the live ObjectManager tree, not a certified +model allowlist, so unknown future modems retain generic mode, signal, SIM, and power controls when +their runtime interfaces/properties advertise them. Its normalized `observe` result uses the root +observation envelope; its lifecycle `start` / `observe` / `stop` methods reuse the epoch-scoped +signal observer. + +### Huawei HiLink provider + +`createHuaweiHiLinkDefinition()` exposes exact E3372H firmware profiles for password types 3 and +4. Firmware plus `SesTokInfo` evidence chooses one profile, and `state-login` must confirm that +profile's password type before the provider makes its only login attempt. Every request is bound to +the injected network interface with redirects disabled. Mode and mobile-data writes acquire +`router-session`, probe their own capability, and require a new authenticated readback before they +can report `applied`; no Wi-Fi write exists. Credentials and session material remain private to the +provider runtime. See [`../docs/HUAWEI-HILINK-PROVIDER.md`](../docs/HUAWEI-HILINK-PROVIDER.md). + +### 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 +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). + +### UFI / HIMI provider — read-only by construction + +`createUfiHimiDefinition()` normalizes the Qualcomm UFI/HIMI telemetry over the vendor's +single `POST /himiapi/json` endpoint. Because that API puts its verb in the request +body's `cmdid` rather than in the HTTP method, read-only is enforced as a **frozen +command vocabulary** — seven `get*` reads plus `login` — so a write command cannot be +expressed at all. `operations()` returns `ProviderReadOperations` entries verbatim and +exposes zero write descriptors. + +The prohibited Qualcomm operations — NV, EFS, identity and calibration writes, firmware +flashing, EDL automation, blind driver/interface retries, DIAG writes, the DIAG info +probe, and shell transport fallback — are inert table entries with **no implementation +anywhere**. `planUfiOperation()` answers each with a typed reason and takes no transport +parameter, so the refusal provably precedes any device contact; the same ids driven +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). + +### NetworkManager adapter — saved vs applied + +`new NetworkManagerAdapter({ port })` is the bearer/APN authority, and the only one. It holds +three separate slots per NM connection: the **desired** profile (what an operator asked for, +recorded from the request), the **applied** bearer (what NM actually put into force, recorded +from NM's readback, together with the interface it landed on), and the **observed** device +state. `observe()` folds one complete NM readout and reports a typed applied-state LOSS — +`interface-absent`, `interface-detached`, `connection-replaced`, or `activation-failed` — while +leaving the desired profile untouched, so a modem that re-enumerates costs you the bearer and +never the configuration. A device still settling is reported `pending` rather than lost, and a +readout from a superseded generation is refused rather than folded late. + +It composes the existing `NmcliNmPort` rather than replacing it, performs no radio, band, SIM +or power operation, keys every slot by NM's connection UUID rather than by a physical modem +identity, mirrors no credential into a state slot, and has no profile-delete path. + +The operation surface composes the existing radio/band backend, GPS location adapter and bounded +fix-state machine, read-only SMS port, USSD session adapter, and FCC coverage catalog. Generic band +reads are always runtime-driven; disruptive band writes additionally require a certification +catalog entry for the device's SKU. It contains no bearer/APN authority and no command-line +fallback. The provider never invokes `mmcli`, `qmicli`, or `mbimcli`—those remain operator +diagnostics only. + +### Radio capability truth — the modem's own catalog, unedited + +The root export also carries the mode/band **capability truth** layer. `SupportedModes` +and `CurrentModes` are decoded without loss: a combination whose preferred mask is 0 +reads `preferred: 'none'` — a value, not a missing field — and reaches +`descriptor.constraints.values` exactly as the modem stated it. A mode bit this build +does not name round-trips as `mode-bit-`, its combination is classified +`unknown-combination`, and it stays **offered**; a catalog member that is not a `(uu)` +pair is retained in `undecodable` rather than dropped, so decoded plus undecodable is +always the member count the modem sent. A selection the modem never advertised is +refused, never rounded to the nearest one. + +`modes` and `bands` are typed write operations with **required readback**: the daemon +accepting `SetCurrentModes` / `SetCurrentBands` only proves the call was accepted, so +the applied value is re-read and compared before either reports success. `bands` +additionally carries `mutationImpact: 'disruptive'`, a `band-certification-present` +live precondition, and an availability that is `refused` with +`band-certification-required` unless the device's SKU resolves to an entry in the +band-lock certification catalog — which ships empty, so that is today's answer for every +device. Supply `bandSku` to `createModemManagerProvider` to resolve one; +ModemManager exposes no USB `vid:pid`, so the package cannot build a `BandSku` alone. + +Both operations expose `describe(context)` alongside their static `descriptor`, because +a static descriptor cannot carry a device's own catalog or its certification state. + +### SIM presence is evidence, never inference + +`readSimPresence` returns the presence together with the `SimPresenceEvidence` that +decided it, and `absent` is reachable through exactly one evidence kind — ModemManager's +own `StateFailedReason: sim-missing`. A blank `Sim` object path proves nothing (MM +reports `/` while a modem initializes and while a slot switch is in flight) and reads +`unknown`. `ModemManagerSimState.present` is positive evidence only: `false` is not a +claim of absence. The Huawei, ZTE and UFI sources still claim no presence at all and now +NAME the vendor code they left undecoded, which stays verbatim in the diagnostics block. + +`Modem.CurrentModes` and `Modem.SignalQuality` are retained as the D-Bus structs they +are, so the preferred mode and the measurement-recency flag survive normalization; +`NormalizedSignal.qualityRecent` claims that flag, and the router sources answer +`unsupported` for it. + +### Mutation safety ports + +The root export includes `MutationAdmissionPort`, `ResourceOwnershipPort`, +`ModemManagerInhibitPort`, and `UhubctlPort`. Admission remains consumer-owned: a required +mutation without an injected admission port is refused as `admission-port-missing`; this +package does not know or infer why the consumer refused it. + +File stores, router sessions, and USB-hub access use acquire-or-refuse exclusive ownership. +`createFlockResourceOwnershipPort({ lockPath })` is the Linux adapter: non-blocking `flock`, +holder PID/start-time metadata, and clean release when the holder process dies. The lock path +is mandatory input; `DEFAULT_MODEM_CONTROL_LOCK_PATH` is only a conventional value callers +may select. There is no pass-through ownership implementation. + +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 +inject one and own its executable policy. + +### Descriptor-gated operation engine + +`createOperationEngine()` executes `OperationDescriptor` contracts through that composition +root. Every mutation enters the root's shared physical-modem actor before its live preconditions +and admission are checked. Writes are therefore single-flight per physical modem, and a mutation +that waited in the queue cannot reuse facts checked before it waited. Reads do not occupy the +write queue; the engine retries only a failed read whose descriptor explicitly says +`idempotent-read`, once. + +A stale-generation completion or a timed-out/dropped write reply is classified by the frozen +domain helper as `unknown-outcome`. The engine then closes a per-`PhysicalModemId` mutation gate: +subsequent mutations are refused as `reconciliation-required` without calling the provider. +`engine.reconcile()` uses the same actor and reopens the gate only when reconciliation finishes in +the requested current generation. Required readback, rollback, and journal hooks are checked before +execution and fired according to the descriptor; an unknown outcome is never treated as a definite +failure that is safe to roll back. + +### Transaction journal — the path comes from you + +`createFileJournalStore({ path })` and `createJournalEngine({ store })` are the durable +half of that reconciliation gate. The engine satisfies the operation engine's +`OperationJournalHook`, so it can be handed straight to an `OperationExecution` as its +`journal`, and `engine.recover()` reads the file back after a restart and reports which +operations were still `pending` and which ended `unknown-outcome` — the set a controller +must reconcile before it mutates those modems again. + +**The path is required and this package has no default for it.** Where a journal lives is +a property of the system embedding this library, not of the library, so there is no +fallback location to accidentally write to. An empty path is refused with +`JournalPathError`. + +The store is append-only and never truncates itself. A record it cannot decode is returned +as typed damage alongside every record that *did* decode — including the ones after it — so +a single corrupt line can never take the rest of the journal with it. Call +`assertJournalIntact(recovery)` to escalate that damage to a `JournalRecoveryError` once +you have seen what survived. Neither an operation's input nor its returned value is ever +written to disk. + +`readLegacyCeraUiJournal({ dir })` reads an older per-modem snapshot journal into the same +recovery model, so a consumer migrating onto this package can enumerate outstanding work +from files written before it existed. It only ever reads. + +### `./testing` is the contract-fakes surface + +A consumer writing tests against this package needs valid instances of the domain and +provider contracts. Hand-rolling them is how a consumer's fixtures come to disagree +with the package — a hand-written `OperationResult` literal quietly stops matching what +`classifyOperationCompletion` actually returns. Every fake in `./testing` is built +through this package's own constructors and classifiers, so it cannot express a shape +the domain refuses. + +```ts +import { createProviderMatcher, createProviderRegistry } from '@ceralive/modem-control/providers'; +import { fakeProviderDefinition, fakeProviderMatchRequest } from '@ceralive/modem-control/testing'; + +const registry = createProviderRegistry(); +registry.register(fakeProviderDefinition({ observation: { registered: true } })); + +const result = await createProviderMatcher(registry).match(fakeProviderMatchRequest()); +``` + +`./testing` is pure data and functions — no bus, no daemon, no process, no filesystem. +It is **not** the repository's `test-support/` directory, which holds the heavy +internals this package's own tests use (an MM-faithful fake D-Bus service on a private +session bus, a stateful `nmcli` harness, and the provider-matching conformance corpus). +Those are unpublished and are not a reusable surface. + +### Provider-matching conformance matrix + +`src/providers/conformance-matrix.test.ts` runs 20 cases — nine fleet profiles plus +eleven ambiguity / malformed / auth-expired / lockout / unknown-firmware / +wrong-interface / wrong-transport cases — with the ModemManager, Huawei HiLink, ZTE +goform and UFI/HIMI providers **all registered at once**, asserting the exact provider, +profile, writability and evidence score per case. A companion suite asserts the exact +per-firmware HTTP transcript (method, path, form/JSON/XML body, header order, cookie, +and request count), and a third is a **software upper-bound fixture at 16 concurrently +attached modems** — a fixture result, not a hardware claim; the bench-verified fleet +size remains 8. See [`../docs/PROVIDER-MATCHING.md`](../docs/PROVIDER-MATCHING.md). + +## Build + +```sh +bun run build # tsc -> dist/ (ESM + .d.ts), then fully specify every relative specifier +bun run verify:tarball # pack and assert the published artifact's shape +bun run verify:consumers # install the tarball into standalone Node 26 + Bun projects and import every subpath +``` + +`dist/` mirrors `src/` one-to-one rather than being bundled. Bundling with code +splitting produced an entry whose `export { … }` list named symbols the file never +imported — accepted by one loader, a `SyntaxError` in another. Bundling *without* +splitting instead gives each subpath its own copy of the shared modules, which silently +breaks `instanceof` across two subpaths of the same package. A 1:1 emit has exactly one +instance of every module. + +Because `tsc` never rewrites a specifier and this package's sources are written for +bundler resolution, `scripts/build.ts` rewrites each emitted `./x` into `./x.js` or +`./x/index.js` — resolved against the emit itself — and fails the build if one +extensionless specifier survives. + +`prepack` runs the build, so `npm pack` / `bun pm pack` can never publish a stale `dist/`. + +## Shape gate + +`scripts/tarball-shape.ts` runs six rules over the extracted tarball, driven from +`bun test` (`scripts/tarball-shape.test.ts`) and from the CLI +(`scripts/assert-tarball-shape.ts`): + +1. no raw source ships — the published surface is built output; +2. `dist/` actually contains JavaScript and declarations; +3. every declared public entry is in the exports map **and** its files are packed; +4. no subpath beyond the declared set — internal barrels stay internal; +5. no export target, `main` or `types` points outside `./dist/`; +6. the library-only proof: no `bin`, no systemd unit, no shebang, no listening-socket + construct. + +The declared entries live in `scripts/entries.ts` and the test additionally spells the +seven specifiers out as a literal, so a subpath cannot be dropped without a reviewable +change to the public contract. + +## License + +AGPL-3.0 diff --git a/control/fixtures/check-public-surface.mjs b/control/fixtures/check-public-surface.mjs new file mode 100644 index 0000000..6347214 --- /dev/null +++ b/control/fixtures/check-public-surface.mjs @@ -0,0 +1,98 @@ +// Standalone consumer check for the PACKED `@ceralive/modem-control` tarball. +// +// Both fixtures (Node and Bun) run this exact file, so a divergence between the two +// runtimes is a runtime difference and never a difference in what was asserted. +// +// It imports every public subpath by SPECIFIER, so it exercises the published exports +// map rather than a file path — a subpath missing from `exports` fails here even +// though the file it would have pointed at is sitting in the tarball. + +const failures = []; + +function check(label, condition) { + if (!condition) { + failures.push(label); + } +} + +const PUBLIC_SUBPATHS = [ + '@ceralive/modem-control', + '@ceralive/modem-control/transport', + '@ceralive/modem-control/domain', + '@ceralive/modem-control/providers', + '@ceralive/modem-control/capabilities', + '@ceralive/modem-control/hardware', + '@ceralive/modem-control/testing', +]; + +// 1 — every declared subpath resolves and evaluates. +const modules = new Map(); +for (const specifier of PUBLIC_SUBPATHS) { + const module = await import(specifier); + modules.set(specifier, module); + check(`${specifier} exported nothing`, Object.keys(module).length > 0); +} + +// 2 — each subpath actually carries the surface it is named for. +const root = modules.get('@ceralive/modem-control'); +check('root PACKAGE_NAME', root.PACKAGE_NAME === '@ceralive/modem-control'); + +const transport = modules.get('@ceralive/modem-control/transport'); +check('transport createDbusTransport', typeof transport.createDbusTransport === 'function'); +check('transport TransportError', typeof transport.TransportError === 'function'); + +const domain = modules.get('@ceralive/modem-control/domain'); +check('domain physicalModemId', typeof domain.physicalModemId === 'function'); +check( + 'domain classifyOperationCompletion', + typeof domain.classifyOperationCompletion === 'function', +); + +const providers = modules.get('@ceralive/modem-control/providers'); +check('providers createProviderRegistry', typeof providers.createProviderRegistry === 'function'); +check('providers createProviderMatcher', typeof providers.createProviderMatcher === 'function'); + +const capabilities = modules.get('@ceralive/modem-control/capabilities'); +check('capabilities resolveSupportClaim', typeof capabilities.resolveSupportClaim === 'function'); +check('capabilities CAPABILITY_MODULES', Array.isArray(capabilities.CAPABILITY_MODULES)); + +const hardware = modules.get('@ceralive/modem-control/hardware'); +check('hardware bandName', typeof hardware.bandName === 'function'); +check('hardware findCatalogEntry', typeof hardware.findCatalogEntry === 'function'); +check('hardware CERTIFIED_CATALOG', hardware.CERTIFIED_CATALOG !== undefined); + +const testing = modules.get('@ceralive/modem-control/testing'); +check('testing fakeProviderDefinition', typeof testing.fakeProviderDefinition === 'function'); +check('testing fakeFreshObservation', typeof testing.fakeFreshObservation === 'function'); + +// 3 — the built code RUNS, not just resolves: drive the real registry + matcher +// through the published `./testing` fakes, across three separate subpaths. +const registry = providers.createProviderRegistry(); +registry.register(testing.fakeProviderDefinition({ observation: { registered: true } })); +const match = await providers + .createProviderMatcher(registry) + .match(testing.fakeProviderMatchRequest()); +check(`matcher selected a provider (got ${match.status})`, match.status === 'selected'); +check('matcher named the fake provider', match.provider === 'contract-fake-provider'); + +// 4 — a domain constructor still refuses what it must, from the built bundle. +let refused = false; +try { + domain.physicalModemId('/org/freedesktop/ModemManager1/Modem/0'); +} catch { + refused = true; +} +check('domain refuses an MM object path as a physical identity', refused); + +// 5 — the band vocabulary decoded from the bundled JSON catalog still answers. +check('hardware band 3 is eutran-3', hardware.bandName(33) !== undefined); + +if (failures.length > 0) { + console.error(`FAIL (${failures.length}):`); + for (const failure of failures) { + console.error(` - ${failure}`); + } + process.exit(1); +} + +console.log(`OK — ${PUBLIC_SUBPATHS.length} public subpaths imported and exercised`); diff --git a/control/fixtures/consumer-bun/package.json b/control/fixtures/consumer-bun/package.json new file mode 100644 index 0000000..18f423a --- /dev/null +++ b/control/fixtures/consumer-bun/package.json @@ -0,0 +1,10 @@ +{ + "name": "modem-control-consumer-bun", + "version": "0.0.0", + "private": true, + "type": "module", + "description": "Standalone Bun consumer of the packed @ceralive/modem-control tarball. The tarball is installed by scripts/verify-consumers.ts; it is deliberately not a committed dependency, because its filename carries the package version.", + "scripts": { + "check": "bun ./check.mjs" + } +} diff --git a/control/fixtures/consumer-node/package.json b/control/fixtures/consumer-node/package.json new file mode 100644 index 0000000..47f509f --- /dev/null +++ b/control/fixtures/consumer-node/package.json @@ -0,0 +1,10 @@ +{ + "name": "modem-control-consumer-node", + "version": "0.0.0", + "private": true, + "type": "module", + "description": "Standalone Node 26 consumer of the packed @ceralive/modem-control tarball. The tarball is installed by scripts/verify-consumers.ts; it is deliberately not a committed dependency, because its filename carries the package version.", + "scripts": { + "check": "node ./check.mjs" + } +} diff --git a/control/package.json b/control/package.json index e47d0a8..cc7b9a9 100644 --- a/control/package.json +++ b/control/package.json @@ -1,6 +1,6 @@ { "name": "@ceralive/modem-control", - "version": "1.0.0", + "version": "1.1.0", "type": "module", "description": "Cellular modem control for CeraLive — ModemManager D-Bus backend, NetworkManager adapter, desired-state reconciler, USB composition-mode model, data-usage sampler.", "license": "AGPL-3.0", @@ -13,14 +13,48 @@ "registry": "https://registry.npmjs.org/", "access": "public" }, + "sideEffects": false, + "main": "./dist/index.js", + "types": "./dist/index.d.ts", "exports": { - ".": "./src/index.ts", - "./transport": "./src/transport/index.ts" + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + }, + "./transport": { + "types": "./dist/transport/index.d.ts", + "import": "./dist/transport/index.js" + }, + "./domain": { + "types": "./dist/domain/index.d.ts", + "import": "./dist/domain/index.js" + }, + "./providers": { + "types": "./dist/providers/index.d.ts", + "import": "./dist/providers/index.js" + }, + "./capabilities": { + "types": "./dist/capability/index.d.ts", + "import": "./dist/capability/index.js" + }, + "./hardware": { + "types": "./dist/hardware/index.d.ts", + "import": "./dist/hardware/index.js" + }, + "./testing": { + "types": "./dist/testing/index.d.ts", + "import": "./dist/testing/index.js" + }, + "./package.json": "./package.json" }, "files": [ - "src" + "dist" ], "scripts": { + "build": "bun run scripts/build.ts", + "prepack": "bun run build", + "verify:consumers": "bun run scripts/verify-consumers.ts", + "verify:tarball": "bun run scripts/assert-tarball-shape.ts", "test": "bun test", "typecheck": "tsc --noEmit" }, diff --git a/control/scripts/assert-tarball-shape.ts b/control/scripts/assert-tarball-shape.ts new file mode 100644 index 0000000..6c5481d --- /dev/null +++ b/control/scripts/assert-tarball-shape.ts @@ -0,0 +1,26 @@ +#!/usr/bin/env bun +/** + * CLI: pack `@ceralive/modem-control` and assert the published artifact's shape. + * + * Exits non-zero with every violation named. Run it directly (`bun run + * scripts/assert-tarball-shape.ts`); `scripts/tarball-shape.test.ts` drives the same + * rules from `bun test`. + */ +import { packTarball } from './pack-tarball'; +import { allViolations } from './tarball-shape'; + +const packed = await packTarball(); +const violations = await allViolations(packed.shape); + +console.log(`tarball: ${packed.tarball}`); +console.log(`entries: ${packed.listing.length}`); + +if (violations.length > 0) { + console.error(`\n${violations.length} shape violation(s):`); + for (const violation of violations) { + console.error(` - ${violation}`); + } + process.exit(1); +} + +console.log('tarball shape OK — built ESM + declarations, no raw source, no service artifact'); diff --git a/control/scripts/build.ts b/control/scripts/build.ts new file mode 100644 index 0000000..c2c90cd --- /dev/null +++ b/control/scripts/build.ts @@ -0,0 +1,150 @@ +/** + * Build `@ceralive/modem-control` into `dist/`: ESM JavaScript + `.d.ts`, emitted by + * `tsc`, then post-processed so every relative specifier is fully specified. + * + * WHY THE POST-PROCESS EXISTS. The package's sources use extensionless relative + * specifiers (`moduleResolution: bundler`), which Node's ESM loader cannot resolve, and + * `tsc` never rewrites a specifier — it emits what you wrote. So the emitted `./x` + * becomes `./x.js` or `./x/index.js` here, resolved against the emit itself rather than + * guessed. `verify()` then fails the build if a single extensionless specifier survives. + * + * WHY NOT A BUNDLER. `Bun.build --splitting` was the first attempt and it emitted an + * entry chunk whose `export { … }` list named symbols the file never imported — Bun's + * own loader accepts it, Node answers `SyntaxError: Export 'BigIntRequiredError' is not + * defined in module`. Bundling without splitting avoids that but gives each subpath its + * own copy of the shared modules, so `instanceof DomainError` stops working across two + * subpaths of the same package. A 1:1 emit has one instance of every module, keeps + * `dist` a readable mirror of `src`, and keeps each `.d.ts` beside the `.js` it describes. + */ +import { existsSync } from 'node:fs'; +import { mkdir, readdir, readFile, rm, stat, writeFile } from 'node:fs/promises'; +import { dirname, join, resolve } from 'node:path'; +import { PUBLIC_ENTRIES } from './entries'; + +const PACKAGE_DIR = resolve(import.meta.dir, '..'); +const DIST_DIR = join(PACKAGE_DIR, 'dist'); + +/** `from './x'`, `export * from '../y'`, and bare side-effect `import './z'`. */ +const RELATIVE_SPECIFIER = /(\bfrom\s*|\bimport\s*)(['"])(\.{1,2}\/[^'"]*)\2/g; +const ALREADY_SPECIFIED = /\.(js|json|mjs|cjs)$/; + +async function emittedModules(): Promise { + const found: string[] = []; + for (const entry of await readdir(DIST_DIR, { withFileTypes: true, recursive: true })) { + if (entry.isFile() && (entry.name.endsWith('.js') || entry.name.endsWith('.d.ts'))) { + found.push(join(entry.parentPath, entry.name)); + } + } + return found; +} + +/** + * Resolve one relative specifier against the emit. + * + * A declaration resolves against sibling declarations and a module against sibling + * modules, so a `.d.ts` never ends up pointing at a file that only exists as JavaScript + * (or the reverse). + */ +function fullySpecify(fromFile: string, specifier: string): string | null { + const base = resolve(dirname(fromFile), specifier); + const candidates: readonly (readonly [string, string])[] = fromFile.endsWith('.d.ts') + ? [ + [`${base}.d.ts`, `${specifier}.js`], + [join(base, 'index.d.ts'), `${specifier}/index.js`], + ] + : [ + [`${base}.js`, `${specifier}.js`], + [join(base, 'index.js'), `${specifier}/index.js`], + ]; + for (const [probe, rewritten] of candidates) { + if (existsSync(probe)) { + return rewritten; + } + } + return null; +} + +async function fullySpecifyEmit(): Promise { + const unresolved: string[] = []; + for (const file of await emittedModules()) { + const source = await readFile(file, 'utf8'); + const rewritten = source.replace( + RELATIVE_SPECIFIER, + (match, keyword: string, quote: string, specifier: string) => { + if (ALREADY_SPECIFIED.test(specifier)) { + return match; + } + const next = fullySpecify(file, specifier); + if (next === null) { + unresolved.push(`${file}: ${specifier}`); + return match; + } + return `${keyword}${quote}${next}${quote}`; + }, + ); + if (rewritten !== source) { + await writeFile(file, rewritten); + } + } + if (unresolved.length > 0) { + throw new Error(`unresolvable relative specifiers:\n ${unresolved.join('\n ')}`); + } +} + +async function verify(): Promise { + const missing: string[] = []; + for (const entry of PUBLIC_ENTRIES) { + for (const artifact of [entry.js, entry.types]) { + const path = join(PACKAGE_DIR, artifact); + if (!existsSync(path) || (await stat(path)).size === 0) { + missing.push(`${entry.subpath} -> ${artifact}`); + } + } + } + if (missing.length > 0) { + throw new Error(`build produced no artifact for:\n ${missing.join('\n ')}`); + } + + const leftovers: string[] = []; + for (const file of await emittedModules()) { + const contents = await readFile(file, 'utf8'); + for (const found of contents.matchAll(RELATIVE_SPECIFIER)) { + const specifier = found[3] as string; + if (!ALREADY_SPECIFIED.test(specifier)) { + leftovers.push(`${file}: ${specifier}`); + } + } + } + if (leftovers.length > 0) { + throw new Error(`emit kept extensionless specifiers:\n ${leftovers.join('\n ')}`); + } +} + +await rm(DIST_DIR, { recursive: true, force: true }); +await mkdir(DIST_DIR, { recursive: true }); + +// Bun hoists the workspace's `typescript` to the repo root, but a non-hoisted install +// puts it in the package. Probe both rather than assume one layout. +const tscBin = [ + join(PACKAGE_DIR, 'node_modules', '.bin', 'tsc'), + join(PACKAGE_DIR, '..', 'node_modules', '.bin', 'tsc'), +].find((candidate) => existsSync(candidate)); +if (tscBin === undefined) { + throw new Error( + 'tsc not found in control/node_modules or the workspace root — run `bun install`', + ); +} + +const tsc = Bun.spawnSync([tscBin, '-p', 'tsconfig.build.json'], { + cwd: PACKAGE_DIR, + stdout: 'inherit', + stderr: 'inherit', +}); +if (tsc.exitCode !== 0) { + throw new Error(`tsc failed with exit code ${tsc.exitCode}`); +} + +await fullySpecifyEmit(); +await verify(); + +console.log(`built ${PUBLIC_ENTRIES.length} public entries into dist/`); diff --git a/control/scripts/entries.ts b/control/scripts/entries.ts new file mode 100644 index 0000000..c459567 --- /dev/null +++ b/control/scripts/entries.ts @@ -0,0 +1,66 @@ +/** + * The PUBLIC entry map of `@ceralive/modem-control`. + * + * This is the single source of truth the build, the exports map and the tarball-shape + * gate all read. A subpath that is not listed here is not published, and a subpath + * listed here that is missing from `package.json` `exports` fails the shape gate — so + * the two cannot drift. + * + * Adding a row is a deliberate widening of the public surface: it exposes a module to + * every consumer forever. Internal barrels (`src/backend`, `src/ports`, `src/sms`, + * `src/ussd`, `src/location`, `src/fcc`, `src/redact`) are reachable only through the + * root entry and are deliberately absent. + */ +export type PublicEntry = { + /** The `exports` specifier, e.g. `.` or `./testing`. */ + readonly subpath: string; + /** Source entry point, relative to the package directory. */ + readonly source: string; + /** Built ESM output, relative to the package directory. */ + readonly js: string; + /** Emitted declaration, relative to the package directory. */ + readonly types: string; +}; + +export const PUBLIC_ENTRIES: readonly PublicEntry[] = [ + { subpath: '.', source: 'src/index.ts', js: 'dist/index.js', types: 'dist/index.d.ts' }, + { + subpath: './transport', + source: 'src/transport/index.ts', + js: 'dist/transport/index.js', + types: 'dist/transport/index.d.ts', + }, + { + subpath: './domain', + source: 'src/domain/index.ts', + js: 'dist/domain/index.js', + types: 'dist/domain/index.d.ts', + }, + { + subpath: './providers', + source: 'src/providers/index.ts', + js: 'dist/providers/index.js', + types: 'dist/providers/index.d.ts', + }, + { + subpath: './capabilities', + source: 'src/capability/index.ts', + js: 'dist/capability/index.js', + types: 'dist/capability/index.d.ts', + }, + { + subpath: './hardware', + source: 'src/hardware/index.ts', + js: 'dist/hardware/index.js', + types: 'dist/hardware/index.d.ts', + }, + { + subpath: './testing', + source: 'src/testing/index.ts', + js: 'dist/testing/index.js', + types: 'dist/testing/index.d.ts', + }, +] as const; + +/** Every published specifier, in declaration order. */ +export const PUBLIC_SUBPATHS: readonly string[] = PUBLIC_ENTRIES.map((entry) => entry.subpath); diff --git a/control/scripts/mf79u-diagnose.sh b/control/scripts/mf79u-diagnose.sh new file mode 100755 index 0000000..66e4a91 --- /dev/null +++ b/control/scripts/mf79u-diagnose.sh @@ -0,0 +1,48 @@ +#!/usr/bin/env bash +set -euo pipefail + +: "${MF79U_BENCH_PASSWORD:?set MF79U_BENCH_PASSWORD for this one bench attempt}" +: "${MF79U_REDACTED_CAPTURE:?set MF79U_REDACTED_CAPTURE to one redacted browser request shape}" + +admin_url="${MF79U_ADMIN_URL:-http://192.168.0.1}" +interface_name="${MF79U_INTERFACE:-usb0}" +expected_shape=$'METHOD POST\nPATH /goform/goform_set_cmd_process\nHEADER content-type\nHEADER origin\nHEADER referer\nFORM goformId\nFORM isTest\nFORM password' + +if [[ ! -r "$MF79U_REDACTED_CAPTURE" ]] || [[ "$(<"$MF79U_REDACTED_CAPTURE")" != "$expected_shape" ]]; then + printf '%s\n' 'protocol-mismatch' + exit 2 +fi + +headers_file="$(mktemp)" +chmod 600 "$headers_file" +trap 'rm -f "$headers_file"' EXIT + +encoded_password="$(printf '%s' "$MF79U_BENCH_PASSWORD" | base64 | tr -d '\n')" +request_body="goformId=LOGIN&isTest=false&password=$(printf '%s' "$encoded_password" | jq -sRr @uri)" + +response_body="$({ + printf '%s' "$request_body" +} | curl --silent --show-error --max-time 10 --interface "$interface_name" \ + --request POST \ + --header 'Content-Type: application/x-www-form-urlencoded; charset=UTF-8' \ + --header "Origin: $admin_url" \ + --header "Referer: $admin_url/index.html" \ + --data-binary @- \ + --dump-header "$headers_file" \ + "$admin_url/goform/goform_set_cmd_process")" + +if [[ "$response_body" == *'"result":"0"'* ]] && grep -Eiq '^set-cookie:[[:space:]]*stok=' "$headers_file"; then + printf '%s\n' 'auth-accepted' + exit 0 +fi +if [[ "$response_body" == *'"result"'* ]]; then + printf '%s\n' 'auth-rejection' + exit 3 +fi +if [[ "$response_body" == *'"LD"'* ]] || [[ "$response_body" == *'LOGIN_MULTI_USER'* ]]; then + printf '%s\n' 'protocol-mismatch' + exit 2 +fi + +printf '%s\n' 'lockout-unknown' +exit 4 diff --git a/control/scripts/pack-tarball.ts b/control/scripts/pack-tarball.ts new file mode 100644 index 0000000..019f6cc --- /dev/null +++ b/control/scripts/pack-tarball.ts @@ -0,0 +1,60 @@ +/** + * Pack `@ceralive/modem-control` with `bun pm pack` and extract the result. + * + * `bun pm pack` runs the package's own `prepack` (`bun run build`), so what is + * inspected here is always a freshly built artifact rather than whatever happened to + * be lying in `dist/`. + * + * Output lands in the gitignored, package-local `test-results/pack/`, which `files: + * ["dist"]` keeps out of the tarball — the shape gate re-proves that every run. + */ +import { mkdir, readdir, rm } from 'node:fs/promises'; +import { join, resolve } from 'node:path'; +import { readExtractedTarball, type TarballShape } from './tarball-shape'; + +const PACKAGE_DIR = resolve(import.meta.dir, '..'); + +export type PackedTarball = { + /** Absolute path of the `.tgz`. */ + readonly tarball: string; + /** `tar -tf` output, verbatim, one entry per line (`package/...`). */ + readonly listing: readonly string[]; + /** The extracted artifact, ready for the shape rules. */ + readonly shape: TarballShape; +}; + +function run(command: string[], cwd: string): string { + const result = Bun.spawnSync(command, { cwd, stdout: 'pipe', stderr: 'pipe' }); + if (result.exitCode !== 0) { + throw new Error( + `${command.join(' ')} failed (${result.exitCode}):\n${result.stderr.toString()}`, + ); + } + return result.stdout.toString(); +} + +/** Pack, list and extract. `workDir` defaults to the package-local `test-results/pack`. */ +export async function packTarball(workDir?: string): Promise { + const dir = workDir ?? join(PACKAGE_DIR, 'test-results', 'pack'); + await rm(dir, { recursive: true, force: true }); + await mkdir(dir, { recursive: true }); + + run(['bun', 'pm', 'pack', '--destination', dir], PACKAGE_DIR); + + const packed = (await readdir(dir)).filter((name) => name.endsWith('.tgz')); + if (packed.length !== 1) { + throw new Error(`expected exactly one .tgz in ${dir}, found ${packed.length}`); + } + const tarball = join(dir, packed[0] as string); + + const listing = run(['tar', '-tf', tarball], dir) + .split('\n') + .map((line) => line.trim()) + .filter((line) => line.length > 0); + + const extracted = join(dir, 'extracted'); + await mkdir(extracted, { recursive: true }); + run(['tar', '-xzf', tarball, '-C', extracted], dir); + + return { tarball, listing, shape: await readExtractedTarball(join(extracted, 'package')) }; +} diff --git a/control/scripts/tarball-shape.test.ts b/control/scripts/tarball-shape.test.ts new file mode 100644 index 0000000..728da0b --- /dev/null +++ b/control/scripts/tarball-shape.test.ts @@ -0,0 +1,204 @@ +import { beforeAll, describe, expect, test } from 'bun:test'; +import { mkdir, mkdtemp, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { PUBLIC_ENTRIES, PUBLIC_SUBPATHS } from './entries'; +import { type PackedTarball, packTarball } from './pack-tarball'; +import { + allViolations, + builtOutputViolations, + exportTargetViolations, + publicEntryViolations, + rawSourceViolations, + readExtractedTarball, + serviceArtifactViolations, + undeclaredExportViolations, +} from './tarball-shape'; + +/** + * The frozen public surface, spelled out. + * + * This literal is the reason the gate cannot be satisfied by deleting a subpath: a + * removal has to be made HERE too, which is a reviewable change to the package's + * public contract rather than a silent edit to `package.json`. + */ +const EXPECTED_SUBPATHS = [ + '.', + './transport', + './domain', + './providers', + './capabilities', + './hardware', + './testing', +] as const; + +let packed: PackedTarball; + +beforeAll(async () => { + packed = await packTarball(); +}, 300_000); + +describe('bun pm pack', () => { + test('produces a tarball with a non-empty listing', () => { + expect(packed.tarball).toEndWith('.tgz'); + expect(packed.listing.length).toBeGreaterThan(0); + }); + + test('the listing carries built JavaScript and declarations under dist/', () => { + expect(packed.listing.some((entry) => /^package\/dist\/.*\.js$/.test(entry))).toBe(true); + expect(packed.listing.some((entry) => /^package\/dist\/.*\.d\.ts$/.test(entry))).toBe(true); + expect(builtOutputViolations(packed.shape)).toEqual([]); + }); + + test('the listing has NO src/ entry — the published surface is built output', () => { + expect(packed.listing.filter((entry) => /(^|\/)src\//.test(entry))).toEqual([]); + expect(rawSourceViolations(packed.shape)).toEqual([]); + }); + + test('nothing but package.json ships outside dist/', () => { + const outside = packed.listing.filter( + (entry) => !entry.startsWith('package/dist/') && entry !== 'package/package.json', + ); + expect(outside.filter((entry) => !/^package\/(README|LICENSE)/i.test(entry))).toEqual([]); + }); +}); + +describe('the public exports map', () => { + test('declares exactly the seven frozen subpaths, ./testing included', () => { + expect([...PUBLIC_SUBPATHS]).toEqual([...EXPECTED_SUBPATHS]); + + const exportsMap = packed.shape.manifest.exports as Record; + for (const subpath of EXPECTED_SUBPATHS) { + expect(Object.keys(exportsMap)).toContain(subpath); + } + expect(Object.keys(exportsMap).sort()).toEqual([...EXPECTED_SUBPATHS, './package.json'].sort()); + }); + + test('the ./testing contract-fakes entry is exported and packed', () => { + const exportsMap = packed.shape.manifest.exports as Record>; + expect(exportsMap['./testing']).toEqual({ + types: './dist/testing/index.d.ts', + import: './dist/testing/index.js', + }); + expect(packed.listing).toContain('package/dist/testing/index.js'); + expect(packed.listing).toContain('package/dist/testing/index.d.ts'); + }); + + test('every declared entry resolves to a packed artifact', () => { + expect(publicEntryViolations(packed.shape)).toEqual([]); + for (const entry of PUBLIC_ENTRIES) { + expect(packed.listing).toContain(`package/${entry.js}`); + expect(packed.listing).toContain(`package/${entry.types}`); + } + }); + + test('exposes no undeclared subpath and points nowhere outside ./dist/', () => { + expect(undeclaredExportViolations(packed.shape)).toEqual([]); + expect(exportTargetViolations(packed.shape)).toEqual([]); + }); +}); + +describe('library-only proof', () => { + test('no bin entry, no systemd unit, no shebang, no listening socket', async () => { + expect(await serviceArtifactViolations(packed.shape)).toEqual([]); + }); + + test('the manifest declares no bin and no directories.bin', () => { + expect(packed.shape.manifest.bin).toBeUndefined(); + expect(packed.shape.manifest.directories).toBeUndefined(); + }); + + test('no packed path names a systemd unit or a socket/daemon entrypoint', () => { + const suspicious = packed.listing.filter((entry) => + /(systemd|\.service$|\.socket$|\.timer$|\.target$|daemon)/i.test(entry), + ); + expect(suspicious).toEqual([]); + }); + + test('the whole artifact is clean', async () => { + expect(await allViolations(packed.shape)).toEqual([]); + }); +}); + +describe('the detectors are non-vacuous', () => { + async function syntheticShape(build: (root: string) => Promise) { + const root = join(await mkdtemp(join(tmpdir(), 'modem-shape-')), 'package'); + await mkdir(join(root, 'dist'), { recursive: true }); + await build(root); + return readExtractedTarball(root); + } + + test('shipped raw source is caught', async () => { + const shape = await syntheticShape(async (root) => { + await writeFile(join(root, 'package.json'), '{"name":"x"}'); + await mkdir(join(root, 'src'), { recursive: true }); + await writeFile(join(root, 'src', 'index.ts'), 'export const a = 1;\n'); + await writeFile(join(root, 'dist', 'index.js'), 'export const a = 1;\n'); + }); + expect(rawSourceViolations(shape)).toEqual(['raw source shipped: src/index.ts']); + }); + + test('a missing ./testing subpath is caught', async () => { + const shape = await syntheticShape(async (root) => { + const exportsMap = Object.fromEntries( + PUBLIC_ENTRIES.filter((entry) => entry.subpath !== './testing').map((entry) => [ + entry.subpath, + { types: `./${entry.types}`, import: `./${entry.js}` }, + ]), + ); + await writeFile(join(root, 'package.json'), JSON.stringify({ exports: exportsMap })); + }); + expect(publicEntryViolations(shape)).toContain( + 'exports map is missing the "./testing" subpath', + ); + }); + + test('an exports target still pointing at src/ is caught', async () => { + const shape = await syntheticShape(async (root) => { + await writeFile( + join(root, 'package.json'), + JSON.stringify({ exports: { '.': './src/index.ts' } }), + ); + }); + expect(exportTargetViolations(shape)).toEqual([ + 'exports["."] points outside ./dist/: ./src/index.ts', + ]); + }); + + test('an extra internal barrel exposed as a subpath is caught', async () => { + const shape = await syntheticShape(async (root) => { + await writeFile( + join(root, 'package.json'), + JSON.stringify({ exports: { './backend': { import: './dist/backend/index.js' } } }), + ); + }); + expect(undeclaredExportViolations(shape)).toEqual(['undeclared public subpath: ./backend']); + }); + + test('a bin entry, a systemd unit, a shebang and a listener are each caught', async () => { + const shape = await syntheticShape(async (root) => { + await writeFile( + join(root, 'package.json'), + JSON.stringify({ bin: { 'modem-controld': './dist/daemon.js' } }), + ); + await writeFile(join(root, 'dist', 'modem-control.service'), '[Unit]\n'); + await writeFile(join(root, 'dist', 'daemon.js'), '#!/usr/bin/env node\nexport {};\n'); + await writeFile( + join(root, 'dist', 'listener.js'), + 'export const s = createServer(() => {});\n', + ); + }); + const violations = await serviceArtifactViolations(shape); + expect(violations.some((v) => v.includes('bin entry'))).toBe(true); + expect(violations).toContain('systemd unit file shipped: dist/modem-control.service'); + expect(violations).toContain('executable entrypoint (shebang) shipped: dist/daemon.js'); + expect(violations.some((v) => v.includes('daemon/socket construct'))).toBe(true); + }); + + test('an empty dist is caught', async () => { + const shape = await syntheticShape(async (root) => { + await writeFile(join(root, 'package.json'), '{"name":"x"}'); + }); + expect(builtOutputViolations(shape)).toContain('no dist/ output in the tarball'); + }); +}); diff --git a/control/scripts/tarball-shape.ts b/control/scripts/tarball-shape.ts new file mode 100644 index 0000000..2008040 --- /dev/null +++ b/control/scripts/tarball-shape.ts @@ -0,0 +1,250 @@ +/** + * The tarball-shape gate for `@ceralive/modem-control`. + * + * `@ceralive/modem-control` is a LIBRARY. It ships built ESM plus declarations and + * nothing that could start: no `bin`, no systemd unit, no shebang, no listening + * socket. That is a claim about a published artifact, so it is checked against the + * artifact — the real `bun pm pack` output — rather than against the source tree. + * + * Every rule returns its violations instead of throwing, so a caller reports all of + * them at once and a test can name each rule separately. + */ +import { readdir, readFile, stat } from 'node:fs/promises'; +import { join, relative } from 'node:path'; +import { PUBLIC_ENTRIES } from './entries'; + +/** An extracted tarball: the `tar -tf` listing plus the packed manifest. */ +export type TarballShape = { + /** Paths relative to the tarball's `package/` root, e.g. `dist/index.js`. */ + readonly entries: readonly string[]; + /** The packed `package.json`, parsed. */ + readonly manifest: Record; + /** Absolute path of the extracted `package/` directory. */ + readonly root: string; +}; + +const SYSTEMD_UNIT_EXTENSIONS = [ + '.service', + '.socket', + '.target', + '.timer', + '.path', + '.mount', + '.automount', + '.slice', + '.swap', + '.device', + '.scope', +]; + +/** Constructs that would open a listening socket — a daemon's defining act. */ +const LISTENING_SOCKET_PATTERNS = [ + /\bcreateServer\s*\(/, + /\bBun\s*\.\s*serve\s*\(/, + /\bnode:net\b/, + /\bnode:http2?\b/, + /\.\s*listen\s*\(/, +]; + +async function walk(dir: string, root: string, found: string[] = []): Promise { + for (const entry of await readdir(dir, { withFileTypes: true })) { + const full = join(dir, entry.name); + if (entry.isDirectory()) { + await walk(full, root, found); + } else { + found.push(relative(root, full)); + } + } + return found; +} + +/** Read an already-extracted `package/` directory into a {@link TarballShape}. */ +export async function readExtractedTarball(root: string): Promise { + const entries = (await walk(root, root)).sort(); + const manifest = JSON.parse(await readFile(join(root, 'package.json'), 'utf8')) as Record< + string, + unknown + >; + return { entries, manifest, root }; +} + +/** RULE 1 — nothing under `src/` may ship; the published surface is the BUILT output. */ +export function rawSourceViolations(shape: TarballShape): readonly string[] { + return shape.entries + .filter((entry) => entry === 'src' || entry.startsWith('src/') || entry.includes('/src/')) + .map((entry) => `raw source shipped: ${entry}`); +} + +/** RULE 2 — the built artifacts a library is supposed to have are actually present. */ +export function builtOutputViolations(shape: TarballShape): readonly string[] { + const violations: string[] = []; + const distFiles = shape.entries.filter((entry) => entry.startsWith('dist/')); + if (distFiles.length === 0) { + violations.push('no dist/ output in the tarball'); + } + if (!distFiles.some((entry) => entry.endsWith('.js'))) { + violations.push('no built JavaScript in dist/'); + } + if (!distFiles.some((entry) => entry.endsWith('.d.ts'))) { + violations.push('no declarations (.d.ts) in dist/'); + } + return violations; +} + +/** + * RULE 3 — every declared public entry is in the exports map AND its files are packed. + * + * Driven by {@link PUBLIC_ENTRIES}, so a subpath cannot be dropped from `package.json` + * without this failing. + */ +export function publicEntryViolations(shape: TarballShape): readonly string[] { + const violations: string[] = []; + const exportsMap = shape.manifest.exports; + if (typeof exportsMap !== 'object' || exportsMap === null) { + return ['package.json has no exports map']; + } + const map = exportsMap as Record; + const packed = new Set(shape.entries); + + for (const entry of PUBLIC_ENTRIES) { + const condition = map[entry.subpath]; + if (condition === undefined) { + violations.push(`exports map is missing the "${entry.subpath}" subpath`); + continue; + } + if (typeof condition !== 'object' || condition === null) { + violations.push(`exports["${entry.subpath}"] is not a conditions object`); + continue; + } + const conditions = condition as Record; + if (conditions.import !== `./${entry.js}`) { + violations.push( + `exports["${entry.subpath}"].import is ${String(conditions.import)}, expected ./${entry.js}`, + ); + } + if (conditions.types !== `./${entry.types}`) { + violations.push( + `exports["${entry.subpath}"].types is ${String(conditions.types)}, expected ./${entry.types}`, + ); + } + for (const artifact of [entry.js, entry.types]) { + if (!packed.has(artifact)) { + violations.push(`exports["${entry.subpath}"] target ${artifact} is not in the tarball`); + } + } + } + return violations; +} + +/** RULE 4 — no subpath beyond the declared set; internal barrels stay internal. */ +export function undeclaredExportViolations(shape: TarballShape): readonly string[] { + const exportsMap = shape.manifest.exports; + if (typeof exportsMap !== 'object' || exportsMap === null) { + return ['package.json has no exports map']; + } + const allowed = new Set([ + ...PUBLIC_ENTRIES.map((entry) => entry.subpath), + './package.json', + ]); + return Object.keys(exportsMap as Record) + .filter((subpath) => !allowed.has(subpath)) + .map((subpath) => `undeclared public subpath: ${subpath}`); +} + +/** RULE 5 — no exports target, nor `main`/`types`, may point outside `./dist/`. */ +export function exportTargetViolations(shape: TarballShape): readonly string[] { + const violations: string[] = []; + const check = (label: string, value: unknown): void => { + if (typeof value !== 'string') { + return; + } + if (value === './package.json') { + return; + } + if (!value.startsWith('./dist/')) { + violations.push(`${label} points outside ./dist/: ${value}`); + } + }; + + check('main', shape.manifest.main); + check('types', shape.manifest.types); + const exportsMap = shape.manifest.exports; + if (typeof exportsMap === 'object' && exportsMap !== null) { + for (const [subpath, condition] of Object.entries(exportsMap as Record)) { + if (typeof condition === 'string') { + check(`exports["${subpath}"]`, condition); + continue; + } + if (typeof condition === 'object' && condition !== null) { + for (const [name, value] of Object.entries(condition as Record)) { + check(`exports["${subpath}"].${name}`, value); + } + } + } + } + return violations; +} + +/** + * RULE 6 — the library-only proof: nothing in this artifact can be STARTED. + * + * No `bin` entry, no systemd unit, no shebang, no listening-socket construct. Each is + * a separate way the same mistake gets made, so each is checked rather than assumed + * away by the others. + */ +export async function serviceArtifactViolations(shape: TarballShape): Promise { + const violations: string[] = []; + + if (shape.manifest.bin !== undefined) { + violations.push(`package.json declares a bin entry: ${JSON.stringify(shape.manifest.bin)}`); + } + const directories = shape.manifest.directories; + if (typeof directories === 'object' && directories !== null) { + if ((directories as Record).bin !== undefined) { + violations.push('package.json declares directories.bin'); + } + } + + for (const entry of shape.entries) { + const lower = entry.toLowerCase(); + if (SYSTEMD_UNIT_EXTENSIONS.some((extension) => lower.endsWith(extension))) { + violations.push(`systemd unit file shipped: ${entry}`); + } + if (lower.includes('systemd')) { + violations.push(`systemd artifact shipped: ${entry}`); + } + } + + for (const entry of shape.entries) { + const path = join(shape.root, entry); + if ((await stat(path)).size === 0) { + continue; + } + const contents = await readFile(path, 'utf8').catch(() => ''); + if (contents.startsWith('#!')) { + violations.push(`executable entrypoint (shebang) shipped: ${entry}`); + } + if (!entry.endsWith('.js')) { + continue; + } + for (const pattern of LISTENING_SOCKET_PATTERNS) { + if (pattern.test(contents)) { + violations.push(`daemon/socket construct ${String(pattern)} found in ${entry}`); + } + } + } + + return violations; +} + +/** Every rule, in order. An empty array is a passing artifact. */ +export async function allViolations(shape: TarballShape): Promise { + return [ + ...rawSourceViolations(shape), + ...builtOutputViolations(shape), + ...publicEntryViolations(shape), + ...undeclaredExportViolations(shape), + ...exportTargetViolations(shape), + ...(await serviceArtifactViolations(shape)), + ]; +} diff --git a/control/scripts/verify-consumers.ts b/control/scripts/verify-consumers.ts new file mode 100644 index 0000000..8e47bd4 --- /dev/null +++ b/control/scripts/verify-consumers.ts @@ -0,0 +1,99 @@ +#!/usr/bin/env bun +/** + * Run the standalone consumer fixtures against the PACKED tarball. + * + * Both fixtures are real, separate projects: each gets its own directory, installs + * `@ceralive/modem-control` from the `.tgz` with its own package manager, and imports + * every public subpath by specifier. Nothing in them resolves through this workspace — + * no `paths` mapping, no `link:`, no relative reach into `control/src`. That is the + * point: the repo's own tsconfig maps the package back to source for development, so + * the built artifact would otherwise never be executed by anything. + * + * The Node fixture asserts its own runtime major is 26 before it runs, so a green + * result cannot come from an older Node that happens to be first on `PATH`. + * + * Fixtures are COPIED into `test-results/consumers//` and installed there, so + * `control/fixtures/` never grows a `node_modules` or a lockfile. + */ +import { cp, mkdir, rm } from 'node:fs/promises'; +import { join, resolve } from 'node:path'; +import { packTarball } from './pack-tarball'; + +const PACKAGE_DIR = resolve(import.meta.dir, '..'); +const FIXTURES_DIR = join(PACKAGE_DIR, 'fixtures'); +const WORK_DIR = join(PACKAGE_DIR, 'test-results', 'consumers'); + +const REQUIRED_NODE_MAJOR = 26; + +type Fixture = { + readonly name: string; + readonly runtime: string; + readonly install: (tarball: string) => string[]; + readonly run: string[]; +}; + +/** Resolve the Node binary the fixture must run under. */ +const NODE_BIN = process.env.CERALIVE_NODE_BIN ?? 'node'; + +const FIXTURES: readonly Fixture[] = [ + { + name: 'consumer-node', + runtime: NODE_BIN, + install: (tarball) => ['npm', 'install', '--no-audit', '--no-fund', tarball], + run: [NODE_BIN, './check.mjs'], + }, + { + name: 'consumer-bun', + runtime: 'bun', + install: (tarball) => ['bun', 'add', tarball], + run: ['bun', './check.mjs'], + }, +]; + +function run(command: string[], cwd: string, env?: Record): string { + const result = Bun.spawnSync(command, { + cwd, + stdout: 'pipe', + stderr: 'pipe', + ...(env === undefined ? {} : { env: { ...process.env, ...env } }), + }); + const output = `${result.stdout.toString()}${result.stderr.toString()}`; + if (result.exitCode !== 0) { + throw new Error(`${command.join(' ')} failed (${result.exitCode}) in ${cwd}:\n${output}`); + } + return output; +} + +function assertNodeMajor(): string { + const version = run([NODE_BIN, '--version'], PACKAGE_DIR).trim(); + const major = Number(version.replace(/^v/, '').split('.')[0]); + if (major !== REQUIRED_NODE_MAJOR) { + throw new Error( + `the Node fixture requires Node ${REQUIRED_NODE_MAJOR}.x, found ${version}. ` + + `Set CERALIVE_NODE_BIN to a Node ${REQUIRED_NODE_MAJOR} binary.`, + ); + } + return version; +} + +const nodeVersion = assertNodeMajor(); +console.log(`node: ${nodeVersion}`); +console.log(`bun: v${Bun.version}`); + +const packed = await packTarball(); +console.log(`tarball: ${packed.tarball}\n`); + +await rm(WORK_DIR, { recursive: true, force: true }); +await mkdir(WORK_DIR, { recursive: true }); + +for (const fixture of FIXTURES) { + const dir = join(WORK_DIR, fixture.name); + await cp(join(FIXTURES_DIR, fixture.name), dir, { recursive: true }); + await cp(join(FIXTURES_DIR, 'check-public-surface.mjs'), join(dir, 'check.mjs')); + + run(fixture.install(packed.tarball), dir); + const output = run(fixture.run, dir).trim(); + console.log(`${fixture.name} (${fixture.runtime}): ${output}`); +} + +console.log('\nboth standalone consumers import every public subpath from the packed tarball'); diff --git a/control/src/backend/constants.ts b/control/src/backend/constants.ts index b09729f..1b49c8b 100644 --- a/control/src/backend/constants.ts +++ b/control/src/backend/constants.ts @@ -26,6 +26,12 @@ export const MODEM_IFACE = 'org.freedesktop.ModemManager1.Modem'; /** The separate `Modem.Modem3gpp` interface (never merged into `Modem`). */ export const MODEM3GPP_IFACE = 'org.freedesktop.ModemManager1.Modem.Modem3gpp'; +/** `Modem.Modem3gpp.Ussd` — a SEPARATE interface a modem may omit entirely. */ +export const MODEM3GPP_USSD_IFACE = `${MODEM3GPP_IFACE}.Ussd`; + +/** The `Modem.Location` interface — GNSS capabilities, `Setup`, `GetLocation`. */ +export const MODEM_LOCATION_IFACE = 'org.freedesktop.ModemManager1.Modem.Location'; + /** A SIM object's `Sim` interface (SIMs are separate `/SIM/` objects). */ export const SIM_IFACE = 'org.freedesktop.ModemManager1.Sim'; diff --git a/control/src/backend/device-classifier.test.ts b/control/src/backend/device-classifier.test.ts index 377d039..00cfdea 100644 --- a/control/src/backend/device-classifier.test.ts +++ b/control/src/backend/device-classifier.test.ts @@ -3,7 +3,15 @@ // confident guess, and mass storage without a modeswitch trigger is NOT a modem. import { describe, expect, test } from 'bun:test'; -import { classifyDevice, detectUsbMode, type UsbDeviceSnapshot } from './device-classifier'; +import { + classifyDevice, + classifyUsbNetDevice, + detectUsbMode, + modelLabel, + type UsbDeviceSnapshot, + unitDiscriminator, + vendorLabel, +} from './device-classifier'; /** A Quectel-style QMI composition: a `qmi_wwan` control port (+ AT serials). */ const QMI: UsbDeviceSnapshot = { @@ -166,3 +174,38 @@ describe('classifyDevice — honesty guards', () => { expect(classifyDevice(bareVendor).deviceClass).toBe('unmanaged'); }); }); + +describe('CeraUI USB-net classification parity', () => { + test('requires positive cellular evidence before naming a tether cellular', () => { + const plainNic: UsbDeviceSnapshot = { + vendorId: '0b95', + productId: '772b', + bDeviceClass: 0, + interfaces: [ + { + interfaceClass: 0x02, + interfaceSubClass: 0x06, + interfaceProtocol: 0, + driver: 'cdc_ether', + }, + ], + }; + expect(classifyUsbNetDevice(plainNic).deviceClass).toBe('wired-ethernet'); + expect(classifyUsbNetDevice(HILINK_ECM).deviceClass).toBe('router-cellular'); + expect(classifyUsbNetDevice(QMI).deviceClass).toBe('mm-managed'); + }); + + test('replaces duplicated class strings with database identity', () => { + const hilink: UsbDeviceSnapshot = { + ...HILINK_ECM, + manufacturer: 'HUAWEI_MOBILE', + product: 'HUAWEI_MOBILE', + databaseVendor: 'Huawei Technologies Co., Ltd.', + databaseModel: 'E3372 LTE/UMTS/GSM HiLink Modem/Networkcard', + serialNumber: 'Y4QDU17621000872', + }; + expect(vendorLabel(hilink)).toBe('Huawei'); + expect(modelLabel(hilink)).toBe('E3372 LTE/UMTS/GSM HiLink Modem/Networkcard'); + expect(unitDiscriminator(hilink)).toBe('Y4QDU17621000872'); + }); +}); diff --git a/control/src/backend/device-classifier.ts b/control/src/backend/device-classifier.ts index f5d13b9..1332bef 100644 --- a/control/src/backend/device-classifier.ts +++ b/control/src/backend/device-classifier.ts @@ -35,6 +35,11 @@ export interface UsbDeviceSnapshot { readonly productId: string; readonly model?: string; readonly firmwareRevision?: string; + readonly manufacturer?: string; + readonly product?: string; + readonly databaseVendor?: string; + readonly databaseModel?: string; + readonly serialNumber?: string; /** The device-descriptor `bDeviceClass` byte (0 ⇒ class is per-interface). */ readonly bDeviceClass: number; readonly interfaces: readonly UsbInterface[]; @@ -48,6 +53,7 @@ export interface UsbDeviceSnapshot { /** The four device classes. `pending-modeswitch` is distinct from `unmanaged`. */ export type DeviceClass = 'mm-managed' | 'router-mode' | 'unmanaged' | 'pending-modeswitch'; +export type UsbNetClass = 'mm-managed' | 'router-cellular' | 'wired-ethernet' | 'unknown'; /** A classification plus a human-readable reason (always populated). */ export interface DeviceClassification { @@ -55,8 +61,14 @@ export interface DeviceClassification { readonly reason: string; } +export interface UsbNetClassification { + readonly deviceClass: UsbNetClass; + readonly reason: string; +} + // USB-IF class / subclass / protocol codes used below. const CLASS_COMM = 0x02; // CDC communications (control interface) +const CLASS_CDC_DATA = 0x0a; const CLASS_MASS_STORAGE = 0x08; const CLASS_WIRELESS = 0xe0; // wireless controller (RNDIS lives here) const CLASS_VENDOR = 0xff; @@ -74,6 +86,21 @@ const ECM_NCM_DRIVERS: ReadonlySet = new Set(['cdc_ether', 'cdc_ncm']); const RNDIS_DRIVERS: ReadonlySet = new Set(['rndis_host']); const STORAGE_DRIVERS: ReadonlySet = new Set(['usb-storage', 'uas']); +export const CELLULAR_USB_VENDOR_IDS: ReadonlyMap = new Map([ + ['05c6', 'Qualcomm'], + ['0af0', 'Option'], + ['1199', 'Sierra Wireless'], + ['12d1', 'Huawei'], + ['1546', 'u-blox'], + ['19d2', 'ZTE'], + ['1bbb', 'TCL/Alcatel'], + ['1c9e', 'Longcheer'], + ['1e0e', 'SIMCom'], + ['2c7c', 'Quectel'], + ['2cb7', 'Fibocom'], + ['413c', 'Dell'], +]); + function isMbimControl(i: UsbInterface): boolean { return i.interfaceClass === CLASS_COMM && i.interfaceSubClass === SUB_MBIM; } @@ -199,6 +226,84 @@ export function classifyDevice(snapshot: UsbDeviceSnapshot): DeviceClassificatio return { deviceClass: 'unmanaged', reason: unmanagedReason(snapshot, hasStorage) }; } +export function cellularVendorName(vendorId: string): string | undefined { + return CELLULAR_USB_VENDOR_IDS.get(vendorId.toLowerCase()); +} + +export function cellularEvidence(device: UsbDeviceSnapshot): string | undefined { + const vendor = cellularVendorName(device.vendorId); + if (vendor !== undefined) + return `USB vendor ${device.vendorId} is ${vendor}, a cellular-module vendor`; + const modeswitch = device.udevProperties?.ID_USB_MODESWITCH; + if (modeswitch !== undefined && modeswitch !== '' && modeswitch !== '0') + return 'device carries a usb_modeswitch trigger — a mode-switching dongle'; + return device.interfaces.some(isMassStorage) + ? 'device also presents a mass-storage installer interface — the ZeroCD personality of a router-mode dongle' + : undefined; +} + +export function classifyUsbNetDevice(device: UsbDeviceSnapshot): UsbNetClassification { + const control = device.interfaces.find( + (i) => isMbimControl(i) || isQmiControl(i) || isAtControl(i), + ); + if (control !== undefined) + return { + deviceClass: 'mm-managed', + reason: `recognized ${controlKind(control)} control interface — ModemManager-manageable`, + }; + const tether = device.interfaces.find( + (i) => isRndis(i) || isEcmNcmData(i) || i.interfaceClass === CLASS_CDC_DATA, + ); + if (tether !== undefined) { + const kind = isRndis(tether) ? 'RNDIS' : 'ECM/NCM'; + const evidence = cellularEvidence(device); + return evidence === undefined + ? { + deviceClass: 'wired-ethernet', + reason: `${kind} Ethernet tether with no modem control port and no cellular evidence — a USB network adapter`, + } + : { + deviceClass: 'router-cellular', + reason: `${kind} Ethernet tether with no modem control port; ${evidence}`, + }; + } + return { + deviceClass: 'unknown', + reason: unmanagedReason( + device, + device.interfaces.some(isMassStorage) || device.bDeviceClass === CLASS_MASS_STORAGE, + ), + }; +} + +export function publishesGenericIdentity(device: UsbDeviceSnapshot): boolean { + const manufacturer = device.manufacturer?.trim(); + const product = device.product?.trim(); + return Boolean(manufacturer && product && manufacturer.toLowerCase() === product.toLowerCase()); +} + +export function vendorLabel(device: UsbDeviceSnapshot): string { + const published = device.manufacturer?.trim(); + if (published && !publishesGenericIdentity(device)) return published; + return cellularVendorName(device.vendorId) ?? device.databaseVendor?.trim() ?? device.vendorId; +} + +export function modelLabel(device: UsbDeviceSnapshot): string { + const published = device.product?.trim(); + if (published && !publishesGenericIdentity(device)) return published; + return device.databaseModel?.trim() ?? device.productId; +} + +export function unitDiscriminator(device: UsbDeviceSnapshot): string | undefined { + const serial = device.serialNumber?.trim(); + if (!serial) return undefined; + const folded = serial.toLowerCase(); + return folded === device.manufacturer?.trim().toLowerCase() || + folded === device.product?.trim().toLowerCase() + ? undefined + : serial; +} + /** * Derive the USB composition MODE a device is currently in (for a transition's * postcondition). Distinct from `classifyDevice`: this reads the data-plane diff --git a/control/src/backend/index.ts b/control/src/backend/index.ts index 67f64ad..c481405 100644 --- a/control/src/backend/index.ts +++ b/control/src/backend/index.ts @@ -29,17 +29,29 @@ export { MM_MANAGER_IFACE, MM_ROOT_PATH, MODEM_IFACE, + MODEM_LOCATION_IFACE, MODEM3GPP_IFACE, + MODEM3GPP_USSD_IFACE, SIM_IFACE, } from './constants'; export { + CELLULAR_USB_VENDOR_IDS, + cellularEvidence, + cellularVendorName, classifyDevice, + classifyUsbNetDevice, type DeviceClass, type DeviceClassification, descriptorsMatch, detectUsbMode, + modelLabel, + publishesGenericIdentity, type UsbDeviceSnapshot, type UsbInterface, + type UsbNetClass, + type UsbNetClassification, + unitDiscriminator, + vendorLabel, } from './device-classifier'; export { buildEnrichment, @@ -102,6 +114,12 @@ export { MmDbusBackend, type MmDbusBackendOptions, } from './mm-backend'; +export { + decodeLocationSources, + encodeLocationSources, + MmLocation, + type MmLocationDeps, +} from './mm-location'; export { MmMutations, type MmMutationsDeps } from './mm-mutations'; export { ModemActor, @@ -210,21 +228,6 @@ export { type UsbModeTransitionOutcome, type UsbModeTransitionRequest, } from './transition-preconditions'; -export { - createUhubctlPowerHook, - parseUhubctlPortMap, - readUhubctlPortMap, - SpawnUhubctlRunner, - type UhubctlPortMap, - type UhubctlPortMapping, - type UhubctlPowerHookDeps, - type UhubctlResult, - type UhubctlRunner, - type UsbEnumerationPoller, - uhubctlCycleArgv, - uhubctlPortMappingSchema, - uhubctlPortMapSchema, -} from './uhubctl-power-hook'; export * from './usage'; export { createUsbEnumerator, diff --git a/control/src/backend/managed-objects.ts b/control/src/backend/managed-objects.ts index 594932b..7b607fa 100644 --- a/control/src/backend/managed-objects.ts +++ b/control/src/backend/managed-objects.ts @@ -96,6 +96,19 @@ export function numberProp(props: DecodedProps | undefined, name: string): numbe return typeof value === 'number' ? value : undefined; } +/** + * A string-array (`as`) property, or `undefined` if absent / not an array. An + * EMPTY array answers `[]` — the device saying "none", a different fact from an + * absent property. Non-string members are dropped, never coerced. + */ +export function stringArrayProp( + props: DecodedProps | undefined, + name: string, +): readonly string[] | undefined { + const value = propValue(props, name); + return Array.isArray(value) ? value.filter((item) => typeof item === 'string') : undefined; +} + /** Resolve an object-path property (e.g. a modem's `Sim`) to that object's props. */ export function followObjectPath( tree: DecodedManagedObjects, diff --git a/control/src/backend/mapping.test.ts b/control/src/backend/mapping.test.ts new file mode 100644 index 0000000..df37737 --- /dev/null +++ b/control/src/backend/mapping.test.ts @@ -0,0 +1,117 @@ +// `Modem.OwnNumbers` → `identity.ownNumbers` — pure fixtures, no bus. +// +// Board evidence (Quectel RM530N-GL, `mmcli -m 3`): `own: +573115422359`. MM +// publishes the property as `as`, so the read must survive a list, a list the +// carrier left empty, and a property the firmware does not export at all — +// three different facts that must not collapse into one another. + +import { describe, expect, test } from 'bun:test'; +import { subscriberNumber } from '../domain'; +import type { DbusVariant } from '../transport'; +import { MODEM_IFACE, MODEM3GPP_IFACE } from './constants'; +import type { DecodedManagedObjects, DecodedProps } from './managed-objects'; +import { fingerprint, mapModem } from './mapping'; + +const MODEM_PATH = '/org/freedesktop/ModemManager1/Modem/3'; +const BOARD_OWN_NUMBER = '+573115422359'; +const SECOND_OWN_NUMBER = '+573001112233'; + +function variant(value: unknown, signature: string): DbusVariant { + return { signature, value } as DbusVariant; +} + +function props(record: Record): DecodedProps { + return Object.entries(record).map(([key, value]) => [key, value] as const); +} + +function tree(modemProps: Record): DecodedManagedObjects { + return [ + [ + MODEM_PATH, + [ + [ + MODEM_IFACE, + props({ + EquipmentIdentifier: variant('867978050016855', 's'), + State: variant(8, 'i'), + PowerState: variant(3, 'u'), + ...modemProps, + }), + ], + [MODEM3GPP_IFACE, props({ Imei: variant('867978050016855', 's') })], + ], + ], + ]; +} + +describe('mapModem — the SIM own number', () => { + test('a published number reaches the identity verbatim', () => { + const mapped = mapModem(tree({ OwnNumbers: variant([BOARD_OWN_NUMBER], 'as') }), MODEM_PATH); + + expect(mapped.identity.ownNumbers).toEqual([subscriberNumber(BOARD_OWN_NUMBER)]); + }); + + test('a multi-number SIM keeps every number, in order', () => { + const mapped = mapModem( + tree({ OwnNumbers: variant([BOARD_OWN_NUMBER, SECOND_OWN_NUMBER], 'as') }), + MODEM_PATH, + ); + + expect(mapped.identity.ownNumbers).toEqual([ + subscriberNumber(BOARD_OWN_NUMBER), + subscriberNumber(SECOND_OWN_NUMBER), + ]); + }); + + test('an ABSENT property omits the key entirely', () => { + const mapped = mapModem(tree({}), MODEM_PATH); + + expect(Object.hasOwn(mapped.identity, 'ownNumbers')).toBe(false); + }); + + test('an EMPTY list reads as not-reported, never as an empty list', () => { + const mapped = mapModem(tree({ OwnNumbers: variant([], 'as') }), MODEM_PATH); + + expect(Object.hasOwn(mapped.identity, 'ownNumbers')).toBe(false); + }); + + test('blank and non-string members are dropped rather than coerced', () => { + const mapped = mapModem( + tree({ OwnNumbers: variant([' ', BOARD_OWN_NUMBER, 42, ''], 'as') }), + MODEM_PATH, + ); + + expect(mapped.identity.ownNumbers).toEqual([subscriberNumber(BOARD_OWN_NUMBER)]); + }); + + test('a whitespace-only list reads as not-reported', () => { + const mapped = mapModem(tree({ OwnNumbers: variant([' ', ''], 'as') }), MODEM_PATH); + + expect(Object.hasOwn(mapped.identity, 'ownNumbers')).toBe(false); + }); + + test('a changed number bumps the fingerprint, so the row re-publishes', () => { + const before = fingerprint( + mapModem(tree({ OwnNumbers: variant([BOARD_OWN_NUMBER], 'as') }), MODEM_PATH), + ); + const after = fingerprint( + mapModem(tree({ OwnNumbers: variant([SECOND_OWN_NUMBER], 'as') }), MODEM_PATH), + ); + const absent = fingerprint(mapModem(tree({}), MODEM_PATH)); + + expect(before).not.toBe(after); + expect(before).not.toBe(absent); + }); + + test('every other mapped dimension is untouched by the read', () => { + const withNumber = mapModem( + tree({ OwnNumbers: variant([BOARD_OWN_NUMBER], 'as') }), + MODEM_PATH, + ); + const without = mapModem(tree({}), MODEM_PATH); + + expect({ ...withNumber, identity: undefined }).toEqual({ ...without, identity: undefined }); + expect(withNumber.identity.equipmentId).toEqual(without.identity.equipmentId); + expect(withNumber.identity.runtimePath).toBe(without.identity.runtimePath); + }); +}); diff --git a/control/src/backend/mapping.ts b/control/src/backend/mapping.ts index 80382ff..be46043 100644 --- a/control/src/backend/mapping.ts +++ b/control/src/backend/mapping.ts @@ -12,18 +12,22 @@ import { type CellularSnapshot, imeiEquipmentId, type MmState, + subscriberNumber as makeSubscriberNumber, subscriptionId as makeSubscriptionId, type RadioPower, runtimePath, + type SubscriberNumber, type SubscriptionId, } from '../domain'; import { MODEM_IFACE, MODEM3GPP_IFACE, SIM_IFACE } from './constants'; import { type DecodedManagedObjects, + type DecodedProps, findInterface, followObjectPath, numberProp, pathsWithInterface, + stringArrayProp, stringProp, } from './managed-objects'; @@ -104,6 +108,24 @@ function readSubscriptionId( return iccid !== undefined && iccid.length > 0 ? makeSubscriptionId(iccid) : undefined; } +/** + * The SIM's own number(s) from `Modem.OwnNumbers`, or `undefined` when the + * carrier published none. Most SIMs carry no MSISDN at all, so an absent or + * empty property is the ordinary case and must read as "not reported" rather + * than as an empty list an operator could mistake for a failed read. + */ +function readOwnNumbers(modem: DecodedProps | undefined): readonly SubscriberNumber[] | undefined { + const raw = stringArrayProp(modem, 'OwnNumbers'); + if (raw === undefined) { + return undefined; + } + const numbers = raw + .map((value) => value.trim()) + .filter((value) => value.length > 0) + .map((value) => makeSubscriberNumber(value)); + return numbers.length > 0 ? numbers : undefined; +} + /** Map ONE modem object to its dimensions. The modem must expose `Modem`. */ export function mapModem(tree: DecodedManagedObjects, modemPath: string): MappedModem { const modem = findInterface(tree, modemPath, MODEM_IFACE); @@ -113,12 +135,14 @@ export function mapModem(tree: DecodedManagedObjects, modemPath: string): Mapped const mmState = mapMmState(numberProp(modem, 'State')); const radioPower = reconcilePower(mapRadioPower(numberProp(modem, 'PowerState')), mmState); const sub = readSubscriptionId(tree, modemPath); + const ownNumbers = readOwnNumbers(modem); return { identity: { equipmentId: imeiEquipmentId(equipment), runtimePath: runtimePath(modemPath), ...(sub !== undefined ? { subscriptionId: sub } : {}), + ...(ownNumbers !== undefined ? { ownNumbers } : {}), }, presence: 'present', sourceHealth: 'live', @@ -145,6 +169,7 @@ export function fingerprint(mapped: MappedModem): string { return JSON.stringify({ id: mapped.identity.equipmentId, sub: mapped.identity.subscriptionId ?? null, + own: mapped.identity.ownNumbers ?? null, path: mapped.identity.runtimePath, presence: mapped.presence, radioPower: mapped.radioPower, diff --git a/control/src/backend/mm-backend.ts b/control/src/backend/mm-backend.ts index d0fdfd7..d9015d5 100644 --- a/control/src/backend/mm-backend.ts +++ b/control/src/backend/mm-backend.ts @@ -16,6 +16,7 @@ import type { DesiredRadio } from '../domain'; import { epochMillis } from '../domain'; import type { + BandReadResult, InhibitLease, ModemManagerPort, ModemRef, @@ -41,6 +42,7 @@ import { type SignalCadence, SignalSetupManager } from './signal-setup'; export interface MmDbusBackendOptions { readonly transport: DbusTransport; readonly destination?: string; + readonly actor?: ModemActor; /** NM quiesce hook for disruptive mode/slot changes (A3.3 default: no-op). */ readonly quiesce?: QuiesceHook; /** Signal.Setup reporting interval in seconds. */ @@ -64,7 +66,7 @@ export class MmDbusBackend implements ModemManagerPort { this.#transport = options.transport; this.#destination = options.destination ?? MM_BUS_NAME; this.#now = options.now ?? Date.now; - const actor = new ModemActor(options.quiesce); + const actor = options.actor ?? new ModemActor(options.quiesce); this.#signalSetup = new SignalSetupManager({ transport: this.#transport, destination: this.#destination, @@ -107,10 +109,22 @@ export class MmDbusBackend implements ModemManagerPort { return this.#mutations.setRadioModes(modem, preference); } + setModeCombination(modem: ModemRef, allowed: number, preferred: number): Promise { + return this.#mutations.setModeCombination(modem, allowed, preferred); + } + setPrimarySimSlot(modem: ModemRef, slotIndex: number): Promise { return this.#mutations.setPrimarySimSlot(modem, slotIndex); } + readBands(modem: ModemRef): Promise { + return this.#mutations.readBands(modem); + } + + setCurrentBands(modem: ModemRef, bands: readonly string[]): Promise { + return this.#mutations.setCurrentBands(modem, bands); + } + sendPin(modem: ModemRef, pin: string): Promise { return this.#mutations.sendPin(modem, pin); } diff --git a/control/src/backend/mm-bands.test.ts b/control/src/backend/mm-bands.test.ts new file mode 100644 index 0000000..69b39c1 --- /dev/null +++ b/control/src/backend/mm-bands.test.ts @@ -0,0 +1,151 @@ +import { describe, expect, it } from 'bun:test'; + +import type { ModemRef } from '../ports'; +import type { DbusTransport, MethodCall, MethodReply } from '../transport'; +import { variant } from '../transport'; +import { MODEM_IFACE } from './constants'; +import { MmMutations } from './mm-mutations'; +import { ModemActor } from './modem-actor'; + +const MODEM = '/org/freedesktop/ModemManager1/Modem/0' as ModemRef; + +// One managed-objects tree carrying the RM530N-GL-shaped band properties, in the +// `au` encoding `GetManagedObjects` really returns them in. +function tree(supported: number[], current: number[]): unknown { + return [ + [ + MODEM, + [ + [ + MODEM_IFACE, + [ + ['SupportedBands', variant('au', supported)], + ['CurrentBands', variant('au', current)], + ], + ], + ], + ], + ]; +} + +interface Harness { + readonly transport: DbusTransport; + readonly calls: MethodCall[]; +} + +function harness(options: { + readonly managed?: unknown; + readonly setFails?: Error; + readonly getFails?: Error; +}): Harness { + const calls: MethodCall[] = []; + const transport = { + connect: () => Promise.resolve(), + disconnect: () => Promise.resolve(), + isConnected: () => true, + callMethod: (call: MethodCall): Promise => { + calls.push(call); + if (call.member === 'GetManagedObjects') { + if (options.getFails !== undefined) return Promise.reject(options.getFails); + return Promise.resolve({ body: [options.managed ?? []] } as unknown as MethodReply); + } + if (options.setFails !== undefined) return Promise.reject(options.setFails); + return Promise.resolve({ body: [] } as unknown as MethodReply); + }, + subscribeSignal: () => Promise.reject(new Error('unused')), + on: () => undefined, + off: () => undefined, + subscriptionCount: () => 0, + } as unknown as DbusTransport; + return { transport, calls }; +} + +function mutations(h: Harness): MmMutations { + return new MmMutations({ + transport: h.transport, + actor: new ModemActor(), + resolveStableKey: () => 'stable-0', + }); +} + +describe('readBands', () => { + it('decodes SupportedBands and CurrentBands off the real property shape', async () => { + const h = harness({ managed: tree([31, 33, 37, 378], [256]) }); + const result = await mutations(h).readBands(MODEM); + expect(result).toEqual({ + ok: true, + bands: { + supported: ['eutran-1', 'eutran-3', 'eutran-7', 'ngran-78'], + current: ['any'], + }, + }); + }); + + it('reports an EMPTY supported set as a real reading, not a failure', async () => { + const h = harness({ managed: tree([], []) }); + const result = await mutations(h).readBands(MODEM); + expect(result).toEqual({ ok: true, bands: { supported: [], current: [] } }); + }); + + it('reports a modem with no Modem interface as a FAILED read', async () => { + const h = harness({ managed: [] }); + const result = await mutations(h).readBands(MODEM); + expect(result.ok).toBe(false); + }); + + it('never throws when the bus call fails', async () => { + const h = harness({ getFails: new Error('bus is gone') }); + const result = await mutations(h).readBands(MODEM); + expect(result).toEqual({ ok: false, reason: 'reading bands failed: bus is gone' }); + }); +}); + +describe('setCurrentBands', () => { + it('calls Modem.SetCurrentBands with the `au` values, on the modem object', async () => { + const h = harness({}); + const result = await mutations(h).setCurrentBands(MODEM, ['eutran-3', 'ngran-78']); + expect(result.status).toBe('applied'); + expect(h.calls).toHaveLength(1); + expect(h.calls[0]).toMatchObject({ + path: MODEM, + interface: MODEM_IFACE, + member: 'SetCurrentBands', + signature: 'au', + args: [[33, 378]], + }); + }); + + it('releases the lock by setting exactly `any` — MM has no reset verb', async () => { + const h = harness({}); + const result = await mutations(h).setCurrentBands(MODEM, ['any']); + expect(result).toEqual({ + dimension: 'band', + status: 'applied', + reason: 'band lock released', + }); + expect(h.calls[0]).toMatchObject({ args: [[256]] }); + }); + + it('DISPATCHES NOTHING for a band this build cannot place', async () => { + const h = harness({}); + const result = await mutations(h).setCurrentBands(MODEM, ['eutran-3', 'nonsense']); + expect(result.status).toBe('unsupported'); + expect(h.calls).toHaveLength(0); + }); + + it('dispatches nothing for an empty selection', async () => { + const h = harness({}); + const result = await mutations(h).setCurrentBands(MODEM, []); + expect(result.status).toBe('failed'); + expect(h.calls).toHaveLength(0); + }); + + it('reports a refused write as failed rather than throwing', async () => { + const h = harness({ + setFails: new Error('org.freedesktop.ModemManager1.Error.Core.Unsupported'), + }); + const result = await mutations(h).setCurrentBands(MODEM, ['eutran-3']); + expect(result.status).toBe('failed'); + expect(result.reason).toContain('SetCurrentBands failed'); + }); +}); diff --git a/control/src/backend/mm-location.test.ts b/control/src/backend/mm-location.test.ts new file mode 100644 index 0000000..d201562 --- /dev/null +++ b/control/src/backend/mm-location.test.ts @@ -0,0 +1,392 @@ +// The `Modem.Location` adapter, against the REAL fleet capability masks. +// +// The four capability fixtures below are the literal `mmcli --location-status` +// readings captured on the bench (`hardware-gates.md` §(c), 2026-08-17): three +// modems advertise GNSS and the FM350-GL advertises `3gpp-lac-ci` only. They are +// used verbatim so the capability gate is proven against hardware that exists +// rather than against an invented mask. + +import { describe, expect, test } from 'bun:test'; +import { epochMillis } from '../domain'; +import type { ModemRef } from '../ports'; +import type { DbusValue, DbusVariant, MethodCall, MethodReply } from '../transport'; +import { MODEM_LOCATION_IFACE } from './constants'; +import { decodeLocationSources, encodeLocationSources, MmLocation } from './mm-location'; +import { ModemActor } from './modem-actor'; + +const MODEM = '/org/freedesktop/ModemManager1/Modem/14' as ModemRef; +const STABLE_KEY = 'slot:bench-c4'; + +// hardware-gates.md §(c) — captured live on ceralive2, 2026-08-17. +const FLEET_CAPABILITIES = { + 'Quectel RM530N-GL': ['gps-raw', 'gps-nmea', 'gps-unmanaged', 'agps-msa', 'agps-msb'], + 'SIMCom SIM7600G-H': ['gps-raw', 'gps-nmea', 'gps-unmanaged', 'agps-msa', 'agps-msb'], + 'Qualcomm HIMI_U01': ['gps-raw', 'gps-nmea', 'agps-msa', 'agps-msb'], + 'Fibocom FM350-GL': ['3gpp-lac-ci'], +} as const; + +function v(signature: string, value: DbusValue): DbusVariant { + return { signature, value }; +} + +interface FakeOptions { + readonly capabilities: readonly string[]; + readonly enabled?: readonly string[]; + /** `GetLocation` payload, or an Error to throw. `undefined` = empty dict. */ + readonly location?: DbusValue | Error; + readonly setupError?: Error; + readonly treeError?: Error; + /** Omit the Location interface entirely, as a non-Location modem would. */ + readonly noLocationInterface?: boolean; +} + +interface FakeTransport { + readonly transport: { + callMethod(call: MethodCall): Promise; + }; + readonly calls: MethodCall[]; +} + +function fakeTransport(options: FakeOptions): FakeTransport { + const calls: MethodCall[] = []; + let enabledMask = encodeLocationSources(options.enabled ?? []); + + const tree = (): DbusValue => + [ + [ + MODEM, + options.noLocationInterface + ? [] + : [ + [ + MODEM_LOCATION_IFACE, + [ + ['Capabilities', v('u', encodeLocationSources(options.capabilities))], + ['Enabled', v('u', enabledMask)], + ], + ], + ], + ], + ] as unknown as DbusValue; + + return { + calls, + transport: { + callMethod(call: MethodCall): Promise { + calls.push(call); + if (call.member === 'GetManagedObjects') { + if (options.treeError) { + return Promise.reject(options.treeError); + } + return Promise.resolve({ signature: 'a{oa{sa{sv}}}', body: [tree()] }); + } + if (call.member === 'Setup') { + if (options.setupError) { + return Promise.reject(options.setupError); + } + enabledMask = Number(call.args?.[0] ?? 0); + return Promise.resolve({ signature: '', body: [] }); + } + if (call.member === 'GetLocation') { + if (options.location instanceof Error) { + return Promise.reject(options.location); + } + return Promise.resolve({ signature: 'a{uv}', body: [options.location ?? []] }); + } + return Promise.reject(new Error(`unexpected member ${call.member}`)); + }, + }, + }; +} + +function build( + options: FakeOptions, + now = () => 1_000, +): { + readonly location: MmLocation; + readonly calls: MethodCall[]; +} { + const fake = fakeTransport(options); + const location = new MmLocation({ + transport: fake.transport as never, + actor: new ModemActor(), + resolveStableKey: () => STABLE_KEY, + now, + }); + return { location, calls: fake.calls }; +} + +const RAW_FIX = (lat: number, lon: number, alt?: number): DbusValue => + [ + [ + 1 << 1, + v('a{sv}', [ + ['latitude', v('d', lat)], + ['longitude', v('d', lon)], + ...(alt === undefined ? [] : [['altitude', v('d', alt)] as const]), + ['utc-time', v('s', '181908.00')], + ] as unknown as DbusValue), + ], + ] as unknown as DbusValue; + +describe('bitmask codec', () => { + test('round-trips every fleet capability set', () => { + for (const [model, sources] of Object.entries(FLEET_CAPABILITIES)) { + const decoded = decodeLocationSources(encodeLocationSources(sources)); + expect([...decoded].sort(), model).toEqual([...sources].sort()); + } + }); + + test('an unknown source name contributes no bits rather than corrupting the mask', () => { + expect(encodeLocationSources(['gps-raw', 'not-a-source'])).toBe( + encodeLocationSources(['gps-raw']), + ); + }); +}); + +describe('capability detection against the real fleet', () => { + test('the three GNSS-advertising fleet modems report gnssCapable', async () => { + for (const model of ['Quectel RM530N-GL', 'SIMCom SIM7600G-H', 'Qualcomm HIMI_U01'] as const) { + const { location } = build({ capabilities: FLEET_CAPABILITIES[model] }); + const result = await location.getLocationStatus(MODEM); + expect(result.ok, model).toBe(true); + if (result.ok) { + expect(result.status.gnssCapable, model).toBe(true); + expect(result.status.gnssEnabled, model).toBe(false); + } + } + }); + + test('the FM350-GL advertises 3gpp-lac-ci only and is NOT gnssCapable', async () => { + const { location } = build({ capabilities: FLEET_CAPABILITIES['Fibocom FM350-GL'] }); + const result = await location.getLocationStatus(MODEM); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.status.capabilities.has('3gpp-lac-ci')).toBe(true); + expect(result.status.gnssCapable).toBe(false); + } + }); + + test('the Quectel reading with 3gpp-lac-ci already enabled reports it enabled, GNSS not', async () => { + const { location } = build({ + capabilities: [...FLEET_CAPABILITIES['Quectel RM530N-GL'], '3gpp-lac-ci'], + enabled: ['3gpp-lac-ci'], + }); + const result = await location.getLocationStatus(MODEM); + expect(result.ok && result.status.enabledSources.has('3gpp-lac-ci')).toBe(true); + expect(result.ok && result.status.gnssEnabled).toBe(false); + }); + + test('a modem with no Location interface is reported honestly, not as absent GNSS', async () => { + const { location } = build({ capabilities: [], noLocationInterface: true }); + const result = await location.getLocationStatus(MODEM); + expect(result.ok).toBe(false); + }); +}); + +describe('enable / disable', () => { + test('enable turns on the requested sources and reports them back', async () => { + const { location, calls } = build({ capabilities: FLEET_CAPABILITIES['Quectel RM530N-GL'] }); + const result = await location.enableGnss(MODEM, ['gps-raw', 'gps-nmea']); + + expect(result.outcome).toBe('applied'); + expect([...result.enabledSources].sort()).toEqual(['gps-nmea', 'gps-raw']); + const setup = calls.find((call) => call.member === 'Setup'); + expect(setup?.interface).toBe(MODEM_LOCATION_IFACE); + expect(setup?.signature).toBe('ub'); + }); + + test('signal_location is ALWAYS false — coordinates never go onto the bus', async () => { + const { location, calls } = build({ capabilities: FLEET_CAPABILITIES['Quectel RM530N-GL'] }); + await location.enableGnss(MODEM, ['gps-raw']); + await location.disableGnss(MODEM); + + const setups = calls.filter((call) => call.member === 'Setup'); + expect(setups.length).toBe(2); + for (const setup of setups) { + expect(setup.args?.[1]).toBe(false); + } + }); + + test('a source the modem does not advertise is dropped, not sent', async () => { + const { location } = build({ capabilities: FLEET_CAPABILITIES['Qualcomm HIMI_U01'] }); + const result = await location.enableGnss(MODEM, ['gps-raw', 'gps-unmanaged']); + expect(result.outcome).toBe('applied'); + expect(result.enabledSources.has('gps-unmanaged')).toBe(false); + expect(result.enabledSources.has('gps-raw')).toBe(true); + }); + + test('enabling on a non-GNSS modem is refused honestly, never silently applied', async () => { + const { location, calls } = build({ capabilities: FLEET_CAPABILITIES['Fibocom FM350-GL'] }); + const result = await location.enableGnss(MODEM, ['gps-raw']); + + expect(result.outcome).toBe('unsupported'); + expect(result.reason).toContain('GNSS'); + expect(calls.some((call) => call.member === 'Setup')).toBe(false); + }); + + test('disable clears ONLY the GNSS bits — cell location survives', async () => { + const { location, calls } = build({ + capabilities: [...FLEET_CAPABILITIES['Quectel RM530N-GL'], '3gpp-lac-ci'], + enabled: ['3gpp-lac-ci', 'gps-raw', 'gps-nmea'], + }); + const result = await location.disableGnss(MODEM); + + expect(result.outcome).toBe('applied'); + expect([...result.enabledSources]).toEqual(['3gpp-lac-ci']); + const setup = calls.find((call) => call.member === 'Setup'); + expect(Number(setup?.args?.[0])).toBe(encodeLocationSources(['3gpp-lac-ci'])); + }); + + test('a Setup failure is surfaced as failed, never as applied', async () => { + const { location } = build({ + capabilities: FLEET_CAPABILITIES['Quectel RM530N-GL'], + setupError: new Error('org.freedesktop.DBus.Error.AccessDenied'), + }); + const result = await location.enableGnss(MODEM, ['gps-raw']); + expect(result.outcome).toBe('failed'); + expect(result.reason).toContain('AccessDenied'); + }); +}); + +describe('reading the fix — the no-antenna paths', () => { + test('GNSS switched off answers `disabled`, not `no-fix`', async () => { + const { location } = build({ capabilities: FLEET_CAPABILITIES['Quectel RM530N-GL'] }); + const read = await location.readFix(MODEM); + expect(read.outcome).toBe('disabled'); + }); + + test('a non-GNSS modem answers `unsupported`, never `no-fix`', async () => { + const { location } = build({ capabilities: FLEET_CAPABILITIES['Fibocom FM350-GL'] }); + expect((await location.readFix(MODEM)).outcome).toBe('unsupported'); + }); + + test('enabled with an empty GetLocation is an honest `no-fix` — the antenna-less case', async () => { + const { location } = build({ + capabilities: FLEET_CAPABILITIES['Quectel RM530N-GL'], + enabled: ['gps-raw', 'gps-nmea'], + location: [], + }); + const read = await location.readFix(MODEM); + expect(read.outcome).toBe('no-fix'); + }); + + test('a gps-raw entry present but carrying no coordinates is still `no-fix`', async () => { + const { location } = build({ + capabilities: FLEET_CAPABILITIES['Quectel RM530N-GL'], + enabled: ['gps-raw'], + location: [ + [1 << 1, v('a{sv}', [['utc-time', v('s', '181908.00')]] as unknown as DbusValue)], + ] as unknown as DbusValue, + }); + expect((await location.readFix(MODEM)).outcome).toBe('no-fix'); + }); + + test('a quality-0 NMEA block is `no-fix`, not a decoded position', async () => { + const { location } = build({ + capabilities: FLEET_CAPABILITIES['Quectel RM530N-GL'], + enabled: ['gps-nmea'], + location: [[1 << 2, v('s', '$GPGGA,000000,,,,,0,00,99.99,,,,,,*48')]] as unknown as DbusValue, + }); + expect((await location.readFix(MODEM)).outcome).toBe('no-fix'); + }); + + test('a GetLocation error is `error`, never a silent `no-fix`', async () => { + const { location } = build({ + capabilities: FLEET_CAPABILITIES['Quectel RM530N-GL'], + enabled: ['gps-raw'], + location: new Error('org.freedesktop.ModemManager1.Error.Core.Retry'), + }); + const read = await location.readFix(MODEM); + expect(read.outcome).toBe('error'); + expect(read.outcome === 'error' && read.reason).toContain('Retry'); + }); +}); + +describe('reading the fix — a real position', () => { + test('a gps-raw dict decodes with the READ timestamp, not the modem clock', async () => { + const { location } = build( + { + capabilities: FLEET_CAPABILITIES['Quectel RM530N-GL'], + enabled: ['gps-raw'], + location: RAW_FIX(4.60971, -74.08175, 2640), + }, + () => 555_000, + ); + const read = await location.readFix(MODEM); + expect(read.outcome).toBe('fix'); + if (read.outcome === 'fix') { + expect(read.fix.latitude).toBe(4.60971); + expect(read.fix.longitude).toBe(-74.08175); + expect(read.fix.altitude).toBe(2640); + expect(read.fix.observedAt).toBe(epochMillis(555_000)); + expect(read.fix.utcTime).toBe('181908.00'); + } + }); + + test('NMEA is the fallback when gps-raw carries nothing usable', async () => { + const { location } = build({ + capabilities: FLEET_CAPABILITIES['Qualcomm HIMI_U01'], + enabled: ['gps-nmea'], + location: [ + [1 << 2, v('s', '$GPGGA,123519,4807.038,N,01131.000,E,1,08,0.9,545.4,M,46.9,M,,*47')], + ] as unknown as DbusValue, + }); + const read = await location.readFix(MODEM); + expect(read.outcome).toBe('fix'); + if (read.outcome === 'fix') { + expect(read.fix.latitude).toBeCloseTo(48.1173, 4); + } + }); +}); + +describe('the bearer is never touched', () => { + test('no GNSS call quiesces NetworkManager — a GPS toggle must not drop a link', async () => { + let quiesced = 0; + const fake = fakeTransport({ capabilities: FLEET_CAPABILITIES['Quectel RM530N-GL'] }); + const location = new MmLocation({ + transport: fake.transport as never, + actor: new ModemActor({ + acquire: () => { + quiesced += 1; + return Promise.resolve({ release: () => Promise.resolve() }); + }, + }), + resolveStableKey: () => STABLE_KEY, + }); + + await location.enableGnss(MODEM, ['gps-raw']); + await location.disableGnss(MODEM); + await location.readFix(MODEM); + + expect(quiesced).toBe(0); + }); + + test('every GNSS D-Bus call targets the Location interface and nothing else', async () => { + const { location, calls } = build({ + capabilities: FLEET_CAPABILITIES['Quectel RM530N-GL'], + enabled: ['gps-raw'], + location: RAW_FIX(4.6, -74.08), + }); + await location.enableGnss(MODEM, ['gps-raw']); + await location.readFix(MODEM); + + const members = new Set(calls.map((call) => call.member)); + expect(members.has('Setup')).toBe(true); + expect(members.has('GetLocation')).toBe(true); + for (const call of calls) { + if (call.member !== 'GetManagedObjects') { + expect(call.interface).toBe(MODEM_LOCATION_IFACE); + } + } + }); +}); + +describe('a hostile bus never throws out of the adapter', () => { + test('a failing GetManagedObjects degrades to a typed refusal', async () => { + const { location } = build({ capabilities: [], treeError: new Error('bus gone') }); + expect((await location.getLocationStatus(MODEM)).ok).toBe(false); + expect((await location.enableGnss(MODEM, ['gps-raw'])).outcome).toBe('unsupported'); + expect((await location.readFix(MODEM)).outcome).toBe('unsupported'); + }); +}); diff --git a/control/src/backend/mm-location.ts b/control/src/backend/mm-location.ts new file mode 100644 index 0000000..ed62f93 --- /dev/null +++ b/control/src/backend/mm-location.ts @@ -0,0 +1,295 @@ +// The `Modem.Location` adapter — GNSS status, enable/disable, and the current fix. +// +// Three decisions here are load-bearing and easy to undo by accident: +// +// 1. `Setup`'s `signal_location` argument is ALWAYS false. Passing true makes MM +// broadcast the `Location` property over `PropertiesChanged`, which would put +// the operator's coordinates on the system bus for every listener — including +// this package's own observer, whose snapshots are logged. The fix is fetched +// by an explicit `GetLocation` call instead, so a coordinate only ever exists +// where someone asked for it. +// 2. GNSS runs through `actor.run`, NOT `actor.runQuiesced`. Quiescing exists to +// stop NetworkManager racing a disruptive change, and it costs a bearer +// deactivation — on a bonded device, dropping a link to switch a GPS receiver +// on would be an absurd trade. `Modem.Location.Setup` touches no bearer, so +// serialization per modem is all that is needed. +// 3. Disable CLEARS only the GNSS bits. `3gpp-lac-ci` is the cell-info module's +// source; blanking the whole mask would silently switch off a neighbouring +// feature the operator never touched. + +import { epochMillis } from '../domain'; +import { parseNmeaFix } from '../location/nmea'; +import type { + FixRead, + GnssFix, + GnssSource, + LocationStatus, + LocationStatusResult, + LocationToggleResult, + ModemLocationPort, + ModemRef, +} from '../ports'; +import { hasGnssSource } from '../ports'; +import type { DbusTransport, DbusValue, DbusVariant } from '../transport'; +import { MM_BUS_NAME, MODEM_LOCATION_IFACE } from './constants'; +import { + type DecodedProps, + fetchManagedObjects, + findInterface, + propValue, +} from './managed-objects'; +import type { ModemActor } from './modem-actor'; + +/** MMModemLocationSource bits, by decoded source name. */ +const SOURCE_BIT: Record = { + '3gpp-lac-ci': 1 << 0, + 'gps-raw': 1 << 1, + 'gps-nmea': 1 << 2, + 'cdma-bs': 1 << 3, + 'gps-unmanaged': 1 << 4, + 'agps-msa': 1 << 5, + 'agps-msb': 1 << 6, +}; + +const GPS_RAW_BIT = SOURCE_BIT['gps-raw'] as number; +const GPS_NMEA_BIT = SOURCE_BIT['gps-nmea'] as number; + +const GNSS_MASK = + GPS_RAW_BIT | + GPS_NMEA_BIT | + (SOURCE_BIT['gps-unmanaged'] as number) | + (SOURCE_BIT['agps-msa'] as number) | + (SOURCE_BIT['agps-msb'] as number); + +/** Decode an MMModemLocationSource bitmask into source names. */ +export function decodeLocationSources(mask: number): ReadonlySet { + const sources = new Set(); + for (const [name, bit] of Object.entries(SOURCE_BIT)) { + if ((mask & bit) !== 0) { + sources.add(name); + } + } + return sources; +} + +export function encodeLocationSources(sources: Iterable): number { + let mask = 0; + for (const source of sources) { + mask |= SOURCE_BIT[source] ?? 0; + } + return mask; +} + +export interface MmLocationDeps { + readonly transport: DbusTransport; + readonly actor: ModemActor; + readonly destination?: string; + readonly resolveStableKey: (modem: ModemRef) => string; + readonly now?: () => number; +} + +export class MmLocation implements ModemLocationPort { + readonly #transport: DbusTransport; + readonly #actor: ModemActor; + readonly #destination: string; + readonly #resolveStableKey: (modem: ModemRef) => string; + readonly #now: () => number; + + constructor(deps: MmLocationDeps) { + this.#transport = deps.transport; + this.#actor = deps.actor; + this.#destination = deps.destination ?? MM_BUS_NAME; + this.#resolveStableKey = deps.resolveStableKey; + this.#now = deps.now ?? Date.now; + } + + async getLocationStatus(modem: ModemRef): Promise { + const props = await this.#readLocationProps(modem); + if (props === undefined) { + return { ok: false, reason: 'the modem does not expose a Location interface' }; + } + return { ok: true, status: statusOf(props) }; + } + + enableGnss(modem: ModemRef, sources: readonly GnssSource[]): Promise { + return this.#actor.run(this.#resolveStableKey(modem), async () => { + const props = await this.#readLocationProps(modem); + if (props === undefined) { + return refused('the modem does not expose a Location interface'); + } + const status = statusOf(props); + if (!status.gnssCapable) { + return refused('the modem advertises no GNSS source', status.enabledSources); + } + const requested = encodeLocationSources(sources) & maskOfNames(status.capabilities); + if (requested === 0) { + return refused( + 'none of the requested GNSS sources is advertised by this modem', + status.enabledSources, + ); + } + const target = maskOfNames(status.enabledSources) | requested; + return this.#setup(modem, target, 'GNSS enabled'); + }); + } + + disableGnss(modem: ModemRef): Promise { + return this.#actor.run(this.#resolveStableKey(modem), async () => { + const props = await this.#readLocationProps(modem); + if (props === undefined) { + return refused('the modem does not expose a Location interface'); + } + const status = statusOf(props); + const target = maskOfNames(status.enabledSources) & ~GNSS_MASK; + return this.#setup(modem, target, 'GNSS disabled'); + }); + } + + async readFix(modem: ModemRef): Promise { + const props = await this.#readLocationProps(modem); + if (props === undefined) { + return { outcome: 'unsupported', reason: 'the modem does not expose a Location interface' }; + } + const status = statusOf(props); + if (!status.gnssCapable) { + return { outcome: 'unsupported', reason: 'the modem advertises no GNSS source' }; + } + if (!status.gnssEnabled) { + return { outcome: 'disabled', reason: 'GNSS is switched off on this modem' }; + } + let reply: { readonly body: readonly DbusValue[] }; + try { + reply = await this.#transport.callMethod({ + destination: this.#destination, + path: modem, + interface: MODEM_LOCATION_IFACE, + member: 'GetLocation', + }); + } catch (error) { + return { outcome: 'error', reason: `GetLocation failed: ${describe(error)}` }; + } + const fix = decodeFix(reply.body[0], epochMillis(this.#now())); + return fix === undefined + ? { outcome: 'no-fix', reason: 'the receiver has not acquired a position' } + : { outcome: 'fix', fix }; + } + + async #setup( + modem: ModemRef, + mask: number, + appliedReason: string, + ): Promise { + try { + await this.#transport.callMethod({ + destination: this.#destination, + path: modem, + interface: MODEM_LOCATION_IFACE, + member: 'Setup', + signature: 'ub', + args: [mask, false], + }); + } catch (error) { + return { + outcome: 'failed', + reason: `Location.Setup failed: ${describe(error)}`, + enabledSources: new Set(), + }; + } + return { + outcome: 'applied', + reason: appliedReason, + enabledSources: decodeLocationSources(mask), + }; + } + + async #readLocationProps(modem: ModemRef): Promise { + try { + const tree = await fetchManagedObjects(this.#transport, this.#destination); + return findInterface(tree, modem, MODEM_LOCATION_IFACE); + } catch { + return undefined; + } + } +} + +function maskOfNames(names: Iterable): number { + return encodeLocationSources(names); +} + +function refused(reason: string, enabled: ReadonlySet = new Set()): LocationToggleResult { + return { outcome: 'unsupported', reason, enabledSources: enabled }; +} + +function statusOf(props: DecodedProps): LocationStatus { + const capabilities = decodeLocationSources(maskProp(props, 'Capabilities')); + const enabledSources = decodeLocationSources(maskProp(props, 'Enabled')); + return { + capabilities, + enabledSources, + gnssCapable: hasGnssSource(capabilities), + gnssEnabled: hasGnssSource(enabledSources), + }; +} + +function maskProp(props: DecodedProps, name: string): number { + const value = propValue(props, name); + return typeof value === 'number' && Number.isFinite(value) ? value : 0; +} + +/** `a{uv}` decodes to `[sourceBit, variant][]`. */ +type LocationEntries = ReadonlyArray; + +function entryValue(payload: DbusValue | undefined, bit: number): DbusValue | undefined { + if (!Array.isArray(payload)) { + return undefined; + } + for (const entry of payload as unknown as LocationEntries) { + if (Number(entry[0]) === bit) { + return entry[1]?.value; + } + } + return undefined; +} + +function numberField(props: DecodedProps, name: string): number | undefined { + const value = propValue(props, name); + return typeof value === 'number' && Number.isFinite(value) ? value : undefined; +} + +/** + * Prefer MM's pre-decoded `gps-raw` dict; fall back to parsing the `gps-nmea` + * sentences. A raw entry that is present but carries no usable coordinate pair is + * NOT treated as a fix — MM populates the key as soon as the source is on. + */ +function decodeFix( + payload: DbusValue | undefined, + observedAt: GnssFix['observedAt'], +): GnssFix | undefined { + const raw = entryValue(payload, GPS_RAW_BIT); + if (Array.isArray(raw)) { + const props = raw as unknown as DecodedProps; + const latitude = numberField(props, 'latitude'); + const longitude = numberField(props, 'longitude'); + if (latitude !== undefined && longitude !== undefined) { + const altitude = numberField(props, 'altitude'); + const utcTime = propValue(props, 'utc-time'); + return { + latitude, + longitude, + ...(altitude === undefined ? {} : { altitude }), + ...(typeof utcTime === 'string' ? { utcTime } : {}), + observedAt, + }; + } + } + const nmea = entryValue(payload, GPS_NMEA_BIT); + if (typeof nmea !== 'string') { + return undefined; + } + const parsed = parseNmeaFix(nmea); + return parsed === undefined ? undefined : { ...parsed, observedAt }; +} + +function describe(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/control/src/backend/mm-mutations.ts b/control/src/backend/mm-mutations.ts index da355da..7da7333 100644 --- a/control/src/backend/mm-mutations.ts +++ b/control/src/backend/mm-mutations.ts @@ -7,9 +7,11 @@ // NOT quiesce — they don't touch the bearer. NONE of these methods can reach a bearer // or connect verb: the port has none, and the fake's tripwire proves it at test time. +import { decodeBandList, encodeBandList, isResetSelection } from '../band'; import type { DesiredRadio, RadioAccessTechnology } from '../domain'; import { epochMillis } from '../domain'; import type { + BandReadResult, InhibitLease, ModemRef, NetworkScanResult, @@ -104,6 +106,95 @@ export class MmMutations { }); } + /** + * `SetCurrentModes` over the RAW `(uu)` masks the modem itself advertised. + * + * `setRadioModes` above speaks the reconciler's vocabulary — an ordered RAT + * preference — and structurally cannot express `MM_MODEM_MODE_NONE`: its preferred + * mask is derived from `preferenceOrdered[0]`, so "allow this set and prefer + * nothing within it" has no spelling. That is the combination the bench FM350-GL + * actually advertises, so a caller selecting an advertised combination verbatim + * needs this entry point. It quiesces for the same reason `setRadioModes` does: a + * mode change re-registers the radio and drops the bearer underneath NM. + * + * An `allowed` mask of 0 is refused — that is not a selection, it is a radio with + * nothing switched on — while a `preferred` mask of 0 is passed through untouched. + */ + setModeCombination(modem: ModemRef, allowed: number, preferred: number): Promise { + if (allowed === 0) { + return Promise.resolve(receipt('radio', 'failed', 'no radio modes were requested')); + } + return this.#actor.runQuiesced({ stableKey: this.#resolveStableKey(modem) }, async () => { + try { + await this.#transport.callMethod({ + destination: this.#destination, + path: modem, + interface: MODEM_IFACE, + member: 'SetCurrentModes', + signature: '(uu)', + args: [[allowed, preferred]], + }); + return receipt('radio', 'applied', 'radio mode combination applied'); + } catch (error) { + return receipt('radio', 'failed', `SetCurrentModes failed: ${describe(error)}`); + } + }); + } + + async readBands(modem: ModemRef): Promise { + try { + const tree = await fetchManagedObjects(this.#transport, this.#destination); + const props = findInterface(tree, modem, MODEM_IFACE); + if (props === undefined) { + return { ok: false, reason: 'the modem exports no Modem interface' }; + } + return { + ok: true, + bands: { + supported: decodeBandList(propValue(props, 'SupportedBands')), + current: decodeBandList(propValue(props, 'CurrentBands')), + }, + }; + } catch (error) { + return { ok: false, reason: `reading bands failed: ${describe(error)}` }; + } + } + + // Quiesced like `setRadioModes`, and for the same reason: a band change + // re-registers the radio, so NM must stand down before the bearer drops + // underneath it rather than after. + setCurrentBands(modem: ModemRef, bands: readonly string[]): Promise { + if (bands.length === 0) { + return Promise.resolve(receipt('band', 'failed', 'no bands were requested')); + } + const encoded = encodeBandList(bands); + if (!encoded.ok) { + return Promise.resolve( + receipt('band', 'unsupported', `this build does not know the band "${encoded.unknown}"`), + ); + } + const values = encoded.values; + return this.#actor.runQuiesced({ stableKey: this.#resolveStableKey(modem) }, async () => { + try { + await this.#transport.callMethod({ + destination: this.#destination, + path: modem, + interface: MODEM_IFACE, + member: 'SetCurrentBands', + signature: 'au', + args: [values], + }); + return receipt( + 'band', + 'applied', + isResetSelection(bands) ? 'band lock released' : `bands set to ${bands.join(', ')}`, + ); + } catch (error) { + return receipt('band', 'failed', `SetCurrentBands failed: ${describe(error)}`); + } + }); + } + async setPrimarySimSlot(modem: ModemRef, slotIndex: number): Promise { const slots = await this.#readSlotCount(modem); if (slots === undefined) { diff --git a/control/src/backend/usb-mode-transition.test.ts b/control/src/backend/usb-mode-transition.test.ts index 47ec0dd..cfddabf 100644 --- a/control/src/backend/usb-mode-transition.test.ts +++ b/control/src/backend/usb-mode-transition.test.ts @@ -11,6 +11,7 @@ import { describe, expect, test } from 'bun:test'; import { epochMillis } from '../domain'; import { connectionId, deviceIfname, type NetworkManagerPort, receipt } from '../ports'; +import type { CertifiedCatalog } from '../usb-mode'; import type { UsbDeviceSnapshot } from './device-classifier'; import { ModemActor } from './modem-actor'; import type { TransitionInterlock, UsbModeTransitionRequest } from './transition-preconditions'; @@ -321,3 +322,172 @@ describe('UsbModeTransition — crash mid-transaction trips the watchdog', () => expect(log.calls).toContain('mm.uninhibit'); }); }); + +/** + * A LOCAL fixture SKU, deliberately not a shipped-catalog entry. + * + * What is under test here is the ENGINE's behaviour when a catalog entry declares an + * `applyCommand` — the send order, the allowlist, and the refusal — not whether any + * particular device is certified. Driving it through the shipped catalog would make this + * suite a certification claim, and would couple an engine test to a review decision. + */ +const NV_ONLY_SKU = { + vidPid: '2c7c:0801', + model: 'CERALIVE-NV-ONLY-TEST-SKU', + firmwarePrefix: 'NVONLYFW01', +}; + +/** The transition shape this engine behaviour exists for: the switch only writes NV. */ +const NV_ONLY_CATALOG: CertifiedCatalog = { + schemaVersion: 1, + entries: [ + { + ...NV_ONLY_SKU, + canonicalMode: 'qmi', + permittedTransitions: [ + { + from: 'qmi', + to: 'mbim', + atCommand: 'AT+QCFG="usbnet",2', + applyCommand: 'AT+CFUN=1,1', + expectedResponse: 'OK', + expectsPortDrop: true, + expectedDescriptors: { + deviceClass: 0, + interfaces: [ + { interfaceClass: 0x02, interfaceSubClass: 0x0e, interfaceProtocol: 0x00 }, + { interfaceClass: 0x0a, interfaceSubClass: 0x00, interfaceProtocol: 0x02 }, + ], + }, + }, + { + from: 'mbim', + to: 'qmi', + atCommand: 'AT+QCFG="usbnet",0', + applyCommand: 'AT+CFUN=1,1', + expectedResponse: 'OK', + expectsPortDrop: true, + expectedDescriptors: { + deviceClass: 0, + interfaces: [ + { interfaceClass: 0xff, interfaceSubClass: 0xff, interfaceProtocol: 0xff }, + ], + }, + }, + ], + }, + ], +}; + +/** Descriptors transcribed from the 2026-08-19 bench capture of `4-1.4.4` on usbnet=0. */ +const NV_ONLY_QMI: UsbDeviceSnapshot = { + vendorId: '2c7c', + productId: '0801', + bDeviceClass: 0, + physicalUid: CACHED_UID, + ifname: 'wwan2', + interfaces: [ + { interfaceClass: 0xff, interfaceSubClass: 0xff, interfaceProtocol: 0x30, driver: 'option' }, + { interfaceClass: 0xff, interfaceSubClass: 0x00, interfaceProtocol: 0x40, driver: 'option' }, + { interfaceClass: 0xff, interfaceSubClass: 0x00, interfaceProtocol: 0x00, driver: 'option' }, + { interfaceClass: 0xff, interfaceSubClass: 0x00, interfaceProtocol: 0x00, driver: 'option' }, + { interfaceClass: 0xff, interfaceSubClass: 0xff, interfaceProtocol: 0xff, driver: 'qmi_wwan' }, + ], +}; + +/** From the same capture on usbnet=2 — the PID is UNCHANGED across the switch. */ +const NV_ONLY_MBIM: UsbDeviceSnapshot = { + ...NV_ONLY_QMI, + ifname: 'wwan3', + interfaces: [ + { interfaceClass: 0xff, interfaceSubClass: 0xff, interfaceProtocol: 0x30, driver: 'option' }, + { interfaceClass: 0xff, interfaceSubClass: 0x00, interfaceProtocol: 0x40, driver: 'option' }, + { interfaceClass: 0xff, interfaceSubClass: 0x00, interfaceProtocol: 0x00, driver: 'option' }, + { interfaceClass: 0xff, interfaceSubClass: 0x00, interfaceProtocol: 0x00, driver: 'option' }, + { interfaceClass: 0x02, interfaceSubClass: 0x0e, interfaceProtocol: 0x00, driver: 'cdc_mbim' }, + { interfaceClass: 0x0a, interfaceSubClass: 0x00, interfaceProtocol: 0x02, driver: 'cdc_mbim' }, + ], +}; + +function nvOnlyRequest(fromMode: 'qmi' | 'mbim', toMode: 'qmi' | 'mbim'): UsbModeTransitionRequest { + return makeRequest({ sku: NV_ONLY_SKU, fromMode, toMode, deviceIfname: deviceIfname('wwan2') }); +} + +describe('UsbModeTransition — a SKU whose AT command only writes NV also sends applyCommand', () => { + test('qmi→mbim sends the switch THEN the commit, in that order', async () => { + const log: SpyLog = { calls: [] }; + const transition = makeTransition(log, { + catalog: NV_ONLY_CATALOG, + enumerate: scriptedEnumerate([[NV_ONLY_QMI], [], [NV_ONLY_MBIM]]), + }); + + const outcome = await transition.execute(nvOnlyRequest('qmi', 'mbim')); + + expect(outcome.status).toBe('succeeded'); + expect(log.calls.filter((c) => c.startsWith('at.send:'))).toEqual([ + 'at.send:AT+QCFG="usbnet",2', + 'at.send:AT+CFUN=1,1', + ]); + // The commit is its OWN step, so a transcript distinguishes it from a retry. + expect(outcome.steps.indexOf('apply-command')).toBeGreaterThan( + outcome.steps.indexOf('at-command'), + ); + expect(outcome.steps.indexOf('await-port-drop')).toBeGreaterThan( + outcome.steps.indexOf('apply-command'), + ); + }); + + test('mbim→qmi sends the reverse switch and the SAME commit', async () => { + const log: SpyLog = { calls: [] }; + const transition = makeTransition(log, { + catalog: NV_ONLY_CATALOG, + enumerate: scriptedEnumerate([[NV_ONLY_MBIM], [], [NV_ONLY_QMI]]), + }); + + const outcome = await transition.execute(nvOnlyRequest('mbim', 'qmi')); + + expect(outcome.status).toBe('succeeded'); + expect(log.calls.filter((c) => c.startsWith('at.send:'))).toEqual([ + 'at.send:AT+QCFG="usbnet",0', + 'at.send:AT+CFUN=1,1', + ]); + }); + + test('a SKU that declares NO applyCommand still sends exactly one command', async () => { + const log: SpyLog = { calls: [] }; + const transition = makeTransition(log); + + const outcome = await transition.execute(makeRequest()); + + expect(outcome.status).toBe('succeeded'); + expect(log.calls.filter((c) => c.startsWith('at.send:'))).toHaveLength(1); + expect(outcome.steps).not.toContain('apply-command'); + }); + + test('a commit command that the AT layer REJECTS fails the transaction, never a silent skip', async () => { + const log: SpyLog = { calls: [] }; + const rejectingSender: UsbModeTransitionDeps['atSender'] = { + send: (command) => { + log.calls.push(`at.send:${command}`); + return command === 'AT+CFUN=1,1' + ? Promise.reject(new Error('commit refused by module')) + : Promise.resolve({ ok: true, raw: 'OK' }); + }, + }; + const transition = makeTransition(log, { + atSender: rejectingSender, + catalog: NV_ONLY_CATALOG, + enumerate: scriptedEnumerate([[NV_ONLY_QMI], [], [NV_ONLY_MBIM]]), + }); + + const outcome = await transition.execute(nvOnlyRequest('qmi', 'mbim')); + + expect(outcome.status).toBe('failed'); + if (outcome.status === 'failed') { + expect(outcome.degraded).toBe(true); + expect(outcome.reason).toContain('commit refused by module'); + } + expect(log.calls.some((c) => c.startsWith('nm.activate:'))).toBe(false); + expect(log.calls).toContain('mm.uninhibit'); + }); +}); diff --git a/control/src/backend/usb-mode-transition.ts b/control/src/backend/usb-mode-transition.ts index 7767359..bdb79e7 100644 --- a/control/src/backend/usb-mode-transition.ts +++ b/control/src/backend/usb-mode-transition.ts @@ -5,9 +5,11 @@ // through A3.3's shared per-modem `ModemActor` (keyed on stableKey, so it serialises // behind every other disruptive op) in a FIXED order: // -// 1 NM-quiesce → 2 inhibit-by-cached-UID → 3 AT command → 4 expected port-drop → -// 5 uninhibit → 6 await SAME physical UID → 7 POSTCONDITION → 8 resolve new ifname → -// 9 reactivate (uuid, newIfname) → 10 release interlock (finally, always). +// 1 NM-quiesce → 2 inhibit-by-cached-UID → 3 AT command → 3b catalog `applyCommand` +// (only for a SKU whose AT command writes NV without re-enumerating) → 4 expected +// port-drop → 5 uninhibit → 6 await SAME physical UID → 7 POSTCONDITION → +// 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 @@ -147,7 +149,11 @@ export class UsbModeTransition { }; const lease = new AtCommandLease({ sender: this.#atSender, - allowlist: computeAtAllowlist(allCommands.map((t) => t.atCommand)), + allowlist: computeAtAllowlist( + allCommands.flatMap((t) => + t.applyCommand === undefined ? [t.atCommand] : [t.atCommand, t.applyCommand], + ), + ), timeoutMs: this.#watchdogMs, onWatchdog: forceUninhibit, ...(this.#audit !== undefined ? { audit: this.#audit } : {}), @@ -163,6 +169,11 @@ export class UsbModeTransition { steps.push('at-command'); await lease.run(transition.atCommand, { inhibitUid: request.inhibitUid }); + if (transition.applyCommand !== undefined) { + steps.push('apply-command'); + await lease.run(transition.applyCommand, { inhibitUid: request.inhibitUid }); + } + steps.push('await-port-drop'); await this.#awaitPortDrop(request.cachedPhysicalUid); diff --git a/control/src/band/band-names.test.ts b/control/src/band/band-names.test.ts new file mode 100644 index 0000000..297764d --- /dev/null +++ b/control/src/band/band-names.test.ts @@ -0,0 +1,121 @@ +import { describe, expect, it } from 'bun:test'; + +import { + BAND_ANY, + bandName, + bandValue, + decodeBandList, + encodeBandList, + isNamedBand, + isResetSelection, +} from './band-names'; + +describe('MMModemBand ↔ name', () => { + it('decodes the irregular GSM/UTRAN head exactly as ModemManager numbers it', () => { + // UTRAN_2 = 12 and UTRAN_6 = 8: the ordering is NOT the band number, which + // is the whole reason this block is a table rather than arithmetic. + expect(bandName(1)).toBe('egsm'); + expect(bandName(4)).toBe('g850'); + expect(bandName(5)).toBe('utran-1'); + expect(bandName(8)).toBe('utran-6'); + expect(bandName(12)).toBe('utran-2'); + expect(bandName(20)).toBe('g810'); + }); + + it('decodes the arithmetic blocks from their bases', () => { + expect(bandName(31)).toBe('eutran-1'); + expect(bandName(33)).toBe('eutran-3'); + expect(bandName(101)).toBe('eutran-71'); + expect(bandName(128)).toBe('cdma-bc0'); + expect(bandName(147)).toBe('cdma-bc19'); + expect(bandName(301)).toBe('ngran-1'); + expect(bandName(378)).toBe('ngran-78'); + }); + + it('names 256 as the reset value', () => { + expect(bandName(256)).toBe(BAND_ANY); + expect(bandValue(BAND_ANY)).toBe(256); + }); + + it('round-trips a value it does not name rather than dropping or guessing', () => { + expect(bandName(9001)).toBe('band-9001'); + expect(bandValue('band-9001')).toBe(9001); + expect(isNamedBand('band-9001')).toBe(false); + expect(isNamedBand('eutran-3')).toBe(true); + }); + + it('refuses a name it cannot place', () => { + expect(bandValue('eutran-999')).toBeUndefined(); + expect(bandValue('nonsense')).toBeUndefined(); + expect(bandValue('band-')).toBeUndefined(); + }); + + it('round-trips every named band through both directions', () => { + for (const value of [1, 12, 31, 101, 128, 147, 256, 301, 378]) { + expect(bandValue(bandName(value))).toBe(value); + } + }); +}); + +describe('decodeBandList — a property read', () => { + it('decodes the RM530N-GL-shaped LTE + NR set a fleet modem advertises', () => { + // eutran-1/3/7/28 + ngran-78: the shape `SupportedBands` takes on a + // multi-mode 5G stick. Numbers, because the D-Bus property is `au`. + expect(decodeBandList([31, 33, 37, 58, 378])).toEqual([ + 'eutran-1', + 'eutran-3', + 'eutran-7', + 'eutran-28', + 'ngran-78', + ]); + }); + + it('decodes the SIM7600-shaped GSM + UTRAN + LTE set', () => { + expect(decodeBandList([1, 2, 3, 4, 5, 12, 31, 33, 38, 50])).toEqual([ + 'egsm', + 'dcs', + 'pcs', + 'g850', + 'utran-1', + 'utran-2', + 'eutran-1', + 'eutran-3', + 'eutran-8', + 'eutran-20', + ]); + }); + + it('reports a single `any` for an unlocked modem', () => { + expect(decodeBandList([256])).toEqual(['any']); + }); + + it('drops `unknown` and non-numeric members instead of inventing bands', () => { + expect(decodeBandList([0, 31, 'eutran-3', null, 1.5])).toEqual(['eutran-1']); + }); + + it('answers an empty list for a non-array property', () => { + expect(decodeBandList(undefined)).toEqual([]); + expect(decodeBandList('eutran-3')).toEqual([]); + }); +}); + +describe('encodeBandList — a write', () => { + it('encodes a selection', () => { + expect(encodeBandList(['eutran-3', 'ngran-78'])).toEqual({ ok: true, values: [33, 378] }); + }); + + it('FAILS CLOSED as a whole on one unplaceable name', () => { + // A partial band set is a different lock from the one that was asked for. + expect(encodeBandList(['eutran-3', 'nonsense', 'eutran-7'])).toEqual({ + ok: false, + unknown: 'nonsense', + }); + }); + + it('recognises the reset selection, and only that selection', () => { + expect(isResetSelection(['any'])).toBe(true); + expect(isResetSelection(['any', 'eutran-3'])).toBe(false); + expect(isResetSelection([])).toBe(false); + expect(isResetSelection(['eutran-3'])).toBe(false); + }); +}); diff --git a/control/src/band/band-names.ts b/control/src/band/band-names.ts new file mode 100644 index 0000000..67731bd --- /dev/null +++ b/control/src/band/band-names.ts @@ -0,0 +1,164 @@ +// MMModemBand ↔ band NAME, in both directions. +// +// The D-Bus surface speaks numbers: `Modem.SupportedBands` and +// `Modem.CurrentBands` are `au`, and `Modem.SetCurrentBands` takes `au`. Every +// operator-facing surface — mmcli's `--set-current-bands`, this package's API, +// CeraUI's selector — speaks the NAME (`eutran-3`, `utran-1`, `egsm`, `any`). +// One mapping, here, so the two can never disagree. +// +// WHERE THE NUMBERS COME FROM. ModemManager's `MMModemBand` (`mm-enums.h`) is +// two different things stitched together, and this module reproduces exactly +// that shape rather than flattening it into one big table: +// +// * the GSM/UTRAN block (1..20) is IRREGULAR — `UTRAN_2` is 12 while `UTRAN_6` +// is 8 — because MM appended bands in the order they were needed, so it is +// an explicit table and can only ever be an explicit table; +// * every later block is ARITHMETIC, by MM's own construction: `EUTRAN_n` is +// `30 + n`, `CDMA_BCn` is `128 + n`, `NGRAN_n` is `300 + n`. +// +// Deriving the arithmetic blocks rather than transcribing ~350 constants is the +// point: a transcription is where a wrong band number would hide, and a wrong +// band number sets a radio to a band the SIM's network does not operate on. +// +// A value this module does not recognise is NEVER dropped and NEVER guessed at. +// It round-trips as `band-`, so an unfamiliar band a future ModemManager +// reports is still shown to the operator, still comparable, and still settable — +// the same "unknown is an answer about the read" discipline `detect.ts` follows. + +/** A band as every operator-facing surface spells it. Opaque; compare by equality. */ +export type BandName = string; + +/** `MM_MODEM_BAND_UNKNOWN`. Never a member of a supported/current set we act on. */ +export const BAND_UNKNOWN = 'unknown'; + +/** + * `MM_MODEM_BAND_ANY` (256) — "let the modem choose", i.e. the reset value. + * Setting exactly this is how a band lock is released; there is no separate + * ModemManager verb for it. + */ +export const BAND_ANY = 'any'; +const BAND_ANY_VALUE = 256; + +/** + * The irregular head of the enum (`MM_MODEM_BAND_EGSM` = 1 … `G810` = 20), + * transcribed because it cannot be derived. + */ +const IRREGULAR: Readonly> = { + 0: BAND_UNKNOWN, + 1: 'egsm', + 2: 'dcs', + 3: 'pcs', + 4: 'g850', + 5: 'utran-1', + 6: 'utran-3', + 7: 'utran-4', + 8: 'utran-6', + 9: 'utran-5', + 10: 'utran-8', + 11: 'utran-9', + 12: 'utran-2', + 13: 'utran-7', + 14: 'g450', + 15: 'g480', + 16: 'g750', + 17: 'g380', + 18: 'g410', + 19: 'g710', + 20: 'g810', + [BAND_ANY_VALUE]: BAND_ANY, +}; + +/** Arithmetic blocks: `[prefix, base, firstIndex, lastIndex]`. */ +const ARITHMETIC: readonly (readonly [string, number, number, number])[] = [ + // MM_MODEM_BAND_EUTRAN_1 = 31 … EUTRAN_71 = 101. + ['eutran-', 30, 1, 71], + // MM_MODEM_BAND_CDMA_BC0 = 128 … CDMA_BC19 = 147. + ['cdma-bc', 128, 0, 19], + // MM_MODEM_BAND_NGRAN_1 = 301 … the 5G NR block. + ['ngran-', 300, 1, 261], +]; + +const NUMBER_TO_NAME = new Map(); +const NAME_TO_NUMBER = new Map(); + +for (const [value, name] of Object.entries(IRREGULAR)) { + NUMBER_TO_NAME.set(Number(value), name); + NAME_TO_NUMBER.set(name, Number(value)); +} +for (const [prefix, base, first, last] of ARITHMETIC) { + for (let index = first; index <= last; index += 1) { + const name = `${prefix}${index}`; + NUMBER_TO_NAME.set(base + index, name); + NAME_TO_NUMBER.set(name, base + index); + } +} + +/** The passthrough spelling for a band value this build does not name. */ +const PASSTHROUGH_RE = /^band-(\d+)$/; + +/** Decode one `MMModemBand` value. Total: an unknown value round-trips. */ +export function bandName(value: number): BandName { + return NUMBER_TO_NAME.get(value) ?? `band-${value}`; +} + +/** Encode one band name. `undefined` for a name this build cannot place. */ +export function bandValue(name: BandName): number | undefined { + const known = NAME_TO_NUMBER.get(name); + if (known !== undefined) return known; + const passthrough = PASSTHROUGH_RE.exec(name); + if (passthrough?.[1] === undefined) return undefined; + const parsed = Number(passthrough[1]); + return Number.isSafeInteger(parsed) ? parsed : undefined; +} + +/** True when this build recognises the name (a `band-` passthrough does not). */ +export function isNamedBand(name: BandName): boolean { + return NAME_TO_NUMBER.has(name); +} + +/** + * Decode a `SupportedBands` / `CurrentBands` property value. + * + * Non-numeric members are DROPPED rather than coerced — a malformed member says + * nothing about the radio, and coercing it would invent a band. `unknown` is + * dropped for the same reason: MM emits it for "the modem did not say", which is + * not a band an operator can select. + */ +export function decodeBandList(value: unknown): readonly BandName[] { + if (!Array.isArray(value)) return []; + const names: BandName[] = []; + for (const member of value) { + if (typeof member !== 'number' || !Number.isSafeInteger(member)) continue; + const name = bandName(member); + if (name === BAND_UNKNOWN) continue; + names.push(name); + } + return names; +} + +/** + * Encode a band selection for `SetCurrentBands`. + * + * FAILS CLOSED AS A WHOLE. One unplaceable name rejects the entire request + * rather than silently narrowing the selection: a partial band set is a + * DIFFERENT lock from the one the operator asked for, and applying it would + * strand the radio on bands they never chose. + */ +export function encodeBandList( + names: readonly BandName[], +): + | { readonly ok: true; readonly values: number[] } + | { readonly ok: false; readonly unknown: BandName } { + const values: number[] = []; + for (const name of names) { + const value = bandValue(name); + if (value === undefined) return { ok: false, unknown: name }; + values.push(value); + } + return { ok: true, values }; +} + +/** True when the selection is exactly the reset value (`any`, alone). */ +export function isResetSelection(names: readonly BandName[]): boolean { + return names.length === 1 && names[0] === BAND_ANY; +} diff --git a/control/src/band/certification.test.ts b/control/src/band/certification.test.ts new file mode 100644 index 0000000..7f7be93 --- /dev/null +++ b/control/src/band/certification.test.ts @@ -0,0 +1,110 @@ +import { describe, expect, it } from 'bun:test'; + +import { + BAND_CERTIFICATION_CATALOG, + type BandCertificationCatalog, + findBandCertification, + isBandControlCertified, + loadBandCertificationCatalog, + offerableBands, +} from './certification'; + +const QUECTEL = { + vidPid: '2c7c:0801', + model: 'RM530N-GL', + firmwarePrefix: 'RM530NGLAAR05A01M4G', +}; + +function catalogWith(entries: unknown[]): BandCertificationCatalog { + return loadBandCertificationCatalog({ schemaVersion: 1, entries }); +} + +const CERTIFIED_QUECTEL = { + vidPid: '2c7c:0801', + model: 'RM530N-GL', + firmwarePrefix: 'RM530NGLAAR05', + evidence: 'evidence/todo30.md', + proofs: { supportedRead: true, set: true, readback: true, reset: true }, +}; + +describe('the SHIPPED band catalog', () => { + it('is EMPTY — nothing in the fleet has been through the drill', () => { + expect(BAND_CERTIFICATION_CATALOG.entries).toEqual([]); + }); + + it('therefore certifies no fleet modem', () => { + expect(isBandControlCertified(BAND_CERTIFICATION_CATALOG, QUECTEL)).toBe(false); + }); +}); + +describe('the schema refuses a HALF-certified entry', () => { + it('rejects an entry missing a proof step', () => { + expect(() => + catalogWith([ + { + ...CERTIFIED_QUECTEL, + proofs: { supportedRead: true, set: true, readback: true }, + }, + ]), + ).toThrow(); + }); + + it('rejects an entry that states a proof step as false', () => { + // A `false` would read as a catalog row, and a catalog row is what + // surfaces the control. The absence of an entry is the only way to say + // "not proven". + expect(() => + catalogWith([ + { ...CERTIFIED_QUECTEL, proofs: { ...CERTIFIED_QUECTEL.proofs, reset: false } }, + ]), + ).toThrow(); + }); + + it('rejects an unknown field', () => { + expect(() => catalogWith([{ ...CERTIFIED_QUECTEL, notes: 'x' }])).toThrow(); + }); +}); + +describe('matching a device', () => { + it('matches a firmware FAMILY by prefix of the device revision', () => { + const catalog = catalogWith([CERTIFIED_QUECTEL]); + expect(findBandCertification(catalog, QUECTEL)?.evidence).toBe('evidence/todo30.md'); + }); + + it('does NOT match when the device revision is one character short of the entry', () => { + const catalog = catalogWith([CERTIFIED_QUECTEL]); + expect( + findBandCertification(catalog, { ...QUECTEL, firmwarePrefix: 'RM530NGLAAR0' }), + ).toBeUndefined(); + }); + + it('requires all three discriminators', () => { + const catalog = catalogWith([CERTIFIED_QUECTEL]); + expect(findBandCertification(catalog, { ...QUECTEL, model: 'RM520N-GL' })).toBeUndefined(); + expect(findBandCertification(catalog, { ...QUECTEL, vidPid: '2c7c:0125' })).toBeUndefined(); + }); + + it('fails closed for a device whose SKU could not be resolved', () => { + expect(isBandControlCertified(catalogWith([CERTIFIED_QUECTEL]), undefined)).toBe(false); + }); +}); + +describe('offerableBands', () => { + const supported = ['eutran-1', 'eutran-3', 'eutran-7', 'ngran-78']; + + it('offers NOTHING without an entry', () => { + expect(offerableBands(undefined, supported)).toEqual([]); + }); + + it('offers the whole advertised set when the drill swept it', () => { + const [entry] = catalogWith([CERTIFIED_QUECTEL]).entries; + expect(offerableBands(entry, supported)).toEqual(supported); + }); + + it('NARROWS to the proven bands, intersected with what the device advertises now', () => { + const [entry] = catalogWith([ + { ...CERTIFIED_QUECTEL, provenBands: ['eutran-3', 'eutran-20'] }, + ]).entries; + expect(offerableBands(entry, supported)).toEqual(['eutran-3']); + }); +}); diff --git a/control/src/band/certification.ts b/control/src/band/certification.ts new file mode 100644 index 0000000..e63f6eb --- /dev/null +++ b/control/src/band/certification.ts @@ -0,0 +1,151 @@ +// The band-lock certification catalog — what has actually been PROVEN, per SKU. +// +// A band lock is the one capability module that can take a working uplink off the +// air and leave it there: a band the SIM's network does not operate on registers +// nowhere, and a modem that does not honour a reset leaves the operator with no +// way back short of a replug they may not be able to reach. So band-lock is +// deliberately STRICTER than the framework floor in `support-claim.ts`. The +// framework offers a control at `capable` (the modem advertises the capability) +// because hiding an uncertified-but-working control puts hardware behind a +// paperwork gate. For this module the paperwork IS the safety argument, so the +// control stays HIDDEN until an entry here proves the whole round trip. +// +// FOUR STEPS, ALL FOUR REQUIRED, and they are separate booleans rather than one +// `certified: true` because each can fail on its own and each failure means a +// different thing: +// +// supportedRead — `SupportedBands` was read and named real bands. Without it +// there is nothing to offer. +// set — `SetCurrentBands` was accepted for a band in that set. +// readback — `CurrentBands` afterwards reported exactly what was asked +// for. An accepted-but-ignored write is the failure mode that +// looks like success from the call site alone. +// reset — setting `any` restored the baseline. This is the escape +// hatch; a SKU that cannot be shown to reset must never be +// locked in the first place. +// +// THE SHIPPED CATALOG IS EMPTY, and that is the honest state. Nothing in the +// fleet has been through the drill — see the phase-C band-lock evidence for why +// (the bench Quectel's SIM never registers, so "re-registration proven" cannot +// be claimed today). An entry is added by a human-reviewed commit carrying the +// bench transcript, exactly like `usb-mode/certified-catalog.json`. + +import { z } from 'zod'; + +import rawCatalog from './certified-bands.json' with { type: 'json' }; + +/** + * The device a certification is about. Firmware is a PREFIX for the same reason + * the USB-mode catalog matches one: an entry certifies a firmware FAMILY, and + * where the family ends is a per-SKU judgement a reviewer makes, not something a + * device can compute. + */ +export const bandSkuSchema = z + .object({ + vidPid: z.string().regex(/^[0-9a-f]{4}:[0-9a-f]{4}$/), + model: z.string().min(1), + firmwarePrefix: z.string().min(1), + }) + .strict(); +export type BandSku = z.infer; + +export const bandProofSchema = z + .object({ + supportedRead: z.literal(true), + set: z.literal(true), + readback: z.literal(true), + reset: z.literal(true), + }) + .strict(); + +/** + * All four proofs are `z.literal(true)`, so a HALF-certified entry cannot be + * expressed at all. A reviewer with three of four proofs has an uncertified SKU + * and the file must say so by omitting it — a `false` field would read as a + * catalog row, and a catalog row is what surfaces the control. + */ +export const bandCertificationEntrySchema = z + .object({ + vidPid: z.string().regex(/^[0-9a-f]{4}:[0-9a-f]{4}$/), + model: z.string().min(1), + firmwarePrefix: z.string().min(1), + /** Free-form: where the transcript proving the four steps lives. */ + evidence: z.string().min(1), + proofs: bandProofSchema, + /** + * Bands the reviewer proved were individually settable AND resettable. + * An EMPTY list means "the whole advertised set", which is what a drill + * that swept the set records; a non-empty list NARROWS what is offered. + */ + provenBands: z.array(z.string()).default([]), + }) + .strict(); +export type BandCertificationEntry = z.infer; + +export const bandCertificationCatalogSchema = z + .object({ + schemaVersion: z.literal(1), + entries: z.array(bandCertificationEntrySchema), + }) + .strict(); +export type BandCertificationCatalog = z.infer; + +/** Parse an untrusted value as a band catalog. Throws with a precise path. */ +export function loadBandCertificationCatalog(value: unknown): BandCertificationCatalog { + return bandCertificationCatalogSchema.parse(value); +} + +/** The catalog shipped in this package, validated at module load. */ +export const BAND_CERTIFICATION_CATALOG: BandCertificationCatalog = + loadBandCertificationCatalog(rawCatalog); + +/** + * The entry certifying this exact device, or `undefined`. + * + * All three discriminators must match, and the firmware match is a PREFIX of the + * device's FULL revision — never a truncation of the device's revision to the + * entry's length, which would certify a family the reviewer never looked at. + */ +export function findBandCertification( + catalog: BandCertificationCatalog, + sku: BandSku, +): BandCertificationEntry | undefined { + return catalog.entries.find( + (entry) => + entry.vidPid === sku.vidPid && + entry.model === sku.model && + sku.firmwarePrefix.startsWith(entry.firmwarePrefix), + ); +} + +/** + * Whether a band-lock control may be OFFERED for this device. + * + * Fail-closed in every direction: an unknown SKU, a SKU with no entry, and a SKU + * whose entry predates a proof step all answer `false`. + */ +export function isBandControlCertified( + catalog: BandCertificationCatalog, + sku: BandSku | undefined, +): boolean { + if (sku === undefined) return false; + return findBandCertification(catalog, sku) !== undefined; +} + +/** + * Narrow an advertised band set to what the certification proves is settable. + * + * An entry with no `provenBands` proves the whole advertised set (the drill swept + * it); an entry that names bands offers ONLY those, intersected with what the + * modem advertises right now — a certified band the device no longer advertises + * is not offerable, and the device's own answer outranks the catalog. + */ +export function offerableBands( + entry: BandCertificationEntry | undefined, + supported: readonly string[], +): readonly string[] { + if (entry === undefined) return []; + if (entry.provenBands.length === 0) return supported; + const proven = new Set(entry.provenBands); + return supported.filter((band) => proven.has(band)); +} diff --git a/control/src/band/certified-bands.json b/control/src/band/certified-bands.json new file mode 100644 index 0000000..6fca69b --- /dev/null +++ b/control/src/band/certified-bands.json @@ -0,0 +1,4 @@ +{ + "schemaVersion": 1, + "entries": [] +} diff --git a/control/src/band/index.ts b/control/src/band/index.ts new file mode 100644 index 0000000..26e571e --- /dev/null +++ b/control/src/band/index.ts @@ -0,0 +1,9 @@ +// The band-lock capability module: the MMModemBand vocabulary and the +// certification catalog that gates whether a control may be offered at all. +// +// The D-Bus verbs themselves live with the other ModemManager mutations +// (`backend/mm-mutations.ts`), declared on `ModemManagerPort` — a band change is +// a radio mutation like a mode change, not a subsystem of its own. + +export * from './band-names'; +export * from './certification'; diff --git a/control/src/capability/detect.test.ts b/control/src/capability/detect.test.ts new file mode 100644 index 0000000..a9a0087 --- /dev/null +++ b/control/src/capability/detect.test.ts @@ -0,0 +1,142 @@ +import { describe, expect, test } from 'bun:test'; + +import { + detectCapabilityModules, + LOCATION_IFACE, + MESSAGING_IFACE, + type ModuleCapabilityProbe, + USSD_IFACE, +} from './detect'; + +type ProbeOverrides = { + readonly [K in keyof ModuleCapabilityProbe]?: ModuleCapabilityProbe[K] | undefined; +}; + +function probe(overrides: ProbeOverrides = {}): ModuleCapabilityProbe { + const interfaces = 'interfaces' in overrides ? overrides.interfaces : DEFAULT_INTERFACES; + const locationSources = + 'locationSources' in overrides ? overrides.locationSources : DEFAULT_LOCATION_SOURCES; + const supportedRats = overrides.supportedRats; + return { + properties: overrides.properties ?? DEFAULT_PROPERTIES, + ...(interfaces === undefined ? {} : { interfaces }), + ...(locationSources === undefined ? {} : { locationSources }), + ...(supportedRats === undefined ? {} : { supportedRats }), + }; +} + +const DEFAULT_PROPERTIES = new Set(['SupportedBands', 'SupportedModes', 'SimType']); +const DEFAULT_INTERFACES = new Set([MESSAGING_IFACE, USSD_IFACE, LOCATION_IFACE]); +const DEFAULT_LOCATION_SOURCES = new Set(['gps-raw', 'gps-nmea']); + +describe('per-module capability detection', () => { + test('a fully-featured modem advertises every probeable module', () => { + const detected = detectCapabilityModules(probe()); + expect(detected['band-lock']).toBe('present'); + expect(detected['five-g-pref']).toBe('present'); + expect(detected.esim).toBe('present'); + expect(detected.sms).toBe('present'); + expect(detected.ussd).toBe('present'); + expect(detected.gps).toBe('present'); + }); + + test('an OBSERVED modem missing a property reports absent, not unknown', () => { + const detected = detectCapabilityModules(probe({ properties: new Set(['SupportedModes']) })); + expect(detected['band-lock']).toBe('absent'); + expect(detected.esim).toBe('absent'); + expect(detected['five-g-pref']).toBe('present'); + }); + + test('a read that never landed reports unknown for every property-backed module', () => { + const detected = detectCapabilityModules(probe({ properties: new Set() })); + expect(detected['band-lock']).toBe('unknown'); + expect(detected['five-g-pref']).toBe('unknown'); + expect(detected.esim).toBe('unknown'); + }); + + test('`EsimStatus` alone is enough for eSIM — the two spellings coexist', () => { + expect(detectCapabilityModules(probe({ properties: new Set(['EsimStatus']) })).esim).toBe( + 'present', + ); + }); + + test('an unobserved interface set reports unknown, never absent', () => { + const detected = detectCapabilityModules(probe({ interfaces: undefined })); + expect(detected.sms).toBe('unknown'); + expect(detected.ussd).toBe('unknown'); + expect(detected.gps).toBe('unknown'); + }); + + test('an observed modem without the Messaging/USSD interfaces reports absent', () => { + const detected = detectCapabilityModules(probe({ interfaces: new Set() })); + expect(detected.sms).toBe('absent'); + expect(detected.ussd).toBe('absent'); + }); + + test('the Location interface alone is NOT a GNSS claim', () => { + expect(detectCapabilityModules(probe({ locationSources: new Set(['3gpp-lac-ci']) })).gps).toBe( + 'absent', + ); + expect(detectCapabilityModules(probe({ locationSources: undefined })).gps).toBe('unknown'); + }); + + test('FCC auto-unlock is never inferred from the modem surface', () => { + expect(detectCapabilityModules(probe())['fcc-auto-unlock']).toBe('unknown'); + expect(detectCapabilityModules(probe({ properties: new Set() }))['fcc-auto-unlock']).toBe( + 'unknown', + ); + }); + + test('detection never throws on a hostile probe', () => { + expect(() => + detectCapabilityModules({ + properties: new Set(), + interfaces: new Set(), + locationSources: new Set(), + }), + ).not.toThrow(); + }); +}); + +describe('`five-g-pref` narrows on the decoded mode catalog', () => { + // The property NAME is not the question: every MM modem exports + // `SupportedModes`, so the name alone resolves `present` on a 4G-only radio. + test('a decoded catalog naming 5GNR is present', () => { + expect( + detectCapabilityModules(probe({ supportedRats: new Set(['lte', '5gnr']) }))['five-g-pref'], + ).toBe('present'); + }); + + test('a decoded catalog WITHOUT 5GNR is absent, even though the property exists', () => { + const detected = detectCapabilityModules(probe({ supportedRats: new Set(['umts', 'lte']) })); + expect(detected['five-g-pref']).toBe('absent'); + // The property-name answer for the same probe is `present` — proving the + // decoded catalog is what narrowed it, not a changed property set. + expect(detectCapabilityModules(probe())['five-g-pref']).toBe('present'); + }); + + test('an EMPTY decoded catalog is unknown, never absent', () => { + expect(detectCapabilityModules(probe({ supportedRats: new Set() }))['five-g-pref']).toBe( + 'unknown', + ); + }); + + test('a caller that decoded no catalog keeps the byte-identical property-name answer', () => { + expect(detectCapabilityModules(probe({ supportedRats: undefined }))['five-g-pref']).toBe( + 'present', + ); + expect( + detectCapabilityModules(probe({ properties: new Set(), supportedRats: undefined }))[ + 'five-g-pref' + ], + ).toBe('unknown'); + }); + + test('narrowing touches ONLY this module', () => { + const narrowed = detectCapabilityModules(probe({ supportedRats: new Set(['lte']) })); + const base = detectCapabilityModules(probe()); + for (const module of ['band-lock', 'sms', 'ussd', 'gps', 'esim', 'fcc-auto-unlock'] as const) { + expect(narrowed[module]).toBe(base[module]); + } + }); +}); diff --git a/control/src/capability/detect.ts b/control/src/capability/detect.ts new file mode 100644 index 0000000..43ac263 --- /dev/null +++ b/control/src/capability/detect.ts @@ -0,0 +1,128 @@ +// Per-module capability detection — what ONE modem can actually be asked to do. +// +// It follows `backend/features.ts` exactly, because the constraints are the same: +// detection must PROBE the observed surface rather than match a version +// whitelist, it must never throw, and an unseen future ModemManager must degrade +// gracefully instead of resolving to a confident wrong answer. +// +// The one rule this module adds is that `unknown` is a first-class result. A +// property set we never observed says nothing about the device, and the ladder in +// `support-claim.ts` stops at `enabled` for it — surfaced by nothing, mutated by +// nothing. Answering `absent` there would hide a working capability; answering +// `present` would offer a control the modem cannot honour. + +import { MODEM_IFACE, MODEM3GPP_USSD_IFACE } from '../backend/constants'; +import type { MmPropertyProbe } from '../backend/features'; +import type { RadioAccessTechnology } from '../domain'; +import { fiveGPreferenceEvidence } from './five-g-preference'; +import type { CapabilityEvidence, CapabilityModule } from './support-claim'; + +/** MM's Messaging interface — SMS list/read lives here. */ +export const MESSAGING_IFACE = `${MODEM_IFACE}.Messaging`; +/** MM's Location interface — GNSS sources are advertised here. */ +export const LOCATION_IFACE = `${MODEM_IFACE}.Location`; +/** MM's 3GPP USSD interface — the same name the USSD adapter dials. */ +export const USSD_IFACE = MODEM3GPP_USSD_IFACE; + +/** + * What A3.2 observed, plus the two signals the static object tree cannot carry. + * + * `interfaces` is the set of interface NAMES exported on the modem object, which + * is how SMS/USSD/Location are advertised — they are separate interfaces, not + * properties, so `MmPropertyProbe` alone cannot see them. + */ +export interface ModuleCapabilityProbe extends MmPropertyProbe { + readonly interfaces?: ReadonlySet; + /** + * `Location.Capabilities` decoded to source names (`gps-raw`, `gps-nmea`, …). + * An EMPTY set is a real answer — the interface exists and offers no GNSS. + */ + readonly locationSources?: ReadonlySet; + /** + * `SupportedModes` decoded to the RAT families it actually names. + * + * OPTIONAL, and its absence is the pre-existing behaviour verbatim: a caller + * that decoded only property NAMES answers exactly as before. Supplying it + * strictly NARROWS `five-g-pref`, because the property's mere presence is + * equally true of a 4G-only modem — see `detectCapabilityModules`. + */ + readonly supportedRats?: ReadonlySet; +} + +const GNSS_SOURCES = ['gps-raw', 'gps-nmea', 'gps-unmanaged', 'agps-msa', 'agps-msb']; + +/** + * A property we did not see is only evidence of ABSENCE when we saw the object at + * all. An empty property set means the read never landed. + */ +function fromProperty(probe: ModuleCapabilityProbe, name: string): CapabilityEvidence { + if (probe.properties.size === 0) { + return 'unknown'; + } + return probe.properties.has(name) ? 'present' : 'absent'; +} + +function fromAnyProperty( + probe: ModuleCapabilityProbe, + names: readonly string[], +): CapabilityEvidence { + if (probe.properties.size === 0) { + return 'unknown'; + } + return names.some((name) => probe.properties.has(name)) ? 'present' : 'absent'; +} + +function fromInterface(probe: ModuleCapabilityProbe, iface: string): CapabilityEvidence { + if (probe.interfaces === undefined) { + return 'unknown'; + } + return probe.interfaces.has(iface) ? 'present' : 'absent'; +} + +function detectGnss(probe: ModuleCapabilityProbe): CapabilityEvidence { + const iface = fromInterface(probe, LOCATION_IFACE); + if (iface !== 'present') { + return iface; + } + // The interface being exported is not the same claim as the modem offering a + // GNSS source — MM exports Location for 3GPP-LAC/CID-only devices too. + if (probe.locationSources === undefined) { + return 'unknown'; + } + return GNSS_SOURCES.some((source) => probe.locationSources?.has(source)) ? 'present' : 'absent'; +} + +/** + * Detect every module's capability for one modem. Pure, total, never throws. + * + * `fcc-auto-unlock` is deliberately always `unknown`: FCC unlock is carried out + * by a ModemManager PLUGIN keyed on the device, and nothing on the modem's own + * D-Bus surface advertises whether one applies. Reporting `absent` would hide the + * module on hardware that supports it, and `present` would promise a plugin that + * may not be installed — so the honest answer is that this probe cannot tell, and + * evidence for it has to come from the catalog instead. + * + * `five-g-pref` is the one module whose property NAME is not the question. Every + * ModemManager modem exports `SupportedModes`, including a 4G-only one, so the + * name alone would resolve `present` on hardware with no 5G at all and offer a 5G + * posture nothing could honour. When the caller decoded the property's VALUE the + * verdict narrows to whether the catalog actually names 5GNR; when it did not, + * the property-name answer stands, so this is a strict narrowing and never a new + * way to claim a capability. + */ +export function detectCapabilityModules( + probe: ModuleCapabilityProbe, +): Record { + return { + 'band-lock': fromProperty(probe, 'SupportedBands'), + sms: fromInterface(probe, MESSAGING_IFACE), + 'five-g-pref': + probe.supportedRats === undefined + ? fromProperty(probe, 'SupportedModes') + : fiveGPreferenceEvidence(probe.supportedRats), + 'fcc-auto-unlock': 'unknown', + gps: detectGnss(probe), + ussd: fromInterface(probe, USSD_IFACE), + esim: fromAnyProperty(probe, ['SimType', 'EsimStatus']), + }; +} diff --git a/control/src/capability/five-g-preference.test.ts b/control/src/capability/five-g-preference.test.ts new file mode 100644 index 0000000..e9d5111 --- /dev/null +++ b/control/src/capability/five-g-preference.test.ts @@ -0,0 +1,174 @@ +import { describe, expect, test } from 'bun:test'; + +import type { RadioAccessTechnology } from '../domain'; +import { + FIVE_G_PREFERENCES, + type FiveGPreference, + fiveGPreferenceConfirmed, + fiveGPreferenceEvidence, + fiveGPreferenceToRadio, + NR_MODE_UNSUPPORTED_REASON, + nrModeSelection, + offeredFiveGPreferences, + type RadioModeSet, + readFiveGPreference, +} from './five-g-preference'; + +const rats = (...list: readonly RadioAccessTechnology[]): ReadonlySet => + new Set(list); + +/** The bench Quectel RM530N-GL's advertised families (todo 2, `ceralive2`). */ +const QUECTEL = rats('gsm', 'umts', 'lte', '5gnr'); +/** The bench SIMCom SIM7600G-H — LTE-max, no 5G. */ +const SIMCOM = rats('gsm', 'umts', 'lte'); + +const modes = ( + allowed: ReadonlySet, + preferred?: RadioAccessTechnology, +): RadioModeSet => ({ allowed, ...(preferred === undefined ? {} : { preferred }) }); + +describe('capability evidence', () => { + test('an unobserved catalog is UNKNOWN, never absent', () => { + expect(fiveGPreferenceEvidence(undefined)).toBe('unknown'); + expect(fiveGPreferenceEvidence(rats())).toBe('unknown'); + }); + + test('an observed catalog answers on whether it names 5GNR', () => { + expect(fiveGPreferenceEvidence(QUECTEL)).toBe('present'); + expect(fiveGPreferenceEvidence(SIMCOM)).toBe('absent'); + }); +}); + +describe('which postures may be offered', () => { + test('a 5G modem with fallback is offered all four', () => { + expect(offeredFiveGPreferences(QUECTEL)).toEqual([...FIVE_G_PREFERENCES]); + }); + + test('a modem with NO 5G is offered NOTHING — not even 5g-off', () => { + // `5g-off` on a radio with no 5G is a control that cannot change anything, + // which is worse than an absent one: it invites an operator to act. + expect(offeredFiveGPreferences(SIMCOM)).toEqual([]); + expect(offeredFiveGPreferences(undefined)).toEqual([]); + }); + + test('a 5G-ONLY radio is offered only 5g-only — three labels for one posture is not a choice', () => { + expect(offeredFiveGPreferences(rats('5gnr'))).toEqual(['5g-only']); + }); + + test('prefer-4g is withheld from a 5G modem that advertises no LTE', () => { + expect(offeredFiveGPreferences(rats('umts', '5gnr'))).toEqual([ + '5g-only', + 'prefer-5g', + '5g-off', + ]); + }); +}); + +describe('preference → (allowed, preferred)', () => { + test('prefer-5g and prefer-4g share ONE allowed set and differ only in ranking', () => { + const preferFive = fiveGPreferenceToRadio('prefer-5g', QUECTEL); + const preferFour = fiveGPreferenceToRadio('prefer-4g', QUECTEL); + + expect([...(preferFive?.allowedSet ?? [])].sort()).toEqual( + [...(preferFour?.allowedSet ?? [])].sort(), + ); + expect(preferFive?.preferenceOrdered[0]).toBe('5gnr'); + expect(preferFour?.preferenceOrdered[0]).toBe('lte'); + }); + + test('5g-only allows 5G and nothing else', () => { + const target = fiveGPreferenceToRadio('5g-only', QUECTEL); + expect([...(target?.allowedSet ?? [])]).toEqual(['5gnr']); + expect(target?.preferenceOrdered).toEqual(['5gnr']); + }); + + test('5g-off keeps every sub-5G family the modem advertised', () => { + const target = fiveGPreferenceToRadio('5g-off', QUECTEL); + expect(target?.preferenceOrdered).toEqual(['lte', 'umts', 'gsm']); + expect(target?.allowedSet.has('5gnr')).toBe(false); + }); + + test('a posture the modem cannot express resolves UNDEFINED, never a neighbour', () => { + // Substituting is how "prefer 4G" on a marginal cell silently becomes 5G-first. + for (const preference of FIVE_G_PREFERENCES) { + expect(fiveGPreferenceToRadio(preference, SIMCOM)).toBeUndefined(); + expect(fiveGPreferenceToRadio(preference, undefined)).toBeUndefined(); + } + expect(fiveGPreferenceToRadio('prefer-4g', rats('umts', '5gnr'))).toBeUndefined(); + }); + + test('every offered posture resolves to a target — the two rules cannot disagree', () => { + for (const catalog of [QUECTEL, rats('5gnr'), rats('lte', '5gnr')]) { + for (const preference of offeredFiveGPreferences(catalog)) { + expect(fiveGPreferenceToRadio(preference, catalog)).toBeDefined(); + } + } + }); +}); + +describe('reading the posture back off the radio', () => { + const cases: readonly (readonly [string, RadioModeSet, FiveGPreference | undefined])[] = [ + ['5G alone', modes(rats('5gnr')), '5g-only'], + ['5G first', modes(QUECTEL, '5gnr'), 'prefer-5g'], + ['LTE first', modes(QUECTEL, 'lte'), 'prefer-4g'], + ['no 5G at all', modes(SIMCOM, 'lte'), '5g-off'], + ['no 5G, UMTS-ranked', modes(SIMCOM, 'umts'), '5g-off'], + ['5G allowed, UMTS preferred', modes(QUECTEL, 'umts'), undefined], + ['5G allowed, nothing preferred', modes(QUECTEL), undefined], + ]; + + for (const [name, current, expected] of cases) { + test(`${name} → ${expected ?? 'undefined'}`, () => { + expect(readFiveGPreference(current)).toBe(expected); + }); + } + + test('an unread radio is UNDEFINED and is never rounded to a posture', () => { + expect(readFiveGPreference(undefined)).toBeUndefined(); + expect(readFiveGPreference(modes(rats()))).toBeUndefined(); + }); + + test('every posture round-trips through its own target', () => { + for (const preference of offeredFiveGPreferences(QUECTEL)) { + const target = fiveGPreferenceToRadio(preference, QUECTEL); + expect( + readFiveGPreference(modes(target?.allowedSet ?? rats(), target?.preferenceOrdered[0])), + ).toBe(preference); + } + }); +}); + +describe('the readback IS the confirmation', () => { + test('a radio that landed on the request confirms', () => { + expect(fiveGPreferenceConfirmed('prefer-4g', modes(QUECTEL, 'lte'))).toBe(true); + }); + + test('a radio that CLAMPED the request does NOT confirm', () => { + // MM accepting the method call is not the radio taking the mode set. + expect(fiveGPreferenceConfirmed('prefer-4g', modes(QUECTEL, '5gnr'))).toBe(false); + expect(fiveGPreferenceConfirmed('5g-only', modes(QUECTEL, '5gnr'))).toBe(false); + }); + + test('an unreadable radio does NOT confirm', () => { + expect(fiveGPreferenceConfirmed('prefer-5g', undefined)).toBe(false); + }); +}); + +describe('SA / NSA', () => { + test('is reported unsupported with a reason, never omitted', () => { + // A missing field reads as "nobody asked"; a stated reason tells an operator + // hunting for an SA toggle why there is none. + expect(nrModeSelection()).toEqual({ + supported: false, + reason: NR_MODE_UNSUPPORTED_REASON, + }); + }); + + test('this module opens NO vendor AT surface for it', async () => { + const source = await Bun.file(new URL('./five-g-preference.ts', import.meta.url)).text(); + const executable = source.replace(/\/\/[^\n]*/g, '').replace(/\/\*[\s\S]*?\*\//g, ''); + for (const token of ['QNWPREFCFG', 'AT+', 'callMethod', 'transport']) { + expect(executable).not.toContain(token); + } + }); +}); diff --git a/control/src/capability/five-g-preference.ts b/control/src/capability/five-g-preference.ts new file mode 100644 index 0000000..1416310 --- /dev/null +++ b/control/src/capability/five-g-preference.ts @@ -0,0 +1,219 @@ +// The `five-g-pref` capability module's MODEL — which 5G postures a modem can be +// asked for, how each maps onto ModemManager's `SetCurrentModes`, and which one +// the radio is on right now. +// +// It is pure and total. Nothing here talks to a bus: `MmMutations.setRadioModes` +// already owns the D-Bus call, the per-modem serialization and the quiesce, so +// this module's whole job is to decide WHICH `DesiredRadio` a stated preference +// means and to refuse to name one the modem never advertised. +// +// WHY THE MODULE EXISTS AT ALL, given the 3G/4G/5G selector already writes +// `SetCurrentModes`: that selector's vocabulary is the ALLOWED SET. Two genuinely +// different postures — "allow 4G and 5G, prefer 5G" and "allow 4G and 5G, prefer +// 4G" — share one allowed set and are distinguishable only by the PREFERRED mode, +// which the coarse selector folds away. An operator on a marginal 5G cell wants +// exactly that distinction, and it is the one thing they could not previously ask +// for. + +import type { RadioAccessTechnology } from '../domain'; +import type { CapabilityEvidence } from './support-claim'; + +/** + * The postures this module offers. Deliberately four NAMED intents rather than a + * free (allowed, preferred) pair: an arbitrary pair is expressible on the wire but + * not answerable by an operator, and most pairs are postures nobody wants. + * + * 5g-only — allow ONLY 5G. No fallback: out of 5G coverage the modem stops + * registering, which is why it is offered but never a default. + * prefer-5g — allow 5G and everything below it, rank 5G first. + * prefer-4g — the SAME allowed set, ranked LTE first. This is the posture a + * marginal 5G cell calls for, and the one the coarse allowed-set + * selector structurally cannot express. + * 5g-off — allow everything the modem supports EXCEPT 5G. + */ +export const FIVE_G_PREFERENCES = ['5g-only', 'prefer-5g', 'prefer-4g', '5g-off'] as const; +export type FiveGPreference = (typeof FIVE_G_PREFERENCES)[number]; + +/** One `(allowed, preferred)` pair, the shape MM's `CurrentModes` really carries. */ +export interface RadioModeSet { + readonly allowed: ReadonlySet; + /** MM's `preferred` is a SINGLE mode, and `none` is a legal answer. */ + readonly preferred?: RadioAccessTechnology | undefined; +} + +/** What a stated preference resolves to, in `MmMutations.setRadioModes`'s shape. */ +export interface FiveGRadioTarget { + readonly preferenceOrdered: readonly RadioAccessTechnology[]; + readonly allowedSet: ReadonlySet; +} + +/** + * SA vs NSA — and the honest answer, which is that ModemManager does not expose + * the choice at all. + * + * Checked against MM 1.24.2's own D-Bus surface rather than recalled: the only + * NR-specific member on a modem object is + * `Modem.Modem3gpp.SetNr5gRegistrationSettings`, whose keys are `mico-mode` and + * `drx-cycle` — power-saving registration parameters, not a standalone-vs + * non-standalone selector. Vendors expose the selector through their own AT + * commands (Quectel `AT+QNWPREFCFG="mode_pref"`, and one per vendor after that), + * which is a per-SKU control surface this module deliberately does not open: an + * uncertified AT write that can cost registration is exactly what the evidence + * gate exists to keep out. + * + * So the axis is REPORTED as unsupported rather than omitted. A missing field + * reads as "nobody asked"; a stated `not-exposed-by-modemmanager` tells an + * operator looking for an SA toggle why there is none. + */ +export const NR_MODE_UNSUPPORTED_REASON = 'not-exposed-by-modemmanager' as const; + +export interface NrModeSelection { + readonly supported: false; + readonly reason: typeof NR_MODE_UNSUPPORTED_REASON; +} + +/** The SA/NSA verdict. Constant today, and a function so a future SKU-certified + * path replaces one call site rather than every consumer. */ +export function nrModeSelection(): NrModeSelection { + return { supported: false, reason: NR_MODE_UNSUPPORTED_REASON }; +} + +/** RAT ranking, highest generation first. The ONE ordering this module uses. */ +const RAT_ORDER: readonly RadioAccessTechnology[] = ['5gnr', 'lte', 'umts', 'gsm']; + +function ranked(rats: ReadonlySet): readonly RadioAccessTechnology[] { + return RAT_ORDER.filter((rat) => rats.has(rat)); +} + +/** + * Does this modem's advertised mode catalog contain 5G? + * + * An EMPTY catalog is `unknown`, never `absent` — an unobserved read says nothing + * about the device, and reporting `absent` there would hide the module on a modem + * that supports it. This is the same first-class-`unknown` rule `detect.ts` + * follows, applied one level deeper: `detect.ts` can only see that a + * `SupportedModes` property EXISTS, which is equally true of a 4G-only modem. + */ +export function fiveGPreferenceEvidence( + supportedRats: ReadonlySet | undefined, +): CapabilityEvidence { + if (supportedRats === undefined || supportedRats.size === 0) { + return 'unknown'; + } + return supportedRats.has('5gnr') ? 'present' : 'absent'; +} + +/** + * Which postures this modem can actually be asked for. + * + * A modem with no 5G is offered NOTHING — not `5g-off` either, because "turn 5G + * off" is a control that would change nothing on a radio that has no 5G, and a + * control that cannot change anything is the defect this repo's evidence gate + * exists to keep off an operator's screen. + * + * `prefer-5g` / `prefer-4g` / `5g-off` each additionally require a SUB-5G mode to + * fall back to: on a 5G-only radio they would all collapse onto the same allowed + * set as `5g-only`, i.e. three labels for one posture. + */ +export function offeredFiveGPreferences( + supportedRats: ReadonlySet | undefined, +): readonly FiveGPreference[] { + if (supportedRats === undefined || !supportedRats.has('5gnr')) { + return []; + } + const lower = ranked(supportedRats).filter((rat) => rat !== '5gnr'); + if (lower.length === 0) { + return ['5g-only']; + } + // `prefer-4g` names LTE specifically, so it is offered only on a modem that + // has LTE. A 5G+UMTS-only radio is not a real fleet device, but naming a mode + // the modem never advertised is the one thing this module must not do. + return FIVE_G_PREFERENCES.filter( + (preference) => preference !== 'prefer-4g' || supportedRats.has('lte'), + ); +} + +/** + * Resolve a stated preference into the `(allowed, preferred)` pair to write. + * + * `undefined` means this modem cannot express the posture — the caller must + * REFUSE rather than substitute a neighbouring one. Substituting is how an + * operator asks for "prefer 4G" on a marginal cell and silently gets 5G-first. + */ +export function fiveGPreferenceToRadio( + preference: FiveGPreference, + supportedRats: ReadonlySet | undefined, +): FiveGRadioTarget | undefined { + if (supportedRats === undefined || !offeredFiveGPreferences(supportedRats).includes(preference)) { + return undefined; + } + const all = ranked(supportedRats); + const withoutNr = all.filter((rat) => rat !== '5gnr'); + + switch (preference) { + case '5g-only': + return { allowedSet: new Set(['5gnr']), preferenceOrdered: ['5gnr'] }; + case 'prefer-5g': + return { allowedSet: new Set(all), preferenceOrdered: all }; + case 'prefer-4g': + // The allowed set is IDENTICAL to `prefer-5g`'s — only the ranking moves. + // That is the whole point of the posture, and it is why a consumer must + // never diff allowed sets to decide whether a write is needed. + return { + allowedSet: new Set(all), + preferenceOrdered: ['lte', ...all.filter((rat) => rat !== 'lte')], + }; + case '5g-off': + return { allowedSet: new Set(withoutNr), preferenceOrdered: withoutNr }; + } +} + +/** + * Which posture a modem's CURRENT modes name — or `undefined` for a pair this + * model does not name. + * + * `undefined` is a first-class answer and must not be rounded to the nearest + * posture: a radio parked on `allowed: 3g,4g; preferred: 3g` is in a state no 5G + * preference describes, and reporting one would show an operator a selection they + * never made and cannot get back to. + */ +export function readFiveGPreference( + current: RadioModeSet | undefined, +): FiveGPreference | undefined { + if (current === undefined || current.allowed.size === 0) { + return undefined; + } + const has5g = current.allowed.has('5gnr'); + const others = ranked(current.allowed).filter((rat) => rat !== '5gnr'); + + if (!has5g) { + // A sub-5G allowed set is `5g-off` whatever it ranks first: the posture is + // about 5G, and this model has no opinion on how 4G and 3G are ordered. + return '5g-off'; + } + if (others.length === 0) { + return '5g-only'; + } + if (current.preferred === '5gnr') { + return 'prefer-5g'; + } + if (current.preferred === 'lte') { + return 'prefer-4g'; + } + return undefined; +} + +/** + * Did a readback land on the requested posture? + * + * The RESULT of the write is not the write's own acknowledgement — MM answering + * the method call says the request was accepted, not that the radio took it, and + * a modem is entitled to clamp a mode set it cannot honour. So a confirmation is + * a re-read compared against the request, and this is that comparison. + */ +export function fiveGPreferenceConfirmed( + requested: FiveGPreference, + readback: RadioModeSet | undefined, +): boolean { + return readFiveGPreference(readback) === requested; +} diff --git a/control/src/capability/index.ts b/control/src/capability/index.ts new file mode 100644 index 0000000..0a60d16 --- /dev/null +++ b/control/src/capability/index.ts @@ -0,0 +1,11 @@ +// Capability-module feature-gate framework — the support-claim taxonomy, the +// per-modem capability detection the seven gated modules resolve against, and the +// modules that have landed their own probe + evidence. +// +// Implemented so far: `five-g-pref` (`five-g-preference.ts`). The remaining six +// are framework-only and may not be surfaced or claimed until their own change +// lands. + +export * from './detect'; +export * from './five-g-preference'; +export * from './support-claim'; diff --git a/control/src/capability/support-claim.test.ts b/control/src/capability/support-claim.test.ts new file mode 100644 index 0000000..d3394c5 --- /dev/null +++ b/control/src/capability/support-claim.test.ts @@ -0,0 +1,105 @@ +import { describe, expect, test } from 'bun:test'; + +import { + CAPABILITY_MODULES, + type CapabilityModule, + claimableModules, + mayClaimSupport, + mayRenderModule, + resolveCapabilityMatrix, + resolveSupportClaim, + SUPPORT_CLAIM_STATES, + surfaceableModules, +} from './support-claim'; + +const ALL: readonly CapabilityModule[] = CAPABILITY_MODULES; + +describe('the support-claim ladder', () => { + test('a module this build does not ship is unavailable, gate or no gate', () => { + for (const gateEnabled of [false, true]) { + expect( + resolveSupportClaim({ + implemented: false, + gateEnabled, + capability: 'present', + certified: true, + }), + ).toBe('unavailable'); + } + }); + + test('each rung requires the one below it', () => { + const base = { implemented: true, certified: false } as const; + expect(resolveSupportClaim({ ...base, gateEnabled: false, capability: 'present' })).toBe( + 'implemented', + ); + expect(resolveSupportClaim({ ...base, gateEnabled: true, capability: 'unknown' })).toBe( + 'enabled', + ); + expect(resolveSupportClaim({ ...base, gateEnabled: true, capability: 'present' })).toBe( + 'capable', + ); + expect( + resolveSupportClaim({ + ...base, + gateEnabled: true, + capability: 'present', + certified: true, + }), + ).toBe('certified'); + }); + + test('a modem that positively LACKS the capability is unavailable, never enabled', () => { + expect( + resolveSupportClaim({ + implemented: true, + gateEnabled: true, + capability: 'absent', + certified: true, + }), + ).toBe('unavailable'); + }); + + test('only capable/certified may be surfaced; only certified may be claimed', () => { + expect(SUPPORT_CLAIM_STATES.filter(mayRenderModule)).toEqual(['capable', 'certified']); + expect(SUPPORT_CLAIM_STATES.filter(mayClaimSupport)).toEqual(['certified']); + }); +}); + +describe('the seven-module matrix', () => { + test('OFF BY DEFAULT: no gates leaves every shipped module at implemented', () => { + const claims = resolveCapabilityMatrix({ implemented: ALL, gates: {}, capability: {} }); + for (const module of ALL) { + expect(claims[module]).toBe('implemented'); + expect(mayRenderModule(claims[module])).toBe(false); + } + }); + + test('an enabled gate on an INCAPABLE modem resolves unavailable', () => { + const claims = resolveCapabilityMatrix({ + implemented: ALL, + gates: { 'band-lock': true }, + capability: { 'band-lock': 'absent' }, + }); + expect(claims['band-lock']).toBe('unavailable'); + }); + + test('the matrix is TOTAL — an unmentioned module still gets a state', () => { + const claims = resolveCapabilityMatrix({ implemented: [], gates: {}, capability: {} }); + for (const module of ALL) { + expect(claims[module]).toBe('unavailable'); + } + }); + + test('derives surfaceable and claimable modules in canonical module order', () => { + const claims = resolveCapabilityMatrix({ + implemented: ALL, + gates: { 'band-lock': true, sms: true, gps: true }, + capability: { 'band-lock': 'present', sms: 'present', gps: 'absent' }, + certified: { sms: true }, + }); + + expect(surfaceableModules(claims)).toEqual(['band-lock', 'sms']); + expect(claimableModules(claims)).toEqual(['sms']); + }); +}); diff --git a/control/src/capability/support-claim.ts b/control/src/capability/support-claim.ts new file mode 100644 index 0000000..88edde1 --- /dev/null +++ b/control/src/capability/support-claim.ts @@ -0,0 +1,114 @@ +// The support-claim taxonomy — the vocabulary this stack uses to say how much is +// actually known about a capability module on a given modem. +// +// It exists because "supported" was one word doing four jobs: the code exists, +// an operator turned it on, the modem advertises it, and somebody proved it on +// this firmware. Those are four different facts with four different consequences +// — the third gates what a UI may offer, the fourth gates what a support matrix +// may claim — and collapsing them is how a doc comes to promise a combination +// nobody ever ran. +// +// This module is PURE and mirrors the same ladder CeraUI's `@ceraui/rpc` +// `capability-modules.schema.ts` carries. It is a mirror rather than a shared +// import by Rule D: this repo builds standalone, so the two halves are kept +// honest by their tests, not by a path. + +/** The seven gated capability modules. */ +export const CAPABILITY_MODULES = [ + 'band-lock', + 'sms', + 'five-g-pref', + 'fcc-auto-unlock', + 'gps', + 'ussd', + 'esim', +] as const; +export type CapabilityModule = (typeof CAPABILITY_MODULES)[number]; + +/** + * The ladder, lowest rung first. `resolveSupportClaim` answers with the highest + * rung reached: + * + * unavailable — not shipped in this build, OR the modem positively lacks it. + * implemented — shipped, gate OFF. The default for every module, everywhere. + * enabled — gate ON, capability UNKNOWN. "Not asked" is not "absent". + * capable — gate ON, modem advertises it. The floor for offering a control. + * certified — capable AND proven on this exact model+firmware. The ONLY rung + * a support matrix or a doc may claim. + */ +export const SUPPORT_CLAIM_STATES = [ + 'unavailable', + 'implemented', + 'enabled', + 'capable', + 'certified', +] as const; +export type SupportClaimState = (typeof SUPPORT_CLAIM_STATES)[number]; + +/** What a probe found. `unknown` is an answer about the READ, not the device. */ +export type CapabilityEvidence = 'present' | 'absent' | 'unknown'; + +export interface SupportClaimInput { + readonly implemented: boolean; + readonly gateEnabled: boolean; + readonly capability: CapabilityEvidence; + readonly certified: boolean; +} + +export function resolveSupportClaim(input: SupportClaimInput): SupportClaimState { + if (!input.implemented || input.capability === 'absent') { + return 'unavailable'; + } + if (!input.gateEnabled) { + return 'implemented'; + } + if (input.capability === 'unknown') { + return 'enabled'; + } + return input.certified ? 'certified' : 'capable'; +} + +export const SURFACEABLE_SUPPORT_STATES: readonly SupportClaimState[] = ['capable', 'certified']; + +export function mayRenderModule(state: SupportClaimState): boolean { + return SURFACEABLE_SUPPORT_STATES.includes(state); +} + +export function mayClaimSupport(state: SupportClaimState): boolean { + return state === 'certified'; +} + +export type CapabilityModuleClaims = Readonly>; + +export interface CapabilityMatrixInput { + readonly implemented: readonly CapabilityModule[]; + readonly gates: Partial>; + readonly capability: Partial>; + readonly certified?: Partial>; +} + +/** Total by construction: every module gets an explicit state. */ +export function resolveCapabilityMatrix(input: CapabilityMatrixInput): CapabilityModuleClaims { + const implemented = new Set(input.implemented); + const claims: Record = {} as Record< + CapabilityModule, + SupportClaimState + >; + for (const module of CAPABILITY_MODULES) { + claims[module] = resolveSupportClaim({ + implemented: implemented.has(module), + gateEnabled: input.gates[module] === true, + capability: input.capability[module] ?? 'unknown', + certified: input.certified?.[module] === true, + }); + } + return claims; +} + +export function surfaceableModules(claims: CapabilityModuleClaims): CapabilityModule[] { + return CAPABILITY_MODULES.filter((module) => mayRenderModule(claims[module])); +} + +export function claimableModules(claims: CapabilityModuleClaims): CapabilityModule[] { + return CAPABILITY_MODULES.filter((module) => mayClaimSupport(claims[module])); +} diff --git a/control/src/domain/generation.ts b/control/src/domain/generation.ts new file mode 100644 index 0000000..215cb0c --- /dev/null +++ b/control/src/domain/generation.ts @@ -0,0 +1,22 @@ +import type { Brand } from './brand'; +import { nonNegativeInteger } from './brand'; + +/** Monotonic lifetime of one physical enumeration/provider selection. */ +export type DeviceGeneration = Brand; + +export function deviceGeneration(value: number): DeviceGeneration { + return nonNegativeInteger(value, 'deviceGeneration') as DeviceGeneration; +} + +/** Re-enumeration or provider replacement starts the next fenced lifetime. */ +export function nextDeviceGeneration(current: DeviceGeneration): DeviceGeneration { + return deviceGeneration(current + 1); +} + +/** Whether an async completion still belongs to the current device lifetime. */ +export function isCurrentGeneration( + completion: DeviceGeneration, + current: DeviceGeneration, +): boolean { + return completion === current; +} diff --git a/control/src/domain/identity.ts b/control/src/domain/identity.ts index 0f52faa..fa0d880 100644 --- a/control/src/domain/identity.ts +++ b/control/src/domain/identity.ts @@ -29,6 +29,16 @@ export type LogicalSlotId = Brand; */ export type SubscriptionId = Brand; +/** + * The SIM's OWN number (MSISDN), as ModemManager's `Modem.OwnNumbers` reports it. + * + * SENSITIVE — it is the subscriber's telephone number, so it belongs to the same + * class as `subscriptionId`: never printed raw, never a policy binding key. It is + * DISPLAYED to the operator on an explicit reveal; that is a rendering decision + * and does not make it loggable. + */ +export type SubscriberNumber = Brand; + /** * The live ModemManager D-Bus object path (e.g. `/org/freedesktop/ModemManager1/Modem/3`). * @@ -76,6 +86,11 @@ export interface ModemIdentity { readonly equipmentId: EquipmentId; /** Absent with no SIM. SENSITIVE — redact everywhere (A2.2 redaction module). */ readonly subscriptionId?: SubscriptionId; + /** + * The SIM's own number(s). ABSENT when the carrier/SIM published none — most + * SIMs do not, so absence is the common case and never an error. SENSITIVE. + */ + readonly ownNumbers?: readonly SubscriberNumber[]; /** NEVER PERSISTED — per-boot runtime handle only. */ readonly runtimePath: RuntimePath; } @@ -92,6 +107,11 @@ export function subscriptionId(value: string): SubscriptionId { return nonEmptyString(value, 'subscriptionId') as SubscriptionId; } +/** Construct a `SubscriberNumber` from a non-empty string. */ +export function subscriberNumber(value: string): SubscriberNumber { + return nonEmptyString(value, 'subscriberNumber') as SubscriberNumber; +} + /** Construct a `RuntimePath` from a non-empty string. */ export function runtimePath(value: string): RuntimePath { return nonEmptyString(value, 'runtimePath') as RuntimePath; diff --git a/control/src/domain/index.ts b/control/src/domain/index.ts index 551b7a6..470f537 100644 --- a/control/src/domain/index.ts +++ b/control/src/domain/index.ts @@ -5,8 +5,15 @@ export * from './brand'; export * from './errors'; +export * from './generation'; export * from './guards'; export * from './identity'; +export * from './mm-enums'; +export * from './modem-presentation'; +export * from './observation'; +export * from './operation'; +export * from './physical-identity'; export * from './policy'; +export * from './shadow-divergence'; export * from './snapshot'; export * from './state'; diff --git a/control/src/domain/mm-enums.test.ts b/control/src/domain/mm-enums.test.ts new file mode 100644 index 0000000..b2be92b --- /dev/null +++ b/control/src/domain/mm-enums.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, test } from 'bun:test'; +import { + decodeMmAccessTechnologies, + decodeMmState, + decodeNetworkRejectionError, + decodeRegistrationState, + decodeUnlockRequired, + modeMaskToLabel, + runtimeIdFromPath, +} from './mm-enums'; + +describe('ModemManager enum normalization', () => { + test('folds access and mode masks into shared wire vocabulary', () => { + expect([...decodeMmAccessTechnologies((1 << 14) | (1 << 15))]).toEqual(['lte', '5gnr']); + expect(modeMaskToLabel((1 << 2) | (1 << 3) | (1 << 4))).toBe('5g4g3g'); + }); + + test('decodes states, locks, and reject causes', () => { + expect(decodeMmState(11)).toBe('connected'); + expect(decodeRegistrationState(9)).toBe('roaming'); + expect(decodeUnlockRequired(3)).toBe('sim-pin2'); + expect(decodeNetworkRejectionError(8)).toBe('gprs-and-non-gprs-not-allowed'); + }); + + test('extracts only a trailing numeric runtime id', () => { + expect(runtimeIdFromPath('/org/freedesktop/ModemManager1/Modem/7')).toBe(7); + expect(runtimeIdFromPath('/org/freedesktop/ModemManager1/Modem/x')).toBeUndefined(); + }); +}); diff --git a/control/src/domain/mm-enums.ts b/control/src/domain/mm-enums.ts new file mode 100644 index 0000000..9c33722 --- /dev/null +++ b/control/src/domain/mm-enums.ts @@ -0,0 +1,143 @@ +import type { RadioAccessTechnology } from './state'; + +const ACCESS_TECH_BITS: ReadonlyArray = [ + [1 << 1, 'gsm'], + [1 << 2, 'gsm'], + [1 << 3, 'gsm'], + [1 << 4, 'gsm'], + [1 << 5, 'umts'], + [1 << 6, 'umts'], + [1 << 7, 'umts'], + [1 << 8, 'umts'], + [1 << 9, 'umts'], + [1 << 14, 'lte'], + [1 << 15, '5gnr'], + [1 << 16, 'lte'], + [1 << 17, 'lte'], +]; + +export function decodeMmAccessTechnologies(mask: number | undefined): Set { + const result = new Set(); + if (mask === undefined || mask <= 0) return result; + for (const [bit, rat] of ACCESS_TECH_BITS) if ((mask & bit) !== 0) result.add(rat); + return result; +} + +export function modeMaskToLabel(mask: number | undefined): string | undefined { + if (mask === undefined || mask <= 0) return undefined; + const tokens = [ + [1 << 1, '2g'], + [1 << 2, '3g'], + [1 << 3, '4g'], + [1 << 4, '5g'], + ] as const; + const named = tokens.filter(([bit]) => (mask & bit) !== 0).map(([, token]) => token); + return named.length === 0 ? undefined : named.reverse().join(''); +} + +const MM_STATES = new Map([ + [-1, 'failed'], + [1, 'initializing'], + [2, 'locked'], + [3, 'disabled'], + [4, 'disabling'], + [5, 'enabling'], + [6, 'enabled'], + [7, 'searching'], + [8, 'registered'], + [9, 'disconnecting'], + [10, 'connecting'], + [11, 'connected'], +]); +export function decodeMmState(state: number | undefined): string { + return state === undefined ? 'unknown' : (MM_STATES.get(state) ?? 'unknown'); +} + +export function decodeRegistrationState(state: number | undefined): string { + if (state === 0) return 'idle'; + if (state === 1 || state === 6 || state === 7) return 'home'; + if (state === 2) return 'searching'; + if (state === 3) return 'denied'; + if (state === 5 || state === 8 || state === 9) return 'roaming'; + return state === 11 ? 'emergency-only' : 'unknown'; +} + +/** + * `MMModemStateFailedReason` → its mmcli spelling. + * + * This decoder is what makes "there is no SIM" an EXPLICIT reading rather than an + * inference. `Modem.StateFailedReason` is a `u` on D-Bus, so a provider that copies + * the property verbatim holds a NUMBER, while the migrated SIM-presence rule + * (`deriveSimPresence`) matches the mmcli STRING `sim-missing` that CeraUI has always + * read. Without this decoder the D-Bus path can never produce the one fact that + * proves absence, and a modem with no SIM reads `unknown` forever — or, worse, + * invites a consumer to infer absence from the blank `Sim` object path, which is + * exactly the guess this package refuses to make. + * + * An unrecognized value answers `undefined`: a reason this build cannot place says + * nothing, and must not be laundered into one that does. + */ +const STATE_FAILED_REASONS = new Map([ + [0, 'none'], + // MM's own `UNKNOWN` member. Spelled distinctly so it is never confused with the + // `'unknown'` sentinel the label decoders use to mean "this build could not place it". + [1, 'unknown-reason'], + [2, 'sim-missing'], + [3, 'sim-error'], + [4, 'unknown-capabilities'], + [5, 'esim-without-profiles'], +]); +export function decodeStateFailedReason(value: number | undefined): string | undefined { + return value === undefined ? undefined : STATE_FAILED_REASONS.get(value); +} + +export function decodeUnlockRequired(lock: number | undefined): string | undefined { + return lock === 1 + ? 'none' + : lock === 2 + ? 'sim-pin' + : lock === 3 + ? 'sim-pin2' + : lock === 4 + ? 'sim-puk' + : lock === 5 + ? 'sim-puk2' + : undefined; +} +export function decodeSimType(value: number | undefined): 'physical' | 'esim' | undefined { + return value === 1 ? 'physical' : value === 2 ? 'esim' : undefined; +} +export function decodeEsimStatus( + value: number | undefined, +): 'no-profiles' | 'with-profiles' | undefined { + return value === 1 ? 'no-profiles' : value === 2 ? 'with-profiles' : undefined; +} +export function decodePacketServiceState( + value: number | undefined, +): 'detached' | 'attached' | undefined { + return value === 1 ? 'detached' : value === 2 ? 'attached' : undefined; +} + +const NETWORK_REJECTIONS = new Map([ + [2, 'imsi-unknown-in-hlr'], + [3, 'illegal-ms'], + [4, 'imsi-unknown-in-vlr'], + [5, 'imei-not-accepted'], + [6, 'illegal-me'], + [7, 'gprs-not-allowed'], + [8, 'gprs-and-non-gprs-not-allowed'], + [11, 'plmn-not-allowed'], + [12, 'location-area-not-allowed'], + [13, 'roaming-not-allowed-in-location-area'], + [14, 'gprs-not-allowed-in-plmn'], + [15, 'no-cells-in-location-area'], + [17, 'network-failure'], + [22, 'congestion'], +]); +export function decodeNetworkRejectionError(value: number | undefined): string | undefined { + return value === undefined ? undefined : NETWORK_REJECTIONS.get(value); +} +export function runtimeIdFromPath(path: string): number | undefined { + const value = /\/(\d+)$/.exec(path)?.[1]; + return value === undefined ? undefined : Number.parseInt(value, 10); +} diff --git a/control/src/domain/modem-presentation.test.ts b/control/src/domain/modem-presentation.test.ts new file mode 100644 index 0000000..74f287b --- /dev/null +++ b/control/src/domain/modem-presentation.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, test } from 'bun:test'; +import { firmwareIdentityLabel, modemHardwareLabel, modemHardwareName } from './modem-presentation'; + +describe('modem hardware presentation', () => { + test('keeps an informative model and adds the IMEI tail', () => { + expect(modemHardwareName({ model: 'RM530N-GL', equipmentId: '868837088254863' })).toBe( + 'RM530N-GL - 54863', + ); + }); + + test('replaces a numeric model with the firmware family', () => { + const identity = { + model: '0', + manufacturer: '1', + firmwareRevision: 'HIMI_U01_MODEM_V1.0 1 [Sep 09 2015 10:00:00]', + }; + expect(firmwareIdentityLabel(identity.firmwareRevision)).toBe('HIMI_U01_MODEM_V1.0'); + expect(modemHardwareLabel(identity)).toBe('HIMI_U01_MODEM_V1.0'); + }); + + test('uses the unnamed floor when every reported field is uninformative', () => { + expect( + modemHardwareName({ model: '--', manufacturer: '1', firmwareRevision: '81600.0000.00' }), + ).toBe('Cellular modem'); + }); +}); diff --git a/control/src/domain/modem-presentation.ts b/control/src/domain/modem-presentation.ts new file mode 100644 index 0000000..2a8268c --- /dev/null +++ b/control/src/domain/modem-presentation.ts @@ -0,0 +1,50 @@ +const BARE_NUMERAL_RE = /^\d{1,4}$/; +const MMCLI_EMPTY = '--'; + +export interface ModemHardwareIdentity { + readonly model?: string; + readonly manufacturer?: string; + readonly firmwareRevision?: string; + readonly equipmentId?: string; +} + +export function isUninformativeIdentity(value: string | undefined): boolean { + const trimmed = value?.trim(); + return ( + trimmed === undefined || + trimmed === '' || + trimmed === MMCLI_EMPTY || + BARE_NUMERAL_RE.test(trimmed) + ); +} + +export function firmwareIdentityLabel(revision: string | undefined): string | undefined { + const trimmed = revision?.trim(); + if (!trimmed || trimmed === MMCLI_EMPTY) return undefined; + const head = + trimmed + .replace(/\s*\[[^\]]*]\s*$/, '') + .trim() + .split(/\s{2,}/)[0] + ?.trim() ?? ''; + return head.length >= 3 && /[A-Za-z]/.test(head) ? head : undefined; +} + +export function modemHardwareLabel(identity: ModemHardwareIdentity): string { + const model = identity.model?.trim(); + if (model !== undefined && !isUninformativeIdentity(model)) return model; + const firmware = firmwareIdentityLabel(identity.firmwareRevision); + if (firmware !== undefined) return firmware; + const manufacturer = identity.manufacturer?.trim(); + return manufacturer !== undefined && !isUninformativeIdentity(manufacturer) + ? manufacturer + : 'Cellular modem'; +} + +export function modemHardwareName(identity: ModemHardwareIdentity): string { + const label = modemHardwareLabel(identity); + const equipmentId = identity.equipmentId?.trim(); + return !equipmentId || equipmentId === MMCLI_EMPTY + ? label + : `${label} - ${equipmentId.slice(-5)}`; +} diff --git a/control/src/domain/observation.ts b/control/src/domain/observation.ts new file mode 100644 index 0000000..3c3696b --- /dev/null +++ b/control/src/domain/observation.ts @@ -0,0 +1,46 @@ +import type { Brand } from './brand'; +import { nonNegativeInteger } from './brand'; +import type { DeviceGeneration } from './generation'; +import type { StableKey } from './physical-identity'; +import type { EpochMillis } from './state'; + +export type SourceEpoch = Brand; + +export function sourceEpoch(value: number): SourceEpoch { + return nonNegativeInteger(value, 'sourceEpoch') as SourceEpoch; +} + +export type ObservationAuthority = 'authoritative' | 'derived' | 'advisory'; + +export type ObservationFreshness = + | { readonly state: 'fresh' } + | { + readonly state: 'stale'; + readonly since: EpochMillis; + readonly reason: 'source-epoch-superseded' | 'ttl-expired' | 'source-degraded'; + } + | { + readonly state: 'unavailable'; + readonly since: EpochMillis; + readonly reason: 'source-unavailable' | 'device-absent' | 'provider-unavailable'; + }; + +type ObservationBase = { + readonly stableKey: StableKey; + readonly generation: DeviceGeneration; + readonly source: string; + readonly sourceEpoch: SourceEpoch; + readonly observedAt: EpochMillis; + readonly authority: ObservationAuthority; +}; + +/** A value is retained while stale, but unavailable is explicit and carries no invented value. */ +export type ObservationEnvelope = + | (ObservationBase & { + readonly freshness: Extract; + readonly value: T; + }) + | (ObservationBase & { + readonly freshness: Extract; + readonly value: null; + }); diff --git a/control/src/domain/operation.ts b/control/src/domain/operation.ts new file mode 100644 index 0000000..624fbbb --- /dev/null +++ b/control/src/domain/operation.ts @@ -0,0 +1,202 @@ +import { DomainError } from './errors'; +import type { DeviceGeneration } from './generation'; + +export type OperationSupport = + | { readonly supported: true } + | { readonly supported: false; readonly reason: string }; + +export type OperationConstraints = + | { readonly kind: 'unconstrained' } + | { readonly kind: 'allowed-values'; readonly values: readonly I[] } + | { + readonly kind: 'numeric-range'; + readonly min: number; + readonly max: number; + readonly step?: number; + }; + +export type OperationAvailability = + | { readonly state: 'available' } + | { readonly state: 'unavailable'; readonly reason: string } + | { readonly state: 'refused'; readonly reason: string }; + +export type MutationImpact = 'read' | 'write' | 'session' | 'disruptive' | 'recovery'; +export type RetryClass = 'never' | 'idempotent-read'; +export type OperationConfidence = 'high' | 'medium' | 'low' | 'unknown'; + +export type OperationRequirement = + | { readonly required: false } + | { readonly required: true; readonly reason: string }; + +export type OperationReadback = + | { readonly required: false } + | { + readonly required: true; + readonly reason: string; + readonly matches: (input: I, observed: O) => boolean; + }; + +export type OperationDescriptor = { + readonly id: string; + readonly support: { + readonly read: OperationSupport; + readonly write: OperationSupport; + }; + readonly authority: 'provider' | 'controller' | 'hardware'; + readonly provider: string; + readonly constraints: OperationConstraints; + readonly livePreconditions: readonly string[]; + readonly availability: OperationAvailability; + readonly mutationImpact: MutationImpact; + readonly retryClass: RetryClass; + readonly readback: OperationReadback; + readonly rollback: OperationRequirement; + readonly journal: OperationRequirement; + readonly admission: OperationRequirement; + readonly evidence: { + readonly profiles: readonly string[]; + readonly firmware: readonly string[]; + }; + readonly confidence: OperationConfidence; +}; + +export class OperationDescriptorError extends DomainError { + override readonly name = 'OperationDescriptorError'; + readonly reason = 'retry-requires-idempotent-read'; + + constructor() { + super('operation descriptor refused: automatic retry requires a supported idempotent read'); + } +} + +export function defineOperationDescriptor( + descriptor: OperationDescriptor, +): OperationDescriptor { + if ( + descriptor.retryClass === 'idempotent-read' && + (descriptor.mutationImpact !== 'read' || !descriptor.support.read.supported) + ) { + throw new OperationDescriptorError(); + } + return descriptor; +} + +export type OperationCompletion = + | { readonly status: 'applied'; readonly value: O } + | { readonly status: 'refused'; readonly reason: string } + | { readonly status: 'failed'; readonly reason: string } + | { readonly status: 'timed-out' } + | { readonly status: 'dropped' }; + +export type OperationResult = + | { + readonly status: 'applied'; + readonly value: O; + readonly generation: DeviceGeneration; + readonly requiresReconciliation: false; + } + | { + readonly status: 'refused'; + readonly reason: string; + readonly generation: DeviceGeneration; + readonly requiresReconciliation: false; + } + | { + readonly status: 'unknown-outcome'; + readonly reason: 'stale-generation' | 'write-reply-timed-out' | 'write-reply-dropped'; + readonly generation: DeviceGeneration; + readonly requiresReconciliation: true; + } + | { + readonly status: 'failed'; + readonly reason: string; + readonly generation: DeviceGeneration; + readonly requiresReconciliation: false; + }; + +export type OperationCompletionContext = { + readonly operation: 'read' | 'write'; + readonly completionGeneration: DeviceGeneration; + readonly currentGeneration: DeviceGeneration; + readonly completion: OperationCompletion; +}; + +/** Apply generation and write-reply uncertainty before exposing a completion to callers. */ +export function classifyOperationCompletion( + context: OperationCompletionContext, +): OperationResult { + const generation = context.completionGeneration; + if (generation !== context.currentGeneration) { + return { + status: 'unknown-outcome', + reason: 'stale-generation', + requiresReconciliation: true, + generation, + }; + } + + switch (context.completion.status) { + case 'applied': + return { + status: 'applied', + value: context.completion.value, + generation, + requiresReconciliation: false, + }; + case 'refused': + return { + status: 'refused', + reason: context.completion.reason, + generation, + requiresReconciliation: false, + }; + case 'failed': + return { + status: 'failed', + reason: context.completion.reason, + generation, + requiresReconciliation: false, + }; + case 'timed-out': + return context.operation === 'write' + ? { + status: 'unknown-outcome', + reason: 'write-reply-timed-out', + requiresReconciliation: true, + generation, + } + : { + status: 'failed', + reason: 'read-reply-timed-out', + requiresReconciliation: false, + generation, + }; + case 'dropped': + return context.operation === 'write' + ? { + status: 'unknown-outcome', + reason: 'write-reply-dropped', + requiresReconciliation: true, + generation, + } + : { + status: 'failed', + reason: 'read-reply-dropped', + requiresReconciliation: false, + generation, + }; + } +} + +/** Automatic retries are restricted to explicitly classified idempotent reads. */ +export function canAutoRetry( + descriptor: OperationDescriptor, + result: OperationResult, +): boolean { + return ( + descriptor.mutationImpact === 'read' && + descriptor.support.read.supported && + descriptor.retryClass === 'idempotent-read' && + result.status === 'failed' + ); +} diff --git a/control/src/domain/physical-identity.ts b/control/src/domain/physical-identity.ts new file mode 100644 index 0000000..2621442 --- /dev/null +++ b/control/src/domain/physical-identity.ts @@ -0,0 +1,176 @@ +import { createHash } from 'node:crypto'; +import type { Brand } from './brand'; +import { DomainError } from './errors'; + +export type PhysicalModemId = Brand; +export type StableKey = Brand; +export type PhysicalIdentitySource = 'serial' | 'id-path' | 'fallback'; + +export type PhysicalIdentityFacts = { + readonly serial?: string; + readonly idPath?: string; + readonly fallback?: string; +}; + +export type ResolvedPhysicalModemIdentity = { + readonly physicalModemId: PhysicalModemId; + readonly stableKey: StableKey; + readonly source: PhysicalIdentitySource; +}; + +export type PhysicalModemIdentityErrorReason = + | 'empty' + | 'unsupported-shape' + | 'mm-object-path' + | 'interface-name' + | 'ip-address' + | 'equipment-identifier' + | 'subscriber-identifier' + | 'fallback-too-long' + | 'no-identity-facts'; + +export class PhysicalModemIdentityError extends DomainError { + override readonly name = 'PhysicalModemIdentityError'; + + constructor(readonly reason: PhysicalModemIdentityErrorReason) { + super(`physical modem identity refused: ${reason}`); + } +} + +const PHYSICAL_ID_PREFIXES = [ + ['serial', 'serial:'], + ['id-path', 'id-path:'], + ['fallback', 'fallback:'], +] as const; +const FALLBACK_MAX_LENGTH = 128; +const MM_OBJECT_PATH = /^\/org\/freedesktop\/ModemManager1\/Modem(?:\/|$)/; +const INTERFACE_NAME = + /^(?:wwan|eth|enp|ens|eno|wlan|wl|ppp|usb|rmnet|cdc-wdm|ttyUSB|ttyACM)\d[\w.-]*$/i; +const EQUIPMENT_IDENTIFIER = /^\d{14,16}$/; +const SUBSCRIBER_IDENTIFIER = /^(?:89\d{16,30}|\d{18,32})$/; + +function isIpv4(value: string): boolean { + const octets = value.split('.'); + return ( + octets.length === 4 && octets.every((octet) => /^\d{1,3}$/.test(octet) && Number(octet) <= 255) + ); +} + +function isIpv6(value: string): boolean { + if (!value.includes(':') || !/^[0-9a-f:]+$/i.test(value)) { + return false; + } + const groups = value.split(':'); + const hasCompression = value.includes('::'); + const populatedGroups = groups.filter((group) => group.length > 0); + return ( + populatedGroups.every((group) => group.length <= 4) && + (hasCompression ? populatedGroups.length < 8 : populatedGroups.length === 8) + ); +} + +function assertSafeIdentityPart(value: string): void { + if (value.length === 0) { + throw new PhysicalModemIdentityError('empty'); + } + if (MM_OBJECT_PATH.test(value)) { + throw new PhysicalModemIdentityError('mm-object-path'); + } + if (INTERFACE_NAME.test(value)) { + throw new PhysicalModemIdentityError('interface-name'); + } + if (isIpv4(value) || isIpv6(value)) { + throw new PhysicalModemIdentityError('ip-address'); + } + if (SUBSCRIBER_IDENTIFIER.test(value)) { + throw new PhysicalModemIdentityError('subscriber-identifier'); + } + if (EQUIPMENT_IDENTIFIER.test(value)) { + throw new PhysicalModemIdentityError('equipment-identifier'); + } +} + +function splitPhysicalModemId(value: string): readonly [PhysicalIdentitySource, string] { + for (const [source, prefix] of PHYSICAL_ID_PREFIXES) { + if (value.startsWith(prefix)) { + return [source, value.slice(prefix.length)]; + } + } + throw new PhysicalModemIdentityError('unsupported-shape'); +} + +/** Construct only from a canonical serial/ID_PATH/bounded-fallback identity. */ +export function physicalModemId(value: string): PhysicalModemId { + const [source, identityPart] = splitPhysicalModemId(value.trim()); + assertSafeIdentityPart(identityPart); + if (source === 'fallback' && identityPart.length > FALLBACK_MAX_LENGTH) { + throw new PhysicalModemIdentityError('fallback-too-long'); + } + return `${source}:${identityPart}` as PhysicalModemId; +} + +/** Construct the actor/storage key for a validated physical modem identity. */ +export function stableKeyFromPhysicalModemId(id: PhysicalModemId): StableKey { + return `modem:${id}` as StableKey; +} + +/** Parse a canonical stable key while re-validating its embedded physical identity. */ +export function stableKey(value: string): StableKey { + if (!value.startsWith('modem:')) { + throw new PhysicalModemIdentityError('unsupported-shape'); + } + return stableKeyFromPhysicalModemId(physicalModemId(value.slice('modem:'.length))); +} + +/** Resolve one identity with the frozen serial → ID_PATH → bounded-fallback precedence. */ +export function resolvePhysicalModemIdentity( + facts: PhysicalIdentityFacts, +): ResolvedPhysicalModemIdentity { + const candidates = [ + ['serial', facts.serial], + ['id-path', facts.idPath], + ['fallback', facts.fallback], + ] as const; + + for (const [source, candidate] of candidates) { + const normalized = candidate?.trim(); + if (normalized !== undefined && normalized.length > 0) { + const id = physicalModemId(`${source}:${normalized}`); + return { physicalModemId: id, stableKey: stableKeyFromPhysicalModemId(id), source }; + } + } + + throw new PhysicalModemIdentityError('no-identity-facts'); +} + +export type PortableIdentityAnchor = 'usb-serial' | 'id-path' | 'ifname'; +export interface PortablePhysicalObservation { + readonly ifname: string; + readonly idPath?: string; + readonly vendorId?: string; + readonly serial?: string; +} +export interface PortablePhysicalDeviceIdentity { + readonly identityKey: string; + readonly anchor: PortableIdentityAnchor; + readonly linkId: string; +} + +export function mintLinkId(identityKey: string): string { + return `lnk_${createHash('sha256').update(identityKey).digest('hex').slice(0, 16)}`; +} + +export function resolvePortablePhysicalIdentity( + observation: PortablePhysicalObservation, +): PortablePhysicalDeviceIdentity { + const serial = observation.serial?.trim(); + const idPath = observation.idPath?.trim(); + const vendor = observation.vendorId?.trim().toLowerCase(); + const identityKey = serial + ? `usb-serial:${vendor ? `${vendor}:` : ''}${serial}` + : idPath + ? `id-path:${idPath}` + : `ifname:${observation.ifname}`; + const anchor: PortableIdentityAnchor = serial ? 'usb-serial' : idPath ? 'id-path' : 'ifname'; + return { identityKey, anchor, linkId: mintLinkId(identityKey) }; +} diff --git a/control/src/domain/portable-physical-identity.test.ts b/control/src/domain/portable-physical-identity.test.ts new file mode 100644 index 0000000..c521b17 --- /dev/null +++ b/control/src/domain/portable-physical-identity.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, test } from 'bun:test'; +import { resolvePortablePhysicalIdentity } from './physical-identity'; + +describe('portable physical identity', () => { + test('keeps one identity across a dual-mode VID:PID change', () => { + const router = resolvePortablePhysicalIdentity({ + ifname: 'enx0', + vendorId: '05c6', + serial: '2b16081', + idPath: 'usb-0:1.4.1', + }); + const qmi = resolvePortablePhysicalIdentity({ + ifname: 'wwan0', + vendorId: '05C6', + serial: '2b16081', + idPath: 'usb-0:1.4.1', + }); + expect(qmi).toEqual(router); + expect(router.anchor).toBe('usb-serial'); + expect(router.linkId).toMatch(/^lnk_[0-9a-f]{16}$/); + expect(router.linkId).not.toContain('2b16081'); + }); + + test('uses same-port identity when no serial exists', () => { + expect(resolvePortablePhysicalIdentity({ ifname: 'eth1', idPath: 'usb-0:1.4.2' }).anchor).toBe( + 'id-path', + ); + expect(resolvePortablePhysicalIdentity({ ifname: 'eth1' }).anchor).toBe('ifname'); + }); +}); diff --git a/control/src/domain/shadow-divergence.test.ts b/control/src/domain/shadow-divergence.test.ts new file mode 100644 index 0000000..9f3bd9d --- /dev/null +++ b/control/src/domain/shadow-divergence.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, test } from 'bun:test'; +import { classifyShadowDivergences, foldGenerations, foldSignalBucket } from './shadow-divergence'; + +describe('shadow divergence', () => { + test('compares only mutually reported fields', () => { + expect( + classifyShadowDivergences( + [{ deviceKey: 'd-a', present: true, operatorName: 'Carrier' }], + [{ deviceKey: 'd-a', present: true }], + ), + ).toEqual([]); + }); + + test('reports roster and field mismatches', () => { + const result = classifyShadowDivergences( + [ + { deviceKey: 'd-a', present: true, networkType: '5G' }, + { deviceKey: 'd-b', present: true }, + ], + [ + { deviceKey: 'd-a', present: true, networkType: '4G' }, + { deviceKey: 'd-c', present: true }, + ], + ); + expect(result.map((entry) => entry.kind)).toEqual([ + 'field-mismatch', + 'only-in-mmcli', + 'only-in-dbus', + ]); + }); + + test('folds generation and signal vocabularies', () => { + expect(foldGenerations(['lte', '5gnr'])).toBe('5G'); + expect(foldSignalBucket(74)).toBe('good'); + }); +}); diff --git a/control/src/domain/shadow-divergence.ts b/control/src/domain/shadow-divergence.ts new file mode 100644 index 0000000..350d1f6 --- /dev/null +++ b/control/src/domain/shadow-divergence.ts @@ -0,0 +1,93 @@ +export type ShadowGeneration = '2G' | '3G' | '4G' | '5G'; +export type ShadowSignalBucket = 'none' | 'poor' | 'fair' | 'good' | 'excellent'; +export interface ShadowModemState { + readonly deviceKey: string; + readonly present: boolean; + readonly registration?: string; + readonly signalBucket?: ShadowSignalBucket; + readonly operatorName?: string; + readonly simPresent?: boolean; + readonly networkType?: ShadowGeneration; +} +export const SHADOW_COMPARABLE_FIELDS = [ + 'present', + 'registration', + 'signalBucket', + 'operatorName', + 'simPresent', + 'networkType', +] as const; +export type ShadowComparableField = (typeof SHADOW_COMPARABLE_FIELDS)[number]; +export interface ShadowFieldDivergence { + readonly field: ShadowComparableField; + readonly mmcli: unknown; + readonly dbus: unknown; +} +export interface ShadowModemDivergence { + readonly deviceKey: string; + readonly kind: 'only-in-mmcli' | 'only-in-dbus' | 'field-mismatch'; + readonly fields?: readonly ShadowFieldDivergence[]; +} + +export function classifyShadowDivergences( + mmcli: readonly ShadowModemState[], + dbus: readonly ShadowModemState[], +): ShadowModemDivergence[] { + const left = new Map(mmcli.map((state) => [state.deviceKey, state])); + const right = new Map(dbus.map((state) => [state.deviceKey, state])); + const result: ShadowModemDivergence[] = []; + for (const [deviceKey, mmcliState] of left) { + const dbusState = right.get(deviceKey); + if (dbusState === undefined) { + result.push({ deviceKey, kind: 'only-in-mmcli' }); + continue; + } + const fields: ShadowFieldDivergence[] = []; + for (const field of SHADOW_COMPARABLE_FIELDS) { + const a = mmcliState[field]; + const b = dbusState[field]; + if (a !== undefined && b !== undefined && a !== b) fields.push({ field, mmcli: a, dbus: b }); + } + if (fields.length > 0) result.push({ deviceKey, kind: 'field-mismatch', fields }); + } + for (const deviceKey of right.keys()) + if (!left.has(deviceKey)) result.push({ deviceKey, kind: 'only-in-dbus' }); + return result; +} + +const GENERATIONS: Readonly> = { + '2g': '2G', + gsm: '2G', + '3g': '3G', + '3g+': '3G', + umts: '3G', + '4g': '4G', + lte: '4G', + '5g': '5G', + '5gnr': '5G', +}; +const ORDER: readonly ShadowGeneration[] = ['2G', '3G', '4G', '5G']; +export function foldGeneration(token: string): ShadowGeneration | undefined { + return GENERATIONS[token.trim().toLowerCase()]; +} +export function foldGenerations(tokens: Iterable): ShadowGeneration | undefined { + let best: ShadowGeneration | undefined; + for (const token of tokens) { + const value = foldGeneration(token); + if (value !== undefined && (best === undefined || ORDER.indexOf(value) > ORDER.indexOf(best))) + best = value; + } + return best; +} +export function foldSignalBucket(quality: number | undefined): ShadowSignalBucket | undefined { + if (quality === undefined || !Number.isFinite(quality)) return undefined; + return quality <= 0 + ? 'none' + : quality < 25 + ? 'poor' + : quality < 50 + ? 'fair' + : quality < 75 + ? 'good' + : 'excellent'; +} diff --git a/control/src/domain/v11-contracts.test.ts b/control/src/domain/v11-contracts.test.ts new file mode 100644 index 0000000..5ba079a --- /dev/null +++ b/control/src/domain/v11-contracts.test.ts @@ -0,0 +1,187 @@ +import { describe, expect, test } from 'bun:test'; +import { + canAutoRetry, + classifyOperationCompletion, + defineOperationDescriptor, + deviceGeneration, + epochMillis, + isCurrentGeneration, + nextDeviceGeneration, + type ObservationEnvelope, + PhysicalModemIdentityError, + physicalModemId, + resolvePhysicalModemIdentity, + sourceEpoch, + stableKey, +} from './index'; + +describe('PhysicalModemId and StableKey', () => { + test('Given an MM object path, when PhysicalModemId is constructed, then construction is refused', () => { + expect(() => physicalModemId('/org/freedesktop/ModemManager1/Modem/7')).toThrow( + PhysicalModemIdentityError, + ); + }); + + test.each([ + ['ifname', 'wwan0'], + ['IPv4 address', '192.168.8.1'], + ['IPv6 address', '2001:db8::1'], + ['uncompressed IPv6 address', '2001:db8:0:0:0:0:0:1'], + ['IMEI', '490154203237518'], + ['ICCID', '8944500101234567890'], + ['EID', '89049032000000000000000000000001'], + ])( + 'Given a %s shape, when PhysicalModemId is constructed, then it is refused', + (_shape, value) => { + expect(() => physicalModemId(`serial:${value}`)).toThrow(PhysicalModemIdentityError); + }, + ); + + test('Given every identity fact, when resolving, then serial outranks ID_PATH and fallback', () => { + const resolved = resolvePhysicalModemIdentity({ + serial: 'SERIAL-A1', + idPath: 'pci-0000:01:00.0-usb-0:2:1.0', + fallback: 'vid-2c7c-pid-0800-port-2', + }); + + expect(resolved.source).toBe('serial'); + expect(resolved.physicalModemId).toBe(physicalModemId('serial:SERIAL-A1')); + expect(resolved.stableKey).toBe(stableKey('modem:serial:SERIAL-A1')); + }); + + test('Given no serial, when resolving, then ID_PATH outranks fallback', () => { + const resolved = resolvePhysicalModemIdentity({ + idPath: 'pci-0000:01:00.0-usb-0:2:1.0', + fallback: 'vid-2c7c-pid-0800-port-2', + }); + + expect(resolved.source).toBe('id-path'); + }); + + test('Given an oversized fallback, when resolving, then construction is refused', () => { + expect(() => resolvePhysicalModemIdentity({ fallback: 'x'.repeat(129) })).toThrow( + PhysicalModemIdentityError, + ); + }); +}); + +describe('DeviceGeneration', () => { + test('Given a current generation, when the device re-enumerates, then generation increments', () => { + const current = deviceGeneration(4); + const replacement = nextDeviceGeneration(current); + + expect(replacement).toBe(deviceGeneration(5)); + expect(isCurrentGeneration(current, replacement)).toBe(false); + expect(isCurrentGeneration(replacement, replacement)).toBe(true); + }); +}); + +describe('ObservationEnvelope', () => { + test('Given an unavailable source, when represented, then unavailable is distinct from stale', () => { + const unavailable: ObservationEnvelope = { + stableKey: resolvePhysicalModemIdentity({ serial: 'SERIAL-A1' }).stableKey, + generation: deviceGeneration(1), + source: 'modemmanager', + sourceEpoch: sourceEpoch(3), + observedAt: epochMillis(100), + freshness: { + state: 'unavailable', + since: epochMillis(90), + reason: 'source-unavailable', + }, + authority: 'authoritative', + value: null, + }; + + expect(unavailable.freshness.state).toBe('unavailable'); + expect(unavailable.value).toBeNull(); + }); +}); + +const READ_DESCRIPTOR = defineOperationDescriptor({ + id: 'radio-power', + support: { + read: { supported: true }, + write: { supported: false, reason: 'provider-read-only' }, + }, + authority: 'provider', + provider: 'modemmanager', + constraints: { kind: 'allowed-values', values: ['on', 'off'] }, + livePreconditions: ['device-present'], + availability: { state: 'available' }, + mutationImpact: 'read', + retryClass: 'idempotent-read', + readback: { required: false }, + rollback: { required: false }, + journal: { required: false }, + admission: { required: false }, + evidence: { profiles: ['generic-mm'], firmware: [] }, + confidence: 'high', +}); + +describe('OperationDescriptor', () => { + test('Given asymmetric provider support, when described, then read and write remain independent', () => { + expect(READ_DESCRIPTOR.support.read.supported).toBe(true); + expect(READ_DESCRIPTOR.support.write).toEqual({ + supported: false, + reason: 'provider-read-only', + }); + }); + + test('Given a write operation marked retryable, when defined, then the invalid contract is refused', () => { + expect(() => + defineOperationDescriptor({ + ...READ_DESCRIPTOR, + mutationImpact: 'write', + retryClass: 'idempotent-read', + }), + ).toThrow(); + }); +}); + +describe('OperationResult', () => { + test('Given a completion under a stale generation, when classified, then it is unknown-outcome and never failed', () => { + const result = classifyOperationCompletion({ + operation: 'write', + completionGeneration: deviceGeneration(2), + currentGeneration: deviceGeneration(3), + completion: { status: 'failed', reason: 'provider-error' }, + }); + + expect(result.status).toBe('unknown-outcome'); + expect(result).toEqual({ + status: 'unknown-outcome', + reason: 'stale-generation', + requiresReconciliation: true, + generation: deviceGeneration(2), + }); + }); + + test.each(['timed-out', 'dropped'] as const)( + 'Given a %s write reply, when classified, then reconciliation is required', + (status) => { + const generation = deviceGeneration(5); + const result = classifyOperationCompletion({ + operation: 'write', + completionGeneration: generation, + currentGeneration: generation, + completion: { status }, + }); + + expect(result.status).toBe('unknown-outcome'); + expect(result.requiresReconciliation).toBe(true); + }, + ); + + test('Given an idempotent read failure, when retry eligibility is checked, then it may auto-retry', () => { + const generation = deviceGeneration(5); + const result = classifyOperationCompletion({ + operation: 'read', + completionGeneration: generation, + currentGeneration: generation, + completion: { status: 'timed-out' }, + }); + + expect(canAutoRetry(READ_DESCRIPTOR, result)).toBe(true); + }); +}); diff --git a/control/src/fcc/coverage.test.ts b/control/src/fcc/coverage.test.ts new file mode 100644 index 0000000..e49a1aa --- /dev/null +++ b/control/src/fcc/coverage.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, it } from 'bun:test'; + +import { + fccUnlockRuntimeBinary, + fccUnlockVendorScript, + isFccUnlockKey, + MM_FCC_UNLOCK_COVERAGE, + MM_FCC_UNLOCK_VENDOR_SCRIPTS, + normalizeVidPid, + resolveFccUnlockCoverage, +} from './coverage'; + +describe('the ModemManager 1.24.2 coverage catalog', () => { + it('Given the pinned release, When the catalog is read, Then it is the 14 shipped entries over 4 vendor scripts', () => { + expect(Object.keys(MM_FCC_UNLOCK_COVERAGE)).toHaveLength(14); + expect(Object.keys(MM_FCC_UNLOCK_VENDOR_SCRIPTS).sort()).toEqual([ + '105b', + '1199', + '14c3', + '2c7c', + ]); + for (const script of Object.values(MM_FCC_UNLOCK_COVERAGE)) { + expect(script in MM_FCC_UNLOCK_VENDOR_SCRIPTS).toBe(true); + } + }); + + it('Given every catalog key, When its shape is checked, Then all are the dispatcher : form', () => { + for (const key of Object.keys(MM_FCC_UNLOCK_COVERAGE)) { + expect(isFccUnlockKey(key)).toBe(true); + } + }); + + // The whole reason the key is :: one silicon vendor, three USB vendor + // ids. A vendor-keyed rule would miss the HP and Dell rebrands entirely. + it('Given Sierra silicon under three vendor ids, When resolved, Then all three map to the 1199 script', () => { + expect(fccUnlockVendorScript('1199:9079')).toBe('1199'); + expect(fccUnlockVendorScript('03f0:4e1d')).toBe('1199'); + expect(fccUnlockVendorScript('413c:81a3')).toBe('1199'); + }); + + it('Given a covered key, When its interpreter is asked, Then it names the packaged binary', () => { + expect(fccUnlockRuntimeBinary('2c7c:0801')).toBe('qmicli'); + expect(fccUnlockRuntimeBinary('14c3:4d75')).toBe('mbimcli'); + expect(fccUnlockRuntimeBinary('12d1:14dc')).toBeUndefined(); + }); +}); + +describe('normalizeVidPid', () => { + it('Given mixed case and a 0x prefix, When normalized, Then it folds to the dispatcher spelling', () => { + expect(normalizeVidPid('2C7C', '0801')).toBe('2c7c:0801'); + expect(normalizeVidPid('0x2c7c', '0x0801')).toBe('2c7c:0801'); + expect(normalizeVidPid(' 2c7c ', ' 0801 ')).toBe('2c7c:0801'); + }); + + it('Given an id that is not 4 hex digits, When normalized, Then it is refused rather than padded', () => { + expect(normalizeVidPid('2c7', '0801')).toBeUndefined(); + expect(normalizeVidPid('2c7c', '00801')).toBeUndefined(); + expect(normalizeVidPid('zzzz', '0801')).toBeUndefined(); + expect(normalizeVidPid('', '')).toBeUndefined(); + }); +}); + +describe('resolveFccUnlockCoverage — three answers, none interchangeable', () => { + // The one fleet modem MM ships a procedure for (todo 2 bench inventory). + it('Given the bench Quectel RM530N-GL, When coverage is resolved, Then it is present', () => { + expect(resolveFccUnlockCoverage('2c7c', '0801')).toBe('present'); + }); + + it.each([ + ['Huawei E3372 HiLink', '12d1', '14dc'], + ['ZTE MF79U-class', '19d2', '1405'], + ['SIMCom SIM7600G-H', '1e0e', '9001'], + ['Qualcomm reference stick', '05c6', '9091'], + // MM covers the FM350's PCIe identity 14c3:4d75; on the USB carrier board it + // re-enumerates under MediaTek's own vendor id, which is NOT in the mapping. + ['Fibocom FM350-GL on a USB carrier', '0e8d', '7127'], + ])('Given %s, When coverage is resolved, Then it is a positive absent', (_name, vid, pid) => { + expect(resolveFccUnlockCoverage(vid, pid)).toBe('absent'); + }); + + it('Given the FM350 native PCIe identity, When coverage is resolved, Then it is present', () => { + expect(resolveFccUnlockCoverage('14c3', '4d75')).toBe('present'); + }); + + // A statement about the READ is not a statement about the DEVICE. Folding this + // into `absent` would hide the module on hardware that may well be covered. + it.each([ + ['no ids at all', undefined, undefined], + ['a missing pid', '2c7c', undefined], + ['an unparseable id', 'nope', '0801'], + ])('Given %s, When coverage is resolved, Then it is unknown', (_name, vid, pid) => { + expect(resolveFccUnlockCoverage(vid, pid)).toBe('unknown'); + }); +}); diff --git a/control/src/fcc/coverage.ts b/control/src/fcc/coverage.ts new file mode 100644 index 0000000..4b3542d --- /dev/null +++ b/control/src/fcc/coverage.ts @@ -0,0 +1,118 @@ +// The FCC-unlock COVERAGE CATALOG — which `:` ModemManager can unlock. +// +// This is the evidence source for the `fcc-auto-unlock` capability module. +// `capability/detect.ts` answers `unknown` for that module on purpose and says why: +// FCC unlock is carried out by a ModemManager DISPATCHER keyed on the device, and +// nothing on the modem's own D-Bus surface says whether a procedure applies. So the +// answer has to come from a catalog, and this is it. +// +// Pinned to ModemManager 1.24.2 (`packaging/upstream-pins.yaml`) — the exact release +// this repository rebuilds. Prose walkthrough, per-device fleet verdicts and the +// market table: `docs/FCC-UNLOCK-COVERAGE.md`. +// +// THE KEY IS `:` AND NOTHING ELSE. `mm-dispatcher-fcc-unlock.c` builds +// exactly one filename — `g_strdup_printf("%04x:%04x", vid, pid)` — and looks for it +// in the two enabled tiers. A vendor-only name is never opened by the dispatcher; it +// only ever exists as the TARGET of a `:` link in the available tier. And a +// vendor-keyed rule would be wrong twice over: Sierra silicon ships under three +// vendor ids (`1199` its own, `03f0` HP-branded, `413c` Dell-branded), so keying on +// the vendor misses two of the three, while keying on the model misses the rebrands. + +/** The four vendor scripts ModemManager 1.24.2 ships, and what each one drives. */ +export const MM_FCC_UNLOCK_VENDOR_SCRIPTS = { + '105b': 'mbimcli', + '1199': 'qmicli', + '14c3': 'mbimcli', + '2c7c': 'qmicli', +} as const; + +export type MmFccUnlockVendorScript = keyof typeof MM_FCC_UNLOCK_VENDOR_SCRIPTS; + +/** The interpreters those scripts invoke, and the packages that provide them. */ +export const MM_FCC_UNLOCK_RUNTIME_PACKAGES = { + qmicli: 'libqmi-utils', + mbimcli: 'libmbim-utils', +} as const; + +/** + * ModemManager 1.24.2's COMPLETE shipped mapping — `data/dispatcher-fcc-unlock/ + * meson.build`'s `vidpids` dict, verbatim. Fourteen entries; there are no others. + */ +export const MM_FCC_UNLOCK_COVERAGE = { + '03f0:4e1d': '1199', + '105b:e0ab': '105b', + '105b:e0c3': '105b', + '1199:9079': '1199', + '14c3:4d75': '14c3', + '1eac:1001': '2c7c', + '1eac:1004': '2c7c', + '1eac:1007': '2c7c', + '2c7c:030a': '2c7c', + '2c7c:0313': '2c7c', + '2c7c:0314': '2c7c', + '2c7c:0801': '2c7c', + '413c:81a3': '1199', + '413c:81a8': '1199', +} as const satisfies Record; + +export type MmFccUnlockKey = keyof typeof MM_FCC_UNLOCK_COVERAGE; + +/** `<4 lowercase hex>:<4 lowercase hex>` — the dispatcher's own filename shape. */ +const VID_PID_RE = /^[0-9a-f]{4}:[0-9a-f]{4}$/; + +/** + * Fold a vid/pid pair into the dispatcher's key, or `undefined` when it is not a + * pair of 4-hex ids. Case is folded because sysfs and udev disagree about it + * (`ID_VENDOR_ID` is lowercase, some vendor strings are not) while the dispatcher's + * `%04x` is unambiguously lowercase; a `0x` prefix is tolerated for the same reason. + * NOTHING ELSE is normalized — a 3-digit or 5-digit id is a different device, not a + * sloppy spelling of this one. + */ +export function normalizeVidPid(vid: string, pid: string): string | undefined { + const fold = (raw: string): string => raw.trim().toLowerCase().replace(/^0x/, ''); + const key = `${fold(vid)}:${fold(pid)}`; + return VID_PID_RE.test(key) ? key : undefined; +} + +/** True for a string already in the dispatcher's `:` shape. */ +export function isFccUnlockKey(value: string): boolean { + return VID_PID_RE.test(value); +} + +/** + * Does ModemManager ship an unlock procedure for this device? + * + * The three answers are NOT interchangeable, and the third is why this returns a + * tri-state rather than a boolean: + * present — a `:` entry exists; the toggle can do something. + * absent — the ids are well-formed and are NOT in the mapping. A positive + * statement about the device, so the module reads `unavailable` and no + * control is offered. + * unknown — we could not read the ids at all. That is a statement about the READ, + * and reporting it as `absent` would hide the module on hardware that + * may well be covered. + */ +export function resolveFccUnlockCoverage( + vid: string | undefined, + pid: string | undefined, +): 'present' | 'absent' | 'unknown' { + if (vid === undefined || pid === undefined) { + return 'unknown'; + } + const key = normalizeVidPid(vid, pid); + if (key === undefined) { + return 'unknown'; + } + return key in MM_FCC_UNLOCK_COVERAGE ? 'present' : 'absent'; +} + +/** The vendor script a covered key resolves to, for diagnostics and docs. */ +export function fccUnlockVendorScript(key: string): MmFccUnlockVendorScript | undefined { + return (MM_FCC_UNLOCK_COVERAGE as Record)[key]; +} + +/** The interpreter a covered key's script invokes (`qmicli` / `mbimcli`). */ +export function fccUnlockRuntimeBinary(key: string): 'qmicli' | 'mbimcli' | undefined { + const script = fccUnlockVendorScript(key); + return script === undefined ? undefined : MM_FCC_UNLOCK_VENDOR_SCRIPTS[script]; +} diff --git a/control/src/fcc/index.ts b/control/src/fcc/index.ts new file mode 100644 index 0000000..8bb5841 --- /dev/null +++ b/control/src/fcc/index.ts @@ -0,0 +1,13 @@ +// FCC auto-unlock — the opt-in POLICY surface, and the catalog that gates it. +// +// This module implements NO unlock procedure and ships NO unlock script. All it does +// is record which `:` models an operator has opted in for, so that +// `ceralive-fcc-reconcile` can re-derive ModemManager's own admin-tier symlinks from +// that record on every boot. The unlocking itself is ModemManager's dispatcher's job, +// start to finish. +// +// Full model, coverage matrix and certification status: `docs/FCC-UNLOCK-COVERAGE.md`. + +export * from './coverage'; +export * from './policy-store'; +export * from './policy-write'; diff --git a/control/src/fcc/policy-store.ts b/control/src/fcc/policy-store.ts new file mode 100644 index 0000000..12b7f2e --- /dev/null +++ b/control/src/fcc/policy-store.ts @@ -0,0 +1,196 @@ +// Durable persistence for the operator's FCC-auto-unlock POLICY. +// +// WHY THE FILE IS THE RECORD AND THE SYMLINK IS NOT. ModemManager's opt-in +// mechanism is a symlink in `/etc/ModemManager/fcc-unlock.d/:`. `/etc` is +// on the rootfs, and the rootfs is exactly what a RAUC A/B slot swap REPLACES +// (`image-building-pipeline/docs/partition-contract.md`), so that symlink survives a +// reboot and does NOT survive an OTA. `/data` is the only update-surviving store, so +// the durable record lives there and the symlink is a derived artifact re-created +// from it on every boot by `ceralive-fcc-reconcile`. +// +// The two hard guarantees are the SAME ones `backend/usage/policy-store.ts` makes, +// and deliberately implemented the same way so the pair can be read side by side: +// - MODE 0600 via temp → chmod → atomic rename, regardless of umask. +// - FAIL-SAFE ON CORRUPTION: an unparseable/incompatible file logs METADATA ONLY +// (byte length + a classification reason, never the content) and is treated as +// EMPTY. Note the divergence from the usage store, and it is load-bearing: the +// usage store REWRITES a fresh file, while this one leaves the damaged bytes on +// disk for an operator to look at and simply refuses to act on them. Enabling a +// regulatory-unlock procedure is not something to infer from a file we could not +// read, and silently replacing the evidence would make the next person's +// diagnosis impossible. +// +// A policy row carries ONLY a `:` MODEL identifier and a boolean. There is +// no per-unit identity here by construction — no serial, no ICCID, no ID_PATH — +// because ModemManager's mechanism is model-wide and a per-unit key would be a +// promise the dispatcher cannot keep. + +import { chmod, mkdir, readFile, rename, writeFile } from 'node:fs/promises'; +import { dirname } from 'node:path'; + +import { isFccUnlockKey } from './coverage'; + +/** The current on-disk schema version. Bump when the persisted shape changes. */ +export const FCC_UNLOCK_SCHEMA_VERSION = 1; + +/** The pinned location of the policy of record. `/data` survives a slot swap. */ +export const FCC_UNLOCK_POLICY_PATH = '/data/ceralive/fcc-unlock-policy.json'; + +/** + * The persisted document. + * + * `unlock` is a TOTAL map from `:` to the operator's answer. An absent key + * and an explicit `false` are the same fact (not opted in) and both are legal on + * disk: a `false` is what an opt-OUT leaves behind, and keeping it is what lets the + * shell reconciler prove it parsed a real answer rather than an empty document. + */ +export interface PersistedFccUnlockPolicy { + readonly schemaVersion: typeof FCC_UNLOCK_SCHEMA_VERSION; + readonly savedAtMs: number; + readonly unlock: Readonly>; +} + +/** A metadata-only log event. Corruption NEVER carries the raw file content. */ +export type FccUnlockLogEvent = { + readonly kind: 'corrupt-policy'; + readonly bytes: number; + readonly reason: string; +}; + +export type FccUnlockLogger = (event: FccUnlockLogEvent) => void; + +/** The persistence seam `setFccUnlockPolicy` drives. */ +export interface FccUnlockPolicyStore { + /** Load persisted policy; an absent or unreadable file loads as EMPTY. */ + load(nowMs: number): Promise; + /** Atomically write policy with mode 0600 (temp → chmod → rename). */ + save(state: PersistedFccUnlockPolicy): Promise; +} + +export interface FccUnlockPolicyFileStoreOptions { + readonly path?: string; + readonly logger?: FccUnlockLogger; +} + +function defaultLogger(event: FccUnlockLogEvent): void { + console.warn(`[fcc-unlock-policy] ${event.kind}: bytes=${event.bytes} reason=${event.reason}`); +} + +function emptyState(nowMs: number): PersistedFccUnlockPolicy { + return { schemaVersion: FCC_UNLOCK_SCHEMA_VERSION, savedAtMs: nowMs, unlock: {} }; +} + +/** A schema violation naming only the offending FIELD (never file content). */ +class FccPolicySchemaError extends Error { + constructor(field: string) { + super(`schema-mismatch: ${field}`); + } +} + +/** + * Parse + validate the document. Throws `FccPolicySchemaError` (metadata-only). + * + * WHOLE-DOCUMENT rejection, never per-key skipping: a half-applied policy is a + * policy nobody wrote, and the shell reconciler makes the same all-or-nothing + * judgement, so the two halves agree on what a damaged file means. + */ +function validate(raw: unknown): PersistedFccUnlockPolicy { + if (typeof raw !== 'object' || raw === null) { + throw new FccPolicySchemaError('document'); + } + const doc = raw as Record; + if (doc.schemaVersion !== FCC_UNLOCK_SCHEMA_VERSION) { + throw new FccPolicySchemaError('schemaVersion'); + } + if (typeof doc.savedAtMs !== 'number' || !Number.isFinite(doc.savedAtMs)) { + throw new FccPolicySchemaError('savedAtMs'); + } + if (typeof doc.unlock !== 'object' || doc.unlock === null || Array.isArray(doc.unlock)) { + throw new FccPolicySchemaError('unlock'); + } + const unlock: Record = {}; + for (const [key, value] of Object.entries(doc.unlock as Record)) { + if (!isFccUnlockKey(key)) { + throw new FccPolicySchemaError('unlock.key'); + } + if (typeof value !== 'boolean') { + throw new FccPolicySchemaError('unlock.value'); + } + unlock[key] = value; + } + return { + schemaVersion: FCC_UNLOCK_SCHEMA_VERSION, + savedAtMs: doc.savedAtMs, + unlock, + }; +} + +/** Classify a load failure into a metadata-only reason string (no raw content). */ +function classifyFailure(error: unknown): string { + if (error instanceof FccPolicySchemaError) { + return error.message; + } + if (error instanceof SyntaxError) { + const offset = /position (\d+)/.exec(error.message)?.[1]; + return offset !== undefined ? `invalid-json at offset ${offset}` : 'invalid-json'; + } + return 'unreadable'; +} + +/** The `:` models the policy positively enables. Sorted, for stability. */ +export function enabledFccUnlockKeys(state: PersistedFccUnlockPolicy): string[] { + return Object.entries(state.unlock) + .filter(([, enabled]) => enabled) + .map(([key]) => key) + .sort(); +} + +/** One model's answer. An unmentioned model is NOT enabled — absence is not consent. */ +export function isFccUnlockEnabled(state: PersistedFccUnlockPolicy, key: string): boolean { + return state.unlock[key] === true; +} + +export function createFccUnlockPolicyFileStore( + options: FccUnlockPolicyFileStoreOptions = {}, +): FccUnlockPolicyStore { + const logger = options.logger ?? defaultLogger; + const path = options.path ?? FCC_UNLOCK_POLICY_PATH; + + async function writeAtomic(state: PersistedFccUnlockPolicy): Promise { + await mkdir(dirname(path), { recursive: true }); + const tmp = `${path}.tmp`; + // The trailing newline is for the shell reconciler's benefit, not decoration: + // it reads the file with `tr -d`, and a newline-terminated document is what a + // human `cat`ing it on a board expects to see. + await writeFile(tmp, `${JSON.stringify(state)}\n`); + // chmod AFTER the write (not an open flag) so mode is 0600 regardless of umask. + await chmod(tmp, 0o600); + await rename(tmp, path); + } + + return { + async load(nowMs: number): Promise { + let text: string; + try { + text = await readFile(path, 'utf8'); + } catch { + // Absent (or unreadable) → EMPTY, which activates nothing. This is the + // state on every device that never opted in, and it is the safe default. + return emptyState(nowMs); + } + try { + return validate(JSON.parse(text)); + } catch (error) { + logger({ + kind: 'corrupt-policy', + bytes: Buffer.byteLength(text, 'utf8'), + reason: classifyFailure(error), + }); + // Deliberately NOT rewritten — see the header. Refuse to act, keep the + // evidence, and let the next write replace it wholesale. + return emptyState(nowMs); + } + }, + save: writeAtomic, + }; +} diff --git a/control/src/fcc/policy-write.ts b/control/src/fcc/policy-write.ts new file mode 100644 index 0000000..3ff92fc --- /dev/null +++ b/control/src/fcc/policy-write.ts @@ -0,0 +1,82 @@ +// `setFccUnlockPolicy` — the WRITE half of the FCC auto-unlock surface. +// +// TYPED RESULTS, NEVER A THROW ON BAD INPUT. Following the `PowerHook` and +// `setUsagePolicy` precedents, a malformed key or an uncovered model is a `rejected` +// result carrying a named reason rather than an exception — this is called from an +// RPC boundary, where a throw becomes an opaque 500. +// +// THE COVERAGE CHECK IS PART OF THE WRITE, not a UI nicety. Persisting `true` for a +// model ModemManager ships no procedure for would leave the operator staring at an +// enabled toggle that provably cannot do anything: the reconciler would find no +// `:` in the available tier and skip it forever, silently. Refusing at the +// write is the only place that fact can be reported to the person who asked for it. +// +// Disabling is deliberately NOT coverage-checked. A `false` must always be +// persistable — including for a model whose coverage answer has since changed, or +// which a previous release wrote — because a fail-closed opt-OUT is not a thing. + +import { normalizeVidPid, resolveFccUnlockCoverage } from './coverage'; +import type { FccUnlockPolicyStore, PersistedFccUnlockPolicy } from './policy-store'; + +export interface SetFccUnlockPolicyDeps { + readonly store: FccUnlockPolicyStore; + /** Injectable clock (defaults to `Date.now`). */ + readonly now?: () => number; +} + +export interface SetFccUnlockPolicyRequest { + readonly vid: string; + readonly pid: string; + readonly enabled: boolean; +} + +export type SetFccUnlockPolicyRejection = 'invalid-vid-pid' | 'not-covered'; + +export type SetFccUnlockPolicyResult = + | { + readonly status: 'applied'; + /** The dispatcher key this write is about, normalized. */ + readonly key: string; + readonly enabled: boolean; + /** The whole document now on disk, so a caller need not re-read it. */ + readonly policy: PersistedFccUnlockPolicy; + /** + * True when the persisted value CHANGED. A caller uses this to decide + * whether the modem needs a re-probe: ModemManager runs the dispatcher + * during initialization only, so a genuine change needs + * `mmcli -m --disable && --enable` (or a replug) to take effect on an + * already-enumerated modem — and an unchanged write must not cost one. + */ + readonly changed: boolean; + } + | { + readonly status: 'rejected'; + readonly reason: SetFccUnlockPolicyRejection; + }; + +export async function setFccUnlockPolicy( + request: SetFccUnlockPolicyRequest, + deps: SetFccUnlockPolicyDeps, +): Promise { + const key = normalizeVidPid(request.vid, request.pid); + if (key === undefined) { + return { status: 'rejected', reason: 'invalid-vid-pid' }; + } + if (request.enabled && resolveFccUnlockCoverage(request.vid, request.pid) !== 'present') { + return { status: 'rejected', reason: 'not-covered' }; + } + + const now = deps.now ?? Date.now; + const nowMs = now(); + const current = await deps.store.load(nowMs); + const changed = (current.unlock[key] === true) !== request.enabled; + + const next: PersistedFccUnlockPolicy = { + schemaVersion: current.schemaVersion, + savedAtMs: nowMs, + unlock: { ...current.unlock, [key]: request.enabled }, + }; + await deps.store.save(next); + + return { status: 'applied', key, enabled: request.enabled, policy: next, changed }; +} diff --git a/control/src/fcc/policy.test.ts b/control/src/fcc/policy.test.ts new file mode 100644 index 0000000..a61ea2a --- /dev/null +++ b/control/src/fcc/policy.test.ts @@ -0,0 +1,148 @@ +import { afterEach, describe, expect, it } from 'bun:test'; +import { mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { + createFccUnlockPolicyFileStore, + enabledFccUnlockKeys, + FCC_UNLOCK_POLICY_PATH, + FCC_UNLOCK_SCHEMA_VERSION, + type FccUnlockLogEvent, + isFccUnlockEnabled, +} from './policy-store'; +import { setFccUnlockPolicy } from './policy-write'; + +const dirs: string[] = []; + +async function scratch(): Promise { + // mkdtemp, not a fixed path: two checkouts of this repo may run the suite at once. + const dir = await mkdtemp(join(tmpdir(), 'ceralive-fcc-')); + dirs.push(dir); + return join(dir, 'fcc-unlock-policy.json'); +} + +afterEach(async () => { + await Promise.all(dirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true }))); +}); + +describe('the policy of record', () => { + it('Given the pinned path, When it is read, Then it is under /data so a slot swap cannot take it', () => { + expect(FCC_UNLOCK_POLICY_PATH.startsWith('/data/')).toBe(true); + }); + + it('Given no file at all, When the policy loads, Then it is empty and nothing is enabled', async () => { + const path = await scratch(); + const state = await createFccUnlockPolicyFileStore({ path }).load(1); + expect(state.unlock).toEqual({}); + expect(enabledFccUnlockKeys(state)).toEqual([]); + expect(isFccUnlockEnabled(state, '2c7c:0801')).toBe(false); + }); + + it('Given a saved policy, When it is reloaded, Then it round-trips at mode 0600', async () => { + const path = await scratch(); + const store = createFccUnlockPolicyFileStore({ path }); + await store.save({ + schemaVersion: FCC_UNLOCK_SCHEMA_VERSION, + savedAtMs: 7, + unlock: { '2c7c:0801': true, '1199:9079': false }, + }); + const state = await store.load(9); + expect(state.savedAtMs).toBe(7); + expect(enabledFccUnlockKeys(state)).toEqual(['2c7c:0801']); + expect((await stat(path)).mode & 0o777).toBe(0o600); + }); + + // Whole-document rejection, matching the shell reconciler's own judgement: a + // half-applied regulatory-unlock policy is a policy nobody wrote. + it.each([ + ['invalid JSON', '{ not json'], + ['a wrong schema version', '{"schemaVersion":99,"savedAtMs":1,"unlock":{}}'], + ['a vendor-only key', '{"schemaVersion":1,"savedAtMs":1,"unlock":{"2c7c":true}}'], + ['a non-boolean answer', '{"schemaVersion":1,"savedAtMs":1,"unlock":{"2c7c:0801":"yes"}}'], + ['an array for unlock', '{"schemaVersion":1,"savedAtMs":1,"unlock":[]}'], + ])( + 'Given %s, When the policy loads, Then it reads as empty and the damaged bytes are KEPT', + async (_name, body) => { + const path = await scratch(); + await writeFile(path, body); + const events: FccUnlockLogEvent[] = []; + const state = await createFccUnlockPolicyFileStore({ + path, + logger: (event) => events.push(event), + }).load(1); + + expect(state.unlock).toEqual({}); + expect(events).toHaveLength(1); + expect(events[0]?.kind).toBe('corrupt-policy'); + // Metadata only — the reason names a field or an offset, never content. + expect(events[0]?.reason).not.toContain('2c7c'); + // The evidence stays on disk for whoever has to diagnose it. + expect(await readFile(path, 'utf8')).toBe(body); + }, + ); +}); + +describe('setFccUnlockPolicy', () => { + it('Given a covered model, When it is enabled, Then it is persisted and reported as changed', async () => { + const path = await scratch(); + const store = createFccUnlockPolicyFileStore({ path }); + const result = await setFccUnlockPolicy( + { vid: '2C7C', pid: '0801', enabled: true }, + { store, now: () => 42 }, + ); + + expect(result).toMatchObject({ status: 'applied', key: '2c7c:0801', changed: true }); + expect(enabledFccUnlockKeys(await store.load(0))).toEqual(['2c7c:0801']); + }); + + // An unchanged write must not cost a modem re-probe. + it('Given an already-enabled model, When it is enabled again, Then changed is false', async () => { + const path = await scratch(); + const store = createFccUnlockPolicyFileStore({ path }); + await setFccUnlockPolicy({ vid: '2c7c', pid: '0801', enabled: true }, { store }); + const again = await setFccUnlockPolicy({ vid: '2c7c', pid: '0801', enabled: true }, { store }); + expect(again).toMatchObject({ status: 'applied', changed: false }); + }); + + // The whole point of the coverage gate: an enabled toggle that provably cannot do + // anything is worse than a refusal the operator can read. + it('Given a model ModemManager ships no procedure for, When it is enabled, Then it is rejected not-covered', async () => { + const path = await scratch(); + const store = createFccUnlockPolicyFileStore({ path }); + const result = await setFccUnlockPolicy({ vid: '12d1', pid: '14dc', enabled: true }, { store }); + expect(result).toEqual({ status: 'rejected', reason: 'not-covered' }); + expect((await store.load(0)).unlock).toEqual({}); + }); + + // A fail-closed opt-OUT is not a thing. + it('Given an uncovered model, When it is DISABLED, Then the write is accepted', async () => { + const path = await scratch(); + const store = createFccUnlockPolicyFileStore({ path }); + const result = await setFccUnlockPolicy( + { vid: '12d1', pid: '14dc', enabled: false }, + { store }, + ); + expect(result).toMatchObject({ status: 'applied', enabled: false }); + }); + + it('Given a malformed id, When it is written, Then it is rejected before any disk write', async () => { + const path = await scratch(); + const store = createFccUnlockPolicyFileStore({ path }); + const result = await setFccUnlockPolicy({ vid: 'nope', pid: '0801', enabled: true }, { store }); + expect(result).toEqual({ status: 'rejected', reason: 'invalid-vid-pid' }); + expect((await store.load(0)).unlock).toEqual({}); + }); + + it('Given two enabled models, When one is disabled, Then the other survives and the opt-out is recorded', async () => { + const path = await scratch(); + const store = createFccUnlockPolicyFileStore({ path }); + await setFccUnlockPolicy({ vid: '2c7c', pid: '0801', enabled: true }, { store }); + await setFccUnlockPolicy({ vid: '1199', pid: '9079', enabled: true }, { store }); + await setFccUnlockPolicy({ vid: '2c7c', pid: '0801', enabled: false }, { store }); + + const state = await store.load(0); + expect(enabledFccUnlockKeys(state)).toEqual(['1199:9079']); + expect(state.unlock['2c7c:0801']).toBe(false); + }); +}); diff --git a/control/src/hardware/hilink-protocol.ts b/control/src/hardware/hilink-protocol.ts new file mode 100644 index 0000000..3fdbcec --- /dev/null +++ b/control/src/hardware/hilink-protocol.ts @@ -0,0 +1,92 @@ +export function parseHilinkXmlValue(body: string, tag: string): string | undefined { + const match = body.match(new RegExp(`<${tag}>([^<]*)`, 'i')); + return match?.[1]?.trim(); +} + +export type HilinkSessionDocument = { + readonly cookie: string; + readonly token: string; +}; + +export function parseHilinkSession(body: string): HilinkSessionDocument | undefined { + const cookie = parseHilinkXmlValue(body, 'SesInfo'); + const token = parseHilinkXmlValue(body, 'TokInfo'); + return cookie === undefined || cookie === '' || token === undefined || token === '' + ? undefined + : { cookie, token }; +} + +export type HilinkUserState = { + readonly state: string; + readonly passwordType: 3 | 4; +}; + +export function parseHilinkUserState(body: string): HilinkUserState | undefined { + const state = parseHilinkXmlValue(body, 'State'); + const rawPasswordType = parseHilinkXmlValue(body, 'password_type'); + if (state === undefined || (rawPasswordType !== '3' && rawPasswordType !== '4')) return undefined; + return { state, passwordType: rawPasswordType === '3' ? 3 : 4 }; +} + +export type HilinkProtocolUnknownReason = + | 'unsupported' + | 'not-reported' + | 'malformed' + | 'auth-expired' + | 'unreachable'; +export type HilinkDataCapability = + | { readonly state: 'reported'; readonly enabled: boolean } + | { + readonly state: 'unavailable'; + readonly reason: HilinkProtocolUnknownReason | 'refused'; + readonly code?: string; + }; + +export function parseHilinkDataCapability(body: string): HilinkDataCapability { + const code = parseHilinkXmlValue(body, 'code'); + if (code === '125002') return { state: 'unavailable', reason: 'auth-expired' }; + if (code !== undefined) return { state: 'unavailable', reason: 'refused', code }; + const value = parseHilinkXmlValue(body, 'dataswitch'); + if (value === undefined) + return { state: 'unavailable', reason: body === '' ? 'unreachable' : 'malformed' }; + if (value !== '0' && value !== '1') return { state: 'unavailable', reason: 'malformed' }; + return { state: 'reported', enabled: value === '1' }; +} + +export type HilinkNetModeCapability = + | { + readonly state: 'reported'; + readonly modes: readonly { readonly id: string; readonly name?: string }[]; + readonly current?: string; + } + | { + readonly state: 'unavailable'; + readonly reason: HilinkProtocolUnknownReason | 'refused'; + readonly code?: string; + }; + +export function parseHilinkCapabilities(input: { + readonly netModeList: string; + readonly netMode?: string; +}): { readonly net_mode: HilinkNetModeCapability } { + const code = parseHilinkXmlValue(input.netModeList, 'code'); + if (code === '125002') return { net_mode: { state: 'unavailable', reason: 'auth-expired' } }; + if (code !== undefined) return { net_mode: { state: 'unavailable', reason: 'refused', code } }; + if (input.netModeList === '') + return { net_mode: { state: 'unavailable', reason: 'unreachable' } }; + if (!//i.test(input.netModeList)) + return { net_mode: { state: 'unavailable', reason: 'malformed' } }; + const modes = [...input.netModeList.matchAll(/([\s\S]*?)<\/NetworkMode>/gi)].flatMap( + (match) => { + const id = parseHilinkXmlValue(match[1] ?? '', 'Index'); + if (id === undefined || id === '') return []; + const name = parseHilinkXmlValue(match[1] ?? '', 'Name'); + return name === undefined || name === '' ? [{ id }] : [{ id, name }]; + }, + ); + if (modes.length === 0) return { net_mode: { state: 'unavailable', reason: 'not-reported' } }; + const current = parseHilinkXmlValue(input.netMode ?? '', 'NetworkMode'); + return current === undefined || current === '' + ? { net_mode: { state: 'reported', modes } } + : { net_mode: { state: 'reported', modes, current } }; +} diff --git a/control/src/hardware/index.ts b/control/src/hardware/index.ts new file mode 100644 index 0000000..67f6efa --- /dev/null +++ b/control/src/hardware/index.ts @@ -0,0 +1,16 @@ +// `@ceralive/modem-control/hardware` — the per-SKU hardware model. +// +// This subpath is the narrow entry for everything keyed on the PHYSICAL unit rather +// than on a live D-Bus object: which USB composition a SKU may be switched into and +// which transitions were certified for it (`../usb-mode`), and the MMModemBand +// vocabulary plus the per-SKU band-lock certification catalog (`../band`). +// +// Both halves are evidence-gated catalogs plus their pure lookups — no transport, no +// mutation verb. The D-Bus verbs that ACT on these facts stay where they live +// (`../backend/mm-mutations.ts`, declared on `ModemManagerPort`); a consumer that only +// needs to know what a SKU is capable of should not have to import a bus client to +// find out. That asymmetry is the reason this entry exists. + +export * from '../band'; +export * from '../usb-mode'; +export * from './router-parsers'; diff --git a/control/src/hardware/router-parsers.test.ts b/control/src/hardware/router-parsers.test.ts new file mode 100644 index 0000000..8e12cd3 --- /dev/null +++ b/control/src/hardware/router-parsers.test.ts @@ -0,0 +1,170 @@ +import { describe, expect, test } from 'bun:test'; + +import { + deriveSimPresence, + parseHilinkCapabilities, + parseHilinkDataCapability, + parseHilinkSession, + parseHilinkSignal, + parseHilinkUserState, + parseUfiDetails, + parseUfiSignal, + parseZteDetails, + parseZteSignal, +} from './router-parsers'; + +describe('migrated modem facts', () => { + test('derives SIM presence from ModemManager slot facts', () => { + expect(deriveSimPresence({ sim: '/org/freedesktop/ModemManager1/SIM/0' })).toBe('present'); + expect(deriveSimPresence({ sim: '', simSlots: ['/', '/'], failedReason: 'sim-missing' })).toBe( + 'absent', + ); + expect(deriveSimPresence({ sim: '' })).toBe('unknown'); + }); +}); + +describe('transport-free router response parsers', () => { + test('normalizes Huawei signal values without fabricating empty metrics', () => { + const signal = parseHilinkSignal({ + status: '45', + signal: + '-71dBm-93dBm12dB', + }); + expect(signal.bars).toEqual({ state: 'known', value: 4 }); + expect(signal.rsrp).toEqual({ state: 'known', value: -93 }); + expect(signal.rsrq).toEqual({ state: 'unknown', reason: 'not-reported' }); + }); + + test('normalizes ZTE and UFI signal dialects', () => { + expect(parseZteSignal('{"signalbar":"4","rssi":"-67","lte_snr":"8"}')).toMatchObject({ + bars: { state: 'known', value: 4 }, + dbm: { state: 'known', value: -67 }, + snr: { state: 'known', value: 8 }, + }); + expect( + parseUfiSignal({ + sysinfo: '{"reply":"ok","params":{"SIGNAL":-96}}', + overview: '', + status: '', + }).dbm, + ).toEqual({ state: 'known', value: -96 }); + }); + + test('extracts ZTE and UFI details while dropping vendor placeholders', () => { + expect( + parseZteDetails( + '{"network_type":"LTE","network_provider":"732103","network_provider_fullname":"Movistar","cell_id":"2c20f34"}', + ), + ).toEqual({ network_type: 'LTE', provider: 'Movistar', cell_id: '2c20f34' }); + expect( + parseUfiDetails({ + overview: + '{"reply":"ok","params":{"SSID":"4G-UFI","WANIP":"-","IMSI":"732123","ICCID":"8957","WEBVER":"WEB1.1"}}', + sysinfo: '{"reply":"ok","params":{"cellid":"42","bsid":"25002"}}', + }), + ).toEqual({ + ssid: '4G-UFI', + imsi: '732123', + iccid: '8957', + web_version: 'WEB1.1', + cell_id: '42', + station_id: '25002', + }); + }); + + test('keeps the serving band and the WAN leg band as two separate readings', () => { + // Folding them onto one key reports a band the device never claimed for that + // leg — and the two genuinely disagree once carrier aggregation is up. + expect(parseZteDetails('{"lte_band":"B4","wan_active_band":"LTE BAND 7"}')).toEqual({ + band: 'B4', + network_band: 'LTE BAND 7', + }); + expect(parseZteDetails('{"band":"B28"}')).toEqual({ band: 'B28' }); + expect(parseZteDetails('{"lte_band":"B4"}')).toEqual({ band: 'B4' }); + expect(parseZteDetails('{"wan_active_band":"LTE BAND 7"}')).toEqual({ + network_band: 'LTE BAND 7', + }); + }); + + test('carries the carrier composition and the dongle-owned counters', () => { + expect( + parseZteDetails( + JSON.stringify({ + lte_ca_pcell_arfcn: '2000', + lte_ca_pcell_band: '4', + lte_ca_pcell_bandwidth: '20', + lte_ca_scell_arfcn: '5230', + lte_ca_scell_band: '7', + lte_ca_scell_bandwidth: '15', + monthly_tx_bytes: '12884901888', + monthly_rx_bytes: '96636764160', + monthly_time: '184320', + date_month: '2026-08', + realtime_tx_bytes: '1048576', + realtime_rx_bytes: '8388608', + realtime_tx_thrpt: '131072', + realtime_rx_thrpt: '1048576', + realtime_time: '3600', + }), + ), + ).toEqual({ + pcell_arfcn: '2000', + pcell_band: '4', + pcell_bandwidth: '20', + scell_arfcn: '5230', + scell_band: '7', + scell_bandwidth: '15', + monthly_tx_bytes: '12884901888', + monthly_rx_bytes: '96636764160', + monthly_time: '184320', + monthly_period: '2026-08', + session_tx_bytes: '1048576', + session_rx_bytes: '8388608', + session_tx_rate: '131072', + session_rx_rate: '1048576', + session_time: '3600', + }); + }); + + test('drops every vendor placeholder, not only the single dash', () => { + expect( + parseZteDetails('{"lte_band":"--","cell_id":"n/a","network_type":"N/A","provider":" "}'), + ).toBeUndefined(); + }); + + test('reports Huawei network-mode capability and refusal distinctly', () => { + expect( + parseHilinkCapabilities({ + netModeList: + '00AUTO', + netMode: '00', + }), + ).toEqual({ + net_mode: { state: 'reported', modes: [{ id: '00', name: 'AUTO' }], current: '00' }, + }); + expect(parseHilinkCapabilities({ netModeList: '112008' })).toEqual({ + net_mode: { state: 'unavailable', reason: 'refused', code: '112008' }, + }); + }); + + test('parses Huawei session, login-profile, and data capability documents centrally', () => { + expect( + parseHilinkSession( + 'SessionID=fixturefixture-token', + ), + ).toEqual({ cookie: 'SessionID=fixture', token: 'fixture-token' }); + expect( + parseHilinkUserState( + '-1admin4', + ), + ).toEqual({ state: '-1', passwordType: 4 }); + expect(parseHilinkDataCapability('1')).toEqual({ + state: 'reported', + enabled: true, + }); + expect(parseHilinkDataCapability('125002')).toEqual({ + state: 'unavailable', + reason: 'auth-expired', + }); + }); +}); diff --git a/control/src/hardware/router-parsers.ts b/control/src/hardware/router-parsers.ts new file mode 100644 index 0000000..97f334a --- /dev/null +++ b/control/src/hardware/router-parsers.ts @@ -0,0 +1,322 @@ +import { z } from 'zod'; +import { parseHilinkXmlValue } from './hilink-protocol'; + +export * from './hilink-protocol'; + +export type SimPresence = 'present' | 'absent' | 'unknown'; +export type SimPresenceFacts = { + readonly sim?: string; + readonly simSlots?: readonly string[]; + readonly failedReason?: string; +}; + +const SIM_OBJECT_PATH = /^\/org\/freedesktop\/ModemManager1\/SIM\/\d+$/; + +/** The mmcli spelling of `MM_MODEM_STATE_FAILED_REASON_SIM_MISSING` — the ONLY proof of absence. */ +export const SIM_MISSING_FAILED_REASON = 'sim-missing'; + +/** The fields a presence decision may be drawn from, in the order they are inspected. */ +export const SIM_PRESENCE_FIELDS = ['sim', 'simSlots', 'failedReason'] as const; +export type SimPresenceField = (typeof SIM_PRESENCE_FIELDS)[number]; + +/** + * WHICH FACT decided a SIM presence — the whole point of this type. + * + * `absent` is reachable through exactly ONE member (`state-failed-reason`), so a + * consumer can prove that no code path inferred "there is no SIM" from a blank or + * missing field. A modem that exports `Sim: '/'` and says nothing else is + * `no-evidence`/`unknown`: an empty object path is ModemManager's answer for "no SIM + * object is bound right now", which a modem also reports while it is still + * initializing, while its SIM is locked out, and while a slot switch is in flight. + * + * `vendor-code-unclaimed` is the router half. HiLink, goform and HIMI each report + * their own presence code with vendor semantics no migrated decoder covers, so the + * code is NAMED here and its value stays verbatim in the diagnostics block, rather + * than being guessed into a presence. + */ +export type SimPresenceEvidence = + | { readonly kind: 'sim-object-path'; readonly field: 'sim'; readonly value: string } + | { readonly kind: 'sim-slot-object-path'; readonly field: 'simSlots'; readonly value: string } + | { + readonly kind: 'state-failed-reason'; + readonly field: 'failedReason'; + readonly value: typeof SIM_MISSING_FAILED_REASON; + } + | { readonly kind: 'no-evidence'; readonly inspected: readonly SimPresenceField[] } + | { readonly kind: 'vendor-code-unclaimed'; readonly field: string }; + +export type SimPresenceReading = { + readonly presence: SimPresence; + readonly evidence: SimPresenceEvidence; +}; + +/** The presence decision together with the fact that produced it. */ +export function readSimPresence(facts: SimPresenceFacts): SimPresenceReading { + const sim = facts.sim?.trim() ?? ''; + if (SIM_OBJECT_PATH.test(sim)) { + return { presence: 'present', evidence: { kind: 'sim-object-path', field: 'sim', value: sim } }; + } + const slot = facts.simSlots + ?.map((each) => each.trim()) + .find((each) => SIM_OBJECT_PATH.test(each)); + if (slot !== undefined) { + return { + presence: 'present', + evidence: { kind: 'sim-slot-object-path', field: 'simSlots', value: slot }, + }; + } + if (facts.failedReason === SIM_MISSING_FAILED_REASON) { + return { + presence: 'absent', + evidence: { + kind: 'state-failed-reason', + field: 'failedReason', + value: SIM_MISSING_FAILED_REASON, + }, + }; + } + return { presence: 'unknown', evidence: { kind: 'no-evidence', inspected: SIM_PRESENCE_FIELDS } }; +} + +export function deriveSimPresence(facts: SimPresenceFacts): SimPresence { + return readSimPresence(facts).presence; +} + +export type RouterSignalUnknownReason = + | 'unsupported' + | 'not-reported' + | 'malformed' + | 'auth-expired' + | 'unreachable'; +export type RouterSignalMetric = + | { readonly state: 'known'; readonly value: number } + | { readonly state: 'unknown'; readonly reason: RouterSignalUnknownReason }; +export type RouterSignalModel = { + readonly provenance: 'hilink-admin-api' | 'zte-goform' | 'ufi-himiapi'; + readonly freshness: 'live' | 'unknown'; + readonly bars: RouterSignalMetric; + readonly max_bars: RouterSignalMetric; + readonly dbm: RouterSignalMetric; + readonly rsrp: RouterSignalMetric; + readonly rsrq: RouterSignalMetric; + readonly snr: RouterSignalMetric; + readonly sinr: RouterSignalMetric; +}; + +const unknown = (reason: RouterSignalUnknownReason): RouterSignalMetric => ({ + state: 'unknown', + reason, +}); +const known = (value: number): RouterSignalMetric => ({ state: 'known', value }); + +function numericMetric(value: string | number | undefined): RouterSignalMetric { + if (value === undefined || String(value).trim() === '') return unknown('not-reported'); + const parsed = Number.parseFloat(String(value)); + return Number.isFinite(parsed) ? known(parsed) : unknown('malformed'); +} + +const flatRecordSchema = z.record(z.string(), z.union([z.string(), z.number()])); +const ufiBodySchema = z.object({ + reply: z.string(), + params: flatRecordSchema.optional(), +}); + +function parseFlatRecord(body: string): Readonly> | undefined { + const parsed = z + .string() + .transform((value, context) => { + try { + return JSON.parse(value); + } catch (error) { + if (!(error instanceof SyntaxError)) throw error; + context.addIssue({ code: 'custom', message: 'invalid JSON' }); + return z.NEVER; + } + }) + .pipe(flatRecordSchema) + .safeParse(body); + return parsed.success ? parsed.data : undefined; +} + +function parseUfiBody(body: string): z.infer | undefined { + const parsed = z + .string() + .transform((value, context) => { + try { + return JSON.parse(value); + } catch (error) { + if (!(error instanceof SyntaxError)) throw error; + context.addIssue({ code: 'custom', message: 'invalid JSON' }); + return z.NEVER; + } + }) + .pipe(ufiBodySchema) + .safeParse(body); + return parsed.success ? parsed.data : undefined; +} + +export function parseHilinkSignal(input: { + readonly status: string; + readonly signal: string; +}): RouterSignalModel { + const authStatus = parseHilinkXmlValue(input.status, 'code') === '125002'; + const authSignal = parseHilinkXmlValue(input.signal, 'code') === '125002'; + const statusReason = authStatus ? 'auth-expired' : 'not-reported'; + const signalReason = authSignal ? 'auth-expired' : 'not-reported'; + const metric = (tag: string): RouterSignalMetric => + authSignal ? unknown(signalReason) : numericMetric(parseHilinkXmlValue(input.signal, tag)); + return { + provenance: 'hilink-admin-api', + freshness: authStatus && authSignal ? 'unknown' : 'live', + bars: authStatus + ? unknown(statusReason) + : numericMetric(parseHilinkXmlValue(input.status, 'SignalIcon')), + max_bars: authStatus + ? unknown(statusReason) + : numericMetric(parseHilinkXmlValue(input.status, 'maxsignal')), + dbm: metric('rssi'), + rsrp: metric('rsrp'), + rsrq: metric('rsrq'), + snr: unknown('unsupported'), + sinr: metric('sinr'), + }; +} + +export function parseZteSignal(body: string): RouterSignalModel { + const record = parseFlatRecord(body); + const malformed = record === undefined; + const metric = (key: string): RouterSignalMetric => + malformed ? unknown('malformed') : numericMetric(record[key]); + const bars = metric('signalbar'); + return { + provenance: 'zte-goform', + freshness: malformed ? 'unknown' : 'live', + bars, + max_bars: bars.state === 'known' ? known(5) : unknown('not-reported'), + dbm: metric('rssi'), + rsrp: metric('lte_rsrp'), + rsrq: metric('lte_rsrq'), + snr: metric('lte_snr'), + sinr: unknown('unsupported'), + }; +} + +export function parseUfiSignal(input: { + readonly sysinfo: string; + readonly overview: string; + readonly status: string; +}): RouterSignalModel { + const bodies = [input.sysinfo, input.overview, input.status].map(parseUfiBody); + const authExpired = bodies.every((body) => body?.reply === 'SessionOut'); + const answered = bodies.some((body) => body?.reply === 'ok'); + const value = + bodies[0]?.params?.SIGNAL ?? bodies[1]?.params?.SIGNAL ?? bodies[2]?.params?.signalStrength; + const dbm = authExpired + ? unknown('auth-expired') + : answered + ? numericMetric(value) + : unknown('malformed'); + return { + provenance: 'ufi-himiapi', + freshness: answered ? 'live' : 'unknown', + bars: unknown('unsupported'), + max_bars: unknown('unsupported'), + dbm, + rsrp: unknown('unsupported'), + rsrq: unknown('unsupported'), + snr: unknown('unsupported'), + sinr: unknown('unsupported'), + }; +} + +export type RouterDetails = Readonly>; + +/** + * The vendor's own "unset" placeholders. A UFI answers `-` for a WAN address, an + * IMSI and an ICCID it does not have, and the ZTE builds answer `--` / `N/A` for + * an unpopulated counter — publishing any of those as a reading puts a value on + * screen that reads like a real one. + */ +const PLACEHOLDERS: ReadonlySet = new Set(['-', '--', 'n/a', 'N/A']); + +function stated(value: string | number | undefined): string | undefined { + if (value === undefined) return undefined; + const normalized = String(value).trim(); + return normalized === '' || PLACEHOLDERS.has(normalized) ? undefined : normalized; +} + +function compact( + entries: readonly (readonly [string, string | undefined])[], +): RouterDetails | undefined { + const result: Record = {}; + for (const [key, value] of entries) if (value !== undefined) result[key] = value; + return Object.keys(result).length === 0 ? undefined : result; +} + +export function parseZteDetails(body: string): RouterDetails | undefined { + const value = parseFlatRecord(body); + if (value === undefined) return undefined; + return compact([ + ['network_type', stated(value.network_type)], + [ + 'provider', + stated(value.network_provider_fullname) ?? + stated(value.network_provider) ?? + stated(value.provider), + ], + ['cell_id', stated(value.cell_id)], + ['roaming', stated(value.simcard_roam)], + ['mcc', stated(value.rmcc)], + ['mnc', stated(value.rmnc)], + ['pci', stated(value.lte_pci)], + // `band` and `network_band` are two DIFFERENT readings and must not be + // folded onto one key: `lte_band` is the serving cell's band, while + // `wan_active_band` is the band the WAN leg is active on, and the two + // disagree the moment carrier aggregation is up. Publishing either under + // the other's name reports a band the device never claimed for that leg. + ['band', stated(value.lte_band) ?? stated(value.band)], + ['network_band', stated(value.wan_active_band)], + ['carrier_aggregation', stated(value.wan_lte_ca)], + ['pcell_arfcn', stated(value.lte_ca_pcell_arfcn)], + ['pcell_band', stated(value.lte_ca_pcell_band)], + ['pcell_bandwidth', stated(value.lte_ca_pcell_bandwidth)], + ['scell_arfcn', stated(value.lte_ca_scell_arfcn)], + ['scell_band', stated(value.lte_ca_scell_band)], + ['scell_bandwidth', stated(value.lte_ca_scell_bandwidth)], + ['monthly_tx_bytes', stated(value.monthly_tx_bytes)], + ['monthly_rx_bytes', stated(value.monthly_rx_bytes)], + ['monthly_time', stated(value.monthly_time)], + ['monthly_period', stated(value.date_month)], + // Named `session_*` rather than `realtime_*`: these are cumulative counters + // and a throughput, and the vendor's own prefix reads as "live rate" for + // all five. + ['session_tx_bytes', stated(value.realtime_tx_bytes)], + ['session_rx_bytes', stated(value.realtime_rx_bytes)], + ['session_tx_rate', stated(value.realtime_tx_thrpt)], + ['session_rx_rate', stated(value.realtime_rx_thrpt)], + ['session_time', stated(value.realtime_time)], + ]); +} + +export function parseUfiDetails(input: { + readonly overview?: string; + readonly sysinfo?: string; + readonly produceInfo?: string; +}): RouterDetails | undefined { + const overview = parseUfiBody(input.overview ?? '')?.params; + const sysinfo = parseUfiBody(input.sysinfo ?? '')?.params; + const product = parseUfiBody(input.produceInfo ?? '')?.params; + return compact([ + ['product', stated(product?.productname) ?? stated(product?.ProductName)], + ['ssid', stated(overview?.SSID)], + ['wan_ip', stated(overview?.WANIP)], + ['imsi', stated(overview?.IMSI)], + ['iccid', stated(overview?.ICCID)], + ['web_version', stated(overview?.WEBVER)], + ['cell_id', stated(sysinfo?.cellid)], + ['station_id', stated(sysinfo?.bsid)], + ['cpu_temp', stated(sysinfo?.cputemp)], + ['wifi_clients', stated(sysinfo?.wifinum)], + ['eth_clients', stated(sysinfo?.ethnum)], + ]); +} diff --git a/control/src/index.ts b/control/src/index.ts index 92312dc..7e6e8dc 100644 --- a/control/src/index.ts +++ b/control/src/index.ts @@ -9,7 +9,20 @@ export const PACKAGE_NAME = '@ceralive/modem-control'; export * from './backend'; +export * from './band'; +export * from './capability'; export * from './domain'; +export * from './fcc'; +export * from './hardware/router-parsers'; +export * from './journal'; +export * from './location'; +export * from './observations'; +export * from './operations'; export * from './ports'; +export * from './providers'; +export * from './radio'; export * from './redact'; +export * from './safety'; +export * from './sms'; export * from './usb-mode'; +export * from './ussd'; diff --git a/control/src/journal/codec.ts b/control/src/journal/codec.ts new file mode 100644 index 0000000..fc353e9 --- /dev/null +++ b/control/src/journal/codec.ts @@ -0,0 +1,233 @@ +// Line codec for the journal: exactly one entry per line, JSON, no wrapper. +// +// WHY DECODING RETURNS A RESULT INSTEAD OF THROWING. A journal is read at exactly +// one moment — recovery after an unclean restart — and that is the moment a throw +// is most expensive: it aborts the read at the first damaged byte and takes every +// still-valid entry after it with it. The whole point of this module is that a +// damaged record is DATA, reported alongside the records that survived, so nothing +// earlier or later is discarded to make one bad line disappear. +// +// FAILURES NAME A FIELD, NEVER CONTENT. Same rule the two policy stores follow: a +// classification carries the offending field name and a byte count, never the bytes +// themselves, because a journal line can hold provider identifiers and refusal +// reasons and a log line is the wrong place to reproduce them. + +import { + type JournalDescriptorEvidence, + type JournalEntry, + type JournalOutcome, + MODEM_CONTROL_JOURNAL_SCHEMA_VERSION, +} from './entry'; + +/** Why one record could not be read. `field` names the offending key, never its value. */ +export interface JournalDecodeFailure { + readonly code: + | 'empty' + | 'invalid-json' + | 'not-an-object' + | 'unsupported-schema-version' + | 'schema-mismatch' + | 'unreadable'; + readonly field?: string; + /** Byte offset for a JSON syntax error, when the runtime reported one. */ + readonly offset?: number; +} + +export type JournalDecodeResult = + | { readonly ok: true; readonly value: T } + | { readonly ok: false; readonly failure: JournalDecodeFailure }; + +class SchemaError extends Error { + constructor(readonly field: string) { + super(`schema-mismatch: ${field}`); + } +} + +class SchemaVersionError extends Error { + constructor() { + super('unsupported-schema-version'); + } +} + +function record(raw: unknown, field: string): Record { + if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) throw new SchemaError(field); + return raw as Record; +} + +function requiredString(source: Record, field: string): string { + const value = source[field]; + if (typeof value !== 'string' || value.length === 0) throw new SchemaError(field); + return value; +} + +function optionalString(source: Record, field: string): string | undefined { + const value = source[field]; + if (value === undefined) return undefined; + if (typeof value !== 'string') throw new SchemaError(field); + return value; +} + +function requiredNonNegativeInteger(source: Record, field: string): number { + const value = source[field]; + if (typeof value !== 'number' || !Number.isInteger(value) || value < 0) { + throw new SchemaError(field); + } + return value; +} + +function requiredStringArray(source: Record, field: string): readonly string[] { + const value = source[field]; + if (!Array.isArray(value) || value.some((member) => typeof member !== 'string')) { + throw new SchemaError(field); + } + return value as readonly string[]; +} + +function member( + source: Record, + field: string, + allowed: readonly T[], +): T { + const value = source[field]; + if (typeof value !== 'string' || !(allowed as readonly string[]).includes(value)) { + throw new SchemaError(field); + } + return value as T; +} + +const AUTHORITIES = ['provider', 'controller', 'hardware'] as const; +const IMPACTS = ['read', 'write', 'session', 'disruptive', 'recovery'] as const; +const CONFIDENCES = ['high', 'medium', 'low', 'unknown'] as const; +const UNKNOWN_OUTCOME_REASONS = [ + 'stale-generation', + 'write-reply-timed-out', + 'write-reply-dropped', +] as const; + +function parseDescriptor(raw: unknown): JournalDescriptorEvidence { + const source = record(raw, 'descriptor'); + return { + descriptorId: requiredString(source, 'descriptorId'), + provider: requiredString(source, 'provider'), + authority: member(source, 'authority', AUTHORITIES), + mutationImpact: member(source, 'mutationImpact', IMPACTS), + profiles: requiredStringArray(source, 'profiles'), + firmware: requiredStringArray(source, 'firmware'), + confidence: member(source, 'confidence', CONFIDENCES), + }; +} + +function parseOutcome(raw: unknown): JournalOutcome { + const source = record(raw, 'outcome'); + switch (member(source, 'status', ['applied', 'refused', 'failed', 'unknown-outcome'] as const)) { + case 'applied': + return { status: 'applied' }; + case 'refused': + return { status: 'refused', reason: requiredString(source, 'reason') }; + case 'failed': + return { status: 'failed', reason: requiredString(source, 'reason') }; + case 'unknown-outcome': + return { + status: 'unknown-outcome', + reason: member(source, 'reason', UNKNOWN_OUTCOME_REASONS), + }; + } +} + +function parseEntry(raw: unknown): JournalEntry { + const source = record(raw, 'entry'); + if (source.schemaVersion !== MODEM_CONTROL_JOURNAL_SCHEMA_VERSION) throw new SchemaVersionError(); + const base = { + schemaVersion: MODEM_CONTROL_JOURNAL_SCHEMA_VERSION, + operationId: requiredString(source, 'operationId'), + physicalModemId: requiredString(source, 'physicalModemId'), + generation: requiredNonNegativeInteger(source, 'generation'), + recordedAtMs: requiredNonNegativeInteger(source, 'recordedAtMs'), + descriptor: parseDescriptor(source.descriptor), + } as const; + if (member(source, 'phase', ['started', 'completed'] as const) === 'started') { + return { ...base, phase: 'started' }; + } + return { ...base, phase: 'completed', outcome: parseOutcome(source.outcome) }; +} + +/** Classify a decode failure into a metadata-only result (never raw content). */ +function classify(error: unknown): JournalDecodeFailure { + if (error instanceof SchemaVersionError) return { code: 'unsupported-schema-version' }; + if (error instanceof SchemaError) return { code: 'schema-mismatch', field: error.field }; + if (error instanceof SyntaxError) { + const offset = /position (\d+)/.exec(error.message)?.[1]; + return offset === undefined + ? { code: 'invalid-json' } + : { code: 'invalid-json', offset: Number(offset) }; + } + return { code: 'unreadable' }; +} + +/** + * Serialize one entry to a single line WITHOUT its terminator. + * + * Keys are emitted in a fixed order because the round-trip tests compare bytes, + * and byte comparison is the only assertion that catches a field a reader silently + * dropped (`JSON.parse` + a permissive validator will happily lose one and still + * report success — the same trap the srtla telemetry byte-parity suite exists for). + */ +export function encodeJournalEntry(entry: JournalEntry): string { + const descriptor = { + descriptorId: entry.descriptor.descriptorId, + provider: entry.descriptor.provider, + authority: entry.descriptor.authority, + mutationImpact: entry.descriptor.mutationImpact, + profiles: entry.descriptor.profiles, + firmware: entry.descriptor.firmware, + confidence: entry.descriptor.confidence, + }; + const base = { + schemaVersion: entry.schemaVersion, + phase: entry.phase, + operationId: entry.operationId, + physicalModemId: entry.physicalModemId, + generation: entry.generation, + recordedAtMs: entry.recordedAtMs, + descriptor, + }; + return JSON.stringify(entry.phase === 'started' ? base : { ...base, outcome: entry.outcome }); +} + +/** Decode one line. An empty/whitespace-only line is reported, never silently dropped. */ +export function decodeJournalEntry(line: string): JournalDecodeResult { + if (line.trim().length === 0) return { ok: false, failure: { code: 'empty' } }; + try { + return { ok: true, value: parseEntry(JSON.parse(line)) }; + } catch (error) { + return { ok: false, failure: classify(error) }; + } +} + +/** Decode an arbitrary JSON document with a caller-supplied validator. */ +export function decodeJournalDocument( + text: string, + validate: (raw: unknown) => T, +): JournalDecodeResult { + if (text.trim().length === 0) return { ok: false, failure: { code: 'empty' } }; + try { + return { ok: true, value: validate(JSON.parse(text)) }; + } catch (error) { + return { ok: false, failure: classify(error) }; + } +} + +/** The validator helpers the legacy reader reuses, so both shapes fail the same way. */ +export const journalSchema = { + record, + requiredString, + optionalString, + requiredNonNegativeInteger, + member, + schemaError(field: string): Error { + return new SchemaError(field); + }, + schemaVersionError(): Error { + return new SchemaVersionError(); + }, +} as const; diff --git a/control/src/journal/engine.ts b/control/src/journal/engine.ts new file mode 100644 index 0000000..6d6f028 --- /dev/null +++ b/control/src/journal/engine.ts @@ -0,0 +1,85 @@ +// The journal engine: the operation engine's journal hook, plus replay. +// +// `OperationExecution.journal` (todo 20's `OperationJournalHook`) is the seam the +// operation engine already calls at `started` and at `completed`, and a descriptor +// that declares `journal: { required: true }` is REFUSED outright when no hook is +// supplied. This class is the durable implementation of that seam: it flattens the +// event onto a journal entry and appends it, and it reads the same file back. +// +// THE ENGINE HOLDS NO PATH. It holds a `JournalStore`, and the store was handed a +// path by whoever composed it. That is the same injection shape todo 19 used for +// the ownership lock (`FlockResourceOwnershipOptions.lockPath` is REQUIRED and the +// adapter substitutes nothing) and the same reason: an embedding process owns its +// filesystem layout, and a library that guesses one is a library that writes to the +// wrong disk on a device it has never seen. +// +// THE CLOCK IS INJECTED TOO. `now` defaults to `Date.now`, but a test that wants +// deterministic timestamps supplies its own — the observation layer's rule that +// this package never stamps data with a time it did not come from applies here as +// well, and a replay assertion over timestamps must not be a race. + +import type { OperationJournalEvent, OperationJournalHook } from '../operations/contracts'; +import { type JournalEntry, journalDescriptorEvidence, journalOutcome } from './entry'; +import { type JournalRecovery, reconstructJournalRecovery } from './recovery'; +import type { JournalStore } from './store'; + +export interface JournalEngineOptions { + /** The store — and therefore the path — is supplied by the composition root. */ + readonly store: JournalStore; + readonly now?: () => number; +} + +export class JournalEngine { + readonly #store: JournalStore; + readonly #now: () => number; + + constructor(options: JournalEngineOptions) { + this.#store = options.store; + this.#now = options.now ?? (() => Date.now()); + } + + /** Where this engine journals to; useful in a recovery log line. */ + get path(): string { + return this.#store.path; + } + + /** + * The `OperationJournalHook` implementation. + * + * Generic per CALL rather than per instance, so one engine journals every + * descriptor in the process. A generic method satisfies the non-generic + * `OperationJournalHook` member by instantiation, which is what lets an + * `OperationExecution` take the engine itself as its `journal`. + */ + record(event: OperationJournalEvent): Promise { + return this.#store.append(entryFor(event, this.#now())); + } + + /** An explicitly typed hook, for a caller that prefers a narrow object. */ + hook(): OperationJournalHook { + return { record: (event) => this.record(event) }; + } + + /** Read the journal back and reconstruct what was in flight. */ + async recover(): Promise { + return reconstructJournalRecovery(await this.#store.read()); + } +} + +function entryFor(event: OperationJournalEvent, recordedAtMs: number): JournalEntry { + const base = { + schemaVersion: 1, + operationId: event.operationId, + physicalModemId: event.physicalModemId as string, + generation: event.generation as number, + recordedAtMs, + descriptor: journalDescriptorEvidence(event.descriptor), + } as const; + return event.phase === 'started' + ? { ...base, phase: 'started' } + : { ...base, phase: 'completed', outcome: journalOutcome(event.result) }; +} + +export function createJournalEngine(options: JournalEngineOptions): JournalEngine { + return new JournalEngine(options); +} diff --git a/control/src/journal/entry.ts b/control/src/journal/entry.ts new file mode 100644 index 0000000..24cf728 --- /dev/null +++ b/control/src/journal/entry.ts @@ -0,0 +1,131 @@ +// The transaction journal's ENTRY vocabulary. +// +// The journal exists to answer ONE question after an unclean restart: which +// mutations were in flight, and which of them ended in an outcome nobody can +// read off the device. `operations/operation-engine.ts` already closes a +// per-modem gate when a write classifies `unknown-outcome`, but that gate lives +// in a `Set` on the engine instance — a process death takes it with it. Writing +// the same two facts down is what makes the gate survive the process. +// +// WHY AN EVENT LOG AND NOT A LATEST-STATE SNAPSHOT. A started event and its +// completion are two facts separated by exactly the window a crash lands in, so +// the shape has to be able to hold the first without the second. A document that +// only ever carries "the current state" cannot distinguish "we never dispatched" +// from "we dispatched and the reply never came" unless it spends a state name on +// each — which is how CeraUI's own mutation journal does it (see +// `legacy-ceraui.ts`, which reads that shape). Both are legitimate; this one is +// append-only because appending is the only write that cannot lose a prior fact. +// +// WHAT IS DELIBERATELY NOT RECORDED: the operation's INPUT and the operation's +// RETURNED VALUE. An input is routinely a PIN, a PUK, or a USSD command carrying +// a voucher code, and a returned value is routinely a message body or a location +// fix — all of them classes `../redact.ts` masks everywhere else. The journal +// records THAT an operation ran and HOW it ended, never WHAT was sent or read. +// A caller that needs a rollback payload owns persisting it beside the journal +// under its own redaction decision. + +import type { + MutationImpact, + OperationConfidence, + OperationDescriptor, + OperationResult, +} from '../domain'; + +/** The current on-disk schema version for one journal line. */ +export const MODEM_CONTROL_JOURNAL_SCHEMA_VERSION = 1; + +/** The two phases the operation engine emits, mirrored one-to-one on disk. */ +export type JournalPhase = 'started' | 'completed'; + +/** + * How an operation ended, projected from `OperationResult` WITHOUT its value. + * + * `unknown-outcome` keeps the frozen domain reason union verbatim rather than + * widening to `string`, because those three reasons are the entire vocabulary a + * recovery pass branches on and a fourth spelling would silently read as an + * ordinary failure. + */ +export type JournalOutcome = + | { readonly status: 'applied' } + | { readonly status: 'refused'; readonly reason: string } + | { readonly status: 'failed'; readonly reason: string } + | { + readonly status: 'unknown-outcome'; + readonly reason: 'stale-generation' | 'write-reply-timed-out' | 'write-reply-dropped'; + }; + +/** + * The descriptor facts a recovery pass needs, flattened out of the descriptor. + * + * The descriptor itself is not persisted: it carries FUNCTIONS (`readback.matches`, + * the constraint predicates) that no serialization round-trips, so storing it would + * produce a document that reads back as a different object than it was written from. + * These are the fields that answer "what was being changed, by whom, on what + * evidence" — everything a human or a reconciler needs to judge a stranded write. + */ +export interface JournalDescriptorEvidence { + readonly descriptorId: string; + readonly provider: string; + readonly authority: 'provider' | 'controller' | 'hardware'; + readonly mutationImpact: MutationImpact; + readonly profiles: readonly string[]; + readonly firmware: readonly string[]; + readonly confidence: OperationConfidence; +} + +interface JournalEntryBase { + readonly schemaVersion: typeof MODEM_CONTROL_JOURNAL_SCHEMA_VERSION; + readonly operationId: string; + /** The serialized `PhysicalModemId`. Stored as text; branding is a compile-time fact. */ + readonly physicalModemId: string; + /** The serialized `DeviceGeneration` the operation was fenced to. */ + readonly generation: number; + readonly recordedAtMs: number; + readonly descriptor: JournalDescriptorEvidence; +} + +/** + * One journal line. + * + * The two members differ in SHAPE, not just in a label: a `started` entry has no + * `outcome` KEY at all. That is the same rule `observations/reading.ts` follows — + * a consumer cannot read an outcome off a phase that has none, so "in flight" can + * never be mistaken for "ended with an unset outcome". + */ +export type JournalEntry = + | (JournalEntryBase & { readonly phase: 'started' }) + | (JournalEntryBase & { readonly phase: 'completed'; readonly outcome: JournalOutcome }); + +/** Flatten a descriptor down to the serializable evidence the journal keeps. */ +export function journalDescriptorEvidence( + descriptor: OperationDescriptor, +): JournalDescriptorEvidence { + return { + descriptorId: descriptor.id, + provider: descriptor.provider, + authority: descriptor.authority, + mutationImpact: descriptor.mutationImpact, + profiles: [...descriptor.evidence.profiles], + firmware: [...descriptor.evidence.firmware], + confidence: descriptor.confidence, + }; +} + +/** Project a result onto its journalable outcome, dropping the value by design. */ +export function journalOutcome(result: OperationResult): JournalOutcome { + switch (result.status) { + case 'applied': + return { status: 'applied' }; + case 'refused': + return { status: 'refused', reason: result.reason }; + case 'failed': + return { status: 'failed', reason: result.reason }; + case 'unknown-outcome': + return { status: 'unknown-outcome', reason: result.reason }; + } +} + +/** True when an outcome leaves the device in a state nobody has read back. */ +export function outcomeRequiresReconciliation(outcome: JournalOutcome): boolean { + return outcome.status === 'unknown-outcome'; +} diff --git a/control/src/journal/index.ts b/control/src/journal/index.ts new file mode 100644 index 0000000..1fd45cb --- /dev/null +++ b/control/src/journal/index.ts @@ -0,0 +1,6 @@ +export * from './codec'; +export * from './engine'; +export * from './entry'; +export * from './legacy-ceraui'; +export * from './recovery'; +export * from './store'; diff --git a/control/src/journal/journal-corruption.test.ts b/control/src/journal/journal-corruption.test.ts new file mode 100644 index 0000000..a15d42a --- /dev/null +++ b/control/src/journal/journal-corruption.test.ts @@ -0,0 +1,195 @@ +import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; +import { appendFile, readFile, writeFile } from 'node:fs/promises'; + +import { + appliedResult, + completedEvent, + createJournalHarness, + type JournalHarness, + startedEvent, + unknownOutcomeResult, +} from '../../test-support/journal-fixture'; +import { decodeJournalEntry } from './codec'; +import { assertJournalIntact, JournalRecoveryError } from './recovery'; +import { decodeJournalText } from './store'; + +let harness: JournalHarness; + +beforeEach(async () => { + harness = await createJournalHarness(); +}); + +afterEach(async () => { + await harness.dispose(); +}); + +/** Write three good operations, then damage only the LAST line on disk. */ +async function seedThenCorruptTrailingLine(trailing: string): Promise { + const writer = harness.restart(); + await writer.record(startedEvent('op-1')); + await writer.record(completedEvent('op-1', appliedResult())); + await writer.record(startedEvent('op-2')); + await writer.record(completedEvent('op-2', unknownOutcomeResult('write-reply-timed-out'))); + await writer.record(startedEvent('op-3')); + await appendFile(harness.path, trailing); +} + +describe('a corrupt TRAILING entry never discards the earlier valid ones', () => { + test('truncated JSON on the last line is reported, and lines 1-5 survive', async () => { + await seedThenCorruptTrailingLine('{"schemaVersion":1,"phase":"star\n'); + + const recovery = await harness.restart().recover(); + + // The five valid records are all still there. + expect(recovery.records.map((record) => record.operationId)).toEqual(['op-1', 'op-2', 'op-3']); + expect(recovery.pending.map((record) => record.operationId)).toEqual(['op-3']); + expect(recovery.unknownOutcome.map((record) => record.operationId)).toEqual(['op-2']); + + // And the damage is reported as a TYPED record naming the trailing line. + expect(recovery.damage).toHaveLength(1); + expect(recovery.damage[0]?.location).toEqual({ kind: 'line', line: 6, trailing: true }); + expect(recovery.damage[0]?.failure.code).toBe('invalid-json'); + expect(recovery.damage[0]?.bytes).toBeGreaterThan(0); + }); + + test('the typed recovery error is raised on demand and carries the damage', async () => { + await seedThenCorruptTrailingLine('{"schemaVersion":1,"phase":"star\n'); + const recovery = await harness.restart().recover(); + + expect(() => assertJournalIntact(recovery)).toThrow(JournalRecoveryError); + try { + assertJournalIntact(recovery); + } catch (error) { + expect(error).toBeInstanceOf(JournalRecoveryError); + expect((error as JournalRecoveryError).damage).toEqual(recovery.damage); + } + }); + + test('a torn line with NO terminator is still reported, not swallowed', async () => { + await seedThenCorruptTrailingLine('{"schemaVersion":1,"operationId":"op-4"'); + + const recovery = await harness.restart().recover(); + expect(recovery.records).toHaveLength(3); + expect(recovery.damage).toHaveLength(1); + expect(recovery.damage[0]?.location).toEqual({ kind: 'line', line: 6, trailing: true }); + }); + + test('a well-formed line carrying a future schema version is typed as such', async () => { + await seedThenCorruptTrailingLine(`${JSON.stringify({ schemaVersion: 99 })}\n`); + + const recovery = await harness.restart().recover(); + expect(recovery.records).toHaveLength(3); + expect(recovery.damage[0]?.failure).toEqual({ code: 'unsupported-schema-version' }); + }); + + test('a valid JSON object missing a required field names the FIELD, not the bytes', async () => { + await seedThenCorruptTrailingLine( + `${JSON.stringify({ schemaVersion: 1, phase: 'started', operationId: 'op-4' })}\n`, + ); + + const recovery = await harness.restart().recover(); + expect(recovery.records).toHaveLength(3); + expect(recovery.damage[0]?.failure).toEqual({ + code: 'schema-mismatch', + field: 'physicalModemId', + }); + // Metadata only: the classification must never carry file content. + expect(JSON.stringify(recovery.damage[0]?.failure)).not.toContain('op-4'); + }); +}); + +describe('damage in the MIDDLE does not truncate the tail either', () => { + test('lines after a corrupt line are still decoded', async () => { + const first = harness.restart(); + await first.record(startedEvent('op-1')); + await first.record(startedEvent('op-2')); + const text = await readFile(harness.path, 'utf8'); + const lines = text.split('\n').filter((line) => line.length > 0); + await writeFile(harness.path, `${lines[0]}\n{ not json }\n${lines[1]}\n`); + + const recovery = await harness.restart().recover(); + expect(recovery.records.map((record) => record.operationId)).toEqual(['op-1', 'op-2']); + expect(recovery.damage).toHaveLength(1); + expect(recovery.damage[0]?.location).toEqual({ kind: 'line', line: 2, trailing: false }); + }); + + test('two damaged lines are both reported, and neither hides the other', async () => { + await writeFile(harness.path, '{ bad one }\n{"schemaVersion":2}\n'); + const recovery = await harness.restart().recover(); + expect(recovery.records).toEqual([]); + expect(recovery.damage.map((record) => record.failure.code)).toEqual([ + 'invalid-json', + 'unsupported-schema-version', + ]); + }); +}); + +describe('a torn trailing line is CLOSED before the next append', () => { + test('appending after a torn write does not corrupt the new entry too', async () => { + const writer = harness.restart(); + await writer.record(startedEvent('op-1')); + // Simulate a process killed mid-write: a partial line with no terminator. + await appendFile(harness.path, '{"schemaVersion":1,"phase":"comp'); + + // A fresh engine (new process) appends the next operation. + await harness.restart().record(startedEvent('op-2')); + + const recovery = await harness.restart().recover(); + // The torn record is still damaged — it really was lost — but the NEW one + // decoded cleanly instead of being glued onto the garbage. + expect(recovery.records.map((record) => record.operationId)).toEqual(['op-1', 'op-2']); + expect(recovery.damage).toHaveLength(1); + expect(recovery.damage[0]?.location).toEqual({ kind: 'line', line: 2, trailing: false }); + }); + + test('the damaged bytes are PRESERVED on disk, never rewritten away', async () => { + const writer = harness.restart(); + await writer.record(startedEvent('op-1')); + await appendFile(harness.path, '{"torn":true'); + + await harness.restart().recover(); + await harness.restart().record(startedEvent('op-2')); + + expect(await readFile(harness.path, 'utf8')).toContain('{"torn":true'); + }); +}); + +describe('non-vacuity: the corruption detectors really fire', () => { + test('a clean journal produces no damage at all', async () => { + const writer = harness.restart(); + await writer.record(startedEvent('op-1')); + await writer.record(completedEvent('op-1', appliedResult())); + expect((await harness.restart().recover()).damage).toEqual([]); + }); + + test('decodeJournalText keeps survivors on either side of every failure', () => { + const line = (id: string): string => + JSON.stringify({ + schemaVersion: 1, + phase: 'started', + operationId: id, + physicalModemId: 'serial:x', + generation: 1, + recordedAtMs: 1, + descriptor: { + descriptorId: 'd', + provider: 'p', + authority: 'provider', + mutationImpact: 'write', + profiles: [], + firmware: [], + confidence: 'high', + }, + }); + const result = decodeJournalText(`${line('a')}\nbroken\n${line('b')}\nalso broken\n`); + expect(result.entries.map((record) => record.entry.operationId)).toEqual(['a', 'b']); + expect(result.damage.map((record) => record.location)).toEqual([ + { kind: 'line', line: 2, trailing: false }, + { kind: 'line', line: 4, trailing: true }, + ]); + }); + + test('an empty line decodes to a typed empty failure rather than a silent skip', () => { + expect(decodeJournalEntry(' ')).toEqual({ ok: false, failure: { code: 'empty' } }); + }); +}); diff --git a/control/src/journal/journal-path-injection.test.ts b/control/src/journal/journal-path-injection.test.ts new file mode 100644 index 0000000..8604cb4 --- /dev/null +++ b/control/src/journal/journal-path-injection.test.ts @@ -0,0 +1,117 @@ +// The gate that keeps the journal path INJECTED. +// +// The whole point of this module is that `/data` policy belongs to the embedding +// process. A constant added "for convenience" is exactly how that ownership leaks +// back into the library, and a code review is a poor detector for one added +// months later. So the rule is a test over the shipped source. +// +// COMMENTS ARE STRIPPED FIRST, and that is load-bearing rather than tidy: the +// documentation in `legacy-ceraui.ts` and in this file has to be able to NAME the +// CeraUI convention it reads in order to explain itself. Prose naming a path stays +// legal; executable code producing one does not. (Same shape as the apt-worker +// count-literal gate and CeraUI's link-id authority gate.) + +import { describe, expect, test } from 'bun:test'; +import { readdir, readFile } from 'node:fs/promises'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const JOURNAL_DIR = dirname(fileURLToPath(import.meta.url)); + +/** Remove block and line comments plus every string literal's interior. */ +function executableSource(source: string): string { + return source + .replace(/\/\*[\s\S]*?\*\//g, ' ') + .replace(/(^|[^:])\/\/[^\n]*/g, '$1 ') + .split('\n') + .filter((line) => !line.trimStart().startsWith('*')) + .join('\n'); +} + +/** + * An absolute POSIX path literal inside quotes or a template. + * + * `${}` is inside the character class on purpose: a template literal is the + * obvious way to smuggle a hardcoded root back in (`` `/data/${name}` ``), and a + * class that stopped at the interpolation would miss exactly that form. + */ +const ABSOLUTE_PATH_LITERAL = /(['"`])\/[A-Za-z][\w./${}-]*\1/g; +/** Any mention of the device data partition or CeraUI's journal directory name. */ +const CERAUI_PATH_TOKENS = /\/data\b|ceralive\/modem-mutations|CERALIVE_MODEM_MUTATION_DIR/; + +async function shippedSources(): Promise { + const names = (await readdir(JOURNAL_DIR)).filter( + (name) => name.endsWith('.ts') && !name.endsWith('.test.ts'), + ); + return Promise.all( + names.map(async (name) => [name, await readFile(join(JOURNAL_DIR, name), 'utf8')] as const), + ); +} + +describe('no journal source hardcodes a path', () => { + test('the gate actually has files to scan', async () => { + const sources = await shippedSources(); + expect(sources.length).toBeGreaterThanOrEqual(5); + expect(sources.map(([name]) => name)).toContain('store.ts'); + expect(sources.map(([name]) => name)).toContain('legacy-ceraui.ts'); + }); + + test('no executable line contains an absolute path literal', async () => { + const violations: string[] = []; + for (const [name, source] of await shippedSources()) { + for (const match of executableSource(source).matchAll(ABSOLUTE_PATH_LITERAL)) { + violations.push(`${name}: ${match[0]}`); + } + } + expect(violations).toEqual([]); + }); + + test('no executable line names /data or a CeraUI-specific journal location', async () => { + const violations: string[] = []; + for (const [name, source] of await shippedSources()) { + if (CERAUI_PATH_TOKENS.test(executableSource(source))) violations.push(name); + } + expect(violations).toEqual([]); + }); + + test('the CeraUI convention IS documented in prose, so the strip is not vacuous', async () => { + const sources = await shippedSources(); + const legacy = sources.find(([name]) => name === 'legacy-ceraui.ts')?.[1] ?? ''; + expect(legacy.length).toBeGreaterThan(0); + // The comments explain the shape they read; only the executable strip is empty. + expect(legacy).toContain('per-modem'); + expect(executableSource(legacy)).not.toContain('per-modem'); + }); +}); + +describe('the gate is non-vacuous', () => { + // The interpolating sample is ASSEMBLED at runtime rather than written as a + // literal: spelling it inline is itself the lint violation the gate exists to + // discourage, so the fabrication must not appear verbatim in this file. + const INTERPOLATION = `\${name}`; + const samples: string[] = [ + "const dir = '/data/ceralive/modem-mutations';", + 'const dir = "/var/lib/ceralive/journal";', + `const dir = \`/data/${INTERPOLATION}\`;`, + ]; + + test.each(samples)('a fabricated path literal is caught: %s', (sample) => { + expect([...executableSource(sample).matchAll(ABSOLUTE_PATH_LITERAL)]).not.toEqual([]); + }); + + test('a fabricated /data reference is caught by the token scan', () => { + expect(CERAUI_PATH_TOKENS.test(executableSource("const d = '/data/x';"))).toBe(true); + }); + + test('the same text inside a comment is NOT a violation', () => { + const commented = '// CeraUI keeps this under /data/ceralive/modem-mutations.\nconst x = 1;'; + expect(CERAUI_PATH_TOKENS.test(executableSource(commented))).toBe(false); + expect([...executableSource(commented).matchAll(ABSOLUTE_PATH_LITERAL)]).toEqual([]); + }); + + test('a relative path literal is legal — only absolute ones are refused', () => { + expect([...executableSource("join(dir, 'slot.json')").matchAll(ABSOLUTE_PATH_LITERAL)]).toEqual( + [], + ); + }); +}); diff --git a/control/src/journal/journal-replay.test.ts b/control/src/journal/journal-replay.test.ts new file mode 100644 index 0000000..6a604c2 --- /dev/null +++ b/control/src/journal/journal-replay.test.ts @@ -0,0 +1,225 @@ +import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; +import { readFile, stat } from 'node:fs/promises'; + +import { + appliedResult, + completedEvent, + createJournalHarness, + failedResult, + JOURNAL_MODEM_A, + JOURNAL_MODEM_B, + type JournalHarness, + startedEvent, + unknownOutcomeResult, +} from '../../test-support/journal-fixture'; +import type { OperationExecution } from '../operations/contracts'; +import { assertJournalIntact, JournalRecoveryError } from './recovery'; +import { createFileJournalStore, JournalPathError } from './store'; + +let harness: JournalHarness; + +beforeEach(async () => { + harness = await createJournalHarness(); +}); + +afterEach(async () => { + await harness.dispose(); +}); + +describe('the journal path is injected and has no default', () => { + test('an empty path is refused rather than substituted', () => { + expect(() => createFileJournalStore({ path: '' })).toThrow(JournalPathError); + expect(() => createFileJournalStore({ path: ' ' })).toThrow(JournalPathError); + }); + + test('the store echoes back exactly the path it was handed', () => { + expect(createFileJournalStore({ path: harness.path }).path).toBe(harness.path); + }); + + test('the engine journals to the injected path and nowhere else', async () => { + const engine = harness.restart(); + await engine.record(startedEvent('op-1')); + expect(engine.path).toBe(harness.path); + await expect(stat(harness.path)).resolves.toBeDefined(); + }); + + test('the journal file is created 0600 regardless of umask', async () => { + await harness.restart().record(startedEvent('op-1')); + expect((await stat(harness.path)).mode & 0o777).toBe(0o600); + }); +}); + +describe('the engine satisfies the operation engine journal hook', () => { + test('an engine is assignable as an OperationExecution journal hook', async () => { + const engine = harness.restart(); + const execution: Pick, 'journal'> = { journal: engine }; + await execution.journal?.record(startedEvent('op-hook')); + const narrow = engine.hook(); + await narrow.record(completedEvent('op-hook', appliedResult())); + + const recovery = await engine.recover(); + expect(recovery.records).toHaveLength(1); + expect(recovery.records[0]?.disposition).toBe('resolved'); + }); +}); + +describe('replay after an unclean restart', () => { + test('N entries survive dropping the engine and enumerate for reconciliation', async () => { + const writer = harness.restart(); + // Five operations: one clean, one stranded mid-flight, two unknown outcomes + // on two different modems, and one definite failure. + await writer.record(startedEvent('op-applied')); + await writer.record(completedEvent('op-applied', appliedResult())); + await writer.record(startedEvent('op-stranded')); + await writer.record(startedEvent('op-timeout')); + await writer.record( + completedEvent('op-timeout', unknownOutcomeResult('write-reply-timed-out')), + ); + await writer.record(startedEvent('op-stale', { physicalModemId: JOURNAL_MODEM_B })); + await writer.record( + completedEvent('op-stale', unknownOutcomeResult('stale-generation'), { + physicalModemId: JOURNAL_MODEM_B, + }), + ); + await writer.record(startedEvent('op-failed')); + await writer.record(completedEvent('op-failed', failedResult('device-refused'))); + + // The "crash": the engine instance is dropped with no shutdown of any kind. + // A brand-new engine reconstructs purely from what reached the disk. + const recovery = await harness.restart().recover(); + + expect(recovery.damage).toEqual([]); + expect(recovery.records.map((record) => record.operationId)).toEqual([ + 'op-applied', + 'op-stranded', + 'op-timeout', + 'op-stale', + 'op-failed', + ]); + + expect(recovery.pending.map((record) => record.operationId)).toEqual(['op-stranded']); + expect(recovery.unknownOutcome.map((record) => record.operationId)).toEqual([ + 'op-timeout', + 'op-stale', + ]); + expect(recovery.blocked).toEqual([]); + expect(recovery.reconciliationRequired).toEqual( + [JOURNAL_MODEM_A as string, JOURNAL_MODEM_B as string].sort(), + ); + }); + + test('a definite failure is resolved, never folded into reconciliation', async () => { + const writer = harness.restart(); + await writer.record(startedEvent('op-failed')); + await writer.record(completedEvent('op-failed', failedResult('device-refused'))); + + const recovery = await harness.restart().recover(); + expect(recovery.records[0]?.disposition).toBe('resolved'); + expect(recovery.records[0]?.outcome).toEqual({ status: 'failed', reason: 'device-refused' }); + expect(recovery.reconciliationRequired).toEqual([]); + }); + + test('the unknown-outcome reason survives the round trip verbatim', async () => { + const writer = harness.restart(); + await writer.record(startedEvent('op-dropped')); + await writer.record(completedEvent('op-dropped', unknownOutcomeResult('write-reply-dropped'))); + + const recovery = await harness.restart().recover(); + expect(recovery.records[0]?.outcome).toEqual({ + status: 'unknown-outcome', + reason: 'write-reply-dropped', + }); + }); + + test('a re-run of the same operation counts attempts instead of hiding one', async () => { + const writer = harness.restart(); + await writer.record(startedEvent('op-retried')); + await writer.record(startedEvent('op-retried')); + await writer.record(completedEvent('op-retried', appliedResult())); + + const recovery = await harness.restart().recover(); + expect(recovery.records).toHaveLength(1); + expect(recovery.records[0]?.attempts).toBe(2); + expect(recovery.records[0]?.disposition).toBe('resolved'); + }); + + test('descriptor evidence is what a reconciler reads back', async () => { + await harness.restart().record(startedEvent('op-evidence')); + const recovery = await harness.restart().recover(); + expect(recovery.records[0]?.descriptor).toEqual({ + descriptorId: 'set-usb-mode', + provider: 'fixture-provider', + authority: 'provider', + mutationImpact: 'write', + profiles: ['fixture'], + firmware: ['1.0'], + confidence: 'high', + }); + }); + + test('an empty journal recovers as empty, not as damaged', async () => { + const recovery = await harness.restart().recover(); + expect(recovery.records).toEqual([]); + expect(recovery.damage).toEqual([]); + expect(() => assertJournalIntact(recovery)).not.toThrow(); + }); + + test('neither the input nor the returned value is ever written to disk', async () => { + const writer = harness.restart(); + await writer.record(startedEvent('op-secret', { input: '8462-PUK-SECRET' })); + await writer.record(completedEvent('op-secret', appliedResult('9911-READBACK-SECRET'))); + + const text = await readFile(harness.path, 'utf8'); + expect(text).not.toContain('8462-PUK-SECRET'); + expect(text).not.toContain('9911-READBACK-SECRET'); + expect(text).not.toContain('input'); + expect(text).not.toContain('value'); + }); +}); + +describe('appends are additive and ordered', () => { + test('a second engine over the same path appends rather than replaces', async () => { + await harness.restart().record(startedEvent('op-first')); + await harness.restart().record(startedEvent('op-second')); + + const recovery = await harness.restart().recover(); + expect(recovery.records.map((record) => record.operationId)).toEqual(['op-first', 'op-second']); + }); + + test('concurrent appends serialize into whole lines', async () => { + const engine = harness.restart(); + await Promise.all( + Array.from({ length: 12 }, (_unused, index) => engine.record(startedEvent(`op-${index}`))), + ); + + const recovery = await engine.recover(); + expect(recovery.damage).toEqual([]); + expect(recovery.records).toHaveLength(12); + }); + + test('every written line is newline-terminated', async () => { + const engine = harness.restart(); + await engine.record(startedEvent('op-1')); + await engine.record(completedEvent('op-1', appliedResult())); + const text = await readFile(harness.path, 'utf8'); + expect(text.endsWith('\n')).toBe(true); + expect(text.split('\n').filter((line) => line.length > 0)).toHaveLength(2); + }); +}); + +describe('JournalRecoveryError', () => { + test('assertJournalIntact does not throw on a clean journal', async () => { + await harness.restart().record(startedEvent('op-1')); + const recovery = await harness.restart().recover(); + expect(() => assertJournalIntact(recovery)).not.toThrow(); + }); + + test('the error carries the damage rather than only a message', () => { + const damage = [ + { location: { kind: 'file' }, bytes: 0, failure: { code: 'unreadable' } }, + ] as const; + const error = new JournalRecoveryError(damage); + expect(error.damage).toEqual(damage); + expect(error.name).toBe('JournalRecoveryError'); + }); +}); diff --git a/control/src/journal/legacy-ceraui.test.ts b/control/src/journal/legacy-ceraui.test.ts new file mode 100644 index 0000000..f2e136c --- /dev/null +++ b/control/src/journal/legacy-ceraui.test.ts @@ -0,0 +1,270 @@ +import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { physicalModemId } from '../domain'; +import { + decodeLegacyCeraUiEntry, + LEGACY_CERAUI_MUTATION_STATES, + legacyMutationSlotName, + legacyOperationRecord, + readLegacyCeraUiJournal, +} from './legacy-ceraui'; + +let dir: string; + +beforeEach(async () => { + dir = await mkdtemp(join(tmpdir(), 'ceraui-journal-')); +}); + +afterEach(async () => { + await rm(dir, { recursive: true, force: true }); +}); + +const STABLE_KEY = 'usb-pci-0000:00:14.0-usb-0:2:1.0'; + +function legacyDocument(overrides: Record = {}): string { + // Byte-faithful to what CeraUI writes: one JSON object plus a trailing newline. + return `${JSON.stringify({ + version: 1, + stableKey: STABLE_KEY, + kind: 'usb-mode', + state: 'executing', + attemptId: 'attempt-abc', + startedAt: 1_700_000_000_000, + updatedAt: 1_700_000_000_500, + preState: { mode: 'rndis' }, + history: [{ state: 'armed', at: 1_700_000_000_000 }], + ...overrides, + })}\n`; +} + +async function writeSlot(key: string, body: string): Promise { + const name = `${legacyMutationSlotName(key)}.json`; + await writeFile(join(dir, name), body); + return name; +} + +describe('the legacy slot filename convention is mirrored, not guessed', () => { + test('the slot name is lowercase-hex sha256 of the stable key', () => { + const name = legacyMutationSlotName(STABLE_KEY); + expect(name).toMatch(/^[0-9a-f]{64}$/); + expect(name).toBe(legacyMutationSlotName(STABLE_KEY)); + expect(name).not.toBe(legacyMutationSlotName(`${STABLE_KEY}x`)); + }); + + test('the stable key never appears in the filename in plaintext', () => { + expect(legacyMutationSlotName(STABLE_KEY)).not.toContain('usb'); + }); +}); + +describe('an existing CeraUI journal file is still readable', () => { + test('a real-shaped slot decodes with every field preserved', async () => { + const decoded = decodeLegacyCeraUiEntry(legacyDocument()); + expect(decoded.ok).toBe(true); + if (!decoded.ok) return; + expect(decoded.value).toEqual({ + version: 1, + stableKey: STABLE_KEY, + kind: 'usb-mode', + state: 'executing', + attemptId: 'attempt-abc', + startedAt: 1_700_000_000_000, + updatedAt: 1_700_000_000_500, + preState: { mode: 'rndis' }, + history: [{ state: 'armed', at: 1_700_000_000_000 }], + }); + }); + + test('the opaque preState rollback payload survives the read verbatim', async () => { + await writeSlot( + STABLE_KEY, + legacyDocument({ preState: { mode: 'rndis', nested: { a: [1] } } }), + ); + const read = await readLegacyCeraUiJournal({ dir }); + expect(read.entries[0]?.preState).toEqual({ mode: 'rndis', nested: { a: [1] } }); + }); + + test('the trailing newline CeraUI writes is accepted', async () => { + expect(decodeLegacyCeraUiEntry(legacyDocument()).ok).toBe(true); + expect(decodeLegacyCeraUiEntry(legacyDocument().trimEnd()).ok).toBe(true); + }); + + test('optional detail and acknowledgedMode round-trip when present', () => { + const decoded = decodeLegacyCeraUiEntry( + legacyDocument({ + state: 'acknowledged', + detail: 'rolled back', + acknowledgedMode: 'force-rebaseline', + }), + ); + expect(decoded.ok).toBe(true); + if (!decoded.ok) return; + expect(decoded.value.detail).toBe('rolled back'); + expect(decoded.value.acknowledgedMode).toBe('force-rebaseline'); + }); + + test('an unmentioned optional key is ABSENT, not undefined-valued', () => { + const decoded = decodeLegacyCeraUiEntry(legacyDocument()); + expect(decoded.ok).toBe(true); + if (!decoded.ok) return; + expect('detail' in decoded.value).toBe(false); + expect('acknowledgedMode' in decoded.value).toBe(false); + }); +}); + +describe('the legacy vocabulary maps onto the shared recovery model', () => { + test('every state has a disposition, so no state can fall through', () => { + for (const state of LEGACY_CERAUI_MUTATION_STATES) { + const decoded = decodeLegacyCeraUiEntry(legacyDocument({ state })); + expect(decoded.ok).toBe(true); + if (!decoded.ok) continue; + expect(legacyOperationRecord(decoded.value).disposition).toBeDefined(); + } + }); + + test('armed is pending but executing is unknown-outcome', () => { + const armed = decodeLegacyCeraUiEntry(legacyDocument({ state: 'armed' })); + const executing = decodeLegacyCeraUiEntry(legacyDocument({ state: 'executing' })); + expect(armed.ok && legacyOperationRecord(armed.value).disposition).toBe('pending'); + expect(executing.ok && legacyOperationRecord(executing.value).disposition).toBe( + 'unknown-outcome', + ); + }); + + test('an unknown-outcome legacy record claims NO reason it cannot know', () => { + const executing = decodeLegacyCeraUiEntry(legacyDocument({ state: 'executing' })); + expect(executing.ok).toBe(true); + if (!executing.ok) return; + const record = legacyOperationRecord(executing.value); + expect(record.disposition).toBe('unknown-outcome'); + expect('outcome' in record).toBe(false); + }); + + test('operator-blocked states are blocked, never resolved and never unknown', () => { + for (const state of [ + 'failed', + 'device-absent-quarantine', + 'decommissioned', + 'recommission-pending', + ] as const) { + const decoded = decodeLegacyCeraUiEntry(legacyDocument({ state })); + expect(decoded.ok).toBe(true); + if (!decoded.ok) continue; + expect(legacyOperationRecord(decoded.value).disposition).toBe('blocked'); + } + }); + + test('a legacy record carries CeraUI stableKey and is labelled legacy-ceraui', () => { + const decoded = decodeLegacyCeraUiEntry(legacyDocument()); + expect(decoded.ok).toBe(true); + if (!decoded.ok) return; + const record = legacyOperationRecord(decoded.value); + expect(record.origin).toBe('legacy-ceraui'); + expect(record.physicalModemId).toBe(STABLE_KEY); + // The identity is deliberately NOT laundered into a branded PhysicalModemId: + // the constructor refuses it, which is exactly why the record keeps text. + expect(() => physicalModemId(record.physicalModemId)).toThrow(); + }); + + test('the descriptor evidence states what the file cannot supply', () => { + const decoded = decodeLegacyCeraUiEntry(legacyDocument({ kind: 'sim-unlock' })); + expect(decoded.ok).toBe(true); + if (!decoded.ok) return; + expect(legacyOperationRecord(decoded.value).descriptor).toEqual({ + descriptorId: 'sim-unlock', + provider: 'ceraui-legacy', + authority: 'controller', + mutationImpact: 'write', + profiles: [], + firmware: [], + confidence: 'unknown', + }); + }); + + test('an unknown future mutation kind is ACCEPTED, not refused', () => { + // CeraUI spreads its capability-module kinds into the runtime enum, so a + // frozen copy here would reject a valid file the day a module is added. + const decoded = decodeLegacyCeraUiEntry(legacyDocument({ kind: 'some-future-module' })); + expect(decoded.ok).toBe(true); + if (!decoded.ok) return; + expect(legacyOperationRecord(decoded.value).descriptor.descriptorId).toBe('some-future-module'); + }); +}); + +describe('reading a whole legacy directory', () => { + test('pending and unknown-outcome slots enumerate for reconciliation', async () => { + await writeSlot('modem-armed', legacyDocument({ stableKey: 'modem-armed', state: 'armed' })); + await writeSlot( + 'modem-executing', + legacyDocument({ stableKey: 'modem-executing', state: 'executing' }), + ); + await writeSlot('modem-done', legacyDocument({ stableKey: 'modem-done', state: 'completed' })); + await writeSlot('modem-failed', legacyDocument({ stableKey: 'modem-failed', state: 'failed' })); + + const { recovery } = await readLegacyCeraUiJournal({ dir }); + expect(recovery.records).toHaveLength(4); + expect(recovery.pending.map((record) => record.physicalModemId)).toEqual(['modem-armed']); + expect(recovery.unknownOutcome.map((record) => record.physicalModemId)).toEqual([ + 'modem-executing', + ]); + expect(recovery.blocked.map((record) => record.physicalModemId)).toEqual(['modem-failed']); + expect(recovery.reconciliationRequired).toEqual(['modem-armed', 'modem-executing']); + }); + + test('a blocked slot is NOT folded into reconciliation', async () => { + await writeSlot('modem-failed', legacyDocument({ stableKey: 'modem-failed', state: 'failed' })); + const { recovery } = await readLegacyCeraUiJournal({ dir }); + expect(recovery.reconciliationRequired).toEqual([]); + }); + + test('a corrupt slot is reported and the readable ones still come back', async () => { + await writeSlot('modem-good', legacyDocument({ stableKey: 'modem-good' })); + const badName = await writeSlot('modem-bad', '{not json'); + + const { recovery, entries } = await readLegacyCeraUiJournal({ dir }); + expect(entries.map((entry) => entry.stableKey)).toEqual(['modem-good']); + expect(recovery.damage).toHaveLength(1); + expect(recovery.damage[0]?.location).toEqual({ kind: 'slot', slot: badName }); + expect(recovery.damage[0]?.failure.code).toBe('invalid-json'); + }); + + test('a corrupt slot is LEFT IN PLACE, exactly as CeraUI leaves it', async () => { + const badName = await writeSlot('modem-bad', '{not json'); + await readLegacyCeraUiJournal({ dir }); + expect(await readFile(join(dir, badName), 'utf8')).toBe('{not json'); + }); + + test('non-json files in the directory are ignored', async () => { + await writeFile(join(dir, 'README.txt'), 'not a slot'); + await writeSlot('modem-good', legacyDocument({ stableKey: 'modem-good' })); + const { recovery } = await readLegacyCeraUiJournal({ dir }); + expect(recovery.records).toHaveLength(1); + expect(recovery.damage).toEqual([]); + }); + + test('an absent directory reads as empty, not as damaged', async () => { + const { recovery, entries } = await readLegacyCeraUiJournal({ dir: join(dir, 'nope') }); + expect(entries).toEqual([]); + expect(recovery.records).toEqual([]); + expect(recovery.damage).toEqual([]); + }); + + test('a wrong-version document is typed as a version mismatch', async () => { + await writeSlot('modem-v2', legacyDocument({ version: 2 })); + const { recovery } = await readLegacyCeraUiJournal({ dir }); + expect(recovery.damage[0]?.failure).toEqual({ code: 'unsupported-schema-version' }); + }); + + test('a history array over the cap is refused by field name', async () => { + await writeSlot( + 'modem-history', + legacyDocument({ + history: Array.from({ length: 33 }, () => ({ state: 'armed', at: 1 })), + }), + ); + const { recovery } = await readLegacyCeraUiJournal({ dir }); + expect(recovery.damage[0]?.failure).toEqual({ code: 'schema-mismatch', field: 'history' }); + }); +}); diff --git a/control/src/journal/legacy-ceraui.ts b/control/src/journal/legacy-ceraui.ts new file mode 100644 index 0000000..8703110 --- /dev/null +++ b/control/src/journal/legacy-ceraui.ts @@ -0,0 +1,292 @@ +// COMPATIBILITY READ PATH for the mutation journal CeraUI already writes. +// +// WHY THIS EXISTS AT ALL. CeraUI has kept a durable modem-mutation journal since +// long before this package had one, and devices in the field have those files on +// disk right now. The two shapes are genuinely different — this package's journal +// is an append-only EVENT LOG in one file, CeraUI's is a directory of per-modem +// LATEST-STATE SNAPSHOTS, one JSON document per modem, rewritten whole on every +// transition. Neither can be re-labelled into the other, so the bridge is a READER: +// it decodes CeraUI's shape into the SAME `JournalOperationRecord` model +// `recovery.ts` produces, so a consumer enumerates pending and unknown-outcome work +// across both without CeraUI having to change its file format first. +// +// NOTHING HERE WRITES. This module reads and decodes; it never rewrites, repairs, +// migrates in place, or deletes a slot. CeraUI's own reader leaves an unreadable +// slot on disk deliberately, and a second reader that "cleaned up" behind it would +// destroy the evidence CeraUI kept on purpose. +// +// THE DIRECTORY IS INJECTED, exactly like the native store's path — this module +// hardcodes no location, and the path-injection gate covers it. +// +// THE SLOT FILENAME IS A HASH, and `legacyMutationSlotName` mirrors that rule so a +// consumer can address ONE modem's slot without scanning. It is a RULE-D MIRROR of +// CeraUI's helper, never a shared import — the same relationship the support-claim +// ladder and the redaction key sets already have with their CeraUI twins. +// +// TWO MAPPING DECISIONS CARRY WEIGHT: +// +// * `armed` maps to `pending` and `executing` maps to `unknown-outcome`. They are +// not the same fact. `armed` says the pre-state was captured and the write had +// not been dispatched, so the device is untouched. `executing` says the write +// WAS dispatched and no terminal state was ever recorded — which is precisely +// what this package calls an unknown outcome. Collapsing them would either +// invent certainty about a dispatched write or manufacture doubt about one that +// never left. +// +// * `kind` is validated as a NON-EMPTY STRING, not against a frozen enum. +// CeraUI's runtime enum spreads its capability-module mutation kinds into the +// list, so the vocabulary grows on CeraUI's release cycle. Freezing a copy here +// would make this reader reject a perfectly valid file the day CeraUI adds a +// capability module — a compatibility reader that fails closed on new-but-valid +// input is worse than no reader at all. + +import { createHash } from 'node:crypto'; +import { readdir, readFile } from 'node:fs/promises'; +import { join } from 'node:path'; + +import { decodeJournalDocument, type JournalDecodeResult, journalSchema } from './codec'; +import type { JournalDescriptorEvidence } from './entry'; +import { + type JournalOperationRecord, + type JournalRecovery, + type JournalRecoveryDisposition, + summarizeJournalRecords, +} from './recovery'; +import type { JournalDamageRecord } from './store'; + +/** The `version` literal CeraUI's schema pins. */ +export const LEGACY_CERAUI_JOURNAL_VERSION = 1; + +/** CeraUI's cap on retained history entries per slot. */ +export const LEGACY_CERAUI_HISTORY_CAP = 32; + +export const LEGACY_CERAUI_MUTATION_STATES = [ + 'armed', + 'executing', + 'completed', + 'failed', + 'acknowledged', + 'device-absent-quarantine', + 'decommissioned', + 'recommission-pending', +] as const; +export type LegacyCeraUiMutationState = (typeof LEGACY_CERAUI_MUTATION_STATES)[number]; + +export const LEGACY_CERAUI_ACK_MODES = ['verified-rollback', 'force-rebaseline'] as const; +export type LegacyCeraUiAckMode = (typeof LEGACY_CERAUI_ACK_MODES)[number]; + +export interface LegacyCeraUiHistoryEntry { + readonly state: LegacyCeraUiMutationState; + readonly at: number; + readonly detail?: string; +} + +/** One CeraUI slot document, decoded verbatim. `preState` is kept opaque. */ +export interface LegacyCeraUiMutationEntry { + readonly version: typeof LEGACY_CERAUI_JOURNAL_VERSION; + readonly stableKey: string; + readonly kind: string; + readonly state: LegacyCeraUiMutationState; + readonly attemptId: string; + readonly startedAt: number; + readonly updatedAt: number; + /** The rollback target. Opaque by design — its shape is the mutation kind's. */ + readonly preState: Readonly>; + readonly detail?: string; + readonly acknowledgedMode?: LegacyCeraUiAckMode; + readonly history: readonly LegacyCeraUiHistoryEntry[]; +} + +export interface LegacyCeraUiJournalRead { + /** The same recovery model `reconstructJournalRecovery` produces. */ + readonly recovery: JournalRecovery; + /** The decoded slot documents, verbatim, so `preState` survives the read. */ + readonly entries: readonly LegacyCeraUiMutationEntry[]; +} + +export interface LegacyCeraUiJournalOptions { + /** REQUIRED. The embedding process owns where CeraUI put its journal. */ + readonly dir: string; +} + +/** + * The slot filename CeraUI derives from a stable key. + * + * RULE-D MIRROR of CeraUI's own helper. It is a plain lowercase-hex SHA-256 of the + * key's UTF-8 bytes; the key itself never appears in the filename in plaintext. + */ +export function legacyMutationSlotName(stableKey: string): string { + return createHash('sha256').update(stableKey, 'utf8').digest('hex'); +} + +function parseHistoryEntry(raw: unknown): LegacyCeraUiHistoryEntry { + const source = journalSchema.record(raw, 'history[]'); + const detail = journalSchema.optionalString(source, 'detail'); + return { + state: journalSchema.member(source, 'state', LEGACY_CERAUI_MUTATION_STATES), + at: journalSchema.requiredNonNegativeInteger(source, 'at'), + ...(detail === undefined ? {} : { detail }), + }; +} + +/** Validate one slot document. Throws the codec's metadata-only schema errors. */ +export function validateLegacyCeraUiEntry(raw: unknown): LegacyCeraUiMutationEntry { + const source = journalSchema.record(raw, 'entry'); + if (source.version !== LEGACY_CERAUI_JOURNAL_VERSION) throw journalSchema.schemaVersionError(); + const history = source.history; + if (!Array.isArray(history) || history.length > LEGACY_CERAUI_HISTORY_CAP) { + throw journalSchema.schemaError('history'); + } + const detail = journalSchema.optionalString(source, 'detail'); + const acknowledgedMode = + source.acknowledgedMode === undefined + ? undefined + : journalSchema.member(source, 'acknowledgedMode', LEGACY_CERAUI_ACK_MODES); + return { + version: LEGACY_CERAUI_JOURNAL_VERSION, + stableKey: journalSchema.requiredString(source, 'stableKey'), + kind: journalSchema.requiredString(source, 'kind'), + state: journalSchema.member(source, 'state', LEGACY_CERAUI_MUTATION_STATES), + attemptId: journalSchema.requiredString(source, 'attemptId'), + startedAt: journalSchema.requiredNonNegativeInteger(source, 'startedAt'), + updatedAt: journalSchema.requiredNonNegativeInteger(source, 'updatedAt'), + preState: journalSchema.record(source.preState, 'preState'), + ...(detail === undefined ? {} : { detail }), + ...(acknowledgedMode === undefined ? {} : { acknowledgedMode }), + history: history.map(parseHistoryEntry), + }; +} + +/** Decode one slot document's text. Never throws; returns a typed failure. */ +export function decodeLegacyCeraUiEntry( + text: string, +): JournalDecodeResult { + return decodeJournalDocument(text, validateLegacyCeraUiEntry); +} + +const DISPOSITION_BY_STATE: Readonly< + Record +> = { + // Pre-state captured, write never dispatched: the device is untouched. + armed: 'pending', + // Dispatched with no terminal record — this package's `unknown-outcome`. + executing: 'unknown-outcome', + completed: 'resolved', + // CeraUI's replay keeps these on disk and refuses further mutations until an + // operator acknowledges. That is a KNOWN bad ending, not an unknown one. + failed: 'blocked', + acknowledged: 'resolved', + 'device-absent-quarantine': 'blocked', + decommissioned: 'blocked', + 'recommission-pending': 'blocked', +}; + +/** + * The descriptor evidence a legacy entry can honestly supply. + * + * CeraUI's journal predates `OperationDescriptor`, so there is no descriptor to + * flatten. Every field below is either a fact the file actually carries (the + * mutation kind) or an explicit statement that the file carries nothing: + * `confidence: 'unknown'` rather than a borrowed default, and empty evidence + * arrays rather than invented profiles. `mutationImpact: 'write'` is a fact, not a + * guess — CeraUI's file is a MUTATION journal and records nothing else. + */ +export function legacyDescriptorEvidence(kind: string): JournalDescriptorEvidence { + return { + descriptorId: kind, + provider: 'ceraui-legacy', + authority: 'controller', + mutationImpact: 'write', + profiles: [], + firmware: [], + confidence: 'unknown', + }; +} + +/** Project one decoded slot onto the shared recovery record model. */ +export function legacyOperationRecord(entry: LegacyCeraUiMutationEntry): JournalOperationRecord { + const disposition = DISPOSITION_BY_STATE[entry.state]; + return { + operationId: entry.attemptId, + // CeraUI's `stableKey`, NOT a `PhysicalModemId` — see the note on the field. + physicalModemId: entry.stableKey, + // CeraUI's journal has no device-generation fence; 0 records "unfenced" + // rather than claiming a generation the file never carried. + generation: 0, + descriptor: legacyDescriptorEvidence(entry.kind), + disposition, + origin: 'legacy-ceraui', + startedAtMs: entry.startedAt, + updatedAtMs: entry.updatedAt, + // One slot is one attempt; CeraUI rewrites the slot rather than appending. + attempts: 1, + // `outcome` is deliberately ABSENT for every legacy record, including the + // `unknown-outcome` one. `JournalOutcome`'s unknown reason union is the + // frozen domain vocabulary — `stale-generation` / `write-reply-timed-out` / + // `write-reply-dropped` — and CeraUI's `executing` state asserts none of + // them: it says a write was dispatched and never concluded, not why. Naming + // one anyway would be the invented reading this package refuses everywhere + // else. The disposition carries the fact; the reason stays unclaimed. + }; +} + +/** + * Read a whole CeraUI journal directory. + * + * An unreadable or non-conforming slot is reported as damage and LEFT IN PLACE; + * every readable slot still comes back. That is the same non-truncating contract + * the native store makes, applied to a directory instead of a file. + */ +export async function readLegacyCeraUiJournal( + options: LegacyCeraUiJournalOptions, +): Promise { + let names: string[]; + try { + names = await readdir(options.dir); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return { recovery: summarizeJournalRecords([], []), entries: [] }; + } + return { + recovery: summarizeJournalRecords( + [], + [{ location: { kind: 'file' }, bytes: 0, failure: { code: 'unreadable' } }], + ), + entries: [], + }; + } + + const entries: LegacyCeraUiMutationEntry[] = []; + const damage: JournalDamageRecord[] = []; + // Sorted so a recovery report is stable across filesystems that do not order + // `readdir`; the slot names are hashes, so the order carries no other meaning. + for (const name of [...names].sort()) { + if (!name.endsWith('.json')) continue; + let text: string; + try { + text = await readFile(join(options.dir, name), 'utf8'); + } catch { + damage.push({ + location: { kind: 'slot', slot: name }, + bytes: 0, + failure: { code: 'unreadable' }, + }); + continue; + } + const decoded = decodeLegacyCeraUiEntry(text); + if (decoded.ok) { + entries.push(decoded.value); + continue; + } + damage.push({ + location: { kind: 'slot', slot: name }, + bytes: Buffer.byteLength(text, 'utf8'), + failure: decoded.failure, + }); + } + + return { + recovery: summarizeJournalRecords(entries.map(legacyOperationRecord), damage), + entries, + }; +} diff --git a/control/src/journal/recovery.ts b/control/src/journal/recovery.ts new file mode 100644 index 0000000..0204371 --- /dev/null +++ b/control/src/journal/recovery.ts @@ -0,0 +1,172 @@ +// Replay: fold a journal back into "what was in flight when we died". +// +// This is the whole reason the journal exists. `operations/operation-engine.ts` +// keeps its uncertainty fence in a `Set` on the engine instance, +// so a process death drops it and the next mutation proceeds as if nothing were +// outstanding. Folding the journal reconstructs that set from disk. +// +// THE FOLD IS ORDER-SENSITIVE AND LAST-WRITE-WINS PER OPERATION. Entries are +// applied in file order, keyed by (physical modem, operation id, generation). A +// second `started` on a live key is a re-run and re-opens the record rather than +// being deduplicated away — the journal records attempts, and hiding a second +// attempt would make a retry loop invisible in exactly the forensics the file is +// kept for. +// +// FOUR DISPOSITIONS, AND TWO OF THEM MEAN "RECONCILE BEFORE MUTATING AGAIN": +// +// pending a start with no completion — the process died inside the +// operation, so whether the device changed is unknown. +// unknown-outcome a completion the engine itself classified unknown: a stale +// generation, or a write reply that timed out or was dropped. +// resolved a definite ending (applied / refused / failed). The engine +// treats a definite failure as definite; so does this. +// blocked a terminal state a human must clear. Native journals never +// produce it today — it exists because CeraUI's own mutation +// journal HAS such states (`failed`, quarantine, decommission) +// and `legacy-ceraui.ts` maps onto this same model. Folding +// those into `resolved` would report an operator-blocked device +// as healthy; folding them into `unknown-outcome` would claim +// uncertainty about an outcome that is actually known. +// +// `reconciliationRequired` deliberately covers `pending` + `unknown-outcome` only. +// A blocked record is a KNOWN bad ending awaiting acknowledgement, and answering +// it with a reconciliation pass would silently clear a state that exists precisely +// so it cannot be cleared silently. + +import { DomainError } from '../domain'; +import type { JournalDescriptorEvidence, JournalEntry, JournalOutcome } from './entry'; +import type { JournalDamageRecord, JournalReadResult } from './store'; + +export type JournalRecoveryDisposition = 'pending' | 'unknown-outcome' | 'resolved' | 'blocked'; + +/** Where a record came from. Also tells a consumer how to read `physicalModemId`. */ +export type JournalRecordOrigin = 'native' | 'legacy-ceraui'; + +/** + * One operation reconstructed from the journal. + * + * `physicalModemId` is TEXT, not the branded `PhysicalModemId`, and that is + * deliberate: for a `legacy-ceraui` record it holds CeraUI's own `stableKey`, + * which is a different identity vocabulary and would be REFUSED by + * `physicalModemId()`'s constructor. Coercing it would either throw on a + * perfectly valid legacy file or launder a foreign identity into a branded type + * that promises it came from the serial / ID_PATH ladder. `origin` is what tells + * a consumer which vocabulary it is holding. + */ +export interface JournalOperationRecord { + readonly operationId: string; + readonly physicalModemId: string; + readonly generation: number; + readonly descriptor: JournalDescriptorEvidence; + readonly disposition: JournalRecoveryDisposition; + readonly origin: JournalRecordOrigin; + readonly startedAtMs: number; + readonly updatedAtMs: number; + readonly attempts: number; + readonly outcome?: JournalOutcome; +} + +export interface JournalRecovery { + /** Every reconstructed operation, in first-appearance order. */ + readonly records: readonly JournalOperationRecord[]; + readonly pending: readonly JournalOperationRecord[]; + readonly unknownOutcome: readonly JournalOperationRecord[]; + readonly blocked: readonly JournalOperationRecord[]; + /** Distinct modem identities that must be reconciled before the next mutation. */ + readonly reconciliationRequired: readonly string[]; + readonly damage: readonly JournalDamageRecord[]; +} + +/** Raised only by `assertJournalIntact`; recovery itself always returns a report. */ +export class JournalRecoveryError extends DomainError { + override readonly name = 'JournalRecoveryError'; + readonly damage: readonly JournalDamageRecord[]; + + constructor(damage: readonly JournalDamageRecord[]) { + super(`journal recovery found ${damage.length} damaged record(s)`); + this.damage = damage; + } +} + +/** + * Escalate a damaged journal to a throw, for a caller that wants fail-closed. + * + * Kept SEPARATE from `reconstructJournalRecovery` on purpose: recovery must be + * able to hand back the survivors even when part of the file is unreadable, so + * the decision to refuse to proceed belongs to the caller, after it has seen + * what did survive. + */ +export function assertJournalIntact(recovery: JournalRecovery): void { + if (recovery.damage.length > 0) throw new JournalRecoveryError(recovery.damage); +} + +function keyOf(entry: JournalEntry): string { + return `${entry.physicalModemId}\u0000${entry.operationId}\u0000${entry.generation}`; +} + +function dispositionOf(outcome: JournalOutcome): JournalRecoveryDisposition { + return outcome.status === 'unknown-outcome' ? 'unknown-outcome' : 'resolved'; +} + +/** Fold decoded entries into per-operation records. Pure; no clock, no I/O. */ +export function reconstructJournalRecovery(read: JournalReadResult): JournalRecovery { + const byKey = new Map(); + const order: string[] = []; + + for (const { entry } of read.entries) { + const key = keyOf(entry); + const previous = byKey.get(key); + if (previous === undefined) order.push(key); + + if (entry.phase === 'started') { + byKey.set(key, { + operationId: entry.operationId, + physicalModemId: entry.physicalModemId, + generation: entry.generation, + descriptor: entry.descriptor, + disposition: 'pending', + origin: 'native', + // The FIRST start is when this operation began; a re-run does not + // rewrite history, it increments the attempt count. + startedAtMs: previous?.startedAtMs ?? entry.recordedAtMs, + updatedAtMs: entry.recordedAtMs, + attempts: (previous?.attempts ?? 0) + 1, + }); + continue; + } + + byKey.set(key, { + operationId: entry.operationId, + physicalModemId: entry.physicalModemId, + generation: entry.generation, + descriptor: entry.descriptor, + disposition: dispositionOf(entry.outcome), + origin: 'native', + startedAtMs: previous?.startedAtMs ?? entry.recordedAtMs, + updatedAtMs: entry.recordedAtMs, + // A completion with no start is a journal whose head was never written; + // counting it as one attempt is more honest than reporting zero. + attempts: previous?.attempts ?? 1, + outcome: entry.outcome, + }); + } + + const records = order + .map((key) => byKey.get(key)) + .filter((value): value is JournalOperationRecord => value !== undefined); + return summarizeJournalRecords(records, read.damage); +} + +/** Build the summary views over already-reconstructed records (native or legacy). */ +export function summarizeJournalRecords( + records: readonly JournalOperationRecord[], + damage: readonly JournalDamageRecord[], +): JournalRecovery { + const pending = records.filter((record) => record.disposition === 'pending'); + const unknownOutcome = records.filter((record) => record.disposition === 'unknown-outcome'); + const blocked = records.filter((record) => record.disposition === 'blocked'); + const reconciliationRequired = [ + ...new Set([...pending, ...unknownOutcome].map((record) => record.physicalModemId)), + ].sort(); + return { records, pending, unknownOutcome, blocked, reconciliationRequired, damage }; +} diff --git a/control/src/journal/store.ts b/control/src/journal/store.ts new file mode 100644 index 0000000..7c409a9 --- /dev/null +++ b/control/src/journal/store.ts @@ -0,0 +1,198 @@ +// The append-only journal store. THE PATH IS INJECTED AND HAS NO DEFAULT. +// +// This package never learns where a journal lives. The embedding process owns that +// decision because it owns the filesystem contract: on a CeraLive device the +// update-surviving partition is the right home, on a bench box a scratch directory +// is, and in a test a `mkdtemp` directory is. A default here would be a policy this +// library has no standing to set, and — worse — a default is what turns "the +// embedder forgot to configure it" into "we silently wrote somewhere plausible". +// `journal-path-injection.test.ts` fails the build if an absolute path literal ever +// appears in this directory's executable source. +// +// THREE PROPERTIES THIS STORE GUARANTEES, ALL LOAD-BEARING: +// +// 1. APPEND-ONLY. There is no verb here that rewrites or truncates the file. +// A rewrite is the one operation that can lose a fact that was already +// durable, and a journal that can lose a fact answers nothing after a crash. +// +// 2. A DAMAGED RECORD NEVER DISCARDS ITS NEIGHBOURS. `read()` decodes every line +// independently and returns the survivors alongside a typed damage report. +// Stopping at the first bad line — the natural thing a `for` loop with a throw +// does — silently truncates the journal to its first corruption, which is +// exactly the failure mode this store exists to make impossible. +// +// 3. A TORN TRAILING LINE IS CLOSED BEFORE THE NEXT APPEND. A process killed +// mid-write leaves a final line with no terminator. Appending straight onto it +// would glue the new entry to the garbage and corrupt a SECOND record — one +// that was never in flight when the crash happened. The store probes the last +// byte once and emits a leading terminator when the file does not end in one, +// so the damage stays confined to the record that actually tore. + +import { appendFile, chmod, mkdir, open, readFile } from 'node:fs/promises'; +import { dirname } from 'node:path'; + +import { decodeJournalEntry, encodeJournalEntry, type JournalDecodeFailure } from './codec'; +import type { JournalEntry } from './entry'; + +const NEWLINE = 0x0a; + +/** Default file mode. `0600` for the same reason the two policy stores use it. */ +export const JOURNAL_FILE_MODE = 0o600; + +/** Where a damaged record was found. Line-based, slot-based, or the whole file. */ +export type JournalDamageLocation = + | { readonly kind: 'line'; readonly line: number; readonly trailing: boolean } + | { readonly kind: 'slot'; readonly slot: string } + | { readonly kind: 'file' }; + +/** One record that could not be read, reported rather than dropped. */ +export interface JournalDamageRecord { + readonly location: JournalDamageLocation; + readonly bytes: number; + readonly failure: JournalDecodeFailure; +} + +/** One decoded entry with the 1-based line it came from. */ +export interface JournalLineRecord { + readonly line: number; + readonly entry: JournalEntry; +} + +export interface JournalReadResult { + readonly entries: readonly JournalLineRecord[]; + readonly damage: readonly JournalDamageRecord[]; +} + +export interface JournalStore { + /** The injected path, echoed back so a caller can report where it recovered from. */ + readonly path: string; + append(entry: JournalEntry): Promise; + read(): Promise; +} + +export interface FileJournalStoreOptions { + /** REQUIRED. There is no default and no fallback. */ + readonly path: string; + readonly mode?: number; +} + +export class JournalPathError extends Error { + override readonly name = 'JournalPathError'; + + constructor() { + super('journal path refused: an explicit, non-empty path must be injected'); + } +} + +/** + * Decode a whole journal document. + * + * Exported because the corruption fixtures assert against it directly — proving + * the "survivors are kept" property without needing a filesystem to prove it. + * Blank lines carry no record and are skipped rather than reported: a trailing + * newline is how every line ends, so the final split member is always empty. + */ +export function decodeJournalText(text: string): JournalReadResult { + const lines = text.split('\n'); + let lastPopulated = -1; + for (let index = lines.length - 1; index >= 0; index -= 1) { + if ((lines[index] ?? '').trim().length > 0) { + lastPopulated = index; + break; + } + } + + const entries: JournalLineRecord[] = []; + const damage: JournalDamageRecord[] = []; + for (const [index, raw] of lines.entries()) { + if (raw.trim().length === 0) continue; + const decoded = decodeJournalEntry(raw); + if (decoded.ok) { + entries.push({ line: index + 1, entry: decoded.value }); + continue; + } + damage.push({ + location: { kind: 'line', line: index + 1, trailing: index === lastPopulated }, + bytes: Buffer.byteLength(raw, 'utf8'), + failure: decoded.failure, + }); + } + return { entries, damage }; +} + +class FileJournalStore implements JournalStore { + readonly path: string; + readonly #mode: number; + #tail: Promise = Promise.resolve(); + /** Undefined until the first append probes the existing file's final byte. */ + #terminated: boolean | undefined; + + constructor(options: FileJournalStoreOptions) { + if (typeof options.path !== 'string' || options.path.trim().length === 0) { + throw new JournalPathError(); + } + this.path = options.path; + this.#mode = options.mode ?? JOURNAL_FILE_MODE; + } + + append(entry: JournalEntry): Promise { + // Chained so two concurrent appends cannot interleave, and recovered from so + // one failed append does not poison every later one with its rejection. + const next = this.#tail.catch(() => undefined).then(() => this.#appendNow(entry)); + this.#tail = next.catch(() => undefined); + return next; + } + + async read(): Promise { + let text: string; + try { + text = await readFile(this.path, 'utf8'); + } catch (error) { + // An ABSENT journal is an empty one — nothing was ever written. Anything + // else (permissions, a directory in the way) is reported as damage: a + // journal we could not open is not evidence that no mutation was pending. + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return { entries: [], damage: [] }; + } + return { + entries: [], + damage: [{ location: { kind: 'file' }, bytes: 0, failure: { code: 'unreadable' } }], + }; + } + return decodeJournalText(text); + } + + async #appendNow(entry: JournalEntry): Promise { + await mkdir(dirname(this.path), { recursive: true }); + this.#terminated ??= await this.#probeTerminated(); + const line = `${this.#terminated ? '' : '\n'}${encodeJournalEntry(entry)}\n`; + await appendFile(this.path, line, { mode: this.#mode }); + // chmod AFTER the write, not as an open flag, so the mode holds regardless of + // umask — the same reason the usage and FCC policy stores do it this way. + await chmod(this.path, this.#mode); + this.#terminated = true; + } + + async #probeTerminated(): Promise { + let handle: Awaited>; + try { + handle = await open(this.path, 'r'); + } catch { + // No file yet: the first line starts the document, nothing to close. + return true; + } + try { + const { size } = await handle.stat(); + if (size === 0) return true; + const tail = Buffer.alloc(1); + await handle.read(tail, 0, 1, size - 1); + return tail[0] === NEWLINE; + } finally { + await handle.close(); + } + } +} + +export function createFileJournalStore(options: FileJournalStoreOptions): JournalStore { + return new FileJournalStore(options); +} diff --git a/control/src/location/fix-state.test.ts b/control/src/location/fix-state.test.ts new file mode 100644 index 0000000..bbcb88d --- /dev/null +++ b/control/src/location/fix-state.test.ts @@ -0,0 +1,200 @@ +// The GNSS display state machine — the two dishonest renders it makes impossible. +// +// These are the paths that are FULLY verifiable with no antenna and no sky, which +// is the whole point: the no-fix bound and the stale-fix drop are exactly what an +// operator with an unconnected GNSS connector will actually experience. + +import { describe, expect, test } from 'bun:test'; +import { epochMillis } from '../domain'; +import type { FixRead, GnssFix } from '../ports'; +import { + advanceGnssFixState, + DEFAULT_FIX_STATE_CONFIG, + GNSS_OFF, + type GnssFixState, + type GnssFixStateConfig, + isAcquiring, + renderableFix, +} from './fix-state'; + +const CONFIG: GnssFixStateConfig = { acquireTimeoutMs: 10_000, fixTtlMs: 5_000 }; + +function fixAt(ms: number): GnssFix { + return { latitude: 4.60971, longitude: -74.08175, observedAt: epochMillis(ms) }; +} + +const FIX_READ = (ms: number): FixRead => ({ outcome: 'fix', fix: fixAt(ms) }); +const NO_FIX_READ: FixRead = { outcome: 'no-fix', reason: 'still searching' }; + +function run(events: readonly Parameters[1][]): GnssFixState { + return events.reduce( + (state, event) => advanceGnssFixState(state, event, CONFIG), + GNSS_OFF, + ); +} + +describe('bounded acquisition — the spinner always ends', () => { + test('enabling starts a bounded wait, not an open-ended one', () => { + const state = run([{ kind: 'gnss-enabled', at: epochMillis(0) }]); + expect(state.kind).toBe('acquiring'); + expect(isAcquiring(state)).toBe(true); + }); + + test('a modem reporting no-fix INSIDE the bound is still legitimately acquiring', () => { + const state = run([ + { kind: 'gnss-enabled', at: epochMillis(0) }, + { kind: 'read', at: epochMillis(4_000), read: NO_FIX_READ }, + { kind: 'read', at: epochMillis(9_999), read: NO_FIX_READ }, + ]); + expect(state.kind).toBe('acquiring'); + }); + + test('at the bound the wait becomes an honest terminal no-fix — never a spinner', () => { + const state = run([ + { kind: 'gnss-enabled', at: epochMillis(0) }, + { kind: 'tick', at: epochMillis(10_000) }, + ]); + expect(state).toEqual({ + kind: 'no-fix', + since: epochMillis(10_000), + reason: 'acquire-timeout', + }); + expect(isAcquiring(state)).toBe(false); + }); + + test('a read that lands exactly at the bound also ends the wait', () => { + const state = run([ + { kind: 'gnss-enabled', at: epochMillis(0) }, + { kind: 'read', at: epochMillis(10_000), read: NO_FIX_READ }, + ]); + expect(state.kind).toBe('no-fix'); + }); + + test('an antenna-less modem NEVER reaches a renderable fix, however long it runs', () => { + let state: GnssFixState = advanceGnssFixState( + GNSS_OFF, + { kind: 'gnss-enabled', at: epochMillis(0) }, + CONFIG, + ); + for (let at = 1_000; at <= 600_000; at += 1_000) { + state = advanceGnssFixState( + state, + { kind: 'read', at: epochMillis(at), read: NO_FIX_READ }, + CONFIG, + ); + state = advanceGnssFixState(state, { kind: 'tick', at: epochMillis(at) }, CONFIG); + expect(renderableFix(state)).toBeUndefined(); + } + expect(state.kind).toBe('no-fix'); + }); +}); + +describe('stale-fix clearing — a coordinate is never shown past its life', () => { + test('a fresh fix is renderable', () => { + const state = run([ + { kind: 'gnss-enabled', at: epochMillis(0) }, + { kind: 'read', at: epochMillis(1_000), read: FIX_READ(1_000) }, + ]); + expect(renderableFix(state)?.latitude).toBe(4.60971); + }); + + test('a fix older than its TTL is DROPPED, not merely marked stale', () => { + const state = run([ + { kind: 'gnss-enabled', at: epochMillis(0) }, + { kind: 'read', at: epochMillis(1_000), read: FIX_READ(1_000) }, + { kind: 'tick', at: epochMillis(6_000) }, + ]); + expect(state).toEqual({ kind: 'no-fix', since: epochMillis(6_000), reason: 'fix-expired' }); + expect(renderableFix(state)).toBeUndefined(); + expect(JSON.stringify(state)).not.toContain('4.60971'); + }); + + test('the modem losing its fix drops the held coordinates immediately', () => { + const state = run([ + { kind: 'gnss-enabled', at: epochMillis(0) }, + { kind: 'read', at: epochMillis(1_000), read: FIX_READ(1_000) }, + { kind: 'read', at: epochMillis(2_000), read: NO_FIX_READ }, + ]); + expect(state).toEqual({ kind: 'no-fix', since: epochMillis(2_000), reason: 'reported-no-fix' }); + expect(renderableFix(state)).toBeUndefined(); + }); + + test('a newer fix replaces an older one and restarts its life', () => { + const state = run([ + { kind: 'gnss-enabled', at: epochMillis(0) }, + { kind: 'read', at: epochMillis(1_000), read: FIX_READ(1_000) }, + { kind: 'read', at: epochMillis(4_000), read: FIX_READ(4_000) }, + { kind: 'tick', at: epochMillis(6_000) }, + ]); + expect(renderableFix(state)?.observedAt).toBe(epochMillis(4_000)); + }); +}); + +describe('disable clears everything', () => { + test('disabling drops a held fix and returns to off', () => { + const state = run([ + { kind: 'gnss-enabled', at: epochMillis(0) }, + { kind: 'read', at: epochMillis(1_000), read: FIX_READ(1_000) }, + { kind: 'gnss-disabled' }, + ]); + expect(state).toEqual({ kind: 'off' }); + expect(renderableFix(state)).toBeUndefined(); + expect(JSON.stringify(state)).not.toContain('74.08175'); + }); + + test('a read reporting the source is off also clears a held fix', () => { + const state = run([ + { kind: 'gnss-enabled', at: epochMillis(0) }, + { kind: 'read', at: epochMillis(1_000), read: FIX_READ(1_000) }, + { kind: 'read', at: epochMillis(2_000), read: { outcome: 'disabled', reason: 'off' } }, + ]); + expect(state).toEqual({ kind: 'off' }); + }); + + test('disabling from a timed-out wait also returns to off', () => { + const state = run([ + { kind: 'gnss-enabled', at: epochMillis(0) }, + { kind: 'tick', at: epochMillis(20_000) }, + { kind: 'gnss-disabled' }, + ]); + expect(state).toEqual({ kind: 'off' }); + }); +}); + +describe('unavailable is distinct from no-fix', () => { + test('an unsupported modem is unavailable, never a no-fix wait', () => { + const state = run([ + { kind: 'gnss-enabled', at: epochMillis(0) }, + { kind: 'read', at: epochMillis(1_000), read: { outcome: 'unsupported', reason: 'no GNSS' } }, + ]); + expect(state).toEqual({ kind: 'unavailable', reason: 'no GNSS' }); + }); + + test('a bus error is unavailable and carries its reason', () => { + const state = run([ + { kind: 'gnss-enabled', at: epochMillis(0) }, + { + kind: 'read', + at: epochMillis(1_000), + read: { outcome: 'error', reason: 'GetLocation failed' }, + }, + ]); + expect(state).toEqual({ kind: 'unavailable', reason: 'GetLocation failed' }); + }); +}); + +describe('the shipped defaults are bounded', () => { + test('both bounds are finite and positive, so neither can hang a render', () => { + expect(DEFAULT_FIX_STATE_CONFIG.acquireTimeoutMs).toBeGreaterThan(0); + expect(Number.isFinite(DEFAULT_FIX_STATE_CONFIG.acquireTimeoutMs)).toBe(true); + expect(DEFAULT_FIX_STATE_CONFIG.fixTtlMs).toBeGreaterThan(0); + expect(Number.isFinite(DEFAULT_FIX_STATE_CONFIG.fixTtlMs)).toBe(true); + }); + + test('the machine is pure — advancing never mutates the state it was given', () => { + const before: GnssFixState = { kind: 'fix', fix: fixAt(1_000) }; + const snapshot = JSON.stringify(before); + advanceGnssFixState(before, { kind: 'tick', at: epochMillis(99_000) }, CONFIG); + expect(JSON.stringify(before)).toBe(snapshot); + }); +}); diff --git a/control/src/location/fix-state.ts b/control/src/location/fix-state.ts new file mode 100644 index 0000000..4644e1d --- /dev/null +++ b/control/src/location/fix-state.ts @@ -0,0 +1,120 @@ +// The GNSS display state machine — bounded acquisition and stale-fix expiry. +// +// It exists to make two dishonest renders impossible rather than merely unlikely: +// +// 1. An endless "acquiring…" spinner. A modem with no antenna answers "no fix" +// forever, quite correctly, and a naive UI waits forever. Acquisition here is +// BOUNDED — past `acquireTimeoutMs` the state becomes `no-fix`, which is a +// terminal render, not a spinner. +// 2. A stale coordinate shown as current. A fix is only reachable through +// `renderableFix`, which returns one ONLY in the `fix` state; every exit from +// that state DROPS the fix rather than carrying it, so there is no code path +// that can render a position the modem is no longer reporting. +// +// Pure and total: no clock, no I/O. The caller supplies `at` on every event, which +// is what makes both bounds testable without waiting for real time to pass. + +import type { EpochMillis } from '../domain'; +import type { FixRead, GnssFix } from '../ports/location'; + +export interface GnssFixStateConfig { + /** How long acquisition may run before the state turns to an honest `no-fix`. */ + readonly acquireTimeoutMs: number; + /** How long a fix stays current before it expires and is dropped. */ + readonly fixTtlMs: number; +} + +/** + * Defaults chosen against the fleet's own numbers: a cold GNSS start on the + * bench modems is a low-minutes affair, so 120 s is long enough to be a fair + * attempt and short enough that a missing antenna is reported inside a support + * call rather than after one. A fix older than 30 s is not "current" on a moving + * vehicle, which is the only context this display has. + */ +export const DEFAULT_FIX_STATE_CONFIG: GnssFixStateConfig = { + acquireTimeoutMs: 120_000, + fixTtlMs: 30_000, +}; + +export type NoFixReason = 'acquire-timeout' | 'reported-no-fix' | 'fix-expired'; + +export type GnssFixState = + | { readonly kind: 'off' } + | { readonly kind: 'acquiring'; readonly since: EpochMillis } + | { readonly kind: 'no-fix'; readonly since: EpochMillis; readonly reason: NoFixReason } + | { readonly kind: 'fix'; readonly fix: GnssFix } + | { readonly kind: 'unavailable'; readonly reason: string }; + +export type GnssFixEvent = + | { readonly kind: 'gnss-enabled'; readonly at: EpochMillis } + | { readonly kind: 'gnss-disabled' } + | { readonly kind: 'read'; readonly at: EpochMillis; readonly read: FixRead } + | { readonly kind: 'tick'; readonly at: EpochMillis }; + +export const GNSS_OFF: GnssFixState = { kind: 'off' }; + +/** A fix is reachable ONLY here, and only while the state actually holds one. */ +export function renderableFix(state: GnssFixState): GnssFix | undefined { + return state.kind === 'fix' ? state.fix : undefined; +} + +/** True while a bounded wait is legitimately in progress — the only spinner state. */ +export function isAcquiring(state: GnssFixState): boolean { + return state.kind === 'acquiring'; +} + +function expireIfDue( + state: GnssFixState, + at: EpochMillis, + config: GnssFixStateConfig, +): GnssFixState { + if (state.kind === 'acquiring' && at - state.since >= config.acquireTimeoutMs) { + return { kind: 'no-fix', since: at, reason: 'acquire-timeout' }; + } + if (state.kind === 'fix' && at - state.fix.observedAt >= config.fixTtlMs) { + return { kind: 'no-fix', since: at, reason: 'fix-expired' }; + } + return state; +} + +function applyRead( + state: GnssFixState, + at: EpochMillis, + read: FixRead, + config: GnssFixStateConfig, +): GnssFixState { + switch (read.outcome) { + case 'fix': + return { kind: 'fix', fix: read.fix }; + case 'no-fix': + // Still inside the bound, the modem simply has not acquired yet — that is + // what `acquiring` means, so a report of no-fix does not end the wait. Any + // other state (including a held fix) drops to an honest `no-fix`. + return state.kind === 'acquiring' + ? expireIfDue(state, at, config) + : { kind: 'no-fix', since: at, reason: 'reported-no-fix' }; + case 'disabled': + return GNSS_OFF; + case 'unsupported': + case 'error': + return { kind: 'unavailable', reason: read.reason }; + } +} + +/** Pure, total transition. Every exit from `fix` drops the coordinates. */ +export function advanceGnssFixState( + state: GnssFixState, + event: GnssFixEvent, + config: GnssFixStateConfig = DEFAULT_FIX_STATE_CONFIG, +): GnssFixState { + switch (event.kind) { + case 'gnss-enabled': + return state.kind === 'fix' ? state : { kind: 'acquiring', since: event.at }; + case 'gnss-disabled': + return GNSS_OFF; + case 'read': + return applyRead(state, event.at, event.read, config); + case 'tick': + return expireIfDue(state, event.at, config); + } +} diff --git a/control/src/location/index.ts b/control/src/location/index.ts new file mode 100644 index 0000000..d561098 --- /dev/null +++ b/control/src/location/index.ts @@ -0,0 +1,9 @@ +// The GPS/location module — the GNSS display state machine and NMEA decoding. +// +// PRIVACY FENCE (a product rule, not a phase limitation): this module reads the +// CURRENT fix and holds it in memory for a live display. There is no history, no +// track log, no persistence, and no upload — and none may be added here. The port +// contract in `../ports/location.ts` is guarded by `location-fence.test.ts`. + +export * from './fix-state'; +export * from './nmea'; diff --git a/control/src/location/nmea.test.ts b/control/src/location/nmea.test.ts new file mode 100644 index 0000000..cc3f3eb --- /dev/null +++ b/control/src/location/nmea.test.ts @@ -0,0 +1,81 @@ +// NMEA GGA decoding — checksum-verified, quality-gated, never throwing. +// +// The negative cases matter more than the positive one here: this parser sits +// behind the honest no-fix path, so anything it CANNOT prove is a fix must come +// back `undefined` rather than a plausible-looking coordinate. + +import { describe, expect, test } from 'bun:test'; +import { parseNmeaFix } from './nmea'; + +const VALID_GGA = '$GPGGA,123519,4807.038,N,01131.000,E,1,08,0.9,545.4,M,46.9,M,,*47'; +const SEARCHING_GGA = '$GPGGA,000000,,,,,0,00,99.99,,,,,,*48'; +const SOUTHWEST_GGA = + '$GNGGA,181908.00,3404.7041778,S,07044.3966270,W,1,13,0.98,1113.0,M,-21.3,M,,*59'; +const RMC = '$GPRMC,123519,A,4807.038,N,01131.000,E,022.4,084.4,230394,003.1,W*6A'; + +describe('a real fix decodes', () => { + test('ddmm.mmmm is converted to signed degrees', () => { + const fix = parseNmeaFix(VALID_GGA); + expect(fix?.latitude).toBeCloseTo(48.1173, 4); + expect(fix?.longitude).toBeCloseTo(11.516667, 5); + expect(fix?.altitude).toBeCloseTo(545.4, 1); + }); + + test('southern and western hemispheres come back negative', () => { + const fix = parseNmeaFix(SOUTHWEST_GGA); + expect(fix?.latitude).toBeLessThan(0); + expect(fix?.longitude).toBeLessThan(0); + expect(fix?.latitude).toBeCloseTo(-34.0784, 3); + expect(fix?.longitude).toBeCloseTo(-70.7399, 3); + }); + + test('the LAST valid fix in a multi-sentence block wins', () => { + const fix = parseNmeaFix([VALID_GGA, RMC, SOUTHWEST_GGA].join('\r\n')); + expect(fix?.latitude).toBeLessThan(0); + }); + + test('a talker-agnostic GGA is accepted — GN as well as GP', () => { + expect(parseNmeaFix(SOUTHWEST_GGA)).toBeDefined(); + }); +}); + +describe('anything unproven is NOT a fix', () => { + test('quality 0 — a receiver that is still searching', () => { + expect(parseNmeaFix(SEARCHING_GGA)).toBeUndefined(); + }); + + test('a searching block never falls back to an earlier fix in the same blob', () => { + // A block whose ONLY sentences are quality-0 must decode to nothing, even + // though the parser retains a `latest` across lines. + expect(parseNmeaFix([SEARCHING_GGA, SEARCHING_GGA].join('\n'))).toBeUndefined(); + }); + + test('a corrupted checksum is rejected even though the fields parse cleanly', () => { + expect(parseNmeaFix(VALID_GGA.replace('*47', '*48'))).toBeUndefined(); + }); + + test('a sentence with no checksum at all is rejected', () => { + expect(parseNmeaFix(VALID_GGA.slice(0, VALID_GGA.lastIndexOf('*')))).toBeUndefined(); + }); + + test('RMC is not read — only GGA carries the quality flag this module needs', () => { + expect(parseNmeaFix(RMC)).toBeUndefined(); + }); + + test('an out-of-range coordinate is refused, not clamped', () => { + const bogus = '$GPGGA,123519,9959.999,N,01131.000,E,1,08,0.9,545.4,M,46.9,M,,'; + let sum = 0; + for (const char of bogus.slice(1)) { + sum ^= char.charCodeAt(0); + } + const sentence = `${bogus}*${sum.toString(16).toUpperCase().padStart(2, '0')}`; + expect(parseNmeaFix(sentence)).toBeUndefined(); + }); + + test('never throws on hostile or truncated input', () => { + for (const input of ['', '$', '$*', '$GPGGA*00', '\0\0\0', '$GPGGA,,,,,,,,,,,,,,*4E']) { + expect(() => parseNmeaFix(input)).not.toThrow(); + expect(parseNmeaFix(input)).toBeUndefined(); + } + }); +}); diff --git a/control/src/location/nmea.ts b/control/src/location/nmea.ts new file mode 100644 index 0000000..055c19b --- /dev/null +++ b/control/src/location/nmea.ts @@ -0,0 +1,100 @@ +// Minimal NMEA decoding — GGA only, checksum-verified. +// +// It exists because `gps-nmea` is the one GNSS source EVERY GNSS-capable modem on +// the fleet advertises, while `gps-raw` (MM's pre-decoded dict) is not guaranteed: +// a `gps-unmanaged`-style device hands over sentences and nothing else. Without this +// the module would have to answer "no fix" to a modem that is reporting one, which +// is exactly the dishonesty the rest of this module is built to avoid. +// +// GGA is the only sentence read: it is the only standard sentence carrying fix +// QUALITY alongside the position, so "the receiver has not locked on" is decodable +// rather than inferred. RMC's A/V validity flag would do, but no fleet modem emits +// RMC without also emitting GGA. + +export interface NmeaFix { + readonly latitude: number; + readonly longitude: number; + readonly altitude?: number; +} + +/** XOR of every character between `$` and `*` — the NMEA checksum. */ +function checksumOf(body: string): number { + let sum = 0; + for (let i = 0; i < body.length; i += 1) { + sum ^= body.charCodeAt(i); + } + return sum; +} + +function verifiedBody(sentence: string): string | undefined { + const trimmed = sentence.trim(); + if (!trimmed.startsWith('$')) { + return undefined; + } + const star = trimmed.lastIndexOf('*'); + if (star < 0) { + return undefined; + } + const body = trimmed.slice(1, star); + const declared = Number.parseInt(trimmed.slice(star + 1, star + 3), 16); + return Number.isNaN(declared) || checksumOf(body) !== declared ? undefined : body; +} + +/** `ddmm.mmmm` + hemisphere → signed degrees. `degreeDigits` is 2 for lat, 3 for lon. */ +function toDegrees(value: string, hemisphere: string, degreeDigits: number): number | undefined { + if (value.length < degreeDigits + 1) { + return undefined; + } + const degrees = Number.parseFloat(value.slice(0, degreeDigits)); + const minutes = Number.parseFloat(value.slice(degreeDigits)); + if (!Number.isFinite(degrees) || !Number.isFinite(minutes)) { + return undefined; + } + const magnitude = degrees + minutes / 60; + const negative = hemisphere === 'S' || hemisphere === 'W'; + return negative ? -magnitude : magnitude; +} + +function parseGga(fields: readonly string[]): NmeaFix | undefined { + const quality = Number.parseInt(fields[6] ?? '', 10); + // Quality 0 is "fix not available". A receiver that is searching emits GGA with + // empty position fields and quality 0, so this is the honest no-fix signal. + if (!Number.isFinite(quality) || quality <= 0) { + return undefined; + } + const latitude = toDegrees(fields[2] ?? '', fields[3] ?? '', 2); + const longitude = toDegrees(fields[4] ?? '', fields[5] ?? '', 3); + if (latitude === undefined || longitude === undefined) { + return undefined; + } + if (Math.abs(latitude) > 90 || Math.abs(longitude) > 180) { + return undefined; + } + const altitude = Number.parseFloat(fields[9] ?? ''); + return { + latitude, + longitude, + ...(Number.isFinite(altitude) ? { altitude } : {}), + }; +} + +/** + * The LAST valid GGA fix in a block of sentences, or `undefined` when none of them + * carries one. Never throws — a truncated or corrupt blob is simply not a fix. + */ +export function parseNmeaFix(text: string): NmeaFix | undefined { + let latest: NmeaFix | undefined; + for (const line of text.split(/[\r\n]+/)) { + const body = verifiedBody(line); + if (body === undefined) { + continue; + } + const fields = body.split(','); + const type = fields[0] ?? ''; + if (type.length !== 5 || !type.endsWith('GGA')) { + continue; + } + latest = parseGga(fields) ?? latest; + } + return latest; +} diff --git a/control/src/observations/envelope.ts b/control/src/observations/envelope.ts new file mode 100644 index 0000000..95ff100 --- /dev/null +++ b/control/src/observations/envelope.ts @@ -0,0 +1,138 @@ +// Envelope construction for normalizers. +// +// One rule shapes this module: **a payload that arrived is an OBSERVATION, however +// little of it could be read.** A refused HiLink session, an unparseable goform body +// and a UFI endpoint that answered without the field all produce a FRESH envelope +// whose metrics are `unknown` with a reason — not an `unavailable` one. That matters +// for a reason beyond taxonomy: `ObservationEnvelope` pairs `unavailable` with +// `value: null`, so emitting `unavailable` for a payload we did hold would throw the +// diagnostics block away with it, and the raw vendor fields with that. +// +// `unavailable` is therefore reserved for the case where there is no payload at all — +// the device is gone, or the provider could not be engaged. + +import type { + DeviceGeneration, + EpochMillis, + ObservationAuthority, + ObservationEnvelope, + ObservationFreshness, + SourceEpoch, + StableKey, +} from '../domain'; +import type { MetricProvenance, ObservationSourceKind } from './provenance'; + +export type ObservationStaleReason = Extract['reason']; +export type ObservationUnavailableReason = Extract< + ObservationFreshness, + { state: 'unavailable' } +>['reason']; + +export type EnvelopeView = + | { + readonly kind: 'valued'; + readonly value: T; + readonly freshness: Extract; + } + | { + readonly kind: 'unavailable'; + readonly freshness: Extract; + }; + +/** + * Split an envelope into its two representable shapes. + * + * The package's only cast over an envelope lives here. `ObservationEnvelope` pairs + * `value: T` with fresh|stale and `value: null` with unavailable BY CONSTRUCTION, but + * the discriminant sits one level down (`freshness.state`) and TypeScript narrows a + * union only on a direct property — so the pairing the type already guarantees has to + * be restated once, here, instead of at every call site. + */ +export function viewEnvelope(envelope: ObservationEnvelope): EnvelopeView { + return envelope.freshness.state === 'unavailable' + ? { kind: 'unavailable', freshness: envelope.freshness } + : { kind: 'valued', value: envelope.value as T, freshness: envelope.freshness }; +} + +/** + * Everything a normalizer needs that it cannot derive from the payload. + * + * There is no clock and no epoch counter in this layer: `observedAt` and + * `sourceEpoch` are supplied by whoever performed the read, so a normalizer cannot + * stamp a payload with a time it did not come from. + */ +export type NormalizationContext = { + readonly stableKey: StableKey; + readonly generation: DeviceGeneration; + readonly sourceEpoch: SourceEpoch; + readonly observedAt: EpochMillis; + readonly authority?: ObservationAuthority; +}; + +export function contextAuthority(context: NormalizationContext): ObservationAuthority { + return context.authority ?? 'authoritative'; +} + +/** + * Provenance for one metric. + * + * `authority` is per-METRIC rather than inherited wholesale, because one payload can + * mix classes: a router's RSRP is a measurement the modem reported, while its bar + * count is a vendor rendering of that measurement, and calling both `authoritative` + * would let a consumer treat a marketing scale as a reading. + */ +export function metricProvenance( + source: ObservationSourceKind, + context: NormalizationContext, + rawFields: readonly string[], + authority?: ObservationAuthority, +): MetricProvenance { + return { + source, + sourceEpoch: context.sourceEpoch, + observedAt: context.observedAt, + authority: authority ?? contextAuthority(context), + rawFields, + }; +} + +/** A fresh envelope around a normalized value. */ +export function freshObservation( + source: ObservationSourceKind, + context: NormalizationContext, + value: T, +): ObservationEnvelope { + return { + stableKey: context.stableKey, + generation: context.generation, + source, + sourceEpoch: context.sourceEpoch, + observedAt: context.observedAt, + authority: contextAuthority(context), + freshness: { state: 'fresh' }, + value, + }; +} + +/** + * An envelope for a read that produced no payload at all. + * + * It carries `value: null` by construction — there is no overload that could invent + * one — which is also why a normalizer holding vendor bytes must not use it. + */ +export function unavailableObservation( + source: ObservationSourceKind, + context: NormalizationContext, + reason: ObservationUnavailableReason, +): ObservationEnvelope { + return { + stableKey: context.stableKey, + generation: context.generation, + source, + sourceEpoch: context.sourceEpoch, + observedAt: context.observedAt, + authority: contextAuthority(context), + freshness: { state: 'unavailable', since: context.observedAt, reason }, + value: null, + }; +} diff --git a/control/src/observations/freshness.ts b/control/src/observations/freshness.ts new file mode 100644 index 0000000..17c1b7a --- /dev/null +++ b/control/src/observations/freshness.ts @@ -0,0 +1,121 @@ +// Freshness evaluation — how a retained observation ages, and what it never becomes. +// +// Three rules carry the weight here: +// +// 1. **Staleness keeps the value.** An aged reading is the last thing the device +// actually said; discarding it leaves an operator with a blank field and no way +// to tell "we lost contact" from "the modem reports nothing". +// 2. **Unavailable is terminal on re-evaluation.** An envelope that carries no value +// cannot become stale, because there is nothing to age. Re-classifying it would +// have to invent a value to be stale about. +// 3. **Staleness is monotonic.** A stale envelope is returned UNCHANGED, so its +// `since` and `reason` record the FIRST cause. Freshness comes from a new read, +// never from re-evaluating an old one. +// +// This module has no clock: the caller supplies `at` on every evaluation, which is +// what makes the whole window testable without waiting. Same discipline as +// `location/fix-state.ts`. + +import { + type DeviceGeneration, + type EpochMillis, + epochMillis, + isCurrentGeneration, + type ObservationEnvelope, + type SourceEpoch, +} from '../domain'; +import { type ObservationStaleReason, viewEnvelope } from './envelope'; + +/** How long a retained value is reported fresh before it ages into `stale`. */ +export type FreshnessWindow = { + readonly ttlMs: number; +}; + +export type FreshnessEvaluation = { + /** The caller's current time. This module never reads a clock of its own. */ + readonly at: EpochMillis; + readonly window: FreshnessWindow; + /** The source's current epoch; an older envelope epoch is superseded. */ + readonly currentSourceEpoch?: SourceEpoch; + /** The device's current generation; an envelope from an older one is fenced out. */ + readonly currentGeneration?: DeviceGeneration; + /** `false` marks a source that is answering but degraded (e.g. a reconnecting bus). */ + readonly sourceHealthy?: boolean; +}; + +/** How long ago an envelope was observed, floored at zero for a clock that moved back. */ +export function observationAgeMs(envelope: ObservationEnvelope, at: EpochMillis): number { + return Math.max(0, at - envelope.observedAt); +} + +/** + * Re-classify an envelope against the current time, epoch, generation and source + * health. + * + * Trigger precedence, when several apply at once: superseded generation → superseded + * source epoch → degraded source → TTL expiry. The first three are positive statements + * that the reading has been overtaken by a newer reality; TTL expiry only says nobody + * has looked recently, and reporting it over a supersession would understate why. + */ +export function evaluateFreshness( + envelope: ObservationEnvelope, + evaluation: FreshnessEvaluation, +): ObservationEnvelope { + const view = viewEnvelope(envelope); + if (view.kind === 'unavailable' || view.freshness.state !== 'fresh') { + return envelope; + } + + const reason = staleReason(envelope, evaluation); + if (reason === undefined) { + return envelope; + } + + return { + ...envelope, + freshness: { state: 'stale', since: staleSince(envelope, evaluation, reason), reason }, + value: view.value, + }; +} + +function staleReason( + envelope: ObservationEnvelope, + evaluation: FreshnessEvaluation, +): ObservationStaleReason | undefined { + if ( + evaluation.currentGeneration !== undefined && + !isCurrentGeneration(envelope.generation, evaluation.currentGeneration) + ) { + return 'source-epoch-superseded'; + } + if ( + evaluation.currentSourceEpoch !== undefined && + envelope.sourceEpoch !== evaluation.currentSourceEpoch + ) { + return 'source-epoch-superseded'; + } + if (evaluation.sourceHealthy === false) { + return 'source-degraded'; + } + return observationAgeMs(envelope, evaluation.at) > evaluation.window.ttlMs + ? 'ttl-expired' + : undefined; +} + +/** + * When the reading became stale. + * + * A TTL expiry has an exact moment — one window after it was observed — and reporting + * the evaluation time instead would make a reading that expired an hour ago look like + * it just went stale. A supersession or a degradation is only known at the moment it + * is evaluated, so those honestly report `at`. + */ +function staleSince( + envelope: ObservationEnvelope, + evaluation: FreshnessEvaluation, + reason: ObservationStaleReason, +): EpochMillis { + return reason === 'ttl-expired' + ? epochMillis(envelope.observedAt + evaluation.window.ttlMs) + : evaluation.at; +} diff --git a/control/src/observations/index.ts b/control/src/observations/index.ts new file mode 100644 index 0000000..20cf02f --- /dev/null +++ b/control/src/observations/index.ts @@ -0,0 +1,25 @@ +// The observation layer — normalization, provenance, freshness. +// +// It sits directly on top of the migrated pure logic (`domain/mm-enums.ts`, +// `domain/modem-presentation.ts`, `hardware/router-parsers.ts`) and turns a raw +// per-vendor payload into ONE `ObservationEnvelope` that +// carries where every value came from and why any value is missing. It opens no +// transport: a provider performs the read, this layer explains the result. +// +// Reachable through the package root, deliberately not through a new subpath — the +// public specifier set is a frozen contract and this is normalization, which the root +// entry already owns. + +export * from './envelope'; +export * from './freshness'; +export * from './metric'; +export * from './model'; +export * from './provenance'; +export * from './raw'; +export * from './reading'; +export * from './sources/hilink'; +export * from './sources/modemmanager'; +export * from './sources/router-shared'; +export * from './sources/ufi'; +export * from './sources/zte'; +export * from './state-separation'; diff --git a/control/src/observations/metric.ts b/control/src/observations/metric.ts new file mode 100644 index 0000000..8c3fdcc --- /dev/null +++ b/control/src/observations/metric.ts @@ -0,0 +1,123 @@ +// A normalized metric: a value, or an explicit reason there is none — with the +// provenance of the exact reading that produced it. +// +// `unknown` is a FIRST-CLASS state here, and its reason is what keeps it from +// collapsing into `unsupported`. Those two answer different questions: `unsupported` +// is a positive claim about the SOURCE ("this provider cannot express this datum at +// all"), while every other reason is a claim about one READ ("the provider could have +// said, and did not / could not be asked / answered nonsense"). Folding the second +// class into the first is how a control disappears from an operator's screen because +// one poll came back empty. + +import type { RouterSignalMetric, RouterSignalUnknownReason } from '../hardware/router-parsers'; +import type { MetricProvenance } from './provenance'; + +export const METRIC_UNKNOWN_REASONS = [ + /** A positive claim about the SOURCE: it cannot express this datum at all. */ + 'unsupported', + /** The source answered and simply did not include this field. */ + 'not-reported', + /** Nobody has read this yet. Says nothing about the source or the device. */ + 'not-observed', + /** The source answered with something this layer could not decode. */ + 'malformed', + /** The source refused the read; the session needs re-authentication. */ + 'auth-expired', + /** The source answered with an explicit refusal code for this field. */ + 'refused', + /** The source could not be reached for this read. */ + 'unreachable', +] as const; +export type MetricUnknownReason = (typeof METRIC_UNKNOWN_REASONS)[number]; + +/** + * The two classes an unknown reason falls into. + * + * `capability` is a durable statement about the source; `read` is a statement about + * one attempt and may differ on the next one. A consumer deciding whether to HIDE a + * control (capability) or show it as pending (read) must branch on this, never on the + * bare fact that a value is missing. + */ +export type MetricUnknownClass = 'capability' | 'read'; + +export function metricUnknownClass(reason: MetricUnknownReason): MetricUnknownClass { + return reason === 'unsupported' ? 'capability' : 'read'; +} + +/** Whether a reason is the positive "this source cannot report it" claim. */ +export function isCapabilityUnknown(reason: MetricUnknownReason): reason is 'unsupported' { + return metricUnknownClass(reason) === 'capability'; +} + +export type NormalizedMetric = + | { + readonly state: 'known'; + readonly value: T; + readonly provenance: MetricProvenance; + } + | { + readonly state: 'unknown'; + readonly reason: MetricUnknownReason; + readonly provenance: MetricProvenance; + }; + +export function knownMetric(value: T, provenance: MetricProvenance): NormalizedMetric { + return { state: 'known', value, provenance }; +} + +export function unknownMetric( + reason: MetricUnknownReason, + provenance: MetricProvenance, +): NormalizedMetric { + return { state: 'unknown', reason, provenance }; +} + +/** + * Lift an optional decode result into a metric. + * + * `reason` is supplied by the caller precisely so this helper cannot pick one: the + * difference between "the provider omitted the field" and "this provider has no such + * field" is knowledge the decoder does not have and the call site does. + */ +export function metricFromOptional( + value: T | undefined, + reason: MetricUnknownReason, + provenance: MetricProvenance, +): NormalizedMetric { + return value === undefined + ? unknownMetric(reason, provenance) + : knownMetric(value, provenance); +} + +/** + * The migrated router parsers' own unknown vocabulary, carried across UNCHANGED. + * + * Every member of `RouterSignalUnknownReason` is a member of `MetricUnknownReason` + * with the same meaning, so this is a widening and never a re-classification — in + * particular `not-reported` stays `not-reported` and does not become `unsupported`. + */ +export function metricUnknownReasonFromRouter( + reason: RouterSignalUnknownReason | 'refused', +): MetricUnknownReason { + return reason; +} + +/** Wrap one migrated router signal metric with its provenance. */ +export function metricFromRouterSignal( + metric: RouterSignalMetric, + provenance: MetricProvenance, +): NormalizedMetric { + return metric.state === 'known' + ? knownMetric(metric.value, provenance) + : unknownMetric(metricUnknownReasonFromRouter(metric.reason), provenance); +} + +/** Transform a known value while preserving state, reason and provenance. */ +export function mapMetric( + metric: NormalizedMetric, + transform: (value: T) => U, +): NormalizedMetric { + return metric.state === 'known' + ? knownMetric(transform(metric.value), metric.provenance) + : unknownMetric(metric.reason, metric.provenance); +} diff --git a/control/src/observations/model.ts b/control/src/observations/model.ts new file mode 100644 index 0000000..398d40e --- /dev/null +++ b/control/src/observations/model.ts @@ -0,0 +1,97 @@ +// The normalized observation shape every source produces. +// +// It is deliberately NARROW. A field earns a slot here only when more than one source +// can express it and an operator surface acts on it; everything else stays verbatim in +// the diagnostics block rather than growing a per-vendor branch of the model. That is +// the whole trade this layer makes — one shape to render, nothing thrown away. +// +// Every leaf is a `NormalizedMetric`, so "the source cannot report this" and "the +// source did not report this on this read" are different values with different +// reasons rather than the same absent field. + +import type { RadioAccessTechnology } from '../domain'; +import type { SimPresenceEvidence } from '../hardware/router-parsers'; +import type { NormalizedMetric } from './metric'; +import type { ObservationDiagnostics, ObservationSourceKind } from './provenance'; + +export type NormalizedHardware = { + /** The operator-facing model label, sanitized by the migrated presentation rules. */ + readonly label: NormalizedMetric; + /** + * The label plus a short equipment-identifier tail, as CeraUI has always rendered + * it. It embeds the last five digits of an IMEI, so it is display copy and must + * not be used as a key or written to a log. + */ + readonly displayName: NormalizedMetric; +}; + +export type NormalizedRadio = { + readonly modemState: NormalizedMetric; + readonly registration: NormalizedMetric; + readonly accessTechnologies: NormalizedMetric; + readonly modeLabel: NormalizedMetric; +}; + +/** + * Signal metrics. + * + * `quality` and `bars`/`maxBars` are different measurements and both are kept: + * ModemManager reports a 0-100 percentage and no bar count, while the router admin + * APIs report a vendor bar scale and no percentage. Deriving one from the other would + * be inventing a reading, so each source reports what it has and answers + * `unsupported` — a positive claim — for what it structurally cannot. + */ +export type NormalizedSignal = { + readonly quality: NormalizedMetric; + /** + * ModemManager's `SignalQuality` is a `(ub)` — a percentage AND a boolean saying + * whether it was measured recently or is the last cached reading. The boolean is a + * separate fact about the same measurement, so it gets its own metric rather than + * being folded into freshness: an envelope's staleness is about when WE read, this + * is about when the MODEM last measured. The router APIs have no such flag and say + * so with `unsupported`. + */ + readonly qualityRecent: NormalizedMetric; + readonly bars: NormalizedMetric; + readonly maxBars: NormalizedMetric; + readonly dbm: NormalizedMetric; + readonly rsrp: NormalizedMetric; + readonly rsrq: NormalizedMetric; + readonly snr: NormalizedMetric; + readonly sinr: NormalizedMetric; +}; + +/** + * SIM presence as a metric value is BINARY on purpose. + * + * The migrated `deriveSimPresence` answers `present | absent | unknown`; the third + * member is not a presence, it is the absence of an answer, so it becomes the + * metric's `unknown` state with a reason instead of a third value. That is what stops + * "we could not tell" from being rendered beside "there is no SIM". + */ +export type SimPresenceValue = 'present' | 'absent'; + +export type NormalizedSim = { + readonly presence: NormalizedMetric; + /** + * WHICH FACT decided `presence`. Carried beside the metric rather than derived from + * it, because "absent" and "we could not tell" are read off the SAME empty fields + * and only the evidence separates them. `absent` is reachable through exactly one + * evidence kind (`state-failed-reason`), which is what makes "never inferred from a + * blank field" a property a test can assert rather than a convention. + */ + readonly presenceEvidence: SimPresenceEvidence; + readonly lockRequired: NormalizedMetric; + readonly kind: NormalizedMetric<'physical' | 'esim'>; + readonly esimStatus: NormalizedMetric<'no-profiles' | 'with-profiles'>; +}; + +export type NormalizedModemObservation = { + readonly source: ObservationSourceKind; + readonly hardware: NormalizedHardware; + readonly radio: NormalizedRadio; + readonly signal: NormalizedSignal; + readonly sim: NormalizedSim; + /** Everything the provider said, verbatim, plus what was and was not claimed. */ + readonly diagnostics: ObservationDiagnostics; +}; diff --git a/control/src/observations/normalization.test.ts b/control/src/observations/normalization.test.ts new file mode 100644 index 0000000..a3f632b --- /dev/null +++ b/control/src/observations/normalization.test.ts @@ -0,0 +1,295 @@ +import { describe, expect, test } from 'bun:test'; +import { + FIXTURE_GENERATION, + FIXTURE_OBSERVED_AT, + FIXTURE_SOURCE_EPOCH, + FIXTURE_STABLE_KEY, + fixtureContext, + HILINK_AUTH_EXPIRED_FIXTURE, + HILINK_FIXTURE, + MM_FIXTURE, + UFI_AUTH_EXPIRED_FIXTURE, + UFI_FIXTURE, + ZTE_FIXTURE, + ZTE_MALFORMED_FIXTURE, +} from '../../test-support/observation-fixtures'; +import { REDACTED } from '../redact'; +import { viewEnvelope } from './envelope'; +import type { NormalizedMetric } from './metric'; +import type { NormalizedModemObservation } from './model'; +import { redactObservationDiagnostics } from './provenance'; +import { normalizeHilinkObservation } from './sources/hilink'; +import { normalizeModemManagerObservation } from './sources/modemmanager'; +import { normalizeUfiObservation } from './sources/ufi'; +import { normalizeZteObservation } from './sources/zte'; + +const CONTEXT = fixtureContext(); + +function observation( + envelope: ReturnType, +): NormalizedModemObservation { + const view = viewEnvelope(envelope); + if (view.kind === 'unavailable') { + throw new Error('fixture normalization must not produce an unavailable envelope'); + } + return view.value; +} + +function reason(metric: NormalizedMetric): string { + return metric.state === 'unknown' ? metric.reason : `known:${String(metric.value)}`; +} + +const MM = observation(normalizeModemManagerObservation(MM_FIXTURE, CONTEXT)); +const HILINK = observation(normalizeHilinkObservation(HILINK_FIXTURE, CONTEXT)); +const ZTE = observation(normalizeZteObservation(ZTE_FIXTURE, CONTEXT)); +const UFI = observation(normalizeUfiObservation(UFI_FIXTURE, CONTEXT)); + +describe('every source produces one envelope shape', () => { + const cases = [ + ['modemmanager', normalizeModemManagerObservation(MM_FIXTURE, CONTEXT)], + ['huawei-hilink', normalizeHilinkObservation(HILINK_FIXTURE, CONTEXT)], + ['zte-goform', normalizeZteObservation(ZTE_FIXTURE, CONTEXT)], + ['ufi-himiapi', normalizeUfiObservation(UFI_FIXTURE, CONTEXT)], + ] as const; + + test.each(cases.map(([name]) => name))( + 'Given the %s fixture, when normalized, then the envelope carries identity, epoch and time', + (name) => { + const envelope = cases.find(([id]) => id === name)?.[1]; + if (envelope === undefined) { + throw new Error(`missing case ${name}`); + } + + expect(envelope.source).toBe(name); + expect(envelope.stableKey).toBe(FIXTURE_STABLE_KEY); + expect(envelope.generation).toBe(FIXTURE_GENERATION); + expect(envelope.sourceEpoch).toBe(FIXTURE_SOURCE_EPOCH); + expect(envelope.observedAt).toBe(FIXTURE_OBSERVED_AT); + expect(envelope.freshness.state).toBe('fresh'); + }, + ); + + test.each(cases.map(([name]) => name))( + 'Given the %s fixture, when normalized, then consumed and unmapped partition the raw record', + (name) => { + const envelope = cases.find(([id]) => id === name)?.[1]; + if (envelope === undefined) { + throw new Error(`missing case ${name}`); + } + const { diagnostics } = observation(envelope); + const rawKeys = Object.keys(diagnostics.raw).sort(); + + expect([...diagnostics.consumed, ...diagnostics.unmapped].sort()).toEqual(rawKeys); + expect(diagnostics.consumed.filter((key) => diagnostics.unmapped.includes(key))).toEqual([]); + expect(diagnostics.source).toBe(name); + }, + ); +}); + +describe('ModemManager normalization', () => { + test('Given a registered modem, when normalized, then the migrated decoders supply the values', () => { + expect(MM.radio.modemState).toMatchObject({ state: 'known', value: 'registered' }); + expect(MM.radio.registration).toMatchObject({ state: 'known', value: 'home' }); + expect(MM.radio.accessTechnologies).toMatchObject({ state: 'known', value: ['5gnr'] }); + expect(MM.radio.modeLabel).toMatchObject({ state: 'known', value: '5g4g' }); + expect(MM.hardware.label).toMatchObject({ state: 'known', value: 'RM530N-GL' }); + expect(MM.sim.presence).toMatchObject({ state: 'known', value: 'present' }); + expect(MM.sim.lockRequired).toMatchObject({ state: 'known', value: 'none' }); + expect(MM.sim.kind).toMatchObject({ state: 'known', value: 'physical' }); + }); + + test('Given the Signal interface, when normalized, then each metric names the field it came from', () => { + expect(MM.signal.quality).toMatchObject({ state: 'known', value: 71 }); + expect(MM.signal.dbm).toMatchObject({ state: 'known', value: -71 }); + expect(MM.signal.rsrp).toMatchObject({ state: 'known', value: -98.5 }); + expect(MM.signal.rsrp.provenance).toEqual({ + source: 'modemmanager', + sourceEpoch: FIXTURE_SOURCE_EPOCH, + observedAt: FIXTURE_OBSERVED_AT, + authority: 'authoritative', + rawFields: ['Signal.rsrp'], + }); + }); + + test('Given a metric ModemManager cannot express, when normalized, then it is a capability claim', () => { + expect(reason(MM.signal.bars)).toBe('unsupported'); + expect(reason(MM.signal.maxBars)).toBe('unsupported'); + expect(reason(MM.signal.sinr)).toBe('unsupported'); + }); + + test('Given a field present but undecodable, when normalized, then it is malformed and noted', () => { + expect(reason(MM.sim.esimStatus)).toBe('malformed'); + expect(MM.diagnostics.notes).toContainEqual({ + code: 'field-shape-unrecognized', + field: 'Sim.EsimStatus', + }); + }); + + test('Given an interface nobody read, when normalized, then it is not-observed rather than not-reported', () => { + const withoutSim = observation( + normalizeModemManagerObservation({ modem: MM_FIXTURE.modem ?? {} }, CONTEXT), + ); + + expect(reason(withoutSim.sim.kind)).toBe('not-observed'); + expect(reason(withoutSim.sim.esimStatus)).toBe('not-observed'); + expect(reason(withoutSim.signal.rsrp)).toBe('not-observed'); + expect(reason(withoutSim.radio.registration)).toBe('not-reported'); + }); + + test('Given an absent modem state, when normalized, then it is not-reported and never unsupported', () => { + const empty = observation(normalizeModemManagerObservation({ modem: {} }, CONTEXT)); + + expect(reason(empty.radio.modemState)).toBe('not-reported'); + expect(reason(empty.sim.presence)).toBe('not-reported'); + expect(reason(empty.hardware.label)).toBe('not-reported'); + }); +}); + +describe('HiLink normalization', () => { + test('Given both bodies, when normalized, then the migrated parser supplies the signal', () => { + expect(HILINK.signal.bars).toMatchObject({ state: 'known', value: 4 }); + expect(HILINK.signal.maxBars).toMatchObject({ state: 'known', value: 5 }); + expect(HILINK.signal.dbm).toMatchObject({ state: 'known', value: -65 }); + expect(HILINK.signal.rsrp).toMatchObject({ state: 'known', value: -101 }); + expect(HILINK.signal.sinr).toMatchObject({ state: 'known', value: 12 }); + expect(HILINK.radio.modeLabel).toMatchObject({ state: 'known', value: '03' }); + }); + + test('Given a vendor bar scale, when normalized, then it is marked derived rather than authoritative', () => { + expect(HILINK.signal.bars.provenance.authority).toBe('derived'); + expect(HILINK.signal.dbm.provenance.authority).toBe('authoritative'); + }); + + test('Given a router admin API, when normalized, then absent modem concepts are capability claims', () => { + expect(reason(HILINK.radio.modemState)).toBe('unsupported'); + expect(reason(HILINK.radio.registration)).toBe('unsupported'); + expect(reason(HILINK.signal.quality)).toBe('unsupported'); + }); + + test('Given a refused session, when normalized, then metrics report auth-expired and NOT unsupported', () => { + const refused = observation(normalizeHilinkObservation(HILINK_AUTH_EXPIRED_FIXTURE, CONTEXT)); + + expect(reason(refused.signal.dbm)).toBe('auth-expired'); + expect(reason(refused.signal.bars)).toBe('auth-expired'); + expect(reason(refused.radio.modeLabel)).toBe('auth-expired'); + expect(refused.diagnostics.notes).toContainEqual({ + code: 'auth-expired', + field: 'monitoring-status.code', + }); + }); + + test('Given a refused session, when normalized, then the envelope is still an observation', () => { + const envelope = normalizeHilinkObservation(HILINK_AUTH_EXPIRED_FIXTURE, CONTEXT); + + expect(envelope.freshness.state).toBe('fresh'); + expect(envelope.value).not.toBeNull(); + }); +}); + +describe('ZTE normalization', () => { + test('Given a goform body, when normalized, then signal and mode come from the migrated parsers', () => { + expect(ZTE.signal.bars).toMatchObject({ state: 'known', value: 4 }); + expect(ZTE.signal.dbm).toMatchObject({ state: 'known', value: -67 }); + expect(ZTE.signal.rsrp).toMatchObject({ state: 'known', value: -99 }); + expect(ZTE.signal.rsrq).toMatchObject({ state: 'known', value: -10 }); + expect(ZTE.signal.snr).toMatchObject({ state: 'known', value: 7 }); + expect(ZTE.radio.modeLabel).toMatchObject({ state: 'known', value: 'LTE' }); + }); + + test('Given the vendor fixed bar scale, when normalized, then it is derived', () => { + expect(ZTE.signal.maxBars).toMatchObject({ state: 'known', value: 5 }); + expect(ZTE.signal.maxBars.provenance.authority).toBe('derived'); + }); + + test('Given an unparseable body, when normalized, then it is a malformed observation, not an unavailable one', () => { + const envelope = normalizeZteObservation(ZTE_MALFORMED_FIXTURE, CONTEXT); + const malformed = observation(envelope); + + expect(envelope.freshness.state).toBe('fresh'); + expect(reason(malformed.signal.dbm)).toBe('malformed'); + expect(reason(malformed.radio.modeLabel)).toBe('malformed'); + expect(malformed.diagnostics.notes).toContainEqual({ + code: 'unparseable-body', + field: 'goform', + }); + }); +}); + +describe('UFI normalization', () => { + test('Given three endpoints, when normalized, then the single reported reading survives', () => { + expect(UFI.signal.dbm).toMatchObject({ state: 'known', value: 3 }); + expect(UFI.hardware.label).toMatchObject({ state: 'known', value: 'UFI-M600' }); + expect(UFI.signal.dbm.provenance.rawFields).toEqual([ + 'sysinfo.SIGNAL', + 'overview.SIGNAL', + 'status.signalStrength', + ]); + }); + + test('Given a SessionOut reply, when normalized, then the reading is auth-expired and noted', () => { + const refused = observation(normalizeUfiObservation(UFI_AUTH_EXPIRED_FIXTURE, CONTEXT)); + + expect(reason(refused.signal.dbm)).toBe('auth-expired'); + expect(refused.diagnostics.notes).toContainEqual({ + code: 'auth-expired', + field: 'overview.reply', + }); + }); + + test('Given subscriber identifiers in the payload, when redacted, then only those are masked', () => { + const redacted = redactObservationDiagnostics(UFI.diagnostics); + + expect(UFI.diagnostics.raw['overview.IMSI']).toBe('732123456789012'); + expect(redacted.raw['overview.IMSI']).toBe(REDACTED); + expect(redacted.raw['overview.ICCID']).toBe(REDACTED); + expect(redacted.raw['sysinfo.cputemp']).toBe('46'); + expect(redacted.unmapped).toEqual(UFI.diagnostics.unmapped); + }); +}); + +describe('no raw vendor field is dropped during normalization', () => { + const roundTrips = [ + ['modemmanager', MM, 'Modem.Ports', ['ttyUSB0', 'wwan0']], + ['modemmanager', MM, 'Modem3gpp.Pco', 'dns-primary=10.0.0.1'], + ['modemmanager', MM, 'Sim.OperatorName', 'CLARO COL'], + ['modemmanager', MM, 'Signal.refresh_rate', 5], + ['huawei-hilink', HILINK, 'monitoring-status.CurrentNetworkTypeEx', '101'], + ['huawei-hilink', HILINK, 'device-signal.TotalDownload', '987654321'], + ['huawei-hilink', HILINK, 'monitoring-status.ConnectionStatus', '901'], + ['zte-goform', ZTE, 'goform.wan_lte_ca', 'ca_deactivated'], + ['zte-goform', ZTE, 'goform.lte_pci', '188'], + ['zte-goform', ZTE, 'goform.rmcc', '732'], + ['ufi-himiapi', UFI, 'sysinfo.cputemp', '46'], + ['ufi-himiapi', UFI, 'overview.WEBVER', 'V1.0.7'], + ['ufi-himiapi', UFI, 'status.battery', '88'], + ] as const; + + test.each(roundTrips.map(([source, , key]) => `${source} ${key}`))( + 'Given %s, when normalized, then the vendor field survives verbatim in diagnostics', + (label) => { + const entry = roundTrips.find(([source, , key]) => `${source} ${key}` === label); + if (entry === undefined) { + throw new Error(`missing round trip ${label}`); + } + const [, normalized, key, value] = entry; + + expect(normalized.diagnostics.raw[key]).toEqual(value); + expect(normalized.diagnostics.unmapped).toContain(key); + }, + ); + + test('Given a repeated XML tag, when flattened, then later occurrences are kept under a suffix', () => { + expect(HILINK.diagnostics.raw['net-mode-list.Index']).toBe('00'); + expect(HILINK.diagnostics.raw['net-mode-list.Index#2']).toBe('03'); + expect(HILINK.diagnostics.raw['net-mode-list.Name']).toBe('AUTO'); + expect(HILINK.diagnostics.raw['net-mode-list.Name#2']).toBe('LTE'); + }); + + test('Given a consumed field, when normalized, then it is still present in the raw record', () => { + for (const normalized of [MM, HILINK, ZTE, UFI]) { + for (const key of normalized.diagnostics.consumed) { + expect(Object.hasOwn(normalized.diagnostics.raw, key)).toBe(true); + } + } + }); +}); diff --git a/control/src/observations/observation-states.test.ts b/control/src/observations/observation-states.test.ts new file mode 100644 index 0000000..43bb2ae --- /dev/null +++ b/control/src/observations/observation-states.test.ts @@ -0,0 +1,219 @@ +import { describe, expect, test } from 'bun:test'; +import { + FIXTURE_GENERATION, + FIXTURE_OBSERVED_AT, + FIXTURE_SOURCE_EPOCH, + fixtureContext, +} from '../../test-support/observation-fixtures'; +import { deviceGeneration, epochMillis, type ObservationEnvelope, sourceEpoch } from '../domain'; +import { freshObservation, metricProvenance, unavailableObservation } from './envelope'; +import { evaluateFreshness, observationAgeMs } from './freshness'; +import { + isCapabilityUnknown, + knownMetric, + METRIC_UNKNOWN_REASONS, + metricUnknownClass, + metricUnknownReasonFromRouter, + type NormalizedMetric, + unknownMetric, +} from './metric'; +import { hasReadableValue, readMetric } from './reading'; + +type Sample = { readonly reading: NormalizedMetric }; + +const CONTEXT = fixtureContext(); +const WINDOW = { ttlMs: 30_000 }; +const PROVENANCE = metricProvenance('modemmanager', CONTEXT, ['Modem.SignalQuality']); + +function sampleEnvelope(metric: NormalizedMetric): ObservationEnvelope { + return freshObservation('modemmanager', CONTEXT, { reading: metric }); +} + +const KNOWN = sampleEnvelope(knownMetric(71, PROVENANCE)); + +describe('freshness windows', () => { + test('Given a fresh observation inside its window, when evaluated, then it stays fresh', () => { + const evaluated = evaluateFreshness(KNOWN, { + at: epochMillis(FIXTURE_OBSERVED_AT + WINDOW.ttlMs), + window: WINDOW, + }); + + expect(evaluated.freshness.state).toBe('fresh'); + expect(evaluated.value).toEqual(KNOWN.value); + }); + + test('Given a fresh observation aged past its window, when evaluated, then it reports stale and KEEPS its value', () => { + const evaluated = evaluateFreshness(KNOWN, { + at: epochMillis(FIXTURE_OBSERVED_AT + WINDOW.ttlMs + 1), + window: WINDOW, + }); + + expect(evaluated.freshness.state).toBe('stale'); + expect(evaluated.value).not.toBeNull(); + expect(readMetric(evaluated, (value) => value.reading)).toMatchObject({ + state: 'stale', + value: 71, + reason: 'ttl-expired', + }); + }); + + test('Given an aged observation, when it goes stale, then `since` is the moment the window closed', () => { + const evaluated = evaluateFreshness(KNOWN, { + at: epochMillis(FIXTURE_OBSERVED_AT + 5 * WINDOW.ttlMs), + window: WINDOW, + }); + + expect(evaluated.freshness).toEqual({ + state: 'stale', + since: epochMillis(FIXTURE_OBSERVED_AT + WINDOW.ttlMs), + reason: 'ttl-expired', + }); + }); + + test('Given a superseded generation, when evaluated, then supersession outranks TTL expiry', () => { + const evaluated = evaluateFreshness(KNOWN, { + at: epochMillis(FIXTURE_OBSERVED_AT + 10 * WINDOW.ttlMs), + window: WINDOW, + currentGeneration: deviceGeneration(FIXTURE_GENERATION + 1), + }); + + expect(evaluated.freshness).toEqual({ + state: 'stale', + since: epochMillis(FIXTURE_OBSERVED_AT + 10 * WINDOW.ttlMs), + reason: 'source-epoch-superseded', + }); + }); + + test('Given a superseded source epoch, when evaluated inside the window, then it is stale anyway', () => { + const evaluated = evaluateFreshness(KNOWN, { + at: FIXTURE_OBSERVED_AT, + window: WINDOW, + currentSourceEpoch: sourceEpoch(FIXTURE_SOURCE_EPOCH + 1), + }); + + expect(evaluated.freshness).toMatchObject({ reason: 'source-epoch-superseded' }); + }); + + test('Given a degraded source, when evaluated inside the window, then it is stale for that reason', () => { + const evaluated = evaluateFreshness(KNOWN, { + at: FIXTURE_OBSERVED_AT, + window: WINDOW, + sourceHealthy: false, + }); + + expect(evaluated.freshness).toMatchObject({ reason: 'source-degraded' }); + }); + + test('Given an already stale observation, when re-evaluated, then its first cause survives', () => { + const stale = evaluateFreshness(KNOWN, { + at: epochMillis(FIXTURE_OBSERVED_AT + WINDOW.ttlMs + 1), + window: WINDOW, + }); + const again = evaluateFreshness(stale, { + at: epochMillis(FIXTURE_OBSERVED_AT + 10 * WINDOW.ttlMs), + window: WINDOW, + sourceHealthy: false, + }); + + expect(again).toBe(stale); + }); + + test('Given a clock that moved backwards, when the age is taken, then it floors at zero', () => { + expect(observationAgeMs(KNOWN, epochMillis(FIXTURE_OBSERVED_AT - 5_000))).toBe(0); + }); +}); + +describe('stale, unavailable and unknown are three distinct states', () => { + const stale = evaluateFreshness(KNOWN, { + at: epochMillis(FIXTURE_OBSERVED_AT + WINDOW.ttlMs + 1), + window: WINDOW, + }); + const unavailable = unavailableObservation('modemmanager', CONTEXT, 'device-absent'); + const unknown = sampleEnvelope(unknownMetric('not-reported', PROVENANCE)); + + const staleReading = readMetric(stale, (value) => value.reading); + const unavailableReading = readMetric(unavailable, (value) => value.reading); + const unknownReading = readMetric(unknown, (value) => value.reading); + + test('Given the three cases, when read, then their discriminants differ', () => { + expect([staleReading.state, unavailableReading.state, unknownReading.state]).toEqual([ + 'stale', + 'unavailable', + 'unknown', + ]); + }); + + test('Given the three cases, when read, then only stale carries a value', () => { + expect(hasReadableValue(staleReading)).toBe(true); + expect(hasReadableValue(unavailableReading)).toBe(false); + expect(hasReadableValue(unknownReading)).toBe(false); + expect('value' in unavailableReading).toBe(false); + expect('value' in unknownReading).toBe(false); + }); + + test('Given an unavailable observation, when read, then no metric provenance is invented', () => { + expect('provenance' in unavailableReading).toBe(false); + expect(unavailable.value).toBeNull(); + expect(unavailableReading.envelope.observedAt).toBe(FIXTURE_OBSERVED_AT); + }); + + test('Given an unavailable observation, when aged past the window, then it never becomes stale', () => { + const aged = evaluateFreshness(unavailable, { + at: epochMillis(FIXTURE_OBSERVED_AT + 100 * WINDOW.ttlMs), + window: WINDOW, + sourceHealthy: false, + }); + + expect(aged.freshness).toEqual({ + state: 'unavailable', + since: FIXTURE_OBSERVED_AT, + reason: 'device-absent', + }); + expect(aged.value).toBeNull(); + }); + + test('Given an unknown metric in a stale envelope, when read, then it reads unknown rather than stale', () => { + const staleUnknown = evaluateFreshness( + sampleEnvelope(unknownMetric('auth-expired', PROVENANCE)), + { at: epochMillis(FIXTURE_OBSERVED_AT + WINDOW.ttlMs + 1), window: WINDOW }, + ); + const reading = readMetric(staleUnknown, (value) => value.reading); + + expect(reading.state).toBe('unknown'); + expect(reading.envelope.observedAt).toBe(FIXTURE_OBSERVED_AT); + }); +}); + +describe('unknown is never coerced to unsupported', () => { + test('Given every unknown reason, when classified, then only `unsupported` is a capability claim', () => { + const capability = METRIC_UNKNOWN_REASONS.filter(isCapabilityUnknown); + + expect(capability).toEqual(['unsupported']); + expect(METRIC_UNKNOWN_REASONS.map(metricUnknownClass)).toEqual( + METRIC_UNKNOWN_REASONS.map((reason) => (reason === 'unsupported' ? 'capability' : 'read')), + ); + }); + + test('Given a migrated router reason, when carried across, then it is preserved verbatim', () => { + const routerReasons = [ + 'unsupported', + 'not-reported', + 'malformed', + 'auth-expired', + 'unreachable', + 'refused', + ] as const; + + for (const reason of routerReasons) { + expect(metricUnknownReasonFromRouter(reason)).toBe(reason); + } + }); + + test('Given a read-class unknown, when compared to a capability claim, then they are different values', () => { + const notReported = unknownMetric('not-reported', PROVENANCE); + const unsupported = unknownMetric('unsupported', PROVENANCE); + + expect(notReported).not.toEqual(unsupported); + expect(metricUnknownClass('not-reported')).not.toBe(metricUnknownClass('unsupported')); + }); +}); diff --git a/control/src/observations/provenance.ts b/control/src/observations/provenance.ts new file mode 100644 index 0000000..d74fe10 --- /dev/null +++ b/control/src/observations/provenance.ts @@ -0,0 +1,141 @@ +// Provenance + diagnostics — where a normalized value came from, and everything the +// provider said that the normalized model has no field for. +// +// The rule this module exists to enforce: normalization NARROWS a vendor payload into +// one shape, it does not DISCARD it. Every provider-native field a normalizer reads — +// and every one it does not — is retained verbatim in `ObservationDiagnostics.raw`, +// and each normalized metric carries the provenance of the exact observation that +// produced it. A consumer can therefore always answer "which provider said this, and +// when" without the provider having to widen the normalized model. + +import type { EpochMillis, ObservationAuthority, SourceEpoch } from '../domain'; +import { redact } from '../redact'; + +/** + * The provider families this layer normalizes. + * + * These are SOURCE SHAPES, not live transports. Todo 18 builds the normalization + * layer; the providers that will feed it (ModemManager D-Bus, the HiLink/goform/HIMI + * HTTP sessions) are separate work and open no connection from here. + * + * `networkmanager` is here because NM is a source of a DIFFERENT fact than the other + * four: it reports which connection is in force on which interface, never a radio + * reading. Giving it its own kind is what keeps a bearer observation attributable to + * NM rather than laundered through whichever provider happened to read the modem. + */ +export const OBSERVATION_SOURCE_KINDS = [ + 'modemmanager', + 'huawei-hilink', + 'zte-goform', + 'ufi-himiapi', + 'networkmanager', +] as const; +export type ObservationSourceKind = (typeof OBSERVATION_SOURCE_KINDS)[number]; + +/** + * A provider-native value, kept exactly as the provider expressed it. + * + * Arrays are part of the union because ModemManager genuinely returns them + * (`Modem.SimSlots` is an `ao`); flattening one to its first member here would be a + * silent drop of exactly the kind this module exists to prevent. + */ +export type RawFieldValue = string | number | boolean | null | readonly RawFieldValue[]; + +/** Provider-native fields keyed by their provider-native names. */ +export type RawFieldRecord = Readonly>; + +/** + * Per-metric provenance: which source, which reading of it, and when. + * + * It is carried on EVERY metric rather than only on the envelope because one + * normalized observation routinely folds several provider reads together (HiLink + * answers `device_signal` and `monitoring_status` separately; UFI answers three + * endpoints), so a single envelope-level `observedAt` would be a claim about a + * reading no individual metric came from. + */ +export type MetricProvenance = { + readonly source: ObservationSourceKind; + readonly sourceEpoch: SourceEpoch; + readonly observedAt: EpochMillis; + readonly authority: ObservationAuthority; + /** The provider-native field name(s) this value was normalized from. */ + readonly rawFields: readonly string[]; +}; + +/** + * Structural complaints about a payload. A note names the FIELD or body it concerns + * and never carries its value: a body can hold an ICCID or a one-time code, and a + * diagnostic is the one place a reviewer reads verbatim. + */ +export const OBSERVATION_DIAGNOSTIC_CODES = [ + 'unparseable-body', + 'empty-body', + 'auth-expired', + 'field-shape-unrecognized', +] as const; +export type ObservationDiagnosticCode = (typeof OBSERVATION_DIAGNOSTIC_CODES)[number]; + +export type ObservationDiagnosticNote = { + readonly code: ObservationDiagnosticCode; + /** The provider-native field or body name, never its content. */ + readonly field: string; +}; + +/** + * The typed diagnostics block: the provider payload, verbatim, plus which fields the + * normalizer claimed and which it did not. + * + * `raw` is a REDACTION-CLASS boundary. A vendor payload routinely carries an ICCID or + * an IMSI (the UFI overview endpoint reports both), so anything that logs, serializes + * or files a diagnostics block must route it through {@link redactObservationDiagnostics} + * first. Retention and disclosure are different decisions; this layer only guarantees + * the first. + */ +export type ObservationDiagnostics = { + readonly source: ObservationSourceKind; + /** Every provider-native field, verbatim. Nothing is dropped during normalization. */ + readonly raw: RawFieldRecord; + /** Fields a normalized metric claims. They remain present in `raw` as well. */ + readonly consumed: readonly string[]; + /** Fields in `raw` that no normalized field claims — retained, never discarded. */ + readonly unmapped: readonly string[]; + readonly notes: readonly ObservationDiagnosticNote[]; +}; + +export type DiagnosticsInput = { + readonly source: ObservationSourceKind; + readonly raw: RawFieldRecord; + readonly consumed?: readonly string[]; + readonly notes?: readonly ObservationDiagnosticNote[]; +}; + +/** + * Build a diagnostics block, deriving `unmapped` rather than accepting it. + * + * Deriving it is what makes the no-drop property structural: a normalizer cannot + * declare a field mapped without a metric that names it, and a field it never + * mentions lands in `unmapped` automatically instead of vanishing. + */ +export function createObservationDiagnostics(input: DiagnosticsInput): ObservationDiagnostics { + const rawKeys = Object.keys(input.raw); + const consumed = [...new Set(input.consumed ?? [])].filter((key) => rawKeys.includes(key)).sort(); + const consumedSet = new Set(consumed); + return { + source: input.source, + raw: input.raw, + consumed, + unmapped: rawKeys.filter((key) => !consumedSet.has(key)).sort(), + notes: input.notes ?? [], + }; +} + +/** + * A copy of `diagnostics` with every sensitive raw field replaced by the shared + * redaction marker. Routed through the package's own key-based `redact`, so the + * classes it masks here are exactly the classes it masks everywhere else. + */ +export function redactObservationDiagnostics( + diagnostics: ObservationDiagnostics, +): ObservationDiagnostics { + return { ...diagnostics, raw: redact(diagnostics.raw) as RawFieldRecord }; +} diff --git a/control/src/observations/raw.ts b/control/src/observations/raw.ts new file mode 100644 index 0000000..0226f37 --- /dev/null +++ b/control/src/observations/raw.ts @@ -0,0 +1,174 @@ +// Raw-payload helpers — the retention half of normalization. +// +// Every normalizer builds ONE flat `RawFieldRecord` out of whatever bodies it was +// handed, keyed by `.`, and reads its metrics out of +// that same record. Reading from the retained copy rather than from the original +// bodies is what makes the no-drop property hold by construction instead of by +// discipline: a field a metric consumed is necessarily a field the diagnostics block +// already carries. + +import type { RawFieldRecord, RawFieldValue } from './provenance'; + +export function rawKey(body: string, field: string): string { + return `${body}.${field}`; +} + +/** Prefix a provider's flat record with the body it came from. */ +export function prefixRawRecord( + body: string, + record: Readonly> | undefined, +): RawFieldRecord { + const out: Record = {}; + for (const [field, value] of Object.entries(record ?? {})) { + out[rawKey(body, field)] = value; + } + return out; +} + +export function mergeRawRecords(...records: readonly RawFieldRecord[]): RawFieldRecord { + return Object.assign({}, ...records) as RawFieldRecord; +} + +export function rawString(record: RawFieldRecord, key: string): string | undefined { + const value = record[key]; + if (typeof value === 'string') { + const trimmed = value.trim(); + return trimmed === '' ? undefined : trimmed; + } + return typeof value === 'number' || typeof value === 'boolean' ? String(value) : undefined; +} + +export function rawNumber(record: RawFieldRecord, key: string): number | undefined { + const value = record[key]; + if (typeof value === 'number') { + return Number.isFinite(value) ? value : undefined; + } + if (typeof value !== 'string' || value.trim() === '') { + return undefined; + } + const parsed = Number.parseFloat(value); + return Number.isFinite(parsed) ? parsed : undefined; +} + +export function rawStringArray(record: RawFieldRecord, key: string): readonly string[] | undefined { + const value = record[key]; + return Array.isArray(value) + ? value.filter((item): item is string => typeof item === 'string') + : undefined; +} + +export function hasRawField(record: RawFieldRecord, key: string): boolean { + return Object.hasOwn(record, key); +} + +/** + * One member of a D-Bus STRUCT retained verbatim. + * + * ModemManager's `SignalQuality` is a `(ub)` and its `CurrentModes` is a `(uu)`, so + * retaining them verbatim means the raw record holds an ARRAY where a naive read + * expects a scalar. A caller that flattened the struct before retention would keep the + * first member and silently drop the second — the recency flag, and the preferred mode + * — which is the drop this layer exists to prevent. A scalar answers at index 0, so a + * source that already flattened (mmcli, or a pre-existing fixture) still decodes. + */ +export function rawStructMember( + record: RawFieldRecord, + key: string, + index: number, +): RawFieldValue | undefined { + const value = record[key]; + if (Array.isArray(value)) return value[index]; + return index === 0 ? value : undefined; +} + +export function rawNumberAt( + record: RawFieldRecord, + key: string, + index: number, +): number | undefined { + const member = rawStructMember(record, key, index); + if (typeof member === 'number') return Number.isFinite(member) ? member : undefined; + if (typeof member !== 'string' || member.trim() === '') return undefined; + const parsed = Number.parseFloat(member); + return Number.isFinite(parsed) ? parsed : undefined; +} + +export function rawBooleanAt( + record: RawFieldRecord, + key: string, + index: number, +): boolean | undefined { + const member = rawStructMember(record, key, index); + return typeof member === 'boolean' ? member : undefined; +} + +const XML_LEAF = /<([A-Za-z_][\w.-]*)>([^<]*)<\/\1>/g; + +/** + * Flatten every leaf element of an XML body into `.` entries. + * + * A repeated tag — HiLink's `` list repeats `` once per mode — + * would otherwise overwrite its predecessors, so the second and later occurrences are + * suffixed `#2`, `#3`, …. Losing a repeat is exactly the silent drop this layer + * exists to prevent, and the suffix keeps the original tag name legible. + */ +export function flattenXmlBody(body: string, name: string): RawFieldRecord { + const out: Record = {}; + const seen = new Map(); + for (const match of body.matchAll(XML_LEAF)) { + const tag = match[1] ?? ''; + const count = (seen.get(tag) ?? 0) + 1; + seen.set(tag, count); + out[rawKey(name, count === 1 ? tag : `${tag}#${count}`)] = (match[2] ?? '').trim(); + } + return out; +} + +/** Parse a JSON object body, or `undefined` when the bytes are not a JSON object. */ +export function parseJsonObject(body: string): Readonly> | undefined { + let parsed: unknown; + try { + parsed = JSON.parse(body); + } catch (error) { + if (!(error instanceof SyntaxError)) { + throw error; + } + return undefined; + } + return typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed) + ? (parsed as Readonly>) + : undefined; +} + +/** Parse a JSON object body into a flat record, or `undefined` when it is not one. */ +export function parseJsonRecord(body: string): Readonly> | undefined { + const parsed = parseJsonObject(body); + if (parsed === undefined) { + return undefined; + } + const out: Record = {}; + for (const [field, value] of Object.entries(parsed)) { + out[field] = normalizeRawValue(value); + } + return out; +} + +/** + * A nested object has no flat representation, so its JSON text is kept. + * + * Serializing rather than dropping is the whole contract of this layer: a payload + * this record cannot model structurally is still a payload a diagnostician can read. + */ +export function normalizeRawValue(value: unknown): RawFieldValue { + if (value === null) { + return null; + } + if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') { + return value; + } + if (Array.isArray(value)) { + return value.map(normalizeRawValue); + } + // A nested object has no flat representation; keep its JSON text rather than drop it. + return JSON.stringify(value); +} diff --git a/control/src/observations/reading.ts b/control/src/observations/reading.ts new file mode 100644 index 0000000..83d8f46 --- /dev/null +++ b/control/src/observations/reading.ts @@ -0,0 +1,128 @@ +// Reading — the projection a consumer renders from. +// +// It is the one place the envelope's freshness and the metric's knownness are folded +// together, and it keeps FOUR genuinely distinct outcomes rather than a value plus a +// flag: +// +// `fresh` — a current value. +// `stale` — a RETAINED value that has aged out, with when and why. +// `unavailable` — there is no observation at all; no value exists to report. +// `unknown` — there IS an observation and this field is not in it, with a reason +// that says whether the source CANNOT report it or merely DID NOT. +// +// They differ in shape, not only in label: `unavailable` and `unknown` carry no +// `value` field at all, so no consumer can read one off a state that has none, and +// `unavailable` carries no metric provenance because no metric was produced. + +import type { + DeviceGeneration, + EpochMillis, + ObservationAuthority, + ObservationEnvelope, + SourceEpoch, + StableKey, +} from '../domain'; +import { + type ObservationStaleReason, + type ObservationUnavailableReason, + viewEnvelope, +} from './envelope'; +import type { MetricUnknownReason, NormalizedMetric } from './metric'; +import type { MetricProvenance } from './provenance'; + +/** The envelope-level provenance every reading carries, whatever its state. */ +export type EnvelopeProvenance = { + readonly stableKey: StableKey; + readonly generation: DeviceGeneration; + readonly source: string; + readonly sourceEpoch: SourceEpoch; + readonly observedAt: EpochMillis; + readonly authority: ObservationAuthority; +}; + +export function envelopeProvenance(envelope: ObservationEnvelope): EnvelopeProvenance { + return { + stableKey: envelope.stableKey, + generation: envelope.generation, + source: envelope.source, + sourceEpoch: envelope.sourceEpoch, + observedAt: envelope.observedAt, + authority: envelope.authority, + }; +} + +type ReadingBase = { readonly envelope: EnvelopeProvenance }; + +export type ObservationReading = + | (ReadingBase & { + readonly state: 'fresh'; + readonly value: T; + readonly provenance: MetricProvenance; + }) + | (ReadingBase & { + readonly state: 'stale'; + readonly value: T; + readonly since: EpochMillis; + readonly reason: ObservationStaleReason; + readonly provenance: MetricProvenance; + }) + | (ReadingBase & { + readonly state: 'unavailable'; + readonly since: EpochMillis; + readonly reason: ObservationUnavailableReason; + }) + | (ReadingBase & { + readonly state: 'unknown'; + readonly reason: MetricUnknownReason; + readonly provenance: MetricProvenance; + }); + +export const OBSERVATION_READING_STATES = ['fresh', 'stale', 'unavailable', 'unknown'] as const; +export type ObservationReadingState = (typeof OBSERVATION_READING_STATES)[number]; + +/** + * Project one metric out of an envelope. + * + * An unknown metric inside a STALE envelope reads `unknown`, not `stale`: staleness is + * a statement about a value's age, and there is no value here to have aged. The + * envelope's own age remains readable through `reading.envelope.observedAt`. + */ +export function readMetric( + envelope: ObservationEnvelope, + select: (value: T) => NormalizedMetric, +): ObservationReading { + const base: ReadingBase = { envelope: envelopeProvenance(envelope) }; + const view = viewEnvelope(envelope); + + if (view.kind === 'unavailable') { + return { + ...base, + state: 'unavailable', + since: view.freshness.since, + reason: view.freshness.reason, + }; + } + + const metric = select(view.value); + if (metric.state === 'unknown') { + return { ...base, state: 'unknown', reason: metric.reason, provenance: metric.provenance }; + } + if (view.freshness.state === 'stale') { + return { + ...base, + state: 'stale', + value: metric.value, + since: view.freshness.since, + reason: view.freshness.reason, + provenance: metric.provenance, + }; + } + return { ...base, state: 'fresh', value: metric.value, provenance: metric.provenance }; +} + +/** Whether a reading carries a value a consumer may render. */ +export function hasReadableValue( + reading: ObservationReading, +): reading is Extract, { readonly state: 'fresh' | 'stale' }> { + return reading.state === 'fresh' || reading.state === 'stale'; +} diff --git a/control/src/observations/sim-evidence.test.ts b/control/src/observations/sim-evidence.test.ts new file mode 100644 index 0000000..8f28771 --- /dev/null +++ b/control/src/observations/sim-evidence.test.ts @@ -0,0 +1,271 @@ +// EXPLICIT no-SIM evidence — the regression this suite locks down. +// +// "There is no SIM" and "we could not tell" are read off the SAME empty fields, and +// only the evidence separates them. The failure mode is silent and expensive: an +// operator is told to re-seat a SIM that is already seated, or a slot switch in +// flight is reported as a missing card. So `absent` is reachable through exactly ONE +// evidence kind here, and the control cases below prove nothing else produces it. + +import { describe, expect, test } from 'bun:test'; +import { + fixtureContext, + HILINK_FIXTURE, + MM_FIXTURE, + UFI_FIXTURE, + ZTE_FIXTURE, +} from '../../test-support/observation-fixtures'; +import { decodeStateFailedReason } from '../domain'; +import { deriveSimPresence, readSimPresence } from '../hardware/router-parsers'; +import { viewEnvelope } from './envelope'; +import type { NormalizedModemObservation } from './model'; +import { normalizeHilinkObservation } from './sources/hilink'; +import { normalizeModemManagerObservation } from './sources/modemmanager'; +import { normalizeUfiObservation } from './sources/ufi'; +import { normalizeZteObservation } from './sources/zte'; + +const SIM_PATH = '/org/freedesktop/ModemManager1/SIM/0'; + +/** A modem with no SIM, exactly as ModemManager 1.24 reports one: `/` plus a `u` reason. */ +const NO_SIM_MODEM = { + Model: 'RM530N-GL', + Manufacturer: 'Quectel', + State: -1, + StateFailedReason: 2, + Sim: '/', + SimSlots: ['/'], +}; + +/** The SAME modem before it failed: blank SIM path, and NO failure reason at all. */ +const BLANK_SIM_MODEM = { + Model: 'RM530N-GL', + Manufacturer: 'Quectel', + State: 1, + Sim: '/', + SimSlots: ['/'], +}; + +/** The normalized value, or a hard failure — an unavailable envelope answers nothing here. */ +function valued( + envelope: ReturnType, +): NormalizedModemObservation { + const view = viewEnvelope(envelope); + if (view.kind !== 'valued' || view.freshness.state !== 'fresh') { + throw new Error('expected a fresh, valued observation'); + } + return view.value; +} + +const observe = (modem: Record) => + normalizeModemManagerObservation({ modem: modem as never, sim: {} }, fixtureContext()); + +const simOf = (modem: Record) => valued(observe(modem)).sim; + +describe('the presence rule itself', () => { + test('`absent` comes from the failure reason and from nothing else', () => { + expect(readSimPresence({ failedReason: 'sim-missing' })).toEqual({ + presence: 'absent', + evidence: { kind: 'state-failed-reason', field: 'failedReason', value: 'sim-missing' }, + }); + }); + + test('a blank SIM path with no reason is UNKNOWN, never absent', () => { + expect(readSimPresence({ sim: '/', simSlots: ['/'] })).toEqual({ + presence: 'unknown', + evidence: { kind: 'no-evidence', inspected: ['sim', 'simSlots', 'failedReason'] }, + }); + }); + + test('a completely empty fact set is UNKNOWN, never absent', () => { + expect(readSimPresence({}).presence).toBe('unknown'); + }); + + test('a failure reason that is not `sim-missing` proves nothing about the SIM', () => { + for (const failedReason of ['sim-error', 'unknown-capabilities', 'esim-without-profiles']) { + expect(readSimPresence({ sim: '/', failedReason }).presence).toBe('unknown'); + } + }); + + test('presence is proven by an object path, and the path is named as the evidence', () => { + expect(readSimPresence({ sim: SIM_PATH })).toEqual({ + presence: 'present', + evidence: { kind: 'sim-object-path', field: 'sim', value: SIM_PATH }, + }); + expect(readSimPresence({ sim: '/', simSlots: ['/', SIM_PATH] })).toEqual({ + presence: 'present', + evidence: { kind: 'sim-slot-object-path', field: 'simSlots', value: SIM_PATH }, + }); + }); + + test('an active SIM outranks a `sim-missing` reason left over from an earlier failure', () => { + expect(readSimPresence({ sim: SIM_PATH, failedReason: 'sim-missing' }).presence).toBe( + 'present', + ); + }); + + test('the migrated `deriveSimPresence` answers identically', () => { + for (const facts of [ + { failedReason: 'sim-missing' }, + { sim: '/', simSlots: ['/'] }, + { sim: SIM_PATH }, + {}, + ]) { + expect(deriveSimPresence(facts)).toBe(readSimPresence(facts).presence); + } + }); +}); + +describe('the D-Bus numeric failure reason is decoded, which is what makes absence readable', () => { + test('MMModemStateFailedReason 2 is `sim-missing`', () => { + expect(decodeStateFailedReason(2)).toBe('sim-missing'); + }); + + test('the other members decode to themselves and none of them is `sim-missing`', () => { + expect(decodeStateFailedReason(0)).toBe('none'); + expect(decodeStateFailedReason(1)).toBe('unknown-reason'); + expect(decodeStateFailedReason(3)).toBe('sim-error'); + expect(decodeStateFailedReason(4)).toBe('unknown-capabilities'); + expect(decodeStateFailedReason(5)).toBe('esim-without-profiles'); + }); + + test('a value this build cannot place proves nothing', () => { + expect(decodeStateFailedReason(99)).toBeUndefined(); + expect(decodeStateFailedReason(undefined)).toBeUndefined(); + }); +}); + +describe('ModemManager observation — the no-SIM regression', () => { + test('a `u` StateFailedReason of 2 yields an EXPLICIT absent reading', () => { + const sim = simOf(NO_SIM_MODEM); + expect(sim.presence).toEqual({ + state: 'known', + value: 'absent', + provenance: expect.objectContaining({ source: 'modemmanager' }), + }); + expect(sim.presenceEvidence).toEqual({ + kind: 'state-failed-reason', + field: 'failedReason', + value: 'sim-missing', + }); + }); + + test('THE CONTROL: the same blank fields WITHOUT the reason are never absent', () => { + const sim = simOf(BLANK_SIM_MODEM); + expect(sim.presence.state).toBe('unknown'); + expect(sim.presenceEvidence.kind).toBe('no-evidence'); + if (sim.presence.state !== 'unknown') return; + expect(sim.presence.reason).toBe('not-reported'); + }); + + test('an unknown presence carries a READ-class reason, so a control is not hidden', () => { + const sim = simOf(BLANK_SIM_MODEM); + if (sim.presence.state !== 'unknown') throw new Error('expected unknown'); + expect(sim.presence.reason).not.toBe('unsupported'); + }); + + test('the raw failure reason is retained verbatim in the diagnostics block', () => { + const envelope = observe(NO_SIM_MODEM); + expect(valued(envelope).diagnostics.raw['Modem.StateFailedReason']).toBe(2); + }); + + test('a present SIM names the object path that proved it', () => { + const sim = simOf(MM_FIXTURE.modem as Record); + expect(sim.presence).toMatchObject({ state: 'known', value: 'present' }); + expect(sim.presenceEvidence).toEqual({ + kind: 'sim-object-path', + field: 'sim', + value: SIM_PATH, + }); + }); +}); + +describe('router sources never claim a presence and say which code they left alone', () => { + test('HiLink names its own `SimStatus` without decoding it', () => { + const envelope = normalizeHilinkObservation(HILINK_FIXTURE, fixtureContext()); + expect(valued(envelope).sim.presence.state).toBe('unknown'); + expect(valued(envelope).sim.presenceEvidence).toEqual({ + kind: 'vendor-code-unclaimed', + field: 'monitoring-status.SimStatus', + }); + }); + + test('a vendor code that is named is still UNMAPPED — retained, not consumed', () => { + const envelope = normalizeHilinkObservation(HILINK_FIXTURE, fixtureContext()); + expect(valued(envelope).diagnostics.unmapped).toContain('monitoring-status.SimStatus'); + expect(valued(envelope).diagnostics.consumed).not.toContain('monitoring-status.SimStatus'); + }); + + test('ZTE and UFI report an unknown presence with no evidence of absence', () => { + for (const envelope of [ + normalizeZteObservation(ZTE_FIXTURE, fixtureContext()), + normalizeUfiObservation(UFI_FIXTURE, fixtureContext()), + ]) { + expect(valued(envelope).sim.presence.state).toBe('unknown'); + expect(valued(envelope).sim.presenceEvidence.kind).not.toBe('state-failed-reason'); + } + }); + + test('NO source can produce `absent` from a router payload', () => { + for (const envelope of [ + normalizeHilinkObservation(HILINK_FIXTURE, fixtureContext()), + normalizeZteObservation(ZTE_FIXTURE, fixtureContext()), + normalizeUfiObservation(UFI_FIXTURE, fixtureContext()), + ]) { + expect(valued(envelope).sim.presence).not.toMatchObject({ value: 'absent' }); + } + }); +}); + +describe('signal normalization is finalized on the same layer', () => { + test('ModemManager`s `(ub)` SignalQuality decodes BOTH members', () => { + const envelope = normalizeModemManagerObservation( + { modem: { SignalQuality: [71, true] } }, + fixtureContext(), + ); + expect(valued(envelope).signal.quality).toMatchObject({ state: 'known', value: 71 }); + expect(valued(envelope).signal.qualityRecent).toMatchObject({ state: 'known', value: true }); + }); + + test('the `(ub)` pair is retained VERBATIM in the diagnostics block', () => { + const envelope = normalizeModemManagerObservation( + { modem: { SignalQuality: [71, false] } }, + fixtureContext(), + ); + expect(valued(envelope).diagnostics.raw['Modem.SignalQuality']).toEqual([71, false]); + expect(valued(envelope).signal.qualityRecent).toMatchObject({ state: 'known', value: false }); + }); + + test('a flattened percentage still decodes, and the flag reads as a READ-class unknown', () => { + const envelope = normalizeModemManagerObservation( + { modem: { SignalQuality: 71 } }, + fixtureContext(), + ); + expect(valued(envelope).signal.quality).toMatchObject({ state: 'known', value: 71 }); + expect(valued(envelope).signal.qualityRecent).toMatchObject({ + state: 'unknown', + reason: 'not-reported', + }); + }); + + test('`CurrentModes` decodes from its `(uu)` pair as well as from a flat mask', () => { + for (const currentModes of [[(1 << 3) | (1 << 4), 0], (1 << 3) | (1 << 4)]) { + const envelope = normalizeModemManagerObservation( + { modem: { CurrentModes: currentModes as never } }, + fixtureContext(), + ); + expect(valued(envelope).radio.modeLabel).toMatchObject({ state: 'known', value: '5g4g' }); + } + }); + + test('the router sources state they have no recency flag at all', () => { + for (const envelope of [ + normalizeHilinkObservation(HILINK_FIXTURE, fixtureContext()), + normalizeZteObservation(ZTE_FIXTURE, fixtureContext()), + normalizeUfiObservation(UFI_FIXTURE, fixtureContext()), + ]) { + expect(valued(envelope).signal.qualityRecent).toMatchObject({ + state: 'unknown', + reason: 'unsupported', + }); + } + }); +}); diff --git a/control/src/observations/sources/hilink.ts b/control/src/observations/sources/hilink.ts new file mode 100644 index 0000000..1b285cb --- /dev/null +++ b/control/src/observations/sources/hilink.ts @@ -0,0 +1,130 @@ +// Huawei HiLink admin API → normalized observation. +// +// The decoding is the migrated `parseHilinkSignal` / `parseHilinkCapabilities`; this +// module adds provenance, retains both XML bodies field-for-field, and states the +// capability boundary honestly. HiLink is a ROUTER admin API, not a modem stack: it +// has no ModemManager state, no 3GPP registration state and no access-technology +// bitmask, so those read `unsupported` — a claim about this source, which stays true +// on the next poll — while a field the API could carry and did not reads +// `not-reported`. +// +// Its `` and `` are NOT folded into `radio.registration` +// or `sim.presence`. They are vendor codes with vendor semantics and no migrated +// decoder claims them, so they stay verbatim in the diagnostics block instead of +// being guessed into a normalized field. + +import type { ObservationEnvelope } from '../../domain'; +import { parseHilinkCapabilities, parseHilinkSignal } from '../../hardware/router-parsers'; +import { freshObservation, metricProvenance, type NormalizationContext } from '../envelope'; +import { + knownMetric, + metricFromRouterSignal, + metricUnknownReasonFromRouter, + type NormalizedMetric, + unknownMetric, +} from '../metric'; +import type { NormalizedModemObservation } from '../model'; +import { createObservationDiagnostics, type ObservationDiagnosticNote } from '../provenance'; +import { flattenXmlBody, hasRawField, mergeRawRecords, rawKey } from '../raw'; +import { + type RouterProvenance, + routerHardware, + routerSim, + unsupportedQualityRecent, + unsupportedRadioMetric, +} from './router-shared'; + +const SOURCE = 'huawei-hilink' as const; +const STATUS = 'monitoring-status'; +const SIGNAL = 'device-signal'; +const NET_MODE_LIST = 'net-mode-list'; +const NET_MODE = 'net-mode'; +/** HiLink's own SIM presence code. Named as evidence, never decoded into a presence. */ +const SIM_STATUS = rawKey(STATUS, 'SimStatus'); + +export type HilinkObservationInput = { + readonly status: string; + readonly signal: string; + readonly netModeList?: string; + readonly netMode?: string; +}; + +const AUTH_REFUSAL_CODE = '125002'; + +export function normalizeHilinkObservation( + input: HilinkObservationInput, + context: NormalizationContext, +): ObservationEnvelope { + const raw = mergeRawRecords( + flattenXmlBody(input.status, STATUS), + flattenXmlBody(input.signal, SIGNAL), + flattenXmlBody(input.netModeList ?? '', NET_MODE_LIST), + flattenXmlBody(input.netMode ?? '', NET_MODE), + ); + const notes: ObservationDiagnosticNote[] = []; + const consumed: string[] = []; + const provenance: RouterProvenance = (fields, authority) => { + consumed.push(...fields); + return authority === undefined + ? metricProvenance(SOURCE, context, fields) + : metricProvenance(SOURCE, context, fields, authority); + }; + + for (const [body, text] of [ + [STATUS, input.status], + [SIGNAL, input.signal], + ] as const) { + if (text.includes(`${AUTH_REFUSAL_CODE}`)) { + notes.push({ code: 'auth-expired', field: rawKey(body, 'code') }); + } + } + + const signalModel = parseHilinkSignal({ status: input.status, signal: input.signal }); + const capabilities = parseHilinkCapabilities({ + netModeList: input.netModeList ?? '', + ...(input.netMode === undefined ? {} : { netMode: input.netMode }), + }); + + return freshObservation(SOURCE, context, { + source: SOURCE, + hardware: routerHardware(provenance, undefined), + radio: { + modemState: unsupportedRadioMetric(provenance), + registration: unsupportedRadioMetric(provenance), + accessTechnologies: unknownMetric('unsupported', provenance([])), + modeLabel: normalizeModeLabel(capabilities.net_mode, provenance), + }, + signal: { + quality: unknownMetric('unsupported', provenance([])), + qualityRecent: unsupportedQualityRecent(provenance), + bars: metricFromRouterSignal( + signalModel.bars, + provenance([rawKey(STATUS, 'SignalIcon')], 'derived'), + ), + maxBars: metricFromRouterSignal( + signalModel.max_bars, + provenance([rawKey(STATUS, 'maxsignal')], 'derived'), + ), + dbm: metricFromRouterSignal(signalModel.dbm, provenance([rawKey(SIGNAL, 'rssi')])), + rsrp: metricFromRouterSignal(signalModel.rsrp, provenance([rawKey(SIGNAL, 'rsrp')])), + rsrq: metricFromRouterSignal(signalModel.rsrq, provenance([rawKey(SIGNAL, 'rsrq')])), + snr: metricFromRouterSignal(signalModel.snr, provenance([])), + sinr: metricFromRouterSignal(signalModel.sinr, provenance([rawKey(SIGNAL, 'sinr')])), + }, + sim: routerSim(provenance, hasRawField(raw, SIM_STATUS) ? SIM_STATUS : undefined), + diagnostics: createObservationDiagnostics({ source: SOURCE, raw, consumed, notes }), + }); +} + +function normalizeModeLabel( + capability: ReturnType['net_mode'], + provenance: RouterProvenance, +): NormalizedMetric { + const source = provenance([rawKey(NET_MODE, 'NetworkMode')]); + if (capability.state === 'unavailable') { + return unknownMetric(metricUnknownReasonFromRouter(capability.reason), source); + } + return capability.current === undefined + ? unknownMetric('not-reported', source) + : knownMetric(capability.current, source); +} diff --git a/control/src/observations/sources/modemmanager.ts b/control/src/observations/sources/modemmanager.ts new file mode 100644 index 0000000..50288d8 --- /dev/null +++ b/control/src/observations/sources/modemmanager.ts @@ -0,0 +1,345 @@ +// ModemManager → normalized observation. +// +// Every decode here is the MIGRATED logic (`domain/mm-enums.ts`, +// `domain/modem-presentation.ts`, `hardware/router-parsers.ts` for SIM presence) — +// this module adds provenance and reason-carrying unknowns, and decodes nothing +// itself. Where a decoder answers `'unknown'` or `undefined`, the distinction it +// cannot make is made here from the RAW record: a field that was never in the payload +// is `not-reported`, an interface nobody read is `not-observed`, and a field that WAS +// present but decoded to nothing is `malformed` with a diagnostic note. Collapsing +// those three into one absent value is precisely the loss this layer exists to stop. + +import { + decodeEsimStatus, + decodeMmAccessTechnologies, + decodeMmState, + decodeRegistrationState, + decodeSimType, + decodeStateFailedReason, + decodeUnlockRequired, + type ModemHardwareIdentity, + modeMaskToLabel, + modemHardwareLabel, + modemHardwareName, + type ObservationEnvelope, + type RadioAccessTechnology, +} from '../../domain'; +import { readSimPresence, type SimPresenceFacts } from '../../hardware/router-parsers'; +import { freshObservation, metricProvenance, type NormalizationContext } from '../envelope'; +import { knownMetric, type NormalizedMetric, unknownMetric } from '../metric'; +import type { NormalizedModemObservation, SimPresenceValue } from '../model'; +import { + createObservationDiagnostics, + type ObservationDiagnosticNote, + type RawFieldRecord, + type RawFieldValue, +} from '../provenance'; +import { + hasRawField, + mergeRawRecords, + prefixRawRecord, + rawBooleanAt, + rawKey, + rawNumber, + rawNumberAt, + rawString, + rawStringArray, +} from '../raw'; + +const SOURCE = 'modemmanager' as const; + +/** + * The decoded ModemManager property records, one per D-Bus interface. + * + * A body left out is a body nobody read, and reads as `not-observed` rather than as a + * modem that reports nothing — that difference decides whether a consumer retries or + * hides a control. + */ +export type ModemManagerObservationInput = { + readonly modem?: Readonly>; + readonly modem3gpp?: Readonly>; + readonly sim?: Readonly>; + readonly signal?: Readonly>; +}; + +const MODEM = 'Modem'; +const MODEM3GPP = 'Modem3gpp'; +const SIM = 'Sim'; +const SIGNAL = 'Signal'; + +export function normalizeModemManagerObservation( + input: ModemManagerObservationInput, + context: NormalizationContext, +): ObservationEnvelope { + const raw = mergeRawRecords( + prefixRawRecord(MODEM, input.modem), + prefixRawRecord(MODEM3GPP, input.modem3gpp), + prefixRawRecord(SIM, input.sim), + prefixRawRecord(SIGNAL, input.signal), + ); + const notes: ObservationDiagnosticNote[] = []; + const consumed: string[] = []; + const provenance = (...fields: readonly string[]) => { + consumed.push(...fields); + return metricProvenance(SOURCE, context, fields); + }; + + const hardware = normalizeHardware(raw, provenance); + const radio = normalizeRadio(raw, provenance, notes); + const signal = normalizeSignal(raw, input.signal !== undefined, provenance); + const sim = normalizeSim(raw, input.sim !== undefined, provenance, notes); + + return freshObservation(SOURCE, context, { + source: SOURCE, + hardware, + radio, + signal, + sim, + diagnostics: createObservationDiagnostics({ source: SOURCE, raw, consumed, notes }), + }); +} + +type Provenance = (...fields: readonly string[]) => ReturnType; + +const MODEL = rawKey(MODEM, 'Model'); +const MANUFACTURER = rawKey(MODEM, 'Manufacturer'); +const REVISION = rawKey(MODEM, 'Revision'); +const EQUIPMENT_ID = rawKey(MODEM, 'EquipmentIdentifier'); +const STATE = rawKey(MODEM, 'State'); +const CURRENT_MODES = rawKey(MODEM, 'CurrentModes'); +const ACCESS_TECHNOLOGIES = rawKey(MODEM, 'AccessTechnologies'); +const SIGNAL_QUALITY = rawKey(MODEM, 'SignalQuality'); +const SIM_PATH = rawKey(MODEM, 'Sim'); +const SIM_SLOTS = rawKey(MODEM, 'SimSlots'); +const FAILED_REASON = rawKey(MODEM, 'StateFailedReason'); +const UNLOCK_REQUIRED = rawKey(MODEM, 'UnlockRequired'); +const REGISTRATION_STATE = rawKey(MODEM3GPP, 'RegistrationState'); +const SIM_TYPE = rawKey(SIM, 'SimType'); +const ESIM_STATUS = rawKey(SIM, 'EsimStatus'); +const RSSI = rawKey(SIGNAL, 'rssi'); +const RSRP = rawKey(SIGNAL, 'rsrp'); +const RSRQ = rawKey(SIGNAL, 'rsrq'); +const SNR = rawKey(SIGNAL, 'snr'); + +function hardwareIdentity(raw: RawFieldRecord): ModemHardwareIdentity { + const model = rawString(raw, MODEL); + const manufacturer = rawString(raw, MANUFACTURER); + const firmwareRevision = rawString(raw, REVISION); + const equipmentId = rawString(raw, EQUIPMENT_ID); + return { + ...(model === undefined ? {} : { model }), + ...(manufacturer === undefined ? {} : { manufacturer }), + ...(firmwareRevision === undefined ? {} : { firmwareRevision }), + ...(equipmentId === undefined ? {} : { equipmentId }), + }; +} + +function normalizeHardware(raw: RawFieldRecord, provenance: Provenance) { + const identity = hardwareIdentity(raw); + const named = + identity.model !== undefined || + identity.manufacturer !== undefined || + identity.firmwareRevision !== undefined; + const labelProvenance = provenance(MODEL, MANUFACTURER, REVISION); + const nameProvenance = provenance(MODEL, MANUFACTURER, REVISION, EQUIPMENT_ID); + return { + label: named + ? knownMetric(modemHardwareLabel(identity), labelProvenance) + : unknownMetric('not-reported', labelProvenance), + displayName: named + ? knownMetric(modemHardwareName(identity), nameProvenance) + : unknownMetric('not-reported', nameProvenance), + }; +} + +function normalizeRadio( + raw: RawFieldRecord, + provenance: Provenance, + notes: ObservationDiagnosticNote[], +) { + return { + modemState: decodedLabel(raw, STATE, decodeMmState, provenance, notes), + registration: decodedLabel(raw, REGISTRATION_STATE, decodeRegistrationState, provenance, notes), + accessTechnologies: normalizeAccessTechnologies(raw, provenance, notes), + modeLabel: normalizeModeLabel(raw, provenance, notes), + }; +} + +/** A decoder whose "I could not place this" answer is the literal string `unknown`. */ +function decodedLabel( + raw: RawFieldRecord, + key: string, + decode: (value: number | undefined) => string, + provenance: Provenance, + notes: ObservationDiagnosticNote[], +): NormalizedMetric { + const source = provenance(key); + if (!hasRawField(raw, key)) { + return unknownMetric('not-reported', source); + } + const decoded = decode(rawNumber(raw, key)); + if (decoded === 'unknown') { + notes.push({ code: 'field-shape-unrecognized', field: key }); + return unknownMetric('malformed', source); + } + return knownMetric(decoded, source); +} + +function normalizeAccessTechnologies( + raw: RawFieldRecord, + provenance: Provenance, + notes: ObservationDiagnosticNote[], +): NormalizedMetric { + const source = provenance(ACCESS_TECHNOLOGIES); + if (!hasRawField(raw, ACCESS_TECHNOLOGIES)) { + return unknownMetric('not-reported', source); + } + const mask = rawNumber(raw, ACCESS_TECHNOLOGIES); + // MM's 0 is `UNKNOWN` — the modem did not say, which is not a malformed answer. + if (mask === undefined || mask <= 0) { + return unknownMetric('not-reported', source); + } + const decoded = [...decodeMmAccessTechnologies(mask)].sort(); + if (decoded.length === 0) { + notes.push({ code: 'field-shape-unrecognized', field: ACCESS_TECHNOLOGIES }); + return unknownMetric('malformed', source); + } + return knownMetric(decoded, source); +} + +function normalizeModeLabel( + raw: RawFieldRecord, + provenance: Provenance, + notes: ObservationDiagnosticNote[], +): NormalizedMetric { + const source = provenance(CURRENT_MODES); + if (!hasRawField(raw, CURRENT_MODES)) { + return unknownMetric('not-reported', source); + } + // `CurrentModes` is a `(uu)`; the ALLOWED mask is member 0. A flattened scalar + // (mmcli's shape) decodes at the same index, so both retentions work here. + const mask = rawNumberAt(raw, CURRENT_MODES, 0); + if (mask === undefined || mask <= 0) { + return unknownMetric('not-reported', source); + } + const label = modeMaskToLabel(mask); + if (label === undefined) { + notes.push({ code: 'field-shape-unrecognized', field: CURRENT_MODES }); + return unknownMetric('malformed', source); + } + return knownMetric(label, source); +} + +function normalizeSignal(raw: RawFieldRecord, signalRead: boolean, provenance: Provenance) { + const missing = signalRead ? ('not-reported' as const) : ('not-observed' as const); + const extended = (key: string): NormalizedMetric => { + const source = provenance(key); + const value = rawNumber(raw, key); + return value === undefined + ? unknownMetric(hasRawField(raw, key) ? 'malformed' : missing, source) + : knownMetric(value, source); + }; + // `SignalQuality` is a `(ub)`: percentage at 0, "measured recently" at 1. Both are + // claimed; a source that retained only the percentage answers `not-reported` for the + // flag, which is a claim about the READ, never about ModemManager's capability. + const quality = rawNumberAt(raw, SIGNAL_QUALITY, 0); + const recent = rawBooleanAt(raw, SIGNAL_QUALITY, 1); + const qualityProvenance = provenance(SIGNAL_QUALITY); + return { + quality: + quality === undefined + ? unknownMetric('not-reported', qualityProvenance) + : knownMetric(quality, qualityProvenance), + qualityRecent: + recent === undefined + ? unknownMetric('not-reported', provenance(SIGNAL_QUALITY)) + : knownMetric(recent, provenance(SIGNAL_QUALITY)), + // ModemManager reports a percentage and no bar scale. Deriving bars from the + // percentage would be inventing a reading, so this is a capability claim. + bars: unknownMetric('unsupported', metricProvenanceEmpty(provenance)), + maxBars: unknownMetric('unsupported', metricProvenanceEmpty(provenance)), + dbm: extended(RSSI), + rsrp: extended(RSRP), + rsrq: extended(RSRQ), + snr: extended(SNR), + // `Modem.Signal` exposes rssi/rsrp/rsrq/snr/ecio/io/rscp — there is no SINR member. + sinr: unknownMetric('unsupported', metricProvenanceEmpty(provenance)), + }; +} + +/** Provenance for a metric no raw field backs, which is what `unsupported` means. */ +function metricProvenanceEmpty(provenance: Provenance) { + return provenance(); +} + +/** + * `Modem.StateFailedReason` in the spelling the presence rule matches. + * + * D-Bus types it `u`, so a provider retaining the property verbatim holds a NUMBER, + * while the migrated rule matches the mmcli STRING. Both are accepted here — the + * number through the enum decoder, the string as it stands — because the two transports + * for the same fact must not disagree about whether a modem has a SIM. An + * unrecognized number decodes to `undefined` and simply proves nothing. + */ +function failedReasonOf(raw: RawFieldRecord): string | undefined { + const numeric = rawNumber(raw, FAILED_REASON); + if (numeric !== undefined) return decodeStateFailedReason(numeric); + return rawString(raw, FAILED_REASON); +} + +function simPresenceFacts(raw: RawFieldRecord): SimPresenceFacts { + const sim = rawString(raw, SIM_PATH); + const simSlots = rawStringArray(raw, SIM_SLOTS); + const failedReason = failedReasonOf(raw); + return { + ...(sim === undefined ? {} : { sim }), + ...(simSlots === undefined ? {} : { simSlots }), + ...(failedReason === undefined ? {} : { failedReason }), + }; +} + +function normalizeSim( + raw: RawFieldRecord, + simRead: boolean, + provenance: Provenance, + notes: ObservationDiagnosticNote[], +) { + const presenceProvenance = provenance(SIM_PATH, SIM_SLOTS, FAILED_REASON); + // `absent` here comes from `state-failed-reason` evidence and from nothing else. + // A blank `Sim` object path with no failure reason stays `unknown`: ModemManager + // reports `/` while a modem is initializing and while a slot switch is in flight, + // so reading it as "no SIM" would report an absent SIM on a modem holding one. + const { presence, evidence } = readSimPresence(simPresenceFacts(raw)); + const missing = simRead ? ('not-reported' as const) : ('not-observed' as const); + return { + presence: + presence === 'unknown' + ? unknownMetric('not-reported', presenceProvenance) + : knownMetric(presence, presenceProvenance), + presenceEvidence: evidence, + lockRequired: decodedOptional(raw, UNLOCK_REQUIRED, decodeUnlockRequired, provenance, notes), + kind: decodedOptional(raw, SIM_TYPE, decodeSimType, provenance, notes, missing), + esimStatus: decodedOptional(raw, ESIM_STATUS, decodeEsimStatus, provenance, notes, missing), + }; +} + +/** A decoder whose "I could not place this" answer is `undefined`. */ +function decodedOptional( + raw: RawFieldRecord, + key: string, + decode: (value: number | undefined) => T | undefined, + provenance: Provenance, + notes: ObservationDiagnosticNote[], + absentReason: 'not-reported' | 'not-observed' = 'not-reported', +): NormalizedMetric { + const source = provenance(key); + if (!hasRawField(raw, key)) { + return unknownMetric(absentReason, source); + } + const decoded = decode(rawNumber(raw, key)); + if (decoded === undefined) { + notes.push({ code: 'field-shape-unrecognized', field: key }); + return unknownMetric('malformed', source); + } + return knownMetric(decoded, source); +} diff --git a/control/src/observations/sources/router-shared.ts b/control/src/observations/sources/router-shared.ts new file mode 100644 index 0000000..92d5eed --- /dev/null +++ b/control/src/observations/sources/router-shared.ts @@ -0,0 +1,92 @@ +// The claims every router admin API makes identically, in one place. +// +// HiLink, goform and HIMI are three vendor HTTP surfaces over a modem, and they share +// a capability boundary: none of them exposes ModemManager's modem state, its 3GPP +// registration enum, its lock enum or its SIM-type enum. Those read `unsupported`, +// which is a durable claim about the SOURCE rather than about one poll — and stating +// it once here is what stops the three normalizers from drifting into three different +// answers for the same structural fact. +// +// SIM PRESENCE is deliberately NOT claimed. Each vendor reports a presence code of +// its own (`SimStatus`, `simcard_state`, `simstate`) with vendor semantics, and no +// migrated decoder covers them; guessing one would be exactly the invented reading +// this layer exists to prevent. It reads `not-reported` and the vendor code stays +// verbatim in the diagnostics block for the per-vendor providers to claim later. + +import { + isUninformativeIdentity, + modemHardwareLabel, + modemHardwareName, + type ObservationAuthority, +} from '../../domain'; +import { knownMetric, type NormalizedMetric, unknownMetric } from '../metric'; +import type { NormalizedHardware, NormalizedSim, SimPresenceValue } from '../model'; +import type { MetricProvenance } from '../provenance'; + +export type RouterProvenance = ( + fields: readonly string[], + authority?: ObservationAuthority, +) => MetricProvenance; + +export function unsupportedRadioMetric(provenance: RouterProvenance): NormalizedMetric { + return unknownMetric('unsupported', provenance([])); +} + +/** + * The SIM block every router source produces. + * + * `vendorPresenceField` NAMES the vendor's own presence code when the payload carried + * one, so the evidence records that a code exists and was deliberately not decoded — + * distinct from `no-evidence`, which says the payload offered nothing at all. Neither + * one is ever `absent`: claiming absence from a vendor code whose semantics no + * migrated decoder covers is precisely the invented reading this layer refuses. + */ +export function routerSim( + provenance: RouterProvenance, + vendorPresenceField?: string, +): NormalizedSim { + // The vendor field is NAMED in the evidence but is NOT consumed: no metric claims its + // value, so it must stay in the diagnostics block's `unmapped` set, verbatim, for the + // per-vendor provider that will one day decode it with evidence. + return { + presence: unknownMetric('not-reported', provenance([])), + presenceEvidence: + vendorPresenceField === undefined + ? { kind: 'no-evidence', inspected: [] } + : { kind: 'vendor-code-unclaimed', field: vendorPresenceField }, + lockRequired: unknownMetric('unsupported', provenance([])), + kind: unknownMetric<'physical' | 'esim'>('unsupported', provenance([])), + esimStatus: unknownMetric<'no-profiles' | 'with-profiles'>('unsupported', provenance([])), + }; +} + +/** Router admin APIs report no measurement-recency flag; that is a source capability claim. */ +export function unsupportedQualityRecent(provenance: RouterProvenance): NormalizedMetric { + return unknownMetric('unsupported', provenance([])); +} + +/** + * A router's product name, screened by the migrated presentation rules. + * + * `modemHardwareLabel` falls back to a generic label when its input says nothing, and + * publishing that as a KNOWN value would be reporting a name no device gave. So an + * uninformative product string is refused up front and reads `not-reported` instead. + */ +export function routerHardware( + provenance: RouterProvenance, + product: { readonly name: string; readonly field: string } | undefined, +): NormalizedHardware { + if (product === undefined || isUninformativeIdentity(product.name)) { + const source = provenance(product === undefined ? [] : [product.field]); + return { + label: unknownMetric('not-reported', source), + displayName: unknownMetric('not-reported', source), + }; + } + const source = provenance([product.field]); + const identity = { model: product.name }; + return { + label: knownMetric(modemHardwareLabel(identity), source), + displayName: knownMetric(modemHardwareName(identity), source), + }; +} diff --git a/control/src/observations/sources/ufi.ts b/control/src/observations/sources/ufi.ts new file mode 100644 index 0000000..dd75baf --- /dev/null +++ b/control/src/observations/sources/ufi.ts @@ -0,0 +1,156 @@ +// Qualcomm UFI / HIMI → normalized observation. +// +// UFI answers three endpoints per read, each an envelope of its own (`{reply, params}`), +// and the migrated `parseUfiSignal` already folds them in the vendor's own precedence +// order. This module keeps all three bodies verbatim — including the `reply` field, +// which is how a `SessionOut` refusal is told apart from a body that simply lacked the +// key — and reports the ONE signal reading the source actually provides. +// +// Its overview endpoint returns an IMSI and an ICCID. They are retained, because +// normalization does not get to decide what a diagnostician may need, and they are +// masked by the shared key-based redactor on the way to anything that renders. That +// split — retain here, redact at the boundary — is why `raw` is documented as a +// redaction-class surface rather than as safe-by-default data. + +import type { ObservationEnvelope } from '../../domain'; +import { parseUfiDetails, parseUfiSignal } from '../../hardware/router-parsers'; +import { freshObservation, metricProvenance, type NormalizationContext } from '../envelope'; +import { metricFromRouterSignal, unknownMetric } from '../metric'; +import type { NormalizedModemObservation } from '../model'; +import { + createObservationDiagnostics, + type ObservationDiagnosticNote, + type RawFieldValue, +} from '../provenance'; +import { + hasRawField, + mergeRawRecords, + normalizeRawValue, + parseJsonObject, + prefixRawRecord, + rawKey, +} from '../raw'; +import { + type RouterProvenance, + routerHardware, + routerSim, + unsupportedQualityRecent, + unsupportedRadioMetric, +} from './router-shared'; + +const SOURCE = 'ufi-himiapi' as const; +const SYSINFO = 'sysinfo'; +const OVERVIEW = 'overview'; +const STATUS = 'status'; +const PRODUCE_INFO = 'produce-info'; +const SESSION_REFUSAL = 'SessionOut'; +/** HIMI's own SIM presence code. Named as evidence, never decoded into a presence. */ +const SIM_STATE = rawKey(SYSINFO, 'simstate'); + +export type UfiObservationInput = { + readonly sysinfo: string; + readonly overview: string; + readonly status: string; + readonly produceInfo?: string; +}; + +export function normalizeUfiObservation( + input: UfiObservationInput, + context: NormalizationContext, +): ObservationEnvelope { + const bodies = [ + [SYSINFO, input.sysinfo], + [OVERVIEW, input.overview], + [STATUS, input.status], + [PRODUCE_INFO, input.produceInfo ?? ''], + ] as const; + + const notes: ObservationDiagnosticNote[] = []; + const records = bodies.map(([name, text]) => { + const parsed = parseJsonObject(text); + if (parsed === undefined && text.trim() !== '') { + notes.push({ code: 'unparseable-body', field: name }); + } + if (parsed?.reply === SESSION_REFUSAL) { + notes.push({ code: 'auth-expired', field: rawKey(name, 'reply') }); + } + return prefixRawRecord(name, flattenUfiEnvelope(parsed)); + }); + + const raw = mergeRawRecords(...records); + const consumed: string[] = []; + const provenance: RouterProvenance = (fields, authority) => { + consumed.push(...fields); + return authority === undefined + ? metricProvenance(SOURCE, context, fields) + : metricProvenance(SOURCE, context, fields, authority); + }; + + const signalModel = parseUfiSignal({ + sysinfo: input.sysinfo, + overview: input.overview, + status: input.status, + }); + const details = parseUfiDetails({ + overview: input.overview, + sysinfo: input.sysinfo, + ...(input.produceInfo === undefined ? {} : { produceInfo: input.produceInfo }), + }); + const product = details?.product; + + return freshObservation(SOURCE, context, { + source: SOURCE, + hardware: routerHardware( + provenance, + product === undefined + ? undefined + : { name: product, field: rawKey(PRODUCE_INFO, 'productname') }, + ), + radio: { + modemState: unsupportedRadioMetric(provenance), + registration: unsupportedRadioMetric(provenance), + accessTechnologies: unknownMetric('unsupported', provenance([])), + modeLabel: unknownMetric('not-reported', provenance([])), + }, + signal: { + quality: unknownMetric('unsupported', provenance([])), + qualityRecent: unsupportedQualityRecent(provenance), + bars: metricFromRouterSignal(signalModel.bars, provenance([])), + maxBars: metricFromRouterSignal(signalModel.max_bars, provenance([])), + dbm: metricFromRouterSignal( + signalModel.dbm, + provenance([ + rawKey(SYSINFO, 'SIGNAL'), + rawKey(OVERVIEW, 'SIGNAL'), + rawKey(STATUS, 'signalStrength'), + ]), + ), + rsrp: metricFromRouterSignal(signalModel.rsrp, provenance([])), + rsrq: metricFromRouterSignal(signalModel.rsrq, provenance([])), + snr: metricFromRouterSignal(signalModel.snr, provenance([])), + sinr: metricFromRouterSignal(signalModel.sinr, provenance([])), + }, + sim: routerSim(provenance, hasRawField(raw, SIM_STATE) ? SIM_STATE : undefined), + diagnostics: createObservationDiagnostics({ source: SOURCE, raw, consumed, notes }), + }); +} + +/** `{reply, params}` flattened to one level — `params` members keep their own names. */ +function flattenUfiEnvelope( + parsed: Readonly> | undefined, +): Record { + const out: Record = {}; + if (parsed === undefined) { + return out; + } + for (const [key, value] of Object.entries(parsed)) { + if (key === 'params' && typeof value === 'object' && value !== null && !Array.isArray(value)) { + for (const [field, param] of Object.entries(value as Record)) { + out[field] = normalizeRawValue(param); + } + continue; + } + out[key] = normalizeRawValue(value); + } + return out; +} diff --git a/control/src/observations/sources/zte.ts b/control/src/observations/sources/zte.ts new file mode 100644 index 0000000..6f100fd --- /dev/null +++ b/control/src/observations/sources/zte.ts @@ -0,0 +1,105 @@ +// ZTE goform → normalized observation. +// +// One goform response answers many keys at once, so the migrated `parseZteSignal` and +// `parseZteDetails` are both driven from the SAME body here — and everything neither +// of them claims (`wan_lte_ca`, `rmcc`, `lte_pci`, the cell id, the roaming flag) is +// retained verbatim in the diagnostics block. That retention is the point: a +// carrier-aggregation flag no normalized field has a slot for is still the difference +// between a diagnosable report and a shrug. +// +// A body that will not parse is an OBSERVATION whose metrics are `malformed`, not an +// unavailable one. The bytes arrived; what failed was decoding them, and an +// unavailable envelope would have to discard them to say so. + +import type { ObservationEnvelope } from '../../domain'; +import { parseZteDetails, parseZteSignal } from '../../hardware/router-parsers'; +import { freshObservation, metricProvenance, type NormalizationContext } from '../envelope'; +import { + knownMetric, + metricFromRouterSignal, + type NormalizedMetric, + unknownMetric, +} from '../metric'; +import type { NormalizedModemObservation } from '../model'; +import { createObservationDiagnostics, type ObservationDiagnosticNote } from '../provenance'; +import { hasRawField, parseJsonRecord, prefixRawRecord, rawKey } from '../raw'; +import { + type RouterProvenance, + routerHardware, + routerSim, + unsupportedQualityRecent, + unsupportedRadioMetric, +} from './router-shared'; + +const SOURCE = 'zte-goform' as const; +const BODY = 'goform'; +/** ZTE's own SIM presence code. Named as evidence, never decoded into a presence. */ +const SIM_CARD_STATE = rawKey(BODY, 'simcard_state'); + +export type ZteObservationInput = { + readonly body: string; +}; + +export function normalizeZteObservation( + input: ZteObservationInput, + context: NormalizationContext, +): ObservationEnvelope { + const parsed = parseJsonRecord(input.body); + const raw = prefixRawRecord(BODY, parsed); + const notes: ObservationDiagnosticNote[] = []; + if (parsed === undefined) { + notes.push({ code: input.body.trim() === '' ? 'empty-body' : 'unparseable-body', field: BODY }); + } + + const consumed: string[] = []; + const provenance: RouterProvenance = (fields, authority) => { + consumed.push(...fields); + return authority === undefined + ? metricProvenance(SOURCE, context, fields) + : metricProvenance(SOURCE, context, fields, authority); + }; + + const signalModel = parseZteSignal(input.body); + const details = parseZteDetails(input.body); + + return freshObservation(SOURCE, context, { + source: SOURCE, + hardware: routerHardware(provenance, undefined), + radio: { + modemState: unsupportedRadioMetric(provenance), + registration: unsupportedRadioMetric(provenance), + accessTechnologies: unknownMetric('unsupported', provenance([])), + modeLabel: normalizeModeLabel(details?.network_type, parsed !== undefined, provenance), + }, + signal: { + quality: unknownMetric('unsupported', provenance([])), + qualityRecent: unsupportedQualityRecent(provenance), + bars: metricFromRouterSignal( + signalModel.bars, + provenance([rawKey(BODY, 'signalbar')], 'derived'), + ), + // The goform payload carries no maximum; the migrated parser states the + // vendor's fixed five-bar scale, which is a derivation and says so. + maxBars: metricFromRouterSignal(signalModel.max_bars, provenance([], 'derived')), + dbm: metricFromRouterSignal(signalModel.dbm, provenance([rawKey(BODY, 'rssi')])), + rsrp: metricFromRouterSignal(signalModel.rsrp, provenance([rawKey(BODY, 'lte_rsrp')])), + rsrq: metricFromRouterSignal(signalModel.rsrq, provenance([rawKey(BODY, 'lte_rsrq')])), + snr: metricFromRouterSignal(signalModel.snr, provenance([rawKey(BODY, 'lte_snr')])), + sinr: metricFromRouterSignal(signalModel.sinr, provenance([])), + }, + sim: routerSim(provenance, hasRawField(raw, SIM_CARD_STATE) ? SIM_CARD_STATE : undefined), + diagnostics: createObservationDiagnostics({ source: SOURCE, raw, consumed, notes }), + }); +} + +function normalizeModeLabel( + networkType: string | undefined, + parsed: boolean, + provenance: RouterProvenance, +): NormalizedMetric { + const source = provenance([rawKey(BODY, 'network_type')]); + if (networkType !== undefined) { + return knownMetric(networkType, source); + } + return unknownMetric(parsed ? 'not-reported' : 'malformed', source); +} diff --git a/control/src/observations/state-separation.test.ts b/control/src/observations/state-separation.test.ts new file mode 100644 index 0000000..7d7e0e6 --- /dev/null +++ b/control/src/observations/state-separation.test.ts @@ -0,0 +1,118 @@ +import { describe, expect, test } from 'bun:test'; +import { fixtureContext } from '../../test-support/observation-fixtures'; +import { deviceGeneration, epochMillis } from '../domain'; +import { freshObservation, unavailableObservation } from './envelope'; +import { + appliedConfiguration, + describeStateDivergence, + desiredProfile, + type ModemStateView, + observedState, + STATE_VIEW_SLOTS, +} from './state-separation'; + +const CONTEXT = fixtureContext(); + +type Apn = { readonly apn: string }; + +const DESIRED = desiredProfile({ apn: 'internet.claro' }, epochMillis(10), 'rpc:operator'); +const APPLIED = appliedConfiguration({ + configuration: { apn: 'internet.claro' }, + appliedAt: epochMillis(20), + generation: deviceGeneration(7), + operationId: 'set-apn#1', +}); +const OBSERVED = observedState( + freshObservation('modemmanager', CONTEXT, { apn: 'internet.claro' }), +); + +const sameApn = (left: Apn, right: Apn) => left.apn === right.apn; + +function view( + overrides: Partial> = {}, +): ModemStateView { + return { desired: DESIRED, applied: APPLIED, observed: OBSERVED, ...overrides }; +} + +describe('desired, applied and observed stay three things', () => { + test('Given the state view, when inspected, then it has exactly three slots and no merged value', () => { + expect(Object.keys(view()).sort()).toEqual([...STATE_VIEW_SLOTS].sort()); + }); + + test('Given the three slots, when discriminated, then each declares its own kind', () => { + const current = view(); + + expect(current.desired?.kind).toBe('desired'); + expect(current.applied?.kind).toBe('applied'); + expect(current.observed.kind).toBe('observed'); + }); + + test('Given an observed slot, when inspected, then the device value is only reachable through an envelope', () => { + const current = view(); + + expect(current.observed.observation.freshness.state).toBe('fresh'); + expect(current.observed.observation.stableKey).toBe(CONTEXT.stableKey); + expect('profile' in current.observed).toBe(false); + expect('configuration' in current.observed).toBe(false); + }); + + test('Given a desired slot, when inspected, then it carries no applied-side evidence', () => { + expect('generation' in DESIRED).toBe(false); + expect('operationId' in DESIRED).toBe(false); + expect(APPLIED.operationId).toBe('set-apn#1'); + expect(APPLIED.generation).toBe(deviceGeneration(7)); + }); +}); + +describe('divergence is reported as two independent comparisons', () => { + test('Given full agreement, when compared, then both comparisons align', () => { + expect(describeStateDivergence(view(), sameApn)).toEqual({ + desiredVsApplied: { status: 'aligned' }, + appliedVsObserved: { status: 'aligned' }, + }); + }); + + test('Given a request that was never applied, when compared, then only the first comparison diverges', () => { + const pending = view({ + desired: desiredProfile({ apn: 'ims.claro' }, epochMillis(30), 'rpc:operator'), + }); + + expect(describeStateDivergence(pending, sameApn)).toEqual({ + desiredVsApplied: { status: 'diverged' }, + appliedVsObserved: { status: 'aligned' }, + }); + }); + + test('Given a write the network undid, when compared, then only the second comparison diverges', () => { + const drifted = view({ + observed: observedState(freshObservation('modemmanager', CONTEXT, { apn: 'ims.claro' })), + }); + + expect(describeStateDivergence(drifted, sameApn)).toEqual({ + desiredVsApplied: { status: 'aligned' }, + appliedVsObserved: { status: 'diverged' }, + }); + }); + + test('Given an unavailable observation, when compared, then it is indeterminate rather than aligned', () => { + const unreadable = view({ + observed: observedState( + unavailableObservation('modemmanager', CONTEXT, 'source-unavailable'), + ), + }); + + expect(describeStateDivergence(unreadable, sameApn)).toEqual({ + desiredVsApplied: { status: 'aligned' }, + appliedVsObserved: { status: 'indeterminate', missing: 'observed' }, + }); + }); + + test('Given nothing applied yet, when compared, then both comparisons name the missing slot', () => { + const unapplied = view({ applied: null }); + + expect(describeStateDivergence(unapplied, sameApn)).toEqual({ + desiredVsApplied: { status: 'indeterminate', missing: 'applied' }, + appliedVsObserved: { status: 'indeterminate', missing: 'applied' }, + }); + }); +}); diff --git a/control/src/observations/state-separation.ts b/control/src/observations/state-separation.ts new file mode 100644 index 0000000..4473323 --- /dev/null +++ b/control/src/observations/state-separation.ts @@ -0,0 +1,142 @@ +// Desired, applied and observed are THREE things, and this module refuses to make +// them one. +// +// The pattern is NetworkManager's: a connection PROFILE is what an operator asked +// for, an active connection's BEARER is what was actually put into force, and the +// device's own reported state is what the hardware is currently doing. All three are +// routinely different at once — a profile edited but not re-activated, a bearer that +// survived a profile change, a device that dropped registration without anything +// having been re-applied — and a merged "current state" blob has to pick one of them +// and silently lose the other two. +// +// Concretely, collapsing them makes these questions unanswerable: +// · did the operator's last change actually take effect, or is it only pending? +// · is what the device reports the consequence of our write, or of the network? +// · when a rollback is needed, what exactly do we roll back TO? +// +// So the three live in separate slots with separate `kind` discriminants, and the +// comparison below reports TWO independent divergences instead of one verdict. +// `NetworkManagerAdapter` (a later change) consumes exactly this shape. + +import type { DeviceGeneration, EpochMillis, ObservationEnvelope } from '../domain'; +import { viewEnvelope } from './envelope'; + +/** What an operator asked for. Not a claim that anything acted on it. */ +export type DesiredProfile = { + readonly kind: 'desired'; + readonly profile: T; + readonly requestedAt: EpochMillis; + /** Opaque origin label (an RPC caller, a policy engine). Never a credential. */ + readonly requestedBy: string; +}; + +/** What was actually put into force, and by which operation, in which generation. */ +export type AppliedConfiguration = { + readonly kind: 'applied'; + readonly configuration: T; + readonly appliedAt: EpochMillis; + readonly generation: DeviceGeneration; + readonly operationId: string; +}; + +/** What the device itself reports — always through an envelope, never bare. */ +export type ObservedState = { + readonly kind: 'observed'; + readonly observation: ObservationEnvelope; +}; + +export function desiredProfile( + profile: T, + requestedAt: EpochMillis, + requestedBy: string, +): DesiredProfile { + return { kind: 'desired', profile, requestedAt, requestedBy }; +} + +export function appliedConfiguration(input: { + readonly configuration: T; + readonly appliedAt: EpochMillis; + readonly generation: DeviceGeneration; + readonly operationId: string; +}): AppliedConfiguration { + return { kind: 'applied', ...input }; +} + +export function observedState(observation: ObservationEnvelope): ObservedState { + return { kind: 'observed', observation }; +} + +/** + * The three-slot view. There is deliberately no fourth field holding a merged + * "effective" value: an effective value is a RENDERING decision, and computing one + * here would bake a policy every consumer would then have to work around. + */ +export type ModemStateView = { + readonly desired: DesiredProfile | null; + readonly applied: AppliedConfiguration | null; + readonly observed: ObservedState; +}; + +export const STATE_VIEW_SLOTS = ['desired', 'applied', 'observed'] as const; +export type StateViewSlot = (typeof STATE_VIEW_SLOTS)[number]; + +export type SlotComparison = + | { readonly status: 'aligned' } + | { readonly status: 'diverged' } + /** One side has nothing to compare — NOT the same as agreeing. */ + | { readonly status: 'indeterminate'; readonly missing: StateViewSlot }; + +/** + * Two independent comparisons, never one verdict. + * + * `desiredVsApplied` answers "did our write happen"; `appliedVsObserved` answers "did + * it stick". A single boolean cannot distinguish a request that was never carried out + * from one the network undid a second later, and those need opposite responses. + */ +export type StateDivergence = { + readonly desiredVsApplied: SlotComparison; + readonly appliedVsObserved: SlotComparison; +}; + +export function describeStateDivergence( + view: ModemStateView, + equals: (left: T, right: T) => boolean, +): StateDivergence { + return { + desiredVsApplied: compareSlots( + view.desired === null ? undefined : view.desired.profile, + view.applied === null ? undefined : view.applied.configuration, + view.desired === null ? 'desired' : 'applied', + equals, + ), + appliedVsObserved: compareSlots( + view.applied === null ? undefined : view.applied.configuration, + observedValue(view.observed), + view.applied === null ? 'applied' : 'observed', + equals, + ), + }; +} + +/** + * The observed value, or `undefined` when the observation carries none. + * + * An unavailable observation is `indeterminate` against anything, never `aligned`: + * "we could not read it" is not evidence that it matches. + */ +function observedValue(observed: ObservedState): T | undefined { + const view = viewEnvelope(observed.observation); + return view.kind === 'unavailable' ? undefined : view.value; +} + +function compareSlots( + left: T | undefined, + right: T | undefined, + missing: StateViewSlot, + equals: (left: T, right: T) => boolean, +): SlotComparison { + if (left === undefined || right === undefined) { + return { status: 'indeterminate', missing }; + } + return equals(left, right) ? { status: 'aligned' } : { status: 'diverged' }; +} diff --git a/control/src/operations/contracts.ts b/control/src/operations/contracts.ts new file mode 100644 index 0000000..16c7f4f --- /dev/null +++ b/control/src/operations/contracts.ts @@ -0,0 +1,80 @@ +import type { + DeviceGeneration, + OperationCompletion, + OperationDescriptor, + OperationResult, + PhysicalModemId, +} from '../domain'; + +export type OperationPreconditionRequest = { + readonly operationId: string; + readonly physicalModemId: PhysicalModemId; + readonly generation: DeviceGeneration; + readonly descriptorId: string; + readonly preconditions: readonly string[]; +}; + +export type OperationPreconditionResult = + | { readonly status: 'satisfied' } + | { readonly status: 'refused'; readonly reason: string }; + +/** Live operation facts. Implementations must inspect them for every invocation. */ +export interface OperationPreconditionPort { + check(request: OperationPreconditionRequest): Promise; +} + +export type OperationAttemptContext = { + readonly operationId: string; + readonly generation: DeviceGeneration; + readonly attempt: number; +}; + +export type OperationJournalEvent = + | { + readonly phase: 'started'; + readonly operationId: string; + readonly physicalModemId: PhysicalModemId; + readonly generation: DeviceGeneration; + readonly descriptor: OperationDescriptor; + readonly input: I; + } + | { + readonly phase: 'completed'; + readonly operationId: string; + readonly physicalModemId: PhysicalModemId; + readonly generation: DeviceGeneration; + readonly descriptor: OperationDescriptor; + readonly result: OperationResult; + }; + +export interface OperationJournalHook { + record(event: OperationJournalEvent): Promise; +} + +export type OperationExecution = { + readonly operationId: string; + readonly physicalModemId: PhysicalModemId; + readonly descriptor: OperationDescriptor; + readonly input: I; + readonly execute: (context: OperationAttemptContext) => Promise>; + readonly readback?: (context: OperationAttemptContext) => Promise>; + readonly rollback?: (context: OperationAttemptContext) => Promise>; + readonly journal?: OperationJournalHook; +}; + +export type ReconciliationExecution = { + readonly physicalModemId: PhysicalModemId; + readonly generation: DeviceGeneration; + readonly run: () => Promise< + { readonly status: 'reconciled' } | { readonly status: 'failed'; readonly reason: string } + >; +}; + +export type ReconciliationResult = + | { readonly status: 'reconciled'; readonly generation: DeviceGeneration } + | { readonly status: 'not-required'; readonly generation: DeviceGeneration } + | { + readonly status: 'failed'; + readonly reason: 'stale-generation' | string; + readonly generation: DeviceGeneration; + }; diff --git a/control/src/operations/index.ts b/control/src/operations/index.ts new file mode 100644 index 0000000..0f841af --- /dev/null +++ b/control/src/operations/index.ts @@ -0,0 +1 @@ +export * from './operation-engine'; diff --git a/control/src/operations/operation-engine-policy.test.ts b/control/src/operations/operation-engine-policy.test.ts new file mode 100644 index 0000000..27c1d8d --- /dev/null +++ b/control/src/operations/operation-engine-policy.test.ts @@ -0,0 +1,119 @@ +import { afterEach, describe, expect, test } from 'bun:test'; +import { + createOperationEngineHarness, + operationDescriptor, + operationExecution, +} from '../../test-support/operation-engine-fixture'; + +const disposals: Array<() => Promise> = []; + +afterEach(async () => { + for (const dispose of disposals.splice(0).reverse()) await dispose(); +}); + +function harness() { + const created = createOperationEngineHarness(); + disposals.push(created.dispose); + return created; +} + +describe('retry classification', () => { + test('Given an idempotent read transient failure, When invoked, Then one automatic retry succeeds', async () => { + const { engine } = harness(); + let calls = 0; + const result = await engine.invoke( + operationExecution( + () => { + calls += 1; + return Promise.resolve( + calls === 1 + ? { status: 'failed', reason: 'transient' } + : { status: 'applied', value: 'auto' }, + ); + }, + { + descriptor: operationDescriptor({ + mutationImpact: 'read', + retryClass: 'idempotent-read', + admission: { required: false }, + }), + }, + ), + ); + + expect(result).toMatchObject({ status: 'applied', value: 'auto' }); + expect(calls).toBe(2); + }); + + test('Given a non-idempotent read failure, When invoked, Then the engine never retries it', async () => { + const { engine } = harness(); + let calls = 0; + const result = await engine.invoke( + operationExecution( + () => { + calls += 1; + return Promise.resolve({ status: 'failed', reason: 'terminal' }); + }, + { + descriptor: operationDescriptor({ + mutationImpact: 'read', + retryClass: 'never', + admission: { required: false }, + }), + }, + ), + ); + + expect(result).toMatchObject({ status: 'failed', reason: 'terminal' }); + expect(calls).toBe(1); + }); +}); + +describe('descriptor hooks', () => { + test('Given required readback and journal hooks, When a write applies, Then both hooks fire', async () => { + const { engine } = harness(); + const phases: string[] = []; + const result = await engine.invoke( + operationExecution(() => Promise.resolve({ status: 'applied', value: 'lte' }), { + descriptor: operationDescriptor({ + readback: { + required: true, + reason: 'confirm mode', + matches: (input, value) => input === value, + }, + journal: { required: true, reason: 'record mutation' }, + }), + readback: () => { + phases.push('readback'); + return Promise.resolve({ status: 'applied', value: 'lte' }); + }, + journal: { + record: (event) => { + phases.push(`journal:${event.phase}`); + return Promise.resolve(); + }, + }, + }), + ); + + expect(result).toMatchObject({ status: 'applied' }); + expect(phases).toEqual(['journal:started', 'readback', 'journal:completed']); + }); + + test('Given required rollback and a definite write failure, When invoked, Then rollback fires once', async () => { + const { engine } = harness(); + let rollbackCalls = 0; + const result = await engine.invoke( + operationExecution(() => Promise.resolve({ status: 'failed', reason: 'provider-failed' }), { + descriptor: operationDescriptor({ rollback: { required: true, reason: 'restore mode' } }), + rollback: () => { + rollbackCalls += 1; + return Promise.resolve({ status: 'applied', value: undefined }); + }, + }), + ); + + expect(result).toMatchObject({ status: 'failed', reason: 'provider-failed' }); + expect(rollbackCalls).toBe(1); + }); +}); diff --git a/control/src/operations/operation-engine-reconciliation.test.ts b/control/src/operations/operation-engine-reconciliation.test.ts new file mode 100644 index 0000000..a68b162 --- /dev/null +++ b/control/src/operations/operation-engine-reconciliation.test.ts @@ -0,0 +1,148 @@ +import { afterEach, describe, expect, test } from 'bun:test'; +import { + createOperationEngineHarness, + deferred, + OPERATION_MODEM, + operationExecution, +} from '../../test-support/operation-engine-fixture'; +import type { OperationCompletion } from '../domain'; +import type { OperationPreconditionPort } from './operation-engine'; + +const disposals: Array<() => Promise> = []; + +afterEach(async () => { + for (const dispose of disposals.splice(0).reverse()) await dispose(); +}); + +function harness(preconditions?: OperationPreconditionPort) { + const created = createOperationEngineHarness(preconditions); + disposals.push(created.dispose); + return created; +} + +describe('unknown-outcome reconciliation gate', () => { + test('Given a timed-out write, When another mutation is invoked, Then it is blocked until reconciliation completes', async () => { + const { engine, generation } = harness(); + const first = await engine.invoke( + operationExecution(() => Promise.resolve({ status: 'timed-out' })), + ); + let secondCalls = 0; + const blocked = await engine.invoke( + operationExecution( + () => { + secondCalls += 1; + return Promise.resolve({ status: 'applied', value: 'lte' }); + }, + { operationId: 'operation-2' }, + ), + ); + + expect(first).toMatchObject({ status: 'unknown-outcome', requiresReconciliation: true }); + expect(blocked).toMatchObject({ status: 'refused', reason: 'reconciliation-required' }); + expect(secondCalls).toBe(0); + expect( + await engine.reconcile({ + physicalModemId: OPERATION_MODEM, + generation: generation(), + run: () => Promise.resolve({ status: 'reconciled' }), + }), + ).toEqual({ status: 'reconciled', generation: generation() }); + expect( + await engine.invoke( + operationExecution(() => Promise.resolve({ status: 'applied', value: 'lte' }), { + operationId: 'operation-3', + }), + ), + ).toMatchObject({ status: 'applied' }); + }); + + test('Given a completion from a replaced generation, When classified, Then its outcome is unknown', async () => { + const { engine, replace } = harness(); + const started = deferred(); + const completion = deferred>(); + const pending = engine.invoke( + operationExecution(() => { + started.resolve(); + return completion.promise; + }), + ); + await started.promise; + replace(); + completion.resolve({ status: 'applied', value: 'lte' }); + + expect(await pending).toMatchObject({ + status: 'unknown-outcome', + reason: 'stale-generation', + requiresReconciliation: true, + }); + }); +}); + +describe('invoke-time gates and operation isolation', () => { + test('Given a mutation waiting behind another write, When its live precondition changes, Then the queued mutation is refused without executing', async () => { + let present = true; + const checked: boolean[] = []; + const { engine } = harness({ + check: () => { + checked.push(present); + return Promise.resolve( + present ? { status: 'satisfied' } : { status: 'refused', reason: 'device-absent' }, + ); + }, + }); + const firstCompletion = deferred>(); + const first = engine.invoke(operationExecution(() => firstCompletion.promise)); + let secondCalls = 0; + const second = engine.invoke( + operationExecution( + () => { + secondCalls += 1; + return Promise.resolve({ status: 'applied', value: 'auto' }); + }, + { operationId: 'operation-2' }, + ), + ); + await Promise.resolve(); + present = false; + firstCompletion.resolve({ status: 'applied', value: 'lte' }); + + expect(await first).toMatchObject({ status: 'applied' }); + expect(await second).toMatchObject({ status: 'refused', reason: 'device-absent' }); + expect(checked).toEqual([true, false]); + expect(secondCalls).toBe(0); + }); + + test('Given a late duplicate completion from a replaced generation, When a newer operation is active, Then only its own completion can terminalize it', async () => { + const { engine, generation, replace } = harness(); + const oldStarted = deferred(); + const oldCompletion = deferred>(); + const newerCompletion = deferred>(); + const old = engine.invoke( + operationExecution(() => { + oldStarted.resolve(); + return oldCompletion.promise; + }), + ); + await oldStarted.promise; + replace(); + oldCompletion.resolve({ status: 'applied', value: 'lte' }); + expect(await old).toMatchObject({ status: 'unknown-outcome', reason: 'stale-generation' }); + await engine.reconcile({ + physicalModemId: OPERATION_MODEM, + generation: generation(), + run: () => Promise.resolve({ status: 'reconciled' }), + }); + const newer = engine.invoke( + operationExecution(() => newerCompletion.promise, { operationId: 'operation-new' }), + ); + let newerSettled = false; + void newer.then(() => { + newerSettled = true; + }); + oldCompletion.resolve({ status: 'failed', reason: 'duplicate-late-reply' }); + await Promise.resolve(); + expect(newerSettled).toBe(false); + newerCompletion.resolve({ status: 'applied', value: 'auto' }); + expect(await newer).toMatchObject({ status: 'applied', value: 'auto' }); + }); +}); diff --git a/control/src/operations/operation-engine.ts b/control/src/operations/operation-engine.ts new file mode 100644 index 0000000..a459cc1 --- /dev/null +++ b/control/src/operations/operation-engine.ts @@ -0,0 +1,246 @@ +import { + canAutoRetry, + classifyOperationCompletion, + type DeviceGeneration, + type OperationDescriptor, + type OperationResult, + type PhysicalModemId, +} from '../domain'; +import { acquireMutationAdmission, type MutationAdmissionResult } from '../ports'; +import type { ModemControlCompositionRoot } from '../safety'; +import type { + OperationAttemptContext, + OperationExecution, + OperationPreconditionPort, + ReconciliationExecution, + ReconciliationResult, +} from './contracts'; + +export * from './contracts'; + +export type OperationEngineOptions = { + readonly root: ModemControlCompositionRoot; + readonly currentGeneration: (physicalModemId: PhysicalModemId) => DeviceGeneration; + readonly preconditions: OperationPreconditionPort; +}; + +const MAX_IDEMPOTENT_READ_ATTEMPTS = 2; + +export class OperationEngine { + readonly #root: ModemControlCompositionRoot; + readonly #currentGeneration: OperationEngineOptions['currentGeneration']; + readonly #preconditions: OperationPreconditionPort; + readonly #reconciliationRequired = new Set(); + + constructor(options: OperationEngineOptions) { + this.#root = options.root; + this.#currentGeneration = options.currentGeneration; + this.#preconditions = options.preconditions; + } + + invoke(execution: OperationExecution): Promise> { + if (execution.descriptor.mutationImpact === 'read') { + return this.#invokeNow(execution); + } + return this.#root + .actorFor(execution.physicalModemId) + .run(execution.physicalModemId, () => this.#invokeNow(execution)); + } + + reconcile(execution: ReconciliationExecution): Promise { + return this.#root + .actorFor(execution.physicalModemId) + .run(execution.physicalModemId, async () => { + const current = this.#currentGeneration(execution.physicalModemId); + if (!this.#reconciliationRequired.has(execution.physicalModemId)) { + return { status: 'not-required', generation: current }; + } + if (current !== execution.generation) { + return { status: 'failed', reason: 'stale-generation', generation: current }; + } + const result = await execution.run(); + const completedGeneration = this.#currentGeneration(execution.physicalModemId); + if (completedGeneration !== execution.generation) { + return { + status: 'failed', + reason: 'stale-generation', + generation: completedGeneration, + }; + } + if (result.status === 'failed') { + return { ...result, generation: completedGeneration }; + } + this.#reconciliationRequired.delete(execution.physicalModemId); + return { status: 'reconciled', generation: completedGeneration }; + }); + } + + async #invokeNow(execution: OperationExecution): Promise> { + const generation = this.#currentGeneration(execution.physicalModemId); + const kind = execution.descriptor.mutationImpact === 'read' ? 'read' : 'write'; + if (kind === 'write' && this.#reconciliationRequired.has(execution.physicalModemId)) { + return refused('reconciliation-required', generation); + } + + const descriptorRefusal = descriptorRefusalReason(execution); + if (descriptorRefusal !== undefined) return refused(descriptorRefusal, generation); + + const precondition = await this.#preconditions.check({ + operationId: execution.operationId, + physicalModemId: execution.physicalModemId, + generation, + descriptorId: execution.descriptor.id, + preconditions: execution.descriptor.livePreconditions, + }); + if (precondition.status === 'refused') return refused(precondition.reason, generation); + + const admission = await acquireMutationAdmission( + { + operationId: execution.operationId, + physicalModemId: execution.physicalModemId, + impact: execution.descriptor.mutationImpact, + requirement: execution.descriptor.admission, + }, + kind === 'write' ? this.#root.admission : undefined, + ); + if (admission.status === 'refused') return refused(admission.reason, generation); + + try { + await execution.journal?.record({ + phase: 'started', + operationId: execution.operationId, + physicalModemId: execution.physicalModemId, + generation, + descriptor: execution.descriptor, + input: execution.input, + }); + const result = await this.#executeWithRetry(execution, generation, kind); + const withHooks = await this.#runCompletionHooks(execution, result, generation); + if (withHooks.status === 'unknown-outcome') { + this.#reconciliationRequired.add(execution.physicalModemId); + } + await execution.journal?.record({ + phase: 'completed', + operationId: execution.operationId, + physicalModemId: execution.physicalModemId, + generation, + descriptor: execution.descriptor, + result: withHooks, + }); + return withHooks; + } finally { + await releaseAdmission(admission); + } + } + + async #executeWithRetry( + execution: OperationExecution, + generation: DeviceGeneration, + kind: 'read' | 'write', + ): Promise> { + for (let attempt = 1; attempt <= MAX_IDEMPOTENT_READ_ATTEMPTS; attempt += 1) { + const completion = await execution.execute({ + operationId: execution.operationId, + generation, + attempt, + }); + const result = classifyOperationCompletion({ + operation: kind, + completionGeneration: generation, + currentGeneration: this.#currentGeneration(execution.physicalModemId), + completion, + }); + if (!canAutoRetry(execution.descriptor, result) || attempt === MAX_IDEMPOTENT_READ_ATTEMPTS) { + return result; + } + } + return refused('retry-budget-exhausted', generation); + } + + async #runCompletionHooks( + execution: OperationExecution, + result: OperationResult, + generation: DeviceGeneration, + ): Promise> { + const context: OperationAttemptContext = { + operationId: execution.operationId, + generation, + attempt: 1, + }; + if (result.status === 'applied' && execution.descriptor.readback.required) { + const completion = await execution.readback?.(context); + if (completion === undefined) return refused('readback-hook-missing', generation); + const readback = classifyOperationCompletion({ + operation: 'read', + completionGeneration: generation, + currentGeneration: this.#currentGeneration(execution.physicalModemId), + completion, + }); + if ( + readback.status === 'applied' && + execution.descriptor.readback.matches(execution.input, readback.value) + ) { + return result; + } + if (readback.status === 'unknown-outcome') return readback; + await execution.rollback?.(context); + return refused( + readback.status === 'applied' ? 'readback-mismatch' : `readback-${readback.reason}`, + generation, + 'failed', + ); + } + if (result.status === 'failed' && execution.descriptor.rollback.required) { + await execution.rollback?.(context); + } + return result; + } +} + +export function createOperationEngine(options: OperationEngineOptions): OperationEngine { + return new OperationEngine(options); +} + +function descriptorRefusalReason(execution: OperationExecution): string | undefined { + const { descriptor } = execution; + const support = + descriptor.mutationImpact === 'read' ? descriptor.support.read : descriptor.support.write; + if (!support.supported) return support.reason; + if (descriptor.availability.state !== 'available') return descriptor.availability.reason; + if (!inputAllowed(descriptor, execution.input)) return 'constraint-refused'; + if (descriptor.readback.required && execution.readback === undefined) + return 'readback-hook-missing'; + if (descriptor.rollback.required && execution.rollback === undefined) + return 'rollback-hook-missing'; + if (descriptor.journal.required && execution.journal === undefined) return 'journal-hook-missing'; + return undefined; +} + +function inputAllowed(descriptor: OperationDescriptor, input: I): boolean { + switch (descriptor.constraints.kind) { + case 'unconstrained': + return true; + case 'allowed-values': + return descriptor.constraints.values.some((value) => Object.is(value, input)); + case 'numeric-range': + return ( + typeof input === 'number' && + input >= descriptor.constraints.min && + input <= descriptor.constraints.max && + (descriptor.constraints.step === undefined || + (input - descriptor.constraints.min) % descriptor.constraints.step === 0) + ); + } +} + +function refused( + reason: string, + generation: DeviceGeneration, + status: 'refused' | 'failed' = 'refused', +): OperationResult { + return { status, reason, generation, requiresReconciliation: false }; +} + +async function releaseAdmission(admission: MutationAdmissionResult): Promise { + if (admission.status === 'admitted') await admission.lease.release(); +} diff --git a/control/src/ports/README.md b/control/src/ports/README.md index 3b8d7c9..b5b0948 100644 --- a/control/src/ports/README.md +++ b/control/src/ports/README.md @@ -11,8 +11,12 @@ performs I/O; these are pure TypeScript contracts. |------|------|----------------| | `ModemObservationPort` | [`observation.ts`](./observation.ts) | Read-only: `start()`, `observe()`, `stop()`; emits discriminated `ObservationList` results that **retain rows** on source failure (removal is only ever an authoritative snapshot omission). | | `ModemManagerPort` | [`modem-manager.ts`](./modem-manager.ts) | **Extends** the observation port with the mutations MM owns: `setRadioModes`, `setPrimarySimSlot`, `sendPin`/`sendPuk`, `scanNetworks`, `inhibit`/`uninhibit`. **NO bearer/connect verb.** | -| `NetworkManagerPort` | [`network-manager.ts`](./network-manager.ts) | GSM profile CRUD; `activate`/`deactivate` taking **both** `(connectionId, deviceIfname)`; quiesce lease. | +| `NetworkManagerPort` | [`network-manager.ts`](./network-manager.ts) | GSM profile CRUD; `activate`/`deactivate` taking **both** `(connectionId, deviceIfname)`; quiesce lease. Implemented by `backend/nmcli-nm-port.ts`; `providers/network-manager/` is the saved-vs-applied adapter built on top of it. | | `RouterPort` | [`router.ts`](./router.ts) | Presence + advisory health only, for devices MM cannot control. | +| `MutationAdmissionPort` | [`mutation-admission.ts`](./mutation-admission.ts) | Consumer-owned acquire/refuse authority for descriptor-gated mutations; no policy is derived here. | +| `ResourceOwnershipPort` | [`resource-ownership.ts`](./resource-ownership.ts) | Non-queueing exclusive ownership for file stores, router sessions, and USB-hub access. | +| `ModemManagerInhibitPort` | [`modem-manager.ts`](./modem-manager.ts) | Narrow MM inhibit/uninhibit maintenance lease. | +| `UhubctlPort` | [`uhubctl.ts`](./uhubctl.ts) | Injected USB-hub actuator contract; the control package ships no implementation. | ## Ownership matrix — one sole writer per resource diff --git a/control/src/ports/index.ts b/control/src/ports/index.ts index 211cd86..4137334 100644 --- a/control/src/ports/index.ts +++ b/control/src/ports/index.ts @@ -7,10 +7,15 @@ // recovery + usage policy. The ModemManagerPort has NO bearer / connect verb — // enforced at build time by forbidden-surface.test.ts. +export * from './location'; export * from './modem-manager'; +export * from './mutation-admission'; export * from './network-manager'; export * from './observation'; export * from './ops'; export * from './receipts'; export * from './reconcile'; +export * from './resource-ownership'; export * from './router'; +export * from './sms'; +export * from './uhubctl'; diff --git a/control/src/ports/location-fence.test.ts b/control/src/ports/location-fence.test.ts new file mode 100644 index 0000000..4324940 --- /dev/null +++ b/control/src/ports/location-fence.test.ts @@ -0,0 +1,126 @@ +// Guard: the GPS module may never grow history, tracking, persistence, or upload. +// +// This is a PRODUCT fence, not a phase limitation — "we only read the current fix" +// is a promise made to the operator, and a promise held by convention is a promise +// already broken. It is enforced the same way the bearer invariant is: an AST scan +// of the declared type surface, plus a source scan of the implementation for the +// I/O primitives a leak would have to go through. + +import { expect, test } from 'bun:test'; +import { readdirSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import * as ts from 'typescript'; +import { MODEM_LOCATION_IFACE } from '../backend/constants'; +import { LOCATION_IFACE } from '../capability/detect'; + +const srcDir = dirname(import.meta.dir); +const portFile = join(import.meta.dir, 'location.ts'); + +/** A member name implying the fix outlives the moment it was read. */ +const FORBIDDEN_MEMBER = /histor|track|breadcrumb|waypoint|trail|upload|persist|archive|replay/i; + +/** Primitives a fix would have to travel through to leave the process. */ +const FORBIDDEN_IO = [ + 'writeFile', + 'appendFile', + 'writeFileSync', + 'appendFileSync', + 'createWriteStream', + 'fetch(', + 'XMLHttpRequest', + 'localStorage', + 'Bun.write', +]; + +function declaredMemberNames(source: string): string[] { + const sourceFile = ts.createSourceFile('port.ts', source, ts.ScriptTarget.Latest, true); + const names: string[] = []; + const visit = (node: ts.Node): void => { + if ( + ts.isInterfaceDeclaration(node) || + ts.isClassDeclaration(node) || + ts.isClassExpression(node) || + ts.isTypeLiteralNode(node) + ) { + for (const member of node.members) { + if (member.name !== undefined && ts.isIdentifier(member.name)) { + names.push(member.name.text); + } + } + } + ts.forEachChild(node, visit); + }; + visit(sourceFile); + return names; +} + +function gpsSourceFiles(): string[] { + const files = [portFile, join(srcDir, 'backend', 'mm-location.ts')]; + for (const entry of readdirSync(join(srcDir, 'location'))) { + if (entry.endsWith('.ts') && !entry.endsWith('.test.ts')) { + files.push(join(srcDir, 'location', entry)); + } + } + return files; +} + +test('the location port declares no history / tracking / persistence member', async () => { + const source = await Bun.file(portFile).text(); + const forbidden = declaredMemberNames(source).filter((name) => FORBIDDEN_MEMBER.test(name)); + expect(forbidden, `forbidden member(s): ${forbidden.join(', ')}`).toEqual([]); +}); + +test('the location port declares exactly the four current-fix verbs', async () => { + const source = await Bun.file(portFile).text(); + const sourceFile = ts.createSourceFile('port.ts', source, ts.ScriptTarget.Latest, true); + let members: string[] = []; + const visit = (node: ts.Node): void => { + if (ts.isInterfaceDeclaration(node) && node.name.text === 'ModemLocationPort') { + members = node.members + .map((member) => (member.name && ts.isIdentifier(member.name) ? member.name.text : '')) + .filter((name) => name !== ''); + } + ts.forEachChild(node, visit); + }; + visit(sourceFile); + + expect(members.sort()).toEqual(['disableGnss', 'enableGnss', 'getLocationStatus', 'readFix']); +}); + +test('no GPS source file reaches a filesystem, a network, or a browser store', async () => { + for (const file of gpsSourceFiles()) { + const source = await Bun.file(file).text(); + for (const primitive of FORBIDDEN_IO) { + expect(source.includes(primitive), `${file} must not use ${primitive}`).toBe(false); + } + } +}); + +test('no GPS source file declares a history / tracking member', async () => { + for (const file of gpsSourceFiles()) { + const source = await Bun.file(file).text(); + const forbidden = declaredMemberNames(source).filter((name) => FORBIDDEN_MEMBER.test(name)); + expect(forbidden, `forbidden member(s) in ${file}: ${forbidden.join(', ')}`).toEqual([]); + } +}); + +test('the detector is not vacuous — it flags a history surface if one is added', () => { + const rogue = ` + export interface RogueLocation { + readFix(): Promise; + fixHistory(): Promise; + startTracking(): void; + uploadTrack(): Promise; + persistFix(): Promise; + } + `; + expect( + declaredMemberNames(rogue) + .filter((name) => FORBIDDEN_MEMBER.test(name)) + .sort(), + ).toEqual(['fixHistory', 'persistFix', 'startTracking', 'uploadTrack']); +}); + +test('the capability probe and the adapter agree on the Location interface name', () => { + expect(LOCATION_IFACE).toBe(MODEM_LOCATION_IFACE); +}); diff --git a/control/src/ports/location.ts b/control/src/ports/location.ts new file mode 100644 index 0000000..3ac40f7 --- /dev/null +++ b/control/src/ports/location.ts @@ -0,0 +1,111 @@ +// The GNSS location port — ModemManager's `Modem.Location` interface, scoped by a +// privacy fence that is a PRODUCT rule, not a phase limitation. +// +// THE FENCE: this port reads the CURRENT fix and nothing else. There is no history +// verb, no track verb, no export verb, and no upload verb, and none may ever be +// added — a fix is held in memory for a live display and is gone the moment GNSS is +// disabled or the fix goes stale. `location-fence.test.ts` fails the build if a +// member whose name implies history, tracking, persistence, or upload appears here. +// +// A fix also carries coordinates, which say where the operator physically is. That +// class is redacted by `../redact` (`latitude` / `longitude` / `altitude` / `nmea`), +// so a fix that reaches a log line or a receipt comes out as the redaction marker +// rather than a position. + +import type { EpochMillis } from '../domain'; +import type { ModemRef } from './modem-manager'; + +/** + * The GNSS sources `Modem.Location.Capabilities` can advertise, as decoded source + * names. `3gpp-lac-ci` is deliberately absent: coarse cell location is the cell-info + * module's surface, and MM advertises it on devices with no GNSS receiver at all. + */ +export const GNSS_SOURCES = [ + 'gps-raw', + 'gps-nmea', + 'gps-unmanaged', + 'agps-msa', + 'agps-msb', +] as const; +export type GnssSource = (typeof GNSS_SOURCES)[number]; + +/** What `Modem.Location` advertises and what is switched on right now. */ +export interface LocationStatus { + /** Every source name in `Capabilities`, GNSS or not (`3gpp-lac-ci` included). */ + readonly capabilities: ReadonlySet; + /** Every source name in `Enabled`. */ + readonly enabledSources: ReadonlySet; + /** True when at least one GNSS source is advertised. */ + readonly gnssCapable: boolean; + /** True when at least one GNSS source is switched on. */ + readonly gnssEnabled: boolean; +} + +export type LocationStatusResult = + | { readonly ok: true; readonly status: LocationStatus } + | { readonly ok: false; readonly reason: string }; + +/** + * One GNSS fix. SENSITIVE: never persist it, never upload it, never put it in a log + * line. `observedAt` is when this process READ the fix, which is what staleness is + * measured against — a modem's own `utc-time` cannot be trusted to advance. + */ +export interface GnssFix { + readonly latitude: number; + readonly longitude: number; + readonly altitude?: number; + readonly utcTime?: string; + readonly observedAt: EpochMillis; +} + +/** + * The outcome of ONE read attempt. `no-fix` is a first-class success: the modem + * answered and has not acquired a position. It is never conflated with an error and + * never answered with a previous fix — a stale coordinate rendered as current is the + * one failure mode this module exists to prevent. + */ +export type FixRead = + | { readonly outcome: 'fix'; readonly fix: GnssFix } + | { readonly outcome: 'no-fix'; readonly reason: string } + | { readonly outcome: 'disabled'; readonly reason: string } + | { readonly outcome: 'unsupported'; readonly reason: string } + | { readonly outcome: 'error'; readonly reason: string }; + +/** + * The outcome of switching GNSS on or off. Modelled like `SimUnlockResult` rather + * than as a reconcile `Receipt`: GNSS is an operator-invoked, gate-defaulted-off + * action with no desired-state dimension, so it is not something the planner + * converges. + */ +export interface LocationToggleResult { + readonly outcome: 'applied' | 'unsupported' | 'failed'; + readonly reason: string; + /** The GNSS sources enabled after the call — empty after a successful disable. */ + readonly enabledSources: ReadonlySet; +} + +/** + * Read GNSS status, switch GNSS on/off, and read the current fix. + * + * Deliberately NOT part of `ModemManagerPort`: a consumer that only reconciles + * radio and SIM state has no business holding a handle that can read a position. + */ +export interface ModemLocationPort { + getLocationStatus(modem: ModemRef): Promise; + enableGnss(modem: ModemRef, sources: readonly GnssSource[]): Promise; + disableGnss(modem: ModemRef): Promise; + readFix(modem: ModemRef): Promise; +} + +export function isGnssSource(value: string): value is GnssSource { + return (GNSS_SOURCES as readonly string[]).includes(value); +} + +export function hasGnssSource(sources: Iterable): boolean { + for (const source of sources) { + if (isGnssSource(source)) { + return true; + } + } + return false; +} diff --git a/control/src/ports/modem-manager.ts b/control/src/ports/modem-manager.ts index f3377aa..4d8d0c4 100644 --- a/control/src/ports/modem-manager.ts +++ b/control/src/ports/modem-manager.ts @@ -41,6 +41,24 @@ export type NetworkScanResult = | { readonly ok: true; readonly networks: readonly ScannedNetwork[] } | { readonly ok: false; readonly reason: string }; +/** + * What a modem says about its bands, right now. + * + * `supported` and `current` are BOTH required, and both may legitimately be + * empty: a modem that advertises no band control answers an empty `supported`, + * which is a real reading and the thing that keeps a control from being offered. + * A read that could not happen at all is the `ok: false` arm instead — the same + * "unknown is an answer about the READ" split the capability probes follow. + */ +export interface ModemBands { + readonly supported: readonly string[]; + readonly current: readonly string[]; +} + +export type BandReadResult = + | { readonly ok: true; readonly bands: ModemBands } + | { readonly ok: false; readonly reason: string }; + /** A held inhibition over a modem, released via `uninhibit`. */ export interface InhibitLease { /** The equipment UID the inhibition is keyed to. */ @@ -54,9 +72,23 @@ export interface InhibitLease { * network scan, and inhibit / uninhibit. It owns NO bearer / connection lifecycle; * bearers and activation belong to `NetworkManagerPort`. */ -export interface ModemManagerPort extends ModemObservationPort { +export interface ModemManagerInhibitPort { + /** Inhibit MM from managing a device (for a maintenance lease), keyed by UID. */ + inhibit(uid: string): Promise; + /** Release a previously-taken inhibition. */ + uninhibit(lease: InhibitLease): Promise; +} + +export interface ModemManagerPort extends ModemObservationPort, ModemManagerInhibitPort { /** Set the modem's radio access-technology preference. */ setRadioModes(modem: ModemRef, preference: DesiredRadio): Promise; + /** Read the modem's supported and currently-selected bands. */ + readBands(modem: ModemRef): Promise; + /** + * Lock the modem to a band selection. Passing exactly `['any']` releases the + * lock — ModemManager has no separate reset verb, so this IS the reset. + */ + setCurrentBands(modem: ModemRef, bands: readonly string[]): Promise; /** Select the primary SIM slot (multi-slot modems only). */ setPrimarySimSlot(modem: ModemRef, slotIndex: number): Promise; /** Submit a SIM PIN (exactly-once; read-before-submit is the adapter's job). */ @@ -65,8 +97,4 @@ export interface ModemManagerPort extends ModemObservationPort { sendPuk(modem: ModemRef, puk: string, newPin: string): Promise; /** Scan for visible networks (long-running). */ scanNetworks(modem: ModemRef): Promise; - /** Inhibit MM from managing a device (for a maintenance lease), keyed by UID. */ - inhibit(uid: string): Promise; - /** Release a previously-taken inhibition. */ - uninhibit(lease: InhibitLease): Promise; } diff --git a/control/src/ports/mutation-admission.test.ts b/control/src/ports/mutation-admission.test.ts new file mode 100644 index 0000000..55f4981 --- /dev/null +++ b/control/src/ports/mutation-admission.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, test } from 'bun:test'; +import { physicalModemId } from '../domain'; +import { acquireMutationAdmission, type MutationAdmissionPort } from './mutation-admission'; + +const modemId = physicalModemId('serial:admission-test'); + +describe('MutationAdmissionPort', () => { + test('Given required admission and no port, When admission is requested, Then it is refused with a typed reason', async () => { + const result = await acquireMutationAdmission( + { + operationId: 'set-radio-mode', + physicalModemId: modemId, + impact: 'disruptive', + requirement: { required: true, reason: 'external controller approval is required' }, + }, + undefined, + ); + + expect(result).toEqual({ status: 'refused', reason: 'admission-port-missing' }); + }); + + test('Given an injected refusing port, When admission is requested, Then its typed refusal is preserved', async () => { + const port: MutationAdmissionPort = { + acquire: () => + Promise.resolve({ + status: 'refused', + reason: 'admission-refused', + detail: 'controller lease unavailable', + }), + }; + + const result = await acquireMutationAdmission( + { + operationId: 'set-radio-mode', + physicalModemId: modemId, + impact: 'write', + requirement: { required: true, reason: 'controller approval' }, + }, + port, + ); + + expect(result).toEqual({ + status: 'refused', + reason: 'admission-refused', + detail: 'controller lease unavailable', + }); + }); + + test('Given admission is not required, When no port is injected, Then no synthetic lease is invented', async () => { + const result = await acquireMutationAdmission( + { + operationId: 'read-signal', + physicalModemId: modemId, + impact: 'read', + requirement: { required: false }, + }, + undefined, + ); + + expect(result).toEqual({ status: 'not-required' }); + }); +}); diff --git a/control/src/ports/mutation-admission.ts b/control/src/ports/mutation-admission.ts new file mode 100644 index 0000000..96ce6be --- /dev/null +++ b/control/src/ports/mutation-admission.ts @@ -0,0 +1,44 @@ +import type { MutationImpact, OperationRequirement, PhysicalModemId } from '../domain'; + +export type MutationAdmissionRequest = { + readonly operationId: string; + readonly physicalModemId: PhysicalModemId; + readonly impact: MutationImpact; + readonly requirement: OperationRequirement; +}; + +export interface MutationAdmissionLease { + release(): Promise; +} + +export type MutationAdmissionRefusalReason = 'admission-port-missing' | 'admission-refused'; + +export type MutationAdmissionDecision = + | { readonly status: 'admitted'; readonly lease: MutationAdmissionLease } + | { + readonly status: 'refused'; + readonly reason: MutationAdmissionRefusalReason; + readonly detail?: string; + }; + +export type MutationAdmissionResult = + | { readonly status: 'not-required' } + | MutationAdmissionDecision; + +/** Consumer-owned admission authority. The package neither derives nor interprets its policy. */ +export interface MutationAdmissionPort { + acquire(request: MutationAdmissionRequest): Promise; +} + +export function acquireMutationAdmission( + request: MutationAdmissionRequest, + port: MutationAdmissionPort | undefined, +): Promise { + if (!request.requirement.required) { + return Promise.resolve({ status: 'not-required' }); + } + if (port === undefined) { + return Promise.resolve({ status: 'refused', reason: 'admission-port-missing' }); + } + return port.acquire(request); +} diff --git a/control/src/ports/receipts.ts b/control/src/ports/receipts.ts index 926f6cb..a781ac0 100644 --- a/control/src/ports/receipts.ts +++ b/control/src/ports/receipts.ts @@ -10,6 +10,7 @@ export type PolicyDimension = | 'connection' | 'roaming' | 'radio' + | 'band' | 'simSlot' | 'recovery' | 'usage'; diff --git a/control/src/ports/resource-ownership.ts b/control/src/ports/resource-ownership.ts new file mode 100644 index 0000000..8b00f90 --- /dev/null +++ b/control/src/ports/resource-ownership.ts @@ -0,0 +1,35 @@ +export const DEFAULT_MODEM_CONTROL_LOCK_PATH = '/run/ceralive/modem-control.lock'; + +export type ExclusiveResource = 'file-store' | 'router-session' | 'usb-hub'; + +export type ResourceOwnershipRequest = { + readonly resource: ExclusiveResource; +}; + +export type ResourceOwnershipHolder = { + readonly pid: number; + readonly startedAtEpochMs: number; +}; + +export type ResourceOwnershipLoss = { + readonly reason: 'holder-exited'; +}; + +export interface ResourceOwnershipLease { + readonly holder: ResourceOwnershipHolder; + readonly lost: Promise; + release(): Promise; +} + +export type ResourceOwnershipResult = + | { readonly status: 'acquired'; readonly lease: ResourceOwnershipLease } + | { + readonly status: 'refused'; + readonly reason: 'already-owned'; + readonly holder?: ResourceOwnershipHolder; + }; + +/** Acquire-or-refuse ownership; implementations must never queue a contender. */ +export interface ResourceOwnershipPort { + acquire(request: ResourceOwnershipRequest): Promise; +} diff --git a/control/src/ports/sms.ts b/control/src/ports/sms.ts new file mode 100644 index 0000000..10b8f89 --- /dev/null +++ b/control/src/ports/sms.ts @@ -0,0 +1,85 @@ +// The SMS port — LIST / READ and Added/Deleted observation. NOTHING ELSE. +// +// READ-ONLY IS PERMANENT, NOT A PHASE LIMITATION. There is no verb here that +// composes, stores, sends, or deletes a message, and none may ever be added. +// Sending or deleting an SMS is billable and irreversible, and it turns a +// diagnostic read into real modem-control capability over the subscriber's +// account. CeraUI carries the same contract on its own side, enforced by a grep +// gate (`tests/modem-sms-readonly-gate.test.ts`); `sms/readonly-gate.test.ts` +// is this package's half. Neither may be weakened to land a write path — that +// is a new spec change with its own confirmation/interlock design. +// +// THE OBSERVATION MODEL IS "LIST ONCE, THEN FOLLOW THE SIGNALS". ModemManager's +// Messaging interface emits `Added` and `Deleted` for every inbox change, so a +// consumer lists once at start and folds events from then on. Re-listing on a +// poll tick is the anti-pattern this port exists to remove: it costs one method +// call per message per tick and still cannot report an arrival any sooner than +// the tick it lands on. +// +// CONTENT NEVER RIDES A DIAGNOSTIC. A message body routinely carries a one-time +// code and its originator identifies the subscriber, so nothing on this port +// puts either into a reason string, a receipt, or a log line — see +// `../redact.ts` for the key class and `../sms/mmcli-parse.ts` for the +// content-free parse errors. + +import type { Unsubscribe } from './observation'; + +/** A message's lifecycle state, verbatim from ModemManager's own vocabulary. */ +export type SmsState = 'unknown' | 'stored' | 'receiving' | 'received' | 'sending' | 'sent'; + +/** One stored message, normalized. `text` is `''` for a data-only (WAP/PDU) message. */ +export interface SmsMessage { + /** The trailing `/SMS/` object index, stringified. */ + readonly id: string; + readonly from?: string; + /** The service-centre timestamp, verbatim as the device reported it. */ + readonly timestamp?: string; + readonly text: string; + readonly state: SmsState; +} + +/** + * Why an inbox read produced no list. These are the four operator-actionable + * facts, and they are deliberately distinct from one another — a modem with no + * Messaging interface, a radio that has not come up, a selector nothing answers + * to, and drift in whatever the source printed are four different next steps. + */ +export type SmsReadRefusal = 'unsupported' | 'not_enabled' | 'unknown_modem' | 'read_failed'; + +/** + * The result of an inbox read. A REFUSAL IS NEVER AN EMPTY LIST: `{ok: true, + * messages: []}` means this modem has an inbox and it is empty. + */ +export type SmsInboxResult = + | { readonly ok: true; readonly messages: readonly SmsMessage[] } + | { readonly ok: false; readonly reason: SmsReadRefusal }; + +/** + * One inbox change. `resynced` carries a full authoritative list and is what a + * source restart produces — a consumer replaces its rows with it rather than + * merging, so a restart can never duplicate a row it already holds. + */ +export type SmsInboxEvent = + | { readonly kind: 'added'; readonly message: SmsMessage } + | { readonly kind: 'deleted'; readonly id: string } + | { readonly kind: 'resynced'; readonly messages: readonly SmsMessage[] }; + +export type SmsInboxListener = (event: SmsInboxEvent) => void; + +/** + * The read-only SMS port. `list()` resolves the current inbox once; `observe()` + * streams every subsequent change; `stop()` releases the source. + * + * It is deliberately NOT an extension of `ModemManagerPort`: a consumer that + * only reads an inbox must not acquire radio, SIM, or inhibit verbs along the + * way — the same narrowing argument `ModemObservationPort` makes for the + * shadow reader. + */ +export interface SmsObservationPort { + /** Read the modem's stored inbox, newest-first and capped. */ + list(): Promise; + /** Subscribe to Added/Deleted/resync events. Returns an unsubscribe. */ + observe(listener: SmsInboxListener): Unsubscribe; + /** Release the source. Idempotent. */ + stop(): Promise; +} diff --git a/control/src/ports/uhubctl.test.ts b/control/src/ports/uhubctl.test.ts new file mode 100644 index 0000000..a06c088 --- /dev/null +++ b/control/src/ports/uhubctl.test.ts @@ -0,0 +1,15 @@ +import { expect, test } from 'bun:test'; +import { readdir, readFile } from 'node:fs/promises'; +import { join } from 'node:path'; + +test('Given the control package, When its source is inspected, Then no concrete uhubctl invocation ships', async () => { + const backend = join(import.meta.dir, '..', 'backend'); + const files = (await readdir(backend)).filter((name) => name.endsWith('.ts')); + const forbidden = ['create', 'Uhubctl', 'PowerHook'].join(''); + const spawn = ['Spawn', 'Uhubctl', 'Runner'].join(''); + for (const file of files) { + const source = await readFile(join(backend, file), 'utf8'); + expect(source.includes(forbidden)).toBeFalse(); + expect(source.includes(spawn)).toBeFalse(); + } +}); diff --git a/control/src/ports/uhubctl.ts b/control/src/ports/uhubctl.ts new file mode 100644 index 0000000..918b958 --- /dev/null +++ b/control/src/ports/uhubctl.ts @@ -0,0 +1,11 @@ +import type { + PowerCapability, + PowerCycleContext, + PowerCycleResult, +} from '../backend/power-contract'; + +/** Consumer-supplied USB-hub actuator. This package intentionally ships no implementation. */ +export interface UhubctlPort { + readonly capability: PowerCapability; + cycle(context: PowerCycleContext): Promise; +} diff --git a/control/src/providers/conformance-matrix.test.ts b/control/src/providers/conformance-matrix.test.ts new file mode 100644 index 0000000..cad72f7 --- /dev/null +++ b/control/src/providers/conformance-matrix.test.ts @@ -0,0 +1,362 @@ +// The provider-matching conformance MATRIX. +// +// `conformance.test.ts` (todo 5) proves the matcher against a synthetic fixture provider; +// each provider suite (todos 22/24/25/26) proves one real provider in a registry +// containing only itself. Neither can answer the question a fleet actually poses: with +// ModemManager, Huawei HiLink, ZTE goform and UFI/HIMI ALL registered, does every device +// still reach exactly the provider and profile it is entitled to — and, more importantly, +// does no device reach one it is not? +// +// Every case here registers all four. The three providers a device does not belong to are +// scripted as devices that answer nothing they understand, which is what a real board +// looks like. The expectation is the exact decision — provider, profile, writability and +// evidence score — because a shape assertion cannot see a provider quietly claiming a +// neighbour's hardware. +// +// The matrix summary artifact is written from this file (test-results/, gitignored). + +import { beforeAll, describe, expect, test } from 'bun:test'; +import { + CONFORMANCE_CASES, + CONFORMANCE_CREDENTIALS, + CORPUS_BODIES, + type ConformanceRun, + goformId, + HILINK_TWIN_INTERFACE, + MATRIX_MARKDOWN_PATH, + type MatrixRow, + observedExpectation, + type RecordedExchange, + SANITIZED_SUBSCRIBER_IDENTIFIERS, + writeMatrixArtifact, +} from '../../test-support/conformance'; +import { HILINK_PATHS } from './huawei-hilink/provider'; + +const runs = new Map(); +let rows: readonly MatrixRow[] = []; +let artifact = ''; + +const runOf = (id: string): ConformanceRun => { + const run = runs.get(id); + if (run === undefined) throw new Error(`conformance case did not run: ${id}`); + return run; +}; + +const allExchanges = (run: ConformanceRun): readonly RecordedExchange[] => [ + ...run.transcripts.hilink, + ...run.transcripts.hilinkTwin, + ...run.transcripts.zte, + ...run.transcripts.ufi, +]; + +const hilinkLogins = (run: ConformanceRun): readonly RecordedExchange[] => + run.transcripts.hilink.filter( + (exchange) => exchange.method === 'POST' && exchange.path === HILINK_PATHS.login, + ); + +const zteLogins = (run: ConformanceRun): readonly RecordedExchange[] => + run.transcripts.zte.filter((exchange) => exchange.method === 'POST'); + +beforeAll(async () => { + const collected: MatrixRow[] = []; + for (const entry of CONFORMANCE_CASES) { + const run = await entry.run(); + runs.set(entry.id, run); + const actual = observedExpectation(run.result); + collected.push({ + id: entry.id, + kind: entry.kind, + summary: entry.summary, + expected: entry.expected, + actual, + agrees: JSON.stringify(entry.expected) === JSON.stringify(actual), + }); + } + rows = collected; + artifact = writeMatrixArtifact(collected); +}); + +describe('provider-matching conformance matrix', () => { + test.each(CONFORMANCE_CASES.map((entry) => [entry.id] as const))( + '%s reaches exactly its entitled decision', + (id) => { + // Given + const entry = CONFORMANCE_CASES.find((candidate) => candidate.id === id); + + // When + const actual = observedExpectation(runOf(id).result); + + // Then + expect(actual).toEqual(entry?.expected as never); + }, + ); + + test('every case is present in the matrix exactly once', () => { + // Given / When + const ids = rows.map((row) => row.id); + + // Then + expect(new Set(ids).size).toBe(CONFORMANCE_CASES.length); + expect(rows.every((row) => row.agrees)).toBe(true); + }); + + test('the matrix artifact lists every case with its expected and actual decision', () => { + // Given / When + const document = artifact; + + // Then + expect(document).toContain('# Provider-matching conformance matrix'); + expect(document).toContain(`Cases: **${CONFORMANCE_CASES.length}**`); + for (const entry of CONFORMANCE_CASES) expect(document).toContain(entry.id); + expect(MATRIX_MARKDOWN_PATH.endsWith('provider-conformance-matrix.md')).toBe(true); + }); +}); + +describe('conformance safety invariants', () => { + test('no unresolved, malformed, expired, locked-out or misrouted case is ever writable', () => { + // Given + const unsafeToWrite = rows.filter((row) => row.kind !== 'fleet-profile'); + + // When + const writable = unsafeToWrite.filter((row) => row.actual.writable); + + // Then + expect(unsafeToWrite.length).toBeGreaterThan(0); + expect(writable).toEqual([]); + }); + + test('an unresolved decision carries no provider, no profile and no operations surface', () => { + // Given + const unresolved = rows.filter((row) => row.actual.status !== 'selected'); + + // Then + expect(unresolved.length).toBeGreaterThan(0); + for (const row of unresolved) { + const result = runOf(row.id).result; + expect(result.provider).toBeNull(); + expect(result.profile).toBeNull(); + expect(result.operations).toBeNull(); + expect(result.writable).toBe(false); + expect(result.evidence.length).toBeGreaterThan(0); + } + }); + + test('no matcher result carries the credential the wire carried', () => { + // Given / When + const serialized = rows.map((row) => JSON.stringify(runOf(row.id).result)); + + // Then + for (const document of serialized) { + expect(document).not.toContain(CONFORMANCE_CREDENTIALS.password); + } + }); +}); + +describe('ambiguous collision — a tie never yields a write-capable pick', () => { + const id = 'ambiguity/colliding-write-capable-twins'; + + test('two write-capable providers sharing one fingerprint resolve read-only', () => { + // Given + const run = runOf(id); + + // Then + expect(run.result.status).toBe('ambiguous'); + expect(run.result.provider).toBeNull(); + expect(run.result.operations).toBeNull(); + expect(run.result.writable).toBe(false); + }); + + test('the tie is decided BEFORE authentication, so neither credential is spent', () => { + // Given + const run = runOf(id); + + // Then + expect(run.twinAuthAttempts).toBe(0); + expect(hilinkLogins(run)).toEqual([]); + expect(run.transcripts.hilinkTwin.filter((exchange) => exchange.method === 'POST')).toEqual([]); + }); + + test('the evidence ledger exposes BOTH colliding providers rather than hiding the tie', () => { + // Given + const run = runOf(id); + + // When + const claimants = new Set( + run.result.evidence + .filter((item) => item.stage === 'passive-facts' && item.signal === 'match') + .map((item) => item.provider), + ); + + // Then + expect([...claimants].sort()).toEqual(['huawei-hilink', 'huawei-hilink-twin']); + expect( + run.result.evidence.some( + (item) => item.stage === 'unauthenticated-fingerprint' && item.signal === 'match', + ), + ).toBe(true); + }); +}); + +describe('bounded credential attempts', () => { + test('a mid-login 125002 refuses after exactly one login POST', () => { + // Given + const run = runOf('auth-expired/hilink-mid-login'); + + // Then + expect(hilinkLogins(run)).toHaveLength(1); + expect(run.result.status).toBe('ambiguous'); + }); + + test('an MF79U lockout is refused on one attempt and never re-tried as another algorithm', () => { + // Given + const run = runOf('lockout-unknown/zte-mf79u'); + + // When + const posts = zteLogins(run); + + // Then + expect(posts).toHaveLength(1); + expect(posts.map(goformId)).toEqual(['LOGIN']); + }); + + test('MF266-shaped answers to an MF79U login never provoke a salted retry', () => { + // Given + const run = runOf('ambiguity/zte-cross-profile-refusal'); + + // When + const posts = zteLogins(run); + + // Then + expect(posts).toHaveLength(1); + expect(posts.map(goformId)).toEqual(['LOGIN']); + }); + + test('unknown ZTE firmware reaches its read-only profile without any login POST', () => { + // Given + const run = runOf('unknown-firmware/zte-read-only'); + + // Then + expect(zteLogins(run)).toEqual([]); + expect(run.result.profile).toBe('zte-unknown-read-only'); + }); + + test('a USB id that is not a HIMI id never spends the UFI login', () => { + // Given + const nonHimi = [ + 'fleet/huawei-e3372h-22.200', + 'fleet/zte-mf79u', + 'unknown-firmware/zte-read-only', + ]; + + // Then + for (const id of nonHimi) expect(runOf(id).transcripts.ufi).toEqual([]); + expect(runOf('fleet/ufi-himi-9024').transcripts.ufi.length).toBeGreaterThan(0); + }); +}); + +describe('interface and transport routing', () => { + test('reaching the wrong duplicate-IP twin refuses before login, still bound to that interface', () => { + // Given + const run = runOf('wrong-interface/hilink-duplicate-ip-twin'); + + // Then + expect(run.result.status).toBe('ambiguous'); + expect(hilinkLogins(run)).toEqual([]); + expect(run.transcripts.hilink.length).toBeGreaterThan(0); + expect( + run.transcripts.hilink.every((exchange) => exchange.interfaceName === HILINK_TWIN_INTERFACE), + ).toBe(true); + expect( + run.result.conflicts.some( + (item) => item.stage === 'authenticated-profile' && item.detail === 'profile-mismatch', + ), + ).toBe(true); + }); + + test('an ineligible transport is refused before ANY device is contacted', () => { + // Given + const run = runOf('wrong-transport/usb-request-reaches-nobody'); + + // Then + expect(allExchanges(run)).toEqual([]); + expect(run.mmCalls).toEqual([]); + expect( + run.result.evidence.filter( + (item) => item.stage === 'transport-eligibility' && item.signal === 'mismatch', + ), + ).toHaveLength(4); + }); + + test('a ModemManager-managed modem is never offered to a router-dialect provider', () => { + // Given + const run = runOf('fleet/mm-fm350-usb'); + + // When + const ineligible = run.result.evidence + .filter((item) => item.stage === 'transport-eligibility' && item.signal === 'mismatch') + .map((item) => item.provider) + .sort(); + + // Then + expect(ineligible).toEqual(['huawei-hilink', 'ufi-himi', 'zte-goform']); + expect(allExchanges(run)).toEqual([]); + }); +}); + +describe('corpus sanitization', () => { + const SUBSCRIBER_DIGIT_RUN = /\d{14,}/g; + const allowed = new Set(SANITIZED_SUBSCRIBER_IDENTIFIERS.map((entry) => entry.value)); + + const unlisted = (documents: readonly string[]): readonly string[] => + documents + .flatMap((document) => [...document.matchAll(SUBSCRIBER_DIGIT_RUN)]) + .map((match) => match[0]) + .filter((value) => !allowed.has(value)); + + test('every subscriber-scale identifier in the corpus is a declared synthetic value', () => { + // Given / When + const found = unlisted(CORPUS_BODIES); + + // Then + expect(found).toEqual([]); + expect(SANITIZED_SUBSCRIBER_IDENTIFIERS.length).toBeGreaterThan(0); + for (const entry of SANITIZED_SUBSCRIBER_IDENTIFIERS) { + expect(entry.provenance.length).toBeGreaterThan(0); + } + }); + + test('the detector trips on an undeclared identifier — it is not vacuous', () => { + // Given + const pasted = '{"params":{"IMSI":"310150123456789"}}'; + + // When + const found = unlisted([pasted]); + + // Then + expect(found).toEqual(['310150123456789']); + }); + + test('every recorded request body across the whole matrix is sanitized too', () => { + // Given + const wire = rows.flatMap((row) => + allExchanges(runOf(row.id)).map((exchange) => JSON.stringify(exchange)), + ); + + // Then + expect(wire.length).toBeGreaterThan(0); + expect(unlisted(wire)).toEqual([]); + }); + + test('the corpus credential is a declared fixture literal, never a bench environment secret', () => { + // Given + const benchSecrets = ['MF79U_BENCH_PASSWORD', 'UFI_BENCH_PASSWORD'] + .map((name) => process.env[name]) + .filter((value): value is string => value !== undefined && value.length > 0); + + // Then + expect(CONFORMANCE_CREDENTIALS.password).toContain('not-a-real-secret'); + for (const secret of benchSecrets) { + for (const document of CORPUS_BODIES) expect(document).not.toContain(secret); + } + }); +}); diff --git a/control/src/providers/conformance-scale.test.ts b/control/src/providers/conformance-scale.test.ts new file mode 100644 index 0000000..5e210f1 --- /dev/null +++ b/control/src/providers/conformance-scale.test.ts @@ -0,0 +1,208 @@ +// SOFTWARE UPPER-BOUND fixture — 16 concurrently attached modems. +// +// READ THIS BEFORE QUOTING THE NUMBER. Sixteen is a FIXTURE result and nothing else: it +// says the observation, epoch, `Signal.Setup` and matching paths hold their shape at that +// count, measured against an in-memory ModemManager. It is NOT a bench measurement and +// must never be reported as one. +// +// The HARDWARE-VERIFIED figure remains the 8-device bench fleet from the predecessor +// evidence. Nothing here raises it; a hardware claim comes from the bench certification +// (todo 42), from a real board, with real modems attached. +// +// What the fixture is actually good for is the resource shape, which is where a fleet +// stack usually goes wrong: subscriptions must be FLEET-WIDE (four, forever) rather than +// per modem, `Signal.Setup` must be issued exactly once per (epoch, modem) rather than +// once per refresh, and sixteen concurrent matches must each answer about their OWN +// modem rather than a cached neighbour's. + +import { afterEach, describe, expect, test } from 'bun:test'; +import { FakeMmTransport } from '../../test-support/conformance'; +import type { ModemSpec } from '../../test-support/fake-mm/object-model'; +import { modemPath } from '../../test-support/fake-mm/object-model'; +import { deviceGeneration, physicalModemId } from '../domain'; +import { createProviderMatcher } from './matcher'; +import { createModemManagerProvider, type ModemManagerProvider } from './modem-manager'; +import { createProviderRegistry } from './registry'; + +/** Fixture-only. See the file header: the bench-verified fleet size is 8. */ +const SOFTWARE_UPPER_BOUND_MODEMS = 16; +/** The figure that IS hardware-verified, kept here so the two are never conflated. */ +const HARDWARE_VERIFIED_BENCH_FLEET = 8; + +/** The four fleet-wide subscriptions the observer takes: three OM/props + NameOwnerChanged. */ +const FLEET_WIDE_SUBSCRIPTIONS = 4; + +const FIRST_INDEX = 101; + +function fleet(count: number): readonly ModemSpec[] { + return Array.from({ length: count }, (_, offset) => { + const index = FIRST_INDEX + offset; + return { + index, + manufacturer: 'Fake Modems Inc.', + model: `Scale-${index}`, + revision: '1.0-fixture', + signalQuality: 40 + (offset % 50), + sims: [ + { + index, + iccid: `890000000000000${String(index)}`, + imsi: `00101000000${String(index)}`, + active: true, + }, + ], + } satisfies ModemSpec; + }); +} + +async function waitFor(predicate: () => boolean, timeoutMs = 5_000): Promise { + const deadline = Date.now() + timeoutMs; + while (!predicate()) { + if (Date.now() >= deadline) throw new Error('waitFor timed out'); + await Bun.sleep(5); + } +} + +describe('16 concurrently attached modems (VALIDATED UPPER BOUND — fixture, not hardware)', () => { + let provider: ModemManagerProvider | undefined; + + afterEach(async () => { + await provider?.stop(); + provider = undefined; + }); + + test('the fixture bound is declared as software-only and exceeds the bench-verified fleet', () => { + // Given / When / Then + expect(SOFTWARE_UPPER_BOUND_MODEMS).toBe(16); + expect(HARDWARE_VERIFIED_BENCH_FLEET).toBe(8); + expect(SOFTWARE_UPPER_BOUND_MODEMS).toBeGreaterThan(HARDWARE_VERIFIED_BENCH_FLEET); + }); + + test('all sixteen are observed, and the subscription count stays fleet-wide', async () => { + // Given + const transport = new FakeMmTransport({ modems: fleet(SOFTWARE_UPPER_BOUND_MODEMS) }); + provider = createModemManagerProvider({ transport }); + + // When + const list = await provider.start(); + + // Then + expect(list.ok).toBe(true); + expect(list.rows).toHaveLength(SOFTWARE_UPPER_BOUND_MODEMS); + expect(transport.subscriptionCount()).toBe(FLEET_WIDE_SUBSCRIPTIONS); + }); + + test('Signal.Setup is issued exactly once per modem for the epoch — never once per refresh', async () => { + // Given + const specs = fleet(SOFTWARE_UPPER_BOUND_MODEMS); + const transport = new FakeMmTransport({ modems: specs }); + provider = createModemManagerProvider({ transport }); + + // When + await provider.start(); + await waitFor(() => transport.signalSetupCalls.length >= SOFTWARE_UPPER_BOUND_MODEMS); + await Bun.sleep(20); + + // Then + const paths = transport.signalSetupCalls.map((call) => call.path); + expect(paths).toHaveLength(SOFTWARE_UPPER_BOUND_MODEMS); + expect(new Set(paths).size).toBe(SOFTWARE_UPPER_BOUND_MODEMS); + expect([...paths].sort()).toEqual(specs.map((spec) => modemPath(spec.index)).sort()); + }); + + test('a burst of sixteen attachments is coalesced instead of refreshing once per event', async () => { + // Given + const specs = fleet(SOFTWARE_UPPER_BOUND_MODEMS); + const transport = new FakeMmTransport(); + provider = createModemManagerProvider({ transport }); + await provider.start(); + const beforeBurst = transport.managedObjectsCalls.length; + + // When + for (const spec of specs) transport.addModem(spec); + await waitFor(() => transport.signalSetupCalls.length >= SOFTWARE_UPPER_BOUND_MODEMS); + + // Then + const refreshes = transport.managedObjectsCalls.length - beforeBurst; + expect(refreshes).toBeGreaterThan(0); + expect(refreshes).toBeLessThan(SOFTWARE_UPPER_BOUND_MODEMS); + expect(transport.subscriptionCount()).toBe(FLEET_WIDE_SUBSCRIPTIONS); + }); + + test('sixteen concurrent matches each answer about their OWN modem', async () => { + // Given + const specs = fleet(SOFTWARE_UPPER_BOUND_MODEMS); + const transport = new FakeMmTransport({ modems: specs }); + provider = createModemManagerProvider({ transport }); + const registry = createProviderRegistry(); + registry.register(provider.definition); + const matcher = createProviderMatcher(registry); + + // When + const results = await Promise.all( + specs.map((spec) => + matcher.match({ + physicalModemId: physicalModemId(`serial:fake-device-${spec.index}`), + generation: deviceGeneration(1), + transport: 'modemmanager', + passiveFacts: [], + composition: 'scale-fixture', + }), + ), + ); + + // Then + expect(results).toHaveLength(SOFTWARE_UPPER_BOUND_MODEMS); + for (const [offset, result] of results.entries()) { + expect(result.status).toBe('selected'); + expect(result.provider).toBe('modemmanager'); + expect(result.profile).toBe('generic-mm'); + expect(String(result.physicalModemId)).toBe(`serial:fake-device-${specs[offset]?.index}`); + } + }); + + test('detaching and re-attaching modems grows no per-modem resource, and stop releases everything', async () => { + // Given + const specs = fleet(SOFTWARE_UPPER_BOUND_MODEMS); + const transport = new FakeMmTransport({ modems: specs }); + provider = createModemManagerProvider({ transport }); + await provider.start(); + await waitFor(() => transport.signalSetupCalls.length >= SOFTWARE_UPPER_BOUND_MODEMS); + + // When + for (const spec of specs.slice(0, 4)) transport.removeModem(spec.index); + await Bun.sleep(20); + for (const spec of specs.slice(0, 4)) transport.addModem(spec); + await Bun.sleep(20); + + // Then + expect(transport.subscriptionCount()).toBe(FLEET_WIDE_SUBSCRIPTIONS); + // TOTAL, not a distinct count: churn inside ONE epoch re-drives the manager, and a + // distinct-path assertion cannot tell a de-duped re-apply from sixteen extra calls. + expect(transport.signalSetupCalls).toHaveLength(SOFTWARE_UPPER_BOUND_MODEMS); + expect( + transport.calls.some((call) => /Connect|CreateBearer|Disconnect/.test(call.member)), + ).toBe(false); + + await provider.stop(); + provider = undefined; + expect(transport.subscriptionCount()).toBe(0); + }); + + test('a new daemon epoch re-applies Signal.Setup to every one of the sixteen survivors', async () => { + // Given + const transport = new FakeMmTransport({ modems: fleet(SOFTWARE_UPPER_BOUND_MODEMS) }); + provider = createModemManagerProvider({ transport }); + await provider.start(); + await waitFor(() => transport.signalSetupCalls.length >= SOFTWARE_UPPER_BOUND_MODEMS); + + // When + transport.takeOverAs(':1.99'); + await waitFor(() => transport.signalSetupCalls.length >= SOFTWARE_UPPER_BOUND_MODEMS * 2); + await Bun.sleep(20); + + // Then + expect(transport.signalSetupCalls).toHaveLength(SOFTWARE_UPPER_BOUND_MODEMS * 2); + expect(transport.subscriptionCount()).toBe(FLEET_WIDE_SUBSCRIPTIONS); + }); +}); diff --git a/control/src/providers/conformance-transcripts.test.ts b/control/src/providers/conformance-transcripts.test.ts new file mode 100644 index 0000000..600f723 --- /dev/null +++ b/control/src/providers/conformance-transcripts.test.ts @@ -0,0 +1,214 @@ +// Sanitized per-firmware HTTP transcripts, asserted EXACTLY. +// +// The matrix next door proves each device reaches the right provider. This file proves +// the provider got there over the right wire: exact method, exact path, exact query, +// exact form/JSON/XML body, exact header ARRAY (order included) and the exact cookie — +// per firmware profile, end to end through the real matcher rather than through a +// hand-driven call. +// +// The expected transcripts are rebuilt from the protocol in +// `test-support/conformance/transcripts.ts`, not read back from the provider, so this is +// a comparison and not an echo. A `toEqual` on the whole array additionally pins the +// COUNT: an extra request nobody asked for — a second login, a re-probe, a stray +// capability read — fails here even when the decision is unchanged. + +import { describe, expect, test } from 'bun:test'; +import { + CONFORMANCE_CASES, + CONFORMANCE_CREDENTIALS, + HILINK_FIRMWARE, + HILINK_PRIMARY_INTERFACE, + hilinkCookieFor, + hilinkGet, + hilinkLoginPost, + hilinkOpenGet, + UFI_INTERFACE, + ufiLoginPost, + ufiReadPost, + ZTE_FINGERPRINT_CMD, + ZTE_INTERFACE, + ZTE_LEGACY_PASSWORD, + ZTE_SALTED_PASSWORD, + ZTE_STOK, + ZTE_TELEMETRY_CMD, + ZTE_VERSIONS_CMD, + zteGet, + ztePost, +} from '../../test-support/conformance'; +import { HILINK_PATHS } from './huawei-hilink/provider'; + +async function transcriptsOf(caseId: string) { + const entry = CONFORMANCE_CASES.find((candidate) => candidate.id === caseId); + if (entry === undefined) throw new Error(`unknown conformance case: ${caseId}`); + return (await entry.run()).transcripts; +} + +describe('Huawei HiLink per-firmware transcripts', () => { + test('22.200 password-type-3: one open probe, one session, one state read, ONE login, four capability reads', async () => { + // Given + const profile = 'e3372h-22.200-password-type-3' as const; + const cookie = hilinkCookieFor(profile); + + // When + const transcripts = await transcriptsOf('fleet/huawei-e3372h-22.200'); + + // Then + expect(transcripts.hilink).toEqual([ + hilinkOpenGet(HILINK_PATHS.session, HILINK_PRIMARY_INTERFACE), + hilinkOpenGet(HILINK_PATHS.session, HILINK_PRIMARY_INTERFACE), + hilinkGet(HILINK_PATHS.loginState, HILINK_PRIMARY_INTERFACE, cookie), + hilinkLoginPost(profile, HILINK_PRIMARY_INTERFACE), + hilinkGet(HILINK_PATHS.status, HILINK_PRIMARY_INTERFACE, cookie), + hilinkGet(HILINK_PATHS.signal, HILINK_PRIMARY_INTERFACE, cookie), + hilinkGet(HILINK_PATHS.modeList, HILINK_PRIMARY_INTERFACE, cookie), + hilinkGet(HILINK_PATHS.data, HILINK_PRIMARY_INTERFACE, cookie), + ]); + }); + + test('22.333 password-type-4: identical shape, a DIFFERENT derived password document', async () => { + // Given + const profile = 'e3372h-22.333-password-type-4' as const; + const cookie = hilinkCookieFor(profile); + + // When + const transcripts = await transcriptsOf('fleet/huawei-e3372h-22.333'); + + // Then + expect(transcripts.hilink).toEqual([ + hilinkOpenGet(HILINK_PATHS.session, HILINK_PRIMARY_INTERFACE), + hilinkOpenGet(HILINK_PATHS.session, HILINK_PRIMARY_INTERFACE), + hilinkGet(HILINK_PATHS.loginState, HILINK_PRIMARY_INTERFACE, cookie), + hilinkLoginPost(profile, HILINK_PRIMARY_INTERFACE), + hilinkGet(HILINK_PATHS.status, HILINK_PRIMARY_INTERFACE, cookie), + hilinkGet(HILINK_PATHS.signal, HILINK_PRIMARY_INTERFACE, cookie), + hilinkGet(HILINK_PATHS.modeList, HILINK_PRIMARY_INTERFACE, cookie), + hilinkGet(HILINK_PATHS.data, HILINK_PRIMARY_INTERFACE, cookie), + ]); + }); + + test('the two firmwares never share a login document, and neither carries the raw password', async () => { + // Given + const type3 = hilinkLoginPost('e3372h-22.200-password-type-3', HILINK_PRIMARY_INTERFACE); + const type4 = hilinkLoginPost('e3372h-22.333-password-type-4', HILINK_PRIMARY_INTERFACE); + + // When + const documents = [type3.body, type4.body].map((body) => + body.kind === 'xml' ? body.text : '', + ); + + // Then + expect(documents[0]).not.toEqual(documents[1]); + expect(documents[0]).toContain('3'); + expect(documents[1]).toContain('4'); + for (const document of documents) { + expect(document).not.toContain(CONFORMANCE_CREDENTIALS.password); + } + expect(HILINK_FIRMWARE['e3372h-22.200-password-type-3']).not.toBe( + HILINK_FIRMWARE['e3372h-22.333-password-type-4'], + ); + }); +}); + +describe('ZTE goform per-firmware transcripts', () => { + test('MF79U legacy: one fingerprint GET, ONE form login with a base64 password, one telemetry GET', 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_TELEMETRY_CMD, ZTE_INTERFACE), + ]); + }); + + test('MF266 salted: LD challenge, salted login, then AD derivation over the stok cookie', async () => { + // When + const transcripts = await transcriptsOf('fleet/zte-mf266'); + + // Then + expect(transcripts.zte).toEqual([ + zteGet(ZTE_FINGERPRINT_CMD, ZTE_INTERFACE), + zteGet('LD', ZTE_INTERFACE), + ztePost( + { + goformId: 'LOGIN_MULTI_USER', + isTest: 'false', + password: ZTE_SALTED_PASSWORD, + IP: 'localhost', + user: CONFORMANCE_CREDENTIALS.username, + }, + 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', () => { + // 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}$/); + }); +}); + +describe('UFI / HIMI transcript', () => { + test('one endpoint, one method, the verb in the body, and exactly one login', async () => { + // When + const transcripts = await transcriptsOf('fleet/ufi-himi-9024'); + + // Then + expect(transcripts.ufi).toEqual([ + ufiLoginPost(UFI_INTERFACE), + ufiReadPost('getallstatus', UFI_INTERFACE), + ufiReadPost('getsysinfo', UFI_INTERFACE), + ]); + expect(transcripts.ufi.every((exchange) => exchange.method === 'POST')).toBe(true); + expect(new Set(transcripts.ufi.map((exchange) => exchange.path)).size).toBe(1); + }); + + test('the firmware-specific product id takes the SAME read-only transcript', async () => { + // When + const transcripts = await transcriptsOf('fleet/ufi-himi-9091'); + + // Then + expect(transcripts.ufi).toEqual([ + ufiLoginPost(UFI_INTERFACE), + ufiReadPost('getallstatus', UFI_INTERFACE), + ufiReadPost('getsysinfo', UFI_INTERFACE), + ]); + }); +}); + +describe('every transcript is interface-bound and redirect-refusing', () => { + test('no request in the whole corpus leaves an interface unset or follows a redirect', async () => { + // Given + const caseIds = CONFORMANCE_CASES.filter((entry) => entry.kind === 'fleet-profile').map( + (entry) => entry.id, + ); + + // When + const exchanges = ( + await Promise.all( + caseIds.map(async (id) => { + const transcripts = await transcriptsOf(id); + return [ + ...transcripts.hilink, + ...transcripts.hilinkTwin, + ...transcripts.zte, + ...transcripts.ufi, + ]; + }), + ) + ).flat(); + + // Then + expect(exchanges.length).toBeGreaterThan(0); + for (const exchange of exchanges) { + expect(exchange.redirect).toBe('error'); + expect(exchange.interfaceName.length).toBeGreaterThan(0); + } + }); +}); diff --git a/control/src/providers/conformance.test.ts b/control/src/providers/conformance.test.ts new file mode 100644 index 0000000..f057fc7 --- /dev/null +++ b/control/src/providers/conformance.test.ts @@ -0,0 +1,191 @@ +import { describe, expect, test } from 'bun:test'; +import { + providerFixture, + providerMatchRequest, +} from '../../test-support/provider-conformance-fixture'; +import { deviceGeneration } from '../domain'; +import { createProviderMatcher, createProviderRegistry } from './index'; + +describe('provider matcher conformance', () => { + test('Given no matching evidence, when matching, then the device is unsupported', async () => { + const registry = createProviderRegistry(); + registry.register(providerFixture('fixture-a')); + + const result = await createProviderMatcher(registry).match( + providerMatchRequest(deviceGeneration(1), 'different-model'), + ); + + expect(result.status).toBe('unsupported'); + expect(result.score).toBe('unsupported'); + expect(result.provider).toBeNull(); + expect(result.writable).toBe(false); + }); + + test('Given one strong match, when matching, then that provider and profile are selected', async () => { + const registry = createProviderRegistry(); + registry.register(providerFixture('fixture-a')); + + const result = await createProviderMatcher(registry).match( + providerMatchRequest(deviceGeneration(1)), + ); + + expect(result.status).toBe('selected'); + expect(result.provider).toBe('fixture-a'); + expect(result.profile).toBe('fixture-profile'); + expect(result.score).toBe('supported'); + }); + + test('Given one weak match, when matching, then it remains ambiguous and read-only', async () => { + const registry = createProviderRegistry(); + const weak = providerFixture('fixture-a'); + registry.register({ + ...weak, + passiveMatchers: weak.passiveMatchers.map((matcher) => ({ + ...matcher, + strength: 'weak' as const, + })), + }); + + const result = await createProviderMatcher(registry).match( + providerMatchRequest(deviceGeneration(1)), + ); + + expect(result.status).toBe('ambiguous'); + expect(result.score).toBe('maybe'); + expect(result.operations).toBeNull(); + expect(result.writable).toBe(false); + }); + + test('Given tied providers, when matching, then the result is ambiguous read-only with evidence', async () => { + const registry = createProviderRegistry(); + registry.register(providerFixture('fixture-a')); + registry.register(providerFixture('fixture-b')); + + const result = await createProviderMatcher(registry).match( + providerMatchRequest(deviceGeneration(1)), + ); + + expect(result.status).toBe('ambiguous'); + expect(result.provider).toBeNull(); + expect(result.writable).toBe(false); + expect( + result.evidence + .filter((item) => item.stage === 'passive-facts' && item.signal === 'match') + .map((item) => item.provider), + ).toEqual(['fixture-a', 'fixture-b']); + }); + + test('Given colliding passive facts, when providers expose writes, then neither writable provider is selected', async () => { + let authAttempts = 0; + const registry = createProviderRegistry(); + const first = providerFixture('fixture-a'); + const second = providerFixture('fixture-b'); + registry.register({ + ...first, + authenticatedProfile: { + algorithm: 'fixture-a-owner-selected', + attemptLimit: 1, + authenticate: async () => { + authAttempts += 1; + return { status: 'matched', profile: 'fixture-profile', detail: 'profile-confirmed' }; + }, + }, + operations: () => ({ ...first.operations('fixture-profile'), access: 'read-write' }), + }); + registry.register({ + ...second, + authenticatedProfile: { + algorithm: 'fixture-b-owner-selected', + attemptLimit: 1, + authenticate: async () => { + authAttempts += 1; + return { status: 'matched', profile: 'fixture-profile', detail: 'profile-confirmed' }; + }, + }, + operations: () => ({ ...second.operations('fixture-profile'), access: 'read-write' }), + }); + + const result = await createProviderMatcher(registry).match( + providerMatchRequest(deviceGeneration(1)), + ); + + expect(result.status).toBe('ambiguous'); + expect(result.provider).toBeNull(); + expect(result.operations).toBeNull(); + expect(result.writable).toBe(false); + expect(authAttempts).toBe(0); + }); + + test('Given a cached generation, when generation advances, then provider evidence is re-evaluated', async () => { + let probeRuns = 0; + const registry = createProviderRegistry(); + registry.register( + providerFixture('fixture-a', { + probe: { + id: 'fixture-a.status', + run: async () => { + probeRuns += 1; + return { + signal: 'match', + strength: 'weak', + profiles: ['fixture-profile'], + detail: 'status-endpoint-shape', + }; + }, + }, + }), + ); + const matcher = createProviderMatcher(registry); + + await matcher.match(providerMatchRequest(deviceGeneration(1))); + await matcher.match(providerMatchRequest(deviceGeneration(1))); + await matcher.match(providerMatchRequest(deviceGeneration(2))); + + expect(probeRuns).toBe(2); + }); + + test('Given one authenticated profile, when matching twice in a generation, then one auth attempt precedes capability reads', async () => { + const stages: string[] = []; + const registry = createProviderRegistry(); + const definition = providerFixture('fixture-a', { + probe: { + id: 'fixture-a.status', + run: async () => { + stages.push('fingerprint'); + return { + signal: 'match', + strength: 'weak', + profiles: ['fixture-profile'], + detail: 'status-endpoint-shape', + }; + }, + }, + }); + registry.register({ + ...definition, + authenticatedProfile: { + algorithm: 'fixture-owner-selected', + attemptLimit: 1, + authenticate: async () => { + stages.push('auth'); + return { status: 'matched', profile: 'fixture-profile', detail: 'profile-confirmed' }; + }, + }, + capabilityReaders: [ + { + id: 'fixture-a.signal', + read: async () => { + stages.push('capability'); + return { signal: 'match', strength: 'weak', detail: 'signal-readable' }; + }, + }, + ], + }); + const matcher = createProviderMatcher(registry); + + await matcher.match(providerMatchRequest(deviceGeneration(1))); + await matcher.match(providerMatchRequest(deviceGeneration(1))); + + expect(stages).toEqual(['fingerprint', 'auth', 'capability']); + }); +}); diff --git a/control/src/providers/contracts.ts b/control/src/providers/contracts.ts new file mode 100644 index 0000000..64dd692 --- /dev/null +++ b/control/src/providers/contracts.ts @@ -0,0 +1,167 @@ +import type { + DeviceGeneration, + ObservationEnvelope, + OperationDescriptor, + OperationResult, + PhysicalModemId, +} from '../domain'; + +export const PROVIDER_TRANSPORTS = ['usb', 'pci', 'network', 'modemmanager'] as const; +export type ProviderTransport = (typeof PROVIDER_TRANSPORTS)[number]; + +export const PASSIVE_FACT_KINDS = [ + 'usb', + 'pci', + 'interface', + 'driver', + 'gateway', + 'model', + 'firmware', +] as const; +export type PassiveFactKind = (typeof PASSIVE_FACT_KINDS)[number]; +export type EvidenceStrength = 'none' | 'weak' | 'moderate' | 'strong'; +export type MatcherScore = 'unsupported' | 'maybe' | 'likely' | 'supported'; + +export type PassiveFact = { + readonly kind: PassiveFactKind; + readonly value: string; +}; + +export type ProviderMatchRequest = { + readonly physicalModemId: PhysicalModemId; + readonly generation: DeviceGeneration; + readonly transport: ProviderTransport; + readonly passiveFacts: readonly PassiveFact[]; + readonly composition: string; + readonly firmware?: string; +}; + +export type ProviderExecutionContext = ProviderMatchRequest & { + readonly profile: string; +}; + +export type PassiveMatcher = { + readonly id: string; + readonly fact: PassiveFactKind; + readonly expected: readonly string[]; + readonly profiles: readonly string[]; + readonly strength: Exclude; + readonly required: boolean; +}; + +export type FingerprintResult = { + readonly signal: 'match' | 'mismatch' | 'unknown'; + readonly strength: Exclude; + readonly profiles: readonly string[]; + readonly detail: string; +}; + +export type UnauthenticatedProbe = { + readonly id: string; + readonly run: (context: ProviderMatchRequest) => Promise; +}; + +export type CapabilityReader = { + readonly id: string; + readonly read: ( + context: ProviderExecutionContext, + ) => Promise>; +}; + +export type AuthenticatedProfileResult = + | { readonly status: 'matched'; readonly profile: string; readonly detail: string } + | { readonly status: 'refused'; readonly detail: string } + | { readonly status: 'unavailable'; readonly detail: string }; + +/** One algorithm and one call per generation-scoped evaluation; cycling is unrepresentable. */ +export type AuthenticatedProfile = { + readonly algorithm: string; + readonly attemptLimit: 1; + readonly authenticate: ( + context: ProviderMatchRequest, + profileCandidates: readonly string[], + ) => Promise; +}; + +export type ProviderOperationsSurface = { + readonly access: 'read-only' | 'read-write'; +}; + +export interface ProviderReadOperations { + readonly descriptor: OperationDescriptor; + readonly read: (context: ProviderExecutionContext) => Promise>; +} + +export interface ProviderWriteOperations { + readonly descriptor: OperationDescriptor; + readonly read: (context: ProviderExecutionContext) => Promise>; + readonly write: (context: ProviderExecutionContext, input: I) => Promise>; +} + +export type ProviderContractFixture = { + readonly profile: string; + readonly request: Readonly>; + readonly response: Readonly>; +}; + +export interface ProviderDefinition< + TObservation = unknown, + TOperations extends ProviderOperationsSurface = ProviderOperationsSurface, +> { + readonly id: string; + readonly profileVersion: string; + readonly eligibleTransports: readonly ProviderTransport[]; + readonly passiveMatchers: readonly PassiveMatcher[]; + readonly unauthenticatedProbes: readonly UnauthenticatedProbe[]; + readonly authenticatedProfile?: AuthenticatedProfile; + readonly capabilityReaders: readonly CapabilityReader[]; + readonly observe: ( + context: ProviderExecutionContext, + ) => Promise[]>; + readonly operations: (profile: string) => TOperations; + readonly contractFixtures: readonly ProviderContractFixture[]; +} + +export type MatcherEvidence = { + readonly provider: string; + readonly profile: string | null; + readonly stage: + | 'transport-eligibility' + | 'passive-facts' + | 'unauthenticated-fingerprint' + | 'authenticated-profile' + | 'capability-read'; + readonly source: string; + readonly signal: 'match' | 'mismatch' | 'unknown'; + readonly strength: EvidenceStrength; + readonly detail: string; +}; + +type MatchResultBase = { + readonly score: MatcherScore; + readonly confidence: number; + readonly evidence: readonly MatcherEvidence[]; + readonly conflicts: readonly MatcherEvidence[]; + readonly generation: DeviceGeneration; + readonly physicalModemId: PhysicalModemId; +}; + +export type ProviderMatchResult = + | (MatchResultBase & { + readonly status: 'unsupported' | 'ambiguous'; + readonly provider: null; + readonly profile: null; + readonly operations: null; + readonly writable: false; + }) + | (MatchResultBase & { + readonly status: 'selected'; + readonly provider: string; + readonly profile: string; + readonly operations: ProviderOperationsSurface; + readonly writable: boolean; + }); + +export interface ProviderMatcher { + match(request: ProviderMatchRequest): Promise; +} diff --git a/control/src/providers/huawei-hilink/index.ts b/control/src/providers/huawei-hilink/index.ts new file mode 100644 index 0000000..c173ea7 --- /dev/null +++ b/control/src/providers/huawei-hilink/index.ts @@ -0,0 +1,2 @@ +export * from './provider'; +export * from './transport'; diff --git a/control/src/providers/huawei-hilink/operations.ts b/control/src/providers/huawei-hilink/operations.ts new file mode 100644 index 0000000..ea54c04 --- /dev/null +++ b/control/src/providers/huawei-hilink/operations.ts @@ -0,0 +1,89 @@ +import type { DeviceGeneration, OperationDescriptor, OperationResult } from '../../domain'; +import { defineOperationDescriptor } from '../../domain'; +import { parseHilinkDataCapability, parseHilinkXmlValue } from '../../hardware/router-parsers'; +import type { ProviderExecutionContext } from '../contracts'; +import { HILINK_PATHS, type HilinkProfile } from './provider'; + +export type ReadValue = Readonly>; +export type ReadOperation = { + readonly descriptor: OperationDescriptor; + readonly read: (context: ProviderExecutionContext) => Promise>; +}; +export type WriteOperation = { + readonly descriptor: OperationDescriptor; + readonly read: ReadOperation['read']; + readonly write: ( + context: ProviderExecutionContext, + value: string | boolean, + ) => Promise>; +}; + +export function applied( + generation: DeviceGeneration, + value: ReadValue, +): OperationResult { + return { status: 'applied', value, generation, requiresReconciliation: false }; +} + +export function refused(generation: DeviceGeneration, reason: string): OperationResult { + return { status: 'refused', reason, generation, requiresReconciliation: false }; +} + +export function operationDescriptor( + id: string, + write: boolean, + profile: HilinkProfile, +): OperationDescriptor { + return defineOperationDescriptor({ + id, + support: { + read: { supported: true }, + write: write ? { supported: true } : { supported: false, reason: 'read-only' }, + }, + authority: 'provider', + provider: 'huawei-hilink', + constraints: { kind: 'unconstrained' }, + livePreconditions: ['fresh-session', 'capability-evidence'], + availability: { state: 'available' }, + mutationImpact: write ? 'write' : 'read', + retryClass: 'never', + readback: write + ? { required: true, reason: 'fresh-readback', matches: () => true } + : { required: false }, + rollback: { required: false }, + journal: { required: false }, + admission: { required: true, reason: 'router-session' }, + evidence: { profiles: [profile.id], firmware: [profile.firmware] }, + confidence: 'high', + }); +} + +export function requestSucceeded(status: number, body: string): boolean { + return status >= 200 && status < 300 && /\s*OK\s*<\/response>/i.test(body); +} + +export function capabilityPath(kind: 'status' | 'signal' | 'mode' | 'data'): string { + return kind === 'status' + ? HILINK_PATHS.status + : kind === 'signal' + ? HILINK_PATHS.signal + : kind === 'mode' + ? HILINK_PATHS.modeList + : HILINK_PATHS.data; +} + +export function writeDocument(kind: 'mode' | 'data', value: string | boolean): string { + return kind === 'mode' + ? `${String(value)}` + : `${value ? 1 : 0}`; +} + +export function readbackMatches( + kind: 'mode' | 'data', + body: string, + value: string | boolean, +): boolean { + if (kind === 'mode') return parseHilinkXmlValue(body, 'NetworkMode') === String(value); + const parsed = parseHilinkDataCapability(body); + return parsed.state === 'reported' && parsed.enabled === Boolean(value); +} diff --git a/control/src/providers/huawei-hilink/provider.test.ts b/control/src/providers/huawei-hilink/provider.test.ts new file mode 100644 index 0000000..782061c --- /dev/null +++ b/control/src/providers/huawei-hilink/provider.test.ts @@ -0,0 +1,193 @@ +import { describe, expect, test } from 'bun:test'; +import { createHash } from 'node:crypto'; +import type { ResourceOwnershipPort } from '../../ports/resource-ownership'; +import { createHuaweiHiLinkDefinition, HILINK_PATHS, HILINK_PROFILES } from './provider'; +import type { HilinkHttpRequest, HilinkHttpResponse, HilinkTransport } from './transport'; + +const PASSWORD = 'fixture-password'; +const USERNAME = 'admin'; +const TYPE4_HASH = Buffer.from( + createHash('sha256') + .update( + `${USERNAME}${Buffer.from(createHash('sha256').update(PASSWORD).digest('hex')).toString('base64')}type4-token`, + ) + .digest('hex'), +).toString('base64'); +const acquired: ResourceOwnershipPort = { + acquire: async () => ({ + status: 'acquired', + lease: { + holder: { pid: 1, startedAtEpochMs: 1 }, + lost: new Promise(() => {}), + release: async () => {}, + }, + }), +}; +const context = (profile: string) => ({ + physicalModemId: 'fixture-device' as never, + generation: 1 as never, + transport: 'network' as const, + passiveFacts: [], + composition: 'rndis', + firmware: profile.includes('22.200') ? '22.200.05.00.1080' : '22.333.01.00.00', + profile, +}); + +function replay(responses: readonly HilinkHttpResponse[]) { + const calls: HilinkHttpRequest[] = []; + const pending = [...responses]; + const transport: HilinkTransport = { + request: async (request) => { + calls.push(request); + const response = pending.shift(); + if (response === undefined) throw new Error('unexpected replay request'); + return response; + }, + }; + return { calls, transport, remaining: () => pending.length }; +} + +function definition(transport: HilinkTransport, ownership = acquired) { + return createHuaweiHiLinkDefinition({ + interfaceName: 'eth9', + adminUrl: 'http://192.168.8.1', + transport, + ownership, + credentials: { username: USERNAME, password: PASSWORD }, + }); +} + +const session = (token: string, cookie = `SessionID=${token}-cookie`): HilinkHttpResponse => ({ + status: 200, + body: `${cookie}${token}`, +}); +const state = (passwordType: 3 | 4): HilinkHttpResponse => ({ + status: 200, + body: `-1${passwordType}`, +}); +const loggedIn: HilinkHttpResponse = { + status: 200, + body: 'OK', + headers: { + 'set-cookie': 'SessionID=logged-in; Path=/', + __requestverificationtoken: 'write-token', + }, +}; + +describe('Huawei HiLink firmware replay profiles', () => { + for (const fixture of [ + { + profile: HILINK_PROFILES[0], + token: 'type3-token', + password: Buffer.from(PASSWORD).toString('base64'), + }, + { profile: HILINK_PROFILES[1], token: 'type4-token', password: TYPE4_HASH }, + ] as const) { + test(`replays exact ${fixture.profile.id} login request`, async () => { + const h = replay([session(fixture.token), state(fixture.profile.passwordType), loggedIn]); + const result = await definition(h.transport).authenticatedProfile?.authenticate( + context(fixture.profile.id), + [fixture.profile.id], + ); + expect(result).toEqual({ + status: 'matched', + profile: fixture.profile.id, + detail: 'login-ok', + }); + expect(h.calls).toHaveLength(3); + expect(h.calls[2]).toEqual({ + method: 'POST', + url: `http://192.168.8.1${HILINK_PATHS.login}`, + body: `${USERNAME}${fixture.password}${fixture.profile.passwordType}`, + headers: [ + `Cookie: SessionID=${fixture.token}-cookie`, + `__RequestVerificationToken: ${fixture.token}`, + 'Content-Type: application/xml', + ], + interfaceName: 'eth9', + redirect: 'error', + }); + expect(h.remaining()).toBe(0); + }); + } + + test('refuses profile mismatch without cycling to another password algorithm', async () => { + const h = replay([session('mismatch-token'), state(4)]); + const profile = HILINK_PROFILES[0]; + const result = await definition(h.transport).authenticatedProfile?.authenticate( + context(profile.id), + [profile.id], + ); + expect(result).toEqual({ status: 'refused', detail: 'profile-mismatch' }); + expect(h.calls).toHaveLength(2); + }); +}); + +describe('Huawei HiLink write safety', () => { + test('requires a fresh authenticated readback before reporting applied', async () => { + const profile = HILINK_PROFILES[1]; + const h = replay([ + session('write-session'), + state(4), + loggedIn, + { status: 200, body: '0' }, + { status: 200, body: 'OK' }, + session('readback-session'), + state(4), + loggedIn, + { status: 200, body: '1' }, + ]); + const result = await definition(h.transport) + .operations(profile.id) + .data.write(context(profile.id), true); + expect(result.status).toBe('applied'); + expect(h.calls.map((call) => new URL(call.url).pathname)).toEqual([ + HILINK_PATHS.session, + HILINK_PATHS.loginState, + HILINK_PATHS.login, + HILINK_PATHS.data, + HILINK_PATHS.data, + HILINK_PATHS.session, + HILINK_PATHS.loginState, + HILINK_PATHS.login, + HILINK_PATHS.data, + ]); + }); + + test('expires mid-write with no credential retry and no secret in the refusal', async () => { + const profile = HILINK_PROFILES[0]; + const h = replay([ + session('expired-token', 'SessionID=private-cookie'), + state(3), + loggedIn, + { status: 200, body: '0' }, + { status: 200, body: '125002' }, + ]); + const result = await definition(h.transport) + .operations(profile.id) + .data.write(context(profile.id), true); + expect(result).toMatchObject({ status: 'refused', reason: 'auth-expired' }); + expect(h.calls.filter((call) => call.url.endsWith(HILINK_PATHS.login))).toHaveLength(1); + const serialized = JSON.stringify(result); + for (const secret of [PASSWORD, 'private-cookie', Buffer.from(PASSWORD).toString('base64')]) { + expect(serialized).not.toContain(secret); + } + }); + + test('does not infer data writability when the data read endpoint refuses capability', async () => { + const profile = HILINK_PROFILES[0]; + const h = replay([ + session('capability-token'), + state(3), + loggedIn, + { status: 200, body: '112008' }, + ]); + const result = await definition(h.transport) + .operations(profile.id) + .data.write(context(profile.id), true); + expect(result).toMatchObject({ status: 'refused', reason: 'capability-unavailable' }); + expect( + h.calls.some((call) => call.method === 'POST' && call.url.endsWith(HILINK_PATHS.data)), + ).toBe(false); + }); +}); diff --git a/control/src/providers/huawei-hilink/provider.ts b/control/src/providers/huawei-hilink/provider.ts new file mode 100644 index 0000000..86f35aa --- /dev/null +++ b/control/src/providers/huawei-hilink/provider.ts @@ -0,0 +1,114 @@ +import type { NormalizedModemObservation } from '../../observations'; +import type { ResourceOwnershipPort } from '../../ports/resource-ownership'; +import type { ProviderDefinition, ProviderOperationsSurface } from '../contracts'; +import { HuaweiProvider, type ReadOperation, type WriteOperation } from './runtime'; +import type { HilinkTransport } from './transport'; + +export const HILINK_PATHS = { + session: '/api/webserver/SesTokInfo', + loginState: '/api/user/state-login', + login: '/api/user/login', + status: '/api/monitoring/status', + signal: '/api/device/signal', + modeList: '/api/net/net-mode-list', + mode: '/api/net/net-mode', + data: '/api/dialup/mobile-dataswitch', +} as const; + +export type HilinkProfile = { + readonly id: string; + readonly firmware: string; + readonly passwordType: 3 | 4; +}; + +export const HILINK_PROFILES = [ + { + id: 'e3372h-22.200-password-type-3', + firmware: '22.200.05.00.1080', + passwordType: 3, + }, + { + id: 'e3372h-22.333-password-type-4', + firmware: '22.333.01.00.00', + passwordType: 4, + }, +] as const satisfies readonly HilinkProfile[]; + +export type HilinkCredentials = { + readonly username: string; + readonly password: string; +}; +export type HilinkOptions = { + readonly interfaceName: string; + readonly adminUrl: string; + readonly transport: HilinkTransport; + readonly ownership: ResourceOwnershipPort; + readonly credentials: HilinkCredentials; + readonly now?: () => number; +}; +export type HilinkObservation = NormalizedModemObservation; +export type HuaweiOperations = ProviderOperationsSurface & { + readonly status: ReadOperation; + readonly signal: ReadOperation; + readonly mode: WriteOperation; + readonly data: WriteOperation; +}; + +export function hilinkProfile(profileId: string): HilinkProfile | undefined { + return HILINK_PROFILES.find((profile) => profile.id === profileId); +} + +export function createHuaweiHiLinkProvider(options: HilinkOptions): HuaweiProvider { + return new HuaweiProvider(options); +} + +export function createHuaweiHiLinkDefinition( + options: HilinkOptions, +): ProviderDefinition { + const runtime = createHuaweiHiLinkProvider(options); + return { + id: 'huawei-hilink', + profileVersion: '1', + eligibleTransports: ['network'], + passiveMatchers: HILINK_PROFILES.map((profile) => ({ + id: `firmware-${profile.firmware}`, + fact: 'firmware', + expected: [profile.firmware], + profiles: [profile.id], + strength: 'strong', + required: true, + })), + unauthenticatedProbes: [ + { + id: 'hilink-session-shape', + run: (request) => runtime.sessionProbe(request), + }, + ], + authenticatedProfile: { + algorithm: 'firmware-selected-hilink-password', + attemptLimit: 1, + authenticate: (request, candidates) => runtime.authenticateProfile(request, candidates), + }, + capabilityReaders: [ + runtime.capability('status'), + runtime.capability('signal'), + runtime.capability('mode'), + runtime.capability('data'), + ], + observe: (context) => runtime.observe(context), + operations: (profile) => runtime.operations(profile), + contractFixtures: HILINK_PROFILES.map((profile) => ({ + profile: profile.id, + request: { + method: 'POST', + path: HILINK_PATHS.login, + passwordType: profile.passwordType, + interfaceBound: true, + redirects: 'disabled', + }, + response: { status: 'matched', sessionMaterial: '[redacted]' }, + })), + }; +} + +export { HuaweiProvider as HuaweiHiLinkProvider }; diff --git a/control/src/providers/huawei-hilink/runtime.ts b/control/src/providers/huawei-hilink/runtime.ts new file mode 100644 index 0000000..7af5781 --- /dev/null +++ b/control/src/providers/huawei-hilink/runtime.ts @@ -0,0 +1,218 @@ +import type { ObservationEnvelope, OperationResult } from '../../domain'; +import { + parseHilinkCapabilities, + parseHilinkDataCapability, + parseHilinkSession, + parseHilinkXmlValue, +} from '../../hardware/router-parsers'; +import type { HilinkObservationInput } from '../../observations/sources/hilink'; +import { normalizeHilinkObservation } from '../../observations/sources/hilink'; +import type { + AuthenticatedProfileResult, + CapabilityReader, + ProviderExecutionContext, + ProviderMatchRequest, +} from '../contracts'; +import { + applied, + capabilityPath, + operationDescriptor, + type ReadOperation, + type ReadValue, + readbackMatches, + refused, + requestSucceeded, + type WriteOperation, + writeDocument, +} from './operations'; +import { + HILINK_PATHS, + HILINK_PROFILES, + type HilinkObservation, + type HilinkProfile, + type HuaweiOperations, + hilinkProfile, +} from './provider'; +import { type HilinkSession, HilinkSessionRuntime } from './session'; + +export type { ReadOperation, WriteOperation } from './operations'; + +export class HuaweiProvider extends HilinkSessionRuntime { + async sessionProbe(request: ProviderMatchRequest) { + const profile = HILINK_PROFILES.find((candidate) => candidate.firmware === request.firmware); + const response = await this.request('GET', HILINK_PATHS.session); + const matches = response.status === 200 && parseHilinkSession(response.body) !== undefined; + return { + signal: matches && profile !== undefined ? ('match' as const) : ('unknown' as const), + strength: 'strong' as const, + profiles: profile === undefined ? [] : [profile.id], + detail: matches ? 'session-token-evidence' : 'session-token-not-proven', + }; + } + + async authenticateProfile( + request: ProviderMatchRequest, + candidates: readonly string[], + ): Promise { + const candidate = candidates.length === 1 ? hilinkProfile(candidates[0] ?? '') : undefined; + if (candidate === undefined) return { status: 'refused', detail: 'profile-mismatch' }; + const result = await this.authenticate(request, candidate, true); + if (result.status === 'ready') + return { status: 'matched', profile: candidate.id, detail: 'login-ok' }; + return { + status: result.reason === 'unreachable' ? 'unavailable' : 'refused', + detail: result.reason, + }; + } + + async observe( + context: ProviderExecutionContext, + ): Promise[]> { + const ready = await this.sessionFor(context); + if (ready === undefined) return []; + const [status, signal, modeList, mode] = await Promise.all([ + this.getResponse(HILINK_PATHS.status, ready), + this.getResponse(HILINK_PATHS.signal, ready), + this.getResponse(HILINK_PATHS.modeList, ready), + this.getResponse(HILINK_PATHS.mode, ready), + ]); + const input: HilinkObservationInput = { + status: status.body, + signal: signal.body, + netModeList: modeList.body, + netMode: mode.body, + }; + return [ + normalizeHilinkObservation(input, { + stableKey: `modem:${this.options.interfaceName}` as never, + generation: context.generation, + sourceEpoch: 1 as never, + observedAt: (this.options.now?.() ?? Date.now()) as never, + }), + ]; + } + + capability(kind: 'status' | 'signal' | 'mode' | 'data'): CapabilityReader { + return { + id: `hilink-${kind}`, + read: async (context) => { + const session = await this.sessionFor(context); + if (session === undefined) + return { signal: 'unknown', strength: 'strong', detail: 'session-unavailable' }; + const path = capabilityPath(kind); + const response = await this.getResponse(path, session); + const supported = + this.capabilitySupported(kind, response.body) && !this.authExpired(response); + return { + signal: supported ? 'match' : 'mismatch', + strength: 'strong', + detail: supported ? 'capability-reported' : 'capability-refused', + }; + }, + }; + } + + operations(profileId: string): HuaweiOperations { + const profile = hilinkProfile(profileId); + if (profile === undefined) return unsupportedOperations(profileId); + return { + access: 'read-write', + status: this.readOperation('status', HILINK_PATHS.status, profile), + signal: this.readOperation('signal', HILINK_PATHS.signal, profile), + mode: this.writeOperation('mode', HILINK_PATHS.mode, profile), + data: this.writeOperation('data', HILINK_PATHS.data, profile), + }; + } + + private async sessionFor(context: ProviderExecutionContext): Promise { + const cached = this.cachedSession(context); + if (cached !== undefined) return cached; + const profile = hilinkProfile(context.profile); + if (profile === undefined) return undefined; + const result = await this.authenticate(context, profile, true); + return result.status === 'ready' ? result.session : undefined; + } + + private capabilitySupported(kind: string, body: string): boolean { + if (kind === 'mode') + return parseHilinkCapabilities({ netModeList: body }).net_mode.state === 'reported'; + if (kind === 'data') return parseHilinkDataCapability(body).state === 'reported'; + return parseHilinkXmlValue(body, 'code') === undefined && //i.test(body); + } + + private readOperation(id: string, path: string, profile: HilinkProfile): ReadOperation { + return { + descriptor: operationDescriptor(id, false, profile), + read: async (context) => { + const result = await this.authenticate(context, profile, false); + if (result.status === 'refused') return refused(context.generation, result.reason); + const response = await this.getResponse(path, result.session); + if (this.authExpired(response)) return refused(context.generation, 'auth-expired'); + if (response.status < 200 || response.status >= 300) + return refused(context.generation, 'http-failure'); + return applied(context.generation, { body: response.body }); + }, + }; + } + + private writeOperation( + id: 'mode' | 'data', + path: string, + profile: HilinkProfile, + ): WriteOperation { + return { + descriptor: operationDescriptor(id, true, profile), + read: this.readOperation(`${id}-read`, path, profile).read, + write: (context, value) => this.write(context, profile, id, path, value), + }; + } + + private async write( + context: ProviderExecutionContext, + profile: HilinkProfile, + kind: 'mode' | 'data', + path: string, + value: string | boolean, + ): Promise> { + return this.withDevice(context, async () => { + const ownership = await this.options.ownership.acquire({ resource: 'router-session' }); + if (ownership.status === 'refused') return refused(context.generation, 'busy'); + try { + const ready = await this.authenticate(context, profile, false); + if (ready.status === 'refused') return refused(context.generation, ready.reason); + const capabilityPath = kind === 'mode' ? HILINK_PATHS.modeList : HILINK_PATHS.data; + const capability = await this.getResponse(capabilityPath, ready.session); + if (this.authExpired(capability)) return refused(context.generation, 'auth-expired'); + if (!this.capabilitySupported(kind, capability.body)) + return refused(context.generation, 'capability-unavailable'); + if (kind === 'mode') { + const offered = parseHilinkCapabilities({ netModeList: capability.body }).net_mode; + if ( + offered.state !== 'reported' || + !offered.modes.some((mode) => mode.id === String(value)) + ) + return refused(context.generation, 'capability-unavailable'); + } + const body = writeDocument(kind, value); + const posted = await this.postResponse(path, body, ready.session); + if (this.authExpired(posted)) return refused(context.generation, 'auth-expired'); + if (!requestSucceeded(posted.status, posted.body)) + return refused(context.generation, 'http-failure'); + const fresh = await this.authenticate(context, profile, false); + if (fresh.status === 'refused') return refused(context.generation, fresh.reason); + const readback = await this.getResponse(path, fresh.session); + if (this.authExpired(readback)) return refused(context.generation, 'auth-expired'); + const matches = readbackMatches(kind, readback.body, value); + return matches + ? applied(context.generation, { body: readback.body }) + : refused(context.generation, 'not-applied'); + } finally { + await ownership.lease.release(); + } + }); + } +} + +function unsupportedOperations(_profileId: string): HuaweiOperations { + throw new Error('unsupported-hilink-profile'); +} diff --git a/control/src/providers/huawei-hilink/session.ts b/control/src/providers/huawei-hilink/session.ts new file mode 100644 index 0000000..8668a25 --- /dev/null +++ b/control/src/providers/huawei-hilink/session.ts @@ -0,0 +1,177 @@ +import { createHash } from 'node:crypto'; +import { + parseHilinkSession, + parseHilinkUserState, + parseHilinkXmlValue, +} from '../../hardware/router-parsers'; +import type { ProviderExecutionContext, ProviderMatchRequest } from '../contracts'; +import { HILINK_PATHS, type HilinkOptions, type HilinkProfile } from './provider'; +import type { HilinkHttpResponse } from './transport'; + +export type HilinkSession = { readonly cookie: string; readonly token: string }; +export type HilinkSessionFailure = + | 'auth-expired' + | 'http-failure' + | 'malformed' + | 'profile-mismatch' + | 'unreachable'; +export type HilinkSessionResult = + | { readonly status: 'ready'; readonly session: HilinkSession } + | { readonly status: 'refused'; readonly reason: HilinkSessionFailure }; + +function base64(value: string): string { + return Buffer.from(value).toString('base64'); +} + +function derivePassword(profile: HilinkProfile, username: string, password: string, token: string) { + if (profile.passwordType === 3) return base64(password); + const passwordHash = base64(createHash('sha256').update(password).digest('hex')); + const loginHash = createHash('sha256').update(`${username}${passwordHash}${token}`).digest('hex'); + return base64(loginHash); +} + +function escapeXml(value: string): string { + return value + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +function loginDocument(profile: HilinkProfile, username: string, password: string, token: string) { + return `${escapeXml(username)}${derivePassword(profile, username, password, token)}${profile.passwordType}`; +} + +function responseHeader(response: HilinkHttpResponse, name: string): string | undefined { + const expected = name.toLowerCase(); + return Object.entries(response.headers ?? {}).find( + ([key]) => key.toLowerCase() === expected, + )?.[1]; +} + +function loggedInSession(response: HilinkHttpResponse, fallback: HilinkSession): HilinkSession { + const cookie = + responseHeader(response, 'set-cookie')?.split(';', 1)[0]?.trim() ?? fallback.cookie; + const tokenHeader = + responseHeader(response, '__requestverificationtoken') ?? + responseHeader(response, '__requestverificationtokenone'); + const token = tokenHeader?.split('#', 1)[0]?.trim() ?? fallback.token; + return { cookie, token }; +} + +export class HilinkSessionRuntime { + protected readonly options: HilinkOptions; + readonly #authenticated = new Map(); + readonly #serial = new Map>(); + + constructor(options: HilinkOptions) { + this.options = options; + } + + protected async withDevice( + context: ProviderExecutionContext, + run: () => Promise, + ): Promise { + const key = String(context.physicalModemId); + const prior = this.#serial.get(key) ?? Promise.resolve(); + let release = () => {}; + const current = new Promise((resolve) => { + release = resolve; + }); + const chain = prior.then(() => current); + this.#serial.set(key, chain); + await prior; + try { + return await run(); + } finally { + release(); + if (this.#serial.get(key) === chain) this.#serial.delete(key); + } + } + + protected sessionKey(context: ProviderMatchRequest): string { + return `${context.physicalModemId}:${context.generation}`; + } + + protected cachedSession(context: ProviderMatchRequest): HilinkSession | undefined { + return this.#authenticated.get(this.sessionKey(context)); + } + + protected async authenticate( + context: ProviderMatchRequest, + profile: HilinkProfile, + cache: boolean, + ): Promise { + const sessionResponse = await this.request('GET', HILINK_PATHS.session); + if (sessionResponse.status === 401 || sessionResponse.status === 403) + return { status: 'refused', reason: 'auth-expired' }; + if (sessionResponse.status !== 200) return { status: 'refused', reason: 'unreachable' }; + const initial = parseHilinkSession(sessionResponse.body); + if (initial === undefined) return { status: 'refused', reason: 'malformed' }; + + const stateResponse = await this.getResponse(HILINK_PATHS.loginState, initial); + if (this.authExpired(stateResponse)) return { status: 'refused', reason: 'auth-expired' }; + const state = parseHilinkUserState(stateResponse.body); + if (state === undefined) return { status: 'refused', reason: 'malformed' }; + if (state.passwordType !== profile.passwordType) + return { status: 'refused', reason: 'profile-mismatch' }; + + const login = await this.postResponse( + HILINK_PATHS.login, + loginDocument( + profile, + this.options.credentials.username, + this.options.credentials.password, + initial.token, + ), + initial, + ); + if (this.authExpired(login)) return { status: 'refused', reason: 'auth-expired' }; + if (login.status !== 200 || !/\s*OK\s*<\/response>/i.test(login.body)) + return { status: 'refused', reason: 'http-failure' }; + const session = loggedInSession(login, initial); + if (cache) this.#authenticated.set(this.sessionKey(context), session); + return { status: 'ready', session }; + } + + protected authExpired(response: HilinkHttpResponse): boolean { + return ( + response.status === 401 || + response.status === 403 || + parseHilinkXmlValue(response.body, 'code') === '125002' + ); + } + + protected getResponse(path: string, session: HilinkSession): Promise { + return this.request('GET', path, undefined, [`Cookie: ${session.cookie}`]); + } + + protected postResponse( + path: string, + body: string, + session: HilinkSession, + ): Promise { + return this.request('POST', path, body, [ + `Cookie: ${session.cookie}`, + `__RequestVerificationToken: ${session.token}`, + 'Content-Type: application/xml', + ]); + } + + protected request( + method: 'GET' | 'POST', + path: string, + body?: string, + headers: readonly string[] = [], + ): Promise { + return this.options.transport.request({ + method, + url: `${this.options.adminUrl}${path}`, + ...(body === undefined ? {} : { body }), + headers, + interfaceName: this.options.interfaceName, + redirect: 'error', + }); + } +} diff --git a/control/src/providers/huawei-hilink/transport.ts b/control/src/providers/huawei-hilink/transport.ts new file mode 100644 index 0000000..8ce5e91 --- /dev/null +++ b/control/src/providers/huawei-hilink/transport.ts @@ -0,0 +1,17 @@ +export type HilinkHttpRequest = { + readonly method: 'GET' | 'POST'; + readonly url: string; + readonly body?: string; + readonly headers: readonly string[]; + readonly interfaceName: string; + readonly redirect: 'error'; +}; +export type HilinkHttpResponse = { + readonly status: number; + readonly body: string; + readonly headers?: Readonly>; +}; +export interface HilinkTransport { + request(request: HilinkHttpRequest): Promise; +} +export type HilinkReplayExchange = HilinkHttpRequest & { readonly response: HilinkHttpResponse }; diff --git a/control/src/providers/index.ts b/control/src/providers/index.ts new file mode 100644 index 0000000..615f19b --- /dev/null +++ b/control/src/providers/index.ts @@ -0,0 +1,8 @@ +export * from './contracts'; +export * from './huawei-hilink'; +export * from './matcher'; +export * from './modem-manager'; +export * from './network-manager'; +export * from './registry'; +export * from './ufi-himi'; +export * from './zte-goform'; diff --git a/control/src/providers/matcher.ts b/control/src/providers/matcher.ts new file mode 100644 index 0000000..131dca9 --- /dev/null +++ b/control/src/providers/matcher.ts @@ -0,0 +1,247 @@ +import type { + EvidenceStrength, + MatcherEvidence, + MatcherScore, + ProviderDefinition, + ProviderMatcher, + ProviderMatchRequest, + ProviderMatchResult, +} from './contracts'; +import type { ProviderRegistry } from './registry'; + +const STRENGTH_POINTS = { + none: 0, + weak: 1, + moderate: 2, + strong: 3, +} as const satisfies Record; + +type Candidate = { + readonly provider: ProviderDefinition; + readonly profile: string; + points: number; +}; + +type CacheEntry = { + readonly signature: string; + readonly result: ProviderMatchResult; +}; + +function scoreOf(points: number): MatcherScore { + if (points <= 0) return 'unsupported'; + if (points === 1) return 'maybe'; + if (points === 2) return 'likely'; + return 'supported'; +} + +function confidenceOf(points: number): number { + return Math.min(1, points / 3); +} + +function cacheSignature(request: ProviderMatchRequest, registryRevision: number): string { + return JSON.stringify({ + registryRevision, + generation: request.generation, + transport: request.transport, + passiveFacts: request.passiveFacts, + composition: request.composition, + firmware: request.firmware ?? null, + }); +} + +function addCandidate( + candidates: Map, + provider: ProviderDefinition, + profile: string, + strength: EvidenceStrength, +): void { + const key = `${provider.id}\u0000${profile}`; + const existing = candidates.get(key); + if (existing === undefined) { + candidates.set(key, { provider, profile, points: STRENGTH_POINTS[strength] }); + return; + } + existing.points += STRENGTH_POINTS[strength]; +} + +function unresolvedResult( + status: 'unsupported' | 'ambiguous', + request: ProviderMatchRequest, + points: number, + evidence: readonly MatcherEvidence[], + conflicts: readonly MatcherEvidence[], +): ProviderMatchResult { + return { + status, + provider: null, + profile: null, + score: scoreOf(points), + confidence: confidenceOf(points), + evidence, + conflicts, + operations: null, + writable: false, + generation: request.generation, + physicalModemId: request.physicalModemId, + }; +} + +async function evaluate( + registry: ProviderRegistry, + request: ProviderMatchRequest, +): Promise { + const evidence: MatcherEvidence[] = []; + const conflicts: MatcherEvidence[] = []; + const candidates = new Map(); + const eligible: ProviderDefinition[] = []; + + for (const provider of registry.list()) { + const transportMatches = provider.eligibleTransports.includes(request.transport); + evidence.push({ + provider: provider.id, + profile: null, + stage: 'transport-eligibility', + source: request.transport, + signal: transportMatches ? 'match' : 'mismatch', + strength: 'none', + detail: transportMatches ? 'eligible-transport' : 'ineligible-transport', + }); + if (transportMatches) eligible.push(provider); + } + + for (const provider of eligible) { + for (const matcher of provider.passiveMatchers) { + const actual = request.passiveFacts + .filter((fact) => fact.kind === matcher.fact) + .map((fact) => fact.value); + const signal = + actual.length === 0 + ? 'unknown' + : actual.some((value) => matcher.expected.includes(value)) + ? 'match' + : 'mismatch'; + const item: MatcherEvidence = { + provider: provider.id, + profile: matcher.profiles.length === 1 ? (matcher.profiles[0] ?? null) : null, + stage: 'passive-facts', + source: matcher.id, + signal, + strength: matcher.strength, + detail: signal === 'match' ? 'expected-fact-present' : 'expected-fact-not-proven', + }; + evidence.push(item); + if (signal === 'mismatch' && matcher.required) conflicts.push(item); + if (signal === 'match') { + for (const profile of matcher.profiles) { + addCandidate(candidates, provider, profile, matcher.strength); + } + } + } + } + + for (const provider of eligible) { + for (const probe of provider.unauthenticatedProbes) { + const result = await probe.run(request); + const item: MatcherEvidence = { + provider: provider.id, + profile: result.profiles.length === 1 ? (result.profiles[0] ?? null) : null, + stage: 'unauthenticated-fingerprint', + source: probe.id, + signal: result.signal, + strength: result.strength, + detail: result.detail, + }; + evidence.push(item); + if (result.signal === 'mismatch') conflicts.push(item); + if (result.signal === 'match') { + for (const profile of result.profiles) { + addCandidate(candidates, provider, profile, result.strength); + } + } + } + } + + const ranked = [...candidates.values()].sort((left, right) => right.points - left.points); + const top = ranked[0]; + if (top === undefined) return unresolvedResult('unsupported', request, 0, evidence, conflicts); + if (ranked[1]?.points === top.points) { + return unresolvedResult('ambiguous', request, top.points, evidence, conflicts); + } + + if (top.provider.authenticatedProfile !== undefined) { + const auth = await top.provider.authenticatedProfile.authenticate(request, [top.profile]); + const matched = auth.status === 'matched' && auth.profile === top.profile; + const item: MatcherEvidence = { + provider: top.provider.id, + profile: top.profile, + stage: 'authenticated-profile', + source: top.provider.authenticatedProfile.algorithm, + signal: matched ? 'match' : auth.status === 'unavailable' ? 'unknown' : 'mismatch', + strength: 'strong', + detail: auth.detail, + }; + evidence.push(item); + if (!matched) { + conflicts.push(item); + return unresolvedResult('ambiguous', request, top.points, evidence, conflicts); + } + top.points += STRENGTH_POINTS.strong; + } + + const context = { ...request, profile: top.profile }; + for (const reader of top.provider.capabilityReaders) { + const result = await reader.read(context); + const item: MatcherEvidence = { + provider: top.provider.id, + profile: top.profile, + stage: 'capability-read', + source: reader.id, + signal: result.signal, + strength: result.strength, + detail: result.detail, + }; + evidence.push(item); + if (result.signal === 'match') top.points += STRENGTH_POINTS[result.strength]; + if (result.signal === 'mismatch') conflicts.push(item); + } + + if (scoreOf(top.points) !== 'supported') { + return unresolvedResult('ambiguous', request, top.points, evidence, conflicts); + } + const operations = top.provider.operations(top.profile); + return { + status: 'selected', + provider: top.provider.id, + profile: top.profile, + score: 'supported', + confidence: confidenceOf(top.points), + evidence, + conflicts, + operations, + writable: operations.access === 'read-write', + generation: request.generation, + physicalModemId: request.physicalModemId, + }; +} + +class GenerationScopedProviderMatcher implements ProviderMatcher { + /** Cache mutation is scoped by physical modem, generation, firmware and composition. */ + readonly #cache = new Map(); + + constructor(private readonly registry: ProviderRegistry) {} + + async match(request: ProviderMatchRequest): Promise { + const key = request.physicalModemId; + const signature = cacheSignature(request, this.registry.revision); + const cached = this.#cache.get(key); + if (cached?.signature === signature) return cached.result; + + const result = await evaluate(this.registry, request); + this.#cache.set(key, { signature, result }); + return result; + } +} + +export function createProviderMatcher(registry: ProviderRegistry): ProviderMatcher { + return new GenerationScopedProviderMatcher(registry); +} diff --git a/control/src/providers/modem-manager/capability-truth.test.ts b/control/src/providers/modem-manager/capability-truth.test.ts new file mode 100644 index 0000000..87a368b --- /dev/null +++ b/control/src/providers/modem-manager/capability-truth.test.ts @@ -0,0 +1,459 @@ +// End-to-end capability truth through the REAL ModemManager provider. +// +// The unit suites in `src/radio/` prove the decoders and the descriptor builders. This +// one proves the WIRE: a `SupportedModes` property served by the MM-faithful object +// model reaches `operations().modes.describe()` without anything in between coercing +// it. That is the acceptance property — "`preferred: none` survives to the descriptor +// VERBATIM" is a claim about the whole path, not about a pure function. +// +// It runs on the in-memory transport (`test-support/conformance/mm-transport.ts`) for +// the reason the conformance matrix does: a suite that SKIPS wherever no session bus +// exists answers nothing. + +import { beforeEach, describe, expect, test } from 'bun:test'; +import { FakeMmTransport } from '../../../test-support/conformance/mm-transport'; +import type { ModemSpec } from '../../../test-support/fake-mm/object-model'; +import { + type DeviceGeneration, + deviceGeneration, + type PhysicalModemId, + physicalModemId, +} from '../../domain'; +import { MODE_NONE, type ModeSelection } from '../../radio'; +import type { DbusValue, MethodCall, MethodReply } from '../../transport'; +import type { ProviderExecutionContext } from '../contracts'; +import { createModemManagerProvider } from './provider'; +import type { ModemManagerProviderOperations } from './types'; + +const CS = 1 << 0; +const M2G = 1 << 1; +const M3G = 1 << 2; +const M4G = 1 << 3; +const M5G = 1 << 4; +/** A mode bit no ModemManager release this build knows about defines. */ +const FUTURE = 1 << 9; + +const GENERATION: DeviceGeneration = deviceGeneration(1); +const PROFILE = 'generic-mm'; + +/** + * Fibocom FM350-GL on the bench M.2→USB carrier — the same spec the conformance matrix + * uses, and the reason this whole todo exists: ONE combination, `preferred` = 0. + */ +const FM350_SPEC: ModemSpec = { + index: 4, + manufacturer: 'Fibocom', + model: 'FM350-GL', + revision: '81600.0000.00.19.17.10', + supportedModes: [[CS | M2G | M3G | M4G, 0]], + currentModes: [CS | M2G | M3G | M4G, 0], + supportedBands: [33, 378], + sims: [{ index: 4, iccid: '8900000000000000004', imsi: '001010000000004', active: true }], +}; + +/** Bench Quectel RM530N-GL, matrix values. */ +const QUECTEL_SPEC: ModemSpec = { + index: 1, + manufacturer: 'Quectel', + model: 'RM530N-GL', + revision: 'RM530NGLAAR11A02M4G', + supportedModes: [[CS | M2G | M3G, 0]], + currentModes: [CS | M2G | M3G, 0], + supportedBands: [33, 378], + sims: [{ index: 1, iccid: '8900000000000000001', imsi: '001010000000001', active: true }], +}; + +/** Bench SIMCom SIM7600G-H, matrix values. */ +const SIMCOM_SPEC: ModemSpec = { + index: 2, + manufacturer: 'SIMCom', + model: 'SIM7600G-H', + revision: 'LE20B04SIM7600G22', + supportedModes: [[M3G, 0]], + currentModes: [M3G, 0], + sims: [{ index: 2, iccid: '8900000000000000002', imsi: '001010000000002', active: true }], +}; + +/** A hypothetical modem advertising a mode bit this build cannot name. */ +const UNKNOWN_COMBINATION_SPEC: ModemSpec = { + index: 5, + manufacturer: 'Unknown Radios Inc.', + model: 'FUTURE-1', + revision: '1.0', + supportedModes: [ + [M4G | M5G, M5G], + [M4G | M5G | FUTURE, FUTURE], + ], + currentModes: [M4G | M5G, M5G], + sims: [{ index: 5, iccid: '8900000000000000005', imsi: '001010000000005', active: true }], +}; + +/** + * A modem with NO SIM, as ModemManager reports one: failed state, `StateFailedReason` + * = 2 (`SIM_MISSING`), and — critically — an empty `sims` list so `Sim` is `/`. + */ +const NO_SIM_SPEC: ModemSpec = { + index: 6, + manufacturer: 'Quectel', + model: 'RM530N-GL', + revision: 'RM530NGLAAR11A02M4G', + state: -1, + stateFailedReason: 2, + supportedModes: [[CS | M2G | M3G, 0]], + currentModes: [CS | M2G | M3G, 0], + sims: [], +}; + +/** + * The SAME modem still initializing: `Sim` is `/` and NO failure reason is exported. + * Built field-by-field rather than spread from `NO_SIM_SPEC`, because the absence of + * `stateFailedReason` is the entire point of this fixture and a spread would carry it. + */ +const BLANK_SIM_SPEC: ModemSpec = { + index: 7, + manufacturer: 'Quectel', + model: 'RM530N-GL', + revision: 'RM530NGLAAR11A02M4G', + state: 1, + supportedModes: [[CS | M2G | M3G, 0]], + currentModes: [CS | M2G | M3G, 0], + sims: [], +}; + +function contextFor(spec: ModemSpec): ProviderExecutionContext { + const id: PhysicalModemId = physicalModemId(`serial:fake-device-${spec.index}`); + return { + physicalModemId: id, + generation: GENERATION, + transport: 'modemmanager', + passiveFacts: [], + composition: 'generic', + profile: PROFILE, + }; +} + +/** + * The in-memory transport with `SetCurrentModes` / `SetCurrentBands` ACTUALLY APPLIED. + * + * The base transport accepts every call and changes nothing, which is exactly the + * accepted-but-ignored write the readback gate exists to catch — so both behaviours are + * needed, and `applyWrites` selects between them. + */ +class WritableMmTransport extends FakeMmTransport { + #spec: ModemSpec; + readonly #applyWrites: boolean; + + constructor(spec: ModemSpec, applyWrites: boolean) { + super({ modems: [spec] }); + this.#spec = spec; + this.#applyWrites = applyWrites; + } + + override async callMethod(call: MethodCall): Promise { + if (call.member === 'SetCurrentModes' && this.#applyWrites) { + const pair = (call.args?.[0] ?? []) as readonly number[]; + this.#spec = { ...this.#spec, currentModes: [pair[0] ?? 0, pair[1] ?? 0] }; + this.removeModem(this.#spec.index); + this.addModem(this.#spec); + return { signature: '', body: [] }; + } + return super.callMethod(call); + } + + override tree(): DbusValue { + return super.tree(); + } +} + +function providerFor(spec: ModemSpec) { + const transport = new FakeMmTransport({ modems: [spec] }); + const provider = createModemManagerProvider({ transport }); + return { provider, transport, operations: provider.definition.operations(PROFILE) }; +} + +const allowedValues = (constraints: { kind: string; values?: readonly unknown[] }) => + constraints.kind === 'allowed-values' ? (constraints.values ?? []) : []; + +describe('FM350 — `preferred: none` survives to the descriptor VERBATIM', () => { + let operations: ModemManagerProviderOperations; + const context = contextFor(FM350_SPEC); + + beforeEach(() => { + operations = providerFor(FM350_SPEC).operations; + }); + + test('the live descriptor offers exactly the modem`s own combination', async () => { + const descriptor = await operations.modes.describe(context); + expect(descriptor.constraints).toEqual({ + kind: 'allowed-values', + values: [{ allowed: ['cs', '2g', '3g', '4g'], preferred: MODE_NONE }], + }); + }); + + test('`preferred` is `none` and is not any of the allowed modes', async () => { + const descriptor = await operations.modes.describe(context); + const values = allowedValues(descriptor.constraints) as readonly ModeSelection[]; + expect(values[0]?.preferred).toBe(MODE_NONE); + for (const mode of ['cs', '2g', '3g', '4g', '5g']) { + expect(values[0]?.preferred).not.toBe(mode); + } + }); + + test('the READ answers the same combination with its raw masks intact', async () => { + const result = await operations.modes.read(context); + expect(result.status).toBe('applied'); + if (result.status !== 'applied') return; + expect(result.value.current).toEqual({ + state: 'reported', + combination: { + allowedMask: CS | M2G | M3G | M4G, + allowed: ['cs', '2g', '3g', '4g'], + preferredMask: 0, + preferred: MODE_NONE, + classification: 'named', + anomalies: [], + }, + }); + }); + + test('the observation retains `CurrentModes` as its `(uu)` pair, unflattened', async () => { + const { provider } = providerFor(FM350_SPEC); + const snapshot = await provider.readSnapshot(context); + expect(snapshot.ok).toBe(true); + if (!snapshot.ok) return; + const view = snapshot.observation; + expect(view.freshness.state).toBe('fresh'); + expect(view.value?.diagnostics.raw['Modem.CurrentModes']).toEqual([CS | M2G | M3G | M4G, 0]); + expect(view.value?.diagnostics.raw['Modem.SignalQuality']).toEqual([71, true]); + }); + + test('the write is disruptive, journalled, admitted and readback-gated', async () => { + const descriptor = await operations.modes.describe(context); + expect(descriptor.mutationImpact).toBe('disruptive'); + expect(descriptor.journal.required).toBe(true); + expect(descriptor.admission.required).toBe(true); + expect(descriptor.readback.required).toBe(true); + }); +}); + +describe('Quectel and SIMCom descriptors', () => { + test('Quectel offers exactly its one advertised combination', async () => { + const { operations } = providerFor(QUECTEL_SPEC); + const descriptor = await operations.modes.describe(contextFor(QUECTEL_SPEC)); + expect(descriptor.constraints).toEqual({ + kind: 'allowed-values', + values: [{ allowed: ['cs', '2g', '3g'], preferred: MODE_NONE }], + }); + }); + + test('SIMCom offers exactly its one advertised combination', async () => { + const { operations } = providerFor(SIMCOM_SPEC); + const descriptor = await operations.modes.describe(contextFor(SIMCOM_SPEC)); + expect(descriptor.constraints).toEqual({ + kind: 'allowed-values', + values: [{ allowed: ['3g'], preferred: MODE_NONE }], + }); + }); + + test('neither device`s preference is invented from its allowed set', async () => { + for (const spec of [QUECTEL_SPEC, SIMCOM_SPEC]) { + const { operations } = providerFor(spec); + const descriptor = await operations.modes.describe(contextFor(spec)); + const values = allowedValues(descriptor.constraints) as readonly ModeSelection[]; + expect(values[0]?.preferred).toBe(MODE_NONE); + } + }); +}); + +describe('an unknown combination is offered, not coerced to unsupported', () => { + const context = contextFor(UNKNOWN_COMBINATION_SPEC); + + test('both combinations reach the descriptor, the unnameable one included', async () => { + const { operations } = providerFor(UNKNOWN_COMBINATION_SPEC); + const descriptor = await operations.modes.describe(context); + expect(allowedValues(descriptor.constraints)).toEqual([ + { allowed: ['4g', '5g'], preferred: '5g' }, + { allowed: ['4g', '5g', 'mode-bit-512'], preferred: 'mode-bit-512' }, + ]); + }); + + test('the descriptor stays AVAILABLE despite the unknown member', async () => { + const { operations } = providerFor(UNKNOWN_COMBINATION_SPEC); + const descriptor = await operations.modes.describe(context); + expect(descriptor.availability).toEqual({ state: 'available' }); + expect(descriptor.support.write).toEqual({ supported: true }); + }); + + test('the read classifies it without dropping it', async () => { + const { operations } = providerFor(UNKNOWN_COMBINATION_SPEC); + const result = await operations.modes.read(context); + if (result.status !== 'applied') throw new Error('expected applied'); + expect(result.value.supported.combinations).toHaveLength(2); + expect(result.value.supported.combinations[1]?.classification).toBe('unknown-combination'); + expect(result.value.supported.undecodable).toEqual([]); + }); + + test('a selection the modem never advertised is refused, never rounded', async () => { + const { operations } = providerFor(UNKNOWN_COMBINATION_SPEC); + const result = await operations.modes.write(context, { + allowed: ['4g', '5g'], + preferred: '4g', + }); + expect(result).toMatchObject({ + status: 'refused', + reason: 'mode-combination-not-advertised', + }); + }); +}); + +describe('mode writes are readback-gated at the call path, not only in the descriptor', () => { + test('an accepted-but-ignored SetCurrentModes is refused, never reported applied', async () => { + const transport = new WritableMmTransport(UNKNOWN_COMBINATION_SPEC, false); + const provider = createModemManagerProvider({ transport }); + const result = await provider.definition + .operations(PROFILE) + .modes.write(contextFor(UNKNOWN_COMBINATION_SPEC), { + allowed: ['4g', '5g', 'mode-bit-512'], + preferred: 'mode-bit-512', + }); + expect(result).toMatchObject({ status: 'refused', reason: 'mode-write-readback-mismatch' }); + }); + + test('a write the modem HONOURS reports applied and returns the new truth', async () => { + const transport = new WritableMmTransport(UNKNOWN_COMBINATION_SPEC, true); + const provider = createModemManagerProvider({ transport }); + const result = await provider.definition + .operations(PROFILE) + .modes.write(contextFor(UNKNOWN_COMBINATION_SPEC), { + allowed: ['4g', '5g', 'mode-bit-512'], + preferred: 'mode-bit-512', + }); + if (result.status !== 'applied') throw new Error(`expected applied, got ${result.status}`); + expect(result.value.current).toMatchObject({ + state: 'reported', + combination: { preferred: 'mode-bit-512', preferredMask: FUTURE }, + }); + }); +}); + +describe('band writes carry the disruptive class and the certification gate', () => { + const context = contextFor(FM350_SPEC); + + test('the live band descriptor is disruptive and refused without certification', async () => { + const { operations } = providerFor(FM350_SPEC); + const descriptor = await operations.bands.describe(context); + expect(descriptor.mutationImpact).toBe('disruptive'); + expect(descriptor.availability).toEqual({ + state: 'refused', + reason: 'band-certification-required', + }); + expect(descriptor.support.write).toEqual({ + supported: false, + reason: 'band-certification-required', + }); + }); + + test('the certification precondition and readback are both declared', async () => { + const { operations } = providerFor(FM350_SPEC); + const descriptor = await operations.bands.describe(context); + expect(descriptor.livePreconditions).toContain('band-certification-present'); + expect(descriptor.readback.required).toBe(true); + expect(descriptor.journal.required).toBe(true); + expect(descriptor.admission.required).toBe(true); + }); + + test('a band WRITE is refused and never reaches the bus', async () => { + const { operations, transport } = providerFor(FM350_SPEC); + const before = transport.calls.length; + const result = await operations.bands.write(context, ['eutran-3']); + expect(result).toMatchObject({ + status: 'refused', + reason: 'band-write-certification-required', + }); + expect( + transport.calls.slice(before).filter((call) => call.member === 'SetCurrentBands'), + ).toEqual([]); + }); + + test('band READS stay available on the same uncertified device', async () => { + const { operations } = providerFor(FM350_SPEC); + const result = await operations.bands.read(context); + expect(result.status).toBe('applied'); + if (result.status !== 'applied') return; + expect(result.value.supported).toEqual(['eutran-3', 'ngran-78']); + }); + + test('an SKU resolver that finds no catalog entry still refuses', async () => { + const transport = new FakeMmTransport({ modems: [FM350_SPEC] }); + const provider = createModemManagerProvider({ + transport, + bandSku: () => ({ vidPid: '0e8d:7127', model: 'FM350-GL', firmwarePrefix: '81600' }), + }); + const descriptor = await provider.definition.operations(PROFILE).bands.describe(context); + expect(descriptor.availability).toEqual({ + state: 'refused', + reason: 'band-certification-required', + }); + }); +}); + +describe('no-SIM is EXPLICIT evidence, through the provider', () => { + test('a modem reporting `sim-missing` yields an evidence-backed absent state', async () => { + const { provider } = providerFor(NO_SIM_SPEC); + const snapshot = await provider.readSnapshot(contextFor(NO_SIM_SPEC)); + if (!snapshot.ok) throw new Error('expected a snapshot'); + expect(snapshot.sim.presence).toBe('absent'); + expect(snapshot.sim.presenceEvidence).toEqual({ + kind: 'state-failed-reason', + field: 'failedReason', + value: 'sim-missing', + }); + }); + + test('THE CONTROL: the same blank SIM path WITHOUT the reason is never absent', async () => { + const { provider } = providerFor(BLANK_SIM_SPEC); + const snapshot = await provider.readSnapshot(contextFor(BLANK_SIM_SPEC)); + if (!snapshot.ok) throw new Error('expected a snapshot'); + expect(snapshot.sim.presence).toBe('unknown'); + expect(snapshot.sim.presenceEvidence).toEqual({ + kind: 'no-evidence', + inspected: ['sim', 'simSlots', 'failedReason'], + }); + }); + + test('`present: false` is carried by BOTH, so it is never a claim of absence', async () => { + for (const spec of [NO_SIM_SPEC, BLANK_SIM_SPEC]) { + const { provider } = providerFor(spec); + const snapshot = await provider.readSnapshot(contextFor(spec)); + if (!snapshot.ok) throw new Error('expected a snapshot'); + expect(snapshot.sim.present).toBe(false); + } + }); + + test('a modem WITH a SIM names the object path that proved it', async () => { + const { provider } = providerFor(QUECTEL_SPEC); + const snapshot = await provider.readSnapshot(contextFor(QUECTEL_SPEC)); + if (!snapshot.ok) throw new Error('expected a snapshot'); + expect(snapshot.sim.presence).toBe('present'); + expect(snapshot.sim.presenceEvidence).toEqual({ + kind: 'sim-object-path', + field: 'sim', + value: '/org/freedesktop/ModemManager1/SIM/1', + }); + }); + + test('the normalized observation agrees with the snapshot on both devices', async () => { + for (const [spec, expected] of [ + [NO_SIM_SPEC, 'known'], + [BLANK_SIM_SPEC, 'unknown'], + ] as const) { + const { provider } = providerFor(spec); + const snapshot = await provider.readSnapshot(contextFor(spec)); + if (!snapshot.ok) throw new Error('expected a snapshot'); + expect(snapshot.observation.value?.sim.presence.state).toBe(expected); + expect(snapshot.observation.value?.sim.presenceEvidence).toEqual( + snapshot.sim.presenceEvidence, + ); + } + }); +}); diff --git a/control/src/providers/modem-manager/error-mapping.test.ts b/control/src/providers/modem-manager/error-mapping.test.ts new file mode 100644 index 0000000..022e4db --- /dev/null +++ b/control/src/providers/modem-manager/error-mapping.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, test } from 'bun:test'; +import { DisconnectedError, TransportError } from '../../transport'; +import { mapModemManagerError } from './errors'; + +function namedError(name: string): Error { + const error = new Error(name); + Object.defineProperty(error, 'dbusName', { value: name }); + return error; +} + +describe('mapModemManagerError', () => { + test('maps Core.Unauthorized to the exact domain refusal', () => { + expect( + mapModemManagerError(namedError('org.freedesktop.ModemManager1.Error.Core.Unauthorized')), + ).toEqual({ reason: 'unauthorized', retryable: false }); + }); + + test('keeps unsupported, wrong-state, busy, timeout, and disconnect distinct', () => { + expect(mapModemManagerError(namedError('x.Error.Core.Unsupported')).reason).toBe('unsupported'); + expect(mapModemManagerError(namedError('x.Error.Core.WrongState')).reason).toBe('wrong-state'); + expect(mapModemManagerError(namedError('x.Error.Core.InProgress')).reason).toBe('busy'); + expect(mapModemManagerError(new TransportError('call timed out')).reason).toBe('timed-out'); + expect(mapModemManagerError(new DisconnectedError()).reason).toBe('disconnected'); + }); +}); diff --git a/control/src/providers/modem-manager/errors.ts b/control/src/providers/modem-manager/errors.ts new file mode 100644 index 0000000..2bfe2ff --- /dev/null +++ b/control/src/providers/modem-manager/errors.ts @@ -0,0 +1,43 @@ +import { DisconnectedError, TransportError } from '../../transport'; + +export const MODEM_MANAGER_REFUSAL_REASONS = [ + 'unauthorized', + 'unsupported', + 'wrong-state', + 'busy', + 'not-found', + 'timed-out', + 'disconnected', + 'failed', +] as const; + +export type ModemManagerRefusalReason = (typeof MODEM_MANAGER_REFUSAL_REASONS)[number]; + +export type ModemManagerRefusal = { + readonly reason: ModemManagerRefusalReason; + readonly retryable: boolean; +}; + +function errorIdentity(error: unknown): string { + if (!(error instanceof Error)) return String(error); + const dbusName = Reflect.get(error, 'dbusName'); + return `${typeof dbusName === 'string' ? dbusName : error.name} ${error.message}`; +} + +export function mapModemManagerError(error: unknown): ModemManagerRefusal { + if (error instanceof DisconnectedError) return { reason: 'disconnected', retryable: true }; + const identity = errorIdentity(error); + if (/Unauthorized|AccessDenied/i.test(identity)) + return { reason: 'unauthorized', retryable: false }; + if (/Unsupported|NotSupported/i.test(identity)) + return { reason: 'unsupported', retryable: false }; + if (/WrongState|InvalidState/i.test(identity)) return { reason: 'wrong-state', retryable: true }; + if (/InProgress|Busy/i.test(identity)) return { reason: 'busy', retryable: true }; + if (/NotFound|UnknownObject|UnknownModem/i.test(identity)) { + return { reason: 'not-found', retryable: false }; + } + if (error instanceof TransportError && /timed out/i.test(error.message)) { + return { reason: 'timed-out', retryable: true }; + } + return { reason: 'failed', retryable: false }; +} diff --git a/control/src/providers/modem-manager/forbidden-subprocess.test.ts b/control/src/providers/modem-manager/forbidden-subprocess.test.ts new file mode 100644 index 0000000..6a2c371 --- /dev/null +++ b/control/src/providers/modem-manager/forbidden-subprocess.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, test } from 'bun:test'; +import { readdirSync, readFileSync, statSync } from 'node:fs'; +import { join } from 'node:path'; + +const FORBIDDEN = + /(?:Bun\.spawn|child_process|execFile|spawn)\s*\([^\n]*(?:mmcli|qmicli|mbimcli)|(?:mmcli|qmicli|mbimcli)["'`]/i; + +function productionSources(dir: string): string[] { + const paths: string[] = []; + for (const entry of readdirSync(dir)) { + const path = join(dir, entry); + if (statSync(path).isDirectory()) { + paths.push(...productionSources(path)); + } else if (path.endsWith('.ts') && !path.endsWith('.test.ts')) { + paths.push(path); + } + } + return paths; +} + +describe('ModemManagerProvider subprocess fence', () => { + test('the detector is non-vacuous for every forbidden diagnostic binary', () => { + for (const binary of ['mmcli', 'qmicli', 'mbimcli']) { + expect(FORBIDDEN.test(`Bun.spawn(["${binary}", "--help"])`)).toBe(true); + } + }); + + test('no provider production source shells out to a modem diagnostic CLI', () => { + const files = productionSources(import.meta.dir); + expect(files.length).toBeGreaterThan(0); + const violations = files.filter((path) => FORBIDDEN.test(readFileSync(path, 'utf8'))); + expect(violations).toEqual([]); + }); +}); diff --git a/control/src/providers/modem-manager/generic-operations.ts b/control/src/providers/modem-manager/generic-operations.ts new file mode 100644 index 0000000..f2691ab --- /dev/null +++ b/control/src/providers/modem-manager/generic-operations.ts @@ -0,0 +1,266 @@ +import type { MmDbusBackend } from '../../backend'; +import type { BandName } from '../../band'; +import { + classifyOperationCompletion, + type DesiredRadio, + defineOperationDescriptor, + type OperationDescriptor, + type OperationResult, + runtimePath, +} from '../../domain'; +import type { ModemBands } from '../../ports'; +import { + bandWriteReadbackMatches, + buildBandWriteDescriptor, + buildModeWriteDescriptor, + encodeModeSelection, + type ModeSelection, + matchAdvertisedCombination, + type RadioModeTruth, + sameSelection, + selectionOf, +} from '../../radio'; +import type { ProviderExecutionContext } from '../contracts'; +import { mapModemManagerError } from './errors'; +import type { + ContextReadOperation, + ContextWriteOperation, + ModemManagerProviderOperations, + ModemManagerProviderSnapshot, + ModemManagerRadioState, + ModemManagerSignalState, + ModemManagerSimState, + ModemManagerSnapshotResult, +} from './types'; + +const PROVIDER_ID = 'modemmanager'; +const PROFILE = 'generic-mm'; + +export interface GenericOperationsDeps { + readonly backend: MmDbusBackend; + readSnapshot(context: ProviderExecutionContext): Promise; +} + +function readDescriptor(id: string): OperationDescriptor { + return defineOperationDescriptor({ + id, + support: { read: { supported: true }, write: { supported: false, reason: 'read-only' } }, + authority: 'provider', + provider: PROVIDER_ID, + constraints: { kind: 'unconstrained' }, + livePreconditions: ['modem-present', 'runtime-interface-present'], + availability: { state: 'available' }, + mutationImpact: 'read', + retryClass: 'idempotent-read', + readback: { required: false }, + rollback: { required: false }, + journal: { required: false }, + admission: { required: false }, + evidence: { profiles: [PROFILE], firmware: [] }, + confidence: 'high', + }); +} + +function writeDescriptor(id: string): OperationDescriptor { + return defineOperationDescriptor({ + id, + support: { read: { supported: true }, write: { supported: true } }, + authority: 'provider', + provider: PROVIDER_ID, + constraints: { kind: 'unconstrained' }, + livePreconditions: ['modem-present', 'runtime-interface-present'], + availability: { state: 'available' }, + mutationImpact: 'disruptive', + retryClass: 'never', + readback: { required: false }, + rollback: { required: false }, + journal: { required: true, reason: 'disruptive-radio-write' }, + admission: { required: true, reason: 'provider-mutation' }, + evidence: { profiles: [PROFILE], firmware: [] }, + confidence: 'high', + }); +} + +function applied(context: ProviderExecutionContext, value: O): OperationResult { + return classifyOperationCompletion({ + operation: 'read', + completionGeneration: context.generation, + currentGeneration: context.generation, + completion: { status: 'applied', value }, + }); +} + +function refused(context: ProviderExecutionContext, reason: string): OperationResult { + return classifyOperationCompletion({ + operation: 'read', + completionGeneration: context.generation, + currentGeneration: context.generation, + completion: { status: 'refused', reason }, + }); +} + +function receiptReason(reason: string): string { + const mapped = mapModemManagerError(new Error(reason)); + return mapped.reason === 'failed' ? reason : mapped.reason; +} + +export function createGenericOperations( + deps: GenericOperationsDeps, +): Pick { + const readField = async ( + context: ProviderExecutionContext, + capability: 'modeRead' | 'signalRead' | 'simRead' | 'powerRead', + select: (snapshot: ModemManagerProviderSnapshot) => O, + reason: string, + ): Promise> => { + const snapshot = await deps.readSnapshot(context); + if (!snapshot.ok) return refused(context, snapshot.reason); + return snapshot.capabilities[capability] + ? applied(context, select(snapshot)) + : refused(context, reason); + }; + + const readBands = async ( + context: ProviderExecutionContext, + ): Promise> => { + const snapshot = await deps.readSnapshot(context); + if (!snapshot.ok) return refused(context, snapshot.reason); + if (!snapshot.capabilities.bandRead) return refused(context, 'band-read-unsupported'); + const result = await deps.backend.readBands(runtimePath(snapshot.modemPath)); + return result.ok + ? applied(context, result.bands) + : refused(context, receiptReason(result.reason)); + }; + + const radioDescriptor = writeDescriptor( + 'modemmanager.radio-modes', + ); + const radio: ContextWriteOperation = { + descriptor: radioDescriptor, + describe: () => Promise.resolve(radioDescriptor), + read: (context) => + readField(context, 'modeRead', (snapshot) => snapshot.radio, 'mode-read-unsupported'), + write: async (context, input) => { + const snapshot = await deps.readSnapshot(context); + if (!snapshot.ok) return refused(context, snapshot.reason); + if (!snapshot.capabilities.modeWrite) return refused(context, 'mode-write-unsupported'); + const receipt = await deps.backend.setRadioModes(runtimePath(snapshot.modemPath), input); + if (receipt.status !== 'applied') return refused(context, receiptReason(receipt.reason)); + return readField(context, 'modeRead', (next) => next.radio, 'mode-read-unsupported'); + }, + }; + + const readModeTruth = async ( + context: ProviderExecutionContext, + ): Promise> => + readField(context, 'modeRead', (snapshot) => snapshot.radio.truth, 'mode-read-unsupported'); + + const modes: ContextWriteOperation = { + descriptor: buildModeWriteDescriptor({ + provider: PROVIDER_ID, + profile: PROFILE, + truth: { + current: { state: 'not-reported' }, + supported: { combinations: [], undecodable: [] }, + }, + writeSupported: true, + }), + describe: async (context) => { + const snapshot = await deps.readSnapshot(context); + return buildModeWriteDescriptor({ + provider: PROVIDER_ID, + profile: PROFILE, + truth: snapshot.ok + ? snapshot.radio.truth + : { + current: { state: 'not-reported' }, + supported: { combinations: [], undecodable: [] }, + }, + writeSupported: snapshot.ok && snapshot.capabilities.modeWrite, + }); + }, + read: readModeTruth, + write: async (context, selection) => { + const snapshot = await deps.readSnapshot(context); + if (!snapshot.ok) return refused(context, snapshot.reason); + if (!snapshot.capabilities.modeWrite) return refused(context, 'mode-write-unsupported'); + // A selection the modem never advertised is refused outright — never rounded to + // the nearest advertised one. Substituting is how "prefer 4G" on a marginal cell + // silently becomes 5G-first, which `five-g-preference.ts` refuses for the same reason. + if (matchAdvertisedCombination(snapshot.radio.truth.supported, selection) === undefined) { + return refused(context, 'mode-combination-not-advertised'); + } + const encoded = encodeModeSelection(selection); + if (!encoded.ok) return refused(context, 'mode-name-unknown'); + const receipt = await deps.backend.setModeCombination( + runtimePath(snapshot.modemPath), + encoded.allowedMask, + encoded.preferredMask, + ); + if (receipt.status !== 'applied') return refused(context, receiptReason(receipt.reason)); + const readback = await readModeTruth(context); + if (readback.status !== 'applied') return readback; + // An accepted-but-ignored `SetCurrentModes` is indistinguishable from success at + // the call site, so the descriptor requires a readback and this enforces it. + return readback.value.current.state === 'reported' && + sameSelection(selectionOf(readback.value.current.combination), selection) + ? readback + : refused(context, 'mode-write-readback-mismatch'); + }, + }; + + const bandDescriptorFor = (snapshot: ModemManagerSnapshotResult) => + buildBandWriteDescriptor({ + provider: PROVIDER_ID, + profile: PROFILE, + certification: snapshot.ok + ? snapshot.bandCertification + : { + required: true, + satisfied: false, + reason: 'band-certification-required', + offerable: [], + }, + readSupported: snapshot.ok && snapshot.capabilities.bandRead, + }); + + const bands: ContextWriteOperation = { + descriptor: bandDescriptorFor({ ok: false, reason: 'not-found' }), + describe: async (context) => bandDescriptorFor(await deps.readSnapshot(context)), + read: readBands, + write: async (context, input) => { + const snapshot = await deps.readSnapshot(context); + if (!snapshot.ok) return refused(context, snapshot.reason); + // The certification gate, second of two. `bandWrite` is already false without a + // catalog entry, and the descriptor already reads `refused` — but a band lock can + // take a working uplink off the air, so the call path refuses independently + // rather than trusting a consumer to have read the descriptor. + if (!snapshot.bandCertification.satisfied || !snapshot.capabilities.bandWrite) + return refused(context, 'band-write-certification-required'); + const receipt = await deps.backend.setCurrentBands(runtimePath(snapshot.modemPath), input); + if (receipt.status !== 'applied') return refused(context, receiptReason(receipt.reason)); + const readback = await readBands(context); + if (readback.status !== 'applied') return readback; + return bandWriteReadbackMatches(input, readback.value) + ? readback + : refused(context, 'band-write-readback-mismatch'); + }, + }; + + const signal: ContextReadOperation = { + descriptor: readDescriptor('modemmanager.signal'), + read: (context) => + readField(context, 'signalRead', (snapshot) => snapshot.signal, 'signal-read-unsupported'), + }; + const sim: ContextReadOperation = { + descriptor: readDescriptor('modemmanager.sim'), + read: (context) => + readField(context, 'simRead', (snapshot) => snapshot.sim, 'sim-read-unsupported'), + }; + const power: ContextReadOperation = { + descriptor: readDescriptor('modemmanager.power'), + read: (context) => + readField(context, 'powerRead', (snapshot) => snapshot.power, 'power-read-unsupported'), + }; + return { radio, modes, bands, signal, sim, power }; +} diff --git a/control/src/providers/modem-manager/index.ts b/control/src/providers/modem-manager/index.ts new file mode 100644 index 0000000..194f407 --- /dev/null +++ b/control/src/providers/modem-manager/index.ts @@ -0,0 +1,3 @@ +export * from './errors'; +export * from './provider'; +export * from './types'; diff --git a/control/src/providers/modem-manager/modem-manager-provider.integration.test.ts b/control/src/providers/modem-manager/modem-manager-provider.integration.test.ts new file mode 100644 index 0000000..d55de97 --- /dev/null +++ b/control/src/providers/modem-manager/modem-manager-provider.integration.test.ts @@ -0,0 +1,270 @@ +import { afterEach, describe, expect, test } from 'bun:test'; +import { FakeModemManager, type ModemSpec, modemPath } from '../../../test-support/fake-mm'; +import { + hasSessionBus, + sessionBusAddress, + warnSkippedWithoutBus, +} from '../../../test-support/session-bus'; +import { deviceGeneration, physicalModemId, runtimePath } from '../../domain'; +import { REDACTED, redact } from '../../redact'; +import { createDbusTransport, type DbusTransport } from '../../transport'; +import { createProviderMatcher } from '../matcher'; +import { createProviderRegistry } from '../registry'; +import { createModemManagerProvider, type ModemManagerProvider } from './provider'; + +warnSkippedWithoutBus('typed ModemManager provider'); + +const UNKNOWN_MODEM: ModemSpec = { + index: 73, + manufacturer: 'Future Radios Ltd', + model: 'Uncatalogued-X1', + revision: '99.0-new', + signalQuality: 64, + sims: [ + { + index: 73, + iccid: '8900000000000000073', + imsi: '001010000000073', + active: true, + simType: 1, + }, + ], +}; + +const request = { + physicalModemId: physicalModemId('serial:fake-device-73'), + generation: deviceGeneration(1), + transport: 'modemmanager' as const, + passiveFacts: [], + composition: 'future-unlisted-modem', +}; + +async function waitFor(predicate: () => boolean, timeoutMs = 2_000): Promise { + const deadline = Date.now() + timeoutMs; + while (!predicate()) { + if (Date.now() >= deadline) throw new Error('waitFor timed out'); + await Bun.sleep(5); + } +} + +describe.skipIf(!hasSessionBus())('ModemManagerProvider on the private session bus', () => { + let fake: FakeModemManager; + let transport: DbusTransport; + let provider: ModemManagerProvider; + + afterEach(async () => { + await provider.stop(); + await transport.disconnect(); + await fake.stop(); + }); + + test('an unknown modem receives generic mode, signal, SIM, and power controls from runtime facts', async () => { + const busAddress = sessionBusAddress(); + fake = await FakeModemManager.start({ busAddress, modems: [UNKNOWN_MODEM] }); + transport = createDbusTransport({ busAddress }); + provider = createModemManagerProvider({ transport }); + + const registry = createProviderRegistry(); + registry.register(provider.definition); + const match = await createProviderMatcher(registry).match(request); + expect(match).toMatchObject({ + status: 'selected', + provider: 'modemmanager', + profile: 'generic-mm', + }); + + const snapshot = await provider.readSnapshot({ ...request, profile: 'generic-mm' }); + expect(snapshot.ok).toBe(true); + if (!snapshot.ok) return; + expect(snapshot.modemPath).toBe(modemPath(73)); + expect(snapshot.capabilities).toMatchObject({ + modeRead: true, + signalRead: true, + simRead: true, + powerRead: true, + }); + expect(snapshot.radio.current.allowed).toBe(7); + expect(snapshot.signal.quality).toBe(64); + expect(snapshot.sim).toMatchObject({ present: true, slotCount: 1 }); + expect(snapshot.power).toBe('on'); + expect(snapshot.observation.value?.hardware.label).toMatchObject({ + state: 'known', + value: 'Uncatalogued-X1', + }); + }); + + test('ObjectManager signals drive provider lifecycle events without touching a bearer', async () => { + const busAddress = sessionBusAddress(); + fake = await FakeModemManager.start({ busAddress, modems: [UNKNOWN_MODEM] }); + transport = createDbusTransport({ busAddress }); + provider = createModemManagerProvider({ transport }); + const events: string[][] = []; + provider.observe((list) => { + events.push(list.rows.map((row) => String(row.identity.runtimePath))); + }); + await provider.start(); + + fake.addModem({ + index: 74, + sims: [{ index: 74, iccid: '8900000000000000074', imsi: '001010000000074' }], + }); + await waitFor(() => events.some((paths) => paths.includes(modemPath(74)))); + + expect(events.some((paths) => paths.includes(modemPath(74)))).toBe(true); + expect(fake.callLog.some((entry) => /Connect|CreateBearer|Disconnect/.test(entry))).toBe(false); + }); + + test('runtime absence refuses a generic read instead of consulting a model catalog', async () => { + const busAddress = sessionBusAddress(); + fake = await FakeModemManager.start({ + busAddress, + modems: [{ ...UNKNOWN_MODEM, hasSignal: false }], + }); + transport = createDbusTransport({ busAddress }); + provider = createModemManagerProvider({ transport }); + + const controls = provider.definition.operations('generic-mm'); + const signal = await controls.signal.read({ ...request, profile: 'generic-mm' }); + expect(signal.status).toBe('applied'); + if (signal.status === 'applied') { + expect(signal.value.extendedAvailable).toBe(false); + expect(signal.value.quality).toBe(64); + } + }); + + test('band reads remain generic while uncertified band writes are refused before dispatch', async () => { + const busAddress = sessionBusAddress(); + fake = await FakeModemManager.start({ + busAddress, + modems: [{ ...UNKNOWN_MODEM, supportedBands: [33, 378], currentBands: [256] }], + }); + transport = createDbusTransport({ busAddress }); + provider = createModemManagerProvider({ transport }); + const controls = provider.definition.operations('generic-mm'); + const context = { ...request, profile: 'generic-mm' }; + + expect(await controls.bands.read(context)).toMatchObject({ + status: 'applied', + value: { supported: ['eutran-3', 'ngran-78'], current: ['any'] }, + }); + expect(await controls.bands.write(context, ['eutran-3'])).toMatchObject({ + status: 'refused', + reason: 'band-write-certification-required', + }); + expect(fake.callLog.some((entry) => entry.includes('SetCurrentBands'))).toBe(false); + expect(controls.fccCoverage('2c7c', '0801')).toBe('present'); + }); + + test('the provider returns the existing location, SMS, and USSD adapters for the resolved modem', async () => { + const busAddress = sessionBusAddress(); + fake = await FakeModemManager.start({ busAddress, modems: [UNKNOWN_MODEM] }); + transport = createDbusTransport({ busAddress }); + provider = createModemManagerProvider({ transport }); + const controls = provider.definition.operations('generic-mm'); + const context = { ...request, profile: 'generic-mm' }; + + expect((await controls.location.status(context)).ok).toBe(false); + expect((await controls.sms(context)).ok).toBe(false); + expect(controls.ussd.snapshot(runtimePath(modemPath(73))).state).toBe('idle'); + }); + + test('GPS capability, enable, bounded no-fix, disable, and signal-location privacy use the existing location module', async () => { + let now = 1_000; + const busAddress = sessionBusAddress(); + fake = await FakeModemManager.start({ + busAddress, + modems: [ + { + ...UNKNOWN_MODEM, + location: { capabilities: 7, enabled: 1, fix: [] }, + messaging: true, + ussd: { state: 1, initiateReply: 'private balance' }, + }, + ], + }); + transport = createDbusTransport({ busAddress }); + provider = createModemManagerProvider({ transport, now: () => now }); + const controls = provider.definition.operations('generic-mm'); + const context = { ...request, profile: 'generic-mm' }; + + const status = await controls.location.status(context); + expect(status.ok && status.status.gnssCapable).toBe(true); + expect((await controls.location.enable(context, ['gps-raw'])).outcome).toBe('applied'); + const enableCalls = fake.locationSetupCalls; + expect(enableCalls[enableCalls.length - 1]).toMatchObject({ + signalLocation: false, + sources: 3, + }); + expect((await controls.location.readFix(context)).outcome).toBe('no-fix'); + expect(controls.location.state(context).kind).toBe('acquiring'); + + now += 120_001; + expect(controls.location.tick(context)).toMatchObject({ + kind: 'no-fix', + reason: 'acquire-timeout', + }); + expect((await controls.location.disable(context)).outcome).toBe('applied'); + expect(controls.location.state(context).kind).toBe('off'); + const disableCalls = fake.locationSetupCalls; + expect(disableCalls[disableCalls.length - 1]).toMatchObject({ + signalLocation: false, + sources: 1, + }); + + const sms = await controls.sms(context); + expect(sms.ok && (await sms.port.list())).toEqual({ ok: true, messages: [] }); + expect((await controls.initiateUssd(context, '*123#')).ussdReply).toBe('private balance'); + }); + + test('a live GPS fix expires in memory and is redacted by the existing privacy class', async () => { + let now = 10_000; + const rawFix = [ + [ + 2, + [ + 'a{sv}', + [ + ['latitude', ['d', 4.60971]], + ['longitude', ['d', -74.08175]], + ['altitude', ['d', 2640]], + ], + ], + ], + ]; + const busAddress = sessionBusAddress(); + fake = await FakeModemManager.start({ + busAddress, + modems: [{ ...UNKNOWN_MODEM, location: { capabilities: 6, enabled: 2, fix: rawFix } }], + }); + transport = createDbusTransport({ busAddress }); + provider = createModemManagerProvider({ transport, now: () => now }); + const controls = provider.definition.operations('generic-mm'); + const context = { ...request, profile: 'generic-mm' }; + + const read = await controls.location.readFix(context); + expect(read.outcome).toBe('fix'); + if (read.outcome !== 'fix') return; + expect(redact({ coordinates: read.fix })).toEqual({ coordinates: REDACTED }); + expect(controls.location.state(context).kind).toBe('fix'); + now += 30_001; + expect(controls.location.tick(context)).toMatchObject({ + kind: 'no-fix', + reason: 'fix-expired', + }); + }); + + test('a typed Core.Unauthorized fake-MM error maps to the exact domain refusal', async () => { + const busAddress = sessionBusAddress(); + fake = await FakeModemManager.start({ busAddress, modems: [UNKNOWN_MODEM] }); + transport = createDbusTransport({ busAddress }); + provider = createModemManagerProvider({ transport }); + fake.failNext('SetCurrentModes', 'org.freedesktop.ModemManager1.Error.Core.Unauthorized'); + const controls = provider.definition.operations('generic-mm'); + + const result = await controls.radio.write( + { ...request, profile: 'generic-mm' }, + { preferenceOrdered: ['lte'] }, + ); + expect(result).toMatchObject({ status: 'refused', reason: 'unauthorized' }); + }); +}); diff --git a/control/src/providers/modem-manager/module-operations.ts b/control/src/providers/modem-manager/module-operations.ts new file mode 100644 index 0000000..1eefe62 --- /dev/null +++ b/control/src/providers/modem-manager/module-operations.ts @@ -0,0 +1,154 @@ +import type { MmLocation } from '../../backend'; +import { epochMillis, runtimePath } from '../../domain'; +import { resolveFccUnlockCoverage } from '../../fcc'; +import { advanceGnssFixState, GNSS_OFF, type GnssFixState } from '../../location'; +import { createDbusSmsPort } from '../../sms'; +import type { DbusTransport } from '../../transport'; +import type { MmUssd } from '../../ussd'; +import type { ProviderExecutionContext } from '../contracts'; +import type { + ModemManagerProviderOperations, + ModemManagerSnapshotResult, + SmsPortResult, +} from './types'; + +export interface ModuleOperationsOptions { + readonly transport: DbusTransport; + readonly destination: string; + readonly location: MmLocation; + readonly ussd: MmUssd; + readonly now: () => number; + readSnapshot(context: ProviderExecutionContext): Promise; +} + +type ModuleOperationSurface = Pick< + ModemManagerProviderOperations, + 'location' | 'sms' | 'ussd' | 'initiateUssd' | 'respondUssd' | 'cancelUssd' | 'fccCoverage' +>; + +export class ModemManagerModuleOperations { + readonly operations: ModuleOperationSurface; + readonly #options: ModuleOperationsOptions; + readonly #smsPorts = new Map>(); + readonly #locationStates = new Map(); + + constructor(options: ModuleOperationsOptions) { + this.#options = options; + this.operations = { + location: { + status: async (context) => { + const path = await this.#modemPath(context); + return path === undefined + ? { ok: false, reason: 'modem-not-found' } + : this.#options.location.getLocationStatus(runtimePath(path)); + }, + enable: async (context, sources) => { + const path = await this.#modemPath(context); + if (path === undefined) return this.#unsupportedToggle(); + const result = await this.#options.location.enableGnss(runtimePath(path), sources); + if (result.outcome === 'applied') { + this.#setLocationState(context, { + kind: 'gnss-enabled', + at: epochMillis(this.#options.now()), + }); + } + return result; + }, + disable: async (context) => { + const path = await this.#modemPath(context); + if (path === undefined) return this.#unsupportedToggle(); + const result = await this.#options.location.disableGnss(runtimePath(path)); + if (result.outcome === 'applied') + this.#setLocationState(context, { kind: 'gnss-disabled' }); + return result; + }, + readFix: async (context) => { + const path = await this.#modemPath(context); + if (path === undefined) + return { outcome: 'unsupported' as const, reason: 'modem-not-found' }; + const read = await this.#options.location.readFix(runtimePath(path)); + this.#setLocationState(context, { + kind: 'read', + at: epochMillis(this.#options.now()), + read, + }); + return read; + }, + state: (context) => this.#locationStates.get(String(context.physicalModemId)) ?? GNSS_OFF, + tick: (context) => + this.#setLocationState(context, { + kind: 'tick', + at: epochMillis(this.#options.now()), + }), + }, + sms: (context) => this.#sms(context), + ussd: options.ussd, + initiateUssd: (context, ussdCommand) => + this.#ussd(context, (path) => options.ussd.initiate(runtimePath(path), ussdCommand)), + respondUssd: (context, ussdResponse) => + this.#ussd(context, (path) => options.ussd.respond(runtimePath(path), ussdResponse)), + cancelUssd: (context) => + this.#ussd(context, (path) => options.ussd.cancel(runtimePath(path))), + fccCoverage: resolveFccUnlockCoverage, + }; + } + + async stop(): Promise { + for (const port of this.#smsPorts.values()) await port.stop(); + this.#smsPorts.clear(); + this.#options.ussd.stop(); + } + + async #sms(context: ProviderExecutionContext): Promise { + const snapshot = await this.#options.readSnapshot(context); + if (!snapshot.ok) return { ok: false, reason: snapshot.reason }; + if (!snapshot.capabilities.sms) return { ok: false, reason: 'unsupported' }; + let port = this.#smsPorts.get(snapshot.modemPath); + if (port === undefined) { + port = createDbusSmsPort({ + transport: this.#options.transport, + modemPath: snapshot.modemPath, + destination: this.#options.destination, + }); + this.#smsPorts.set(snapshot.modemPath, port); + } + return { ok: true, port }; + } + + async #ussd( + context: ProviderExecutionContext, + run: (path: string) => ReturnType, + ) { + const path = await this.#modemPath(context); + return path === undefined + ? { + ok: false as const, + snapshot: this.#options.ussd.snapshot(runtimePath('/')), + refusal: 'unsupported' as const, + } + : run(path); + } + + async #modemPath(context: ProviderExecutionContext): Promise { + const snapshot = await this.#options.readSnapshot(context); + return snapshot.ok ? snapshot.modemPath : undefined; + } + + #setLocationState( + context: ProviderExecutionContext, + event: Parameters[1], + ): GnssFixState { + const key = String(context.physicalModemId); + const next = advanceGnssFixState(this.#locationStates.get(key) ?? GNSS_OFF, event); + this.#locationStates.set(key, next); + return next; + } + + #unsupportedToggle() { + return { + outcome: 'unsupported' as const, + reason: 'modem-not-found', + enabledSources: new Set(), + }; + } +} diff --git a/control/src/providers/modem-manager/provider.ts b/control/src/providers/modem-manager/provider.ts new file mode 100644 index 0000000..e8f998a --- /dev/null +++ b/control/src/providers/modem-manager/provider.ts @@ -0,0 +1,232 @@ +import { + createMmDbusBackend, + fetchManagedObjects, + MM_BUS_NAME, + type MmDbusBackend, + MmLocation, + ModemActor, + type QuiesceHook, +} from '../../backend'; +import { + BAND_CERTIFICATION_CATALOG, + type BandCertificationCatalog, + type BandCertificationEntry, + type BandSku, + findBandCertification, +} from '../../band'; +import { + epochMillis, + type ObservationEnvelope, + sourceEpoch, + stableKeyFromPhysicalModemId, +} from '../../domain'; +import { type NormalizedModemObservation, unavailableObservation } from '../../observations'; +import type { ObservationListener } from '../../ports'; +import type { DbusTransport } from '../../transport'; +import { MmUssd } from '../../ussd'; +import type { + ProviderDefinition, + ProviderExecutionContext, + ProviderMatchRequest, +} from '../contracts'; +import { mapModemManagerError } from './errors'; +import { createGenericOperations } from './generic-operations'; +import { ModemManagerModuleOperations } from './module-operations'; +import { buildModemManagerSnapshot } from './snapshot'; +import type { + ModemManagerProviderLifecycle, + ModemManagerProviderOperations, + ModemManagerSnapshotResult, +} from './types'; + +const PROFILE = 'generic-mm'; + +export interface ModemManagerProviderOptions { + readonly transport: DbusTransport; + readonly destination?: string; + readonly quiesce?: QuiesceHook; + readonly now?: () => number; + /** + * The band-lock certification catalog. Defaults to the one shipped in this package, + * which is EMPTY — so a band write is refused on every device until a human-reviewed + * commit adds an entry carrying its bench transcript. + */ + readonly bandCertificationCatalog?: BandCertificationCatalog; + /** + * The device's SKU, if the embedding process can resolve one. + * + * ModemManager's `Modem` interface carries `Model` and `Revision` but NO USB + * `vid:pid`, and `BandSku` is keyed on all three — so this package structurally + * cannot build one from the bus alone, and a partial match would certify a family + * no reviewer looked at. With no resolver injected there is no SKU, hence no entry, + * hence no band write: fail-closed, which is this module's whole stance. + */ + readonly bandSku?: (context: ProviderExecutionContext) => BandSku | undefined; +} + +export class ModemManagerProvider implements ModemManagerProviderLifecycle { + readonly definition: ProviderDefinition< + NormalizedModemObservation, + ModemManagerProviderOperations + >; + readonly #transport: DbusTransport; + readonly #destination: string; + readonly #now: () => number; + readonly #backend: MmDbusBackend; + readonly #bandCatalog: BandCertificationCatalog; + readonly #bandSku: (context: ProviderExecutionContext) => BandSku | undefined; + readonly #stableKeyByPath = new Map(); + readonly #moduleOperations: ModemManagerModuleOperations; + #startPromise: ReturnType | undefined; + #epoch = 1; + + constructor(options: ModemManagerProviderOptions) { + this.#transport = options.transport; + this.#destination = options.destination ?? MM_BUS_NAME; + this.#now = options.now ?? Date.now; + this.#bandCatalog = options.bandCertificationCatalog ?? BAND_CERTIFICATION_CATALOG; + this.#bandSku = options.bandSku ?? (() => undefined); + const actor = new ModemActor(options.quiesce); + const resolveStableKey = (modem: string): string => this.#stableKeyByPath.get(modem) ?? modem; + this.#backend = createMmDbusBackend({ + transport: this.#transport, + destination: this.#destination, + actor, + now: this.#now, + }); + const location = new MmLocation({ + transport: this.#transport, + actor, + destination: this.#destination, + resolveStableKey, + now: this.#now, + }); + const ussd = new MmUssd({ + transport: this.#transport, + actor, + destination: this.#destination, + resolveStableKey, + }); + this.#moduleOperations = new ModemManagerModuleOperations({ + transport: this.#transport, + destination: this.#destination, + location, + ussd, + now: this.#now, + readSnapshot: (context) => this.readSnapshot(context), + }); + const operations: ModemManagerProviderOperations = { + access: 'read-write', + ...createGenericOperations({ + backend: this.#backend, + readSnapshot: (context) => this.readSnapshot(context), + }), + ...this.#moduleOperations.operations, + }; + this.definition = { + id: 'modemmanager', + profileVersion: '1', + eligibleTransports: ['modemmanager'], + passiveMatchers: [], + unauthenticatedProbes: [ + { id: 'object-manager-modem', run: (request) => this.#probe(request) }, + ], + capabilityReaders: [ + { id: 'runtime-modem-interface', read: (context) => this.#capability(context) }, + ], + observe: (context) => this.#observeNormalized(context), + operations: () => operations, + contractFixtures: [], + }; + } + + start(): ReturnType { + this.#startPromise ??= this.#backend.start(); + return this.#startPromise; + } + + observe(listener: ObservationListener): () => void { + return this.#backend.observe(listener); + } + + async stop(): Promise { + await this.#moduleOperations.stop(); + await this.#backend.stop(); + } + + async readSnapshot(context: ProviderExecutionContext): Promise { + try { + await this.start(); + const tree = await fetchManagedObjects(this.#transport, this.#destination); + const snapshot = buildModemManagerSnapshot( + tree, + context, + this.#now, + this.#epoch, + this.#bandCertification(context), + ); + if (snapshot === undefined) return { ok: false, reason: 'not-found' }; + this.#stableKeyByPath.set(snapshot.modemPath, `modem:${context.physicalModemId}`); + return { ok: true, ...snapshot }; + } catch (error) { + return { ok: false, reason: mapModemManagerError(error).reason }; + } + } + + #bandCertification(context: ProviderExecutionContext): BandCertificationEntry | undefined { + const sku = this.#bandSku(context); + return sku === undefined ? undefined : findBandCertification(this.#bandCatalog, sku); + } + + async #probe(request: ProviderMatchRequest) { + const snapshot = await this.readSnapshot({ ...request, profile: PROFILE }); + return { + signal: snapshot.ok + ? ('match' as const) + : snapshot.reason === 'not-found' + ? ('mismatch' as const) + : ('unknown' as const), + strength: 'strong' as const, + profiles: [PROFILE], + detail: snapshot.ok ? 'runtime-modem-interface-present' : snapshot.reason, + }; + } + + async #capability(context: ProviderExecutionContext) { + const snapshot = await this.readSnapshot(context); + return { + signal: snapshot.ok + ? ('match' as const) + : snapshot.reason === 'not-found' + ? ('mismatch' as const) + : ('unknown' as const), + strength: 'strong' as const, + detail: snapshot.ok ? 'generic-controls-derived-from-runtime-properties' : snapshot.reason, + }; + } + + async #observeNormalized( + context: ProviderExecutionContext, + ): Promise[]> { + const snapshot = await this.readSnapshot(context); + if (snapshot.ok) return [snapshot.observation]; + return [ + unavailableObservation( + 'modemmanager', + { + stableKey: stableKeyFromPhysicalModemId(context.physicalModemId), + generation: context.generation, + sourceEpoch: sourceEpoch(this.#epoch), + observedAt: epochMillis(this.#now()), + }, + snapshot.reason === 'not-found' ? 'device-absent' : 'provider-unavailable', + ), + ]; + } +} + +export function createModemManagerProvider( + options: ModemManagerProviderOptions, +): ModemManagerProvider { + return new ModemManagerProvider(options); +} diff --git a/control/src/providers/modem-manager/snapshot.ts b/control/src/providers/modem-manager/snapshot.ts new file mode 100644 index 0000000..f421eb0 --- /dev/null +++ b/control/src/providers/modem-manager/snapshot.ts @@ -0,0 +1,255 @@ +import { + MODEM_IFACE, + MODEM_LOCATION_IFACE, + MODEM3GPP_IFACE, + MODEM3GPP_USSD_IFACE, + SIM_IFACE, +} from '../../backend/constants'; +import { + type DecodedManagedObjects, + type DecodedProps, + findInterface, + pathsWithInterface, + propValue, +} from '../../backend/managed-objects'; +import { type BandCertificationEntry, decodeBandList } from '../../band'; +import { MESSAGING_IFACE } from '../../capability'; +import type { RadioPower } from '../../domain'; +import { + decodeStateFailedReason, + epochMillis, + sourceEpoch, + stableKeyFromPhysicalModemId, +} from '../../domain'; +import { readSimPresence } from '../../hardware/router-parsers'; +import type { NormalizationContext, RawFieldRecord, RawFieldValue } from '../../observations'; +import { normalizeModemManagerObservation } from '../../observations'; +import { describeBandWriteCertification, readRadioModeTruth } from '../../radio'; +import type { DbusValue } from '../../transport'; +import type { ProviderExecutionContext } from '../contracts'; +import type { + ModemManagerCapabilities, + ModemManagerProviderSnapshot, + ModemManagerRadioState, + ModemManagerSignalState, + ModemManagerSimState, +} from './types'; + +const SIGNAL_IFACE = `${MODEM_IFACE}.Signal`; + +function rawValue(value: DbusValue): RawFieldValue | undefined { + if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') + return value; + if (Array.isArray(value)) { + const result: RawFieldValue[] = []; + for (const item of value) { + const decoded = rawValue(item); + if (decoded !== undefined) result.push(decoded); + } + return result; + } + return undefined; +} + +function propsRecord(props: DecodedProps | undefined): RawFieldRecord | undefined { + if (props === undefined) return undefined; + const record: Record = {}; + for (const [name, wrapped] of props) { + const decoded = rawValue(wrapped.value); + if (decoded !== undefined) record[name] = decoded; + } + return record; +} + +function tuple(value: DbusValue | undefined): readonly [number, number] { + if (!Array.isArray(value)) return [0, 0]; + const allowed = value[0]; + const preferred = value[1]; + return [typeof allowed === 'number' ? allowed : 0, typeof preferred === 'number' ? preferred : 0]; +} + +function modePairs(value: DbusValue | undefined): ModemManagerRadioState['supported'] { + if (!Array.isArray(value)) return []; + const result: { allowed: number; preferred: number }[] = []; + for (const candidate of value) { + const [allowed, preferred] = tuple(candidate); + if (allowed > 0) result.push({ allowed, preferred }); + } + return result; +} + +/** + * A `DbusValue` as the retention layer keeps it. + * + * `rawValue` already produces exactly this for a struct (an array of its members), so + * `SupportedModes` and `CurrentModes` reach the verbatim decoder in the same shape the + * diagnostics block retains — one representation, so the two cannot disagree. + */ +function structValue(value: DbusValue | undefined): unknown { + return value === undefined ? undefined : rawValue(value); +} + +function signalState( + modem: DecodedProps, + signal: DecodedProps | undefined, +): ModemManagerSignalState { + const quality = tuple(propValue(modem, 'SignalQuality')); + return { + ...(quality[0] >= 0 ? { quality: quality[0] } : {}), + ...(Array.isArray(propValue(modem, 'SignalQuality')) ? { recent: quality[1] !== 0 } : {}), + extendedAvailable: signal !== undefined, + }; +} + +function simState(tree: DecodedManagedObjects, modem: DecodedProps): ModemManagerSimState { + const activePath = propValue(modem, 'Sim'); + const slots = propValue(modem, 'SimSlots'); + const paths = Array.isArray(slots) + ? slots.filter((value): value is string => typeof value === 'string' && value !== '/') + : []; + const active = + typeof activePath === 'string' ? findInterface(tree, activePath, SIM_IFACE) : undefined; + const lockRequired = propValue(modem, 'UnlockRequired'); + const rawFailedReason = propValue(modem, 'StateFailedReason'); + const failedReason = + typeof rawFailedReason === 'number' + ? decodeStateFailedReason(rawFailedReason) + : typeof rawFailedReason === 'string' + ? rawFailedReason + : undefined; + const primarySlot = propValue(modem, 'PrimarySimSlot'); + const simType = propValue(active, 'SimType'); + const esimStatus = propValue(active, 'EsimStatus'); + const reading = readSimPresence({ + ...(typeof activePath === 'string' ? { sim: activePath } : {}), + ...(paths.length > 0 ? { simSlots: paths } : {}), + ...(failedReason === undefined ? {} : { failedReason }), + }); + return { + // POSITIVE evidence only: an exported SIM object path proves a SIM. Its ABSENCE + // proves nothing — `presence` below is the answer to "is there a SIM", and it + // says `unknown` unless the modem stated `sim-missing` itself. + present: typeof activePath === 'string' && activePath !== '/', + presence: reading.presence, + presenceEvidence: reading.evidence, + slotCount: paths.length, + primarySlot: typeof primarySlot === 'number' ? primarySlot : 0, + ...(typeof lockRequired === 'number' ? { lockRequired } : {}), + ...(typeof simType === 'number' ? { simType } : {}), + ...(typeof esimStatus === 'number' ? { esimStatus } : {}), + }; +} + +function radioPower(value: DbusValue | undefined): RadioPower { + switch (value) { + case 1: + return 'off'; + case 2: + return 'low'; + case 3: + return 'on'; + default: + return 'unknown'; + } +} + +function hasProperty(props: DecodedProps, name: string): boolean { + return props.some(([property]) => property === name); +} + +function findModemPath( + tree: DecodedManagedObjects, + context: ProviderExecutionContext, +): string | undefined { + const [kind, ...parts] = String(context.physicalModemId).split(':'); + const expected = parts.join(':'); + for (const path of pathsWithInterface(tree, MODEM_IFACE)) { + const modem = findInterface(tree, path, MODEM_IFACE); + const candidates = + kind === 'serial' + ? [propValue(modem, 'DeviceIdentifier')] + : [propValue(modem, 'Device'), propValue(modem, 'Physdev')]; + if (candidates.some((candidate) => candidate === expected)) return path; + } + return undefined; +} + +export function buildModemManagerSnapshot( + tree: DecodedManagedObjects, + context: ProviderExecutionContext, + now: () => number, + epoch: number, + bandCertificationEntry: BandCertificationEntry | undefined, +): ModemManagerProviderSnapshot | undefined { + const modemPath = findModemPath(tree, context); + if (modemPath === undefined) return undefined; + const modem = findInterface(tree, modemPath, MODEM_IFACE); + if (modem === undefined) return undefined; + const modem3gpp = findInterface(tree, modemPath, MODEM3GPP_IFACE); + const signal = findInterface(tree, modemPath, SIGNAL_IFACE); + const activePath = propValue(modem, 'Sim'); + const sim = + typeof activePath === 'string' ? findInterface(tree, activePath, SIM_IFACE) : undefined; + const interfaces = tree.find(([path]) => path === modemPath)?.[1] ?? []; + const interfaceNames = new Set(interfaces.map(([name]) => name)); + const slots = propValue(modem, 'SimSlots'); + const bandCertification = describeBandWriteCertification({ + entry: bandCertificationEntry, + supported: decodeBandList(propValue(modem, 'SupportedBands')), + }); + const capabilities: ModemManagerCapabilities = { + modeRead: hasProperty(modem, 'CurrentModes'), + modeWrite: hasProperty(modem, 'CurrentModes'), + bandRead: hasProperty(modem, 'SupportedBands'), + bandWrite: + hasProperty(modem, 'SupportedBands') && + bandCertification.satisfied && + bandCertification.offerable.length > 0, + signalRead: hasProperty(modem, 'SignalQuality') || signal !== undefined, + simRead: hasProperty(modem, 'Sim') || hasProperty(modem, 'SimSlots'), + multiSim: Array.isArray(slots) && slots.filter((entry) => entry !== '/').length > 1, + location: interfaceNames.has(MODEM_LOCATION_IFACE), + sms: interfaceNames.has(MESSAGING_IFACE), + ussd: interfaceNames.has(MODEM3GPP_USSD_IFACE), + powerRead: hasProperty(modem, 'PowerState'), + }; + const [allowed, preferred] = tuple(propValue(modem, 'CurrentModes')); + const modeTruth = readRadioModeTruth({ + currentModes: structValue(propValue(modem, 'CurrentModes')), + supportedModes: structValue(propValue(modem, 'SupportedModes')), + }); + const normalizationContext: NormalizationContext = { + stableKey: stableKeyFromPhysicalModemId(context.physicalModemId), + generation: context.generation, + sourceEpoch: sourceEpoch(epoch), + observedAt: epochMillis(now()), + }; + // The struct properties are handed to normalization VERBATIM — `CurrentModes` as its + // `(uu)` pair, `SignalQuality` as its `(ub)` pair. Flattening them here (which this + // did) kept the first member and dropped the preferred mode and the recency flag + // before the diagnostics block ever saw them, which is a drop no downstream layer + // can undo. The normalizer reads either shape. + const modemRaw = propsRecord(modem) ?? {}; + return { + modemPath, + capabilities, + bandCertification, + radio: { + current: { allowed, preferred }, + supported: modePairs(propValue(modem, 'SupportedModes')), + truth: modeTruth, + }, + signal: signalState(modem, signal), + sim: simState(tree, modem), + power: radioPower(propValue(modem, 'PowerState')), + observation: normalizeModemManagerObservation( + { + modem: modemRaw, + ...(modem3gpp === undefined ? {} : { modem3gpp: propsRecord(modem3gpp) ?? {} }), + ...(sim === undefined ? {} : { sim: propsRecord(sim) ?? {} }), + ...(signal === undefined ? {} : { signal: propsRecord(signal) ?? {} }), + }, + normalizationContext, + ), + }; +} diff --git a/control/src/providers/modem-manager/types.ts b/control/src/providers/modem-manager/types.ts new file mode 100644 index 0000000..6c38a53 --- /dev/null +++ b/control/src/providers/modem-manager/types.ts @@ -0,0 +1,154 @@ +import type { + DesiredRadio, + ObservationEnvelope, + OperationDescriptor, + OperationResult, + RadioPower, +} from '../../domain'; +import type { SimPresence, SimPresenceEvidence } from '../../hardware/router-parsers'; +import type { GnssFixState } from '../../location'; +import type { NormalizedModemObservation } from '../../observations'; +import type { + FixRead, + GnssSource, + LocationStatusResult, + LocationToggleResult, + ModemBands, + ObservationList, + ObservationListener, + SmsObservationPort, +} from '../../ports'; +import type { BandWriteCertification, ModeSelection, RadioModeTruth } from '../../radio'; +import type { MmUssd, UssdVerbResult } from '../../ussd'; +import type { ProviderExecutionContext, ProviderOperationsSurface } from '../contracts'; +import type { ModemManagerRefusalReason } from './errors'; + +export type ModemManagerCapabilities = { + readonly modeRead: boolean; + readonly modeWrite: boolean; + readonly bandRead: boolean; + readonly bandWrite: boolean; + readonly signalRead: boolean; + readonly simRead: boolean; + readonly multiSim: boolean; + readonly location: boolean; + readonly sms: boolean; + readonly ussd: boolean; + readonly powerRead: boolean; +}; + +export type ModemManagerRadioState = { + readonly current: { readonly allowed: number; readonly preferred: number }; + readonly supported: readonly { readonly allowed: number; readonly preferred: number }[]; + /** + * The SAME properties decoded without loss — `preferred: 'none'` stays `none`, an + * unfamiliar mode bit stays offerable as `unknown-combination`, and a member that + * is not a `(uu)` lands in `undecodable` rather than vanishing. `supported` above + * is the pre-existing raw-mask view and DROPS a zero-allowed member; this one does + * not, which is the whole point of carrying both. + */ + readonly truth: RadioModeTruth; +}; + +export type ModemManagerSignalState = { + readonly quality?: number; + readonly recent?: boolean; + readonly extendedAvailable: boolean; +}; + +export type ModemManagerSimState = { + /** + * The modem exports an ACTIVE SIM object path. `true` is positive evidence of a + * SIM; `false` is NOT evidence of absence — read `presence` for that. + */ + readonly present: boolean; + /** `absent` only ever comes from `StateFailedReason: sim-missing`. */ + readonly presence: SimPresence; + readonly presenceEvidence: SimPresenceEvidence; + readonly slotCount: number; + readonly primarySlot: number; + readonly lockRequired?: number; + readonly simType?: number; + readonly esimStatus?: number; +}; + +export type ModemManagerProviderSnapshot = { + readonly modemPath: string; + readonly capabilities: ModemManagerCapabilities; + readonly radio: ModemManagerRadioState; + /** The band-lock gate for THIS device, resolved from the shipped certification catalog. */ + readonly bandCertification: BandWriteCertification; + readonly signal: ModemManagerSignalState; + readonly sim: ModemManagerSimState; + readonly power: RadioPower; + readonly observation: ObservationEnvelope; +}; + +export type ModemManagerSnapshotResult = + | ({ readonly ok: true } & ModemManagerProviderSnapshot) + | { readonly ok: false; readonly reason: ModemManagerRefusalReason }; + +export interface ContextReadOperation { + readonly descriptor: OperationDescriptor; + read(context: ProviderExecutionContext): Promise>; +} + +export interface ContextWriteOperation { + /** The shape-only descriptor: what this operation is, before any device answered. */ + readonly descriptor: OperationDescriptor; + /** + * The descriptor as the LIVE device entitles it. + * + * A static descriptor cannot carry a device's own catalog, so it cannot say which + * mode combinations this modem advertises or whether this SKU's band lock is + * certified. Those are exactly the facts a consumer needs before it offers a + * control, so they are read here instead of being inferred from a capability flag. + */ + describe(context: ProviderExecutionContext): Promise>; + read(context: ProviderExecutionContext): Promise>; + write(context: ProviderExecutionContext, input: I): Promise>; +} + +export type SmsPortResult = + | { readonly ok: true; readonly port: SmsObservationPort } + | { readonly ok: false; readonly reason: ModemManagerRefusalReason }; + +export interface ModemManagerProviderOperations extends ProviderOperationsSurface { + readonly radio: ContextWriteOperation; + /** + * The modem's OWN mode vocabulary — an advertised `(allowed, preferred)` pair, + * selected verbatim. Separate from `radio` because `DesiredRadio` is an ordered RAT + * preference and cannot express `preferred: none`, which real hardware advertises. + */ + readonly modes: ContextWriteOperation; + readonly bands: ContextWriteOperation; + readonly signal: ContextReadOperation; + readonly sim: ContextReadOperation; + readonly power: ContextReadOperation; + readonly location: { + status(context: ProviderExecutionContext): Promise; + enable( + context: ProviderExecutionContext, + sources: readonly GnssSource[], + ): Promise; + disable(context: ProviderExecutionContext): Promise; + readFix(context: ProviderExecutionContext): Promise; + state(context: ProviderExecutionContext): GnssFixState; + tick(context: ProviderExecutionContext): GnssFixState; + }; + sms(context: ProviderExecutionContext): Promise; + readonly ussd: MmUssd; + initiateUssd(context: ProviderExecutionContext, ussdCommand: string): Promise; + respondUssd(context: ProviderExecutionContext, ussdResponse: string): Promise; + cancelUssd(context: ProviderExecutionContext): Promise; + readonly fccCoverage: ( + vid: string | undefined, + pid: string | undefined, + ) => 'present' | 'absent' | 'unknown'; +} + +export interface ModemManagerProviderLifecycle { + start(): Promise; + observe(listener: ObservationListener): () => void; + stop(): Promise; +} diff --git a/control/src/providers/network-manager/adapter.ts b/control/src/providers/network-manager/adapter.ts new file mode 100644 index 0000000..4127537 --- /dev/null +++ b/control/src/providers/network-manager/adapter.ts @@ -0,0 +1,455 @@ +// The thin NetworkManager adapter: desired profiles, applied bearers, and the +// interface each one landed on — the saved-vs-applied boundary and nothing else. +// +// It is the ONLY bearer/APN authority surface in the package. `ModemManagerPort` has +// no connect verb by construction (`ports/forbidden-surface.test.ts`), and the +// ModemManager provider has no bearer operation, so nothing else can put a bearer into +// force. Correspondingly, this adapter performs no radio, band, SIM or power operation: +// those belong to the ModemManager provider, and expressing them here would create a +// second writer for a resource the ownership matrix gives to exactly one. +// +// Two rules shape every method below. +// +// 1. OBSERVED STATE NEVER WRITES THE DESIRED SLOT. `observe()` may clear the applied +// slot and always rewrites the observed one; it does not touch `desired` on any +// path. Reality overtaking a write does not un-ask the question the operator asked +// — and if it did, a re-enumeration would silently erase the configuration the +// controller is supposed to restore. +// 2. WRITES GO THROUGH THE PORT'S TYPED OPERATIONS, AND ONLY THOSE. The NM half of the +// port-tagged op set (`ports/ops.ts` `NmOp`) is create / update / activate / +// deactivate. There is deliberately no delete path here: profile removal is not in +// that set, so this adapter cannot express it. + +import type { DeviceGeneration, EpochMillis } from '../../domain'; +import { epochMillis } from '../../domain'; +import type { + AppliedConfiguration, + DesiredProfile, + ModemStateView, + NormalizationContext, + ObservedState, + StateDivergence, +} from '../../observations'; +import { + appliedConfiguration, + describeStateDivergence, + desiredProfile, + freshObservation, + observedState, + unavailableObservation, +} from '../../observations'; +import type { + ConnectionId, + DeviceIfname, + GsmProfile, + NetworkManagerPort, + Receipt, +} from '../../ports'; +import { receipt } from '../../ports'; +import type { + NmAdapterRefusalReason, + NmAppliedLoss, + NmAppliedOutcome, + NmApplyResult, + NmBearerState, + NmConnectionOutcome, + NmDesiredRequest, + NmObservationInput, + NmObservationResult, + NmObservedDevice, + NmSaveResult, +} from './types'; +import { boundBearer, nmBearerStateEquals, unboundBearer } from './types'; + +const SOURCE = 'networkmanager' as const; + +/** The device states in which NM is still settling an activation, not losing it. */ +const TRANSITIONAL_STATES: ReadonlySet = new Set([ + 'prepare', + 'config', + 'need-auth', + 'ip-config', + 'ip-check', + 'secondaries', + 'deactivating', +]); + +interface ConnectionSlots { + desired: DesiredProfile | null; + applied: AppliedConfiguration | null; + observed: ObservedState | null; +} + +export interface NetworkManagerAdapterOptions { + readonly port: NetworkManagerPort; + /** Clock for the desired/applied slots only; observations carry their own. */ + readonly now?: () => number; +} + +export interface NmApplyOptions { + readonly operationId: string; + readonly generation: DeviceGeneration; +} + +export class NetworkManagerAdapter { + readonly #port: NetworkManagerPort; + readonly #now: () => number; + readonly #slots = new Map(); + #observedGeneration: DeviceGeneration | null = null; + + constructor(options: NetworkManagerAdapterOptions) { + this.#port = options.port; + this.#now = options.now ?? Date.now; + } + + /** Every connection this adapter holds a slot for, in insertion order. */ + trackedConnections(): readonly ConnectionId[] { + return [...this.#slots.keys()]; + } + + // ── desired ──────────────────────────────────────────────────────────────── + + /** + * Save an operator's profile and record it as DESIRED. + * + * The desired slot is built from what the caller ASKED for, not from what NM read + * back. That is the whole point of the slot: if NM normalized a field on the way + * in, the next `describeStateDivergence` is supposed to report it, and seeding + * desired from the readback would make that divergence structurally invisible. + */ + async saveDesiredProfile(request: NmDesiredRequest): Promise { + let saved: GsmProfile; + try { + saved = + request.connectionId === undefined + ? await this.#port.createGsmProfile(request.profile) + : await this.#port.updateGsmProfile(request.connectionId, request.profile); + } catch (error: unknown) { + return { + ok: false, + reason: 'write-failed', + receipt: receipt('connection', 'failed', describeError(error)), + }; + } + const id = saved.connectionId; + const desired = desiredProfile( + boundBearer({ + connectionId: id, + deviceIfname: request.deviceIfname, + apn: request.profile.apn, + autoConfig: request.profile.autoConfig, + homeOnly: request.profile.homeOnly, + }), + epochMillis(this.#now()), + request.requestedBy, + ); + this.#slotsFor(id).desired = desired; + return { ok: true, connectionId: id, desired }; + } + + /** + * Record that the operator wants NO bearer on this connection's device. + * + * It performs no I/O on purpose — desired is a record of intent, and the + * deactivation it implies happens when `applyDesired` is called, so a request and + * its execution stay two separately observable facts. + */ + releaseDesired(id: ConnectionId, requestedBy: string): NmSaveResult { + const slots = this.#slots.get(id); + const ifname = slots === undefined ? undefined : targetIfname(slots); + if (ifname === undefined) { + return { + ok: false, + reason: 'no-desired-profile', + receipt: receipt('connection', 'failed', `no tracked device for connection ${id}`), + }; + } + const desired = desiredProfile(unboundBearer(ifname), epochMillis(this.#now()), requestedBy); + this.#slotsFor(id).desired = desired; + return { ok: true, connectionId: id, desired }; + } + + desiredFor(id: ConnectionId): DesiredProfile | null { + return this.#slots.get(id)?.desired ?? null; + } + + /** The saved profile as NM itself holds it — a straight read, no slot involved. */ + async readSavedProfile(id: ConnectionId): Promise { + return await this.#port.readGsmProfile(id); + } + + // ── applied ──────────────────────────────────────────────────────────────── + + /** + * Put the desired state into force and record what was applied. + * + * The applied slot is built from the profile NM READ BACK, not from the request: + * "what was actually put into force" is a claim about NM's content, and asserting + * it from the input would make a silently-rejected field look applied. + */ + async applyDesired(id: ConnectionId, options: NmApplyOptions): Promise { + const desired = this.desiredFor(id); + if (desired === null) { + return refuse('no-desired-profile', `connection ${id} has no desired profile`); + } + return desired.profile.kind === 'unbound' + ? await this.#deactivate(id, desired.profile.deviceIfname, options) + : await this.#activate(id, desired.profile, options); + } + + appliedFor(id: ConnectionId): AppliedConfiguration | null { + return this.#slots.get(id)?.applied ?? null; + } + + /** + * The interface the applied bearer landed on, or `null` when no bearer is in + * force. An applied `unbound` state resolves to `null` rather than to its device: + * there is a device, but no bearer on it, and returning the name would read as one. + */ + resolveAppliedInterface(id: ConnectionId): DeviceIfname | null { + const applied = this.appliedFor(id); + if (applied === null || applied.configuration.kind !== 'bound') { + return null; + } + return applied.configuration.binding.deviceIfname; + } + + // ── observed ─────────────────────────────────────────────────────────────── + + /** + * Fold one complete NM readout. + * + * Never touches the desired slot. Clears the applied slot only when the readout + * positively contradicts it, and always says which of the four contradictions it + * was. + */ + observe(input: NmObservationInput): NmObservationResult { + const generation = input.context.generation; + if (this.#observedGeneration !== null && generation < this.#observedGeneration) { + return { + kind: 'refused', + reason: 'superseded-generation', + currentGeneration: this.#observedGeneration, + }; + } + this.#observedGeneration = generation; + const devices = new Map( + input.devices.map((device) => [device.ifname, device]), + ); + const outcomes: NmConnectionOutcome[] = []; + const losses: NmAppliedLoss[] = []; + for (const [id, slots] of this.#slots) { + const ifname = targetIfname(slots); + if (ifname === undefined) { + continue; + } + const device = devices.get(ifname); + slots.observed = observedState( + device === undefined + ? unavailableObservation(SOURCE, input.context, 'device-absent') + : freshObservation(SOURCE, input.context, observedBearer(device)), + ); + const outcome = this.#classify(id, slots, ifname, device, input.context.observedAt); + outcomes.push({ connectionId: id, outcome }); + if (outcome.status === 'lost') { + losses.push(outcome.loss); + } + } + return { + kind: 'accepted', + generation, + observedAt: input.context.observedAt, + outcomes, + losses, + }; + } + + observedFor(id: ConnectionId): ObservedState | null { + return this.#slots.get(id)?.observed ?? null; + } + + /** + * The three-slot view. `context` supplies the provenance for the "we have not + * observed this yet" case — this layer has no clock or epoch counter of its own + * for observations, so it cannot manufacture one. + */ + stateView( + id: ConnectionId, + context: NormalizationContext, + ): ModemStateView | null { + const slots = this.#slots.get(id); + if (slots === undefined) { + return null; + } + return { + desired: slots.desired, + applied: slots.applied, + observed: + slots.observed ?? + observedState( + unavailableObservation(SOURCE, context, 'provider-unavailable'), + ), + }; + } + + /** `desiredVsApplied` ("did our write happen") and `appliedVsObserved` ("did it stick"). */ + divergence(id: ConnectionId, context: NormalizationContext): StateDivergence | null { + const view = this.stateView(id, context); + return view === null ? null : describeStateDivergence(view, nmBearerStateEquals); + } + + // ── internals ────────────────────────────────────────────────────────────── + + async #activate( + id: ConnectionId, + desired: NmBearerState, + options: NmApplyOptions, + ): Promise { + if (desired.kind !== 'bound') { + return refuse('no-desired-profile', `connection ${id} has no bearer to activate`); + } + const ifname = desired.binding.deviceIfname; + const saved = await this.#port.readGsmProfile(id); + if (saved === undefined) { + return refuse('profile-absent', `connection ${id} is no longer saved in NetworkManager`); + } + let activation: Receipt; + try { + activation = await this.#port.activate(id, ifname); + } catch (error: unknown) { + return refuse('activation-failed', describeError(error)); + } + if (activation.status !== 'applied') { + return { ok: false, reason: 'activation-failed', receipt: activation }; + } + const applied = appliedConfiguration({ + configuration: boundBearer({ + connectionId: id, + deviceIfname: ifname, + apn: saved.apn, + autoConfig: saved.autoConfig, + homeOnly: saved.homeOnly, + }), + appliedAt: epochMillis(this.#now()), + generation: options.generation, + operationId: options.operationId, + }); + this.#slotsFor(id).applied = applied; + return { ok: true, applied, receipt: activation }; + } + + async #deactivate( + id: ConnectionId, + ifname: DeviceIfname, + options: NmApplyOptions, + ): Promise { + let result: Receipt; + try { + result = await this.#port.deactivate(id, ifname); + } catch (error: unknown) { + return refuse('deactivation-failed', describeError(error)); + } + if (result.status !== 'applied') { + return { ok: false, reason: 'deactivation-failed', receipt: result }; + } + const applied = appliedConfiguration({ + configuration: unboundBearer(ifname), + appliedAt: epochMillis(this.#now()), + generation: options.generation, + operationId: options.operationId, + }); + this.#slotsFor(id).applied = applied; + return { ok: true, applied, receipt: result }; + } + + #classify( + id: ConnectionId, + slots: ConnectionSlots, + ifname: DeviceIfname, + device: NmObservedDevice | undefined, + observedAt: EpochMillis, + ): NmAppliedOutcome { + const applied = slots.applied; + if (applied === null) { + return { status: 'unapplied' }; + } + const lose = (reason: NmAppliedLoss['reason']): NmAppliedOutcome => { + slots.applied = null; + return { + status: 'lost', + loss: { + connectionId: id, + deviceIfname: ifname, + reason, + lostAt: observedAt, + generation: applied.generation, + previous: applied, + }, + }; + }; + if (device === undefined) { + return lose('interface-absent'); + } + if (device.state === 'failed') { + return lose('activation-failed'); + } + if (applied.configuration.kind === 'unbound') { + // We deliberately took the bearer down; anything active here is somebody else. + return device.activeConnection === undefined + ? { status: 'retained', applied } + : lose('connection-replaced'); + } + if (device.activeConnection === undefined) { + return lose('interface-detached'); + } + if (device.activeConnection.connectionId !== id) { + return lose('connection-replaced'); + } + if (device.state === 'activated') { + return { status: 'retained', applied }; + } + return TRANSITIONAL_STATES.has(device.state) + ? { status: 'pending', applied, deviceState: device.state } + : lose('interface-detached'); + } + + #slotsFor(id: ConnectionId): ConnectionSlots { + const existing = this.#slots.get(id); + if (existing !== undefined) { + return existing; + } + const created: ConnectionSlots = { desired: null, applied: null, observed: null }; + this.#slots.set(id, created); + return created; + } +} + +/** The device the slots are about: where the bearer IS, else where it was asked for. */ +function targetIfname(slots: ConnectionSlots): DeviceIfname | undefined { + const state = slots.applied?.configuration ?? slots.desired?.profile; + if (state === undefined) { + return undefined; + } + return state.kind === 'bound' ? state.binding.deviceIfname : state.deviceIfname; +} + +function observedBearer(device: NmObservedDevice): NmBearerState { + const active = device.activeConnection; + return active === undefined + ? unboundBearer(device.ifname) + : boundBearer({ + connectionId: active.connectionId, + deviceIfname: device.ifname, + apn: active.apn, + autoConfig: active.autoConfig, + homeOnly: active.homeOnly, + }); +} + +function refuse(reason: NmAdapterRefusalReason, message: string): NmApplyResult { + return { ok: false, reason, receipt: receipt('connection', 'failed', message) }; +} + +function describeError(error: unknown): string { + return error instanceof Error ? error.message : 'NetworkManager rejected the request'; +} diff --git a/control/src/providers/network-manager/index.ts b/control/src/providers/network-manager/index.ts new file mode 100644 index 0000000..5975e8e --- /dev/null +++ b/control/src/providers/network-manager/index.ts @@ -0,0 +1,2 @@ +export * from './adapter'; +export * from './types'; diff --git a/control/src/providers/network-manager/network-manager-adapter.test.ts b/control/src/providers/network-manager/network-manager-adapter.test.ts new file mode 100644 index 0000000..fd8f38b --- /dev/null +++ b/control/src/providers/network-manager/network-manager-adapter.test.ts @@ -0,0 +1,689 @@ +// The saved-vs-applied boundary, proven against A2.3's stateful nmcli harness. +// +// The harness is a real state machine over the nmcli argv grammar, so a profile read +// back after a write is exactly what was written — which is what lets these tests tell +// "the desired slot echoed the request" apart from "the desired slot was seeded from +// the readback". No bus and no subprocess: this suite always runs. +// +// The headline scenario is re-enumeration: NM reports an interface, then stops +// reporting it. The applied bearer must be reported LOST while the desired profile +// survives byte-for-byte — an operator's configuration is not un-asked by a device +// disappearing, and a controller that lost it would have nothing to restore. + +import { describe, expect, test } from 'bun:test'; +import { readFileSync } from 'node:fs'; +import { FakeNetworkManagerPort } from '../../../test-support/fake-nm'; +import { fixtureContext } from '../../../test-support/observation-fixtures'; +import { deviceGeneration, epochMillis, sourceEpoch } from '../../domain'; +import { + type ConnectionId, + connectionId, + type DeviceIfname, + deviceIfname, + type GsmProfileInput, + type NetworkManagerPort, + receipt, +} from '../../ports'; +import { NetworkManagerAdapter } from './adapter'; +import type { NmObservedDevice } from './types'; + +const WWAN0 = deviceIfname('wwan0'); +const WWAN1 = deviceIfname('wwan1'); + +const PROFILE: GsmProfileInput = { + connectionName: 'cell-primary', + apn: 'internet', + username: 'operator', + password: 's3cr3t', + homeOnly: true, + autoConfig: false, +}; + +const APPLY = { operationId: 'op-1', generation: deviceGeneration(7) } as const; + +/** A device NM reports as carrying `id` and nothing unusual. */ +function activatedOn(ifname: DeviceIfname, id: ConnectionId, apn = 'internet'): NmObservedDevice { + return { + ifname, + state: 'activated', + activeConnection: { connectionId: id, apn, autoConfig: false, homeOnly: true }, + }; +} + +/** Save a profile and put it into force — the state every applied-side test starts from. */ +async function applied(port: NetworkManagerPort = new FakeNetworkManagerPort()): Promise<{ + adapter: NetworkManagerAdapter; + id: ConnectionId; +}> { + const adapter = new NetworkManagerAdapter({ port }); + const saved = await adapter.saveDesiredProfile({ + profile: PROFILE, + deviceIfname: WWAN0, + requestedBy: 'rpc:setApn', + }); + if (!saved.ok) { + throw new Error(`fixture could not save a profile: ${saved.receipt.reason}`); + } + const result = await adapter.applyDesired(saved.connectionId, APPLY); + if (!result.ok) { + throw new Error(`fixture could not apply: ${result.receipt.reason}`); + } + return { adapter, id: saved.connectionId }; +} + +describe('NetworkManagerAdapter — desired profiles (the saved side)', () => { + test('a save writes NM and records the REQUEST, not the readback', async () => { + const port = new FakeNetworkManagerPort(); + const adapter = new NetworkManagerAdapter({ port, now: () => 1_000 }); + + const saved = await adapter.saveDesiredProfile({ + profile: PROFILE, + deviceIfname: WWAN0, + requestedBy: 'rpc:setApn', + }); + + expect(saved.ok).toBe(true); + if (!saved.ok) { + return; + } + const desired = adapter.desiredFor(saved.connectionId); + expect(desired?.kind).toBe('desired'); + expect(desired?.requestedBy).toBe('rpc:setApn'); + expect(desired?.requestedAt).toBe(epochMillis(1_000)); + expect(desired?.profile).toEqual({ + kind: 'bound', + binding: { + connectionId: saved.connectionId, + deviceIfname: WWAN0, + apn: 'internet', + autoConfig: false, + homeOnly: true, + }, + }); + }); + + test('the saved profile reads back out of NM itself', async () => { + const port = new FakeNetworkManagerPort(); + const adapter = new NetworkManagerAdapter({ port }); + const saved = await adapter.saveDesiredProfile({ + profile: PROFILE, + deviceIfname: WWAN0, + requestedBy: 'rpc:setApn', + }); + if (!saved.ok) { + throw new Error('save failed'); + } + + const readBack = await adapter.readSavedProfile(saved.connectionId); + + expect(readBack?.apn).toBe('internet'); + expect(readBack?.connectionName).toBe('cell-primary'); + expect(readBack?.homeOnly).toBe(true); + }); + + test('a save with a connection id MODIFIES rather than creating a second profile', async () => { + const port = new FakeNetworkManagerPort(); + const adapter = new NetworkManagerAdapter({ port }); + const first = await adapter.saveDesiredProfile({ + profile: PROFILE, + deviceIfname: WWAN0, + requestedBy: 'rpc:setApn', + }); + if (!first.ok) { + throw new Error('save failed'); + } + + const second = await adapter.saveDesiredProfile({ + connectionId: first.connectionId, + profile: { ...PROFILE, apn: 'iot.example' }, + deviceIfname: WWAN0, + requestedBy: 'rpc:setApn', + }); + + expect(second.ok).toBe(true); + expect(adapter.trackedConnections()).toEqual([first.connectionId]); + expect(port.runner.calls.filter((call) => call[1] === 'add')).toHaveLength(1); + expect(port.runner.calls.filter((call) => call[1] === 'modify')).toHaveLength(1); + expect((await adapter.readSavedProfile(first.connectionId))?.apn).toBe('iot.example'); + }); + + test('desired records what was ASKED, even when NM stores something else', async () => { + // NM lets `gsm.auto-config` drive the APN, so the concrete apn an operator typed + // is NOT what NM ends up holding. Seeding desired from the readback would erase + // the request and make the divergence below structurally unreportable. + const port = autoApnDrivesApn(new FakeNetworkManagerPort()); + const adapter = new NetworkManagerAdapter({ port }); + + const saved = await adapter.saveDesiredProfile({ + profile: { ...PROFILE, autoConfig: true }, + deviceIfname: WWAN0, + requestedBy: 'rpc:setApn', + }); + if (!saved.ok) { + throw new Error('save failed'); + } + const result = await adapter.applyDesired(saved.connectionId, APPLY); + + expect(saved.desired.profile).toEqual({ + kind: 'bound', + binding: { + connectionId: saved.connectionId, + deviceIfname: WWAN0, + apn: 'internet', + autoConfig: true, + homeOnly: true, + }, + }); + expect((await adapter.readSavedProfile(saved.connectionId))?.apn).toBe(''); + expect(result.ok && result.applied.configuration).toEqual({ + kind: 'bound', + binding: { + connectionId: saved.connectionId, + deviceIfname: WWAN0, + apn: '', + autoConfig: true, + homeOnly: true, + }, + }); + expect(adapter.divergence(saved.connectionId, fixtureContext())?.desiredVsApplied).toEqual({ + status: 'diverged', + }); + }); + + test('the desired slot never carries the connection password', async () => { + const { adapter, id } = await applied(); + + expect(JSON.stringify(adapter.desiredFor(id))).not.toContain('s3cr3t'); + expect(JSON.stringify(adapter.appliedFor(id))).not.toContain('s3cr3t'); + // The credential IS in NM — this adapter simply does not mirror it into a slot. + expect((await adapter.readSavedProfile(id))?.password).toBe('s3cr3t'); + }); + + test('a rejected write is a typed refusal and records NO desired state', async () => { + const adapter = new NetworkManagerAdapter({ port: new FakeNetworkManagerPort() }); + const ghost = connectionId('no-such-uuid'); + + const result = await adapter.saveDesiredProfile({ + connectionId: ghost, + profile: PROFILE, + deviceIfname: WWAN0, + requestedBy: 'rpc:setApn', + }); + + expect(result.ok).toBe(false); + if (result.ok) { + return; + } + expect(result.reason).toBe('write-failed'); + expect(result.receipt.status).toBe('failed'); + expect(adapter.desiredFor(ghost)).toBeNull(); + expect(adapter.trackedConnections()).toEqual([]); + }); +}); + +describe('NetworkManagerAdapter — applied bearers resolve to an interface', () => { + test('an applied bearer names the exact device it landed on', async () => { + const { adapter, id } = await applied(); + + const bearer = adapter.appliedFor(id); + expect(bearer?.kind).toBe('applied'); + expect(bearer?.generation).toBe(APPLY.generation); + expect(bearer?.operationId).toBe('op-1'); + expect(bearer?.configuration).toEqual({ + kind: 'bound', + binding: { + connectionId: id, + deviceIfname: WWAN0, + apn: 'internet', + autoConfig: false, + homeOnly: true, + }, + }); + expect(adapter.resolveAppliedInterface(id)).toBe(WWAN0); + }); + + test('applied is built from NM\u2019s readback, so a drifted profile diverges from desired', async () => { + const port = new FakeNetworkManagerPort(); + const adapter = new NetworkManagerAdapter({ port }); + const saved = await adapter.saveDesiredProfile({ + profile: PROFILE, + deviceIfname: WWAN0, + requestedBy: 'rpc:setApn', + }); + if (!saved.ok) { + throw new Error('save failed'); + } + // Somebody else edits the profile in NM between the save and the activation. + await port.updateGsmProfile(saved.connectionId, { apn: 'someone-elses.apn' }); + + const result = await adapter.applyDesired(saved.connectionId, APPLY); + + expect(result.ok).toBe(true); + if (!result.ok) { + return; + } + expect(result.applied.configuration).toEqual({ + kind: 'bound', + binding: { + connectionId: saved.connectionId, + deviceIfname: WWAN0, + apn: 'someone-elses.apn', + autoConfig: false, + homeOnly: true, + }, + }); + expect(adapter.desiredFor(saved.connectionId)?.profile).toEqual({ + kind: 'bound', + binding: { + connectionId: saved.connectionId, + deviceIfname: WWAN0, + apn: 'internet', + autoConfig: false, + homeOnly: true, + }, + }); + expect(adapter.divergence(saved.connectionId, fixtureContext())?.desiredVsApplied).toEqual({ + status: 'diverged', + }); + }); + + test('applying with no desired profile is refused, not attempted', async () => { + const port = new FakeNetworkManagerPort(); + const adapter = new NetworkManagerAdapter({ port }); + + const result = await adapter.applyDesired(connectionId('unknown'), APPLY); + + expect(result.ok).toBe(false); + if (result.ok) { + return; + } + expect(result.reason).toBe('no-desired-profile'); + expect(port.runner.calls).toEqual([]); + }); + + test('a profile deleted out from under us refuses without touching desired', async () => { + const port = new FakeNetworkManagerPort(); + const adapter = new NetworkManagerAdapter({ port }); + const saved = await adapter.saveDesiredProfile({ + profile: PROFILE, + deviceIfname: WWAN0, + requestedBy: 'rpc:setApn', + }); + if (!saved.ok) { + throw new Error('save failed'); + } + const before = adapter.desiredFor(saved.connectionId); + // The adapter itself has no delete verb; only the port can do this. + await port.deleteGsmProfile(saved.connectionId); + + const result = await adapter.applyDesired(saved.connectionId, APPLY); + + expect(result.ok).toBe(false); + if (result.ok) { + return; + } + expect(result.reason).toBe('profile-absent'); + expect(adapter.desiredFor(saved.connectionId)).toBe(before); + expect(adapter.appliedFor(saved.connectionId)).toBeNull(); + }); + + test('a failed activation records NO applied state', async () => { + const port = new FakeNetworkManagerPort(); + const adapter = new NetworkManagerAdapter({ port: refusingActivation(port) }); + const saved = await adapter.saveDesiredProfile({ + profile: PROFILE, + deviceIfname: WWAN0, + requestedBy: 'rpc:setApn', + }); + if (!saved.ok) { + throw new Error('save failed'); + } + + const result = await adapter.applyDesired(saved.connectionId, APPLY); + + expect(result.ok).toBe(false); + if (result.ok) { + return; + } + expect(result.reason).toBe('activation-failed'); + expect(adapter.appliedFor(saved.connectionId)).toBeNull(); + expect(adapter.resolveAppliedInterface(saved.connectionId)).toBeNull(); + expect(adapter.desiredFor(saved.connectionId)).not.toBeNull(); + }); + + test('a released bearer applies as UNBOUND and resolves to no interface', async () => { + const { adapter, id } = await applied(); + + const released = adapter.releaseDesired(id, 'rpc:stopBearer'); + expect(released.ok).toBe(true); + const result = await adapter.applyDesired(id, { + operationId: 'op-2', + generation: APPLY.generation, + }); + + expect(result.ok).toBe(true); + expect(adapter.appliedFor(id)?.configuration).toEqual({ + kind: 'unbound', + deviceIfname: WWAN0, + }); + expect(adapter.resolveAppliedInterface(id)).toBeNull(); + }); +}); + +describe('NetworkManagerAdapter — observed state never overwrites desired', () => { + test('a readout that contradicts the profile leaves the desired slot identical', async () => { + const { adapter, id } = await applied(); + const before = adapter.desiredFor(id); + + const result = adapter.observe({ + context: fixtureContext(), + devices: [activatedOn(WWAN0, id, 'carrier-pushed.apn')], + }); + + expect(result.kind).toBe('accepted'); + expect(adapter.desiredFor(id)).toBe(before); + expect(adapter.desiredFor(id)?.profile).toEqual({ + kind: 'bound', + binding: { + connectionId: id, + deviceIfname: WWAN0, + apn: 'internet', + autoConfig: false, + homeOnly: true, + }, + }); + const observed = adapter.observedFor(id); + expect(observed?.kind).toBe('observed'); + expect(observed?.observation.value).toEqual({ + kind: 'bound', + binding: { + connectionId: id, + deviceIfname: WWAN0, + apn: 'carrier-pushed.apn', + autoConfig: false, + homeOnly: true, + }, + }); + expect(adapter.divergence(id, fixtureContext())).toEqual({ + desiredVsApplied: { status: 'aligned' }, + appliedVsObserved: { status: 'diverged' }, + }); + }); + + test('the three slots stay three slots, each with its own discriminant', async () => { + const { adapter, id } = await applied(); + adapter.observe({ context: fixtureContext(), devices: [activatedOn(WWAN0, id)] }); + + const view = adapter.stateView(id, fixtureContext()); + + expect(view?.desired?.kind).toBe('desired'); + expect(view?.applied?.kind).toBe('applied'); + expect(view?.observed.kind).toBe('observed'); + }); + + test('an unobserved connection reports unavailable rather than borrowing a slot', async () => { + const { adapter, id } = await applied(); + + const view = adapter.stateView(id, fixtureContext()); + + expect(view?.observed.observation.freshness).toEqual({ + state: 'unavailable', + since: fixtureContext().observedAt, + reason: 'provider-unavailable', + }); + expect(view?.observed.observation.value).toBeNull(); + expect(adapter.divergence(id, fixtureContext())?.appliedVsObserved).toEqual({ + status: 'indeterminate', + missing: 'observed', + }); + }); +}); + +describe('NetworkManagerAdapter — re-enumeration loses the bearer, never the profile', () => { + test('an interface that disappears is an applied LOSS with the desired profile intact', async () => { + const { adapter, id } = await applied(); + const desiredBefore = adapter.desiredFor(id); + const appliedBefore = adapter.appliedFor(id); + + const present = adapter.observe({ + context: fixtureContext(), + devices: [activatedOn(WWAN0, id)], + }); + expect(present.kind === 'accepted' && present.losses).toEqual([]); + expect(present.kind === 'accepted' && present.outcomes[0]?.outcome.status).toBe('retained'); + + // The modem re-enumerates: a NEW generation, and `wwan0` is simply not there. + const gone = adapter.observe({ + context: fixtureContext({ + generation: deviceGeneration(8), + sourceEpoch: sourceEpoch(43), + observedAt: epochMillis(1_700_000_060_000), + }), + devices: [{ ifname: WWAN1, state: 'disconnected' }], + }); + + expect(gone.kind).toBe('accepted'); + if (gone.kind !== 'accepted') { + return; + } + expect(gone.losses).toHaveLength(1); + expect(gone.losses[0]).toEqual({ + connectionId: id, + deviceIfname: WWAN0, + reason: 'interface-absent', + lostAt: epochMillis(1_700_000_060_000), + generation: APPLY.generation, + // biome-ignore lint/style/noNonNullAssertion: the fixture asserted this is set. + previous: appliedBefore!, + }); + + // APPLIED clears, OBSERVED reflects reality, DESIRED is untouched. + expect(adapter.appliedFor(id)).toBeNull(); + expect(adapter.resolveAppliedInterface(id)).toBeNull(); + expect(adapter.observedFor(id)?.observation.freshness).toEqual({ + state: 'unavailable', + since: epochMillis(1_700_000_060_000), + reason: 'device-absent', + }); + expect(adapter.observedFor(id)?.observation.value).toBeNull(); + expect(adapter.desiredFor(id)).toBe(desiredBefore); + expect(adapter.divergence(id, fixtureContext())?.desiredVsApplied).toEqual({ + status: 'indeterminate', + missing: 'applied', + }); + }); + + test('the surviving desired profile is enough to re-apply once the device returns', async () => { + const { adapter, id } = await applied(); + adapter.observe({ context: fixtureContext(), devices: [] }); + expect(adapter.appliedFor(id)).toBeNull(); + + // No new save: the desired slot still holds everything the re-apply needs. + const again = await adapter.applyDesired(id, { + operationId: 'op-3', + generation: deviceGeneration(8), + }); + + expect(again.ok).toBe(true); + expect(adapter.resolveAppliedInterface(id)).toBe(WWAN0); + expect(adapter.appliedFor(id)?.generation).toBe(deviceGeneration(8)); + }); + + test('a device present but carrying nothing is a FRESH observation, not an unavailable one', async () => { + const { adapter, id } = await applied(); + + const result = adapter.observe({ + context: fixtureContext(), + devices: [{ ifname: WWAN0, state: 'disconnected' }], + }); + + expect(result.kind === 'accepted' && result.losses[0]?.reason).toBe('interface-detached'); + expect(adapter.observedFor(id)?.observation.freshness).toEqual({ state: 'fresh' }); + expect(adapter.observedFor(id)?.observation.value).toEqual({ + kind: 'unbound', + deviceIfname: WWAN0, + }); + expect(adapter.appliedFor(id)).toBeNull(); + expect(adapter.desiredFor(id)).not.toBeNull(); + }); + + test('another connection on our device is a replacement, not a detachment', async () => { + const { adapter, id } = await applied(); + + const result = adapter.observe({ + context: fixtureContext(), + devices: [activatedOn(WWAN0, connectionId('someone-else'))], + }); + + expect(result.kind === 'accepted' && result.losses[0]?.reason).toBe('connection-replaced'); + expect(adapter.appliedFor(id)).toBeNull(); + expect(adapter.desiredFor(id)).not.toBeNull(); + }); + + test('a device NM reports as failed loses the bearer with that reason', async () => { + const { adapter, id } = await applied(); + + const result = adapter.observe({ + context: fixtureContext(), + devices: [{ ...activatedOn(WWAN0, id), state: 'failed' }], + }); + + expect(result.kind === 'accepted' && result.losses[0]?.reason).toBe('activation-failed'); + expect(adapter.appliedFor(id)).toBeNull(); + }); + + test('a device still settling is PENDING — an activation in flight is not a loss', async () => { + const { adapter, id } = await applied(); + + const result = adapter.observe({ + context: fixtureContext(), + devices: [{ ...activatedOn(WWAN0, id), state: 'ip-config' }], + }); + + expect(result.kind).toBe('accepted'); + if (result.kind !== 'accepted') { + return; + } + expect(result.losses).toEqual([]); + expect(result.outcomes[0]?.outcome).toEqual({ + status: 'pending', + // biome-ignore lint/style/noNonNullAssertion: asserted non-null immediately above. + applied: adapter.appliedFor(id)!, + deviceState: 'ip-config', + }); + expect(adapter.resolveAppliedInterface(id)).toBe(WWAN0); + }); + + test('a readout from a superseded generation cannot clear applied state', async () => { + const { adapter, id } = await applied(); + adapter.observe({ + context: fixtureContext({ generation: deviceGeneration(9) }), + devices: [activatedOn(WWAN0, id)], + }); + + const late = adapter.observe({ + context: fixtureContext({ generation: deviceGeneration(8) }), + devices: [], + }); + + expect(late).toEqual({ + kind: 'refused', + reason: 'superseded-generation', + currentGeneration: deviceGeneration(9), + }); + expect(adapter.resolveAppliedInterface(id)).toBe(WWAN0); + }); + + test('a saved-but-never-applied connection reports unapplied, not lost', async () => { + const port = new FakeNetworkManagerPort(); + const adapter = new NetworkManagerAdapter({ port }); + const saved = await adapter.saveDesiredProfile({ + profile: PROFILE, + deviceIfname: WWAN0, + requestedBy: 'rpc:setApn', + }); + if (!saved.ok) { + throw new Error('save failed'); + } + + const result = adapter.observe({ context: fixtureContext(), devices: [] }); + + expect(result.kind === 'accepted' && result.losses).toEqual([]); + expect(result.kind === 'accepted' && result.outcomes).toEqual([ + { connectionId: saved.connectionId, outcome: { status: 'unapplied' } }, + ]); + }); +}); + +describe('NetworkManagerAdapter — scope boundary', () => { + // The adapter owns bearers and ONLY bearers. Radio/SIM verbs belong to the + // ModemManager provider, profile deletion is outside the port-tagged `NmOp` set, + // and physical modem identity belongs to the domain layer — so none of those + // identifiers may appear in this module's executable source. + const FORBIDDEN = [ + 'setRadioModes', + 'setPrimarySimSlot', + 'sendPin', + 'sendPuk', + 'scanNetworks', + 'setCurrentBands', + 'deleteGsmProfile', + 'PhysicalModemId', + 'physicalModemId', + ]; + + const sources = ['adapter.ts', 'types.ts', 'index.ts'].map((name) => ({ + name, + code: stripComments(readFileSync(new URL(name, import.meta.url), 'utf8')), + })); + + test('the comment strip is non-vacuous in both directions', () => { + expect( + stripComments('const a = 1; // setRadioModes\n/* sendPin */\nconst b = 2;'), + ).not.toContain('setRadioModes'); + expect(stripComments('const a = 1; // setRadioModes')).toContain('const a = 1;'); + expect(sources.every((source) => source.code.includes('export'))).toBe(true); + }); + + for (const forbidden of FORBIDDEN) { + test(`no executable source names ${forbidden}`, () => { + for (const source of sources) { + expect(`${source.name}:${source.code.includes(forbidden)}`).toBe(`${source.name}:false`); + } + }); + } +}); + +function delegating(port: FakeNetworkManagerPort): NetworkManagerPort { + return { + createGsmProfile: (profile) => port.createGsmProfile(profile), + readGsmProfile: (id) => port.readGsmProfile(id), + updateGsmProfile: (id, patch) => port.updateGsmProfile(id, patch), + deleteGsmProfile: (id) => port.deleteGsmProfile(id), + activate: (id, ifname) => port.activate(id, ifname), + deactivate: (id, ifname) => port.deactivate(id, ifname), + acquireQuiesceLease: (id, ifname) => port.acquireQuiesceLease(id, ifname), + releaseQuiesceLease: (lease) => port.releaseQuiesceLease(lease), + }; +} + +function refusingActivation(port: FakeNetworkManagerPort): NetworkManagerPort { + return { + ...delegating(port), + activate: async () => receipt('enabled', 'failed', 'No suitable device found for connection'), + }; +} + +function autoApnDrivesApn(port: FakeNetworkManagerPort): NetworkManagerPort { + return { + ...delegating(port), + createGsmProfile: (profile) => + port.createGsmProfile(profile.autoConfig ? { ...profile, apn: '' } : profile), + updateGsmProfile: (id, patch) => + port.updateGsmProfile(id, patch.autoConfig === true ? { ...patch, apn: '' } : patch), + }; +} + +function stripComments(source: string): string { + return source.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|\s)\/\/.*$/gm, '$1'); +} diff --git a/control/src/providers/network-manager/types.ts b/control/src/providers/network-manager/types.ts new file mode 100644 index 0000000..6387c24 --- /dev/null +++ b/control/src/providers/network-manager/types.ts @@ -0,0 +1,240 @@ +// The saved-vs-applied vocabulary, in NetworkManager's own terms. +// +// NM is the sole writer of bearers, APN, auth, roaming, autoconnect and activation +// (`ports/README.md` ownership matrix), and it is also the stack that draws the +// distinction this whole module is built on: a connection PROFILE is what was saved, +// an ACTIVE connection is what was actually put into force on a device, and the device +// itself reports what it is currently doing. `observations/state-separation.ts` models +// those three slots generically; this file gives them an NM-shaped payload. +// +// Radio, band, SIM and power operations are deliberately absent. Those belong to the +// ModemManager provider, and a second surface expressing them here would make two +// writers for one resource — the exact thing the ownership matrix exists to prevent. +// Nothing here carries a `PhysicalModemId` either: every slot is keyed by NM's own +// connection UUID, so this adapter can never become a second authority on which +// physical modem is which. + +import type { DeviceGeneration, EpochMillis } from '../../domain'; +import type { + AppliedConfiguration, + DesiredProfile, + NormalizationContext, +} from '../../observations'; +import type { ConnectionId, DeviceIfname, GsmProfileInput, Receipt } from '../../ports'; + +/** + * NM's own device states (`NMDeviceState`), by name. + * + * The transitional members are kept rather than collapsed into "not activated", + * because a device in `prepare` carrying our connection is COMING UP, and reporting + * that as a lost bearer would turn every ordinary activation into a false alarm. + */ +export const NM_DEVICE_STATES = [ + 'unknown', + 'unmanaged', + 'unavailable', + 'disconnected', + 'prepare', + 'config', + 'need-auth', + 'ip-config', + 'ip-check', + 'secondaries', + 'activated', + 'deactivating', + 'failed', +] as const; +export type NmDeviceState = (typeof NM_DEVICE_STATES)[number]; + +/** + * The bearer identity all three slots compare in. + * + * `username` and `password` are deliberately NOT members. A state slot is read, + * compared, and surfaced in divergence output; `gsm.password` is the one field in the + * profile the redaction module masks everywhere else, so putting it in a comparable + * value would re-open that hole through a side door. The credential lives in NM, is + * written through the port, and is never mirrored here. + */ +export interface NmBearerBinding { + readonly connectionId: ConnectionId; + readonly deviceIfname: DeviceIfname; + readonly apn: string; + readonly autoConfig: boolean; + readonly homeOnly: boolean; +} + +/** + * A bearer, or the positive absence of one. + * + * `unbound` exists so "NM answered, and it says nothing is in force on this device" + * is a VALUE rather than an unavailable observation. The distinction is load-bearing: + * an unavailable observation compares `indeterminate` against everything, which is + * right for "the device is gone" and wrong for "the device is here and idle" — the + * second is a definite divergence from a desired bearer, and an operator needs to see + * it as one. + */ +export type NmBearerState = + | { readonly kind: 'bound'; readonly binding: NmBearerBinding } + | { readonly kind: 'unbound'; readonly deviceIfname: DeviceIfname }; + +export function boundBearer(binding: NmBearerBinding): NmBearerState { + return { kind: 'bound', binding }; +} + +export function unboundBearer(deviceIfname: DeviceIfname): NmBearerState { + return { kind: 'unbound', deviceIfname }; +} + +export function nmBearerBindingEquals(left: NmBearerBinding, right: NmBearerBinding): boolean { + return ( + left.connectionId === right.connectionId && + left.deviceIfname === right.deviceIfname && + left.apn === right.apn && + left.autoConfig === right.autoConfig && + left.homeOnly === right.homeOnly + ); +} + +/** The equality `describeStateDivergence` is driven with for NM state. */ +export function nmBearerStateEquals(left: NmBearerState, right: NmBearerState): boolean { + if (left.kind === 'bound' && right.kind === 'bound') { + return nmBearerBindingEquals(left.binding, right.binding); + } + if (left.kind === 'unbound' && right.kind === 'unbound') { + return left.deviceIfname === right.deviceIfname; + } + return false; +} + +/** The connection NM reports active on a device, with the settings it put in force. */ +export interface NmObservedActiveConnection { + readonly connectionId: ConnectionId; + readonly apn: string; + readonly autoConfig: boolean; + readonly homeOnly: boolean; +} + +/** One device exactly as NM reports it in a readout. */ +export interface NmObservedDevice { + readonly ifname: DeviceIfname; + readonly state: NmDeviceState; + readonly activeConnection?: NmObservedActiveConnection; +} + +/** + * ONE COMPLETE NM readout — an enumeration, never a delta. + * + * That is what makes re-enumeration detectable at all: a device missing from `devices` + * means the device is GONE, and a delta stream has no way to say that without a + * removal event nobody guarantees will arrive. + * + * `context` supplies the clock, the generation and the source epoch, exactly as the + * observation layer requires — this adapter has no clock of its own for observations + * and therefore cannot stamp a readout with a time it did not come from. + */ +export interface NmObservationInput { + readonly context: NormalizationContext; + readonly devices: readonly NmObservedDevice[]; +} + +/** What an operator asked for: a saved profile, targeted at an exact device. */ +export interface NmDesiredRequest { + readonly profile: GsmProfileInput; + readonly deviceIfname: DeviceIfname; + /** Opaque origin label (an RPC caller, a policy engine). Never a credential. */ + readonly requestedBy: string; + /** Update this existing profile instead of creating a new one. */ + readonly connectionId?: ConnectionId; +} + +export const NM_ADAPTER_REFUSAL_REASONS = [ + 'no-desired-profile', + 'profile-absent', + 'activation-failed', + 'deactivation-failed', + 'write-failed', +] as const; +export type NmAdapterRefusalReason = (typeof NM_ADAPTER_REFUSAL_REASONS)[number]; + +export type NmSaveResult = + | { + readonly ok: true; + readonly connectionId: ConnectionId; + readonly desired: DesiredProfile; + } + | { readonly ok: false; readonly reason: NmAdapterRefusalReason; readonly receipt: Receipt }; + +/** Applying a desired profile: the write either took, or it did not and says why. */ +export type NmApplyResult = + | { + readonly ok: true; + readonly applied: AppliedConfiguration; + readonly receipt: Receipt; + } + | { readonly ok: false; readonly reason: NmAdapterRefusalReason; readonly receipt: Receipt }; + +export const NM_APPLIED_LOSS_REASONS = [ + /** The readout has no such device at all — a re-enumeration, or an unplug. */ + 'interface-absent', + /** The device is present and carries no active connection. */ + 'interface-detached', + /** A DIFFERENT connection is active on our device. */ + 'connection-replaced', + /** NM reports the device itself failed. */ + 'activation-failed', +] as const; +export type NmAppliedLossReason = (typeof NM_APPLIED_LOSS_REASONS)[number]; + +/** + * The applied bearer stopped being in force. + * + * `previous` is retained rather than discarded: a caller deciding whether to re-apply + * or to roll back needs to know what WAS in force, and the applied slot has just been + * cleared precisely because it no longer describes reality. + */ +export interface NmAppliedLoss { + readonly connectionId: ConnectionId; + readonly deviceIfname: DeviceIfname; + readonly reason: NmAppliedLossReason; + readonly lostAt: EpochMillis; + readonly generation: DeviceGeneration; + readonly previous: AppliedConfiguration; +} + +export type NmAppliedOutcome = + /** A desired profile is tracked, and nothing has been put into force for it yet. */ + | { readonly status: 'unapplied' } + | { readonly status: 'retained'; readonly applied: AppliedConfiguration } + | { + readonly status: 'pending'; + readonly applied: AppliedConfiguration; + readonly deviceState: NmDeviceState; + } + | { readonly status: 'lost'; readonly loss: NmAppliedLoss }; + +export interface NmConnectionOutcome { + readonly connectionId: ConnectionId; + readonly outcome: NmAppliedOutcome; +} + +/** + * The result of folding one readout. + * + * A readout from a superseded generation is REFUSED rather than applied late: the + * generation fence exists so a reply about a previous enumeration cannot clear applied + * state that belongs to the current one. + */ +export type NmObservationResult = + | { + readonly kind: 'refused'; + readonly reason: 'superseded-generation'; + readonly currentGeneration: DeviceGeneration; + } + | { + readonly kind: 'accepted'; + readonly generation: DeviceGeneration; + readonly observedAt: EpochMillis; + readonly outcomes: readonly NmConnectionOutcome[]; + /** Just the losses, so the case that matters is not behind a filter. */ + readonly losses: readonly NmAppliedLoss[]; + }; diff --git a/control/src/providers/registry.ts b/control/src/providers/registry.ts new file mode 100644 index 0000000..b3f8f0f --- /dev/null +++ b/control/src/providers/registry.ts @@ -0,0 +1,49 @@ +import { DomainError } from '../domain'; +import type { ProviderDefinition, ProviderOperationsSurface } from './contracts'; + +export class ProviderRegistryError extends DomainError { + override readonly name = 'ProviderRegistryError'; + + constructor(readonly reason: 'duplicate-provider' | 'empty-profile-version') { + super(`provider registration refused: ${reason}`); + } +} + +export interface ProviderRegistry { + readonly revision: number; + register( + definition: ProviderDefinition, + ): void; + list(): readonly ProviderDefinition[]; +} + +class InMemoryProviderRegistry implements ProviderRegistry { + /** Startup registration is the registry's documented mutation boundary. */ + readonly #definitions: ProviderDefinition[] = []; + #revision = 0; + + get revision(): number { + return this.#revision; + } + + register( + definition: ProviderDefinition, + ): void { + if (this.#definitions.some((candidate) => candidate.id === definition.id)) { + throw new ProviderRegistryError('duplicate-provider'); + } + if (definition.profileVersion.trim().length === 0) { + throw new ProviderRegistryError('empty-profile-version'); + } + this.#definitions.push(definition); + this.#revision += 1; + } + + list(): readonly ProviderDefinition[] { + return [...this.#definitions]; + } +} + +export function createProviderRegistry(): ProviderRegistry { + return new InMemoryProviderRegistry(); +} diff --git a/control/src/providers/ufi-himi/credential-fence.test.ts b/control/src/providers/ufi-himi/credential-fence.test.ts new file mode 100644 index 0000000..fe675af --- /dev/null +++ b/control/src/providers/ufi-himi/credential-fence.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, test } from 'bun:test'; +import { createHash } from 'node:crypto'; + +/** + * The generic UFI admin password is EPHEMERAL BENCH INPUT, exactly like the MF79U's: + * injected through `UFI_BENCH_PASSWORD` for one supervised bench run, never committed, + * never logged, never echoed into evidence. This scan is what keeps that true — it + * walks tracked AND intended-untracked files for the password and for the two + * derivatives a careless capture would leave behind. + */ +function candidateSecret(): string { + return process.env.UFI_BENCH_PASSWORD ?? ['ceralive', 'ufi', 'himi', 'secret', 'scan'].join('-'); +} + +describe('UFI bench credential fence', () => { + test('keeps the ephemeral password and its derivatives out of tracked or intended files', async () => { + // Given + const password = candidateSecret(); + const forbidden = [ + password, + Buffer.from(password).toString('base64'), + createHash('sha256').update(password).digest('hex'), + ]; + const tracked = Bun.spawn( + ['git', 'ls-files', '--cached', '--others', '--exclude-standard', '-z'], + { stdout: 'pipe', stderr: 'pipe' }, + ); + + // When + const [exitCode, names] = await Promise.all([ + tracked.exited, + new Response(tracked.stdout).text(), + ]); + expect(exitCode).toBe(0); + const files = names.split('\0').filter((name) => name.length > 0); + const leaks: string[] = []; + for (const file of files) { + const content = await Bun.file(file).text(); + if (forbidden.some((value) => value.length > 0 && content.includes(value))) leaks.push(file); + } + + // Then + expect(leaks).toEqual([]); + }); +}); diff --git a/control/src/providers/ufi-himi/index.ts b/control/src/providers/ufi-himi/index.ts new file mode 100644 index 0000000..cf7b8f5 --- /dev/null +++ b/control/src/providers/ufi-himi/index.ts @@ -0,0 +1,6 @@ +export * from './operations'; +export * from './prohibitions'; +export * from './provider'; +export * from './qualcomm-evidence'; +export * from './session'; +export * from './transport'; diff --git a/control/src/providers/ufi-himi/no-write-path.test.ts b/control/src/providers/ufi-himi/no-write-path.test.ts new file mode 100644 index 0000000..29cb308 --- /dev/null +++ b/control/src/providers/ufi-himi/no-write-path.test.ts @@ -0,0 +1,129 @@ +// The "no code path at all" half of the fence. +// +// prohibition-fence.test.ts proves the forbidden operations are REFUSED. This file +// proves something stronger and different: that no function capable of performing one +// exists in the shipped provider. It scans the comment-stripped production source for +// the constructs such a function would need — a subprocess, a raw socket, a shell +// fallback binary, a DIAG device node, a mutating HTTP verb, or a HIMI command outside +// the frozen read vocabulary — and every detector has a non-vacuity control. + +import { describe, expect, test } from 'bun:test'; +import { readdirSync } from 'node:fs'; +import { join } from 'node:path'; +import { UFI_COMMANDS, UFI_READ_COMMANDS, UFI_SESSION_COMMANDS } from './transport'; + +const DIR = import.meta.dir; + +const DETECTORS = [ + { + label: 'subprocess', + re: /(?:Bun\.spawn|execFileSync?|spawnSync|execSync)\s*\(|node:child_process/, + sample: "Bun.spawn(['adb', 'shell'])", + }, + { + label: 'raw-socket', + re: /node:(?:net|dgram|tls)|createConnection\s*\(/, + sample: "import { createConnection } from 'node:net';", + }, + { + label: 'shell-fallback-binary', + re: /(['"`])(?:adb|ssh|telnet|qcsuper|edl|fastboot|firehose|sahara)\1/i, + sample: "const tool = 'qcsuper';", + }, + { + label: 'diag-device-node', + re: /\/dev\/diag|\/dev\/ttyUSB\d/, + sample: "open('/dev/diag')", + }, + { + label: 'mutating-http-verb', + re: /(['"`])(?:PUT|PATCH|DELETE)\1/, + sample: "method: 'PUT'", + }, + { + // In a read-only provider a bare write-shaped literal is a review trigger by + // itself: it is either a HIMI write command or a descriptor claiming a write + // impact, and neither may exist here. + label: 'write-shaped-literal', + re: /(['"`])(?:set|write|upgrade|reboot|reset|restore|erase|flash)[a-z_]*\1/i, + sample: "cmdid: 'setnetworkmode'", + }, +] as const; + +function stripComments(source: string): string { + return source + .replace(/\/\*[\s\S]*?\*\//g, ' ') + .split('\n') + .filter((line) => !/^\s*(?:\/\/|\*)/.test(line)) + .join('\n'); +} + +const CODE = new Map(); +for (const name of readdirSync(DIR)) { + if (!name.endsWith('.ts') || name.endsWith('.test.ts')) continue; + CODE.set(name, stripComments(await Bun.file(join(DIR, name)).text())); +} + +describe('the UFI/HIMI provider has no write path to have', () => { + test('scans the whole shipped provider, so the gate cannot pass vacuously', () => { + // Given / When / Then + expect([...CODE.keys()].sort()).toEqual([ + 'index.ts', + 'operations.ts', + 'prohibitions.ts', + 'provider.ts', + 'qualcomm-evidence.ts', + 'session.ts', + 'transport.ts', + ]); + }); + + test.each(DETECTORS.map((detector) => [detector.label, detector] as const))( + 'the %s detector fires on a synthetic violation', + (_label, detector) => { + // Given / When / Then + expect(detector.re.test(stripComments(detector.sample))).toBe(true); + }, + ); + + test.each(DETECTORS.map((detector) => [detector.label, detector] as const))( + 'no production source contains a %s construct', + (_label, detector) => { + // Given / When + const violations = [...CODE.entries()] + .filter(([, source]) => detector.re.test(source)) + .map(([name]) => name); + + // Then + expect(violations).toEqual([]); + }, + ); +}); + +describe('the HIMI command vocabulary is closed and read-only', () => { + test('every command is a read or the session login, and nothing else', () => { + // Given / When + const nonRead = UFI_COMMANDS.filter( + (command) => !command.startsWith('get') && command !== 'login', + ); + + // Then + expect(nonRead).toEqual([]); + expect(UFI_SESSION_COMMANDS).toEqual(['login']); + expect(UFI_READ_COMMANDS.every((command) => command.startsWith('get'))).toBe(true); + }); + + test('every cmdid literal in the shipped source names a member of that vocabulary', () => { + // Given + const allowed = new Set(UFI_COMMANDS); + + // When + const literals = [...CODE.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([]); + }); +}); diff --git a/control/src/providers/ufi-himi/operations.ts b/control/src/providers/ufi-himi/operations.ts new file mode 100644 index 0000000..48fe5bf --- /dev/null +++ b/control/src/providers/ufi-himi/operations.ts @@ -0,0 +1,113 @@ +import { + defineOperationDescriptor, + type OperationDescriptor, + type OperationResult, +} from '../../domain'; +import type { ProviderReadOperations } from '../contracts'; +import { + type UfiProhibitionClass, + type UfiProhibitionReason, + ufiProhibition, +} from './prohibitions'; + +export const UFI_PROFILE = 'ufi-himi-read-only'; + +/** The reason every descriptor gives for its absent write half. One string, one meaning. */ +export const UFI_READ_ONLY_REASON = 'ufi-himi-provider-is-read-only'; + +export type UfiReadValue = Readonly>; + +/** + * `ProviderReadOperations` is used verbatim rather than a local shape, because it has no + * `write` member to omit — the absence is structural, not a convention this file keeps. + */ +export type UfiReadOperation = ProviderReadOperations; + +export const UFI_READ_OPERATION_IDS = ['ufi.signal.read', 'ufi.details.read'] as const; +export type UfiReadOperationId = (typeof UFI_READ_OPERATION_IDS)[number]; + +export function isUfiReadOperation(operationId: string): operationId is UfiReadOperationId { + return (UFI_READ_OPERATION_IDS as readonly string[]).includes(operationId); +} + +export function ufiReadDescriptor( + operationId: UfiReadOperationId, +): OperationDescriptor { + return defineOperationDescriptor({ + id: operationId, + support: { + read: { supported: true }, + write: { supported: false, reason: UFI_READ_ONLY_REASON }, + }, + authority: 'provider', + provider: 'ufi-himi', + constraints: { kind: 'unconstrained' }, + livePreconditions: ['ufi-himi-session'], + availability: { state: 'available' }, + mutationImpact: 'read', + retryClass: 'idempotent-read', + readback: { required: false }, + rollback: { required: false }, + journal: { required: false }, + admission: { required: false }, + evidence: { profiles: [UFI_PROFILE], firmware: [] }, + confidence: 'medium', + }); +} + +export function ufiReadResult( + generation: OperationResult['generation'], + value: UfiReadValue, +): OperationResult { + return { status: 'applied', value, generation, requiresReconciliation: false }; +} + +export function ufiRefusedResult( + generation: OperationResult['generation'], + reason: string, +): OperationResult { + return { status: 'refused', reason, generation, requiresReconciliation: false }; +} + +export type UfiOperationPlan = + | { readonly status: 'read'; readonly operationId: UfiReadOperationId } + | { + readonly status: 'refused'; + readonly operationId: string; + readonly reason: UfiProhibitionReason | 'unknown-operation'; + readonly prohibitionClass: UfiProhibitionClass | null; + /** + * A literal, and provable: this function takes no transport, holds no session, + * and returns synchronously — there is nothing here that could reach the device. + */ + readonly transportContacted: false; + }; + +/** + * Resolve an operation id BEFORE anything is dialled. + * + * Prohibited ids are answered from the frozen table first, so a forbidden operation is + * never mistaken for a typo. An id that is neither prohibited nor a known read is + * `unknown-operation` — also refused, because a read-only provider's operation set is + * closed and "not in the set" is a complete answer. + */ +export function planUfiOperation(operationId: string): UfiOperationPlan { + const prohibition = ufiProhibition(operationId); + if (prohibition !== undefined) { + return { + status: 'refused', + operationId, + reason: prohibition.reason, + prohibitionClass: prohibition.class, + transportContacted: false, + }; + } + if (isUfiReadOperation(operationId)) return { status: 'read', operationId }; + return { + status: 'refused', + operationId, + reason: 'unknown-operation', + prohibitionClass: null, + transportContacted: false, + }; +} diff --git a/control/src/providers/ufi-himi/prohibition-fence.test.ts b/control/src/providers/ufi-himi/prohibition-fence.test.ts new file mode 100644 index 0000000..0550ff1 --- /dev/null +++ b/control/src/providers/ufi-himi/prohibition-fence.test.ts @@ -0,0 +1,233 @@ +import { afterEach, describe, expect, test } from 'bun:test'; +import { createOperationEngineHarness } from '../../../test-support/operation-engine-fixture'; +import { physicalModemId } from '../../domain'; +import type { OperationExecution } from '../../operations'; +import { UFI_PROFILE } from './operations'; +import { + UFI_PROHIBITED_OPERATION_IDS, + UFI_PROHIBITED_OPERATIONS, + type UfiProhibitedOperationId, + ufiProhibitionDescriptor, +} from './prohibitions'; +import { createUfiHimiDefinition } from './provider'; +import type { UfiHttpRequest, UfiTransport } from './transport'; + +const FENCE_MODEM = physicalModemId('serial:ufi-fence'); + +const disposals: Array<() => Promise> = []; + +afterEach(async () => { + for (const dispose of disposals.splice(0).reverse()) await dispose(); +}); + +/** A transport that records every call and answers nothing. Any use is a failure. */ +function spyTransport() { + const calls: UfiHttpRequest[] = []; + const transport: UfiTransport = { + request: async (request) => { + calls.push(request); + return { status: 200, body: '{"reply":"ok"}' }; + }, + }; + return { calls, transport }; +} + +function operationsFor(transport: UfiTransport) { + return createUfiHimiDefinition({ + interfaceName: 'usb0', + adminUrl: 'http://192.168.0.1', + transport, + credentials: { username: 'admin', password: 'unused-in-this-suite' }, + }).operations(UFI_PROFILE); +} + +/** Every callable reachable from a value, by path — the structural half of "no write". */ +function functionPaths(value: unknown, path = ''): readonly string[] { + if (typeof value === 'function') return [path]; + if (Array.isArray(value)) { + return value.flatMap((item, index) => functionPaths(item, `${path}[${index}]`)); + } + if (typeof value === 'object' && value !== null) { + return Object.entries(value).flatMap(([key, item]) => + functionPaths(item, path === '' ? key : `${path}.${key}`), + ); + } + return []; +} + +describe('UFI operations expose zero write descriptors', () => { + test('the only callables on the surface are the two reads and the pure planner', () => { + // Given + const spy = spyTransport(); + + // When + const operations = operationsFor(spy.transport); + + // Then + expect(operations.access).toBe('read-only'); + expect(functionPaths(operations)).toEqual(['reads[0].read', 'reads[1].read', 'plan']); + expect(spy.calls).toEqual([]); + }); + + test('no read entry carries a write member and no descriptor supports a write', () => { + // Given + const spy = spyTransport(); + + // When + const operations = operationsFor(spy.transport); + + // Then + expect(operations.reads.length).toBeGreaterThan(0); + for (const entry of operations.reads) { + expect(Object.keys(entry).sort()).toEqual(['descriptor', 'read']); + expect('write' in entry).toBe(false); + expect(entry.descriptor.mutationImpact).toBe('read'); + expect(entry.descriptor.support.write).toEqual({ + supported: false, + reason: 'ufi-himi-provider-is-read-only', + }); + } + expect(spy.calls).toEqual([]); + }); +}); + +describe('prohibited operations', () => { + test('the fence enumerates every forbidden class the safety model names', () => { + // Given / When + const ids = [...UFI_PROHIBITED_OPERATION_IDS].sort(); + + // Then + expect(ids).toEqual([ + 'calibration.write', + 'diag.info-probe', + 'diag.write', + 'driver.blind-retry', + 'edl.automation', + 'efs.write', + 'firmware.flash', + 'identity.write', + 'interface.blind-retry', + 'nv.write', + 'shell.transport-fallback', + ]); + }); + + test.each([...UFI_PROHIBITED_OPERATION_IDS])( + '%s is refused with its typed reason and never reaches the transport', + (operationId) => { + // Given + const spy = spyTransport(); + const operations = operationsFor(spy.transport); + + // When + const plan = operations.plan(operationId); + + // Then + expect(plan).toEqual({ + status: 'refused', + operationId, + reason: UFI_PROHIBITED_OPERATIONS[operationId].reason, + prohibitionClass: UFI_PROHIBITED_OPERATIONS[operationId].class, + transportContacted: false, + }); + expect(operations.reads.some((entry) => entry.descriptor.id === operationId)).toBe(false); + expect(spy.calls).toEqual([]); + }, + ); + + test('an unknown operation id is refused too — the read set is closed', () => { + // Given + const spy = spyTransport(); + + // When + const plan = operationsFor(spy.transport).plan('ufi.wifi.enable'); + + // Then + expect(plan).toMatchObject({ status: 'refused', reason: 'unknown-operation' }); + expect(spy.calls).toEqual([]); + }); + + test('a supported read id still plans as a read', () => { + // Given + const spy = spyTransport(); + + // When + const plan = operationsFor(spy.transport).plan('ufi.signal.read'); + + // Then + expect(plan).toEqual({ status: 'read', operationId: 'ufi.signal.read' }); + expect(spy.calls).toEqual([]); + }); +}); + +describe('prohibited operations driven through the operation engine', () => { + test('every forbidden id is refused before any transport activity', async () => { + // Given — an ADVERSARIAL execution: its execute() would dial the device, so a + // single recorded call proves the engine ran it. The descriptor is the shipped, + // inert one; nothing in the package can supply a matching execute. + const spy = spyTransport(); + const harness = createOperationEngineHarness(); + disposals.push(harness.dispose); + const attempt = ( + operationId: UfiProhibitedOperationId, + ): OperationExecution => ({ + operationId, + physicalModemId: FENCE_MODEM, + descriptor: ufiProhibitionDescriptor(operationId), + input: undefined, + execute: async () => { + await spy.transport.request({ + method: 'POST', + url: 'http://192.168.0.1/himiapi/json', + command: 'getsysinfo', + body: '{"cmdid":"getsysinfo"}', + headers: [], + interfaceName: 'usb0', + redirect: 'error', + }); + return { status: 'failed', reason: 'prohibited-operation-must-never-execute' }; + }, + }); + + // When + const results = await Promise.all( + UFI_PROHIBITED_OPERATION_IDS.map((operationId) => + harness.engine.invoke(attempt(operationId)), + ), + ); + + // Then + expect( + results.map((result) => ({ + status: result.status, + reason: 'reason' in result ? result.reason : null, + })), + ).toEqual( + UFI_PROHIBITED_OPERATION_IDS.map((operationId) => ({ + status: 'refused', + reason: UFI_PROHIBITED_OPERATIONS[operationId].reason, + })), + ); + expect(spy.calls).toEqual([]); + }); + + test('the inert descriptor refuses on three independent fences', () => { + // Given / When + const descriptor = ufiProhibitionDescriptor('firmware.flash'); + + // Then + expect(descriptor.support.read).toEqual({ + supported: false, + reason: 'firmware-flash-prohibited', + }); + expect(descriptor.support.write).toEqual({ + supported: false, + reason: 'firmware-flash-prohibited', + }); + expect(descriptor.availability).toEqual({ + state: 'refused', + reason: 'firmware-flash-prohibited', + }); + expect(descriptor.constraints).toEqual({ kind: 'allowed-values', values: [] }); + }); +}); diff --git a/control/src/providers/ufi-himi/prohibitions.ts b/control/src/providers/ufi-himi/prohibitions.ts new file mode 100644 index 0000000..81bd172 --- /dev/null +++ b/control/src/providers/ufi-himi/prohibitions.ts @@ -0,0 +1,114 @@ +// The Qualcomm/HIMI prohibition table. +// +// These operations have NO IMPLEMENTATION ANYWHERE in this provider — not a refused +// stub, not a disabled branch, not a private helper. The table below is inert DATA: it +// exists so a caller asking for one of these ids gets a NAMED refusal instead of an +// "unknown operation" shrug, and so the fence can be enumerated by a test rather than +// argued for in prose. Nothing here can perform anything. +// +// Why each is prohibited rather than merely gated: +// +// NV / EFS / identity / calibration writes — they rewrite the modem's own persistent +// storage. A bad write bricks the radio, forges an IMEI, or destroys the factory RF +// calibration, and none of the three is recoverable from this device's userspace. +// Identity writes are additionally illegal in most jurisdictions. +// Firmware flashing and EDL automation — an interrupted flash leaves a device that +// enumerates only in emergency-download mode, which needs physical access this +// controller by definition does not have on a deployed board. +// Blind driver / interface retries — re-binding a driver or cycling an interface +// "until it works" is how a transient enumeration fault becomes a reboot loop, and +// it destroys the evidence the next diagnosis needs. +// DIAG writes — the Qualcomm DIAG channel can reach every one of the above. +// The DIAG info probe — READ-only and legitimate, but bench-only and human-supervised +// (docs/UFI-DIAG-PROBE.md). It is not a production operation and never becomes one. +// Shell transport fallback — production never reaches this device over ADB, SSH, +// telnet or DIAG under any circumstance. The HIMI HTTP API is the only transport. + +import { defineOperationDescriptor, type OperationDescriptor } from '../../domain'; + +export type UfiProhibitionClass = + | 'device-integrity' + | 'firmware' + | 'recovery-automation' + | 'diagnostic-channel' + | 'transport-fallback'; + +export const UFI_PROHIBITED_OPERATIONS = { + 'nv.write': { reason: 'nv-write-prohibited', class: 'device-integrity' }, + 'efs.write': { reason: 'efs-write-prohibited', class: 'device-integrity' }, + 'identity.write': { reason: 'identity-write-prohibited', class: 'device-integrity' }, + 'calibration.write': { reason: 'calibration-write-prohibited', class: 'device-integrity' }, + 'firmware.flash': { reason: 'firmware-flash-prohibited', class: 'firmware' }, + 'edl.automation': { reason: 'edl-automation-prohibited', class: 'recovery-automation' }, + 'driver.blind-retry': { reason: 'blind-driver-retry-prohibited', class: 'recovery-automation' }, + 'interface.blind-retry': { + reason: 'blind-interface-retry-prohibited', + class: 'recovery-automation', + }, + 'diag.write': { reason: 'diag-write-prohibited', class: 'diagnostic-channel' }, + 'diag.info-probe': { + reason: 'diag-probe-is-bench-supervised-only', + class: 'diagnostic-channel', + }, + 'shell.transport-fallback': { + reason: 'shell-transport-fallback-prohibited', + class: 'transport-fallback', + }, +} as const satisfies Record< + string, + { readonly reason: string; readonly class: UfiProhibitionClass } +>; + +export type UfiProhibitedOperationId = keyof typeof UFI_PROHIBITED_OPERATIONS; +export type UfiProhibition = (typeof UFI_PROHIBITED_OPERATIONS)[UfiProhibitedOperationId]; +export type UfiProhibitionReason = UfiProhibition['reason']; + +export const UFI_PROHIBITED_OPERATION_IDS = Object.keys( + UFI_PROHIBITED_OPERATIONS, +) as readonly UfiProhibitedOperationId[]; + +export function isUfiProhibitedOperation( + operationId: string, +): operationId is UfiProhibitedOperationId { + return Object.hasOwn(UFI_PROHIBITED_OPERATIONS, operationId); +} + +export function ufiProhibition(operationId: string): UfiProhibition | undefined { + return isUfiProhibitedOperation(operationId) ? UFI_PROHIBITED_OPERATIONS[operationId] : undefined; +} + +/** + * An INERT descriptor, so the same refusal survives a trip through the operation engine. + * + * It declares read AND write unsupported and availability refused, and it constrains + * inputs to the empty set — three independent fences, each sufficient on its own. It + * carries no execute/readback/rollback function because none exists to carry. + */ +export function ufiProhibitionDescriptor( + operationId: UfiProhibitedOperationId, +): OperationDescriptor { + const { reason } = UFI_PROHIBITED_OPERATIONS[operationId]; + return defineOperationDescriptor({ + id: operationId, + support: { + read: { supported: false, reason }, + write: { supported: false, reason }, + }, + authority: 'provider', + provider: 'ufi-himi', + constraints: { kind: 'allowed-values', values: [] }, + livePreconditions: [], + availability: { state: 'refused', reason }, + // `disruptive`, not `write`: every one of these rewrites persistent device state + // or drives a recovery mode. The engine treats any non-read impact as a write + // (queued behind the modem's actor), so the refusal path is identical. + mutationImpact: 'disruptive', + retryClass: 'never', + readback: { required: false }, + rollback: { required: false }, + journal: { required: false }, + admission: { required: false }, + evidence: { profiles: [], firmware: [] }, + confidence: 'high', + }); +} diff --git a/control/src/providers/ufi-himi/provider.test.ts b/control/src/providers/ufi-himi/provider.test.ts new file mode 100644 index 0000000..80ac0f4 --- /dev/null +++ b/control/src/providers/ufi-himi/provider.test.ts @@ -0,0 +1,214 @@ +import { describe, expect, test } from 'bun:test'; +import { UFI_AUTH_EXPIRED_FIXTURE, UFI_FIXTURE } from '../../../test-support/observation-fixtures'; +import { deviceGeneration, physicalModemId } from '../../domain'; +import { createProviderMatcher } from '../matcher'; +import { createProviderRegistry } from '../registry'; +import { UFI_PROFILE } from './operations'; +import { createUfiHimiDefinition } from './provider'; +import { + classifyUfiDiagEvidence, + UFI_FIRMWARE_SPECIFIC_USB_ID, + UFI_RNDIS_ADB_USB_ID, + ufiUsbClaims, +} from './qualcomm-evidence'; +import type { UfiHttpRequest, UfiHttpResponse, UfiTransport } from './transport'; +import { UFI_API_PATH } from './transport'; + +const PASSWORD = 'fixture-ufi-password'; +const ADMIN_URL = 'http://192.168.0.1'; +const SESSION = 'bench-session-token'; + +const context = (usbId: string) => ({ + physicalModemId: physicalModemId('serial:ufi-fixture'), + generation: deviceGeneration(1), + transport: 'network' as const, + passiveFacts: [{ kind: 'usb' as const, value: usbId }], + composition: 'rndis', + profile: UFI_PROFILE, +}); + +function replay(bodies: readonly string[]) { + const calls: UfiHttpRequest[] = []; + const pending = [...bodies]; + const transport: UfiTransport = { + request: async (request): Promise => { + calls.push(request); + const body = pending.shift(); + if (body === undefined) throw new Error('unexpected replay request'); + return { status: 200, body }; + }, + }; + return { calls, transport, remaining: () => pending.length }; +} + +function definition(transport: UfiTransport) { + return createUfiHimiDefinition({ + interfaceName: 'usb0', + adminUrl: ADMIN_URL, + transport, + credentials: { username: 'admin', password: PASSWORD }, + now: () => 1_700_000_000_000, + }); +} + +const loginOk = JSON.stringify({ reply: 'ok', session: SESSION }); + +describe('UFI/HIMI read-only telemetry', () => { + test('normalizes one read cycle from the migrated parsers over a single session', async () => { + // Given + const h = replay([ + loginOk, + UFI_FIXTURE.sysinfo, + UFI_FIXTURE.overview, + UFI_FIXTURE.status, + UFI_FIXTURE.produceInfo ?? '', + ]); + + // When + const [envelope] = await definition(h.transport).observe(context(UFI_RNDIS_ADB_USB_ID)); + + // Then + expect(envelope?.value).toMatchObject({ + source: 'ufi-himiapi', + signal: { dbm: { state: 'known', value: 3 } }, + hardware: { label: { state: 'known', value: 'UFI-M600' } }, + }); + expect(h.calls.map((call) => call.command)).toEqual([ + 'login', + 'getsysinfo', + 'getoverview', + 'getallstatus', + 'getproduceinfo', + ]); + expect(h.calls[0]).toEqual({ + method: 'POST', + url: `${ADMIN_URL}${UFI_API_PATH}`, + command: 'login', + body: JSON.stringify({ cmdid: 'login', username: 'admin', password: PASSWORD }), + headers: ['Content-Type: application/json;charset=UTF-8'], + interfaceName: 'usb0', + redirect: 'error', + }); + expect(h.calls[1]).toEqual({ + method: 'POST', + url: `${ADMIN_URL}${UFI_API_PATH}`, + command: 'getsysinfo', + body: JSON.stringify({ cmdid: 'getsysinfo', sessionId: SESSION }), + headers: [`Authorization: ${SESSION}`, 'Content-Type: application/json;charset=UTF-8'], + interfaceName: 'usb0', + redirect: 'error', + }); + expect(JSON.stringify(envelope)).not.toContain(PASSWORD); + expect(h.remaining()).toBe(0); + }); + + test('reports a refused session honestly and never spends a second login', async () => { + // Given + const h = replay([ + loginOk, + UFI_AUTH_EXPIRED_FIXTURE.sysinfo, + UFI_AUTH_EXPIRED_FIXTURE.overview, + UFI_AUTH_EXPIRED_FIXTURE.status, + JSON.stringify({ reply: 'SessionOut' }), + ]); + const provider = definition(h.transport); + + // When + const [envelope] = await provider.observe(context(UFI_RNDIS_ADB_USB_ID)); + const afterFirstCycle = h.calls.length; + const second = await provider.observe(context(UFI_RNDIS_ADB_USB_ID)); + + // Then + expect(envelope?.value?.signal.dbm).toMatchObject({ + state: 'unknown', + reason: 'auth-expired', + }); + expect(second).toEqual([]); + expect(h.calls).toHaveLength(afterFirstCycle); + expect(h.calls.filter((call) => call.command === 'login')).toHaveLength(1); + }); +}); + +describe('UFI/HIMI provider matching', () => { + test.each([UFI_RNDIS_ADB_USB_ID, UFI_FIRMWARE_SPECIFIC_USB_ID])( + '%s selects the read-only profile with no writable surface', + async (usbId) => { + // Given + const h = replay([loginOk, UFI_FIXTURE.status, UFI_FIXTURE.sysinfo]); + const registry = createProviderRegistry(); + registry.register(definition(h.transport)); + + // When + const result = await createProviderMatcher(registry).match(context(usbId)); + + // Then + expect(result).toMatchObject({ + status: 'selected', + provider: 'ufi-himi', + profile: UFI_PROFILE, + writable: false, + operations: { access: 'read-only', diagAccess: 'prohibited' }, + }); + expect(h.calls.filter((call) => call.command === 'login')).toHaveLength(1); + }, + ); +}); + +describe('Qualcomm composition evidence', () => { + test('05c6:9024 claims RNDIS and an ADB interface, and nothing more', () => { + // Given / When + const claims = ufiUsbClaims(UFI_RNDIS_ADB_USB_ID); + + // Then + expect(claims).toEqual(['rndis-network', 'adb-interface']); + }); + + test('05c6:9091 is firmware-specific and is not evidence of a DIAG channel', () => { + // Given / When + const evidence = classifyUfiDiagEvidence({ + usbId: UFI_FIRMWARE_SPECIFIC_USB_ID, + interfaces: [], + }); + + // Then + expect(ufiUsbClaims(UFI_FIRMWARE_SPECIFIC_USB_ID)).toEqual([]); + expect(evidence).toEqual({ state: 'not-proven', reason: 'product-id-is-not-evidence' }); + }); + + test('an enumerated composition without a DIAG descriptor stays not-proven', () => { + // Given / When + const evidence = classifyUfiDiagEvidence({ + usbId: UFI_RNDIS_ADB_USB_ID, + interfaces: [ + { number: 0, interfaceClass: 0xe0, interfaceSubClass: 0x01, interfaceProtocol: 0x03 }, + { number: 2, interfaceClass: 0xff, interfaceSubClass: 0x42, interfaceProtocol: 0x01 }, + ], + }); + + // Then + expect(evidence).toEqual({ state: 'not-proven', reason: 'no-diag-interface-descriptor' }); + }); + + test('a confirmed DIAG descriptor still leaves production access prohibited', async () => { + // Given + const h = replay([]); + const evidence = classifyUfiDiagEvidence({ + usbId: UFI_RNDIS_ADB_USB_ID, + interfaces: [ + { number: 3, interfaceClass: 0xff, interfaceSubClass: 0xff, interfaceProtocol: 0x30 }, + ], + }); + + // When + const operations = definition(h.transport).operations(UFI_PROFILE); + + // Then + expect(evidence).toEqual({ state: 'descriptor-confirmed', interfaceNumber: 3 }); + expect(operations.diagAccess).toBe('prohibited'); + expect(operations.reads.map((entry) => entry.descriptor.id)).toEqual([ + 'ufi.signal.read', + 'ufi.details.read', + ]); + expect(h.calls).toEqual([]); + }); +}); diff --git a/control/src/providers/ufi-himi/provider.ts b/control/src/providers/ufi-himi/provider.ts new file mode 100644 index 0000000..d005866 --- /dev/null +++ b/control/src/providers/ufi-himi/provider.ts @@ -0,0 +1,254 @@ +// Qualcomm UFI / HIMI — a READ-ONLY provider, by construction rather than by policy. +// +// This provider normalizes the telemetry the pure parsers already own +// (`hardware/router-parsers.ts` → `observations/sources/ufi.ts`, migrated in todo 17). +// It adds a session and a transport around them and nothing else: there is no write +// descriptor, no write command in the transport vocabulary, and no operation id outside +// the closed read set that resolves to anything but a typed refusal. +// +// The Qualcomm safety model it encodes lives in two neighbouring modules — +// `qualcomm-evidence.ts` (what a USB id proves) and `prohibitions.ts` (what may never be +// attempted). Both are inert data; neither can act. + +import type { ObservationEnvelope } from '../../domain'; +import { epochMillis, sourceEpoch, stableKeyFromPhysicalModemId } from '../../domain'; +import { parseUfiDetails, parseUfiSignal } from '../../hardware/router-parsers'; +import type { NormalizedModemObservation } from '../../observations'; +import { normalizeUfiObservation } from '../../observations/sources/ufi'; +import type { + CapabilityReader, + ProviderDefinition, + ProviderExecutionContext, + ProviderMatchRequest, + ProviderOperationsSurface, +} from '../contracts'; +import { + planUfiOperation, + UFI_PROFILE, + type UfiOperationPlan, + type UfiReadOperation, + type UfiReadValue, + ufiReadDescriptor, + ufiReadResult, + ufiRefusedResult, +} from './operations'; +import { UFI_PROHIBITED_OPERATION_IDS, type UfiProhibitedOperationId } from './prohibitions'; +import { UFI_DIAG_PRODUCTION_ACCESS, UFI_MATCHED_USB_IDS } from './qualcomm-evidence'; +import { parseUfiReply, UFI_SESSION_REFUSAL, type UfiOptions, UfiSessionRuntime } from './session'; +import { UFI_API_PATH, type UfiReadCommand } from './transport'; + +export type { UfiOptions } from './session'; + +/** One read cycle's bodies, keyed by the command that produced them. */ +type UfiBodies = Partial>; + +export type UfiOperationsSurface = ProviderOperationsSurface & { + readonly access: 'read-only'; + readonly reads: readonly UfiReadOperation[]; + readonly prohibited: readonly UfiProhibitedOperationId[]; + readonly diagAccess: typeof UFI_DIAG_PRODUCTION_ACCESS; + readonly plan: (operationId: string) => UfiOperationPlan; +}; + +export class UfiHimiProvider extends UfiSessionRuntime { + async fingerprint(request: ProviderMatchRequest) { + // The HIMI fingerprint needs a SESSION to read anything, so this probe is the one + // place a "harmless unauthenticated probe" would spend the provider's single + // bounded login. On a device that enumerated a USB id and it is not a HIMI id, + // that login would be aimed at somebody else's dongle — a credential attempt + // against hardware this provider has positive evidence it does not own. An + // ABSENT usb fact is still probed (nobody enumerated one; that is not a denial). + if (!this.usbEvidencePermitsProbe(request)) { + return { + signal: 'unknown' as const, + strength: 'strong' as const, + profiles: [] as readonly string[], + detail: 'usb-id-is-not-himi', + }; + } + const bodies = await this.fetch(request, ['getallstatus']); + const reply = parseUfiReply(bodies?.getallstatus ?? ''); + const matched = reply?.reply === 'ok' && reply.params !== undefined; + return { + signal: matched ? ('match' as const) : ('unknown' as const), + strength: 'strong' as const, + profiles: matched ? [UFI_PROFILE] : [], + detail: matched ? 'himi-json-shape' : 'himi-json-not-proven', + }; + } + + capability(): CapabilityReader { + return { + id: 'ufi-himi-telemetry', + read: async (context) => { + const bodies = await this.fetch(context, ['getsysinfo']); + const reply = parseUfiReply(bodies?.getsysinfo ?? ''); + const supported = reply?.reply === 'ok' && reply.params !== undefined; + return { + signal: supported ? 'match' : 'mismatch', + strength: 'strong', + detail: supported ? 'telemetry-reported' : 'telemetry-refused', + }; + }, + }; + } + + async observe( + context: ProviderExecutionContext, + ): Promise[]> { + const bodies = await this.fetch(context, [ + 'getsysinfo', + 'getoverview', + 'getallstatus', + 'getproduceinfo', + ]); + if (bodies === undefined) return []; + const produceInfo = bodies.getproduceinfo; + return [ + normalizeUfiObservation( + { + sysinfo: bodies.getsysinfo ?? '', + overview: bodies.getoverview ?? '', + status: bodies.getallstatus ?? '', + ...(produceInfo === undefined ? {} : { produceInfo }), + }, + { + stableKey: stableKeyFromPhysicalModemId(context.physicalModemId), + generation: context.generation, + sourceEpoch: sourceEpoch(1), + observedAt: epochMillis(this.observedAt()), + }, + ), + ]; + } + + operations(_profile: string): UfiOperationsSurface { + return { + access: 'read-only', + reads: [ + { + descriptor: ufiReadDescriptor('ufi.signal.read'), + read: (context) => this.readSignal(context), + }, + { + descriptor: ufiReadDescriptor('ufi.details.read'), + read: (context) => this.readDetails(context), + }, + ], + prohibited: UFI_PROHIBITED_OPERATION_IDS, + diagAccess: UFI_DIAG_PRODUCTION_ACCESS, + plan: planUfiOperation, + }; + } + + private async readSignal(context: ProviderExecutionContext) { + const bodies = await this.fetch(context, ['getsysinfo', 'getoverview', 'getallstatus']); + if (bodies === undefined) return ufiRefusedResult(context.generation, 'session-unavailable'); + const value: UfiReadValue = parseUfiSignal({ + sysinfo: bodies.getsysinfo ?? '', + overview: bodies.getoverview ?? '', + status: bodies.getallstatus ?? '', + }); + return ufiReadResult(context.generation, value); + } + + private async readDetails(context: ProviderExecutionContext) { + const bodies = await this.fetch(context, ['getoverview', 'getsysinfo', 'getproduceinfo']); + if (bodies === undefined) return ufiRefusedResult(context.generation, 'session-unavailable'); + const details = parseUfiDetails({ + overview: bodies.getoverview ?? '', + sysinfo: bodies.getsysinfo ?? '', + produceInfo: bodies.getproduceinfo ?? '', + }); + return details === undefined + ? ufiRefusedResult(context.generation, 'not-reported') + : ufiReadResult(context.generation, details); + } + + private usbEvidencePermitsProbe(request: ProviderMatchRequest): boolean { + const enumerated = request.passiveFacts.filter((fact) => fact.kind === 'usb'); + return ( + enumerated.length === 0 || + enumerated.some((fact) => UFI_MATCHED_USB_IDS.some((id) => id === fact.value)) + ); + } + + /** + * Reads are issued sequentially on ONE session. A `SessionOut` in any body drops the + * cached session so the NEXT generation may spend its own single login; it never + * re-authenticates inside this cycle. + */ + private async fetch( + request: ProviderMatchRequest, + commands: readonly UfiReadCommand[], + ): Promise { + const session = await this.session(request); + if (session === undefined) return undefined; + const bodies: UfiBodies = {}; + for (const command of commands) { + const response = await this.read(command, session); + bodies[command] = response.status === 200 ? response.body : ''; + } + const refused = Object.values(bodies).some( + (body) => parseUfiReply(body)?.reply === UFI_SESSION_REFUSAL, + ); + if (refused) this.forgetSession(request); + return bodies; + } +} + +export function createUfiHimiDefinition( + options: UfiOptions, +): ProviderDefinition { + const runtime = new UfiHimiProvider(options); + return { + id: 'ufi-himi', + profileVersion: '1', + eligibleTransports: ['network'], + passiveMatchers: [ + { + // ONE matcher over both ids. `9024` is the RNDIS+ADB composition and `9091` is + // a firmware-chosen product id; both identify a HIMI stick, and NEITHER is + // evidence of a DIAG channel (see qualcomm-evidence.ts). + id: 'usb-qualcomm-himi', + fact: 'usb', + expected: UFI_MATCHED_USB_IDS, + profiles: [UFI_PROFILE], + strength: 'strong', + required: true, + }, + ], + unauthenticatedProbes: [ + { id: 'ufi-himi-shape', run: (request) => runtime.fingerprint(request) }, + ], + capabilityReaders: [runtime.capability()], + observe: (context) => runtime.observe(context), + operations: (profile) => runtime.operations(profile), + contractFixtures: [ + { + profile: UFI_PROFILE, + request: { + method: 'POST', + path: UFI_API_PATH, + cmdid: 'login', + credentials: '[redacted]', + interfaceBound: true, + redirects: 'disabled', + }, + response: { reply: 'ok', session: '[redacted]' }, + }, + { + profile: UFI_PROFILE, + request: { + method: 'POST', + path: UFI_API_PATH, + cmdid: 'getsysinfo', + authorization: '[redacted]', + interfaceBound: true, + redirects: 'disabled', + }, + response: { reply: 'ok', params: '[verbatim-diagnostics]' }, + }, + ], + }; +} diff --git a/control/src/providers/ufi-himi/qualcomm-evidence.ts b/control/src/providers/ufi-himi/qualcomm-evidence.ts new file mode 100644 index 0000000..33c7a9c --- /dev/null +++ b/control/src/providers/ufi-himi/qualcomm-evidence.ts @@ -0,0 +1,80 @@ +// What a Qualcomm USB id does and does not prove. +// +// `05c6:9024` is the composition the bench UFI sticks enumerate in: RNDIS networking +// plus an ADB interface. That is EVIDENCE OF A COMPOSITION, not a permission — the ADB +// interface being present is exactly why production must be explicit that it never uses +// it (see `shell.transport-fallback` in prohibitions.ts). +// +// `05c6:9091` is a FIRMWARE-SPECIFIC product id. It is routinely read as "DIAG is +// available", and that reading is wrong: `05c6` is Qualcomm's generic vendor id and the +// product id is chosen by whoever built the firmware image, so it says nothing about +// which interfaces the device actually exposes. Treating it as proof of DIAG is how a +// tool ends up opening a channel that is not there, or worse, one that is there and +// belongs to something else. +// +// The only thing that proves a DIAG channel is a DIAG INTERFACE DESCRIPTOR. And even a +// confirmed one buys nothing in production: `UFI_DIAG_PRODUCTION_ACCESS` is `prohibited` +// unconditionally, and the supervised read-only info probe is a bench runbook +// (docs/UFI-DIAG-PROBE.md), never an operation this package can perform. + +export const UFI_RNDIS_ADB_USB_ID = '05c6:9024'; +export const UFI_FIRMWARE_SPECIFIC_USB_ID = '05c6:9091'; + +export type UfiUsbClaim = 'rndis-network' | 'adb-interface'; + +export const UFI_USB_EVIDENCE = { + [UFI_RNDIS_ADB_USB_ID]: ['rndis-network', 'adb-interface'], + /** Deliberately empty: a firmware-chosen product id claims nothing by itself. */ + [UFI_FIRMWARE_SPECIFIC_USB_ID]: [], +} as const satisfies Record; + +export const UFI_MATCHED_USB_IDS = [UFI_RNDIS_ADB_USB_ID, UFI_FIRMWARE_SPECIFIC_USB_ID] as const; + +export function ufiUsbClaims(usbId: string): readonly UfiUsbClaim[] { + return Object.hasOwn(UFI_USB_EVIDENCE, usbId) + ? UFI_USB_EVIDENCE[usbId as keyof typeof UFI_USB_EVIDENCE] + : []; +} + +/** Qualcomm's DIAG interface: vendor-specific class/subclass with protocol 0x30. */ +const DIAG_INTERFACE_CLASS = 0xff; +const DIAG_INTERFACE_SUBCLASS = 0xff; +const DIAG_INTERFACE_PROTOCOL = 0x30; + +export type UfiUsbInterfaceDescriptor = { + readonly number: number; + readonly interfaceClass: number; + readonly interfaceSubClass: number; + readonly interfaceProtocol: number; +}; + +export type UfiDiagEvidence = + | { readonly state: 'descriptor-confirmed'; readonly interfaceNumber: number } + | { + readonly state: 'not-proven'; + readonly reason: 'product-id-is-not-evidence' | 'no-diag-interface-descriptor'; + }; + +export function classifyUfiDiagEvidence(input: { + readonly usbId: string; + readonly interfaces: readonly UfiUsbInterfaceDescriptor[]; +}): UfiDiagEvidence { + const diag = input.interfaces.find( + (descriptor) => + descriptor.interfaceClass === DIAG_INTERFACE_CLASS && + descriptor.interfaceSubClass === DIAG_INTERFACE_SUBCLASS && + descriptor.interfaceProtocol === DIAG_INTERFACE_PROTOCOL, + ); + if (diag !== undefined) { + return { state: 'descriptor-confirmed', interfaceNumber: diag.number }; + } + return input.interfaces.length === 0 + ? { state: 'not-proven', reason: 'product-id-is-not-evidence' } + : { state: 'not-proven', reason: 'no-diag-interface-descriptor' }; +} + +/** + * Unconditional. A descriptor-confirmed DIAG channel raises what a SUPERVISED BENCH + * operator may attempt by hand; it never raises what this package may do on its own. + */ +export const UFI_DIAG_PRODUCTION_ACCESS = 'prohibited' as const; diff --git a/control/src/providers/ufi-himi/session.ts b/control/src/providers/ufi-himi/session.ts new file mode 100644 index 0000000..63b2811 --- /dev/null +++ b/control/src/providers/ufi-himi/session.ts @@ -0,0 +1,119 @@ +import { z } from 'zod'; +import type { ProviderMatchRequest } from '../contracts'; +import { + UFI_API_PATH, + type UfiCommand, + type UfiHttpResponse, + type UfiReadCommand, + type UfiTransport, +} from './transport'; + +export type UfiCredentials = { readonly username: string; readonly password: string }; + +export type UfiOptions = { + readonly interfaceName: string; + readonly adminUrl: string; + readonly transport: UfiTransport; + /** Ephemeral bench input. Injected by the caller, never persisted, never logged. */ + readonly credentials: UfiCredentials; + readonly now?: () => number; +}; + +/** The HIMI firmware's own word for "your session is gone". */ +export const UFI_SESSION_REFUSAL = 'SessionOut'; + +const flatRecordSchema = z.record(z.string(), z.union([z.string(), z.number()])); +const ufiReplySchema = z.object({ + reply: z.string(), + session: z.string().optional(), + params: flatRecordSchema.optional(), +}); + +export type UfiReply = z.infer; + +export function parseUfiReply(body: string): UfiReply | undefined { + const parsed = z + .string() + .transform((value, context) => { + try { + return JSON.parse(value); + } catch (error) { + if (!(error instanceof SyntaxError)) throw error; + context.addIssue({ code: 'custom', message: 'invalid JSON' }); + return z.NEVER; + } + }) + .pipe(ufiReplySchema) + .safeParse(body); + return parsed.success ? parsed.data : undefined; +} + +/** + * Session acquisition, bounded to ONE login per physical modem per generation. + * + * The bound is a set of spent keys rather than a counter reset on failure: a HIMI + * device that refused a credential refuses it again, and a provider that re-tries on + * every poll turns a wrong password into a lockout nobody asked for. A refused or + * expired session therefore surfaces as an honest auth-expired READING; the next + * enumeration (a new generation) gets the next single attempt. + */ +export class UfiSessionRuntime { + readonly #sessions = new Map(); + readonly #spent = new Set(); + + constructor(protected readonly options: UfiOptions) {} + + protected async session(request: ProviderMatchRequest): Promise { + const key = this.sessionKey(request); + const held = this.#sessions.get(key); + if (held !== undefined) return held; + if (this.#spent.has(key)) return undefined; + this.#spent.add(key); + + const response = await this.request( + 'login', + JSON.stringify({ + cmdid: 'login', + username: this.options.credentials.username, + password: this.options.credentials.password, + }), + ); + const reply = response.status === 200 ? parseUfiReply(response.body) : undefined; + const session = reply?.reply === 'ok' ? reply.session : undefined; + if (session === undefined || session === '') return undefined; + this.#sessions.set(key, session); + return session; + } + + protected read(command: UfiReadCommand, session: string): Promise { + return this.request(command, JSON.stringify({ cmdid: command, sessionId: session }), session); + } + + /** A refused session is dropped so the NEXT generation may spend its own attempt. */ + protected forgetSession(request: ProviderMatchRequest): void { + this.#sessions.delete(this.sessionKey(request)); + } + + protected sessionKey(request: ProviderMatchRequest): string { + return `${request.physicalModemId}:${request.generation}`; + } + + protected observedAt(): number { + return this.options.now?.() ?? Date.now(); + } + + private request(command: UfiCommand, body: string, session?: string): Promise { + return this.options.transport.request({ + method: 'POST', + url: `${this.options.adminUrl}${UFI_API_PATH}`, + command, + body, + headers: [ + ...(session === undefined ? [] : [`Authorization: ${session}`]), + 'Content-Type: application/json;charset=UTF-8', + ], + interfaceName: this.options.interfaceName, + redirect: 'error', + }); + } +} diff --git a/control/src/providers/ufi-himi/transport.ts b/control/src/providers/ufi-himi/transport.ts new file mode 100644 index 0000000..9dfdf6a --- /dev/null +++ b/control/src/providers/ufi-himi/transport.ts @@ -0,0 +1,52 @@ +// Qualcomm UFI / HIMI transport shape. +// +// HIMI is not a REST surface: every call is `POST /himiapi/json` and the VERB lives in +// the JSON body's `cmdid`. So "read-only" cannot be expressed here as an HTTP-method +// restriction the way it can for HiLink. The command vocabulary is frozen as a union +// instead — a request this transport can carry names a member of `UFI_COMMANDS`, and no +// `set*` / flash / reset member exists — which makes a write UNREPRESENTABLE rather than +// merely refused at runtime. + +/** The seven read commands the HIMI firmware answers. Bench-observed, not inferred. */ +export const UFI_READ_COMMANDS = [ + 'getoverview', + 'getsysinfo', + 'getallstatus', + 'getapninfo', + 'getnetworkmode', + 'gethimiusbtether', + 'getproduceinfo', +] as const; +export type UfiReadCommand = (typeof UFI_READ_COMMANDS)[number]; + +/** + * The ONE non-read command. It opens a server-side session and changes nothing on the + * device; the modem's configuration, radio and storage are untouched by it. + */ +export const UFI_SESSION_COMMANDS = ['login'] as const; +export type UfiSessionCommand = (typeof UFI_SESSION_COMMANDS)[number]; + +export const UFI_COMMANDS = [...UFI_READ_COMMANDS, ...UFI_SESSION_COMMANDS] as const; +export type UfiCommand = UfiReadCommand | UfiSessionCommand; + +export const UFI_API_PATH = '/himiapi/json'; + +export type UfiHttpRequest = { + readonly method: 'POST'; + readonly url: string; + /** Frozen vocabulary: the field a write would have to travel in cannot name one. */ + readonly command: UfiCommand; + readonly body: string; + readonly headers: readonly string[]; + readonly interfaceName: string; + readonly redirect: 'error'; +}; + +export type UfiHttpResponse = { + readonly status: number; + readonly body: string; +}; + +export interface UfiTransport { + request(request: UfiHttpRequest): Promise; +} diff --git a/control/src/providers/zte-goform/index.ts b/control/src/providers/zte-goform/index.ts new file mode 100644 index 0000000..c173ea7 --- /dev/null +++ b/control/src/providers/zte-goform/index.ts @@ -0,0 +1,2 @@ +export * from './provider'; +export * from './transport'; diff --git a/control/src/providers/zte-goform/provider.test.ts b/control/src/providers/zte-goform/provider.test.ts new file mode 100644 index 0000000..8d4554a --- /dev/null +++ b/control/src/providers/zte-goform/provider.test.ts @@ -0,0 +1,206 @@ +import { describe, expect, test } from 'bun:test'; +import { createHash } from 'node:crypto'; +import { createProviderMatcher } from '../matcher'; +import { createProviderRegistry } from '../registry'; +import { + createZteGoformDefinition, + ZTE_PATHS, + ZTE_PROFILES, + ZTE_UNKNOWN_PROFILE, +} from './provider'; +import type { ZteHttpRequest, ZteHttpResponse, ZteTransport } from './transport'; + +const PASSWORD = 'fixture-password'; +const ADMIN_URL = 'http://192.168.0.1'; +const context = (profile: string, firmware: string) => ({ + physicalModemId: 'fixture-zte' as never, + generation: 1 as never, + transport: 'network' as const, + passiveFacts: [{ kind: 'firmware' as const, value: firmware }], + composition: 'rndis', + firmware, + profile, +}); + +function replay(responses: readonly ZteHttpResponse[]) { + const calls: ZteHttpRequest[] = []; + const pending = [...responses]; + const transport: ZteTransport = { + request: async (request) => { + calls.push(request); + const response = pending.shift(); + if (response === undefined) throw new Error('unexpected replay request'); + return response; + }, + }; + return { calls, transport, remaining: () => pending.length }; +} + +function definition(transport: ZteTransport) { + return createZteGoformDefinition({ + interfaceName: 'eth9', + adminUrl: ADMIN_URL, + transport, + credentials: { username: 'admin', password: PASSWORD }, + }); +} + +const response = (body: string, headers?: Readonly>): ZteHttpResponse => ({ + status: 200, + body, + ...(headers === undefined ? {} : { headers }), +}); + +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]; + + // When + const result = await definition(h.transport).authenticatedProfile?.authenticate( + context(profile.id, profile.firmware), + [profile.id], + ); + + // Then + expect(result).toEqual({ status: 'matched', profile: profile.id, detail: 'login-ok' }); + expect(h.calls).toEqual([ + { + method: 'POST', + url: `${ADMIN_URL}${ZTE_PATHS.set}`, + body: `goformId=LOGIN&isTest=false&password=${encodeURIComponent(Buffer.from(PASSWORD).toString('base64'))}`, + headers: [ + 'Content-Type: application/x-www-form-urlencoded; charset=UTF-8', + `Origin: ${ADMIN_URL}`, + `Referer: ${ADMIN_URL}/index.html`, + ], + interfaceName: 'eth9', + redirect: 'error', + }, + ]); + 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'; + const rd = 'fixture-rd'; + const waVersion = 'BD_MF266V1.0.0B01'; + const crVersion = 'CR_MF266V1.0.0B01'; + const password = createHash('sha256') + .update(`${createHash('sha256').update(PASSWORD).digest('hex').toUpperCase()}${ld}`) + .digest('hex') + .toUpperCase(); + const ad = createHash('sha256') + .update( + `${createHash('sha256').update(`${waVersion}${crVersion}`).digest('hex').toUpperCase()}${rd}`, + ) + .digest('hex') + .toUpperCase(); + const h = replay([ + response(`{"LD":"${ld}"}`), + 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]; + + // When + const result = await definition(h.transport).authenticatedProfile?.authenticate( + context(profile.id, profile.firmware), + [profile.id], + ); + + // Then + expect(result).toEqual({ status: 'matched', profile: profile.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`, + body: undefined, + headers: [`Origin: ${ADMIN_URL}`, `Referer: ${ADMIN_URL}/index.html`], + }, + { + method: 'POST', + url: `${ADMIN_URL}${ZTE_PATHS.set}`, + body: `goformId=LOGIN_MULTI_USER&isTest=false&password=${password}&IP=localhost&user=admin`, + headers: [ + 'Content-Type: application/x-www-form-urlencoded; charset=UTF-8', + `Origin: ${ADMIN_URL}`, + `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`, + body: undefined, + headers: [ + `Cookie: stok=salted-token`, + `Origin: ${ADMIN_URL}`, + `Referer: ${ADMIN_URL}/index.html`, + ], + }, + ]); + expect(definition(h.transport).contractFixtures[1]?.response).toEqual({ + status: 'matched', + sessionMaterial: '[redacted]', + ad: '[redacted]', + }); + expect(JSON.stringify(result)).not.toContain(ad); + expect(h.remaining()).toBe(0); + }); + + test('refuses MF266 responses for MF79U without trying a second algorithm', async () => { + // Given + const h = replay([response('{"LD":"salted-shape"}')]); + const profile = ZTE_PROFILES[0]; + + // When + const result = await definition(h.transport).authenticatedProfile?.authenticate( + context(profile.id, profile.firmware), + [profile.id], + ); + + // Then + expect(result).toEqual({ status: 'refused', detail: 'protocol-mismatch' }); + expect(h.calls).toHaveLength(1); + }); + + test('selects unknown ZTE firmware as read-only without authenticating', async () => { + // Given + const h = replay([ + response('{"cr_version":"unknown-zte-build","network_type":"LTE"}'), + response('{"network_type":"LTE","signalbar":"3"}'), + ]); + const registry = createProviderRegistry(); + registry.register(definition(h.transport)); + + // When + const result = await createProviderMatcher(registry).match( + context(ZTE_UNKNOWN_PROFILE, 'unknown-zte-build'), + ); + + // Then + expect(result).toMatchObject({ + status: 'selected', + provider: 'zte-goform', + profile: ZTE_UNKNOWN_PROFILE, + writable: false, + operations: { access: 'read-only' }, + }); + expect(h.calls.every((call) => call.method === 'GET')).toBe(true); + }); +}); diff --git a/control/src/providers/zte-goform/provider.ts b/control/src/providers/zte-goform/provider.ts new file mode 100644 index 0000000..ef9e71a --- /dev/null +++ b/control/src/providers/zte-goform/provider.ts @@ -0,0 +1,147 @@ +import type { ObservationEnvelope } from '../../domain'; +import type { NormalizedModemObservation } from '../../observations'; +import { normalizeZteObservation } from '../../observations/sources/zte'; +import type { + CapabilityReader, + ProviderDefinition, + ProviderExecutionContext, + ProviderMatchRequest, + ProviderOperationsSurface, +} from '../contracts'; +import { parseZteRecord, ZteSessionRuntime } from './session'; +import type { ZteTransport } from './transport'; + +export const ZTE_PATHS = { + get: '/goform/goform_get_cmd_process', + set: '/goform/goform_set_cmd_process', +} as const; + +export type ZteProfile = { + readonly id: 'mf79u-legacy' | 'mf266-salted'; + readonly firmware: 'MF79U' | 'MF266'; + readonly algorithm: 'legacy-base64' | 'salted-sha256'; +}; + +export const ZTE_PROFILES = [ + { id: 'mf79u-legacy', firmware: 'MF79U', algorithm: 'legacy-base64' }, + { id: 'mf266-salted', firmware: 'MF266', algorithm: 'salted-sha256' }, +] as const satisfies readonly ZteProfile[]; +export const ZTE_UNKNOWN_PROFILE = 'zte-unknown-read-only'; + +export type ZteCredentials = { readonly username: string; readonly password: string }; +export type ZteOptions = { + readonly interfaceName: string; + readonly adminUrl: string; + readonly transport: ZteTransport; + readonly credentials: ZteCredentials; + readonly now?: () => number; +}; + +type ZteOperations = ProviderOperationsSurface; + +export function zteProfileForFirmware(firmware: string | undefined): ZteProfile | undefined { + return ZTE_PROFILES.find((profile) => profile.firmware === firmware); +} + +export function zteProfileById(profileId: string): ZteProfile | undefined { + return ZTE_PROFILES.find((profile) => profile.id === profileId); +} + +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; + return { + signal: matches ? ('match' as const) : ('unknown' as const), + strength: 'strong' as const, + profiles: matches ? [profile?.id ?? ZTE_UNKNOWN_PROFILE] : [], + detail: matches ? 'zte-goform-shape' : 'zte-goform-not-proven', + }; + } + + capability(): CapabilityReader { + return { + id: 'zte-telemetry', + read: async () => { + const response = await this.get('network_type,signalbar,rssi,lte_rsrp,lte_rsrq,lte_snr'); + const supported = response.status === 200 && parseZteRecord(response.body) !== undefined; + return { + signal: supported ? 'match' : 'mismatch', + strength: 'strong', + detail: supported ? 'telemetry-reported' : 'telemetry-refused', + }; + }, + }; + } + + async observe( + context: ProviderExecutionContext, + ): Promise[]> { + const response = await this.get( + 'network_type,signalbar,rssi,lte_rsrp,lte_rsrq,lte_snr,network_provider,cell_id', + this.sessionCookie(context), + ); + if (response.status !== 200) return []; + return [ + normalizeZteObservation( + { body: response.body }, + { + stableKey: `modem:${this.options.interfaceName}` as never, + generation: context.generation, + sourceEpoch: 1 as never, + observedAt: (this.options.now?.() ?? Date.now()) as never, + }, + ), + ]; + } + + operations(_profile: string): ZteOperations { + return { access: 'read-only' }; + } +} + +export function createZteGoformDefinition( + options: ZteOptions, +): ProviderDefinition { + const runtime = new ZteGoformProvider(options); + return { + id: 'zte-goform', + profileVersion: '1', + eligibleTransports: ['network'], + passiveMatchers: ZTE_PROFILES.map((profile) => ({ + id: `firmware-${profile.firmware}`, + fact: 'firmware', + expected: [profile.firmware], + profiles: [profile.id], + strength: 'strong', + required: true, + })), + unauthenticatedProbes: [ + { id: 'zte-goform-shape', run: (request) => runtime.fingerprint(request) }, + ], + authenticatedProfile: { + algorithm: 'firmware-selected-zte-goform', + attemptLimit: 1, + authenticate: (request, candidates) => runtime.authenticateProfile(request, candidates), + }, + capabilityReaders: [runtime.capability()], + observe: (context) => runtime.observe(context), + operations: (profile) => runtime.operations(profile), + contractFixtures: ZTE_PROFILES.map((profile) => ({ + profile: profile.id, + request: { + method: 'POST', + path: ZTE_PATHS.set, + goformId: profile.algorithm === 'legacy-base64' ? 'LOGIN' : 'LOGIN_MULTI_USER', + interfaceBound: true, + redirects: 'disabled', + }, + response: + profile.algorithm === 'salted-sha256' + ? { status: 'matched', sessionMaterial: '[redacted]', ad: '[redacted]' } + : { status: 'matched', sessionMaterial: '[redacted]' }, + })), + }; +} diff --git a/control/src/providers/zte-goform/secret-fence.test.ts b/control/src/providers/zte-goform/secret-fence.test.ts new file mode 100644 index 0000000..3643797 --- /dev/null +++ b/control/src/providers/zte-goform/secret-fence.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, test } from 'bun:test'; +import { createHash } from 'node:crypto'; + +function candidateSecret(): string { + return process.env.MF79U_BENCH_PASSWORD ?? ['ceralive', 'mf79u', 'secret', 'scan'].join('-'); +} + +describe('MF79U credential fence', () => { + test('keeps the ephemeral password and its derivatives out of tracked or intended files', async () => { + // Given + const password = candidateSecret(); + const forbidden = [ + password, + Buffer.from(password).toString('base64'), + createHash('sha256').update(password).digest('hex'), + ]; + const tracked = Bun.spawn( + ['git', 'ls-files', '--cached', '--others', '--exclude-standard', '-z'], + { stdout: 'pipe', stderr: 'pipe' }, + ); + + // When + const [exitCode, names] = await Promise.all([ + tracked.exited, + new Response(tracked.stdout).text(), + ]); + expect(exitCode).toBe(0); + const files = names.split('\0').filter((name) => name.length > 0); + const leaks: string[] = []; + for (const file of files) { + const content = await Bun.file(file).text(); + if (forbidden.some((value) => value.length > 0 && content.includes(value))) leaks.push(file); + } + + // Then + expect(leaks).toEqual([]); + }); +}); diff --git a/control/src/providers/zte-goform/session.ts b/control/src/providers/zte-goform/session.ts new file mode 100644 index 0000000..54297e4 --- /dev/null +++ b/control/src/providers/zte-goform/session.ts @@ -0,0 +1,177 @@ +import { createHash } from 'node:crypto'; +import { z } from 'zod'; +import type { AuthenticatedProfileResult, ProviderMatchRequest } from '../contracts'; +import { + ZTE_PATHS, + ZTE_UNKNOWN_PROFILE, + type ZteOptions, + type ZteProfile, + zteProfileById, + zteProfileForFirmware, +} from './provider'; +import type { ZteHttpResponse } from './transport'; + +type ZteSession = { readonly cookie: string; readonly ad?: string }; +const flatRecordSchema = z.record(z.string(), z.union([z.string(), z.number()])); + +export function parseZteRecord( + body: string, +): Readonly> | undefined { + const result = z + .string() + .transform((value, context) => { + try { + return JSON.parse(value); + } catch (error) { + if (!(error instanceof SyntaxError)) throw error; + context.addIssue({ code: 'custom', message: 'invalid JSON' }); + return z.NEVER; + } + }) + .pipe(flatRecordSchema) + .safeParse(body); + return result.success ? result.data : undefined; +} + +function stokCookie(response: ZteHttpResponse): string | undefined { + const expected = 'set-cookie'; + const header = Object.entries(response.headers ?? {}).find( + ([key]) => key.toLowerCase() === expected, + )?.[1]; + const cookie = header?.split(';', 1)[0]?.trim(); + return cookie?.startsWith('stok=') ? cookie : undefined; +} + +function sha256(value: string): string { + return createHash('sha256').update(value).digest('hex').toUpperCase(); +} + +export class ZteSessionRuntime { + readonly #sessions = new Map(); + + constructor(protected readonly options: ZteOptions) {} + + async authenticateProfile( + request: ProviderMatchRequest, + candidates: readonly string[], + ): Promise { + const candidate = candidates.length === 1 ? candidates[0] : undefined; + if (candidate === ZTE_UNKNOWN_PROFILE) { + return { status: 'matched', profile: ZTE_UNKNOWN_PROFILE, detail: 'read-only-fingerprint' }; + } + const profile = zteProfileById(candidate ?? ''); + if (profile === undefined || zteProfileForFirmware(request.firmware)?.id !== profile.id) { + return { status: 'refused', detail: 'profile-mismatch' }; + } + return profile.algorithm === 'legacy-base64' + ? this.loginLegacy(request, profile) + : this.loginSalted(request, profile); + } + + protected sessionCookie(request: ProviderMatchRequest): string | undefined { + return this.#sessions.get(this.sessionKey(request))?.cookie; + } + + protected get(cmd: string, cookie?: string, multiData = false): Promise { + const query = new URLSearchParams({ isTest: 'false', cmd }); + if (multiData) query.set('multi_data', '1'); + return this.request('GET', `${ZTE_PATHS.get}?${query.toString()}`, undefined, cookie); + } + + private async loginLegacy( + request: ProviderMatchRequest, + profile: ZteProfile, + ): Promise { + const response = await this.post( + new URLSearchParams({ + goformId: 'LOGIN', + isTest: 'false', + password: Buffer.from(this.options.credentials.password).toString('base64'), + }).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 }); + return { status: 'matched', profile: profile.id, detail: 'login-ok' }; + } + + private async loginSalted( + request: ProviderMatchRequest, + profile: ZteProfile, + ): Promise { + const ldResponse = await this.get('LD'); + const ld = parseZteRecord(ldResponse.body)?.LD; + if (ldResponse.status !== 200 || typeof ld !== 'string') { + return { status: 'refused', detail: 'protocol-mismatch' }; + } + const login = await this.post( + new URLSearchParams({ + goformId: 'LOGIN_MULTI_USER', + isTest: 'false', + password: sha256(`${sha256(this.options.credentials.password)}${ld}`), + IP: 'localhost', + 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; + if (typeof waVersion !== 'string' || typeof crVersion !== 'string' || typeof rd !== 'string') { + return { status: 'refused', detail: 'protocol-mismatch' }; + } + this.#sessions.set(this.sessionKey(request), { + cookie, + ad: sha256(`${sha256(`${waVersion}${crVersion}`)}${rd}`), + }); + return { status: 'matched', profile: profile.id, detail: 'login-ok' }; + } + + private sessionKey(request: ProviderMatchRequest): string { + return `${request.physicalModemId}:${request.generation}`; + } + + private post(body: string): Promise { + return this.request('POST', ZTE_PATHS.set, body); + } + + private request( + method: 'GET' | 'POST', + path: string, + body?: string, + cookie?: string, + ): Promise { + const headers = [ + ...(method === 'POST' + ? ['Content-Type: application/x-www-form-urlencoded; charset=UTF-8'] + : []), + ...(cookie === undefined ? [] : [`Cookie: ${cookie}`]), + `Origin: ${this.options.adminUrl}`, + `Referer: ${this.options.adminUrl}/index.html`, + ]; + return this.options.transport.request({ + method, + url: `${this.options.adminUrl}${path}`, + ...(body === undefined ? {} : { body }), + headers, + interfaceName: this.options.interfaceName, + redirect: 'error', + }); + } +} diff --git a/control/src/providers/zte-goform/transport.ts b/control/src/providers/zte-goform/transport.ts new file mode 100644 index 0000000..a5928b0 --- /dev/null +++ b/control/src/providers/zte-goform/transport.ts @@ -0,0 +1,18 @@ +export type ZteHttpRequest = { + readonly method: 'GET' | 'POST'; + readonly url: string; + readonly body?: string; + readonly headers: readonly string[]; + readonly interfaceName: string; + readonly redirect: 'error'; +}; + +export type ZteHttpResponse = { + readonly status: number; + readonly body: string; + readonly headers?: Readonly>; +}; + +export interface ZteTransport { + request(request: ZteHttpRequest): Promise; +} diff --git a/control/src/radio/band-truth.test.ts b/control/src/radio/band-truth.test.ts new file mode 100644 index 0000000..41196dc --- /dev/null +++ b/control/src/radio/band-truth.test.ts @@ -0,0 +1,180 @@ +// The band-write descriptor and its certification gate. +// +// This suite proves the WIRING, not a second gate: `band/certification.ts` already +// owns the four-proof catalog and `offerableBands`, and every decision here is read +// out of it. What is asserted is that a band write can only ever reach a consumer as +// a `disruptive`, readback-required, certification-gated operation — and that the +// shipped (empty) catalog therefore refuses every device on the fleet today. + +import { describe, expect, test } from 'bun:test'; + +import { + BAND_CERTIFICATION_CATALOG, + type BandCertificationEntry, + findBandCertification, +} from '../band'; +import type { ModemBands } from '../ports'; +import { + BAND_NONE_OFFERABLE_REFUSAL, + BAND_WRITE_OPERATION_ID, + BAND_WRITE_REFUSAL, + bandWriteReadbackMatches, + buildBandWriteDescriptor, + describeBandWriteCertification, +} from './band-truth'; + +/** The bench Quectel's advertised bands, as the conformance fixtures spell them. */ +const SUPPORTED = ['eutran-3', 'eutran-7', 'ngran-78']; + +const SWEPT_ENTRY: BandCertificationEntry = { + vidPid: '2c7c:0801', + model: 'RM530N-GL', + firmwarePrefix: 'RM530NGLAAR11A02', + evidence: 'docs/BENCH.md RB-11 (fixture only)', + proofs: { supportedRead: true, set: true, readback: true, reset: true }, + provenBands: [], +}; + +const NARROWED_ENTRY: BandCertificationEntry = { ...SWEPT_ENTRY, provenBands: ['eutran-3'] }; + +const descriptorFor = (entry: BandCertificationEntry | undefined, supported = SUPPORTED) => + buildBandWriteDescriptor({ + provider: 'modemmanager', + profile: 'generic-mm', + certification: describeBandWriteCertification({ entry, supported }), + readSupported: true, + }); + +describe('the shipped catalog is empty, so every device is refused today', () => { + test('no fleet SKU resolves to an entry', () => { + expect(BAND_CERTIFICATION_CATALOG.entries).toEqual([]); + expect( + findBandCertification(BAND_CERTIFICATION_CATALOG, { + vidPid: '2c7c:0801', + model: 'RM530N-GL', + firmwarePrefix: 'RM530NGLAAR11A02M4G', + }), + ).toBeUndefined(); + }); + + test('an uncertified device refuses the write in support AND availability', () => { + const descriptor = descriptorFor(undefined); + expect(descriptor.support.write).toEqual({ supported: false, reason: BAND_WRITE_REFUSAL }); + expect(descriptor.availability).toEqual({ state: 'refused', reason: BAND_WRITE_REFUSAL }); + }); + + test('an uncertified device offers NO bands even though the modem advertises three', () => { + const certification = describeBandWriteCertification({ + entry: undefined, + supported: SUPPORTED, + }); + expect(certification.satisfied).toBe(false); + expect(certification.offerable).toEqual([]); + }); + + test('reading bands stays available on an uncertified device', () => { + expect(descriptorFor(undefined).support.read).toEqual({ supported: true }); + }); +}); + +describe('certification is REQUIRED, structurally', () => { + test('every certification decision requires certification, certified or not', () => { + for (const entry of [undefined, SWEPT_ENTRY, NARROWED_ENTRY]) { + expect(describeBandWriteCertification({ entry, supported: SUPPORTED }).required).toBe(true); + } + }); + + test('a swept entry offers the whole advertised set', () => { + expect(describeBandWriteCertification({ entry: SWEPT_ENTRY, supported: SUPPORTED })).toEqual({ + required: true, + satisfied: true, + reason: 'band-certification-proven', + offerable: SUPPORTED, + }); + }); + + test('a narrowed entry offers only its proven bands', () => { + expect( + describeBandWriteCertification({ entry: NARROWED_ENTRY, supported: SUPPORTED }).offerable, + ).toEqual(['eutran-3']); + }); + + test('a certified band the modem no longer advertises is not offerable', () => { + const certification = describeBandWriteCertification({ + entry: NARROWED_ENTRY, + supported: ['eutran-7'], + }); + expect(certification.offerable).toEqual([]); + expect(descriptorFor(NARROWED_ENTRY, ['eutran-7']).availability).toEqual({ + state: 'refused', + reason: BAND_NONE_OFFERABLE_REFUSAL, + }); + }); +}); + +describe('the descriptor carries the disruptive class and its readback', () => { + const descriptor = descriptorFor(SWEPT_ENTRY); + + test('mutation impact is `disruptive`, never `write`', () => { + expect(descriptor.mutationImpact).toBe('disruptive'); + expect(descriptor.mutationImpact).not.toBe('write'); + expect(descriptor.id).toBe(BAND_WRITE_OPERATION_ID); + }); + + test('the write is journalled, admitted and never auto-retried', () => { + expect(descriptor.journal).toEqual({ required: true, reason: 'disruptive-radio-write' }); + expect(descriptor.admission).toEqual({ required: true, reason: 'provider-mutation' }); + expect(descriptor.retryClass).toBe('never'); + }); + + test('the certification precondition is named in `livePreconditions`', () => { + expect(descriptor.livePreconditions).toContain('band-certification-present'); + }); + + test('the offered values are exactly what the catalog proves', () => { + expect(descriptor.constraints).toEqual({ kind: 'allowed-values', values: [SUPPORTED] }); + }); + + test('readback is required and refuses a partially-applied lock', () => { + expect(descriptor.readback.required).toBe(true); + if (!descriptor.readback.required) return; + const observed: ModemBands = { supported: SUPPORTED, current: ['eutran-3'] }; + expect(descriptor.readback.matches(['eutran-3'], observed)).toBe(true); + expect(descriptor.readback.matches(['eutran-3', 'eutran-7'], observed)).toBe(false); + }); +}); + +describe('readback semantics', () => { + test('a narrowing lock must match exactly — a superset is a different lock', () => { + expect( + bandWriteReadbackMatches(['eutran-3'], { + supported: SUPPORTED, + current: ['eutran-3', 'eutran-7'], + }), + ).toBe(false); + }); + + test('order does not matter for an exact set', () => { + expect( + bandWriteReadbackMatches(['eutran-7', 'eutran-3'], { + supported: SUPPORTED, + current: ['eutran-3', 'eutran-7'], + }), + ).toBe(true); + }); + + test('a reset is confirmed by `any` OR by the whole supported set', () => { + expect(bandWriteReadbackMatches(['any'], { supported: SUPPORTED, current: ['any'] })).toBe( + true, + ); + expect(bandWriteReadbackMatches(['any'], { supported: SUPPORTED, current: SUPPORTED })).toBe( + true, + ); + }); + + test('a reset is NOT confirmed by a modem that stayed locked to one band', () => { + expect(bandWriteReadbackMatches(['any'], { supported: SUPPORTED, current: ['eutran-3'] })).toBe( + false, + ); + }); +}); diff --git a/control/src/radio/band-truth.ts b/control/src/radio/band-truth.ts new file mode 100644 index 0000000..0cad4b2 --- /dev/null +++ b/control/src/radio/band-truth.ts @@ -0,0 +1,132 @@ +// The band-write descriptor — the certification gate, expressed as an operation contract. +// +// It ADDS NOTHING to `band/certification.ts`. That module already owns the four-proof +// per-SKU catalog, the fail-closed lookup, and the `offerableBands` narrowing, and it +// is the only place a SKU may be declared certified. What was missing was the other +// end of the wire: a band write's OPERATION DESCRIPTOR did not say that it is +// disruptive-with-a-certification-requirement, so the gate lived only inside the +// provider's capability flag and a consumer reading the descriptor could not see it. +// +// TWO INDEPENDENT FENCES, deliberately. `availability: refused` is what a consumer +// reads to decide whether to offer the control at all; the provider's own +// `bandWrite` capability check is what refuses the call if one is made anyway. A gate +// that exists only in the descriptor is advisory, and a gate that exists only in the +// provider is invisible — a band lock can take a working uplink off the air, so it +// gets both. + +import { type BandCertificationEntry, type BandName, offerableBands } from '../band'; +import { defineOperationDescriptor, type OperationDescriptor } from '../domain'; +import type { ModemBands } from '../ports'; + +export const BAND_WRITE_OPERATION_ID = 'modemmanager.bands'; + +/** The refusal reasons a band write may carry. Certification is the first gate. */ +export const BAND_WRITE_REFUSAL = 'band-certification-required'; +export const BAND_READ_REFUSAL = 'band-read-unsupported'; +export const BAND_NONE_OFFERABLE_REFUSAL = 'no-offerable-certified-bands'; + +/** + * The band-lock certification decision for one device, as a descriptor consumer sees it. + * + * `required` is `true` unconditionally and is typed as the literal, so no code path can + * produce a band-write decision that does not require certification. This is the one + * capability module documented as STRICTER than `support-claim.ts`'s `capable` floor. + */ +export type BandWriteCertification = { + readonly required: true; + readonly satisfied: boolean; + readonly reason: 'band-certification-proven' | typeof BAND_WRITE_REFUSAL; + /** What the catalog proves is settable, intersected with what the modem advertises. */ + readonly offerable: readonly BandName[]; +}; + +export function describeBandWriteCertification(input: { + readonly entry: BandCertificationEntry | undefined; + readonly supported: readonly BandName[]; +}): BandWriteCertification { + const offerable = offerableBands(input.entry, input.supported); + return input.entry === undefined + ? { required: true, satisfied: false, reason: BAND_WRITE_REFUSAL, offerable } + : { required: true, satisfied: true, reason: 'band-certification-proven', offerable }; +} + +export type BandWriteDescriptorInput = { + readonly provider: string; + readonly profile: string; + readonly certification: BandWriteCertification; + readonly readSupported: boolean; +}; + +function sameBandSet(left: readonly BandName[], right: readonly BandName[]): boolean { + if (left.length !== right.length) return false; + const sorted = [...right].sort(); + return [...left].sort().every((band, index) => band === sorted[index]); +} + +/** + * Whether a band readback confirms the write. + * + * A NARROWING lock must match exactly — a superset is a different lock from the one + * that was asked for. The reset (`['any']`) is the exception ModemManager forces: + * releasing a lock is `SetCurrentBands([ANY])`, and a modem afterwards reports either + * `any` or its whole supported set, both of which mean "no lock is in force". + */ +export function bandWriteReadbackMatches( + requested: readonly BandName[], + observed: ModemBands, +): boolean { + if (requested.length === 1 && requested[0] === 'any') { + return ( + (observed.current.length === 1 && observed.current[0] === 'any') || + sameBandSet(observed.current, observed.supported) + ); + } + return sameBandSet(requested, observed.current); +} + +/** + * The live band-write descriptor. + * + * `mutationImpact: 'disruptive'` because `SetCurrentBands` re-registers the radio and + * drops the bearer underneath NetworkManager; `readback` is REQUIRED because an + * accepted-but-ignored band write is the failure mode the catalog's own `readback` + * proof exists to catch, and it looks like success from the call site alone. + */ +export function buildBandWriteDescriptor( + input: BandWriteDescriptorInput, +): OperationDescriptor { + const refusal = !input.certification.satisfied + ? BAND_WRITE_REFUSAL + : input.certification.offerable.length === 0 + ? BAND_NONE_OFFERABLE_REFUSAL + : undefined; + return defineOperationDescriptor({ + id: BAND_WRITE_OPERATION_ID, + support: { + read: input.readSupported + ? { supported: true } + : { supported: false, reason: BAND_READ_REFUSAL }, + write: input.certification.satisfied + ? { supported: true } + : { supported: false, reason: BAND_WRITE_REFUSAL }, + }, + authority: 'provider', + provider: input.provider, + constraints: { kind: 'allowed-values', values: [input.certification.offerable] }, + livePreconditions: ['modem-present', 'runtime-interface-present', 'band-certification-present'], + availability: + refusal === undefined ? { state: 'available' } : { state: 'refused', reason: refusal }, + mutationImpact: 'disruptive', + retryClass: 'never', + readback: { + required: true, + reason: 'band-write-readback', + matches: bandWriteReadbackMatches, + }, + rollback: { required: false }, + journal: { required: true, reason: 'disruptive-radio-write' }, + admission: { required: true, reason: 'provider-mutation' }, + evidence: { profiles: [input.profile], firmware: [] }, + confidence: 'high', + }); +} diff --git a/control/src/radio/index.ts b/control/src/radio/index.ts new file mode 100644 index 0000000..1ea13c0 --- /dev/null +++ b/control/src/radio/index.ts @@ -0,0 +1,11 @@ +// Radio capability TRUTH: the modem's own mode catalog and band-write gate, verbatim. +// +// It sits beside `src/band/` (the band vocabulary and per-SKU certification catalog) +// rather than inside it, because a mode combination is not a band and the two have +// different safety models — a mode change is disruptive-but-reversible, a band lock +// can strand a radio where nothing registers. Reachable through the package ROOT +// entry, deliberately NOT a new package subpath (the todo 17/18/23 precedent). + +export * from './band-truth'; +export * from './mode-combinations'; +export * from './mode-truth'; diff --git a/control/src/radio/mode-combinations.test.ts b/control/src/radio/mode-combinations.test.ts new file mode 100644 index 0000000..b4589da --- /dev/null +++ b/control/src/radio/mode-combinations.test.ts @@ -0,0 +1,197 @@ +// The verbatim mode-catalog decoder. +// +// Every case here is a LOSS this decoder exists to prevent, not a shape exercise: +// the FM350's stated no-preference, a mode bit a future ModemManager adds, and a +// catalog member that is not a `(uu)` at all. + +import { describe, expect, test } from 'bun:test'; + +import { + decodeModeCombination, + decodeSupportedModeCombinations, + encodeModeNames, + hasUnknownCombination, + isFullyNamedMask, + isNamedMode, + MODE_ANY, + MODE_NONE, + modeCombination, + modeName, + modeNames, + modeValue, + statesNoPreference, +} from './mode-combinations'; + +const CS = 1 << 0; +const M2G = 1 << 1; +const M3G = 1 << 2; +const M4G = 1 << 3; +const M5G = 1 << 4; + +/** The bench FM350-GL's single advertised combination: allowed CS+2G+3G+4G, preferred NONE. */ +const FM350_ALLOWED = CS | M2G | M3G | M4G; + +describe('mode name vocabulary', () => { + test('the five named MM mode bits round-trip', () => { + for (const [bit, name] of [ + [CS, 'cs'], + [M2G, '2g'], + [M3G, '3g'], + [M4G, '4g'], + [M5G, '5g'], + ] as const) { + expect(modeName(bit)).toBe(name); + expect(modeValue(name)).toBe(bit); + expect(isNamedMode(name)).toBe(true); + } + }); + + test('an unnamed bit round-trips as a passthrough rather than being dropped', () => { + const future = 1 << 9; + expect(modeName(future)).toBe('mode-bit-512'); + expect(modeValue('mode-bit-512')).toBe(future); + expect(isNamedMode('mode-bit-512')).toBe(false); + }); + + test('none is 0 and any is the full mask', () => { + expect(modeValue(MODE_NONE)).toBe(0); + expect(modeValue(MODE_ANY)).toBe(0xffffffff); + expect(modeNames(0)).toEqual([]); + expect(modeNames(0xffffffff)).toEqual([MODE_ANY]); + }); + + test('a name this build cannot place is refused, never coerced to a neighbour', () => { + expect(modeValue('lte-advanced')).toBeUndefined(); + expect(encodeModeNames(['4g', 'lte-advanced'])).toEqual({ ok: false, unknown: 'lte-advanced' }); + }); + + test('encoding a set is the OR of its bits', () => { + expect(encodeModeNames(['2g', '3g', '4g'])).toEqual({ ok: true, mask: M2G | M3G | M4G }); + expect(encodeModeNames([])).toEqual({ ok: true, mask: 0 }); + }); +}); + +describe('FM350 `preferred: none` — the case this module exists for', () => { + const combination = modeCombination(FM350_ALLOWED, 0); + + test('preferred is the literal `none`, not the highest allowed mode', () => { + expect(combination.preferred).toBe(MODE_NONE); + expect(combination.preferred).not.toBe('4g'); + expect(combination.preferredMask).toBe(0); + }); + + test('a stated no-preference is NOT an anomaly and stays fully named', () => { + expect(combination.anomalies).toEqual([]); + expect(combination.classification).toBe('named'); + expect(statesNoPreference(combination)).toBe(true); + }); + + test('the allowed set is decoded in MM bit order, verbatim', () => { + expect(combination.allowed).toEqual(['cs', '2g', '3g', '4g']); + expect(combination.allowedMask).toBe(FM350_ALLOWED); + }); + + test('the raw masks survive alongside the names', () => { + expect(decodeModeCombination([FM350_ALLOWED, 0])).toEqual(combination); + }); +}); + +describe('unknown combinations are passed through TYPED, never dropped', () => { + test('an unfamiliar allowed bit keeps the combination and classifies it', () => { + const combination = modeCombination(M4G | (1 << 9), M4G); + expect(combination.allowed).toEqual(['4g', 'mode-bit-512']); + expect(combination.classification).toBe('unknown-combination'); + expect(combination.anomalies).toEqual(['unnamed-allowed-bit']); + }); + + test('a preferred mode outside its own allowed set is reported, not corrected', () => { + const combination = modeCombination(M4G, M5G); + expect(combination.preferred).toBe('5g'); + expect(combination.anomalies).toEqual(['preferred-not-in-allowed']); + expect(combination.classification).toBe('unknown-combination'); + }); + + test('a zero allowed mask is retained as an anomaly rather than filtered away', () => { + const combination = modeCombination(0, 0); + expect(combination.allowed).toEqual([]); + expect(combination.anomalies).toEqual(['empty-allowed']); + }); + + test('a multi-bit preferred mask is reported as non-singular and kept whole', () => { + const combination = modeCombination(M4G | M5G, M4G | M5G); + expect(combination.preferred).toBe('4g+5g'); + expect(combination.preferredMask).toBe(M4G | M5G); + expect(combination.anomalies).toContain('preferred-not-singular'); + }); + + test('`unknown` is never `unsupported` — the classification carries no support claim', () => { + const combination = modeCombination(M4G | (1 << 9), 0); + expect(combination.classification).toBe('unknown-combination'); + expect(combination.allowedMask).toBe(M4G | (1 << 9)); + expect(combination.allowed.length).toBeGreaterThan(0); + }); +}); + +describe('decoding a whole SupportedModes catalog', () => { + test('nothing is dropped: decoded + undecodable equals what the provider sent', () => { + const members = [ + [M4G, 0], + [M4G | M5G, M5G], + 'not-a-pair', + [M4G, M5G, M2G], + [M4G], + [M2G | M3G, M3G], + ]; + const set = decodeSupportedModeCombinations(members); + expect(set.combinations.length + set.undecodable.length).toBe(members.length); + expect(set.undecodable).toEqual(['not-a-pair', [M4G, M5G, M2G], [M4G]]); + }); + + test('an undecodable member is retained EXACTLY as it arrived', () => { + const set = decodeSupportedModeCombinations([{ allowed: 8 }]); + expect(set.combinations).toEqual([]); + expect(set.undecodable).toEqual([{ allowed: 8 }]); + }); + + test('a non-array property is an empty catalog, not a throw', () => { + expect(decodeSupportedModeCombinations(undefined)).toEqual({ + combinations: [], + undecodable: [], + }); + expect(decodeSupportedModeCombinations(7)).toEqual({ combinations: [], undecodable: [] }); + }); + + test('the FM350 catalog decodes to exactly one no-preference combination', () => { + const set = decodeSupportedModeCombinations([[FM350_ALLOWED, 0]]); + expect(set.combinations).toHaveLength(1); + expect(set.combinations[0]?.preferred).toBe(MODE_NONE); + expect(hasUnknownCombination(set)).toBe(false); + }); + + test('a catalog carrying one unknown member reports so without hiding the rest', () => { + const set = decodeSupportedModeCombinations([ + [M4G, 0], + [M4G | (1 << 9), 0], + ]); + expect(set.combinations).toHaveLength(2); + expect(hasUnknownCombination(set)).toBe(true); + }); + + test('a negative or non-integer mask is not a pair', () => { + const set = decodeSupportedModeCombinations([ + [-1, 0], + [8, 1.5], + ]); + expect(set.combinations).toEqual([]); + expect(set.undecodable).toHaveLength(2); + }); +}); + +describe('mask naming', () => { + test('isFullyNamedMask accepts none, any and every named combination', () => { + expect(isFullyNamedMask(0)).toBe(true); + expect(isFullyNamedMask(0xffffffff)).toBe(true); + expect(isFullyNamedMask(FM350_ALLOWED)).toBe(true); + expect(isFullyNamedMask(M4G | (1 << 9))).toBe(false); + }); +}); diff --git a/control/src/radio/mode-combinations.ts b/control/src/radio/mode-combinations.ts new file mode 100644 index 0000000..949e70c --- /dev/null +++ b/control/src/radio/mode-combinations.ts @@ -0,0 +1,244 @@ +// `MMModemMode` ↔ mode NAME, and the (allowed, preferred) COMBINATION, verbatim. +// +// ModemManager advertises what a radio can do as `Modem.SupportedModes`, an `a(uu)` +// of (allowed-mask, preferred-mask) pairs, and what it is doing as `Modem.CurrentModes`, +// one `(uu)`. Everything an operator may legally ask for is in that list, and this +// module's whole job is to carry it across UNCHANGED. +// +// THREE THINGS ARE ROUTINELY LOST BY A DECODER THAT MEANS WELL, and each one is a +// documented CeraLive case rather than a hypothetical: +// +// 1. `preferred: 0` (`MM_MODEM_MODE_NONE`). The bench Fibocom FM350-GL advertises +// exactly one combination, and its preferred mask is 0 — the modem allows a set +// of modes and states NO preference within it. That is a real, legal answer, and +// it is NOT the same as "prefer the highest allowed mode". Substituting a default +// shows an operator a preference the modem never expressed and cannot be returned +// to. `preferred` is therefore the name `none`, carried through to the operation +// descriptor's own allowed-value list. +// 2. A bit this build does not name. MM's mode enum grows; a mask carrying an +// unfamiliar bit is still a combination the modem advertised and will accept. It +// round-trips as `mode-bit-` (the `band-` discipline from `band-names.ts`), +// the combination is CLASSIFIED `unknown-combination`, and it stays offerable. +// `unknown` is never coerced to `unsupported` — that is the support-claim +// taxonomy's first rule, and it applies to a mode catalog exactly as it does to a +// capability probe. +// 3. A member that is not a `(uu)` at all. Dropping it silently shortens the catalog, +// so `decodeSupportedModeCombinations` RETAINS it in a separate `undecodable` +// list. Decoded + undecodable always equals what the provider sent. +// +// This module is pure and total: no transport, no clock, no I/O. The `SetCurrentModes` +// call itself stays where every other radio mutation lives (`backend/mm-mutations.ts`). + +/** A mode as every operator-facing surface spells it. Opaque; compare by equality. */ +export type ModeName = string; + +/** + * `MM_MODEM_MODE_NONE` (0) — the modem expresses no preference within its allowed set. + * A legal, load-bearing value; never a stand-in for "not reported". + */ +export const MODE_NONE = 'none'; + +/** `MM_MODEM_MODE_ANY` (0xFFFFFFFF) — every mode the modem has. */ +export const MODE_ANY = 'any'; +const MODE_ANY_VALUE = 0xffffffff; + +/** The named `MMModemMode` bits, in MM's own bit order. */ +const NAMED_BITS: readonly (readonly [number, ModeName])[] = [ + [1 << 0, 'cs'], + [1 << 1, '2g'], + [1 << 2, '3g'], + [1 << 3, '4g'], + [1 << 4, '5g'], +]; + +const BIT_TO_NAME = new Map(NAMED_BITS); +const NAME_TO_BIT = new Map(NAMED_BITS.map(([bit, name]) => [name, bit])); + +/** The passthrough spelling for a mode bit this build does not name. */ +const PASSTHROUGH_RE = /^mode-bit-(\d+)$/; + +const NAMED_MASK = NAMED_BITS.reduce((mask, [bit]) => mask | bit, 0); + +/** Decode one `MMModemMode` bit. Total: an unnamed bit round-trips. */ +export function modeName(bit: number): ModeName { + return BIT_TO_NAME.get(bit) ?? `mode-bit-${bit}`; +} + +/** Encode one mode name. `undefined` for a name this build cannot place. */ +export function modeValue(name: ModeName): number | undefined { + if (name === MODE_NONE) return 0; + if (name === MODE_ANY) return MODE_ANY_VALUE; + const known = NAME_TO_BIT.get(name); + if (known !== undefined) return known; + const passthrough = PASSTHROUGH_RE.exec(name); + if (passthrough?.[1] === undefined) return undefined; + const parsed = Number(passthrough[1]); + return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : undefined; +} + +/** True when this build recognises the name (a `mode-bit-` passthrough does not). */ +export function isNamedMode(name: ModeName): boolean { + return name === MODE_NONE || name === MODE_ANY || NAME_TO_BIT.has(name); +} + +/** + * Split a mask into its set bits, named where this build can and passed through where + * it cannot. A zero mask is the EMPTY set — the caller decides whether that reads as + * `none` (a preference) or as an anomaly (an allowed set nothing can be chosen from). + */ +export function modeNames(mask: number): readonly ModeName[] { + if (!Number.isSafeInteger(mask) || mask <= 0) return []; + if (mask === MODE_ANY_VALUE) return [MODE_ANY]; + const names: ModeName[] = []; + for (let bit = 1; bit <= mask && bit > 0; bit *= 2) { + if ((mask & bit) !== 0) names.push(modeName(bit)); + } + return names; +} + +/** Encode a set of mode names into one mask. Fails closed on an unplaceable name. */ +export function encodeModeNames( + names: readonly ModeName[], +): + | { readonly ok: true; readonly mask: number } + | { readonly ok: false; readonly unknown: ModeName } { + let mask = 0; + for (const name of names) { + const value = modeValue(name); + if (value === undefined) return { ok: false, unknown: name }; + mask |= value; + } + return { ok: true, mask: mask >>> 0 }; +} + +/** Why a combination could not be fully placed. Never a reason to hide it. */ +export const MODE_COMBINATION_ANOMALIES = [ + /** The allowed mask carries a bit this build does not name. */ + 'unnamed-allowed-bit', + /** The preferred mask carries a bit this build does not name. */ + 'unnamed-preferred-bit', + /** The preferred mode is not a member of the allowed set. */ + 'preferred-not-in-allowed', + /** The preferred mask names more than one mode; MM's contract is at most one. */ + 'preferred-not-singular', + /** The allowed mask is zero — nothing can be selected from this combination. */ + 'empty-allowed', +] as const; +export type ModeCombinationAnomaly = (typeof MODE_COMBINATION_ANOMALIES)[number]; + +export type ModeCombinationClassification = 'named' | 'unknown-combination'; + +/** + * One `(allowed, preferred)` pair, decoded without loss. + * + * The masks are retained ALONGSIDE the names so a consumer can always reproduce the + * exact bytes the modem reported, whatever this build managed to name. + */ +export type ModeCombination = { + readonly allowedMask: number; + readonly allowed: readonly ModeName[]; + readonly preferredMask: number; + /** `none` when the modem stated no preference — verbatim, never a substitute. */ + readonly preferred: ModeName; + readonly classification: ModeCombinationClassification; + readonly anomalies: readonly ModeCombinationAnomaly[]; +}; + +/** + * A `SupportedModes` catalog: everything decoded, plus everything that could not be. + * + * `undecodable` exists so the no-drop property is structural rather than a promise — + * `combinations.length + undecodable.length` is the member count the provider sent. + */ +export type ModeCombinationSet = { + readonly combinations: readonly ModeCombination[]; + /** Members that were not a `(uu)` pair, retained exactly as the provider sent them. */ + readonly undecodable: readonly unknown[]; +}; + +function isUnnamed(names: readonly ModeName[]): boolean { + return names.some((name) => !isNamedMode(name)); +} + +/** Build a combination from two raw masks, recording every anomaly it carries. */ +export function modeCombination(allowedMask: number, preferredMask: number): ModeCombination { + const allowed = modeNames(allowedMask); + const preferredNames = modeNames(preferredMask); + const anomalies: ModeCombinationAnomaly[] = []; + if (allowed.length === 0) anomalies.push('empty-allowed'); + if (isUnnamed(allowed)) anomalies.push('unnamed-allowed-bit'); + if (isUnnamed(preferredNames)) anomalies.push('unnamed-preferred-bit'); + if (preferredNames.length > 1) anomalies.push('preferred-not-singular'); + // A zero preferred mask is `none` and is NOT an anomaly: it is MM's own way of + // saying "no preference within the allowed set", which the FM350 actually reports. + if ( + preferredNames.length > 0 && + preferredMask !== MODE_ANY_VALUE && + (preferredMask & allowedMask) !== preferredMask + ) { + anomalies.push('preferred-not-in-allowed'); + } + return { + allowedMask, + allowed, + preferredMask, + preferred: preferredNames.length === 0 ? MODE_NONE : (preferredNames.join('+') as ModeName), + classification: anomalies.length === 0 ? 'named' : 'unknown-combination', + anomalies, + }; +} + +/** True when the modem stated no preference within this combination's allowed set. */ +export function statesNoPreference(combination: ModeCombination): boolean { + return combination.preferredMask === 0; +} + +function decodePair(value: unknown): readonly [number, number] | undefined { + if (!Array.isArray(value) || value.length !== 2) return undefined; + const [allowed, preferred] = value; + if (typeof allowed !== 'number' || !Number.isSafeInteger(allowed) || allowed < 0) + return undefined; + if (typeof preferred !== 'number' || !Number.isSafeInteger(preferred) || preferred < 0) { + return undefined; + } + return [allowed, preferred]; +} + +/** Decode one `CurrentModes` `(uu)`. `undefined` only when the value is not a pair. */ +export function decodeModeCombination(value: unknown): ModeCombination | undefined { + const pair = decodePair(value); + return pair === undefined ? undefined : modeCombination(pair[0], pair[1]); +} + +/** + * Decode a `SupportedModes` `a(uu)`. + * + * Nothing is dropped: a member that is not a pair lands in `undecodable`, and a pair + * this build cannot fully name lands in `combinations` classified + * `unknown-combination`. A non-array value is an empty catalog, not an error — a modem + * that advertises no mode control is a real reading. + */ +export function decodeSupportedModeCombinations(value: unknown): ModeCombinationSet { + if (!Array.isArray(value)) return { combinations: [], undecodable: [] }; + const combinations: ModeCombination[] = []; + const undecodable: unknown[] = []; + for (const member of value) { + const pair = decodePair(member); + if (pair === undefined) undecodable.push(member); + else combinations.push(modeCombination(pair[0], pair[1])); + } + return { combinations, undecodable }; +} + +/** True when any advertised combination carries an anomaly this build could not place. */ +export function hasUnknownCombination(set: ModeCombinationSet): boolean { + return ( + set.undecodable.length > 0 || + set.combinations.some((each) => each.classification === 'unknown-combination') + ); +} + +/** Whether a mask names a mode this build recognises across every set bit. */ +export function isFullyNamedMask(mask: number): boolean { + return mask === MODE_ANY_VALUE || mask === 0 || (mask & ~NAMED_MASK) === 0; +} diff --git a/control/src/radio/mode-truth.test.ts b/control/src/radio/mode-truth.test.ts new file mode 100644 index 0000000..d321811 --- /dev/null +++ b/control/src/radio/mode-truth.test.ts @@ -0,0 +1,237 @@ +// The mode-write DESCRIPTOR — where the modem's own catalog becomes a contract. +// +// The acceptance property this suite exists for: a combination the modem advertised +// reaches `descriptor.constraints.values` unchanged, `preferred: 'none'` included. A +// descriptor that quietly substituted a default would be indistinguishable from a +// correct one at every other layer. + +import { describe, expect, test } from 'bun:test'; + +import { MODE_NONE, modeCombination } from './mode-combinations'; +import { + advertisesNoPreference, + buildModeWriteDescriptor, + encodeModeSelection, + MODE_WRITE_OPERATION_ID, + type ModeSelection, + matchAdvertisedCombination, + readRadioModeTruth, + sameSelection, + selectionOf, +} from './mode-truth'; + +const CS = 1 << 0; +const M2G = 1 << 1; +const M3G = 1 << 2; +const M4G = 1 << 3; +const M5G = 1 << 4; +const FUTURE = 1 << 9; + +const FM350_ALLOWED = CS | M2G | M3G | M4G; + +/** Fibocom FM350-GL: one combination, preferred NONE (the conformance-matrix spec's own values). */ +const FM350 = readRadioModeTruth({ + currentModes: [FM350_ALLOWED, 0], + supportedModes: [[FM350_ALLOWED, 0]], +}); + +/** Quectel RM530N-GL as the matrix fixtures spell it: allowed CS+2G+3G, preferred NONE. */ +const QUECTEL = readRadioModeTruth({ + currentModes: [CS | M2G | M3G, 0], + supportedModes: [[CS | M2G | M3G, 0]], +}); + +/** SIMCom SIM7600G-H as the matrix fixtures spell it: allowed 3G, preferred NONE. */ +const SIMCOM = readRadioModeTruth({ + currentModes: [M3G, 0], + supportedModes: [[M3G, 0]], +}); + +/** A modem advertising a real preference plus a bit this build cannot name. */ +const UNKNOWN_COMBINATION = readRadioModeTruth({ + currentModes: [M4G | M5G, M5G], + supportedModes: [ + [M4G | M5G, M5G], + [M4G | M5G, M4G], + [M4G | M5G | FUTURE, FUTURE], + ], +}); + +const descriptorFor = (truth: Parameters[0]['truth']) => + buildModeWriteDescriptor({ + provider: 'modemmanager', + profile: 'generic-mm', + truth, + writeSupported: true, + }); + +describe('FM350 `preferred: none` survives to the descriptor VERBATIM', () => { + const descriptor = descriptorFor(FM350); + + test('the descriptor offers exactly the advertised combination', () => { + expect(descriptor.constraints).toEqual({ + kind: 'allowed-values', + values: [{ allowed: ['cs', '2g', '3g', '4g'], preferred: MODE_NONE }], + }); + }); + + test('`preferred` is `none` and was not coerced to any allowed mode', () => { + const values = + descriptor.constraints.kind === 'allowed-values' ? descriptor.constraints.values : []; + expect(values[0]?.preferred).toBe(MODE_NONE); + for (const mode of ['cs', '2g', '3g', '4g', '5g']) { + expect(values[0]?.preferred).not.toBe(mode); + } + }); + + test('the reading is `reported` and its combination is the same one', () => { + expect(FM350.current).toEqual({ + state: 'reported', + combination: modeCombination(FM350_ALLOWED, 0), + }); + expect(advertisesNoPreference(FM350)).toBe(true); + }); + + test('the descriptor is available and write-supported', () => { + expect(descriptor.availability).toEqual({ state: 'available' }); + expect(descriptor.support.write).toEqual({ supported: true }); + expect(descriptor.id).toBe(MODE_WRITE_OPERATION_ID); + }); +}); + +describe('Quectel and SIMCom fixtures', () => { + test('Quectel advertises one no-preference combination', () => { + expect(descriptorFor(QUECTEL).constraints).toEqual({ + kind: 'allowed-values', + values: [{ allowed: ['cs', '2g', '3g'], preferred: MODE_NONE }], + }); + }); + + test('SIMCom advertises one 3G-only no-preference combination', () => { + expect(descriptorFor(SIMCOM).constraints).toEqual({ + kind: 'allowed-values', + values: [{ allowed: ['3g'], preferred: MODE_NONE }], + }); + }); + + test('two combinations sharing an allowed set differ only in the preference', () => { + const values = UNKNOWN_COMBINATION.supported.combinations.map(selectionOf); + expect(values[0]).toEqual({ allowed: ['4g', '5g'], preferred: '5g' }); + expect(values[1]).toEqual({ allowed: ['4g', '5g'], preferred: '4g' }); + expect(sameSelection(values[0] as ModeSelection, values[1] as ModeSelection)).toBe(false); + }); +}); + +describe('an unknown combination stays OFFERED, never coerced to unsupported', () => { + const descriptor = descriptorFor(UNKNOWN_COMBINATION); + + test('the unnameable combination is in the descriptor`s allowed values', () => { + const values = + descriptor.constraints.kind === 'allowed-values' ? descriptor.constraints.values : []; + expect(values).toHaveLength(3); + expect(values[2]).toEqual({ + allowed: ['4g', '5g', 'mode-bit-512'], + preferred: 'mode-bit-512', + }); + }); + + test('the descriptor stays available despite carrying an unknown combination', () => { + expect(descriptor.availability).toEqual({ state: 'available' }); + expect(descriptor.support.write).toEqual({ supported: true }); + }); + + test('it can be matched and encoded back to the exact masks the modem sent', () => { + const selection: ModeSelection = { + allowed: ['4g', '5g', 'mode-bit-512'], + preferred: 'mode-bit-512', + }; + expect(matchAdvertisedCombination(UNKNOWN_COMBINATION.supported, selection)).toBeDefined(); + expect(encodeModeSelection(selection)).toEqual({ + ok: true, + allowedMask: M4G | M5G | FUTURE, + preferredMask: FUTURE, + }); + }); +}); + +describe('refusals are refusals, never substitutions', () => { + test('a selection the modem never advertised matches nothing', () => { + expect( + matchAdvertisedCombination(FM350.supported, { + allowed: ['cs', '2g', '3g', '4g'], + preferred: '4g', + }), + ).toBeUndefined(); + }); + + test('a nearest-neighbour is NOT returned for an unadvertised preference', () => { + const nearest = matchAdvertisedCombination(UNKNOWN_COMBINATION.supported, { + allowed: ['4g', '5g'], + preferred: '3g', + }); + expect(nearest).toBeUndefined(); + }); + + test('an empty catalog refuses the descriptor rather than offering an open write', () => { + const descriptor = descriptorFor(readRadioModeTruth({ currentModes: 7, supportedModes: [] })); + expect(descriptor.availability).toEqual({ + state: 'refused', + reason: 'no-advertised-mode-combinations', + }); + }); + + test('a modem with no mode write says so in support AND availability', () => { + const descriptor = buildModeWriteDescriptor({ + provider: 'modemmanager', + profile: 'generic-mm', + truth: FM350, + writeSupported: false, + }); + expect(descriptor.support.write).toEqual({ + supported: false, + reason: 'mode-write-unsupported', + }); + expect(descriptor.availability).toEqual({ + state: 'refused', + reason: 'mode-write-unsupported', + }); + }); + + test('an unplaceable mode name fails the encode closed', () => { + expect(encodeModeSelection({ allowed: ['4g', 'lte-advanced'], preferred: '4g' })).toEqual({ + ok: false, + unknown: 'lte-advanced', + }); + }); +}); + +describe('the write is disruptive and readback-gated', () => { + const descriptor = descriptorFor(FM350); + + test('mutation impact is disruptive and the write is journalled and admitted', () => { + expect(descriptor.mutationImpact).toBe('disruptive'); + expect(descriptor.journal).toEqual({ required: true, reason: 'disruptive-radio-write' }); + expect(descriptor.admission).toEqual({ required: true, reason: 'provider-mutation' }); + expect(descriptor.retryClass).toBe('never'); + }); + + test('readback confirms only when BOTH the allowed set and the preference match', () => { + expect(descriptor.readback.required).toBe(true); + if (!descriptor.readback.required) return; + const selection: ModeSelection = { allowed: ['cs', '2g', '3g', '4g'], preferred: MODE_NONE }; + expect(descriptor.readback.matches(selection, FM350)).toBe(true); + expect( + descriptor.readback.matches({ allowed: ['cs', '2g', '3g', '4g'], preferred: '4g' }, FM350), + ).toBe(false); + }); + + test('a modem that reported no current modes cannot satisfy a readback', () => { + expect(descriptor.readback.required).toBe(true); + if (!descriptor.readback.required) return; + const unreported = readRadioModeTruth({ currentModes: undefined, supportedModes: [] }); + expect(unreported.current).toEqual({ state: 'not-reported' }); + expect(descriptor.readback.matches({ allowed: ['cs'], preferred: MODE_NONE }, unreported)).toBe( + false, + ); + }); +}); diff --git a/control/src/radio/mode-truth.ts b/control/src/radio/mode-truth.ts new file mode 100644 index 0000000..81d3484 --- /dev/null +++ b/control/src/radio/mode-truth.ts @@ -0,0 +1,172 @@ +// What the modem says it can do with its modes, and the operation descriptor built +// from exactly that — nothing added, nothing narrowed. +// +// `capability/five-g-preference.ts` maps four named POSTURES onto an (allowed, +// preferred) pair and refuses to name one the modem never advertised. This module is +// the layer underneath it: the modem's own catalog, unedited, so a posture selector +// and a raw combination selector are reading the same truth. It deliberately does not +// know what a posture is. +// +// THE OFFERED SET IS THE ADVERTISED SET. A combination classified +// `unknown-combination` — an unfamiliar mode bit, a preferred mode outside its own +// allowed set — is STILL offered, because the modem advertised it and will accept it. +// Hiding it would be coercing `unknown` into `unsupported`, which is the one thing +// `support-claim.ts` exists to stop. + +import { defineOperationDescriptor, type OperationDescriptor } from '../domain'; +import { + decodeModeCombination, + decodeSupportedModeCombinations, + encodeModeNames, + MODE_NONE, + type ModeCombination, + type ModeCombinationSet, + type ModeName, + statesNoPreference, +} from './mode-combinations'; + +/** + * A mode selection as a caller expresses it: the set to allow, and the one to prefer + * within it. `preferred: 'none'` is a first-class selection, not a missing field. + */ +export type ModeSelection = { + readonly allowed: readonly ModeName[]; + readonly preferred: ModeName; +}; + +/** `CurrentModes` as a reading — "the modem did not report it" is an answer, not a null. */ +export type ModeCombinationReading = + | { readonly state: 'reported'; readonly combination: ModeCombination } + | { readonly state: 'not-reported' }; + +export type RadioModeTruth = { + readonly current: ModeCombinationReading; + readonly supported: ModeCombinationSet; +}; + +export function readRadioModeTruth(input: { + readonly currentModes: unknown; + readonly supportedModes: unknown; +}): RadioModeTruth { + const current = decodeModeCombination(input.currentModes); + return { + current: + current === undefined + ? { state: 'not-reported' } + : { state: 'reported', combination: current }, + supported: decodeSupportedModeCombinations(input.supportedModes), + }; +} + +/** The selection a combination represents, with `preferred` carried across verbatim. */ +export function selectionOf(combination: ModeCombination): ModeSelection { + return { allowed: combination.allowed, preferred: combination.preferred }; +} + +function sameNameSet(left: readonly ModeName[], right: readonly ModeName[]): boolean { + if (left.length !== right.length) return false; + const sorted = [...right].sort(); + return [...left].sort().every((name, index) => name === sorted[index]); +} + +/** Two selections are the same when both the allowed set AND the preference agree. */ +export function sameSelection(left: ModeSelection, right: ModeSelection): boolean { + return left.preferred === right.preferred && sameNameSet(left.allowed, right.allowed); +} + +/** + * The advertised combination a selection names, or `undefined`. + * + * `undefined` is the whole answer — never the nearest neighbour. `prefer-4g` silently + * becoming `prefer-5g` on a marginal cell is the exact substitution + * `five-g-preference.ts` refuses, and it is refused here for the same reason. + */ +export function matchAdvertisedCombination( + supported: ModeCombinationSet, + selection: ModeSelection, +): ModeCombination | undefined { + return supported.combinations.find((each) => sameSelection(selectionOf(each), selection)); +} + +/** The masks `SetCurrentModes` needs, or the name that could not be placed. */ +export function encodeModeSelection( + selection: ModeSelection, +): + | { readonly ok: true; readonly allowedMask: number; readonly preferredMask: number } + | { readonly ok: false; readonly unknown: ModeName } { + const allowed = encodeModeNames(selection.allowed); + if (!allowed.ok) return allowed; + const preferred = encodeModeNames(selection.preferred === MODE_NONE ? [] : [selection.preferred]); + if (!preferred.ok) return preferred; + return { ok: true, allowedMask: allowed.mask, preferredMask: preferred.mask }; +} + +export const MODE_WRITE_OPERATION_ID = 'modemmanager.mode-combination'; + +/** Why a mode write is not on offer right now. */ +export type ModeWriteRefusal = 'mode-write-unsupported' | 'no-advertised-mode-combinations'; + +export type ModeWriteDescriptorInput = { + readonly provider: string; + readonly profile: string; + readonly truth: RadioModeTruth; + readonly writeSupported: boolean; +}; + +/** + * The live mode-write descriptor. + * + * `constraints` is an `allowed-values` list built from the modem's OWN catalog, so a + * combination carrying `preferred: 'none'` reaches a consumer through the descriptor + * exactly as the modem stated it. Readback is REQUIRED: `SetCurrentModes` returning + * without an error only proves the daemon accepted the call, and an + * accepted-but-ignored mode write is indistinguishable from success at the call site. + */ +export function buildModeWriteDescriptor( + input: ModeWriteDescriptorInput, +): OperationDescriptor { + const values = input.truth.supported.combinations.map(selectionOf); + const refusal: ModeWriteRefusal | undefined = !input.writeSupported + ? 'mode-write-unsupported' + : values.length === 0 + ? 'no-advertised-mode-combinations' + : undefined; + return defineOperationDescriptor({ + id: MODE_WRITE_OPERATION_ID, + support: { + read: { supported: true }, + write: input.writeSupported + ? { supported: true } + : { supported: false, reason: 'mode-write-unsupported' }, + }, + authority: 'provider', + provider: input.provider, + constraints: { kind: 'allowed-values', values }, + livePreconditions: [ + 'modem-present', + 'runtime-interface-present', + 'mode-combination-advertised', + ], + availability: + refusal === undefined ? { state: 'available' } : { state: 'refused', reason: refusal }, + mutationImpact: 'disruptive', + retryClass: 'never', + readback: { + required: true, + reason: 'mode-write-readback', + matches: (selection, observed) => + observed.current.state === 'reported' && + sameSelection(selectionOf(observed.current.combination), selection), + }, + rollback: { required: false }, + journal: { required: true, reason: 'disruptive-radio-write' }, + admission: { required: true, reason: 'provider-mutation' }, + evidence: { profiles: [input.profile], firmware: [] }, + confidence: 'high', + }); +} + +/** True when the modem advertises at least one combination stating no preference. */ +export function advertisesNoPreference(truth: RadioModeTruth): boolean { + return truth.supported.combinations.some(statesNoPreference); +} diff --git a/control/src/redact.test.ts b/control/src/redact.test.ts index b3834f2..f042dea 100644 --- a/control/src/redact.test.ts +++ b/control/src/redact.test.ts @@ -65,6 +65,46 @@ test('redacts subscriptionId, newPin, and puk2 variants', () => { expect(out.puk2).toBe(REDACTED); }); +test('redacts a GNSS fix — coordinates never survive redaction', () => { + const fix = { latitude: 4.60971, longitude: -74.08175, altitude: 2640, observedAt: 1000 }; + const out = redact(fix) as Record; + + expect(out.latitude).toBe(REDACTED); + expect(out.longitude).toBe(REDACTED); + expect(out.altitude).toBe(REDACTED); + expect(out.observedAt).toBe(1000); +}); + +test('redacts raw NMEA sentences, which carry the position in their payload', () => { + const input = { nmea: '$GPGGA,123519.00,4807.038,N,01131.000,E,1,08,0.9,545.4,M,,,,*47' }; + const out = redact(input) as Record; + expect(out.nmea).toBe(REDACTED); +}); + +test('redacts a fix nested inside a modem row and inside an array', () => { + const input = { + modems: [ + { stableKey: 'slot:a', gnss: { fix: { lat: 4.6, lng: -74.08 } } }, + { stableKey: 'slot:b', gnss: { fix: undefined } }, + ], + }; + const serialized = JSON.stringify(redact(input)); + + expect(serialized).not.toContain('4.6'); + expect(serialized).not.toContain('-74.08'); + expect(serialized).toContain('slot:a'); +}); + +test('coarse cell location stays visible — the GNSS fence does not blank cell-info', () => { + // `3gpp-lac-ci` is the cell-info module's own gated output. Redacting it here + // would silently blank a shipping surface that never opted into the GNSS fence. + const input = { lac: '0x1a2b', ci: '0x00c1f204', tac: '0x1234' }; + const out = redact(input) as Record; + expect(out.lac).toBe('0x1a2b'); + expect(out.ci).toBe('0x00c1f204'); + expect(out.tac).toBe('0x1234'); +}); + test('does not mutate the input', () => { const input = { pin: '1234', nested: { iccid: '5678' } }; const before = JSON.stringify(input); @@ -80,3 +120,128 @@ test('passes primitives and empty containers through unchanged', () => { expect(redact({})).toEqual({}); expect(redact([])).toEqual([]); }); + +test('SMS content is redacted by key, in every spelling', () => { + const out = redact({ + smsText: 'Tu pin es 4821', + sms_body: 'code 9911', + smsNumber: '85573', + smsSender: '+573103154363', + sender: '85573', + msisdn: '+573001112233', + 'sms.content.text': 'Tu pin es 4821', + 'sms.content.number': '85573', + }) as Record; + for (const value of Object.values(out)) { + expect(value).toBe(REDACTED); + } +}); + +test('a message body nested in an inbox array is redacted at depth', () => { + const out = redact({ + inbox: [{ id: '36', smsText: 'Tu pin es 4821', state: 'received' }], + }) as { inbox: Array> }; + expect(out.inbox[0]?.smsText).toBe(REDACTED); + expect(out.inbox[0]?.id).toBe('36'); + expect(out.inbox[0]?.state).toBe('received'); +}); + +test('the SMS class does not over-redact ordinary text and number fields', () => { + // This is why the SMS keys are their own set: `SENSITIVE_KEYS` matches a leaf + // name exactly, so adding `text` / `number` / `sender` to it would blank a + // receipt reason, a slot number, and a signal reading across the package. + const out = redact({ + text: 'reason: unsupported', + number: 3, + reason: 'no messaging capabilities', + smsCount: 37, + senderName: 'CeraLive', + }) as Record; + expect(out.text).toBe('reason: unsupported'); + expect(out.number).toBe(3); + expect(out.reason).toBe('no messaging capabilities'); + expect(out.smsCount).toBe(37); + expect(out.senderName).toBe('CeraLive'); +}); + +test('every USSD carrier-text key is redacted, in both directions', () => { + const out = redact({ + ussd: '*123#', + ussdCommand: '*123*1234567890123456#', + ussdReply: 'Your balance is $4.20', + ussd_response: '1', + ussdText: 'Menu: 1) Balance 2) Data', + networkNotification: 'You have been topped up', + network_request: 'Enter your PIN', + }) as Record; + for (const value of Object.values(out)) { + expect(value).toBe(REDACTED); + } +}); + +test('a USSD reply nested in a session snapshot is redacted at depth', () => { + const out = redact({ + session: { state: 'awaiting-reply', ussdReply: 'Your balance is $4.20' }, + }) as { session: Record }; + expect(out.session.ussdReply).toBe(REDACTED); + expect(out.session.state).toBe('awaiting-reply'); +}); + +test('the USSD class does not over-redact ordinary reply and command fields', () => { + const out = redact({ + reply: 'ok', + command: 'AT+CFUN?', + response: 'OK', + ussdCapable: true, + ussdSessionState: 'active', + }) as Record; + expect(out.reply).toBe('ok'); + expect(out.command).toBe('AT+CFUN?'); + expect(out.response).toBe('OK'); + expect(out.ussdCapable).toBe(true); + expect(out.ussdSessionState).toBe('active'); +}); + +test('the SIM own-number is redacted in every spelling the stack can produce', () => { + const out = redact({ + ownNumbers: ['+573115422359'], + own_number: '+573115422359', + OwnNumbers: ['+573115422359'], + phoneNumber: '+573115422359', + simNumber: '+573115422359', + subscriberNumber: '+573115422359', + 'modem.generic.own-numbers': ['+573115422359'], + }) as Record; + for (const value of Object.values(out)) { + expect(value).toBe(REDACTED); + } +}); + +test('an own-number nested inside an identity is redacted at depth', () => { + const out = redact({ + identity: { + equipmentId: { provenance: 'imei', value: '867978050016855', confidence: 'high' }, + ownNumbers: ['+573115422359'], + runtimePath: '/org/freedesktop/ModemManager1/Modem/3', + }, + }) as { identity: Record }; + expect(out.identity.ownNumbers).toBe(REDACTED); + expect(out.identity.runtimePath).toBe('/org/freedesktop/ModemManager1/Modem/3'); +}); + +test('the own-number class does not over-redact ordinary number fields', () => { + // This is why it is its own whole-key set: `SENSITIVE_KEYS` matches a leaf + // name, so `number`/`numbers` there would blank a slot index and a band count. + const out = redact({ + number: 3, + numbers: [1, 2, 3], + slotNumber: 1, + serialNumber: 'c6125db3', + ownNumberSupported: true, + }) as Record; + expect(out.number).toBe(3); + expect(out.numbers).toEqual([1, 2, 3]); + expect(out.slotNumber).toBe(1); + expect(out.serialNumber).toBe('c6125db3'); + expect(out.ownNumberSupported).toBe(true); +}); diff --git a/control/src/redact.ts b/control/src/redact.ts index 0ee12a9..dc136b2 100644 --- a/control/src/redact.ts +++ b/control/src/redact.ts @@ -2,10 +2,11 @@ // serialized into a receipt, or written to a bundle. // // The sensitive CLASSES (draft §Oracle #1, round-5 auth semantics): ICCID, IMSI, -// EID, SIM PIN, SIM PUK, and APN / connection passwords. Redaction is KEY-BASED and -// RECURSIVE: it walks nested objects and arrays and replaces the value under any -// sensitive key with a fixed marker, no matter how deep — e.g. a password at -// `policy.connection.auth.password`, or an `iccid` inside an array of SIM slots. +// EID, SIM PIN, SIM PUK, and APN / connection passwords, PLUS the GNSS coordinate +// class below. Redaction is KEY-BASED and RECURSIVE: it walks nested objects and +// arrays and replaces the value under any sensitive key with a fixed marker, no +// matter how deep — e.g. a password at `policy.connection.auth.password`, or an +// `iccid` inside an array of SIM slots. /** The marker substituted for every redacted value. */ export const REDACTED = '[redacted]'; @@ -28,13 +29,137 @@ const SENSITIVE_KEYS: ReadonlySet = new Set([ 'subscriptionid', ]); +// GNSS coordinate keys. A fix says where the operator physically is, so it is +// sensitive for a reason none of the keys above share, and it gets its own set so +// the privacy fence stays readable: the GPS module keeps a fix in memory for a +// live display and NEVER persists, uploads, or logs one. +// +// Scoped to a GNSS fix on purpose. `3gpp-lac-ci` (coarse cell location) is NOT +// here — it is the existing cell-info module's deliberate, separately-gated output, +// and silently blanking it would break a surface that already ships. +const LOCATION_KEYS: ReadonlySet = new Set([ + 'latitude', + 'longitude', + 'altitude', + 'lat', + 'lon', + 'lng', + 'nmea', + 'nmeasentences', + 'coordinates', +]); + +/** + * SMS content is its own key class, and it may NOT be folded into + * `SENSITIVE_KEYS` above. That set matches a leaf name exactly (or the last + * dotted segment), so adding `text` / `number` / `sender` to it would redact + * every unrelated `text` and `number` in the package — a receipt's reason text, + * a slot number, a signal reading. These keys are matched WHOLE after case- AND + * separator-folding, so only a key that genuinely names message content or an + * originator is scrubbed. + * + * A message body routinely carries a one-time code (the bench SIM's inbox holds + * a literal "Tu pin es: …") and a sender number identifies the subscriber, so + * both are treated exactly like a PIN: never rendered anywhere. + * + * This mirrors CeraUI's `isSmsSensitiveKey` (`helpers/logger.ts`) key-for-key. + * It is a Rule-D MIRROR, never a shared import — the two halves are kept honest + * by their tests, not by a path. + */ +const SMS_SENSITIVE_KEYS: ReadonlySet = new Set([ + 'smstext', + 'smsbody', + 'smsfrom', + 'smssender', + 'smsnumber', + 'messagetext', + 'messagebody', + 'msisdn', + 'sender', + 'sms.content.text', + 'sms.content.number', +]); + +/** Whole-key SMS-content test, case- and separator-insensitive. */ +export function isSmsSensitiveKey(key: string): boolean { + return SMS_SENSITIVE_KEYS.has(key.toLowerCase().replace(/[_-]/g, '')); +} + +/** + * USSD carrier text — its own class, for the same reason SMS is: `reply`, + * `command`, and `response` are far too common to redact by leaf name, so these + * are matched WHOLE after case- and separator-folding. + * + * BOTH DIRECTIONS are sensitive, not just the reply. A USSD dialogue is how a + * subscriber tops up a prepaid line, so the COMMAND routinely carries a voucher + * code (`*123*<16 digits>#`) and the reply carries a balance, a subscriber + * number, or a one-time code. `NetworkNotification` / `NetworkRequest` are + * ModemManager's own property names for network-initiated USSD text and are + * included so a raw property dump cannot leak what the call path masks. + * + * This is why the USSD module names its carrier-text fields `ussdCommand`, + * `ussdResponse`, and `ussdReply` rather than the shorter names that read better: + * redaction here is key-based, so the FIELD NAME is the guarantee. + */ +const USSD_SENSITIVE_KEYS: ReadonlySet = new Set([ + 'ussd', + 'ussdcommand', + 'ussdreply', + 'ussdresponse', + 'ussdtext', + 'networknotification', + 'networkrequest', +]); + +/** Whole-key USSD-content test, case- and separator-insensitive. */ +export function isUssdSensitiveKey(key: string): boolean { + return USSD_SENSITIVE_KEYS.has(key.toLowerCase().replace(/[_-]/g, '')); +} + +/** + * The SIM's OWN number (MSISDN) — its own class, matched WHOLE after case-, + * separator- AND dot-folding so ModemManager's `Modem.OwnNumbers` and mmcli's + * `modem.generic.own-numbers` are both caught by one rule. + * + * It cannot join `SENSITIVE_KEYS`: that set matches a leaf name, and `number` / + * `numbers` are far too common — a slot number and a band count would both + * vanish. `msisdn` stays in the SMS set (its historical home) and is not + * duplicated here. + * + * It is DISPLAYED to the operator behind an explicit reveal. That is a + * rendering decision about a surface the subscriber already owns; it does not + * make the value loggable, so it is redacted exactly like a PIN. + */ +const OWN_NUMBER_SENSITIVE_KEYS: ReadonlySet = new Set([ + 'ownnumber', + 'ownnumbers', + 'phonenumber', + 'phonenumbers', + 'simnumber', + 'subscribernumber', + 'modemgenericownnumbers', + 'modemownnumbers', +]); + +/** Whole-key own-number test, case-, separator- and dot-insensitive. */ +export function isOwnNumberSensitiveKey(key: string): boolean { + return OWN_NUMBER_SENSITIVE_KEYS.has(key.toLowerCase().replace(/[_.-]/g, '')); +} + function isSensitiveKey(key: string): boolean { const lower = key.toLowerCase(); - if (SENSITIVE_KEYS.has(lower)) { + if (SENSITIVE_KEYS.has(lower) || LOCATION_KEYS.has(lower)) { + return true; + } + if (isSmsSensitiveKey(key) || isUssdSensitiveKey(key) || isOwnNumberSensitiveKey(key)) { return true; } const dot = lower.lastIndexOf('.'); - return dot >= 0 && SENSITIVE_KEYS.has(lower.slice(dot + 1)); + if (dot < 0) { + return false; + } + const leaf = lower.slice(dot + 1); + return SENSITIVE_KEYS.has(leaf) || LOCATION_KEYS.has(leaf); } function isPlainObject(value: unknown): value is Record { diff --git a/control/src/safety/composition-root.test.ts b/control/src/safety/composition-root.test.ts new file mode 100644 index 0000000..8cb9bc8 --- /dev/null +++ b/control/src/safety/composition-root.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, test } from 'bun:test'; +import { physicalModemId } from '../domain'; +import type { MutationAdmissionPort, ResourceOwnershipPort } from '../ports'; +import { + CompositionRootAlreadyExistsError, + createModemControlCompositionRoot, + MissingResourceOwnershipPortError, +} from './composition-root'; + +const admission: MutationAdmissionPort = { + acquire: () => Promise.resolve({ status: 'refused', reason: 'admission-refused' }), +}; +const ownership: ResourceOwnershipPort = { + acquire: () => Promise.resolve({ status: 'refused', reason: 'already-owned' }), +}; + +describe('modem control composition root', () => { + test('Given no ownership port, When a root is constructed, Then construction fails hard', () => { + expect(() => createModemControlCompositionRoot({ admission, ownership: undefined })).toThrow( + MissingResourceOwnershipPortError, + ); + }); + + test('Given one live root, When a second root is constructed, Then construction throws', async () => { + const first = createModemControlCompositionRoot({ admission, ownership }); + try { + expect(() => createModemControlCompositionRoot({ admission, ownership })).toThrow( + CompositionRootAlreadyExistsError, + ); + } finally { + await first.dispose(); + } + }); + + test('Given callers ask for the same physical modem, When they obtain an actor, Then the root shares one actor', async () => { + const root = createModemControlCompositionRoot({ admission, ownership }); + try { + const modem = physicalModemId('serial:shared-actor'); + expect(root.actorFor(modem)).toBe(root.actorFor(modem)); + } finally { + await root.dispose(); + } + }); +}); diff --git a/control/src/safety/composition-root.ts b/control/src/safety/composition-root.ts new file mode 100644 index 0000000..13aa181 --- /dev/null +++ b/control/src/safety/composition-root.ts @@ -0,0 +1,88 @@ +import { ModemActor, type QuiesceHook } from '../backend/modem-actor'; +import type { PhysicalModemId } from '../domain'; +import type { + MutationAdmissionPort, + ResourceOwnershipLease, + ResourceOwnershipPort, + ResourceOwnershipRequest, + ResourceOwnershipResult, + UhubctlPort, +} from '../ports'; + +let compositionRootExists = false; + +export class CompositionRootAlreadyExistsError extends Error { + override readonly name = 'CompositionRootAlreadyExistsError'; + + constructor() { + super('a modem-control composition root already exists in this process'); + } +} + +export type ModemControlCompositionRootOptions = { + readonly admission: MutationAdmissionPort; + readonly ownership: ResourceOwnershipPort | undefined; + readonly quiesce?: QuiesceHook; + readonly uhubctl?: UhubctlPort; +}; + +export class ModemControlCompositionRoot { + readonly admission: MutationAdmissionPort; + readonly ownership: ResourceOwnershipPort; + readonly uhubctl: UhubctlPort | undefined; + readonly #quiesce: QuiesceHook | undefined; + readonly #actors = new Map(); + readonly #ownershipLeases: ResourceOwnershipLease[] = []; + #disposed = false; + + constructor(options: ModemControlCompositionRootOptions) { + if (compositionRootExists) throw new CompositionRootAlreadyExistsError(); + if (options.ownership === undefined) throw new MissingResourceOwnershipPortError(); + compositionRootExists = true; + this.admission = options.admission; + this.ownership = options.ownership; + this.#quiesce = options.quiesce; + this.uhubctl = options.uhubctl; + } + + actorFor(modemId: PhysicalModemId): ModemActor { + const existing = this.#actors.get(modemId); + if (existing !== undefined) return existing; + const actor = new ModemActor(this.#quiesce); + this.#actors.set(modemId, actor); + return actor; + } + + async acquireOwnership(request: ResourceOwnershipRequest): Promise { + const result = await this.ownership.acquire(request); + if (result.status === 'acquired') this.#ownershipLeases.push(result.lease); + return result; + } + + async dispose(): Promise { + if (this.#disposed) return; + this.#disposed = true; + try { + for (const lease of this.#ownershipLeases.splice(0).reverse()) { + await lease.release(); + } + } finally { + this.#actors.clear(); + compositionRootExists = false; + } + } +} + +export class MissingResourceOwnershipPortError extends Error { + override readonly name = 'MissingResourceOwnershipPortError'; + + constructor() { + super('a ResourceOwnershipPort is required; no pass-through default exists'); + } +} + +export function createModemControlCompositionRoot( + options: ModemControlCompositionRootOptions, +): ModemControlCompositionRoot { + return new ModemControlCompositionRoot(options); +} diff --git a/control/src/safety/flock-resource-ownership.ts b/control/src/safety/flock-resource-ownership.ts new file mode 100644 index 0000000..fe0fa05 --- /dev/null +++ b/control/src/safety/flock-resource-ownership.ts @@ -0,0 +1,174 @@ +import { type ChildProcessWithoutNullStreams, spawn } from 'node:child_process'; +import { readFile } from 'node:fs/promises'; +import type { + ResourceOwnershipHolder, + ResourceOwnershipLease, + ResourceOwnershipPort, + 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; +}; + +export class FlockResourceOwnershipError extends Error { + override readonly name = 'FlockResourceOwnershipError'; + + constructor(readonly detail: string) { + super(`flock ownership failed: ${detail}`); + } +} + +type ChildStart = + | { readonly status: 'acquired'; readonly holder: ResourceOwnershipHolder } + | { readonly status: 'closed'; readonly code: number | null; readonly stderr: string }; + +export function createFlockResourceOwnershipPort( + options: FlockResourceOwnershipOptions, +): ResourceOwnershipPort { + return { + 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'], + }, + ); + const closed = childClosed(child); + const started = await childStarted(child); + if (started.status === 'closed') { + if (started.code === 1) { + const holder = await readHolder(options.lockPath); + return { + status: 'refused', + reason: 'already-owned', + ...(holder !== undefined && pidIsAlive(holder.pid) ? { holder } : {}), + }; + } + throw new FlockResourceOwnershipError( + `helper exited ${started.code ?? 'by signal'}${started.stderr ? `: ${started.stderr}` : ''}`, + ); + } + + return { + status: 'acquired', + lease: createLease(child, closed, started.holder), + }; + }, + }; +} + +function createLease( + child: ChildProcessWithoutNullStreams, + closed: Promise, + holder: ResourceOwnershipHolder, +): ResourceOwnershipLease { + let released = false; + return { + holder, + lost: closed.then(() => ({ reason: 'holder-exited' }) as const), + async release(): Promise { + if (released) return; + released = true; + if (child.exitCode === null && child.signalCode === null) { + child.stdin.end(); + } + await closed; + }, + }; +} + +function childClosed(child: ChildProcessWithoutNullStreams): Promise { + return new Promise((resolve) => child.once('close', () => resolve())); +} + +function childStarted(child: ChildProcessWithoutNullStreams): Promise { + return new Promise((resolve) => { + let stdout = ''; + let stderr = ''; + let settled = false; + const finish = (result: ChildStart): void => { + if (settled) return; + settled = true; + resolve(result); + }; + child.stderr.on('data', (chunk: Buffer) => { + stderr += chunk.toString('utf8'); + }); + child.stdout.on('data', (chunk: Buffer) => { + stdout += chunk.toString('utf8'); + const newline = stdout.indexOf('\n'); + if (newline < 0) return; + const holder = parseAcquiredLine(stdout.slice(0, newline)); + if (holder !== undefined) finish({ status: 'acquired', holder }); + }); + child.once('close', (code) => finish({ status: 'closed', code, stderr: stderr.trim() })); + }); +} + +function parseAcquiredLine(line: string): ResourceOwnershipHolder | undefined { + try { + const parsed: unknown = JSON.parse(line); + if (typeof parsed !== 'object' || parsed === null || !('holder' in parsed)) return undefined; + return parseHolder(parsed.holder); + } catch (error) { + if (error instanceof SyntaxError) return undefined; + throw error; + } +} + +async function readHolder(lockPath: string): Promise { + try { + return parseHolder(JSON.parse(await readFile(lockPath, 'utf8'))); + } catch (error) { + if (error instanceof Error && 'code' in error && error.code === 'ENOENT') return undefined; + if (error instanceof SyntaxError) return undefined; + throw error; + } +} + +function parseHolder(value: unknown): ResourceOwnershipHolder | undefined { + if (typeof value !== 'object' || value === null) return undefined; + if (!('pid' in value) || !('startedAtEpochMs' in value)) return undefined; + if (typeof value.pid !== 'number' || !Number.isSafeInteger(value.pid) || value.pid <= 0) { + return undefined; + } + if (typeof value.startedAtEpochMs !== 'number' || !Number.isFinite(value.startedAtEpochMs)) { + return undefined; + } + return { pid: value.pid, startedAtEpochMs: value.startedAtEpochMs }; +} + +function pidIsAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch (error) { + if (error instanceof Error && 'code' in error && error.code === 'ESRCH') return false; + return true; + } +} diff --git a/control/src/safety/index.ts b/control/src/safety/index.ts new file mode 100644 index 0000000..f0702ca --- /dev/null +++ b/control/src/safety/index.ts @@ -0,0 +1,2 @@ +export * from './composition-root'; +export * from './flock-resource-ownership'; diff --git a/control/src/safety/resource-ownership.integration.test.ts b/control/src/safety/resource-ownership.integration.test.ts new file mode 100644 index 0000000..056171d --- /dev/null +++ b/control/src/safety/resource-ownership.integration.test.ts @@ -0,0 +1,96 @@ +import { afterEach, describe, expect, test } from 'bun:test'; +import { type ChildProcessWithoutNullStreams, spawn } from 'node:child_process'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +type RootMessage = + | { readonly type: 'acquired'; readonly holderPid: number } + | { readonly type: 'refused'; readonly reason: 'already-owned'; readonly holderPid?: number } + | { readonly type: 'expired' } + | { readonly type: 'released' }; + +const children: ChildProcessWithoutNullStreams[] = []; +const tempPaths: string[] = []; + +afterEach(async () => { + for (const child of children.splice(0)) { + if (child.exitCode === null) { + child.stdin.end('release\n'); + await new Promise((resolve) => child.once('exit', () => resolve())); + } + } + for (const path of tempPaths.splice(0)) { + await rm(path, { recursive: true, force: true }); + } +}); + +async function lockPath(): Promise { + const directory = await mkdtemp(join(tmpdir(), 'modem-control-lock-')); + tempPaths.push(directory); + return join(directory, 'ownership.lock'); +} + +function spawnRoot(path: string): ChildProcessWithoutNullStreams { + const child = spawn(process.execPath, ['control/test-support/ownership-root-fixture.ts', path], { + cwd: process.cwd(), + stdio: ['pipe', 'pipe', 'pipe'], + }); + children.push(child); + return child; +} + +function nextMessage(child: ChildProcessWithoutNullStreams): Promise { + return new Promise((resolve, reject) => { + let buffer = ''; + const onData = (chunk: Buffer): void => { + buffer += chunk.toString('utf8'); + const newline = buffer.indexOf('\n'); + if (newline < 0) return; + cleanup(); + resolve(JSON.parse(buffer.slice(0, newline)) as RootMessage); + }; + const onExit = (code: number | null): void => { + cleanup(); + reject(new Error(`ownership fixture exited before a message (code ${code})`)); + }; + const cleanup = (): void => { + child.stdout.off('data', onData); + child.off('exit', onExit); + }; + child.stdout.on('data', onData); + child.once('exit', onExit); + }); +} + +describe('flock resource ownership across composition roots', () => { + 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)]; + const results = await Promise.all(roots.map(nextMessage)); + + expect(results.filter((result) => result.type === 'acquired')).toHaveLength(1); + expect(results.filter((result) => result.type === 'refused')).toHaveLength(1); + }); + + test('Given a live holder, When its lock process is killed, Then no successor steals before PID expiry and one acquires after expiry', async () => { + const path = await lockPath(); + const holderRoot = spawnRoot(path); + const acquired = await nextMessage(holderRoot); + expect(acquired.type).toBe('acquired'); + if (acquired.type !== 'acquired') return; + + const liveContender = spawnRoot(path); + expect(await nextMessage(liveContender)).toMatchObject({ + type: 'refused', + reason: 'already-owned', + holderPid: acquired.holderPid, + }); + + process.kill(acquired.holderPid, 'SIGKILL'); + expect(await nextMessage(holderRoot)).toEqual({ type: 'expired' }); + + const successor = spawnRoot(path); + expect(await nextMessage(successor)).toMatchObject({ type: 'acquired' }); + }); +}); diff --git a/control/src/sms/dbus-messaging.test.ts b/control/src/sms/dbus-messaging.test.ts new file mode 100644 index 0000000..7335d92 --- /dev/null +++ b/control/src/sms/dbus-messaging.test.ts @@ -0,0 +1,279 @@ +// The Messaging adapter, driven over a fake transport. +// +// The claims under test are the ones that only show up against a live bus: the +// inbox is listed ONCE and never re-listed on a tick, an arrival reaches the +// consumer through the `Added` signal, a message that vanished between the list +// and its read is skipped rather than failing the whole inbox, and a reconnect +// republishes an authoritative list instead of a stream of Added events. + +import { describe, expect, test } from 'bun:test'; +import type { SmsInboxEvent } from '../ports/sms'; +import type { + DbusTransport, + DbusValue, + MethodCall, + MethodReply, + SignalListener, + SignalSpec, + Subscription, + TransportEvent, +} from '../transport'; +import { createDbusSmsPort, smsFromProps } from './dbus-messaging'; +import { createSmsInboxStore } from './inbox-store'; + +const MODEM = '/org/freedesktop/ModemManager1/Modem/0'; +const variant = (signature: string, value: DbusValue): DbusValue => ({ signature, value }); + +const props = (over: Record = {}): DbusValue => + Object.entries({ + State: variant('u', 3), + Number: variant('s', '85573'), + Text: variant('s', 'neutral body'), + Timestamp: variant('s', '2025-08-21T17:20:16-05'), + ...over, + }).map(([key, value]) => [key, value] as unknown as DbusValue); + +interface FakeBus { + readonly transport: DbusTransport; + readonly calls: MethodCall[]; + inbox: string[]; + missing: Set; + listError?: Error; + emitSignal(member: string, body: DbusValue[]): void; + emitReconnect(): void; + subscriptions: number; +} + +function fakeBus(): FakeBus { + const calls: MethodCall[] = []; + const listeners = new Map>(); + const handlers = new Map void>>(); + const bus: FakeBus = { + calls, + inbox: [], + missing: new Set(), + subscriptions: 0, + transport: { + async connect(): Promise {}, + async disconnect(): Promise {}, + isConnected: () => true, + async callMethod(call: MethodCall): Promise { + calls.push(call); + if (call.member === 'List') { + if (bus.listError !== undefined) { + throw bus.listError; + } + return { signature: 'ao', body: [bus.inbox as unknown as DbusValue] }; + } + if (bus.missing.has(call.path)) { + throw new Error('No such object'); + } + return { signature: 'a{sv}', body: [props()] }; + }, + async subscribeSignal(spec: SignalSpec, listener: SignalListener): Promise { + const key = spec.member; + const set = listeners.get(key) ?? new Set(); + set.add(listener); + listeners.set(key, set); + bus.subscriptions += 1; + return { + async unsubscribe(): Promise { + set.delete(listener); + bus.subscriptions -= 1; + }, + }; + }, + on(event: TransportEvent, handler: (payload?: unknown) => void): void { + const set = handlers.get(event) ?? new Set<(payload?: unknown) => void>(); + set.add(handler); + handlers.set(event, set); + }, + off(event: TransportEvent, handler: (payload?: unknown) => void): void { + handlers.get(event)?.delete(handler); + }, + subscriptionCount: () => bus.subscriptions, + } as unknown as DbusTransport, + emitSignal(member: string, body: DbusValue[]): void { + for (const listener of listeners.get(member) ?? []) { + listener({ + path: MODEM, + interface: 'org.freedesktop.ModemManager1.Modem.Messaging', + member, + sender: undefined, + signature: 'ob', + body, + }); + } + }, + emitReconnect(): void { + for (const handler of handlers.get('reconnected') ?? []) { + handler(); + } + }, + }; + return bus; +} + +const settle = (): Promise => new Promise((resolve) => setTimeout(resolve, 0)); + +describe('smsFromProps', () => { + test('maps MMSmsState by ordinal and keeps the raw timestamp', () => { + const message = smsFromProps( + '/org/freedesktop/ModemManager1/SMS/36', + props() as unknown as never, + ); + expect(message).toEqual({ + id: '36', + from: '85573', + text: 'neutral body', + timestamp: '2025-08-21T17:20:16-05', + state: 'received', + }); + }); + + test('an out-of-range state ordinal folds to `unknown`, never a number', () => { + const message = smsFromProps( + '/org/freedesktop/ModemManager1/SMS/1', + props({ State: variant('u', 99) }) as unknown as never, + ); + expect(message.state).toBe('unknown'); + }); + + test('an absent body reports `` rather than dropping the field', () => { + const message = smsFromProps( + '/org/freedesktop/ModemManager1/SMS/1', + props({ Text: variant('s', '') }) as unknown as never, + ); + expect(message.text).toBe(''); + expect(message.from).toBe('85573'); + }); +}); + +describe('list()', () => { + test('reads every message once, newest first', async () => { + const bus = fakeBus(); + bus.inbox = ['/org/freedesktop/ModemManager1/SMS/1', '/org/freedesktop/ModemManager1/SMS/2']; + const port = createDbusSmsPort({ transport: bus.transport, modemPath: MODEM }); + + const result = await port.list(); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.messages.map((entry) => entry.id)).toEqual(['2', '1']); + } + expect(bus.calls.filter((call) => call.member === 'List')).toHaveLength(1); + expect(bus.calls.filter((call) => call.member === 'GetAll')).toHaveLength(2); + }); + + test('a message that vanished between list and read is SKIPPED', async () => { + const bus = fakeBus(); + bus.inbox = ['/org/freedesktop/ModemManager1/SMS/1', '/org/freedesktop/ModemManager1/SMS/2']; + bus.missing.add('/org/freedesktop/ModemManager1/SMS/2'); + const port = createDbusSmsPort({ transport: bus.transport, modemPath: MODEM }); + + const result = await port.list(); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.messages.map((entry) => entry.id)).toEqual(['1']); + } + }); + + test('a failed LIST is a typed refusal, never an empty inbox', async () => { + const bus = fakeBus(); + bus.listError = new Error('error: modem has no messaging capabilities'); + const port = createDbusSmsPort({ transport: bus.transport, modemPath: MODEM }); + + const result = await port.list(); + expect(result).toEqual({ ok: false, reason: 'unsupported' }); + }); + + test('the read is bounded BEFORE any per-message call', async () => { + const bus = fakeBus(); + bus.inbox = Array.from( + { length: 120 }, + (_, index) => `/org/freedesktop/ModemManager1/SMS/${index}`, + ); + const port = createDbusSmsPort({ transport: bus.transport, modemPath: MODEM }); + + const result = await port.list(); + expect(result.ok && result.messages).toHaveLength(50); + expect(bus.calls.filter((call) => call.member === 'GetAll')).toHaveLength(50); + }); +}); + +describe('observe() — signals, never a poll', () => { + test('an arrival reaches the consumer through Added', async () => { + const bus = fakeBus(); + const port = createDbusSmsPort({ transport: bus.transport, modemPath: MODEM }); + const seen: SmsInboxEvent[] = []; + port.observe((event) => seen.push(event)); + await settle(); + + bus.emitSignal('Added', ['/org/freedesktop/ModemManager1/SMS/9', true]); + await settle(); + + expect(seen.filter((event) => event.kind === 'added')).toHaveLength(1); + await port.stop(); + }); + + test('Deleted names the retired row by index', async () => { + const bus = fakeBus(); + const port = createDbusSmsPort({ transport: bus.transport, modemPath: MODEM }); + const seen: SmsInboxEvent[] = []; + port.observe((event) => seen.push(event)); + await settle(); + + bus.emitSignal('Deleted', ['/org/freedesktop/ModemManager1/SMS/9']); + await settle(); + + expect(seen).toContainEqual({ kind: 'deleted', id: '9' }); + await port.stop(); + }); + + test('observing NEVER re-lists on its own — only wiring and reconnects list', async () => { + const bus = fakeBus(); + bus.inbox = ['/org/freedesktop/ModemManager1/SMS/1']; + const port = createDbusSmsPort({ transport: bus.transport, modemPath: MODEM }); + port.observe(() => undefined); + await settle(); + + const afterWiring = bus.calls.filter((call) => call.member === 'List').length; + bus.emitSignal('Added', ['/org/freedesktop/ModemManager1/SMS/2', true]); + bus.emitSignal('Deleted', ['/org/freedesktop/ModemManager1/SMS/1']); + await settle(); + + expect(bus.calls.filter((call) => call.member === 'List')).toHaveLength(afterWiring); + await port.stop(); + }); + + test('a reconnect resyncs — and the store does not double the inbox', async () => { + const bus = fakeBus(); + bus.inbox = ['/org/freedesktop/ModemManager1/SMS/1', '/org/freedesktop/ModemManager1/SMS/2']; + const port = createDbusSmsPort({ transport: bus.transport, modemPath: MODEM }); + const store = createSmsInboxStore(); + port.observe((event) => store.apply(event)); + await settle(); + expect(store.size()).toBe(2); + + bus.emitReconnect(); + await settle(); + + expect(store.size()).toBe(2); + await port.stop(); + }); + + test('stop() releases every subscription and detaches the reconnect handler', async () => { + const bus = fakeBus(); + const port = createDbusSmsPort({ transport: bus.transport, modemPath: MODEM }); + port.observe(() => undefined); + await settle(); + expect(bus.subscriptions).toBe(2); + + await port.stop(); + expect(bus.subscriptions).toBe(0); + + const before = bus.calls.length; + bus.emitReconnect(); + await settle(); + expect(bus.calls).toHaveLength(before); + }); +}); diff --git a/control/src/sms/dbus-messaging.ts b/control/src/sms/dbus-messaging.ts new file mode 100644 index 0000000..8aa4895 --- /dev/null +++ b/control/src/sms/dbus-messaging.ts @@ -0,0 +1,229 @@ +// The ModemManager Messaging adapter — LIST ONCE, then follow Added / Deleted. +// +// Three verbs, all reads: `Messaging.List` for the object paths, +// `Properties.GetAll` per message, and a subscription to `Added` / `Deleted`. +// There is no `Create`, no `Send`, no `Delete`, and none may be added — see +// `../ports/sms.ts` for why that is permanent rather than staged, and +// `readonly-gate.test.ts` for the lock. +// +// NEVER RE-LIST ON A TICK. MM tells us about every change, so a poll would cost +// one method call per stored message per tick and still could not report an +// arrival sooner than the tick it happened to land on. The ONLY re-list is on a +// source RECONNECT, where the events that occurred while the bus was down were +// never delivered and folding is therefore impossible; that re-list is published +// as `resynced` so the consumer replaces its rows instead of merging (see +// `inbox-store.ts`). +// +// A MESSAGE THAT VANISHES BETWEEN THE LIST AND ITS READ IS ORDINARY. The modem's +// own storage rotates, so a failed per-message read SKIPS that message rather +// than failing the whole inbox. A failed LIST is different — nothing was read at +// all — and answers a typed refusal. + +import { MM_BUS_NAME, PROPERTIES_IFACE } from '../backend/constants'; +import { type DecodedProps, numberProp, stringProp } from '../backend/managed-objects'; +// The one definition of MM's Messaging interface name lives with the capability +// probe that also keys on it; duplicating it here is how the two drift apart. +import { MESSAGING_IFACE } from '../capability/detect'; +import type { Unsubscribe } from '../ports/observation'; +import type { + SmsInboxEvent, + SmsInboxListener, + SmsInboxResult, + SmsMessage, + SmsObservationPort, + SmsState, +} from '../ports/sms'; +import type { DbusTransport, DbusValue, Subscription } from '../transport'; +import { classifySmsFailure } from './mmcli-parse'; +import { normalizeSmsState, SMS_INBOX_CAP, selectReadablePaths, sortAndCapSms } from './normalize'; + +/** `org.freedesktop.ModemManager1.Sms` — one message object. */ +export const SMS_IFACE = 'org.freedesktop.ModemManager1.Sms'; + +/** + * `MMSmsState` (ModemManager `mm-enums.h`) as the port's own vocabulary. The + * index IS the enum value; an out-of-range value folds to `unknown` through + * {@link normalizeSmsState}, so a future MM state can never surface as a raw + * number an operator cannot act on. + */ +const SMS_STATE_BY_ORDINAL: readonly string[] = [ + 'unknown', + 'stored', + 'receiving', + 'received', + 'sending', + 'sent', +]; + +function stateFromOrdinal(ordinal: number | undefined): SmsState { + if (ordinal === undefined) { + return 'unknown'; + } + return normalizeSmsState(SMS_STATE_BY_ORDINAL[ordinal]); +} + +/** Build one message from an SMS object's decoded `Sms` properties. */ +export function smsFromProps(path: string, props: DecodedProps | undefined): SmsMessage { + const from = stringProp(props, 'Number'); + const timestamp = stringProp(props, 'Timestamp'); + const index = path.slice(path.lastIndexOf('/') + 1); + return { + id: index, + ...(from !== undefined && from !== '' ? { from } : {}), + ...(timestamp !== undefined && timestamp !== '' ? { timestamp } : {}), + // A data-only (WAP/PDU) message carries no text at all; `''` says so. + text: stringProp(props, 'Text') ?? '', + state: stateFromOrdinal(numberProp(props, 'State')), + }; +} + +export interface DbusSmsPortOptions { + readonly transport: DbusTransport; + /** The modem's ModemManager object path. */ + readonly modemPath: string; + readonly destination?: string; + readonly cap?: number; + /** Called when a fold-time error must be surfaced without failing a read. */ + readonly onError?: (error: unknown) => void; +} + +function describe(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +export function createDbusSmsPort(options: DbusSmsPortOptions): SmsObservationPort { + const { transport, modemPath } = options; + const destination = options.destination ?? MM_BUS_NAME; + const cap = options.cap ?? SMS_INBOX_CAP; + const listeners = new Set(); + const subscriptions: Subscription[] = []; + let wiring: Promise | undefined; + let stopped = false; + + const emit = (event: SmsInboxEvent): void => { + for (const listener of listeners) { + try { + listener(event); + } catch (error) { + options.onError?.(error); + } + } + }; + + const readMessage = async (path: string): Promise => { + try { + const reply = await transport.callMethod({ + destination, + path, + interface: PROPERTIES_IFACE, + member: 'GetAll', + signature: 's', + args: [SMS_IFACE], + }); + return smsFromProps(path, reply.body[0] as unknown as DecodedProps); + } catch { + // Vanished between the list and this read — ordinary storage rotation. + // Nothing is logged: the error text is the only place a body could hide. + return undefined; + } + }; + + const listInbox = async (): Promise => { + let paths: readonly DbusValue[]; + try { + const reply = await transport.callMethod({ + destination, + path: modemPath, + interface: MESSAGING_IFACE, + member: 'List', + }); + paths = Array.isArray(reply.body[0]) ? (reply.body[0] as DbusValue[]) : []; + } catch (error) { + return { ok: false, reason: classifySmsFailure(describe(error)) }; + } + + const candidates = selectReadablePaths( + paths.filter((value): value is string => typeof value === 'string'), + cap, + ); + const messages: SmsMessage[] = []; + for (const path of candidates) { + const message = await readMessage(path); + if (message !== undefined) { + messages.push(message); + } + } + return { ok: true, messages: sortAndCapSms(messages, cap) }; + }; + + const resync = async (): Promise => { + const result = await listInbox(); + if (result.ok) { + emit({ kind: 'resynced', messages: result.messages }); + } + }; + + const onAdded = async (body: readonly DbusValue[]): Promise => { + const path = body[0]; + if (typeof path !== 'string') { + return; + } + const message = await readMessage(path); + if (message !== undefined) { + emit({ kind: 'added', message }); + } + }; + + const wire = async (): Promise => { + const added = await transport.subscribeSignal( + { interface: MESSAGING_IFACE, member: 'Added', path: modemPath }, + (event) => { + void onAdded(event.body).catch((error) => options.onError?.(error)); + }, + ); + const deleted = await transport.subscribeSignal( + { interface: MESSAGING_IFACE, member: 'Deleted', path: modemPath }, + (event) => { + const path = event.body[0]; + if (typeof path === 'string') { + emit({ kind: 'deleted', id: path.slice(path.lastIndexOf('/') + 1) }); + } + }, + ); + subscriptions.push(added, deleted); + transport.on('reconnected', reconnected); + // A subscription established AFTER the caller's own `list()` can have + // missed an arrival in between, so the wiring ends with one resync. + await resync(); + }; + + const reconnected = (): void => { + void resync().catch((error) => options.onError?.(error)); + }; + + return { + list: listInbox, + + observe(listener: SmsInboxListener): Unsubscribe { + listeners.add(listener); + if (wiring === undefined && !stopped) { + wiring = wire().catch((error) => { + options.onError?.(error); + }); + } + return () => { + listeners.delete(listener); + }; + }, + + async stop(): Promise { + stopped = true; + listeners.clear(); + transport.off('reconnected', reconnected); + await wiring?.catch(() => undefined); + wiring = undefined; + const held = subscriptions.splice(0, subscriptions.length); + await Promise.all(held.map((subscription) => subscription.unsubscribe())); + }, + }; +} diff --git a/control/src/sms/inbox-store.test.ts b/control/src/sms/inbox-store.test.ts new file mode 100644 index 0000000..a8446db --- /dev/null +++ b/control/src/sms/inbox-store.test.ts @@ -0,0 +1,100 @@ +// The two failures a signal-driven inbox produces on its own: a duplicated +// message, and an inbox that doubles after a source restart. + +import { describe, expect, test } from 'bun:test'; +import type { SmsMessage } from '../ports/sms'; +import { createSmsInboxStore } from './inbox-store'; + +const message = (id: string, over: Partial = {}): SmsMessage => ({ + id, + text: `body ${id}`, + state: 'received', + timestamp: `2025-08-2${id.length}T10:0${id.at(-1)}:00-05`, + ...over, +}); + +describe('duplicate suppression', () => { + test('a repeated Added for an identical row changes nothing', () => { + const store = createSmsInboxStore(); + expect(store.apply({ kind: 'added', message: message('1') })).toBe(true); + expect(store.apply({ kind: 'added', message: message('1') })).toBe(false); + expect(store.size()).toBe(1); + expect(store.snapshot()).toHaveLength(1); + }); + + test('MM emits Added twice for one arrival (receiving, then received)', () => { + // The real duplicate on the wire is not byte-identical: MM announces the + // message while it is still receiving, then again once it is stored. The + // row must UPDATE rather than accumulate. + const store = createSmsInboxStore(); + store.apply({ kind: 'added', message: message('7', { state: 'receiving', text: '' }) }); + expect(store.apply({ kind: 'added', message: message('7') })).toBe(true); + expect(store.size()).toBe(1); + expect(store.snapshot()[0]?.state).toBe('received'); + }); + + test('a Deleted for a row we never held is not a change', () => { + const store = createSmsInboxStore(); + expect(store.apply({ kind: 'deleted', id: '404' })).toBe(false); + }); + + test('a Deleted retires the row', () => { + const store = createSmsInboxStore(); + store.apply({ kind: 'added', message: message('1') }); + expect(store.apply({ kind: 'deleted', id: '1' })).toBe(true); + expect(store.snapshot()).toEqual([]); + }); +}); + +describe('restart recovery', () => { + test('a resync REPLACES the rows — a re-list never doubles the inbox', () => { + const store = createSmsInboxStore(); + store.apply({ kind: 'added', message: message('1') }); + store.apply({ kind: 'added', message: message('2') }); + + // The source came back and re-listed the SAME two messages. + expect(store.apply({ kind: 'resynced', messages: [message('1'), message('2')] })).toBe(false); + expect(store.size()).toBe(2); + expect( + store + .snapshot() + .map((entry) => entry.id) + .sort(), + ).toEqual(['1', '2']); + }); + + test('a message deleted while the source was down is dropped, not kept', () => { + // This is why a resync may not be folded as a series of Added events: + // nothing would ever retire row 2. + const store = createSmsInboxStore(); + store.apply({ kind: 'resynced', messages: [message('1'), message('2')] }); + expect(store.apply({ kind: 'resynced', messages: [message('1')] })).toBe(true); + expect(store.snapshot().map((entry) => entry.id)).toEqual(['1']); + }); + + test('a message that ARRIVED while the source was down appears once', () => { + const store = createSmsInboxStore(); + store.apply({ kind: 'added', message: message('1') }); + store.apply({ kind: 'resynced', messages: [message('1'), message('2')] }); + store.apply({ kind: 'added', message: message('2') }); + expect(store.size()).toBe(2); + }); + + test('an empty resync clears an inbox the operator emptied elsewhere', () => { + const store = createSmsInboxStore(); + store.apply({ kind: 'added', message: message('1') }); + expect(store.apply({ kind: 'resynced', messages: [] })).toBe(true); + expect(store.snapshot()).toEqual([]); + }); +}); + +describe('the snapshot obeys the shared ordering + cap', () => { + test('newest first and capped', () => { + const store = createSmsInboxStore(2); + store.apply({ kind: 'added', message: message('1', { timestamp: '2025-08-21T10:00:00-05' }) }); + store.apply({ kind: 'added', message: message('2', { timestamp: '2025-08-23T10:00:00-05' }) }); + store.apply({ kind: 'added', message: message('3', { timestamp: '2025-08-22T10:00:00-05' }) }); + expect(store.snapshot().map((entry) => entry.id)).toEqual(['2', '3']); + expect(store.size()).toBe(3); + }); +}); diff --git a/control/src/sms/inbox-store.ts b/control/src/sms/inbox-store.ts new file mode 100644 index 0000000..c521272 --- /dev/null +++ b/control/src/sms/inbox-store.ts @@ -0,0 +1,100 @@ +// The inbox row store — folds `Added` / `Deleted` / resync events into one +// newest-first, capped list. +// +// TWO PROPERTIES ARE THE WHOLE POINT OF THIS FILE, and both are failures a +// signal-driven inbox produces on its own unless something stops them. +// +// DUPLICATE SUPPRESSION. ModemManager emits `Added` for a message it is +// receiving AND again once it is stored, a re-subscribe after a bus hiccup can +// replay one, and a consumer that also re-listed would hold the same message +// twice. Rows are therefore keyed by object index and an `Added` carrying +// nothing new is a NO-OP — `apply` answers `false` and no consumer is woken. +// +// RESTART RECOVERY. A source restart cannot be folded, because the events that +// happened while it was down were never delivered. `resynced` therefore REPLACES +// the whole row set rather than merging into it: merging would keep a message +// deleted during the outage forever, and re-adding each row of a fresh list one +// `added` at a time is exactly how a restart comes to duplicate the inbox it +// already had. + +import type { SmsInboxEvent, SmsMessage } from '../ports/sms'; +import { SMS_INBOX_CAP, sortAndCapSms } from './normalize'; + +export interface SmsInboxStore { + /** Apply one event. Answers whether the visible inbox actually changed. */ + apply(event: SmsInboxEvent): boolean; + /** The current inbox, newest-first and capped. */ + snapshot(): SmsMessage[]; + /** Number of retained rows, before the cap is applied. */ + size(): number; +} + +/** Two rows are the same message when every rendered field matches. */ +function sameMessage(a: SmsMessage, b: SmsMessage): boolean { + return ( + a.id === b.id && + a.text === b.text && + a.state === b.state && + a.from === b.from && + a.timestamp === b.timestamp + ); +} + +export function createSmsInboxStore(cap: number = SMS_INBOX_CAP): SmsInboxStore { + const rows = new Map(); + + const applyAdded = (message: SmsMessage): boolean => { + const held = rows.get(message.id); + // A repeated Added for a row we already hold VERBATIM is the duplicate + // this store exists to swallow. An Added that genuinely differs is a + // state transition (receiving -> received) and does update the row. + if (held !== undefined && sameMessage(held, message)) { + return false; + } + rows.set(message.id, message); + return true; + }; + + const applyResynced = (messages: readonly SmsMessage[]): boolean => { + const next = new Map(); + for (const message of messages) { + next.set(message.id, message); + } + // Report "unchanged" when the authoritative list matches what is already + // held, so a reconnect that found nothing new broadcasts nothing. + let changed = next.size !== rows.size; + if (!changed) { + for (const [id, message] of next) { + const held = rows.get(id); + if (held === undefined || !sameMessage(held, message)) { + changed = true; + break; + } + } + } + rows.clear(); + for (const [id, message] of next) { + rows.set(id, message); + } + return changed; + }; + + return { + apply(event: SmsInboxEvent): boolean { + switch (event.kind) { + case 'added': + return applyAdded(event.message); + case 'deleted': + return rows.delete(event.id); + case 'resynced': + return applyResynced(event.messages); + } + }, + snapshot(): SmsMessage[] { + return sortAndCapSms([...rows.values()], cap); + }, + size(): number { + return rows.size; + }, + }; +} diff --git a/control/src/sms/index.ts b/control/src/sms/index.ts new file mode 100644 index 0000000..3dfa0f7 --- /dev/null +++ b/control/src/sms/index.ts @@ -0,0 +1,11 @@ +// The read-only SMS surface: normalization, the `mmcli -K` grammar, the +// Added/Deleted fold, and the ModemManager Messaging adapter. +// +// LIST / READ and observation ONLY. Nothing exported here composes, stores, +// sends, or deletes a message; `readonly-gate.test.ts` fails the build if that +// ever stops being true. + +export * from './dbus-messaging'; +export * from './inbox-store'; +export * from './mmcli-parse'; +export * from './normalize'; diff --git a/control/src/sms/mmcli-parse.ts b/control/src/sms/mmcli-parse.ts new file mode 100644 index 0000000..ad52ce7 --- /dev/null +++ b/control/src/sms/mmcli-parse.ts @@ -0,0 +1,248 @@ +// The `mmcli -K` SMS grammar. +// +// WHY A CLI GRAMMAR LIVES BESIDE A D-BUS PORT: `mmcli` is a client of the SAME +// ModemManager daemon the D-Bus adapter talks to, and CeraUI has been reading +// its inbox through it on real hardware since Phase A. Owning the grammar here +// is what makes the port's output provable byte-for-byte against that reader on +// the same captured output (`mmcli-parse.parity.test.ts`) — a claim no amount of +// D-Bus-only code could support — and it is what lets a consumer move its +// parsing onto this package without also having to move its transport in the +// same change. +// +// CONTENT-FREE BY CONSTRUCTION. Unlike an ordinary parser, nothing here ever +// puts a parsed LINE into an error, a log, or a receipt. A message body is +// routinely a one-time code and a sender identifies the subscriber, so a +// malformed record reports the KEY NAMES it found and nothing else — enough to +// diagnose CLI drift, and carrying no message content. This module also never +// logs at all, which is why it does not reuse a generic key/value splitter: the +// generic one prints the offending line verbatim on any line it cannot split, +// which would put message text into a log the first time mmcli reframed a body. + +import type { SmsMessage, SmsReadRefusal } from '../ports/sms'; +import { normalizeSmsState, smsPathIndex } from './normalize'; + +/** A parse outcome. `detail` NEVER carries message content — key names only. */ +export type SmsParseResult = + | { readonly ok: true; readonly value: T } + | { readonly ok: false; readonly reason: string; readonly detail: string }; + +const fail = (reason: string, detail: string): SmsParseResult => ({ + ok: false, + reason, + detail, +}); + +const SMS_LIST_KEY = 'modem.messaging.sms'; +const LIST_LENGTH_SUFFIX = /\.length$/; +const LIST_VALUE_SUFFIX = /\.value\[\d+]$/; +const SMS_PATH_ANYWHERE_RE = /\/org\/freedesktop\/ModemManager1\/SMS\/(\d+)/; + +/** + * The escapes `g_strescape()` emits, minus the octal form. mmcli's `-K` writer + * runs EVERY value it prints through `g_strescape (value, NULL)` + * (`cli/mmcli-output.c`), so this table plus the 3-digit octal form is the + * complete grammar — anything else can be left untouched instead of guessed at. + */ +const SIMPLE_ESCAPES: Readonly> = { + b: 0x08, + f: 0x0c, + n: 0x0a, + r: 0x0d, + t: 0x09, + v: 0x0b, + '"': 0x22, + '\\': 0x5c, +}; + +/** Octal triplet first: `\302` is a byte, `\\302` is a backslash then "302". */ +const ESCAPE_RE = /\\(?:([0-7]{3})|([bfnrtv"\\]))/g; + +/** + * Undo `g_strescape()` on one `-K` value. + * + * mmcli does not print non-ASCII text — it prints the LITERAL ASCII characters + * of the octal escape. A Spanish message arrives on stdout as the eight + * characters `\`,`3`,`0`,`2`,`\`,`2`,`4`,`1` where the wire carried the two + * UTF-8 bytes `0xC2 0xA1` (confirmed on the bench board with `od -c`). The + * escapes are therefore per-BYTE, not per-character, and the only correct + * decode is to rebuild the byte sequence and read it back as UTF-8 — decoding + * each escape with `String.fromCharCode` instead turns `¡` into `¡`. + * + * Total and silent: it never throws and never logs, because the value it holds + * is message content. An escape outside the grammar is copied VERBATIM rather + * than dropped — mmcli cannot emit one, so meeting one means the value was + * never escaped and copying it is the only lossless answer. + */ +export function unescapeMmcliValue(value: string): string { + if (!value.includes('\\')) { + return value; + } + + const encoder = new TextEncoder(); + const bytes: number[] = []; + const pushText = (text: string): void => { + for (const byte of encoder.encode(text)) { + bytes.push(byte); + } + }; + + let cursor = 0; + ESCAPE_RE.lastIndex = 0; + let match = ESCAPE_RE.exec(value); + while (match !== null) { + if (match.index > cursor) { + pushText(value.slice(cursor, match.index)); + } + const octal = match[1]; + const simple = match[2]; + if (octal !== undefined) { + bytes.push(Number.parseInt(octal, 8)); + } else if (simple !== undefined) { + const byte = SIMPLE_ESCAPES[simple]; + if (byte !== undefined) { + bytes.push(byte); + } + } + cursor = match.index + match[0].length; + match = ESCAPE_RE.exec(value); + } + if (cursor < value.length) { + pushText(value.slice(cursor)); + } + + // A byte run that is not valid UTF-8 becomes U+FFFD — an honest + // "undecodable here" mark. Failing the whole read would lose every other + // field over one bad byte. + return new TextDecoder().decode(new Uint8Array(bytes)); +} + +/** Collect the `modem.messaging.sms` entries, honouring mmcli's array form. */ +function collectListEntries(raw: string): string[] | undefined { + let scalar: string | undefined; + const array: string[] = []; + let sawArray = false; + + for (const line of raw.split('\n')) { + const separator = line.indexOf(':'); + if (separator <= 0) { + continue; + } + const key = line.slice(0, separator).trim(); + // Unescaped AFTER the split, never before: a decoded byte must never be + // able to forge the `:` the line was split on. + const value = unescapeMmcliValue(line.slice(separator + 1).trim()); + // mmcli renders an absent value as `--`; it carries no entry. + if (value === '--') { + continue; + } + if (key.replace(LIST_LENGTH_SUFFIX, '') === SMS_LIST_KEY && LIST_LENGTH_SUFFIX.test(key)) { + sawArray = true; + } else if (key.replace(LIST_VALUE_SUFFIX, '') === SMS_LIST_KEY && LIST_VALUE_SUFFIX.test(key)) { + sawArray = true; + array.push(value); + } else if (key === SMS_LIST_KEY) { + scalar = value; + } + } + + if (sawArray) { + return array; + } + return scalar === undefined ? undefined : [scalar]; +} + +/** + * Extract SMS object paths from `mmcli -K -m --messaging-list-sms`. + * + * An inbox with no messages prints as `modem.messaging.sms: --`, whose `--` + * carries no entry — so an ABSENT key is a legitimate EMPTY INBOX, exactly as it + * is for `--3gpp-scan`, not drift. What IS drift, and what fails loud here, is + * output that never mentions the key at all (a renamed field, an error body + * reaching this parser): answering "no messages" to a read that never ran would + * be the worst possible lie about an inbox. + */ +export function parseSmsListOutput(raw: string): SmsParseResult { + const entries = collectListEntries(raw); + + if (entries === undefined) { + if (!raw.includes(SMS_LIST_KEY)) { + return fail('no modem.messaging.sms key in the mmcli output', 'no-key'); + } + return { ok: true, value: [] }; + } + + const paths = entries.filter((entry) => SMS_PATH_ANYWHERE_RE.test(entry)); + if (entries.length > 0 && paths.length === 0) { + return fail('no SMS paths matched the ModemManager path grammar', 'no-path-match'); + } + return { ok: true, value: paths }; +} + +/** + * Parse one `mmcli -K -s ` record. + * + * A malformed record reports the KEY NAMES it found and nothing else — see the + * content-free note at the top of this file. + */ +export function parseSmsRecordOutput(raw: string): SmsParseResult { + const fields = new Map(); + for (const line of raw.split('\n')) { + const separator = line.indexOf(':'); + if (separator <= 0) { + continue; + } + const key = line.slice(0, separator).trim(); + const value = line.slice(separator + 1).trim(); + if (!key.startsWith('sms.') || value === '' || value === '--') { + continue; + } + fields.set(key, unescapeMmcliValue(value)); + } + + const keyNames = [...fields.keys()].join(', '); + const dbusPath = fields.get('sms.dbus-path'); + if (dbusPath === undefined) { + return fail('no sms.dbus-path key in the mmcli output', keyNames); + } + + const index = smsPathIndex(dbusPath); + if (Number.isNaN(index)) { + return fail('sms.dbus-path did not match the ModemManager path grammar', keyNames); + } + + const from = fields.get('sms.content.number'); + const timestamp = fields.get('sms.properties.timestamp'); + + return { + ok: true, + value: { + id: String(index), + ...(from !== undefined ? { from } : {}), + ...(timestamp !== undefined ? { timestamp } : {}), + // A data-only (WAP/PDU) message has no text at all; `''` says so honestly. + text: fields.get('sms.content.text') ?? '', + state: normalizeSmsState(fields.get('sms.properties.state')), + }, + }; +} + +/** + * Classify a source failure into a refusal the operator can act on. + * + * The three recognised strings are ModemManager 1.24's own, confirmed on the + * bench board: a modem with no Messaging interface, a radio that has not come up + * yet, and a selector nothing answers to. Anything else stays `read_failed` + * rather than being guessed at. + */ +export function classifySmsFailure(description: string): SmsReadRefusal { + if (/no messaging capabilities/i.test(description)) { + return 'unsupported'; + } + if (/not enabled yet/i.test(description)) { + return 'not_enabled'; + } + if (/couldn't find modem|cannot find modem/i.test(description)) { + return 'unknown_modem'; + } + return 'read_failed'; +} diff --git a/control/src/sms/normalize.ts b/control/src/sms/normalize.ts new file mode 100644 index 0000000..bedd0f9 --- /dev/null +++ b/control/src/sms/normalize.ts @@ -0,0 +1,115 @@ +// Device-agnostic SMS normalization — the rules that hold whichever source the +// inbox was read through (ModemManager D-Bus or the `mmcli` client of the same +// daemon). +// +// Every rule here is a PORT of a behaviour proven on the bench board through +// CeraUI's `modules/modems/mmcli-sms.ts`, and each one exists because getting it +// wrong is silent rather than loud. They are kept in one pure module so the +// D-Bus adapter and the mmcli grammar cannot drift into two different answers +// about the same inbox. + +import type { SmsMessage, SmsState } from '../ports/sms'; + +/** + * The read cap. The list is reduced to the highest-indexed paths BEFORE any + * per-message read, so a modem holding several hundred stored messages still + * costs at most this many reads. + */ +export const SMS_INBOX_CAP = 50; + +/** + * The SMS object-path grammar. + * + * It is a SEPARATE regex from the modem-path one on purpose: a ModemManager + * modem path is anchored on `/Modem/`, so reusing it here would refuse every + * real message (`/org/freedesktop/ModemManager1/SMS/36`) on the device. + */ +export const SMS_PATH_RE: RegExp = /^(?:\/org\/freedesktop\/ModemManager1\/SMS\/\d+|\d+)$/; + +const SMS_PATH_INDEX_RE = /\/org\/freedesktop\/ModemManager1\/SMS\/(\d+)/; + +const KNOWN_SMS_STATES: ReadonlySet = new Set([ + 'unknown', + 'stored', + 'receiving', + 'received', + 'sending', + 'sent', +]); + +/** The trailing `/SMS/` index, or `Number.NaN` when the path carries none. */ +export function smsPathIndex(path: string): number { + const match = path.match(SMS_PATH_INDEX_RE); + return match?.[1] === undefined ? Number.NaN : Number.parseInt(match[1], 10); +} + +/** + * Fold whatever the source called the state onto the known vocabulary. An + * unrecognised value becomes `unknown` rather than being passed through — the + * state is rendered to an operator, and a raw token nobody can act on is worse + * than an honest "we do not know". + */ +export function normalizeSmsState(raw: string | undefined): SmsState { + return raw !== undefined && KNOWN_SMS_STATES.has(raw) ? (raw as SmsState) : 'unknown'; +} + +/** + * ModemManager reports the service-centre timestamp with an HOURS-ONLY UTC + * offset — `2025-08-21T17:20:16-05`, captured verbatim from the bench board. + * That is not valid ISO 8601 and `Date.parse` answers NaN for it. Left + * unhandled, EVERY message scores as undated and "newest first" silently + * degrades to object-index order, which is the one ordering this module must + * not trust. The offset is widened to `-05:00`; the anchor requires a full + * `T??:??:??` time in front of it, so a bare `YYYY-MM-DD` is never mangled. + */ +const HOURS_ONLY_OFFSET_RE = /(T\d{2}:\d{2}:\d{2})([+-]\d{2})$/; + +export function smsTimestampEpoch(timestamp: string): number { + const parsedTime = Date.parse(timestamp.replace(HOURS_ONLY_OFFSET_RE, '$1$2:00')); + return Number.isNaN(parsedTime) ? Number.NEGATIVE_INFINITY : parsedTime; +} + +/** + * Newest first, then capped. + * + * Sorted on the CARRIER timestamp because the object index is only a proxy for + * arrival order — ModemManager reuses freed indices, so a re-enumerated inbox + * can hand back a low index for the newest message. A message with no (or an + * unparseable) timestamp sorts LAST rather than first: promoting an undated + * message to the top of a "newest first" list would be a claim the device + * cannot support. Ties fall back to the index, descending. + */ +export function sortAndCapSms( + messages: readonly SmsMessage[], + cap: number = SMS_INBOX_CAP, +): SmsMessage[] { + const epoch = (message: SmsMessage): number => + message.timestamp === undefined + ? Number.NEGATIVE_INFINITY + : smsTimestampEpoch(message.timestamp); + + return [...messages] + .sort((a, b) => { + const delta = epoch(b) - epoch(a); + if (delta !== 0 && !Number.isNaN(delta)) { + return delta; + } + return Number(b.id) - Number(a.id); + }) + .slice(0, cap); +} + +/** + * Reduce a candidate path list to the paths worth reading: the highest-indexed + * {@link SMS_INBOX_CAP}, each of which must match the path grammar before it can + * reach a source as a selector. + */ +export function selectReadablePaths( + paths: readonly string[], + cap: number = SMS_INBOX_CAP, +): string[] { + return [...paths] + .sort((a, b) => smsPathIndex(b) - smsPathIndex(a)) + .slice(0, cap) + .filter((path) => SMS_PATH_RE.test(path)); +} diff --git a/control/src/sms/parse.test.ts b/control/src/sms/parse.test.ts new file mode 100644 index 0000000..7c2bdaf --- /dev/null +++ b/control/src/sms/parse.test.ts @@ -0,0 +1,280 @@ +// Parity fixtures for the read-only SMS port. +// +// Every fixture below is VERBATIM `mmcli 1.24.2 -K` output captured from the +// bench board (Quectel RM530N-GL, 37 stored messages) with the bodies replaced +// by neutral copy, and every expected value is the answer CeraUI's shipped +// `modules/modems/mmcli-sms.ts` reader produces for it. This suite is therefore +// the port's half of the migrate-then-remove gate: it pins the behaviour, and +// CeraUI's `modem-sms-port-parity.test.ts` runs BOTH implementations over the +// same fixtures and asserts they agree. Rule D forbids importing that reader +// here, so the differential lives on the side that can see both. + +import { describe, expect, test } from 'bun:test'; +import type { SmsMessage } from '../ports/sms'; +import { + classifySmsFailure, + parseSmsListOutput, + parseSmsRecordOutput, + unescapeMmcliValue, +} from './mmcli-parse'; +import { + normalizeSmsState, + SMS_INBOX_CAP, + SMS_PATH_RE, + selectReadablePaths, + smsPathIndex, + smsTimestampEpoch, + sortAndCapSms, +} from './normalize'; + +const POPULATED_LIST = [ + 'modem.messaging.sms.length : 3', + 'modem.messaging.sms.value[1] : /org/freedesktop/ModemManager1/SMS/36', + 'modem.messaging.sms.value[2] : /org/freedesktop/ModemManager1/SMS/35', + 'modem.messaging.sms.value[3] : /org/freedesktop/ModemManager1/SMS/0', +].join('\n'); + +const EMPTY_LIST = 'modem.messaging.sms : --'; + +const RECORD = [ + 'sms.dbus-path : /org/freedesktop/ModemManager1/SMS/36', + 'sms.content.number : 85573', + 'sms.content.text : Neutral fixture body with a colon: and more', + 'sms.content.data : --', + 'sms.properties.pdu-type : deliver', + 'sms.properties.state : received', + 'sms.properties.validity : --', + 'sms.properties.storage : me', + 'sms.properties.smsc : +573103154363', + 'sms.properties.class : --', + 'sms.properties.timestamp : 2025-08-21T17:20:16-05', + 'sms.properties.delivery-state : --', +].join('\n'); + +const message = (id: string, timestamp?: string): SmsMessage => ({ + id, + text: `body ${id}`, + state: 'received', + ...(timestamp !== undefined ? { timestamp } : {}), +}); + +describe('parseSmsListOutput', () => { + test('extracts every path from a populated inbox listing', () => { + const result = parseSmsListOutput(POPULATED_LIST); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.value).toEqual([ + '/org/freedesktop/ModemManager1/SMS/36', + '/org/freedesktop/ModemManager1/SMS/35', + '/org/freedesktop/ModemManager1/SMS/0', + ]); + } + }); + + test('a `--` list is a genuinely EMPTY inbox, not drift', () => { + const result = parseSmsListOutput(EMPTY_LIST); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.value).toEqual([]); + } + }); + + test('output that never mentions the key fails LOUD', () => { + const result = parseSmsListOutput('modem.generic.state : connected'); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.reason).toContain('modem.messaging.sms'); + } + }); + + test('entries matching no path grammar fail LOUD', () => { + const result = parseSmsListOutput( + ['modem.messaging.sms.length : 1', 'modem.messaging.sms.value[1] : nope'].join('\n'), + ); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.reason).toContain('path grammar'); + } + }); +}); + +describe('SMS_PATH_RE — a retargeted grammar, not the modem one', () => { + test('accepts a real SMS object path and a bare index', () => { + expect(SMS_PATH_RE.test('/org/freedesktop/ModemManager1/SMS/36')).toBe(true); + expect(SMS_PATH_RE.test('36')).toBe(true); + }); + + test('rejects anything that could escape an argv boundary', () => { + for (const hostile of [ + '--send', + '36; rm -rf /', + '/org/freedesktop/ModemManager1/Modem/0', + '', + '/org/freedesktop/ModemManager1/SMS/', + ]) { + expect(SMS_PATH_RE.test(hostile)).toBe(false); + } + }); +}); + +describe('parseSmsRecordOutput', () => { + test('reads the board record, keeping a colon inside the body', () => { + const result = parseSmsRecordOutput(RECORD); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.value).toEqual({ + id: '36', + from: '85573', + text: 'Neutral fixture body with a colon: and more', + timestamp: '2025-08-21T17:20:16-05', + state: 'received', + }); + } + }); + + test('a data-only message reports an empty body rather than guessing', () => { + const result = parseSmsRecordOutput( + [ + 'sms.dbus-path : /org/freedesktop/ModemManager1/SMS/7', + 'sms.content.text : --', + 'sms.properties.state : received', + ].join('\n'), + ); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.value.text).toBe(''); + } + }); + + test('an unknown state folds to `unknown`, never a raw token', () => { + const result = parseSmsRecordOutput( + [ + 'sms.dbus-path : /org/freedesktop/ModemManager1/SMS/7', + 'sms.properties.state : some-future-state', + ].join('\n'), + ); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.value.state).toBe('unknown'); + } + }); + + test('a malformed record reports KEY NAMES and no content', () => { + const body = 'Tu pin es 4821'; + const result = parseSmsRecordOutput( + [`sms.content.text : ${body}`, 'sms.properties.state : received'].join('\n'), + ); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.reason).toContain('sms.dbus-path'); + expect(result.detail).toContain('sms.content.text'); + expect(result.detail).not.toContain(body); + expect(JSON.stringify(result)).not.toContain(body); + } + }); +}); + +describe('unescapeMmcliValue — per-BYTE, then UTF-8', () => { + test('rebuilds a multi-byte character from its octal escapes', () => { + expect(unescapeMmcliValue('\\302\\241Disfruta!')).toBe('\u00a1Disfruta!'); + }); + + test('a doubled backslash is a literal backslash, not a byte', () => { + expect(unescapeMmcliValue('\\\\302')).toBe('\\302'); + }); + + test('leaves an unescaped value untouched', () => { + expect(unescapeMmcliValue('plain body')).toBe('plain body'); + }); +}); + +describe('smsTimestampEpoch — the hours-only offset', () => { + test('widens `-05` to `-05:00` so the board timestamp parses at all', () => { + expect(smsTimestampEpoch('2025-08-21T17:20:16-05')).toBe( + Date.parse('2025-08-21T17:20:16-05:00'), + ); + }); + + test('an already-valid offset is unchanged', () => { + expect(smsTimestampEpoch('2025-08-21T17:20:16+02:00')).toBe( + Date.parse('2025-08-21T17:20:16+02:00'), + ); + }); + + test('a bare date is never mangled by the widening anchor', () => { + expect(smsTimestampEpoch('2025-08-21')).toBe(Date.parse('2025-08-21')); + }); + + test('an unparseable timestamp sorts last rather than throwing', () => { + expect(smsTimestampEpoch('not a timestamp')).toBe(Number.NEGATIVE_INFINITY); + }); +}); + +describe('sortAndCapSms — newest first, undated last', () => { + test('orders on the carrier timestamp, not the object index', () => { + const sorted = sortAndCapSms([ + message('1', '2025-08-21T10:00:00-05'), + message('99', '2025-08-20T10:00:00-05'), + message('50', '2025-08-22T10:00:00-05'), + ]); + expect(sorted.map((entry) => entry.id)).toEqual(['50', '1', '99']); + }); + + test('an undated message sorts LAST, never promoted to the top', () => { + const sorted = sortAndCapSms([message('5'), message('1', '2025-08-21T10:00:00-05')]); + expect(sorted.map((entry) => entry.id)).toEqual(['1', '5']); + }); + + test('ties fall back to the index, descending', () => { + const stamp = '2025-08-21T10:00:00-05'; + const sorted = sortAndCapSms([message('3', stamp), message('9', stamp), message('7', stamp)]); + expect(sorted.map((entry) => entry.id)).toEqual(['9', '7', '3']); + }); + + test('caps at SMS_INBOX_CAP', () => { + const many = Array.from({ length: 120 }, (_, index) => message(String(index))); + expect(sortAndCapSms(many)).toHaveLength(SMS_INBOX_CAP); + expect(SMS_INBOX_CAP).toBe(50); + }); +}); + +describe('selectReadablePaths — bounded BEFORE any per-message read', () => { + test('keeps the highest-indexed cap and drops the rest', () => { + const paths = Array.from( + { length: 120 }, + (_, index) => `/org/freedesktop/ModemManager1/SMS/${index}`, + ); + const selected = selectReadablePaths(paths); + expect(selected).toHaveLength(SMS_INBOX_CAP); + expect(selected[0]).toBe('/org/freedesktop/ModemManager1/SMS/119'); + }); + + test('refuses a path that does not match the grammar', () => { + expect(selectReadablePaths(['/org/freedesktop/ModemManager1/SMS/1; rm -rf /'])).toEqual([]); + }); +}); + +describe('smsPathIndex + normalizeSmsState', () => { + test('reads the trailing index, and NaN when there is none', () => { + expect(smsPathIndex('/org/freedesktop/ModemManager1/SMS/36')).toBe(36); + expect(Number.isNaN(smsPathIndex('36'))).toBe(true); + }); + + test('every ModemManager state passes through verbatim', () => { + for (const state of ['unknown', 'stored', 'receiving', 'received', 'sending', 'sent']) { + expect(normalizeSmsState(state)).toBe(state as never); + } + expect(normalizeSmsState(undefined)).toBe('unknown'); + }); +}); + +describe('classifySmsFailure — four distinct operator facts', () => { + test.each([ + ["couldn't find modem 'nope'", 'unknown_modem'], + ['error: modem has no messaging capabilities', 'unsupported'], + ['error: modem not enabled yet', 'not_enabled'], + ['something else entirely', 'read_failed'], + ])('%s -> %s', (description, expected) => { + expect(classifySmsFailure(description)).toBe(expected as never); + }); +}); diff --git a/control/src/sms/readonly-gate.test.ts b/control/src/sms/readonly-gate.test.ts new file mode 100644 index 0000000..fdd78a0 --- /dev/null +++ b/control/src/sms/readonly-gate.test.ts @@ -0,0 +1,151 @@ +// The SMS surface is READ-ONLY, and this is the lock. +// +// A comment saying "never add a send path" is not a control — the next person to +// touch this surface will not read it. This test greps the ACTUAL SMS source for +// every ModemManager verb and identifier that would turn the inbox into a write +// surface, and fails the build if one appears. Sending or deleting a message is +// billable, irreversible, and adds real modem-control capability over the +// subscriber's account to what is otherwise a diagnostic read; it is out of +// scope PERMANENTLY, not until later. +// +// It is the modem-stack half of the same gate CeraUI has carried since Phase A +// (`apps/backend/src/tests/modem-sms-readonly-gate.test.ts`). Neither half may +// be deleted or narrowed to land a write path — that is exactly the move they +// exist to stop, and it would be a new spec change with its own confirmation and +// interlock design. + +import { describe, expect, test } from 'bun:test'; +import { readdirSync } from 'node:fs'; +import { join } from 'node:path'; + +const SMS_DIR = import.meta.dir; +const PORT_FILE = join(SMS_DIR, '..', 'ports', 'sms.ts'); + +/** This gate names the forbidden verbs, so it must not scan itself. */ +const SELF = 'readonly-gate.test.ts'; + +/** + * ModemManager's SMS WRITE surface (`Modem.Messaging.Create` / `Delete`, and + * `Sms.Send` / `Sms.Store`), mmcli's spelling of the same verbs, and the + * identifiers a hand-rolled write path would use. `'--send'` is listed on its + * own because mmcli spells the send as `-s --send`, with no `sms` token + * anywhere in the flag. + */ +const FORBIDDEN: ReadonlyArray<{ label: string; re: RegExp }> = [ + { label: "MM Messaging 'Create'", re: /['"`]Create['"`]/ }, + { label: "MM Messaging 'Delete'", re: /['"`]Delete['"`]/ }, + { label: "MM Sms 'Send'", re: /['"`]Send['"`]/ }, + { label: "MM Sms 'Store'", re: /['"`]Store['"`]/ }, + { label: 'mmcli --messaging-create-sms', re: /--messaging-create-sms/ }, + { label: 'mmcli --messaging-delete-sms', re: /--messaging-delete-sms/ }, + { label: 'mmcli --create-sms', re: /--create-sms/ }, + { label: 'mmcli --delete-sms', re: /--delete-sms/ }, + { label: 'mmcli --send', re: /['"`]--send['"`]/ }, + { label: 'mmcli --store', re: /['"`]--store['"`]/ }, + { label: 'a sendSms identifier', re: /\bsendSms\b/i }, + { label: 'a deleteSms identifier', re: /\bdeleteSms\b/i }, + { label: 'a createSms identifier', re: /\bcreateSms\b/i }, + { label: 'an smsSend identifier', re: /\bsmsSend\b/i }, + { label: 'an smsDelete identifier', re: /\bsmsDelete\b/i }, + { label: 'an smsStore identifier', re: /\bsmsStore\b/i }, +]; + +/** + * Scan CODE, not prose. The port and this gate both state the read-only + * invariant by NAMING the very verbs they forbid, and a gate that cannot tell + * "we will never call Delete" from an actual `Delete` call is a gate nobody can + * document around. Full-line and block comments are stripped; a trailing `//` on + * a code line is left alone so a URL inside a string cannot swallow the rest. + */ +function stripComments(source: string): string { + return source + .replace(/\/\*[\s\S]*?\*\//g, ' ') + .split('\n') + .filter((line) => !/^\s*(?:\/\/|\*)/.test(line)) + .join('\n'); +} + +function smsSourceFiles(): string[] { + const files = readdirSync(SMS_DIR) + .filter((name) => name.endsWith('.ts') && !name.endsWith('.test.ts') && name !== SELF) + .map((name) => join(SMS_DIR, name)); + return [...files, PORT_FILE]; +} + +const CODE = new Map(); +for (const path of smsSourceFiles()) { + CODE.set(path, stripComments(await Bun.file(path).text())); +} + +describe('the SMS surface is read-only, and stays that way', () => { + test('scans the whole SMS surface, port included', () => { + // Guards the gate itself: a moved directory would otherwise make this + // suite pass vacuously by scanning nothing. + expect(CODE.size).toBeGreaterThanOrEqual(5); + for (const expected of [ + 'ports/sms.ts', + 'sms/normalize.ts', + 'sms/mmcli-parse.ts', + 'sms/inbox-store.ts', + 'sms/dbus-messaging.ts', + ]) { + expect([...CODE.keys()].some((path) => path.endsWith(expected))).toBe(true); + } + }); + + for (const { label, re } of FORBIDDEN) { + test(`has no ${label} anywhere on the SMS surface`, () => { + const offenders = [...CODE.entries()] + .filter(([, source]) => re.test(source)) + .map(([path]) => path.slice(path.lastIndexOf('/src/') + 5)); + expect(offenders).toEqual([]); + }); + } + + test('every D-Bus METHOD the surface calls is a read', () => { + // Members are named in two places — an outgoing `callMethod` and an + // incoming `subscribeSignal` filter — and only the first can mutate a + // device, so they are asserted separately rather than as one bag. + const called = new Set(); + for (const source of CODE.values()) { + for (const region of source.matchAll(/callMethod\(\{[\s\S]*?\}\)/g)) { + for (const match of region[0].matchAll(/member:\s*'([A-Za-z]+)'/g)) { + const name = match[1]; + if (name !== undefined) { + called.add(name); + } + } + } + } + expect([...called].sort()).toEqual(['GetAll', 'List']); + }); + + test('the only signals it subscribes to are the inbox observations', () => { + const subscribed = new Set(); + for (const source of CODE.values()) { + for (const region of source.matchAll(/subscribeSignal\(\s*\{[\s\S]*?\}/g)) { + for (const match of region[0].matchAll(/member:\s*'([A-Za-z]+)'/g)) { + const name = match[1]; + if (name !== undefined) { + subscribed.add(name); + } + } + } + } + expect([...subscribed].sort()).toEqual(['Added', 'Deleted']); + }); + + test('the port declares list/observe/stop and nothing that mutates', () => { + const port = CODE.get(PORT_FILE) ?? ''; + for (const expected of ['list(', 'observe(', 'stop(']) { + expect(port).toContain(expected); + } + expect(/\b(create|send|store|delete)\s*\(/i.test(port)).toBe(false); + }); + + test('the detector is not vacuous (self-test)', () => { + const rogue = stripComments("await transport.callMethod({ member: 'Delete' });"); + const flagged = FORBIDDEN.filter(({ re }) => re.test(rogue)).map(({ label }) => label); + expect(flagged).toContain("MM Messaging 'Delete'"); + }); +}); diff --git a/control/src/testing/domain-fakes.ts b/control/src/testing/domain-fakes.ts new file mode 100644 index 0000000..25dab71 --- /dev/null +++ b/control/src/testing/domain-fakes.ts @@ -0,0 +1,185 @@ +import { + classifyOperationCompletion, + type DeviceGeneration, + defineOperationDescriptor, + deviceGeneration, + type EpochMillis, + epochMillis, + type MutationImpact, + type ObservationAuthority, + type ObservationEnvelope, + type OperationCompletion, + type OperationConfidence, + type OperationDescriptor, + type OperationResult, + type PhysicalModemId, + physicalModemId, + type RetryClass, + type SourceEpoch, + type StableKey, + sourceEpoch, + stableKeyFromPhysicalModemId, +} from '../domain'; + +/** + * A canonical, valid `PhysicalModemId` for contract tests. + * + * It is built through the real constructor, so it can never be a value the domain + * refuses — an MM object path, an interface name, an IP address, an IMEI, or a + * subscriber identifier. + */ +export const FAKE_PHYSICAL_MODEM_ID: PhysicalModemId = physicalModemId( + 'serial:ceralive-contract-fake', +); + +/** The actor/storage key for {@link FAKE_PHYSICAL_MODEM_ID}. */ +export const FAKE_STABLE_KEY: StableKey = stableKeyFromPhysicalModemId(FAKE_PHYSICAL_MODEM_ID); + +/** The generation every fake defaults to, so a consumer's fences have a stable anchor. */ +export const FAKE_GENERATION: DeviceGeneration = deviceGeneration(1); + +export type FakeObservationOptions = { + readonly stableKey?: StableKey; + readonly generation?: DeviceGeneration; + readonly source?: string; + readonly sourceEpoch?: SourceEpoch; + readonly observedAt?: EpochMillis; + readonly authority?: ObservationAuthority; +}; + +type ObservationBaseFields = { + readonly stableKey: StableKey; + readonly generation: DeviceGeneration; + readonly source: string; + readonly sourceEpoch: SourceEpoch; + readonly observedAt: EpochMillis; + readonly authority: ObservationAuthority; +}; + +function observationBase(options: FakeObservationOptions): ObservationBaseFields { + return { + stableKey: options.stableKey ?? FAKE_STABLE_KEY, + generation: options.generation ?? FAKE_GENERATION, + source: options.source ?? 'contract-fake', + sourceEpoch: options.sourceEpoch ?? sourceEpoch(1), + observedAt: options.observedAt ?? epochMillis(1), + authority: options.authority ?? 'authoritative', + }; +} + +/** A fresh observation carrying `value`. */ +export function fakeFreshObservation( + value: T, + options: FakeObservationOptions = {}, +): ObservationEnvelope { + return { ...observationBase(options), freshness: { state: 'fresh' }, value }; +} + +/** A stale observation that RETAINS `value` — staleness never discards what was read. */ +export function fakeStaleObservation( + value: T, + reason: 'source-epoch-superseded' | 'ttl-expired' | 'source-degraded' = 'ttl-expired', + options: FakeObservationOptions = {}, +): ObservationEnvelope { + const base = observationBase(options); + return { + ...base, + freshness: { state: 'stale', since: base.observedAt, reason }, + value, + }; +} + +/** + * An unavailable observation. It carries `value: null` by construction — there is no + * overload that lets a consumer's fake invent a value for data nobody could read. + */ +export function fakeUnavailableObservation( + reason: 'source-unavailable' | 'device-absent' | 'provider-unavailable' = 'source-unavailable', + options: FakeObservationOptions = {}, +): ObservationEnvelope { + const base = observationBase(options); + return { + ...base, + freshness: { state: 'unavailable', since: base.observedAt, reason }, + value: null, + }; +} + +export type FakeDescriptorOptions = { + readonly id?: string; + readonly provider?: string; + readonly authority?: 'provider' | 'controller' | 'hardware'; + readonly confidence?: OperationConfidence; +}; + +function baseDescriptor( + options: FakeDescriptorOptions, + support: { readonly read: boolean; readonly write: boolean }, + mutationImpact: MutationImpact, + retryClass: RetryClass, +): OperationDescriptor { + return defineOperationDescriptor({ + id: options.id ?? 'contract-fake-operation', + support: { + read: support.read ? { supported: true } : { supported: false, reason: 'contract-fake' }, + write: support.write ? { supported: true } : { supported: false, reason: 'contract-fake' }, + }, + authority: options.authority ?? 'provider', + provider: options.provider ?? 'contract-fake-provider', + constraints: { kind: 'unconstrained' }, + livePreconditions: [], + availability: { state: 'available' }, + mutationImpact, + retryClass, + readback: { required: false }, + rollback: { required: false }, + journal: { required: false }, + admission: { required: false }, + evidence: { profiles: [], firmware: [] }, + confidence: options.confidence ?? 'medium', + }); +} + +/** + * A supported, auto-retryable idempotent READ descriptor. + * + * Routed through `defineOperationDescriptor`, so a fake that drifted into an + * unsupported/retryable combination throws instead of silently modelling a + * descriptor the domain would refuse. + */ +export function fakeReadDescriptor( + options: FakeDescriptorOptions = {}, +): OperationDescriptor { + return baseDescriptor(options, { read: true, write: false }, 'read', 'idempotent-read'); +} + +/** A supported WRITE descriptor. Writes are never auto-retryable, so `retryClass` is `never`. */ +export function fakeWriteDescriptor( + options: FakeDescriptorOptions = {}, +): OperationDescriptor { + return baseDescriptor(options, { read: true, write: true }, 'write', 'never'); +} + +export type FakeOperationResultOptions = { + readonly operation?: 'read' | 'write'; + readonly completionGeneration?: DeviceGeneration; + readonly currentGeneration?: DeviceGeneration; +}; + +/** + * Build an `OperationResult` by running the REAL classifier over a completion. + * + * A hand-written result literal is how a consumer's fake comes to disagree with the + * package about which completions require reconciliation; this cannot. + */ +export function fakeOperationResult( + completion: OperationCompletion, + options: FakeOperationResultOptions = {}, +): OperationResult { + return classifyOperationCompletion({ + operation: options.operation ?? 'write', + completionGeneration: options.completionGeneration ?? FAKE_GENERATION, + currentGeneration: options.currentGeneration ?? FAKE_GENERATION, + completion, + }); +} diff --git a/control/src/testing/fakes.test.ts b/control/src/testing/fakes.test.ts new file mode 100644 index 0000000..81495ac --- /dev/null +++ b/control/src/testing/fakes.test.ts @@ -0,0 +1,131 @@ +import { describe, expect, test } from 'bun:test'; +import { + canAutoRetry, + deviceGeneration, + nextDeviceGeneration, + physicalModemId, + stableKeyFromPhysicalModemId, +} from '../domain'; +import { createProviderMatcher, createProviderRegistry } from '../providers'; +import { + FAKE_GENERATION, + FAKE_PHYSICAL_MODEM_ID, + FAKE_PROVIDER_MODEL, + FAKE_STABLE_KEY, + fakeFreshObservation, + fakeOperationResult, + fakeProviderDefinition, + fakeProviderMatchRequest, + fakeReadDescriptor, + fakeStaleObservation, + fakeUnauthenticatedProbe, + fakeUnavailableObservation, + fakeWriteDescriptor, +} from './index'; + +describe('public identity fakes', () => { + test('the canonical id round-trips through the real constructor', () => { + expect(physicalModemId(FAKE_PHYSICAL_MODEM_ID)).toBe(FAKE_PHYSICAL_MODEM_ID); + expect(stableKeyFromPhysicalModemId(FAKE_PHYSICAL_MODEM_ID)).toBe(FAKE_STABLE_KEY); + }); + + test('the default generation is the first real lifetime', () => { + expect(FAKE_GENERATION).toBe(deviceGeneration(1)); + }); +}); + +describe('observation fakes', () => { + test('a fresh envelope carries the value', () => { + const envelope = fakeFreshObservation({ registered: true }); + expect(envelope.freshness.state).toBe('fresh'); + expect(envelope.value).toEqual({ registered: true }); + expect(envelope.stableKey).toBe(FAKE_STABLE_KEY); + }); + + test('a stale envelope RETAINS the value it was built with', () => { + const envelope = fakeStaleObservation({ registered: false }, 'source-degraded'); + expect(envelope.freshness).toEqual({ + state: 'stale', + since: envelope.observedAt, + reason: 'source-degraded', + }); + expect(envelope.value).toEqual({ registered: false }); + }); + + test('an unavailable envelope can only be null — no value can be invented', () => { + const envelope = fakeUnavailableObservation<{ registered: boolean }>('device-absent'); + expect(envelope.freshness.state).toBe('unavailable'); + expect(envelope.value).toBeNull(); + }); +}); + +describe('operation fakes', () => { + test('the read descriptor is the only one that may auto-retry', () => { + const read = fakeReadDescriptor(); + const write = fakeWriteDescriptor(); + const failed = fakeOperationResult({ status: 'failed', reason: 'contract-fake' }); + + expect(canAutoRetry(read, failed)).toBe(true); + expect(canAutoRetry(write, failed)).toBe(false); + }); + + test('a stale-generation completion is classified by the REAL classifier', () => { + const result = fakeOperationResult( + { status: 'applied', value: 7 }, + { currentGeneration: nextDeviceGeneration(FAKE_GENERATION) }, + ); + expect(result).toEqual({ + status: 'unknown-outcome', + reason: 'stale-generation', + requiresReconciliation: true, + generation: FAKE_GENERATION, + }); + }); + + test('a timed-out WRITE demands reconciliation while a timed-out read does not', () => { + expect(fakeOperationResult({ status: 'timed-out' }).requiresReconciliation).toBe(true); + expect( + fakeOperationResult({ status: 'timed-out' }, { operation: 'read' }) + .requiresReconciliation, + ).toBe(false); + }); +}); + +describe('provider fakes', () => { + test('a fake definition is selectable by the real registry + matcher', async () => { + const registry = createProviderRegistry(); + registry.register(fakeProviderDefinition({ observation: { registered: true } })); + + const matcher = createProviderMatcher(registry); + const result = await matcher.match(fakeProviderMatchRequest()); + + expect(result.status).toBe('selected'); + expect(result.provider).toBe('contract-fake-provider'); + expect(result.profile).toBe('contract-fake-profile'); + }); + + test('a request naming another model is not selected', async () => { + const registry = createProviderRegistry(); + registry.register(fakeProviderDefinition({ observation: { registered: true } })); + + const matcher = createProviderMatcher(registry); + const result = await matcher.match(fakeProviderMatchRequest({ model: 'some-other-model' })); + + expect(result.status).toBe('unsupported'); + expect(result.operations).toBeNull(); + }); + + test('the fake probe answers its configured fingerprint', async () => { + const probe = fakeUnauthenticatedProbe({ signal: 'mismatch', strength: 'weak' }); + await expect(probe.run(fakeProviderMatchRequest())).resolves.toMatchObject({ + signal: 'mismatch', + strength: 'weak', + }); + }); + + test('the default match request names the fake model', () => { + expect(fakeProviderMatchRequest().passiveFacts).toEqual([ + { kind: 'model', value: FAKE_PROVIDER_MODEL }, + ]); + }); +}); diff --git a/control/src/testing/index.ts b/control/src/testing/index.ts new file mode 100644 index 0000000..6f2c970 --- /dev/null +++ b/control/src/testing/index.ts @@ -0,0 +1,18 @@ +// `@ceralive/modem-control/testing` — the PUBLIC contract-fakes surface. +// +// A consumer testing against this package needs valid instances of the frozen v1.1 +// domain and provider contracts (`../domain`, `../providers`). Hand-rolling them is +// how a consumer's fixtures come to disagree with the package: a hand-written +// `OperationResult` literal quietly stops matching what `classifyOperationCompletion` +// actually returns, and a hand-written envelope invents a value for an unavailable +// read. Every fake here is built through the package's own constructors and +// classifiers, so it cannot express a shape the domain refuses. +// +// This is NOT `control/test-support/`. That directory holds this repo's own heavy +// internals — an MM-faithful fake D-Bus service on a private session bus and a +// stateful `nmcli` harness. It lives outside `src`, is not published, and is not a +// reusable public surface. This entry is pure data and functions: no bus, no daemon, +// no process, no filesystem. + +export * from './domain-fakes'; +export * from './provider-fakes'; diff --git a/control/src/testing/provider-fakes.ts b/control/src/testing/provider-fakes.ts new file mode 100644 index 0000000..12b4722 --- /dev/null +++ b/control/src/testing/provider-fakes.ts @@ -0,0 +1,115 @@ +import type { DeviceGeneration, ObservationEnvelope } from '../domain'; +import type { + FingerprintResult, + PassiveFact, + ProviderDefinition, + ProviderExecutionContext, + ProviderMatchRequest, + ProviderOperationsSurface, + ProviderTransport, + UnauthenticatedProbe, +} from '../providers'; +import { FAKE_GENERATION, FAKE_PHYSICAL_MODEM_ID, fakeFreshObservation } from './domain-fakes'; + +/** The model string every provider fake matches on unless one is named. */ +export const FAKE_PROVIDER_MODEL = 'contract-fake-model'; + +export type FakeMatchRequestOptions = { + readonly generation?: DeviceGeneration; + readonly transport?: ProviderTransport; + readonly model?: string; + readonly composition?: string; + readonly firmware?: string; + readonly passiveFacts?: readonly PassiveFact[]; +}; + +/** A well-formed `ProviderMatchRequest` whose passive facts name exactly one model. */ +export function fakeProviderMatchRequest( + options: FakeMatchRequestOptions = {}, +): ProviderMatchRequest { + const model = options.model ?? FAKE_PROVIDER_MODEL; + return { + physicalModemId: FAKE_PHYSICAL_MODEM_ID, + generation: options.generation ?? FAKE_GENERATION, + transport: options.transport ?? 'network', + passiveFacts: options.passiveFacts ?? [{ kind: 'model', value: model }], + composition: options.composition ?? 'ethernet-router', + firmware: options.firmware ?? '1.0.0', + }; +} + +export type FakeProviderOptions = { + readonly id?: string; + readonly profile?: string; + readonly profileVersion?: string; + readonly model?: string; + readonly eligibleTransports?: readonly ProviderTransport[]; + readonly access?: ProviderOperationsSurface['access']; + readonly observation?: TObservation; + readonly probes?: readonly UnauthenticatedProbe[]; +}; + +/** + * A minimal but COMPLETE `ProviderDefinition` — one strong required passive matcher on + * the model fact, no authenticated profile, no capability readers. + * + * It registers nothing and reaches no device: `observe` answers a fresh envelope built + * from the supplied observation, and `operations` returns the access surface asked for. + * This is the shape a consumer writes its own registry/matcher tests against without + * inventing a parallel provider contract. + */ +export function fakeProviderDefinition( + options: FakeProviderOptions & { readonly observation: TObservation }, +): ProviderDefinition { + const id = options.id ?? 'contract-fake-provider'; + const profile = options.profile ?? 'contract-fake-profile'; + const model = options.model ?? FAKE_PROVIDER_MODEL; + const access: ProviderOperationsSurface['access'] = options.access ?? 'read-only'; + const observation = options.observation; + + return { + id, + profileVersion: options.profileVersion ?? '1.0.0', + eligibleTransports: options.eligibleTransports ?? ['network'], + passiveMatchers: [ + { + id: `${id}-model`, + fact: 'model', + expected: [model], + profiles: [profile], + strength: 'strong', + required: true, + }, + ], + unauthenticatedProbes: options.probes ?? [], + capabilityReaders: [], + observe: ( + _context: ProviderExecutionContext, + ): Promise[]> => + Promise.resolve([fakeFreshObservation(observation, { source: id })]), + operations: (_profile: string): ProviderOperationsSurface => ({ access }), + contractFixtures: [], + }; +} + +export type FakeProbeOptions = { + readonly id?: string; + readonly signal?: FingerprintResult['signal']; + readonly strength?: FingerprintResult['strength']; + readonly profiles?: readonly string[]; + readonly detail?: string; +}; + +/** A harmless unauthenticated probe that answers a fixed fingerprint. */ +export function fakeUnauthenticatedProbe(options: FakeProbeOptions = {}): UnauthenticatedProbe { + const result: FingerprintResult = { + signal: options.signal ?? 'match', + strength: options.strength ?? 'moderate', + profiles: options.profiles ?? ['contract-fake-profile'], + detail: options.detail ?? 'contract-fake probe', + }; + return { + id: options.id ?? 'contract-fake-probe', + run: (_context: ProviderMatchRequest): Promise => Promise.resolve(result), + }; +} diff --git a/control/src/usb-mode/catalog-schema.test.ts b/control/src/usb-mode/catalog-schema.test.ts index d1e7471..3073848 100644 --- a/control/src/usb-mode/catalog-schema.test.ts +++ b/control/src/usb-mode/catalog-schema.test.ts @@ -179,3 +179,21 @@ describe('the shipped certified catalog', () => { expect(entry).toBeUndefined(); }); }); + +describe('permittedTransition.applyCommand — optional, but never empty', () => { + test('a transition with NO applyCommand parses (the SKU re-enumerates on its own)', () => { + expect(certifiedCatalogSchema.safeParse(validCatalog()).success).toBe(true); + }); + + test('a declared applyCommand parses', () => { + const catalog = validCatalog(); + firstTransition(catalog).applyCommand = 'AT+CFUN=1,1'; + expect(certifiedCatalogSchema.safeParse(catalog).success).toBe(true); + }); + + test('an EMPTY applyCommand is refused — an unsendable command is not "no command"', () => { + const catalog = validCatalog(); + firstTransition(catalog).applyCommand = ''; + expect(certifiedCatalogSchema.safeParse(catalog).success).toBe(false); + }); +}); diff --git a/control/src/usb-mode/catalog-schema.ts b/control/src/usb-mode/catalog-schema.ts index e5b1cee..d7f3eea 100644 --- a/control/src/usb-mode/catalog-schema.ts +++ b/control/src/usb-mode/catalog-schema.ts @@ -61,6 +61,18 @@ export const permittedTransitionSchema = z to: mmMode, /** The EXACT AT command that performs the switch (allowlisted at send time). */ atCommand: z.string().min(1), + /** + * The EXACT AT command that COMMITS the switch on a SKU whose `atCommand` only + * writes non-volatile configuration; omitted for a SKU that re-enumerates on + * `atCommand` alone. Sent through the SAME allowlisted lease. + * + * Declaring it is a per-SKU hardware FACT, not a retry. Measured on the bench + * RM530N-GL: `AT+QCFG="usbnet",` answers `OK`, reads back the new value, and + * leaves the device in the OLD composition for the whole port-drop budget — having + * already committed to NV. Undeclared, such a SKU fails on a timeout AFTER it was + * silently changed, then lands in the new composition at the next unrelated reboot. + */ + applyCommand: z.string().min(1).optional(), /** The AT response expected on success (e.g. `OK`) — never proof on its own. */ expectedResponse: z.string().min(1), /** Whether the control port is expected to drop after the command is written. */ diff --git a/control/src/usb-mode/ingestion.hardware.test.ts b/control/src/usb-mode/ingestion.hardware.test.ts new file mode 100644 index 0000000..7a9eff8 --- /dev/null +++ b/control/src/usb-mode/ingestion.hardware.test.ts @@ -0,0 +1,374 @@ +// Ingestion against REAL bench-captured composition evidence — SIMCom SIM7600G-H and +// Fibocom FM350-GL (the latter on an M.2->USB bench carrier). +// +// Every descriptor byte, driver binding, and udev property below is VERBATIM from a +// non-mutating capture on bench board `ceralive2` (2026-08-18). Nothing here is +// hand-shaped to make a test pass: the `usb-devices` blocks are the exact records the +// board emitted, and the udev maps are the exact `usb_device` records from +// `udevadm info --export-db`. +// +// WHY THIS FILE EXISTS SEPARATELY FROM `ingestion.test.ts`: that suite proves the seam's +// CONTRACT with shaped input. This one proves the seam survives contact with the actual +// bench fleet, and — more importantly — it PINS THREE BLOCKERS the real capture exposed, +// so none of them can be quietly forgotten or silently regress: +// +// B-27.1 `certify` matches its USB device by `ifname`, which `parseUdevDatabase` never +// populates. Every real bundle therefore lands with NO `sku` and an EMPTY +// `udevProperties`, and ingestion correctly refuses it `sku-missing`. The +// capture pipeline cannot currently produce a promotable bundle at all. +// B-27.2 Even with B-27.1 fixed, `skuOf` reads `firmwarePrefix` from udev +// `ID_REVISION`, which is the USB **bcdDevice** (`0318` / `0001`) — NOT the +// modem firmware revision. A firmware-keyed certification cannot be built on it. +// B-27.3 (not testable here, recorded in docs) the shared redactor does not mask +// `imei` / `equipment-identifier`, so a real bundle's `modemManager` half +// carries every bench modem's IMEI and must not be committed or posted. +// +// NEITHER DEVICE IS CERTIFIED BY THIS FILE. `certified-catalog.json` is unchanged, and +// two guard tests below assert it stays that way — the SIMCom's target compositions are +// UNPROVEN (only the parameter DOMAIN was read back, never a tuple), and the FM350's +// `0e8d:7127` is a bench-carrier artifact that `docs/FM350-DECISION.md` keeps out of the +// USB classifier. + +import { describe, expect, test } from 'bun:test'; +import { classifyDevice, detectUsbMode } from '../backend/device-classifier'; +import { CERTIFIED_CATALOG } from './catalog'; +import { + buildCatalogEntryCandidate, + buildClassifierFixture, + type IngestionRequest, +} from './ingestion'; +import { parseUsbDevices, selectUniqueDevice } from './usb-devices-parse'; + +/** sha256 the bench `certify` run printed for the SIMCom bundle (slot `Modem/20`). */ +const SIMCOM_BUNDLE_SHA = '3ce28784e421eb448494f1f566072335c96110be3d01b5eac883ab14c01661ad'; +/** sha256 the bench `certify` run printed for the FM350 bundle (slot `Modem/4`). */ +const FM350_BUNDLE_SHA = '57a33dafe3038542cdb3976184fc4a1bebb740a96e38fdfe33bd4031c3c03df9'; + +/** + * VERBATIM `usb-devices` records for the two units, lifted unmodified out of the real + * bench capture. Endpoint (`E:`) lines are retained exactly as captured — the parser + * skips them, and trimming them would make this no longer a verbatim capture. + */ +const BENCH_USB_DEVICES = ` +T: Bus=01 Lev=02 Prnt=15 Port=01 Cnt=01 Dev#= 17 Spd=480 MxCh= 0 +D: Ver= 2.10 Cls=ef(misc ) Sub=02 Prot=01 MxPS=64 #Cfgs= 1 +P: Vendor=0e8d ProdID=7127 Rev=00.01 +S: Manufacturer=Fibocom Wireless Inc. +S: Product=FM350-GL +C: #Ifs=10 Cfg#= 1 Atr=a0 MxPwr=500mA +I: If#= 0 Alt= 0 #EPs= 1 Cls=02(commc) Sub=02 Prot=ff Driver=rndis_host +E: Ad=82(I) Atr=03(Int.) MxPS= 64 Ivl=125us +I: If#= 1 Alt= 0 #EPs= 2 Cls=0a(data ) Sub=00 Prot=00 Driver=rndis_host +E: Ad=01(O) Atr=02(Bulk) MxPS= 512 Ivl=0ms +E: Ad=81(I) Atr=02(Bulk) MxPS= 512 Ivl=0ms +I: If#= 2 Alt= 0 #EPs= 2 Cls=ff(vend.) Sub=00 Prot=00 Driver=option +E: Ad=02(O) Atr=02(Bulk) MxPS= 512 Ivl=0ms +E: Ad=83(I) Atr=02(Bulk) MxPS= 512 Ivl=0ms +I: If#= 3 Alt= 0 #EPs= 2 Cls=ff(vend.) Sub=00 Prot=00 Driver=option +E: Ad=03(O) Atr=02(Bulk) MxPS= 512 Ivl=0ms +E: Ad=84(I) Atr=02(Bulk) MxPS= 512 Ivl=0ms +I: If#= 4 Alt= 0 #EPs= 2 Cls=ff(vend.) Sub=00 Prot=00 Driver=option +E: Ad=04(O) Atr=02(Bulk) MxPS= 512 Ivl=0ms +E: Ad=85(I) Atr=02(Bulk) MxPS= 512 Ivl=0ms +I: If#= 5 Alt= 0 #EPs= 2 Cls=ff(vend.) Sub=42 Prot=01 Driver=(none) +E: Ad=05(O) Atr=02(Bulk) MxPS= 512 Ivl=0ms +E: Ad=86(I) Atr=02(Bulk) MxPS= 512 Ivl=0ms +I: If#= 6 Alt= 0 #EPs= 2 Cls=ff(vend.) Sub=00 Prot=00 Driver=option +E: Ad=06(O) Atr=02(Bulk) MxPS= 512 Ivl=0ms +E: Ad=87(I) Atr=02(Bulk) MxPS= 512 Ivl=0ms +I: If#= 7 Alt= 0 #EPs= 2 Cls=ff(vend.) Sub=00 Prot=00 Driver=option +E: Ad=07(O) Atr=02(Bulk) MxPS= 512 Ivl=0ms +E: Ad=88(I) Atr=02(Bulk) MxPS= 512 Ivl=0ms +I: If#= 8 Alt= 0 #EPs= 2 Cls=ff(vend.) Sub=00 Prot=00 Driver=option +E: Ad=08(O) Atr=02(Bulk) MxPS= 512 Ivl=0ms +E: Ad=89(I) Atr=02(Bulk) MxPS= 512 Ivl=0ms +I: If#= 9 Alt= 0 #EPs= 2 Cls=ff(vend.) Sub=00 Prot=00 Driver=option +E: Ad=09(O) Atr=02(Bulk) MxPS= 512 Ivl=0ms +E: Ad=8a(I) Atr=02(Bulk) MxPS= 512 Ivl=0ms + +T: Bus=01 Lev=03 Prnt=21 Port=03 Cnt=01 Dev#= 37 Spd=480 MxCh= 0 +D: Ver= 2.00 Cls=00(>ifc ) Sub=00 Prot=00 MxPS=64 #Cfgs= 1 +P: Vendor=1e0e ProdID=9001 Rev=03.18 +S: Manufacturer=SimTech, Incorporated +S: Product=SimTech, Incorporated +S: SerialNumber=0123456789ABCDEF +C: #Ifs= 6 Cfg#= 1 Atr=a0 MxPwr=500mA +I: If#= 0 Alt= 0 #EPs= 2 Cls=ff(vend.) Sub=ff Prot=ff Driver=option +E: Ad=01(O) Atr=02(Bulk) MxPS= 512 Ivl=0ms +E: Ad=81(I) Atr=02(Bulk) MxPS= 512 Ivl=0ms +I: If#= 1 Alt= 0 #EPs= 3 Cls=ff(vend.) Sub=00 Prot=00 Driver=option +E: Ad=02(O) Atr=02(Bulk) MxPS= 512 Ivl=0ms +E: Ad=82(I) Atr=02(Bulk) MxPS= 512 Ivl=0ms +E: Ad=83(I) Atr=03(Int.) MxPS= 10 Ivl=32ms +I: If#= 2 Alt= 0 #EPs= 3 Cls=ff(vend.) Sub=00 Prot=00 Driver=option +E: Ad=03(O) Atr=02(Bulk) MxPS= 512 Ivl=0ms +E: Ad=84(I) Atr=02(Bulk) MxPS= 512 Ivl=0ms +E: Ad=85(I) Atr=03(Int.) MxPS= 10 Ivl=32ms +I: If#= 3 Alt= 0 #EPs= 3 Cls=ff(vend.) Sub=00 Prot=00 Driver=option +E: Ad=04(O) Atr=02(Bulk) MxPS= 512 Ivl=0ms +E: Ad=86(I) Atr=02(Bulk) MxPS= 512 Ivl=0ms +E: Ad=87(I) Atr=03(Int.) MxPS= 10 Ivl=32ms +I: If#= 4 Alt= 0 #EPs= 3 Cls=ff(vend.) Sub=00 Prot=00 Driver=option +E: Ad=05(O) Atr=02(Bulk) MxPS= 512 Ivl=0ms +E: Ad=88(I) Atr=02(Bulk) MxPS= 512 Ivl=0ms +E: Ad=89(I) Atr=03(Int.) MxPS= 10 Ivl=32ms +I: If#= 5 Alt= 0 #EPs= 3 Cls=ff(vend.) Sub=ff Prot=ff Driver=qmi_wwan +E: Ad=06(O) Atr=02(Bulk) MxPS= 512 Ivl=0ms +E: Ad=8a(I) Atr=02(Bulk) MxPS= 512 Ivl=0ms +E: Ad=8b(I) Atr=03(Int.) MxPS= 8 Ivl=32ms +`; + +/** VERBATIM udev `usb_device` record for the SIMCom at `1-1.3.4`. */ +const SIMCOM_UDEV: Record = { + DEVPATH: '/devices/platform/fc400000.usb/xhci-hcd.0.auto/usb1/1-1/1-1.3/1-1.3.4', + DEVTYPE: 'usb_device', + DRIVER: 'usb', + ID_BUS: 'usb', + ID_MM_QMI_PCO_DISABLED: '1', + ID_MODEL: 'SimTech__Incorporated', + ID_MODEL_ID: '9001', + ID_PATH: 'platform-xhci-hcd.0.auto-usb-0:1.3.4', + ID_REVISION: '0318', + ID_USB_INTERFACES: ':ffffff:ff0000:', + ID_VENDOR: 'SimTech__Incorporated', + ID_VENDOR_FROM_DATABASE: 'Qualcomm / Option', + ID_VENDOR_ID: '1e0e', + PRODUCT: '1e0e/9001/318', + SUBSYSTEM: 'usb', + TYPE: '0/0/0', +}; + +/** VERBATIM udev `usb_device` record for the FM350-on-carrier at `1-1.2`. */ +const FM350_UDEV: Record = { + DEVPATH: '/devices/platform/fc400000.usb/xhci-hcd.0.auto/usb1/1-1/1-1.2', + DEVTYPE: 'usb_device', + DRIVER: 'usb', + ID_BUS: 'usb', + ID_MODEL: 'FM350-GL', + ID_MODEL_ID: '7127', + ID_PATH: 'platform-xhci-hcd.0.auto-usb-0:1.2', + ID_REVISION: '0001', + ID_USB_INTERFACES: ':0202ff:0a0000:ff0000:ff4201:', + ID_VENDOR: 'Fibocom_Wireless_Inc.', + ID_VENDOR_FROM_DATABASE: 'MediaTek Inc.', + ID_VENDOR_ID: '0e8d', + PRODUCT: 'e8d/7127/1', + SUBSYSTEM: 'usb', + TYPE: '239/2/1', +}; + +/** Firmware revisions ModemManager and the AT transcripts BOTH reported for each unit. */ +const SIMCOM_MODEM_FIRMWARE = 'LE20B04SIM7600G22'; +const FM350_MODEM_FIRMWARE = '81600.0000.00.19.17.10'; + +/** + * The ingestion view of a bench capture. `sku` is supplied by the caller because the + * REAL bundle did not carry one — see B-27.1 and the refusal test that pins it. + */ +function benchBundle( + sku: Record | undefined, + udev: Record, + overrides: Record = {}, +): Record { + return { + schemaVersion: 1, + synthetic: false, + capturedAtMs: 1_787_027_878_000, + slot: 'Modem/20', + ...(sku !== undefined ? { sku } : {}), + usb: { usbDevices: BENCH_USB_DEVICES, udevProperties: udev }, + ...overrides, + }; +} + +const SIMCOM_SKU = { + vidPid: '1e0e:9001', + model: 'SimTech__Incorporated', + firmwarePrefix: '0318', +}; +const FM350_SKU = { vidPid: '0e8d:7127', model: 'FM350-GL', firmwarePrefix: '0001' }; + +const simcomRequest = (overrides: Record = {}): IngestionRequest => ({ + bundle: benchBundle(SIMCOM_SKU, SIMCOM_UDEV, overrides), + bundleSha256: SIMCOM_BUNDLE_SHA, +}); + +const fm350Request = (overrides: Record = {}): IngestionRequest => ({ + bundle: benchBundle(FM350_SKU, FM350_UDEV, { slot: 'Modem/4', ...overrides }), + bundleSha256: FM350_BUNDLE_SHA, +}); + +describe('bench capture — SIMCom SIM7600G-H (1e0e:9001) composition evidence', () => { + test('the real usb-devices record parses to the observed 6-interface QMI composition', () => { + const selected = selectUniqueDevice(parseUsbDevices(BENCH_USB_DEVICES), '1e0e:9001'); + expect(selected).toHaveProperty('device'); + if (!('device' in selected)) { + return; + } + expect(selected.device.bDeviceClass).toBe(0x00); + expect(selected.device.manufacturer).toBe('SimTech, Incorporated'); + // Five `option` serial interfaces plus one `qmi_wwan` control interface. The + // qmi_wwan binding is what makes this device mm-managed rather than router-mode. + expect(selected.device.interfaces).toHaveLength(6); + expect(selected.device.interfaces.map((i) => i.driver)).toEqual([ + 'option', + 'option', + 'option', + 'option', + 'option', + 'qmi_wwan', + ]); + }); + + test('the fixture the seam derives is classified mm-managed / qmi by the REAL classifier', () => { + const outcome = buildClassifierFixture(simcomRequest()); + expect(outcome.ok).toBe(true); + if (!outcome.ok) { + return; + } + const { snapshot, provenance } = outcome.value; + expect(snapshot.vendorId).toBe('1e0e'); + expect(snapshot.productId).toBe('9001'); + expect(snapshot.physicalUid).toBe('platform-xhci-hcd.0.auto-usb-0:1.3.4'); + // Matches the live `modem-control probe` verdict recorded in the evidence. + expect(classifyDevice(snapshot).deviceClass).toBe('mm-managed'); + expect(detectUsbMode(snapshot)).toBe('qmi'); + expect(provenance.synthetic).toBe(false); + expect(provenance.bundleSha256).toBe(SIMCOM_BUNDLE_SHA); + }); + + test('udev ID_USB_INTERFACES is LOSSY — which is why ingestion reads usb-devices', () => { + // udev collapsed six interfaces into two distinct triples. A fixture built from + // `ID_USB_INTERFACES` alone would misrepresent the composition AND carry no + // driver bindings at all, so the seam deliberately parses `usb-devices` instead. + const udevTriples = (SIMCOM_UDEV.ID_USB_INTERFACES ?? '') + .split(':') + .filter((t) => t.length === 6); + expect(udevTriples).toHaveLength(2); + const selected = selectUniqueDevice(parseUsbDevices(BENCH_USB_DEVICES), '1e0e:9001'); + expect('device' in selected && selected.device.interfaces.length).toBe(6); + }); +}); + +describe('bench capture — Fibocom FM350-GL on an M.2->USB carrier (0e8d:7127)', () => { + test('the real usb-devices record parses to the observed 10-interface RNDIS+AT composition', () => { + const selected = selectUniqueDevice(parseUsbDevices(BENCH_USB_DEVICES), '0e8d:7127'); + expect(selected).toHaveProperty('device'); + if (!('device' in selected)) { + return; + } + // 0xef/0x02/0x01 — an IAD composite device, not a per-interface-class device. + expect(selected.device.bDeviceClass).toBe(0xef); + expect(selected.device.product).toBe('FM350-GL'); + expect(selected.device.interfaces).toHaveLength(10); + expect(selected.device.interfaces.map((i) => i.driver)).toEqual([ + 'rndis_host', + 'rndis_host', + 'option', + 'option', + 'option', + // If#5 (ff/42/01) is claimed by NO driver; the parser drops `(none)` rather + // than recording a fictitious binding. + undefined, + 'option', + 'option', + 'option', + 'option', + ]); + }); + + test('the fixture is classified mm-managed / rndis by the REAL classifier', () => { + const outcome = buildClassifierFixture(fm350Request()); + expect(outcome.ok).toBe(true); + if (!outcome.ok) { + return; + } + const { snapshot } = outcome.value; + expect(snapshot.vendorId).toBe('0e8d'); + expect(snapshot.productId).toBe('7127'); + expect(snapshot.physicalUid).toBe('platform-xhci-hcd.0.auto-usb-0:1.2'); + // Matches the live `modem-control probe` verdict recorded in the evidence: + // an `option` AT control port makes it MM-manageable despite the RNDIS tether. + expect(classifyDevice(snapshot).deviceClass).toBe('mm-managed'); + expect(detectUsbMode(snapshot)).toBe('rndis'); + }); +}); + +describe('B-27.1 — a REAL certify bundle carries no sku, so ingestion refuses it', () => { + // `certify` matches its USB device with `devices.find(d => d.ifname === ifname)`, but + // `parseUdevDatabase` never sets `ifname` on any snapshot. Both bench runs therefore + // produced `synthetic=false` bundles with NO `sku` and an EMPTY `udevProperties`. + // This is the exact shape those two files have on disk. + const asCaptured = (sha: string, slot: string): IngestionRequest => ({ + bundle: { + schemaVersion: 1, + synthetic: false, + capturedAtMs: 1_787_027_878_000, + slot, + usb: { usbDevices: BENCH_USB_DEVICES, udevProperties: {} }, + }, + bundleSha256: sha, + }); + + test('the SIMCom bundle as actually captured is refused sku-missing', () => { + expect(buildClassifierFixture(asCaptured(SIMCOM_BUNDLE_SHA, 'Modem/20'))).toMatchObject({ + ok: false, + reason: 'sku-missing', + }); + expect( + buildCatalogEntryCandidate(asCaptured(SIMCOM_BUNDLE_SHA, 'Modem/20'), { + canonicalMode: 'qmi', + }), + ).toMatchObject({ ok: false, reason: 'sku-missing' }); + }); + + test('the FM350 bundle as actually captured is refused sku-missing', () => { + expect(buildClassifierFixture(asCaptured(FM350_BUNDLE_SHA, 'Modem/4'))).toMatchObject({ + ok: false, + reason: 'sku-missing', + }); + }); +}); + +describe('B-27.2 — udev ID_REVISION is bcdDevice, NOT the modem firmware revision', () => { + test('the SKU firmwarePrefix a capture would carry differs from the modem firmware', () => { + // `skuOf` reads udev `ID_REVISION`. For these two units that is the USB + // bcdDevice, so a catalog entry keyed on it would NOT be firmware-keyed. + expect(SIMCOM_SKU.firmwarePrefix).toBe('0318'); + expect(SIMCOM_SKU.firmwarePrefix).not.toBe(SIMCOM_MODEM_FIRMWARE); + expect(FM350_SKU.firmwarePrefix).toBe('0001'); + expect(FM350_SKU.firmwarePrefix).not.toBe(FM350_MODEM_FIRMWARE); + }); +}); + +describe('nothing on the bench is certified by this evidence', () => { + test('a stage-1 candidate is a REVIEW ARTIFACT — it is never the shipped catalog', () => { + const outcome = buildCatalogEntryCandidate(simcomRequest(), { canonicalMode: 'qmi' }); + expect(outcome.ok).toBe(true); + if (!outcome.ok) { + return; + } + // A base capture declares NO transition: the SIMCom's target compositions were + // never proven, only the parameter DOMAIN of AT+CUSBPIDSWITCH was read back. + expect(outcome.value.permittedTransitions).toEqual([]); + }); + + test('certified-catalog.json has NO entry for the SIMCom — target modes stay hidden', () => { + expect(CERTIFIED_CATALOG.entries.some((e) => e.vidPid === '1e0e:9001')).toBe(false); + }); + + test('certified-catalog.json has NO entry for 0e8d:7127 — FM350-DECISION guard', () => { + // `0e8d:7127` is the M.2->USB bench carrier's identity, not a native FM350 USB + // personality. `docs/FM350-DECISION.md` keeps the FM350 out of the USB model; + // this guard fails the build if anyone promotes the carrier id by accident. + expect(CERTIFIED_CATALOG.entries.some((e) => e.vidPid === '0e8d:7127')).toBe(false); + expect(CERTIFIED_CATALOG.entries.some((e) => e.vidPid === '14c3:4d75')).toBe(false); + }); + + test('a synthetic re-mark of the same real capture is still refused for promotion', () => { + const outcome = buildCatalogEntryCandidate(simcomRequest({ synthetic: true }), { + canonicalMode: 'qmi', + }); + expect(outcome).toMatchObject({ ok: false, reason: 'synthetic-bundle' }); + }); +}); diff --git a/control/src/ussd/calls.ts b/control/src/ussd/calls.ts new file mode 100644 index 0000000..eeeb5b5 --- /dev/null +++ b/control/src/ussd/calls.ts @@ -0,0 +1,112 @@ +// The four D-Bus calls the USSD adapter makes, and nothing else. +// +// Split from the adapter so the session machinery above can be read without the +// marshalling below, and so a call's shape (interface, member, signature) is +// stated once in one place. +// +// CARRIER TEXT DISCIPLINE: `Initiate` and `Respond` both take and return operator +// text, and neither the command nor the reply may ever reach a log line. Nothing +// in this file logs, and every error raised here is re-thrown untouched so the +// classifier — not a string built around the payload — decides what the caller is +// told. + +import { MODEM3GPP_USSD_IFACE, PROPERTIES_IFACE } from '../backend/constants'; +import type { DbusTransport } from '../transport'; +import type { UssdRepliedState } from './session'; + +/** `MMModem3gppUssdSessionState`. */ +export const USSD_STATE_UNKNOWN = 0; +export const USSD_STATE_IDLE = 1; +export const USSD_STATE_ACTIVE = 2; +export const USSD_STATE_USER_RESPONSE = 3; + +/** + * Decode MM's post-call session state. `UNKNOWN` folds onto `released` with the + * rest: an unreadable state must close the session rather than leave the operator + * looking at a dialogue nothing can advance. + */ +export function decodeRepliedState(state: number): UssdRepliedState { + if (state === USSD_STATE_USER_RESPONSE) { + return 'awaiting-reply'; + } + return state === USSD_STATE_ACTIVE ? 'active' : 'released'; +} + +export interface UssdCallTarget { + readonly transport: DbusTransport; + readonly destination: string; + readonly modem: string; + readonly timeoutMs: number; +} + +function firstString(body: readonly unknown[]): string { + const value = body[0]; + return typeof value === 'string' ? value : ''; +} + +export async function callInitiate(target: UssdCallTarget, ussdCommand: string): Promise { + const reply = await target.transport.callMethod({ + destination: target.destination, + path: target.modem, + interface: MODEM3GPP_USSD_IFACE, + member: 'Initiate', + signature: 's', + args: [ussdCommand], + timeoutMs: target.timeoutMs, + }); + return firstString(reply.body); +} + +export async function callRespond(target: UssdCallTarget, ussdResponse: string): Promise { + const reply = await target.transport.callMethod({ + destination: target.destination, + path: target.modem, + interface: MODEM3GPP_USSD_IFACE, + member: 'Respond', + signature: 's', + args: [ussdResponse], + timeoutMs: target.timeoutMs, + }); + return firstString(reply.body); +} + +export async function callCancel(target: UssdCallTarget): Promise { + await target.transport.callMethod({ + destination: target.destination, + path: target.modem, + interface: MODEM3GPP_USSD_IFACE, + member: 'Cancel', + timeoutMs: target.timeoutMs, + }); +} + +/** + * Read the post-call session state. + * + * A targeted `Properties.Get` rather than a `GetManagedObjects` sweep: this runs + * after every network round-trip, and the whole-tree read is the most expensive + * call in the package. `unknown` on anything unreadable — the caller treats that + * as "the network released the session", which is the conservative direction (it + * closes a session rather than leaving one the operator cannot see). + */ +export async function readUssdState(target: UssdCallTarget): Promise { + try { + const reply = await target.transport.callMethod({ + destination: target.destination, + path: target.modem, + interface: PROPERTIES_IFACE, + member: 'Get', + signature: 'ss', + args: [MODEM3GPP_USSD_IFACE, 'State'], + timeoutMs: target.timeoutMs, + }); + const wrapped = reply.body[0]; + if (typeof wrapped === 'number') { + return wrapped; + } + const inner = (wrapped as { value?: unknown } | undefined)?.value; + return typeof inner === 'number' ? inner : USSD_STATE_UNKNOWN; + } catch { + return USSD_STATE_UNKNOWN; + } +} diff --git a/control/src/ussd/index.ts b/control/src/ussd/index.ts new file mode 100644 index 0000000..1d191f8 --- /dev/null +++ b/control/src/ussd/index.ts @@ -0,0 +1,53 @@ +// The gated USSD capability module — session state machine, refusal taxonomy, and +// the ModemManager `Modem3gpp.Ussd` adapter. +// +// GATED: nothing here runs unless the operator has enabled the `ussd` capability +// module and the modem positively advertises the interface (`../capability`). +// LEASE-ONLY: a USSD session cannot re-register the radio, so it takes the +// per-modem mutation lease and is NOT journaled. + +export { + callCancel, + callInitiate, + callRespond, + decodeRepliedState, + readUssdState, + USSD_STATE_ACTIVE, + USSD_STATE_IDLE, + USSD_STATE_UNKNOWN, + USSD_STATE_USER_RESPONSE, + type UssdCallTarget, +} from './calls'; +export { + MmUssd, + type MmUssdDeps, + type UssdScheduler, + type UssdTimerHandle, + type UssdVerbResult, +} from './mm-ussd'; +export { + classifyUssdFailure, + isPacketSwitchedOnly, + USSD_REFUSAL_REASONS, + type UssdRefusalReason, + type UssdRegistrationFacts, +} from './refusal'; +export { + decodeAccessTechnologies, + readUssdRegistrationFacts, + registrationFactsFromTree, + UNKNOWN_REGISTRATION, +} from './registration'; +export { + IDLE_SESSION, + isUssdSessionOpen, + reduceUssdSession, + USSD_SESSION_OUTCOMES, + USSD_SESSION_STATES, + type UssdRepliedState, + type UssdSessionEvent, + type UssdSessionOutcome, + type UssdSessionSnapshot, + type UssdSessionState, + type UssdTransition, +} from './session'; diff --git a/control/src/ussd/mm-ussd.test.ts b/control/src/ussd/mm-ussd.test.ts new file mode 100644 index 0000000..cb64df1 --- /dev/null +++ b/control/src/ussd/mm-ussd.test.ts @@ -0,0 +1,342 @@ +// The ModemManager USSD adapter, driven over a fake bus. +// +// Two properties are proved here that the pure machine cannot prove on its own: +// a REFUSED verb dispatches ZERO D-Bus calls (a doomed request must not disturb a +// live session, and must not cost a network round-trip), and an unanswered +// session closes at its bound AND best-effort releases the network's side. + +import { expect, test } from 'bun:test'; +import { MODEM_IFACE, MODEM3GPP_IFACE, MODEM3GPP_USSD_IFACE } from '../backend/constants'; +import { ModemActor } from '../backend/modem-actor'; +import { runtimePath } from '../domain'; +import { REDACTED, redact } from '../redact'; +import type { DbusTransport, MethodCall, MethodReply } from '../transport'; +import { USSD_STATE_ACTIVE, USSD_STATE_IDLE, USSD_STATE_USER_RESPONSE } from './calls'; +import { MmUssd, type UssdScheduler } from './mm-ussd'; + +const MODEM_PATH = '/org/freedesktop/ModemManager1/Modem/0'; +const MODEM = runtimePath(MODEM_PATH); +const STABLE_KEY = 'platform-xhci-hcd.0.auto-usb-0:1.4.4'; +const BALANCE_REPLY = 'Your balance is $4.20. Ref 998877'; + +const LTE_BIT = 1 << 14; +const UMTS_BIT = 1 << 5; + +interface BusOptions { + /** Value returned by `Initiate`/`Respond`, or an error to throw. */ + readonly reply?: string | Error; + /** MM's post-call `Ussd.State`. */ + readonly ussdState?: number; + readonly registrationState?: number; + readonly accessTechnologies?: number; + readonly cancelThrows?: boolean; +} + +interface FakeBus { + readonly transport: DbusTransport; + readonly calls: MethodCall[]; + members(): string[]; +} + +function fakeBus(options: BusOptions = {}): FakeBus { + const calls: MethodCall[] = []; + const managedObjects = [ + [ + MODEM_PATH, + [ + [ + MODEM_IFACE, + [['AccessTechnologies', { signature: 'u', value: options.accessTechnologies ?? 0 }]], + ], + [ + MODEM3GPP_IFACE, + [['RegistrationState', { signature: 'u', value: options.registrationState ?? 1 }]], + ], + ], + ], + ]; + + const transport = { + connect: () => Promise.resolve(), + disconnect: () => Promise.resolve(), + isConnected: () => true, + subscribeSignal: () => Promise.resolve({ unsubscribe: () => Promise.resolve() }), + on: () => undefined, + off: () => undefined, + subscriptionCount: () => 0, + callMethod(call: MethodCall): Promise { + calls.push(call); + if (call.member === 'GetManagedObjects') { + return Promise.resolve({ + signature: 'a{oa{sa{sv}}}', + body: [managedObjects], + } as unknown as MethodReply); + } + if (call.member === 'Get') { + return Promise.resolve({ + signature: 'v', + body: [{ signature: 'u', value: options.ussdState ?? USSD_STATE_IDLE }], + } as unknown as MethodReply); + } + if (call.member === 'Cancel') { + return options.cancelThrows + ? Promise.reject(new Error('modem is not answering')) + : Promise.resolve({ signature: '', body: [] }); + } + const reply = options.reply ?? ''; + return reply instanceof Error + ? Promise.reject(reply) + : Promise.resolve({ signature: 's', body: [reply] } as unknown as MethodReply); + }, + } as unknown as DbusTransport; + + return { + transport, + calls, + members: () => calls.map((call) => call.member), + }; +} + +/** A scheduler that fires nothing until the test asks it to. */ +function manualScheduler(): { scheduler: UssdScheduler; fire(): Promise } { + let pending: (() => void) | undefined; + return { + scheduler: (_delayMs, run) => { + pending = run; + return { + cancel: () => { + pending = undefined; + }, + }; + }, + fire: async () => { + pending?.(); + pending = undefined; + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + }, + }; +} + +function build(bus: FakeBus, extra: Partial[0]> = {}): MmUssd { + return new MmUssd({ + transport: bus.transport, + actor: new ModemActor(), + resolveStableKey: () => STABLE_KEY, + ...extra, + }); +} + +function dbusError(name: string): Error { + const error = new Error('operation failed'); + error.name = name; + return error; +} + +test('an initiate that the network answers with a question opens the dialogue', async () => { + const bus = fakeBus({ reply: BALANCE_REPLY, ussdState: USSD_STATE_USER_RESPONSE }); + const ussd = build(bus); + + const result = await ussd.initiate(MODEM, '*123#'); + + expect(result.ok).toBe(true); + expect(result.snapshot.state).toBe('awaiting-reply'); + expect(result.ussdReply).toBe(BALANCE_REPLY); + expect(bus.calls[0]?.interface).toBe(MODEM3GPP_USSD_IFACE); + expect(bus.calls[0]?.member).toBe('Initiate'); + expect(bus.calls[0]?.args).toEqual(['*123#']); + ussd.stop(); +}); + +test('an initiate the network releases completes the session in one shot', async () => { + const bus = fakeBus({ reply: BALANCE_REPLY, ussdState: USSD_STATE_IDLE }); + const ussd = build(bus); + + const result = await ussd.initiate(MODEM, '*123#'); + + expect(result.ok).toBe(true); + expect(result.snapshot).toEqual({ state: 'closed', outcome: 'completed' }); + expect(result.ussdReply).toBe(BALANCE_REPLY); + // The stored state resets, so the very next dialogue is admitted. + expect(ussd.snapshot(MODEM).state).toBe('idle'); + ussd.stop(); +}); + +test('a session the network holds open with nothing pending is `active`', async () => { + const bus = fakeBus({ reply: 'Request accepted', ussdState: USSD_STATE_ACTIVE }); + const ussd = build(bus); + + const result = await ussd.initiate(MODEM, '*111#'); + + expect(result.snapshot.state).toBe('active'); + expect(ussd.snapshot(MODEM).state).toBe('active'); + ussd.stop(); +}); + +test('responding to a prompt continues the dialogue', async () => { + const bus = fakeBus({ reply: 'Choose: 1) Data 2) Voice', ussdState: USSD_STATE_USER_RESPONSE }); + const ussd = build(bus); + await ussd.initiate(MODEM, '*123#'); + + const result = await ussd.respond(MODEM, '1'); + + expect(result.ok).toBe(true); + expect(result.snapshot.state).toBe('awaiting-reply'); + expect(bus.members()).toContain('Respond'); + expect(bus.calls.find((call) => call.member === 'Respond')?.args).toEqual(['1']); + ussd.stop(); +}); + +test('cancelling mid-dialogue closes the session and dispatches Cancel', async () => { + const bus = fakeBus({ reply: 'Menu', ussdState: USSD_STATE_USER_RESPONSE }); + const ussd = build(bus); + await ussd.initiate(MODEM, '*123#'); + + const result = await ussd.cancel(MODEM); + + expect(result.ok).toBe(true); + expect(result.snapshot).toEqual({ state: 'closed', outcome: 'cancelled' }); + expect(bus.members()).toContain('Cancel'); + expect(ussd.snapshot(MODEM).state).toBe('idle'); + ussd.stop(); +}); + +test('an unanswered session closes at the bound and releases the network side', async () => { + const bus = fakeBus({ reply: 'Menu', ussdState: USSD_STATE_USER_RESPONSE }); + const timer = manualScheduler(); + const seen: string[] = []; + const ussd = build(bus, { + scheduler: timer.scheduler, + onSessionChange: (_key, snapshot) => seen.push(snapshot.state), + }); + await ussd.initiate(MODEM, '*123#'); + expect(bus.members()).not.toContain('Cancel'); + + await timer.fire(); + + expect(ussd.snapshot(MODEM).state).toBe('idle'); + expect(seen.at(-1)).toBe('closed'); + expect(bus.members()).toContain('Cancel'); + ussd.stop(); +}); + +test('a bound that fires while the modem is unreachable still closes the session', async () => { + const bus = fakeBus({ + reply: 'Menu', + ussdState: USSD_STATE_USER_RESPONSE, + cancelThrows: true, + }); + const timer = manualScheduler(); + const ussd = build(bus, { scheduler: timer.scheduler }); + await ussd.initiate(MODEM, '*123#'); + + await timer.fire(); + + expect(ussd.snapshot(MODEM).state).toBe('idle'); + ussd.stop(); +}); + +test('an LTE-only carrier rejection is surfaced as its own typed refusal', async () => { + const bus = fakeBus({ + reply: dbusError('org.freedesktop.ModemManager1.Error.Core.Unsupported'), + registrationState: 1, + accessTechnologies: LTE_BIT, + }); + const ussd = build(bus); + + const result = await ussd.initiate(MODEM, '*123#'); + + expect(result.ok).toBe(false); + expect(result.refusal).toBe('lte-only-unsupported'); + expect(result.snapshot).toEqual({ + state: 'closed', + outcome: 'failed', + refusal: 'lte-only-unsupported', + }); + ussd.stop(); +}); + +test('the same rejection on a CS-capable registration reports a device limit', async () => { + const bus = fakeBus({ + reply: dbusError('org.freedesktop.ModemManager1.Error.Core.Unsupported'), + registrationState: 1, + accessTechnologies: UMTS_BIT, + }); + const ussd = build(bus); + + const result = await ussd.initiate(MODEM, '*123#'); + + expect(result.refusal).toBe('unsupported'); + ussd.stop(); +}); + +test('a second initiate is refused busy and dispatches ZERO calls', async () => { + const bus = fakeBus({ reply: 'Menu', ussdState: USSD_STATE_USER_RESPONSE }); + const ussd = build(bus); + await ussd.initiate(MODEM, '*123#'); + const before = bus.calls.length; + + const result = await ussd.initiate(MODEM, '*100#'); + + expect(result.ok).toBe(false); + expect(result.refusal).toBe('session-busy'); + expect(result.snapshot.state).toBe('awaiting-reply'); + expect(bus.calls.length).toBe(before); + ussd.stop(); +}); + +test('respond and cancel with no session are refused, and dispatch ZERO calls', async () => { + const bus = fakeBus(); + const ussd = build(bus); + + const responded = await ussd.respond(MODEM, '1'); + const cancelled = await ussd.cancel(MODEM); + + expect(responded.refusal).toBe('invalid-state'); + expect(cancelled.refusal).toBe('no-session'); + expect(bus.calls).toHaveLength(0); + ussd.stop(); +}); + +test('a modem that never answered a session can start a new one', async () => { + const bus = fakeBus({ + reply: dbusError('org.freedesktop.ModemManager1.Error.Core.Failed'), + }); + const ussd = build(bus); + await ussd.initiate(MODEM, '*123#'); + + const second = await ussd.initiate(MODEM, '*123#'); + + // Failed is terminal for the SESSION, never for the modem. + expect(second.refusal).not.toBe('session-busy'); + ussd.stop(); +}); + +test('the carrier reply is masked by the shared redactor wherever it is serialized', async () => { + const bus = fakeBus({ reply: BALANCE_REPLY, ussdState: USSD_STATE_USER_RESPONSE }); + const ussd = build(bus); + + const result = await ussd.initiate(MODEM, '*123*9911#'); + const serialized = JSON.stringify(redact({ result, request: { ussdCommand: '*123*9911#' } })); + + expect(serialized).not.toContain(BALANCE_REPLY); + expect(serialized).not.toContain('9911'); + expect(serialized).toContain(REDACTED); + // The non-secret half of the same object survives, so this is redaction and + // not a blanket drop. + expect(serialized).toContain('awaiting-reply'); + ussd.stop(); +}); + +test('stop() drops the pending bound without cancelling the modem session', async () => { + const bus = fakeBus({ reply: 'Menu', ussdState: USSD_STATE_USER_RESPONSE }); + const timer = manualScheduler(); + const ussd = build(bus, { scheduler: timer.scheduler }); + await ussd.initiate(MODEM, '*123#'); + + ussd.stop(); + await timer.fire(); + + expect(bus.members()).not.toContain('Cancel'); +}); diff --git a/control/src/ussd/mm-ussd.ts b/control/src/ussd/mm-ussd.ts new file mode 100644 index 0000000..f858f29 --- /dev/null +++ b/control/src/ussd/mm-ussd.ts @@ -0,0 +1,276 @@ +// The ModemManager USSD adapter — `Modem3gpp.Ussd` driven by the pure session +// machine in `./session`. +// +// Three properties are load-bearing and none of them is obvious from the D-Bus +// API alone: +// +// 1. **Every verb runs through the shared per-modem `ModemActor`.** A USSD +// session is a single network-side resource per subscriber, so two verbs +// interleaving on one modem is exactly the double-dialogue the network +// answers busy. The actor is keyed on the STABLE key, so the serialization +// survives a replug like every other disruptive op in this package. +// +// 2. **An unanswered session is closed at a bound, and the CANCEL is attempted.** +// A session nobody responds to stays open NETWORK-side; letting it expire in +// silence would leave the next `Initiate` failing busy for reasons the +// operator cannot see. The bound closes our machine and best-effort releases +// the network's — best-effort because a modem that did not answer the last +// call may not answer this one either, and a timeout must still terminate. +// +// 3. **A `closed` session resets the STORED state to idle, while the CALLER is +// told `closed`.** The machine is deliberately terminal so a finished session +// cannot be resurrected; the adapter's map is per-modem and long-lived, so it +// starts each new dialogue from a fresh machine. +// +// The carrier's text rides `ussdReply` and NOTHING here logs it. That field name +// is not cosmetic: `../redact` is key-based, so the name IS what guarantees the +// value is masked in every receipt, bundle, and log line it can reach. + +import { MM_BUS_NAME } from '../backend/constants'; +import type { ModemActor } from '../backend/modem-actor'; +import type { ModemRef } from '../ports'; +import type { DbusTransport } from '../transport'; +import { + callCancel, + callInitiate, + callRespond, + decodeRepliedState, + readUssdState, + type UssdCallTarget, +} from './calls'; +import { classifyUssdFailure, type UssdRefusalReason } from './refusal'; +import { readUssdRegistrationFacts } from './registration'; +import { + IDLE_SESSION, + reduceUssdSession, + type UssdSessionEvent, + type UssdSessionSnapshot, +} from './session'; + +/** A network round-trip. USSD legitimately takes tens of seconds. */ +const DEFAULT_CALL_TIMEOUT_MS = 45_000; +/** How long a session may sit awaiting an operator response before it is closed. */ +const DEFAULT_SESSION_IDLE_TIMEOUT_MS = 120_000; + +export interface UssdTimerHandle { + cancel(): void; +} +export type UssdScheduler = (delayMs: number, run: () => void) => UssdTimerHandle; + +const defaultScheduler: UssdScheduler = (delayMs, run) => { + const timer = setTimeout(run, delayMs); + timer.unref?.(); + return { + cancel: () => { + clearTimeout(timer); + }, + }; +}; + +export interface UssdVerbResult { + readonly ok: boolean; + readonly snapshot: UssdSessionSnapshot; + /** Carrier text. Redacted by key everywhere it is serialized. */ + readonly ussdReply?: string; + readonly refusal?: UssdRefusalReason; +} + +export interface MmUssdDeps { + readonly transport: DbusTransport; + readonly actor: ModemActor; + readonly destination?: string; + readonly resolveStableKey: (modem: ModemRef) => string; + readonly callTimeoutMs?: number; + readonly sessionIdleTimeoutMs?: number; + readonly scheduler?: UssdScheduler; + /** Notified on every stored-state change, so a UI can follow a session. */ + readonly onSessionChange?: (stableKey: string, snapshot: UssdSessionSnapshot) => void; +} + +export class MmUssd { + readonly #deps: MmUssdDeps; + readonly #destination: string; + readonly #callTimeoutMs: number; + readonly #idleTimeoutMs: number; + readonly #scheduler: UssdScheduler; + readonly #sessions = new Map(); + readonly #timers = new Map(); + + constructor(deps: MmUssdDeps) { + this.#deps = deps; + this.#destination = deps.destination ?? MM_BUS_NAME; + this.#callTimeoutMs = deps.callTimeoutMs ?? DEFAULT_CALL_TIMEOUT_MS; + this.#idleTimeoutMs = deps.sessionIdleTimeoutMs ?? DEFAULT_SESSION_IDLE_TIMEOUT_MS; + this.#scheduler = deps.scheduler ?? defaultScheduler; + } + + snapshot(modem: ModemRef): UssdSessionSnapshot { + return this.#sessions.get(this.#deps.resolveStableKey(modem)) ?? IDLE_SESSION; + } + + initiate(modem: ModemRef, ussdCommand: string): Promise { + return this.#runVerb(modem, { kind: 'initiate' }, (target) => + callInitiate(target, ussdCommand), + ); + } + + respond(modem: ModemRef, ussdResponse: string): Promise { + return this.#runVerb(modem, { kind: 'respond' }, (target) => callRespond(target, ussdResponse)); + } + + cancel(modem: ModemRef): Promise { + const key = this.#deps.resolveStableKey(modem); + return this.#deps.actor.run(key, async () => { + const gate = this.#gate(key, { kind: 'cancel' }); + if (!gate.ok) { + return gate.result; + } + const target = this.#target(modem); + try { + await callCancel(target); + return this.#settle(key, { kind: 'cancelled' }); + } catch (error) { + return this.#fail(key, modem, error); + } + }); + } + + /** Drop every timer. A live session on the modem is NOT cancelled by this. */ + stop(): void { + for (const timer of this.#timers.values()) { + timer.cancel(); + } + this.#timers.clear(); + } + + async #runVerb( + modem: ModemRef, + event: UssdSessionEvent, + dispatch: (target: UssdCallTarget) => Promise, + ): Promise { + const key = this.#deps.resolveStableKey(modem); + return this.#deps.actor.run(key, async () => { + const gate = this.#gate(key, event); + if (!gate.ok) { + return gate.result; + } + const target = this.#target(modem); + try { + const ussdReply = await dispatch(target); + const sessionState = decodeRepliedState(await readUssdState(target)); + const settled = this.#settle(key, { kind: 'replied', sessionState }); + // A session the network kept open — whether or not it asked a + // question — is what the idle bound exists to release. + if (sessionState !== 'released') { + this.#armIdleTimeout(key, modem); + } + return { ...settled, ussdReply }; + } catch (error) { + return this.#fail(key, modem, error); + } + }); + } + + /** + * Apply the operator's verb to the machine. A refusal is returned WITHOUT + * touching the stored state or dialling the bus — a doomed verb must not + * disturb a live session. + */ + #gate( + key: string, + event: UssdSessionEvent, + ): { ok: true } | { ok: false; result: UssdVerbResult } { + const current = this.#sessions.get(key) ?? IDLE_SESSION; + const transition = reduceUssdSession(current, event); + if (!transition.ok) { + return { + ok: false, + result: { ok: false, snapshot: current, refusal: transition.refusal }, + }; + } + this.#store(key, transition.snapshot); + return { ok: true }; + } + + #settle(key: string, event: UssdSessionEvent): UssdVerbResult { + const current = this.#sessions.get(key) ?? IDLE_SESSION; + const transition = reduceUssdSession(current, event); + if (!transition.ok) { + return { ok: false, snapshot: current, refusal: transition.refusal }; + } + this.#store(key, transition.snapshot); + const closedRefusal = transition.snapshot.refusal; + return { + ok: closedRefusal === undefined, + snapshot: transition.snapshot, + ...(closedRefusal === undefined ? {} : { refusal: closedRefusal }), + }; + } + + async #fail(key: string, modem: ModemRef, error: unknown): Promise { + const registration = await readUssdRegistrationFacts( + this.#deps.transport, + this.#destination, + modem, + ); + return this.#settle(key, { kind: 'failed', reason: classifyUssdFailure(error, registration) }); + } + + /** + * A session reaching `closed` is REPORTED as closed and STORED as idle, so the + * next `initiate` starts from a fresh machine rather than the terminal one. + */ + #store(key: string, snapshot: UssdSessionSnapshot): void { + this.#clearTimer(key); + if (snapshot.state === 'closed') { + this.#sessions.delete(key); + } else { + this.#sessions.set(key, snapshot); + } + this.#deps.onSessionChange?.(key, snapshot); + } + + #armIdleTimeout(key: string, modem: ModemRef): void { + this.#clearTimer(key); + this.#timers.set( + key, + this.#scheduler(this.#idleTimeoutMs, () => { + void this.#expire(key, modem); + }), + ); + } + + #clearTimer(key: string): void { + this.#timers.get(key)?.cancel(); + this.#timers.delete(key); + } + + /** + * The bound elapsed. The machine closes `timed-out` FIRST — that outcome is + * the operator's answer whether or not the release lands — and the modem-side + * cancel is attempted afterwards, best-effort. + */ + async #expire(key: string, modem: ModemRef): Promise { + await this.#deps.actor.run(key, async () => { + if (!this.#sessions.has(key)) { + return; + } + this.#settle(key, { kind: 'timeout' }); + try { + await callCancel(this.#target(modem)); + } catch { + // The modem that did not answer the dialogue may not answer this + // either; the session is already closed on our side. + } + }); + } + + #target(modem: ModemRef): UssdCallTarget { + return { + transport: this.#deps.transport, + destination: this.#destination, + modem, + timeoutMs: this.#callTimeoutMs, + }; + } +} diff --git a/control/src/ussd/refusal.test.ts b/control/src/ussd/refusal.test.ts new file mode 100644 index 0000000..7d15d7a --- /dev/null +++ b/control/src/ussd/refusal.test.ts @@ -0,0 +1,121 @@ +// Classifying a failed USSD call — and the one distinction this module exists +// for: a carrier that will not carry USSD on a packet-only registration is NOT a +// modem that cannot do USSD. + +import { expect, test } from 'bun:test'; +import { classifyUssdFailure, isPacketSwitchedOnly } from './refusal'; + +/** MM raises D-Bus errors whose `name` is the fully-qualified error name. */ +function dbusError(name: string, message = 'operation failed'): Error { + const error = new Error(message); + error.name = name; + return error; +} + +const CS_CAPABLE = { registered: true, csDomain: true, accessTechnologies: ['umts'] }; +const LTE_ONLY = { registered: true, csDomain: false, accessTechnologies: ['lte'] }; +const UNREAD = { registered: false }; + +test('an unsupported error on a CS-capable registration is a device limit', () => { + const reason = classifyUssdFailure( + dbusError('org.freedesktop.ModemManager1.Error.Core.Unsupported'), + CS_CAPABLE, + ); + expect(reason).toBe('unsupported'); +}); + +test('the SAME error on an LTE-only registration is reported as a carrier policy', () => { + const reason = classifyUssdFailure( + dbusError('org.freedesktop.ModemManager1.Error.Core.Unsupported'), + LTE_ONLY, + ); + expect(reason).toBe('lte-only-unsupported'); +}); + +test('a generic Core.Failed on an LTE-only registration is promoted too', () => { + const reason = classifyUssdFailure( + dbusError('org.freedesktop.ModemManager1.Error.Core.Failed', 'network rejected the request'), + LTE_ONLY, + ); + expect(reason).toBe('lte-only-unsupported'); +}); + +test('an UNREAD registration never earns the more specific claim', () => { + // "We did not look" is not evidence, so the honest answer stays the generic + // one. This is the property that makes the promotion above safe. + const reason = classifyUssdFailure( + dbusError('org.freedesktop.ModemManager1.Error.Core.Unsupported'), + UNREAD, + ); + expect(reason).toBe('unsupported'); +}); + +test('a not-registered failure is NOT promoted, on any registration', () => { + const reason = classifyUssdFailure( + dbusError('org.freedesktop.ModemManager1.Error.Core.NoNetwork'), + LTE_ONLY, + ); + expect(reason).toBe('not-registered'); +}); + +test('a bus failure is NOT promoted and stays transport-failed', () => { + const reason = classifyUssdFailure(new Error('D-Bus connection is not established'), LTE_ONLY); + expect(reason).toBe('transport-failed'); +}); + +test('an in-progress error is the busy session', () => { + expect( + classifyUssdFailure(dbusError('org.freedesktop.ModemManager1.Error.Core.InProgress')), + ).toBe('session-busy'); +}); + +test('the transport timeout message classifies as a timeout', () => { + const reason = classifyUssdFailure( + new Error('Method call Modem3gpp.Ussd.Initiate timed out after 45000ms'), + ); + expect(reason).toBe('timeout'); +}); + +test('a Core.Failed naming no active session is no-session, not carrier-rejected', () => { + const reason = classifyUssdFailure( + dbusError('org.freedesktop.ModemManager1.Error.Core.Failed', 'no active USSD session'), + ); + expect(reason).toBe('no-session'); +}); + +test('an error carrying its D-Bus name on `dbusName` is read too', () => { + const error = Object.assign(new Error('boom'), { + dbusName: 'org.freedesktop.ModemManager1.Error.Core.Unsupported', + }); + expect(classifyUssdFailure(error, CS_CAPABLE)).toBe('unsupported'); +}); + +test('an unrecognised failure of any shape is transport-failed', () => { + expect(classifyUssdFailure('something went wrong')).toBe('transport-failed'); + expect(classifyUssdFailure(undefined)).toBe('transport-failed'); + expect(classifyUssdFailure({})).toBe('transport-failed'); +}); + +test('packet-switched-only needs evidence in BOTH directions', () => { + expect(isPacketSwitchedOnly(LTE_ONLY)).toBe(true); + expect( + isPacketSwitchedOnly({ registered: true, csDomain: false, accessTechnologies: ['5gnr'] }), + ).toBe(true); + // A CS domain that is present, unknown, or contradicted by a CS radio all + // answer false — the claim requires a positive absence. + expect(isPacketSwitchedOnly(CS_CAPABLE)).toBe(false); + expect(isPacketSwitchedOnly({ registered: true, accessTechnologies: ['lte'] })).toBe(false); + expect( + isPacketSwitchedOnly({ + registered: true, + csDomain: false, + accessTechnologies: ['lte', 'umts'], + }), + ).toBe(false); + expect(isPacketSwitchedOnly({ registered: true, csDomain: false, accessTechnologies: [] })).toBe( + false, + ); + expect( + isPacketSwitchedOnly({ registered: false, csDomain: false, accessTechnologies: ['lte'] }), + ).toBe(false); +}); diff --git a/control/src/ussd/refusal.ts b/control/src/ussd/refusal.ts new file mode 100644 index 0000000..9129042 --- /dev/null +++ b/control/src/ussd/refusal.ts @@ -0,0 +1,191 @@ +// Why a USSD verb did not succeed — one typed vocabulary, and the classifier that +// maps ModemManager's own failure surface onto it. +// +// The point of typing these is that every member names a DIFFERENT thing the +// operator can do, and the one this module exists for is the honest reporting of +// a carrier that will not carry USSD at all on the registration the modem has. +// +// THE LTE-ONLY CASE, stated plainly. USSD is a circuit-switched supplementary +// service. A modem registered PS-only — LTE or 5G-SA with no CS domain and no +// CSFB — can only carry it if the operator deploys USSI (USSD over IMS, 3GPP TS +// 24.390); many do not, and the modem/network then answers a generic +// unsupported/failed error that is indistinguishable, on its face, from "this +// modem has no USSD interface". Reporting that as a device limitation would send +// an operator hunting for a firmware fix for a network policy, so the classifier +// takes the modem's REGISTRATION alongside the error and separates the two. +// +// Nothing here guesses: `lte-only-unsupported` is claimed ONLY when the +// registration is positively known to be PS-only. An unknown registration keeps +// the generic reason, because "we did not look" is not evidence. + +/** + * Every way a USSD verb can fail to do what the operator asked. + * + * unsupported — this modem exposes no USSD interface at all. + * lte-only-unsupported — the modem is registered PS-only (LTE/5G with no CS + * domain) and the network refused. NOT a device fault. + * carrier-rejected — the network refused for some other reason. + * not-registered — there is no network to carry the session. + * session-busy — a session is already open (locally or network-side). + * no-session — respond/cancel with nothing open. + * invalid-state — the verb is wrong for the state the session is in. + * timeout — the bounded wait elapsed with no answer. + * transport-failed — the bus call itself failed; the modem never answered. + */ +export const USSD_REFUSAL_REASONS = [ + 'unsupported', + 'lte-only-unsupported', + 'carrier-rejected', + 'not-registered', + 'session-busy', + 'no-session', + 'invalid-state', + 'timeout', + 'transport-failed', +] as const; +export type UssdRefusalReason = (typeof USSD_REFUSAL_REASONS)[number]; + +/** + * What the modem is registered on, as far as anyone has looked. + * + * `csDomain` is the load-bearing field: `false` means the modem is attached with + * NO circuit-switched domain available (LTE/5G-SA without CSFB), which is the + * registration on which a plain USSD refusal is a carrier-policy statement rather + * than a device one. `undefined` means nobody read it, and is never treated as + * `false`. + */ +export interface UssdRegistrationFacts { + readonly registered: boolean; + /** Is a circuit-switched domain available on this registration? */ + readonly csDomain?: boolean; + /** MM access technologies currently in use, lowercased (`lte`, `5gnr`, …). */ + readonly accessTechnologies?: readonly string[]; +} + +/** Access technologies that carry no circuit-switched domain of their own. */ +const PACKET_ONLY_RATS: ReadonlySet = new Set([ + 'lte', + '5gnr', + 'lte-cat-m', + 'lte-nb-iot', +]); + +/** + * True when the modem's registration positively cannot carry a circuit-switched + * service. Requires evidence in BOTH directions: something must say the CS domain + * is absent, and every technology in use must be a packet-only one. A modem that + * reported no technologies at all answers `false` — an empty list is a statement + * about the read. + */ +export function isPacketSwitchedOnly(facts: UssdRegistrationFacts): boolean { + if (!facts.registered) { + return false; + } + if (facts.csDomain !== false) { + return false; + } + const rats = facts.accessTechnologies; + if (rats === undefined || rats.length === 0) { + return false; + } + return rats.every((rat) => PACKET_ONLY_RATS.has(rat.toLowerCase())); +} + +/** + * ModemManager D-Bus error names this classifier recognises, matched on the + * SUFFIX after the last dot so a future `...Error.Core.Unsupported` regrouping + * does not silently fall through to `transport-failed`. + */ +const ERROR_SUFFIX_REASONS: ReadonlyMap = new Map< + string, + UssdRefusalReason +>([ + ['Unsupported', 'unsupported'], + ['NotSupported', 'unsupported'], + ['InProgress', 'session-busy'], + ['NoNetwork', 'not-registered'], + ['NotRegistered', 'not-registered'], + ['Timeout', 'timeout'], + ['Aborted', 'carrier-rejected'], + ['Failed', 'carrier-rejected'], +]); + +/** + * Message fragments that identify a refusal no error NAME distinguishes. MM + * folds several modem answers into `Core.Failed`, so the text is the only signal + * separating "the network said no" from "the bus call broke". + */ +const MESSAGE_REASONS: readonly (readonly [RegExp, UssdRefusalReason])[] = [ + [/ussd.*(?:not supported|unsupported)/i, 'unsupported'], + [/(?:session|operation) (?:already )?(?:active|in progress)/i, 'session-busy'], + [/no (?:active )?ussd session/i, 'no-session'], + [/not registered|no network/i, 'not-registered'], + [/timed? ?out/i, 'timeout'], + [/rejected|refused|denied|network error/i, 'carrier-rejected'], +]; + +function errorName(error: unknown): string | undefined { + if (typeof error !== 'object' || error === null) { + return undefined; + } + const name = (error as { dbusName?: unknown; name?: unknown }).dbusName; + if (typeof name === 'string' && name.includes('.')) { + return name; + } + const fallback = (error as { name?: unknown }).name; + return typeof fallback === 'string' && fallback.includes('.') ? fallback : undefined; +} + +function errorMessage(error: unknown): string { + if (error instanceof Error) { + return error.message; + } + if (typeof error === 'object' && error !== null) { + const message = (error as { message?: unknown }).message; + if (typeof message === 'string') { + return message; + } + } + return String(error); +} + +/** + * Classify a failed USSD call. Pure, total, never throws. + * + * Order is deliberate: the D-Bus error NAME is the strongest signal and is read + * first; the message text is consulted only for the names MM overloads; anything + * unrecognised stays `transport-failed`, which is the honest answer for a failure + * whose origin cannot be attributed to the network. + * + * The PS-only promotion runs LAST and applies to exactly the two reasons that are + * ambiguous between a device limit and a carrier policy — a `not-registered` or a + * bus failure is neither, and is left alone. + */ +export function classifyUssdFailure( + error: unknown, + registration: UssdRegistrationFacts = { registered: false }, +): UssdRefusalReason { + const name = errorName(error); + const suffix = name?.slice(name.lastIndexOf('.') + 1); + const message = errorMessage(error); + + let reason: UssdRefusalReason | undefined = + suffix === undefined ? undefined : ERROR_SUFFIX_REASONS.get(suffix); + + // `Core.Failed` is MM's catch-all, so its message is worth more than its name. + if (reason === undefined || reason === 'carrier-rejected') { + for (const [pattern, mapped] of MESSAGE_REASONS) { + if (pattern.test(message)) { + reason = mapped; + break; + } + } + } + + if (reason === undefined) { + return 'transport-failed'; + } + + const ambiguous = reason === 'unsupported' || reason === 'carrier-rejected'; + return ambiguous && isPacketSwitchedOnly(registration) ? 'lte-only-unsupported' : reason; +} diff --git a/control/src/ussd/registration.test.ts b/control/src/ussd/registration.test.ts new file mode 100644 index 0000000..4667b80 --- /dev/null +++ b/control/src/ussd/registration.test.ts @@ -0,0 +1,122 @@ +// Deriving the registration facts the USSD classifier needs. +// +// ModemManager publishes no "is a CS domain available" property, so this is the +// derivation that decides whether a refusal may be reported as a carrier policy +// rather than a device limit — and it must never over-claim. + +import { expect, test } from 'bun:test'; +import { MODEM_IFACE, MODEM3GPP_IFACE } from '../backend/constants'; +import type { DecodedManagedObjects } from '../backend/managed-objects'; +import { decodeAccessTechnologies, registrationFactsFromTree } from './registration'; + +const MODEM = '/org/freedesktop/ModemManager1/Modem/0'; + +const LTE_BIT = 1 << 14; +const NR_BIT = 1 << 15; +const UMTS_BIT = 1 << 5; +const GSM_BIT = 1 << 1; + +function tree(options: { + registrationState?: number; + accessTechnologies?: number; +}): DecodedManagedObjects { + const modemProps: Array = []; + if (options.accessTechnologies !== undefined) { + modemProps.push(['AccessTechnologies', { signature: 'u', value: options.accessTechnologies }]); + } + const threeGppProps: Array = []; + if (options.registrationState !== undefined) { + threeGppProps.push(['RegistrationState', { signature: 'u', value: options.registrationState }]); + } + return [ + [ + MODEM, + [ + [MODEM_IFACE, modemProps], + [MODEM3GPP_IFACE, threeGppProps], + ], + ], + ] as unknown as DecodedManagedObjects; +} + +test('an LTE-only home registration is registered with NO circuit-switched domain', () => { + const facts = registrationFactsFromTree( + tree({ registrationState: 1, accessTechnologies: LTE_BIT }), + MODEM, + ); + expect(facts).toEqual({ registered: true, csDomain: false, accessTechnologies: ['lte'] }); +}); + +test('a 5G-NR-only registration is packet-only too', () => { + const facts = registrationFactsFromTree( + tree({ registrationState: 5, accessTechnologies: NR_BIT }), + MODEM, + ); + expect(facts.csDomain).toBe(false); + expect(facts.accessTechnologies).toEqual(['5gnr']); +}); + +test('a CSFB registration is CS-capable even while camped on LTE', () => { + // States 9/10 (`*_CSFB_NOT_PREFERRED`) are MM saying outright that the CS + // domain is reachable, so the radio in use does not get to overrule them. + for (const state of [9, 10]) { + const facts = registrationFactsFromTree( + tree({ registrationState: state, accessTechnologies: LTE_BIT }), + MODEM, + ); + expect(facts.csDomain).toBe(true); + expect(facts.registered).toBe(true); + } +}); + +test('a UMTS registration is CS-capable', () => { + const facts = registrationFactsFromTree( + tree({ registrationState: 1, accessTechnologies: UMTS_BIT }), + MODEM, + ); + expect(facts.csDomain).toBe(true); +}); + +test('an LTE registration with a CS radio also in use is CS-capable', () => { + const facts = registrationFactsFromTree( + tree({ registrationState: 1, accessTechnologies: LTE_BIT | GSM_BIT }), + MODEM, + ); + expect(facts.csDomain).toBe(true); + expect(facts.accessTechnologies).toEqual(['gsm', 'lte']); +}); + +test('an UNREAD access-technology mask leaves the CS domain undeclared', () => { + const facts = registrationFactsFromTree(tree({ registrationState: 1 }), MODEM); + expect(facts.registered).toBe(true); + expect(facts.csDomain).toBeUndefined(); + expect(facts.accessTechnologies).toBeUndefined(); +}); + +test('a zero mask is a read that named no radio, and declares nothing', () => { + const facts = registrationFactsFromTree( + tree({ registrationState: 1, accessTechnologies: 0 }), + MODEM, + ); + expect(facts.accessTechnologies).toEqual([]); + expect(facts.csDomain).toBeUndefined(); +}); + +test('a searching or denied modem is not registered', () => { + for (const state of [0, 2, 3, 4]) { + expect(registrationFactsFromTree(tree({ registrationState: state }), MODEM).registered).toBe( + false, + ); + } +}); + +test('a modem missing from the tree yields the unregistered default', () => { + expect(registrationFactsFromTree([], MODEM)).toEqual({ registered: false }); +}); + +test('the access-technology mask decodes every named bit', () => { + expect(decodeAccessTechnologies(LTE_BIT | NR_BIT)).toEqual(['lte', '5gnr']); + expect(decodeAccessTechnologies(1 << 16)).toEqual(['lte-cat-m']); + expect(decodeAccessTechnologies(1 << 17)).toEqual(['lte-nb-iot']); + expect(decodeAccessTechnologies(0)).toEqual([]); +}); diff --git a/control/src/ussd/registration.ts b/control/src/ussd/registration.ts new file mode 100644 index 0000000..a1a7ae6 --- /dev/null +++ b/control/src/ussd/registration.ts @@ -0,0 +1,126 @@ +// Reading the registration facts the USSD refusal classifier needs. +// +// ModemManager publishes NO "is a circuit-switched domain available" property, so +// the fact has to be DERIVED — and the derivation is worth stating, because it is +// the difference between telling an operator their modem cannot do USSD and +// telling them their carrier will not carry it on this registration: +// +// * `Modem.AccessTechnologies` is a bitmask. When every bit in use is a +// packet-only radio (LTE, 5G-NR, LTE-M, NB-IoT) the modem is not camped on a +// circuit-switched radio at all. +// * `Modem3gpp.RegistrationState` still overrides that, because CS FALLBACK is +// exactly the case where an LTE-camped modem CAN reach the CS domain. MM has +// two states that say so outright — `HOME_CSFB_NOT_PREFERRED` (9) and +// `ROAMING_CSFB_NOT_PREFERRED` (10) — and a modem in either of them is +// reported CS-capable regardless of its radio. +// +// Both reads are best-effort and NEVER throw: a fact nobody could read is left +// `undefined`, which the classifier treats as "we did not look" rather than as a +// negative. That asymmetry is the whole safety property — an unread registration +// can only ever produce the generic refusal, never the more specific claim. + +import { MODEM_IFACE, MODEM3GPP_IFACE } from '../backend/constants'; +import { + type DecodedManagedObjects, + fetchManagedObjects, + findInterface, + numberProp, +} from '../backend/managed-objects'; +import type { DbusTransport } from '../transport'; +import type { UssdRegistrationFacts } from './refusal'; + +/** `MMModemAccessTechnology` bits this module names. */ +const ACCESS_TECHNOLOGY_BITS: readonly (readonly [number, string])[] = [ + [1 << 1, 'gsm'], + [1 << 2, 'gsm-compact'], + [1 << 3, 'gprs'], + [1 << 4, 'edge'], + [1 << 5, 'umts'], + [1 << 6, 'hsdpa'], + [1 << 7, 'hsupa'], + [1 << 8, 'hspa'], + [1 << 9, 'hspa-plus'], + [1 << 10, '1xrtt'], + [1 << 11, 'evdo0'], + [1 << 12, 'evdoa'], + [1 << 13, 'evdob'], + [1 << 14, 'lte'], + [1 << 15, '5gnr'], + [1 << 16, 'lte-cat-m'], + [1 << 17, 'lte-nb-iot'], +]; + +/** `MMModem3gppRegistrationState` values that mean the modem is on a network. */ +const REGISTERED_STATES: ReadonlySet = new Set([1, 5, 6, 7, 9, 10]); + +/** …and the two that positively advertise a circuit-switched fallback. */ +const CSFB_STATES: ReadonlySet = new Set([9, 10]); + +/** Decode `Modem.AccessTechnologies` into the technology names in use. */ +export function decodeAccessTechnologies(mask: number): readonly string[] { + const names: string[] = []; + for (const [bit, name] of ACCESS_TECHNOLOGY_BITS) { + if ((mask & bit) !== 0) { + names.push(name); + } + } + return names; +} + +/** + * Derive the registration facts from an already-fetched managed-objects tree. + * + * Pure, so the derivation is testable against a fixture tree without a bus — the + * `sim-unlock.ts` split between "read the tree" and "decide from the tree". + */ +export function registrationFactsFromTree( + tree: DecodedManagedObjects, + modemPath: string, +): UssdRegistrationFacts { + const modem = findInterface(tree, modemPath, MODEM_IFACE); + const threeGpp = findInterface(tree, modemPath, MODEM3GPP_IFACE); + const registrationState = numberProp(threeGpp, 'RegistrationState'); + const accessMask = numberProp(modem, 'AccessTechnologies'); + + const registered = registrationState !== undefined && REGISTERED_STATES.has(registrationState); + const technologies = accessMask === undefined ? undefined : decodeAccessTechnologies(accessMask); + + // A CSFB registration is CS-capable outright. Otherwise the domain is only + // declared ABSENT when the radios in use were actually read and are all + // packet-only; an unread mask leaves the field undefined on purpose. + let csDomain: boolean | undefined; + if (registrationState !== undefined && CSFB_STATES.has(registrationState)) { + csDomain = true; + } else if (technologies !== undefined && technologies.length > 0) { + csDomain = technologies.some( + (rat) => rat !== 'lte' && rat !== '5gnr' && rat !== 'lte-cat-m' && rat !== 'lte-nb-iot', + ); + } + + return { + registered, + ...(csDomain === undefined ? {} : { csDomain }), + ...(technologies === undefined ? {} : { accessTechnologies: technologies }), + }; +} + +/** The unread default — every field withheld, so nothing can be claimed from it. */ +export const UNKNOWN_REGISTRATION: UssdRegistrationFacts = { registered: false }; + +/** + * Read the registration facts for one modem. Fail-soft: a tree we could not fetch + * yields {@link UNKNOWN_REGISTRATION}, which can only ever make the refusal LESS + * specific. + */ +export async function readUssdRegistrationFacts( + transport: DbusTransport, + destination: string, + modemPath: string, +): Promise { + try { + const tree = await fetchManagedObjects(transport, destination); + return registrationFactsFromTree(tree, modemPath); + } catch { + return UNKNOWN_REGISTRATION; + } +} diff --git a/control/src/ussd/session.test.ts b/control/src/ussd/session.test.ts new file mode 100644 index 0000000..7d06148 --- /dev/null +++ b/control/src/ussd/session.test.ts @@ -0,0 +1,219 @@ +// The USSD session machine, one test per (state, event) cell. +// +// The table below is the CONTRACT, not a convenience: a USSD session is a scarce +// network-side resource, so "which verb is legal here" has a wrong answer that +// costs the operator a busy error they cannot explain. Every cell is enumerated +// and every cell is its own test, so adding a state or an event fails loudly +// (the exhaustiveness check at the bottom) instead of silently landing in a +// default branch. + +import { describe, expect, test } from 'bun:test'; +import { + IDLE_SESSION, + isUssdSessionOpen, + reduceUssdSession, + USSD_SESSION_STATES, + type UssdSessionEvent, + type UssdSessionSnapshot, + type UssdSessionState, +} from './session'; + +const EVENTS = { + initiate: { kind: 'initiate' }, + respond: { kind: 'respond' }, + cancel: { kind: 'cancel' }, + 'replied(awaiting-reply)': { kind: 'replied', sessionState: 'awaiting-reply' }, + 'replied(active)': { kind: 'replied', sessionState: 'active' }, + 'replied(released)': { kind: 'replied', sessionState: 'released' }, + cancelled: { kind: 'cancelled' }, + 'network-released': { kind: 'network-released' }, + timeout: { kind: 'timeout' }, + failed: { kind: 'failed', reason: 'carrier-rejected' }, +} as const satisfies Record; + +type EventName = keyof typeof EVENTS; + +/** `open:` | `close:` | `refuse:`. */ +type Expected = string; + +const TABLE: Record> = { + idle: { + initiate: 'open:initiating', + respond: 'refuse:invalid-state', + cancel: 'refuse:no-session', + 'replied(awaiting-reply)': 'refuse:invalid-state', + 'replied(active)': 'refuse:invalid-state', + 'replied(released)': 'refuse:invalid-state', + cancelled: 'refuse:invalid-state', + 'network-released': 'refuse:no-session', + timeout: 'refuse:no-session', + failed: 'refuse:no-session', + }, + initiating: { + initiate: 'refuse:session-busy', + respond: 'refuse:invalid-state', + cancel: 'open:cancelling', + 'replied(awaiting-reply)': 'open:awaiting-reply', + 'replied(active)': 'open:active', + 'replied(released)': 'close:completed', + cancelled: 'refuse:invalid-state', + 'network-released': 'close:completed', + timeout: 'close:timed-out', + failed: 'close:failed', + }, + active: { + initiate: 'refuse:session-busy', + respond: 'refuse:invalid-state', + cancel: 'open:cancelling', + 'replied(awaiting-reply)': 'refuse:invalid-state', + 'replied(active)': 'refuse:invalid-state', + 'replied(released)': 'refuse:invalid-state', + cancelled: 'refuse:invalid-state', + 'network-released': 'close:completed', + timeout: 'close:timed-out', + failed: 'close:failed', + }, + 'awaiting-reply': { + initiate: 'refuse:session-busy', + respond: 'open:responding', + cancel: 'open:cancelling', + 'replied(awaiting-reply)': 'refuse:invalid-state', + 'replied(active)': 'refuse:invalid-state', + 'replied(released)': 'refuse:invalid-state', + cancelled: 'refuse:invalid-state', + 'network-released': 'close:completed', + timeout: 'close:timed-out', + failed: 'close:failed', + }, + responding: { + initiate: 'refuse:session-busy', + respond: 'refuse:invalid-state', + cancel: 'open:cancelling', + 'replied(awaiting-reply)': 'open:awaiting-reply', + 'replied(active)': 'open:active', + 'replied(released)': 'close:completed', + cancelled: 'refuse:invalid-state', + 'network-released': 'close:completed', + timeout: 'close:timed-out', + failed: 'close:failed', + }, + cancelling: { + initiate: 'refuse:session-busy', + respond: 'refuse:invalid-state', + cancel: 'refuse:invalid-state', + 'replied(awaiting-reply)': 'refuse:invalid-state', + 'replied(active)': 'refuse:invalid-state', + 'replied(released)': 'refuse:invalid-state', + cancelled: 'close:cancelled', + 'network-released': 'close:cancelled', + timeout: 'close:timed-out', + failed: 'close:failed', + }, + closed: { + initiate: 'refuse:no-session', + respond: 'refuse:no-session', + cancel: 'refuse:no-session', + 'replied(awaiting-reply)': 'refuse:no-session', + 'replied(active)': 'refuse:no-session', + 'replied(released)': 'refuse:no-session', + cancelled: 'refuse:no-session', + 'network-released': 'refuse:no-session', + timeout: 'refuse:no-session', + failed: 'refuse:no-session', + }, +}; + +/** + * Reach a state by DRIVING the machine from idle rather than casting a literal, + * so every state in the table is proven reachable through legal events. A cast + * would let an unreachable state sit in the table looking covered. + */ +function drive(events: readonly UssdSessionEvent[]): UssdSessionSnapshot { + let snapshot = IDLE_SESSION; + for (const event of events) { + const transition = reduceUssdSession(snapshot, event); + if (!transition.ok) { + throw new Error(`could not reach the state: ${event.kind} was refused`); + } + snapshot = transition.snapshot; + } + return snapshot; +} + +const PATHS: Record = { + idle: [], + initiating: [EVENTS.initiate], + active: [EVENTS.initiate, EVENTS['replied(active)']], + 'awaiting-reply': [EVENTS.initiate, EVENTS['replied(awaiting-reply)']], + responding: [EVENTS.initiate, EVENTS['replied(awaiting-reply)'], EVENTS.respond], + cancelling: [EVENTS.initiate, EVENTS.cancel], + closed: [EVENTS.initiate, EVENTS.timeout], +}; + +function describeTransition(transition: ReturnType): Expected { + if (!transition.ok) { + return `refuse:${transition.refusal}`; + } + const { state, outcome } = transition.snapshot; + return state === 'closed' ? `close:${outcome}` : `open:${state}`; +} + +describe('the USSD session transition table', () => { + for (const state of USSD_SESSION_STATES) { + const start = drive(PATHS[state]); + expect(start.state).toBe(state); + + for (const eventName of Object.keys(EVENTS) as EventName[]) { + const expected = TABLE[state][eventName]; + test(`${state} + ${eventName} -> ${expected}`, () => { + const transition = reduceUssdSession(start, EVENTS[eventName]); + expect(describeTransition(transition)).toBe(expected); + }); + } + } +}); + +test('the table covers every state and every event, and nothing more', () => { + expect(Object.keys(TABLE).sort()).toEqual([...USSD_SESSION_STATES].sort()); + const eventNames = Object.keys(EVENTS).sort(); + for (const state of USSD_SESSION_STATES) { + expect(Object.keys(TABLE[state]).sort()).toEqual(eventNames); + } +}); + +test('a refused verb leaves the machine untouched', () => { + const awaiting = drive(PATHS['awaiting-reply']); + const transition = reduceUssdSession(awaiting, EVENTS.initiate); + expect(transition.ok).toBe(false); + // The caller still holds the snapshot it passed in; nothing here mutates it. + expect(awaiting.state).toBe('awaiting-reply'); +}); + +test('a failed session carries its refusal onto the terminal state', () => { + const transition = reduceUssdSession(drive(PATHS.initiating), { + kind: 'failed', + reason: 'lte-only-unsupported', + }); + expect(transition.ok).toBe(true); + if (!transition.ok) return; + expect(transition.snapshot).toEqual({ + state: 'closed', + outcome: 'failed', + refusal: 'lte-only-unsupported', + }); +}); + +test('a completed session carries no refusal', () => { + const transition = reduceUssdSession(drive(PATHS.responding), EVENTS['replied(released)']); + expect(transition.ok).toBe(true); + if (!transition.ok) return; + expect(transition.snapshot.refusal).toBeUndefined(); + expect(transition.snapshot.outcome).toBe('completed'); +}); + +test('a session is open in every state but idle and closed', () => { + for (const state of USSD_SESSION_STATES) { + const expected = state !== 'idle' && state !== 'closed'; + expect(isUssdSessionOpen(drive(PATHS[state]))).toBe(expected); + } +}); diff --git a/control/src/ussd/session.ts b/control/src/ussd/session.ts new file mode 100644 index 0000000..fc79c2c --- /dev/null +++ b/control/src/ussd/session.ts @@ -0,0 +1,228 @@ +// The USSD session state machine — pure, total, and the only place a session's +// legality is decided. +// +// USSD is a SESSION protocol, not a request/response one: `Initiate` opens a +// dialogue the network may keep open pending a `Respond`, and a session that is +// neither responded to nor cancelled stays open on the NETWORK side, consuming a +// scarce per-subscriber slot and blocking the next `Initiate` with a busy error. +// So "which verb is legal right now" is a real question with a real wrong answer, +// and answering it inside the D-Bus adapter would make it untestable without a +// bus. It lives here instead, as data. +// +// Everything the machine can be told is an EVENT and every answer is a +// TRANSITION — an illegal verb is REFUSED with a typed reason, never thrown and +// never silently ignored. A refusal at an RPC boundary must name what the caller +// can do about it; a throw becomes an opaque failure and a silent no-op becomes a +// UI that spins forever. +// +// The machine carries NO carrier text. The reply an operator sees is threaded by +// the adapter and redacted at every log boundary (`../redact`); keeping it out of +// the state entirely means a state snapshot can never leak one. + +import type { UssdRefusalReason } from './refusal'; + +/** + * Session states. + * + * Three of them (`idle`, `active`, `awaiting-reply`) mirror MM's own + * `MMModem3gppUssdSessionState`; the rest are LOCAL in-flight states, because MM + * has no state for "we dispatched a call and the reply has not landed". Without + * them a second `initiate` racing the first would be judged against `idle` and + * allowed through, which is exactly the double-open the network answers busy. + */ +export const USSD_SESSION_STATES = [ + /** No session. MM `IDLE`. */ + 'idle', + /** `Initiate` dispatched, reply outstanding. Local. */ + 'initiating', + /** Network answered and the session is open with nothing pending. MM `ACTIVE`. */ + 'active', + /** Network asked a question; a `Respond` is required. MM `USER_RESPONSE`. */ + 'awaiting-reply', + /** `Respond` dispatched, reply outstanding. Local. */ + 'responding', + /** `Cancel` dispatched, confirmation outstanding. Local. */ + 'cancelling', + /** Terminal for this session object. A new session starts from a new machine. */ + 'closed', +] as const; +export type UssdSessionState = (typeof USSD_SESSION_STATES)[number]; + +/** How a session that reached `closed` got there. */ +export const USSD_SESSION_OUTCOMES = [ + /** The network completed the dialogue and released the session. */ + 'completed', + /** The operator cancelled it. */ + 'cancelled', + /** No answer within the bound; the machine closed it locally. */ + 'timed-out', + /** The network or the modem refused. `refusal` names which. */ + 'failed', +] as const; +export type UssdSessionOutcome = (typeof USSD_SESSION_OUTCOMES)[number]; + +/** MM's post-call session state, decoded. */ +export type UssdRepliedState = 'awaiting-reply' | 'active' | 'released'; + +export type UssdSessionEvent = + /** The operator asked to open a session. */ + | { readonly kind: 'initiate' } + /** The operator answered a network prompt. */ + | { readonly kind: 'respond' } + /** The operator asked to close the session. */ + | { readonly kind: 'cancel' } + /** + * The network answered an `Initiate`/`Respond`. `sessionState` is MM's own + * post-call `Modem3gpp.Ussd.State`, decoded: the network either wants an + * answer, is holding the session open with nothing pending, or released it. + */ + | { readonly kind: 'replied'; readonly sessionState: UssdRepliedState } + /** A `Cancel` was confirmed by the modem. */ + | { readonly kind: 'cancelled' } + /** The network released the session without our asking (notification path). */ + | { readonly kind: 'network-released' } + /** The bounded wait elapsed with no answer. */ + | { readonly kind: 'timeout' } + /** The call failed. The reason is carried onto the terminal state verbatim. */ + | { readonly kind: 'failed'; readonly reason: UssdRefusalReason }; + +export interface UssdSessionSnapshot { + readonly state: UssdSessionState; + /** Present only at `closed`. */ + readonly outcome?: UssdSessionOutcome; + /** Present only at `closed` with outcome `failed`. */ + readonly refusal?: UssdRefusalReason; +} + +export type UssdTransition = + | { readonly ok: true; readonly snapshot: UssdSessionSnapshot } + /** The verb is not legal in this state, and the machine did NOT move. */ + | { readonly ok: false; readonly refusal: UssdRefusalReason }; + +export const IDLE_SESSION: UssdSessionSnapshot = { state: 'idle' }; + +/** States in which an operator verb may be dispatched at all. */ +const ACCEPTS_INITIATE: ReadonlySet = new Set(['idle']); +const ACCEPTS_RESPOND: ReadonlySet = new Set([ + 'awaiting-reply', +]); +const ACCEPTS_CANCEL: ReadonlySet = new Set([ + 'initiating', + 'active', + 'awaiting-reply', + 'responding', +]); + +/** States with a call in flight — the only ones a network answer may land on. */ +const IN_FLIGHT: ReadonlySet = new Set([ + 'initiating', + 'responding', +]); + +function open(state: UssdSessionState): UssdTransition { + return { ok: true, snapshot: { state } }; +} + +function close(outcome: UssdSessionOutcome, refusal?: UssdRefusalReason): UssdTransition { + return { + ok: true, + snapshot: { state: 'closed', outcome, ...(refusal === undefined ? {} : { refusal }) }, + }; +} + +function refuse(refusal: UssdRefusalReason): UssdTransition { + return { ok: false, refusal }; +} + +/** + * Apply one event. TOTAL: every (state, event) pair has an answer, and an answer + * is either a new snapshot or a typed refusal that leaves the machine untouched. + * + * A `closed` machine accepts NOTHING — not even another `cancel`. Re-opening a + * terminal session would hide the fact that the previous one ended, and the + * cost of a fresh machine is one object. + */ +export function reduceUssdSession( + snapshot: UssdSessionSnapshot, + event: UssdSessionEvent, +): UssdTransition { + const state = snapshot.state; + if (state === 'closed') { + return refuse('no-session'); + } + + // Every event except `initiate` describes something happening TO a session, + // and an idle machine has none for them to happen to. + const sessionOpen = state !== 'idle'; + + switch (event.kind) { + case 'initiate': + // A session already in flight or open is the busy case the network + // itself would answer — refused locally so no second dialogue is opened. + return ACCEPTS_INITIATE.has(state) ? open('initiating') : refuse('session-busy'); + + case 'respond': + // Responding to a session that never asked a question is not a busy + // device; it is the wrong verb, and `invalid-state` says so. + return ACCEPTS_RESPOND.has(state) ? open('responding') : refuse('invalid-state'); + + case 'cancel': + // The two refusals are different operator facts: nothing to close, vs a + // cancel that is already in flight. + if (ACCEPTS_CANCEL.has(state)) { + return open('cancelling'); + } + return refuse(sessionOpen ? 'invalid-state' : 'no-session'); + + case 'replied': + // A network answer that lands on a state with no call in flight is + // evidence of a lost reply or a duplicate; it is refused rather than + // used to resurrect a session the machine already moved past. + if (!IN_FLIGHT.has(state)) { + return refuse('invalid-state'); + } + // The three-way answer is the dialogue-vs-one-shot distinction, and + // collapsing `active` into `released` is what would leave a session MM + // still considers open dangling on the network side. + if (event.sessionState === 'released') { + return close('completed'); + } + return open(event.sessionState); + + case 'cancelled': + // Only a machine that asked to cancel may be closed by one, so a stray + // confirmation cannot tear down a live dialogue. + return state === 'cancelling' ? close('cancelled') : refuse('invalid-state'); + + case 'network-released': + // The network is authoritative about its own session, so this lands + // from anywhere the session is still open — including mid-call, where + // it is the honest end of a dialogue whose reply will not come. During + // a cancel it is reported as `cancelled`: the operator asked for the + // session to end and it ended. + if (!sessionOpen) { + return refuse('no-session'); + } + return close(state === 'cancelling' ? 'cancelled' : 'completed'); + + case 'timeout': + // The bound closes rather than reverting: after an unanswered call the + // network's own view is unknown, and pretending we are back at `idle` + // would let the next `initiate` walk into a busy error with no + // explanation. + return sessionOpen ? close('timed-out') : refuse('no-session'); + + case 'failed': + return sessionOpen ? close('failed', event.reason) : refuse('no-session'); + + default: { + const unreachable: never = event; + return unreachable; + } + } +} + +/** True while the session still holds a network dialogue open. */ +export function isUssdSessionOpen(snapshot: UssdSessionSnapshot): boolean { + return snapshot.state !== 'idle' && snapshot.state !== 'closed'; +} diff --git a/control/test-support/README.md b/control/test-support/README.md index 59294e9..e2d6c23 100644 --- a/control/test-support/README.md +++ b/control/test-support/README.md @@ -2,7 +2,14 @@ The doubles the A3.x ModemManager D-Bus backend and A4.1 NetworkManager adapter are tested against. This lives outside `control/src`, so it is **not** published in the -`@ceralive/modem-control` npm package (`files: ["src"]`) — it is test-only. +`@ceralive/modem-control` npm package (`files: ["dist"]`, and the build only emits from +`src`) — it is test-only. + +**This is not the package's contract-fakes surface.** What a CONSUMER should reuse is +the published `@ceralive/modem-control/testing` entry (`control/src/testing/`): pure +data and functions, no bus, no process. Everything here needs a private session bus or +a stateful `nmcli` state machine and is deliberately unpublished — do not promote it to +a public subpath, and do not have a consumer reach into it by path. ## `fake-mm/` — a scriptable, MM-faithful `org.freedesktop.ModemManager1` @@ -38,9 +45,38 @@ A real ModemManager object model served on a private session bus (built on the s reflects exactly what was written — **real readback**, no canned strings. - `FakeNetworkManagerPort` fulfils the `NetworkManagerPort` contract over that runner. -Neither is the shipping adapter. A4.1's `NmcliNmPort` owns the full nine-field GSM write -parity and the atomic Auto-APN transitions; this harness is what A4.1 injects to assert -them. +Neither is the shipping adapter. `NmcliNmPort` (`control/src/backend/nmcli-nm-port.ts`) owns +the full nine-field GSM write parity and the atomic Auto-APN transitions; this harness is +what its suite injects to assert them. `NetworkManagerAdapter` +(`control/src/providers/network-manager/`) sits one level up on the same harness — the +runner's real readback is what lets its tests tell "the desired slot echoed the request" +apart from "the desired slot was seeded from the readback". + +## `conformance/` — the provider-matching matrix harness + +The corpus and harness behind `control/src/providers/conformance-{matrix,transcripts,scale}.test.ts`. +It is the only place all four real providers (ModemManager, Huawei HiLink, ZTE goform, +UFI/HIMI) are registered at once, so it is where a provider claiming a neighbour's +hardware is visible at all. + +- `exchange.ts` — ONE normalized `RecordedExchange` shape for three transports that agree + about nothing (HiLink posts XML to a path, goform posts a form and carries its verb in + `goformId`, HIMI posts JSON to one endpoint and carries its verb in `cmdid`). Bodies are + decoded into the vendor's own encoding rather than flattened to a string, headers are kept + verbatim **in order**, and the cookie is lifted out because that is where the three diverge. +- `corpus.ts` — the sanitized per-firmware documents. It **reuses** `observation-fixtures.ts` + for every telemetry payload; what is new is only the session / login / challenge documents + those fixtures do not carry. Devices are ROUTE TABLES, not ordered reply queues, so a case + cannot break just because another provider's probe interleaved. +- `transcripts.ts` — expected transcripts rebuilt **from the protocol**, so a `toEqual` + against a recording is a comparison and not an echo. +- `cases.ts` — the 20-case table with each case's entitled decision. +- `mm-transport.ts` — an in-memory `DbusTransport` over the SAME `fake-mm/object-model.ts` + tree. `fake-mm/service.ts` remains the right harness for codec and epoch proof (only a real + bus proves those); this one exists because a matrix whose ModemManager rows SKIP wherever + there is no session bus is a matrix with holes in it, and it makes the 16-modem scale + fixture's resource counts deterministic. +- `matrix-report.ts` — writes the summary artifact to the gitignored `test-results/`. ## Running diff --git a/control/test-support/conformance/cases.ts b/control/test-support/conformance/cases.ts new file mode 100644 index 0000000..ad7d1ee --- /dev/null +++ b/control/test-support/conformance/cases.ts @@ -0,0 +1,633 @@ +// The conformance case table — every fleet profile and every safety case, run through +// the REAL matcher with all four real providers registered at once. +// +// Each provider suite (todos 22/24/25/26) proves its own provider in isolation, with +// only that provider in the registry. That is the right shape for a provider suite and +// it structurally cannot answer the question this table exists for: does a Huawei dongle +// stay a Huawei dongle when a ZTE provider and a UFI provider are also asking? Every +// case below registers ALL FOUR and scripts the three the device is not as devices that +// answer nothing this vendor understands — which is what a real board looks like. +// +// The expectation is the exact decision, not a shape: provider, profile, writable and +// evidence score. A case that is "close enough" is a case that would not notice a +// provider quietly claiming a neighbour's hardware. + +import { + type DeviceGeneration, + deviceGeneration, + type PhysicalModemId, + physicalModemId, +} from '../../src/domain'; +import type { ResourceOwnershipPort } from '../../src/ports/resource-ownership'; +import { + createProviderMatcher, + createProviderRegistry, + type MatcherScore, + type PassiveFact, + type ProviderMatchRequest, + type ProviderMatchResult, + type ProviderTransport, +} from '../../src/providers'; +import { createHuaweiHiLinkDefinition } from '../../src/providers/huawei-hilink'; +import { createModemManagerProvider } from '../../src/providers/modem-manager'; +import { createUfiHimiDefinition } from '../../src/providers/ufi-himi'; +import { createZteGoformDefinition } from '../../src/providers/zte-goform'; +import type { ModemSpec } from '../fake-mm/object-model'; +import { + CONFORMANCE_CREDENTIALS, + HILINK_ADMIN_URL, + HILINK_FIRMWARE, + HILINK_PRIMARY_INTERFACE, + HILINK_TWIN_INTERFACE, + hilinkDevice, + interfaceRoutedDevice, + UFI_ADMIN_URL, + UFI_INTERFACE, + USB_IDS, + ufiDevice, + ZTE_ADMIN_URL, + ZTE_INTERFACE, + zteDevice, +} from './corpus'; +import { + absentDevice, + type RecordedExchange, + recordHilink, + recordUfi, + recordZte, + type ScriptedDevice, +} from './exchange'; +import { FakeMmTransport, type RecordedCall } from './mm-transport'; + +/** Ownership is not what a matcher exercises; an always-granting port keeps it out. */ +const grantingOwnership: ResourceOwnershipPort = { + acquire: async () => ({ + status: 'acquired', + lease: { + holder: { pid: 1, startedAtEpochMs: 1 }, + lost: new Promise(() => {}), + release: async () => {}, + }, + }), +}; + +export type ScenarioOptions = { + readonly hilink?: ScriptedDevice; + readonly hilinkInterface?: string; + /** A SECOND write-capable HiLink definition with an identical passive fingerprint. */ + readonly hilinkTwin?: ScriptedDevice; + readonly zte?: ScriptedDevice; + readonly ufi?: ScriptedDevice; + readonly mmModems?: readonly ModemSpec[]; +}; + +export type ScenarioTranscripts = { + readonly hilink: readonly RecordedExchange[]; + readonly hilinkTwin: readonly RecordedExchange[]; + readonly zte: readonly RecordedExchange[]; + readonly ufi: readonly RecordedExchange[]; +}; + +export type ConformanceRun = { + readonly result: ProviderMatchResult; + readonly transcripts: ScenarioTranscripts; + /** Authentication attempts made by the COLLIDING twin — must stay 0 on a tie. */ + readonly twinAuthAttempts: number; + readonly mmCalls: readonly RecordedCall[]; +}; + +async function runScenario( + options: ScenarioOptions, + request: ProviderMatchRequest, +): Promise { + const hilinkInterface = options.hilinkInterface ?? HILINK_PRIMARY_INTERFACE; + const hilink = recordHilink(options.hilink ?? absentDevice); + const hilinkTwin = recordHilink(options.hilinkTwin ?? absentDevice); + const zte = recordZte(options.zte ?? absentDevice); + const ufi = recordUfi(options.ufi ?? absentDevice); + const mm = new FakeMmTransport({ ...(options.mmModems ? { modems: options.mmModems } : {}) }); + const mmProvider = createModemManagerProvider({ transport: mm }); + + const registry = createProviderRegistry(); + registry.register(mmProvider.definition); + registry.register( + createHuaweiHiLinkDefinition({ + interfaceName: hilinkInterface, + adminUrl: HILINK_ADMIN_URL, + transport: hilink.transport, + ownership: grantingOwnership, + credentials: CONFORMANCE_CREDENTIALS, + }), + ); + let twinAuthAttempts = 0; + if (options.hilinkTwin !== undefined) { + const twin = createHuaweiHiLinkDefinition({ + interfaceName: hilinkInterface, + adminUrl: HILINK_ADMIN_URL, + transport: hilinkTwin.transport, + ownership: grantingOwnership, + credentials: CONFORMANCE_CREDENTIALS, + }); + const authenticatedProfile = twin.authenticatedProfile; + registry.register({ + ...twin, + id: 'huawei-hilink-twin', + ...(authenticatedProfile === undefined + ? {} + : { + authenticatedProfile: { + ...authenticatedProfile, + authenticate: async (context, candidates) => { + twinAuthAttempts += 1; + return authenticatedProfile.authenticate(context, candidates); + }, + }, + }), + }); + } + registry.register( + createZteGoformDefinition({ + interfaceName: ZTE_INTERFACE, + adminUrl: ZTE_ADMIN_URL, + transport: zte.transport, + credentials: CONFORMANCE_CREDENTIALS, + }), + ); + registry.register( + createUfiHimiDefinition({ + interfaceName: UFI_INTERFACE, + adminUrl: UFI_ADMIN_URL, + transport: ufi.transport, + credentials: CONFORMANCE_CREDENTIALS, + now: () => 1_700_000_000_000, + }), + ); + + try { + const result = await createProviderMatcher(registry).match(request); + return { + result, + transcripts: { + hilink: hilink.exchanges, + hilinkTwin: hilinkTwin.exchanges, + zte: zte.exchanges, + ufi: ufi.exchanges, + }, + twinAuthAttempts, + mmCalls: mm.calls, + }; + } finally { + await mmProvider.stop(); + } +} + +const GENERATION: DeviceGeneration = deviceGeneration(1); + +function request(options: { + readonly id: string; + readonly transport?: ProviderTransport; + readonly facts?: readonly PassiveFact[]; + readonly firmware?: string; + readonly composition?: string; +}): ProviderMatchRequest { + const id: PhysicalModemId = physicalModemId(options.id); + return { + physicalModemId: id, + generation: GENERATION, + transport: options.transport ?? 'network', + passiveFacts: options.facts ?? [], + composition: options.composition ?? 'rndis', + ...(options.firmware === undefined ? {} : { firmware: options.firmware }), + }; +} + +const usbFact = (value: string): PassiveFact => ({ kind: 'usb', value }); +const firmwareFact = (value: string): PassiveFact => ({ kind: 'firmware', value }); + +// ── the fleet's ModemManager-managed specs ────────────────────────────────────────── + +/** Bench Quectel RM530N-GL. `DeviceIdentifier` is what the identity ladder keys on. */ +export const QUECTEL_SPEC: ModemSpec = { + index: 1, + manufacturer: 'Quectel', + model: 'RM530N-GL', + revision: 'RM530NGLAAR11A02M4G', + supportedModes: [[7, 0]], + currentModes: [7, 0], + supportedBands: [33, 378], + sims: [{ index: 1, iccid: '8900000000000000001', imsi: '001010000000001', active: true }], +}; + +/** Bench SIMCom SIM7600G-H. */ +export const SIMCOM_SPEC: ModemSpec = { + index: 2, + manufacturer: 'SIMCom', + model: 'SIM7600G-H', + revision: 'LE20B04SIM7600G22', + supportedModes: [[4, 0]], + currentModes: [4, 0], + sims: [{ index: 2, iccid: '8900000000000000002', imsi: '001010000000002', active: true }], +}; + +/** + * Fibocom FM350-GL as the bench sees it: on an M.2→USB carrier, enumerated and managed + * by ModemManager. `docs/FM350-DECISION.md` keeps its `0e8d:7127` carrier identity out + * of the USB composition catalog; that is a MODE-CONTROL exclusion, and it says nothing + * about matching — the modem is still an MM-managed modem and must still be selected. + */ +export const FM350_USB_SPEC: ModemSpec = { + index: 4, + manufacturer: 'Fibocom', + model: 'FM350-GL', + revision: '81600.0000.00.19.17.10', + supportedModes: [[15, 0]], + currentModes: [15, 0], + location: { capabilities: 4 }, + sims: [{ index: 4, iccid: '8900000000000000004', imsi: '001010000000004', active: true }], +}; + +// ── case table ────────────────────────────────────────────────────────────────────── + +export type ConformanceKind = + | 'fleet-profile' + | 'ambiguity' + | 'malformed' + | 'auth-expired' + | 'lockout-unknown' + | 'unknown-firmware' + | 'wrong-interface' + | 'wrong-transport'; + +export type ConformanceExpectation = { + readonly status: ProviderMatchResult['status']; + readonly provider: string | null; + readonly profile: string | null; + readonly writable: boolean; + readonly score: MatcherScore; +}; + +export type ConformanceCase = { + readonly id: string; + readonly kind: ConformanceKind; + readonly summary: string; + readonly expected: ConformanceExpectation; + readonly run: () => Promise; +}; + +const HILINK_A: keyof typeof HILINK_FIRMWARE = 'e3372h-22.200-password-type-3'; +const HILINK_B: keyof typeof HILINK_FIRMWARE = 'e3372h-22.333-password-type-4'; + +const selected = ( + provider: string, + profile: string, + writable: boolean, +): ConformanceExpectation => ({ + status: 'selected', + provider, + profile, + writable, + score: 'supported', +}); + +const unresolved = ( + status: 'ambiguous' | 'unsupported', + score: MatcherScore, +): ConformanceExpectation => ({ status, provider: null, profile: null, writable: false, score }); + +export const CONFORMANCE_CASES: readonly ConformanceCase[] = [ + { + id: 'fleet/mm-quectel-rm530n', + kind: 'fleet-profile', + summary: 'MM-managed Quectel RM530N-GL selects the generic runtime profile', + expected: selected('modemmanager', 'generic-mm', true), + run: () => + runScenario( + { mmModems: [QUECTEL_SPEC] }, + request({ + id: 'serial:fake-device-1', + transport: 'modemmanager', + composition: 'quectel-rmnet', + }), + ), + }, + { + id: 'fleet/mm-simcom-sim7600', + kind: 'fleet-profile', + summary: 'MM-managed SIMCom SIM7600G-H selects the generic runtime profile', + expected: selected('modemmanager', 'generic-mm', true), + run: () => + runScenario( + { mmModems: [SIMCOM_SPEC] }, + request({ + id: 'serial:fake-device-2', + transport: 'modemmanager', + composition: 'simcom-ecm', + }), + ), + }, + { + id: 'fleet/mm-fm350-usb', + kind: 'fleet-profile', + summary: 'FM350-GL on an M.2→USB carrier stays an MM-managed modem', + expected: selected('modemmanager', 'generic-mm', true), + run: () => + runScenario( + { mmModems: [FM350_USB_SPEC] }, + request({ + id: 'serial:fake-device-4', + transport: 'modemmanager', + composition: 'fm350-usb-carrier', + }), + ), + }, + { + id: 'fleet/huawei-e3372h-22.200', + kind: 'fleet-profile', + summary: 'HiLink firmware 22.200 selects the password-type-3 profile', + expected: selected('huawei-hilink', HILINK_A, true), + run: () => + runScenario( + { hilink: hilinkDevice({ profileId: HILINK_A }) }, + request({ + id: 'serial:conformance-hilink-a', + facts: [usbFact(USB_IDS.huaweiE3372h), firmwareFact(HILINK_FIRMWARE[HILINK_A])], + firmware: HILINK_FIRMWARE[HILINK_A], + }), + ), + }, + { + id: 'fleet/huawei-e3372h-22.333', + kind: 'fleet-profile', + summary: 'HiLink firmware 22.333 selects the password-type-4 profile', + expected: selected('huawei-hilink', HILINK_B, true), + run: () => + runScenario( + { hilink: hilinkDevice({ profileId: HILINK_B }) }, + request({ + id: 'serial:conformance-hilink-b', + facts: [usbFact(USB_IDS.huaweiE3372h), firmwareFact(HILINK_FIRMWARE[HILINK_B])], + firmware: HILINK_FIRMWARE[HILINK_B], + }), + ), + }, + { + 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), + run: () => + runScenario( + { zte: zteDevice({ firmware: 'MF79U' }) }, + request({ + id: 'serial:conformance-zte-mf79u', + facts: [usbFact(USB_IDS.zteMf79u), firmwareFact('MF79U')], + firmware: 'MF79U', + }), + ), + }, + { + id: 'fleet/zte-mf266', + kind: 'fleet-profile', + summary: 'MF266 selects the salted SHA-256 profile and stays read-only', + expected: selected('zte-goform', 'mf266-salted', false), + run: () => + runScenario( + { zte: zteDevice({ firmware: 'MF266' }) }, + request({ + id: 'serial:conformance-zte-mf266', + facts: [usbFact(USB_IDS.zteMf266), firmwareFact('MF266')], + firmware: 'MF266', + }), + ), + }, + { + id: 'fleet/ufi-himi-9024', + kind: 'fleet-profile', + summary: '05c6:9024 (RNDIS+ADB composition) selects the read-only HIMI profile', + expected: selected('ufi-himi', 'ufi-himi-read-only', false), + run: () => + runScenario( + { ufi: ufiDevice() }, + request({ + id: 'serial:conformance-ufi-9024', + facts: [usbFact(USB_IDS.ufiRndisAdb)], + }), + ), + }, + { + id: 'fleet/ufi-himi-9091', + kind: 'fleet-profile', + summary: '05c6:9091 (firmware-specific, NOT DIAG evidence) selects the same read-only profile', + expected: selected('ufi-himi', 'ufi-himi-read-only', false), + run: () => + runScenario( + { ufi: ufiDevice() }, + request({ + id: 'serial:conformance-ufi-9091', + facts: [usbFact(USB_IDS.ufiFirmwareSpecific)], + }), + ), + }, + { + id: 'ambiguity/colliding-write-capable-twins', + kind: 'ambiguity', + summary: 'two write-capable providers with one fingerprint tie into read-only ambiguity', + expected: unresolved('ambiguous', 'supported'), + run: () => + runScenario( + { + hilink: hilinkDevice({ profileId: HILINK_A }), + hilinkTwin: hilinkDevice({ profileId: HILINK_A }), + }, + request({ + id: 'serial:conformance-collision', + facts: [usbFact(USB_IDS.huaweiE3372h), firmwareFact(HILINK_FIRMWARE[HILINK_A])], + firmware: HILINK_FIRMWARE[HILINK_A], + }), + ), + }, + { + id: 'ambiguity/zte-cross-profile-refusal', + kind: 'ambiguity', + summary: 'MF266-shaped answers to an MF79U login are refused, never re-tried salted', + expected: unresolved('ambiguous', 'supported'), + run: () => + runScenario( + { zte: zteDevice({ firmware: 'MF79U', login: 'salted-shape' }) }, + request({ + id: 'serial:conformance-zte-crossprofile', + facts: [usbFact(USB_IDS.zteMf79u), firmwareFact('MF79U')], + firmware: 'MF79U', + }), + ), + }, + { + id: 'malformed/hilink-session-document', + kind: 'malformed', + summary: 'an unparseable SesTokInfo refuses the profile instead of guessing one', + expected: unresolved('ambiguous', 'supported'), + run: () => + runScenario( + { hilink: hilinkDevice({ profileId: HILINK_A, session: 'malformed' }) }, + request({ + id: 'serial:conformance-hilink-malformed', + facts: [usbFact(USB_IDS.huaweiE3372h), firmwareFact(HILINK_FIRMWARE[HILINK_A])], + firmware: HILINK_FIRMWARE[HILINK_A], + }), + ), + }, + { + 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), + run: () => + runScenario( + { + zte: zteDevice({ + firmware: 'MF79U', + fingerprint: 'malformed', + telemetry: 'malformed', + }), + }, + request({ + id: 'serial:conformance-zte-malformed', + facts: [usbFact(USB_IDS.zteMf79u), firmwareFact('MF79U')], + firmware: 'MF79U', + }), + ), + }, + { + id: 'malformed/ufi-himi-body', + kind: 'malformed', + summary: 'a non-JSON HIMI body keeps the USB-proven profile read-only and records the conflict', + expected: selected('ufi-himi', 'ufi-himi-read-only', false), + run: () => + runScenario( + { ufi: ufiDevice({ telemetry: 'malformed' }) }, + request({ + id: 'serial:conformance-ufi-malformed', + facts: [usbFact(USB_IDS.ufiRndisAdb)], + }), + ), + }, + { + id: 'auth-expired/hilink-mid-login', + kind: 'auth-expired', + summary: 'HiLink error 125002 during login refuses without a second credential attempt', + expected: unresolved('ambiguous', 'supported'), + run: () => + runScenario( + { hilink: hilinkDevice({ profileId: HILINK_A, login: 'auth-expired' }) }, + request({ + id: 'serial:conformance-hilink-expired', + facts: [usbFact(USB_IDS.huaweiE3372h), firmwareFact(HILINK_FIRMWARE[HILINK_A])], + firmware: HILINK_FIRMWARE[HILINK_A], + }), + ), + }, + { + id: 'lockout-unknown/zte-mf79u', + kind: 'lockout-unknown', + summary: 'a locked-out MF79U is refused identically to a rejection — one attempt, no guess', + expected: unresolved('ambiguous', 'supported'), + run: () => + runScenario( + { zte: zteDevice({ firmware: 'MF79U', login: 'lockout-unknown' }) }, + request({ + id: 'serial:conformance-zte-lockout', + facts: [usbFact(USB_IDS.zteMf79u), firmwareFact('MF79U')], + firmware: 'MF79U', + }), + ), + }, + { + id: 'unknown-firmware/zte-read-only', + kind: 'unknown-firmware', + summary: 'unknown ZTE firmware fingerprints into the read-only profile, never an auth guess', + expected: selected('zte-goform', 'zte-unknown-read-only', false), + run: () => + runScenario( + { zte: zteDevice({ firmware: 'unknown' }) }, + request({ + id: 'serial:conformance-zte-unknown', + facts: [usbFact(USB_IDS.zteMf79u), firmwareFact('ZTE-UNLISTED-BUILD')], + firmware: 'ZTE-UNLISTED-BUILD', + }), + ), + }, + { + id: 'unknown-firmware/hilink-unlisted', + kind: 'unknown-firmware', + summary: 'an unlisted HiLink firmware is unsupported — no profile is inferred from the shape', + expected: unresolved('unsupported', 'unsupported'), + run: () => + runScenario( + { hilink: hilinkDevice({ profileId: HILINK_A }) }, + request({ + id: 'serial:conformance-hilink-unlisted', + facts: [usbFact(USB_IDS.huaweiE3372h), firmwareFact('22.999.99.00.0000')], + firmware: '22.999.99.00.0000', + }), + ), + }, + { + id: 'wrong-interface/hilink-duplicate-ip-twin', + kind: 'wrong-interface', + summary: 'binding to the wrong twin reaches the other dongle and refuses before login', + expected: unresolved('ambiguous', 'supported'), + run: () => + runScenario( + { + hilinkInterface: HILINK_TWIN_INTERFACE, + hilink: interfaceRoutedDevice( + { + [HILINK_PRIMARY_INTERFACE]: hilinkDevice({ profileId: HILINK_A }), + [HILINK_TWIN_INTERFACE]: hilinkDevice({ + profileId: HILINK_A, + reportedPasswordType: 4, + }), + }, + absentDevice, + ), + }, + request({ + id: 'serial:conformance-hilink-twin', + facts: [usbFact(USB_IDS.huaweiE3372h), firmwareFact(HILINK_FIRMWARE[HILINK_A])], + firmware: HILINK_FIRMWARE[HILINK_A], + }), + ), + }, + { + id: 'wrong-transport/usb-request-reaches-nobody', + kind: 'wrong-transport', + summary: 'a raw-USB request is refused by transport eligibility before any device is touched', + expected: unresolved('unsupported', 'unsupported'), + run: () => + runScenario( + { + hilink: hilinkDevice({ profileId: HILINK_A }), + zte: zteDevice({ firmware: 'MF79U' }), + ufi: ufiDevice(), + mmModems: [QUECTEL_SPEC], + }, + request({ + id: 'serial:conformance-wrong-transport', + transport: 'usb', + facts: [usbFact(USB_IDS.huaweiE3372h), firmwareFact(HILINK_FIRMWARE[HILINK_A])], + firmware: HILINK_FIRMWARE[HILINK_A], + }), + ), + }, +]; + +/** The observed decision, in the same shape the expectation is written in. */ +export function observedExpectation(result: ProviderMatchResult): ConformanceExpectation { + return { + status: result.status, + provider: result.provider, + profile: result.profile, + writable: result.writable, + score: result.score, + }; +} diff --git a/control/test-support/conformance/corpus.ts b/control/test-support/conformance/corpus.ts new file mode 100644 index 0000000..f9c0dc1 --- /dev/null +++ b/control/test-support/conformance/corpus.ts @@ -0,0 +1,427 @@ +// The sanitized per-firmware HTTP transcript corpus. +// +// REPO-LOCAL AND UNPUBLISHED, like everything else under `test-support/`: it is not in +// `control/src`, so `files: ["dist"]` cannot carry it to npm, and it is not the public +// `./testing` contract-fakes surface either. +// +// It REUSES the payload fixtures todos 18/24/25/26 already built rather than minting a +// second set that can drift from them: `HILINK_FIXTURE`, `ZTE_FIXTURE`, `UFI_FIXTURE` +// and the auth-expired/malformed variants all come from `observation-fixtures.ts`. What +// is new here is only what those fixtures do not carry — the session, login and +// challenge documents that sit BEFORE a telemetry read, and the exact request each one +// is answered by. +// +// SANITIZATION IS STRUCTURAL, not a review promise. Every credential is a declared +// literal in `CONFORMANCE_CREDENTIALS` that says in its own text that it is not real, +// and every subscriber-scale identifier anywhere in the corpus is a member of the frozen +// `SANITIZED_SUBSCRIBER_IDENTIFIERS` table with its provenance recorded. A digit run +// that is in the corpus and not in that table fails `conformance-matrix.test.ts`. + +import { createHash } from 'node:crypto'; +import { HILINK_PATHS } from '../../src/providers/huawei-hilink/provider'; +import { UFI_API_PATH } from '../../src/providers/ufi-himi/transport'; +import { ZTE_PATHS } from '../../src/providers/zte-goform/provider'; +import { + HILINK_AUTH_EXPIRED_FIXTURE, + HILINK_FIXTURE, + UFI_AUTH_EXPIRED_FIXTURE, + UFI_FIXTURE, + ZTE_FIXTURE, + ZTE_MALFORMED_FIXTURE, +} from '../observation-fixtures'; +import type { RecordedExchange, ScriptedDevice, ScriptedResponse } from './exchange'; +import { goformId, himiCommand, NOT_THIS_VENDOR } from './exchange'; + +// ── identity and credential sanitation ────────────────────────────────────────────── + +/** + * The ONLY credential any conformance device accepts. It says what it is in its own + * text so a grep over the corpus cannot mistake it for a captured secret, and it is + * deliberately NOT read from the environment — `MF79U_BENCH_PASSWORD` and + * `UFI_BENCH_PASSWORD` are ephemeral bench inputs for the supervised harnesses in + * todos 25/26 and have no business inside a committed fixture. + */ +export const CONFORMANCE_CREDENTIALS = { + username: 'admin', + password: 'conformance-fixture-password-not-a-real-secret', +} as const; + +/** + * Every subscriber-scale identifier the corpus contains, with its provenance. + * + * A conformance corpus is the one place a real capture is most likely to be pasted in + * unedited, so the rule is inverted: a digit run of 14+ characters is a FAILURE unless + * it is listed here. Each entry is synthetic and says where it came from. + */ +export const SANITIZED_SUBSCRIBER_IDENTIFIERS: readonly { + readonly value: string; + readonly kind: 'imsi' | 'iccid' | 'imei' | 'device-identifier'; + readonly provenance: string; +}[] = [ + { + value: '732123456789012', + kind: 'imsi', + provenance: + 'synthetic value already carried by UFI_FIXTURE (test-support/observation-fixtures.ts)', + }, + { + value: '8957010000000000001', + kind: 'iccid', + provenance: + 'synthetic value already carried by UFI_FIXTURE (test-support/observation-fixtures.ts)', + }, +]; + +// ── device addresses ──────────────────────────────────────────────────────────────── + +/** + * Two HiLink twins on the SAME address. That duplication is the point: a HiLink dongle + * hard-codes `192.168.8.1`, so a board with two of them can only tell them apart by the + * interface a request is bound to. + */ +export const HILINK_ADMIN_URL = 'http://192.168.8.1'; +export const ZTE_ADMIN_URL = 'http://192.168.0.1'; +export const UFI_ADMIN_URL = 'http://192.168.100.1'; + +export const HILINK_PRIMARY_INTERFACE = 'enx001a2b3c4d01'; +export const HILINK_TWIN_INTERFACE = 'enx001a2b3c4d02'; +export const ZTE_INTERFACE = 'enx001a2b3c4d03'; +export const UFI_INTERFACE = 'enx001a2b3c4d04'; + +/** USB composition ids the fleet enumerates. Vendor:product only — never a serial. */ +export const USB_IDS = { + huaweiE3372h: '12d1:14dc', + zteMf79u: '19d2:1405', + zteMf266: '19d2:1476', + ufiRndisAdb: '05c6:9024', + ufiFirmwareSpecific: '05c6:9091', +} as const; + +// ── Huawei HiLink documents ───────────────────────────────────────────────────────── + +export const HILINK_TOKENS = { + 'e3372h-22.200-password-type-3': 'conformance-hilink-token-22200', + 'e3372h-22.333-password-type-4': 'conformance-hilink-token-22333', +} as const; +export type HilinkProfileId = keyof typeof HILINK_TOKENS; + +export const HILINK_FIRMWARE = { + 'e3372h-22.200-password-type-3': '22.200.05.00.1080', + 'e3372h-22.333-password-type-4': '22.333.01.00.00', +} as const; + +const hilinkCookie = (token: string): string => `SessionID=${token}-cookie`; + +const hilinkSessionBody = (token: string): string => + `${hilinkCookie(token)}${token}`; + +/** A `SesTokInfo` reply with neither tag — `parseHilinkSession` answers `undefined`. */ +export const HILINK_SESSION_MALFORMED = '100002'; + +const hilinkStateBody = (passwordType: 3 | 4): string => + `-1${passwordType}`; + +export const HILINK_LOGIN_OK = 'OK'; +export const HILINK_LOGIN_AUTH_EXPIRED = '125002'; +export const HILINK_LOGIN_REJECTED = '108003'; +export const HILINK_DATA_ENABLED = '1'; + +/** The password each profile's login POST must carry, derived the way the wire does. */ +export function hilinkWirePassword(profileId: HilinkProfileId): string { + const { username, password } = CONFORMANCE_CREDENTIALS; + if (profileId === 'e3372h-22.200-password-type-3') { + return Buffer.from(password).toString('base64'); + } + const token = HILINK_TOKENS[profileId]; + const passwordHash = Buffer.from(createHash('sha256').update(password).digest('hex')).toString( + 'base64', + ); + const loginHash = createHash('sha256').update(`${username}${passwordHash}${token}`).digest('hex'); + return Buffer.from(loginHash).toString('base64'); +} + +export function hilinkLoginDocument(profileId: HilinkProfileId): string { + const passwordType = profileId === 'e3372h-22.200-password-type-3' ? 3 : 4; + return `${CONFORMANCE_CREDENTIALS.username}${hilinkWirePassword(profileId)}${passwordType}`; +} + +export type HilinkScript = { + readonly profileId: HilinkProfileId; + /** What `SesTokInfo` answers. */ + readonly session?: 'valid' | 'malformed' | 'unreachable'; + /** The password type `state-login` REPORTS — a twin reports the other one. */ + readonly reportedPasswordType?: 3 | 4; + readonly login?: 'ok' | 'auth-expired' | 'rejected'; + readonly telemetry?: 'reported' | 'auth-expired'; +}; + +export function hilinkDevice(script: HilinkScript): ScriptedDevice { + const token = HILINK_TOKENS[script.profileId]; + const reported = + script.reportedPasswordType ?? (script.profileId === 'e3372h-22.200-password-type-3' ? 3 : 4); + const telemetryRefused = script.telemetry === 'auth-expired'; + return (exchange: RecordedExchange): ScriptedResponse => { + switch (exchange.path) { + case HILINK_PATHS.session: + if (script.session === 'unreachable') return { status: 503, body: '' }; + return { + status: 200, + body: + script.session === 'malformed' ? HILINK_SESSION_MALFORMED : hilinkSessionBody(token), + }; + case HILINK_PATHS.loginState: + return { status: 200, body: hilinkStateBody(reported) }; + case HILINK_PATHS.login: + if (script.login === 'auth-expired') + return { status: 200, body: HILINK_LOGIN_AUTH_EXPIRED }; + if (script.login === 'rejected') return { status: 200, body: HILINK_LOGIN_REJECTED }; + return { + status: 200, + body: HILINK_LOGIN_OK, + headers: { + 'set-cookie': `${hilinkCookie(token)}; Path=/`, + __requestverificationtoken: token, + }, + }; + case HILINK_PATHS.status: + return { + status: 200, + body: telemetryRefused ? HILINK_AUTH_EXPIRED_FIXTURE.status : HILINK_FIXTURE.status, + }; + case HILINK_PATHS.signal: + return { + status: 200, + body: telemetryRefused ? HILINK_AUTH_EXPIRED_FIXTURE.signal : HILINK_FIXTURE.signal, + }; + case HILINK_PATHS.modeList: + return { + status: 200, + body: telemetryRefused + ? (HILINK_AUTH_EXPIRED_FIXTURE.netModeList ?? '') + : (HILINK_FIXTURE.netModeList ?? ''), + }; + case HILINK_PATHS.mode: + return { status: 200, body: HILINK_FIXTURE.netMode ?? '' }; + case HILINK_PATHS.data: + return { + status: 200, + body: telemetryRefused ? HILINK_LOGIN_AUTH_EXPIRED : HILINK_DATA_ENABLED, + }; + default: + return NOT_THIS_VENDOR; + } + }; +} + +/** + * Two devices behind ONE address, resolved by the interface a request was bound to. + * + * This is the duplicate-IP fleet reality, and it is why every provider request carries + * `interfaceName`. A request bound to an interface nobody claims reaches the fallback — + * which is the honest answer, because on a real board it reaches whichever twin the + * route table happened to pick. + */ +export function interfaceRoutedDevice( + byInterface: Readonly>, + fallback: ScriptedDevice, +): ScriptedDevice { + return (exchange) => (byInterface[exchange.interfaceName] ?? fallback)(exchange); +} + +// ── ZTE goform documents ──────────────────────────────────────────────────────────── + +export const ZTE_LD = 'conformance-ld'; +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'; + +const sha256Upper = (value: string): string => + createHash('sha256').update(value).digest('hex').toUpperCase(); + +/** The salted password an MF266 `LOGIN_MULTI_USER` must carry. */ +export const ZTE_SALTED_PASSWORD = sha256Upper( + `${sha256Upper(CONFORMANCE_CREDENTIALS.password)}${ZTE_LD}`, +); +/** 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' }), +} 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' }); +/** 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 telemetry?: 'reported' | 'malformed'; +}; + +export const ZTE_FINGERPRINT_CMD = 'cr_version,network_type'; +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 === 'salted-shape' + ? ZTE_LOGIN_SALTED_SHAPE + : ZTE_LOGIN_ACCEPTED; + const loginAccepted = script.login === undefined || script.login === 'ok'; + return (exchange: RecordedExchange): ScriptedResponse => { + if (exchange.method === 'POST' && exchange.path === ZTE_PATHS.set) { + const id = goformId(exchange); + if (id !== 'LOGIN' && id !== 'LOGIN_MULTI_USER') return NOT_THIS_VENDOR; + return loginAccepted + ? { + status: 200, + body: loginBody, + headers: { 'set-cookie': `${ZTE_STOK}; Path=/` }, + } + : { status: 200, body: loginBody }; + } + if (exchange.path !== ZTE_PATHS.get) return NOT_THIS_VENDOR; + switch (exchange.query.cmd) { + case ZTE_FINGERPRINT_CMD: + return { + status: 200, + body: + script.fingerprint === 'malformed' + ? ZTE_MALFORMED_FIXTURE.body + : ZTE_FINGERPRINT_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, + body: script.telemetry === 'malformed' ? ZTE_MALFORMED_FIXTURE.body : ZTE_FIXTURE.body, + }; + default: + return NOT_THIS_VENDOR; + } + }; +} + +// ── Qualcomm UFI / HIMI documents ─────────────────────────────────────────────────── + +export const UFI_SESSION = 'conformance-himi-session'; +export const UFI_LOGIN_OK = JSON.stringify({ reply: 'ok', session: UFI_SESSION }); +export const UFI_LOGIN_REJECTED = JSON.stringify({ reply: 'fail' }); +export const UFI_MALFORMED = 'login'; + +export type UfiScript = { + readonly login?: 'ok' | 'rejected'; + readonly telemetry?: 'reported' | 'session-out' | 'malformed'; +}; + +export function ufiDevice(script: UfiScript = {}): ScriptedDevice { + const refused = script.telemetry === 'session-out'; + const malformed = script.telemetry === 'malformed'; + return (exchange: RecordedExchange): ScriptedResponse => { + if (exchange.path !== UFI_API_PATH) return NOT_THIS_VENDOR; + const command = himiCommand(exchange); + if (command === 'login') { + return { + status: 200, + body: script.login === 'rejected' ? UFI_LOGIN_REJECTED : UFI_LOGIN_OK, + }; + } + if (malformed) return { status: 200, body: UFI_MALFORMED }; + switch (command) { + case 'getsysinfo': + return { + status: 200, + body: refused ? UFI_AUTH_EXPIRED_FIXTURE.sysinfo : UFI_FIXTURE.sysinfo, + }; + case 'getoverview': + return { + status: 200, + body: refused ? UFI_AUTH_EXPIRED_FIXTURE.overview : UFI_FIXTURE.overview, + }; + case 'getallstatus': + return { + status: 200, + body: refused ? UFI_AUTH_EXPIRED_FIXTURE.status : UFI_FIXTURE.status, + }; + case 'getproduceinfo': + return { status: 200, body: UFI_FIXTURE.produceInfo ?? '' }; + default: + return NOT_THIS_VENDOR; + } + }; +} + +/** + * Every literal body the corpus can put on the wire, for the sanitization gate. + * + * A gate that scanned the module SOURCE would be defeated by a template; scanning the + * bodies themselves is what makes it a statement about what a device actually answers. + */ +export const CORPUS_BODIES: readonly string[] = [ + hilinkSessionBody(HILINK_TOKENS['e3372h-22.200-password-type-3']), + hilinkSessionBody(HILINK_TOKENS['e3372h-22.333-password-type-4']), + HILINK_SESSION_MALFORMED, + hilinkStateBody(3), + hilinkStateBody(4), + HILINK_LOGIN_OK, + HILINK_LOGIN_AUTH_EXPIRED, + HILINK_LOGIN_REJECTED, + HILINK_DATA_ENABLED, + hilinkLoginDocument('e3372h-22.200-password-type-3'), + hilinkLoginDocument('e3372h-22.333-password-type-4'), + HILINK_FIXTURE.status, + HILINK_FIXTURE.signal, + HILINK_FIXTURE.netModeList ?? '', + HILINK_FIXTURE.netMode ?? '', + HILINK_AUTH_EXPIRED_FIXTURE.status, + HILINK_AUTH_EXPIRED_FIXTURE.signal, + HILINK_AUTH_EXPIRED_FIXTURE.netModeList ?? '', + ...Object.values(ZTE_FINGERPRINT_BODIES), + ZTE_LOGIN_ACCEPTED, + ZTE_LOGIN_REJECTED, + ZTE_LOGIN_LOCKOUT_UNKNOWN, + 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, + UFI_FIXTURE.sysinfo, + UFI_FIXTURE.overview, + UFI_FIXTURE.status, + UFI_FIXTURE.produceInfo ?? '', + UFI_AUTH_EXPIRED_FIXTURE.sysinfo, + UFI_AUTH_EXPIRED_FIXTURE.overview, + UFI_AUTH_EXPIRED_FIXTURE.status, +]; diff --git a/control/test-support/conformance/exchange.ts b/control/test-support/conformance/exchange.ts new file mode 100644 index 0000000..e8b52e3 --- /dev/null +++ b/control/test-support/conformance/exchange.ts @@ -0,0 +1,199 @@ +// ONE normalized wire shape for THREE vendor transports. +// +// HiLink, goform and HIMI disagree about everything a transcript assertion cares about: +// HiLink posts XML to a path, goform posts a URL-encoded form and carries its verb in a +// `goformId` field, HIMI posts JSON to a single endpoint and carries its verb in the +// body's `cmdid`. Each provider suite (todos 24/25/26) therefore asserts its own request +// literal in its own shape, which is right for a provider suite and useless for a matrix +// that has to compare all of them side by side. +// +// `RecordedExchange` is that comparison shape. It decodes the body into the form the +// vendor actually uses — `form` / `json` / `xml` — instead of flattening everything to a +// string, so a form-field assertion stays a form-field assertion and a header-order +// assertion stays exact. Nothing is normalized AWAY: the header array is kept verbatim +// (order included), the cookie is additionally lifted out because cookie handling is the +// one thing all three do and all three do differently, and `interfaceName` / `redirect` +// ride along because duplicate-IP binding and redirect refusal are safety properties, not +// transport trivia. + +import type { + HilinkHttpRequest, + HilinkHttpResponse, + HilinkTransport, +} from '../../src/providers/huawei-hilink/transport'; +import type { + UfiHttpRequest, + UfiHttpResponse, + UfiTransport, +} from '../../src/providers/ufi-himi/transport'; +import type { + ZteHttpRequest, + ZteHttpResponse, + ZteTransport, +} from '../../src/providers/zte-goform/transport'; + +/** The vendor's own body encoding, decoded — never flattened to one string. */ +export type ExchangeBody = + | { readonly kind: 'none' } + | { readonly kind: 'form'; readonly fields: Readonly> } + | { readonly kind: 'json'; readonly fields: Readonly> } + | { readonly kind: 'xml'; readonly text: string }; + +export type RecordedExchange = { + readonly provider: 'huawei-hilink' | 'zte-goform' | 'ufi-himi'; + readonly method: 'GET' | 'POST'; + readonly path: string; + readonly query: Readonly>; + readonly headers: readonly string[]; + /** Lifted out of `headers` because cookie handling is where the three diverge. */ + readonly cookie: string | undefined; + readonly body: ExchangeBody; + readonly interfaceName: string; + readonly redirect: 'error'; +}; + +/** What a scripted device answers. `headers` carries `set-cookie` where it matters. */ +export type ScriptedResponse = { + readonly status: number; + readonly body: string; + readonly headers?: Readonly>; +}; + +/** A scripted device: the exchange in, its answer out. Never order-dependent. */ +export type ScriptedDevice = (exchange: RecordedExchange) => ScriptedResponse; + +/** HTTP 404 with an empty body — what a device that is NOT this vendor answers. */ +export const NOT_THIS_VENDOR: ScriptedResponse = { status: 404, body: '' }; + +/** A device that answers nothing this vendor understands, on every path. */ +export const absentDevice: ScriptedDevice = () => NOT_THIS_VENDOR; + +function cookieOf(headers: readonly string[]): string | undefined { + const header = headers.find((entry) => entry.toLowerCase().startsWith('cookie:')); + return header?.slice(header.indexOf(':') + 1).trim(); +} + +function formFields(body: string): Readonly> { + const fields: Record = {}; + for (const [key, value] of new URLSearchParams(body)) fields[key] = value; + return fields; +} + +function jsonFields(body: string): Readonly> { + try { + const parsed: unknown = JSON.parse(body); + return typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed) + ? (parsed as Record) + : {}; + } catch { + return {}; + } +} + +function queryFields(url: URL): Readonly> { + const query: Record = {}; + for (const [key, value] of url.searchParams) query[key] = value; + return query; +} + +/** A recorder is one device's transport plus the ordered transcript it produced. */ +export type Recorder = { + readonly transport: T; + readonly exchanges: readonly RecordedExchange[]; +}; + +class ExchangeLog { + readonly entries: RecordedExchange[] = []; + + record(exchange: RecordedExchange): RecordedExchange { + this.entries.push(exchange); + return exchange; + } +} + +export function recordHilink(device: ScriptedDevice): Recorder { + const log = new ExchangeLog(); + const transport: HilinkTransport = { + request: async (request: HilinkHttpRequest): Promise => { + const url = new URL(request.url); + const exchange = log.record({ + provider: 'huawei-hilink', + method: request.method, + path: url.pathname, + query: queryFields(url), + headers: request.headers, + cookie: cookieOf(request.headers), + body: request.body === undefined ? { kind: 'none' } : { kind: 'xml', text: request.body }, + interfaceName: request.interfaceName, + redirect: request.redirect, + }); + const scripted = device(exchange); + return scripted.headers === undefined + ? { status: scripted.status, body: scripted.body } + : { status: scripted.status, body: scripted.body, headers: scripted.headers }; + }, + }; + return { transport, exchanges: log.entries }; +} + +export function recordZte(device: ScriptedDevice): Recorder { + const log = new ExchangeLog(); + const transport: ZteTransport = { + request: async (request: ZteHttpRequest): Promise => { + const url = new URL(request.url); + const exchange = log.record({ + provider: 'zte-goform', + method: request.method, + path: url.pathname, + query: queryFields(url), + headers: request.headers, + cookie: cookieOf(request.headers), + body: + request.body === undefined + ? { kind: 'none' } + : { kind: 'form', fields: formFields(request.body) }, + interfaceName: request.interfaceName, + redirect: request.redirect, + }); + const scripted = device(exchange); + return scripted.headers === undefined + ? { status: scripted.status, body: scripted.body } + : { status: scripted.status, body: scripted.body, headers: scripted.headers }; + }, + }; + return { transport, exchanges: log.entries }; +} + +export function recordUfi(device: ScriptedDevice): Recorder { + const log = new ExchangeLog(); + const transport: UfiTransport = { + request: async (request: UfiHttpRequest): Promise => { + const url = new URL(request.url); + const exchange = log.record({ + provider: 'ufi-himi', + method: request.method, + path: url.pathname, + query: queryFields(url), + headers: request.headers, + cookie: cookieOf(request.headers), + body: { kind: 'json', fields: jsonFields(request.body) }, + interfaceName: request.interfaceName, + redirect: request.redirect, + }); + const scripted = device(exchange); + return { status: scripted.status, body: scripted.body }; + }, + }; + return { transport, exchanges: log.entries }; +} + +/** The `goformId` a goform POST carries, or `undefined` for a GET. */ +export function goformId(exchange: RecordedExchange): string | undefined { + return exchange.body.kind === 'form' ? exchange.body.fields.goformId : undefined; +} + +/** The `cmdid` a HIMI POST carries. */ +export function himiCommand(exchange: RecordedExchange): string | undefined { + const value = exchange.body.kind === 'json' ? exchange.body.fields.cmdid : undefined; + return typeof value === 'string' ? value : undefined; +} diff --git a/control/test-support/conformance/index.ts b/control/test-support/conformance/index.ts new file mode 100644 index 0000000..fca53fc --- /dev/null +++ b/control/test-support/conformance/index.ts @@ -0,0 +1,90 @@ +// Public surface of the repo-local conformance harness. +// +// Unpublished, like the rest of `test-support/`: it lives outside `control/src`, so +// `files: ["dist"]` cannot carry it to npm, and it is NOT the package's public +// `./testing` contract-fakes surface. Do not promote it to a package subpath. + +export type { + ConformanceCase, + ConformanceExpectation, + ConformanceKind, + ConformanceRun, + ScenarioOptions, + ScenarioTranscripts, +} from './cases'; +export { + CONFORMANCE_CASES, + FM350_USB_SPEC, + observedExpectation, + QUECTEL_SPEC, + SIMCOM_SPEC, +} from './cases'; +export { + CONFORMANCE_CREDENTIALS, + CORPUS_BODIES, + HILINK_ADMIN_URL, + HILINK_FIRMWARE, + HILINK_PRIMARY_INTERFACE, + HILINK_TOKENS, + HILINK_TWIN_INTERFACE, + type HilinkProfileId, + type HilinkScript, + hilinkDevice, + hilinkLoginDocument, + hilinkWirePassword, + interfaceRoutedDevice, + SANITIZED_SUBSCRIBER_IDENTIFIERS, + UFI_ADMIN_URL, + UFI_INTERFACE, + UFI_SESSION, + type UfiScript, + USB_IDS, + ufiDevice, + ZTE_ADMIN_URL, + ZTE_FINGERPRINT_CMD, + ZTE_INTERFACE, + ZTE_LD, + ZTE_LEGACY_PASSWORD, + ZTE_SALTED_PASSWORD, + ZTE_STOK, + ZTE_TELEMETRY_CMD, + ZTE_VERSIONS_CMD, + type ZteScript, + zteDevice, +} from './corpus'; +export { + absentDevice, + type ExchangeBody, + goformId, + himiCommand, + NOT_THIS_VENDOR, + type RecordedExchange, + type Recorder, + recordHilink, + recordUfi, + recordZte, + type ScriptedDevice, + type ScriptedResponse, +} from './exchange'; +export { + MATRIX_JSON_PATH, + MATRIX_MARKDOWN_PATH, + type MatrixRow, + writeMatrixArtifact, +} from './matrix-report'; +export { + decodeTree, + FakeMmTransport, + type FakeMmTransportOptions, + type RecordedCall, +} from './mm-transport'; +export { + hilinkCookieFor, + hilinkGet, + hilinkLoginPost, + hilinkOpenGet, + ufiLoginPost, + ufiReadPost, + zteGet, + ztePost, +} from './transcripts'; diff --git a/control/test-support/conformance/matrix-report.ts b/control/test-support/conformance/matrix-report.ts new file mode 100644 index 0000000..e838c8e --- /dev/null +++ b/control/test-support/conformance/matrix-report.ts @@ -0,0 +1,73 @@ +// The matrix summary artifact. +// +// A conformance suite that only goes green tells a reviewer that nothing broke and +// nothing else. The artifact is the other half: every case, its kind, the decision it +// was ENTITLED to and the decision it actually got, in a form a human reads and a later +// todo (29's CeraUI migration, 42's hardware bench certification) can diff against. +// +// It lands in the repo-local, gitignored `test-results/` — evidence, never a tracked +// file, and never a path above this checkout root (AGENTS.md § Rule D). + +import { mkdirSync, writeFileSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import type { ConformanceExpectation, ConformanceKind } from './cases'; + +export type MatrixRow = { + readonly id: string; + readonly kind: ConformanceKind; + readonly summary: string; + readonly expected: ConformanceExpectation; + readonly actual: ConformanceExpectation; + readonly agrees: boolean; +}; + +const ARTIFACT_DIR = resolve(import.meta.dir, '../../../test-results'); +export const MATRIX_JSON_PATH = resolve(ARTIFACT_DIR, 'provider-conformance-matrix.json'); +export const MATRIX_MARKDOWN_PATH = resolve(ARTIFACT_DIR, 'provider-conformance-matrix.md'); + +function decision(value: ConformanceExpectation): string { + const target = value.provider === null ? '—' : `${value.provider} / ${value.profile}`; + return `${value.status} · ${target} · ${value.writable ? 'writable' : 'read-only'} · ${value.score}`; +} + +function markdown(rows: readonly MatrixRow[]): string { + const byKind = new Map(); + for (const row of rows) byKind.set(row.kind, (byKind.get(row.kind) ?? 0) + 1); + const lines = [ + '# Provider-matching conformance matrix', + '', + `Cases: **${rows.length}** · agreeing: **${rows.filter((row) => row.agrees).length}** · ` + + `writable selections: **${rows.filter((row) => row.actual.writable).length}**`, + '', + 'Generated by `control/src/providers/conformance-matrix.test.ts`. Every row runs the real', + 'matcher with the ModemManager, Huawei HiLink, ZTE goform and UFI/HIMI providers all', + 'registered at once.', + '', + '| # | Case | Kind | Expected decision | Actual decision | Agrees |', + '|---|------|------|-------------------|-----------------|--------|', + ...rows.map( + (row, index) => + `| ${index + 1} | \`${row.id}\`
${row.summary} | ${row.kind} | ${decision(row.expected)} | ${decision(row.actual)} | ${row.agrees ? 'yes' : '**NO**'} |`, + ), + '', + '## Cases by kind', + '', + '| Kind | Cases |', + '|------|-------|', + ...[...byKind.entries()].map(([kind, count]) => `| ${kind} | ${count} |`), + '', + ]; + return `${lines.join('\n')}\n`; +} + +/** Write both artifact forms and return the markdown, so a caller can assert on it. */ +export function writeMatrixArtifact(rows: readonly MatrixRow[]): string { + const document = markdown(rows); + mkdirSync(dirname(MATRIX_JSON_PATH), { recursive: true }); + writeFileSync( + MATRIX_JSON_PATH, + `${JSON.stringify({ caseCount: rows.length, rows }, null, '\t')}\n`, + ); + writeFileSync(MATRIX_MARKDOWN_PATH, document); + return document; +} diff --git a/control/test-support/conformance/mm-transport.ts b/control/test-support/conformance/mm-transport.ts new file mode 100644 index 0000000..c698b1a --- /dev/null +++ b/control/test-support/conformance/mm-transport.ts @@ -0,0 +1,218 @@ +// An in-memory `DbusTransport` serving the SAME MM-faithful object model as +// `test-support/fake-mm`, with no bus and no daemon. +// +// `fake-mm/service.ts` is the right harness for proving the transport codec and the +// observer's epoch handling — it is a real service on a real session bus, which is the +// only way to prove those. It is the WRONG harness for a conformance matrix: every case +// would need `dbus-run-session`, so a matrix row would SKIP rather than run wherever a +// session bus is absent, and a matrix with holes in it answers nothing. This transport +// reuses `fake-mm/object-model.ts` verbatim — the same `ModemSpec`, the same property +// sets, the same separate `Modem` / `Modem3gpp` interfaces, the same `/SIM/` objects +// and the same bearer objects — and converts that ENCODE-form tree into the DECODE form +// the transport hands back, so the model is shared rather than re-invented. +// +// It is also what makes the 16-modem scale fixture deterministic: subscription counts, +// `Signal.Setup` issue counts and refresh coalescing are all observable here without a +// bus in the middle, so a resource assertion measures the stack rather than the daemon. + +import type { + DbusTransport, + DbusValue, + MethodCall, + MethodReply, + SignalEvent, + SignalListener, + SignalSpec, + Subscription, + TransportEvent, +} from '../../src/transport'; +import type { + EncodeVariant, + InterfaceEntry, + ManagedObject, + ManagedObjects, + MmShape, + ModemSpec, + PropEntry, +} from '../fake-mm/object-model'; +import { BUS_NAME, managedObjects, modemObjects, ROOT_PATH } from '../fake-mm/object-model'; + +const DBUS_DESTINATION = 'org.freedesktop.DBus'; +const DBUS_IFACE = 'org.freedesktop.DBus'; +const OBJECT_MANAGER_IFACE = 'org.freedesktop.DBus.ObjectManager'; + +/** One recorded outgoing call — the resource ledger the scale fixture asserts against. */ +export type RecordedCall = { + readonly path: string; + readonly interface: string; + readonly member: string; +}; + +function decodeVariant(variant: EncodeVariant): DbusValue { + return { signature: variant[0], value: variant[1] as DbusValue }; +} + +function decodeProps(props: readonly PropEntry[]): DbusValue { + return props.map(([name, variant]) => [name, decodeVariant(variant)] as unknown as DbusValue); +} + +function decodeInterfaces(interfaces: readonly InterfaceEntry[]): DbusValue { + return interfaces.map(([name, props]) => [name, decodeProps(props)] as unknown as DbusValue); +} + +function decodeObject(object: ManagedObject): DbusValue { + return [object[0], decodeInterfaces(object[1])] as unknown as DbusValue; +} + +/** The ENCODE-form tree `fake-mm` builds, in the DECODE form the transport returns. */ +export function decodeTree(tree: ManagedObjects): DbusValue { + return tree.map(decodeObject) as unknown as DbusValue; +} + +export type FakeMmTransportOptions = { + readonly modems?: readonly ModemSpec[]; + readonly shape?: MmShape; + /** The unique bus name this epoch owns — every emitted signal carries it. */ + readonly owner?: string; +}; + +/** + * A scriptable in-memory ModemManager. `addModem` / `removeModem` emit the real + * ObjectManager signals with the current epoch's `sender`, so the observer's epoch guard + * is exercised rather than bypassed. + */ +export class FakeMmTransport implements DbusTransport { + readonly calls: RecordedCall[] = []; + readonly #specs = new Map(); + readonly #shape: MmShape; + readonly #subscriptions = new Set<{ spec: SignalSpec; listener: SignalListener }>(); + readonly #handlers = new Map void>>(); + #owner: string; + #connected = false; + + constructor(options: FakeMmTransportOptions = {}) { + this.#shape = options.shape ?? '1.24'; + this.#owner = options.owner ?? ':1.42'; + for (const spec of options.modems ?? []) this.#specs.set(spec.index, spec); + } + + get owner(): string { + return this.#owner; + } + + /** Every `Signal.Setup` call issued, in order — one per (epoch, modem) or it is a bug. */ + get signalSetupCalls(): readonly RecordedCall[] { + return this.calls.filter( + (call) => call.member === 'Setup' && call.interface.endsWith('.Signal'), + ); + } + + get managedObjectsCalls(): readonly RecordedCall[] { + return this.calls.filter((call) => call.member === 'GetManagedObjects'); + } + + async connect(): Promise { + this.#connected = true; + } + + async disconnect(): Promise { + this.#connected = false; + this.#subscriptions.clear(); + } + + isConnected(): boolean { + return this.#connected; + } + + async callMethod(call: MethodCall): Promise { + this.calls.push({ path: call.path, interface: call.interface, member: call.member }); + if (call.interface === DBUS_IFACE && call.member === 'GetNameOwner') { + return { signature: 's', body: [this.#owner] }; + } + if (call.interface === OBJECT_MANAGER_IFACE && call.member === 'GetManagedObjects') { + return { signature: 'a{oa{sa{sv}}}', body: [this.tree()] }; + } + return { signature: '', body: [] }; + } + + async subscribeSignal(spec: SignalSpec, listener: SignalListener): Promise { + const entry = { spec, listener }; + this.#subscriptions.add(entry); + return { + unsubscribe: async () => { + this.#subscriptions.delete(entry); + }, + }; + } + + on(event: TransportEvent, handler: (payload?: unknown) => void): void { + const set = this.#handlers.get(event) ?? new Set(); + set.add(handler); + this.#handlers.set(event, set); + } + + off(event: TransportEvent, handler: (payload?: unknown) => void): void { + this.#handlers.get(event)?.delete(handler); + } + + subscriptionCount(): number { + return this.#subscriptions.size; + } + + tree(): DbusValue { + return decodeTree(managedObjects([...this.#specs.values()], this.#shape)); + } + + /** Attach a modem and announce it exactly as MM does — one signal per object. */ + addModem(spec: ModemSpec): void { + this.#specs.set(spec.index, spec); + for (const object of modemObjects(spec, this.#shape)) { + this.#emit({ + path: ROOT_PATH, + interface: OBJECT_MANAGER_IFACE, + member: 'InterfacesAdded', + sender: this.#owner, + signature: 'oa{sa{sv}}', + body: [object[0], decodeInterfaces(object[1])], + }); + } + } + + removeModem(index: number): void { + const spec = this.#specs.get(index); + if (spec === undefined) return; + this.#specs.delete(index); + for (const [path, interfaces] of modemObjects(spec, this.#shape)) { + this.#emit({ + path: ROOT_PATH, + interface: OBJECT_MANAGER_IFACE, + member: 'InterfacesRemoved', + sender: this.#owner, + signature: 'oas', + body: [path, interfaces.map(([name]) => name) as unknown as DbusValue], + }); + } + } + + /** Hand the well-known name to a NEW unique owner — a genuine epoch change. */ + takeOverAs(owner: string): void { + const previous = this.#owner; + this.#owner = owner; + this.#emit({ + path: '/org/freedesktop/DBus', + interface: DBUS_IFACE, + member: 'NameOwnerChanged', + sender: DBUS_DESTINATION, + signature: 'sss', + body: [BUS_NAME, previous, owner], + }); + } + + #emit(event: SignalEvent): void { + for (const { spec, listener } of [...this.#subscriptions]) { + if (spec.interface !== event.interface || spec.member !== event.member) continue; + if (spec.path !== undefined && spec.path !== event.path) continue; + listener(event); + } + } +} diff --git a/control/test-support/conformance/transcripts.ts b/control/test-support/conformance/transcripts.ts new file mode 100644 index 0000000..bd24de5 --- /dev/null +++ b/control/test-support/conformance/transcripts.ts @@ -0,0 +1,167 @@ +// Expected transcripts — the exact wire a firmware profile is entitled to see. +// +// These builders are written from the PROTOCOL, not from the provider: a HiLink session +// GET carries no headers at all, an authenticated HiLink GET carries exactly one +// `Cookie:`, a HiLink POST carries cookie + verification token + content-type IN THAT +// ORDER, a goform request always carries browser-equivalent `Origin`/`Referer`, and a +// HIMI read carries `Authorization` before its content-type. Rebuilding them here is +// what makes `toEqual` against a recorded transcript an assertion rather than an echo. +// +// Header ORDER is part of the literal on purpose. A router firmware that rejects an +// out-of-order request is a real class of failure, and an order-insensitive comparison +// cannot see the day a refactor reorders them. + +import { HILINK_PATHS } from '../../src/providers/huawei-hilink/provider'; +import { UFI_API_PATH } from '../../src/providers/ufi-himi/transport'; +import { ZTE_PATHS } from '../../src/providers/zte-goform/provider'; +import { + CONFORMANCE_CREDENTIALS, + HILINK_TOKENS, + type HilinkProfileId, + hilinkLoginDocument, + UFI_SESSION, + ZTE_ADMIN_URL, +} from './corpus'; +import type { RecordedExchange } from './exchange'; + +/** The `SessionID=…` cookie a HiLink profile's session document hands out. */ +export function hilinkCookieFor(profileId: HilinkProfileId): string { + return `SessionID=${HILINK_TOKENS[profileId]}-cookie`; +} + +/** An unauthenticated HiLink GET — no cookie, no token, no headers whatsoever. */ +export function hilinkOpenGet(path: string, interfaceName: string): RecordedExchange { + return { + provider: 'huawei-hilink', + method: 'GET', + path, + query: {}, + headers: [], + cookie: undefined, + body: { kind: 'none' }, + interfaceName, + redirect: 'error', + }; +} + +/** An authenticated HiLink GET — exactly one `Cookie:` header and nothing else. */ +export function hilinkGet(path: string, interfaceName: string, cookie: string): RecordedExchange { + return { + provider: 'huawei-hilink', + method: 'GET', + path, + query: {}, + headers: [`Cookie: ${cookie}`], + cookie, + body: { kind: 'none' }, + interfaceName, + redirect: 'error', + }; +} + +/** The ONE login POST a profile is allowed, with its exact derived password document. */ +export function hilinkLoginPost( + profileId: HilinkProfileId, + interfaceName: string, +): RecordedExchange { + const cookie = hilinkCookieFor(profileId); + return { + provider: 'huawei-hilink', + method: 'POST', + path: HILINK_PATHS.login, + query: {}, + headers: [ + `Cookie: ${cookie}`, + `__RequestVerificationToken: ${HILINK_TOKENS[profileId]}`, + 'Content-Type: application/xml', + ], + cookie, + body: { kind: 'xml', text: hilinkLoginDocument(profileId) }, + interfaceName, + redirect: 'error', + }; +} + +/** A goform GET. `multiData` adds `multi_data=1`; `cookie` adds the `stok` jar entry. */ +export function zteGet( + cmd: string, + interfaceName: string, + options: { readonly cookie?: string; readonly multiData?: boolean } = {}, +): RecordedExchange { + const query: Record = { isTest: 'false', cmd }; + if (options.multiData === true) query.multi_data = '1'; + return { + provider: 'zte-goform', + method: 'GET', + path: ZTE_PATHS.get, + query, + headers: [ + ...(options.cookie === undefined ? [] : [`Cookie: ${options.cookie}`]), + `Origin: ${ZTE_ADMIN_URL}`, + `Referer: ${ZTE_ADMIN_URL}/index.html`, + ], + cookie: options.cookie, + body: { kind: 'none' }, + interfaceName, + redirect: 'error', + }; +} + +/** A goform POST — form-encoded, browser-equivalent, never JSON. */ +export function ztePost( + fields: Readonly>, + interfaceName: string, +): RecordedExchange { + return { + provider: 'zte-goform', + method: 'POST', + path: ZTE_PATHS.set, + query: {}, + headers: [ + 'Content-Type: application/x-www-form-urlencoded; charset=UTF-8', + `Origin: ${ZTE_ADMIN_URL}`, + `Referer: ${ZTE_ADMIN_URL}/index.html`, + ], + cookie: undefined, + body: { kind: 'form', fields }, + interfaceName, + redirect: 'error', + }; +} + +/** The HIMI login POST — the ONE command in the vocabulary that is not a `get*`. */ +export function ufiLoginPost(interfaceName: string): RecordedExchange { + return { + provider: 'ufi-himi', + method: 'POST', + path: UFI_API_PATH, + query: {}, + headers: ['Content-Type: application/json;charset=UTF-8'], + cookie: undefined, + body: { + kind: 'json', + fields: { + cmdid: 'login', + username: CONFORMANCE_CREDENTIALS.username, + password: CONFORMANCE_CREDENTIALS.password, + }, + }, + interfaceName, + redirect: 'error', + }; +} + +/** A HIMI read — same path, same method, verb in the body, session in the header. */ +export function ufiReadPost(command: string, interfaceName: string): RecordedExchange { + return { + provider: 'ufi-himi', + method: 'POST', + path: UFI_API_PATH, + query: {}, + headers: [`Authorization: ${UFI_SESSION}`, 'Content-Type: application/json;charset=UTF-8'], + cookie: undefined, + body: { kind: 'json', fields: { cmdid: command, sessionId: UFI_SESSION } }, + interfaceName, + redirect: 'error', + }; +} diff --git a/control/test-support/fake-mm/handlers.ts b/control/test-support/fake-mm/handlers.ts index be2b744..9362d44 100644 --- a/control/test-support/fake-mm/handlers.ts +++ b/control/test-support/fake-mm/handlers.ts @@ -15,7 +15,9 @@ import type { BusSession } from './bus-session'; import { BEARER_IFACE, bearerPath, + LOCATION_IFACE, type ManagedObjects, + MESSAGING_IFACE, MM_MANAGER_IFACE, MODEM_IFACE, MODEM3GPP_IFACE, @@ -27,6 +29,7 @@ import { SIM_IFACE, SIMPLE_IFACE, simPath, + USSD_IFACE, } from './object-model'; const MANAGED_OBJECTS_SIG = 'a{oa{sa{sv}}}'; @@ -40,6 +43,11 @@ export interface HandlerContext { submitPin(modemIndex: number, simObjectPath: string, pin: unknown): null; submitPuk(modemIndex: number, simObjectPath: string, puk: unknown, newPin: unknown): null; recordSignalSetup(modemIndex: number, rate: unknown): void; + setupLocation(modemIndex: number, sources: unknown, signalLocation: unknown): null; + locationReply(modemIndex: number): unknown; + ussdReply(modemIndex: number, member: 'Initiate' | 'Respond'): string; + ussdState(modemIndex: number): number; + maybeFail(member: string, modemIndex: number): void; tripwire(iface: string, member: string): never; delay(value: T): T | Promise; /** Record a call-log event, then run `produce` after the reply delay. */ @@ -68,7 +76,11 @@ export function registerModemHandlers( path, MODEM_IFACE, 'SetCurrentModes', - () => ctx.traced('SetCurrentModes', i, () => null), + () => + ctx.traced('SetCurrentModes', i, () => { + ctx.maybeFail('SetCurrentModes', i); + return null; + }), '', ); session.handle( @@ -99,6 +111,34 @@ export function registerModemHandlers( '', ); } + if (spec.location !== undefined) { + session.handle( + path, + LOCATION_IFACE, + 'Setup', + (sources, signalLocation) => ctx.setupLocation(i, sources, signalLocation), + '', + ); + session.handle(path, LOCATION_IFACE, 'GetLocation', () => ctx.locationReply(i), 'a{uv}'); + } + if (spec.messaging === true) { + session.handle(path, MESSAGING_IFACE, 'List', () => [], 'ao'); + } + if (spec.ussd !== undefined) { + session.handle(path, USSD_IFACE, 'Initiate', () => ctx.ussdReply(i, 'Initiate'), 's'); + session.handle(path, USSD_IFACE, 'Respond', () => ctx.ussdReply(i, 'Respond'), 's'); + session.handle(path, USSD_IFACE, 'Cancel', () => null, ''); + session.handle( + path, + 'org.freedesktop.DBus.Properties', + 'Get', + (iface, property) => { + if (iface === USSD_IFACE && property === 'State') return ['u', ctx.ussdState(i)]; + return ['u', 0]; + }, + 'v', + ); + } session.handle(path, SIMPLE_IFACE, 'Connect', () => ctx.tripwire(SIMPLE_IFACE, 'Connect'), ''); session.handle( path, diff --git a/control/test-support/fake-mm/index.ts b/control/test-support/fake-mm/index.ts index 718a909..760e4c4 100644 --- a/control/test-support/fake-mm/index.ts +++ b/control/test-support/fake-mm/index.ts @@ -9,8 +9,10 @@ export { bearerPath, type EncodeVariant, type InterfaceEntry, + LOCATION_IFACE, type ManagedObject, type ManagedObjects, + MESSAGING_IFACE, MM_LOCK_NONE, MM_LOCK_SIM_PIN, MM_LOCK_SIM_PUK, @@ -29,11 +31,13 @@ export { SIMPLE_IFACE, type SimSpec, simPath, + USSD_IFACE, } from './object-model'; export type { PreviousEpoch } from './previous-epoch'; export { FakeModemManager, type FakeModemManagerOptions, + type LocationSetupCall, type SignalSetupCall, TRIPWIRE_ERROR, } from './service'; diff --git a/control/test-support/fake-mm/object-model.ts b/control/test-support/fake-mm/object-model.ts index 59c27bb..306f995 100644 --- a/control/test-support/fake-mm/object-model.ts +++ b/control/test-support/fake-mm/object-model.ts @@ -24,6 +24,9 @@ export const MODEM_IFACE = 'org.freedesktop.ModemManager1.Modem'; export const MODEM3GPP_IFACE = 'org.freedesktop.ModemManager1.Modem.Modem3gpp'; export const SIMPLE_IFACE = 'org.freedesktop.ModemManager1.Modem.Simple'; export const SIGNAL_IFACE = 'org.freedesktop.ModemManager1.Modem.Signal'; +export const LOCATION_IFACE = 'org.freedesktop.ModemManager1.Modem.Location'; +export const MESSAGING_IFACE = 'org.freedesktop.ModemManager1.Modem.Messaging'; +export const USSD_IFACE = 'org.freedesktop.ModemManager1.Modem.Modem3gpp.Ussd'; export const SIM_IFACE = 'org.freedesktop.ModemManager1.Sim'; export const BEARER_IFACE = 'org.freedesktop.ModemManager1.Bearer'; @@ -87,6 +90,8 @@ export interface ModemSpec { readonly physdev?: string; /** MMModemState (e.g. 8 registered, 11 connected). */ readonly state?: number; + /** MMModemStateFailedReason (`u`, e.g. 2 = SIM_MISSING). Absent unless set. */ + readonly stateFailedReason?: number; /** Signal quality percent (0-100) for the `(ub)` SignalQuality struct. */ readonly signalQuality?: number; /** MMModem3gppRegistrationState (1 home, 5 roaming). */ @@ -101,6 +106,21 @@ export interface ModemSpec { readonly unlockRequired?: number; /** Remaining attempts per lock (`Modem.UnlockRetries`, `a(uu)`). */ readonly unlockRetries?: readonly (readonly [number, number])[]; + readonly supportedModes?: readonly (readonly [number, number])[]; + readonly currentModes?: readonly [number, number]; + readonly supportedBands?: readonly number[]; + readonly currentBands?: readonly number[]; + readonly location?: { + readonly capabilities: number; + readonly enabled?: number; + readonly fix?: unknown; + }; + readonly messaging?: boolean; + readonly ussd?: { + readonly state?: number; + readonly initiateReply?: string; + readonly respondReply?: string; + }; } export const modemPath = (index: number): string => `${ROOT_PATH}/Modem/${index}`; @@ -132,10 +152,19 @@ export function modemProps(spec: ModemSpec, shape: MmShape): readonly PropEntry[ ['Bearers', ['ao', [bearerPath(modemBearerIndex(spec))]]], ['SupportedCapabilities', ['au', [4, 8]]], ['CurrentCapabilities', ['u', 4]], - ['CurrentModes', ['(uu)', [7, 0]]], + ['SupportedModes', ['a(uu)', (spec.supportedModes ?? [[7, 0]]).map((pair) => [...pair])]], + ['CurrentModes', ['(uu)', [...(spec.currentModes ?? [7, 0])]]], + ['SupportedBands', ['au', [...(spec.supportedBands ?? [])]]], + ['CurrentBands', ['au', [...(spec.currentBands ?? [])]]], ['UnlockRequired', ['u', spec.unlockRequired ?? MM_LOCK_NONE]], ['UnlockRetries', ['a(uu)', (spec.unlockRetries ?? []).map(([lock, left]) => [lock, left])]], ]; + // Absent unless the scenario sets it: an ABSENT failure reason is what a healthy + // modem reports, and the SIM-presence evidence rule turns on presence-vs-absence + // of this exact property. + if (spec.stateFailedReason !== undefined) { + props.push(['StateFailedReason', ['u', spec.stateFailedReason]]); + } // `Physdev` (physical path) exists from 1.22+ — present on 1.22 and 1.24, absent on 1.20. if (shape === '1.22' || shape === '1.24') { props.push(['Physdev', ['s', spec.physdev ?? `/sys/devices/fake/usb${spec.index}`]]); @@ -180,6 +209,17 @@ export function signalProps(): readonly PropEntry[] { return [['Rate', ['u', 0]]]; } +export function locationProps(spec: ModemSpec): readonly PropEntry[] { + return [ + ['Capabilities', ['u', spec.location?.capabilities ?? 0]], + ['Enabled', ['u', spec.location?.enabled ?? 0]], + ]; +} + +export function ussdProps(spec: ModemSpec): readonly PropEntry[] { + return [['State', ['u', spec.ussd?.state ?? 1]]]; +} + /** A bearer object's property set — observable, but every connect method throws. */ export function bearerProps(): readonly PropEntry[] { return [ @@ -199,6 +239,9 @@ export function modemObject(spec: ModemSpec, shape: MmShape): ManagedObject { if (spec.hasSignal !== false) { interfaces.push([SIGNAL_IFACE, signalProps()]); } + if (spec.location !== undefined) interfaces.push([LOCATION_IFACE, locationProps(spec)]); + if (spec.messaging === true) interfaces.push([MESSAGING_IFACE, []]); + if (spec.ussd !== undefined) interfaces.push([USSD_IFACE, ussdProps(spec)]); return [modemPath(spec.index), interfaces]; } diff --git a/control/test-support/fake-mm/service.ts b/control/test-support/fake-mm/service.ts index 0d7bed2..89be00c 100644 --- a/control/test-support/fake-mm/service.ts +++ b/control/test-support/fake-mm/service.ts @@ -40,6 +40,12 @@ export interface SignalSetupCall { readonly owner: string | undefined; } +export interface LocationSetupCall { + readonly modemIndex: number; + readonly sources: number; + readonly signalLocation: boolean; +} + import { makePreviousEpoch, type PreviousEpoch } from './previous-epoch'; import { emitInterfacesAdded, emitInterfacesRemoved, emitPropertiesChanged } from './signals'; @@ -67,6 +73,8 @@ export class FakeModemManager { readonly #expectedPuks = new Map(); readonly #callLog: string[] = []; readonly #signalSetupLog: SignalSetupCall[] = []; + readonly #locationSetupLog: LocationSetupCall[] = []; + readonly #failures = new Map(); readonly #ctx: HandlerContext; #session: BusSession; #replyDelayMs = 0; @@ -82,6 +90,12 @@ export class FakeModemManager { submitPin: (index, sp, pin) => this.#submitPin(index, sp, pin), submitPuk: (index, sp, puk, newPin) => this.#submitPuk(index, sp, puk, newPin), recordSignalSetup: (index, rate) => this.#recordSignalSetup(index, rate), + setupLocation: (index, sources, signalLocation) => + this.#setupLocation(index, sources, signalLocation), + locationReply: (index) => this.#specs.get(index)?.location?.fix ?? [], + ussdReply: (index, member) => this.#ussdReply(index, member), + ussdState: (index) => this.#specs.get(index)?.ussd?.state ?? 1, + maybeFail: (member, index) => this.#maybeFail(member, index), tripwire: (iface, member) => this.#tripwire(iface, member), delay: (value) => this.#delay(value), traced: (member, index, produce) => this.#traced(member, index, produce), @@ -171,6 +185,11 @@ export class FakeModemManager { this.#cells.set(modemIndex, cells); } + configureLocation(modemIndex: number, location: NonNullable): void { + const spec = this.#specs.get(modemIndex); + if (spec !== undefined) this.#specs.set(modemIndex, { ...spec, location }); + } + /** The ordered disruptive-op call log (`member:start:` / `member:end:`). */ get callLog(): readonly string[] { return [...this.#callLog]; @@ -181,10 +200,19 @@ export class FakeModemManager { return [...this.#signalSetupLog]; } + get locationSetupCalls(): readonly LocationSetupCall[] { + return [...this.#locationSetupLog]; + } + + failNext(member: string, errorName: string): void { + this.#failures.set(member, errorName); + } + /** Reset the call + Signal.Setup logs (use between scenario phases). */ clearLogs(): void { this.#callLog.length = 0; this.#signalSetupLog.length = 0; + this.#locationSetupLog.length = 0; } /** Delay every subsequent method reply by `ms` (0 disables) — late-reply scenarios. */ @@ -286,6 +314,36 @@ export class FakeModemManager { }); } + #setupLocation(index: number, sources: unknown, signalLocation: unknown): null { + this.#maybeFail('Setup', index); + const spec = this.#specs.get(index); + const mask = typeof sources === 'number' ? sources : Number(sources); + this.#locationSetupLog.push({ + modemIndex: index, + sources: mask, + signalLocation: signalLocation === true, + }); + if (spec?.location !== undefined) { + this.#specs.set(index, { ...spec, location: { ...spec.location, enabled: mask } }); + } + return null; + } + + #ussdReply(index: number, member: 'Initiate' | 'Respond'): string { + this.#maybeFail(member, index); + const ussd = this.#specs.get(index)?.ussd; + return member === 'Initiate' ? (ussd?.initiateReply ?? '') : (ussd?.respondReply ?? ''); + } + + #maybeFail(member: string, _index: number): void { + const name = this.#failures.get(member); + if (name === undefined) return; + this.#failures.delete(member); + const error = new Error(name); + Object.defineProperty(error, 'dbusName', { value: name }); + throw error; + } + #traced(member: string, index: number, produce: () => T): T | Promise { this.#callLog.push(`${member}:start:${index}`); const finish = (): T => { diff --git a/control/test-support/journal-fixture.ts b/control/test-support/journal-fixture.ts new file mode 100644 index 0000000..937a43b --- /dev/null +++ b/control/test-support/journal-fixture.ts @@ -0,0 +1,148 @@ +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { + defineOperationDescriptor, + deviceGeneration, + type OperationDescriptor, + type OperationResult, + physicalModemId, +} from '../src/domain'; +import { createJournalEngine, type JournalEngine } from '../src/journal/engine'; +import type { JournalEntry } from '../src/journal/entry'; +import { createFileJournalStore } from '../src/journal/store'; +import type { OperationJournalEvent } from '../src/operations/contracts'; + +export const JOURNAL_MODEM_A = physicalModemId('serial:journal-a'); +export const JOURNAL_MODEM_B = physicalModemId('serial:journal-b'); +export const JOURNAL_GENERATION = deviceGeneration(7); + +export function journalDescriptor( + overrides: Partial> = {}, +): OperationDescriptor { + return defineOperationDescriptor({ + id: 'set-usb-mode', + support: { read: { supported: true }, write: { supported: true } }, + authority: 'provider', + provider: 'fixture-provider', + constraints: { kind: 'unconstrained' }, + livePreconditions: ['device-present'], + availability: { state: 'available' }, + mutationImpact: 'write', + retryClass: 'never', + readback: { required: false }, + rollback: { required: false }, + journal: { required: true, reason: 'stranded writes must survive a restart' }, + admission: { required: false }, + evidence: { profiles: ['fixture'], firmware: ['1.0'] }, + confidence: 'high', + ...overrides, + }); +} + +export function startedEvent( + operationId: string, + overrides: Partial, { phase: 'started' }>> = {}, +): OperationJournalEvent { + return { + phase: 'started', + operationId, + physicalModemId: JOURNAL_MODEM_A, + generation: JOURNAL_GENERATION, + descriptor: journalDescriptor(), + input: 'lte', + ...overrides, + }; +} + +export function completedEvent( + operationId: string, + result: OperationResult, + overrides: Partial, { phase: 'completed' }>> = {}, +): OperationJournalEvent { + return { + phase: 'completed', + operationId, + physicalModemId: JOURNAL_MODEM_A, + generation: JOURNAL_GENERATION, + descriptor: journalDescriptor(), + result, + ...overrides, + }; +} + +export function appliedResult(value = 'lte'): OperationResult { + return { + status: 'applied', + value, + generation: JOURNAL_GENERATION, + requiresReconciliation: false, + }; +} + +export function unknownOutcomeResult( + reason: 'stale-generation' | 'write-reply-timed-out' | 'write-reply-dropped', +): OperationResult { + return { + status: 'unknown-outcome', + reason, + generation: JOURNAL_GENERATION, + requiresReconciliation: true, + }; +} + +export function failedResult(reason: string): OperationResult { + return { + status: 'failed', + reason, + generation: JOURNAL_GENERATION, + requiresReconciliation: false, + }; +} + +export function journalEntry(overrides: Partial = {}): JournalEntry { + return { + schemaVersion: 1, + phase: 'started', + operationId: 'op-1', + physicalModemId: JOURNAL_MODEM_A as string, + generation: JOURNAL_GENERATION as number, + recordedAtMs: 1_000, + descriptor: { + descriptorId: 'set-usb-mode', + provider: 'fixture-provider', + authority: 'provider', + mutationImpact: 'write', + profiles: ['fixture'], + firmware: ['1.0'], + confidence: 'high', + }, + ...overrides, + } as JournalEntry; +} + +export interface JournalHarness { + readonly dir: string; + readonly path: string; + /** A fresh engine over the SAME path — the "restart" in a replay test. */ + restart(): JournalEngine; + dispose(): Promise; +} + +export async function createJournalHarness(fileName = 'operations.jsonl'): Promise { + const dir = await mkdtemp(join(tmpdir(), 'modem-journal-')); + const path = join(dir, fileName); + let tick = 0; + return { + dir, + path, + restart(): JournalEngine { + return createJournalEngine({ + store: createFileJournalStore({ path }), + now: () => 1_000 + ++tick, + }); + }, + dispose: () => rm(dir, { recursive: true, force: true }), + }; +} diff --git a/control/test-support/observation-fixtures.ts b/control/test-support/observation-fixtures.ts new file mode 100644 index 0000000..e26b83b --- /dev/null +++ b/control/test-support/observation-fixtures.ts @@ -0,0 +1,185 @@ +// Canonical per-source normalization fixtures. +// +// Each one is shaped like the payload its provider really returns — ModemManager's +// decoded property records, HiLink's XML bodies, a ZTE goform JSON blob, three UFI +// endpoint envelopes — and each deliberately carries at least one VENDOR-SPECIFIC +// field the normalized model has no slot for. Those extra fields are the point: they +// are what the round-trip assertions prove is retained rather than dropped. +// +// The providers that will eventually produce these payloads live in later changes. +// These fixtures are what the normalization layer is proven against in the meantime, +// and what those providers' contract tests should reuse rather than re-invent. + +import { + type DeviceGeneration, + deviceGeneration, + type EpochMillis, + epochMillis, + resolvePhysicalModemIdentity, + type SourceEpoch, + type StableKey, + sourceEpoch, +} from '../src/domain'; +import type { + HilinkObservationInput, + ModemManagerObservationInput, + NormalizationContext, + UfiObservationInput, + ZteObservationInput, +} from '../src/observations'; + +export const FIXTURE_STABLE_KEY: StableKey = resolvePhysicalModemIdentity({ + serial: 'FIXTURE-MODEM-0001', +}).stableKey; + +export const FIXTURE_GENERATION: DeviceGeneration = deviceGeneration(7); +export const FIXTURE_SOURCE_EPOCH: SourceEpoch = sourceEpoch(42); +export const FIXTURE_OBSERVED_AT: EpochMillis = epochMillis(1_700_000_000_000); + +export function fixtureContext( + overrides: Partial = {}, +): NormalizationContext { + return { + stableKey: FIXTURE_STABLE_KEY, + generation: FIXTURE_GENERATION, + sourceEpoch: FIXTURE_SOURCE_EPOCH, + observedAt: FIXTURE_OBSERVED_AT, + ...overrides, + }; +} + +/** + * A registered ModemManager modem. + * + * `Modem.Ports` and `Modem3gpp.Pco` are the vendor-specific extras here: real MM + * properties that this layer's normalized model has no slot for. + */ +export const MM_FIXTURE: ModemManagerObservationInput = { + modem: { + Model: 'RM530N-GL', + Manufacturer: 'Quectel', + Revision: 'RM530NGLAAR11A02M4G 1 [Feb 13 2024 05:00:00]', + State: 8, + CurrentModes: (1 << 3) | (1 << 4), + AccessTechnologies: 1 << 15, + SignalQuality: 71, + Sim: '/org/freedesktop/ModemManager1/SIM/0', + SimSlots: ['/org/freedesktop/ModemManager1/SIM/0', '/'], + UnlockRequired: 1, + Ports: ['ttyUSB0', 'wwan0'], + }, + modem3gpp: { + RegistrationState: 1, + Pco: 'dns-primary=10.0.0.1', + }, + sim: { + SimType: 1, + EsimStatus: 0, + OperatorName: 'CLARO COL', + }, + signal: { + rssi: -71, + rsrp: -98.5, + rsrq: -11, + snr: 6.5, + refresh_rate: 5, + }, +}; + +/** + * A HiLink dongle answering both bodies. + * + * `` and `` are the vendor-specific extras. + */ +export const HILINK_FIXTURE: HilinkObservationInput = { + status: [ + '', + '', + '901', + '4', + '5', + '1', + '101', + '', + ].join(''), + signal: [ + '', + '', + '-65dBm', + '-101dBm', + '-9dB', + '12dB', + '12345678', + '987654321', + '', + ].join(''), + netModeList: [ + '', + '00AUTO', + '03LTE', + '', + ].join(''), + netMode: '033FFFFFFF', +}; + +/** A ZTE goform reading. `wan_lte_ca` and `lte_pci` are the vendor-specific extras. */ +export const ZTE_FIXTURE: ZteObservationInput = { + body: JSON.stringify({ + signalbar: '4', + rssi: '-67', + lte_rsrp: '-99', + lte_rsrq: '-10', + lte_snr: '7', + network_type: 'LTE', + network_provider_fullname: 'Movistar', + cell_id: '0A1B2C', + simcard_roam: 'Home', + rmcc: '732', + rmnc: '123', + lte_pci: '188', + wan_active_band: 'LTE BAND 4', + wan_lte_ca: 'ca_deactivated', + }), +}; + +/** + * A UFI/HIMI reading across its three endpoints plus the product endpoint. + * + * `cputemp`, `wifinum` and `ethnum` are the vendor-specific extras; `IMSI` and + * `ICCID` are present because the real overview endpoint returns them, and they are + * what the redaction assertions exercise. + */ +export const UFI_FIXTURE: UfiObservationInput = { + sysinfo: JSON.stringify({ + reply: 'ok', + params: { SIGNAL: '3', cellid: '3344', bsid: '77', cputemp: '46', wifinum: '2', ethnum: '1' }, + }), + overview: JSON.stringify({ + reply: 'ok', + params: { + SIGNAL: '3', + SSID: 'CeraLive-UFI', + WANIP: '10.64.12.9', + IMSI: '732123456789012', + ICCID: '8957010000000000001', + WEBVER: 'V1.0.7', + }, + }), + status: JSON.stringify({ reply: 'ok', params: { signalStrength: '3', battery: '88' } }), + produceInfo: JSON.stringify({ reply: 'ok', params: { productname: 'UFI-M600', hwver: 'A2' } }), +}; + +/** Every fixture's session refused — the auth-expired shape, per source. */ +export const HILINK_AUTH_EXPIRED_FIXTURE: HilinkObservationInput = { + status: '125002need login', + signal: '125002need login', + netModeList: '125002', +}; + +export const UFI_AUTH_EXPIRED_FIXTURE: UfiObservationInput = { + sysinfo: JSON.stringify({ reply: 'SessionOut' }), + overview: JSON.stringify({ reply: 'SessionOut' }), + status: JSON.stringify({ reply: 'SessionOut' }), +}; + +export const ZTE_MALFORMED_FIXTURE: ZteObservationInput = { body: 'login' }; diff --git a/control/test-support/operation-engine-fixture.ts b/control/test-support/operation-engine-fixture.ts new file mode 100644 index 0000000..d72004d --- /dev/null +++ b/control/test-support/operation-engine-fixture.ts @@ -0,0 +1,102 @@ +import { + defineOperationDescriptor, + deviceGeneration, + nextDeviceGeneration, + type OperationCompletion, + type OperationDescriptor, + physicalModemId, +} from '../src/domain'; +import { + createOperationEngine, + type OperationExecution, + type OperationPreconditionPort, +} from '../src/operations/operation-engine'; +import type { MutationAdmissionPort, ResourceOwnershipPort } from '../src/ports'; +import { createModemControlCompositionRoot } from '../src/safety'; + +export const OPERATION_MODEM = physicalModemId('serial:operation-engine'); + +const ownership: ResourceOwnershipPort = { + acquire: () => Promise.resolve({ status: 'refused', reason: 'already-owned' }), +}; +const admission: MutationAdmissionPort = { + acquire: () => + Promise.resolve({ + status: 'admitted', + lease: { release: () => Promise.resolve() }, + }), +}; + +export function operationDescriptor( + overrides: Partial> = {}, +): OperationDescriptor { + return defineOperationDescriptor({ + id: 'set-mode', + support: { read: { supported: true }, write: { supported: true } }, + authority: 'provider', + provider: 'fixture', + constraints: { kind: 'allowed-values', values: ['auto', 'lte'] }, + livePreconditions: ['device-present'], + availability: { state: 'available' }, + mutationImpact: 'write', + retryClass: 'never', + readback: { required: false }, + rollback: { required: false }, + journal: { required: false }, + admission: { required: true, reason: 'controller approval' }, + evidence: { profiles: ['fixture'], firmware: [] }, + confidence: 'high', + ...overrides, + }); +} + +export function deferred(): { + readonly promise: Promise; + readonly resolve: (value: T) => void; +} { + let resolvePromise: ((value: T) => void) | undefined; + const promise = new Promise((resolve) => { + resolvePromise = resolve; + }); + return { + promise, + resolve(value: T): void { + if (resolvePromise === undefined) throw new Error('deferred resolver was not initialized'); + resolvePromise(value); + }, + }; +} + +export function operationExecution( + completion: () => Promise>, + overrides: Partial> = {}, +): OperationExecution { + return { + operationId: 'operation-1', + physicalModemId: OPERATION_MODEM, + descriptor: operationDescriptor(), + input: 'lte', + execute: completion, + ...overrides, + }; +} + +export function createOperationEngineHarness(preconditions?: OperationPreconditionPort) { + const root = createModemControlCompositionRoot({ admission, ownership }); + let generation = deviceGeneration(1); + const engine = createOperationEngine({ + root, + currentGeneration: () => generation, + preconditions: preconditions ?? { + check: () => Promise.resolve({ status: 'satisfied' }), + }, + }); + return { + engine, + generation: () => generation, + replace(): void { + generation = nextDeviceGeneration(generation); + }, + dispose: () => root.dispose(), + }; +} diff --git a/control/test-support/ownership-root-fixture.ts b/control/test-support/ownership-root-fixture.ts new file mode 100644 index 0000000..88f0781 --- /dev/null +++ b/control/test-support/ownership-root-fixture.ts @@ -0,0 +1,44 @@ +import { + createFlockResourceOwnershipPort, + createModemControlCompositionRoot, + type MutationAdmissionPort, +} from '../src'; + +const lockPath = process.argv[2]; +if (lockPath === undefined) throw new Error('lock path is required'); + +const admission: MutationAdmissionPort = { + acquire: () => Promise.resolve({ status: 'refused', reason: 'admission-refused' }), +}; +const root = createModemControlCompositionRoot({ + admission, + ownership: createFlockResourceOwnershipPort({ lockPath }), +}); +const result = await root.acquireOwnership({ resource: 'file-store' }); + +if (result.status === 'refused') { + process.stdout.write( + `${JSON.stringify({ + type: 'refused', + reason: result.reason, + ...(result.holder !== undefined ? { holderPid: result.holder.pid } : {}), + })}\n`, + ); + await root.dispose(); + process.exit(0); +} + +process.stdout.write( + `${JSON.stringify({ type: 'acquired', holderPid: result.lease.holder.pid })}\n`, +); +void result.lease.lost.then(() => { + process.stdout.write(`${JSON.stringify({ type: 'expired' })}\n`); +}); + +process.stdin.setEncoding('utf8'); +process.stdin.once('data', async () => { + await root.dispose(); + process.stdout.write(`${JSON.stringify({ type: 'released' })}\n`); + process.exit(0); +}); +process.stdin.resume(); diff --git a/control/test-support/provider-conformance-fixture.ts b/control/test-support/provider-conformance-fixture.ts new file mode 100644 index 0000000..ee5120a --- /dev/null +++ b/control/test-support/provider-conformance-fixture.ts @@ -0,0 +1,122 @@ +import { + type DeviceGeneration, + deviceGeneration, + epochMillis, + type ObservationEnvelope, + type OperationDescriptor, + type OperationResult, + type PhysicalModemId, + physicalModemId, + sourceEpoch, + stableKeyFromPhysicalModemId, +} from '../src/domain'; +import type { + ProviderDefinition, + ProviderMatchRequest, + ProviderOperationsSurface, + ProviderReadOperations, +} from '../src/providers'; + +type TestObservation = { readonly registered: boolean }; + +type TestOperations = ProviderOperationsSurface & { + readonly signal: ProviderReadOperations; +}; + +const PHYSICAL_ID: PhysicalModemId = physicalModemId('serial:provider-conformance'); + +export function providerMatchRequest( + generation: DeviceGeneration, + model = 'fixture-model', +): ProviderMatchRequest { + return { + physicalModemId: PHYSICAL_ID, + generation, + transport: 'network', + passiveFacts: [{ kind: 'model', value: model }], + composition: 'ethernet-router', + firmware: '1.0.0', + }; +} + +export function providerFixture( + id: string, + options: { + readonly model?: string; + readonly probe?: ProviderDefinition< + TestObservation, + TestOperations + >['unauthenticatedProbes'][number]; + } = {}, +): ProviderDefinition { + const model = options.model ?? 'fixture-model'; + const generation = deviceGeneration(1); + const observation: ObservationEnvelope = { + stableKey: stableKeyFromPhysicalModemId(PHYSICAL_ID), + generation, + source: id, + sourceEpoch: sourceEpoch(1), + observedAt: epochMillis(1), + freshness: { state: 'fresh' }, + authority: 'authoritative', + value: { registered: true }, + }; + const descriptor: OperationDescriptor = { + id: 'signal.read', + support: { read: { supported: true }, write: { supported: false, reason: 'read-only' } }, + authority: 'provider', + provider: id, + constraints: { kind: 'unconstrained' }, + livePreconditions: ['device-present'], + availability: { state: 'available' }, + mutationImpact: 'read', + retryClass: 'idempotent-read', + readback: { required: false }, + rollback: { required: false }, + journal: { required: false }, + admission: { required: false }, + evidence: { profiles: ['fixture-profile'], firmware: ['1.0.0'] }, + confidence: 'high', + }; + + return { + id, + profileVersion: '1', + eligibleTransports: ['network'], + passiveMatchers: [ + { + id: `${id}.model`, + fact: 'model', + expected: [model], + profiles: ['fixture-profile'], + strength: 'strong', + required: true, + }, + ], + unauthenticatedProbes: options.probe === undefined ? [] : [options.probe], + capabilityReaders: [], + observe: async () => [observation], + operations: () => ({ + access: 'read-only', + signal: { + descriptor, + read: async () => { + const result: OperationResult = { + status: 'applied', + value: -70, + generation, + requiresReconciliation: false, + }; + return result; + }, + }, + }), + contractFixtures: [ + { + profile: 'fixture-profile', + request: { method: 'GET', path: '/status' }, + response: { registered: true }, + }, + ], + }; +} diff --git a/control/tsconfig.build.json b/control/tsconfig.build.json new file mode 100644 index 0000000..e39cbb4 --- /dev/null +++ b/control/tsconfig.build.json @@ -0,0 +1,19 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "noEmit": false, + "declaration": true, + "declarationMap": false, + "sourceMap": false, + "rootDir": "src", + "outDir": "dist" + }, + "include": ["src"], + "exclude": [ + "src/**/*.test.ts", + "src/**/*.type-test.ts", + "src/transport/test-support", + "dist", + "fixtures" + ] +} diff --git a/docs/BENCH.md b/docs/BENCH.md index 33ac967..49455b9 100644 --- a/docs/BENCH.md +++ b/docs/BENCH.md @@ -812,19 +812,32 @@ the same reviewed ingestion path: [`CATALOG-INGESTION.md`](CATALOG-INGESTION.md) ### Blockers that make every RB-11…RB-15 row `[PARTIAL]` today -All four were verified on `ceralive2` (192.168.78.132) on 2026-08-16. None is "not run yet"; +Originally verified on `ceralive2` (192.168.78.132) on 2026-08-16 and **re-verified on the +same board on 2026-08-18** by a non-mutating capture pass against the SIMCom SIM7600G-H and +the Fibocom FM350-GL. The re-run **cleared B1, downgraded B3, promoted B2 from a code read +to a hardware-proven failure, and found two new blockers (B5, B6).** None is "not run yet"; each is a named obligation with a code or packaging fix behind it. -| # | Blocker | Verified how | Consequence | -|---|---------|--------------|-------------| -| **B1** | `usbutils` is **not installed and not in the board's apt archive** (`apt-cache show usbutils` → `E: No packages found`) | live on the board | `certify` runs `lsusb -v` **first** and `usb-devices` second (`cli/src/certify/capture.ts`); both fail, so **no bundle of any kind can be captured** until `usbutils` is installed from an archive that carries it, or built | -| **B2** | The production USB enumerator never populates `ifname` (`control/src/backend/usb-enumerator.ts`, `buildSnapshot`), but `certify` matches its target device **by** `ifname` | code read | the matched device is always `undefined`, so a bundle comes out with **no `sku` and empty `udevProperties`**, and `--transition` refuses with `--transition needs a matched USB device` | -| **B3** | There is **no AT transport** on the bench: the CLI's `benchAtSender` rejects every send, and the image has no `socat` / `picocom` / `minicom` for a manual session either | code read + live `command -v` sweep | stage 2 cannot execute an AT command at all — every `AT+…` line in RB-11/13/14 below is **documented, never executed** | -| **B4** | The shipped `certified-catalog.json` holds exactly one entry, `CERALIVE-SYNTHETIC-TEST-SKU` | repo read | stage 2 is unreachable for every real SKU until that SKU's stage-1 entry is merged | - -**B1 and B2 gate stage 1. B3 and B4 additionally gate stage 2.** A run that reports a -`CERTIFY OK` line with `synthetic=true`, or with an empty `sku`, is **not** a passing -RB-11…RB-15 — the gate is the `synthetic=false` line *and* a bundle whose `sku` is populated. +| # | Blocker | State | Verified how | Consequence | +|---|---------|-------|--------------|-------------| +| **B1** | `usbutils` absent from the board and its apt archive | **CLEARED (2026-08-18)** | live `command -v` sweep: `/usr/bin/lsusb`, `/usr/bin/usb-devices` both present | `certify` now completes its base capture; two real `CERTIFY OK … synthetic=false` bundles were captured on 2026-08-18 | +| **B2** | The production USB enumerator never populates `ifname` (`control/src/backend/usb-enumerator.ts`, `buildSnapshot`), but `certify` matches its target device **by** `ifname` (`cli/src/commands/certify.ts`) | **OPEN — now hardware-proven** | two live `certify` runs | the matched device is always `undefined`, so both real bundles came out with **no `sku` and empty `udevProperties`**; the ingestion seam correctly refuses them `sku-missing`, so **no bundle this pipeline produces can currently be promoted**. Pinned by `control/src/usb-mode/ingestion.hardware.test.ts` | +| **B3** | No AT transport | **PARTIALLY CLEARED (2026-08-18)** | live: `socat` **is** present at `/usr/bin/socat`; a query-only AT session over `/dev/ttyUSB2` (SIMCom) and `/dev/ttyUSB12` (FM350) succeeded | a **manual** AT session is now possible on the bench. The CLI half is unchanged: `benchAtSender` still rejects every send, so `certify --transition` still cannot execute an AT command. `picocom` / `minicom` remain absent; ModemManager's `--command` passthrough remains unavailable (MM is not run with `--debug`, so `mmcli --command` answers `Operation only allowed in debug mode`) | +| **B4** | The shipped `certified-catalog.json` holds exactly one entry, `CERALIVE-SYNTHETIC-TEST-SKU` | OPEN | repo read | stage 2 is unreachable for every real SKU until that SKU's stage-1 entry is merged | +| **B5** | The shared redactor does **not** mask `imei` / `equipment-identifier` / `device-identifier` (`control/src/redact.ts` `SENSITIVE_KEYS` covers ICCID / IMSI / EID / PIN / PUK / passwords only) | **OPEN — new** | inspected both real bundles | every real bundle's `modemManager` half carries the IMEI of **every** modem on the bench (`GetManagedObjects` is fleet-wide, not slot-scoped). A bundle therefore **must not be committed to this repo or pasted into a PR** as-is — which directly conflicts with the review workflow in [`CATALOG-INGESTION.md`](CATALOG-INGESTION.md). The `usb.lsusb` / `usb.usbDevices` halves are IMEI-free and safe to quote | +| **B6** | `skuOf` (`cli/src/certify/transform.ts`) derives `firmwarePrefix` from udev `ID_REVISION`, which is the USB **bcdDevice** — not the modem firmware revision | **OPEN — new** | compared udev against `mmcli` and AT `AT+CGMR` for both units | for the bench SIMCom, `ID_REVISION` is `0318` while the firmware is `LE20B04SIM7600G22`; for the FM350, `0001` vs `81600.0000.00.19.17.10`. A catalog entry keyed on `ID_REVISION` would **not** be firmware-keyed, so it could not distinguish two firmware builds of one SKU. Pinned by `ingestion.hardware.test.ts` | + +**B2 and B6 gate stage 1. B3 and B4 additionally gate stage 2. B5 gates the review step for +every stage.** A run that reports a `CERTIFY OK` line with `synthetic=true`, or with an empty +`sku`, is **not** a passing RB-11…RB-15 — the gate is the `synthetic=false` line *and* a +bundle whose `sku` is populated. + +> **Query-only AT sessions are safe; SET forms are not.** The 2026-08-18 pass established +> that a read-only AT survey needs no ModemManager inhibit on this bench: MM held only the +> Quectel's `ttyUSB7`/`ttyUSB8` open, so every other AT port was free. Restrict such a +> session to bare execute commands (`ATI`), READ forms (`AT+X?`), and TEST forms (`AT+X=?`) +> — the TEST form returns a parameter range and assigns nothing (ITU-T V.250 §5.4.1). A SET +> form (`AT+X=`) is a certification-gated mutation and must never appear in a survey. ### The shared certify step diff --git a/docs/CATALOG-INGESTION.md b/docs/CATALOG-INGESTION.md index a77bd70..fe0e47b 100644 --- a/docs/CATALOG-INGESTION.md +++ b/docs/CATALOG-INGESTION.md @@ -225,3 +225,25 @@ const comment = renderPromotionReview({ Post `comment` on the PR that proposes the catalog change. A refused promotion still renders a comment — a silently-absent comment is indistinguishable from a forgotten run. + +--- + +## Bench reality, 2026-08-18 — this path does not yet reach the catalog + +A non-mutating capture pass against the SIMCom SIM7600G-H and the carrier-mounted Fibocom +FM350-GL ([`COMPOSITION-EVIDENCE.md`](COMPOSITION-EVIDENCE.md)) ran the real +`modem-control certify` on real hardware. Both runs printed a valid +`CERTIFY OK … synthetic=false` line, and **neither bundle was promotable**. Three blockers +sit between `certify` and the seam above; all three are recorded in [`BENCH.md`](BENCH.md) +and the first two are pinned by +[`../control/src/usb-mode/ingestion.hardware.test.ts`](../control/src/usb-mode/ingestion.hardware.test.ts): + +| Blocker | Effect on this document's flow | +|---|---| +| **B2** — `certify` matches its USB device by `ifname`, which `parseUdevDatabase` never populates | the bundle arrives with **no `sku`** and **empty `udevProperties`**, so `buildCatalogEntryCandidate` and `buildClassifierFixture` both refuse `sku-missing`. The refusal is correct; the capture is what is broken | +| **B5** — the shared redactor masks ICCID / IMSI / EID but **not** `imei` / `equipment-identifier` | a real bundle's `modemManager` half carries the IMEI of every modem on the bench, because `GetManagedObjects` is fleet-wide. **"Post `comment` on the PR" is unsafe for a real bundle today**, and a real bundle must not be committed as a fixture. The `usb.lsusb` / `usb.usbDevices` halves are IMEI-free and safe to quote | +| **B6** — `skuOf` reads `firmwarePrefix` from udev `ID_REVISION` (the USB `bcdDevice`) | a promoted entry would be keyed on `0318` / `0001` rather than on `LE20B04SIM7600G22` / `81600.0000.00.19.17.10`, so it could not distinguish two firmware builds of one SKU — defeating the point of a firmware-keyed catalog | + +None of these is a flaw in the seam itself: the refusals above are exactly the typed, +loud behaviour this document promises. They are defects in the **capture** step, and until +they are fixed no SKU can reach `certified-catalog.json` from bench evidence. diff --git a/docs/COMPOSITION-EVIDENCE.md b/docs/COMPOSITION-EVIDENCE.md new file mode 100644 index 0000000..a9e913d --- /dev/null +++ b/docs/COMPOSITION-EVIDENCE.md @@ -0,0 +1,153 @@ +# Composition evidence — SIMCom SIM7600G-H and Fibocom FM350-GL + +A **non-mutating** bench capture of what these two units actually expose: USB descriptors, +driver bindings, firmware revisions, and the read-back state of each vendor's USB-mode +command. Captured on `ceralive2` (RK3588, kernel `7.1.7-ceralive-rk3588`, Debian 12, +packaged ModemManager `1.24.2-2~ceralive0.2.0`) on **2026-08-18**. + +**Neither unit is certified by this document, and neither gains a catalog entry.** This is +the evidence half only. `certified-catalog.json` is unchanged; two guard tests in +[`ingestion.hardware.test.ts`](../control/src/usb-mode/ingestion.hardware.test.ts) fail the +build if either SKU is promoted without going through +[`CATALOG-INGESTION.md`](CATALOG-INGESTION.md). + +## What "non-mutating" means here, precisely + +Every AT command sent was one of three read-only forms: + +| Form | Example | Effect | +|------|---------|--------| +| bare execute | `ATI` | identifies the module | +| READ | `AT+GTUSBMODE?` | returns the current value | +| TEST | `AT+GTUSBMODE=?` | returns the supported parameter range (ITU-T V.250 §5.4.1) | + +**No SET form (`AT+X=`) was sent to either unit.** The capture harness carried a +guard that refused any command containing `=` unless it was exactly `=?`. Each unit's +`idVendor`/`idProduct` was read from sysfs immediately before and after its AT session and +was **unchanged** in both cases — `1e0e:9001` → `1e0e:9001`, `0e8d:7127` → `0e8d:7127`. + +No modem was enabled, no SIM operation was attempted (neither unit has a SIM), no USB port +was power-cycled, and no ModemManager inhibit was taken. MM held only the *Quectel's* +`ttyUSB7`/`ttyUSB8`, so the AT ports used here were free. + +--- + +## SIMCom SIM7600G-H R2 — `1e0e:9001` + +| Field | Value | +|-------|-------| +| USB id | `1e0e:9001` (`bcdDevice` 3.18) | +| sysfs / udev `ID_PATH` | `1-1.3.4` / `platform-xhci-hcd.0.auto-usb-0:1.3.4` | +| Device descriptor | `bDeviceClass=00` (class is per-interface), 6 interfaces | +| Drivers | 5 × `option` + 1 × `qmi_wwan` | +| MM plugin / primary port | `simtech` / `cdc-wdm0` (QMI) | +| MM state | `failed` / `sim-missing` (no SIM in either slot) | +| Classifier verdict | `mm-managed`, mode `qmi` | +| Firmware (`AT+CGMR`, `mmcli`) | `LE20B04SIM7600G22` | +| Sub-version (`AT+CSUB`) | `B04V03` / `MDM9x07_LE20_G-H_22_V1.16_221104` | + +### The USB-mode command — domain read back, tuple NOT proven + +``` +AT+CUSBPIDSWITCH? ++CUSBPIDSWITCH: 9001 + +AT+CUSBPIDSWITCH=? ++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) +``` + +This is the first **device-sourced** statement of the command's shape. It establishes two +facts that were previously guesses: + +1. **Arity is three**, not one: `AT+CUSBPIDSWITCH=,,`, with both trailing + parameters in `(0-1)`. Vendor documentation commonly describes these as a + *save-to-NV* flag and a *reset-now* flag, **but this capture does not establish that** — + the module reports only the ranges, not the semantics. +2. **The PID domain is the 26 values listed above**, and the unit is currently on `9001`. + +What it does **not** establish, and what nothing on this bench establishes: + +- **which** PID yields which composition (QMI vs MBIM vs ECM vs RNDIS); +- what the two trailing flags actually do; +- whether any target composition survives a power cycle; +- whether a wrong tuple is recoverable without a vendor tool. + +Discovering that would require sending SET forms — a mutation, deliberately out of scope +for this pass. **Consequence: every SIM7600G-H target mode stays UNCERTIFIED and the mode +control stays HIDDEN.** Certification requires the exact tuple proven with a working +recovery path and ≥10 cold-boot persistence cycles; none of that has been attempted. + +`AT+CUSBSPEED?` and `AT+CUSBSPEED=?` both answer `ERROR` — this firmware has no such +command, so USB-speed selection is not a control surface on this unit. + +--- + +## Fibocom FM350-GL on an M.2→USB carrier — `0e8d:7127` + +> **Read [`FM350-DECISION.md`](FM350-DECISION.md) before acting on anything here.** The +> `0e8d:7127` identity belongs to the **bench carrier**, not to a native FM350 USB +> personality; a production board seats this module in a real M.2 **PCIe** slot. That +> decision is unchanged by this capture, and **no USB classifier entry is added for +> `0e8d:7127`.** Everything below is classified strictly as *USB-attachment-only* evidence. + +| Field | Value | +|-------|-------| +| USB id | `0e8d:7127` (`bcdDevice` 0.01) — MediaTek's **USB** vendor id, not the PCI `14c3` | +| sysfs / udev `ID_PATH` | `1-1.2` / `platform-xhci-hcd.0.auto-usb-0:1.2` | +| Device descriptor | `bDeviceClass=ef` / `Sub=02` / `Prot=01` (IAD composite), 10 interfaces | +| Drivers | 2 × `rndis_host`, 7 × `option`, 1 × unbound (`ff/42/01`) | +| MM plugin / primary port | `generic` / `ttyUSB12` (AT) | +| MM state | `failed` / `sim-missing` | +| Classifier verdict | `mm-managed`, mode `rndis` | +| Firmware (`AT+CGMR`, `mmcli`) | `81600.0000.00.19.17.10` | +| Package version (`AT+GTPKGVER?`) | `81600.0000.00.19.17.10_5001.0000.030.000.026_B77` | + +### The USB-mode command — read back cleanly + +``` +AT+GTUSBMODE? ++GTUSBMODE: 41 + +AT+GTUSBMODE=? ++GTUSBMODE: (40,41) +``` + +This **corroborates the kernel-sourced research** that the FM350's mode command is +`AT+GTUSBMODE` with values 40 and 41, and adds what the research could not: the module on +this bench reports exactly those two values as its supported set and is currently on **41**. + +Still not established, and not attempted: + +- which of 40 / 41 corresponds to which composition (the unit is enumerating RNDIS + AT + today, but that is not attributed to mode 41 by any evidence here); +- persistence, recovery, or behaviour of a mode change of any kind. + +**Consequence: no mode control is offered for the FM350 in any topology.** For the native +PCIe attachment the exclusion is a standing decision (`FM350-DECISION.md`); for this +USB-carrier attachment it is simply uncertified. + +`AT+GTFLAGS?` answers `+CME ERROR: 100` (not supported on this firmware). `AT+CFUN?` +reports `1` (full functionality) for both units' equivalent query. + +--- + +## Why no bundle from this capture is promotable + +Both units were captured with the real `modem-control certify` CLI, and both produced a +valid `CERTIFY OK … synthetic=false` line. Neither bundle can be promoted, for reasons that +are **defects in the capture pipeline, not gaps in the hardware evidence** — see blockers +**B2**, **B5**, and **B6** in [`BENCH.md`](BENCH.md): + +- the bundles carry **no `sku` and empty `udevProperties`** (B2), so the ingestion seam + refuses them `sku-missing`; +- they carry **every bench modem's IMEI** in the `modemManager` half (B5), so they cannot be + committed here or pasted into a review comment; +- the `firmwarePrefix` a fixed capture would carry is the USB `bcdDevice`, not the modem + firmware revision (B6). + +The raw bundles and full transcripts therefore live in the local orchestration scratch +directory, not in this repository. What is reproduced in +[`ingestion.hardware.test.ts`](../control/src/usb-mode/ingestion.hardware.test.ts) is the +IMEI-free half — the verbatim `usb-devices` records and udev property maps — which is +enough to pin every descriptor and driver binding above as an executable regression test. diff --git a/docs/DOMAIN-CONTRACTS.md b/docs/DOMAIN-CONTRACTS.md new file mode 100644 index 0000000..f2a3312 --- /dev/null +++ b/docs/DOMAIN-CONTRACTS.md @@ -0,0 +1,83 @@ +# Modem control domain contracts + +The additive v1.1 domain surface lives in `control/src/domain/` and is re-exported by the +existing package root. The v1.0 identity, snapshot, policy, ports, backend, and transport +exports remain unchanged. + +## Physical identity + +`resolvePhysicalModemIdentity()` applies one precedence order: serial, then udev `ID_PATH`, +then an opaque fallback capped at 128 characters. It returns a branded `PhysicalModemId`, a +branded `StableKey`, and the selected `PhysicalIdentitySource`. + +Canonical physical ids are prefixed `serial:`, `id-path:`, or `fallback:`; stable keys add +the `modem:` namespace. `physicalModemId()` and `stableKey()` refuse values shaped like a +ModemManager object path, network interface, IP address, IMEI, ICCID/IMSI/EID, or an +unbounded fallback. Runtime MM paths, ifnames, addresses, and subscriber/equipment ids are +observations, never physical identity. + +## Lifetimes and observations + +`DeviceGeneration` is a non-negative monotonic generation. Call +`nextDeviceGeneration()` whenever a physical modem re-enumerates or its selected provider +is replaced. Every asynchronous observation and operation completion carries the generation +it started under; `isCurrentGeneration()` identifies stale work. + +`ObservationEnvelope` always carries `stableKey`, `generation`, `source`, `sourceEpoch`, +`observedAt`, `freshness`, `authority`, and `value`. Fresh and stale observations retain a +typed value. Unavailable observations are a separate discriminated state with `value: null`; +they cannot be confused with stale data or with a boolean freshness flag. + +The layer that PRODUCES these envelopes — normalization, per-metric provenance, freshness +evaluation, and the desired/applied/observed split — is `control/src/observations/`, +documented in `AGENTS.md` § "OBSERVATIONS". It builds on this contract and does not redefine +it: `ObservationEnvelope`, `SourceEpoch`, `DeviceGeneration` and `StableKey` stay frozen +exactly as described above. + +## Operations + +`OperationDescriptor` records independent read/write support, provider authority, +input constraints, live preconditions, typed availability, mutation impact, retry class, +readback/rollback/journal/admission requirements, profile and firmware evidence, and +confidence. `defineOperationDescriptor()` refuses automatic retry unless the operation is a +supported read classified `idempotent-read`. + +`classifyOperationCompletion()` maps results to `applied`, `refused`, `unknown-outcome`, or +`failed`. A stale-generation completion is always `unknown-outcome`. A timed-out or dropped +write reply is also `unknown-outcome`, even when the generation is current. Every unknown +outcome has `requiresReconciliation: true`; callers must reconcile that modem before another +mutation. `canAutoRetry()` returns true only for a failed, supported, explicitly idempotent +read. + +`createOperationEngine()` is the executable form of those contracts. It receives a composition +root, a live generation reader, and an `OperationPreconditionPort`. Mutations enter the root's +shared physical-modem actor before the engine re-reads preconditions and admission, so a queued +write cannot act on an earlier check. Unsupported, unavailable, out-of-constraint, or missing +required-hook requests are typed refusals and never reach the provider. + +The engine keeps a reconciliation-required gate per `PhysicalModemId`. Any classified +`unknown-outcome` closes that modem's mutation gate; later writes return +`reconciliation-required` without calling their executor. `engine.reconcile()` runs through the +same actor and clears the gate only after reconciliation succeeds without a generation change. +Reads remain outside the write queue, and only a failed descriptor-classified idempotent read gets +one automatic retry. Required journal, readback, and rollback hooks are invoked from the +descriptor-defined lifecycle; rollback is never guessed after an unknown outcome. + +## Admission, ownership, and actors + +`MutationAdmissionPort` consumes the descriptor's `admission` requirement without adding a +second policy vocabulary. Required admission with no injected authority is the typed refusal +`admission-port-missing`. The interface contains no stream concept; the embedding controller +owns whatever policy backs its decision and releases the returned lease. + +`ResourceOwnershipPort` is acquire-or-refuse, never queued. File-backed stores, router +sessions, and USB-hub access require it. `createFlockResourceOwnershipPort({ lockPath })` +provides the Linux default with non-blocking `flock` and holder PID/start-time metadata; the +path is mandatory input and the exported `/run/ceralive/modem-control.lock` value is a caller +convention, not an implementation hardcode. Kernel lock lifetime and PID liveness make a +dead holder recoverable without stealing from a live process. + +`createModemControlCompositionRoot()` enforces one live root per process and owns a +per-`PhysicalModemId` actor registry. Repeated `actorFor()` calls for the same physical modem +return one shared `ModemActor`. `ModemManagerInhibitPort` narrows MM maintenance inhibition; +`UhubctlPort` is an injected contract with no implementation in the v1.1 control package. diff --git a/docs/ESIM-DECISION.md b/docs/ESIM-DECISION.md index 7c32a96..67bdd6f 100644 --- a/docs/ESIM-DECISION.md +++ b/docs/ESIM-DECISION.md @@ -1,7 +1,15 @@ # eSIM Decision Record (Investigate-Only) -**Status:** Investigation complete. Implementation explicitly deferred by user decision, 2026-08-13. -This document is the exit artifact for that investigation — see §8. +**Status:** **`blocked` — no eUICC-capable hardware exists on the fleet.** No eSIM code +ships and the feature gate stays off. + +| Date | Decision | Recorded in | +|---|---|---| +| 2026-08-13 | Implementation **deferred** by user decision; investigation closed as investigate-only. | §8 | +| 2026-08-18 | Deferral **reversed** by user decision, then closed **`blocked`** on a named hardware gap: no bench modem exposes an eUICC. The adoption spike could not run; its **licensing half did**, and is recorded. | §9 | + +§1–§7 are the 2026-08-13 research and stand unchanged. §8 records the first decision; +**§9 is the current one and supersedes §8's "that decision stands."** ## Why this document exists @@ -325,3 +333,190 @@ eSIM work is picked back up, it starts from verified findings instead of re-deri Nothing in this document should be read as a task list, a roadmap, or a set of "next steps to build." It is a closed research record. + +> **Superseded on 2026-08-18 — see §9.** The deferral above was reversed by user decision, +> and eSIM re-entered scope as a hardware-gated adoption spike. That spike could not run, +> so the outcome is not "still deferred by choice" but **`blocked` by a measured hardware +> gap**. The distinction matters: §8 is a scope decision, §9 is a hardware fact. + +--- + +## 9. 2026-08-18 — reversal, then `blocked`: no eUICC target exists; licensing review completed + +**Closing state: `blocked (named hardware gap from todo 2, B5)`.** + +This is one of the four legal outcome states for the phase's gated capability modules, and +eSIM is the only module permitted to use it. A `blocked` closure does **not** halt the +release wave — the feature gate simply stays off and this document says so truthfully. + +### 9.1 The hardware gap (the reason) + +The phase's hardware-prerequisites gate measured the entire bench fleet live on the board +(RK3588, ModemManager 1.24.2) and recorded item (b) as **BLOCKER B5 — no eUICC/eSIM-capable +modem on the fleet**: + +| Check | Measured result | +|---|---| +| SIMs present across the whole bench | exactly **one** — on the Quectel RM530N-GL. Every other modem reads `sim-missing`. | +| That SIM's ModemManager property set | `active`, `imsi`, `iccid`, `operator id` — **no `eid`** | +| Interpretation | MM 1.24.2 reports an `eid` property for an eUICC. Its absence is **positive evidence of a classic removable UICC**, not merely missing data. | +| RM530N-GL eUICC capability | **UNPROVEN** for this unit and firmware (`RM530NGLAAR05A01M4G`). Consistent with §6: Quectel's own support staff state the module "doesn't have eSIM… it should be physical eSIM installed in SIM slot or soldered MFF2 SIM." | +| Any other fleet modem with an eUICC | none — a `sim-missing` modem cannot present one | + +Nothing on the bench can hold, receive, or report an eUICC profile. + +### 9.2 What that does to the spike — `blocked`, which is NOT a NO-GO + +The spike had four questions: lpac↔ModemManager coexistence, device/APDU access on a +certified eUICC target, arm64 packaging, and licensing. Three of the four are hardware +questions: + +| Spike step | Status | Why | +|---|---|---| +| lpac builds/runs on arm64, version captured | **not run** | Executable in isolation, but it is not evidence *toward the verdict* — it would prove a binary starts, not that lpac can reach an eUICC or share the port with MM. Running it would mean installing an AGPL-3.0 binary on the bench to answer a question nobody asked. | +| APDU/QMI channel opens against the certified eUICC target, MM inhibited | **impossible** | There is no eUICC target to open a channel to. | +| Port arbitration — MM regains the modem cleanly after lpac exits | **impossible** | Depends on the step above having run. | +| Licensing review | **COMPLETE** — §9.3 | Research, not hardware. It ran. | + +**Therefore no GO/NO-GO verdict is recorded, and none may be inferred.** A NO-GO would be a +technical judgment that lpac cannot work here; nothing was measured that would support such +a judgment. The spike did not resolve — it could not start. That is precisely what `blocked` +means, and it is why this todo closes `blocked` rather than `re-deferred` (a scope choice) or +`NO-GO` (a verdict). + +**Consequence, stated explicitly:** with no GO verdict, none of the GO-path delivery ships. +There is **no** `ceralive-lpac` `.deb`, **no** row added to the packaging closure, **no** +release-manifest entry, **no** apt publication, and **no** image package pin. The +modem-stack release manifest stays at `closure_version: 2` with its frozen matrix, untouched +by this decision. + +### 9.3 Licensing review — the half that could run (and did) + +Re-verified against upstream on **2026-08-18**, not carried over from §5. + +**Primary sources, fetched this session:** + +- `REUSE.toml` at [`estkme-group/lpac@main`](https://raw.githubusercontent.com/estkme-group/lpac/main/REUSE.toml) + — confirmed **unchanged** from what §5 recorded on 2026-08-13: + + | Path glob | SPDX identifier | + |---|---| + | `src/**`, `driver/**`, `utils/**` | **`AGPL-3.0-only`** | + | `euicc/**` | `LGPL-2.1-only OR LicenseRef-ESTKME-Commercial` | + | `docs/**`, `cmake/**`, `CMakeLists.txt`, `README.md`, `.github/**`, tool configs | `MIT` | + | `cjson-ext/**` | `MIT` | + | `dlfcn-win32/**` | `MIT` (Ramiro Polla + contributors) | + +- GitHub's own repository metadata + ([API](https://api.github.com/repos/estkme-group/lpac)) reports + `license.spdx_id: "AGPL-3.0"` for the project as a whole. The + `/license` endpoint returns **404** — there is no single detectable top-level `LICENSE` + file, because the project is REUSE-structured rather than single-licensed. +- [`LICENSES/`](https://github.com/estkme-group/lpac/tree/main/LICENSES) carries six license + texts: `AGPL-3.0-only`, `LGPL-2.1-only`, `GPL-2.0-only`, `MIT`, `CC0-1.0`, and + `LicenseRef-ESTKME-Commercial`. That last one reads, in full: *"Non-public commercial + license, please contact ESTKME TECHNOLOGY LIMITED, Hong Kong for details via + inquiry@estk.me."* It is the paid alternative to LGPL-2.1 for `euicc/**` and is + irrelevant to us — LGPL-2.1-only is acceptable, so the commercial branch never applies. + +**Verdict 1 — the program logic is AGPL-3.0-only, so lpac can only ever be an external +process.** This re-confirms §5's boundary against live upstream state: lpac must be +spawned as a separate binary over its CLI, and must **never** be linked, statically +embedded, or otherwise combined into `cerastream`, `CeraUI`, or +`@ceralive/modem-control`. None of those are AGPL-licensed and none may become so. + +**Verdict 2 — redistributing lpac is permitted, but it is not free of obligations.** AGPL-3.0 +is a free-software license and Debian carries lpac in `main` (§9.4), so redistribution is +clearly allowed. Two obligations attach: + +- **Corresponding Source, from the same place (AGPL-3.0 §6).** Conveying object code + requires the Corresponding Source under one of §6(a)–(e); for network distribution the + applicable option is **§6(d)** — equivalent access to the source *at the same place*, at + no further charge. `apt.ceralive.tv` today publishes **binary indexes only** + (`binary-arm64` / `binary-amd64`); there is no source channel in the closure at all. So + publishing an lpac `.deb` there would require **adding a source channel** (or an + equivalent §6(b) written offer) before the first upload — an infrastructure precondition, + not a paperwork detail. Recording it now means a future GO does not discover it late. +- **§13 attaches only to *modified* versions.** AGPL-3.0 §13's obligation to offer + Corresponding Source to users interacting with the program remotely over a network is + written against a version *you modified*. Shipping lpac **unmodified** and driving it by + subprocess does not trigger that clause. Patch it even once, however, and the CeraUI web + surface driving it makes §13 a live, continuous obligation. **Policy, therefore: ship + lpac unmodified or not at all** — which happens to be exactly what `POLICY.md`'s no-fork + rule already demands of every package in `packaging/`. The licensing constraint and the + existing repo policy point the same direction; no new rule is needed. + +**Observation (flagged, not adjudicated) — one vendored file is `GPL-2.0-only`.** +[`utils/lpac/list.h`](https://github.com/estkme-group/lpac/blob/main/utils/lpac/list.h) +carries an inline `SPDX-License-Identifier: GPL-2.0-only` with +`SPDX-FileCopyrightText: Linux Project` — the kernel's linked-list header — inside a +directory that `REUSE.toml` annotates as `AGPL-3.0-only`. GPLv2-only is +[documented by the FSF](https://www.gnu.org/licenses/gpl-faq.html#v2v3Compatibility) as +incompatible with GPLv3-family terms for combination into a single work. This is an +**upstream** question, not a CeraLive one, and Debian's acceptance of lpac into `main` +strongly suggests ftp-master's copyright review already addressed it. It is recorded here +so a future GO-stage review **reads Debian's `debian/copyright` for `lpac`** instead of +re-deriving the analysis — and does not treat §9.3 as having cleared it. Nothing here is +legal advice; a real adoption decision gets counsel review of the whole file set. +(`CC0-1.0.txt` is likewise present in `LICENSES/` with no annotation naming it; the files +it covers were not located in this review.) + +### 9.4 Shipping form — decided in principle, moot in practice + +The todo made the licensing verdict the input that decides the shipping form. It does, but +so does availability, and the availability answer is the one that changed since §5. + +**lpac is now packaged in Debian — but not in a suite we ship.** + +| Channel | State (checked 2026-08-18) | Source | +|---|---|---| +| Debian archive | source `lpac` **2.3.0-1** in `main`, binaries **amd64 / arm64 / armhf** — present in **testing + unstable only**; **absent from bookworm and trixie** | [tracker.debian.org/pkg/lpac](https://tracker.debian.org/pkg/lpac) | +| Ubuntu | source `lpac` 2.3.0-1 in `universe`, amd64 / arm64 / armhf | [launchpad.net/ubuntu/+source/lpac](https://launchpad.net/ubuntu/+source/lpac) | +| Upstream release artifacts | **v2.3.0 (2025-08-15)**, including Linux **aarch64** ZIPs and an upstream-built **`lpac_2.3.0_arm64.deb`** | [releases/tag/v2.3.0](https://github.com/estkme-group/lpac/releases/tag/v2.3.0) | +| Others | Alpine `community` (aarch64), OpenWrt `packages` feed (2.3.0-2), Nixpkgs 2.3.0, AUR (`lpac`, `lpac-git`). **Absent** from Arch official repos and from Fedora. | [Alpine](https://pkgs.alpinelinux.org/package/v3.20/community/aarch64/lpac) · [OpenWrt](https://github.com/openwrt/packages/blob/master/utils/lpac/Makefile) · [Nixpkgs](https://github.com/NixOS/nixpkgs/blob/master/pkgs/by-name/lp/lpac/package.nix) · [AUR](https://aur.archlinux.org/packages/lpac) | +| Build from source | CMake (`cmake -B build`; `-DSTANDALONE_MODE=ON` for a relocatable install). Build deps per Debian's control: `cmake`, `libcurl4-openssl-dev`, `libpcsclite-dev`, QMI/QRTR dev libraries, `zip`. | [docs/DEVELOPERS.md](https://github.com/estkme-group/lpac/blob/main/docs/DEVELOPERS.md) · [Debian control](https://tracker.debian.org/media/packages/l/lpac/control-2.3.0-1) | + +Ranked the same way the bench's `uhubctl` delivery decision was ranked — stock distro +package first, first-party build only if that fails: + +1. **Stock Debian package — preferred, but currently unavailable to us.** Ruled out by + *suite*, not by licence: 2.3.0-1 exists only in testing/unstable, and this stack's + packaging targets bookworm. Revisit on any suite move; this is the option that would + cost the least. +2. **Upstream's own `lpac_2.3.0_arm64.deb` — rejected as a shipping form.** It is not built + by us, not covered by `packaging/upstream-pins.yaml`'s four-link provenance chain, and + not signed by our key. Adopting it would put a binary into the device image through a + path every other `.deb` in this repo is forbidden to use. +3. **First-party bookworm rebuild in `modem-stack/packaging/` — the recommended form on a + future GO.** It is not a new model: Debian now maintains a `debian/` recipe (2.3.0-1) + that can be pinned and rebuilt for bookworm exactly like ModemManager, libmbim, libqmi, + and libqrtr-glib already are — same zero-patch rule, same provenance chain, same + two-set package model. The one genuinely new requirement is the AGPL §6(d) source + channel from §9.3. + +**All three are moot today.** No GO verdict exists, so nothing ships, and this subsection +is a decision recorded *in advance* for whoever picks eSIM back up — not authorization to +build anything. + +### 9.5 What would unblock this + +The gate is one piece of hardware. Two ways to clear it, cheapest first: + +1. **A removable eUICC card in an existing bench modem.** Per §4 path 1, a programmable + eUICC in plastic form factor (eSTK.me, ST4SIM, or similar) dropped into the Quectel + RM530N-GL's SIM slot gives the bench a real EID to read — no new modem required. It also + unlocks a second, better-behaved route: with a **PC/SC reader**, lpac's *default* `pcsc` + backend reaches the card entirely outside ModemManager, which sidesteps the port- + arbitration question for the inventory/EID half of the spike and isolates the + coexistence question to the modem-slot path only. +2. **A modem with a genuine on-board eUICC**, per §6's reality table — noting that entry's + central warning: eUICC presence is a per-SKU/per-firmware fact, so a datasheet claim is + not acceptance. The unit still has to clear §7's checklist. + +With either in hand, §7 step 1 (EID read) becomes runnable, and the spike's three blocked +steps become answerable. Until then this record stands as written. + +**The question put to the user, verbatim:** *"No modem on the bench exposes an eUICC — the +only SIM present reports no `eid`, and RM530N-GL eSIM capability is unproven for this unit. +Can you supply an eUICC-capable modem (or confirm the RM530N-GL variant on the bench has +one)? Otherwise eSIM closes `blocked` with its feature gate off and the docs stating so."* diff --git a/docs/FCC-UNLOCK-COVERAGE.md b/docs/FCC-UNLOCK-COVERAGE.md new file mode 100644 index 0000000..1675ffb --- /dev/null +++ b/docs/FCC-UNLOCK-COVERAGE.md @@ -0,0 +1,207 @@ +# FCC-unlock coverage matrix + +Which modems ModemManager can FCC-unlock, and which of them CeraLive actually has. + +**Status:** `[EXISTS]` — the matrix below is derived from ModemManager **1.24.2** +(`packaging/upstream-pins.yaml`), the exact release this repository rebuilds, and +from the live bench inventory captured on `ceralive2` (2026-08-17). Nothing here is +recalled or inferred from documentation. + +--- + +## 1. What an FCC unlock is, and what ModemManager does about it + +Some modems ship from the factory in a state where the radio refuses to transmit +until a vendor-specific unlock command is sent. The lock is a **regulatory** +(FCC-certification) interlock, not a SIM lock, and it **re-applies on every power +cycle** — a modem that needs it needs it again after every boot. + +Since **1.18.4** ModemManager no longer performs that unlock automatically. It +ships the vendor procedures as *disabled* scripts and requires an explicit, +per-device opt-in. There are **three directories**, and only the last two are +consulted at runtime: + +| Tier | Path (Debian) | Role | +|---|---|---| +| **available** | `/usr/share/ModemManager/fcc-unlock.available.d/` | shipped-but-INERT. ModemManager owns it. Nothing here ever runs. | +| **enabled — admin** | `/etc/ModemManager/fcc-unlock.d/` | the ADMIN opt-in tier. **This is the tier CeraLive uses.** | +| **enabled — package** | `${libdir}/ModemManager/fcc-unlock.d/` (multiarch) | the tier a distribution package would own. **CeraLive ships nothing here, ever.** | + +The dispatcher (`src/mm-dispatcher-fcc-unlock.c`) builds ONE filename — + +```c +filename = g_strdup_printf ("%04x:%04x", vid, pid); +``` + +— and looks for exactly that name in the admin tier first, then the package tier. +**It consults no other name.** A vendor-only file (`2c7c`) is therefore never +opened by the dispatcher; it is only ever the *target* of a `:` link. + +### The available tier is 4 scripts and 14 links + +`data/dispatcher-fcc-unlock/` in ModemManager 1.24.2 contains four real scripts, +one per vendor, and the build installs a `:` symlink onto the right one +for every model the vendor script covers (`meson.build`, the `vidpids` dict). + +| Vendor script | Talks to the modem via | +|---|---| +| `105b` (Foxconn) | `mbimcli` | +| `1199` (Sierra Wireless) | `qmicli` | +| `14c3` (MediaTek / Fibocom) | `mbimcli` | +| `2c7c` (Quectel) | `qmicli` | + +**Runtime dependency — asserted, not assumed.** Those scripts call `qmicli` and +`mbimcli`, which are shipped by `libqmi-utils` and `libmbim-utils`. Both are +members of the frozen nine-package runtime closure this repository builds and +`apt-worker` publishes (`packaging/ci/expected-packages.txt`, +`apt-worker/scripts/modem-closure-lib.sh`), so the unlock path can never be +activated on a device that lacks its own interpreter. A device that somehow did +would see the dispatcher fail loudly rather than silently no-op. + +--- + +## 2. ModemManager 1.24.2's complete shipped mapping (14 entries) + +Every `:` ModemManager 1.24.2 can unlock. There are no others. + +| `:` | Vendor script | Device | +|---|---|---| +| `03f0:4e1d` | `1199` | HP lt4120 / lt4132 (Sierra silicon, HP-branded) | +| `105b:e0ab` | `105b` | Foxconn T77W968 (Fibocom L850-GL class) | +| `105b:e0c3` | `105b` | Foxconn T99W175 (Snapdragon X55 class) | +| `1199:9079` | `1199` | Sierra Wireless EM7455 / MC7455 | +| `14c3:4d75` | `14c3` | Fibocom FM350-GL — **PCIe/MediaTek identity** | +| `1eac:1001` | `2c7c` | Quectel-silicon OEM rebrand | +| `1eac:1004` | `2c7c` | Quectel-silicon OEM rebrand | +| `1eac:1007` | `2c7c` | Quectel-silicon OEM rebrand | +| `2c7c:030a` | `2c7c` | Quectel EM120R-GL | +| `2c7c:0313` | `2c7c` | Quectel EM160R-GL | +| `2c7c:0314` | `2c7c` | Quectel EM060K-GL | +| `2c7c:0801` | `2c7c` | **Quectel RM520N-GL / RM530N-GL** | +| `413c:81a3` | `1199` | Dell DW5811e (Sierra silicon, Dell-branded) | +| `413c:81a8` | `1199` | Dell DW5816e (Sierra silicon, Dell-branded) | + +**Vendor-branding is per-PID, and that is the trap.** Sierra silicon appears three +times under three different vendor ids (`1199`, `03f0`, `413c`) because HP and Dell +re-badge the USB descriptor. A rule keyed on the vendor id alone would miss two of +the three; a rule keyed on the model would miss the OEM rebrands. The dispatcher's +`:` key is the only correct one, which is why CeraLive's policy keys on +it too. + +--- + +## 3. The CeraLive bench fleet against that mapping + +Captured live on `ceralive2` (RK3588, kernel `7.1.7-ceralive-rk3588`, 2026-08-17 — +`.omo` todo-2 hardware gate). Every VID:PID below was read from the device, not +from a datasheet. + +| Device | `:` | Covered by MM 1.24.2? | Notes | +|---|---|---|---| +| **Quectel RM530N-GL** | `2c7c:0801` | ✅ **YES** — `2c7c` script | The one fleet modem with a shipped unlock procedure. | +| Fibocom FM350-GL (via M.2→USB adapter) | `0e8d:7127` | ❌ no | MM covers the FM350's **PCIe** identity `14c3:4d75`. On the USB adapter the carrier board re-enumerates it under MediaTek's `0e8d` vendor id, which is **not** in the mapping. See `docs/FM350-DECISION.md`. | +| SIMCom SIM7600G-H R2 | `1e0e:9001` | ❌ no | SIMCom ships no MM unlock script. | +| Huawei E3372 HiLink (twin A) | `12d1:14dc` | ❌ no | Router-mode dongle; no AT/QMI control port for a script to use. | +| Huawei E3372 HiLink (twin B) | `12d1:14dc` | ❌ no | Same model, same answer — the toggle is per MODEL, so the twins are inseparable by construction. | +| ZTE MF79U-class | `19d2:1405` | ❌ no | Router-mode dongle. | +| Qualcomm dual-mode stick (`2b16081`) | `05c6:9024` ⇄ `05c6:9091` | ❌ no | Generic Qualcomm reference ids; both compositions are uncovered. | +| Qualcomm dual-mode stick (`c6125db3`) | `05c6:9091` | ❌ no | Same. | + +**Coverage: 1 of 8 fleet devices.** That number is the reason this module is +capability-gated per model rather than offered device-wide: on seven of the eight, +turning it on would create a symlink the dispatcher would never open. + +--- + +## 4. Common market modems not in the fleet + +For operators buying hardware, the same matrix from the buyer's side. "Covered" +means an unlock procedure ships with ModemManager 1.24.2 and can be activated by +the CeraLive toggle. + +| Modem | `:` | Covered? | +|---|---|---| +| Quectel RM520N-GL / RM530N-GL | `2c7c:0801` | ✅ | +| Quectel EM160R-GL | `2c7c:0313` | ✅ | +| Quectel EM120R-GL | `2c7c:030a` | ✅ | +| Quectel EM060K-GL | `2c7c:0314` | ✅ | +| Sierra Wireless EM7455 / MC7455 | `1199:9079` | ✅ | +| Dell DW5811e / DW5816e | `413c:81a3` / `413c:81a8` | ✅ | +| HP lt4120 / lt4132 | `03f0:4e1d` | ✅ | +| Foxconn T77W968 / T99W175 | `105b:e0ab` / `105b:e0c3` | ✅ | +| Fibocom FM350-GL (native PCIe) | `14c3:4d75` | ✅ | +| Fibocom FM350-GL (USB carrier) | `0e8d:7127` | ❌ | +| Quectel EC25 / EG25-G | `2c7c:0125` | ❌ — not FCC-locked; no script needed | +| SIMCom SIM7600 family | `1e0e:9001` | ❌ | +| Huawei / ZTE router-mode dongles | `12d1:*` / `19d2:*` | ❌ | +| Telit LN940 / LM940 | `1bc7:*` | ❌ | + +A modem in the ❌ rows is not necessarily locked — most consumer dongles never +were. The absence of a script means only that ModemManager has no procedure to +run, so CeraLive offers no toggle. + +--- + +## 5. What CeraLive does with this + +### The policy of record lives in `/data`, not in `/etc` + +`/etc` is on the rootfs, and the rootfs is what a RAUC A/B slot swap REPLACES +(`image-building-pipeline/docs/partition-contract.md`). A symlink written into +`/etc/ModemManager/fcc-unlock.d/` therefore survives a reboot and does **not** +survive an OTA. `/data` is the only update-surviving store, so the durable record +is a file there: + +``` +/data/ceralive/fcc-unlock-policy.json mode 0600 +``` + +```json +{ "schemaVersion": 1, "savedAtMs": 1755500000000, "unlock": { "2c7c:0801": true } } +``` + +The symlink is a *derived artifact*, re-created from that file on every boot by +`ceralive-fcc-reconcile` (a oneshot ordered `Before=ModemManager.service`, with +`RequiresMountsFor=/data` and `After=ceralive-migrate-data.service` so it can never +race an unmounted or unmigrated `/data` and silently skip an enabled model). + +### Default-OFF is absolute + +- No key present ⇒ no symlink ⇒ no unlock. An absent policy file is exactly this. +- A malformed policy is treated as absent — fail-safe, never fail-open. A file we + cannot read must not be read as consent to touch a regulatory-locked radio. +- **CeraLive authors no unlock script.** Not in `/etc`, not in the multiarch + package tier, not anywhere. If one is ever needed it ships INACTIVE alongside + ModemManager's own `available.d` and is activated through the same per-model + opt-in — there is no second mechanism. +- Automation is confined to ModemManager's own dispatcher. There is no CeraLive + startup AT channel, and the AT-lease engine is not involved. + +### The toggle is per MODEL, and the UI says so + +The mechanism is a symlink named `:`, so it applies to **every attached +device matching that VID:PID** — the two Huawei twins on this bench are the shape +of the problem, and no per-unit refinement is possible without changing +ModemManager. The toggle is therefore modelled and labelled per model, and the +disclosure is part of the control rather than a footnote. + +### Enabling has no retroactive effect + +ModemManager runs the dispatcher during modem *initialization*. A modem already +enumerated when the toggle flips is not re-processed, so the toggle RPC performs a +re-probe (`mmcli -m --disable && --enable`) under the mutation lease; a replug +has the same effect. + +--- + +## 6. Certification status + +| Claim | State | +|---|---| +| The policy store, the reconciler and the per-model toggle behave as documented | proven by unit tests + the packaging chroot contract | +| The reconciler activates the right link on a real board before ModemManager starts | **bench-gated** — see `.omo/notepads/modem-phase-c-quality/evidence/todo33.md` | +| An actually-FCC-locked modem is unlocked on every boot | **BLOCKED — no such modem exists on this bench.** Todo 2's hardware gate `needs-user N2` recorded the search: three modems `failed`/`sim-missing`, one `searching`, and `journalctl -u ModemManager \| grep -iE 'fcc\|unlock'` produced only SIM-absence lines and **no FCC-lock line at all**. The FM350-GL — the likeliest candidate — cannot be distinguished while no SIM is present. The **no-op path** is what gets tested, which is the outcome this work anticipated. | + +The claim this documentation may make is therefore: *the opt-in mechanism is +implemented and its no-op path is proven.* It is not: *CeraLive unlocks +FCC-locked modems.* That second sentence needs an FCC-locked modem on a bench. diff --git a/docs/FM350-DECISION.md b/docs/FM350-DECISION.md index 959c466..366d66b 100644 --- a/docs/FM350-DECISION.md +++ b/docs/FM350-DECISION.md @@ -385,6 +385,35 @@ existing rule, USB enumeration alone does not promote support, matrix, or certif status. This note closes the Branch-A human-decision-required STOP; it does not retract or rewrite Citation 6. +### Non-mutating composition capture, 2026-08-18 — decision UNCHANGED + +A read-only capture pass re-verified the carrier-mounted unit on the same board and added +the first **AT-level** evidence for it. Full record: +[`COMPOSITION-EVIDENCE.md`](COMPOSITION-EVIDENCE.md). + +| Query | Response | +|-------|----------| +| `AT+CGMR` | `81600.0000.00.19.17.10` (matches `mmcli`'s `modem.generic.revision`) | +| `AT+GTPKGVER?` | `81600.0000.00.19.17.10_5001.0000.030.000.026_B77` | +| `AT+GTUSBMODE?` | `41` | +| `AT+GTUSBMODE=?` | `(40,41)` | + +Only bare-execute, READ (`?`), and TEST (`=?`) forms were sent; **no SET form**, and the +unit's `0e8d:7127` identity was read from sysfs before and after and was unchanged. + +**Nothing in this record changes.** Specifically: + +- **No USB classifier entry is added for `0e8d:7127`.** The Branch-A STOP closure above + stands: the id belongs to the M.2→USB carrier, not to a native FM350 USB personality. +- **The three-gate ledger is untouched** — gate 1 CLEARED, gates 2 and 3 OPEN. Reading a + mode register is not an end-to-end HIL pass, and the unit still has no SIM. +- **`docs/MODEM-SUPPORT-MATRIX.md` is unchanged.** Per the standing rule, USB enumeration — + and now an AT read-back — promotes no support, matrix, or certification status. +- **No mode control is offered for the FM350 in any topology.** The `(40,41)` domain + corroborates the kernel-sourced `AT+GTUSBMODE=40/41` research, but which value maps to + which composition, and whether a change persists or is recoverable, remains unproven and + was deliberately not tested. + ### Branch B — PCIe-only observed (`14c3:4d75` on the PCI bus, `mtk_t7xx` driver bound, no USB VID:PID) This CONFIRMS the mechanical rule's already-fired branch 3 (documented-deferred) with real diff --git a/docs/HUAWEI-HILINK-PROVIDER.md b/docs/HUAWEI-HILINK-PROVIDER.md new file mode 100644 index 0000000..efbf7d0 --- /dev/null +++ b/docs/HUAWEI-HILINK-PROVIDER.md @@ -0,0 +1,9 @@ +# Huawei HiLink provider + +`createHuaweiHiLinkDefinition()` returns the canonical `ProviderDefinition` and is exported through `./providers`; callers can register it with `createProviderRegistry().register(...)`. The definition has two exact, evidence-scoped profiles: `e3372h-22.200-password-type-3` (`22.200.05.00.1080`) and `e3372h-22.333-password-type-4` (`22.333.01.00.00`). Passive firmware evidence and the unauthenticated `SesTokInfo` shape select one candidate; `/api/user/state-login` must then report that profile's password type before the provider makes its single login attempt. A mismatch refuses the candidate instead of trying the other derivation. Unknown firmware receives no profile and no write surface. + +The type-3 profile sends base64 of the password. The type-4 profile sends base64 of the SHA-256 hexadecimal digest of `username + base64(SHA-256(password) hexadecimal digest) + session token`. Credentials are constructor input, stay in memory, and are absent from results, contract fixtures, and errors. Login responses may replace the session cookie and verification token; both remain transport-internal. + +Every transport request carries the selected device interface and `redirect: "error"`, which keeps duplicate-address HiLink units on the intended link. Reads use the authenticated cookie. POSTs additionally carry the current verification token and XML content type. Mode and data writes are independently capability-gated, acquire `router-session`, serialize by physical modem, and create a second authenticated session for readback before returning `applied`. A mode must be present in the freshly read mode list. Wi-Fi writes are absent and are never inferred from a read endpoint. HTTP 401/403 and HiLink `125002` become typed `auth-expired` refusals with no credential retry. + +The XML document parsers live in the pure `hardware/router-parsers` surface via `hilink-protocol.ts`; the provider does not maintain a second parser. Exact replay coverage for both profiles, profile mismatch, capability refusal, fresh readback, interface binding, redirect refusal, and mid-write expiry is in `control/src/providers/huawei-hilink/provider.test.ts`. diff --git a/docs/MF79U-DIAGNOSIS.md b/docs/MF79U-DIAGNOSIS.md new file mode 100644 index 0000000..e5c2e98 --- /dev/null +++ b/docs/MF79U-DIAGNOSIS.md @@ -0,0 +1,55 @@ +# 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 +retries, never tries the MF266 algorithm, and never writes Wi-Fi or modem configuration. + +## Preconditions + +- Connect one MF79U and identify its USB-network interface. +- In browser developer tools, capture the login request once, redact every value, and save + only this request-shape manifest outside the repository (for example under + `test-results/`): + + ```text + METHOD POST + PATH /goform/goform_set_cmd_process + HEADER content-type + HEADER origin + HEADER referer + FORM goformId + FORM isTest + FORM password + ``` + +- Install `curl`, `jq`, and GNU `base64`. Do not enable shell tracing. + +## One-attempt command + +Run from the repository root. Inject the password only into the command environment; do not +put it in a shell script, transcript, issue, or evidence file. + +```sh +read -rsp 'MF79U bench password: ' MF79U_BENCH_PASSWORD; printf '\n' +export MF79U_BENCH_PASSWORD +MF79U_INTERFACE=usb0 \ +MF79U_ADMIN_URL=http://192.168.0.1 \ +MF79U_REDACTED_CAPTURE=test-results/mf79u-login-shape.txt \ + control/scripts/mf79u-diagnose.sh | + tee test-results/mf79u-diagnosis.txt +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 + 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. + +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/MODEMMANAGER-PROVIDER.md b/docs/MODEMMANAGER-PROVIDER.md new file mode 100644 index 0000000..118af1b --- /dev/null +++ b/docs/MODEMMANAGER-PROVIDER.md @@ -0,0 +1,38 @@ +# ModemManager provider + +`ModemManagerProvider` is the concrete provider-registry adapter for ModemManager 1.20+. +It uses the package's typed D-Bus transport directly. Command-line clients such as `mmcli`, +`qmicli`, and `mbimcli` are diagnostics for an operator, never production transports. + +## Runtime discovery + +The provider resolves a physical modem from `ObjectManager.GetManagedObjects`, then derives its +generic controls from the interfaces and properties on that object. A modem does not need a catalog +entry to expose current mode, signal, SIM, multi-SIM, or power state. Missing runtime evidence +refuses only the affected control; it is not converted into an unsupported-model verdict. + +Certification is narrower: a band read is generic, while a band mutation is unavailable unless the +embedding process injects a positive certification decision. FCC auto-unlock likewise keeps using +the existing `:` coverage catalog because ModemManager's own modem object does not advertise +dispatcher coverage. + +## Existing modules composed + +- `backend/observer.ts` and `backend/managed-objects.ts`: authoritative snapshot and signal lifecycle. +- `backend/mm-backend.ts` and `backend/mm-mutations.ts`: radio, band, SIM, scan, and inhibit calls. +- `backend/mm-location.ts` plus `location/fix-state.ts`: `Location.Setup` with signaling disabled, + bounded acquisition, fix expiry, and no location history. +- `sms/dbus-messaging.ts`: permanently read-only list/read plus `Added`/`Deleted` events. +- `ussd/mm-ussd.ts`: bounded serialized USSD sessions. +- `band/` and `fcc/`: existing safety gates and catalogs. + +All composed mutations share one `ModemActor`. No provider surface creates, connects, or disconnects +a bearer; NetworkManager owns bearer and APN state. + +## Refusals and tests + +Typed D-Bus failures map to stable domain reasons instead of exposing daemon text. The private-bus +suite in `control/src/providers/modem-manager/modem-manager-provider.integration.test.ts` drives the +real fake-MM ObjectManager service. It covers unknown-model discovery, events, radio/signal/SIM/power, +GPS capability and privacy, SMS, USSD, certification-gated bands, and an injected +`Core.Unauthorized` error. `forbidden-subprocess.test.ts` is a non-vacuous production-source gate. diff --git a/docs/PROVIDER-MATCHING.md b/docs/PROVIDER-MATCHING.md new file mode 100644 index 0000000..ce83569 --- /dev/null +++ b/docs/PROVIDER-MATCHING.md @@ -0,0 +1,97 @@ +# Provider registration and evidence matching + +`control/src/providers/` is the provider-neutral selection layer for +`@ceralive/modem-control`. It contains no Huawei, ZTE, UFI/HIMI or other concrete provider. + +## Registration contract + +Each `ProviderDefinition` declares: + +- a stable `id` and `profileVersion`; +- eligible transports and passive USB, PCI, interface, driver, gateway, model and firmware facts; +- harmless unauthenticated fingerprint probes; +- optionally, one owner-selected authentication algorithm with `attemptLimit: 1`; +- capability evidence readers; +- `observe()` returning generation-fenced `ObservationEnvelope` values; +- `operations()` returning that provider's own capability-specific operations object; and +- sanitized request/response `contractFixtures` keyed by profile. + +Providers compose `ProviderReadOperations` and `ProviderWriteOperations` into their own +named operation surfaces. The registry sees only the common `access` marker. This preserves each +provider's distinctions instead of forcing unrelated capabilities into a universal interface. + +## Ordered matching + +`createProviderMatcher(registry).match(request)` always evaluates these stages in order: + +1. transport eligibility; +2. passive facts; +3. unauthenticated fingerprints; +4. profile candidate ranking; +5. at most one authentication call, using the registered algorithm; and +6. capability reads. + +Evidence strengths contribute `weak = 1`, `moderate = 2`, and `strong = 3`. Totals map to +`unsupported` (0), `maybe` (1), `likely` (2), and `supported` (3 or more). Transport eligibility +is a gate, not identity evidence. The result always exposes its evidence and conflicts. + +Only one unique `supported` candidate is selected. A tied top score or a candidate that remains +`maybe`/`likely` returns `ambiguous`; its provider, profile and operations are `null` and +`writable` is false. Ties stop before authentication, so matching never tries several providers' +credentials and never cycles algorithms after a failure. + +## Generation scope + +Selections are cached by physical modem plus registry revision, `DeviceGeneration`, transport, +passive facts, firmware and composition. Re-enumeration/reboot advances the generation; a firmware +or composition change changes the cache signature. Each case re-runs the complete evidence pipeline. + +## Conformance + +`control/src/providers/conformance.test.ts` is the generic provider conformance skeleton, backed by +the internal `control/test-support/provider-conformance-fixture.ts` fixture. It covers unsupported, +selected, weak, tied/read-only, colliding writable-provider, auth ordering and generation +re-evaluation paths without registering a real vendor implementation. + +### The conformance matrix — every real provider, registered at once + +The skeleton above uses a synthetic provider, and each concrete provider's own suite runs with +only itself in the registry. Neither shape can answer the question a fleet poses: with +ModemManager, Huawei HiLink, ZTE goform and UFI/HIMI **all registered**, does every device reach +exactly the provider and profile it is entitled to — and does no device reach one it is not? + +`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 +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. + +Three invariants hold across the whole matrix and are asserted as such: + +- **No case outside `fleet-profile` is ever writable.** Ambiguity, malformed input, an expired + session, a lockout and a misrouted request all resolve read-only or unresolved. +- **An unresolved decision carries no provider, no profile and no operations surface**, and its + evidence ledger is never empty. +- **No matcher result carries the credential the wire carried**, even though the transcript does. + +Two companion suites sit beside it: + +- `conformance-transcripts.test.ts` asserts the exact per-firmware wire — method, path, query, + form/JSON/XML body, the header ARRAY in order, and the cookie — rebuilt from the protocol in + `control/test-support/conformance/transcripts.ts` rather than read back from the provider. A + whole-array `toEqual` additionally pins the request COUNT, so a second login or a stray probe + fails even when the decision is unchanged. +- `conformance-scale.test.ts` is a **software upper-bound fixture at 16 concurrently attached + modems** — subscriptions stay fleet-wide, `Signal.Setup` is issued once per (epoch, modem) and + re-applied to every survivor on a new epoch, a burst of attachments is coalesced, and sixteen + concurrent matches each answer about their own modem. **The hardware-verified fleet size + remains 8**; 16 is a fixture result and must never be reported as a bench measurement. + +The corpus lives in `control/test-support/conformance/` (repo-local, unpublished) and reuses the +payload fixtures from `observation-fixtures.ts` rather than minting a second set. Sanitization is +structural: every credential is a declared literal that says it is not real, and any 14+ digit run +in the corpus or in a recorded request fails the suite unless it is a declared member of +`SANITIZED_SUBSCRIBER_IDENTIFIERS`. The matrix summary artifact is written to the gitignored +`test-results/provider-conformance-matrix.{md,json}`. diff --git a/docs/UFI-DIAG-PROBE.md b/docs/UFI-DIAG-PROBE.md new file mode 100644 index 0000000..575e9a4 --- /dev/null +++ b/docs/UFI-DIAG-PROBE.md @@ -0,0 +1,113 @@ +# UFI / HIMI supervised DIAG info probe — BENCH ONLY `[PARTIAL]` + +**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, +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 +`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. + +## What this probe may establish, and what it may not + +It may establish exactly one fact: **whether a DIAG interface descriptor exists on this +unit, and whether it answers a read-only information request.** + +It may not be used to read or write NV items, read or write EFS, read or write device +identity or RF calibration, flash firmware, enter or automate EDL, or re-bind a driver. +Those are the ids in `control/src/providers/ufi-himi/prohibitions.ts`; they are refused +with typed reasons by the provider and have no implementation anywhere. + +## The `9091` trap — read this before starting + +`05c6` is Qualcomm's generic vendor id and `05c6:9091` is a **firmware-chosen** product +id. It is routinely quoted as proof that DIAG is available. It is not: the product id is +picked by whoever built the firmware image and says nothing about which interfaces the +device exposes. `05c6:9024` is likewise evidence of a composition — RNDIS plus an ADB +interface — and not a permission; production never reaches this device over ADB, SSH, +telnet or DIAG under any circumstance. + +Only an interface descriptor proves a DIAG channel. `classifyUfiDiagEvidence()` encodes +that rule, and answers `not-proven` / `product-id-is-not-evidence` for a bare product id. + +## Preconditions + +- 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: + + ```sh + read -rsp 'UFI bench password: ' UFI_BENCH_PASSWORD; printf '\n' + export UFI_BENCH_PASSWORD + ``` + + Never put it in a script, a transcript, an issue, or an evidence file, and `unset` it + 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 + +```sh +lsusb -d 05c6: -v 2>/dev/null | + awk '/bInterfaceNumber|bInterfaceClass|bInterfaceSubClass|bInterfaceProtocol/' +``` + +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`. + +## Step 2 — read the telemetry the provider itself uses + +This is the READ that answers most questions, and it needs no DIAG at all: + +```sh +curl --interface usb0 --no-location -sS \ + -H 'Content-Type: application/json;charset=UTF-8' \ + -d "{\"cmdid\":\"login\",\"username\":\"admin\",\"password\":\"${UFI_BENCH_PASSWORD}\"}" \ + http://192.168.0.1/himiapi/json +``` + +Then re-issue with `"cmdid":"getsysinfo"` (and `getoverview`, `getallstatus`, +`getproduceinfo`) carrying the returned session in an `Authorization:` header. A +`{"reply":"SessionOut"}` means the session expired — record it and stop; do not retry the +login in a loop. **If this step answers what you came to find out, the probe is over.** + +## Step 3 — the supervised read-only info request + +Only with `diag-descriptor-confirmed`, only with the supervisor present, and only for an +**information** request — the command that asks the modem what it is, and writes nothing. + +Before running anything, state out loud (or in the transcript) the exact command and why +it cannot write. Abort on any of these: + +- the tool wants to enable a log mask, a packet filter, or any "capture" mode; +- the tool offers, or defaults to, an NV/EFS read-write session; +- the unit stops answering the HIMI HTTP API mid-probe; +- the supervisor is no longer watching. + +Record ONE classification: + +- `probe-read-only-ok` — the info request answered; note nothing but the classification. +- `probe-refused` — the channel exists but refused the request. +- `probe-aborted` — any abort condition above fired. + +## 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. + +## After the probe + +A `diag-descriptor-confirmed` result changes what a supervised bench operator may attempt +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. diff --git a/package.json b/package.json index af461a4..cddba03 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "modem-stack", - "version": "0.1.0", + "version": "1.1.0", "private": true, "type": "module", "description": "Cellular modem control for CeraLive — @ceralive/modem-control library, bench CLI, and ModemManager-stack .deb packaging (Phase A, standalone).", @@ -11,14 +11,15 @@ "cli" ], "scripts": { + "build": "bun run --filter '@ceralive/modem-control' build", "lint": "biome check .", "format": "biome format --write .", "typecheck": "tsc --noEmit", "test": "bun test" }, "devDependencies": { - "@biomejs/biome": "2.5.5", - "@ceralive/biome-config": "2026.6.2", + "@biomejs/biome": "2.5.8", + "@ceralive/biome-config": "2026.8.0", "@types/bun": "1.3.14", "typescript": "^5" } diff --git a/packaging/README.md b/packaging/README.md index 3a2f55c..9950715 100644 --- a/packaging/README.md +++ b/packaging/README.md @@ -5,7 +5,12 @@ patches** (see `POLICY.md` at the repo root). Bench devices install the resultin 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) +## 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). + | Source | Provides | |--------|----------| @@ -72,6 +77,103 @@ caller's `~/.gnupg`) and fails closed with a NAMED field on any drift. The curre ModemManager 1.24.2, libmbim 1.34.0, libqmi 1.38.0, libqrtr-glib 1.4.0 (salsa `debian/1.24.2-2`, `debian/1.34.0-1`, `debian/1.38.0-1`, `debian/1.4.0-1`). +## First-party companion: `ceralive-modem-support` + +`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. + +### Ownership boundary + +| Asset class | Owner | Why | +|---|---|---| +| CeraLive modem udev rules (identification/tagging only) | **companion** | generic; exact VID:PID + interface predicates, no device mutation | +| usb-modeswitch Zero-CD device data | **companion** | generic; mass-storage → modem composition only | +| FCC policy-reconciliation helper + its oneshot unit | **companion** | generic; reads an operator policy at runtime | +| Active FCC-unlock scripts | **nobody (none ship)** | todo 33: authored scripts ship inactive and activate only via the opt-in symlink | +| M.2 SIM quirk rows (`CERALIVE_BOARD_QUIRKS`) | **device image** | consumes build-time board facts a generic package cannot know | +| Per-slot modem UID rules (`modem_ports.status=verified`) | **device image** | same — hardware-verified per-board `ID_PATH`s | +| modem node permission/group policy | **device image** | a generic package must not mutate device nodes | + +### Installation table (per-asset, NOT a blanket `/usr/lib` rule) + +| Asset | Packaged destination | Admin override tier | +|---|---|---| +| udev rules | `/usr/lib/udev/rules.d/60-ceralive-modem.rules` | `/etc/udev/rules.d/` | +| usb-modeswitch device data | `/usr/share/usb_modeswitch/:` | `/etc/usb_modeswitch.d/` | +| FCC reconcile helper | `/usr/lib/ceralive-modem-support/ceralive-fcc-reconcile` | — | +| FCC reconcile unit | `/usr/lib/systemd/system/ceralive-fcc-reconcile.service` | `/etc/systemd/system/` | +| legacy-override hash list | `/usr/share/ceralive-modem-support/` | — | + +ModemManager 1.24 exposes **no vendor `conf.d` tier** — verified against the pinned Debian +packaging, whose only `/etc` surface is the D-Bus policy file. Its documented per-device +configuration mechanism is udev properties, so CeraLive's MM-directed configuration rides +the packaged rules file rather than a conf snippet. ModemManager's own available FCC-unlock +tier (`/usr/share/ModemManager/fcc-unlock.available.d`) receives nothing from this package. + +### The FCC-unlock tiers, and which one the reconciler owns + +ModemManager has THREE fcc-unlock directories and consults only two of them. Getting the +pair wrong is silent — a link in the wrong place is simply never opened: + +| Tier | Path (Debian) | Who owns it | +|---|---|---| +| available | `/usr/share/ModemManager/fcc-unlock.available.d/` | ModemManager. Shipped-but-INERT; nothing here ever runs. | +| enabled — **admin** | `/etc/ModemManager/fcc-unlock.d/` | **this package's reconciler**, and only from an operator opt-in | +| enabled — package | `${libdir}/ModemManager/fcc-unlock.d/` (multiarch) | a distribution package. **CeraLive writes here never.** | + +`src/mm-dispatcher-fcc-unlock.c` builds ONE filename, `g_strdup_printf("%04x:%04x", vid, +pid)`, and looks for it in the admin tier then the package tier. It opens no other name, +so a vendor-only file (`2c7c`) is never a dispatcher target — it exists only as what the +available tier's `:` symlinks point AT. The policy therefore keys on +`:` too, and the reconciler refuses a vendor-only key. + +Full coverage matrix and the fleet verdicts: [`../docs/FCC-UNLOCK-COVERAGE.md`](../docs/FCC-UNLOCK-COVERAGE.md). + +### The udev basename hazard (read before renaming anything) + +udev resolves rules by **basename** across its search path, and a file in +`/etc/udev/rules.d` **shadows** a same-basename file in `/usr/lib/udev/rules.d` completely. +The shadowing file is owned by whatever wrote it, so `dpkg -S` on the packaged path keeps +naming this package and **the substitution is undetectable from package metadata**. + +The device image owns `/etc/udev/rules.d/99-ceralive-hardware.rules` and +`78-mm-ceralive-slot-uid.rules`. The companion therefore uses the distinct, modem-only +basename `60-ceralive-modem.rules`, which no image-owned `/etc` file shares. The chroot QA +asserts both halves of this: the `/etc` copy wins precedence, and `dpkg -S` cannot see it. + +The postinst removes a stale same-basename `/etc` override **only** when it is a KNOWN +legacy generated payload — marker header **and** a sha256 listed in +`debian/legacy-etc-overrides.sha256`. Anything unknown, or an operator's edit of a generated +file, is **preserved**; Debian admin-override semantics stand. Both branches are tested. + +### Two-stage QA + +A clean chroot has no sysfs devices and no running daemons, so the contract is split: + +* **CHROOT stage** — [`ci/test-companion-chroot.sh`](ci/test-companion-chroot.sh), run in a + clean `debian:trixie` container: fresh install, declared-inventory equality, chroot guard + (with a non-vacuity leg), single-owner `dpkg -S`, `/etc` override precedence, both + override-removal branches, the FCC absent/enabled/opt-out/malformed/foreign-file matrix + (against the real `/etc` admin tier and `:` keys), upgrade with no + conffile prompt, downgrade, and purge-with-zero-leftovers. +* **CONSUMER stage** — bench-board only: `udevadm test` against a real modem's sysfs path, + `usb_modeswitch -c`, `systemd-analyze verify` + `systemctl is-enabled` + a real boot's + journal proving `ceralive-migrate-data → fcc-reconcile → ModemManager`, and ModemManager's + effective configuration listing. Not runnable in a container and deliberately not faked. + +### Versioning + +The companion is a **native** package versioned with the repo's SemVer tag verbatim +(`v1.1.0` → `1.1.0`). It does NOT use the upstream rebuilds' `-~ceraliveX.Y.Z` +form: there is no upstream version to order against, and a bare SemVer sorts correctly on +its own. Non-tag builds are `0.0.0~dev`. + +It is built **exactly once** into `build/all/`. A per-arch build would produce two +byte-different files under one package/version key, which the APT publisher's immutable-key +rule refuses. + ## Versioning `.deb` internal versions encode the repo's SemVer tag as `-~ceralive` @@ -92,6 +194,10 @@ ModemManager 1.24.2, libmbim 1.34.0, libqmi 1.38.0, libqrtr-glib 1.4.0 (salsa | [`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. The deb-consuming contract lives in the two scripts below. | | [`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`); coherence (identical `~ceralive` suffix + mismatched-libqmi negative); real ordering proofs; 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/generate-release-manifest.sh`](ci/generate-release-manifest.sh) | Emits the **manifest-complete per-release manifest** (`generate-release-manifest.sh ` → `dist/release-manifest.txt`): a checksum row for **every** built deb (both arches), the 9-package runtime closure MARKED (`role=runtime`, the rest `role=aux`) — the `arch package source version role filename sha256` matrix Phase-B apt publication AND `create-release` asset reconciliation consume. Fails closed if the produced set (per source, per arch) is not exactly the frozen `[ all-artifact]` set in [`ci/expected-packages.txt`](ci/expected-packages.txt). dpkg-free (filename parse + `sha256sum`), so it runs anywhere. | +| [`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. | +| [`ci/test-fcc-reconcile.sh`](ci/test-fcc-reconcile.sh) | The FCC reconciler's **behaviour** contract, runnable on any host with no container and no root: every path is redirected into a scratch tree via `CERALIVE_FCC_{POLICY_FILE,AVAILABLE_DIR,ACTIVE_DIR}`. Covers absent/malformed/opt-out/idempotence, the one-model policy that must not parse as empty, the refused vendor-only key, an enabled model MM ships no script for, and both foreign-entry cases (a real file and a symlink pointing outside the available tier). Complements — never replaces — the chroot contract, which proves the same logic from the PACKAGED location after a real `dpkg` install. | +| [`ci/test-companion-chroot.sh`](ci/test-companion-chroot.sh) | The companion's **CHROOT-stage** contract in a clean `debian:trixie` container: install / declared-inventory equality / chroot guard (+ non-vacuity) / single-owner `dpkg -S` / `/etc` override precedence / both override-removal branches / FCC absent-enabled-malformed matrix / upgrade with no conffile prompt / downgrade / purge with zero leftovers. Builds 0.9.0 and 1.1.0 so the upgrade and downgrade legs exercise real dpkg. The consumer stage is bench-gated. | +| [`ci/companion-inventory.txt`](ci/companion-inventory.txt) | The companion's frozen declared file inventory, compared for EQUALITY by the chroot QA — an added, dropped or relocated asset fails the gate naming itself. | +| [`ci/generate-release-manifest.sh`](ci/generate-release-manifest.sh) | Emits the **manifest-complete per-release manifest** (`generate-release-manifest.sh ` → `dist/release-manifest.txt`): a checksum row for **every** built deb (both arches), the 9-package runtime closure MARKED (`role=runtime`, the rest `role=aux`) — the `build_arch package source version role filename sha256` matrix Phase-B apt publication AND `create-release` asset reconciliation consume. Emits **`closure_version: 2`** — the versioned contract apt-worker validates against — which adds the `Architecture: all` companion as ONE row with `build_arch` `all`, alongside the unchanged 9 × 2 arch-dependent rows. Build architecture and index membership are separate: `all` enters EVERY index arch, anything else its own. Per-source equality is scoped by `[arch-all sources]` so `build/all` is checked against the companion only and `build/` against the four upstream sources only. Fails closed on any set drift. dpkg-free (filename parse + `sha256sum`), so it runs anywhere. | | [`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. | diff --git a/packaging/ceralive-modem-support/assets/fcc/ceralive-fcc-reconcile b/packaging/ceralive-modem-support/assets/fcc/ceralive-fcc-reconcile new file mode 100755 index 0000000..8f94555 --- /dev/null +++ b/packaging/ceralive-modem-support/assets/fcc/ceralive-fcc-reconcile @@ -0,0 +1,152 @@ +#!/bin/sh +# ceralive-fcc-reconcile — reconcile ModemManager's ACTIVE fcc-unlock.d directory +# against the operator's persisted policy. +# +# CONTRACT (todo 33): +# * This package ships NO FCC-unlock scripts, active or otherwise. ModemManager's +# own AVAILABLE tier (/usr/share/ModemManager/fcc-unlock.available.d) is +# populated by ModemManager itself; a script becomes ACTIVE only when a link to +# it exists in one of the two ENABLED tiers the dispatcher consults, in order: +# 1. ${sysconfdir}/ModemManager/fcc-unlock.d (/etc/… — the ADMIN tier) +# 2. ${libdir}/ModemManager/fcc-unlock.d (the PACKAGE tier) +# This reconciler owns tier 1 and only tier 1. Creating a link there is an +# OPT-IN act; nothing in this package ever writes tier 2. +# * The opt-in lives in /data/ceralive/fcc-unlock-policy.json. /etc does NOT +# survive a RAUC slot swap and /data does, which is the whole reason this unit +# exists: the policy is the record, and the symlink is re-derived from it on +# every boot before ModemManager probes a radio. +# * An ABSENT policy — the state on generic Debian with no CeraLive partition +# layout — exits 0 and creates NO active links. That is the only safe default: +# an FCC-unlock script talks to a regulatory-locked radio, so "we could not read +# the policy" must never mean "enable it". +# * An UNPARSEABLE or malformed policy is treated exactly like an absent one: log, +# exit 0, activate nothing. Fail-safe, not fail-open. +# * The reconciler only ever creates, keeps or removes SYMLINKS whose target +# resolves under the available tier. A real file in the active directory is +# somebody else's property and is left strictly alone. +# +# Policy format (schema version 1): +# { "schemaVersion": 1, "unlock": { "2c7c:0801": true, "1199:9079": false } } +# +# Keys are `:`, 4 lowercase hex digits each — the EXACT filename +# ModemManager's dispatcher looks for. mm-dispatcher-fcc-unlock.c builds it as +# `g_strdup_printf("%04x:%04x", vid, pid)` and consults NOTHING else, so a +# vendor-only key (`2c7c`) would name a file the dispatcher never opens. The +# available tier ships one real script per VENDOR plus a `:` symlink +# onto it for every model the vendor script covers; we link the model name. +# +# A trailing `"savedAtMs": ` (and any other sibling key) is accepted and +# ignored — only the `"unlock"` object is read. +# +# Deliberately dependency-free: /bin/sh + coreutils only. The CeraLive production +# image ships neither jq nor python3 (python3 is in the debug-only package delta), +# so a JSON library dependency here would make the unit fail on exactly the images +# it is for. +# +# Exit codes: 0 always, except for an internal error the operator must see. + +set -eu + +POLICY_FILE="${CERALIVE_FCC_POLICY_FILE:-/data/ceralive/fcc-unlock-policy.json}" +AVAILABLE_DIR="${CERALIVE_FCC_AVAILABLE_DIR:-/usr/share/ModemManager/fcc-unlock.available.d}" +ACTIVE_DIR="${CERALIVE_FCC_ACTIVE_DIR:-/etc/ModemManager/fcc-unlock.d}" + +log() { printf 'ceralive-fcc-reconcile: %s\n' "$*"; } + +# ── Parse the policy into a newline-separated list of ENABLED vid:pid keys ─── +# Rejects the whole document on any token that is not `"<4hex>:<4hex>": true|false`, +# so a hand-mangled or truncated file can never be half-applied. +parse_enabled() { + file="$1" + body="$(tr -d ' \t\n\r' <"$file")" || return 1 + case "$body" in + *'"unlock":{'*) ;; + *) return 1 ;; + esac + inner="${body#*\"unlock\":\{}" + inner="${inner%%\}*}" + [ -n "$inner" ] || return 0 + + # Every comma-separated token must match the strict pair shape. + # The trailing newline is load-bearing: `read` returns non-zero on a final line + # with no newline, so `printf '%s'` here would silently skip a single-entry + # policy entirely — a one-model document would parse as "nothing enabled" + # instead of as itself. + hex='[0-9a-f][0-9a-f][0-9a-f][0-9a-f]' + printf '%s\n' "$inner" | tr ',' '\n' | while IFS= read -r tok; do + [ -n "$tok" ] || continue + case "$tok" in + '"'$hex:$hex'":true') printf '%s\n' "$(printf '%s' "$tok" | cut -c2-10)" ;; + '"'$hex:$hex'":false') ;; + *) printf 'MALFORMED\n' ;; + esac + done +} + +# Remove symlinks we own (target resolves under the available tier) that the policy +# no longer authorizes. Real files and foreign symlinks are never touched. +prune_unauthorized() { + keep="$1" + [ -d "$ACTIVE_DIR" ] || return 0 + for link in "$ACTIVE_DIR"/*; do + [ -L "$link" ] || continue + target="$(readlink -f "$link" 2>/dev/null || true)" + case "$target" in + "$AVAILABLE_DIR"/*) ;; + *) continue ;; + esac + name="$(basename "$link")" + wanted=0 + for key in $keep; do + [ "$key" = "$name" ] && wanted=1 + done + if [ "$wanted" -eq 0 ]; then + rm -f "$link" + log "deactivated FCC-unlock '$name' (not authorized by policy)" + fi + done +} + +main() { + if [ ! -d "$AVAILABLE_DIR" ]; then + log "ModemManager available fcc-unlock dir '$AVAILABLE_DIR' absent — nothing to reconcile" + return 0 + fi + + if [ ! -e "$POLICY_FILE" ]; then + log "no policy at '$POLICY_FILE' — no active FCC-unlock scripts (this is the default and safe state)" + prune_unauthorized "" + return 0 + fi + + enabled="$(parse_enabled "$POLICY_FILE" 2>/dev/null || printf 'MALFORMED\n')" + case "$enabled" in + *MALFORMED*) + log "policy at '$POLICY_FILE' is malformed — treating as absent; no active FCC-unlock scripts" + prune_unauthorized "" + return 0 + ;; + esac + + mkdir -p "$ACTIVE_DIR" + + for key in $enabled; do + src="$AVAILABLE_DIR/$key" + if [ ! -f "$src" ]; then + log "policy enables '$key' but ModemManager ships no '$src' — skipped" + continue + fi + dst="$ACTIVE_DIR/$key" + if [ -e "$dst" ] && [ ! -L "$dst" ]; then + log "'$dst' exists and is NOT a symlink — leaving it alone (not ours)" + continue + fi + ln -sfn "$src" "$dst" + log "activated FCC-unlock '$key' -> $src" + done + + prune_unauthorized "$enabled" + return 0 +} + +main "$@" diff --git a/packaging/ceralive-modem-support/assets/legacy/60-ceralive-modem.rules.legacy-v1 b/packaging/ceralive-modem-support/assets/legacy/60-ceralive-modem.rules.legacy-v1 new file mode 100644 index 0000000..3d25081 --- /dev/null +++ b/packaging/ceralive-modem-support/assets/legacy/60-ceralive-modem.rules.legacy-v1 @@ -0,0 +1,11 @@ +# CERALIVE-GENERATED: modem-udev v1 — DO NOT EDIT +# Superseded by /usr/lib/udev/rules.d/60-ceralive-modem.rules from the +# ceralive-modem-support package. A copy of THIS EXACT payload under +# /etc/udev/rules.d/ shadows the packaged file by basename, so the package's +# postinst removes it — and ONLY it, matched by marker line AND by the sha256 +# recorded in legacy-etc-overrides.sha256. +# +# Any other content at that /etc path — including an operator's edit of this +# file — is PRESERVED. Debian admin-override semantics stand. +ACTION!="add|bind|change", GOTO="ceralive_modem_end" +LABEL="ceralive_modem_end" diff --git a/packaging/ceralive-modem-support/assets/systemd/ceralive-fcc-reconcile.service b/packaging/ceralive-modem-support/assets/systemd/ceralive-fcc-reconcile.service new file mode 100644 index 0000000..76a302a --- /dev/null +++ b/packaging/ceralive-modem-support/assets/systemd/ceralive-fcc-reconcile.service @@ -0,0 +1,45 @@ +[Unit] +Description=Reconcile CeraLive ModemManager FCC-unlock policy +Documentation=file:/usr/share/doc/ceralive-modem-support/README.md + +# ORDERING CONTRACT (todo 33). All three edges are deliberately SOFT on a host that has +# none of the CeraLive layout, so this unit is a clean no-op on generic Debian: +# * Before=ModemManager.service — the active fcc-unlock.d set must be settled before MM +# probes a radio. If ModemManager is not installed this edge is simply inert. +# * RequiresMountsFor=/data — systemd only synthesises a dependency when /data is an +# actual mount point; on a host where it is an ordinary directory (or absent) this +# adds nothing and blocks nothing. +# * After=ceralive-migrate-data.service — the image unit that seeds /data. An ordering +# edge to a unit that does not exist is a no-op, never a failure. +Before=ModemManager.service +RequiresMountsFor=/data +After=ceralive-migrate-data.service + +[Service] +Type=oneshot +RemainAfterExit=yes +ExecStart=/usr/lib/ceralive-modem-support/ceralive-fcc-reconcile + +# The reconciler answers 0 for every "cannot act" case (absent policy, malformed policy, +# no ModemManager available tier) precisely so a device without the CeraLive partition +# layout boots clean. A non-zero exit therefore means a genuine internal error and is +# surfaced rather than swallowed. +# +# It writes ONLY into /etc/ModemManager/fcc-unlock.d — ModemManager's ADMIN enabled tier, +# the first of the two the dispatcher consults. The multiarch PACKAGE tier +# (${libdir}/ModemManager/fcc-unlock.d) is never touched: it is the tier a distribution +# package would own, and this package deliberately owns no active FCC-unlock script. +ProtectHome=yes +ProtectHostname=yes +ProtectKernelLogs=yes +ProtectKernelTunables=yes +ProtectControlGroups=yes +RestrictRealtime=yes +RestrictSUIDSGID=yes +NoNewPrivileges=yes +PrivateTmp=yes +SystemCallFilter=@system-service +SystemCallErrorNumber=EPERM + +[Install] +WantedBy=multi-user.target diff --git a/packaging/ceralive-modem-support/assets/udev/60-ceralive-modem.rules b/packaging/ceralive-modem-support/assets/udev/60-ceralive-modem.rules new file mode 100644 index 0000000..c9debad --- /dev/null +++ b/packaging/ceralive-modem-support/assets/udev/60-ceralive-modem.rules @@ -0,0 +1,98 @@ +# 60-ceralive-modem.rules — CeraLive modem IDENTIFICATION rules. +# +# OWNED BY: the first-party `ceralive-modem-support` package. +# +# BASENAME CONTRACT (load-bearing — read before renaming anything here): +# udev resolves rules by BASENAME across its directory search path, and a file in +# /etc/udev/rules.d SHADOWS a same-basename file in /usr/lib/udev/rules.d completely. +# The shadowing file is still owned by whatever wrote it, so `dpkg -S` on the packaged +# path keeps naming this package and the substitution is invisible to package tooling. +# The device image separately owns /etc/udev/rules.d/99-ceralive-hardware.rules and +# /etc/udev/rules.d/78-mm-ceralive-slot-uid.rules. This file therefore uses a DISTINCT, +# MODEM-ONLY basename that no image-owned /etc file shares. Never rename this file to +# an image-owned basename, and never add an /etc copy of it from this package. +# +# SCOPE (identification/tagging ONLY): +# Every rule below assigns ENV{} properties and nothing else. There is deliberately no +# RUN+=, no MODE=, no GROUP=, no OWNER=, no SYMLINK+= and no ATTR{}= write anywhere in +# this file — a udev rule that MUTATES a device is the image's business (or a service's), +# never a generic package's. Permission/group policy for modem nodes stays image-owned. +# +# PREDICATE DISCIPLINE: +# Device-level rules match an EXACT idVendor + idProduct pair. There is no bare +# vendor-wide match (ATTR{idVendor}=="12d1" alone would claim every Huawei device on the +# bus, cameras and dongled storage included). Interface-level rules additionally pin +# bInterfaceClass/bInterfaceSubClass so a non-modem interface of a matched composition +# is not tagged as one. +# +# Properties published (consumed by CeraLive userspace, never by udev itself): +# ID_CERALIVE_MODEM=1 — this device is a CeraLive-recognised WWAN unit +# ID_CERALIVE_MODEM_CLASS= — cellular | router | zerocd +# ID_CERALIVE_MODEM_SKU= — stable, human-readable SKU token +# +# `zerocd` marks a mass-storage "Zero-CD" personality that usb-modeswitch is expected to +# flip into a modem composition; see /usr/share/usb_modeswitch/ in this package. + +ACTION!="add|bind|change", GOTO="ceralive_modem_end" +SUBSYSTEM!="usb", GOTO="ceralive_modem_iface" + +# ── Cellular modems (native modem compositions) ────────────────────────────── +# Quectel RM530N-GL (5G, QMI/MBIM) +ATTR{idVendor}=="2c7c", ATTR{idProduct}=="0801", \ + ENV{ID_CERALIVE_MODEM}="1", ENV{ID_CERALIVE_MODEM_CLASS}="cellular", ENV{ID_CERALIVE_MODEM_SKU}="quectel-rm530n-gl" + +# SIMCom SIM7600G-H R2 (LTE Cat-4, QMI) +ATTR{idVendor}=="1e0e", ATTR{idProduct}=="9001", \ + ENV{ID_CERALIVE_MODEM}="1", ENV{ID_CERALIVE_MODEM_CLASS}="cellular", ENV{ID_CERALIVE_MODEM_SKU}="simcom-sim7600g-h" + +# Qualcomm-reference dual-personality stick, QMI personality (qmi_wwan). +# Its sibling 05c6:9024 personality is RNDIS/router and is tagged `router` below — +# the two are the SAME physical unit under one USB serial, so both are listed. +ATTR{idVendor}=="05c6", ATTR{idProduct}=="9091", \ + ENV{ID_CERALIVE_MODEM}="1", ENV{ID_CERALIVE_MODEM_CLASS}="cellular", ENV{ID_CERALIVE_MODEM_SKU}="qualcomm-9091" + +# ── Router-class units (own IP stack behind an RNDIS/ECM netdev) ───────────── +# Huawei HiLink +ATTR{idVendor}=="12d1", ATTR{idProduct}=="14dc", \ + ENV{ID_CERALIVE_MODEM}="1", ENV{ID_CERALIVE_MODEM_CLASS}="router", ENV{ID_CERALIVE_MODEM_SKU}="huawei-hilink" + +# ZTE MF79U (post-modeswitch router personality) +ATTR{idVendor}=="19d2", ATTR{idProduct}=="1405", \ + ENV{ID_CERALIVE_MODEM}="1", ENV{ID_CERALIVE_MODEM_CLASS}="router", ENV{ID_CERALIVE_MODEM_SKU}="zte-mf79u" + +# Qualcomm-reference dual-personality stick, RNDIS/router personality. +ATTR{idVendor}=="05c6", ATTR{idProduct}=="9024", \ + ENV{ID_CERALIVE_MODEM}="1", ENV{ID_CERALIVE_MODEM_CLASS}="router", ENV{ID_CERALIVE_MODEM_SKU}="qualcomm-9024" + +# ── Zero-CD (mass-storage) personalities awaiting usb-modeswitch ───────────── +ATTR{idVendor}=="19d2", ATTR{idProduct}=="1225", \ + ENV{ID_CERALIVE_MODEM}="1", ENV{ID_CERALIVE_MODEM_CLASS}="zerocd", ENV{ID_CERALIVE_MODEM_SKU}="zte-mf79u" + +ATTR{idVendor}=="12d1", ATTR{idProduct}=="1f01", \ + ENV{ID_CERALIVE_MODEM}="1", ENV{ID_CERALIVE_MODEM_CLASS}="zerocd", ENV{ID_CERALIVE_MODEM_SKU}="huawei-hilink" + +LABEL="ceralive_modem_iface" + +# ── Interface-level tagging ────────────────────────────────────────────────── +# CDC (0x02/0x0e vendor-specific WWAN control) interfaces of the SKUs above only. +# The idVendor/idProduct pair is re-asserted at interface level so a matched +# composition's audio/storage/HID interfaces are never tagged as modem control. +SUBSYSTEM=="usb", ENV{DEVTYPE}=="usb_interface", \ + ATTRS{idVendor}=="2c7c", ATTRS{idProduct}=="0801", \ + ATTR{bInterfaceClass}=="ff", ATTR{bInterfaceSubClass}=="ff", \ + ENV{ID_CERALIVE_MODEM_IFACE}="control" + +SUBSYSTEM=="usb", ENV{DEVTYPE}=="usb_interface", \ + ATTRS{idVendor}=="1e0e", ATTRS{idProduct}=="9001", \ + ATTR{bInterfaceClass}=="ff", ATTR{bInterfaceSubClass}=="ff", \ + ENV{ID_CERALIVE_MODEM_IFACE}="control" + +# WWAN control channel character devices belonging to a tagged parent. +SUBSYSTEM=="usbmisc", KERNEL=="cdc-wdm[0-9]*", ENV{ID_CERALIVE_MODEM}=="1", \ + ENV{ID_CERALIVE_MODEM_PORT}="wdm" + +# WWAN netdevs belonging to a tagged parent. +SUBSYSTEM=="net", ENV{ID_CERALIVE_MODEM}=="1", \ + ENV{ID_CERALIVE_MODEM_PORT}="net" + +LABEL="ceralive_modem_end" diff --git a/packaging/ceralive-modem-support/assets/usb_modeswitch/12d1:1f01 b/packaging/ceralive-modem-support/assets/usb_modeswitch/12d1:1f01 new file mode 100644 index 0000000..b8b8a7c --- /dev/null +++ b/packaging/ceralive-modem-support/assets/usb_modeswitch/12d1:1f01 @@ -0,0 +1,11 @@ +# Huawei HiLink-class dongle — Zero-CD (USB mass storage) personality. +# +# ZERO-CD ONLY — see the note in 19d2:1225. Nothing here is board-gated or behavioural. +# +# Admin override tier: /etc/usb_modeswitch.d/12d1:1f01. +# +# 12d1:1f01 (mass storage) -> 12d1:14dc (HiLink RNDIS composition) + +TargetVendor=0x12d1 +TargetProductList="14dc,1506,1c1e" +HuaweiNewMode=1 diff --git a/packaging/ceralive-modem-support/assets/usb_modeswitch/19d2:1225 b/packaging/ceralive-modem-support/assets/usb_modeswitch/19d2:1225 new file mode 100644 index 0000000..2e1e466 --- /dev/null +++ b/packaging/ceralive-modem-support/assets/usb_modeswitch/19d2:1225 @@ -0,0 +1,15 @@ +# ZTE MF79U-class dongle — Zero-CD (USB mass storage) personality. +# +# ZERO-CD ONLY. This file exists to flip a mass-storage "driver CD" personality into the +# unit's real modem/router composition. It carries NO runtime modem behaviour, no APN, no +# bearer policy and no board-specific data — that is the whole reason a generic package may +# own it. Anything board-gated stays with the image. +# +# Admin override tier: /etc/usb_modeswitch.d/19d2:1225 (an operator file there wins and is +# never touched by this package). +# +# 19d2:1225 (mass storage) -> 19d2:1405 (router/RNDIS composition) + +TargetVendor=0x19d2 +TargetProductList="1405,1403,1408,0016" +StandardEject=1 diff --git a/packaging/ceralive-modem-support/debian/changelog b/packaging/ceralive-modem-support/debian/changelog new file mode 100644 index 0000000..fb8f938 --- /dev/null +++ b/packaging/ceralive-modem-support/debian/changelog @@ -0,0 +1,7 @@ +ceralive-modem-support (0.0.0~dev) UNRELEASED; urgency=medium + + * Initial first-party companion package: CeraLive generic modem system assets. + Version is rewritten to the release tag by packaging/ci/inject-companion-version.sh + at release time; a non-tag build keeps 0.0.0~dev. + + -- CeraLive Mon, 17 Aug 2026 00:00:00 +0000 diff --git a/packaging/ceralive-modem-support/debian/control b/packaging/ceralive-modem-support/debian/control new file mode 100644 index 0000000..28fa7a0 --- /dev/null +++ b/packaging/ceralive-modem-support/debian/control @@ -0,0 +1,34 @@ +Source: ceralive-modem-support +Section: net +Priority: optional +Maintainer: CeraLive +Build-Depends: debhelper-compat (= 13) +Standards-Version: 4.6.2 +Rules-Requires-Root: no +Homepage: https://github.com/CERALIVE/modem-stack + +Package: ceralive-modem-support +Architecture: all +Depends: ${misc:Depends}, + udev +Recommends: modemmanager, + usb-modeswitch +Description: CeraLive generic modem system assets + First-party companion package for the CeraLive ModemManager stack. It carries the + UNCONDITIONAL, generic, board-independent system assets that the upstream + ModemManager / libqmi / libmbim / libqrtr rebuilds must not absorb — those four + sources are byte-faithful, zero-patch rebuilds and stay that way. + . + Contents: + * /usr/lib/udev/rules.d/60-ceralive-modem.rules — identification/tagging only + (exact VID:PID and interface predicates; no RUN+=, no permission or node + mutation of any kind). + * /usr/share/usb_modeswitch/ — Zero-CD mode-switch device data only. + * /usr/lib/ceralive-modem-support/ceralive-fcc-reconcile plus its oneshot unit, + which reconciles ModemManager's active fcc-unlock.d against an operator policy + at /data/ceralive/fcc-unlock-policy.json. No active FCC-unlock script ships + here; an absent policy activates nothing and exits 0. + . + Board-gated generated assets — M.2 SIM quirk rows and per-slot modem UID rules — + are deliberately NOT here. They consume build-time board facts a generic package + cannot know and remain owned by the device-image generators. diff --git a/packaging/ceralive-modem-support/debian/copyright b/packaging/ceralive-modem-support/debian/copyright new file mode 100644 index 0000000..7259585 --- /dev/null +++ b/packaging/ceralive-modem-support/debian/copyright @@ -0,0 +1,14 @@ +Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/ +Upstream-Name: ceralive-modem-support +Source: https://github.com/CERALIVE/modem-stack + +Files: * +Copyright: 2026 CeraLive +License: AGPL-3.0+ + This program is free software: you can redistribute it and/or modify it under + the terms of the GNU Affero General Public License as published by the Free + Software Foundation, either version 3 of the License, or (at your option) any + later version. + . + On Debian systems the full text of the GNU Affero General Public License + version 3 can be found in /usr/share/common-licenses/AGPL-3. diff --git a/packaging/ceralive-modem-support/debian/legacy-etc-overrides.sha256 b/packaging/ceralive-modem-support/debian/legacy-etc-overrides.sha256 new file mode 100644 index 0000000..5bf1af6 --- /dev/null +++ b/packaging/ceralive-modem-support/debian/legacy-etc-overrides.sha256 @@ -0,0 +1,17 @@ +# legacy-etc-overrides.sha256 — the CLOSED list of /etc override payloads this package is +# permitted to remove on upgrade. +# +# WHY A LIST AND NOT A HEURISTIC +# udev resolves rules by BASENAME, and /etc/udev/rules.d shadows /usr/lib/udev/rules.d +# completely. A stale generated file at the packaged basename would therefore silently +# replace this package's rules while `dpkg -S` still names this package as the owner of +# the /usr/lib path — the substitution is undetectable from package metadata alone. +# So the postinst removes such a file, but ONLY when it is provably a KNOWN GENERATED +# payload: it must carry the generated-marker header AND its sha256 must appear below. +# Anything else — an operator's own file, an operator's EDIT of a generated file, a +# payload from a version not listed here — is PRESERVED. Debian admin-override semantics +# are not negotiable, and "it looked generated" is not proof that nobody edited it. +# +# FORMAT: (comments and blank lines ignored) + +cd54e952af9ebe0b0d56f47ec296aeab48a9cbf7383c9dbefc16eef7d177558f /etc/udev/rules.d/60-ceralive-modem.rules diff --git a/packaging/ceralive-modem-support/debian/postinst b/packaging/ceralive-modem-support/debian/postinst new file mode 100755 index 0000000..8033b61 --- /dev/null +++ b/packaging/ceralive-modem-support/debian/postinst @@ -0,0 +1,79 @@ +#!/bin/sh +# postinst for ceralive-modem-support. +# +# Two jobs, both idempotent and both safe inside a build chroot: +# 1. Remove a KNOWN-STALE /etc override that would shadow the packaged udev rules +# by basename (marker + sha256 gated; anything else is preserved). +# 2. Reload udev and fire a NARROW trigger — only when a live udev is present. +set -e + +PKGDATA=/usr/share/ceralive-modem-support +HASHLIST="$PKGDATA/legacy-etc-overrides.sha256" +GENERATED_MARKER='# CERALIVE-GENERATED: modem-udev' + +# ── 1. Marker+hash-gated removal of a stale generated /etc override ────────── +# The gate is a CONJUNCTION on purpose. The marker alone proves only that the file +# STARTED life generated; an operator who edited it must keep their edit. The hash alone +# would be enough, but requiring the marker too keeps a hash collision with an unrelated +# operator file from ever mattering. +remove_known_stale_overrides() { + [ -r "$HASHLIST" ] || return 0 + command -v sha256sum >/dev/null 2>&1 || return 0 + + while read -r want path; do + case "$want" in ''|'#'*) continue ;; esac + [ -n "$path" ] || continue + [ -f "$path" ] || continue + # Never touch a symlink: following one could delete something outside /etc. + [ -L "$path" ] && continue + + if ! head -n 1 "$path" | grep -qF "$GENERATED_MARKER"; then + echo "ceralive-modem-support: preserving operator-owned $path (no generated marker)" + continue + fi + have="$(sha256sum "$path" | awk '{print $1}')" + if [ "$have" != "$want" ]; then + echo "ceralive-modem-support: preserving modified $path (sha256 not a known generated payload)" + continue + fi + rm -f "$path" + echo "ceralive-modem-support: removed stale generated override $path (superseded by the packaged rules)" + done < "$HASHLIST" +} + +# ── 2. Chroot-guarded udev reload + narrow trigger ────────────────────────── +# /run/udev is created by a RUNNING systemd-udevd. Inside a build chroot, a container +# image build or a piuparts run it does not exist, and `udevadm control` would fail (or +# worse, talk to the host's udev through a shared /run). Guarding on it is the documented +# Debian idiom and is what makes this postinst safe in every non-booted context. +# +# The trigger is NARROW — usb + net + usbmisc, action=change — never a bare +# `udevadm trigger`, which would re-run every rule for every device on the system. +refresh_udev() { + [ -d /run/udev ] || { + echo "ceralive-modem-support: no /run/udev (chroot/container) — skipping udev reload+trigger" + return 0 + } + command -v udevadm >/dev/null 2>&1 || return 0 + udevadm control --reload-rules || true + udevadm trigger --action=change --subsystem-match=usb || true + udevadm trigger --action=change --subsystem-match=usbmisc || true + udevadm trigger --action=change --subsystem-match=net || true +} + +case "$1" in + configure) + remove_known_stale_overrides + refresh_udev + ;; + abort-upgrade|abort-remove|abort-deconfigure) + ;; + *) + echo "postinst called with unknown argument '$1'" >&2 + exit 1 + ;; +esac + +#DEBHELPER# + +exit 0 diff --git a/packaging/ceralive-modem-support/debian/postrm b/packaging/ceralive-modem-support/debian/postrm new file mode 100755 index 0000000..a4a6ea8 --- /dev/null +++ b/packaging/ceralive-modem-support/debian/postrm @@ -0,0 +1,50 @@ +#!/bin/sh +# postrm for ceralive-modem-support. +# +# On PURGE, retire the runtime state this package's helper creates but does not own as +# packaged files: the ACTIVE fcc-unlock symlinks it wrote into ModemManager's multiarch +# directory. Those are symlinks into /usr/share/ModemManager/fcc-unlock.d, so removing +# them removes nothing of ModemManager's own — but leaving them behind would let a purged +# package keep a radio's FCC-unlock script active, which is exactly the state todo 33 +# forbids by default. +# +# Anything that is not a symlink, and any symlink pointing outside the available tier, +# is left strictly alone: it is not ours. +set -e + +AVAILABLE_DIR=/usr/share/ModemManager/fcc-unlock.d + +purge_active_fcc_symlinks() { + for dir in /usr/lib/*/ModemManager/fcc-unlock.d; do + [ -d "$dir" ] || continue + for link in "$dir"/*; do + [ -L "$link" ] || continue + target="$(readlink -f "$link" 2>/dev/null || true)" + case "$target" in + "$AVAILABLE_DIR"/*) + rm -f "$link" + echo "ceralive-modem-support: purge removed active FCC-unlock symlink $link" + ;; + esac + done + rmdir "$dir" 2>/dev/null || true + done +} + +case "$1" in + purge) + purge_active_fcc_symlinks + rm -rf /usr/share/ceralive-modem-support + rm -rf /usr/lib/ceralive-modem-support + ;; + remove|upgrade|failed-upgrade|abort-install|abort-upgrade|disappear) + ;; + *) + echo "postrm called with unknown argument '$1'" >&2 + exit 1 + ;; +esac + +#DEBHELPER# + +exit 0 diff --git a/packaging/ceralive-modem-support/debian/rules b/packaging/ceralive-modem-support/debian/rules new file mode 100755 index 0000000..46e7699 --- /dev/null +++ b/packaging/ceralive-modem-support/debian/rules @@ -0,0 +1,52 @@ +#!/usr/bin/make -f + +export DEB_BUILD_MAINT_OPTIONS = hardening=+all + +%: + dh $@ + +override_dh_auto_build: +override_dh_auto_test: + +# EXPLICIT PER-ASSET INSTALLATION TABLE. +# +# There is deliberately no blanket "/usr/lib for everything" rule here: each asset goes to +# the destination ITS OWN CONSUMER canonically reads, and the admin-override tier for each +# is a different directory that this package never writes. +# +# asset | packaged destination | admin override +# ----------------------------+-------------------------------------------+----------------------------- +# udev rules | /usr/lib/udev/rules.d/ | /etc/udev/rules.d/ +# usb-modeswitch device data | /usr/share/usb_modeswitch/ | /etc/usb_modeswitch.d/ +# FCC reconcile helper | /usr/lib/ceralive-modem-support/ | (none — executable) +# FCC reconcile unit | /usr/lib/systemd/system/ | /etc/systemd/system/ +# legacy-override hash list | /usr/share/ceralive-modem-support/ | (none — package data) +# +# The udev BASENAME is modem-only (`60-ceralive-modem.rules`) and is shared with NO +# image-owned /etc/udev/rules.d file. See the header of that file for why that matters. +# +# ModemManager 1.24 exposes NO vendor conf.d tier — verified against the pinned Debian +# packaging, whose only /etc surface is the D-Bus policy file. Its documented per-device +# configuration mechanism is udev properties, which is why CeraLive's MM-directed +# configuration rides the packaged rules file above rather than a conf snippet. The +# available FCC-unlock tier (/usr/share/ModemManager/fcc-unlock.d) is ModemManager's own +# and receives nothing from this package. +override_dh_auto_install: + install -D -m 0644 assets/udev/60-ceralive-modem.rules \ + debian/ceralive-modem-support/usr/lib/udev/rules.d/60-ceralive-modem.rules + install -d -m 0755 debian/ceralive-modem-support/usr/share/usb_modeswitch + install -m 0644 assets/usb_modeswitch/19d2:1225 assets/usb_modeswitch/12d1:1f01 \ + debian/ceralive-modem-support/usr/share/usb_modeswitch/ + install -D -m 0755 assets/fcc/ceralive-fcc-reconcile \ + debian/ceralive-modem-support/usr/lib/ceralive-modem-support/ceralive-fcc-reconcile + install -D -m 0644 assets/legacy/60-ceralive-modem.rules.legacy-v1 \ + debian/ceralive-modem-support/usr/share/ceralive-modem-support/legacy/60-ceralive-modem.rules.legacy-v1 + install -D -m 0644 debian/legacy-etc-overrides.sha256 \ + debian/ceralive-modem-support/usr/share/ceralive-modem-support/legacy-etc-overrides.sha256 + install -D -m 0644 assets/systemd/ceralive-fcc-reconcile.service \ + debian/ceralive-modem-support/usr/lib/systemd/system/ceralive-fcc-reconcile.service + +# dh_installsystemd finds the unit already staged under usr/lib/systemd/system by the +# installation table above, so the unit's canonical home stays assets/systemd/. +override_dh_installsystemd: + dh_installsystemd --name=ceralive-fcc-reconcile --no-restart-after-upgrade diff --git a/packaging/ceralive-modem-support/debian/source/format b/packaging/ceralive-modem-support/debian/source/format new file mode 100644 index 0000000..89ae9db --- /dev/null +++ b/packaging/ceralive-modem-support/debian/source/format @@ -0,0 +1 @@ +3.0 (native) diff --git a/packaging/ci/build-companion.sh b/packaging/ci/build-companion.sh new file mode 100755 index 0000000..34f890e --- /dev/null +++ b/packaging/ci/build-companion.sh @@ -0,0 +1,103 @@ +#!/usr/bin/env bash +# build-companion.sh — build the first-party `ceralive-modem-support` companion .deb. +# +# The companion is `Architecture: all`: ONE build produces ONE immutable release asset that +# is later indexed into BOTH per-arch APT indexes. It is therefore built exactly ONCE, into +# packaging/build/all/, and never per-arch — a per-arch build would produce two byte-different +# files claiming the same package/version key and break the publisher's immutable-key rule. +# +# It shares NOTHING with the four upstream sources' build: those are byte-faithful, zero-patch +# rebuilds (packaging/README.md, POLICY.md) and this package exists precisely so they never +# have to absorb a CeraLive-specific asset. +# +# USAGE build-companion.sh [--native] +# default: builds inside a `debian:bookworm` container (matches the upstream lane). +# --native: builds on the host (needs debhelper + dpkg-dev); used by the local QA harness. +# +# ENV +# RELEASE_VERSION vX.Y.Z -> the .deb Version: is X.Y.Z. Unset -> 0.0.0~dev. +# CONTAINER_ENGINE podman|docker (auto-detected, podman preferred). +# BUILD_ROOT output root (default packaging/build). +set -euo pipefail + +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PKG_ROOT="$(cd "$HERE/.." && pwd)" +SRC_DIR="$PKG_ROOT/ceralive-modem-support" +BUILD_ROOT="${BUILD_ROOT:-$PKG_ROOT/build}" +OUT_DIR="$BUILD_ROOT/all" +IMAGE="${COMPANION_BUILD_IMAGE:-debian:bookworm}" + +NATIVE=0 +[[ "${1:-}" == "--native" ]] && NATIVE=1 + +# ── Version ────────────────────────────────────────────────────────────────── +# The companion is a native package versioned with the repo's SemVer tag verbatim +# (v1.1.0 -> 1.1.0). It deliberately does NOT use the upstream rebuilds' +# `-~ceraliveX.Y.Z` form: there is no upstream version to order against, +# and a bare SemVer sorts correctly under dpkg on its own. +resolve_version() { + local tag="${RELEASE_VERSION:-}" + if [[ -z "$tag" ]]; then echo "0.0.0~dev"; return 0; fi + bash "$HERE/tag-guard.sh" "$tag" >/dev/null + echo "${tag#v}" +} + +VERSION="$(resolve_version)" +echo "build-companion: version=$VERSION out=$OUT_DIR native=$NATIVE" >&2 + +build_here() { + local work="$1" version="$2" + cd "$work" + # Rewrite the changelog top entry in the WORK COPY only; the committed changelog stays + # pristine, exactly as inject-deb-version.sh does for the four upstream sources. + DEBEMAIL="dev@ceralive.tv" DEBFULLNAME="CeraLive" \ + dch --force-bad-version --newversion "$version" --distribution stable --force-distribution \ + "CeraLive modem-stack release $version." + dpkg-buildpackage -us -uc -b +} + +if [[ "$NATIVE" -eq 1 ]]; then + command -v dpkg-buildpackage >/dev/null || { echo "build-companion: dpkg-buildpackage not found (--native needs dpkg-dev + debhelper)" >&2; exit 2; } + WORK="$(mktemp -d)" + trap 'rm -rf "$WORK"' EXIT + cp -a "$SRC_DIR" "$WORK/ceralive-modem-support" + ( build_here "$WORK/ceralive-modem-support" "$VERSION" ) + mkdir -p "$OUT_DIR" + find "$WORK" -maxdepth 1 -name '*.deb' -exec cp {} "$OUT_DIR/" \; +else + ENGINE="${CONTAINER_ENGINE:-}" + if [[ -z "$ENGINE" ]]; then + if command -v podman >/dev/null 2>&1; then ENGINE=podman + elif command -v docker >/dev/null 2>&1; then ENGINE=docker + else echo "build-companion: no podman/docker available" >&2; exit 2; fi + fi + mkdir -p "$OUT_DIR" + "$ENGINE" run --rm \ + -v "$SRC_DIR:/src:ro" \ + -v "$OUT_DIR:/out" \ + -e "VERSION=$VERSION" \ + "$IMAGE" bash -euo pipefail -c ' + export DEBIAN_FRONTEND=noninteractive + apt-get update -qq + apt-get install -y -qq --no-install-recommends build-essential debhelper devscripts dpkg-dev >/dev/null + cp -a /src /work + cd /work + DEBEMAIL=dev@ceralive.tv DEBFULLNAME=CeraLive \ + dch --force-bad-version --newversion "$VERSION" --distribution stable --force-distribution \ + "CeraLive modem-stack release $VERSION." + dpkg-buildpackage -us -uc -b + cp /*.deb /out/ + chmod 0644 /out/*.deb + ' +fi + +DEB="$(find "$OUT_DIR" -maxdepth 1 -name 'ceralive-modem-support_*.deb' -newermt '-1 day' | head -n1)" +[[ -n "$DEB" ]] || { echo "build-companion: no .deb produced in $OUT_DIR" >&2; exit 1; } + +# Fail closed on the two identity axes the publisher and the manifest both rely on. +got_arch="$(dpkg-deb -f "$DEB" Architecture)" +got_ver="$(dpkg-deb -f "$DEB" Version)" +[[ "$got_arch" == "all" ]] || { echo "build-companion: Architecture '$got_arch' != all" >&2; exit 1; } +[[ "$got_ver" == "$VERSION" ]] || { echo "build-companion: Version '$got_ver' != '$VERSION'" >&2; exit 1; } + +echo "build-companion: built $(basename "$DEB") (Architecture: all, Version: $VERSION)" >&2 diff --git a/packaging/ci/companion-inventory.txt b/packaging/ci/companion-inventory.txt new file mode 100644 index 0000000..6b53357 --- /dev/null +++ b/packaging/ci/companion-inventory.txt @@ -0,0 +1,16 @@ +# companion-inventory.txt — the EXACT declared file inventory of ceralive-modem-support. +# +# `dpkg -L ceralive-modem-support` after a fresh install must list exactly these paths +# (directories excluded; docs excluded — changelog/copyright are debhelper's, not ours). +# The QA harness compares this list for EQUALITY, so an added, dropped or relocated asset +# fails the gate naming itself rather than silently shipping. +# +# Each line is a packaged path. The destination of each is a per-asset decision documented +# in debian/rules' installation table, not a blanket /usr/lib placement. +/usr/lib/ceralive-modem-support/ceralive-fcc-reconcile +/usr/lib/systemd/system/ceralive-fcc-reconcile.service +/usr/lib/udev/rules.d/60-ceralive-modem.rules +/usr/share/ceralive-modem-support/legacy-etc-overrides.sha256 +/usr/share/ceralive-modem-support/legacy/60-ceralive-modem.rules.legacy-v1 +/usr/share/usb_modeswitch/12d1:1f01 +/usr/share/usb_modeswitch/19d2:1225 diff --git a/packaging/ci/contract.sh b/packaging/ci/contract.sh index 441f235..db91d12 100755 --- a/packaging/ci/contract.sh +++ b/packaging/ci/contract.sh @@ -39,6 +39,38 @@ require "ci/build-bookworm.sh" require "ci/test-package-contract.sh" require "ci/daemon-smoke.sh" require "ci/generate-release-manifest.sh" +require "ci/build-companion.sh" +require "ci/test-companion-chroot.sh" +require "ci/companion-inventory.txt" +require "ceralive-modem-support/debian/control" +require "ceralive-modem-support/debian/rules" +require "ceralive-modem-support/debian/legacy-etc-overrides.sha256" +require "ceralive-modem-support/assets/udev/60-ceralive-modem.rules" +require "ceralive-modem-support/assets/systemd/ceralive-fcc-reconcile.service" +require "ceralive-modem-support/assets/fcc/ceralive-fcc-reconcile" + +# The companion's udev rules file may never carry a device MUTATION. This is a text contract +# because the failure it prevents — a generic package writing device permissions or running a +# command from udev — is invisible to every other gate in this repo. +echo " running companion udev no-mutation contract..." +if grep -nE '^[^#]*(RUN\+?=|MODE=|OWNER=|GROUP=|SYMLINK\+?=|ATTR\{[^}]+\}=[^=])' \ + "$PKG_ROOT/ceralive-modem-support/assets/udev/60-ceralive-modem.rules"; then + echo " MUTATION FOUND in 60-ceralive-modem.rules — the companion tags devices, it never mutates them" + fail=1 +else + echo " ok: companion udev rules are identification/tagging only" +fi + +# The packaged basename must not collide with an image-owned /etc/udev/rules.d basename: udev +# resolves by basename and an /etc file shadows the packaged one in a way dpkg -S cannot see. +echo " running companion udev basename contract..." +for reserved in 99-ceralive-hardware.rules 78-mm-ceralive-slot-uid.rules; do + if [ -e "$PKG_ROOT/ceralive-modem-support/assets/udev/$reserved" ]; then + echo " RESERVED BASENAME: the companion must not ship $reserved (image-owned)" + fail=1 + fi +done +echo " ok: companion ships no image-owned udev basename" # The tag-guard contract must hold. echo " running tag-guard contract..." diff --git a/packaging/ci/expected-packages.txt b/packaging/ci/expected-packages.txt index f95b99f..cda6219 100644 --- a/packaging/ci/expected-packages.txt +++ b/packaging/ci/expected-packages.txt @@ -97,3 +97,25 @@ libqrtr-glib0 libqrtr-glib-dev gir1.2-qrtr-1.0 libqrtr-glib0-dbgsym + +# ── FIRST-PARTY COMPANION (Architecture: all) ──────────────────────────────── +# `ceralive-modem-support` is NOT an upstream rebuild. It is the first-party companion that +# owns CeraLive's generic modem system assets so the four zero-patch sources above never have +# to absorb one. It is built ONCE (packaging/ci/build-companion.sh -> packaging/build/all/) +# and is a single immutable release asset with TWO index memberships — see closure_version 2 +# in generate-release-manifest.sh. +# +# `[arch-all sources]` names every source whose artifacts are Architecture: all. The manifest +# generator uses it to scope its per-source EQUALITY check to the right build directory: an +# arch-all source is asserted only against build/all, and the four arch-dependent sources only +# against build/. Without that scoping, build/all would be required to contain +# modemmanager and every per-arch dir would be required to contain the companion. + +[arch-all sources] +ceralive-modem-support + +[ceralive-modem-support declared] +ceralive-modem-support + +[ceralive-modem-support all-artifact] # no -dbgsym: the package ships no ELF at all +ceralive-modem-support diff --git a/packaging/ci/generate-release-manifest.sh b/packaging/ci/generate-release-manifest.sh index 1704bb8..3ce0a2c 100755 --- a/packaging/ci/generate-release-manifest.sh +++ b/packaging/ci/generate-release-manifest.sh @@ -9,6 +9,23 @@ # dpkg-free: it parses each `__.deb` filename and sha256sums the file, # so it runs identically on a CI runner, a bench box, or this dev host. # +# CLOSURE VERSION 2 — the versioned contract apt-worker's publisher validates against. +# v1 (legacy, still valid): 9 arch-dependent runtime packages x 2 arches = 18 runtime rows. +# v2 (this): the same 9 x 2, PLUS the first-party `Architecture: all` +# companion `ceralive-modem-support`, emitted as ONE row with +# arch column `all`. +# +# BUILD ARCHITECTURE vs INDEX MEMBERSHIP are different questions and the manifest now says +# so. Column 1 is the BUILD architecture of the artifact (`arm64`, `amd64`, or `all`). +# Index membership is DERIVED from it: `all` enters BOTH per-arch APT indexes, anything +# else enters its own. That is why the companion is ONE immutable release asset with TWO +# index memberships rather than two builds — two builds would produce two byte-different +# files claiming the same package/version key and break the publisher's immutable-key rule. +# +# The header therefore carries THREE counts rather than one, so a consumer never has to +# infer the shape: `runtime_closure_size` (per-arch, arch-dependent), `arch_all_closure_size`, +# and `index_arches`. +# # It also FAILS CLOSED if the produced set is not exactly the frozen all-artifact set: per arch, # per source, the enumerated packages must EQUAL `[ all-artifact]` in expected-packages.txt # (the two-set model finalized in todo 1.4). An added, dropped, renamed, or unmapped deb is a @@ -37,10 +54,19 @@ EXPECTED="${EXPECTED_PACKAGES:-$HERE/expected-packages.txt}" VERSION="${TAG#v}" SUFFIX="~ceralive${VERSION}" -# The 9-package runtime closure — used to mark rows (role) and to assert none is missing. +CLOSURE_VERSION=2 + +# The 9 arch-dependent runtime packages, and the arch-all runtime companion. RUNTIME_PKGS=(modemmanager libmm-glib0 libmbim-glib4 libmbim-proxy libmbim-utils \ libqmi-glib5 libqmi-proxy libqmi-utils libqrtr-glib0) -is_runtime() { local p="$1" r; for r in "${RUNTIME_PKGS[@]}"; do [ "$r" = "$p" ] && return 0; done; return 1; } +ARCH_ALL_RUNTIME_PKGS=(ceralive-modem-support) +INDEX_ARCHES=(arm64 amd64) + +is_runtime() { + local p="$1" r + for r in "${RUNTIME_PKGS[@]}" "${ARCH_ALL_RUNTIME_PKGS[@]}"; do [ "$r" = "$p" ] && return 0; done + return 1 +} # Parse "__.deb" -> pkg / version / arch (version never contains '_'). deb_field() { # @@ -54,6 +80,13 @@ deb_field() { # expected_sources() { awk -F'[][]' '/^\[[^]]+ all-artifact\]/ { split($2, a, " "); print a[1] }' "$EXPECTED" | LC_ALL=C sort -u } +is_arch_all_source() { + awk -v want="$1" ' + /^\[/ { insec=($0 ~ /^\[arch-all sources\]/) ? 1 : 0; next } + insec { l=$0; sub(/#.*$/, "", l); gsub(/[ \t]+/, "", l); if (l==want) { found=1 } } + END { exit(found ? 0 : 1) } + ' "$EXPECTED" +} expected_set() { # -> its all-artifact package list, sorted-unique awk -v want="[$1 all-artifact]" ' /^\[/ { h=$0; sub(/[ \t]*#.*$/, "", h); insec=(h==want)?1:0; next } @@ -79,12 +112,17 @@ mkdir -p "$(dirname "$OUT")" echo "version: ${VERSION}" echo "deb_version_suffix: ${SUFFIX}" echo "sources: [${SOURCES[*]}]" + echo "closure_version: ${CLOSURE_VERSION}" echo "runtime_closure_size: ${#RUNTIME_PKGS[@]}" - echo "# columns: arch package source version role filename sha256" + echo "arch_all_closure_size: ${#ARCH_ALL_RUNTIME_PKGS[@]}" + echo "index_arches: [${INDEX_ARCHES[*]}]" + echo "# columns: build_arch package source version role filename sha256" + echo "# build_arch 'all' => the artifact enters EVERY index arch; otherwise its own only." } > "$OUT" total_all=0 total_runtime=0 +total_arch_all=0 arches_seen=() rc=0 for archdir in "$BUILD_ROOT"/*/; do @@ -100,8 +138,20 @@ for archdir in "$BUILD_ROOT"/*/; do done produced_sorted="$(printf '%s\n' "${!FILE_OF[@]}" | LC_ALL=C sort -u)" - # (a) No runtime package may be absent (kept from the original closure check). - for pkg in "${RUNTIME_PKGS[@]}"; do + # A build directory is either an INDEX-ARCH directory (arm64/amd64, holding the four + # upstream sources' arch-dependent debs) or the single ARCH-ALL directory. Every check + # below is scoped to its kind: without that, build/all would be required to contain + # modemmanager and every per-arch dir would be required to contain the companion. + dir_is_arch_all=0 + [ "$arch" = "all" ] && dir_is_arch_all=1 + + # (a) No runtime package of this directory's kind may be absent. + if [ "$dir_is_arch_all" -eq 1 ]; then + required_pkgs=("${ARCH_ALL_RUNTIME_PKGS[@]}") + else + required_pkgs=("${RUNTIME_PKGS[@]}") + fi + for pkg in "${required_pkgs[@]}"; do [ -n "${FILE_OF[$pkg]:-}" ] || { echo "generate-release-manifest: MISSING runtime deb '$pkg' for $arch" >&2; exit 2; } done @@ -111,8 +161,14 @@ for archdir in "$BUILD_ROOT"/*/; do [ -n "${SOURCE_OF[$p]:-}" ] || { echo "generate-release-manifest: FAIL CLOSED — [$arch] produced package '$p' is in no expected all-artifact set" >&2; rc=3; } done <<< "$produced_sorted" - # (c) Per-source EQUALITY: produced set for this source == its all-artifact set. + # (c) Per-source EQUALITY: produced set for this source == its all-artifact set, + # over only the sources that belong in THIS directory's kind. for src in "${SOURCES[@]}"; do + if is_arch_all_source "$src"; then + [ "$dir_is_arch_all" -eq 1 ] || continue + else + [ "$dir_is_arch_all" -eq 0 ] || continue + fi want="$(expected_set "$src")" # `|| true`: the filter's last test can be a non-match (exit 1); pipefail would else # abort. An empty `got` simply fails the equality below, closed. @@ -132,7 +188,16 @@ for archdir in "$BUILD_ROOT"/*/; do fn="$(basename "$deb")" ver="$(deb_field "$fn" version)" sha="$(sha256sum "$deb" | awk '{print $1}')" - if is_runtime "$pkg"; then role=runtime; total_runtime=$((total_runtime + 1)); else role=aux; fi + if is_runtime "$pkg"; then + role=runtime + if [ "$dir_is_arch_all" -eq 1 ]; then + total_arch_all=$((total_arch_all + 1)) + else + total_runtime=$((total_runtime + 1)) + fi + else + role=aux + fi printf '%s %s %s %s %s %s %s\n' "$arch" "$pkg" "${SOURCE_OF[$pkg]:-UNKNOWN}" "$ver" "$role" "$fn" "$sha" >> "$OUT" total_all=$((total_all + 1)) done <<< "$produced_sorted" @@ -141,8 +206,8 @@ for archdir in "$BUILD_ROOT"/*/; do done { - echo "# arches: ${arches_seen[*]:-none}" - echo "# all_debs_total: ${total_all} runtime_debs_total: ${total_runtime} (across ${#arches_seen[@]} arch)" + echo "# build_dirs: ${arches_seen[*]:-none}" + echo "# all_debs_total: ${total_all} arch_runtime_rows: ${total_runtime} arch_all_runtime_rows: ${total_arch_all}" } >> "$OUT" [ "$total_all" -gt 0 ] || { echo "generate-release-manifest: no debs found under $BUILD_ROOT" >&2; exit 2; } @@ -152,4 +217,4 @@ if [ "$rc" -ne 0 ]; then fi cat "$OUT" -echo "generate-release-manifest: wrote $OUT (${total_all} deb rows, ${total_runtime} runtime, across ${#arches_seen[@]} arch)" >&2 +echo "generate-release-manifest: wrote $OUT (${total_all} deb rows; ${total_runtime} arch-runtime + ${total_arch_all} arch-all runtime; build dirs: ${arches_seen[*]})" >&2 diff --git a/packaging/ci/test-companion-chroot.sh b/packaging/ci/test-companion-chroot.sh new file mode 100755 index 0000000..e880a64 --- /dev/null +++ b/packaging/ci/test-companion-chroot.sh @@ -0,0 +1,268 @@ +#!/usr/bin/env bash +# test-companion-chroot.sh — the CHROOT-STAGE contract for ceralive-modem-support. +# +# TWO-STAGE SPLIT (deliberate). A clean container has no sysfs devices and no running +# daemons, so it can prove PACKAGING SHAPE and nothing else. This script is that stage: +# install / upgrade / downgrade / purge, /etc override precedence, the chroot guard, the +# two /etc-override maintscript branches, the absent-policy no-op, and single-owner. +# +# The CONSUMER stage — `udevadm test` against a real modem's sysfs path, `usb_modeswitch -c`, +# `systemd-analyze verify` + `systemctl is-enabled` + a real boot's journal ordering, and +# ModemManager's effective configuration listing — runs ON THE BENCH BOARD and is documented +# in docs/BENCH.md (RB-18). Do not fake it here; a green fake is worse than a recorded gap. +# +# USAGE test-companion-chroot.sh [image] (default: debian:trixie) +# ENV CONTAINER_ENGINE=podman|docker, COMPANION_TEST_DIR= +# EXIT 0 = every step passed; non-zero names the failing step. +set -euo pipefail + +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PKG_ROOT="$(cd "$HERE/.." && pwd)" +REPO_ROOT="$(cd "$PKG_ROOT/.." && pwd)" +IMAGE="${1:-${COMPANION_TEST_IMAGE:-debian:trixie}}" +WORK="${COMPANION_TEST_DIR:-$REPO_ROOT/test-results/companion-chroot}" + +ENGINE="${CONTAINER_ENGINE:-}" +if [[ -z "$ENGINE" ]]; then + if command -v podman >/dev/null 2>&1; then ENGINE=podman + elif command -v docker >/dev/null 2>&1; then ENGINE=docker + else echo "test-companion-chroot: no podman/docker available" >&2; exit 2; fi +fi + +rm -rf "$WORK" +mkdir -p "$WORK/debs" + +echo "== building the companion at two versions (old 0.9.0, new 1.1.0) ==" >&2 +BUILD_ROOT="$WORK/b-old" RELEASE_VERSION=v0.9.0 bash "$HERE/build-companion.sh" >"$WORK/build-old.log" 2>&1 +BUILD_ROOT="$WORK/b-new" RELEASE_VERSION=v1.1.0 bash "$HERE/build-companion.sh" >"$WORK/build-new.log" 2>&1 +cp "$WORK"/b-old/all/*.deb "$WORK"/b-new/all/*.deb "$WORK/debs/" +cp "$HERE/companion-inventory.txt" "$WORK/companion-inventory.txt" +cp "$PKG_ROOT/ceralive-modem-support/assets/legacy/60-ceralive-modem.rules.legacy-v1" "$WORK/legacy-payload" + +cat >"$WORK/run-in-container.sh" <<'CONTAINER_SCRIPT' +#!/bin/bash +# Runs INSIDE a clean Debian container. Everything below is dpkg + coreutils against the +# two locally-built .debs — no network, no apt transaction, no host state. +set -uo pipefail + +QA=/qa +OLD="$QA/debs/ceralive-modem-support_0.9.0_all.deb" +NEW="$QA/debs/ceralive-modem-support_1.1.0_all.deb" +RULES_ETC=/etc/udev/rules.d/60-ceralive-modem.rules +RULES_LIB=/usr/lib/udev/rules.d/60-ceralive-modem.rules +POSTINST=/var/lib/dpkg/info/ceralive-modem-support.postinst + +export DEBIAN_FRONTEND=noninteractive + +pass=0; fail=0 +ok() { printf ' ok %s\n' "$*"; pass=$((pass+1)); } +bad() { printf ' FAIL %s\n' "$*"; fail=$((fail+1)); } + +# The companion declares `Depends: udev`, so the install legs go through apt (which resolves +# it) rather than bare `dpkg -i` (which cannot). This is also the invocation an operator and +# the device image actually use. +apt-get update -qq >/tmp/apt-update.log 2>&1 || { echo "apt-get update failed"; cat /tmp/apt-update.log; exit 2; } + +installed_files() { + dpkg -L ceralive-modem-support 2>/dev/null | while IFS= read -r p; do + [ -f "$p" ] && printf '%s\n' "$p" + done | LC_ALL=C sort +} +declared_files() { + grep -v '^[[:space:]]*#' "$QA/companion-inventory.txt" | grep -v '^[[:space:]]*$' | LC_ALL=C sort +} + +echo "== 1. fresh install in a clean chroot ==" +if [ -d /run/udev ]; then bad "the container unexpectedly has /run/udev — the guard leg would be vacuous"; fi +if apt-get install -y -qq "$NEW" >/tmp/install.log 2>&1; then ok "apt-get install ./deb exit 0"; else bad "apt-get install failed"; cat /tmp/install.log; fi +[ -d /run/udev ] && { rmdir /run/udev 2>/dev/null || true; } + +got="$(installed_files | grep -v '^/usr/share/doc/')" +want="$(declared_files)" +if [ "$got" = "$want" ]; then + ok "dpkg -L lists exactly the declared inventory ($(printf '%s\n' "$want" | wc -l) paths)" +else + bad "dpkg -L != declared inventory" + diff <(printf '%s\n' "$want") <(printf '%s\n' "$got") | sed 's/^/ /' +fi + +echo "== 2. chroot guard: no /run/udev => postinst no-ops, no udevadm invocation ==" +if bash -x "$POSTINST" configure >/tmp/postinst-trace.log 2>&1; then + ok "postinst re-run exit 0 without /run/udev" +else + bad "postinst failed without /run/udev"; cat /tmp/postinst-trace.log +fi +if grep -q 'no /run/udev' /tmp/postinst-trace.log; then ok "postinst announced the chroot skip"; else bad "no chroot-skip announcement"; fi +if grep -qE '^\+.*udevadm (control|trigger)' /tmp/postinst-trace.log; then + bad "postinst invoked udevadm without /run/udev" +else + ok "set -x trace contains NO udevadm control/trigger invocation" +fi + +echo "== 2b. non-vacuity: with /run/udev present the guard lets the reload path run ==" +mkdir -p /run/udev +bash -x "$POSTINST" configure >/tmp/postinst-udev.log 2>&1 +if grep -q 'no /run/udev' /tmp/postinst-udev.log; then + bad "the guard still short-circuited even though /run/udev exists" +else + ok "with /run/udev present the guard does NOT short-circuit" +fi +# The trigger must be NARROW. A bare `udevadm trigger` would re-run every rule for every +# device on the system; assert no such call exists in the shipped script text. +if grep -nE 'udevadm trigger *$|udevadm trigger +\|\||udevadm trigger *#' "$POSTINST" >/dev/null 2>&1; then + bad "postinst contains a BARE udevadm trigger (no subsystem match)" +else + ok "every udevadm trigger in the shipped postinst carries --subsystem-match" +fi +rmdir /run/udev + +echo "== 3. single-owner: dpkg -S names exactly one package per packaged path ==" +multi=0 +while IFS= read -r p; do + owners="$(dpkg -S "$p" 2>/dev/null | cut -d: -f1 | tr ',' '\n' | sed 's/ //g' | grep . | sort -u)" + n="$(printf '%s\n' "$owners" | grep -c . )" + if [ "$n" != "1" ] || [ "$owners" != "ceralive-modem-support" ]; then + bad "dpkg -S $p => '$owners' (expected exactly ceralive-modem-support)"; multi=1 + fi +done < <(declared_files) +[ "$multi" -eq 0 ] && ok "every declared path has exactly one owner" + +echo "== 4. /etc override precedence ==" +mkdir -p /etc/udev/rules.d +printf '# operator override\n' > "$RULES_ETC" +winner="" +for d in /etc/udev/rules.d /run/udev/rules.d /usr/local/lib/udev/rules.d /usr/lib/udev/rules.d /lib/udev/rules.d; do + [ -f "$d/60-ceralive-modem.rules" ] && { winner="$d/60-ceralive-modem.rules"; break; } +done +if [ "$winner" = "$RULES_ETC" ]; then ok "the /etc copy wins udev's basename precedence"; else bad "precedence resolved to '$winner'"; fi +if dpkg -S "$RULES_LIB" >/dev/null 2>&1 && ! dpkg -S "$RULES_ETC" >/dev/null 2>&1; then + ok "dpkg -S cannot see the shadowing /etc file (the exact hazard the basename rule exists for)" +else + bad "dpkg -S reported the /etc shadow — assumption broken" +fi +rm -f "$RULES_ETC" + +echo "== 5. /etc override maintscript branches ==" +echo "-- 5a. marker-carrying but operator-MODIFIED file is PRESERVED --" +{ printf '# CERALIVE-GENERATED: modem-udev v1 — DO NOT EDIT\n'; printf '# operator added this line\n'; } > "$RULES_ETC" +bash "$POSTINST" configure >/tmp/preserve.log 2>&1 +if [ -f "$RULES_ETC" ]; then ok "marker-carrying but MODIFIED /etc file preserved"; else bad "an operator-modified /etc file was deleted"; fi +grep -q 'preserving modified' /tmp/preserve.log && ok "preservation was announced" || bad "no modified-preservation announcement" + +echo "-- 5b. unmarked operator file is PRESERVED --" +printf '# my own rules\n' > "$RULES_ETC" +bash "$POSTINST" configure >/tmp/preserve2.log 2>&1 +if [ -f "$RULES_ETC" ]; then ok "unmarked operator file preserved"; else bad "an unmarked operator file was deleted"; fi +grep -q 'no generated marker' /tmp/preserve2.log && ok "no-marker preservation was announced" || bad "no no-marker announcement" + +echo "-- 5c. KNOWN-STALE generated payload IS removed --" +cp "$QA/legacy-payload" "$RULES_ETC" +bash "$POSTINST" configure >/tmp/stale.log 2>&1 +if [ ! -e "$RULES_ETC" ]; then ok "known-stale generated override removed"; else bad "known-stale override survived"; cat /tmp/stale.log; fi +grep -q 'removed stale generated override' /tmp/stale.log && ok "removal was announced" || bad "no removal announcement" + +echo "== 6. FCC reconcile ==" +# ModemManager's tiers, spelled the way Debian's packaging actually installs them: +# the shipped-but-inert AVAILABLE tier is `fcc-unlock.available.d` under /usr/share, +# and the tier the dispatcher consults first is the ADMIN one under /etc. The +# available tier holds one real script per VENDOR plus a `:` symlink onto +# it per covered model — the dispatcher only ever opens the `:` name. +AVAILABLE=/usr/share/ModemManager/fcc-unlock.available.d +ACTIVE=/etc/ModemManager/fcc-unlock.d +mkdir -p "$AVAILABLE" +printf '#!/bin/sh\nexit 0\n' > "$AVAILABLE/2c7c" +chmod +x "$AVAILABLE/2c7c" +ln -sfn 2c7c "$AVAILABLE/2c7c:0801" +ln -sfn 2c7c "$AVAILABLE/2c7c:0313" +rm -rf "$ACTIVE" + +echo "-- 6a. absent policy exits 0 and creates NO active symlink --" +if /usr/lib/ceralive-modem-support/ceralive-fcc-reconcile >/tmp/fcc-absent.log 2>&1; then + ok "reconciler exits 0 with no /data policy (generic Debian, no CeraLive layout)" +else + bad "reconciler failed with no policy"; cat /tmp/fcc-absent.log +fi +if [ -z "$(find "$ACTIVE" -type l 2>/dev/null)" ]; then ok "no active FCC symlink created without a policy"; else bad "an active FCC symlink appeared without a policy"; fi + +echo "-- 6b. an enabling policy activates exactly the enabled MODEL --" +mkdir -p /data/ceralive +printf '{"schemaVersion":1,"savedAtMs":1,"unlock":{"2c7c:0801":true,"2c7c:0313":false}}\n' > /data/ceralive/fcc-unlock-policy.json +/usr/lib/ceralive-modem-support/ceralive-fcc-reconcile >/tmp/fcc-on.log 2>&1 +if [ -L "$ACTIVE/2c7c:0801" ]; then ok "policy-enabled 2c7c:0801 activated in the ADMIN tier"; else bad "2c7c:0801 not activated"; cat /tmp/fcc-on.log; fi +if [ -e "$ACTIVE/2c7c:0313" ]; then bad "policy-disabled 2c7c:0313 was activated"; else ok "policy-disabled 2c7c:0313 stayed inactive"; fi +# The vendor-only name is NOT what the dispatcher opens, so it must never be linked. +if [ -e "$ACTIVE/2c7c" ]; then bad "a vendor-only name was linked (the dispatcher never opens it)"; else ok "no vendor-only link created"; fi + +echo "-- 6c. an opt-out removes the link the previous run created --" +printf '{"schemaVersion":1,"savedAtMs":2,"unlock":{"2c7c:0801":false}}\n' > /data/ceralive/fcc-unlock-policy.json +/usr/lib/ceralive-modem-support/ceralive-fcc-reconcile >/tmp/fcc-off.log 2>&1 +if [ -e "$ACTIVE/2c7c:0801" ]; then bad "an opt-out left the active symlink behind"; else ok "opt-out deactivated the model"; fi + +echo "-- 6d. a MALFORMED policy is treated as absent (fail-safe, never fail-open) --" +printf '{"schemaVersion":1,"savedAtMs":1,"unlock":{"2c7c:0801":true}}\n' > /data/ceralive/fcc-unlock-policy.json +/usr/lib/ceralive-modem-support/ceralive-fcc-reconcile >/dev/null 2>&1 +printf '{"schemaVersion":1,"savedAtMs":1,"unlock":{"2c7c:0801":yes}}\n' > /data/ceralive/fcc-unlock-policy.json +/usr/lib/ceralive-modem-support/ceralive-fcc-reconcile >/tmp/fcc-bad.log 2>&1 +if [ -e "$ACTIVE/2c7c:0801" ]; then bad "a malformed policy left an active FCC symlink"; else ok "malformed policy deactivated everything"; fi +grep -q 'malformed' /tmp/fcc-bad.log && ok "malformed policy was announced" || bad "no malformed announcement" + +echo "-- 6e. a real file in the ADMIN tier is somebody else's property --" +mkdir -p "$ACTIVE" +printf '#!/bin/sh\nexit 0\n' > "$ACTIVE/2c7c:0801" +printf '{"schemaVersion":1,"savedAtMs":3,"unlock":{"2c7c:0801":true}}\n' > /data/ceralive/fcc-unlock-policy.json +/usr/lib/ceralive-modem-support/ceralive-fcc-reconcile >/tmp/fcc-foreign.log 2>&1 +if [ -L "$ACTIVE/2c7c:0801" ]; then bad "a foreign REAL file was replaced by our symlink"; else ok "a foreign real file was left alone"; fi +rm -f "$ACTIVE/2c7c:0801" +rm -rf /data + +echo "== 7. upgrade path (0.9.0 -> 1.1.0), no conffile prompt ==" +dpkg -P ceralive-modem-support >/dev/null 2>&1 +apt-get install -y -qq --allow-downgrades "$OLD" >/tmp/up1.log 2>&1 || { bad "installing the old version failed"; cat /tmp/up1.log; } +[ "$(dpkg-query -W -f='${Version}' ceralive-modem-support)" = "0.9.0" ] && ok "old version installed" || bad "old version not 0.9.0" +if apt-get install -y -qq "$NEW" >/tmp/up2.log 2>&1; then ok "upgrade exit 0"; else bad "upgrade failed"; cat /tmp/up2.log; fi +[ "$(dpkg-query -W -f='${Version}' ceralive-modem-support)" = "1.1.0" ] && ok "upgraded to 1.1.0" || bad "version did not advance" +if [ -z "$(dpkg-query -W -f='${Conffiles}' ceralive-modem-support | tr -d ' \n')" ]; then + ok "package declares ZERO conffiles (a conffile prompt is structurally impossible)" +else + bad "package declares conffiles: $(dpkg-query -W -f='${Conffiles}' ceralive-modem-support)" +fi +if grep -qi 'configuration file.*modified\|what would you like to do' /tmp/up2.log; then + bad "upgrade emitted a conffile prompt" +else + ok "upgrade transcript carries no conffile prompt" +fi + +echo "== 8. downgrade path (1.1.0 -> 0.9.0) ==" +if apt-get install -y -qq --allow-downgrades "$OLD" >/tmp/down.log 2>&1; then ok "downgrade exit 0"; else bad "downgrade failed"; cat /tmp/down.log; fi +[ "$(dpkg-query -W -f='${Version}' ceralive-modem-support)" = "0.9.0" ] && ok "downgraded to 0.9.0" || bad "downgrade did not take" + +echo "== 9. purge leaves zero leftovers ==" +mkdir -p "$ACTIVE"; ln -sfn /usr/share/ModemManager/fcc-unlock.d/2c7c "$ACTIVE/2c7c" +printf 'not ours\n' > /usr/share/ModemManager/fcc-unlock.d/keepme +if dpkg -P ceralive-modem-support >/tmp/purge.log 2>&1; then ok "dpkg -P exit 0"; else bad "purge failed"; cat /tmp/purge.log; fi +if dpkg -L ceralive-modem-support >/dev/null 2>&1; then bad "dpkg -L still resolves after purge"; else ok "dpkg -L errors after purge"; fi +left=0 +while IFS= read -r p; do + [ -e "$p" ] && { bad "leftover after purge: $p"; left=1; } +done < <(declared_files) +for d in /usr/lib/ceralive-modem-support /usr/share/ceralive-modem-support; do + [ -e "$d" ] && { bad "leftover directory after purge: $d"; left=1; } +done +[ -L "$ACTIVE/2c7c" ] && { bad "purge left an active FCC symlink behind"; left=1; } +[ -f /usr/share/ModemManager/fcc-unlock.d/keepme ] || { bad "purge deleted a file that was not ours"; left=1; } +[ "$left" -eq 0 ] && ok "zero leftovers over every declared path; foreign files untouched" + +echo +echo "== summary: $pass passed, $fail failed ==" +[ "$fail" -eq 0 ] +CONTAINER_SCRIPT + +chmod +x "$WORK/run-in-container.sh" + +echo "== running the dpkg matrix in a clean $IMAGE container ==" >&2 +set +e +"$ENGINE" run --rm -v "$WORK:/qa:z" "$IMAGE" bash /qa/run-in-container.sh 2>&1 | tee "$WORK/chroot-qa.log" +rc="${PIPESTATUS[0]}" +set -e +echo "test-companion-chroot: transcript at $WORK/chroot-qa.log" >&2 +exit "$rc" diff --git a/packaging/ci/test-fcc-reconcile.sh b/packaging/ci/test-fcc-reconcile.sh new file mode 100755 index 0000000..044514a --- /dev/null +++ b/packaging/ci/test-fcc-reconcile.sh @@ -0,0 +1,128 @@ +#!/usr/bin/env bash +# The FCC reconciler's BEHAVIOUR contract, runnable on any host. +# +# The chroot contract (`test-companion-chroot.sh` § 6) proves the reconciler works +# from its PACKAGED location after a real dpkg install. This script proves the same +# logic against the source file with every path redirected into a scratch tree, so it +# needs no container, no root and no ModemManager — which is what makes it usable as +# the pre-bench proof for the todo-33 policy work and as a fast local gate. +# +# Both are kept: this one can be run in a second, that one proves the packaging. + +set -euo pipefail + +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# CERALIVE_FCC_RECONCILE_BIN lets the SAME suite run against a copy that is not +# beside this script — the INSTALLED helper on a bench board, which is how the +# packaged path gets exercised without a container. +RECONCILE="${CERALIVE_FCC_RECONCILE_BIN:-$HERE/../ceralive-modem-support/assets/fcc/ceralive-fcc-reconcile}" +[ -x "$RECONCILE" ] || { echo "not executable: $RECONCILE" >&2; exit 1; } + +pass=0; fail=0 +ok() { printf ' ok %s\n' "$1"; pass=$((pass + 1)); } +bad() { printf ' FAIL %s\n' "$1"; fail=$((fail + 1)); } + +ROOT="$(mktemp -d "${TMPDIR:-/tmp}/ceralive-fcc-reconcile.XXXXXX")" +trap 'rm -rf "$ROOT"' EXIT + +AVAILABLE="$ROOT/available" +ACTIVE="$ROOT/active" +POLICY="$ROOT/data/ceralive/fcc-unlock-policy.json" +mkdir -p "$AVAILABLE" "$(dirname "$POLICY")" + +# The available tier's real shape: one script per VENDOR, plus a `:` symlink +# onto it for every model that vendor script covers. The dispatcher opens ONLY the +# `:` name, which is why the policy keys on it. +printf '#!/bin/sh\nexit 0\n' > "$AVAILABLE/2c7c" +chmod +x "$AVAILABLE/2c7c" +ln -sfn 2c7c "$AVAILABLE/2c7c:0801" +ln -sfn 2c7c "$AVAILABLE/2c7c:0313" + +run() { + CERALIVE_FCC_POLICY_FILE="$POLICY" \ + CERALIVE_FCC_AVAILABLE_DIR="$AVAILABLE" \ + CERALIVE_FCC_ACTIVE_DIR="$ACTIVE" \ + "$RECONCILE" 2>&1 +} + +write_policy() { printf '%s\n' "$1" > "$POLICY"; } + +echo "-- absent policy: exit 0, activate nothing --" +rm -rf "$ACTIVE"; rm -f "$POLICY" +if out="$(run)"; then ok "exit 0 with no policy"; else bad "non-zero exit with no policy"; fi +[ -z "$(find "$ACTIVE" -type l 2>/dev/null)" ] && ok "no link created" || bad "a link appeared with no policy" + +echo "-- an absent AVAILABLE tier is a clean no-op (generic Debian) --" +if CERALIVE_FCC_POLICY_FILE="$POLICY" CERALIVE_FCC_AVAILABLE_DIR="$ROOT/nope" \ + CERALIVE_FCC_ACTIVE_DIR="$ACTIVE" "$RECONCILE" >/dev/null 2>&1; then + ok "exit 0 with no ModemManager available tier" +else + bad "non-zero exit with no available tier" +fi + +echo "-- enabling policy: exactly the enabled MODEL, in the ADMIN tier --" +write_policy '{"schemaVersion":1,"savedAtMs":1,"unlock":{"2c7c:0801":true,"2c7c:0313":false}}' +run >/dev/null +[ -L "$ACTIVE/2c7c:0801" ] && ok "2c7c:0801 activated" || bad "2c7c:0801 not activated" +[ -e "$ACTIVE/2c7c:0313" ] && bad "disabled model was activated" || ok "disabled model stayed inactive" +[ -e "$ACTIVE/2c7c" ] && bad "a vendor-only name was linked" || ok "no vendor-only link (the dispatcher never opens one)" +[ "$(readlink "$ACTIVE/2c7c:0801")" = "$AVAILABLE/2c7c:0801" ] \ + && ok "the link targets the available tier's model entry" \ + || bad "unexpected link target: $(readlink "$ACTIVE/2c7c:0801")" + +echo "-- a single-entry policy is not read as an empty one --" +rm -rf "$ACTIVE" +write_policy '{"schemaVersion":1,"savedAtMs":1,"unlock":{"2c7c:0801":true}}' +run >/dev/null +[ -L "$ACTIVE/2c7c:0801" ] && ok "one-model policy activated" || bad "one-model policy activated nothing" + +echo "-- re-running is idempotent --" +before="$(readlink "$ACTIVE/2c7c:0801")" +run >/dev/null +[ "$(readlink "$ACTIVE/2c7c:0801")" = "$before" ] && ok "second run left the link unchanged" || bad "second run changed the link" + +echo "-- opt-out removes what the previous run created --" +write_policy '{"schemaVersion":1,"savedAtMs":2,"unlock":{"2c7c:0801":false}}' +run >/dev/null +[ -e "$ACTIVE/2c7c:0801" ] && bad "opt-out left the link behind" || ok "opt-out deactivated the model" + +echo "-- a malformed policy is treated as absent, and says so --" +write_policy '{"schemaVersion":1,"savedAtMs":1,"unlock":{"2c7c:0801":true}}' +run >/dev/null +write_policy '{"schemaVersion":1,"savedAtMs":1,"unlock":{"2c7c:0801":yes}}' +out="$(run)" +[ -e "$ACTIVE/2c7c:0801" ] && bad "malformed policy left an active link" || ok "malformed policy activated nothing" +printf '%s' "$out" | grep -q 'malformed' && ok "malformed policy announced" || bad "no malformed announcement" + +echo "-- a vendor-only KEY names a file the dispatcher never opens, and is refused --" +rm -rf "$ACTIVE" +write_policy '{"schemaVersion":1,"savedAtMs":1,"unlock":{"2c7c":true}}' +out="$(run)" +[ -e "$ACTIVE/2c7c" ] && bad "a vendor-only key was activated" || ok "vendor-only key refused" +printf '%s' "$out" | grep -q 'malformed' && ok "vendor-only key reported as malformed" || bad "vendor-only key silently ignored" + +echo "-- an enabled model MM ships no script for is skipped, loudly --" +rm -rf "$ACTIVE" +write_policy '{"schemaVersion":1,"savedAtMs":1,"unlock":{"1199:9079":true}}' +out="$(run)" +[ -e "$ACTIVE/1199:9079" ] && bad "a link was created for an absent script" || ok "absent script skipped" +printf '%s' "$out" | grep -q 'ships no' && ok "the skip was announced" || bad "the skip was silent" + +echo "-- a foreign REAL file in the ADMIN tier is left alone --" +mkdir -p "$ACTIVE" +printf '#!/bin/sh\nexit 0\n' > "$ACTIVE/2c7c:0801" +write_policy '{"schemaVersion":1,"savedAtMs":1,"unlock":{"2c7c:0801":true}}' +run >/dev/null +[ -L "$ACTIVE/2c7c:0801" ] && bad "a foreign real file was replaced" || ok "foreign real file untouched" +rm -f "$ACTIVE/2c7c:0801" + +echo "-- a foreign SYMLINK pointing outside the available tier is left alone --" +printf 'x\n' > "$ROOT/elsewhere" +ln -sfn "$ROOT/elsewhere" "$ACTIVE/1199:9079" +write_policy '{"schemaVersion":1,"savedAtMs":1,"unlock":{}}' +run >/dev/null +[ -L "$ACTIVE/1199:9079" ] && ok "foreign symlink untouched" || bad "a foreign symlink was pruned" + +echo +echo "== fcc-reconcile: $pass passed, $fail failed ==" +[ "$fail" -eq 0 ] diff --git a/tsconfig.json b/tsconfig.json index f42f431..d9568e9 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -14,7 +14,30 @@ "noFallthroughCasesInSwitch": true, "verbatimModuleSyntax": true, "skipLibCheck": true, - "resolveJsonModule": true + "resolveJsonModule": true, + + // The workspace develops against `control/src`, never against `control/dist`. + // + // `@ceralive/modem-control` publishes BUILT output, so its exports map points at + // `dist`. Left alone, the workspace `cli` would resolve the package through that + // map while `control/test-support` resolves the same modules relatively — two + // type identities for one module, which `Brand`'s `unique symbol` turns into hard + // errors the moment a branded value crosses between them. It would also mean the + // whole workspace could not typecheck or test until `dist` existed. + // + // These mappings are DEV-ONLY: they live in the repo's tsconfig, never in the + // published package, whose exports map still resolves to `dist` for consumers. + // The built artifact is proven separately by the standalone consumer fixtures in + // `control/fixtures/`, which install the real tarball and know nothing of this file. + "paths": { + "@ceralive/modem-control": ["./control/src/index.ts"], + "@ceralive/modem-control/transport": ["./control/src/transport/index.ts"], + "@ceralive/modem-control/domain": ["./control/src/domain/index.ts"], + "@ceralive/modem-control/providers": ["./control/src/providers/index.ts"], + "@ceralive/modem-control/capabilities": ["./control/src/capability/index.ts"], + "@ceralive/modem-control/hardware": ["./control/src/hardware/index.ts"], + "@ceralive/modem-control/testing": ["./control/src/testing/index.ts"] + } }, - "include": ["control/src", "control/test-support", "cli/src", "cli/smoke"] + "include": ["control/src", "control/scripts", "control/test-support", "cli/src", "cli/smoke"] }