brokerd: the [runner] section
Implemented-By: OpenCode session (model recorded in docs/implementer-log.md)
This commit is contained in:
@@ -45,6 +45,79 @@ impl Default for Approvals {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn default_podman() -> PathBuf {
|
||||||
|
PathBuf::from("podman")
|
||||||
|
}
|
||||||
|
fn default_egress_network() -> String {
|
||||||
|
"pasta".to_string()
|
||||||
|
}
|
||||||
|
fn default_output_cap() -> u64 {
|
||||||
|
262_144
|
||||||
|
}
|
||||||
|
fn default_memory() -> String {
|
||||||
|
"512m".to_string()
|
||||||
|
}
|
||||||
|
fn default_pids() -> u32 {
|
||||||
|
128
|
||||||
|
}
|
||||||
|
fn default_read_file_ms() -> u64 {
|
||||||
|
30_000
|
||||||
|
}
|
||||||
|
fn default_write_file_ms() -> u64 {
|
||||||
|
30_000
|
||||||
|
}
|
||||||
|
fn default_shell_ms() -> u64 {
|
||||||
|
100_000
|
||||||
|
}
|
||||||
|
fn default_http_fetch_ms() -> u64 {
|
||||||
|
60_000
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct Runner {
|
||||||
|
#[serde(default = "default_podman")]
|
||||||
|
pub podman: PathBuf,
|
||||||
|
pub image: String,
|
||||||
|
#[serde(default = "default_egress_network")]
|
||||||
|
pub egress_network: String,
|
||||||
|
#[serde(default = "default_output_cap")]
|
||||||
|
pub output_cap: u64,
|
||||||
|
#[serde(default = "default_memory")]
|
||||||
|
pub memory: String,
|
||||||
|
#[serde(default = "default_pids")]
|
||||||
|
pub pids: u32,
|
||||||
|
#[serde(default = "default_read_file_ms")]
|
||||||
|
pub read_file_ms: u64,
|
||||||
|
#[serde(default = "default_write_file_ms")]
|
||||||
|
pub write_file_ms: u64,
|
||||||
|
#[serde(default = "default_shell_ms")]
|
||||||
|
pub shell_ms: u64,
|
||||||
|
#[serde(default = "default_http_fetch_ms")]
|
||||||
|
pub http_fetch_ms: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Runner {
|
||||||
|
/// The time limit for one call of `tool`: the matching `_ms` field.
|
||||||
|
pub fn time_limit(&self, tool: crate::args::ToolName) -> std::time::Duration {
|
||||||
|
let ms = match tool {
|
||||||
|
crate::args::ToolName::ReadFile => self.read_file_ms,
|
||||||
|
crate::args::ToolName::WriteFile => self.write_file_ms,
|
||||||
|
crate::args::ToolName::Shell => self.shell_ms,
|
||||||
|
crate::args::ToolName::HttpFetch => self.http_fetch_ms,
|
||||||
|
};
|
||||||
|
std::time::Duration::from_millis(ms)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `memory` is well formed if it is one or more digits followed by `b`, `k`, `m` or `g`.
|
||||||
|
fn valid_memory(memory: &str) -> bool {
|
||||||
|
let bytes = memory.as_bytes();
|
||||||
|
bytes.len() >= 2
|
||||||
|
&& matches!(bytes[bytes.len() - 1], b'b' | b'k' | b'm' | b'g')
|
||||||
|
&& bytes[..bytes.len() - 1].iter().all(|&c| c.is_ascii_digit())
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Default)]
|
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Default)]
|
||||||
#[serde(deny_unknown_fields)]
|
#[serde(deny_unknown_fields)]
|
||||||
pub struct Config {
|
pub struct Config {
|
||||||
@@ -54,6 +127,8 @@ pub struct Config {
|
|||||||
pub sockets: Sockets,
|
pub sockets: Sockets,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub approvals: Approvals,
|
pub approvals: Approvals,
|
||||||
|
#[serde(default)]
|
||||||
|
pub runner: Option<Runner>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The longest an approval may wait: a day, the longest `loopd` waits after a pending frame.
|
/// The longest an approval may wait: a day, the longest `loopd` waits after a pending frame.
|
||||||
@@ -99,6 +174,78 @@ impl Config {
|
|||||||
),
|
),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
if let Some(runner) = &config.runner {
|
||||||
|
let where_image = format!(
|
||||||
|
"[runner] image is {}; it must be named by digest: <name>@sha256:<64 hex digits>",
|
||||||
|
runner.image
|
||||||
|
);
|
||||||
|
let (name, hex) = match runner.image.rsplit_once("@sha256:") {
|
||||||
|
Some(pair) => pair,
|
||||||
|
None => {
|
||||||
|
return Err(ConfigError::Invalid(
|
||||||
|
path.to_path_buf(),
|
||||||
|
where_image.clone(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if name.is_empty()
|
||||||
|
|| hex.len() != 64
|
||||||
|
|| !hex.chars().all(|c| matches!(c, '0'..='9' | 'a'..='f'))
|
||||||
|
{
|
||||||
|
return Err(ConfigError::Invalid(path.to_path_buf(), where_image));
|
||||||
|
}
|
||||||
|
if !valid_memory(&runner.memory) {
|
||||||
|
return Err(ConfigError::Invalid(
|
||||||
|
path.to_path_buf(),
|
||||||
|
format!(
|
||||||
|
"[runner] memory is {}; it must be a number and one of b, k, m, g",
|
||||||
|
runner.memory
|
||||||
|
),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if runner.egress_network.is_empty() || runner.podman.as_os_str().is_empty() {
|
||||||
|
return Err(ConfigError::Invalid(
|
||||||
|
path.to_path_buf(),
|
||||||
|
"[runner] podman and egress_network must not be empty".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if runner.output_cap == 0 {
|
||||||
|
return Err(ConfigError::Invalid(
|
||||||
|
path.to_path_buf(),
|
||||||
|
"[runner] output_cap must be at least 1".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if runner.pids == 0 {
|
||||||
|
return Err(ConfigError::Invalid(
|
||||||
|
path.to_path_buf(),
|
||||||
|
"[runner] pids must be at least 1".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if runner.read_file_ms == 0 {
|
||||||
|
return Err(ConfigError::Invalid(
|
||||||
|
path.to_path_buf(),
|
||||||
|
"[runner] read_file_ms must be at least 1".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if runner.write_file_ms == 0 {
|
||||||
|
return Err(ConfigError::Invalid(
|
||||||
|
path.to_path_buf(),
|
||||||
|
"[runner] write_file_ms must be at least 1".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if runner.shell_ms == 0 {
|
||||||
|
return Err(ConfigError::Invalid(
|
||||||
|
path.to_path_buf(),
|
||||||
|
"[runner] shell_ms must be at least 1".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if runner.http_fetch_ms == 0 {
|
||||||
|
return Err(ConfigError::Invalid(
|
||||||
|
path.to_path_buf(),
|
||||||
|
"[runner] http_fetch_ms must be at least 1".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
Ok(config)
|
Ok(config)
|
||||||
}
|
}
|
||||||
pub fn broker_socket(&self) -> PathBuf {
|
pub fn broker_socket(&self) -> PathBuf {
|
||||||
@@ -121,4 +268,7 @@ impl Config {
|
|||||||
pub fn state_dir(&self) -> PathBuf {
|
pub fn state_dir(&self) -> PathBuf {
|
||||||
self.paths.home.join("broker/sessions")
|
self.paths.home.join("broker/sessions")
|
||||||
}
|
}
|
||||||
|
pub fn egress_dir(&self) -> PathBuf {
|
||||||
|
self.paths.home.join("run/egress")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,140 @@
|
|||||||
|
//! `[runner]` in `brokerd.toml` (M3b spec, section 6). Do not edit.
|
||||||
|
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use brokerd::args::ToolName;
|
||||||
|
use brokerd::config::{Config, ConfigError};
|
||||||
|
|
||||||
|
const DIGEST: &str = "localhost/boxmaker-tools@sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
||||||
|
|
||||||
|
fn fixture(name: &str) -> PathBuf {
|
||||||
|
Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||||
|
.join("tests/fixtures/config")
|
||||||
|
.join(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn invalid(name: &str) -> String {
|
||||||
|
match Config::load(&fixture(name)) {
|
||||||
|
Err(ConfigError::Invalid(_, why)) => why,
|
||||||
|
other => panic!("{name}: expected Invalid, got {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn without_a_runner_section_there_is_no_runner() {
|
||||||
|
assert_eq!(Config::load(&fixture("empty.toml")).unwrap().runner, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_runner_with_only_an_image_gets_every_default() {
|
||||||
|
let r = Config::load(&fixture("runner_minimal.toml"))
|
||||||
|
.unwrap()
|
||||||
|
.runner
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(r.podman, PathBuf::from("podman"));
|
||||||
|
assert_eq!(r.image, DIGEST);
|
||||||
|
assert_eq!(r.egress_network, "pasta");
|
||||||
|
assert_eq!(r.output_cap, 262_144);
|
||||||
|
assert_eq!(r.memory, "512m");
|
||||||
|
assert_eq!(r.pids, 128);
|
||||||
|
assert_eq!(
|
||||||
|
r.time_limit(ToolName::ReadFile),
|
||||||
|
Duration::from_millis(30_000)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
r.time_limit(ToolName::WriteFile),
|
||||||
|
Duration::from_millis(30_000)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
r.time_limit(ToolName::Shell),
|
||||||
|
Duration::from_millis(100_000)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
r.time_limit(ToolName::HttpFetch),
|
||||||
|
Duration::from_millis(60_000)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn every_runner_value_can_be_set() {
|
||||||
|
let r = Config::load(&fixture("runner_full.toml"))
|
||||||
|
.unwrap()
|
||||||
|
.runner
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(r.podman, PathBuf::from("/run/current-system/sw/bin/podman"));
|
||||||
|
assert_eq!(r.egress_network, "slirp4netns");
|
||||||
|
assert_eq!((r.output_cap, r.memory.as_str(), r.pids), (1000, "1g", 64));
|
||||||
|
let limits: Vec<Duration> = ToolName::ALL.iter().map(|t| r.time_limit(*t)).collect();
|
||||||
|
assert_eq!(limits, [1, 2, 3, 4].map(Duration::from_millis));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_runner_needs_an_image_named_by_digest() {
|
||||||
|
assert!(matches!(
|
||||||
|
Config::load(&fixture("runner_no_image.toml")),
|
||||||
|
Err(ConfigError::Parse(..))
|
||||||
|
));
|
||||||
|
assert!(invalid("runner_tag.toml").contains("by digest"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unknown_runner_keys_are_errors() {
|
||||||
|
assert!(matches!(
|
||||||
|
Config::load(&fixture("runner_unknown_key.toml")),
|
||||||
|
Err(ConfigError::Parse(..))
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn bad_values_are_errors_that_name_them() {
|
||||||
|
let dir = std::env::temp_dir().join(format!("bx-runner-cfg-{}", std::process::id()));
|
||||||
|
std::fs::create_dir_all(&dir).unwrap();
|
||||||
|
let cases = [
|
||||||
|
(
|
||||||
|
format!("image = \"x@sha256:{}\"", "0".repeat(63)),
|
||||||
|
"by digest",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
format!("image = \"x@sha256:{}\"", "A".repeat(64)),
|
||||||
|
"by digest",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
format!("image = \"@sha256:{}\"", "0".repeat(64)),
|
||||||
|
"by digest",
|
||||||
|
),
|
||||||
|
(format!("image = \"{DIGEST}\"\nmemory = \"lots\""), "memory"),
|
||||||
|
(format!("image = \"{DIGEST}\"\nmemory = \"512\""), "memory"),
|
||||||
|
(format!("image = \"{DIGEST}\"\nmemory = \"m\""), "memory"),
|
||||||
|
(
|
||||||
|
format!("image = \"{DIGEST}\"\noutput_cap = 0"),
|
||||||
|
"output_cap",
|
||||||
|
),
|
||||||
|
(format!("image = \"{DIGEST}\"\npids = 0"), "pids"),
|
||||||
|
(format!("image = \"{DIGEST}\"\nshell_ms = 0"), "shell_ms"),
|
||||||
|
(
|
||||||
|
format!("image = \"{DIGEST}\"\nhttp_fetch_ms = 0"),
|
||||||
|
"http_fetch_ms",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
format!("image = \"{DIGEST}\"\negress_network = \"\""),
|
||||||
|
"egress_network",
|
||||||
|
),
|
||||||
|
(format!("image = \"{DIGEST}\"\npodman = \"\""), "podman"),
|
||||||
|
];
|
||||||
|
for (n, (body, word)) in cases.iter().enumerate() {
|
||||||
|
let path = dir.join(format!("c{n}.toml"));
|
||||||
|
std::fs::write(&path, format!("[runner]\n{body}\n")).unwrap();
|
||||||
|
match Config::load(&path) {
|
||||||
|
Err(ConfigError::Invalid(_, why)) => assert!(why.contains(word), "{body}: {why}"),
|
||||||
|
other => panic!("{body}: expected Invalid, got {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_egress_directory_is_under_home() {
|
||||||
|
let c = Config::parse("[paths]\nhome = \"/h\"\n").unwrap();
|
||||||
|
assert_eq!(c.egress_dir(), PathBuf::from("/h/run/egress"));
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
[runner]
|
||||||
|
podman = "/run/current-system/sw/bin/podman"
|
||||||
|
image = "localhost/boxmaker-tools@sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
|
||||||
|
egress_network = "slirp4netns"
|
||||||
|
output_cap = 1000
|
||||||
|
memory = "1g"
|
||||||
|
pids = 64
|
||||||
|
read_file_ms = 1
|
||||||
|
write_file_ms = 2
|
||||||
|
shell_ms = 3
|
||||||
|
http_fetch_ms = 4
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
[runner]
|
||||||
|
image = "localhost/boxmaker-tools@sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
[runner]
|
||||||
|
podman = "podman"
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
[runner]
|
||||||
|
image = "localhost/boxmaker-tools:latest"
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
[runner]
|
||||||
|
image = "localhost/boxmaker-tools@sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
|
||||||
|
network = "host"
|
||||||
@@ -40,6 +40,7 @@ impl Rig {
|
|||||||
},
|
},
|
||||||
sockets: Sockets::default(),
|
sockets: Sockets::default(),
|
||||||
approvals: Approvals { ttl_ms },
|
approvals: Approvals { ttl_ms },
|
||||||
|
runner: None,
|
||||||
};
|
};
|
||||||
Rig {
|
Rig {
|
||||||
dir,
|
dir,
|
||||||
|
|||||||
@@ -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/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<Runner>` with `#[serde(default)]`. `Runner::time_limit` maps each `ToolName` to its matching `_ms` field as a `Duration`. `Config::egress_dir()` joins `<home>/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 `<name>@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<String> }` 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<dyn Dial>, 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 <path> --allow <list>` 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/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<String> }` 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<dyn Dial>, 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 <path> --allow <list>` 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. | ? |
|
| 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`. | ? |
|
||||||
|
|||||||
Reference in New Issue
Block a user