budctl66d7a70
Drop cargo-deny and the network bans; rule 1 is about telemetry
Rule 1 was meant to keep out telemetry and cloud services, not every network crate. Remove deny.toml, the clippy disallowed-types for TCP/UDP sockets, and the guards.rs tests that searched for network code and checked the build against the ban list. CLAUDE.md rule 1 now says: no telemetry or cloud, network only when a feature truly needs it.
+5 −197
7 files changed
- M CLAUDE.md +2 −6
- M clippy.toml +0 −7
- M crates/budctl-core/src/bluez.rs +1 −1
- M crates/budctl-core/src/lib.rs +1 −2
- M crates/budctl-core/tests/guards.rs +0 −105
- D deny.toml +0 −75
- M docs/protocol/transport.md +1 −1
| @@ -21,11 +21,7 @@ The first supported device is the **realme Buds T310**. More devices are added a | |||
| 21 | 21 | ||
| 22 | 22 | ## Hard rules | |
| 23 | 23 | ||
| 24 | − | 1. **No telemetry; network only when a feature needs it.** The owner relaxed this rule on 2026-10-27: network access is fine when a feature truly needs it, but nothing may ever report on the user. No feature needs the network so far. The sockets budctl opens today: | |
| 25 | − | - Bluetooth sockets: RFCOMM for the control link, L2CAP for SDP. | |
| 26 | − | - The local system D-Bus Unix socket, used through `zbus` only to talk to BlueZ (`org.bluez`). budctl opens that socket itself at the fixed path, so an environment variable can never redirect it to TCP. | |
| 27 | − | - `budctl-gui` only: the Wayland compositor's Unix socket. The GUI is built for Wayland only (no X11), so `DISPLAY` can never point it at TCP. | |
| 28 | − | - HTTP clients (reqwest, hyper, ureq, surf, etc.) stay banned until a feature needs one. The bans live in `cargo deny` (`deny.toml`), clippy `disallowed-types` and `crates/budctl-core/tests/guards.rs`. Lifting one is fine then; say why in the commit. | |
| 24 | + | 1. **No telemetry or cloud.** Nothing may ever report on the user: no analytics, crash reports, usage data, update checks or accounts. Network access is fine when a feature truly needs it; none does so far. | |
| 29 | 25 | 2. **Clean-room protocol work.** Protocol facts (UUIDs, channel numbers, packet framing, command IDs, init sequences) may come from public references, but no other project's source code is ever translated or copied. Write findings into `docs/protocol/<device>.md` in our own words, then implement from that spec. | |
| 30 | 26 | - Committed files do not name or cite those references. Each fact carries an evidence level instead (rule 3). | |
| 31 | 27 | - Only the owner can change this rule, by editing it here. | |
| @@ -45,7 +41,7 @@ The first supported device is the **realme Buds T310**. More devices are added a | |||
| 45 | 41 | - `zbus` (blocking API) only to list paired devices. | |
| 46 | 42 | - Supporting BLE GATT would need a new transport kind in core. | |
| 47 | 43 | - CLI: `clap` (derive). It is parsed with `try_parse`, so usage errors exit 1, not clap's default 2. `serde_json` provides `--json`. | |
| 48 | − | - Tests: `cargo test --workspace`. Lints: `cargo clippy --workspace --all-targets -- -D warnings`. Dependency policy: `cargo deny check bans licenses sources`. | |
| 44 | + | - Tests: `cargo test --workspace`. Lints: `cargo clippy --workspace --all-targets -- -D warnings`. | |
| 49 | 45 | - Keep dependencies minimal. Every new dependency needs a reason in the commit message. The current direct dependencies: | |
| 50 | 46 | - core: `libc`, `zbus`, `serde` | |
| 51 | 47 | - cli: `clap`, `serde_json` | |
| @@ -4,13 +4,6 @@ allow-expect-in-tests = true | |||
| 4 | 4 | allow-panic-in-tests = true | |
| 5 | 5 | allow-indexing-slicing-in-tests = true | |
| 6 | 6 | ||
| 7 | − | # CLAUDE.md hard rule 1: no network sockets, ever. | |
| 8 | − | disallowed-types = [ | |
| 9 | − | { path = "std::net::TcpStream", reason = "budctl never opens network sockets (CLAUDE.md rule 1)" }, | |
| 10 | − | { path = "std::net::TcpListener", reason = "budctl never opens network sockets (CLAUDE.md rule 1)" }, | |
| 11 | − | { path = "std::net::UdpSocket", reason = "budctl never opens network sockets (CLAUDE.md rule 1)" }, | |
| 12 | − | ] | |
| 13 | − | ||
| 14 | 7 | disallowed-methods = [ | |
| 15 | 8 | { path = "std::thread::sleep", reason = "wait with Link::pump_until, so the buds' requests are still answered" }, | |
| 16 | 9 | { path = "std::process::exit", reason = "return an error and let main turn it into an exit code" }, | |
| @@ -1,6 +1,6 @@ | |||
| 1 | 1 | //! Paired devices, from BlueZ over the system D-Bus. | |
| 2 | 2 | //! | |
| 3 | − | //! The only use of D-Bus in budctl is listing paired devices (CLAUDE.md, hard rule 1). budctl | |
| 3 | + | //! The only use of D-Bus in budctl is listing paired devices. budctl | |
| 4 | 4 | //! connects to the system bus socket at its fixed path itself, so no environment variable can | |
| 5 | 5 | //! point the connection anywhere else (such as a TCP address). | |
| 6 | 6 | ||
| @@ -3,8 +3,7 @@ | |||
| 3 | 3 | //! A front end (CLI, GUI) calls [`scan`], picks a device with [`select`], opens a session with | |
| 4 | 4 | //! [`connect`] and then reads or changes settings through [`devices::Session`]. | |
| 5 | 5 | //! | |
| 6 | − | //! No network: the only sockets are Bluetooth sockets and the local system D-Bus (BlueZ). | |
| 7 | − | //! See CLAUDE.md, hard rule 1. | |
| 6 | + | //! No telemetry: the only sockets are Bluetooth sockets and the local system D-Bus (BlueZ). | |
| 8 | 7 | ||
| 9 | 8 | pub mod bluez; | |
| 10 | 9 | pub mod bt; | |
| @@ -1,6 +1,5 @@ | |||
| 1 | 1 | //! Checks for the CLAUDE.md hard rules that the compiler and clippy cannot make on their own. | |
| 2 | 2 | //! | |
| 3 | − | //! - Rule 1: no network code in our sources, and no banned crate (deny.toml) in a Linux build. | |
| 4 | 3 | //! - Rule 3: every `Spec: <file>.md#<anchor>` comment points at an anchor that exists. | |
| 5 | 4 | //! | |
| 6 | 5 | //! The CLI's exit codes are checked in crates/budctl-cli/tests/cli.rs. | |
| @@ -8,10 +7,8 @@ | |||
| 8 | 7 | // Test-only file: helpers may panic (clippy.toml exempts only #[test] functions themselves). | |
| 9 | 8 | #![allow(clippy::unwrap_used, clippy::expect_used, clippy::indexing_slicing)] | |
| 10 | 9 | ||
| 11 | − | use std::collections::BTreeSet; | |
| 12 | 10 | use std::fs; | |
| 13 | 11 | use std::path::{Path, PathBuf}; | |
| 14 | − | use std::process::Command; | |
| 15 | 12 | ||
| 16 | 13 | fn workspace() -> PathBuf { | |
| 17 | 14 | Path::new(env!("CARGO_MANIFEST_DIR")).join("../..") | |
| @@ -40,108 +37,6 @@ fn sources() -> Vec<PathBuf> { | |||
| 40 | 37 | files | |
| 41 | 38 | } | |
| 42 | 39 | ||
| 43 | − | #[test] | |
| 44 | − | fn no_network_code_in_sources() { | |
| 45 | − | // Built from pieces so this file does not trip its own check. | |
| 46 | − | let banned = [ | |
| 47 | − | ["std", "::net"].concat(), | |
| 48 | − | ["Tcp", "Stream"].concat(), | |
| 49 | − | ["Tcp", "Listener"].concat(), | |
| 50 | − | ["Udp", "Socket"].concat(), | |
| 51 | − | ["ToSocket", "Addrs"].concat(), | |
| 52 | − | ["AF_", "INET"].concat(), | |
| 53 | − | ]; | |
| 54 | − | let mut hits = Vec::new(); | |
| 55 | − | for file in sources() { | |
| 56 | − | let text = fs::read_to_string(&file).unwrap(); | |
| 57 | − | for (n, line) in text.lines().enumerate() { | |
| 58 | − | for word in &banned { | |
| 59 | − | if line.contains(word.as_str()) { | |
| 60 | − | hits.push(format!("{}:{}: {word}", file.display(), n + 1)); | |
| 61 | − | } | |
| 62 | − | } | |
| 63 | − | } | |
| 64 | − | } | |
| 65 | − | assert!( | |
| 66 | − | hits.is_empty(), | |
| 67 | − | "network code is not allowed (CLAUDE.md rule 1):\n{}", | |
| 68 | − | hits.join("\n") | |
| 69 | − | ); | |
| 70 | − | } | |
| 71 | − | ||
| 72 | − | /// The crate names in deny.toml's `[bans] deny` list. | |
| 73 | − | fn banned_crates() -> BTreeSet<String> { | |
| 74 | − | let text = fs::read_to_string(workspace().join("deny.toml")).unwrap(); | |
| 75 | − | let mut names = BTreeSet::new(); | |
| 76 | − | let mut in_bans = false; | |
| 77 | − | for line in text.lines() { | |
| 78 | − | let line = line.trim(); | |
| 79 | − | if line.starts_with('[') { | |
| 80 | − | in_bans = line == "[bans]"; | |
| 81 | − | continue; | |
| 82 | − | } | |
| 83 | − | if !in_bans { | |
| 84 | − | continue; | |
| 85 | − | } | |
| 86 | − | if let Some(rest) = line.strip_prefix("{ crate = \"") { | |
| 87 | − | let name = rest.split('"').next().unwrap(); | |
| 88 | − | names.insert(name.to_owned()); | |
| 89 | − | } | |
| 90 | − | } | |
| 91 | − | assert!( | |
| 92 | − | names.contains("reqwest"), | |
| 93 | − | "could not read the [bans] deny list from deny.toml" | |
| 94 | − | ); | |
| 95 | − | names | |
| 96 | − | } | |
| 97 | − | ||
| 98 | − | /// The crates a Linux build of the workspace uses, tests included, for the targets deny.toml | |
| 99 | − | /// checks. Cargo.lock is no help here: it also lists crates that only other platforms build (iced's | |
| 100 | − | /// lock entries include tokio, for wasm). | |
| 101 | − | fn built_crates() -> BTreeSet<String> { | |
| 102 | − | let out = Command::new(env!("CARGO")) | |
| 103 | − | .current_dir(workspace()) | |
| 104 | − | .args(["tree", "--offline", "--locked", "--workspace", "--all-features"]) | |
| 105 | − | .args(["--edges", "normal,build,dev", "--prefix", "none", "--format", "{p}"]) | |
| 106 | − | .args([ | |
| 107 | − | "--target", | |
| 108 | − | "x86_64-unknown-linux-gnu", | |
| 109 | − | "--target", | |
| 110 | − | "aarch64-unknown-linux-gnu", | |
| 111 | − | ]) | |
| 112 | − | .output() | |
| 113 | − | .unwrap(); | |
| 114 | − | assert!( | |
| 115 | − | out.status.success(), | |
| 116 | − | "cargo tree failed: {}", | |
| 117 | − | String::from_utf8_lossy(&out.stderr) | |
| 118 | − | ); | |
| 119 | − | let names: BTreeSet<String> = String::from_utf8(out.stdout) | |
| 120 | − | .unwrap() | |
| 121 | − | .lines() | |
| 122 | − | .filter_map(|line| line.split_whitespace().next()) | |
| 123 | − | .map(str::to_owned) | |
| 124 | − | .collect(); | |
| 125 | − | assert!( | |
| 126 | − | names.contains("budctl-core") && names.contains("libc"), | |
| 127 | − | "could not read crate names from cargo tree" | |
| 128 | − | ); | |
| 129 | − | names | |
| 130 | − | } | |
| 131 | − | ||
| 132 | − | #[test] | |
| 133 | − | fn no_banned_crate_in_the_build() { | |
| 134 | − | let built = built_crates(); | |
| 135 | − | let found: Vec<String> = banned_crates() | |
| 136 | − | .into_iter() | |
| 137 | − | .filter(|banned| built.contains(banned)) | |
| 138 | − | .collect(); | |
| 139 | − | assert!( | |
| 140 | − | found.is_empty(), | |
| 141 | − | "banned crates in the build (see deny.toml): {found:?}" | |
| 142 | − | ); | |
| 143 | − | } | |
| 144 | − | ||
| 145 | 40 | #[test] | |
| 146 | 41 | fn spec_references_point_at_existing_anchors() { | |
| 147 | 42 | let docs = workspace().join("docs/protocol"); | |
| @@ -1,75 +0,0 @@ | |||
| 1 | − | # cargo-deny policy (`cargo deny check bans licenses sources`). | |
| 2 | − | # CLAUDE.md hard rule 1: no network code anywhere in the dependency tree. | |
| 3 | − | # crates/budctl-core/tests/guards.rs reads the [bans] list below and checks Cargo.lock against | |
| 4 | − | # it, so the rule also holds where cargo-deny is not installed. | |
| 5 | − | ||
| 6 | − | [graph] | |
| 7 | − | targets = ["x86_64-unknown-linux-gnu", "aarch64-unknown-linux-gnu"] | |
| 8 | − | all-features = true | |
| 9 | − | ||
| 10 | − | [advisories] | |
| 11 | − | # Note: this check downloads the RustSec database (a dev-tool network access, not budctl's). | |
| 12 | − | version = 2 | |
| 13 | − | ||
| 14 | − | [licenses] | |
| 15 | − | version = 2 | |
| 16 | − | confidence-threshold = 0.9 | |
| 17 | − | # Everything here is compatible with GPL-3.0-or-later. | |
| 18 | − | allow = [ | |
| 19 | − | "GPL-3.0-or-later", | |
| 20 | − | "MIT", | |
| 21 | − | "Apache-2.0", | |
| 22 | − | "Apache-2.0 WITH LLVM-exception", | |
| 23 | − | "BSD-2-Clause", | |
| 24 | − | "BSD-3-Clause", | |
| 25 | − | "ISC", | |
| 26 | − | "Zlib", | |
| 27 | − | "Unicode-3.0", | |
| 28 | − | "MPL-2.0", | |
| 29 | − | ] | |
| 30 | − | ||
| 31 | − | [bans] | |
| 32 | − | multiple-versions = "warn" | |
| 33 | − | wildcards = "deny" | |
| 34 | − | allow-wildcard-paths = true | |
| 35 | − | deny = [ | |
| 36 | − | # HTTP clients and servers | |
| 37 | − | { crate = "reqwest", reason = "no network code (CLAUDE.md rule 1)" }, | |
| 38 | − | { crate = "hyper", reason = "no network code (CLAUDE.md rule 1)" }, | |
| 39 | − | { crate = "hyper-util", reason = "no network code (CLAUDE.md rule 1)" }, | |
| 40 | − | { crate = "h2", reason = "no network code (CLAUDE.md rule 1)" }, | |
| 41 | − | { crate = "h3", reason = "no network code (CLAUDE.md rule 1)" }, | |
| 42 | − | { crate = "quinn", reason = "no network code (CLAUDE.md rule 1)" }, | |
| 43 | − | { crate = "ureq", reason = "no network code (CLAUDE.md rule 1)" }, | |
| 44 | − | { crate = "surf", reason = "no network code (CLAUDE.md rule 1)" }, | |
| 45 | − | { crate = "isahc", reason = "no network code (CLAUDE.md rule 1)" }, | |
| 46 | − | { crate = "curl", reason = "no network code (CLAUDE.md rule 1)" }, | |
| 47 | − | { crate = "curl-sys", reason = "no network code (CLAUDE.md rule 1)" }, | |
| 48 | − | { crate = "attohttpc", reason = "no network code (CLAUDE.md rule 1)" }, | |
| 49 | − | { crate = "minreq", reason = "no network code (CLAUDE.md rule 1)" }, | |
| 50 | − | { crate = "tiny_http", reason = "no network code (CLAUDE.md rule 1)" }, | |
| 51 | − | { crate = "axum", reason = "no network code (CLAUDE.md rule 1)" }, | |
| 52 | − | { crate = "warp", reason = "no network code (CLAUDE.md rule 1)" }, | |
| 53 | − | { crate = "actix-web", reason = "no network code (CLAUDE.md rule 1)" }, | |
| 54 | − | { crate = "tungstenite", reason = "no network code (CLAUDE.md rule 1)" }, | |
| 55 | − | { crate = "tokio-tungstenite", reason = "no network code (CLAUDE.md rule 1)" }, | |
| 56 | − | # TLS | |
| 57 | − | { crate = "rustls", reason = "no network code (CLAUDE.md rule 1)" }, | |
| 58 | − | { crate = "native-tls", reason = "no network code (CLAUDE.md rule 1)" }, | |
| 59 | − | { crate = "openssl", reason = "no network code (CLAUDE.md rule 1)" }, | |
| 60 | − | { crate = "openssl-sys", reason = "no network code (CLAUDE.md rule 1)" }, | |
| 61 | − | { crate = "aws-lc-rs", reason = "no network code (CLAUDE.md rule 1)" }, | |
| 62 | − | # DNS, telemetry, self-update | |
| 63 | − | { crate = "hickory-resolver", reason = "no network code (CLAUDE.md rule 1)" }, | |
| 64 | − | { crate = "trust-dns-resolver", reason = "no network code (CLAUDE.md rule 1)" }, | |
| 65 | − | { crate = "sentry", reason = "no telemetry (CLAUDE.md non-goals)" }, | |
| 66 | − | { crate = "opentelemetry", reason = "no telemetry (CLAUDE.md non-goals)" }, | |
| 67 | − | { crate = "self_update", reason = "no update checks (CLAUDE.md non-goals)" }, | |
| 68 | − | # Keeps zbus on its own small executor instead of pulling in a whole async runtime. | |
| 69 | − | { crate = "tokio", reason = "not needed; keep the dependency tree small" }, | |
| 70 | − | ] | |
| 71 | − | ||
| 72 | − | [sources] | |
| 73 | − | unknown-registry = "deny" | |
| 74 | − | unknown-git = "deny" | |
| 75 | − | allow-registry = ["https://github.com/rust-lang/crates.io-index"] | |
| @@ -12,7 +12,7 @@ All of this is public, standard material: | |||
| 12 | 12 | <a id="paired-devices"></a> | |
| 13 | 13 | ## 1. Paired devices (BlueZ over D-Bus) | |
| 14 | 14 | ||
| 15 | − | - **Connection.** budctl talks to BlueZ only over the **system bus**. The D-Bus specification gives its default address as `unix:path=/var/run/dbus/system_bus_socket`. budctl connects to that path itself and hands the stream to zbus. It never reads `DBUS_SYSTEM_BUS_ADDRESS`, which could name a TCP address (CLAUDE.md rule 1). | |
| 15 | + | - **Connection.** budctl talks to BlueZ only over the **system bus**. The D-Bus specification gives its default address as `unix:path=/var/run/dbus/system_bus_socket`. budctl connects to that path itself and hands the stream to zbus. It never reads `DBUS_SYSTEM_BUS_ADDRESS`, which could name a TCP address. | |
| 16 | 16 | - **Listing devices.** Call `org.freedesktop.DBus.ObjectManager.GetManagedObjects` on service `org.bluez`, path `/`. Each object that has the interface `org.bluez.Device1` is a known device. | |
| 17 | 17 | - **Device1 properties used:** | |
| 18 | 18 | ||