From 49ac8d72d257670c5e842bc95f8250c5aadda58e Mon Sep 17 00:00:00 2001 From: "K. Isom" Date: Wed, 23 Sep 2026 01:13:25 -0700 Subject: [PATCH] brokerd: the podman argument lists Implemented-By: OpenCode session (model recorded in docs/implementer-log.md) --- crates/brokerd/src/lib.rs | 1 + crates/brokerd/src/podman.rs | 109 ++++++++++++ crates/brokerd/src/runner.rs | 19 ++- .../brokerd/tests/fixtures/podman/egress.args | 20 +++ .../tests/fixtures/podman/http_fetch.args | 17 ++ .../tests/fixtures/podman/read_file.args | 17 ++ .../brokerd/tests/fixtures/podman/shell.args | 18 ++ .../tests/fixtures/podman/shell_no_paths.args | 16 ++ .../tests/fixtures/podman/write_file.args | 17 ++ crates/brokerd/tests/podman_args.rs | 155 ++++++++++++++++++ docs/implementer-log.md | 1 + 11 files changed, 387 insertions(+), 3 deletions(-) create mode 100644 crates/brokerd/src/podman.rs create mode 100644 crates/brokerd/tests/fixtures/podman/egress.args create mode 100644 crates/brokerd/tests/fixtures/podman/http_fetch.args create mode 100644 crates/brokerd/tests/fixtures/podman/read_file.args create mode 100644 crates/brokerd/tests/fixtures/podman/shell.args create mode 100644 crates/brokerd/tests/fixtures/podman/shell_no_paths.args create mode 100644 crates/brokerd/tests/fixtures/podman/write_file.args create mode 100644 crates/brokerd/tests/podman_args.rs diff --git a/crates/brokerd/src/lib.rs b/crates/brokerd/src/lib.rs index aa1665c..d304524 100644 --- a/crates/brokerd/src/lib.rs +++ b/crates/brokerd/src/lib.rs @@ -8,6 +8,7 @@ pub mod broker; pub mod config; pub mod grants; pub mod ledger; +pub mod podman; pub mod policy; pub mod runner; pub mod serve; diff --git a/crates/brokerd/src/podman.rs b/crates/brokerd/src/podman.rs new file mode 100644 index 0000000..430cbd5 --- /dev/null +++ b/crates/brokerd/src/podman.rs @@ -0,0 +1,109 @@ +//! The `podman` argument lists for one call's container and for the `http_fetch` egress proxy. +//! +//! Nothing is passed through a shell: every argument is its own `OsString`, and the tool's +//! arguments go on standard input, never on the command line. Built without running Podman, so a +//! runtime that only builds the list can be tested as a golden file. Spec section 6. + +use crate::config::Runner; +use crate::runner::RunSpec; +use proto::{CallId, SessionId}; +use std::ffi::OsString; +use std::path::Path; + +pub const EGRESS_MOUNT: &str = "/run/egress"; +pub const EGRESS_SOCKET: &str = "/run/egress/egress.sock"; +pub const TOOLKIT: &str = "/bin/toolkit"; + +/// The container's name: `boxmaker---`, so it says whose call it is. +pub fn container_name(session: &SessionId, call: CallId, n: u64) -> String { + format!("boxmaker-{}-{}-{}", session.as_str(), call.0, n) +} + +/// The six hardening flags, common to the tool and the proxy. `pids` and `memory` differ: the tool +/// takes the runner's, the proxy its fixed limits. +fn hardening(pids: u32, memory: &str) -> Vec { + [ + "--read-only", + "--cap-drop=all", + "--security-opt=no-new-privileges", + "--userns=keep-id", + ] + .map(OsString::from) + .into_iter() + .chain([ + OsString::from(format!("--pids-limit={pids}")), + OsString::from(format!("--memory={memory}")), + ]) + .collect() +} + +/// A `--volume=::` argument, built with `push` so a directory need not be +/// UTF-8. +fn volume(host: &Path, container: &Path, mode: &str) -> OsString { + let mut arg = OsString::new(); + arg.push("--volume="); + arg.push(host); + arg.push(":"); + arg.push(container); + arg.push(":"); + arg.push(mode); + arg +} + +/// The tool's container. `egress` is the call's egress directory, for `http_fetch` only. +pub fn tool_args( + spec: &RunSpec, + runner: &Runner, + name: &str, + egress: Option<&Path>, +) -> Vec { + let mut args: Vec = [ + "run", + "--rm", + "-i", + &format!("--name={name}"), + "--label=boxmaker=tool", + "--network=none", + ] + .map(OsString::from) + .into_iter() + .collect(); + args.extend(hardening(runner.pids, &runner.memory)); + args.push(OsString::from("--tmpfs=/tmp:rw,size=64m,mode=1777")); + for mount in spec.mounts() { + let mode = if mount.writable { "rw" } else { "ro" }; + args.push(volume(Path::new(&mount.path), Path::new(&mount.path), mode)); + } + if let Some(dir) = egress { + args.push(volume(dir, Path::new(EGRESS_MOUNT), "rw")); + } + args.push(OsString::from(runner.image.as_str())); + args.push(OsString::from(TOOLKIT)); + args.push(OsString::from(spec.tool().as_str())); + args +} + +/// The egress proxy's container. +pub fn egress_args(runner: &Runner, name: &str, dir: &Path, hosts: &[String]) -> Vec { + let mut args: Vec = [ + "run", + "-d", + "--rm", + &format!("--name={name}-egress"), + "--label=boxmaker=egress", + &format!("--network={}", runner.egress_network), + ] + .map(OsString::from) + .into_iter() + .collect(); + args.extend(hardening(64, "128m")); + args.push(volume(dir, Path::new(EGRESS_MOUNT), "rw")); + args.push(OsString::from(runner.image.as_str())); + args.push(OsString::from(TOOLKIT)); + args.push(OsString::from("egress-proxy")); + args.push(OsString::from("--socket")); + args.push(OsString::from(EGRESS_SOCKET)); + args.push(OsString::from("--allow")); + args.push(OsString::from(hosts.join(","))); + args +} diff --git a/crates/brokerd/src/runner.rs b/crates/brokerd/src/runner.rs index 389ac9f..3a02876 100644 --- a/crates/brokerd/src/runner.rs +++ b/crates/brokerd/src/runner.rs @@ -5,6 +5,8 @@ //! //! ```compile_fail //! let _ = brokerd::runner::RunSpec { +//! session: proto::SessionId::new("s1").unwrap(), +//! call: proto::CallId(1), //! tool: brokerd::args::ToolName::Shell, //! arguments: todo!(), //! mounts: Vec::new(), @@ -20,7 +22,7 @@ use crate::args::{ToolArgs, ToolName}; use crate::policy::Decision; -use proto::ToolResponse; +use proto::{CallId, SessionId, ToolResponse}; /// A directory mounted for one call: a path and whether the runtime may write to it. #[derive(Debug, Clone, PartialEq, Eq)] @@ -29,10 +31,13 @@ pub struct Mount { pub writable: bool, } -/// What one call was turned into before it reached a `Runtime`: the tool, its arguments, the -/// directories mounted and the hosts it may reach. Built only here, from a `Decision`. +/// What one call was turned into before it reached a `Runtime`: the session and call it is, the +/// tool, its arguments, the directories mounted and the hosts it may reach. Built only here, from a +/// `Decision`. #[derive(Debug)] pub struct RunSpec { + session: SessionId, + call: CallId, tool: ToolName, arguments: ToolArgs, mounts: Vec, @@ -40,6 +45,12 @@ pub struct RunSpec { } impl RunSpec { + pub fn session(&self) -> &SessionId { + &self.session + } + pub fn call(&self) -> CallId { + self.call + } pub fn tool(&self) -> ToolName { self.tool } @@ -108,6 +119,8 @@ pub fn run(decision: Decision, runtime: &dyn Runtime) -> proto::ToolResponse { ToolArgs::HttpFetch(_) => (Vec::new(), Some(decision.hosts().to_vec())), }; let spec = RunSpec { + session: decision.request().session.clone(), + call: decision.request().call, tool: args.tool(), arguments: args.clone(), mounts, diff --git a/crates/brokerd/tests/fixtures/podman/egress.args b/crates/brokerd/tests/fixtures/podman/egress.args new file mode 100644 index 0000000..7f70a7d --- /dev/null +++ b/crates/brokerd/tests/fixtures/podman/egress.args @@ -0,0 +1,20 @@ +run +-d +--rm +--name=boxmaker-s1-1-7-egress +--label=boxmaker=egress +--network=pasta +--read-only +--cap-drop=all +--security-opt=no-new-privileges +--userns=keep-id +--pids-limit=64 +--memory=128m +--volume=/h/run/egress/boxmaker-s1-1-7:/run/egress:rw +localhost/boxmaker-tools@sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef +/bin/toolkit +egress-proxy +--socket +/run/egress/egress.sock +--allow +example.com,*.example.org diff --git a/crates/brokerd/tests/fixtures/podman/http_fetch.args b/crates/brokerd/tests/fixtures/podman/http_fetch.args new file mode 100644 index 0000000..7763d10 --- /dev/null +++ b/crates/brokerd/tests/fixtures/podman/http_fetch.args @@ -0,0 +1,17 @@ +run +--rm +-i +--name=boxmaker-s1-1-7 +--label=boxmaker=tool +--network=none +--read-only +--cap-drop=all +--security-opt=no-new-privileges +--userns=keep-id +--pids-limit=128 +--memory=512m +--tmpfs=/tmp:rw,size=64m,mode=1777 +--volume=/h/run/egress/boxmaker-s1-1-7:/run/egress:rw +localhost/boxmaker-tools@sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef +/bin/toolkit +http_fetch diff --git a/crates/brokerd/tests/fixtures/podman/read_file.args b/crates/brokerd/tests/fixtures/podman/read_file.args new file mode 100644 index 0000000..4fd89e9 --- /dev/null +++ b/crates/brokerd/tests/fixtures/podman/read_file.args @@ -0,0 +1,17 @@ +run +--rm +-i +--name=boxmaker-s1-1-7 +--label=boxmaker=tool +--network=none +--read-only +--cap-drop=all +--security-opt=no-new-privileges +--userns=keep-id +--pids-limit=128 +--memory=512m +--tmpfs=/tmp:rw,size=64m,mode=1777 +--volume=/home/kyle/notes:/home/kyle/notes:ro +localhost/boxmaker-tools@sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef +/bin/toolkit +read_file diff --git a/crates/brokerd/tests/fixtures/podman/shell.args b/crates/brokerd/tests/fixtures/podman/shell.args new file mode 100644 index 0000000..7d85f69 --- /dev/null +++ b/crates/brokerd/tests/fixtures/podman/shell.args @@ -0,0 +1,18 @@ +run +--rm +-i +--name=boxmaker-s1-1-7 +--label=boxmaker=tool +--network=none +--read-only +--cap-drop=all +--security-opt=no-new-privileges +--userns=keep-id +--pids-limit=128 +--memory=512m +--tmpfs=/tmp:rw,size=64m,mode=1777 +--volume=/srv/a:/srv/a:rw +--volume=/srv/b:/srv/b:rw +localhost/boxmaker-tools@sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef +/bin/toolkit +shell diff --git a/crates/brokerd/tests/fixtures/podman/shell_no_paths.args b/crates/brokerd/tests/fixtures/podman/shell_no_paths.args new file mode 100644 index 0000000..5d26eea --- /dev/null +++ b/crates/brokerd/tests/fixtures/podman/shell_no_paths.args @@ -0,0 +1,16 @@ +run +--rm +-i +--name=boxmaker-s1-1-7 +--label=boxmaker=tool +--network=none +--read-only +--cap-drop=all +--security-opt=no-new-privileges +--userns=keep-id +--pids-limit=128 +--memory=512m +--tmpfs=/tmp:rw,size=64m,mode=1777 +localhost/boxmaker-tools@sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef +/bin/toolkit +shell diff --git a/crates/brokerd/tests/fixtures/podman/write_file.args b/crates/brokerd/tests/fixtures/podman/write_file.args new file mode 100644 index 0000000..015b5d5 --- /dev/null +++ b/crates/brokerd/tests/fixtures/podman/write_file.args @@ -0,0 +1,17 @@ +run +--rm +-i +--name=boxmaker-s1-1-7 +--label=boxmaker=tool +--network=none +--read-only +--cap-drop=all +--security-opt=no-new-privileges +--userns=keep-id +--pids-limit=128 +--memory=512m +--tmpfs=/tmp:rw,size=64m,mode=1777 +--volume=/home/kyle/out:/home/kyle/out:rw +localhost/boxmaker-tools@sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef +/bin/toolkit +write_file diff --git a/crates/brokerd/tests/podman_args.rs b/crates/brokerd/tests/podman_args.rs new file mode 100644 index 0000000..6977c47 --- /dev/null +++ b/crates/brokerd/tests/podman_args.rs @@ -0,0 +1,155 @@ +//! The `podman` argument lists, as golden files: one argument per line, compared exactly. A +//! runtime that only builds the list stands in for Podman, so the lists are built from real +//! `RunSpec`s, which only `runner::run` can make. Do not edit. + +#[path = "support/build.rs"] +mod build; + +use std::ffi::OsString; +use std::path::{Path, PathBuf}; +use std::sync::Mutex; + +use brokerd::config::Runner; +use brokerd::podman::{container_name, egress_args, tool_args}; +use brokerd::policy::{Outcome, SessionState, decide}; +use brokerd::runner::{RunError, RunOutput, RunSpec, Runtime, run}; +use build::{fetch, grant, now, read, request, set, shell, write}; +use proto::{CallId, Mode, SessionId, ToolRequest}; + +const IMAGE: &str = "localhost/boxmaker-tools@sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + +fn runner() -> Runner { + let text = format!("[runner]\nimage = \"{IMAGE}\"\n"); + brokerd::config::Config::parse(&text) + .unwrap() + .runner + .unwrap() +} + +/// Builds the tool's argument list inside `run`, as the real runtime will. +struct Lists { + egress: Option, + got: Mutex>>, +} + +impl Runtime for Lists { + fn run(&self, spec: &RunSpec) -> Result { + let name = container_name(spec.session(), spec.call(), 7); + let args = tool_args(spec, &runner(), &name, self.egress.as_deref()); + self.got.lock().unwrap().push(args); + Ok(RunOutput { + content: String::new(), + truncated: false, + }) + } +} + +fn list_for(grants: Vec, req: ToolRequest, egress: Option<&Path>) -> Vec { + let decision = match decide(req, &set(grants), SessionState::default(), now()) { + Outcome::Allowed(d) => d, + other => panic!("not allowed: {other:?}"), + }; + let lists = Lists { + egress: egress.map(Path::to_path_buf), + got: Mutex::new(Vec::new()), + }; + run(decision, &lists); + let got = lists.got.into_inner().unwrap(); + assert_eq!(got.len(), 1); + got[0] + .iter() + .map(|a| a.to_str().unwrap().to_string()) + .collect() +} + +fn golden(name: &str) -> Vec { + let path = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("tests/fixtures/podman") + .join(name); + let text = std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("{}: {e}", path.display())); + text.lines().map(str::to_string).collect() +} + +#[test] +fn the_container_name_says_whose_call_it_is() { + let s = SessionId::new("chat-17").unwrap(); + assert_eq!(container_name(&s, CallId(42), 3), "boxmaker-chat-17-42-3"); +} + +#[test] +fn read_file_gets_its_one_directory_read_only_and_no_network() { + let got = list_for( + vec![grant("notes", "read_file", Mode::Auto).paths(&["/home/kyle/notes"])], + read("/home/kyle/notes/a.md"), + None, + ); + assert_eq!(got, golden("read_file.args")); +} + +#[test] +fn write_file_gets_its_one_directory_writable() { + let got = list_for( + vec![grant("out", "write_file", Mode::Auto).paths(&["/home/kyle/out"])], + write("/home/kyle/out/b.md"), + None, + ); + assert_eq!(got, golden("write_file.args")); +} + +#[test] +fn shell_gets_every_grant_directory_writable_in_order() { + let got = list_for( + vec![grant("sh", "shell", Mode::Auto).paths(&["/srv/a", "/srv/b"])], + shell(Some("/srv/b")), + None, + ); + assert_eq!(got, golden("shell.args")); + let bare = list_for( + vec![grant("sh", "shell", Mode::Auto)], + request("shell", r#"{"command":"ls"}"#), + None, + ); + assert_eq!(bare, golden("shell_no_paths.args")); +} + +#[test] +fn http_fetch_gets_the_egress_directory_and_still_no_network() { + let got = list_for( + vec![grant("web", "http_fetch", Mode::Auto).hosts(&["example.com"])], + fetch("https://example.com/a"), + Some(Path::new("/h/run/egress/boxmaker-s1-1-7")), + ); + assert_eq!(got, golden("http_fetch.args")); + assert!(got.contains(&"--network=none".to_string())); +} + +#[test] +fn the_proxy_gets_a_network_the_socket_and_the_hosts() { + let got: Vec = egress_args( + &runner(), + "boxmaker-s1-1-7", + Path::new("/h/run/egress/boxmaker-s1-1-7"), + &["example.com".to_string(), "*.example.org".to_string()], + ) + .iter() + .map(|a| a.to_str().unwrap().to_string()) + .collect(); + assert_eq!(got, golden("egress.args")); +} + +#[test] +fn no_argument_holds_a_shell_string_or_a_second_network() { + let got = list_for( + vec![grant("sh", "shell", Mode::Auto).paths(&["/srv/a"])], + request( + "shell", + r#"{"command":"curl evil.test; rm -rf /","cwd":"/srv/a"}"#, + ), + None, + ); + assert!( + got.iter().all(|a| !a.contains("evil.test")), + "the command goes on standard input" + ); + assert_eq!(got.iter().filter(|a| a.starts_with("--network")).count(), 1); +} diff --git a/docs/implementer-log.md b/docs/implementer-log.md index 6be3f9d..125ea55 100644 --- a/docs/implementer-log.md +++ b/docs/implementer-log.md @@ -6,6 +6,7 @@ reviewer adds findings under "Reviews" once per milestone. | Task | Date | Status | Gate runs | First gate | Deviations | Notes | Model | |---|---|---|---|---|---|---|---| +| M3b/10-brokerd-podman-args | 2026-09-23 | done | 2 | fail | none | Wrote `crates/brokerd/src/podman.rs`: the `EGRESS_MOUNT`/`EGRESS_SOCKET`/`TOOLKIT` constants; `container_name` = `boxmaker---`; the private `hardening(pids, memory)` emitting the six shared flags (`--read-only` to `--memory=…`) once; the private `volume(host, container, mode)` built with `push` so a directory need not be UTF-8; `tool_args` = `run --rm -i --name= --label=boxmaker=tool --network=none`, then hardening with `runner.pids`/`runner.memory`, `--tmpfs=/tmp:rw,size=64m,mode=1777`, one `--volume=::ro|rw` per `spec.mounts()` in order, the egress volume when `egress` is Some, then ` /bin/toolkit `; `egress_args` = `run -d --rm --name=-egress --label=boxmaker=egress --network=`, hardening 64/128m, the egress volume, then ` /bin/toolkit egress-proxy --socket /run/egress/egress.sock --allow `. `RunSpec` gained private `session: SessionId` and `call: CallId` as its first two fields with `session()`/`call()` getters; `run` fills them from `decision.request()`, and the `compile_fail` doctest struct literal gains the two fields first. Copied `tests/podman_args.rs` and the six `podman/*.args` golden files. `podman_args` 7 passed, `runner` 8 passed, all doctests pass. First gate failed on clippy `redundant_closure` (`.map(|f| OsString::from(f))` → `.map(OsString::from)`). `make gate` prints `gate: ok`. | ? | | M3b/09-brokerd-runner-config | 2026-09-23 | done | 2 | fail | none | Wrote `crates/brokerd/src/config.rs`: the `Runner` struct (`podman`, `image`, `egress_network`, `output_cap`, `memory`, `pids`, `read_file_ms`, `write_file_ms`, `shell_ms`, `http_fetch_ms`) with `#[serde(deny_unknown_fields)]` and one private `default_…()` per defaulted field; `image` is required with no default. `Config` gained `runner: Option` with `#[serde(default)]`. `Runner::time_limit` maps each `ToolName` to its matching `_ms` field as a `Duration`. `Config::egress_dir()` joins `/run/egress`. `load` runs, after the `ttl_ms` check and only when `runner` is `Some`, the four checks in order (first problem wins): image must be `@sha256:<64 lowercase hex>` via `rsplit_once("@sha256:")` with a non-empty name and exactly 64 `0-9a-f`, memory must be digits then one of b/k/m/g (`valid_memory`), `egress_network`/`podman` non-empty, and the six non-negative fields checked for zero in order — each returns `ConfigError::Invalid`. `Config::parse` runs none of them. Copied `tests/config_runner.rs`, the five `runner_*.toml` fixtures, and the new `support/rig.rs`, which builds `Config` with `runner: None`. All brokerd tests pass; `config` and `config_runner` each 7 passed. First gate failed on clippy `incompatible_msrv`: `PathBuf::is_empty()` is stable since 1.98 but the MSRV is 1.95, fixed with `as_os_str().is_empty()` (the pattern the file already used for the socket paths). `make gate` prints `gate: ok`. | ? | | M3b/08-toolkit-egress-proxy | 2026-09-23 | done | 2 | fail | none | Copied `docs/plans/M3b/files/crates/toolkit/tests/egress.rs` to `crates/toolkit/tests/`. Wrote `crates/toolkit/src/egress.rs`: the reply-code and timeout constants; `Allow { patterns: Vec }` with `parse` splitting on ',' and rejecting any piece that fails `proto::hosts::valid_host_pattern` (an empty piece like `","`/`"x,"`/`""` errors) and `permits` = `valid_host(host) && any host_matches`; the `Dial` trait and `SystemDial` (`to_socket_addrs()?.collect()` and `connect_timeout`); `Proxy { allow, dial: Arc, handshake }` with `new`/`with_handshake_timeout`/`serve`/`handle`. `handle` shares one deadline across the whole handshake: `read_n` reads exactly the byte count the protocol gives, setting the read timeout to `deadline.checked_duration_since(now)` before each read and returning `None` (stop, no reply) on no time left, a timeout, a failed read, or 0 bytes — so a client trickling one byte per 100 ms is still cut at the deadline. The 11 exits are in order (egress.rs:133 version, :144 methods 0, :154 version/reserved, :160 command, :164 kind without reading the address, :172 zero length, :187 non-UTF-8, :190 port/host, :195 resolve / :203 no public addr, :208 connect, :211 success reply); step 9 takes the first address where `crate::addr::is_public` is true via `.find`, never trying the skipped non-public ones. `serve` is a single-threaded accept loop with an `AtomicUsize` count: over the limit is dropped at once, otherwise a `std::thread::Builder` thread handles it and uncounts itself on return, and `serve` never joins (so the second client is not starved). After the handshake the read timeout is cleared and two `try_clone`'d threads copy both ways with a half-close each way, then join. `main.rs` gained the `egress-proxy --socket --allow ` form before the tool form via `std::env::args_os().skip(1)` (without `skip(1)` the first element is the program path and the form never matches); `Allow::parse`/bind (no removal first — a pre-existing file is a mistake)/`serve` errors print to stderr and exit 2. `lib.rs` gained `pub mod egress;`. First gate failed on clippy `question_mark` (accept loop → `stream?`) and `manual_contains` (`methods.contains(&0)`); fixed both and `make gate` prints `gate: ok`. 15 egress tests pass ten runs in a row. | ? | | M3b/07-toolkit-addr | 2026-09-23 | done | 1 | pass | none | Wrote `crates/toolkit/src/addr.rs`: `is_public(ip)` matches on `IpAddr` and dispatches to `is_public_v4`/`is_public_v6`. The IPv4 function checks the 13 refused ranges in table order with early returns (lines 16-52) then returns `true`. The IPv6 function checks the two "judge as IPv4" rows first — `is_ipv4_mapped` for `::ffff:0:0/96` (lines 60-63) and `is_nat64` for `64:ff9b::/96` (lines 64-67) — reconstructing the last 32 bits as an `Ipv4Addr` via `(u32::from(s[6]) << 16) | u32::from(s[7])` with no `as` casts, then judging it through `is_public_v4`; the remaining rows `::/96` (line 68), `fc00::/7` (71), `fe80::/10` (74), `ff00::/8` (77) and `2001:db8::/32` (80) follow. `cargo fmt --all` first. Added `pub mod addr;` to `lib.rs` in alphabetical position (before `fetch`). All 4 addr tests pass; `make gate` prints `gate: ok` on the first run. | ? |