diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d4fd304..6193c43 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -128,51 +128,56 @@ jobs: run: npm publish --access public build-deb: - name: Build .deb set (specified version injection) + name: Build .deb set + contract + daemon smoke (real artifacts) needs: [tag-guard, test] + # Runs on the runner host (NOT inside a container): build-bookworm.sh and the contract / + # smoke scripts each launch their OWN debian:bookworm containers, so the job needs the + # host docker daemon, not to be a container itself. runs-on: ubuntu-latest - container: debian:bookworm steps: - # git for actions/checkout; devscripts/dpkg-dev provide dch + dpkg-parsechangelog. - - name: Install build prerequisites - run: | - apt-get update - apt-get install -y --no-install-recommends \ - git ca-certificates devscripts dpkg-dev ccache - - uses: actions/checkout@v7 - # Strip the leading `v` and inject -~ceraliveX.Y.Z as the top - # debian/changelog entry of each of the 4 sources via - # dch --force-bad-version --newversion "" "CeraLive rebuild" - # (--force-bad-version is REQUIRED — the tilde version is LOWER than the pinned - # -1 changelog top; plain `dch --newversion` refuses it, per dch(1)). - # Until the packaging wave lands real changelogs this documents the exact per-source - # invocation and exits 0. - - name: Inject encoded .deb version into each source changelog + # arm64 .debs are built under full-system QEMU (never cross-compiled); register binfmt. + - name: Set up QEMU (arm64 emulation) + uses: docker/setup-qemu-action@v3 + + # Real rebuilds. RELEASE_VERSION injects -~ceraliveX.Y.Z into a COPY of + # each source's debian/changelog inside the container (the committed tree is never + # mutated), builds in bootstrap order on native amd64 + QEMU arm64, and asserts the + # 9-package runtime closure per arch. --force-bad-version is applied by inject-deb-version. + - name: Build the MM 1.24 stack (.deb) — amd64 + arm64 env: - DEBFULLNAME: CeraLive CI - DEBEMAIL: ci@ceralive.tv - run: bash packaging/ci/inject-deb-version.sh "${{ github.event.inputs.tag }}" + RELEASE_VERSION: ${{ github.event.inputs.tag }} + run: | + packaging/ci/build-bookworm.sh amd64 + packaging/ci/build-bookworm.sh arm64 - - name: Assemble release manifest (tag -> deb versions) + # Package contract: amd64 full (metadata / closure install / upgrade / rollback / + # coherence / ordering / tag-guard / piuparts); arm64 metadata-level (apt-install under + # QEMU is prohibitively slow on a runner — the metadata/coherence/ordering proofs run). + - name: Package contract suite run: | - mkdir -p dist - { - echo "tag: ${{ github.event.inputs.tag }}" - echo "version: ${{ needs.tag-guard.outputs.version }}" - echo "deb_version_suffix: ~ceralive${{ needs.tag-guard.outputs.version }}" - echo "sources: [ModemManager, libmbim, libqmi, libqrtr-glib]" - } > dist/release-manifest.txt - cat dist/release-manifest.txt - - # `warn` (not `error`): no real .deb exists until the packaging wave lands recipes; - # the manifest still uploads so the release artifact set is present. + packaging/ci/test-package-contract.sh amd64 + packaging/ci/test-package-contract.sh arm64 + + # Daemon smoke: system D-Bus + polkit + NetworkManager 1.42, start MM, busctl + # introspect the root ObjectManager, mmcli --version == 1.24.0, udev/FCC/GIR/Vala paths. + - name: Daemon smoke (amd64) + run: packaging/ci/daemon-smoke.sh amd64 + + # Per-release manifest: the release tag -> the 9 runtime deb versions per arch + # (Phase-B apt publication consumes this as its package -> source -> version matrix). + - name: Generate release manifest + run: packaging/ci/generate-release-manifest.sh "${{ github.event.inputs.tag }}" + + # Upload the REAL built .debs (both arches: 9 runtime + dev/gir/dbgsym) + the manifest as + # CI release artifacts. Bench devices download this set and `apt install ./*.deb`. - name: Upload .deb artifacts + release manifest uses: actions/upload-artifact@v7 with: name: modem-stack-debs-${{ needs.tag-guard.outputs.version }} path: | - dist/** - packaging/**/*.deb - if-no-files-found: warn + dist/release-manifest.txt + packaging/build/amd64/*.deb + packaging/build/arm64/*.deb + if-no-files-found: error diff --git a/.gitignore b/.gitignore index 48a3c80..4b6af97 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,9 @@ node_modules/ dist/ *.tsbuildinfo +# Packaging build artifacts (.deb output from packaging/ci/build-bookworm.sh) +packaging/build/ + # Local QA evidence (agent-generated, never committed) test-results/ diff --git a/AGENTS.md b/AGENTS.md index 6338501..b35e044 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -67,7 +67,14 @@ bun run typecheck # tsc --noEmit (strict + exactOptionalPropertyTypes) ``` `packaging/` runs in a `debian:bookworm` container; its contract/verification scripts live -under `packaging/ci/`. +under `packaging/ci/`. The four sources' `debian/` recipes are checked in at +`packaging//debian/` (`ModemManager`, `libmbim`, `libqmi`, `libqrtr-glib` — +byte-identical to their pinned salsa commits except the bookworm adaptations documented in +`packaging/BOOKWORM-ADAPTATIONS.md`). `packaging/ci/build-bookworm.sh ` rebuilds +them from source in the mandatory bootstrap order (`libqrtr-glib → libmbim → libqmi → +modemmanager`) via a temporary local apt repo, on native amd64 or full-system-QEMU arm64 +(never cross-built), and asserts the 9-package runtime closure. `.deb` output lands in the +gitignored `packaging/build//`. ## CI / CD @@ -78,8 +85,9 @@ major action versions, per-manager caches, weekly grouped Dependabot, test-befor `control/**` and `cli/**`: `bun install` → Biome check → `tsc --noEmit` → `bun test`. `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`. At this - stage the contract lane is a stub (real recipes land in a later task). + for `packaging/**`: runs the packaging contract scripts in `debian:bookworm`. The four + `debian/` recipes and `build-bookworm.sh` now exist; the full contract suite (metadata / + closure / upgrade / rollback / daemon smoke) lands in a later task. `cancel-in-progress: true`. - **`.github/workflows/release.yml`** — the **single** release workflow, owns **both** artifacts. `workflow_dispatch` with a `tag` input. Job graph: diff --git a/bun.lock b/bun.lock index 393f669..74bd565 100644 --- a/bun.lock +++ b/bun.lock @@ -19,11 +19,16 @@ }, "dependencies": { "@ceralive/modem-control": "workspace:*", + "zod": "4.4.3", }, }, "control": { "name": "@ceralive/modem-control", "version": "0.1.0", + "dependencies": { + "@httptoolkit/dbus-native": "0.1.5", + "zod": "4.4.3", + }, }, }, "packages": { @@ -49,16 +54,56 @@ "@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=="], + "@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=="], + "anynum": ["anynum@1.0.1", "", {}, "sha512-N6//FLET/tXYNM/F6ABca1oH6fWB+KlTt909Le28WMDBk8oaT4vY17DCrwg2MvmuqUKt3Ni4N5dGJ/EoBgcO6A=="], + "bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="], + "duplexer": ["duplexer@0.1.2", "", {}, "sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg=="], + + "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-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=="], + + "from": ["from@0.1.7", "", {}, "sha512-twe20eF1OxVxp/ML/kq2p1uc6KvFK/+vs8WjEbeKmV2He22MKm7YF2ANIt+EOqhJ5L3K/SuuPhk0hWQDjOM23g=="], + + "is-unsafe": ["is-unsafe@2.0.0", "", {}, "sha512-2LdV822R+wmI86unXA93WCFpL6g+av8ynWk0nrHyJqGop5VoocYsSLFgN8jrfalT6iGeLNM4KXuVSsULP53kEA=="], + + "long": ["long@4.0.0", "", {}, "sha512-XsP+KhQif4bjX1kbuSiySJFNAehNxgLb6hPRGJ9QsUr8ajHkuXGdrHmFUTUUXhDwVX2R5bY4JNZEwbUiMhV+MA=="], + + "map-stream": ["map-stream@0.0.7", "", {}, "sha512-C0X0KQmGm3N2ftbTGBhSyuydQ+vV1LC3f3zPvT3RXHXNZrvfPZcoXp/N5DOa8vedX/rTMm2CjTtivFg2STJMRQ=="], + "modem-control-cli": ["modem-control-cli@workspace:cli"], + "path-expression-matcher": ["path-expression-matcher@1.6.2", "", {}, "sha512-enSlaiat05iasnzmgNxRj8reFdj3puY2QpNgP1aPIaVfT6nn9ICuPoFlKHk8EN22HcwewshO+mN2DGbkCEOtqQ=="], + + "pause-stream": ["pause-stream@0.0.11", "", { "dependencies": { "through": "~2.3" } }, "sha512-e3FBlXLmN/D1S+zHzanP4E/4Z60oFAa3O051qt1pxa7DEJWKAyil6upYVXCWadEnuoqa4Pkc9oUx9zsxYeRv8A=="], + + "safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], + + "split": ["split@1.0.1", "", { "dependencies": { "through": "2" } }, "sha512-mTyOoPbrivtXnwnIxZRFYRrPNtEFKlpB2fvjSnCQUiAA6qAZzqwna5envK4uk6OIeP17CsdF3rSBGYVBsU0Tkg=="], + + "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=="], + + "through": ["through@2.3.8", "", {}, "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg=="], + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], "undici-types": ["undici-types@8.3.0", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="], + + "xml-naming": ["xml-naming@0.3.0", "", {}, "sha512-ghig2TBE/H11aOVgmahA3MhimvkBr6JIYknH/Dhdk10nXwdbIqBJsbfMxpvFPG8bAw77gN29aQWvKpmVoPlvPQ=="], + + "zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="], } } diff --git a/cli/README.md b/cli/README.md new file mode 100644 index 0000000..5079534 --- /dev/null +++ b/cli/README.md @@ -0,0 +1,94 @@ +# modem-control — bench CLI + +The iteration surface for the CeraLive modem stack. It drives the +[`@ceralive/modem-control`](../control/) library against real modems on a bench device +to mature the package and (with `certify`) capture per-SKU certification bundles. + +## Commands + +| Command | What it does | +|---------|--------------| +| `probe` | One-shot stack snapshot: identities (+ identity-ladder resolution), lifecycle state, MM feature detection, read-only enrichment, cell info, and classified USB devices. Ends with `PROBE OK: external-auth, objects=`. | +| `watch` | Live event stream. Prints `+ ADDED` / `~ CHANGED` / `- REMOVED` per change and `! SOURCE-UNAVAILABLE` on a bus drop. A bus drop / MM restart marks a modem source-unavailable with its row **retained** — never a removal (A3.1 epoch authority). `--duration ` / `--events ` bound the run; otherwise it runs until Ctrl-C. | +| `apply --policy ` | Reads a JSON/YAML desired-state policy, derives the modem's durable binding key (refusing an ambiguous identity), runs the reconcile planner, applies the ops, and prints one receipt per policy dimension. | +| `set-usb-mode --confirm` | Runs a certified USB-mode transition. **Omitting `--confirm` refuses the transition with zero side effects.** `` is one of `qmi` / `mbim` / `ecm-ncm`. | +| `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. | + +### Global options + +- `--bus-address ` (or env `MODEM_CONTROL_BUS_ADDRESS`) — the D-Bus bus address. + Defaults to the system bus (`unix:path=/var/run/dbus/system_bus_socket`), where a real + ModemManager lives. This is the injection point that points the CLI at a fake MM service + for harness-driven tests and the compiled-probe smoke. +- `--destination ` — override the ModemManager bus name. + +### Policy file + +`apply --policy` accepts JSON or YAML (by file extension). The file carries the desired +intent only — `apply` binds it to the selected modem's live identity: + +```json +{ + "slot": "Modem/0", + "enabled": true, + "connection": { "apn": "auto", "ipFamily": "ipv4v6" }, + "roaming": false, + "radio": { "preferenceOrdered": ["5gnr", "lte", "umts", "gsm"] }, + "recovery": { "enabled": false }, + "usage": { "cycleDay": 1, "thresholdBytes": 5000000000 } +} +``` + +## Develop + +From the repository root (single Bun workspace): + +```sh +bun install +bun run typecheck # tsc --noEmit (strict) +bun run lint # Biome +dbus-run-session -- bun test cli # harness-driven CLI integration tests +``` + +The integration tests run the dev/TS CLI against the fake ModemManager + NetworkManager +(A2.3). They need a session bus, hence `dbus-run-session`. + +## Compiled binaries + cross-arch probe smoke + +The CLI is compiled to standalone binaries for `arm64` + `amd64`, and a self-contained +smoke harness proves a real D-Bus probe on both architectures: + +```sh +cli/smoke/build-binaries.sh # -> cli/dist/modem-control-{amd64,arm64} + smoke harness +cli/smoke/run.sh # amd64 native + arm64 (Docker QEMU) + a negative sanity run +``` + +`run.sh` proves, on both arches, that the compiled binary completes an EXTERNAL-auth +handshake, `GetManagedObjects` returns real data, a signal is received, and a bus drop is +discriminated from a real removal. The negative run spins a modem-less fake and MUST fail, +proving the smoke exercises the real dependency rather than passing unconditionally. The +arm64 run reuses the `docker run --platform linux/arm64` QEMU path. + +## Hardware-gated: on-device system-bus probe `[PARTIAL]` + +Run against a **real** ModemManager on a bench device's system bus. This path is gated on +hardware and is not exercised in CI. + +```sh +# On the bench device, with the packaged ModemManager stack installed and running: +./modem-control probe +``` + +Expected: the run ends with a line of the form + +``` +PROBE OK: external-auth, objects= +``` + +where `` is the number of managed objects ModemManager reports (modems + SIMs + +bearers). Capture the full output to the evidence path `A6.1/hil-system-bus.txt`. + +If the device's ModemManager is on a non-default address, point the CLI at it with +`--bus-address` or `MODEM_CONTROL_BUS_ADDRESS`. diff --git a/cli/package.json b/cli/package.json index c52538a..5bc7c6e 100644 --- a/cli/package.json +++ b/cli/package.json @@ -9,7 +9,8 @@ "modem-control": "./src/index.ts" }, "dependencies": { - "@ceralive/modem-control": "workspace:*" + "@ceralive/modem-control": "workspace:*", + "zod": "4.4.3" }, "scripts": { "test": "bun test", diff --git a/cli/smoke/build-binaries.sh b/cli/smoke/build-binaries.sh new file mode 100755 index 0000000..9f48e9b --- /dev/null +++ b/cli/smoke/build-binaries.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env bash +# Cross-compile the bench CLI and the smoke harness for amd64 + arm64. +# +# Bun 1.3.14 cross-compiles with `--compile --target=bun-linux-` (verified: +# the target flag downloads the matching bun runtime and emits a standalone ELF for that +# architecture). Outputs land in cli/dist/ (gitignored). +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +DIST="${ROOT}/cli/dist" +mkdir -p "${DIST}" +cd "${ROOT}" + +for pair in "x64:amd64" "arm64:arm64"; do + bun_target="bun-linux-${pair%%:*}" + arch="${pair##*:}" + echo "== compiling modem-control (${arch}) ==" + bun build --compile --target="${bun_target}" --outfile="${DIST}/modem-control-${arch}" cli/src/index.ts + echo "== compiling modem-control-smoke (${arch}) ==" + bun build --compile --target="${bun_target}" --outfile="${DIST}/modem-control-smoke-${arch}" cli/smoke/harness.ts +done + +echo "== built binaries ==" +ls -1 "${DIST}" diff --git a/cli/smoke/harness.ts b/cli/smoke/harness.ts new file mode 100644 index 0000000..3f8ea96 --- /dev/null +++ b/cli/smoke/harness.ts @@ -0,0 +1,159 @@ +#!/usr/bin/env bun + +// Cross-arch D-Bus probe smoke harness. +// +// A self-contained smoke binary (compiled for amd64 + arm64) that spins the A2.3 fake +// ModemManager on the session bus and PROVES the four properties the plan requires: +// 1. EXTERNAL-auth handshake succeeds + GetManagedObjects returns real data — via the +// SHIPPED compiled `modem-control probe --bus-address ` subprocess (`--cli`). +// 2. at least one signal is received (InterfacesAdded -> a `+ ADDED` watch event). +// 3. MM-restart / bus-loss vs real-removal are DISCRIMINATED: a bus name drop marks +// the modem SOURCE-UNAVAILABLE with its row RETAINED, whereas an InterfacesRemoved +// is a `- REMOVED` — two different observable outcomes (A3.1 epoch authority). +// +// `--negative` spins the fake with ZERO modems (a deliberately broken fixture): the probe +// assertion then fails and the harness exits non-zero, proving the smoke is not a no-op. + +import { parseArgs } from 'node:util'; +import { createDbusTransport } from '@ceralive/modem-control/transport'; +import { FakeModemManager, type ModemSpec } from '../../control/test-support/fake-mm'; +import { FakeNetworkManagerPort } from '../../control/test-support/fake-nm'; +import { runWatch } from '../src/commands/watch'; +import { createStackContext } from '../src/context'; +import { capturingIo } from '../src/io'; + +const ICCID_PREFIX = '89000000000000'; + +function fail(message: string): never { + console.error(`SMOKE FAIL: ${message}`); + process.exit(1); +} + +function check(condition: boolean, message: string): void { + if (!condition) { + fail(message); + } +} + +const sleep = (ms: number): Promise => new Promise((resolve) => setTimeout(resolve, ms)); + +async function waitFor(predicate: () => boolean, what: string, timeoutMs = 20_000): Promise { + const deadline = Date.now() + timeoutMs; + while (!predicate()) { + if (Date.now() > deadline) { + fail(`timed out waiting for ${what}`); + } + await sleep(10); + } +} + +const spec = (index: number): ModemSpec => ({ + index, + sims: [ + { + index, + iccid: `${ICCID_PREFIX}${1000 + index}`, + imsi: `0010100000${1000 + index}`, + active: true, + }, + ], +}); + +/** Run the SHIPPED `modem-control probe` binary and assert the handshake + data. */ +async function probeCheck( + cliPath: string, + busAddress: string, + expectModems: number, +): Promise { + const proc = Bun.spawn([cliPath, 'probe', '--bus-address', busAddress], { + stdout: 'pipe', + stderr: 'pipe', + }); + const [stdout, stderr, code] = await Promise.all([ + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + proc.exited, + ]); + process.stdout.write(stdout); + const okLine = stdout.split('\n').find((l) => l.startsWith('PROBE OK: external-auth, objects=')); + check( + okLine !== undefined, + `probe printed no PROBE OK line (exit ${code}); stderr=${stderr.trim()}`, + ); + const objects = Number((okLine as string).split('objects=')[1]); + check(objects >= expectModems, `probe reported objects=${objects}, expected >= ${expectModems}`); + for (let index = 0; index < expectModems; index += 1) { + check(stdout.includes(`/Modem/${index}`), `probe did not list /Modem/${index}`); + } + check(!stdout.includes(ICCID_PREFIX), 'probe leaked a raw ICCID — redaction failed'); + console.log(`SMOKE: probe OK (external-auth handshake + GetManagedObjects, objects=${objects})`); +} + +async function main(): Promise { + const { values } = parseArgs({ + options: { cli: { type: 'string' }, negative: { type: 'boolean', default: false } }, + }); + const busAddress = process.env.DBUS_SESSION_BUS_ADDRESS; + if (busAddress === undefined) { + fail('no DBUS_SESSION_BUS_ADDRESS — run under dbus-run-session'); + } + + const modems = values.negative ? [] : [spec(0), spec(1)]; + const fake = await FakeModemManager.start({ busAddress, modems }); + + if (values.cli !== undefined) { + await probeCheck(values.cli, busAddress, 2); + } + + // Signal + discrimination, in-process against the SAME fake. + const transport = createDbusTransport({ busAddress }); + const ctx = createStackContext( + { busAddress }, + { transport, nm: new FakeNetworkManagerPort(), enumerate: () => Promise.resolve([]) }, + ); + const io = capturingIo(); + const controller = new AbortController(); + const watching = runWatch(ctx, io, { signal: controller.signal }); + + await waitFor( + () => io.stdout.some((l) => l.startsWith('+ ADDED') && l.includes('/Modem/0')), + 'initial modem', + ); + fake.addModem(spec(2)); + await waitFor(() => io.stdout.some((l) => l.includes('/Modem/2')), 'InterfacesAdded signal'); + console.log('SMOKE: signal received (InterfacesAdded -> + ADDED)'); + + await fake.dropName(); + await waitFor( + () => io.stdout.some((l) => l.startsWith('! SOURCE-UNAVAILABLE')), + 'bus-loss -> source-unavailable', + ); + check( + io.stdout.some((l) => l.includes('sourceUnavailable')), + 'bus-loss did not mark the modem sourceUnavailable', + ); + check( + !io.stdout.some((l) => l.startsWith('- REMOVED')), + 'bus-loss was mis-read as a removal (should retain the row)', + ); + await fake.reclaimName(); + await waitFor(() => io.stdout.some((l) => l.includes('health=live')), 'restore after reclaim'); + console.log('SMOKE: bus-loss -> SOURCE-UNAVAILABLE (row retained, NOT removed)'); + + fake.removeModem(2); + await waitFor( + () => io.stdout.some((l) => l.startsWith('- REMOVED') && l.includes('/Modem/2')), + 'real removal', + ); + console.log('SMOKE: real removal -> REMOVED (discriminated from bus-loss)'); + + controller.abort(); + await watching; + await ctx.close(); + await transport.disconnect(); + await fake.stop(); + console.log('SMOKE PASS'); + process.exit(0); +} + +main().catch((error) => fail(error instanceof Error ? error.message : String(error))); diff --git a/cli/smoke/run.sh b/cli/smoke/run.sh new file mode 100755 index 0000000..3b74400 --- /dev/null +++ b/cli/smoke/run.sh @@ -0,0 +1,41 @@ +#!/usr/bin/env bash +# Run the cross-arch D-Bus probe smoke: amd64 native + arm64 under Docker QEMU, plus a +# negative-bundle sanity run that MUST fail (proving the smoke exercises the real dep). +# +# amd64 runs the compiled smoke harness natively under dbus-run-session; the harness +# spawns the SHIPPED compiled `modem-control probe` against a fake ModemManager and then +# proves signal reception + source-unavailable-vs-removal in-process. arm64 runs the SAME +# compiled arm64 binaries inside `docker run --platform linux/arm64` (A5.1's proven QEMU +# path), so the arm64 build's real D-Bus handshake is exercised, not just designed. +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +DIST="${ROOT}/cli/dist" +ARM64_IMAGE="${SMOKE_ARM64_IMAGE:-debian:bookworm-slim}" + +"${ROOT}/cli/smoke/build-binaries.sh" + +echo +echo "== amd64 native probe smoke ==" +dbus-run-session -- "${DIST}/modem-control-smoke-amd64" --cli "${DIST}/modem-control-amd64" + +echo +echo "== arm64 QEMU probe smoke (docker --platform linux/arm64) ==" +docker run --rm --platform linux/arm64 -v "${DIST}:/w:ro" "${ARM64_IMAGE}" bash -c ' + set -e + export DEBIAN_FRONTEND=noninteractive + apt-get update -qq >/dev/null + apt-get install -y -qq dbus >/dev/null + dbus-run-session -- /w/modem-control-smoke-arm64 --cli /w/modem-control-arm64 +' + +echo +echo "== negative-bundle sanity (MUST fail — proves the smoke is not a no-op) ==" +if dbus-run-session -- "${DIST}/modem-control-smoke-amd64" --cli "${DIST}/modem-control-amd64" --negative; then + echo "NEGATIVE SANITY FAILED: the smoke passed against a broken fixture (no-op smoke)" + exit 1 +fi +echo "NEGATIVE SANITY OK: the smoke correctly detected the broken dependency" + +echo +echo "ALL SMOKE CHECKS PASSED" diff --git a/cli/src/certify/bundle-schema.ts b/cli/src/certify/bundle-schema.ts new file mode 100644 index 0000000..9bee254 --- /dev/null +++ b/cli/src/certify/bundle-schema.ts @@ -0,0 +1,81 @@ +// The certification-bundle output schema — validated before the bundle is written. +// +// A capture that comes out malformed or incomplete fails LOUDLY here (a `ZodError` with a +// precise path) rather than silently producing a garbage bundle a reviewer would trust. +// The transition-evidence descriptors reuse A4.2's `expectedDescriptorsSchema` and the MM +// transition-mode enum DIRECTLY, so the evidence is shape-compatible with a certified +// catalog entry: a reviewer copies `afterDescriptors` into the entry's `expectedDescriptors` +// and the bundle sha256 into its `evidenceBundleSha256`. + +import { expectedDescriptorsSchema, MM_USB_MODES } from '@ceralive/modem-control'; +import { z } from 'zod'; + +/** The current bundle schema version. */ +export const CERTIFY_SCHEMA_VERSION = 1; + +const mmMode = z.enum(MM_USB_MODES); + +/** A live device's SKU discriminator (VID:PID + model + firmware prefix). */ +const skuSchema = z.strictObject({ + vidPid: z.string().regex(/^[0-9a-f]{4}:[0-9a-f]{4}$/), + model: z.string().min(1), + firmwarePrefix: z.string().min(1), +}); + +/** One bounded-window D-Bus signal — metadata only (property NAMES, never values). */ +const signalRecordSchema = z.strictObject({ + atMs: z.number(), + path: z.string(), + interface: z.string(), + member: z.string(), + changed: z.array(z.string()), + invalidated: z.array(z.string()), +}); +export type SignalRecord = z.infer; + +/** One timestamped step of a mode transition (command / port-drop / re-enumeration). */ +const timelineEventSchema = z.strictObject({ + event: z.enum(['command-sent', 'port-drop', 're-enumeration']), + atMs: z.number(), +}); +export type TransitionTimelineEvent = z.infer; + +/** + * Transition evidence — EXACTLY the fields a reviewer needs to author a catalog entry: + * the before/after USB descriptors, the executed AT command, and the port-drop / + * re-enumeration timeline. `afterDescriptors` is `expectedDescriptorsSchema`, so it drops + * straight into a catalog entry's `expectedDescriptors`. + */ +const transitionEvidenceSchema = z.strictObject({ + from: mmMode, + to: mmMode, + atCommand: z.string().min(1), + expectedResponse: z.string().min(1), + expectsPortDrop: z.boolean(), + beforeDescriptors: expectedDescriptorsSchema, + afterDescriptors: expectedDescriptorsSchema, + timeline: z.array(timelineEventSchema).min(1), +}); +export type TransitionEvidence = z.infer; + +/** The whole redacted certification bundle. */ +export const certificationBundleSchema = z.strictObject({ + schemaVersion: z.literal(CERTIFY_SCHEMA_VERSION), + /** `false` on a real hardware capture; `true` only for synthetic test/sample bundles. */ + synthetic: z.boolean(), + capturedAtMs: z.number(), + slot: z.string().min(1), + sku: skuSchema.optional(), + usb: z.strictObject({ + lsusb: z.string().min(1), + usbDevices: z.string().min(1), + udevProperties: z.record(z.string(), z.string()), + }), + modemManager: z.strictObject({ + mmcliKeyfile: z.record(z.string(), z.string()), + managedObjects: z.record(z.string(), z.record(z.string(), z.unknown())), + signalWindow: z.array(signalRecordSchema), + }), + transition: transitionEvidenceSchema.optional(), +}); +export type CertificationBundle = z.infer; diff --git a/cli/src/certify/bundle.test.ts b/cli/src/certify/bundle.test.ts new file mode 100644 index 0000000..332426d --- /dev/null +++ b/cli/src/certify/bundle.test.ts @@ -0,0 +1,203 @@ +// Synthetic-fixture tests for the certification bundle — no real hardware (Phase A). +// +// Proves the three properties that matter: a valid bundle is built from fake tool output +// with a stable, reproducible sha256; a real capture is unambiguously `synthetic: false`; +// subscriber secrets (ICCID / IMSI / EID) are masked EVERYWHERE — the `mmcli -K` keyfile, +// the redacted `GetManagedObjects`, and the udev properties; and a failed or truncated +// capture fails LOUDLY with a named error instead of writing a broken bundle. + +import { expect, test } from 'bun:test'; +import type { DecodedManagedObjects, UsbDeviceSnapshot } from '@ceralive/modem-control'; +import { buildCertificationBundle } from './bundle'; +import type { SignalRecord } from './bundle-schema'; +import { type BaseCaptureDeps, type BaseCaptureParts, captureBase } from './capture'; +import type { CommandResult } from './command-runner'; +import { CertifyError } from './errors'; + +const ICCID = '8900000000000000123'; +const IMSI = '001010000000123'; +const EID = '89033024000000000000000000012345'; +const IMEI = '350000000000001'; + +const LSUSB = `Bus 001 Device 004: ID 2c7c:0125 Quectel EG25-G +Device Descriptor: + bLength 18 + idVendor 0x2c7c Quectel + idProduct 0x0125 + iSerial 3 abcdef123456 +`; + +const USB_DEVICES = `T: Bus=01 Dev#= 4 Spd=480 +P: Vendor=2c7c ProdID=0125 Rev=03.18 +S: Product=EG25-G +`; + +const MMCLI_K = `modem.generic.device-identifier : 0123456789abcdef +modem.generic.equipment-identifier : ${IMEI} +modem.3gpp.imei : ${IMEI} +sim.properties.iccid : ${ICCID} +sim.properties.imsi : ${IMSI} +sim.properties.eid : ${EID} +sim.properties.operator-name : CeraTel +`; + +const TREE = [ + [ + '/org/freedesktop/ModemManager1/Modem/0', + [ + [ + 'org.freedesktop.ModemManager1.Modem', + [ + ['EquipmentIdentifier', { signature: 's', value: IMEI }], + ['Sim', { signature: 'o', value: '/org/freedesktop/ModemManager1/SIM/0' }], + ['State', { signature: 'i', value: 8 }], + ], + ], + ], + ], + [ + '/org/freedesktop/ModemManager1/SIM/0', + [ + [ + 'org.freedesktop.ModemManager1.Sim', + [ + ['SimIdentifier', { signature: 's', value: ICCID }], + ['Imsi', { signature: 's', value: IMSI }], + ['Eid', { signature: 's', value: EID }], + ['OperatorName', { signature: 's', value: 'CeraTel' }], + ], + ], + ], + ], +] as unknown as DecodedManagedObjects; + +const DEVICE: UsbDeviceSnapshot = { + vendorId: '2c7c', + productId: '0125', + model: 'EG25-G', + firmwareRevision: 'SYNTHETICFW01', + bDeviceClass: 0, + interfaces: [ + { interfaceClass: 255, interfaceSubClass: 255, interfaceProtocol: 255, driver: 'qmi_wwan' }, + ], + physicalUid: 'usb-1-2', + ifname: 'wwan0', + udevProperties: { + ID_VENDOR_ID: '2c7c', + ID_MODEL_ID: '0125', + ID_MODEL: 'EG25-G', + ID_REVISION: 'SYNTHETICFW01', + ID_SERIAL_SHORT: 'abcdef123456', + // A udev rule that surfaced the SIM ICCID — proves redaction reaches udev props. + iccid: ICCID, + }, +}; + +const SIGNALS: SignalRecord[] = [ + { + atMs: 10, + path: '/org/freedesktop/ModemManager1/Modem/0', + interface: 'org.freedesktop.DBus.Properties', + member: 'PropertiesChanged', + changed: ['SignalQuality'], + invalidated: [], + }, +]; + +const ok = (stdout: string): CommandResult => ({ stdout, stderr: '', exitCode: 0 }); + +interface RunnerScript { + readonly lsusb?: CommandResult; + readonly usbDevices?: CommandResult; + readonly mmcli?: CommandResult; +} + +function fakeDeps(script: RunnerScript = {}): BaseCaptureDeps { + return { + run(command) { + if (command === 'lsusb') return Promise.resolve(script.lsusb ?? ok(LSUSB)); + if (command === 'usb-devices') return Promise.resolve(script.usbDevices ?? ok(USB_DEVICES)); + if (command === 'mmcli') return Promise.resolve(script.mmcli ?? ok(MMCLI_K)); + return Promise.resolve({ stdout: '', stderr: `unknown ${command}`, exitCode: 127 }); + }, + fetchManagedObjects: () => Promise.resolve(TREE), + captureSignalWindow: () => Promise.resolve(SIGNALS), + }; +} + +const INPUT = { mmcliTarget: '/org/freedesktop/ModemManager1/Modem/0', device: DEVICE }; + +const buildFrom = (base: BaseCaptureParts, synthetic: boolean) => + buildCertificationBundle({ slot: 'Modem/0', synthetic, capturedAtMs: 1000, base }); + +test('builds a schema-valid synthetic bundle with a stable, reproducible sha256', async () => { + const first = buildFrom(await captureBase(fakeDeps(), INPUT), true); + expect(first.bundle.schemaVersion).toBe(1); + expect(first.bundle.synthetic).toBe(true); + expect(first.bundle.sku).toEqual({ + vidPid: '2c7c:0125', + model: 'EG25-G', + firmwarePrefix: 'SYNTHETICFW01', + }); + expect(first.sha256).toMatch(/^[0-9a-f]{64}$/); + + const second = buildFrom(await captureBase(fakeDeps(), INPUT), true); + expect(second.sha256).toBe(first.sha256); +}); + +test('a real capture is unambiguously marked synthetic:false', async () => { + const { bundle } = buildFrom(await captureBase(fakeDeps(), INPUT), false); + expect(bundle.synthetic).toBe(false); +}); + +test('redacts ICCID / IMSI / EID across mmcli-K, managed objects, and udev', async () => { + const { bundle } = buildFrom(await captureBase(fakeDeps(), INPUT), true); + const serialized = JSON.stringify(bundle); + expect(serialized).not.toContain(ICCID); + expect(serialized).not.toContain(IMSI); + expect(serialized).not.toContain(EID); + + // mmcli -K keyfile: subscriber secrets masked by dotted-segment; IMEI retained. + expect(bundle.modemManager.mmcliKeyfile['sim.properties.iccid']).toBe('[redacted]'); + expect(bundle.modemManager.mmcliKeyfile['sim.properties.imsi']).toBe('[redacted]'); + expect(bundle.modemManager.mmcliKeyfile['sim.properties.eid']).toBe('[redacted]'); + expect(bundle.modemManager.mmcliKeyfile['modem.3gpp.imei']).toBe(IMEI); + + // GetManagedObjects: MM's SimIdentifier surfaced as `iccid` and masked; Imsi/Eid masked. + const sim = bundle.modemManager.managedObjects['/org/freedesktop/ModemManager1/SIM/0']?.[ + 'org.freedesktop.ModemManager1.Sim' + ] as Record; + expect(sim.iccid).toBe('[redacted]'); + expect(sim.Imsi).toBe('[redacted]'); + expect(sim.Eid).toBe('[redacted]'); + expect(sim.OperatorName).toBe('CeraTel'); + + // udev: redaction reaches the props (the ICCID-bearing rule is masked); the device + // serial is equipment identity (non-subscriber), retained per the A2.1 policy. + expect(bundle.usb.udevProperties.iccid).toBe('[redacted]'); + expect(bundle.usb.udevProperties.ID_SERIAL_SHORT).toBe('abcdef123456'); +}); + +test('a failed lsusb capture fails loudly with a named error', async () => { + const deps = fakeDeps({ lsusb: { stdout: '', stderr: 'lsusb: cannot open', exitCode: 1 } }); + await expect(captureBase(deps, INPUT)).rejects.toThrow(CertifyError); + await expect(captureBase(deps, INPUT)).rejects.toThrow(/lsusb.*failed/); +}); + +test('a truncated lsusb (no Device Descriptor block) fails loudly', async () => { + const deps = fakeDeps({ lsusb: ok('Bus 001 Device 004: ID 2c7c:0125 Quectel EG25-G\n') }); + await expect(captureBase(deps, INPUT)).rejects.toThrow(/truncated or malformed/); +}); + +test('a malformed mmcli -K (no keyfile properties) fails loudly', async () => { + const deps = fakeDeps({ mmcli: ok('no properties parsed here\n') }); + await expect(captureBase(deps, INPUT)).rejects.toThrow(/mmcli.*malformed/); +}); + +test('buildCertificationBundle rejects a schema-invalid bundle loudly', () => { + const badBase: BaseCaptureParts = { + usb: { lsusb: '', usbDevices: 'x', udevProperties: {} }, + modemManager: { mmcliKeyfile: { a: 'b' }, managedObjects: {}, signalWindow: [] }, + }; + expect(() => buildFrom(badBase, true)).toThrow(CertifyError); +}); diff --git a/cli/src/certify/bundle.ts b/cli/src/certify/bundle.ts new file mode 100644 index 0000000..d19784e --- /dev/null +++ b/cli/src/certify/bundle.ts @@ -0,0 +1,85 @@ +// Assembling, redacting, validating, and hashing a certification bundle. +// +// The base capture and (optionally) the transition evidence are composed into one +// object, run through the shared key-based redactor so ICCID / IMSI / EID are masked +// everywhere they appear, validated against the bundle schema (a malformed capture +// throws a `CertifyError` here rather than being written), and hashed. The sha256 is +// computed over a CANONICAL (sorted-key) serialization so it is reproducible: this hash +// IS the value a reviewer records in a catalog entry's `evidenceBundleSha256`. + +import { createHash } from 'node:crypto'; +import { redact } from '@ceralive/modem-control'; +import { z } from 'zod'; +import { + CERTIFY_SCHEMA_VERSION, + type CertificationBundle, + certificationBundleSchema, + type TransitionEvidence, +} from './bundle-schema'; +import type { BaseCaptureParts } from './capture'; +import { CertifyError } from './errors'; + +/** Everything needed to assemble one bundle. */ +export interface BundleInput { + readonly slot: string; + /** `false` for a real hardware capture; `true` only for synthetic test/sample bundles. */ + readonly synthetic: boolean; + readonly capturedAtMs: number; + readonly base: BaseCaptureParts; + readonly transition?: TransitionEvidence; +} + +/** A validated, redacted bundle and its reproducible sha256. */ +export interface CertificationResult { + readonly bundle: CertificationBundle; + /** sha256 of the canonical bundle — the catalog's `evidenceBundleSha256` value. */ + readonly sha256: string; +} + +type Json = string | number | boolean | null | Json[] | { [key: string]: Json }; + +/** Recursively sort object keys so the serialization (and thus the hash) is stable. */ +function sortKeys(value: Json): Json { + if (Array.isArray(value)) { + return value.map(sortKeys); + } + if (value !== null && typeof value === 'object') { + const out: { [key: string]: Json } = {}; + for (const key of Object.keys(value).sort()) { + out[key] = sortKeys(value[key] as Json); + } + return out; + } + return value; +} + +/** Canonical JSON of a bundle — sorted keys, no whitespace. */ +export function canonicalJson(bundle: CertificationBundle): string { + return JSON.stringify(sortKeys(bundle as unknown as Json)); +} + +/** + * Assemble, redact, validate, and hash a certification bundle. Throws a `CertifyError` + * if the redacted bundle fails schema validation — a broken bundle is never returned. + */ +export function buildCertificationBundle(input: BundleInput): CertificationResult { + const assembled = { + schemaVersion: CERTIFY_SCHEMA_VERSION, + synthetic: input.synthetic, + capturedAtMs: input.capturedAtMs, + slot: input.slot, + ...(input.base.sku !== undefined ? { sku: input.base.sku } : {}), + usb: input.base.usb, + modemManager: input.base.modemManager, + ...(input.transition !== undefined ? { transition: input.transition } : {}), + }; + + const redacted = redact(assembled); + const parsed = certificationBundleSchema.safeParse(redacted); + if (!parsed.success) { + throw new CertifyError(`bundle failed schema validation: ${z.prettifyError(parsed.error)}`); + } + const bundle = parsed.data; + const sha256 = createHash('sha256').update(canonicalJson(bundle), 'utf8').digest('hex'); + return { bundle, sha256 }; +} diff --git a/cli/src/certify/capture.ts b/cli/src/certify/capture.ts new file mode 100644 index 0000000..485939e --- /dev/null +++ b/cli/src/certify/capture.ts @@ -0,0 +1,123 @@ +// The base capture — the always-present half of a certification bundle. +// +// Runs `lsusb -v`, `usb-devices`, and `mmcli -K`, reads the target slot's udev +// properties off the matched USB device, dumps `GetManagedObjects`, and records a +// BOUNDED window of D-Bus signals. Every tool output is sanity-checked: a failed or +// truncated capture throws a `CertifyError` here, so the command never assembles a +// broken bundle. The subscriber secrets live in the `mmcli -K` keyfile and the managed +// objects, both captured as OBJECTS so the shared key-based redactor can mask them. + +import type { + DecodedManagedObjects, + SkuDiscriminator, + UsbDeviceSnapshot, +} from '@ceralive/modem-control'; +import type { SignalRecord } from './bundle-schema'; +import type { CommandResult } from './command-runner'; +import { CertifyError } from './errors'; +import { type JsonValue, objectifyManagedObjects, parseKeyfile, skuOf } from './transform'; + +/** The parts of a bundle the base capture produces. */ +export interface BaseCaptureParts { + readonly sku?: SkuDiscriminator; + readonly usb: { + readonly lsusb: string; + readonly usbDevices: string; + readonly udevProperties: Record; + }; + readonly modemManager: { + readonly mmcliKeyfile: Record; + readonly managedObjects: Record>; + readonly signalWindow: readonly SignalRecord[]; + }; +} + +/** The injectable seams the base capture reads from (fakes drive the synthetic tests). */ +export interface BaseCaptureDeps { + run(command: string, args: readonly string[]): Promise; + fetchManagedObjects(): Promise; + captureSignalWindow(): Promise; +} + +/** The target selection the base capture needs (matched device + the mmcli selector). */ +export interface BaseCaptureInput { + /** The matched target USB device — source of the slot's udev properties and SKU. */ + readonly device?: UsbDeviceSnapshot; + /** The `mmcli -m ` modem selector (a modem index or D-Bus path). */ + readonly mmcliTarget: string; +} + +/** Run a capture command and fail loudly on a non-zero exit. */ +async function runOrThrow( + deps: BaseCaptureDeps, + command: string, + args: readonly string[], +): Promise { + const result = await deps.run(command, args); + if (result.exitCode !== 0) { + throw new CertifyError( + `${command} ${args.join(' ')} failed (exit ${result.exitCode}): ${result.stderr.trim() || 'no stderr'}`, + ); + } + return result.stdout; +} + +/** Capture `lsusb -v`, rejecting empty or truncated (no descriptor block) output. */ +async function captureLsusb(deps: BaseCaptureDeps): Promise { + const stdout = await runOrThrow(deps, 'lsusb', ['-v']); + if (stdout.trim() === '') { + throw new CertifyError('lsusb -v produced no output (truncated or failed capture)'); + } + if (!stdout.includes('Device Descriptor:')) { + throw new CertifyError( + 'lsusb -v output is truncated or malformed (no "Device Descriptor:" block)', + ); + } + return stdout; +} + +/** Capture `usb-devices`, rejecting empty output. */ +async function captureUsbDevices(deps: BaseCaptureDeps): Promise { + const stdout = await runOrThrow(deps, 'usb-devices', []); + if (stdout.trim() === '') { + throw new CertifyError('usb-devices produced no output (truncated or failed capture)'); + } + return stdout; +} + +/** Capture `mmcli -m -K`, rejecting output that parses to zero keys. */ +async function captureMmcli( + deps: BaseCaptureDeps, + mmcliTarget: string, +): Promise> { + const stdout = await runOrThrow(deps, 'mmcli', ['-m', mmcliTarget, '-K']); + const keyfile = parseKeyfile(stdout); + if (Object.keys(keyfile).length === 0) { + throw new CertifyError('mmcli -K output is malformed (no keyfile properties parsed)'); + } + return keyfile; +} + +/** + * Capture the always-present base of a certification bundle. Throws a `CertifyError` + * the instant any tool fails or returns malformed output — a partial bundle is never + * returned. + */ +export async function captureBase( + deps: BaseCaptureDeps, + input: BaseCaptureInput, +): Promise { + const lsusb = await captureLsusb(deps); + const usbDevices = await captureUsbDevices(deps); + const mmcliKeyfile = await captureMmcli(deps, input.mmcliTarget); + const managedObjects = objectifyManagedObjects(await deps.fetchManagedObjects()); + const signalWindow = await deps.captureSignalWindow(); + const udevProperties = { ...(input.device?.udevProperties ?? {}) }; + const sku = input.device !== undefined ? skuOf(input.device) : undefined; + + return { + ...(sku !== undefined ? { sku } : {}), + usb: { lsusb, usbDevices, udevProperties }, + modemManager: { mmcliKeyfile, managedObjects, signalWindow }, + }; +} diff --git a/cli/src/certify/command-runner.ts b/cli/src/certify/command-runner.ts new file mode 100644 index 0000000..badc896 --- /dev/null +++ b/cli/src/certify/command-runner.ts @@ -0,0 +1,33 @@ +// The command seam the `certify` capture shells out through. +// +// `certify` reads `lsusb -v`, `usb-devices`, and `mmcli -K` — none of which have a +// D-Bus or library API, so they are captured by running the tool. Every run goes +// through `CommandRunner` rather than touching `Bun.spawn` directly, so the +// synthetic-fixture tests feed canned tool output with no real hardware (this is Phase +// A bench iteration — no real modem exists yet). The production `SpawnCommandRunner` +// runs the real binaries. + +/** The result of running one capture command. */ +export interface CommandResult { + readonly stdout: string; + readonly stderr: string; + readonly exitCode: number; +} + +/** Runs a capture command and returns its output — the mockable capture seam. */ +export interface CommandRunner { + run(command: string, args: readonly string[]): Promise; +} + +/** The production runner: spawns the real binary and collects its output. */ +export class SpawnCommandRunner implements CommandRunner { + async run(command: string, args: readonly string[]): Promise { + const proc = Bun.spawn([command, ...args], { stdout: 'pipe', stderr: 'pipe' }); + const [stdout, stderr, exitCode] = await Promise.all([ + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + proc.exited, + ]); + return { stdout, stderr, exitCode }; + } +} diff --git a/cli/src/certify/errors.ts b/cli/src/certify/errors.ts new file mode 100644 index 0000000..bc998fc --- /dev/null +++ b/cli/src/certify/errors.ts @@ -0,0 +1,15 @@ +// The one error type the certify capture throws. +// +// Every malformed / incomplete / failed capture surfaces as a `CertifyError` with a +// clear, human-readable message naming what went wrong (which command, which field). +// The command layer turns it into a non-zero exit and a printed diagnostic — a broken +// bundle is NEVER written silently. + +/** A visible capture failure — malformed input, a failed tool, an uncertified SKU. */ +export class CertifyError extends Error { + constructor(message: string) { + super(message); + this.name = 'CertifyError'; + Object.setPrototypeOf(this, CertifyError.prototype); + } +} diff --git a/cli/src/certify/signal-window.ts b/cli/src/certify/signal-window.ts new file mode 100644 index 0000000..da6c8aa --- /dev/null +++ b/cli/src/certify/signal-window.ts @@ -0,0 +1,118 @@ +// The bounded D-Bus signal window — the production capture seam. +// +// A certification bundle records a BOUNDED slice of ModemManager's signal traffic: +// `PropertiesChanged` plus interface add/remove, capped at N signals OR N milliseconds, +// whichever comes first. It is never an unbounded capture — the window always closes and +// the subscriptions are always torn down. Only property NAMES are recorded, never their +// values, so no subscriber secret can ride out through a signal body. + +import type { DbusTransport, SignalEvent, Subscription } from '@ceralive/modem-control/transport'; +import type { SignalRecord } from './bundle-schema'; + +// The two well-known D-Bus signal interfaces the window subscribes to. These standard +// names are not re-exported by `@ceralive/modem-control`, so — like the observer's own +// `constants.ts` — this module keeps its own copies rather than reaching into internals. +const PROPERTIES_IFACE = 'org.freedesktop.DBus.Properties'; +const OBJECT_MANAGER_IFACE = 'org.freedesktop.DBus.ObjectManager'; + +/** How the signal window is bounded. */ +export interface SignalWindowBound { + readonly maxSignals: number; + readonly windowMs: number; +} + +/** A short, safe default window for a bench capture. */ +export const DEFAULT_SIGNAL_WINDOW: SignalWindowBound = { maxSignals: 32, windowMs: 3000 }; + +/** Extract the changed property NAMES from a `PropertiesChanged` body (never values). */ +function changedNames(entries: SignalEvent['body'][number] | undefined): string[] { + if (!Array.isArray(entries)) { + return []; + } + return entries.flatMap((entry) => + Array.isArray(entry) && typeof entry[0] === 'string' ? [entry[0]] : [], + ); +} + +/** Extract the invalidated property names from a `PropertiesChanged` body. */ +function invalidatedNames(names: SignalEvent['body'][number] | undefined): string[] { + return Array.isArray(names) ? names.filter((n): n is string => typeof n === 'string') : []; +} + +function toRecord(event: SignalEvent, atMs: number): SignalRecord { + const isPropsChanged = event.member === 'PropertiesChanged'; + return { + atMs, + path: event.path, + interface: event.interface, + member: event.member, + changed: isPropsChanged ? changedNames(event.body[1]) : [], + invalidated: isPropsChanged ? invalidatedNames(event.body[2]) : [], + }; +} + +/** + * Build a bounded-signal-window capture over a live transport. The returned function + * subscribes, collects up to `bound.maxSignals` signals or waits `bound.windowMs`, then + * unsubscribes and resolves — whichever limit is hit first. + */ +export function createTransportSignalWindow( + transport: DbusTransport, + now: () => number, + bound: SignalWindowBound = DEFAULT_SIGNAL_WINDOW, +): () => Promise { + return async () => { + const records: SignalRecord[] = []; + const subscriptions: Subscription[] = []; + return new Promise((resolve) => { + let closed = false; + let timer: ReturnType | undefined; + + const close = async (): Promise => { + if (closed) { + return; + } + closed = true; + if (timer !== undefined) { + clearTimeout(timer); + } + await Promise.all(subscriptions.map((s) => s.unsubscribe().catch(() => undefined))); + resolve(records); + }; + + const onSignal = (event: SignalEvent): void => { + if (closed) { + return; + } + records.push(toRecord(event, now())); + if (records.length >= bound.maxSignals) { + void close(); + } + }; + + timer = setTimeout(() => void close(), bound.windowMs); + void Promise.all([ + transport.subscribeSignal( + { interface: PROPERTIES_IFACE, member: 'PropertiesChanged' }, + onSignal, + ), + transport.subscribeSignal( + { interface: OBJECT_MANAGER_IFACE, member: 'InterfacesAdded' }, + onSignal, + ), + transport.subscribeSignal( + { interface: OBJECT_MANAGER_IFACE, member: 'InterfacesRemoved' }, + onSignal, + ), + ]) + .then((subs) => { + if (closed) { + void Promise.all(subs.map((s) => s.unsubscribe().catch(() => undefined))); + return; + } + subscriptions.push(...subs); + }) + .catch(() => void close()); + }); + }; +} diff --git a/cli/src/certify/transform.ts b/cli/src/certify/transform.ts new file mode 100644 index 0000000..7d07f3c --- /dev/null +++ b/cli/src/certify/transform.ts @@ -0,0 +1,136 @@ +// Turning raw capture output into redaction-walkable, JSON-safe shapes. +// +// The shared redactor (`@ceralive/modem-control` `redact`) is KEY-BASED: it walks plain +// objects and masks the value under a sensitive key (`iccid`, `imsi`, `eid`, …), matching +// the last dotted segment too (so `sim.properties.iccid` is caught). Two capture inputs +// carry SIM subscriber secrets and therefore MUST be handed to the redactor as objects, +// not raw text: +// * `mmcli -K` — a keyfile of `a.b.c : value` lines → a flat `{ "a.b.c": value }` map; +// `sim.properties.iccid` / `.imsi` / `.eid` are then masked by dotted-segment match. +// * `GetManagedObjects` — the decoded D-Bus tree → nested `{ path: { iface: { prop } } }`. +// ModemManager exposes the ICCID as the property `SimIdentifier`, the ONE secret whose +// raw name the shared redactor does not recognize; we surface it under its canonical name +// `iccid` (exactly the domain layer's `SimIdentifier → subscriptionId` mapping) so the +// redactor masks it. `Imsi` and `Eid` already match by name. + +import type { + DecodedInterfaces, + DecodedManagedObjects, + DecodedProps, + ExpectedDescriptors, + SkuDiscriminator, + UsbDeviceSnapshot, +} from '@ceralive/modem-control'; +import { type DbusValue, isVariant } from '@ceralive/modem-control/transport'; + +/** MM's ICCID property name; surfaced as `iccid` so the shared redactor masks it. */ +const SIM_IDENTIFIER_PROP = 'SimIdentifier'; + +/** A JSON-safe value — what the objectified tree and parsed keyfile contain. */ +export type JsonValue = + | string + | number + | boolean + | null + | JsonValue[] + | { [key: string]: JsonValue }; + +/** Parse `mmcli -K` keyfile text (`a.b.c : value` lines) into a flat map. */ +export function parseKeyfile(text: string): Record { + const out: Record = {}; + for (const line of text.split('\n')) { + const sep = line.indexOf(':'); + if (sep < 0) { + continue; + } + const key = line.slice(0, sep).trim(); + if (key === '') { + continue; + } + out[key] = line.slice(sep + 1).trim(); + } + return out; +} + +/** Convert a decoded D-Bus value to a JSON-safe one (bigint→string, bytes→numbers). */ +function jsonValue(value: DbusValue): JsonValue { + if (typeof value === 'bigint') { + return value.toString(); + } + if (value instanceof Uint8Array) { + return Array.from(value); + } + if (isVariant(value)) { + return jsonValue(value.value); + } + if (Array.isArray(value)) { + return value.map(jsonValue); + } + return value; +} + +/** Objectify one interface's `[propName, variant][]` into `{ prop: value }`. */ +function objectifyProps(props: DecodedProps): Record { + const out: Record = {}; + for (const [name, propVariant] of props) { + const key = name === SIM_IDENTIFIER_PROP ? 'iccid' : name; + out[key] = jsonValue(propVariant.value); + } + return out; +} + +/** Objectify one object's `[iface, props][]` into `{ iface: { prop: value } }`. */ +function objectifyInterfaces(interfaces: DecodedInterfaces): Record { + const out: Record = {}; + for (const [iface, props] of interfaces) { + out[iface] = objectifyProps(props); + } + return out; +} + +/** + * Objectify a decoded `GetManagedObjects` tree into `{ path: { iface: { prop } } }`. + * The result is plain objects and JSON primitives only — walkable by the shared + * key-based redactor and safe to serialize into the bundle. + */ +export function objectifyManagedObjects( + tree: DecodedManagedObjects, +): Record> { + const out: Record> = {}; + for (const [path, interfaces] of tree) { + out[path] = objectifyInterfaces(interfaces); + } + return out; +} + +/** + * Derive the `ExpectedDescriptors` shape (A4.2's catalog postcondition) from a live USB + * device snapshot — the before/after descriptors of transition-evidence mode. A human + * reviewer copies the `after` descriptors straight into a new catalog entry. + */ +export function descriptorsOf(device: UsbDeviceSnapshot): ExpectedDescriptors { + return { + deviceClass: device.bDeviceClass, + interfaces: device.interfaces.map((i) => ({ + interfaceClass: i.interfaceClass, + interfaceSubClass: i.interfaceSubClass, + interfaceProtocol: i.interfaceProtocol, + })), + }; +} + +/** + * Build the SKU discriminator (VID:PID + model + firmware prefix) from a device, or + * `undefined` when the device lacks a model or firmware string — the three parts the + * certified catalog matches on. A partial SKU is not a certified device. + */ +export function skuOf(device: UsbDeviceSnapshot): SkuDiscriminator | undefined { + if (device.model === undefined || device.firmwareRevision === undefined) { + return undefined; + } + return { + vidPid: `${device.vendorId}:${device.productId}`, + model: device.model, + firmwarePrefix: device.firmwareRevision, + }; +} diff --git a/cli/src/certify/transition-evidence.test.ts b/cli/src/certify/transition-evidence.test.ts new file mode 100644 index 0000000..fc3f3ea --- /dev/null +++ b/cli/src/certify/transition-evidence.test.ts @@ -0,0 +1,160 @@ +// Transition-evidence capture — synthetic fixtures, no real hardware (Phase A). +// +// Proves the evidence is SHAPE-COMPATIBLE with an A4.2 catalog entry: the before/after +// descriptors validate against `expectedDescriptorsSchema`, the AT command comes FROM the +// certified catalog (never invented), and the port-drop / re-enumeration timeline is +// ordered. An uncertified SKU, a non-permitted transition, and a device with no stable +// physical UID each fail loudly. + +import { expect, test } from 'bun:test'; +import { + type AtCommandSender, + type AtResponse, + expectedDescriptorsSchema, + type UsbDeviceSnapshot, +} from '@ceralive/modem-control'; +import { buildCertificationBundle } from './bundle'; +import type { BaseCaptureParts } from './capture'; +import { captureTransitionEvidence, type TransitionCaptureDeps } from './transition-evidence'; + +const QMI_DEVICE: UsbDeviceSnapshot = { + vendorId: '2c7c', + productId: '0125', + model: 'CERALIVE-SYNTHETIC-TEST-SKU', + firmwareRevision: 'SYNTHETICFW01', + bDeviceClass: 0, + physicalUid: 'usb-1-2', + ifname: 'wwan0', + interfaces: [ + { interfaceClass: 255, interfaceSubClass: 255, interfaceProtocol: 255, driver: 'qmi_wwan' }, + ], +}; + +const MBIM_DEVICE: UsbDeviceSnapshot = { + vendorId: '2c7c', + productId: '0125', + model: 'CERALIVE-SYNTHETIC-TEST-SKU', + firmwareRevision: 'SYNTHETICFW01', + bDeviceClass: 0, + physicalUid: 'usb-1-2', + ifname: 'wwan0', + interfaces: [ + { interfaceClass: 2, interfaceSubClass: 14, interfaceProtocol: 0 }, + { interfaceClass: 10, interfaceSubClass: 0, interfaceProtocol: 2 }, + ], +}; + +interface ScriptedDeps { + readonly deps: TransitionCaptureDeps; + readonly sends: string[]; +} + +/** A scripted transition: qmi present → AT sends → port drops → mbim re-enumerates. */ +function scripted(): ScriptedDeps { + let phase: 'qmi' | 'dropped' | 'mbim' = 'qmi'; + let clock = 0; + const sends: string[] = []; + const atSender: AtCommandSender = { + send(command: string): Promise { + sends.push(command); + phase = 'dropped'; + return Promise.resolve({ ok: true, raw: 'OK' }); + }, + }; + const deps: TransitionCaptureDeps = { + enumerate() { + clock += 10; + if (phase === 'qmi') return Promise.resolve([QMI_DEVICE]); + if (phase === 'dropped') { + phase = 'mbim'; + return Promise.resolve([]); + } + return Promise.resolve([MBIM_DEVICE]); + }, + atSender, + now: () => clock, + pollIntervalMs: 1, + timeoutMs: 5000, + }; + return { deps, sends }; +} + +test('captures shape-compatible transition evidence with the catalog AT command', async () => { + const { deps, sends } = scripted(); + const evidence = await captureTransitionEvidence(deps, { + targetMode: 'mbim', + device: QMI_DEVICE, + }); + + expect(evidence.from).toBe('qmi'); + expect(evidence.to).toBe('mbim'); + expect(evidence.atCommand).toBe('AT+QCFG="usbnet",2'); + expect(sends).toEqual(['AT+QCFG="usbnet",2']); + expect(evidence.expectsPortDrop).toBe(true); + + // before/after descriptors are drop-in for a catalog entry's `expectedDescriptors`. + expect(expectedDescriptorsSchema.safeParse(evidence.beforeDescriptors).success).toBe(true); + expect(expectedDescriptorsSchema.safeParse(evidence.afterDescriptors).success).toBe(true); + expect(evidence.afterDescriptors).toEqual({ + deviceClass: 0, + interfaces: [ + { interfaceClass: 2, interfaceSubClass: 14, interfaceProtocol: 0 }, + { interfaceClass: 10, interfaceSubClass: 0, interfaceProtocol: 2 }, + ], + }); + + // The timeline records the three transition milestones in order, monotonic in time. + expect(evidence.timeline.map((e) => e.event)).toEqual([ + 'command-sent', + 'port-drop', + 're-enumeration', + ]); + const times = evidence.timeline.map((e) => e.atMs); + for (let i = 1; i < times.length; i++) { + expect(times[i] ?? 0).toBeGreaterThanOrEqual(times[i - 1] ?? 0); + } +}); + +test('the evidence embeds into a bundle and validates against the bundle schema', async () => { + const { deps } = scripted(); + const transition = await captureTransitionEvidence(deps, { + targetMode: 'mbim', + device: QMI_DEVICE, + }); + const base: BaseCaptureParts = { + usb: { lsusb: 'Device Descriptor:', usbDevices: 'T:', udevProperties: {} }, + modemManager: { mmcliKeyfile: { a: 'b' }, managedObjects: {}, signalWindow: [] }, + }; + const { bundle, sha256 } = buildCertificationBundle({ + slot: 'Modem/0', + synthetic: true, + capturedAtMs: 1, + base, + transition, + }); + expect(bundle.transition).toEqual(transition); + expect(sha256).toMatch(/^[0-9a-f]{64}$/); +}); + +test('an uncertified SKU fails loudly', async () => { + const { deps } = scripted(); + const uncertified: UsbDeviceSnapshot = { ...QMI_DEVICE, model: 'UNKNOWN-MODEL' }; + await expect( + captureTransitionEvidence(deps, { targetMode: 'mbim', device: uncertified }), + ).rejects.toThrow(/no certified catalog entry/); +}); + +test('a non-permitted transition (mbim -> ecm-ncm) fails loudly', async () => { + const { deps } = scripted(); + await expect( + captureTransitionEvidence(deps, { targetMode: 'ecm-ncm', device: MBIM_DEVICE }), + ).rejects.toThrow(/no permitted transition mbim -> ecm-ncm/); +}); + +test('a device without a stable physical UID fails loudly', async () => { + const { deps } = scripted(); + const { physicalUid: _drop, ...noUid } = QMI_DEVICE; + await expect( + captureTransitionEvidence(deps, { targetMode: 'mbim', device: noUid }), + ).rejects.toThrow(/no stable physical UID/); +}); diff --git a/cli/src/certify/transition-evidence.ts b/cli/src/certify/transition-evidence.ts new file mode 100644 index 0000000..6875056 --- /dev/null +++ b/cli/src/certify/transition-evidence.ts @@ -0,0 +1,162 @@ +// Transition-evidence mode — capturing a USB-mode switch as it happens. +// +// Certifying AROUND a transition records EXACTLY the fields A4.2's catalog entry +// declares: the before-mode USB descriptors, the exact AT command executed (taken FROM +// the certified catalog, never invented), the port-drop / re-enumeration timeline, and +// the after-mode USB descriptors. The output is shape-compatible with a catalog entry's +// evidence: a reviewer copies `afterDescriptors` into `expectedDescriptors` and the +// bundle sha256 into `evidenceBundleSha256`. This is capture-only — it observes and +// timestamps a real transition; it is NOT the safety-gated production switch (A4.2). + +import { + type AtCommandSender, + CERTIFIED_CATALOG, + type CertifiedCatalog, + detectUsbMode, + findCatalogEntry, + findPermittedTransition, + MM_USB_MODES, + type MmUsbMode, + type UsbDeviceSnapshot, +} from '@ceralive/modem-control'; +import type { TransitionEvidence, TransitionTimelineEvent } from './bundle-schema'; +import { CertifyError } from './errors'; +import { descriptorsOf, skuOf } from './transform'; + +const DEFAULT_POLL_INTERVAL_MS = 100; +const DEFAULT_TIMEOUT_MS = 30_000; + +/** The injectable seams the transition capture drives (fakes script the synthetic tests). */ +export interface TransitionCaptureDeps { + enumerate(): Promise; + readonly atSender: AtCommandSender; + now(): number; + readonly catalog?: CertifiedCatalog; + readonly pollIntervalMs?: number; + readonly timeoutMs?: number; +} + +/** What to certify: the target mode and the matched before-transition device. */ +export interface TransitionCaptureInput { + readonly targetMode: MmUsbMode; + readonly device: UsbDeviceSnapshot; +} + +const sleep = (ms: number): Promise => new Promise((resolve) => setTimeout(resolve, ms)); + +function isMmMode(mode: string): mode is MmUsbMode { + return (MM_USB_MODES as readonly string[]).includes(mode); +} + +/** Find the device with `physicalUid` in a snapshot list. */ +function byPhysicalUid( + devices: readonly UsbDeviceSnapshot[], + physicalUid: string, +): UsbDeviceSnapshot | undefined { + return devices.find((d) => d.physicalUid === physicalUid); +} + +/** Poll `enumerate` until `predicate` holds, or throw on timeout. */ +async function pollUntil( + deps: TransitionCaptureDeps, + predicate: (devices: readonly UsbDeviceSnapshot[]) => boolean, + what: string, +): Promise { + const pollMs = deps.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS; + const deadline = deps.now() + (deps.timeoutMs ?? DEFAULT_TIMEOUT_MS); + while (true) { + if (predicate(await deps.enumerate())) { + return; + } + if (deps.now() >= deadline) { + throw new CertifyError(`transition timed out waiting for ${what}`); + } + await sleep(pollMs); + } +} + +/** + * Capture transition evidence for one certified mode switch. Throws a `CertifyError` + * for an uncertified SKU, a non-permitted transition, a missing physical UID, or a + * capture timeout — the evidence is never partial. + */ +export async function captureTransitionEvidence( + deps: TransitionCaptureDeps, + input: TransitionCaptureInput, +): Promise { + const before = input.device; + const physicalUid = before.physicalUid; + if (physicalUid === undefined) { + throw new CertifyError('device has no stable physical UID; cannot track re-enumeration'); + } + const fromMode = detectUsbMode(before); + if (fromMode === undefined || !isMmMode(fromMode)) { + throw new CertifyError( + `device is not in an MM-manageable mode (detected ${fromMode ?? 'none'}); cannot certify a transition`, + ); + } + const sku = skuOf(before); + if (sku === undefined) { + throw new CertifyError('device is missing a model or firmware string; SKU is not certifiable'); + } + const entry = findCatalogEntry(deps.catalog ?? CERTIFIED_CATALOG, sku); + if (entry === undefined) { + throw new CertifyError( + `no certified catalog entry for SKU ${sku.vidPid}/${sku.model}/${sku.firmwarePrefix}`, + ); + } + const transition = findPermittedTransition(entry, fromMode, input.targetMode); + if (transition === undefined) { + throw new CertifyError( + `no permitted transition ${fromMode} -> ${input.targetMode} in the catalog for ${sku.vidPid}`, + ); + } + + const beforeDescriptors = descriptorsOf(before); + const timeline: TransitionTimelineEvent[] = []; + + timeline.push({ event: 'command-sent', atMs: deps.now() }); + await deps.atSender.send(transition.atCommand); + + if (transition.expectsPortDrop) { + await pollUntil( + deps, + (devices) => byPhysicalUid(devices, physicalUid) === undefined, + 'port drop', + ); + timeline.push({ event: 'port-drop', atMs: deps.now() }); + } + + // Re-enumeration is complete when the device is back on the same physical port AND + // its data-plane composition reads as the TARGET mode. Matching on the descriptor + // composition (not the PID) is correct: a `usbnet` switch keeps the PID and only + // re-composes the interfaces. + let after: UsbDeviceSnapshot | undefined; + await pollUntil( + deps, + (devices) => { + const candidate = byPhysicalUid(devices, physicalUid); + if (candidate === undefined || detectUsbMode(candidate) !== input.targetMode) { + return false; + } + after = candidate; + return true; + }, + 're-enumeration in the target mode', + ); + timeline.push({ event: 're-enumeration', atMs: deps.now() }); + if (after === undefined) { + throw new CertifyError('device did not re-enumerate in the target mode'); + } + + return { + from: fromMode, + to: input.targetMode, + atCommand: transition.atCommand, + expectedResponse: transition.expectedResponse, + expectsPortDrop: transition.expectsPortDrop, + beforeDescriptors, + afterDescriptors: descriptorsOf(after), + timeline, + }; +} diff --git a/cli/src/cli.ts b/cli/src/cli.ts new file mode 100644 index 0000000..4022500 --- /dev/null +++ b/cli/src/cli.ts @@ -0,0 +1,173 @@ +// The bench CLI dispatcher — parse argv, wire the real stack, run one command. +// +// `runCli` is the testable core: it takes an argv array and a `CliIo`, so a test drives +// any command with captured output and no real process I/O. `--bus-address` (or the +// `MODEM_CONTROL_BUS_ADDRESS` env var) is the injection point that points the SAME code +// at the A2.3 fake ModemManager service instead of the real system bus. + +import { parseArgs } from 'node:util'; +import { MM_USB_MODES, type MmUsbMode } from '@ceralive/modem-control'; +import { runApply } from './commands/apply'; +import { type CertifyArgs, certifyDepsFromContext, runCertify } from './commands/certify'; +import { runProbe } from './commands/probe'; +import { runSetUsbMode, type UsbModeArgs } from './commands/set-usb-mode'; +import { runUnlock } from './commands/unlock'; +import { runUsage } from './commands/usage'; +import { runWatch } from './commands/watch'; +import { createStackContext, type GlobalOptions, type StackContext } from './context'; +import type { CliIo } from './io'; +import { readPolicyFile } from './policy-file'; +import { buildRequestResolver, buildUsageInputs, buildUsbModeTransition } from './wiring'; + +const HELP = `modem-control — bench CLI for the CeraLive modem stack + +Usage: modem-control [options] + +Commands: + probe Stack snapshot: identities, classes, modes, features, cell info + watch Live event stream of observation changes + apply --policy Reconcile a desired-state policy (JSON/YAML); prints receipts + set-usb-mode --confirm Certified USB-mode switch (refuses without --confirm) + certify Capture a redacted, schema-validated certification bundle + usage Print the data-usage sampler snapshot + unlock-pin [slot] Unlock a SIM PIN (redacted prompt) + unlock-puk [slot] Unblock a SIM PUK (redacted prompts) + +Global options: + --bus-address D-Bus bus address (harness/smoke injection; env MODEM_CONTROL_BUS_ADDRESS) + --destination ModemManager bus name (default org.freedesktop.ModemManager1) + +Command options: + --policy (apply) desired-state file (.json / .yaml / .yml) + --confirm (set-usb-mode) maps to the transaction confirm gate + --duration | --events (watch) exit bound; default runs until Ctrl-C + --transition (certify) capture transition evidence into (qmi/mbim/ecm-ncm) + --output (certify) write the bundle here (default: stdout) + --synthetic (certify) mark the bundle a synthetic sample (never for real captures)`; + +/** Run one CLI invocation. Returns a process exit code. */ +export async function runCli(argv: readonly string[], io: CliIo): Promise { + const { values, positionals } = parseArgs({ + args: [...argv], + allowPositionals: true, + options: { + 'bus-address': { type: 'string' }, + destination: { type: 'string' }, + policy: { type: 'string' }, + confirm: { type: 'boolean', default: false }, + duration: { type: 'string' }, + events: { type: 'string' }, + transition: { type: 'string' }, + output: { type: 'string' }, + synthetic: { type: 'boolean', default: false }, + signals: { type: 'string' }, + window: { type: 'string' }, + help: { type: 'boolean', default: false }, + }, + }); + + const command = positionals[0]; + if (values.help || command === undefined || command === 'help') { + io.out(HELP); + return command === undefined ? 1 : 0; + } + + const global: GlobalOptions = { + ...(values['bus-address'] !== undefined ? { busAddress: values['bus-address'] } : {}), + ...(values.destination !== undefined ? { destination: values.destination } : {}), + }; + + switch (command) { + case 'probe': + return withStack(global, io, (ctx) => runProbe(ctx, io)); + case 'watch': + return withStack(global, io, (ctx) => + runWatch(ctx, io, { + ...(values.duration !== undefined ? { durationMs: Number(values.duration) } : {}), + ...(values.events !== undefined ? { events: Number(values.events) } : {}), + }), + ); + case 'apply': { + if (values.policy === undefined) { + io.err('apply: --policy is required'); + return 2; + } + const spec = await readPolicyFile(values.policy); + return withStack(global, io, (ctx) => runApply(ctx, io, spec)); + } + case 'set-usb-mode': { + const slot = positionals[1]; + const target = positionals[2]; + if (slot === undefined || target === undefined) { + io.err('set-usb-mode: usage: set-usb-mode --confirm'); + return 2; + } + if (!isMmUsbMode(target)) { + io.err(`set-usb-mode: invalid target '${target}' (expected ${MM_USB_MODES.join(' | ')})`); + return 2; + } + const args: UsbModeArgs = { slot, target, confirm: values.confirm, maintenance: true }; + return withStack(global, io, (ctx) => + runSetUsbMode(io, buildRequestResolver(ctx), buildUsbModeTransition(ctx), args), + ); + } + case 'certify': { + const slot = positionals[1]; + if (slot === undefined) { + io.err('certify: usage: certify [--transition ] [--output ]'); + return 2; + } + if (values.transition !== undefined && !isMmUsbMode(values.transition)) { + io.err( + `certify: invalid --transition '${values.transition}' (expected ${MM_USB_MODES.join(' | ')})`, + ); + return 2; + } + const args: CertifyArgs = { + slot, + synthetic: values.synthetic, + ...(values.transition !== undefined ? { transition: values.transition as MmUsbMode } : {}), + ...(values.output !== undefined ? { output: values.output } : {}), + ...(values.signals !== undefined ? { maxSignals: Number(values.signals) } : {}), + ...(values.window !== undefined ? { windowMs: Number(values.window) } : {}), + }; + return withStack(global, io, (ctx) => + runCertify(ctx, io, args, certifyDepsFromContext(ctx, args)), + ); + } + case 'usage': + return withStack(global, io, async (ctx) => { + const { sampler, observations } = await buildUsageInputs(ctx); + return runUsage(io, sampler, observations); + }); + case 'unlock-pin': + return withStack(global, io, (ctx) => runUnlock(ctx, io, 'pin', positionals[1])); + case 'unlock-puk': + return withStack(global, io, (ctx) => runUnlock(ctx, io, 'puk', positionals[1])); + default: + io.err(`unknown command '${command}'`); + io.out(HELP); + return 2; + } +} + +/** Open a stack context, run `command`, and always close it. */ +async function withStack( + global: GlobalOptions, + io: CliIo, + command: (ctx: StackContext) => Promise, +): Promise { + const ctx = createStackContext(global); + try { + return await command(ctx); + } catch (error) { + io.err(`error: ${error instanceof Error ? error.message : String(error)}`); + return 1; + } finally { + await ctx.close(); + } +} + +function isMmUsbMode(value: string): value is MmUsbMode { + return (MM_USB_MODES as readonly string[]).includes(value); +} diff --git a/cli/src/commands/apply.ts b/cli/src/commands/apply.ts new file mode 100644 index 0000000..b06fb98 --- /dev/null +++ b/cli/src/commands/apply.ts @@ -0,0 +1,125 @@ +// `modem-control apply --policy ` — reconcile a desired-state policy. +// +// Reads the policy spec, selects the target modem, DERIVES its durable binding key from +// the live identity (refusing a low-confidence / ambiguous modem — a policy can never +// bind to one), runs the pure reconcile planner (A2.2) against the observed state, then +// applies the resulting port-tagged ops through the MM / NM ports and prints one honest +// receipt per policy dimension. + +import { + type AppliedCellularState, + type CellularSnapshot, + canBindPolicy, + deviceIfname, + type ModemCapabilities, + type PortTaggedOp, + planReconcile, + policyBindingKey, + type RadioAccessTechnology, +} from '@ceralive/modem-control'; +import type { StackContext } from '../context'; +import type { CliIo } from '../io'; +import { type PolicyFileSpec, toDesiredPolicy } from '../policy-file'; +import { selectModem } from '../select'; + +const ALL_RATS: readonly RadioAccessTechnology[] = ['gsm', 'umts', 'lte', '5gnr']; + +/** Derive the planner's "is" state from an observed snapshot (bench-conservative). */ +function currentState(snapshot: CellularSnapshot): AppliedCellularState { + const name = snapshot.dataInterface.present ? snapshot.dataInterface.name : undefined; + const activeSlot = snapshot.simSlots.find((slot) => slot.active)?.index; + return { + nmActivation: snapshot.nmActivation, + hasProfile: false, + ...(name !== undefined ? { deviceIfname: deviceIfname(name) } : {}), + ...(activeSlot !== undefined ? { activePrimarySlot: activeSlot } : {}), + }; +} + +/** Bench capability set — the full RAT set, observed slot count, Auto-APN assumed. */ +function capabilities(snapshot: CellularSnapshot): ModemCapabilities { + return { + supportedRats: new Set(ALL_RATS), + simSlotCount: Math.max(1, snapshot.simSlots.length), + supportsAutoApn: true, + }; +} + +/** Apply one port-tagged op, printing its outcome. */ +async function applyOp( + ctx: StackContext, + modem: CellularSnapshot, + op: PortTaggedOp, + io: CliIo, +): Promise { + const ref = modem.identity.runtimePath; + if (op.port === 'nm') { + switch (op.op.kind) { + case 'createGsmProfile': { + const profile = await ctx.nm.createGsmProfile(op.op.profile); + io.out(` applied nm.createGsmProfile -> ${profile.connectionId}`); + return; + } + case 'updateGsmProfile': + await ctx.nm.updateGsmProfile(op.op.connectionId, op.op.patch); + io.out(` applied nm.updateGsmProfile ${op.op.connectionId}`); + return; + case 'activate': { + const receipt = await ctx.nm.activate(op.op.connectionId, op.op.deviceIfname); + io.out(` applied nm.activate -> ${receipt.status}: ${receipt.reason}`); + return; + } + case 'deactivate': { + const receipt = await ctx.nm.deactivate(op.op.connectionId, op.op.deviceIfname); + io.out(` applied nm.deactivate -> ${receipt.status}: ${receipt.reason}`); + return; + } + } + } + switch (op.op.kind) { + case 'setRadioModes': { + const receipt = await ctx.backend.setRadioModes(ref, op.op.preference); + io.out(` applied mm.setRadioModes -> ${receipt.status}: ${receipt.reason}`); + return; + } + case 'setPrimarySimSlot': { + const receipt = await ctx.backend.setPrimarySimSlot(ref, op.op.slotIndex); + io.out(` applied mm.setPrimarySimSlot -> ${receipt.status}: ${receipt.reason}`); + return; + } + } +} + +/** Run the reconcile: plan, apply the ops, and print the receipts. Returns an exit code. */ +export async function runApply( + ctx: StackContext, + io: CliIo, + spec: PolicyFileSpec, +): Promise { + const list = await ctx.backend.start(); + const modem = selectModem(list.rows, spec.slot); + if (modem === undefined) { + io.err( + `apply: no modem${spec.slot !== undefined ? ` matching slot '${spec.slot}'` : ''} observed`, + ); + return 1; + } + if (!canBindPolicy(modem.identity)) { + io.err( + `apply: refusing — modem ${modem.identity.runtimePath} has a low-confidence identity; cannot bind durable policy`, + ); + return 1; + } + const desired = toDesiredPolicy(spec, policyBindingKey(modem.identity)); + const plan = planReconcile(currentState(modem), desired, capabilities(modem)); + + io.out(`apply: modem ${modem.identity.runtimePath} — ${plan.ops.length} op(s)`); + for (const op of plan.ops) { + await applyOp(ctx, modem, op, io); + } + io.out('receipts:'); + for (const receipt of plan.receipts) { + io.out(` ${receipt.dimension}: ${receipt.status} — ${receipt.reason}`); + } + return 0; +} diff --git a/cli/src/commands/certify.ts b/cli/src/commands/certify.ts new file mode 100644 index 0000000..5e9a727 --- /dev/null +++ b/cli/src/commands/certify.ts @@ -0,0 +1,157 @@ +// `modem-control certify ` — capture a redacted, schema-validated certification +// bundle for one modem slot. +// +// The bundle records the USB and ModemManager evidence a human reviewer needs to certify +// a SKU: `lsusb -v`, `usb-devices`, the slot's udev properties, an `mmcli -K` dump, a +// redacted `GetManagedObjects`, and a bounded signal window. With `--transition ` +// it also captures transition evidence (before/after descriptors, the executed AT command, +// the port-drop / re-enumeration timeline) shaped to drop straight into an A4.2 catalog +// entry. Real captures are marked `synthetic: false`; the bundle sha256 is the value that +// goes into a catalog entry's `evidenceBundleSha256`. A failed or malformed capture exits +// non-zero with a clear message — a broken bundle is never written. + +import { + type AtCommandSender, + type DecodedManagedObjects, + fetchManagedObjects, + type MmUsbMode, + type UsbDeviceSnapshot, +} from '@ceralive/modem-control'; +import { buildCertificationBundle } from '../certify/bundle'; +import type { SignalRecord } from '../certify/bundle-schema'; +import { captureBase } from '../certify/capture'; +import { type CommandResult, SpawnCommandRunner } from '../certify/command-runner'; +import { CertifyError } from '../certify/errors'; +import { + createTransportSignalWindow, + DEFAULT_SIGNAL_WINDOW, + type SignalWindowBound, +} from '../certify/signal-window'; +import { captureTransitionEvidence } from '../certify/transition-evidence'; +import type { StackContext } from '../context'; +import type { CliIo } from '../io'; +import { selectModem } from '../select'; + +/** Parsed `certify` arguments. */ +export interface CertifyArgs { + readonly slot: string; + /** Certify AROUND a transition to this MM mode (transition-evidence mode). */ + readonly transition?: MmUsbMode; + /** Write the bundle JSON here; printed to stdout when omitted. */ + readonly output?: string; + /** `true` marks the bundle a synthetic sample; a real bench capture is `false`. */ + readonly synthetic: boolean; + readonly maxSignals?: number; + readonly windowMs?: number; +} + +/** The injectable capture seams — production builds these from the live stack. */ +export interface CertifyDeps { + run(command: string, args: readonly string[]): Promise; + fetchManagedObjects(): Promise; + captureSignalWindow(): Promise; + enumerate(): Promise; + readonly atSender: AtCommandSender; + now(): number; + readonly synthetic: boolean; + writeBundle(path: string, content: string): Promise; +} + +/** A bench AT sender: there is no raw serial port here, so any send is a clear error. */ +const benchAtSender: AtCommandSender = { + send(command: string) { + return Promise.reject( + new CertifyError(`no AT serial transport on the bench (hardware-gated): '${command}'`), + ); + }, +}; + +/** Build the production capture seams from a live stack context. */ +export function certifyDepsFromContext(ctx: StackContext, args: CertifyArgs): CertifyDeps { + const bound: SignalWindowBound | undefined = + args.maxSignals !== undefined || args.windowMs !== undefined + ? { + maxSignals: args.maxSignals ?? DEFAULT_SIGNAL_WINDOW.maxSignals, + windowMs: args.windowMs ?? DEFAULT_SIGNAL_WINDOW.windowMs, + } + : undefined; + return { + run: (command, cmdArgs) => new SpawnCommandRunner().run(command, cmdArgs), + fetchManagedObjects: () => fetchManagedObjects(ctx.transport, ctx.destination), + captureSignalWindow: createTransportSignalWindow(ctx.transport, () => ctx.now(), bound), + enumerate: () => ctx.enumerate(), + atSender: benchAtSender, + now: () => ctx.now(), + synthetic: false, + writeBundle: async (path, content) => { + await Bun.write(path, content); + }, + }; +} + +/** Run the certify capture, writing (or printing) the bundle. Returns an exit code. */ +export async function runCertify( + ctx: StackContext, + io: CliIo, + args: CertifyArgs, + deps: CertifyDeps, +): Promise { + const list = await ctx.backend.start(); + const modem = selectModem(list.rows, args.slot); + if (modem === undefined) { + io.err(`certify: no modem matching slot '${args.slot}'`); + return 1; + } + const ifname = modem.dataInterface.present ? modem.dataInterface.name : undefined; + const devices = await deps.enumerate().catch(() => []); + const device = devices.find((d) => d.ifname !== undefined && d.ifname === ifname); + + if (args.transition !== undefined && device === undefined) { + io.err( + `certify: --transition needs a matched USB device for slot '${args.slot}' (hardware-gated)`, + ); + return 1; + } + + // A malformed / failed capture throws a `CertifyError`; catch it so the tool exits + // non-zero with a clear message rather than crashing — a broken bundle is never written. + try { + const base = await captureBase(deps, { + mmcliTarget: String(modem.identity.runtimePath), + ...(device !== undefined ? { device } : {}), + }); + + let transition: Awaited> | undefined; + if (args.transition !== undefined && device !== undefined) { + transition = await captureTransitionEvidence( + { enumerate: () => deps.enumerate(), atSender: deps.atSender, now: () => deps.now() }, + { targetMode: args.transition, device }, + ); + } + + const { bundle, sha256 } = buildCertificationBundle({ + slot: args.slot, + synthetic: deps.synthetic || args.synthetic, + capturedAtMs: deps.now(), + base, + ...(transition !== undefined ? { transition } : {}), + }); + + const json = `${JSON.stringify(bundle, null, 2)}\n`; + if (args.output !== undefined) { + await deps.writeBundle(args.output, json); + io.out(`certify: wrote bundle to ${args.output}`); + } else { + io.out(json.trimEnd()); + } + const transitionLabel = + transition !== undefined ? `${transition.from}->${transition.to}` : 'none'; + io.out( + `CERTIFY OK: sha256=${sha256} synthetic=${bundle.synthetic} transition=${transitionLabel} slot=${args.slot}`, + ); + return 0; + } catch (error) { + io.err(`certify: ${error instanceof Error ? error.message : String(error)}`); + return 1; + } +} diff --git a/cli/src/commands/probe.ts b/cli/src/commands/probe.ts new file mode 100644 index 0000000..92f7853 --- /dev/null +++ b/cli/src/commands/probe.ts @@ -0,0 +1,132 @@ +// `modem-control probe` — a one-shot stack snapshot. +// +// Prints, for every currently-observed modem: its identity (with the identity-ladder +// resolution), lifecycle state, MM feature-detection result, read-only enrichment, and +// normalized cell info — plus the classified USB devices (device class + observed USB +// mode). It ends with a machine-checkable `PROBE OK: external-auth, objects=` line +// the compiled-probe smoke asserts. The EXTERNAL-auth D-Bus handshake and the +// authoritative `GetManagedObjects` read both happen inside `backend.start()`. + +import { + classifyDevice, + detectModemFeatures, + detectUsbMode, + fetchManagedObjects, + MM_MANAGER_IFACE, + MM_ROOT_PATH, + MODEM_IFACE, + modemIdentityFactsFromTree, + pathsWithInterface, + type ResolvedIdentity, + resolveModemIdentities, +} from '@ceralive/modem-control'; +import type { DbusValue } from '@ceralive/modem-control/transport'; +import type { StackContext } from '../context'; +import type { CliIo } from '../io'; +import { + renderCellReading, + renderEnrichment, + renderFeatures, + renderIdentity, + renderResolvedIdentity, + renderState, + renderUsbDevice, +} from '../render'; + +/** Read the MM daemon `Version` property; '' when unreadable (e.g. the fake service). */ +async function readMmVersion(ctx: StackContext): Promise { + try { + const reply = await ctx.transport.callMethod({ + destination: ctx.destination, + path: MM_ROOT_PATH, + interface: 'org.freedesktop.DBus.Properties', + member: 'Get', + signature: 'ss', + args: [MM_MANAGER_IFACE, 'Version'], + }); + return variantString(reply.body[0]); + } catch { + return ''; + } +} + +/** Unwrap a `Get` reply value (a variant `{ signature, value }`) to a string. */ +function variantString(value: DbusValue | undefined): string { + if ( + value !== undefined && + typeof value === 'object' && + !Array.isArray(value) && + 'value' in value + ) { + return String((value as { value: unknown }).value); + } + return typeof value === 'string' ? value : ''; +} + +/** Run the probe against the stack, writing the snapshot to `io`. Returns an exit code. */ +export async function runProbe(ctx: StackContext, io: CliIo): Promise { + const list = await ctx.backend.start(); + if (!list.ok) { + io.err(`probe: observation source unavailable (${list.reason})`); + } + + let objectCount = 0; + const version = await readMmVersion(ctx); + const resolvedByPath = new Map(); + try { + const tree = await fetchManagedObjects(ctx.transport, ctx.destination); + objectCount = tree.length; + const paths = pathsWithInterface(tree, MODEM_IFACE); + const resolved = resolveModemIdentities( + paths.map((path) => modemIdentityFactsFromTree(tree, path)), + ); + paths.forEach((path, index) => { + const entry = resolved[index]; + if (entry !== undefined) { + resolvedByPath.set(path, entry); + } + }); + io.out(`ModemManager: version=${version || 'unknown'} objects=${objectCount}`); + + io.out(`modems: ${list.rows.length}`); + for (const snapshot of list.rows) { + const path = snapshot.identity.runtimePath; + io.out(''); + io.out(`modem ${path}`); + io.out(` ${renderIdentity(snapshot.identity)}`); + const ladder = resolvedByPath.get(path); + if (ladder !== undefined) { + io.out(` ${renderResolvedIdentity(ladder)}`); + } + io.out(` ${renderState(snapshot)}`); + io.out(` features: ${renderFeatures(detectModemFeatures(version, tree, path))}`); + const enrichment = await ctx.backend.readEnrichment(path); + io.out(` enrichment: ${renderEnrichment(enrichment)}`); + if (enrichment.cellInfo.length === 0) { + io.out(' cell-info: (none)'); + } else { + for (const reading of enrichment.cellInfo) { + io.out(` cell: ${renderCellReading(reading)}`); + } + } + } + } catch (error) { + io.err(`probe: failed to read managed objects: ${message(error)}`); + return 1; + } + + const devices = await ctx.enumerate().catch(() => []); + io.out(''); + io.out(`usb-devices: ${devices.length}`); + for (const device of devices) { + io.out(` ${renderUsbDevice(device, classifyDevice(device), detectUsbMode(device))}`); + } + + io.out(''); + io.out(`PROBE OK: external-auth, objects=${objectCount}`); + return 0; +} + +function message(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/cli/src/commands/set-usb-mode.ts b/cli/src/commands/set-usb-mode.ts new file mode 100644 index 0000000..15febae --- /dev/null +++ b/cli/src/commands/set-usb-mode.ts @@ -0,0 +1,63 @@ +// `modem-control set-usb-mode --confirm` — a certified USB-mode switch. +// +// The `--confirm` flag maps DIRECTLY to the transaction's `confirm: true` precondition +// (A4.2). Omitting it MUST refuse the transition at entry with ZERO side effects (the +// A4.2 TIER-A entry refusal): the transaction never enters the actor, so no NM / MM / AT +// call is issued. `set-usb-mode` builds the request (physical facts resolved from the +// stack) and lets the transaction decide — the CLI never bypasses the entry gate. + +import type { + MmUsbMode, + UsbModeTransition, + UsbModeTransitionRequest, +} from '@ceralive/modem-control'; +import type { CliIo } from '../io'; + +/** Parsed `set-usb-mode` arguments. */ +export interface UsbModeArgs { + readonly slot: string; + readonly target: MmUsbMode; + /** From the `--confirm` flag — maps to the transaction's `confirm` precondition. */ + readonly confirm: boolean; + /** Bench runs are a maintenance context; the second A4.2 gate. */ + readonly maintenance: boolean; +} + +/** Resolve the physical transition request for a slot, or explain why it cannot. */ +export type RequestResolver = ( + args: UsbModeArgs, +) => Promise< + | { readonly ok: true; readonly request: UsbModeTransitionRequest } + | { readonly ok: false; readonly error: string } +>; + +/** Run the transition and report its outcome. Returns a process exit code. */ +export async function runSetUsbMode( + io: CliIo, + resolve: RequestResolver, + transition: UsbModeTransition, + args: UsbModeArgs, +): Promise { + const resolved = await resolve(args); + if (!resolved.ok) { + io.err(`set-usb-mode: ${resolved.error}`); + return 1; + } + const outcome = await transition.execute(resolved.request); + switch (outcome.status) { + case 'refused': + io.out(`set-usb-mode: REFUSED (${outcome.stage}) — ${outcome.reason}`); + io.out( + `steps: ${outcome.steps.length > 0 ? outcome.steps.join(' -> ') : '(none — zero side effects)'}`, + ); + return 1; + case 'failed': + io.out(`set-usb-mode: FAILED${outcome.degraded ? ' (degraded)' : ''} — ${outcome.reason}`); + io.out(`steps: ${outcome.steps.join(' -> ')}`); + return 1; + case 'succeeded': + io.out(`set-usb-mode: OK ${args.slot} -> ${args.target} on ${outcome.newIfname}`); + io.out(`steps: ${outcome.steps.join(' -> ')}`); + return 0; + } +} diff --git a/cli/src/commands/unlock.ts b/cli/src/commands/unlock.ts new file mode 100644 index 0000000..f5404f6 --- /dev/null +++ b/cli/src/commands/unlock.ts @@ -0,0 +1,49 @@ +// `modem-control unlock-pin` / `unlock-puk ` — SIM unlock with redacted prompts. +// +// The PIN / PUK is read through the I/O seam's `promptSecret`, which reads the secret +// with terminal echo DISABLED — it is never rendered to the screen, and it is never +// written back to any stream (the outcome `reason` from A3.3 never carries the secret +// either). The mutations run through the ModemManager port's exactly-once `sendPin` / +// `sendPuk` (read-before-submit is the adapter's job, A3.3). + +import type { StackContext } from '../context'; +import type { CliIo } from '../io'; +import { selectModem } from '../select'; + +/** Which secret the command unlocks. */ +export type UnlockKind = 'pin' | 'puk'; + +/** Prompt for the secret(s) and submit them; never echoes the secret. Returns an exit code. */ +export async function runUnlock( + ctx: StackContext, + io: CliIo, + kind: UnlockKind, + slot: string | undefined, +): Promise { + const list = await ctx.backend.start(); + const modem = selectModem(list.rows, slot); + if (modem === undefined) { + io.err( + `unlock-${kind}: no modem${slot !== undefined ? ` matching slot '${slot}'` : ''} observed`, + ); + return 1; + } + const ref = modem.identity.runtimePath; + + if (kind === 'pin') { + const pin = await io.promptSecret('SIM PIN: '); + const result = await ctx.backend.sendPin(ref, pin); + const remaining = + result.remainingAttempts !== undefined ? ` (remaining=${result.remainingAttempts})` : ''; + io.out(`unlock-pin: ${result.outcome}${remaining} — ${result.reason}`); + return result.outcome === 'unlocked' ? 0 : 1; + } + + const puk = await io.promptSecret('SIM PUK: '); + const newPin = await io.promptSecret('New SIM PIN: '); + const result = await ctx.backend.sendPuk(ref, puk, newPin); + const remaining = + result.remainingAttempts !== undefined ? ` (remaining=${result.remainingAttempts})` : ''; + io.out(`unlock-puk: ${result.outcome}${remaining} — ${result.reason}`); + return result.outcome === 'unlocked' ? 0 : 1; +} diff --git a/cli/src/commands/usage.ts b/cli/src/commands/usage.ts new file mode 100644 index 0000000..c79d0b0 --- /dev/null +++ b/cli/src/commands/usage.ts @@ -0,0 +1,31 @@ +// `modem-control usage` — print the data-usage sampler snapshot. +// +// Takes one sampling pass over the per-interface cumulative counters for the supplied +// per-slot observations, then prints the queryable `UsageSnapshot` (A4.3). `cycleBytes` +// is CUMULATIVE-COUNTER-DERIVED per-cycle usage — never a rate — and `thresholdExceeded` +// is advisory only. A `flush()` bounds unpersisted loss on exit. + +import type { UsageObservation, UsageSampler } from '@ceralive/modem-control'; +import type { CliIo } from '../io'; + +/** Sample once and print the usage snapshot. Returns a process exit code. */ +export async function runUsage( + io: CliIo, + sampler: UsageSampler, + observations: readonly UsageObservation[], +): Promise { + await sampler.sample(observations); + const snapshot = sampler.snapshot(); + io.out(`usage: bootId=${snapshot.bootId} slots=${snapshot.slots.length}`); + for (const slot of snapshot.slots) { + const threshold = + slot.thresholdBytes !== undefined + ? ` threshold=${slot.thresholdBytes} exceeded=${slot.thresholdExceeded}` + : ''; + io.out( + ` ${slot.logicalSlotId}: cycleBytes=${slot.cycleBytes} cycleStart=${slot.cycleStartMs} paused=${slot.paused}${threshold}`, + ); + } + await sampler.flush(); + return 0; +} diff --git a/cli/src/commands/watch.ts b/cli/src/commands/watch.ts new file mode 100644 index 0000000..3bc34a4 --- /dev/null +++ b/cli/src/commands/watch.ts @@ -0,0 +1,111 @@ +// `modem-control watch` — a live event stream over the epoch-scoped observer. +// +// It subscribes BEFORE `start()` (the observer does not replay on subscribe), then +// prints each authoritative change as it happens. The safety-critical distinction the +// stream makes visible is the A3.1 epoch-authority one: a bus drop or MM restart marks +// modems `SOURCE-UNAVAILABLE` with their rows RETAINED (never a removal), whereas a real +// removal is an omission from a live snapshot and prints `REMOVED`. It exits after a +// bounded `--duration`/`--events` (for CI), or on Ctrl-C. + +import type { StackContext } from '../context'; +import type { CliIo } from '../io'; + +/** Bounded-run options so the stream terminates in CI. */ +export interface WatchOptions { + /** Stop after this many milliseconds. */ + readonly durationMs?: number; + /** Stop after this many change events. */ + readonly events?: number; + /** Stop when this signal aborts (SIGINT wiring, deterministic test teardown). */ + readonly signal?: AbortSignal; +} + +/** Tallies printed at exit, and asserted by the smoke. */ +export interface WatchSummary { + readonly events: number; + readonly unavailable: number; + readonly removed: number; +} + +/** Stream observation changes to `io` until the bound is reached. Returns an exit code. */ +export async function runWatch( + ctx: StackContext, + io: CliIo, + options: WatchOptions, +): Promise { + let events = 0; + let unavailable = 0; + let removed = 0; + let previous = new Map(); + + let resolveDone: () => void = () => undefined; + const done = new Promise((resolve) => { + resolveDone = resolve; + }); + + const unsubscribe = ctx.backend.observe((list) => { + const current = new Map(); + for (const row of list.rows) { + current.set(String(row.identity.runtimePath), row.sourceHealth); + } + for (const [path, health] of current) { + const before = previous.get(path); + if (before === undefined) { + io.out(`+ ADDED ${path} health=${health}`); + events += 1; + } else if (before !== health) { + io.out(`~ CHANGED ${path} health=${health}`); + events += 1; + } + } + // A removal is ONLY an omission from a live authoritative snapshot — a retained + // row on an `ok:false` list is NOT a removal (epoch authority, A3.1). + for (const path of previous.keys()) { + if (!current.has(path)) { + io.out(`- REMOVED ${path}`); + events += 1; + removed += 1; + } + } + if (!list.ok) { + io.out(`! SOURCE-UNAVAILABLE reason=${list.reason} retained=${list.rows.length}`); + unavailable += 1; + } + previous = current; + if (options.events !== undefined && events >= options.events) { + resolveDone(); + } + }); + + const timer = + options.durationMs !== undefined + ? setTimeout(() => resolveDone(), options.durationMs) + : undefined; + const onSigint = (): void => resolveDone(); + process.on('SIGINT', onSigint); + if (options.signal !== undefined) { + if (options.signal.aborted) { + resolveDone(); + } else { + options.signal.addEventListener('abort', () => resolveDone(), { once: true }); + } + } + if ( + options.durationMs === undefined && + options.events === undefined && + options.signal === undefined + ) { + io.err('watch: streaming changes — press Ctrl-C to stop'); + } + + await ctx.backend.start(); + await done; + + if (timer !== undefined) { + clearTimeout(timer); + } + process.off('SIGINT', onSigint); + unsubscribe(); + io.out(`WATCH DONE: events=${events}, unavailable=${unavailable}, removed=${removed}`); + return 0; +} diff --git a/cli/src/context.ts b/cli/src/context.ts new file mode 100644 index 0000000..49f3b34 --- /dev/null +++ b/cli/src/context.ts @@ -0,0 +1,101 @@ +// The bench stack context — one live handle to the D-Bus + NetworkManager backends. +// +// `--bus-address` (or `MODEM_CONTROL_BUS_ADDRESS`) is the injection point that lets the +// harness-driven CLI tests and the compiled-probe smoke point the SAME command code at +// the A2.3 fake ModemManager service instead of the real system bus. Without it the CLI +// talks to the system bus, where a real ModemManager lives. +// +// The interlock is STUBBED as always-allow here: the bench has no streaming to interlock +// against. Phase B wires CeraUI's real streaming-admission check into `LifecycleInterlock`. + +import { + ALLOW_ALL_INTERLOCK, + createMmDbusBackend, + createUsbEnumerator, + type LifecycleInterlock, + type MmDbusBackend, + type NetworkManagerPort, + NmcliNmPort, + SpawnNmcliRunner, + type UsbDeviceSnapshot, +} from '@ceralive/modem-control'; +import { createDbusTransport, type DbusTransport } from '@ceralive/modem-control/transport'; + +/** The default system-bus socket ModemManager listens on. */ +const SYSTEM_BUS_ADDRESS = 'unix:path=/var/run/dbus/system_bus_socket'; + +/** Global options shared by every command. */ +export interface GlobalOptions { + /** Encoded D-Bus bus address (harness / smoke injection). */ + readonly busAddress?: string; + /** Override the ModemManager bus name (defaults to the well-known name). */ + readonly destination?: string; +} + +/** + * Resolve the bus address the CLI connects to. Precedence: an explicit + * `--bus-address`, then `MODEM_CONTROL_BUS_ADDRESS`, then the real system bus. + */ +export function resolveBusAddress(options: GlobalOptions): string { + return options.busAddress ?? process.env.MODEM_CONTROL_BUS_ADDRESS ?? SYSTEM_BUS_ADDRESS; +} + +/** A live handle to the bench stack backends — closed via `close()`. */ +export interface StackContext { + readonly transport: DbusTransport; + readonly backend: MmDbusBackend; + readonly nm: NetworkManagerPort; + readonly destination: string; + readonly interlock: LifecycleInterlock; + enumerate(): Promise; + now(): number; + close(): Promise; +} + +/** Injectable overrides so tests can drive the commands against the fakes. */ +export interface StackContextDeps { + readonly transport?: DbusTransport; + readonly nm?: NetworkManagerPort; + readonly enumerate?: () => Promise; + readonly now?: () => number; +} + +/** + * Build a stack context. In production every dependency is created from the resolved + * bus address; a test injects a transport already pointed at the fake bus plus a fake + * NetworkManager and a canned USB enumeration. + */ +export function createStackContext( + options: GlobalOptions, + deps: StackContextDeps = {}, +): StackContext { + const busAddress = resolveBusAddress(options); + const ownsTransport = deps.transport === undefined; + const transport = deps.transport ?? createDbusTransport({ busAddress }); + const backend = createMmDbusBackend( + options.destination !== undefined + ? { transport, destination: options.destination } + : { transport }, + ); + const nm = deps.nm ?? new NmcliNmPort({ runner: new SpawnNmcliRunner() }); + const enumerator = createUsbEnumerator(); + const enumerate = deps.enumerate ?? (() => enumerator.enumerate()); + const now = deps.now ?? Date.now; + return { + transport, + backend, + nm, + destination: options.destination ?? 'org.freedesktop.ModemManager1', + interlock: ALLOW_ALL_INTERLOCK, + enumerate, + now, + async close(): Promise { + await backend.stop().catch(() => undefined); + // Only disconnect a transport this context created; an injected one is the + // caller's to close (the test owns its lifecycle). + if (ownsTransport) { + await transport.disconnect().catch(() => undefined); + } + }, + }; +} diff --git a/cli/src/index.ts b/cli/src/index.ts index eecfbdd..b8e6f04 100644 --- a/cli/src/index.ts +++ b/cli/src/index.ts @@ -1,16 +1,19 @@ #!/usr/bin/env bun // modem-control — bench CLI entry point. // -// Phase A bootstrap: the subcommands (probe/watch/apply/set-usb-mode/usage/certify) land -// in later waves. This placeholder keeps the binary wired to @ceralive/modem-control and -// the workspace test suite green. +// The command surface (probe / watch / apply / set-usb-mode / usage / unlock-pin / +// unlock-puk) lives under `./commands`, dispatched by `runCli` in `./cli`. This entry +// point wires the production stdio and runs the requested command, exiting with its code. import { PACKAGE_NAME } from '@ceralive/modem-control'; +import { runCli } from './cli'; +import { stdioIo } from './io'; export function banner(): string { return `modem-control (bench CLI) — backed by ${PACKAGE_NAME}`; } if (import.meta.main) { - console.log(banner()); + const code = await runCli(process.argv.slice(2), stdioIo()); + process.exit(code); } diff --git a/cli/src/integration.test.ts b/cli/src/integration.test.ts new file mode 100644 index 0000000..b619938 --- /dev/null +++ b/cli/src/integration.test.ts @@ -0,0 +1,152 @@ +// Harness-driven CLI integration — probe / watch / apply / unlock-pin run end-to-end +// against the A2.3 fake ModemManager (real D-Bus, EXTERNAL auth) plus the A2.3 fake +// NetworkManager. Runs under `dbus-run-session -- bun test cli`. The commands are the +// dev/TS build here (faster iteration); the compiled binary is exercised separately by +// the cross-arch probe smoke. + +import { afterEach, describe, expect, test } from 'bun:test'; +import { createDbusTransport, type DbusTransport } from '@ceralive/modem-control/transport'; +import { + FakeModemManager, + MM_LOCK_SIM_PIN, + type ModemSpec, +} from '../../control/test-support/fake-mm'; +import { FakeNetworkManagerPort } from '../../control/test-support/fake-nm'; +import { + hasSessionBus, + sessionBusAddress, + warnSkippedWithoutBus, +} from '../../control/test-support/session-bus'; +import { runApply } from './commands/apply'; +import { runProbe } from './commands/probe'; +import { runUnlock } from './commands/unlock'; +import { runWatch } from './commands/watch'; +import { createStackContext, type StackContext } from './context'; +import { type CapturingIo, capturingIo } from './io'; +import type { PolicyFileSpec } from './policy-file'; + +warnSkippedWithoutBus('bench CLI integration'); + +const sleep = (ms: number): Promise => new Promise((resolve) => setTimeout(resolve, ms)); + +async function waitFor(predicate: () => boolean, timeoutMs = 4000): Promise { + const deadline = Date.now() + timeoutMs; + while (!predicate()) { + if (Date.now() > deadline) { + throw new Error('waitFor timed out'); + } + await sleep(5); + } +} + +const spec = (index: number, extra: Partial = {}): ModemSpec => ({ + index, + sims: [ + { index, iccid: `890000000000000000${index}`, imsi: `00101000000000${index}`, active: true }, + ], + ...extra, +}); + +const POLICY: PolicyFileSpec = { + enabled: true, + connection: { apn: 'auto', ipFamily: 'ipv4v6' }, + roaming: false, + radio: { preferenceOrdered: ['5gnr', 'lte', 'umts', 'gsm'] }, +}; + +describe.skipIf(!hasSessionBus())('bench CLI — against the fake MM + NM', () => { + let fake: FakeModemManager; + let transport: DbusTransport; + let ctx: StackContext; + + async function boot(modems: readonly ModemSpec[]): Promise { + const busAddress = sessionBusAddress(); + fake = await FakeModemManager.start({ busAddress, modems }); + transport = createDbusTransport({ busAddress }); + ctx = createStackContext( + { busAddress }, + { transport, nm: new FakeNetworkManagerPort(), enumerate: () => Promise.resolve([]) }, + ); + return capturingIo(); + } + + afterEach(async () => { + await ctx.close(); + await transport.disconnect(); + await fake.stop(); + }); + + test('probe prints a stack snapshot and ends with PROBE OK; ICCID is redacted', async () => { + const io = await boot([spec(0), spec(1)]); + const code = await runProbe(ctx, io); + expect(code).toBe(0); + const text = io.stdout.join('\n'); + expect(text).toMatch(/PROBE OK: external-auth, objects=[1-9]/); + expect(text).toContain('/Modem/0'); + expect(text).toContain('/Modem/1'); + expect(text).toContain('sim=[redacted]'); + // The sensitive ICCID must NEVER appear raw in the output. + expect(text).not.toContain('8900000000000000000'); + }); + + test('watch discriminates bus-loss (source-unavailable, retained) from real removal', async () => { + const io = await boot([spec(0)]); + const controller = new AbortController(); + const watching = runWatch(ctx, io, { signal: controller.signal }); + + await waitFor(() => io.stdout.some((l) => l.startsWith('+ ADDED') && l.includes('/Modem/0'))); + fake.addModem(spec(1)); + await waitFor(() => io.stdout.some((l) => l.includes('/Modem/1'))); + + await fake.dropName(); + await waitFor(() => io.stdout.some((l) => l.startsWith('! SOURCE-UNAVAILABLE'))); + await fake.reclaimName(); + await waitFor(() => + io.stdout.some((l) => l.startsWith('~ CHANGED') && l.includes('health=live')), + ); + + fake.removeModem(1); + await waitFor(() => io.stdout.some((l) => l.startsWith('- REMOVED') && l.includes('/Modem/1'))); + + controller.abort(); + expect(await watching).toBe(0); + const text = io.stdout.join('\n'); + expect(text).toContain('! SOURCE-UNAVAILABLE'); + expect(text).toMatch(/- REMOVED .*\/Modem\/1/); + }, 15_000); + + test('apply reconciles the policy: creates the profile, sets radio, prints receipts', async () => { + const io = await boot([spec(0)]); + const code = await runApply(ctx, io, POLICY); + expect(code).toBe(0); + const text = io.stdout.join('\n'); + expect(text).toContain('applied nm.createGsmProfile'); + expect(text).toContain('applied mm.setRadioModes'); + expect(text).toContain('receipts:'); + expect(text).toMatch(/connection: (applied|pending)/); + expect(ctx.nm instanceof FakeNetworkManagerPort).toBe(true); + }); + + test('unlock-pin submits the PIN and never echoes it', async () => { + const io = capturingIo(['1234']); + const busAddress = sessionBusAddress(); + fake = await FakeModemManager.start({ + busAddress, + modems: [spec(0, { unlockRequired: MM_LOCK_SIM_PIN })], + }); + fake.expectPin(0, '1234'); + transport = createDbusTransport({ busAddress }); + ctx = createStackContext( + { busAddress }, + { transport, nm: new FakeNetworkManagerPort(), enumerate: () => Promise.resolve([]) }, + ); + + const code = await runUnlock(ctx, io, 'pin', undefined); + expect(code).toBe(0); + const text = io.stdout.join('\n'); + expect(text).toContain('unlock-pin: unlocked'); + // The secret must never be echoed to any output stream. + expect(text).not.toContain('1234'); + expect(io.stderr.join('\n')).not.toContain('1234'); + }); +}); diff --git a/cli/src/io.ts b/cli/src/io.ts new file mode 100644 index 0000000..2ab78b1 --- /dev/null +++ b/cli/src/io.ts @@ -0,0 +1,125 @@ +// Terminal I/O seam for the bench CLI. +// +// Every command writes through a `CliIo` rather than touching `console` / `stdin` +// directly, so harness-driven tests capture output and feed canned secrets without a +// real TTY. The production `stdioIo()` writes to stdout/stderr and reads PIN / PUK +// secrets with terminal echo DISABLED — the secret is never rendered back to the +// screen (a hard requirement: redacted prompts). + +/** The output + prompt surface every command depends on. */ +export interface CliIo { + /** Write a line to standard output. */ + out(line: string): void; + /** Write a line to standard error (diagnostics, prompts). */ + err(line: string): void; + /** + * Prompt for a SECRET (PIN / PUK) with NO terminal echo. The typed characters + * are never shown and the secret is never written back to any stream. + */ + promptSecret(label: string): Promise; +} + +/** Read one line from stdin with echo suppressed on an interactive TTY. */ +async function readSecret(label: string): Promise { + process.stderr.write(label); + const stdin = process.stdin; + const isTty = Boolean((stdin as { isTTY?: boolean }).isTTY); + if (!isTty) { + // Non-interactive (piped) stdin: read a line as-is — nothing is echoed anyway. + const line = await readLine(stdin); + process.stderr.write('\n'); + return line; + } + const setRawMode = (stdin as { setRawMode?: (mode: boolean) => void }).setRawMode; + setRawMode?.call(stdin, true); + try { + return await new Promise((resolve, reject) => { + let secret = ''; + const onData = (chunk: Buffer): void => { + for (const byte of chunk) { + if (byte === 0x03) { + cleanup(); + reject(new Error('aborted')); + return; + } + if (byte === 0x0d || byte === 0x0a) { + cleanup(); + resolve(secret); + return; + } + if (byte === 0x7f || byte === 0x08) { + secret = secret.slice(0, -1); + continue; + } + secret += String.fromCharCode(byte); + } + }; + const cleanup = (): void => { + stdin.off('data', onData); + setRawMode?.call(stdin, false); + process.stderr.write('\n'); + }; + stdin.on('data', onData); + }); + } finally { + setRawMode?.call(stdin, false); + } +} + +/** Read a single newline-terminated line from a stream (non-TTY path). */ +function readLine(stdin: NodeJS.ReadStream): Promise { + return new Promise((resolve) => { + let buffer = ''; + const onData = (chunk: Buffer): void => { + buffer += chunk.toString('utf8'); + const newline = buffer.indexOf('\n'); + if (newline >= 0) { + stdin.off('data', onData); + resolve(buffer.slice(0, newline).replace(/\r$/, '')); + } + }; + stdin.on('data', onData); + stdin.on('end', () => resolve(buffer.replace(/\r$/, ''))); + }); +} + +/** The production I/O: stdout / stderr + a no-echo secret prompt. */ +export function stdioIo(): CliIo { + return { + out(line: string): void { + process.stdout.write(`${line}\n`); + }, + err(line: string): void { + process.stderr.write(`${line}\n`); + }, + promptSecret(label: string): Promise { + return readSecret(label); + }, + }; +} + +/** A buffered I/O for tests: captures output and returns queued secrets. */ +export interface CapturingIo extends CliIo { + readonly stdout: string[]; + readonly stderr: string[]; +} + +/** Create a capturing I/O that yields `secrets` in order from `promptSecret`. */ +export function capturingIo(secrets: readonly string[] = []): CapturingIo { + const stdout: string[] = []; + const stderr: string[] = []; + const queue = [...secrets]; + return { + stdout, + stderr, + out(line: string): void { + stdout.push(line); + }, + err(line: string): void { + stderr.push(line); + }, + promptSecret(): Promise { + return Promise.resolve(queue.shift() ?? ''); + }, + }; +} diff --git a/cli/src/policy-file.ts b/cli/src/policy-file.ts new file mode 100644 index 0000000..1aab4cd --- /dev/null +++ b/cli/src/policy-file.ts @@ -0,0 +1,129 @@ +// Parse + validate an `apply --policy ` desired-state file (JSON or YAML). +// +// The file carries the operator's INTENT — the connection / roaming / radio / simSlot +// / recovery / usage desires the reconcile planner consumes. It does NOT carry the +// identity binding key: `apply` derives `boundTo` from the selected modem's live +// identity (refusing an ambiguous one), so a hand-written file never has to encode a +// branded equipment id. Validation is schema-based (zod) so malformed input fails +// visibly with a named path, never silently. + +import type { + DesiredCellularPolicy, + PolicyBindingKey, + RadioAccessTechnology, +} from '@ceralive/modem-control'; +import { z } from 'zod'; + +const RAT_VALUES = ['gsm', 'umts', 'lte', '5gnr'] as const; + +const authSchema = z + .object({ + username: z.string().optional(), + password: z.string().optional(), + }) + .strict(); + +const connectionSchema = z + .object({ + apn: z.string().min(1), + ipFamily: z.enum(['ipv4', 'ipv6', 'ipv4v6']), + auth: authSchema.optional(), + networkId: z.string().optional(), + }) + .strict(); + +const radioSchema = z + .object({ + preferenceOrdered: z.array(z.enum(RAT_VALUES)).min(1), + allowedSet: z.array(z.enum(RAT_VALUES)).optional(), + }) + .strict(); + +/** The validated shape of a policy file. */ +export const policyFileSchema = z + .object({ + /** Optional slot selector (`logicalSlotId` or modem index) — which modem to bind. */ + slot: z.string().optional(), + enabled: z.boolean(), + connection: connectionSchema, + roaming: z.boolean(), + radio: radioSchema, + simSlot: z.number().int().positive().optional(), + recovery: z.object({ enabled: z.boolean() }).strict().optional(), + usage: z + .object({ + cycleDay: z.number().int().min(1).max(31).optional(), + thresholdBytes: z.number().int().nonnegative().optional(), + }) + .strict() + .optional(), + }) + .strict(); + +/** A parsed, validated policy file. */ +export type PolicyFileSpec = z.infer; + +/** Parse raw policy-file text (JSON or YAML) into a validated spec. */ +export function parsePolicyText(text: string, path: string): PolicyFileSpec { + const raw = + path.endsWith('.yaml') || path.endsWith('.yml') ? Bun.YAML.parse(text) : JSON.parse(text); + const result = policyFileSchema.safeParse(raw); + if (!result.success) { + const issue = result.error.issues[0]; + const where = issue?.path.join('.') || '(root)'; + throw new Error( + `invalid policy file ${path}: ${where}: ${issue?.message ?? 'schema mismatch'}`, + ); + } + return result.data; +} + +/** Read + parse a policy file from disk. */ +export async function readPolicyFile(path: string): Promise { + const text = await Bun.file(path).text(); + return parsePolicyText(text, path); +} + +/** Rebuild auth omitting absent optional fields (exactOptionalPropertyTypes-safe). */ +function toAuth(auth: NonNullable): { + username?: string; + password?: string; +} { + return { + ...(auth.username !== undefined ? { username: auth.username } : {}), + ...(auth.password !== undefined ? { password: auth.password } : {}), + }; +} + +/** Build the full `DesiredCellularPolicy` from a file spec bound to one modem. */ +export function toDesiredPolicy( + spec: PolicyFileSpec, + boundTo: PolicyBindingKey, +): DesiredCellularPolicy { + const allowed = spec.radio.allowedSet; + return { + boundTo, + enabled: spec.enabled, + connection: { + apn: spec.connection.apn, + ipFamily: spec.connection.ipFamily, + ...(spec.connection.auth !== undefined ? { auth: toAuth(spec.connection.auth) } : {}), + ...(spec.connection.networkId !== undefined ? { networkId: spec.connection.networkId } : {}), + }, + roaming: spec.roaming, + radio: { + preferenceOrdered: spec.radio.preferenceOrdered as readonly RadioAccessTechnology[], + ...(allowed !== undefined + ? { allowedSet: new Set(allowed as RadioAccessTechnology[]) } + : {}), + }, + ...(spec.simSlot !== undefined ? { simSlot: spec.simSlot } : {}), + recovery: { enabled: spec.recovery?.enabled ?? false }, + usage: { + ...(spec.usage?.cycleDay !== undefined ? { cycleDay: spec.usage.cycleDay } : {}), + ...(spec.usage?.thresholdBytes !== undefined + ? { thresholdBytes: spec.usage.thresholdBytes } + : {}), + }, + }; +} diff --git a/cli/src/render.ts b/cli/src/render.ts new file mode 100644 index 0000000..73bf4c5 --- /dev/null +++ b/cli/src/render.ts @@ -0,0 +1,104 @@ +// Text rendering for the bench CLI's `probe` / `watch` output. +// +// Pure string builders — no I/O — so they are trivially unit-testable and shared by +// both commands. The one hard rule: the SENSITIVE subscription id (ICCID / EID) is +// NEVER printed raw; it is shown as a redacted presence marker only. + +import type { + CellReading, + CellularSnapshot, + DeviceClassification, + MmFeatures, + ModemEnrichment, + ModemIdentity, + ResolvedIdentity, + UsbDeviceSnapshot, +} from '@ceralive/modem-control'; + +/** A redacted marker for a present-but-sensitive value. */ +const REDACTED = '[redacted]'; + +/** Render the equipment id as `provenance:value(confidence)` — value is not sensitive. */ +function renderEquipment(identity: ModemIdentity): string { + const equipment = identity.equipmentId; + if (equipment.provenance === 'none') { + return `none(${equipment.confidence})`; + } + return `${equipment.provenance}:${equipment.value}(${equipment.confidence})`; +} + +/** Render a modem identity line; the subscription id is redacted, never shown raw. */ +export function renderIdentity(identity: ModemIdentity): string { + const parts = [ + `path=${identity.runtimePath}`, + `equipment=${renderEquipment(identity)}`, + `slot=${identity.logicalSlotId ?? '-'}`, + `sim=${identity.subscriptionId !== undefined ? REDACTED : '-'}`, + ]; + return parts.join(' '); +} + +/** Render the identity-ladder resolution (slot source, confidence, stable key). */ +export function renderResolvedIdentity(resolved: ResolvedIdentity): string { + return `ladder: source=${resolved.slotSource} confidence=${resolved.confidence} key=${resolved.stableKey}`; +} + +/** Render the orthogonal lifecycle state of a snapshot. */ +export function renderState(snapshot: CellularSnapshot): string { + const rats = [...snapshot.registration.activeRats].join('+') || '-'; + return [ + `presence=${snapshot.presence}`, + `health=${snapshot.sourceHealth}`, + `mm=${snapshot.mmState}`, + `radio=${snapshot.radioPower}`, + `reg=${snapshot.registration.status}(${rats})`, + `nm=${snapshot.nmActivation}`, + `rev=${snapshot.revision}`, + ].join(' '); +} + +/** Render the MM feature-detection result. */ +export function renderFeatures(features: MmFeatures): string { + return [ + `physdev=${features.physdev}`, + `cellInfo=${features.cellInfo}`, + `esim=${features.esimStatus}`, + `opSerialization=${features.opSerialization}`, + ].join(' '); +} + +/** Render one normalized cell reading. */ +export function renderCellReading(reading: CellReading): string { + const parts = [reading.serving ? 'serving' : 'neighbor']; + if (reading.cellId !== undefined) parts.push(`cellId=${reading.cellId}`); + if (reading.pci !== undefined) parts.push(`pci=${reading.pci}`); + if (reading.rsrp !== undefined) parts.push(`rsrp=${reading.rsrp}`); + if (reading.rsrq !== undefined) parts.push(`rsrq=${reading.rsrq}`); + if (reading.sinr !== undefined) parts.push(`sinr=${reading.sinr}`); + if (reading.band !== undefined) parts.push(`band=${reading.band}`); + return parts.join(' '); +} + +/** Render the read-only enrichment (firmware, eSIM, signal cadence, serving cell). */ +export function renderEnrichment(enrichment: ModemEnrichment): string { + return [ + `firmware=${enrichment.revision ?? '-'}`, + `simType=${enrichment.esim.simType}`, + `esimStatus=${enrichment.esim.esimStatus}`, + `signalCadence=${enrichment.signalCadence}`, + ].join(' '); +} + +/** Render one classified USB device (class + observed mode). */ +export function renderUsbDevice( + device: UsbDeviceSnapshot, + classification: DeviceClassification, + mode: string | undefined, +): string { + return [ + `${device.vendorId}:${device.productId}`, + `class=${classification.deviceClass}`, + `mode=${mode ?? 'unknown'}`, + `reason=${classification.reason}`, + ].join(' '); +} diff --git a/cli/src/select.ts b/cli/src/select.ts new file mode 100644 index 0000000..10f5554 --- /dev/null +++ b/cli/src/select.ts @@ -0,0 +1,22 @@ +// Shared modem selection — pick one observed modem by slot label or path suffix. +// +// Commands that act on a single modem (`apply`, `set-usb-mode`, `unlock-*`) accept an +// optional slot; absent one, they default to the first observed modem. Matching is by +// the stable `logicalSlotId` label or the trailing path segment (`/Modem/`). + +import type { CellularSnapshot } from '@ceralive/modem-control'; + +/** Pick the modem a command targets, or `undefined` when the slot matches nothing. */ +export function selectModem( + rows: readonly CellularSnapshot[], + slot: string | undefined, +): CellularSnapshot | undefined { + if (slot === undefined) { + return rows[0]; + } + return rows.find( + (row) => + String(row.identity.logicalSlotId) === slot || + String(row.identity.runtimePath).endsWith(`/${slot}`), + ); +} diff --git a/cli/src/set-usb-mode.test.ts b/cli/src/set-usb-mode.test.ts new file mode 100644 index 0000000..841c8cb --- /dev/null +++ b/cli/src/set-usb-mode.test.ts @@ -0,0 +1,157 @@ +// Harness-driven `set-usb-mode` integration — against the fake NM + a scripted AT / +// enumeration, no D-Bus needed. Proves the two behaviours that matter most: omitting +// `--confirm` refuses the transition at ENTRY with ZERO side effects (no AT, no inhibit, +// no nmcli), and supplying `--confirm` drives the certified synthetic-SKU transition to +// success (postcondition-verified re-enumeration into the target mode). + +import { expect, test } from 'bun:test'; +import { + type AtCommandSender, + type AtResponse, + connectionId, + deviceIfname, + epochMillis, + ModemActor, + type UsbDeviceSnapshot, + UsbModeTransition, + type UsbModeTransitionRequest, +} from '@ceralive/modem-control'; +import { FakeNetworkManagerPort } from '../../control/test-support/fake-nm'; +import { type RequestResolver, runSetUsbMode, type UsbModeArgs } from './commands/set-usb-mode'; +import { capturingIo } from './io'; + +const SYNTHETIC_SKU = { + vidPid: '2c7c:0125', + model: 'CERALIVE-SYNTHETIC-TEST-SKU', + firmwarePrefix: 'SYNTHETICFW01', +} as const; + +function baseRequest(): UsbModeTransitionRequest { + return { + stableKey: 'slot-x', + sku: SYNTHETIC_SKU, + fromMode: 'qmi', + toMode: 'mbim', + connectionId: connectionId('uuid-1'), + deviceIfname: deviceIfname('wwan0'), + cachedPhysicalUid: 'usb-1-2', + inhibitUid: 'usb-1-2', + confirm: false, + maintenance: true, + now: epochMillis(Date.now()), + probeReadiness: () => Promise.resolve({ identityConfidence: 'high' }), + }; +} + +const resolverFor = + (request: UsbModeTransitionRequest): RequestResolver => + (args: UsbModeArgs) => + Promise.resolve({ ok: true, request: { ...request, confirm: args.confirm } }); + +test('set-usb-mode refuses without --confirm and touches nothing', async () => { + const nm = new FakeNetworkManagerPort(); + const sends: string[] = []; + const inhibits: string[] = []; + const atSender: AtCommandSender = { + send(command: string): Promise { + sends.push(command); + return Promise.resolve({ ok: true, raw: 'OK' }); + }, + }; + const transition = new UsbModeTransition({ + actor: new ModemActor(), + nm, + modemManager: { + inhibit(uid: string) { + inhibits.push(uid); + return Promise.resolve({ uid, acquiredAt: epochMillis(Date.now()) }); + }, + uninhibit: () => Promise.resolve(), + }, + atSender, + enumerate: () => Promise.resolve([]), + }); + const io = capturingIo(); + + const code = await runSetUsbMode(io, resolverFor(baseRequest()), transition, { + slot: 'slot-x', + target: 'mbim', + confirm: false, + maintenance: true, + }); + + expect(code).toBe(1); + expect(io.stdout.join('\n')).toContain('REFUSED (entry)'); + expect(io.stdout.join('\n')).toMatch(/confirm/i); + // Zero side effects: no AT command, no inhibit, no nmcli call. + expect(sends).toEqual([]); + expect(inhibits).toEqual([]); + expect(nm.runner.calls).toEqual([]); +}); + +test('set-usb-mode with --confirm runs the certified transition to success', async () => { + const nm = new FakeNetworkManagerPort(); + const qmiDevice: UsbDeviceSnapshot = { + vendorId: '2c7c', + productId: '0125', + bDeviceClass: 0, + physicalUid: 'usb-1-2', + ifname: 'wwan0', + interfaces: [ + { interfaceClass: 255, interfaceSubClass: 255, interfaceProtocol: 255, driver: 'qmi_wwan' }, + ], + }; + const mbimDevice: UsbDeviceSnapshot = { + vendorId: '2c7c', + productId: '0125', + bDeviceClass: 0, + physicalUid: 'usb-1-2', + ifname: 'wwan0', + interfaces: [ + { interfaceClass: 2, interfaceSubClass: 14, interfaceProtocol: 0 }, + { interfaceClass: 10, interfaceSubClass: 0, interfaceProtocol: 2 }, + ], + }; + let phase: 'qmi' | 'dropped' | 'mbim' = 'qmi'; + const enumerate = (): Promise => { + if (phase === 'qmi') { + return Promise.resolve([qmiDevice]); + } + if (phase === 'dropped') { + phase = 'mbim'; + return Promise.resolve([]); + } + return Promise.resolve([mbimDevice]); + }; + const atSender: AtCommandSender = { + send(): Promise { + phase = 'dropped'; + return Promise.resolve({ ok: true, raw: 'OK' }); + }, + }; + const transition = new UsbModeTransition({ + actor: new ModemActor(), + nm, + modemManager: { + inhibit: (uid: string) => Promise.resolve({ uid, acquiredAt: epochMillis(Date.now()) }), + uninhibit: () => Promise.resolve(), + }, + atSender, + enumerate, + pollIntervalMs: 5, + reenumerationTimeoutMs: 2000, + watchdogMs: 2000, + }); + const io = capturingIo(); + + const code = await runSetUsbMode(io, resolverFor(baseRequest()), transition, { + slot: 'slot-x', + target: 'mbim', + confirm: true, + maintenance: true, + }); + + expect(code).toBe(0); + expect(io.stdout.join('\n')).toContain('set-usb-mode: OK'); + expect(io.stdout.join('\n')).toContain('wwan0'); +}); diff --git a/cli/src/wiring.ts b/cli/src/wiring.ts new file mode 100644 index 0000000..efdaca5 --- /dev/null +++ b/cli/src/wiring.ts @@ -0,0 +1,117 @@ +// Production wiring — builds the real dependencies the `set-usb-mode` and `usage` +// commands need from a live `StackContext`. The harness tests build their OWN +// dependencies (fakes) against the fake MM + NM, so these builders are the bench / +// on-device path only. Where a dependency is genuinely hardware-gated (a raw AT serial +// port, an NM connection for a modem the fake cannot supply) they degrade with a clear +// error rather than pretending — full wiring lands with the Phase-B composition root. + +import { + type AtCommandSender, + type AtResponse, + createUsageFileStore, + createUsageSampler, + fetchManagedObjects, + logicalSlotId, + MODEM_IFACE, + ModemActor, + modemIdentityFactsFromTree, + pathsWithInterface, + procNetDevCounterSource, + readBootId, + resolveModemIdentities, + type UsageObservation, + type UsageSampler, + UsbModeTransition, +} from '@ceralive/modem-control'; +import type { RequestResolver, UsbModeArgs } from './commands/set-usb-mode'; +import type { StackContext } from './context'; +import { selectModem } from './select'; + +/** Where per-slot usage state is persisted on device. */ +const USAGE_STORE_PATH = + process.env.MODEM_CONTROL_USAGE_STORE ?? '/var/lib/modem-control/usage.json'; + +/** A bench AT sender: there is no raw serial port here, so any send is a clear error. */ +const benchAtSender: AtCommandSender = { + send(command: string): Promise { + return Promise.reject( + new Error(`no AT serial transport on the bench (hardware-gated): '${command}'`), + ); + }, +}; + +/** Build the real USB-mode transaction over the live NM + MM ports. */ +export function buildUsbModeTransition(ctx: StackContext): UsbModeTransition { + return new UsbModeTransition({ + actor: new ModemActor(), + nm: ctx.nm, + modemManager: ctx.backend, + atSender: benchAtSender, + enumerate: () => ctx.enumerate(), + }); +} + +/** + * Resolve a transition request from the live stack. On the bench most physical facts + * (a matched USB device, an NM connection for the modem) are unavailable, so this + * returns a clear error — the real resolution runs on device. `confirm` and + * `maintenance` are carried straight through so the transaction's entry gate decides. + */ +export function buildRequestResolver(ctx: StackContext): RequestResolver { + return async (args: UsbModeArgs) => { + const list = await ctx.backend.start(); + const modem = selectModem(list.rows, args.slot); + if (modem === undefined) { + return { ok: false, error: `no modem matching slot '${args.slot}'` }; + } + const tree = await fetchManagedObjects(ctx.transport, ctx.destination); + const paths = pathsWithInterface(tree, MODEM_IFACE); + const resolved = resolveModemIdentities(paths.map((p) => modemIdentityFactsFromTree(tree, p))); + const index = paths.indexOf(String(modem.identity.runtimePath)); + const stableKey = resolved[index]?.stableKey ?? String(modem.identity.runtimePath); + const devices = await ctx.enumerate().catch(() => []); + const ifname = modem.dataInterface.present ? modem.dataInterface.name : undefined; + const device = devices.find((d) => d.ifname !== undefined && d.ifname === ifname); + if (device === undefined || device.physicalUid === undefined) { + return { + ok: false, + error: `cannot match modem ${stableKey} to a certified USB device on this bench (hardware-gated)`, + }; + } + return { + ok: false, + error: + 'set-usb-mode requires an NM connection for the modem, which the bench cannot supply (hardware-gated)', + }; + }; +} + +/** Build the usage sampler and the per-slot observations from the live stack. */ +export async function buildUsageInputs( + ctx: StackContext, +): Promise<{ sampler: UsageSampler; observations: readonly UsageObservation[] }> { + const bootId = await readBootId(); + const sampler = await createUsageSampler({ + bootId, + source: procNetDevCounterSource(), + store: createUsageFileStore({ path: USAGE_STORE_PATH }), + now: () => ctx.now(), + }); + const list = await ctx.backend.start(); + const observations: UsageObservation[] = []; + for (const row of list.rows) { + const slot = row.identity.logicalSlotId; + const name = row.dataInterface.present ? row.dataInterface.name : undefined; + if (slot === undefined || name === undefined) { + continue; + } + observations.push({ + logicalSlotId: logicalSlotId(String(slot)), + mappingGeneration: 0, + ifname: name, + confidence: row.identity.equipmentId.confidence, + usage: {}, + }); + } + return { sampler, observations }; +} diff --git a/control/package.json b/control/package.json index 444a29b..7642b8d 100644 --- a/control/package.json +++ b/control/package.json @@ -14,7 +14,8 @@ "access": "public" }, "exports": { - ".": "./src/index.ts" + ".": "./src/index.ts", + "./transport": "./src/transport/index.ts" }, "files": [ "src" @@ -22,5 +23,9 @@ "scripts": { "test": "bun test", "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@httptoolkit/dbus-native": "0.1.5", + "zod": "4.4.3" } } diff --git a/control/src/backend/at-lease.test.ts b/control/src/backend/at-lease.test.ts new file mode 100644 index 0000000..8c9f57d --- /dev/null +++ b/control/src/backend/at-lease.test.ts @@ -0,0 +1,106 @@ +// The AT lease's three guarantees: the allowlist rejects anything but ATI + catalog +// commands (and never touches the sender when it does); the watchdog fires + rejects +// a hung command; every attempt is audited through `redact`, so a secret in the +// context is stripped before it is stored. + +import { describe, expect, test } from 'bun:test'; +import { + AT_BASELINE_ALLOWLIST, + AtCommandLease, + AtCommandNotAllowedError, + type AtCommandSender, + AtCommandTimeoutError, + type AtResponse, + computeAtAllowlist, +} from './at-lease'; + +const CATALOG_COMMAND = 'AT+QCFG="usbnet",2'; + +function recordingSender(): { sender: AtCommandSender; sent: string[] } { + const sent: string[] = []; + return { + sent, + sender: { + send(command: string): Promise { + sent.push(command); + return Promise.resolve({ ok: true, raw: 'OK' }); + }, + }, + }; +} + +describe('AtCommandLease — allowlist', () => { + test('the baseline allowlist is exactly {ATI}; catalog commands union in', () => { + expect([...AT_BASELINE_ALLOWLIST]).toEqual(['ATI']); + const allowlist = computeAtAllowlist([CATALOG_COMMAND]); + expect(allowlist.has('ATI')).toBe(true); + expect(allowlist.has(CATALOG_COMMAND)).toBe(true); + }); + + test('ATI and the catalog command are allowed', async () => { + const { sender, sent } = recordingSender(); + const lease = new AtCommandLease({ sender, allowlist: computeAtAllowlist([CATALOG_COMMAND]) }); + await lease.run('ATI'); + await lease.run(CATALOG_COMMAND); + expect(sent).toEqual(['ATI', CATALOG_COMMAND]); + }); + + test('a command outside the allowlist is rejected WITHOUT touching the sender', async () => { + const { sender, sent } = recordingSender(); + const lease = new AtCommandLease({ sender, allowlist: computeAtAllowlist([CATALOG_COMMAND]) }); + await expect(lease.run('AT+DANGEROUS')).rejects.toBeInstanceOf(AtCommandNotAllowedError); + expect(sent).toEqual([]); + }); +}); + +describe('AtCommandLease — watchdog', () => { + test('a hung command fires the watchdog and rejects with a timeout', async () => { + const hangingSender: AtCommandSender = { send: () => new Promise(() => undefined) }; + const watchdogHits: string[] = []; + const lease = new AtCommandLease({ + sender: hangingSender, + allowlist: computeAtAllowlist([]), + timeoutMs: 20, + onWatchdog: (command) => { + watchdogHits.push(command); + }, + }); + await expect(lease.run('ATI')).rejects.toBeInstanceOf(AtCommandTimeoutError); + expect(watchdogHits).toEqual(['ATI']); + }); +}); + +describe('AtCommandLease — audit + redaction', () => { + test('a sensitive value in the audit context is redacted before recording', async () => { + const { sender } = recordingSender(); + const entries: unknown[] = []; + const lease = new AtCommandLease({ + sender, + allowlist: computeAtAllowlist([]), + audit: { record: (entry) => entries.push(entry) }, + }); + await lease.run('ATI', { subscriptionId: 'SECRET-ICCID', note: 'keep-me' }); + expect(entries).toHaveLength(1); + const recorded = entries[0] as { + command: string; + outcome: string; + context: Record; + }; + expect(recorded.command).toBe('ATI'); + expect(recorded.outcome).toBe('sent'); + expect(recorded.context.subscriptionId).toBe('[redacted]'); + expect(recorded.context.note).toBe('keep-me'); + }); + + test('a rejected command is audited with outcome "rejected"', async () => { + const { sender } = recordingSender(); + const entries: Array<{ outcome: string }> = []; + const lease = new AtCommandLease({ + sender, + allowlist: computeAtAllowlist([]), + audit: { record: (entry) => entries.push(entry as { outcome: string }) }, + }); + await expect(lease.run('AT+NOPE')).rejects.toBeInstanceOf(AtCommandNotAllowedError); + expect(entries[0]?.outcome).toBe('rejected'); + }); +}); diff --git a/control/src/backend/at-lease.ts b/control/src/backend/at-lease.ts new file mode 100644 index 0000000..0cff2ec --- /dev/null +++ b/control/src/backend/at-lease.ts @@ -0,0 +1,158 @@ +// The AT-command lease baseline — the ONLY channel raw AT commands may travel. +// +// Three non-negotiable safety properties (draft §rounds 5/6, §84 raw-AT lease): +// 1. ALLOWLIST — only `ATI` (identify) plus the exact commands a certified catalog +// entry declares may ever be sent. Anything else is rejected BEFORE the sender +// is touched. There is no escape hatch. +// 2. WATCHDOG — a command that does not return within the timeout fires the +// `onWatchdog` hook (the transition wires this to force-uninhibit) and rejects, +// so a hung AT write can never wedge the transaction forever. +// 3. AUDIT + REDACTION — every attempt is recorded through A2.2's `redact` (never a +// reimplementation), so an identifier that lands in an audit entry's context is +// stripped before it is stored. + +import { type EpochMillis, epochMillis } from '../domain'; +import { redact } from '../redact'; + +/** The baseline allowlist — identify only. Catalog commands are unioned in per SKU. */ +export const AT_BASELINE_ALLOWLIST: ReadonlySet = new Set(['ATI']); + +/** Union the baseline allowlist with a catalog entry's declared transition commands. */ +export function computeAtAllowlist(commands: Iterable): ReadonlySet { + return new Set([...AT_BASELINE_ALLOWLIST, ...commands]); +} + +/** An AT command's response. `ok` (an `OK` terminator) is NEVER transition-success alone. */ +export interface AtResponse { + readonly ok: boolean; + readonly raw: string; +} + +/** The raw AT transport — a serial write, injected so tests need no hardware. */ +export interface AtCommandSender { + send(command: string): Promise; +} + +/** One audited AT attempt. Recorded only after passing through `redact`. */ +export interface AtAuditEntry { + readonly command: string; + readonly outcome: 'sent' | 'rejected' | 'timeout' | 'error'; + readonly at: EpochMillis; + readonly ok?: boolean; + readonly reason?: string; + readonly context?: Record; +} + +/** Where audit entries go — receives a REDACTED copy of each `AtAuditEntry`. */ +export interface AtAuditSink { + record(entry: unknown): void; +} + +/** Thrown when a command outside the allowlist is attempted — the sender is never called. */ +export class AtCommandNotAllowedError extends Error { + constructor(command: string) { + super(`AT command not in allowlist: ${command}`); + this.name = 'AtCommandNotAllowedError'; + Object.setPrototypeOf(this, AtCommandNotAllowedError.prototype); + } +} + +/** Thrown when a command exceeds the watchdog timeout. */ +export class AtCommandTimeoutError extends Error { + constructor(command: string, timeoutMs: number) { + super(`AT command timed out after ${timeoutMs}ms: ${command}`); + this.name = 'AtCommandTimeoutError'; + Object.setPrototypeOf(this, AtCommandTimeoutError.prototype); + } +} + +/** Construction dependencies for an `AtCommandLease`. */ +export interface AtCommandLeaseDeps { + readonly sender: AtCommandSender; + readonly allowlist: ReadonlySet; + readonly audit?: AtAuditSink; + readonly now?: () => EpochMillis; + readonly timeoutMs?: number; + /** Fired when a command exceeds the timeout — the transition wires force-uninhibit here. */ + readonly onWatchdog?: (command: string) => void | Promise; +} + +const DEFAULT_AT_TIMEOUT_MS = 10_000; + +/** + * A held AT-command lease. `run` enforces the allowlist, bounds the send with a + * watchdog, and audits every attempt (redacted). The allowlist is fixed at + * construction from `computeAtAllowlist(entry)`, so a lease can only ever emit the + * commands one certified SKU permits. + */ +export class AtCommandLease { + readonly #sender: AtCommandSender; + readonly #allowlist: ReadonlySet; + readonly #audit: AtAuditSink | undefined; + readonly #now: () => EpochMillis; + readonly #timeoutMs: number; + readonly #onWatchdog: ((command: string) => void | Promise) | undefined; + + constructor(deps: AtCommandLeaseDeps) { + this.#sender = deps.sender; + this.#allowlist = deps.allowlist; + this.#audit = deps.audit; + this.#now = deps.now ?? ((): EpochMillis => epochMillis(Date.now())); + this.#timeoutMs = deps.timeoutMs ?? DEFAULT_AT_TIMEOUT_MS; + this.#onWatchdog = deps.onWatchdog; + } + + /** Send one AT command through the lease. `context` is redacted into the audit entry. */ + async run(command: string, context?: Record): Promise { + const ctx = context !== undefined ? { context } : {}; + if (!this.#allowlist.has(command)) { + this.#record({ + command, + outcome: 'rejected', + at: this.#now(), + reason: 'not in allowlist', + ...ctx, + }); + throw new AtCommandNotAllowedError(command); + } + try { + const response = await this.#sendWithWatchdog(command); + this.#record({ command, outcome: 'sent', at: this.#now(), ok: response.ok, ...ctx }); + return response; + } catch (error) { + const timedOut = error instanceof AtCommandTimeoutError; + if (timedOut) { + await this.#onWatchdog?.(command); + } + this.#record({ + command, + outcome: timedOut ? 'timeout' : 'error', + at: this.#now(), + reason: error instanceof Error ? error.message : String(error), + ...ctx, + }); + throw error; + } + } + + async #sendWithWatchdog(command: string): Promise { + let timer: ReturnType | undefined; + const watchdog = new Promise((_, reject) => { + timer = setTimeout( + () => reject(new AtCommandTimeoutError(command, this.#timeoutMs)), + this.#timeoutMs, + ); + }); + try { + return await Promise.race([this.#sender.send(command), watchdog]); + } finally { + if (timer !== undefined) { + clearTimeout(timer); + } + } + } + + #record(entry: AtAuditEntry): void { + this.#audit?.record(redact(entry)); + } +} diff --git a/control/src/backend/cell-info.test.ts b/control/src/backend/cell-info.test.ts new file mode 100644 index 0000000..3fbb176 --- /dev/null +++ b/control/src/backend/cell-info.test.ts @@ -0,0 +1,154 @@ +// Cell-info normalization — pure fixtures, no bus. +// +// Locks the review-corrected mappings (draft round 10/11): the real NR key is `sinr` +// (a `snr`-carrying dict is IGNORED), `physical-ci` → `pci`, `rsrp`/`rsrq` pass +// through, `band` only when directly supplied, provenance is always carried, and the +// serving-cell TOTAL order is permutation-invariant. + +import { describe, expect, test } from 'bun:test'; +import { epochMillis } from '../domain'; +import type { DbusVariant } from '../transport'; +import { type CellReading, normalizeCellReading, selectServingCell } from './cell-info'; +import type { DecodedProps } from './managed-objects'; + +type Scalar = number | string | boolean; + +function v(value: Scalar): DbusVariant { + const signature = typeof value === 'number' ? 'i' : typeof value === 'boolean' ? 'b' : 's'; + return { signature, value }; +} + +function cell(record: Record): DecodedProps { + return Object.entries(record).map(([key, value]) => [key, v(value)] as const); +} + +const PROVENANCE = { + source: '/org/freedesktop/ModemManager1/Modem/0', + observedAt: epochMillis(1000), +}; + +const read = (record: Record): CellReading => + normalizeCellReading(cell(record), PROVENANCE); + +/** Every fixed permutation of a small array — exhaustive, deterministic shuffle. */ +function permutations(items: readonly T[]): T[][] { + if (items.length <= 1) { + return [[...items]]; + } + const result: T[][] = []; + items.forEach((item, index) => { + const rest = [...items.slice(0, index), ...items.slice(index + 1)]; + for (const tail of permutations(rest)) { + result.push([item, ...tail]); + } + }); + return result; +} + +describe('normalizeCellReading — pinned key mappings', () => { + test('the real NR key is `sinr`', () => { + expect(read({ sinr: 12 }).sinr).toBe(12); + }); + + test('a `snr`-carrying dict is IGNORED — sinr stays undefined', () => { + const reading = read({ snr: 9, rsrp: -95 }); + expect(reading.sinr).toBeUndefined(); + expect(reading.rsrp).toBe(-95); + }); + + test('`sinr` wins even when `snr` is also present (wrong key never leaks in)', () => { + expect(read({ snr: 9, sinr: 12 }).sinr).toBe(12); + }); + + test('`physical-ci` maps to `pci`; rsrp/rsrq pass through', () => { + const reading = read({ 'physical-ci': 42, rsrp: -95, rsrq: -10 }); + expect(reading.pci).toBe(42); + expect(reading.rsrp).toBe(-95); + expect(reading.rsrq).toBe(-10); + }); + + test('`band` is surfaced ONLY when directly supplied', () => { + expect(read({ rsrp: -90 }).band).toBeUndefined(); + expect(read({ rsrp: -90, band: 'n78' }).band).toBe('n78'); + }); + + test('every reading carries `source` + `observedAt` provenance', () => { + const reading = read({ 'cell-id': 'A', rsrp: -80 }); + expect(reading.source).toBe(PROVENANCE.source); + expect(reading.observedAt).toBe(PROVENANCE.observedAt); + }); + + test('serving flag reads a `serving` bool OR a serving `cell-type`', () => { + expect(read({ serving: true }).serving).toBe(true); + expect(read({ 'cell-type': 'lte-serving' }).serving).toBe(true); + expect(read({ 'cell-type': 'lte-neighbor' }).serving).toBe(false); + }); +}); + +describe('selectServingCell — TOTAL order', () => { + test('a serving-marked cell wins even with a LOWER rsrp', () => { + const marked = read({ 'cell-id': 'A', serving: true, rsrp: -110 }); + const strong = read({ 'cell-id': 'B', rsrp: -70 }); + expect(selectServingCell([strong, marked])?.cellId).toBe('A'); + }); + + test('with no serving mark, the HIGHEST rsrp wins', () => { + const cells = [ + read({ 'cell-id': 'A', rsrp: -95 }), + read({ 'cell-id': 'B', rsrp: -70 }), + read({ 'cell-id': 'C', rsrp: -110 }), + ]; + expect(selectServingCell(cells)?.cellId).toBe('B'); + }); + + test('cells lacking rsrp sort LAST', () => { + const withRsrp = read({ 'cell-id': 'A', rsrp: -120 }); + const noRsrp = read({ 'cell-id': 'B' }); + expect(selectServingCell([noRsrp, withRsrp])?.cellId).toBe('A'); + }); + + test('an rsrp tie breaks lexicographically by cell-id', () => { + const cells = [ + read({ 'cell-id': 'zeta', rsrp: -80 }), + read({ 'cell-id': 'alpha', rsrp: -80 }), + read({ 'cell-id': 'mike', rsrp: -80 }), + ]; + expect(selectServingCell(cells)?.cellId).toBe('alpha'); + }); + + test('empty input has no serving cell', () => { + expect(selectServingCell([])).toBeUndefined(); + }); +}); + +describe('selectServingCell — permutation invariance (MANDATORY)', () => { + test('every permutation of a mixed cell set picks the SAME serving cell', () => { + const cells = [ + read({ 'cell-id': 'A', rsrp: -95, 'physical-ci': 1 }), + read({ 'cell-id': 'B', serving: true, rsrp: -108, 'physical-ci': 2 }), + read({ 'cell-id': 'C', rsrp: -70, 'physical-ci': 3 }), + read({ 'cell-id': 'D', 'physical-ci': 4 }), + read({ 'cell-id': 'E', rsrp: -88, 'physical-ci': 5 }), + ]; + const perms = permutations(cells); + expect(perms.length).toBe(120); + // The serving-marked cell B wins regardless of order. + for (const perm of perms) { + expect(selectServingCell(perm)?.cellId).toBe('B'); + } + }); + + test('without a serving mark, the highest-rsrp winner is permutation-invariant', () => { + const cells = [ + read({ 'cell-id': 'A', rsrp: -95 }), + read({ 'cell-id': 'B', rsrp: -70 }), + read({ 'cell-id': 'C', rsrp: -110 }), + read({ 'cell-id': 'D', rsrp: -70 }), + read({ 'cell-id': 'E' }), + ]; + // Tie at -70 between B and D → lexicographic → B. + for (const perm of permutations(cells)) { + expect(selectServingCell(perm)?.cellId).toBe('B'); + } + }); +}); diff --git a/control/src/backend/cell-info.ts b/control/src/backend/cell-info.ts new file mode 100644 index 0000000..a50709e --- /dev/null +++ b/control/src/backend/cell-info.ts @@ -0,0 +1,160 @@ +// Cell-info normalization — a decoded ModemManager cell dict → a stable reading. +// +// `Modem.GetCellInfo` returns `aa{sv}`: one `a{sv}` dict per visible cell. The keys +// vary by RAT and MM version, so this module pins EXACTLY the mappings the rest of +// the stack depends on and ignores everything else: +// +// - `physical-ci` → `pci` (the real MM key; never guessed from anything else) +// - `rsrp`, `rsrq` pass through as numbers +// - `sinr` the REAL NR SINR key. A dict carrying `snr` (the +// WRONG name) is IGNORED — `sinr` stays undefined. +// - `cell-id` the cell identifier (serving-cell tiebreak) +// - `band` surfaced ONLY when the source supplies it directly; +// never inferred from earfcn / frequency / anything. +// - `serving` / `cell-type` whether this is the serving cell. +// +// Every reading also carries `source` + `observedAt` provenance, so a consumer can +// tell a fresh reading from a cached one and know where it came from. Pure — no I/O. + +import type { EpochMillis } from '../domain'; +import type { DecodedProps } from './managed-objects'; +import { numberProp, stringProp } from './managed-objects'; + +/** Where a batch of cell readings came from, and when it was observed. */ +export interface CellInfoProvenance { + /** A human/source tag, e.g. the modem path or `'Modem.GetCellInfo'`. */ + readonly source: string; + readonly observedAt: EpochMillis; +} + +/** One normalized cell reading — only the pinned fields, plus provenance. */ +export interface CellReading { + /** `true` when this dict is marked as the serving cell. */ + readonly serving: boolean; + /** The cell identifier, when supplied (serving-cell tiebreak key). */ + readonly cellId?: string; + /** Physical cell id — from the `physical-ci` key ONLY. */ + readonly pci?: number; + readonly rsrp?: number; + readonly rsrq?: number; + /** NR SINR — from the `sinr` key ONLY (a `snr`-keyed dict is ignored). */ + readonly sinr?: number; + /** Radio band — present ONLY when the source supplied it directly. */ + readonly band?: string; + readonly source: string; + readonly observedAt: EpochMillis; +} + +/** Read a `serving` flag: an explicit `serving` bool, else a `cell-type` naming it. */ +function readServing(cell: DecodedProps): boolean { + const flag = cell.find(([key]) => key === 'serving')?.[1]?.value; + if (typeof flag === 'boolean') { + return flag; + } + const cellType = stringProp(cell, 'cell-type'); + return cellType?.toLowerCase().includes('serving') ?? false; +} + +/** Normalize ONE cell's `a{sv}` dict into a `CellReading`, pinning the known keys. */ +export function normalizeCellReading( + cell: DecodedProps, + provenance: CellInfoProvenance, +): CellReading { + const cellId = stringProp(cell, 'cell-id'); + const pci = numberProp(cell, 'physical-ci'); + const rsrp = numberProp(cell, 'rsrp'); + const rsrq = numberProp(cell, 'rsrq'); + // `sinr` ONLY — a dict carrying `snr` must never populate this field. + const sinr = numberProp(cell, 'sinr'); + // `band` ONLY when directly supplied; never inferred. + const band = stringProp(cell, 'band'); + return { + serving: readServing(cell), + ...(cellId !== undefined ? { cellId } : {}), + ...(pci !== undefined ? { pci } : {}), + ...(rsrp !== undefined ? { rsrp } : {}), + ...(rsrq !== undefined ? { rsrq } : {}), + ...(sinr !== undefined ? { sinr } : {}), + ...(band !== undefined ? { band } : {}), + source: provenance.source, + observedAt: provenance.observedAt, + }; +} + +/** Normalize a whole `GetCellInfo` reply (`aa{sv}` → cell dicts) into readings. */ +export function normalizeCellInfo( + cells: readonly DecodedProps[], + provenance: CellInfoProvenance, +): readonly CellReading[] { + return cells.map((cell) => normalizeCellReading(cell, provenance)); +} + +/** + * Serving-cell TOTAL order — a strict, permutation-invariant ranking of readings. + * Returns < 0 when `a` outranks `b` (should sort first / is the better serving cell): + * + * 1. a `serving`-marked cell outranks a non-serving one; + * 2. then the HIGHER `rsrp` outranks the lower — a cell with NO `rsrp` sorts LAST; + * 3. ties break lexicographically by `cell-id` (a cell with none sorts last); + * 4. final deterministic tiebreaks (`pci`, then a stable serialization) guarantee + * the order is total even for otherwise-identical distinct cells, so the winner + * never depends on input order. + */ +export function compareServing(a: CellReading, b: CellReading): number { + if (a.serving !== b.serving) { + return a.serving ? -1 : 1; + } + const rsrpRank = compareOptionalDesc(a.rsrp, b.rsrp); + if (rsrpRank !== 0) { + return rsrpRank; + } + const cellRank = compareOptionalAsc(a.cellId, b.cellId); + if (cellRank !== 0) { + return cellRank; + } + const pciRank = compareOptionalAsc(a.pci, b.pci); + if (pciRank !== 0) { + return pciRank; + } + return stableTag(a) < stableTag(b) ? -1 : stableTag(a) > stableTag(b) ? 1 : 0; +} + +/** Higher value first; `undefined` last. */ +function compareOptionalDesc(a: number | undefined, b: number | undefined): number { + if (a === b) return 0; + if (a === undefined) return 1; + if (b === undefined) return -1; + return b - a; +} + +/** Lower value first; `undefined` last. Works for numbers and strings. */ +function compareOptionalAsc(a: T | undefined, b: T | undefined): number { + if (a === b) return 0; + if (a === undefined) return 1; + if (b === undefined) return -1; + return a < b ? -1 : 1; +} + +/** A stable, order-independent fingerprint used only as the final total-order tiebreak. */ +function stableTag(reading: CellReading): string { + return JSON.stringify([ + reading.serving, + reading.cellId ?? null, + reading.pci ?? null, + reading.rsrp ?? null, + reading.rsrq ?? null, + reading.sinr ?? null, + reading.band ?? null, + ]); +} + +/** + * Select the serving cell under the total order above. Permutation-invariant: the + * same set of readings always yields the same winner regardless of their order. + */ +export function selectServingCell(cells: readonly CellReading[]): CellReading | undefined { + if (cells.length === 0) { + return undefined; + } + return cells.reduce((best, cell) => (compareServing(cell, best) < 0 ? cell : best)); +} diff --git a/control/src/backend/constants.ts b/control/src/backend/constants.ts new file mode 100644 index 0000000..b09729f --- /dev/null +++ b/control/src/backend/constants.ts @@ -0,0 +1,35 @@ +// Well-known D-Bus names the ModemManager observer talks to. +// +// These mirror the real ModemManager bus topology. They are duplicated here (rather +// than imported from the A2.3 test fake) on purpose: `control/test-support/` is not +// published in the npm package, so `src` must never depend on it — the observer that +// SHIPS owns its own copy of the constants it needs. + +/** The well-known bus name ModemManager owns. */ +export const MM_BUS_NAME = 'org.freedesktop.ModemManager1'; + +/** The root ObjectManager object path. */ +export const MM_ROOT_PATH = '/org/freedesktop/ModemManager1'; + +/** `org.freedesktop.DBus.ObjectManager` — `GetManagedObjects`, `InterfacesAdded/Removed`. */ +export const OBJECT_MANAGER_IFACE = 'org.freedesktop.DBus.ObjectManager'; + +/** `org.freedesktop.DBus.Properties` — `PropertiesChanged`. */ +export const PROPERTIES_IFACE = 'org.freedesktop.DBus.Properties'; + +/** The root manager interface — `InhibitDevice`, `ScanDevices`, `Version`. */ +export const MM_MANAGER_IFACE = 'org.freedesktop.ModemManager1'; + +/** The core `Modem` interface. */ +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'; + +/** A SIM object's `Sim` interface (SIMs are separate `/SIM/` objects). */ +export const SIM_IFACE = 'org.freedesktop.ModemManager1.Sim'; + +/** The bus daemon itself — `NameOwnerChanged`, `GetNameOwner`. */ +export const DBUS_IFACE = 'org.freedesktop.DBus'; +export const DBUS_PATH = '/org/freedesktop/DBus'; +export const DBUS_DESTINATION = 'org.freedesktop.DBus'; diff --git a/control/src/backend/device-classifier.test.ts b/control/src/backend/device-classifier.test.ts new file mode 100644 index 0000000..377d039 --- /dev/null +++ b/control/src/backend/device-classifier.test.ts @@ -0,0 +1,168 @@ +// The classifier's contract, one fixture per class — plus the HONESTY guarantee: an +// ambiguous descriptor set returns `unmanaged` with a truthful reason, never a +// 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'; + +/** A Quectel-style QMI composition: a `qmi_wwan` control port (+ AT serials). */ +const QMI: UsbDeviceSnapshot = { + vendorId: '2c7c', + productId: '0125', + model: 'EG25-G', + bDeviceClass: 0, + interfaces: [ + { interfaceClass: 0xff, interfaceSubClass: 0xff, interfaceProtocol: 0xff, driver: 'option' }, + { interfaceClass: 0xff, interfaceSubClass: 0xff, interfaceProtocol: 0xff, driver: 'qmi_wwan' }, + ], +}; + +/** An MBIM composition: the standard 0x02/0x0e control + 0x0a data pair. */ +const MBIM: UsbDeviceSnapshot = { + vendorId: '1199', + productId: '9071', + model: 'Sierra EM-series', + bDeviceClass: 0, + interfaces: [ + { interfaceClass: 0x02, interfaceSubClass: 0x0e, interfaceProtocol: 0x00, driver: 'cdc_mbim' }, + { interfaceClass: 0x0a, interfaceSubClass: 0x00, interfaceProtocol: 0x02, driver: 'cdc_mbim' }, + ], +}; + +/** A Huawei HiLink personality: CDC-ECM Ethernet, no control port — firmware, not switchable. */ +const HILINK_ECM: UsbDeviceSnapshot = { + vendorId: '12d1', + productId: '14db', + model: 'HUAWEI HiLink', + bDeviceClass: 0x02, + interfaces: [ + { interfaceClass: 0x02, interfaceSubClass: 0x06, interfaceProtocol: 0x00, driver: 'cdc_ether' }, + { interfaceClass: 0x0a, interfaceSubClass: 0x00, interfaceProtocol: 0x00, driver: 'cdc_ether' }, + ], +}; + +/** An RNDIS tether: the wireless-RNDIS descriptor, no control port. */ +const RNDIS: UsbDeviceSnapshot = { + vendorId: '19d2', + productId: '0601', + model: 'RNDIS tether', + bDeviceClass: 0x00, + interfaces: [ + { + interfaceClass: 0xe0, + interfaceSubClass: 0x01, + interfaceProtocol: 0x03, + driver: 'rndis_host', + }, + { + interfaceClass: 0x0a, + interfaceSubClass: 0x00, + interfaceProtocol: 0x00, + driver: 'rndis_host', + }, + ], +}; + +/** A modem in mass-storage installer mode, flagged for usb_modeswitch. */ +const STORAGE_PRE_MODESWITCH: UsbDeviceSnapshot = { + vendorId: '12d1', + productId: '1f01', + model: 'HUAWEI Mobile', + bDeviceClass: 0x00, + interfaces: [ + { + interfaceClass: 0x08, + interfaceSubClass: 0x06, + interfaceProtocol: 0x50, + driver: 'usb-storage', + }, + ], + udevProperties: { ID_USB_MODESWITCH: '1' }, +}; + +/** A composite that matches NO known modem pattern — vendor + HID, no driver. */ +const AMBIGUOUS: UsbDeviceSnapshot = { + vendorId: '1234', + productId: '5678', + model: 'Unknown Composite', + bDeviceClass: 0x00, + interfaces: [ + { interfaceClass: 0x03, interfaceSubClass: 0x00, interfaceProtocol: 0x00 }, + { interfaceClass: 0xff, interfaceSubClass: 0x42, interfaceProtocol: 0x01 }, + ], +}; + +describe('classifyDevice — the six canonical fixtures', () => { + test('QMI composition → mm-managed', () => { + expect(classifyDevice(QMI).deviceClass).toBe('mm-managed'); + expect(detectUsbMode(QMI)).toBe('qmi'); + }); + + test('MBIM composition → mm-managed', () => { + const result = classifyDevice(MBIM); + expect(result.deviceClass).toBe('mm-managed'); + expect(result.reason).toContain('MBIM'); + expect(detectUsbMode(MBIM)).toBe('mbim'); + }); + + test('Huawei HiLink-ECM (firmware personality) → router-mode', () => { + const result = classifyDevice(HILINK_ECM); + expect(result.deviceClass).toBe('router-mode'); + expect(result.reason).toContain('ECM'); + expect(detectUsbMode(HILINK_ECM)).toBe('router-ethernet'); + }); + + test('RNDIS tether → router-mode', () => { + const result = classifyDevice(RNDIS); + expect(result.deviceClass).toBe('router-mode'); + expect(result.reason).toContain('RNDIS'); + expect(detectUsbMode(RNDIS)).toBe('rndis'); + }); + + test('mass-storage before usb_modeswitch → pending-modeswitch (a distinct state)', () => { + const result = classifyDevice(STORAGE_PRE_MODESWITCH); + expect(result.deviceClass).toBe('pending-modeswitch'); + expect(result.reason).toContain('usb_modeswitch'); + }); + + test('ambiguous composite → unmanaged, with an HONEST reason (never guessed)', () => { + const result = classifyDevice(AMBIGUOUS); + expect(result.deviceClass).toBe('unmanaged'); + expect(result.reason).toContain('vendor-specific'); + // Never a confident modem/router class. + expect(result.deviceClass).not.toBe('mm-managed'); + expect(result.deviceClass).not.toBe('router-mode'); + }); +}); + +describe('classifyDevice — honesty guards', () => { + test('mass storage WITHOUT a modeswitch trigger is unmanaged, not pending-modeswitch', () => { + const plainDisk: UsbDeviceSnapshot = { + vendorId: '0781', + productId: '5567', + model: 'SanDisk Cruzer', + bDeviceClass: 0x00, + interfaces: [ + { + interfaceClass: 0x08, + interfaceSubClass: 0x06, + interfaceProtocol: 0x50, + driver: 'usb-storage', + }, + ], + }; + const result = classifyDevice(plainDisk); + expect(result.deviceClass).toBe('unmanaged'); + expect(result.reason).toContain('usb_modeswitch'); + }); + + test('a bare vendor interface with no driver is NOT assumed to be a QMI modem', () => { + const bareVendor: UsbDeviceSnapshot = { + vendorId: '2357', + productId: '0001', + bDeviceClass: 0x00, + interfaces: [{ interfaceClass: 0xff, interfaceSubClass: 0x00, interfaceProtocol: 0x00 }], + }; + expect(classifyDevice(bareVendor).deviceClass).toBe('unmanaged'); + }); +}); diff --git a/control/src/backend/device-classifier.ts b/control/src/backend/device-classifier.ts new file mode 100644 index 0000000..333a7c3 --- /dev/null +++ b/control/src/backend/device-classifier.ts @@ -0,0 +1,240 @@ +// Device classification — deciding, from a udev/sysfs snapshot alone, whether a USB +// device is a ModemManager-manageable modem, a router-ethernet uplink MM cannot +// control, a modem still in mass-storage installer mode, or something we simply do +// not recognize. +// +// THE HONESTY RULE (draft §gap sweep, matrix §1): the classifier NEVER guesses. An +// ambiguous descriptor set returns `unmanaged` with a truthful reason, not a +// confident-sounding wrong class. A control-port presence — not a vendor id — decides +// `mm-managed`; a bare vendor-specific interface with no recognized driver is NOT a +// modem. `pending-modeswitch` is a DISTINCT state (a modem installer awaiting +// `usb_modeswitch`), never conflated with `unmanaged`. + +import type { CanonicalUsbMode, ExpectedDescriptors } from '../usb-mode'; + +/** One USB interface's descriptor bytes plus its bound kernel driver, if any. */ +export interface UsbInterface { + readonly interfaceClass: number; + readonly interfaceSubClass: number; + readonly interfaceProtocol: number; + /** The bound kernel driver (`qmi_wwan`, `cdc_mbim`, `option`, `cdc_ether`, …). */ + readonly driver?: string; +} + +/** A single USB device as observed from udev/sysfs — the classifier's whole input. */ +export interface UsbDeviceSnapshot { + readonly vendorId: string; + readonly productId: string; + readonly model?: string; + readonly firmwareRevision?: string; + /** The device-descriptor `bDeviceClass` byte (0 ⇒ class is per-interface). */ + readonly bDeviceClass: number; + readonly interfaces: readonly UsbInterface[]; + /** Stable physical-topology UID (udev `ID_PATH` / physdev) — survives a mode change. */ + readonly physicalUid?: string; + /** The bound network interface name, if the device presents one (`wwan0`, `usb0`). */ + readonly ifname?: string; + /** Raw udev properties (`ID_USB_MODESWITCH`, `ID_MM_CANDIDATE`, …). */ + readonly udevProperties?: Readonly>; +} + +/** The four device classes. `pending-modeswitch` is distinct from `unmanaged`. */ +export type DeviceClass = 'mm-managed' | 'router-mode' | 'unmanaged' | 'pending-modeswitch'; + +/** A classification plus a human-readable reason (always populated). */ +export interface DeviceClassification { + readonly deviceClass: DeviceClass; + readonly reason: string; +} + +// USB-IF class / subclass / protocol codes used below. +const CLASS_COMM = 0x02; // CDC communications (control interface) +const CLASS_MASS_STORAGE = 0x08; +const CLASS_WIRELESS = 0xe0; // wireless controller (RNDIS lives here) +const CLASS_VENDOR = 0xff; +const SUB_ACM = 0x02; // abstract control model (AT commands) +const SUB_ECM = 0x06; +const SUB_NCM = 0x0d; +const SUB_MBIM = 0x0e; +const SUB_RNDIS_WIRELESS = 0x01; +const PROTO_VENDOR = 0xff; +const PROTO_RNDIS = 0x03; + +const QMI_DRIVERS: ReadonlySet = new Set(['qmi_wwan']); +const AT_DRIVERS: ReadonlySet = new Set(['option', 'qcserial', 'cdc_acm']); +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']); + +function isMbimControl(i: UsbInterface): boolean { + return i.interfaceClass === CLASS_COMM && i.interfaceSubClass === SUB_MBIM; +} + +function isQmiControl(i: UsbInterface): boolean { + return i.interfaceClass === CLASS_VENDOR && i.driver !== undefined && QMI_DRIVERS.has(i.driver); +} + +function isAtControl(i: UsbInterface): boolean { + // Standard CDC-ACM AT port (protocol 0xff on 0x02/0x02 is RNDIS, not AT). + if ( + i.interfaceClass === CLASS_COMM && + i.interfaceSubClass === SUB_ACM && + i.interfaceProtocol !== PROTO_VENDOR + ) { + return true; + } + // Vendor-specific serial port bound to a known AT driver. + return i.interfaceClass === CLASS_VENDOR && i.driver !== undefined && AT_DRIVERS.has(i.driver); +} + +function isRndis(i: UsbInterface): boolean { + if ( + i.interfaceClass === CLASS_WIRELESS && + i.interfaceSubClass === SUB_RNDIS_WIRELESS && + i.interfaceProtocol === PROTO_RNDIS + ) { + return true; + } + if ( + i.interfaceClass === CLASS_COMM && + i.interfaceSubClass === SUB_ACM && + i.interfaceProtocol === PROTO_VENDOR + ) { + return true; + } + return i.driver !== undefined && RNDIS_DRIVERS.has(i.driver); +} + +function isEcmNcmData(i: UsbInterface): boolean { + if ( + i.interfaceClass === CLASS_COMM && + (i.interfaceSubClass === SUB_ECM || i.interfaceSubClass === SUB_NCM) + ) { + return true; + } + return i.driver !== undefined && ECM_NCM_DRIVERS.has(i.driver); +} + +function isMassStorage(i: UsbInterface): boolean { + if (i.interfaceClass === CLASS_MASS_STORAGE) { + return true; + } + return i.driver !== undefined && STORAGE_DRIVERS.has(i.driver); +} + +function isVendorSpecific(i: UsbInterface): boolean { + return i.interfaceClass === CLASS_VENDOR; +} + +function controlKind(i: UsbInterface): string { + if (isMbimControl(i)) { + return 'MBIM'; + } + return isQmiControl(i) ? 'QMI' : 'AT'; +} + +/** Whether a mass-storage device is a modem awaiting `usb_modeswitch` (not a plain disk). */ +function isModeswitchCandidate(snapshot: UsbDeviceSnapshot): boolean { + const flag = snapshot.udevProperties?.ID_USB_MODESWITCH; + if (flag !== undefined && flag !== '' && flag !== '0') { + return true; + } + const model = (snapshot.model ?? '').toLowerCase(); + return ['cd-rom', 'installer', 'zerocd', 'autoinstall'].some((marker) => model.includes(marker)); +} + +function unmanagedReason(snapshot: UsbDeviceSnapshot, hasStorage: boolean): string { + if (hasStorage) { + return 'mass-storage device with no usb_modeswitch trigger — not a recognized modem installer'; + } + if (snapshot.interfaces.some(isVendorSpecific)) { + return 'vendor-specific interface(s) with no recognized modem driver — cannot confidently classify'; + } + return 'no recognized modem control port, Ethernet tether, or installer interface'; +} + +/** + * Classify one USB device from its udev/sysfs snapshot. Precedence: + * 1. a recognized MM control port (MBIM / QMI / AT) ⇒ `mm-managed`; + * 2. a network tether with NO control port (ECM / NCM / RNDIS) ⇒ `router-mode`; + * 3. mass storage with a `usb_modeswitch` trigger ⇒ `pending-modeswitch`; + * 4. anything else ⇒ `unmanaged`, with an honest reason — NEVER a guessed class. + */ +export function classifyDevice(snapshot: UsbDeviceSnapshot): DeviceClassification { + const ifaces = snapshot.interfaces; + + const control = ifaces.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 = ifaces.find((i) => isRndis(i) || isEcmNcmData(i)); + if (tether !== undefined) { + const kind = isRndis(tether) ? 'RNDIS' : 'ECM/NCM'; + return { + deviceClass: 'router-mode', + reason: `${kind} Ethernet tether with no modem control port — router-ethernet class, not MM-manageable`, + }; + } + + const hasStorage = ifaces.some(isMassStorage) || snapshot.bDeviceClass === CLASS_MASS_STORAGE; + if (hasStorage && isModeswitchCandidate(snapshot)) { + return { + deviceClass: 'pending-modeswitch', + reason: 'mass-storage installer mode with a usb_modeswitch trigger — awaiting mode switch', + }; + } + + return { deviceClass: 'unmanaged', reason: unmanagedReason(snapshot, hasStorage) }; +} + +/** + * Derive the USB composition MODE a device is currently in (for a transition's + * postcondition). Distinct from `classifyDevice`: this reads the data-plane + * composition, MBIM and RNDIS being unambiguous descriptor signatures, an ECM/NCM + * data interface being `ecm-ncm` only when a control/vendor port accompanies it + * (else it is a dumb `router-ethernet` tether), and a bare vendor interface being + * `qmi`. Returns `undefined` when nothing recognizable is present. + */ +export function detectUsbMode(snapshot: UsbDeviceSnapshot): CanonicalUsbMode | undefined { + const ifaces = snapshot.interfaces; + if (ifaces.some(isMbimControl)) { + return 'mbim'; + } + if (ifaces.some(isRndis)) { + return 'rndis'; + } + if (ifaces.some(isEcmNcmData)) { + const hasControl = ifaces.some((i) => isAtControl(i) || isVendorSpecific(i)); + return hasControl ? 'ecm-ncm' : 'router-ethernet'; + } + if (ifaces.some((i) => isQmiControl(i) || isVendorSpecific(i))) { + return 'qmi'; + } + return undefined; +} + +/** + * Whether a device presents (at least) the descriptors a catalog transition expects + * after re-enumeration — the descriptor half of the postcondition. Every expected + * interface triple must be present and `bDeviceClass` must match. + */ +export function descriptorsMatch( + snapshot: UsbDeviceSnapshot, + expected: ExpectedDescriptors, +): boolean { + if (snapshot.bDeviceClass !== expected.deviceClass) { + return false; + } + return expected.interfaces.every((exp) => + snapshot.interfaces.some( + (i) => + i.interfaceClass === exp.interfaceClass && + i.interfaceSubClass === exp.interfaceSubClass && + i.interfaceProtocol === exp.interfaceProtocol, + ), + ); +} diff --git a/control/src/backend/enrichment.ts b/control/src/backend/enrichment.ts new file mode 100644 index 0000000..1e0f722 --- /dev/null +++ b/control/src/backend/enrichment.ts @@ -0,0 +1,96 @@ +// Read-only status enrichment — firmware revision, eSIM facts, signal cadence, +// and normalized cell info, assembled for one modem's status surface. +// +// These are additive, read-only details layered on top of the lifecycle snapshot +// (A3.1) — they never gate anything and never fail a backend start. `Modem.Revision` +// and the eSIM `SimType` / `EsimStatus` (MM 1.20+) are read straight from the decoded +// `GetManagedObjects` tree; `signalCadence` comes from the Signal.Setup manager; cell +// info is normalized from a `GetCellInfo` reply by `./cell-info`. + +import type { CellReading } from './cell-info'; +import { selectServingCell } from './cell-info'; +import { MODEM_IFACE, SIM_IFACE } from './constants'; +import type { DecodedManagedObjects } from './managed-objects'; +import { findInterface, followObjectPath, numberProp, stringProp } from './managed-objects'; +import type { SignalCadence } from './signal-setup'; + +/** eSIM provisioning type — `Sim.SimType` (MMSimType), 1.20+. */ +export type SimType = 'physical' | 'esim' | 'unknown'; + +/** eSIM profile status — `Sim.EsimStatus` (MMSimEsimStatus), 1.20+. */ +export type EsimStatus = 'no-profiles' | 'with-profiles' | 'unknown'; + +/** The eSIM facts of a modem's active SIM. */ +export interface EsimInfo { + readonly simType: SimType; + readonly esimStatus: EsimStatus; +} + +/** The full read-only enrichment surfaced beside a modem's lifecycle snapshot. */ +export interface ModemEnrichment { + /** `Modem.Revision` — firmware/hardware revision string, when present. */ + readonly revision?: string; + readonly esim: EsimInfo; + readonly signalCadence: SignalCadence; + readonly cellInfo: readonly CellReading[]; + /** The selected serving cell (total-order winner), when any cell is present. */ + readonly servingCell?: CellReading; +} + +/** MMSimType (`Sim.SimType`) → the domain `SimType`. */ +function mapSimType(value: number | undefined): SimType { + switch (value) { + case 1: + return 'physical'; + case 2: + return 'esim'; + default: + return 'unknown'; + } +} + +/** MMSimEsimStatus (`Sim.EsimStatus`) → the domain `EsimStatus`. */ +function mapEsimStatus(value: number | undefined): EsimStatus { + switch (value) { + case 1: + return 'no-profiles'; + case 2: + return 'with-profiles'; + default: + return 'unknown'; + } +} + +/** Read `Modem.Revision`, when present. */ +export function readRevision(tree: DecodedManagedObjects, modemPath: string): string | undefined { + const revision = stringProp(findInterface(tree, modemPath, MODEM_IFACE), 'Revision'); + return revision !== undefined && revision.length > 0 ? revision : undefined; +} + +/** Read the eSIM `SimType` / `EsimStatus` off the modem's active SIM object. */ +export function readEsimInfo(tree: DecodedManagedObjects, modemPath: string): EsimInfo { + const modem = findInterface(tree, modemPath, MODEM_IFACE); + const sim = followObjectPath(tree, modem, 'Sim', SIM_IFACE); + return { + simType: mapSimType(numberProp(sim, 'SimType')), + esimStatus: mapEsimStatus(numberProp(sim, 'EsimStatus')), + }; +} + +/** Assemble the full enrichment for one modem from its tree, cadence, and cell info. */ +export function buildEnrichment( + tree: DecodedManagedObjects, + modemPath: string, + signalCadence: SignalCadence, + cellInfo: readonly CellReading[], +): ModemEnrichment { + const revision = readRevision(tree, modemPath); + const serving = selectServingCell(cellInfo); + return { + ...(revision !== undefined ? { revision } : {}), + esim: readEsimInfo(tree, modemPath), + signalCadence, + cellInfo, + ...(serving !== undefined ? { servingCell: serving } : {}), + }; +} diff --git a/control/src/backend/features.test.ts b/control/src/backend/features.test.ts new file mode 100644 index 0000000..12c0f7d --- /dev/null +++ b/control/src/backend/features.test.ts @@ -0,0 +1,158 @@ +import { describe, expect, test } from 'bun:test'; +import { + detectMmFeatures, + type MmPropertyProbe, + parseMmVersion, + probeModemProperties, +} from './features'; +import type { DecodedManagedObjects } from './managed-objects'; + +const BASE_MODEM_PROPS = new Set(['Manufacturer', 'Model', 'Device', 'State', 'SimType']); + +function probe(overrides: Partial = {}): MmPropertyProbe { + return { properties: BASE_MODEM_PROPS, ...overrides }; +} + +function withPhysdev(): MmPropertyProbe { + return { properties: new Set([...BASE_MODEM_PROPS, 'Physdev']) }; +} + +describe('parseMmVersion', () => { + test('parses major.minor.patch', () => { + expect(parseMmVersion('1.24.0')).toEqual({ major: 1, minor: 24 }); + }); + + test('parses major.minor without a patch', () => { + expect(parseMmVersion('1.20')).toEqual({ major: 1, minor: 20 }); + }); + + test('tolerates a pre-release suffix', () => { + expect(parseMmVersion('1.26.0-rc1')).toEqual({ major: 1, minor: 26 }); + }); + + test('returns null for an unparseable string', () => { + expect(parseMmVersion('not-a-version')).toBeNull(); + }); +}); + +describe('detectMmFeatures — 3 property shapes', () => { + test('1.20 shape: no Physdev, basic cell info, eSIM, serialization', () => { + expect(detectMmFeatures('1.20.0', probe())).toEqual({ + physdev: false, + cellInfo: 'basic', + esimStatus: true, + opSerialization: true, + }); + }); + + test('1.22 shape: Physdev present, rich cell info', () => { + expect(detectMmFeatures('1.22.0', withPhysdev())).toEqual({ + physdev: true, + cellInfo: 'rich', + esimStatus: true, + opSerialization: true, + }); + }); + + test('1.24 shape: Physdev present, rich cell info', () => { + expect(detectMmFeatures('1.24.0', withPhysdev())).toEqual({ + physdev: true, + cellInfo: 'rich', + esimStatus: true, + opSerialization: true, + }); + }); +}); + +describe('detectMmFeatures — property presence overrides the version', () => { + test('physdev is property-driven: 1.24 version but no Physdev property ⇒ false', () => { + expect(detectMmFeatures('1.24.0', probe()).physdev).toBe(false); + }); + + test('a 1.22+ modem that omits Physdev still reports rich cell info by version', () => { + expect(detectMmFeatures('1.22.0', probe()).cellInfo).toBe('rich'); + }); + + test('rich cell-info fields promote the tier even without a version signal', () => { + const withFields = probe({ cellInfoFields: new Set(['serving-type', 'bandwidth']) }); + expect(detectMmFeatures('1.20.0', withFields).cellInfo).toBe('rich'); + }); +}); + +describe('detectMmFeatures — degrade paths', () => { + test('below 1.20 has no cell info, no eSIM, no serialization', () => { + expect(detectMmFeatures('1.18.0', probe({ properties: new Set(['Device']) }))).toEqual({ + physdev: false, + cellInfo: 'none', + esimStatus: false, + opSerialization: false, + }); + }); + + test('a modem advertising no cell-info capability degrades to none on a supported version', () => { + expect(detectMmFeatures('1.24.0', withPhysdevNoCellInfo()).cellInfo).toBe('none'); + }); + + test('an eSIM property forces esimStatus true even on an unparseable version', () => { + const esimProbe = probe({ properties: new Set(['EsimStatus']) }); + expect(detectMmFeatures('garbage', esimProbe).esimStatus).toBe(true); + }); +}); + +describe('detectMmFeatures — unknown future version probes, never throws', () => { + test('1.26.0 falls back to property probing, not a whitelist throw', () => { + expect(() => detectMmFeatures('1.26.0', withPhysdev())).not.toThrow(); + expect(detectMmFeatures('1.26.0', withPhysdev())).toEqual({ + physdev: true, + cellInfo: 'rich', + esimStatus: true, + opSerialization: true, + }); + }); + + test('a totally unparseable version degrades to the safe floor (property-only physdev)', () => { + const physdevOnly = { properties: new Set(['Device', 'Physdev']) }; + expect(detectMmFeatures('???', physdevOnly)).toEqual({ + physdev: true, + cellInfo: 'none', + esimStatus: false, + opSerialization: false, + }); + }); +}); + +function withPhysdevNoCellInfo(): MmPropertyProbe { + return { properties: new Set([...BASE_MODEM_PROPS, 'Physdev']), cellInfoAvailable: false }; +} + +describe('probeModemProperties', () => { + test('collects property names across Modem, Modem3gpp and the active SIM', () => { + const tree: DecodedManagedObjects = [ + [ + '/org/freedesktop/ModemManager1/Modem/0', + [ + [ + 'org.freedesktop.ModemManager1.Modem', + [ + ['Device', { signature: 's', value: 'slot-usb2-1' }], + ['Physdev', { signature: 's', value: '/sys/devices/usb2' }], + ['Sim', { signature: 'o', value: '/org/freedesktop/ModemManager1/SIM/0' }], + ], + ], + [ + 'org.freedesktop.ModemManager1.Modem.Modem3gpp', + [['Imei', { signature: 's', value: '490154203237518' }]], + ], + ], + ], + [ + '/org/freedesktop/ModemManager1/SIM/0', + [['org.freedesktop.ModemManager1.Sim', [['SimType', { signature: 'u', value: 2 }]]]], + ], + ]; + const result = probeModemProperties(tree, '/org/freedesktop/ModemManager1/Modem/0'); + expect(result.properties.has('Physdev')).toBe(true); + expect(result.properties.has('Imei')).toBe(true); + expect(result.properties.has('SimType')).toBe(true); + }); +}); diff --git a/control/src/backend/features.ts b/control/src/backend/features.ts new file mode 100644 index 0000000..b004199 --- /dev/null +++ b/control/src/backend/features.ts @@ -0,0 +1,179 @@ +// ModemManager feature detection — what a given MM daemon can actually do. +// +// A3.2 must run against MM 1.20 (the bookworm floor), 1.22, 1.24, and versions +// nobody has shipped yet. The plan is explicit that this is NOT a version-string +// whitelist: a version we have never seen must degrade gracefully by PROBING the +// observed property set, never throw. So detection combines two signals: +// +// - the daemon `Version` string (a floor: e.g. eSIM read model is 1.20+), and +// - the properties actually present on the observed modem (authoritative for +// `physdev` — the plan requires `physdev` be true ONLY when the `Physdev` +// property is really there, never merely inferred from a version number). +// +// Property facts pinned by the reviewed design (draft §Oracle round-2 #9 / round-4): +// - `Modem.Device` carries the udev slot UID on ALL versions. +// - `Modem.Physdev` is the physical topology path, 1.22+ ONLY. +// - there is NO `PhysdevUid` property anywhere — do not look for one. + +import { MODEM_IFACE, MODEM3GPP_IFACE, SIM_IFACE } from './constants'; +import { type DecodedManagedObjects, findInterface, followObjectPath } from './managed-objects'; + +/** The capabilities the D-Bus backend gates its behaviour on. */ +export interface MmFeatures { + /** `Modem.Physdev` present (physical topology path, 1.22+). Property-probed. */ + readonly physdev: boolean; + /** + * Cell-info tier: + * - `none` — below 1.20, or the modem advertises no cell-info capability. + * - `basic` — 1.20/1.21 `GetCellInfo`. + * - `rich` — 1.22+ (serving-type / bandwidth fields). + */ + readonly cellInfo: 'none' | 'basic' | 'rich'; + /** eSIM read model (`Sim.SimType` / `Sim.EsimStatus`), 1.20+. */ + readonly esimStatus: boolean; + /** Per-modem operation serialization is available (the shared disruptive actor). */ + readonly opSerialization: boolean; +} + +/** + * What A3.2 observed about ONE modem's property surface. + * + * `properties` is the union of property NAMES seen across the modem's interfaces + * (built by `probeModemProperties` from a decoded `GetManagedObjects` tree). The + * two optional fields let a caller feed richer cell-info evidence than the static + * object tree carries — MM does not advertise cell-info richness as a Modem + * property (it comes back from the `GetCellInfo` method), so a backend that has + * probed cell info can pass what it saw; absent that, detection falls back to the + * version + `Physdev` co-introduction signal. + */ +export interface MmPropertyProbe { + /** Property names present on the observed modem (Modem + Modem3gpp + active SIM). */ + readonly properties: ReadonlySet; + /** Explicit "modem advertises cell info": `false` forces `cellInfo: 'none'`. */ + readonly cellInfoAvailable?: boolean; + /** Cell-info field names seen in a probe; `serving-type`/`bandwidth` ⇒ `rich`. */ + readonly cellInfoFields?: ReadonlySet; +} + +/** A parsed `major.minor` MM version. `null` when the string is not parseable. */ +export interface MmVersion { + readonly major: number; + readonly minor: number; +} + +const VERSION_PATTERN = /^(\d+)\.(\d+)(?:\.\d+)*/; + +/** + * Parse an MM `Version` string (`1.20.0`, `1.24`, `1.26.0-rc1`, …) to `major.minor`. + * Returns `null` on anything unparseable — callers must degrade, never throw. + */ +export function parseMmVersion(version: string): MmVersion | null { + const match = VERSION_PATTERN.exec(version.trim()); + if (match === null) { + return null; + } + const major = Number(match[1]); + const minor = Number(match[2]); + if (!Number.isSafeInteger(major) || !Number.isSafeInteger(minor)) { + return null; + } + return { major, minor }; +} + +/** Whether `version` is at least `major.minor` (false when unparseable). */ +function atLeast(version: MmVersion | null, major: number, minor: number): boolean { + if (version === null) { + return false; + } + return version.major > major || (version.major === major && version.minor >= minor); +} + +/** MM baseline this stack supports — eSIM read model, `GetCellInfo`, Signal.Setup. */ +const MM_MIN = { major: 1, minor: 20 } as const; +/** Physdev + rich cell-info were co-introduced at 1.22. */ +const MM_RICH = { major: 1, minor: 22 } as const; + +function detectCellInfo( + probe: MmPropertyProbe, + supported: boolean, + modern: boolean, + physdev: boolean, +): MmFeatures['cellInfo'] { + // Version floor first: below 1.20 there is no GetCellInfo at all. + if (!supported) { + return 'none'; + } + // A modem that explicitly advertises no cell-info capability degrades to none, + // even on a version that would otherwise support it. + if (probe.cellInfoAvailable === false) { + return 'none'; + } + const fields = probe.cellInfoFields; + if (fields !== undefined && (fields.has('serving-type') || fields.has('bandwidth'))) { + return 'rich'; + } + // 1.22 co-introduced `Physdev` and the rich cell-info fields, so either signal + // promotes the tier — this is what makes an unseen future version (e.g. 1.26) + // resolve to `rich` by probing rather than by matching a whitelist. + if (modern || physdev) { + return 'rich'; + } + return 'basic'; +} + +/** + * Detect what an MM daemon can do from its version string AND the properties + * observed on a modem. Pure — no I/O, never throws (an unparseable version yields + * the safe floor: property-probed `physdev`, everything else off). + */ +export function detectMmFeatures(version: string, probe: MmPropertyProbe): MmFeatures { + const parsed = parseMmVersion(version); + const supported = atLeast(parsed, MM_MIN.major, MM_MIN.minor); + const modern = atLeast(parsed, MM_RICH.major, MM_RICH.minor); + + // `physdev` is property-authoritative: present iff the `Physdev` property is + // really in the observed set (never inferred from the version alone). + const physdev = probe.properties.has('Physdev'); + + const esimStatus = + probe.properties.has('SimType') || probe.properties.has('EsimStatus') || supported; + + const cellInfo = detectCellInfo(probe, supported, modern, physdev); + + return { physdev, cellInfo, esimStatus, opSerialization: supported }; +} + +/** + * Build an `MmPropertyProbe` for one modem from a decoded `GetManagedObjects` tree: + * the union of property names on its `Modem`, `Modem.Modem3gpp`, and active `Sim` + * interfaces. Cell-info fields are not carried by the static tree, so the returned + * probe leaves them unset (detection falls back to version + `Physdev`). + */ +export function probeModemProperties( + tree: DecodedManagedObjects, + modemPath: string, +): MmPropertyProbe { + const modem = findInterface(tree, modemPath, MODEM_IFACE); + const modem3gpp = findInterface(tree, modemPath, MODEM3GPP_IFACE); + const sim = followObjectPath(tree, modem, 'Sim', SIM_IFACE); + + const properties = new Set(); + for (const source of [modem, modem3gpp, sim]) { + if (source === undefined) { + continue; + } + for (const [name] of source) { + properties.add(name); + } + } + return { properties }; +} + +/** Convenience: detect features straight off a decoded tree for one modem. */ +export function detectModemFeatures( + version: string, + tree: DecodedManagedObjects, + modemPath: string, +): MmFeatures { + return detectMmFeatures(version, probeModemProperties(tree, modemPath)); +} diff --git a/control/src/backend/identity-ladder.test.ts b/control/src/backend/identity-ladder.test.ts new file mode 100644 index 0000000..cebe03a --- /dev/null +++ b/control/src/backend/identity-ladder.test.ts @@ -0,0 +1,117 @@ +import { describe, expect, test } from 'bun:test'; +import { + canBindPolicy, + imeiEquipmentId, + logicalSlotId, + NO_EQUIPMENT_ID, + PolicyBindingRefusedError, + policyBindingKey, +} from '../domain'; +import { + looksLikeSlotUid, + type ModemIdentityFacts, + resolveModemIdentities, + resolveModemIdentity, +} from './identity-ladder'; + +const IMEI_A = '490154203237518'; +const IMEI_B = '356938035643809'; +const PATH = '/org/freedesktop/ModemManager1/Modem/0'; + +function facts(overrides: Partial = {}): ModemIdentityFacts { + return { runtimePath: PATH, equipmentId: imeiEquipmentId(IMEI_A), ...overrides }; +} + +describe('looksLikeSlotUid', () => { + test('our slot-* naming is detected', () => { + expect(looksLikeSlotUid('slot-usb2-1')).toBe(true); + }); + + test('MM path-shaped default is not a slot UID', () => { + expect(looksLikeSlotUid('/sys/devices/pci0000:00/usb2')).toBe(false); + }); + + test('the bare prefix alone is not a slot UID', () => { + expect(looksLikeSlotUid('slot-')).toBe(false); + }); + + test('an absent Device is not a slot UID', () => { + expect(looksLikeSlotUid(undefined)).toBe(false); + }); +}); + +describe('identity ladder rungs (in priority order)', () => { + test('rung 1: a slot-UID Device wins over Physdev and ports', () => { + const resolved = resolveModemIdentity( + facts({ device: 'slot-usb2-1', physdev: '/sys/devices/usb2', ports: ['ttyUSB0'] }), + ); + expect(resolved.slotSource).toBe('device-slot-uid'); + expect(resolved.confidence).toBe('high'); + expect(resolved.identity.logicalSlotId).toBe(logicalSlotId('slot-usb2-1')); + expect(resolved.stableKey).toBe('slot:slot-usb2-1'); + }); + + test('rung 2: Physdev is used when Device is a path-shaped default', () => { + const resolved = resolveModemIdentity( + facts({ device: '/sys/devices/usb2', physdev: '/sys/devices/pci/usb2/2-1' }), + ); + expect(resolved.slotSource).toBe('physdev'); + expect(resolved.confidence).toBe('high'); + expect(resolved.stableKey).toBe('physdev:/sys/devices/pci/usb2/2-1'); + }); + + test('rung 3: a ports-derived sysfs walk when no slot UID or Physdev', () => { + const resolved = resolveModemIdentity(facts({ ports: ['wwan0', 'ttyUSB2', 'ttyUSB0'] })); + expect(resolved.slotSource).toBe('sysfs-walk'); + expect(resolved.confidence).toBe('medium'); + expect(resolved.stableKey).toBe('sysfs:ttyUSB0+ttyUSB2+wwan0'); + }); + + test('rung 4: equipment fallback is low confidence with no slot', () => { + const resolved = resolveModemIdentity(facts()); + expect(resolved.slotSource).toBe('equipment-fallback'); + expect(resolved.confidence).toBe('low'); + expect(resolved.identity.logicalSlotId).toBeUndefined(); + expect(resolved.stableKey).toBe(`equip:${IMEI_A}`); + }); + + test('rung 4 with no equipment id keys off the transient path', () => { + const resolved = resolveModemIdentity(facts({ equipmentId: NO_EQUIPMENT_ID })); + expect(resolved.stableKey).toBe(`path:${PATH}`); + }); +}); + +describe('durable policy binding through the ladder', () => { + test('a slot-resolved unique modem may bind durable policy', () => { + const resolved = resolveModemIdentity(facts({ device: 'slot-usb2-1' })); + expect(canBindPolicy(resolved.identity)).toBe(true); + expect(policyBindingKey(resolved.identity).logicalSlotId).toBe(logicalSlotId('slot-usb2-1')); + }); + + test('duplicate-IMEI fixture: the ladder falls to low-confidence equipment fallback and refuses binding', () => { + const resolved = resolveModemIdentities([ + facts({ + runtimePath: '/org/freedesktop/ModemManager1/Modem/0', + equipmentId: imeiEquipmentId(IMEI_A), + }), + facts({ + runtimePath: '/org/freedesktop/ModemManager1/Modem/1', + equipmentId: imeiEquipmentId(IMEI_A), + }), + ]); + for (const each of resolved) { + expect(each.slotSource).toBe('equipment-fallback'); + expect(each.identity.equipmentId.confidence).toBe('low'); + expect(canBindPolicy(each.identity)).toBe(false); + expect(() => policyBindingKey(each.identity)).toThrow(PolicyBindingRefusedError); + } + }); + + test('unique IMEIs are NOT demoted in a batch resolve', () => { + const resolved = resolveModemIdentities([ + facts({ device: 'slot-a', equipmentId: imeiEquipmentId(IMEI_A) }), + facts({ device: 'slot-b', equipmentId: imeiEquipmentId(IMEI_B) }), + ]); + expect(resolved.every((each) => each.identity.equipmentId.confidence === 'high')).toBe(true); + }); +}); diff --git a/control/src/backend/identity-ladder.ts b/control/src/backend/identity-ladder.ts new file mode 100644 index 0000000..87f9702 --- /dev/null +++ b/control/src/backend/identity-ladder.ts @@ -0,0 +1,221 @@ +// The identity ladder — resolving a STABLE slot handle for a live modem. +// +// A modem's D-Bus object path (`/org/freedesktop/ModemManager1/Modem/3`) is a +// per-boot, per-plug value: it changes on replug and daemon restart, so it can +// never be the durable key. The ladder derives a stable key by trying, in order: +// +// 1. slot-UID in `Modem.Device` — when OUR udev rules label the slot they set +// `Device` to a `slot-*` UID (e.g. `slot-usb2-1`). That is stable and ours, +// distinguishable from MM's own path-shaped default (`/sys/devices/...`). +// 2. `Modem.Physdev` (1.22+) — the physical USB topology path. Stable across +// replug into the same port; used when no slot-UID is present. +// 3. a ports-derived sysfs walk — a deterministic key from the modem's port +// list when neither of the above is available (MM 1.20, no udev rule yet). +// 4. equipment-id fallback — LOW confidence. Per A2.1's ambiguity rules a +// low-confidence identity must never bind durable policy, so this rung sets +// `confidence: 'low'` and `canBindPolicy` refuses on an ambiguous equipment id. +// +// Phase A note: no image-pipeline udev rules ship yet, so on the bench `Device` +// is usually MM's path-shaped default and the ladder lands on Physdev / sysfs. +// The `slot-*` heuristic is documented here so a bench operator can label a slot +// by hand and see rung 1 fire. + +import { + demoteToLowConfidence, + type EquipmentId, + type IdentityConfidence, + imeiEquipmentId, + type LogicalSlotId, + logicalSlotId, + type ModemIdentity, + runtimePath, +} from '../domain'; +import { MODEM_IFACE, MODEM3GPP_IFACE } from './constants'; +import { + type DecodedManagedObjects, + findInterface, + propValue, + stringProp, +} from './managed-objects'; + +/** Which rung of the ladder produced the stable key. */ +export type SlotSource = 'device-slot-uid' | 'physdev' | 'sysfs-walk' | 'equipment-fallback'; + +/** The prefix OUR udev rules use to label a slot in `Modem.Device`. */ +export const SLOT_UID_PREFIX = 'slot-'; + +/** The raw facts the ladder resolves from — one modem's observed identity inputs. */ +export interface ModemIdentityFacts { + /** The live MM object path (becomes `runtimePath`; never a durable key). */ + readonly runtimePath: string; + /** `Modem.Device` — a `slot-*` UID (ours) or MM's path-shaped default. */ + readonly device?: string; + /** `Modem.Physdev` — physical topology path (1.22+; absent on 1.20). */ + readonly physdev?: string; + /** The modem's port device names (e.g. `ttyUSB0`, `wwan0`) for the sysfs walk. */ + readonly ports?: readonly string[]; + /** IMEI/serial equipment id with its own confidence (A2.1). */ + readonly equipmentId: EquipmentId; +} + +/** The ladder's output: a resolved identity plus how (and how firmly) we got it. */ +export interface ResolvedIdentity { + /** The four-part identity; `logicalSlotId` is set for rungs 1-3 only. */ + readonly identity: ModemIdentity; + /** Which rung produced the result. */ + readonly slotSource: SlotSource; + /** Confidence in the SLOT resolution (rung 4 is always `low`). */ + readonly confidence: IdentityConfidence; + /** The durable row key — stable across replug for rungs 1-3 (and unique-IMEI rung 4). */ + readonly stableKey: string; +} + +/** + * Whether a `Modem.Device` value is one of OUR slot UIDs (shaped `slot-*`) rather + * than MM's path-shaped default. This is the documented Phase-A heuristic that + * keeps a hand-labelled slot preferred over a `/sys/devices/...` path. + */ +export function looksLikeSlotUid(device: string | undefined): boolean { + if (device === undefined) { + return false; + } + return device.startsWith(SLOT_UID_PREFIX) && device.length > SLOT_UID_PREFIX.length; +} + +/** Derive a deterministic key from a modem's port list (sorted, stable across replug). */ +function sysfsWalkKey(ports: readonly string[]): string | undefined { + const named = ports.map((p) => p.trim()).filter((p) => p.length > 0); + if (named.length === 0) { + return undefined; + } + return [...named].sort().join('+'); +} + +function buildIdentity(facts: ModemIdentityFacts, slot: LogicalSlotId | undefined): ModemIdentity { + return { + equipmentId: facts.equipmentId, + runtimePath: runtimePath(facts.runtimePath), + ...(slot !== undefined ? { logicalSlotId: slot } : {}), + }; +} + +/** + * Resolve ONE modem's stable identity by walking the ladder. Pure — no I/O. + * + * Rungs 1-3 yield a `logicalSlotId` and a stable key that survives replug into the + * same slot. Rung 4 (equipment fallback) yields NO slot and `confidence: 'low'`, + * so a durable policy bound via `policyBindingKey` is refused for an ambiguous + * (duplicate/zero) equipment id. + */ +export function resolveModemIdentity(facts: ModemIdentityFacts): ResolvedIdentity { + // Rung 1 — our slot-UID in `Device`. + if (looksLikeSlotUid(facts.device)) { + const device = facts.device as string; + const slot = logicalSlotId(device); + return { + identity: buildIdentity(facts, slot), + slotSource: 'device-slot-uid', + confidence: 'high', + stableKey: `slot:${device}`, + }; + } + + // Rung 2 — `Physdev` physical topology path (1.22+). + const physdev = facts.physdev?.trim(); + if (physdev !== undefined && physdev.length > 0) { + const slot = logicalSlotId(`physdev:${physdev}`); + return { + identity: buildIdentity(facts, slot), + slotSource: 'physdev', + confidence: 'high', + stableKey: `physdev:${physdev}`, + }; + } + + // Rung 3 — ports-derived sysfs walk. + const walk = facts.ports !== undefined ? sysfsWalkKey(facts.ports) : undefined; + if (walk !== undefined) { + const slot = logicalSlotId(`sysfs:${walk}`); + return { + identity: buildIdentity(facts, slot), + slotSource: 'sysfs-walk', + confidence: 'medium', + stableKey: `sysfs:${walk}`, + }; + } + + // Rung 4 — equipment fallback, LOW confidence, no durable slot. + const equip = facts.equipmentId; + const stableKey = + equip.provenance !== 'none' && equip.value.trim().length > 0 + ? `equip:${equip.value}` + : `path:${facts.runtimePath}`; + return { + identity: buildIdentity(facts, undefined), + slotSource: 'equipment-fallback', + confidence: 'low', + stableKey, + }; +} + +/** + * Resolve a batch of modems, demoting any equipment id that appears on more than + * one modem to LOW confidence FIRST (cross-modem duplicate detection A2.1 cannot do + * from a single value). A duplicate IMEI thus reaches rung 4 with a low-confidence + * equipment id, and `canBindPolicy` refuses durable binding on it. + */ +export function resolveModemIdentities( + factsList: readonly ModemIdentityFacts[], +): ResolvedIdentity[] { + const counts = new Map(); + for (const facts of factsList) { + const { equipmentId } = facts; + if (equipmentId.provenance !== 'none' && equipmentId.value.trim().length > 0) { + counts.set(equipmentId.value, (counts.get(equipmentId.value) ?? 0) + 1); + } + } + return factsList.map((facts) => { + const { equipmentId } = facts; + const duplicated = + equipmentId.provenance !== 'none' && (counts.get(equipmentId.value) ?? 0) > 1; + const resolvedFacts: ModemIdentityFacts = duplicated + ? { ...facts, equipmentId: demoteToLowConfidence(equipmentId) } + : facts; + return resolveModemIdentity(resolvedFacts); + }); +} + +/** MM `Modem.Ports` is `a(su)`: entries of `[portName, portType]`. */ +function portNames(value: ReturnType): string[] | undefined { + if (!Array.isArray(value)) { + return undefined; + } + const names = value + .map((entry) => (Array.isArray(entry) ? entry[0] : undefined)) + .filter((name): name is string => typeof name === 'string'); + return names.length > 0 ? names : undefined; +} + +/** + * Extract one modem's `ModemIdentityFacts` from a decoded `GetManagedObjects` tree — + * the bridge real backends use to feed the ladder from an observed snapshot. + */ +export function modemIdentityFactsFromTree( + tree: DecodedManagedObjects, + modemPath: string, +): ModemIdentityFacts { + const modem = findInterface(tree, modemPath, MODEM_IFACE); + const modem3gpp = findInterface(tree, modemPath, MODEM3GPP_IFACE); + const equipment = stringProp(modem, 'EquipmentIdentifier') ?? stringProp(modem3gpp, 'Imei') ?? ''; + const device = stringProp(modem, 'Device'); + const physdev = stringProp(modem, 'Physdev'); + const ports = portNames(propValue(modem, 'Ports')); + + return { + runtimePath: modemPath, + equipmentId: imeiEquipmentId(equipment), + ...(device !== undefined ? { device } : {}), + ...(physdev !== undefined ? { physdev } : {}), + ...(ports !== undefined ? { ports } : {}), + }; +} diff --git a/control/src/backend/identity-registry.test.ts b/control/src/backend/identity-registry.test.ts new file mode 100644 index 0000000..e35912d --- /dev/null +++ b/control/src/backend/identity-registry.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, test } from 'bun:test'; +import { imeiEquipmentId, logicalSlotId, runtimePath } from '../domain'; +import { type ModemIdentityFacts, resolveModemIdentity } from './identity-ladder'; +import { IdentityRegistry } from './identity-registry'; + +const IMEI_A = '490154203237518'; +const IMEI_B = '356938035643809'; + +function resolvedAt( + device: string, + imei: string, + path: string, + extra: Partial = {}, +) { + return resolveModemIdentity({ + runtimePath: path, + device, + equipmentId: imeiEquipmentId(imei), + ...extra, + }); +} + +describe('IdentityRegistry transitions', () => { + test('a fresh stable key attaches a new row', () => { + const registry = new IdentityRegistry(); + const transition = registry.apply(resolvedAt('slot-a', IMEI_A, '/Modem/0')); + expect(transition.kind).toBe('attached'); + expect(registry.rows).toHaveLength(1); + }); + + test('replug into the SAME slot keeps ONE row and updates the path', () => { + const registry = new IdentityRegistry(); + registry.apply(resolvedAt('slot-a', IMEI_A, '/Modem/0')); + const transition = registry.apply(resolvedAt('slot-a', IMEI_A, '/Modem/7')); + + expect(transition.kind).toBe('replugged'); + expect(registry.rows).toHaveLength(1); + expect(registry.rows[0]?.runtimePath).toBe(runtimePath('/Modem/7')); + if (transition.kind === 'replugged') { + expect(transition.previousPath).toBe(runtimePath('/Modem/0')); + } + }); + + test('a different modem in the SAME slot is an equipment-swap (slot inherits policy)', () => { + const registry = new IdentityRegistry(); + registry.apply(resolvedAt('slot-a', IMEI_A, '/Modem/0')); + const transition = registry.apply(resolvedAt('slot-a', IMEI_B, '/Modem/1')); + + expect(transition.kind).toBe('equipment-swapped-in-slot'); + expect(registry.rows).toHaveLength(1); + expect(registry.rows[0]?.logicalSlotId).toBe(logicalSlotId('slot-a')); + expect( + registry.rows[0]?.equipmentId.provenance === 'imei' && registry.rows[0]?.equipmentId.value, + ).toBe(IMEI_B); + if (transition.kind === 'equipment-swapped-in-slot') { + expect( + transition.previousEquipment.provenance === 'imei' && transition.previousEquipment.value, + ).toBe(IMEI_A); + } + }); + + test('the SAME equipment in a different slot is an equipment-move', () => { + const registry = new IdentityRegistry(); + registry.apply(resolvedAt('slot-a', IMEI_A, '/Modem/0')); + const transition = registry.apply(resolvedAt('slot-b', IMEI_A, '/Modem/2')); + + expect(transition.kind).toBe('equipment-moved'); + expect(registry.rows).toHaveLength(1); + expect(registry.rows[0]?.logicalSlotId).toBe(logicalSlotId('slot-b')); + if (transition.kind === 'equipment-moved') { + expect(transition.previousKey).toBe('slot:slot-a'); + expect(transition.previousSlot).toBe(logicalSlotId('slot-a')); + } + }); + + test('two distinct slots keep two independent rows', () => { + const registry = new IdentityRegistry(); + registry.apply(resolvedAt('slot-a', IMEI_A, '/Modem/0')); + registry.apply(resolvedAt('slot-b', IMEI_B, '/Modem/1')); + expect(registry.rows).toHaveLength(2); + }); + + test('remove drops a row by stable key', () => { + const registry = new IdentityRegistry(); + registry.apply(resolvedAt('slot-a', IMEI_A, '/Modem/0')); + registry.remove('slot:slot-a'); + expect(registry.rows).toHaveLength(0); + }); +}); diff --git a/control/src/backend/identity-registry.ts b/control/src/backend/identity-registry.ts new file mode 100644 index 0000000..97c1a32 --- /dev/null +++ b/control/src/backend/identity-registry.ts @@ -0,0 +1,151 @@ +// The identity registry — ONE logical row per physical slot, across replugs. +// +// The epoch observer (A3.1) keys rows by the MM object path, which CHANGES on +// replug and daemon restart. This registry sits above it and keys rows by the +// STABLE identifier the ladder resolves (slot-UID / Physdev / sysfs / unique +// equipment id), so a modem that disconnects and reconnects to the SAME slot +// resolves to the SAME logical row instead of a duplicate. +// +// It also names the transitions the ladder can observe: +// +// - `attached` — a stable key we have not seen before. +// - `replugged` — same stable key, same equipment: one row, new path. +// - `equipment-swapped-in-slot` — same slot, DIFFERENT equipment. The slot keeps +// its policy (slot-inherits-policy): unplug modem A, +// plug modem B into the same slot, B adopts A's +// slot-bound policy. +// - `equipment-moved` — same equipment, DIFFERENT slot: the modem was +// physically moved to another port. + +import type { EquipmentId, LogicalSlotId, RuntimePath } from '../domain'; +import type { ResolvedIdentity } from './identity-ladder'; + +/** One durable logical row, keyed by the ladder's stable identifier. */ +export interface IdentityRow { + /** The stable durable key (`slot:…` / `physdev:…` / `sysfs:…` / `equip:…`). */ + readonly stableKey: string; + /** The resolved slot, when the row was slot-resolved (rungs 1-3). */ + readonly logicalSlotId?: LogicalSlotId; + /** The equipment currently occupying this slot. */ + readonly equipmentId: EquipmentId; + /** The live MM path — updated on every replug (never a durable key). */ + readonly runtimePath: RuntimePath; +} + +/** A transition the registry detected while applying a resolved identity. */ +export type IdentityTransition = + | { readonly kind: 'attached'; readonly row: IdentityRow } + | { readonly kind: 'replugged'; readonly row: IdentityRow; readonly previousPath: RuntimePath } + | { + readonly kind: 'equipment-swapped-in-slot'; + readonly row: IdentityRow; + readonly previousEquipment: EquipmentId; + } + | { + readonly kind: 'equipment-moved'; + readonly row: IdentityRow; + readonly previousKey: string; + readonly previousSlot?: LogicalSlotId; + }; + +const equipmentValue = (equipment: EquipmentId): string | undefined => + equipment.provenance !== 'none' && equipment.value.trim().length > 0 + ? equipment.value + : undefined; + +/** + * Tracks logical modem rows across replug/swap/move. Stateful by design — it holds + * the last-known row per stable key. `apply()` folds one resolved identity in and + * reports the transition it produced. + */ +export class IdentityRegistry { + readonly #rows = new Map(); + + /** A snapshot of the current logical rows, in insertion order. */ + get rows(): IdentityRow[] { + return [...this.#rows.values()]; + } + + /** The row for a stable key, or `undefined`. */ + get(stableKey: string): IdentityRow | undefined { + return this.#rows.get(stableKey); + } + + /** Drop a row (e.g. after a current-epoch removal). */ + remove(stableKey: string): void { + this.#rows.delete(stableKey); + } + + /** + * Fold one resolved identity into the registry and report the transition. + * Keeps ONE row per stable key: a replug into the same slot updates the live + * path in place rather than adding a duplicate. + */ + apply(resolved: ResolvedIdentity): IdentityTransition { + const row = this.#rowFrom(resolved); + const existing = this.#rows.get(row.stableKey); + + if (existing !== undefined) { + return this.#applyToExisting(existing, resolved, row); + } + + // A unique equipment id at a NEW stable key that already lives elsewhere + // means the modem physically moved to a different slot/port. + const moved = this.#findByEquipment(row.equipmentId, row.stableKey); + if (moved !== undefined) { + this.#rows.delete(moved.stableKey); + this.#rows.set(row.stableKey, row); + return { + kind: 'equipment-moved', + row, + previousKey: moved.stableKey, + ...(moved.logicalSlotId !== undefined ? { previousSlot: moved.logicalSlotId } : {}), + }; + } + + this.#rows.set(row.stableKey, row); + return { kind: 'attached', row }; + } + + #applyToExisting( + existing: IdentityRow, + resolved: ResolvedIdentity, + row: IdentityRow, + ): IdentityTransition { + const before = equipmentValue(existing.equipmentId); + const after = equipmentValue(resolved.identity.equipmentId); + + if (before !== undefined && after !== undefined && before !== after) { + // Same slot, different equipment — the slot keeps its policy binding. + this.#rows.set(row.stableKey, row); + return { kind: 'equipment-swapped-in-slot', row, previousEquipment: existing.equipmentId }; + } + + // Same slot, same (or unchanged) equipment — a replug: one row, new path. + this.#rows.set(row.stableKey, row); + return { kind: 'replugged', row, previousPath: existing.runtimePath }; + } + + #rowFrom(resolved: ResolvedIdentity): IdentityRow { + const { identity, stableKey } = resolved; + return { + stableKey, + equipmentId: identity.equipmentId, + runtimePath: identity.runtimePath, + ...(identity.logicalSlotId !== undefined ? { logicalSlotId: identity.logicalSlotId } : {}), + }; + } + + #findByEquipment(equipment: EquipmentId, exceptKey: string): IdentityRow | undefined { + const value = equipmentValue(equipment); + if (value === undefined) { + return undefined; + } + for (const row of this.#rows.values()) { + if (row.stableKey !== exceptKey && equipmentValue(row.equipmentId) === value) { + return row; + } + } + return undefined; + } +} diff --git a/control/src/backend/index.ts b/control/src/backend/index.ts new file mode 100644 index 0000000..5fbaf71 --- /dev/null +++ b/control/src/backend/index.ts @@ -0,0 +1,221 @@ +// Backend adapters — the concrete D-Bus implementations of the port contracts. +// +// A3.1 lands the epoch-scoped `MmDbusObserver` (the read side). A3.2 adds MM +// feature detection + the stable identity ladder. Later A3 waves add mutations + +// Signal.Setup (A3.3) and the recovery ladder (A3.4) here. + +export { + AT_BASELINE_ALLOWLIST, + type AtAuditEntry, + type AtAuditSink, + AtCommandLease, + type AtCommandLeaseDeps, + AtCommandNotAllowedError, + type AtCommandSender, + AtCommandTimeoutError, + type AtResponse, + computeAtAllowlist, +} from './at-lease'; +export { + type CellInfoProvenance, + type CellReading, + compareServing, + normalizeCellInfo, + normalizeCellReading, + selectServingCell, +} from './cell-info'; +export { + MM_BUS_NAME, + MM_MANAGER_IFACE, + MM_ROOT_PATH, + MODEM_IFACE, + MODEM3GPP_IFACE, + SIM_IFACE, +} from './constants'; +export { + classifyDevice, + type DeviceClass, + type DeviceClassification, + descriptorsMatch, + detectUsbMode, + type UsbDeviceSnapshot, + type UsbInterface, +} from './device-classifier'; +export { + buildEnrichment, + type EsimInfo, + type EsimStatus, + type ModemEnrichment, + readEsimInfo, + readRevision, + type SimType, +} from './enrichment'; +export { + detectMmFeatures, + detectModemFeatures, + type MmFeatures, + type MmPropertyProbe, + type MmVersion, + parseMmVersion, + probeModemProperties, +} from './features'; +export { + looksLikeSlotUid, + type ModemIdentityFacts, + modemIdentityFactsFromTree, + type ResolvedIdentity, + resolveModemIdentities, + resolveModemIdentity, + SLOT_UID_PREFIX, + type SlotSource, +} from './identity-ladder'; +export { + IdentityRegistry, + type IdentityRow, + type IdentityTransition, +} from './identity-registry'; +export { + ALLOW_ALL_INTERLOCK, + type InterlockDecision, + type InterlockTarget, + type LifecycleInterlock, +} from './lifecycle-interlock'; +export { + asManagedObjects, + type DecodedInterfaces, + type DecodedManagedObjects, + type DecodedObject, + type DecodedProps, + fetchManagedObjects, + findInterface, + findObject, + followObjectPath, + hasInterface, + numberProp, + objectPaths, + pathsWithInterface, + propValue, + stringProp, +} from './managed-objects'; +export { + createMmDbusBackend, + MmDbusBackend, + type MmDbusBackendOptions, +} from './mm-backend'; +export { MmMutations, type MmMutationsDeps } from './mm-mutations'; +export { + ModemActor, + NO_OP_QUIESCE, + type QuiesceHook, + type QuiesceLeaseHandle, + type QuiesceTarget, +} from './modem-actor'; +export { + AUTO_APN_ADVISORY, + type AutoApnAdvisory, + type AutoApnTransitionResult, + autoApnCapableFromVersion, + autoApnSupportedByVersion, + classifyActivation, + type ManualApn, + parseNmVersion, + probeAutoApnCapability, + toAutoArgs, + toManualArgs, +} from './nm-auto-apn'; +export { + buildProfile, + cliArg, + createGsmArgs, + flattenPairs, + gsmFieldPairs, + passwordFlags, + patchPairs, +} from './nm-gsm-fields'; +export { + NmcliNmPort, + type NmcliNmPortOptions, +} from './nmcli-nm-port'; +export { + type NmcliResult, + type NmcliRunner, + parseTerse, + runNmcli, + SpawnNmcliRunner, +} from './nmcli-runner'; +export { + createMmDbusObserver, + type EpochRefreshEvent, + MmDbusObserver, + type MmDbusObserverOptions, +} from './observer'; +export { + NONE_POWER_CAPABILITY, + NONE_POWER_HOOK, + type PowerCapability, + type PowerCapabilityKind, + type PowerCycleContext, + type PowerCycleResult, + type PowerHook, + type PreferredUsbMode, + unsupportedPowerHook, +} from './power-contract'; +export { + attributeFault, + attributeSnapshot, + type FaultAttribution, + type FaultSymptoms, + symptomsFromSnapshot, +} from './recovery-attribution'; +export { + type BudgetDecision, + beginAttempt, + DEFAULT_RECOVERY_BUDGET, + INITIAL_BUDGET_STATE, + markRecovered, + type RecoveryBudget, + type RecoveryBudgetState, +} from './recovery-budget'; +export { + DEFAULT_LADDER_CONFIG, + LADDER_ORDER, + RecoveryLadder, + type RecoveryLadderConfig, + type RecoveryLadderDeps, + type RecoveryOutcome, + type RecoveryOutcomeKind, + type RecoveryRequest, + type RecoveryRung, + type RecoveryStepContext, + type RecoveryStepGate, + type RecoveryStepReport, + type RecoverySteps, + type StepOutcome, +} from './recovery-ladder'; +export { createRouterEthernetProbe, type RouterEthernetProbeDeps } from './router-ethernet'; +export { + DEFAULT_SIGNAL_INTERVAL_SECONDS, + type SignalCadence, + SignalSetupManager, + type SignalSetupManagerOptions, +} from './signal-setup'; +export { sendSimPin, sendSimPuk } from './sim-unlock'; +export { + ALLOW_ALL_TRANSITION_INTERLOCK, + checkTransitionPreconditions, + type InterlockHold, + type PreconditionResult, + type TransitionInterlock, + type TransitionReadiness, + type UsbModeTransitionOutcome, + type UsbModeTransitionRequest, +} from './transition-preconditions'; +export * from './usage'; +export { + createUsbEnumerator, + enumerateUsbDevices, + parseUdevDatabase, + type UsbEnumerator, + type UsbEnumeratorDeps, +} from './usb-enumerator'; +export { UsbModeTransition, type UsbModeTransitionDeps } from './usb-mode-transition'; diff --git a/control/src/backend/lifecycle-interlock.ts b/control/src/backend/lifecycle-interlock.ts new file mode 100644 index 0000000..5d9e317 --- /dev/null +++ b/control/src/backend/lifecycle-interlock.ts @@ -0,0 +1,38 @@ +// The pluggable safety interlock the recovery ladder consults before disruption. +// +// A disruptive recovery step (deactivate NM, disable MM, Reset(), power-cycle) must +// never fire while the modem is carrying a live stream. The ladder therefore asks a +// `LifecycleInterlock` BEFORE every disruptive step. Phase A (CLI) injects the +// always-allow stub below; Phase B wires CeraUI's real streaming-admission check +// into this SAME interface — the ladder code does not change, only the injected +// instance. A4.2's USB-mode transition reuses this exact interlock shape. + +/** What a disruptive step is about to act on — enough for an interlock to decide. */ +export interface InterlockTarget { + readonly stableKey: string; +} + +/** An interlock verdict. A denial ALWAYS carries a reason (why it is unsafe now). */ +export type InterlockDecision = + | { readonly allow: true } + | { readonly allow: false; readonly reason: string }; + +/** + * The safety interlock consulted before each disruptive recovery step. Returning + * `{ allow: false, reason }` stops the ladder — the modem is left as-is rather than + * disrupted mid-stream. + */ +export interface LifecycleInterlock { + canDisrupt(target: InterlockTarget): Promise; +} + +/** + * The Phase-A interlock: always allows. There is no streaming-admission signal in + * the CLI, so nothing to block against. Phase B replaces this instance with a + * real streaming-aware interlock; no ladder code changes. + */ +export const ALLOW_ALL_INTERLOCK: LifecycleInterlock = { + canDisrupt(): Promise { + return Promise.resolve({ allow: true }); + }, +}; diff --git a/control/src/backend/managed-objects.ts b/control/src/backend/managed-objects.ts new file mode 100644 index 0000000..594932b --- /dev/null +++ b/control/src/backend/managed-objects.ts @@ -0,0 +1,108 @@ +// Walkers over a DECODED `GetManagedObjects` / `InterfacesAdded` tree. +// +// The transport decodes `a{oa{sa{sv}}}` into nested tuple arrays: a dict `a{sv}` +// becomes `[key, variant][]` and a variant becomes `{ signature, value }`. These +// accessors navigate that structure by object path and interface name. They live in +// `src` (not the A2.3 test fake's `tree.ts`) because the observer that ships must not +// import test-support — the published package would not contain it. + +import type { DbusTransport, DbusValue, DbusVariant } from '../transport'; +import { MM_ROOT_PATH, OBJECT_MANAGER_IFACE } from './constants'; + +/** One interface's property entries: `[propertyName, variant][]`. */ +export type DecodedProps = ReadonlyArray; +/** One object's interfaces: `[interfaceName, props][]`. */ +export type DecodedInterfaces = ReadonlyArray; +/** One managed object: `[objectPath, interfaces]`. */ +export type DecodedObject = readonly [string, DecodedInterfaces]; +/** The whole `a{oa{sa{sv}}}` payload. */ +export type DecodedManagedObjects = readonly DecodedObject[]; + +/** Treat a `GetManagedObjects` reply body value as the decoded tree. */ +export function asManagedObjects(value: DbusValue | undefined): DecodedManagedObjects { + if (!Array.isArray(value)) { + throw new TypeError('managed-objects payload is not an array'); + } + return value as unknown as DecodedManagedObjects; +} + +/** Call `ObjectManager.GetManagedObjects` on `destination` and decode the reply. */ +export async function fetchManagedObjects( + transport: DbusTransport, + destination: string, +): Promise { + const reply = await transport.callMethod({ + destination, + path: MM_ROOT_PATH, + interface: OBJECT_MANAGER_IFACE, + member: 'GetManagedObjects', + }); + return asManagedObjects(reply.body[0]); +} + +/** Treat an `InterfacesAdded` body (`[path, interfaces]`) as one decoded object. */ +export function asAddedObject(path: DbusValue, interfaces: DbusValue): DecodedObject { + if (typeof path !== 'string' || !Array.isArray(interfaces)) { + throw new TypeError('InterfacesAdded body is not [objectPath, interfaces]'); + } + return [path, interfaces as unknown as DecodedInterfaces]; +} + +/** Every object path in the tree, in wire order. */ +export function objectPaths(tree: DecodedManagedObjects): string[] { + return tree.map(([path]) => path); +} + +/** The object at `path`, or `undefined`. */ +export function findObject(tree: DecodedManagedObjects, path: string): DecodedObject | undefined { + return tree.find(([objectPath]) => objectPath === path); +} + +/** The property entries of one interface on one object, or `undefined`. */ +export function findInterface( + tree: DecodedManagedObjects, + path: string, + iface: string, +): DecodedProps | undefined { + return findObject(tree, path)?.[1].find(([name]) => name === iface)?.[1]; +} + +/** Object paths that carry a given interface. */ +export function pathsWithInterface(tree: DecodedManagedObjects, iface: string): string[] { + return tree + .filter(([, interfaces]) => interfaces.some(([name]) => name === iface)) + .map(([path]) => path); +} + +/** Whether an object exposes a given interface. */ +export function hasInterface(tree: DecodedManagedObjects, path: string, iface: string): boolean { + return findInterface(tree, path, iface) !== undefined; +} + +/** The inner value of a property (a variant's `.value`), or `undefined`. */ +export function propValue(props: DecodedProps | undefined, name: string): DbusValue | undefined { + return props?.find(([key]) => key === name)?.[1]?.value; +} + +/** A string-typed property, or `undefined` if absent / not a string. */ +export function stringProp(props: DecodedProps | undefined, name: string): string | undefined { + const value = propValue(props, name); + return typeof value === 'string' ? value : undefined; +} + +/** A number-typed property, or `undefined` if absent / not a number. */ +export function numberProp(props: DecodedProps | undefined, name: string): number | undefined { + const value = propValue(props, name); + return typeof value === 'number' ? value : undefined; +} + +/** Resolve an object-path property (e.g. a modem's `Sim`) to that object's props. */ +export function followObjectPath( + tree: DecodedManagedObjects, + props: DecodedProps | undefined, + propName: string, + iface: string, +): DecodedProps | undefined { + const target = propValue(props, propName); + return typeof target === 'string' ? findInterface(tree, target, iface) : undefined; +} diff --git a/control/src/backend/mapping.ts b/control/src/backend/mapping.ts new file mode 100644 index 0000000..80382ff --- /dev/null +++ b/control/src/backend/mapping.ts @@ -0,0 +1,160 @@ +// Pure mapping: a decoded ModemManager object tree → a `CellularSnapshot`. +// +// A3.1 is lifecycle-only. This mapper is DELIBERATELY conservative: it captures +// identity, presence, source health, radio power, and MM state — the facts the +// observer needs to order and reconcile modems — but leaves the richer 3GPP +// registration + access-technology set as `unknown`/empty. Faithful registration and +// cell-info normalisation are A3.2 (identity ladder) and A3.3 (Signal.Setup / cell +// info); mapping them here would either be a guess or trip the domain guards. The +// snapshot this produces is always guard-valid by construction. + +import { + type CellularSnapshot, + imeiEquipmentId, + type MmState, + subscriptionId as makeSubscriptionId, + type RadioPower, + runtimePath, + type SubscriptionId, +} from '../domain'; +import { MODEM_IFACE, MODEM3GPP_IFACE, SIM_IFACE } from './constants'; +import { + type DecodedManagedObjects, + findInterface, + followObjectPath, + numberProp, + pathsWithInterface, + stringProp, +} from './managed-objects'; + +/** The dimensions of a mapped modem, WITHOUT a revision (the observer stamps that). */ +export type MappedModem = Omit; + +/** MMModemState (`Modem.State`, signed) → the domain `MmState`. */ +function mapMmState(state: number | undefined): MmState { + switch (state) { + case -1: + return 'failed'; + case 1: + return 'initializing'; + case 2: + return 'locked'; + case 3: + return 'disabled'; + case 4: + return 'disabling'; + case 5: + return 'enabling'; + case 6: + return 'enabled'; + case 7: + return 'searching'; + case 8: + return 'registered'; + case 9: + return 'disconnecting'; + case 10: + return 'connecting'; + case 11: + return 'connected'; + default: + return 'unknown'; + } +} + +/** MMModemPowerState (`Modem.PowerState`) → the domain `RadioPower`. */ +function mapRadioPower(power: number | undefined): RadioPower { + switch (power) { + case 1: + return 'off'; + case 2: + return 'low'; + case 3: + return 'on'; + default: + return 'unknown'; + } +} + +/** Locked / failed modems keep the radio off; every other state we cannot see as + * off (the guards forbid an active MM state with the radio off), so an unknown + * power reading is treated as `on` when the modem is clearly on the air. */ +function reconcilePower(power: RadioPower, state: MmState): RadioPower { + const onTheAir = + state === 'enabled' || + state === 'searching' || + state === 'registered' || + state === 'connecting' || + state === 'connected' || + state === 'disconnecting'; + if (onTheAir && (power === 'unknown' || power === 'off')) { + return 'on'; + } + return power; +} + +/** Read a modem's subscription id (ICCID) from its active SIM object, if any. */ +function readSubscriptionId( + tree: DecodedManagedObjects, + modemPath: string, +): SubscriptionId | undefined { + const modem = findInterface(tree, modemPath, MODEM_IFACE); + const sim = followObjectPath(tree, modem, 'Sim', SIM_IFACE); + const iccid = stringProp(sim, 'SimIdentifier'); + return iccid !== undefined && iccid.length > 0 ? makeSubscriptionId(iccid) : 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); + const modem3gpp = findInterface(tree, modemPath, MODEM3GPP_IFACE); + + const equipment = stringProp(modem, 'EquipmentIdentifier') ?? stringProp(modem3gpp, 'Imei') ?? ''; + const mmState = mapMmState(numberProp(modem, 'State')); + const radioPower = reconcilePower(mapRadioPower(numberProp(modem, 'PowerState')), mmState); + const sub = readSubscriptionId(tree, modemPath); + + return { + identity: { + equipmentId: imeiEquipmentId(equipment), + runtimePath: runtimePath(modemPath), + ...(sub !== undefined ? { subscriptionId: sub } : {}), + }, + presence: 'present', + sourceHealth: 'live', + simSlots: [], + radioPower, + mmState, + // Conservative: 3GPP registration + RAT set are A3.3's job (see file header). + registration: { status: 'unknown', activeRats: new Set() }, + nmActivation: 'unavailable', + dataInterface: { present: false }, + reconcileStatus: 'pending', + recoveryState: { stage: 'idle', attempts: 0 }, + }; +} + +/** Every modem path in the tree (objects exposing the `Modem` interface), in order. */ +export function modemPaths(tree: DecodedManagedObjects): string[] { + return pathsWithInterface(tree, MODEM_IFACE); +} + +/** A stable fingerprint of a mapped modem's meaningful dimensions (ignores revision + * and source health), used to suppress redundant revision bumps on identical reads. */ +export function fingerprint(mapped: MappedModem): string { + return JSON.stringify({ + id: mapped.identity.equipmentId, + sub: mapped.identity.subscriptionId ?? null, + path: mapped.identity.runtimePath, + presence: mapped.presence, + radioPower: mapped.radioPower, + mmState: mapped.mmState, + registration: { + status: mapped.registration.status, + rats: [...mapped.registration.activeRats].sort(), + }, + nmActivation: mapped.nmActivation, + dataInterface: mapped.dataInterface, + reconcileStatus: mapped.reconcileStatus, + }); +} diff --git a/control/src/backend/mm-backend.ts b/control/src/backend/mm-backend.ts new file mode 100644 index 0000000..d0fdfd7 --- /dev/null +++ b/control/src/backend/mm-backend.ts @@ -0,0 +1,191 @@ +// MmDbusBackend — the real D-Bus `ModemManagerPort`, composed from the A3 parts. +// +// It fulfils the whole port: the read side delegates to A3.1's epoch-scoped observer; +// the mutations run through `MmMutations` (each serialized on the shared per-modem +// `ModemActor`, keyed on A3.2's STABLE key so serialization survives replug); the +// Signal.Setup lifecycle is driven off the observer's `onEpochRefresh` hook; and the +// read-only enrichment (firmware revision, eSIM, signal cadence, normalized cell info) +// is assembled on demand. One transport is shared by all of them. +// +// The `onEpochRefresh` hook is the single integration seam: on every current-epoch +// authoritative snapshot it (1) rebuilds the live path → stable-key map (so a mutation +// keys the actor by the durable identity, not the transient path) and (2) re-drives +// Signal.Setup for that epoch — applying to new modems, re-applying to survivors after +// an owner change, and never firing for a superseded epoch. + +import type { DesiredRadio } from '../domain'; +import { epochMillis } from '../domain'; +import type { + InhibitLease, + ModemManagerPort, + ModemRef, + NetworkScanResult, + ObservationList, + ObservationListener, + Receipt, + SimPukUnlockResult, + SimUnlockResult, + Unsubscribe, +} from '../ports'; +import type { DbusTransport } from '../transport'; +import { type CellReading, normalizeCellInfo } from './cell-info'; +import { MM_BUS_NAME, MODEM_IFACE } from './constants'; +import { buildEnrichment, type ModemEnrichment } from './enrichment'; +import { modemIdentityFactsFromTree, resolveModemIdentities } from './identity-ladder'; +import { type DecodedProps, fetchManagedObjects, pathsWithInterface } from './managed-objects'; +import { MmMutations } from './mm-mutations'; +import { ModemActor, type QuiesceHook } from './modem-actor'; +import { createMmDbusObserver, type EpochRefreshEvent, type MmDbusObserver } from './observer'; +import { type SignalCadence, SignalSetupManager } from './signal-setup'; + +export interface MmDbusBackendOptions { + readonly transport: DbusTransport; + readonly destination?: string; + /** NM quiesce hook for disruptive mode/slot changes (A3.3 default: no-op). */ + readonly quiesce?: QuiesceHook; + /** Signal.Setup reporting interval in seconds. */ + readonly signalIntervalSeconds?: number; + readonly scanTimeoutMs?: number; + readonly now?: () => number; +} + +/** The real D-Bus ModemManager backend: observation + mutations + Signal.Setup. */ +export class MmDbusBackend implements ModemManagerPort { + readonly #transport: DbusTransport; + readonly #destination: string; + readonly #now: () => number; + readonly #observer: MmDbusObserver; + readonly #mutations: MmMutations; + readonly #signalSetup: SignalSetupManager; + // Live modem path → durable stable key; rebuilt on every epoch snapshot. + readonly #stableKeyByPath = new Map(); + + constructor(options: MmDbusBackendOptions) { + this.#transport = options.transport; + this.#destination = options.destination ?? MM_BUS_NAME; + this.#now = options.now ?? Date.now; + const actor = new ModemActor(options.quiesce); + this.#signalSetup = new SignalSetupManager({ + transport: this.#transport, + destination: this.#destination, + ...(options.signalIntervalSeconds !== undefined + ? { intervalSeconds: options.signalIntervalSeconds } + : {}), + }); + this.#observer = createMmDbusObserver({ + transport: this.#transport, + destination: this.#destination, + onEpochRefresh: (event) => this.#onEpochRefresh(event), + }); + this.#mutations = new MmMutations({ + transport: this.#transport, + actor, + destination: this.#destination, + resolveStableKey: (modem) => this.#stableKeyByPath.get(modem) ?? modem, + ...(options.scanTimeoutMs !== undefined ? { scanTimeoutMs: options.scanTimeoutMs } : {}), + now: this.#now, + }); + } + + // ── observation (delegated to the A3.1 observer) ──────────────────────────────── + + start(): Promise { + return this.#observer.start(); + } + + observe(listener: ObservationListener): Unsubscribe { + return this.#observer.observe(listener); + } + + stop(): Promise { + return this.#observer.stop(); + } + + // ── mutations (serialized per modem through the shared actor) ──────────────────── + + setRadioModes(modem: ModemRef, preference: DesiredRadio): Promise { + return this.#mutations.setRadioModes(modem, preference); + } + + setPrimarySimSlot(modem: ModemRef, slotIndex: number): Promise { + return this.#mutations.setPrimarySimSlot(modem, slotIndex); + } + + sendPin(modem: ModemRef, pin: string): Promise { + return this.#mutations.sendPin(modem, pin); + } + + sendPuk(modem: ModemRef, puk: string, newPin: string): Promise { + return this.#mutations.sendPuk(modem, puk, newPin); + } + + scanNetworks(modem: ModemRef): Promise { + return this.#mutations.scanNetworks(modem); + } + + inhibit(uid: string): Promise { + return this.#mutations.inhibit(uid); + } + + uninhibit(lease: InhibitLease): Promise { + return this.#mutations.uninhibit(lease); + } + + // ── enrichment (additive, read-only, never gating) ────────────────────────────── + + /** Whether periodic signal reporting is configured for a modem (Signal.Setup). */ + signalCadence(modem: ModemRef): SignalCadence { + return this.#signalSetup.cadenceFor(modem); + } + + /** Read the normalized visible-cell list for a modem via `Modem.GetCellInfo`. */ + async readCellInfo(modem: ModemRef): Promise { + const provenance = { source: modem, observedAt: epochMillis(this.#now()) }; + try { + const reply = await this.#transport.callMethod({ + destination: this.#destination, + path: modem, + interface: MODEM_IFACE, + member: 'GetCellInfo', + }); + const cells = Array.isArray(reply.body[0]) + ? (reply.body[0] as unknown as DecodedProps[]) + : []; + return normalizeCellInfo(cells, provenance); + } catch { + return []; + } + } + + /** Assemble the full read-only enrichment (revision, eSIM, cadence, cell info). */ + async readEnrichment(modem: ModemRef): Promise { + const [tree, cellInfo] = await Promise.all([ + fetchManagedObjects(this.#transport, this.#destination), + this.readCellInfo(modem), + ]); + return buildEnrichment(tree, modem, this.#signalSetup.cadenceFor(modem), cellInfo); + } + + #onEpochRefresh(event: EpochRefreshEvent): void { + this.#refreshStableKeys(event); + this.#signalSetup.applyForEpoch(event.epoch, event.tree); + } + + #refreshStableKeys(event: EpochRefreshEvent): void { + const paths = pathsWithInterface(event.tree, MODEM_IFACE); + const facts = paths.map((path) => modemIdentityFactsFromTree(event.tree, path)); + const resolved = resolveModemIdentities(facts); + this.#stableKeyByPath.clear(); + paths.forEach((path, index) => { + const entry = resolved[index]; + if (entry !== undefined) { + this.#stableKeyByPath.set(path, entry.stableKey); + } + }); + } +} + +/** Construct the real D-Bus ModemManager backend over an A2.4 transport. */ +export function createMmDbusBackend(options: MmDbusBackendOptions): MmDbusBackend { + return new MmDbusBackend(options); +} diff --git a/control/src/backend/mm-mutations.ts b/control/src/backend/mm-mutations.ts new file mode 100644 index 0000000..da355da --- /dev/null +++ b/control/src/backend/mm-mutations.ts @@ -0,0 +1,228 @@ +// The ModemManager mutations — the disruptive-and-SIM half of `ModemManagerPort`. +// +// Every disruptive op runs through the shared per-modem `ModemActor` (serialized on +// the STABLE key, so two ops on one modem never interleave and a replug keeps the +// same queue). Mode and slot changes additionally run QUIESCED (NM briefly stands +// down first, via the actor's quiesce hook). PIN / PUK / scan serialize too but do +// 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 type { DesiredRadio, RadioAccessTechnology } from '../domain'; +import { epochMillis } from '../domain'; +import type { + InhibitLease, + ModemRef, + NetworkScanResult, + Receipt, + ScannedNetwork, + SimPukUnlockResult, + SimUnlockResult, +} from '../ports'; +import { receipt } from '../ports'; +import type { DbusTransport } from '../transport'; +import { + MM_BUS_NAME, + MM_MANAGER_IFACE, + MM_ROOT_PATH, + MODEM_IFACE, + MODEM3GPP_IFACE, +} from './constants'; +import { + type DecodedProps, + fetchManagedObjects, + findInterface, + numberProp, + propValue, + stringProp, +} from './managed-objects'; +import type { ModemActor } from './modem-actor'; +import { sendSimPin, sendSimPuk } from './sim-unlock'; + +/** MMModemMode bit per RAT family (2G/3G/4G/5G). */ +const MODE_BIT: Record = { gsm: 2, umts: 4, lte: 8, '5gnr': 16 }; + +/** MMModem3gppNetworkAvailability → the port's availability. */ +const AVAILABILITY: Record = { + 0: 'unknown', + 1: 'available', + 2: 'current', + 3: 'forbidden', +}; + +/** A network scan can take a long time — MM's own default is not enough. */ +const DEFAULT_SCAN_TIMEOUT_MS = 300_000; + +export interface MmMutationsDeps { + readonly transport: DbusTransport; + readonly actor: ModemActor; + readonly destination?: string; + /** Map a live modem path to its stable actor key (survives replug). */ + readonly resolveStableKey: (modem: ModemRef) => string; + readonly scanTimeoutMs?: number; + readonly now?: () => number; +} + +/** The disruptive + SIM mutations of `ModemManagerPort`, serialized per modem. */ +export class MmMutations { + readonly #transport: DbusTransport; + readonly #actor: ModemActor; + readonly #destination: string; + readonly #resolveStableKey: (modem: ModemRef) => string; + readonly #scanTimeoutMs: number; + readonly #now: () => number; + + constructor(deps: MmMutationsDeps) { + this.#transport = deps.transport; + this.#actor = deps.actor; + this.#destination = deps.destination ?? MM_BUS_NAME; + this.#resolveStableKey = deps.resolveStableKey; + this.#scanTimeoutMs = deps.scanTimeoutMs ?? DEFAULT_SCAN_TIMEOUT_MS; + this.#now = deps.now ?? Date.now; + } + + setRadioModes(modem: ModemRef, preference: DesiredRadio): Promise { + const allowed = maskOf(preference.allowedSet ?? new Set(preference.preferenceOrdered)); + const preferred = preference.preferenceOrdered[0]; + const preferredMask = preferred !== undefined ? MODE_BIT[preferred] : 0; + 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, preferredMask]], + }); + return receipt('radio', 'applied', 'radio mode preference applied'); + } catch (error) { + return receipt('radio', 'failed', `SetCurrentModes failed: ${describe(error)}`); + } + }); + } + + async setPrimarySimSlot(modem: ModemRef, slotIndex: number): Promise { + const slots = await this.#readSlotCount(modem); + if (slots === undefined) { + return receipt('simSlot', 'failed', 'could not read the modem SIM-slot list'); + } + if (slots <= 1) { + return receipt('simSlot', 'unsupported', 'single-slot modem has no primary slot to select'); + } + if (slotIndex < 1 || slotIndex > slots) { + return receipt('simSlot', 'failed', `slot ${slotIndex} is out of range (1..${slots})`); + } + return this.#actor.runQuiesced({ stableKey: this.#resolveStableKey(modem) }, async () => { + try { + await this.#transport.callMethod({ + destination: this.#destination, + path: modem, + interface: MODEM_IFACE, + member: 'SetPrimarySimSlot', + signature: 'u', + args: [slotIndex], + }); + return receipt('simSlot', 'applied', `primary SIM slot set to ${slotIndex}`); + } catch (error) { + return receipt('simSlot', 'failed', `SetPrimarySimSlot failed: ${describe(error)}`); + } + }); + } + + sendPin(modem: ModemRef, pin: string): Promise { + return this.#actor.run(this.#resolveStableKey(modem), () => + sendSimPin(this.#transport, this.#destination, modem, pin), + ); + } + + sendPuk(modem: ModemRef, puk: string, newPin: string): Promise { + return this.#actor.run(this.#resolveStableKey(modem), () => + sendSimPuk(this.#transport, this.#destination, modem, puk, newPin), + ); + } + + scanNetworks(modem: ModemRef): Promise { + return this.#actor.run(this.#resolveStableKey(modem), async () => { + try { + const reply = await this.#transport.callMethod({ + destination: this.#destination, + path: modem, + interface: MODEM3GPP_IFACE, + member: 'Scan', + timeoutMs: this.#scanTimeoutMs, + }); + return { ok: true, networks: parseScan(reply.body[0]) }; + } catch (error) { + return { ok: false, reason: `network scan failed: ${describe(error)}` }; + } + }); + } + + async inhibit(uid: string): Promise { + await this.#inhibitDevice(uid, true); + return { uid, acquiredAt: epochMillis(this.#now()) }; + } + + async uninhibit(lease: InhibitLease): Promise { + await this.#inhibitDevice(lease.uid, false); + } + + #inhibitDevice(uid: string, inhibit: boolean): Promise { + return this.#transport.callMethod({ + destination: this.#destination, + path: MM_ROOT_PATH, + interface: MM_MANAGER_IFACE, + member: 'InhibitDevice', + signature: 'sb', + args: [uid, inhibit], + }); + } + + async #readSlotCount(modem: ModemRef): Promise { + try { + const tree = await fetchManagedObjects(this.#transport, this.#destination); + const slots = propValue(findInterface(tree, modem, MODEM_IFACE), 'SimSlots'); + return Array.isArray(slots) ? slots.length : undefined; + } catch { + return undefined; + } + } +} + +/** OR of the MMModemMode bits for a set of RATs. */ +function maskOf(rats: ReadonlySet): number { + let mask = 0; + for (const rat of rats) { + mask |= MODE_BIT[rat]; + } + return mask; +} + +/** Parse a `Modem3gpp.Scan` reply (`aa{sv}` → dicts) into scanned networks. */ +function parseScan(value: unknown): readonly ScannedNetwork[] { + if (!Array.isArray(value)) { + return []; + } + const networks: ScannedNetwork[] = []; + for (const entry of value as DecodedProps[]) { + const operatorCode = stringProp(entry, 'operator-code'); + if (operatorCode === undefined) { + continue; + } + const name = stringProp(entry, 'operator-long') ?? stringProp(entry, 'operator-short'); + const availability = AVAILABILITY[numberProp(entry, 'status') ?? 0] ?? 'unknown'; + networks.push({ + operatorCode, + ...(name !== undefined ? { operatorName: name } : {}), + availability, + }); + } + return networks; +} + +function describe(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/control/src/backend/modem-actor.test.ts b/control/src/backend/modem-actor.test.ts new file mode 100644 index 0000000..1032fd4 --- /dev/null +++ b/control/src/backend/modem-actor.test.ts @@ -0,0 +1,95 @@ +// The shared per-modem disruptive actor — pure, deterministic serialization proof. +// +// Same stable key ⇒ strict serialization (no interleave); different keys ⇒ +// independent (overlap); a rejected task never stalls the queue; and a quiesce lease +// is always acquired around and released after a runQuiesced task, even on throw. + +import { describe, expect, test } from 'bun:test'; +import { + ModemActor, + type QuiesceHook, + type QuiesceLeaseHandle, + type QuiesceTarget, +} from './modem-actor'; + +const sleep = (ms: number): Promise => new Promise((resolve) => setTimeout(resolve, ms)); + +const worker = (log: string[], name: string) => async (): Promise => { + log.push(`${name}:start`); + await sleep(15); + log.push(`${name}:end`); +}; + +describe('ModemActor — per-key serialization', () => { + test('two tasks on the SAME key run strictly in order, never interleaved', async () => { + const actor = new ModemActor(); + const log: string[] = []; + await Promise.all([ + actor.run('slot:a', worker(log, 'a')), + actor.run('slot:a', worker(log, 'b')), + ]); + expect(log).toEqual(['a:start', 'a:end', 'b:start', 'b:end']); + }); + + test('tasks on DIFFERENT keys run independently (they overlap)', async () => { + const actor = new ModemActor(); + const log: string[] = []; + await Promise.all([ + actor.run('slot:a', worker(log, 'a')), + actor.run('slot:b', worker(log, 'b')), + ]); + const lastStart = Math.max(log.indexOf('a:start'), log.indexOf('b:start')); + const firstEnd = Math.min(log.indexOf('a:end'), log.indexOf('b:end')); + // Both started before either finished → the two keys did not serialize. + expect(lastStart).toBeLessThan(firstEnd); + }); + + test('a rejected task does not stall the queue', async () => { + const actor = new ModemActor(); + await expect(actor.run('slot:a', () => Promise.reject(new Error('boom')))).rejects.toThrow( + 'boom', + ); + await expect(actor.run('slot:a', () => Promise.resolve('ok'))).resolves.toBe('ok'); + }); + + test('an idle key drops its queue entry (bounded map)', async () => { + const actor = new ModemActor(); + await actor.run('slot:a', () => Promise.resolve(1)); + await sleep(0); + expect(actor.activeKeyCount).toBe(0); + }); +}); + +describe('ModemActor — quiesce lease', () => { + function recordingHook(events: string[]): QuiesceHook { + return { + acquire(target: QuiesceTarget): Promise { + events.push(`acquire:${target.stableKey}`); + return Promise.resolve({ + release(): Promise { + events.push('release'); + return Promise.resolve(); + }, + }); + }, + }; + } + + test('runQuiesced acquires before, releases after the task', async () => { + const events: string[] = []; + const actor = new ModemActor(recordingHook(events)); + await actor.runQuiesced({ stableKey: 'slot:a' }, async () => { + events.push('task'); + }); + expect(events).toEqual(['acquire:slot:a', 'task', 'release']); + }); + + test('the lease is released even when the task throws', async () => { + const events: string[] = []; + const actor = new ModemActor(recordingHook(events)); + await expect( + actor.runQuiesced({ stableKey: 'slot:a' }, () => Promise.reject(new Error('x'))), + ).rejects.toThrow('x'); + expect(events).toEqual(['acquire:slot:a', 'release']); + }); +}); diff --git a/control/src/backend/modem-actor.ts b/control/src/backend/modem-actor.ts new file mode 100644 index 0000000..1ddb9cf --- /dev/null +++ b/control/src/backend/modem-actor.ts @@ -0,0 +1,112 @@ +// The shared per-modem disruptive-operation actor. +// +// EVERY disruptive modem operation funnels through ONE serialization queue PER +// MODEM: radio-mode changes and primary-slot changes (A3.3), the recovery ladder +// (A3.4), and USB-mode / AT transitions (A4.2) all route through the SAME actor so +// two disruptive ops on ONE modem can never interleave. Ops on DIFFERENT modems run +// independently — each modem owns its own queue. +// +// The queue is keyed on the STABLE identity key (A3.2's `stableKey`), NOT the +// transient D-Bus object path. A modem that unplugs and replugs into the same slot +// resolves to the same stable key, so serialization survives the replug (the path +// changed, the queue did not). +// +// Mode and slot changes additionally coordinate with NetworkManager through the +// `QuiesceHook` — before a disruptive change NM briefly deactivates the connection so +// it cannot race MM's own re-activation, then reactivates it on release. In A3.3 the +// hook is a NO-OP stub with the exact lease shape A4.1's real nmcli adapter will fill +// in (`NetworkManagerPort.acquireQuiesceLease`). + +import type { ConnectionId, DeviceIfname } from '../ports'; + +/** What a quiesce lease is taken over — the modem, and (once A4.1 knows them) its + * NM connection + device. In A3.3 only `stableKey` is populated. */ +export interface QuiesceTarget { + readonly stableKey: string; + readonly connectionId?: ConnectionId; + readonly deviceIfname?: DeviceIfname; +} + +/** A held quiesce lease — released (reactivating the connection) when the op ends. */ +export interface QuiesceLeaseHandle { + release(): Promise; +} + +/** + * The NM-quiesce coordination hook the actor calls around a disruptive mode/slot + * change. A4.1 wires the real `nmcli` lease (verify-active → `device disconnect` → + * hold → reactivate); A3.3 ships `NO_OP_QUIESCE`, whose call site + shape are the + * exact seam A4.1 fills in. + */ +export interface QuiesceHook { + acquire(target: QuiesceTarget): Promise; +} + +const NO_OP_LEASE: QuiesceLeaseHandle = { + release(): Promise { + return Promise.resolve(); + }, +}; + +/** The A3.3 default quiesce hook — acquires nothing, releases nothing. */ +export const NO_OP_QUIESCE: QuiesceHook = { + acquire(): Promise { + return Promise.resolve(NO_OP_LEASE); + }, +}; + +/** + * A per-modem serialized actor. `run` serializes a task behind every prior task for + * the same stable key; `runQuiesced` additionally holds an NM quiesce lease for the + * task's duration. Different stable keys never block one another. + */ +export class ModemActor { + readonly #quiesce: QuiesceHook; + // Tail of each modem's queue — a promise that settles when the last-enqueued task + // for that key finishes (errors swallowed so the chain never breaks). + readonly #tails = new Map>(); + + constructor(quiesce: QuiesceHook = NO_OP_QUIESCE) { + this.#quiesce = quiesce; + } + + /** Run `task` serialized behind every prior task for `stableKey`. */ + run(stableKey: string, task: () => Promise): Promise { + const prior = this.#tails.get(stableKey) ?? Promise.resolve(); + // `then(task, task)` runs `task` whether the prior task resolved OR rejected, + // so one failure never stalls the queue. + const result = prior.then(task, task); + const settled = result.then( + () => undefined, + () => undefined, + ); + this.#tails.set(stableKey, settled); + // Drop the entry once this task was the last one, to bound the map. + void settled.then(() => { + if (this.#tails.get(stableKey) === settled) { + this.#tails.delete(stableKey); + } + }); + return result; + } + + /** + * Run `task` serialized for `target.stableKey` with an NM quiesce lease held for + * its whole duration — the lease is always released, even if `task` throws. + */ + runQuiesced(target: QuiesceTarget, task: () => Promise): Promise { + return this.run(target.stableKey, async () => { + const lease = await this.#quiesce.acquire(target); + try { + return await task(); + } finally { + await lease.release(); + } + }); + } + + /** Number of modems with a live queue — exposed for leak / idle assertions. */ + get activeKeyCount(): number { + return this.#tails.size; + } +} diff --git a/control/src/backend/nm-auto-apn.ts b/control/src/backend/nm-auto-apn.ts new file mode 100644 index 0000000..b603d6e --- /dev/null +++ b/control/src/backend/nm-auto-apn.ts @@ -0,0 +1,161 @@ +// Auto-APN: boot capability probe, the two atomic transition argv builders, and the +// activation-result classifier. +// +// Auto-APN (`apn:"auto"` ⇒ NM `gsm.auto-config yes`) needs NetworkManager >= 1.22 +// (bookworm ships 1.42). A boot capability probe reads nmcli's version — replacing any +// implicit/hardcoded gating — and when NM is too old an auto policy resolves to +// `unsupported` up front with the `autoApnUnavailable` advisory (no modify at all). +// +// When NM DOES support auto-config a bad/unknown SIM can still have no MBPI match: the +// connection fails to activate with a GSM_APN_FAILED device-state reason. That is +// classified at ACTIVATION, not guessed ahead of time — reactivate the exact +// (uuid, ifname) pair, await the terminal state, then: +// GSM_APN_FAILED under an auto profile → unsupported + autoApnUnavailable advisory +// any other activation error → failed +// nmcli's own activation wait timed out → pending + +import { type Receipt, receipt } from '../ports'; +import { cliArg, passwordFlags } from './nm-gsm-fields'; +import { type NmcliResult, type NmcliRunner, runNmcli } from './nmcli-runner'; + +/** The single advisory Auto-APN raises when it cannot be honored. */ +export const AUTO_APN_ADVISORY = 'autoApnUnavailable'; +export type AutoApnAdvisory = typeof AUTO_APN_ADVISORY; + +/** The outcome of an Auto-APN transition: a receipt plus an optional advisory flag. */ +export interface AutoApnTransitionResult { + readonly receipt: Receipt; + readonly advisory?: AutoApnAdvisory; +} + +/** Explicit APN + credentials for the manual direction of a transition. */ +export interface ManualApn { + readonly apn: string; + readonly username?: string; + readonly password?: string; +} + +const AUTO_CONFIG_MIN_MAJOR = 1; +const AUTO_CONFIG_MIN_MINOR = 22; + +/** nmcli's own `--wait` timeout exit status (nmcli(1): 3 = timeout expired). */ +const NMCLI_EXIT_TIMEOUT = 3; + +const APN_FAILED = /gsm[_-]?apn[_-]?failed/i; + +/** Parse `nmcli tool, version 1.42.4` → `{ major, minor }` (null on garbage). */ +export function parseNmVersion(output: string): { major: number; minor: number } | null { + const match = /version\s+(\d+)\.(\d+)/i.exec(output); + if (match?.[1] === undefined || match[2] === undefined) { + return null; + } + return { major: Number(match[1]), minor: Number(match[2]) }; +} + +/** `true` iff the parsed NM version is >= 1.22 (auto-config support floor). */ +export function autoApnSupportedByVersion(version: { major: number; minor: number }): boolean { + if (version.major !== AUTO_CONFIG_MIN_MAJOR) { + return version.major > AUTO_CONFIG_MIN_MAJOR; + } + return version.minor >= AUTO_CONFIG_MIN_MINOR; +} + +/** + * Decide from a `nmcli --version` result whether Auto-APN is available on this install. + * Fail-CLOSED — an unreadable/garbled version is "not capable", so we never claim + * Auto-APN works when we cannot prove it does. + */ +export function autoApnCapableFromVersion(result: NmcliResult): boolean { + if (result.exitCode !== 0) { + return false; + } + const version = parseNmVersion(result.stdout); + return version !== null && autoApnSupportedByVersion(version); +} + +/** Boot capability probe: run `nmcli --version` and resolve Auto-APN availability. */ +export async function probeAutoApnCapability(runner: NmcliRunner): Promise { + return autoApnCapableFromVersion(await runNmcli(runner, ['--version'])); +} + +/** + * ONE atomic `connection modify` that flips a profile to Auto-APN: clears every + * credential AND sets `gsm.auto-config yes` in the SAME invocation. Order/atomicity + * matter — NM rejects `auto-config yes` while any credential is still set, so the clear + * and the enable MUST land together (never two modifies). + */ +export function toAutoArgs(id: string): string[] { + return [ + 'connection', + 'modify', + id, + 'gsm.apn', + '', + 'gsm.username', + '', + 'gsm.password', + '', + 'gsm.password-flags', + '4', + 'gsm.auto-config', + 'yes', + ]; +} + +/** + * The exact reverse: ONE atomic `connection modify` restoring explicit APN + creds and + * clearing `gsm.auto-config`, with password-flags matching the (new) password. + */ +export function toManualArgs(id: string, creds: ManualApn): string[] { + return [ + 'connection', + 'modify', + id, + 'gsm.apn', + cliArg(creds.apn), + 'gsm.username', + cliArg(creds.username), + 'gsm.password', + cliArg(creds.password), + 'gsm.password-flags', + passwordFlags(creds.password), + 'gsm.auto-config', + 'no', + ]; +} + +/** + * Classify a `connection up` result into a receipt (+ advisory). `underAuto` marks that + * the profile being activated is an Auto-APN profile, so an APN failure is the "no MBPI + * match for this SIM" case → unsupported + advisory rather than a hard fail. + */ +export function classifyActivation( + result: NmcliResult, + options: { readonly underAuto: boolean }, +): AutoApnTransitionResult { + if (result.exitCode === 0) { + return { receipt: receipt('connection', 'applied', 'connection activated') }; + } + if (result.exitCode === NMCLI_EXIT_TIMEOUT || /timeout/i.test(result.stderr)) { + return { + receipt: receipt( + 'connection', + 'pending', + 'activation wait timed out; terminal state unknown', + ), + }; + } + if (options.underAuto && (APN_FAILED.test(result.stderr) || APN_FAILED.test(result.stdout))) { + return { + receipt: receipt( + 'connection', + 'unsupported', + 'Auto-APN found no operator match for this SIM', + ), + advisory: AUTO_APN_ADVISORY, + }; + } + return { + receipt: receipt('connection', 'failed', result.stderr || 'connection activation failed'), + }; +} diff --git a/control/src/backend/nm-gsm-fields.ts b/control/src/backend/nm-gsm-fields.ts new file mode 100644 index 0000000..30e268c --- /dev/null +++ b/control/src/backend/nm-gsm-fields.ts @@ -0,0 +1,122 @@ +// Pure nmcli GSM-connection argv builders — the FULL nine-field write parity today's +// CeraUI carried (modem-registration.ts `sanitizeModemConfigForNetworkManager` + +// `addConnectionForModem`), rebuilt from scratch for the device-exact adapter so the +// greenfield port loses nothing the wrap-first path wrote. +// +// The nine fields and their exact NM conventions (`gsm.*` + `connection.*` setting +// semantics) are the contract A4.1 must not regress: +// gsm.apn / gsm.username / gsm.password — creds ("" when unset) +// gsm.password-flags — "4" NOT_REQUIRED (no password), +// "0" NONE / system-stored (password set) +// gsm.home-only — "yes" ⇒ roaming DISABLED +// gsm.network-id — manual operator PLMN, only while roaming +// gsm.auto-config — "yes" ⇒ Auto-APN (creds MUST be empty) +// connection.autoconnect — always "yes" +// connection.autoconnect-retries — "2" + +import type { ConnectionId, GsmProfile, GsmProfileInput, GsmProfilePatch } from '../ports'; + +// Empty-string fallback; Bun runtime limitation with empty CLI args: a bare +// `undefined` reaching `Bun.spawn` argv drops the slot and shifts every following +// token, so every optional nmcli value is coerced to "" first — the same `value || ""` +// convention CeraUI documented on gsm.apn / gsm.username / gsm.password. +export const cliArg = (value: string | undefined): string => value || ''; + +/** NM secret-flags: no password ⇒ NOT_REQUIRED ("4"); password set ⇒ NONE ("0"). */ +export const passwordFlags = (password: string | undefined): string => (password ? '0' : '4'); + +/** `gsm.network-id` — the manual operator id, and only while roaming (home-only off). */ +const networkIdField = (input: GsmProfileInput): string => + input.homeOnly ? '' : cliArg(input.networkId); + +/** + * The nine `gsm.*` + `connection.*` key/value pairs for a profile, in stable order. + * With `autoConfig` set the creds are forced empty (NM rejects `auto-config yes` while + * any credential is present — nm-setting-gsm.c:440-447), so an Auto-APN create is always + * NM-valid by construction. + */ +export function gsmFieldPairs(input: GsmProfileInput): Array<[string, string]> { + const auto = input.autoConfig; + return [ + ['gsm.apn', auto ? '' : cliArg(input.apn)], + ['gsm.username', auto ? '' : cliArg(input.username)], + ['gsm.password', auto ? '' : cliArg(input.password)], + ['gsm.password-flags', passwordFlags(auto ? undefined : input.password)], + ['gsm.home-only', input.homeOnly ? 'yes' : 'no'], + ['gsm.network-id', networkIdField(input)], + ['gsm.auto-config', auto ? 'yes' : 'no'], + ['connection.autoconnect', 'yes'], + ['connection.autoconnect-retries', '2'], + ]; +} + +/** Flatten key/value pairs into the alternating argv nmcli expects. */ +export function flattenPairs(pairs: ReadonlyArray): string[] { + return pairs.flat(); +} + +/** `connection add type gsm con-name ` — device-exact create argv. */ +export function createGsmArgs(input: GsmProfileInput): string[] { + return [ + 'connection', + 'add', + 'type', + 'gsm', + 'con-name', + input.connectionName, + ...flattenPairs(gsmFieldPairs(input)), + ]; +} + +/** + * The key/value pairs for a `connection modify`. Roaming (home-only) and the manual + * operator id move together — turning roaming off clears `gsm.network-id`, honoring the + * `gsm.network-id = roaming ? id : ""` invariant in the SAME modify. + */ +export function patchPairs(patch: GsmProfilePatch): string[] { + const pairs: string[] = []; + if (patch.connectionName !== undefined) { + pairs.push('connection.id', patch.connectionName); + } + if (patch.apn !== undefined) { + pairs.push('gsm.apn', cliArg(patch.apn)); + } + if (patch.username !== undefined) { + pairs.push('gsm.username', cliArg(patch.username)); + } + if (patch.password !== undefined) { + pairs.push( + 'gsm.password', + cliArg(patch.password), + 'gsm.password-flags', + passwordFlags(patch.password), + ); + } + if (patch.autoConfig !== undefined) { + pairs.push('gsm.auto-config', patch.autoConfig ? 'yes' : 'no'); + } + if (patch.homeOnly !== undefined) { + pairs.push('gsm.home-only', patch.homeOnly ? 'yes' : 'no'); + pairs.push('gsm.network-id', patch.homeOnly ? '' : cliArg(patch.networkId)); + } else if (patch.networkId !== undefined) { + pairs.push('gsm.network-id', cliArg(patch.networkId)); + } + return pairs; +} + +/** Map an nmcli terse readback back onto a `GsmProfile` (inverse of the write parity). */ +export function buildProfile(id: ConnectionId, settings: Map): GsmProfile { + const username = settings.get('gsm.username') ?? ''; + const password = settings.get('gsm.password') ?? ''; + const networkId = settings.get('gsm.network-id') ?? ''; + return { + connectionId: id, + connectionName: settings.get('connection.id') ?? '', + apn: settings.get('gsm.apn') ?? '', + homeOnly: settings.get('gsm.home-only') === 'yes', + autoConfig: settings.get('gsm.auto-config') === 'yes', + ...(username !== '' ? { username } : {}), + ...(password !== '' ? { password } : {}), + ...(networkId !== '' ? { networkId } : {}), + }; +} diff --git a/control/src/backend/nmcli-nm-port.ts b/control/src/backend/nmcli-nm-port.ts new file mode 100644 index 0000000..a4c098f --- /dev/null +++ b/control/src/backend/nmcli-nm-port.ts @@ -0,0 +1,228 @@ +// The device-exact NetworkManager adapter: a FRESH nmcli-based `NetworkManagerPort` +// (no reused CeraUI code) with the full nine-field GSM write parity, verify-then- +// device-disconnect deactivation (never id-only `connection down`), abandoned-lease +// quiesce, and atomic Auto-APN transitions. + +import { epochMillis } from '../domain'; +import { + type ConnectionId, + connectionId, + type DeviceIfname, + type GsmProfile, + type GsmProfileInput, + type GsmProfilePatch, + type NetworkManagerPort, + type QuiesceLease, + type Receipt, + receipt, +} from '../ports'; +import { + AUTO_APN_ADVISORY, + type AutoApnTransitionResult, + classifyActivation, + type ManualApn, + toAutoArgs, + toManualArgs, +} from './nm-auto-apn'; +import { buildProfile, createGsmArgs, patchPairs } from './nm-gsm-fields'; +import { type NmcliResult, type NmcliRunner, parseTerse, runNmcli } from './nmcli-runner'; + +const READBACK_FIELDS = [ + 'connection.id', + 'gsm.apn', + 'gsm.username', + 'gsm.password', + 'gsm.password-flags', + 'gsm.home-only', + 'gsm.network-id', + 'gsm.auto-config', + 'connection.autoconnect', + 'connection.autoconnect-retries', +]; + +/** Default abandoned-lease TTL: a held quiesce lease older than this auto-releases. */ +const DEFAULT_LEASE_TTL_MS = 60_000; + +export interface NmcliNmPortOptions { + readonly runner: NmcliRunner; + /** Resolved once at boot by the capability probe; default `true`. */ + readonly autoApnCapable?: boolean; + readonly leaseTtlMs?: number; + readonly now?: () => number; +} + +export class NmcliNmPort implements NetworkManagerPort { + readonly #runner: NmcliRunner; + readonly #autoApnCapable: boolean; + readonly #leaseTtlMs: number; + readonly #now: () => number; + readonly #leases = new Set(); + + constructor(options: NmcliNmPortOptions) { + this.#runner = options.runner; + this.#autoApnCapable = options.autoApnCapable ?? true; + this.#leaseTtlMs = options.leaseTtlMs ?? DEFAULT_LEASE_TTL_MS; + this.#now = options.now ?? Date.now; + } + + async createGsmProfile(profile: GsmProfileInput): Promise { + const result = await runNmcli(this.#runner, createGsmArgs(profile)); + const uuid = /\(([^)]+)\) successfully added/.exec(result.stdout)?.[1]; + if (uuid === undefined) { + throw new Error(`nmcli connection add failed: ${result.stderr || result.stdout}`); + } + return this.#requireProfile(connectionId(uuid)); + } + + async readGsmProfile(id: ConnectionId): Promise { + const result = await runNmcli(this.#runner, [ + '-t', + '-f', + READBACK_FIELDS.join(','), + 'connection', + 'show', + id, + ]); + if (result.exitCode !== 0) { + return undefined; + } + return buildProfile(id, parseTerse(result.stdout)); + } + + async updateGsmProfile(id: ConnectionId, patch: GsmProfilePatch): Promise { + const result = await runNmcli(this.#runner, ['connection', 'modify', id, ...patchPairs(patch)]); + if (result.exitCode !== 0) { + throw new Error(`nmcli connection modify failed: ${result.stderr}`); + } + return this.#requireProfile(id); + } + + async deleteGsmProfile(id: ConnectionId): Promise { + await runNmcli(this.#runner, ['connection', 'delete', id]); + } + + async activate(id: ConnectionId, ifname: DeviceIfname): Promise { + const result = await runNmcli(this.#runner, ['connection', 'up', id, 'ifname', ifname]); + return activationReceipt(result, `activated ${id} on ${ifname}`); + } + + async deactivate(id: ConnectionId, ifname: DeviceIfname): Promise { + if ((await this.#activeUuidOn(ifname)) !== id) { + return receipt('enabled', 'applied', `${id} not active on ${ifname}; nothing to deactivate`); + } + const result = await runNmcli(this.#runner, ['device', 'disconnect', ifname]); + return activationReceipt(result, `deactivated ${id} on ${ifname}`); + } + + async acquireQuiesceLease(id: ConnectionId, ifname: DeviceIfname): Promise { + if ((await this.#activeUuidOn(ifname)) === id) { + await runNmcli(this.#runner, ['device', 'disconnect', ifname]); + } + const lease: QuiesceLease = { + connectionId: id, + deviceIfname: ifname, + acquiredAt: epochMillis(this.#now()), + }; + this.#leases.add(lease); + return lease; + } + + async releaseQuiesceLease(lease: QuiesceLease): Promise { + if (this.#leases.delete(lease)) { + await this.#reactivate(lease); + } + } + + /** Reactivate every lease held past the TTL — the abandoned-lease watchdog. */ + async sweepExpiredLeases(now: number = this.#now()): Promise { + for (const lease of [...this.#leases]) { + if (now - lease.acquiredAt >= this.#leaseTtlMs) { + this.#leases.delete(lease); + await this.#reactivate(lease); + } + } + } + + /** Flip a profile to Auto-APN via ONE atomic modify, then reactivate + classify. */ + async transitionToAuto(id: ConnectionId, ifname: DeviceIfname): Promise { + if (!this.#autoApnCapable) { + return { + receipt: receipt('connection', 'unsupported', 'Auto-APN requires NetworkManager >= 1.22'), + advisory: AUTO_APN_ADVISORY, + }; + } + const modify = await runNmcli(this.#runner, toAutoArgs(id)); + if (modify.exitCode !== 0) { + return { + receipt: receipt('connection', 'failed', modify.stderr || 'auto-config modify rejected'), + }; + } + return this.#reactivateAndClassify(id, ifname, true); + } + + /** Flip a profile to manual APN via ONE atomic modify, then reactivate + classify. */ + async transitionToManual( + id: ConnectionId, + ifname: DeviceIfname, + creds: ManualApn, + ): Promise { + const modify = await runNmcli(this.#runner, toManualArgs(id, creds)); + if (modify.exitCode !== 0) { + return { + receipt: receipt('connection', 'failed', modify.stderr || 'manual-apn modify rejected'), + }; + } + return this.#reactivateAndClassify(id, ifname, false); + } + + async #reactivateAndClassify( + id: ConnectionId, + ifname: DeviceIfname, + underAuto: boolean, + ): Promise { + const up = await runNmcli(this.#runner, ['connection', 'up', id, 'ifname', ifname]); + return classifyActivation(up, { underAuto }); + } + + async #reactivate(lease: QuiesceLease): Promise { + await runNmcli(this.#runner, [ + 'connection', + 'up', + lease.connectionId, + 'ifname', + lease.deviceIfname, + ]); + } + + async #activeUuidOn(ifname: DeviceIfname): Promise { + const result = await runNmcli(this.#runner, [ + '-t', + '-f', + 'UUID,DEVICE', + 'connection', + 'show', + '--active', + ]); + for (const line of result.stdout.split('\n')) { + const [uuid, device] = line.split(':'); + if (device === ifname && uuid) { + return uuid; + } + } + return undefined; + } + + async #requireProfile(id: ConnectionId): Promise { + const profile = await this.readGsmProfile(id); + if (profile === undefined) { + throw new Error(`nmcli: profile ${id} did not read back`); + } + return profile; + } +} + +function activationReceipt(result: NmcliResult, appliedReason: string): Receipt { + return result.exitCode === 0 + ? receipt('enabled', 'applied', appliedReason) + : receipt('enabled', 'failed', result.stderr || 'nmcli returned a non-zero exit code'); +} diff --git a/control/src/backend/nmcli-runner.ts b/control/src/backend/nmcli-runner.ts new file mode 100644 index 0000000..2f7f61c --- /dev/null +++ b/control/src/backend/nmcli-runner.ts @@ -0,0 +1,52 @@ +// The nmcli invocation seam. The shipping adapter spawns the real `nmcli`; tests +// inject A2.3's stateful in-memory runner (structurally the same `run(argv)`). The +// port never spawns directly — it only ever talks to an injected `NmcliRunner`, so +// the exact code path exercised under test is the one that runs on-device. + +export interface NmcliResult { + readonly stdout: string; + readonly stderr: string; + readonly exitCode: number; +} + +/** + * A runner over nmcli argv. `run` may be synchronous (the stateful test double) or + * asynchronous (the real `Bun.spawn` adapter) — the port awaits either way. + */ +export interface NmcliRunner { + run(argv: readonly string[]): NmcliResult | Promise; +} + +/** Await a runner's result whether it returned synchronously or as a promise. */ +export async function runNmcli(runner: NmcliRunner, argv: readonly string[]): Promise { + return runner.run(argv); +} + +/** + * The device-exact runner: spawns the real `nmcli` via Bun. Every argv the port + * builds is passed verbatim, so what the tests assert against the stateful stub is + * byte-for-byte what runs on the device. + */ +export class SpawnNmcliRunner implements NmcliRunner { + async run(argv: readonly string[]): Promise { + const proc = Bun.spawn(['nmcli', ...argv], { stdout: 'pipe', stderr: 'pipe' }); + const [stdout, stderr, exitCode] = await Promise.all([ + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + proc.exited, + ]); + return { stdout, stderr, exitCode }; + } +} + +/** Parse nmcli terse (`-t`) `key:value` lines into a map, splitting on the first `:`. */ +export function parseTerse(stdout: string): Map { + const settings = new Map(); + for (const line of stdout.split('\n')) { + const separator = line.indexOf(':'); + if (separator >= 0) { + settings.set(line.slice(0, separator), line.slice(separator + 1)); + } + } + return settings; +} diff --git a/control/src/backend/observer.ts b/control/src/backend/observer.ts new file mode 100644 index 0000000..e1df1a0 --- /dev/null +++ b/control/src/backend/observer.ts @@ -0,0 +1,297 @@ +// The epoch-scoped ModemManager observer. +// +// `MmDbusObserver` implements the read-only `ModemObservationPort` over the A2.4 +// transport, tested against the A2.3 fake. `start()` connects, subscribes to the four +// lifecycle signals, THEN takes the first authoritative `GetManagedObjects` snapshot — +// reconciling any signal that raced in between. +// +// SAFETY-CRITICAL — epoch-scoped removal (draft §Oracle round-3 #5). An "epoch" is one +// continuous ownership period of the MM bus name, tracked via `NameOwnerChanged`. A +// modem is REMOVED only when it is missing from a CURRENT-epoch authoritative snapshot. +// Owner loss, bus disconnect, and any signal whose `sender` is not the current owner +// (an OLD-epoch straggler) never remove a modem — they only ever mark it +// `sourceUnavailable`. The false-removal class is dead by construction: even the +// `ObservationList` failure arm retains its rows. Row bookkeeping lives in +// `ObservationRowStore`; this file owns epoch tracking, subscriptions, and refresh. + +import type { + ModemObservationPort, + ObservationFailureReason, + ObservationList, + ObservationListener, + Unsubscribe, +} from '../ports'; +import type { DbusTransport, SignalEvent, Subscription } from '../transport'; +import { + DBUS_DESTINATION, + DBUS_IFACE, + DBUS_PATH, + MM_BUS_NAME, + MM_ROOT_PATH, + OBJECT_MANAGER_IFACE, + PROPERTIES_IFACE, +} from './constants'; +import { asManagedObjects, type DecodedManagedObjects } from './managed-objects'; +import { ObservationRowStore } from './row-store'; + +/** + * A current-epoch authoritative snapshot, delivered to `onEpochRefresh` AFTER the + * epoch guard passes. `epoch` is the owning MM unique bus name; `tree` is the decoded + * `GetManagedObjects` payload the snapshot was reconciled from. The Signal.Setup + * manager (A3.3) hooks this to (re-)apply cadence per modem per epoch, and the D-Bus + * backend uses it to refresh its path→stable-key map. + */ +export interface EpochRefreshEvent { + readonly epoch: string; + readonly tree: DecodedManagedObjects; +} + +export interface MmDbusObserverOptions { + /** The transport to talk D-Bus over (A2.4). The observer connects it on `start()`. */ + readonly transport: DbusTransport; + /** MM bus name override (defaults to `org.freedesktop.ModemManager1`). */ + readonly destination?: string; + /** + * Called after EVERY successful current-epoch authoritative snapshot (start, + * hot-plug, epoch change, property change) — never for a superseded epoch. The + * hook fires whether or not any row changed, so a consumer always sees the live + * epoch + tree. It must not throw; a throw is swallowed so it can never break the + * observer's refresh loop. + */ + readonly onEpochRefresh?: (event: EpochRefreshEvent) => void; +} + +export class MmDbusObserver implements ModemObservationPort { + readonly #transport: DbusTransport; + readonly #destination: string; + readonly #onEpochRefresh: ((event: EpochRefreshEvent) => void) | undefined; + readonly #store = new ObservationRowStore(); + readonly #listeners = new Set(); + readonly #subscriptions: Subscription[] = []; + + #currentOwner: string | undefined; + #started = false; + #stopped = false; + #priming = false; + #refreshDuringPrime = false; + #refreshing = false; + #refreshQueued = false; + + readonly #onDisconnected = (): void => this.#handleSourceGone('source-unavailable'); + readonly #onReconnected = (): void => { + void this.#adoptCurrentOwner(); + }; + + constructor(options: MmDbusObserverOptions) { + this.#transport = options.transport; + this.#destination = options.destination ?? MM_BUS_NAME; + this.#onEpochRefresh = options.onEpochRefresh; + } + + async start(): Promise { + if (this.#started) { + return this.#store.list(); + } + this.#started = true; + this.#priming = true; + await this.#transport.connect(); + this.#transport.on('disconnected', this.#onDisconnected); + this.#transport.on('reconnected', this.#onReconnected); + await this.#subscribeAll(); + await this.#adoptCurrentOwner(); + this.#priming = false; + if (this.#refreshDuringPrime) { + this.#refreshDuringPrime = false; + this.#scheduleRefresh(); + } + return this.#store.list(); + } + + observe(listener: ObservationListener): Unsubscribe { + this.#listeners.add(listener); + return () => { + this.#listeners.delete(listener); + }; + } + + async stop(): Promise { + if (this.#stopped) { + return; + } + this.#stopped = true; + this.#transport.off('disconnected', this.#onDisconnected); + this.#transport.off('reconnected', this.#onReconnected); + const subs = this.#subscriptions.splice(0); + await Promise.all(subs.map((sub) => sub.unsubscribe().catch(() => undefined))); + this.#listeners.clear(); + } + + // ── signal subscription ──────────────────────────────────────────────────────── + + async #subscribeAll(): Promise { + const om = { interface: OBJECT_MANAGER_IFACE, path: MM_ROOT_PATH } as const; + this.#subscriptions.push( + await this.#transport.subscribeSignal({ ...om, member: 'InterfacesAdded' }, (event) => + this.#onObjectSignal(event), + ), + await this.#transport.subscribeSignal({ ...om, member: 'InterfacesRemoved' }, (event) => + this.#onObjectSignal(event), + ), + await this.#transport.subscribeSignal( + { interface: PROPERTIES_IFACE, member: 'PropertiesChanged' }, + (event) => this.#onObjectSignal(event), + ), + await this.#transport.subscribeSignal( + { interface: DBUS_IFACE, member: 'NameOwnerChanged' }, + (event) => this.#onNameOwnerChanged(event), + ), + ); + } + + // ── epoch tracking ─────────────────────────────────────────────────────────── + + async #adoptCurrentOwner(): Promise { + const owner = await this.#queryOwner(); + if (owner === undefined) { + this.#handleSourceGone('source-unavailable'); + return; + } + this.#currentOwner = owner; + await this.#runRefresh(owner); + } + + async #queryOwner(): Promise { + try { + const reply = await this.#transport.callMethod({ + destination: DBUS_DESTINATION, + path: DBUS_PATH, + interface: DBUS_IFACE, + member: 'GetNameOwner', + signature: 's', + args: [MM_BUS_NAME], + }); + const owner = reply.body[0]; + return typeof owner === 'string' && owner.length > 0 ? owner : undefined; + } catch { + // NameHasNoOwner (or a transient failure) → no current epoch yet. + return undefined; + } + } + + #onNameOwnerChanged(event: SignalEvent): void { + if (event.body[0] !== MM_BUS_NAME) { + return; + } + const newOwner = typeof event.body[2] === 'string' ? event.body[2] : ''; + if (newOwner.length === 0) { + // Owner lost — stale, never a removal. + this.#handleSourceGone('source-unavailable'); + return; + } + if (newOwner === this.#currentOwner) { + return; + } + // New epoch: everything goes stale until the fresh snapshot restores it. + if (this.#store.markUnavailable('source-unavailable')) { + this.#emit(); + } + this.#currentOwner = newOwner; + this.#scheduleRefresh(); + } + + #onObjectSignal(event: SignalEvent): void { + // Epoch guard: a signal from anyone but the current owner is an OLD-epoch + // straggler and must never drive a removal (draft §Oracle round-3 #5). + if (this.#currentOwner === undefined || event.sender !== this.#currentOwner) { + return; + } + if (this.#priming) { + this.#refreshDuringPrime = true; + return; + } + this.#scheduleRefresh(); + } + + // ── authoritative refresh ────────────────────────────────────────────────────── + + #scheduleRefresh(): void { + const owner = this.#currentOwner; + if (owner === undefined || this.#stopped) { + return; + } + if (this.#refreshing) { + this.#refreshQueued = true; + return; + } + void this.#runRefresh(owner); + } + + async #runRefresh(epochOwner: string): Promise { + this.#refreshing = true; + try { + const reply = await this.#transport.callMethod({ + destination: this.#destination, + path: MM_ROOT_PATH, + interface: OBJECT_MANAGER_IFACE, + member: 'GetManagedObjects', + }); + // Late reply from a superseded epoch — discard (draft §Oracle round-3 #5). + if (this.#currentOwner !== epochOwner || this.#stopped) { + return; + } + const tree = asManagedObjects(reply.body[0]); + const rowsChanged = this.#store.reconcile(tree); + const healthChanged = this.#store.markHealthy(); + this.#notifyEpochRefresh(epochOwner, tree); + if (rowsChanged || healthChanged) { + this.#emit(); + } + } catch { + if (this.#currentOwner === epochOwner && !this.#stopped) { + this.#markUnavailable('bus-error'); + } + } finally { + this.#refreshing = false; + if (this.#refreshQueued && !this.#stopped) { + this.#refreshQueued = false; + this.#scheduleRefresh(); + } + } + } + + // ── source-unavailable transitions ───────────────────────────────────────────── + + #handleSourceGone(reason: ObservationFailureReason): void { + this.#currentOwner = undefined; + this.#markUnavailable(reason); + } + + #markUnavailable(reason: ObservationFailureReason): void { + if (this.#store.markUnavailable(reason)) { + this.#emit(); + } + } + + #notifyEpochRefresh(epoch: string, tree: DecodedManagedObjects): void { + if (this.#onEpochRefresh === undefined) { + return; + } + try { + this.#onEpochRefresh({ epoch, tree }); + } catch { + // A consumer's hook must never break the observer's refresh loop. + } + } + + #emit(): void { + const list = this.#store.list(); + for (const listener of [...this.#listeners]) { + listener(list); + } + } +} + +/** Construct an epoch-scoped ModemManager observer over an A2.4 transport. */ +export function createMmDbusObserver(options: MmDbusObserverOptions): MmDbusObserver { + return new MmDbusObserver(options); +} diff --git a/control/src/backend/power-contract.test.ts b/control/src/backend/power-contract.test.ts new file mode 100644 index 0000000..8305173 --- /dev/null +++ b/control/src/backend/power-contract.test.ts @@ -0,0 +1,40 @@ +// Power contract — only `none` is implemented, so every capability returns +// `unsupported` in Phase A (ladder rung 4 never actuates on today's hardware). + +import { describe, expect, test } from 'bun:test'; +import { epochMillis } from '../domain'; +import { + NONE_POWER_CAPABILITY, + NONE_POWER_HOOK, + type PowerCapability, + unsupportedPowerHook, +} from './power-contract'; + +const context = { stableKey: 'slot:a', at: epochMillis(0) }; + +describe('power contract — Phase A', () => { + test('the none capability describes a no-op with an enumeration timeout', () => { + expect(NONE_POWER_CAPABILITY.power).toBe('none'); + expect(NONE_POWER_CAPABILITY.enumerationTimeoutMs).toBeGreaterThan(0); + }); + + test('NONE_POWER_HOOK.cycle always returns unsupported', async () => { + const result = await NONE_POWER_HOOK.cycle(context); + expect(result.status).toBe('unsupported'); + expect(result.reason).toContain('none'); + }); + + test('a real capability is typed but unsupported (declared, not implemented)', async () => { + const gpio: PowerCapability = { + power: 'gpio-cut', + usbReset: true, + enumerationTimeoutMs: 15_000, + preferredUsbMode: 'qmi', + }; + const hook = unsupportedPowerHook(gpio); + expect(hook.capability.power).toBe('gpio-cut'); + const result = await hook.cycle(context); + expect(result.status).toBe('unsupported'); + expect(result.reason).toContain('gpio-cut'); + }); +}); diff --git a/control/src/backend/power-contract.ts b/control/src/backend/power-contract.ts new file mode 100644 index 0000000..7d004ed --- /dev/null +++ b/control/src/backend/power-contract.ts @@ -0,0 +1,83 @@ +// The modem power-control capability contract — recovery ladder rung 4. +// +// Power control becomes a first-class v1 CONTRACT (draft §gap sweep: Sixfab GPIO26 +// power-cut + PWRKEY-pulse boards, BELABOX-reported RM520N USB instability needing a +// powered carrier). The FIELDS ship in Phase A; the board-specific GPIO / USB-hub +// IMPLEMENTATIONS stay hardware-gated. Only the `none` capability is implemented (a +// no-op), so ladder rung 4 always returns `unsupported` on today's hardware — every +// real mechanism is a typed-but-unsupported placeholder. + +import type { EpochMillis } from '../domain'; + +/** How a board can power-cycle a modem. Only `none` is implemented in Phase A. */ +export type PowerCapabilityKind = 'none' | 'gpio-cut' | 'pwrkey-pulse' | 'usb-hub-port-cycle'; + +/** + * The USB data mode a modem should re-enumerate into after a power cycle. A4.2 owns + * the certified USB-mode catalog; the power contract only records the operator's + * preferred post-cycle enumeration mode. + */ +export type PreferredUsbMode = 'qmi' | 'mbim' | 'ecm-ncm' | 'rndis' | 'router-ethernet'; + +/** A board's modem power-control capability description. */ +export interface PowerCapability { + readonly power: PowerCapabilityKind; + /** The board can pulse a USB-level reset on the modem's port. */ + readonly usbReset?: boolean; + /** How long to wait for the modem to re-enumerate after a cycle. */ + readonly enumerationTimeoutMs: number; + /** Which USB mode to prefer when the modem re-enumerates (A4.2 catalog vocabulary). */ + readonly preferredUsbMode?: PreferredUsbMode; +} + +/** The Phase-A capability: no board power control exists — a no-op. */ +export const NONE_POWER_CAPABILITY: PowerCapability = { + power: 'none', + enumerationTimeoutMs: 30_000, +}; + +/** Outcome of asking the power hook to cycle a modem. */ +export interface PowerCycleResult { + readonly status: 'applied' | 'unsupported' | 'failed'; + readonly reason: string; +} + +/** The minimal context handed to the power hook. */ +export interface PowerCycleContext { + readonly stableKey: string; + readonly at: EpochMillis; +} + +/** + * The pluggable power-cycle hook (recovery ladder rung 4). Phase A ships only the + * `none` no-op; a real GPIO / PWRKEY / USB-hub implementation is hardware-gated and + * injected later without changing the ladder. + */ +export interface PowerHook { + readonly capability: PowerCapability; + cycle(context: PowerCycleContext): Promise; +} + +/** + * Build a Phase-A power hook for `capability`. EVERY capability returns + * `unsupported`: `none` because there is nothing to cut, and every real mechanism + * because its hardware driver is not implemented yet — the contract field exists so + * a board can DECLARE the capability, but rung 4 never actuates in Phase A. + */ +export function unsupportedPowerHook(capability: PowerCapability): PowerHook { + return { + capability, + cycle(): Promise { + return Promise.resolve({ + status: 'unsupported', + reason: + capability.power === 'none' + ? "power capability 'none' — no board power control exists" + : `power capability '${capability.power}' is declared but not implemented in Phase A`, + }); + }, + }; +} + +/** The Phase-A default power hook: describes `none` and always returns `unsupported`. */ +export const NONE_POWER_HOOK: PowerHook = unsupportedPowerHook(NONE_POWER_CAPABILITY); diff --git a/control/src/backend/recovery-attribution.test.ts b/control/src/backend/recovery-attribution.test.ts new file mode 100644 index 0000000..8719884 --- /dev/null +++ b/control/src/backend/recovery-attribution.test.ts @@ -0,0 +1,102 @@ +// Fault attribution — pure classification with the stale-forces-indeterminate +// invariant as the headline safety test. + +import { describe, expect, test } from 'bun:test'; +import { imeiEquipmentId, initialSnapshot, type ModemIdentity, runtimePath } from '../domain'; +import { + attributeFault, + attributeSnapshot, + type FaultSymptoms, + symptomsFromSnapshot, +} from './recovery-attribution'; + +/** A live, present, otherwise-unremarkable symptom baseline. */ +function symptoms(overrides: Partial = {}): FaultSymptoms { + return { + sourceHealth: 'live', + presence: 'present', + mmState: 'connected', + registration: 'home', + nmActivation: 'activated', + ...overrides, + }; +} + +describe('attributeFault — confident classification', () => { + test('MM reporting a failed modem on a live source → modem-fault', () => { + expect(attributeFault(symptoms({ mmState: 'failed' }))).toBe('modem-fault'); + }); + + test('denied registration → network-fault', () => { + expect(attributeFault(symptoms({ registration: 'denied' }))).toBe('network-fault'); + }); + + test('registered but NM not activated (registered-but-no-data) → network-fault', () => { + expect(attributeFault(symptoms({ registration: 'home', nmActivation: 'failed' }))).toBe( + 'network-fault', + ); + expect( + attributeFault(symptoms({ registration: 'roaming', nmActivation: 'disconnected' })), + ).toBe('network-fault'); + }); + + test('healthy registered+activated modem → indeterminate (no clear fault)', () => { + expect(attributeFault(symptoms())).toBe('indeterminate'); + }); + + test('a searching modem is ambiguous → indeterminate', () => { + expect(attributeFault(symptoms({ mmState: 'searching', registration: 'searching' }))).toBe( + 'indeterminate', + ); + }); + + test('absent modem on a live source → indeterminate (nothing to attribute)', () => { + expect(attributeFault(symptoms({ presence: 'absent', mmState: 'unknown' }))).toBe( + 'indeterminate', + ); + }); +}); + +describe('attributeFault — HARD INVARIANT: stale / sourceUnavailable forces indeterminate', () => { + test('a STALE source forces indeterminate even when MM says the modem failed', () => { + // Without the invariant this would classify modem-fault and could authorise a + // disruptive step off data we no longer trust. + expect(attributeFault(symptoms({ sourceHealth: 'stale', mmState: 'failed' }))).toBe( + 'indeterminate', + ); + }); + + test('a sourceUnavailable source forces indeterminate even for a clear modem-fault', () => { + expect(attributeFault(symptoms({ sourceHealth: 'sourceUnavailable', mmState: 'failed' }))).toBe( + 'indeterminate', + ); + }); + + test('stale input never yields network-fault either', () => { + expect(attributeFault(symptoms({ sourceHealth: 'stale', registration: 'denied' }))).toBe( + 'indeterminate', + ); + }); +}); + +describe('symptomsFromSnapshot / attributeSnapshot', () => { + const identity: ModemIdentity = { + equipmentId: imeiEquipmentId('359000000000001'), + runtimePath: runtimePath('/org/freedesktop/ModemManager1/Modem/0'), + }; + + test('projects the attribution-relevant dimensions out of a snapshot', () => { + const snapshot = initialSnapshot(identity); + expect(symptomsFromSnapshot(snapshot)).toEqual({ + sourceHealth: 'live', + presence: 'absent', + mmState: 'unknown', + registration: 'unknown', + nmActivation: 'unavailable', + }); + }); + + test('attributeSnapshot on a fresh (absent) snapshot → indeterminate', () => { + expect(attributeSnapshot(initialSnapshot(identity))).toBe('indeterminate'); + }); +}); diff --git a/control/src/backend/recovery-attribution.ts b/control/src/backend/recovery-attribution.ts new file mode 100644 index 0000000..291d88b --- /dev/null +++ b/control/src/backend/recovery-attribution.ts @@ -0,0 +1,86 @@ +// Fault attribution — the safety gate the recovery ladder is built around. +// +// Before any disruptive recovery action, a fault MUST be attributed. Recovery may +// ONLY ever act on a confident `modem-fault`; `network-fault` and `indeterminate` +// never authorise a disruptive step (draft §Oracle recovery: "fault attribution +// required before disruptive action"). This module is pure — it classifies from a +// narrow projection of one snapshot and never performs I/O. + +import type { + CellularSnapshot, + MmState, + NmActivation, + Presence, + RegistrationStatus, + SourceHealth, +} from '../domain'; +import { isRegistered } from '../domain'; + +/** + * The confident classification of a modem fault: + * - `modem-fault` — the modem itself is broken (only this may be disruptive). + * - `network-fault` — attached to (or refused by) the network; the modem is fine. + * - `indeterminate` — ambiguous, or observed from an unreliable source. + */ +export type FaultAttribution = 'modem-fault' | 'network-fault' | 'indeterminate'; + +/** The narrow set of symptoms attribution reasons over. */ +export interface FaultSymptoms { + readonly sourceHealth: SourceHealth; + readonly presence: Presence; + readonly mmState: MmState; + readonly registration: RegistrationStatus; + readonly nmActivation: NmActivation; +} + +/** + * Classify a modem fault from observed symptoms. + * + * HARD SAFETY INVARIANT (draft §round-5 stale-forces-indeterminate): if the + * observation SOURCE is not `live` — i.e. `stale` or `sourceUnavailable`, as A3.1's + * epoch observer reports on owner loss / bus disconnect / old-epoch signals — the + * attribution is FORCED to `indeterminate`. We never guess `modem-fault` or + * `network-fault` from unreliable data. Because only a confident `modem-fault` can + * later authorise a disruptive step, forcing indeterminate here makes stale input + * strictly safe: no ladder step can fire off data we do not trust. + */ +export function attributeFault(symptoms: FaultSymptoms): FaultAttribution { + // 1. Unreliable source → never guess. (The invariant — checked first.) + if (symptoms.sourceHealth !== 'live') { + return 'indeterminate'; + } + // 2. Nothing present to attribute (and nothing to act on via D-Bus). + if (symptoms.presence === 'absent') { + return 'indeterminate'; + } + // 3. MM — a healthy source — reports THIS modem terminally failed → modem-fault. + if (symptoms.mmState === 'failed') { + return 'modem-fault'; + } + // 4. The network refused registration → network-fault (not the modem's fault). + if (symptoms.registration === 'denied') { + return 'network-fault'; + } + // 5. Registered to a network but data is not up → registered-but-no-data. + if (isRegistered(symptoms.registration) && symptoms.nmActivation !== 'activated') { + return 'network-fault'; + } + // 6. Anything else is ambiguous — stay safe. + return 'indeterminate'; +} + +/** Project the symptoms attribution needs out of a full snapshot. */ +export function symptomsFromSnapshot(snapshot: CellularSnapshot): FaultSymptoms { + return { + sourceHealth: snapshot.sourceHealth, + presence: snapshot.presence, + mmState: snapshot.mmState, + registration: snapshot.registration.status, + nmActivation: snapshot.nmActivation, + }; +} + +/** Attribute a fault directly from a snapshot (symptoms projection + classify). */ +export function attributeSnapshot(snapshot: CellularSnapshot): FaultAttribution { + return attributeFault(symptomsFromSnapshot(snapshot)); +} diff --git a/control/src/backend/recovery-budget.test.ts b/control/src/backend/recovery-budget.test.ts new file mode 100644 index 0000000..ba0815c --- /dev/null +++ b/control/src/backend/recovery-budget.test.ts @@ -0,0 +1,64 @@ +// Recovery budget — pure reducer proof: bounded attempts, cooldown refusal, and the +// loop-stop latch that marks a flapping modem degraded. + +import { describe, expect, test } from 'bun:test'; +import { epochMillis } from '../domain'; +import { + beginAttempt, + INITIAL_BUDGET_STATE, + markRecovered, + type RecoveryBudget, + type RecoveryBudgetState, +} from './recovery-budget'; + +const budget: RecoveryBudget = { maxAttempts: 2, cooldownMs: 1000 }; + +describe('beginAttempt — budget / cooldown / loop-stop', () => { + test('a fresh modem proceeds and increments attempts', () => { + const decision = beginAttempt(INITIAL_BUDGET_STATE, budget, epochMillis(0)); + expect(decision.kind).toBe('proceed'); + expect(decision.state.attempts).toBe(1); + expect(decision.state.lastAttemptAt).toBe(epochMillis(0)); + }); + + test('a second attempt sooner than cooldownMs is refused (not counted)', () => { + const first = beginAttempt(INITIAL_BUDGET_STATE, budget, epochMillis(0)); + const second = beginAttempt(first.state, budget, epochMillis(500)); + expect(second.kind).toBe('cooldown'); + // Attempts unchanged — a refused attempt does not spend budget. + expect(second.state.attempts).toBe(1); + if (second.kind === 'cooldown') { + expect(second.retryAfter).toBe(epochMillis(1000)); + } + }); + + test('exactly maxAttempts proceed, then the loop-stop latches degraded', () => { + let state: RecoveryBudgetState = INITIAL_BUDGET_STATE; + // Attempt 1 at t=0, attempt 2 at t=1000 (past cooldown) both proceed. + const a1 = beginAttempt(state, budget, epochMillis(0)); + expect(a1.kind).toBe('proceed'); + state = a1.state; + const a2 = beginAttempt(state, budget, epochMillis(1000)); + expect(a2.kind).toBe('proceed'); + state = a2.state; + expect(state.attempts).toBe(2); + // Attempt 3 past cooldown: budget spent → loop-stop, degraded latched. + const a3 = beginAttempt(state, budget, epochMillis(2000)); + expect(a3.kind).toBe('loop-stop'); + expect(a3.state.degraded).toBe(true); + }); + + test('once degraded, every further attempt is loop-stopped (latched)', () => { + const degraded: RecoveryBudgetState = { attempts: 2, degraded: true }; + const again = beginAttempt(degraded, budget, epochMillis(999_999)); + expect(again.kind).toBe('loop-stop'); + expect(again.state.degraded).toBe(true); + }); + + test('markRecovered clears the counter and the degraded latch', () => { + expect(markRecovered()).toEqual(INITIAL_BUDGET_STATE); + const reset = markRecovered(); + expect(reset.attempts).toBe(0); + expect(reset.degraded).toBe(false); + }); +}); diff --git a/control/src/backend/recovery-budget.ts b/control/src/backend/recovery-budget.ts new file mode 100644 index 0000000..be525be --- /dev/null +++ b/control/src/backend/recovery-budget.ts @@ -0,0 +1,84 @@ +// Recovery budget — the bounded-attempts / cooldown / loop-stop circuit breaker. +// +// A permanently-broken modem must not be recovered forever. The budget caps how many +// attempts may fire within a cooldown window; once the cap is spent it latches the +// modem `degraded` (the loop-stop), and no further attempt is allowed until an +// explicit reset. This module is a PURE reducer: it owns no clock and mutates +// nothing — the caller supplies `now` and stores the returned next state. + +import type { EpochMillis } from '../domain'; + +/** + * Recovery budget. `maxAttempts` recovery attempts are allowed before the loop-stop + * engages; two attempts sooner than `cooldownMs` apart are refused (cooldown). + */ +export interface RecoveryBudget { + readonly maxAttempts: number; + readonly cooldownMs: number; +} + +/** The Phase-A default: two attempts, 30s cooldown between them. */ +export const DEFAULT_RECOVERY_BUDGET: RecoveryBudget = { + maxAttempts: 2, + cooldownMs: 30_000, +}; + +/** + * Per-modem budget bookkeeping. `degraded` is a LATCH: once the loop-stop fires it + * stays set until an explicit `markRecovered`, so a flapping modem is left degraded + * rather than retried forever. + */ +export interface RecoveryBudgetState { + readonly attempts: number; + readonly degraded: boolean; + readonly lastAttemptAt?: EpochMillis; +} + +/** A fresh, un-attempted budget state. */ +export const INITIAL_BUDGET_STATE: RecoveryBudgetState = { + attempts: 0, + degraded: false, +}; + +/** The verdict on whether a recovery attempt may proceed, plus the next state. */ +export type BudgetDecision = + | { readonly kind: 'proceed'; readonly state: RecoveryBudgetState } + | { + readonly kind: 'cooldown'; + readonly state: RecoveryBudgetState; + readonly retryAfter: EpochMillis; + } + | { readonly kind: 'loop-stop'; readonly state: RecoveryBudgetState }; + +/** + * Decide whether a recovery attempt may proceed at `now`, returning the NEXT state: + * - already `degraded` → loop-stop (latched; zero further attempts) + * - within `cooldownMs` of last → cooldown (refused; attempts NOT incremented) + * - budget already spent → loop-stop (latch `degraded`) + * - otherwise → proceed (attempts + 1; stamp `lastAttemptAt`) + */ +export function beginAttempt( + state: RecoveryBudgetState, + budget: RecoveryBudget, + now: EpochMillis, +): BudgetDecision { + if (state.degraded) { + return { kind: 'loop-stop', state }; + } + if (state.lastAttemptAt !== undefined && now - state.lastAttemptAt < budget.cooldownMs) { + const retryAfter = (state.lastAttemptAt + budget.cooldownMs) as EpochMillis; + return { kind: 'cooldown', state, retryAfter }; + } + if (state.attempts >= budget.maxAttempts) { + return { kind: 'loop-stop', state: { ...state, degraded: true } }; + } + return { + kind: 'proceed', + state: { attempts: state.attempts + 1, degraded: false, lastAttemptAt: now }, + }; +} + +/** A successful recovery clears the counter and the degraded latch. */ +export function markRecovered(): RecoveryBudgetState { + return INITIAL_BUDGET_STATE; +} diff --git a/control/src/backend/recovery-ladder.test.ts b/control/src/backend/recovery-ladder.test.ts new file mode 100644 index 0000000..af63681 --- /dev/null +++ b/control/src/backend/recovery-ladder.test.ts @@ -0,0 +1,257 @@ +// The recovery ladder — the safety gates are the whole point: +// - disabled by default ⇒ literally ZERO side effects (throwing spies prove it) +// - only a confident modem-fault may ever be disruptive +// - budget → loop-stop marks a flapping modem degraded, never retried forever +// - a successful step restores health and resets the budget +// - rung 4 (power) is always unsupported in Phase A +// - disruptive rungs route through the shared per-modem actor (serialisation) + +import { describe, expect, test } from 'bun:test'; +import { type DesiredRecovery, epochMillis, runtimePath } from '../domain'; +import { ALLOW_ALL_INTERLOCK, type LifecycleInterlock } from './lifecycle-interlock'; +import { ModemActor } from './modem-actor'; +import { NONE_POWER_CAPABILITY, type PowerHook } from './power-contract'; +import { DEFAULT_RECOVERY_BUDGET } from './recovery-budget'; +import { + RecoveryLadder, + type RecoveryLadderConfig, + type RecoveryRequest, + type RecoverySteps, + type StepOutcome, +} from './recovery-ladder'; + +const ENABLED: DesiredRecovery = { enabled: true }; +const DISABLED: DesiredRecovery = { enabled: false }; + +const sleep = (ms: number): Promise => new Promise((resolve) => setTimeout(resolve, ms)); + +interface StepCalls { + nmCycle: number; + mmCycle: number; + reset: number; +} + +/** Steps that count calls and return a fixed outcome. */ +function countingSteps(outcome: StepOutcome, calls: StepCalls): RecoverySteps { + return { + nmCycle: () => { + calls.nmCycle += 1; + return Promise.resolve(outcome); + }, + mmCycle: () => { + calls.mmCycle += 1; + return Promise.resolve(outcome); + }, + reset: () => { + calls.reset += 1; + return Promise.resolve(outcome); + }, + }; +} + +/** Steps that throw if ever invoked — the strongest "zero side effects" proof. */ +const throwingSteps: RecoverySteps = { + nmCycle: () => Promise.reject(new Error('nmCycle must not fire')), + mmCycle: () => Promise.reject(new Error('mmCycle must not fire')), + reset: () => Promise.reject(new Error('reset must not fire')), +}; + +const throwingPowerHook: PowerHook = { + capability: NONE_POWER_CAPABILITY, + cycle: () => Promise.reject(new Error('power hook must not fire')), +}; + +const throwingInterlock: LifecycleInterlock = { + canDisrupt: () => Promise.reject(new Error('interlock must not be consulted')), +}; + +function makeRequest(overrides: Partial = {}): RecoveryRequest { + return { + stableKey: 'slot:a', + modem: runtimePath('/org/freedesktop/ModemManager1/Modem/0'), + attribution: 'modem-fault', + now: epochMillis(0), + probeHealthy: () => Promise.resolve(false), + ...overrides, + }; +} + +describe('RecoveryLadder — disabled by default fires ZERO steps', () => { + test('recovery.enabled=false makes zero side-effecting calls, even for a clear modem-fault', async () => { + const ladder = new RecoveryLadder({ + actor: new ModemActor(), + steps: throwingSteps, + powerHook: throwingPowerHook, + interlock: throwingInterlock, + }); + // If ANY of the throwing spies leaked through, run() would reject here. + const outcome = await ladder.run(DISABLED, makeRequest({ attribution: 'modem-fault' })); + expect(outcome.kind).toBe('disabled'); + expect(outcome.steps).toEqual([]); + expect(outcome.degraded).toBe(false); + }); +}); + +describe('RecoveryLadder — attribution gating (never disrupt on non-modem-fault)', () => { + for (const attribution of ['indeterminate', 'network-fault'] as const) { + test(`enabled + '${attribution}' fires zero disruptive steps`, async () => { + const ladder = new RecoveryLadder({ + actor: new ModemActor(), + steps: throwingSteps, + powerHook: throwingPowerHook, + interlock: throwingInterlock, + }); + const outcome = await ladder.run(ENABLED, makeRequest({ attribution })); + expect(outcome.kind).toBe('not-attributed'); + expect(outcome.steps).toEqual([]); + }); + } +}); + +describe('RecoveryLadder — budget, cooldown, loop-stop', () => { + test('a flapping modem gets EXACTLY 2 attempts, then loop-stop marks it degraded', async () => { + const calls: StepCalls = { nmCycle: 0, mmCycle: 0, reset: 0 }; + const ladder = new RecoveryLadder({ + actor: new ModemActor(), + steps: countingSteps({ status: 'failed', reason: 'still broken' }, calls), + config: { ...allowAll(), budget: { maxAttempts: 2, cooldownMs: 1000 } }, + }); + + const a1 = await ladder.run(ENABLED, makeRequest({ now: epochMillis(0) })); + const a2 = await ladder.run(ENABLED, makeRequest({ now: epochMillis(1000) })); + const a3 = await ladder.run(ENABLED, makeRequest({ now: epochMillis(2000) })); + + expect(a1.kind).toBe('exhausted'); + expect(a2.kind).toBe('exhausted'); + expect(a3.kind).toBe('loop-stop'); + // Rung 1 fired exactly twice — the third invocation short-circuited. + expect(calls.nmCycle).toBe(2); + expect(a3.degraded).toBe(true); + expect(ladder.budgetStateFor('slot:a').degraded).toBe(true); + }); + + test('a second attempt within the cooldown window is refused with zero steps', async () => { + const calls: StepCalls = { nmCycle: 0, mmCycle: 0, reset: 0 }; + const ladder = new RecoveryLadder({ + actor: new ModemActor(), + steps: countingSteps({ status: 'failed', reason: 'still broken' }, calls), + config: { ...allowAll(), budget: { maxAttempts: 3, cooldownMs: 1000 } }, + }); + await ladder.run(ENABLED, makeRequest({ now: epochMillis(0) })); + const soon = await ladder.run(ENABLED, makeRequest({ now: epochMillis(500) })); + expect(soon.kind).toBe('cooldown'); + expect(calls.nmCycle).toBe(1); + }); +}); + +describe('RecoveryLadder — a successful step restores health', () => { + test('rung 1 succeeding + a healthy probe → recovered, later rungs never run, budget reset', async () => { + const calls: StepCalls = { nmCycle: 0, mmCycle: 0, reset: 0 }; + const ladder = new RecoveryLadder({ + actor: new ModemActor(), + steps: countingSteps({ status: 'applied', reason: 'nm reactivated' }, calls), + }); + const outcome = await ladder.run( + ENABLED, + makeRequest({ probeHealthy: () => Promise.resolve(true) }), + ); + expect(outcome.kind).toBe('recovered'); + expect(calls.nmCycle).toBe(1); + expect(calls.mmCycle).toBe(0); + expect(calls.reset).toBe(0); + expect(ladder.budgetStateFor('slot:a').attempts).toBe(0); + expect(outcome.degraded).toBe(false); + }); +}); + +describe('RecoveryLadder — rung 4 (power) is always unsupported', () => { + test('with rungs 1-3 gated off, the power rung runs and reports unsupported', async () => { + const ladder = new RecoveryLadder({ + actor: new ModemActor(), + // Throwing steps prove rungs 1-3 are skipped (allowDisruptive:false), not run. + steps: throwingSteps, + config: { + nmCycle: { allowDisruptive: false }, + mmCycle: { allowDisruptive: false }, + reset: { allowDisruptive: false }, + powerCycle: { allowDisruptive: true }, + budget: DEFAULT_RECOVERY_BUDGET, + }, + }); + const outcome = await ladder.run(ENABLED, makeRequest()); + expect(outcome.kind).toBe('exhausted'); + const power = outcome.steps.find((s) => s.rung === 'powerCycle'); + expect(power?.status).toBe('unsupported'); + expect(power?.reason).toContain('none'); + // The gated rungs are reported skipped, never executed. + expect(outcome.steps.filter((s) => s.status === 'skipped').map((s) => s.rung)).toEqual([ + 'nmCycle', + 'mmCycle', + 'reset', + ]); + }); +}); + +describe('RecoveryLadder — interlock blocks disruption', () => { + test('a denying interlock stops the ladder before any step fires', async () => { + const calls: StepCalls = { nmCycle: 0, mmCycle: 0, reset: 0 }; + const denying: LifecycleInterlock = { + canDisrupt: () => Promise.resolve({ allow: false, reason: 'modem is streaming' }), + }; + const ladder = new RecoveryLadder({ + actor: new ModemActor(), + steps: countingSteps({ status: 'applied', reason: 'x' }, calls), + interlock: denying, + }); + const outcome = await ladder.run(ENABLED, makeRequest()); + expect(outcome.kind).toBe('interlock-blocked'); + expect(calls.nmCycle).toBe(0); + expect(outcome.steps[0]?.status).toBe('blocked'); + expect(outcome.reason).toContain('streaming'); + }); +}); + +describe('RecoveryLadder — disruptive steps route through the shared actor', () => { + test('two same-key recoveries serialise their steps (no interleave)', async () => { + const order: string[] = []; + const slowSteps: RecoverySteps = { + nmCycle: async () => { + order.push('start'); + await sleep(20); + order.push('end'); + return { status: 'failed', reason: 'still broken' }; + }, + mmCycle: () => Promise.resolve({ status: 'failed', reason: 'x' }), + reset: () => Promise.resolve({ status: 'failed', reason: 'x' }), + }; + const ladder = new RecoveryLadder({ + actor: new ModemActor(), + // Only rung 1 is enabled so the serialisation of nmCycle is unambiguous. + steps: slowSteps, + interlock: ALLOW_ALL_INTERLOCK, + config: { + nmCycle: { allowDisruptive: true }, + mmCycle: { allowDisruptive: false }, + reset: { allowDisruptive: false }, + powerCycle: { allowDisruptive: false }, + budget: { maxAttempts: 5, cooldownMs: 0 }, + }, + }); + await Promise.all([ + ladder.run(ENABLED, makeRequest({ now: epochMillis(0) })), + ladder.run(ENABLED, makeRequest({ now: epochMillis(1) })), + ]); + // Serialised through the actor ⇒ the first nmCycle fully completes before the second. + expect(order).toEqual(['start', 'end', 'start', 'end']); + }); +}); + +/** Every rung allowed — the shared base for budget-focused configs. */ +function allowAll(): Omit { + return { + nmCycle: { allowDisruptive: true }, + mmCycle: { allowDisruptive: true }, + reset: { allowDisruptive: true }, + powerCycle: { allowDisruptive: true }, + }; +} diff --git a/control/src/backend/recovery-ladder.ts b/control/src/backend/recovery-ladder.ts new file mode 100644 index 0000000..f7f5811 --- /dev/null +++ b/control/src/backend/recovery-ladder.ts @@ -0,0 +1,249 @@ +// The evidence-gated recovery ladder — disabled by default. +// +// A bounded four-rung ladder [1 nm-cycle: NM deactivate→reactivate exact pair; 2 +// mm-cycle: MM disable→enable; 3 reset: MM Reset(); 4 power-cycle: power hook (only +// `none` → always unsupported)]. Every disruptive rung routes through A3.3's shared +// per-modem `ModemActor` (serialised behind all other disruptive ops) and consults +// the `LifecycleInterlock` first so it never disrupts a streaming modem. GATES, in +// order: recovery.enabled=false → zero steps · attribution≠modem-fault → zero steps · +// budget spent / cooldown → zero steps · per-rung allowDisruptive · interlock. +// Disabled or un-attributed, NOT ONE side-effecting call is made. + +import type { DesiredRecovery, EpochMillis } from '../domain'; +import type { ModemRef } from '../ports'; +import { ALLOW_ALL_INTERLOCK, type LifecycleInterlock } from './lifecycle-interlock'; +import type { ModemActor } from './modem-actor'; +import { NONE_POWER_HOOK, type PowerHook } from './power-contract'; +import type { FaultAttribution } from './recovery-attribution'; +import { + beginAttempt, + DEFAULT_RECOVERY_BUDGET, + INITIAL_BUDGET_STATE, + markRecovered, + type RecoveryBudget, + type RecoveryBudgetState, +} from './recovery-budget'; + +export const LADDER_ORDER = ['nmCycle', 'mmCycle', 'reset', 'powerCycle'] as const; +export type RecoveryRung = (typeof LADDER_ORDER)[number]; + +/** Context handed to each disruptive rung. */ +export interface RecoveryStepContext { + readonly stableKey: string; + readonly modem: ModemRef; + readonly at: EpochMillis; +} + +/** Outcome of a disruptive rung (1–3). */ +export interface StepOutcome { + readonly status: 'applied' | 'failed'; + readonly reason: string; +} + +/** + * The disruptive recovery actions for rungs 1–3. Each is the raw effect only — the + * ladder wraps every call in `actor.run(stableKey, …)`, so serialisation is the + * ladder's job, not the step's. Phase A injects a fake in tests; the real D-Bus / NM + * implementation (hardware-gated) is wired at the composition root. + */ +export interface RecoverySteps { + /** Rung 1: NM deactivate then reactivate — an EXACT pair, never a bare deactivate. */ + nmCycle(context: RecoveryStepContext): Promise; + /** Rung 2: MM disable then enable. */ + mmCycle(context: RecoveryStepContext): Promise; + /** Rung 3: MM `Reset()`. */ + reset(context: RecoveryStepContext): Promise; +} + +export interface RecoveryStepGate { + readonly allowDisruptive: boolean; +} + +/** The ladder's operational config: per-rung gates + the attempt budget. */ +export interface RecoveryLadderConfig { + readonly nmCycle: RecoveryStepGate; + readonly mmCycle: RecoveryStepGate; + readonly reset: RecoveryStepGate; + readonly powerCycle: RecoveryStepGate; + readonly budget: RecoveryBudget; +} + +const ALLOW: RecoveryStepGate = { allowDisruptive: true }; + +/** Default config: every rung permitted, default budget. */ +export const DEFAULT_LADDER_CONFIG: RecoveryLadderConfig = { + nmCycle: ALLOW, + mmCycle: ALLOW, + reset: ALLOW, + powerCycle: ALLOW, + budget: DEFAULT_RECOVERY_BUDGET, +}; + +export interface RecoveryStepReport { + readonly rung: RecoveryRung; + readonly status: 'applied' | 'unsupported' | 'failed' | 'skipped' | 'blocked'; + readonly reason: string; +} + +/** How a whole ladder invocation ended. */ +export type RecoveryOutcomeKind = + | 'disabled' + | 'not-attributed' + | 'cooldown' + | 'loop-stop' + | 'interlock-blocked' + | 'recovered' + | 'exhausted'; + +/** The result of one ladder invocation. */ +export interface RecoveryOutcome { + readonly kind: RecoveryOutcomeKind; + readonly attribution: FaultAttribution; + readonly steps: readonly RecoveryStepReport[]; + readonly degraded: boolean; + readonly reason: string; +} + +/** One recovery request for one modem at one instant. */ +export interface RecoveryRequest { + readonly stableKey: string; + readonly modem: ModemRef; + /** The pre-computed attribution (see `attributeFault`). */ + readonly attribution: FaultAttribution; + readonly now: EpochMillis; + /** Re-checked AFTER an applied rung; `true` ⇒ the modem recovered (ladder stops). */ + readonly probeHealthy: () => Promise; +} + +/** Dependencies the ladder is constructed with. */ +export interface RecoveryLadderDeps { + readonly actor: ModemActor; + readonly steps: RecoverySteps; + readonly powerHook?: PowerHook; + readonly interlock?: LifecycleInterlock; + readonly config?: RecoveryLadderConfig; +} + +/** + * The recovery ladder. Holds per-modem budget state keyed by stable key so the + * loop-stop can latch a flapping modem `degraded` across invocations. + */ +export class RecoveryLadder { + readonly #actor: ModemActor; + readonly #steps: RecoverySteps; + readonly #powerHook: PowerHook; + readonly #interlock: LifecycleInterlock; + readonly #config: RecoveryLadderConfig; + readonly #states = new Map(); + + constructor(deps: RecoveryLadderDeps) { + this.#actor = deps.actor; + this.#steps = deps.steps; + this.#powerHook = deps.powerHook ?? NONE_POWER_HOOK; + this.#interlock = deps.interlock ?? ALLOW_ALL_INTERLOCK; + this.#config = deps.config ?? DEFAULT_LADDER_CONFIG; + } + + /** The current budget state for a modem (for observability / assertions). */ + budgetStateFor(stableKey: string): RecoveryBudgetState { + return this.#states.get(stableKey) ?? INITIAL_BUDGET_STATE; + } + + /** Clear a modem's budget / degraded latch (operator un-degrade). */ + clear(stableKey: string): void { + this.#states.delete(stableKey); + } + + /** Run one recovery attempt. Enforces every gate before any side effect. */ + async run(recovery: DesiredRecovery, request: RecoveryRequest): Promise { + const { stableKey, attribution } = request; + + // GATE 1 — master switch. Disabled ⇒ literally zero steps, zero side effects. + if (!recovery.enabled) { + return this.#end('disabled', request, [], 'recovery disabled by policy'); + } + // GATE 2 — attribution. Only a confident modem-fault may ever be disruptive. + if (attribution !== 'modem-fault') { + return this.#end( + 'not-attributed', + request, + [], + `attribution '${attribution}' is never disruptive`, + ); + } + // GATE 3 — budget / cooldown / loop-stop. + const decision = beginAttempt(this.budgetStateFor(stableKey), this.#config.budget, request.now); + if (decision.kind === 'loop-stop') { + this.#states.set(stableKey, decision.state); + return this.#end( + 'loop-stop', + request, + [], + 'recovery budget exhausted — modem marked degraded', + ); + } + if (decision.kind === 'cooldown') { + return this.#end('cooldown', request, [], 'within cooldown window — attempt refused'); + } + this.#states.set(stableKey, decision.state); + + return this.#runRungs(request); + } + + async #runRungs(request: RecoveryRequest): Promise { + const { stableKey } = request; + const context: RecoveryStepContext = { stableKey, modem: request.modem, at: request.now }; + const steps: RecoveryStepReport[] = []; + + for (const rung of LADDER_ORDER) { + if (!this.#config[rung].allowDisruptive) { + steps.push({ rung, status: 'skipped', reason: 'allowDisruptive is false' }); + continue; + } + // Interlock BEFORE any disruptive step — never disrupt a streaming modem. + const verdict = await this.#interlock.canDisrupt({ stableKey }); + if (!verdict.allow) { + steps.push({ rung, status: 'blocked', reason: verdict.reason }); + return this.#end( + 'interlock-blocked', + request, + steps, + `interlock blocked '${rung}': ${verdict.reason}`, + ); + } + const report = await this.#runRung(rung, context); + steps.push(report); + if (report.status === 'applied' && (await request.probeHealthy())) { + this.#states.set(stableKey, markRecovered()); + return this.#end('recovered', request, steps, `recovered at rung '${rung}'`); + } + } + return this.#end('exhausted', request, steps, 'ladder exhausted without restoring health'); + } + + async #runRung(rung: RecoveryRung, context: RecoveryStepContext): Promise { + if (rung === 'powerCycle') { + const result = await this.#powerHook.cycle({ stableKey: context.stableKey, at: context.at }); + return { rung, status: result.status, reason: result.reason }; + } + // Rungs 1–3 route through the shared per-modem actor for serialisation. + const step = this.#steps[rung]; + const outcome = await this.#actor.run(context.stableKey, () => step(context)); + return { rung, status: outcome.status, reason: outcome.reason }; + } + + #end( + kind: RecoveryOutcomeKind, + request: RecoveryRequest, + steps: readonly RecoveryStepReport[], + reason: string, + ): RecoveryOutcome { + return { + kind, + attribution: request.attribution, + steps, + degraded: this.budgetStateFor(request.stableKey).degraded, + reason, + }; + } +} diff --git a/control/src/backend/router-ethernet.test.ts b/control/src/backend/router-ethernet.test.ts new file mode 100644 index 0000000..982bcfb --- /dev/null +++ b/control/src/backend/router-ethernet.test.ts @@ -0,0 +1,71 @@ +// The router-ethernet probe is ADVISORY: presence, gateway reachability, and egress +// health are reported, never gated. `checkHealth` must never throw — a throwing probe +// degrades to `false`, it does not blow up the caller. + +import { describe, expect, test } from 'bun:test'; +import { deviceIfname } from '../ports'; +import { createRouterEthernetProbe, type RouterEthernetProbeDeps } from './router-ethernet'; + +const IFNAME = deviceIfname('eth1'); + +function probeWith(overrides: RouterEthernetProbeDeps) { + return createRouterEthernetProbe(overrides); +} + +describe('createRouterEthernetProbe — presence', () => { + test('a link that is up is present; a link that is down is absent', async () => { + const up = probeWith({ checkLinkUp: () => Promise.resolve(true) }); + const down = probeWith({ checkLinkUp: () => Promise.resolve(false) }); + expect(await up.probePresence(IFNAME)).toBe('present'); + expect(await down.probePresence(IFNAME)).toBe('absent'); + }); +}); + +describe('createRouterEthernetProbe — advisory health', () => { + test('present + gateway + egress all healthy', async () => { + const probe = probeWith({ + checkLinkUp: () => Promise.resolve(true), + resolveGateway: () => Promise.resolve('192.168.8.1'), + ping: () => Promise.resolve(true), + }); + const health = await probe.checkHealth(IFNAME); + expect(health.presence).toBe('present'); + expect(health.gatewayReachable).toBe(true); + expect(health.egressHealthy).toBe(true); + }); + + test('present but no gateway → degraded health, but still returned (not thrown)', async () => { + const probe = probeWith({ + checkLinkUp: () => Promise.resolve(true), + resolveGateway: () => Promise.resolve(undefined), + ping: () => Promise.resolve(true), + }); + const health = await probe.checkHealth(IFNAME); + expect(health.presence).toBe('present'); + expect(health.gatewayReachable).toBe(false); + expect(health.egressHealthy).toBe(false); + }); + + test('gateway reachable but egress down → egressHealthy false only', async () => { + const probe = probeWith({ + checkLinkUp: () => Promise.resolve(true), + resolveGateway: () => Promise.resolve('192.168.8.1'), + ping: (host) => Promise.resolve(host === '192.168.8.1'), + }); + const health = await probe.checkHealth(IFNAME); + expect(health.gatewayReachable).toBe(true); + expect(health.egressHealthy).toBe(false); + }); + + test('checkHealth NEVER throws, even when a probe throws', async () => { + const probe = probeWith({ + checkLinkUp: () => Promise.reject(new Error('ip crashed')), + resolveGateway: () => Promise.reject(new Error('route crashed')), + ping: () => Promise.reject(new Error('ping crashed')), + }); + const health = await probe.checkHealth(IFNAME); + expect(health.presence).toBe('absent'); + expect(health.gatewayReachable).toBe(false); + expect(health.egressHealthy).toBe(false); + }); +}); diff --git a/control/src/backend/router-ethernet.ts b/control/src/backend/router-ethernet.ts new file mode 100644 index 0000000..3174001 --- /dev/null +++ b/control/src/backend/router-ethernet.ts @@ -0,0 +1,90 @@ +// Generic router-ethernet detection — presence, DHCP-gateway reachability, and a +// basic egress-health probe for uplinks ModemManager cannot control (HiLink, RNDIS +// tether, full router firmware). +// +// EVERYTHING here is ADVISORY (ports/router.ts, matrix §1-R): health degradation is +// informational only. `checkHealth` NEVER throws and NEVER gates anything — a +// degraded router stays in the routing set; the controller merely reports what it +// observed. Each probe is an injectable seam (default `Bun.spawn` of `ip` / `ping`) +// so tests run with no real network. + +import { type EpochMillis, epochMillis } from '../domain'; +import type { DeviceIfname, RouterHealth, RouterPort, RouterPresence } from '../ports'; + +/** Injectable probes — each defaults to a `Bun.spawn` shell-out; all advisory. */ +export interface RouterEthernetProbeDeps { + readonly now?: () => EpochMillis; + /** Whether `ifname` exists and is administratively up. */ + readonly checkLinkUp?: (ifname: DeviceIfname) => Promise; + /** The DHCP-assigned default gateway on `ifname`, if any. */ + readonly resolveGateway?: (ifname: DeviceIfname) => Promise; + /** Whether `host` is reachable via `ifname` (ICMP). */ + readonly ping?: (host: string, ifname: DeviceIfname) => Promise; +} + +/** The host used for the basic egress-health probe (public DNS anycast). */ +const EGRESS_PROBE_HOST = '1.1.1.1'; + +async function spawnSucceeds(command: readonly string[]): Promise { + try { + const proc = Bun.spawn([...command], { stdout: 'pipe', stderr: 'pipe' }); + return (await proc.exited) === 0; + } catch { + return false; + } +} + +async function spawnOutput(command: readonly string[]): Promise { + try { + const proc = Bun.spawn([...command], { stdout: 'pipe', stderr: 'pipe' }); + const [out, code] = await Promise.all([new Response(proc.stdout).text(), proc.exited]); + return code === 0 ? out : ''; + } catch { + return ''; + } +} + +function defaultCheckLinkUp(ifname: DeviceIfname): Promise { + return spawnSucceeds(['ip', 'link', 'show', 'up', 'dev', String(ifname)]); +} + +async function defaultResolveGateway(ifname: DeviceIfname): Promise { + // `ip -o route show default dev ` → "default via 192.168.8.1 dev …". + const out = await spawnOutput(['ip', '-o', 'route', 'show', 'default', 'dev', String(ifname)]); + const match = out.match(/default via (\S+)/); + return match?.[1]; +} + +function defaultPing(host: string, ifname: DeviceIfname): Promise { + return spawnSucceeds(['ping', '-c', '1', '-W', '2', '-I', String(ifname), host]); +} + +/** + * Create a router-ethernet probe implementing the advisory `RouterPort`. Presence is + * link-up; health additionally reports DHCP-gateway reachability and a basic egress + * probe — all informational, never a gate. `checkHealth` swallows every error into a + * `false` field so it can never throw. + */ +export function createRouterEthernetProbe(deps: RouterEthernetProbeDeps = {}): RouterPort { + const now = deps.now ?? ((): EpochMillis => epochMillis(Date.now())); + const checkLinkUp = deps.checkLinkUp ?? defaultCheckLinkUp; + const resolveGateway = deps.resolveGateway ?? defaultResolveGateway; + const ping = deps.ping ?? defaultPing; + + async function probePresence(ifname: DeviceIfname): Promise { + return (await checkLinkUp(ifname).catch(() => false)) ? 'present' : 'absent'; + } + + async function checkHealth(ifname: DeviceIfname): Promise { + const presence = await probePresence(ifname); + const gateway = + presence === 'present' ? await resolveGateway(ifname).catch(() => undefined) : undefined; + const gatewayReachable = + gateway !== undefined && (await ping(gateway, ifname).catch(() => false)); + const egressHealthy = + gatewayReachable && (await ping(EGRESS_PROBE_HOST, ifname).catch(() => false)); + return { presence, gatewayReachable, egressHealthy, observedAt: now() }; + } + + return { probePresence, checkHealth }; +} diff --git a/control/src/backend/row-store.ts b/control/src/backend/row-store.ts new file mode 100644 index 0000000..0dc6d52 --- /dev/null +++ b/control/src/backend/row-store.ts @@ -0,0 +1,105 @@ +// The observer's row bookkeeping — revisions, source health, and the discriminated +// list result — split out of the observer so each file stays focused. +// +// Removal lives ONLY in `reconcile`: a modem is dropped exactly when a current-epoch +// authoritative snapshot omits it. `markUnavailable` never removes — it flags rows +// stale and retains them, so the `ObservationList` failure arm always carries its rows. + +import { + type CellularSnapshot, + createSnapshot, + revision as makeRevision, + markSourceUnavailable, + type Revision, +} from '../domain'; +import type { ObservationFailureReason, ObservationList } from '../ports'; +import type { DecodedManagedObjects } from './managed-objects'; +import { fingerprint, type MappedModem, mapModem, modemPaths } from './mapping'; + +interface Row { + snapshot: CellularSnapshot; + fingerprint: string; +} + +export class ObservationRowStore { + readonly #rows = new Map(); + #revCounter = 0; + #sourceHealthy = false; + #failureReason: ObservationFailureReason = 'not-started'; + + /** Reconcile the authoritative tree into the rows. Returns whether rows changed. + * This is the SOLE removal path — an omission from a current-epoch snapshot. */ + reconcile(tree: DecodedManagedObjects): boolean { + let changed = false; + const seen = new Set(); + for (const path of modemPaths(tree)) { + seen.add(path); + if (this.#upsert(path, mapModem(tree, path))) { + changed = true; + } + } + for (const path of [...this.#rows.keys()]) { + if (!seen.has(path)) { + this.#rows.delete(path); + changed = true; + } + } + return changed; + } + + /** Mark the source healthy after a successful reconcile. Returns whether health flipped. */ + markHealthy(): boolean { + const flipped = !this.#sourceHealthy; + this.#sourceHealthy = true; + return flipped; + } + + /** Flag every row stale (retained, never removed). Returns whether an emission is due. */ + markUnavailable(reason: ObservationFailureReason): boolean { + const wasHealthy = this.#sourceHealthy; + let changed = false; + for (const [path, row] of this.#rows) { + if (row.snapshot.sourceHealth === 'sourceUnavailable') { + continue; + } + this.#rows.set(path, { + snapshot: markSourceUnavailable(row.snapshot), + fingerprint: row.fingerprint, + }); + changed = true; + } + this.#sourceHealthy = false; + this.#failureReason = reason; + return changed || wasHealthy; + } + + list(): ObservationList { + const rows = [...this.#rows.values()].map((row) => row.snapshot); + if (this.#sourceHealthy) { + return { ok: true, rows }; + } + return { ok: false, reason: this.#failureReason, rows }; + } + + #upsert(path: string, mapped: MappedModem): boolean { + const fp = fingerprint(mapped); + const existing = this.#rows.get(path); + if ( + existing !== undefined && + existing.fingerprint === fp && + existing.snapshot.sourceHealth === 'live' + ) { + return false; + } + this.#rows.set(path, { + snapshot: createSnapshot({ ...mapped, revision: this.#nextRevision() }), + fingerprint: fp, + }); + return true; + } + + #nextRevision(): Revision { + this.#revCounter += 1; + return makeRevision(this.#revCounter); + } +} diff --git a/control/src/backend/signal-setup.ts b/control/src/backend/signal-setup.ts new file mode 100644 index 0000000..3c26a96 --- /dev/null +++ b/control/src/backend/signal-setup.ts @@ -0,0 +1,112 @@ +// Signal.Setup(interval) full-lifecycle management, epoch-scoped. +// +// ModemManager's `Modem.Signal.Setup(rate)` turns on periodic extended signal +// reporting. Its lifecycle mirrors the observer's epochs (A3.1): +// +// - applied once when the backend starts observing a modem; +// - applied to a hot-plugged modem the moment it appears (`InterfacesAdded`); +// - RE-APPLIED to every surviving modem after every owner-epoch change (a fresh MM +// owner has none of the previous owner's cadence configured); +// - NEVER applied for an OLD epoch — a setup scheduled for an epoch that is no +// longer current is dropped before the call ever goes out. +// +// A modem that lacks the `Modem.Signal` interface entirely reports +// `signalCadence: 'unsupported'` and is never called — a soft capability gap, never +// a start failure. The manager is driven purely by the observer's `onEpochRefresh` +// hook: every current-epoch snapshot re-drives it, and it de-dupes per (epoch, modem) +// so a modem is set up exactly once per epoch. + +import type { DbusTransport } from '../transport'; +import { MM_BUS_NAME, MODEM_IFACE } from './constants'; +import type { DecodedManagedObjects } from './managed-objects'; +import { hasInterface, pathsWithInterface } from './managed-objects'; + +/** Whether periodic signal reporting is configured for a modem. */ +export type SignalCadence = 'active' | 'unsupported' | 'unknown'; + +/** The `Modem.Signal` interface — absent means signal cadence is unsupported. */ +const SIGNAL_IFACE = 'org.freedesktop.ModemManager1.Modem.Signal'; + +/** MM's default reporting rate is seconds; callers pass whole seconds. */ +export const DEFAULT_SIGNAL_INTERVAL_SECONDS = 5; + +export interface SignalSetupManagerOptions { + readonly transport: DbusTransport; + /** MM bus name override (defaults to `org.freedesktop.ModemManager1`). */ + readonly destination?: string; + /** Reporting interval in seconds passed to `Signal.Setup`. */ + readonly intervalSeconds?: number; +} + +/** + * Drives `Signal.Setup` across the modem fleet, keyed to the observer's epochs. Feed + * it every `onEpochRefresh` event; it applies setup to each modem exactly once per + * epoch, re-applies to survivors on a new epoch, and never calls for an old one. + */ +export class SignalSetupManager { + readonly #transport: DbusTransport; + readonly #destination: string; + readonly #interval: number; + #currentEpoch: string | undefined; + // Guards double-issue within an epoch: `${epoch}\u0000${modemPath}`. + readonly #applied = new Set(); + readonly #cadence = new Map(); + + constructor(options: SignalSetupManagerOptions) { + this.#transport = options.transport; + this.#destination = options.destination ?? MM_BUS_NAME; + this.#interval = options.intervalSeconds ?? DEFAULT_SIGNAL_INTERVAL_SECONDS; + } + + /** The last-known cadence for a modem path (`'unknown'` until first applied). */ + cadenceFor(modemPath: string): SignalCadence { + return this.#cadence.get(modemPath) ?? 'unknown'; + } + + /** + * Apply `Signal.Setup` for the current epoch's modems. New epoch ⇒ every survivor + * is re-applied; an already-applied (epoch, modem) is skipped. Modems absent from + * this snapshot keep their last cadence but are not re-driven. + */ + applyForEpoch(epoch: string, tree: DecodedManagedObjects): void { + this.#currentEpoch = epoch; + for (const modemPath of pathsWithInterface(tree, MODEM_IFACE)) { + const key = `${epoch}\u0000${modemPath}`; + if (this.#applied.has(key)) { + continue; + } + this.#applied.add(key); + void this.#setupOne(epoch, modemPath, tree); + } + } + + async #setupOne(epoch: string, modemPath: string, tree: DecodedManagedObjects): Promise { + // Epoch changed out from under us before we could issue the call — drop it, so + // no Signal.Setup ever fires for a superseded epoch. + if (this.#currentEpoch !== epoch) { + return; + } + if (!hasInterface(tree, modemPath, SIGNAL_IFACE)) { + this.#cadence.set(modemPath, 'unsupported'); + return; + } + if (this.#currentEpoch !== epoch) { + return; + } + try { + await this.#transport.callMethod({ + destination: this.#destination, + path: modemPath, + interface: SIGNAL_IFACE, + member: 'Setup', + signature: 'u', + args: [this.#interval], + }); + this.#cadence.set(modemPath, 'active'); + } catch { + // A Setup call that errors is a soft gap — surfaced as unsupported, never a + // start failure. + this.#cadence.set(modemPath, 'unsupported'); + } + } +} diff --git a/control/src/backend/sim-unlock.ts b/control/src/backend/sim-unlock.ts new file mode 100644 index 0000000..f4bc633 --- /dev/null +++ b/control/src/backend/sim-unlock.ts @@ -0,0 +1,193 @@ +// SIM PIN / PUK unlock — read-before-submit, exactly-once, CeraUI-taxonomy-faithful. +// +// The ordering is the whole point (mirrors CeraUI `mmcli.ts` unlockSimPin/Puk): the +// lock state is READ first, and the secret is submitted EXACTLY ONCE. A blind +// resubmit walks the SIM toward an irreversible PUK / permanent lockout, so on a +// failure we only RE-READ the lock state to report remaining attempts — we never +// resubmit. A PUK cannot be cleared with a PIN, so a PUK-locked SIM is surfaced, not +// submitted into. +// +// The result outcomes map onto A2.2's `SimUnlockResult` / `SimPukUnlockResult`, which +// mirror CeraUI's own shapes so Phase-B adoption is a rename, not a rewrite: +// success → unlocked · wrong-pin → incorrect-pin(+remaining) · puk-required → +// sim-puk-required · wrong-puk → incorrect-puk(+remaining) · locked(0) → +// permanently-blocked · read failure → error · nothing-to-unlock → unlocked. +// The secret is passed only as a call arg and is NEVER placed in a `reason` string. + +import type { SimPukUnlockResult, SimUnlockResult } from '../ports'; +import type { DbusTransport } from '../transport'; +import { MODEM_IFACE, SIM_IFACE } from './constants'; +import { + type DecodedManagedObjects, + fetchManagedObjects, + findInterface, + numberProp, + propValue, + stringProp, +} from './managed-objects'; + +// MMModemLock values we branch on. +const LOCK_SIM_PIN = 2; +const LOCK_SIM_PUK = 4; +const LOCK_SIM_PUK2 = 5; + +/** A modem's current SIM lock state, read before any submit. */ +interface LockState { + readonly required: number; + readonly retries: ReadonlyMap; +} + +function readLockState(tree: DecodedManagedObjects, modemPath: string): LockState | undefined { + const modem = findInterface(tree, modemPath, MODEM_IFACE); + if (modem === undefined) { + return undefined; + } + const required = numberProp(modem, 'UnlockRequired') ?? 0; + const retries = new Map(); + const raw = propValue(modem, 'UnlockRetries'); + if (Array.isArray(raw)) { + for (const entry of raw) { + if (Array.isArray(entry) && typeof entry[0] === 'number' && typeof entry[1] === 'number') { + retries.set(entry[0], entry[1]); + } + } + } + return { required, retries }; +} + +/** The object path of a modem's active SIM (its `Sim` property), when present. */ +function activeSimPath(tree: DecodedManagedObjects, modemPath: string): string | undefined { + const path = stringProp(findInterface(tree, modemPath, MODEM_IFACE), 'Sim'); + return path?.startsWith('/') && path !== '/' ? path : undefined; +} + +const isPukLock = (lock: number): boolean => lock === LOCK_SIM_PUK || lock === LOCK_SIM_PUK2; + +/** + * Submit a SIM PIN read-before-submit and exactly-once. On a wrong PIN the lock state + * is re-read (never resubmitted) to report remaining attempts or a resulting PUK lock. + */ +export async function sendSimPin( + transport: DbusTransport, + destination: string, + modemPath: string, + pin: string, +): Promise { + let tree: DecodedManagedObjects; + try { + tree = await fetchManagedObjects(transport, destination); + } catch { + return { outcome: 'error', reason: 'could not read modem lock state before PIN submit' }; + } + const state = readLockState(tree, modemPath); + if (state === undefined) { + return { outcome: 'error', reason: 'modem not found while reading lock state' }; + } + if (isPukLock(state.required)) { + return { outcome: 'sim-puk-required', reason: 'SIM is PUK-locked; a PIN cannot clear it' }; + } + if (state.required !== LOCK_SIM_PIN) { + return { outcome: 'unlocked', reason: 'no SIM PIN is pending' }; + } + const simPath = activeSimPath(tree, modemPath); + if (simPath === undefined) { + return { outcome: 'error', reason: 'no active SIM object to submit the PIN to' }; + } + try { + await transport.callMethod({ + destination, + path: simPath, + interface: SIM_IFACE, + member: 'SendPin', + signature: 's', + args: [pin], + }); + return { outcome: 'unlocked', reason: 'PIN accepted' }; + } catch { + return classifyPinFailure(transport, destination, modemPath); + } +} + +async function classifyPinFailure( + transport: DbusTransport, + destination: string, + modemPath: string, +): Promise { + const state = await readLockState(await fetchManagedObjects(transport, destination), modemPath); + if (state !== undefined && isPukLock(state.required)) { + return { outcome: 'sim-puk-required', reason: 'wrong PIN tripped the SIM into a PUK lock' }; + } + const remaining = state?.retries.get(LOCK_SIM_PIN); + return { + outcome: 'incorrect-pin', + ...(remaining !== undefined ? { remainingAttempts: remaining } : {}), + reason: 'PIN was rejected', + }; +} + +/** + * Submit a SIM PUK + new PIN read-before-submit and exactly-once. On a wrong PUK the + * remaining PUK attempts are re-read (never resubmitted); zero remaining is a + * permanent block. + */ +export async function sendSimPuk( + transport: DbusTransport, + destination: string, + modemPath: string, + puk: string, + newPin: string, +): Promise { + let tree: DecodedManagedObjects; + try { + tree = await fetchManagedObjects(transport, destination); + } catch { + return { outcome: 'error', reason: 'could not read modem lock state before PUK submit' }; + } + const state = readLockState(tree, modemPath); + if (state === undefined) { + return { outcome: 'error', reason: 'modem not found while reading lock state' }; + } + if (!isPukLock(state.required)) { + return { outcome: 'unlocked', reason: 'no SIM PUK is pending' }; + } + const pukKind = state.required; + const simPath = activeSimPath(tree, modemPath); + if (simPath === undefined) { + return { outcome: 'error', reason: 'no active SIM object to submit the PUK to' }; + } + try { + await transport.callMethod({ + destination, + path: simPath, + interface: SIM_IFACE, + member: 'SendPuk', + signature: 'ss', + args: [puk, newPin], + }); + return { outcome: 'unlocked', reason: 'PUK accepted; new PIN set' }; + } catch { + return classifyPukFailure(transport, destination, modemPath, pukKind); + } +} + +async function classifyPukFailure( + transport: DbusTransport, + destination: string, + modemPath: string, + pukKind: number, +): Promise { + const state = await readLockState(await fetchManagedObjects(transport, destination), modemPath); + const remaining = state?.retries.get(pukKind); + if (remaining === 0) { + return { + outcome: 'permanently-blocked', + remainingAttempts: 0, + reason: 'PUK attempts exhausted; SIM is permanently locked', + }; + } + return { + outcome: 'incorrect-puk', + ...(remaining !== undefined ? { remainingAttempts: remaining } : {}), + reason: 'PUK was rejected', + }; +} diff --git a/control/src/backend/transition-preconditions.ts b/control/src/backend/transition-preconditions.ts new file mode 100644 index 0000000..713b752 --- /dev/null +++ b/control/src/backend/transition-preconditions.ts @@ -0,0 +1,149 @@ +// USB-mode transition preconditions + the transition interlock seam. +// +// The preconditions gate a mode switch and are checked TWICE (draft §rounds 5/6 +// TOCTOU): once at transaction entry (so a doomed request never enters the actor) and +// again INSIDE the actor (so a request that was valid at entry but became invalid +// while queued is caught before any disruptive call). `checkTransitionPreconditions` +// re-polls the LIVE inputs (`probeReadiness`, `interlock.canDisrupt`) each call, so +// the two checks can genuinely disagree — that disagreement is the TIER-B race. +// +// The interlock is BIDIRECTIONAL: `canDisrupt` is the streaming→transition gate (may +// I disrupt now?), inherited from A3.4's `LifecycleInterlock`; `hold` is the +// transition→streaming gate (mark "transition active" until released). The Phase-A +// stub allows both; Phase B wires CeraUI's streaming-admission check into the same +// interface without changing the transaction. + +import type { EpochMillis, IdentityConfidence } from '../domain'; +import type { ConnectionId, DeviceIfname } from '../ports'; +import { + type CatalogEntry, + type CertifiedCatalog, + findCatalogEntry, + findPermittedTransition, + type MmUsbMode, + type PermittedTransition, + type SkuDiscriminator, +} from '../usb-mode'; +import type { InterlockTarget, LifecycleInterlock } from './lifecycle-interlock'; + +/** A held "transition active" interlock — released when the transaction ends. */ +export interface InterlockHold { + release(): Promise; +} + +/** + * The transition interlock. `canDisrupt` (from `LifecycleInterlock`) answers "is it + * safe to disrupt this modem now?"; `hold` marks a transition in progress so a + * streaming start is blocked for its duration. Both directions, one seam. + */ +export interface TransitionInterlock extends LifecycleInterlock { + hold(target: InterlockTarget): Promise; +} + +const NO_OP_HOLD: InterlockHold = { + release(): Promise { + return Promise.resolve(); + }, +}; + +/** The Phase-A interlock: always allows, holds nothing. */ +export const ALLOW_ALL_TRANSITION_INTERLOCK: TransitionInterlock = { + canDisrupt() { + return Promise.resolve({ allow: true } as const); + }, + hold(): Promise { + return Promise.resolve(NO_OP_HOLD); + }, +}; + +/** Live, re-evaluable readiness inputs — re-polled at entry AND in-actor. */ +export interface TransitionReadiness { + /** The identity confidence from A3.2's ladder; `'low'` refuses the transition. */ + readonly identityConfidence: IdentityConfidence; +} + +/** One USB-mode transition request. */ +export interface UsbModeTransitionRequest { + readonly stableKey: string; + readonly sku: SkuDiscriminator; + readonly fromMode: MmUsbMode; + readonly toMode: MmUsbMode; + readonly connectionId: ConnectionId; + readonly deviceIfname: DeviceIfname; + /** Physical-topology UID captured BEFORE the switch — survives re-enumeration. */ + readonly cachedPhysicalUid: string; + /** The MM `Device` UID to inhibit by (cached — the modem disappears mid-switch). */ + readonly inhibitUid: string; + /** Must be `true` (mirrors the CLI `--confirm` flag). */ + readonly confirm: boolean; + /** Must be `true` (an extra maintenance-mode safety gate). */ + readonly maintenance: boolean; + readonly now: EpochMillis; + /** Live readiness, re-polled at entry AND in-actor (TOCTOU-safe). */ + probeReadiness(): Promise; +} + +/** How a transition ended. */ +export type UsbModeTransitionOutcome = + | { + readonly status: 'refused'; + readonly stage: 'entry' | 'in-actor'; + readonly reason: string; + readonly steps: readonly string[]; + } + | { + readonly status: 'succeeded'; + readonly newIfname: DeviceIfname; + readonly steps: readonly string[]; + } + | { + readonly status: 'failed'; + readonly degraded: boolean; + readonly reason: string; + readonly steps: readonly string[]; + }; + +/** The result of a precondition check — the matched entry/transition, or a reason. */ +export type PreconditionResult = + | { readonly ok: true; readonly entry: CatalogEntry; readonly transition: PermittedTransition } + | { readonly ok: false; readonly reason: string }; + +/** + * Check every transition precondition against the LIVE inputs. Called at entry and + * again in-actor; a request that passed at entry can fail here if the identity + * confidence dropped or the interlock closed in the meantime. Order is cheap-static + * checks first (confirm, maintenance, catalog, permitted) then the live probes + * (identity, interlock), so a refusal touches as little as possible. + */ +export async function checkTransitionPreconditions( + request: UsbModeTransitionRequest, + catalog: CertifiedCatalog, + interlock: TransitionInterlock, +): Promise { + if (!request.confirm) { + return { ok: false, reason: 'confirm:true is required (missing --confirm)' }; + } + if (!request.maintenance) { + return { ok: false, reason: 'maintenance flag is required' }; + } + const entry = findCatalogEntry(catalog, request.sku); + if (entry === undefined) { + return { ok: false, reason: `uncertified SKU ${request.sku.vidPid} ${request.sku.model}` }; + } + const transition = findPermittedTransition(entry, request.fromMode, request.toMode); + if (transition === undefined) { + return { + ok: false, + reason: `transition ${request.fromMode}->${request.toMode} not permitted for ${request.sku.model}`, + }; + } + const readiness = await request.probeReadiness(); + if (readiness.identityConfidence === 'low') { + return { ok: false, reason: 'low-confidence identity — refusing to transition' }; + } + const verdict = await interlock.canDisrupt({ stableKey: request.stableKey }); + if (!verdict.allow) { + return { ok: false, reason: `interlock held: ${verdict.reason}` }; + } + return { ok: true, entry, transition }; +} diff --git a/control/src/backend/usage/accounting.test.ts b/control/src/backend/usage/accounting.test.ts new file mode 100644 index 0000000..ed82861 --- /dev/null +++ b/control/src/backend/usage/accounting.test.ts @@ -0,0 +1,147 @@ +// The adversarial accounting matrix — pure, deterministic proofs that per-slot usage +// is attributed correctly across remaps, swaps, reuse, resets, pauses, and rollovers. + +import { describe, expect, test } from 'bun:test'; +import { type IdentityConfidence, logicalSlotId } from '../../domain'; +import { applySample, type BaselineKey, type SlotAccount } from './accounting'; + +const SLOT_A = logicalSlotId('slot-a'); +const SLOT_B = logicalSlotId('slot-b'); +const SLOT_C = logicalSlotId('slot-c'); +const BOOT = 'boot-uuid-1'; + +interface Step { + readonly slot: string; + readonly gen: number; + readonly ifname: string; + readonly current: number; + readonly confidence?: IdentityConfidence; + readonly cycleStartMs?: number; +} + +function key(slot: string, gen: number, ifname: string): BaselineKey { + return { logicalSlotId: slot, mappingGeneration: gen, ifname, bootId: BOOT }; +} + +function step(prior: SlotAccount | undefined, s: Step): SlotAccount { + return applySample(prior, { + key: key(s.slot, s.gen, s.ifname), + current: s.current, + confidence: s.confidence ?? 'high', + cycleStartMs: s.cycleStartMs ?? 1000, + }); +} + +describe('applySample — two modems tracked independently', () => { + test('separate slots never cross-contaminate', () => { + let a = step(undefined, { slot: SLOT_A, gen: 0, ifname: 'wwan0', current: 100 }); + let b = step(undefined, { slot: SLOT_B, gen: 0, ifname: 'wwan1', current: 5000 }); + a = step(a, { slot: SLOT_A, gen: 0, ifname: 'wwan0', current: 300 }); + b = step(b, { slot: SLOT_B, gen: 0, ifname: 'wwan1', current: 5001 }); + expect(a.cycleBytes).toBe(200); + expect(b.cycleBytes).toBe(1); + }); +}); + +describe('applySample — A/B ifname swap follows the slot, not the interface', () => { + test('each slot keeps its own total across a swap and re-baselines zero-delta', () => { + // Prime: A on wwan0, B on wwan1. + let a = step(undefined, { slot: SLOT_A, gen: 0, ifname: 'wwan0', current: 100 }); + let b = step(undefined, { slot: SLOT_B, gen: 0, ifname: 'wwan1', current: 1000 }); + a = step(a, { slot: SLOT_A, gen: 0, ifname: 'wwan0', current: 200 }); + b = step(b, { slot: SLOT_B, gen: 0, ifname: 'wwan1', current: 1100 }); + expect(a.cycleBytes).toBe(100); + expect(b.cycleBytes).toBe(100); + + // Swap: A now maps to wwan1, B to wwan0 (mapping generation bumps for both). + // First post-swap sample must be zero-delta despite the large counter change. + a = step(a, { slot: SLOT_A, gen: 1, ifname: 'wwan1', current: 1100 }); + b = step(b, { slot: SLOT_B, gen: 1, ifname: 'wwan0', current: 200 }); + expect(a.cycleBytes).toBe(100); + expect(b.cycleBytes).toBe(100); + + // Subsequent growth attributes to the correct slot on its new interface. + a = step(a, { slot: SLOT_A, gen: 1, ifname: 'wwan1', current: 1150 }); + b = step(b, { slot: SLOT_B, gen: 1, ifname: 'wwan0', current: 260 }); + expect(a.cycleBytes).toBe(150); + expect(b.cycleBytes).toBe(160); + }); +}); + +describe('applySample — old ifname reused by a different slot (no bleed)', () => { + test('a new slot inheriting an old interface starts from zero', () => { + let a = step(undefined, { slot: SLOT_A, gen: 0, ifname: 'wwan0', current: 100 }); + a = step(a, { slot: SLOT_A, gen: 0, ifname: 'wwan0', current: 500 }); + expect(a.cycleBytes).toBe(400); + + // Slot C now occupies wwan0. Its own account starts fresh (zero-delta baseline). + let c = step(undefined, { slot: SLOT_C, gen: 0, ifname: 'wwan0', current: 500 }); + c = step(c, { slot: SLOT_C, gen: 0, ifname: 'wwan0', current: 700 }); + expect(c.cycleBytes).toBe(200); + // A's total is untouched — no bleed either direction. + expect(a.cycleBytes).toBe(400); + }); +}); + +describe('applySample — rename-first-sample is zero-delta', () => { + test('the first sample after a remap never reports a spurious jump', () => { + let s = step(undefined, { slot: SLOT_A, gen: 0, ifname: 'wwan0', current: 100 }); + s = step(s, { slot: SLOT_A, gen: 0, ifname: 'wwan0', current: 200 }); + expect(s.cycleBytes).toBe(100); + // Rename wwan0 → wwan5 with a huge counter: must NOT attribute 9M. + s = step(s, { slot: SLOT_A, gen: 1, ifname: 'wwan5', current: 9_000_000 }); + expect(s.cycleBytes).toBe(100); + }); +}); + +describe('applySample — reset-then-positive', () => { + test('a counter reset clamps to zero, then normal increases resume', () => { + let s = step(undefined, { slot: SLOT_A, gen: 0, ifname: 'wwan0', current: 1000 }); + s = step(s, { slot: SLOT_A, gen: 0, ifname: 'wwan0', current: 1500 }); + expect(s.cycleBytes).toBe(500); + // Interface recreated: counter drops to 50 (decrease) → clamp + rebase. + s = step(s, { slot: SLOT_A, gen: 0, ifname: 'wwan0', current: 50 }); + expect(s.cycleBytes).toBe(500); + // Growth from the rebased baseline is tracked again. + s = step(s, { slot: SLOT_A, gen: 0, ifname: 'wwan0', current: 250 }); + expect(s.cycleBytes).toBe(700); + }); + + test('a decrease never produces a negative delta', () => { + let s = step(undefined, { slot: SLOT_A, gen: 0, ifname: 'wwan0', current: 800 }); + s = step(s, { slot: SLOT_A, gen: 0, ifname: 'wwan0', current: 300 }); + expect(s.cycleBytes).toBe(0); + expect(s.cycleBytes).toBeGreaterThanOrEqual(0); + }); +}); + +describe('applySample — ambiguous identity pauses sampling', () => { + test('low confidence attributes nothing and re-baselines zero-delta on resume', () => { + let s = step(undefined, { slot: SLOT_A, gen: 0, ifname: 'wwan0', current: 100 }); + s = step(s, { slot: SLOT_A, gen: 0, ifname: 'wwan0', current: 300 }); + expect(s.cycleBytes).toBe(200); + // Identity turns ambiguous → paused; the counter keeps climbing unattributed. + s = step(s, { slot: SLOT_A, gen: 0, ifname: 'wwan0', current: 9999, confidence: 'low' }); + expect(s.paused).toBe(true); + expect(s.cycleBytes).toBe(200); + // Confidence returns: resume is zero-delta, so the paused-window bytes are NOT + // back-attributed; only growth after resume counts. + s = step(s, { slot: SLOT_A, gen: 0, ifname: 'wwan0', current: 9999 }); + expect(s.paused).toBe(false); + expect(s.cycleBytes).toBe(200); + s = step(s, { slot: SLOT_A, gen: 0, ifname: 'wwan0', current: 10_099 }); + expect(s.cycleBytes).toBe(300); + }); +}); + +describe('applySample — cycle rollover resets the per-cycle total', () => { + test('crossing into a newer cycle zeroes cycleBytes but keeps the baseline', () => { + let s = step(undefined, { slot: SLOT_A, gen: 0, ifname: 'wwan0', current: 100 }); + s = step(s, { slot: SLOT_A, gen: 0, ifname: 'wwan0', current: 600 }); + expect(s.cycleBytes).toBe(500); + // New cycle boundary — cycleBytes resets, counter continuity preserved. + s = step(s, { slot: SLOT_A, gen: 0, ifname: 'wwan0', current: 650, cycleStartMs: 2000 }); + expect(s.cycleStartMs).toBe(2000); + expect(s.cycleBytes).toBe(50); + }); +}); diff --git a/control/src/backend/usage/accounting.ts b/control/src/backend/usage/accounting.ts new file mode 100644 index 0000000..bd8c03d --- /dev/null +++ b/control/src/backend/usage/accounting.ts @@ -0,0 +1,123 @@ +// The pure usage-accounting reducer — the heart of the sampler. +// +// For each logical slot we keep a per-cycle byte total plus a BASELINE: the last +// observed cumulative counter value under a specific key. The key is the composite +// `{logicalSlotId, mappingGeneration, ifname, bootId}`. The rules, all encoded here +// with zero I/O so they can be exhaustively unit-tested: +// +// - REMAP (any key field changes — e.g. the ifname-to-slot mapping bumped +// `mappingGeneration`, or a reboot changed `bootId`): rebaseline with a +// ZERO-DELTA sample. We start counting fresh from the current cumulative value +// and attribute nothing for that first sample — never a spurious jump. +// - SAME-KEY DECREASE (the cumulative counter went DOWN — interface re-created / +// counter reset without a reported remap): CLAMP the negative delta to zero and +// REBASE the baseline to the new lower value. Never report negative usage. +// - LOW CONFIDENCE (ambiguous identity, A3.2 ladder): sampling is PAUSED. We drop +// the baseline and attribute nothing; on resume the next sample is zero-delta so +// bytes moved during the ambiguous window are never mis-attributed. +// - CYCLE ROLLOVER (`now` crossed into a new billing cycle): the per-cycle total +// resets to zero. The baseline is kept — the kernel counter is continuous across +// a billing boundary; only OUR accounting window resets. + +import type { IdentityConfidence, LogicalSlotId } from '../../domain'; + +/** The composite baseline key. A change in ANY field is a remap (zero-delta rebase). */ +export interface BaselineKey { + readonly logicalSlotId: string; + readonly mappingGeneration: number; + readonly ifname: string; + readonly bootId: string; +} + +/** Per-slot accounting state. `key`/`lastObserved` are unset before the first sample. */ +export interface SlotAccount { + /** Bytes attributed to this slot within the current cycle. */ + readonly cycleBytes: number; + /** UTC start of the cycle `cycleBytes` is accruing into. */ + readonly cycleStartMs: number; + /** True when the last observation was low-confidence and sampling is paused. */ + readonly paused: boolean; + /** The key the baseline was captured under (undefined = no baseline yet). */ + readonly key?: BaselineKey; + /** The cumulative counter value at the last accepted sample. */ + readonly lastObserved?: number; +} + +/** One observation fed to the reducer for a single slot in one sampling pass. */ +export interface SampleInput { + readonly key: BaselineKey; + /** The current cumulative rx+tx counter for the slot's interface. */ + readonly current: number; + readonly confidence: IdentityConfidence; + /** The UTC start of the slot's active cycle (from `cycleStart`). */ + readonly cycleStartMs: number; +} + +/** A fresh account for a slot first seen in `cycleStartMs`. */ +export function initialAccount(cycleStartMs: number): SlotAccount { + return { cycleBytes: 0, cycleStartMs, paused: false }; +} + +function sameKey(a: BaselineKey, b: BaselineKey): boolean { + return ( + a.logicalSlotId === b.logicalSlotId && + a.mappingGeneration === b.mappingGeneration && + a.ifname === b.ifname && + a.bootId === b.bootId + ); +} + +/** Apply a cycle rollover: if we crossed into a newer cycle, zero the per-cycle total. */ +function rollCycle(account: SlotAccount, cycleStartMs: number): SlotAccount { + if (cycleStartMs > account.cycleStartMs) { + return { ...account, cycleBytes: 0, cycleStartMs }; + } + return account; +} + +/** + * Fold one observation into a slot's account, returning the NEXT account. Pure — + * no clock, no I/O; the caller supplies `current`, `confidence` and `cycleStartMs`. + */ +export function applySample(prior: SlotAccount | undefined, input: SampleInput): SlotAccount { + const base = rollCycle(prior ?? initialAccount(input.cycleStartMs), input.cycleStartMs); + + // Ambiguous identity → pause: attribute nothing and drop the baseline so the + // next confident sample re-baselines zero-delta (no back-attribution). + if (input.confidence === 'low') { + return { cycleBytes: base.cycleBytes, cycleStartMs: base.cycleStartMs, paused: true }; + } + + // Remap, first-ever sample, or resuming from a pause → zero-delta rebaseline. + if (base.paused || base.key === undefined || base.lastObserved === undefined) { + return { ...base, paused: false, key: input.key, lastObserved: input.current }; + } + if (!sameKey(base.key, input.key)) { + return { ...base, paused: false, key: input.key, lastObserved: input.current }; + } + + // Same key: a decrease is a counter reset → clamp the negative delta and rebase. + if (input.current < base.lastObserved) { + return { ...base, paused: false, key: input.key, lastObserved: input.current }; + } + + // Normal case: attribute the non-negative delta and advance the baseline. + const delta = input.current - base.lastObserved; + return { + ...base, + paused: false, + key: input.key, + lastObserved: input.current, + cycleBytes: base.cycleBytes + delta, + }; +} + +/** Convenience constructor for a `BaselineKey` from its parts. */ +export function baselineKey( + logicalSlotId: LogicalSlotId, + mappingGeneration: number, + ifname: string, + bootId: string, +): BaselineKey { + return { logicalSlotId, mappingGeneration, ifname, bootId }; +} diff --git a/control/src/backend/usage/billing-cycle.test.ts b/control/src/backend/usage/billing-cycle.test.ts new file mode 100644 index 0000000..5545fb4 --- /dev/null +++ b/control/src/backend/usage/billing-cycle.test.ts @@ -0,0 +1,62 @@ +// UTC billing-cycle math — month-length clamping (cycleDay 31 → Feb 28/29) and the +// before/after-boundary cycle-start selection. + +import { describe, expect, test } from 'bun:test'; +import { type EpochMillis, epochMillis } from '../../domain'; +import { clampCycleDay, cycleStart, daysInMonth } from './billing-cycle'; + +const utc = (y: number, m: number, d: number, h = 0): EpochMillis => + epochMillis(Date.UTC(y, m, d, h)); + +describe('daysInMonth', () => { + test('February is 28 days in a common year, 29 in a leap year', () => { + expect(daysInMonth(2023, 1)).toBe(28); + expect(daysInMonth(2024, 1)).toBe(29); // 2024 is a leap year + expect(daysInMonth(2100, 1)).toBe(28); // century non-leap + expect(daysInMonth(2000, 1)).toBe(29); // 400-divisible leap + }); + + test('30- and 31-day months', () => { + expect(daysInMonth(2024, 3)).toBe(30); // April + expect(daysInMonth(2024, 0)).toBe(31); // January + }); +}); + +describe('clampCycleDay', () => { + test('cycleDay 31 clamps to the last day of a short month', () => { + expect(clampCycleDay(31, 2023, 1)).toBe(28); // Feb non-leap + expect(clampCycleDay(31, 2024, 1)).toBe(29); // Feb leap + expect(clampCycleDay(31, 2024, 3)).toBe(30); // April + expect(clampCycleDay(31, 2024, 0)).toBe(31); // January — no clamp + }); + + test('a valid in-range day is unchanged; a floor of 1 is enforced', () => { + expect(clampCycleDay(15, 2024, 5)).toBe(15); + expect(clampCycleDay(0, 2024, 5)).toBe(1); + }); +}); + +describe('cycleStart — month-length clamp with before/after selection', () => { + test('cycleDay 31 in February resolves to the clamped Feb boundary', () => { + // 2023-02-15 with cycleDay 31 → cycle started 2023-01-31 (Feb boundary Feb 28 + // is still ahead of the 15th). + expect(cycleStart(epochMillis(utc(2023, 1, 15)), 31)).toBe(utc(2023, 0, 31)); + // 2023-03-01 with cycleDay 31 → the active cycle began at clamped Feb 28. + expect(cycleStart(epochMillis(utc(2023, 2, 1)), 31)).toBe(utc(2023, 1, 28)); + // Leap year: 2024-03-01 → Feb 29. + expect(cycleStart(epochMillis(utc(2024, 2, 1)), 31)).toBe(utc(2024, 1, 29)); + }); + + test('now exactly on the boundary starts a new cycle', () => { + expect(cycleStart(epochMillis(utc(2024, 5, 10)), 10)).toBe(utc(2024, 5, 10)); + }); + + test('now before this month boundary rolls back to the previous month', () => { + // cycleDay 20, now is the 5th → cycle began on the 20th of the prior month. + expect(cycleStart(epochMillis(utc(2024, 6, 5)), 20)).toBe(utc(2024, 5, 20)); + }); + + test('January rollback wraps to December of the prior year', () => { + expect(cycleStart(epochMillis(utc(2024, 0, 5)), 20)).toBe(utc(2023, 11, 20)); + }); +}); diff --git a/control/src/backend/usage/billing-cycle.ts b/control/src/backend/usage/billing-cycle.ts new file mode 100644 index 0000000..fad1b81 --- /dev/null +++ b/control/src/backend/usage/billing-cycle.ts @@ -0,0 +1,45 @@ +// UTC billing-cycle computation with month-length clamping. +// +// A usage cycle resets on a configured day of the month (`cycleDay`, 1–31) in UTC. +// Months are not all the same length, so a `cycleDay` past the end of a short month +// must clamp to that month's LAST day rather than rolling into the next month or +// throwing: `cycleDay: 31` resolves to Feb 28 (or Feb 29 in a leap year), Apr 30, +// etc. All arithmetic is UTC — the device clock's local zone never shifts a cycle. + +import type { EpochMillis } from '../../domain'; + +/** Days in a given UTC month. `month` is 0-based (0 = January). Handles leap Feb. */ +export function daysInMonth(year: number, month: number): number { + // Day 0 of the next month is the last day of this month; UTC avoids DST drift. + return new Date(Date.UTC(year, month + 1, 0)).getUTCDate(); +} + +/** Clamp a requested cycle day to the last valid day of the given month. */ +export function clampCycleDay(cycleDay: number, year: number, month: number): number { + const floor = Math.max(1, Math.trunc(cycleDay)); + return Math.min(floor, daysInMonth(year, month)); +} + +/** The UTC epoch-ms of the cycle boundary in a specific month (clamped). */ +function boundaryFor(cycleDay: number, year: number, month: number): number { + return Date.UTC(year, month, clampCycleDay(cycleDay, year, month)); +} + +/** + * The UTC start (`EpochMillis`) of the cycle that `now` falls in for a given + * `cycleDay`. If `now` is before this month's (clamped) boundary the active cycle + * began on the previous month's boundary; otherwise it began on this month's. + */ +export function cycleStart(now: EpochMillis, cycleDay: number): EpochMillis { + const at = new Date(now); + const year = at.getUTCFullYear(); + const month = at.getUTCMonth(); + const thisBoundary = boundaryFor(cycleDay, year, month); + if (now >= thisBoundary) { + return thisBoundary as EpochMillis; + } + // Roll back one month (December wraps to the prior year), re-clamping there. + const prevMonth = month === 0 ? 11 : month - 1; + const prevYear = month === 0 ? year - 1 : year; + return boundaryFor(cycleDay, prevYear, prevMonth) as EpochMillis; +} diff --git a/control/src/backend/usage/boot-id.ts b/control/src/backend/usage/boot-id.ts new file mode 100644 index 0000000..bd7812f --- /dev/null +++ b/control/src/backend/usage/boot-id.ts @@ -0,0 +1,18 @@ +// Reading the kernel boot id — the usage sampler's session identity. +// +// `/proc/sys/kernel/random/boot_id` is a per-boot random UUID string. It changes on +// every reboot, which is exactly when interface byte counters reset to zero, so the +// sampler keys its baselines on it and re-baselines when it changes. + +/** + * Read the current kernel boot id (a UUID string). The path is injectable for tests; + * the default is the live `/proc` file. A read failure yields an empty string so the + * sampler still starts (baselines then simply never match a persisted session). + */ +export async function readBootId(path = '/proc/sys/kernel/random/boot_id'): Promise { + try { + return (await Bun.file(path).text()).trim(); + } catch { + return ''; + } +} diff --git a/control/src/backend/usage/index.ts b/control/src/backend/usage/index.ts new file mode 100644 index 0000000..a9774a7 --- /dev/null +++ b/control/src/backend/usage/index.ts @@ -0,0 +1,37 @@ +// Data-usage sampler — internal all-interface sampler over `/proc/net/dev` +// CUMULATIVE counters (A4.3). Public surface consumed by the A6.1 bench CLI `usage` +// command (via `UsageSnapshot`) and the composition root that wires the sampler. + +export { + applySample, + type BaselineKey, + baselineKey, + initialAccount, + type SampleInput, + type SlotAccount, +} from './accounting'; +export { clampCycleDay, cycleStart, daysInMonth } from './billing-cycle'; +export { readBootId } from './boot-id'; +export { + type CounterSource, + parseProcNetDev, + procNetDevCounterSource, +} from './proc-net-dev'; +export { + createUsageSampler, + type SlotUsageSnapshot, + type UsageObservation, + UsageSampler, + type UsageSamplerOptions, + type UsageSnapshot, +} from './sampler'; +export { + createUsageFileStore, + type PersistedSlot, + type PersistedUsage, + USAGE_SCHEMA_VERSION, + type UsageFileStoreOptions, + type UsageLogEvent, + type UsageLogger, + type UsageStore, +} from './store'; diff --git a/control/src/backend/usage/proc-net-dev.test.ts b/control/src/backend/usage/proc-net-dev.test.ts new file mode 100644 index 0000000..69adeca --- /dev/null +++ b/control/src/backend/usage/proc-net-dev.test.ts @@ -0,0 +1,56 @@ +// `/proc/net/dev` parser — proves rx+tx cumulative totals over the real fixed-column +// format, header skipping, and fail-soft handling of malformed rows. + +import { describe, expect, test } from 'bun:test'; +import { parseProcNetDev } from './proc-net-dev'; + +// A real capture (columns: rx bytes packets errs drop fifo frame compressed multicast +// | tx bytes packets errs drop fifo colls carrier compressed). +const REAL = `Inter-| Receive | Transmit + face |bytes packets errs drop fifo frame compressed multicast|bytes packets errs drop fifo colls carrier compressed + lo: 160183007830 18519510 0 0 0 0 0 0 160183007830 18519510 0 0 0 0 0 0 + eno2: 88166142004 86575608 0 0 48 0 0 146660 25843393633 48414413 0 3 0 0 0 0 + wlan0: 44865976 194892 0 0 0 0 0 0 5698312 36057 0 23 0 0 0 0 +`; + +describe('parseProcNetDev', () => { + test('reads rx+tx cumulative bytes for each interface, skipping the 2-line header', () => { + const counters = parseProcNetDev(REAL); + expect(counters.size).toBe(3); + // lo: 160183007830 (rx) + 160183007830 (tx). + expect(counters.get('lo')).toBe(160183007830 + 160183007830); + // eno2: rx 88166142004 + tx 25843393633. + expect(counters.get('eno2')).toBe(88166142004 + 25843393633); + // wlan0: rx 44865976 + tx 5698312. + expect(counters.get('wlan0')).toBe(44865976 + 5698312); + }); + + test('handles bridge-style names with no leading space and long names', () => { + const text = `h1 +h2 +br-d739ee545df1: 575402021 542380 0 0 0 0 0 0 450821722 810015 0 4 0 0 0 0 +docker0: 1750867165 1097738 0 0 0 0 0 0 5810268898 1237921 0 12223 0 0 0 0 +`; + const counters = parseProcNetDev(text); + expect(counters.get('br-d739ee545df1')).toBe(575402021 + 450821722); + expect(counters.get('docker0')).toBe(1750867165 + 5810268898); + }); + + test('ignores malformed / short rows without throwing', () => { + const text = `h1 +h2 + lo: 100 1 0 0 0 0 0 0 200 2 0 0 0 0 0 0 +garbage-without-colon 1 2 3 + short: 1 2 3 + nan: x 1 0 0 0 0 0 0 y 2 0 0 0 0 0 0 +`; + const counters = parseProcNetDev(text); + // Only the well-formed `lo` row survives. + expect(counters.size).toBe(1); + expect(counters.get('lo')).toBe(300); + }); + + test('empty input yields an empty map', () => { + expect(parseProcNetDev('').size).toBe(0); + }); +}); diff --git a/control/src/backend/usage/proc-net-dev.ts b/control/src/backend/usage/proc-net-dev.ts new file mode 100644 index 0000000..c240b3b --- /dev/null +++ b/control/src/backend/usage/proc-net-dev.ts @@ -0,0 +1,77 @@ +// Our own `/proc/net/dev` parser over the kernel's CUMULATIVE byte counters. +// +// `/proc/net/dev` is a fixed-format text file: two header lines, then one row per +// interface. Each row is `: <16 numeric columns>` — the first 8 are the +// Receive block (bytes packets errs drop fifo frame compressed multicast) and the +// next 8 are the Transmit block in the same order. We read the ever-increasing +// rx-bytes (column 0) + tx-bytes (column 8) as ONE cumulative total per interface. +// +// This is deliberately NOT a rate/bitrate signal: it is the raw monotonic kernel +// counter (it only decreases on a counter reset / interface re-creation, which the +// accounting layer clamps). We parse the proc text ourselves rather than shelling +// out to `ip`/`ifconfig` because the raw file is the most reliable, consistent +// source and matches exactly what the plan names. + +/** A source of cumulative rx+tx byte counters keyed by interface name. */ +export interface CounterSource { + /** Read the current cumulative rx+tx byte total for every interface. */ + read(): Promise>; +} + +const HEADER_LINE_COUNT = 2; +const RX_BYTES_COLUMN = 0; +const TX_BYTES_COLUMN = 8; +const MIN_COLUMNS = TX_BYTES_COLUMN + 1; + +/** + * Parse `/proc/net/dev` text into a map of interface name → cumulative rx+tx bytes. + * The two-line header is skipped; malformed or short rows are ignored rather than + * throwing, so a single odd line can never break sampling. + */ +export function parseProcNetDev(text: string): Map { + const counters = new Map(); + const lines = text.split('\n').slice(HEADER_LINE_COUNT); + for (const line of lines) { + const colon = line.indexOf(':'); + if (colon < 0) { + continue; + } + const ifname = line.slice(0, colon).trim(); + if (ifname.length === 0) { + continue; + } + const columns = line + .slice(colon + 1) + .trim() + .split(/\s+/); + if (columns.length < MIN_COLUMNS) { + continue; + } + const rx = Number(columns[RX_BYTES_COLUMN]); + const tx = Number(columns[TX_BYTES_COLUMN]); + if (!Number.isFinite(rx) || !Number.isFinite(tx) || rx < 0 || tx < 0) { + continue; + } + counters.set(ifname, rx + tx); + } + return counters; +} + +/** + * The production counter source: reads and parses the real `/proc/net/dev`. The + * path is injectable so tests can point at a fixture, but the default is the live + * kernel file. A missing/unreadable file yields an empty map (fail-soft — a read + * error never crashes the sampler; that pass simply attributes no new bytes). + */ +export function procNetDevCounterSource(path = '/proc/net/dev'): CounterSource { + return { + async read(): Promise> { + try { + const text = await Bun.file(path).text(); + return parseProcNetDev(text); + } catch { + return new Map(); + } + }, + }; +} diff --git a/control/src/backend/usage/sampler.test.ts b/control/src/backend/usage/sampler.test.ts new file mode 100644 index 0000000..e0f8f50 --- /dev/null +++ b/control/src/backend/usage/sampler.test.ts @@ -0,0 +1,219 @@ +// The sampler orchestration — ambiguous-identity pause, threshold advisory +// appear/clear, rate-limited persistence + shutdown flush, reboot re-baselining, and +// the queryable snapshot API the A6.1 bench CLI `usage` command consumes. + +import { describe, expect, test } from 'bun:test'; +import { type DesiredUsage, logicalSlotId } from '../../domain'; +import type { CounterSource } from './proc-net-dev'; +import { createUsageSampler, type UsageObservation } from './sampler'; +import { type PersistedUsage, USAGE_SCHEMA_VERSION, type UsageStore } from './store'; + +const SLOT_A = logicalSlotId('slot-a'); +const SLOT_B = logicalSlotId('slot-b'); + +class FakeCounters implements CounterSource { + readonly #map = new Map(); + set(ifname: string, value: number): void { + this.#map.set(ifname, value); + } + async read(): Promise> { + return new Map(this.#map); + } +} + +class MemStore implements UsageStore { + doc: PersistedUsage | null = null; + saves = 0; + async load(bootId: string, nowMs: number): Promise { + return this.doc ?? { schemaVersion: USAGE_SCHEMA_VERSION, bootId, savedAtMs: nowMs, slots: [] }; + } + async save(state: PersistedUsage): Promise { + this.saves += 1; + this.doc = state; + } +} + +function obs( + slot: string, + ifname: string, + usage: DesiredUsage = {}, + confidence: 'high' | 'medium' | 'low' = 'high', + mappingGeneration = 0, +): UsageObservation { + return { logicalSlotId: logicalSlotId(slot), mappingGeneration, ifname, confidence, usage }; +} + +describe('UsageSampler — snapshot API (A6.1 usage command)', () => { + test('reports independent per-slot cumulative usage', async () => { + const counters = new FakeCounters(); + const sampler = await createUsageSampler({ + bootId: 'boot-1', + source: counters, + store: new MemStore(), + now: () => 1000, + }); + counters.set('wwan0', 100); + counters.set('wwan1', 5000); + await sampler.sample([obs(SLOT_A, 'wwan0'), obs(SLOT_B, 'wwan1')]); + counters.set('wwan0', 300); + counters.set('wwan1', 5001); + await sampler.sample([obs(SLOT_A, 'wwan0'), obs(SLOT_B, 'wwan1')]); + + const snap = sampler.snapshot(); + expect(snap.bootId).toBe('boot-1'); + expect(snap.slots).toHaveLength(2); + const a = snap.slots.find((s) => s.logicalSlotId === 'slot-a'); + const b = snap.slots.find((s) => s.logicalSlotId === 'slot-b'); + expect(a?.cycleBytes).toBe(200); + expect(a?.paused).toBe(false); + expect(a?.thresholdExceeded).toBe(false); + expect(b?.cycleBytes).toBe(1); + }); +}); + +describe('UsageSampler — ambiguous identity pauses sampling', () => { + test('a low-confidence slot attributes nothing and reports paused', async () => { + const counters = new FakeCounters(); + const sampler = await createUsageSampler({ + bootId: 'boot-1', + source: counters, + store: new MemStore(), + now: () => 1000, + }); + counters.set('wwan0', 100); + await sampler.sample([obs(SLOT_A, 'wwan0')]); + counters.set('wwan0', 400); + await sampler.sample([obs(SLOT_A, 'wwan0')]); + expect(sampler.snapshot().slots[0]?.cycleBytes).toBe(300); + + // Identity turns ambiguous — counter keeps climbing, nothing is attributed. + counters.set('wwan0', 9999); + await sampler.sample([obs(SLOT_A, 'wwan0', {}, 'low')]); + const paused = sampler.snapshot().slots[0]; + expect(paused?.paused).toBe(true); + expect(paused?.cycleBytes).toBe(300); + }); +}); + +describe('UsageSampler — threshold advisory appears and clears', () => { + test('crossing thresholdBytes flags an advisory that clears on cycle rollover', async () => { + const counters = new FakeCounters(); + let clock = Date.UTC(2024, 5, 15); // mid-June + const usage: DesiredUsage = { cycleDay: 1, thresholdBytes: 1000 }; + const sampler = await createUsageSampler({ + bootId: 'boot-1', + source: counters, + store: new MemStore(), + now: () => clock, + }); + + counters.set('wwan0', 0); + await sampler.sample([obs(SLOT_A, 'wwan0', usage)]); + counters.set('wwan0', 2000); // +2000 > 1000 threshold + await sampler.sample([obs(SLOT_A, 'wwan0', usage)]); + const exceeded = sampler.snapshot().slots[0]; + expect(exceeded?.cycleBytes).toBe(2000); + expect(exceeded?.thresholdBytes).toBe(1000); + expect(exceeded?.thresholdExceeded).toBe(true); + + // New billing cycle (July 1) → per-cycle total resets → advisory clears. + clock = Date.UTC(2024, 6, 2); + counters.set('wwan0', 2100); // +100 into the fresh cycle + await sampler.sample([obs(SLOT_A, 'wwan0', usage)]); + const cleared = sampler.snapshot().slots[0]; + expect(cleared?.cycleBytes).toBe(100); + expect(cleared?.thresholdExceeded).toBe(false); + }); +}); + +describe('UsageSampler — rate-limited persistence + shutdown flush', () => { + test('persists at most once per minute; flush forces an immediate write', async () => { + const counters = new FakeCounters(); + const store = new MemStore(); + let clock = 1000; + const sampler = await createUsageSampler({ + bootId: 'boot-1', + source: counters, + store, + now: () => clock, + persistIntervalMs: 60_000, + }); + counters.set('wwan0', 100); + await sampler.sample([obs(SLOT_A, 'wwan0')]); // t=1000, within interval → no save + clock = 2000; + await sampler.sample([obs(SLOT_A, 'wwan0')]); // t=2000, still within → no save + expect(store.saves).toBe(0); + + await sampler.flush(); // shutdown hook — forces a write despite the rate limit + expect(store.saves).toBe(1); + + clock = 62_000; // > 60s since the flush persist → next sample persists + counters.set('wwan0', 150); + await sampler.sample([obs(SLOT_A, 'wwan0')]); + expect(store.saves).toBe(2); + }); + + test('a clean flush persists the latest cycle total', async () => { + const counters = new FakeCounters(); + const store = new MemStore(); + const sampler = await createUsageSampler({ + bootId: 'boot-1', + source: counters, + store, + now: () => 1000, + }); + counters.set('wwan0', 100); + await sampler.sample([obs(SLOT_A, 'wwan0')]); + counters.set('wwan0', 500); + await sampler.sample([obs(SLOT_A, 'wwan0')]); + await sampler.flush(); + expect(store.doc?.slots[0]?.cycleBytes).toBe(400); + expect(store.doc?.slots[0]?.lastObserved).toBe(500); + }); +}); + +describe('UsageSampler — reboot (new boot id) re-baselines without losing the cycle total', () => { + test('same-boot reload resumes the baseline; a new boot id drops it', async () => { + const counters = new FakeCounters(); + const store = new MemStore(); + const first = await createUsageSampler({ + bootId: 'boot-1', + source: counters, + store, + now: () => 1000, + }); + counters.set('wwan0', 100); + await first.sample([obs(SLOT_A, 'wwan0')]); + counters.set('wwan0', 300); + await first.sample([obs(SLOT_A, 'wwan0')]); + await first.flush(); + expect(store.doc?.slots[0]?.cycleBytes).toBe(200); + + // Same boot id → baseline resumes; counter continues from 300. + const resumed = await createUsageSampler({ + bootId: 'boot-1', + source: counters, + store, + now: () => 1000, + }); + counters.set('wwan0', 350); + await resumed.sample([obs(SLOT_A, 'wwan0')]); + expect(resumed.snapshot().slots[0]?.cycleBytes).toBe(250); + await resumed.flush(); + + // Reboot: new boot id + counters reset to a low value. The cycle total is kept, + // but the baseline is dropped so the reset is not mis-attributed. + const rebooted = await createUsageSampler({ + bootId: 'boot-2', + source: counters, + store, + now: () => 1000, + }); + counters.set('wwan0', 50); // kernel counters reset on reboot + await rebooted.sample([obs(SLOT_A, 'wwan0')]); // zero-delta re-baseline + expect(rebooted.snapshot().slots[0]?.cycleBytes).toBe(250); + counters.set('wwan0', 150); + await rebooted.sample([obs(SLOT_A, 'wwan0')]); + expect(rebooted.snapshot().slots[0]?.cycleBytes).toBe(350); + }); +}); diff --git a/control/src/backend/usage/sampler.ts b/control/src/backend/usage/sampler.ts new file mode 100644 index 0000000..554d406 --- /dev/null +++ b/control/src/backend/usage/sampler.ts @@ -0,0 +1,228 @@ +// The usage sampler — orchestrates the counter source, pure accounting reducer, +// billing-cycle math, and fail-soft persistence into one internal service. +// +// SESSION = the kernel boot id. Interface byte counters reset to zero on reboot, so +// the boot id both scopes every baseline key AND lets a reload detect a reboot: a +// persisted document from a different boot keeps its per-cycle totals but drops its +// baselines, so the first post-reboot sample re-baselines zero-delta. +// +// PERSISTENCE: state is written at most once per minute (rate-limited) plus a +// `flush()` shutdown hook. A crash therefore loses AT MOST ~1 minute of unpersisted +// deltas (the window since the last rate-limited write); a clean shutdown calls +// `flush()` and loses effectively nothing. + +import { type DesiredUsage, epochMillis, type LogicalSlotId } from '../../domain'; +import { applySample, type BaselineKey, initialAccount, type SlotAccount } from './accounting'; +import { cycleStart } from './billing-cycle'; +import type { CounterSource } from './proc-net-dev'; +import type { PersistedSlot, PersistedUsage, UsageStore } from './store'; +import { USAGE_SCHEMA_VERSION } from './store'; + +/** One slot's observation for a sampling pass — identity + mapping + local policy. */ +export interface UsageObservation { + readonly logicalSlotId: LogicalSlotId; + /** Bumped by A4.2 whenever the ifname-to-slot mapping changes (→ zero-delta rebase). */ + readonly mappingGeneration: number; + readonly ifname: string; + /** A3.2 identity-ladder confidence; `low` pauses sampling for this slot. */ + readonly confidence: 'high' | 'medium' | 'low'; + /** Local-controller-owned usage policy (cycle day + advisory threshold). */ + readonly usage: DesiredUsage; +} + +/** A queryable per-slot usage figure — consumed by the A6.1 bench CLI `usage` command. */ +export interface SlotUsageSnapshot { + readonly logicalSlotId: string; + readonly cycleBytes: number; + readonly cycleStartMs: number; + readonly paused: boolean; + readonly thresholdBytes?: number; + /** Advisory-only: `cycleBytes > thresholdBytes`. Never gates the connection. */ + readonly thresholdExceeded: boolean; +} + +/** The sampler's current state, per slot, at a point in time. */ +export interface UsageSnapshot { + readonly bootId: string; + readonly generatedAtMs: number; + readonly slots: readonly SlotUsageSnapshot[]; +} + +export interface UsageSamplerOptions { + /** The kernel boot id (see `readBootId`) — the session identity. */ + readonly bootId: string; + readonly source: CounterSource; + readonly store: UsageStore; + /** Injectable clock (defaults to `Date.now`). */ + readonly now?: () => number; + /** Minimum spacing between persists (default 60_000 ms = the ≤1-min loss bound). */ + readonly persistIntervalMs?: number; + /** Cycle day used when a slot's policy omits `cycleDay` (default 1 = 1st of month). */ + readonly defaultCycleDay?: number; +} + +const DEFAULT_PERSIST_INTERVAL_MS = 60_000; +const DEFAULT_CYCLE_DAY = 1; + +function toPersistedSlot(logicalSlotId: string, account: SlotAccount): PersistedSlot { + return { + logicalSlotId, + cycleBytes: account.cycleBytes, + cycleStartMs: account.cycleStartMs, + ...(account.key !== undefined + ? { mappingGeneration: account.key.mappingGeneration, ifname: account.key.ifname } + : {}), + ...(account.lastObserved !== undefined ? { lastObserved: account.lastObserved } : {}), + }; +} + +export class UsageSampler { + readonly #bootId: string; + readonly #source: CounterSource; + readonly #store: UsageStore; + readonly #now: () => number; + readonly #persistIntervalMs: number; + readonly #defaultCycleDay: number; + readonly #accounts = new Map(); + readonly #policies = new Map(); + #lastPersistMs: number; + #dirty = false; + + private constructor(options: UsageSamplerOptions, initial: PersistedUsage) { + this.#bootId = options.bootId; + this.#source = options.source; + this.#store = options.store; + this.#now = options.now ?? Date.now; + this.#persistIntervalMs = options.persistIntervalMs ?? DEFAULT_PERSIST_INTERVAL_MS; + this.#defaultCycleDay = options.defaultCycleDay ?? DEFAULT_CYCLE_DAY; + this.#lastPersistMs = this.#now(); + this.#hydrate(initial); + } + + /** Load persisted state (recreating a fresh file if absent/corrupt) then build the sampler. */ + static async create(options: UsageSamplerOptions): Promise { + const now = options.now ?? Date.now; + const initial = await options.store.load(options.bootId, now()); + return new UsageSampler(options, initial); + } + + /** Rebuild in-memory accounts. A reboot (differing boot id) drops the baselines. */ + #hydrate(initial: PersistedUsage): void { + const sameBoot = initial.bootId === this.#bootId; + for (const slot of initial.slots) { + const canResume = + sameBoot && + slot.ifname !== undefined && + slot.mappingGeneration !== undefined && + slot.lastObserved !== undefined; + if (canResume) { + const key: BaselineKey = { + logicalSlotId: slot.logicalSlotId, + mappingGeneration: slot.mappingGeneration as number, + ifname: slot.ifname as string, + bootId: this.#bootId, + }; + this.#accounts.set(slot.logicalSlotId, { + cycleBytes: slot.cycleBytes, + cycleStartMs: slot.cycleStartMs, + paused: false, + key, + lastObserved: slot.lastObserved as number, + }); + } else { + this.#accounts.set(slot.logicalSlotId, { + cycleBytes: slot.cycleBytes, + cycleStartMs: slot.cycleStartMs, + paused: false, + }); + } + } + } + + /** Take one sampling pass over the current counters for the given observations. */ + async sample(observations: readonly UsageObservation[]): Promise { + const counters = await this.#source.read(); + const now = this.#now(); + for (const obs of observations) { + const slotId = obs.logicalSlotId as string; + this.#policies.set(slotId, obs.usage); + const cycleDay = obs.usage.cycleDay ?? this.#defaultCycleDay; + const cycleStartMs = cycleStart(epochMillis(now), cycleDay); + const current = counters.get(obs.ifname); + if (current === undefined) { + // No reading for this interface — ensure the slot exists, attribute nothing. + if (!this.#accounts.has(slotId)) { + this.#accounts.set(slotId, initialAccount(cycleStartMs)); + } + continue; + } + const key: BaselineKey = { + logicalSlotId: slotId, + mappingGeneration: obs.mappingGeneration, + ifname: obs.ifname, + bootId: this.#bootId, + }; + const next = applySample(this.#accounts.get(slotId), { + key, + current, + confidence: obs.confidence, + cycleStartMs, + }); + this.#accounts.set(slotId, next); + } + this.#dirty = true; + await this.#maybePersist(now); + } + + /** Current per-slot usage — the queryable snapshot the CLI and platform read. */ + snapshot(): UsageSnapshot { + const generatedAtMs = this.#now(); + const slots: SlotUsageSnapshot[] = []; + for (const [slotId, account] of this.#accounts) { + const thresholdBytes = this.#policies.get(slotId)?.thresholdBytes; + slots.push({ + logicalSlotId: slotId, + cycleBytes: account.cycleBytes, + cycleStartMs: account.cycleStartMs, + paused: account.paused, + ...(thresholdBytes !== undefined ? { thresholdBytes } : {}), + thresholdExceeded: thresholdBytes !== undefined && account.cycleBytes > thresholdBytes, + }); + } + return { bootId: this.#bootId, generatedAtMs, slots }; + } + + /** Flush unpersisted state immediately — the shutdown hook (bounds loss to ≤1 min). */ + async flush(): Promise { + if (this.#dirty) { + await this.#persist(this.#now()); + } + } + + async #maybePersist(now: number): Promise { + if (now - this.#lastPersistMs >= this.#persistIntervalMs) { + await this.#persist(now); + } + } + + async #persist(now: number): Promise { + const slots: PersistedSlot[] = []; + for (const [slotId, account] of this.#accounts) { + slots.push(toPersistedSlot(slotId, account)); + } + const state: PersistedUsage = { + schemaVersion: USAGE_SCHEMA_VERSION, + bootId: this.#bootId, + savedAtMs: now, + slots, + }; + await this.#store.save(state); + this.#lastPersistMs = now; + this.#dirty = false; + } +} + +/** Load persisted state and build a ready sampler. See `UsageSampler.create`. */ +export function createUsageSampler(options: UsageSamplerOptions): Promise { + return UsageSampler.create(options); +} diff --git a/control/src/backend/usage/store.test.ts b/control/src/backend/usage/store.test.ts new file mode 100644 index 0000000..bc877ba --- /dev/null +++ b/control/src/backend/usage/store.test.ts @@ -0,0 +1,148 @@ +// Persistence contract — mode 0600 (real fs.stat), fail-soft corruption recovery +// with METADATA-ONLY logging, and the no-PII guarantee on the serialized bytes. + +import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; +import { mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { + createUsageFileStore, + type PersistedUsage, + USAGE_SCHEMA_VERSION, + type UsageLogEvent, +} from './store'; + +let dir: string; +let path: string; + +beforeEach(async () => { + dir = await mkdtemp(join(tmpdir(), 'usage-store-')); + path = join(dir, 'usage.json'); +}); + +afterEach(async () => { + await rm(dir, { recursive: true, force: true }); +}); + +const sample: PersistedUsage = { + schemaVersion: USAGE_SCHEMA_VERSION, + bootId: 'boot-uuid-1', + savedAtMs: 1_700_000_000_000, + slots: [ + { logicalSlotId: 'slot-a', cycleBytes: 1234, cycleStartMs: 1_699_000_000_000 }, + { + logicalSlotId: 'slot-b', + cycleBytes: 42, + cycleStartMs: 1_699_000_000_000, + mappingGeneration: 2, + ifname: 'wwan0', + lastObserved: 999, + }, + ], +}; + +describe('UsageStore — versioned round-trip', () => { + test('save then load returns identical, schema-versioned state', async () => { + const store = createUsageFileStore({ path }); + await store.save(sample); + const loaded = await store.load('boot-uuid-1', 1_700_000_100_000); + expect(loaded.schemaVersion).toBe(USAGE_SCHEMA_VERSION); + expect(loaded).toEqual(sample); + }); + + test('an absent file loads as fresh empty state (no write, no warning)', async () => { + const events: UsageLogEvent[] = []; + const store = createUsageFileStore({ path, logger: (e) => events.push(e) }); + const loaded = await store.load('boot-uuid-9', 5); + expect(loaded.slots).toEqual([]); + expect(loaded.bootId).toBe('boot-uuid-9'); + expect(events).toHaveLength(0); + }); +}); + +describe('UsageStore — mode 0600 via chmod-after-write', () => { + test('the persisted file ends up owner-read/write only (real fs.stat)', async () => { + const store = createUsageFileStore({ path }); + await store.save(sample); + const info = await stat(path); + expect(info.mode & 0o777).toBe(0o600); + }); +}); + +describe('UsageStore — fail-soft corruption recovery', () => { + test('a corrupt file logs METADATA ONLY and recreates a fresh 0600 file', async () => { + // Corrupt content carrying a secret-looking token we must never echo in the log. + const corrupt = '{ this is not json ICCID=8988211000000123456 }'; + await writeFile(path, corrupt); + const events: UsageLogEvent[] = []; + const store = createUsageFileStore({ path, logger: (e) => events.push(e) }); + + const loaded = await store.load('boot-uuid-2', 77); + + // Exactly one metadata-only warning. + expect(events).toHaveLength(1); + const event = events[0]; + expect(event?.kind).toBe('corrupt-state'); + expect(event?.bytes).toBe(Buffer.byteLength(corrupt, 'utf8')); + expect(typeof event?.reason).toBe('string'); + // The log NEVER contains the raw content (no secret token leak). + expect(JSON.stringify(event)).not.toContain('ICCID'); + expect(JSON.stringify(event)).not.toContain('8988211000000123456'); + + // Fail-soft: fresh empty state returned and the file recreated at mode 0600. + expect(loaded.slots).toEqual([]); + expect(loaded.bootId).toBe('boot-uuid-2'); + const info = await stat(path); + expect(info.mode & 0o777).toBe(0o600); + + // The recreated file is now valid — a second load is clean (no new warning). + const reloaded = await store.load('boot-uuid-2', 78); + expect(reloaded.slots).toEqual([]); + expect(events).toHaveLength(1); + }); + + test('an incompatible schema version is treated as corruption and recreated', async () => { + await writeFile( + path, + JSON.stringify({ schemaVersion: 999, bootId: 'x', savedAtMs: 0, slots: [] }), + ); + const events: UsageLogEvent[] = []; + const store = createUsageFileStore({ path, logger: (e) => events.push(e) }); + const loaded = await store.load('boot-uuid-3', 1); + expect(events).toHaveLength(1); + expect(events[0]?.reason).toContain('schemaVersion'); + expect(loaded.slots).toEqual([]); + }); +}); + +describe('UsageStore — no PII in the persisted bytes', () => { + test('the serialized file contains only opaque ids and numbers', async () => { + const store = createUsageFileStore({ path }); + await store.save(sample); + const raw = await readFile(path, 'utf8'); + + // No subscriber/device-identifying fields, by construction. Word-bounded so a + // benign key like `mappingGeneration` (contains "pin") is not a false positive. + expect(raw).not.toMatch( + /\b(iccid|imsi|imei|eid|msisdn|operator|manufacturer|subscriber|pin|puk|password)\b/i, + ); + + // Every slot object exposes only the allowed keys; ids are opaque strings. + const parsed = JSON.parse(raw) as PersistedUsage; + const allowed = new Set([ + 'logicalSlotId', + 'cycleBytes', + 'cycleStartMs', + 'mappingGeneration', + 'ifname', + 'lastObserved', + ]); + for (const slot of parsed.slots) { + for (const keyName of Object.keys(slot)) { + expect(allowed.has(keyName)).toBe(true); + } + expect(typeof slot.logicalSlotId).toBe('string'); + expect(typeof slot.cycleBytes).toBe('number'); + } + }); +}); diff --git a/control/src/backend/usage/store.ts b/control/src/backend/usage/store.ts new file mode 100644 index 0000000..8a21b7a --- /dev/null +++ b/control/src/backend/usage/store.ts @@ -0,0 +1,177 @@ +// Versioned, fail-soft persistence for the usage sampler. +// +// The persisted file is a small JSON document carrying ONLY opaque slot ids, +// interface names, and numbers — never any subscriber/device identity (no ICCID, +// IMSI, IMEI, operator or model). By construction the sampler holds no such fields, +// and `no-pii.test.ts` proves the serialized bytes stay clean. +// +// Two hard guarantees: +// - MODE 0600: the file is written to a temp path, chmod'd to 0600 AFTER the +// write, then atomically renamed over the target — so the on-disk file always +// ends up owner-read/write-only regardless of the process umask. +// - FAIL-SOFT ON CORRUPTION: an unparseable/incompatible file is never fatal. We +// log METADATA ONLY (byte length + a classification reason — never the raw, +// possibly-sensitive content), recreate a fresh empty 0600 file, and carry on. + +import { chmod, mkdir, readFile, rename, writeFile } from 'node:fs/promises'; +import { dirname } from 'node:path'; + +/** The current on-disk schema version. Bump when the persisted shape changes. */ +export const USAGE_SCHEMA_VERSION = 1; + +/** One slot's persisted accounting row — opaque ids and numbers only. */ +export interface PersistedSlot { + readonly logicalSlotId: string; + readonly cycleBytes: number; + readonly cycleStartMs: number; + readonly mappingGeneration?: number; + readonly ifname?: string; + readonly lastObserved?: number; +} + +/** The full persisted document. `bootId` scopes the baselines to one kernel session. */ +export interface PersistedUsage { + readonly schemaVersion: typeof USAGE_SCHEMA_VERSION; + readonly bootId: string; + readonly savedAtMs: number; + readonly slots: readonly PersistedSlot[]; +} + +/** A metadata-only log event. Corruption NEVER carries the raw file content. */ +export type UsageLogEvent = { + readonly kind: 'corrupt-state'; + readonly bytes: number; + readonly reason: string; +}; + +/** Sink for sampler log events. Defaults to a metadata-only `console.warn`. */ +export type UsageLogger = (event: UsageLogEvent) => void; + +/** The persistence seam the sampler drives. */ +export interface UsageStore { + /** Load persisted state; recreate a fresh 0600 file if absent or corrupt. */ + load(currentBootId: string, nowMs: number): Promise; + /** Atomically write state with mode 0600 (temp → chmod → rename). */ + save(state: PersistedUsage): Promise; +} + +export interface UsageFileStoreOptions { + readonly path: string; + readonly logger?: UsageLogger; +} + +function defaultLogger(event: UsageLogEvent): void { + console.warn(`[usage-sampler] ${event.kind}: bytes=${event.bytes} reason=${event.reason}`); +} + +function freshState(bootId: string, nowMs: number): PersistedUsage { + return { schemaVersion: USAGE_SCHEMA_VERSION, bootId, savedAtMs: nowMs, slots: [] }; +} + +function isFiniteNumber(value: unknown): value is number { + return typeof value === 'number' && Number.isFinite(value); +} + +function validateSlot(raw: unknown): PersistedSlot { + if (typeof raw !== 'object' || raw === null) { + throw new SchemaError('slot'); + } + const slot = raw as Record; + if (typeof slot.logicalSlotId !== 'string') { + throw new SchemaError('logicalSlotId'); + } + if (!isFiniteNumber(slot.cycleBytes) || !isFiniteNumber(slot.cycleStartMs)) { + throw new SchemaError('cycleBytes|cycleStartMs'); + } + return { + logicalSlotId: slot.logicalSlotId, + cycleBytes: slot.cycleBytes, + cycleStartMs: slot.cycleStartMs, + ...(isFiniteNumber(slot.mappingGeneration) + ? { mappingGeneration: slot.mappingGeneration } + : {}), + ...(typeof slot.ifname === 'string' ? { ifname: slot.ifname } : {}), + ...(isFiniteNumber(slot.lastObserved) ? { lastObserved: slot.lastObserved } : {}), + }; +} + +/** Parse + validate the document. Throws `SchemaError` (metadata-only) on mismatch. */ +function validate(raw: unknown): PersistedUsage { + if (typeof raw !== 'object' || raw === null) { + throw new SchemaError('document'); + } + const doc = raw as Record; + if (doc.schemaVersion !== USAGE_SCHEMA_VERSION) { + throw new SchemaError('schemaVersion'); + } + if (typeof doc.bootId !== 'string' || !isFiniteNumber(doc.savedAtMs)) { + throw new SchemaError('bootId|savedAtMs'); + } + if (!Array.isArray(doc.slots)) { + throw new SchemaError('slots'); + } + return { + schemaVersion: USAGE_SCHEMA_VERSION, + bootId: doc.bootId, + savedAtMs: doc.savedAtMs, + slots: doc.slots.map(validateSlot), + }; +} + +/** A schema violation naming only the offending FIELD (never file content). */ +class SchemaError extends Error { + constructor(field: string) { + super(`schema-mismatch: ${field}`); + } +} + +/** Classify a load failure into a metadata-only reason string (no raw content). */ +function classifyFailure(error: unknown): string { + if (error instanceof SchemaError) { + 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'; +} + +export function createUsageFileStore(options: UsageFileStoreOptions): UsageStore { + const logger = options.logger ?? defaultLogger; + const { path } = options; + + async function writeAtomic(state: PersistedUsage): Promise { + await mkdir(dirname(path), { recursive: true }); + const tmp = `${path}.tmp`; + await writeFile(tmp, JSON.stringify(state)); + // 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(currentBootId: string, nowMs: number): Promise { + let text: string; + try { + text = await readFile(path, 'utf8'); + } catch { + // Absent (or unreadable) → start empty; the first save lays down a 0600 file. + return freshState(currentBootId, nowMs); + } + try { + return validate(JSON.parse(text)); + } catch (error) { + logger({ + kind: 'corrupt-state', + bytes: Buffer.byteLength(text, 'utf8'), + reason: classifyFailure(error), + }); + const fresh = freshState(currentBootId, nowMs); + await writeAtomic(fresh); + return fresh; + } + }, + save: writeAtomic, + }; +} diff --git a/control/src/backend/usb-enumerator.test.ts b/control/src/backend/usb-enumerator.test.ts new file mode 100644 index 0000000..e2e7618 --- /dev/null +++ b/control/src/backend/usb-enumerator.test.ts @@ -0,0 +1,87 @@ +// The enumerator parses `udevadm info --export-db` into device snapshots: interface +// class/subclass/protocol come from the parent device's `ID_USB_INTERFACES`, each +// interface's driver from its own `usb_interface` record. The raw read is injectable +// so this runs with canned udev text and no hardware, and `enumerate` re-reads every +// call (refresh-triggered, never cached). + +import { describe, expect, test } from 'bun:test'; +import { createUsbEnumerator, parseUdevDatabase } from './usb-enumerator'; + +const UDEV_DB = `P: /devices/pci0000:00/usb1/1-1 +E: SUBSYSTEM=usb +E: DEVTYPE=usb_device +E: ID_VENDOR_ID=2c7c +E: ID_MODEL_ID=0125 +E: ID_MODEL=EG25-G +E: ID_REVISION=0318 +E: ID_PATH=pci-0000:00-usb-0:1 +E: ID_USB_INTERFACES=:ff0000:ffffff: + +P: /devices/pci0000:00/usb1/1-1/1-1:1.0 +E: SUBSYSTEM=usb +E: DEVTYPE=usb_interface +E: DRIVER=option + +P: /devices/pci0000:00/usb1/1-1/1-1:1.1 +E: SUBSYSTEM=usb +E: DEVTYPE=usb_interface +E: DRIVER=qmi_wwan + +P: /devices/pci0000:00/usb2/2-1 +E: SUBSYSTEM=usb +E: DEVTYPE=usb_device +E: ID_VENDOR_ID=12d1 +E: ID_MODEL_ID=14db +E: ID_MODEL=HUAWEI_HiLink +E: ID_USB_INTERFACES=:020600:0a0000: +`; + +describe('parseUdevDatabase', () => { + test('parses two devices with their vendor/product/model', () => { + const devices = parseUdevDatabase(UDEV_DB); + expect(devices).toHaveLength(2); + const quectel = devices.find((d) => d.vendorId === '2c7c'); + expect(quectel?.productId).toBe('0125'); + expect(quectel?.model).toBe('EG25-G'); + expect(quectel?.firmwareRevision).toBe('0318'); + expect(quectel?.physicalUid).toBe('pci-0000:00-usb-0:1'); + }); + + test('parses interface class triples from ID_USB_INTERFACES', () => { + const devices = parseUdevDatabase(UDEV_DB); + const huawei = devices.find((d) => d.vendorId === '12d1'); + expect(huawei?.interfaces).toEqual([ + { interfaceClass: 0x02, interfaceSubClass: 0x06, interfaceProtocol: 0x00 }, + { interfaceClass: 0x0a, interfaceSubClass: 0x00, interfaceProtocol: 0x00 }, + ]); + }); + + test('stitches each interface driver onto its parent device', () => { + const devices = parseUdevDatabase(UDEV_DB); + const quectel = devices.find((d) => d.vendorId === '2c7c'); + expect(quectel?.interfaces[0]?.driver).toBe('option'); + expect(quectel?.interfaces[1]?.driver).toBe('qmi_wwan'); + }); + + test('a record without a vendor/product id is skipped', () => { + const devices = parseUdevDatabase('P: /devices/x\nE: DEVTYPE=usb_device\nE: SUBSYSTEM=usb\n'); + expect(devices).toEqual([]); + }); +}); + +describe('createUsbEnumerator', () => { + test('enumerate() re-reads the injected source every call (refresh-triggered)', async () => { + let reads = 0; + const enumerator = createUsbEnumerator({ + readUdevDatabase: () => { + reads += 1; + return Promise.resolve(UDEV_DB); + }, + }); + const first = await enumerator.enumerate(); + const second = await enumerator.enumerate(); + expect(first).toHaveLength(2); + expect(second).toHaveLength(2); + expect(reads).toBe(2); + }); +}); diff --git a/control/src/backend/usb-enumerator.ts b/control/src/backend/usb-enumerator.ts new file mode 100644 index 0000000..6ed7360 --- /dev/null +++ b/control/src/backend/usb-enumerator.ts @@ -0,0 +1,181 @@ +// Production USB enumeration — a refresh-triggered snapshot of udev/sysfs state. +// +// `enumerate()` shells out to `udevadm info --export-db` (via `Bun.spawn`) each call +// — it is deliberately NOT cached, so a caller re-reads current state after a hot-plug +// or a mode switch. The raw-database read is an injectable seam (`readUdevDatabase`) +// so tests drive canned udev output with no real hardware, and the parser itself is a +// pure, exported function. udev exports every interface's class/subclass/protocol on +// the parent device as `ID_USB_INTERFACES` (`:ff0000:0a0000:` …), and each interface's +// bound DRIVER on its own `usb_interface` record — this parser stitches the two. + +import type { UsbDeviceSnapshot, UsbInterface } from './device-classifier'; + +/** The injectable dependencies for the enumerator. */ +export interface UsbEnumeratorDeps { + /** Provides the raw `udevadm info --export-db` text. Defaults to a `Bun.spawn` call. */ + readonly readUdevDatabase?: () => Promise; +} + +/** A refresh-triggered USB device enumerator. */ +export interface UsbEnumerator { + /** Snapshot current USB state — re-reads udev every call (never cached). */ + enumerate(): Promise; +} + +async function defaultReadUdevDatabase(): Promise { + const proc = Bun.spawn(['udevadm', 'info', '--export-db'], { stdout: 'pipe', stderr: 'pipe' }); + const [stdout, exitCode] = await Promise.all([new Response(proc.stdout).text(), proc.exited]); + if (exitCode !== 0) { + const stderr = await new Response(proc.stderr).text(); + throw new Error(`udevadm info --export-db exited ${exitCode}: ${stderr.trim()}`); + } + return stdout; +} + +interface UdevRecord { + readonly devpath: string; + readonly env: ReadonlyMap; +} + +function parseRecords(text: string): UdevRecord[] { + const records: UdevRecord[] = []; + let devpath = ''; + let env = new Map(); + const flush = (): void => { + if (env.size > 0 || devpath !== '') { + records.push({ devpath, env }); + } + devpath = ''; + env = new Map(); + }; + for (const line of text.split('\n')) { + if (line.trim() === '') { + flush(); + continue; + } + const kind = line.slice(0, 2); + const rest = line.slice(3); + if (kind === 'P:') { + devpath = rest; + } else if (kind === 'E:') { + const eq = rest.indexOf('='); + if (eq > 0) { + env.set(rest.slice(0, eq), rest.slice(eq + 1)); + } + } + } + flush(); + return records; +} + +/** Parse an `ID_USB_INTERFACES` value (`:ff0000:0a0000:`) into class/subclass/protocol triples. */ +function parseInterfaces(value: string | undefined): UsbInterface[] { + if (value === undefined) { + return []; + } + const interfaces: UsbInterface[] = []; + for (const token of value.split(':')) { + if (token.length !== 6) { + continue; + } + interfaces.push({ + interfaceClass: Number.parseInt(token.slice(0, 2), 16), + interfaceSubClass: Number.parseInt(token.slice(2, 4), 16), + interfaceProtocol: Number.parseInt(token.slice(4, 6), 16), + }); + } + return interfaces; +} + +function interfaceIndex(devpath: string): number | undefined { + const dot = devpath.lastIndexOf('.'); + if (dot < 0) { + return undefined; + } + const index = Number.parseInt(devpath.slice(dot + 1), 10); + return Number.isNaN(index) ? undefined : index; +} + +function buildSnapshot(env: ReadonlyMap): UsbDeviceSnapshot | undefined { + const vendorId = env.get('ID_VENDOR_ID'); + const productId = env.get('ID_MODEL_ID'); + if (vendorId === undefined || productId === undefined) { + return undefined; + } + const props: Record = {}; + for (const [key, value] of env) { + props[key] = value; + } + const model = env.get('ID_MODEL'); + const firmwareRevision = env.get('ID_REVISION'); + const physicalUid = env.get('ID_PATH'); + return { + vendorId, + productId, + bDeviceClass: 0, + interfaces: parseInterfaces(env.get('ID_USB_INTERFACES')), + udevProperties: props, + ...(model !== undefined ? { model } : {}), + ...(firmwareRevision !== undefined ? { firmwareRevision } : {}), + ...(physicalUid !== undefined ? { physicalUid } : {}), + }; +} + +/** + * Parse `udevadm info --export-db` output into device snapshots. Pure and exported so + * the production parse is unit-testable against canned udev text. Interface drivers + * are stitched from each `usb_interface` record onto its parent `usb_device`. + */ +export function parseUdevDatabase(text: string): UsbDeviceSnapshot[] { + const records = parseRecords(text); + const devices = new Map(); + const snapshots = new Map(); + + for (const record of records) { + if (record.env.get('DEVTYPE') !== 'usb_device') { + continue; + } + const snapshot = buildSnapshot(record.env); + if (snapshot !== undefined) { + snapshots.set(record.devpath, snapshot); + devices.set(record.devpath, [...snapshot.interfaces]); + } + } + + for (const record of records) { + if (record.env.get('DEVTYPE') !== 'usb_interface') { + continue; + } + const driver = record.env.get('DRIVER'); + const index = interfaceIndex(record.devpath); + if (driver === undefined || index === undefined) { + continue; + } + const parent = record.devpath.slice(0, record.devpath.lastIndexOf('/')); + const ifaces = devices.get(parent); + const iface = ifaces?.[index]; + if (ifaces !== undefined && iface !== undefined) { + ifaces[index] = { ...iface, driver }; + } + } + + return [...snapshots.entries()].map(([devpath, snapshot]) => ({ + ...snapshot, + interfaces: devices.get(devpath) ?? snapshot.interfaces, + })); +} + +/** Create a refresh-triggered USB enumerator over an injectable udev reader. */ +export function createUsbEnumerator(deps: UsbEnumeratorDeps = {}): UsbEnumerator { + const read = deps.readUdevDatabase ?? defaultReadUdevDatabase; + return { + async enumerate(): Promise { + return parseUdevDatabase(await read()); + }, + }; +} + +/** Convenience: enumerate current USB devices once via the default udev reader. */ +export function enumerateUsbDevices(): Promise { + return createUsbEnumerator().enumerate(); +} diff --git a/control/src/backend/usb-mode-transition.test.ts b/control/src/backend/usb-mode-transition.test.ts new file mode 100644 index 0000000..47ec0dd --- /dev/null +++ b/control/src/backend/usb-mode-transition.test.ts @@ -0,0 +1,323 @@ +// The transition transaction — the safety-critical core. This proves: +// - the happy path walks all TEN steps in order and yields a NEW ifname on the SAME +// stableKey; +// - the THREE-TIER negative matrix: TIER A entry refusals fire ZERO actor/lease/AT +// calls; TIER B an in-actor race is caught after exactly one actor entry with zero +// lease/AT; TIER C a postcondition mismatch fails degraded, never reactivates, and +// still releases the interlock via finally; +// - a crash mid-transaction trips the watchdog, force-uninhibits, and returns +// degraded rather than hanging forever. + +import { describe, expect, test } from 'bun:test'; +import { epochMillis } from '../domain'; +import { connectionId, deviceIfname, type NetworkManagerPort, receipt } from '../ports'; +import type { UsbDeviceSnapshot } from './device-classifier'; +import { ModemActor } from './modem-actor'; +import type { TransitionInterlock, UsbModeTransitionRequest } from './transition-preconditions'; +import { UsbModeTransition, type UsbModeTransitionDeps } from './usb-mode-transition'; + +const CACHED_UID = 'pci-0000:00-usb-0:1'; +const SKU = { + vidPid: '2c7c:0125', + model: 'CERALIVE-SYNTHETIC-TEST-SKU', + firmwarePrefix: 'SYNTHETICFW01', +}; + +const TEN_STEPS = [ + 'nm-quiesce', + 'inhibit', + 'at-command', + 'await-port-drop', + 'uninhibit', + 'await-reenumeration', + 'postcondition', + 'resolve-ifname', + 'reactivate', + 'release-interlock', +]; + +const OLD_QMI: UsbDeviceSnapshot = { + vendorId: '2c7c', + productId: '0125', + bDeviceClass: 0, + physicalUid: CACHED_UID, + ifname: 'wwan0', + interfaces: [ + { interfaceClass: 0xff, interfaceSubClass: 0xff, interfaceProtocol: 0xff, driver: 'qmi_wwan' }, + ], +}; + +/** Re-enumerated as MBIM (target), same physical UID, NEW ifname — matches the catalog. */ +const NEW_MBIM: UsbDeviceSnapshot = { + vendorId: '2c7c', + productId: '0126', + bDeviceClass: 0, + physicalUid: CACHED_UID, + ifname: 'wwan1', + interfaces: [ + { interfaceClass: 0x02, interfaceSubClass: 0x0e, interfaceProtocol: 0x00, driver: 'cdc_mbim' }, + { interfaceClass: 0x0a, interfaceSubClass: 0x00, interfaceProtocol: 0x02, driver: 'cdc_mbim' }, + ], +}; + +/** Re-enumerated STILL as QMI — the postcondition-mismatch device (same UID). */ +const STILL_QMI: UsbDeviceSnapshot = { ...OLD_QMI, ifname: 'wwan0' }; + +interface SpyLog { + readonly calls: string[]; +} + +function makeNm(log: SpyLog): NetworkManagerPort { + const rcpt = () => receipt('connection', 'applied', 'ok'); + return { + createGsmProfile: () => Promise.reject(new Error('unused')), + readGsmProfile: () => Promise.resolve(undefined), + updateGsmProfile: () => Promise.reject(new Error('unused')), + deleteGsmProfile: () => Promise.resolve(), + activate: (_id, ifname) => { + log.calls.push(`nm.activate:${ifname}`); + return Promise.resolve(rcpt()); + }, + deactivate: () => Promise.resolve(rcpt()), + acquireQuiesceLease: (id, ifname) => { + log.calls.push('nm.acquireQuiesceLease'); + return Promise.resolve({ + connectionId: id, + deviceIfname: ifname, + acquiredAt: epochMillis(0), + }); + }, + releaseQuiesceLease: () => { + log.calls.push('nm.releaseQuiesceLease'); + return Promise.resolve(); + }, + }; +} + +function makeMm(log: SpyLog): Pick { + return { + inhibit: (uid) => { + log.calls.push(`mm.inhibit:${uid}`); + return Promise.resolve({ uid, acquiredAt: epochMillis(0) }); + }, + uninhibit: () => { + log.calls.push('mm.uninhibit'); + return Promise.resolve(); + }, + }; +} + +function okSender(log: SpyLog): UsbModeTransitionDeps['atSender'] { + return { + send: (command) => { + log.calls.push(`at.send:${command}`); + return Promise.resolve({ ok: true, raw: 'OK' }); + }, + }; +} + +function scriptedEnumerate( + frames: UsbDeviceSnapshot[][], +): () => Promise { + let i = 0; + return () => { + const frame = frames[Math.min(i, frames.length - 1)] ?? []; + i += 1; + return Promise.resolve(frame); + }; +} + +function makeRequest(overrides: Partial = {}): UsbModeTransitionRequest { + return { + stableKey: 'slot:test', + sku: SKU, + fromMode: 'qmi', + toMode: 'mbim', + connectionId: connectionId('uuid-1'), + deviceIfname: deviceIfname('wwan0'), + cachedPhysicalUid: CACHED_UID, + inhibitUid: CACHED_UID, + confirm: true, + maintenance: true, + now: epochMillis(0), + probeReadiness: () => Promise.resolve({ identityConfidence: 'high' }), + ...overrides, + }; +} + +function makeTransition( + log: SpyLog, + overrides: Partial = {}, +): UsbModeTransition { + return new UsbModeTransition({ + actor: new ModemActor(), + nm: makeNm(log), + modemManager: makeMm(log), + atSender: okSender(log), + enumerate: scriptedEnumerate([[OLD_QMI], [], [NEW_MBIM]]), + reenumerationTimeoutMs: 500, + pollIntervalMs: 1, + watchdogMs: 200, + ...overrides, + }); +} + +describe('UsbModeTransition — the happy path walks all ten steps', () => { + test('a certified qmi→mbim transition succeeds with a NEW ifname on the same stableKey', async () => { + const log: SpyLog = { calls: [] }; + const transition = makeTransition(log); + const request = makeRequest(); + const outcome = await transition.execute(request); + + expect(outcome.status).toBe('succeeded'); + if (outcome.status !== 'succeeded') { + return; + } + // The row survives with the SAME stableKey but a NEW ifname. + expect(request.stableKey).toBe('slot:test'); + expect(String(outcome.newIfname)).toBe('wwan1'); + expect(String(request.deviceIfname)).toBe('wwan0'); + // All ten goal steps, in order. + expect(outcome.steps.filter((s) => TEN_STEPS.includes(s))).toEqual(TEN_STEPS); + // The catalog command was sent exactly once; reactivation targeted the new ifname. + expect(log.calls.filter((c) => c.startsWith('at.send:'))).toEqual([ + 'at.send:AT+QCFG="usbnet",2', + ]); + expect(log.calls).toContain('nm.activate:wwan1'); + }); +}); + +/** No nm/mm/at side-effecting call ran, and the actor was never entered. */ +function expectZeroSideEffects(log: SpyLog, steps: readonly string[]): void { + expect(log.calls).toEqual([]); + expect(steps).not.toContain('actor-enter'); +} + +describe('UsbModeTransition — TIER A: entry refusals fire ZERO actor/lease/AT calls', () => { + const cases: Array<[string, Partial]> = [ + ['unconfirmed (confirm:false)', { confirm: false }], + ['uncertified SKU (no catalog entry)', { sku: { ...SKU, firmwarePrefix: 'UNKNOWNFW' } }], + [ + 'non-permitted transition (mbim→ecm-ncm not certified)', + { fromMode: 'mbim', toMode: 'ecm-ncm' }, + ], + [ + 'ambiguous / low-confidence identity', + { probeReadiness: () => Promise.resolve({ identityConfidence: 'low' }) }, + ], + ['missing maintenance flag', { maintenance: false }], + ]; + for (const [name, overrides] of cases) { + test(name, async () => { + const log: SpyLog = { calls: [] }; + const transition = makeTransition(log); + const outcome = await transition.execute(makeRequest(overrides)); + expect(outcome.status).toBe('refused'); + if (outcome.status === 'refused') { + expect(outcome.stage).toBe('entry'); + } + expectZeroSideEffects(log, outcome.steps); + }); + } + + test('interlock already held → refused at entry, hold never acquired', async () => { + const log: SpyLog = { calls: [] }; + const heldInterlock: TransitionInterlock = { + canDisrupt: () => Promise.resolve({ allow: false, reason: 'a stream is admitted' }), + hold: () => Promise.reject(new Error('hold must not be acquired')), + }; + const transition = makeTransition(log, { interlock: heldInterlock }); + const outcome = await transition.execute(makeRequest()); + expect(outcome.status).toBe('refused'); + expectZeroSideEffects(log, outcome.steps); + }); +}); + +describe('UsbModeTransition — TIER B: an in-actor race is caught with zero lease/AT', () => { + test('valid at entry, invalid in-actor → one actor entry, zero lease/AT calls', async () => { + const log: SpyLog = { calls: [] }; + let probes = 0; + const transition = makeTransition(log, {}); + const request = makeRequest({ + // High at entry, low by the time the actor runs (a duplicate IMEI appeared). + probeReadiness: () => { + probes += 1; + return Promise.resolve({ identityConfidence: probes === 1 ? 'high' : 'low' }); + }, + }); + const outcome = await transition.execute(request); + + expect(outcome.status).toBe('refused'); + if (outcome.status === 'refused') { + expect(outcome.stage).toBe('in-actor'); + } + // Exactly one actor entry. + expect(outcome.steps.filter((s) => s === 'actor-enter')).toEqual(['actor-enter']); + // Zero lease/AT (and zero nm/mm) calls fired. + expect(log.calls).toEqual([]); + }); +}); + +describe('UsbModeTransition — TIER C: a postcondition mismatch fails degraded', () => { + test('AT returned OK but the device stayed qmi → FAILED+degraded, NO reactivation, interlock released', async () => { + const log: SpyLog = { calls: [] }; + let holdReleases = 0; + const interlock: TransitionInterlock = { + canDisrupt: () => Promise.resolve({ allow: true }), + hold: () => + Promise.resolve({ + release: () => { + holdReleases += 1; + return Promise.resolve(); + }, + }), + }; + const transition = makeTransition(log, { + interlock, + // Re-enumerates STILL as qmi — the switch did not take, even though AT said OK. + enumerate: scriptedEnumerate([[OLD_QMI], [], [STILL_QMI]]), + }); + const outcome = await transition.execute(makeRequest()); + + expect(outcome.status).toBe('failed'); + if (outcome.status === 'failed') { + expect(outcome.degraded).toBe(true); + expect(outcome.reason).toContain('postcondition mismatch'); + } + // The AT command WAS sent (OK ignored) but reactivation NEVER happened. + expect(log.calls.filter((c) => c.startsWith('at.send:'))).toHaveLength(1); + expect(log.calls.some((c) => c.startsWith('nm.activate:'))).toBe(false); + expect(outcome.steps).not.toContain('reactivate'); + // The interlock hook was released via finally, on the failure path. + expect(outcome.steps).toContain('release-interlock'); + expect(holdReleases).toBe(1); + }); +}); + +describe('UsbModeTransition — crash mid-transaction trips the watchdog', () => { + test('a hung AT command force-uninhibits, reprobes, and returns degraded (never hangs)', async () => { + const log: SpyLog = { calls: [] }; + const hangingSender: UsbModeTransitionDeps['atSender'] = { + send: (command) => { + log.calls.push(`at.send:${command}`); + return new Promise(() => undefined); + }, + }; + const transition = makeTransition(log, { + atSender: hangingSender, + enumerate: scriptedEnumerate([[OLD_QMI]]), + watchdogMs: 30, + }); + const outcome = await transition.execute(makeRequest()); + + expect(outcome.status).toBe('failed'); + if (outcome.status === 'failed') { + expect(outcome.degraded).toBe(true); + expect(outcome.reason).toContain('timed out'); + } + // The watchdog force-uninhibited the modem and released the interlock. + expect(outcome.steps).toContain('force-uninhibit'); + expect(outcome.steps).toContain('release-interlock'); + expect(log.calls).toContain('mm.uninhibit'); + }); +}); diff --git a/control/src/backend/usb-mode-transition.ts b/control/src/backend/usb-mode-transition.ts new file mode 100644 index 0000000..7767359 --- /dev/null +++ b/control/src/backend/usb-mode-transition.ts @@ -0,0 +1,253 @@ +// The certified USB-mode transition transaction. +// +// A mode switch is destructive: the modem physically re-enumerates, its interface +// name changes, and MM briefly loses sight of it. This transaction runs the switch +// 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). +// +// THE POSTCONDITION IS THE ONLY PROOF OF SUCCESS. An AT `OK` proves nothing — only a +// re-enumerated device whose descriptors AND observed mode equal the catalog target +// counts. On a postcondition MISMATCH the whole transaction fails `degraded`, does NOT +// reactivate, and still releases the interlock via `finally`. A hung command trips the +// AT watchdog, which force-uninhibits so the system reprobes rather than wedging. + +import type { DeviceIfname, InhibitLease, ModemManagerPort, NetworkManagerPort } from '../ports'; +import { deviceIfname } from '../ports'; +import { CERTIFIED_CATALOG, type CertifiedCatalog, type PermittedTransition } from '../usb-mode'; +import { + type AtAuditSink, + AtCommandLease, + type AtCommandSender, + computeAtAllowlist, +} from './at-lease'; +import { descriptorsMatch, detectUsbMode, type UsbDeviceSnapshot } from './device-classifier'; +import type { ModemActor } from './modem-actor'; +import { + ALLOW_ALL_TRANSITION_INTERLOCK, + checkTransitionPreconditions, + type TransitionInterlock, + type UsbModeTransitionOutcome, + type UsbModeTransitionRequest, +} from './transition-preconditions'; + +const sleep = (ms: number): Promise => new Promise((resolve) => setTimeout(resolve, ms)); + +const DEFAULT_WATCHDOG_MS = 30_000; +const DEFAULT_REENUM_TIMEOUT_MS = 60_000; +const DEFAULT_POLL_INTERVAL_MS = 250; + +/** Construction dependencies for the transition. Everything I/O is injectable. */ +export interface UsbModeTransitionDeps { + readonly actor: ModemActor; + readonly nm: NetworkManagerPort; + readonly modemManager: Pick; + readonly atSender: AtCommandSender; + /** Refresh-triggered USB enumeration (e.g. `createUsbEnumerator().enumerate`). */ + readonly enumerate: () => Promise; + readonly interlock?: TransitionInterlock; + readonly catalog?: CertifiedCatalog; + readonly audit?: AtAuditSink; + readonly resolveIfname?: (device: UsbDeviceSnapshot) => DeviceIfname | undefined; + readonly watchdogMs?: number; + readonly reenumerationTimeoutMs?: number; + readonly pollIntervalMs?: number; +} + +function defaultResolveIfname(device: UsbDeviceSnapshot): DeviceIfname | undefined { + return device.ifname !== undefined && device.ifname !== '' + ? deviceIfname(device.ifname) + : undefined; +} + +/** The USB-mode transition transaction. One instance is reusable across requests. */ +export class UsbModeTransition { + readonly #actor: ModemActor; + readonly #nm: NetworkManagerPort; + readonly #modemManager: Pick; + readonly #atSender: AtCommandSender; + readonly #enumerate: () => Promise; + readonly #interlock: TransitionInterlock; + readonly #catalog: CertifiedCatalog; + readonly #audit: AtAuditSink | undefined; + readonly #resolveIfname: (device: UsbDeviceSnapshot) => DeviceIfname | undefined; + readonly #watchdogMs: number; + readonly #reenumMs: number; + readonly #pollMs: number; + + constructor(deps: UsbModeTransitionDeps) { + this.#actor = deps.actor; + this.#nm = deps.nm; + this.#modemManager = deps.modemManager; + this.#atSender = deps.atSender; + this.#enumerate = deps.enumerate; + this.#interlock = deps.interlock ?? ALLOW_ALL_TRANSITION_INTERLOCK; + this.#catalog = deps.catalog ?? CERTIFIED_CATALOG; + this.#audit = deps.audit; + this.#resolveIfname = deps.resolveIfname ?? defaultResolveIfname; + this.#watchdogMs = deps.watchdogMs ?? DEFAULT_WATCHDOG_MS; + this.#reenumMs = deps.reenumerationTimeoutMs ?? DEFAULT_REENUM_TIMEOUT_MS; + this.#pollMs = deps.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS; + } + + /** Run one transition. Preconditions are checked at entry, then again in-actor. */ + async execute(request: UsbModeTransitionRequest): Promise { + const steps: string[] = []; + // ENTRY check — a doomed request NEVER enters the actor (TIER A: zero calls). + const entry = await checkTransitionPreconditions(request, this.#catalog, this.#interlock); + if (!entry.ok) { + return { status: 'refused', stage: 'entry', reason: entry.reason, steps }; + } + return this.#actor.run(request.stableKey, () => this.#inActor(request, steps)); + } + + async #inActor( + request: UsbModeTransitionRequest, + steps: string[], + ): Promise { + steps.push('actor-enter'); + // IN-ACTOR re-check — catches a race that closed a gate while queued (TIER B). + const recheck = await checkTransitionPreconditions(request, this.#catalog, this.#interlock); + if (!recheck.ok) { + return { status: 'refused', stage: 'in-actor', reason: recheck.reason, steps }; + } + const hold = await this.#interlock.hold({ stableKey: request.stableKey }); + try { + return await this.#runTransaction( + request, + recheck.entry.permittedTransitions, + recheck.transition, + steps, + ); + } finally { + steps.push('release-interlock'); + await hold.release().catch(() => undefined); + } + } + + async #runTransaction( + request: UsbModeTransitionRequest, + allCommands: readonly PermittedTransition[], + transition: PermittedTransition, + steps: string[], + ): Promise { + let inhibit: InhibitLease | undefined; + let reactivated = false; + const forceUninhibit = async (): Promise => { + if (inhibit === undefined) { + return; + } + const held = inhibit; + inhibit = undefined; + steps.push('force-uninhibit'); + await this.#modemManager.uninhibit(held).catch(() => undefined); + }; + const lease = new AtCommandLease({ + sender: this.#atSender, + allowlist: computeAtAllowlist(allCommands.map((t) => t.atCommand)), + timeoutMs: this.#watchdogMs, + onWatchdog: forceUninhibit, + ...(this.#audit !== undefined ? { audit: this.#audit } : {}), + }); + + steps.push('nm-quiesce'); + const quiesce = await this.#nm.acquireQuiesceLease(request.connectionId, request.deviceIfname); + try { + steps.push('inhibit'); + inhibit = await this.#modemManager.inhibit(request.inhibitUid); + + // AT `OK` is IGNORED for success — only the postcondition below decides. + steps.push('at-command'); + await lease.run(transition.atCommand, { inhibitUid: request.inhibitUid }); + + steps.push('await-port-drop'); + await this.#awaitPortDrop(request.cachedPhysicalUid); + + steps.push('uninhibit'); + if (inhibit !== undefined) { + const held = inhibit; + inhibit = undefined; + await this.#modemManager.uninhibit(held); + } + + steps.push('await-reenumeration'); + const device = await this.#awaitReenumeration(request.cachedPhysicalUid); + + steps.push('postcondition'); + const observedMode = detectUsbMode(device); + const descriptorsOk = descriptorsMatch(device, transition.expectedDescriptors); + if (observedMode !== request.toMode || !descriptorsOk) { + return { + status: 'failed', + degraded: true, + reason: `postcondition mismatch: observed ${observedMode ?? 'unknown'} vs target ${request.toMode}; descriptors ${descriptorsOk ? 'ok' : 'mismatch'}`, + steps, + }; + } + + steps.push('resolve-ifname'); + const newIfname = this.#resolveIfname(device); + if (newIfname === undefined) { + return { status: 'failed', degraded: true, reason: 'could not resolve new ifname', steps }; + } + + steps.push('reactivate'); + await this.#nm.activate(request.connectionId, newIfname); + reactivated = true; + return { status: 'succeeded', newIfname, steps }; + } catch (error) { + await forceUninhibit(); + await this.#reprobe(); + return { + status: 'failed', + degraded: true, + reason: error instanceof Error ? error.message : String(error), + steps, + }; + } finally { + await forceUninhibit(); + if (!reactivated) { + // Failure path: restore the old connection. On success the new-ifname + // activation supersedes the quiesce lease (old ifname is gone). + steps.push('release-quiesce'); + await this.#nm.releaseQuiesceLease(quiesce).catch(() => undefined); + } + } + } + + async #awaitPortDrop(uid: string): Promise { + const deadline = Date.now() + this.#reenumMs; + while (Date.now() < deadline) { + const devices = await this.#enumerate(); + if (!devices.some((d) => d.physicalUid === uid)) { + return; + } + await sleep(this.#pollMs); + } + throw new Error(`control port did not drop within ${this.#reenumMs}ms (uid ${uid})`); + } + + async #awaitReenumeration(uid: string): Promise { + const deadline = Date.now() + this.#reenumMs; + while (Date.now() < deadline) { + const devices = await this.#enumerate(); + const device = devices.find((d) => d.physicalUid === uid); + if (device !== undefined) { + return device; + } + await sleep(this.#pollMs); + } + throw new Error(`device did not re-enumerate within ${this.#reenumMs}ms (uid ${uid})`); + } + + /** Best-effort state re-read after a crash — the transaction still fails degraded. */ + async #reprobe(): Promise { + await this.#enumerate().then( + () => undefined, + () => undefined, + ); + } +} diff --git a/control/src/domain/brand.ts b/control/src/domain/brand.ts new file mode 100644 index 0000000..93bf313 --- /dev/null +++ b/control/src/domain/brand.ts @@ -0,0 +1,29 @@ +// Nominal (branded) primitive types for the domain layer. +// +// Branding stops the four identity strings — and the two counter numbers — from +// being interchangeable at the type level: a `LogicalSlotId` can never be passed +// where a `SubscriptionId` is expected, even though both are strings at runtime. +// Downstream waves (A2.2 ports, A3.x D-Bus backend) depend on this distinction. + +import { DomainError } from './errors'; + +declare const brand: unique symbol; + +/** A primitive `T` tagged with a compile-time-only brand `B`. Erased at runtime. */ +export type Brand = T & { readonly [brand]: B }; + +/** Assert a value is a non-empty string, throwing a typed error otherwise. */ +export function nonEmptyString(value: string, label: string): string { + if (value.length === 0) { + throw new DomainError(`${label} must be a non-empty string`); + } + return value; +} + +/** Assert a value is a non-negative safe integer, throwing a typed error otherwise. */ +export function nonNegativeInteger(value: number, label: string): number { + if (!Number.isSafeInteger(value) || value < 0) { + throw new DomainError(`${label} must be a non-negative safe integer, got ${value}`); + } + return value; +} diff --git a/control/src/domain/errors.ts b/control/src/domain/errors.ts new file mode 100644 index 0000000..24f5c03 --- /dev/null +++ b/control/src/domain/errors.ts @@ -0,0 +1,77 @@ +// Typed errors for the domain layer. +// +// The domain never fails silently: an impossible state combination, a refused +// policy binding, or a non-monotonic revision each raise a distinct, catchable +// error class carrying a machine-readable reason. QA and callers discriminate on +// the class and the `code`, never on a message string. + +/** Base class for every domain-layer error. Callers can catch this to trap all. */ +export class DomainError extends Error { + override readonly name: string = 'DomainError'; + + constructor(message: string) { + super(message); + // Restore the prototype chain across the ES5 target transpile so + // `instanceof` works on subclasses (standard TS extends-Error guard). + Object.setPrototypeOf(this, new.target.prototype); + } +} + +/** + * Every distinct impossible cross-dimension combination the guards reject. + * One code per rule so a failing construction names exactly what it violated. + */ +export type ImpossibleStateCode = + | 'registered-while-absent' + | 'registered-radio-off' + | 'registered-empty-rat-set' + | 'active-state-while-absent' + | 'radio-off-while-active' + | 'nm-activated-while-absent' + | 'nm-activated-without-interface' + | 'nm-activated-without-mm-connected' + | 'multiple-active-sim-slots' + | 'locked-sim-in-empty-slot' + | 'mm-locked-without-sim-lock' + | 'data-interface-name-without-presence' + | 'recovery-attempts-negative' + | 'recovery-cooldown-stage-mismatch' + | 'recovery-idle-with-attempts'; + +/** A snapshot was constructed (or transitioned into) a physically impossible state. */ +export class ImpossibleStateError extends DomainError { + override readonly name = 'ImpossibleStateError'; + readonly code: ImpossibleStateCode; + + constructor(code: ImpossibleStateCode, detail: string) { + super(`impossible cellular state [${code}]: ${detail}`); + this.code = code; + } +} + +/** + * A durable policy binding was attempted against an identity that is not allowed + * to carry one — today only low-confidence (ambiguous) equipment identities. + */ +export class PolicyBindingRefusedError extends DomainError { + override readonly name = 'PolicyBindingRefusedError'; + readonly reason: 'ambiguous-identity'; + + constructor(detail: string) { + super(`durable policy binding refused: ${detail}`); + this.reason = 'ambiguous-identity'; + } +} + +/** A snapshot transition tried to keep or lower the monotonic revision. */ +export class RevisionMonotonicityError extends DomainError { + override readonly name = 'RevisionMonotonicityError'; + readonly previous: number; + readonly next: number; + + constructor(previous: number, next: number) { + super(`revision must strictly increase: ${previous} -> ${next}`); + this.previous = previous; + this.next = next; + } +} diff --git a/control/src/domain/guards.test.ts b/control/src/domain/guards.test.ts new file mode 100644 index 0000000..85d1f78 --- /dev/null +++ b/control/src/domain/guards.test.ts @@ -0,0 +1,218 @@ +import { describe, expect, test } from 'bun:test'; +import { type ImpossibleStateCode, ImpossibleStateError } from './errors'; +import { imeiEquipmentId, type ModemIdentity, runtimePath } from './identity'; +import type { CellularSnapshot } from './snapshot'; +import { createSnapshot, revision } from './snapshot'; +import { epochMillis } from './state'; + +const IDENTITY: ModemIdentity = { + equipmentId: imeiEquipmentId('490154203237518'), + runtimePath: runtimePath('/org/freedesktop/ModemManager1/Modem/0'), +}; + +/** A fully valid registered + connected + NM-activated snapshot to mutate from. */ +function connectedBase(): CellularSnapshot { + return { + identity: IDENTITY, + presence: 'present', + sourceHealth: 'live', + simSlots: [{ index: 1, occupied: true, active: true, lock: 'none' }], + radioPower: 'on', + mmState: 'connected', + registration: { status: 'home', activeRats: new Set(['lte']) }, + nmActivation: 'activated', + dataInterface: { present: true, name: 'wwan0' }, + reconcileStatus: 'converged', + recoveryState: { stage: 'idle', attempts: 0 }, + revision: revision(5), + }; +} + +function expectImpossible(fields: CellularSnapshot, code: ImpossibleStateCode): void { + expect(() => createSnapshot(fields)).toThrow(ImpossibleStateError); + try { + createSnapshot(fields); + } catch (error) { + expect(error).toBeInstanceOf(ImpossibleStateError); + expect((error as ImpossibleStateError).code).toBe(code); + return; + } + throw new Error(`expected ${code} to throw`); +} + +describe('valid snapshots pass', () => { + test('a coherent connected snapshot constructs', () => { + expect(() => createSnapshot(connectedBase())).not.toThrow(); + }); + + test('mmState locked WITH an occupied pin-locked slot is valid', () => { + const s: CellularSnapshot = { + ...connectedBase(), + mmState: 'locked', + nmActivation: 'disconnected', + registration: { status: 'idle', activeRats: new Set() }, + radioPower: 'on', + dataInterface: { present: false }, + simSlots: [{ index: 1, occupied: true, active: true, lock: 'sim-pin' }], + }; + expect(() => createSnapshot(s)).not.toThrow(); + }); + + test('a cooldown recovery state with a deadline is valid', () => { + const s: CellularSnapshot = { + ...connectedBase(), + recoveryState: { + stage: 'cooldown', + attempts: 2, + cooldownUntil: epochMillis(1_700_000_000_000), + }, + }; + expect(() => createSnapshot(s)).not.toThrow(); + }); +}); + +describe('impossible combinations throw typed errors', () => { + test('registered while absent (the named guard)', () => { + expectImpossible({ ...connectedBase(), presence: 'absent' }, 'registered-while-absent'); + }); + + test('registered with the radio off', () => { + expectImpossible({ ...connectedBase(), radioPower: 'off' }, 'registered-radio-off'); + }); + + test('registered with an empty RAT set', () => { + expectImpossible( + { ...connectedBase(), registration: { status: 'home', activeRats: new Set() } }, + 'registered-empty-rat-set', + ); + }); + + test('an active MM state while absent', () => { + expectImpossible( + { + ...connectedBase(), + presence: 'absent', + mmState: 'enabled', + registration: { status: 'idle', activeRats: new Set() }, + nmActivation: 'disconnected', + dataInterface: { present: false }, + }, + 'active-state-while-absent', + ); + }); + + test('the radio off while searching', () => { + expectImpossible( + { + ...connectedBase(), + radioPower: 'off', + mmState: 'searching', + registration: { status: 'searching', activeRats: new Set() }, + nmActivation: 'disconnected', + dataInterface: { present: false }, + }, + 'radio-off-while-active', + ); + }); + + test('NM activated while absent', () => { + expectImpossible( + { + ...connectedBase(), + presence: 'absent', + mmState: 'disabled', + registration: { status: 'idle', activeRats: new Set() }, + dataInterface: { present: false }, + }, + 'nm-activated-while-absent', + ); + }); + + test('NM activated without a data interface', () => { + expectImpossible( + { ...connectedBase(), dataInterface: { present: false } }, + 'nm-activated-without-interface', + ); + }); + + test('NM activated while MM is not connected', () => { + expectImpossible( + { ...connectedBase(), mmState: 'registered' }, + 'nm-activated-without-mm-connected', + ); + }); + + test('more than one active SIM slot', () => { + expectImpossible( + { + ...connectedBase(), + simSlots: [ + { index: 1, occupied: true, active: true, lock: 'none' }, + { index: 2, occupied: true, active: true, lock: 'none' }, + ], + }, + 'multiple-active-sim-slots', + ); + }); + + test('a locked SIM in an empty slot', () => { + expectImpossible( + { + ...connectedBase(), + simSlots: [{ index: 1, occupied: false, active: false, lock: 'sim-pin' }], + }, + 'locked-sim-in-empty-slot', + ); + }); + + test('MM locked without any locked SIM', () => { + expectImpossible( + { + ...connectedBase(), + mmState: 'locked', + nmActivation: 'disconnected', + registration: { status: 'idle', activeRats: new Set() }, + dataInterface: { present: false }, + simSlots: [{ index: 1, occupied: true, active: true, lock: 'none' }], + }, + 'mm-locked-without-sim-lock', + ); + }); + + test('a data-interface name without presence', () => { + expectImpossible( + { + ...connectedBase(), + mmState: 'disabled', + nmActivation: 'disconnected', + registration: { status: 'idle', activeRats: new Set() }, + dataInterface: { present: false, name: 'wwan0' }, + }, + 'data-interface-name-without-presence', + ); + }); + + test('a negative recovery attempt count', () => { + expectImpossible( + { ...connectedBase(), recoveryState: { stage: 'nm-cycle', attempts: -1 } }, + 'recovery-attempts-negative', + ); + }); + + test('a cooldown deadline outside the cooldown stage', () => { + expectImpossible( + { + ...connectedBase(), + recoveryState: { stage: 'nm-cycle', attempts: 1, cooldownUntil: epochMillis(123) }, + }, + 'recovery-cooldown-stage-mismatch', + ); + }); + + test('idle recovery carrying attempts', () => { + expectImpossible( + { ...connectedBase(), recoveryState: { stage: 'idle', attempts: 3 } }, + 'recovery-idle-with-attempts', + ); + }); +}); diff --git a/control/src/domain/guards.ts b/control/src/domain/guards.ts new file mode 100644 index 0000000..7820e47 --- /dev/null +++ b/control/src/domain/guards.ts @@ -0,0 +1,144 @@ +// Impossible-combination guards. +// +// The orthogonal dimensions are independent, but not EVERY combination is +// physically real. A modem cannot be `registered` while `absent`; the radio +// cannot be `off` while the modem is `connected`. Each rule below rejects one +// such impossible cross-dimension combination with a distinct code, so a bad +// construction names exactly what it violated instead of silently persisting an +// incoherent snapshot. `checkSnapshot` returns the first violation (or null); +// `assertSnapshot` throws `ImpossibleStateError`. + +import type { ImpossibleStateCode } from './errors'; +import { ImpossibleStateError } from './errors'; +import type { CellularSnapshot } from './snapshot'; +import { isRegistered, MM_STATES_REQUIRING_RADIO, SIM_LOCK_REQUIRES_CARD } from './state'; + +type Violation = { readonly code: ImpossibleStateCode; readonly detail: string } | null; + +function checkRegistration(s: CellularSnapshot): Violation { + if (!isRegistered(s.registration.status)) { + return null; + } + if (s.presence === 'absent') { + return { code: 'registered-while-absent', detail: `status=${s.registration.status}` }; + } + if (s.radioPower === 'off') { + return { code: 'registered-radio-off', detail: `status=${s.registration.status}` }; + } + if (s.registration.activeRats.size === 0) { + return { code: 'registered-empty-rat-set', detail: `status=${s.registration.status}` }; + } + return null; +} + +function radioIsOnTheAir(s: CellularSnapshot): boolean { + return ( + MM_STATES_REQUIRING_RADIO.has(s.mmState) || + s.registration.status === 'searching' || + s.nmActivation === 'activating' || + s.nmActivation === 'activated' + ); +} + +function checkRadioAndPresence(s: CellularSnapshot): Violation { + if (MM_STATES_REQUIRING_RADIO.has(s.mmState) && s.presence === 'absent') { + return { code: 'active-state-while-absent', detail: `mmState=${s.mmState}` }; + } + if (s.radioPower === 'off' && radioIsOnTheAir(s)) { + return { code: 'radio-off-while-active', detail: `mmState=${s.mmState}` }; + } + return null; +} + +function checkNmActivation(s: CellularSnapshot): Violation { + if (s.nmActivation !== 'activated') { + return null; + } + if (s.presence === 'absent') { + return { code: 'nm-activated-while-absent', detail: 'nmActivation=activated' }; + } + if (!s.dataInterface.present) { + return { code: 'nm-activated-without-interface', detail: 'nmActivation=activated' }; + } + if (s.mmState !== 'connected') { + return { code: 'nm-activated-without-mm-connected', detail: `mmState=${s.mmState}` }; + } + return null; +} + +function checkSimSlots(s: CellularSnapshot): Violation { + let activeCount = 0; + let hasLockedCard = false; + for (const slot of s.simSlots) { + if (slot.active) { + activeCount += 1; + } + if (!slot.occupied && SIM_LOCK_REQUIRES_CARD.has(slot.lock)) { + return { code: 'locked-sim-in-empty-slot', detail: `slot=${slot.index} lock=${slot.lock}` }; + } + if (slot.occupied && SIM_LOCK_REQUIRES_CARD.has(slot.lock)) { + hasLockedCard = true; + } + } + if (activeCount > 1) { + return { code: 'multiple-active-sim-slots', detail: `active=${activeCount}` }; + } + if (s.mmState === 'locked' && !hasLockedCard) { + return { code: 'mm-locked-without-sim-lock', detail: 'mmState=locked' }; + } + return null; +} + +function checkDataInterface(s: CellularSnapshot): Violation { + if (!s.dataInterface.present && s.dataInterface.name !== undefined) { + return { + code: 'data-interface-name-without-presence', + detail: `name=${s.dataInterface.name}`, + }; + } + return null; +} + +function checkRecovery(s: CellularSnapshot): Violation { + const { stage, attempts, cooldownUntil } = s.recoveryState; + if (!Number.isSafeInteger(attempts) || attempts < 0) { + return { code: 'recovery-attempts-negative', detail: `attempts=${attempts}` }; + } + if ((cooldownUntil !== undefined) !== (stage === 'cooldown')) { + return { code: 'recovery-cooldown-stage-mismatch', detail: `stage=${stage}` }; + } + if (stage === 'idle' && attempts !== 0) { + return { code: 'recovery-idle-with-attempts', detail: `attempts=${attempts}` }; + } + return null; +} + +const CHECKS: ReadonlyArray<(s: CellularSnapshot) => Violation> = [ + checkRegistration, + checkRadioAndPresence, + checkNmActivation, + checkSimSlots, + checkDataInterface, + checkRecovery, +]; + +/** Return the first impossible-combination code the snapshot violates, or null. */ +export function checkSnapshot(snapshot: CellularSnapshot): ImpossibleStateCode | null { + for (const check of CHECKS) { + const violation = check(snapshot); + if (violation !== null) { + return violation.code; + } + } + return null; +} + +/** Throw `ImpossibleStateError` if the snapshot holds an impossible combination. */ +export function assertSnapshot(snapshot: CellularSnapshot): void { + for (const check of CHECKS) { + const violation = check(snapshot); + if (violation !== null) { + throw new ImpossibleStateError(violation.code, violation.detail); + } + } +} diff --git a/control/src/domain/identity.test.ts b/control/src/domain/identity.test.ts new file mode 100644 index 0000000..dca86e5 --- /dev/null +++ b/control/src/domain/identity.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, test } from 'bun:test'; +import { PolicyBindingRefusedError } from './errors'; +import { + canBindPolicy, + demoteToLowConfidence, + imeiEquipmentId, + type ModemIdentity, + NO_EQUIPMENT_ID, + runtimePath, + serialEquipmentId, +} from './identity'; +import { policyBindingKey } from './policy'; + +const PATH = runtimePath('/org/freedesktop/ModemManager1/Modem/0'); + +function identityWith(equipmentId: ModemIdentity['equipmentId']): ModemIdentity { + return { equipmentId, runtimePath: PATH }; +} + +describe('equipment id confidence grading', () => { + test('a canonical 15-digit IMEI grades high', () => { + const id = imeiEquipmentId('490154203237518'); + expect(id).toEqual({ provenance: 'imei', value: '490154203237518', confidence: 'high' }); + }); + + test('an all-zeros IMEI grades low (ambiguous placeholder)', () => { + expect(imeiEquipmentId('000000000000000').confidence).toBe('low'); + }); + + test('a blank IMEI grades low', () => { + expect(imeiEquipmentId(' ').confidence).toBe('low'); + }); + + test('a non-standard IMEI shape grades medium', () => { + expect(imeiEquipmentId('49015420').confidence).toBe('medium'); + }); + + test('a serial fallback caps at medium', () => { + expect(serialEquipmentId('SN-ABC-123').confidence).toBe('medium'); + }); + + test('the none provenance carries no value and is always low', () => { + expect(NO_EQUIPMENT_ID).toEqual({ provenance: 'none', confidence: 'low' }); + }); +}); + +describe('duplicate demotion', () => { + test('a duplicate IMEI is demoted to low confidence but keeps its value', () => { + const original = imeiEquipmentId('490154203237518'); + const demoted = demoteToLowConfidence(original); + expect(demoted).toEqual({ provenance: 'imei', value: '490154203237518', confidence: 'low' }); + }); + + test('demoting a none id is a no-op', () => { + expect(demoteToLowConfidence(NO_EQUIPMENT_ID)).toBe(NO_EQUIPMENT_ID); + }); +}); + +describe('durable policy binding gate', () => { + test('a high-confidence identity may bind policy and yields a key', () => { + const identity = identityWith(imeiEquipmentId('490154203237518')); + expect(canBindPolicy(identity)).toBe(true); + expect(policyBindingKey(identity)).toEqual({ equipmentId: identity.equipmentId }); + }); + + test('a low-confidence (zero IMEI) identity is refused', () => { + const identity = identityWith(imeiEquipmentId('000000000000000')); + expect(canBindPolicy(identity)).toBe(false); + expect(() => policyBindingKey(identity)).toThrow(PolicyBindingRefusedError); + }); + + test('a duplicate-demoted identity is refused', () => { + const identity = identityWith(demoteToLowConfidence(imeiEquipmentId('490154203237518'))); + expect(canBindPolicy(identity)).toBe(false); + expect(() => policyBindingKey(identity)).toThrow(PolicyBindingRefusedError); + }); + + test('a none-provenance identity is refused (never binds durable policy)', () => { + const identity = identityWith(NO_EQUIPMENT_ID); + expect(canBindPolicy(identity)).toBe(false); + expect(() => policyBindingKey(identity)).toThrow(PolicyBindingRefusedError); + }); +}); diff --git a/control/src/domain/identity.ts b/control/src/domain/identity.ts new file mode 100644 index 0000000..0f52faa --- /dev/null +++ b/control/src/domain/identity.ts @@ -0,0 +1,165 @@ +// Modem identity — the four ids that pin a physical modem across its lifecycle. +// +// Design (draft §Oracle round-1): a modem is identified by FOUR distinct ids, not +// one, because each answers a different question and each has a different lifetime: +// +// - logicalSlotId — the stable physical-slot handle. Durable policy and routing +// bind to THIS. Optional: absent until the identity ladder +// (A3.2) resolves a slot from udev/Physdev/sysfs. +// - equipmentId — IMEI (or serial fallback) with provenance + confidence. +// - subscriptionId — ICCID/EID. SENSITIVE (see marker below). Optional: no SIM. +// - runtimePath — the live ModemManager D-Bus object path. NEVER PERSISTED. + +import type { Brand } from './brand'; +import { nonEmptyString } from './brand'; +import { PolicyBindingRefusedError } from './errors'; + +/** + * Stable physical-slot handle (e.g. `slot-usb-1-2`). The ONLY id durable policy + * and routing are allowed to bind to — it survives equipment swaps in the slot. + */ +export type LogicalSlotId = Brand; + +/** + * SIM subscription id (ICCID or EID). + * + * SENSITIVE — this value identifies a subscriber. It MUST be redacted in every + * log, telemetry, and error surface (the redaction module lands in A2.2). Treat + * it as PII: never print it raw, never include it in a policy binding key. + */ +export type SubscriptionId = Brand; + +/** + * The live ModemManager D-Bus object path (e.g. `/org/freedesktop/ModemManager1/Modem/3`). + * + * NEVER PERSISTED — this is a per-boot runtime handle. ModemManager reassigns it + * across daemon restarts and device replug, so it is meaningless in durable + * storage. Durable keys derive from `logicalSlotId`/`equipmentId` only; storage + * code in later waves must never write this field. The type name and this note + * make that intent unambiguous at the domain layer. + */ +export type RuntimePath = Brand; + +/** How the equipment id was obtained. Discriminated union over the source. */ +export type EquipmentProvenance = 'imei' | 'serial' | 'none'; + +/** + * Trust in the equipment id as a durable key. + * + * - high — a well-formed, unique IMEI. + * - medium — a serial fallback, or an IMEI of non-standard shape. + * - low — AMBIGUOUS: a zero/blank IMEI, or a value seen on more than one + * modem (duplicate). A low-confidence identity must NEVER bind + * durable policy — see `policyBindingKey` / `canBindPolicy`. + */ +export type IdentityConfidence = 'high' | 'medium' | 'low'; + +/** + * The equipment id, discriminated on `provenance`. + * + * `imei` and `serial` carry a `value`; `none` carries none — we genuinely have no + * equipment identifier, so `value` would be a lie. `none` is always low confidence. + */ +export type EquipmentId = + | { readonly provenance: 'imei'; readonly value: string; readonly confidence: IdentityConfidence } + | { + readonly provenance: 'serial'; + readonly value: string; + readonly confidence: IdentityConfidence; + } + | { readonly provenance: 'none'; readonly confidence: 'low' }; + +/** The four-part modem identity. */ +export interface ModemIdentity { + /** Absent until the identity ladder resolves a physical slot. */ + readonly logicalSlotId?: LogicalSlotId; + readonly equipmentId: EquipmentId; + /** Absent with no SIM. SENSITIVE — redact everywhere (A2.2 redaction module). */ + readonly subscriptionId?: SubscriptionId; + /** NEVER PERSISTED — per-boot runtime handle only. */ + readonly runtimePath: RuntimePath; +} + +// --- constructors ---------------------------------------------------------- + +/** Construct a `LogicalSlotId` from a non-empty string. */ +export function logicalSlotId(value: string): LogicalSlotId { + return nonEmptyString(value, 'logicalSlotId') as LogicalSlotId; +} + +/** Construct a `SubscriptionId` from a non-empty string. */ +export function subscriptionId(value: string): SubscriptionId { + return nonEmptyString(value, 'subscriptionId') as SubscriptionId; +} + +/** Construct a `RuntimePath` from a non-empty string. */ +export function runtimePath(value: string): RuntimePath { + return nonEmptyString(value, 'runtimePath') as RuntimePath; +} + +const ALL_ZEROS = /^0+$/; + +/** + * Grade an IMEI's confidence from its own shape alone. + * + * A blank or all-zeros IMEI is a well-known ambiguous placeholder → `low`. A + * canonical 15/16-digit IMEI → `high`. Anything else → `medium`. DUPLICATE + * detection is cross-modem and cannot be done from a single value here; A3.2 + * demotes duplicates to `low` via `demoteToLowConfidence`. + */ +function gradeImei(value: string): IdentityConfidence { + const trimmed = value.trim(); + if (trimmed.length === 0 || ALL_ZEROS.test(trimmed)) { + return 'low'; + } + return /^\d{15,16}$/.test(trimmed) ? 'high' : 'medium'; +} + +/** The absence of any equipment id — always low confidence. */ +export const NO_EQUIPMENT_ID: EquipmentId = { provenance: 'none', confidence: 'low' }; + +/** Build an IMEI-provenance equipment id, grading confidence from the value. */ +export function imeiEquipmentId(value: string): EquipmentId { + return { provenance: 'imei', value, confidence: gradeImei(value) }; +} + +/** + * Build a serial-fallback equipment id. A serial is never as trustworthy as a + * clean IMEI, so it caps at `medium` (blank → `low`). + */ +export function serialEquipmentId(value: string): EquipmentId { + const confidence: IdentityConfidence = value.trim().length === 0 ? 'low' : 'medium'; + return { provenance: 'serial', value, confidence }; +} + +/** + * Demote an equipment id to `low` confidence — used by the identity ladder when a + * value turns out to be shared across modems (duplicate IMEI). Idempotent; `none` + * is already low. + */ +export function demoteToLowConfidence(id: EquipmentId): EquipmentId { + if (id.provenance === 'none') { + return id; + } + return { provenance: id.provenance, value: id.value, confidence: 'low' }; +} + +// --- policy-binding gate ---------------------------------------------------- + +/** + * Whether this identity may bind DURABLE policy. False for ambiguous + * (low-confidence) equipment identities — a duplicate or zero IMEI must never + * become the durable key that a saved policy attaches to. + */ +export function canBindPolicy(identity: ModemIdentity): boolean { + return identity.equipmentId.confidence !== 'low'; +} + +/** Throw `PolicyBindingRefusedError` if the identity may not bind durable policy. */ +export function assertCanBindPolicy(identity: ModemIdentity): void { + if (!canBindPolicy(identity)) { + throw new PolicyBindingRefusedError( + `equipment id (provenance=${identity.equipmentId.provenance}) is low-confidence`, + ); + } +} diff --git a/control/src/domain/index.ts b/control/src/domain/index.ts new file mode 100644 index 0000000..551b7a6 --- /dev/null +++ b/control/src/domain/index.ts @@ -0,0 +1,12 @@ +// Domain layer — pure types + reducers for modem identity and orthogonal state. +// +// No I/O, no D-Bus, no NetworkManager: this module is data and pure functions +// only. Later waves (A2.2 ports, A3.x D-Bus backend) build on these exact shapes. + +export * from './brand'; +export * from './errors'; +export * from './guards'; +export * from './identity'; +export * from './policy'; +export * from './snapshot'; +export * from './state'; diff --git a/control/src/domain/policy.ts b/control/src/domain/policy.ts new file mode 100644 index 0000000..c62f98f --- /dev/null +++ b/control/src/domain/policy.ts @@ -0,0 +1,137 @@ +// Durable desired-state policy — the operator's intent for one modem. +// +// A2.1 stubbed this shape so the domain could enforce "an ambiguous identity may +// never bind durable policy" (see `policyBindingKey`). A2.2 owns the REAL +// definition below: the full connection / roaming / radio / simSlot / recovery / +// usage intent the desired-state planner reconciles (see `../ports/reconcile`). + +import type { EquipmentId, LogicalSlotId, ModemIdentity } from './identity'; +import { assertCanBindPolicy } from './identity'; +import type { RadioAccessTechnology } from './state'; + +/** + * The subset of identity a durable policy is allowed to key off. + * + * Deliberately excludes `runtimePath` (never persisted) and `subscriptionId` + * (sensitive). Durable policy binds to the physical slot when known, always + * anchored by the equipment id — never to a per-boot handle or a subscriber id. + */ +export interface PolicyBindingKey { + readonly logicalSlotId?: LogicalSlotId; + readonly equipmentId: EquipmentId; +} + +/** IP address family a connection requests. */ +export type IpFamily = 'ipv4' | 'ipv6' | 'ipv4v6'; + +/** + * Connection credentials. TODAY'S SEMANTICS (draft §round-5 auth): these are + * persisted ONLY in the NetworkManager profile; the controller keeps them + * transient in memory and NEVER writes them to its own store. `password` is + * SENSITIVE and is ALWAYS redacted in logs / output / receipts (see `../redact`). + */ +export interface DesiredAuth { + readonly username?: string; + /** SENSITIVE — always redacted; NM-profile-persisted only, transient in memory. */ + readonly password?: string; +} + +/** The data-connection intent. `apn: "auto"` selects NM Auto-APN (A4.1). */ +export interface DesiredConnection { + readonly apn: 'auto' | string; + readonly ipFamily: IpFamily; + readonly auth?: DesiredAuth; + /** + * Manual operator selection (`gsm.network-id`) — pin registration to a specific + * PLMN. Honored only while roaming (A4.1 amendment): the NM profile writes + * `gsm.network-id = roaming ? networkId : ""`, so it is cleared when roaming is off. + */ + readonly networkId?: string; +} + +/** + * Radio access-technology intent. `preferenceOrdered` is ranked most-preferred + * first ("prefer 5G" ⇒ `['5gnr', 'lte', …]`); it must be honored as a PREFERENCE, + * never silently narrowed to an exclusive set — the planner reports `unsupported` + * when the top preference is unavailable (never a silent downgrade). `allowedSet`, + * when present, hard-limits the technologies the modem may use at all. + */ +export interface DesiredRadio { + readonly preferenceOrdered: readonly RadioAccessTechnology[]; + readonly allowedSet?: ReadonlySet; +} + +/** + * Recovery intent. DISABLED BY DEFAULT — the evidence-gated recovery ladder (A3.4) + * adds the per-step budgets / cooldowns; in Phase A `enabled` defaults to `false` + * and no recovery action is ever taken unless it is explicitly turned on. + */ +export interface DesiredRecovery { + readonly enabled: boolean; +} + +/** Data-usage policy — local-controller owned (see ports README ownership table). */ +export interface DesiredUsage { + /** Day of month (1–31) the usage cycle resets; UTC, month-length clamped (A4.3). */ + readonly cycleDay?: number; + /** Advisory threshold in bytes; crossing it raises an advisory, never gates. */ + readonly thresholdBytes?: number; +} + +/** + * Operator-authored durable cellular policy, bound to a high/medium-confidence + * identity via `boundTo` (a low-confidence identity cannot produce a binding key, + * so it can never be named here). + * + * - enabled — desired NM activation state (`enabled ≙ NM-activation`). + * - connection — APN (or `"auto"`), IP family, optional (redacted) credentials. + * - roaming — allow the modem to register while roaming. + * - radio — ranked RAT preference (+ optional hard-allowed set). + * - simSlot — preferred primary SIM slot (1-based; multi-slot modems). + * - recovery — recovery-ladder intent (disabled by default). + * - usage — data-usage cycle + advisory threshold. + */ +export interface DesiredCellularPolicy { + readonly boundTo: PolicyBindingKey; + readonly enabled: boolean; + readonly connection: DesiredConnection; + readonly roaming: boolean; + readonly radio: DesiredRadio; + readonly simSlot?: number; + readonly recovery: DesiredRecovery; + readonly usage: DesiredUsage; +} + +/** Recovery disabled — the Phase-A default (A3.4 adds budgets / cooldowns). */ +export const RECOVERY_DISABLED: DesiredRecovery = { enabled: false }; + +/** + * Build a sensible default policy for a bindable identity: NM activation on, + * Auto-APN, dual-stack IP, roaming off, prefer newest RAT down to GSM, recovery + * disabled, no usage limits. Concrete adapters and the planner refine from here. + */ +export function defaultCellularPolicy(boundTo: PolicyBindingKey): DesiredCellularPolicy { + return { + boundTo, + enabled: true, + connection: { apn: 'auto', ipFamily: 'ipv4v6' }, + roaming: false, + radio: { preferenceOrdered: ['5gnr', 'lte', 'umts', 'gsm'] }, + recovery: RECOVERY_DISABLED, + usage: {}, + }; +} + +/** + * Derive the durable binding key for an identity, REFUSING low-confidence + * (ambiguous) identities. This is the structural gate that makes it impossible to + * bind durable policy to a duplicate/zero-IMEI modem: no key can be produced, so + * no `DesiredCellularPolicy` can name it. Throws `PolicyBindingRefusedError`. + */ +export function policyBindingKey(identity: ModemIdentity): PolicyBindingKey { + assertCanBindPolicy(identity); + // exactOptionalPropertyTypes: only include logicalSlotId when actually present. + return identity.logicalSlotId !== undefined + ? { logicalSlotId: identity.logicalSlotId, equipmentId: identity.equipmentId } + : { equipmentId: identity.equipmentId }; +} diff --git a/control/src/domain/snapshot.test.ts b/control/src/domain/snapshot.test.ts new file mode 100644 index 0000000..aca22e6 --- /dev/null +++ b/control/src/domain/snapshot.test.ts @@ -0,0 +1,266 @@ +import { describe, expect, test } from 'bun:test'; +import { ImpossibleStateError, RevisionMonotonicityError } from './errors'; +import { imeiEquipmentId, type ModemIdentity, runtimePath } from './identity'; +import { + applyTransition, + type CellularSnapshot, + createSnapshot, + initialSnapshot, + markSourceUnavailable, + revision, + supersede, +} from './snapshot'; +import { + type DataInterface, + epochMillis, + isRegistered, + MM_STATES_REQUIRING_RADIO, + type MmState, + type NmActivation, + type RadioAccessTechnology, + type RadioPower, + type RecoveryStage, + type RecoveryState, + type RegistrationStatus, + SIM_LOCK_REQUIRES_CARD, + type SimLock, + type SimSlot, +} from './state'; + +const IDENTITY: ModemIdentity = { + equipmentId: imeiEquipmentId('490154203237518'), + runtimePath: runtimePath('/org/freedesktop/ModemManager1/Modem/0'), +}; + +describe('reducers', () => { + test('initialSnapshot is a valid absent baseline at revision 0', () => { + const snapshot = initialSnapshot(IDENTITY); + expect(snapshot.presence).toBe('absent'); + expect(snapshot.revision).toBe(revision(0)); + expect(() => createSnapshot(snapshot)).not.toThrow(); + }); + + test('applyTransition bumps the revision by exactly one', () => { + const start = initialSnapshot(IDENTITY); + const next = applyTransition(start, { reconcileStatus: 'reconciling' }); + expect(next.revision).toBe(revision(1)); + expect(next.reconcileStatus).toBe('reconciling'); + }); + + test('applyTransition re-validates and throws on an impossible patch', () => { + const start = applyTransition(initialSnapshot(IDENTITY), { + presence: 'present', + radioPower: 'on', + mmState: 'registered', + registration: { status: 'home', activeRats: new Set(['lte']) }, + }); + expect(() => applyTransition(start, { presence: 'absent' })).toThrow(ImpossibleStateError); + }); + + test('markSourceUnavailable keeps presence and marks the source, bumping revision', () => { + const present = applyTransition(initialSnapshot(IDENTITY), { presence: 'present' }); + const unavailable = markSourceUnavailable(present); + expect(unavailable.presence).toBe('present'); + expect(unavailable.sourceHealth).toBe('sourceUnavailable'); + expect(unavailable.revision).toBe(revision(2)); + }); + + test('supersede rejects a stale or equal revision', () => { + const current = applyTransition(initialSnapshot(IDENTITY), { reconcileStatus: 'converged' }); + const stale: CellularSnapshot = { ...initialSnapshot(IDENTITY), revision: revision(0) }; + expect(() => supersede(current, stale)).toThrow(RevisionMonotonicityError); + }); + + test('supersede accepts a strictly-newer valid snapshot', () => { + const current = initialSnapshot(IDENTITY); + const newer: CellularSnapshot = { ...initialSnapshot(IDENTITY), revision: revision(9) }; + expect(supersede(current, newer).revision).toBe(revision(9)); + }); +}); + +// --- randomized property test ---------------------------------------------- + +const MM_STATES: readonly MmState[] = [ + 'failed', + 'unknown', + 'initializing', + 'locked', + 'disabled', + 'enabled', + 'searching', + 'registered', + 'connecting', + 'connected', +]; +const REG_STATUSES: readonly RegistrationStatus[] = [ + 'idle', + 'home', + 'searching', + 'denied', + 'unknown', + 'roaming', +]; +const NM_STATES: readonly NmActivation[] = [ + 'unmanaged', + 'unavailable', + 'disconnected', + 'activating', + 'activated', + 'failed', +]; +const POWERS: readonly RadioPower[] = ['unknown', 'off', 'low', 'on']; +const STAGES: readonly RecoveryStage[] = [ + 'idle', + 'attributing', + 'nm-cycle', + 'mm-cycle', + 'reset', + 'power-cycle', + 'cooldown', + 'exhausted', +]; +const LOCKS: readonly SimLock[] = [ + 'unknown', + 'none', + 'sim-pin', + 'sim-puk', + 'net-pers', + 'permanently-blocked', +]; +const RATS: readonly RadioAccessTechnology[] = ['gsm', 'umts', 'lte', '5gnr']; + +/** Deterministic xorshift32 PRNG so a failing case is always reproducible. */ +function makePrng(seed: number): () => number { + let state = seed >>> 0 || 1; + return () => { + state ^= state << 13; + state >>>= 0; + state ^= state >> 17; + state ^= state << 5; + state >>>= 0; + return state / 0xffffffff; + }; +} + +function pick(rng: () => number, values: readonly T[]): T { + const index = Math.min(values.length - 1, Math.floor(rng() * values.length)); + return values[index] as T; +} + +function randomSnapshot(rng: () => number): CellularSnapshot { + const rats = new Set(); + for (const rat of RATS) { + if (rng() < 0.5) { + rats.add(rat); + } + } + + const slotCount = Math.floor(rng() * 3); + const simSlots: SimSlot[] = []; + for (let i = 0; i < slotCount; i += 1) { + simSlots.push({ + index: i + 1, + occupied: rng() < 0.7, + active: rng() < 0.5, + lock: pick(rng, LOCKS), + }); + } + + let dataInterface: DataInterface; + if (rng() < 0.6) { + dataInterface = rng() < 0.5 ? { present: true, name: 'wwan0' } : { present: true }; + } else { + dataInterface = rng() < 0.5 ? { present: false } : { present: false, name: 'wwan0' }; + } + + const stage = pick(rng, STAGES); + const attempts = Math.floor(rng() * 4); + const recoveryState: RecoveryState = + rng() < 0.5 + ? { stage, attempts, cooldownUntil: epochMillis(1_700_000_000_000) } + : { stage, attempts }; + + return { + identity: IDENTITY, + presence: rng() < 0.5 ? 'present' : 'absent', + sourceHealth: pick(rng, ['live', 'stale', 'sourceUnavailable'] as const), + simSlots, + radioPower: pick(rng, POWERS), + mmState: pick(rng, MM_STATES), + registration: { status: pick(rng, REG_STATUSES), activeRats: rats }, + nmActivation: pick(rng, NM_STATES), + dataInterface, + reconcileStatus: pick(rng, [ + 'converged', + 'reconciling', + 'pending', + 'divergent', + 'unsupported', + ] as const), + recoveryState, + revision: revision(Math.floor(rng() * 1_000_000)), + }; +} + +/** Independent restatement of the invariants — never calls the guards under test. */ +function assertInvariants(s: CellularSnapshot): void { + if (isRegistered(s.registration.status)) { + expect(s.presence).toBe('present'); + expect(s.radioPower).not.toBe('off'); + expect(s.registration.activeRats.size).toBeGreaterThan(0); + } + if (MM_STATES_REQUIRING_RADIO.has(s.mmState)) { + expect(s.presence).toBe('present'); + } + if (s.nmActivation === 'activated') { + expect(s.presence).toBe('present'); + expect(s.dataInterface.present).toBe(true); + expect(s.mmState).toBe('connected'); + } + if (s.radioPower === 'off') { + const onAir = + MM_STATES_REQUIRING_RADIO.has(s.mmState) || + s.registration.status === 'searching' || + s.nmActivation === 'activating' || + s.nmActivation === 'activated'; + expect(onAir).toBe(false); + } + expect(s.simSlots.filter((slot) => slot.active).length).toBeLessThanOrEqual(1); + for (const slot of s.simSlots) { + if (!slot.occupied) { + expect(SIM_LOCK_REQUIRES_CARD.has(slot.lock)).toBe(false); + } + } + if (!s.dataInterface.present) { + expect(s.dataInterface.name).toBeUndefined(); + } + expect(s.recoveryState.attempts).toBeGreaterThanOrEqual(0); + expect(s.recoveryState.cooldownUntil !== undefined).toBe(s.recoveryState.stage === 'cooldown'); +} + +describe('randomized property test', () => { + test('every constructed snapshot upholds the invariants; every rejection is typed', () => { + const rng = makePrng(0x9e3779b9); + let constructed = 0; + let rejected = 0; + + for (let i = 0; i < 5000; i += 1) { + const candidate = randomSnapshot(rng); + let built: CellularSnapshot | null = null; + try { + built = createSnapshot(candidate); + } catch (error) { + rejected += 1; + expect(error).toBeInstanceOf(ImpossibleStateError); + continue; + } + constructed += 1; + assertInvariants(built); + // The plan's headline invariant, stated on its own. + expect(isRegistered(built.registration.status) && built.presence === 'absent').toBe(false); + } + + expect(constructed).toBeGreaterThan(0); + expect(rejected).toBeGreaterThan(0); + }); +}); diff --git a/control/src/domain/snapshot.ts b/control/src/domain/snapshot.ts new file mode 100644 index 0000000..6a00e4d --- /dev/null +++ b/control/src/domain/snapshot.ts @@ -0,0 +1,120 @@ +// CellularSnapshot — the whole coherent state of one modem at one revision. +// +// A snapshot composes the identity and all eight orthogonal dimensions plus a +// monotonic `revision`. Every snapshot that exists has passed the guards: the +// constructors here are the ONLY sanctioned way to build or advance one, so an +// impossible combination can never be observed downstream. Revisions strictly +// increase, letting the observer (A3.1) and consumers order and dedupe events. + +import type { Brand } from './brand'; +import { nonNegativeInteger } from './brand'; +import { RevisionMonotonicityError } from './errors'; +import { assertSnapshot } from './guards'; +import type { ModemIdentity } from './identity'; +import type { + DataInterface, + MmState, + NmActivation, + Presence, + RadioPower, + ReconcileStatus, + RecoveryState, + Registration, + SimSlot, + SourceHealth, +} from './state'; + +/** A monotonically increasing snapshot revision. */ +export type Revision = Brand; + +/** Construct a `Revision` from a non-negative integer. */ +export function revision(value: number): Revision { + return nonNegativeInteger(value, 'revision') as Revision; +} + +/** The revision every fresh identity starts at. */ +export const INITIAL_REVISION: Revision = revision(0); + +/** The next revision after `current`. */ +export function nextRevision(current: Revision): Revision { + return (current + 1) as Revision; +} + +/** The full coherent state of one modem at one point in time. */ +export interface CellularSnapshot { + readonly identity: ModemIdentity; + readonly presence: Presence; + readonly sourceHealth: SourceHealth; + readonly simSlots: readonly SimSlot[]; + readonly radioPower: RadioPower; + readonly mmState: MmState; + readonly registration: Registration; + readonly nmActivation: NmActivation; + readonly dataInterface: DataInterface; + readonly reconcileStatus: ReconcileStatus; + readonly recoveryState: RecoveryState; + readonly revision: Revision; +} + +/** A partial update to a snapshot's dimensions; `revision` is managed, not patched. */ +export type SnapshotPatch = Partial>; + +/** + * Validate and return a snapshot. The guards run here: an impossible combination + * throws `ImpossibleStateError` rather than producing an incoherent value. This + * is the sole sanctioned constructor for an arbitrary snapshot. + */ +export function createSnapshot(fields: CellularSnapshot): CellularSnapshot { + assertSnapshot(fields); + return fields; +} + +/** A valid baseline for a freshly-observed-but-absent modem, at revision 0. */ +export function initialSnapshot(identity: ModemIdentity): CellularSnapshot { + return { + identity, + presence: 'absent', + sourceHealth: 'live', + simSlots: [], + radioPower: 'unknown', + mmState: 'unknown', + registration: { status: 'unknown', activeRats: new Set() }, + nmActivation: 'unavailable', + dataInterface: { present: false }, + reconcileStatus: 'pending', + recoveryState: { stage: 'idle', attempts: 0 }, + revision: INITIAL_REVISION, + }; +} + +/** + * Apply a dimension patch, bump the revision, and re-validate. Monotonicity is + * automatic (revision always advances by one); an impossible result throws. + */ +export function applyTransition(prev: CellularSnapshot, patch: SnapshotPatch): CellularSnapshot { + const next: CellularSnapshot = { ...prev, ...patch, revision: nextRevision(prev.revision) }; + assertSnapshot(next); + return next; +} + +/** + * Replace a snapshot with a fully-formed successor that carries its own revision + * (the observer path). Enforces strict monotonicity — a stale or equal revision + * throws `RevisionMonotonicityError` — and validates the successor. + */ +export function supersede(prev: CellularSnapshot, next: CellularSnapshot): CellularSnapshot { + if (next.revision <= prev.revision) { + throw new RevisionMonotonicityError(prev.revision, next.revision); + } + assertSnapshot(next); + return next; +} + +/** + * The source (MM daemon / bus) dropped: mark the data `sourceUnavailable` while + * KEEPING presence and all other facts. Stale is never removal — only an + * authoritative snapshot confirms absence (draft §Oracle round-1 lifecycle). + */ +export function markSourceUnavailable(prev: CellularSnapshot): CellularSnapshot { + return applyTransition(prev, { sourceHealth: 'sourceUnavailable' }); +} diff --git a/control/src/domain/state.ts b/control/src/domain/state.ts new file mode 100644 index 0000000..ddb6f28 --- /dev/null +++ b/control/src/domain/state.ts @@ -0,0 +1,189 @@ +// Orthogonal state dimensions. +// +// Draft §Oracle round-1: modem state is NOT one enum. It is a set of independent +// dimensions that vary separately — presence, SIM, radio, registration, +// NM-activation, data interface, reconcile, recovery. Collapsing them into a +// single enum loses real, simultaneously-true facts (e.g. "present + radio on + +// searching + no NM connection"). Each dimension below mirrors a real +// ModemManager / NetworkManager concept; values track those enums. + +import type { Brand } from './brand'; +import { nonNegativeInteger } from './brand'; + +/** Milliseconds since the Unix epoch. */ +export type EpochMillis = Brand; + +/** Construct an `EpochMillis` from a non-negative integer timestamp. */ +export function epochMillis(value: number): EpochMillis { + return nonNegativeInteger(value, 'epochMillis') as EpochMillis; +} + +// --- 1. presence + source health ------------------------------------------- + +/** Whether the modem is in the current authoritative observation snapshot. */ +export type Presence = 'present' | 'absent'; + +/** + * Health of the observation SOURCE (the ModemManager daemon / bus), independent + * of presence. When the source drops (owner loss, bus disconnect) the last data + * goes `stale` and then `sourceUnavailable` — it is NEVER silently turned into + * `absent`. Only an authoritative snapshot confirms real removal (A3.1 epochs). + */ +export type SourceHealth = 'live' | 'stale' | 'sourceUnavailable'; + +// --- 2. SIM slots + lock ---------------------------------------------------- + +/** SIM lock state — subset of `MMModemLock` plus `unknown`. */ +export type SimLock = + | 'unknown' + | 'none' + | 'sim-pin' + | 'sim-puk' + | 'sim-pin2' + | 'sim-puk2' + | 'net-pers' + | 'permanently-blocked'; + +/** A single physical SIM slot. */ +export interface SimSlot { + /** 1-based slot index (ModemManager numbers slots from 1). */ + readonly index: number; + /** A SIM card is physically inserted in this slot. */ + readonly occupied: boolean; + /** This is the primary/active slot the modem is currently using. */ + readonly active: boolean; + readonly lock: SimLock; +} + +/** Lock states that require a SIM to actually be present in the slot. */ +export const SIM_LOCK_REQUIRES_CARD: ReadonlySet = new Set([ + 'sim-pin', + 'sim-puk', + 'sim-pin2', + 'sim-puk2', + 'net-pers', + 'permanently-blocked', +]); + +// --- 3. radio power + MM state --------------------------------------------- + +/** Radio power state — `MMModemPowerState`. */ +export type RadioPower = 'unknown' | 'off' | 'low' | 'on'; + +/** Modem lifecycle state — `MMModemState`. */ +export type MmState = + | 'failed' + | 'unknown' + | 'initializing' + | 'locked' + | 'disabled' + | 'disabling' + | 'enabling' + | 'enabled' + | 'searching' + | 'registered' + | 'disconnecting' + | 'connecting' + | 'connected'; + +/** + * MM states that imply the radio is powered and actively on the air — none of + * these can coexist with `radioPower: 'off'`, and all imply the modem is present. + */ +export const MM_STATES_REQUIRING_RADIO: ReadonlySet = new Set([ + 'enabled', + 'searching', + 'registered', + 'connecting', + 'connected', + 'disconnecting', +]); + +// --- 4. registration + RAT set --------------------------------------------- + +/** 3GPP registration state — `MMModem3gppRegistrationState`. */ +export type RegistrationStatus = 'idle' | 'home' | 'searching' | 'denied' | 'unknown' | 'roaming'; + +/** Radio access technology family — subset of `MMModemAccessTechnology` groups. */ +export type RadioAccessTechnology = 'gsm' | 'umts' | 'lte' | '5gnr'; + +/** + * Registration dimension: a status plus the SET of currently-active access + * technologies. MM's access-technology field is a bitmask (carrier aggregation + * can light more than one), so a set — not a single value — is the faithful model. + */ +export interface Registration { + readonly status: RegistrationStatus; + readonly activeRats: ReadonlySet; +} + +/** Registration statuses that mean the modem is attached to a network. */ +export function isRegistered(status: RegistrationStatus): boolean { + return status === 'home' || status === 'roaming'; +} + +// --- 5. NM activation ------------------------------------------------------- + +/** + * NetworkManager connection/activation state for this modem's device — `NMDeviceState` + * collapsed to the states that matter. NM is the SOLE owner of activation; this + * dimension reflects, never drives, that ownership. + */ +export type NmActivation = + | 'unmanaged' + | 'unavailable' + | 'disconnected' + | 'activating' + | 'activated' + | 'deactivating' + | 'failed'; + +// --- 6. data interface ------------------------------------------------------ + +/** + * The net device the modem exposes for data (e.g. `wwan0`). `name` may be absent + * even when `present` (MM can report a bearer whose ip-interface is not yet + * named); a `name` without `present` is impossible and guarded. + */ +export interface DataInterface { + readonly present: boolean; + readonly name?: string; +} + +// --- 7. reconcile status ---------------------------------------------------- + +/** + * Aggregate desired-state reconciliation status. Mirrors the A2.2 receipt + * taxonomy at snapshot granularity: `unsupported` means the desired state cannot + * be applied on this hardware (e.g. "prefer 5G" on a 4G-only modem) — surfaced, + * never silently dropped. + */ +export type ReconcileStatus = 'converged' | 'reconciling' | 'pending' | 'divergent' | 'unsupported'; + +// --- 8. recovery state ------------------------------------------------------ + +/** + * Recovery-ladder stage. Ordered rungs mirror A3.4: + * nm-cycle → mm-cycle → reset → power-cycle, gated by attribution and budgets. + * `idle` = nothing in flight; `exhausted` = budget spent, gave up. + */ +export type RecoveryStage = + | 'idle' + | 'attributing' + | 'nm-cycle' + | 'mm-cycle' + | 'reset' + | 'power-cycle' + | 'cooldown' + | 'exhausted'; + +/** + * Recovery dimension. `cooldownUntil` is present ONLY while `stage` is `cooldown` + * (guarded); `attempts` counts disruptive rungs fired in the current budget window + * and resets to 0 at `idle`. + */ +export interface RecoveryState { + readonly stage: RecoveryStage; + readonly attempts: number; + readonly cooldownUntil?: EpochMillis; +} diff --git a/control/src/index.ts b/control/src/index.ts index 7ec894f..92312dc 100644 --- a/control/src/index.ts +++ b/control/src/index.ts @@ -1,8 +1,15 @@ // @ceralive/modem-control — package entry point. // -// Phase A bootstrap: the control library (domain model, ModemManager D-Bus backend, -// NetworkManager adapter, desired-state reconciler, USB composition-mode model, and -// data-usage sampler) lands in later waves. This placeholder keeps the package -// importable and the workspace test suite green. +// Phase A: the domain model (identity + orthogonal state + revisions) under +// `./domain`, the MM / NM / Router port contracts + desired-state planner under +// `./ports`, the redaction module (`./redact`), and the epoch-scoped ModemManager +// D-Bus observer under `./backend`. The NetworkManager adapter, USB composition-mode +// model, and data-usage sampler land in later waves. export const PACKAGE_NAME = '@ceralive/modem-control'; + +export * from './backend'; +export * from './domain'; +export * from './ports'; +export * from './redact'; +export * from './usb-mode'; diff --git a/control/src/ports/README.md b/control/src/ports/README.md new file mode 100644 index 0000000..3b8d7c9 --- /dev/null +++ b/control/src/ports/README.md @@ -0,0 +1,61 @@ +# Port contracts + ownership matrix + +The adapter boundaries `@ceralive/modem-control` reconciles across. Every concrete +backend — the A2.3 fake harness, the A3.x ModemManager D-Bus backend, the A4.1 +`nmcli` NetworkManager adapter — implements one of these interfaces. Nothing here +performs I/O; these are pure TypeScript contracts. + +## The ports + +| Port | File | Responsibility | +|------|------|----------------| +| `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. | +| `RouterPort` | [`router.ts`](./router.ts) | Presence + advisory health only, for devices MM cannot control. | + +## Ownership matrix — one sole writer per resource + +Each resource below has **exactly one** owner. No other component writes it. This is +the NM-owns-bearers architecture the whole package is built around (independent +review correction, draft §Oracle #1): the controller is a reconciler over two +adapters, never a second writer of the same resource. + +| Resource | Sole writer | Port / owner | +|----------|-------------|--------------| +| APN | NetworkManager | `NetworkManagerPort` (`gsm.apn` / auto-config) | +| Connection auth (username / password) | NetworkManager | `NetworkManagerPort` (`gsm.username` / `gsm.password`) | +| Roaming | NetworkManager | `NetworkManagerPort` (`gsm.home-only`) | +| Autoconnect | NetworkManager | `NetworkManagerPort` (`connection.autoconnect`) | +| Activation (bearer lifecycle) | NetworkManager | `NetworkManagerPort` (`activate`/`deactivate`) | +| Radio access-technology modes | ModemManager | `ModemManagerPort.setRadioModes` | +| SIM operations (PIN / PUK / primary slot / scan) | ModemManager | `ModemManagerPort` | +| Recovery ladder | Local controller | policy `recovery` (disabled by default, A3.4) | +| Usage policy (cycle / threshold) | Local controller | policy `usage` (A4.3 sampler) | + +### The bearer invariant (safety-critical) + +**The controller NEVER calls MM's `Simple.Connect`, `CreateBearer`, or +`Bearer.Connect`.** Bearers, APN, and connection activation belong exclusively to +NetworkManager. `ModemManagerPort` therefore has no `connect`, `createBearer`, or +`bearerConnect` method, and none may ever be added. This is enforced at build time +by [`forbidden-surface.test.ts`](./forbidden-surface.test.ts), which scans every +port source file and fails if any bearer/connect method declaration appears. + +## Port-tagged ops + +The planner ([`reconcile.ts`](./reconcile.ts)) emits **port-tagged ops** +([`ops.ts`](./ops.ts)): a discriminated union `{ port: 'mm', op: MmOp } | { port: +'nm', op: NmOp }` whose op-kind spaces are disjoint. A radio op tagged for the NM +port — `{ port: 'nm', op: { kind: 'setRadioModes', … } }` — is a **compile-time type +error**, proved by [`ops.type-test.ts`](./ops.type-test.ts) (`@ts-expect-error` +lines that `tsc --noEmit` must flag). The ownership matrix is thus enforced by the +type system, not merely by convention. + +## Receipts + +Reconciliation is honest: each policy dimension yields exactly one +[`Receipt`](./receipts.ts) with a status (`applied | pending | unsupported | +failed`) and a **reason**. Nothing is silently dropped — "prefer 5G" on a 4G-only +modem returns `unsupported` with an explicit reason, never a quiet downgrade to a +4G-only mode. diff --git a/control/src/ports/forbidden-surface.test.ts b/control/src/ports/forbidden-surface.test.ts new file mode 100644 index 0000000..6209846 --- /dev/null +++ b/control/src/ports/forbidden-surface.test.ts @@ -0,0 +1,80 @@ +// Guard: no port interface may ever declare a bearer / connect method. +// +// The single most safety-critical constraint in the package (Must-NOT-Have: "no MM +// Simple.Connect / CreateBearer / Bearer.Connect calls ever"). Interfaces are +// erased at runtime, so the enforcement is a source scan: every port `.ts` file is +// stripped of comments (so prose mentions of "bearer" never trip it) and checked +// for a method declaration whose name is `connect`, `simpleConnect`, or contains +// "bearer". Adding such a method to any port fails this test — and therefore CI. + +import { expect, test } from 'bun:test'; +import { readdirSync } from 'node:fs'; +import { join } from 'node:path'; + +const portsDir = import.meta.dir; + +function isForbiddenMethodName(name: string): boolean { + const lower = name.toLowerCase(); + return lower === 'connect' || lower === 'simpleconnect' || lower.includes('bearer'); +} + +function stripComments(source: string): string { + return source.replace(/\/\*[\s\S]*?\*\//g, '').replace(/\/\/[^\n]*/g, ''); +} + +function declaredMemberNames(source: string): string[] { + const names: string[] = []; + const methodDecl = /(?:^|\n)\s*([a-zA-Z_$][\w$]*)\s*[<(]/g; + let match = methodDecl.exec(source); + while (match !== null) { + const name = match[1]; + if (name !== undefined) { + names.push(name); + } + match = methodDecl.exec(source); + } + return names; +} + +function portSourceFiles(): string[] { + return readdirSync(portsDir) + .filter((file) => file.endsWith('.ts')) + .filter((file) => !file.endsWith('.test.ts') && !file.endsWith('.type-test.ts')); +} + +test('no port source declares a bearer / connect method', async () => { + for (const file of portSourceFiles()) { + const source = stripComments(await Bun.file(join(portsDir, file)).text()); + const forbidden = declaredMemberNames(source).filter(isForbiddenMethodName); + expect(forbidden, `forbidden method(s) in ${file}: ${forbidden.join(', ')}`).toEqual([]); + } +}); + +test('the ModemManager port source declares the expected non-bearer mutations', async () => { + const source = stripComments(await Bun.file(join(portsDir, 'modem-manager.ts')).text()); + const names = declaredMemberNames(source); + for (const expected of [ + 'setRadioModes', + 'setPrimarySimSlot', + 'sendPin', + 'sendPuk', + 'scanNetworks', + 'inhibit', + 'uninhibit', + ]) { + expect(names).toContain(expected); + } +}); + +test('the detector actually catches a bearer / connect method (self-test)', () => { + const rogue = ` + export interface Rogue { + connect(): Promise; + createBearer(): Promise; + bearerConnect(): Promise; + setRadioModes(): Promise; + } + `; + const flagged = declaredMemberNames(stripComments(rogue)).filter(isForbiddenMethodName).sort(); + expect(flagged).toEqual(['bearerConnect', 'connect', 'createBearer']); +}); diff --git a/control/src/ports/index.ts b/control/src/ports/index.ts new file mode 100644 index 0000000..211cd86 --- /dev/null +++ b/control/src/ports/index.ts @@ -0,0 +1,16 @@ +// Port contracts — the MM / NM / Router adapter boundaries plus the desired-state +// planner and receipts. Concrete backends (A2.3 fake, A3.x D-Bus, A4.1 nmcli) +// implement these interfaces; nothing here performs I/O. +// +// OWNERSHIP MATRIX (see ./README.md): NM owns bearers / APN / auth / roaming / +// autoconnect / activation; MM owns radio / SIM ops; the local controller owns +// recovery + usage policy. The ModemManagerPort has NO bearer / connect verb — +// enforced at build time by forbidden-surface.test.ts. + +export * from './modem-manager'; +export * from './network-manager'; +export * from './observation'; +export * from './ops'; +export * from './receipts'; +export * from './reconcile'; +export * from './router'; diff --git a/control/src/ports/modem-manager.ts b/control/src/ports/modem-manager.ts new file mode 100644 index 0000000..f3377aa --- /dev/null +++ b/control/src/ports/modem-manager.ts @@ -0,0 +1,72 @@ +// The ModemManager port — radio, SIM, scan, inhibit, and observation. +// +// CRITICAL INVARIANT (the single most safety-critical constraint in the package): +// this port has NO bearer / connection verb. There is no connect, no createBearer, +// no bearerConnect — MM's Simple.Connect / CreateBearer / Bearer.Connect are NEVER +// callable through here. NetworkManager is the sole owner of bearers and activation +// (see README ownership table). `forbidden-surface.test.ts` fails the build if any +// such method is ever added to this file. + +import type { DesiredRadio, EpochMillis, RuntimePath } from '../domain'; +import type { ModemObservationPort } from './observation'; +import type { Receipt } from './receipts'; + +/** A live handle to one modem — its ModemManager D-Bus object path (per-boot). */ +export type ModemRef = RuntimePath; + +/** Outcome of a SIM PIN unlock attempt. */ +export interface SimUnlockResult { + readonly outcome: 'unlocked' | 'incorrect-pin' | 'sim-puk-required' | 'unsupported' | 'error'; + readonly remainingAttempts?: number; + readonly reason: string; +} + +/** Outcome of a SIM PUK unblock attempt. */ +export interface SimPukUnlockResult { + readonly outcome: 'unlocked' | 'incorrect-puk' | 'permanently-blocked' | 'unsupported' | 'error'; + readonly remainingAttempts?: number; + readonly reason: string; +} + +/** One operator returned by a network scan. */ +export interface ScannedNetwork { + /** MCC+MNC operator code. */ + readonly operatorCode: string; + readonly operatorName?: string; + readonly availability: 'available' | 'current' | 'forbidden' | 'unknown'; +} + +/** Result of a network scan — discriminated, retaining the reason on failure. */ +export type NetworkScanResult = + | { readonly ok: true; readonly networks: readonly ScannedNetwork[] } + | { 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. */ + readonly uid: string; + readonly acquiredAt: EpochMillis; +} + +/** + * The ModemManager port — EXTENDS the read-only observation port with the modem + * mutations MM legitimately owns: radio modes, primary SIM slot, PIN / PUK unlock, + * network scan, and inhibit / uninhibit. It owns NO bearer / connection lifecycle; + * bearers and activation belong to `NetworkManagerPort`. + */ +export interface ModemManagerPort extends ModemObservationPort { + /** Set the modem's radio access-technology preference. */ + setRadioModes(modem: ModemRef, preference: DesiredRadio): 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). */ + sendPin(modem: ModemRef, pin: string): Promise; + /** Submit a SIM PUK plus the new PIN (exactly-once). */ + 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/network-manager.ts b/control/src/ports/network-manager.ts new file mode 100644 index 0000000..c30c47c --- /dev/null +++ b/control/src/ports/network-manager.ts @@ -0,0 +1,87 @@ +// The NetworkManager port — NM is the SOLE owner of bearers, APN, auth, roaming, +// autoconnect, and activation (see README ownership table). This port is the ONLY +// way the controller touches any of those resources; the ModemManager port has no +// bearer / connection verbs at all. + +import type { Brand, EpochMillis } from '../domain'; +import { nonEmptyString } from '../domain'; +import type { Receipt } from './receipts'; + +/** A NetworkManager connection-profile UUID. */ +export type ConnectionId = Brand; + +/** A kernel network-device interface name (e.g. `wwan0`). */ +export type DeviceIfname = Brand; + +/** Construct a `ConnectionId` from a non-empty NM connection UUID. */ +export function connectionId(value: string): ConnectionId { + return nonEmptyString(value, 'connectionId') as ConnectionId; +} + +/** Construct a `DeviceIfname` from a non-empty interface name. */ +export function deviceIfname(value: string): DeviceIfname { + return nonEmptyString(value, 'deviceIfname') as DeviceIfname; +} + +/** + * A GSM connection profile — NM's `gsm.*` setting group. `password` is SENSITIVE + * and MUST be redacted in every log / output (see `../redact`). The full nine-field + * nmcli write parity lands with the concrete adapter (A4.1); this is the port + * contract that adapter fulfils. + */ +export interface GsmProfileInput { + readonly connectionName: string; + /** Concrete APN, or the empty string when `autoConfig` drives it (A4.1). */ + readonly apn: string; + readonly username?: string; + /** SENSITIVE — redact everywhere. */ + readonly password?: string; + /** `true` ⇒ `gsm.home-only` (roaming disabled). */ + readonly homeOnly: boolean; + /** `true` ⇒ `gsm.auto-config yes` (Auto-APN); mutually exclusive with creds. */ + readonly autoConfig: boolean; + /** Manual operator selection (`gsm.network-id`); empty unless roaming-pinned. */ + readonly networkId?: string; +} + +/** A persisted GSM profile as read back from NM, keyed by its connection id. */ +export interface GsmProfile extends GsmProfileInput { + readonly connectionId: ConnectionId; +} + +/** A partial update to an existing GSM profile. */ +export type GsmProfilePatch = Partial; + +/** + * A quiesce lease — a held guarantee that the connection on THIS exact device stays + * deactivated while a disruptive MM operation runs, then is reactivated on release. + * Bound to BOTH `connectionId` and `deviceIfname` so it can never quiesce the wrong + * device (the two-device isolation invariant, A4.1). + */ +export interface QuiesceLease { + readonly connectionId: ConnectionId; + readonly deviceIfname: DeviceIfname; + readonly acquiredAt: EpochMillis; +} + +/** + * The NetworkManager port. Activation and deactivation take BOTH the connection id + * AND the device interface name — never an id alone. This encodes, at the type + * level, that we address a connection on an EXACT device (`nmcli connection up + * ifname ` / verify-then-`device disconnect `); the id-only + * `nmcli connection down` is structurally impossible to express here (A4.1). + */ +export interface NetworkManagerPort { + createGsmProfile(profile: GsmProfileInput): Promise; + readGsmProfile(id: ConnectionId): Promise; + updateGsmProfile(id: ConnectionId, patch: GsmProfilePatch): Promise; + deleteGsmProfile(id: ConnectionId): Promise; + /** Activate `id` on the EXACT device `ifname`. */ + activate(id: ConnectionId, ifname: DeviceIfname): Promise; + /** Deactivate `id` on the EXACT device `ifname`. */ + deactivate(id: ConnectionId, ifname: DeviceIfname): Promise; + /** Take a quiesce lease over `id` on the EXACT device `ifname`. */ + acquireQuiesceLease(id: ConnectionId, ifname: DeviceIfname): Promise; + /** Release a quiesce lease, reactivating the connection it held down. */ + releaseQuiesceLease(lease: QuiesceLease): Promise; +} diff --git a/control/src/ports/observation.ts b/control/src/ports/observation.ts new file mode 100644 index 0000000..e775ca6 --- /dev/null +++ b/control/src/ports/observation.ts @@ -0,0 +1,48 @@ +// The observation contract — the read side shared by every ModemManager backend. +// +// `ModemObservationPort` is the NARROW port: it observes modems and reports them as +// a stream of discriminated list results. It carries NO mutation methods at all. +// The full `ModemManagerPort` (mutations) EXTENDS this, so a shadow / read-only +// consumer can depend on observation alone and never gain a mutating verb. + +import type { CellularSnapshot } from '../domain'; + +/** Cancels a subscription created by `observe`. Calling it twice is a no-op. */ +export type Unsubscribe = () => void; + +/** Why an observation could not produce an authoritative list this cycle. */ +export type ObservationFailureReason = 'not-started' | 'source-unavailable' | 'bus-error'; + +/** + * The result of listing modems — DISCRIMINATED on `ok`, and it NEVER throws away + * rows. Even the failure arm carries `rows`: the last-known modems are RETAINED so + * a source drop can never be mistaken for a removal. Real removal is only ever + * expressed by an `ok: true` snapshot that OMITS a modem (A3.1 epoch authority) — + * the false-removal class is dead by construction. + */ +export type ObservationList = + | { readonly ok: true; readonly rows: readonly T[] } + | { + readonly ok: false; + readonly reason: ObservationFailureReason; + /** Retained rows from the last authoritative snapshot — never dropped. */ + readonly rows: readonly T[]; + }; + +/** A subscriber to the ongoing observation stream. */ +export type ObservationListener = (list: ObservationList) => void; + +/** + * The read-only observation port. `start()` connects, subscribes, and resolves the + * first AUTHORITATIVE list; `observe()` streams every subsequent list (each snapshot + * carrying a monotonic revision); `stop()` tears the subscription down. No method + * here can change modem state — that narrowness is the whole point of the port. + */ +export interface ModemObservationPort { + /** Connect, subscribe, and resolve the first authoritative observation list. */ + start(): Promise; + /** Subscribe to the ongoing stream of observation lists. Returns an unsubscribe. */ + observe(listener: ObservationListener): Unsubscribe; + /** Tear down the subscription and release the source. Idempotent. */ + stop(): Promise; +} diff --git a/control/src/ports/ops.ts b/control/src/ports/ops.ts new file mode 100644 index 0000000..fe5587a --- /dev/null +++ b/control/src/ports/ops.ts @@ -0,0 +1,60 @@ +// Port-tagged reconcile ops — a planned mutation TAGGED with the port that owns it. +// +// The op kinds are DISJOINT by construction: a radio / SIM op (`MmOp`) shares no +// `kind` with a connection / APN op (`NmOp`). Tagging is therefore type-checked — +// `{ port: 'nm', op: { kind: 'setRadioModes', … } }` does NOT compile, because a +// radio op is not assignable to the NM arm. That mis-tag is a COMPILE-TIME error +// (see `ops.type-test.ts` for the `@ts-expect-error` proofs). This is the ownership +// matrix (README) enforced by the type system, not merely by convention. + +import type { DesiredRadio } from '../domain'; +import type { + ConnectionId, + DeviceIfname, + GsmProfileInput, + GsmProfilePatch, +} from './network-manager'; + +/** Radio + SIM ops — owned SOLELY by the ModemManager port. */ +export type MmOp = + | { readonly kind: 'setRadioModes'; readonly preference: DesiredRadio } + | { readonly kind: 'setPrimarySimSlot'; readonly slotIndex: number }; + +/** Connection / APN / activation ops — owned SOLELY by the NetworkManager port. */ +export type NmOp = + | { readonly kind: 'createGsmProfile'; readonly profile: GsmProfileInput } + | { + readonly kind: 'updateGsmProfile'; + readonly connectionId: ConnectionId; + readonly patch: GsmProfilePatch; + } + | { + readonly kind: 'activate'; + readonly connectionId: ConnectionId; + readonly deviceIfname: DeviceIfname; + } + | { + readonly kind: 'deactivate'; + readonly connectionId: ConnectionId; + readonly deviceIfname: DeviceIfname; + }; + +/** + * A planned op tagged with its owning port. The two arms are disjoint on BOTH the + * `port` discriminant AND the op `kind` space, so the planner cannot emit — and a + * reviewer cannot write — a radio op under the NM port, or a connection op under + * the MM port. + */ +export type PortTaggedOp = + | { readonly port: 'mm'; readonly op: MmOp } + | { readonly port: 'nm'; readonly op: NmOp }; + +/** Tag an MM op for the ModemManager port. Only an `MmOp` is accepted. */ +export function mmOp(op: MmOp): PortTaggedOp { + return { port: 'mm', op }; +} + +/** Tag an NM op for the NetworkManager port. Only an `NmOp` is accepted. */ +export function nmOp(op: NmOp): PortTaggedOp { + return { port: 'nm', op }; +} diff --git a/control/src/ports/ops.type-test.ts b/control/src/ports/ops.type-test.ts new file mode 100644 index 0000000..a7b38ff --- /dev/null +++ b/control/src/ports/ops.type-test.ts @@ -0,0 +1,39 @@ +// COMPILE-TIME negative tests for port-tagged op disjointness. +// +// This file is type-checked by `tsc --noEmit` (it is inside the workspace +// `include`) but is NOT a runtime test — it is never imported and every binding is +// a type assertion. Each `@ts-expect-error` asserts the type system REJECTS a +// mis-tagged op; if disjointness ever regresses (e.g. an `NmOp` gains a radio +// `kind`), the directive becomes UNUSED and `tsc` fails the build. That failure IS +// the ownership matrix enforced at the type level. + +import { connectionId, deviceIfname } from './network-manager'; +import type { MmOp, NmOp, PortTaggedOp } from './ops'; + +const radioOp: MmOp = { kind: 'setRadioModes', preference: { preferenceOrdered: ['5gnr'] } }; +const slotOp: MmOp = { kind: 'setPrimarySimSlot', slotIndex: 2 }; +const activateOp: NmOp = { + kind: 'activate', + connectionId: connectionId('11111111-1111-1111-1111-111111111111'), + deviceIfname: deviceIfname('wwan0'), +}; + +// Positive controls — correctly-tagged ops MUST type-check. +const goodMm: PortTaggedOp = { port: 'mm', op: radioOp }; +const goodNm: PortTaggedOp = { port: 'nm', op: activateOp }; + +// @ts-expect-error a radio op tagged for the NM port must NOT compile. +const badRadioOnNm: PortTaggedOp = { port: 'nm', op: radioOp }; +// @ts-expect-error a SIM op tagged for the NM port must NOT compile. +const badSlotOnNm: PortTaggedOp = { port: 'nm', op: slotOp }; +// @ts-expect-error an activate op tagged for the MM port must NOT compile. +const badActivateOnMm: PortTaggedOp = { port: 'mm', op: activateOp }; + +// Exported so the bindings are "used" and lint stays quiet; never imported at runtime. +export const PORT_TAGGED_OP_TYPE_CASES: readonly PortTaggedOp[] = [ + goodMm, + goodNm, + badRadioOnNm, + badSlotOnNm, + badActivateOnMm, +]; diff --git a/control/src/ports/receipts.test.ts b/control/src/ports/receipts.test.ts new file mode 100644 index 0000000..a7ae278 --- /dev/null +++ b/control/src/ports/receipts.test.ts @@ -0,0 +1,153 @@ +// Receipt taxonomy — every status reachable, "prefer 5G on 4G-only" is surfaced as +// `unsupported` (never silently downgraded), and every receipt carries a reason. + +import { expect, test } from 'bun:test'; +import { + type DesiredCellularPolicy, + defaultCellularPolicy, + imeiEquipmentId, + type PolicyBindingKey, + policyBindingKey, + runtimePath, +} from '../domain'; +import { connectionId, deviceIfname } from './network-manager'; +import type { PolicyDimension, Receipt } from './receipts'; +import { type AppliedCellularState, type ModemCapabilities, planReconcile } from './reconcile'; + +const BOUND_TO: PolicyBindingKey = policyBindingKey({ + equipmentId: imeiEquipmentId('490154203237518'), + runtimePath: runtimePath('/org/freedesktop/ModemManager1/Modem/0'), +}); + +const FOUR_G_ONLY: ModemCapabilities = { + supportedRats: new Set(['lte', 'umts', 'gsm']), + simSlotCount: 1, + supportsAutoApn: true, +}; + +const FIVE_G_DUAL_SLOT: ModemCapabilities = { + supportedRats: new Set(['5gnr', 'lte', 'umts', 'gsm']), + simSlotCount: 2, + supportsAutoApn: true, +}; + +const ADDRESSABLE = { + connectionId: connectionId('conn-uuid'), + deviceIfname: deviceIfname('wwan0'), + appliedApn: 'auto', + appliedRoaming: false, + appliedRadioPreference: ['5gnr', 'lte', 'umts', 'gsm'], +} as const; + +function receiptFor(receipts: readonly Receipt[], dimension: PolicyDimension): Receipt { + const found = receipts.find((entry) => entry.dimension === dimension); + if (found === undefined) { + throw new Error(`no receipt for dimension ${dimension}`); + } + return found; +} + +test('prefer 5G on a 4G-only modem is unsupported with a reason, never silently applied', () => { + const policy = defaultCellularPolicy(BOUND_TO); + const applied: AppliedCellularState = { nmActivation: 'disconnected', hasProfile: false }; + const { ops, receipts } = planReconcile(applied, policy, FOUR_G_ONLY); + + const radio = receiptFor(receipts, 'radio'); + expect(radio.status).toBe('unsupported'); + expect(radio.reason).toContain('5gnr'); + expect(radio.reason.length).toBeGreaterThan(0); + expect(ops.filter((op) => op.port === 'mm' && op.op.kind === 'setRadioModes')).toEqual([]); +}); + +test('all four receipt statuses are reachable', () => { + const policy = defaultCellularPolicy(BOUND_TO); + + const applied: AppliedCellularState = { + nmActivation: 'activated', + hasProfile: true, + ...ADDRESSABLE, + }; + expect( + receiptFor(planReconcile(applied, policy, FIVE_G_DUAL_SLOT).receipts, 'enabled').status, + ).toBe('applied'); + + const pending: AppliedCellularState = { + nmActivation: 'disconnected', + hasProfile: true, + ...ADDRESSABLE, + }; + expect( + receiptFor(planReconcile(pending, policy, FIVE_G_DUAL_SLOT).receipts, 'enabled').status, + ).toBe('pending'); + + const unsupported: AppliedCellularState = { nmActivation: 'disconnected', hasProfile: false }; + expect(receiptFor(planReconcile(unsupported, policy, FOUR_G_ONLY).receipts, 'radio').status).toBe( + 'unsupported', + ); + + const failed: AppliedCellularState = { + nmActivation: 'failed', + hasProfile: true, + activationFailureReason: 'no-signal', + ...ADDRESSABLE, + }; + const failedReceipt = receiptFor( + planReconcile(failed, policy, FIVE_G_DUAL_SLOT).receipts, + 'enabled', + ); + expect(failedReceipt.status).toBe('failed'); + expect(failedReceipt.reason).toContain('no-signal'); +}); + +test('every dimension yields a receipt and every receipt has a non-empty reason', () => { + const policy = defaultCellularPolicy(BOUND_TO); + const { receipts } = planReconcile( + { nmActivation: 'disconnected', hasProfile: false }, + policy, + FOUR_G_ONLY, + ); + for (const entry of receipts) { + expect(entry.reason.length).toBeGreaterThan(0); + } + const expectedDimensions: PolicyDimension[] = [ + 'connection', + 'enabled', + 'radio', + 'recovery', + 'roaming', + 'simSlot', + 'usage', + ]; + expect(receipts.map((entry) => entry.dimension).sort()).toEqual(expectedDimensions.sort()); +}); + +test('primary SIM slot selection is unsupported on a single-slot modem', () => { + const policy: DesiredCellularPolicy = { ...defaultCellularPolicy(BOUND_TO), simSlot: 2 }; + const { receipts } = planReconcile( + { nmActivation: 'disconnected', hasProfile: false }, + policy, + FOUR_G_ONLY, + ); + expect(receiptFor(receipts, 'simSlot').status).toBe('unsupported'); +}); + +test('an out-of-range primary SIM slot is a failed receipt', () => { + const policy: DesiredCellularPolicy = { ...defaultCellularPolicy(BOUND_TO), simSlot: 5 }; + const { receipts } = planReconcile( + { nmActivation: 'disconnected', hasProfile: false }, + policy, + FIVE_G_DUAL_SLOT, + ); + expect(receiptFor(receipts, 'simSlot').status).toBe('failed'); +}); + +test('Auto-APN is unsupported when the stack cannot auto-configure', () => { + const policy = defaultCellularPolicy(BOUND_TO); + const caps: ModemCapabilities = { ...FIVE_G_DUAL_SLOT, supportsAutoApn: false }; + const { receipts } = planReconcile( + { nmActivation: 'disconnected', hasProfile: false }, + policy, + caps, + ); + expect(receiptFor(receipts, 'connection').status).toBe('unsupported'); +}); diff --git a/control/src/ports/receipts.ts b/control/src/ports/receipts.ts new file mode 100644 index 0000000..926f6cb --- /dev/null +++ b/control/src/ports/receipts.ts @@ -0,0 +1,43 @@ +// Reconcile receipts — the HONEST outcome of trying to apply one policy dimension. +// +// Every attempt to converge a dimension yields exactly one receipt with a status +// AND a reason. Nothing is ever silently dropped: "prefer 5G" on a 4G-only modem is +// reported as `unsupported` with a reason, never quietly downgraded to "5G off". + +/** The policy dimension a receipt is about. */ +export type PolicyDimension = + | 'enabled' + | 'connection' + | 'roaming' + | 'radio' + | 'simSlot' + | 'recovery' + | 'usage'; + +/** + * Receipt status taxonomy: + * - applied — the desired state is in effect (converged, or already was). + * - pending — an op was issued; a terminal state is not yet observed. + * - unsupported — the hardware / stack cannot honor this desire (surfaced, not silent). + * - failed — an op was attempted and errored. + */ +export type ReceiptStatus = 'applied' | 'pending' | 'unsupported' | 'failed'; + +/** + * One reconcile receipt. `reason` is ALWAYS populated — a receipt with no reason + * would be a silent outcome, which the contract forbids. + */ +export interface Receipt { + readonly dimension: PolicyDimension; + readonly status: ReceiptStatus; + readonly reason: string; +} + +/** Build a receipt. Kept as a helper so `reason` can never be forgotten. */ +export function receipt( + dimension: PolicyDimension, + status: ReceiptStatus, + reason: string, +): Receipt { + return { dimension, status, reason }; +} diff --git a/control/src/ports/reconcile.test.ts b/control/src/ports/reconcile.test.ts new file mode 100644 index 0000000..7815f74 --- /dev/null +++ b/control/src/ports/reconcile.test.ts @@ -0,0 +1,152 @@ +// Idempotent re-apply — a minimal in-memory stack executes the planner's +// port-tagged ops; re-applying the same policy performs ZERO new side effects and +// returns the same receipts. NOT the A2.3 fake D-Bus harness — just enough to prove +// the pure planner converges to a fixpoint. + +import { expect, test } from 'bun:test'; +import { + type DesiredCellularPolicy, + defaultCellularPolicy, + imeiEquipmentId, + type PolicyBindingKey, + policyBindingKey, + runtimePath, +} from '../domain'; +import { connectionId, deviceIfname } from './network-manager'; +import type { PortTaggedOp } from './ops'; +import type { Receipt } from './receipts'; +import { type AppliedCellularState, type ModemCapabilities, planReconcile } from './reconcile'; + +const BOUND_TO: PolicyBindingKey = policyBindingKey({ + equipmentId: imeiEquipmentId('490154203237518'), + runtimePath: runtimePath('/org/freedesktop/ModemManager1/Modem/0'), +}); + +const FIVE_G_DUAL_SLOT: ModemCapabilities = { + supportedRats: new Set(['5gnr', 'lte', 'umts', 'gsm']), + simSlotCount: 2, + supportsAutoApn: true, +}; + +const FOUR_G_ONLY: ModemCapabilities = { + supportedRats: new Set(['lte', 'umts', 'gsm']), + simSlotCount: 2, + supportsAutoApn: true, +}; + +class InMemoryStack { + private state: AppliedCellularState; + readonly executed: PortTaggedOp[] = []; + + constructor( + initial: AppliedCellularState, + private readonly capabilities: ModemCapabilities, + ) { + this.state = initial; + } + + private execute(op: PortTaggedOp): void { + this.executed.push(op); + if (op.port === 'mm') { + this.state = + op.op.kind === 'setRadioModes' + ? { ...this.state, appliedRadioPreference: op.op.preference.preferenceOrdered } + : { ...this.state, activePrimarySlot: op.op.slotIndex }; + return; + } + switch (op.op.kind) { + case 'createGsmProfile': + this.state = { + ...this.state, + hasProfile: true, + connectionId: connectionId('conn-generated'), + appliedApn: op.op.profile.autoConfig ? 'auto' : op.op.profile.apn, + appliedRoaming: !op.op.profile.homeOnly, + }; + break; + case 'updateGsmProfile': { + const { patch } = op.op; + const nextApn = patch.autoConfig ? 'auto' : patch.apn; + this.state = { + ...this.state, + ...(nextApn !== undefined ? { appliedApn: nextApn } : {}), + ...(patch.homeOnly !== undefined ? { appliedRoaming: !patch.homeOnly } : {}), + }; + break; + } + case 'activate': + this.state = { ...this.state, nmActivation: 'activated' }; + break; + case 'deactivate': + this.state = { ...this.state, nmActivation: 'disconnected' }; + break; + } + } + + apply(desired: DesiredCellularPolicy): readonly Receipt[] { + for (let iteration = 0; iteration < 20; iteration += 1) { + const plan = planReconcile(this.state, desired, this.capabilities); + if (plan.ops.length === 0) { + return plan.receipts; + } + for (const op of plan.ops) { + this.execute(op); + } + } + throw new Error('reconcile did not converge within the iteration budget'); + } +} + +function baseState(): AppliedCellularState { + return { nmActivation: 'disconnected', hasProfile: false, deviceIfname: deviceIfname('wwan0') }; +} + +test('a fresh policy converges, then re-apply does nothing and returns the same receipts', () => { + const stack = new InMemoryStack(baseState(), FIVE_G_DUAL_SLOT); + const policy = defaultCellularPolicy(BOUND_TO); + + const first = stack.apply(policy); + const sideEffectsAfterFirst = stack.executed.length; + expect(sideEffectsAfterFirst).toBeGreaterThan(0); + + const second = stack.apply(policy); + expect(stack.executed.length).toBe(sideEffectsAfterFirst); + expect(second).toEqual(first); + + for (const entry of second) { + expect(entry.status).toBe('applied'); + } +}); + +test('each op is executed exactly once across convergence (no double side-effects)', () => { + const stack = new InMemoryStack(baseState(), FIVE_G_DUAL_SLOT); + const policy = defaultCellularPolicy(BOUND_TO); + stack.apply(policy); + + const kinds = stack.executed.map((op) => op.op.kind).sort(); + expect(kinds).toEqual(['activate', 'createGsmProfile', 'setRadioModes']); +}); + +test('an unsupported dimension is never applied as an op, on the first apply or any re-apply', () => { + const stack = new InMemoryStack(baseState(), FOUR_G_ONLY); + const policy = defaultCellularPolicy(BOUND_TO); + + const first = stack.apply(policy); + stack.apply(policy); + + expect(stack.executed.some((op) => op.port === 'mm' && op.op.kind === 'setRadioModes')).toBe( + false, + ); + expect(first.find((entry) => entry.dimension === 'radio')?.status).toBe('unsupported'); +}); + +test('deactivation is idempotent — disabling an already-inactive connection is a no-op', () => { + const stack = new InMemoryStack(baseState(), FIVE_G_DUAL_SLOT); + const policy: DesiredCellularPolicy = { ...defaultCellularPolicy(BOUND_TO), enabled: false }; + + stack.apply(policy); + const sideEffects = stack.executed.length; + stack.apply(policy); + expect(stack.executed.length).toBe(sideEffects); + expect(stack.executed.some((op) => op.port === 'nm' && op.op.kind === 'activate')).toBe(false); +}); diff --git a/control/src/ports/reconcile.ts b/control/src/ports/reconcile.ts new file mode 100644 index 0000000..a63f2eb --- /dev/null +++ b/control/src/ports/reconcile.ts @@ -0,0 +1,338 @@ +// The desired-state planner — pure. Given the currently-applied state, a desired +// policy, and the modem's capabilities, it produces the port-tagged ops needed to +// converge PLUS an honest receipt per policy dimension. It performs no I/O and no +// side effects: the same inputs always yield the same ops and receipts, which is +// what makes re-applying a policy idempotent (see reconcile.test.ts). + +import type { DesiredCellularPolicy, NmActivation, RadioAccessTechnology } from '../domain'; +import type { ConnectionId, DeviceIfname, GsmProfileInput } from './network-manager'; +import { mmOp, nmOp, type PortTaggedOp } from './ops'; +import { type Receipt, receipt } from './receipts'; + +/** What the modem / stack can actually do — the capability set the planner honors. */ +export interface ModemCapabilities { + readonly supportedRats: ReadonlySet; + readonly simSlotCount: number; + readonly supportsAutoApn: boolean; +} + +/** + * The currently-applied cellular configuration, as the planner sees it — the "is" + * state, distinct from the observational snapshot: it tracks what has actually been + * written (profile, radio preference, primary slot) plus the live NM activation. + */ +export interface AppliedCellularState { + readonly nmActivation: NmActivation; + readonly hasProfile: boolean; + readonly connectionId?: ConnectionId; + readonly deviceIfname?: DeviceIfname; + readonly appliedApn?: 'auto' | string; + readonly appliedRoaming?: boolean; + readonly appliedRadioPreference?: readonly RadioAccessTechnology[]; + readonly activePrimarySlot?: number; + readonly activationFailureReason?: string; +} + +/** A reconcile plan: the ops to run and one receipt per policy dimension. */ +export interface Plan { + readonly ops: readonly PortTaggedOp[]; + readonly receipts: readonly Receipt[]; +} + +interface DimensionResult { + readonly receipt: Receipt; + readonly op?: PortTaggedOp; +} + +interface ProfilePlan { + readonly connection: Receipt; + readonly roaming: Receipt; + readonly op?: PortTaggedOp; +} + +/** Reconcile a desired policy against the applied state into ops + receipts. */ +export function planReconcile( + current: AppliedCellularState, + desired: DesiredCellularPolicy, + capabilities: ModemCapabilities, +): Plan { + const profile = planNmProfile(current, desired, capabilities); + const results: readonly DimensionResult[] = [ + planRadio(current, desired, capabilities), + planSimSlot(current, desired, capabilities), + planEnabled(current, desired), + { receipt: planRecovery(desired) }, + { receipt: planUsage(desired) }, + ]; + + const ops: PortTaggedOp[] = []; + const receipts: Receipt[] = [profile.connection, profile.roaming]; + if (profile.op !== undefined) { + ops.push(profile.op); + } + for (const result of results) { + receipts.push(result.receipt); + if (result.op !== undefined) { + ops.push(result.op); + } + } + return { ops, receipts }; +} + +function profileFromPolicy(desired: DesiredCellularPolicy): GsmProfileInput { + const auto = desired.connection.apn === 'auto'; + const base: GsmProfileInput = { + connectionName: 'ceralive-cellular', + apn: auto ? '' : desired.connection.apn, + homeOnly: !desired.roaming, + autoConfig: auto, + }; + // SENSITIVE creds only when explicitly provided AND not in auto-config mode. + if (!auto && desired.connection.auth !== undefined) { + const { username, password } = desired.connection.auth; + return { + ...base, + ...(username !== undefined ? { username } : {}), + ...(password !== undefined ? { password } : {}), + }; + } + return base; +} + +// NM owns APN / auth / roaming / autoconnect: one profile write converges all of +// them, so connection + roaming share a single op and each get their own receipt. +function planNmProfile( + current: AppliedCellularState, + desired: DesiredCellularPolicy, + capabilities: ModemCapabilities, +): ProfilePlan { + const auto = desired.connection.apn === 'auto'; + if (auto && !capabilities.supportsAutoApn) { + return { + connection: receipt( + 'connection', + 'unsupported', + 'Auto-APN is not available on this NetworkManager / modem', + ), + roaming: receipt( + 'roaming', + 'unsupported', + 'roaming cannot be applied without a connection profile', + ), + }; + } + const needsWrite = + !current.hasProfile || + current.appliedApn !== desired.connection.apn || + current.appliedRoaming !== desired.roaming; + if (!needsWrite) { + return { + connection: receipt( + 'connection', + 'applied', + `connection APN '${desired.connection.apn}' already configured`, + ), + roaming: receipt( + 'roaming', + 'applied', + `roaming already ${desired.roaming ? 'enabled' : 'disabled'}`, + ), + }; + } + const op = + current.hasProfile && current.connectionId !== undefined + ? nmOp({ + kind: 'updateGsmProfile', + connectionId: current.connectionId, + patch: profileFromPolicy(desired), + }) + : nmOp({ kind: 'createGsmProfile', profile: profileFromPolicy(desired) }); + const verb = current.hasProfile ? 'updating' : 'creating'; + return { + connection: receipt( + 'connection', + 'pending', + `${verb} connection profile for APN '${desired.connection.apn}'`, + ), + roaming: receipt( + 'roaming', + 'pending', + `roaming ${desired.roaming ? 'enabled' : 'disabled'} via connection profile`, + ), + op, + }; +} + +function radioMatches( + applied: readonly RadioAccessTechnology[] | undefined, + desired: readonly RadioAccessTechnology[], +): boolean { + if (applied === undefined || applied.length !== desired.length) { + return false; + } + return applied.every((rat, index) => rat === desired[index]); +} + +function planRadio( + current: AppliedCellularState, + desired: DesiredCellularPolicy, + capabilities: ModemCapabilities, +): DimensionResult { + const preference = desired.radio.preferenceOrdered; + const top = preference[0]; + if (top === undefined) { + return { + receipt: receipt( + 'radio', + 'failed', + 'radio preference must list at least one access technology', + ), + }; + } + if (!capabilities.supportedRats.has(top)) { + const supported = [...capabilities.supportedRats].join(', ') || 'none'; + return { + receipt: receipt( + 'radio', + 'unsupported', + `preferred radio access technology '${top}' is not supported by this modem (supports ${supported})`, + ), + }; + } + if (radioMatches(current.appliedRadioPreference, preference)) { + return { + receipt: receipt('radio', 'applied', `radio preference already ${preference.join(' > ')}`), + }; + } + return { + receipt: receipt('radio', 'pending', `setting radio preference to ${preference.join(' > ')}`), + op: mmOp({ kind: 'setRadioModes', preference: desired.radio }), + }; +} + +function planSimSlot( + current: AppliedCellularState, + desired: DesiredCellularPolicy, + capabilities: ModemCapabilities, +): DimensionResult { + if (desired.simSlot === undefined) { + return { receipt: receipt('simSlot', 'applied', 'no primary SIM slot preference set') }; + } + if (capabilities.simSlotCount <= 1) { + return { + receipt: receipt( + 'simSlot', + 'unsupported', + `primary SIM slot selection requires a multi-slot modem (this modem has ${capabilities.simSlotCount})`, + ), + }; + } + if (desired.simSlot < 1 || desired.simSlot > capabilities.simSlotCount) { + return { + receipt: receipt( + 'simSlot', + 'failed', + `SIM slot ${desired.simSlot} is out of range (1..${capabilities.simSlotCount})`, + ), + }; + } + if (current.activePrimarySlot === desired.simSlot) { + return { + receipt: receipt('simSlot', 'applied', `SIM slot ${desired.simSlot} already primary`), + }; + } + return { + receipt: receipt('simSlot', 'pending', `switching primary SIM slot to ${desired.simSlot}`), + op: mmOp({ kind: 'setPrimarySimSlot', slotIndex: desired.simSlot }), + }; +} + +function planEnabled( + current: AppliedCellularState, + desired: DesiredCellularPolicy, +): DimensionResult { + const nm = current.nmActivation; + const addressable = current.connectionId !== undefined && current.deviceIfname !== undefined; + if (desired.enabled) { + switch (nm) { + case 'activated': + return { receipt: receipt('enabled', 'applied', 'connection is active') }; + case 'activating': + return { receipt: receipt('enabled', 'pending', 'connection activation in progress') }; + case 'failed': + return { + receipt: receipt( + 'enabled', + 'failed', + `connection activation failed${current.activationFailureReason ? `: ${current.activationFailureReason}` : ''}`, + ), + }; + case 'unmanaged': + return { + receipt: receipt('enabled', 'unsupported', 'device is not managed by NetworkManager'), + }; + default: + if ( + !addressable || + current.connectionId === undefined || + current.deviceIfname === undefined + ) { + return { + receipt: receipt( + 'enabled', + 'pending', + 'awaiting connection profile and data interface before activation', + ), + }; + } + return { + receipt: receipt('enabled', 'pending', 'activating connection'), + op: nmOp({ + kind: 'activate', + connectionId: current.connectionId, + deviceIfname: current.deviceIfname, + }), + }; + } + } + if ( + (nm === 'activated' || nm === 'activating') && + current.connectionId !== undefined && + current.deviceIfname !== undefined + ) { + return { + receipt: receipt('enabled', 'pending', 'deactivating connection'), + op: nmOp({ + kind: 'deactivate', + connectionId: current.connectionId, + deviceIfname: current.deviceIfname, + }), + }; + } + return { receipt: receipt('enabled', 'applied', 'connection is inactive') }; +} + +// Recovery + usage are LOCAL-CONTROLLER owned (README ownership table): they emit no +// MM / NM op, only a receipt recording that the local policy was accepted. +function planRecovery(desired: DesiredCellularPolicy): Receipt { + return receipt( + 'recovery', + 'applied', + desired.recovery.enabled ? 'recovery policy recorded (enabled)' : 'recovery disabled (default)', + ); +} + +function planUsage(desired: DesiredCellularPolicy): Receipt { + const parts: string[] = []; + if (desired.usage.cycleDay !== undefined) { + parts.push(`cycle day ${desired.usage.cycleDay}`); + } + if (desired.usage.thresholdBytes !== undefined) { + parts.push(`threshold ${desired.usage.thresholdBytes} bytes`); + } + return receipt( + 'usage', + 'applied', + parts.length > 0 ? `usage policy recorded (${parts.join(', ')})` : 'no usage policy set', + ); +} diff --git a/control/src/ports/router.ts b/control/src/ports/router.ts new file mode 100644 index 0000000..17f5a33 --- /dev/null +++ b/control/src/ports/router.ts @@ -0,0 +1,29 @@ +// The router port — for devices MM cannot control (HiLink, router-ethernet class). +// +// This port is DELIBERATELY tiny: presence and advisory health ONLY. Health never +// drives activation or recovery — a degraded router stays in the routing set (it is +// never evicted on health alone). A router device is an Ethernet uplink we OBSERVE, +// not a modem we configure; there is no bearer / APN / radio verb here. + +import type { EpochMillis } from '../domain'; +import type { DeviceIfname } from './network-manager'; + +/** Whether a router-class device is present on an interface. */ +export type RouterPresence = 'present' | 'absent'; + +/** + * Advisory health of a router-class uplink. Every field is informational — a + * degraded router is reported, never removed from the routing set on health alone. + */ +export interface RouterHealth { + readonly presence: RouterPresence; + readonly gatewayReachable: boolean; + readonly egressHealthy: boolean; + readonly observedAt: EpochMillis; +} + +/** The router port — presence and advisory health only. No mutation verbs. */ +export interface RouterPort { + probePresence(ifname: DeviceIfname): Promise; + checkHealth(ifname: DeviceIfname): Promise; +} diff --git a/control/src/redact.test.ts b/control/src/redact.test.ts new file mode 100644 index 0000000..b3834f2 --- /dev/null +++ b/control/src/redact.test.ts @@ -0,0 +1,82 @@ +// Redaction classes — ICCID, IMSI, EID, PIN, PUK, and APN/connection passwords are +// stripped, including deeply nested occurrences and inside arrays; non-secret +// siblings survive and the input is never mutated. + +import { expect, test } from 'bun:test'; +import { REDACTED, redact } from './redact'; + +test('redacts every sensitive class at the top level, keeping non-secret siblings', () => { + const input = { + iccid: '8988303000000000000', + imsi: '310150123456789', + eid: '89049032000000000000000000000000', + pin: '1234', + puk: '12345678', + password: 's3cret', + apn: 'internet', + username: 'operator-user', + }; + const out = redact(input) as Record; + + expect(out.iccid).toBe(REDACTED); + expect(out.imsi).toBe(REDACTED); + expect(out.eid).toBe(REDACTED); + expect(out.pin).toBe(REDACTED); + expect(out.puk).toBe(REDACTED); + expect(out.password).toBe(REDACTED); + expect(out.apn).toBe('internet'); + expect(out.username).toBe('operator-user'); +}); + +test('redacts an APN password nested three levels deep', () => { + const policy = { connection: { auth: { username: 'u', password: 'hunter2' } } }; + const out = redact(policy) as { connection: { auth: { username: string; password: string } } }; + expect(out.connection.auth.password).toBe(REDACTED); + expect(out.connection.auth.username).toBe('u'); +}); + +test('redacts ICCID inside an array of SIM slots', () => { + const input = { + simSlots: [ + { index: 1, iccid: '8988000000000000001' }, + { index: 2, iccid: '8988000000000000002' }, + ], + }; + const out = redact(input) as { simSlots: Array<{ index: number; iccid: string }> }; + expect(out.simSlots[0]?.iccid).toBe(REDACTED); + expect(out.simSlots[1]?.iccid).toBe(REDACTED); + expect(out.simSlots[0]?.index).toBe(1); + expect(out.simSlots[1]?.index).toBe(2); +}); + +test('redacts NM-style gsm.password but keeps the gsm.password-flags flag', () => { + const input = { 'gsm.password': 'secret', 'gsm.password-flags': '0', 'gsm.apn': 'internet' }; + const out = redact(input) as Record; + expect(out['gsm.password']).toBe(REDACTED); + expect(out['gsm.password-flags']).toBe('0'); + expect(out['gsm.apn']).toBe('internet'); +}); + +test('redacts subscriptionId, newPin, and puk2 variants', () => { + const input = { subscriptionId: '8988303000000000000', newPin: '4321', puk2: '87654321' }; + const out = redact(input) as Record; + expect(out.subscriptionId).toBe(REDACTED); + expect(out.newPin).toBe(REDACTED); + expect(out.puk2).toBe(REDACTED); +}); + +test('does not mutate the input', () => { + const input = { pin: '1234', nested: { iccid: '5678' } }; + const before = JSON.stringify(input); + redact(input); + expect(JSON.stringify(input)).toBe(before); +}); + +test('passes primitives and empty containers through unchanged', () => { + expect(redact('plain')).toBe('plain'); + expect(redact(42)).toBe(42); + expect(redact(null)).toBe(null); + expect(redact(undefined)).toBe(undefined); + expect(redact({})).toEqual({}); + expect(redact([])).toEqual([]); +}); diff --git a/control/src/redact.ts b/control/src/redact.ts new file mode 100644 index 0000000..0ee12a9 --- /dev/null +++ b/control/src/redact.ts @@ -0,0 +1,73 @@ +// Redaction — strip sensitive identifiers from any value before it is logged, +// 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. + +/** The marker substituted for every redacted value. */ +export const REDACTED = '[redacted]'; + +// Leaf key names carrying a sensitive value, matched case-insensitively. The match +// is EXACT (or exact on the last dotted segment), so NM-style keys like +// `gsm.password` are caught while non-secret siblings like `gsm.password-flags` +// (a "0"/"4" flag, not a secret) are not. +const SENSITIVE_KEYS: ReadonlySet = new Set([ + 'iccid', + 'imsi', + 'eid', + 'pin', + 'pin2', + 'newpin', + 'puk', + 'puk2', + 'password', + 'passwd', + 'subscriptionid', +]); + +function isSensitiveKey(key: string): boolean { + const lower = key.toLowerCase(); + if (SENSITIVE_KEYS.has(lower)) { + return true; + } + const dot = lower.lastIndexOf('.'); + return dot >= 0 && SENSITIVE_KEYS.has(lower.slice(dot + 1)); +} + +function isPlainObject(value: unknown): value is Record { + if (typeof value !== 'object' || value === null) { + return false; + } + const proto = Object.getPrototypeOf(value) as unknown; + return proto === Object.prototype || proto === null; +} + +function redactValue(value: unknown, underSensitiveKey: boolean): unknown { + if (underSensitiveKey) { + return REDACTED; + } + if (Array.isArray(value)) { + return value.map((item) => redactValue(item, false)); + } + if (isPlainObject(value)) { + const out: Record = {}; + for (const [key, child] of Object.entries(value)) { + out[key] = redactValue(child, isSensitiveKey(key)); + } + return out; + } + return value; +} + +/** + * Return a deep copy of `value` with every sensitive field redacted. Plain objects + * and arrays are walked recursively; all other values (primitives, and opaque + * objects like `Date` / `Set` / `Map`) are returned unchanged. The input is never + * mutated. + */ +export function redact(value: unknown): unknown { + return redactValue(value, false); +} diff --git a/control/src/transport/README.md b/control/src/transport/README.md new file mode 100644 index 0000000..fd05df2 --- /dev/null +++ b/control/src/transport/README.md @@ -0,0 +1,65 @@ +# D-Bus transport seam + +A minimal internal transport interface over [`@httptoolkit/dbus-native`](https://www.npmjs.com/package/@httptoolkit/dbus-native). +Everything the rest of `@ceralive/modem-control` needs to talk to ModemManager over +D-Bus goes through the `DbusTransport` interface exported by [`index.ts`](./index.ts) — +method calls, signal subscriptions, and reconnect. The A3.x D-Bus backend builds +directly on this shape. + +## Why this library + +`@httptoolkit/dbus-native` is a pure-JavaScript D-Bus client (no libdbus / native +addon), which is the deciding factor: it imports and runs under **Bun 1.3.14** with +**EXTERNAL** auth on a session bus — session-verified during A2.4, and pinned exactly at +`0.1.5`. A native-addon client (anything binding libdbus) is a portability and +cross-compile liability for the arm64 + amd64 device image; a pure-JS client is not. + +## The fallback + +If `@httptoolkit/dbus-native` proves inadequate — an un-fixable marshalling bug, an +unmaintained upstream, or a Bun incompatibility introduced by a future runtime bump — +the documented fallback is [`@particle/dbus-next`](https://www.npmjs.com/package/@particle/dbus-next), +which is also pure-JS and was verified importable under Bun during planning (draft +ledger §dbus-native). It is **not** implemented here; this note records the escape hatch. +Because the entire library surface is quarantined behind this seam (see below), swapping +to it would touch only [`dbus-native.ts`](./dbus-native.ts), [`transport.ts`](./transport.ts), +and [`codec.ts`](./codec.ts) — never a caller. + +## The seam contract + +* **No library types leak.** `index.ts` re-exports only the transport's own types + (`DbusValue`, `DbusVariant`, `MethodCall`, `SignalEvent`, …). The raw library types + live in [`dbus-native.ts`](./dbus-native.ts) and go no further. A guard test asserts + the package entry (`../index.ts`) never re-exports the library. +* **Lossless 64-bit.** D-Bus `x` (INT64) and `t` (UINT64) are `bigint` end-to-end, never + a JS `number`. On decode the library runs with `ReturnLongjs: true` and we convert its + Long.js objects to `bigint` via their exact decimal string; on encode we require a + `bigint` and hand the library a decimal string (its only lossless 64-bit input). + Passing a `number` for a 64-bit field throws `BigIntRequiredError`. +* **`h` is unsupported.** UNIX_FD / file-descriptor passing is rejected up front with a + typed `UnsupportedSignatureError` — in an outgoing signature, a reply/signal signature, + or nested in a variant — never silently dropped or coerced. +* **Variants round-trip.** A decoded variant keeps its inner signature + (`DbusVariant { signature, value }`), so encode/decode is symmetric. + +## Conformance + +Tests run under `dbus-run-session -- bun test control/src/transport` and prove the seam +two independent ways: + +1. **Same library** ([`conformance-same-lib.test.ts`](./conformance-same-lib.test.ts)) — + round-trips representative signatures against a minimal fake service built on the same + `@httptoolkit/dbus-native`. +2. **Independent producer** ([`conformance-python.test.ts`](./conformance-python.test.ts)) — + round-trips against a `python3-dbus` (`dbus-python`) service in a subprocess, + exercising `a{oa{sa{sv}}}`, `x`/`t` above 2^53, variants, and `PropertiesChanged` + invalidations. A different implementation on the wire is the real proof our codec is + correct, not merely self-consistent. + +Plus [`reliability.test.ts`](./reliability.test.ts): reconnect after a bus restart +(against a dedicated private `dbus-daemon`), a ≥5000-event signal stream, late replies, +and a 100-cycle subscribe/unsubscribe listener-leak check. + +The `test-support/` fakes here are intentionally minimal — just enough to round-trip the +signatures under test. The MM-faithful fake service (root ObjectManager, `Modem` / +`Modem3gpp` interfaces, SIM objects, bearer tripwires) is a separate, later task (A2.3). diff --git a/control/src/transport/codec.test.ts b/control/src/transport/codec.test.ts new file mode 100644 index 0000000..9b9f0a7 --- /dev/null +++ b/control/src/transport/codec.test.ts @@ -0,0 +1,118 @@ +// Pure, in-process encode/decode round-trip: no bus, fully deterministic. Marshals with +// the library's own marshaller and reads it back with its DBusBuffer under +// `ReturnLongjs: true` — exactly the wire path a real call takes — so a green run here is +// exact proof the codec is lossless, independent of any daemon timing. + +import { expect, test } from 'bun:test'; +import DBusBuffer from '@httptoolkit/dbus-native/lib/dbus-buffer'; +import marshall from '@httptoolkit/dbus-native/lib/marshall'; +import { decodeBody, encodeBody } from './codec'; +import { BigIntRequiredError, SixtyFourBitRangeError, UnsupportedSignatureError } from './errors'; +import { type DbusValue, variant } from './types'; + +const INT64_MAX = 2n ** 63n - 1n; +const INT64_MIN = -(2n ** 63n); +const UINT64_MAX = 2n ** 64n - 1n; + +function roundTrip(signature: string, values: readonly DbusValue[]): DbusValue[] { + const encoded = encodeBody(signature, values); + const buffer = marshall(signature, encoded); + const reader = new DBusBuffer(buffer, 0, { ayBuffer: true, ReturnLongjs: true }); + const raw = reader.read(signature) as unknown[]; + return decodeBody(signature, raw); +} + +test('2^63-1 round-trips through an INT64 exactly (bigint equality)', () => { + const [value] = roundTrip('x', [INT64_MAX]); + expect(value).toBe(INT64_MAX); + expect(typeof value).toBe('bigint'); +}); + +test('the full UINT64 range (2^64-1) round-trips exactly', () => { + const [value] = roundTrip('t', [UINT64_MAX]); + expect(value).toBe(UINT64_MAX); +}); + +test('INT64 minimum round-trips exactly', () => { + const [value] = roundTrip('x', [INT64_MIN]); + expect(value).toBe(INT64_MIN); +}); + +test('a value just above 2^53 survives (no silent Number coercion)', () => { + const beyondSafe = 2n ** 53n + 1n; + const [asInt, asUint] = roundTrip('xt', [beyondSafe, beyondSafe]); + expect(asInt).toBe(beyondSafe); + expect(asUint).toBe(beyondSafe); + // The precision-losing path would have produced 9007199254740992n (== 2^53). + expect(asInt).not.toBe(2n ** 53n); +}); + +test('the MM GetManagedObjects shape a{oa{sa{sv}}} round-trips with a 64-bit variant', () => { + const managed: DbusValue = [ + [ + '/org/freedesktop/ModemManager1/Modem/0', + [ + [ + 'org.freedesktop.ModemManager1.Modem', + [ + ['SupportedCapabilities', variant('t', UINT64_MAX)], + ['SignalQuality', variant('u', 87)], + ['DeviceIdentifier', variant('s', 'dev-0')], + ], + ], + ], + ], + ]; + const [decoded] = roundTrip('a{oa{sa{sv}}}', [managed]); + expect(decoded).toEqual(managed); +}); + +test('a variant preserves its inner signature and 64-bit value across a round-trip', () => { + const [decoded] = roundTrip('v', [variant('t', UINT64_MAX)]); + expect(decoded).toEqual({ signature: 't', value: UINT64_MAX }); +}); + +test('a byte array (ay) round-trips as a Uint8Array', () => { + const bytes = new Uint8Array([0, 1, 2, 254, 255]); + const [decoded] = roundTrip('ay', [bytes]); + expect(decoded).toBeInstanceOf(Uint8Array); + expect(Array.from(decoded as Uint8Array)).toEqual([0, 1, 2, 254, 255]); +}); + +test('a struct with mixed 64-bit fields round-trips', () => { + const [decoded] = roundTrip('(xtu)', [[INT64_MIN, UINT64_MAX, 7]]); + expect(decoded).toEqual([INT64_MIN, UINT64_MAX, 7]); +}); + +test('signature h (UNIX_FD) is rejected on encode with a typed error', () => { + expect(() => encodeBody('h', [0])).toThrow(UnsupportedSignatureError); +}); + +test('signature h nested inside a container is rejected on encode', () => { + expect(() => encodeBody('a{sh}', [[]])).toThrow(UnsupportedSignatureError); +}); + +test('signature h is rejected on decode with a typed error', () => { + expect(() => decodeBody('h', [0])).toThrow(UnsupportedSignatureError); +}); + +test('the UnsupportedSignatureError carries the offending type', () => { + try { + encodeBody('h', [0]); + throw new Error('expected throw'); + } catch (error) { + expect(error).toBeInstanceOf(UnsupportedSignatureError); + expect((error as UnsupportedSignatureError).unsupportedType).toBe('h'); + } +}); + +test('passing a JS number for a 64-bit field throws BigIntRequiredError', () => { + expect(() => encodeBody('t', [123])).toThrow(BigIntRequiredError); + expect(() => encodeBody('x', [123])).toThrow(BigIntRequiredError); +}); + +test('an out-of-range 64-bit bigint throws SixtyFourBitRangeError', () => { + expect(() => encodeBody('t', [-1n])).toThrow(SixtyFourBitRangeError); + expect(() => encodeBody('x', [2n ** 63n])).toThrow(SixtyFourBitRangeError); + expect(() => encodeBody('t', [2n ** 64n])).toThrow(SixtyFourBitRangeError); +}); diff --git a/control/src/transport/codec.ts b/control/src/transport/codec.ts new file mode 100644 index 0000000..71b288e --- /dev/null +++ b/control/src/transport/codec.ts @@ -0,0 +1,240 @@ +// Signature-aware encode / decode between the transport's public value vocabulary +// (`DbusValue`, `bigint` for 64-bit, `DbusVariant`) and the shape the underlying +// `@httptoolkit/dbus-native` marshaller consumes and produces. +// +// Two invariants this layer enforces that the library does not: +// * 64-bit `x`/`t` are `bigint` end-to-end. On decode the library returns Long.js +// objects (only under `ReturnLongjs: true`); we convert them to `bigint` losslessly +// via their exact decimal `toString()`. On encode the library accepts a decimal +// string for the full 64-bit range but silently truncates a `number` above 2^53 — +// so we require a `bigint` and convert it to a decimal string ourselves. +// * `h` (UNIX_FD) is rejected up front with a typed error rather than reaching the +// library, which would throw a generic "Unknown/Unsupported type" instead. + +import { BigIntRequiredError, SixtyFourBitRangeError, UnsupportedSignatureError } from './errors'; +import { parseSignature, type SignatureNode, signatureFromNode } from './signature'; +import type { DbusValue, DbusVariant } from './types'; + +const INT64_MIN = -(2n ** 63n); +const INT64_MAX = 2n ** 63n - 1n; +const UINT64_MAX = 2n ** 64n - 1n; + +// A Long.js instance as the library hands it back with `ReturnLongjs: true`. We never +// import `long` (it is a transitive dependency, not ours) — we duck-type and use the +// exact decimal `toString()`, so no library type crosses this boundary. +interface LongLike { + readonly low: number; + readonly high: number; + readonly unsigned: boolean; + toString(): string; +} + +function isLongLike(value: unknown): value is LongLike { + return ( + typeof value === 'object' && + value !== null && + typeof (value as LongLike).low === 'number' && + typeof (value as LongLike).high === 'number' && + typeof (value as LongLike).unsigned === 'boolean' + ); +} + +function longToBigInt(value: LongLike): bigint { + return BigInt(value.toString()); +} + +// ── Encode ───────────────────────────────────────────────────────────────────────── +// Public values → library-native body. `assertSupportedSignature` is applied to the +// whole signature first, so no `h` reaches the recursion. +export function encodeBody(signature: string, args: readonly DbusValue[]): unknown[] { + if (signature.includes('h')) { + throw new UnsupportedSignatureError(signature, 'h'); + } + const nodes = parseSignature(signature); + if (nodes.length !== args.length) { + throw new Error( + `Body arity ${args.length} does not match signature "${signature}" (${nodes.length} types)`, + ); + } + return nodes.map((node, i) => encodeNode(node, args[i] as DbusValue)); +} + +function encodeNode(node: SignatureNode, value: DbusValue): unknown { + switch (node.type) { + case 'x': + return encode64(node.type, value, INT64_MIN, INT64_MAX); + case 't': + return encode64(node.type, value, 0n, UINT64_MAX); + case 'a': + return encodeArray(node, value); + case '(': + return encodeStruct(node, value); + case '{': + return encodeDictEntry(node, value); + case 'v': + return encodeVariant(value); + default: + // y b n q i u d s o g — the library validates range/type on marshal. + return value; + } +} + +function encode64(type: string, value: DbusValue, min: bigint, max: bigint): string { + if (typeof value !== 'bigint') { + throw new BigIntRequiredError(type, value); + } + if (value < min || value > max) { + throw new SixtyFourBitRangeError(type, value); + } + // Decimal string: the library parses this across the full 64-bit range losslessly. + return value.toString(); +} + +function encodeArray(node: SignatureNode, value: DbusValue): unknown { + const element = node.child[0] as SignatureNode; + // `ay` byte arrays pass straight through as a Uint8Array / Buffer (index-addressable). + if (element.type === 'y' && value instanceof Uint8Array) { + return value; + } + if (!Array.isArray(value)) { + throw new Error(`Expected array for signature "a${element.type}"`); + } + return value.map((item) => encodeNode(element, item)); +} + +function encodeStruct(node: SignatureNode, value: DbusValue): unknown[] { + if (!Array.isArray(value)) { + throw new Error('Expected array for struct value'); + } + if (value.length !== node.child.length) { + throw new Error(`Struct arity ${value.length} does not match ${node.child.length} field types`); + } + return node.child.map((child, i) => encodeNode(child, value[i] as DbusValue)); +} + +function encodeDictEntry(node: SignatureNode, value: DbusValue): unknown[] { + if (!Array.isArray(value) || value.length !== 2) { + throw new Error('Expected [key, value] pair for dict entry'); + } + const keyNode = node.child[0] as SignatureNode; + const valueNode = node.child[1] as SignatureNode; + return [encodeNode(keyNode, value[0] as DbusValue), encodeNode(valueNode, value[1] as DbusValue)]; +} + +function encodeVariant(value: DbusValue): [string, unknown] { + const asVariant = value as DbusVariant; + if (typeof asVariant?.signature !== 'string' || !('value' in (asVariant as object))) { + throw new Error('Expected a DbusVariant ({ signature, value }) for variant field'); + } + if (asVariant.signature.includes('h')) { + throw new UnsupportedSignatureError(asVariant.signature, 'h'); + } + const inner = parseSignature(asVariant.signature); + if (inner.length !== 1) { + throw new Error(`Variant signature "${asVariant.signature}" must be exactly one complete type`); + } + return [asVariant.signature, encodeNode(inner[0] as SignatureNode, asVariant.value)]; +} + +// ── Decode ───────────────────────────────────────────────────────────────────────── +// Library-native body → public values. The library must have been created with +// `ReturnLongjs: true` so `x`/`t` arrive as Long objects (this transport does exactly +// that); a raw `number` in a 64-bit slot means that flag was lost and is treated as a +// bug, not silently accepted. +export function decodeBody(signature: string, body: readonly unknown[]): DbusValue[] { + if (signature.includes('h')) { + throw new UnsupportedSignatureError(signature, 'h'); + } + const nodes = parseSignature(signature); + return nodes.map((node, i) => decodeNode(node, body[i])); +} + +function decodeNode(node: SignatureNode, value: unknown): DbusValue { + switch (node.type) { + case 'x': + case 't': + return decode64(node.type, value); + case 'a': + return decodeArray(node, value); + case '(': + return decodeStruct(node, value); + case '{': + return decodeDictEntry(node, value); + case 'v': + return decodeVariant(value); + case 'y': + case 'b': + case 'n': + case 'q': + case 'i': + case 'u': + case 'd': + case 's': + case 'o': + case 'g': + return value as DbusValue; + default: + throw new UnsupportedSignatureError(node.type, node.type); + } +} + +function decode64(type: string, value: unknown): bigint { + if (isLongLike(value)) { + return longToBigInt(value); + } + if (typeof value === 'bigint') { + return value; + } + // A plain number reaching here means ReturnLongjs was not applied — refuse it rather + // than risk having already lost precision above 2^53. + throw new BigIntRequiredError(type, value); +} + +function decodeArray(node: SignatureNode, value: unknown): DbusValue { + const element = node.child[0] as SignatureNode; + if (element.type === 'y') { + // `ay`: the library returns a Buffer; normalise to a plain Uint8Array. + if (value instanceof Uint8Array) { + return new Uint8Array(value); + } + } + if (!Array.isArray(value)) { + throw new Error(`Expected array while decoding "a${element.type}"`); + } + return value.map((item) => decodeNode(element, item)); +} + +function decodeStruct(node: SignatureNode, value: unknown): DbusValue { + if (!Array.isArray(value)) { + throw new Error('Expected array while decoding struct'); + } + return node.child.map((child, i) => decodeNode(child, value[i])); +} + +function decodeDictEntry(node: SignatureNode, value: unknown): DbusValue { + if (!Array.isArray(value) || value.length !== 2) { + throw new Error('Expected [key, value] pair while decoding dict entry'); + } + const keyNode = node.child[0] as SignatureNode; + const valueNode = node.child[1] as SignatureNode; + return [decodeNode(keyNode, value[0]), decodeNode(valueNode, value[1])]; +} + +// The library decodes a variant to `[parseTree, [innerValue]]`. We recover the inner +// signature from the tree and decode the contained value recursively, preserving both. +function decodeVariant(value: unknown): DbusVariant { + if (!Array.isArray(value) || value.length !== 2) { + throw new Error('Malformed variant from library (expected [tree, [value]])'); + } + const tree = value[0] as SignatureNode[]; + const inner = value[1] as unknown[]; + if (!Array.isArray(tree) || tree.length !== 1 || !Array.isArray(inner)) { + throw new Error('Malformed variant tree from library'); + } + const innerNode = tree[0] as SignatureNode; + const innerSignature = signatureFromNode(innerNode); + return { + signature: innerSignature, + value: decodeNode(innerNode, inner[0]), + }; +} diff --git a/control/src/transport/conformance-python.test.ts b/control/src/transport/conformance-python.test.ts new file mode 100644 index 0000000..0e5a333 --- /dev/null +++ b/control/src/transport/conformance-python.test.ts @@ -0,0 +1,152 @@ +// Conformance half (b): round-trip against an INDEPENDENT `python3-dbus` producer running +// as a subprocess — a different implementation on the wire than the JavaScript library +// under test. Agreement with a foreign producer, not just the same-library fake, is the +// real proof the codec is correct. Exercises the MM `a{oa{sa{sv}}}` shape, `x`/`t` above +// 2^53, variants, and a `PropertiesChanged` signal carrying invalidated properties. +// +// Skips loudly (never fails) when the session bus or `dbus-python` bindings are absent. + +import { afterAll, beforeAll, describe, expect, test } from 'bun:test'; +import { join } from 'node:path'; +import type { DbusTransport, SignalEvent } from './index'; +import { createDbusTransport } from './index'; + +const SESSION_BUS = process.env.DBUS_SESSION_BUS_ADDRESS; + +const PY_BUS_NAME = 'tv.ceralive.ModemStackPy'; +const PY_PATH = '/tv/ceralive/pyfake'; +const PY_IFACE = 'tv.ceralive.ModemStackPy.Conformance'; +const PROPERTIES_IFACE = 'org.freedesktop.DBus.Properties'; + +const INT64_MAX = 2n ** 63n - 1n; +const UINT64_MAX = 2n ** 64n - 1n; + +function hasDbusPython(): boolean { + const probe = Bun.spawnSync(['python3', '-c', 'import dbus, dbus.service, gi.repository.GLib']); + return probe.exitCode === 0; +} + +async function waitForReady(proc: ReturnType, timeoutMs: number): Promise { + const reader = (proc.stdout as ReadableStream).getReader(); + const decoder = new TextDecoder(); + let seen = ''; + const deadline = Date.now() + timeoutMs; + try { + while (Date.now() < deadline) { + const { value, done } = await reader.read(); + if (done) { + throw new Error('python producer exited before signalling READY'); + } + seen += decoder.decode(value, { stream: true }); + if (seen.includes('READY')) { + return; + } + } + throw new Error('timed out waiting for python producer READY'); + } finally { + reader.releaseLock(); + } +} + +const runnable = Boolean(SESSION_BUS) && hasDbusPython(); + +describe.skipIf(!runnable)('conformance vs independent python3-dbus producer', () => { + let producer: ReturnType; + let transport: DbusTransport; + + beforeAll(async () => { + const script = join(import.meta.dir, 'test-support', 'independent-producer.py'); + producer = Bun.spawn(['python3', script], { + stdout: 'pipe', + stderr: 'inherit', + env: process.env, + }); + await waitForReady(producer, 10_000); + transport = createDbusTransport({ busAddress: SESSION_BUS as string }); + await transport.connect(); + }); + + afterAll(async () => { + await transport.disconnect(); + producer.kill(); + }); + + const call = (member: string, signature?: string, args?: unknown[]) => + transport.callMethod({ + destination: PY_BUS_NAME, + path: PY_PATH, + interface: PY_IFACE, + member, + ...(signature !== undefined ? { signature } : {}), + ...(args !== undefined ? { args: args as never } : {}), + }); + + test('the independent producer returns the a{oa{sa{sv}}} shape with 64-bit variants', async () => { + const reply = await call('GetManagedObjects'); + expect(reply.signature).toBe('a{oa{sa{sv}}}'); + expect(reply.body[0]).toEqual([ + [ + '/org/freedesktop/ModemManager1/Modem/0', + [ + [ + 'org.freedesktop.ModemManager1.Modem', + [ + ['SupportedCapabilities', { signature: 't', value: UINT64_MAX }], + ['MaxBearers', { signature: 'x', value: -INT64_MAX }], + ['SignalQuality', { signature: 'u', value: 87 }], + ['DeviceIdentifier', { signature: 's', value: 'py-device-0' }], + ], + ], + ], + ], + ]); + }); + + test('an INT64 above 2^53 from the independent producer decodes to an exact bigint', async () => { + const reply = await call('GetInt64'); + expect(reply.body[0]).toBe(INT64_MAX); + }); + + test('a UINT64 at the 64-bit maximum from the independent producer decodes exactly', async () => { + const reply = await call('GetUint64'); + expect(reply.body[0]).toBe(UINT64_MAX); + }); + + test('a bare variant from the independent producer preserves signature and value', async () => { + const reply = await call('GetVariant'); + expect(reply.body[0]).toEqual({ signature: 't', value: UINT64_MAX }); + }); + + test('2^63-1 round-trips through the independent producer exactly', async () => { + const reply = await call('EchoUint64', 't', [INT64_MAX]); + expect(reply.body[0]).toBe(INT64_MAX); + }); + + test('the independent producer confirms the exact 64-bit value we encoded', async () => { + const reply = await call('DescribeUint64', 't', [UINT64_MAX]); + expect(reply.body[0]).toBe(UINT64_MAX.toString()); + }); + + test('a PropertiesChanged signal decodes changed 64-bit values and invalidated names', async () => { + let resolveEvent: (event: SignalEvent) => void = () => undefined; + const received = new Promise((resolve) => { + resolveEvent = resolve; + }); + const subscription = await transport.subscribeSignal( + { interface: PROPERTIES_IFACE, member: 'PropertiesChanged', path: PY_PATH }, + (event) => resolveEvent(event), + ); + await call('TriggerPropertiesChanged'); + + const event = await received; + expect(event.signature).toBe('sa{sv}as'); + const [interfaceName, changed, invalidated] = event.body; + expect(interfaceName).toBe(PY_IFACE); + expect(changed).toEqual([ + ['AccessTechnologies', { signature: 't', value: UINT64_MAX }], + ['SignalQuality', { signature: 'u', value: 42 }], + ]); + expect(invalidated).toEqual(['OperatorName', 'OperatorCode']); + await subscription.unsubscribe(); + }); +}); diff --git a/control/src/transport/conformance-same-lib.test.ts b/control/src/transport/conformance-same-lib.test.ts new file mode 100644 index 0000000..3efcfb2 --- /dev/null +++ b/control/src/transport/conformance-same-lib.test.ts @@ -0,0 +1,115 @@ +// Conformance half (a): round-trip representative signatures against a fake service built +// on the SAME `@httptoolkit/dbus-native` library, over a real session bus. This proves the +// transport works end-to-end against the daemon; half (b) proves it against an independent +// producer. Run under `dbus-run-session -- bun test control/src/transport`. + +import { afterAll, beforeAll, describe, expect, test } from 'bun:test'; +import type { DbusTransport } from './index'; +import { createDbusTransport } from './index'; +import { + FAKE_IFACE, + FAKE_PATH, + type FakeService, + INT64_MAX, + startFakeService, + UINT64_MAX, +} from './test-support/fake-service'; + +const SESSION_BUS = process.env.DBUS_SESSION_BUS_ADDRESS; + +describe.skipIf(!SESSION_BUS)('conformance vs same-library fake service', () => { + let fake: FakeService; + let transport: DbusTransport; + + beforeAll(async () => { + const busAddress = SESSION_BUS as string; + fake = await startFakeService({ busAddress }); + transport = createDbusTransport({ busAddress }); + await transport.connect(); + }); + + afterAll(async () => { + await transport.disconnect(); + await fake.stop(); + }); + + const call = (member: string, signature?: string, args?: unknown[]) => + transport.callMethod({ + destination: fake.busName, + path: FAKE_PATH, + interface: FAKE_IFACE, + member, + ...(signature !== undefined ? { signature } : {}), + ...(args !== undefined ? { args: args as never } : {}), + }); + + test('a basic string method replies', async () => { + const reply = await call('Ping'); + expect(reply.signature).toBe('s'); + expect(reply.body[0]).toBe('pong'); + }); + + test('GetManagedObjects decodes the a{oa{sa{sv}}} shape with a 64-bit variant', async () => { + const reply = await call('GetManagedObjects'); + expect(reply.signature).toBe('a{oa{sa{sv}}}'); + expect(reply.body[0]).toEqual([ + [ + '/org/freedesktop/ModemManager1/Modem/0', + [ + [ + 'org.freedesktop.ModemManager1.Modem', + [ + ['SupportedCapabilities', { signature: 't', value: UINT64_MAX }], + ['SignalQuality', { signature: 'u', value: 87 }], + ['DeviceIdentifier', { signature: 's', value: 'fake-device-0' }], + ], + ], + ], + ], + ]); + }); + + test('an INT64 reply above 2^53 decodes to an exact bigint', async () => { + const reply = await call('GetInt64'); + expect(reply.body[0]).toBe(INT64_MAX); + expect(typeof reply.body[0]).toBe('bigint'); + }); + + test('a UINT64 reply at the full 64-bit maximum decodes exactly', async () => { + const reply = await call('GetUint64'); + expect(reply.body[0]).toBe(UINT64_MAX); + }); + + test('2^63-1 round-trips through the daemon exactly (encode + decode)', async () => { + const reply = await call('EchoUint64', 't', [INT64_MAX]); + expect(reply.body[0]).toBe(INT64_MAX); + }); + + test('the full UINT64 maximum round-trips through the daemon exactly', async () => { + const reply = await call('EchoUint64', 't', [UINT64_MAX]); + expect(reply.body[0]).toBe(UINT64_MAX); + }); + + test('a negative INT64 round-trips through the daemon exactly', async () => { + const reply = await call('EchoInt64', 'x', [-INT64_MAX]); + expect(reply.body[0]).toBe(-INT64_MAX); + }); + + test('the service reports back the exact 64-bit value we encoded', async () => { + const reply = await call('DescribeUint64', 't', [INT64_MAX]); + expect(reply.body[0]).toBe(INT64_MAX.toString()); + }); + + test('passing a JS number for a 64-bit argument is refused before the wire', async () => { + await expect( + transport.callMethod({ + destination: fake.busName, + path: FAKE_PATH, + interface: FAKE_IFACE, + member: 'EchoUint64', + signature: 't', + args: [123 as never], + }), + ).rejects.toThrow('requires a bigint'); + }); +}); diff --git a/control/src/transport/dbus-native-lib.d.ts b/control/src/transport/dbus-native-lib.d.ts new file mode 100644 index 0000000..03a57b8 --- /dev/null +++ b/control/src/transport/dbus-native-lib.d.ts @@ -0,0 +1,19 @@ +// Ambient declarations for the deep library entry points the transport tests use to run +// an in-process, bus-free marshal/unmarshal round-trip. These are test-only reaches into +// `@httptoolkit/dbus-native` internals; the production transport never imports them. + +declare module '@httptoolkit/dbus-native/lib/marshall' { + const marshall: (signature: string, data: unknown[], offset?: number) => Buffer; + export default marshall; +} + +declare module '@httptoolkit/dbus-native/lib/dbus-buffer' { + export default class DBusBuffer { + constructor( + buffer: Buffer, + startPos?: number, + options?: { ayBuffer?: boolean; ReturnLongjs?: boolean }, + ); + read(signature: string): unknown; + } +} diff --git a/control/src/transport/dbus-native.ts b/control/src/transport/dbus-native.ts new file mode 100644 index 0000000..1fcd32a --- /dev/null +++ b/control/src/transport/dbus-native.ts @@ -0,0 +1,85 @@ +// Internal typed facade over the `@httptoolkit/dbus-native` CommonJS module. +// +// The library ships a `types.d.ts` that covers only a fraction of the surface we use +// (no `invoke`, `addMatch`, `connection`, or `messageType`), so we describe the exact +// parts we depend on here and cast once, at this single boundary. NOTHING in this file +// is re-exported from the package's public entry (`../index.ts`): the raw library types +// stay quarantined behind the transport seam. See `./README.md` for why this library +// was chosen and the documented fallback (`@particle/dbus-next`). + +import * as dbusNativeModule from '@httptoolkit/dbus-native'; + +// A D-Bus message as the library marshals/unmarshals it. +export interface RawMessage { + type?: number; + path?: string; + interface?: string; + member?: string; + destination?: string; + sender?: string; + signature?: string; + body?: unknown[]; + errorName?: string; + serial?: number; + replySerial?: number; +} + +// The context (`this`) the library binds when it invokes a reply callback. `signature` +// is the reply's own D-Bus signature — the promisified `invoke` drops this and collapses +// multi-value bodies, which is why we always call `invoke` with an explicit callback. +export interface ReplyContext { + signature?: string; + message?: RawMessage; +} + +export type ReplyCallback = (this: ReplyContext, error: unknown, ...body: unknown[]) => void; + +// The underlying stream/EventEmitter. We drive reconnect off its lifecycle events and +// assert on `listenerCount` in the leak test, so both are part of the facade. +export interface RawConnection { + on(event: string, handler: (...args: unknown[]) => void): void; + once(event: string, handler: (...args: unknown[]) => void): void; + removeListener(event: string, handler: (...args: unknown[]) => void): void; + removeAllListeners(event?: string): void; + listenerCount(event: string): number; + end(): void; +} + +export interface RawBus { + connection: RawConnection; + invoke(message: RawMessage, callback: ReplyCallback): void; + addMatch(rule: string): Promise; + removeMatch(rule: string): Promise; + disconnect(): Promise; +} + +export interface CreateClientOptions { + busAddress?: string; + socket?: string; + // Return 64-bit `x`/`t` fields as Long.js objects instead of lossy numbers. The + // transport always sets this so `codec.decodeBody` can convert them to `bigint`. + ReturnLongjs?: boolean; + direct?: boolean; +} + +export interface DbusNativeModule { + createClient(options: CreateClientOptions): RawBus; + messageType: { + invalid: number; + methodCall: number; + methodReturn: number; + error: number; + signal: number; + }; +} + +// Bun/Node CJS interop: `import *` yields the module namespace whose `default` (when +// present) is `module.exports`. One cast confines the untyped surface to this line. +const resolved = ((dbusNativeModule as { default?: unknown }).default ?? + dbusNativeModule) as unknown as DbusNativeModule; + +export const messageType = resolved.messageType; + +export function createClient(options: CreateClientOptions): RawBus { + return resolved.createClient(options); +} diff --git a/control/src/transport/errors.ts b/control/src/transport/errors.ts new file mode 100644 index 0000000..2327b13 --- /dev/null +++ b/control/src/transport/errors.ts @@ -0,0 +1,74 @@ +// Typed error taxonomy for the D-Bus transport seam. +// +// Wire data crossing the transport boundary is never compile-time checked, so every +// failure mode that a caller (or the A3.x D-Bus backend) must branch on is a real, +// exported class — never a bare thrown string or a generic Error. + +export class TransportError extends Error { + constructor(message: string, options?: ErrorOptions) { + super(message, options); + this.name = 'TransportError'; + } +} + +// A D-Bus signature contained a type this transport refuses to handle. The only such +// type today is `h` (UNIX_FD / file-descriptor passing): the transport never marshals +// or unmarshals a file descriptor, so encountering `h` in an outgoing call signature, +// a reply signature, a signal signature, or nested inside a variant throws this rather +// than silently dropping or coercing the descriptor. +export class UnsupportedSignatureError extends TransportError { + readonly signature: string; + readonly unsupportedType: string; + + constructor(signature: string, unsupportedType: string) { + super( + `D-Bus signature "${signature}" contains unsupported type "${unsupportedType}": ` + + 'UNIX_FD / file-descriptor passing is not supported by this transport', + ); + this.name = 'UnsupportedSignatureError'; + this.signature = signature; + this.unsupportedType = unsupportedType; + } +} + +// A 64-bit field (`x` INT64 / `t` UINT64) was handed a JavaScript `number` on encode. +// A `number` cannot carry the full 64-bit range without silent precision loss above +// 2^53, so the transport requires a `bigint` for these fields and refuses `number` +// loudly instead of corrupting the value on the wire. +export class BigIntRequiredError extends TransportError { + readonly signature: string; + readonly received: string; + + constructor(signature: string, received: unknown) { + super( + `D-Bus 64-bit field "${signature}" requires a bigint, received ${typeof received} ` + + `(${String(received)}): a JS number silently loses precision above 2^53`, + ); + this.name = 'BigIntRequiredError'; + this.signature = signature; + this.received = typeof received; + } +} + +// A 64-bit value was outside the range representable by its signed/unsigned field. +export class SixtyFourBitRangeError extends TransportError { + readonly signature: string; + readonly value: bigint; + + constructor(signature: string, value: bigint) { + super(`Value ${value} is out of range for 64-bit D-Bus field "${signature}"`); + this.name = 'SixtyFourBitRangeError'; + this.signature = signature; + this.value = value; + } +} + +// The bus connection dropped (or was never established) while a method call was +// in-flight or pending. The call rejects with this instead of hanging forever; the +// transport's own reconnect loop keeps running independently. +export class DisconnectedError extends TransportError { + constructor(message = 'D-Bus connection is not established') { + super(message); + this.name = 'DisconnectedError'; + } +} diff --git a/control/src/transport/index.ts b/control/src/transport/index.ts new file mode 100644 index 0000000..a15f436 --- /dev/null +++ b/control/src/transport/index.ts @@ -0,0 +1,30 @@ +// Public surface of the D-Bus transport seam. +// +// This is the ONLY module the rest of `@ceralive/modem-control` (and the A3.x D-Bus +// backend) imports from `./transport`. Every type here is the transport's own — no +// `@httptoolkit/dbus-native` type is re-exported, so swapping the underlying library +// (documented fallback: `@particle/dbus-next`) is invisible to callers. See README.md. + +export { + BigIntRequiredError, + DisconnectedError, + SixtyFourBitRangeError, + TransportError, + UnsupportedSignatureError, +} from './errors'; +export { createDbusTransport } from './transport'; +export type { + DbusTransport, + DbusTransportOptions, + DbusValue, + DbusVariant, + MethodCall, + MethodReply, + ReconnectOptions, + SignalEvent, + SignalListener, + SignalSpec, + Subscription, + TransportEvent, +} from './types'; +export { isVariant, variant } from './types'; diff --git a/control/src/transport/no-library-leak.test.ts b/control/src/transport/no-library-leak.test.ts new file mode 100644 index 0000000..3a6804c --- /dev/null +++ b/control/src/transport/no-library-leak.test.ts @@ -0,0 +1,50 @@ +// Guard: the `@httptoolkit/dbus-native` library must never surface in a public export. +// +// The package entry (`control/src/index.ts`) and the transport seam's own public entry +// (`./index.ts`) may reference the library only through the quarantined facade +// (`./dbus-native.ts`) — never re-export it. Swapping the underlying library (documented +// fallback `@particle/dbus-next`) must stay invisible to every caller. + +import { expect, test } from 'bun:test'; +import { join } from 'node:path'; +import * as transportPublic from './index'; + +// A single-quoted module specifier — the shape of a real import/export, distinct from a +// prose mention of the library in a comment (those use backticks). +const LIBRARY_IMPORT = "'@httptoolkit/dbus-native'"; +const transportDir = import.meta.dir; +const controlSrcDir = join(transportDir, '..'); + +test('the package entry does not import or re-export the D-Bus library', async () => { + const source = await Bun.file(join(controlSrcDir, 'index.ts')).text(); + expect(source).not.toContain(LIBRARY_IMPORT); +}); + +test('the transport public entry does not import or re-export the D-Bus library', async () => { + const source = await Bun.file(join(transportDir, 'index.ts')).text(); + expect(source).not.toContain(LIBRARY_IMPORT); +}); + +test('only the quarantined facade imports the D-Bus library from production modules', async () => { + const productionModules = ['transport.ts', 'codec.ts', 'signature.ts', 'errors.ts', 'types.ts']; + for (const moduleName of productionModules) { + const source = await Bun.file(join(transportDir, moduleName)).text(); + expect(source).not.toContain(LIBRARY_IMPORT); + } +}); + +test('the transport public surface exposes only the seam\u2019s own values', () => { + const exported = Object.keys(transportPublic).sort(); + expect(exported).toEqual( + [ + 'BigIntRequiredError', + 'DisconnectedError', + 'SixtyFourBitRangeError', + 'TransportError', + 'UnsupportedSignatureError', + 'createDbusTransport', + 'isVariant', + 'variant', + ].sort(), + ); +}); diff --git a/control/src/transport/reliability.test.ts b/control/src/transport/reliability.test.ts new file mode 100644 index 0000000..23ce2a2 --- /dev/null +++ b/control/src/transport/reliability.test.ts @@ -0,0 +1,173 @@ +// Reliability tests for the transport seam: reconnect after a bus restart, a ≥5000-event +// signal stream, late replies, and a 100-cycle subscribe/unsubscribe leak check. +// +// These run against dedicated private `dbus-daemon` instances (not the outer session +// bus): the reconnect test must kill and respawn its bus, which is only safe on a bus we +// own. That also makes this file self-contained — it needs no `dbus-run-session`. + +import { afterAll, beforeAll, describe, expect, test } from 'bun:test'; +import type { DbusTransport } from './index'; +import { createDbusTransport } from './index'; +import { + FAKE_IFACE, + FAKE_PATH, + type FakeService, + startFakeService, + TICK_MEMBER, +} from './test-support/fake-service'; +import { PrivateBus } from './test-support/private-bus'; + +const HAS_DBUS_DAEMON = Bun.which('dbus-daemon') !== null; + +const sleep = (ms: number): Promise => new Promise((resolve) => setTimeout(resolve, ms)); + +async function waitFor(predicate: () => boolean, timeoutMs: number, label: string): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (predicate()) { + return; + } + await sleep(10); + } + throw new Error(`timed out after ${timeoutMs}ms waiting for ${label}`); +} + +const tickSpec = { interface: FAKE_IFACE, member: TICK_MEMBER, path: FAKE_PATH }; + +describe.skipIf(!HAS_DBUS_DAEMON)('transport reliability (shared private bus)', () => { + let bus: PrivateBus; + let fake: FakeService; + let transport: DbusTransport; + + beforeAll(async () => { + bus = new PrivateBus(); + await bus.start(); + fake = await startFakeService({ socket: bus.socket }); + transport = createDbusTransport({ socket: bus.socket }); + await transport.connect(); + }); + + afterAll(async () => { + await transport.disconnect(); + await fake.stop(); + await bus.stop(); + }); + + test('a late reply (reply delayed 600ms) still resolves the call correctly', async () => { + const reply = await transport.callMethod({ + destination: fake.busName, + path: FAKE_PATH, + interface: FAKE_IFACE, + member: 'SlowPing', + signature: 'u', + args: [600], + }); + expect(reply.body[0]).toBe('pong'); + }); + + test('a ≥5000-event signal stream is delivered completely, in order, as exact bigints', async () => { + const total = 5000; + const received: bigint[] = []; + const subscription = await transport.subscribeSignal(tickSpec, (event) => { + received.push(event.body[0] as bigint); + }); + + for (let seq = 0; seq < total; seq += 1) { + fake.emitTick(BigInt(seq)); + } + + await waitFor(() => received.length >= total, 20_000, `${total} tick signals`); + expect(received.length).toBe(total); + expect(received[0]).toBe(0n); + expect(received[total - 1]).toBe(BigInt(total - 1)); + // Order and exactness across the whole stream. + let ordered = true; + for (let i = 0; i < total; i += 1) { + if (received[i] !== BigInt(i)) { + ordered = false; + break; + } + } + expect(ordered).toBe(true); + + await subscription.unsubscribe(); + }); + + test('100 subscribe/unsubscribe cycles leave no leaked listener', async () => { + const baseline = transport.subscriptionCount(); + for (let cycle = 0; cycle < 100; cycle += 1) { + const subscription = await transport.subscribeSignal(tickSpec, () => undefined); + await subscription.unsubscribe(); + } + expect(transport.subscriptionCount()).toBe(baseline); + + // Behavioural proof: after all those cycles a fresh subscriber receives each signal + // exactly once — a leaked listener from an earlier cycle would double-deliver. + let deliveries = 0; + const subscription = await transport.subscribeSignal(tickSpec, () => { + deliveries += 1; + }); + fake.emitTick(1n); + await waitFor(() => deliveries >= 1, 3_000, 'one tick after leak cycles'); + await sleep(100); + expect(deliveries).toBe(1); + await subscription.unsubscribe(); + }); +}); + +test.skipIf(!HAS_DBUS_DAEMON)( + 'transport reconnects and resubscribes after a bus restart without a consumer-facing crash', + async () => { + const bus = new PrivateBus(); + await bus.start(); + let fake = await startFakeService({ socket: bus.socket }); + const transport = createDbusTransport({ + socket: bus.socket, + reconnect: { initialDelayMs: 25, maxDelayMs: 200 }, + }); + + const events: string[] = []; + let consumerError: unknown = null; + transport.on('disconnected', () => events.push('disconnected')); + transport.on('reconnected', () => events.push('reconnected')); + transport.on('error', (error) => { + consumerError = error; + }); + + await transport.connect(); + + const ticks: bigint[] = []; + const subscription = await transport.subscribeSignal(tickSpec, (event) => { + ticks.push(event.body[0] as bigint); + }); + + fake.emitTick(11n); + await waitFor(() => ticks.includes(11n), 3_000, 'pre-restart tick'); + + // Kill the bus mid-flight; the old fake dies with it. + bus.kill(); + await waitFor(() => events.includes('disconnected'), 5_000, 'disconnected event'); + + // Bring the bus back at the same socket; the transport must reconnect on its own. + await bus.start(); + await waitFor(() => events.includes('reconnected'), 15_000, 'reconnected event'); + + // A fresh producer on the restored bus; the transport auto-resubscribed, so its + // signal must arrive without the caller re-subscribing. + fake = await startFakeService({ socket: bus.socket }); + fake.emitTick(22n); + await waitFor(() => ticks.includes(22n), 8_000, 'post-reconnect tick (resubscribe)'); + + expect(events).toContain('disconnected'); + expect(events).toContain('reconnected'); + expect(ticks).toContain(11n); + expect(ticks).toContain(22n); + expect(consumerError).toBeNull(); + + await subscription.unsubscribe(); + await transport.disconnect(); + await fake.stop(); + await bus.stop(); + }, + 30_000, +); diff --git a/control/src/transport/signature.ts b/control/src/transport/signature.ts new file mode 100644 index 0000000..cb56992 --- /dev/null +++ b/control/src/transport/signature.ts @@ -0,0 +1,110 @@ +// D-Bus signature parsing and support checks. +// +// We keep our own tiny recursive-descent parser rather than reaching into the +// underlying library's internal `lib/signature.js`: the parsed tree drives our +// signature-aware encode/decode (below) and the tree shape is part of this seam's +// contract, not the library's. The node shape intentionally mirrors the library's +// (`{ type, child }`) so a tree the library hands us inside a decoded variant can be +// re-serialised with `signatureFromNode` without a translation step. + +import { UnsupportedSignatureError } from './errors'; + +export interface SignatureNode { + readonly type: string; + readonly child: readonly SignatureNode[]; +} + +const CONTAINER_CLOSE: Record = { + '{': '}', + '(': ')', +}; + +// Every basic and container type char D-Bus defines. `h` (UNIX_FD) is deliberately +// listed as *known* so the parser accepts it structurally — we reject it explicitly in +// `assertSupportedSignature` with a typed error rather than as a vague parse failure. +const KNOWN_TYPES = new Set('ybnqiuxtdsogarvhe{}()'.split('')); + +// UNIX_FD: file-descriptor passing. Unsupported everywhere in this transport. +const UNSUPPORTED_TYPE = 'h'; + +// Throws UnsupportedSignatureError if the signature contains `h` anywhere (including +// nested inside arrays, structs, dict entries, or variants). Signatures are pure type +// strings, so a plain character scan is exact. +export function assertSupportedSignature(signature: string): void { + const index = signature.indexOf(UNSUPPORTED_TYPE); + if (index !== -1) { + throw new UnsupportedSignatureError(signature, UNSUPPORTED_TYPE); + } +} + +// Parse a full signature (which may contain several top-level complete types) into a +// flat list of nodes. +export function parseSignature(signature: string): SignatureNode[] { + let index = 0; + + function next(): string | null { + if (index < signature.length) { + const char = signature[index] as string; + index += 1; + return char; + } + return null; + } + + function parseOne(char: string): SignatureNode { + if (!KNOWN_TYPES.has(char)) { + throw new Error(`Unknown D-Bus type "${char}" in signature "${signature}"`); + } + + const children: SignatureNode[] = []; + switch (char) { + case 'a': { + const element = next(); + if (element === null) { + throw new Error(`Bad signature "${signature}": array with no element type`); + } + children.push(parseOne(element)); + return { type: 'a', child: children }; + } + case '{': + case '(': { + const close = CONTAINER_CLOSE[char]; + let element = next(); + while (element !== null && element !== close) { + children.push(parseOne(element)); + element = next(); + } + if (element === null) { + throw new Error(`Bad signature "${signature}": unterminated "${char}"`); + } + return { type: char, child: children }; + } + default: + return { type: char, child: children }; + } + } + + const nodes: SignatureNode[] = []; + let char = next(); + while (char !== null) { + nodes.push(parseOne(char)); + char = next(); + } + return nodes; +} + +// Re-serialise a single parsed node back to its signature string. Used to recover the +// inner signature of a decoded variant, whose contained type the library hands us as a +// parse tree rather than a string. +export function signatureFromNode(node: SignatureNode): string { + switch (node.type) { + case 'a': + return `a${signatureFromNode(node.child[0] as SignatureNode)}`; + case '(': + return `(${node.child.map(signatureFromNode).join('')})`; + case '{': + return `{${node.child.map(signatureFromNode).join('')}}`; + default: + return node.type; + } +} diff --git a/control/src/transport/test-support/fake-service.ts b/control/src/transport/test-support/fake-service.ts new file mode 100644 index 0000000..e28ade7 --- /dev/null +++ b/control/src/transport/test-support/fake-service.ts @@ -0,0 +1,143 @@ +// Minimal fake D-Bus service built on the SAME `@httptoolkit/dbus-native` library, for +// the half-(a) conformance tests. It is deliberately tiny — a handful of echo/describe +// methods and one signal — NOT the MM-faithful object model (root ObjectManager, Modem / +// Modem3gpp interfaces, SIM objects, bearer tripwires); that richer fake is task A2.3. +// +// The service runs with `ReturnLongjs: true` so 64-bit values it receives survive as +// Long objects and re-marshal losslessly on echo. Decode-direction methods return fixed, +// hand-built values in the library's native encode shape; encode-direction methods report +// back a canonical string of what they received, so the tests never depend on this +// transport's own codec to judge it. + +import * as dbusNativeModule from '@httptoolkit/dbus-native'; + +export const FAKE_BUS_NAME = 'tv.ceralive.ModemStackFake'; +export const FAKE_PATH = '/tv/ceralive/fake'; +export const FAKE_IFACE = 'tv.ceralive.ModemStackFake.Conformance'; +export const TICK_MEMBER = 'Tick'; + +export const INT64_MAX = 2n ** 63n - 1n; +export const UINT64_MAX = 2n ** 64n - 1n; + +type MethodImpl = (...args: unknown[]) => unknown; + +interface FakeBus { + connection: { + on(event: string, handler: (...args: unknown[]) => void): void; + once(event: string, handler: (...args: unknown[]) => void): void; + removeListener(event: string, handler: (...args: unknown[]) => void): void; + }; + requestName(name: string, flags: number): Promise; + setMethodCallHandler( + path: string, + iface: string, + member: string, + handler: [MethodImpl, string], + ): void; + sendSignal(path: string, iface: string, member: string, signature: string, args: unknown[]): void; + disconnect(): Promise; +} + +interface FakeModule { + createClient(options: { busAddress?: string; socket?: string; ReturnLongjs?: boolean }): FakeBus; +} + +const fakeModule = ((dbusNativeModule as { default?: unknown }).default ?? + dbusNativeModule) as unknown as FakeModule; + +// Fixed GetManagedObjects reply in library-native encode shape: object path → interface +// name → property name → variant [signature, value]. Exercises the real MM +// `a{oa{sa{sv}}}` nesting plus a 64-bit variant (t = 2^64-1) and a string variant. +function managedObjectsValue(): unknown { + return [ + [ + '/org/freedesktop/ModemManager1/Modem/0', + [ + [ + 'org.freedesktop.ModemManager1.Modem', + [ + ['SupportedCapabilities', ['t', UINT64_MAX.toString()]], + ['SignalQuality', ['u', 87]], + ['DeviceIdentifier', ['s', 'fake-device-0']], + ], + ], + ], + ], + ]; +} + +export interface FakeService { + readonly busName: string; + emitTick(seq: bigint): void; + stop(): Promise; +} + +export interface FakeServiceOptions { + readonly busAddress?: string; + readonly socket?: string; +} + +export async function startFakeService(options: FakeServiceOptions): Promise { + const clientOptions: { busAddress?: string; socket?: string; ReturnLongjs?: boolean } = { + ReturnLongjs: true, + }; + if (options.socket !== undefined) { + clientOptions.socket = options.socket; + } else if (options.busAddress !== undefined) { + clientOptions.busAddress = options.busAddress; + } + + const bus = fakeModule.createClient(clientOptions); + + await new Promise((resolve, reject) => { + const onConnect = (): void => { + bus.connection.removeListener('error', onError); + resolve(); + }; + const onError = (error: unknown): void => { + bus.connection.removeListener('connect', onConnect); + reject(error instanceof Error ? error : new Error(String(error))); + }; + bus.connection.once('connect', onConnect); + bus.connection.once('error', onError); + }); + + // After connect, swallow late socket errors so a killed bus (reconnect test) does not + // surface an unhandled EventEmitter 'error' from this helper's dead connection. + bus.connection.on('error', () => undefined); + + const define = (member: string, impl: MethodImpl, resultSignature: string): void => { + bus.setMethodCallHandler(FAKE_PATH, FAKE_IFACE, member, [impl, resultSignature]); + }; + + define('Ping', () => 'pong', 's'); + // The library awaits a Promise returned by a handler, so this replies after a delay — + // used to prove a late reply still resolves the caller's method call. + define( + 'SlowPing', + (delayMs) => new Promise((resolve) => setTimeout(() => resolve('pong'), Number(delayMs))), + 's', + ); + define('GetManagedObjects', () => managedObjectsValue(), 'a{oa{sa{sv}}}'); + define('GetInt64', () => INT64_MAX.toString(), 'x'); + define('GetUint64', () => UINT64_MAX.toString(), 't'); + // Echo re-marshals the received Long — an exact 64-bit round-trip through the daemon. + define('EchoInt64', (value) => value, 'x'); + define('EchoUint64', (value) => value, 't'); + // Describe reports the received value's exact decimal string, judging the transport's + // encode without re-encoding anything. + define('DescribeInt64', (value) => String(value), 's'); + define('DescribeUint64', (value) => String(value), 's'); + + await bus.requestName(FAKE_BUS_NAME, 0); + + return { + busName: FAKE_BUS_NAME, + emitTick(seq: bigint): void { + bus.sendSignal(FAKE_PATH, FAKE_IFACE, TICK_MEMBER, 't', [seq.toString()]); + }, + async stop(): Promise { + await bus.disconnect().catch(() => undefined); + }, + }; +} diff --git a/control/src/transport/test-support/independent-producer.py b/control/src/transport/test-support/independent-producer.py new file mode 100644 index 0000000..a0116ba --- /dev/null +++ b/control/src/transport/test-support/independent-producer.py @@ -0,0 +1,110 @@ +#!/usr/bin/env python3 +"""Independent D-Bus producer for transport conformance half (b). + +A different implementation on the wire than the JavaScript library under test: this uses +the `dbus-python` bindings (`python3-dbus`) plus a GLib main loop. If our TypeScript codec +agrees with THIS producer as well as with the same-library fake, the codec is correct, not +merely self-consistent. + +It exercises the shapes the plan calls out: the real ModemManager `GetManagedObjects` +shape `a{oa{sa{sv}}}`, 64-bit `x`/`t` values above 2**53, variants, and a +`PropertiesChanged` signal that carries an invalidated-properties array (not just changed +values). + +Connects to the bus named by `DBUS_SESSION_BUS_ADDRESS` (set by `dbus-run-session`), +claims a well-known name, prints `READY` on stdout once serving, then runs the main loop. +""" + +import sys + +import dbus +import dbus.mainloop.glib +import dbus.service +from gi.repository import GLib + +BUS_NAME = "tv.ceralive.ModemStackPy" +OBJECT_PATH = "/tv/ceralive/pyfake" +IFACE = "tv.ceralive.ModemStackPy.Conformance" +PROPERTIES_IFACE = "org.freedesktop.DBus.Properties" + +INT64_MAX = 2**63 - 1 +UINT64_MAX = 2**64 - 1 + + +class ConformanceProducer(dbus.service.Object): + @dbus.service.method(IFACE, in_signature="", out_signature="a{oa{sa{sv}}}") + def GetManagedObjects(self): + return dbus.Dictionary( + { + dbus.ObjectPath("/org/freedesktop/ModemManager1/Modem/0"): dbus.Dictionary( + { + "org.freedesktop.ModemManager1.Modem": dbus.Dictionary( + { + "SupportedCapabilities": dbus.UInt64(UINT64_MAX, variant_level=1), + "MaxBearers": dbus.Int64(-INT64_MAX, variant_level=1), + "SignalQuality": dbus.UInt32(87, variant_level=1), + "DeviceIdentifier": dbus.String("py-device-0", variant_level=1), + }, + signature="sv", + ), + }, + signature="sa{sv}", + ), + }, + signature="oa{sa{sv}}", + ) + + @dbus.service.method(IFACE, in_signature="", out_signature="x") + def GetInt64(self): + return dbus.Int64(INT64_MAX) + + @dbus.service.method(IFACE, in_signature="", out_signature="t") + def GetUint64(self): + return dbus.UInt64(UINT64_MAX) + + @dbus.service.method(IFACE, in_signature="", out_signature="v") + def GetVariant(self): + return dbus.UInt64(UINT64_MAX, variant_level=1) + + @dbus.service.method(IFACE, in_signature="x", out_signature="x") + def EchoInt64(self, value): + return dbus.Int64(value) + + @dbus.service.method(IFACE, in_signature="t", out_signature="t") + def EchoUint64(self, value): + return dbus.UInt64(value) + + @dbus.service.method(IFACE, in_signature="t", out_signature="s") + def DescribeUint64(self, value): + return dbus.String(str(int(value))) + + @dbus.service.method(IFACE, in_signature="", out_signature="") + def TriggerPropertiesChanged(self): + changed = dbus.Dictionary( + { + "AccessTechnologies": dbus.UInt64(UINT64_MAX, variant_level=1), + "SignalQuality": dbus.UInt32(42, variant_level=1), + }, + signature="sv", + ) + invalidated = dbus.Array(["OperatorName", "OperatorCode"], signature="s") + self.PropertiesChanged(IFACE, changed, invalidated) + + @dbus.service.signal(PROPERTIES_IFACE, signature="sa{sv}as") + def PropertiesChanged(self, interface_name, changed_properties, invalidated_properties): + pass + + +def main(): + dbus.mainloop.glib.DBusGMainLoop(set_as_default=True) + bus = dbus.SessionBus() + name = dbus.service.BusName(BUS_NAME, bus) + ConformanceProducer(bus, OBJECT_PATH) + # Signal readiness only after the name is owned and the object is exported. + sys.stdout.write("READY\n") + sys.stdout.flush() + GLib.MainLoop().run() + + +if __name__ == "__main__": + main() diff --git a/control/src/transport/test-support/private-bus.ts b/control/src/transport/test-support/private-bus.ts new file mode 100644 index 0000000..b717b8f --- /dev/null +++ b/control/src/transport/test-support/private-bus.ts @@ -0,0 +1,66 @@ +// A dedicated private `dbus-daemon` at a FIXED socket path, for the reconnect test. +// +// The outer `dbus-run-session` bus is shared by the whole `bun test` run, so the +// destructive kill/restart cannot happen there. This helper owns a throwaway daemon we +// can kill and respawn at the same socket path, so the transport reconnects to "the same +// bus" exactly as it would after a real bus restart on a device. + +import { existsSync, mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +const sleep = (ms: number): Promise => new Promise((resolve) => setTimeout(resolve, ms)); + +export class PrivateBus { + readonly socket: string; + readonly address: string; + readonly #dir: string; + #proc: ReturnType | null = null; + + constructor() { + this.#dir = mkdtempSync(join(tmpdir(), 'ceralive-dbus-')); + this.socket = join(this.#dir, 'bus'); + this.address = `unix:path=${this.socket}`; + } + + async start(): Promise { + // A SIGKILL leaves the socket file behind; dbus-daemon refuses to bind over it. + if (existsSync(this.socket)) { + rmSync(this.socket, { force: true }); + } + this.#proc = Bun.spawn( + ['dbus-daemon', '--session', `--address=${this.address}`, '--nofork', '--nopidfile'], + { stdout: 'ignore', stderr: 'ignore' }, + ); + await this.#waitForSocket(); + } + + kill(): void { + this.#proc?.kill('SIGKILL'); + this.#proc = null; + } + + async restart(): Promise { + this.kill(); + await sleep(50); + await this.start(); + } + + async stop(): Promise { + this.kill(); + await sleep(20); + rmSync(this.#dir, { recursive: true, force: true }); + } + + async #waitForSocket(): Promise { + for (let attempt = 0; attempt < 300; attempt += 1) { + if (existsSync(this.socket)) { + // Socket exists → daemon is listening; a short grace covers the accept race. + await sleep(30); + return; + } + await sleep(10); + } + throw new Error(`private dbus-daemon socket ${this.socket} never appeared`); + } +} diff --git a/control/src/transport/transport.ts b/control/src/transport/transport.ts new file mode 100644 index 0000000..d0151ce --- /dev/null +++ b/control/src/transport/transport.ts @@ -0,0 +1,439 @@ +// The D-Bus transport seam implementation. +// +// Wraps `@httptoolkit/dbus-native` behind the `DbusTransport` interface: method calls, +// signal subscriptions, and an automatic reconnect loop that re-issues every match rule +// after a bus restart. A single persistent `message` listener fans out to the live +// subscription registry, so subscribing/unsubscribing never grows the connection's +// listener count — the 100-cycle leak check depends on this. + +import { EventEmitter } from 'node:events'; +import { decodeBody, encodeBody } from './codec'; +import { + type CreateClientOptions, + createClient, + messageType, + type RawBus, + type RawMessage, + type ReplyContext, +} from './dbus-native'; +import { DisconnectedError, TransportError } from './errors'; +import type { + DbusTransport, + DbusTransportOptions, + DbusValue, + MethodCall, + MethodReply, + SignalEvent, + SignalListener, + SignalSpec, + Subscription, + TransportEvent, +} from './types'; + +type State = 'idle' | 'connecting' | 'connected' | 'disconnected' | 'reconnecting' | 'closed'; + +interface ResolvedReconnect { + readonly enabled: boolean; + readonly initialDelayMs: number; + readonly maxDelayMs: number; + readonly maxAttempts: number; +} + +interface SubscriptionRecord { + readonly id: number; + readonly spec: SignalSpec; + readonly listener: SignalListener; + readonly rule: string; +} + +interface PendingCall { + settle(): void; + reject(error: unknown): void; +} + +const DEFAULT_CALL_TIMEOUT_MS = 30_000; +// Bound a single connect/auth attempt so a stalled handshake cannot freeze the reconnect +// loop. A local unix-socket D-Bus connect completes in milliseconds; 2s is ample headroom +// while keeping reconnect responsive after a bus restart. +const CONNECT_TIMEOUT_MS = 2_000; +const DEFAULT_RECONNECT: ResolvedReconnect = { + enabled: true, + initialDelayMs: 50, + maxDelayMs: 2_000, + maxAttempts: 0, +}; + +const sleep = (ms: number): Promise => new Promise((resolve) => setTimeout(resolve, ms)); + +function buildMatchRule(spec: SignalSpec): string { + const parts = [`type='signal'`, `interface='${spec.interface}'`, `member='${spec.member}'`]; + if (spec.path !== undefined) { + parts.push(`path='${spec.path}'`); + } + if (spec.sender !== undefined) { + parts.push(`sender='${spec.sender}'`); + } + return parts.join(','); +} + +function signalMatches(spec: SignalSpec, message: RawMessage): boolean { + if (message.interface !== spec.interface || message.member !== spec.member) { + return false; + } + if (spec.path !== undefined && message.path !== spec.path) { + return false; + } + if (spec.sender !== undefined && message.sender !== spec.sender) { + return false; + } + return true; +} + +class DbusTransportImpl implements DbusTransport { + readonly #options: DbusTransportOptions; + readonly #reconnect: ResolvedReconnect; + readonly #callTimeoutMs: number; + readonly #emitter = new EventEmitter(); + readonly #subscriptions = new Map(); + readonly #matchRuleRefcount = new Map(); + readonly #pending = new Set(); + + #bus: RawBus | null = null; + #state: State = 'idle'; + #closing = false; + #nextSubId = 1; + + // Bound once so the same references can be detached from a dead connection. + readonly #onMessage = (message: RawMessage): void => this.#dispatchSignal(message); + readonly #onConnectionError = (cause: unknown): void => + this.#handleDrop(cause instanceof Error ? cause : new DisconnectedError(String(cause))); + readonly #onConnectionEnd = (): void => + this.#handleDrop(new DisconnectedError('bus connection ended')); + + constructor(options: DbusTransportOptions) { + this.#options = options; + this.#callTimeoutMs = options.callTimeoutMs ?? DEFAULT_CALL_TIMEOUT_MS; + this.#reconnect = { + enabled: options.reconnect?.enabled ?? DEFAULT_RECONNECT.enabled, + initialDelayMs: options.reconnect?.initialDelayMs ?? DEFAULT_RECONNECT.initialDelayMs, + maxDelayMs: options.reconnect?.maxDelayMs ?? DEFAULT_RECONNECT.maxDelayMs, + maxAttempts: options.reconnect?.maxAttempts ?? DEFAULT_RECONNECT.maxAttempts, + }; + // Avoid MaxListeners warnings when many consumers observe transport events. + this.#emitter.setMaxListeners(0); + } + + async connect(): Promise { + if (this.#state === 'connected') { + return; + } + if (this.#closing) { + throw new TransportError('Transport is closed'); + } + this.#state = 'connecting'; + await this.#establish(); + this.#emitter.emit('connected'); + } + + isConnected(): boolean { + return this.#state === 'connected'; + } + + async disconnect(): Promise { + this.#closing = true; + this.#state = 'closed'; + const bus = this.#bus; + this.#bus = null; + this.#rejectPending(new DisconnectedError('transport closed')); + if (bus) { + this.#quiesce(bus); + await bus.disconnect().catch(() => undefined); + } + } + + async callMethod(call: MethodCall): Promise { + const bus = this.#bus; + if (this.#state !== 'connected' || bus === null) { + throw new DisconnectedError('cannot call method: transport not connected'); + } + + const signature = call.signature ?? ''; + const args = call.args ?? []; + const message: RawMessage = { + type: messageType.methodCall, + destination: call.destination, + path: call.path, + interface: call.interface, + member: call.member, + }; + if (signature.length > 0) { + // Throws UnsupportedSignatureError / BigIntRequiredError before anything hits + // the wire. + message.signature = signature; + message.body = encodeBody(signature, args); + } + + const timeoutMs = call.timeoutMs ?? this.#callTimeoutMs; + const pendingSet = this.#pending; + return new Promise((resolve, reject) => { + let done = false; + const pending: PendingCall = { + settle: finish, + reject: (error) => { + finish(); + reject(error); + }, + }; + + function finish(): void { + if (done) { + return; + } + done = true; + clearTimeout(timer); + pendingSet.delete(pending); + } + + const timer = setTimeout(() => { + finish(); + reject( + new TransportError( + `Method call ${call.interface}.${call.member} timed out after ${timeoutMs}ms`, + ), + ); + }, timeoutMs); + + pendingSet.add(pending); + + bus.invoke( + message, + function reply(this: ReplyContext, error: unknown, ...body: unknown[]): void { + if (done) { + // Reply arrived after timeout/disconnect already settled the promise — ignore. + return; + } + finish(); + if (error) { + reject(error instanceof Error ? error : new TransportError(String(error))); + return; + } + try { + const replySignature = this.signature ?? ''; + const decoded: DbusValue[] = + replySignature.length > 0 ? decodeBody(replySignature, body) : []; + resolve({ signature: replySignature, body: decoded }); + } catch (decodeError) { + reject(decodeError); + } + }, + ); + }); + } + + async subscribeSignal(spec: SignalSpec, listener: SignalListener): Promise { + const rule = buildMatchRule(spec); + const id = this.#nextSubId++; + this.#subscriptions.set(id, { id, spec, listener, rule }); + await this.#addMatchRule(rule); + + let removed = false; + return { + unsubscribe: async (): Promise => { + if (removed) { + return; + } + removed = true; + this.#subscriptions.delete(id); + await this.#removeMatchRule(rule); + }, + }; + } + + subscriptionCount(): number { + return this.#subscriptions.size; + } + + on(event: TransportEvent, handler: (payload?: unknown) => void): void { + this.#emitter.on(event, handler); + } + + off(event: TransportEvent, handler: (payload?: unknown) => void): void { + this.#emitter.off(event, handler); + } + + // ── internals ────────────────────────────────────────────────────────────────── + + async #establish(): Promise { + const options: CreateClientOptions = { ReturnLongjs: true }; + if (this.#options.socket !== undefined) { + options.socket = this.#options.socket; + } else if (this.#options.busAddress !== undefined) { + options.busAddress = this.#options.busAddress; + } + + const bus = createClient(options); + try { + await new Promise((resolve, reject) => { + const onConnect = (): void => { + cleanup(); + resolve(); + }; + const onError = (error: unknown): void => { + cleanup(); + reject(error instanceof Error ? error : new TransportError(String(error))); + }; + const timer = setTimeout(() => { + cleanup(); + reject(new TransportError(`bus connect timed out after ${CONNECT_TIMEOUT_MS}ms`)); + }, CONNECT_TIMEOUT_MS); + const cleanup = (): void => { + clearTimeout(timer); + bus.connection.removeListener('connect', onConnect); + bus.connection.removeListener('error', onError); + }; + bus.connection.once('connect', onConnect); + bus.connection.once('error', onError); + }); + + bus.connection.on('message', this.#onMessage as (...args: unknown[]) => void); + bus.connection.on('error', this.#onConnectionError); + bus.connection.on('end', this.#onConnectionEnd); + + // Re-issue every live match rule so a reconnect resubscribes transparently. + for (const rule of this.#matchRuleRefcount.keys()) { + await bus.addMatch(rule); + } + + this.#bus = bus; + this.#state = 'connected'; + } catch (error) { + this.#detachHandlers(bus); + bus.connection.removeAllListeners(); + bus.connection.on('error', () => undefined); + try { + bus.connection.end(); + } catch { + // The half-open connection is already dead; nothing to close. + } + throw error; + } + } + + #dispatchSignal(message: RawMessage): void { + if (message.type !== messageType.signal) { + return; + } + for (const record of this.#subscriptions.values()) { + if (!signalMatches(record.spec, message)) { + continue; + } + let body: DbusValue[]; + try { + const signature = message.signature ?? ''; + body = signature.length > 0 ? decodeBody(signature, message.body ?? []) : []; + } catch (error) { + this.#emitter.emit('error', error); + continue; + } + const event: SignalEvent = { + path: message.path ?? '', + interface: message.interface ?? '', + member: message.member ?? '', + sender: message.sender, + signature: message.signature ?? '', + body, + }; + try { + record.listener(event); + } catch (error) { + this.#emitter.emit('error', error); + } + } + } + + async #addMatchRule(rule: string): Promise { + const current = this.#matchRuleRefcount.get(rule) ?? 0; + this.#matchRuleRefcount.set(rule, current + 1); + if (current === 0 && this.#state === 'connected' && this.#bus) { + await this.#bus.addMatch(rule); + } + } + + async #removeMatchRule(rule: string): Promise { + const current = this.#matchRuleRefcount.get(rule) ?? 0; + if (current <= 1) { + this.#matchRuleRefcount.delete(rule); + if (current === 1 && this.#state === 'connected' && this.#bus) { + await this.#bus.removeMatch(rule).catch(() => undefined); + } + } else { + this.#matchRuleRefcount.set(rule, current - 1); + } + } + + #detachHandlers(bus: RawBus): void { + bus.connection.removeListener('message', this.#onMessage as (...args: unknown[]) => void); + bus.connection.removeListener('error', this.#onConnectionError); + bus.connection.removeListener('end', this.#onConnectionEnd); + } + + // Detach our handlers from a dead connection, then swallow any late socket error it + // still emits — without a listener, Node re-throws an EventEmitter 'error' and crashes + // the process mid-reconnect. + #quiesce(bus: RawBus): void { + this.#detachHandlers(bus); + bus.connection.on('error', () => undefined); + } + + #rejectPending(cause: unknown): void { + for (const pending of this.#pending) { + pending.reject(cause); + } + this.#pending.clear(); + } + + #handleDrop(cause: unknown): void { + if (this.#closing) { + return; + } + if (this.#state === 'disconnected' || this.#state === 'reconnecting') { + return; + } + this.#state = 'disconnected'; + if (this.#bus) { + this.#quiesce(this.#bus); + } + this.#bus = null; + this.#rejectPending(cause); + this.#emitter.emit('disconnected', cause); + if (this.#reconnect.enabled) { + void this.#reconnectLoop(); + } + } + + async #reconnectLoop(): Promise { + this.#state = 'reconnecting'; + let delay = this.#reconnect.initialDelayMs; + let attempt = 0; + while (!this.#closing) { + attempt += 1; + try { + await this.#establish(); + this.#emitter.emit('reconnected'); + return; + } catch (error) { + if (this.#reconnect.maxAttempts > 0 && attempt >= this.#reconnect.maxAttempts) { + this.#state = 'disconnected'; + this.#emitter.emit('error', error); + return; + } + await sleep(delay); + delay = Math.min(delay * 2, this.#reconnect.maxDelayMs); + } + } + } +} + +export function createDbusTransport(options: DbusTransportOptions = {}): DbusTransport { + return new DbusTransportImpl(options); +} diff --git a/control/src/transport/types.ts b/control/src/transport/types.ts new file mode 100644 index 0000000..dd8df0d --- /dev/null +++ b/control/src/transport/types.ts @@ -0,0 +1,118 @@ +// Public value and message types for the D-Bus transport seam. +// +// These types are the transport's own vocabulary. None of the underlying +// `@httptoolkit/dbus-native` types appear here or in `./index.ts`, so the package's +// public surface never leaks the library — swapping the implementation (see README: +// documented fallback `@particle/dbus-next`) would not change a single caller type. + +// A decoded / encodable D-Bus value. +// +// 64-bit integers (`x`, `t`) are ALWAYS `bigint`, never `number`. Byte arrays (`ay`) +// are `Uint8Array`. Arrays, structs, and dict entries are all plain arrays; a dict +// `a{KV}` decodes to an array of `[key, value]` entry pairs, preserving order and +// tolerating duplicate/non-string keys losslessly. Variants are wrapped in +// `DbusVariant` so their inner signature survives a round-trip. +export type DbusValue = string | number | boolean | bigint | Uint8Array | DbusVariant | DbusValue[]; + +// A D-Bus variant (`v`): a value tagged with the signature of its contained type. +export interface DbusVariant { + readonly signature: string; + readonly value: DbusValue; +} + +// Construct a variant for encoding, e.g. `variant('u', 42)` or `variant('t', 5n)`. +export function variant(signature: string, value: DbusValue): DbusVariant { + return { signature, value }; +} + +// Narrowing guard for a decoded variant. +export function isVariant(value: DbusValue): value is DbusVariant { + return ( + typeof value === 'object' && + value !== null && + !Array.isArray(value) && + !(value instanceof Uint8Array) && + typeof (value as DbusVariant).signature === 'string' && + 'value' in value + ); +} + +// An outgoing method call. +export interface MethodCall { + readonly destination: string; + readonly path: string; + readonly interface: string; + readonly member: string; + // D-Bus signature of `args`. Omit (or empty) for a no-argument call. + readonly signature?: string; + readonly args?: readonly DbusValue[]; + // Per-call reply timeout in ms. Defaults to the transport's configured timeout. + readonly timeoutMs?: number; +} + +// A decoded method reply. +export interface MethodReply { + readonly signature: string; + readonly body: DbusValue[]; +} + +// A signal subscription filter. `interface` and `member` are required; `path` and +// `sender` narrow further when supplied (MM emits the same signal from many paths). +export interface SignalSpec { + readonly interface: string; + readonly member: string; + readonly path?: string; + readonly sender?: string; +} + +// A decoded signal delivered to a subscriber. +export interface SignalEvent { + readonly path: string; + readonly interface: string; + readonly member: string; + readonly sender: string | undefined; + readonly signature: string; + readonly body: DbusValue[]; +} + +export type SignalListener = (event: SignalEvent) => void; + +// Handle returned by `subscribeSignal`; call `unsubscribe` to detach the listener and +// (when it was the last subscriber for its match rule) drop the bus-side match. +export interface Subscription { + unsubscribe(): Promise; +} + +export type TransportEvent = 'connected' | 'reconnected' | 'disconnected' | 'error'; + +export interface DbusTransportOptions { + // Encoded bus address. Defaults to `DBUS_SESSION_BUS_ADDRESS`. + readonly busAddress?: string; + // Unix socket path (an alternative to `busAddress` for a private test bus). + readonly socket?: string; + // Default per-call reply timeout in ms (default 30000). + readonly callTimeoutMs?: number; + // Automatic reconnect after an unexpected bus drop (default enabled). + readonly reconnect?: ReconnectOptions; +} + +export interface ReconnectOptions { + readonly enabled?: boolean; + readonly initialDelayMs?: number; + readonly maxDelayMs?: number; + // 0 = retry forever (default). + readonly maxAttempts?: number; +} + +// The transport seam. This is the entire surface the A3.x D-Bus backend builds on. +export interface DbusTransport { + connect(): Promise; + disconnect(): Promise; + isConnected(): boolean; + callMethod(call: MethodCall): Promise; + subscribeSignal(spec: SignalSpec, listener: SignalListener): Promise; + on(event: TransportEvent, handler: (payload?: unknown) => void): void; + off(event: TransportEvent, handler: (payload?: unknown) => void): void; + // Number of live signal subscriptions — exposed for leak assertions in tests. + subscriptionCount(): number; +} diff --git a/control/src/usb-mode/catalog-schema.test.ts b/control/src/usb-mode/catalog-schema.test.ts new file mode 100644 index 0000000..d1e7471 --- /dev/null +++ b/control/src/usb-mode/catalog-schema.test.ts @@ -0,0 +1,181 @@ +// The catalog schema is a SAFETY BOUNDARY: the two invariants it enforces (only +// within-MM transitions are expressible; no unknown field slips through) are what +// keep an uncertified or MM↔router switch from ever becoming valid catalog data. + +import { describe, expect, test } from 'bun:test'; +import { + CERTIFIED_CATALOG, + findCatalogEntry, + findPermittedTransition, + loadCertifiedCatalog, +} from './catalog'; +import { catalogEntrySchema, certifiedCatalogSchema } from './catalog-schema'; + +function validCatalog(): unknown { + return { + schemaVersion: 1, + entries: [ + { + vidPid: '2c7c:0125', + model: 'TEST-SKU', + firmwarePrefix: 'FW01', + canonicalMode: 'qmi', + permittedTransitions: [ + { + from: 'qmi', + to: 'mbim', + atCommand: 'AT+QCFG="usbnet",2', + expectedResponse: 'OK', + expectsPortDrop: true, + expectedDescriptors: { + deviceClass: 0, + interfaces: [{ interfaceClass: 2, interfaceSubClass: 14, interfaceProtocol: 0 }], + }, + }, + ], + }, + ], + }; +} + +function firstTransition(catalog: ReturnType): Record { + // biome-ignore lint/suspicious/noExplicitAny: test helper reaching into a plain fixture + return (catalog as any).entries[0].permittedTransitions[0]; +} + +function firstEntry(catalog: ReturnType): Record { + // biome-ignore lint/suspicious/noExplicitAny: test helper reaching into a plain fixture + return (catalog as any).entries[0]; +} + +describe('certified catalog schema — accepts valid data', () => { + test('a well-formed catalog parses', () => { + expect(certifiedCatalogSchema.safeParse(validCatalog()).success).toBe(true); + }); + + test('a router-ethernet SKU with NO transitions is valid', () => { + const catalog = validCatalog() as { entries: Array> }; + catalog.entries[0] = { + vidPid: '12d1:14db', + model: 'HILINK-STICK', + firmwarePrefix: 'HILINK', + canonicalMode: 'router-ethernet', + permittedTransitions: [], + }; + expect(certifiedCatalogSchema.safeParse(catalog).success).toBe(true); + }); +}); + +describe('certified catalog schema — rejects MM↔router transitions (schema-invalid)', () => { + test('a transition TO router-ethernet fails to parse', () => { + const catalog = validCatalog(); + firstTransition(catalog).to = 'router-ethernet'; + expect(certifiedCatalogSchema.safeParse(catalog).success).toBe(false); + }); + + test('a transition FROM router-ethernet fails to parse', () => { + const catalog = validCatalog(); + firstTransition(catalog).from = 'router-ethernet'; + expect(certifiedCatalogSchema.safeParse(catalog).success).toBe(false); + }); + + test('a transition touching rndis fails to parse', () => { + const catalog = validCatalog(); + firstTransition(catalog).to = 'rndis'; + expect(certifiedCatalogSchema.safeParse(catalog).success).toBe(false); + }); + + test('a router-ethernet SKU may NOT declare any transition', () => { + const catalog = validCatalog(); + firstEntry(catalog).canonicalMode = 'router-ethernet'; + expect(certifiedCatalogSchema.safeParse(catalog).success).toBe(false); + }); +}); + +describe('certified catalog schema — strict mode rejects unknown fields', () => { + test('an unknown field on the catalog root is rejected', () => { + const catalog = validCatalog() as Record; + catalog.smuggled = true; + expect(certifiedCatalogSchema.safeParse(catalog).success).toBe(false); + }); + + test('an unknown field on an entry is rejected', () => { + const catalog = validCatalog(); + firstEntry(catalog).hidden = 'x'; + expect(certifiedCatalogSchema.safeParse(catalog).success).toBe(false); + }); + + test('an unknown field on a transition is rejected', () => { + const catalog = validCatalog(); + firstTransition(catalog).sneaky = 1; + expect(certifiedCatalogSchema.safeParse(catalog).success).toBe(false); + }); +}); + +describe('certified catalog schema — field validation', () => { + test('a self-transition (from === to) is rejected', () => { + const catalog = validCatalog(); + firstTransition(catalog).to = 'qmi'; + expect(certifiedCatalogSchema.safeParse(catalog).success).toBe(false); + }); + + test('a malformed VID:PID is rejected', () => { + const catalog = validCatalog(); + firstEntry(catalog).vidPid = '2c7c-0125'; + expect(certifiedCatalogSchema.safeParse(catalog).success).toBe(false); + }); + + test('a schemaVersion other than 1 is rejected', () => { + const catalog = validCatalog() as Record; + catalog.schemaVersion = 2; + expect(certifiedCatalogSchema.safeParse(catalog).success).toBe(false); + }); + + test('a valid evidence-bundle sha256 is accepted; a malformed one is rejected', () => { + const good = validCatalog(); + firstTransition(good).evidenceBundleSha256 = 'a'.repeat(64); + expect(certifiedCatalogSchema.safeParse(good).success).toBe(true); + + const bad = validCatalog(); + firstTransition(bad).evidenceBundleSha256 = 'nothex'; + expect(certifiedCatalogSchema.safeParse(bad).success).toBe(false); + }); + + test('loadCertifiedCatalog throws on invalid input', () => { + expect(() => loadCertifiedCatalog({ schemaVersion: 1 })).toThrow(); + }); + + test('a lone entry also validates against catalogEntrySchema', () => { + const entry = (validCatalog() as { entries: unknown[] }).entries[0]; + expect(catalogEntrySchema.safeParse(entry).success).toBe(true); + }); +}); + +describe('the shipped certified catalog', () => { + test('validates and exposes the synthetic test SKU with within-MM transitions', () => { + const sku = { + vidPid: '2c7c:0125', + model: 'CERALIVE-SYNTHETIC-TEST-SKU', + firmwarePrefix: 'SYNTHETICFW01', + }; + const entry = findCatalogEntry(CERTIFIED_CATALOG, sku); + expect(entry).toBeDefined(); + expect(entry?.canonicalMode).toBe('qmi'); + const transition = + entry !== undefined ? findPermittedTransition(entry, 'qmi', 'mbim') : undefined; + expect(transition?.atCommand).toBe('AT+QCFG="usbnet",2'); + // A transition the catalog does NOT declare is absent (never a guess). + expect( + entry !== undefined ? findPermittedTransition(entry, 'mbim', 'ecm-ncm') : undefined, + ).toBeUndefined(); + }); + + test('an unmatched SKU discriminator returns undefined (uncertified)', () => { + const entry = findCatalogEntry(CERTIFIED_CATALOG, { + vidPid: '2c7c:0125', + model: 'CERALIVE-SYNTHETIC-TEST-SKU', + firmwarePrefix: 'WRONGFW', + }); + expect(entry).toBeUndefined(); + }); +}); diff --git a/control/src/usb-mode/catalog-schema.ts b/control/src/usb-mode/catalog-schema.ts new file mode 100644 index 0000000..e5b1cee --- /dev/null +++ b/control/src/usb-mode/catalog-schema.ts @@ -0,0 +1,113 @@ +// The certified USB-mode catalog schema — the contract that gates a mode switch. +// +// A catalog ENTRY is one server-derived SKU discriminator (VID:PID + model + +// firmware prefix) plus everything a certified transition needs: the canonical mode +// the SKU speaks, the AT commands that switch it, the expected AT response and +// port-drop behaviour, the USB descriptors it should present AFTER the switch, and a +// forward-reference to the evidence bundle that certified it. Certification is a +// human-reviewed commit that adds an entry — evidence bundles are inputs to that +// review, never read at runtime. +// +// TWO safety invariants are enforced by the SCHEMA ITSELF, not by convention: +// 1. Permitted transitions are WITHIN-ModemManager only — `qmi ↔ mbim ↔ ecm-ncm`. +// A transition's `from`/`to` are typed to the MM-mode enum, so any attempt to +// declare an `MM ↔ router` (or `rndis`) transition FAILS to parse. +// 2. `.strict()` everywhere — an entry carrying an unknown field is rejected, so a +// typo or a smuggled extra field can never slip past review as valid data. + +import { z } from 'zod'; + +/** + * The full canonical USB composition-mode vocabulary. A SKU's `canonicalMode` may be + * any of these; only the first three are ModemManager-manageable. (Structurally + * identical to `PreferredUsbMode` in the power contract — A4.2 owns this vocabulary.) + */ +export const CANONICAL_USB_MODES = ['qmi', 'mbim', 'ecm-ncm', 'rndis', 'router-ethernet'] as const; +export type CanonicalUsbMode = (typeof CANONICAL_USB_MODES)[number]; + +/** + * The ModemManager-manageable modes — the ONLY modes a transition may move between. + * `rndis` and `router-ethernet` are deliberately absent: a device in either is not + * MM-managed, so switching to/from them crosses the MM↔router line the schema forbids. + */ +export const MM_USB_MODES = ['qmi', 'mbim', 'ecm-ncm'] as const; +export type MmUsbMode = (typeof MM_USB_MODES)[number]; + +const canonicalMode = z.enum(CANONICAL_USB_MODES); +const mmMode = z.enum(MM_USB_MODES); + +/** A `bInterfaceClass`/`bInterfaceSubClass`/`bInterfaceProtocol` byte triple. */ +const usbByte = z.number().int().min(0).max(255); + +/** The USB descriptors a device must present AFTER a transition — the postcondition. */ +export const expectedDescriptorsSchema = z.strictObject({ + deviceClass: usbByte, + interfaces: z + .array( + z.strictObject({ + interfaceClass: usbByte, + interfaceSubClass: usbByte, + interfaceProtocol: usbByte, + }), + ) + .min(1), +}); +export type ExpectedDescriptors = z.infer; + +/** One permitted, certified transition between two MM modes. */ +export const permittedTransitionSchema = z + .strictObject({ + from: mmMode, + to: mmMode, + /** The EXACT AT command that performs the switch (allowlisted at send time). */ + atCommand: z.string().min(1), + /** 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. */ + expectsPortDrop: z.boolean(), + /** The descriptors the device must present after re-enumerating — the postcondition. */ + expectedDescriptors: expectedDescriptorsSchema, + /** sha256 of the evidence bundle that certified this transition (A6.2 fills it). */ + evidenceBundleSha256: z + .string() + .regex(/^[0-9a-f]{64}$/) + .optional(), + }) + .refine((t) => t.from !== t.to, { message: 'a transition must change mode (from !== to)' }); +export type PermittedTransition = z.infer; + +/** One certified SKU: its discriminator, canonical mode, and permitted transitions. */ +export const catalogEntrySchema = z + .strictObject({ + /** VID:PID, lowercase hex, `xxxx:xxxx`. */ + vidPid: z.string().regex(/^[0-9a-f]{4}:[0-9a-f]{4}$/), + /** The server-derived model string (a discriminator, not free text). */ + model: z.string().min(1), + /** The firmware-revision prefix that discriminates this personality. */ + firmwarePrefix: z.string().min(1), + /** The mode this SKU speaks as certified. */ + canonicalMode, + /** The certified transitions — empty for a non-MM (router/rndis) SKU. */ + permittedTransitions: z.array(permittedTransitionSchema), + }) + .refine( + (e) => + e.permittedTransitions.length === 0 || + (MM_USB_MODES as readonly string[]).includes(e.canonicalMode), + { message: 'only an MM-mode SKU (qmi/mbim/ecm-ncm) may declare permitted transitions' }, + ); +export type CatalogEntry = z.infer; + +/** The whole certified catalog file. */ +export const certifiedCatalogSchema = z.strictObject({ + schemaVersion: z.literal(1), + entries: z.array(catalogEntrySchema), +}); +export type CertifiedCatalog = z.infer; + +/** A live device's SKU discriminator — matched against the catalog. */ +export interface SkuDiscriminator { + readonly vidPid: string; + readonly model: string; + readonly firmwarePrefix: string; +} diff --git a/control/src/usb-mode/catalog.ts b/control/src/usb-mode/catalog.ts new file mode 100644 index 0000000..0cfc6f9 --- /dev/null +++ b/control/src/usb-mode/catalog.ts @@ -0,0 +1,60 @@ +// Loading and querying the certified USB-mode catalog. +// +// The catalog is validated on load — a malformed file fails LOUDLY, never silently +// half-parsed. The certified-catalog.json shipped in this package is validated once +// at module load and exposed as `CERTIFIED_CATALOG`; a caller that wants to validate +// an alternate file (e.g. A6.2's `certify` tool checking a candidate entry) uses +// `loadCertifiedCatalog`. The evidence bundles the entries reference are NOT read +// here — only the catalog metadata is. + +import { + type CatalogEntry, + type CertifiedCatalog, + certifiedCatalogSchema, + type MmUsbMode, + type PermittedTransition, + type SkuDiscriminator, +} from './catalog-schema'; +import rawCatalog from './certified-catalog.json' with { type: 'json' }; + +/** + * Parse and validate an untrusted value as a certified catalog. Throws a `ZodError` + * with a precise path if the value violates the schema (unknown field, an MM↔router + * transition, a bad VID:PID, etc.). + */ +export function loadCertifiedCatalog(value: unknown): CertifiedCatalog { + return certifiedCatalogSchema.parse(value); +} + +/** The catalog shipped in this package, validated at module load. */ +export const CERTIFIED_CATALOG: CertifiedCatalog = loadCertifiedCatalog(rawCatalog); + +/** + * Find the catalog entry matching a live device's SKU discriminator. All three + * discriminators (VID:PID, model, firmware prefix) must match — a partial match is + * NOT a certified device. Returns `undefined` for an uncertified SKU (never a guess). + */ +export function findCatalogEntry( + catalog: CertifiedCatalog, + sku: SkuDiscriminator, +): CatalogEntry | undefined { + return catalog.entries.find( + (entry) => + entry.vidPid === sku.vidPid && + entry.model === sku.model && + entry.firmwarePrefix === sku.firmwarePrefix, + ); +} + +/** + * Find the permitted transition `from → to` in a catalog entry. Returns `undefined` + * when the entry declares no such transition — the caller MUST treat that as "not + * permitted", never as "permitted with no command". + */ +export function findPermittedTransition( + entry: CatalogEntry, + from: MmUsbMode, + to: MmUsbMode, +): PermittedTransition | undefined { + return entry.permittedTransitions.find((t) => t.from === from && t.to === to); +} diff --git a/control/src/usb-mode/certified-catalog.json b/control/src/usb-mode/certified-catalog.json new file mode 100644 index 0000000..3ed5776 --- /dev/null +++ b/control/src/usb-mode/certified-catalog.json @@ -0,0 +1,67 @@ +{ + "schemaVersion": 1, + "entries": [ + { + "vidPid": "2c7c:0125", + "model": "CERALIVE-SYNTHETIC-TEST-SKU", + "firmwarePrefix": "SYNTHETICFW01", + "canonicalMode": "qmi", + "permittedTransitions": [ + { + "from": "qmi", + "to": "mbim", + "atCommand": "AT+QCFG=\"usbnet\",2", + "expectedResponse": "OK", + "expectsPortDrop": true, + "expectedDescriptors": { + "deviceClass": 0, + "interfaces": [ + { "interfaceClass": 2, "interfaceSubClass": 14, "interfaceProtocol": 0 }, + { "interfaceClass": 10, "interfaceSubClass": 0, "interfaceProtocol": 2 } + ] + } + }, + { + "from": "mbim", + "to": "qmi", + "atCommand": "AT+QCFG=\"usbnet\",0", + "expectedResponse": "OK", + "expectsPortDrop": true, + "expectedDescriptors": { + "deviceClass": 0, + "interfaces": [ + { "interfaceClass": 255, "interfaceSubClass": 255, "interfaceProtocol": 255 } + ] + } + }, + { + "from": "qmi", + "to": "ecm-ncm", + "atCommand": "AT+QCFG=\"usbnet\",1", + "expectedResponse": "OK", + "expectsPortDrop": true, + "expectedDescriptors": { + "deviceClass": 0, + "interfaces": [ + { "interfaceClass": 2, "interfaceSubClass": 6, "interfaceProtocol": 0 }, + { "interfaceClass": 255, "interfaceSubClass": 255, "interfaceProtocol": 255 } + ] + } + }, + { + "from": "ecm-ncm", + "to": "qmi", + "atCommand": "AT+QCFG=\"usbnet\",0", + "expectedResponse": "OK", + "expectsPortDrop": true, + "expectedDescriptors": { + "deviceClass": 0, + "interfaces": [ + { "interfaceClass": 255, "interfaceSubClass": 255, "interfaceProtocol": 255 } + ] + } + } + ] + } + ] +} diff --git a/control/src/usb-mode/index.ts b/control/src/usb-mode/index.ts new file mode 100644 index 0000000..0994236 --- /dev/null +++ b/control/src/usb-mode/index.ts @@ -0,0 +1,27 @@ +// The certified USB-mode catalog — schema, data, and lookups. +// +// A6.1's bench CLI (`set-usb-mode`) and A6.2's `certify` tool both consume this: the +// CLI looks up the permitted transition for a target mode, `certify` validates a +// candidate entry against the schema before a human commits it. + +export { + CERTIFIED_CATALOG, + findCatalogEntry, + findPermittedTransition, + loadCertifiedCatalog, +} from './catalog'; +export { + CANONICAL_USB_MODES, + type CanonicalUsbMode, + type CatalogEntry, + type CertifiedCatalog, + catalogEntrySchema, + certifiedCatalogSchema, + type ExpectedDescriptors, + expectedDescriptorsSchema, + MM_USB_MODES, + type MmUsbMode, + type PermittedTransition, + permittedTransitionSchema, + type SkuDiscriminator, +} from './catalog-schema'; diff --git a/control/test-support/README.md b/control/test-support/README.md new file mode 100644 index 0000000..59294e9 --- /dev/null +++ b/control/test-support/README.md @@ -0,0 +1,54 @@ +# Test support — MM-faithful fake D-Bus service + stateful NM harness + +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. + +## `fake-mm/` — a scriptable, MM-faithful `org.freedesktop.ModemManager1` + +A real ModemManager object model served on a private session bus (built on the same +`@httptoolkit/dbus-native` the transport uses): + +- **Root ObjectManager** at `/org/freedesktop/ModemManager1` answering `GetManagedObjects` + (`a{oa{sa{sv}}}`) and emitting `InterfacesAdded` / `InterfacesRemoved`. +- **Modems** at `/Modem/` exposing `Modem` and `Modem.Modem3gpp` as **separate** + D-Bus interfaces — never merged (draft §Oracle round-2 #6). +- **SIMs** as **separate** `/SIM/` objects, reached from a modem's `Sim` object-path + property (draft §Oracle round-2 #6). +- **Bearers** at `/Bearer/` that are observable in the tree but **throw a tripwire** + on `Connect` / `Disconnect` — and so do `Modem.Simple.Connect` and `Modem.CreateBearer` + — proving nothing in the stack ever activates a bearer through MM (NM owns activation). +- **Three property shapes:** `1.20` (has `Device`, no `Physdev`), `1.22` and `1.24` + (both `Device` and `Physdev`, since `Physdev` is 1.22+), switched per scenario + (draft §round-4). Feature detection (A3.2) reads the shape's real property set. +- **Signals:** invalidated-only `PropertiesChanged` (`sa{sv}as`, empty changed dict, + names in the invalidated array), value-carrying `PropertiesChanged`, and real daemon + `NameOwnerChanged` via name drop / reclaim / restart (new-owner epoch). +- **Scenario scripting:** `addModem`, `removeModem`, `replaceSim` (SIM hot-swap), + `configureScan`, `expectPin`, `setReplyDelay` (late replies), `changeProperties`, + `invalidateProperties`, `dropName`, `reclaimName`, `restart`. + +`tree.ts` provides the decoded-tree walk helpers (`fetchManagedObjects`, `findInterface`, +`propValue`, `followObjectPath`, …) the A3.x observer reuses. + +## `fake-nm/` — a stateful `nmcli` runner + a fake `NetworkManagerPort` + +- `StatefulNmcliRunner` runs a tiny state machine over the real `nmcli` argv grammar and + keeps profiles + active-device state, so `connection show` after `add` / `modify` + 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. + +## Running + +```sh +dbus-run-session -- bun test control/test-support +``` + +The bus-dependent suites use `describe.skipIf(!hasSessionBus())` and print one loud +`console.warn` when skipped, so a missing `dbus-run-session` is never mistaken for a +pass. The `fake-nm` suite needs no bus and always runs. CI installs `dbus` + +`python3-dbus`. diff --git a/control/test-support/fake-mm/bus-session.ts b/control/test-support/fake-mm/bus-session.ts new file mode 100644 index 0000000..2c2dc92 --- /dev/null +++ b/control/test-support/fake-mm/bus-session.ts @@ -0,0 +1,126 @@ +// A thin typed facade over one `@httptoolkit/dbus-native` client connection. +// +// The fake service owns a bus connection whose whole job is to serve the MM object +// model and emit its signals. This wrapper hides the untyped library surface behind +// a small, purpose-built API (connect, claim/release a name, register a method +// handler, emit a signal, disconnect) and applies the two Bun survival rules A2.4 +// discovered: run with `ReturnLongjs: true`, and after connect attach a no-op +// `error` listener so a killed/closed bus never re-throws an EventEmitter error and +// crashes the test process. + +import * as dbusNativeModule from '@httptoolkit/dbus-native'; + +/** A method-call handler: receives the decoded arg list, returns the reply value. */ +export type MethodImpl = (...args: unknown[]) => unknown; + +/** Where to reach the bus — a session address or a private-bus socket path. */ +export interface BusAddress { + readonly busAddress?: string; + readonly socket?: string; +} + +interface RawConnection { + on(event: string, handler: (...args: unknown[]) => void): void; + once(event: string, handler: (...args: unknown[]) => void): void; + removeListener(event: string, handler: (...args: unknown[]) => void): void; +} + +interface RawBus { + name?: string; + connection: RawConnection; + requestName(name: string, flags: number): Promise; + releaseName(name: string): Promise; + setMethodCallHandler( + path: string, + iface: string, + member: string, + handler: [MethodImpl, string], + ): void; + sendSignal(path: string, iface: string, member: string, signature: string, args: unknown[]): void; + disconnect(): Promise; +} + +interface RawModule { + createClient(options: { busAddress?: string; socket?: string; ReturnLongjs?: boolean }): RawBus; +} + +const rawModule = ((dbusNativeModule as { default?: unknown }).default ?? + dbusNativeModule) as unknown as RawModule; + +/** Narrow a `BusAddress` to exactly one populated field (socket wins). */ +export function pickAddress(address: BusAddress): BusAddress { + if (address.socket !== undefined) { + return { socket: address.socket }; + } + if (address.busAddress !== undefined) { + return { busAddress: address.busAddress }; + } + return {}; +} + +export class BusSession { + readonly #bus: RawBus; + + private constructor(bus: RawBus) { + this.#bus = bus; + } + + static async connect(address: BusAddress): Promise { + const options: { busAddress?: string; socket?: string; ReturnLongjs?: boolean } = { + ReturnLongjs: true, + }; + if (address.socket !== undefined) { + options.socket = address.socket; + } else if (address.busAddress !== undefined) { + options.busAddress = address.busAddress; + } + const bus = rawModule.createClient(options); + await new Promise((resolve, reject) => { + const onConnect = (): void => { + bus.connection.removeListener('error', onError); + resolve(); + }; + const onError = (error: unknown): void => { + bus.connection.removeListener('connect', onConnect); + reject(error instanceof Error ? error : new Error(String(error))); + }; + bus.connection.once('connect', onConnect); + bus.connection.once('error', onError); + }); + // Swallow late socket errors from a killed/closed bus (see A2.4 reconnect notes). + bus.connection.on('error', () => undefined); + return new BusSession(bus); + } + + /** The connection's unique bus name — populated once `Hello` (and thus the first + * awaited name request) has completed. */ + get uniqueName(): string | undefined { + return this.#bus.name; + } + + async requestName(name: string, flags = 0): Promise { + await this.#bus.requestName(name, flags); + } + + async releaseName(name: string): Promise { + await this.#bus.releaseName(name); + } + + handle( + path: string, + iface: string, + member: string, + impl: MethodImpl, + resultSignature: string, + ): void { + this.#bus.setMethodCallHandler(path, iface, member, [impl, resultSignature]); + } + + emit(path: string, iface: string, member: string, signature: string, args: unknown[]): void { + this.#bus.sendSignal(path, iface, member, signature, args); + } + + async disconnect(): Promise { + await this.#bus.disconnect().catch(() => undefined); + } +} diff --git a/control/test-support/fake-mm/handlers.ts b/control/test-support/fake-mm/handlers.ts new file mode 100644 index 0000000..be2b744 --- /dev/null +++ b/control/test-support/fake-mm/handlers.ts @@ -0,0 +1,144 @@ +// Method-call handler registration for the fake MM service. +// +// Registers the root `ObjectManager.GetManagedObjects` + `InhibitDevice` and every +// modem's methods on a `BusSession`. The bearer-creating methods (`Bearer.Connect`/ +// `Disconnect`, `Modem.Simple.Connect`/`Disconnect`, `Modem.CreateBearer`) are +// TRIPWIRES: they call `ctx.tripwire`, which throws — proving the controller never +// activates a bearer through MM. The disruptive + SIM ops (`SetCurrentModes`, +// `SetPrimarySimSlot`, `Scan`, `SendPin`, `SendPuk`) are TRACED: they record a +// `member:start:` on entry and a `member:end:` after the reply delay, so a +// test can prove per-modem serialization (no interleave) from the call log. State the +// handlers need at call time is supplied by the service through `ctx`, so +// re-registering after a restart just points the same wiring at the new connection. + +import type { BusSession } from './bus-session'; +import { + BEARER_IFACE, + bearerPath, + type ManagedObjects, + MM_MANAGER_IFACE, + MODEM_IFACE, + MODEM3GPP_IFACE, + type ModemSpec, + modemPath, + OBJECT_MANAGER_IFACE, + ROOT_PATH, + SIGNAL_IFACE, + SIM_IFACE, + SIMPLE_IFACE, + simPath, +} from './object-model'; + +const MANAGED_OBJECTS_SIG = 'a{oa{sa{sv}}}'; +const CELL_INFO_SIG = 'aa{sv}'; + +/** Live instance state a handler consults to answer a call. */ +export interface HandlerContext { + tree(): ManagedObjects; + scanReply(modemIndex: number): unknown; + cellInfo(modemIndex: number): unknown; + submitPin(modemIndex: number, simObjectPath: string, pin: unknown): null; + submitPuk(modemIndex: number, simObjectPath: string, puk: unknown, newPin: unknown): null; + recordSignalSetup(modemIndex: number, rate: unknown): void; + tripwire(iface: string, member: string): never; + delay(value: T): T | Promise; + /** Record a call-log event, then run `produce` after the reply delay. */ + traced(member: string, modemIndex: number, produce: () => T): T | Promise; +} + +export function registerRoot(session: BusSession, ctx: HandlerContext): void { + session.handle( + ROOT_PATH, + OBJECT_MANAGER_IFACE, + 'GetManagedObjects', + () => ctx.delay(ctx.tree()), + MANAGED_OBJECTS_SIG, + ); + session.handle(ROOT_PATH, MM_MANAGER_IFACE, 'InhibitDevice', () => ctx.delay(null), ''); +} + +export function registerModemHandlers( + session: BusSession, + spec: ModemSpec, + ctx: HandlerContext, +): void { + const path = modemPath(spec.index); + const i = spec.index; + session.handle( + path, + MODEM_IFACE, + 'SetCurrentModes', + () => ctx.traced('SetCurrentModes', i, () => null), + '', + ); + session.handle( + path, + MODEM_IFACE, + 'SetPrimarySimSlot', + () => ctx.traced('SetPrimarySimSlot', i, () => null), + '', + ); + session.handle(path, MODEM_IFACE, 'GetCellInfo', () => ctx.delay(ctx.cellInfo(i)), CELL_INFO_SIG); + session.handle(path, MODEM_IFACE, 'Command', () => ctx.delay('OK'), 's'); + session.handle( + path, + MODEM_IFACE, + 'CreateBearer', + () => ctx.tripwire(MODEM_IFACE, 'CreateBearer'), + '', + ); + if (spec.hasSignal !== false) { + session.handle( + path, + SIGNAL_IFACE, + 'Setup', + (rate) => { + ctx.recordSignalSetup(i, rate); + return ctx.delay(null); + }, + '', + ); + } + session.handle(path, SIMPLE_IFACE, 'Connect', () => ctx.tripwire(SIMPLE_IFACE, 'Connect'), ''); + session.handle( + path, + SIMPLE_IFACE, + 'Disconnect', + () => ctx.tripwire(SIMPLE_IFACE, 'Disconnect'), + '', + ); + session.handle( + path, + MODEM3GPP_IFACE, + 'Scan', + () => ctx.traced('Scan', i, () => ctx.scanReply(i)), + 'aa{sv}', + ); + session.handle(path, MODEM3GPP_IFACE, 'Register', () => ctx.delay(null), ''); + for (const sim of spec.sims) { + const sp = simPath(sim.index); + session.handle( + sp, + SIM_IFACE, + 'SendPin', + (pin) => ctx.traced('SendPin', i, () => ctx.submitPin(i, sp, pin)), + '', + ); + session.handle( + sp, + SIM_IFACE, + 'SendPuk', + (puk, newPin) => ctx.traced('SendPuk', i, () => ctx.submitPuk(i, sp, puk, newPin)), + '', + ); + } + const bp = bearerPath(spec.bearerIndex ?? spec.index); + session.handle(bp, BEARER_IFACE, 'Connect', () => ctx.tripwire(BEARER_IFACE, 'Connect'), ''); + session.handle( + bp, + BEARER_IFACE, + 'Disconnect', + () => ctx.tripwire(BEARER_IFACE, 'Disconnect'), + '', + ); +} diff --git a/control/test-support/fake-mm/index.ts b/control/test-support/fake-mm/index.ts new file mode 100644 index 0000000..718a909 --- /dev/null +++ b/control/test-support/fake-mm/index.ts @@ -0,0 +1,55 @@ +// Public surface of the MM-faithful fake service. The A3.x D-Bus backend tests import +// the `FakeModemManager` service, the `ModemSpec` / `SimSpec` scenario types, the path +// and interface-name constants, and the decoded-tree walk helpers from here. + +export type { BusAddress } from './bus-session'; +export { + BEARER_IFACE, + BUS_NAME, + bearerPath, + type EncodeVariant, + type InterfaceEntry, + type ManagedObject, + type ManagedObjects, + MM_LOCK_NONE, + MM_LOCK_SIM_PIN, + MM_LOCK_SIM_PUK, + MM_MANAGER_IFACE, + type MmShape, + MODEM_IFACE, + MODEM3GPP_IFACE, + type ModemSpec, + modemPath, + OBJECT_MANAGER_IFACE, + type PropEntry, + ROOT_PATH, + type ScannedNetworkEntry, + SIGNAL_IFACE, + SIM_IFACE, + SIMPLE_IFACE, + type SimSpec, + simPath, +} from './object-model'; +export type { PreviousEpoch } from './previous-epoch'; +export { + FakeModemManager, + type FakeModemManagerOptions, + type SignalSetupCall, + TRIPWIRE_ERROR, +} from './service'; +export { + asManagedObjects, + type DecodedInterfaces, + type DecodedManagedObjects, + type DecodedObject, + type DecodedProps, + fetchManagedObjects, + findInterface, + findObject, + followObjectPath, + hasInterface, + interfaceNames, + objectPaths, + pathsWithInterface, + propValue, +} from './tree'; diff --git a/control/test-support/fake-mm/object-model.ts b/control/test-support/fake-mm/object-model.ts new file mode 100644 index 0000000..59c27bb --- /dev/null +++ b/control/test-support/fake-mm/object-model.ts @@ -0,0 +1,239 @@ +// The MM-faithful object model, in `@httptoolkit/dbus-native` ENCODE form. +// +// This is the architectural heart of the fake: it models the REAL ModemManager +// object tree, not a flattened convenience shape. The two corrections review +// insisted on (draft §Oracle round-2 #6, round-4) are encoded here structurally: +// +// 1. A modem exposes `Modem` and `Modem.Modem3gpp` as SEPARATE D-Bus interfaces +// under one object path — never merged into a single interface. +// 2. SIMs are SEPARATE objects at `/org/freedesktop/ModemManager1/SIM/`, +// reachable from the modem's `Sim` property (an object path), never inlined. +// 3. `Modem.Device` carries the udev slot UID on ALL versions; `Physdev` exists +// ONLY from 1.22+ — the 1.20-shape omits it, the 1.22- and 1.24-shapes include it. +// +// Every value below is already in the library's native encode form (a variant is +// `[signature, value]`, a dict is an array of `[key, value]` entries), so the tree +// marshals straight onto the wire and the transport decodes it symmetrically. + +export const ROOT_PATH = '/org/freedesktop/ModemManager1'; +export const BUS_NAME = 'org.freedesktop.ModemManager1'; + +export const OBJECT_MANAGER_IFACE = 'org.freedesktop.DBus.ObjectManager'; +export const MM_MANAGER_IFACE = 'org.freedesktop.ModemManager1'; +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 SIM_IFACE = 'org.freedesktop.ModemManager1.Sim'; +export const BEARER_IFACE = 'org.freedesktop.ModemManager1.Bearer'; + +/** MMModemLock codes the read-before-submit tests script. */ +export const MM_LOCK_NONE = 1; +export const MM_LOCK_SIM_PIN = 2; +export const MM_LOCK_SIM_PUK = 4; + +/** Which ModemManager property shape a scenario presents (1.22+ adds `Physdev`). */ +export type MmShape = '1.20' | '1.22' | '1.24'; + +/** A variant in encode form: `[signature, value]`. */ +export type EncodeVariant = readonly [string, unknown]; +/** One `a{sv}` entry: `[propertyName, variant]`. */ +export type PropEntry = readonly [string, EncodeVariant]; +/** One interface's property set. */ +export type InterfaceEntry = readonly [string, readonly PropEntry[]]; +/** One managed object: `[objectPath, interfaces]` (an `oa{sa{sv}}` pair). */ +export type ManagedObject = readonly [string, readonly InterfaceEntry[]]; +/** The full `a{oa{sa{sv}}}` GetManagedObjects payload. */ +export type ManagedObjects = readonly ManagedObject[]; + +/** A visible network row returned by `Modem3gpp.Scan` (`a{sv}`). */ +export interface ScannedNetworkEntry { + readonly operatorCode: string; + readonly operatorName?: string; + /** MMModem3gppNetworkAvailability: 0 unknown, 1 available, 2 current, 3 forbidden. */ + readonly availability: number; + /** MMModemAccessTechnology bitmask (e.g. 1<<14 = LTE). */ + readonly accessTechnology?: number; +} + +/** One SIM card, modeled as its own `/SIM/` object. */ +export interface SimSpec { + /** Global SIM index → `/org/freedesktop/ModemManager1/SIM/`. */ + readonly index: number; + readonly iccid: string; + readonly imsi: string; + readonly operatorName?: string; + readonly operatorCode?: string; + /** Slot currently selected as primary (drives the modem's `Sim` path). */ + readonly active?: boolean; + /** MMSimType (1 physical, 2 esim) — surfaced on `Sim.SimType` when set. */ + readonly simType?: number; + /** MMSimEsimStatus (1 no-profiles, 2 with-profiles) — `Sim.EsimStatus` when set. */ + readonly esimStatus?: number; +} + +/** One modem, with separate `Modem` + `Modem3gpp` interfaces and a bearer tripwire. */ +export interface ModemSpec { + /** Modem index → `/org/freedesktop/ModemManager1/Modem/`. */ + readonly index: number; + readonly manufacturer?: string; + readonly model?: string; + readonly revision?: string; + /** IMEI / equipment id. */ + readonly equipmentId?: string; + /** udev slot UID (or device path). Present on the `Modem.Device` prop, ALL versions. */ + readonly device?: string; + /** Physical device path — only surfaced on the 1.24-shape (`Physdev`, 1.22+). */ + readonly physdev?: string; + /** MMModemState (e.g. 8 registered, 11 connected). */ + readonly state?: number; + /** Signal quality percent (0-100) for the `(ub)` SignalQuality struct. */ + readonly signalQuality?: number; + /** MMModem3gppRegistrationState (1 home, 5 roaming). */ + readonly registrationState?: number; + readonly sims: readonly SimSpec[]; + readonly primarySimSlot?: number; + /** The bearer index → `/org/freedesktop/ModemManager1/Bearer/`. */ + readonly bearerIndex?: number; + /** Whether this modem exposes the `Modem.Signal` interface (default true). */ + readonly hasSignal?: boolean; + /** MMModemLock currently required (`Modem.UnlockRequired`); default NONE. */ + readonly unlockRequired?: number; + /** Remaining attempts per lock (`Modem.UnlockRetries`, `a(uu)`). */ + readonly unlockRetries?: readonly (readonly [number, number])[]; +} + +export const modemPath = (index: number): string => `${ROOT_PATH}/Modem/${index}`; +export const simPath = (index: number): string => `${ROOT_PATH}/SIM/${index}`; +export const bearerPath = (index: number): string => `${ROOT_PATH}/Bearer/${index}`; + +const activeSim = (spec: ModemSpec): SimSpec | undefined => + spec.sims.find((sim) => sim.active) ?? spec.sims[0]; + +const modemBearerIndex = (spec: ModemSpec): number => spec.bearerIndex ?? spec.index; + +/** The `Modem` interface property set. `Physdev` appears only on the 1.24-shape. */ +export function modemProps(spec: ModemSpec, shape: MmShape): readonly PropEntry[] { + const sim = activeSim(spec); + const props: PropEntry[] = [ + ['Manufacturer', ['s', spec.manufacturer ?? 'Fake Modems Inc.']], + ['Model', ['s', spec.model ?? 'FM-0']], + ['Revision', ['s', spec.revision ?? '1.0-fake']], + ['DeviceIdentifier', ['s', `fake-device-${spec.index}`]], + ['EquipmentIdentifier', ['s', spec.equipmentId ?? `35000000000000${spec.index}`]], + // `Device` carries the slot UID on every MM version (draft round-4). + ['Device', ['s', spec.device ?? `/sys/devices/fake/usb${spec.index}`]], + ['State', ['i', spec.state ?? 8]], + ['PowerState', ['u', 3]], + ['SignalQuality', ['(ub)', [spec.signalQuality ?? 71, true]]], + ['Sim', ['o', sim ? simPath(sim.index) : '/']], + ['SimSlots', ['ao', spec.sims.map((each) => simPath(each.index))]], + ['PrimarySimSlot', ['u', spec.primarySimSlot ?? 0]], + ['Bearers', ['ao', [bearerPath(modemBearerIndex(spec))]]], + ['SupportedCapabilities', ['au', [4, 8]]], + ['CurrentCapabilities', ['u', 4]], + ['CurrentModes', ['(uu)', [7, 0]]], + ['UnlockRequired', ['u', spec.unlockRequired ?? MM_LOCK_NONE]], + ['UnlockRetries', ['a(uu)', (spec.unlockRetries ?? []).map(([lock, left]) => [lock, left])]], + ]; + // `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}`]]); + } + return props; +} + +/** The SEPARATE `Modem.Modem3gpp` interface property set. */ +export function modem3gppProps(spec: ModemSpec): readonly PropEntry[] { + const sim = activeSim(spec); + return [ + ['Imei', ['s', spec.equipmentId ?? `35000000000000${spec.index}`]], + ['OperatorCode', ['s', sim?.operatorCode ?? '00101']], + ['OperatorName', ['s', sim?.operatorName ?? 'Fake Network']], + ['RegistrationState', ['u', spec.registrationState ?? 1]], + ['EnabledFacilityLocks', ['u', 0]], + ]; +} + +/** A SIM object's `Sim` interface property set. `SimType` / `EsimStatus` (1.20+) + * are surfaced only when the spec sets them. */ +export function simProps(sim: SimSpec): readonly PropEntry[] { + const props: PropEntry[] = [ + ['SimIdentifier', ['s', sim.iccid]], + ['Imsi', ['s', sim.imsi]], + ['OperatorIdentifier', ['s', sim.operatorCode ?? '00101']], + ['OperatorName', ['s', sim.operatorName ?? 'Fake Network']], + ['Active', ['b', sim.active ?? true]], + ]; + if (sim.simType !== undefined) { + props.push(['SimType', ['u', sim.simType]]); + } + if (sim.esimStatus !== undefined) { + props.push(['EsimStatus', ['u', sim.esimStatus]]); + } + return props; +} + +/** The `Modem.Signal` interface property set — its mere presence is what the + * Signal.Setup manager gates on (absent ⇒ `signalCadence: unsupported`). */ +export function signalProps(): readonly PropEntry[] { + return [['Rate', ['u', 0]]]; +} + +/** A bearer object's property set — observable, but every connect method throws. */ +export function bearerProps(): readonly PropEntry[] { + return [ + ['Connected', ['b', false]], + ['Suspended', ['b', false]], + ['Interface', ['s', '']], + ]; +} + +/** The managed object for a modem: `Modem` + `Modem3gpp` as SEPARATE interfaces, + * plus `Modem.Signal` unless the spec opts out (`hasSignal: false`). */ +export function modemObject(spec: ModemSpec, shape: MmShape): ManagedObject { + const interfaces: InterfaceEntry[] = [ + [MODEM_IFACE, modemProps(spec, shape)], + [MODEM3GPP_IFACE, modem3gppProps(spec)], + ]; + if (spec.hasSignal !== false) { + interfaces.push([SIGNAL_IFACE, signalProps()]); + } + return [modemPath(spec.index), interfaces]; +} + +/** The managed object for a SIM — a top-level `/SIM/` object. */ +export function simObject(sim: SimSpec): ManagedObject { + return [simPath(sim.index), [[SIM_IFACE, simProps(sim)]]]; +} + +/** The managed object for a bearer — observable in the tree, tripwired on connect. */ +export function bearerObject(spec: ModemSpec): ManagedObject { + return [bearerPath(modemBearerIndex(spec)), [[BEARER_IFACE, bearerProps()]]]; +} + +/** Every managed object a modem contributes: the modem, its SIMs, and its bearer. */ +export function modemObjects(spec: ModemSpec, shape: MmShape): readonly ManagedObject[] { + return [modemObject(spec, shape), ...spec.sims.map(simObject), bearerObject(spec)]; +} + +/** Build the full `a{oa{sa{sv}}}` tree from the live modem specs. */ +export function managedObjects(specs: readonly ModemSpec[], shape: MmShape): ManagedObjects { + return specs.flatMap((spec) => modemObjects(spec, shape)); +} + +/** Encode a scanned-network row as a `Modem3gpp.Scan` `a{sv}` entry list. */ +export function scanEntry(network: ScannedNetworkEntry): readonly PropEntry[] { + const props: PropEntry[] = [ + ['status', ['u', network.availability]], + ['operator-code', ['s', network.operatorCode]], + ]; + if (network.operatorName !== undefined) { + props.push(['operator-long', ['s', network.operatorName]]); + props.push(['operator-short', ['s', network.operatorName]]); + } + if (network.accessTechnology !== undefined) { + props.push(['access-technology', ['u', network.accessTechnology]]); + } + return props; +} diff --git a/control/test-support/fake-mm/previous-epoch.ts b/control/test-support/fake-mm/previous-epoch.ts new file mode 100644 index 0000000..dd437b7 --- /dev/null +++ b/control/test-support/fake-mm/previous-epoch.ts @@ -0,0 +1,44 @@ +// The retained previous-epoch handle for `FakeModemManager.restartRetainingPrevious`. +// +// After a new owner replaces the old one, the OLD connection stays up. This handle +// lets a test emit a stale, old-epoch `InterfacesRemoved` from that connection and +// prove the observer ignores it (the signal's `sender` is not the current owner). + +import type { BusSession } from './bus-session'; +import { type MmShape, type ModemSpec, modemObjects } from './object-model'; +import { emitInterfacesRemoved } from './signals'; + +export interface PreviousEpoch { + readonly uniqueName: string | undefined; + /** Emit an old-epoch `InterfacesRemoved` for a modem from the retained connection. */ + removeModem(index: number): void; + stop(): Promise; +} + +export function makePreviousEpoch( + previous: BusSession, + specs: ReadonlyMap, + shape: MmShape, +): PreviousEpoch { + return { + get uniqueName(): string | undefined { + return previous.uniqueName; + }, + removeModem(index: number): void { + const spec = specs.get(index); + if (spec === undefined) { + return; + } + for (const [path, interfaces] of modemObjects(spec, shape)) { + emitInterfacesRemoved( + previous, + path, + interfaces.map(([name]) => name), + ); + } + }, + stop(): Promise { + return previous.disconnect(); + }, + }; +} diff --git a/control/test-support/fake-mm/service.ts b/control/test-support/fake-mm/service.ts new file mode 100644 index 0000000..0d7bed2 --- /dev/null +++ b/control/test-support/fake-mm/service.ts @@ -0,0 +1,327 @@ +// A scriptable, MM-faithful fake `org.freedesktop.ModemManager1` service. +// +// It serves a REAL ModemManager object model on a private session bus: a root +// ObjectManager, modems with SEPARATE `Modem` + `Modem.Modem3gpp` interfaces, SIMs +// as separate `/SIM/` objects reached via each modem's `Sim` path, and bearers +// that are observable in the tree but THROW on any connect method (the tripwire +// proving nothing in the stack ever activates a bearer through MM). Scenarios drive +// it through the methods below: add/remove modems, hot-swap SIMs, emit invalidated +// or changed `PropertiesChanged`, configure `Scan`, delay replies, drop/reclaim the +// bus name (real `NameOwnerChanged`), and restart under a fresh owner. +// +// The A3.x D-Bus backend is written and tested against THIS service. Method wiring +// lives in `handlers.ts`, signal emission in `signals.ts`, the object model in +// `object-model.ts`; this file holds the scenario state and public API. + +import { type BusAddress, BusSession, pickAddress } from './bus-session'; +import { type HandlerContext, registerModemHandlers, registerRoot } from './handlers'; +import { + BUS_NAME, + type MmShape, + MODEM_IFACE, + type ModemSpec, + managedObjects, + modemObjects, + modemPath, + type PropEntry, + type ScannedNetworkEntry, + SIM_IFACE, + type SimSpec, + scanEntry, + simObject, + simPath, +} from './object-model'; +import { simLockError, submitPin, submitPuk, type UnlockOutcome } from './unlock-state'; + +/** One recorded `Signal.Setup` call — the modem, its rate, and the serving epoch owner. */ +export interface SignalSetupCall { + readonly modemIndex: number; + readonly rate: number; + readonly owner: string | undefined; +} + +import { makePreviousEpoch, type PreviousEpoch } from './previous-epoch'; +import { emitInterfacesAdded, emitInterfacesRemoved, emitPropertiesChanged } from './signals'; + +const TRIPWIRE_ERROR = 'tv.ceralive.FakeModemManager.Error.BearerTripwire'; + +// DBus name-request flags. A current owner claims with ALLOW_REPLACEMENT so a fresh +// epoch can take the name over WITHOUT the old connection dropping — the state a +// `restartRetainingPrevious()` needs to emit a genuine old-epoch straggler signal. +const NAME_FLAG_ALLOW_REPLACEMENT = 0x1; +const NAME_FLAG_REPLACE_EXISTING = 0x2; + +export interface FakeModemManagerOptions extends BusAddress { + readonly shape?: MmShape; + readonly modems?: readonly ModemSpec[]; +} + +export class FakeModemManager { + readonly busName = BUS_NAME; + readonly #address: BusAddress; + readonly #shape: MmShape; + readonly #specs = new Map(); + readonly #scans = new Map(); + readonly #cells = new Map(); + readonly #expectedPins = new Map(); + readonly #expectedPuks = new Map(); + readonly #callLog: string[] = []; + readonly #signalSetupLog: SignalSetupCall[] = []; + readonly #ctx: HandlerContext; + #session: BusSession; + #replyDelayMs = 0; + + private constructor(session: BusSession, shape: MmShape, address: BusAddress) { + this.#session = session; + this.#shape = shape; + this.#address = address; + this.#ctx = { + tree: () => managedObjects([...this.#specs.values()], this.#shape), + scanReply: (index) => (this.#scans.get(index) ?? []).map(scanEntry), + cellInfo: (index) => this.#cells.get(index) ?? [], + 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), + tripwire: (iface, member) => this.#tripwire(iface, member), + delay: (value) => this.#delay(value), + traced: (member, index, produce) => this.#traced(member, index, produce), + }; + } + + static async start(options: FakeModemManagerOptions): Promise { + const session = await BusSession.connect(options); + const fake = new FakeModemManager(session, options.shape ?? '1.24', pickAddress(options)); + for (const spec of options.modems ?? []) { + fake.#specs.set(spec.index, spec); + } + fake.#registerAll(); + // Claiming the name completes only after Hello, so `uniqueName` is set afterwards. + await session.requestName(BUS_NAME, NAME_FLAG_ALLOW_REPLACEMENT); + return fake; + } + + get uniqueName(): string | undefined { + return this.#session.uniqueName; + } + + get shape(): MmShape { + return this.#shape; + } + + /** Add a modem and announce it: `InterfacesAdded` for the modem, its SIMs, its bearer. */ + addModem(spec: ModemSpec): void { + this.#specs.set(spec.index, spec); + registerModemHandlers(this.#session, spec, this.#ctx); + for (const object of modemObjects(spec, this.#shape)) { + emitInterfacesAdded(this.#session, object); + } + } + + /** Remove a modem and announce it: `InterfacesRemoved` for each of its objects. */ + 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)) { + emitInterfacesRemoved( + this.#session, + path, + interfaces.map(([name]) => name), + ); + } + } + + /** Hot-swap the SIM: remove the old SIM object, add the new one, invalidate `Sim`. */ + replaceSim(modemIndex: number, replacement: SimSpec): void { + const spec = this.#specs.get(modemIndex); + if (spec === undefined) { + return; + } + const newSim: SimSpec = { ...replacement, active: true }; + const next: ModemSpec = { ...spec, sims: [newSim] }; + this.#specs.set(modemIndex, next); + registerModemHandlers(this.#session, next, this.#ctx); + for (const sim of spec.sims) { + emitInterfacesRemoved(this.#session, simPath(sim.index), [SIM_IFACE]); + } + emitInterfacesAdded(this.#session, simObject(newSim)); + // The modem's `Sim` path changed — MM invalidates it so the client re-reads. + emitPropertiesChanged(this.#session, modemPath(modemIndex), MODEM_IFACE, [], ['Sim']); + } + + /** Configure what `Modem3gpp.Scan` returns for a modem. */ + configureScan(modemIndex: number, networks: readonly ScannedNetworkEntry[]): void { + this.#scans.set(modemIndex, networks); + } + + /** Make a SIM demand a specific PIN; a wrong `SendPin` throws the MM SimPin error. */ + expectPin(simIndex: number, pin: string): void { + this.#expectedPins.set(simPath(simIndex), pin); + } + + /** Make a SIM demand a specific PUK; a wrong `SendPuk` throws the MM SimPuk error. */ + expectPuk(simIndex: number, puk: string): void { + this.#expectedPuks.set(simPath(simIndex), puk); + } + + /** Configure what `Modem.GetCellInfo` returns for a modem (`aa{sv}` encode form). */ + configureCellInfo(modemIndex: number, cells: readonly (readonly PropEntry[])[]): void { + this.#cells.set(modemIndex, cells); + } + + /** The ordered disruptive-op call log (`member:start:` / `member:end:`). */ + get callLog(): readonly string[] { + return [...this.#callLog]; + } + + /** Every recorded `Signal.Setup` call, in order, tagged with its serving owner. */ + get signalSetupCalls(): readonly SignalSetupCall[] { + return [...this.#signalSetupLog]; + } + + /** Reset the call + Signal.Setup logs (use between scenario phases). */ + clearLogs(): void { + this.#callLog.length = 0; + this.#signalSetupLog.length = 0; + } + + /** Delay every subsequent method reply by `ms` (0 disables) — late-reply scenarios. */ + setReplyDelay(ms: number): void { + this.#replyDelayMs = ms; + } + + /** Emit a `PropertiesChanged` carrying new VALUES in the changed dict. */ + changeProperties(path: string, iface: string, changed: readonly PropEntry[]): void { + emitPropertiesChanged(this.#session, path, iface, changed, []); + } + + /** Emit an INVALIDATED-ONLY `PropertiesChanged`: empty changed dict, names invalidated. */ + invalidateProperties(path: string, iface: string, names: readonly string[]): void { + emitPropertiesChanged(this.#session, path, iface, [], names); + } + + /** Release the well-known name — the daemon emits a real `NameOwnerChanged` (owner → ""). */ + async dropName(): Promise { + await this.#session.releaseName(BUS_NAME); + } + + /** Re-claim the name on the SAME connection — `NameOwnerChanged` ("" → owner). */ + async reclaimName(): Promise { + await this.#session.requestName(BUS_NAME, NAME_FLAG_ALLOW_REPLACEMENT); + } + + /** Restart under a FRESH connection (new unique owner = new epoch), re-serving the model. */ + async restart(): Promise { + const previous = this.#session; + this.#session = await BusSession.connect(this.#address); + this.#registerAll(); + await this.#session.requestName(BUS_NAME, NAME_FLAG_ALLOW_REPLACEMENT); + await previous.disconnect(); + } + + /** + * Restart to a NEW epoch that REPLACES the old owner while KEEPING the previous + * connection alive. The new connection takes the name via REPLACE_EXISTING (the old + * claim allowed replacement), so `NameOwnerChanged` (old → new) fires with both + * connections up. The returned handle can emit stale old-epoch signals. + */ + async restartRetainingPrevious(): Promise { + const handle = makePreviousEpoch(this.#session, new Map(this.#specs), this.#shape); + this.#session = await BusSession.connect(this.#address); + this.#registerAll(); + await this.#session.requestName( + BUS_NAME, + NAME_FLAG_ALLOW_REPLACEMENT | NAME_FLAG_REPLACE_EXISTING, + ); + return handle; + } + + async stop(): Promise { + await this.#session.disconnect(); + } + + #registerAll(): void { + registerRoot(this.#session, this.#ctx); + for (const spec of this.#specs.values()) { + registerModemHandlers(this.#session, spec, this.#ctx); + } + } + + #submitPin(index: number, simObjectPath: string, pin: unknown): null { + return this.#applyUnlock(index, simObjectPath, (spec) => + submitPin(spec, this.#expectedPins.get(simObjectPath), pin), + ); + } + + #submitPuk(index: number, simObjectPath: string, puk: unknown, _newPin: unknown): null { + return this.#applyUnlock(index, simObjectPath, (spec) => + submitPuk(spec, this.#expectedPuks.get(simObjectPath), puk), + ); + } + + #applyUnlock( + index: number, + simObjectPath: string, + run: (spec: ModemSpec) => UnlockOutcome, + ): null { + const spec = this.#specs.get(index); + if (spec === undefined) { + return null; + } + const outcome = run(spec); + this.#specs.set(index, outcome.next); + if (outcome.reject !== undefined) { + simLockError(simObjectPath, outcome.reject); + } + return null; + } + + #recordSignalSetup(index: number, rate: unknown): void { + this.#signalSetupLog.push({ + modemIndex: index, + rate: typeof rate === 'number' ? rate : Number(rate), + owner: this.#session.uniqueName, + }); + } + + #traced(member: string, index: number, produce: () => T): T | Promise { + this.#callLog.push(`${member}:start:${index}`); + const finish = (): T => { + const value = produce(); + this.#callLog.push(`${member}:end:${index}`); + return value; + }; + if (this.#replyDelayMs <= 0) { + return finish(); + } + return new Promise((resolve, reject) => { + setTimeout(() => { + try { + resolve(finish()); + } catch (error) { + reject(error instanceof Error ? error : new Error(String(error))); + } + }, this.#replyDelayMs); + }); + } + + #tripwire(iface: string, member: string): never { + const error = new Error( + `TRIPWIRE: ${iface}.${member} was called — the controller must NEVER touch bearers; ` + + 'NetworkManager owns activation (see the ownership matrix).', + ) as Error & { dbusName?: string }; + error.dbusName = TRIPWIRE_ERROR; + throw error; + } + + #delay(value: T): T | Promise { + if (this.#replyDelayMs <= 0) { + return value; + } + return new Promise((resolve) => setTimeout(() => resolve(value), this.#replyDelayMs)); + } +} + +export { TRIPWIRE_ERROR }; diff --git a/control/test-support/fake-mm/signals.ts b/control/test-support/fake-mm/signals.ts new file mode 100644 index 0000000..9767439 --- /dev/null +++ b/control/test-support/fake-mm/signals.ts @@ -0,0 +1,44 @@ +// The ObjectManager / Properties signal emitters, in `@httptoolkit/dbus-native` encode +// form. `InterfacesAdded` (`oa{sa{sv}}`) and `InterfacesRemoved` (`oas`) come from the +// root ObjectManager path; `PropertiesChanged` (`sa{sv}as`) comes from the object whose +// properties changed. An invalidated-only change is an empty `changed` dict with the +// names in `invalidated` — the real MM semantics the observer must handle. + +import type { BusSession } from './bus-session'; +import { + type ManagedObject, + OBJECT_MANAGER_IFACE, + type PropEntry, + ROOT_PATH, +} from './object-model'; + +const PROPERTIES_IFACE = 'org.freedesktop.DBus.Properties'; + +export function emitInterfacesAdded(session: BusSession, object: ManagedObject): void { + session.emit(ROOT_PATH, OBJECT_MANAGER_IFACE, 'InterfacesAdded', 'oa{sa{sv}}', [ + object[0], + object[1], + ]); +} + +export function emitInterfacesRemoved( + session: BusSession, + path: string, + interfaces: readonly string[], +): void { + session.emit(ROOT_PATH, OBJECT_MANAGER_IFACE, 'InterfacesRemoved', 'oas', [path, interfaces]); +} + +export function emitPropertiesChanged( + session: BusSession, + path: string, + iface: string, + changed: readonly PropEntry[], + invalidated: readonly string[], +): void { + session.emit(path, PROPERTIES_IFACE, 'PropertiesChanged', 'sa{sv}as', [ + iface, + changed, + invalidated, + ]); +} diff --git a/control/test-support/fake-mm/tree.ts b/control/test-support/fake-mm/tree.ts new file mode 100644 index 0000000..eb47f4c --- /dev/null +++ b/control/test-support/fake-mm/tree.ts @@ -0,0 +1,91 @@ +// Helpers for walking a DECODED managed-objects tree — the shape the transport hands +// back from `ObjectManager.GetManagedObjects` (and, per-entry, from `InterfacesAdded`). +// +// A dict `a{sv}` decodes to an array of `[key, variant]` entries and a variant to +// `{ signature, value }`, so the whole `a{oa{sa{sv}}}` payload is nested tuple arrays. +// These accessors let a test (and, later, the A3.x observer) navigate that structure +// by object path and interface name without re-implementing the same lookups. + +import type { DbusTransport, DbusValue, DbusVariant } from '../../src/transport'; +import { OBJECT_MANAGER_IFACE, ROOT_PATH } from './object-model'; + +export type DecodedProps = ReadonlyArray; +export type DecodedInterfaces = ReadonlyArray; +export type DecodedObject = readonly [string, DecodedInterfaces]; +export type DecodedManagedObjects = readonly DecodedObject[]; + +/** Treat a `GetManagedObjects` reply body value as the decoded tree. */ +export function asManagedObjects(value: DbusValue | undefined): DecodedManagedObjects { + if (!Array.isArray(value)) { + throw new TypeError('managed-objects payload is not an array'); + } + return value as unknown as DecodedManagedObjects; +} + +/** Call `ObjectManager.GetManagedObjects` on `destination` and return the decoded tree. */ +export async function fetchManagedObjects( + transport: DbusTransport, + destination: string, +): Promise { + const reply = await transport.callMethod({ + destination, + path: ROOT_PATH, + interface: OBJECT_MANAGER_IFACE, + member: 'GetManagedObjects', + }); + return asManagedObjects(reply.body[0]); +} + +/** Every object path in the tree, in wire order. */ +export function objectPaths(tree: DecodedManagedObjects): string[] { + return tree.map(([path]) => path); +} + +/** The object at `path`, or `undefined` if absent. */ +export function findObject(tree: DecodedManagedObjects, path: string): DecodedObject | undefined { + return tree.find(([objectPath]) => objectPath === path); +} + +/** The interface names an object exposes — the proof that `Modem` and `Modem3gpp` + * are SEPARATE keys, never merged. */ +export function interfaceNames(tree: DecodedManagedObjects, path: string): string[] { + const object = findObject(tree, path); + return object ? object[1].map(([name]) => name) : []; +} + +/** The property entries of one interface on one object, or `undefined`. */ +export function findInterface( + tree: DecodedManagedObjects, + path: string, + iface: string, +): DecodedProps | undefined { + const object = findObject(tree, path); + return object?.[1].find(([name]) => name === iface)?.[1]; +} + +/** Whether an object exposes an interface. */ +export function hasInterface(tree: DecodedManagedObjects, path: string, iface: string): boolean { + return findInterface(tree, path, iface) !== undefined; +} + +/** The inner value of a property (a variant's `.value`), or `undefined`. */ +export function propValue(props: DecodedProps | undefined, name: string): DbusValue | undefined { + return props?.find(([key]) => key === name)?.[1]?.value; +} + +/** Object paths that carry a given interface. */ +export function pathsWithInterface(tree: DecodedManagedObjects, iface: string): string[] { + return tree + .filter(([, interfaces]) => interfaces.some(([name]) => name === iface)) + .map(([path]) => path); +} + +/** Read an object-path property (e.g. a modem's `Sim`) and resolve that object. */ +export function followObjectPath( + tree: DecodedManagedObjects, + props: DecodedProps | undefined, + propName: string, +): DecodedObject | undefined { + const target = propValue(props, propName); + return typeof target === 'string' ? findObject(tree, target) : undefined; +} diff --git a/control/test-support/fake-mm/unlock-state.ts b/control/test-support/fake-mm/unlock-state.ts new file mode 100644 index 0000000..cc06524 --- /dev/null +++ b/control/test-support/fake-mm/unlock-state.ts @@ -0,0 +1,59 @@ +// The fake's SIM-unlock state machine — pure spec transitions for SendPin / SendPuk. +// +// A wrong PIN decrements the SIM-PIN retry budget and, once it hits zero, trips the +// SIM into a PUK lock; a wrong PUK decrements the PUK budget (zero remaining is a +// permanent block). A correct secret clears the lock. This mirrors real MM so the +// backend's read-before-submit / exactly-once logic can be exercised against it. + +import { MM_LOCK_NONE, MM_LOCK_SIM_PIN, MM_LOCK_SIM_PUK, type ModemSpec } from './object-model'; + +/** Which MM error a rejected submission raises (empty ⇒ the secret was accepted). */ +export type UnlockReject = 'SimPin' | 'SimPuk'; + +/** The spec after a submission, plus the rejection (if the secret was wrong). */ +export interface UnlockOutcome { + readonly next: ModemSpec; + readonly reject?: UnlockReject; +} + +function retriesMap(spec: ModemSpec): Map { + return new Map(spec.unlockRetries ?? []); +} + +/** Apply a `SendPin` submission to a spec. */ +export function submitPin( + spec: ModemSpec, + expected: string | undefined, + pin: unknown, +): UnlockOutcome { + if (expected !== undefined && pin !== expected) { + const retries = retriesMap(spec); + const left = Math.max(0, (retries.get(MM_LOCK_SIM_PIN) ?? 0) - 1); + retries.set(MM_LOCK_SIM_PIN, left); + const unlockRequired = left === 0 ? MM_LOCK_SIM_PUK : (spec.unlockRequired ?? MM_LOCK_SIM_PIN); + return { next: { ...spec, unlockRetries: [...retries], unlockRequired }, reject: 'SimPin' }; + } + return { next: { ...spec, unlockRequired: MM_LOCK_NONE } }; +} + +/** Apply a `SendPuk` submission to a spec. */ +export function submitPuk( + spec: ModemSpec, + expected: string | undefined, + puk: unknown, +): UnlockOutcome { + if (expected !== undefined && puk !== expected) { + const retries = retriesMap(spec); + const left = Math.max(0, (retries.get(MM_LOCK_SIM_PUK) ?? 0) - 1); + retries.set(MM_LOCK_SIM_PUK, left); + return { next: { ...spec, unlockRetries: [...retries] }, reject: 'SimPuk' }; + } + return { next: { ...spec, unlockRequired: MM_LOCK_NONE } }; +} + +/** Raise the MM MobileEquipment error a wrong secret produces (never returns). */ +export function simLockError(path: string, kind: UnlockReject): never { + const error = new Error(`incorrect ${kind} for ${path}`) as Error & { dbusName?: string }; + error.dbusName = `org.freedesktop.ModemManager1.Error.MobileEquipment.${kind}`; + throw error; +} diff --git a/control/test-support/fake-nm.test.ts b/control/test-support/fake-nm.test.ts new file mode 100644 index 0000000..741adc0 --- /dev/null +++ b/control/test-support/fake-nm.test.ts @@ -0,0 +1,155 @@ +// Stateful NM harness — proves the nmcli-runner stub actually remembers state. +// +// These are real readback assertions, not mocked returns: a `connection show` after a +// `create` / `update` reflects exactly the fields written, and activation state tracks +// which UUID is up on which device. No D-Bus is involved, so the suite always runs. +// A4.1's `NmcliNmPort` injects this same runner to assert its nine-field GSM writes. + +import { describe, expect, test } from 'bun:test'; +import { connectionId, deviceIfname, type GsmProfileInput } from '../src/ports'; +import { FakeNetworkManagerPort, StatefulNmcliRunner } from './fake-nm'; + +const BASE_PROFILE: GsmProfileInput = { + connectionName: 'cell-home', + apn: 'internet', + username: 'user1', + password: 'secret', + homeOnly: true, + autoConfig: false, + networkId: '', +}; + +describe('StatefulNmcliRunner — raw argv state machine', () => { + test('a connection added is then visible in connection show', () => { + const runner = new StatefulNmcliRunner(); + const add = runner.run([ + 'connection', + 'add', + 'type', + 'gsm', + 'con-name', + 'c1', + 'gsm.apn', + 'iot', + ]); + const uuid = /\(([^)]+)\)/.exec(add.stdout)?.[1] ?? ''; + + const show = runner.run(['-t', '-f', 'gsm.apn', 'connection', 'show', uuid]); + expect(show.exitCode).toBe(0); + expect(show.stdout).toBe('gsm.apn:iot'); + }); + + test('modify updates a stored field and readback reflects it', () => { + const runner = new StatefulNmcliRunner(); + const uuid = /\(([^)]+)\)/.exec( + runner.run(['connection', 'add', 'type', 'gsm', 'con-name', 'c1', 'gsm.apn', 'old']).stdout, + )?.[1] as string; + + runner.run(['connection', 'modify', uuid, 'gsm.apn', 'new']); + expect(runner.run(['-t', '-f', 'gsm.apn', 'connection', 'show', uuid]).stdout).toBe( + 'gsm.apn:new', + ); + }); + + test('up marks a device active and device disconnect clears it', () => { + const runner = new StatefulNmcliRunner(); + const uuid = /\(([^)]+)\)/.exec( + runner.run(['connection', 'add', 'type', 'gsm', 'con-name', 'c1']).stdout, + )?.[1] as string; + + runner.run(['connection', 'up', uuid, 'ifname', 'wwan0']); + expect(runner.run(['-t', '-f', 'UUID,DEVICE', 'connection', 'show', '--active']).stdout).toBe( + `${uuid}:wwan0`, + ); + + runner.run(['device', 'disconnect', 'wwan0']); + expect(runner.run(['-t', '-f', 'UUID,DEVICE', 'connection', 'show', '--active']).stdout).toBe( + '', + ); + }); +}); + +describe('FakeNetworkManagerPort — port contract over the stateful runner', () => { + test('create then read returns the exact fields written', async () => { + const nm = new FakeNetworkManagerPort(); + const created = await nm.createGsmProfile(BASE_PROFILE); + const readBack = await nm.readGsmProfile(created.connectionId); + + expect(readBack?.connectionName).toBe('cell-home'); + expect(readBack?.apn).toBe('internet'); + expect(readBack?.username).toBe('user1'); + expect(readBack?.password).toBe('secret'); + expect(readBack?.homeOnly).toBe(true); + expect(readBack?.autoConfig).toBe(false); + }); + + test('an update is persisted and reflected on the next read (manual → auto APN)', async () => { + const nm = new FakeNetworkManagerPort(); + const created = await nm.createGsmProfile(BASE_PROFILE); + + const updated = await nm.updateGsmProfile(created.connectionId, { + apn: '', + username: '', + password: '', + autoConfig: true, + }); + expect(updated.autoConfig).toBe(true); + expect(updated.apn).toBe(''); + expect(updated.username).toBeUndefined(); + expect(updated.password).toBeUndefined(); + }); + + test('activation on an exact device is readable, and deactivate clears it', async () => { + const nm = new FakeNetworkManagerPort(); + const created = await nm.createGsmProfile(BASE_PROFILE); + const ifname = deviceIfname('wwan0'); + + const up = await nm.activate(created.connectionId, ifname); + expect(up.status).toBe('applied'); + + const down = await nm.deactivate(created.connectionId, ifname); + expect(down.status).toBe('applied'); + }); + + test('a quiesce lease deactivates the device and release reactivates it', async () => { + const nm = new FakeNetworkManagerPort(); + const created = await nm.createGsmProfile(BASE_PROFILE); + const ifname = deviceIfname('wwan0'); + await nm.activate(created.connectionId, ifname); + + const lease = await nm.acquireQuiesceLease(created.connectionId, ifname); + expect(lease.deviceIfname).toBe(ifname); + const activeDuringLease = nm.runner.run([ + '-t', + '-f', + 'UUID,DEVICE', + 'connection', + 'show', + '--active', + ]); + expect(activeDuringLease.stdout).toBe(''); + + await nm.releaseQuiesceLease(lease); + const activeAfter = nm.runner.run([ + '-t', + '-f', + 'UUID,DEVICE', + 'connection', + 'show', + '--active', + ]); + expect(activeAfter.stdout).toBe(`${created.connectionId}:wwan0`); + }); + + test('a deleted profile no longer reads back', async () => { + const nm = new FakeNetworkManagerPort(); + const created = await nm.createGsmProfile(BASE_PROFILE); + await nm.deleteGsmProfile(created.connectionId); + expect(await nm.readGsmProfile(created.connectionId)).toBeUndefined(); + }); + + test('an unknown connection id reads back as undefined', async () => { + const nm = new FakeNetworkManagerPort(); + expect(await nm.readGsmProfile(connectionId('nope'))).toBeUndefined(); + }); +}); diff --git a/control/test-support/fake-nm/fake-network-manager.ts b/control/test-support/fake-nm/fake-network-manager.ts new file mode 100644 index 0000000..7182fd7 --- /dev/null +++ b/control/test-support/fake-nm/fake-network-manager.ts @@ -0,0 +1,206 @@ +// A fake `NetworkManagerPort` that drives the stateful nmcli-runner. +// +// It fulfils the real port contract (profile CRUD, device-exact activate/deactivate, +// quiesce lease) by building nmcli argv and running them through `StatefulNmcliRunner`, +// so a `readGsmProfile` after a `createGsmProfile`/`updateGsmProfile` returns exactly +// what was written — a genuine readback double for reconcile/observer tests. It is a +// TEST DOUBLE, not the shipping adapter: it wires only the fields the port interface +// carries. 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. + +import { epochMillis } from '../../src/domain'; +import { + type ConnectionId, + connectionId, + type DeviceIfname, + type GsmProfile, + type GsmProfileInput, + type GsmProfilePatch, + type NetworkManagerPort, + type QuiesceLease, + type Receipt, + receipt, +} from '../../src/ports'; +import { type NmcliResult, StatefulNmcliRunner } from './nmcli-runner'; + +const READBACK_FIELDS = [ + 'connection.id', + 'gsm.apn', + 'gsm.username', + 'gsm.password', + 'gsm.home-only', + 'gsm.auto-config', + 'gsm.network-id', +]; + +const yesNo = (value: boolean): string => (value ? 'yes' : 'no'); + +export class FakeNetworkManagerPort implements NetworkManagerPort { + readonly #runner: StatefulNmcliRunner; + + constructor(runner: StatefulNmcliRunner = new StatefulNmcliRunner()) { + this.#runner = runner; + } + + /** The underlying runner — exposed so tests can assert the argv call log. */ + get runner(): StatefulNmcliRunner { + return this.#runner; + } + + async createGsmProfile(profile: GsmProfileInput): Promise { + const argv = [ + 'connection', + 'add', + 'type', + 'gsm', + 'con-name', + profile.connectionName, + 'gsm.apn', + profile.apn, + 'gsm.home-only', + yesNo(profile.homeOnly), + 'gsm.auto-config', + yesNo(profile.autoConfig), + ]; + if (profile.username !== undefined) { + argv.push('gsm.username', profile.username); + } + if (profile.password !== undefined) { + argv.push('gsm.password', profile.password); + } + if (profile.networkId !== undefined) { + argv.push('gsm.network-id', profile.networkId); + } + const result = this.#runner.run(argv); + const uuid = /\(([^)]+)\) successfully added/.exec(result.stdout)?.[1]; + if (uuid === undefined) { + throw new Error(`fake nmcli: add did not return a UUID (${result.stderr})`); + } + const created = await this.readGsmProfile(connectionId(uuid)); + if (created === undefined) { + throw new Error('fake nmcli: created profile did not read back'); + } + return created; + } + + async readGsmProfile(id: ConnectionId): Promise { + const result = this.#runner.run([ + '-t', + '-f', + READBACK_FIELDS.join(','), + 'connection', + 'show', + id, + ]); + if (result.exitCode !== 0) { + return undefined; + } + return buildProfile(id, parseTerse(result.stdout)); + } + + async updateGsmProfile(id: ConnectionId, patch: GsmProfilePatch): Promise { + const argv: string[] = ['connection', 'modify', id]; + if (patch.connectionName !== undefined) { + argv.push('connection.id', patch.connectionName); + } + if (patch.apn !== undefined) { + argv.push('gsm.apn', patch.apn); + } + if (patch.username !== undefined) { + argv.push('gsm.username', patch.username); + } + if (patch.password !== undefined) { + argv.push('gsm.password', patch.password); + } + if (patch.homeOnly !== undefined) { + argv.push('gsm.home-only', yesNo(patch.homeOnly)); + } + if (patch.autoConfig !== undefined) { + argv.push('gsm.auto-config', yesNo(patch.autoConfig)); + } + if (patch.networkId !== undefined) { + argv.push('gsm.network-id', patch.networkId); + } + const result = this.#runner.run(argv); + if (result.exitCode !== 0) { + throw new Error(`fake nmcli: modify failed (${result.stderr})`); + } + const updated = await this.readGsmProfile(id); + if (updated === undefined) { + throw new Error(`fake nmcli: profile ${id} vanished after modify`); + } + return updated; + } + + async deleteGsmProfile(id: ConnectionId): Promise { + this.#runner.run(['connection', 'delete', id]); + } + + async activate(id: ConnectionId, ifname: DeviceIfname): Promise { + const result = this.#runner.run(['connection', 'up', id, 'ifname', ifname]); + return activationReceipt(result, `activated ${id} on ${ifname}`); + } + + async deactivate(id: ConnectionId, ifname: DeviceIfname): Promise { + if (this.#activeUuidOn(ifname) !== id) { + return receipt('enabled', 'applied', `${id} not active on ${ifname}; nothing to deactivate`); + } + const result = this.#runner.run(['device', 'disconnect', ifname]); + return activationReceipt(result, `deactivated ${id} on ${ifname}`); + } + + async acquireQuiesceLease(id: ConnectionId, ifname: DeviceIfname): Promise { + if (this.#activeUuidOn(ifname) === id) { + this.#runner.run(['device', 'disconnect', ifname]); + } + return { connectionId: id, deviceIfname: ifname, acquiredAt: epochMillis(Date.now()) }; + } + + async releaseQuiesceLease(lease: QuiesceLease): Promise { + this.#runner.run(['connection', 'up', lease.connectionId, 'ifname', lease.deviceIfname]); + } + + #activeUuidOn(ifname: DeviceIfname): string | undefined { + const result = this.#runner.run(['-t', '-f', 'UUID,DEVICE', 'connection', 'show', '--active']); + for (const line of result.stdout.split('\n')) { + const [uuid, device] = line.split(':'); + if (device === ifname && uuid) { + return uuid; + } + } + return undefined; + } +} + +function activationReceipt(result: NmcliResult, appliedReason: string): Receipt { + return result.exitCode === 0 + ? receipt('enabled', 'applied', appliedReason) + : receipt('enabled', 'failed', result.stderr || 'nmcli returned a non-zero exit code'); +} + +function parseTerse(stdout: string): Map { + const settings = new Map(); + for (const line of stdout.split('\n')) { + const separator = line.indexOf(':'); + if (separator >= 0) { + settings.set(line.slice(0, separator), line.slice(separator + 1)); + } + } + return settings; +} + +function buildProfile(id: ConnectionId, settings: Map): GsmProfile { + const username = settings.get('gsm.username') ?? ''; + const password = settings.get('gsm.password') ?? ''; + const networkId = settings.get('gsm.network-id') ?? ''; + return { + connectionId: id, + connectionName: settings.get('connection.id') ?? '', + apn: settings.get('gsm.apn') ?? '', + homeOnly: settings.get('gsm.home-only') === 'yes', + autoConfig: settings.get('gsm.auto-config') === 'yes', + ...(username !== '' ? { username } : {}), + ...(password !== '' ? { password } : {}), + ...(networkId !== '' ? { networkId } : {}), + }; +} diff --git a/control/test-support/fake-nm/index.ts b/control/test-support/fake-nm/index.ts new file mode 100644 index 0000000..acdb6e4 --- /dev/null +++ b/control/test-support/fake-nm/index.ts @@ -0,0 +1,6 @@ +// Public surface of the fake NetworkManager harness: a stateful nmcli-runner stub and +// a `NetworkManagerPort` implemented over it. A4.1 injects the runner to assert its GSM +// write argv + readback; reconcile/observer tests use the port as an NM double. + +export { FakeNetworkManagerPort } from './fake-network-manager'; +export { type NmcliResult, StatefulNmcliRunner } from './nmcli-runner'; diff --git a/control/test-support/fake-nm/nmcli-runner.ts b/control/test-support/fake-nm/nmcli-runner.ts new file mode 100644 index 0000000..e8c4020 --- /dev/null +++ b/control/test-support/fake-nm/nmcli-runner.ts @@ -0,0 +1,292 @@ +// A STATEFUL `nmcli` stub: a test double that actually remembers what it was told. +// +// Unlike a mock that returns canned strings, this runs a tiny state machine over the +// real `nmcli` argv grammar and keeps profiles + active-device state, so a `connection +// show` after a `connection add`/`modify` reflects exactly what was written — real +// readback, the way A4.1's `NmcliNmPort` will assert its nine-field GSM writes. It is +// NOT the shipping adapter; it is the harness A4.1 injects in place of a live nmcli. +// +// Property keys are stored verbatim (dotted `gsm.apn`, `connection.autoconnect`, …), so +// every field a caller writes round-trips — the stub imposes no field whitelist. + +export interface NmcliResult { + readonly stdout: string; + readonly stderr: string; + readonly exitCode: number; +} + +interface Profile { + readonly uuid: string; + readonly settings: Map; +} + +const ok = (stdout: string): NmcliResult => ({ stdout, stderr: '', exitCode: 0 }); +const fail = (stderr: string): NmcliResult => ({ stdout: '', stderr, exitCode: 10 }); + +// nmcli shorthands used on `add`/`modify`, mapped to their canonical dotted keys. +const KEY_ALIASES: Readonly> = { + type: 'connection.type', + 'con-name': 'connection.id', + ifname: 'connection.interface-name', + autoconnect: 'connection.autoconnect', +}; + +const canonicalKey = (key: string): string => KEY_ALIASES[key] ?? key; + +let uuidCounter = 0; +const nextUuid = (): string => { + uuidCounter += 1; + return `fake-uuid-${uuidCounter.toString().padStart(4, '0')}`; +}; + +export class StatefulNmcliRunner { + readonly #profiles = new Map(); + readonly #active = new Map(); // ifname -> uuid + readonly #calls: string[][] = []; + + /** Every argv the runner has seen, in order — for call-order / spy assertions. */ + get calls(): readonly (readonly string[])[] { + return this.#calls; + } + + run(argv: readonly string[]): NmcliResult { + this.#calls.push([...argv]); + const { terse, fields, rest } = parseGlobals(argv); + const object = rest[0]; + const command = rest[1]; + const args = rest.slice(2); + if (object === 'connection' || object === 'c') { + return this.#connection(command, args, terse, fields); + } + if (object === 'device' || object === 'd') { + return this.#device(command, args); + } + return fail(`fake nmcli: unsupported object '${object ?? ''}'`); + } + + #connection( + command: string | undefined, + args: readonly string[], + terse: boolean, + fields: readonly string[] | undefined, + ): NmcliResult { + switch (command) { + case 'add': + return this.#add(args); + case 'modify': + case 'mod': + return this.#modify(args); + case 'delete': + case 'del': + return this.#delete(args); + case 'up': + return this.#up(args); + case 'down': + return this.#down(args); + case 'show': + return this.#show(args, terse, fields); + default: + return fail(`fake nmcli: unsupported connection command '${command ?? ''}'`); + } + } + + #device(command: string | undefined, args: readonly string[]): NmcliResult { + if (command === 'disconnect') { + const ifname = args[0]; + if (ifname === undefined) { + return fail('fake nmcli: device disconnect requires an ifname'); + } + this.#active.delete(ifname); + return ok(`Device '${ifname}' successfully disconnected.`); + } + return fail(`fake nmcli: unsupported device command '${command ?? ''}'`); + } + + #add(args: readonly string[]): NmcliResult { + const settings = new Map(); + applyPairs(settings, args); + const name = settings.get('connection.id') ?? 'unnamed'; + const uuid = nextUuid(); + this.#profiles.set(uuid, { uuid, settings }); + return ok(`Connection '${name}' (${uuid}) successfully added.`); + } + + #modify(args: readonly string[]): NmcliResult { + const profile = this.#resolve(args[0]); + if (profile === undefined) { + return fail(`fake nmcli: unknown connection '${args[0] ?? ''}'`); + } + applyPairs(profile.settings, args.slice(1)); + return ok(''); + } + + #delete(args: readonly string[]): NmcliResult { + const profile = this.#resolve(args[0]); + if (profile === undefined) { + return fail(`fake nmcli: unknown connection '${args[0] ?? ''}'`); + } + this.#profiles.delete(profile.uuid); + for (const [ifname, uuid] of this.#active) { + if (uuid === profile.uuid) { + this.#active.delete(ifname); + } + } + return ok(`Connection '${profile.uuid}' successfully deleted.`); + } + + #up(args: readonly string[]): NmcliResult { + const profile = this.#resolve(args[0]); + if (profile === undefined) { + return fail(`fake nmcli: unknown connection '${args[0] ?? ''}'`); + } + const ifname = valueAfter(args, 'ifname'); + if (ifname === undefined) { + return fail('fake nmcli: connection up requires `ifname `'); + } + this.#active.set(ifname, profile.uuid); + return ok(`Connection successfully activated (D-Bus active path: /fake/${profile.uuid})`); + } + + #down(args: readonly string[]): NmcliResult { + const profile = this.#resolve(args[0]); + if (profile === undefined) { + return fail(`fake nmcli: unknown connection '${args[0] ?? ''}'`); + } + for (const [ifname, uuid] of this.#active) { + if (uuid === profile.uuid) { + this.#active.delete(ifname); + } + } + return ok('Connection successfully deactivated'); + } + + #show( + args: readonly string[], + terse: boolean, + fields: readonly string[] | undefined, + ): NmcliResult { + const activeOnly = args.includes('--active'); + const id = args.find((arg) => arg !== '--active'); + if (id !== undefined) { + const profile = this.#resolve(id); + if (profile === undefined) { + return fail(`fake nmcli: unknown connection '${id}'`); + } + return ok(this.#dumpProfile(profile, terse, fields)); + } + const cols = fields ?? ['NAME', 'UUID', 'TYPE', 'DEVICE']; + if (activeOnly) { + // One row per ACTIVE (device, connection) instance — a profile up on two + // devices yields two rows, matching real `nmcli connection show --active`. + return ok(this.#activeRows(cols, terse)); + } + const lines = [...this.#profiles.values()].map((profile) => + cols.map((field) => this.#fieldValue(profile, field)).join(terse ? ':' : ' '), + ); + return ok(lines.join('\n')); + } + + #dumpProfile(profile: Profile, terse: boolean, fields: readonly string[] | undefined): string { + const keys = fields ?? [...profile.settings.keys()]; + const sep = terse ? ':' : ' : '; + return keys.map((key) => `${key}${sep}${this.#fieldValue(profile, key)}`).join('\n'); + } + + #fieldValue(profile: Profile, field: string): string { + switch (field) { + case 'NAME': + return profile.settings.get('connection.id') ?? ''; + case 'UUID': + return profile.uuid; + case 'TYPE': + return profile.settings.get('connection.type') ?? ''; + case 'DEVICE': + return this.#ifnameFor(profile.uuid) ?? ''; + default: + return profile.settings.get(field) ?? ''; + } + } + + #activeRows(cols: readonly string[], terse: boolean): string { + const lines: string[] = []; + for (const [ifname, uuid] of this.#active) { + const profile = this.#profiles.get(uuid); + if (profile === undefined) { + continue; + } + const row = cols.map((field) => + field === 'DEVICE' ? ifname : this.#fieldValue(profile, field), + ); + lines.push(row.join(terse ? ':' : ' ')); + } + return lines.join('\n'); + } + + #ifnameFor(uuid: string): string | undefined { + for (const [ifname, activeUuid] of this.#active) { + if (activeUuid === uuid) { + return ifname; + } + } + return undefined; + } + + #resolve(idOrUuid: string | undefined): Profile | undefined { + if (idOrUuid === undefined) { + return undefined; + } + const byUuid = this.#profiles.get(idOrUuid); + if (byUuid !== undefined) { + return byUuid; + } + return [...this.#profiles.values()].find( + (profile) => profile.settings.get('connection.id') === idOrUuid, + ); + } +} + +function parseGlobals(argv: readonly string[]): { + terse: boolean; + fields: readonly string[] | undefined; + rest: readonly string[]; +} { + let terse = false; + let fields: readonly string[] | undefined; + const rest: string[] = []; + for (let i = 0; i < argv.length; i += 1) { + const token = argv[i]; + if (token === '-t' || token === '--terse') { + terse = true; + } else if ( + token === '-f' || + token === '--fields' || + token === '-g' || + token === '--get-values' + ) { + if (token === '-g' || token === '--get-values') { + terse = true; + } + const value = argv[i + 1]; + fields = value ? value.split(',') : []; + i += 1; + } else { + rest.push(token as string); + } + } + return { terse, fields, rest }; +} + +function applyPairs(settings: Map, args: readonly string[]): void { + for (let i = 0; i < args.length; i += 2) { + const key = args[i]; + if (key === undefined) { + break; + } + settings.set(canonicalKey(key), args[i + 1] ?? ''); + } +} + +function valueAfter(args: readonly string[], token: string): string | undefined { + const index = args.indexOf(token); + return index >= 0 ? args[index + 1] : undefined; +} diff --git a/control/test-support/features-identity.test.ts b/control/test-support/features-identity.test.ts new file mode 100644 index 0000000..99c5afc --- /dev/null +++ b/control/test-support/features-identity.test.ts @@ -0,0 +1,131 @@ +// Feature detection + identity ladder against the MM-faithful fake, over the three +// property shapes the fake serves (1.20 / 1.22 / 1.24). Proves detection reads the +// REAL observed property set — Physdev present on 1.22+ and absent on 1.20 — and that +// the identity ladder resolves a stable slot from the tree's Device / Physdev values. +// +// Runs under `dbus-run-session -- bun test control/test-support`. + +import { afterEach, describe, expect, test } from 'bun:test'; +import { + detectModemFeatures, + type MmFeatures, + modemIdentityFactsFromTree, + resolveModemIdentity, +} from '../src/backend'; +import { logicalSlotId } from '../src/domain'; +import { createDbusTransport, type DbusTransport } from '../src/transport'; +import { + FakeModemManager, + fetchManagedObjects, + type MmShape, + type ModemSpec, + modemPath, +} from './fake-mm'; +import { hasSessionBus, sessionBusAddress, warnSkippedWithoutBus } from './session-bus'; + +warnSkippedWithoutBus('MM feature detection + identity ladder'); + +const VERSION_FOR: Record = { + '1.20': '1.20.0', + '1.22': '1.22.0', + '1.24': '1.24.0', +}; + +const modem = (index: number, device?: string): ModemSpec => ({ + index, + equipmentId: `49015420323751${index}`, + ...(device !== undefined ? { device } : {}), + sims: [ + { index, iccid: `890000000000000000${index}`, imsi: `00101000000000${index}`, active: true }, + ], +}); + +describe.skipIf(!hasSessionBus())('feature detection over 3 fake shapes', () => { + let fake: FakeModemManager; + let transport: DbusTransport; + + async function boot(shape: MmShape, modems: readonly ModemSpec[]): Promise { + const busAddress = sessionBusAddress(); + fake = await FakeModemManager.start({ busAddress, shape, modems }); + transport = createDbusTransport({ busAddress }); + await transport.connect(); + } + + afterEach(async () => { + await transport.disconnect(); + await fake.stop(); + }); + + async function detect(shape: MmShape): Promise { + await boot(shape, [modem(0)]); + const tree = await fetchManagedObjects(transport, fake.busName); + return detectModemFeatures(VERSION_FOR[shape], tree, modemPath(0)); + } + + test('1.20 shape ⇒ no physdev, basic cell info', async () => { + expect(await detect('1.20')).toEqual({ + physdev: false, + cellInfo: 'basic', + esimStatus: true, + opSerialization: true, + }); + }); + + test('1.22 shape ⇒ physdev present, rich cell info', async () => { + expect(await detect('1.22')).toEqual({ + physdev: true, + cellInfo: 'rich', + esimStatus: true, + opSerialization: true, + }); + }); + + test('1.24 shape ⇒ physdev present, rich cell info', async () => { + expect(await detect('1.24')).toEqual({ + physdev: true, + cellInfo: 'rich', + esimStatus: true, + opSerialization: true, + }); + }); +}); + +describe.skipIf(!hasSessionBus())('identity ladder against the observed tree', () => { + let fake: FakeModemManager; + let transport: DbusTransport; + + async function boot(shape: MmShape, modems: readonly ModemSpec[]): Promise { + const busAddress = sessionBusAddress(); + fake = await FakeModemManager.start({ busAddress, shape, modems }); + transport = createDbusTransport({ busAddress }); + await transport.connect(); + } + + afterEach(async () => { + await transport.disconnect(); + await fake.stop(); + }); + + test('a slot-* Device resolves via rung 1 with high confidence', async () => { + await boot('1.24', [modem(0, 'slot-usb2-1')]); + const tree = await fetchManagedObjects(transport, fake.busName); + const resolved = resolveModemIdentity(modemIdentityFactsFromTree(tree, modemPath(0))); + expect(resolved.slotSource).toBe('device-slot-uid'); + expect(resolved.identity.logicalSlotId).toBe(logicalSlotId('slot-usb2-1')); + }); + + test('a path-shaped Device on 1.24 falls to Physdev (rung 2)', async () => { + await boot('1.24', [modem(0)]); + const tree = await fetchManagedObjects(transport, fake.busName); + const resolved = resolveModemIdentity(modemIdentityFactsFromTree(tree, modemPath(0))); + expect(resolved.slotSource).toBe('physdev'); + expect(resolved.confidence).toBe('high'); + }); + + test('1.20 has no Physdev, so a path-shaped Device falls to equipment fallback', async () => { + await boot('1.20', [modem(0)]); + const tree = await fetchManagedObjects(transport, fake.busName); + const resolved = resolveModemIdentity(modemIdentityFactsFromTree(tree, modemPath(0))); + expect(resolved.slotSource).toBe('equipment-fallback'); + }); +}); diff --git a/control/test-support/mutations.test.ts b/control/test-support/mutations.test.ts new file mode 100644 index 0000000..800b477 --- /dev/null +++ b/control/test-support/mutations.test.ts @@ -0,0 +1,231 @@ +// D-Bus mutations against the MM-faithful fake — the mode/PIN/PUK/scan/slot cases, +// per-modem serialization proof (call log), and the zero-bearer-calls tripwire. +// +// Runs under `dbus-run-session -- bun test control/test-support`. + +import { afterEach, describe, expect, test } from 'bun:test'; +import { createMmDbusBackend, type MmDbusBackend } from '../src/backend'; +import { runtimePath } from '../src/domain'; +import type { ModemRef } from '../src/ports'; +import { createDbusTransport, type DbusTransport } from '../src/transport'; +import { + BUS_NAME, + FakeModemManager, + MM_LOCK_NONE, + MM_LOCK_SIM_PIN, + MM_LOCK_SIM_PUK, + type ModemSpec, + modemPath, + SIMPLE_IFACE, +} from './fake-mm'; +import { hasSessionBus, sessionBusAddress, warnSkippedWithoutBus } from './session-bus'; + +warnSkippedWithoutBus('D-Bus mutations'); + +const ref = (index: number): ModemRef => runtimePath(modemPath(index)) as ModemRef; + +const sim = (index: number) => ({ + index, + iccid: `890000000000000000${index}`, + imsi: `00101000000000${index}`, + active: true, +}); + +const modem = (index: number, extra: Partial = {}): ModemSpec => ({ + index, + sims: [sim(index)], + ...extra, +}); + +describe.skipIf(!hasSessionBus())('MmDbusBackend — mutations', () => { + let fake: FakeModemManager; + let transport: DbusTransport; + let backend: MmDbusBackend; + + async function boot(modems: readonly ModemSpec[]): Promise { + const busAddress = sessionBusAddress(); + fake = await FakeModemManager.start({ busAddress, modems }); + transport = createDbusTransport({ busAddress }); + backend = createMmDbusBackend({ transport }); + await backend.start(); + } + + afterEach(async () => { + await backend.stop(); + await transport.disconnect(); + await fake.stop(); + }); + + test('setRadioModes applies a radio-mode receipt', async () => { + await boot([modem(0)]); + const receipt = await backend.setRadioModes(ref(0), { preferenceOrdered: ['5gnr', 'lte'] }); + expect(receipt.dimension).toBe('radio'); + expect(receipt.status).toBe('applied'); + }); + + test('setPrimarySimSlot is unsupported on a single-slot modem', async () => { + await boot([modem(0)]); + const receipt = await backend.setPrimarySimSlot(ref(0), 1); + expect(receipt.status).toBe('unsupported'); + }); + + test('setPrimarySimSlot applies on a multi-slot modem', async () => { + await boot([{ index: 0, sims: [sim(0), sim(10)] }]); + const receipt = await backend.setPrimarySimSlot(ref(0), 2); + expect(receipt.status).toBe('applied'); + }); + + test('sendPin unlocks with the correct PIN', async () => { + await boot([ + modem(0, { unlockRequired: MM_LOCK_SIM_PIN, unlockRetries: [[MM_LOCK_SIM_PIN, 3]] }), + ]); + fake.expectPin(0, '0000'); + const result = await backend.sendPin(ref(0), '0000'); + expect(result.outcome).toBe('unlocked'); + }); + + test('sendPin reports incorrect-pin with remaining attempts (exactly once)', async () => { + await boot([ + modem(0, { unlockRequired: MM_LOCK_SIM_PIN, unlockRetries: [[MM_LOCK_SIM_PIN, 3]] }), + ]); + fake.expectPin(0, '0000'); + const result = await backend.sendPin(ref(0), '9999'); + expect(result.outcome).toBe('incorrect-pin'); + expect(result.remainingAttempts).toBe(2); + }); + + test('sendPin surfaces a PUK lock when the last PIN attempt is spent (never a resubmit)', async () => { + await boot([ + modem(0, { unlockRequired: MM_LOCK_SIM_PIN, unlockRetries: [[MM_LOCK_SIM_PIN, 1]] }), + ]); + fake.expectPin(0, '0000'); + const result = await backend.sendPin(ref(0), '9999'); + expect(result.outcome).toBe('sim-puk-required'); + }); + + test('sendPin on an unlocked SIM is a no-op unlocked receipt', async () => { + await boot([modem(0, { unlockRequired: MM_LOCK_NONE })]); + const result = await backend.sendPin(ref(0), '0000'); + expect(result.outcome).toBe('unlocked'); + }); + + test('sendPuk unblocks with the correct PUK', async () => { + await boot([ + modem(0, { unlockRequired: MM_LOCK_SIM_PUK, unlockRetries: [[MM_LOCK_SIM_PUK, 10]] }), + ]); + fake.expectPuk(0, '12345678'); + const result = await backend.sendPuk(ref(0), '12345678', '1111'); + expect(result.outcome).toBe('unlocked'); + }); + + test('sendPuk exhaustion permanently blocks the SIM (locked, zero remaining)', async () => { + await boot([ + modem(0, { unlockRequired: MM_LOCK_SIM_PUK, unlockRetries: [[MM_LOCK_SIM_PUK, 1]] }), + ]); + fake.expectPuk(0, '12345678'); + const result = await backend.sendPuk(ref(0), '00000000', '1111'); + expect(result.outcome).toBe('permanently-blocked'); + expect(result.remainingAttempts).toBe(0); + }); + + test('scanNetworks returns the configured operators', async () => { + await boot([modem(0)]); + fake.configureScan(0, [ + { operatorCode: '310260', operatorName: 'T-Mobile', availability: 2 }, + { operatorCode: '311480', operatorName: 'Verizon', availability: 1 }, + ]); + const result = await backend.scanNetworks(ref(0)); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.networks).toHaveLength(2); + expect(result.networks[0]).toMatchObject({ operatorCode: '310260', availability: 'current' }); + expect(result.networks[1]).toMatchObject({ + operatorName: 'Verizon', + availability: 'available', + }); + } + }); +}); + +describe.skipIf(!hasSessionBus())('MmDbusBackend — serialization + bearer safety', () => { + let fake: FakeModemManager; + let transport: DbusTransport; + let backend: MmDbusBackend; + + async function boot(modems: readonly ModemSpec[]): Promise { + const busAddress = sessionBusAddress(); + fake = await FakeModemManager.start({ busAddress, modems }); + transport = createDbusTransport({ busAddress }); + backend = createMmDbusBackend({ transport }); + await backend.start(); + } + + afterEach(async () => { + await backend.stop(); + await transport.disconnect(); + await fake.stop(); + }); + + test('two disruptive ops on the SAME modem serialize (no interleave)', async () => { + await boot([modem(0)]); + fake.setReplyDelay(80); + fake.clearLogs(); + await Promise.all([ + backend.setRadioModes(ref(0), { preferenceOrdered: ['lte'] }), + backend.setRadioModes(ref(0), { preferenceOrdered: ['5gnr'] }), + ]); + expect(fake.callLog).toEqual([ + 'SetCurrentModes:start:0', + 'SetCurrentModes:end:0', + 'SetCurrentModes:start:0', + 'SetCurrentModes:end:0', + ]); + }); + + test('concurrent mode-set + PIN on ONE modem serialize', async () => { + await boot([ + modem(0, { unlockRequired: MM_LOCK_SIM_PIN, unlockRetries: [[MM_LOCK_SIM_PIN, 3]] }), + ]); + fake.expectPin(0, '0000'); + fake.setReplyDelay(60); + fake.clearLogs(); + await Promise.all([ + backend.setRadioModes(ref(0), { preferenceOrdered: ['lte'] }), + backend.sendPin(ref(0), '0000'), + ]); + const log = fake.callLog; + expect(log.indexOf('SetCurrentModes:end:0')).toBeLessThan(log.indexOf('SendPin:start:0')); + }); + + test('disruptive ops on DIFFERENT modems run independently (overlap)', async () => { + await boot([modem(0), modem(1)]); + fake.setReplyDelay(100); + fake.clearLogs(); + await Promise.all([ + backend.setRadioModes(ref(0), { preferenceOrdered: ['lte'] }), + backend.setRadioModes(ref(1), { preferenceOrdered: ['lte'] }), + ]); + const log = fake.callLog; + const lastStart = Math.max( + log.indexOf('SetCurrentModes:start:0'), + log.indexOf('SetCurrentModes:start:1'), + ); + const firstEnd = Math.min( + log.indexOf('SetCurrentModes:end:0'), + log.indexOf('SetCurrentModes:end:1'), + ); + expect(lastStart).toBeLessThan(firstEnd); + }); + + test('the A2.3 bearer tripwire still fires — nothing here activates a bearer', async () => { + await boot([modem(0)]); + await expect( + transport.callMethod({ + destination: BUS_NAME, + path: modemPath(0), + interface: SIMPLE_IFACE, + member: 'Connect', + }), + ).rejects.toThrow(/TRIPWIRE/); + }); +}); diff --git a/control/test-support/nmcli-nm-port.test.ts b/control/test-support/nmcli-nm-port.test.ts new file mode 100644 index 0000000..51a2c65 --- /dev/null +++ b/control/test-support/nmcli-nm-port.test.ts @@ -0,0 +1,445 @@ +// The device-exact `NmcliNmPort` proven against A2.3's stateful nmcli runner. +// +// No D-Bus, no bus — the runner is a synchronous in-memory nmcli state machine, so this +// suite always runs. It asserts the FULL nine-field GSM write parity on create + both +// Auto-APN transition directions (with real stateful readback), the password-flags 0/4 +// convention, roaming ↔ network-id coupling, autoconnect/retries realism, verify-then- +// device-disconnect deactivation (never `connection down`), two-device isolation, +// abandoned-lease auto-release, and the activation-classification trio. + +import { describe, expect, test } from 'bun:test'; +import { + NmcliNmPort, + type NmcliResult, + parseNmVersion, + probeAutoApnCapability, +} from '../src/backend'; +import { connectionId, deviceIfname, type GsmProfileInput } from '../src/ports'; +import { StatefulNmcliRunner } from './fake-nm'; + +const MANUAL: GsmProfileInput = { + connectionName: 'cell-roam', + apn: 'internet', + username: 'u', + password: 'p', + homeOnly: false, + autoConfig: false, + networkId: '310410', +}; + +const READBACK = [ + 'connection.id', + 'gsm.apn', + 'gsm.username', + 'gsm.password', + 'gsm.password-flags', + 'gsm.home-only', + 'gsm.network-id', + 'gsm.auto-config', + 'connection.autoconnect', + 'connection.autoconnect-retries', +].join(','); + +const reject = (stderr: string, exitCode = 10): NmcliResult => ({ stdout: '', stderr, exitCode }); + +/** Pairs from an add/modify argv, starting past the leading verb tokens. */ +function argvPairs(argv: readonly string[], start: number): Map { + const map = new Map(); + for (let i = start; i + 1 < argv.length; i += 2) { + const key = argv[i]; + if (key === undefined) { + break; + } + map.set(key, argv[i + 1] ?? ''); + } + return map; +} + +const addCall = (runner: StatefulNmcliRunner): readonly string[] => + runner.calls.find((c) => c[0] === 'connection' && c[1] === 'add') ?? []; + +const showFields = (runner: StatefulNmcliRunner, id: string): Map => { + const out = runner.run(['-t', '-f', READBACK, 'connection', 'show', id]).stdout; + const map = new Map(); + for (const line of out.split('\n')) { + const at = line.indexOf(':'); + if (at >= 0) { + map.set(line.slice(0, at), line.slice(at + 1)); + } + } + return map; +}; + +/** Emulates NM's real rejection of `auto-config yes` with any credential still set. */ +function violatesAutoConfig(argv: readonly string[]): boolean { + const pairs = argvPairs(argv, 3); + if (pairs.get('gsm.auto-config') !== 'yes') { + return false; + } + return ['gsm.apn', 'gsm.username', 'gsm.password'].some((k) => (pairs.get(k) ?? '') !== ''); +} + +class ValidatingRunner { + readonly inner = new StatefulNmcliRunner(); + readonly seen: string[][] = []; + #failNext = false; + + failNextModify(): void { + this.#failNext = true; + } + + run(argv: readonly string[]): NmcliResult { + this.seen.push([...argv]); + if (argv[0] === 'connection' && (argv[1] === 'modify' || argv[1] === 'mod')) { + if (this.#failNext) { + this.#failNext = false; + return reject('nmcli: modify rejected (test)'); + } + if (violatesAutoConfig(argv)) { + return reject('gsm.auto-config: mutually exclusive with APN/username/password'); + } + } + return this.inner.run(argv); + } +} + +class ScriptedUpRunner { + readonly inner = new StatefulNmcliRunner(); + #up: NmcliResult | undefined; + + scriptUp(result: NmcliResult): void { + this.#up = result; + } + + run(argv: readonly string[]): NmcliResult { + if (argv[0] === 'connection' && argv[1] === 'up' && this.#up !== undefined) { + const scripted = this.#up; + this.#up = undefined; + return scripted; + } + return this.inner.run(argv); + } +} + +describe('NmcliNmPort — nine-field GSM write parity on create', () => { + test('every field is written with its exact convention (roaming on, password set)', async () => { + const runner = new StatefulNmcliRunner(); + const port = new NmcliNmPort({ runner }); + const created = await port.createGsmProfile(MANUAL); + + const pairs = argvPairs(addCall(runner), 2); + expect(pairs.get('type')).toBe('gsm'); + expect(pairs.get('con-name')).toBe('cell-roam'); + expect(pairs.get('gsm.apn')).toBe('internet'); + expect(pairs.get('gsm.username')).toBe('u'); + expect(pairs.get('gsm.password')).toBe('p'); + expect(pairs.get('gsm.password-flags')).toBe('0'); + expect(pairs.get('gsm.home-only')).toBe('no'); + expect(pairs.get('gsm.network-id')).toBe('310410'); + expect(pairs.get('gsm.auto-config')).toBe('no'); + expect(pairs.get('connection.autoconnect')).toBe('yes'); + expect(pairs.get('connection.autoconnect-retries')).toBe('2'); + + expect(created.apn).toBe('internet'); + expect(created.networkId).toBe('310410'); + expect(created.homeOnly).toBe(false); + }); + + test('password-flags is "4" and creds are "" when no password is set (Bun empty-arg quirk)', async () => { + const runner = new StatefulNmcliRunner(); + const port = new NmcliNmPort({ runner }); + await port.createGsmProfile({ + connectionName: 'c', + apn: 'iot', + homeOnly: true, + autoConfig: false, + }); + + const pairs = argvPairs(addCall(runner), 2); + expect(pairs.get('gsm.password-flags')).toBe('4'); + expect(pairs.get('gsm.password')).toBe(''); + expect(pairs.get('gsm.username')).toBe(''); + }); + + test('created profiles carry autoconnect + retries (bench replug realism)', async () => { + const runner = new StatefulNmcliRunner(); + const port = new NmcliNmPort({ runner }); + const created = await port.createGsmProfile(MANUAL); + + const fields = showFields(runner, created.connectionId); + expect(fields.get('connection.autoconnect')).toBe('yes'); + expect(fields.get('connection.autoconnect-retries')).toBe('2'); + }); +}); + +describe('NmcliNmPort — network-id tracks roaming', () => { + test('network-id is set while roaming and cleared when roaming turns off', async () => { + const runner = new StatefulNmcliRunner(); + const port = new NmcliNmPort({ runner }); + const created = await port.createGsmProfile(MANUAL); + expect(showFields(runner, created.connectionId).get('gsm.network-id')).toBe('310410'); + + await port.updateGsmProfile(created.connectionId, { homeOnly: true }); + const off = showFields(runner, created.connectionId); + expect(off.get('gsm.network-id')).toBe(''); + expect(off.get('gsm.home-only')).toBe('yes'); + + await port.updateGsmProfile(created.connectionId, { homeOnly: false, networkId: '260010' }); + const on = showFields(runner, created.connectionId); + expect(on.get('gsm.network-id')).toBe('260010'); + expect(on.get('gsm.home-only')).toBe('no'); + }); +}); + +describe('NmcliNmPort — atomic Auto-APN transitions (validating NM runner)', () => { + test('manual → auto: ONE atomic modify clears creds + sets auto-config; readback correct', async () => { + const runner = new ValidatingRunner(); + const port = new NmcliNmPort({ runner }); + const created = await port.createGsmProfile(MANUAL); + const ifname = deviceIfname('wwan0'); + + const result = await port.transitionToAuto(created.connectionId, ifname); + expect(result.receipt.status).toBe('applied'); + + const modifies = runner.seen.filter((c) => c[0] === 'connection' && c[1] === 'modify'); + expect(modifies).toHaveLength(1); + const pairs = argvPairs(modifies[0] ?? [], 3); + expect(pairs.get('gsm.apn')).toBe(''); + expect(pairs.get('gsm.username')).toBe(''); + expect(pairs.get('gsm.password')).toBe(''); + expect(pairs.get('gsm.password-flags')).toBe('4'); + expect(pairs.get('gsm.auto-config')).toBe('yes'); + + const fields = showFields(runner.inner, created.connectionId); + expect(fields.get('gsm.auto-config')).toBe('yes'); + expect(fields.get('gsm.apn')).toBe(''); + expect(fields.get('gsm.password-flags')).toBe('4'); + }); + + test('auto → manual: exact-reverse ONE atomic modify restores creds; readback correct', async () => { + const runner = new ValidatingRunner(); + const port = new NmcliNmPort({ runner }); + const created = await port.createGsmProfile({ + connectionName: 'auto', + apn: '', + homeOnly: true, + autoConfig: true, + }); + const ifname = deviceIfname('wwan0'); + + const result = await port.transitionToManual(created.connectionId, ifname, { + apn: 'internet', + username: 'u', + password: 'p', + }); + expect(result.receipt.status).toBe('applied'); + + const modifies = runner.seen.filter((c) => c[0] === 'connection' && c[1] === 'modify'); + expect(modifies).toHaveLength(1); + const pairs = argvPairs(modifies[0] ?? [], 3); + expect(pairs.get('gsm.apn')).toBe('internet'); + expect(pairs.get('gsm.password-flags')).toBe('0'); + expect(pairs.get('gsm.auto-config')).toBe('no'); + + const fields = showFields(runner.inner, created.connectionId); + expect(fields.get('gsm.apn')).toBe('internet'); + expect(fields.get('gsm.auto-config')).toBe('no'); + }); + + test('a rejected modify leaves the profile byte-unchanged', async () => { + const runner = new ValidatingRunner(); + const port = new NmcliNmPort({ runner }); + const created = await port.createGsmProfile(MANUAL); + const before = runner.inner.run([ + '-t', + '-f', + READBACK, + 'connection', + 'show', + created.connectionId, + ]).stdout; + + runner.failNextModify(); + const result = await port.transitionToAuto(created.connectionId, deviceIfname('wwan0')); + expect(result.receipt.status).toBe('failed'); + + const after = runner.inner.run([ + '-t', + '-f', + READBACK, + 'connection', + 'show', + created.connectionId, + ]).stdout; + expect(after).toBe(before); + expect(runner.seen.some((c) => c[0] === 'connection' && c[1] === 'up')).toBe(false); + }); + + test('boot capability gate: NM too old → unsupported + advisory, no modify issued', async () => { + const runner = new ValidatingRunner(); + const port = new NmcliNmPort({ runner, autoApnCapable: false }); + const created = await port.createGsmProfile(MANUAL); + + const result = await port.transitionToAuto(created.connectionId, deviceIfname('wwan0')); + expect(result.receipt.status).toBe('unsupported'); + expect(result.advisory).toBe('autoApnUnavailable'); + expect(runner.seen.some((c) => c[0] === 'connection' && c[1] === 'modify')).toBe(false); + }); +}); + +describe('NmcliNmPort — device-exact deactivation + two-device isolation', () => { + test('quiescing one device leaves a shared profile active on the other, ifname per call', async () => { + const runner = new StatefulNmcliRunner(); + const port = new NmcliNmPort({ runner }); + const created = await port.createGsmProfile(MANUAL); + const devA = deviceIfname('wwan0'); + const devB = deviceIfname('wwan1'); + await port.activate(created.connectionId, devA); + await port.activate(created.connectionId, devB); + + const before = runner.run(['-t', '-f', 'UUID,DEVICE', 'connection', 'show', '--active']).stdout; + expect(before).toContain(`${created.connectionId}:wwan0`); + expect(before).toContain(`${created.connectionId}:wwan1`); + + const down = await port.deactivate(created.connectionId, devA); + expect(down.status).toBe('applied'); + + const after = runner.run(['-t', '-f', 'UUID,DEVICE', 'connection', 'show', '--active']).stdout; + expect(after).not.toContain('wwan0'); + expect(after).toContain(`${created.connectionId}:wwan1`); + + expect(runner.calls).toContainEqual(['device', 'disconnect', 'wwan0']); + expect(runner.calls.some((c) => c[0] === 'connection' && c[1] === 'down')).toBe(false); + }); + + test('deactivate is a no-op when the id is not active on the requested ifname', async () => { + const runner = new StatefulNmcliRunner(); + const port = new NmcliNmPort({ runner }); + const created = await port.createGsmProfile(MANUAL); + await port.activate(created.connectionId, deviceIfname('wwan0')); + + const down = await port.deactivate(created.connectionId, deviceIfname('wwan1')); + expect(down.status).toBe('applied'); + expect(runner.calls).not.toContainEqual(['device', 'disconnect', 'wwan1']); + }); +}); + +describe('NmcliNmPort — quiesce lease lifecycle', () => { + test('an abandoned lease auto-releases and reactivates on sweep', async () => { + let clock = 1_000; + const runner = new StatefulNmcliRunner(); + const port = new NmcliNmPort({ runner, leaseTtlMs: 5_000, now: () => clock }); + const created = await port.createGsmProfile(MANUAL); + const ifname = deviceIfname('wwan0'); + await port.activate(created.connectionId, ifname); + + await port.acquireQuiesceLease(created.connectionId, ifname); + expect(runner.run(['-t', '-f', 'UUID,DEVICE', 'connection', 'show', '--active']).stdout).toBe( + '', + ); + + clock = 1_000 + 4_000; + await port.sweepExpiredLeases(); + expect(runner.run(['-t', '-f', 'UUID,DEVICE', 'connection', 'show', '--active']).stdout).toBe( + '', + ); + + clock = 1_000 + 5_001; + await port.sweepExpiredLeases(); + expect(runner.run(['-t', '-f', 'UUID,DEVICE', 'connection', 'show', '--active']).stdout).toBe( + `${created.connectionId}:wwan0`, + ); + }); + + test('explicit release reactivates and a second release is a no-op', async () => { + const runner = new StatefulNmcliRunner(); + const port = new NmcliNmPort({ runner }); + const created = await port.createGsmProfile(MANUAL); + const ifname = deviceIfname('wwan0'); + await port.activate(created.connectionId, ifname); + + const lease = await port.acquireQuiesceLease(created.connectionId, ifname); + await port.releaseQuiesceLease(lease); + const ups = runner.calls.filter((c) => c[0] === 'connection' && c[1] === 'up').length; + await port.releaseQuiesceLease(lease); + expect(runner.calls.filter((c) => c[0] === 'connection' && c[1] === 'up')).toHaveLength(ups); + }); +}); + +describe('NmcliNmPort — activation-result classification trio', () => { + test('GSM_APN_FAILED under an auto profile → unsupported + autoApnUnavailable', async () => { + const runner = new ScriptedUpRunner(); + const port = new NmcliNmPort({ runner }); + const created = await port.createGsmProfile({ + connectionName: 'auto', + apn: '', + homeOnly: true, + autoConfig: true, + }); + runner.scriptUp(reject('Error: Connection activation failed: GSM_APN_FAILED', 4)); + + const result = await port.transitionToAuto(created.connectionId, deviceIfname('wwan0')); + expect(result.receipt.status).toBe('unsupported'); + expect(result.advisory).toBe('autoApnUnavailable'); + }); + + test('any other activation error → failed', async () => { + const runner = new ScriptedUpRunner(); + const port = new NmcliNmPort({ runner }); + const created = await port.createGsmProfile(MANUAL); + runner.scriptUp(reject('Error: Connection activation failed: no valid secrets', 4)); + + const result = await port.transitionToManual(created.connectionId, deviceIfname('wwan0'), { + apn: 'internet', + username: 'u', + password: 'p', + }); + expect(result.receipt.status).toBe('failed'); + expect(result.advisory).toBeUndefined(); + }); + + test('activation wait timeout → pending', async () => { + const runner = new ScriptedUpRunner(); + const port = new NmcliNmPort({ runner }); + const created = await port.createGsmProfile({ + connectionName: 'auto', + apn: '', + homeOnly: true, + autoConfig: true, + }); + runner.scriptUp(reject('Error: Timeout expired (90 seconds)', 3)); + + const result = await port.transitionToAuto(created.connectionId, deviceIfname('wwan0')); + expect(result.receipt.status).toBe('pending'); + expect(result.advisory).toBeUndefined(); + }); +}); + +describe('Auto-APN capability probe', () => { + test('parses the nmcli version banner', () => { + expect(parseNmVersion('nmcli tool, version 1.42.4')).toEqual({ major: 1, minor: 42 }); + expect(parseNmVersion('garbage')).toBeNull(); + }); + + test('probe resolves capability from a --version answer (>=1.22 capable, older not)', async () => { + const capable = { + run: (argv: readonly string[]) => + argv[0] === '--version' + ? { stdout: 'nmcli tool, version 1.42.4', stderr: '', exitCode: 0 } + : { stdout: '', stderr: '', exitCode: 0 }, + }; + const old = { + run: (argv: readonly string[]) => + argv[0] === '--version' + ? { stdout: 'nmcli tool, version 1.20.6', stderr: '', exitCode: 0 } + : { stdout: '', stderr: '', exitCode: 0 }, + }; + expect(await probeAutoApnCapability(capable)).toBe(true); + expect(await probeAutoApnCapability(old)).toBe(false); + }); + + test('an unknown connection id reads back as undefined', async () => { + const port = new NmcliNmPort({ runner: new StatefulNmcliRunner() }); + expect(await port.readGsmProfile(connectionId('nope'))).toBeUndefined(); + }); +}); diff --git a/control/test-support/observer.test.ts b/control/test-support/observer.test.ts new file mode 100644 index 0000000..be6b4f0 --- /dev/null +++ b/control/test-support/observer.test.ts @@ -0,0 +1,236 @@ +// Epoch-scoped observer — lifecycle correctness against the A2.3 fake MM service. +// +// Proves the safety-critical contract (draft §Oracle round-3 #5): a modem is REMOVED +// only by omission from a current-epoch authoritative snapshot; owner loss, bus epoch +// change, and old-epoch straggler signals only ever mark it `sourceUnavailable`, never +// remove it. Runs under `dbus-run-session -- bun test control/test-support`. + +import { afterEach, describe, expect, test } from 'bun:test'; +import { createMmDbusObserver, type MmDbusObserver } from '../src/backend'; +import type { CellularSnapshot } from '../src/domain'; +import type { ObservationList } from '../src/ports'; +import { createDbusTransport, type DbusTransport } from '../src/transport'; +import { FakeModemManager, MODEM_IFACE, type ModemSpec, modemPath } from './fake-mm'; +import { hasSessionBus, sessionBusAddress, warnSkippedWithoutBus } from './session-bus'; + +warnSkippedWithoutBus('epoch-scoped MM observer'); + +const sleep = (ms: number): Promise => new Promise((resolve) => setTimeout(resolve, ms)); + +async function waitFor(predicate: () => boolean, timeoutMs = 4000): Promise { + const deadline = Date.now() + timeoutMs; + while (!predicate()) { + if (Date.now() > deadline) { + throw new Error('waitFor timed out'); + } + await sleep(5); + } +} + +const spec = (index: number): ModemSpec => ({ + index, + sims: [ + { + index, + iccid: `890000000000000000${index}`, + imsi: `00101000000000${index}`, + active: true, + }, + ], +}); + +const rowFor = (list: ObservationList | undefined, index: number): CellularSnapshot | undefined => + list?.rows.find((row) => row.identity.runtimePath === modemPath(index)); + +const hasRow = (list: ObservationList | undefined, index: number): boolean => + rowFor(list, index) !== undefined; + +describe.skipIf(!hasSessionBus())('MmDbusObserver — epoch-scoped lifecycle', () => { + let fake: FakeModemManager; + let transport: DbusTransport; + let observer: MmDbusObserver; + let lists: ObservationList[]; + + const latest = (): ObservationList | undefined => lists.at(-1); + + async function boot(modems: readonly ModemSpec[]): Promise { + const busAddress = sessionBusAddress(); + fake = await FakeModemManager.start({ busAddress, modems }); + transport = createDbusTransport({ busAddress }); + observer = createMmDbusObserver({ transport }); + lists = []; + observer.observe((list) => lists.push(list)); + } + + afterEach(async () => { + await observer.stop(); + await transport.disconnect(); + await fake.stop(); + }); + + test('start() returns the first authoritative list; revisions are monotonic', async () => { + await boot([spec(0)]); + const first = await observer.start(); + expect(first.ok).toBe(true); + expect(hasRow(first, 0)).toBe(true); + const row = rowFor(first, 0); + expect(row?.presence).toBe('present'); + expect(row?.sourceHealth).toBe('live'); + expect(row?.revision).toBeGreaterThan(0); + }); + + test('churn ×50 add/remove cycles keep the base modem; revisions never regress', async () => { + await boot([spec(0)]); + await observer.start(); + await waitFor(() => hasRow(latest(), 0)); + + for (let cycle = 0; cycle < 50; cycle += 1) { + fake.addModem(spec(1)); + await waitFor(() => hasRow(latest(), 1)); + fake.removeModem(1); + await waitFor(() => !hasRow(latest(), 1)); + // The base modem is NEVER removed by the churn of its neighbour. + expect(hasRow(latest(), 0)).toBe(true); + } + + const lastRevByPath = new Map(); + for (const list of lists) { + for (const r of list.rows) { + const prev = lastRevByPath.get(r.identity.runtimePath) ?? 0; + expect(r.revision).toBeGreaterThanOrEqual(prev); + lastRevByPath.set(r.identity.runtimePath, r.revision); + } + } + expect(hasRow(latest(), 0)).toBe(true); + expect(hasRow(latest(), 1)).toBe(false); + }, 30_000); + + test('owner loss → sourceUnavailable (rows retained), reclaim → restored; zero removals', async () => { + await boot([spec(0)]); + await observer.start(); + await waitFor(() => hasRow(latest(), 0)); + + await fake.dropName(); + await waitFor(() => latest()?.ok === false); + const dropped = latest(); + expect(dropped?.ok).toBe(false); + // The whole point: the modem is RETAINED, just stale — never removed. + expect(hasRow(dropped, 0)).toBe(true); + expect(rowFor(dropped, 0)?.sourceHealth).toBe('sourceUnavailable'); + + await fake.reclaimName(); + await waitFor(() => latest()?.ok === true && rowFor(latest(), 0)?.sourceHealth === 'live'); + expect(hasRow(latest(), 0)).toBe(true); + }); + + test('restart mid-poll → zero removals, exactly one unavailable→restored cycle', async () => { + await boot([spec(0)]); + await observer.start(); + await waitFor(() => hasRow(latest(), 0)); + const startLen = lists.length; + + await fake.restart(); + // The unavailable state is transient — assert it appeared in the emission history, + // then wait for the restore. (Polling `latest()` can skip a fast transient.) + await waitFor(() => + lists.slice(startLen).some((l) => rowFor(l, 0)?.sourceHealth === 'sourceUnavailable'), + ); + await waitFor(() => latest()?.ok === true && rowFor(latest(), 0)?.sourceHealth === 'live'); + + // The modem is present in every emission after the restart — never removed. + for (const list of lists.slice(startLen)) { + expect(hasRow(list, 0)).toBe(true); + } + const healths = lists.slice(startLen).map((l) => rowFor(l, 0)?.sourceHealth); + expect(healths).toContain('sourceUnavailable'); + expect(healths.at(-1)).toBe('live'); + }); + + test('an invalidated PropertiesChanged is reconciled, not dropped', async () => { + await boot([spec(0)]); + await observer.start(); + await waitFor(() => hasRow(latest(), 0)); + + // replaceSim changes the SERVED tree (new ICCID) and emits an invalidated + // PropertiesChanged on the modem's `Sim` — the observer must re-read and reconcile. + const newIccid = '8900000000000000999'; + fake.replaceSim(0, { index: 0, iccid: newIccid, imsi: '001010000000999', active: true }); + await waitFor(() => String(rowFor(latest(), 0)?.identity.subscriptionId) === newIccid); + expect(hasRow(latest(), 0)).toBe(true); + + // A pure invalidated-only signal is handled gracefully: no removal, still ok. + fake.invalidateProperties(modemPath(0), MODEM_IFACE, ['State']); + await sleep(60); + expect(hasRow(latest(), 0)).toBe(true); + expect(latest()?.ok).toBe(true); + }); + + test('subscribe-before-snapshot race: a modem added during the initial poll is not lost', async () => { + await boot([spec(0)]); + fake.setReplyDelay(120); + const starting = observer.start(); + await sleep(25); + fake.addModem(spec(1)); + await starting; + fake.setReplyDelay(0); + await waitFor(() => hasRow(latest(), 0) && hasRow(latest(), 1)); + expect(latest()?.ok).toBe(true); + }); + + test('late reply from a superseded epoch is discarded, not applied', async () => { + await boot([spec(0)]); + await observer.start(); + await waitFor(() => hasRow(latest(), 0)); + + fake.setReplyDelay(150); + fake.changeProperties(modemPath(0), MODEM_IFACE, [['State', ['i', 7]]]); + await sleep(25); + const previous = await fake.restartRetainingPrevious(); + fake.setReplyDelay(0); + + await waitFor(() => rowFor(latest(), 0)?.sourceHealth === 'sourceUnavailable'); + await waitFor(() => latest()?.ok === true && rowFor(latest(), 0)?.sourceHealth === 'live'); + await sleep(200); + // The old-epoch late reply must not have removed or corrupted the modem. + expect(hasRow(latest(), 0)).toBe(true); + expect(latest()?.ok).toBe(true); + await previous.stop(); + }); + + test('order 1 — removed-then-restart: removal sticks, survivor restores, no resurrection', async () => { + await boot([spec(0), spec(1)]); + await observer.start(); + await waitFor(() => hasRow(latest(), 0) && hasRow(latest(), 1)); + + fake.removeModem(1); + await waitFor(() => !hasRow(latest(), 1)); + const startLen = lists.length; + + await fake.restart(); + await waitFor(() => + lists.slice(startLen).some((l) => rowFor(l, 0)?.sourceHealth === 'sourceUnavailable'), + ); + await waitFor(() => latest()?.ok === true && rowFor(latest(), 0)?.sourceHealth === 'live'); + + expect(hasRow(latest(), 0)).toBe(true); + // The legitimately-removed modem is NOT resurrected by the restart. + expect(hasRow(latest(), 1)).toBe(false); + }); + + test('order 2 — restart-then-late-old-owner-removed: stale removal is IGNORED', async () => { + await boot([spec(0)]); + await observer.start(); + await waitFor(() => hasRow(latest(), 0)); + + const previous = await fake.restartRetainingPrevious(); + await waitFor(() => latest()?.ok === true && rowFor(latest(), 0)?.sourceHealth === 'live'); + expect(hasRow(latest(), 0)).toBe(true); + + // An InterfacesRemoved from the OLD owner arrives late — it must be ignored. + previous.removeModem(0); + await sleep(150); + expect(hasRow(latest(), 0)).toBe(true); + expect(rowFor(latest(), 0)?.sourceHealth).toBe('live'); + await previous.stop(); + }); +}); diff --git a/control/test-support/restart-semantics.test.ts b/control/test-support/restart-semantics.test.ts new file mode 100644 index 0000000..afdb83c --- /dev/null +++ b/control/test-support/restart-semantics.test.ts @@ -0,0 +1,141 @@ +// Restart / lifecycle semantics — the QA-failure scenario A3.1 will build against. +// +// When the MM service loses or regains bus ownership, a correct consumer must treat it +// as a source becoming unavailable, NOT as the modems being removed. This proves the +// FAKE emits the right signals for that: dropping the name produces a real daemon +// `NameOwnerChanged` (owner → "") with ZERO spurious `InterfacesRemoved`, reclaiming it +// produces `NameOwnerChanged` ("" → owner), and a full restart hands the name to a NEW +// owner (a new epoch) — still without any bogus removal. Add/remove drive the +// `InterfacesAdded` / `InterfacesRemoved` object-lifecycle signals. Run under +// `dbus-run-session`. (A3.1 owns the CONSUMER logic; this task proves the emissions.) + +import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; +import { createDbusTransport, type DbusTransport, type SignalEvent } from '../src/transport'; +import { + BUS_NAME, + bearerPath, + FakeModemManager, + type ModemSpec, + modemPath, + OBJECT_MANAGER_IFACE, + simPath, +} from './fake-mm'; +import { hasSessionBus, sessionBusAddress, warnSkippedWithoutBus } from './session-bus'; + +warnSkippedWithoutBus('MM fake restart semantics'); + +const DBUS_IFACE = 'org.freedesktop.DBus'; + +const MODEM_0: ModemSpec = { + index: 0, + sims: [{ index: 0, iccid: '8900000000000000001', imsi: '001010000000001', active: true }], +}; + +const sleep = (ms: number): Promise => new Promise((resolve) => setTimeout(resolve, ms)); + +async function waitFor(predicate: () => boolean, timeoutMs = 2000): Promise { + const deadline = Date.now() + timeoutMs; + while (!predicate()) { + if (Date.now() > deadline) { + throw new Error('waitFor timed out'); + } + await sleep(5); + } +} + +const paths = (events: readonly SignalEvent[]): unknown[] => events.map((event) => event.body[0]); + +describe.skipIf(!hasSessionBus())('MM fake — restart / name-ownership semantics', () => { + let fake: FakeModemManager; + let transport: DbusTransport; + let nameEvents: SignalEvent[]; + let removedEvents: SignalEvent[]; + let addedEvents: SignalEvent[]; + + beforeEach(async () => { + const busAddress = sessionBusAddress(); + fake = await FakeModemManager.start({ busAddress, modems: [MODEM_0] }); + transport = createDbusTransport({ busAddress }); + await transport.connect(); + nameEvents = []; + removedEvents = []; + addedEvents = []; + await transport.subscribeSignal( + { interface: DBUS_IFACE, member: 'NameOwnerChanged' }, + (event) => { + if (event.body[0] === BUS_NAME) { + nameEvents.push(event); + } + }, + ); + await transport.subscribeSignal( + { interface: OBJECT_MANAGER_IFACE, member: 'InterfacesRemoved' }, + (event) => removedEvents.push(event), + ); + await transport.subscribeSignal( + { interface: OBJECT_MANAGER_IFACE, member: 'InterfacesAdded' }, + (event) => addedEvents.push(event), + ); + }); + + afterEach(async () => { + await transport.disconnect(); + await fake.stop(); + }); + + test('dropping the name emits NameOwnerChanged (owner → "") with ZERO removals', async () => { + const owner = fake.uniqueName; + await fake.dropName(); + await waitFor(() => nameEvents.some((event) => event.body[2] === '')); + + const lost = nameEvents.find((event) => event.body[2] === ''); + expect(lost?.body[0]).toBe(BUS_NAME); + expect(lost?.body[1]).toBe(owner); + // The whole point: name loss is NOT a modem removal. + await sleep(50); + expect(removedEvents).toHaveLength(0); + }); + + test('reclaiming the name emits NameOwnerChanged ("" → owner)', async () => { + await fake.dropName(); + await waitFor(() => nameEvents.some((event) => event.body[2] === '')); + await fake.reclaimName(); + await waitFor(() => nameEvents.some((event) => event.body[1] === '' && event.body[2] !== '')); + + const regained = nameEvents.find((event) => event.body[1] === '' && event.body[2] !== ''); + expect(regained?.body[0]).toBe(BUS_NAME); + expect(regained?.body[2]).toBe(fake.uniqueName); + expect(removedEvents).toHaveLength(0); + }); + + test('a full restart hands the name to a NEW owner with ZERO removals', async () => { + const before = fake.uniqueName; + await fake.restart(); + const after = fake.uniqueName; + expect(after).not.toBe(before); + await waitFor(() => nameEvents.some((event) => event.body[2] === after)); + + const handoff = nameEvents.find((event) => event.body[2] === after); + expect(handoff?.body[0]).toBe(BUS_NAME); + await sleep(50); + expect(removedEvents).toHaveLength(0); + }); + + test('add / remove drive InterfacesAdded / InterfacesRemoved for every object', async () => { + const modem1: ModemSpec = { + index: 1, + sims: [{ index: 1, iccid: '8900000000000000002', imsi: '001010000000002', active: true }], + }; + fake.addModem(modem1); + await waitFor(() => addedEvents.length >= 3); + expect(paths(addedEvents)).toEqual( + expect.arrayContaining([modemPath(1), simPath(1), bearerPath(1)]), + ); + + fake.removeModem(1); + await waitFor(() => removedEvents.length >= 3); + expect(paths(removedEvents)).toEqual( + expect.arrayContaining([modemPath(1), simPath(1), bearerPath(1)]), + ); + }); +}); diff --git a/control/test-support/self-test.test.ts b/control/test-support/self-test.test.ts new file mode 100644 index 0000000..9235a97 --- /dev/null +++ b/control/test-support/self-test.test.ts @@ -0,0 +1,205 @@ +// Self-test proving the harness itself models the REAL ModemManager object tree. +// +// It drives the fake through the production transport (the same seam A3.x uses) and +// walks ObjectManager → modem → SIM → Modem3gpp, asserting the architectural facts +// review insisted on: `Modem` and `Modem.Modem3gpp` are SEPARATE interfaces, SIMs are +// SEPARATE `/SIM/` objects reached via the modem's `Sim` path, the bearer is +// observable but every connect method throws the tripwire, and the 1.20 vs 1.24 +// property shapes differ exactly by `Physdev`. Run under `dbus-run-session`. + +import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; +import { createDbusTransport, type DbusTransport, type SignalEvent } from '../src/transport'; +import { + BEARER_IFACE, + bearerPath, + FakeModemManager, + fetchManagedObjects, + findInterface, + followObjectPath, + hasInterface, + interfaceNames, + MODEM_IFACE, + MODEM3GPP_IFACE, + type ModemSpec, + modemPath, + pathsWithInterface, + propValue, + SIM_IFACE, + SIMPLE_IFACE, + simPath, +} from './fake-mm'; +import { hasSessionBus, sessionBusAddress, warnSkippedWithoutBus } from './session-bus'; + +warnSkippedWithoutBus('MM-faithful fake self-test'); + +const PROPERTIES_IFACE = 'org.freedesktop.DBus.Properties'; + +const MODEM_0: ModemSpec = { + index: 0, + equipmentId: '350000000000001', + device: 'slot-usb2-1', + sims: [{ index: 0, iccid: '8900000000000000001', imsi: '001010000000001', active: true }], +}; + +const sleep = (ms: number): Promise => new Promise((resolve) => setTimeout(resolve, ms)); + +async function waitFor(predicate: () => boolean, timeoutMs = 2000): Promise { + const deadline = Date.now() + timeoutMs; + while (!predicate()) { + if (Date.now() > deadline) { + throw new Error('waitFor timed out'); + } + await sleep(5); + } +} + +describe.skipIf(!hasSessionBus())('MM-faithful fake service — self-test walk', () => { + let fake: FakeModemManager; + let transport: DbusTransport; + + beforeEach(async () => { + const busAddress = sessionBusAddress(); + fake = await FakeModemManager.start({ busAddress, shape: '1.24', modems: [MODEM_0] }); + transport = createDbusTransport({ busAddress }); + await transport.connect(); + }); + + afterEach(async () => { + await transport.disconnect(); + await fake.stop(); + }); + + test('ObjectManager lists the modem, its SIM, and its bearer as separate objects', async () => { + const tree = await fetchManagedObjects(transport, fake.busName); + expect(pathsWithInterface(tree, MODEM_IFACE)).toEqual([modemPath(0)]); + expect(pathsWithInterface(tree, SIM_IFACE)).toEqual([simPath(0)]); + expect(pathsWithInterface(tree, BEARER_IFACE)).toEqual([bearerPath(0)]); + }); + + test('the modem exposes Modem and Modem3gpp as SEPARATE interfaces, never merged', async () => { + const tree = await fetchManagedObjects(transport, fake.busName); + const names = interfaceNames(tree, modemPath(0)); + expect(names).toContain(MODEM_IFACE); + expect(names).toContain(MODEM3GPP_IFACE); + // Modem3gpp props (Imei) live under their OWN interface key, not under Modem. + expect(propValue(findInterface(tree, modemPath(0), MODEM_IFACE), 'Imei')).toBeUndefined(); + expect(propValue(findInterface(tree, modemPath(0), MODEM3GPP_IFACE), 'Imei')).toBe( + '350000000000001', + ); + }); + + test("the modem's Sim path resolves to a separate /SIM/ object", async () => { + const tree = await fetchManagedObjects(transport, fake.busName); + const modemProps = findInterface(tree, modemPath(0), MODEM_IFACE); + expect(propValue(modemProps, 'Sim')).toBe(simPath(0)); + const simObject = followObjectPath(tree, modemProps, 'Sim'); + expect(simObject?.[0]).toBe(simPath(0)); + expect(hasInterface(tree, simPath(0), SIM_IFACE)).toBe(true); + expect(propValue(findInterface(tree, simPath(0), SIM_IFACE), 'SimIdentifier')).toBe( + '8900000000000000001', + ); + }); + + test('the bearer is observable in the tree but Connect throws the tripwire', async () => { + const tree = await fetchManagedObjects(transport, fake.busName); + expect(hasInterface(tree, bearerPath(0), BEARER_IFACE)).toBe(true); + await expect( + transport.callMethod({ + destination: fake.busName, + path: bearerPath(0), + interface: BEARER_IFACE, + member: 'Connect', + }), + ).rejects.toThrow(/TRIPWIRE/); + }); + + test('the modem Simple.Connect and CreateBearer are tripwires too', async () => { + const call = (iface: string, member: string) => + transport.callMethod({ + destination: fake.busName, + path: modemPath(0), + interface: iface, + member, + }); + await expect(call(SIMPLE_IFACE, 'Connect')).rejects.toThrow(/TRIPWIRE/); + await expect(call(MODEM_IFACE, 'CreateBearer')).rejects.toThrow(/TRIPWIRE/); + }); + + test('an invalidated-only PropertiesChanged carries names in the invalidated array', async () => { + const events: SignalEvent[] = []; + const sub = await transport.subscribeSignal( + { interface: PROPERTIES_IFACE, member: 'PropertiesChanged', path: modemPath(0) }, + (event) => events.push(event), + ); + fake.invalidateProperties(modemPath(0), MODEM_IFACE, ['SignalQuality']); + await waitFor(() => events.length > 0); + await sub.unsubscribe(); + + const [event] = events; + expect(event?.body[0]).toBe(MODEM_IFACE); + expect(event?.body[1]).toEqual([]); + expect(event?.body[2]).toEqual(['SignalQuality']); + }); + + test('a changed PropertiesChanged carries new values in the changed dict', async () => { + const events: SignalEvent[] = []; + const sub = await transport.subscribeSignal( + { interface: PROPERTIES_IFACE, member: 'PropertiesChanged', path: modemPath(0) }, + (event) => events.push(event), + ); + fake.changeProperties(modemPath(0), MODEM_IFACE, [['SignalQuality', ['(ub)', [42, true]]]]); + await waitFor(() => events.length > 0); + await sub.unsubscribe(); + + const [event] = events; + expect(event?.body[1]).toEqual([['SignalQuality', { signature: '(ub)', value: [42, true] }]]); + expect(event?.body[2]).toEqual([]); + }); + + test('SIM hot-swap replaces the /SIM object and invalidates the modem Sim', async () => { + const events: SignalEvent[] = []; + const sub = await transport.subscribeSignal( + { interface: PROPERTIES_IFACE, member: 'PropertiesChanged', path: modemPath(0) }, + (event) => events.push(event), + ); + fake.replaceSim(0, { index: 7, iccid: '8900000000000000007', imsi: '001010000000007' }); + await waitFor(() => events.length > 0); + await sub.unsubscribe(); + + expect(events[0]?.body[2]).toEqual(['Sim']); + const tree = await fetchManagedObjects(transport, fake.busName); + expect(propValue(findInterface(tree, modemPath(0), MODEM_IFACE), 'Sim')).toBe(simPath(7)); + expect(hasInterface(tree, simPath(0), SIM_IFACE)).toBe(false); + expect(propValue(findInterface(tree, simPath(7), SIM_IFACE), 'SimIdentifier')).toBe( + '8900000000000000007', + ); + }); +}); + +describe.skipIf(!hasSessionBus())('MM property shapes — 1.20 vs 1.24', () => { + async function readModemProps(shape: '1.20' | '1.24') { + const busAddress = sessionBusAddress(); + const fake = await FakeModemManager.start({ busAddress, shape, modems: [MODEM_0] }); + const transport = createDbusTransport({ busAddress }); + await transport.connect(); + try { + const tree = await fetchManagedObjects(transport, fake.busName); + return findInterface(tree, modemPath(0), MODEM_IFACE); + } finally { + await transport.disconnect(); + await fake.stop(); + } + } + + test('the 1.20-shape carries Device but omits Physdev', async () => { + const props = await readModemProps('1.20'); + expect(propValue(props, 'Device')).toBe('slot-usb2-1'); + expect(propValue(props, 'Physdev')).toBeUndefined(); + }); + + test('the 1.24-shape carries both Device and Physdev', async () => { + const props = await readModemProps('1.24'); + expect(propValue(props, 'Device')).toBe('slot-usb2-1'); + expect(typeof propValue(props, 'Physdev')).toBe('string'); + }); +}); diff --git a/control/test-support/session-bus.ts b/control/test-support/session-bus.ts new file mode 100644 index 0000000..92704d6 --- /dev/null +++ b/control/test-support/session-bus.ts @@ -0,0 +1,35 @@ +// Graceful skip for the D-Bus harness when there is no session bus. +// +// Every test that spins the fake service needs a real session bus, which the suite +// gets from `dbus-run-session -- bun test control/test-support`. Without it there is +// nothing to talk to, so those tests SKIP rather than fail — but LOUDLY: a single +// `console.warn` names exactly what is missing and how to run them, so a skip is never +// mistaken for a pass. `describe.skipIf(!hasSessionBus())` gates each suite. + +/** True when a D-Bus session bus is reachable (set by `dbus-run-session`). */ +export function hasSessionBus(): boolean { + return Boolean(process.env.DBUS_SESSION_BUS_ADDRESS); +} + +/** The encoded session-bus address, asserted present — call only inside a gated suite. */ +export function sessionBusAddress(): string { + const address = process.env.DBUS_SESSION_BUS_ADDRESS; + if (!address) { + throw new Error('DBUS_SESSION_BUS_ADDRESS is not set — run under dbus-run-session'); + } + return address; +} + +let warned = false; + +/** Emit a one-time, loud annotation explaining why bus-dependent suites are skipped. */ +export function warnSkippedWithoutBus(context: string): void { + if (hasSessionBus() || warned) { + return; + } + warned = true; + console.warn( + `[test-support] SKIPPING ${context}: no DBUS_SESSION_BUS_ADDRESS. ` + + 'Run `dbus-run-session -- bun test control/test-support` to exercise the fake D-Bus service.', + ); +} diff --git a/control/test-support/signal-setup.test.ts b/control/test-support/signal-setup.test.ts new file mode 100644 index 0000000..67cd7a2 --- /dev/null +++ b/control/test-support/signal-setup.test.ts @@ -0,0 +1,165 @@ +// Signal.Setup full lifecycle + read-only enrichment against the fake MM service. +// +// Locks the reviewed contract (draft §rounds 4-6): Setup is applied once at start, +// once per hot-plug, RE-APPLIED to survivors after an owner-epoch change, NEVER fired +// for an old epoch, and a modem lacking `Modem.Signal` reports `signalCadence: +// unsupported` (never a start failure). Also surfaces `Modem.Revision` + eSIM. +// +// Runs under `dbus-run-session -- bun test control/test-support`. + +import { afterEach, describe, expect, test } from 'bun:test'; +import { createMmDbusBackend, type MmDbusBackend } from '../src/backend'; +import { runtimePath } from '../src/domain'; +import type { ModemRef } from '../src/ports'; +import { createDbusTransport, type DbusTransport } from '../src/transport'; +import { FakeModemManager, type ModemSpec, modemPath, type SignalSetupCall } from './fake-mm'; +import { hasSessionBus, sessionBusAddress, warnSkippedWithoutBus } from './session-bus'; + +warnSkippedWithoutBus('Signal.Setup lifecycle'); + +const sleep = (ms: number): Promise => new Promise((resolve) => setTimeout(resolve, ms)); + +async function waitFor(predicate: () => boolean, timeoutMs = 4000): Promise { + const deadline = Date.now() + timeoutMs; + while (!predicate()) { + if (Date.now() > deadline) { + throw new Error('waitFor timed out'); + } + await sleep(5); + } +} + +const ref = (index: number): ModemRef => runtimePath(modemPath(index)) as ModemRef; + +const sim = (index: number, extra: Record = {}) => ({ + index, + iccid: `890000000000000000${index}`, + imsi: `00101000000000${index}`, + active: true, + ...extra, +}); + +const modem = (index: number, extra: Partial = {}): ModemSpec => ({ + index, + sims: [sim(index)], + ...extra, +}); + +describe.skipIf(!hasSessionBus())('SignalSetupManager — epoch lifecycle', () => { + let fake: FakeModemManager; + let transport: DbusTransport; + let backend: MmDbusBackend; + + async function boot(modems: readonly ModemSpec[]): Promise { + const busAddress = sessionBusAddress(); + fake = await FakeModemManager.start({ busAddress, modems }); + transport = createDbusTransport({ busAddress }); + backend = createMmDbusBackend({ transport }); + await backend.start(); + } + + afterEach(async () => { + await backend.stop(); + await transport.disconnect(); + await fake.stop(); + }); + + const forOwner = (owner: string | undefined): SignalSetupCall[] => + fake.signalSetupCalls.filter((call) => call.owner === owner); + + const modemsForOwner = (owner: string | undefined): number[] => + forOwner(owner) + .map((call) => call.modemIndex) + .sort((a, b) => a - b); + + test('applies Signal.Setup once per modem at start', async () => { + await boot([modem(0), modem(1)]); + const owner = fake.uniqueName; + await waitFor(() => forOwner(owner).length === 2); + expect(modemsForOwner(owner)).toEqual([0, 1]); + }); + + test('applies Signal.Setup to a hot-plugged modem', async () => { + await boot([modem(0)]); + const owner = fake.uniqueName; + await waitFor(() => forOwner(owner).length === 1); + fake.addModem(modem(2)); + await waitFor(() => forOwner(owner).length === 2); + expect(modemsForOwner(owner)).toEqual([0, 2]); + }); + + test('re-applies to survivors on a new epoch, with ZERO old-epoch calls', async () => { + await boot([modem(0), modem(1)]); + const oldOwner = fake.uniqueName; + await waitFor(() => forOwner(oldOwner).length === 2); + fake.addModem(modem(2)); + await waitFor(() => forOwner(oldOwner).length === 3); + + await fake.restart(); + const newOwner = fake.uniqueName; + expect(newOwner).not.toBe(oldOwner); + await waitFor(() => forOwner(newOwner).length === 3); + + // Survivors re-applied under the new epoch... + expect(modemsForOwner(newOwner)).toEqual([0, 1, 2]); + // ...and NOT one extra Setup fired for the old epoch. + expect(modemsForOwner(oldOwner)).toEqual([0, 1, 2]); + }); + + test('a modem without Modem.Signal reports signalCadence unsupported (no call, no failure)', async () => { + await boot([modem(0, { hasSignal: false }), modem(1)]); + const owner = fake.uniqueName; + await waitFor(() => forOwner(owner).length === 1); + await waitFor(() => backend.signalCadence(ref(0)) === 'unsupported'); + expect(backend.signalCadence(ref(0))).toBe('unsupported'); + expect(modemsForOwner(owner)).toEqual([1]); + }); +}); + +describe.skipIf(!hasSessionBus())('MmDbusBackend — read-only enrichment', () => { + let fake: FakeModemManager; + let transport: DbusTransport; + let backend: MmDbusBackend; + + afterEach(async () => { + await backend.stop(); + await transport.disconnect(); + await fake.stop(); + }); + + test('surfaces Modem.Revision, eSIM SimType/EsimStatus, cadence, and serving cell', async () => { + const busAddress = sessionBusAddress(); + fake = await FakeModemManager.start({ + busAddress, + modems: [ + { + index: 0, + revision: 'EM120R-GL_V1.0', + sims: [sim(0, { simType: 2, esimStatus: 2 })], + }, + ], + }); + transport = createDbusTransport({ busAddress }); + backend = createMmDbusBackend({ transport }); + await backend.start(); + fake.configureCellInfo(0, [ + [ + ['serving', ['b', true]], + ['cell-id', ['s', 'CELL-A']], + ['rsrp', ['i', -85]], + ['rsrq', ['i', -9]], + ['sinr', ['i', 15]], + ['physical-ci', ['u', 7]], + ], + ]); + await waitFor(() => backend.signalCadence(ref(0)) === 'active'); + + const enrichment = await backend.readEnrichment(ref(0)); + expect(enrichment.revision).toBe('EM120R-GL_V1.0'); + expect(enrichment.esim.simType).toBe('esim'); + expect(enrichment.esim.esimStatus).toBe('with-profiles'); + expect(enrichment.signalCadence).toBe('active'); + expect(enrichment.servingCell).toMatchObject({ cellId: 'CELL-A', pci: 7, sinr: 15, rsrp: -85 }); + expect(enrichment.servingCell?.source).toBe(modemPath(0)); + }); +}); diff --git a/docs/BENCH.md b/docs/BENCH.md new file mode 100644 index 0000000..b3db2ac --- /dev/null +++ b/docs/BENCH.md @@ -0,0 +1,529 @@ +# Bench runbooks — modem-stack + +Agent-executable runbooks for every **hardware-gated** claim in the modem stack. Each of +these paths needs a real modem, a real SIM, or real arm64 hardware, so none of them run in +CI. Every runbook below is therefore marked **`[PARTIAL]`** — the CI proxy that stands in +for it is green, but the on-hardware evidence has not been captured in this session because +no bench hardware exists here yet. A runbook flips to `[EXISTS]` only when its evidence +artifact under `test-results/modem-control/A6.3/` (repo-local, gitignored) is captured on a +real bench device and the machine-checkable assertion passes. + +These are the Phase-A **iteration surface**. You run them against real modems on a bench +device to mature `@ceralive/modem-control`, feed findings back as issues, and cut the next +`0.x` release. Nothing here ships to a product; nothing here requires a human judgment call +— every gate is a machine-checkable expected line. + +## Conventions + +Every runbook has the same shape: + +- **Preconditions** — what must be true before you start. +- **Commands** — copy-pasteable and **non-interactive**. Secrets are piped on `stdin` + (never typed at a prompt), so the whole runbook can be driven by an agent or a script. +- **Expected output** — an exact line (or a `PASS`/`FAIL` from an inline assertion) that a + machine can grep for. This is the pass/fail gate. +- **Evidence** — the repo-local path the captured output is written to. All evidence lives + under `test-results/modem-control/A6.3/` (gitignored per Rule D); the CLI `[PARTIAL]` + probe runbook from A6.1 keeps its original `A6.1/hil-system-bus.txt` path. + +The compiled `modem-control` binary comes from `cli/smoke/build-binaries.sh` +(`cli/dist/modem-control-{amd64,arm64}`) or the release artifact set. On a bench device with +the packaged ModemManager stack installed it talks to the **system bus** by default; point +it elsewhere with `--bus-address` / `MODEM_CONTROL_BUS_ADDRESS`. + +> **Non-interactivity note (runbook-lint contract).** `unlock-pin` / `unlock-puk` disable +> terminal echo when attached to a TTY, but read one line from `stdin` when they are not. So +> **every** step below — including PIN/PUK entry — is expressed as a non-interactive pipe +> (`printf '%s\n' "$PIN" | modem-control unlock-pin …`). No step waits on a human at a +> prompt; each ends in a greppable expected line. A runbook that reintroduces an interactive +> prompt (a bare `modem-control unlock-pin` on a TTY with no stdin redirect) is a +> runbook-lint failure. + +--- + +## The Phase-A iteration loop + +The bench is not a one-shot certification pass; it is a loop that drives the `0.x` line to +maturity: + +``` + ┌──────────────────────────────────────────────────────────────────────┐ + │ │ + ▼ │ +bench run ──▶ finding ──▶ issue ──▶ fix on integration branch ──▶ 0.x release ──┐ +(runbook) (a runbook (notepad (one PR, wave-ordered (ONE tag │ + gate fails issues.md coherent commits) vX.Y.Z: │ + OR a new + GH issue) npm + debs) │ + SKU appears) │ + │ + matrix / catalog update ◀── certify bundle ◀────────────────────────────────┘ + (MODEM-SUPPORT-MATRIX.md (modem-control certify + recommended_usb_mode; --transition → sha256 → + certified-catalog.json certified-catalog.json + evidenceBundleSha256) evidenceBundleSha256) +``` + +1. **Bench findings.** Run the runbooks below against a real device/SKU. A gate that fails, + or a new SKU that a runbook surfaces (`probe` classifies it `unmanaged`, a transition + postcondition mismatches, the usage meter drifts >5 %), is a finding. +2. **Issues.** Each finding becomes an entry in + `.omo/notepads/modem-control-package/issues.md` and, when it needs code, a GitHub issue + on `CERALIVE/modem-stack`. A finding never silently disappears — it is either fixed or + recorded as a known limitation. +3. **Unified `0.x` releases.** Fixes land on the single Phase-A integration branch as + wave-ordered coherent commits (one PR), then a **single** tag `vX.Y.Z` cuts **both** + artifacts together — `@ceralive/modem-control@X.Y.Z` on npm and the ModemManager-stack + `.deb` set (`…~ceraliveX.Y.Z`) + release manifest as CI artifacts. `0.x` allows breaking + changes between minors while the API settles; see [`VERSIONING.md`](VERSIONING.md). +4. **Matrix / catalog updates.** A certified USB-mode transition (RB-5) produces a redacted + evidence bundle whose `sha256` is recorded in a new `certified-catalog.json` entry's + `evidenceBundleSha256`, and the SKU's confirmed `recommended_usb_mode` is written back to + [`../docs/MODEM-SUPPORT-MATRIX.md`](../../docs/MODEM-SUPPORT-MATRIX.md). Certification is a + human-reviewed commit adding a real entry — never an automated write. + +The loop repeats for as many `0.x` releases as the hardware fleet needs. `1.0.0` is reserved +for Phase-B adoption. + +--- + +## RB-1 — System-bus probe `[PARTIAL]` + +The real on-device D-Bus probe: EXTERNAL-auth handshake on the system bus, one authoritative +`GetManagedObjects`, identity-ladder resolution, feature detection, and USB classification. +This is the on-hardware counterpart of A6.1's compiled cross-arch probe smoke (which runs in +CI against the fake MM on both arches). CLI reference: [`../cli/README.md`](../cli/README.md). + +**Preconditions** + +- Bench device with the packaged ModemManager 1.24 stack installed and running (RB-3). +- At least one modem enumerated by ModemManager. + +**Commands** + +```sh +# On the bench device, against the real system bus: +./modem-control probe | tee A6.1/hil-system-bus.txt +``` + +**Expected output** — the run ends with: + +``` +PROBE OK: external-auth, objects= +``` + +where `` ≥ 1 (modems + SIMs + bearers ModemManager reports). The ICCID must **not** appear +anywhere in the output — the subscription id is rendered `sim=[redacted]`. + +**Machine check** + +```sh +grep -Eq '^PROBE OK: external-auth, objects=[1-9][0-9]*$' A6.1/hil-system-bus.txt \ + && ! grep -Eq '[0-9]{18,22}' A6.1/hil-system-bus.txt \ + && echo "RB-1 PASS" || echo "RB-1 FAIL" +``` + +**Evidence:** `test-results/modem-control/A6.1/hil-system-bus.txt` + +--- + +## RB-2 — Slot-UID stability across replug `[PARTIAL]` + +Asserts that a modem's **`modem.generic.device`** (ModemManager's `Modem.Device` property, +the udev slot UID) is stable across a physical unplug/replug, so the A3.2 identity ladder +resolves the **same** stable key and the identity registry keeps **one** row (a `replugged` +transition, not `attached` + a phantom removal). This is the on-hardware proof of the +replug-keeps-one-row guarantee. + +**Preconditions** + +- One modem on a known port; you can physically unplug and replug it (or toggle a USB-hub + port). `mmcli` from the packaged stack is on `PATH`. + +**Commands** + +```sh +mkdir -p A6.3 +# Modem index (first modem): +M=$(mmcli -L 2>/dev/null | grep -oE '/Modem/[0-9]+' | head -1 | grep -oE '[0-9]+$') + +# 1) Capture the slot UID BEFORE replug: +mmcli -m "$M" -K | grep '^modem.generic.device ' | tee A6.3/slot-uid-before.txt + +# 2) Physically unplug the modem, wait for MM to drop it, replug, wait for re-enumeration. +# (usb-hub port toggle is equivalent; give udev + MM time to settle.) +# Then re-read the slot UID for the (possibly renumbered) modem on the SAME port: +M2=$(mmcli -L 2>/dev/null | grep -oE '/Modem/[0-9]+' | head -1 | grep -oE '[0-9]+$') +mmcli -m "$M2" -K | grep '^modem.generic.device ' | tee A6.3/slot-uid-after.txt + +# 3) Cross-check via the identity ladder (the resolved logical slot must match too): +./modem-control probe | tee A6.3/slot-uid-probe.txt +``` + +**Expected output** — the `modem.generic.device` value is **identical** before and after, +even though the `/Modem/` runtime path may change: + +``` +modem.generic.device : # before +modem.generic.device : # after (same ) +``` + +**Machine check** + +```sh +b=$(sed 's/.*: *//' A6.3/slot-uid-before.txt); a=$(sed 's/.*: *//' A6.3/slot-uid-after.txt) +[ -n "$b" ] && [ "$b" = "$a" ] && echo "RB-2 PASS (device=$b stable)" || echo "RB-2 FAIL (before=$b after=$a)" +``` + +> If `modem.generic.device` is a path-shaped default (`/sys/devices/…`) rather than a +> CeraLive `slot-*` UID, the ladder falls through to `physdev` / `sysfs-walk`; the stability +> assertion is still on `modem.generic.device`. The `slot-*` udev rule that makes the value a +> stable hand-labeled UID is Phase-B image-integration work (`78-mm-ceralive-slot-uid.rules`). + +**Evidence:** `test-results/modem-control/A6.3/slot-uid-{before,after,probe}.txt` + +--- + +## RB-3 — MM-1.24-from-artifacts install `[PARTIAL]` + +Install the packaged ModemManager 1.24 stack on a clean bench device **from the release CI +artifacts** (Phase A does no apt publication) and prove the daemon comes up at 1.24.0. This +is the on-hardware counterpart of A5.2's daemon smoke (which runs in a `debian:bookworm` +container in CI). + +**Preconditions** + +- Clean Debian bookworm bench device (`arm64` for the shipping target; `amd64` acceptable + for the desk proxy). `gh` authenticated, or the artifact zip already copied to the device. + +**Commands** + +```sh +mkdir -p A6.3 debs && cd debs +# Download the release artifact set produced by release.yml for tag v0.1.0: +gh run download --repo CERALIVE/modem-stack \ + -n modem-stack-debs-0.1.0 --dir . # -> release-manifest.txt + /*.deb + +ARCH=$(dpkg --print-architecture) # arm64 on the shipping SBC +sudo apt-get update +sudo apt-get install -y --allow-downgrades ./"$ARCH"/*.deb | tee ../A6.3/mm-install.txt +cd .. + +# Bring the daemon up (or `systemctl restart ModemManager` on a systemd device): +sudo systemctl restart ModemManager 2>/dev/null || true +mmcli --version | tee A6.3/mm-version.txt +busctl introspect org.freedesktop.ModemManager1 /org/freedesktop/ModemManager1 \ + 2>/dev/null | grep -i version | tee -a A6.3/mm-version.txt +``` + +**Expected output** + +``` +mmcli 1.24.0 +``` + +and the coherence check: every installed stack package carries the same `~ceralive0.1.0` +suffix (per the release manifest). + +**Machine check** + +```sh +grep -q 'mmcli 1\.24\.0' A6.3/mm-version.txt \ + && awk -F'[ \t]+' 'NR>0 && /~ceralive0\.1\.0/{n++} END{exit !(n>=9)}' debs/release-manifest.txt \ + && echo "RB-3 PASS" || echo "RB-3 FAIL" +``` + +> `libqrtr-glib0` is the one package whose `~ceralive0.1.0` sorts **below** bookworm stock +> `1.2.2-1` — hence `--allow-downgrades` on install. Phase-B image integration replaces this +> with apt pin 990. All other three sources sort above stock. + +**Evidence:** `test-results/modem-control/A6.3/{mm-install,mm-version}.txt` + +--- + +## RB-4 — PIN/PUK on a real SIM `[PARTIAL]` + +Unlock a **real** PIN-locked (and, for the PUK path, PUK-locked) SIM through the A3.3 +read-before-submit, exactly-once mutation path. The secret is piped on `stdin` (non-echoing, +never printed back, never in any receipt reason). + +**Preconditions** + +- A modem with a **real, PIN-locked** SIM inserted. You know the correct PIN (and, for the + PUK drill, the PUK + a new PIN). ⚠️ A wrong PIN spends a real retry; a wrong PUK can + **permanently brick** the SIM — use a disposable test SIM. + +**Commands** + +```sh +mkdir -p A6.3 +SLOT=Modem/0 + +# PIN unlock — non-interactive (PIN on stdin, echo disabled / not needed): +printf '%s\n' "$PIN" | ./modem-control unlock-pin "$SLOT" | tee A6.3/unlock-pin.txt + +# PUK unlock (only when UnlockRequired == sim-puk) — PUK then new PIN on stdin: +printf '%s\n%s\n' "$PUK" "$NEWPIN" | ./modem-control unlock-puk "$SLOT" | tee A6.3/unlock-puk.txt +``` + +**Expected output** — the outcome line reports `unlocked` for a correct secret: + +``` +unlock-pin Modem/0: unlocked +``` + +For a wrong PIN the outcome is `incorrect-pin (retries left: )`; when the SIM has fallen +to PUK it is `sim-puk-required`. In **no** case does the entered PIN/PUK appear in the +output. + +**Machine check** + +```sh +grep -q ': unlocked' A6.3/unlock-pin.txt \ + && ! grep -qF "$PIN" A6.3/unlock-pin.txt \ + && echo "RB-4 PASS" || echo "RB-4 FAIL" +``` + +**Evidence:** `test-results/modem-control/A6.3/unlock-{pin,puk}.txt` + +--- + +## RB-5 — Certified USB-mode transition `[PARTIAL]` + +Execute a certified, postcondition-verified USB composition-mode transition on a real SKU, +then capture the transition-evidence bundle whose `sha256` becomes the catalog entry's +`evidenceBundleSha256`. Only within-MM transitions (`qmi` / `mbim` / `ecm-ncm`) are +representable; MM↔router is schema-invalid by construction. The transition **only** succeeds +when the re-enumerated device's descriptors match the target mode — an AT `OK` alone is never +success. + +**Preconditions** + +- A real modem whose SKU is a **certified** entry in `certified-catalog.json` with a + permitted transition `from → to`. `--confirm` is the operator gate; bench is a maintenance + context. + +**Commands** + +```sh +mkdir -p A6.3 +SLOT=Modem/0 +TARGET=mbim # one of qmi | mbim | ecm-ncm, permitted by the catalog entry + +# 1) Run the transition (idempotent transaction; nm-quiesce → inhibit → AT → port-drop → +# re-enumeration → POSTCONDITION → reactivate). --confirm is REQUIRED: +./modem-control set-usb-mode "$SLOT" "$TARGET" --confirm | tee A6.3/set-usb-mode.txt + +# 2) Capture the redacted transition-evidence bundle (before/after descriptors, the executed +# AT command, port-drop / re-enumeration timeline) for the catalog: +./modem-control certify "$SLOT" --transition "$TARGET" --output A6.3/transition-bundle.json \ + | tee A6.3/certify-transition.txt +``` + +**Expected output** + +``` +set-usb-mode: OK Modem/0 -> mbim on +steps: nm-quiesce -> inhibit -> at-command -> await-port-drop -> uninhibit -> await-reenumeration -> postcondition -> resolve-ifname -> reactivate -> release-interlock +``` + +and from `certify --transition`: + +``` +CERTIFY OK: sha256= synthetic=false transition=-> slot=Modem/0 +``` + +**Machine check** + +```sh +grep -Eq "^set-usb-mode: OK $SLOT -> $TARGET on " A6.3/set-usb-mode.txt \ + && grep -Eq '^CERTIFY OK: sha256=[0-9a-f]{64} synthetic=false ' A6.3/certify-transition.txt \ + && echo "RB-5 PASS" || echo "RB-5 FAIL" +``` + +> The **negative** proof is already non-hardware: omitting `--confirm` prints +> `set-usb-mode: REFUSED (entry)` + `steps: (none — zero side effects)` and exits 1 +> (covered by `cli/src/set-usb-mode.test.ts`). On the bench, run it once to confirm the +> zero-side-effect refusal before the real switch. + +**Evidence:** `test-results/modem-control/A6.3/{set-usb-mode,certify-transition}.txt` + +`transition-bundle.json` + +--- + +## RB-6 — Usage-meter accuracy (MACHINE-CHECKABLE) `[PARTIAL]` + +The one runbook with a self-contained numeric pass/fail. Download a **known-size** payload +over the modem's own network interface, then assert the A4.3 usage sampler's reported delta +equals the kernel's own `/proc/net/dev` delta for that **same window**, within **±5 %**. + +**The gate is meter-delta vs. kernel-delta only.** There is deliberately **no** +carrier-portal comparison in the executable gate — portal reconciliation is an optional +manual note (below), outside this runbook's pass/fail. + +**Preconditions** + +- A modem with data connectivity; you know its data interface name (`wwan0`, `ppp0`, …) — + the `logicalSlotId ↔ ifname` mapping the sampler is fed. A fixed-size public payload URL. +- The modem interface carries the download (bind `curl --interface`), and it is the only + significant traffic on that interface during the window (stop other consumers). + +**Runbook (copy-paste; exits 0 on PASS, 1 on FAIL)** + +```sh +#!/usr/bin/env bash +set -euo pipefail +mkdir -p A6.3 + +IFACE="${IFACE:-wwan0}" # the modem data interface +SLOT="${SLOT:-Modem/0}" # logical slot the sampler reports +URL="${URL:-https://speed.hetzner.de/100MB.bin}" # a FIXED N-MB payload +N_BYTES="${N_BYTES:-104857600}" # 100 MiB — must match URL's real size + +kernel_total() { # rx+tx bytes for $IFACE from /proc/net/dev (rx=col1, tx=col9 after the ':') + awk -v ifc="$IFACE" -F'[: ]+' '$1==ifc || $2==ifc { + for (i=1;i<=NF;i++) if ($i==ifc){print $(i+1)+$(i+9); exit}}' /proc/net/dev +} +meter_bytes() { # cycleBytes for $SLOT from the sampler snapshot + ./modem-control usage | awk -v s="$SLOT" ' + $1 ~ (s"$") { for (i=1;i<=NF;i++) if ($i ~ /^cycleBytes=/){sub(/cycleBytes=/,"",$i); print $i; exit} } + index($0,s)>0 { for (i=1;i<=NF;i++) if ($i ~ /^cycleBytes=/){sub(/cycleBytes=/,"",$i); print $i; exit} }' +} + +k0=$(kernel_total); m0=$(meter_bytes) +curl --interface "$IFACE" -s -o /dev/null "$URL" # download the fixed payload over the modem +k1=$(kernel_total); m1=$(meter_bytes) + +kd=$(( k1 - k0 )); md=$(( m1 - m0 )) +# PASS iff the meter delta is within ±5% of the KERNEL delta for the same window: +awk -v kd="$kd" -v md="$md" -v n="$N_BYTES" 'BEGIN{ + if (kd<=0){print "RB-6 FAIL: kernel delta non-positive ("kd")"; exit 1} + err=(md-kd); if (err<0) err=-err; pct=100*err/kd; + printf "RB-6 kernel_delta=%d meter_delta=%d payload=%d drift=%.2f%%\n", kd, md, n, pct; + if (pct<=5.0){print "RB-6 PASS"; exit 0} else {print "RB-6 FAIL: drift >5%"; exit 1} +}' | tee A6.3/usage-accuracy.txt +``` + +**Expected output** — a `drift ≤ 5 %` line and `RB-6 PASS`: + +``` +RB-6 kernel_delta=104920000 meter_delta=104880000 payload=104857600 drift=0.04% +RB-6 PASS +``` + +(The kernel delta runs a little above the raw payload — TLS + framing overhead; the meter is +derived from the **same** counters so it tracks the kernel delta, which is what the gate +checks.) + +**Evidence:** `test-results/modem-control/A6.3/usage-accuracy.txt` + +**Optional manual note — carrier-portal reconciliation (OUTSIDE the gate).** As a +non-blocking sanity check you may, separately, compare the meter against the carrier's own +usage portal over a longer billing window. Portals lag, round, and count differently, so this +is **never** part of the pass/fail above — it is an advisory data point recorded in +`issues.md` if it diverges materially, not a runbook gate. + +--- + +## RB-7 — arm64-on-real-hardware validation `[PARTIAL]` + +CI builds the `.deb` stack and runs the compiled probe on arm64 under **QEMU** (A5.1 build, +A6.1 probe smoke). This runbook re-runs the two on a **real arm64 SBC** (e.g. Rock 5B+), +where JIT, real USB, and real timing differ from emulation. + +**Preconditions** + +- A real arm64 bench SBC with the RB-3 install completed and a modem attached. + +**Commands** + +```sh +mkdir -p A6.3 +uname -m | tee A6.3/arm64-uname.txt # expect: aarch64 +./modem-control probe | tee A6.3/arm64-probe.txt # the shipped arm64 binary, native +mmcli --version | tee -a A6.3/arm64-probe.txt +``` + +**Expected output** + +``` +aarch64 +PROBE OK: external-auth, objects= +mmcli 1.24.0 +``` + +**Machine check** + +```sh +grep -q aarch64 A6.3/arm64-uname.txt \ + && grep -Eq '^PROBE OK: external-auth, objects=[1-9]' A6.3/arm64-probe.txt \ + && grep -q 'mmcli 1\.24\.0' A6.3/arm64-probe.txt \ + && echo "RB-7 PASS" || echo "RB-7 FAIL" +``` + +**Evidence:** `test-results/modem-control/A6.3/arm64-{uname,probe}.txt` + +--- + +## RB-8 — Daemon smoke on real hardware `[PARTIAL]` + +The full A5.2 daemon smoke (system D-Bus + polkit + NetworkManager 1.42, `busctl` +introspect, udev/FCC/GIR/Vala paths) re-run on a real device rather than the CI bookworm +container — the last check that the packaged stack is coherent end-to-end on hardware. + +**Preconditions** + +- RB-3 install completed on the bench device; NetworkManager present. + +**Commands** + +```sh +mkdir -p A6.3 +{ busctl --system list | grep -i ModemManager1 + busctl introspect org.freedesktop.ModemManager1 /org/freedesktop/ModemManager1 | grep -i version + ls /usr/lib/udev/rules.d/77-mm-*.rules + ls /etc/ModemManager/fcc-unlock.d/ 2>/dev/null || true + mmcli --version +} | tee A6.3/daemon-smoke.txt +``` + +**Expected output** — MM owns the bus name, reports version `1.24.0`, and the udev rules are +present: + +``` +org.freedesktop.ModemManager1 … +.Version property s "1.24.0" … +/usr/lib/udev/rules.d/77-mm-…rules +mmcli 1.24.0 +``` + +**Machine check** + +```sh +grep -q 'ModemManager1' A6.3/daemon-smoke.txt \ + && grep -q '"1\.24\.0"' A6.3/daemon-smoke.txt \ + && grep -q 'mmcli 1\.24\.0' A6.3/daemon-smoke.txt \ + && echo "RB-8 PASS" || echo "RB-8 FAIL" +``` + +**Evidence:** `test-results/modem-control/A6.3/daemon-smoke.txt` + +--- + +## Evidence index + +| Runbook | Item | Status | Evidence path (`test-results/modem-control/…`) | +|---------|------|--------|-----------------------------------------------| +| RB-1 | System-bus probe | `[PARTIAL]` | `A6.1/hil-system-bus.txt` | +| RB-2 | Slot-UID stability across replug | `[PARTIAL]` | `A6.3/slot-uid-{before,after,probe}.txt` | +| RB-3 | MM-1.24-from-artifacts install | `[PARTIAL]` | `A6.3/{mm-install,mm-version}.txt` | +| RB-4 | PIN/PUK on a real SIM | `[PARTIAL]` | `A6.3/unlock-{pin,puk}.txt` | +| RB-5 | Certified USB-mode transition | `[PARTIAL]` | `A6.3/{set-usb-mode,certify-transition}.txt`, `transition-bundle.json` | +| RB-6 | Usage-meter accuracy (machine-checkable) | `[PARTIAL]` | `A6.3/usage-accuracy.txt` | +| RB-7 | arm64-on-real-hardware validation | `[PARTIAL]` | `A6.3/arm64-{uname,probe}.txt` | +| RB-8 | Daemon smoke on real hardware | `[PARTIAL]` | `A6.3/daemon-smoke.txt` | + +Every row stays `[PARTIAL]` until its evidence artifact is captured on a real bench device +and its machine check prints `PASS`. No row may be claimed `[EXISTS]` on the strength of the +CI proxy alone — the CI proxy is green (compiled probe smoke both arches, packaging contract ++ daemon smoke in a bookworm container, the full `bun test` suite), but the hardware evidence +is what closes each gate. diff --git a/packaging/BOOKWORM-ADAPTATIONS.md b/packaging/BOOKWORM-ADAPTATIONS.md new file mode 100644 index 0000000..7dceacd --- /dev/null +++ b/packaging/BOOKWORM-ADAPTATIONS.md @@ -0,0 +1,78 @@ +# Bookworm adaptations + +The four sources are rebuilt from their **pinned trixie `debian/-1` packaging** +([`upstream-pins.yaml`](upstream-pins.yaml) `salsa_commit_sha`) — **zero source patches** +(`debian/patches/` is empty for every source; see [`POLICY.md`](../POLICY.md)). The trixie +packaging does not build unmodified on bookworm, so a small set of **packaging-metadata** +adaptations is applied. This file records every one of them and why. + +Each `debian/` dir here is byte-identical to the pinned salsa commit **except** for the +hunks below. Reproduce the delta with `test-results/modem-control/A5.1/debdiff.txt` +(a recursive diff of each adapted `debian/` against the salsa tree at its pinned SHA). + +## libqrtr-glib, libmbim, libqmi — zero adaptations + +These three build unmodified on bookworm. Their `debian/` dirs are pristine copies of the +pinned salsa commits. Their only cross-source build-deps (`libqmi` → `libqrtr-glib-dev`, +`libmbim-glib-dev`) are satisfied by the freshly built packages in the temporary local apt +repo — that is what the bootstrap build order exists for, not a packaging change. + +## ModemManager — three adaptations (all in `debian/control` + `debian/rules`) + +The trixie `modemmanager 1.24.0-1` packaging assumes a **trixie** build environment. Three +things differ on bookworm; each adaptation is the minimal, bookworm-native fix. + +### 1. debhelper version relax (documented) — `debian/control` + +`debhelper (>= 13.11.6)` → `debhelper (>= 13.11.4)`. Bookworm ships debhelper **13.11.4**; +the trixie packaging pinned `>= 13.11.6`, which is unsatisfiable on bookworm. Compat level +is unchanged (`debhelper-compat (= 13)`, provided by bookworm's debhelper). Nothing the +build needs from 13.11.6 is used. + +### 2. `systemd-dev` → `udev` build-dep (documented) — `debian/control` + +`systemd-dev` is a **trixie/forky-only** package that does not exist in bookworm. In trixie +it ships **both** `udev.pc` (for the udev base dir) **and** `systemd.pc` (for the systemd +system-unit dir). On bookworm those two `.pc` files are split across separate packages: +`udev.pc` is in `udev`, `systemd.pc` is in `systemd`. The substitution restores the `udev.pc` +half. `libsystemd-dev (>= 209)` is kept unchanged (it links libsystemd for journal / +suspend-resume and is unrelated to `systemd-dev`). + +### 3. systemd + udev install-dir pins (**adaptation beyond the two documented ones — see below**) — `debian/rules` + +Two meson flags added to `override_dh_auto_configure`: + +``` +-Dsystemdsystemunitdir=/usr/lib/systemd/system +-Dudevdir=/usr/lib/udev +``` + +Both are **required companions to adaptation 2** and are why this is more than a one-token +build-dep swap: + +- **`systemdsystemunitdir`** — MM's `meson.build` calls `dependency('systemd')` whenever the + option is left empty (its default), to read `systemdsystemunitdir` from `systemd.pc`. In + trixie that `.pc` came from `systemd-dev`; after swapping to `udev` it is gone (bookworm's + `udev` ships only `udev.pc`), so `dependency('systemd')` fails with *"systemd required but + not found"*. Pinning the dir explicitly makes meson skip the `dependency('systemd')` call + entirely — exactly what **stock bookworm `modemmanager 1.20.4-1` does** (it build-deps on + `udev` and pins `-Dsystemdsystemunitdir=/lib/systemd/system` in its own `debian/rules`). +- **`udevdir`** — bookworm's `udev.pc` reports `udevdir = /lib/udev` (non-usr), but the trixie + `modemmanager.install` hardcodes usr-merged paths (`usr/lib/systemd`, `usr/lib/udev`). Left + unpinned, meson installs udev files to `/lib/udev` and `dh_install` aborts with *"missing + files: usr/lib/udev"*. Pinning `udevdir` (and using the usr-merged `/usr/lib/systemd/system` + for the unit dir) makes the install paths match the trixie `.install` files. + +## STOP-and-surface note: the documented adaptation set was incomplete + +The plan (A5.1) and draft documented exactly **two** adaptations: debhelper relax and +`systemd-dev → udev`. Empirically, those two alone **do not** produce a clean ModemManager +build on bookworm — the build fails first at meson (`dependency('systemd')` not found) and, +once that is worked around, again at `dh_install` (usr-merged path mismatch). Adaptation 3 +(the two `debian/rules` install-dir pins) is the minimal fix and is precisely what Debian's +own bookworm MM packaging does. It is **packaging metadata, not a source patch**: `debian/` +config only, upstream source untouched, `debian/patches/` still empty — so it does not +trip the [`POLICY.md`](../POLICY.md) no-fork gate. It is surfaced here (rather than applied +silently) because it extends the documented adaptation list; the plan/draft adaptation list +should be corrected to name these systemd/udev install-dir pins as the third bookworm +adaptation for ModemManager. diff --git a/packaging/ModemManager/debian/77-mm-qdl-device-blacklist.rules b/packaging/ModemManager/debian/77-mm-qdl-device-blacklist.rules new file mode 100644 index 0000000..f461587 --- /dev/null +++ b/packaging/ModemManager/debian/77-mm-qdl-device-blacklist.rules @@ -0,0 +1,84 @@ +# do not edit this file, it will be overwritten on update + +ACTION!="add|change", GOTO="mm_qdl_device_blacklist_end" + +# Acer Gobi QDL device +ATTRS{idVendor}=="05c6", ATTRS{idProduct}=="9211", ENV{ID_MM_DEVICE_IGNORE}="1" + +# HP un2400 Gobi QDL Device +ATTRS{idVendor}=="03f0", ATTRS{idProduct}=="201d", ENV{ID_MM_DEVICE_IGNORE}="1" + +# HP un2420 Gobi QDL Device +ATTRS{idVendor}=="03f0", ATTRS{idProduct}=="241d", ENV{ID_MM_DEVICE_IGNORE}="1" + +# Panasonic Gobi QDL device +ATTRS{idVendor}=="04da", ATTRS{idProduct}=="250c", ENV{ID_MM_DEVICE_IGNORE}="1" + +# Dell Gobi QDL device +ATTRS{idVendor}=="413c", ATTRS{idProduct}=="8171", ENV{ID_MM_DEVICE_IGNORE}="1" + +# Novatel Gobi QDL device +ATTRS{idVendor}=="1410", ATTRS{idProduct}=="a008", ENV{ID_MM_DEVICE_IGNORE}="1" +ATTRS{idVendor}=="1410", ATTRS{idProduct}=="a014", ENV{ID_MM_DEVICE_IGNORE}="1" + +# Asus Gobi QDL device +ATTRS{idVendor}=="0b05", ATTRS{idProduct}=="1774", ENV{ID_MM_DEVICE_IGNORE}="1" + +# ONDA Gobi QDL device +ATTRS{idVendor}=="19d2", ATTRS{idProduct}=="fff2", ENV{ID_MM_DEVICE_IGNORE}="1" + +# OQO Gobi QDL device +ATTRS{idVendor}=="1557", ATTRS{idProduct}=="0a80", ENV{ID_MM_DEVICE_IGNORE}="1" + +# Generic Gobi QDL device +ATTRS{idVendor}=="05c6", ATTRS{idProduct}=="9008", ENV{ID_MM_DEVICE_IGNORE}="1" + +# Generic Gobi QDL device +ATTRS{idVendor}=="05c6", ATTRS{idProduct}=="9201", ENV{ID_MM_DEVICE_IGNORE}="1" + +# Generic Gobi QDL device +ATTRS{idVendor}=="05c6", ATTRS{idProduct}=="9221", ENV{ID_MM_DEVICE_IGNORE}="1" + +# Generic Gobi QDL device +ATTRS{idVendor}=="05c6", ATTRS{idProduct}=="9231", ENV{ID_MM_DEVICE_IGNORE}="1" + +# Unknown Gobi QDL device +ATTRS{idVendor}=="1f45", ATTRS{idProduct}=="0001", ENV{ID_MM_DEVICE_IGNORE}="1" + +# Dell Gobi 2000 QDL device (N0218, VU936) +ATTRS{idVendor}=="413c", ATTRS{idProduct}=="8185", ENV{ID_MM_DEVICE_IGNORE}="1" + +# Generic Gobi 2000 QDL device +ATTRS{idVendor}=="05c6", ATTRS{idProduct}=="9208", ENV{ID_MM_DEVICE_IGNORE}="1" + +# Sony Gobi 2000 QDL device (N0279, VU730) +ATTRS{idVendor}=="05c6", ATTRS{idProduct}=="9224", ENV{ID_MM_DEVICE_IGNORE}="1" + +# Samsung Gobi 2000 QDL device (VL176) +ATTRS{idVendor}=="05c6", ATTRS{idProduct}=="9244", ENV{ID_MM_DEVICE_IGNORE}="1" + +# HP Gobi 2000 QDL device (VP412) +ATTRS{idVendor}=="03f0", ATTRS{idProduct}=="241d", ENV{ID_MM_DEVICE_IGNORE}="1" + +# Acer Gobi 2000 QDL device (VP413) +ATTRS{idVendor}=="05c6", ATTRS{idProduct}=="9214", ENV{ID_MM_DEVICE_IGNORE}="1" + +# Asus Gobi 2000 QDL device (VR305) +ATTRS{idVendor}=="05c6", ATTRS{idProduct}=="9264", ENV{ID_MM_DEVICE_IGNORE}="1" + +# Top Global Gobi 2000 QDL device (VR306) +ATTRS{idVendor}=="05c6", ATTRS{idProduct}=="9234", ENV{ID_MM_DEVICE_IGNORE}="1" + +# iRex Technologies Gobi 2000 QDL device (VR307) +ATTRS{idVendor}=="05c6", ATTRS{idProduct}=="9274", ENV{ID_MM_DEVICE_IGNORE}="1" + +# Sierra Wireless Gobi 2000 QDL device (VT773) +ATTRS{idVendor}=="1199", ATTRS{idProduct}=="9000", ENV{ID_MM_DEVICE_IGNORE}="1" + +# CMDTech Gobi 2000 QDL device (VU922) +ATTRS{idVendor}=="16d8", ATTRS{idProduct}=="8001", ENV{ID_MM_DEVICE_IGNORE}="1" + +# Gobi 2000 QDL device +ATTRS{idVendor}=="05c6", ATTRS{idProduct}=="9204", ENV{ID_MM_DEVICE_IGNORE}="1" + +LABEL="mm_qdl_device_blacklist_end" diff --git a/packaging/ModemManager/debian/README.Debian b/packaging/ModemManager/debian/README.Debian new file mode 100644 index 0000000..81e1289 --- /dev/null +++ b/packaging/ModemManager/debian/README.Debian @@ -0,0 +1,30 @@ +ModemManager +============ + + +Important notice +---------------- + +If you are using a mobile device with integrated WWAN module, a manual +configuration step is needed after installing or upgrading modemmanager, +otherwise the module might NOT be useable anymore. + +Unfortunately, the logged error message will be slightly misleading. +So, if you see a message like + + [modem0] couldn't enable interface: 'Invalid transition' + +in your system logs or ModemManager keeps enabling and disabling the +module every second, you are probably affected by this change: + +Starting with release 1.18.4, ModemManager does no longer enable the +automatic FCC unlock for certain types of modems by default. + + THIS MAY BREAK EXISTING SETUPS + +You can restore the old behaviour AT YOUR OWN RISK by creating symlinks +in /etc/ModemManager/fcc-unlock.d: + + sudo ln -sft /etc/ModemManager/fcc-unlock.d /usr/share/ModemManager/fcc-unlock.available.d/* + +See https://modemmanager.org/docs/modemmanager/fcc-unlock/ for details. diff --git a/packaging/ModemManager/debian/changelog b/packaging/ModemManager/debian/changelog new file mode 100644 index 0000000..6f95083 --- /dev/null +++ b/packaging/ModemManager/debian/changelog @@ -0,0 +1,1414 @@ +modemmanager (1.24.0-1) unstable; urgency=medium + + * New upstream release. Final release of RC already in Trixie. + * libmm-glib0: Update symbols + * d/control: Bump libmbim and libqmi versions. + Depend on the final versions rather than the RCs. + + -- Guido Günther Fri, 11 Apr 2025 23:32:31 +0200 + +modemmanager (1.23.95-2) unstable; urgency=medium + + * Upload to unstable + * Bump standards version to 4.7.2 + + -- Guido Günther Thu, 13 Mar 2025 14:34:38 +0100 + +modemmanager (1.23.95-1) experimental; urgency=medium + + * New upstream version 1.23.95 + * Add superficial autopktests + * Bump libqmi and libmbim build dependencies + * Update symbols + * Bump standards version + * Depend on dh-sequence-gir + + -- Guido Günther Tue, 11 Mar 2025 17:49:57 +0100 + +modemmanager (1.23.12-1) experimental; urgency=medium + + [ Arnaud Ferraris ] + * d/control: depend on gobject-introspection-bin. + This is now the package containing `dh_girepository`, having been split + from `gobject-introspection`. + Fixes: lintian: missing-build-dependency-for-dh-addon gir + + [ Jeremy Bicha ] + * Drop unnecessary Build-Depends: gnome-common, intltool, libtool + + [ Guido Günther ] + * gbp.conf: Use branches that don't conflict with released versions + * New upstream version 1.23.12 + * docs: README got renamed to README.md + * Update symbols + * d/control: Bump libqmi build-dep + + -- Guido Günther Tue, 25 Feb 2025 15:19:33 +0100 + +modemmanager (1.22.0-3) unstable; urgency=medium + + [ Jeremy Bícha ] + * d/control: update build dependencies. + Upstream bumped the required version for `libmbim` and `libqmi`. + + [ Arnaud Ferraris ] + * d/control: build-depend on systemd-dev instead of udev. + We only needed `udev` for its pkg-config file, which is now shipped in + `systemd-dev`. (Closes: #1060566) + + -- Arnaud Ferraris Sat, 20 Jan 2024 12:14:00 +0100 + +modemmanager (1.22.0-2) unstable; urgency=medium + + * modemmanager: acknowledge udev rules now being under /usr. + (Closes: #1057269) + * d/control: make libmm-glib-dev depend on libglib2.0-dev. + This is required as `libmm-glib` is actually built on top of `glib`. + (Closes: #1035481) + * d/gbp.conf: use 'debian/latest' as debian branch. + This way we stay consistent with the other team-maintained packages. + + -- Arnaud Ferraris Tue, 02 Jan 2024 09:35:55 +0100 + +modemmanager (1.22.0-2~exp1) experimental; urgency=medium + + * d/gbp.conf: use 'debian/experimental' as debian branch + * debian: keep systemd service under /usr. + In the current state of the Debian "/usr merge", we are allowed to move + systemd services under `/usr/lib/systemd` when relying on + `dh_installsystemd`. This requires adding a versioned dependency on + `debhelper`. + Let's comply by no longer overriding `systemdsystemunitdir`. + + -- Arnaud Ferraris Mon, 30 Oct 2023 17:41:57 +0100 + +modemmanager (1.22.0-1) unstable; urgency=medium + + * New upstream version 1.22.0 + * prerm: drop invalid option (Closes: #1038858) + * d/control: update build dependencies + Upstream bumped the required version for `libmbim` and `libqmi`. + * d/patches: drop backported patch. + This is part of the new released version, so no need for us to carry it + anymore. + * libmm-glib0: update symbols file for new upstream release + + -- Arnaud Ferraris Sat, 21 Oct 2023 10:37:44 +0200 + +modemmanager (1.20.6-2) unstable; urgency=medium + + * d/patches: backport patch fixing SMS reception after suspend on Librem 5 + * d/rules: install D-bus policy to proper system folder. + Fixes: lintian: dbus-policy-in-etc + * d/control: drop alternate dependency on `policykit-1` + This used to be a transitional package for bookworm, it is now obsolete + and shouldn't be mentioned moving forward. + + -- Arnaud Ferraris Tue, 13 Jun 2023 16:56:17 +0200 + +modemmanager (1.20.6-1) experimental; urgency=medium + + * New upstream version 1.20.6 + + -- Arnaud Ferraris Sun, 23 Apr 2023 15:49:32 +0200 + +modemmanager (1.20.4-1) unstable; urgency=medium + + * New upstream version 1.20.4 + * d/control: bump Standards-Version, no change required + + -- Arnaud Ferraris Tue, 03 Jan 2023 13:34:49 +0100 + +modemmanager (1.20.2-1) unstable; urgency=medium + + * d/watch: only watch for stable releases. + ModemManager has a x.y.z version numbering scheme where y is even for + stable releases and odd for development ones. Stable release candidates + have x.y-rcz version numbers with y being an even number. This change + ensures we only watch for stable releases, including RCs. + * New upstream version 1.20.2 + * d/patches: drop upstreamed patch + + -- Arnaud Ferraris Mon, 19 Dec 2022 13:59:08 +0100 + +modemmanager (1.20.0-1) unstable; urgency=medium + + * New upstream version 1.20.0 + * d/control: depend on polkitd instead of policykit-1 + `policykit-1` has recently been split in 2 packages and replaced by a + transitional package. The one we actually need is `polkitd`, so let's + have this one as the "main" dependency. + However, as this package is often backported to bullseye as well, let's + use an alternative as `polkitd` only exists in current testing/sid. + (Closes: #1022072) + * d/watch: look for git tags instead of source tarballs. + Upstream stopped publishing source tarballs and now requests that we + use (signed) git tags for new versions. + * d/control: update build dependencies for new version. + Upstream changed build system from `autotools` to `meson`, let's + acknowledge this change. Due to the way `meson` looks for build + dependencies, this also requires adding a few more one to ensure the + package gets properly built. + * d/rules: update for build system change. + With the change to `meson` as the build system, configure options are + handled differently, requiring minor changes to our package. + * debian: honor nodoc build profile. + Documentation was previously always built. + * debian: update lintian-overrides for new version + * libmm-glib0: update symbols file for new release + * d/patches: make build reproducible + + -- Arnaud Ferraris Fri, 28 Oct 2022 17:54:26 +0200 + +modemmanager (1.18.12-1) unstable; urgency=medium + + [ Evangelos Ribeiro Tzaras ] + * modemmanager: Recommend libqmi-utils and libmbim-utils. + The FCC unlock scripts shipped in modemmanager use `qmicli` to + perform unlocking which is part of the `libqmi-utils` package. + Additionally `mbimcli` is used by the `leac` script, + so recommend libmbim-utils as well. (Closes: #1017975) + + [ Arnaud Ferraris ] + * d/upstream: add new upstream signing key + * New upstream version 1.18.12 + * d/patches: drop upstreamed patches + + -- Arnaud Ferraris Wed, 28 Sep 2022 15:06:56 +0200 + +modemmanager (1.18.10-2) unstable; urgency=medium + + [ Matteo F. Vescovi 2022-09-07 ] + * debian/patches/: patchset updated (Closes: #1017794, #1018198) + - 0001-Fix_invalid_EXZ_error_parser.patch added + - 0002-Fix_issue_with_EOL_for_pcre2.patch added + + -- Martin Wed, 07 Sep 2022 21:55:00 +0000 + +modemmanager (1.18.10-1) unstable; urgency=medium + + * New upstream version 1.18.10 + * d/control: update my email address and bump libqmi dependency. + Upstream bumped the required version of `libqmi`, so let's ensure this + is reflected in this package. + This commit also bumps the Standards-Version, as no additional change is + required. + * debian: update linitian-overrides. + Due to recent changes in `lintian`, our overrides for the -doc packages + are no longer needed. However, a new `source-is-missing` lintian error + message must be overridden as it relates to HTML files, which are source + files themselves. + + -- Arnaud Ferraris Mon, 11 Jul 2022 17:30:09 +0200 + +modemmanager (1.18.8-1) unstable; urgency=medium + + * debian: lintian-driven fixes and small improvements. + This commit includes minor changes spotted by `lintian-brush`: + - remove obsolete/empty maintainer scripts + - `d/upstream/metadata`: append `.git` to repo URL + - `d/rules`: drop symbols migration (1.4.14 is long gone) + * d/control: drop build dependency on libqrtr-glib + `libqrtr-glib-dev` is now a dependency to `libqmi-glib-dev`, and as such + can be safely omitted from this package's build deps. + We must however increase the required `libqmi` version to ensure we + avoid build failures. + * New upstream version 1.18.8 + * d/symbols: update for new release. + + -- Arnaud Ferraris Fri, 20 May 2022 10:19:43 +0200 + +modemmanager (1.18.6-2) unstable; urgency=medium + + * d/control: build-depend on libqrtr-glib. + This enables support for the QRTR protocol so we can use the integrated + modem found on Qualcomm SoCs. + + -- Arnaud Ferraris Wed, 23 Feb 2022 13:58:35 +0100 + +modemmanager (1.18.6-1) unstable; urgency=medium + + [ Bjørn Bürger ] + * Add README.Debian. + * Added upgrade notice regarding changed fcc-unlock.d defaults: + https://bugs.debian.org/cgi-bin/bugreport.cgi?bug=1004447 + + [ Guido Günther ] + * Mention FCC unlock in NEWS.Debian + + [ Arnaud Ferraris ] + * New upstream version 1.18.6 + * d/libmm-glib0: update symbols file for new version. + + -- Arnaud Ferraris Wed, 16 Feb 2022 17:00:52 +0100 + +modemmanager (1.18.4-1) unstable; urgency=medium + + [ Cyril Brulebois ] + * Fix missing dependency on policykit-1 (Closes: #991223). + Without it, a lot of operations would result in the following error at + the D-Bus level: + The name org.freedesktop.PolicyKit1 was not provided by any .service files + In bullseye, ModemManager.service wouldn't even start, because of the + missing polkit.service dependency at the systemd level. + + [ Arnaud Ferraris ] + * New upstream version 1.18.4 + * d/copyright: fix wrong path for libqcdm + `libqcdm` was previously `libwmc`, but the corresponding copyright entry + wasn't updated when the folder was renamed. + * d/control: update packages descriptions. + As it was pointed out, ModemManager now supports 5G modems, make sure + the packages descriptions reflect that. + * debian: address lintian issues. + This commit addresses multiple issues reported by lintian: + - doc installed into `/usr/share/gtk-doc` instead of `/usr/share/doc`, + which is normal behavior for glib-based software + - missing `Build-Depends-Package` in `libmm-glib0` symbols file + - ModemManager plugins not including dependency information + - system-wide `fcc-unlock.d` folder installed even though we don't ship + any distro-specific configuration which would be included there + * d/modemmanager.prerm: don't call systemctl directly. + Debian provides a helper tool for avoiding direct calls to systemctl in + maintainer scripts, so let's use it. + * d/control: sort build dependencies. + Also avoid installing unnecessary dependencies when using the `nodoc` + build profile. + + -- Arnaud Ferraris Tue, 14 Dec 2021 10:09:29 +0100 + +modemmanager (1.18.2-1) unstable; urgency=medium + + [ Henry-Nicolas Tourneur ] + * d/rules: set with-polkit to permissive (Closes: #800598) + + [ Arnaud Ferraris ] + * New upstream version 1.18.2 (Closes: #1000450) + * d/control: change maintainer to DebianOnMobile team (Closes: #949129) + * d/control: bump debhelper-compat version and Standards-Version. + * d/copyright: add Upstream-Contact and update maintainer email + * d/gbp.conf: fix debian branch name + * debian: add salsa CI pipeline + * d/source: add upstream metadata + * d/control: update build dependencies. + * d/watch: bump watch file version, no change required + * d/libmm-glib0: update symbols file for new version + * d/rules: add hardening build options + + -- Arnaud Ferraris Mon, 06 Dec 2021 17:46:45 +0100 + +modemmanager (1.16.6-2) experimental; urgency=medium + + * debian/control: include python build-depends for tests + + -- Sebastien Bacher Fri, 25 Jun 2021 15:32:16 +0200 + +modemmanager (1.16.6-1) experimental; urgency=medium + + * New upstream version + * debian/control.in: + - updated libqmi requirement + * debian/libmm-glib0.symbols: + - updated for the new version + + -- Sebastien Bacher Fri, 25 Jun 2021 11:30:20 +0200 + +modemmanager (1.14.12-0.1) unstable; urgency=medium + + * Non-maintainer upload. + * New upstream version, bug fixes only, no new features. + - MBIM: + - Plug memleak in disconnection logic. + - Don't fail IPv4v6 connection attempt if only IPv4 succeeds. + - QMI: + - Fix network registration cancellation logic with asserts disabled. + + [ Helmut Grohne ] + * Annotate dbus dependency . (Closes: #980827) + + -- Martin Fri, 12 Mar 2021 18:09:44 +0000 + +modemmanager (1.14.10-0.1) unstable; urgency=medium + + * Non-maintainer upload. + * New upstream version. + * Add upstream signing key. + + -- Martin Sat, 16 Jan 2021 17:23:28 +0000 + +modemmanager (1.14.8-0.1) unstable; urgency=medium + + * Non-maintainer upload. + * New upstream version. + + -- Martin Mon, 16 Nov 2020 12:22:26 +0000 + +modemmanager (1.14.6-0.1) unstable; urgency=medium + + * Non-maintainer upload. + * New upstream version. + + -- Martin Wed, 14 Oct 2020 15:11:13 +0000 + +modemmanager (1.14.2-0.1) unstable; urgency=medium + + * Non-maintainer upload. + * New upstream version. + + -- Martin Wed, 19 Aug 2020 17:43:57 +0000 + +modemmanager (1.14.0-0.1) unstable; urgency=medium + + * Non-maintainer upload. + * New upstream version. + + -- Martin Tue, 23 Jun 2020 18:16:49 +0000 + +modemmanager (1.13.900-0.1) experimental; urgency=medium + + * Non-maintainer upload. + * New upstream version. + * debian/libmm-glib0.symbols: 13 additional symbols, + mm_common_build_capability_combinations_any was not part of API, + no soname bump necessary. + + -- Martin Fri, 12 Jun 2020 08:23:35 +0000 + +modemmanager (1.12.10-0.2) unstable; urgency=medium + + * Non-maintainer upload. + * Apply upstream patch to fix problem with wavecom modems. + + -- Martin Sun, 07 Jun 2020 11:01:26 +0000 + +modemmanager (1.12.10-0.1) unstable; urgency=medium + + * Non-maintainer upload. + * New upstream version. + + -- Martin Tue, 26 May 2020 22:39:43 +0000 + +modemmanager (1.12.8-1.1) unstable; urgency=medium + + * Non-maintainer upload. + * Update Vcs-* to salsa. + * Use Rules-Requires-Root: no + + -- Martin Thu, 30 Apr 2020 00:44:37 +0000 + +modemmanager (1.12.8-1) unstable; urgency=medium + + * New upstream version + + -- Sebastien Bacher Fri, 10 Apr 2020 17:34:03 +0200 + +modemmanager (1.12.6-1) unstable; urgency=medium + + * New upstream version + * debian/control: + - updated qmi requirement + * debian/libmm-glib0.symbols: + - refreshed the symbols for the new version + + -- Sebastien Bacher Thu, 27 Feb 2020 21:23:17 +0100 + +modemmanager (1.10.4-0.1) unstable; urgency=medium + + * Non-maintainer upload. + * New upstream release + * debian/patches/error-propagation-fix.patch: Removed, included + upstream. + * debian/control: Update build dependencies as now libqmi >= 1.22.4 is + required. + * debian/modemmanager.install: Also install /usr/share/ModemManager, + for the new carrier mapping files. + * debian/libmm-glib0.symbols: Updated symbols. + + -- Till Kamppeter Tue, 20 Aug 2019 20:37:03 +0200 + +modemmanager (1.10.0-1) unstable; urgency=medium + + * New upstream release + * debian/control: Update build dependencies as now libmbim >= 1.18.0 and + libqmi >= 1.22.0 are required. + * debian/libmm-glib0.symbols: update symbols for new release. + + -- Till Kamppeter Tue, 05 Feb 2019 23:40:29 +0100 + +modemmanager (1.8.2-1) unstable; urgency=medium + + * New upstream version 1.8.2. (Closes: #910013) (Closes: #900907) + * debian/modemmanager.postrm: don't unmask ModemManager on postrm; we were + unmasking because we're about to remove ModemManager anyway, and prerm did + its mask with --runtime which is supposed to not be persistent. + (Closes: #902260) + * debian/control: canonicalize Vcs-* URLs. + * debian/control: bump to Standard-Version: 4.2.1; no changes required. + + -- Mathieu Trudel-Lapierre Thu, 04 Oct 2018 10:01:18 -0400 + +modemmanager (1.7.990-1ubuntu1) cosmic; urgency=low + + * Merge from Debian unstable. Remaining changes: + - Pass --enable-more-warnings so we build without -Werror. Patch + m4/compiler_warnings.m4 to allow this. + + -- Gianfranco Costamagna Wed, 06 Jun 2018 16:34:06 +0200 + +modemmanager (1.7.990-1) unstable; urgency=medium + + * New upstream version 1.7.990. + * debian/control: Bump libqmi-glib-dev and libmbim-glib-dev Depends to new + required versions (libmbim > 1.16, libqmi > 1.20) for this release. + * Update Maintainer field for my 'real' address. (Closes: #884617) + * debian/control: Update Vcs-* fields: moved project to salsa.d.o. + * debian/control: Bump to Standards-Version 4.1.3. + * debian/copyright: + - Fix Format: URL to use https. + - Drop extra stanza for libwmc; it really is only GPL2, not GPL2+. + * debian/rules: set DPKG_GENSYMBOLS_CHECK_LEVEL=4; more verbose when there + are added/changed symbols. + * debian/libmm-glib0.symbols: update symbols for new release. + * debian/patches/default_strict_probing_policy.patch: set the filtering + policy for probing to 'strict' by default: this will avoid probing devices + that are not modems, and potentially interfering with RNGs, braille, etc. + (Closes: #683839) + + -- Mathieu Trudel-Lapierre Thu, 01 Mar 2018 09:20:02 -0500 + +modemmanager (1.6.8-2ubuntu1) bionic; urgency=medium + + * Pass --enable-more-warnings so we build without -Werror. Patch + m4/compiler_warnings.m4 to allow this. + + -- Iain Lane Tue, 24 Apr 2018 12:06:03 +0100 + +modemmanager (1.6.8-2) unstable; urgency=medium + + * Remove myself from Uploaders and re-instate Mathieu Trudel-Lapierre as + Maintainer. + + -- Michael Biebl Sat, 21 Oct 2017 00:45:12 +0200 + +modemmanager (1.6.8-1) unstable; urgency=medium + + * New upstream version 1.6.8 + * Drop obsolete Breaks and Replaces from pre-jessie. + * Move D-Bus interface files into modemmanager-dev package + + -- Michael Biebl Sat, 17 Jun 2017 13:40:08 +0200 + +modemmanager (1.6.4-1) unstable; urgency=medium + + * New upstream release. + * Drop overrides for dh_systemd_start and dh_installinit. + Starting with compat level 10, --restart-after-upgrade is the default. + * Drop obsolete maintainer scripts code. + + -- Michael Biebl Wed, 16 Nov 2016 19:08:30 +0100 + +modemmanager (1.6.2-1) unstable; urgency=medium + + * New upstream release. + * Bump debhelper compat level to 10. + + -- Michael Biebl Sat, 17 Sep 2016 09:32:49 +0200 + +modemmanager (1.6.0-1) unstable; urgency=medium + + * New upstream release. + + -- Michael Biebl Thu, 28 Jul 2016 15:39:57 +0200 + +modemmanager (1.5.993-1) unstable; urgency=medium + + * New upstream release (1.6-rc4). + * Update Build-Depends as per configure.ac. + * Install bash-completion file for mmcli. + * Update symbols file. + * Enable suspend/resume support. Requires libsystemd. + * Bump Standards-Version to 3.9.8. + + -- Michael Biebl Fri, 08 Jul 2016 02:19:24 +0200 + +modemmanager (1.4.14-1) unstable; urgency=medium + + [ Laurent Bigonville ] + * New upstream release. + * debian/control: Fix capitalisation of Bluetooth (Closes: #754439) + * Remove modemmanager-dbg package and rely on automatic dbgsym package + * debian/control: Fix Vcs-Git URL (again) to please lintian + * debian/control: Bump Standards-Version to 3.9.7 (no further changes) + + [ Michael Biebl ] + * Remove outdated debian/README.source. + * Use https:// for upstream homepage. + * Bump Build-Depends on debhelper for dh_strip --dbgsym-migration support. + * Drop Ubuntu specific upstart job now that Ubuntu has switched to systemd as + well. (Closes: #804054) + + -- Michael Biebl Wed, 23 Mar 2016 21:30:46 +0100 + +modemmanager (1.4.12-1) unstable; urgency=medium + + * New upstream release. + + -- Michael Biebl Thu, 08 Oct 2015 12:47:56 +0200 + +modemmanager (1.4.10-1) unstable; urgency=medium + + * New upstream release. + - Blacklist: ignore devices from Posnet Polska S.A. (Closes: #791341) + + -- Michael Biebl Sun, 26 Jul 2015 20:43:23 +0200 + +modemmanager (1.4.8-1) unstable; urgency=medium + + * Upload to unstable. + * New upstream release. + + -- Michael Biebl Fri, 08 May 2015 01:44:53 +0200 + +modemmanager (1.4.4-1) experimental; urgency=medium + + * Imported Upstream version 1.4.4. + * Bump Build-Depends on libqmi-glib-dev to (>= 1.12.4). + + -- Michael Biebl Fri, 13 Feb 2015 10:28:55 +0100 + +modemmanager (1.4.2-1) experimental; urgency=medium + + [ Marius B. Kotsbak ] + * Imported Upstream version 1.4.2. + * Removed all patches applied upstream. + * Update to latest Standards-Version 3.9.6. + + [ Michael Biebl ] + * Set debian-branch to experimental in gbp.conf. + * Update Vcs-Browser to use cgit and https. + * Install typelib files into multiarch paths now that gobject-introspection + supports that. + * Mark dev and gir packages as Multi-Arch: same. + + -- Michael Biebl Mon, 09 Feb 2015 00:29:46 +0100 + +modemmanager (1.4.0-1) unstable; urgency=medium + + * New upstream release. + - Fixes conflicting definitions of mm_manager_new. (Closes: #749387) + * Refresh patches. + * Bump Build-Depends on libmbim-glib-dev to (>= 1.10). + * Install NEWS file which includes the relevant upstream changes. + (Closes: #641467) + * Update symbols file for libmm-glib0. + * Add Build-Depends on dbus, required to run the test suite. + + -- Michael Biebl Wed, 17 Sep 2014 08:46:25 +0200 + +modemmanager (1.2.0-1) unstable; urgency=medium + + [ Guido Günther ] + * New upstream version 1.2.0 (Closes: #731851) + * Update patches + * Install locale files + * Require newer libqmi + * Update symbols file + * Ship gobject introspection data + * Ship vala bindings + + [ Michael Biebl ] + * Use canonical URI for Vcs-Git + * Use gir dh addon + * Update extendend package description (Closes: #744180) + + -- Michael Biebl Wed, 25 Jun 2014 02:23:09 +0200 + +modemmanager (1.0.0-5) unstable; urgency=medium + + [ Michael Biebl ] + * Fix typo in package description (Closes: #745140) + + [ Martin Pitt ] + * libmm-glib: Initialize result array + * Add systemd unit link to shadow upstart job (LP: #1314795) + * Fix systemd/upstart job on upgrades on Ubuntu + * Avoid cancelling of systemd unit stop on remove (Closes: #745621) + + -- Martin Pitt Tue, 03 Jun 2014 09:15:24 +0200 + +modemmanager (1.0.0-4) unstable; urgency=medium + + * Kill old modem-manager process when upgrading from pre 1.0.0 versions. + + -- Michael Biebl Sat, 29 Mar 2014 00:46:03 +0100 + +modemmanager (1.0.0-3) unstable; urgency=medium + + [ Michael Biebl ] + * Upload to unstable. + * Refresh patches to apply cleanly. + + [ Guido Günther ] + * Update VCS- URLs + * Add homepage URL + * Enable PolicyKit support (Closes: #736475, #736900) + + [ Michael Biebl ] + * Update Standards Version to 3.9.5 + * Drop link to Git repository from package descriptions + * Build-Depend on the latest automake version + * Fix D-Bus service file generation (Closes: #733752) + * Add gbp.conf and enable pristine-tar + * Install systemd service file + * Use dh-systemd to enable the systemd service and restart it on upgrades + * Set pkg-utopia-maintainers@lists.alioth.debian.org as Maintainer + * Remove obsolete D-Bus policy conffile on upgrades (Closes: #733305) + * Update debian/copyright using machine-readable copyright format 1.0 + + -- Michael Biebl Thu, 27 Mar 2014 15:46:44 +0100 + +modemmanager (1.0.0-2) experimental; urgency=medium + + * Sync more changes from Ubuntu: + - changelog for 0.6.0.0.really-0ubuntu4 (all changes dropped, they are + patches included in the current upstream version). + - changelog for 0.6.0.0.really-0ubuntu5 (all changes dropped, they are + patches included in the current upstream version). + - changelog for 0.6.0.0.really-0ubuntu6. + - Build-Depend on automake1.11. + - changelog for 0.6.0.0.really-0ubuntu7. + - lp1229748_bluegiga_blacklist.patch: don't reset BLED112B. + + -- Mathieu Trudel-Lapierre Tue, 14 Jan 2014 10:02:26 -0500 + +modemmanager (1.0.0-1) experimental; urgency=low + + * New upstream release. (Closes: #728214, #728215) + * debian/control: add a versioned dependency for libqmi -- we need at least + version 1.4. + * debian/control: build-depends on libmbim-glib-dev 1.4 + * debian/rules: run the local autogen.sh rather than gnome-autogen.sh. + * debian/rules: drop --with-tests. + * debian/rules: clean up after gtk-doc files left around. + * debian/patches/glib_fixes.patch: link against glib explicitly for the + huawei modem helper tests. + * debian/libmm-glib0.symbols: updated symbols: Contacts interface that was + in 0.7.990 got dropped; it's not actually implemented, so upstream avoids + exposing it. + * debian/watch: update watch file for new location of upstream source. + + -- Mathieu Trudel-Lapierre Fri, 20 Dec 2013 15:35:26 -0500 + +modemmanager (0.7.991-1) experimental; urgency=low + + [ Mathieu Trudel-Lapierre ] + * New upstream release. + * debian/patches/handle_data07_capabilities_probing.patch, + debian/patches/git_skip_add_utf8_check_219424a.patch, + debian/patches/git_lte_etsi_mode_0af47c7.patch, + debian/patches/git_lte_4g_parsing_90489ae.patch, + debian/patches/git_lp1015328_segfault_in_clck_parser_318aaa0.patch, + debian/patches/git_better_handle_ucs2_convert_e07c216.patch: Dropped, + these patches were cherry-picks; included upstream + * debian/patches/ericsson_h5321gw_usbids.patch: dropped, included upstream. + * debian/control: + - clean up build-depends: remove xsltproc, bump + libglib2.0-dev to (>= 2.30.2), libgudev-1.0-dev to (>= 147). + - Add gtk-doc-tools to Build-Depends. + - Add libqmi-glib-dev to Build-Depends. + - Add gnome-common to Build-Depends. + - Bump debhelper Build-Depends to >= 9. + - Make sure libmm-glib0 pre-depends on multiarch-support (lintian). + - Make libmm-glib0 Multi-Arch: same. + - Make sure all binary packages (except modemmanager-dbg) are priority + optional. + - Update short descriptions. + - Breaks network-manager (<< 0.9.8.2-1) since otherwise NetworkManager + will not see ModemManager with its new API. + * debian/compat: bump to compat level 9. + * debian/rules: + - Replace --with-docs with --enable-gtk-doc. + - Fix autoreconf to run gnome-autogen.sh. + - Drop the override for installdocs; docs/spec.html isn't being built + anymore. + - Run dh_install with --fail-missing. + - Remove test pppd plugin which we shouldn't install. + - Drop old cruft for getting git snapshots. + - We don't need to exclude the pppd path from makeshlibs, since nothing + gets installed there. + * debian/patches/lp700316_usb_blacklist.patch: refreshed. + * debian/patches/arduino-blacklist.patch: refreshed. + * debian/patches/linux-default-usb-id.patch: refreshed. + * debian/*.install: make sure the files are properly installed given the new + packages, also take into account multiarch paths. + * debian/modemmanager.install: install the new mmcli binary. + * debian/ubuntu/modemmanager.upstart: fix the name for the ModemManager + binary, since it was changed upstream. + * debian/patches/dbus_remove_max_replies_per_connection_limit.patch: dropped, + included upstream. + + [ Michael Biebl ] + * Add symbols file for libmm-glib0. + + [ Marius B. Kotsbak ] + * Added binary packages modemmanager-doc and libmm-glib-doc for gtk-docs. + * Split out modemmanager-dev package containing header files and .pc file. + - add dependency on modemmanager-dev from libmm-glib-dev + as stated in "mm-glib.pc" + - add proper replaces/breaks for modemmanager-dev because of moved files + * Build-depends: added "libglib2.0-doc" for the cross references in the doc + to work. + * debian/rules: make dh_makeshlibs override multiarch aware. + * Update standards version to current 3.9.4. + + -- Mathieu Trudel-Lapierre Tue, 11 Jun 2013 10:35:42 -0400 + +modemmanager (0.6.0.0.really-0ubuntu7) saucy; urgency=low + + * debian/patches/lp1229748_bluegiga_blacklist.patch: don't reset BLED112B. + (LP: #1229748) + + -- Alan Bell Tue, 24 Sep 2013 15:39:47 +0100 + +modemmanager (0.6.0.0.really-0ubuntu6) saucy; urgency=low + + * Build-depend on automake1.11. + + -- Matthias Klose Tue, 24 Sep 2013 14:57:53 +0200 + +modemmanager (0.6.0.0.really-0ubuntu5) raring; urgency=low + + * debian/patches/lp1164023_try_csq_if_cind_fails_5d854a3.patch: fallback to + trying AT+CSQ if AT+CIND fails. (LP: #1164023) + * debian/patches/git_udev_match_both_vid_pid_e322ccf.patch: match both VID + and PID when matching devices: If the rules to tag specific USB interface + numbers only apply on the PID, we'll end up seeing that if the port has a + parent with another PID, and that other PID also has a rule, port will get + tagged multiple times. + * debian/patches/git_mbm_remove_check_poll_when_conn_1652019.patch: mbm: + remove connection attempt check poll explicitly when connected: Completing + a MMCallbackInfo is done asynchronously (in an idle), which means that we + may get the poll timeout called in between... + + -- Mathieu Trudel-Lapierre Wed, 10 Apr 2013 21:20:53 -0400 + +modemmanager (0.6.0.0.really-0ubuntu4) raring; urgency=low + + * debian/patches/git_cdma_double_free_05a4226.patch: cdma: avoid double free + of GError (LP: #1083659) + * debian/patches/git_ignore_arduino_aa84ce9.patch: properly ignore more + arduino devices. (LP: #1153632) + * debian/patches/git_ignore_west_mountain_radios_e608b17.patch: ignore West + Mountain amateur radio systems. (LP: #1154654) + * debian/patches/lp700316_usb_blacklist.patch: refreshed. + + -- Mathieu Trudel-Lapierre Mon, 01 Apr 2013 11:07:37 -0400 + +modemmanager (0.6.0.0.really-0ubuntu3) raring; urgency=low + + * debian/patches/linux-default-usb-id.patch: blacklist the USB-Gadget default + USB ID (the module actually allows you to set a custom one). The default ID + is re-used by the Nexus 7 with Ubuntu images to expose a tty you can use + to login to the system; but if it's not ignored ModemManager will + harass it for some time trying to probe it. (LP: #1105352) + + -- Mathieu Trudel-Lapierre Mon, 28 Jan 2013 11:24:42 -0500 + +modemmanager (0.6.0.0.really-0ubuntu2) raring; urgency=low + + * debian/patches/handle_data07_capabilities_probing.patch: properly handle + probing for the Huawei Data07 modem. (LP: #1071492) + + -- Mathieu Trudel-Lapierre Tue, 20 Nov 2012 17:26:04 -0500 + +modemmanager (0.6.0.0.really-0ubuntu1) quantal; urgency=low + + * Upload the right tarball for 0.6.0.0 from upstream, which I somehow + mishandled before. Thanks to Marius B. Kotsbak for pointing out the issue. + + -- Mathieu Trudel-Lapierre Mon, 08 Oct 2012 11:03:19 -0400 + +modemmanager (0.6.0.0-0ubuntu4) quantal; urgency=low + + * debian/patches/git_fall_back_to_csq_cind_8bd6903.patch: fallback to +CSQ if + +CIND reports a signal level of 0. (LP: #1060831) + * debian/patches/git_move_cind_process_func_1dee45e.patch: moving the CIND + processing function down a bit to make the above change cleaner. Cherry-pick + from upstream. + * debian/patches/ericsson_h5321gw_usbids.patch: Add another known USB ID for + the Ericsson H5321gw. (LP: #1057956) + + -- Mathieu Trudel-Lapierre Thu, 04 Oct 2012 16:38:54 -0400 + +modemmanager (0.6.0.0-0ubuntu3) quantal; urgency=low + + * debian/patches/git_lte_4g_parsing_90489ae.patch, + debian/patches/git_lte_etsi_mode_0af47c7.patch: cherry-pick the patches + to properly handle and display LTE/4G technology modes. (LP: #1044744) + * debian/patches/git_lp1015328_segfault_in_clck_parser_318aaa0.patch: avoid + crashing when parsing +CLCK responses. (LP: #1015328) + + -- Mathieu Trudel-Lapierre Tue, 25 Sep 2012 16:51:16 -0400 + +modemmanager (0.6.0.0-0ubuntu2) quantal; urgency=low + + * debian/patches/git_better_handle_ucs2_convert_e07c216.patch, + debian/patches/git_skip_add_utf8_check_219424a.patch: fix UCS2 conversion + for some Huawei devices which return "garbage" along with the response for + the +COPS command. (LP: #1049426) + + -- Mathieu Trudel-Lapierre Fri, 14 Sep 2012 14:15:15 -0400 + +modemmanager (0.6.0.0-0ubuntu1) quantal; urgency=low + + * New upstream release. (LP: #1043486) + * debian/patches/fix-format-string.patch: dropped, applied upstream. + * debian/patches/qdl-blacklist.patch: replaced with installing the rules file + directly to the udev rules directory. + * debian/77-mm-qdl-device-blacklist.rules, debian/rules: additional rules + file from the qdl-blacklist.patch; we now install it directly to the udev + rules directory. This fixes daily builds. + * debian/rules: build with --with-tests. + + -- Mathieu Trudel-Lapierre Fri, 07 Sep 2012 10:58:06 -0400 + +modemmanager (0.5.2.0-2) unstable; urgency=low + + * debian/patches/dbus_remove_max_replies_per_connection_limit.patch: Remove + 'max_replies_per_connection' limit from D-Bus configuration which sets it + to 512. It was intended to increase the limit from its historical value of + 32. However, since 2007 the default limit has been 8192, so this is actually + a reduction. (Closes: #678964) + + -- Michael Biebl Thu, 06 Sep 2012 17:07:18 +0200 + +modemmanager (0.6~git201206221719.8289a64-0ubuntu1) quantal; urgency=low + + * upstream snapshot 2012-06-22 17:19:35 (GMT) + + 8289a646fd19b6ddfba48214e94297ff96f731eb + - decode: harmonize with git master + - decode: update with latest QMI enums + - zte: try to handle Icera devices that use PPP + - uml290: allow setting more global modes (LP: #824114) + - qcdm: fix 1x/HDR mode pref and add GSM/UMTS mode prefs + - trivial: whitespace fixes + - uml290: add mode switching tool + - wmc: namespace stuff properly + - qcdm: namespace stuff properly + - wmc: add command for setting global mode + - cdma: fix QCDM registration state checking + - test: ignore ESN errors in info.py + - dbus: remove 'max_replies_per_connection' limit from D-Bus configuration + * debian/patches/fix-format-string.patch: fix a missing format string in + a fprintf call for uml290mode; it makes the build fail otherwise. + + -- Mathieu Trudel-Lapierre Fri, 22 Jun 2012 15:04:53 -0400 + +modemmanager (0.6~git201203261122.16a0029-0ubuntu1) quantal; urgency=low + + * upstream snapshot 2012-03-26 11:22:47 (GMT) + + 16a00296e42aeceaca3d43c00baa01b7d4de3aa2 + * debian/rules: switch to upstream git branch MM_06. + + -- Mathieu Trudel-Lapierre Fri, 15 Jun 2012 13:45:28 -0400 + +modemmanager (0.5.2.0-1) unstable; urgency=low + + * Upload to unstable. + * debian/watch: Track .xz tarballs. + + -- Michael Biebl Sat, 24 Mar 2012 01:24:48 +0100 + +modemmanager (0.5.2.0-0ubuntu1) precise; urgency=low + + * New upstream bugfix release (stable release 0.5.2). + - hso: disable echo removal (LP: #953294) + - gsm: retry sending SMS in PDU mode if text fails and PDU is supported + + -- Mathieu Trudel-Lapierre Wed, 14 Mar 2012 12:19:08 -0400 + +modemmanager (0.5.1.97-0ubuntu1) precise; urgency=low + + * New upstream bugfix release. + - release: update NEWS + - cdma: fix crash on NULL error (bgo #670145) + - Revert "huawei: rework probing and detection" (LP: #868034) + - core: fix loop limits in echo removal + - at-serial-port: implement built-in echo/garbage removal (LP: #916038) + + -- Mathieu Trudel-Lapierre Wed, 07 Mar 2012 16:22:00 -0500 + +modemmanager (0.5.1.96+git201202081807.635fce1-0ubuntu3) precise; urgency=low + + * debian/ubuntu/modemmanager.upstart: Remove unnecessary expect fork as + this does the same thing as just exec'ing. Also check for existence of + modemmanager in case it has been removed but not purged. (LP: #942908) + + -- Clint Byrum Tue, 28 Feb 2012 14:35:15 -0800 + +modemmanager (0.5.1.96+git201202081807.635fce1-0ubuntu2) precise; urgency=low + + * debian/ubuntu/modemmanager.upstart: fix stop condition to make sure we stop + only when NetworkManager is stopped as much as possible, to avoid being + respawned by it via DBus. (LP: #869635, #919071) + + -- Mathieu Trudel-Lapierre Thu, 16 Feb 2012 10:43:54 -0500 + +modemmanager (0.5.1.96+git201202081807.635fce1-0ubuntu1) precise; urgency=low + + * upstream snapshot 2012-02-08 18:07:13 (GMT) + + 635fce193ff3a1dbbdee2abab9aa3ab121df25f0 + * debian/rules: as for NetworkManager, drop a few characters from the version + number for git snapshots. + + -- Mathieu Trudel-Lapierre Wed, 08 Feb 2012 15:47:24 -0500 + +modemmanager (0.5+git.20111231t174444.1e332ab-0ubuntu1) precise; urgency=low + + [ Mathieu Trudel-Lapierre ] + * upstream snapshot 2011-12-31 17:44:44 (GMT) + + 1e332abc957d7eea4521c95d1e28b097de5427e5 + + [ Artem Popov ] + * debian/patches/arduino-blacklist.patch: add Arduino devices to blacklist + to prevent managing Arduino devices. (LP: #910736) + - [0403:6001] FT232 USB-Serial (UART) IC. + - [03eb:204b] Atmel Corp. LUFA USB to Serial Adapter Project. + - Anything with the Arduino VID (2341). + + -- Artem Popov Mon, 02 Jan 2012 13:32:18 +0700 + +modemmanager (0.5-1ubuntu1) oneiric; urgency=low + + * debian/patches/qdl-blacklist.patch: add more devices to the blacklist of + Gobi QDL devices. (LP: #842702, #807889) + - [1410:a014] Novatel Gobi found in the Google CR-48. + - [03f0:241d] HP un2420 Gobi found in the HP Mini 5102 and Mini 5103. + + -- Mathieu Trudel-Lapierre Tue, 06 Sep 2011 10:09:17 -0400 + +modemmanager (0.5-1) unstable; urgency=low + + * debian/rules: override dh_autoreconf in a nicer way so we don't have to + clean up manually afterwards. + + -- Michael Biebl Sun, 07 Aug 2011 01:47:27 +0200 + +modemmanager (0.5-0ubuntu1) oneiric; urgency=low + + * New upstream release 0.5. + - gsm: send init command twice to make the N900 happy (LP: #765516) + - fix sierra modems' sleep mode command (LP: #459052, #738005) + * debian/patches/lp700316_usb_blacklist.patch: add extra devices to blacklist + of USB devices known to usually be serial dongles or other things MM should + not touch. (LP: #700316) + * debian/control, debian/rules: add a -dbg package for modemmanager, and + override dh_strip accordingly. (LP: #415394) + * debian/rules: fix .la/.a file removal to not fail if there is nothing to + remove. + * debian/modemmanager.install: install files to the modemmanager package + explicitly now that it's not the only binary package. + + -- Mathieu Trudel-Lapierre Fri, 05 Aug 2011 12:46:32 -0400 + +modemmanager (0.4.997-1ubuntu1) oneiric; urgency=low + + * debian/modemmanager.upstart: add an upstart config file so ModemManager + gets started just before NM, and stopped along with it. (LP: #806082) + * debian/rules: install upstart file only for Ubuntu. + + -- Mathieu Trudel-Lapierre Fri, 08 Jul 2011 15:32:13 -0400 + +modemmanager (0.4.997-1) unstable; urgency=low + + * debian/watch: Switch to .bz2 tarballs. + * Upload to unstable. + + -- Michael Biebl Thu, 16 Jun 2011 17:12:41 +0200 + +modemmanager (0.4.997-0ubuntu1) oneiric; urgency=low + + * New upstream release 0.4.997. + + switch to git branch MM_05. + + -- Mathieu Trudel-Lapierre Thu, 09 Jun 2011 09:41:20 -0400 + +modemmanager (0.4+git.20110429t103114.863dbca-1) unstable; urgency=low + + [ Thomas Bechtold ] + * debian/rules, debian/control: migrate from CDBS to using dh + + [ Mathieu Trudel-Lapierre ] + * upstream snapshot 2011-04-29 10:31:14 (GMT) + + 863dbca63132b820fca6c48a9c212f852752ee16 + * debian/README.source: add instructions for using this packaging branch and + how to build from it; adapted from the NetworkManager package. + * debian/rules, debian/control: update control and rules to use dh-autoreconf + rather than just autoreconf. + * debian/control: bump Standards-Version to 3.9.2. + * debian/rules: re-add DEB_* variables which got dropped from the dh + migration, since we no longer include buildvars.mk. + * debian/patches/git-backport-e208c52-to-0c4b944.patch, + debian/patches/git-backport-verbose-cmee-errors-7d20acc.patch: dropped, + applied upstream. + * debian/control: bump debhelper Build-Depends to 8. + * debian/compat: bump to compat level 8, according to the debhelper update. + * debian/rules: cleanup extra files left behind by intltoolize. + + -- Michael Biebl Wed, 04 May 2011 13:17:33 +0200 + +modemmanager (0.4+git.20110124t203624.00b6cce-2ubuntu1) natty; urgency=low + + * debian/patches/git-backport-e208c52-to-0c4b944.patch: backport a number of + upstream bug fixes: + - gsm: fix for parsing malformed Gobi CREG response + - core: allow plugins to handle custom init responses (LP: #712580) + - policy: loosen permissions somewhat for reading device info (kde#266807) + - logging: use glong for secs and usecs + - cdma: ensure the ActivationStateChanged signal exists + - log: fix spacing so messages line up + - simtech: add port tags for SCT U300 (Element Mobile) + - core: allow platform devices without a VID/PID + - zte: fix handling of Icera simple connect process + - icera: fix username and password ordering for authentication + * debian/patches/0001-Use-type-glong-for-secs-and-usecs.patch: drop, included + in the above backports from upstream. + * debian/patches/git-backport-verbose-cmee-errors-7d20acc.patch: backport + patch to make sure error reporting is set to verbose when asking modems + about PIN status, so we get a useful message instead of a generic error. + + -- Mathieu Trudel-Lapierre Sun, 10 Apr 2011 18:16:54 -0400 + +modemmanager (0.4+git.20110124t203624.00b6cce-2) unstable; urgency=low + + * debian/patches/0001-Use-type-glong-for-secs-and-usecs.patch + - GVariant defines tv_usec as glong and suseconds_t is not guaranteed to + be of type long on sparc, resulting in a build failure. + So use type glong instead for sec and usecs. + + -- Michael Biebl Fri, 25 Feb 2011 11:16:05 +0100 + +modemmanager (0.4+git.20110124t203624.00b6cce-1) unstable; urgency=low + + * Upload to unstable. + * Fixes GTest build failures. (Closes: #614448) + * Switch to source format 3.0 (quilt) + - Add debian/source/format. + - Drop Build-Depends on quilt. + - Remove /usr/share/cdbs/1/rules/patchsys-quilt.mk from debian/rules. + * Bump Standards-Version to 3.9.1. No further changes. + * Bump Build-Depends on libdbus-glib-1-dev to (>= 0.86). + + -- Michael Biebl Fri, 25 Feb 2011 00:08:15 +0100 + +modemmanager (0.4+git.20110124t203624.00b6cce-0ubuntu1) natty; urgency=low + + * upstream snapshot 2011-01-24 20:36:24 (GMT) + + 00b6cce4df7d4acbf3e580a03c2f044e18589d2c + - rules: blacklist some unlikely USB serial dongles + - gsm: enable unsolicited codes on secondary ports too + - test: handle cancel and distinguish between initiate and respond + - gsm: wire up USSD Respond function + - api: add reply parameter to USSD Respond method + - linktop: add plugin for Linktop/Teracom LW273 + - gsm: prefer AT+CIND signal quality for modems that support it (LP: #682282) + - huawei: don't spam syslog with tx/rx stats (LP: #673457, #662791) + - gsm: query signal strength with +CIND if modem does not support +CSQ + - gsm: add preliminary USSD support + - huawei: fix potential double-free on error + - core: prefer CDMA capabilities over GSM for dual-mode devices + - gsm: allow use of GSM 03.38 character set + - polkit: fix for polkit >= 0.97 + - core: work around dbus-glib property access bug (CVE-2010-1172) + * debian/patches/qdl-blacklist.patch: add a blacklist of Gobi QDL devices so + that we don't break firmware loading by trying to initialize them before + the modem is really ready. (LP: #686418) + + -- Mathieu Trudel-Lapierre Wed, 26 Jan 2011 21:03:12 -0500 + +modemmanager (0.4+git.20100809t153145.be28089-0ubuntu1) maverick; urgency=low + + * upstream snapshot 2010-08-09 15:31:45 (GMT) + + be28089dc4c1b07d9def45a3c763f432ae8322c4 + - cdma: determine EVDO registration even when in 1X mode + - zte: add more port tags + - gsm: ensure invalid operator names don't get used (rh #597088) + - nokia: N900 appears to need a longer port delay (rh #583691) + - novatel: detect CDMA home/roaming status + - novatel: fix S720 signal quality reporting + + -- Mathieu Trudel-Lapierre Wed, 11 Aug 2010 10:57:27 -0400 + +modemmanager (0.4+git.20100624t180933.6e79d15-1) unstable; urgency=low + + [ Michael Biebl ] + * New upstream release. (Closes: #585957) + * Drop debian/patches/01-termios.patch, merged upstream. + * debian/rules + - Drop aclocal workaround, no longer required. + - Add intltoolize call to pre-build. + + [ Mathieu Trudel-Lapierre ] + * upstream snapshot 2010-06-24 18:09:33 (GMT) + + 6e79d153efc30fb2030536f7f795c19ad4a0661a + * Add myself to Uploaders. + - update debian/control + + [ Michael Biebl ] + * Use watch file to track new upstream releases. + - Add debian/watch. + * Add Build-Depends on intltool. + - Update debian/control. + + -- Michael Biebl Sun, 27 Jun 2010 02:46:38 +0200 + +modemmanager (0.3-2) unstable; urgency=low + + [ Alexander Sack ] + * also bump debhelper build-depends to >= 7 + - update debian/control + * recommend usb-modeswitch + - update debian/control + * fix lintian warning about empty quilt series file; add + a single line comment + - update debian/patches/series + * add Michael Biebl to Uploaders + - update debian/control + * use asac@ubuntu.com as maintainer as the mailing list + bounces bug mail (Closes: #566833) + - update debian/control + + [ Michael Biebl ] + * Bump Standards-Version to 3.8.4. No further changes. + - update debian/control + * Fix FTBFS on alpha by using the POSIX.1 struct termios interface instead + of the obsolete struct termio ioctl interface. (Closes: #570661) + - add debian/patches/01-termios.patch + + -- Michael Biebl Mon, 01 Mar 2010 18:07:54 +0100 + +modemmanager (0.3-1) unstable; urgency=low + + * initial upload to debian (Closes: #546587) + * address ftpmaster comments: refined debian/copyright to list all copyright + holders and explicitly declare doc-generator.xsl to be LGPL-2.1 + - update debian/copyright + * use debhelper compat level 7 and bump Standards-Version to 3.8.3 + - update debian/compat + + -- Alexander Sack Sat, 23 Jan 2010 16:08:03 +0100 + +modemmanager (0.3-0ubuntu1) lucid; urgency=low + + * upstream snapshot: 2009-12-08 06:07:58 (GMT) + + 07114d4f43c6e724d22294108b1e73785e7aab2a + - release: bump version to 0.2.997 + - gsm: handle different +COPS response behavior + - gsm: implement enable/connecting/disconnecting state handling + - core: fix ordering of DISABLING and DISABLED states + - cdma: reset previous state if disconnect failed + - cdma: correctly handle state update after disconnection + - cdma: handle interim enabling/disabling states better + - cdma: update for new modem states and make connect actually work + - mbm: update state after enabling the device + - core: schedule enabled/disabled callbacks to avoid infinite recursion + - cdma: set correct modem state on connect failure + - build: require glib-2.0 >= 2.6.18 for g_set_error_literal() + - zte: add missing ZTE device aux port tags + - gsm: more complete parsing of PIN responses + - gsm: fix unsolicited registration segfaults + - hso: ensure authentication works again after auth errors + - serial: don't run commands when there's already one in progress + - hso: fix up connection issues and error ignorance + - nozomi: fix detection (LP: #425312) + - gsm: CGDCONT parsing fixes (bgo #602552) + - core: don't allow double scheduled callbacks (fixes crash) + - serial: handle arbitrary amounts of padding in responses + - gsm: make "X4 &C1" init arguments optional (LP: #455031) + + * upstream release 0.3 + - cdma: increase data call initiation timeout + - core: fix mm_modem_check_removed() to return errors correctly + - cdma: allow plugins to override AT+CSS? during registration checking (rh #547294) + - anydata: add plugin for AnyData CDMA devices (rh #547294) + - huawei-cdma: robustify SYSINFO parsing + - sierra: prefer primary port for status + - core: protect against modem removal in critical callbacks (rh #553953) + - cdma: prefer primary port unless it's connected + - probe: add CPIN request during the probing process (bgo #604369) + - zte: ignore SIM Build Main Menu requests (rh #551376) + - longcheer: new plugin for Longcheer (Alcatel etc) devices (bgo #606550) + - gsm: correctly parse Nokia N80 +COPS response + - gsm: split out +COPS response parsing and add testcases + - serial: prevent "hangs" by limiting EAGAIN retries on serial writes + - gsm: ensure registration state is reset when disabling the modem + - core: implement a PropertiesChanged signal for the MMModem class + - core: add Enabled property to org.freedesktop.ModemManager.Modem interface + - sierra: fix CDMA registration detection in some cases + - zte: handle unsolicited messages during probe + - cdma: fix quality parsing if modem doesn't prepend +CSQ: + - sierra: use at!pcstate on CDMA modems for power control + - option: always pick the right data port + - mbm: ensure various unsolicited responses are turned off when disabling + - cdma: try +CSQ? if CSQ fails + - cdma: accept SID 0 in some cases + + [ Tony Espy ] + * add get-snapshot-info target to rules + - update debian/rules + + [ Alexander Sack ] + * package improvements pointed out by mbiebl (thx) + + exclude pkglibdir and pppd from makeshlibs + - update debian/rules + + generate dbus spec during build and add it to package; build depend on + xsltproc accordingly + - update debian/rules + - update debian/control + + explicitly depend on libglib2.0-dev (>= 2.18) + - update debian/control + + fix git url in package description + - update debian/control + + -- Tony Espy Thu, 21 Jan 2010 19:07:37 -0500 + +modemmanager (0.2.git.20091014t233208.16f3e00-0ubuntu1) karmic; urgency=low + + New upstream snapshot: 2009-10-14 21:33:52 (GMT) + + 16f3e00f3438507aee06ffcaee560e337b8e8279 + - gsm: fix unsolicited registration by calling the statemachine callback + info (LP: #416893) + - mbm: use generic get_signal_quality implementation rather than our own + CIND based one - helps to fix LP: #449037 as its less error prone + - mbm: implement AT*ENAP polling; needed for some mbm devices, like m300 (LP: #442967) + - gsm: don't issue CFUN=0 by default (LP: #438150) + - mbm: fix disable after 3b19a85727458821f5df20153f8c04bc7717ba30 (LP: #447347, #438150) + - nokia: fix class property overrides to stop modemmanager from sending AT+CFUN (LP: #450256) + + -- Alexander Sack Thu, 15 Oct 2009 02:03:37 +0200 + +modemmanager (0.2.git.20091003t063318.aa78b5f-0ubuntu1) karmic; urgency=low + + New upstream snapshot: 2009-10-03 06:33:18 (GMT) + + aa78b5f5e5319e04f5b57f928bfab69dd4b93d88 + * fixes + - LP: #430576 - refuse to connect over Nokia N85, turns off the handset + - LP: #416126 - various unsolicited messages for mbm cause fail on connect + + -- Alexander Sack Tue, 06 Oct 2009 01:02:12 +0200 + +modemmanager (0.2.git.20090923t083842.f2a3825-0ubuntu1) karmic; urgency=low + + New upstream snapshot 2009-09-23 08:38:42 (GMT) + + commit: f2a3825f9d10ecebc63ce3c8602473cbbb6ab72c + + fix LP: #430576 - refuse to connect over Nokia N85, turns off the handset + + fix various F3507g related issues on first connect + + -- Alexander Sack Thu, 24 Sep 2009 11:41:08 +0200 + +modemmanager (0.2.git.20090909t142854.64b7be7-1) unstable; urgency=low + + * initial upload to debian (Closes: #546587) + + -- Alexander Sack Mon, 14 Sep 2009 14:53:24 +0200 + +modemmanager (0.2.git.20090909t142854.64b7be7-0ubuntu1) karmic; urgency=low + + New upstream snapshot 2009-09-09 14:28:54 (GMT) + + commit: 64b7be7460c3a9dbb3c9981de5754b330a8d2cdd + + fix LP: #414604 - Modem Manager setting bad baud rate, breaking pppd + connections. + + fix LP: #416418 - First connection attempt via F3507g fails + + fix LP: #424603 - Not closing modems when disconnected + + [ Alexander Sack ] + * address MIR comments (LP: #410259) + + cleanup debian/copyright + + set debhelper compat level to 5, matching debian/control + - add debian/compat + + remove not used patches + - delete debian/patches/ftbfs_hardy_glib_include_signal_h.patch + + use proper automake lower and upper version bounds matching the + versions referred to in debian/rules + - update debian/control + + don't redefine cdbs variables DEB_SOURCE, DEB_VERSION and + DEB_UPSTREAM_VERSION; in turn move DEB_MAJOR_VERSION definition + below the cdbs includes and use DEB_SOURCE_PACKAGE in GET_SOURCE + function + - update debian/rules + + document why we touch aclocal.m4 in pre-build:: + - update debian/rules + + use Ubuntu Network Manager Team as Maintainer and add asac@debian.org + to Uploaders + - update debian/control + + -- Alexander Sack Mon, 14 Sep 2009 11:38:05 +0200 + +modemmanager (0.2.git.20090820t183237.cd75023-0ubuntu1) karmic; urgency=low + + [ Tony Espy ] + * upstream snapshot 2009-08-20 18:32:37 (GMT) + + cd750230687177b45c2b8e507a0f0b22095aeb02 + - includes gsm and mbm fixes + + [ Alexander Sack ] + * add LOCAL_BRANCH feature to play nicely with fta's build bot + - update debian/rules + * use automake 1.10 vs. 1.11 depending on what is currently available + on the system + - update debian/rules + + -- Tony Espy Thu, 20 Aug 2009 15:06:51 -0400 + +modemmanager (0.2.git.20090806t105932.354a4cb-0ubuntu3) karmic; urgency=low + + [ Tony Espy ] + * update automake1.10 to automake in Build-Depends:, as automake + provides automake1.10 now + - update debian/control + + [ Alexander Sack ] + * fix LP: #410261 - do not ship .la and .a files in pkglibdir for + plugins; we strop .a and .la files in cdbs binary-post-install/modemmanager:: + - update debian/rules + + -- Alexander Sack Tue, 11 Aug 2009 13:38:38 +0200 + +modemmanager (0.2.git.20090806t105932.354a4cb-0ubuntu2) karmic; urgency=low + + * fix LP: #409943 - modemmanager debian/copyright has LGPL, + but upstream source is GPL + - update debian/copyright + + -- Tony Espy Thu, 06 Aug 2009 17:13:08 +0100 + +modemmanager (0.2.git.20090806t105932.354a4cb-0ubuntu1) karmic; urgency=low + + [ Tony Espy ] + * upstream snapshot 2009-08-06 10:59:32 (GMT) + + fixes license problems (LP: #403009) + + git://anongit.freedesktop.org/ModemManager/ModemManager + * drop patches superseded by upstream code base + - delete debian/patches/fix_makefile.patch + - update debian/patches/series + * add tarball generation code; added get-curr-source to + grab a specific version, and get-orig-source grabs the tip + of variable GIT_BRANCH; adjusted changelog version to the + new scheme. + - update debian/rules + * add real copyright notices and update git URL + - update debian/copyright + + [ Alexander Sack ] + * Initial release. + * replace hal with gudev build dependency + - update debian/control + * signal.h is not implicitly included by hardy glib.h; add patch to + explicitly include this in src/main.c when SIGUSR1 is not defined + - add debian/patches/ftbfs_hardy_glib_include_signal_h.patch + - update debian/patches/series + * fix build failure due to gcc pickiness (committed upstream) + - add debian/patches/ftbfs_serial_quickfix.patch + - update debian/patches/series + + -- Tony Espy Thu, 06 Aug 2009 12:50:45 +0100 diff --git a/packaging/ModemManager/debian/control b/packaging/ModemManager/debian/control new file mode 100644 index 0000000..e148a5b --- /dev/null +++ b/packaging/ModemManager/debian/control @@ -0,0 +1,159 @@ +Source: modemmanager +Section: net +Priority: optional +Maintainer: DebianOnMobile Maintainers +Uploaders: Arnaud Ferraris , + Guido Günther , + Henry-Nicolas Tourneur , + Martin +Build-Depends: debhelper-compat (= 13), + debhelper (>= 13.11.4), + dh-sequence-gir, + bash-completion, + gettext, + libdbus-1-dev, + libgirepository1.0-dev, + libglib2.0-dev, + libgudev-1.0-dev, + libmbim-glib-dev (>= 1.32~), + libpolkit-gobject-1-dev (>= 0.97), + libqmi-glib-dev (>= 1.36~), + libsystemd-dev (>= 209), + meson, + polkitd, + python3-dbus, + python3-gi, + udev, + valac (>= 0.22), + xsltproc, +# Needed for building documentation + gtk-doc-tools , + libglib2.0-doc , +# Needed for automated tests + dbus , +Standards-Version: 4.7.2 +Rules-Requires-Root: no +Vcs-Git: https://salsa.debian.org/DebianOnMobile-team/modemmanager.git +Vcs-Browser: https://salsa.debian.org/DebianOnMobile-team/modemmanager +Homepage: https://www.freedesktop.org/wiki/Software/ModemManager/ + +Package: modemmanager +Architecture: linux-any +Depends: ${shlibs:Depends}, + ${misc:Depends}, + polkitd, +Recommends: libmbim-utils, + libqmi-utils, + usb-modeswitch, +Description: D-Bus service for managing modems + ModemManager is a DBus-activated daemon which controls mobile broadband + (2G/3G/4G/5G) devices and connections. Whether built-in devices, USB dongles, + Bluetooth-paired telephones or professional RS232/USB devices with external + power supplies, ModemManager is able to prepare and configure the modems and + setup connections with them. + +Package: modemmanager-dev +Section: libdevel +Architecture: linux-any +Multi-Arch: same +Depends: ${misc:Depends} +Suggests: modemmanager-doc +Breaks: modemmanager (<< 1.6.6-1) +Replaces: modemmanager (<< 1.6.6-1) +Description: D-Bus service for managing modems - development files + ModemManager is a DBus-activated daemon which controls mobile broadband + (2G/3G/4G/5G) devices and connections. Whether built-in devices, USB dongles, + Bluetooth-paired telephones or professional RS232/USB devices with external + power supplies, ModemManager is able to prepare and configure the modems and + setup connections with them. + . + This package contains header files for ModemManager. + +Package: modemmanager-doc +Section: doc +Architecture: all +Multi-Arch: foreign +Depends: ${misc:Depends} +Build-Profiles: +Suggests: devhelp +Description: D-Bus service for managing modems - documentation files + ModemManager is a DBus-activated daemon which controls mobile broadband + (2G/3G/4G/5G) devices and connections. Whether built-in devices, USB dongles, + Bluetooth-paired telephones or professional RS232/USB devices with external + power supplies, ModemManager is able to prepare and configure the modems and + setup connections with them. + . + This package contains documentation of the D-Bus protocol to interface + ModemManager. + +Package: libmm-glib0 +Architecture: linux-any +Section: libs +Pre-Depends: ${misc:Pre-Depends}, +Multi-Arch: same +Depends: ${shlibs:Depends}, + ${misc:Depends} +Recommends: modemmanager (>= ${binary:Version}) +Description: D-Bus service for managing modems - shared libraries + ModemManager is a DBus-activated daemon which controls mobile broadband + (2G/3G/4G/5G) devices and connections. Whether built-in devices, USB dongles, + Bluetooth-paired telephones or professional RS232/USB devices with external + power supplies, ModemManager is able to prepare and configure the modems and + setup connections with them. + . + This package contains shared libraries for applications interfacing with + ModemManager. + +Package: libmm-glib-dev +Architecture: linux-any +Multi-Arch: same +Section: libdevel +Depends: libmm-glib0 (= ${binary:Version}), + gir1.2-modemmanager-1.0 (= ${binary:Version}), + libglib2.0-dev, + modemmanager-dev, + ${shlibs:Depends}, + ${misc:Depends}, +Suggests: libmm-glib-doc +Description: D-Bus service for managing modems - library development files + ModemManager is a DBus-activated daemon which controls mobile broadband + (2G/3G/4G/5G) devices and connections. Whether built-in devices, USB dongles, + Bluetooth-paired telephones or professional RS232/USB devices with external + power supplies, ModemManager is able to prepare and configure the modems and + setup connections with them. + . + This package contains development files and Vala bindings to use when writing + applications that interface with ModemManager. + +Package: libmm-glib-doc +Architecture: all +Multi-Arch: foreign +Section: doc +Depends: ${misc:Depends} +Build-Profiles: +Suggests: devhelp +Description: D-Bus service for managing modems - library documentation + ModemManager is a DBus-activated daemon which controls mobile broadband + (2G/3G/4G/5G) devices and connections. Whether built-in devices, USB dongles, + Bluetooth-paired telephones or professional RS232/USB devices with external + power supplies, ModemManager is able to prepare and configure the modems and + setup connections with them. + . + This package contains documentation of libmm-glib to use when writing + applications that interface with ModemManager. + +Package: gir1.2-modemmanager-1.0 +Section: introspection +Architecture: linux-any +Multi-Arch: same +Depends: ${gir:Depends}, + ${shlibs:Depends}, + ${misc:Depends} +Description: GObject introspection data for modemmanager + ModemManager is a DBus-activated daemon which controls mobile broadband + (2G/3G/4G/5G) devices and connections. Whether built-in devices, USB dongles, + Bluetooth-paired telephones or professional RS232/USB devices with external + power supplies, ModemManager is able to prepare and configure the modems and + setup connections with them. + . + This package contains introspection data for the libmm-glib library. diff --git a/packaging/ModemManager/debian/copyright b/packaging/ModemManager/debian/copyright new file mode 100644 index 0000000..50131e5 --- /dev/null +++ b/packaging/ModemManager/debian/copyright @@ -0,0 +1,94 @@ +Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/ +Upstream-Name: ModemManager +Upstream-Contact: Aleksander Morgado +Source: https://www.freedesktop.org/software/ModemManager/ + +Files: * +Copyright: 2011 - 2013 Aleksander Morgado + 2008 - 2009 Novell, Inc. + 2009 - 2012 Red Hat, Inc. + 2011 - 2012 Google, Inc. +License: GPL-2.0+ + +Files: cli/* +Copyright: 2011 - 2012 Aleksander Morgado + 2011, 2012 Google, Inc. +License: GPL-3.0+ + +Files: libqcdm/* +Copyright: 2010, 2011 Red Hat, Inc. +License: GPL-2.0 + +Files: libmm-glib/* + include/* +Copyright: 2011 - 2012 Aleksander Morgado + 2012 Lanedo GmbH + 2012 Google, Inc. +License: LGPL-2.0+ + +License: GPL-2.0+ + This package is free software; you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation; either version 2 of the License, or + (at your option) any later version. + . + This package is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + . + You should have received a copy of the GNU General Public License + along with this program. If not, see + . + On Debian systems, the complete text of the GNU General Public + License version 2 can be found in "/usr/share/common-licenses/GPL-2". + +License: GPL-2.0 + This package is free software; you can redistribute it and/or modify + it under the terms of version 2 of the GNU General Public License + as published by the Free Software Foundation. + . + This package is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + . + You should have received a copy of the GNU General Public License + along with this program. If not, see + . + On Debian systems, the complete text of the GNU General Public + License version 2 can be found in "/usr/share/common-licenses/GPL-2". + +License: GPL-3.0+ + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + . + This package is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + . + You should have received a copy of the GNU General Public License + along with this program. If not, see . + . + On Debian systems, the complete text of the GNU General Public + License version 3 can be found in "/usr/share/common-licenses/GPL-3". + +License: LGPL-2.0+ + This package is free software; you can redistribute it and/or + modify it under the terms of the GNU Lesser General Public + License as published by the Free Software Foundation; either + version 2 of the License, or (at your option) any later version. + . + This package is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + Lesser General Public License for more details. + . + You should have received a copy of the GNU General Public License + along with this program. If not, see . + . + On Debian systems, the complete text of the GNU Lesser General + Public License can be found in "/usr/share/common-licenses/LGPL-2". diff --git a/packaging/ModemManager/debian/gbp.conf b/packaging/ModemManager/debian/gbp.conf new file mode 100644 index 0000000..2b1fbcd --- /dev/null +++ b/packaging/ModemManager/debian/gbp.conf @@ -0,0 +1,11 @@ +[DEFAULT] +pristine-tar = True +debian-branch = debian/latest +upstream-branch = upstream/latest +upstream-vcs-tag=%(version)s + +[import-orig] +postimport = dch -v%(version)s New upstream release; git add debian/changelog; debcommit + +[dch] +multimaint-merge = True diff --git a/packaging/ModemManager/debian/gir1.2-modemmanager-1.0.install b/packaging/ModemManager/debian/gir1.2-modemmanager-1.0.install new file mode 100644 index 0000000..9038727 --- /dev/null +++ b/packaging/ModemManager/debian/gir1.2-modemmanager-1.0.install @@ -0,0 +1 @@ +usr/lib/*/girepository-1.0/ diff --git a/packaging/ModemManager/debian/libmm-glib-dev.install b/packaging/ModemManager/debian/libmm-glib-dev.install new file mode 100644 index 0000000..ac35f29 --- /dev/null +++ b/packaging/ModemManager/debian/libmm-glib-dev.install @@ -0,0 +1,5 @@ +usr/lib/*/libmm-glib.so +usr/lib/*/pkgconfig/mm-glib.pc +usr/include/libmm-glib/ +usr/share/gir-1.0/ +usr/share/vala/vapi/ diff --git a/packaging/ModemManager/debian/libmm-glib-doc.install b/packaging/ModemManager/debian/libmm-glib-doc.install new file mode 100644 index 0000000..77a49c7 --- /dev/null +++ b/packaging/ModemManager/debian/libmm-glib-doc.install @@ -0,0 +1 @@ +usr/share/gtk-doc/html/libmm-glib/ diff --git a/packaging/ModemManager/debian/libmm-glib0.install b/packaging/ModemManager/debian/libmm-glib0.install new file mode 100644 index 0000000..78d2be9 --- /dev/null +++ b/packaging/ModemManager/debian/libmm-glib0.install @@ -0,0 +1 @@ +usr/lib/*/libmm-glib.so.* diff --git a/packaging/ModemManager/debian/libmm-glib0.symbols b/packaging/ModemManager/debian/libmm-glib0.symbols new file mode 100644 index 0000000..4cf8ab0 --- /dev/null +++ b/packaging/ModemManager/debian/libmm-glib0.symbols @@ -0,0 +1,2415 @@ +libmm-glib.so.0 libmm-glib0 #MINVER# +* Build-Depends-Package: libmm-glib-dev + mm_3gpp_profile_cmp@Base 1.18.2 + mm_3gpp_profile_consume_string@Base 1.18.2 + mm_3gpp_profile_consume_variant@Base 1.18.2 + mm_3gpp_profile_get_access_type_preference@Base 1.20.0 + mm_3gpp_profile_get_allowed_auth@Base 1.18.2 + mm_3gpp_profile_get_apn@Base 1.18.2 + mm_3gpp_profile_get_apn_type@Base 1.18.2 + mm_3gpp_profile_get_dictionary@Base 1.18.2 + mm_3gpp_profile_get_enabled@Base 1.20.0 + mm_3gpp_profile_get_ip_type@Base 1.18.2 + mm_3gpp_profile_get_password@Base 1.18.2 + mm_3gpp_profile_get_profile_id@Base 1.18.2 + mm_3gpp_profile_get_profile_name@Base 1.20.0 + mm_3gpp_profile_get_profile_source@Base 1.20.0 + mm_3gpp_profile_get_roaming_allowance@Base 1.20.0 + mm_3gpp_profile_get_type@Base 1.18.2 + mm_3gpp_profile_get_user@Base 1.18.2 + mm_3gpp_profile_new@Base 1.18.2 + mm_3gpp_profile_new_from_dictionary@Base 1.18.2 + mm_3gpp_profile_new_from_string@Base 1.18.2 + mm_3gpp_profile_print@Base 1.22.0 + mm_3gpp_profile_set_access_type_preference@Base 1.20.0 + mm_3gpp_profile_set_allowed_auth@Base 1.18.2 + mm_3gpp_profile_set_apn@Base 1.18.2 + mm_3gpp_profile_set_apn_type@Base 1.18.2 + mm_3gpp_profile_set_enabled@Base 1.20.0 + mm_3gpp_profile_set_ip_type@Base 1.18.2 + mm_3gpp_profile_set_password@Base 1.18.2 + mm_3gpp_profile_set_profile_id@Base 1.18.2 + mm_3gpp_profile_set_profile_name@Base 1.20.0 + mm_3gpp_profile_set_profile_source@Base 1.20.0 + mm_3gpp_profile_set_roaming_allowance@Base 1.20.0 + mm_3gpp_profile_set_user@Base 1.18.2 + mm_bearer_access_type_preference_get_string@Base 1.20.0 + mm_bearer_access_type_preference_get_type@Base 1.20.0 + mm_bearer_allowed_auth_build_string_from_mask@Base 0.7.991 + mm_bearer_allowed_auth_get_type@Base 0.7.991 + mm_bearer_apn_type_build_string_from_mask@Base 1.18.2 + mm_bearer_apn_type_get_type@Base 1.18.2 + mm_bearer_connect@Base 0.7.991 + mm_bearer_connect_finish@Base 0.7.991 + mm_bearer_connect_sync@Base 0.7.991 + mm_bearer_disconnect@Base 0.7.991 + mm_bearer_disconnect_finish@Base 0.7.991 + mm_bearer_disconnect_sync@Base 0.7.991 + mm_bearer_dup_interface@Base 0.7.991 + mm_bearer_dup_path@Base 0.7.991 + mm_bearer_get_bearer_type@Base 1.10.0 + mm_bearer_get_connected@Base 0.7.991 + mm_bearer_get_connection_error@Base 1.18.2 + mm_bearer_get_interface@Base 0.7.991 + mm_bearer_get_ip_timeout@Base 0.7.991 + mm_bearer_get_ipv4_config@Base 0.7.991 + mm_bearer_get_ipv6_config@Base 0.7.991 + mm_bearer_get_multiplexed@Base 1.18.2 + mm_bearer_get_path@Base 0.7.991 + mm_bearer_get_profile_id@Base 1.18.2 + mm_bearer_get_properties@Base 0.7.991 + mm_bearer_get_reload_stats_supported@Base 1.20.0 + mm_bearer_get_stats@Base 1.5.993 + mm_bearer_get_suspended@Base 0.7.991 + mm_bearer_get_type@Base 0.7.991 + mm_bearer_ip_config_get_address@Base 0.7.991 + mm_bearer_ip_config_get_dictionary@Base 0.7.991 + mm_bearer_ip_config_get_dns@Base 0.7.991 + mm_bearer_ip_config_get_gateway@Base 0.7.991 + mm_bearer_ip_config_get_method@Base 0.7.991 + mm_bearer_ip_config_get_mtu@Base 1.4.0 + mm_bearer_ip_config_get_prefix@Base 0.7.991 + mm_bearer_ip_config_get_type@Base 0.7.991 + mm_bearer_ip_config_new@Base 0.7.991 + mm_bearer_ip_config_new_from_dictionary@Base 0.7.991 + mm_bearer_ip_config_set_address@Base 0.7.991 + mm_bearer_ip_config_set_dns@Base 0.7.991 + mm_bearer_ip_config_set_gateway@Base 0.7.991 + mm_bearer_ip_config_set_method@Base 0.7.991 + mm_bearer_ip_config_set_mtu@Base 1.4.0 + mm_bearer_ip_config_set_prefix@Base 0.7.991 + mm_bearer_ip_family_build_string_from_mask@Base 0.7.991 + mm_bearer_ip_family_get_type@Base 0.7.991 + mm_bearer_ip_method_get_string@Base 0.7.991 + mm_bearer_ip_method_get_type@Base 0.7.991 + mm_bearer_multiplex_support_get_string@Base 1.18.2 + mm_bearer_multiplex_support_get_type@Base 1.18.2 + mm_bearer_peek_connection_error@Base 1.18.2 + mm_bearer_peek_ipv4_config@Base 0.7.991 + mm_bearer_peek_ipv6_config@Base 0.7.991 + mm_bearer_peek_properties@Base 0.7.991 + mm_bearer_peek_stats@Base 1.5.993 + mm_bearer_profile_source_get_string@Base 1.20.0 + mm_bearer_profile_source_get_type@Base 1.20.0 + mm_bearer_properties_cmp@Base 0.7.991 + mm_bearer_properties_consume_string@Base 0.7.991 + mm_bearer_properties_consume_variant@Base 0.7.991 + mm_bearer_properties_get_access_type_preference@Base 1.20.0 + mm_bearer_properties_get_allow_roaming@Base 0.7.991 + mm_bearer_properties_get_allowed_auth@Base 0.7.991 + mm_bearer_properties_get_apn@Base 0.7.991 + mm_bearer_properties_get_apn_type@Base 1.18.2 + mm_bearer_properties_get_dictionary@Base 0.7.991 + mm_bearer_properties_get_force@Base 1.23.12 + mm_bearer_properties_get_ip_type@Base 0.7.991 + mm_bearer_properties_get_multiplex@Base 1.18.2 + mm_bearer_properties_get_number@Base 0.7.991 + mm_bearer_properties_get_password@Base 0.7.991 + mm_bearer_properties_get_profile_id@Base 1.18.2 + mm_bearer_properties_get_profile_name@Base 1.20.0 + mm_bearer_properties_get_rm_protocol@Base 0.7.991 + mm_bearer_properties_get_roaming_allowance@Base 1.20.0 + mm_bearer_properties_get_type@Base 0.7.991 + mm_bearer_properties_get_user@Base 0.7.991 + mm_bearer_properties_new@Base 0.7.991 + mm_bearer_properties_new_from_dictionary@Base 0.7.991 + mm_bearer_properties_new_from_profile@Base 1.18.2 + mm_bearer_properties_new_from_string@Base 0.7.991 + mm_bearer_properties_peek_3gpp_profile@Base 1.18.2 + mm_bearer_properties_print@Base 1.22.0 + mm_bearer_properties_set_access_type_preference@Base 1.20.0 + mm_bearer_properties_set_allow_roaming@Base 0.7.991 + mm_bearer_properties_set_allowed_auth@Base 0.7.991 + mm_bearer_properties_set_apn@Base 0.7.991 + mm_bearer_properties_set_apn_type@Base 1.18.2 + mm_bearer_properties_set_force@Base 1.23.12 + mm_bearer_properties_set_ip_type@Base 0.7.991 + mm_bearer_properties_set_multiplex@Base 1.18.2 + mm_bearer_properties_set_number@Base 0.7.991 + mm_bearer_properties_set_password@Base 0.7.991 + mm_bearer_properties_set_profile_id@Base 1.18.2 + mm_bearer_properties_set_profile_name@Base 1.20.0 + mm_bearer_properties_set_rm_protocol@Base 0.7.991 + mm_bearer_properties_set_roaming_allowance@Base 1.20.0 + mm_bearer_properties_set_user@Base 0.7.991 + mm_bearer_roaming_allowance_build_string_from_mask@Base 1.20.0 + mm_bearer_roaming_allowance_get_type@Base 1.20.0 + mm_bearer_stats_get_attempts@Base 1.13.900 + mm_bearer_stats_get_dictionary@Base 1.5.993 + mm_bearer_stats_get_downlink_speed@Base 1.20.0 + mm_bearer_stats_get_duration@Base 1.5.993 + mm_bearer_stats_get_failed_attempts@Base 1.13.900 + mm_bearer_stats_get_rx_bytes@Base 1.5.993 + mm_bearer_stats_get_start_date@Base 1.20.0 + mm_bearer_stats_get_total_duration@Base 1.13.900 + mm_bearer_stats_get_total_rx_bytes@Base 1.13.900 + mm_bearer_stats_get_total_tx_bytes@Base 1.13.900 + mm_bearer_stats_get_tx_bytes@Base 1.5.993 + mm_bearer_stats_get_type@Base 1.5.993 + mm_bearer_stats_get_uplink_speed@Base 1.20.0 + mm_bearer_stats_new@Base 1.5.993 + mm_bearer_stats_new_from_dictionary@Base 1.5.993 + mm_bearer_stats_set_attempts@Base 1.13.900 + mm_bearer_stats_set_downlink_speed@Base 1.20.0 + mm_bearer_stats_set_duration@Base 1.5.993 + mm_bearer_stats_set_failed_attempts@Base 1.13.900 + mm_bearer_stats_set_rx_bytes@Base 1.5.993 + mm_bearer_stats_set_start_date@Base 1.20.0 + mm_bearer_stats_set_total_duration@Base 1.13.900 + mm_bearer_stats_set_total_rx_bytes@Base 1.13.900 + mm_bearer_stats_set_total_tx_bytes@Base 1.13.900 + mm_bearer_stats_set_tx_bytes@Base 1.5.993 + mm_bearer_stats_set_uplink_speed@Base 1.20.0 + mm_bearer_type_get_string@Base 1.10.0 + mm_bearer_type_get_type@Base 1.10.0 + mm_call_accept@Base 1.5.993 + mm_call_accept_finish@Base 1.5.993 + mm_call_accept_sync@Base 1.5.993 + mm_call_audio_format_get_dictionary@Base 1.10.0 + mm_call_audio_format_get_encoding@Base 1.10.0 + mm_call_audio_format_get_rate@Base 1.10.0 + mm_call_audio_format_get_resolution@Base 1.10.0 + mm_call_audio_format_get_type@Base 1.10.0 + mm_call_audio_format_new@Base 1.10.0 + mm_call_audio_format_new_from_dictionary@Base 1.10.0 + mm_call_audio_format_set_encoding@Base 1.10.0 + mm_call_audio_format_set_rate@Base 1.10.0 + mm_call_audio_format_set_resolution@Base 1.10.0 + mm_call_deflect@Base 1.12.6 + mm_call_deflect_finish@Base 1.12.6 + mm_call_deflect_sync@Base 1.12.6 + mm_call_direction_get_string@Base 1.5.993 + mm_call_direction_get_type@Base 1.5.993 + mm_call_dup_audio_port@Base 1.10.0 + mm_call_dup_number@Base 1.5.993 + mm_call_dup_path@Base 1.5.993 + mm_call_get_audio_format@Base 1.10.0 + mm_call_get_audio_port@Base 1.10.0 + mm_call_get_direction@Base 1.5.993 + mm_call_get_multiparty@Base 1.12.6 + mm_call_get_number@Base 1.5.993 + mm_call_get_path@Base 1.5.993 + mm_call_get_state@Base 1.5.993 + mm_call_get_state_reason@Base 1.5.993 + mm_call_get_type@Base 1.5.993 + mm_call_hangup@Base 1.5.993 + mm_call_hangup_finish@Base 1.5.993 + mm_call_hangup_sync@Base 1.5.993 + mm_call_join_multiparty@Base 1.12.6 + mm_call_join_multiparty_finish@Base 1.12.6 + mm_call_join_multiparty_sync@Base 1.12.6 + mm_call_leave_multiparty@Base 1.12.6 + mm_call_leave_multiparty_finish@Base 1.12.6 + mm_call_leave_multiparty_sync@Base 1.12.6 + mm_call_peek_audio_format@Base 1.10.0 + mm_call_properties_get_dictionary@Base 1.5.993 + mm_call_properties_get_direction@Base 1.5.993 + mm_call_properties_get_number@Base 1.5.993 + mm_call_properties_get_state@Base 1.5.993 + mm_call_properties_get_state_reason@Base 1.5.993 + mm_call_properties_get_type@Base 1.5.993 + mm_call_properties_new@Base 1.5.993 + mm_call_properties_new_from_dictionary@Base 1.5.993 + mm_call_properties_new_from_string@Base 1.5.993 + mm_call_properties_set_direction@Base 1.5.993 + mm_call_properties_set_number@Base 1.5.993 + mm_call_properties_set_state@Base 1.5.993 + mm_call_properties_set_state_reason@Base 1.5.993 + mm_call_send_dtmf@Base 1.5.993 + mm_call_send_dtmf_finish@Base 1.5.993 + mm_call_send_dtmf_sync@Base 1.5.993 + mm_call_start@Base 1.5.993 + mm_call_start_finish@Base 1.5.993 + mm_call_start_sync@Base 1.5.993 + mm_call_state_get_string@Base 1.5.993 + mm_call_state_get_type@Base 1.5.993 + mm_call_state_reason_get_string@Base 1.5.993 + mm_call_state_reason_get_type@Base 1.5.993 + mm_carrier_lock_error_get_type@Base 1.23.12 + mm_carrier_lock_error_quark@Base 1.23.12 + mm_cbm_dup_path@Base 1.23.95 + mm_cbm_dup_text@Base 1.23.95 + mm_cbm_get_channel@Base 1.23.95 + mm_cbm_get_message_code@Base 1.23.95 + mm_cbm_get_path@Base 1.23.95 + mm_cbm_get_state@Base 1.23.95 + mm_cbm_get_text@Base 1.23.95 + mm_cbm_get_type@Base 1.23.95 + mm_cbm_get_update@Base 1.23.95 + mm_cbm_state_get_string@Base 1.23.95 + mm_cbm_state_get_type@Base 1.23.95 + mm_cdma_activation_error_get_type@Base 0.7.991 + mm_cdma_activation_error_quark@Base 0.7.991 + mm_cdma_manual_activation_properties_get_dictionary@Base 1.2.0 + mm_cdma_manual_activation_properties_get_mdn@Base 1.2.0 + mm_cdma_manual_activation_properties_get_min@Base 1.2.0 + mm_cdma_manual_activation_properties_get_mn_aaa_key@Base 1.2.0 + mm_cdma_manual_activation_properties_get_mn_ha_key@Base 1.2.0 + mm_cdma_manual_activation_properties_get_prl@Base 1.2.0 + mm_cdma_manual_activation_properties_get_prl_bytearray@Base 1.2.0 + mm_cdma_manual_activation_properties_get_sid@Base 1.2.0 + mm_cdma_manual_activation_properties_get_spc@Base 1.2.0 + mm_cdma_manual_activation_properties_get_type@Base 1.2.0 + mm_cdma_manual_activation_properties_new@Base 1.2.0 + mm_cdma_manual_activation_properties_new_from_dictionary@Base 1.2.0 + mm_cdma_manual_activation_properties_new_from_string@Base 1.2.0 + mm_cdma_manual_activation_properties_peek_prl_bytearray@Base 1.2.0 + mm_cdma_manual_activation_properties_set_mdn@Base 1.2.0 + mm_cdma_manual_activation_properties_set_min@Base 1.2.0 + mm_cdma_manual_activation_properties_set_mn_aaa_key@Base 1.2.0 + mm_cdma_manual_activation_properties_set_mn_ha_key@Base 1.2.0 + mm_cdma_manual_activation_properties_set_prl@Base 1.2.0 + mm_cdma_manual_activation_properties_set_prl_bytearray@Base 1.2.0 + mm_cdma_manual_activation_properties_set_sid@Base 1.2.0 + mm_cdma_manual_activation_properties_set_spc@Base 1.2.0 + mm_cell_info_build_string@Base 1.20.0 + mm_cell_info_cdma_get_base_station_id@Base 1.20.0 + mm_cell_info_cdma_get_nid@Base 1.20.0 + mm_cell_info_cdma_get_pilot_strength@Base 1.20.0 + mm_cell_info_cdma_get_ref_pn@Base 1.20.0 + mm_cell_info_cdma_get_sid@Base 1.20.0 + mm_cell_info_cdma_get_type@Base 1.20.0 + mm_cell_info_cdma_new_from_dictionary@Base 1.20.0 + mm_cell_info_cdma_set_base_station_id@Base 1.20.0 + mm_cell_info_cdma_set_nid@Base 1.20.0 + mm_cell_info_cdma_set_pilot_strength@Base 1.20.0 + mm_cell_info_cdma_set_ref_pn@Base 1.20.0 + mm_cell_info_cdma_set_sid@Base 1.20.0 + mm_cell_info_get_cell_type@Base 1.20.0 + mm_cell_info_get_dictionary@Base 1.20.0 + mm_cell_info_get_serving@Base 1.20.0 + mm_cell_info_get_type@Base 1.20.0 + mm_cell_info_gsm_get_arfcn@Base 1.20.0 + mm_cell_info_gsm_get_base_station_id@Base 1.20.0 + mm_cell_info_gsm_get_ci@Base 1.20.0 + mm_cell_info_gsm_get_lac@Base 1.20.0 + mm_cell_info_gsm_get_operator_id@Base 1.20.0 + mm_cell_info_gsm_get_rx_level@Base 1.20.0 + mm_cell_info_gsm_get_timing_advance@Base 1.20.0 + mm_cell_info_gsm_get_type@Base 1.20.0 + mm_cell_info_gsm_new_from_dictionary@Base 1.20.0 + mm_cell_info_gsm_set_arfcn@Base 1.20.0 + mm_cell_info_gsm_set_base_station_id@Base 1.20.0 + mm_cell_info_gsm_set_ci@Base 1.20.0 + mm_cell_info_gsm_set_lac@Base 1.20.0 + mm_cell_info_gsm_set_operator_id@Base 1.20.0 + mm_cell_info_gsm_set_rx_level@Base 1.20.0 + mm_cell_info_gsm_set_timing_advance@Base 1.20.0 + mm_cell_info_lte_get_bandwidth@Base 1.22.0 + mm_cell_info_lte_get_ci@Base 1.20.0 + mm_cell_info_lte_get_earfcn@Base 1.20.0 + mm_cell_info_lte_get_operator_id@Base 1.20.0 + mm_cell_info_lte_get_physical_ci@Base 1.20.0 + mm_cell_info_lte_get_rsrp@Base 1.20.0 + mm_cell_info_lte_get_rsrq@Base 1.20.0 + mm_cell_info_lte_get_serving_cell_type@Base 1.22.0 + mm_cell_info_lte_get_tac@Base 1.20.0 + mm_cell_info_lte_get_timing_advance@Base 1.20.0 + mm_cell_info_lte_get_type@Base 1.20.0 + mm_cell_info_lte_new_from_dictionary@Base 1.20.0 + mm_cell_info_lte_set_bandwidth@Base 1.22.0 + mm_cell_info_lte_set_ci@Base 1.20.0 + mm_cell_info_lte_set_earfcn@Base 1.20.0 + mm_cell_info_lte_set_operator_id@Base 1.20.0 + mm_cell_info_lte_set_physical_ci@Base 1.20.0 + mm_cell_info_lte_set_rsrp@Base 1.20.0 + mm_cell_info_lte_set_rsrq@Base 1.20.0 + mm_cell_info_lte_set_serving_cell_type@Base 1.22.0 + mm_cell_info_lte_set_tac@Base 1.20.0 + mm_cell_info_lte_set_timing_advance@Base 1.20.0 + mm_cell_info_new_from_dictionary@Base 1.20.0 + mm_cell_info_nr5g_get_bandwidth@Base 1.22.0 + mm_cell_info_nr5g_get_ci@Base 1.20.0 + mm_cell_info_nr5g_get_nrarfcn@Base 1.20.0 + mm_cell_info_nr5g_get_operator_id@Base 1.20.0 + mm_cell_info_nr5g_get_physical_ci@Base 1.20.0 + mm_cell_info_nr5g_get_rsrp@Base 1.20.0 + mm_cell_info_nr5g_get_rsrq@Base 1.20.0 + mm_cell_info_nr5g_get_serving_cell_type@Base 1.22.0 + mm_cell_info_nr5g_get_sinr@Base 1.20.0 + mm_cell_info_nr5g_get_tac@Base 1.20.0 + mm_cell_info_nr5g_get_timing_advance@Base 1.20.0 + mm_cell_info_nr5g_get_type@Base 1.20.0 + mm_cell_info_nr5g_new_from_dictionary@Base 1.20.0 + mm_cell_info_nr5g_set_bandwidth@Base 1.22.0 + mm_cell_info_nr5g_set_ci@Base 1.20.0 + mm_cell_info_nr5g_set_nrarfcn@Base 1.20.0 + mm_cell_info_nr5g_set_operator_id@Base 1.20.0 + mm_cell_info_nr5g_set_physical_ci@Base 1.20.0 + mm_cell_info_nr5g_set_rsrp@Base 1.20.0 + mm_cell_info_nr5g_set_rsrq@Base 1.20.0 + mm_cell_info_nr5g_set_serving_cell_type@Base 1.22.0 + mm_cell_info_nr5g_set_sinr@Base 1.20.0 + mm_cell_info_nr5g_set_tac@Base 1.20.0 + mm_cell_info_nr5g_set_timing_advance@Base 1.20.0 + mm_cell_info_set_serving@Base 1.20.0 + mm_cell_info_tdscdma_get_cell_parameter_id@Base 1.20.0 + mm_cell_info_tdscdma_get_ci@Base 1.20.0 + mm_cell_info_tdscdma_get_lac@Base 1.20.0 + mm_cell_info_tdscdma_get_operator_id@Base 1.20.0 + mm_cell_info_tdscdma_get_path_loss@Base 1.20.0 + mm_cell_info_tdscdma_get_rscp@Base 1.20.0 + mm_cell_info_tdscdma_get_timing_advance@Base 1.20.0 + mm_cell_info_tdscdma_get_type@Base 1.20.0 + mm_cell_info_tdscdma_get_uarfcn@Base 1.20.0 + mm_cell_info_tdscdma_new_from_dictionary@Base 1.20.0 + mm_cell_info_tdscdma_set_cell_parameter_id@Base 1.20.0 + mm_cell_info_tdscdma_set_ci@Base 1.20.0 + mm_cell_info_tdscdma_set_lac@Base 1.20.0 + mm_cell_info_tdscdma_set_operator_id@Base 1.20.0 + mm_cell_info_tdscdma_set_path_loss@Base 1.20.0 + mm_cell_info_tdscdma_set_rscp@Base 1.20.0 + mm_cell_info_tdscdma_set_timing_advance@Base 1.20.0 + mm_cell_info_tdscdma_set_uarfcn@Base 1.20.0 + mm_cell_info_umts_get_ci@Base 1.20.0 + mm_cell_info_umts_get_ecio@Base 1.20.0 + mm_cell_info_umts_get_frequency_fdd_dl@Base 1.20.0 + mm_cell_info_umts_get_frequency_fdd_ul@Base 1.20.0 + mm_cell_info_umts_get_frequency_tdd@Base 1.20.0 + mm_cell_info_umts_get_lac@Base 1.20.0 + mm_cell_info_umts_get_operator_id@Base 1.20.0 + mm_cell_info_umts_get_path_loss@Base 1.20.0 + mm_cell_info_umts_get_psc@Base 1.20.0 + mm_cell_info_umts_get_rscp@Base 1.20.0 + mm_cell_info_umts_get_type@Base 1.20.0 + mm_cell_info_umts_get_uarfcn@Base 1.20.0 + mm_cell_info_umts_new_from_dictionary@Base 1.20.0 + mm_cell_info_umts_set_ci@Base 1.20.0 + mm_cell_info_umts_set_ecio@Base 1.20.0 + mm_cell_info_umts_set_frequency_fdd_dl@Base 1.20.0 + mm_cell_info_umts_set_frequency_fdd_ul@Base 1.20.0 + mm_cell_info_umts_set_frequency_tdd@Base 1.20.0 + mm_cell_info_umts_set_lac@Base 1.20.0 + mm_cell_info_umts_set_operator_id@Base 1.20.0 + mm_cell_info_umts_set_path_loss@Base 1.20.0 + mm_cell_info_umts_set_psc@Base 1.20.0 + mm_cell_info_umts_set_rscp@Base 1.20.0 + mm_cell_info_umts_set_uarfcn@Base 1.20.0 + mm_cell_type_get_string@Base 1.20.0 + mm_cell_type_get_type@Base 1.20.0 + mm_common_band_is_cdma@Base 1.10.0 + mm_common_band_is_eutran@Base 1.10.0 + mm_common_band_is_gsm@Base 1.10.0 + mm_common_band_is_utran@Base 1.10.0 + mm_common_bands_array_to_variant@Base 0.7.991 + mm_common_bands_garray_cmp@Base 0.7.991 + mm_common_bands_garray_lookup@Base 1.12.6 + mm_common_bands_garray_sort@Base 1.7.990 + mm_common_bands_garray_to_variant@Base 0.7.991 + mm_common_bands_variant_to_garray@Base 0.7.991 + mm_common_build_bands_any@Base 0.7.991 + mm_common_build_bands_string@Base 0.7.991 + mm_common_build_bands_unknown@Base 0.7.991 + mm_common_build_capabilities_string@Base 0.7.991 + mm_common_build_capability_combinations_none@Base 0.7.991 + mm_common_build_cell_broadcast_channels_default@Base 1.24.0 + mm_common_build_channels_string@Base 1.24.0 + mm_common_build_mode_combinations_default@Base 0.7.991 + mm_common_build_mode_combinations_string@Base 0.7.991 + mm_common_build_oma_pending_network_initiated_sessions_default@Base 1.2.0 + mm_common_build_ports_string@Base 1.0.0 + mm_common_build_sms_storages_string@Base 0.7.991 + mm_common_capability_combinations_array_to_variant@Base 0.7.991 + mm_common_capability_combinations_garray_to_variant@Base 0.7.991 + mm_common_capability_combinations_variant_to_garray@Base 0.7.991 + mm_common_cell_broadcast_channels_array_to_variant@Base 1.24.0 + mm_common_cell_broadcast_channels_garray_to_variant@Base 1.24.0 + mm_common_cell_broadcast_channels_variant_to_garray@Base 1.24.0 + mm_common_error_from_tuple@Base 1.18.2 + mm_common_error_to_tuple@Base 1.18.2 + mm_common_get_3gpp_drx_cycle_from_string@Base 1.20.0 + mm_common_get_3gpp_facility_from_string@Base 1.18.2 + mm_common_get_3gpp_mico_mode_from_string@Base 1.20.0 + mm_common_get_3gpp_packet_service_state_from_string@Base 1.20.0 + mm_common_get_access_technology_from_string@Base 1.18.2 + mm_common_get_access_type_preference_from_string@Base 1.20.0 + mm_common_get_allowed_auth_from_string@Base 0.7.991 + mm_common_get_apn_type_from_string@Base 1.18.2 + mm_common_get_bands_from_string@Base 0.7.991 + mm_common_get_boolean_from_string@Base 0.7.991 + mm_common_get_call_direction_from_string@Base 1.5.993 + mm_common_get_call_state_from_string@Base 1.5.993 + mm_common_get_call_state_reason_from_string@Base 1.5.993 + mm_common_get_capabilities_from_string@Base 0.7.991 + mm_common_get_cell_broadcast_channels_from_string@Base 1.24.0 + mm_common_get_eps_ue_mode_operation_from_string@Base 1.7.990 + mm_common_get_ip_type_from_string@Base 0.7.991 + mm_common_get_modes_from_string@Base 0.7.991 + mm_common_get_multiplex_support_from_string@Base 1.18.2 + mm_common_get_oma_features_from_string@Base 1.2.0 + mm_common_get_oma_session_type_from_string@Base 1.2.0 + mm_common_get_profile_source_from_string@Base 1.20.0 + mm_common_get_rm_protocol_from_string@Base 0.7.991 + mm_common_get_sms_cdma_service_category_from_string@Base 1.2.0 + mm_common_get_sms_cdma_teleservice_id_from_string@Base 1.2.0 + mm_common_get_sms_storage_from_string@Base 0.7.991 + mm_common_mode_combinations_array_to_variant@Base 0.7.991 + mm_common_mode_combinations_garray_to_variant@Base 0.7.991 + mm_common_mode_combinations_variant_to_garray@Base 0.7.991 + mm_common_oma_pending_network_initiated_sessions_array_to_variant@Base 1.2.0 + mm_common_oma_pending_network_initiated_sessions_garray_to_variant@Base 1.2.0 + mm_common_oma_pending_network_initiated_sessions_variant_to_garray@Base 1.2.0 + mm_common_parse_key_value_string@Base 0.7.991 + mm_common_ports_array_to_variant@Base 1.0.0 + mm_common_ports_garray_to_array@Base 1.18.2 + mm_common_ports_garray_to_variant@Base 1.0.0 + mm_common_ports_variant_to_garray@Base 1.0.0 + mm_common_register_errors@Base 1.18.2 + mm_common_sms_storages_array_to_variant@Base 0.7.991 + mm_common_sms_storages_garray_to_variant@Base 0.7.991 + mm_common_sms_storages_variant_to_garray@Base 0.7.991 + mm_common_str_array_human_keys@Base 1.22.0 + mm_common_str_boolean@Base 1.22.0 + mm_common_str_personal_info@Base 1.22.0 + mm_connection_error_get_type@Base 0.7.991 + mm_connection_error_quark@Base 0.7.991 + mm_core_error_get_type@Base 0.7.991 + mm_core_error_quark@Base 0.7.991 + mm_firmware_image_type_get_string@Base 0.7.991 + mm_firmware_image_type_get_type@Base 0.7.991 + mm_firmware_properties_get_dictionary@Base 0.7.991 + mm_firmware_properties_get_gobi_boot_version@Base 0.7.991 + mm_firmware_properties_get_gobi_modem_unique_id@Base 0.7.991 + mm_firmware_properties_get_gobi_pri_info@Base 0.7.991 + mm_firmware_properties_get_gobi_pri_unique_id@Base 0.7.991 + mm_firmware_properties_get_gobi_pri_version@Base 0.7.991 + mm_firmware_properties_get_image_type@Base 0.7.991 + mm_firmware_properties_get_type@Base 0.7.991 + mm_firmware_properties_get_unique_id@Base 0.7.991 + mm_firmware_properties_new@Base 0.7.991 + mm_firmware_properties_new_from_dictionary@Base 0.7.991 + mm_firmware_properties_set_gobi_boot_version@Base 0.7.991 + mm_firmware_properties_set_gobi_modem_unique_id@Base 0.7.991 + mm_firmware_properties_set_gobi_pri_info@Base 0.7.991 + mm_firmware_properties_set_gobi_pri_unique_id@Base 0.7.991 + mm_firmware_properties_set_gobi_pri_version@Base 0.7.991 + mm_firmware_update_settings_get_device_ids@Base 1.10.0 + mm_firmware_update_settings_get_fastboot_at@Base 1.10.0 + mm_firmware_update_settings_get_method@Base 1.10.0 + mm_firmware_update_settings_get_type@Base 1.10.0 + mm_firmware_update_settings_get_variant@Base 1.10.0 + mm_firmware_update_settings_get_version@Base 1.10.0 + mm_firmware_update_settings_new@Base 1.10.0 + mm_firmware_update_settings_new_from_variant@Base 1.10.0 + mm_firmware_update_settings_set_device_ids@Base 1.10.0 + mm_firmware_update_settings_set_fastboot_at@Base 1.10.0 + mm_firmware_update_settings_set_method@Base 1.18.6 + mm_firmware_update_settings_set_version@Base 1.10.0 + mm_gdbus_bearer_call_connect@Base 0.7.991 + mm_gdbus_bearer_call_connect_finish@Base 0.7.991 + mm_gdbus_bearer_call_connect_sync@Base 0.7.991 + mm_gdbus_bearer_call_disconnect@Base 0.7.991 + mm_gdbus_bearer_call_disconnect_finish@Base 0.7.991 + mm_gdbus_bearer_call_disconnect_sync@Base 0.7.991 + mm_gdbus_bearer_complete_connect@Base 0.7.991 + mm_gdbus_bearer_complete_disconnect@Base 0.7.991 + mm_gdbus_bearer_dup_connection_error@Base 1.18.2 + mm_gdbus_bearer_dup_interface@Base 0.7.991 + mm_gdbus_bearer_dup_ip4_config@Base 0.7.991 + mm_gdbus_bearer_dup_ip6_config@Base 0.7.991 + mm_gdbus_bearer_dup_properties@Base 0.7.991 + mm_gdbus_bearer_dup_stats@Base 1.5.993 + mm_gdbus_bearer_get_bearer_type@Base 1.10.0 + mm_gdbus_bearer_get_connected@Base 0.7.991 + mm_gdbus_bearer_get_connection_error@Base 1.18.2 + mm_gdbus_bearer_get_interface@Base 0.7.991 + mm_gdbus_bearer_get_ip4_config@Base 0.7.991 + mm_gdbus_bearer_get_ip6_config@Base 0.7.991 + mm_gdbus_bearer_get_ip_timeout@Base 0.7.991 + mm_gdbus_bearer_get_multiplexed@Base 1.18.2 + mm_gdbus_bearer_get_profile_id@Base 1.18.2 + mm_gdbus_bearer_get_properties@Base 0.7.991 + mm_gdbus_bearer_get_reload_stats_supported@Base 1.20.0 + mm_gdbus_bearer_get_stats@Base 1.5.993 + mm_gdbus_bearer_get_suspended@Base 0.7.991 + mm_gdbus_bearer_get_type@Base 0.7.991 + mm_gdbus_bearer_interface_info@Base 0.7.991 + mm_gdbus_bearer_override_properties@Base 0.7.991 + mm_gdbus_bearer_proxy_get_type@Base 0.7.991 + mm_gdbus_bearer_proxy_new@Base 0.7.991 + mm_gdbus_bearer_proxy_new_finish@Base 0.7.991 + mm_gdbus_bearer_proxy_new_for_bus@Base 0.7.991 + mm_gdbus_bearer_proxy_new_for_bus_finish@Base 0.7.991 + mm_gdbus_bearer_proxy_new_for_bus_sync@Base 0.7.991 + mm_gdbus_bearer_proxy_new_sync@Base 0.7.991 + mm_gdbus_bearer_set_bearer_type@Base 1.10.0 + mm_gdbus_bearer_set_connected@Base 0.7.991 + mm_gdbus_bearer_set_connection_error@Base 1.18.2 + mm_gdbus_bearer_set_interface@Base 0.7.991 + mm_gdbus_bearer_set_ip4_config@Base 0.7.991 + mm_gdbus_bearer_set_ip6_config@Base 0.7.991 + mm_gdbus_bearer_set_ip_timeout@Base 0.7.991 + mm_gdbus_bearer_set_multiplexed@Base 1.18.2 + mm_gdbus_bearer_set_profile_id@Base 1.18.2 + mm_gdbus_bearer_set_properties@Base 0.7.991 + mm_gdbus_bearer_set_reload_stats_supported@Base 1.20.0 + mm_gdbus_bearer_set_stats@Base 1.5.993 + mm_gdbus_bearer_set_suspended@Base 0.7.991 + mm_gdbus_bearer_skeleton_get_type@Base 0.7.991 + mm_gdbus_bearer_skeleton_new@Base 0.7.991 + mm_gdbus_call_call_accept@Base 1.5.993 + mm_gdbus_call_call_accept_finish@Base 1.5.993 + mm_gdbus_call_call_accept_sync@Base 1.5.993 + mm_gdbus_call_call_deflect@Base 1.12.6 + mm_gdbus_call_call_deflect_finish@Base 1.12.6 + mm_gdbus_call_call_deflect_sync@Base 1.12.6 + mm_gdbus_call_call_hangup@Base 1.5.993 + mm_gdbus_call_call_hangup_finish@Base 1.5.993 + mm_gdbus_call_call_hangup_sync@Base 1.5.993 + mm_gdbus_call_call_join_multiparty@Base 1.12.6 + mm_gdbus_call_call_join_multiparty_finish@Base 1.12.6 + mm_gdbus_call_call_join_multiparty_sync@Base 1.12.6 + mm_gdbus_call_call_leave_multiparty@Base 1.12.6 + mm_gdbus_call_call_leave_multiparty_finish@Base 1.12.6 + mm_gdbus_call_call_leave_multiparty_sync@Base 1.12.6 + mm_gdbus_call_call_send_dtmf@Base 1.5.993 + mm_gdbus_call_call_send_dtmf_finish@Base 1.5.993 + mm_gdbus_call_call_send_dtmf_sync@Base 1.5.993 + mm_gdbus_call_call_start@Base 1.5.993 + mm_gdbus_call_call_start_finish@Base 1.5.993 + mm_gdbus_call_call_start_sync@Base 1.5.993 + mm_gdbus_call_complete_accept@Base 1.5.993 + mm_gdbus_call_complete_deflect@Base 1.12.6 + mm_gdbus_call_complete_hangup@Base 1.5.993 + mm_gdbus_call_complete_join_multiparty@Base 1.12.6 + mm_gdbus_call_complete_leave_multiparty@Base 1.12.6 + mm_gdbus_call_complete_send_dtmf@Base 1.5.993 + mm_gdbus_call_complete_start@Base 1.5.993 + mm_gdbus_call_dup_audio_format@Base 1.10.0 + mm_gdbus_call_dup_audio_port@Base 1.10.0 + mm_gdbus_call_dup_number@Base 1.5.993 + mm_gdbus_call_emit_dtmf_received@Base 1.5.993 + mm_gdbus_call_emit_state_changed@Base 1.5.993 + mm_gdbus_call_get_audio_format@Base 1.10.0 + mm_gdbus_call_get_audio_port@Base 1.10.0 + mm_gdbus_call_get_direction@Base 1.5.993 + mm_gdbus_call_get_multiparty@Base 1.12.6 + mm_gdbus_call_get_number@Base 1.5.993 + mm_gdbus_call_get_state@Base 1.5.993 + mm_gdbus_call_get_state_reason@Base 1.5.993 + mm_gdbus_call_get_type@Base 1.5.993 + mm_gdbus_call_interface_info@Base 1.5.993 + mm_gdbus_call_override_properties@Base 1.5.993 + mm_gdbus_call_proxy_get_type@Base 1.5.993 + mm_gdbus_call_proxy_new@Base 1.5.993 + mm_gdbus_call_proxy_new_finish@Base 1.5.993 + mm_gdbus_call_proxy_new_for_bus@Base 1.5.993 + mm_gdbus_call_proxy_new_for_bus_finish@Base 1.5.993 + mm_gdbus_call_proxy_new_for_bus_sync@Base 1.5.993 + mm_gdbus_call_proxy_new_sync@Base 1.5.993 + mm_gdbus_call_set_audio_format@Base 1.10.0 + mm_gdbus_call_set_audio_port@Base 1.10.0 + mm_gdbus_call_set_direction@Base 1.5.993 + mm_gdbus_call_set_multiparty@Base 1.12.6 + mm_gdbus_call_set_number@Base 1.5.993 + mm_gdbus_call_set_state@Base 1.5.993 + mm_gdbus_call_set_state_reason@Base 1.5.993 + mm_gdbus_call_skeleton_get_type@Base 1.5.993 + mm_gdbus_call_skeleton_new@Base 1.5.993 + mm_gdbus_cbm_dup_text@Base 1.23.95 + mm_gdbus_cbm_get_channel@Base 1.23.95 + mm_gdbus_cbm_get_message_code@Base 1.23.95 + mm_gdbus_cbm_get_state@Base 1.23.95 + mm_gdbus_cbm_get_text@Base 1.23.95 + mm_gdbus_cbm_get_type@Base 1.23.95 + mm_gdbus_cbm_get_update@Base 1.23.95 + mm_gdbus_cbm_interface_info@Base 1.23.95 + mm_gdbus_cbm_override_properties@Base 1.23.95 + mm_gdbus_cbm_proxy_get_type@Base 1.23.95 + mm_gdbus_cbm_proxy_new@Base 1.23.95 + mm_gdbus_cbm_proxy_new_finish@Base 1.23.95 + mm_gdbus_cbm_proxy_new_for_bus@Base 1.23.95 + mm_gdbus_cbm_proxy_new_for_bus_finish@Base 1.23.95 + mm_gdbus_cbm_proxy_new_for_bus_sync@Base 1.23.95 + mm_gdbus_cbm_proxy_new_sync@Base 1.23.95 + mm_gdbus_cbm_set_channel@Base 1.23.95 + mm_gdbus_cbm_set_message_code@Base 1.23.95 + mm_gdbus_cbm_set_state@Base 1.23.95 + mm_gdbus_cbm_set_text@Base 1.23.95 + mm_gdbus_cbm_set_update@Base 1.23.95 + mm_gdbus_cbm_skeleton_get_type@Base 1.23.95 + mm_gdbus_cbm_skeleton_new@Base 1.23.95 + mm_gdbus_modem3gpp_call_disable_facility_lock@Base 1.18.2 + mm_gdbus_modem3gpp_call_disable_facility_lock_finish@Base 1.18.2 + mm_gdbus_modem3gpp_call_disable_facility_lock_sync@Base 1.18.2 + mm_gdbus_modem3gpp_call_register@Base 0.7.991 + mm_gdbus_modem3gpp_call_register_finish@Base 0.7.991 + mm_gdbus_modem3gpp_call_register_sync@Base 0.7.991 + mm_gdbus_modem3gpp_call_scan@Base 0.7.991 + mm_gdbus_modem3gpp_call_scan_finish@Base 0.7.991 + mm_gdbus_modem3gpp_call_scan_sync@Base 0.7.991 + mm_gdbus_modem3gpp_call_set_carrier_lock@Base 1.22.0 + mm_gdbus_modem3gpp_call_set_carrier_lock_finish@Base 1.22.0 + mm_gdbus_modem3gpp_call_set_carrier_lock_sync@Base 1.22.0 + mm_gdbus_modem3gpp_call_set_eps_ue_mode_operation@Base 1.7.990 + mm_gdbus_modem3gpp_call_set_eps_ue_mode_operation_finish@Base 1.7.990 + mm_gdbus_modem3gpp_call_set_eps_ue_mode_operation_sync@Base 1.7.990 + mm_gdbus_modem3gpp_call_set_initial_eps_bearer_settings@Base 1.10.0 + mm_gdbus_modem3gpp_call_set_initial_eps_bearer_settings_finish@Base 1.10.0 + mm_gdbus_modem3gpp_call_set_initial_eps_bearer_settings_sync@Base 1.10.0 + mm_gdbus_modem3gpp_call_set_nr5g_registration_settings@Base 1.20.0 + mm_gdbus_modem3gpp_call_set_nr5g_registration_settings_finish@Base 1.20.0 + mm_gdbus_modem3gpp_call_set_nr5g_registration_settings_sync@Base 1.20.0 + mm_gdbus_modem3gpp_call_set_packet_service_state@Base 1.20.0 + mm_gdbus_modem3gpp_call_set_packet_service_state_finish@Base 1.20.0 + mm_gdbus_modem3gpp_call_set_packet_service_state_sync@Base 1.20.0 + mm_gdbus_modem3gpp_complete_disable_facility_lock@Base 1.18.2 + mm_gdbus_modem3gpp_complete_register@Base 0.7.991 + mm_gdbus_modem3gpp_complete_scan@Base 0.7.991 + mm_gdbus_modem3gpp_complete_set_carrier_lock@Base 1.22.0 + mm_gdbus_modem3gpp_complete_set_eps_ue_mode_operation@Base 1.7.990 + mm_gdbus_modem3gpp_complete_set_initial_eps_bearer_settings@Base 1.10.0 + mm_gdbus_modem3gpp_complete_set_nr5g_registration_settings@Base 1.20.0 + mm_gdbus_modem3gpp_complete_set_packet_service_state@Base 1.20.0 + mm_gdbus_modem3gpp_dup_imei@Base 0.7.991 + mm_gdbus_modem3gpp_dup_initial_eps_bearer@Base 1.10.0 + mm_gdbus_modem3gpp_dup_initial_eps_bearer_settings@Base 1.10.0 + mm_gdbus_modem3gpp_dup_network_rejection@Base 1.23.12 + mm_gdbus_modem3gpp_dup_nr5g_registration_settings@Base 1.20.0 + mm_gdbus_modem3gpp_dup_operator_code@Base 0.7.991 + mm_gdbus_modem3gpp_dup_operator_name@Base 0.7.991 + mm_gdbus_modem3gpp_dup_pco@Base 1.10.0 + mm_gdbus_modem3gpp_get_enabled_facility_locks@Base 0.7.991 + mm_gdbus_modem3gpp_get_eps_ue_mode_operation@Base 1.7.990 + mm_gdbus_modem3gpp_get_imei@Base 0.7.991 + mm_gdbus_modem3gpp_get_initial_eps_bearer@Base 1.10.0 + mm_gdbus_modem3gpp_get_initial_eps_bearer_settings@Base 1.10.0 + mm_gdbus_modem3gpp_get_network_rejection@Base 1.23.12 + mm_gdbus_modem3gpp_get_nr5g_registration_settings@Base 1.20.0 + mm_gdbus_modem3gpp_get_operator_code@Base 0.7.991 + mm_gdbus_modem3gpp_get_operator_name@Base 0.7.991 + mm_gdbus_modem3gpp_get_packet_service_state@Base 1.20.0 + mm_gdbus_modem3gpp_get_pco@Base 1.10.0 + mm_gdbus_modem3gpp_get_registration_state@Base 0.7.991 + mm_gdbus_modem3gpp_get_subscription_state@Base 1.2.0 + mm_gdbus_modem3gpp_get_type@Base 0.7.991 + mm_gdbus_modem3gpp_interface_info@Base 0.7.991 + mm_gdbus_modem3gpp_override_properties@Base 0.7.991 + mm_gdbus_modem3gpp_profile_manager_call_delete@Base 1.18.2 + mm_gdbus_modem3gpp_profile_manager_call_delete_finish@Base 1.18.2 + mm_gdbus_modem3gpp_profile_manager_call_delete_sync@Base 1.18.2 + mm_gdbus_modem3gpp_profile_manager_call_list@Base 1.18.2 + mm_gdbus_modem3gpp_profile_manager_call_list_finish@Base 1.18.2 + mm_gdbus_modem3gpp_profile_manager_call_list_sync@Base 1.18.2 + mm_gdbus_modem3gpp_profile_manager_call_set@Base 1.18.2 + mm_gdbus_modem3gpp_profile_manager_call_set_finish@Base 1.18.2 + mm_gdbus_modem3gpp_profile_manager_call_set_sync@Base 1.18.2 + mm_gdbus_modem3gpp_profile_manager_complete_delete@Base 1.18.2 + mm_gdbus_modem3gpp_profile_manager_complete_list@Base 1.18.2 + mm_gdbus_modem3gpp_profile_manager_complete_set@Base 1.18.2 + mm_gdbus_modem3gpp_profile_manager_dup_index_field@Base 1.20.0 + mm_gdbus_modem3gpp_profile_manager_emit_updated@Base 1.18.2 + mm_gdbus_modem3gpp_profile_manager_get_index_field@Base 1.20.0 + mm_gdbus_modem3gpp_profile_manager_get_type@Base 1.18.2 + mm_gdbus_modem3gpp_profile_manager_interface_info@Base 1.18.2 + mm_gdbus_modem3gpp_profile_manager_override_properties@Base 1.18.2 + mm_gdbus_modem3gpp_profile_manager_proxy_get_type@Base 1.18.2 + mm_gdbus_modem3gpp_profile_manager_proxy_new@Base 1.18.2 + mm_gdbus_modem3gpp_profile_manager_proxy_new_finish@Base 1.18.2 + mm_gdbus_modem3gpp_profile_manager_proxy_new_for_bus@Base 1.18.2 + mm_gdbus_modem3gpp_profile_manager_proxy_new_for_bus_finish@Base 1.18.2 + mm_gdbus_modem3gpp_profile_manager_proxy_new_for_bus_sync@Base 1.18.2 + mm_gdbus_modem3gpp_profile_manager_proxy_new_sync@Base 1.18.2 + mm_gdbus_modem3gpp_profile_manager_set_index_field@Base 1.20.0 + mm_gdbus_modem3gpp_profile_manager_skeleton_get_type@Base 1.18.2 + mm_gdbus_modem3gpp_profile_manager_skeleton_new@Base 1.18.2 + mm_gdbus_modem3gpp_proxy_get_type@Base 0.7.991 + mm_gdbus_modem3gpp_proxy_new@Base 0.7.991 + mm_gdbus_modem3gpp_proxy_new_finish@Base 0.7.991 + mm_gdbus_modem3gpp_proxy_new_for_bus@Base 0.7.991 + mm_gdbus_modem3gpp_proxy_new_for_bus_finish@Base 0.7.991 + mm_gdbus_modem3gpp_proxy_new_for_bus_sync@Base 0.7.991 + mm_gdbus_modem3gpp_proxy_new_sync@Base 0.7.991 + mm_gdbus_modem3gpp_set_enabled_facility_locks@Base 0.7.991 + mm_gdbus_modem3gpp_set_eps_ue_mode_operation@Base 1.7.990 + mm_gdbus_modem3gpp_set_imei@Base 0.7.991 + mm_gdbus_modem3gpp_set_initial_eps_bearer@Base 1.10.0 + mm_gdbus_modem3gpp_set_initial_eps_bearer_settings@Base 1.10.0 + mm_gdbus_modem3gpp_set_network_rejection@Base 1.23.12 + mm_gdbus_modem3gpp_set_nr5g_registration_settings@Base 1.20.0 + mm_gdbus_modem3gpp_set_operator_code@Base 0.7.991 + mm_gdbus_modem3gpp_set_operator_name@Base 0.7.991 + mm_gdbus_modem3gpp_set_packet_service_state@Base 1.20.0 + mm_gdbus_modem3gpp_set_pco@Base 1.10.0 + mm_gdbus_modem3gpp_set_registration_state@Base 0.7.991 + mm_gdbus_modem3gpp_set_subscription_state@Base 1.2.0 + mm_gdbus_modem3gpp_skeleton_get_type@Base 0.7.991 + mm_gdbus_modem3gpp_skeleton_new@Base 0.7.991 + mm_gdbus_modem3gpp_ussd_call_cancel@Base 0.7.991 + mm_gdbus_modem3gpp_ussd_call_cancel_finish@Base 0.7.991 + mm_gdbus_modem3gpp_ussd_call_cancel_sync@Base 0.7.991 + mm_gdbus_modem3gpp_ussd_call_initiate@Base 0.7.991 + mm_gdbus_modem3gpp_ussd_call_initiate_finish@Base 0.7.991 + mm_gdbus_modem3gpp_ussd_call_initiate_sync@Base 0.7.991 + mm_gdbus_modem3gpp_ussd_call_respond@Base 0.7.991 + mm_gdbus_modem3gpp_ussd_call_respond_finish@Base 0.7.991 + mm_gdbus_modem3gpp_ussd_call_respond_sync@Base 0.7.991 + mm_gdbus_modem3gpp_ussd_complete_cancel@Base 0.7.991 + mm_gdbus_modem3gpp_ussd_complete_initiate@Base 0.7.991 + mm_gdbus_modem3gpp_ussd_complete_respond@Base 0.7.991 + mm_gdbus_modem3gpp_ussd_dup_network_notification@Base 0.7.991 + mm_gdbus_modem3gpp_ussd_dup_network_request@Base 0.7.991 + mm_gdbus_modem3gpp_ussd_get_network_notification@Base 0.7.991 + mm_gdbus_modem3gpp_ussd_get_network_request@Base 0.7.991 + mm_gdbus_modem3gpp_ussd_get_state@Base 0.7.991 + mm_gdbus_modem3gpp_ussd_get_type@Base 0.7.991 + mm_gdbus_modem3gpp_ussd_interface_info@Base 0.7.991 + mm_gdbus_modem3gpp_ussd_override_properties@Base 0.7.991 + mm_gdbus_modem3gpp_ussd_proxy_get_type@Base 0.7.991 + mm_gdbus_modem3gpp_ussd_proxy_new@Base 0.7.991 + mm_gdbus_modem3gpp_ussd_proxy_new_finish@Base 0.7.991 + mm_gdbus_modem3gpp_ussd_proxy_new_for_bus@Base 0.7.991 + mm_gdbus_modem3gpp_ussd_proxy_new_for_bus_finish@Base 0.7.991 + mm_gdbus_modem3gpp_ussd_proxy_new_for_bus_sync@Base 0.7.991 + mm_gdbus_modem3gpp_ussd_proxy_new_sync@Base 0.7.991 + mm_gdbus_modem3gpp_ussd_set_network_notification@Base 0.7.991 + mm_gdbus_modem3gpp_ussd_set_network_request@Base 0.7.991 + mm_gdbus_modem3gpp_ussd_set_state@Base 0.7.991 + mm_gdbus_modem3gpp_ussd_skeleton_get_type@Base 0.7.991 + mm_gdbus_modem3gpp_ussd_skeleton_new@Base 0.7.991 + mm_gdbus_modem_call_command@Base 0.7.991 + mm_gdbus_modem_call_command_finish@Base 0.7.991 + mm_gdbus_modem_call_command_sync@Base 0.7.991 + mm_gdbus_modem_call_create_bearer@Base 0.7.991 + mm_gdbus_modem_call_create_bearer_finish@Base 0.7.991 + mm_gdbus_modem_call_create_bearer_sync@Base 0.7.991 + mm_gdbus_modem_call_delete_bearer@Base 0.7.991 + mm_gdbus_modem_call_delete_bearer_finish@Base 0.7.991 + mm_gdbus_modem_call_delete_bearer_sync@Base 0.7.991 + mm_gdbus_modem_call_enable@Base 0.7.991 + mm_gdbus_modem_call_enable_finish@Base 0.7.991 + mm_gdbus_modem_call_enable_sync@Base 0.7.991 + mm_gdbus_modem_call_factory_reset@Base 0.7.991 + mm_gdbus_modem_call_factory_reset_finish@Base 0.7.991 + mm_gdbus_modem_call_factory_reset_sync@Base 0.7.991 + mm_gdbus_modem_call_get_cell_info@Base 1.20.0 + mm_gdbus_modem_call_get_cell_info_finish@Base 1.20.0 + mm_gdbus_modem_call_get_cell_info_sync@Base 1.20.0 + mm_gdbus_modem_call_list_bearers@Base 0.7.991 + mm_gdbus_modem_call_list_bearers_finish@Base 0.7.991 + mm_gdbus_modem_call_list_bearers_sync@Base 0.7.991 + mm_gdbus_modem_call_reset@Base 0.7.991 + mm_gdbus_modem_call_reset_finish@Base 0.7.991 + mm_gdbus_modem_call_reset_sync@Base 0.7.991 + mm_gdbus_modem_call_set_current_bands@Base 0.7.991 + mm_gdbus_modem_call_set_current_bands_finish@Base 0.7.991 + mm_gdbus_modem_call_set_current_bands_sync@Base 0.7.991 + mm_gdbus_modem_call_set_current_capabilities@Base 0.7.991 + mm_gdbus_modem_call_set_current_capabilities_finish@Base 0.7.991 + mm_gdbus_modem_call_set_current_capabilities_sync@Base 0.7.991 + mm_gdbus_modem_call_set_current_modes@Base 0.7.991 + mm_gdbus_modem_call_set_current_modes_finish@Base 0.7.991 + mm_gdbus_modem_call_set_current_modes_sync@Base 0.7.991 + mm_gdbus_modem_call_set_power_state@Base 0.7.991 + mm_gdbus_modem_call_set_power_state_finish@Base 0.7.991 + mm_gdbus_modem_call_set_power_state_sync@Base 0.7.991 + mm_gdbus_modem_call_set_primary_sim_slot@Base 1.16.6 + mm_gdbus_modem_call_set_primary_sim_slot_finish@Base 1.16.6 + mm_gdbus_modem_call_set_primary_sim_slot_sync@Base 1.16.6 + mm_gdbus_modem_cdma_call_activate@Base 0.7.991 + mm_gdbus_modem_cdma_call_activate_finish@Base 0.7.991 + mm_gdbus_modem_cdma_call_activate_manual@Base 0.7.991 + mm_gdbus_modem_cdma_call_activate_manual_finish@Base 0.7.991 + mm_gdbus_modem_cdma_call_activate_manual_sync@Base 0.7.991 + mm_gdbus_modem_cdma_call_activate_sync@Base 0.7.991 + mm_gdbus_modem_cdma_complete_activate@Base 0.7.991 + mm_gdbus_modem_cdma_complete_activate_manual@Base 0.7.991 + mm_gdbus_modem_cdma_dup_esn@Base 0.7.991 + mm_gdbus_modem_cdma_dup_meid@Base 0.7.991 + mm_gdbus_modem_cdma_emit_activation_state_changed@Base 0.7.991 + mm_gdbus_modem_cdma_get_activation_state@Base 0.7.991 + mm_gdbus_modem_cdma_get_cdma1x_registration_state@Base 0.7.991 + mm_gdbus_modem_cdma_get_esn@Base 0.7.991 + mm_gdbus_modem_cdma_get_evdo_registration_state@Base 0.7.991 + mm_gdbus_modem_cdma_get_meid@Base 0.7.991 + mm_gdbus_modem_cdma_get_nid@Base 0.7.991 + mm_gdbus_modem_cdma_get_sid@Base 0.7.991 + mm_gdbus_modem_cdma_get_type@Base 0.7.991 + mm_gdbus_modem_cdma_interface_info@Base 0.7.991 + mm_gdbus_modem_cdma_override_properties@Base 0.7.991 + mm_gdbus_modem_cdma_proxy_get_type@Base 0.7.991 + mm_gdbus_modem_cdma_proxy_new@Base 0.7.991 + mm_gdbus_modem_cdma_proxy_new_finish@Base 0.7.991 + mm_gdbus_modem_cdma_proxy_new_for_bus@Base 0.7.991 + mm_gdbus_modem_cdma_proxy_new_for_bus_finish@Base 0.7.991 + mm_gdbus_modem_cdma_proxy_new_for_bus_sync@Base 0.7.991 + mm_gdbus_modem_cdma_proxy_new_sync@Base 0.7.991 + mm_gdbus_modem_cdma_set_activation_state@Base 0.7.991 + mm_gdbus_modem_cdma_set_cdma1x_registration_state@Base 0.7.991 + mm_gdbus_modem_cdma_set_esn@Base 0.7.991 + mm_gdbus_modem_cdma_set_evdo_registration_state@Base 0.7.991 + mm_gdbus_modem_cdma_set_meid@Base 0.7.991 + mm_gdbus_modem_cdma_set_nid@Base 0.7.991 + mm_gdbus_modem_cdma_set_sid@Base 0.7.991 + mm_gdbus_modem_cdma_skeleton_get_type@Base 0.7.991 + mm_gdbus_modem_cdma_skeleton_new@Base 0.7.991 + mm_gdbus_modem_cell_broadcast_call_delete@Base 1.23.95 + mm_gdbus_modem_cell_broadcast_call_delete_finish@Base 1.23.95 + mm_gdbus_modem_cell_broadcast_call_delete_sync@Base 1.23.95 + mm_gdbus_modem_cell_broadcast_call_list@Base 1.23.95 + mm_gdbus_modem_cell_broadcast_call_list_finish@Base 1.23.95 + mm_gdbus_modem_cell_broadcast_call_list_sync@Base 1.23.95 + mm_gdbus_modem_cell_broadcast_call_set_channels@Base 1.24.0 + mm_gdbus_modem_cell_broadcast_call_set_channels_finish@Base 1.24.0 + mm_gdbus_modem_cell_broadcast_call_set_channels_sync@Base 1.24.0 + mm_gdbus_modem_cell_broadcast_complete_delete@Base 1.23.95 + mm_gdbus_modem_cell_broadcast_complete_list@Base 1.23.95 + mm_gdbus_modem_cell_broadcast_complete_set_channels@Base 1.24.0 + mm_gdbus_modem_cell_broadcast_dup_cell_broadcasts@Base 1.23.95 + mm_gdbus_modem_cell_broadcast_dup_channels@Base 1.24.0 + mm_gdbus_modem_cell_broadcast_emit_added@Base 1.23.95 + mm_gdbus_modem_cell_broadcast_emit_deleted@Base 1.23.95 + mm_gdbus_modem_cell_broadcast_get_cell_broadcasts@Base 1.23.95 + mm_gdbus_modem_cell_broadcast_get_channels@Base 1.24.0 + mm_gdbus_modem_cell_broadcast_get_type@Base 1.23.95 + mm_gdbus_modem_cell_broadcast_interface_info@Base 1.23.95 + mm_gdbus_modem_cell_broadcast_override_properties@Base 1.23.95 + mm_gdbus_modem_cell_broadcast_proxy_get_type@Base 1.23.95 + mm_gdbus_modem_cell_broadcast_proxy_new@Base 1.23.95 + mm_gdbus_modem_cell_broadcast_proxy_new_finish@Base 1.23.95 + mm_gdbus_modem_cell_broadcast_proxy_new_for_bus@Base 1.23.95 + mm_gdbus_modem_cell_broadcast_proxy_new_for_bus_finish@Base 1.23.95 + mm_gdbus_modem_cell_broadcast_proxy_new_for_bus_sync@Base 1.23.95 + mm_gdbus_modem_cell_broadcast_proxy_new_sync@Base 1.23.95 + mm_gdbus_modem_cell_broadcast_set_cell_broadcasts@Base 1.23.95 + mm_gdbus_modem_cell_broadcast_set_channels@Base 1.24.0 + mm_gdbus_modem_cell_broadcast_skeleton_get_type@Base 1.23.95 + mm_gdbus_modem_cell_broadcast_skeleton_new@Base 1.23.95 + mm_gdbus_modem_complete_command@Base 0.7.991 + mm_gdbus_modem_complete_create_bearer@Base 0.7.991 + mm_gdbus_modem_complete_delete_bearer@Base 0.7.991 + mm_gdbus_modem_complete_enable@Base 0.7.991 + mm_gdbus_modem_complete_factory_reset@Base 0.7.991 + mm_gdbus_modem_complete_get_cell_info@Base 1.20.0 + mm_gdbus_modem_complete_list_bearers@Base 0.7.991 + mm_gdbus_modem_complete_reset@Base 0.7.991 + mm_gdbus_modem_complete_set_current_bands@Base 0.7.991 + mm_gdbus_modem_complete_set_current_capabilities@Base 0.7.991 + mm_gdbus_modem_complete_set_current_modes@Base 0.7.991 + mm_gdbus_modem_complete_set_power_state@Base 0.7.991 + mm_gdbus_modem_complete_set_primary_sim_slot@Base 1.16.6 + mm_gdbus_modem_dup_bearers@Base 1.2.0 + mm_gdbus_modem_dup_carrier_configuration@Base 1.10.4 + mm_gdbus_modem_dup_carrier_configuration_revision@Base 1.10.4 + mm_gdbus_modem_dup_current_bands@Base 0.7.991 + mm_gdbus_modem_dup_current_modes@Base 0.7.991 + mm_gdbus_modem_dup_device@Base 0.7.991 + mm_gdbus_modem_dup_device_identifier@Base 0.7.991 + mm_gdbus_modem_dup_drivers@Base 0.7.991 + mm_gdbus_modem_dup_equipment_identifier@Base 0.7.991 + mm_gdbus_modem_dup_hardware_revision@Base 1.7.990 + mm_gdbus_modem_dup_manufacturer@Base 0.7.991 + mm_gdbus_modem_dup_model@Base 0.7.991 + mm_gdbus_modem_dup_own_numbers@Base 0.7.991 + mm_gdbus_modem_dup_physdev@Base 1.22.0 + mm_gdbus_modem_dup_plugin@Base 0.7.991 + mm_gdbus_modem_dup_ports@Base 1.0.0 + mm_gdbus_modem_dup_primary_port@Base 0.7.991 + mm_gdbus_modem_dup_revision@Base 0.7.991 + mm_gdbus_modem_dup_signal_quality@Base 0.7.991 + mm_gdbus_modem_dup_sim@Base 0.7.991 + mm_gdbus_modem_dup_sim_slots@Base 1.16.6 + mm_gdbus_modem_dup_supported_bands@Base 0.7.991 + mm_gdbus_modem_dup_supported_capabilities@Base 0.7.991 + mm_gdbus_modem_dup_supported_modes@Base 0.7.991 + mm_gdbus_modem_dup_unlock_retries@Base 0.7.991 + mm_gdbus_modem_emit_state_changed@Base 0.7.991 + mm_gdbus_modem_firmware_call_list@Base 0.7.991 + mm_gdbus_modem_firmware_call_list_finish@Base 0.7.991 + mm_gdbus_modem_firmware_call_list_sync@Base 0.7.991 + mm_gdbus_modem_firmware_call_select@Base 0.7.991 + mm_gdbus_modem_firmware_call_select_finish@Base 0.7.991 + mm_gdbus_modem_firmware_call_select_sync@Base 0.7.991 + mm_gdbus_modem_firmware_complete_list@Base 0.7.991 + mm_gdbus_modem_firmware_complete_select@Base 0.7.991 + mm_gdbus_modem_firmware_dup_update_settings@Base 1.10.0 + mm_gdbus_modem_firmware_get_type@Base 0.7.991 + mm_gdbus_modem_firmware_get_update_settings@Base 1.10.0 + mm_gdbus_modem_firmware_interface_info@Base 0.7.991 + mm_gdbus_modem_firmware_override_properties@Base 0.7.991 + mm_gdbus_modem_firmware_proxy_get_type@Base 0.7.991 + mm_gdbus_modem_firmware_proxy_new@Base 0.7.991 + mm_gdbus_modem_firmware_proxy_new_finish@Base 0.7.991 + mm_gdbus_modem_firmware_proxy_new_for_bus@Base 0.7.991 + mm_gdbus_modem_firmware_proxy_new_for_bus_finish@Base 0.7.991 + mm_gdbus_modem_firmware_proxy_new_for_bus_sync@Base 0.7.991 + mm_gdbus_modem_firmware_proxy_new_sync@Base 0.7.991 + mm_gdbus_modem_firmware_set_update_settings@Base 1.10.0 + mm_gdbus_modem_firmware_skeleton_get_type@Base 0.7.991 + mm_gdbus_modem_firmware_skeleton_new@Base 0.7.991 + mm_gdbus_modem_get_access_technologies@Base 0.7.991 + mm_gdbus_modem_get_bearers@Base 1.2.0 + mm_gdbus_modem_get_carrier_configuration@Base 1.10.4 + mm_gdbus_modem_get_carrier_configuration_revision@Base 1.10.4 + mm_gdbus_modem_get_current_bands@Base 0.7.991 + mm_gdbus_modem_get_current_capabilities@Base 0.7.991 + mm_gdbus_modem_get_current_modes@Base 0.7.991 + mm_gdbus_modem_get_device@Base 0.7.991 + mm_gdbus_modem_get_device_identifier@Base 0.7.991 + mm_gdbus_modem_get_drivers@Base 0.7.991 + mm_gdbus_modem_get_equipment_identifier@Base 0.7.991 + mm_gdbus_modem_get_hardware_revision@Base 1.7.990 + mm_gdbus_modem_get_manufacturer@Base 0.7.991 + mm_gdbus_modem_get_max_active_bearers@Base 0.7.991 + mm_gdbus_modem_get_max_active_multiplexed_bearers@Base 1.18.2 + mm_gdbus_modem_get_max_bearers@Base 0.7.991 + mm_gdbus_modem_get_model@Base 0.7.991 + mm_gdbus_modem_get_own_numbers@Base 0.7.991 + mm_gdbus_modem_get_physdev@Base 1.22.0 + mm_gdbus_modem_get_plugin@Base 0.7.991 + mm_gdbus_modem_get_ports@Base 1.0.0 + mm_gdbus_modem_get_power_state@Base 0.7.991 + mm_gdbus_modem_get_primary_port@Base 0.7.991 + mm_gdbus_modem_get_primary_sim_slot@Base 1.16.6 + mm_gdbus_modem_get_revision@Base 0.7.991 + mm_gdbus_modem_get_signal_quality@Base 0.7.991 + mm_gdbus_modem_get_sim@Base 0.7.991 + mm_gdbus_modem_get_sim_slots@Base 1.16.6 + mm_gdbus_modem_get_state@Base 0.7.991 + mm_gdbus_modem_get_state_failed_reason@Base 0.7.991 + mm_gdbus_modem_get_supported_bands@Base 0.7.991 + mm_gdbus_modem_get_supported_capabilities@Base 0.7.991 + mm_gdbus_modem_get_supported_ip_families@Base 0.7.991 + mm_gdbus_modem_get_supported_modes@Base 0.7.991 + mm_gdbus_modem_get_type@Base 0.7.991 + mm_gdbus_modem_get_unlock_required@Base 0.7.991 + mm_gdbus_modem_get_unlock_retries@Base 0.7.991 + mm_gdbus_modem_interface_info@Base 0.7.991 + mm_gdbus_modem_location_call_get_location@Base 0.7.991 + mm_gdbus_modem_location_call_get_location_finish@Base 0.7.991 + mm_gdbus_modem_location_call_get_location_sync@Base 0.7.991 + mm_gdbus_modem_location_call_inject_assistance_data@Base 1.10.0 + mm_gdbus_modem_location_call_inject_assistance_data_finish@Base 1.10.0 + mm_gdbus_modem_location_call_inject_assistance_data_sync@Base 1.10.0 + mm_gdbus_modem_location_call_set_gps_refresh_rate@Base 1.5.993 + mm_gdbus_modem_location_call_set_gps_refresh_rate_finish@Base 1.5.993 + mm_gdbus_modem_location_call_set_gps_refresh_rate_sync@Base 1.5.993 + mm_gdbus_modem_location_call_set_supl_server@Base 1.5.993 + mm_gdbus_modem_location_call_set_supl_server_finish@Base 1.5.993 + mm_gdbus_modem_location_call_set_supl_server_sync@Base 1.5.993 + mm_gdbus_modem_location_call_setup@Base 0.7.991 + mm_gdbus_modem_location_call_setup_finish@Base 0.7.991 + mm_gdbus_modem_location_call_setup_sync@Base 0.7.991 + mm_gdbus_modem_location_complete_get_location@Base 0.7.991 + mm_gdbus_modem_location_complete_inject_assistance_data@Base 1.10.0 + mm_gdbus_modem_location_complete_set_gps_refresh_rate@Base 1.5.993 + mm_gdbus_modem_location_complete_set_supl_server@Base 1.5.993 + mm_gdbus_modem_location_complete_setup@Base 0.7.991 + mm_gdbus_modem_location_dup_assistance_data_servers@Base 1.10.0 + mm_gdbus_modem_location_dup_location@Base 0.7.991 + mm_gdbus_modem_location_dup_supl_server@Base 1.5.993 + mm_gdbus_modem_location_get_assistance_data_servers@Base 1.10.0 + mm_gdbus_modem_location_get_capabilities@Base 0.7.991 + mm_gdbus_modem_location_get_enabled@Base 0.7.991 + mm_gdbus_modem_location_get_gps_refresh_rate@Base 1.5.993 + mm_gdbus_modem_location_get_location@Base 0.7.991 + mm_gdbus_modem_location_get_signals_location@Base 0.7.991 + mm_gdbus_modem_location_get_supl_server@Base 1.5.993 + mm_gdbus_modem_location_get_supported_assistance_data@Base 1.10.0 + mm_gdbus_modem_location_get_type@Base 0.7.991 + mm_gdbus_modem_location_interface_info@Base 0.7.991 + mm_gdbus_modem_location_override_properties@Base 0.7.991 + mm_gdbus_modem_location_proxy_get_type@Base 0.7.991 + mm_gdbus_modem_location_proxy_new@Base 0.7.991 + mm_gdbus_modem_location_proxy_new_finish@Base 0.7.991 + mm_gdbus_modem_location_proxy_new_for_bus@Base 0.7.991 + mm_gdbus_modem_location_proxy_new_for_bus_finish@Base 0.7.991 + mm_gdbus_modem_location_proxy_new_for_bus_sync@Base 0.7.991 + mm_gdbus_modem_location_proxy_new_sync@Base 0.7.991 + mm_gdbus_modem_location_set_assistance_data_servers@Base 1.10.0 + mm_gdbus_modem_location_set_capabilities@Base 0.7.991 + mm_gdbus_modem_location_set_enabled@Base 0.7.991 + mm_gdbus_modem_location_set_gps_refresh_rate@Base 1.5.993 + mm_gdbus_modem_location_set_location@Base 0.7.991 + mm_gdbus_modem_location_set_signals_location@Base 0.7.991 + mm_gdbus_modem_location_set_supl_server@Base 1.5.993 + mm_gdbus_modem_location_set_supported_assistance_data@Base 1.10.0 + mm_gdbus_modem_location_skeleton_get_type@Base 0.7.991 + mm_gdbus_modem_location_skeleton_new@Base 0.7.991 + mm_gdbus_modem_messaging_call_create@Base 0.7.991 + mm_gdbus_modem_messaging_call_create_finish@Base 0.7.991 + mm_gdbus_modem_messaging_call_create_sync@Base 0.7.991 + mm_gdbus_modem_messaging_call_delete@Base 0.7.991 + mm_gdbus_modem_messaging_call_delete_finish@Base 0.7.991 + mm_gdbus_modem_messaging_call_delete_sync@Base 0.7.991 + mm_gdbus_modem_messaging_call_list@Base 0.7.991 + mm_gdbus_modem_messaging_call_list_finish@Base 0.7.991 + mm_gdbus_modem_messaging_call_list_sync@Base 0.7.991 + mm_gdbus_modem_messaging_call_set_default_storage@Base 1.23.12 + mm_gdbus_modem_messaging_call_set_default_storage_finish@Base 1.23.12 + mm_gdbus_modem_messaging_call_set_default_storage_sync@Base 1.23.12 + mm_gdbus_modem_messaging_complete_create@Base 0.7.991 + mm_gdbus_modem_messaging_complete_delete@Base 0.7.991 + mm_gdbus_modem_messaging_complete_list@Base 0.7.991 + mm_gdbus_modem_messaging_complete_set_default_storage@Base 1.23.12 + mm_gdbus_modem_messaging_dup_messages@Base 1.2.0 + mm_gdbus_modem_messaging_dup_supported_storages@Base 0.7.991 + mm_gdbus_modem_messaging_emit_added@Base 0.7.991 + mm_gdbus_modem_messaging_emit_deleted@Base 0.7.991 + mm_gdbus_modem_messaging_get_default_storage@Base 0.7.991 + mm_gdbus_modem_messaging_get_messages@Base 1.2.0 + mm_gdbus_modem_messaging_get_supported_storages@Base 0.7.991 + mm_gdbus_modem_messaging_get_type@Base 0.7.991 + mm_gdbus_modem_messaging_interface_info@Base 0.7.991 + mm_gdbus_modem_messaging_override_properties@Base 0.7.991 + mm_gdbus_modem_messaging_proxy_get_type@Base 0.7.991 + mm_gdbus_modem_messaging_proxy_new@Base 0.7.991 + mm_gdbus_modem_messaging_proxy_new_finish@Base 0.7.991 + mm_gdbus_modem_messaging_proxy_new_for_bus@Base 0.7.991 + mm_gdbus_modem_messaging_proxy_new_for_bus_finish@Base 0.7.991 + mm_gdbus_modem_messaging_proxy_new_for_bus_sync@Base 0.7.991 + mm_gdbus_modem_messaging_proxy_new_sync@Base 0.7.991 + mm_gdbus_modem_messaging_set_default_storage@Base 0.7.991 + mm_gdbus_modem_messaging_set_messages@Base 1.2.0 + mm_gdbus_modem_messaging_set_supported_storages@Base 0.7.991 + mm_gdbus_modem_messaging_skeleton_get_type@Base 0.7.991 + mm_gdbus_modem_messaging_skeleton_new@Base 0.7.991 + mm_gdbus_modem_oma_call_accept_network_initiated_session@Base 1.2.0 + mm_gdbus_modem_oma_call_accept_network_initiated_session_finish@Base 1.2.0 + mm_gdbus_modem_oma_call_accept_network_initiated_session_sync@Base 1.2.0 + mm_gdbus_modem_oma_call_cancel_session@Base 1.2.0 + mm_gdbus_modem_oma_call_cancel_session_finish@Base 1.2.0 + mm_gdbus_modem_oma_call_cancel_session_sync@Base 1.2.0 + mm_gdbus_modem_oma_call_setup@Base 1.2.0 + mm_gdbus_modem_oma_call_setup_finish@Base 1.2.0 + mm_gdbus_modem_oma_call_setup_sync@Base 1.2.0 + mm_gdbus_modem_oma_call_start_client_initiated_session@Base 1.2.0 + mm_gdbus_modem_oma_call_start_client_initiated_session_finish@Base 1.2.0 + mm_gdbus_modem_oma_call_start_client_initiated_session_sync@Base 1.2.0 + mm_gdbus_modem_oma_complete_accept_network_initiated_session@Base 1.2.0 + mm_gdbus_modem_oma_complete_cancel_session@Base 1.2.0 + mm_gdbus_modem_oma_complete_setup@Base 1.2.0 + mm_gdbus_modem_oma_complete_start_client_initiated_session@Base 1.2.0 + mm_gdbus_modem_oma_dup_pending_network_initiated_sessions@Base 1.2.0 + mm_gdbus_modem_oma_emit_session_state_changed@Base 1.2.0 + mm_gdbus_modem_oma_get_features@Base 1.2.0 + mm_gdbus_modem_oma_get_pending_network_initiated_sessions@Base 1.2.0 + mm_gdbus_modem_oma_get_session_state@Base 1.2.0 + mm_gdbus_modem_oma_get_session_type@Base 1.2.0 + mm_gdbus_modem_oma_get_type@Base 1.2.0 + mm_gdbus_modem_oma_interface_info@Base 1.2.0 + mm_gdbus_modem_oma_override_properties@Base 1.2.0 + mm_gdbus_modem_oma_proxy_get_type@Base 1.2.0 + mm_gdbus_modem_oma_proxy_new@Base 1.2.0 + mm_gdbus_modem_oma_proxy_new_finish@Base 1.2.0 + mm_gdbus_modem_oma_proxy_new_for_bus@Base 1.2.0 + mm_gdbus_modem_oma_proxy_new_for_bus_finish@Base 1.2.0 + mm_gdbus_modem_oma_proxy_new_for_bus_sync@Base 1.2.0 + mm_gdbus_modem_oma_proxy_new_sync@Base 1.2.0 + mm_gdbus_modem_oma_set_features@Base 1.2.0 + mm_gdbus_modem_oma_set_pending_network_initiated_sessions@Base 1.2.0 + mm_gdbus_modem_oma_set_session_state@Base 1.2.0 + mm_gdbus_modem_oma_set_session_type@Base 1.2.0 + mm_gdbus_modem_oma_skeleton_get_type@Base 1.2.0 + mm_gdbus_modem_oma_skeleton_new@Base 1.2.0 + mm_gdbus_modem_override_properties@Base 0.7.991 + mm_gdbus_modem_proxy_get_type@Base 0.7.991 + mm_gdbus_modem_proxy_new@Base 0.7.991 + mm_gdbus_modem_proxy_new_finish@Base 0.7.991 + mm_gdbus_modem_proxy_new_for_bus@Base 0.7.991 + mm_gdbus_modem_proxy_new_for_bus_finish@Base 0.7.991 + mm_gdbus_modem_proxy_new_for_bus_sync@Base 0.7.991 + mm_gdbus_modem_proxy_new_sync@Base 0.7.991 + mm_gdbus_modem_sar_call_enable@Base 1.20.0 + mm_gdbus_modem_sar_call_enable_finish@Base 1.20.0 + mm_gdbus_modem_sar_call_enable_sync@Base 1.20.0 + mm_gdbus_modem_sar_call_set_power_level@Base 1.20.0 + mm_gdbus_modem_sar_call_set_power_level_finish@Base 1.20.0 + mm_gdbus_modem_sar_call_set_power_level_sync@Base 1.20.0 + mm_gdbus_modem_sar_complete_enable@Base 1.20.0 + mm_gdbus_modem_sar_complete_set_power_level@Base 1.20.0 + mm_gdbus_modem_sar_get_power_level@Base 1.20.0 + mm_gdbus_modem_sar_get_state@Base 1.20.0 + mm_gdbus_modem_sar_get_type@Base 1.20.0 + mm_gdbus_modem_sar_interface_info@Base 1.20.0 + mm_gdbus_modem_sar_override_properties@Base 1.20.0 + mm_gdbus_modem_sar_proxy_get_type@Base 1.20.0 + mm_gdbus_modem_sar_proxy_new@Base 1.20.0 + mm_gdbus_modem_sar_proxy_new_finish@Base 1.20.0 + mm_gdbus_modem_sar_proxy_new_for_bus@Base 1.20.0 + mm_gdbus_modem_sar_proxy_new_for_bus_finish@Base 1.20.0 + mm_gdbus_modem_sar_proxy_new_for_bus_sync@Base 1.20.0 + mm_gdbus_modem_sar_proxy_new_sync@Base 1.20.0 + mm_gdbus_modem_sar_set_power_level@Base 1.20.0 + mm_gdbus_modem_sar_set_state@Base 1.20.0 + mm_gdbus_modem_sar_skeleton_get_type@Base 1.20.0 + mm_gdbus_modem_sar_skeleton_new@Base 1.20.0 + mm_gdbus_modem_set_access_technologies@Base 0.7.991 + mm_gdbus_modem_set_bearers@Base 1.2.0 + mm_gdbus_modem_set_carrier_configuration@Base 1.10.4 + mm_gdbus_modem_set_carrier_configuration_revision@Base 1.10.4 + mm_gdbus_modem_set_current_bands@Base 0.7.991 + mm_gdbus_modem_set_current_capabilities@Base 0.7.991 + mm_gdbus_modem_set_current_modes@Base 0.7.991 + mm_gdbus_modem_set_device@Base 0.7.991 + mm_gdbus_modem_set_device_identifier@Base 0.7.991 + mm_gdbus_modem_set_drivers@Base 0.7.991 + mm_gdbus_modem_set_equipment_identifier@Base 0.7.991 + mm_gdbus_modem_set_hardware_revision@Base 1.7.990 + mm_gdbus_modem_set_manufacturer@Base 0.7.991 + mm_gdbus_modem_set_max_active_bearers@Base 0.7.991 + mm_gdbus_modem_set_max_active_multiplexed_bearers@Base 1.18.2 + mm_gdbus_modem_set_max_bearers@Base 0.7.991 + mm_gdbus_modem_set_model@Base 0.7.991 + mm_gdbus_modem_set_own_numbers@Base 0.7.991 + mm_gdbus_modem_set_physdev@Base 1.22.0 + mm_gdbus_modem_set_plugin@Base 0.7.991 + mm_gdbus_modem_set_ports@Base 1.0.0 + mm_gdbus_modem_set_power_state@Base 0.7.991 + mm_gdbus_modem_set_primary_port@Base 0.7.991 + mm_gdbus_modem_set_primary_sim_slot@Base 1.16.6 + mm_gdbus_modem_set_revision@Base 0.7.991 + mm_gdbus_modem_set_signal_quality@Base 0.7.991 + mm_gdbus_modem_set_sim@Base 0.7.991 + mm_gdbus_modem_set_sim_slots@Base 1.16.6 + mm_gdbus_modem_set_state@Base 0.7.991 + mm_gdbus_modem_set_state_failed_reason@Base 0.7.991 + mm_gdbus_modem_set_supported_bands@Base 0.7.991 + mm_gdbus_modem_set_supported_capabilities@Base 0.7.991 + mm_gdbus_modem_set_supported_ip_families@Base 0.7.991 + mm_gdbus_modem_set_supported_modes@Base 0.7.991 + mm_gdbus_modem_set_unlock_required@Base 0.7.991 + mm_gdbus_modem_set_unlock_retries@Base 0.7.991 + mm_gdbus_modem_signal_call_setup@Base 1.2.0 + mm_gdbus_modem_signal_call_setup_finish@Base 1.2.0 + mm_gdbus_modem_signal_call_setup_sync@Base 1.2.0 + mm_gdbus_modem_signal_call_setup_thresholds@Base 1.20.0 + mm_gdbus_modem_signal_call_setup_thresholds_finish@Base 1.20.0 + mm_gdbus_modem_signal_call_setup_thresholds_sync@Base 1.20.0 + mm_gdbus_modem_signal_complete_setup@Base 1.2.0 + mm_gdbus_modem_signal_complete_setup_thresholds@Base 1.20.0 + mm_gdbus_modem_signal_dup_cdma@Base 1.2.0 + mm_gdbus_modem_signal_dup_evdo@Base 1.2.0 + mm_gdbus_modem_signal_dup_gsm@Base 1.2.0 + mm_gdbus_modem_signal_dup_lte@Base 1.2.0 + mm_gdbus_modem_signal_dup_nr5g@Base 1.16.6 + mm_gdbus_modem_signal_dup_umts@Base 1.2.0 + mm_gdbus_modem_signal_get_cdma@Base 1.2.0 + mm_gdbus_modem_signal_get_error_rate_threshold@Base 1.20.0 + mm_gdbus_modem_signal_get_evdo@Base 1.2.0 + mm_gdbus_modem_signal_get_gsm@Base 1.2.0 + mm_gdbus_modem_signal_get_lte@Base 1.2.0 + mm_gdbus_modem_signal_get_nr5g@Base 1.16.6 + mm_gdbus_modem_signal_get_rate@Base 1.2.0 + mm_gdbus_modem_signal_get_rssi_threshold@Base 1.20.0 + mm_gdbus_modem_signal_get_type@Base 1.2.0 + mm_gdbus_modem_signal_get_umts@Base 1.2.0 + mm_gdbus_modem_signal_interface_info@Base 1.2.0 + mm_gdbus_modem_signal_override_properties@Base 1.2.0 + mm_gdbus_modem_signal_proxy_get_type@Base 1.2.0 + mm_gdbus_modem_signal_proxy_new@Base 1.2.0 + mm_gdbus_modem_signal_proxy_new_finish@Base 1.2.0 + mm_gdbus_modem_signal_proxy_new_for_bus@Base 1.2.0 + mm_gdbus_modem_signal_proxy_new_for_bus_finish@Base 1.2.0 + mm_gdbus_modem_signal_proxy_new_for_bus_sync@Base 1.2.0 + mm_gdbus_modem_signal_proxy_new_sync@Base 1.2.0 + mm_gdbus_modem_signal_set_cdma@Base 1.2.0 + mm_gdbus_modem_signal_set_error_rate_threshold@Base 1.20.0 + mm_gdbus_modem_signal_set_evdo@Base 1.2.0 + mm_gdbus_modem_signal_set_gsm@Base 1.2.0 + mm_gdbus_modem_signal_set_lte@Base 1.2.0 + mm_gdbus_modem_signal_set_nr5g@Base 1.16.6 + mm_gdbus_modem_signal_set_rate@Base 1.2.0 + mm_gdbus_modem_signal_set_rssi_threshold@Base 1.20.0 + mm_gdbus_modem_signal_set_umts@Base 1.2.0 + mm_gdbus_modem_signal_skeleton_get_type@Base 1.2.0 + mm_gdbus_modem_signal_skeleton_new@Base 1.2.0 + mm_gdbus_modem_simple_call_connect@Base 0.7.991 + mm_gdbus_modem_simple_call_connect_finish@Base 0.7.991 + mm_gdbus_modem_simple_call_connect_sync@Base 0.7.991 + mm_gdbus_modem_simple_call_disconnect@Base 0.7.991 + mm_gdbus_modem_simple_call_disconnect_finish@Base 0.7.991 + mm_gdbus_modem_simple_call_disconnect_sync@Base 0.7.991 + mm_gdbus_modem_simple_call_get_status@Base 0.7.991 + mm_gdbus_modem_simple_call_get_status_finish@Base 0.7.991 + mm_gdbus_modem_simple_call_get_status_sync@Base 0.7.991 + mm_gdbus_modem_simple_complete_connect@Base 0.7.991 + mm_gdbus_modem_simple_complete_disconnect@Base 0.7.991 + mm_gdbus_modem_simple_complete_get_status@Base 0.7.991 + mm_gdbus_modem_simple_get_type@Base 0.7.991 + mm_gdbus_modem_simple_interface_info@Base 0.7.991 + mm_gdbus_modem_simple_override_properties@Base 0.7.991 + mm_gdbus_modem_simple_proxy_get_type@Base 0.7.991 + mm_gdbus_modem_simple_proxy_new@Base 0.7.991 + mm_gdbus_modem_simple_proxy_new_finish@Base 0.7.991 + mm_gdbus_modem_simple_proxy_new_for_bus@Base 0.7.991 + mm_gdbus_modem_simple_proxy_new_for_bus_finish@Base 0.7.991 + mm_gdbus_modem_simple_proxy_new_for_bus_sync@Base 0.7.991 + mm_gdbus_modem_simple_proxy_new_sync@Base 0.7.991 + mm_gdbus_modem_simple_skeleton_get_type@Base 0.7.991 + mm_gdbus_modem_simple_skeleton_new@Base 0.7.991 + mm_gdbus_modem_skeleton_get_type@Base 0.7.991 + mm_gdbus_modem_skeleton_new@Base 0.7.991 + mm_gdbus_modem_time_call_get_network_time@Base 0.7.991 + mm_gdbus_modem_time_call_get_network_time_finish@Base 0.7.991 + mm_gdbus_modem_time_call_get_network_time_sync@Base 0.7.991 + mm_gdbus_modem_time_complete_get_network_time@Base 0.7.991 + mm_gdbus_modem_time_dup_network_timezone@Base 0.7.991 + mm_gdbus_modem_time_emit_network_time_changed@Base 0.7.991 + mm_gdbus_modem_time_get_network_timezone@Base 0.7.991 + mm_gdbus_modem_time_get_type@Base 0.7.991 + mm_gdbus_modem_time_interface_info@Base 0.7.991 + mm_gdbus_modem_time_override_properties@Base 0.7.991 + mm_gdbus_modem_time_proxy_get_type@Base 0.7.991 + mm_gdbus_modem_time_proxy_new@Base 0.7.991 + mm_gdbus_modem_time_proxy_new_finish@Base 0.7.991 + mm_gdbus_modem_time_proxy_new_for_bus@Base 0.7.991 + mm_gdbus_modem_time_proxy_new_for_bus_finish@Base 0.7.991 + mm_gdbus_modem_time_proxy_new_for_bus_sync@Base 0.7.991 + mm_gdbus_modem_time_proxy_new_sync@Base 0.7.991 + mm_gdbus_modem_time_set_network_timezone@Base 0.7.991 + mm_gdbus_modem_time_skeleton_get_type@Base 0.7.991 + mm_gdbus_modem_time_skeleton_new@Base 0.7.991 + mm_gdbus_modem_voice_call_call_waiting_query@Base 1.12.6 + mm_gdbus_modem_voice_call_call_waiting_query_finish@Base 1.12.6 + mm_gdbus_modem_voice_call_call_waiting_query_sync@Base 1.12.6 + mm_gdbus_modem_voice_call_call_waiting_setup@Base 1.12.6 + mm_gdbus_modem_voice_call_call_waiting_setup_finish@Base 1.12.6 + mm_gdbus_modem_voice_call_call_waiting_setup_sync@Base 1.12.6 + mm_gdbus_modem_voice_call_create_call@Base 1.5.993 + mm_gdbus_modem_voice_call_create_call_finish@Base 1.5.993 + mm_gdbus_modem_voice_call_create_call_sync@Base 1.5.993 + mm_gdbus_modem_voice_call_delete_call@Base 1.5.993 + mm_gdbus_modem_voice_call_delete_call_finish@Base 1.5.993 + mm_gdbus_modem_voice_call_delete_call_sync@Base 1.5.993 + mm_gdbus_modem_voice_call_hangup_all@Base 1.12.6 + mm_gdbus_modem_voice_call_hangup_all_finish@Base 1.12.6 + mm_gdbus_modem_voice_call_hangup_all_sync@Base 1.12.6 + mm_gdbus_modem_voice_call_hangup_and_accept@Base 1.12.6 + mm_gdbus_modem_voice_call_hangup_and_accept_finish@Base 1.12.6 + mm_gdbus_modem_voice_call_hangup_and_accept_sync@Base 1.12.6 + mm_gdbus_modem_voice_call_hold_and_accept@Base 1.12.6 + mm_gdbus_modem_voice_call_hold_and_accept_finish@Base 1.12.6 + mm_gdbus_modem_voice_call_hold_and_accept_sync@Base 1.12.6 + mm_gdbus_modem_voice_call_list_calls@Base 1.5.993 + mm_gdbus_modem_voice_call_list_calls_finish@Base 1.5.993 + mm_gdbus_modem_voice_call_list_calls_sync@Base 1.5.993 + mm_gdbus_modem_voice_call_transfer@Base 1.12.6 + mm_gdbus_modem_voice_call_transfer_finish@Base 1.12.6 + mm_gdbus_modem_voice_call_transfer_sync@Base 1.12.6 + mm_gdbus_modem_voice_complete_call_waiting_query@Base 1.12.6 + mm_gdbus_modem_voice_complete_call_waiting_setup@Base 1.12.6 + mm_gdbus_modem_voice_complete_create_call@Base 1.5.993 + mm_gdbus_modem_voice_complete_delete_call@Base 1.5.993 + mm_gdbus_modem_voice_complete_hangup_all@Base 1.12.6 + mm_gdbus_modem_voice_complete_hangup_and_accept@Base 1.12.6 + mm_gdbus_modem_voice_complete_hold_and_accept@Base 1.12.6 + mm_gdbus_modem_voice_complete_list_calls@Base 1.5.993 + mm_gdbus_modem_voice_complete_transfer@Base 1.12.6 + mm_gdbus_modem_voice_dup_calls@Base 1.5.993 + mm_gdbus_modem_voice_emit_call_added@Base 1.5.993 + mm_gdbus_modem_voice_emit_call_deleted@Base 1.5.993 + mm_gdbus_modem_voice_get_calls@Base 1.5.993 + mm_gdbus_modem_voice_get_emergency_only@Base 1.12.6 + mm_gdbus_modem_voice_get_type@Base 1.5.993 + mm_gdbus_modem_voice_interface_info@Base 1.5.993 + mm_gdbus_modem_voice_override_properties@Base 1.5.993 + mm_gdbus_modem_voice_proxy_get_type@Base 1.5.993 + mm_gdbus_modem_voice_proxy_new@Base 1.5.993 + mm_gdbus_modem_voice_proxy_new_finish@Base 1.5.993 + mm_gdbus_modem_voice_proxy_new_for_bus@Base 1.5.993 + mm_gdbus_modem_voice_proxy_new_for_bus_finish@Base 1.5.993 + mm_gdbus_modem_voice_proxy_new_for_bus_sync@Base 1.5.993 + mm_gdbus_modem_voice_proxy_new_sync@Base 1.5.993 + mm_gdbus_modem_voice_set_calls@Base 1.5.993 + mm_gdbus_modem_voice_set_emergency_only@Base 1.12.6 + mm_gdbus_modem_voice_skeleton_get_type@Base 1.5.993 + mm_gdbus_modem_voice_skeleton_new@Base 1.5.993 + mm_gdbus_object_get_modem3gpp@Base 0.7.991 + mm_gdbus_object_get_modem3gpp_profile_manager@Base 1.18.2 + mm_gdbus_object_get_modem3gpp_ussd@Base 0.7.991 + mm_gdbus_object_get_modem@Base 0.7.991 + mm_gdbus_object_get_modem_cdma@Base 0.7.991 + mm_gdbus_object_get_modem_cell_broadcast@Base 1.23.95 + mm_gdbus_object_get_modem_firmware@Base 0.7.991 + mm_gdbus_object_get_modem_location@Base 0.7.991 + mm_gdbus_object_get_modem_messaging@Base 0.7.991 + mm_gdbus_object_get_modem_oma@Base 1.2.0 + mm_gdbus_object_get_modem_sar@Base 1.20.0 + mm_gdbus_object_get_modem_signal@Base 1.2.0 + mm_gdbus_object_get_modem_simple@Base 0.7.991 + mm_gdbus_object_get_modem_time@Base 0.7.991 + mm_gdbus_object_get_modem_voice@Base 1.5.993 + mm_gdbus_object_get_type@Base 0.7.991 + mm_gdbus_object_manager_client_get_proxy_type@Base 0.7.991 + mm_gdbus_object_manager_client_get_type@Base 0.7.991 + mm_gdbus_object_manager_client_new@Base 0.7.991 + mm_gdbus_object_manager_client_new_finish@Base 0.7.991 + mm_gdbus_object_manager_client_new_for_bus@Base 0.7.991 + mm_gdbus_object_manager_client_new_for_bus_finish@Base 0.7.991 + mm_gdbus_object_manager_client_new_for_bus_sync@Base 0.7.991 + mm_gdbus_object_manager_client_new_sync@Base 0.7.991 + mm_gdbus_object_peek_modem3gpp@Base 0.7.991 + mm_gdbus_object_peek_modem3gpp_profile_manager@Base 1.18.2 + mm_gdbus_object_peek_modem3gpp_ussd@Base 0.7.991 + mm_gdbus_object_peek_modem@Base 0.7.991 + mm_gdbus_object_peek_modem_cdma@Base 0.7.991 + mm_gdbus_object_peek_modem_cell_broadcast@Base 1.23.95 + mm_gdbus_object_peek_modem_firmware@Base 0.7.991 + mm_gdbus_object_peek_modem_location@Base 0.7.991 + mm_gdbus_object_peek_modem_messaging@Base 0.7.991 + mm_gdbus_object_peek_modem_oma@Base 1.2.0 + mm_gdbus_object_peek_modem_sar@Base 1.20.0 + mm_gdbus_object_peek_modem_signal@Base 1.2.0 + mm_gdbus_object_peek_modem_simple@Base 0.7.991 + mm_gdbus_object_peek_modem_time@Base 0.7.991 + mm_gdbus_object_peek_modem_voice@Base 1.5.993 + mm_gdbus_object_proxy_get_type@Base 0.7.991 + mm_gdbus_object_proxy_new@Base 0.7.991 + mm_gdbus_object_skeleton_get_type@Base 0.7.991 + mm_gdbus_object_skeleton_new@Base 0.7.991 + mm_gdbus_object_skeleton_set_modem3gpp@Base 0.7.991 + mm_gdbus_object_skeleton_set_modem3gpp_profile_manager@Base 1.18.2 + mm_gdbus_object_skeleton_set_modem3gpp_ussd@Base 0.7.991 + mm_gdbus_object_skeleton_set_modem@Base 0.7.991 + mm_gdbus_object_skeleton_set_modem_cdma@Base 0.7.991 + mm_gdbus_object_skeleton_set_modem_cell_broadcast@Base 1.23.95 + mm_gdbus_object_skeleton_set_modem_firmware@Base 0.7.991 + mm_gdbus_object_skeleton_set_modem_location@Base 0.7.991 + mm_gdbus_object_skeleton_set_modem_messaging@Base 0.7.991 + mm_gdbus_object_skeleton_set_modem_oma@Base 1.2.0 + mm_gdbus_object_skeleton_set_modem_sar@Base 1.20.0 + mm_gdbus_object_skeleton_set_modem_signal@Base 1.2.0 + mm_gdbus_object_skeleton_set_modem_simple@Base 0.7.991 + mm_gdbus_object_skeleton_set_modem_time@Base 0.7.991 + mm_gdbus_object_skeleton_set_modem_voice@Base 1.5.993 + mm_gdbus_org_freedesktop_modem_manager1_call_inhibit_device@Base 1.10.0 + mm_gdbus_org_freedesktop_modem_manager1_call_inhibit_device_finish@Base 1.10.0 + mm_gdbus_org_freedesktop_modem_manager1_call_inhibit_device_sync@Base 1.10.0 + mm_gdbus_org_freedesktop_modem_manager1_call_report_kernel_event@Base 1.7.990 + mm_gdbus_org_freedesktop_modem_manager1_call_report_kernel_event_finish@Base 1.7.990 + mm_gdbus_org_freedesktop_modem_manager1_call_report_kernel_event_sync@Base 1.7.990 + mm_gdbus_org_freedesktop_modem_manager1_call_scan_devices@Base 0.7.991 + mm_gdbus_org_freedesktop_modem_manager1_call_scan_devices_finish@Base 0.7.991 + mm_gdbus_org_freedesktop_modem_manager1_call_scan_devices_sync@Base 0.7.991 + mm_gdbus_org_freedesktop_modem_manager1_call_set_logging@Base 0.7.991 + mm_gdbus_org_freedesktop_modem_manager1_call_set_logging_finish@Base 0.7.991 + mm_gdbus_org_freedesktop_modem_manager1_call_set_logging_sync@Base 0.7.991 + mm_gdbus_org_freedesktop_modem_manager1_complete_inhibit_device@Base 1.10.0 + mm_gdbus_org_freedesktop_modem_manager1_complete_report_kernel_event@Base 1.7.990 + mm_gdbus_org_freedesktop_modem_manager1_complete_scan_devices@Base 0.7.991 + mm_gdbus_org_freedesktop_modem_manager1_complete_set_logging@Base 0.7.991 + mm_gdbus_org_freedesktop_modem_manager1_dup_version@Base 1.10.0 + mm_gdbus_org_freedesktop_modem_manager1_get_type@Base 0.7.991 + mm_gdbus_org_freedesktop_modem_manager1_get_version@Base 1.10.0 + mm_gdbus_org_freedesktop_modem_manager1_interface_info@Base 0.7.991 + mm_gdbus_org_freedesktop_modem_manager1_override_properties@Base 0.7.991 + mm_gdbus_org_freedesktop_modem_manager1_proxy_get_type@Base 0.7.991 + mm_gdbus_org_freedesktop_modem_manager1_proxy_new@Base 0.7.991 + mm_gdbus_org_freedesktop_modem_manager1_proxy_new_finish@Base 0.7.991 + mm_gdbus_org_freedesktop_modem_manager1_proxy_new_for_bus@Base 0.7.991 + mm_gdbus_org_freedesktop_modem_manager1_proxy_new_for_bus_finish@Base 0.7.991 + mm_gdbus_org_freedesktop_modem_manager1_proxy_new_for_bus_sync@Base 0.7.991 + mm_gdbus_org_freedesktop_modem_manager1_proxy_new_sync@Base 0.7.991 + mm_gdbus_org_freedesktop_modem_manager1_set_version@Base 1.10.0 + mm_gdbus_org_freedesktop_modem_manager1_skeleton_get_type@Base 0.7.991 + mm_gdbus_org_freedesktop_modem_manager1_skeleton_new@Base 0.7.991 + mm_gdbus_sim_call_change_pin@Base 0.7.991 + mm_gdbus_sim_call_change_pin_finish@Base 0.7.991 + mm_gdbus_sim_call_change_pin_sync@Base 0.7.991 + mm_gdbus_sim_call_enable_pin@Base 0.7.991 + mm_gdbus_sim_call_enable_pin_finish@Base 0.7.991 + mm_gdbus_sim_call_enable_pin_sync@Base 0.7.991 + mm_gdbus_sim_call_send_pin@Base 0.7.991 + mm_gdbus_sim_call_send_pin_finish@Base 0.7.991 + mm_gdbus_sim_call_send_pin_sync@Base 0.7.991 + mm_gdbus_sim_call_send_puk@Base 0.7.991 + mm_gdbus_sim_call_send_puk_finish@Base 0.7.991 + mm_gdbus_sim_call_send_puk_sync@Base 0.7.991 + mm_gdbus_sim_call_set_preferred_networks@Base 1.18.2 + mm_gdbus_sim_call_set_preferred_networks_finish@Base 1.18.2 + mm_gdbus_sim_call_set_preferred_networks_sync@Base 1.18.2 + mm_gdbus_sim_complete_change_pin@Base 0.7.991 + mm_gdbus_sim_complete_enable_pin@Base 0.7.991 + mm_gdbus_sim_complete_send_pin@Base 0.7.991 + mm_gdbus_sim_complete_send_puk@Base 0.7.991 + mm_gdbus_sim_complete_set_preferred_networks@Base 1.18.2 + mm_gdbus_sim_dup_eid@Base 1.16.6 + mm_gdbus_sim_dup_emergency_numbers@Base 1.12.6 + mm_gdbus_sim_dup_gid1@Base 1.20.0 + mm_gdbus_sim_dup_gid2@Base 1.20.0 + mm_gdbus_sim_dup_imsi@Base 0.7.991 + mm_gdbus_sim_dup_operator_identifier@Base 0.7.991 + mm_gdbus_sim_dup_operator_name@Base 0.7.991 + mm_gdbus_sim_dup_preferred_networks@Base 1.18.2 + mm_gdbus_sim_dup_sim_identifier@Base 0.7.991 + mm_gdbus_sim_get_active@Base 1.16.6 + mm_gdbus_sim_get_eid@Base 1.16.6 + mm_gdbus_sim_get_emergency_numbers@Base 1.12.6 + mm_gdbus_sim_get_esim_status@Base 1.20.0 + mm_gdbus_sim_get_gid1@Base 1.20.0 + mm_gdbus_sim_get_gid2@Base 1.20.0 + mm_gdbus_sim_get_imsi@Base 0.7.991 + mm_gdbus_sim_get_operator_identifier@Base 0.7.991 + mm_gdbus_sim_get_operator_name@Base 0.7.991 + mm_gdbus_sim_get_preferred_networks@Base 1.18.2 + mm_gdbus_sim_get_removability@Base 1.20.0 + mm_gdbus_sim_get_sim_identifier@Base 0.7.991 + mm_gdbus_sim_get_sim_type@Base 1.20.0 + mm_gdbus_sim_get_type@Base 0.7.991 + mm_gdbus_sim_interface_info@Base 0.7.991 + mm_gdbus_sim_override_properties@Base 0.7.991 + mm_gdbus_sim_proxy_get_type@Base 0.7.991 + mm_gdbus_sim_proxy_new@Base 0.7.991 + mm_gdbus_sim_proxy_new_finish@Base 0.7.991 + mm_gdbus_sim_proxy_new_for_bus@Base 0.7.991 + mm_gdbus_sim_proxy_new_for_bus_finish@Base 0.7.991 + mm_gdbus_sim_proxy_new_for_bus_sync@Base 0.7.991 + mm_gdbus_sim_proxy_new_sync@Base 0.7.991 + mm_gdbus_sim_set_active@Base 1.16.6 + mm_gdbus_sim_set_eid@Base 1.16.6 + mm_gdbus_sim_set_emergency_numbers@Base 1.12.6 + mm_gdbus_sim_set_esim_status@Base 1.20.0 + mm_gdbus_sim_set_gid1@Base 1.20.0 + mm_gdbus_sim_set_gid2@Base 1.20.0 + mm_gdbus_sim_set_imsi@Base 0.7.991 + mm_gdbus_sim_set_operator_identifier@Base 0.7.991 + mm_gdbus_sim_set_operator_name@Base 0.7.991 + mm_gdbus_sim_set_preferred_networks@Base 1.18.2 + mm_gdbus_sim_set_removability@Base 1.20.0 + mm_gdbus_sim_set_sim_identifier@Base 0.7.991 + mm_gdbus_sim_set_sim_type@Base 1.20.0 + mm_gdbus_sim_skeleton_get_type@Base 0.7.991 + mm_gdbus_sim_skeleton_new@Base 0.7.991 + mm_gdbus_sms_call_send@Base 0.7.991 + mm_gdbus_sms_call_send_finish@Base 0.7.991 + mm_gdbus_sms_call_send_sync@Base 0.7.991 + mm_gdbus_sms_call_store@Base 0.7.991 + mm_gdbus_sms_call_store_finish@Base 0.7.991 + mm_gdbus_sms_call_store_sync@Base 0.7.991 + mm_gdbus_sms_complete_send@Base 0.7.991 + mm_gdbus_sms_complete_store@Base 0.7.991 + mm_gdbus_sms_dup_data@Base 0.7.991 + mm_gdbus_sms_dup_discharge_timestamp@Base 0.7.991 + mm_gdbus_sms_dup_number@Base 0.7.991 + mm_gdbus_sms_dup_smsc@Base 0.7.991 + mm_gdbus_sms_dup_text@Base 0.7.991 + mm_gdbus_sms_dup_timestamp@Base 0.7.991 + mm_gdbus_sms_dup_validity@Base 0.7.991 + mm_gdbus_sms_get_class@Base 0.7.991 + mm_gdbus_sms_get_data@Base 0.7.991 + mm_gdbus_sms_get_delivery_report_request@Base 0.7.991 + mm_gdbus_sms_get_delivery_state@Base 0.7.991 + mm_gdbus_sms_get_discharge_timestamp@Base 0.7.991 + mm_gdbus_sms_get_message_reference@Base 0.7.991 + mm_gdbus_sms_get_number@Base 0.7.991 + mm_gdbus_sms_get_pdu_type@Base 0.7.991 + mm_gdbus_sms_get_service_category@Base 1.2.0 + mm_gdbus_sms_get_smsc@Base 0.7.991 + mm_gdbus_sms_get_state@Base 0.7.991 + mm_gdbus_sms_get_storage@Base 0.7.991 + mm_gdbus_sms_get_teleservice_id@Base 1.2.0 + mm_gdbus_sms_get_text@Base 0.7.991 + mm_gdbus_sms_get_timestamp@Base 0.7.991 + mm_gdbus_sms_get_type@Base 0.7.991 + mm_gdbus_sms_get_validity@Base 0.7.991 + mm_gdbus_sms_interface_info@Base 0.7.991 + mm_gdbus_sms_override_properties@Base 0.7.991 + mm_gdbus_sms_proxy_get_type@Base 0.7.991 + mm_gdbus_sms_proxy_new@Base 0.7.991 + mm_gdbus_sms_proxy_new_finish@Base 0.7.991 + mm_gdbus_sms_proxy_new_for_bus@Base 0.7.991 + mm_gdbus_sms_proxy_new_for_bus_finish@Base 0.7.991 + mm_gdbus_sms_proxy_new_for_bus_sync@Base 0.7.991 + mm_gdbus_sms_proxy_new_sync@Base 0.7.991 + mm_gdbus_sms_set_class@Base 0.7.991 + mm_gdbus_sms_set_data@Base 0.7.991 + mm_gdbus_sms_set_delivery_report_request@Base 0.7.991 + mm_gdbus_sms_set_delivery_state@Base 0.7.991 + mm_gdbus_sms_set_discharge_timestamp@Base 0.7.991 + mm_gdbus_sms_set_message_reference@Base 0.7.991 + mm_gdbus_sms_set_number@Base 0.7.991 + mm_gdbus_sms_set_pdu_type@Base 0.7.991 + mm_gdbus_sms_set_service_category@Base 1.2.0 + mm_gdbus_sms_set_smsc@Base 0.7.991 + mm_gdbus_sms_set_state@Base 0.7.991 + mm_gdbus_sms_set_storage@Base 0.7.991 + mm_gdbus_sms_set_teleservice_id@Base 1.2.0 + mm_gdbus_sms_set_text@Base 0.7.991 + mm_gdbus_sms_set_timestamp@Base 0.7.991 + mm_gdbus_sms_set_validity@Base 0.7.991 + mm_gdbus_sms_skeleton_get_type@Base 0.7.991 + mm_gdbus_sms_skeleton_new@Base 0.7.991 + mm_get_double_from_match_info@Base 0.7.991 + mm_get_double_from_str@Base 0.7.991 + mm_get_int_from_match_info@Base 0.7.991 + mm_get_int_from_str@Base 0.7.991 + mm_get_string_unquoted_from_match_info@Base 0.7.991 + mm_get_u64_from_hex_match_info@Base 1.13.900 + mm_get_u64_from_hex_str@Base 1.12.6 + mm_get_u64_from_match_info@Base 1.12.6 + mm_get_u64_from_str@Base 1.12.6 + mm_get_uint_from_hex_match_info@Base 1.13.900 + mm_get_uint_from_hex_str@Base 1.7.990 + mm_get_uint_from_match_info@Base 0.7.991 + mm_get_uint_from_str@Base 0.7.991 + mm_is_string_mccmnc@Base 1.18.2 + mm_kernel_event_properties_get_action@Base 1.7.990 + mm_kernel_event_properties_get_dictionary@Base 1.7.990 + mm_kernel_event_properties_get_name@Base 1.7.990 + mm_kernel_event_properties_get_subsystem@Base 1.7.990 + mm_kernel_event_properties_get_type@Base 1.7.990 + mm_kernel_event_properties_get_uid@Base 1.7.990 + mm_kernel_event_properties_new@Base 1.7.990 + mm_kernel_event_properties_new_from_dictionary@Base 1.7.990 + mm_kernel_event_properties_new_from_string@Base 1.7.990 + mm_kernel_event_properties_set_action@Base 1.7.990 + mm_kernel_event_properties_set_name@Base 1.7.990 + mm_kernel_event_properties_set_subsystem@Base 1.7.990 + mm_kernel_event_properties_set_uid@Base 1.7.990 + mm_location_3gpp_get_cell_id@Base 0.7.991 + mm_location_3gpp_get_location_area_code@Base 0.7.991 + mm_location_3gpp_get_mobile_country_code@Base 0.7.991 + mm_location_3gpp_get_mobile_network_code@Base 0.7.991 + mm_location_3gpp_get_operator_code@Base 1.18.2 + mm_location_3gpp_get_string_variant@Base 0.7.991 + mm_location_3gpp_get_tracking_area_code@Base 1.10.0 + mm_location_3gpp_get_type@Base 0.7.991 + mm_location_3gpp_new@Base 0.7.991 + mm_location_3gpp_new_from_string_variant@Base 0.7.991 + mm_location_3gpp_reset@Base 1.10.0 + mm_location_3gpp_set_cell_id@Base 0.7.991 + mm_location_3gpp_set_location_area_code@Base 0.7.991 + mm_location_3gpp_set_operator_code@Base 1.18.2 + mm_location_3gpp_set_tracking_area_code@Base 1.10.0 + mm_location_cdma_bs_get_dictionary@Base 0.7.991 + mm_location_cdma_bs_get_latitude@Base 0.7.991 + mm_location_cdma_bs_get_longitude@Base 0.7.991 + mm_location_cdma_bs_get_type@Base 0.7.991 + mm_location_cdma_bs_new@Base 0.7.991 + mm_location_cdma_bs_new_from_dictionary@Base 0.7.991 + mm_location_cdma_bs_set@Base 0.7.991 + mm_location_gps_nmea_add_trace@Base 0.7.991 + mm_location_gps_nmea_build_full@Base 0.7.991 + mm_location_gps_nmea_get_string_variant@Base 0.7.991 + mm_location_gps_nmea_get_trace@Base 0.7.991 + mm_location_gps_nmea_get_traces@Base 1.13.900 + mm_location_gps_nmea_get_type@Base 0.7.991 + mm_location_gps_nmea_new@Base 0.7.991 + mm_location_gps_nmea_new_from_string_variant@Base 0.7.991 + mm_location_gps_raw_add_trace@Base 0.7.991 + mm_location_gps_raw_get_altitude@Base 0.7.991 + mm_location_gps_raw_get_dictionary@Base 0.7.991 + mm_location_gps_raw_get_latitude@Base 0.7.991 + mm_location_gps_raw_get_longitude@Base 0.7.991 + mm_location_gps_raw_get_type@Base 0.7.991 + mm_location_gps_raw_get_utc_time@Base 0.7.991 + mm_location_gps_raw_new@Base 0.7.991 + mm_location_gps_raw_new_from_dictionary@Base 0.7.991 + mm_manager_get_proxy@Base 0.7.991 + mm_manager_get_type@Base 0.7.991 + mm_manager_get_version@Base 1.10.0 + mm_manager_inhibit_device@Base 1.10.0 + mm_manager_inhibit_device_finish@Base 1.10.0 + mm_manager_inhibit_device_sync@Base 1.10.0 + mm_manager_new@Base 0.7.991 + mm_manager_new_finish@Base 0.7.991 + mm_manager_new_sync@Base 0.7.991 + mm_manager_peek_proxy@Base 0.7.991 + mm_manager_report_kernel_event@Base 1.7.990 + mm_manager_report_kernel_event_finish@Base 1.7.990 + mm_manager_report_kernel_event_sync@Base 1.7.990 + mm_manager_scan_devices@Base 0.7.991 + mm_manager_scan_devices_finish@Base 0.7.991 + mm_manager_scan_devices_sync@Base 0.7.991 + mm_manager_set_logging@Base 0.7.991 + mm_manager_set_logging_finish@Base 0.7.991 + mm_manager_set_logging_sync@Base 0.7.991 + mm_manager_uninhibit_device@Base 1.10.0 + mm_manager_uninhibit_device_finish@Base 1.10.0 + mm_manager_uninhibit_device_sync@Base 1.10.0 + mm_message_error_get_type@Base 0.7.991 + mm_message_error_quark@Base 0.7.991 + mm_mobile_equipment_error_get_type@Base 0.7.991 + mm_mobile_equipment_error_quark@Base 0.7.991 + mm_modem_3gpp_disable_facility_lock@Base 1.18.2 + mm_modem_3gpp_disable_facility_lock_finish@Base 1.18.2 + mm_modem_3gpp_disable_facility_lock_sync@Base 1.18.2 + mm_modem_3gpp_drx_cycle_get_string@Base 1.20.0 + mm_modem_3gpp_drx_cycle_get_type@Base 1.20.0 + mm_modem_3gpp_dup_imei@Base 0.7.991 + mm_modem_3gpp_dup_initial_eps_bearer_path@Base 1.10.0 + mm_modem_3gpp_dup_operator_code@Base 0.7.991 + mm_modem_3gpp_dup_operator_name@Base 0.7.991 + mm_modem_3gpp_dup_path@Base 0.7.991 + mm_modem_3gpp_eps_ue_mode_operation_get_string@Base 1.7.990 + mm_modem_3gpp_eps_ue_mode_operation_get_type@Base 1.7.990 + mm_modem_3gpp_facility_build_string_from_mask@Base 0.7.991 + mm_modem_3gpp_facility_get_type@Base 0.7.991 + mm_modem_3gpp_get_enabled_facility_locks@Base 0.7.991 + mm_modem_3gpp_get_eps_ue_mode_operation@Base 1.7.990 + mm_modem_3gpp_get_imei@Base 0.7.991 + mm_modem_3gpp_get_initial_eps_bearer@Base 1.10.0 + mm_modem_3gpp_get_initial_eps_bearer_finish@Base 1.10.0 + mm_modem_3gpp_get_initial_eps_bearer_path@Base 1.10.0 + mm_modem_3gpp_get_initial_eps_bearer_settings@Base 1.10.0 + mm_modem_3gpp_get_initial_eps_bearer_sync@Base 1.10.0 + mm_modem_3gpp_get_network_rejection@Base 1.23.12 + mm_modem_3gpp_get_nr5g_registration_settings@Base 1.20.0 + mm_modem_3gpp_get_operator_code@Base 0.7.991 + mm_modem_3gpp_get_operator_name@Base 0.7.991 + mm_modem_3gpp_get_packet_service_state@Base 1.20.0 + mm_modem_3gpp_get_path@Base 0.7.991 + mm_modem_3gpp_get_pco@Base 1.10.0 + mm_modem_3gpp_get_registration_state@Base 0.7.991 + mm_modem_3gpp_get_subscription_state@Base 1.2.0 + mm_modem_3gpp_get_type@Base 0.7.991 + mm_modem_3gpp_mico_mode_get_string@Base 1.20.0 + mm_modem_3gpp_mico_mode_get_type@Base 1.20.0 + mm_modem_3gpp_network_availability_get_string@Base 0.7.991 + mm_modem_3gpp_network_availability_get_type@Base 0.7.991 + mm_modem_3gpp_network_free@Base 0.7.991 + mm_modem_3gpp_network_get_access_technology@Base 0.7.991 + mm_modem_3gpp_network_get_availability@Base 0.7.991 + mm_modem_3gpp_network_get_operator_code@Base 0.7.991 + mm_modem_3gpp_network_get_operator_long@Base 0.7.991 + mm_modem_3gpp_network_get_operator_short@Base 0.7.991 + mm_modem_3gpp_network_get_type@Base 1.12.6 + mm_modem_3gpp_packet_service_state_get_string@Base 1.20.0 + mm_modem_3gpp_packet_service_state_get_type@Base 1.20.0 + mm_modem_3gpp_peek_initial_eps_bearer_settings@Base 1.10.0 + mm_modem_3gpp_peek_network_rejection@Base 1.23.12 + mm_modem_3gpp_peek_nr5g_registration_settings@Base 1.20.0 + mm_modem_3gpp_profile_manager_delete@Base 1.18.2 + mm_modem_3gpp_profile_manager_delete_finish@Base 1.18.2 + mm_modem_3gpp_profile_manager_delete_sync@Base 1.18.2 + mm_modem_3gpp_profile_manager_dup_index_field@Base 1.20.0 + mm_modem_3gpp_profile_manager_dup_path@Base 1.18.2 + mm_modem_3gpp_profile_manager_get_index_field@Base 1.20.0 + mm_modem_3gpp_profile_manager_get_path@Base 1.18.2 + mm_modem_3gpp_profile_manager_get_type@Base 1.18.2 + mm_modem_3gpp_profile_manager_list@Base 1.18.2 + mm_modem_3gpp_profile_manager_list_finish@Base 1.18.2 + mm_modem_3gpp_profile_manager_list_sync@Base 1.18.2 + mm_modem_3gpp_profile_manager_set@Base 1.18.2 + mm_modem_3gpp_profile_manager_set_finish@Base 1.18.2 + mm_modem_3gpp_profile_manager_set_sync@Base 1.18.2 + mm_modem_3gpp_register@Base 0.7.991 + mm_modem_3gpp_register_finish@Base 0.7.991 + mm_modem_3gpp_register_sync@Base 0.7.991 + mm_modem_3gpp_registration_state_get_string@Base 0.7.991 + mm_modem_3gpp_registration_state_get_type@Base 0.7.991 + mm_modem_3gpp_scan@Base 0.7.991 + mm_modem_3gpp_scan_finish@Base 0.7.991 + mm_modem_3gpp_scan_sync@Base 0.7.991 + mm_modem_3gpp_set_carrier_lock@Base 1.22.0 + mm_modem_3gpp_set_carrier_lock_finish@Base 1.22.0 + mm_modem_3gpp_set_carrier_lock_sync@Base 1.22.0 + mm_modem_3gpp_set_eps_ue_mode_operation@Base 1.7.990 + mm_modem_3gpp_set_eps_ue_mode_operation_finish@Base 1.7.990 + mm_modem_3gpp_set_eps_ue_mode_operation_sync@Base 1.7.990 + mm_modem_3gpp_set_initial_eps_bearer_settings@Base 1.10.0 + mm_modem_3gpp_set_initial_eps_bearer_settings_finish@Base 1.10.0 + mm_modem_3gpp_set_initial_eps_bearer_settings_sync@Base 1.10.0 + mm_modem_3gpp_set_nr5g_registration_settings@Base 1.20.0 + mm_modem_3gpp_set_nr5g_registration_settings_finish@Base 1.20.0 + mm_modem_3gpp_set_nr5g_registration_settings_sync@Base 1.20.0 + mm_modem_3gpp_set_packet_service_state@Base 1.20.0 + mm_modem_3gpp_set_packet_service_state_finish@Base 1.20.0 + mm_modem_3gpp_set_packet_service_state_sync@Base 1.20.0 + mm_modem_3gpp_subscription_state_get_string@Base 1.2.0 + mm_modem_3gpp_subscription_state_get_type@Base 1.2.0 + mm_modem_3gpp_ussd_cancel@Base 0.7.991 + mm_modem_3gpp_ussd_cancel_finish@Base 0.7.991 + mm_modem_3gpp_ussd_cancel_sync@Base 0.7.991 + mm_modem_3gpp_ussd_dup_network_notification@Base 0.7.991 + mm_modem_3gpp_ussd_dup_network_request@Base 0.7.991 + mm_modem_3gpp_ussd_dup_path@Base 0.7.991 + mm_modem_3gpp_ussd_get_network_notification@Base 0.7.991 + mm_modem_3gpp_ussd_get_network_request@Base 0.7.991 + mm_modem_3gpp_ussd_get_path@Base 0.7.991 + mm_modem_3gpp_ussd_get_state@Base 0.7.991 + mm_modem_3gpp_ussd_get_type@Base 0.7.991 + mm_modem_3gpp_ussd_initiate@Base 0.7.991 + mm_modem_3gpp_ussd_initiate_finish@Base 0.7.991 + mm_modem_3gpp_ussd_initiate_sync@Base 0.7.991 + mm_modem_3gpp_ussd_respond@Base 0.7.991 + mm_modem_3gpp_ussd_respond_finish@Base 0.7.991 + mm_modem_3gpp_ussd_respond_sync@Base 0.7.991 + mm_modem_3gpp_ussd_session_state_get_string@Base 0.7.991 + mm_modem_3gpp_ussd_session_state_get_type@Base 0.7.991 + mm_modem_access_technology_build_string_from_mask@Base 0.7.991 + mm_modem_access_technology_get_type@Base 0.7.991 + mm_modem_band_get_string@Base 0.7.991 + mm_modem_band_get_type@Base 0.7.991 + mm_modem_capability_build_string_from_mask@Base 0.7.991 + mm_modem_capability_get_type@Base 0.7.991 + mm_modem_cdma_activate@Base 0.7.991 + mm_modem_cdma_activate_finish@Base 0.7.991 + mm_modem_cdma_activate_manual@Base 1.2.0 + mm_modem_cdma_activate_manual_finish@Base 1.2.0 + mm_modem_cdma_activate_manual_sync@Base 1.2.0 + mm_modem_cdma_activate_sync@Base 0.7.991 + mm_modem_cdma_activation_state_get_string@Base 0.7.991 + mm_modem_cdma_activation_state_get_type@Base 0.7.991 + mm_modem_cdma_dup_esn@Base 0.7.991 + mm_modem_cdma_dup_meid@Base 0.7.991 + mm_modem_cdma_dup_path@Base 0.7.991 + mm_modem_cdma_get_activation_state@Base 0.7.991 + mm_modem_cdma_get_cdma1x_registration_state@Base 0.7.991 + mm_modem_cdma_get_esn@Base 0.7.991 + mm_modem_cdma_get_evdo_registration_state@Base 0.7.991 + mm_modem_cdma_get_meid@Base 0.7.991 + mm_modem_cdma_get_nid@Base 0.7.991 + mm_modem_cdma_get_path@Base 0.7.991 + mm_modem_cdma_get_sid@Base 0.7.991 + mm_modem_cdma_get_type@Base 0.7.991 + mm_modem_cdma_registration_state_get_string@Base 0.7.991 + mm_modem_cdma_registration_state_get_type@Base 0.7.991 + mm_modem_cdma_rm_protocol_get_string@Base 0.7.991 + mm_modem_cdma_rm_protocol_get_type@Base 0.7.991 + mm_modem_cell_broadcast_delete@Base 1.23.95 + mm_modem_cell_broadcast_delete_finish@Base 1.23.95 + mm_modem_cell_broadcast_delete_sync@Base 1.23.95 + mm_modem_cell_broadcast_dup_path@Base 1.23.95 + mm_modem_cell_broadcast_get_channels@Base 1.24.0 + mm_modem_cell_broadcast_get_path@Base 1.23.95 + mm_modem_cell_broadcast_get_type@Base 1.23.95 + mm_modem_cell_broadcast_list@Base 1.23.95 + mm_modem_cell_broadcast_list_finish@Base 1.23.95 + mm_modem_cell_broadcast_list_sync@Base 1.23.95 + mm_modem_cell_broadcast_peek_channels@Base 1.24.0 + mm_modem_cell_broadcast_set_channels@Base 1.24.0 + mm_modem_cell_broadcast_set_channels_finish@Base 1.24.0 + mm_modem_cell_broadcast_set_channels_sync@Base 1.24.0 + mm_modem_command@Base 0.7.991 + mm_modem_command_finish@Base 0.7.991 + mm_modem_command_sync@Base 0.7.991 + mm_modem_contacts_storage_get_string@Base 0.7.991 + mm_modem_contacts_storage_get_type@Base 0.7.991 + mm_modem_create_bearer@Base 0.7.991 + mm_modem_create_bearer_finish@Base 0.7.991 + mm_modem_create_bearer_sync@Base 0.7.991 + mm_modem_delete_bearer@Base 0.7.991 + mm_modem_delete_bearer_finish@Base 0.7.991 + mm_modem_delete_bearer_sync@Base 0.7.991 + mm_modem_disable@Base 0.7.991 + mm_modem_disable_finish@Base 0.7.991 + mm_modem_disable_sync@Base 0.7.991 + mm_modem_dup_bearer_paths@Base 1.2.0 + mm_modem_dup_carrier_configuration@Base 1.10.4 + mm_modem_dup_carrier_configuration_revision@Base 1.10.4 + mm_modem_dup_device@Base 0.7.991 + mm_modem_dup_device_identifier@Base 0.7.991 + mm_modem_dup_drivers@Base 0.7.991 + mm_modem_dup_equipment_identifier@Base 0.7.991 + mm_modem_dup_hardware_revision@Base 1.7.990 + mm_modem_dup_manufacturer@Base 0.7.991 + mm_modem_dup_model@Base 0.7.991 + mm_modem_dup_own_numbers@Base 0.7.991 + mm_modem_dup_path@Base 0.7.991 + mm_modem_dup_physdev@Base 1.22.0 + mm_modem_dup_plugin@Base 0.7.991 + mm_modem_dup_primary_port@Base 0.7.991 + mm_modem_dup_revision@Base 0.7.991 + mm_modem_dup_sim_path@Base 0.7.991 + mm_modem_dup_sim_slot_paths@Base 1.16.6 + mm_modem_enable@Base 0.7.991 + mm_modem_enable_finish@Base 0.7.991 + mm_modem_enable_sync@Base 0.7.991 + mm_modem_factory_reset@Base 0.7.991 + mm_modem_factory_reset_finish@Base 0.7.991 + mm_modem_factory_reset_sync@Base 0.7.991 + mm_modem_firmware_dup_path@Base 0.7.991 + mm_modem_firmware_get_path@Base 0.7.991 + mm_modem_firmware_get_type@Base 0.7.991 + mm_modem_firmware_get_update_settings@Base 1.10.0 + mm_modem_firmware_list@Base 0.7.991 + mm_modem_firmware_list_finish@Base 0.7.991 + mm_modem_firmware_list_sync@Base 0.7.991 + mm_modem_firmware_peek_update_settings@Base 1.10.0 + mm_modem_firmware_select@Base 0.7.991 + mm_modem_firmware_select_finish@Base 0.7.991 + mm_modem_firmware_select_sync@Base 0.7.991 + mm_modem_firmware_update_method_build_string_from_mask@Base 1.10.0 + mm_modem_firmware_update_method_get_type@Base 1.10.0 + mm_modem_get_access_technologies@Base 0.7.991 + mm_modem_get_bearer_paths@Base 1.2.0 + mm_modem_get_carrier_configuration@Base 1.10.4 + mm_modem_get_carrier_configuration_revision@Base 1.10.4 + mm_modem_get_cell_info@Base 1.20.0 + mm_modem_get_cell_info_finish@Base 1.20.0 + mm_modem_get_cell_info_sync@Base 1.20.0 + mm_modem_get_current_bands@Base 0.7.991 + mm_modem_get_current_capabilities@Base 0.7.991 + mm_modem_get_current_modes@Base 0.7.991 + mm_modem_get_device@Base 0.7.991 + mm_modem_get_device_identifier@Base 0.7.991 + mm_modem_get_drivers@Base 0.7.991 + mm_modem_get_equipment_identifier@Base 0.7.991 + mm_modem_get_hardware_revision@Base 1.7.990 + mm_modem_get_manufacturer@Base 0.7.991 + mm_modem_get_max_active_bearers@Base 0.7.991 + mm_modem_get_max_active_multiplexed_bearers@Base 1.18.2 + mm_modem_get_max_bearers@Base 0.7.991 + mm_modem_get_model@Base 0.7.991 + mm_modem_get_own_numbers@Base 0.7.991 + mm_modem_get_path@Base 0.7.991 + mm_modem_get_pending_network_initiated_sessions@Base 1.2.0 + mm_modem_get_physdev@Base 1.22.0 + mm_modem_get_plugin@Base 0.7.991 + mm_modem_get_ports@Base 1.0.0 + mm_modem_get_power_state@Base 0.7.991 + mm_modem_get_primary_port@Base 0.7.991 + mm_modem_get_primary_sim_slot@Base 1.16.6 + mm_modem_get_revision@Base 0.7.991 + mm_modem_get_signal_quality@Base 0.7.991 + mm_modem_get_sim@Base 0.7.991 + mm_modem_get_sim_finish@Base 0.7.991 + mm_modem_get_sim_path@Base 0.7.991 + mm_modem_get_sim_slot_paths@Base 1.16.6 + mm_modem_get_sim_sync@Base 0.7.991 + mm_modem_get_state@Base 0.7.991 + mm_modem_get_state_failed_reason@Base 0.7.991 + mm_modem_get_supported_bands@Base 0.7.991 + mm_modem_get_supported_capabilities@Base 0.7.991 + mm_modem_get_supported_ip_families@Base 0.7.991 + mm_modem_get_supported_modes@Base 0.7.991 + mm_modem_get_type@Base 0.7.991 + mm_modem_get_unlock_required@Base 0.7.991 + mm_modem_get_unlock_retries@Base 0.7.991 + mm_modem_list_bearers@Base 0.7.991 + mm_modem_list_bearers_finish@Base 0.7.991 + mm_modem_list_bearers_sync@Base 0.7.991 + mm_modem_list_sim_slots@Base 1.16.6 + mm_modem_list_sim_slots_finish@Base 1.16.6 + mm_modem_list_sim_slots_sync@Base 1.16.6 + mm_modem_location_assistance_data_type_build_string_from_mask@Base 1.10.0 + mm_modem_location_assistance_data_type_get_type@Base 1.10.0 + mm_modem_location_dup_assistance_data_servers@Base 1.10.0 + mm_modem_location_dup_path@Base 0.7.991 + mm_modem_location_dup_supl_server@Base 1.5.993 + mm_modem_location_get_3gpp@Base 0.7.991 + mm_modem_location_get_3gpp_finish@Base 0.7.991 + mm_modem_location_get_3gpp_sync@Base 0.7.991 + mm_modem_location_get_assistance_data_servers@Base 1.10.0 + mm_modem_location_get_capabilities@Base 0.7.991 + mm_modem_location_get_cdma_bs@Base 0.7.991 + mm_modem_location_get_cdma_bs_finish@Base 0.7.991 + mm_modem_location_get_cdma_bs_sync@Base 0.7.991 + mm_modem_location_get_enabled@Base 0.7.991 + mm_modem_location_get_full@Base 0.7.991 + mm_modem_location_get_full_finish@Base 0.7.991 + mm_modem_location_get_full_sync@Base 0.7.991 + mm_modem_location_get_gps_nmea@Base 0.7.991 + mm_modem_location_get_gps_nmea_finish@Base 0.7.991 + mm_modem_location_get_gps_nmea_sync@Base 0.7.991 + mm_modem_location_get_gps_raw@Base 0.7.991 + mm_modem_location_get_gps_raw_finish@Base 0.7.991 + mm_modem_location_get_gps_raw_sync@Base 0.7.991 + mm_modem_location_get_gps_refresh_rate@Base 1.5.993 + mm_modem_location_get_path@Base 0.7.991 + mm_modem_location_get_signaled_3gpp@Base 1.18.2 + mm_modem_location_get_signaled_cdma_bs@Base 1.18.2 + mm_modem_location_get_signaled_gps_nmea@Base 1.18.2 + mm_modem_location_get_signaled_gps_raw@Base 1.18.2 + mm_modem_location_get_supl_server@Base 1.5.993 + mm_modem_location_get_supported_assistance_data@Base 1.10.0 + mm_modem_location_get_type@Base 0.7.991 + mm_modem_location_inject_assistance_data@Base 1.10.0 + mm_modem_location_inject_assistance_data_finish@Base 1.10.0 + mm_modem_location_inject_assistance_data_sync@Base 1.10.0 + mm_modem_location_peek_signaled_3gpp@Base 1.18.2 + mm_modem_location_peek_signaled_cdma_bs@Base 1.18.2 + mm_modem_location_peek_signaled_gps_nmea@Base 1.18.2 + mm_modem_location_peek_signaled_gps_raw@Base 1.18.2 + mm_modem_location_set_gps_refresh_rate@Base 1.5.993 + mm_modem_location_set_gps_refresh_rate_finish@Base 1.5.993 + mm_modem_location_set_gps_refresh_rate_sync@Base 1.5.993 + mm_modem_location_set_supl_server@Base 1.5.993 + mm_modem_location_set_supl_server_finish@Base 1.5.993 + mm_modem_location_set_supl_server_sync@Base 1.5.993 + mm_modem_location_setup@Base 0.7.991 + mm_modem_location_setup_finish@Base 0.7.991 + mm_modem_location_setup_sync@Base 0.7.991 + mm_modem_location_signals_location@Base 0.7.991 + mm_modem_location_source_build_string_from_mask@Base 0.7.991 + mm_modem_location_source_get_type@Base 0.7.991 + mm_modem_lock_get_string@Base 0.7.991 + mm_modem_lock_get_type@Base 0.7.991 + mm_modem_messaging_create@Base 0.7.991 + mm_modem_messaging_create_finish@Base 0.7.991 + mm_modem_messaging_create_sync@Base 0.7.991 + mm_modem_messaging_delete@Base 0.7.991 + mm_modem_messaging_delete_finish@Base 0.7.991 + mm_modem_messaging_delete_sync@Base 0.7.991 + mm_modem_messaging_dup_path@Base 0.7.991 + mm_modem_messaging_get_default_storage@Base 0.7.991 + mm_modem_messaging_get_path@Base 0.7.991 + mm_modem_messaging_get_supported_storages@Base 0.7.991 + mm_modem_messaging_get_type@Base 0.7.991 + mm_modem_messaging_list@Base 0.7.991 + mm_modem_messaging_list_finish@Base 0.7.991 + mm_modem_messaging_list_sync@Base 0.7.991 + mm_modem_messaging_peek_supported_storages@Base 0.7.991 + mm_modem_messaging_set_default_storage@Base 1.23.12 + mm_modem_messaging_set_default_storage_finish@Base 1.23.12 + mm_modem_messaging_set_default_storage_sync@Base 1.23.12 + mm_modem_mode_build_string_from_mask@Base 0.7.991 + mm_modem_mode_get_type@Base 0.7.991 + mm_modem_oma_accept_network_initiated_session@Base 1.2.0 + mm_modem_oma_accept_network_initiated_session_finish@Base 1.2.0 + mm_modem_oma_accept_network_initiated_session_sync@Base 1.2.0 + mm_modem_oma_cancel_session@Base 1.2.0 + mm_modem_oma_cancel_session_finish@Base 1.2.0 + mm_modem_oma_cancel_session_sync@Base 1.2.0 + mm_modem_oma_dup_path@Base 1.2.0 + mm_modem_oma_get_features@Base 1.2.0 + mm_modem_oma_get_path@Base 1.2.0 + mm_modem_oma_get_pending_network_initiated_sessions@Base 1.18.2 + mm_modem_oma_get_session_state@Base 1.2.0 + mm_modem_oma_get_session_type@Base 1.2.0 + mm_modem_oma_get_type@Base 1.2.0 + mm_modem_oma_peek_pending_network_initiated_sessions@Base 1.18.2 + mm_modem_oma_setup@Base 1.2.0 + mm_modem_oma_setup_finish@Base 1.2.0 + mm_modem_oma_setup_sync@Base 1.2.0 + mm_modem_oma_start_client_initiated_session@Base 1.2.0 + mm_modem_oma_start_client_initiated_session_finish@Base 1.2.0 + mm_modem_oma_start_client_initiated_session_sync@Base 1.2.0 + mm_modem_peek_current_bands@Base 0.7.991 + mm_modem_peek_pending_network_initiated_sessions@Base 1.2.0 + mm_modem_peek_ports@Base 1.0.0 + mm_modem_peek_supported_bands@Base 0.7.991 + mm_modem_peek_supported_capabilities@Base 0.7.991 + mm_modem_peek_supported_modes@Base 0.7.991 + mm_modem_peek_unlock_retries@Base 0.7.991 + mm_modem_port_info_array_free@Base 1.0.0 + mm_modem_port_type_get_string@Base 1.0.0 + mm_modem_port_type_get_type@Base 1.0.0 + mm_modem_power_state_get_string@Base 0.7.991 + mm_modem_power_state_get_type@Base 0.7.991 + mm_modem_reset@Base 0.7.991 + mm_modem_reset_finish@Base 0.7.991 + mm_modem_reset_sync@Base 0.7.991 + mm_modem_sar_dup_path@Base 1.20.0 + mm_modem_sar_enable@Base 1.20.0 + mm_modem_sar_enable_finish@Base 1.20.0 + mm_modem_sar_enable_sync@Base 1.20.0 + mm_modem_sar_get_path@Base 1.20.0 + mm_modem_sar_get_power_level@Base 1.20.0 + mm_modem_sar_get_state@Base 1.20.0 + mm_modem_sar_get_type@Base 1.20.0 + mm_modem_sar_set_power_level@Base 1.20.0 + mm_modem_sar_set_power_level_finish@Base 1.20.0 + mm_modem_sar_set_power_level_sync@Base 1.20.0 + mm_modem_set_current_bands@Base 0.7.991 + mm_modem_set_current_bands_finish@Base 0.7.991 + mm_modem_set_current_bands_sync@Base 0.7.991 + mm_modem_set_current_capabilities@Base 0.7.991 + mm_modem_set_current_capabilities_finish@Base 0.7.991 + mm_modem_set_current_capabilities_sync@Base 0.7.991 + mm_modem_set_current_modes@Base 0.7.991 + mm_modem_set_current_modes_finish@Base 0.7.991 + mm_modem_set_current_modes_sync@Base 0.7.991 + mm_modem_set_power_state@Base 0.7.991 + mm_modem_set_power_state_finish@Base 0.7.991 + mm_modem_set_power_state_sync@Base 0.7.991 + mm_modem_set_primary_sim_slot@Base 1.16.6 + mm_modem_set_primary_sim_slot_finish@Base 1.16.6 + mm_modem_set_primary_sim_slot_sync@Base 1.16.6 + mm_modem_signal_dup_path@Base 1.2.0 + mm_modem_signal_get_cdma@Base 1.2.0 + mm_modem_signal_get_error_rate_threshold@Base 1.20.0 + mm_modem_signal_get_evdo@Base 1.2.0 + mm_modem_signal_get_gsm@Base 1.2.0 + mm_modem_signal_get_lte@Base 1.2.0 + mm_modem_signal_get_nr5g@Base 1.16.6 + mm_modem_signal_get_path@Base 1.2.0 + mm_modem_signal_get_rate@Base 1.2.0 + mm_modem_signal_get_rssi_threshold@Base 1.20.0 + mm_modem_signal_get_type@Base 1.2.0 + mm_modem_signal_get_umts@Base 1.2.0 + mm_modem_signal_peek_cdma@Base 1.2.0 + mm_modem_signal_peek_evdo@Base 1.2.0 + mm_modem_signal_peek_gsm@Base 1.2.0 + mm_modem_signal_peek_lte@Base 1.2.0 + mm_modem_signal_peek_nr5g@Base 1.16.6 + mm_modem_signal_peek_umts@Base 1.2.0 + mm_modem_signal_setup@Base 1.2.0 + mm_modem_signal_setup_finish@Base 1.2.0 + mm_modem_signal_setup_sync@Base 1.2.0 + mm_modem_signal_setup_thresholds@Base 1.20.0 + mm_modem_signal_setup_thresholds_finish@Base 1.20.0 + mm_modem_signal_setup_thresholds_sync@Base 1.20.0 + mm_modem_simple_connect@Base 0.7.991 + mm_modem_simple_connect_finish@Base 0.7.991 + mm_modem_simple_connect_sync@Base 0.7.991 + mm_modem_simple_disconnect@Base 0.7.991 + mm_modem_simple_disconnect_finish@Base 0.7.991 + mm_modem_simple_disconnect_sync@Base 0.7.991 + mm_modem_simple_dup_path@Base 0.7.991 + mm_modem_simple_get_path@Base 0.7.991 + mm_modem_simple_get_status@Base 0.7.991 + mm_modem_simple_get_status_finish@Base 0.7.991 + mm_modem_simple_get_status_sync@Base 0.7.991 + mm_modem_simple_get_type@Base 0.7.991 + mm_modem_state_change_reason_get_string@Base 0.7.991 + mm_modem_state_change_reason_get_type@Base 0.7.991 + mm_modem_state_failed_reason_get_string@Base 0.7.991 + mm_modem_state_failed_reason_get_type@Base 0.7.991 + mm_modem_state_get_string@Base 0.7.991 + mm_modem_state_get_type@Base 0.7.991 + mm_modem_time_dup_path@Base 0.7.991 + mm_modem_time_get_network_time@Base 0.7.991 + mm_modem_time_get_network_time_finish@Base 0.7.991 + mm_modem_time_get_network_time_sync@Base 0.7.991 + mm_modem_time_get_network_timezone@Base 0.7.991 + mm_modem_time_get_path@Base 0.7.991 + mm_modem_time_get_type@Base 0.7.991 + mm_modem_time_peek_network_timezone@Base 0.7.991 + mm_modem_voice_call_waiting_query@Base 1.12.6 + mm_modem_voice_call_waiting_query_finish@Base 1.12.6 + mm_modem_voice_call_waiting_query_sync@Base 1.12.6 + mm_modem_voice_call_waiting_setup@Base 1.12.6 + mm_modem_voice_call_waiting_setup_finish@Base 1.12.6 + mm_modem_voice_call_waiting_setup_sync@Base 1.12.6 + mm_modem_voice_create_call@Base 1.5.993 + mm_modem_voice_create_call_finish@Base 1.5.993 + mm_modem_voice_create_call_sync@Base 1.5.993 + mm_modem_voice_delete_call@Base 1.5.993 + mm_modem_voice_delete_call_finish@Base 1.5.993 + mm_modem_voice_delete_call_sync@Base 1.5.993 + mm_modem_voice_dup_path@Base 1.5.993 + mm_modem_voice_get_emergency_only@Base 1.12.6 + mm_modem_voice_get_path@Base 1.5.993 + mm_modem_voice_get_type@Base 1.5.993 + mm_modem_voice_hangup_all@Base 1.12.6 + mm_modem_voice_hangup_all_finish@Base 1.12.6 + mm_modem_voice_hangup_all_sync@Base 1.12.6 + mm_modem_voice_hangup_and_accept@Base 1.12.6 + mm_modem_voice_hangup_and_accept_finish@Base 1.12.6 + mm_modem_voice_hangup_and_accept_sync@Base 1.12.6 + mm_modem_voice_hold_and_accept@Base 1.12.6 + mm_modem_voice_hold_and_accept_finish@Base 1.12.6 + mm_modem_voice_hold_and_accept_sync@Base 1.12.6 + mm_modem_voice_list_calls@Base 1.5.993 + mm_modem_voice_list_calls_finish@Base 1.5.993 + mm_modem_voice_list_calls_sync@Base 1.5.993 + mm_modem_voice_transfer@Base 1.12.6 + mm_modem_voice_transfer_finish@Base 1.12.6 + mm_modem_voice_transfer_sync@Base 1.12.6 + mm_network_error_get_string@Base 1.23.12 + mm_network_error_get_type@Base 1.23.12 + mm_network_rejection_get_access_technology@Base 1.23.12 + mm_network_rejection_get_dictionary@Base 1.23.12 + mm_network_rejection_get_error@Base 1.23.12 + mm_network_rejection_get_operator_id@Base 1.23.12 + mm_network_rejection_get_operator_name@Base 1.23.12 + mm_network_rejection_get_type@Base 1.23.12 + mm_network_rejection_new@Base 1.23.12 + mm_network_rejection_new_from_dictionary@Base 1.23.12 + mm_network_rejection_set_access_technology@Base 1.23.12 + mm_network_rejection_set_error@Base 1.23.12 + mm_network_rejection_set_operator_id@Base 1.23.12 + mm_network_rejection_set_operator_name@Base 1.23.12 + mm_network_timezone_get_dictionary@Base 0.7.991 + mm_network_timezone_get_dst_offset@Base 0.7.991 + mm_network_timezone_get_leap_seconds@Base 0.7.991 + mm_network_timezone_get_offset@Base 0.7.991 + mm_network_timezone_get_type@Base 0.7.991 + mm_network_timezone_new@Base 0.7.991 + mm_network_timezone_new_from_dictionary@Base 0.7.991 + mm_network_timezone_set_dst_offset@Base 0.7.991 + mm_network_timezone_set_leap_seconds@Base 0.7.991 + mm_network_timezone_set_offset@Base 0.7.991 + mm_new_iso8601_time@Base 1.18.8 + mm_new_iso8601_time_from_unix_time@Base 1.18.8 + mm_nr5g_registration_settings_cmp@Base 1.20.0 + mm_nr5g_registration_settings_get_dictionary@Base 1.20.0 + mm_nr5g_registration_settings_get_drx_cycle@Base 1.20.0 + mm_nr5g_registration_settings_get_mico_mode@Base 1.20.0 + mm_nr5g_registration_settings_get_type@Base 1.20.0 + mm_nr5g_registration_settings_new@Base 1.20.0 + mm_nr5g_registration_settings_new_from_dictionary@Base 1.20.0 + mm_nr5g_registration_settings_new_from_string@Base 1.20.0 + mm_nr5g_registration_settings_set_drx_cycle@Base 1.20.0 + mm_nr5g_registration_settings_set_mico_mode@Base 1.20.0 + mm_object_dup_path@Base 0.7.991 + mm_object_get_modem@Base 0.7.991 + mm_object_get_modem_3gpp@Base 0.7.991 + mm_object_get_modem_3gpp_profile_manager@Base 1.18.2 + mm_object_get_modem_3gpp_ussd@Base 0.7.991 + mm_object_get_modem_cdma@Base 0.7.991 + mm_object_get_modem_cell_broadcast@Base 1.23.95 + mm_object_get_modem_firmware@Base 0.7.991 + mm_object_get_modem_location@Base 0.7.991 + mm_object_get_modem_messaging@Base 0.7.991 + mm_object_get_modem_oma@Base 1.2.0 + mm_object_get_modem_sar@Base 1.20.0 + mm_object_get_modem_signal@Base 1.2.0 + mm_object_get_modem_simple@Base 0.7.991 + mm_object_get_modem_time@Base 0.7.991 + mm_object_get_modem_voice@Base 1.5.993 + mm_object_get_path@Base 0.7.991 + mm_object_get_type@Base 0.7.991 + mm_object_peek_modem@Base 0.7.991 + mm_object_peek_modem_3gpp@Base 0.7.991 + mm_object_peek_modem_3gpp_profile_manager@Base 1.18.2 + mm_object_peek_modem_3gpp_ussd@Base 0.7.991 + mm_object_peek_modem_cdma@Base 0.7.991 + mm_object_peek_modem_cell_broadcast@Base 1.23.95 + mm_object_peek_modem_firmware@Base 0.7.991 + mm_object_peek_modem_location@Base 0.7.991 + mm_object_peek_modem_messaging@Base 0.7.991 + mm_object_peek_modem_oma@Base 1.2.0 + mm_object_peek_modem_sar@Base 1.20.0 + mm_object_peek_modem_signal@Base 1.2.0 + mm_object_peek_modem_simple@Base 0.7.991 + mm_object_peek_modem_time@Base 0.7.991 + mm_object_peek_modem_voice@Base 1.5.993 + mm_oma_feature_build_string_from_mask@Base 1.2.0 + mm_oma_feature_get_type@Base 1.2.0 + mm_oma_session_state_failed_reason_get_string@Base 1.2.0 + mm_oma_session_state_failed_reason_get_type@Base 1.2.0 + mm_oma_session_state_get_string@Base 1.2.0 + mm_oma_session_state_get_type@Base 1.2.0 + mm_oma_session_type_get_string@Base 1.2.0 + mm_oma_session_type_get_type@Base 1.2.0 + mm_pco_from_variant@Base 1.10.0 + mm_pco_get_data@Base 1.10.0 + mm_pco_get_session_id@Base 1.10.0 + mm_pco_get_type@Base 1.10.0 + mm_pco_is_complete@Base 1.10.0 + mm_pco_list_add@Base 1.10.0 + mm_pco_list_free@Base 1.10.0 + mm_pco_new@Base 1.10.0 + mm_pco_set_complete@Base 1.10.0 + mm_pco_set_data@Base 1.10.0 + mm_pco_set_session_id@Base 1.10.0 + mm_pco_to_variant@Base 1.10.0 + mm_serial_error_get_type@Base 0.7.991 + mm_serial_error_quark@Base 0.7.991 + mm_serving_cell_type_get_string@Base 1.22.0 + mm_serving_cell_type_get_type@Base 1.22.0 + mm_signal_get_dictionary@Base 1.2.0 + mm_signal_get_ecio@Base 1.2.0 + mm_signal_get_error_rate@Base 1.20.0 + mm_signal_get_io@Base 1.2.0 + mm_signal_get_rscp@Base 1.6.8 + mm_signal_get_rsrp@Base 1.2.0 + mm_signal_get_rsrq@Base 1.2.0 + mm_signal_get_rssi@Base 1.2.0 + mm_signal_get_sinr@Base 1.2.0 + mm_signal_get_snr@Base 1.2.0 + mm_signal_get_string@Base 1.22.0 + mm_signal_get_type@Base 1.2.0 + mm_signal_new@Base 1.2.0 + mm_signal_new_from_dictionary@Base 1.2.0 + mm_signal_set_ecio@Base 1.2.0 + mm_signal_set_error_rate@Base 1.20.0 + mm_signal_set_io@Base 1.2.0 + mm_signal_set_rscp@Base 1.6.8 + mm_signal_set_rsrp@Base 1.2.0 + mm_signal_set_rsrq@Base 1.2.0 + mm_signal_set_rssi@Base 1.2.0 + mm_signal_set_sinr@Base 1.2.0 + mm_signal_set_snr@Base 1.2.0 + mm_signal_threshold_properties_get_dictionary@Base 1.20.0 + mm_signal_threshold_properties_get_error_rate@Base 1.20.0 + mm_signal_threshold_properties_get_rssi@Base 1.20.0 + mm_signal_threshold_properties_get_type@Base 1.20.0 + mm_signal_threshold_properties_new@Base 1.20.0 + mm_signal_threshold_properties_new_from_dictionary@Base 1.20.0 + mm_signal_threshold_properties_new_from_string@Base 1.20.0 + mm_signal_threshold_properties_set_error_rate@Base 1.20.0 + mm_signal_threshold_properties_set_rssi@Base 1.20.0 + mm_sim_change_pin@Base 0.7.991 + mm_sim_change_pin_finish@Base 0.7.991 + mm_sim_change_pin_sync@Base 0.7.991 + mm_sim_disable_pin@Base 0.7.991 + mm_sim_disable_pin_finish@Base 0.7.991 + mm_sim_disable_pin_sync@Base 0.7.991 + mm_sim_dup_eid@Base 1.16.6 + mm_sim_dup_emergency_numbers@Base 1.12.6 + mm_sim_dup_gid1@Base 1.20.0 + mm_sim_dup_gid2@Base 1.20.0 + mm_sim_dup_identifier@Base 0.7.991 + mm_sim_dup_imsi@Base 0.7.991 + mm_sim_dup_operator_identifier@Base 0.7.991 + mm_sim_dup_operator_name@Base 0.7.991 + mm_sim_dup_path@Base 0.7.991 + mm_sim_enable_pin@Base 0.7.991 + mm_sim_enable_pin_finish@Base 0.7.991 + mm_sim_enable_pin_sync@Base 0.7.991 + mm_sim_esim_status_get_string@Base 1.20.0 + mm_sim_esim_status_get_type@Base 1.20.0 + mm_sim_get_active@Base 1.16.6 + mm_sim_get_eid@Base 1.16.6 + mm_sim_get_emergency_numbers@Base 1.12.6 + mm_sim_get_esim_status@Base 1.20.0 + mm_sim_get_gid1@Base 1.20.0 + mm_sim_get_gid2@Base 1.20.0 + mm_sim_get_identifier@Base 0.7.991 + mm_sim_get_imsi@Base 0.7.991 + mm_sim_get_operator_identifier@Base 0.7.991 + mm_sim_get_operator_name@Base 0.7.991 + mm_sim_get_path@Base 0.7.991 + mm_sim_get_preferred_networks@Base 1.18.2 + mm_sim_get_removability@Base 1.20.0 + mm_sim_get_sim_type@Base 1.20.0 + mm_sim_get_type@Base 0.7.991 + mm_sim_preferred_network_free@Base 1.18.2 + mm_sim_preferred_network_get_access_technology@Base 1.18.2 + mm_sim_preferred_network_get_operator_code@Base 1.18.2 + mm_sim_preferred_network_get_tuple@Base 1.18.2 + mm_sim_preferred_network_get_type@Base 1.18.2 + mm_sim_preferred_network_list_copy@Base 1.18.2 + mm_sim_preferred_network_list_free@Base 1.18.2 + mm_sim_preferred_network_list_get_variant@Base 1.18.2 + mm_sim_preferred_network_list_new_from_variant@Base 1.18.2 + mm_sim_preferred_network_new@Base 1.18.2 + mm_sim_preferred_network_new_from_variant@Base 1.18.2 + mm_sim_preferred_network_set_access_technology@Base 1.18.2 + mm_sim_preferred_network_set_operator_code@Base 1.18.2 + mm_sim_removability_get_string@Base 1.20.0 + mm_sim_removability_get_type@Base 1.20.0 + mm_sim_send_pin@Base 0.7.991 + mm_sim_send_pin_finish@Base 0.7.991 + mm_sim_send_pin_sync@Base 0.7.991 + mm_sim_send_puk@Base 0.7.991 + mm_sim_send_puk_finish@Base 0.7.991 + mm_sim_send_puk_sync@Base 0.7.991 + mm_sim_set_preferred_networks@Base 1.18.2 + mm_sim_set_preferred_networks_finish@Base 1.18.2 + mm_sim_set_preferred_networks_sync@Base 1.18.2 + mm_sim_type_get_string@Base 1.20.0 + mm_sim_type_get_type@Base 1.20.0 + mm_simple_connect_properties_get_allow_roaming@Base 0.7.991 + mm_simple_connect_properties_get_allowed_auth@Base 0.7.991 + mm_simple_connect_properties_get_apn@Base 0.7.991 + mm_simple_connect_properties_get_apn_type@Base 1.18.2 + mm_simple_connect_properties_get_bearer_properties@Base 0.7.991 + mm_simple_connect_properties_get_dictionary@Base 0.7.991 + mm_simple_connect_properties_get_ip_type@Base 0.7.991 + mm_simple_connect_properties_get_multiplex@Base 1.18.2 + mm_simple_connect_properties_get_number@Base 0.7.991 + mm_simple_connect_properties_get_operator_id@Base 0.7.991 + mm_simple_connect_properties_get_password@Base 0.7.991 + mm_simple_connect_properties_get_pin@Base 0.7.991 + mm_simple_connect_properties_get_profile_id@Base 1.18.2 + mm_simple_connect_properties_get_rm_protocol@Base 1.16.6 + mm_simple_connect_properties_get_type@Base 0.7.991 + mm_simple_connect_properties_get_user@Base 0.7.991 + mm_simple_connect_properties_new@Base 0.7.991 + mm_simple_connect_properties_new_from_dictionary@Base 0.7.991 + mm_simple_connect_properties_new_from_string@Base 0.7.991 + mm_simple_connect_properties_print@Base 1.22.0 + mm_simple_connect_properties_set_allow_roaming@Base 0.7.991 + mm_simple_connect_properties_set_allowed_auth@Base 0.7.991 + mm_simple_connect_properties_set_apn@Base 0.7.991 + mm_simple_connect_properties_set_apn_type@Base 1.18.2 + mm_simple_connect_properties_set_ip_type@Base 0.7.991 + mm_simple_connect_properties_set_multiplex@Base 1.18.2 + mm_simple_connect_properties_set_number@Base 0.7.991 + mm_simple_connect_properties_set_operator_id@Base 0.7.991 + mm_simple_connect_properties_set_password@Base 0.7.991 + mm_simple_connect_properties_set_pin@Base 0.7.991 + mm_simple_connect_properties_set_profile_id@Base 1.18.2 + mm_simple_connect_properties_set_rm_protocol@Base 1.16.6 + mm_simple_connect_properties_set_user@Base 0.7.991 + mm_simple_status_get_3gpp_operator_code@Base 0.7.991 + mm_simple_status_get_3gpp_operator_name@Base 0.7.991 + mm_simple_status_get_3gpp_registration_state@Base 0.7.991 + mm_simple_status_get_3gpp_subscription_state@Base 1.2.0 + mm_simple_status_get_access_technologies@Base 0.7.991 + mm_simple_status_get_cdma_cdma1x_registration_state@Base 0.7.991 + mm_simple_status_get_cdma_evdo_registration_state@Base 0.7.991 + mm_simple_status_get_cdma_nid@Base 0.7.991 + mm_simple_status_get_cdma_sid@Base 0.7.991 + mm_simple_status_get_current_bands@Base 0.7.991 + mm_simple_status_get_dictionary@Base 0.7.991 + mm_simple_status_get_signal_quality@Base 0.7.991 + mm_simple_status_get_state@Base 0.7.991 + mm_simple_status_get_type@Base 0.7.991 + mm_simple_status_new@Base 0.7.991 + mm_simple_status_new_from_dictionary@Base 0.7.991 + mm_sms_cdma_service_category_get_string@Base 1.2.0 + mm_sms_cdma_service_category_get_type@Base 1.2.0 + mm_sms_cdma_teleservice_id_get_string@Base 1.2.0 + mm_sms_cdma_teleservice_id_get_type@Base 1.2.0 + mm_sms_delivery_state_get_string@Base 0.7.991 + mm_sms_delivery_state_get_string_extended@Base 0.7.991 + mm_sms_delivery_state_get_type@Base 0.7.991 + mm_sms_dup_data@Base 0.7.991 + mm_sms_dup_discharge_timestamp@Base 0.7.991 + mm_sms_dup_number@Base 0.7.991 + mm_sms_dup_path@Base 0.7.991 + mm_sms_dup_smsc@Base 0.7.991 + mm_sms_dup_text@Base 0.7.991 + mm_sms_dup_timestamp@Base 0.7.991 + mm_sms_get_class@Base 0.7.991 + mm_sms_get_data@Base 0.7.991 + mm_sms_get_delivery_report_request@Base 0.7.991 + mm_sms_get_delivery_state@Base 0.7.991 + mm_sms_get_discharge_timestamp@Base 0.7.991 + mm_sms_get_message_reference@Base 0.7.991 + mm_sms_get_number@Base 0.7.991 + mm_sms_get_path@Base 0.7.991 + mm_sms_get_pdu_type@Base 0.7.991 + mm_sms_get_service_category@Base 1.2.0 + mm_sms_get_smsc@Base 0.7.991 + mm_sms_get_state@Base 0.7.991 + mm_sms_get_storage@Base 0.7.991 + mm_sms_get_teleservice_id@Base 1.2.0 + mm_sms_get_text@Base 0.7.991 + mm_sms_get_timestamp@Base 0.7.991 + mm_sms_get_type@Base 0.7.991 + mm_sms_get_validity_relative@Base 0.7.991 + mm_sms_get_validity_type@Base 0.7.991 + mm_sms_pdu_type_get_string@Base 0.7.991 + mm_sms_pdu_type_get_type@Base 0.7.991 + mm_sms_properties_get_class@Base 0.7.991 + mm_sms_properties_get_data@Base 0.7.991 + mm_sms_properties_get_data_bytearray@Base 0.7.991 + mm_sms_properties_get_delivery_report_request@Base 0.7.991 + mm_sms_properties_get_dictionary@Base 0.7.991 + mm_sms_properties_get_number@Base 0.7.991 + mm_sms_properties_get_service_category@Base 1.2.0 + mm_sms_properties_get_smsc@Base 0.7.991 + mm_sms_properties_get_teleservice_id@Base 1.2.0 + mm_sms_properties_get_text@Base 0.7.991 + mm_sms_properties_get_type@Base 0.7.991 + mm_sms_properties_get_validity_relative@Base 0.7.991 + mm_sms_properties_get_validity_type@Base 0.7.991 + mm_sms_properties_new@Base 0.7.991 + mm_sms_properties_new_from_dictionary@Base 0.7.991 + mm_sms_properties_new_from_string@Base 0.7.991 + mm_sms_properties_peek_data_bytearray@Base 0.7.991 + mm_sms_properties_set_class@Base 0.7.991 + mm_sms_properties_set_data@Base 0.7.991 + mm_sms_properties_set_data_bytearray@Base 0.7.991 + mm_sms_properties_set_delivery_report_request@Base 0.7.991 + mm_sms_properties_set_number@Base 0.7.991 + mm_sms_properties_set_service_category@Base 1.2.0 + mm_sms_properties_set_smsc@Base 0.7.991 + mm_sms_properties_set_teleservice_id@Base 1.2.0 + mm_sms_properties_set_text@Base 0.7.991 + mm_sms_properties_set_validity_relative@Base 0.7.991 + mm_sms_send@Base 0.7.991 + mm_sms_send_finish@Base 0.7.991 + mm_sms_send_sync@Base 0.7.991 + mm_sms_state_get_string@Base 0.7.991 + mm_sms_state_get_type@Base 0.7.991 + mm_sms_storage_get_string@Base 0.7.991 + mm_sms_storage_get_type@Base 0.7.991 + mm_sms_store@Base 0.7.991 + mm_sms_store_finish@Base 0.7.991 + mm_sms_store_sync@Base 0.7.991 + mm_sms_validity_type_get_string@Base 0.7.991 + mm_sms_validity_type_get_type@Base 0.7.991 + mm_unlock_retries_build_string@Base 0.7.991 + mm_unlock_retries_cmp@Base 0.7.991 + mm_unlock_retries_foreach@Base 0.7.991 + mm_unlock_retries_get@Base 0.7.991 + mm_unlock_retries_get_dictionary@Base 0.7.991 + mm_unlock_retries_get_type@Base 0.7.991 + mm_unlock_retries_new@Base 0.7.991 + mm_unlock_retries_new_from_dictionary@Base 0.7.991 + mm_unlock_retries_set@Base 0.7.991 + mm_unlock_retries_unset@Base 0.7.991 + mm_utils_bin2hexstr@Base 0.7.991 + mm_utils_check_for_single_value@Base 0.7.991 + mm_utils_hex2byte@Base 0.7.991 + mm_utils_hexstr2bin@Base 0.7.991 + mm_utils_ishexstr@Base 0.7.991 diff --git a/packaging/ModemManager/debian/modemmanager-dev.install b/packaging/ModemManager/debian/modemmanager-dev.install new file mode 100644 index 0000000..a13d483 --- /dev/null +++ b/packaging/ModemManager/debian/modemmanager-dev.install @@ -0,0 +1,3 @@ +usr/include/ModemManager/ +usr/lib/*/pkgconfig/ModemManager.pc +usr/share/dbus-1/interfaces/ diff --git a/packaging/ModemManager/debian/modemmanager-doc.install b/packaging/ModemManager/debian/modemmanager-doc.install new file mode 100644 index 0000000..50020da --- /dev/null +++ b/packaging/ModemManager/debian/modemmanager-doc.install @@ -0,0 +1 @@ +usr/share/gtk-doc/html/ModemManager/ diff --git a/packaging/ModemManager/debian/modemmanager.NEWS b/packaging/ModemManager/debian/modemmanager.NEWS new file mode 100644 index 0000000..686bf32 --- /dev/null +++ b/packaging/ModemManager/debian/modemmanager.NEWS @@ -0,0 +1,7 @@ +modemmanager (1.18.6-1) unstable; urgency=medium + + Starting with release 1.18.4, ModemManager does no longer perform the FCC + unlock procedure automatically. If your modem requires FCC unlock see + /usr/share/doc/modemmanager/README.Debian for details. + + -- Guido Günther Tue, 01 Feb 2022 09:05:48 +0100 diff --git a/packaging/ModemManager/debian/modemmanager.conffiles b/packaging/ModemManager/debian/modemmanager.conffiles new file mode 100644 index 0000000..1ee86e7 --- /dev/null +++ b/packaging/ModemManager/debian/modemmanager.conffiles @@ -0,0 +1 @@ +remove-on-upgrade /etc/dbus-1/system.d/org.freedesktop.ModemManager1.conf diff --git a/packaging/ModemManager/debian/modemmanager.docs b/packaging/ModemManager/debian/modemmanager.docs new file mode 100644 index 0000000..d379acf --- /dev/null +++ b/packaging/ModemManager/debian/modemmanager.docs @@ -0,0 +1,3 @@ +NEWS +README.md +AUTHORS diff --git a/packaging/ModemManager/debian/modemmanager.install b/packaging/ModemManager/debian/modemmanager.install new file mode 100644 index 0000000..80519d4 --- /dev/null +++ b/packaging/ModemManager/debian/modemmanager.install @@ -0,0 +1,15 @@ +debian/77-mm-qdl-device-blacklist.rules usr/lib/udev/rules.d/ +etc/ +usr/bin/ +usr/sbin/ +usr/share/ModemManager/ +usr/share/bash-completion/ +usr/share/dbus-1/system-services/ +usr/share/dbus-1/system.d +usr/share/polkit-1/ +usr/share/icons/ +usr/share/man/ +usr/share/locale/ +usr/lib/*/ModemManager/ +usr/lib/systemd +usr/lib/udev diff --git a/packaging/ModemManager/debian/modemmanager.lintian-overrides b/packaging/ModemManager/debian/modemmanager.lintian-overrides new file mode 100644 index 0000000..7aa6022 --- /dev/null +++ b/packaging/ModemManager/debian/modemmanager.lintian-overrides @@ -0,0 +1,7 @@ +# ModemManager ships many plugins as .so files. These are not proper shared +# libraries though, and therefore don't need to include information about +# other libraries against which they would be linked. +modemmanager: library-not-linked-against-libc [usr/lib/*/ModemManager/*] +# We ship an empty /usr/lib/ARCH/ModemManager/connection.d/ folder so users +# have it readily available if needed +modemmanager: package-contains-empty-directory [usr/lib/*/ModemManager/connection.d/] diff --git a/packaging/ModemManager/debian/modemmanager.prerm b/packaging/ModemManager/debian/modemmanager.prerm new file mode 100644 index 0000000..f128537 --- /dev/null +++ b/packaging/ModemManager/debian/modemmanager.prerm @@ -0,0 +1,9 @@ +#!/bin/sh +set -e + +# avoid cancelling of "stop" when NM D-BUS reactivates modemmanager +if [ -d /run/systemd/system ] && [ "$1" = remove ]; then + deb-systemd-helper mask ModemManager +fi + +#DEBHELPER# diff --git a/packaging/ModemManager/debian/rules b/packaging/ModemManager/debian/rules new file mode 100755 index 0000000..cdaff8a --- /dev/null +++ b/packaging/ModemManager/debian/rules @@ -0,0 +1,36 @@ +#!/usr/bin/make -f + +export DPKG_GENSYMBOLS_CHECK_LEVEL=4 +export DEB_BUILD_MAINT_OPTIONS = hardening=+all + +DEB_HOST_MULTIARCH ?= $(shell dpkg-architecture -qDEB_HOST_MULTIARCH) + +ifeq ($(filter libmm-glib-doc,$(shell dh_listpackages)),) +configure_flags += -Dgtk_doc=false +else +configure_flags += -Dgtk_doc=true +endif + +%: + dh $@ --with gir --buildsystem=meson + +# Although meson is the preferred build system, autotools files are still present, +# leading dh_autoreconf to execute anyway, which is useless. +override_dh_autoreconf: + +override_dh_auto_configure: + dh_auto_configure -- $(configure_flags) \ + -Ddbus_policy_dir=/usr/share/dbus-1/system.d \ + -Dpolkit=permissive \ + -Dsystemdsystemunitdir=/usr/lib/systemd/system \ + -Dudevdir=/usr/lib/udev \ + -Dvapi=true + +override_dh_install: + rmdir $(CURDIR)/debian/tmp/usr/lib/$(DEB_HOST_MULTIARCH)/ModemManager/fcc-unlock.d + dh_install + +execute_after_dh_builddeb-indep: + # Confirm that the DEP-8 patch still applies to new upstream versions, + # before running into autopkgtest failures. + patch -p1 < debian/tests/0001-Test-running-service-in-plugin-generic.patch diff --git a/packaging/ModemManager/debian/salsa-ci.yml b/packaging/ModemManager/debian/salsa-ci.yml new file mode 100644 index 0000000..33cc810 --- /dev/null +++ b/packaging/ModemManager/debian/salsa-ci.yml @@ -0,0 +1,7 @@ +--- +include: + - https://salsa.debian.org/salsa-ci-team/pipeline/raw/master/salsa-ci.yml + - https://salsa.debian.org/salsa-ci-team/pipeline/raw/master/pipeline-jobs.yml + +variables: + SALSA_CI_DISABLE_PIUPARTS: 1 diff --git a/packaging/ModemManager/debian/source/format b/packaging/ModemManager/debian/source/format new file mode 100644 index 0000000..163aaf8 --- /dev/null +++ b/packaging/ModemManager/debian/source/format @@ -0,0 +1 @@ +3.0 (quilt) diff --git a/packaging/ModemManager/debian/tests/0001-Test-running-service-in-plugin-generic.patch b/packaging/ModemManager/debian/tests/0001-Test-running-service-in-plugin-generic.patch new file mode 100644 index 0000000..43a428e --- /dev/null +++ b/packaging/ModemManager/debian/tests/0001-Test-running-service-in-plugin-generic.patch @@ -0,0 +1,73 @@ +From 8178037c1e5ecaa8ee3abf5b1d198616f415a772 Mon Sep 17 00:00:00 2001 +From: Lukas Märdian +Date: Thu, 6 Mar 2025 12:57:37 +0100 +Subject: [PATCH] Test running service in plugin-generic + +--- + .../generic/tests/test-service-generic.c | 22 ------------------- + src/plugins/tests/test-fixture.c | 8 ++----- + 2 files changed, 2 insertions(+), 28 deletions(-) + +diff --git a/src/plugins/generic/tests/test-service-generic.c b/src/plugins/generic/tests/test-service-generic.c +index d7bc4e0..131de91 100644 +--- a/src/plugins/generic/tests/test-service-generic.c ++++ b/src/plugins/generic/tests/test-service-generic.c +@@ -48,28 +48,6 @@ test_enable_disable (TestFixture *fixture) + /* Ensure no modem is modem exported */ + test_fixture_no_modem (fixture); + +- /* Set the test profile */ +- test_fixture_set_profile (fixture, +- "test-enable-disable", +- "generic", +- (const gchar *const *)ports); +- +- /* Wait and get the modem object */ +- obj = test_fixture_get_modem (fixture); +- +- /* Get Modem interface, and enable */ +- modem = mm_object_get_modem (obj); +- g_assert (modem != NULL); +- mm_modem_enable_sync (modem, NULL, &error); +- g_assert_no_error (error); +- +- /* And disable */ +- mm_modem_disable_sync (modem, NULL, &error); +- g_assert_no_error (error); +- +- g_object_unref (modem); +- g_object_unref (obj); +- + /* Stop port context */ + test_port_context_stop (port0); + test_port_context_free (port0); +diff --git a/src/plugins/tests/test-fixture.c b/src/plugins/tests/test-fixture.c +index 29eb8d5..9744185 100644 +--- a/src/plugins/tests/test-fixture.c ++++ b/src/plugins/tests/test-fixture.c +@@ -29,11 +29,9 @@ test_fixture_setup (TestFixture *fixture) + * to the right directory. */ + g_test_dbus_add_service_dir (fixture->dbus, TEST_SERVICES); + +- /* Start the private DBus daemon */ +- g_test_dbus_up (fixture->dbus); +- + /* Create DBus connection */ +- fixture->connection = g_bus_get_sync (G_BUS_TYPE_SESSION, NULL, &error); ++ /* Use system bus of installed components on running system */ ++ fixture->connection = g_bus_get_sync (G_BUS_TYPE_SYSTEM, NULL, &error); + if (fixture->connection == NULL) + g_error ("Error getting connection to test bus: %s", error->message); + +@@ -73,8 +71,6 @@ test_fixture_teardown (TestFixture *fixture) + if (fixture->test) + g_object_unref (fixture->test); + +- /* Stop the private D-Bus daemon; stopping the bus will stop MM as well */ +- g_test_dbus_down (fixture->dbus); + g_object_unref (fixture->dbus); + } + +-- +2.43.0 + diff --git a/packaging/ModemManager/debian/tests/control b/packaging/ModemManager/debian/tests/control new file mode 100644 index 0000000..d493e96 --- /dev/null +++ b/packaging/ModemManager/debian/tests/control @@ -0,0 +1,26 @@ +Test-Command: mmcli --help +Restrictions: superficial +Depends: + modemmanager, + +Test-Command: /usr/sbin/ModemManager --help +Restrictions: superficial +Depends: + modemmanager, + +Test-Command: pkgconf --libs --cflags mm-glib +Restrictions: superficial +Depends: pkgconf, + libmm-glib-dev, + +Tests: smoke +Depends: @ +Restrictions: needs-sudo, breaks-testbed, superficial + +Tests: unittests +Depends: @, @builddeps@ +Restrictions: needs-sudo, breaks-testbed + +Tests: mmutils +Depends: @, @builddeps@, dpkg-dev +Restrictions: needs-sudo, breaks-testbed diff --git a/packaging/ModemManager/debian/tests/launch-mm.sh b/packaging/ModemManager/debian/tests/launch-mm.sh new file mode 100644 index 0000000..1977d03 --- /dev/null +++ b/packaging/ModemManager/debian/tests/launch-mm.sh @@ -0,0 +1,9 @@ +#!/bin/bash +# allow ModemManager to start inside a container +sudo mkdir -p /etc/systemd/system/ModemManager.service.d/ +cat < output.txt +cat output.txt +grep "text: Hello ModemManager!" output.txt +grep "number: 0123456789" output.txt +grep "encoding: GSM7" output.txt +grep "smsc: +4242" output.txt + +# confirm MM_CANDIDATE udev rules are properly installed +# NB: We need a properly working udev to execute this test, which is not +# not available in LXC containers (such as DebCI). +if [[ $(systemctl is-active systemd-udevd.service) == "active" ]]; then + LD_PRELOAD="$LIBMM" test/mmrules -v -p /usr/lib/udev/rules.d/ +else + echo "SKIP: 'mmrules' due to inactive udev ..." +fi + +# connect to ModemManager.service, listening for incoming SMS +# NB: We will not receive anything, but can make sure the service is running +# properly and we can connect through the expected interfaces. +source ../tests/launch-mm.sh + +# listen to smsmonitor for 2 sec +LD_PRELOAD="$LIBMM" test/mmsmsmonitor 2>&1 & pid=$! +sleep 2 && kill -HUP $pid 2>&1 diff --git a/packaging/ModemManager/debian/tests/smoke b/packaging/ModemManager/debian/tests/smoke new file mode 100644 index 0000000..7f8d5cd --- /dev/null +++ b/packaging/ModemManager/debian/tests/smoke @@ -0,0 +1,7 @@ +#!/bin/bash +set -e + +source debian/tests/launch-mm.sh + +mmcli -B # log MM version +mmcli -L # query modems diff --git a/packaging/ModemManager/debian/tests/unittests b/packaging/ModemManager/debian/tests/unittests new file mode 100755 index 0000000..4a471f3 --- /dev/null +++ b/packaging/ModemManager/debian/tests/unittests @@ -0,0 +1,23 @@ +#!/bin/bash +set -e + +source debian/tests/launch-mm.sh + +# stop ModemManager and make sure it's inactive +sudo systemctl stop ModemManager.service +[[ $(systemctl is-active ModemManager.service) == "inactive" ]] || exit 1 + +# Patch some tests to use the installed ModemManager.service +patch -p1 < debian/tests/0001-Test-running-service-in-plugin-generic.patch +meson setup debian/build + +# filter tests that do not need sudo powers +test_list=$(meson test --list -C debian/build) 2> /dev/null +test_list=${test_list//test-plugin-generic} +meson test $test_list -C debian/build + +# sudo needed for this specific test to dbus-launch the installed ModemManager.service +sudo meson test test-plugin-generic -C debian/build + +# make sure ModemManager got dbus-activated by the unit-tests +[[ $(systemctl is-active ModemManager.service) == "active" ]] || exit 1 diff --git a/packaging/ModemManager/debian/upstream/metadata b/packaging/ModemManager/debian/upstream/metadata new file mode 100644 index 0000000..b137352 --- /dev/null +++ b/packaging/ModemManager/debian/upstream/metadata @@ -0,0 +1,5 @@ +--- +Bug-Database: https://gitlab.freedesktop.org/mobile-broadband/ModemManager/issues/ +Bug-Submit: https://gitlab.freedesktop.org/mobile-broadband/ModemManager/issues/new +Repository: https://gitlab.freedesktop.org/mobile-broadband/ModemManager.git +Repository-Browse: https://gitlab.freedesktop.org/mobile-broadband/ModemManager diff --git a/packaging/ModemManager/debian/upstream/signing-key.asc b/packaging/ModemManager/debian/upstream/signing-key.asc new file mode 100644 index 0000000..1310c2b --- /dev/null +++ b/packaging/ModemManager/debian/upstream/signing-key.asc @@ -0,0 +1,92 @@ +-----BEGIN PGP PUBLIC KEY BLOCK----- + +mQGiBElVP3gRBADLxXlEnz7zt/hlWuqMIY7EmH1jkvyqFNkG3O7KrN09Kq7X6ovb +Kg14Ou4E6hdaTdvnaDinOU33e0SD1IDIQUjindd0iSEi7K61qD9oQH1LVkNK5Kww +DOfxuEi3K3PkESrHbZ6y/5WjNo5FDRgByJ/Eyh9RMWEMnLp0Zo+HZ7tZtwCgho2F +VFUlAWk91Owg/idPC7lf9gcEALzx2VBFE86YAv4wbCjuuIdqjl9ceUvIOLZWTlrY +c4KFdBEF6NZ0BPP1Ck0AjKoJyZk3NcCGfNwqbtxxVyaMdcI/PDSuSWO5aC/ZuTUv +Sr8cjh4i7Sqa5YXQOwOXHXImvHhFwo2lW2sdIaAeDwJxIfaDUyFuVKvY/sK+Ajxr +PQ1QA/44JVKaxqLYL3rf43EwE0cnSbVThAAa/F5pKXcSYwAG0WxSERzEWvnFk1ea +y766n7yIrylwoaHeTirXxyRTXvY0VTpKEPkROuKKv7nLC6SEvyH8Z8W7a999Ctw0 +gCjtB6dEuFYRdS2fSZ88cQeEjBBLiJCDpG9Fvwb4g+y7gl5+h7QtQWxla3NhbmRl +ciBNb3JnYWRvIDxhbGVrc2FuZGVyQGFsZWtzYW5kZXIuZXM+iGAEExECACAFAklV +P3gCGyMGCwkIBwMCBBUCCAMEFgIDAQIeAQIXgAAKCRA8rVM5iXP/+vWXAJ4hViyk +is1lA7YBUISNEor/erPEkgCfWO5AuVhh5nZNM5GUwCtwOovSui65BA0ESVU/eBAQ +ALmjzgIjhR/Ppv0aQIxKtE9HiGWMoDF1SSpz99DEYnTwNgXBM+vMTxCGyoMl1nFQ +mY+rIoMBTAE8r50eDP6zBYNgrNVx0yl0cCWX4n8WbAxF2iQ2oIetzugaQlU20TwN +ccqF4w0QdkDCLedlsxKhiXyx57LIzWGCTcOVbIZB9C2gFqWUSyM2zT6LgQ6NKjWk +VZVJCUvwZGrh4VPIG8TVDC/oXujIazdMTwfjyPHLVTNFQSuhRKfWop/sKkm0JrC2 +4Keo7Eg9f9hdIysqHv3PCYa3oX15agUALldmVCdiDmLd+vuN1Ivpo78KZ/nh9t2/ +DN24HFUi6yArPzNwJqg4WNW5ef0q7/np+vIY1sr382c9/2GF2Ir+d0TkTVz+kKaj +58qcKxMxVgytR2jVr7Z/elLSxm+Oo0piFN9ExJyGT/x57899tEavtJk/f/lV8VTW +MrqgbooP1HinXbOKOVOPwASUGJ89FILNxriFGt9o9GoJirr4cIMcb6WVirwgJe7+ +UUYg+51OC8xkQS63QHircaKL1U1d5iXy2P6Vm1zp2p2ZGvybHg5Ucy3HXa82FrE7 +TA+AfyljMoMpUdHWPU9fqW+NhQbfwzDnYJZzi1V/XbTHcAUHD66+QDihwKsgZz4R +gL+Iv6lzdXz2Is/F9WvS2WR+K5teMXq4cCOATYzZO9qPAAMFEACMNPM1M2nTS93K +eEwmr2eKhR1PRGOyRdlvicaHZiqCYX8km7NhSD3SEKdX553HGN/8Te5QQc3XagPi +4dDX7uPZjg5ueZwg0aDOtnLzlopCgfYZOLyn1LdPFKZ+bEkpbtLI3WbPRvQXjPLI ++Cuq7CWCUJSY9qruks21qu3Yhjay8wYZIGqYYd1lh+Aiqy7RUeNDfdPjMZD4et4n +unKcBW4S5uQ2QyMHYNEbY4BpR4Tc88Sn1/PshNaRKqTeRi/FEgmnu8TwHkMQhVM2 +wxrrFKC8V1XUlIHOKY0ictodwd3ysUcFwXTqwqvgxV+UlMFiRWcT7XY1c+LuexFq +jF9IxbY5oMMP8GgS59yR5bk21ikk9swIfbSx70JXWL99H9vNPgh99cUS110xJKg9 +NShEjWdCdGj3TdsUJ2NEjJYjKO6VTnkjLGdZxvm5p9lFLMDbWdvwllDAT41txfF5 +0qhTwHNSpHMBO545Jeot5OUCk8hq9gnAISxubRT4zClr3IQqZVhkdr3+3bkywLTd +TetzCP/wqy6RpEtSOc5bYQQ4P44mlMfASqI1Bi09hlM64F/je/yhjPqVHBcSy00v +H+ctRQNxLGBQmJtRn3iqqfM0qa02k2PzDMQHbvgHZcjNTk9VPLVHhRAD36VHNMfz +iZSvJJW5xiQg9DqbQ9TaO3uBTTyJQ4hJBBgRAgAJBQJJVT94AhsMAAoJEDytUzmJ +c//6dfsAoIEOqluSj6fzW8+q9jPLT6719QaeAJ9MlO1jXSCeQ7z95/Lun1+jWn2j +sg== +=J+xH +-----END PGP PUBLIC KEY BLOCK----- +-----BEGIN PGP PUBLIC KEY BLOCK----- + +mQINBGMaVyoBEACiF6hvaBfQhFxSI//OQ0BVJ7iNVdBErGIOybyiltWgH6ApULEG +MnHCHoiLAjpgmwydiluFQaQN4bt7AR1z1g82djJZ6RvknFhIMc+iIFJOnhEq6Xea +lHnUC/8X7oQGdf80MvsA1Njd57XX/rqV2xpdP/WpVOljCvuNbTUsgXUWmAKBHFc/ +gkpVQTWK3eJS8uFxPJTXjzqv7N3/C+OvwIO1YhswHyDHnyLH3+mD2y7PKy/PE+ay +7e/gTaJmchSDN8fJrVOnasTRSloGf404AUYitSJ03VPSXz8uz+TjyHfabgmeruk+ +8cpvDiqU6UQbUxftiCOPyiiqs/HI6vi1Gqn05LJDVZSNLUV/W/JXUxwGQic02m+6 +O+GoezNIotFCFV4gPbIsEAvJpH/XOMq1pwz/1+iHOt+F6m9o8P/DpbhJ80gBIubg +UP50mDSw7Ceu6O39YaEK+cKqZGO0ruMcArpScDxuQPpgzaxQmgXjPBGtg4MsGAe2 +puRszqbziMi91q18PH9CGYUgev7kFCFIQLe1HN+P2XkTDExfwOwktPXUuWphdnij +ov+Wuf9FvzHz2AUtWJT7pcRiOLaHBEGC7T4hy8kN+Ek3tClB+/LZOYwE3NEI+tRc +PjKoasxGM7RrBpbV8+1iReB/NcLxPAg6cb0L0SFhdv/6Ek3FuIZ06MFmowARAQAB +tC5BbGVrc2FuZGVyIE1vcmdhZG8gPGFsZWtzYW5kZXJtakBjaHJvbWl1bS5vcmc+ +iQJHBBMBCAAxFiEEqBTQm5xbwBlFpkMIrs4COcZgatUFAmMaVywCGwMECwkIBwUV +CAkKCwUWAgMBAAAKCRCuzgI5xmBq1S6fD/4tWziXt1DN0nb7OI1SscvPQGVnMf5q +rlRJJPHkmrUUkFomKs2PIZD9LmvT09j/QdbjNThl+yTxqLOHnS4Gl0LNOh1fjZeB +575uDkpffj0m7deHTIU93V2V+BOhtMU0df1KTL3CU7ySbP/GkxKp7GKJpst4ouYz +/Rz8SybIY8cgd2sGBJiH8ShixifZNtlp9MgE1YylZ0pHA+mf+S3zyAozo3baFGrC +6Iw0nHZkTKlq7SN+DP5/ZiMZ/x0hvexcq9MLmSBccvIh6hHitkaYjFe+PnntLi83 +DhvBO63SdamtsrdHY0SUi1UHrka6LvP2sCcBcqE00uFwifBFaF+QYAbamGc9SOcy +hsiCRM7kPkklipJkTp4dBBogV6ExA8OSJnQMhK2T3ygJLNCAhbFOsX9mtWAk2ZtQ +P/XrbeoSsgmKR0Ye0AzOVY9wk2HMsPPiRAydz8w8UWfJ09yXmzhbZARciM7QXaoY +AQiyCKdiTGye80SEjDc9cbQxP1YUUaz8lEMkjcRT5bE0w6RiYZkUM/en5mbygt29 +QDimDX+62k102lfH+gRJenhuFZ5EfXecCB8ia/lwnHgy5atZ0D6R4yKNlL499FAx +7d9pia7Y4umfnm5p+MLme5w++847lAo/BZyjl4OKNLFKNCM608U27kMbIXT/weMN +PNOV8lCEoBs3L7kCDQRjGlcsARAAvfIXEKFcKR12BlhjEXOMbvfnyN9zzGdS2JuD +2ibvnYX6ou3gB4fG1PFeYkCBGyHMKAjKCbfXkxH5iVKku+ykT8qkwa+qs4FK6C0s +fsPWvEF2qJZdROOR62lBIct2rXM3EoEEl47QxJSsvICIXTKUOf/d+BMxauGxPw1J +ZCpZPx5V1eeWJ1exRQqt3IsLlrhBulnMcaBLeusuKCckAH5JfhiMfqM8v6T+ed6i +baJo+wQwHBFkin9GqFqOmEaSYLmt/AmvIfE4KVJ0BZrhqz/feKw5QbnC3ffwQzLV +TPWYi/4CDWGjkwILac1VCPKR7YA1DY5/eAqThGMQoLe5Bk2ObTx89dT9bq/poQTG +QOdcaat2ZaBoDVA2PSMNVSSXjK/cP8UJOsDpxo12K05FLOJogQBf8L4xKUQKYv8z +EI1BiGduyS//k9dkUEa9Pi5DqPLXmrrHBuVK/Ikds7C18lQwZpaaOl2fZhmLPwyK +q36ch2+KiF7XFyTDYEqA/kb4Tfkm2WzrNvawj7hx4avFFeo7LAnvMRy4JT8STaxx +KL8TvxCsJzu+CsLMcNykDKb18Ez3TWc79NIAkrhbgVFPWVmQQaaCqrvEs17ggQXU +KiFt24zFBJNFHq6+W+9Nm2FAPx7pqUQDUaoZMzGAs9zPQEACRvDVSAmHXdSQ3Bu/ +we1hBE8AEQEAAYkCNgQYAQgAIBYhBKgU0JucW8AZRaZDCK7OAjnGYGrVBQJjGlcs +AhsMAAoJEK7OAjnGYGrVQmIP/1tNstIYqsI9Q/FRFp3u1qA/Wr8DoJloNk0AKuvT +LVimFgXyQX1Lepn8BpJwsmDk3820nfJ9gwus5Ha5Cu5H26Ssg8WEGH6tzETJf/lI +lZnucem31Bxo+BnyTVQAulcYXc7NewoM+2zAepL3HNtvYrrBBIL/NAOZQwJrHK25 +G95QQVucubuj3j3/D2Ve2ezPuqXOXTvSls06xk3agcyhWuIY5QMdQq8O2ya1Hxy5 +w/Z4oS5UFOyoVs9ngXdtFmZMA+TjYRl03nYq4ploCaTyQS6GkgxVDsITlSkJlXmH +9Z9OR8atwyL7/NVu0JqeolMlqI08O3Uf/fdiDWAR4vAogqWM2fsnK9Ur9ToRyQ/K +Pdcv6ZuaEAVbqSVIuAcq5mtEYpM2TjsYuLGwHFHa/d77Tw8+qNx/z7b3KaWYXoj5 +SYbigkYvB4Mynmg0NOR91iRMCElklSTOqF4XvzXuhrsKxdZwmUFtAWUU5iylPaXx +chSDxWVEj//NPwXuUVcgb2BlDV6EdlLRVLhm1uFiRBiQ/la1yje6mFTUMc5Wf303 +iVfQknPDTOIdqoOQNtJo4vwJZH+IsAD1DtnTXahD+Gy2cYdtpG0XKzkF/IneQqzI +g3cO7H5+wW3dks6wOEBmeEuAi38wsG412e3LnkWLRFjyntenXZVDnMXuyDCG7Pn0 +MGUY +=+Ikk +-----END PGP PUBLIC KEY BLOCK----- diff --git a/packaging/ModemManager/debian/watch b/packaging/ModemManager/debian/watch new file mode 100644 index 0000000..86a2bc4 --- /dev/null +++ b/packaging/ModemManager/debian/watch @@ -0,0 +1,4 @@ +version=4 +opts="mode=git,pgpmode=gittag,uversionmangle=s/-(alpha|beta|rc)/~$1/" \ + https://gitlab.freedesktop.org/mobile-broadband/@PACKAGE@.git \ + refs/tags/(\d+\.\d?[02468][\.\-].*) diff --git a/packaging/README.md b/packaging/README.md index 9259219..76c04cb 100644 --- a/packaging/README.md +++ b/packaging/README.md @@ -14,12 +14,53 @@ from CI artifacts; nothing is published to `apt.ceralive.tv` in Phase A. | libqrtr-glib | QRTR (IPC router) GLib bindings | Each is rebuilt from its pinned, provenance-verified upstream release and its pinned -Debian packaging tag. The authoritative pin manifest (`upstream-pins.yaml`) and the -provenance-verification script land in a later task; this directory currently holds the -CI scaffold only. +Debian packaging tag. The authoritative pin manifest is [`upstream-pins.yaml`](upstream-pins.yaml), +re-verified end-to-end by [`ci/verify-upstream-pins.sh`](ci/verify-upstream-pins.sh). -> **Status:** `[GREENFIELD]` — scaffold only. Real `debian/` recipes, the pin manifest, -> and the container build order arrive in the packaging wave. +> **Status:** `[EXISTS]` — provenance pin manifest + verifier, the four `debian/` recipes, +> the bootstrap-order container build (`ci/build-bookworm.sh`, amd64 + QEMU arm64), and the +> package **contract suite** (`ci/test-package-contract.sh`) + **daemon smoke** +> (`ci/daemon-smoke.sh`) all landed. Both arches build clean with the exact 9-package runtime +> closure; the contract suite is green on amd64 (full) and arm64 (metadata). The +> deb-artifact + per-release-manifest job runs inside `release.yml`'s `build-deb`. + +## Recipes & build + +Each source's `debian/` dir is checked in at `/debian/` (`ModemManager`, `libmbim`, +`libqmi`, `libqrtr-glib`), copied byte-for-byte from its pinned salsa commit +([`upstream-pins.yaml`](upstream-pins.yaml) `salsa_commit_sha`) with **zero source patches**. +Only ModemManager carries bookworm adaptations (debhelper relax, `systemd-dev → udev`, and +systemd/udev install-dir pins) — all documented, with rationale, in +[`BOOKWORM-ADAPTATIONS.md`](BOOKWORM-ADAPTATIONS.md). + +[`ci/build-bookworm.sh`](ci/build-bookworm.sh) `` rebuilds all four in a +`debian:bookworm` container in the mandatory bootstrap order +`libqrtr-glib → libmbim → libqmi → modemmanager`. Each source's freshly built `.deb`s feed a +temporary **local apt repo** so the next source resolves its build-deps against them (not the +older bookworm-main versions). Arches are **native** — amd64 directly, arm64 via full-system +QEMU (`--platform linux/arm64`) — never cross-built. The script injects +`~ceralive0.0.0~dev` (via [`ci/inject-deb-version.sh`](ci/inject-deb-version.sh)), runs real +`dpkg-buildpackage`, and asserts the **9-package runtime closure** from the `.changes` +(drift ⇒ non-zero exit). `.deb`s land in the gitignored `build//`. + +## Provenance pins + +[`upstream-pins.yaml`](upstream-pins.yaml) pins each source to an exact upstream release +tag and its matching Debian (salsa) packaging tag, and records a three-link chain: + +1. **Lineage** — the upstream git tag object + peeled commit SHA (re-resolved by + `git ls-remote`; the tag is never byte-compared to a git archive). +2. **Authority** — the signed Debian `.dsc`, GPG-verified against a pinned signer + fingerprint whose armored key lives in [`keys/`](keys/) (acquisition documented in + [`keys/README.md`](keys/README.md)). A verified `.dsc` is the authority for its tarball + checksums. +3. **Artifact** — the `.orig.tar`, whose sha256 must equal the pin, which equals the + `.dsc`'s own `Checksums-Sha256` entry (copied verbatim into the manifest). + +`ci/verify-upstream-pins.sh` re-checks all of it in an **isolated** `GNUPGHOME` (never the +caller's `~/.gnupg`) and fails closed with a NAMED field on any drift. The current pins are +ModemManager 1.24.0, libmbim 1.32.0, libqmi 1.36.0, libqrtr-glib 1.2.2 (salsa +`debian/-1`). ## Versioning @@ -33,4 +74,10 @@ CI scaffold only. | [`ci/tag-guard.sh`](ci/tag-guard.sh) | The release-tag contract: accepts only `vX.Y.Z`, fails closed on pre-release / build-metadata / missing-`v`. Sourced by `release.yml` (job 1) and by the version-injection + test scripts. | | [`ci/test-tag-guard.sh`](ci/test-tag-guard.sh) | Executable proof of the tag-guard negatives (`v1.0.0-rc.1`, `v1.0.0+build5`, `1.0.0`, …). Run in CI and locally. | | [`ci/inject-deb-version.sh`](ci/inject-deb-version.sh) | Writes `-~ceralive` (or `~ceralive0.0.0~dev` for non-tag builds) into each source's `debian/changelog` top entry via `dch --force-bad-version`. Reads upstream versions from each source's changelog — never hardcoded here. | -| [`ci/contract.sh`](ci/contract.sh) | The packaging **container lane** (bookworm) entry point. Wave-A1 stub: asserts the scaffold is present and the tag-guard + version-injection scripts are wired. Real contract tests (metadata / closure / upgrade / rollback / ordering / daemon smoke) land with the recipes. | +| [`ci/verify-upstream-pins.sh`](ci/verify-upstream-pins.sh) | Re-verifies every field of `upstream-pins.yaml` in an isolated `GNUPGHOME`: git-tag lineage (`git ls-remote`), `.dsc` GPG signature vs pinned signer, `.dsc` checksums vs manifest, and the downloaded `.orig.tar` sha256. Exit 0 on success; non-zero with a NAMED failing field on any drift. | +| [`ci/test-verify-upstream-pins.sh`](ci/test-verify-upstream-pins.sh) | Offline fail-closed proof: runs the three [`ci/fixtures/`](ci/fixtures) tampers (wrong-signer / altered-`.dsc` / altered-`.orig.tar`) and asserts each is rejected on the correct named field. Run standalone; the packaging-wave container lane can adopt it. | +| [`ci/build-bookworm.sh`](ci/build-bookworm.sh) | Rebuilds all four sources in a `debian:bookworm` container in bootstrap order via a temporary local apt repo. `build-bookworm.sh ` — native amd64 or full-system-QEMU arm64, never cross-built. Fetches + sha256-verifies each pinned `.orig.tar`, overlays the checked-in `debian/`, injects the version (`RELEASE_VERSION=vX.Y.Z` → `~ceraliveX.Y.Z`; unset → `~ceralive0.0.0~dev`) into a **copy** of each changelog, runs real `dpkg-buildpackage`, and asserts the 9-package runtime closure from the `.changes` (drift ⇒ non-zero). Output to gitignored `build//`. | +| [`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; clean-bookworm `apt-get install ./*.deb`; upgrade (stock 1.20.4 → ceralive set); 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. 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` == 1.24.0; the udev-rules + FCC-unlock dispatcher dirs exist; the GIR typelib (`gir1.2-modemmanager-1.0`) and Vala `.vapi` (`libmm-glib-dev`) are installed. amd64 by default. | +| [`ci/generate-release-manifest.sh`](ci/generate-release-manifest.sh) | Emits the **per-release manifest** (`generate-release-manifest.sh ` → `dist/release-manifest.txt`) mapping the release tag to the 9 runtime deb versions **per arch** — the `arch package source version filename sha256` matrix Phase-B apt publication consumes. dpkg-free (filename parse + `sha256sum`), so it runs anywhere. | diff --git a/packaging/ci/build-bookworm.sh b/packaging/ci/build-bookworm.sh new file mode 100755 index 0000000..fee7cc0 --- /dev/null +++ b/packaging/ci/build-bookworm.sh @@ -0,0 +1,253 @@ +#!/usr/bin/env bash +# build-bookworm.sh — rebuild the ModemManager stack for bookworm. +# +# WHAT IT DOES +# Builds the four provenance-pinned sources (upstream-pins.yaml) in the mandatory +# bootstrap order libqrtr-glib -> libmbim -> libqmi -> modemmanager inside a +# `debian:bookworm` container. Each source's freshly built .debs are dropped into a +# temporary LOCAL apt repo (dpkg-scanpackages + `deb [trusted=yes] file:` line) so the +# NEXT source's build-deps resolve against the just-built package, not the older +# bookworm-main version. Real `dpkg-buildpackage` — no faking. +# +# ARCHES (native — never cross) +# amd64 -> `--platform linux/amd64`; arm64 -> `--platform linux/arm64` (full-system QEMU +# via the host's binfmt_misc `qemu-aarch64` handler — a genuine aarch64 userland, not a +# cross-compile). +# +# INPUTS (all under packaging/, this script's parent) +# /debian/ the pinned salsa debian/ dir + the bookworm adaptations (ModemManager +# only: debhelper relax, systemd-dev->udev, and the systemd/udev +# install-dir rules pins — see packaging/BOOKWORM-ADAPTATIONS.md). +# upstream-pins.yaml orig_tar_url / orig_tar_name / orig_tar_sha256 per source. +# ci/inject-deb-version.sh writes ~ceralive0.0.0~dev (dev build) into each changelog. +# +# OUTPUT +# Binary .debs + the four *.changes into $OUT (default: packaging/build/, gitignored). +# The runtime closure is asserted from the *.changes: EXACTLY the 9 runtime packages +# (modemmanager libmm-glib0 libmbim-{glib4,proxy,utils} libqmi-{glib5,proxy,utils} +# libqrtr-glib0). Any drift => exit 3 (STOP-and-surface). +# +# USAGE +# packaging/ci/build-bookworm.sh amd64 +# packaging/ci/build-bookworm.sh arm64 +# OUT=/some/dir packaging/ci/build-bookworm.sh amd64 # override output dir +# +# EXIT +# 0 success (all 4 built, closure == the 9). 2 usage/env. 3 closure drift. non-zero build fail. +set -euo pipefail + +# ------------------------------------------------------------------------------------------ +# Bootstrap order + the 9-package runtime closure are contract constants. +BUILD_ORDER=(libqrtr-glib libmbim libqmi ModemManager) +EXPECTED_RUNTIME=(libmbim-glib4 libmbim-proxy libmbim-utils libmm-glib0 libqmi-glib5 \ + libqmi-proxy libqmi-utils libqrtr-glib0 modemmanager) + +# Map a packaging dir name -> its upstream-pins.yaml source key (only ModemManager differs). +pin_key() { case "$1" in ModemManager) echo modemmanager ;; *) echo "$1" ;; esac; } + +# ========================================================================================== +# HOST ROLE — arg parse, launch the container, then post-process the results it wrote. +# ========================================================================================== +if [ "${BUILD_IN_CONTAINER:-0}" != "1" ]; then + ARCH="${1:-}" + case "$ARCH" in + amd64) PLATFORM="linux/amd64" ;; + arm64) PLATFORM="linux/arm64" ;; + *) echo "usage: build-bookworm.sh " >&2; exit 2 ;; + esac + + command -v docker >/dev/null 2>&1 || { echo "build-bookworm: docker not found" >&2; exit 2; } + + HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + PKG_ROOT="$(cd "$HERE/.." && pwd)" + OUT="${OUT:-$PKG_ROOT/build/$ARCH}" + mkdir -p "$OUT" + # Clean any prior artifacts so the closure check sees only this run's output. + rm -f "$OUT"/*.deb "$OUT"/*.changes "$OUT"/*.buildinfo 2>/dev/null || true + + echo "build-bookworm: arch=$ARCH platform=$PLATFORM" + echo "build-bookworm: packaging root=$PKG_ROOT" + echo "build-bookworm: output=$OUT" + + # Mount packaging/ read-only at /pkg; output read-write at /out. Re-invoke self in-container. + # RELEASE_VERSION (optional): a vX.Y.Z tag → build ~ceraliveX.Y.Z debs. Unset → dev build + # (~ceralive0.0.0~dev). release.yml passes the release tag here; local/CI dev builds omit it. + docker run --rm --platform "$PLATFORM" \ + -e BUILD_IN_CONTAINER=1 \ + -e ARCH="$ARCH" \ + -e RELEASE_VERSION="${RELEASE_VERSION:-}" \ + -v "$PKG_ROOT":/pkg:ro \ + -v "$OUT":/out \ + debian:bookworm \ + bash /pkg/ci/build-bookworm.sh "$ARCH" + + echo "build-bookworm: container finished; artifacts in $OUT" + exit 0 +fi + +# ========================================================================================== +# CONTAINER ROLE — the real build, inside debian:bookworm. +# ========================================================================================== +ARCH="${ARCH:-$(dpkg --print-architecture)}" +echo "== in-container build (arch=$(dpkg --print-architecture), target=$ARCH, $(uname -m)) ==" + +export DEBIAN_FRONTEND=noninteractive +# nocheck: skip the upstream test phase (needs a live session bus; that is A5.2 daemon-smoke, +# not a build-time concern). nodoc: skip gtk-doc (arch:all -doc pkgs are not in the +# runtime closure). Both are standard for a binary rebuild. +NPROC="$(nproc)" +export DEB_BUILD_OPTIONS="nocheck nodoc parallel=$NPROC" +# dch (version injection) needs a maintainer identity; the container has none by default. +export DEBEMAIL="ci@ceralive.tv" +export DEBFULLNAME="CeraLive CI" + +log() { echo " [build] $*"; } +step() { echo; echo "==== $* ===="; } + +# apt drops to the unprivileged `_apt` user for acquire, which cannot read the local +# file: repo under a 0700 mktemp dir — turn the sandbox off (standard container fix). +echo 'APT::Sandbox::User "root";' > /etc/apt/apt.conf.d/01-no-sandbox + +step "install build tooling" +apt-get update -qq +apt-get install -y -qq --no-install-recommends \ + build-essential dpkg-dev devscripts equivs \ + meson ninja-build pkgconf ca-certificates curl xz-utils bzip2 >/dev/null +DPKGBP_VER="$(dpkg-buildpackage --version 2>/dev/null | sed -n '1p' || true)" +log "toolchain ready: $DPKGBP_VER" + +# ---- writable copy of the packaging tree (source of truth is the ro mount) --------------- +WORK="$(mktemp -d /tmp/mmbuild.XXXXXX)" +PKGW="$WORK/pkg" +cp -a /pkg "$PKGW" +REPO="$WORK/repo" # the temporary LOCAL apt repo +mkdir -p "$REPO" +: > "$REPO/Packages" # empty index so `apt-get update` is happy before build 1 +echo "deb [trusted=yes] file:$REPO ./" > /etc/apt/sources.list.d/local-mm.list +# Prefer the freshly built local packages over bookworm-main for the stack's own libs. +cat > /etc/apt/preferences.d/local-mm.pref <<'EOF' +Package: * +Pin: origin "" +Pin-Priority: 1001 +EOF +apt-get update -qq + +refresh_repo() { + ( cd "$REPO" && dpkg-scanpackages -m . /dev/null > Packages 2>/dev/null ) + apt-get update -qq +} + +# ---- version injection: A1.1's script, run against the writable packaging copy ----------- +# RELEASE_VERSION set (a vX.Y.Z tag) → inject ~ceraliveX.Y.Z; unset → the dev suffix. Either +# way this runs against the WRITABLE copy ($PKGW), never the committed source-of-truth tree. +INJECT_ARG="${RELEASE_VERSION:-}"; [ -n "$INJECT_ARG" ] || INJECT_ARG="--dev" +step "inject version ($INJECT_ARG) via ci/inject-deb-version.sh" +( cd "$PKGW" && bash ci/inject-deb-version.sh "$INJECT_ARG" ) + +# ---- tiny pin reader (same awk shape as verify-upstream-pins.sh) ------------------------- +pin_scalar() { + awk -v src="$1" -v key="$2" ' + $0 ~ "^ " src ":[ \t]*$" { inblk=1; next } + inblk && /^ [^ ]/ { inblk=0 } + inblk && /^[^ ]/ { inblk=0 } + inblk && $0 ~ "^ " key ":" { + v=$0; sub("^ " key ":[ \t]*", "", v); gsub(/^"|"$/, "", v); print v; exit + } + ' "$PKGW/upstream-pins.yaml" +} + +# ---- build one source ------------------------------------------------------------------- +build_one() { + local dir="$1" key; key="$(pin_key "$dir")" + step "BUILD $dir (pin key: $key)" + + # Resolve the injected source + upstream version from the (now version-injected) changelog. + local src ver upstream + src="$(dpkg-parsechangelog -l "$PKGW/$dir/debian/changelog" -S Source)" + ver="$(dpkg-parsechangelog -l "$PKGW/$dir/debian/changelog" -S Version)" + upstream="${ver%%-*}" # strip -~ceralive... + log "source=$src version=$ver upstream=$upstream" + + # Fetch + verify the provenance-pinned orig tarball. + local url name sha + url="$(pin_scalar "$key" orig_tar_url)" + name="$(pin_scalar "$key" orig_tar_name)" + sha="$(pin_scalar "$key" orig_tar_sha256)" + [ -n "$url" ] && [ -n "$name" ] && [ -n "$sha" ] || { echo "missing pin for $key" >&2; exit 2; } + log "orig: $name" + curl -fsSL --retry 3 --retry-delay 2 -o "$WORK/$name" "$url" + local got; got="$(sha256sum "$WORK/$name" | awk '{print $1}')" + [ "$got" = "$sha" ] || { echo "STOP: orig sha256 drift for $name (pin $sha, got $got)" >&2; exit 3; } + log "orig sha256 OK ($sha)" + + # Assemble the build tree: -/ with debian/ overlaid; orig in the parent. + local bdir="$WORK/build" + mkdir -p "$bdir" + local tree="$bdir/${src}-${upstream}" + rm -rf "$tree"; mkdir -p "$tree" + tar -xf "$WORK/$name" -C "$tree" --strip-components=1 + cp -a "$PKGW/$dir/debian" "$tree/debian" + # Non-native 3.0 (quilt): dpkg-source wants ../_.orig.tar.. + cp "$WORK/$name" "$bdir/${src}_${upstream}.orig.${name#*.orig.}" + + # Resolve build-deps against bookworm-main + the local repo (freshly built deps). + log "apt-get build-dep (resolves against local repo for stack deps)" + apt-get build-dep -y --no-install-recommends "$tree" >/dev/null + + # Real binary build, arch-only (-B): all 9 runtime pkgs are arch-specific; -B skips the + # arch:all -doc pkgs and the -indep DEP-8 patch target. + log "dpkg-buildpackage -B (DEB_BUILD_OPTIONS='$DEB_BUILD_OPTIONS')" + ( cd "$tree" && dpkg-buildpackage -B -us -uc ) + + # dpkg-buildpackage writes artifacts to $bdir (the source tree's PARENT); prior sources' + # debs were already moved to $REPO, so $bdir holds only this source's fresh output. + find "$bdir" -maxdepth 1 -name '*.changes' -exec cp -t /out {} + 2>/dev/null || true + find "$bdir" -maxdepth 1 -name '*.buildinfo' -exec cp -t /out {} + 2>/dev/null || true + find "$bdir" -maxdepth 1 -name '*.deb' -exec cp -t /out {} + 2>/dev/null || true + find "$bdir" -maxdepth 1 -name '*.deb' -exec mv -t "$REPO" {} + 2>/dev/null || true + + refresh_repo + log "$dir built; local repo now has $(ls "$REPO"/*.deb 2>/dev/null | wc -l) .deb(s)" +} + +for d in "${BUILD_ORDER[@]}"; do build_one "$d"; done + +# ---- runtime-closure verification from the four *.changes -------------------------------- +step "runtime closure verification (from *.changes)" +# All binary package names across the 4 binary .changes (RFC822 Binary: field, fold-safe). +mapfile -t ALL_BINS < <( + for ch in /out/*.changes; do + awk ' + /^[A-Za-z][A-Za-z0-9-]*:/ { inb=0 } + /^Binary:/ { inb=1; l=$0; sub(/^Binary:[ \t]*/, "", l); print l; next } + inb && /^[ \t]/ { l=$0; sub(/^[ \t]+/, "", l); print l } + ' "$ch" + done | tr ' ' '\n' | sed '/^$/d' | sort -u +) +# Runtime = not -dev, not -doc, not an auto-generated -dbgsym, not a gir typelib. +RUNTIME=() +for b in "${ALL_BINS[@]}"; do + case "$b" in + *-dev|*-doc|*-dbgsym|gir1.2-*) : ;; + *) RUNTIME+=("$b") ;; + esac +done +mapfile -t RUNTIME_SORTED < <(printf '%s\n' "${RUNTIME[@]}" | sort -u) + +echo "all binary packages produced:"; printf ' %s\n' "${ALL_BINS[@]}" +echo "runtime closure (dev/doc/gir excluded):"; printf ' %s\n' "${RUNTIME_SORTED[@]}" + +expected="$(printf '%s\n' "${EXPECTED_RUNTIME[@]}" | sort -u)" +got="$(printf '%s\n' "${RUNTIME_SORTED[@]}")" +if [ "$expected" = "$got" ]; then + echo "CLOSURE OK: exactly the 9 expected runtime packages." +else + echo "STOP: runtime closure drift." >&2 + echo "--- expected ---" >&2; printf '%s\n' "$expected" >&2 + echo "--- got ---" >&2; printf '%s\n' "$got" >&2 + echo "--- diff (want<->got) ---" >&2; diff <(printf '%s\n' "$expected") <(printf '%s\n' "$got") >&2 || true + exit 3 +fi + +echo +echo "PASS [$ARCH]: 4 sources built in bootstrap order; runtime closure == the 9." diff --git a/packaging/ci/contract.sh b/packaging/ci/contract.sh index 7dbb2bf..b2c509d 100755 --- a/packaging/ci/contract.sh +++ b/packaging/ci/contract.sh @@ -1,17 +1,22 @@ #!/usr/bin/env bash -# contract.sh — the packaging container lane (bookworm) entry point. +# contract.sh — the packaging PR lane (bookworm container, ci-packaging.yml). # -# STUB (Wave A1): the real ModemManager-stack recipes and their contract tests (metadata / -# dependency closure / upgrade / rollback semantics / build ordering / daemon smoke) land in -# the packaging wave. Until then this lane asserts the packaging scaffold is present and that -# the tag-guard + version-injection scripts are wired and pass, so the container lane runs -# something real and stays green. +# This is the LIGHTWEIGHT gate that runs on every packaging PR without building any .deb: +# it needs no docker-in-docker and no built artifacts. It asserts the packaging scaffold is +# present, the tag-guard contract holds, the dch version-injection works on a COPY (never +# the committed source tree), and the tilde version ordering is real. +# +# The HEAVY, deb-consuming contract — metadata / closure install / upgrade / rollback / +# coherence / piuparts and the daemon smoke — lives in ci/test-package-contract.sh and +# ci/daemon-smoke.sh. Those each launch their own debian:bookworm container against the +# A5.1 build output and run inside release.yml's build-deb job (which has the host docker +# daemon), not in this container-based PR lane. set -euo pipefail HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" PKG_ROOT="$(cd "$HERE/.." && pwd)" -echo "packaging contract lane (Wave A1 stub)" +echo "packaging PR contract lane (bookworm)" echo " packaging root: $PKG_ROOT" fail=0 @@ -25,21 +30,68 @@ require() { } require "README.md" +require "BOOKWORM-ADAPTATIONS.md" require "ci/tag-guard.sh" require "ci/test-tag-guard.sh" require "ci/inject-deb-version.sh" +require "ci/build-bookworm.sh" +require "ci/test-package-contract.sh" +require "ci/daemon-smoke.sh" +require "ci/generate-release-manifest.sh" # The tag-guard contract must hold. echo " running tag-guard contract..." bash "$HERE/test-tag-guard.sh" >/dev/null -# The version-injection script must run in dev mode without real recipes present. -echo " running version-injection (dev)..." -bash "$HERE/inject-deb-version.sh" --dev >/dev/null +# Version injection must run WITHOUT mutating the committed debian/changelog files. Now that +# the recipes carry real changelogs (A5.1), `inject-deb-version.sh --dev` would dch-rewrite +# the source-of-truth tree if run in place — so run it against a throwaway COPY and then +# prove the committed changelogs are byte-for-byte unchanged. +echo " running version-injection (dev) on a COPY (committed tree must stay pristine)..." +export DEBEMAIL="${DEBEMAIL:-ci@ceralive.tv}" DEBFULLNAME="${DEBFULLNAME:-CeraLive CI}" +TMP="$(mktemp -d)" +trap 'rm -rf "$TMP"' EXIT +for item in "$PKG_ROOT"/*; do + [ "$(basename "$item")" = build ] && continue # skip the (gitignored) .deb output + cp -a "$item" "$TMP/" +done +if command -v dch >/dev/null 2>&1; then + ( cd "$TMP" && bash ci/inject-deb-version.sh --dev >/dev/null ) + # The copy's changelogs must now carry the dev suffix; the SOURCE tree must not. + for src in ModemManager libmbim libqmi libqrtr-glib; do + cl="$PKG_ROOT/$src/debian/changelog" + [ -f "$cl" ] || continue + if grep -q '~ceralive' "$cl"; then + echo " FAIL: committed $src/debian/changelog was mutated by injection"; fail=1 + fi + if ! grep -q '~ceralive0.0.0~dev' "$TMP/$src/debian/changelog" 2>/dev/null; then + echo " FAIL: injection did not write the dev suffix into the copy for $src"; fail=1 + fi + done + echo " ok: injection wrote to the copy; committed changelogs untouched" +else + # No devscripts here (e.g. a non-container run) — document instead of failing. + echo " note: dch not present; skipping live injection (runs in the bookworm PR lane)" +fi + +# Real tilde-ordering proofs (dpkg is present in the bookworm lane). These are the invariant +# the encoded ~ceralive version depends on; a broken comparator would silently invert +# release ordering. +if command -v dpkg >/dev/null 2>&1; then + echo " running dpkg --compare-versions ordering proofs..." + ord_ok() { dpkg --compare-versions "$1" lt "$2" || { echo " FAIL: '$1' !lt '$2'"; fail=1; }; } + ord_ok "1.24.0-1~ceralive0.1.0" "1.24.0-1~ceralive0.2.0" + ord_ok "1.24.0-1~ceralive0.9.0" "1.24.0-1~ceralive0.10.0" + ord_ok "1.24.0-1~ceralive0.1.0" "1.24.0-1" + if dpkg --compare-versions "1.24.0-1~ceralive0.2.0" lt "1.24.0-1~ceralive0.1.0"; then + echo " FAIL: comparator is always-true (0.2.0 lt 0.1.0)"; fail=1 + fi + echo " ok: tilde ordering holds" +fi if [ "$fail" -eq 0 ]; then - echo "PASS: packaging scaffold present; tag-guard + version-injection wired" + echo "PASS: scaffold present; tag-guard + non-mutating injection + version ordering wired" else - echo "FAIL: packaging scaffold incomplete" + echo "FAIL: packaging PR contract lane" exit 1 fi diff --git a/packaging/ci/daemon-smoke.sh b/packaging/ci/daemon-smoke.sh new file mode 100755 index 0000000..225550e --- /dev/null +++ b/packaging/ci/daemon-smoke.sh @@ -0,0 +1,164 @@ +#!/usr/bin/env bash +# daemon-smoke.sh — start the rebuilt ModemManager 1.24 on a real system bus +# and prove it is a working daemon, not just an installable file set. +# +# Inside a throwaway `debian:bookworm` container it installs system D-Bus + polkit + +# NetworkManager (bookworm ships 1.42.4 — the plan's "NM 1.42") and the A5.1 build output, +# then: +# * starts a system dbus-daemon and the ModemManager daemon on it; +# * `busctl introspect` the MM service at its root path -> the ObjectManager interface; +# * `mmcli --version` reports 1.24.0; +# * the udev-rules + FCC-unlock dispatcher directories exist at their install paths; +# * the GIR typelib (gir1.2-modemmanager-1.0) and the Vala .vapi (libmm-glib-dev) are present. +# +# There is no modem hardware in CI, so MM starts with zero modems — the root ObjectManager +# is still exported, which is exactly what the smoke asserts. amd64 only by default +# (apt-install + daemon start under arm64 QEMU is too slow for a runner; arm64 daemon smoke +# is a bench/HIL item, see cli/ A6.1). +# +# EXIT 0 smoke green. 2 usage/env. non-zero = a smoke failure. +set -euo pipefail + +# ========================================================================================== +# HOST ROLE — launch the container. +# ========================================================================================== +if [ "${IN_CONTAINER:-0}" != "1" ]; then + ARCH="${1:-amd64}" + case "$ARCH" in + amd64) PLATFORM="linux/amd64" ;; + arm64) PLATFORM="linux/arm64" ;; + *) echo "usage: daemon-smoke.sh " >&2; exit 2 ;; + esac + HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + PKG_ROOT="$(cd "$HERE/.." && pwd)" + BUILD_DIR="${BUILD_DIR:-$PKG_ROOT/build/$ARCH}" + + command -v docker >/dev/null 2>&1 || { echo "smoke: docker not found" >&2; exit 2; } + ls "$BUILD_DIR"/*.deb >/dev/null 2>&1 || { + echo "smoke: no .deb in $BUILD_DIR — run ci/build-bookworm.sh $ARCH first" >&2; exit 2; } + + echo "======================================================================" + echo "daemon smoke arch=$ARCH" + echo " build dir: $BUILD_DIR" + echo "======================================================================" + + # --privileged is NOT required: a plain container can run its own dbus-daemon + MM. + docker run --rm --platform "$PLATFORM" \ + -e IN_CONTAINER=1 -e ARCH="$ARCH" \ + -v "$PKG_ROOT":/pkg:ro \ + -v "$BUILD_DIR":/debs:ro \ + debian:bookworm \ + bash /pkg/ci/daemon-smoke.sh "$ARCH" + + echo + echo "======================================================================" + echo "DAEMON SMOKE PASS [$ARCH]" + echo "======================================================================" + exit 0 +fi + +# ========================================================================================== +# CONTAINER ROLE — install, start the daemon, assert. +# ========================================================================================== +ARCH="${ARCH:-$(dpkg --print-architecture)}" +export DEBIAN_FRONTEND=noninteractive +case "$ARCH" in amd64) MA=x86_64-linux-gnu ;; arm64) MA=aarch64-linux-gnu ;; *) MA="$(dpkg --print-architecture)-linux-gnu" ;; esac + +echo +echo "== in-container daemon smoke (arch=$(dpkg --print-architecture), multiarch=$MA) ==" + +echo 'APT::Sandbox::User "root";' > /etc/apt/apt.conf.d/01-no-sandbox +apt-get update -qq + +# System bus (dbus), busctl (systemd), polkit, NetworkManager 1.42, mmcli deps, and dpkg-dev +# (dpkg-scanpackages for the local repo). --no-install-recommends keeps NM from pulling stock +# modemmanager back in as a recommend. +echo "-- installing system D-Bus + polkit + NetworkManager (bookworm 1.42) + tooling --" +apt-get install -y -qq --no-install-recommends \ + dbus systemd policykit-1 network-manager \ + dpkg-dev iproute2 udev ca-certificates >/tmp/base.log 2>&1 || { sed 's/^/ /' /tmp/base.log; echo "FAIL: base install"; exit 1; } + +NM_VER="$(dpkg-query -W -f='${Version}' network-manager 2>/dev/null || echo '?')" +echo "-- NetworkManager installed: $NM_VER" + +# Local repo of the built stack, pinned above bookworm-main (coherent ceralive set wins). +REPO=/tmp/localrepo +rm -rf "$REPO"; mkdir -p "$REPO"; cp /debs/*.deb "$REPO/" +( cd "$REPO" && dpkg-scanpackages -m . /dev/null > Packages 2>/dev/null ) +echo "deb [trusted=yes] file:$REPO ./" > /etc/apt/sources.list.d/local-mm.list +printf 'Package: *\nPin: origin ""\nPin-Priority: 1001\n' > /etc/apt/preferences.d/local-mm.pref +apt-get update -qq + +echo "-- installing rebuilt MM 1.24 runtime + GIR + dev (for typelib/.vapi) --" +apt-get install -y -qq --allow-downgrades \ + modemmanager libmm-glib0 libmbim-glib4 libmbim-proxy libmbim-utils \ + libqmi-glib5 libqmi-proxy libqmi-utils libqrtr-glib0 \ + gir1.2-modemmanager-1.0 libmm-glib-dev >/tmp/mm.log 2>&1 || { sed 's/^/ /' /tmp/mm.log; echo "FAIL: MM install"; exit 1; } +echo "-- modemmanager installed: $(dpkg-query -W -f='${Version}' modemmanager)" + +fail=0 +ok() { echo " ok: $*"; } +bad() { echo " FAIL: $*"; fail=1; } + +# ---- start a system D-Bus, then the ModemManager daemon on it ---------------------------- +echo +echo "==== starting system D-Bus + ModemManager daemon ====" +mkdir -p /run/dbus +dbus-uuidgen --ensure=/etc/machine-id +dbus-daemon --system --fork +sleep 1 +[ -S /run/dbus/system_bus_socket ] && ok "system bus socket up" || bad "no system bus socket" + +# polkit is installed (plan: "polkit installed"); start it too for realism (best-effort). +if [ -x /usr/lib/polkit-1/polkitd ]; then /usr/lib/polkit-1/polkitd --no-debug >/tmp/polkitd.log 2>&1 & sleep 1; ok "polkitd started (pid $!)"; fi + +# MM daemon in the background; poll until it owns its well-known name (<= ~15s). +/usr/sbin/ModemManager --debug >/tmp/mm-daemon.log 2>&1 & +MM_PID=$! +owned=0 +for _ in $(seq 1 30); do + if busctl --system list 2>/dev/null | grep -q org.freedesktop.ModemManager1; then owned=1; break; fi + kill -0 "$MM_PID" 2>/dev/null || { echo " MM daemon exited early; log:"; sed 's/^/ /' /tmp/mm-daemon.log; break; } + sleep 0.5 +done +[ "$owned" -eq 1 ] && ok "ModemManager owns org.freedesktop.ModemManager1 (pid $MM_PID)" || bad "MM never acquired its bus name" + +# ---- ASSERTION 1: busctl introspect shows ObjectManager at the root path ------------------ +echo +echo "==== busctl introspect (root ObjectManager) ====" +INTRO="$(busctl --system introspect org.freedesktop.ModemManager1 /org/freedesktop/ModemManager1 2>/tmp/introspect.err || true)" +echo "$INTRO" | sed 's/^/ /' +if echo "$INTRO" | grep -q 'org.freedesktop.DBus.ObjectManager'; then ok "root path exposes org.freedesktop.DBus.ObjectManager"; else sed 's/^/ /' /tmp/introspect.err; bad "ObjectManager interface not found at root"; fi + +# ---- ASSERTION 2: mmcli --version reports 1.24.0 ----------------------------------------- +echo +echo "==== mmcli --version ====" +MMCLI_V="$(mmcli --version 2>&1 | head -1)" +echo " $MMCLI_V" +echo "$MMCLI_V" | grep -q '1\.24\.0' && ok "mmcli reports 1.24.0" || bad "mmcli version is not 1.24.0" + +# ---- ASSERTION 3: udev-rules + FCC-unlock dispatcher directories at install paths -------- +echo +echo "==== udev / FCC-unlock install paths ====" +ls /usr/lib/udev/rules.d/77-mm-*.rules >/dev/null 2>&1 && ok "udev rules present (/usr/lib/udev/rules.d/77-mm-*.rules)" || bad "udev rules missing" +[ -d /etc/ModemManager/fcc-unlock.d ] && ok "FCC-unlock dispatcher dir (/etc/ModemManager/fcc-unlock.d)" || bad "FCC-unlock dispatcher dir missing" +[ -d /usr/share/ModemManager/fcc-unlock.available.d ] && ok "FCC-unlock available dir (/usr/share/ModemManager/fcc-unlock.available.d)" || bad "FCC-unlock available dir missing" + +# ---- ASSERTION 4: GIR typelib + Vala .vapi in the installed file set ---------------------- +echo +echo "==== GIR / Vala artifacts ====" +TYPELIB="/usr/lib/${MA}/girepository-1.0/ModemManager-1.0.typelib" +[ -f "$TYPELIB" ] && ok "GIR typelib present ($TYPELIB)" || bad "GIR typelib missing ($TYPELIB)" +[ -f /usr/share/gir-1.0/ModemManager-1.0.gir ] && ok "GIR xml present (/usr/share/gir-1.0/ModemManager-1.0.gir)" || bad "GIR xml missing" +[ -f /usr/share/vala/vapi/libmm-glib.vapi ] && ok "Vala .vapi present (/usr/share/vala/vapi/libmm-glib.vapi)" || bad "Vala .vapi missing" + +# ---- teardown ---------------------------------------------------------------------------- +kill "$MM_PID" 2>/dev/null || true + +echo +if [ "$fail" -eq 0 ]; then + echo "IN-CONTAINER DAEMON SMOKE PASS [$ARCH] (NetworkManager $NM_VER)" +else + echo "IN-CONTAINER DAEMON SMOKE FAIL [$ARCH]" >&2 + exit 1 +fi diff --git a/packaging/ci/fixtures/altered-dsc/modemmanager.dsc b/packaging/ci/fixtures/altered-dsc/modemmanager.dsc new file mode 100644 index 0000000..21e0c0e --- /dev/null +++ b/packaging/ci/fixtures/altered-dsc/modemmanager.dsc @@ -0,0 +1,51 @@ +-----BEGIN PGP SIGNED MESSAGE----- +Hash: SHA512 + +Format: 3.0 (quilt) +Source: modemmanager +Binary: modemmanager, modemmanager-dev, modemmanager-doc, libmm-glib0, libmm-glib-dev, libmm-glib-doc, gir1.2-modemmanager-1.0 +Architecture: linux-any all +Version: 1.24.0-1 +Maintainer: DebianOnMobile Maintainers +Uploaders: Arnaud Ferraris , Guido Günther , Henry-Nicolas Tourneur , Martin +Homepage: https://www.freedesktop.org/wiki/Software/ModemManager/ +Standards-Version: 4.7.2 +Vcs-Browser: https://salsa.debian.org/DebianOnMobile-team/modemmanager +Vcs-Git: https://salsa.debian.org/DebianOnMobile-team/modemmanager.git +Testsuite: autopkgtest +Testsuite-Triggers: @builddeps@, dpkg-dev, pkgconf +Build-Depends: debhelper-compat (= 13), debhelper (>= 13.11.6), dh-sequence-gir, bash-completion, gettext, libdbus-1-dev, libgirepository1.0-dev, libglib2.0-dev, libgudev-1.0-dev, libmbim-glib-dev (>= 1.32~), libpolkit-gobject-1-dev (>= 0.97), libqmi-glib-dev (>= 1.36~), libsystemd-dev (>= 209), meson, polkitd, python3-dbus, python3-gi, systemd-dev, valac (>= 0.22), xsltproc, gtk-doc-tools , libglib2.0-doc , dbus +Package-List: + gir1.2-modemmanager-1.0 deb introspection optional arch=linux-any + libmm-glib-dev deb libdevel optional arch=linux-any + libmm-glib-doc deb doc optional arch=all profile=!nodoc + libmm-glib0 deb libs optional arch=linux-any + modemmanager deb net optional arch=linux-any + modemmanager-dev deb libdevel optional arch=linux-any + modemmanager-doc deb doc optional arch=all profile=!nodoc +Checksums-Sha1: + d0d6f2b3d5d003bb9825d5158bcee9cbd4853009 1361836 modemmanager_1.24.0.orig.tar.xz + b675ec976905fe175947a8aafcc66ec99edd6dd5 36680 modemmanager_1.24.0-1.debian.tar.xz +Checksums-Sha256: + 63ded4c0f3936bb0db5ae35ef1dfd57c5d5b4dd8a5cdaa7fb2182255218c9169 1361836 modemmanager_1.24.0.orig.tar.xz + 7585e8cf6fb920e516372ed54d8f5c3d2faf1ffec920ec90fb550b9aaa11de12 36680 modemmanager_1.24.0-1.debian.tar.xz +Files: + 288cdf074430ef95268c9fe04873f047 1361836 modemmanager_1.24.0.orig.tar.xz + 97dce306035804a7bfb5e263febb1e98 36680 modemmanager_1.24.0-1.debian.tar.xz + +-----BEGIN PGP SIGNATURE----- + +iQIzBAEBCgAdFiEEY/bM35YinQkoayrDJb+GUkr8weMFAmf6CusACgkQJb+GUkr8 +weM7YRAAuDc4hy8TDZN/nqGAvk5so+r5v/ixxlQ3pvJG6cih08LkzTOrjgSS+tg/ +k4+3FiDQ+NgeOry7NcDdXldTUfN9GqRk5bznVzavNugrrN0HmgiHhQYTjHEttlh6 +JS6trZdINHTx4LpOlfMFhq+A1skK6JDDLcjS+YM1AXbBRGAoThd0VGb7hZhEn33U +aVXYFuD9ULxjDPzEVSqeTZrqy/6CaQkx8J7XSbpWh1u1MLBBvEABbqI5vN47/qHp +LfpBcNeExmbPbBMW267tcnlLvC3jeasCM7B8Ioq+hrnYqpJnW6lioFUjEhNShJzy +CiG19h8mkXNRAUdwnv63Bcnt9tsFL74SfHfJPkwW0OruGaJbB88A9yvotrRllCsn +xHnMxTB2Pf6qUF6gdPwkxfThil4FWKI6dgxIVLFamsuByrNt5CU3Ejdw2MKKeRRc +SPjftsy3yZPLD8XYKUCigDEzzXQU/5uNURJ7ZfLSjmBW0rqW63Wd3/kLDh/j8+BY +oDz4iyH1iWbPxpjqh63J0fhUYxgRRo7ORA5sLvEbr7sUbQyZFa1u5xlh8HlM8A9h +jOWpIaMD8aUJcm7VVhkYX4S7mZqHU2beEls5aSh3/EKk6lWn8XOL3dXDWuFJZWhv +o3wBsmPF/YAiRZtHuM5g/qhJHrXUEtR0WLrbxWvQk58l6kJ5r7o= +=9bMe +-----END PGP SIGNATURE----- diff --git a/packaging/ci/fixtures/altered-dsc/pins.yaml b/packaging/ci/fixtures/altered-dsc/pins.yaml new file mode 100644 index 0000000..aaee935 --- /dev/null +++ b/packaging/ci/fixtures/altered-dsc/pins.yaml @@ -0,0 +1,30 @@ +# FIXTURE — NOT A REAL PIN. Negative test for verify-upstream-pins.sh. +# +# Tamper: the packaged .dsc has ONE hex digit flipped inside its signed body +# (…218c9168 -> …218c9169 in the Checksums-Sha256 orig line), so the GPG clearsign +# no longer matches the content. `dsc_sha256` here is the hash of the ALTERED file, +# so the integrity pre-check passes — proving the GPG signature (not just a recorded +# hash) is what catches the tamper. The signer pin is the correct Guido key. +# Expected: FAIL [modemmanager] dsc_signature: signature invalid (BADSIG) … (exit 1). +schema_version: 1 +sources: + modemmanager: + upstream_tag: "1.24.0" + upstream_repo: "https://gitlab.freedesktop.org/mobile-broadband/ModemManager.git" + upstream_tag_sha: "8e8dbf92f4e3be2aa9ae1b42edbbde275b3426c2" + upstream_commit_sha: "dfa41adf391b090720fb1ea56d884f61ea7fba29" + signer_fingerprint: "63F6CCDF96229D09286B2AC325BF86524AFCC1E3" + signer_key_file: "keys/63F6CCDF96229D09286B2AC325BF86524AFCC1E3.asc" + orig_tar_name: "modemmanager_1.24.0.orig.tar.xz" + orig_tar_url: "local:UNREACHED-fails-at-signature-check" + orig_tar_sha256: "63ded4c0f3936bb0db5ae35ef1dfd57c5d5b4dd8a5cdaa7fb2182255218c9168" + salsa_repo: "https://salsa.debian.org/DebianOnMobile-team/modemmanager.git" + salsa_tag: "debian/1.24.0-1" + salsa_tag_sha: "92692e6a3f030dded827b74f29e45fa216e2a421" + salsa_commit_sha: "260e3c0fe1878929ab3f2d727f9ae47c69f729f1" + dsc_url: "local:modemmanager.dsc" + dsc_sha256: "760ec3116216655b6b677df93eaa1e4df73b15d33f61559b887b02e0d93c2b0a" + dsc_signer: "Guido Günther " + dsc_checksums_sha256: | + 63ded4c0f3936bb0db5ae35ef1dfd57c5d5b4dd8a5cdaa7fb2182255218c9169 1361836 modemmanager_1.24.0.orig.tar.xz + 7585e8cf6fb920e516372ed54d8f5c3d2faf1ffec920ec90fb550b9aaa11de12 36680 modemmanager_1.24.0-1.debian.tar.xz diff --git a/packaging/ci/fixtures/altered-orig/modemmanager.dsc b/packaging/ci/fixtures/altered-orig/modemmanager.dsc new file mode 100644 index 0000000..00b557a --- /dev/null +++ b/packaging/ci/fixtures/altered-orig/modemmanager.dsc @@ -0,0 +1,51 @@ +-----BEGIN PGP SIGNED MESSAGE----- +Hash: SHA512 + +Format: 3.0 (quilt) +Source: modemmanager +Binary: modemmanager, modemmanager-dev, modemmanager-doc, libmm-glib0, libmm-glib-dev, libmm-glib-doc, gir1.2-modemmanager-1.0 +Architecture: linux-any all +Version: 1.24.0-1 +Maintainer: DebianOnMobile Maintainers +Uploaders: Arnaud Ferraris , Guido Günther , Henry-Nicolas Tourneur , Martin +Homepage: https://www.freedesktop.org/wiki/Software/ModemManager/ +Standards-Version: 4.7.2 +Vcs-Browser: https://salsa.debian.org/DebianOnMobile-team/modemmanager +Vcs-Git: https://salsa.debian.org/DebianOnMobile-team/modemmanager.git +Testsuite: autopkgtest +Testsuite-Triggers: @builddeps@, dpkg-dev, pkgconf +Build-Depends: debhelper-compat (= 13), debhelper (>= 13.11.6), dh-sequence-gir, bash-completion, gettext, libdbus-1-dev, libgirepository1.0-dev, libglib2.0-dev, libgudev-1.0-dev, libmbim-glib-dev (>= 1.32~), libpolkit-gobject-1-dev (>= 0.97), libqmi-glib-dev (>= 1.36~), libsystemd-dev (>= 209), meson, polkitd, python3-dbus, python3-gi, systemd-dev, valac (>= 0.22), xsltproc, gtk-doc-tools , libglib2.0-doc , dbus +Package-List: + gir1.2-modemmanager-1.0 deb introspection optional arch=linux-any + libmm-glib-dev deb libdevel optional arch=linux-any + libmm-glib-doc deb doc optional arch=all profile=!nodoc + libmm-glib0 deb libs optional arch=linux-any + modemmanager deb net optional arch=linux-any + modemmanager-dev deb libdevel optional arch=linux-any + modemmanager-doc deb doc optional arch=all profile=!nodoc +Checksums-Sha1: + d0d6f2b3d5d003bb9825d5158bcee9cbd4853009 1361836 modemmanager_1.24.0.orig.tar.xz + b675ec976905fe175947a8aafcc66ec99edd6dd5 36680 modemmanager_1.24.0-1.debian.tar.xz +Checksums-Sha256: + 63ded4c0f3936bb0db5ae35ef1dfd57c5d5b4dd8a5cdaa7fb2182255218c9168 1361836 modemmanager_1.24.0.orig.tar.xz + 7585e8cf6fb920e516372ed54d8f5c3d2faf1ffec920ec90fb550b9aaa11de12 36680 modemmanager_1.24.0-1.debian.tar.xz +Files: + 288cdf074430ef95268c9fe04873f047 1361836 modemmanager_1.24.0.orig.tar.xz + 97dce306035804a7bfb5e263febb1e98 36680 modemmanager_1.24.0-1.debian.tar.xz + +-----BEGIN PGP SIGNATURE----- + +iQIzBAEBCgAdFiEEY/bM35YinQkoayrDJb+GUkr8weMFAmf6CusACgkQJb+GUkr8 +weM7YRAAuDc4hy8TDZN/nqGAvk5so+r5v/ixxlQ3pvJG6cih08LkzTOrjgSS+tg/ +k4+3FiDQ+NgeOry7NcDdXldTUfN9GqRk5bznVzavNugrrN0HmgiHhQYTjHEttlh6 +JS6trZdINHTx4LpOlfMFhq+A1skK6JDDLcjS+YM1AXbBRGAoThd0VGb7hZhEn33U +aVXYFuD9ULxjDPzEVSqeTZrqy/6CaQkx8J7XSbpWh1u1MLBBvEABbqI5vN47/qHp +LfpBcNeExmbPbBMW267tcnlLvC3jeasCM7B8Ioq+hrnYqpJnW6lioFUjEhNShJzy +CiG19h8mkXNRAUdwnv63Bcnt9tsFL74SfHfJPkwW0OruGaJbB88A9yvotrRllCsn +xHnMxTB2Pf6qUF6gdPwkxfThil4FWKI6dgxIVLFamsuByrNt5CU3Ejdw2MKKeRRc +SPjftsy3yZPLD8XYKUCigDEzzXQU/5uNURJ7ZfLSjmBW0rqW63Wd3/kLDh/j8+BY +oDz4iyH1iWbPxpjqh63J0fhUYxgRRo7ORA5sLvEbr7sUbQyZFa1u5xlh8HlM8A9h +jOWpIaMD8aUJcm7VVhkYX4S7mZqHU2beEls5aSh3/EKk6lWn8XOL3dXDWuFJZWhv +o3wBsmPF/YAiRZtHuM5g/qhJHrXUEtR0WLrbxWvQk58l6kJ5r7o= +=9bMe +-----END PGP SIGNATURE----- diff --git a/packaging/ci/fixtures/altered-orig/modemmanager_1.24.0.orig.tar.xz b/packaging/ci/fixtures/altered-orig/modemmanager_1.24.0.orig.tar.xz new file mode 100644 index 0000000..aea4d57 --- /dev/null +++ b/packaging/ci/fixtures/altered-orig/modemmanager_1.24.0.orig.tar.xz @@ -0,0 +1 @@ +this is NOT the real modemmanager tarball diff --git a/packaging/ci/fixtures/altered-orig/pins.yaml b/packaging/ci/fixtures/altered-orig/pins.yaml new file mode 100644 index 0000000..2b92c26 --- /dev/null +++ b/packaging/ci/fixtures/altered-orig/pins.yaml @@ -0,0 +1,29 @@ +# FIXTURE — NOT A REAL PIN. Negative test for verify-upstream-pins.sh. +# +# Tamper: the .dsc is real & valid (correct Guido signature, checksums intact), so +# lineage/signature/checksum-consistency all pass — but the packaged .orig.tar is a +# tiny impostor whose sha256 does not match the pinned `orig_tar_sha256` (the real one +# from the verified .dsc). This is the last link in the chain: a swapped-out artifact. +# Expected: FAIL [modemmanager] orig_tar_sha256: checksum mismatch … (exit 1). +schema_version: 1 +sources: + modemmanager: + upstream_tag: "1.24.0" + upstream_repo: "https://gitlab.freedesktop.org/mobile-broadband/ModemManager.git" + upstream_tag_sha: "8e8dbf92f4e3be2aa9ae1b42edbbde275b3426c2" + upstream_commit_sha: "dfa41adf391b090720fb1ea56d884f61ea7fba29" + signer_fingerprint: "63F6CCDF96229D09286B2AC325BF86524AFCC1E3" + signer_key_file: "keys/63F6CCDF96229D09286B2AC325BF86524AFCC1E3.asc" + orig_tar_name: "modemmanager_1.24.0.orig.tar.xz" + orig_tar_url: "local:modemmanager_1.24.0.orig.tar.xz" + orig_tar_sha256: "63ded4c0f3936bb0db5ae35ef1dfd57c5d5b4dd8a5cdaa7fb2182255218c9168" + salsa_repo: "https://salsa.debian.org/DebianOnMobile-team/modemmanager.git" + salsa_tag: "debian/1.24.0-1" + salsa_tag_sha: "92692e6a3f030dded827b74f29e45fa216e2a421" + salsa_commit_sha: "260e3c0fe1878929ab3f2d727f9ae47c69f729f1" + dsc_url: "local:modemmanager.dsc" + dsc_sha256: "f2a0a21fdce6619bf874ab182385225c65b82d217d6b6ed20933ded5c375fa1c" + dsc_signer: "Guido Günther " + dsc_checksums_sha256: | + 63ded4c0f3936bb0db5ae35ef1dfd57c5d5b4dd8a5cdaa7fb2182255218c9168 1361836 modemmanager_1.24.0.orig.tar.xz + 7585e8cf6fb920e516372ed54d8f5c3d2faf1ffec920ec90fb550b9aaa11de12 36680 modemmanager_1.24.0-1.debian.tar.xz diff --git a/packaging/ci/fixtures/wrong-signer/modemmanager.dsc b/packaging/ci/fixtures/wrong-signer/modemmanager.dsc new file mode 100644 index 0000000..00b557a --- /dev/null +++ b/packaging/ci/fixtures/wrong-signer/modemmanager.dsc @@ -0,0 +1,51 @@ +-----BEGIN PGP SIGNED MESSAGE----- +Hash: SHA512 + +Format: 3.0 (quilt) +Source: modemmanager +Binary: modemmanager, modemmanager-dev, modemmanager-doc, libmm-glib0, libmm-glib-dev, libmm-glib-doc, gir1.2-modemmanager-1.0 +Architecture: linux-any all +Version: 1.24.0-1 +Maintainer: DebianOnMobile Maintainers +Uploaders: Arnaud Ferraris , Guido Günther , Henry-Nicolas Tourneur , Martin +Homepage: https://www.freedesktop.org/wiki/Software/ModemManager/ +Standards-Version: 4.7.2 +Vcs-Browser: https://salsa.debian.org/DebianOnMobile-team/modemmanager +Vcs-Git: https://salsa.debian.org/DebianOnMobile-team/modemmanager.git +Testsuite: autopkgtest +Testsuite-Triggers: @builddeps@, dpkg-dev, pkgconf +Build-Depends: debhelper-compat (= 13), debhelper (>= 13.11.6), dh-sequence-gir, bash-completion, gettext, libdbus-1-dev, libgirepository1.0-dev, libglib2.0-dev, libgudev-1.0-dev, libmbim-glib-dev (>= 1.32~), libpolkit-gobject-1-dev (>= 0.97), libqmi-glib-dev (>= 1.36~), libsystemd-dev (>= 209), meson, polkitd, python3-dbus, python3-gi, systemd-dev, valac (>= 0.22), xsltproc, gtk-doc-tools , libglib2.0-doc , dbus +Package-List: + gir1.2-modemmanager-1.0 deb introspection optional arch=linux-any + libmm-glib-dev deb libdevel optional arch=linux-any + libmm-glib-doc deb doc optional arch=all profile=!nodoc + libmm-glib0 deb libs optional arch=linux-any + modemmanager deb net optional arch=linux-any + modemmanager-dev deb libdevel optional arch=linux-any + modemmanager-doc deb doc optional arch=all profile=!nodoc +Checksums-Sha1: + d0d6f2b3d5d003bb9825d5158bcee9cbd4853009 1361836 modemmanager_1.24.0.orig.tar.xz + b675ec976905fe175947a8aafcc66ec99edd6dd5 36680 modemmanager_1.24.0-1.debian.tar.xz +Checksums-Sha256: + 63ded4c0f3936bb0db5ae35ef1dfd57c5d5b4dd8a5cdaa7fb2182255218c9168 1361836 modemmanager_1.24.0.orig.tar.xz + 7585e8cf6fb920e516372ed54d8f5c3d2faf1ffec920ec90fb550b9aaa11de12 36680 modemmanager_1.24.0-1.debian.tar.xz +Files: + 288cdf074430ef95268c9fe04873f047 1361836 modemmanager_1.24.0.orig.tar.xz + 97dce306035804a7bfb5e263febb1e98 36680 modemmanager_1.24.0-1.debian.tar.xz + +-----BEGIN PGP SIGNATURE----- + +iQIzBAEBCgAdFiEEY/bM35YinQkoayrDJb+GUkr8weMFAmf6CusACgkQJb+GUkr8 +weM7YRAAuDc4hy8TDZN/nqGAvk5so+r5v/ixxlQ3pvJG6cih08LkzTOrjgSS+tg/ +k4+3FiDQ+NgeOry7NcDdXldTUfN9GqRk5bznVzavNugrrN0HmgiHhQYTjHEttlh6 +JS6trZdINHTx4LpOlfMFhq+A1skK6JDDLcjS+YM1AXbBRGAoThd0VGb7hZhEn33U +aVXYFuD9ULxjDPzEVSqeTZrqy/6CaQkx8J7XSbpWh1u1MLBBvEABbqI5vN47/qHp +LfpBcNeExmbPbBMW267tcnlLvC3jeasCM7B8Ioq+hrnYqpJnW6lioFUjEhNShJzy +CiG19h8mkXNRAUdwnv63Bcnt9tsFL74SfHfJPkwW0OruGaJbB88A9yvotrRllCsn +xHnMxTB2Pf6qUF6gdPwkxfThil4FWKI6dgxIVLFamsuByrNt5CU3Ejdw2MKKeRRc +SPjftsy3yZPLD8XYKUCigDEzzXQU/5uNURJ7ZfLSjmBW0rqW63Wd3/kLDh/j8+BY +oDz4iyH1iWbPxpjqh63J0fhUYxgRRo7ORA5sLvEbr7sUbQyZFa1u5xlh8HlM8A9h +jOWpIaMD8aUJcm7VVhkYX4S7mZqHU2beEls5aSh3/EKk6lWn8XOL3dXDWuFJZWhv +o3wBsmPF/YAiRZtHuM5g/qhJHrXUEtR0WLrbxWvQk58l6kJ5r7o= +=9bMe +-----END PGP SIGNATURE----- diff --git a/packaging/ci/fixtures/wrong-signer/pins.yaml b/packaging/ci/fixtures/wrong-signer/pins.yaml new file mode 100644 index 0000000..eb741a4 --- /dev/null +++ b/packaging/ci/fixtures/wrong-signer/pins.yaml @@ -0,0 +1,29 @@ +# FIXTURE — NOT A REAL PIN. Negative test for verify-upstream-pins.sh. +# +# Tamper: the .dsc (packaged here, real & unaltered, signed by Guido Günther +# 63F6CCDF…C1E3) is pinned to the WRONG signer — Arnaud Ferraris' fingerprint +# 796DB393…9196. Only that pinned key is imported into the isolated keyring, so +# gpg cannot verify the signature made by Guido's key and reports NO_PUBKEY for it. +# Expected: FAIL [modemmanager] signer_fingerprint: signer mismatch … (exit 1). +schema_version: 1 +sources: + modemmanager: + upstream_tag: "1.24.0" + upstream_repo: "https://gitlab.freedesktop.org/mobile-broadband/ModemManager.git" + upstream_tag_sha: "8e8dbf92f4e3be2aa9ae1b42edbbde275b3426c2" + upstream_commit_sha: "dfa41adf391b090720fb1ea56d884f61ea7fba29" + signer_fingerprint: "796DB393DC3FF40222B6EA22D3EBB5966BB99196" + signer_key_file: "keys/796DB393DC3FF40222B6EA22D3EBB5966BB99196.asc" + orig_tar_name: "modemmanager_1.24.0.orig.tar.xz" + orig_tar_url: "local:UNREACHED-fails-at-signer-check" + orig_tar_sha256: "63ded4c0f3936bb0db5ae35ef1dfd57c5d5b4dd8a5cdaa7fb2182255218c9168" + salsa_repo: "https://salsa.debian.org/DebianOnMobile-team/modemmanager.git" + salsa_tag: "debian/1.24.0-1" + salsa_tag_sha: "92692e6a3f030dded827b74f29e45fa216e2a421" + salsa_commit_sha: "260e3c0fe1878929ab3f2d727f9ae47c69f729f1" + dsc_url: "local:modemmanager.dsc" + dsc_sha256: "f2a0a21fdce6619bf874ab182385225c65b82d217d6b6ed20933ded5c375fa1c" + dsc_signer: "Guido Günther " + dsc_checksums_sha256: | + 63ded4c0f3936bb0db5ae35ef1dfd57c5d5b4dd8a5cdaa7fb2182255218c9168 1361836 modemmanager_1.24.0.orig.tar.xz + 7585e8cf6fb920e516372ed54d8f5c3d2faf1ffec920ec90fb550b9aaa11de12 36680 modemmanager_1.24.0-1.debian.tar.xz diff --git a/packaging/ci/generate-release-manifest.sh b/packaging/ci/generate-release-manifest.sh new file mode 100755 index 0000000..5013e67 --- /dev/null +++ b/packaging/ci/generate-release-manifest.sh @@ -0,0 +1,98 @@ +#!/usr/bin/env bash +# generate-release-manifest.sh [build-root] [out-file] — emit the per-release manifest +# that maps ONE release tag to the exact .deb versions it produced, per arch. +# +# Phase-B apt publication consumes this file as the package -> source -> version matrix, so +# it is the single source of truth for "which debs are release ". It is deliberately +# 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. +# +# INPUT build-root (default: packaging/build) holding /*.deb from build-bookworm.sh. +# OUTPUT a manifest at out-file (default: dist/release-manifest.txt), also echoed to stdout. +# +# The manifest lists the 9-package RUNTIME closure per arch (the "9 exact deb versions" the +# plan speaks of). Non-runtime debs (-dev / -dbgsym / gir1.2-*) are recorded in a trailing +# comment count but are not part of the runtime version matrix. +# +# USAGE generate-release-manifest.sh v0.1.0 +# generate-release-manifest.sh v0.1.0 packaging/build dist/release-manifest.txt +# EXIT 0 ok. 2 usage / no debs. +set -euo pipefail + +TAG="${1:-}" +[ -n "$TAG" ] || { echo "usage: generate-release-manifest.sh [build-root] [out-file]" >&2; exit 2; } + +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PKG_ROOT="$(cd "$HERE/.." && pwd)" +BUILD_ROOT="${2:-$PKG_ROOT/build}" +OUT="${3:-$PKG_ROOT/../dist/release-manifest.txt}" + +# Strip a leading v for the encoded suffix (tag guard already vetted the shape upstream). +VERSION="${TAG#v}" +SUFFIX="~ceralive${VERSION}" + +# The 9-package runtime closure and each package's source (for the Phase-B matrix). +declare -A SOURCE_OF=( + [modemmanager]=ModemManager [libmm-glib0]=ModemManager + [libmbim-glib4]=libmbim [libmbim-proxy]=libmbim [libmbim-utils]=libmbim + [libqmi-glib5]=libqmi [libqmi-proxy]=libqmi [libqmi-utils]=libqmi + [libqrtr-glib0]=libqrtr-glib +) +RUNTIME_PKGS=(modemmanager libmm-glib0 libmbim-glib4 libmbim-proxy libmbim-utils \ + libqmi-glib5 libqmi-proxy libqmi-utils libqrtr-glib0) + +# Parse "__.deb" -> pkg / version / arch (version never contains '_'). +deb_field() { # + local b="${1%.deb}" pkg ver arch + arch="${b##*_}"; b="${b%_*}" + ver="${b##*_}"; pkg="${b%_*}" + case "$2" in pkg) echo "$pkg" ;; version) echo "$ver" ;; arch) echo "$arch" ;; esac +} + +mkdir -p "$(dirname "$OUT")" +{ + echo "# CeraLive modem-stack release manifest" + echo "# Maps release tag -> exact .deb versions (Phase-B apt publication consumes this)." + echo "tag: ${TAG}" + echo "version: ${VERSION}" + echo "deb_version_suffix: ${SUFFIX}" + echo "sources: [ModemManager, libmbim, libqmi, libqrtr-glib]" + echo "runtime_closure_size: ${#RUNTIME_PKGS[@]}" + echo "# columns: arch package source version filename sha256" +} > "$OUT" + +total_runtime=0 +arches_seen=() +for archdir in "$BUILD_ROOT"/*/; do + [ -d "$archdir" ] || continue + arch="$(basename "$archdir")" + ls "$archdir"/*.deb >/dev/null 2>&1 || continue + arches_seen+=("$arch") + + # Index this arch's debs by package name. + declare -A FILE_OF=() + for deb in "$archdir"/*.deb; do + FILE_OF["$(deb_field "$(basename "$deb")" pkg)"]="$deb" + done + + for pkg in "${RUNTIME_PKGS[@]}"; do + deb="${FILE_OF[$pkg]:-}" + [ -n "$deb" ] || { echo "generate-release-manifest: MISSING runtime deb '$pkg' for $arch" >&2; exit 2; } + fn="$(basename "$deb")" + ver="$(deb_field "$fn" version)" + sha="$(sha256sum "$deb" | awk '{print $1}')" + printf '%s %s %s %s %s %s\n' "$arch" "$pkg" "${SOURCE_OF[$pkg]}" "$ver" "$fn" "$sha" >> "$OUT" + total_runtime=$((total_runtime + 1)) + done + unset FILE_OF +done + +{ + echo "# arches: ${arches_seen[*]:-none}" + echo "# runtime_debs_total: ${total_runtime} (= ${#RUNTIME_PKGS[@]} runtime x ${#arches_seen[@]} arch)" +} >> "$OUT" + +[ "$total_runtime" -gt 0 ] || { echo "generate-release-manifest: no runtime debs found under $BUILD_ROOT" >&2; exit 2; } + +cat "$OUT" +echo "generate-release-manifest: wrote $OUT (${total_runtime} runtime deb rows across ${#arches_seen[@]} arch)" >&2 diff --git a/packaging/ci/test-package-contract.sh b/packaging/ci/test-package-contract.sh new file mode 100755 index 0000000..e0ac000 --- /dev/null +++ b/packaging/ci/test-package-contract.sh @@ -0,0 +1,382 @@ +#!/usr/bin/env bash +# test-package-contract.sh — the package contract suite for the bookworm +# ModemManager 1.24 stack rebuilds. +# +# Runs the A5.1 build output (packaging/build//*.deb) through the contract every +# device install must satisfy. All checks are REAL executed commands inside a throwaway +# `debian:bookworm` container — nothing is narrated. It NEVER mutates the committed +# packaging tree (version-injection experiments operate on ephemeral strings / copies). +# +# CHECKS +# 1 metadata/arch — Package/Version/Architecture over the 9-package runtime closure +# 2 closure install — clean bookworm: `apt-get install ./*.deb` of the 9, no missing deps +# 3 upgrade — stock modemmanager 1.20.4 -> the tag-encoded ceralive set +# 4 rollback — ceralive set -> stock, correct apt semantics (source-disable + +# explicit stock versions + --allow-downgrades) +# 5 coherence — every runtime deb carries the SAME ~ceralive suffix +# (+ a mismatched-libqmi negative fixture that must fail closed) +# 6 ordering — REAL `dpkg --compare-versions` proofs of the tilde ordering +# 7 tag-guard negative — a pre-release tag is rejected BEFORE any deb is produced +# 8 piuparts-style — install then purge each package; assert zero leftover files +# +# MODES (per plan: "amd64 full; arm64 metadata + QEMU install where runner permits") +# full — all 8 checks (default for amd64) +# metadata — checks 1,5,6 only, the fast dpkg-metadata proofs (default for arm64, whose +# apt-install-under-QEMU is prohibitively slow on a CI runner) +# Override with CONTRACT_MODE=full|metadata. +# +# USAGE +# packaging/ci/test-package-contract.sh amd64 +# CONTRACT_MODE=full packaging/ci/test-package-contract.sh arm64 # force full under QEMU +# +# EXIT 0 all checks pass. 2 usage/env (no debs, no docker). non-zero = a contract breach. +set -euo pipefail + +# The 9-package runtime closure (contract constant, matches build-bookworm.sh). +RUNTIME_PKGS=(modemmanager libmm-glib0 libmbim-glib4 libmbim-proxy libmbim-utils \ + libqmi-glib5 libqmi-proxy libqmi-utils libqrtr-glib0) +STOCK_MM_UPSTREAM="1.20.4" # bookworm's stock modemmanager upstream version + +# ========================================================================================== +# HOST ROLE — tag-guard preamble (no container needed), then launch the container. +# ========================================================================================== +if [ "${IN_CONTAINER:-0}" != "1" ]; then + ARCH="${1:-amd64}" + case "$ARCH" in + amd64) PLATFORM="linux/amd64" ;; + arm64) PLATFORM="linux/arm64" ;; + *) echo "usage: test-package-contract.sh " >&2; exit 2 ;; + esac + MODE="${CONTRACT_MODE:-$([ "$ARCH" = amd64 ] && echo full || echo metadata)}" + + HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + PKG_ROOT="$(cd "$HERE/.." && pwd)" + BUILD_DIR="${BUILD_DIR:-$PKG_ROOT/build/$ARCH}" + + command -v docker >/dev/null 2>&1 || { echo "contract: docker not found" >&2; exit 2; } + ls "$BUILD_DIR"/*.deb >/dev/null 2>&1 || { + echo "contract: no .deb in $BUILD_DIR — run ci/build-bookworm.sh $ARCH first" >&2; exit 2; } + + echo "======================================================================" + echo "package contract suite arch=$ARCH mode=$MODE" + echo " packaging root: $PKG_ROOT" + echo " build dir: $BUILD_DIR" + echo "======================================================================" + + # ---- CHECK 7 (host-side, before any container/deb work) ------------------------------ + # The tag-guard runs FIRST in release.yml and gates build-deb, so a pre-release tag is + # rejected before a single .deb exists. Prove that here, with no debs touched. + echo + echo "==== CHECK 7: tag-guard negative (pre-release rejected before any deb build) ====" + guard_reject() { + if bash "$HERE/tag-guard.sh" "$1" >/dev/null 2>&1; then + echo " FAIL: tag-guard ACCEPTED '$1' (should reject before build)"; return 1 + fi + echo " ok: rejected '$1' (no deb produced)" + } + guard_accept() { + local v; v="$(bash "$HERE/tag-guard.sh" "$1")" || { echo " FAIL: tag-guard rejected valid '$1'"; return 1; } + echo " ok: accepted '$1' -> $v" + } + guard_reject "v1.0.0-rc.1" + guard_reject "v1.0.0+build5" + guard_reject "1.0.0" + guard_accept "v1.2.3" + echo " CHECK 7 PASS: the tag guard fails closed on pre-release/metadata tags." + + # Mount packaging/ (ro, for the ci/ scripts) + the built debs (ro). Re-invoke in-container. + docker run --rm --platform "$PLATFORM" \ + -e IN_CONTAINER=1 -e ARCH="$ARCH" -e CONTRACT_MODE="$MODE" \ + -e STOCK_MM_UPSTREAM="$STOCK_MM_UPSTREAM" \ + -v "$PKG_ROOT":/pkg:ro \ + -v "$BUILD_DIR":/debs:ro \ + debian:bookworm \ + bash /pkg/ci/test-package-contract.sh "$ARCH" + + echo + echo "======================================================================" + echo "CONTRACT SUITE PASS [$ARCH, mode=$MODE]" + echo "======================================================================" + exit 0 +fi + +# ========================================================================================== +# CONTAINER ROLE — the real dpkg/apt checks, inside debian:bookworm. +# ========================================================================================== +ARCH="${ARCH:-$(dpkg --print-architecture)}" +MODE="${CONTRACT_MODE:-full}" +STOCK_MM_UPSTREAM="${STOCK_MM_UPSTREAM:-1.20.4}" +export DEBIAN_FRONTEND=noninteractive + +echo +echo "== in-container contract (arch=$(dpkg --print-architecture), target=$ARCH, mode=$MODE) ==" + +# apt drops to the unprivileged _apt user for acquire and cannot read a local file: repo +# under a 0700 dir — same fix build-bookworm.sh uses. +echo 'APT::Sandbox::User "root";' > /etc/apt/apt.conf.d/01-no-sandbox +apt-get update -qq +# dpkg-scanpackages (local-repo index for the upgrade/rollback scenarios) ships in dpkg-dev; +# base debian:bookworm has dpkg-deb/dpkg-query but not dpkg-dev. +apt-get install -y -qq dpkg-dev >/dev/null 2>&1 + +# Resolve each runtime package name to its single .deb file (exact Package match — so +# libmbim-glib4 is never confused with libmbim-glib4-dbgsym). +declare -A DEB_OF +resolve_debs() { + local deb pkg + for deb in /debs/*.deb; do + pkg="$(dpkg-deb -f "$deb" Package)" + DEB_OF["$pkg"]="$deb" + done + local missing=0 + for pkg in "${RUNTIME_PKGS[@]}"; do + [ -n "${DEB_OF[$pkg]:-}" ] || { echo " MISSING runtime deb: $pkg" >&2; missing=1; } + done + [ "$missing" -eq 0 ] || { echo "STOP: runtime closure incomplete in /debs" >&2; exit 3; } +} +resolve_debs + +# ------------------------------------------------------------------------------------------ +# CHECK 1 — metadata / architecture over the 9-package runtime closure. +# ------------------------------------------------------------------------------------------ +check_metadata() { + echo; echo "==== CHECK 1: metadata / arch over the 9-package runtime closure ====" + local pkg deb ver arch fail=0 + printf ' %-20s %-32s %s\n' "PACKAGE" "VERSION" "ARCH" + for pkg in "${RUNTIME_PKGS[@]}"; do + deb="${DEB_OF[$pkg]}" + ver="$(dpkg-deb -f "$deb" Version)" + arch="$(dpkg-deb -f "$deb" Architecture)" + printf ' %-20s %-32s %s\n' "$pkg" "$ver" "$arch" + [ "$arch" = "$ARCH" ] || { echo " FAIL: arch $arch != $ARCH"; fail=1; } + [ "${ver#*~ceralive}" != "$ver" ] || { echo " FAIL: version has no ~ceralive suffix"; fail=1; } + done + [ "$fail" -eq 0 ] || { echo " CHECK 1 FAIL"; return 1; } + echo " CHECK 1 PASS: 9 runtime packages, all Architecture=$ARCH, all ~ceralive-suffixed." +} + +# ------------------------------------------------------------------------------------------ +# CHECK 5 — coherence: every runtime deb carries the SAME ~ceralive suffix. +# assert_coherent — returns 0 iff every arg shares one ~ceralive suffix. +# ------------------------------------------------------------------------------------------ +assert_coherent() { + local v suf first="" + for v in "$@"; do + suf="~ceralive${v##*~ceralive}" + [ "$suf" != "~ceralive$v" ] || { echo " no ~ceralive suffix in '$v'"; return 1; } + if [ -z "$first" ]; then first="$suf" + elif [ "$suf" != "$first" ]; then + echo " incoherent: '$suf' != '$first'"; return 1 + fi + done + echo "$first" +} +check_coherence() { + echo; echo "==== CHECK 5: coherence (identical ~ceralive suffix across all 9) ====" + local pkg vers=() + for pkg in "${RUNTIME_PKGS[@]}"; do vers+=("$(dpkg-deb -f "${DEB_OF[$pkg]}" Version)"); done + local suffix + if ! suffix="$(assert_coherent "${vers[@]}")"; then + echo " CHECK 5 FAIL: runtime versions are not coherent"; return 1 + fi + echo " ok: all 9 runtime debs share suffix '${suffix}'" + + # Negative fixture: a mismatched libqmi suffix MUST fail closed (QA-failure evidence). + echo " negative fixture (mismatched libqmi suffix expected to fail):" + local tampered=("1.24.0-1~ceralive0.1.0" "1.32.0-1~ceralive0.1.0" "1.36.0-1~ceralive0.2.0") + if assert_coherent "${tampered[@]}" >/dev/null 2>&1; then + echo " CHECK 5 FAIL: coherence accepted a mismatched-libqmi set"; return 1 + fi + echo " ok: mismatched-libqmi set rejected (fails closed)" + echo " CHECK 5 PASS." +} + +# ------------------------------------------------------------------------------------------ +# CHECK 6 — REAL dpkg --compare-versions ordering proofs of the tilde encoding. +# ------------------------------------------------------------------------------------------ +check_ordering() { + echo; echo "==== CHECK 6: dpkg --compare-versions ordering proofs (real invocations) ====" + local fail=0 + prove_lt() { + if dpkg --compare-versions "$1" lt "$2"; then echo " ok: '$1' lt '$2'" + else echo " FAIL: '$1' NOT lt '$2'"; fail=1; fi + } + prove_not_lt() { + if dpkg --compare-versions "$1" lt "$2"; then echo " FAIL: '$1' lt '$2' (expected NOT)"; fail=1 + else echo " ok: '$1' not lt '$2'"; fi + } + prove_lt "1.24.0-1~ceralive0.1.0" "1.24.0-1~ceralive0.2.0" + prove_lt "1.24.0-1~ceralive0.9.0" "1.24.0-1~ceralive0.10.0" + prove_lt "1.24.0-1~ceralive0.1.0" "1.24.0-1" + prove_lt "1.24.0-1~ceralive0.0.0~dev" "1.24.0-1~ceralive0.1.0" + # comparator must be real, not always-true: + prove_not_lt "1.24.0-1~ceralive0.2.0" "1.24.0-1~ceralive0.1.0" + [ "$fail" -eq 0 ] || { echo " CHECK 6 FAIL"; return 1; } + echo " CHECK 6 PASS: tilde ordering holds (pre-suffix < release; N.9 < N.10; dev < first)." +} + +# ---- local apt repo helpers (for install/upgrade/rollback scenarios) --------------------- +REPO="/tmp/localrepo" +setup_local_repo() { + rm -rf "$REPO"; mkdir -p "$REPO" + cp /debs/*.deb "$REPO/" + ( cd "$REPO" && dpkg-scanpackages -m . /dev/null > Packages 2>/dev/null ) + echo "deb [trusted=yes] file:$REPO ./" > /etc/apt/sources.list.d/local-mm.list + # Pin the local (freshly built) stack above bookworm-main so the coherent ceralive set + # wins even where its upstream matches (libqrtr-glib 1.2.2 == bookworm's, tilde-lower). + cat > /etc/apt/preferences.d/local-mm.pref <<'EOF' +Package: * +Pin: origin "" +Pin-Priority: 1001 +EOF + apt-get update -qq +} +disable_local_repo() { + rm -f /etc/apt/sources.list.d/local-mm.list /etc/apt/preferences.d/local-mm.pref + apt-get update -qq +} +purge_stack() { + apt-get purge -y -qq "${RUNTIME_PKGS[@]}" >/dev/null 2>&1 || true + apt-get autoremove -y -qq >/dev/null 2>&1 || true +} +dpkg_ver() { dpkg-query -W -f='${Version}' "$1" 2>/dev/null || echo "(absent)"; } + +# ------------------------------------------------------------------------------------------ +# CHECK 2 — clean-bookworm dependency-closure install via `apt-get install ./*.deb`. +# ------------------------------------------------------------------------------------------ +check_closure_install() { + echo; echo "==== CHECK 2: clean-bookworm dependency-closure install (apt-get install ./*.deb) ====" + purge_stack + local files=() + for pkg in "${RUNTIME_PKGS[@]}"; do files+=("${DEB_OF[$pkg]}"); done + echo " installing the 9 runtime debs as local files (deps resolve from bookworm-main)..." + apt-get install -y -qq "${files[@]}" >/tmp/closure.log 2>&1 || { sed 's/^/ /' /tmp/closure.log; echo " CHECK 2 FAIL: install error"; return 1; } + local pkg fail=0 + for pkg in "${RUNTIME_PKGS[@]}"; do + local v; v="$(dpkg_ver "$pkg")" + case "$v" in *~ceralive*) echo " ok: $pkg = $v" ;; *) echo " FAIL: $pkg = $v (not ceralive)"; fail=1 ;; esac + done + # No unmet dependencies anywhere. + if ! apt-get check >/tmp/aptcheck.log 2>&1; then sed 's/^/ /' /tmp/aptcheck.log; echo " CHECK 2 FAIL: apt-get check reports broken deps"; return 1; fi + [ "$fail" -eq 0 ] || { echo " CHECK 2 FAIL"; return 1; } + echo " ok: apt-get check clean (no missing deps)" + echo " CHECK 2 PASS: the 9-package closure installs cleanly on stock bookworm." + purge_stack +} + +# ------------------------------------------------------------------------------------------ +# CHECK 3 — upgrade: stock modemmanager 1.20.4 -> the tag-encoded ceralive set. +# ------------------------------------------------------------------------------------------ +check_upgrade() { + echo; echo "==== CHECK 3: upgrade (stock modemmanager ${STOCK_MM_UPSTREAM} -> ceralive set) ====" + purge_stack + disable_local_repo + echo " installing stock bookworm modemmanager + utils..." + apt-get install -y -qq modemmanager libmbim-utils libqmi-utils >/tmp/stock.log 2>&1 || { sed 's/^/ /' /tmp/stock.log; echo " CHECK 3 FAIL: stock install"; return 1; } + local before; before="$(dpkg_ver modemmanager)" + echo " stock modemmanager installed: $before" + case "$before" in ${STOCK_MM_UPSTREAM}*) echo " ok: stock is ${STOCK_MM_UPSTREAM}-series" ;; *) echo " note: bookworm stock modemmanager is $before" ;; esac + + echo " enabling local ceralive repo and upgrading the coherent set..." + setup_local_repo + # --allow-downgrades: libqrtr-glib 1.2.2-1~ceralive is tilde-LOWER than bookworm's 1.2.2-1 + # (same upstream), so landing the FULL coherent ceralive set is a downgrade for that one + # package even though modemmanager itself genuinely upgrades 1.20.4 -> 1.24.0. + apt-get install -y -qq --allow-downgrades "${RUNTIME_PKGS[@]}" >/tmp/upgrade.log 2>&1 || { sed 's/^/ /' /tmp/upgrade.log; echo " CHECK 3 FAIL: upgrade"; return 1; } + local after; after="$(dpkg_ver modemmanager)" + echo " modemmanager after upgrade: $after" + dpkg --compare-versions "$before" lt "$after" || { echo " CHECK 3 FAIL: modemmanager did not move UP ($before !< $after)"; return 1; } + case "$after" in 1.24.0*~ceralive*) echo " ok: modemmanager upgraded to 1.24.0 ceralive" ;; *) echo " CHECK 3 FAIL: unexpected upgraded version $after"; return 1 ;; esac + local pkg fail=0 + for pkg in "${RUNTIME_PKGS[@]}"; do case "$(dpkg_ver "$pkg")" in *~ceralive*) : ;; *) echo " FAIL: $pkg not on ceralive after upgrade"; fail=1 ;; esac; done + [ "$fail" -eq 0 ] || { echo " CHECK 3 FAIL"; return 1; } + echo " CHECK 3 PASS: apt upgraded modemmanager 1.20.4 -> 1.24.0 and landed the full coherent set." + purge_stack + disable_local_repo +} + +# ------------------------------------------------------------------------------------------ +# CHECK 4 — rollback: ceralive set -> stock, correct apt semantics. +# ------------------------------------------------------------------------------------------ +check_rollback() { + echo; echo "==== CHECK 4: rollback (ceralive set -> stock, apt downgrade semantics) ====" + purge_stack + setup_local_repo + echo " installing the ceralive set..." + apt-get install -y -qq --allow-downgrades "${RUNTIME_PKGS[@]}" >/tmp/rb-install.log 2>&1 || { sed 's/^/ /' /tmp/rb-install.log; echo " CHECK 4 FAIL: ceralive install"; return 1; } + echo " ceralive modemmanager: $(dpkg_ver modemmanager)" + + # Correct apt rollback = BOTH: disable the local source AND pin explicit stock versions + # with --allow-downgrades. The stock version is read from `apt-cache madison` (the INDEX + # view) NOT `apt-cache policy` Candidate — because apt refuses to auto-downgrade to a + # version below priority 1000, Candidate keeps reporting the installed ceralive version. + # madison lists only indexed versions, so once the local repo is gone it yields the real + # bookworm-main version (never hardcoded — point releases like +deb12u1 shift it). + echo " disabling local repo and pinning explicit stock versions..." + disable_local_repo + local specs=() pkg stock + for pkg in "${RUNTIME_PKGS[@]}"; do + stock="$(apt-cache madison "$pkg" 2>/dev/null | awk -F'|' 'NR==1{gsub(/^[ \t]+|[ \t]+$/,"",$2); print $2; exit}')" + [ -n "$stock" ] || { echo " note: $pkg has no bookworm stock version in the index — skipping"; continue; } + specs+=("${pkg}=${stock}") + done + echo " downgrading to: ${specs[*]}" + apt-get install -y -qq --allow-downgrades "${specs[@]}" >/tmp/rollback.log 2>&1 || { sed 's/^/ /' /tmp/rollback.log; echo " CHECK 4 FAIL: rollback"; return 1; } + local after; after="$(dpkg_ver modemmanager)" + echo " modemmanager after rollback: $after" + case "$after" in *~ceralive*) echo " CHECK 4 FAIL: still on ceralive after rollback"; return 1 ;; ${STOCK_MM_UPSTREAM}*) echo " ok: back to stock ${STOCK_MM_UPSTREAM}" ;; *) echo " ok: back to stock $after" ;; esac + echo " CHECK 4 PASS: apt cleanly downgraded the stack back to stock bookworm." + purge_stack +} + +# ------------------------------------------------------------------------------------------ +# CHECK 8 — piuparts-style install/purge cleanliness (lightweight approximation). +# ------------------------------------------------------------------------------------------ +check_piuparts() { + echo; echo "==== CHECK 8: piuparts-style install -> purge cleanliness ====" + echo " (lightweight install/purge/leftover-scan; real piuparts 1.1.7 exists in bookworm" + echo " but needs a privileged debootstrap chroot not available in this container.)" + purge_stack + local files=() + for pkg in "${RUNTIME_PKGS[@]}"; do files+=("${DEB_OF[$pkg]}"); done + apt-get install -y -qq "${files[@]}" >/tmp/piu-install.log 2>&1 || { sed 's/^/ /' /tmp/piu-install.log; echo " CHECK 8 FAIL: install"; return 1; } + # Record every regular file the 9 packages own, before purge. + local owned; owned="$(mktemp)" + local pkg + for pkg in "${RUNTIME_PKGS[@]}"; do + dpkg-query -L "$pkg" 2>/dev/null + done | sort -u > "$owned" + local nfiles; nfiles="$(wc -l < "$owned")" + echo " installed file entries owned by the 9 packages: $nfiles" + echo " purging all 9..." + apt-get purge -y -qq "${RUNTIME_PKGS[@]}" >/tmp/piu-purge.log 2>&1 || { sed 's/^/ /' /tmp/piu-purge.log; echo " CHECK 8 FAIL: purge"; return 1; } + # Any REGULAR FILE (not a dir — dirs may be shared with base packages) still present is a leak. + local leftovers=0 f + while IFS= read -r f; do + [ -f "$f" ] && { echo " LEFTOVER: $f"; leftovers=$((leftovers + 1)); } + done < "$owned" + rm -f "$owned" + [ "$leftovers" -eq 0 ] || { echo " CHECK 8 FAIL: $leftovers file(s) survived purge"; return 1; } + # Config tree must be gone too. + [ ! -e /etc/ModemManager/fcc-unlock.d ] || { echo " note: /etc/ModemManager remnant (dir may be base-owned)"; } + echo " CHECK 8 PASS: no owned regular file survived purge." +} + +# ------------------------------------------------------------------------------------------ +# Run the selected checks. +# ------------------------------------------------------------------------------------------ +check_metadata +check_coherence +check_ordering +if [ "$MODE" = full ]; then + check_closure_install + check_upgrade + check_rollback + check_piuparts +else + echo; echo "== mode=metadata: skipping install/upgrade/rollback/piuparts (apt-under-QEMU is" + echo " prohibitively slow on a CI runner). Metadata/coherence/ordering ran natively above. ==" +fi + +echo +echo "IN-CONTAINER CONTRACT CHECKS PASS [$ARCH, mode=$MODE]" diff --git a/packaging/ci/test-verify-upstream-pins.sh b/packaging/ci/test-verify-upstream-pins.sh new file mode 100755 index 0000000..d10d1c5 --- /dev/null +++ b/packaging/ci/test-verify-upstream-pins.sh @@ -0,0 +1,60 @@ +#!/usr/bin/env bash +# test-verify-upstream-pins.sh — executable proof that verify-upstream-pins.sh FAILS CLOSED. +# +# Runs the three negative fixtures under ci/fixtures/ and asserts that each makes the verifier +# exit non-zero AND print the correct NAMED failing field on stderr: +# +# wrong-signer -> FAIL [modemmanager] signer_fingerprint: signer mismatch … +# altered-dsc -> FAIL [modemmanager] dsc_signature: signature invalid (BADSIG) … +# altered-orig -> FAIL [modemmanager] orig_tar_sha256: checksum mismatch … +# +# Fixtures are OFFLINE: they use `local:` URLs (files packaged next to each fixture manifest) +# and are run with --no-lineage, so this test needs no network and isolates the one tamper it +# targets. The real acceptance run (verify-upstream-pins.sh with no flags) does the full +# network verification and is exercised by ci/contract.sh, not here. +set -uo pipefail + +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PKG_ROOT="$(cd "$HERE/.." && pwd)" # packaging/ (holds keys/) +FIX="$HERE/fixtures" +VERIFY="$HERE/verify-upstream-pins.sh" + +rc=0 + +# assert_reject +# Runs the verifier on /pins.yaml and requires: non-zero exit, a +# `FAIL [..] :` line, and somewhere in stderr. +assert_reject() { + local dir="$1" field="$2" needle="$3" + local manifest="$FIX/$dir/pins.yaml" out ec + out="$(bash "$VERIFY" --no-lineage --source modemmanager --keys-base "$PKG_ROOT" "$manifest" 2>&1)" + ec=$? + if [ "$ec" -eq 0 ]; then + echo "FAIL: '$dir' unexpectedly PASSED (exit 0) — fail-closed broken" + rc=1 + return + fi + if ! printf '%s\n' "$out" | grep -q "FAIL \[modemmanager\] $field:"; then + echo "FAIL: '$dir' rejected but not on field '$field'; got:" + printf ' %s\n' "$out" | tail -1 + rc=1 + return + fi + if ! printf '%s\n' "$out" | grep -qi "$needle"; then + echo "FAIL: '$dir' rejected on '$field' but message lacked '$needle'" + rc=1 + return + fi + echo "ok: REJECT '$dir' (exit $ec) -> $(printf '%s\n' "$out" | grep -o "FAIL \[modemmanager\] $field:.*" | sed 's/^FAIL \[modemmanager\] //')" +} + +assert_reject wrong-signer signer_fingerprint "signer mismatch" +assert_reject altered-dsc dsc_signature "signature invalid" +assert_reject altered-orig orig_tar_sha256 "checksum mismatch" + +if [ "$rc" -eq 0 ]; then + echo "PASS: verify-upstream-pins fails closed on wrong-signer / altered-.dsc / altered-.orig.tar" +else + echo "FAIL: fail-closed contract violated" +fi +exit "$rc" diff --git a/packaging/ci/verify-upstream-pins.sh b/packaging/ci/verify-upstream-pins.sh new file mode 100755 index 0000000..9257add --- /dev/null +++ b/packaging/ci/verify-upstream-pins.sh @@ -0,0 +1,254 @@ +#!/usr/bin/env bash +# verify-upstream-pins.sh — re-verify every field in packaging/upstream-pins.yaml. +# +# Proves the provenance chain for the four ModemManager-stack sources CeraLive rebuilds: +# +# 1. LINEAGE — `git ls-remote --tags ` still resolves to +# the pinned tag-object SHA and peeled commit SHA (and the same for the +# salsa packaging tag). The git tag authenticates *which commit* the release +# names; it is never byte-compared to a git archive. +# 2. AUTHORITY — the signed Debian `.dsc`'s GPG clearsign verifies against the pinned +# `signer_fingerprint`, whose armored key lives in packaging/keys/. A verified +# `.dsc` is the authority for the tarball checksums it embeds. +# 3. ARTIFACT — the `.orig.tar` downloads and its sha256 equals `orig_tar_sha256`, which +# equals the matching line in the `.dsc`'s Checksums-Sha256 (copied verbatim +# into the manifest's `dsc_checksums_sha256`). Chain closed. +# +# ALL GPG work happens in a throwaway isolated GNUPGHOME (mktemp -d, 0700, rm -rf on exit). +# The caller's ~/.gnupg is never touched or read. +# +# Exit status: +# 0 every field of every checked source verified. +# 1 a verification failure — a single line `FAIL [] : ` on stderr +# names exactly which field failed (fail-closed). +# 2 usage / environment error (missing tool, unreadable manifest). +# +# Usage: +# verify-upstream-pins.sh [OPTIONS] [MANIFEST] +# MANIFEST path to the pin manifest (default: