toolkit: is_public, the addresses the egress proxy may reach
Implemented-By: OpenCode session (model recorded in docs/implementer-log.md)
This commit is contained in:
@@ -0,0 +1,94 @@
|
|||||||
|
//! `is_public`: the egress proxy connects only to public addresses. Everything in the M3b spec,
|
||||||
|
//! section 5, "Refused ranges" tables is not public; everything else is.
|
||||||
|
|
||||||
|
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
|
||||||
|
|
||||||
|
/// True if `ip` is a public unicast address.
|
||||||
|
pub fn is_public(ip: IpAddr) -> bool {
|
||||||
|
match ip {
|
||||||
|
IpAddr::V4(v4) => is_public_v4(v4),
|
||||||
|
IpAddr::V6(v6) => is_public_v6(v6),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_public_v4(ip: Ipv4Addr) -> bool {
|
||||||
|
let [a, b, c, _] = ip.octets();
|
||||||
|
if a == 0 {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if a == 10 {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if a == 100 && (64..=127).contains(&b) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if a == 127 {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if a == 169 && b == 254 {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if a == 172 && (16..=31).contains(&b) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if a == 192 && b == 0 && c == 0 {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if a == 192 && b == 0 && c == 2 {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if a == 192 && b == 168 {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if a == 198 && (b == 18 || b == 19) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if a == 198 && b == 51 && c == 100 {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if a == 203 && b == 0 && c == 113 {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if a >= 224 {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_public_v6(ip: Ipv6Addr) -> bool {
|
||||||
|
let s = ip.segments();
|
||||||
|
if is_ipv4_mapped(s) {
|
||||||
|
let last32 = (u32::from(s[6]) << 16) | u32::from(s[7]);
|
||||||
|
return is_public_v4(Ipv4Addr::from(last32));
|
||||||
|
}
|
||||||
|
if is_nat64(s) {
|
||||||
|
let last32 = (u32::from(s[6]) << 16) | u32::from(s[7]);
|
||||||
|
return is_public_v4(Ipv4Addr::from(last32));
|
||||||
|
}
|
||||||
|
if s[0..6].iter().all(|&x| x == 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if s[0] & 0xfe00 == 0xfc00 {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if s[0] & 0xffc0 == 0xfe80 {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if s[0] & 0xff00 == 0xff00 {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if s[0] == 0x2001 && s[1] == 0x0db8 {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `::ffff:0:0/96`: the high 80 bits are zero, then the `ffff` marker.
|
||||||
|
fn is_ipv4_mapped(s: [u16; 8]) -> bool {
|
||||||
|
s[0..5].iter().all(|&x| x == 0) && s[5] == 0xffff
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `64:ff9b::/96`: the NAT64 prefix, then 32 zero bits, then the embedded IPv4 address.
|
||||||
|
fn is_nat64(s: [u16; 8]) -> bool {
|
||||||
|
s[0] == 0x64 && s[1] == 0xff9b && s[2] == 0 && s[3] == 0 && s[4] == 0 && s[5] == 0
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
use proto::tools::{HttpFetchArgs, ReadFileArgs, ShellArgs, WriteFileArgs};
|
use proto::tools::{HttpFetchArgs, ReadFileArgs, ShellArgs, WriteFileArgs};
|
||||||
|
|
||||||
|
pub mod addr;
|
||||||
pub mod fetch;
|
pub mod fetch;
|
||||||
pub mod files;
|
pub mod files;
|
||||||
pub mod input;
|
pub mod input;
|
||||||
|
|||||||
@@ -0,0 +1,116 @@
|
|||||||
|
//! `is_public`: the egress proxy connects only to public addresses. Every range in the M3b spec,
|
||||||
|
//! section 5, has a case at each end, and a public neighbour just outside it. Do not edit.
|
||||||
|
|
||||||
|
use std::net::IpAddr;
|
||||||
|
|
||||||
|
use toolkit::addr::is_public;
|
||||||
|
|
||||||
|
fn ip(s: &str) -> IpAddr {
|
||||||
|
s.parse().unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn refused_ipv4() {
|
||||||
|
for s in [
|
||||||
|
"0.0.0.0",
|
||||||
|
"0.255.255.255",
|
||||||
|
"10.0.0.0",
|
||||||
|
"10.255.255.255",
|
||||||
|
"100.64.0.0",
|
||||||
|
"100.100.100.100",
|
||||||
|
"100.127.255.255",
|
||||||
|
"127.0.0.1",
|
||||||
|
"127.255.255.255",
|
||||||
|
"169.254.0.1",
|
||||||
|
"169.254.255.255",
|
||||||
|
"172.16.0.0",
|
||||||
|
"172.31.255.255",
|
||||||
|
"192.0.0.0",
|
||||||
|
"192.0.0.255",
|
||||||
|
"192.0.2.1",
|
||||||
|
"192.168.0.1",
|
||||||
|
"192.168.255.255",
|
||||||
|
"198.18.0.0",
|
||||||
|
"198.19.255.255",
|
||||||
|
"198.51.100.7",
|
||||||
|
"203.0.113.9",
|
||||||
|
"224.0.0.1",
|
||||||
|
"239.255.255.255",
|
||||||
|
"240.0.0.0",
|
||||||
|
"255.255.255.255",
|
||||||
|
] {
|
||||||
|
assert!(!is_public(ip(s)), "{s} must be refused");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn public_ipv4() {
|
||||||
|
for s in [
|
||||||
|
"1.1.1.1",
|
||||||
|
"8.8.8.8",
|
||||||
|
"9.255.255.255",
|
||||||
|
"11.0.0.0",
|
||||||
|
"100.63.255.255",
|
||||||
|
"100.128.0.0",
|
||||||
|
"126.255.255.255",
|
||||||
|
"128.0.0.0",
|
||||||
|
"169.253.255.255",
|
||||||
|
"172.15.255.255",
|
||||||
|
"172.32.0.0",
|
||||||
|
"192.0.1.0",
|
||||||
|
"192.0.3.0",
|
||||||
|
"192.167.255.255",
|
||||||
|
"192.169.0.0",
|
||||||
|
"198.17.255.255",
|
||||||
|
"198.20.0.0",
|
||||||
|
"198.51.99.255",
|
||||||
|
"203.0.112.255",
|
||||||
|
"223.255.255.255",
|
||||||
|
"93.184.216.34",
|
||||||
|
] {
|
||||||
|
assert!(is_public(ip(s)), "{s} is public");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn refused_ipv6() {
|
||||||
|
for s in [
|
||||||
|
"::",
|
||||||
|
"::1",
|
||||||
|
"fc00::1",
|
||||||
|
"fdff:ffff::1",
|
||||||
|
"fd7a:115c:a1e0::1",
|
||||||
|
"fe80::1",
|
||||||
|
"febf::1",
|
||||||
|
"ff02::1",
|
||||||
|
"ff00::",
|
||||||
|
"2001:db8::1",
|
||||||
|
"2001:db8:ffff::1",
|
||||||
|
"::ffff:127.0.0.1",
|
||||||
|
"::ffff:10.1.2.3",
|
||||||
|
"::ffff:100.100.100.100",
|
||||||
|
"64:ff9b::7f00:1",
|
||||||
|
"64:ff9b::a01:203",
|
||||||
|
"::ffff:0.0.0.0",
|
||||||
|
"::127.0.0.1",
|
||||||
|
"::1.1.1.1",
|
||||||
|
"::ffff",
|
||||||
|
] {
|
||||||
|
assert!(!is_public(ip(s)), "{s} must be refused");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn public_ipv6() {
|
||||||
|
for s in [
|
||||||
|
"2606:4700:4700::1111",
|
||||||
|
"2a00:1450::1",
|
||||||
|
"fbff::1",
|
||||||
|
"fec0::1",
|
||||||
|
"2001:db9::1",
|
||||||
|
"::ffff:1.1.1.1",
|
||||||
|
"64:ff9b::101:101",
|
||||||
|
] {
|
||||||
|
assert!(is_public(ip(s)), "{s} is public");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ reviewer adds findings under "Reviews" once per milestone.
|
|||||||
|
|
||||||
| Task | Date | Status | Gate runs | First gate | Deviations | Notes | Model |
|
| Task | Date | Status | Gate runs | First gate | Deviations | Notes | Model |
|
||||||
|---|---|---|---|---|---|---|---|
|
|---|---|---|---|---|---|---|---|
|
||||||
|
| 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. | ? |
|
||||||
| M3b/06-toolkit-fetch | 2026-09-23 | done | 3 | fail | none | Wrote `crates/toolkit/src/fetch.rs`: `CURL=/bin/curl`, `PROXY=socks5h://localhost/run/egress/egress.sock`, `CA_BUNDLE=/etc/ssl/certs/ca-certificates.crt`, `MAX_STDERR=65536`. `curl_args(url)` returns the 21 fixed strings in order ending in `--url <url>`. `fetch` calls `fetch_with(Path::new(CURL), args)`. `fetch_with`: spawn curl with `curl_args(&args.url)`, null stdin, stdout/stderr piped (spawn failure -> `"http_fetch: cannot start {}: {e}"`); read stderr on its own thread via the `read_capped` helper (8 KiB buffer, keeps the first 64 KiB and drains/discards the rest so curl never blocks on a full pipe), main thread reads stdout to the end; `wait()` then join the thread (a panicked reader falls back to empty stderr); exit 0 -> `done(from_utf8_lossy(body))` with the `--write-out` status line already in it, else the first non-blank trimmed stderr line as `why` or `"curl exited {code}"` or `"curl was killed"` when there is no code, wrapped as `"http_fetch: {url}: {why}"`, and a wait failure -> `"http_fetch: cannot wait for curl: {e}"`. `lib.rs` gained `pub mod fetch` (between `files` and `input`) and an `"http_fetch"` arm parsing `HttpFetchArgs` like the other three tools. First gate run failed on a rustfmt line-wrap of the spawn-error return; second on clippy `manual_unwrap_or_default` for the stderr-join match, switched to `unwrap_or_default()`. 7 fetch tests pass ten runs; `make gate` prints `gate: ok`. | ? |
|
| M3b/06-toolkit-fetch | 2026-09-23 | done | 3 | fail | none | Wrote `crates/toolkit/src/fetch.rs`: `CURL=/bin/curl`, `PROXY=socks5h://localhost/run/egress/egress.sock`, `CA_BUNDLE=/etc/ssl/certs/ca-certificates.crt`, `MAX_STDERR=65536`. `curl_args(url)` returns the 21 fixed strings in order ending in `--url <url>`. `fetch` calls `fetch_with(Path::new(CURL), args)`. `fetch_with`: spawn curl with `curl_args(&args.url)`, null stdin, stdout/stderr piped (spawn failure -> `"http_fetch: cannot start {}: {e}"`); read stderr on its own thread via the `read_capped` helper (8 KiB buffer, keeps the first 64 KiB and drains/discards the rest so curl never blocks on a full pipe), main thread reads stdout to the end; `wait()` then join the thread (a panicked reader falls back to empty stderr); exit 0 -> `done(from_utf8_lossy(body))` with the `--write-out` status line already in it, else the first non-blank trimmed stderr line as `why` or `"curl exited {code}"` or `"curl was killed"` when there is no code, wrapped as `"http_fetch: {url}: {why}"`, and a wait failure -> `"http_fetch: cannot wait for curl: {e}"`. `lib.rs` gained `pub mod fetch` (between `files` and `input`) and an `"http_fetch"` arm parsing `HttpFetchArgs` like the other three tools. First gate run failed on a rustfmt line-wrap of the spawn-error return; second on clippy `manual_unwrap_or_default` for the stderr-join match, switched to `unwrap_or_default()`. 7 fetch tests pass ten runs; `make gate` prints `gate: ok`. | ? |
|
||||||
| M3b/05-toolkit-shell | 2026-09-23 | done | 2 | fail | none | Wrote `crates/toolkit/src/shell.rs` (`SHELL=/bin/sh`, `DEFAULT_CWD=/tmp`, `MAX_OUTPUT=1048576`). `shell()`: `cwd` defaults to `/tmp`, `Path::is_dir` check returns `"shell: {cwd}: no such directory"`; one `io::pipe` with a `try_clone`'d second write end (stdout gets `writer`, stderr gets `writer2`); `Command::new(SHELL).arg("-c").arg(command).current_dir(cwd)` with null stdin, `spawn`, then `drop(command)` so the parent holds no write end and reading reaches EOF (the hang fix); an 8 KiB-buffer read loop keeping the first `MAX_OUTPUT` bytes and draining+discarding the rest with a `dropped` flag, retrying `Interrupted`, decoding with `from_utf8_lossy`; `wait()` then appending the dropped line and the exit/signal/none status in that order via a `match` on `code()`/`signal()` → `Outcome::done`. `lib.rs` gained `pub mod shell` (after `input`) and a `"shell"` arm parsing `ShellArgs` like the other two tools. One logic bug caught by the limit test: when the buffer fills `MAX_OUTPUT` exactly the drain branch never set `dropped`, fixed by marking `dropped` in the drain branch. First gate failed on an unused `Write` import (removed); 8 shell tests pass five runs, `make gate` prints `gate: ok`. | ? |
|
| M3b/05-toolkit-shell | 2026-09-23 | done | 2 | fail | none | Wrote `crates/toolkit/src/shell.rs` (`SHELL=/bin/sh`, `DEFAULT_CWD=/tmp`, `MAX_OUTPUT=1048576`). `shell()`: `cwd` defaults to `/tmp`, `Path::is_dir` check returns `"shell: {cwd}: no such directory"`; one `io::pipe` with a `try_clone`'d second write end (stdout gets `writer`, stderr gets `writer2`); `Command::new(SHELL).arg("-c").arg(command).current_dir(cwd)` with null stdin, `spawn`, then `drop(command)` so the parent holds no write end and reading reaches EOF (the hang fix); an 8 KiB-buffer read loop keeping the first `MAX_OUTPUT` bytes and draining+discarding the rest with a `dropped` flag, retrying `Interrupted`, decoding with `from_utf8_lossy`; `wait()` then appending the dropped line and the exit/signal/none status in that order via a `match` on `code()`/`signal()` → `Outcome::done`. `lib.rs` gained `pub mod shell` (after `input`) and a `"shell"` arm parsing `ShellArgs` like the other two tools. One logic bug caught by the limit test: when the buffer fills `MAX_OUTPUT` exactly the drain branch never set `dropped`, fixed by marking `dropped` in the drain branch. First gate failed on an unused `Write` import (removed); 8 shell tests pass five runs, `make gate` prints `gate: ok`. | ? |
|
||||||
| M3b/04-toolkit-files | 2026-09-22 | done | 1 | pass | none | Wrote `crates/toolkit/src/input.rs` (`MAX_INPUT`, `InputError` with hand-written `Display` + `std::error::Error` + `From<io::Error>`, `read_input` reading at most `MAX_INPUT+1` bytes via `Read::take` and flagging `TooLarge`, `parse` returning the whole stderr line) and `crates/toolkit/src/files.rs` (`MAX_READ`, `read_file` and `write_file`). `read_file` walks the exits in order: metadata NotFound→"no such file", other metadata error→Display text, directory→"is a directory", open failure→text, `take(MAX_READ+1)` read failure→text, over-limit→"larger than {MAX_READ} bytes", non-UTF-8→"not UTF-8 text", else `done`. `write_file` checks the parent is an existing directory before writing so no directory is ever created, then the path-is-directory case, then `fs::write`. Replaced the `lib.rs`/`main.rs` stubs: `Exit` via a `match` (no `as`), `Outcome` with `done`/`tool_error`/`misuse` (misuse stdout empty), `run` reading input before the name lookup for every name. `Cargo.toml` gained `serde` and `serde_json` workspace deps; `dependencies.md` lists `toolkit` under both. Copied the two given test files. `an_unreadable_file_names_the_error` ran for real (UID 1000, not root). 7 `files.rs` tests pass; `make gate` prints `gate: ok` on the first run. | ? |
|
| M3b/04-toolkit-files | 2026-09-22 | done | 1 | pass | none | Wrote `crates/toolkit/src/input.rs` (`MAX_INPUT`, `InputError` with hand-written `Display` + `std::error::Error` + `From<io::Error>`, `read_input` reading at most `MAX_INPUT+1` bytes via `Read::take` and flagging `TooLarge`, `parse` returning the whole stderr line) and `crates/toolkit/src/files.rs` (`MAX_READ`, `read_file` and `write_file`). `read_file` walks the exits in order: metadata NotFound→"no such file", other metadata error→Display text, directory→"is a directory", open failure→text, `take(MAX_READ+1)` read failure→text, over-limit→"larger than {MAX_READ} bytes", non-UTF-8→"not UTF-8 text", else `done`. `write_file` checks the parent is an existing directory before writing so no directory is ever created, then the path-is-directory case, then `fs::write`. Replaced the `lib.rs`/`main.rs` stubs: `Exit` via a `match` (no `as`), `Outcome` with `done`/`tool_error`/`misuse` (misuse stdout empty), `run` reading input before the name lookup for every name. `Cargo.toml` gained `serde` and `serde_json` workspace deps; `dependencies.md` lists `toolkit` under both. Copied the two given test files. `an_unreadable_file_names_the_error` ran for real (UID 1000, not root). 7 `files.rs` tests pass; `make gate` prints `gate: ok` on the first run. | ? |
|
||||||
|
|||||||
Reference in New Issue
Block a user