diff --git a/.forgejo/workflows/check.yml b/.forgejo/workflows/check.yml new file mode 100644 index 0000000..38e3b07 --- /dev/null +++ b/.forgejo/workflows/check.yml @@ -0,0 +1,24 @@ +name: check + +# Fast-fail lint/test on short-lived work branches, before it ever reaches +# main and triggers a dev-track release build. +on: + push: + branches: ['feature/**', 'fix/**'] + +jobs: + check: + runs-on: [self-hosted, hestia] + steps: + - name: checkout + run: | + set -euo pipefail + rm -rf src && mkdir src + git clone --branch "${GITHUB_REF_NAME}" --depth 1 \ + "https://git.breadway.dev/${GITHUB_REPOSITORY}.git" src + + - name: clippy + run: cd src && bash ci/build.sh cargo clippy --all-targets --locked -- -D warnings + + - name: test + run: cd src && bash ci/build.sh cargo test --release --locked diff --git a/.forgejo/workflows/dev-release.yml b/.forgejo/workflows/dev-release.yml new file mode 100644 index 0000000..d129c73 --- /dev/null +++ b/.forgejo/workflows/dev-release.yml @@ -0,0 +1,77 @@ +name: dev release + +# Publishes a dev-track build on every push to `main` (the trunk +# branch — there is no separate `dev` branch). See bread-ecosystem's +# docs/release-channels.md for the release-track policy this is part of. +on: + push: + branches: ['main'] + +jobs: + build: + runs-on: [self-hosted, hestia] + steps: + - name: checkout + run: | + set -euo pipefail + rm -rf src && mkdir src + git clone --branch main --depth 1 \ + "https://git.breadway.dev/${GITHUB_REPOSITORY}.git" src + + - name: build + run: cd src && bash ci/build.sh cargo build --release --locked + + - name: compute dev version + run: | + set -euo pipefail + cd src + # Base the dev version off the latest published stable tag, + # not Cargo.toml — Cargo.toml can go stale relative to the last + # real release (seen in practice: breadbox/breadpad/breadcrumbs/ + # breadpaper), which would make a dev build sort as OLDER than + # what's already installed and bakery would correctly refuse it. + LATEST_TAG="$(git ls-remote --tags --refs \ + "https://git.breadway.dev/${GITHUB_REPOSITORY}.git" 'v*' \ + | awk -F/ '{print $NF}' | sed 's/^v//' | (grep -v -- '-' || true) | sort -V | tail -1)" + if [ -n "${LATEST_TAG}" ]; then + CUR="${LATEST_TAG}" + else + CUR="$(grep -m1 '^version' Cargo.toml | sed -E 's/.*"(.*)".*/\1/')" + fi + IFS='.' read -r MA MI PA <<< "${CUR}" + SHA="$(git rev-parse --short HEAD)" + TS="$(date -u +%Y%m%d%H%M%S)" + echo "VERSION=${MA}.${MI}.$((PA + 1))-dev.${TS}+${SHA}" >> "$GITHUB_ENV" + + - name: prepare artifacts + run: | + set -euo pipefail + PKG_DIR="/srv/breadway-dl/dev/breadcrumbs/${VERSION}" + mkdir -p "${PKG_DIR}" + cp "src/target/release/breadcrumbs" "${PKG_DIR}/breadcrumbs-x86_64" + strip "${PKG_DIR}/breadcrumbs-x86_64" + sha256sum "${PKG_DIR}/breadcrumbs-x86_64" | awk '{print $1}' \ + > "${PKG_DIR}/breadcrumbs-x86_64.sha256" + cp src/breadcrumbs.example.toml "${PKG_DIR}/" + cp src/LICENSE "${PKG_DIR}/" + cp src/bakery.toml "${PKG_DIR}/bakery.toml" + ln -sfn "${VERSION}" "/srv/breadway-dl/dev/breadcrumbs/latest" + + # No GitHub Release upload — dev, like the other non-stable track, + # is only distributed via dl.breadway.dev/dev/. + - name: regenerate dev index.json + env: + MINISIGN_SEC_KEY: ${{ secrets.BAKERY_MINISIGN_SEC_KEY_PATH }} + run: | + set -euo pipefail + if [ -z "${MINISIGN_SEC_KEY:-}" ]; then + echo "::error::BAKERY_MINISIGN_SEC_KEY_PATH secret not set — refusing to regenerate dev index.json unsigned (would leave a stale signature mismatched against fresh content and break bakery for everyone on the dev track)" + exit 1 + fi + rm -rf /tmp/bread-ecosystem-ci-* 2>/dev/null || true + # mktemp: a fixed clone path races when multiple repos' dev/beta + # workflows run close together on the same self-hosted runner. + ECOSYSTEM_CI_DIR="$(mktemp -d /tmp/bread-ecosystem-ci-XXXXXX)" + git clone --branch main https://git.breadway.dev/Breadway/bread-ecosystem.git "${ECOSYSTEM_CI_DIR}" + TRACK=dev bash "${ECOSYSTEM_CI_DIR}/scripts/gen-index.sh" + rm -rf "${ECOSYSTEM_CI_DIR}" diff --git a/.forgejo/workflows/mirror.yml b/.forgejo/workflows/mirror.yml deleted file mode 100644 index 249d4de..0000000 --- a/.forgejo/workflows/mirror.yml +++ /dev/null @@ -1,19 +0,0 @@ -name: Mirror to GitHub - -on: - push: - branches: ['**'] - tags: ['**'] - -jobs: - mirror: - runs-on: [self-hosted, hestia] - steps: - - name: Mirror to GitHub - run: | - set -euo pipefail - git clone --mirror "https://git.breadway.dev/${GITHUB_REPOSITORY}.git" repo.git - cd repo.git - git push --prune \ - "https://x-access-token:${{ secrets.MIRROR_TOKEN }}@github.com/Breadway/breadcrumbs.git" \ - '+refs/heads/*:refs/heads/*' '+refs/tags/*:refs/tags/*' diff --git a/.forgejo/workflows/package.yml b/.forgejo/workflows/package.yml deleted file mode 100644 index 1b86c83..0000000 --- a/.forgejo/workflows/package.yml +++ /dev/null @@ -1,40 +0,0 @@ -name: Build and publish package - -on: - push: - tags: ['v*'] - -jobs: - package: - runs-on: [self-hosted, hestia] - container: - image: archlinux:latest - steps: - # Note: no actions/checkout — the archlinux image has no Node, which JS - # actions require. Everything runs as shell steps and clones manually. - - name: Build and publish - env: - PUBLISH_TOKEN: ${{ secrets.REGISTRY_TOKEN }} - run: | - set -euo pipefail - VERSION="${GITHUB_REF_NAME#v}" - pacman -Syu --noconfirm base-devel git rust cargo networkmanager - useradd -m builder - git config --global --add safe.directory '*' - git clone --branch "${GITHUB_REF_NAME}" --depth 1 \ - "https://git.breadway.dev/${GITHUB_REPOSITORY}.git" /home/builder/src - cd /home/builder/src - git archive --format=tar.gz --prefix="breadcrumbs-${VERSION}/" HEAD \ - > packaging/arch/breadcrumbs-${VERSION}.tar.gz - SHA=$(sha256sum packaging/arch/breadcrumbs-${VERSION}.tar.gz | awk '{print $1}') - sed -i "s/^pkgver=.*/pkgver=${VERSION}/" packaging/arch/PKGBUILD - sed -i "s/^sha256sums=.*/sha256sums=('${SHA}')/" packaging/arch/PKGBUILD - chown -R builder:builder /home/builder/src - # --nocheck: packaging builds the artifact; tests belong in a CI job. - su builder -c "cd /home/builder/src/packaging/arch && makepkg -f --noconfirm --nocheck" - PKG=$(find /home/builder/src/packaging/arch -name '*.pkg.tar.zst' | head -1) - curl -fsS -X PUT \ - -H "Authorization: token ${PUBLISH_TOKEN}" \ - -H "Content-Type: application/octet-stream" \ - --data-binary "@${PKG}" \ - "https://git.breadway.dev/api/packages/Breadway/arch/os" diff --git a/.forgejo/workflows/rc-release.yml b/.forgejo/workflows/rc-release.yml new file mode 100644 index 0000000..9a0b2e8 --- /dev/null +++ b/.forgejo/workflows/rc-release.yml @@ -0,0 +1,58 @@ +name: beta (rc) release + +# Publishes a beta-track build for any `vX.Y.Z-rc.N` prerelease tag +# pushed to `main` — there is no separate `beta` branch; "freezing" is +# just pausing pushes to main while an RC gets tested. See +# bread-ecosystem's docs/release-channels.md for the release-track policy. +on: + push: + tags: ['v*'] + +jobs: + build: + if: ${{ contains(github.ref_name, '-rc.') }} + runs-on: [self-hosted, hestia] + steps: + - name: checkout + run: | + set -euo pipefail + rm -rf src && mkdir src + git clone --branch "${GITHUB_REF_NAME}" --depth 1 \ + "https://git.breadway.dev/${GITHUB_REPOSITORY}.git" src + + - name: build + run: cd src && bash ci/build.sh cargo build --release --locked + + - name: prepare artifacts + run: | + set -euo pipefail + VERSION="${GITHUB_REF_NAME#v}" + PKG_DIR="/srv/breadway-dl/beta/breadcrumbs/${VERSION}" + mkdir -p "${PKG_DIR}" + cp "src/target/release/breadcrumbs" "${PKG_DIR}/breadcrumbs-x86_64" + strip "${PKG_DIR}/breadcrumbs-x86_64" + sha256sum "${PKG_DIR}/breadcrumbs-x86_64" | awk '{print $1}' \ + > "${PKG_DIR}/breadcrumbs-x86_64.sha256" + cp src/breadcrumbs.example.toml "${PKG_DIR}/" + cp src/LICENSE "${PKG_DIR}/" + cp src/bakery.toml "${PKG_DIR}/bakery.toml" + ln -sfn "${VERSION}" "/srv/breadway-dl/beta/breadcrumbs/latest" + + # No GitHub Release upload — beta, like dev, is only distributed via + # dl.breadway.dev/beta/. + - name: regenerate beta index.json + env: + MINISIGN_SEC_KEY: ${{ secrets.BAKERY_MINISIGN_SEC_KEY_PATH }} + run: | + set -euo pipefail + if [ -z "${MINISIGN_SEC_KEY:-}" ]; then + echo "::error::BAKERY_MINISIGN_SEC_KEY_PATH secret not set — refusing to regenerate beta index.json unsigned (would leave a stale signature mismatched against fresh content and break bakery for everyone on the beta track)" + exit 1 + fi + rm -rf /tmp/bread-ecosystem-ci-* 2>/dev/null || true + # mktemp: a fixed clone path races when multiple repos' dev/beta + # workflows run close together on the same self-hosted runner. + ECOSYSTEM_CI_DIR="$(mktemp -d /tmp/bread-ecosystem-ci-XXXXXX)" + git clone https://git.breadway.dev/Breadway/bread-ecosystem.git "${ECOSYSTEM_CI_DIR}" + TRACK=beta bash "${ECOSYSTEM_CI_DIR}/scripts/gen-index.sh" + rm -rf "${ECOSYSTEM_CI_DIR}" diff --git a/.forgejo/workflows/release.yml b/.forgejo/workflows/release.yml index 2ccf445..e04645d 100644 --- a/.forgejo/workflows/release.yml +++ b/.forgejo/workflows/release.yml @@ -6,6 +6,7 @@ on: jobs: build: + if: ${{ !contains(github.ref_name, '-rc.') }} runs-on: [self-hosted, hestia] steps: - name: checkout @@ -16,10 +17,19 @@ jobs: "https://git.breadway.dev/${GITHUB_REPOSITORY}.git" src - name: build - run: cd src && cargo build --release --locked + run: | + set -euo pipefail + if [ ! -f src/ci/build.sh ]; then + echo "::error::ci/build.sh is missing — bakery release builds must go through the shared CI wrapper" + exit 1 + fi + cd src && bash ci/build.sh cargo build --release --locked || { + echo "::error::cargo build --release --locked failed. If Cargo.lock drifted, update and commit it; do not drop --locked." + exit 1 + } - name: test - run: cd src && cargo test --release --locked + run: cd src && bash ci/build.sh cargo test --release --locked - name: prepare artifacts run: | @@ -32,13 +42,19 @@ jobs: sha256sum "${PKG_DIR}/breadcrumbs-x86_64" | awk '{print $1}' \ > "${PKG_DIR}/breadcrumbs-x86_64.sha256" cp src/breadcrumbs.example.toml "${PKG_DIR}/" + cp src/LICENSE "${PKG_DIR}/" cp src/bakery.toml "${PKG_DIR}/bakery.toml" - cp src/contrib/breadcrumbs.service "${PKG_DIR}/" ln -sfn "${VERSION}" "/srv/breadway-dl/breadcrumbs/latest" - name: regenerate index.json + env: + MINISIGN_SEC_KEY: ${{ secrets.BAKERY_MINISIGN_SEC_KEY_PATH }} run: | set -euo pipefail + if [ -z "${MINISIGN_SEC_KEY:-}" ]; then + echo "::error::BAKERY_MINISIGN_SEC_KEY_PATH secret not set — refusing to regenerate index.json unsigned (would leave a stale signature mismatched against fresh content and break bakery for everyone)" + exit 1 + fi rm -rf /tmp/bread-ecosystem-ci git clone https://git.breadway.dev/Breadway/bread-ecosystem.git /tmp/bread-ecosystem-ci bash /tmp/bread-ecosystem-ci/scripts/gen-index.sh diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..828096e --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,65 @@ +name: release + +on: + push: + tags: ["v*"] + +permissions: + contents: write + +env: + DL_DIR: /srv/breadway-dl + ECOSYSTEM_DIR: /home/breadway/Projects/bread-ecosystem + +jobs: + build: + runs-on: [self-hosted, hestia] + steps: + - uses: actions/checkout@v4 + + - name: install build deps + run: sudo apt-get install -y libnm-dev libdbus-1-dev pkg-config 2>/dev/null || true + + - name: build + run: cargo build --release --locked + + - name: test + run: cargo test --release --locked + + - name: prepare artifacts + run: | + VERSION="${GITHUB_REF_NAME#v}" + PKG_DIR="${DL_DIR}/breadcrumbs/${VERSION}" + mkdir -p "${PKG_DIR}" + cp target/release/breadcrumbs "${PKG_DIR}/breadcrumbs-x86_64" + strip "${PKG_DIR}/breadcrumbs-x86_64" + sha256sum "${PKG_DIR}/breadcrumbs-x86_64" | awk '{print $1}' \ + > "${PKG_DIR}/breadcrumbs-x86_64.sha256" + cp breadcrumbs.example.toml "${PKG_DIR}/" + cp bakery.toml "${PKG_DIR}/bakery.toml" + ln -sfn "${VERSION}" "${DL_DIR}/breadcrumbs/latest" + + - name: ensure bread-ecosystem + run: | + if [[ -d "${ECOSYSTEM_DIR}/.git" ]]; then + git -C "${ECOSYSTEM_DIR}" pull --ff-only + else + mkdir -p "$(dirname "${ECOSYSTEM_DIR}")" + git clone https://github.com/Breadway/bread-ecosystem.git "${ECOSYSTEM_DIR}" + fi + + - name: regenerate index.json + run: bash "${ECOSYSTEM_DIR}/scripts/gen-index.sh" + + - name: upload to GitHub Release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + VERSION="${GITHUB_REF_NAME#v}" + PKG_DIR="${DL_DIR}/breadcrumbs/${VERSION}" + gh release create "${GITHUB_REF_NAME}" \ + --title "breadcrumbs v${VERSION}" --generate-notes 2>/dev/null || true + gh release upload "${GITHUB_REF_NAME}" \ + "${PKG_DIR}/breadcrumbs-x86_64" \ + "${PKG_DIR}/breadcrumbs-x86_64.sha256" \ + --clobber diff --git a/.gitignore b/.gitignore index 21ec4d6..387532f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,9 +1,12 @@ # Build output /target/ -# Secrets: live config holds plaintext Wi-Fi passwords. -# Keep local only; see breadcrumbs.example.toml for the schema. +# Secrets: live config holds plaintext Wi-Fi passwords (until NetworkManager +# takes over each one on first connect — see README's "Credential handling"). +# Keep local only; see breadcrumbs.example.toml / networks.example.toml for +# the schemas. /breadcrumbs.toml +/networks.toml # Legacy plaintext credential store (migrated into breadcrumbs.toml on first run) /Networks/ @@ -34,3 +37,9 @@ desktop.ini # Claude Code local state .claude/ + +# Local hygiene notes (not for commit) +CLAUDE.md + +# graphify knowledge-graph output (local tool cache, not for commit) +graphify-out/ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..04d6d81 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,50 @@ +# AGENTS.md — Repo hygiene + +Scope: this file covers *repo hygiene* — branching, remotes, CI, cleanup. It is not project documentation. + +This repo follows the branch/release workflow documented in `CONTRIBUTING.md` +— read and follow it for any git, branch, or release work here (the +single-trunk model, `feature/x`/`fix/x` branch naming, how RC tags work, +etc). Don't improvise a different workflow. The short version: there is one +long-lived branch, `main` — no `dev` or `beta` branch exists. `main` +auto-publishes a dev-track build on every push. "Beta" and "stable" are both +just tags, not branches: push a `vX.Y.Z-rc.N` tag to publish a beta-track +build, push a plain `vX.Y.Z` tag to cut the signed stable release. +"Freezing" for stabilization means pausing pushes to `main`, not moving a +branch. This replaced an earlier three-branch (`dev`/`beta`/`main`) model +after `main` was found to have silently rotted out of sync with `dev`/`beta` +across most repos in this ecosystem — a manual "merge beta into main +monthly" step nobody reliably did across a dozen-plus repos. Collapsing to +one branch removes the class of bug; there's nothing left that can fall out +of sync. + +## Remotes +- `origin` — Forgejo (`git.breadway.dev` via Hestia, SSH) — authoritative. +- `github` — GitHub mirror. Push both when publishing. Agents push `origin` only; the GitHub remote auto-mirrors. + +## Distribution +- Bakery-only. `bakery.toml` is the product manifest; there is no + `packaging/arch/PKGBUILD` in this repo. +- Tracks: `bakery track set {dev,beta,stable}` then `bakery update breadcrumbs` + (or `bakery update --all`). See CONTRIBUTING.md and bread-ecosystem's + `docs/release-channels.md`. + +## Events +- Bread bus contract: `EVENTS.md`. App id is `crumbs`. +- Fail-silent: breadcrumbs behaves the same whether `breadd` is running or + not. Commands (`bread.command.crumbs.*`) are only received while + `breadcrumbs watch` / the user systemd unit is up. + +## CI +- `check.yml` — clippy + `cargo test --release` on `feature/**` and `fix/**`. +- `dev-release.yml` — triggered on push to `main` (dev-track bakery publish). +- `rc-release.yml` — triggered on any `vX.Y.Z-rc.N` tag push (beta track). +- `release.yml` — triggered on any other `v*` tag push (signed stable). + +All CI runs on a self-hosted runner. No build/lint/test CI runs on ordinary +commits or PRs to `main` beyond the dev-track workflow above. + +## Don't +- Don't embed credentials in remote URLs — SSH or a credential helper only. +- Don't invent bread command verbs that have no real breadcrumbs feature + behind them. See EVENTS.md. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..1de0eb2 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,84 @@ +# Contributing + +`breadcrumbs` — Profile-aware Wi-Fi state machine with Tailscale integration. + +Part of the bread ecosystem; this repo follows the same branch/release +workflow as every other ecosystem product. + +## Branches + +There is one long-lived branch: **`main`**. All day-to-day work lands here. +Every push to `main` automatically builds and publishes a **dev-track** +build (see Tracks below) — a real install you can test before cutting +anything more formal. + +New work — features and bug fixes alike — goes on a short-lived branch: + +``` +feature/ +fix/ +``` + +Branch off `main`, open a PR/push back into `main` when ready. Short-lived +branches get deleted on merge — they never accumulate the kind of drift a +second long-lived branch does. + +## The release cycle + +There's no separate `beta` or release branch — "stable" and "beta" are both +just **tags** on `main`, not branches that need to be kept in sync: + +1. Work accumulates on `main` via `feature/x` / `fix/x` branches. Each push + auto-publishes a dev build — install it with `bakery track set dev` and + `bakery update --all`, then fix anything broken with another push. +2. When you want to stabilize before a real release, tag a release + candidate: `git tag vX.Y.Z-rc.1 && git push origin vX.Y.Z-rc.1` (push to + both remotes). That tag alone triggers a beta-track build — + "freezing" is just pausing pushes to `main` while you test it, not a + branch operation. Cut `-rc.2`, `-rc.3`, etc. for further fixes. +3. Once an RC has gone without issues, tag the real release: + `git tag vX.Y.Z && git push origin vX.Y.Z` — that's what triggers the + signed stable release build. + +## Tracks, from a user's perspective + +``` +bakery track show # what you're currently on (defaults to stable) +bakery track set dev # or beta, or stable +bakery update --all # pull the latest build on your current track +``` + +| Track | What it is | Published from | +|--------|-----------|-----------------| +| `stable` | The last tagged release | a `vX.Y.Z` tag | +| `beta` | Latest release candidate | a `vX.Y.Z-rc.N` tag | +| `dev` | Bleeding edge | `main`, on every push | + +Dev versions are auto-computed (`X.Y.Z-dev.+`) from the +latest published stable tag, so they always sort as newer than what you +have installed — no manual version bumping needed. Beta versions are just +the RC tag itself (already valid semver, already sorts below the real +release it's a candidate for). + +## Local development + +```sh +cargo build --release +cargo test --release +``` + +## CI + +- `dev-release.yml` — triggered on push to `main`. +- `rc-release.yml` — triggered on any `vX.Y.Z-rc.N` tag push. +- `release.yml` — triggered on any other `v*` tag push, cuts the actual + stable release. + +All CI runs on a self-hosted runner; nothing runs automatically on plain +commits or PRs beyond the track builds above. See +[bread-ecosystem's docs/release-channels.md](https://git.breadway.dev/Breadway/bread-ecosystem/src/branch/main/docs/release-channels.md) +for the full policy, including how a new product gets wired onto these tracks. + +## Questions + +Open an issue on this repo's Forgejo tracker. diff --git a/Cargo.lock b/Cargo.lock index b2f1d99..2f1a4fa 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -38,7 +38,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -49,19 +49,237 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] -name = "breadcrumbs" -version = "2.1.4" +name = "async-broadcast" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" dependencies = [ - "clap", + "event-listener", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-executor" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" +dependencies = [ + "async-task", + "concurrent-queue", + "fastrand", + "futures-lite", + "pin-project-lite", + "slab", +] + +[[package]] +name = "async-fs" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8034a681df4aed8b8edbd7fbe472401ecf009251c8b40556b304567052e294c5" +dependencies = [ + "async-lock", + "blocking", + "futures-lite", +] + +[[package]] +name = "async-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-lock" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-process" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" +dependencies = [ + "async-channel", + "async-io", + "async-lock", + "async-signal", + "async-task", + "blocking", + "cfg-if", + "event-listener", + "futures-lite", + "rustix", +] + +[[package]] +name = "async-recursion" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "async-signal" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485" +dependencies = [ + "async-io", + "async-lock", + "atomic-waker", + "cfg-if", + "futures-core", + "futures-io", + "rustix", + "signal-hook-registry", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + +[[package]] +name = "async-trait" +version = "0.1.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "blocking" +version = "1.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21" +dependencies = [ + "async-channel", + "async-task", + "futures-io", + "futures-lite", + "piper", +] + +[[package]] +name = "bread-shared" +version = "0.7.0" +source = "git+https://git.breadway.dev/Breadway/bread?tag=v0.7.0#22e34e2cf2202305d7960759dfccb54dc79f948b" +dependencies = [ + "dirs", "serde", "serde_json", "toml", ] +[[package]] +name = "bread-utils" +version = "0.7.2" +source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?tag=v0.7.2#30517f161724132cdeb658c04cf5e490be07ee73" +dependencies = [ + "bread-shared", + "dirs", + "serde", + "serde_json", +] + +[[package]] +name = "breadcrumbs" +version = "2.1.7" +dependencies = [ + "bread-utils", + "clap", + "serde", + "serde_json", + "toml", + "zbus", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + [[package]] name = "clap" version = "4.6.1" @@ -93,7 +311,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -108,12 +326,224 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "dirs" +version = "5.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.48.0", +] + +[[package]] +name = "endi" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" + +[[package]] +name = "enumflags2" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" +dependencies = [ + "enumflags2_derive", + "serde", +] + +[[package]] +name = "enumflags2_derive" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "equivalent" version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "event-listener" +version = "5.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" +dependencies = [ + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-io" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" + +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + +[[package]] +name = "futures-sink" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-core", + "futures-io", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", +] + [[package]] name = "hashbrown" version = "0.17.1" @@ -126,6 +556,18 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + [[package]] name = "indexmap" version = "2.14.0" @@ -148,18 +590,138 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "libredox" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28d0a00925a9f930d679b6789b721e3a7f9ed110f41b86d2497caa780c3a070a" +dependencies = [ + "libc", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + [[package]] name = "memchr" version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "nix" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +dependencies = [ + "bitflags", + "cfg-if", + "cfg_aliases", + "libc", + "memoffset", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + [[package]] name = "once_cell_polyfill" version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "ordered-stream" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" +dependencies = [ + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "piper" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] + +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit 0.25.13+spec-1.1.0", +] + [[package]] name = "proc-macro2" version = "1.0.106" @@ -178,6 +740,66 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22f6172bdec972074665ed81ed53b71da00bfc44b65a753cfde883ec4c702a1a" +dependencies = [ + "libc", + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "redox_users" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + [[package]] name = "serde" version = "1.0.228" @@ -205,7 +827,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -221,6 +843,17 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_repr" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + [[package]] name = "serde_spanned" version = "0.6.9" @@ -230,6 +863,39 @@ dependencies = [ "serde", ] +[[package]] +name = "sha1" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + [[package]] name = "strsim" version = "0.11.1" @@ -247,6 +913,50 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "toml" version = "0.8.23" @@ -255,8 +965,8 @@ checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" dependencies = [ "serde", "serde_spanned", - "toml_datetime", - "toml_edit", + "toml_datetime 0.6.11", + "toml_edit 0.22.27", ] [[package]] @@ -268,6 +978,15 @@ dependencies = [ "serde", ] +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + [[package]] name = "toml_edit" version = "0.22.27" @@ -277,9 +996,30 @@ dependencies = [ "indexmap", "serde", "serde_spanned", - "toml_datetime", + "toml_datetime 0.6.11", "toml_write", - "winnow", + "winnow 0.7.15", +] + +[[package]] +name = "toml_edit" +version = "0.25.13+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6975367e4d2ef766d86af01ffad14b622fecc8d4357a998fbc4deb6e9bacaf9b" +dependencies = [ + "indexmap", + "toml_datetime 1.1.1+spec-1.1.0", + "toml_parser", + "winnow 1.0.4", +] + +[[package]] +name = "toml_parser" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" +dependencies = [ + "winnow 1.0.4", ] [[package]] @@ -288,6 +1028,54 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "uds_windows" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" +dependencies = [ + "memoffset", + "tempfile", + "windows-sys 0.61.2", +] + [[package]] name = "unicode-ident" version = "1.0.24" @@ -300,12 +1088,51 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + [[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + [[package]] name = "windows-sys" version = "0.61.2" @@ -315,6 +1142,127 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + [[package]] name = "winnow" version = "0.7.15" @@ -324,8 +1272,146 @@ dependencies = [ "memchr", ] +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" +dependencies = [ + "memchr", +] + +[[package]] +name = "xdg-home" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec1cdab258fb55c0da61328dc52c8764709b249011b2cad0454c72f0bf10a1f6" +dependencies = [ + "libc", + "windows-sys 0.59.0", +] + +[[package]] +name = "zbus" +version = "4.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb97012beadd29e654708a0fdb4c84bc046f537aecfde2c3ee0a9e4b4d48c725" +dependencies = [ + "async-broadcast", + "async-executor", + "async-fs", + "async-io", + "async-lock", + "async-process", + "async-recursion", + "async-task", + "async-trait", + "blocking", + "enumflags2", + "event-listener", + "futures-core", + "futures-sink", + "futures-util", + "hex", + "nix", + "ordered-stream", + "rand", + "serde", + "serde_repr", + "sha1", + "static_assertions", + "tracing", + "uds_windows", + "windows-sys 0.52.0", + "xdg-home", + "zbus_macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "zbus_macros" +version = "4.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "267db9407081e90bbfa46d841d3cbc60f59c0351838c4bc65199ecd79ab1983e" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.117", + "zvariant_utils", +] + +[[package]] +name = "zbus_names" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b9b1fef7d021261cc16cba64c351d291b715febe0fa10dc3a443ac5a5022e6c" +dependencies = [ + "serde", + "static_assertions", + "zvariant", +] + +[[package]] +name = "zerocopy" +version = "0.8.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "556764e583adb45a9f8d413c2a147fa7e8d821e48e12b14fd560b607998b75eb" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ab42fc20575779bd240faa45f94a74256f755c0fa9e89f0ede20d91d0cdfc1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "zmij" version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" + +[[package]] +name = "zvariant" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2084290ab9a1c471c38fc524945837734fbf124487e105daec2bb57fd48c81fe" +dependencies = [ + "endi", + "enumflags2", + "serde", + "static_assertions", + "zvariant_derive", +] + +[[package]] +name = "zvariant_derive" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73e2ba546bda683a90652bac4a279bc146adad1386f25379cf73200d2002c449" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.117", + "zvariant_utils", +] + +[[package]] +name = "zvariant_utils" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c51bcff7cc3dbb5055396bcf774748c3dab426b4b8659046963523cee4808340" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] diff --git a/Cargo.toml b/Cargo.toml index e2333f2..59d3fb2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "breadcrumbs" -version = "2.1.4" +version = "2.1.7" edition = "2021" description = "Profile-aware Wi-Fi state machine with Tailscale handling and self-healing watch daemon" license = "MIT" @@ -14,6 +14,8 @@ clap = { version = "4", features = ["derive"] } serde = { version = "1", features = ["derive"] } toml = "0.8" serde_json = "1" +zbus = "4" +bread-utils = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.2", features = ["bread-client"] } [profile.release] opt-level = "s" diff --git a/EVENTS.md b/EVENTS.md new file mode 100644 index 0000000..a17325a --- /dev/null +++ b/EVENTS.md @@ -0,0 +1,81 @@ +# breadcrumbs — bread event integration + +breadcrumbs is a standalone Wi-Fi state machine: it works exactly the same +with or without `breadd` running. When breadd *is* present **and** the +`breadcrumbs watch` daemon (or the systemd user service it installs) is up, +breadcrumbs publishes events into the shared bread automation fabric and +listens for a small set of commands. See the parent `bread` repo's +`Documentation.md` — specifically its "Namespaces" and "Integrating a +bread\* app" sections — for the general convention this follows. + +App id: **`crumbs`**. Transport: `bread-utils`'s `bread_client` module +(feature `bread-client`) — the watch process links it directly, since it's +the long-running piece that both emits on real transitions and holds the +command subscription open. + +One-shot CLI invocations (`breadcrumbs status`, `profile set`, `init`, …) +do **not** emit or subscribe on their own. A `breadcrumbs profile set home` +while the watcher is running is picked up on the watcher's next tick +(state is re-read every loop) and *then* published as +`bread.crumbs.profile.changed`. If the watcher is not running, the CLI +still switches the profile on disk — there is just nobody listening for +`bread.command.crumbs.*`, and nobody emitting `bread.crumbs.*`. + +## Events published (`bread.crumbs.*`) + +| Event | Data | When | +|-------|------|------| +| `bread.crumbs.profile.changed` | `{ "from": "", "to": "" }` | The watch loop observes that the persisted active profile is no longer the one it last acted on (CLI `profile set`, `detect --apply`, `bread.command.crumbs.set_profile`, or a time-of-day schedule switch). Not emitted on watcher start just because a profile is already selected. | +| `bread.crumbs.health.changed` | `{ "profile": "", "health": "", "ssid": , "iface": , "ip": , "exit_node": "", "tailscale": }` | The watch loop's health classification changes — including the first observation after start, and the forced re-evaluation after a profile change. **Not** emitted on every poll tick while the classification stays the same. | +| `bread.crumbs.network.changed` | `{ "from": , "to": , "profile": "" }` | The active SSID changed between watch-loop ticks. `from` is `null` on the first association observed after start (or after a profile switch). | +| `bread.crumbs.tailscale.changed` | `{ "profile": "", "state": , "exit_node": "" }` | The Tailscale health state (or its mere presence) changed between ticks. `state` is the `TsHealth` variant name or `null` when Tailscale isn't installed. | +| `bread.crumbs.set_profile.done` | `{ "profile": "" }` | `bread.command.crumbs.set_profile` persisted the new profile. | +| `bread.crumbs.set_profile.failed` | `{ "error": "" }` | `bread.command.crumbs.set_profile` was received but rejected (unknown profile, missing `profile` field, config unreadable). | + +`health` is the Rust enum variant name, not a prettier label: + +| Variant | Meaning | +|---------|---------| +| `Up` | Adapter present, internet reachable, Tailscale healthy if the profile requires it. | +| `DownNoNet` | No internet. | +| `CaptivePortal` | No internet and an HTTP response arrived that wasn't the 204 generate_204 returns — traffic is being intercepted (captive/guest portal). Needs a browser sign-in, not a reconnect. | +| `DownTailscaleManual` | Tailscale required but needs login / isn't installed — cannot auto-fix. | +| `DownTailscaleOther` | Tailscale required and unhealthy for some other (usually auto-recoverable) reason. | +| `NoAdapter` | No Wi-Fi interface. | +| `UnknownProfile` | Persisted profile name is not in the config. | + +`ssid` is the currently-associated SSID, or `null` when there isn't one +(no adapter, not associated, unknown profile). + +## Commands honored (`bread.command.crumbs.*`) + +These are only received while `breadcrumbs watch` / `breadcrumbs.service` +is running. Publishing a command with no subscriber is a silent no-op — +that is the documented bread convention, not a breadcrumbs bug. + +| Verb | Data | Effect | +|------|------|--------| +| `set_profile` | `{ "profile": "" }` | Persist `` via the same `state::set_profile` path the CLI uses. Wakes the watch loop immediately so the new profile is classified (and recovered, if down) on the next tick rather than waiting out the current poll interval. Does **not** run `flow::run` on the command thread — that would race the watch loop. Emits `bread.crumbs.set_profile.done`/`.failed`. | + +### Not implemented: extra verbs + +There is no `pin`, `select`, `scan`, `init`, or other command verb. The +CLI already covers those as synchronous one-shots (`breadcrumbs init`, +`breadcrumbs scan`, …), and breadcrumbs has no "pinned network" concept +to hang a bus verb on. If/when that changes, the corresponding +`bread.command.crumbs.*` verb should be added at the same time, not +stubbed out ahead of it. + +## Fail-safe behavior + +- If breadd isn't installed or isn't running, `emit` is a silent no-op + (`BreadClient::emit` never blocks or errors the caller) and the + command subscription simply never receives anything — breadcrumbs' + actual Wi-Fi / Tailscale / watch functionality is entirely unaffected + either way. +- If breadd restarts, the command subscription reconnects automatically + (`BreadClient::subscribe`'s background thread has its own backoff loop); + no restart of the breadcrumbs watcher is needed. +- If the breadcrumbs watcher is not running, commands are a graceful + no-op at the bus (no subscriber) and no `bread.crumbs.*` events fire. + The CLI still works. diff --git a/README.md b/README.md index 6bc6220..d8877f5 100644 --- a/README.md +++ b/README.md @@ -2,23 +2,21 @@ A profile-aware Wi-Fi state machine for Linux with Tailscale exit-node management and a self-healing watch daemon. -breadcrumbs sits on top of NetworkManager (`nmcli`) and manages your Wi-Fi based on **location profiles**. Switch between home, work, school, or any other context with a single command — it handles scanning, connecting, DNS pinning, and Tailscale setup automatically. +breadcrumbs sits on top of NetworkManager's **D-Bus API** (`org.freedesktop.NetworkManager` on the system bus — no `nmcli` subprocesses) and manages your Wi-Fi based on **location profiles**. Switch between home, work, school, or any other context with a single command — it handles scanning, connecting, DNS pinning, and Tailscale setup automatically. ## Features - **Profile-based connection management** — define ordered network priority lists per location - **Bootstrap + Tailscale gating** — connect to an interim network first, bring up Tailscale, then move to the target network -- **Self-healing watch daemon** — monitors for drops, auto-recovers, reacts within seconds via `nmcli monitor` -- **Auto-detection** — scans visible SSIDs and guesses your location from config-defined markers (picks the profile with the most markers in range) -- **Captive-portal detection** — distinguishes a real connection from a sign-in page and surfaces the portal URL instead of falsely reporting "online" -- **Secure credential handling** — passwords fed to `nmcli` out-of-band (via stdin with `--ask`, or a 0600 `passwd-file`), never in argv/`ps`; config stored at 0600 -- **Machine-readable status** — `breadcrumbs status --json` for bars/scripts +- **Self-healing watch daemon** — monitors for drops, auto-recovers, reacts within seconds via NetworkManager D-Bus signals +- **Auto-detection** — scans visible SSIDs and guesses your location from config-defined markers +- **Credential handling** — a saved network's password is only needed the *first* time breadcrumbs connects to it. Once that connect succeeds, NetworkManager durably owns the credential (a new connection profile, or an updated PSK on an existing one), so breadcrumbs clears its own local copy and stops writing it to disk. Both config files are `0600` (owner-only); saved networks live in a separate `networks.toml` from settings/profiles (see [Configuration](#configuration)). Secrets are never exposed in a process command line: everything travels inside D-Bus `Update2`/`AddAndActivateConnection2` settings payloads, invisible to other local users via `/proc//cmdline`. - **Desktop notifications** via `notify-send` (optional) - **systemd user service** generation via `breadcrumbs install-service` ## Requirements -- Linux with NetworkManager (`nmcli` in `$PATH`) +- Linux with NetworkManager running on the D-Bus system bus - Rust toolchain (to build from source) - `tailscale` (optional — only needed if any profile sets `tailscale = true`) - `notify-send` (optional — for desktop notifications) @@ -27,7 +25,7 @@ breadcrumbs sits on top of NetworkManager (`nmcli`) and manages your Wi-Fi based ## Installation ```bash -git clone https://github.com/Breadway/breadcrumbs +git clone https://github.com/breadway/breadcrumbs cd breadcrumbs cargo build --release # Copy to somewhere on your PATH: @@ -36,32 +34,38 @@ cp target/release/breadcrumbs ~/.local/bin/ ## Configuration -On first run, breadcrumbs creates `~/.config/breadcrumbs/breadcrumbs.toml` with default profiles. Copy `breadcrumbs.example.toml` as a starting point and fill in your real network credentials: +On first run, breadcrumbs creates `~/.config/breadcrumbs/breadcrumbs.toml` (settings + profiles) and `~/.config/breadcrumbs/networks.toml` (saved networks) with default profiles. Copy `breadcrumbs.example.toml` as a starting point for the former: ```bash cp breadcrumbs.example.toml ~/.config/breadcrumbs/breadcrumbs.toml -breadcrumbs edit # opens in $EDITOR +breadcrumbs edit # opens breadcrumbs.toml in $EDITOR ``` +...then add your real networks with `breadcrumbs add`/`scan` rather than hand-editing `networks.toml` (see `networks.example.toml` if you want to see its shape or write it by hand anyway). + Config paths respect `$XDG_CONFIG_HOME` and `$XDG_STATE_HOME`. ### Config structure +Settings and location profiles live in `breadcrumbs.toml` — the file people actually hand-edit or dotfile: + ```toml [settings] dns = "1.1.1.1" # DNS server pinned on every connection -nmcli_wait = 8 # seconds to wait for nmcli connect +connect_wait = 8 # seconds to wait for the device to reach the activated state (legacy key: nmcli_wait) exit_node = "myhostname" # default Tailscale exit node +exit_nodes = ["a", "b"] # optional priority list; tried in order (fallback nodes) +interface = "wlan0" # optional preferred Wi-Fi interface +schedule = [] # optional time-of-day profile switches, e.g. + # [[settings.schedule]] + # profile = "home" + # from = "18:00" + # to = "08:00" # from >= to = overnight window default_profile = "away" watch_interval = 12 # seconds between health checks (minimum 4) connectivity_url = "http://connectivitycheck.gstatic.com/generate_204" ping_host = "1.1.1.1" -[[networks]] -ssid = "MyHomeNetwork" -password = "hunter2" -hidden = false - [profiles.home] networks = ["MyHomeNetwork"] # priority-ordered SSIDs tailscale = false @@ -76,6 +80,26 @@ exit_node = "jump-host" # per-profile override detect_ssids = ["CorpWifi", "Corp-5G"] ``` +Saved networks (SSID + optional local password) live separately, in `networks.toml`, managed via `add`/`scan`/`forget`: + +```toml +[[networks]] +ssid = "MyHomeNetwork" +password = "hunter2" # optional — see "Credential handling" below +hidden = false +dns = "1.1.1.1" # optional per-network DNS override; "" disables pinning + +# WPA-Enterprise (802.1x) networks use these instead of a PSK: +# [[networks]] +# ssid = "CorpEAP" +# eap = "peap" # or "tls" +# identity = "user@corp" +# password = "..." # 802.1x password +# ca_cert = "/etc/ssl/certs/corp-ca.pem" # optional +``` + +`password` is only needed the first time breadcrumbs connects to a network. Once NetworkManager durably saves the credential, breadcrumbs clears its local copy and omits the key on the next save — an existing config with `password = "..."` still loads fine either way, no migration step needed. A config with `[[networks]]` still written inline in `breadcrumbs.toml` (from before this split) also still loads: it's read once, then migrated into `networks.toml` automatically on the next save. + ### Profiles Each profile defines: @@ -87,7 +111,8 @@ Each profile defines: | `bootstrap` | SSID to connect to first (e.g. guest Wi-Fi that allows Tailscale traffic). | | `exit_node` | Tailscale exit node for this profile (overrides `settings.exit_node`). | | `include_all_known` | After the priority list, also try every other known network. | -| `detect_ssids` | Any visible SSID in this list marks this profile as a candidate for `breadcrumbs detect`. | +| `detect_ssids` | Any visible SSID in this list marks this profile as a candidate for `breadcrumbs detect`. Profiles with more matching markers win. | +| `learn` | If `true`, SSIDs this profile successfully connects to are appended to `detect_ssids` (bounded), so `detect` improves without hand-editing. Off by default. | ## Usage @@ -97,21 +122,17 @@ breadcrumbs [--profile ] | Command | Description | |---------|-------------| -| `status [--json]` | Show current Wi-Fi / Tailscale health (default); `--json` for scripts | -| `init` | Run the full connect sequence for the active profile | +| `status [--json]` | Show current Wi-Fi / Tailscale health (default) | +| `init [--wait ]` | Run the full connect sequence; `--wait` retries until connected or the timeout elapses | | `watch [--no-initial]` | Self-healing daemon: monitors and auto-recovers drops | | `profile get` | Print the active profile | | `profile set ` | Switch profile (and apply it, unless `--no-apply`) | | `profile list` | List all profiles | -| `profile add [--detect ]…` | Create a new (empty) profile, optionally with detection markers | -| `profile remove ` | Delete a profile (core `home`/`work`/`away` are protected) | -| `detect [--apply]` | Guess profile from visible networks; optionally apply it | -| `add [password]` | Add or update a saved network | +| `detect [--apply] [--json]` | Guess profile from visible networks; optionally apply it | +| `add [password]` | Add or update a saved network (`--dns`, `--eap`, `--identity`, `--ca-cert`, `--hidden`, `--to`, `--at`) | | `forget ` | Remove a network from config and NetworkManager | -| `join ` | Connect to a specific saved network by SSID, bypassing profile routing | -| `networks [--json]` | List saved network SSIDs | +| `prune [--dry-run]` | Remove NetworkManager wireless profiles whose SSID is no longer in the config | | `scan [--to ]` | Interactive scan, pick, connect and save | -| `scan-list [--json]` | Scan for visible networks and list them with signal strength | | `list [--show-passwords]` | Show config: settings, networks, profiles | | `edit` | Open config in `$EDITOR`, validate on exit | | `doctor [--full]` | Quick connectivity and Tailscale diagnostics | @@ -148,9 +169,20 @@ breadcrumbs install-service `breadcrumbs watch` is the recommended way to run breadcrumbs for daily use. It: 1. Polls health every `watch_interval` seconds (adaptive backoff on repeated failures) -2. Reacts immediately to link-state changes via `nmcli monitor` +2. Reacts immediately to link-state changes via NetworkManager D-Bus signals (`Device.StateChanged`, `Connectivity` property changes, hotplug events) 3. Runs `flow::run` (the connect state machine) on any detected drop 4. Handles profile changes live — re-reads config and state on every tick +5. Distinguishes captive portals from plain no-internet (a 200/301/302 instead + of the 204 generate_204 returns) and tells you to sign in instead of + pointlessly reconnecting +6. Applies a `[settings.schedule]` time-of-day profile switch, respecting a + 30-minute grace window after a manual `profile set` +7. Detects suspend/resume (a large gap between ticks) and forces an immediate + recovery check instead of waiting out the poll interval + +When a Tailscale profile is connected through a bootstrap network and the +connectivity check is intercepted, the watcher stays put and notifies once — +it does not churn reconnects against a portal. Install as a systemd user service: diff --git a/bakery.toml b/bakery.toml index 58d8b32..4e3a567 100644 --- a/bakery.toml +++ b/bakery.toml @@ -4,16 +4,11 @@ binaries = ["breadcrumbs"] system_deps = ["networkmanager"] optional_system_deps = ["tailscale", "sudo", "xdg-utils"] bread_deps = [] +license_file = "LICENSE" [config] dir = "~/.config/breadcrumbs" example = "breadcrumbs.example.toml" -[[service]] -unit = "breadcrumbs.service" -enable = true - [install] -post_install = [ - "systemctl --user is-active --quiet breadcrumbs || systemctl --user start breadcrumbs", -] +post_install = [] diff --git a/breadcrumbs.example.toml b/breadcrumbs.example.toml index 6d42e44..745fb50 100644 --- a/breadcrumbs.example.toml +++ b/breadcrumbs.example.toml @@ -4,33 +4,22 @@ # just run breadcrumbs once (it generates a skeleton) and then use # `breadcrumbs add` / `breadcrumbs edit` to fill in your networks. # The real breadcrumbs.toml is gitignored and never committed. +# +# Saved networks (SSID + optional local password) live in a separate file, +# networks.toml, in the same directory — not here. See +# networks.example.toml for its format; in practice you never hand-edit it, +# `breadcrumbs add` / `scan` / `forget` manage it for you. This file is just +# settings + the location profiles built from those saved networks. [settings] dns = "1.1.1.1" -nmcli_wait = 8 +connect_wait = 8 exit_node = "my-exit-node" # Tailscale hostname of your preferred exit node default_profile = "away" watch_interval = 12 -# Must be a "generate_204"-style endpoint: only an empty HTTP 204 counts as -# online, so a captive portal (200 login page / 30x redirect) is detected. connectivity_url = "http://connectivitycheck.gstatic.com/generate_204" ping_host = "1.1.1.1" -[[networks]] -ssid = "HomeWifi" -password = "REPLACE_ME" -hidden = false - -[[networks]] -ssid = "WorkGuest" -password = "REPLACE_ME" -hidden = false - -[[networks]] -ssid = "CorpWifi" -password = "REPLACE_ME" -hidden = false - # Location state machine. Switch with: breadcrumbs profile set # # detect_ssids: list any SSIDs that reliably indicate you are at this location. diff --git a/ci/bread-ecosystem.rev b/ci/bread-ecosystem.rev new file mode 100644 index 0000000..34e7aa9 --- /dev/null +++ b/ci/bread-ecosystem.rev @@ -0,0 +1 @@ +147cfbbf96ae4b171027defa1130d2caddb934b1 diff --git a/ci/build.sh b/ci/build.sh new file mode 100755 index 0000000..95e06f8 --- /dev/null +++ b/ci/build.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +# Delegates to bread-ecosystem's shared CI build image/script, pinned to +# the commit in ci/bread-ecosystem.rev — not `main`. bread-ecosystem's CI +# files now affect every product's release pipeline, so bumping the pin +# is a deliberate act instead of silent drift (see the bread-theme test +# that broke here for exactly that reason, before it was pinned by rev). +# +# Usage: ci/build.sh cargo build --release --locked +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +REV="$(cat "${ROOT}/ci/bread-ecosystem.rev")" + +CACHE_DIR="/tmp/bread-ecosystem-ci-${REV}" +if [ ! -d "$CACHE_DIR" ]; then + rm -rf /tmp/bread-ecosystem-ci-* + git clone https://git.breadway.dev/Breadway/bread-ecosystem.git "$CACHE_DIR" + git -C "$CACHE_DIR" checkout --quiet "$REV" +fi + +bash "${CACHE_DIR}/ci/build.sh" breadcrumbs "$ROOT" "$@" diff --git a/contrib/breadcrumbs.service b/contrib/breadcrumbs.service deleted file mode 100644 index 5db772d..0000000 --- a/contrib/breadcrumbs.service +++ /dev/null @@ -1,30 +0,0 @@ -[Unit] -Description=breadcrumbs Wi-Fi state machine watcher -Documentation=https://git.breadway.dev/Breadway/breadcrumbs -After=network.target NetworkManager.service -Wants=network.target - -[Service] -Type=simple -Environment=PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin -# ExecStart is rewritten at install time by bakery to point at the real -# bin_dir (patch_exec_start in bakery's install.rs) — this path is only a -# placeholder for local testing. -ExecStart=%h/.local/bin/breadcrumbs watch -Restart=always -RestartSec=5 -Nice=5 - -# Forward stdout/stderr to the journal so `journalctl --user -u breadcrumbs` works -StandardOutput=journal -StandardError=journal - -[Install] -# default.target, not graphical-session.target: breadcrumbs is a headless -# network daemon with no Wayland/GUI dependency, and graphical-session.target -# ships with RefuseManualStart=yes (only a session manager like uwsm can -# activate it — BOS doesn't use one, which is why breadclipd needs a manual -# `systemctl --user start` in hyprland.lua's exec-once instead of relying on -# WantedBy). default.target activates normally with the user session, no -# workaround needed. -WantedBy=default.target diff --git a/networks.example.toml b/networks.example.toml new file mode 100644 index 0000000..90d8689 --- /dev/null +++ b/networks.example.toml @@ -0,0 +1,29 @@ +# breadcrumbs saved-networks file. +# +# Lives alongside breadcrumbs.toml at ~/.config/breadcrumbs/networks.toml, +# 0600 permissions. Shown here purely for reference — you normally never +# hand-edit this file; use `breadcrumbs add` / `scan` / `forget` instead. +# +# `password` is optional and only needed the first time breadcrumbs connects +# to a network. Once NetworkManager has durably saved the credential (either +# a brand-new connection profile, or an updated PSK on an existing one), +# breadcrumbs clears its own local copy and omits the `password` key +# entirely on the next save — both the plaintext-on-disk copy and the +# argv exposure on every subsequent connect go away for that network from +# then on. Omit `password` altogether for a genuinely open (no-security) +# network, or for one NetworkManager already knows about. + +[[networks]] +ssid = "HomeWifi" +password = "REPLACE_ME" +hidden = false + +[[networks]] +ssid = "WorkGuest" +password = "REPLACE_ME" +hidden = false + +[[networks]] +ssid = "CorpWifi" +password = "REPLACE_ME" +hidden = false diff --git a/packaging/arch/PKGBUILD b/packaging/arch/PKGBUILD deleted file mode 100644 index 8b84450..0000000 --- a/packaging/arch/PKGBUILD +++ /dev/null @@ -1,36 +0,0 @@ -# Maintainer: Breadway - -pkgname=breadcrumbs -pkgver=2.1.0 -pkgrel=1 -pkgdesc="Profile-aware Wi-Fi state machine with Tailscale integration" -arch=('x86_64') -url="https://github.com/Breadway/breadcrumbs" -license=('MIT') -# Some Rust deps (ring/mlua) build vendored C/asm into static archives; makepkg's -# default -flto=auto emits GCC LTO bitcode the Rust (lld) link cannot read, -# causing undefined-symbol errors. Disable LTO. -options=(!lto !debug) -depends=('networkmanager') -optdepends=( - 'tailscale: Tailscale VPN profile integration' -) -makedepends=('rust' 'cargo') -source=("${pkgname}-${pkgver}.tar.gz") -sha256sums=('SKIP') - -build() { - cd "${srcdir}/${pkgname}-${pkgver}" - cargo build --release --locked -} - -check() { - cd "${srcdir}/${pkgname}-${pkgver}" - cargo test --release --locked -} - -package() { - cd "${srcdir}/${pkgname}-${pkgver}" - install -Dm755 target/release/breadcrumbs "${pkgdir}/usr/bin/breadcrumbs" - install -Dm644 LICENSE "${pkgdir}/usr/share/licenses/${pkgname}/LICENSE" -} diff --git a/src/app.rs b/src/app.rs new file mode 100644 index 0000000..6f78ea0 --- /dev/null +++ b/src/app.rs @@ -0,0 +1,980 @@ +//! CLI argument parsing and command handlers. This is the only module +//! `src/main.rs` calls into; everything else (the actual state machine, +//! nmcli/tailscale wrappers, config, …) is exercised directly by library +//! consumers (including the integration tests under `tests/`). + +use std::io::{BufRead, Write}; +use std::path::PathBuf; +use std::process::Command; +use std::time::Duration; + +use clap::{Parser, Subcommand}; + +use crate::config::{Config, NetworkDef}; +use crate::state::{self, State}; +use crate::util::{self, command_exists, home_dir}; +use crate::{config, flow, nm, watch}; + +const C_RESET: &str = "\x1b[0m"; +const C_BOLD: &str = "\x1b[1m"; +const C_GREEN: &str = "\x1b[32m"; +const C_RED: &str = "\x1b[31m"; +const C_YELLOW: &str = "\x1b[33m"; +const C_DIM: &str = "\x1b[2m"; + +#[derive(Parser)] +#[command( + name = "breadcrumbs", + version, + about = "Profile-aware Wi-Fi state machine with Tailscale handling", + disable_help_subcommand = true +)] +struct Cli { + /// Override the active profile for this run only (does not persist) + #[arg(long, short, global = true)] + profile: Option, + + #[command(subcommand)] + cmd: Option, +} + +/// Optional flags for `add`. Flattened into the `Add` subcommand so the +/// CLI surface is unchanged while keeping `cmd_add`'s signature small. +#[derive(clap::Args)] +struct AddOpts { + /// Password (prompted if omitted) + password: Option, + /// Network is hidden (does not broadcast its SSID). + /// `--hidden` sets it; `--hidden=false` clears it on an existing + /// entry; omitted leaves an existing entry's flag untouched. + #[arg(long, num_args = 0..=1, default_missing_value = "true")] + hidden: Option, + /// Per-network DNS override (empty string disables DNS pinning + /// for this network) + #[arg(long)] + dns: Option, + /// 802.1x EAP method for enterprise networks (e.g. "peap", "tls") + #[arg(long)] + eap: Option, + /// 802.1x identity for enterprise networks + #[arg(long)] + identity: Option, + /// Path to a CA certificate for 802.1x + #[arg(long)] + ca_cert: Option, + /// Attach this SSID to a profile's priority list + #[arg(long)] + to: Option, + /// Position in the profile list (0 = highest priority) + #[arg(long)] + at: Option, +} + +#[derive(Subcommand)] +enum Cmd { + /// Show current Wi-Fi / profile / Tailscale status (default) + Status { + /// Emit machine-readable JSON + #[arg(long)] + json: bool, + }, + /// Run the full connect sequence for the active profile + #[command(visible_aliases = ["up", "connect", "i"])] + Init { + /// Retry until connected or this many seconds have elapsed + /// (0 = single attempt) + #[arg(long, default_value_t = 0)] + wait: u64, + }, + /// Run as a daemon: watch for drops and auto-recover + Watch { + /// Skip the connect attempt on startup + #[arg(long)] + no_initial: bool, + }, + /// Get / set / list location profiles (the state machine) + Profile { + #[command(subcommand)] + action: Option, + }, + /// Guess the profile from visible networks + Detect { + /// Set + apply the detected profile + #[arg(long)] + apply: bool, + /// Emit machine-readable JSON + #[arg(long)] + json: bool, + }, + /// Add or update a saved network + Add { + ssid: String, + #[command(flatten)] + opts: AddOpts, + }, + /// Remove a saved network (config + NetworkManager) + Forget { ssid: String }, + /// Remove NetworkManager wireless profiles whose SSID is no longer in + /// the breadcrumbs config + Prune { + /// Only list what would be removed + #[arg(long)] + dry_run: bool, + }, + /// Scan, pick, connect and save a network interactively + Scan { + /// Attach the saved network to this profile + #[arg(long)] + to: Option, + }, + /// List configured networks and profiles + List { + #[arg(long)] + show_passwords: bool, + }, + /// Open the config file in $EDITOR + Edit, + /// Quick connectivity / Tailscale diagnostics + Doctor { + /// Run the full diag.sh report from the config directory + #[arg(long)] + full: bool, + }, + /// Print the breadcrumbs config directory + Cd { + #[arg(long)] + shell: bool, + }, + /// Install + enable the systemd user watcher service + InstallService { + /// Install the unit but do not enable/start it + #[arg(long)] + no_enable: bool, + }, +} + +#[derive(Subcommand)] +enum ProfileCmd { + /// Print the active profile + Get, + /// Set the active profile (and apply it unless --no-apply) + Set { + name: String, + #[arg(long)] + no_apply: bool, + }, + /// List available profiles + List, +} + +/// Parse `argv` and run the requested command. Returns the process exit code. +pub fn run() -> i32 { + let cli = Cli::parse(); + match real_main(cli) { + Ok(c) => c, + Err(e) => { + eprintln!("{C_RED}error:{C_RESET} {e}"); + 1 + } + } +} + +fn active_profile(cfg: &Config, override_p: &Option) -> String { + if let Some(p) = override_p { + return p.clone(); + } + State::load(&cfg.settings.default_profile).profile +} + +fn real_main(cli: Cli) -> Result { + let cmd = cli.cmd.unwrap_or(Cmd::Status { json: false }); + + // `cd` and `install-service` don't need a parsed config first. + if let Cmd::Cd { shell } = &cmd { + return cmd_cd(*shell); + } + + let mut cfg = Config::load()?; + + match cmd { + Cmd::Status { json } => cmd_status(&cfg, &cli.profile, json), + Cmd::Init { wait } => cmd_init(&mut cfg, &cli.profile, wait), + Cmd::Watch { no_initial } => Ok(watch::run(cfg, !no_initial)), + Cmd::Profile { action } => cmd_profile(&mut cfg, action), + Cmd::Detect { apply, json } => cmd_detect(&mut cfg, apply, json), + Cmd::Add { ssid, opts } => cmd_add(&mut cfg, ssid, opts), + Cmd::Forget { ssid } => cmd_forget(&mut cfg, &ssid), + Cmd::Prune { dry_run } => cmd_prune(&cfg, dry_run), + Cmd::Scan { to } => cmd_scan(&mut cfg, to), + Cmd::List { show_passwords } => cmd_list(&cfg, show_passwords), + Cmd::Edit => cmd_edit(), + Cmd::Doctor { full } => cmd_doctor(&cfg, &cli.profile, full), + Cmd::InstallService { no_enable } => cmd_install_service(!no_enable), + Cmd::Cd { .. } => unreachable!(), + } +} + +fn cmd_init(cfg: &mut Config, override_p: &Option, wait: u64) -> Result { + let p = active_profile(cfg, override_p); + let deadline = std::time::Instant::now() + Duration::from_secs(wait); + let mut attempt = 0; + loop { + // First attempt notifies normally (user-initiated); retries are + // quiet so a long --wait run doesn't spam notifications. + let outcome = if attempt == 0 { + flow::run(cfg, &p) + } else { + flow::run_quiet(cfg, &p) + }; + if outcome.ok() { + print_outcome(&p, &outcome); + return Ok(0); + } + if wait == 0 || std::time::Instant::now() >= deadline { + print_outcome(&p, &outcome); + return Ok(1); + } + attempt += 1; + println!("{C_DIM}not connected yet — retrying in 3s…{C_RESET}"); + std::thread::sleep(Duration::from_secs(3)); + } +} + +fn print_outcome(profile: &str, o: &flow::Outcome) { + match o { + flow::Outcome::Connected { ssid, note } => { + print!("{C_GREEN}connected{C_RESET} {C_BOLD}{ssid}{C_RESET} ({profile})"); + match note { + Some(n) => println!(" {C_YELLOW}— {n}{C_RESET}"), + None => println!(), + } + } + flow::Outcome::TailscaleError { ssid, health } => { + println!( + "{C_RED}tailscale error{C_RESET}: {} {C_DIM}(on {}){C_RESET}", + health.describe(), + ssid.clone().unwrap_or_else(|| "—".into()) + ); + } + flow::Outcome::NoInterface => { + println!("{C_RED}no Wi-Fi adapter{C_RESET} — hardware issue") + } + flow::Outcome::NoNetworks => { + println!("{C_RED}no known networks in range{C_RESET} (profile {profile})") + } + flow::Outcome::UnknownProfile(p) => { + println!("{C_RED}unknown profile{C_RESET}: {p}") + } + } +} + +fn cmd_status(cfg: &Config, override_p: &Option, json: bool) -> Result { + let p = active_profile(cfg, override_p); + let s = crate::status::gather(cfg, &p); + + let healthy = s.internet + && s.iface.is_some() + && (!s.tailscale_required || s.tailscale.as_ref().map(|h| h.is_ok()).unwrap_or(false)); + + if json { + let tailscale = s.tailscale.as_ref().map(|h| h.state_str()); + println!( + "{}", + serde_json::json!({ + "profile": p, + "iface": s.iface, + "ssid": s.ssid, + "ip": s.ip, + "internet": s.internet, + "portal": s.portal, + "tailscale_required": s.tailscale_required, + "tailscale": tailscale, + "exit_node": s.exit_node, + "healthy": healthy, + }) + ); + return Ok(if healthy { 0 } else { 1 }); + } + + let dot = |ok: bool| { + if ok { + format!("{C_GREEN}●{C_RESET}") + } else { + format!("{C_RED}●{C_RESET}") + } + }; + + println!("{C_BOLD}breadcrumbs{C_RESET}"); + println!(" profile {C_BOLD}{p}{C_RESET}"); + println!( + " adapter {}", + s.iface + .clone() + .unwrap_or_else(|| format!("{C_RED}none{C_RESET}")) + ); + println!( + " ssid {}", + s.ssid + .clone() + .unwrap_or_else(|| format!("{C_DIM}—{C_RESET}")) + ); + println!( + " ip {}", + s.ip.clone().unwrap_or_else(|| format!("{C_DIM}—{C_RESET}")) + ); + println!( + " internet {} {}", + dot(s.internet), + if s.internet { "ok" } else { "down" } + ); + + match (&s.tailscale, s.tailscale_required) { + (Some(h), req) => { + let ok = h.is_ok(); + println!( + " tailscale {} {} {C_DIM}(exit: {}{}){C_RESET}", + dot(ok || !req), + h.describe(), + s.exit_node, + if req { "" } else { ", optional" } + ); + } + (None, _) => println!(" tailscale {C_DIM}not installed{C_RESET}"), + } + + println!( + " state {}", + if healthy { + format!("{C_GREEN}healthy{C_RESET}") + } else { + format!("{C_YELLOW}needs attention{C_RESET} — run `breadcrumbs init`") + } + ); + Ok(if healthy { 0 } else { 1 }) +} + +fn cmd_profile(cfg: &mut Config, action: Option) -> Result { + match action.unwrap_or(ProfileCmd::Get) { + ProfileCmd::Get => { + println!("{}", State::load(&cfg.settings.default_profile).profile); + Ok(0) + } + ProfileCmd::List => { + let cur = State::load(&cfg.settings.default_profile).profile; + for name in cfg.profiles.keys() { + let mark = if *name == cur { "*" } else { " " }; + println!("{mark} {name}"); + } + Ok(0) + } + ProfileCmd::Set { name, no_apply } => { + state::set_profile(cfg, &name)?; + println!("profile = {C_BOLD}{name}{C_RESET}"); + if no_apply { + return Ok(0); + } + let outcome = flow::run(cfg, &name); + print_outcome(&name, &outcome); + Ok(if outcome.ok() { 0 } else { 1 }) + } + } +} + +fn detect_profile(cfg: &Config) -> Option { + let iface = nm::wifi_interface_preferred(cfg.settings.interface.as_deref())?; + nm::radio_on(); + nm::rescan(&iface, &[]); + let visible = nm::visible_signals(&iface); + + // Scored detection: the profile with the most matching markers wins, so + // a 2-marker match beats a 1-marker one. Profiles are stored in a + // BTreeMap, so ties resolve deterministically (alphabetically first). + let mut best: Option<(String, usize)> = None; + for (name, profile) in &cfg.profiles { + if profile.detect_ssids.is_empty() { + continue; + } + let count = profile + .detect_ssids + .iter() + .filter(|s| visible.contains_key(s.as_str())) + .count(); + if count > 0 { + let better = match &best { + None => true, + Some((_, c)) => count > *c, + }; + if better { + best = Some((name.clone(), count)); + } + } + } + + best.map(|(p, _)| p).or_else(|| { + // Fall back to the default profile if no markers matched — but only + // if it actually exists: a stale `default_profile` name is a config + // error, not a detection result, and persisting it would wedge the + // watcher in UnknownProfile forever. + if cfg.profiles.contains_key(&cfg.settings.default_profile) { + Some(cfg.settings.default_profile.clone()) + } else { + None + } + }) +} + +fn cmd_detect(cfg: &mut Config, apply: bool, json: bool) -> Result { + match detect_profile(cfg) { + Some(p) => { + if json && !apply { + println!("{}", serde_json::json!({ "profile": p })); + return Ok(0); + } + if apply { + if json { + println!("{}", serde_json::json!({ "profile": p })); + } else { + println!("{p}"); + } + // Route through state::set_profile (like the CLI and the + // bread bus do) so an unknown fallback is rejected with a + // proper error instead of being persisted as active. + state::set_profile(cfg, &p)?; + let outcome = flow::run(cfg, &p); + print_outcome(&p, &outcome); + return Ok(if outcome.ok() { 0 } else { 1 }); + } + println!("{p}"); + Ok(0) + } + None => Err("could not detect a profile (no Wi-Fi adapter, or no \ + profile matches and the default is misconfigured)" + .into()), + } +} + +fn prompt_line(msg: &str) -> String { + print!("{msg}"); + let _ = std::io::stdout().flush(); + let mut s = String::new(); + let _ = std::io::stdin().lock().read_line(&mut s); + s.trim_end_matches(['\n', '\r']).to_string() +} + +/// An empty string entered for a password (CLI arg or a blank prompt +/// response) means "this network has no password" (open Wi-Fi) — normalize +/// it to `None` right at the point of entry so it flows the same way a +/// genuinely absent/cleared password does. Without this, `Some("")` would +/// make `nm::connect_verbose` send an empty PSK in the settings payload, +/// which NetworkManager treats as "secured with a blank password" rather +/// than "open", and the connect fails against a real open SSID. +fn non_empty(s: String) -> Option { + if s.is_empty() { + None + } else { + Some(s) + } +} + +fn prompt_secret(msg: &str) -> String { + // `util::run` redirects child stdin to /dev/null, so plain `stty -echo` + // would target the wrong fd and silently leave echo ON (leaking the + // password to the screen). `-F /dev/tty` makes stty act on the controlling + // terminal directly. If there is no tty we fall back to visible input. + let had_tty = util::run("stty", &["-F", "/dev/tty", "-echo"], Duration::from_secs(2)).success; + let val = prompt_line(msg); + if had_tty { + let _ = util::run("stty", &["-F", "/dev/tty", "echo"], Duration::from_secs(2)); + println!(); + } + val +} + +fn cmd_add(cfg: &mut Config, ssid: String, opts: AddOpts) -> Result { + let AddOpts { + password, + hidden, + dns, + eap, + identity, + ca_cert, + to, + at, + } = opts; + // `--dns ""` is the explicit "don't pin DNS" opt-out; normalize an + // absent flag to None (use the global setting). + let dns = match dns { + Some(s) if s.is_empty() => Some(String::new()), + Some(s) => Some(s), + None => None, + }; + // For enterprise networks, the password is the 802.1x password. + let password = match password { + Some(p) => p, + None if eap.is_some() => prompt_secret(&format!("802.1x password for '{ssid}': ")), + None => prompt_secret(&format!("Password for '{ssid}': ")), + }; + let password = non_empty(password); + match cfg.networks.iter_mut().find(|n| n.ssid == ssid) { + Some(n) => { + n.password = password; + // `--hidden` / `--hidden=false` set the flag explicitly; when + // the flag is omitted, leave an existing entry's hidden state + // alone (a password-only update must not un-hide a network). + if let Some(h) = hidden { + n.hidden = h; + } + if dns.is_some() { + n.dns = dns; + } + if eap.is_some() { + n.eap = eap; + } + if identity.is_some() { + n.identity = identity; + } + if ca_cert.is_some() { + n.ca_cert = ca_cert; + } + } + None => cfg.networks.push(NetworkDef { + ssid: ssid.clone(), + password, + dns, + eap, + identity, + ca_cert, + hidden: hidden.unwrap_or(false), + }), + } + if let Some(prof_name) = to { + let prof = cfg + .profiles + .get_mut(&prof_name) + .ok_or_else(|| format!("unknown profile '{prof_name}'"))?; + prof.networks.retain(|s| s != &ssid); + let idx = at.unwrap_or(prof.networks.len()).min(prof.networks.len()); + prof.networks.insert(idx, ssid.clone()); + } + cfg.save()?; + println!("{C_GREEN}saved{C_RESET} {ssid}"); + Ok(0) +} + +fn cmd_forget(cfg: &mut Config, ssid: &str) -> Result { + let before = cfg.networks.len(); + cfg.networks.retain(|n| n.ssid != ssid); + for p in cfg.profiles.values_mut() { + p.networks.retain(|s| s != ssid); + if p.bootstrap.as_deref() == Some(ssid) { + p.bootstrap = None; + } + } + cfg.save()?; + let removed = nm::delete_connections_for_ssid(ssid); + println!( + "{C_GREEN}forgot{C_RESET} {ssid} (config: {}, NetworkManager: {})", + if cfg.networks.len() < before { + "removed" + } else { + "not present" + }, + if removed { "removed" } else { "not present" } + ); + Ok(0) +} + +/// Remove NetworkManager wireless profiles whose SSID is no longer known to +/// breadcrumbs (config `networks`, or any profile's priority list or +/// bootstrap). `--dry-run` only lists. Returns the number removed. +fn cmd_prune(cfg: &Config, dry_run: bool) -> Result { + let known: Vec<&str> = cfg + .networks + .iter() + .map(|n| n.ssid.as_str()) + .chain( + cfg.profiles + .values() + .flat_map(|p| p.networks.iter().map(|s| s.as_str()).chain(p.bootstrap.iter().map(|s| s.as_str()))), + ) + .collect(); + let stale: Vec<(String, String)> = nm::wireless_profiles() + .into_iter() + .filter(|(_name, ssid)| !known.contains(&ssid.as_str())) + .collect(); + if stale.is_empty() { + println!("{C_GREEN}nothing to prune{C_RESET}"); + return Ok(0); + } + for (name, ssid) in &stale { + if dry_run { + println!("{C_DIM}would remove{C_RESET} {name} ({ssid})"); + } else { + println!("{C_GREEN}removed{C_RESET} {name} ({ssid})"); + let _ = nm::delete_connections_for_ssid(ssid); + } + } + Ok(0) +} + +fn cmd_scan(cfg: &mut Config, to: Option) -> Result { + // Validate `--to` up front, before any side effects (connecting is + // one): `add --to` errors on an unknown profile, so `scan --to` must + // too instead of silently saving a network that never gets attached. + if let Some(prof_name) = &to { + if !cfg.profiles.contains_key(prof_name) { + return Err(format!("unknown profile '{prof_name}'")); + } + } + let iface = nm::wifi_interface_preferred(cfg.settings.interface.as_deref()) + .ok_or("no Wi-Fi adapter")?; + nm::radio_on(); + nm::rescan(&iface, &[]); + let entries = nm::scan_list(&iface); + if entries.is_empty() { + return Err("no networks found".into()); + } + for (i, e) in entries.iter().enumerate() { + println!( + "{:>2}. {C_BOLD}{}{C_RESET} {C_DIM}sig {} {}{C_RESET}", + i + 1, + if e.ssid.is_empty() { + "" + } else { + &e.ssid + }, + e.signal, + e.security + ); + } + let sel = prompt_line("Select number: "); + let idx: usize = sel + .parse::() + .ok() + .filter(|n| *n >= 1 && *n <= entries.len()) + .ok_or("invalid selection")?; + let ssid = entries[idx - 1].ssid.clone(); + if ssid.is_empty() { + return Err("cannot select a hidden SSID here; use `breadcrumbs add`".into()); + } + let password = non_empty(prompt_secret(&format!("Password for '{ssid}': "))); + let mut def = NetworkDef { + ssid: ssid.clone(), + password, + dns: None, + eap: None, + identity: None, + ca_cert: None, + hidden: false, + }; + if !nm::connect(&iface, &def, cfg.settings.connect_wait, &cfg.settings.dns) { + return Err(format!("failed to connect to {ssid}")); + } + // A successful connect means NetworkManager now durably holds the PSK + // (either in a freshly created profile, or one whose PSK we just set) — + // breadcrumbs no longer needs to keep its own plaintext copy. + def.password = None; + match cfg.networks.iter_mut().find(|n| n.ssid == ssid) { + Some(n) => n.password = None, + None => cfg.networks.push(def), + } + if let Some(prof_name) = to { + if let Some(prof) = cfg.profiles.get_mut(&prof_name) { + if !prof.networks.contains(&ssid) { + prof.networks.push(ssid.clone()); + } + } + } + cfg.save()?; + println!("{C_GREEN}connected + saved{C_RESET} {ssid}"); + Ok(0) +} + +/// Mask a secret for display. Always renders the same fixed-length +/// placeholder so the output reveals neither the secret's length nor any +/// character of it (a fixed placeholder is what password managers show; +/// length-hiding also means multi-byte UTF-8 passwords need no special +/// handling). +fn mask(_p: &str) -> String { + "•".repeat(8) +} + +fn cmd_list(cfg: &Config, show_pw: bool) -> Result { + println!("{C_BOLD}settings{C_RESET}"); + println!(" dns {}", cfg.settings.dns); + println!(" exit_node {}", cfg.settings.exit_node); + println!(" default {}", cfg.settings.default_profile); + println!(" watch every {}s", cfg.settings.watch_interval); + + println!("\n{C_BOLD}networks{C_RESET}"); + for n in &cfg.networks { + let pw_display = match &n.password { + Some(p) if show_pw => p.clone(), + Some(p) => mask(p), + // No local secret: NetworkManager already owns the credential for + // this SSID, so there is nothing to mask — showing dots here + // would falsely imply breadcrumbs is still hiding a password. + None => format!("{C_DIM}managed by NetworkManager{C_RESET}"), + }; + println!( + " {C_BOLD}{}{C_RESET} {C_DIM}{}{}{C_RESET}", + n.ssid, + pw_display, + if n.hidden { " (hidden)" } else { "" } + ); + } + + println!("\n{C_BOLD}profiles{C_RESET}"); + let cur = State::load(&cfg.settings.default_profile).profile; + for (name, p) in &cfg.profiles { + let mark = if *name == cur { + format!("{C_GREEN}*{C_RESET}") + } else { + " ".into() + }; + println!("{mark} {C_BOLD}{name}{C_RESET}"); + if let Some(b) = &p.bootstrap { + println!(" bootstrap {b}"); + } + if p.tailscale { + println!( + " tailscale required (exit: {})", + p.exit_node + .clone() + .unwrap_or_else(|| cfg.settings.exit_node.clone()) + ); + } + let mut order: Vec = p.networks.clone(); + if p.include_all_known { + order.push("…all other known networks".into()); + } + println!(" priority {}", order.join(" > ")); + } + Ok(0) +} + +fn cmd_edit() -> Result { + let editor = std::env::var("EDITOR").unwrap_or_else(|_| "nano".into()); + let path = config::config_path(); + // EDITOR values routinely carry arguments ("code -w", "subl -w"), so + // split on whitespace: the first token is the program, the rest are its + // arguments. The path stays a separate argument — never interpolated + // into a shell string — so it can't be used for injection. + let mut parts = editor.split_whitespace(); + let prog = parts.next().unwrap_or("nano"); + let mut cmd = Command::new(prog); + cmd.args(parts); + let status = cmd + .arg(&path) + .status() + .map_err(|e| format!("launching {editor}: {e}"))?; + if !status.success() { + return Err("editor exited with error".into()); + } + match Config::load() { + Ok(_) => { + println!("{C_GREEN}config OK{C_RESET}"); + Ok(0) + } + Err(e) => Err(format!("config is now invalid: {e}")), + } +} + +fn cmd_doctor(cfg: &Config, override_p: &Option, full: bool) -> Result { + if full { + let script = config::config_dir().join("diag.sh"); + if !script.exists() { + return Err(format!( + "diag.sh not found (expected at {})", + script.display() + )); + } + let st = Command::new("bash") + .arg(&script) + .status() + .map_err(|e| format!("running diag: {e}"))?; + return Ok(st.code().unwrap_or(1)); + } + + let p = active_profile(cfg, override_p); + let s = crate::status::gather(cfg, &p); + println!("{C_BOLD}breadcrumbs doctor{C_RESET} (profile {p})"); + println!( + " network-manager {}", + if nm::available() { + "present (D-Bus)" + } else { + "MISSING" + } + ); + println!( + " tailscale {}", + if command_exists("tailscale") { + "present" + } else { + "absent" + } + ); + println!( + " adapter {}", + s.iface.clone().unwrap_or_else(|| "none".into()) + ); + println!( + " ssid {}", + s.ssid.clone().unwrap_or_else(|| "—".into()) + ); + println!( + " ip {}", + s.ip.clone().unwrap_or_else(|| "—".into()) + ); + println!(" internet {}", if s.internet { "ok" } else { "DOWN" }); + if let Some(h) = &s.tailscale { + println!(" tailscale {} (exit {})", h.describe(), s.exit_node); + } + + if let Some(iface) = &s.iface { + let visible = nm::visible_ssids(iface); + let known: Vec<&str> = cfg + .networks + .iter() + .filter(|n| visible.contains(&n.ssid)) + .map(|n| n.ssid.as_str()) + .collect(); + println!( + " in range {}", + if known.is_empty() { + "none of your saved networks".into() + } else { + known.join(", ") + } + ); + } + println!("\nFull report: {C_DIM}breadcrumbs doctor --full{C_RESET}"); + Ok(0) +} + +fn cmd_cd(shell: bool) -> Result { + let dir = config::config_dir(); + if shell { + let sh = std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".into()); + let err = exec_replace(&sh, &dir); + return Err(err); + } + println!("{}", dir.display()); + Ok(0) +} + +/// Re-exec into an interactive login shell inside `dir`, replacing the +/// current process. `dir` is passed as `$1` to the shell script rather than +/// interpolated into the script text — the config dir can come from +/// `$XDG_CONFIG_HOME`/`$HOME`, and string-formatting an arbitrary path +/// straight into a `sh -c` command would let shell metacharacters (`$(...)`, +/// backticks, etc.) in that path execute as commands. +fn exec_replace(prog: &str, dir: &std::path::Path) -> String { + use std::os::unix::process::CommandExt; + let e = Command::new(prog) + .arg("-lc") + .arg("cd \"$1\" && exec \"$0\"") + .arg(prog) + .arg(dir) + .exec(); + format!("exec {prog} failed: {e}") +} + +fn cmd_install_service(enable: bool) -> Result { + // Honor XDG_CONFIG_HOME like the rest of the app: systemd --user units + // live in $XDG_CONFIG_HOME/systemd/user (default ~/.config/systemd/user). + let unit_dir = std::env::var_os("XDG_CONFIG_HOME") + .map(PathBuf::from) + .unwrap_or_else(|| home_dir().join(".config")) + .join("systemd") + .join("user"); + std::fs::create_dir_all(&unit_dir) + .map_err(|e| format!("creating {}: {e}", unit_dir.display()))?; + let bin = std::env::current_exe().map_err(|e| format!("resolving current executable: {e}"))?; + // Ordering against graphical-session.target lets the watcher inherit the + // session's DISPLAY/WAYLAND_DISPLAY/DBUS so notify-send and the Tailscale + // login browser-open actually work. PATH is pinned because systemd --user + // units do not get the login shell's PATH, and the watcher shells out to + // tailscale/sudo/xdg-open by name (NetworkManager is reached over D-Bus, + // so no nmcli is needed). + let unit = format!( + "[Unit]\n\ + Description=breadcrumbs Wi-Fi state machine watcher\n\ + After=network.target NetworkManager.service graphical-session.target\n\ + Wants=network.target graphical-session.target\n\n\ + [Service]\n\ + Type=simple\n\ + Environment=PATH=/usr/local/bin:/usr/bin:/bin\n\ + ExecStart={bin} watch\n\ + Restart=always\n\ + RestartSec=5\n\ + Nice=5\n\n\ + [Install]\n\ + WantedBy=default.target\n", + bin = bin.display() + ); + let unit_path = unit_dir.join("breadcrumbs.service"); + std::fs::write(&unit_path, unit) + .map_err(|e| format!("writing {}: {e}", unit_path.display()))?; + println!("{C_GREEN}wrote{C_RESET} {}", unit_path.display()); + + let _ = util::run( + "systemctl", + &["--user", "daemon-reload"], + Duration::from_secs(10), + ); + if enable { + let o = util::run( + "systemctl", + &["--user", "enable", "--now", "breadcrumbs.service"], + Duration::from_secs(15), + ); + if o.success { + println!("{C_GREEN}enabled + started{C_RESET} breadcrumbs.service"); + } else { + println!( + "{C_YELLOW}unit installed{C_RESET}; enable failed: {}", + o.stderr.trim() + ); + return Ok(1); + } + } else { + println!("Run: systemctl --user enable --now breadcrumbs.service"); + } + Ok(0) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn mask_is_fixed_length_regardless_of_secret() { + // Fixed-length masking: the output must reveal neither the secret's + // length nor any character of it — for empty, short, and long + // secrets alike. + assert_eq!(mask(""), "•".repeat(8)); + assert_eq!(mask("a"), "•".repeat(8)); + assert_eq!(mask("ab"), "•".repeat(8)); + assert_eq!(mask("hunter2"), "•".repeat(8)); + assert!(!mask("hunter2").contains('h')); + } + + #[test] + fn mask_multibyte_password_does_not_panic() { + // Regression test: the old byte-slicing `&p[..1]` panicked whenever + // the first character of the password was multi-byte UTF-8 (e.g. an + // emoji or accented character), since byte index 1 can land mid-char. + let pw = "日本語パスワード"; + let masked = mask(pw); + assert_eq!(masked, "•".repeat(8)); + assert!(masked.chars().all(|c| c == '•')); + } + + #[test] + fn mask_emoji_first_character_does_not_panic() { + let pw = "🔒password123"; + assert_eq!(mask(pw), "•".repeat(8)); + } +} diff --git a/src/backend.rs b/src/backend.rs deleted file mode 100644 index 48511cb..0000000 --- a/src/backend.rs +++ /dev/null @@ -1,80 +0,0 @@ -//! The seam between breadcrumbs' decision logic and the outside world. -//! -//! Every interaction with NetworkManager, Tailscale, connectivity probes, -//! notifications and logging goes through the [`Backend`] trait. Production code -//! uses [`System`], which delegates to the `nm`/`tailscale`/`status`/`notify` -//! modules that shell out. Tests inject a fake so the connect state machine -//! (`flow`) and watch classifier can be exercised without touching the host. - -use std::collections::HashSet; - -use crate::config::{Config, NetworkDef}; -use crate::nm; -use crate::notify::{self, Urgency}; -use crate::status::{self, Connectivity}; -use crate::tailscale::{self, TsHealth}; - -pub trait Backend { - fn wifi_interface(&self) -> Option; - fn radio_on(&self); - fn rescan(&self, iface: &str, ssids: &[String]); - fn visible_ssids(&self, iface: &str) -> HashSet; - fn active_ssid(&self, iface: &str) -> Option; - fn ipv4(&self, iface: &str) -> Option; - fn device_connected(&self, iface: &str) -> bool; - fn connect(&self, iface: &str, net: &NetworkDef, wait: u32, dns: &str) -> Result<(), String>; - fn tailscale_installed(&self) -> bool; - fn ensure_exit_node(&self, node: &str) -> TsHealth; - fn tailscale_check(&self, node: &str) -> TsHealth; - fn connectivity(&self, cfg: &Config) -> Connectivity; - fn notify(&self, summary: &str, body: &str, urgency: Urgency); - fn log(&self, line: &str); -} - -/// The real backend: every method delegates to the system-facing modules. -pub struct System; - -impl Backend for System { - fn wifi_interface(&self) -> Option { - nm::wifi_interface() - } - fn radio_on(&self) { - nm::radio_on() - } - fn rescan(&self, iface: &str, ssids: &[String]) { - nm::rescan(iface, ssids) - } - fn visible_ssids(&self, iface: &str) -> HashSet { - nm::visible_ssids(iface) - } - fn active_ssid(&self, iface: &str) -> Option { - nm::active_ssid(iface) - } - fn ipv4(&self, iface: &str) -> Option { - status::ipv4(iface) - } - fn device_connected(&self, iface: &str) -> bool { - nm::device_connected(iface) - } - fn connect(&self, iface: &str, net: &NetworkDef, wait: u32, dns: &str) -> Result<(), String> { - nm::connect_verbose(iface, net, wait, dns) - } - fn tailscale_installed(&self) -> bool { - tailscale::installed() - } - fn ensure_exit_node(&self, node: &str) -> TsHealth { - tailscale::ensure_exit_node(node) - } - fn tailscale_check(&self, node: &str) -> TsHealth { - tailscale::check(node) - } - fn connectivity(&self, cfg: &Config) -> Connectivity { - status::connectivity(cfg) - } - fn notify(&self, summary: &str, body: &str, urgency: Urgency) { - notify::notify(summary, body, urgency) - } - fn log(&self, line: &str) { - notify::log(line) - } -} diff --git a/src/bread_events.rs b/src/bread_events.rs new file mode 100644 index 0000000..ecdd8d3 --- /dev/null +++ b/src/bread_events.rs @@ -0,0 +1,156 @@ +//! `bread.crumbs.*` event integration — optional, non-blocking. See +//! `EVENTS.md` at the repo root for the full contract. breadcrumbs works +//! identically with or without breadd running; every call here is +//! fire-and-forget (`BreadClient::emit` never blocks or errors this +//! process) so a missing or restarting breadd never affects Wi-Fi +//! automation itself. + +use bread_utils::bread_client::{BreadClient, BreadEvent}; + +use crate::config::Config; +use crate::state; + +/// This app's id in bread's sibling-app namespace registry +/// (`bread_shared::apps::KNOWN_APPS`) — events publish as `bread.crumbs.*`, +/// commands arrive on `bread.command.crumbs.*`. +pub const APP_ID: &str = "crumbs"; + +pub fn client() -> BreadClient { + BreadClient::connect(APP_ID) +} + +pub fn emit_profile_changed(client: &BreadClient, from: &str, to: &str) { + client.emit( + "bread.crumbs.profile.changed", + serde_json::json!({ "from": from, "to": to }), + ); +} + +/// Payload for `bread.crumbs.health.changed`. Constructed by the watch +/// loop from a classification and passed as a unit so the emit functions +/// stay small. +pub struct HealthChanged<'a> { + pub profile: &'a str, + pub health: &'a str, + pub ssid: Option<&'a str>, + pub iface: Option<&'a str>, + pub ip: Option<&'a str>, + pub exit_node: &'a str, + pub tailscale: Option<&'a str>, +} + +pub fn emit_health_changed(client: &BreadClient, ev: HealthChanged<'_>) { + client.emit( + "bread.crumbs.health.changed", + serde_json::json!({ + "profile": ev.profile, + "health": ev.health, + "ssid": ev.ssid, + "iface": ev.iface, + "ip": ev.ip, + "exit_node": ev.exit_node, + "tailscale": ev.tailscale, + }), + ); +} + +/// `bread.crumbs.network.changed` — the watch loop observed the active SSID +/// transition. `from` is `null` when there was no previous association. +pub fn emit_network_changed(client: &BreadClient, from: Option<&str>, to: Option<&str>, profile: &str) { + client.emit( + "bread.crumbs.network.changed", + serde_json::json!({ "from": from, "to": to, "profile": profile }), + ); +} + +/// `bread.crumbs.tailscale.changed` — the Tailscale health state (or its +/// mere presence) changed between watch-loop ticks. +pub fn emit_tailscale_changed( + client: &BreadClient, + profile: &str, + state: Option<&str>, + exit_node: &str, +) { + client.emit( + "bread.crumbs.tailscale.changed", + serde_json::json!({ + "profile": profile, + "state": state, + "exit_node": exit_node, + }), + ); +} + +/// What a `bread.command.crumbs.*` event asks the watch loop to do. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum CommandAction { + /// Persist this profile via [`state::set_profile`]. Applied on the + /// watch loop thread — the single owner of config/state file access — + /// never on the subscription thread, which would race the loop's own + /// `Config::load`/`save`. + SetProfile(String), + /// Nothing to do: unknown verb, or a validation failure already + /// reported via `bread.crumbs.set_profile.failed`. + Ignore, +} + +/// Reacts to `bread.command.crumbs.*` verbs. Only `set_profile` maps to +/// real, existing breadcrumbs functionality today — there is no pin/select +/// (or other) verb because breadcrumbs has no such concept. Unrecognized +/// verbs are ignored, not stubbed as no-ops that pretend to succeed. +/// +/// This only *parses and validates* the command — it performs no file I/O +/// (that would race the watch loop's own config access from a second +/// thread). The returned [`CommandAction`] is forwarded to the loop, which +/// applies it via [`apply_set_profile`]. +pub fn handle_command(event: &BreadEvent) -> CommandAction { + let Some(verb) = event.event.strip_prefix("bread.command.crumbs.") else { + return CommandAction::Ignore; + }; + match verb { + "set_profile" => match event.data.get("profile").and_then(|v| v.as_str()) { + Some(name) if !name.trim().is_empty() => CommandAction::SetProfile(name.to_string()), + _ => { + emit_set_profile_failed("missing string \"profile\" in command data"); + CommandAction::Ignore + } + }, + other => { + crate::notify::log(&format!( + "watch: ignoring unrecognized bread.command.crumbs.{other}" + )); + CommandAction::Ignore + } + } +} + +/// Apply a `set_profile` command on the watch loop thread and emit the +/// `done`/`failed` confirmation. Kept separate from [`handle_command`] so +/// the bread subscription thread never touches config/state files +/// concurrently with the loop. +pub fn apply_set_profile(name: &str) { + match Config::load().and_then(|cfg| state::set_profile(&cfg, name)) { + Ok(()) => { + crate::notify::log(&format!( + "watch: profile set via bread.command.crumbs.set_profile -> {name}" + )); + client().emit( + "bread.crumbs.set_profile.done", + serde_json::json!({ "profile": name }), + ); + } + Err(e) => { + emit_set_profile_failed(&e); + } + } +} + +fn emit_set_profile_failed(error: &str) { + crate::notify::log(&format!( + "watch: bread.command.crumbs.set_profile failed: {error}" + )); + client().emit( + "bread.crumbs.set_profile.failed", + serde_json::json!({ "error": error }), + ); +} diff --git a/src/config.rs b/src/config.rs index da094aa..11ed02f 100644 --- a/src/config.rs +++ b/src/config.rs @@ -9,7 +9,7 @@ use crate::util::home_dir; fn default_dns() -> String { "1.1.1.1".to_string() } -fn default_nmcli_wait() -> u32 { +fn default_connect_wait() -> u32 { 8 } fn default_exit_node() -> String { @@ -28,12 +28,59 @@ fn default_ping_host() -> String { "1.1.1.1".to_string() } +/// Parse "HH:MM" (24h) into minutes since midnight; `None` if malformed. +pub fn hhmm_to_minutes(s: &str) -> Option { + let (h, m) = s.trim().split_once(':')?; + let h: u32 = h.parse().ok()?; + let m: u32 = m.parse().ok()?; + if h > 23 || m > 59 { + return None; + } + Some(h * 60 + m) +} + +/// Does the window `[from, to)` (minutes since midnight) contain `now`? +/// `from >= to` means an overnight window (e.g. 22:00–07:00). +pub fn window_contains(from: u32, to: u32, now: u32) -> bool { + if from < to { + now >= from && now < to + } else { + now >= from || now < to + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct ScheduleEntry { + /// Profile to switch to while the window is active. + pub profile: String, + /// "HH:MM", inclusive start. + pub from: String, + /// "HH:MM", exclusive end (`from >= to` means overnight). + pub to: String, +} + +impl ScheduleEntry { + /// Whether the window contains `now_minutes` (minutes since midnight). + pub fn contains(&self, now_minutes: u32) -> bool { + match (hhmm_to_minutes(&self.from), hhmm_to_minutes(&self.to)) { + (Some(f), Some(t)) => window_contains(f, t, now_minutes), + _ => false, + } + } +} + +fn is_false(b: &bool) -> bool { + !b +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Settings { #[serde(default = "default_dns")] pub dns: String, - #[serde(default = "default_nmcli_wait")] - pub nmcli_wait: u32, + /// Seconds to wait for a connect to reach the ACTIVATED device state. + /// `nmcli_wait` is accepted as a legacy alias. + #[serde(default = "default_connect_wait", alias = "nmcli_wait")] + pub connect_wait: u32, #[serde(default = "default_exit_node")] pub exit_node: String, #[serde(default = "default_profile_name")] @@ -44,30 +91,101 @@ pub struct Settings { pub connectivity_url: String, #[serde(default = "default_ping_host")] pub ping_host: String, + /// Set the first time the config is saved. Core profiles (`home` / + /// `work` / `away`) are only backfilled for genuinely fresh or legacy + /// configs; once the user owns the file, a profile they deliberately + /// deleted stays deleted instead of being silently resurrected on the + /// next load. Omits itself from the TOML until set, so existing + /// configs keep parsing exactly as before. + #[serde(default, skip_serializing_if = "is_false")] + pub core_profiles_initialized: bool, + /// Preferred Wi-Fi interface (e.g. "wlan0"). When set, this exact + /// device is used if present; otherwise the first Wi-Fi device wins. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub interface: Option, + /// Priority-ordered fallback exit nodes. Tried in order by the flow; + /// the first healthy one is selected. Falls back to `exit_node` when + /// empty. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub exit_nodes: Vec, + /// Optional time-of-day schedule: at a given time, switch to the listed + /// profile automatically (respecting a manual-override grace window; + /// see the watch loop). First matching rule wins; outside every window + /// nothing is switched. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub schedule: Vec, } impl Default for Settings { fn default() -> Self { Settings { dns: default_dns(), - nmcli_wait: default_nmcli_wait(), + connect_wait: default_connect_wait(), exit_node: default_exit_node(), default_profile: default_profile_name(), watch_interval: default_watch_interval(), connectivity_url: default_connectivity_url(), ping_host: default_ping_host(), + core_profiles_initialized: false, + interface: None, + exit_nodes: Vec::new(), + schedule: Vec::new(), } } } +impl Settings { + /// The profile a time-of-day schedule picks for `now_minutes` (minutes + /// since midnight), if any — first matching rule wins. + pub fn scheduled_profile(&self, now_minutes: u32) -> Option { + self.schedule + .iter() + .find(|e| e.contains(now_minutes)) + .map(|e| e.profile.clone()) + } +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct NetworkDef { pub ssid: String, - pub password: String, + /// A password is only needed once. On first successful connect, + /// NetworkManager durably saves the credential (a new connection + /// profile, or an updated PSK on an existing one); breadcrumbs then + /// clears this field and, on the next save, omits the key entirely + /// rather than writing a plaintext copy that's no longer needed. `None` + /// means either "NetworkManager already owns this secret" or "this is + /// an open (unsecured) network" — both cases behave the same way on + /// connect: no PSK is sent to nmcli at all. When `Some`, the PSK is + /// fed to `nmcli --ask` on stdin, never as an argv element. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub password: Option, + /// Per-network DNS override. `None` falls back to `settings.dns`; + /// an explicitly empty string disables DNS pinning for this network. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub dns: Option, + /// WPA-Enterprise (802.1x). When `eap` is set the network is treated as + /// enterprise: `identity` + `password` (reused) + optional `ca_cert` + /// path. `eap` is e.g. "peap" or "tls". + #[serde(default, skip_serializing_if = "Option::is_none")] + pub eap: Option, + /// 802.1x identity (e.g. `user@corp`) for enterprise networks. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub identity: Option, + /// Path to a CA certificate for 802.1x (optional). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub ca_cert: Option, #[serde(default)] pub hidden: bool, } +impl NetworkDef { + /// The DNS to pin for this network: the per-network override if set, + /// otherwise the global setting. + pub fn effective_dns<'a>(&'a self, fallback: &'a str) -> &'a str { + self.dns.as_deref().unwrap_or(fallback) + } +} + #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct Profile { /// Optional SSID connected first to bootstrap connectivity (e.g. for Tailscale). @@ -89,18 +207,41 @@ pub struct Profile { /// Used by `breadcrumbs detect` to guess the active profile. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub detect_ssids: Vec, + /// Opt-in learning: on a successful connect, the SSID is appended to + /// `detect_ssids` (bounded) so `breadcrumbs detect` improves without + /// hand-editing. Off by default to keep detect predictable. + #[serde(default, skip_serializing_if = "is_false")] + pub learn: bool, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Config { #[serde(default)] pub settings: Settings, - #[serde(default, rename = "networks")] + /// Saved networks (SSID + optional local password). Persisted to the + /// separate `networks.toml` file (see [`networks_path`]), not to + /// `breadcrumbs.toml` — kept here, and still deserialized from a + /// `[[networks]]` block if one is present in `breadcrumbs.toml`, purely + /// for backward compatibility with configs written before the secrets + /// split: an old-format file's inline networks load in on first read and + /// migrate to `networks.toml` automatically on the next `save()`, no + /// explicit migration step required. + #[serde(default, rename = "networks", skip_serializing)] pub networks: Vec, #[serde(default)] pub profiles: BTreeMap, } +/// The on-disk shape of `networks.toml`: just the `[[networks]]` array, +/// split out of the main config so a file that's mostly just settings and +/// profiles (the parts people actually hand-edit or dotfile) doesn't also +/// carry whatever plaintext Wi-Fi credentials breadcrumbs still holds. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +struct NetworksFile { + #[serde(default, rename = "networks")] + networks: Vec, +} + pub fn config_dir() -> PathBuf { std::env::var_os("XDG_CONFIG_HOME") .map(PathBuf::from) @@ -112,6 +253,13 @@ pub fn config_path() -> PathBuf { config_dir().join("breadcrumbs.toml") } +/// Where saved networks (SSID + optional local password) live, split out of +/// `breadcrumbs.toml`. Not meant to be hand-edited — managed via +/// `breadcrumbs add` / `scan` / `forget`. +pub fn networks_path() -> PathBuf { + config_dir().join("networks.toml") +} + pub fn state_dir() -> PathBuf { std::env::var_os("XDG_STATE_HOME") .map(PathBuf::from) @@ -136,36 +284,119 @@ impl Config { self.networks.iter().find(|n| n.ssid == ssid) } + /// The effective exit-node list for a profile, in priority order: + /// per-profile `exit_node`, else `settings.exit_nodes`, else + /// `settings.exit_node`. Empty entries are filtered out. + pub fn exit_nodes_for(&self, profile: &str) -> Vec { + if let Some(p) = self.profiles.get(profile).and_then(|p| p.exit_node.clone()) { + return vec![p]; + } + let list = if self.settings.exit_nodes.is_empty() { + vec![self.settings.exit_node.clone()] + } else { + self.settings.exit_nodes.clone() + }; + list.into_iter() + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .collect() + } + /// Load config, creating a skeleton one on first run. pub fn load() -> Result { let path = config_path(); if !path.exists() { - let cfg = build_initial_config(); + let mut cfg = build_initial_config(); cfg.save()?; return Ok(cfg); } let text = fs::read_to_string(&path).map_err(|e| format!("reading {}: {e}", path.display()))?; + // May carry a legacy inline `[[networks]]` block (pre-split + // configs) — that's fine, see the field doc on `Config::networks`. let mut cfg: Config = toml::from_str(&text).map_err(|e| format!("parsing {}: {e}", path.display()))?; - // Self-heal: guarantee the three core profiles always exist. + + let net_path = networks_path(); + if net_path.exists() { + let net_text = fs::read_to_string(&net_path) + .map_err(|e| format!("reading {}: {e}", net_path.display()))?; + let nf: NetworksFile = toml::from_str(&net_text) + .map_err(|e| format!("parsing {}: {e}", net_path.display()))?; + // Merge, don't overwrite: a legacy config can still carry an + // inline `[[networks]]` block, and those entries must survive + // even when networks.toml already exists — otherwise the next + // save() (which writes only networks.toml) would silently drop + // hand-added inline networks. networks.toml wins on SSID + // conflicts; inline-only entries are appended and migrated. + let mut merged = nf.networks; + for def in std::mem::take(&mut cfg.networks) { + if !merged.iter().any(|n| n.ssid == def.ssid) { + merged.push(def); + } + } + cfg.networks = merged; + } + // else: no networks.toml yet — keep whatever legacy inline networks + // were read from breadcrumbs.toml above (or none, on a genuinely + // fresh config). The next `save()` writes them to networks.toml and + // stops writing them into breadcrumbs.toml, completing the migration. + + // Enforce the documented minimum so `list` and the watch loop agree + // on the poll interval (watch silently clamps to 4 otherwise). + if cfg.settings.watch_interval < 4 { + cfg.settings.watch_interval = 4; + } + ensure_core_profiles(&mut cfg); Ok(cfg) } - pub fn save(&self) -> Result<(), String> { + /// Persist settings + profiles to `breadcrumbs.toml` and networks to the + /// separate `networks.toml`, both `0600`. Every mutating command (`add`, + /// `forget`, `scan`, `profile set`, and `flow::run`'s own credential + /// clearing) goes through this single method so the two files never + /// drift out of sync with each other. + pub fn save(&mut self) -> Result<(), String> { + // The first save marks the config as user-owned: core profiles are + // backfilled only for genuinely fresh/legacy configs, never + // resurrected after the user has edited (or deleted) them. + self.settings.core_profiles_initialized = true; + let dir = config_dir(); fs::create_dir_all(&dir).map_err(|e| format!("creating {}: {e}", dir.display()))?; + let text = toml::to_string_pretty(self).map_err(|e| format!("serializing config: {e}"))?; let path = config_path(); - // Plaintext Wi-Fi passwords live here: write atomically and owner-only, - // so there's no torn read and no world-readable window. - crate::util::write_atomic(&path, &text, 0o600) - .map_err(|e| format!("writing {}: {e}", path.display()))?; + fs::write(&path, text).map_err(|e| format!("writing {}: {e}", path.display()))?; + secure_permissions(&path); + + let nf = NetworksFile { + networks: self.networks.clone(), + }; + let net_text = + toml::to_string_pretty(&nf).map_err(|e| format!("serializing networks: {e}"))?; + let net_path = networks_path(); + fs::write(&net_path, net_text) + .map_err(|e| format!("writing {}: {e}", net_path.display()))?; + secure_permissions(&net_path); + Ok(()) } } +/// Any local Wi-Fi passwords still held (pre-first-connect, or a network +/// breadcrumbs doesn't yet know NetworkManager owns) live in plaintext on +/// disk — keep both config files owner-only. Best-effort: a failure here +/// isn't fatal to saving the config itself. +#[cfg(unix)] +fn secure_permissions(path: &std::path::Path) { + use std::os::unix::fs::PermissionsExt; + let _ = fs::set_permissions(path, fs::Permissions::from_mode(0o600)); +} +#[cfg(not(unix))] +fn secure_permissions(_path: &std::path::Path) {} + /// Initial skeleton networks generated for a brand-new installation. /// Passwords are intentionally blank — secrets never live in source. /// Users fill them via `breadcrumbs add`, `breadcrumbs scan`, or @@ -190,6 +421,7 @@ fn core_profiles() -> BTreeMap { exit_node: None, include_all_known: false, detect_ssids: vec![], + learn: false, }, ); p.insert( @@ -201,6 +433,7 @@ fn core_profiles() -> BTreeMap { exit_node: None, include_all_known: false, detect_ssids: vec![], + learn: false, }, ); p.insert( @@ -212,12 +445,20 @@ fn core_profiles() -> BTreeMap { exit_node: None, include_all_known: true, detect_ssids: vec![], + learn: false, }, ); p } fn ensure_core_profiles(cfg: &mut Config) { + // Backfill missing core profiles only until the user has taken + // ownership of the config (`core_profiles_initialized` is set by the + // first save). After that, a profile the user deliberately deleted + // stays deleted. + if cfg.settings.core_profiles_initialized { + return; + } for (name, prof) in core_profiles() { cfg.profiles.entry(name).or_insert(prof); } @@ -273,122 +514,279 @@ mod tests { } #[test] - fn ensure_core_profiles_does_not_overwrite_existing() { + fn ensure_core_profiles_skips_backfill_once_initialized() { + // After the first save the config is user-owned: a deliberately + // deleted core profile must stay deleted instead of being + // resurrected on every load. let mut cfg = Config { - settings: Settings::default(), + settings: Settings { + core_profiles_initialized: true, + ..Default::default() + }, networks: vec![], profiles: BTreeMap::new(), }; - cfg.profiles.insert( + ensure_core_profiles(&mut cfg); + assert!(cfg.profiles.is_empty(), "no backfill once user-owned"); + } + + #[test] + fn ensure_core_profiles_preserves_user_customized_core_profile() { + // A user-edited "home" (custom SSIDs) must not be clobbered by the + // self-heal backfill — only genuinely *missing* core profiles should + // be inserted. + let mut profiles = BTreeMap::new(); + profiles.insert( "home".to_string(), Profile { - tailscale: true, - exit_node: Some("mynode".into()), + networks: vec!["CustomSSID".into()], ..Default::default() }, ); + let mut cfg = Config { + settings: Settings::default(), + networks: vec![], + profiles, + }; ensure_core_profiles(&mut cfg); - let home = cfg.profile("home").unwrap(); - assert!(home.tailscale, "existing field should be preserved"); - assert_eq!(home.exit_node.as_deref(), Some("mynode")); + assert_eq!( + cfg.profile("home").unwrap().networks, + vec!["CustomSSID".to_string()] + ); + // Still backfills the ones that were actually missing. + assert!(cfg.profile("work").is_some()); + assert!(cfg.profile("away").is_some()); } #[test] - fn network_lookup_found_and_not_found() { - let mut cfg = build_initial_config(); - cfg.networks.push(NetworkDef { - ssid: "TestNet".into(), - password: "secret".into(), - hidden: false, - }); - let found = cfg.network("TestNet"); - assert!(found.is_some()); - assert_eq!(found.unwrap().password, "secret"); - assert!(cfg.network("NoSuchSSID").is_none()); - } - - #[test] - fn profile_lookup_found_and_not_found() { - let cfg = build_initial_config(); - assert!(cfg.profile("home").is_some()); - assert!(cfg.profile("nonexistent").is_none()); - } - - #[test] - fn settings_default_values() { + fn settings_default_matches_documented_defaults() { let s = Settings::default(); assert_eq!(s.dns, "1.1.1.1"); - assert_eq!(s.nmcli_wait, 8); - assert!(s.exit_node.is_empty()); + assert_eq!(s.connect_wait, 8); assert_eq!(s.default_profile, "away"); assert_eq!(s.watch_interval, 12); - assert!(!s.connectivity_url.is_empty()); - assert!(!s.ping_host.is_empty()); + assert_eq!(s.ping_host, "1.1.1.1"); + assert!(s.exit_node.is_empty()); } #[test] - fn config_toml_roundtrip_with_hidden_network() { - let mut cfg = build_initial_config(); - cfg.networks.push(NetworkDef { - ssid: "HiddenNet".into(), - password: "pw".into(), - hidden: true, - }); - cfg.networks.push(NetworkDef { - ssid: "VisibleNet".into(), - password: "pw2".into(), + fn network_def_hidden_defaults_false_when_omitted() { + let text = r#"ssid = "Cafe" +password = "pw""#; + let n: NetworkDef = toml::from_str(text).unwrap(); + assert!(!n.hidden); + } + + #[test] + fn network_def_password_defaults_to_none_when_key_absent() { + // No `password` key at all — e.g. a network whose secret breadcrumbs + // already cleared after NetworkManager took it over. + let text = r#"ssid = "Cafe" +hidden = false"#; + let n: NetworkDef = toml::from_str(text).unwrap(); + assert_eq!(n.password, None); + } + + #[test] + fn network_def_omits_password_key_entirely_when_none() { + // Round-tripping a cleared password must not write `password = ""` + // (which would read back as "has an empty secret") or any other + // stand-in — the key should be gone, full stop. + let n = NetworkDef { + ssid: "Cafe".into(), + password: None, + dns: None, + eap: None, + identity: None, + ca_cert: None, hidden: false, - }); - let text = toml::to_string_pretty(&cfg).unwrap(); - let back: Config = toml::from_str(&text).unwrap(); - assert_eq!(back.networks.len(), 2); - let hidden = back.network("HiddenNet").unwrap(); - assert!(hidden.hidden); - let visible = back.network("VisibleNet").unwrap(); - assert!(!visible.hidden); + }; + let text = toml::to_string_pretty(&n).unwrap(); + assert!(!text.contains("password"), "text: {text}"); } #[test] - fn config_toml_roundtrip_with_full_profile_fields() { + fn network_def_password_round_trips_through_toml() { + let n = NetworkDef { + ssid: "Cafe".into(), + password: Some("hunter2".into()), + dns: None, + eap: None, + identity: None, + ca_cert: None, + hidden: false, + }; + let text = toml::to_string_pretty(&n).unwrap(); + assert!(text.contains("hunter2")); + let back: NetworkDef = toml::from_str(&text).unwrap(); + assert_eq!(back.password, Some("hunter2".to_string())); + } + + // Note: `Config::save`/`Config::load`'s real filesystem behavior (the + // networks.toml split, and a cleared password actually landing on disk) + // is covered by `tests/cli.rs`'s Sandbox-isolated integration tests + // (`networks_are_stored_separately_from_settings_and_profiles`, + // `password_is_cleared_after_first_connect_and_never_sent_again`) rather + // than here — this module's tests stay pure per the project's test + // discipline (no real fs/env/subprocess access from a `#[cfg(test)]` + // unit test). + + #[test] + fn profile_default_has_no_bootstrap_or_tailscale() { + let p = Profile::default(); + assert!(p.bootstrap.is_none()); + assert!(!p.tailscale); + assert!(!p.include_all_known); + assert!(p.networks.is_empty()); + assert!(p.detect_ssids.is_empty()); + } + + #[test] + fn malformed_toml_fails_to_parse() { + let bad = "this is not [ valid toml"; + assert!(toml::from_str::(bad).is_err()); + } + + #[test] + fn config_with_only_settings_defaults_networks_and_profiles() { + // A hand-written config that only sets `[settings]` shouldn't require + // `networks`/`profiles` sections — both must fall back to `#[serde(default)]`. + let text = r#"[settings] +dns = "9.9.9.9""#; + let cfg: Config = toml::from_str(text).unwrap(); + assert_eq!(cfg.settings.dns, "9.9.9.9"); + assert!(cfg.networks.is_empty()); + assert!(cfg.profiles.is_empty()); + } + + #[test] + fn exit_nodes_for_prioritizes_profile_then_list_then_single_node() { let mut cfg = build_initial_config(); - let work = cfg.profiles.get_mut("work").unwrap(); - work.tailscale = true; - work.exit_node = Some("myexit".into()); - work.bootstrap = Some("BootstrapSSID".into()); - work.detect_ssids = vec!["WorkWifi".into(), "CorpGuest".into()]; - work.networks = vec!["WorkWifi".into()]; - let text = toml::to_string_pretty(&cfg).unwrap(); - let back: Config = toml::from_str(&text).unwrap(); - let w = back.profile("work").unwrap(); - assert!(w.tailscale); - assert_eq!(w.exit_node.as_deref(), Some("myexit")); - assert_eq!(w.bootstrap.as_deref(), Some("BootstrapSSID")); - assert_eq!(w.detect_ssids, vec!["WorkWifi", "CorpGuest"]); - assert_eq!(w.networks, vec!["WorkWifi"]); + cfg.settings.exit_node = "global".into(); + cfg.settings.exit_nodes = vec!["listA".into(), "listB".into()]; + cfg.profiles.get_mut("home").unwrap().exit_node = Some("profile".into()); + + // Per-profile override wins outright. + assert_eq!(cfg.exit_nodes_for("home"), vec!["profile".to_string()]); + + // Otherwise the priority list is used verbatim. + cfg.profiles.get_mut("home").unwrap().exit_node = None; + assert_eq!( + cfg.exit_nodes_for("home"), + vec!["listA".to_string(), "listB".to_string()] + ); + + // Without a list, the single setting is the (one-element) fallback. + cfg.settings.exit_nodes = vec![]; + assert_eq!(cfg.exit_nodes_for("home"), vec!["global".to_string()]); } #[test] - fn config_deserialization_applies_settings_defaults_for_missing_fields() { - let toml_str = r#" -[settings] -dns = "8.8.8.8" -"#; - let cfg: Config = toml::from_str(toml_str).unwrap(); - assert_eq!(cfg.settings.dns, "8.8.8.8"); - // Fields not specified should get their defaults. - assert_eq!(cfg.settings.nmcli_wait, 8); - assert_eq!(cfg.settings.default_profile, "away"); - assert_eq!(cfg.settings.watch_interval, 12); + fn exit_nodes_for_filters_empty_and_whitespace_entries() { + let mut cfg = build_initial_config(); + cfg.settings.exit_nodes = vec![ + " ".into(), + "nodeA".into(), + "".into(), + " nodeB ".into(), + ]; + assert_eq!( + cfg.exit_nodes_for("home"), + vec!["nodeA".to_string(), "nodeB".to_string()] + ); } #[test] - fn network_def_hidden_defaults_to_false() { - let toml_str = r#" -[[networks]] -ssid = "MyNet" -password = "pass" -"#; - let cfg: Config = toml::from_str(toml_str).unwrap(); - assert!(!cfg.networks[0].hidden); + fn hhmm_to_minutes_parses_and_rejects_malformed() { + assert_eq!(hhmm_to_minutes("09:30"), Some(570)); + assert_eq!(hhmm_to_minutes("00:00"), Some(0)); + assert_eq!(hhmm_to_minutes("23:59"), Some(1439)); + assert_eq!(hhmm_to_minutes("9:30"), Some(570)); // lenient about padding + assert_eq!(hhmm_to_minutes("24:00"), None); // hour out of range + assert_eq!(hhmm_to_minutes("12:60"), None); // minute out of range + assert_eq!(hhmm_to_minutes("0930"), None); // no colon + assert_eq!(hhmm_to_minutes(""), None); + } + + #[test] + fn window_contains_handles_same_day_and_overnight() { + // Same-day window 09:00–17:00 (end exclusive). + assert!(window_contains(540, 1020, 600)); + assert!(!window_contains(540, 1020, 1020)); + assert!(!window_contains(540, 1020, 500)); + // Overnight window 22:00–07:00. + assert!(window_contains(1320, 420, 1380)); // 23:00 + assert!(window_contains(1320, 420, 60)); // 01:00 + assert!(!window_contains(1320, 420, 720)); // 12:00 + } + + #[test] + fn scheduled_profile_returns_first_matching_rule() { + let mut cfg = build_initial_config(); + cfg.settings.schedule = vec![ + ScheduleEntry { + profile: "work".into(), + from: "09:00".into(), + to: "17:00".into(), + }, + ScheduleEntry { + profile: "home".into(), + from: "09:30".into(), + to: "18:00".into(), + }, + ]; + // 10:00 matches both — the first rule (work) wins. + assert_eq!(cfg.settings.scheduled_profile(600), Some("work".into())); + // Outside every window → no schedule applies. + assert_eq!(cfg.settings.scheduled_profile(60), None); + } + + #[test] + fn effective_dns_uses_per_network_override_then_global_fallback() { + let n = NetworkDef { + ssid: "x".into(), + password: None, + dns: Some("9.9.9.9".into()), + eap: None, + identity: None, + ca_cert: None, + hidden: false, + }; + assert_eq!(n.effective_dns("1.1.1.1"), "9.9.9.9"); + + let n2 = NetworkDef { dns: None, ..n.clone() }; + assert_eq!(n2.effective_dns("1.1.1.1"), "1.1.1.1"); + + // An explicit empty string is a valid per-network opt-out. + let n3 = NetworkDef { dns: Some(String::new()), ..n.clone() }; + assert_eq!(n3.effective_dns("1.1.1.1"), ""); + } + + #[test] + fn enterprise_fields_round_trip_and_omit_when_none() { + let n = NetworkDef { + ssid: "Corp".into(), + password: Some("pw".into()), + dns: None, + eap: Some("peap".into()), + identity: Some("user@corp".into()), + ca_cert: Some("/etc/ca.pem".into()), + hidden: false, + }; + let text = toml::to_string_pretty(&n).unwrap(); + assert!(text.contains("eap") && text.contains("identity") && text.contains("ca_cert")); + let back: NetworkDef = toml::from_str(&text).unwrap(); + assert_eq!(back.eap.as_deref(), Some("peap")); + assert_eq!(back.identity.as_deref(), Some("user@corp")); + assert_eq!(back.ca_cert.as_deref(), Some("/etc/ca.pem")); + + let plain = NetworkDef { + eap: None, + identity: None, + ca_cert: None, + ..n + }; + let t2 = toml::to_string_pretty(&plain).unwrap(); + assert!(!t2.contains("eap") && !t2.contains("identity") && !t2.contains("ca_cert")); } } diff --git a/src/flow.rs b/src/flow.rs index 0261496..469a3cf 100644 --- a/src/flow.rs +++ b/src/flow.rs @@ -1,8 +1,11 @@ -use crate::backend::Backend; +use std::thread; +use std::time::Duration; + use crate::config::{Config, NetworkDef}; -use crate::notify::Urgency; -use crate::status::Connectivity; -use crate::tailscale::TsHealth; +use crate::nm; +use crate::notify::{log, notify, Urgency}; +use crate::status::internet_ok; +use crate::tailscale::{self, TsHealth}; #[derive(Debug)] pub enum Outcome { @@ -27,84 +30,153 @@ impl Outcome { } } -fn resolve_candidates<'a>(cfg: &'a Config, p: &crate::config::Profile) -> Vec<&'a NetworkDef> { - let mut out: Vec<&NetworkDef> = Vec::new(); +/// Resolve a profile's priority list to concrete network definitions. +/// +/// Returns owned clones rather than borrows of `cfg` — small structs, and it +/// decouples the result's lifetime from `cfg` so callers (namely [`run`]) can +/// still mutate `cfg` (to clear a used password and persist it) while a +/// candidate from this list is being acted on. +fn resolve_candidates(cfg: &Config, p: &crate::config::Profile) -> Vec { + let mut out: Vec = Vec::new(); for ssid in &p.networks { if let Some(def) = cfg.network(ssid) { if !out.iter().any(|d| d.ssid == def.ssid) { - out.push(def); + out.push(def.clone()); } } } if p.include_all_known { for def in &cfg.networks { if !out.iter().any(|d| d.ssid == def.ssid) { - out.push(def); + out.push(def.clone()); } } } out } -/// Try to connect + confirm it actually carries traffic. -/// Returns Ok(()) on success, Err(reason) on failure. -fn connect_and_verify( - be: &dyn Backend, - iface: &str, - def: &NetworkDef, - cfg: &Config, -) -> Result<(), String> { - be.connect(iface, def, cfg.settings.nmcli_wait, &cfg.settings.dns)?; - if !be.device_connected(iface) { - return Err("device not connected after nmcli success".into()); +/// A network was just connected to using a local password, and NetworkManager +/// now durably holds that secret — either in a freshly created connection +/// profile (`device wifi connect --ask`) or an existing one whose PSK we just +/// supplied via `--ask connection up`. Either way breadcrumbs no longer needs +/// its own plaintext copy: clear it and persist immediately so it doesn't sit +/// on disk any longer than necessary. A no-op (no save) if the network has no +/// local password to begin with. +fn clear_password_if_used(cfg: &mut Config, ssid: &str) { + let Some(def) = cfg.networks.iter_mut().find(|n| n.ssid == ssid) else { + return; + }; + if def.password.is_none() { + return; + } + def.password = None; + if let Err(e) = cfg.save() { + log(&format!( + "failed to persist cleared password for {ssid}: {e}" + )); } - Ok(()) } -/// Describe post-association connectivity as an optional caveat note. -fn connectivity_note(be: &dyn Backend, cfg: &Config) -> Option { - match be.connectivity(cfg) { - Connectivity::Online => None, - Connectivity::Portal(Some(url)) => Some(format!("captive portal — sign in at {url}")), - Connectivity::Portal(None) => Some("captive portal — sign in required".into()), - Connectivity::Offline => Some("associated but no internet yet".into()), +/// Opt-in learning (`profiles..learn = true`): remember the SSIDs a +/// profile successfully connects to so `breadcrumbs detect` improves without +/// hand-editing. Bounded to keep the list sane; never touches an existing +/// marker. +fn learn_ssid(cfg: &mut Config, profile: &str, ssid: &str) { + let Some(p) = cfg.profiles.get_mut(profile) else { + return; + }; + if !p.learn || p.detect_ssids.len() >= 8 || p.detect_ssids.iter().any(|s| s == ssid) { + return; } + p.detect_ssids.push(ssid.to_string()); + if let Err(e) = cfg.save() { + log(&format!("failed to persist learned SSID {ssid} for {profile}: {e}")); + } +} + +/// Try to connect + confirm the device actually landed on the *requested* +/// SSID. Returns Ok(()) on success, Err(reason) on failure. +fn connect_and_verify(iface: &str, def: &NetworkDef, cfg: &Config) -> Result<(), String> { + nm::connect_verbose(iface, def, cfg.settings.connect_wait, def.effective_dns(&cfg.settings.dns))?; + // Confirm the SSID, not just "device connected": NM autoconnect can win + // a race and leave the device on a different network, and the wifi list + // can lag activation by a moment — so poll briefly before giving up. + for _ in 0..8 { + match nm::active_ssid(iface) { + // Explicitly on the requested network — success. + Some(active) if active == def.ssid => return Ok(()), + // Associated with a *different* network — the failure this + // check exists to catch. + Some(_) => break, + // Scan list stale right after activation — keep polling while + // the device is at least connected. + None => { + if !nm::device_connected(iface) { + break; + } + } + } + thread::sleep(Duration::from_millis(250)); + } + Err(format!("not associated with '{}' after connect", def.ssid)) +} + +/// Run the connection state machine for `profile_name`, with desktop +/// notifications enabled. See [`run_quiet`] for the daemon-facing variant. +pub fn run(cfg: &mut Config, profile_name: &str) -> Outcome { + run_inner(cfg, profile_name, true) +} + +/// Same state machine, but suppresses desktop notifications. Used by the +/// watch loop, which does its own transition-gated notifications — without +/// this, a persistent failure (e.g. a stopped Tailscale daemon) would +/// re-notify on every recovery retry instead of once per state change. +pub fn run_quiet(cfg: &mut Config, profile_name: &str) -> Outcome { + run_inner(cfg, profile_name, false) } /// Run the connection state machine for `profile_name`. -pub fn run(be: &dyn Backend, cfg: &Config, profile_name: &str) -> Outcome { +/// +/// Takes `cfg` mutably: a successful connect that used a local password +/// clears that network's `password` field and persists the config +/// immediately (see [`clear_password_if_used`]) — this is the only way that +/// clearing happens for the `init` / `profile set --apply` / `detect --apply` +/// commands and the watch loop, all of which route through here. +fn run_inner(cfg: &mut Config, profile_name: &str, notify_user: bool) -> Outcome { let profile = match cfg.profile(profile_name) { Some(p) => p.clone(), None => { - be.notify( - "breadcrumbs: unknown profile", - &format!("'{profile_name}' is not defined in breadcrumbs.toml"), - Urgency::Critical, - ); + if notify_user { + notify( + "breadcrumbs: unknown profile", + &format!("'{profile_name}' is not defined in breadcrumbs.toml"), + Urgency::Critical, + ); + } return Outcome::UnknownProfile(profile_name.to_string()); } }; - let iface = match be.wifi_interface() { + let iface = match nm::wifi_interface_preferred(cfg.settings.interface.as_deref()) { Some(i) => i, None => { - be.notify( - "breadcrumbs: no Wi-Fi adapter", - "Hardware issue — Wi-Fi device not found. Manual check needed.", - Urgency::Critical, - ); + if notify_user { + notify( + "breadcrumbs: no Wi-Fi adapter", + "Hardware issue — Wi-Fi device not found. Manual check needed.", + Urgency::Critical, + ); + } return Outcome::NoInterface; } }; - be.radio_on(); + nm::radio_on(); - let exit_node = profile - .exit_node - .clone() - .unwrap_or_else(|| cfg.settings.exit_node.clone()); + let exit_nodes = cfg.exit_nodes_for(profile_name); + let exit_node = exit_nodes.first().cloned().unwrap_or_default(); let candidates = resolve_candidates(cfg, &profile); - be.log(&format!( + log(&format!( "flow start: profile={profile_name} iface={iface} tailscale={} candidates=[{}]", profile.tailscale, candidates @@ -119,89 +191,116 @@ pub fn run(be: &dyn Backend, cfg: &Config, profile_name: &str) -> Outcome { if let Some(bs) = &profile.bootstrap { scan_targets.push(bs.clone()); } - be.rescan(&iface, &scan_targets); - let visible = be.visible_ssids(&iface); + nm::rescan(&iface, &scan_targets); + let visible = nm::visible_ssids(&iface); // ---- Tailscale-gated profiles (e.g. school) ------------------------- let mut on_bootstrap = false; if profile.tailscale { if let Some(bs_ssid) = profile.bootstrap.clone() { - match cfg.network(&bs_ssid) { + // Owned clone (not a borrow of `cfg`) so we're free to mutate + // `cfg` below on a successful connect. + match cfg.network(&bs_ssid).cloned() { Some(bdef) => { if visible.contains(&bdef.ssid) || bdef.hidden { - match connect_and_verify(be, &iface, bdef, cfg) { + match connect_and_verify(&iface, &bdef, cfg) { Ok(()) => { on_bootstrap = true; - be.log(&format!("bootstrap connected: {}", bdef.ssid)); + log(&format!("bootstrap connected: {}", bdef.ssid)); + clear_password_if_used(cfg, &bdef.ssid); } Err(e) => { - be.log(&format!("bootstrap connect failed: {} — {e}", bdef.ssid)) + log(&format!("bootstrap connect failed: {} — {e}", bdef.ssid)) } } } else { - be.log(&format!("bootstrap not in range: {}", bdef.ssid)); + log(&format!("bootstrap not in range: {}", bdef.ssid)); } } - None => be.log(&format!( + None => log(&format!( "bootstrap SSID '{bs_ssid}' has no credentials in config" )), } } - let ts = be.ensure_exit_node(&exit_node); + let ts = tailscale::ensure_exit_node(&exit_nodes); if !ts.is_ok() { - let ssid = be.active_ssid(&iface).or_else(|| profile.bootstrap.clone()); - be.notify( - "Tailscale Error", - &format!( - "{} — staying on {}", - ts.describe(), - ssid.clone().unwrap_or_else(|| "Wi-Fi".into()) - ), - Urgency::Critical, - ); + let ssid = nm::active_ssid(&iface).or_else(|| profile.bootstrap.clone()); + if notify_user { + notify( + "Tailscale Error", + &format!( + "{} — staying on {}", + ts.describe(), + ssid.clone().unwrap_or_else(|| "Wi-Fi".into()) + ), + Urgency::Critical, + ); + } return Outcome::TailscaleError { ssid, health: ts }; } - be.log(&format!("tailscale healthy via exit node {exit_node}")); + log(&format!("tailscale healthy via exit node {exit_node}")); // Refresh visibility before moving to the target network. - be.rescan(&iface, &scan_targets); + nm::rescan(&iface, &scan_targets); } - let visible = be.visible_ssids(&iface); + // Signals are re-read after the Tailscale gate so pass 1 can prefer the + // strongest AP among a profile's visible networks. + let visible_sig = nm::visible_signals(&iface); // ---- Connect to the priority list ---------------------------------- - // Pass 1: visible networks in priority order. + // Pass 1: visible networks, strongest signal first (priority order is + // the stable tiebreaker for equal signals). + let mut visible_candidates: Vec<&NetworkDef> = candidates + .iter() + .filter(|d| visible_sig.contains_key(&d.ssid)) + .collect(); + visible_candidates.sort_by(|a, b| { + visible_sig + .get(&b.ssid) + .cmp(&visible_sig.get(&a.ssid)) + }); let mut any_attempted = false; - for def in &candidates { - if visible.contains(&def.ssid) { - any_attempted = true; - match connect_and_verify(be, &iface, def, cfg) { - Ok(()) => { - let note = connectivity_note(be, cfg); - finish_connected(be, &def.ssid, profile_name, ¬e); - return Outcome::Connected { - ssid: def.ssid.clone(), - note, - }; - } - Err(e) => be.log(&format!("connect failed (visible): {} — {e}", def.ssid)), + for def in &visible_candidates { + any_attempted = true; + match connect_and_verify(&iface, def, cfg) { + Ok(()) => { + clear_password_if_used(cfg, &def.ssid); + learn_ssid(cfg, profile_name, &def.ssid); + let note = if internet_ok(cfg) { + None + } else { + Some("associated but no internet yet".to_string()) + }; + finish_connected(&def.ssid, profile_name, ¬e, notify_user); + return Outcome::Connected { + ssid: def.ssid.clone(), + note, + }; } + Err(e) => log(&format!("connect failed (visible): {} — {e}", def.ssid)), } } // Pass 2: hidden networks we couldn't see in the scan. for def in &candidates { if def.hidden && !visible.contains(&def.ssid) { any_attempted = true; - match connect_and_verify(be, &iface, def, cfg) { + match connect_and_verify(&iface, def, cfg) { Ok(()) => { - let note = connectivity_note(be, cfg); - finish_connected(be, &def.ssid, profile_name, ¬e); + clear_password_if_used(cfg, &def.ssid); + learn_ssid(cfg, profile_name, &def.ssid); + let note = if internet_ok(cfg) { + None + } else { + Some("associated but no internet yet".to_string()) + }; + finish_connected(&def.ssid, profile_name, ¬e, notify_user); return Outcome::Connected { ssid: def.ssid.clone(), note, }; } - Err(e) => be.log(&format!("connect failed (hidden): {} — {e}", def.ssid)), + Err(e) => log(&format!("connect failed (hidden): {} — {e}", def.ssid)), } } } @@ -214,12 +313,21 @@ pub fn run(be: &dyn Backend, cfg: &Config, profile_name: &str) -> Outcome { .bootstrap .clone() .unwrap_or_else(|| "bootstrap".into()); - if !be.device_connected(&iface) { - if let Some(bdef) = profile.bootstrap.as_deref().and_then(|s| cfg.network(s)) { - match connect_and_verify(be, &iface, bdef, cfg) { - Ok(()) => be.log(&format!("bootstrap reconnected: {}", bdef.ssid)), + if !nm::device_connected(&iface) { + // Owned clone (not a borrow of `cfg`) so a successful reconnect + // is free to mutate `cfg` to clear the used password. + if let Some(bdef) = profile + .bootstrap + .as_deref() + .and_then(|s| cfg.network(s).cloned()) + { + match connect_and_verify(&iface, &bdef, cfg) { + Ok(()) => { + log(&format!("bootstrap reconnected: {}", bdef.ssid)); + clear_password_if_used(cfg, &bdef.ssid); + } Err(e) => { - be.log(&format!("bootstrap reconnect failed: {} — {e}", bdef.ssid)); + log(&format!("bootstrap reconnect failed: {} — {e}", bdef.ssid)); on_bootstrap = false; } } @@ -231,8 +339,10 @@ pub fn run(be: &dyn Backend, cfg: &Config, profile_name: &str) -> Outcome { } else { format!("target network not in range — staying on {bs_ssid} (Tailscale OK)") }; - be.notify("breadcrumbs: using bootstrap", &reason, Urgency::Normal); - be.log(&format!("flow end: on bootstrap {bs_ssid}; {reason}")); + if notify_user { + notify("breadcrumbs: using bootstrap", &reason, Urgency::Normal); + } + log(&format!("flow end: on bootstrap {bs_ssid}; {reason}")); return Outcome::Connected { ssid: bs_ssid, note: Some(reason), @@ -245,34 +355,41 @@ pub fn run(be: &dyn Backend, cfg: &Config, profile_name: &str) -> Outcome { .map(|c| c.ssid.as_str()) .collect::>() .join(", "); - be.notify( - "breadcrumbs: no known networks", - &format!("profile '{profile_name}': none of [{names}] are in range"), - Urgency::Critical, - ); - be.log(&format!( + let msg = if candidates.is_empty() { + format!("profile '{profile_name}' has no networks configured") + } else { + format!("profile '{profile_name}': none of [{names}] are in range") + }; + if notify_user { + notify("breadcrumbs: no known networks", &msg, Urgency::Critical); + } + log(&format!( "flow end: no networks connected (profile={profile_name})" )); Outcome::NoNetworks } -fn finish_connected(be: &dyn Backend, ssid: &str, profile: &str, note: &Option) { +fn finish_connected(ssid: &str, profile: &str, note: &Option, notify_user: bool) { match note { None => { - be.notify( - "breadcrumbs: connected", - &format!("{ssid} ({profile})"), - Urgency::Low, - ); - be.log(&format!("flow end: connected {ssid} (profile={profile})")); + if notify_user { + notify( + "breadcrumbs: connected", + &format!("{ssid} ({profile})"), + Urgency::Low, + ); + } + log(&format!("flow end: connected {ssid} (profile={profile})")); } Some(n) => { - be.notify( - "breadcrumbs: connected (degraded)", - &format!("{ssid} ({profile}) — {n}"), - Urgency::Normal, - ); - be.log(&format!( + if notify_user { + notify( + "breadcrumbs: connected (degraded)", + &format!("{ssid} ({profile}) — {n}"), + Urgency::Normal, + ); + } + log(&format!( "flow end: connected {ssid} (profile={profile}) note={n}" )); } @@ -283,13 +400,16 @@ fn finish_connected(be: &dyn Backend, ssid: &str, profile: &str, note: &Option NetworkDef { NetworkDef { ssid: ssid.into(), - password: "x".into(), + password: Some("x".into()), + dns: None, + eap: None, + identity: None, + ca_cert: None, hidden: false, } } @@ -307,341 +427,6 @@ mod tests { } } - // --- a scriptable in-memory Backend for testing the state machine --- - - use std::cell::RefCell; - use std::collections::HashSet; - - struct Fake { - iface: Option, - visible: HashSet, - connectable: HashSet, - connected: RefCell>, - ts: TsHealth, - conn: Connectivity, - notes: RefCell>, - } - - impl Fake { - fn new() -> Fake { - Fake { - iface: Some("wlan0".into()), - visible: HashSet::new(), - connectable: HashSet::new(), - connected: RefCell::new(None), - ts: TsHealth::Ok, - conn: Connectivity::Online, - notes: RefCell::new(Vec::new()), - } - } - fn set(ssids: &[&str]) -> HashSet { - ssids.iter().map(|s| s.to_string()).collect() - } - fn visible(mut self, ssids: &[&str]) -> Self { - self.visible = Fake::set(ssids); - self - } - fn connectable(mut self, ssids: &[&str]) -> Self { - self.connectable = Fake::set(ssids); - self - } - fn ts(mut self, h: TsHealth) -> Self { - self.ts = h; - self - } - fn conn(mut self, c: Connectivity) -> Self { - self.conn = c; - self - } - fn no_iface(mut self) -> Self { - self.iface = None; - self - } - fn notified(&self, needle: &str) -> bool { - self.notes.borrow().iter().any(|n| n.contains(needle)) - } - } - - impl Backend for Fake { - fn wifi_interface(&self) -> Option { - self.iface.clone() - } - fn radio_on(&self) {} - fn rescan(&self, _: &str, _: &[String]) {} - fn visible_ssids(&self, _: &str) -> HashSet { - self.visible.clone() - } - fn active_ssid(&self, _: &str) -> Option { - self.connected.borrow().clone() - } - fn ipv4(&self, _: &str) -> Option { - self.connected.borrow().as_ref().map(|_| "10.0.0.2".into()) - } - fn device_connected(&self, _: &str) -> bool { - self.connected.borrow().is_some() - } - fn connect(&self, _: &str, net: &NetworkDef, _: u32, _: &str) -> Result<(), String> { - if self.connectable.contains(&net.ssid) { - *self.connected.borrow_mut() = Some(net.ssid.clone()); - Ok(()) - } else { - // A failed association drops the current link, like nmcli. - *self.connected.borrow_mut() = None; - Err(format!("cannot connect to {}", net.ssid)) - } - } - fn tailscale_installed(&self) -> bool { - true - } - fn ensure_exit_node(&self, _: &str) -> TsHealth { - self.ts.clone() - } - fn tailscale_check(&self, _: &str) -> TsHealth { - self.ts.clone() - } - fn connectivity(&self, _: &Config) -> Connectivity { - self.conn.clone() - } - fn notify(&self, summary: &str, _: &str, _: Urgency) { - self.notes.borrow_mut().push(summary.to_string()); - } - fn log(&self, _: &str) {} - } - - fn with_profile(name: &str, p: Profile) -> Config { - let mut c = cfg(); - c.profiles.insert(name.to_string(), p); - c - } - - fn connected(o: &Outcome) -> (&str, &Option) { - match o { - Outcome::Connected { ssid, note } => (ssid.as_str(), note), - other => panic!("expected Connected, got {other:?}"), - } - } - - // --- flow::run state machine --- - - #[test] - fn run_unknown_profile_returns_unknown_and_notifies() { - let be = Fake::new(); - let o = run(&be, &cfg(), "ghost"); - assert!(matches!(o, Outcome::UnknownProfile(p) if p == "ghost")); - assert!(be.notified("unknown profile")); - } - - #[test] - fn run_no_interface_returns_no_interface() { - let be = Fake::new().no_iface(); - let c = with_profile( - "home", - Profile { - networks: vec!["HomeWifi".into()], - ..Default::default() - }, - ); - assert!(matches!(run(&be, &c, "home"), Outcome::NoInterface)); - } - - #[test] - fn run_connects_to_visible_priority_network() { - let c = with_profile( - "home", - Profile { - networks: vec!["HomeWifi".into()], - ..Default::default() - }, - ); - let be = Fake::new() - .visible(&["HomeWifi", "CafeWifi"]) - .connectable(&["HomeWifi"]); - let o = run(&be, &c, "home"); - let (ssid, note) = connected(&o); - assert_eq!(ssid, "HomeWifi"); - assert!(note.is_none()); - } - - #[test] - fn run_follows_priority_order() { - let c = with_profile( - "home", - Profile { - networks: vec!["WorkNet".into(), "HomeWifi".into()], - ..Default::default() - }, - ); - let be = Fake::new() - .visible(&["WorkNet", "HomeWifi"]) - .connectable(&["WorkNet", "HomeWifi"]); - assert_eq!(connected(&run(&be, &c, "home")).0, "WorkNet"); - } - - #[test] - fn run_skips_failing_network_and_tries_next() { - let c = with_profile( - "home", - Profile { - networks: vec!["WorkNet".into(), "HomeWifi".into()], - ..Default::default() - }, - ); - let be = Fake::new() - .visible(&["WorkNet", "HomeWifi"]) - .connectable(&["HomeWifi"]); // WorkNet fails - assert_eq!(connected(&run(&be, &c, "home")).0, "HomeWifi"); - } - - #[test] - fn run_no_networks_in_range_returns_no_networks() { - let c = with_profile( - "home", - Profile { - networks: vec!["HomeWifi".into()], - ..Default::default() - }, - ); - let be = Fake::new().visible(&["SomeoneElse"]); - assert!(matches!(run(&be, &c, "home"), Outcome::NoNetworks)); - assert!(be.notified("no known networks")); - } - - #[test] - fn run_captive_portal_surfaces_as_note() { - let c = with_profile( - "home", - Profile { - networks: vec!["HomeWifi".into()], - ..Default::default() - }, - ); - let be = Fake::new() - .visible(&["HomeWifi"]) - .connectable(&["HomeWifi"]) - .conn(Connectivity::Portal(Some("http://login.test".into()))); - let o = run(&be, &c, "home"); - let (_, note) = connected(&o); - assert!(note.as_ref().unwrap().contains("captive portal")); - assert!(note.as_ref().unwrap().contains("http://login.test")); - } - - #[test] - fn run_offline_after_associate_is_degraded_note() { - let c = with_profile( - "home", - Profile { - networks: vec!["HomeWifi".into()], - ..Default::default() - }, - ); - let be = Fake::new() - .visible(&["HomeWifi"]) - .connectable(&["HomeWifi"]) - .conn(Connectivity::Offline); - let o = run(&be, &c, "home"); - let (_, note) = connected(&o); - assert!(note.as_ref().unwrap().contains("no internet")); - } - - #[test] - fn run_tailscale_gated_moves_to_target_when_healthy() { - let c = with_profile( - "work", - Profile { - bootstrap: Some("CafeWifi".into()), - networks: vec!["WorkNet".into()], - tailscale: true, - ..Default::default() - }, - ); - let be = Fake::new() - .visible(&["CafeWifi", "WorkNet"]) - .connectable(&["CafeWifi", "WorkNet"]) - .ts(TsHealth::Ok); - assert_eq!(connected(&run(&be, &c, "work")).0, "WorkNet"); - } - - #[test] - fn run_tailscale_unhealthy_stays_on_bootstrap() { - let c = with_profile( - "work", - Profile { - bootstrap: Some("CafeWifi".into()), - networks: vec!["WorkNet".into()], - tailscale: true, - ..Default::default() - }, - ); - let be = Fake::new() - .visible(&["CafeWifi", "WorkNet"]) - .connectable(&["CafeWifi", "WorkNet"]) - .ts(TsHealth::NeedsLogin); - match run(&be, &c, "work") { - Outcome::TailscaleError { ssid, health } => { - assert_eq!(ssid.as_deref(), Some("CafeWifi")); - assert_eq!(health, TsHealth::NeedsLogin); - } - other => panic!("expected TailscaleError, got {other:?}"), - } - assert!(be.notified("Tailscale")); - } - - #[test] - fn run_tailscale_ok_but_target_out_of_range_keeps_bootstrap() { - let c = with_profile( - "work", - Profile { - bootstrap: Some("CafeWifi".into()), - networks: vec!["WorkNet".into()], - tailscale: true, - ..Default::default() - }, - ); - let be = Fake::new() - .visible(&["CafeWifi"]) // WorkNet not in range - .connectable(&["CafeWifi"]) - .ts(TsHealth::Ok); - let o = run(&be, &c, "work"); - let (ssid, note) = connected(&o); - assert_eq!(ssid, "CafeWifi"); - assert!(note.as_ref().unwrap().contains("not in range")); - } - - // --- Outcome::ok --- - - #[test] - fn outcome_ok_true_for_connected_with_and_without_note() { - assert!(Outcome::Connected { - ssid: "x".into(), - note: None - } - .ok()); - assert!(Outcome::Connected { - ssid: "x".into(), - note: Some("associated but no internet yet".into()), - } - .ok()); - } - - #[test] - fn outcome_ok_false_for_all_error_variants() { - assert!(!Outcome::NoInterface.ok()); - assert!(!Outcome::NoNetworks.ok()); - assert!(!Outcome::UnknownProfile("p".into()).ok()); - assert!(!Outcome::TailscaleError { - ssid: None, - health: TsHealth::NeedsLogin, - } - .ok()); - assert!(!Outcome::TailscaleError { - ssid: Some("boot".into()), - health: TsHealth::ExitNodeOffline, - } - .ok()); - } - - // --- resolve_candidates --- - #[test] fn candidates_follow_priority_order() { let c = cfg(); @@ -649,10 +434,8 @@ mod tests { networks: vec!["FallbackNet".into(), "HomeWifi".into()], ..Default::default() }; - let got: Vec<&str> = resolve_candidates(&c, &p) - .iter() - .map(|n| n.ssid.as_str()) - .collect(); + let candidates = resolve_candidates(&c, &p); + let got: Vec<&str> = candidates.iter().map(|n| n.ssid.as_str()).collect(); assert_eq!(got, vec!["FallbackNet", "HomeWifi"]); } @@ -664,10 +447,8 @@ mod tests { include_all_known: true, ..Default::default() }; - let got: Vec<&str> = resolve_candidates(&c, &p) - .iter() - .map(|n| n.ssid.as_str()) - .collect(); + let candidates = resolve_candidates(&c, &p); + let got: Vec<&str> = candidates.iter().map(|n| n.ssid.as_str()).collect(); assert_eq!(got[0], "HomeWifi"); assert_eq!(got.len(), 4); assert!(got.contains(&"WorkNet")); @@ -682,56 +463,71 @@ mod tests { networks: vec!["Ghost".into(), "WorkNet".into()], ..Default::default() }; - let got: Vec<&str> = resolve_candidates(&c, &p) - .iter() - .map(|n| n.ssid.as_str()) - .collect(); + let candidates = resolve_candidates(&c, &p); + let got: Vec<&str> = candidates.iter().map(|n| n.ssid.as_str()).collect(); assert_eq!(got, vec!["WorkNet"]); } #[test] - fn candidates_empty_when_profile_has_no_networks() { + fn empty_profile_network_list_yields_no_candidates() { let c = cfg(); let p = Profile::default(); assert!(resolve_candidates(&c, &p).is_empty()); } #[test] - fn candidates_include_all_known_only_no_explicit_networks() { - let c = cfg(); - let p = Profile { - include_all_known: true, - ..Default::default() - }; - let got: Vec<&str> = resolve_candidates(&c, &p) - .iter() - .map(|n| n.ssid.as_str()) - .collect(); - assert_eq!(got.len(), 4); - assert_eq!(got[0], "HomeWifi"); - } - - #[test] - fn candidates_deduplicates_repeated_ssid_in_explicit_list() { + fn duplicate_ssids_within_profile_list_are_deduped() { let c = cfg(); let p = Profile { networks: vec!["HomeWifi".into(), "HomeWifi".into(), "WorkNet".into()], ..Default::default() }; - let got: Vec<&str> = resolve_candidates(&c, &p) - .iter() - .map(|n| n.ssid.as_str()) - .collect(); + let candidates = resolve_candidates(&c, &p); + let got: Vec<&str> = candidates.iter().map(|n| n.ssid.as_str()).collect(); assert_eq!(got, vec!["HomeWifi", "WorkNet"]); } #[test] - fn candidates_all_unknown_ssids_returns_empty() { + fn include_all_known_with_full_priority_list_appends_nothing_new() { let c = cfg(); let p = Profile { - networks: vec!["Ghost1".into(), "Ghost2".into()], + networks: vec![ + "HomeWifi".into(), + "WorkNet".into(), + "CafeWifi".into(), + "FallbackNet".into(), + ], + include_all_known: true, ..Default::default() }; - assert!(resolve_candidates(&c, &p).is_empty()); + let got = resolve_candidates(&c, &p); + assert_eq!(got.len(), 4); + } + + #[test] + fn include_all_known_on_empty_priority_list_returns_all_networks() { + let c = cfg(); + let p = Profile { + include_all_known: true, + ..Default::default() + }; + assert_eq!(resolve_candidates(&c, &p).len(), 4); + } + + #[test] + fn outcome_ok_is_true_only_for_connected() { + assert!(Outcome::Connected { + ssid: "x".into(), + note: None + } + .ok()); + assert!(!Outcome::NoInterface.ok()); + assert!(!Outcome::NoNetworks.ok()); + assert!(!Outcome::UnknownProfile("ghost".into()).ok()); + assert!(!Outcome::TailscaleError { + ssid: None, + health: crate::tailscale::TsHealth::NotInstalled + } + .ok()); } } diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..a76610e --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,20 @@ +//! breadcrumbs library crate. +//! +//! All actual logic lives here; `src/main.rs` is a thin binary shim that +//! parses no arguments itself — it just calls [`app::run`]. Splitting things +//! this way means integration tests can link against `breadcrumbs` as an +//! ordinary library crate and drive the real state machine (`flow::run`, +//! `watch::classify`, …) in-process, instead of only being able to spawn the +//! compiled binary. + +pub mod app; +pub mod bread_events; +pub mod config; +pub mod flow; +pub mod nm; +pub mod notify; +pub mod state; +pub mod status; +pub mod tailscale; +pub mod util; +pub mod watch; diff --git a/src/main.rs b/src/main.rs index 558ec96..5263504 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,909 +1,7 @@ -mod backend; -mod config; -mod flow; -mod nm; -mod notify; -mod state; -mod status; -mod tailscale; -mod util; -mod watch; - -use std::io::{BufRead, Write}; -use std::process::Command; -use std::time::Duration; - -use clap::{Parser, Subcommand}; - -use backend::Backend; -use config::{Config, NetworkDef}; -use state::State; -use util::{command_exists, home_dir, run}; - -const C_RESET: &str = "\x1b[0m"; -const C_BOLD: &str = "\x1b[1m"; -const C_GREEN: &str = "\x1b[32m"; -const C_RED: &str = "\x1b[31m"; -const C_YELLOW: &str = "\x1b[33m"; -const C_DIM: &str = "\x1b[2m"; - -#[derive(Parser)] -#[command( - name = "breadcrumbs", - version, - about = "Profile-aware Wi-Fi state machine with Tailscale handling", - disable_help_subcommand = true -)] -struct Cli { - /// Override the active profile for this run only (does not persist) - #[arg(long, short, global = true)] - profile: Option, - - #[command(subcommand)] - cmd: Option, -} - -#[derive(Subcommand)] -enum Cmd { - /// Show current Wi-Fi / profile / Tailscale status (default) - Status { - /// Emit machine-readable JSON instead of the human summary - #[arg(long)] - json: bool, - }, - /// Run the full connect sequence for the active profile - #[command(visible_aliases = ["up", "connect", "i"])] - Init, - /// Run as a daemon: watch for drops and auto-recover - Watch { - /// Skip the connect attempt on startup - #[arg(long)] - no_initial: bool, - }, - /// Get / set / list location profiles (the state machine) - Profile { - #[command(subcommand)] - action: Option, - }, - /// Guess the profile from visible networks - Detect { - /// Set + apply the detected profile - #[arg(long)] - apply: bool, - }, - /// Add or update a saved network - Add { - ssid: String, - /// Password (prompted if omitted) - password: Option, - /// Network is hidden (does not broadcast its SSID) - #[arg(long)] - hidden: bool, - /// Attach this SSID to a profile's priority list - #[arg(long)] - to: Option, - /// Position in the profile list (0 = highest priority) - #[arg(long)] - at: Option, - }, - /// Remove a saved network (config + NetworkManager) - Forget { ssid: String }, - /// Scan, pick, connect and save a network interactively - Scan { - /// Attach the saved network to this profile - #[arg(long)] - to: Option, - }, - /// List configured networks and profiles - List { - #[arg(long)] - show_passwords: bool, - }, - /// Connect to a specific saved network by SSID, bypassing profile routing - Join { ssid: String }, - /// List saved network SSIDs - Networks { - /// Emit a JSON array instead of one-per-line - #[arg(long)] - json: bool, - }, - /// Scan for visible networks and list them with signal strength - ScanList { - /// Emit JSON instead of human-readable output - #[arg(long)] - json: bool, - }, - /// Open the config file in $EDITOR - Edit, - /// Quick connectivity / Tailscale diagnostics - Doctor { - /// Run the full diag.sh report from the config directory - #[arg(long)] - full: bool, - }, - /// Print the breadcrumbs config directory - Cd { - #[arg(long)] - shell: bool, - }, - /// Install + enable the systemd user watcher service - InstallService { - /// Install the unit but do not enable/start it - #[arg(long)] - no_enable: bool, - }, -} - -#[derive(Subcommand)] -enum ProfileCmd { - /// Print the active profile - Get, - /// Set the active profile (and apply it unless --no-apply) - Set { - name: String, - #[arg(long)] - no_apply: bool, - }, - /// List available profiles - List, - /// Create a new (empty) profile - Add { - name: String, - /// SSID whose presence marks this location (repeatable, for `detect`) - #[arg(long = "detect")] - detect: Vec, - }, - /// Delete a profile (core profiles home/work/away cannot be removed) - Remove { name: String }, -} +//! Thin binary entry point. All argument parsing and command logic lives in +//! the library crate (`breadcrumbs::app`) so it can also be exercised +//! in-process by the integration tests under `tests/`. fn main() { - let cli = Cli::parse(); - let code = match real_main(cli) { - Ok(c) => c, - Err(e) => { - eprintln!("{C_RED}error:{C_RESET} {e}"); - 1 - } - }; - std::process::exit(code); -} - -fn active_profile(cfg: &Config, override_p: &Option) -> String { - if let Some(p) = override_p { - return p.clone(); - } - State::load(&cfg.settings.default_profile).profile -} - -fn real_main(cli: Cli) -> Result { - let cmd = cli.cmd.unwrap_or(Cmd::Status { json: false }); - - // `cd` and `install-service` don't need a parsed config first. - if let Cmd::Cd { shell } = &cmd { - return cmd_cd(*shell); - } - - let mut cfg = Config::load()?; - let be = backend::System; - - match cmd { - Cmd::Status { json } => cmd_status(&be, &cfg, &cli.profile, json), - Cmd::Init => { - let p = active_profile(&cfg, &cli.profile); - let outcome = flow::run(&be, &cfg, &p); - print_outcome(&p, &outcome); - Ok(if outcome.ok() { 0 } else { 1 }) - } - Cmd::Watch { no_initial } => Ok(watch::run(cfg, !no_initial)), - Cmd::Profile { action } => cmd_profile(&be, &mut cfg, action), - Cmd::Detect { apply } => cmd_detect(&be, &cfg, apply), - Cmd::Add { - ssid, - password, - hidden, - to, - at, - } => cmd_add(&mut cfg, ssid, password, hidden, to, at), - Cmd::Forget { ssid } => cmd_forget(&mut cfg, &ssid), - Cmd::Join { ssid } => cmd_join(&be, &cfg, &ssid), - Cmd::Networks { json } => cmd_networks(&cfg, json), - Cmd::ScanList { json } => cmd_scan_list(&cfg, json), - Cmd::Scan { to } => cmd_scan(&mut cfg, to), - Cmd::List { show_passwords } => cmd_list(&cfg, show_passwords), - Cmd::Edit => cmd_edit(), - Cmd::Doctor { full } => cmd_doctor(&be, &cfg, &cli.profile, full), - Cmd::InstallService { no_enable } => cmd_install_service(!no_enable), - Cmd::Cd { .. } => unreachable!(), - } -} - -fn print_outcome(profile: &str, o: &flow::Outcome) { - match o { - flow::Outcome::Connected { ssid, note } => { - print!("{C_GREEN}connected{C_RESET} {C_BOLD}{ssid}{C_RESET} ({profile})"); - match note { - Some(n) => println!(" {C_YELLOW}— {n}{C_RESET}"), - None => println!(), - } - } - flow::Outcome::TailscaleError { ssid, health } => { - println!( - "{C_RED}tailscale error{C_RESET}: {} {C_DIM}(on {}){C_RESET}", - health.describe(), - ssid.clone().unwrap_or_else(|| "—".into()) - ); - } - flow::Outcome::NoInterface => { - println!("{C_RED}no Wi-Fi adapter{C_RESET} — hardware issue") - } - flow::Outcome::NoNetworks => { - println!("{C_RED}no known networks in range{C_RESET} (profile {profile})") - } - flow::Outcome::UnknownProfile(p) => { - println!("{C_RED}unknown profile{C_RESET}: {p}") - } - } -} - -fn status_healthy(s: &status::Status) -> bool { - s.internet - && s.iface.is_some() - && (!s.tailscale_required || s.tailscale.as_ref().map(|h| h.is_ok()).unwrap_or(false)) -} - -fn cmd_status( - be: &dyn Backend, - cfg: &Config, - override_p: &Option, - json: bool, -) -> Result { - let p = active_profile(cfg, override_p); - let s = status::gather(be, cfg, &p); - let healthy = status_healthy(&s); - - if json { - let v = serde_json::json!({ - "profile": p, - "adapter": s.iface, - "ssid": s.ssid, - "ip": s.ip, - "internet": s.internet, - "captive_portal": s.portal, - "tailscale": { - "required": s.tailscale_required, - "installed": s.tailscale.is_some(), - "ok": s.tailscale.as_ref().map(|h| h.is_ok()), - "health": s.tailscale.as_ref().map(|h| h.describe()), - "exit_node": s.exit_node, - }, - "healthy": healthy, - }); - println!( - "{}", - serde_json::to_string_pretty(&v).unwrap_or_else(|_| "{}".into()) - ); - return Ok(if healthy { 0 } else { 1 }); - } - - let dot = |ok: bool| { - if ok { - format!("{C_GREEN}●{C_RESET}") - } else { - format!("{C_RED}●{C_RESET}") - } - }; - - println!("{C_BOLD}breadcrumbs{C_RESET}"); - println!(" profile {C_BOLD}{p}{C_RESET}"); - println!( - " adapter {}", - s.iface - .clone() - .unwrap_or_else(|| format!("{C_RED}none{C_RESET}")) - ); - println!( - " ssid {}", - s.ssid - .clone() - .unwrap_or_else(|| format!("{C_DIM}—{C_RESET}")) - ); - println!( - " ip {}", - s.ip.clone().unwrap_or_else(|| format!("{C_DIM}—{C_RESET}")) - ); - println!( - " internet {} {}", - dot(s.internet), - if s.internet { "ok" } else { "down" } - ); - if let Some(portal) = &s.portal { - let detail = if portal.is_empty() { - "sign-in required".to_string() - } else { - portal.clone() - }; - println!(" portal {C_YELLOW}captive portal{C_RESET} {C_DIM}{detail}{C_RESET}"); - } - - match (&s.tailscale, s.tailscale_required) { - (Some(h), req) => { - let ok = h.is_ok(); - println!( - " tailscale {} {} {C_DIM}(exit node: {}{}){C_RESET}", - dot(ok || !req), - h.describe(), - if s.exit_node.is_empty() { "none" } else { &s.exit_node }, - if req { "" } else { ", optional" } - ); - } - (None, _) => println!(" tailscale {C_DIM}not installed{C_RESET}"), - } - - println!( - " state {}", - if healthy { - format!("{C_GREEN}healthy{C_RESET}") - } else { - format!("{C_YELLOW}needs attention{C_RESET} — run `breadcrumbs init`") - } - ); - Ok(if healthy { 0 } else { 1 }) -} - -const CORE_PROFILES: [&str; 3] = ["home", "work", "away"]; - -fn cmd_profile( - be: &dyn Backend, - cfg: &mut Config, - action: Option, -) -> Result { - match action.unwrap_or(ProfileCmd::Get) { - ProfileCmd::Get => { - println!("{}", State::load(&cfg.settings.default_profile).profile); - Ok(0) - } - ProfileCmd::List => { - let cur = State::load(&cfg.settings.default_profile).profile; - for name in cfg.profiles.keys() { - let mark = if *name == cur { "*" } else { " " }; - println!("{mark} {name}"); - } - Ok(0) - } - ProfileCmd::Add { name, detect } => { - if cfg.profiles.contains_key(&name) { - return Err(format!("profile '{name}' already exists")); - } - cfg.profiles.insert( - name.clone(), - config::Profile { - detect_ssids: detect, - ..Default::default() - }, - ); - cfg.save()?; - println!("{C_GREEN}added{C_RESET} profile {name}"); - Ok(0) - } - ProfileCmd::Remove { name } => { - if CORE_PROFILES.contains(&name.as_str()) { - return Err(format!( - "'{name}' is a core profile and is always recreated; clear its networks instead" - )); - } - if cfg.profiles.remove(&name).is_none() { - return Err(format!("unknown profile '{name}'")); - } - cfg.save()?; - println!("{C_GREEN}removed{C_RESET} profile {name}"); - Ok(0) - } - ProfileCmd::Set { name, no_apply } => { - if !cfg.profiles.contains_key(&name) { - let avail: Vec<&String> = cfg.profiles.keys().collect(); - return Err(format!("unknown profile '{name}'. Available: {avail:?}")); - } - let st = State { - profile: name.clone(), - updated: util::timestamp(), - }; - st.save()?; - notify::log(&format!("profile set -> {name}")); - println!("profile = {C_BOLD}{name}{C_RESET}"); - if no_apply { - return Ok(0); - } - let outcome = flow::run(be, cfg, &name); - print_outcome(&name, &outcome); - Ok(if outcome.ok() { 0 } else { 1 }) - } - } -} - -fn detect_profile(be: &dyn Backend, cfg: &Config) -> Option { - let iface = be.wifi_interface()?; - be.radio_on(); - be.rescan(&iface, &[]); - let visible = be.visible_ssids(&iface); - - // Pick the profile with the most marker SSIDs in range, so overlapping - // locations disambiguate by strength of evidence. Profiles iterate in - // BTreeMap (alphabetical) order, which deterministically breaks ties. - let mut best: Option<(usize, String)> = None; - for (name, profile) in &cfg.profiles { - let score = profile - .detect_ssids - .iter() - .filter(|s| visible.contains(s.as_str())) - .count(); - if score == 0 { - continue; - } - if best.as_ref().map(|(s, _)| score > *s).unwrap_or(true) { - best = Some((score, name.clone())); - } - } - - // Fall back to the default profile if no markers matched. - Some( - best.map(|(_, name)| name) - .unwrap_or_else(|| cfg.settings.default_profile.clone()), - ) -} - -fn cmd_detect(be: &dyn Backend, cfg: &Config, apply: bool) -> Result { - match detect_profile(be, cfg) { - Some(p) => { - println!("{p}"); - if apply { - State { - profile: p.clone(), - updated: util::timestamp(), - } - .save()?; - let outcome = flow::run(be, cfg, &p); - print_outcome(&p, &outcome); - return Ok(if outcome.ok() { 0 } else { 1 }); - } - Ok(0) - } - None => Err("could not detect a profile (no Wi-Fi adapter?)".into()), - } -} - -fn prompt_line(msg: &str) -> String { - print!("{msg}"); - let _ = std::io::stdout().flush(); - let mut s = String::new(); - let _ = std::io::stdin().lock().read_line(&mut s); - s.trim_end_matches(['\n', '\r']).to_string() -} - -fn prompt_secret(msg: &str) -> String { - // `util::run` redirects child stdin to /dev/null, so plain `stty -echo` - // would target the wrong fd and silently leave echo ON (leaking the - // password to the screen). `-F /dev/tty` makes stty act on the controlling - // terminal directly. If there is no tty we fall back to visible input. - let had_tty = run("stty", &["-F", "/dev/tty", "-echo"], Duration::from_secs(2)).success; - let val = prompt_line(msg); - if had_tty { - let _ = run("stty", &["-F", "/dev/tty", "echo"], Duration::from_secs(2)); - println!(); - } - val -} - -fn cmd_add( - cfg: &mut Config, - ssid: String, - password: Option, - hidden: bool, - to: Option, - at: Option, -) -> Result { - let password = match password { - Some(p) => p, - None => prompt_secret(&format!("Password for '{ssid}': ")), - }; - match cfg.networks.iter_mut().find(|n| n.ssid == ssid) { - Some(n) => { - n.password = password; - n.hidden = hidden || n.hidden; - } - None => cfg.networks.push(NetworkDef { - ssid: ssid.clone(), - password, - hidden, - }), - } - if let Some(prof_name) = to { - let prof = cfg - .profiles - .get_mut(&prof_name) - .ok_or_else(|| format!("unknown profile '{prof_name}'"))?; - prof.networks.retain(|s| s != &ssid); - let idx = at.unwrap_or(prof.networks.len()).min(prof.networks.len()); - prof.networks.insert(idx, ssid.clone()); - } - cfg.save()?; - println!("{C_GREEN}saved{C_RESET} {ssid}"); - Ok(0) -} - -fn cmd_join(be: &dyn Backend, cfg: &Config, ssid: &str) -> Result { - let net = cfg - .network(ssid) - .ok_or_else(|| format!("no saved network '{ssid}' — add it first with `breadcrumbs add {ssid}`"))?; - let iface = be - .wifi_interface() - .ok_or_else(|| "no Wi-Fi adapter found".to_string())?; - be.radio_on(); - match nm::connect_verbose(&iface, net, cfg.settings.nmcli_wait, &cfg.settings.dns) { - Ok(()) => { - println!("{C_GREEN}connected{C_RESET} {C_BOLD}{ssid}{C_RESET}"); - Ok(0) - } - Err(e) => { - eprintln!("{C_RED}connect failed{C_RESET}: {e}"); - Ok(1) - } - } -} - -fn cmd_scan_list(cfg: &Config, json: bool) -> Result { - let iface = nm::wifi_interface().ok_or("no Wi-Fi adapter found")?; - let entries = nm::scan_list(&iface); - let saved: std::collections::HashSet<&str> = - cfg.networks.iter().map(|n| n.ssid.as_str()).collect(); - if json { - let v: Vec = entries - .iter() - .map(|e| { - serde_json::json!({ - "ssid": e.ssid, - "signal": e.signal, - "security": e.security, - "saved": saved.contains(e.ssid.as_str()), - }) - }) - .collect(); - println!("{}", serde_json::to_string(&v).unwrap_or_else(|_| "[]".into())); - } else { - for e in &entries { - let mark = if saved.contains(e.ssid.as_str()) { "*" } else { " " }; - println!("{mark} {:>3}% {} {}", e.signal, e.ssid, e.security); - } - } - Ok(0) -} - -fn cmd_networks(cfg: &Config, json: bool) -> Result { - let ssids: Vec<&str> = cfg.networks.iter().map(|n| n.ssid.as_str()).collect(); - if json { - println!("{}", serde_json::to_string(&ssids).unwrap_or_else(|_| "[]".into())); - } else { - for ssid in &ssids { - println!("{ssid}"); - } - } - Ok(0) -} - -fn cmd_forget(cfg: &mut Config, ssid: &str) -> Result { - let before = cfg.networks.len(); - cfg.networks.retain(|n| n.ssid != ssid); - for p in cfg.profiles.values_mut() { - p.networks.retain(|s| s != ssid); - if p.bootstrap.as_deref() == Some(ssid) { - p.bootstrap = None; - } - } - cfg.save()?; - let removed = nm::delete_connections_for_ssid(ssid); - println!( - "{C_GREEN}forgot{C_RESET} {ssid} (config: {}, NetworkManager: {})", - if cfg.networks.len() < before { - "removed" - } else { - "not present" - }, - if removed { "removed" } else { "not present" } - ); - Ok(0) -} - -fn cmd_scan(cfg: &mut Config, to: Option) -> Result { - let iface = nm::wifi_interface().ok_or("no Wi-Fi adapter")?; - nm::radio_on(); - nm::rescan(&iface, &[]); - let entries = nm::scan_list(&iface); - if entries.is_empty() { - return Err("no networks found".into()); - } - for (i, e) in entries.iter().enumerate() { - println!( - "{:>2}. {C_BOLD}{}{C_RESET} {C_DIM}sig {} {}{C_RESET}", - i + 1, - if e.ssid.is_empty() { - "" - } else { - &e.ssid - }, - e.signal, - e.security - ); - } - let sel = prompt_line("Select number: "); - let idx: usize = sel - .parse::() - .ok() - .filter(|n| *n >= 1 && *n <= entries.len()) - .ok_or("invalid selection")?; - let ssid = entries[idx - 1].ssid.clone(); - if ssid.is_empty() { - return Err("cannot select a hidden SSID here; use `breadcrumbs add`".into()); - } - let password = prompt_secret(&format!("Password for '{ssid}': ")); - let def = NetworkDef { - ssid: ssid.clone(), - password: password.clone(), - hidden: false, - }; - if !nm::connect(&iface, &def, cfg.settings.nmcli_wait, &cfg.settings.dns) { - return Err(format!("failed to connect to {ssid}")); - } - match cfg.networks.iter_mut().find(|n| n.ssid == ssid) { - Some(n) => n.password = password, - None => cfg.networks.push(def), - } - if let Some(prof_name) = to { - if let Some(prof) = cfg.profiles.get_mut(&prof_name) { - if !prof.networks.contains(&ssid) { - prof.networks.push(ssid.clone()); - } - } - } - cfg.save()?; - println!("{C_GREEN}connected + saved{C_RESET} {ssid}"); - Ok(0) -} - -fn mask(p: &str) -> String { - // Count by characters, not bytes: slicing &p[..1] would panic on a - // multi-byte first character (valid in WPA passphrases). - let count = p.chars().count(); - if count <= 2 { - "••".into() - } else { - let first: String = p.chars().take(1).collect(); - format!("{}{}", first, "•".repeat(count - 1)) - } -} - -fn cmd_list(cfg: &Config, show_pw: bool) -> Result { - println!("{C_BOLD}settings{C_RESET}"); - println!(" dns {}", cfg.settings.dns); - println!(" exit_node {}", cfg.settings.exit_node); - println!(" default {}", cfg.settings.default_profile); - println!(" watch every {}s", cfg.settings.watch_interval); - - println!("\n{C_BOLD}networks{C_RESET}"); - for n in &cfg.networks { - println!( - " {C_BOLD}{}{C_RESET} {C_DIM}{}{}{C_RESET}", - n.ssid, - if show_pw { - n.password.clone() - } else { - mask(&n.password) - }, - if n.hidden { " (hidden)" } else { "" } - ); - } - - println!("\n{C_BOLD}profiles{C_RESET}"); - let cur = State::load(&cfg.settings.default_profile).profile; - for (name, p) in &cfg.profiles { - let mark = if *name == cur { - format!("{C_GREEN}*{C_RESET}") - } else { - " ".into() - }; - println!("{mark} {C_BOLD}{name}{C_RESET}"); - if let Some(b) = &p.bootstrap { - println!(" bootstrap {b}"); - } - if p.tailscale { - let exit_node = p - .exit_node - .clone() - .unwrap_or_else(|| cfg.settings.exit_node.clone()); - println!( - " tailscale required (exit node: {})", - if exit_node.is_empty() { "none".to_string() } else { exit_node } - ); - } - let mut order: Vec = p.networks.clone(); - if p.include_all_known { - order.push("…all other known networks".into()); - } - println!(" priority {}", order.join(" > ")); - } - Ok(0) -} - -fn cmd_edit() -> Result { - let editor = std::env::var("EDITOR").unwrap_or_else(|_| "nano".into()); - let path = config::config_path(); - let status = Command::new(&editor) - .arg(&path) - .status() - .map_err(|e| format!("launching {editor}: {e}"))?; - if !status.success() { - return Err("editor exited with error".into()); - } - match Config::load() { - Ok(_) => { - println!("{C_GREEN}config OK{C_RESET}"); - Ok(0) - } - Err(e) => Err(format!("config is now invalid: {e}")), - } -} - -fn cmd_doctor( - be: &dyn Backend, - cfg: &Config, - override_p: &Option, - full: bool, -) -> Result { - if full { - let script = config::config_dir().join("diag.sh"); - if !script.exists() { - return Err(format!( - "diag.sh not found (expected at {})", - script.display() - )); - } - let st = Command::new("bash") - .arg(&script) - .status() - .map_err(|e| format!("running diag: {e}"))?; - return Ok(st.code().unwrap_or(1)); - } - - let p = active_profile(cfg, override_p); - let s = status::gather(be, cfg, &p); - println!("{C_BOLD}breadcrumbs doctor{C_RESET} (profile {p})"); - println!( - " nmcli {}", - if command_exists("nmcli") { - "present" - } else { - "MISSING" - } - ); - println!( - " tailscale {}", - if command_exists("tailscale") { - "present" - } else { - "absent" - } - ); - println!( - " adapter {}", - s.iface.clone().unwrap_or_else(|| "none".into()) - ); - println!( - " ssid {}", - s.ssid.clone().unwrap_or_else(|| "—".into()) - ); - println!( - " ip {}", - s.ip.clone().unwrap_or_else(|| "—".into()) - ); - println!(" internet {}", if s.internet { "ok" } else { "DOWN" }); - if let Some(h) = &s.tailscale { - println!( - " tailscale {} (exit node: {})", - h.describe(), - if s.exit_node.is_empty() { "none" } else { &s.exit_node } - ); - } - - if let Some(iface) = &s.iface { - let visible = nm::visible_ssids(iface); - let known: Vec<&str> = cfg - .networks - .iter() - .filter(|n| visible.contains(&n.ssid)) - .map(|n| n.ssid.as_str()) - .collect(); - println!( - " in range {}", - if known.is_empty() { - "none of your saved networks".into() - } else { - known.join(", ") - } - ); - } - println!("\nFull report: {C_DIM}breadcrumbs doctor --full{C_RESET}"); - Ok(0) -} - -fn cmd_cd(shell: bool) -> Result { - let dir = config::config_dir(); - if shell { - let sh = std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".into()); - let err = exec_replace(&sh, &["-lc", &format!("cd {:?} && exec {sh}", dir)]); - return Err(err); - } - println!("{}", dir.display()); - Ok(0) -} - -fn exec_replace(prog: &str, args: &[&str]) -> String { - use std::os::unix::process::CommandExt; - let e = Command::new(prog).args(args).exec(); - format!("exec {prog} failed: {e}") -} - -fn cmd_install_service(enable: bool) -> Result { - let unit_dir = home_dir().join(".config/systemd/user"); - std::fs::create_dir_all(&unit_dir) - .map_err(|e| format!("creating {}: {e}", unit_dir.display()))?; - let bin = std::env::current_exe().map_err(|e| format!("resolving current executable: {e}"))?; - // Ordering against graphical-session.target lets the watcher inherit the - // session's DISPLAY/WAYLAND_DISPLAY/DBUS so notify-send and the Tailscale - // login browser-open actually work. PATH is pinned because systemd --user - // units do not get the login shell's PATH, and the watcher shells out to - // nmcli/tailscale/sudo/xdg-open by name. - let unit = format!( - "[Unit]\n\ - Description=breadcrumbs Wi-Fi state machine watcher\n\ - After=network.target NetworkManager.service graphical-session.target\n\ - Wants=network.target graphical-session.target\n\n\ - [Service]\n\ - Type=simple\n\ - Environment=PATH=/usr/local/bin:/usr/bin:/bin\n\ - ExecStart={bin} watch\n\ - Restart=always\n\ - RestartSec=5\n\ - Nice=5\n\n\ - [Install]\n\ - WantedBy=default.target\n", - bin = bin.display() - ); - let unit_path = unit_dir.join("breadcrumbs.service"); - std::fs::write(&unit_path, unit) - .map_err(|e| format!("writing {}: {e}", unit_path.display()))?; - println!("{C_GREEN}wrote{C_RESET} {}", unit_path.display()); - - let _ = run( - "systemctl", - &["--user", "daemon-reload"], - Duration::from_secs(10), - ); - if enable { - let o = run( - "systemctl", - &["--user", "enable", "--now", "breadcrumbs.service"], - Duration::from_secs(15), - ); - if o.success { - println!("{C_GREEN}enabled + started{C_RESET} breadcrumbs.service"); - } else { - println!( - "{C_YELLOW}unit installed{C_RESET}; enable failed: {}", - o.stderr.trim() - ); - return Ok(1); - } - } else { - println!("Run: systemctl --user enable --now breadcrumbs.service"); - } - Ok(0) + std::process::exit(breadcrumbs::app::run()); } diff --git a/src/nm.rs b/src/nm.rs index df90791..38f5e42 100644 --- a/src/nm.rs +++ b/src/nm.rs @@ -1,108 +1,271 @@ -use std::collections::HashSet; -use std::path::PathBuf; -use std::time::Duration; +//! All NetworkManager access goes over its D-Bus API +//! (`org.freedesktop.NetworkManager` on the system bus) — no `nmcli` +//! subprocess and no terse-output parsing. The D-Bus API returns structured +//! values (SSID as a byte array, signal strength as `u8`, device state as a +//! `u32` enum), so there is nothing like the old `-t` escaping to get wrong. +//! +//! Every function here is fail-silent in the same spirit as the old nmcli +//! calls: a missing bus / unreachable NetworkManager yields `None`/`false`/ +//! an empty collection, never a panic. The one exception is +//! [`connect_verbose`], which returns the D-Bus error text so callers can +//! surface *why* a connect failed. +//! +//! The client connects with [`zbus::blocking::Connection::system`], which +//! honors the standard `DBUS_SYSTEM_BUS_ADDRESS` environment variable — the +//! test suite uses that to point at a fake NetworkManager served on a +//! private bus, with no test-only code paths in here. -use crate::config::{state_dir, NetworkDef}; -use crate::util::{run, run_ok, run_with_stdin}; +use std::collections::{HashMap, HashSet}; +use std::time::{Duration, Instant}; -/// nmcli `-t` escapes `:` and `\` in field values; undo that. -fn unescape(s: &str) -> String { - let mut out = String::with_capacity(s.len()); - let mut chars = s.chars().peekable(); - while let Some(c) = chars.next() { - if c == '\\' { - if let Some(&n) = chars.peek() { - out.push(n); - chars.next(); - continue; - } - } - out.push(c); - } - out +use zbus::blocking::{Connection, Proxy}; +use zbus::zvariant::{OwnedObjectPath, OwnedValue, Value}; + +use crate::config::NetworkDef; + +const NM_DEST: &str = "org.freedesktop.NetworkManager"; +const NM_PATH: &str = "/org/freedesktop/NetworkManager"; +const NM_IFACE: &str = "org.freedesktop.NetworkManager"; +const DEV_IFACE: &str = "org.freedesktop.NetworkManager.Device"; +const WIFI_IFACE: &str = "org.freedesktop.NetworkManager.Device.Wireless"; +const AP_IFACE: &str = "org.freedesktop.NetworkManager.AccessPoint"; +const SETTINGS_PATH: &str = "/org/freedesktop/NetworkManager/Settings"; +const SETTINGS_IFACE: &str = "org.freedesktop.NetworkManager.Settings"; +const CONN_IFACE: &str = "org.freedesktop.NetworkManager.Settings.Connection"; +const ACTIVE_IFACE: &str = "org.freedesktop.NetworkManager.Connection.Active"; +const IP4_IFACE: &str = "org.freedesktop.NetworkManager.IP4Config"; + +// NM_DEVICE_TYPE_WIFI +const DEV_TYPE_WIFI: u32 = 2; +// NM_DEVICE_STATE_ACTIVATED +const DEV_STATE_ACTIVATED: u32 = 100; +// NM_802_11_AP_FLAGS_PRIVACY +const AP_FLAG_PRIVACY: u32 = 0x1; +// NM_802_11_AP_SEC_KEY_MGMT_PSK / SAE / 802_1X +const SEC_PSK: u32 = 0x100; +const SEC_802_1X: u32 = 0x200; +const SEC_SAE: u32 = 0x400; +// NM_SETTINGS_ADD_CONNECTION2_FLAG_TO_DISK / UPDATE2_FLAG_TO_DISK +const FLAG_TO_DISK: u32 = 0x1; + +/// A fresh connection to the system bus (or wherever `DBUS_SYSTEM_BUS_ADDRESS` +/// points). Created per call — cheap relative to the subprocess the old code +/// spawned — and immune to environment changes between calls. +fn connection() -> Option { + Connection::system().ok() } -/// Split one nmcli `-t` line into fields. Fields are ':'-separated but values -/// escape ':' as '\:' and '\' as '\\'. -fn parse_scan_line(line: &str) -> Vec { - let mut fields: Vec = Vec::new(); - let mut cur = String::new(); - let mut chars = line.chars().peekable(); - while let Some(c) = chars.next() { - if c == '\\' { - if let Some(&n) = chars.peek() { - cur.push(n); - chars.next(); - continue; - } - } - if c == ':' { - fields.push(std::mem::take(&mut cur)); - } else { - cur.push(c); - } - } - fields.push(cur); - fields +/// zbus error text, for the one call that surfaces it. +fn err_text(e: zbus::Error) -> String { + format!("D-Bus: {e}") } -pub fn wifi_interface() -> Option { - let o = run( - "nmcli", - &["-t", "-f", "DEVICE,TYPE", "device", "status"], - Duration::from_secs(8), - ); - if !o.success { - return None; +/// Extract a byte array (SSID / CA cert blob) from a settings dict value. +/// zvariant has no `TryFrom<&Value>` for `Vec` (only for owned `Value`), +/// so we peel the `Array` ourselves. +fn value_bytes(v: &Value) -> Option> { + match v { + Value::Array(a) => { + let mut out = Vec::new(); + for item in a.inner() { + if let Value::U8(b) = item { + out.push(*b); + } else { + return None; + } + } + Some(out) + } + _ => None, } - for line in o.stdout.lines() { - let parts: Vec<&str> = line.splitn(2, ':').collect(); - if parts.len() == 2 && parts[1] == "wifi" { - return Some(unescape(parts[0])); +} + +/// Wrap an owned [`Value`] as an [`OwnedValue`] for storage in a settings +/// dict. Only fails for exotic non-ownable values (FDs); our data never hits +/// that, so a panic here would be a genuine bug. +fn ov(v: Value<'_>) -> OwnedValue { + OwnedValue::try_from(v).expect("settings value is ownable") +} + +fn proxy<'a>(conn: &'a Connection, path: &'a str, iface: &'a str) -> Option> { + Proxy::new(conn, NM_DEST, path, iface).ok() +} + +fn nm_proxy(conn: &Connection) -> Option> { + proxy(conn, NM_PATH, NM_IFACE) +} + +/// All realized device object paths (GetDevices), empty on error. +fn devices(conn: &Connection) -> Vec { + nm_proxy(conn) + .and_then(|p| p.call("GetDevices", &()).ok()) + .unwrap_or_default() +} + +/// The object path of the device whose `Interface` property is `iface`. +fn device_path(conn: &Connection, iface: &str) -> Option { + for d in devices(conn) { + // Scope the proxy so its borrow of `d` ends before we move `d` out. + let name: String = { + let dev = proxy(conn, d.as_str(), DEV_IFACE)?; + dev.get_property("Interface").ok()? + }; + if name == iface { + return Some(d); } } None } +/// All access points visible to a Wi-Fi device, as `(path, ssid, strength, +/// flags, wpa_flags, rsn_flags)` tuples (SSID decoded lossily from bytes). +fn access_points(conn: &Connection, iface: &str) -> Vec<(String, String, u8, u32, u32, u32)> { + let dev = match device_path(conn, iface) { + Some(d) => d, + None => return Vec::new(), + }; + let wifi = match proxy(conn, dev.as_str(), WIFI_IFACE) { + Some(p) => p, + None => return Vec::new(), + }; + let aps: Vec = match wifi.call("GetAllAccessPoints", &()) { + Ok(v) => v, + Err(_) => return Vec::new(), + }; + aps.into_iter() + .filter_map(|ap| { + let p = proxy(conn, ap.as_str(), AP_IFACE)?; + let ssid: Vec = p.get_property("Ssid").ok()?; + let strength: u8 = p.get_property("Strength").ok()?; + let flags: u32 = p.get_property("Flags").ok()?; + let wpa: u32 = p.get_property("WpaFlags").ok()?; + let rsn: u32 = p.get_property("RsnFlags").ok()?; + Some(( + ap.as_str().to_string(), + String::from_utf8_lossy(&ssid).into_owned(), + strength, + flags, + wpa, + rsn, + )) + }) + .collect() +} + +pub fn wifi_interface() -> Option { + wifi_interface_preferred(None) +} + +/// Object paths of every Wi-Fi device. The watch loop's monitor subscribes +/// to `Device.StateChanged` on each of these so link churn wakes it early. +pub fn wifi_device_paths() -> Vec { + let Some(conn) = connection() else { + return Vec::new(); + }; + let mut out = Vec::new(); + for d in devices(&conn) { + let dev = match proxy(&conn, d.as_str(), DEV_IFACE) { + Some(p) => p, + None => continue, + }; + if matches!(dev.get_property::("DeviceType"), Ok(t) if t == DEV_TYPE_WIFI) { + out.push(d.as_str().to_string()); + } + } + out +} + +/// Whether NetworkManager itself is present and answering on the system bus +/// (or wherever `DBUS_SYSTEM_BUS_ADDRESS` points). Used by `doctor` to +/// report NetworkManager presence without shelling out. A reachable bus with +/// no NM service (or a non-NM service under the name) reports false. +pub fn available() -> bool { + let Some(conn) = connection() else { + return false; + }; + let Some(p) = nm_proxy(&conn) else { + return false; + }; + // A well-known-name lookup that actually gets a reply proves the real + // NetworkManager holds the name. + p.call::<_, _, Vec>("GetDevices", &()).is_ok() +} + +/// Find the Wi-Fi interface. When `pref` is `Some`, that exact device is +/// used if present; otherwise (or if the preferred device is missing — e.g. +/// an unplugged USB dongle) the first Wi-Fi device wins. +pub fn wifi_interface_preferred(pref: Option<&str>) -> Option { + let conn = connection()?; + let mut wifi: Vec = Vec::new(); + for d in devices(&conn) { + let dev = proxy(&conn, d.as_str(), DEV_IFACE)?; + let devtype: u32 = dev.get_property("DeviceType").ok()?; + if devtype == DEV_TYPE_WIFI { + let name: String = dev.get_property("Interface").ok()?; + wifi.push(name); + } + } + if let Some(p) = pref { + if wifi.iter().any(|d| d == p) { + return Some(p.to_string()); + } + } + wifi.into_iter().next() +} + pub fn radio_on() { - let _ = run("nmcli", &["radio", "wifi", "on"], Duration::from_secs(6)); + let Some(conn) = connection() else { return }; + let Some(p) = nm_proxy(&conn) else { return }; + let _ = p.set_property("WirelessEnabled", true); } pub fn rescan(iface: &str, ssids: &[String]) { - let mut args: Vec = vec![ - "device".into(), - "wifi".into(), - "rescan".into(), - "ifname".into(), - iface.into(), - ]; - for s in ssids { - args.push("ssid".into()); - args.push(s.clone()); + let Some(conn) = connection() else { return }; + let Some(dev) = device_path(&conn, iface) else { return }; + let Some(wifi) = proxy(&conn, dev.as_str(), WIFI_IFACE) else { + return; + }; + let mut options: HashMap = HashMap::new(); + if !ssids.is_empty() { + let ssid_bytes: Vec> = ssids.iter().map(|s| s.as_bytes().to_vec()).collect(); + options.insert("ssids".into(), Value::from(ssid_bytes)); } - let argv: Vec<&str> = args.iter().map(|s| s.as_str()).collect(); - let _ = run("nmcli", &argv, Duration::from_secs(20)); + let _ = wifi.call_noreply("RequestScan", &(options,)); } pub fn visible_ssids(iface: &str) -> HashSet { - let o = run( - "nmcli", - &[ - "-t", "-f", "SSID", "device", "wifi", "list", "ifname", iface, - ], - Duration::from_secs(12), - ); - let mut set = HashSet::new(); - if !o.success { - return set; - } - for line in o.stdout.lines() { - let ssid = unescape(line.trim()); - if !ssid.is_empty() { - set.insert(ssid); + let Some(conn) = connection() else { + return HashSet::new(); + }; + access_points(&conn, iface) + .into_iter() + .map(|(_, ssid, _, _, _, _)| ssid) + .filter(|s| !s.is_empty()) + .collect() +} + +/// Visible SSIDs with their signal strength (0–100), one entry per SSID +/// (strongest BSSID wins). Used for signal-aware network selection and +/// scored detection. +pub fn visible_signals(iface: &str) -> HashMap { + let Some(conn) = connection() else { + return HashMap::new(); + }; + let mut m: HashMap = HashMap::new(); + for (_, ssid, strength, _, _, _) in access_points(&conn, iface) { + if ssid.is_empty() { + continue; } + let sig = strength as i32; + m.entry(ssid) + .and_modify(|e| { + if sig > *e { + *e = sig; + } + }) + .or_insert(sig); } - set + m } #[derive(Debug, Clone)] @@ -112,212 +275,354 @@ pub struct ScanEntry { pub security: String, } -pub fn scan_list(iface: &str) -> Vec { - let o = run( - "nmcli", - &[ - "-t", - "-f", - "SSID,SIGNAL,SECURITY", - "device", - "wifi", - "list", - "ifname", - iface, - ], - Duration::from_secs(12), - ); - let mut seen = HashSet::new(); - let mut out = Vec::new(); - if !o.success { - return out; +/// Derive an nmcli-style SECURITY column value from the 802.11 flag sets. +/// Mirrors what nmcli shows for an AP: `--` for open, `WPA1 WPA2` / `WPA2` / +/// `WPA3` / `802.1X` / `WEP` for secured networks. +fn security_string(flags: u32, wpa: u32, rsn: u32) -> String { + if flags & AP_FLAG_PRIVACY == 0 && wpa == 0 && rsn == 0 { + return "--".into(); } - for line in o.stdout.lines() { - let fields = parse_scan_line(line); - if fields.is_empty() { + if rsn & SEC_SAE != 0 { + return "WPA3".into(); + } + let mut parts: Vec<&str> = Vec::new(); + if wpa & SEC_PSK != 0 { + parts.push("WPA1"); + } + if rsn & SEC_PSK != 0 { + parts.push("WPA2"); + } + if !parts.is_empty() { + return parts.join(" "); + } + if wpa & SEC_802_1X != 0 || rsn & SEC_802_1X != 0 { + return "802.1X".into(); + } + "WEP".into() +} + +pub fn scan_list(iface: &str) -> Vec { + let Some(conn) = connection() else { + return Vec::new(); + }; + let mut out: Vec = Vec::new(); + for (_, ssid, strength, flags, wpa, rsn) in access_points(&conn, iface) { + if ssid.is_empty() { + // Hidden networks have no SSID in the scan; they're not + // selectable here anyway (see `cmd_scan`), so skip them. continue; } - let ssid = fields[0].trim().to_string(); - if ssid.is_empty() || !seen.insert(ssid.clone()) { - continue; + let signal = strength.to_string(); + let security = security_string(flags, wpa, rsn); + // One entry per SSID: dedup keeping the *strongest* signal, so a + // network broadcast by several APs shows once (at its best signal). + match out.iter_mut().find(|e| e.ssid == ssid) { + Some(existing) => { + if signal_rank(&signal) > signal_rank(&existing.signal) { + existing.signal = signal; + existing.security = security; + } + } + None => out.push(ScanEntry { + ssid, + signal, + security, + }), } - out.push(ScanEntry { - ssid, - signal: fields.get(1).cloned().unwrap_or_default(), - security: fields.get(2).cloned().unwrap_or_default(), - }); } out } +/// Numeric rank of a signal string ("72" or "72 %" — the fake NM can emit +/// either), for the strongest-wins dedup. +fn signal_rank(s: &str) -> i32 { + s.trim() + .trim_end_matches('%') + .trim() + .parse::() + .unwrap_or(-100) +} + pub fn active_ssid(iface: &str) -> Option { - let o = run( - "nmcli", - &[ - "-t", - "-f", - "ACTIVE,SSID", - "device", - "wifi", - "list", - "ifname", - iface, - ], - Duration::from_secs(8), - ); - if !o.success { + let conn = connection()?; + let dev = device_path(&conn, iface)?; + let wifi = proxy(&conn, dev.as_str(), WIFI_IFACE)?; + let ap: OwnedObjectPath = wifi.get_property("ActiveAccessPoint").ok()?; + if ap.as_str() == "/" { return None; } - for line in o.stdout.lines() { - let parts: Vec<&str> = line.splitn(2, ':').collect(); - if parts.len() == 2 && parts[0] == "yes" { - let s = unescape(parts[1].trim()); - if !s.is_empty() { - return Some(s); - } - } + let p = proxy(&conn, ap.as_str(), AP_IFACE)?; + let ssid: Vec = p.get_property("Ssid").ok()?; + let s = String::from_utf8_lossy(&ssid).into_owned(); + if s.is_empty() { + None + } else { + Some(s) } - None +} + +/// The device's current IPv4 address (dotted quad), via `Device.Ip4Config` +/// → `IP4Config.Addresses` (first address). Replaces the old +/// `nmcli -g IP4.ADDRESS device show` call. +pub fn ipv4_address(iface: &str) -> Option { + let conn = connection()?; + let dev = device_path(&conn, iface)?; + let dev_proxy = proxy(&conn, dev.as_str(), DEV_IFACE)?; + let cfg_path: OwnedObjectPath = dev_proxy.get_property("Ip4Config").ok()?; + if cfg_path.as_str() == "/" { + return None; + } + let ip4 = proxy(&conn, cfg_path.as_str(), IP4_IFACE)?; + // a(ayu): each entry is (address, prefix, gateway), address in network + // byte order as a host-order u32. + let addresses: Vec<(u32, u32, u32)> = ip4.get_property("Addresses").ok()?; + let (ip, _, _) = addresses.into_iter().next()?; + Some(format!( + "{}.{}.{}.{}", + (ip >> 24) & 0xff, + (ip >> 16) & 0xff, + (ip >> 8) & 0xff, + ip & 0xff + )) } pub fn device_connected(iface: &str) -> bool { - let o = run( - "nmcli", - &["-t", "-f", "DEVICE,STATE", "device", "status"], - Duration::from_secs(6), - ); - if !o.success { + let Some(conn) = connection() else { return false; - } - for line in o.stdout.lines() { - let parts: Vec<&str> = line.splitn(2, ':').collect(); - if parts.len() == 2 && unescape(parts[0]) == iface { - return parts[1].starts_with("connected"); - } - } - false + }; + let Some(dev) = device_path(&conn, iface) else { + return false; + }; + let Some(p) = proxy(&conn, dev.as_str(), DEV_IFACE) else { + return false; + }; + matches!(p.get_property::("State"), Ok(s) if s == DEV_STATE_ACTIVATED) } -fn active_uuid(iface: &str) -> Option { - let o = run( - "nmcli", - &["-g", "GENERAL.CON-UUID", "device", "show", iface], - Duration::from_secs(6), - ); - if !o.success { +/// The object path of the *active* connection's settings profile (via +/// Device.ActiveConnection → Connection.Active.Connection), if the device is +/// up. Used to locate the profile to pin DNS onto. +fn active_connection_path(conn: &Connection, iface: &str) -> Option { + let dev = device_path(conn, iface)?; + let dev_proxy = proxy(conn, dev.as_str(), DEV_IFACE)?; + let active: OwnedObjectPath = dev_proxy.get_property("ActiveConnection").ok()?; + if active.as_str() == "/" { return None; } - let u = o.stdout.trim().to_string(); - if u.is_empty() { - None - } else { - Some(u) - } + let ac = proxy(conn, active.as_str(), ACTIVE_IFACE)?; + let conn_path: OwnedObjectPath = ac.get_property("Connection").ok()?; + Some(conn_path) } -fn enforce_dns(uuid: &str, iface: &str, dns: &str) { - if dns.trim().is_empty() { - return; - } - let ok = run_ok( - "nmcli", - &[ - "connection", - "modify", - uuid, - "ipv4.ignore-auto-dns", - "yes", - "ipv4.dns", - dns, - ], - Duration::from_secs(8), - ); - if ok { - let _ = run( - "nmcli", - &["device", "reapply", iface], - Duration::from_secs(8), - ); - } +/// Full settings dict of a saved connection profile. +type SettingsMap = HashMap>; + +fn get_settings(conn: &Connection, conn_path: &str) -> Option { + let p = proxy(conn, conn_path, CONN_IFACE)?; + p.call("GetSettings", &()).ok() } -/// Return true if `name` is NetworkManager's numbered-duplicate convention for -/// `ssid`: exactly `ssid` followed by a space and one or more decimal digits -/// (e.g. "MyNet 1", "MyNet 2"). A name like "MyNet1" (no space) is a distinct -/// SSID and must not match. -fn is_numbered_nm_duplicate(name: &str, ssid: &str) -> bool { - if let Some(suffix) = name.strip_prefix(ssid) { - if let Some(digits) = suffix.strip_prefix(' ') { - return !digits.is_empty() && digits.chars().all(|c| c.is_ascii_digit()); - } - } - false -} - -/// Return the name of the first saved NM connection profile whose name is -/// either exactly `ssid` or `ssid N` (NM's numbered-duplicate convention). -/// Returns `None` if no such profile exists. -fn first_profile_for_ssid(ssid: &str) -> Option { - let o = run( - "nmcli", - &["-t", "-f", "NAME,TYPE", "connection", "show"], - Duration::from_secs(8), - ); - if !o.success { - return None; - } - let mut fallback: Option = None; - for line in o.stdout.lines() { - // NAME may itself contain an (escaped) ':', so a plain splitn would - // mis-split it — parse the line the same way nmcli escapes it. - let fields = parse_scan_line(line); - if fields.len() < 2 || !fields[1].contains("wireless") { +/// Return the path of the first saved NM connection profile whose name is +/// either exactly `ssid` or `ssid N` (NM's numbered-duplicate convention), or +/// whose 802-11-wireless SSID equals `ssid`. Returns `None` if no such +/// profile exists. +fn first_profile_for_ssid(conn: &Connection, ssid: &str) -> Option { + let settings = proxy(conn, SETTINGS_PATH, SETTINGS_IFACE)?; + let conns: Vec = settings.call("ListConnections", &()).ok()?; + let mut fallback: Option = None; + for c in conns { + let Some(s) = get_settings(conn, c.as_str()) else { continue; + }; + let conn_id = s + .get("connection") + .and_then(|m| m.get("id")) + .and_then(|v| v.downcast_ref::().ok()); + let conn_ssid = s + .get("802-11-wireless") + .and_then(|m| m.get("ssid")) + .and_then(|v| value_bytes(v)) + .map(|b| String::from_utf8_lossy(&b).into_owned()); + match &conn_id { + Some(id) if id == ssid => return Some(c), + _ => {} } - let name = fields[0].clone(); - if name == ssid { - return Some(name); + if conn_ssid.as_deref() == Some(ssid) { + return Some(c); } - if fallback.is_none() && is_numbered_nm_duplicate(&name, ssid) { - fallback = Some(name); + if fallback.is_none() { + if let Some(id) = &conn_id { + if let Some(suffix) = id.strip_prefix(ssid) { + let s = suffix.trim(); + if !s.is_empty() && s.chars().all(|c| c.is_ascii_digit()) { + fallback = Some(c); + } + } + } } } fallback } -/// Write the PSK to a 0600 file in the `setting.property:value` format nmcli's -/// `passwd-file` expects, so the secret reaches NetworkManager without ever -/// appearing in argv (where any local user could read it via `ps`). Returns the -/// path; the caller is responsible for removing it. -fn write_psk_file(password: &str) -> Option { - use std::io::Write; - // Prefer $XDG_RUNTIME_DIR (per-user tmpfs, mode 0700, wiped on logout) for a - // transient secret; fall back to the on-disk state dir only if it's unset. - let dir = std::env::var_os("XDG_RUNTIME_DIR") - .map(PathBuf::from) - .unwrap_or_else(state_dir); - let _ = std::fs::create_dir_all(&dir); - let path = dir.join(format!("breadcrumbs.psk.{}", std::process::id())); - let mut f = { - #[cfg(unix)] - { - use std::os::unix::fs::OpenOptionsExt; - std::fs::OpenOptions::new() - .write(true) - .create(true) - .truncate(true) - .mode(0o600) - .open(&path) - .ok()? +/// Build the `a{sa{sv}}` settings dict for a new Wi-Fi connection. +fn wifi_settings(net: &NetworkDef, uuid: &str) -> SettingsMap { + let mut settings: SettingsMap = HashMap::new(); + + let mut conn: HashMap = HashMap::new(); + conn.insert("id".into(), ov(Value::from(net.ssid.clone()))); + conn.insert("type".into(), ov(Value::from("802-11-wireless"))); + conn.insert("uuid".into(), ov(Value::from(uuid.to_string()))); + settings.insert("connection".into(), conn); + + let mut wifi: HashMap = HashMap::new(); + wifi.insert("ssid".into(), ov(Value::from(net.ssid.as_bytes().to_vec()))); + wifi.insert("mode".into(), ov(Value::from("infrastructure"))); + wifi.insert("hidden".into(), ov(Value::from(net.hidden))); + settings.insert("802-11-wireless".into(), wifi); + + if net.eap.is_some() { + let mut sec: HashMap = HashMap::new(); + sec.insert("key-mgmt".into(), ov(Value::from("wpa-eap"))); + settings.insert("802-11-wireless-security".into(), sec); + + let mut x1: HashMap = HashMap::new(); + if let Some(eap) = &net.eap { + x1.insert("eap".into(), ov(Value::from(vec![eap.clone()]))); } - #[cfg(not(unix))] - { - std::fs::File::create(&path).ok()? + if let Some(id) = &net.identity { + x1.insert("identity".into(), ov(Value::from(id.clone()))); } + if let Some(ca) = &net.ca_cert { + // NM stores `802-1x.ca-cert` as a GBytes (`ay`). For a filesystem + // path we send the conventional `file://` URI — the same form + // `nmcli` persists when given a path — not a bare string. + x1.insert( + "ca-cert".into(), + ov(Value::from(format!("file://{ca}").into_bytes())), + ); + } + if let Some(pw) = &net.password { + x1.insert("password".into(), ov(Value::from(pw.clone()))); + } + settings.insert("802-1x".into(), x1); + } else if let Some(pw) = &net.password { + let mut sec: HashMap = HashMap::new(); + sec.insert("key-mgmt".into(), ov(Value::from("wpa-psk"))); + sec.insert("psk".into(), ov(Value::from(pw.clone()))); + settings.insert("802-11-wireless-security".into(), sec); + } + + let mut ipv4: HashMap = HashMap::new(); + ipv4.insert("method".into(), ov(Value::from("auto"))); + settings.insert("ipv4".into(), ipv4); + let mut ipv6: HashMap = HashMap::new(); + ipv6.insert("method".into(), ov(Value::from("auto"))); + settings.insert("ipv6".into(), ipv6); + + settings +} + +/// Update a saved profile's settings in place for `net`: PSK (or 802.1x +/// properties for enterprise networks) and the hidden flag. Returns the +/// updated dict. +fn updated_settings_for(net: &NetworkDef, s: &mut SettingsMap) { + let sec = s.entry("802-11-wireless-security".to_string()).or_default(); + if net.eap.is_some() { + sec.insert("key-mgmt".into(), ov(Value::from("wpa-eap"))); + } else if net.password.is_some() { + sec.insert("key-mgmt".into(), ov(Value::from("wpa-psk"))); + } + if let Some(pw) = &net.password { + if net.eap.is_some() { + let x1 = s.entry("802-1x".to_string()).or_default(); + x1.insert("password".into(), ov(Value::from(pw.clone()))); + } else { + sec.insert("psk".into(), ov(Value::from(pw.clone()))); + } + } + let wifi = s.entry("802-11-wireless".to_string()).or_default(); + wifi.insert("hidden".into(), ov(Value::from(net.hidden))); +} + +/// RFC 4122 v4 UUID from `/dev/urandom` — used for the `connection.uuid` of +/// newly created profiles. (NetworkManager would generate one itself if +/// omitted, but being explicit matches `nmcli` and keeps the fake NM simple.) +fn new_uuid() -> String { + let mut bytes = [0u8; 16]; + if let Ok(mut f) = std::fs::File::open("/dev/urandom") { + use std::io::Read; + let _ = f.read_exact(&mut bytes); + } + bytes[6] = (bytes[6] & 0x0f) | 0x40; // version 4 + bytes[8] = (bytes[8] & 0x3f) | 0x80; // variant 10 + format!( + "{:02x}{:02x}{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}", + bytes[0], bytes[1], bytes[2], bytes[3], bytes[4], bytes[5], bytes[6], bytes[7], bytes[8], + bytes[9], bytes[10], bytes[11], bytes[12], bytes[13], bytes[14], bytes[15] + ) +} + +/// Wait up to `wait` seconds for the device to reach ACTIVATED. Polls the +/// device State property, like `nmcli --wait` blocks for activation. +fn wait_activated(conn: &Connection, dev: &OwnedObjectPath, wait: u32) -> Result<(), String> { + let deadline = Instant::now() + Duration::from_secs(wait.max(1) as u64); + loop { + match proxy(conn, dev.as_str(), DEV_IFACE) { + Some(p) => match p.get_property::("State") { + Ok(s) if s == DEV_STATE_ACTIVATED => return Ok(()), + Ok(_) => {} + Err(e) => return Err(err_text(e)), + }, + None => return Err("device disappeared while waiting for activation".into()), + } + if Instant::now() >= deadline { + return Err(format!( + "timed out waiting for device {dev} to activate after {wait}s" + )); + } + std::thread::sleep(Duration::from_millis(100)); + } +} + +/// Pin DNS onto the active connection of `iface`: update `ipv4.ignore-auto-dns` +/// and `ipv4.dns`, persist via Update2, then reapply on the device. +fn enforce_dns(conn: &Connection, iface: &str, dns: &str) -> bool { + if dns.trim().is_empty() { + return true; + } + let Some(active) = active_connection_path(conn, iface) else { + return false; }; - f.write_all(format!("802-11-wireless-security.psk:{password}\n").as_bytes()) - .ok()?; - Some(path) + let Some(mut s) = get_settings(conn, active.as_str()) else { + return false; + }; + let ipv4 = s.entry("ipv4".to_string()).or_default(); + ipv4.insert("ignore-auto-dns".into(), ov(Value::from(true))); + ipv4.insert("dns".into(), ov(Value::from(vec![dns.to_string()]))); + + // Persist the change on the *profile* object via `Update2` — calling + // `Settings.AddConnection2` with the profile's own UUID would fail on + // real NetworkManager (NM_SETTINGS_ERROR_UUID_EXISTS; duplicates are + // rejected, not upserted). + let conn_proxy = match proxy(conn, active.as_str(), CONN_IFACE) { + Some(p) => p, + None => return false, + }; + let _: Result, _> = conn_proxy.call( + "Update2", + &(&s, FLAG_TO_DISK, &HashMap::::new()), + ); + + // Reapply the updated settings on the device so DNS takes effect without + // bouncing the link. + let Some(dev) = device_path(conn, iface) else { + return false; + }; + let Some(dev_proxy) = proxy(conn, dev.as_str(), DEV_IFACE) else { + return false; + }; + let _: Result<(), _> = dev_proxy.call("Reapply", &(&s, 0u64, 0u32)); + true } /// Connect to a network and pin DNS. Returns true only if associated. @@ -325,137 +630,152 @@ pub fn connect(iface: &str, net: &NetworkDef, wait: u32, dns: &str) -> bool { connect_verbose(iface, net, wait, dns).is_ok() } -/// Connect to a network and pin DNS. Returns the nmcli error on failure. +/// Connect to a network and pin DNS. Returns the D-Bus error on failure. /// /// Reuses an existing saved profile for the SSID when one exists (updating its /// PSK) so that repeated connections do not accumulate numbered duplicates in -/// NetworkManager ("NCC", "NCC 1", "NCC 2", …). Falls back to -/// `nmcli device wifi connect` — which creates a new profile — only when no -/// saved profile is found. +/// NetworkManager. Falls back to creating a new connection +/// (`AddAndActivateConnection2`, which also activates it) only when no saved +/// profile is found. +/// +/// `net.password` is only sent when `Some`: on the reuse path, `None` means +/// \"leave the saved secret alone\" (either NetworkManager already durably +/// owns it, or the network is open); on the create path it means \"no secret +/// section\", which is also how a genuinely open (no-security) SSID is +/// connected. See the field doc on [`NetworkDef::password`] for how a local +/// secret transitions to `None` after its first successful use. +/// +/// The secret (when sent) travels inside the D-Bus `Update2`/`AddAndActivate +/// Connection2` settings payload — never on a process argv line — so it is +/// not readable by other local users via `/proc//cmdline` the way the +/// old `nmcli ... psk ` invocation was. pub fn connect_verbose(iface: &str, net: &NetworkDef, wait: u32, dns: &str) -> Result<(), String> { - let wait_s = wait.to_string(); + let conn = connection().ok_or_else(|| "cannot connect to the D-Bus system bus".to_string())?; + let dev = device_path(&conn, iface) + .ok_or_else(|| format!("no NetworkManager device named '{iface}'"))?; - if let Some(profile) = first_profile_for_ssid(&net.ssid) { - // Ensure the hidden flag is set — this carries no secret, so passing it - // in argv is safe. - if net.hidden { - let _ = run( - "nmcli", - &[ - "connection", - "modify", - &profile, - "802-11-wireless.hidden", - "yes", - ], - Duration::from_secs(6), - ); + if let Some(profile) = first_profile_for_ssid(&conn, &net.ssid) { + // Update the saved credentials and, for hidden networks, ensure the + // hidden flag is set. PSK vs 802.1x (enterprise) profiles get their + // own property sets. + if net.password.is_some() || net.hidden { + let mut s = get_settings(&conn, profile.as_str()) + .ok_or_else(|| "failed to read saved profile settings".to_string())?; + updated_settings_for(net, &mut s); + // Update the existing profile in place via `Settings.Connection + // .Update2`. `Settings.AddConnection2` with the profile's own + // UUID would be rejected by real NetworkManager + // (NM_SETTINGS_ERROR_UUID_EXISTS — duplicates are not upserted). + let conn_proxy = proxy(&conn, profile.as_str(), CONN_IFACE) + .ok_or_else(|| "NetworkManager Settings.Connection unavailable".to_string())?; + let _: HashMap = conn_proxy + .call("Update2", &(&s, FLAG_TO_DISK, &HashMap::::new())) + .map_err(err_text)?; } - // Supply the PSK to the activation via a 0600 passwd-file rather than - // argv. Harmless if NM already has a matching secret stored. - let psk_file = if net.password.is_empty() { - None - } else { - write_psk_file(&net.password) - }; - let psk_path = psk_file.as_ref().map(|p| p.display().to_string()); - let mut args: Vec<&str> = vec![ - "--wait", - &wait_s, - "connection", - "up", - &profile, - "ifname", - iface, - ]; - if let Some(ref p) = psk_path { - args.push("passwd-file"); - args.push(p.as_str()); - } - let o = run("nmcli", &args, Duration::from_secs(wait as u64 + 15)); - if let Some(p) = psk_file { - let _ = std::fs::remove_file(p); - } - if o.success { - if let Some(uuid) = active_uuid(iface) { - enforce_dns(&uuid, iface, dns); - } - return Ok(()); - } - // Activation failed (e.g. the saved secret is stale). Fall through to a - // fresh connect below, which re-supplies the PSK over stdin. + let nm = nm_proxy(&conn).ok_or_else(|| "NetworkManager unavailable".to_string())?; + let specific = zbus::zvariant::ObjectPath::try_from("/").expect("root object path"); + let _: OwnedObjectPath = nm + .call("ActivateConnection", &(&profile, &dev, &specific)) + .map_err(err_text)?; + wait_activated(&conn, &dev, wait)?; + enforce_dns(&conn, iface, dns); + return Ok(()); } - // No saved profile (or reuse failed) — create/refresh via device wifi - // connect. `--ask` makes nmcli read the PSK from stdin instead of argv. - let hidden = if net.hidden { "yes" } else { "no" }; - let args = [ - "--ask", - "--wait", - &wait_s, - "device", - "wifi", - "connect", - net.ssid.as_str(), - "hidden", - hidden, - "ifname", - iface, - ]; - let stdin = format!("{}\n", net.password); - let o = run_with_stdin( - "nmcli", - &args, - Some(&stdin), - Duration::from_secs(wait as u64 + 15), - ); - if !o.success { - let detail = o.stderr.trim().to_string(); - return Err(if detail.is_empty() { - o.stdout.trim().to_string() - } else { - detail - }); - } - if let Some(uuid) = active_uuid(iface) { - enforce_dns(&uuid, iface, dns); - } + // No saved profile — create one (and activate it in one call). + let settings = wifi_settings(net, &new_uuid()); + let nm = nm_proxy(&conn).ok_or_else(|| "NetworkManager unavailable".to_string())?; + let options: HashMap = + HashMap::from([("persist".to_string(), Value::from("disk"))]); + let specific = zbus::zvariant::ObjectPath::try_from("/").expect("root object path"); + let (_path, _active): (OwnedObjectPath, OwnedObjectPath) = nm + .call( + "AddAndActivateConnection2", + &(&settings, &dev, &specific, &options), + ) + .map_err(err_text)?; + wait_activated(&conn, &dev, wait)?; + enforce_dns(&conn, iface, dns); Ok(()) } +/// List all wireless connection profiles as `(name, ssid)` pairs, using the +/// profile's `802-11-wireless.ssid` setting when present (falling back to +/// the profile name). Used by `breadcrumbs prune`. +pub fn wireless_profiles() -> Vec<(String, String)> { + let Some(conn) = connection() else { + return Vec::new(); + }; + let Some(settings) = proxy(&conn, SETTINGS_PATH, SETTINGS_IFACE) else { + return Vec::new(); + }; + let conns: Vec = match settings.call("ListConnections", &()) { + Ok(v) => v, + Err(_) => return Vec::new(), + }; + let mut out = Vec::new(); + for c in conns { + let Some(s) = get_settings(&conn, c.as_str()) else { + continue; + }; + let typ = s + .get("connection") + .and_then(|m| m.get("type")) + .and_then(|v| v.downcast_ref::().ok()); + if typ.as_deref() != Some("802-11-wireless") { + continue; + } + let name = s + .get("connection") + .and_then(|m| m.get("id")) + .and_then(|v| v.downcast_ref::().ok()) + .unwrap_or_default(); + let conn_ssid = s + .get("802-11-wireless") + .and_then(|m| m.get("ssid")) + .and_then(|v| value_bytes(v)) + .map(|b| String::from_utf8_lossy(&b).into_owned()) + .filter(|s| !s.is_empty()); + out.push((name.clone(), conn_ssid.unwrap_or(name))); + } + out +} + /// Delete every saved connection profile whose name or 802-11-wireless SSID /// matches `ssid` (used by `breadcrumbs forget` to purge stale entries). pub fn delete_connections_for_ssid(ssid: &str) -> bool { - let list = run( - "nmcli", - &["-t", "-f", "NAME,TYPE", "connection", "show"], - Duration::from_secs(8), - ); - if !list.success { + let Some(conn) = connection() else { return false; - } + }; + let Some(settings) = proxy(&conn, SETTINGS_PATH, SETTINGS_IFACE) else { + return false; + }; + let conns: Vec = match settings.call("ListConnections", &()) { + Ok(v) => v, + Err(_) => return false, + }; let mut removed = false; - for line in list.stdout.lines() { - // NAME may contain an escaped ':' — parse rather than naive-split. - let fields = parse_scan_line(line); - if fields.len() < 2 || !fields[1].contains("wireless") { + for c in conns { + let Some(s) = get_settings(&conn, c.as_str()) else { continue; - } - let name = fields[0].clone(); - let conn_ssid = run( - "nmcli", - &["-g", "802-11-wireless.ssid", "connection", "show", &name], - Duration::from_secs(6), - ); - let conn_ssid = conn_ssid.stdout.trim(); - if (name == ssid || conn_ssid == ssid) - && run_ok( - "nmcli", - &["connection", "delete", "id", &name], - Duration::from_secs(8), - ) - { - removed = true; + }; + let name = s + .get("connection") + .and_then(|m| m.get("id")) + .and_then(|v| v.downcast_ref::().ok()) + .unwrap_or_default(); + let conn_ssid = s + .get("802-11-wireless") + .and_then(|m| m.get("ssid")) + .and_then(|v| value_bytes(v)) + .map(|b| String::from_utf8_lossy(&b).into_owned()) + .unwrap_or_default(); + if name == ssid || conn_ssid == ssid { + if let Some(p) = proxy(&conn, c.as_str(), CONN_IFACE) { + if p.call::<_, _, ()>("Delete", &()).is_ok() { + removed = true; + } + } } } removed @@ -466,107 +786,41 @@ mod tests { use super::*; #[test] - fn unescape_handles_nmcli_escaping() { - assert_eq!(unescape("plain"), "plain"); - assert_eq!(unescape(r"a\:b"), "a:b"); - assert_eq!(unescape(r"back\\slash"), r"back\slash"); - assert_eq!(unescape("trailing\\"), "trailing\\"); + fn security_string_derivation() { + // Open AP: no privacy bit, no WPA/RSN. + assert_eq!(security_string(0, 0, 0), "--"); + // WPA2-PSK (RSN PSK set). + assert_eq!(security_string(AP_FLAG_PRIVACY, 0, SEC_PSK), "WPA2"); + // WPA1+WPA2 (both flag sets carry PSK). + assert_eq!(security_string(AP_FLAG_PRIVACY, SEC_PSK, SEC_PSK), "WPA1 WPA2"); + // WPA3 (SAE). + assert_eq!(security_string(AP_FLAG_PRIVACY, 0, SEC_SAE), "WPA3"); + // Enterprise (802.1x key management). + assert_eq!( + security_string(AP_FLAG_PRIVACY, SEC_802_1X, SEC_802_1X), + "802.1X" + ); + // WEP: privacy bit but no WPA/RSN. + assert_eq!(security_string(AP_FLAG_PRIVACY, 0, 0), "WEP"); } #[test] - fn unescape_empty_string() { - assert_eq!(unescape(""), ""); + fn signal_rank_handles_percent_suffix() { + assert_eq!(signal_rank("90 %"), 90); + assert_eq!(signal_rank("80"), 80); + assert_eq!(signal_rank("garbage"), -100); } #[test] - fn unescape_multiple_consecutive_escapes() { - assert_eq!(unescape(r"a\:b\:c"), "a:b:c"); - } - - #[test] - fn unescape_double_backslash_produces_single() { - assert_eq!(unescape(r"a\\b"), r"a\b"); - } - - #[test] - fn parse_scan_line_splits_and_unescapes() { - // SSID:SIGNAL:SECURITY with an escaped ':' inside the SSID. - let f = parse_scan_line(r"My\:Net:72:WPA2"); - assert_eq!(f, vec!["My:Net", "72", "WPA2"]); - - // SSID with a space (common in real network names) - let f = parse_scan_line("My Network:88:WPA2"); - assert_eq!(f, vec!["My Network", "88", "WPA2"]); - - // Empty SSID (hidden) keeps the empty leading field. - let f = parse_scan_line(":40:WPA3"); - assert_eq!(f, vec!["", "40", "WPA3"]); - } - - #[test] - fn parse_scan_line_single_field_no_separators() { - let f = parse_scan_line("OnlySSID"); - assert_eq!(f, vec!["OnlySSID"]); - } - - #[test] - fn parse_scan_line_empty_input_yields_one_empty_field() { - let f = parse_scan_line(""); - assert_eq!(f, vec![""]); - } - - #[test] - fn parse_scan_line_all_empty_fields() { - // Three colons → four empty fields. - let f = parse_scan_line(":::"); - assert_eq!(f, vec!["", "", "", ""]); - } - - #[test] - fn parse_scan_line_multiple_escaped_colons_in_ssid() { - let f = parse_scan_line(r"a\:b\:c:80:WPA3"); - assert_eq!(f, vec!["a:b:c", "80", "WPA3"]); - } - - #[test] - fn parse_scan_line_backslash_escape_then_colon_separator() { - // "abc\:60:WPA2" — \: is an escaped colon inside the SSID, not a separator. - let f = parse_scan_line(r"abc\:60:WPA2"); - assert_eq!(f, vec!["abc:60", "WPA2"]); - } - - #[test] - fn is_numbered_nm_duplicate_exact_match_is_not_duplicate() { - assert!(!is_numbered_nm_duplicate("Net", "Net")); - } - - #[test] - fn is_numbered_nm_duplicate_space_digits_matches() { - assert!(is_numbered_nm_duplicate("Net 1", "Net")); - assert!(is_numbered_nm_duplicate("My Network 12", "My Network")); - } - - #[test] - fn is_numbered_nm_duplicate_no_space_does_not_match() { - // "Net1" is a distinct SSID, not a numbered duplicate of "Net". - assert!(!is_numbered_nm_duplicate("Net1", "Net")); - assert!(!is_numbered_nm_duplicate("HomeWifi2", "HomeWifi")); - } - - #[test] - fn is_numbered_nm_duplicate_non_numeric_suffix_does_not_match() { - assert!(!is_numbered_nm_duplicate("Net foo", "Net")); - assert!(!is_numbered_nm_duplicate("Net 1x", "Net")); - } - - #[test] - fn is_numbered_nm_duplicate_empty_digits_does_not_match() { - // "Net " (trailing space only, no digits) must not match. - assert!(!is_numbered_nm_duplicate("Net ", "Net")); - } - - #[test] - fn is_numbered_nm_duplicate_unrelated_name_does_not_match() { - assert!(!is_numbered_nm_duplicate("OtherNet 1", "Net")); + fn uuid_v4_is_shape_valid() { + let u = new_uuid(); + let bytes = u.as_bytes(); + assert_eq!(bytes.len(), 36); + assert_eq!(bytes[8], b'-'); + assert_eq!(bytes[13], b'-'); + assert_eq!(bytes[18], b'-'); + assert_eq!(bytes[23], b'-'); + // Version nibble is 4. + assert_eq!(bytes[14], b'4'); } } diff --git a/src/state.rs b/src/state.rs index 8565e0d..c55f570 100644 --- a/src/state.rs +++ b/src/state.rs @@ -2,7 +2,7 @@ use std::fs; use serde::{Deserialize, Serialize}; -use crate::config::{state_dir, state_path}; +use crate::config::{state_dir, state_path, Config}; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct State { @@ -29,7 +29,25 @@ impl State { pub fn save(&self) -> Result<(), String> { fs::create_dir_all(state_dir()).map_err(|e| format!("creating state dir: {e}"))?; let text = toml::to_string_pretty(self).map_err(|e| format!("serializing state: {e}"))?; - crate::util::write_atomic(&state_path(), &text, 0o644) - .map_err(|e| format!("writing state: {e}")) + fs::write(state_path(), text).map_err(|e| format!("writing state: {e}")) } } + +/// Persist `name` as the active profile if it exists in `cfg`. Shared by the +/// CLI `profile set` path and `bread.command.crumbs.set_profile` so they +/// cannot drift. Does not run [`crate::flow::run`] — the CLI applies +/// afterwards unless `--no-apply`, and the watch daemon picks the new +/// profile up on its next tick. +pub fn set_profile(cfg: &Config, name: &str) -> Result<(), String> { + if !cfg.profiles.contains_key(name) { + let avail: Vec<&String> = cfg.profiles.keys().collect(); + return Err(format!("unknown profile '{name}'. Available: {avail:?}")); + } + State { + profile: name.to_string(), + updated: crate::util::timestamp(), + } + .save()?; + crate::notify::log(&format!("profile set -> {name}")); + Ok(()) +} diff --git a/src/status.rs b/src/status.rs index 0deaa83..84b50e3 100644 --- a/src/status.rs +++ b/src/status.rs @@ -1,30 +1,22 @@ use std::time::Duration; -use crate::backend::Backend; use crate::config::Config; -use crate::tailscale::TsHealth; +use crate::nm; +use crate::tailscale::{self, TsHealth}; use crate::util::{command_exists, run}; -/// Result of a connectivity probe. `Portal` distinguishes a captive portal -/// (associated, but traffic is being intercepted) from real internet or a hard -/// outage — the optional string is the portal's sign-in URL when known. -#[derive(Debug, Clone, PartialEq, Eq)] +/// Connectivity verdict. `Portal` is the interesting case: an HTTP response +/// arrived (200/301/302) but it wasn't the 204 the generate_204 endpoint +/// returns for genuine internet — the classic captive/guest-portal +/// signature, and the reason `classify` can tell "no internet at all" from +/// "internet but intercepted". +#[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Connectivity { Online, - Portal(Option), - Offline, + Portal, + NoNet, } -impl Connectivity { - pub fn online(&self) -> bool { - matches!(self, Connectivity::Online) - } -} - -/// Probe connectivity. Only an empty HTTP 204 from the generate_204-style -/// endpoint counts as online; a 200/redirect means a captive portal is -/// intercepting traffic. If the HTTP probe is inconclusive (timeout/5xx) we fall -/// back to ICMP, which reaching the host treats as online. pub fn connectivity(cfg: &Config) -> Connectivity { if command_exists("curl") { let o = run( @@ -34,61 +26,43 @@ pub fn connectivity(cfg: &Config) -> Connectivity { "-o", "/dev/null", "-w", - "%{http_code} %{redirect_url}", + "%{http_code}", "--max-time", "4", &cfg.settings.connectivity_url, ], Duration::from_secs(6), ); - let mut parts = o.stdout.split_whitespace(); - let code = parts.next().unwrap_or(""); - let redirect = parts.next().unwrap_or("").trim(); - match code { - "204" => return Connectivity::Online, - "200" | "301" | "302" | "303" | "307" | "308" => { - let url = if redirect.is_empty() { - None - } else { - Some(redirect.to_string()) - }; - return Connectivity::Portal(url); - } - // 000/timeout/5xx → inconclusive, try ICMP below. - _ => {} + // Only a 204 counts as real internet. Captive/guest portals answer + // 200 (a login page) or 302 (a redirect to it). The default endpoint + // is generate_204, which returns 204 precisely when traffic isn't + // being intercepted. + let code = o.stdout.trim(); + if code == "204" { + return Connectivity::Online; + } + if code == "200" || code == "301" || code == "302" { + return Connectivity::Portal; } } + // Fallback: ICMP to the configured host. A working ping overrides a + // non-204 curl answer that wasn't portal-shaped (e.g. a 403 from an + // overzealous firewall); a portal usually blocks ICMP too, so this + // stays Portal for the genuine case. let ping = run( "ping", &["-c", "1", "-W", "2", &cfg.settings.ping_host], Duration::from_secs(4), - ) - .success; - if ping { + ); + if ping.success { Connectivity::Online } else { - Connectivity::Offline + Connectivity::NoNet } } -/// Best-effort IPv4 address of `iface` via nmcli, with the CIDR prefix stripped. -pub fn ipv4(iface: &str) -> Option { - let o = run( - "nmcli", - &["-g", "IP4.ADDRESS", "device", "show", iface], - Duration::from_secs(6), - ); - if !o.success { - return None; - } - let s = o.stdout.trim(); - if s.is_empty() { - None - } else { - // nmcli reports "192.168.1.5/24"; drop the prefix length for display. - let first = s.lines().next().unwrap_or(s).trim(); - Some(first.split('/').next().unwrap_or(first).to_string()) - } +pub fn internet_ok(cfg: &Config) -> bool { + matches!(connectivity(cfg), Connectivity::Online) } pub struct Status { @@ -96,33 +70,40 @@ pub struct Status { pub ssid: Option, pub ip: Option, pub internet: bool, - /// Set when a captive portal was detected; inner string is its URL if known. - pub portal: Option, + /// True when traffic is being intercepted (captive/guest portal). + pub portal: bool, pub tailscale_required: bool, pub tailscale: Option, pub exit_node: String, } -pub fn gather(be: &dyn Backend, cfg: &Config, profile_name: &str) -> Status { - let iface = be.wifi_interface(); - let ssid = iface.as_deref().and_then(|i| be.active_ssid(i)); - let ip = iface.as_deref().and_then(|i| be.ipv4(i)); - - let conn = be.connectivity(cfg); - let internet = conn.online(); - let portal = match conn { - Connectivity::Portal(url) => Some(url.unwrap_or_default()), - _ => None, +pub fn gather(cfg: &Config, profile_name: &str) -> Status { + let iface = nm::wifi_interface_preferred(cfg.settings.interface.as_deref()); + let ssid = iface.as_deref().and_then(nm::active_ssid); + let ip = iface.as_deref().and_then(nm::ipv4_address); + // Skip the (potentially 4s-blocking) connectivity probe when there's no + // Wi-Fi interface at all: the watch loop classifies NoAdapter and would + // otherwise burn a network round-trip (curl/ping) every tick for nothing. + let (internet, portal) = if iface.is_some() { + match connectivity(cfg) { + Connectivity::Online => (true, false), + Connectivity::Portal => (false, true), + Connectivity::NoNet => (false, false), + } + } else { + (false, false) }; let prof = cfg.profile(profile_name); let ts_required = prof.map(|p| p.tailscale).unwrap_or(false); - let exit_node = prof - .and_then(|p| p.exit_node.clone()) - .unwrap_or_else(|| cfg.settings.exit_node.clone()); + let exit_nodes = cfg.exit_nodes_for(profile_name); + let exit_node = exit_nodes.first().cloned().unwrap_or_default(); - let tailscale = if be.tailscale_installed() { - Some(be.tailscale_check(&exit_node)) + // Checked whenever tailscale is installed so `status`/`doctor` can show + // it even for non-required profiles; classify only consults it when the + // profile requires Tailscale. + let tailscale = if tailscale::installed() { + Some(tailscale::check(&exit_nodes)) } else { None }; diff --git a/src/tailscale.rs b/src/tailscale.rs index 79aaa34..294d9ec 100644 --- a/src/tailscale.rs +++ b/src/tailscale.rs @@ -21,6 +21,10 @@ pub enum TsHealth { ExitNodeMissing, /// The exit node exists but is offline. ExitNodeOffline, + /// The profile requires an exit node but none is configured + /// (`settings.exit_node` / per-profile `exit_node` empty). Cannot be + /// auto-fixed — the user must configure one. + NoExitNode, Error(String), } @@ -29,6 +33,21 @@ impl TsHealth { matches!(self, TsHealth::Ok) } + /// Wire name for `bread.crumbs.*` event payloads (variant name, like + /// `Health::as_str`). + pub fn state_str(&self) -> &'static str { + match self { + TsHealth::Ok => "Ok", + TsHealth::NotInstalled => "NotInstalled", + TsHealth::NeedsLogin => "NeedsLogin", + TsHealth::Stopped => "Stopped", + TsHealth::ExitNodeMissing => "ExitNodeMissing", + TsHealth::ExitNodeOffline => "ExitNodeOffline", + TsHealth::NoExitNode => "NoExitNode", + TsHealth::Error(_) => "Error", + } + } + pub fn describe(&self) -> String { match self { TsHealth::Ok => "ok".into(), @@ -37,6 +56,7 @@ impl TsHealth { TsHealth::Stopped => "backend stopped".into(), TsHealth::ExitNodeMissing => "exit node not found in tailnet".into(), TsHealth::ExitNodeOffline => "exit node is offline".into(), + TsHealth::NoExitNode => "no exit node configured".into(), TsHealth::Error(e) => format!("error: {e}"), } } @@ -219,16 +239,82 @@ fn run_login() { } } -/// Bring Tailscale to a state where `node` is the active, online exit node. -/// Performs at most one bring-up/login and one `tailscale set` attempt. -pub fn ensure_exit_node(node: &str) -> TsHealth { +/// Strip whitespace and drop empty entries from the acceptable-node list. +fn effective_nodes(nodes: &[String]) -> Vec { + nodes + .iter() + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .collect() +} + +/// Given a status JSON and the acceptable exit nodes, report `Ok` when the +/// active selection is one of them and online; otherwise the closest +/// actionable failure: not-selected (present + online, flow must select), +/// offline, or missing. +fn exit_node_health(nodes: &[String], v: &Value) -> TsHealth { + let mut any_exists = false; + let mut any_online = false; + let mut any_selected = false; + for node in nodes { + let (exists, online, selected) = exit_node_state(v, node); + if exists { + any_exists = true; + if online { + any_online = true; + } + if selected { + any_selected = true; + } + } + } + if any_selected && any_online { + TsHealth::Ok + } else if any_selected { + // The active exit node is one of ours but offline. + TsHealth::ExitNodeOffline + } else if any_online { + // Present + online but not selected — the flow will select it. + TsHealth::Error("exit node not selected".into()) + } else if any_exists { + TsHealth::ExitNodeOffline + } else { + TsHealth::ExitNodeMissing + } +} + +/// Bring Tailscale to a state where one of `nodes` (priority order) is the +/// active, online exit node. Performs at most one bring-up/login, then one +/// `tailscale set` per node until one takes. +pub fn ensure_exit_node(nodes: &[String]) -> TsHealth { if !installed() { return TsHealth::NotInstalled; } + let eff = effective_nodes(nodes); + if eff.is_empty() { + // Never run `tailscale set --exit-node=` with an empty node — that + // would clear the user's current exit-node selection. This is a + // config error, surfaced as its own health state. + return TsHealth::NoExitNode; + } let v = match status_json() { Some(v) => v, - None => return TsHealth::Error("could not read tailscale status".into()), + None => { + // Daemon unreachable — usually *not running*: a stopped daemon + // prints its error to stderr and leaves stdout empty, which is + // exactly why this branch used to be dead code (the + // BackendState "Stopped" case below only fires when the daemon + // is up but the backend is stopped). Try to bring it up before + // giving up; `tailscale up` is idempotent when already running + // and fails fast (no sudo prompt — stdin is /dev/null) when + // the caller lacks permission to manage the daemon. + let _ = run("tailscale", &["up"], Duration::from_secs(20)); + match status_json() { + Some(v2) => v2, + None => return TsHealth::Error("could not read tailscale status".into()), + } + } }; match backend_state(&v).as_str() { @@ -249,43 +335,45 @@ pub fn ensure_exit_node(node: &str) -> TsHealth { _ => {} } - // Select the exit node (idempotent). - let _ = run( - "tailscale", - &["set", &format!("--exit-node={node}")], - Duration::from_secs(10), - ); + // Failover: try each acceptable node in priority order until one is + // selected and online. + for node in &eff { + let _ = run( + "tailscale", + &["set", &format!("--exit-node={node}")], + Duration::from_secs(10), + ); + if let Some(v2) = status_json() { + if matches!(exit_node_health(std::slice::from_ref(node), &v2), TsHealth::Ok) { + return TsHealth::Ok; + } + } + } let v = match status_json() { Some(v) => v, None => return TsHealth::Error("could not re-read tailscale status".into()), }; - match backend_state(&v).as_str() { "Running" => {} "NeedsLogin" | "NoState" => return TsHealth::NeedsLogin, "Stopped" => return TsHealth::Stopped, other => return TsHealth::Error(format!("backend state: {other}")), } - - let (exists, online, selected) = exit_node_state(&v, node); - if !exists { - TsHealth::ExitNodeMissing - } else if !online { - TsHealth::ExitNodeOffline - } else if !selected { - // Online and present but our set didn't take — treat as missing/selectable error. - TsHealth::Error("exit node not selected".into()) - } else { - TsHealth::Ok - } + exit_node_health(&eff, &v) } /// Lightweight health check without trying to (re)configure anything. -pub fn check(node: &str) -> TsHealth { +pub fn check(nodes: &[String]) -> TsHealth { if !installed() { return TsHealth::NotInstalled; } + let eff = effective_nodes(nodes); + if eff.is_empty() { + // Read-only check, so never runs `tailscale set` — an empty node is + // a config error, not something this probe can fix. + return TsHealth::NoExitNode; + } let v = match status_json() { Some(v) => v, None => return TsHealth::Error("status unavailable".into()), @@ -296,16 +384,7 @@ pub fn check(node: &str) -> TsHealth { "Stopped" => return TsHealth::Stopped, other => return TsHealth::Error(format!("backend state: {other}")), } - let (exists, online, selected) = exit_node_state(&v, node); - if !exists { - TsHealth::ExitNodeMissing - } else if !online { - TsHealth::ExitNodeOffline - } else if !selected { - TsHealth::Error("exit node not selected".into()) - } else { - TsHealth::Ok - } + exit_node_health(&eff, &v) } #[cfg(test)] @@ -313,54 +392,6 @@ mod tests { use super::*; use serde_json::json; - #[test] - fn ts_health_is_ok_only_for_ok_variant() { - assert!(TsHealth::Ok.is_ok()); - assert!(!TsHealth::NotInstalled.is_ok()); - assert!(!TsHealth::NeedsLogin.is_ok()); - assert!(!TsHealth::Stopped.is_ok()); - assert!(!TsHealth::ExitNodeMissing.is_ok()); - assert!(!TsHealth::ExitNodeOffline.is_ok()); - assert!(!TsHealth::Error("x".into()).is_ok()); - } - - #[test] - fn ts_health_describe_covers_all_variants() { - assert_eq!(TsHealth::Ok.describe(), "ok"); - assert!(TsHealth::NotInstalled.describe().contains("not installed")); - assert!(TsHealth::NeedsLogin.describe().contains("not logged in")); - assert!(TsHealth::Stopped.describe().contains("stopped")); - assert!(TsHealth::ExitNodeMissing.describe().contains("not found")); - assert!(TsHealth::ExitNodeOffline.describe().contains("offline")); - let msg = TsHealth::Error("boom".into()).describe(); - assert!(msg.contains("boom")); - } - - #[test] - fn extract_url_finds_https_url() { - assert_eq!( - extract_url("To authenticate, visit https://login.tailscale.com/a/xxx"), - Some("https://login.tailscale.com/a/xxx".into()) - ); - } - - #[test] - fn extract_url_returns_none_when_no_url() { - assert_eq!(extract_url("Waiting for login..."), None); - assert_eq!(extract_url(""), None); - } - - #[test] - fn extract_url_picks_first_https_token() { - let line = "Try https://first.example.com https://second.example.com"; - assert_eq!(extract_url(line), Some("https://first.example.com".into())); - } - - #[test] - fn extract_url_does_not_match_plain_http() { - assert_eq!(extract_url("see http://example.com for info"), None); - } - #[test] fn backend_state_extraction() { assert_eq!( @@ -370,13 +401,6 @@ mod tests { assert_eq!(backend_state(&json!({})), ""); } - #[test] - fn backend_state_all_known_values() { - for state in ["Running", "NeedsLogin", "NoState", "Stopped"] { - assert_eq!(backend_state(&json!({"BackendState": state})), state); - } - } - #[test] fn exit_node_healthy_and_selected() { let v = json!({ @@ -438,57 +462,149 @@ mod tests { } #[test] - fn exit_node_state_empty_peer_map() { - let v = json!({ "BackendState": "Running", "Peer": {} }); - assert_eq!(exit_node_state(&v, "anynode"), (false, false, false)); - } - - #[test] - fn exit_node_state_no_peer_field() { - let v = json!({ "BackendState": "Running" }); - assert_eq!(exit_node_state(&v, "anynode"), (false, false, false)); - } - - #[test] - fn exit_node_state_case_insensitive_hostname() { + fn exit_node_lookup_is_case_insensitive() { let v = json!({ "Peer": { - "k1": { "HostName": "MYNODE", "DNSName": "mynode.ts.net.", + "k1": { "HostName": "ExitNode", "DNSName": "ExitNode.ts.net.", "Online": true, "ExitNode": true, "ExitNodeOption": true } } }); - let (exists, online, selected) = exit_node_state(&v, "mynode"); - assert!(exists && online && selected); + assert_eq!(exit_node_state(&v, "EXITNODE"), (true, true, true)); } #[test] - fn exit_node_status_overrides_peer_online_when_selected() { - // ExitNodeStatus.Online=false should override the peer's Online=true - // when the peer is the currently-selected exit node. + fn exit_node_state_with_no_peers_is_all_false() { + let v = json!({ "BackendState": "Running" }); + assert_eq!(exit_node_state(&v, "exitnode"), (false, false, false)); + } + + #[test] + fn exit_node_state_empty_node_name_matches_nothing() { let v = json!({ - "ExitNodeStatus": { "Online": false }, "Peer": { "k1": { "HostName": "exitnode", "DNSName": "exitnode.ts.net.", "Online": true, "ExitNode": true, "ExitNodeOption": true } } }); - let (exists, online, selected) = exit_node_state(&v, "exitnode"); - assert!(exists); - assert!( - !online, - "ExitNodeStatus.Online=false should override peer Online" - ); - assert!(selected); + assert_eq!(exit_node_state(&v, ""), (false, false, false)); } #[test] - fn exit_node_state_wrong_node_name_not_matched() { + fn exit_node_status_online_only_applies_when_selected() { + // ExitNodeStatus.Online reflects the currently *active* exit node — + // it must not leak into the reported state of a peer that merely + // matches by name but isn't the one actually selected. let v = json!({ + "ExitNodeStatus": { "Online": true }, "Peer": { - "k1": { "HostName": "othernode", "DNSName": "othernode.ts.net.", + "k1": { "HostName": "other", "DNSName": "other.ts.net.", + "Online": false, "ExitNode": false, "ExitNodeOption": true } + } + }); + assert_eq!(exit_node_state(&v, "other"), (true, false, false)); + } + + #[test] + fn ts_health_is_ok_only_for_ok_variant() { + assert!(TsHealth::Ok.is_ok()); + assert!(!TsHealth::NotInstalled.is_ok()); + assert!(!TsHealth::NeedsLogin.is_ok()); + assert!(!TsHealth::Stopped.is_ok()); + assert!(!TsHealth::ExitNodeMissing.is_ok()); + assert!(!TsHealth::ExitNodeOffline.is_ok()); + assert!(!TsHealth::NoExitNode.is_ok()); + assert!(!TsHealth::Error("x".into()).is_ok()); + } + + #[test] + fn ts_health_describe_is_human_readable() { + assert_eq!(TsHealth::Ok.describe(), "ok"); + assert_eq!( + TsHealth::NeedsLogin.describe(), + "not logged in (run: tailscale up)" + ); + assert_eq!(TsHealth::NoExitNode.describe(), "no exit node configured"); + assert_eq!(TsHealth::Error("boom".into()).describe(), "error: boom"); + } + + #[test] + fn extract_url_finds_https_token_among_others() { + assert_eq!( + extract_url("To authenticate, visit: https://login.tailscale.com/abc"), + Some("https://login.tailscale.com/abc".to_string()) + ); + assert_eq!(extract_url("no url on this line"), None); + } + + #[test] + fn effective_nodes_trims_and_drops_empties() { + assert_eq!( + effective_nodes(&[" a ".into(), "".into(), "b".into()]), + vec!["a".to_string(), "b".to_string()] + ); + } + + #[test] + fn exit_node_health_ok_when_any_node_selected_and_online() { + let v = json!({ + "BackendState": "Running", + "Peer": { + "k1": { "HostName": "nodeB", "DNSName": "nodeB.ts.net.", "Online": true, "ExitNode": true, "ExitNodeOption": true } } }); - assert_eq!(exit_node_state(&v, "exitnode"), (false, false, false)); + assert_eq!( + exit_node_health(&["nodeA".into(), "nodeB".into()], &v), + TsHealth::Ok + ); + } + + #[test] + fn exit_node_health_reports_not_selected_when_a_node_is_online_but_unselected() { + let v = json!({ + "Peer": { + "k1": { "HostName": "nodeA", "DNSName": "nodeA.ts.net.", + "Online": true, "ExitNode": false, "ExitNodeOption": true } + } + }); + assert_eq!( + exit_node_health(&["nodeA".into()], &v), + TsHealth::Error("exit node not selected".into()) + ); + } + + #[test] + fn exit_node_health_reports_offline_when_all_exist_but_none_online() { + let v = json!({ + "Peer": { + "k1": { "HostName": "nodeA", "DNSName": "nodeA.ts.net.", + "Online": false, "ExitNode": false, "ExitNodeOption": true } + } + }); + assert_eq!( + exit_node_health(&["nodeA".into()], &v), + TsHealth::ExitNodeOffline + ); + } + + #[test] + fn exit_node_health_reports_missing_when_no_node_present() { + let v = json!({ "BackendState": "Running" }); + assert_eq!( + exit_node_health(&["nodeA".into()], &v), + TsHealth::ExitNodeMissing + ); + } + + #[test] + fn state_str_is_the_variant_name() { + assert_eq!(TsHealth::Ok.state_str(), "Ok"); + assert_eq!(TsHealth::NotInstalled.state_str(), "NotInstalled"); + assert_eq!(TsHealth::NeedsLogin.state_str(), "NeedsLogin"); + assert_eq!(TsHealth::Stopped.state_str(), "Stopped"); + assert_eq!(TsHealth::ExitNodeMissing.state_str(), "ExitNodeMissing"); + assert_eq!(TsHealth::ExitNodeOffline.state_str(), "ExitNodeOffline"); + assert_eq!(TsHealth::NoExitNode.state_str(), "NoExitNode"); + assert_eq!(TsHealth::Error("x".into()).state_str(), "Error"); } } diff --git a/src/util.rs b/src/util.rs index db9bc8d..3e8fced 100644 --- a/src/util.rs +++ b/src/util.rs @@ -1,6 +1,6 @@ -use std::fs; +use std::cell::RefCell; use std::io::{Read, Write}; -use std::path::{Path, PathBuf}; +use std::path::PathBuf; use std::process::{Command, Stdio}; use std::thread; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; @@ -11,53 +11,6 @@ pub fn home_dir() -> PathBuf { .unwrap_or_else(|| PathBuf::from("/root")) } -/// Atomically replace `path` with `contents`: write a sibling temp file (created -/// with `mode` on unix) and `rename` it over the target. Avoids torn reads by a -/// concurrent reader (the watch daemon reloads config every tick) and never -/// leaves a half-written file behind on crash. Because the temp file is created -/// with `mode` up front, secrets never exist world-readable even briefly. -pub fn write_atomic(path: &Path, contents: &str, mode: u32) -> std::io::Result<()> { - let dir = path.parent().unwrap_or_else(|| Path::new(".")); - fs::create_dir_all(dir)?; - let stem = path - .file_name() - .and_then(|s| s.to_str()) - .unwrap_or("breadcrumbs"); - let tmp = dir.join(format!(".{stem}.tmp.{}", std::process::id())); - - let mut open = fs::OpenOptions::new(); - open.write(true).create(true).truncate(true); - #[cfg(unix)] - { - use std::os::unix::fs::OpenOptionsExt; - open.mode(mode); - } - #[cfg(not(unix))] - let _ = mode; - - let res = (|| { - let mut f = open.open(&tmp)?; - f.write_all(contents.as_bytes())?; - f.sync_all()?; - fs::rename(&tmp, path) - })(); - if res.is_err() { - let _ = fs::remove_file(&tmp); - } - res -} - -pub fn command_exists(name: &str) -> bool { - if let Some(paths) = std::env::var_os("PATH") { - for dir in std::env::split_paths(&paths) { - if dir.join(name).is_file() { - return true; - } - } - } - false -} - #[derive(Debug, Clone)] pub struct Output { pub success: bool, @@ -75,16 +28,101 @@ impl Output { } } +/// Everything breadcrumbs does to touch the outside world *other than* its +/// own file I/O and env var reads: spawning an external program, and +/// checking whether one is available at all. Every call site in this crate +/// (`nm.rs`, `tailscale.rs`, `status.rs`, `notify.rs`, `app.rs`) goes through +/// the free functions below (`run`/`run_with_stdin`/`run_ok`/ +/// `command_exists`), which are thin wrappers dispatching to whatever +/// `Runner` is currently installed in the thread-local slot — `RealRunner` by +/// default. +/// +/// Tests swap in a fake implementation via [`with_runner`] so real call +/// chains (`flow::run`, `watch::classify`, …) can be driven in-process +/// against canned output, with no subprocess ever spawned and full +/// visibility into exactly what *would* have been executed — the natural +/// mechanism for asserting things like "no password ever reaches nmcli's +/// argv on a repeat connect" (see the credential tests under `tests/`). +/// +/// A thread-local (rather than an explicit parameter threaded through every +/// function) was chosen so the large existing call surface in `nm.rs` et al. +/// didn't need every signature rewritten to carry a `&dyn Runner` — call +/// sites are unchanged, only `util`'s internals dispatch differently. It's +/// safe across `cargo test`'s parallel test threads because each thread gets +/// its own independent slot, defaulting to `RealRunner`, so tests that don't +/// install a fake are unaffected by ones that do. +pub trait Runner { + fn run(&self, prog: &str, args: &[&str], stdin: Option<&str>, timeout: Duration) -> Output; + fn command_exists(&self, name: &str) -> bool; +} + +struct RealRunner; + +impl Runner for RealRunner { + fn run(&self, prog: &str, args: &[&str], stdin: Option<&str>, timeout: Duration) -> Output { + spawn_run(prog, args, stdin, timeout) + } + + fn command_exists(&self, name: &str) -> bool { + path_lookup_exists(name) + } +} + +fn path_lookup_exists(name: &str) -> bool { + if let Some(paths) = std::env::var_os("PATH") { + for dir in std::env::split_paths(&paths) { + if dir.join(name).is_file() { + return true; + } + } + } + false +} + +thread_local! { + static RUNNER: RefCell> = RefCell::new(Box::new(RealRunner)); +} + +pub fn command_exists(name: &str) -> bool { + RUNNER.with(|r| r.borrow().command_exists(name)) +} + /// Run a command with a hard timeout. The child is killed if it overruns so a -/// hung nmcli/tailscale can never wedge the daemon. +/// hung subprocess can never wedge the daemon. pub fn run(prog: &str, args: &[&str], timeout: Duration) -> Output { run_with_stdin(prog, args, None, timeout) } -/// Like [`run`], but feeds `stdin` to the child's standard input. Used to hand -/// secrets (e.g. Wi-Fi PSKs) to `nmcli --ask` without exposing them in argv, -/// where any local user could read them via `ps`. +/// Like [`run`], but feeds `stdin` to the child's standard input. +/// (Wi-Fi secrets no longer go through here: `nm` sends them inside D-Bus +/// payloads, never on a command line.) pub fn run_with_stdin(prog: &str, args: &[&str], stdin: Option<&str>, timeout: Duration) -> Output { + RUNNER.with(|r| r.borrow().run(prog, args, stdin, timeout)) +} + +pub fn run_ok(prog: &str, args: &[&str], timeout: Duration) -> bool { + run(prog, args, timeout).success +} + +/// Swap the thread-local [`Runner`] for `runner` for the duration of `f`, +/// restoring whatever was previously installed afterward — even if `f` +/// panics, so a failing assertion inside a test can't leak a fake runner +/// into whatever test happens to run next on this thread. This is the seam +/// integration tests use to drive real logic without spawning subprocesses. +pub fn with_runner(runner: R, f: impl FnOnce() -> T) -> T +where + R: Runner + 'static, +{ + let prev = RUNNER.with(|r| std::mem::replace(&mut *r.borrow_mut(), Box::new(runner))); + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(f)); + RUNNER.with(|r| *r.borrow_mut() = prev); + match result { + Ok(v) => v, + Err(payload) => std::panic::resume_unwind(payload), + } +} + +fn spawn_run(prog: &str, args: &[&str], stdin: Option<&str>, timeout: Duration) -> Output { let stdin_cfg = if stdin.is_some() { Stdio::piped() } else { @@ -92,11 +130,6 @@ pub fn run_with_stdin(prog: &str, args: &[&str], stdin: Option<&str>, timeout: D }; let mut child = match Command::new(prog) .args(args) - // Pin the C locale so message text we parse (nmcli states, monitor - // lines) is stable English regardless of the user's LANG. SSID/value - // bytes are unaffected. - .env("LC_ALL", "C") - .env("LANG", "C") .stdin(stdin_cfg) .stdout(Stdio::piped()) .stderr(Stdio::piped()) @@ -124,9 +157,9 @@ pub fn run_with_stdin(prog: &str, args: &[&str], stdin: Option<&str>, timeout: D buf }); - // Feed stdin only after the reader threads are draining stdout/stderr, so a - // child that writes more than a pipe buffer before consuming stdin can't - // deadlock against our blocking write. + // Feed stdin only now that the reader threads are draining stdout and + // stderr: a chatty child could otherwise fill its stdout pipe while we + // block writing stdin, deadlocking both sides. if let Some(data) = stdin { if let Some(mut sink) = child.stdin.take() { let _ = sink.write_all(data.as_bytes()); @@ -160,8 +193,17 @@ pub fn run_with_stdin(prog: &str, args: &[&str], stdin: Option<&str>, timeout: D } } -pub fn run_ok(prog: &str, args: &[&str], timeout: Duration) -> bool { - run(prog, args, timeout).success +/// Current local "HH:MM" (24h), for the time-of-day schedule. `None` if the +/// clock can't be read — the schedule is skipped, never guessed. +pub fn local_hhmm() -> Option { + let o = run("date", &["+%H:%M"], Duration::from_secs(2)); + if o.success { + let t = o.stdout.trim().to_string(); + if t.len() == 5 && t.as_bytes()[2] == b':' { + return Some(t); + } + } + None } /// Local "YYYY-MM-DD HH:MM:SS". Uses `date` for correct local time, falling @@ -223,56 +265,39 @@ mod tests { } #[test] - fn fmt_epoch_year_2000_century_divisible_by_400_leap() { - // 2000-01-01 00:00:00 UTC — divisible by 400, so it IS a leap year. - assert_eq!(fmt_epoch(946_684_800), "2000-01-01 00:00:00"); - } - - #[test] - fn fmt_epoch_end_of_year_boundary() { - // 2023-12-31 23:59:59 UTC - assert_eq!(fmt_epoch(1_704_067_199), "2023-12-31 23:59:59"); - } - - #[test] - fn fmt_epoch_negative_before_unix_epoch() { - // 1969-12-31 23:59:59 UTC - assert_eq!(fmt_epoch(-1), "1969-12-31 23:59:59"); - // 1969-12-31 00:00:00 UTC + fn fmt_epoch_pre_1970_is_handled() { + // The div_euclid/rem_euclid split must stay correct for negative + // epoch seconds (dates before 1970), not just the common positive case. assert_eq!(fmt_epoch(-86_400), "1969-12-31 00:00:00"); } #[test] - fn fmt_epoch_february_non_leap_year_boundary() { - // 2023-02-28 00:00:00 UTC (2023 is not a leap year) - assert_eq!(fmt_epoch(1_677_542_400), "2023-02-28 00:00:00"); - // 2023-03-01 00:00:00 UTC — next day after Feb 28 in non-leap year - assert_eq!(fmt_epoch(1_677_628_800), "2023-03-01 00:00:00"); + fn fmt_epoch_year_and_month_boundaries() { + assert_eq!(fmt_epoch(1_704_067_199), "2023-12-31 23:59:59"); + assert_eq!(fmt_epoch(1_735_689_600), "2025-01-01 00:00:00"); + // Last second of October (non-leap-day month boundary). + assert_eq!(fmt_epoch(1_730_419_199), "2024-10-31 23:59:59"); } #[test] - fn fmt_epoch_century_non_leap_year_1900_equivalent() { - // 1900 is NOT a leap year (div by 100 but not 400). - // 1900-03-01 00:00:00 UTC: days from epoch = (1900-1970)*365.25 ≈ use known anchor. - // 2100-02-28 00:00:00 UTC = epoch 4107456000; next day is Mar 1 (not Feb 29). - // We verify via the leap day boundary: 2100-02-28 + 86400 must be 2100-03-01. - assert_eq!(fmt_epoch(4_107_456_000), "2100-02-28 00:00:00"); - assert_eq!(fmt_epoch(4_107_456_000 + 86_400), "2100-03-01 00:00:00"); + fn command_exists_false_for_bogus_binary() { + assert!(!command_exists("definitely-not-a-real-binary-xyz123")); } #[test] - fn fmt_epoch_midnight_vs_end_of_day() { - // 2022-06-15 00:00:00 UTC - assert_eq!(fmt_epoch(1_655_251_200), "2022-06-15 00:00:00"); - // 2022-06-15 23:59:59 UTC - assert_eq!(fmt_epoch(1_655_337_599), "2022-06-15 23:59:59"); + fn command_exists_true_for_a_real_binary() { + // `sh` is guaranteed present on any POSIX system this runs on. + assert!(command_exists("sh")); } #[test] - fn fmt_epoch_time_of_day_components() { - // 1970-01-01 01:02:03 UTC - assert_eq!(fmt_epoch(3723), "1970-01-01 01:02:03"); - // 1970-01-01 23:59:59 UTC - assert_eq!(fmt_epoch(86_399), "1970-01-01 23:59:59"); + fn run_on_missing_binary_fails_cleanly_instead_of_panicking() { + let o = run( + "definitely-not-a-real-binary-xyz123", + &[], + Duration::from_secs(1), + ); + assert!(!o.success); + assert_eq!(o.stdout, ""); } } diff --git a/src/watch.rs b/src/watch.rs index 30f2360..6da41b7 100644 --- a/src/watch.rs +++ b/src/watch.rs @@ -1,98 +1,266 @@ -use std::io::{BufRead, BufReader}; -use std::process::{Command, Stdio}; +use std::collections::HashMap; use std::sync::mpsc::{self, Receiver}; use std::thread; use std::time::{Duration, Instant}; -use crate::backend::{Backend, System}; +use bread_utils::bread_client::BreadClient; +use zbus::blocking::{Connection, Proxy}; +use zbus::zvariant::OwnedValue; + +use crate::bread_events; use crate::config::Config; use crate::flow; +use crate::nm; use crate::notify::{log, notify, Urgency}; -use crate::state::State; +use crate::state::{self, State}; use crate::status::{self}; use crate::tailscale::TsHealth; +const NM_DEST: &str = "org.freedesktop.NetworkManager"; +const NM_PATH: &str = "/org/freedesktop/NetworkManager"; +const NM_IFACE: &str = "org.freedesktop.NetworkManager"; +const DEV_IFACE: &str = "org.freedesktop.NetworkManager.Device"; +const PROPS_IFACE: &str = "org.freedesktop.DBus.Properties"; + +/// Coarse health classification the watch loop reacts to each tick. `pub` +/// (and so is [`classify`]) purely so integration tests can drive the real +/// classification logic in-process against a faked [`crate::util::Runner`], +/// instead of only being able to observe it indirectly through the watch +/// loop's side effects. #[derive(PartialEq, Eq, Clone, Debug)] -enum Health { +pub enum Health { Up, DownNoNet, - /// Associated, but a captive portal is intercepting traffic (manual login). + /// Traffic is being intercepted — a captive/guest portal answered the + /// connectivity check with 200/301/302 instead of 204. Not something + /// reconnecting fixes; the user must sign in. CaptivePortal, DownTailscaleManual, DownTailscaleOther, NoAdapter, + /// `profile` isn't defined in the config (e.g. state still points at a + /// custom profile the user deleted from breadcrumbs.toml). + UnknownProfile, } -fn classify(be: &dyn Backend, cfg: &Config, profile: &str) -> (Health, status::Status) { - let s = status::gather(be, cfg, profile); - let health = if s.iface.is_none() { - Health::NoAdapter - } else if s.portal.is_some() { - Health::CaptivePortal - } else if !s.internet { - Health::DownNoNet +impl Health { + /// Wire name used in `bread.crumbs.health.changed` — the Rust variant + /// as a string, not a prettier label. + pub fn as_str(&self) -> &'static str { + match self { + Health::Up => "Up", + Health::DownNoNet => "DownNoNet", + Health::CaptivePortal => "CaptivePortal", + Health::DownTailscaleManual => "DownTailscaleManual", + Health::DownTailscaleOther => "DownTailscaleOther", + Health::NoAdapter => "NoAdapter", + Health::UnknownProfile => "UnknownProfile", + } + } +} + +/// Everything the watch loop needs to know about one health observation: +/// the classification plus the context used for events and notifications. +#[derive(Debug, Clone)] +pub struct Classification { + pub health: Health, + pub ssid: Option, + pub iface: Option, + pub ip: Option, + pub tailscale: Option, + pub exit_node: String, +} + +pub fn classify(cfg: &Config, profile: &str) -> Classification { + // Checked before gather(): a profile missing from config would otherwise + // silently fall back to "tailscale not required" and read as healthy off + // of nothing but a bare internet check, never surfacing the misconfig. + if cfg.profile(profile).is_none() { + return Classification { + health: Health::UnknownProfile, + ssid: None, + iface: None, + ip: None, + tailscale: None, + exit_node: String::new(), + }; + } + let s = status::gather(cfg, profile); + if s.iface.is_none() { + return Classification { + health: Health::NoAdapter, + ssid: None, + iface: None, + ip: None, + tailscale: None, + exit_node: s.exit_node, + }; + } + let ssid = s.ssid.clone(); + let health = if !s.internet { + if s.portal { + Health::CaptivePortal + } else { + Health::DownNoNet + } } else if s.tailscale_required { match s.tailscale { Some(TsHealth::Ok) => Health::Up, - Some(TsHealth::NeedsLogin) | Some(TsHealth::NotInstalled) => { - Health::DownTailscaleManual - } + // NeedsLogin / NotInstalled / NoExitNode all need human action: + // a missing exit-node config can't be auto-fixed either. + Some(TsHealth::NeedsLogin) + | Some(TsHealth::NotInstalled) + | Some(TsHealth::NoExitNode) => Health::DownTailscaleManual, Some(_) => Health::DownTailscaleOther, None => Health::DownTailscaleManual, } } else { Health::Up }; - (health, s) + Classification { + health, + ssid, + iface: s.iface, + ip: s.ip, + tailscale: s.tailscale, + exit_node: s.exit_node, + } } -/// Tail `nmcli monitor` and ping the channel on link-state churn so we react -/// to drops within a second instead of waiting out the poll interval. -fn spawn_nm_monitor(tx: mpsc::Sender<()>) { +/// Whether a debounced signal is allowed to fire. `None` (never fired) always +/// fires; otherwise it fires only once more than `gap` has elapsed since the +/// last fire. Pulled out as a pure helper so the debounce logic is testable and +/// so the "first event fires immediately" case is expressed without the +/// panic-prone `Instant::now() - gap` seed. +fn debounce_ready(last: Option, gap: Duration) -> bool { + last.map(|t| t.elapsed() > gap).unwrap_or(true) +} + +/// Whether the flow-recovery cooldown has elapsed since the last `flow::run` +/// (or one never ran). Pure so the recovery pacing is unit-testable. +fn recovery_due(last_flow_at: Option, now: Instant, cooldown_secs: u64) -> bool { + last_flow_at + .map(|t| now.duration_since(t).as_secs()) + .unwrap_or(u64::MAX) + >= cooldown_secs +} + +/// A wake signal for the watch loop. `SetProfile` is an *action* (applied on +/// the loop thread), `LinkChurn` is just "go look" — the distinction keeps +/// every config/state file access on the single loop thread, so the bread +/// subscription thread can never race the loop's own `Config::load`/`save`. +enum Wake { + LinkChurn, + SetProfile(String), +} + +/// Whether a `PropertiesChanged` message on the NM root changes the +/// `Connectivity` property of the NetworkManager interface — the signal +/// that catches "still connected but lost the internet" (portal, DHCP +/// failure) without waiting out the poll interval. +fn props_changed_connectivity(msg: &zbus::Message) -> bool { + let Ok(body) = msg.body().deserialize::<(String, HashMap, Vec)>() else { + return false; + }; + body.0 == NM_IFACE && body.1.contains_key("Connectivity") +} + +/// Subscribe to a D-Bus signal from NetworkManager and ping the channel for +/// each matching message, reconnecting on bus/NM restarts. One thread per +/// subscription (a handful at most); each owns its own connection so a dead +/// bus can't wedge the others. +fn spawn_signal_watcher(tx: mpsc::Sender, path: String, iface: &'static str, signal: &'static str, mut on_msg: F) +where + F: FnMut(&zbus::Message) -> bool + Send + 'static, +{ thread::spawn(move || loop { - let child = Command::new("nmcli") - .arg("monitor") - .stdin(Stdio::null()) - .stdout(Stdio::piped()) - .stderr(Stdio::null()) - .spawn(); - let mut child = match child { - Ok(c) => c, - Err(_) => { - thread::sleep(Duration::from_secs(10)); - continue; - } + let Ok(conn) = Connection::system() else { + thread::sleep(Duration::from_secs(10)); + continue; }; - if let Some(out) = child.stdout.take() { - let reader = BufReader::new(out); - let mut last = Instant::now() - Duration::from_secs(10); - for line in reader.lines().map_while(Result::ok) { - let l = line.to_lowercase(); - let interesting = - l.contains("disconnect") || l.contains("unavailable") || l.contains("failed"); - if interesting && last.elapsed() > Duration::from_millis(1500) { - last = Instant::now(); - let _ = tx.send(()); - } + let Ok(proxy) = Proxy::new(&conn, NM_DEST, path.as_str(), iface) else { + thread::sleep(Duration::from_secs(10)); + continue; + }; + let Ok(mut iter) = proxy.receive_signal(signal) else { + thread::sleep(Duration::from_secs(10)); + continue; + }; + // `None` means "haven't fired yet, so fire on the first interesting + // signal". Storing an `Option` instead of seeding with + // `Instant::now() - 10s` avoids a panic: `Instant - Duration` + // underflows (and panics) when the monotonic clock is younger than + // the offset, which happens if `watch` starts within ~10s of boot — + // exactly when the systemd unit (ordered after graphical-session) + // tends to launch. + let mut last: Option = None; + for msg in iter.by_ref() { + if on_msg(&msg) && debounce_ready(last, Duration::from_millis(1500)) { + last = Some(Instant::now()); + let _ = tx.send(Wake::LinkChurn); } } - let _ = child.wait(); - // monitor died (NM restart?) — back off and respawn. + // Subscription died (NM or bus restart) — back off and resubscribe. thread::sleep(Duration::from_secs(5)); }); } -/// Sleep up to `dur`, but wake early if `nmcli monitor` signals link churn. -fn wait_for_tick(rx: &Receiver<()>, dur: Duration) { +/// Subscribe to NetworkManager D-Bus signals and ping the channel on +/// link-state churn so we react to drops within a second instead of waiting +/// out the poll interval. Replaces the old `nmcli monitor` subprocess: the +/// same events are observed, but as structured D-Bus signals. +/// +/// Watched signals: +/// - `PropertiesChanged` on the NM root object, filtered to the +/// `Connectivity` property — catches captive portals / DHCP failures that +/// keep the device "connected" while losing the internet; +/// - `DeviceAdded` / `DeviceRemoved` — hotplug; +/// - `Device.StateChanged` on every Wi-Fi device — drops and reconnects. +fn spawn_nm_monitor(tx: mpsc::Sender) { + spawn_signal_watcher( + tx.clone(), + NM_PATH.to_string(), + PROPS_IFACE, + "PropertiesChanged", + props_changed_connectivity, + ); + spawn_signal_watcher(tx.clone(), NM_PATH.to_string(), NM_IFACE, "DeviceAdded", |_| true); + spawn_signal_watcher(tx.clone(), NM_PATH.to_string(), NM_IFACE, "DeviceRemoved", |_| true); + // StateChanged on each Wi-Fi device. Devices added later (USB dongle + // hotplug) are caught by the DeviceAdded watcher waking the loop; the + // poll interval covers anything else. + for path in nm::wifi_device_paths() { + spawn_signal_watcher(tx.clone(), path, DEV_IFACE, "StateChanged", |_| true); + } +} + +/// Sleep up to `dur`, but wake early if the D-Bus signal monitor signals +/// link churn or a `set_profile` command arrives. Returns the pending +/// action, if any. +fn wait_for_tick(rx: &Receiver, dur: Duration) -> Option { match rx.recv_timeout(dur) { - Ok(()) => { - // Drain any burst of events so we don't re-fire immediately. - while rx.try_recv().is_ok() {} + Ok(first) => { + // Drain any burst of churn signals so we don't re-fire + // immediately, but never drop a queued set_profile — it's an + // action, not a signal, and the earliest one wins. + let mut pending = match &first { + Wake::SetProfile(_) => Some(first), + Wake::LinkChurn => None, + }; + while let Ok(w) = rx.try_recv() { + if pending.is_none() && matches!(&w, Wake::SetProfile(_)) { + pending = Some(w); + } + } + pending } - Err(mpsc::RecvTimeoutError::Timeout) => {} + Err(mpsc::RecvTimeoutError::Timeout) => None, // Monitor thread gone (shouldn't happen: we hold the sender) — fall // back to a plain sleep so we don't busy-spin. - Err(mpsc::RecvTimeoutError::Disconnected) => thread::sleep(dur), + Err(mpsc::RecvTimeoutError::Disconnected) => { + thread::sleep(dur); + None + } } } @@ -105,37 +273,112 @@ pub fn run(mut cfg: Config, run_initial: bool) -> i32 { ); log("watch: started"); - let (tx, rx) = mpsc::channel::<()>(); - spawn_nm_monitor(tx); + let (tx, rx) = mpsc::channel::(); + spawn_nm_monitor(tx.clone()); + + // Long-lived, so this uses BreadClient::subscribe (a persistent + // background thread with its own reconnect/backoff loop). breadd being + // absent or restarting is transparent: the subscription just quietly + // stops delivering commands until it reconnects. The callback only + // *validates* the command and forwards an action through the channel — + // it never touches config/state files itself (that would race this + // loop's own Config::load/save), so all file access stays on this one + // thread. + let bread = BreadClient::connect(bread_events::APP_ID); + let wake = tx; + let _commands = bread.subscribe("bread.command.crumbs.**", move |event| { + match bread_events::handle_command(&event) { + bread_events::CommandAction::SetProfile(name) => { + let _ = wake.send(Wake::SetProfile(name)); + } + bread_events::CommandAction::Ignore => {} + } + }); - let be = System; let mut profile = State::load(&cfg.settings.default_profile).profile; if run_initial { // Don't churn an already-working connection on (re)start. - let (h, _) = classify(&be, &cfg, &profile); - if h == Health::Up { + let class = classify(&cfg, &profile); + if class.health == Health::Up { log(&format!( "watch: already healthy on start (profile={profile}); skipping initial flow" )); } else { log(&format!("watch: initial flow for profile={profile}")); - let _ = flow::run(&be, &cfg, &profile); + let _ = flow::run_quiet(&mut cfg, &profile); } } let mut prev_health: Option = None; let mut prev_profile = profile.clone(); + let mut prev_ssid: Option = None; + let mut prev_ts: Option<&'static str> = None; let mut fail_streak: u32 = 0; let mut last_flow_at: Option = None; const FLOW_COOLDOWN: u64 = 20; + const RESUME_SLACK: Duration = Duration::from_secs(60); + const SCHEDULE_GRACE: Duration = Duration::from_secs(30 * 60); + let mut prev_wait = Duration::from_secs(base); + let mut last_tick_at = Instant::now(); + // Tracks what the *schedule* last applied, so the loop can tell a + // manual `profile set` (CLI or bus) apart from its own switch and give + // manual changes a grace window before the schedule overrides them. + let mut last_schedule_applied: Option = Some(profile.clone()); + let mut manual_set_at: Option = None; loop { // Reload config + state so edits and `profile set` take effect live. + // This always runs *before* `flow::run` below (never after, within + // the same tick), so a password `flow::run` clears-and-saves this + // iteration is durably on disk by the time the *next* iteration's + // reload runs. All config/state file access happens on this loop + // thread: `set_profile` commands from the bread bus are queued as + // [`Wake::SetProfile`] and applied here (see the bottom of the + // loop), never on the subscription thread. if let Ok(fresh) = Config::load() { cfg = fresh; } profile = State::load(&cfg.settings.default_profile).profile; + // Suspend/resume: the D-Bus signal monitor sees nothing while the + // machine sleeps, so a large wall-clock gap means the network state + // may have changed underneath us — allow an immediate recovery run + // instead of waiting out any remaining flow cooldown. + if last_tick_at.elapsed() > prev_wait + RESUME_SLACK { + log("watch: large gap since last tick (suspend/resume?) — forcing recovery check"); + last_flow_at = None; + } + + // Time-of-day schedule: switch to the scheduled profile when its + // window is active, unless the user manually set the profile within + // the grace window. + if last_schedule_applied.as_deref() != Some(profile.as_str()) { + // The persisted profile changed and it wasn't our own schedule + // switch — a manual set (CLI or bus). Start the grace window. + if last_schedule_applied.is_some() { + manual_set_at = Some(Instant::now()); + } + last_schedule_applied = Some(profile.clone()); + } + if let Some(sched) = scheduled_profile_now(&cfg) { + if sched != profile { + let grace_ok = manual_set_at + .map(|t| t.elapsed() >= SCHEDULE_GRACE) + .unwrap_or(true); + if grace_ok { + if state::set_profile(&cfg, &sched).is_ok() { + log(&format!("watch: schedule applied profile {sched}")); + last_schedule_applied = Some(sched.clone()); + manual_set_at = None; + } + } else { + log(&format!( + "watch: schedule would switch to {sched}, but a manual set is still in grace" + )); + } + } + } + let profile_changed = profile != prev_profile; if profile_changed { log(&format!( @@ -146,14 +389,46 @@ pub fn run(mut cfg: Config, run_initial: bool) -> i32 { &format!("{prev_profile} -> {profile}"), Urgency::Low, ); + bread_events::emit_profile_changed(&bread, &prev_profile, &profile); prev_profile = profile.clone(); prev_health = None; // force re-evaluation/recovery for new profile + prev_ssid = None; // a profile switch is a fresh network context + prev_ts = None; last_flow_at = None; // allow immediate recovery on profile change } - let (health, s) = classify(&be, &cfg, &profile); - let ssid = s.ssid.clone(); + let class = classify(&cfg, &profile); + let health = class.health.clone(); + let ssid = class.ssid.clone(); let transition = prev_health.as_ref() != Some(&health); + if transition { + bread_events::emit_health_changed( + &bread, + bread_events::HealthChanged { + profile: &profile, + health: health.as_str(), + ssid: ssid.as_deref(), + iface: class.iface.as_deref(), + ip: class.ip.as_deref(), + exit_node: &class.exit_node, + tailscale: class.tailscale.as_ref().map(|t| t.state_str()), + }, + ); + } + if class.ssid != prev_ssid { + bread_events::emit_network_changed( + &bread, + prev_ssid.as_deref(), + class.ssid.as_deref(), + &profile, + ); + prev_ssid = class.ssid.clone(); + } + let ts_state = class.tailscale.as_ref().map(|t| t.state_str()); + if ts_state != prev_ts { + bread_events::emit_tailscale_changed(&bread, &profile, ts_state, &class.exit_node); + prev_ts = ts_state; + } match &health { Health::Up => { @@ -169,18 +444,6 @@ pub fn run(mut cfg: Config, run_initial: bool) -> i32 { } fail_streak = 0; } - Health::CaptivePortal => { - // Associated but gated behind a sign-in page we can't automate; - // notify once and don't hammer flow (reconnecting won't help). - if transition { - let body = match s.portal.as_deref().filter(|u| !u.is_empty()) { - Some(url) => format!("Sign in to continue: {url}"), - None => format!("Sign in to continue ({profile})."), - }; - notify("breadcrumbs: captive portal", &body, Urgency::Normal); - } - fail_streak = 0; - } Health::NoAdapter => { if transition { notify( @@ -191,23 +454,57 @@ pub fn run(mut cfg: Config, run_initial: bool) -> i32 { } fail_streak = fail_streak.saturating_add(1); } - Health::DownTailscaleManual => { - // Can't be auto-fixed (login / not installed). Notify once. - if transition { + Health::UnknownProfile => { + // flow::run() is quiet from here, so surface the misconfig + // ourselves — once per transition/change, not every tick. + if transition || profile_changed { notify( - "Tailscale Error", - "Tailscale needs manual attention (login / install). \ - Other Wi-Fi automation paused until resolved.", + "breadcrumbs: unknown profile", + &format!("'{profile}' is not defined in breadcrumbs.toml"), Urgency::Critical, ); } - // Re-run flow only on transition so we land on the bootstrap net. - if transition || profile_changed { - let _ = flow::run(&be, &cfg, &profile); - last_flow_at = Some(Instant::now()); - } fail_streak = fail_streak.saturating_add(1); } + Health::CaptivePortal => { + if transition { + notify( + "breadcrumbs: captive portal detected", + "Traffic is being intercepted — open a browser and sign in.", + Urgency::Normal, + ); + } + // Reconnecting won't fix a portal; keep the poll fast (don't + // count it as a failure) so a successful sign-in is noticed + // promptly and the state flips back to Up. + fail_streak = 0; + } + Health::DownTailscaleManual => { + // Can't be auto-fixed (login / install / exit-node config). + // Notify once per transition. + if transition { + notify( + "Tailscale Error", + "Tailscale needs manual attention (login / install / \ + exit node config). Other Wi-Fi automation paused \ + until resolved.", + Urgency::Critical, + ); + } + // Re-attempt periodically and on the transition into this + // state: login may have completed since the last attempt, or + // the user may have missed the browser window. Quiet — a + // still-broken state must not re-notify on every retry. + if recovery_due(last_flow_at, Instant::now(), FLOW_COOLDOWN) { + let outcome = flow::run_quiet(&mut cfg, &profile); + last_flow_at = Some(Instant::now()); + fail_streak = if outcome.ok() { + 0 + } else { + fail_streak.saturating_add(1) + }; + } + } Health::DownNoNet | Health::DownTailscaleOther => { if transition { notify( @@ -216,15 +513,12 @@ pub fn run(mut cfg: Config, run_initial: bool) -> i32 { Urgency::Normal, ); } - let elapsed = last_flow_at - .map(|t| t.elapsed().as_secs()) - .unwrap_or(u64::MAX); - if elapsed >= FLOW_COOLDOWN { + if recovery_due(last_flow_at, Instant::now(), FLOW_COOLDOWN) { log(&format!( "watch: down ({:?}) profile={profile} ssid={:?} — running flow", health, ssid )); - let outcome = flow::run(&be, &cfg, &profile); + let outcome = flow::run_quiet(&mut cfg, &profile); log(&format!("watch: recovery outcome = {:?}", outcome)); last_flow_at = Some(Instant::now()); fail_streak = if outcome.ok() { @@ -234,7 +528,7 @@ pub fn run(mut cfg: Config, run_initial: bool) -> i32 { }; } else { log(&format!( - "watch: down ({:?}) — cooldown ({elapsed}s/{FLOW_COOLDOWN}s), skipping flow", + "watch: down ({:?}) — cooldown, skipping flow", health )); } @@ -246,6 +540,101 @@ pub fn run(mut cfg: Config, run_initial: bool) -> i32 { // Adaptive backoff: healthy -> base; failing -> grow up to ~6x. let mult = 1 + fail_streak.min(5); let dur = Duration::from_secs(base * mult as u64); - wait_for_tick(&rx, dur); + prev_wait = dur; + last_tick_at = Instant::now(); + // Apply a queued set_profile on this thread — the single owner of + // config/state file access — and emit the confirmation. The next + // iteration's reload sees the new profile and recovers accordingly. + if let Some(Wake::SetProfile(name)) = wait_for_tick(&rx, dur) { + bread_events::apply_set_profile(&name); + } + } +} + +/// The profile a time-of-day schedule wants right now, if any. Returns +/// `None` when no schedule is configured or the local time can't be read. +fn scheduled_profile_now(cfg: &Config) -> Option { + let hhmm = crate::util::local_hhmm()?; + let mins = crate::config::hhmm_to_minutes(&hhmm)?; + cfg.settings.scheduled_profile(mins) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn debounce_fires_immediately_when_never_fired() { + // Regression guard for the old `Instant::now() - Duration::from_secs(10)` + // seed, which panicked near boot. `None` must fire without any + // subtraction on the clock. + assert!(debounce_ready(None, Duration::from_millis(1500))); + } + + #[test] + fn debounce_suppresses_immediately_after_firing() { + let just_now = Instant::now(); + assert!(!debounce_ready(Some(just_now), Duration::from_secs(3600))); + } + + #[test] + fn debounce_fires_again_after_gap_elapses() { + // A zero gap is always already-elapsed, so a prior fire doesn't block. + let earlier = Instant::now(); + assert!(debounce_ready(Some(earlier), Duration::from_millis(0))); + } + + #[test] + fn health_as_str_is_the_variant_name() { + assert_eq!(Health::Up.as_str(), "Up"); + assert_eq!(Health::DownNoNet.as_str(), "DownNoNet"); + assert_eq!(Health::DownTailscaleManual.as_str(), "DownTailscaleManual"); + assert_eq!(Health::DownTailscaleOther.as_str(), "DownTailscaleOther"); + assert_eq!(Health::NoAdapter.as_str(), "NoAdapter"); + assert_eq!(Health::UnknownProfile.as_str(), "UnknownProfile"); + } + + #[test] + fn wait_for_tick_returns_pending_set_profile_and_drains_churn() { + // A queued set_profile is an action, not a signal: it must survive + // the churn-burst drain and be returned to the loop. + let (tx, rx) = mpsc::channel::(); + let _ = tx.send(Wake::LinkChurn); + let _ = tx.send(Wake::SetProfile("home".into())); + let _ = tx.send(Wake::LinkChurn); + + let wake = wait_for_tick(&rx, Duration::from_millis(10)); + assert!(matches!(wake, Some(Wake::SetProfile(n)) if n == "home")); + // The burst was fully drained. + assert!(rx.try_recv().is_err()); + } + + #[test] + fn wait_for_tick_drains_churn_burst_without_action() { + // A burst of monitor signals collapses to one wake with no action. + let (tx, rx) = mpsc::channel::(); + let _ = tx.send(Wake::LinkChurn); + let _ = tx.send(Wake::LinkChurn); + let _ = tx.send(Wake::LinkChurn); + + assert!(wait_for_tick(&rx, Duration::from_millis(10)).is_none()); + assert!(rx.try_recv().is_err()); + } + + #[test] + fn wait_for_tick_times_out_with_no_signal() { + let (_tx, rx) = mpsc::channel::(); + assert!(wait_for_tick(&rx, Duration::from_millis(10)).is_none()); + } + + #[test] + fn recovery_due_fires_when_never_run_and_after_cooldown() { + // Never run → due immediately (the map() to u64::MAX path). + assert!(recovery_due(None, Instant::now(), 20)); + // Just ran → not due again yet. + let now = Instant::now(); + assert!(!recovery_due(Some(now), now, 20)); + // A zero cooldown is always already-elapsed. + assert!(recovery_due(Some(now), now, 0)); } } diff --git a/tests/cli.rs b/tests/cli.rs index 3722709..2d1a422 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -1,6 +1,10 @@ //! End-to-end CLI tests. Each run is fully isolated: HOME / XDG dirs point at a -//! throwaway tempdir and PATH is emptied so no real `nmcli`/`tailscale`/`date` -//! is ever invoked and the host system is never touched. +//! throwaway tempdir, PATH is emptied so no real `tailscale`/`curl`/`date` is +//! ever invoked, and a private `dbus-daemon` (optionally hosting a fake +//! NetworkManager service — see `tests/common::fake_nm`) stands in for the +//! system bus so the binary's D-Bus calls never touch the host. + +mod common; use std::fs; use std::path::PathBuf; @@ -8,12 +12,15 @@ use std::process::Command; use std::sync::atomic::{AtomicU32, Ordering}; use std::time::{SystemTime, UNIX_EPOCH}; +use common::fake_nm::{self, Security}; + const BIN: &str = env!("CARGO_BIN_EXE_breadcrumbs"); static COUNTER: AtomicU32 = AtomicU32::new(0); struct Sandbox { root: PathBuf, + _bus: fake_nm::Daemon, } impl Sandbox { @@ -30,26 +37,65 @@ impl Sandbox { nanos )); fs::create_dir_all(root.join("bin")).unwrap(); - Sandbox { root } + Sandbox { + root, + _bus: fake_nm::launch_daemon(), + } + } + + /// Attach the fake NetworkManager service to this sandbox's bus and + /// return a handle for driving its state. + fn nm(&self) -> fake_nm::FakeNmBus { + fake_nm::serve_on(&self._bus.addr) } /// Binary invocation with an isolated, side-effect-free environment. fn cmd(&self, args: &[&str]) -> std::process::Output { - Command::new(BIN) - .args(args) + self.cmd_env(args, &[]) + } + + /// Like [`cmd`], with extra environment variables layered on top of the + /// isolated base (e.g. `EDITOR` for the `edit` command). + fn cmd_env(&self, args: &[&str], extra: &[(&str, &str)]) -> std::process::Output { + let mut c = Command::new(BIN); + c.args(args) .env_clear() .env("HOME", &self.root) .env("XDG_CONFIG_HOME", self.root.join("config")) .env("XDG_STATE_HOME", self.root.join("state")) // Empty bin dir => no external commands resolve. .env("PATH", self.root.join("bin")) - .output() - .expect("failed to spawn breadcrumbs") + // Point the binary's `Connection::system()` at this test's + // private bus so it never touches a real system bus. + .env("DBUS_SYSTEM_BUS_ADDRESS", &self._bus.addr); + for (k, v) in extra { + c.env(k, v); + } + c.output().expect("failed to spawn breadcrumbs") } fn config_file(&self) -> PathBuf { self.root.join("config/breadcrumbs/breadcrumbs.toml") } + + /// Saved networks live in their own file, split out of `breadcrumbs.toml` + /// (see `config::networks_path`). + fn networks_file(&self) -> PathBuf { + self.root.join("config/breadcrumbs/networks.toml") + } + + /// Write an executable shell script into the sandbox's PATH dir so a + /// test can stand in for an external command (e.g. `$EDITOR`, `curl`). + fn write_fake_bin(&self, name: &str, script: &str) -> PathBuf { + let path = self.root.join("bin").join(name); + fs::write(&path, script).unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(&path, fs::Permissions::from_mode(0o755)).unwrap(); + } + path + } } impl Drop for Sandbox { @@ -62,6 +108,10 @@ fn stdout(o: &std::process::Output) -> String { String::from_utf8_lossy(&o.stdout).to_string() } +fn stderr(o: &std::process::Output) -> String { + String::from_utf8_lossy(&o.stderr).to_string() +} + #[test] fn help_lists_all_commands() { let sb = Sandbox::new(); @@ -120,7 +170,7 @@ fn profile_defaults_to_away_then_persists_set() { assert!(o.status.success()); assert_eq!(stdout(&o).trim(), "away"); - // `set --no-apply` must not touch the network (no nmcli available anyway). + // `set --no-apply` must not touch the network (no NM on the bus anyway). let o = sb.cmd(&["profile", "set", "home", "--no-apply"]); assert!( o.status.success(), @@ -144,348 +194,633 @@ fn unknown_profile_override_is_reported() { } #[test] -fn profile_list_marks_active_profile() { +fn profile_set_unknown_reports_available_profiles() { let sb = Sandbox::new(); - - // Default profile is "away". - let o = sb.cmd(&["profile", "list"]); - assert!(o.status.success()); - let out = stdout(&o); - // The active profile line starts with "* ". - assert!( - out.lines().any(|l| l.starts_with("* away")), - "active profile marker missing: {out}" - ); - // Inactive profiles start with " ". - assert!( - out.lines().any(|l| l.starts_with(" home")), - "inactive profile format wrong: {out}" - ); - - // After switching to "work" the marker should move. - sb.cmd(&["profile", "set", "work", "--no-apply"]); - let o = sb.cmd(&["profile", "list"]); - let out = stdout(&o); - assert!(out.lines().any(|l| l.starts_with("* work"))); - assert!(out.lines().any(|l| l.starts_with(" away"))); -} - -#[test] -fn add_saves_network_to_config() { - let sb = Sandbox::new(); - - let o = sb.cmd(&["add", "CafeWifi", "mypassword"]); - assert!( - o.status.success(), - "stderr: {}", - String::from_utf8_lossy(&o.stderr) - ); - assert!(stdout(&o).contains("saved")); - - // The network should now appear in the config file. - let text = fs::read_to_string(sb.config_file()).unwrap(); - assert!(text.contains("CafeWifi"), "SSID missing from config"); - assert!(text.contains("mypassword"), "password missing from config"); -} - -#[test] -fn add_attaches_ssid_to_profile_at_position() { - let sb = Sandbox::new(); - - // First add without attaching, then add again with --to. - sb.cmd(&["add", "HomeWifi", "pw1"]); - let o = sb.cmd(&["add", "HomeWifi", "pw1", "--to", "home"]); - assert!( - o.status.success(), - "stderr: {}", - String::from_utf8_lossy(&o.stderr) - ); - - let text = fs::read_to_string(sb.config_file()).unwrap(); - // After attaching, the home profile's networks list should contain HomeWifi. - assert!( - text.contains("HomeWifi"), - "SSID not found in config after --to: {text}" - ); -} - -#[test] -fn add_to_unknown_profile_fails() { - let sb = Sandbox::new(); - let o = sb.cmd(&["add", "SomeNet", "pw", "--to", "nonexistent"]); + sb.cmd(&["list"]); // bootstrap the config + let o = sb.cmd(&["profile", "set", "bogus"]); assert!(!o.status.success()); + let err = stderr(&o); + assert!(err.contains("unknown profile"), "stderr: {err}"); + assert!(err.contains("home") && err.contains("work") && err.contains("away")); } #[test] -fn add_updates_existing_network_password() { +fn profile_list_marks_exactly_the_current_profile() { let sb = Sandbox::new(); - - sb.cmd(&["add", "MyNet", "oldpass"]); - let o = sb.cmd(&["add", "MyNet", "newpass"]); + sb.cmd(&["profile", "set", "home", "--no-apply"]); + let o = sb.cmd(&["profile", "list"]); assert!(o.status.success()); + let out = stdout(&o); + assert!(out.contains("* home"), "out: {out}"); + assert_eq!( + out.lines().filter(|l| l.trim_start().starts_with('*')).count(), + 1, + "expected exactly one marked profile, got: {out}" + ); +} + +#[test] +fn add_with_multibyte_password_does_not_crash_list() { + // Regression test for a byte-slicing panic in the password-masking code: + // `list` used to index into the first *byte* of the password, which + // panicked whenever that byte fell mid-character in a multi-byte UTF-8 + // password (e.g. non-Latin scripts or an emoji as the first character). + let sb = Sandbox::new(); + let add = sb.cmd(&["add", "CafeWifi", "日本語パスワード🔒"]); + assert!(add.status.success(), "stderr: {}", stderr(&add)); + + let list = sb.cmd(&["list"]); + assert!( + list.status.success(), + "list crashed on multibyte password — stderr: {}", + stderr(&list) + ); + assert!(!stdout(&list).contains("日本語パスワード🔒")); +} + +#[test] +fn list_hides_password_by_default_and_reveals_with_flag() { + let sb = Sandbox::new(); + sb.cmd(&["add", "CafeWifi", "hunter2"]); + + let hidden = sb.cmd(&["list"]); + assert!(!stdout(&hidden).contains("hunter2")); + + let shown = sb.cmd(&["list", "--show-passwords"]); + assert!(stdout(&shown).contains("hunter2")); +} + +#[test] +fn add_to_profile_persists_in_config_priority_list() { + let sb = Sandbox::new(); + sb.cmd(&["list"]); // bootstrap + let o = sb.cmd(&["add", "CafeWifi", "pw", "--to", "home"]); + assert!(o.status.success(), "stderr: {}", stderr(&o)); let text = fs::read_to_string(sb.config_file()).unwrap(); - assert!(text.contains("newpass"), "updated password missing"); - // Old password must be gone. - assert!(!text.contains("oldpass"), "old password still present"); - // Only one entry for MyNet. - assert_eq!( - text.matches("MyNet").count(), - 1, - "duplicate network entries" + let home_section = text.split("[profiles.home]").nth(1).unwrap_or(""); + assert!( + home_section.contains("CafeWifi"), + "CafeWifi not attached under [profiles.home]: {text}" ); } #[test] fn forget_removes_network_from_config() { let sb = Sandbox::new(); + sb.cmd(&["add", "CafeWifi", "pw", "--to", "away"]); + let o = sb.cmd(&["forget", "CafeWifi"]); + assert!(o.status.success(), "stderr: {}", stderr(&o)); - sb.cmd(&["add", "ToDelete", "pw"]); - let text = fs::read_to_string(sb.config_file()).unwrap(); - assert!(text.contains("ToDelete")); - - let o = sb.cmd(&["forget", "ToDelete"]); - assert!(o.status.success()); - assert!(stdout(&o).contains("forgot")); - - let text = fs::read_to_string(sb.config_file()).unwrap(); + // Networks live in networks.toml (see the split-secrets test below) — + // that's the file that actually needs to lose the entry. + let networks = fs::read_to_string(sb.networks_file()).unwrap(); assert!( - !text.contains("ToDelete"), - "network still in config after forget" + !networks.contains("CafeWifi"), + "network still in networks.toml: {networks}" ); -} - -#[test] -fn forget_nonexistent_network_is_graceful() { - let sb = Sandbox::new(); - // Should succeed (idempotent) even if the SSID was never saved. - let o = sb.cmd(&["forget", "NeverSaved"]); - assert!(o.status.success()); -} - -#[test] -fn forget_removes_ssid_from_profile_networks_list() { - let sb = Sandbox::new(); - - // Add "WorkNet" and attach it to the "work" profile. - sb.cmd(&["add", "WorkNet", "pw", "--to", "work"]); + // ...and it should never have been in breadcrumbs.toml to begin with. let text = fs::read_to_string(sb.config_file()).unwrap(); - assert!(text.contains("WorkNet")); - - sb.cmd(&["forget", "WorkNet"]); - - let text = fs::read_to_string(sb.config_file()).unwrap(); - assert!( - !text.contains("WorkNet"), - "SSID still in profile after forget" - ); + assert!(!text.contains("CafeWifi"), "network leaked into config: {text}"); } #[test] -fn list_masks_passwords_by_default() { +fn detect_without_wifi_adapter_errors() { let sb = Sandbox::new(); - sb.cmd(&["add", "SecretNet", "hunter2"]); + let o = sb.cmd(&["detect"]); + assert!(!o.status.success()); + assert!(stderr(&o).contains("could not detect"), "stderr: {}", stderr(&o)); +} - let o = sb.cmd(&["list"]); - assert!(o.status.success()); +#[test] +fn doctor_reports_missing_network_manager_on_private_bus() { + // The sandbox bus has no NetworkManager service on it, so doctor must + // report it missing rather than assuming presence. + let sb = Sandbox::new(); + let o = sb.cmd(&["doctor"]); + assert!(o.status.success(), "stderr: {}", stderr(&o)); + assert!(stdout(&o).contains("MISSING")); +} + +#[test] +fn status_runs_without_crashing_when_offline() { + let sb = Sandbox::new(); + let o = sb.cmd(&["status"]); + // No adapter/internet in the sandbox, so this reports unhealthy (exit 1) + // rather than crashing. + assert_eq!(o.status.code(), Some(1)); let out = stdout(&o); - assert!( - !out.contains("hunter2"), - "plain-text password exposed in list" - ); - // A masking bullet should appear. - assert!(out.contains('•'), "no masking character in list output"); + assert!(out.contains("breadcrumbs")); + assert!(out.contains("needs attention")); } #[test] -fn list_masks_multibyte_password_without_panicking() { +fn cd_prints_the_config_directory() { let sb = Sandbox::new(); - // A password whose first character is multi-byte UTF-8 used to panic the - // byte-slicing mask(); list must mask it cleanly instead. - sb.cmd(&["add", "UnicodeNet", "ñoño-café-🔐"]); - - let o = sb.cmd(&["list"]); - assert!( - o.status.success(), - "list crashed on multibyte password: {}", - String::from_utf8_lossy(&o.stderr) - ); - let out = stdout(&o); - assert!( - !out.contains("ñoño-café-🔐"), - "password leaked in masked list" - ); - assert!(out.contains('•'), "no masking character in list output"); -} - -#[test] -fn list_show_passwords_reveals_password() { - let sb = Sandbox::new(); - sb.cmd(&["add", "SecretNet", "hunter2"]); - - let o = sb.cmd(&["list", "--show-passwords"]); - assert!(o.status.success()); - assert!( - stdout(&o).contains("hunter2"), - "password not shown with --show-passwords" - ); -} - -#[test] -fn cd_prints_config_directory() { - let sb = Sandbox::new(); - // Trigger config creation first. - sb.cmd(&["list"]); - let o = sb.cmd(&["cd"]); assert!(o.status.success()); - let out = stdout(&o).trim().to_string(); - assert!(!out.is_empty(), "cd produced no output"); - // The printed path must end with "breadcrumbs" (the config subdirectory). - assert!(out.ends_with("breadcrumbs"), "unexpected config dir: {out}"); + let printed = PathBuf::from(stdout(&o).trim()); + assert_eq!(printed, sb.root.join("config/breadcrumbs")); } #[test] -fn doctor_runs_without_crashing() { +fn install_service_no_enable_writes_valid_unit_file() { let sb = Sandbox::new(); - // With an empty PATH, nmcli/tailscale are absent. Doctor should report - // "MISSING"/"absent" but still exit successfully (it's a diag tool). - let o = sb.cmd(&["doctor"]); - assert!( - o.status.success(), - "stderr: {}", - String::from_utf8_lossy(&o.stderr) - ); - let out = stdout(&o); - assert!(out.contains("nmcli"), "doctor missing nmcli line"); - assert!(out.contains("tailscale"), "doctor missing tailscale line"); -} - -#[test] -fn status_exits_nonzero_when_unhealthy() { - let sb = Sandbox::new(); - // No nmcli/tailscale → internet check fails → unhealthy → exit code 1. - let o = sb.cmd(&["status"]); - assert!( - !o.status.success(), - "expected non-zero exit for unhealthy status" - ); - let out = stdout(&o); - assert!( - out.contains("breadcrumbs"), - "missing header in status output" - ); - assert!( - out.contains("profile"), - "missing profile line in status output" - ); -} - -#[test] -fn profile_override_flag_does_not_persist() { - let sb = Sandbox::new(); - - // Set profile to "home" persistently. - sb.cmd(&["profile", "set", "home", "--no-apply"]); - - // Use --profile flag to override for a single run (status). - let o = sb.cmd(&["--profile", "work", "status"]); - // Status exits non-zero (no network), but it should show the overridden profile. - let out = stdout(&o); - assert!(out.contains("work"), "override profile not shown in status"); - - // The persistent profile must still be "home". - let o = sb.cmd(&["profile", "get"]); - assert_eq!(stdout(&o).trim(), "home"); -} - -#[test] -fn add_hidden_flag_is_persisted() { - let sb = Sandbox::new(); - let o = sb.cmd(&["add", "HiddenNet", "pw", "--hidden"]); - assert!(o.status.success()); - - let text = fs::read_to_string(sb.config_file()).unwrap(); - assert!(text.contains("hidden = true"), "hidden flag not persisted"); -} - -#[test] -fn status_json_is_valid_and_machine_readable() { - let sb = Sandbox::new(); - // No nmcli/tailscale → unhealthy, exit 1, but JSON must still be valid. - let o = sb.cmd(&["status", "--json"]); - assert!( - !o.status.success(), - "expected non-zero exit for unhealthy status" - ); - let v: serde_json::Value = - serde_json::from_str(&stdout(&o)).expect("status --json did not emit valid JSON"); - assert_eq!(v["profile"], "away"); - assert_eq!(v["internet"], false); - assert_eq!(v["healthy"], false); - assert!(v["tailscale"].is_object()); -} - -#[test] -fn profile_add_persists_detect_ssids_and_remove_deletes() { - let sb = Sandbox::new(); - - let o = sb.cmd(&[ - "profile", "add", "lab", "--detect", "LabWifi", "--detect", "LabGuest", - ]); - assert!( - o.status.success(), - "stderr: {}", - String::from_utf8_lossy(&o.stderr) - ); - - let text = fs::read_to_string(sb.config_file()).unwrap(); - assert!( - text.contains("[profiles.lab]"), - "profile not written: {text}" - ); - assert!( - text.contains("LabWifi") && text.contains("LabGuest"), - "detect ssids missing" - ); - - // Adding the same profile again is rejected. - assert!(!sb.cmd(&["profile", "add", "lab"]).status.success()); - - // Remove it. - let o = sb.cmd(&["profile", "remove", "lab"]); - assert!(o.status.success()); - let text = fs::read_to_string(sb.config_file()).unwrap(); - assert!( - !text.contains("[profiles.lab]"), - "profile still present after remove" - ); -} - -#[test] -fn profile_remove_core_is_rejected() { - let sb = Sandbox::new(); - let o = sb.cmd(&["profile", "remove", "home"]); - assert!(!o.status.success(), "removing a core profile should fail"); -} - -#[test] -fn install_service_writes_unit_file() { - let sb = Sandbox::new(); - // Install but don't enable (systemctl is absent in the empty PATH, but - // writing the unit file should succeed regardless). let o = sb.cmd(&["install-service", "--no-enable"]); + assert!(o.status.success(), "stderr: {}", stderr(&o)); + + // The sandbox sets XDG_CONFIG_HOME=$root/config, so the unit lands + // under $XDG_CONFIG_HOME/systemd/user — the whole point of the fix is + // honoring XDG rather than hardcoding ~/.config. + let unit_path = sb.root.join("config/systemd/user/breadcrumbs.service"); + assert!(unit_path.exists()); + let text = fs::read_to_string(unit_path).unwrap(); + assert!(text.contains("ExecStart=")); + assert!(text.contains("breadcrumbs watch")); + assert!(text.contains("[Install]")); + assert!(text.contains("WantedBy=default.target")); +} + +#[test] +fn edit_invokes_editor_then_validates_config() { + let sb = Sandbox::new(); + sb.write_fake_bin("fake-editor", "#!/bin/sh\nexit 0\n"); + + let o = sb.cmd_env(&["edit"], &[("EDITOR", "fake-editor")]); + assert!(o.status.success(), "stderr: {}", stderr(&o)); + assert!(stdout(&o).contains("config OK")); + assert!(sb.config_file().exists()); +} + +#[test] +fn edit_reports_editor_failure() { + let sb = Sandbox::new(); + sb.write_fake_bin("fake-editor", "#!/bin/sh\nexit 1\n"); + + let o = sb.cmd_env(&["edit"], &[("EDITOR", "fake-editor")]); + assert!(!o.status.success()); + assert!(stderr(&o).contains("editor exited with error")); +} + +// ----------------------------------------------------------------------- +// Split secrets file (item 5) +// ----------------------------------------------------------------------- + +#[test] +fn networks_are_stored_separately_from_settings_and_profiles() { + let sb = Sandbox::new(); + let o = sb.cmd(&["add", "CafeWifi", "hunter2", "--to", "away"]); + assert!(o.status.success(), "stderr: {}", stderr(&o)); + + let settings = fs::read_to_string(sb.config_file()).unwrap(); assert!( - o.status.success(), - "stderr: {}", - String::from_utf8_lossy(&o.stderr) + !settings.contains("hunter2") && !settings.contains("[[networks]]"), + "breadcrumbs.toml should hold settings/profiles only, not network credentials: {settings}" ); - let unit = sb.root.join(".config/systemd/user/breadcrumbs.service"); - assert!(unit.exists(), "unit file not written at {}", unit.display()); - let content = fs::read_to_string(&unit).unwrap(); - assert!(content.contains("ExecStart="), "unit missing ExecStart"); + // The profile's priority list (an SSID *reference*, not a credential) + // does still live in breadcrumbs.toml — that's expected and fine. + assert!(settings.contains("[profiles.away]")); + assert!(settings.contains("CafeWifi")); + + let networks = fs::read_to_string(sb.networks_file()).unwrap(); + assert!(networks.contains("CafeWifi") && networks.contains("hunter2")); + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mode = fs::metadata(sb.networks_file()).unwrap().permissions().mode() & 0o777; + assert_eq!(mode, 0o600, "networks.toml should be owner-only"); + } +} + +#[test] +fn add_with_empty_password_is_stored_as_no_password() { + // An explicitly empty password (e.g. `add SSID ""`, or a blank response + // at the interactive prompt) means "this is an open network" — it must + // round-trip as an absent `password` key, the same as a cleared one, + // not as `password = ""` (which `nm::connect_verbose` would send as a + // literal empty PSK and fail against a real open SSID). + let sb = Sandbox::new(); + let o = sb.cmd(&["add", "OpenCafe", ""]); + assert!(o.status.success(), "stderr: {}", stderr(&o)); + + let networks = fs::read_to_string(sb.networks_file()).unwrap(); + assert!(networks.contains("OpenCafe")); + assert!(!networks.contains("password"), "networks.toml: {networks}"); + + let list = sb.cmd(&["list"]); + assert!(stdout(&list).contains("managed by NetworkManager")); +} + +// ----------------------------------------------------------------------- +// NM-owned credentials (item 4): a password is only ever needed once. +// ----------------------------------------------------------------------- + +#[test] +fn password_is_cleared_after_first_connect_and_never_sent_again() { + // The first connect creates an NM profile carrying the PSK (over D-Bus, + // never in argv); the second connect reuses that profile and must not + // create a duplicate or resend the password. + let sb = Sandbox::new(); + let nm = sb.nm(); + let dev = nm.add_wifi_device("wlan0", 100); + nm.add_ap(&dev, "TestNet", 80, Security::Wpa2); + + let add = sb.cmd(&["add", "TestNet", "hunter2"]); + assert!(add.status.success(), "stderr: {}", stderr(&add)); + // "away" is the default profile and defaults to include_all_known, so + // TestNet is already a connect candidate with no `--to` needed. + + // First connect: no saved NM profile yet, so breadcrumbs creates one + // with the password. + let first = sb.cmd(&["init"]); + assert!(first.status.success(), "stderr: {}", stderr(&first)); + + // The local copy is gone from disk immediately after. + let networks = fs::read_to_string(sb.networks_file()).unwrap(); assert!( - content.contains("breadcrumbs watch"), - "unit missing watch subcommand" + !networks.contains("hunter2"), + "password should have been cleared from networks.toml: {networks}" + ); + assert!(networks.contains("TestNet"), "network entry itself should remain"); + + // The NM profile durably holds the secret. + let psk = { + let st = nm.state.lock().unwrap(); + st.connections + .values() + .filter_map(|s| { + let sec = s.get("802-11-wireless-security")?; + sec.get("psk").and_then(|v| v.downcast_ref::().ok()) + }) + .next() + }; + assert_eq!(psk.as_deref(), Some("hunter2"), "NM profile must hold the PSK"); + + // Second connect: breadcrumbs has no local password anymore, so it must + // reuse the existing profile without creating a duplicate. + let second = sb.cmd(&["init"]); + assert!(second.status.success(), "stderr: {}", stderr(&second)); + assert_eq!( + nm.connection_count(), + 1, + "reuse must not accumulate duplicate NM profiles" ); } + +// ----------------------------------------------------------------------- +// CLI-level coverage through fake NM + tailscale (item 3) +// ----------------------------------------------------------------------- + +#[test] +fn status_reports_healthy_through_fake_nm_and_curl() { + let sb = Sandbox::new(); + let nm = sb.nm(); + let dev = nm.add_wifi_device("wlan0", 100); + let ap = nm.add_ap(&dev, "HomeWifi", 80, Security::Wpa2); + nm.set_active_ap(&dev, &ap); + sb.write_fake_bin("curl", "#!/bin/sh\necho -n 204\nexit 0\n"); + + let o = sb.cmd(&["status"]); + assert!(o.status.success(), "stderr: {}", stderr(&o)); + let out = stdout(&o); + assert!(out.contains("HomeWifi"), "out: {out}"); + assert!(out.contains("healthy"), "out: {out}"); +} + +#[test] +fn doctor_reports_present_when_nm_and_tailscale_are_available() { + let sb = Sandbox::new(); + let _nm = sb.nm(); // attach the fake NM; keep the handle alive for the run + sb.write_fake_bin("tailscale", "#!/bin/sh\nexit 0\n"); + + let o = sb.cmd(&["doctor"]); + assert!(o.status.success(), "stderr: {}", stderr(&o)); + let out = stdout(&o); + assert!( + out.contains("network-manager") && out.contains("present"), + "out: {out}" + ); + assert!(!out.contains("MISSING"), "out: {out}"); +} + +#[test] +fn detect_picks_profile_whose_detect_ssids_are_visible() { + let sb = Sandbox::new(); + let nm = sb.nm(); + let dev = nm.add_wifi_device("wlan0", 100); + nm.add_ap(&dev, "CorpWifi", 80, Security::Wpa2); + sb.cmd(&["list"]); // bootstrap the default config (home/work/away) + + // Attach a marker SSID to "work" so detection has something to match — + // the skeleton config ships with empty detect_ssids everywhere. + let text = fs::read_to_string(sb.config_file()).unwrap(); + let patched = text.replace( + "[profiles.work]", + "[profiles.work]\ndetect_ssids = [\"CorpWifi\"]", + ); + fs::write(sb.config_file(), patched).unwrap(); + + let o = sb.cmd(&["detect"]); + assert!(o.status.success(), "stderr: {}", stderr(&o)); + assert_eq!(stdout(&o).trim(), "work"); +} + +// ----------------------------------------------------------------------- +// Regression tests for the audit fixes (XDG paths, EDITOR args, config +// merging/clamping, core-profile ownership, scan validation). +// ----------------------------------------------------------------------- + +#[test] +fn edit_splits_editor_arguments() { + // EDITOR="code -w" style values must be split into program + args + // instead of being treated as one (nonexistent) binary path. + let sb = Sandbox::new(); + sb.write_fake_bin( + "fake-editor", + "#!/bin/sh\necho \"$@\" > \"$HOME/editor-args\"\nexit 0\n", + ); + + let o = sb.cmd_env(&["edit"], &[("EDITOR", "fake-editor --wait")]); + assert!(o.status.success(), "stderr: {}", stderr(&o)); + assert!(stdout(&o).contains("config OK")); + let args = fs::read_to_string(sb.root.join("editor-args")).unwrap(); + assert!(args.contains("--wait"), "editor args must be split off: {args}"); + assert!( + args.contains("breadcrumbs.toml"), + "config path must be appended as its own argument: {args}" + ); +} + +#[test] +fn scan_to_unknown_profile_errors_like_add() { + // `scan --to bogus` must fail up front, matching `add --to`, instead of + // silently saving a network that never gets attached. + let sb = Sandbox::new(); + sb.cmd(&["list"]); // bootstrap the config + let o = sb.cmd(&["scan", "--to", "bogus"]); + assert!(!o.status.success()); + assert!( + stderr(&o).contains("unknown profile 'bogus'"), + "stderr: {}", + stderr(&o) + ); +} + +#[test] +fn watch_interval_below_minimum_is_clamped_to_four() { + // `list` and the watch loop must agree on the poll interval: a value + // below the documented minimum of 4 is clamped at load, not just + // silently clamped inside the watch loop. + let sb = Sandbox::new(); + fs::create_dir_all(sb.root.join("config/breadcrumbs")).unwrap(); + fs::write(sb.config_file(), "[settings]\nwatch_interval = 1\n").unwrap(); + + let o = sb.cmd(&["list"]); + assert!(o.status.success(), "stderr: {}", stderr(&o)); + assert!( + stdout(&o).contains("watch every 4s"), + "watch interval must clamp to the minimum: {}", + stdout(&o) + ); +} + +#[test] +fn legacy_inline_networks_merge_with_networks_toml_instead_of_dropping() { + // A breadcrumbs.toml still carrying a legacy inline `[[networks]]` block + // must keep those networks even when networks.toml already exists — the + // merge is completed (and the inline block dropped) on the next save. + let sb = Sandbox::new(); + fs::create_dir_all(sb.root.join("config/breadcrumbs")).unwrap(); + fs::write( + sb.config_file(), + "[settings]\ndefault_profile = \"away\"\n\n[[networks]]\nssid = \"InlineNet\"\npassword = \"pw-inline\"\n", + ) + .unwrap(); + fs::write( + sb.networks_file(), + "[[networks]]\nssid = \"FileNet\"\npassword = \"pw-file\"\n", + ) + .unwrap(); + + let o = sb.cmd(&["list"]); + assert!(o.status.success(), "stderr: {}", stderr(&o)); + let out = stdout(&o); + assert!(out.contains("InlineNet"), "inline network must survive the merge: {out}"); + assert!(out.contains("FileNet"), "networks.toml network must be present: {out}"); + + // A later save migrates the merged set into networks.toml and drops the + // inline block from breadcrumbs.toml. + let o2 = sb.cmd(&["add", "OtherNet", "pw3"]); + assert!(o2.status.success(), "stderr: {}", stderr(&o2)); + let networks = fs::read_to_string(sb.networks_file()).unwrap(); + assert!( + networks.contains("InlineNet") + && networks.contains("FileNet") + && networks.contains("OtherNet"), + "save must persist the merged set: {networks}" + ); + let config_text = fs::read_to_string(sb.config_file()).unwrap(); + assert!( + !config_text.contains("[[networks]]"), + "inline block should be gone after migration: {config_text}" + ); +} + +#[test] +fn core_profiles_are_not_resurrected_once_config_is_user_owned() { + // After the first save the config is user-owned: a deliberately deleted + // core profile must stay deleted. + let sb = Sandbox::new(); + fs::create_dir_all(sb.root.join("config/breadcrumbs")).unwrap(); + fs::write( + sb.config_file(), + "[settings]\ncore_profiles_initialized = true\n", + ) + .unwrap(); + + let o = sb.cmd(&["profile", "list"]); + assert!(o.status.success(), "stderr: {}", stderr(&o)); + let out = stdout(&o); + assert!( + !out.contains("home") && !out.contains("work") && !out.contains("away"), + "deleted core profiles must stay deleted: {out}" + ); +} + +#[test] +fn legacy_config_without_profiles_gets_core_profiles_backfilled() { + // Pre-ownership configs (no flag yet) still get the core profiles + // backfilled once — the self-heal that makes bare `[settings]` configs + // usable. + let sb = Sandbox::new(); + fs::create_dir_all(sb.root.join("config/breadcrumbs")).unwrap(); + fs::write(sb.config_file(), "[settings]\n").unwrap(); + + let o = sb.cmd(&["profile", "list"]); + assert!(o.status.success(), "stderr: {}", stderr(&o)); + let out = stdout(&o); + assert!( + out.contains("home") && out.contains("work") && out.contains("away"), + "legacy configs get the core profiles backfilled once: {out}" + ); +} + +// ----------------------------------------------------------------------- +// New features: per-network DNS, enterprise (802.1x) networks, --json +// output, prune, scored detection, and init --wait retry. +// ----------------------------------------------------------------------- + +#[test] +fn add_with_dns_persists_per_network_override() { + let sb = Sandbox::new(); + let o = sb.cmd(&["add", "CafeWifi", "pw", "--dns", "9.9.9.9"]); + assert!(o.status.success(), "stderr: {}", stderr(&o)); + let networks = fs::read_to_string(sb.networks_file()).unwrap(); + assert!( + networks.contains("dns = \"9.9.9.9\""), + "per-network DNS override must be persisted: {networks}" + ); +} + +#[test] +fn add_enterprise_fields_persist() { + let sb = Sandbox::new(); + let o = sb.cmd(&[ + "add", + "CorpWifi", + "pw", + "--eap", + "peap", + "--identity", + "user@corp", + "--ca-cert", + "/etc/ca.pem", + ]); + assert!(o.status.success(), "stderr: {}", stderr(&o)); + let networks = fs::read_to_string(sb.networks_file()).unwrap(); + assert!(networks.contains("eap = \"peap\""), "networks: {networks}"); + assert!(networks.contains("identity = \"user@corp\"")); + assert!(networks.contains("ca_cert = \"/etc/ca.pem\"")); +} + +#[test] +fn status_json_emits_machine_readable_output() { + let sb = Sandbox::new(); + let o = sb.cmd(&["status", "--json"]); + // No adapter in the sandbox → unhealthy (exit 1), but still valid JSON. + assert_eq!(o.status.code(), Some(1)); + let v: serde_json::Value = + serde_json::from_str(&stdout(&o)).expect("status --json must emit valid JSON"); + assert_eq!(v["profile"].as_str(), Some("away")); + assert_eq!(v["healthy"].as_bool(), Some(false)); + assert_eq!(v["internet"].as_bool(), Some(false)); +} + +#[test] +fn detect_json_emits_machine_readable_output() { + let sb = Sandbox::new(); + let nm = sb.nm(); + let dev = nm.add_wifi_device("wlan0", 100); + nm.add_ap(&dev, "CorpWifi", 80, Security::Wpa2); + sb.cmd(&["list"]); // bootstrap the default config + + let text = fs::read_to_string(sb.config_file()).unwrap(); + let patched = text.replace( + "[profiles.work]", + "[profiles.work]\ndetect_ssids = [\"CorpWifi\"]", + ); + fs::write(sb.config_file(), patched).unwrap(); + + let o = sb.cmd(&["detect", "--json"]); + assert!(o.status.success(), "stderr: {}", stderr(&o)); + let v: serde_json::Value = + serde_json::from_str(&stdout(&o)).expect("detect --json must emit valid JSON"); + assert_eq!(v["profile"].as_str(), Some("work")); +} + +#[test] +fn detect_prefers_profile_with_more_matching_markers() { + let sb = Sandbox::new(); + let nm = sb.nm(); + let dev = nm.add_wifi_device("wlan0", 100); + nm.add_ap(&dev, "CorpWifi", 80, Security::Wpa2); + nm.add_ap(&dev, "CafeWifi", 70, Security::Wpa2); + sb.cmd(&["list"]); // bootstrap + + // home matches 1 marker (CorpWifi); work matches 2 (CorpWifi + CafeWifi). + let text = fs::read_to_string(sb.config_file()).unwrap(); + let patched = text + .replace( + "[profiles.home]", + "[profiles.home]\ndetect_ssids = [\"CorpWifi\"]", + ) + .replace( + "[profiles.work]", + "[profiles.work]\ndetect_ssids = [\"CorpWifi\", \"CafeWifi\"]", + ); + fs::write(sb.config_file(), patched).unwrap(); + + let o = sb.cmd(&["detect"]); + assert!(o.status.success(), "stderr: {}", stderr(&o)); + assert_eq!( + stdout(&o).trim(), + "work", + "the profile with more matching markers must win" + ); +} + +#[test] +fn prune_dry_run_lists_stale_nm_profiles() { + let sb = Sandbox::new(); + let nm = sb.nm(); + nm.save_connection("OldCafe", None); + sb.cmd(&["list"]); // bootstrap (no saved networks → everything is stale) + + let o = sb.cmd(&["prune", "--dry-run"]); + assert!(o.status.success(), "stderr: {}", stderr(&o)); + let out = stdout(&o); + assert!( + out.contains("would remove") && out.contains("OldCafe"), + "out: {out}" + ); +} + +#[test] +fn prune_removes_stale_nm_profiles() { + let sb = Sandbox::new(); + let nm = sb.nm(); + nm.save_connection("OldCafe", None); + sb.cmd(&["list"]); + + let o = sb.cmd(&["prune"]); + assert!(o.status.success(), "stderr: {}", stderr(&o)); + let out = stdout(&o); + assert!( + out.contains("removed") && out.contains("OldCafe"), + "out: {out}" + ); + assert_eq!( + nm.connection_count(), + 0, + "prune must actually delete the stale profile" + ); +} + +#[test] +fn init_wait_retries_until_connect_succeeds() { + let sb = Sandbox::new(); + let nm = sb.nm(); + let dev = nm.add_wifi_device("wlan0", 100); + nm.add_ap(&dev, "HomeWifi", 80, Security::Wpa2); + // "away" defaults to include_all_known, so HomeWifi is a candidate. + let add = sb.cmd(&["add", "HomeWifi", "hunter2"]); + assert!(add.status.success(), "stderr: {}", stderr(&add)); + + // The fake's first activation fails; the retry succeeds. `--wait` must + // keep going past the first failure rather than bailing. + nm.fail_next_activations(1); + let o = sb.cmd(&["init", "--wait", "5"]); + assert!(o.status.success(), "stderr: {}", stderr(&o)); + assert!(stdout(&o).contains("connected"), "out: {}", stdout(&o)); +} diff --git a/tests/common/fake_nm.rs b/tests/common/fake_nm.rs new file mode 100644 index 0000000..bc2ee96 --- /dev/null +++ b/tests/common/fake_nm.rs @@ -0,0 +1,1007 @@ +//! A faithful fake NetworkManager served as a real D-Bus service on a +//! private `dbus-daemon`, so the production `nm` module (a pure zbus client +//! against `org.freedesktop.NetworkManager` on the system bus) can be +//! exercised end-to-end — real D-Bus marshalling, real property reads, real +//! method calls — with zero test-only code paths in `src/`. +//! +//! Two ways to use it: +//! +//! - [`launch_private`] — starts a fresh private daemon + fake NM and +//! returns a [`FakeNmBus`] handle. The caller passes +//! `DBUS_SYSTEM_BUS_ADDRESS=` to any subprocess (the CLI sandbox) so +//! the binary's `Connection::system()` lands on this bus. Independent per +//! test → safe to run in parallel. +//! - [`shared`] — one process-wide fake NM bus pointed at by the process +//! env var (in-process tests can't set per-test env safely). Tests using +//! it must serialize against each other, which the returned guard does. +//! +//! The fake implements the subset of the NetworkManager D-Bus API the +//! client uses: devices, access points (SSID/strength/security flags), +//! connection profiles (list/add/get/delete), activation (which lands the +//! device on the matching AP and marks it ACTIVATED), per-device IP4Config, +//! and the `WirelessEnabled`/`Connectivity` root properties. It also +//! records every call for assertions. + +use std::collections::{BTreeMap, HashMap}; +use std::io::BufRead; +use std::ops::Deref; +use std::process::{Child, Command, Stdio}; +use std::sync::{Arc, Mutex, MutexGuard, OnceLock}; + +use zbus::fdo; +use zbus::interface; +use zbus::zvariant::{OwnedObjectPath, OwnedValue, Value}; + +pub const NM_DEST: &str = "org.freedesktop.NetworkManager"; +pub const NM_PATH: &str = "/org/freedesktop/NetworkManager"; +pub const SETTINGS_PATH: &str = "/org/freedesktop/NetworkManager/Settings"; +const DEVICES_PREFIX: &str = "/org/freedesktop/NetworkManager/Devices"; +const APS_PREFIX: &str = "/org/freedesktop/NetworkManager/AccessPoints"; +const ACTIVE_PREFIX: &str = "/org/freedesktop/NetworkManager/ActiveConnection"; +const IP4_PREFIX: &str = "/org/freedesktop/NetworkManager/IP4Config"; +const CONNS_PREFIX: &str = "/org/freedesktop/NetworkManager/Settings"; + +// Security flag constants (mirrors src/nm.rs). +pub const DEV_TYPE_WIFI: u32 = 2; +pub const DEV_STATE_ACTIVATED: u32 = 100; +const AP_FLAG_PRIVACY: u32 = 0x1; +const SEC_PSK: u32 = 0x100; +const SEC_802_1X: u32 = 0x200; +const SEC_SAE: u32 = 0x400; + +/// Wi-Fi security flavors the fake can advertise for an AP. +#[derive(Debug, Clone, Copy)] +pub enum Security { + Open, + Wpa2, + Wpa3, + Wpa1Wpa2, + Enterprise, + Wep, +} + +impl Security { + fn to_flags(self) -> (u32, u32, u32) { + match self { + Security::Open => (0, 0, 0), + Security::Wpa2 => (AP_FLAG_PRIVACY, 0, SEC_PSK), + Security::Wpa3 => (AP_FLAG_PRIVACY, 0, SEC_SAE), + Security::Wpa1Wpa2 => (AP_FLAG_PRIVACY, SEC_PSK, SEC_PSK), + Security::Enterprise => (AP_FLAG_PRIVACY, SEC_802_1X, SEC_802_1X), + Security::Wep => (AP_FLAG_PRIVACY, 0, 0), + } + } +} + +pub type SettingsMap = HashMap>; + +#[derive(Debug, Clone)] +pub struct FakeDeviceData { + pub iface: String, + pub dev_type: u32, + pub state: u32, + pub active_ap: Option, + pub ip4: String, +} + +#[derive(Debug, Clone)] +pub struct FakeApData { + pub ssid: Vec, + pub strength: u8, + pub flags: u32, + pub wpa: u32, + pub rsn: u32, +} + +#[derive(Debug, Default)] +pub struct FakeState { + pub devices: BTreeMap, + pub aps: BTreeMap, + pub dev_aps: HashMap>, + pub connections: BTreeMap, + /// active-conn path -> settings-conn path + pub active_conns: BTreeMap, + /// device path -> active-conn path + pub dev_active: HashMap, + pub connectivity: u32, + pub wireless_enabled: bool, + /// When set, activation lands the device on the AP with this SSID + /// (simulates an NM autoconnect race landing elsewhere). + pub land_on: Option, + /// When set, activation creates a matching AP on the fly if none exists + /// (hidden networks appear in the scan only after connecting). + pub connect_any: bool, + /// When > 0, the next activations fail (simulating a transient NM + /// failure); each attempted activation decrements the counter. + pub fail_next_activations: u32, + pub calls: Vec, + pub next_dev: u32, + pub next_ap: u32, + pub next_conn: u32, + pub next_active: u32, +} + +#[derive(Clone)] +struct Shared { + state: Arc>, +} + +pub fn value_bytes(v: &OwnedValue) -> Option> { + match v.deref() { + Value::Array(a) => { + let mut out = Vec::new(); + for item in a.inner() { + if let Value::U8(b) = item { + out.push(*b); + } else { + return None; + } + } + Some(out) + } + _ => None, + } +} + +/// Extract an array-of-strings (e.g. `ipv4.dns`, `802-1x.eap`) from a +/// settings dict value. zvariant has no `TryFrom<&Value>` for `Vec`, +/// so we peel the `Array` ourselves. +pub fn value_str_list(v: &OwnedValue) -> Option> { + match v.deref() { + Value::Array(a) => { + let mut out = Vec::new(); + for item in a.inner() { + match item { + Value::Str(s) => out.push(s.as_str().to_string()), + _ => return None, + } + } + Some(out) + } + _ => None, + } +} + +fn ov(v: Value<'_>) -> OwnedValue { + OwnedValue::try_from(v).expect("settings value is ownable") +} + +/// OwnedValue isn't `Clone` (only `try_clone`), so settings dicts must be +/// copied field-by-field when the fake hands one out. +fn clone_settings(s: &SettingsMap) -> SettingsMap { + s.iter() + .map(|(section, vals)| { + let cloned = vals + .iter() + .map(|(k, v)| v.try_clone().map(|c| (k.clone(), c))) + .collect::, _>>() + .expect("settings values are ownable"); + (section.clone(), cloned) + }) + .collect() +} + +fn conn_ssid(s: &SettingsMap) -> Option { + s.get("802-11-wireless") + .and_then(|m| m.get("ssid")) + .and_then(value_bytes) + .map(|b| String::from_utf8_lossy(&b).into_owned()) +} + +fn conn_id(s: &SettingsMap) -> Option { + s.get("connection") + .and_then(|m| m.get("id")) + .and_then(|v| v.downcast_ref::().ok()) +} + +fn obj(path: &str) -> OwnedObjectPath { + OwnedObjectPath::try_from(path).expect("valid object path") +} + +// --------------------------------------------------------------------- +// Root interface: org.freedesktop.NetworkManager +// --------------------------------------------------------------------- + +struct FakeNm { + shared: Shared, +} + +#[interface(name = "org.freedesktop.NetworkManager")] +impl FakeNm { + async fn get_devices(&self) -> fdo::Result> { + let st = self.shared.state.lock().unwrap(); + Ok(st.devices.keys().map(|p| obj(p)).collect()) + } + + #[zbus(property)] + fn wireless_enabled(&self) -> fdo::Result { + Ok(self.shared.state.lock().unwrap().wireless_enabled) + } + + // Setters must return `zbus::Error` (not `fdo::Error`): the macro's + // generated setter keeps the fallible arm's error type verbatim, and the + // dispatch future is typed `Result<(), zbus::Error>`. + #[zbus(property)] + fn set_wireless_enabled(&self, v: bool) -> zbus::Result<()> { + self.shared.state.lock().unwrap().wireless_enabled = v; + Ok(()) + } + + #[zbus(property)] + fn connectivity(&self) -> fdo::Result { + Ok(self.shared.state.lock().unwrap().connectivity) + } + + async fn activate_connection( + &self, + conn: OwnedObjectPath, + dev: OwnedObjectPath, + _specific: OwnedObjectPath, + #[zbus(connection)] c: &zbus::Connection, + ) -> fdo::Result { + let active = self.register_active(c, conn.as_str(), dev.as_str()).await?; + Ok(active) + } + + async fn add_and_activate_connection2( + &self, + settings: SettingsMap, + dev: OwnedObjectPath, + _specific: OwnedObjectPath, + _options: HashMap, + #[zbus(connection)] c: &zbus::Connection, + ) -> fdo::Result<(OwnedObjectPath, OwnedObjectPath)> { + let conn_path = self.save_connection(settings, c).await?; + let active = self.register_active(c, conn_path.as_str(), dev.as_str()).await?; + Ok((conn_path, active)) + } +} + +impl FakeNm { + async fn save_connection(&self, settings: SettingsMap, c: &zbus::Connection) -> fdo::Result { + let path = { + let mut st = self.shared.state.lock().unwrap(); + st.calls.push("AddAndActivateConnection2".into()); + // Same UUID-exists semantics as real NetworkManager: this call + // creates a *new* profile, so a duplicate UUID is an error, not + // an upsert (updates go through Settings.Connection.Update2). + if st.connections.values().any(|s| conn_id(s) == conn_id(&settings)) { + return Err(fdo::Error::Failed( + "A connection with this UUID already exists.".into(), + )); + } + let p = format!("{CONNS_PREFIX}/{}", st.next_conn); + st.next_conn += 1; + st.connections.insert(p.clone(), settings); + p + }; + c.object_server() + .at(path.as_str(), FakeConn { + shared: self.shared.clone(), + path: path.clone(), + }) + .await?; + Ok(obj(&path)) + } + + async fn register_active( + &self, + c: &zbus::Connection, + conn_path: &str, + dev: &str, + ) -> fdo::Result { + let (active_path, new_ap) = { + let mut st = self.shared.state.lock().unwrap(); + st.calls.push(format!("activate {conn_path} -> {dev}")); + if st.fail_next_activations > 0 { + st.fail_next_activations -= 1; + return Err(fdo::Error::Failed("transient activation failure".into())); + } + let ssid = st.connections.get(conn_path).and_then(conn_ssid); + let target = st.land_on.clone().or(ssid); + let mut dev_ap = target.as_ref().and_then(|t| { + let aps = st.dev_aps.get(dev).cloned().unwrap_or_default(); + aps.into_iter().find(|ap| { + st.aps + .get(ap) + .map(|a| String::from_utf8_lossy(&a.ssid).into_owned() == *t) + .unwrap_or(false) + }) + }); + // Hidden networks don't appear in a scan until after they're + // associated; create the AP on the fly in that case. + let mut new_ap = None; + if dev_ap.is_none() && st.connect_any { + if let Some(t) = &target { + let id = st.next_ap; + st.next_ap += 1; + let ap_path = format!("{APS_PREFIX}/{id}"); + st.aps.insert( + ap_path.clone(), + FakeApData { + ssid: t.as_bytes().to_vec(), + strength: 80, + flags: AP_FLAG_PRIVACY, + wpa: 0, + rsn: SEC_PSK, + }, + ); + st.dev_aps.entry(dev.to_string()).or_default().push(ap_path.clone()); + dev_ap = Some(ap_path.clone()); + new_ap = Some(ap_path); + } + } + if let Some(d) = st.devices.get_mut(dev) { + if let Some(ap) = &dev_ap { + d.active_ap = Some(ap.clone()); + } + d.state = DEV_STATE_ACTIVATED; + } + let p = format!("{ACTIVE_PREFIX}/{}", st.next_active); + st.next_active += 1; + st.active_conns.insert(p.clone(), conn_path.to_string()); + st.dev_active.insert(dev.to_string(), p.clone()); + (p, new_ap) + }; + if let Some(ap) = &new_ap { + c.object_server() + .at(ap.as_str(), FakeAp { + shared: self.shared.clone(), + path: ap.clone(), + }) + .await?; + } + c.object_server() + .at(active_path.as_str(), FakeActive { + shared: self.shared.clone(), + path: active_path.clone(), + }) + .await?; + Ok(obj(&active_path)) + } +} + +// --------------------------------------------------------------------- +// Settings: org.freedesktop.NetworkManager.Settings +// --------------------------------------------------------------------- + +struct FakeSettings { + shared: Shared, +} + +#[interface(name = "org.freedesktop.NetworkManager.Settings")] +impl FakeSettings { + async fn list_connections(&self) -> fdo::Result> { + let st = self.shared.state.lock().unwrap(); + Ok(st.connections.keys().map(|p| obj(p)).collect()) + } + + async fn add_connection2( + &self, + settings: SettingsMap, + _flags: u32, + _args: HashMap, + #[zbus(connection)] c: &zbus::Connection, + ) -> fdo::Result<(OwnedObjectPath, HashMap)> { + let path = { + let mut st = self.shared.state.lock().unwrap(); + st.calls.push("AddConnection2".into()); + // Real NetworkManager rejects a duplicate UUID with + // NM_SETTINGS_ERROR_UUID_EXISTS — it does NOT upsert. Existing + // profiles must be edited via Settings.Connection.Update2; model + // that here so a client regression fails loudly. + if st.connections.values().any(|s| conn_id(s) == conn_id(&settings)) { + return Err(fdo::Error::Failed( + "A connection with this UUID already exists.".into(), + )); + } + let p = format!("{CONNS_PREFIX}/{}", st.next_conn); + st.next_conn += 1; + st.connections.insert(p.clone(), settings); + p + }; + c.object_server() + .at(path.as_str(), FakeConn { + shared: self.shared.clone(), + path: path.clone(), + }) + .await?; + Ok((obj(&path), HashMap::new())) + } +} + +// --------------------------------------------------------------------- +// Settings.Connection +// --------------------------------------------------------------------- + +struct FakeConn { + shared: Shared, + path: String, +} + +#[interface(name = "org.freedesktop.NetworkManager.Settings.Connection")] +impl FakeConn { + async fn get_settings(&self) -> fdo::Result { + let st = self.shared.state.lock().unwrap(); + let settings = st + .connections + .get(&self.path) + .ok_or_else(|| fdo::Error::UnknownObject(self.path.clone()))?; + Ok(clone_settings(settings)) + } + + async fn delete(&self) -> fdo::Result<()> { + let mut st = self.shared.state.lock().unwrap(); + st.calls.push(format!("delete {}", self.path)); + st.connections.remove(&self.path); + Ok(()) + } + + async fn update2( + &self, + settings: SettingsMap, + _flags: u32, + _args: HashMap, + ) -> fdo::Result> { + let mut st = self.shared.state.lock().unwrap(); + st.calls.push("Update2".into()); + st.connections.insert(self.path.clone(), settings); + Ok(HashMap::new()) + } +} + +// --------------------------------------------------------------------- +// Access point +// --------------------------------------------------------------------- + +struct FakeAp { + shared: Shared, + path: String, +} + +#[interface(name = "org.freedesktop.NetworkManager.AccessPoint")] +impl FakeAp { + #[zbus(property)] + fn ssid(&self) -> fdo::Result> { + Ok(self.shared.state.lock().unwrap().aps[&self.path].ssid.clone()) + } + + #[zbus(property)] + fn strength(&self) -> fdo::Result { + Ok(self.shared.state.lock().unwrap().aps[&self.path].strength) + } + + #[zbus(property)] + fn flags(&self) -> fdo::Result { + Ok(self.shared.state.lock().unwrap().aps[&self.path].flags) + } + + #[zbus(property)] + fn wpa_flags(&self) -> fdo::Result { + Ok(self.shared.state.lock().unwrap().aps[&self.path].wpa) + } + + #[zbus(property)] + fn rsn_flags(&self) -> fdo::Result { + Ok(self.shared.state.lock().unwrap().aps[&self.path].rsn) + } +} + +// --------------------------------------------------------------------- +// Device + Wireless + IP4Config +// --------------------------------------------------------------------- + +struct FakeDevice { + shared: Shared, + path: String, +} + +#[interface(name = "org.freedesktop.NetworkManager.Device")] +impl FakeDevice { + #[zbus(property)] + fn interface(&self) -> fdo::Result { + Ok(self.shared.state.lock().unwrap().devices[&self.path].iface.clone()) + } + + #[zbus(property)] + fn device_type(&self) -> fdo::Result { + Ok(self.shared.state.lock().unwrap().devices[&self.path].dev_type) + } + + #[zbus(property)] + fn state(&self) -> fdo::Result { + Ok(self.shared.state.lock().unwrap().devices[&self.path].state) + } + + #[zbus(property)] + fn active_connection(&self) -> fdo::Result { + let st = self.shared.state.lock().unwrap(); + Ok(st + .dev_active + .get(&self.path) + .map(|p| obj(p)) + .unwrap_or_else(|| obj("/"))) + } + + #[zbus(property)] + fn ip4_config(&self) -> fdo::Result { + let st = self.shared.state.lock().unwrap(); + Ok(obj(&st.devices[&self.path].ip4)) + } + + async fn reapply( + &self, + _settings: SettingsMap, + _version: u64, + _flags: u32, + ) -> fdo::Result<()> { + let mut st = self.shared.state.lock().unwrap(); + st.calls.push("Reapply".into()); + Ok(()) + } +} + +struct FakeWireless { + shared: Shared, + path: String, +} + +#[interface(name = "org.freedesktop.NetworkManager.Device.Wireless")] +impl FakeWireless { + #[zbus(property)] + fn active_access_point(&self) -> fdo::Result { + let st = self.shared.state.lock().unwrap(); + Ok(st.devices[&self.path] + .active_ap + .as_ref() + .map(|p| obj(p)) + .unwrap_or_else(|| obj("/"))) + } + + async fn get_all_access_points(&self) -> fdo::Result> { + let st = self.shared.state.lock().unwrap(); + Ok(st + .dev_aps + .get(&self.path) + .map(|aps| aps.iter().map(|p| obj(p)).collect()) + .unwrap_or_default()) + } + + async fn request_scan(&self, _options: HashMap) -> fdo::Result<()> { + let mut st = self.shared.state.lock().unwrap(); + st.calls.push("RequestScan".into()); + Ok(()) + } +} + +struct FakeIp4 { + shared: Shared, + /// Device path (so the fake can find the device's IP). + dev: String, +} + +#[interface(name = "org.freedesktop.NetworkManager.IP4Config")] +impl FakeIp4 { + #[zbus(property)] + fn addresses(&self) -> fdo::Result> { + let st = self.shared.state.lock().unwrap(); + // A fixed, recognizable address: 192.168.1.42/24, gw .1. + let _ = &st.devices[&self.dev]; + Ok(vec![(0xC0A8012A, 24, 0xC0A80101)]) + } +} + +// --------------------------------------------------------------------- +// Connection.Active +// --------------------------------------------------------------------- + +struct FakeActive { + shared: Shared, + path: String, +} + +#[interface(name = "org.freedesktop.NetworkManager.Connection.Active")] +impl FakeActive { + #[zbus(property)] + fn connection(&self) -> fdo::Result { + let st = self.shared.state.lock().unwrap(); + Ok(obj(&st.active_conns[&self.path])) + } +} + +// --------------------------------------------------------------------- +// Harness +// --------------------------------------------------------------------- + +/// A private `dbus-daemon`. Dropping it kills the daemon. +pub struct Daemon { + pub addr: String, + child: Child, +} + +impl Drop for Daemon { + fn drop(&mut self) { + let _ = self.child.kill(); + let _ = self.child.wait(); + } +} + +static DBUS_COUNTER: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0); + +/// Start a private `dbus-daemon` with permissive policies. Nothing is +/// registered on it — attach the fake NM with [`serve_on`] if the test +/// needs NetworkManager to be present. +pub fn launch_daemon() -> Daemon { + let n = DBUS_COUNTER.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + let dir = std::env::temp_dir().join(format!( + "breadcrumbs-dbus-{}-{}", + std::process::id(), + n + )); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).expect("create dbus dir"); + let config = dir.join("bus.conf"); + std::fs::write(&config, BUS_CONFIG).expect("write bus config"); + + let mut child = Command::new("dbus-daemon") + .arg("--nofork") + .arg("--nopidfile") + .arg(format!("--config-file={}", config.display())) + .arg("--print-address=1") + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn() + .expect("dbus-daemon must be installed to run the NetworkManager fake tests"); + let mut line = String::new(); + std::io::BufReader::new(child.stdout.take().expect("dbus stdout")) + .read_line(&mut line) + .expect("read dbus-daemon address"); + let addr = line + .trim() + .split(';') + .next() + .expect("address") + .to_string(); + Daemon { addr, child } +} + +/// A fake NM service served on an existing (usually private) bus. +pub struct FakeNmBus { + pub addr: String, + pub state: Arc>, + server: zbus::blocking::Connection, +} + +const BUS_CONFIG: &str = r#" + + session + unix:tmpdir=/tmp + + + + + + +"#; + +/// Serve the fake NetworkManager on the bus at `addr` and claim its name. +/// Subprocesses pointed at the same address via `DBUS_SYSTEM_BUS_ADDRESS` +/// will see this as their system NetworkManager. +pub fn serve_on(addr: &str) -> FakeNmBus { + let conn = zbus::blocking::connection::Builder::address(addr) + .expect("connect to private bus") + .build() + .expect("build blocking connection"); + let state = Arc::new(Mutex::new(FakeState { + connectivity: 4, + wireless_enabled: true, + next_dev: 1, + next_ap: 1, + next_conn: 1, + next_active: 1, + ..Default::default() + })); + let shared = Shared { + state: state.clone(), + }; + + conn.object_server() + .at(NM_PATH, FakeNm { + shared: shared.clone(), + }) + .expect("register fake NM"); + conn.object_server() + .at(SETTINGS_PATH, FakeSettings { + shared: shared.clone(), + }) + .expect("register fake settings"); + conn.request_name(NM_DEST).expect("claim NM name"); + + FakeNmBus { + addr: addr.to_string(), + state, + server: conn, + } +} + +impl FakeNmBus { + pub fn addr(&self) -> &str { + &self.addr + } + + pub fn reset(&self) { + let mut st = self.state.lock().unwrap(); + st.devices.clear(); + st.aps.clear(); + st.dev_aps.clear(); + st.connections.clear(); + st.active_conns.clear(); + st.dev_active.clear(); + st.connectivity = 4; + st.wireless_enabled = true; + st.land_on = None; + st.connect_any = false; + st.fail_next_activations = 0; + st.calls.clear(); + st.next_dev = 1; + st.next_ap = 1; + st.next_conn = 1; + st.next_active = 1; + } + + /// Add a Wi-Fi device; returns its object path. `state` is the device + /// state (e.g. 100 = ACTIVATED). + pub fn add_wifi_device(&self, iface: &str, state: u32) -> String { + let (dev_path, ip4_path) = { + let mut st = self.state.lock().unwrap(); + let id = st.next_dev; + st.next_dev += 1; + let dev_path = format!("{DEVICES_PREFIX}/{id}"); + let ip4_path = format!("{IP4_PREFIX}/{id}"); + st.devices.insert( + dev_path.clone(), + FakeDeviceData { + iface: iface.to_string(), + dev_type: DEV_TYPE_WIFI, + state, + active_ap: None, + ip4: ip4_path.clone(), + }, + ); + (dev_path, ip4_path) + }; + self.server + .object_server() + .at( + dev_path.as_str(), + FakeDevice { + shared: self.shared(), + path: dev_path.clone(), + }, + ) + .expect("register device"); + self.server + .object_server() + .at( + dev_path.as_str(), + FakeWireless { + shared: self.shared(), + path: dev_path.clone(), + }, + ) + .expect("register wireless"); + self.server + .object_server() + .at( + ip4_path.as_str(), + FakeIp4 { + shared: self.shared(), + dev: dev_path.clone(), + }, + ) + .expect("register ip4"); + dev_path + } + + fn shared(&self) -> Shared { + Shared { + state: self.state.clone(), + } + } + + /// Add an access point to a device; returns its object path. + pub fn add_ap(&self, dev: &str, ssid: &str, strength: u8, sec: Security) -> String { + let (ap_path, flags, wpa, rsn) = { + let mut st = self.state.lock().unwrap(); + let id = st.next_ap; + st.next_ap += 1; + let ap_path = format!("{APS_PREFIX}/{id}"); + let (flags, wpa, rsn) = sec.to_flags(); + st.aps.insert( + ap_path.clone(), + FakeApData { + ssid: ssid.as_bytes().to_vec(), + strength, + flags, + wpa, + rsn, + }, + ); + st.dev_aps.entry(dev.to_string()).or_default().push(ap_path.clone()); + (ap_path, flags, wpa, rsn) + }; + let _ = (flags, wpa, rsn); + self.server + .object_server() + .at( + ap_path.as_str(), + FakeAp { + shared: self.shared(), + path: ap_path.clone(), + }, + ) + .expect("register AP"); + ap_path + } + + pub fn set_active_ap(&self, dev: &str, ap: &str) { + let mut st = self.state.lock().unwrap(); + if let Some(d) = st.devices.get_mut(dev) { + d.active_ap = Some(ap.to_string()); + d.state = DEV_STATE_ACTIVATED; + } + } + + /// When set, any activation lands the device on the AP with this SSID + /// (simulating an NM autoconnect race). + pub fn set_land_on(&self, ssid: Option<&str>) { + self.state.lock().unwrap().land_on = ssid.map(str::to_string); + } + + /// When enabled, connecting to a network with no visible AP creates one + /// (hidden-network semantics). + pub fn set_connect_any(&self, on: bool) { + self.state.lock().unwrap().connect_any = on; + } + + /// Make the next `n` activation attempts fail (transient-failure + /// simulation, e.g. for `init --wait` retries). + pub fn fail_next_activations(&self, n: u32) { + self.state.lock().unwrap().fail_next_activations = n; + } + + pub fn set_connectivity(&self, c: u32) { + self.state.lock().unwrap().connectivity = c; + } + + pub fn set_device_state(&self, dev: &str, state: u32) { + let mut st = self.state.lock().unwrap(); + if let Some(d) = st.devices.get_mut(dev) { + d.state = state; + } + } + + /// Save a wireless connection profile (as `nm` would create one) and + /// return its path. + pub fn save_connection(&self, ssid: &str, password: Option<&str>) -> String { + let mut settings: SettingsMap = HashMap::new(); + let mut conn: HashMap = HashMap::new(); + conn.insert("id".into(), ov(Value::from(ssid.to_string()))); + conn.insert("type".into(), ov(Value::from("802-11-wireless"))); + conn.insert( + "uuid".into(), + ov(Value::from("00000000-0000-4000-8000-000000000001")), + ); + settings.insert("connection".into(), conn); + + let mut wifi: HashMap = HashMap::new(); + wifi.insert("ssid".into(), ov(Value::from(ssid.as_bytes().to_vec()))); + wifi.insert("mode".into(), ov(Value::from("infrastructure"))); + settings.insert("802-11-wireless".into(), wifi); + + if let Some(pw) = password { + let mut sec: HashMap = HashMap::new(); + sec.insert("key-mgmt".into(), ov(Value::from("wpa-psk"))); + sec.insert("psk".into(), ov(Value::from(pw.to_string()))); + settings.insert("802-11-wireless-security".into(), sec); + } + + let mut ipv4: HashMap = HashMap::new(); + ipv4.insert("method".into(), ov(Value::from("auto"))); + settings.insert("ipv4".into(), ipv4); + + let path = { + let mut st = self.state.lock().unwrap(); + st.calls.push(format!("save {ssid}")); + let path = format!("{CONNS_PREFIX}/{}", st.next_conn); + st.next_conn += 1; + st.connections.insert(path.clone(), settings); + path + }; + self.server + .object_server() + .at( + path.as_str(), + FakeConn { + shared: self.shared(), + path: path.clone(), + }, + ) + .expect("register saved connection"); + path + } + + pub fn calls(&self) -> Vec { + self.state.lock().unwrap().calls.clone() + } + + pub fn connection_count(&self) -> usize { + self.state.lock().unwrap().connections.len() + } + + pub fn device_state(&self, dev: &str) -> u32 { + self.state.lock().unwrap().devices.get(dev).map(|d| d.state).unwrap_or(0) + } + + pub fn active_ssid(&self, dev: &str) -> Option { + let st = self.state.lock().unwrap(); + let ap = st.devices.get(dev)?.active_ap.clone()?; + st.aps.get(&ap).map(|a| String::from_utf8_lossy(&a.ssid).into_owned()) + } + + /// SSIDs of every connection activated so far, in activation order. + pub fn activated_ssids(&self) -> Vec { + let st = self.state.lock().unwrap(); + st.active_conns + .values() + .filter_map(|p| st.connections.get(p).and_then(conn_ssid)) + .collect() + } +} + +// --------------------------------------------------------------------- +// Shared in-process bus (flow_watch tests) +// --------------------------------------------------------------------- + +struct SharedBus { + _daemon: Daemon, + bus: FakeNmBus, +} + +static SHARED: OnceLock> = OnceLock::new(); + +/// The process-wide fake NM bus for in-process tests. The env var +/// `DBUS_SYSTEM_BUS_ADDRESS` is pointed at it once, so the production +/// `nm` module (which uses `Connection::system()`) reaches it with zero +/// test seams. Tests using this must serialize against each other — the +/// returned guard holds the bus's lock for its whole lifetime. +pub struct SharedNm { + guard: MutexGuard<'static, SharedBus>, +} + +impl std::ops::Deref for SharedNm { + type Target = FakeNmBus; + fn deref(&self) -> &FakeNmBus { + &self.guard.bus + } +} + +pub fn shared() -> SharedNm { + let bus = SHARED.get_or_init(|| { + let daemon = launch_daemon(); + let bus = serve_on(&daemon.addr); + std::env::set_var("DBUS_SYSTEM_BUS_ADDRESS", &daemon.addr); + Mutex::new(SharedBus { _daemon: daemon, bus }) + }); + let guard = bus.lock().unwrap_or_else(|e| e.into_inner()); + SharedNm { guard } +} + +/// Convenience guard used by tests that only need the bus available (no +/// state control) — e.g. classify tests that merely observe "no adapter". +/// Ensures the shared bus is up (and the env var set) before any `nm` +/// call happens. +pub fn ensure_shared() -> SharedNm { + shared() +} diff --git a/tests/common/mod.rs b/tests/common/mod.rs new file mode 100644 index 0000000..704a989 --- /dev/null +++ b/tests/common/mod.rs @@ -0,0 +1,252 @@ +//! Shared test infrastructure for in-process integration tests (as opposed +//! to `tests/cli.rs`'s black-box `Sandbox`, which spawns the compiled +//! binary). This module is `mod`-included by each test file that needs it — +//! see `tests/flow_watch.rs`. +//! +//! Two pieces: +//! +//! - [`FakeRunner`]: a `breadcrumbs::util::Runner` implementation driven by +//! rules ("if the program+args match this predicate, return this canned +//! `Output`"), which also records every invocation so a test can assert +//! exactly what was — or, just as importantly, was *not* — passed (e.g. +//! that a password argument never reaches a fake subprocess). +//! - [`EnvSandbox`]: real logic (`flow::run`, `watch::classify`) still does +//! its own best-effort file logging via `notify::log`, which resolves a +//! path from `$HOME`/`$XDG_STATE_HOME`. `EnvSandbox` points those at a +//! throwaway tempdir for the duration of a test so nothing lands in the +//! developer's real `~/.local/state/breadcrumbs`. Mutating process env is +//! inherently cross-test-within-this-binary racy, so it's guarded by a +//! process-wide mutex — tests using it serialize against each other but +//! not against unrelated tests (each `tests/*.rs` file is its own binary). +//! - [`fake_nm`]: a real fake NetworkManager D-Bus service on a private +//! `dbus-daemon`. The production `nm` module talks to it over real D-Bus +//! marshalling (`Connection::system()` honors `DBUS_SYSTEM_BUS_ADDRESS`), +//! replacing the old fake-`nmcli`-argv rules. + +#![allow(dead_code)] // not every test file uses every helper here + +pub mod fake_nm; + +use std::cell::RefCell; +use std::collections::HashSet; +use std::path::{Path, PathBuf}; +use std::rc::Rc; +use std::sync::atomic::{AtomicU32, Ordering}; +use std::sync::{Mutex, MutexGuard, OnceLock}; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +use breadcrumbs::util::{Output, Runner}; + +/// One recorded call to the fake `Runner::run`. +#[derive(Debug, Clone)] +pub struct RecordedCall { + pub prog: String, + pub args: Vec, + pub stdin: Option, +} + +impl RecordedCall { + /// Convenience for glob-style assertions, e.g. + /// `call.argv().contains(&"connection")`. + pub fn argv(&self) -> Vec<&str> { + std::iter::once(self.prog.as_str()) + .chain(self.args.iter().map(String::as_str)) + .collect() + } +} + +type Matcher = Box bool>; +type DynamicRule = (Matcher, Box Output>); + +/// A canned, rule-based [`Runner`]. Rules are tried in registration order; +/// the first whose matcher returns `true` supplies the response. No rule +/// matching falls back to [`Output::failed`] — the same "closed" default the +/// old empty-`PATH` sandbox relied on, so an un-anticipated call fails loud +/// (a wrong exit code) rather than silently returning success. +pub struct FakeRunner { + rules: Vec<(Matcher, Output)>, + dynamic_rules: Vec, + commands: HashSet, + calls: Rc>>, +} + +impl FakeRunner { + pub fn new() -> Self { + FakeRunner { + rules: Vec::new(), + dynamic_rules: Vec::new(), + commands: HashSet::new(), + calls: Rc::new(RefCell::new(Vec::new())), + } + } + + /// Handle to inspect recorded calls after the runner has been consumed by + /// [`breadcrumbs::util::with_runner`] (which takes it by value). + pub fn calls_handle(&self) -> Rc>> { + self.calls.clone() + } + + /// Make `breadcrumbs::util::command_exists(name)` report present. + pub fn with_command(mut self, name: &str) -> Self { + self.commands.insert(name.to_string()); + self + } + + /// Register a canned response: the first registered matcher that returns + /// `true` for a given `(prog, args)` supplies the `Output`. + pub fn on(mut self, matcher: impl Fn(&str, &[&str]) -> bool + 'static, output: Output) -> Self { + self.rules.push((Box::new(matcher), output)); + self + } + + /// Shorthand for matching on `prog` plus a whitespace-joined view of + /// `args` containing `substr` (handy for `tailscale`/`curl` calls, whose + /// interesting bit is usually a subcommand somewhere in the middle). + pub fn on_contains(self, prog: &'static str, substr: &'static str, output: Output) -> Self { + self.on( + move |p, args| p == prog && args.join(" ").contains(substr), + output, + ) + } + + /// Register a rule whose *response* is computed at call time (rather + /// than canned), enabling stateful fakes — e.g. answering "which SSID is + /// active?" with the SSID of the most recently dialed connection. + /// Dynamic rules are tried after the static ones. + pub fn on_dynamic( + mut self, + matcher: impl Fn(&str, &[&str]) -> bool + 'static, + out: impl Fn(&str, &[&str]) -> Output + 'static, + ) -> Self { + self.dynamic_rules + .push((Box::new(matcher), Box::new(out))); + self + } +} + +impl Default for FakeRunner { + fn default() -> Self { + Self::new() + } +} + +impl Runner for FakeRunner { + fn run(&self, prog: &str, args: &[&str], stdin: Option<&str>, _timeout: Duration) -> Output { + self.calls.borrow_mut().push(RecordedCall { + prog: prog.to_string(), + args: args.iter().map(|s| s.to_string()).collect(), + stdin: stdin.map(|s| s.to_string()), + }); + for (matcher, out) in &self.rules { + if matcher(prog, args) { + return out.clone(); + } + } + for (matcher, out) in &self.dynamic_rules { + if matcher(prog, args) { + return out(prog, args); + } + } + Output::failed() + } + + fn command_exists(&self, name: &str) -> bool { + self.commands.contains(name) + } +} + +pub fn ok(stdout: &str) -> Output { + Output { + success: true, + stdout: stdout.to_string(), + stderr: String::new(), + } +} + +pub fn ok_empty() -> Output { + ok("") +} + +pub fn fail(stderr: &str) -> Output { + Output { + success: false, + stdout: String::new(), + stderr: stderr.to_string(), + } +} + +fn env_lock() -> &'static Mutex<()> { + static LOCK: OnceLock> = OnceLock::new(); + LOCK.get_or_init(|| Mutex::new(())) +} + +static SANDBOX_COUNTER: AtomicU32 = AtomicU32::new(0); + +/// Points `HOME` / `XDG_CONFIG_HOME` / `XDG_STATE_HOME` at a throwaway +/// tempdir for its lifetime, so any real filesystem side effect +/// (`notify::log`'s best-effort log file, `Config::save`, …) that in-process +/// logic performs during a test lands there instead of the developer's real +/// home directory. Holds a process-wide lock for its lifetime — construct +/// one per test, drop it (or let it go out of scope) before the test ends. +pub struct EnvSandbox { + _guard: MutexGuard<'static, ()>, + root: PathBuf, + prev: Vec<(&'static str, Option)>, +} + +const ENV_VARS: [&str; 3] = ["HOME", "XDG_CONFIG_HOME", "XDG_STATE_HOME"]; + +impl EnvSandbox { + pub fn new() -> Self { + let guard = env_lock().lock().unwrap_or_else(|e| e.into_inner()); + + let n = SANDBOX_COUNTER.fetch_add(1, Ordering::SeqCst); + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let root = std::env::temp_dir().join(format!( + "breadcrumbs-inproc-{}-{}-{}", + std::process::id(), + n, + nanos + )); + std::fs::create_dir_all(&root).expect("create EnvSandbox root"); + + let prev: Vec<(&'static str, Option)> = ENV_VARS + .iter() + .map(|v| (*v, std::env::var(v).ok())) + .collect(); + std::env::set_var("HOME", &root); + std::env::set_var("XDG_CONFIG_HOME", root.join("config")); + std::env::set_var("XDG_STATE_HOME", root.join("state")); + + EnvSandbox { + _guard: guard, + root, + prev, + } + } + + pub fn root(&self) -> &Path { + &self.root + } +} + +impl Default for EnvSandbox { + fn default() -> Self { + Self::new() + } +} + +impl Drop for EnvSandbox { + fn drop(&mut self) { + for (k, v) in &self.prev { + match v { + Some(val) => std::env::set_var(k, val), + None => std::env::remove_var(k), + } + } + let _ = std::fs::remove_dir_all(&self.root); + } +} diff --git a/tests/flow_watch.rs b/tests/flow_watch.rs new file mode 100644 index 0000000..eadeb0e --- /dev/null +++ b/tests/flow_watch.rs @@ -0,0 +1,968 @@ +//! In-process tests for the actual state machine (`flow::run`) and the watch +//! loop's health classification (`watch::classify`). NetworkManager is a real +//! fake NM D-Bus service on a private bus (see `tests/common::fake_nm`), so +//! every `nm` call is exercised over genuine D-Bus marshalling. Everything +//! else (tailscale, curl/ping, notify) is faked through a +//! `breadcrumbs::util::Runner` (see `tests/common`). This complements +//! `tests/cli.rs`'s black-box coverage (which spawns the real binary) with +//! fast, precise coverage of the logic itself: candidate priority order, the +//! bootstrap+Tailscale gate, and every `watch::Health` transition. + +mod common; + +use std::collections::BTreeMap; + +use bread_utils::bread_client::BreadEvent; +use breadcrumbs::bread_events; +use breadcrumbs::config::{Config, NetworkDef, Profile, Settings}; +use breadcrumbs::flow; +use breadcrumbs::state::{self, State}; +use breadcrumbs::util::with_runner; +use breadcrumbs::watch::{classify, Health}; + +use common::fake_nm::{self, Security, SharedNm}; +use common::{fail, ok, EnvSandbox, FakeRunner}; + +fn net(ssid: &str, password: Option<&str>) -> NetworkDef { + NetworkDef { + ssid: ssid.to_string(), + password: password.map(str::to_string), + dns: None, + eap: None, + identity: None, + ca_cert: None, + hidden: false, + } +} + +fn hidden_net(ssid: &str, password: Option<&str>) -> NetworkDef { + NetworkDef { + ssid: ssid.to_string(), + password: password.map(str::to_string), + dns: None, + eap: None, + identity: None, + ca_cert: None, + hidden: true, + } +} + +fn base_config() -> Config { + Config { + settings: Settings::default(), + networks: Vec::new(), + profiles: BTreeMap::new(), + } +} + +/// Reset the shared fake-NM bus and put a Wi-Fi device on it with one AP per +/// SSID at the given signal strength. Returns the bus guard (held for the +/// whole test so tests serialize) and the device path. +fn setup_wifi(ssids: &[(&str, u8)]) -> (SharedNm, String) { + let nm = fake_nm::shared(); + nm.reset(); + let dev = nm.add_wifi_device("wlan0", 100); + for (ssid, strength) in ssids { + nm.add_ap(&dev, ssid, *strength, Security::Wpa2); + } + (nm, dev) +} + +/// A runner that fakes the non-NM subprocesses a successful `flow::run` +/// needs: curl (internet check) and nothing else. +fn healthy_runner() -> FakeRunner { + FakeRunner::new() + .with_command("curl") + .on(|prog, _| prog == "curl", ok("204")) +} + +/// The runner used by `classify` tests: internet check + optional tailscale. +fn classify_runner(curl: &str, tailscale_status: Option<&str>) -> FakeRunner { + let mut r = FakeRunner::new().with_command("curl").on(|p, _| p == "curl", ok(curl)); + if let Some(json) = tailscale_status { + r = r + .with_command("tailscale") + .on(|p, args| p == "tailscale" && args.contains(&"status"), ok(json)); + } + r +} + +/// Make the device report being associated with `ssid` (for classify tests). +fn associate(nm: &SharedNm, dev: &str, ssid: &str) { + let ap = nm.add_ap(dev, ssid, 80, Security::Wpa2); + nm.set_active_ap(dev, &ap); +} + +fn tailscale_json_ok(exit_node: &str) -> String { + format!( + r#"{{"BackendState":"Running","Peer":{{"k1":{{"HostName":"{exit_node}","DNSName":"{exit_node}.ts.net.","Online":true,"ExitNode":true,"ExitNodeOption":true}}}}}}"# + ) +} + +fn tailscale_json_missing() -> &'static str { + r#"{"BackendState":"Running","Peer":{}}"# +} + +// --------------------------------------------------------------------- +// flow::run — candidate priority (pass 1 / pass 2) +// --------------------------------------------------------------------- + +#[test] +fn flow_run_connects_to_first_visible_candidate_in_priority_order() { + let _env = EnvSandbox::new(); + let (nm, _dev) = setup_wifi(&[("First", 80), ("Second", 80)]); + + let mut cfg = base_config(); + cfg.networks = vec![net("First", Some("pw1")), net("Second", Some("pw2"))]; + cfg.profiles.insert( + "home".into(), + Profile { + networks: vec!["First".into(), "Second".into()], + ..Default::default() + }, + ); + + let outcome = with_runner(healthy_runner(), || flow::run(&mut cfg, "home")); + + match outcome { + flow::Outcome::Connected { ssid, note } => { + assert_eq!(ssid, "First"); + assert_eq!(note, None); + } + other => panic!("expected Connected, got {other:?}"), + } + + // Priority order actually mattered: "Second" was never activated even + // though it was visible and would have succeeded too. + assert_eq!( + nm.activated_ssids(), + vec!["First".to_string()], + "Second must not be dialed when First wins" + ); + + // The password used for the winning connect is now NM's problem, not + // breadcrumbs' — cleared and (via clear_password_if_used) persisted. + assert_eq!(cfg.network("First").unwrap().password, None); + // Never touched, so its password is untouched too. + assert_eq!( + cfg.network("Second").unwrap().password, + Some("pw2".to_string()) + ); +} + +#[test] +fn flow_run_pass2_falls_back_to_hidden_candidate_not_in_scan() { + let _env = EnvSandbox::new(); + // Nothing is visible; connecting creates the AP on the fly (hidden + // networks appear only after association). + let (nm, _dev) = setup_wifi(&[]); + nm.set_connect_any(true); + + let mut cfg = base_config(); + // "Ghost" is neither visible nor hidden, so pass 1 *and* pass 2 both + // skip it outright — it should never be dialed. + cfg.networks = vec![net("Ghost", Some("pw-ghost")), hidden_net("Shadow", Some("pw-shadow"))]; + cfg.profiles.insert( + "away".into(), + Profile { + networks: vec!["Ghost".into(), "Shadow".into()], + ..Default::default() + }, + ); + + let outcome = with_runner(healthy_runner(), || flow::run(&mut cfg, "away")); + + match outcome { + flow::Outcome::Connected { ssid, .. } => assert_eq!(ssid, "Shadow"), + other => panic!("expected Connected to Shadow, got {other:?}"), + } + assert_eq!( + nm.activated_ssids(), + vec!["Shadow".to_string()], + "Ghost must never have been dialed" + ); +} + +#[test] +fn flow_run_unknown_profile_short_circuits_before_touching_nm() { + let _env = EnvSandbox::new(); + let nm = fake_nm::shared(); + nm.reset(); + let mut cfg = base_config(); + + let runner = FakeRunner::new(); // no rules at all + + let outcome = with_runner(runner, || flow::run(&mut cfg, "does-not-exist")); + + assert!(matches!(outcome, flow::Outcome::UnknownProfile(p) if p == "does-not-exist")); + // The fake NetworkManager must never be touched for a profile that + // doesn't exist (no devices, no scans, no activations). + assert!( + nm.calls().is_empty(), + "unknown-profile path should never call NetworkManager: {:?}", + nm.calls() + ); +} + +// --------------------------------------------------------------------- +// flow::run — bootstrap + Tailscale gating +// --------------------------------------------------------------------- + +#[test] +fn flow_run_moves_past_bootstrap_once_tailscale_is_healthy() { + let _env = EnvSandbox::new(); + let (nm, _dev) = setup_wifi(&[("Guest", 80), ("Corp", 80)]); + + let mut cfg = base_config(); + cfg.settings.exit_node = "exitnode".into(); + cfg.networks = vec![net("Guest", Some("guest-pw")), net("Corp", Some("corp-pw"))]; + cfg.profiles.insert( + "work".into(), + Profile { + bootstrap: Some("Guest".into()), + networks: vec!["Corp".into()], + tailscale: true, + ..Default::default() + }, + ); + + let runner = healthy_runner() + .with_command("tailscale") + .on_contains("tailscale", "status", ok(&tailscale_json_ok("exitnode"))) + .on_contains("tailscale", "set", ok("")); + + let outcome = with_runner(runner, || flow::run(&mut cfg, "work")); + + match outcome { + flow::Outcome::Connected { ssid, .. } => assert_eq!(ssid, "Corp"), + other => panic!("expected Connected to Corp, got {other:?}"), + } + // Both the bootstrap and target connects used a local password, so both + // should have been cleared once NetworkManager took over. + assert_eq!(cfg.network("Guest").unwrap().password, None); + assert_eq!(cfg.network("Corp").unwrap().password, None); + + assert_eq!( + nm.activated_ssids(), + vec!["Guest".to_string(), "Corp".to_string()], + "bootstrap must be dialed before the target" + ); +} + +#[test] +fn flow_run_stays_on_bootstrap_and_never_dials_target_when_tailscale_unhealthy() { + let _env = EnvSandbox::new(); + let (nm, _dev) = setup_wifi(&[("Guest", 80), ("Corp", 80)]); + + let mut cfg = base_config(); + cfg.settings.exit_node = "exitnode".into(); + cfg.networks = vec![net("Guest", Some("guest-pw")), net("Corp", Some("corp-pw"))]; + cfg.profiles.insert( + "work".into(), + Profile { + bootstrap: Some("Guest".into()), + networks: vec!["Corp".into()], + tailscale: true, + ..Default::default() + }, + ); + + let runner = healthy_runner() + .with_command("tailscale") + .on(|p, args| p == "tailscale" && args.contains(&"status"), ok(tailscale_json_missing())) + .on(|p, args| p == "tailscale" && args.contains(&"set"), ok("")); + + let outcome = with_runner(runner, || flow::run(&mut cfg, "work")); + + match &outcome { + flow::Outcome::TailscaleError { ssid, health } => { + assert_eq!(ssid.as_deref(), Some("Guest")); + assert_eq!(*health, breadcrumbs::tailscale::TsHealth::ExitNodeMissing); + } + other => panic!("expected TailscaleError, got {other:?}"), + } + + assert_eq!( + nm.activated_ssids(), + vec!["Guest".to_string()], + "target network must never be dialed while Tailscale is unhealthy" + ); + // The bootstrap connect *did* use a password and succeeded, so it's + // cleared even though the overall flow ends in an error. + assert_eq!(cfg.network("Guest").unwrap().password, None); +} + +// --------------------------------------------------------------------- +// watch::classify — health-state transitions +// --------------------------------------------------------------------- + +#[test] +fn classify_reports_unknown_profile_without_touching_nm() { + let _env = EnvSandbox::new(); + let nm = fake_nm::shared(); + nm.reset(); + let cfg = base_config(); // no profiles at all + + let runner = FakeRunner::new(); + let calls = runner.calls_handle(); + let class = with_runner(runner, || classify(&cfg, "ghost")); + + assert_eq!(class.health, Health::UnknownProfile); + assert_eq!(class.ssid, None); + assert!(calls.borrow().is_empty()); + assert!( + nm.calls().is_empty(), + "unknown-profile classify must not touch NetworkManager" + ); +} + +#[test] +fn classify_reports_no_adapter_when_wifi_interface_absent() { + let _env = EnvSandbox::new(); + let nm = fake_nm::shared(); + nm.reset(); // no devices at all + let mut cfg = base_config(); + cfg.profiles.insert("away".into(), Profile::default()); + + let class = with_runner(FakeRunner::new(), || classify(&cfg, "away")); + assert_eq!(class.health, Health::NoAdapter); +} + +#[test] +fn classify_reports_down_no_net_when_internet_check_fails() { + let _env = EnvSandbox::new(); + let (nm, dev) = setup_wifi(&[]); + associate(&nm, &dev, "HomeWifi"); + let mut cfg = base_config(); + cfg.profiles.insert("away".into(), Profile::default()); + + let runner = FakeRunner::new().on(|prog, _| prog == "curl" || prog == "ping", fail("")); + let class = with_runner(runner, || classify(&cfg, "away")); + + assert_eq!(class.health, Health::DownNoNet); + assert_eq!(class.ssid, Some("HomeWifi".to_string())); +} + +#[test] +fn classify_reports_up_when_healthy_and_tailscale_not_required() { + let _env = EnvSandbox::new(); + let (nm, dev) = setup_wifi(&[]); + associate(&nm, &dev, "HomeWifi"); + let mut cfg = base_config(); + cfg.profiles.insert("home".into(), Profile::default()); // tailscale: false + + let class = with_runner(healthy_runner(), || classify(&cfg, "home")); + + assert_eq!(class.health, Health::Up); + assert_eq!(class.ssid, Some("HomeWifi".to_string())); +} + +#[test] +fn classify_reports_down_tailscale_manual_when_not_installed() { + let _env = EnvSandbox::new(); + let (nm, dev) = setup_wifi(&[]); + associate(&nm, &dev, "CorpWifi"); + let mut cfg = base_config(); + cfg.profiles.insert( + "work".into(), + Profile { + tailscale: true, + ..Default::default() + }, + ); + + // No `with_command("tailscale")`, so `tailscale::installed()` is false. + let class = with_runner(healthy_runner(), || classify(&cfg, "work")); + + assert_eq!(class.health, Health::DownTailscaleManual); +} + +#[test] +fn classify_reports_down_tailscale_manual_when_needs_login() { + let _env = EnvSandbox::new(); + let (nm, dev) = setup_wifi(&[]); + associate(&nm, &dev, "CorpWifi"); + let mut cfg = base_config(); + cfg.profiles.insert( + "work".into(), + Profile { + tailscale: true, + ..Default::default() + }, + ); + + let runner = classify_runner("204", Some(r#"{"BackendState":"NeedsLogin"}"#)); + let class = with_runner(runner, || classify(&cfg, "work")); + + assert_eq!(class.health, Health::DownTailscaleManual); +} + +#[test] +fn classify_reports_down_tailscale_other_when_exit_node_offline() { + let _env = EnvSandbox::new(); + let (nm, dev) = setup_wifi(&[]); + associate(&nm, &dev, "CorpWifi"); + let mut cfg = base_config(); + cfg.settings.exit_node = "exitnode".into(); + cfg.profiles.insert( + "work".into(), + Profile { + tailscale: true, + ..Default::default() + }, + ); + + let json = r#"{"BackendState":"Running","Peer":{"k1":{"HostName":"exitnode","Online":false,"ExitNode":false,"ExitNodeOption":true}}}"#; + let runner = classify_runner("204", Some(json)); + let class = with_runner(runner, || classify(&cfg, "work")); + + assert_eq!(class.health, Health::DownTailscaleOther); +} + +#[test] +fn classify_reports_up_when_tailscale_healthy() { + let _env = EnvSandbox::new(); + let (nm, dev) = setup_wifi(&[]); + associate(&nm, &dev, "CorpWifi"); + let mut cfg = base_config(); + cfg.settings.exit_node = "exitnode".into(); + cfg.profiles.insert( + "work".into(), + Profile { + tailscale: true, + ..Default::default() + }, + ); + + let runner = classify_runner("204", Some(&tailscale_json_ok("exitnode"))); + let class = with_runner(runner, || classify(&cfg, "work")); + + assert_eq!(class.health, Health::Up); +} + +// --------------------------------------------------------------------- +// bread.command.crumbs.set_profile — persists via the same path as the +// CLI, and must not depend on breadd being reachable (`emit` is +// fire-and-forget). +// --------------------------------------------------------------------- + +fn command_event(event: &str, data: serde_json::Value) -> BreadEvent { + BreadEvent { + event: event.to_string(), + timestamp: 0, + data, + } +} + +#[test] +fn set_profile_command_persists_even_with_no_daemon_reachable() { + let _env = EnvSandbox::new(); + let cfg = Config::load().expect("fresh config"); + state::set_profile(&cfg, "away").unwrap(); + assert_eq!(State::load("away").profile, "away"); + + // handle_command only parses/validates (no file I/O on the subscription + // thread); the loop thread then applies the action. + let action = bread_events::handle_command(&command_event( + "bread.command.crumbs.set_profile", + serde_json::json!({ "profile": "home" }), + )); + assert!( + matches!(action, bread_events::CommandAction::SetProfile(n) if n == "home"), + "a known profile must yield a SetProfile action" + ); + bread_events::apply_set_profile("home"); + assert_eq!(State::load("away").profile, "home"); +} + +#[test] +fn set_profile_command_rejects_unknown_profile() { + let _env = EnvSandbox::new(); + let cfg = Config::load().expect("fresh config"); + state::set_profile(&cfg, "away").unwrap(); + + let action = bread_events::handle_command(&command_event( + "bread.command.crumbs.set_profile", + serde_json::json!({ "profile": "bogus" }), + )); + assert!(matches!(action, bread_events::CommandAction::SetProfile(n) if n == "bogus")); + // The rejection happens when the loop thread applies it: state is + // untouched and the failure event is emitted (a no-op without breadd). + bread_events::apply_set_profile("bogus"); + assert_eq!( + State::load("away").profile, + "away", + "a rejected set_profile must not touch state" + ); +} + +#[test] +fn set_profile_command_rejects_missing_profile_field() { + let _env = EnvSandbox::new(); + let cfg = Config::load().expect("fresh config"); + state::set_profile(&cfg, "away").unwrap(); + + let action = bread_events::handle_command(&command_event( + "bread.command.crumbs.set_profile", + serde_json::json!({}), + )); + assert!(matches!(action, bread_events::CommandAction::Ignore)); + assert_eq!(State::load("away").profile, "away"); +} + +#[test] +fn handle_command_ignores_unrecognized_verb() { + let _env = EnvSandbox::new(); + let cfg = Config::load().expect("fresh config"); + state::set_profile(&cfg, "away").unwrap(); + + let action = bread_events::handle_command(&command_event( + "bread.command.crumbs.pin", + serde_json::json!({}), + )); + assert!(matches!(action, bread_events::CommandAction::Ignore)); + assert_eq!( + State::load("away").profile, + "away", + "an unrecognized verb must not touch state" + ); +} + +#[test] +fn handle_command_ignores_events_outside_its_own_command_namespace() { + let _env = EnvSandbox::new(); + let cfg = Config::load().expect("fresh config"); + state::set_profile(&cfg, "away").unwrap(); + + assert!(matches!( + bread_events::handle_command(&command_event( + "bread.command.clip.clear", + serde_json::json!({}), + )), + bread_events::CommandAction::Ignore + )); + assert!(matches!( + bread_events::handle_command(&command_event( + "bread.crumbs.profile.changed", + serde_json::json!({ "from": "away", "to": "home" }), + )), + bread_events::CommandAction::Ignore + )); + assert_eq!(State::load("away").profile, "away"); +} + +// --------------------------------------------------------------------- +// Regression tests for the audit fixes. +// --------------------------------------------------------------------- + +#[test] +fn flow_run_reports_no_exit_node_and_never_clears_selection() { + // A tailscale profile with no exit node configured must report + // TsHealth::NoExitNode — and must never run `tailscale set --exit-node=` + // with an empty value, which would clear the user's current selection. + let _env = EnvSandbox::new(); + let (nm, _dev) = setup_wifi(&[("Corp", 80)]); + + let mut cfg = base_config(); + cfg.networks = vec![net("Corp", Some("corp-pw"))]; + cfg.profiles.insert( + "work".into(), + Profile { + networks: vec!["Corp".into()], + tailscale: true, + ..Default::default() + }, + ); + + let runner = FakeRunner::new().with_command("tailscale"); + let calls = runner.calls_handle(); + let outcome = with_runner(runner, || flow::run(&mut cfg, "work")); + + match &outcome { + flow::Outcome::TailscaleError { health, .. } => { + assert_eq!(*health, breadcrumbs::tailscale::TsHealth::NoExitNode); + } + other => panic!("expected TailscaleError(NoExitNode), got {other:?}"), + } + assert!( + !calls.borrow().iter().any(|c| c.prog == "tailscale"), + "with no exit node configured, tailscale must not be touched: {:?}", + calls.borrow() + ); + assert!( + nm.activated_ssids().is_empty(), + "with no exit node configured, no network must be dialed: {:?}", + nm.activated_ssids() + ); +} + +#[test] +fn classify_reports_down_tailscale_manual_when_no_exit_node_configured() { + // An unset exit node needs human action (config edit), so it must + // classify as DownTailscaleManual — not DownTailscaleOther, which would + // make the watcher spin auto-recovery forever. + let _env = EnvSandbox::new(); + let (nm, dev) = setup_wifi(&[]); + associate(&nm, &dev, "CorpWifi"); + let mut cfg = base_config(); + cfg.profiles.insert( + "work".into(), + Profile { + tailscale: true, + ..Default::default() + }, + ); + + let runner = classify_runner("204", None).with_command("tailscale"); + let class = with_runner(runner, || classify(&cfg, "work")); + + assert_eq!(class.health, Health::DownTailscaleManual); + assert_eq!(class.ssid, Some("CorpWifi".to_string())); +} + +#[test] +fn ensure_exit_node_attempts_to_start_unreachable_daemon() { + // `tailscale status --json` with empty stdout is the "daemon not + // running" signature (the error goes to stderr). ensure_exit_node must + // try `tailscale up` and re-read instead of bailing out with an opaque + // error — the old dead-code path that made the Stopped recovery + // unreachable. + let _env = EnvSandbox::new(); + let runner = FakeRunner::new() + .with_command("tailscale") + .on(|p, args| p == "tailscale" && args.contains(&"status"), ok("")) + .on(|p, args| p == "tailscale" && args.contains(&"up"), ok("")); + let calls = runner.calls_handle(); + let health = with_runner(runner, || { + breadcrumbs::tailscale::ensure_exit_node(&["exitnode".to_string()]) + }); + assert!( + matches!(health, breadcrumbs::tailscale::TsHealth::Error(_)), + "daemon still unreachable after `up` → Error, got {health:?}" + ); + let tailscale_calls: Vec = calls + .borrow() + .iter() + .filter(|c| c.prog == "tailscale") + .map(|c| c.args.join(" ")) + .collect(); + assert!( + tailscale_calls.iter().any(|c| c.starts_with("up")), + "must attempt `tailscale up` when the daemon is unreachable: {tailscale_calls:?}" + ); +} + +#[test] +fn flow_run_fails_when_device_lands_on_wrong_ssid() { + // NM autoconnect race: the connect succeeds but the device ends up on a + // *different* network than requested. flow must not report Connected to + // the requested SSID, and must not clear its password. + let _env = EnvSandbox::new(); + let (nm, _dev) = setup_wifi(&[("First", 80), ("OtherNet", 90)]); + nm.set_land_on(Some("OtherNet")); + + let mut cfg = base_config(); + cfg.networks = vec![net("First", Some("pw1"))]; + cfg.profiles.insert( + "home".into(), + Profile { + networks: vec!["First".into()], + ..Default::default() + }, + ); + + let outcome = with_runner(healthy_runner(), || flow::run(&mut cfg, "home")); + + assert!( + !matches!(outcome, flow::Outcome::Connected { .. }), + "must not report Connected when the device is on a different SSID: {outcome:?}" + ); + assert_eq!( + cfg.network("First").unwrap().password, + Some("pw1".to_string()), + "password must not be cleared for a network that was never joined" + ); +} + +#[test] +fn run_quiet_suppresses_notifications_that_run_emits() { + // The watch loop calls flow::run_quiet so a persistent failure doesn't + // re-notify on every retry; the CLI keeps flow::run's notifications. + let _env = EnvSandbox::new(); + let nm = fake_nm::shared(); + nm.reset(); + let mut cfg = base_config(); // no profiles → UnknownProfile path notifies + + let runner = FakeRunner::new().with_command("notify-send"); + let calls = runner.calls_handle(); + with_runner(runner, || flow::run_quiet(&mut cfg, "ghost")); + assert!( + !calls.borrow().iter().any(|c| c.prog == "notify-send"), + "run_quiet must not fire desktop notifications: {:?}", + calls.borrow() + ); + + let runner = FakeRunner::new().with_command("notify-send"); + let calls = runner.calls_handle(); + with_runner(runner, || flow::run(&mut cfg, "ghost")); + assert!( + calls.borrow().iter().any(|c| c.prog == "notify-send"), + "run (CLI path) must still notify: {:?}", + calls.borrow() + ); +} + +#[test] +fn internet_ok_requires_204_and_falls_back_to_ping() { + // Only 204 counts as internet: captive/guest portals answer 200/301/302 + // with a login page or redirect, so those must not report healthy. + let _env = EnvSandbox::new(); + let cfg = base_config(); + + let r = FakeRunner::new().with_command("curl").on(|p, _| p == "curl", ok("204")); + assert!(with_runner(r, || breadcrumbs::status::internet_ok(&cfg))); + + for code in ["200", "301", "302"] { + let r = FakeRunner::new() + .with_command("curl") + .on(|p, _| p == "curl", ok(code)) + .on(|p, _| p == "ping", fail("")); + assert!( + !with_runner(r, || breadcrumbs::status::internet_ok(&cfg)), + "{code} must not count as internet" + ); + } + + // curl absent → the ping fallback decides. + let r = FakeRunner::new().with_command("ping").on(|p, _| p == "ping", ok("")); + assert!(with_runner(r, || breadcrumbs::status::internet_ok(&cfg))); +} + +#[test] +fn scan_list_dedups_by_ssid_keeping_strongest_signal() { + // One entry per SSID, at its strongest signal (not the first, possibly + // weak, listing). Hidden (empty-SSID) APs are skipped. + let _env = EnvSandbox::new(); + let (nm, dev) = setup_wifi(&[]); + nm.add_ap(&dev, "Cafe", 40, Security::Wpa2); + nm.add_ap(&dev, "Cafe", 80, Security::Wpa2); + nm.add_ap(&dev, "Office", 60, Security::Wpa3); + nm.add_ap(&dev, "Cafe", 90, Security::Wpa2); + + let list = breadcrumbs::nm::scan_list("wlan0"); + assert_eq!(list.len(), 2, "dedup by SSID: {list:?}"); + let cafe = list.iter().find(|e| e.ssid == "Cafe").unwrap(); + assert_eq!(cafe.signal, "90", "strongest signal wins"); + let office = list.iter().find(|e| e.ssid == "Office").unwrap(); + assert_eq!(office.signal, "60"); + assert_eq!(office.security, "WPA3"); +} + +// --------------------------------------------------------------------- +// New features: signal-aware selection, per-network DNS, learning, +// captive portals, exit-node failover, preferred interface, enterprise +// (802.1x) connect. +// --------------------------------------------------------------------- + +#[test] +fn flow_run_prefers_strongest_visible_signal_over_priority_order() { + let _env = EnvSandbox::new(); + // "Weak" is listed first (higher priority), but "Strong" has the better + // signal — signal-aware selection must dial Strong first. + let (nm, _dev) = setup_wifi(&[("Weak", 40), ("Strong", 90)]); + + let mut cfg = base_config(); + cfg.networks = vec![net("Weak", Some("pw1")), net("Strong", Some("pw2"))]; + cfg.profiles.insert( + "home".into(), + Profile { + networks: vec!["Weak".into(), "Strong".into()], + ..Default::default() + }, + ); + + let outcome = with_runner(healthy_runner(), || flow::run(&mut cfg, "home")); + match &outcome { + flow::Outcome::Connected { ssid, .. } => assert_eq!(ssid, "Strong"), + other => panic!("expected Connected to Strong, got {other:?}"), + } + + assert_eq!( + nm.activated_ssids(), + vec!["Strong".to_string()], + "the stronger network must be dialed, and only it" + ); +} + +#[test] +fn flow_run_pins_per_network_dns_override() { + let _env = EnvSandbox::new(); + let (nm, _dev) = setup_wifi(&[("Home", 80)]); + + let mut cfg = base_config(); + cfg.settings.dns = "1.1.1.1".into(); + let mut def = net("Home", Some("pw")); + def.dns = Some("9.9.9.9".into()); + cfg.networks = vec![def]; + cfg.profiles.insert( + "home".into(), + Profile { + networks: vec!["Home".into()], + ..Default::default() + }, + ); + + let outcome = with_runner(healthy_runner(), || flow::run(&mut cfg, "home")); + assert!(matches!(outcome, flow::Outcome::Connected { .. })); + + // The DNS-pinned profile must carry the per-network override, not the + // global 1.1.1.1. + let st = nm.state.lock().unwrap(); + let conn = st.connections.values().next().expect("a profile was saved"); + let dns = conn + .get("ipv4") + .and_then(|m| m.get("dns")) + .and_then(fake_nm::value_str_list); + assert_eq!(dns, Some(vec!["9.9.9.9".to_string()])); +} + +#[test] +fn flow_run_appends_learned_ssid_to_detect_ssids() { + let _env = EnvSandbox::new(); + let (_nm, _dev) = setup_wifi(&[("Home", 80)]); + + let mut cfg = base_config(); + cfg.networks = vec![net("Home", Some("pw"))]; + cfg.profiles.insert( + "home".into(), + Profile { + networks: vec!["Home".into()], + learn: true, + ..Default::default() + }, + ); + + let outcome = with_runner(healthy_runner(), || flow::run(&mut cfg, "home")); + assert!(matches!(outcome, flow::Outcome::Connected { .. })); + + assert_eq!( + cfg.profile("home").unwrap().detect_ssids, + vec!["Home".to_string()], + "a successful connect on a learn=true profile must record the SSID" + ); +} + +#[test] +fn classify_reports_captive_portal_when_connectivity_returns_200() { + let _env = EnvSandbox::new(); + let (nm, dev) = setup_wifi(&[]); + associate(&nm, &dev, "HomeWifi"); + let mut cfg = base_config(); + cfg.profiles.insert("home".into(), Profile::default()); + + let runner = classify_runner("200", None); + let class = with_runner(runner, || classify(&cfg, "home")); + + assert_eq!(class.health, Health::CaptivePortal); + assert_eq!(class.ssid, Some("HomeWifi".to_string())); +} + +#[test] +fn ensure_exit_node_failover_tries_nodes_in_priority_order() { + // The status never shows nodeA; it always shows nodeB selected + online. + // ensure_exit_node must therefore try nodeA (fail), then nodeB (succeed), + // in that exact priority order. + let _env = EnvSandbox::new(); + let json = r#"{"BackendState":"Running","Peer":{"k1":{"HostName":"nodeB","DNSName":"nodeB.ts.net.","Online":true,"ExitNode":true,"ExitNodeOption":true}}}"#; + let runner = FakeRunner::new() + .with_command("tailscale") + .on_contains("tailscale", "status", ok(json)) + .on(|p, args| p == "tailscale" && args.contains(&"set"), ok("")); + let calls = runner.calls_handle(); + + let health = with_runner(runner, || { + breadcrumbs::tailscale::ensure_exit_node(&["nodeA".into(), "nodeB".into()]) + }); + assert_eq!(health, breadcrumbs::tailscale::TsHealth::Ok); + + let sets: Vec = calls + .borrow() + .iter() + .filter(|c| c.args.iter().any(|a| a == "set")) + .map(|c| c.args.join(" ")) + .collect(); + assert_eq!( + sets, + vec!["set --exit-node=nodeA".to_string(), "set --exit-node=nodeB".to_string()], + "failover must try nodes in priority order" + ); +} + +#[test] +fn wifi_interface_preferred_picks_named_device_over_first_wifi() { + let _env = EnvSandbox::new(); + let nm = fake_nm::shared(); + nm.reset(); + nm.add_wifi_device("wlan0", 100); + nm.add_wifi_device("wlan1", 100); + + assert_eq!(breadcrumbs::nm::wifi_interface_preferred(Some("wlan1")).as_deref(), Some("wlan1")); +} + +#[test] +fn wifi_interface_preferred_falls_back_to_first_wifi_when_pref_missing() { + let _env = EnvSandbox::new(); + let nm = fake_nm::shared(); + nm.reset(); + nm.add_wifi_device("wlan0", 100); + nm.add_wifi_device("wlan1", 100); + + assert_eq!(breadcrumbs::nm::wifi_interface_preferred(Some("wlan9")).as_deref(), Some("wlan0")); +} + +#[test] +fn visible_signals_dedups_by_strongest_signal() { + let _env = EnvSandbox::new(); + let (nm, dev) = setup_wifi(&[]); + nm.add_ap(&dev, "Cafe", 40, Security::Wpa2); + nm.add_ap(&dev, "Cafe", 85, Security::Wpa2); + nm.add_ap(&dev, "Office", 60, Security::Wpa2); + + let map = breadcrumbs::nm::visible_signals("wlan0"); + assert_eq!(map.get("Cafe"), Some(&85)); + assert_eq!(map.get("Office"), Some(&60)); +} + +#[test] +fn connect_verbose_enterprise_creates_8021x_profile() { + let _env = EnvSandbox::new(); + let (nm, dev) = setup_wifi(&[]); + nm.add_ap(&dev, "Corp", 80, Security::Enterprise); + + let mut def = net("Corp", Some("pw")); + def.eap = Some("peap".into()); + def.identity = Some("user@corp".into()); + def.ca_cert = Some("/etc/ca.pem".into()); + + let res = breadcrumbs::nm::connect_verbose("wlan0", &def, 8, "1.1.1.1"); + assert!(res.is_ok(), "enterprise connect should succeed: {res:?}"); + + let st = nm.state.lock().unwrap(); + let (_, settings) = st.connections.iter().next().expect("a profile was saved"); + let x1 = settings.get("802-1x").expect("802-1x section"); + assert_eq!( + x1.get("identity").and_then(|v| v.downcast_ref::().ok()).as_deref(), + Some("user@corp") + ); + let eap = x1.get("eap").and_then(fake_nm::value_str_list); + assert_eq!(eap.as_deref(), Some(&["peap".to_string()][..])); + // ca-cert is a GBytes (`ay`) holding the conventional `file://` URI for + // a filesystem path — never a bare string. + let ca = x1.get("ca-cert").and_then(fake_nm::value_bytes); + assert_eq!(ca.as_deref(), Some(b"file:///etc/ca.pem".as_slice())); + let sec = settings.get("802-11-wireless-security").expect("security section"); + assert_eq!( + sec.get("key-mgmt").and_then(|v| v.downcast_ref::().ok()).as_deref(), + Some("wpa-eap") + ); +}