Specify and plan M3b: the runner and the tools

A draft spec for the owner's review and 13 offline tasks with their given
tests: shared tool arguments and host rules in proto, the sealed fetch
target (M3a finding 14), the toolkit tools and SOCKS5 egress proxy, and
brokerd's [runner], podman argument lists, runtime and proxy lifecycle. Each
task's tests were run against a reference at that task's end state (560 to
638 tests, clippy clean); the reference is not in the repository. Adds the
runner-unavailable runbook entry and tip T23 (ETXTBSY in script tests).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-22 22:29:27 -07:00
co-authored by Claude Opus 5.5
parent d988edac4a
commit b426ca1958
47 changed files with 4491 additions and 2 deletions
@@ -0,0 +1,428 @@
//! Tests for tool arguments: their shape, and the form of paths, hosts and URLs. Do not edit.
//!
//! Every row of the "Paths" and "Hosts" tables in the M3a spec is a case here.
use brokerd::args::{
ArgsError, MAX_PATH, MAX_URL, ToolArgs, ToolName, host_matches, inside, parse, url_host,
valid_host, valid_host_pattern, valid_path,
};
#[test]
fn the_four_tool_names() {
let names = ["read_file", "write_file", "shell", "http_fetch"];
for (tool, name) in ToolName::ALL.into_iter().zip(names) {
assert_eq!(tool.as_str(), name);
assert_eq!(ToolName::parse(name), Some(tool));
}
for other in [
"",
"echo",
"clock",
"call_tool",
"Read_File",
"read_file ",
"readfile",
] {
assert_eq!(ToolName::parse(other), None, "{other:?}");
}
}
#[test]
fn valid_paths() {
let longest = format!("/{}", "a".repeat(MAX_PATH - 1));
assert_eq!(longest.len(), MAX_PATH);
for path in [
"/",
"/etc",
"/home/kyle/notes/a.md",
"/home/kyle/notes",
"/with space/and\ttab",
"/dots.in.names/..hidden/...",
"/unicode/\u{e9}t\u{e9}",
longest.as_str(),
] {
assert!(valid_path(path), "{path:?} should be valid");
}
}
#[test]
fn invalid_paths() {
let too_long = format!("/{}", "a".repeat(MAX_PATH));
assert_eq!(too_long.len(), MAX_PATH + 1);
for path in [
"",
"notes/a.md",
"./notes",
"~/notes",
"/home/kyle/notes/../.ssh/id",
"/home/kyle//notes/./a.md",
"/home//kyle",
"/home/./kyle",
"/home/kyle/",
"/home/kyle/..",
"/..",
"/.",
"//",
"/nul\0byte",
too_long.as_str(),
] {
assert!(!valid_path(path), "{path:?} should be invalid");
}
}
/// The table in the spec, row by row, for the rows about form and containment.
#[test]
fn inside_is_by_whole_components() {
let grant = "/home/kyle/notes";
assert!(inside(grant, "/home/kyle/notes/a.md"));
assert!(inside(grant, "/home/kyle/notes"));
assert!(inside(grant, "/home/kyle/notes/deep/er/b.md"));
assert!(!inside(grant, "/home/kyle/notes2/a.md"));
assert!(!inside(grant, "/home/kyle/note"));
assert!(!inside(grant, "/home/kyle"));
assert!(!inside(grant, "/"));
assert!(!inside(grant, "/other/home/kyle/notes/a.md"));
// A grant of the root is refused when grants are loaded, but the function is still right.
assert!(inside("/", "/etc/passwd"));
assert!(inside("/", "/"));
}
#[test]
fn valid_hosts_and_patterns() {
let label63 = "a".repeat(63);
let long = format!("{label63}.{label63}.{label63}.{}.com", "b".repeat(57));
assert_eq!(long.len(), 253);
for host in [
"example.com",
"www.example.com",
"a.b.example.com",
"xn--bcher-kva.example",
"1password.com",
"3.example.org",
"a-b.c-d.io",
long.as_str(),
] {
assert!(valid_host(host), "{host:?} should be a valid host");
assert!(
valid_host_pattern(host),
"{host:?} should be a valid pattern"
);
let wild = format!("*.{host}");
assert!(!valid_host(&wild), "{wild:?} is a pattern, not a host");
}
assert!(valid_host_pattern("*.example.com"));
assert!(valid_host_pattern("*.a.b.example.com"));
}
#[test]
fn invalid_hosts_and_patterns() {
let label64 = format!("{}.com", "a".repeat(64));
let too_long = format!("{}.com", ["a".repeat(63).as_str(); 4].join("."));
assert!(too_long.len() > 253);
for host in [
"",
"localhost",
"com",
"Example.com",
"example.COM",
"example.com.",
".example.com",
"example..com",
"-example.com",
"example-.com",
"exa_mple.com",
"example.com:443",
"example.com/path",
"user@example.com",
"exa mple.com",
"[::1]",
"::1",
// Every spelling of an IPv4 address: the last label does not start with a letter.
"127.0.0.1",
"127.1",
"10.0.0.0x1",
"1.2.3.4",
"example.123",
"b\u{fc}cher.example",
label64.as_str(),
too_long.as_str(),
] {
assert!(!valid_host(host), "{host:?} should not be a valid host");
assert!(
!valid_host_pattern(host),
"{host:?} should not be a valid pattern"
);
}
for pattern in [
"*",
"*.",
"*.com",
"*example.com",
"www.*.com",
"*.*.example.com",
"**.example.com",
"*.Example.com",
"*.127.0.0.1",
] {
assert!(!valid_host_pattern(pattern), "{pattern:?}");
}
}
/// The host table in the spec, row by row.
#[test]
fn host_matching() {
assert!(host_matches("example.com", "example.com"));
assert!(!host_matches("example.com", "www.example.com"));
assert!(host_matches("*.example.com", "www.example.com"));
assert!(host_matches("*.example.com", "a.b.example.com"));
assert!(!host_matches("*.example.com", "example.com"));
// A suffix is not enough: the match is by whole labels.
assert!(!host_matches("*.example.com", "badexample.com"));
assert!(!host_matches("*.example.com", "www.example.com.evil.org"));
assert!(!host_matches("example.com", "example.com.evil.org"));
assert!(!host_matches("*.example.com", ".example.com"));
}
#[test]
fn valid_urls_and_their_hosts() {
let base = "https://example.com/";
let longest = format!("{base}{}", "a".repeat(MAX_URL - base.len()));
assert_eq!(longest.len(), MAX_URL);
for (url, host) in [
("https://example.com", "example.com"),
("https://example.com/", "example.com"),
("https://example.com:443", "example.com"),
("https://example.com:443/", "example.com"),
("https://www.example.com/a/b.html", "www.example.com"),
("https://example.com/search?q=a+b&x=%20#frag", "example.com"),
("https://example.com/@user", "example.com"),
("https://example.com/a:8080/b", "example.com"),
("https://example.com/https://other.org/", "example.com"),
("https://example.com/back\\slash", "example.com"),
(longest.as_str(), "example.com"),
] {
assert_eq!(url_host(url), Some(host), "{url}");
}
}
#[test]
fn invalid_urls() {
let base = "https://example.com/";
let too_long = format!("{base}{}", "a".repeat(MAX_URL - base.len() + 1));
assert_eq!(too_long.len(), MAX_URL + 1);
for url in [
"",
"example.com",
"http://example.com/",
"HTTPS://example.com/",
"https:/example.com/",
"https://",
"https:///path",
"ftp://example.com/",
"file:///etc/passwd",
// userinfo
"https://user@example.com/",
"https://user:pw@example.com/",
"https://example.com@evil.org/",
// ports
"https://example.com:8443/",
"https://example.com:80/",
"https://example.com:/",
"https://example.com:443x/",
"https://example.com:4433/",
"https://example.com:443:443/",
// what follows the host must be the end, `:443` or `/`
"https://example.com?q=1",
"https://example.com#frag",
"https://example.com\\@evil.org/",
// hosts that are not host names
"https://localhost/",
"https://127.0.0.1/",
"https://127.1/",
"https://[::1]/",
"https://Example.com/",
"https://example.com./",
"https://b\u{fc}cher.example/",
// the rest must be printable ASCII with no space
"https://example.com/a b",
"https://example.com/a\tb",
"https://example.com/a\nb",
"https://example.com/caf\u{e9}",
"https://example.com/\u{7f}",
" https://example.com/",
"https://example.com/ ",
too_long.as_str(),
] {
assert_eq!(url_host(url), None, "{url:?} should be invalid");
}
}
#[test]
fn each_tool_parses_its_own_arguments() {
assert_eq!(
parse(ToolName::ReadFile, r#"{"path":"/home/kyle/notes/a.md"}"#),
Ok(ToolArgs::ReadFile {
path: "/home/kyle/notes/a.md".to_string()
})
);
assert_eq!(
parse(
ToolName::WriteFile,
r#"{"path":"/home/kyle/notes/a.md","content":"line\n"}"#
),
Ok(ToolArgs::WriteFile {
path: "/home/kyle/notes/a.md".to_string(),
content: "line\n".to_string()
})
);
assert_eq!(
parse(ToolName::Shell, r#"{"command":"ls -l"}"#),
Ok(ToolArgs::Shell {
command: "ls -l".to_string(),
cwd: None
})
);
assert_eq!(
parse(ToolName::Shell, r#"{"command":"ls","cwd":"/home/kyle"}"#),
Ok(ToolArgs::Shell {
command: "ls".to_string(),
cwd: Some("/home/kyle".to_string())
})
);
// A `FetchUrl` cannot be built outside `args`, so the parsed value is read through its getters.
let fetched = parse(
ToolName::HttpFetch,
r#"{"url":"https://www.example.com/a"}"#,
);
let Ok(ToolArgs::HttpFetch(target)) = fetched else {
panic!("{fetched:?}")
};
assert_eq!(target.url(), "https://www.example.com/a");
assert_eq!(target.host(), "www.example.com");
// Field order and white space in the request do not matter.
assert_eq!(
parse(
ToolName::WriteFile,
" { \"content\" : \"x\" , \"path\" : \"/a/b\" } "
),
Ok(ToolArgs::WriteFile {
path: "/a/b".to_string(),
content: "x".to_string()
})
);
// `command` and `content` are not inspected.
assert!(parse(ToolName::Shell, r#"{"command":""}"#).is_ok());
assert!(parse(ToolName::Shell, r#"{"command":"rm -rf / ; ../../x"}"#).is_ok());
assert!(parse(ToolName::WriteFile, r#"{"path":"/a/b","content":""}"#).is_ok());
}
#[test]
fn arguments_of_the_wrong_shape_are_refused() {
let cases: [(ToolName, &str); 17] = [
(ToolName::ReadFile, ""),
(ToolName::ReadFile, "null"),
(ToolName::ReadFile, "[]"),
(ToolName::ReadFile, r#""/etc/hosts""#),
(ToolName::ReadFile, "{}"),
(ToolName::ReadFile, r#"{"path":"/a","mode":"r"}"#),
(ToolName::ReadFile, r#"{"path":"/a","path":"/b"}"#),
(ToolName::ReadFile, r#"{"path":7}"#),
(ToolName::ReadFile, r#"{"path":null}"#),
(ToolName::ReadFile, r#"{"path":"/a"} trailing"#),
(ToolName::WriteFile, r#"{"path":"/a/b"}"#),
(ToolName::WriteFile, r#"{"content":"x"}"#),
(
ToolName::WriteFile,
r#"{"path":"/a/b","content":"x","append":true}"#,
),
(ToolName::Shell, r#"{"cwd":"/a"}"#),
(ToolName::Shell, r#"{"command":"ls","cwd":"/a","env":{}}"#),
(ToolName::Shell, r#"{"command":["ls"]}"#),
(
ToolName::HttpFetch,
r#"{"url":"https://example.com/","method":"POST"}"#,
),
];
for (tool, text) in cases {
match parse(tool, text) {
Err(ArgsError::Shape(_)) => {}
other => panic!("{tool:?} {text:?}: expected a shape error, got {other:?}"),
}
}
// One tool's arguments do not fit another tool.
assert!(parse(ToolName::Shell, r#"{"path":"/a"}"#).is_err());
assert!(parse(ToolName::ReadFile, r#"{"url":"https://example.com/"}"#).is_err());
}
#[test]
fn a_bad_path_or_url_is_refused_in_every_tool_that_takes_one() {
for bad in ["notes/a.md", "/home/kyle/notes/../.ssh/id", "/a//b", "/a/"] {
let quoted = serde_json::to_string(bad).unwrap();
let read = format!(r#"{{"path":{quoted}}}"#);
let write = format!(r#"{{"path":{quoted},"content":"x"}}"#);
let shell = format!(r#"{{"command":"ls","cwd":{quoted}}}"#);
assert_eq!(
parse(ToolName::ReadFile, &read),
Err(ArgsError::Path(bad.to_string()))
);
assert_eq!(
parse(ToolName::WriteFile, &write),
Err(ArgsError::Path(bad.to_string()))
);
assert_eq!(
parse(ToolName::Shell, &shell),
Err(ArgsError::Path(bad.to_string()))
);
}
assert_eq!(
parse(ToolName::HttpFetch, r#"{"url":"http://example.com/"}"#),
Err(ArgsError::Url("http://example.com/".to_string()))
);
// A NUL can only arrive as a JSON escape; it is refused once decoded.
let nul = format!("{{\"path\":\"/a{}u0000b\"}}", '\\');
assert!(matches!(
parse(ToolName::ReadFile, &nul),
Err(ArgsError::Path(_))
));
// `cwd: null` is the same as no `cwd`.
assert_eq!(
parse(ToolName::Shell, r#"{"command":"ls","cwd":null}"#),
Ok(ToolArgs::Shell {
command: "ls".to_string(),
cwd: None
})
);
}
/// What the owner is shown is the parsed value written out again, so two spellings of one path
/// look the same. The escape is built from pieces so that no tool rewrites it on the way here.
#[test]
fn canonical_json_shows_what_was_parsed() {
let escaped_slash = format!("{}u002f", '\\');
let sneaky = format!("{{ \"path\" : \"{escaped_slash}etc{escaped_slash}hosts\" }}");
assert!(sneaky.contains("u002fetc"));
let args = parse(ToolName::ReadFile, &sneaky).unwrap();
assert_eq!(
args,
ToolArgs::ReadFile {
path: "/etc/hosts".to_string()
}
);
assert_eq!(args.canonical_json(), r#"{"path":"/etc/hosts"}"#);
// Fields come out in the spec's order whatever order they came in.
let write = parse(ToolName::WriteFile, r#"{"content":"x\ny","path":"/a/b"}"#).unwrap();
assert_eq!(
write.canonical_json(),
r#"{"path":"/a/b","content":"x\ny"}"#
);
let shell = parse(ToolName::Shell, r#"{"cwd":"/a","command":"ls"}"#).unwrap();
assert_eq!(shell.canonical_json(), r#"{"command":"ls","cwd":"/a"}"#);
// An absent cwd is left out, and the host is never written: it is not an argument.
let bare = parse(ToolName::Shell, r#"{"command":"ls","cwd":null}"#).unwrap();
assert_eq!(bare.canonical_json(), r#"{"command":"ls"}"#);
let fetch = parse(ToolName::HttpFetch, r#"{"url":"https://example.com/a"}"#).unwrap();
assert_eq!(fetch.canonical_json(), r#"{"url":"https://example.com/a"}"#);
assert_eq!(fetch.tool(), ToolName::HttpFetch);
assert_eq!(write.tool(), ToolName::WriteFile);
}
@@ -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,271 @@
//! The Podman runtime against a fake `podman`: what it is given, and what each way a container can
//! end becomes (M3b spec, section 6). Every call goes through `runner::run`, as in `brokerd`.
//! Do not edit.
#[path = "support/build.rs"]
mod build;
#[path = "support/fake_podman.rs"]
mod fake_podman;
use std::time::{Duration, Instant};
use brokerd::container::{
CANNOT_START, COULD_NOT_RUN, KILLED, Podman, RUNBOOK, TIMED_OUT, UNEXPECTED,
};
use brokerd::policy::{Outcome, SessionState, decide};
use brokerd::runner::run;
use build::{grant, now, read, request, set};
use fake_podman::{Fake, Lines, serial};
use proto::{DataClass, Mode, ToolRequest, ToolResponse};
fn call(podman: &Podman, req: ToolRequest, grants: Vec<build::Build>) -> ToolResponse {
let decision = match decide(req, &set(grants), SessionState::default(), now()) {
Outcome::Allowed(d) => d,
other => panic!("not allowed: {other:?}"),
};
run(decision, podman)
}
fn notes() -> Vec<build::Build> {
vec![grant("notes", "read_file", Mode::Auto).paths(&["/n"])]
}
fn podman(fake: &Fake, extra: &str, log: &Lines) -> Podman {
Podman::new(fake.runner(extra), fake.dir.join("egress"), log.sink())
}
fn failed(message: &str) -> ToolResponse {
ToolResponse::Failed {
message: message.to_string(),
}
}
#[test]
fn a_tool_that_succeeds_is_a_result_labelled_by_its_grant() {
let _s = serial();
let fake = Fake::new("ok", r#"cat > "$D/stdin"; printf 'the file text'; exit 0"#);
let log = Lines::default();
let got = call(&podman(&fake, "", &log), read("/n/a.md"), notes());
assert_eq!(
got,
ToolResponse::Result {
content: "the file text".to_string(),
class: DataClass::Private,
untrusted: true,
truncated: false,
}
);
assert_eq!(
fake.stdin(),
r#"{"path":"/n/a.md"}"#,
"the arguments go on standard input"
);
}
#[test]
fn podman_is_given_the_tool_argument_list_and_the_first_container_is_numbered_0() {
let _s = serial();
let fake = Fake::new("args", r#"cat > /dev/null; exit 0"#);
let log = Lines::default();
let p = podman(&fake, "", &log);
call(&p, read("/n/a.md"), notes());
call(&p, read("/n/b.md"), notes());
let calls = fake.calls();
assert_eq!(
calls.len(),
2,
"one podman run per call and nothing else: {calls:?}"
);
assert_eq!(calls[0][3], "--name=boxmaker-s1-1-0");
assert_eq!(calls[1][3], "--name=boxmaker-s1-1-1");
// The whole list is `podman::tool_args`, tested as golden files in podman_args.rs.
assert_eq!(calls[0].first().map(String::as_str), Some("run"));
assert_eq!(calls[0].last().map(String::as_str), Some("read_file"));
assert!(calls[0].contains(&"--volume=/n:/n:ro".to_string()));
}
#[test]
fn exit_1_is_the_tools_own_error_and_still_a_result() {
let _s = serial();
let fake = Fake::new(
"e1",
"cat > /dev/null; printf 'read_file: /n/x: no such file'; exit 1",
);
let log = Lines::default();
let got = call(&podman(&fake, "", &log), read("/n/x"), notes());
assert!(
matches!(&got, ToolResponse::Result { content, .. } if content == "read_file: /n/x: no such file"),
"{got:?}"
);
}
#[test]
fn every_other_ending_is_a_fixed_sentence() {
let cases = [
("2", COULD_NOT_RUN),
("125", CANNOT_START),
("126", CANNOT_START),
("127", CANNOT_START),
("137", KILLED),
("3", UNEXPECTED),
("124", UNEXPECTED),
];
for (code, sentence) in cases {
let _s = serial();
let body = format!(
"cat > /dev/null; printf 'secret tool output'; echo 'podman said this' >&2; exit {code}"
);
let fake = Fake::new("codes", &body);
let log = Lines::default();
let got = call(&podman(&fake, "", &log), read("/n/a"), notes());
assert_eq!(got, failed(sentence), "exit {code}");
}
}
#[test]
fn a_podman_failure_is_logged_with_the_runbook_pointer_and_its_stderr() {
let _s = serial();
let fake = Fake::new(
"125",
"cat > /dev/null; echo 'Error: image not known' >&2; exit 125",
);
let log = Lines::default();
call(&podman(&fake, "", &log), read("/n/a"), notes());
let text = log.all();
assert!(text.contains("Error: image not known"), "{text}");
assert!(text.contains(RUNBOOK), "{text}");
}
#[test]
fn a_podman_that_cannot_be_started_is_unavailable_and_logged() {
let _s = serial();
let fake = Fake::new("missing", "exit 0");
let mut runner = fake.runner("");
runner.podman = fake.dir.join("no-such-podman");
let log = Lines::default();
let p = Podman::new(runner, fake.dir.join("egress"), log.sink());
assert_eq!(call(&p, read("/n/a"), notes()), failed(CANNOT_START));
assert!(log.all().contains(RUNBOOK), "{}", log.all());
}
#[test]
fn output_past_the_cap_is_cut_and_marked() {
let _s = serial();
let fake = Fake::new(
"cap",
"cat > /dev/null; head -c 1000 /dev/zero | tr '\\0' x; exit 0",
);
let log = Lines::default();
let got = call(
&podman(&fake, "output_cap = 100", &log),
read("/n/a"),
notes(),
);
assert!(
matches!(&got, ToolResponse::Result { content, truncated: true, .. } if *content == "x".repeat(100)),
"{got:?}"
);
let exact = Fake::new(
"cap-exact",
"cat > /dev/null; head -c 100 /dev/zero | tr '\\0' x; exit 0",
);
let got = call(
&podman(&exact, "output_cap = 100", &log),
read("/n/a"),
notes(),
);
assert!(
matches!(
&got,
ToolResponse::Result {
truncated: false,
..
}
),
"{got:?}"
);
}
#[test]
fn output_that_is_not_utf8_is_replaced() {
let _s = serial();
let fake = Fake::new("utf8", "cat > /dev/null; printf 'a\\377b'; exit 0");
let log = Lines::default();
let got = call(&podman(&fake, "", &log), read("/n/a"), notes());
assert!(
matches!(&got, ToolResponse::Result { content, .. } if content == "a\u{fffd}b"),
"{got:?}"
);
}
#[test]
fn a_tool_past_its_time_limit_is_killed_removed_and_failed() {
let _s = serial();
// `exec`, so killing the process kills the sleep and nothing holds the pipes open.
let fake = Fake::new("slow", "cat > /dev/null; exec sleep 30");
let log = Lines::default();
let started = Instant::now();
let got = call(
&podman(&fake, "read_file_ms = 300", &log),
read("/n/a"),
notes(),
);
let took = started.elapsed();
assert_eq!(got, failed(TIMED_OUT));
assert!(took >= Duration::from_millis(300), "{took:?}");
assert!(took < Duration::from_secs(5), "{took:?}");
let calls = fake.calls();
assert_eq!(calls.len(), 3, "{calls:?}");
assert_eq!(calls[1], ["kill", "boxmaker-s1-1-0"]);
assert_eq!(calls[2], ["rm", "-f", "boxmaker-s1-1-0"]);
}
#[test]
fn a_large_argument_is_written_whole_while_the_tool_reads_it() {
let _s = serial();
let fake = Fake::new("big", r#"cat > "$D/stdin"; printf done; exit 0"#);
let log = Lines::default();
let content = "y".repeat(900_000);
let req = request(
"write_file",
&format!(r#"{{"path":"/w/big.txt","content":"{content}"}}"#),
);
let got = call(
&podman(&fake, "", &log),
req,
vec![grant("w", "write_file", Mode::Auto).paths(&["/w"])],
);
assert!(
matches!(&got, ToolResponse::Result { content, .. } if content == "done"),
"{got:?}"
);
assert_eq!(
fake.stdin().len(),
content.len() + r#"{"path":"/w/big.txt","content":""}"#.len()
);
}
#[test]
fn a_tool_that_never_reads_its_input_still_ends() {
let _s = serial();
let fake = Fake::new("noread", "printf ignored; exit 0");
let log = Lines::default();
let req = request(
"write_file",
&format!(
r#"{{"path":"/w/big.txt","content":"{}"}}"#,
"z".repeat(900_000)
),
);
let started = Instant::now();
let got = call(
&podman(&fake, "write_file_ms = 5000", &log),
req,
vec![grant("w", "write_file", Mode::Auto).paths(&["/w"])],
);
assert!(
matches!(&got, ToolResponse::Result { content, .. } if content == "ignored"),
"{got:?}"
);
assert!(started.elapsed() < Duration::from_secs(4));
}
@@ -0,0 +1,213 @@
//! `http_fetch` through the Podman runtime: the proxy is started first, its socket awaited, the tool
//! run with the directory mounted, and the proxy and directory removed afterwards on every path
//! (M3b spec, section 6, "http_fetch"). Against a fake `podman`. Do not edit.
#[path = "support/build.rs"]
mod build;
#[path = "support/fake_podman.rs"]
mod fake_podman;
use std::os::unix::fs::PermissionsExt;
use std::time::{Duration, Instant};
use brokerd::container::{CANNOT_START, Podman, RUNBOOK, TIMED_OUT};
use brokerd::policy::{Outcome, SessionState, decide};
use brokerd::runner::run;
use build::{fetch, grant, now, set};
use fake_podman::{Fake, Lines, path, serial};
use proto::{Mode, ToolResponse};
/// For `run -d` (the proxy): make the socket file in the mounted directory, as the proxy does.
const PROXY_OK: &str = r#"for a in "$@"; do case "$a" in --volume=*:/run/egress:rw) v=${a#--volume=}; v=${v%:/run/egress:rw};; esac; done"#;
fn body(proxy: &str, tool: &str) -> String {
format!("{PROXY_OK}\nif [ \"$2\" = -d ]; then\n{proxy}\nfi\n{tool}")
}
fn call(podman: &Podman) -> ToolResponse {
let grants = set(vec![
grant("web", "http_fetch", Mode::Auto).hosts(&["example.com", "*.example.org"]),
]);
let decision = match decide(
fetch("https://example.com/a"),
&grants,
SessionState::default(),
now(),
) {
Outcome::Allowed(d) => d,
other => panic!("not allowed: {other:?}"),
};
run(decision, podman)
}
fn failed(message: &str) -> ToolResponse {
ToolResponse::Failed {
message: message.to_string(),
}
}
#[test]
fn the_proxy_starts_first_the_tool_gets_its_socket_and_both_are_cleaned_up() {
let _s = serial();
let fake = Fake::new(
"eg-ok",
&body(
r#": > "$v/egress.sock"; exit 0"#,
r#"cat > "$D/stdin"; printf 'body\n[http 200]'; exit 0"#,
),
);
let log = Lines::default();
let egress = fake.dir.join("egress");
let got = call(&Podman::new(fake.runner(""), egress.clone(), log.sink()));
assert!(
matches!(&got, ToolResponse::Result { content, .. } if content == "body\n[http 200]"),
"{got:?} {}",
log.all()
);
let calls = fake.calls();
assert_eq!(calls.len(), 3, "{calls:?}");
let dir = egress.join("boxmaker-s1-1-0");
// 1. The proxy, detached, with the call's hosts.
assert_eq!(&calls[0][..2], ["run", "-d"]);
assert!(calls[0].contains(&"--name=boxmaker-s1-1-0-egress".to_string()));
assert!(calls[0].contains(&format!("--volume={}:/run/egress:rw", path(&dir))));
assert_eq!(calls[0].last().unwrap(), "example.com,*.example.org");
// 2. The tool, with the same directory and no network.
assert_eq!(&calls[1][..3], ["run", "--rm", "-i"]);
assert!(calls[1].contains(&format!("--volume={}:/run/egress:rw", path(&dir))));
assert!(calls[1].contains(&"--network=none".to_string()));
assert_eq!(fake.stdin(), r#"{"url":"https://example.com/a"}"#);
// 3. The proxy removed, and its directory with it.
assert_eq!(calls[2], ["rm", "-f", "boxmaker-s1-1-0-egress"]);
assert!(!dir.exists(), "the call's directory is removed");
let mode = std::fs::metadata(&egress).unwrap().permissions().mode() & 0o777;
assert_eq!(mode, 0o700);
}
#[test]
fn a_proxy_that_podman_cannot_start_means_no_tool_and_nothing_left() {
let _s = serial();
let fake = Fake::new(
"eg-fail",
&body(
"echo 'Error: network pasta not found' >&2; exit 125",
"cat > /dev/null; exit 0",
),
);
let log = Lines::default();
let egress = fake.dir.join("egress");
let got = call(&Podman::new(fake.runner(""), egress.clone(), log.sink()));
assert_eq!(got, failed(CANNOT_START));
let calls = fake.calls();
assert!(
calls
.iter()
.all(|c| c.get(2).map(String::as_str) != Some("-i")),
"no tool ran: {calls:?}"
);
assert_eq!(
calls.last().unwrap(),
&["rm", "-f", "boxmaker-s1-1-0-egress"]
);
assert!(!egress.join("boxmaker-s1-1-0").exists());
assert!(
log.all().contains("network pasta not found"),
"{}",
log.all()
);
assert!(log.all().contains(RUNBOOK));
}
#[test]
fn a_proxy_that_makes_no_socket_in_time_means_no_tool_and_nothing_left() {
let _s = serial();
let fake = Fake::new("eg-nosock", &body("exit 0", "cat > /dev/null; exit 0"));
let log = Lines::default();
let egress = fake.dir.join("egress");
let podman = Podman::new(fake.runner(""), egress.clone(), log.sink())
.with_egress_wait(Duration::from_millis(200));
let started = Instant::now();
assert_eq!(call(&podman), failed(CANNOT_START));
assert!(started.elapsed() < Duration::from_secs(3));
let calls = fake.calls();
assert_eq!(calls.len(), 2, "{calls:?}");
assert_eq!(calls[1], ["rm", "-f", "boxmaker-s1-1-0-egress"]);
assert!(!egress.join("boxmaker-s1-1-0").exists());
assert!(log.all().contains(RUNBOOK));
}
#[test]
fn a_tool_that_runs_too_long_still_leaves_nothing_behind() {
let _s = serial();
let fake = Fake::new(
"eg-slow",
&body(
r#": > "$v/egress.sock"; exit 0"#,
"cat > /dev/null; exec sleep 30",
),
);
let log = Lines::default();
let egress = fake.dir.join("egress");
let got = call(&Podman::new(
fake.runner("http_fetch_ms = 300"),
egress.clone(),
log.sink(),
));
assert_eq!(got, failed(TIMED_OUT));
let calls = fake.calls();
let tail: Vec<Vec<String>> = calls[2..].to_vec();
assert_eq!(
tail,
[
vec!["kill", "boxmaker-s1-1-0"],
vec!["rm", "-f", "boxmaker-s1-1-0"],
vec!["rm", "-f", "boxmaker-s1-1-0-egress"],
]
.map(|c| c.into_iter().map(String::from).collect::<Vec<_>>())
);
assert!(!egress.join("boxmaker-s1-1-0").exists());
}
#[test]
fn a_tool_that_podman_cannot_start_still_leaves_nothing_behind() {
let _s = serial();
let fake = Fake::new(
"eg-toolfail",
&body(
r#": > "$v/egress.sock"; exit 0"#,
"cat > /dev/null; exit 125",
),
);
let log = Lines::default();
let egress = fake.dir.join("egress");
assert_eq!(
call(&Podman::new(fake.runner(""), egress.clone(), log.sink())),
failed(CANNOT_START)
);
assert_eq!(
fake.calls().last().unwrap(),
&["rm", "-f", "boxmaker-s1-1-0-egress"]
);
assert!(!egress.join("boxmaker-s1-1-0").exists());
}
#[test]
fn a_directory_left_by_a_crash_is_replaced() {
let _s = serial();
let fake = Fake::new(
"eg-stale",
&body(
r#"[ -e "$v/old.sock" ] && exit 9; : > "$v/egress.sock"; exit 0"#,
"cat > /dev/null; printf ok; exit 0",
),
);
let log = Lines::default();
let egress = fake.dir.join("egress");
std::fs::create_dir_all(egress.join("boxmaker-s1-1-0")).unwrap();
std::fs::write(egress.join("boxmaker-s1-1-0/old.sock"), "").unwrap();
let got = call(&Podman::new(fake.runner(""), egress.clone(), log.sink()));
assert!(
matches!(&got, ToolResponse::Result { content, .. } if content == "ok"),
"{got:?}"
);
}
@@ -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"
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -0,0 +1,45 @@
//! A grant path is mounted into the tool container as `--volume=<path>:<path>:ro`, so a path with
//! `:` or `,` in it cannot be granted: the set is invalid, as for any other bad grant (M3b spec,
//! section 3). Do not edit.
#[path = "support/tmp.rs"]
mod tmp;
use brokerd::grants::load;
use tmp::TempDir;
fn grant_with_path(path: &str) -> String {
format!(
"tool = \"read_file\"\nmode = \"auto\"\nmax_taint = \"secret\"\n[constraints]\npaths = [{path:?}]\n"
)
}
#[test]
fn a_path_with_a_colon_or_a_comma_makes_the_set_invalid() {
for path in ["/home/kyle/a:b", "/home/kyle/a,b", "/x:/y", "/n,ro"] {
let dir = TempDir::new("mount-bad");
dir.write("notes.toml", &grant_with_path(path));
let problems = load(dir.path()).expect_err(path);
assert_eq!(problems.len(), 1, "{path}: {problems:?}");
assert_eq!(problems[0].file, "notes.toml");
assert!(
problems[0].problem.contains("cannot be mounted"),
"{path}: {}",
problems[0].problem
);
}
}
#[test]
fn other_punctuation_is_still_fine() {
for path in [
"/home/kyle/a b",
"/home/kyle/a;b",
"/home/kyle/a=b",
"/home/kyle/a.b-c_d",
] {
let dir = TempDir::new("mount-ok");
dir.write("notes.toml", &grant_with_path(path));
assert!(load(dir.path()).is_ok(), "{path}");
}
}
@@ -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<PathBuf>,
got: Mutex<Vec<Vec<OsString>>>,
}
impl Runtime for Lists {
fn run(&self, spec: &RunSpec) -> Result<RunOutput, RunError> {
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<build::Build>, req: ToolRequest, egress: Option<&Path>) -> Vec<String> {
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<String> {
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<String> = 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);
}
@@ -0,0 +1,143 @@
//! `brokerd serve` with a `[runner]` section runs allowed calls through Podman (here a fake), and
//! says which runtime it uses. Without the section it refuses every call, as in M3a. Do not edit.
#[path = "support/fake_podman.rs"]
mod fake_podman;
use std::io::Read;
use std::os::unix::net::UnixStream;
use std::path::Path;
use std::process::{Child, Command, Stdio};
use std::time::{Duration, Instant};
use fake_podman::{Fake, IMAGE, serial};
use proto::{
CallId, DataClass, Envelope, Message, PROTOCOL_VERSION, SessionId, ToolRequest, ToolResponse,
read_frame, write_frame,
};
struct Running(Child);
impl Drop for Running {
fn drop(&mut self) {
let _ = self.0.kill();
let _ = self.0.wait();
}
}
impl Running {
fn stop(mut self) -> String {
let _ = self.0.kill();
let _ = self.0.wait();
let mut err = String::new();
if let Some(mut stderr) = self.0.stderr.take() {
let _ = stderr.read_to_string(&mut err);
}
err
}
}
fn start(home: &Path, runner: &str) -> Running {
std::fs::create_dir_all(home.join("grants")).unwrap();
std::fs::write(
home.join("grants/notes.toml"),
"tool = \"read_file\"\nmode = \"auto\"\nmax_taint = \"secret\"\n[constraints]\npaths = [\"/n\"]\n",
)
.unwrap();
let config = home.join("brokerd.toml");
std::fs::write(
&config,
format!(
"[paths]\nhome = \"{0}\"\ngrants = \"{0}/grants\"\n{runner}\n",
home.display()
),
)
.unwrap();
let child = Command::new(env!("CARGO_BIN_EXE_brokerd"))
.args(["serve", "--config"])
.arg(&config)
.stdout(Stdio::null())
.stderr(Stdio::piped())
.spawn()
.unwrap();
let running = Running(child);
let until = Instant::now() + Duration::from_secs(10);
while UnixStream::connect(home.join("run/loop-broker/broker.sock")).is_err() {
assert!(Instant::now() < until, "brokerd never listened");
std::thread::sleep(Duration::from_millis(20));
}
running
}
fn read_call(home: &Path) -> ToolResponse {
let mut stream = UnixStream::connect(home.join("run/loop-broker/broker.sock")).unwrap();
stream
.set_read_timeout(Some(Duration::from_secs(10)))
.unwrap();
let request = ToolRequest {
session: SessionId::new("s1").unwrap(),
call: CallId(1),
tool: "read_file".to_string(),
arguments: r#"{"path":"/n/a.md"}"#.to_string(),
};
let envelope = Envelope {
v: PROTOCOL_VERSION,
id: 1,
r#final: true,
msg: Message::ToolRequest(request),
};
write_frame(&mut stream, &envelope).unwrap();
match read_frame(&mut stream).unwrap().msg {
Message::ToolResponse(response) => response,
other => panic!("{other:?}"),
}
}
#[test]
fn with_a_runner_an_allowed_call_runs_in_a_container() {
let _s = serial();
let fake = Fake::new(
"serve",
"cat > /dev/null; printf 'from the container'; exit 0",
);
let home = fake.dir.join("home");
let running = start(
&home,
&format!(
"[runner]\npodman = \"{}\"\nimage = \"{IMAGE}\"\n",
fake.script.display()
),
);
assert_eq!(
read_call(&home),
ToolResponse::Result {
content: "from the container".to_string(),
class: DataClass::Private,
untrusted: true,
truncated: false,
}
);
let printed = running.stop();
assert!(
printed.contains(&format!("tools run in containers from {IMAGE}")),
"{printed}"
);
assert_eq!(fake.calls().len(), 1);
}
#[test]
fn without_a_runner_every_call_is_refused_and_it_says_so() {
let _s = serial();
let fake = Fake::new("serve-none", "exit 0");
let home = fake.dir.join("home");
let running = start(&home, "");
assert_eq!(
read_call(&home),
ToolResponse::Failed {
message: brokerd::runner::REFUSING.to_string()
}
);
let printed = running.stop();
assert!(printed.contains("no [runner] section"), "{printed}");
assert!(fake.calls().is_empty());
}
@@ -0,0 +1,102 @@
//! A fake `podman` for the runtime tests: a shell script that records every call's arguments and,
//! for `run`, does what the test says. Do not edit.
//!
//! Included with `#[path = "support/fake_podman.rs"] mod fake_podman;`.
#![allow(dead_code)] // each test file uses a different part of this module
use std::os::unix::fs::PermissionsExt;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU32, Ordering};
use std::sync::{Arc, Mutex, MutexGuard};
use brokerd::config::{Config, Runner};
static NEXT: AtomicU32 = AtomicU32::new(0);
static SERIAL: Mutex<()> = Mutex::new(());
/// Tests that write a script and run it take turns. Otherwise another test's fork can hold the
/// script open for writing at the moment it is run, and running it fails with "text file busy"
/// (ETXTBSY), which has nothing to do with the code under test.
pub fn serial() -> MutexGuard<'static, ()> {
SERIAL.lock().unwrap_or_else(|p| p.into_inner())
}
pub const IMAGE: &str = "localhost/boxmaker-tools@sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
pub struct Fake {
pub dir: PathBuf,
pub script: PathBuf,
}
impl Drop for Fake {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.dir);
}
}
impl Fake {
/// A fake whose `run` does `run_body` (a shell fragment; `$D` is the fake's directory). Every
/// other command (`kill`, `rm`) is recorded and succeeds.
pub fn new(tag: &str, run_body: &str) -> Fake {
let n = NEXT.fetch_add(1, Ordering::SeqCst);
let dir = std::env::temp_dir().join(format!("bx-fp-{tag}-{}-{n}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let script = dir.join("podman");
let text = format!(
"#!/bin/sh\nD='{}'\nfor a in \"$@\"; do printf '%s\\n' \"$a\"; done >> \"$D/calls\"\necho --- >> \"$D/calls\"\n[ \"$1\" = run ] || exit 0\n{run_body}\n",
dir.display()
);
std::fs::write(&script, text).unwrap();
std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap();
Fake { dir, script }
}
/// Every call so far, each as its arguments.
pub fn calls(&self) -> Vec<Vec<String>> {
let text = std::fs::read_to_string(self.dir.join("calls")).unwrap_or_default();
let mut calls = Vec::new();
let mut current = Vec::new();
for line in text.lines() {
if line == "---" {
calls.push(std::mem::take(&mut current));
} else {
current.push(line.to_string());
}
}
calls
}
/// What the last `run` read on standard input, if the body saved it to `$D/stdin`.
pub fn stdin(&self) -> String {
std::fs::read_to_string(self.dir.join("stdin")).unwrap_or_default()
}
/// A `[runner]` using this fake, with `extra` lines added.
pub fn runner(&self, extra: &str) -> Runner {
let text = format!(
"[runner]\npodman = \"{}\"\nimage = \"{IMAGE}\"\n{extra}\n",
self.script.display()
);
Config::parse(&text).unwrap().runner.unwrap()
}
}
/// A log that keeps its lines.
#[derive(Clone, Default)]
pub struct Lines(pub Arc<Mutex<Vec<String>>>);
impl Lines {
pub fn sink(&self) -> Arc<dyn Fn(&str) + Send + Sync> {
let lines = Arc::clone(&self.0);
Arc::new(move |l: &str| lines.lock().unwrap().push(l.to_string()))
}
pub fn all(&self) -> String {
self.0.lock().unwrap().join("\n")
}
}
pub fn path(p: &Path) -> String {
p.to_str().unwrap().to_string()
}
@@ -0,0 +1,119 @@
//! A home for ledger and broker tests: grants, audit log, session state, a flaky audit sink and
//! a log to read. Do not edit.
//!
//! Included with `#[path = "support/rig.rs"] mod rig;`, beside `tmp` and `sink`. The broker
//! tests add `client`.
#![allow(dead_code)] // each test file uses a different part of this module
use std::path::PathBuf;
use brokerd::audit::Writer;
use brokerd::config::{Approvals, Config, Paths, Sockets};
use brokerd::ledger::Ledger;
use brokerd::state::StateStore;
use proto::{AuditEvent, AuditRecord, CallId, SessionId, ToolRequest};
use crate::sink::{Flaky, Lines, Switch};
use crate::tmp::TempDir;
pub struct Rig {
pub dir: TempDir,
pub cfg: Config,
pub switch: Switch,
pub lines: Lines,
}
impl Rig {
pub fn new(tag: &str) -> Rig {
Rig::with_ttl(tag, 900_000)
}
pub fn with_ttl(tag: &str, ttl_ms: u64) -> Rig {
let dir = TempDir::new(tag);
let grants = dir.path().join("grants");
std::fs::create_dir_all(&grants).unwrap();
let cfg = Config {
paths: Paths {
home: dir.path().to_path_buf(),
grants,
},
sockets: Sockets::default(),
approvals: Approvals { ttl_ms },
runner: None,
};
Rig {
dir,
cfg,
switch: Switch::default(),
lines: Lines::default(),
}
}
pub fn state(&self) -> StateStore {
StateStore::new(&self.cfg.state_dir())
}
/// Opens the audit log (once: the writer holds its lock) behind the flaky sink.
pub fn ledger(&self) -> Ledger {
let opened = Writer::open(&self.cfg.audit_dir(), false).unwrap();
let sink = Flaky {
writer: opened.writer,
switch: self.switch.clone(),
};
Ledger::new(Box::new(sink), self.state(), self.lines.sink())
}
/// Writes `grants/<id>.toml`.
pub fn grant(&self, id: &str, text: &str) {
std::fs::write(self.cfg.paths.grants.join(format!("{id}.toml")), text).unwrap();
}
pub fn remove_grant(&self, id: &str) {
std::fs::remove_file(self.cfg.paths.grants.join(format!("{id}.toml"))).unwrap();
}
pub fn state_file(&self, session: &str) -> PathBuf {
self.cfg.state_dir().join(format!("{session}.json"))
}
/// Every record in the audit log, in order.
pub fn records(&self) -> Vec<AuditRecord> {
let dir = self.cfg.audit_dir();
let mut names: Vec<String> = std::fs::read_dir(&dir)
.unwrap()
.map(|e| e.unwrap().file_name().into_string().unwrap())
.filter(|n| n.ends_with(".jsonl"))
.collect();
names.sort();
let mut out = Vec::new();
for name in names {
let text = std::fs::read_to_string(dir.join(name)).unwrap();
for line in text.lines() {
out.push(serde_json::from_str(line).unwrap());
}
}
out
}
pub fn events(&self) -> Vec<AuditEvent> {
self.records().into_iter().map(|r| r.event).collect()
}
}
/// A grant file's text. `extra` goes before `[constraints]`, `constraints` after it.
pub fn grant_text(tool: &str, mode: &str, extra: &str, constraints: &str) -> String {
format!(
"tool = \"{tool}\"\nmode = \"{mode}\"\nmax_taint = \"secret\"\nresult_class = \"private\"\n\
untrusted = false\n{extra}\n[constraints]\n{constraints}\n"
)
}
pub fn request(session: &str, call: u64, tool: &str, arguments: &str) -> ToolRequest {
ToolRequest {
session: SessionId::new(session).unwrap(),
call: CallId(call),
tool: tool.to_string(),
arguments: arguments.to_string(),
}
}
@@ -0,0 +1,50 @@
//! Host names and patterns, shared by `brokerd` and `toolkit`'s egress proxy. The full tables are
//! in `crates/brokerd/tests/args.rs`, which reaches these through `brokerd::args`. Do not edit.
use proto::hosts::{host_matches, valid_host, valid_host_pattern};
#[test]
fn hosts() {
for good in ["example.com", "a.b.example.com", "x-1.example.org", "a.b"] {
assert!(valid_host(good), "{good}");
}
for bad in [
"",
"example",
"Example.com",
"-a.com",
"a-.com",
"a..com",
".a.com",
"a.com.",
"127.0.0.1",
"127.1",
"1.2.3.4x",
"[::1]",
"a_b.com",
"a.com:443",
"*.a.com",
] {
assert!(!valid_host(bad), "{bad}");
}
}
#[test]
fn patterns() {
assert!(valid_host_pattern("example.com"));
assert!(valid_host_pattern("*.example.com"));
for bad in ["*", "*.", "*.*.a.com", "a.*.com", "**.a.com", "*a.com"] {
assert!(!valid_host_pattern(bad), "{bad}");
}
}
#[test]
fn matching() {
assert!(host_matches("example.com", "example.com"));
assert!(!host_matches("example.com", "www.example.com"));
assert!(host_matches("*.example.com", "www.example.com"));
assert!(host_matches("*.example.com", "a.b.example.com"));
assert!(!host_matches("*.example.com", "example.com"));
assert!(!host_matches("*.example.com", "badexample.com"));
assert!(!host_matches("*.example.com", ".example.com"));
}
@@ -0,0 +1,60 @@
//! The four tools' arguments: `brokerd` writes them to a container's standard input, `toolkit`
//! reads them back, and both use these types. Do not edit.
use proto::tools::{HttpFetchArgs, ReadFileArgs, ShellArgs, WriteFileArgs};
#[test]
fn each_type_round_trips_in_field_order() {
let read = ReadFileArgs {
path: "/n/a.md".to_string(),
};
assert_eq!(
serde_json::to_string(&read).unwrap(),
r#"{"path":"/n/a.md"}"#
);
let write = WriteFileArgs {
path: "/n/a.md".to_string(),
content: "hi\n".to_string(),
};
let text = serde_json::to_string(&write).unwrap();
assert_eq!(text, r#"{"path":"/n/a.md","content":"hi\n"}"#);
assert_eq!(serde_json::from_str::<WriteFileArgs>(&text).unwrap(), write);
let fetch = HttpFetchArgs {
url: "https://example.com/".to_string(),
};
assert_eq!(
serde_json::to_string(&fetch).unwrap(),
r#"{"url":"https://example.com/"}"#
);
}
#[test]
fn a_shell_cwd_is_left_out_when_absent_and_may_be_null_or_missing() {
let bare = ShellArgs {
command: "ls".to_string(),
cwd: None,
};
assert_eq!(serde_json::to_string(&bare).unwrap(), r#"{"command":"ls"}"#);
for text in [r#"{"command":"ls"}"#, r#"{"command":"ls","cwd":null}"#] {
assert_eq!(serde_json::from_str::<ShellArgs>(text).unwrap(), bare);
}
let with = ShellArgs {
command: "ls".to_string(),
cwd: Some("/n".to_string()),
};
assert_eq!(
serde_json::to_string(&with).unwrap(),
r#"{"command":"ls","cwd":"/n"}"#
);
}
#[test]
fn unknown_and_missing_fields_are_refused() {
assert!(serde_json::from_str::<ReadFileArgs>(r#"{"path":"/a","mode":"x"}"#).is_err());
assert!(serde_json::from_str::<ReadFileArgs>(r#"{}"#).is_err());
assert!(serde_json::from_str::<WriteFileArgs>(r#"{"path":"/a"}"#).is_err());
assert!(serde_json::from_str::<ShellArgs>(r#"{"command":"ls","env":{}}"#).is_err());
assert!(
serde_json::from_str::<HttpFetchArgs>(r#"{"url":"https://a.b/","method":"POST"}"#).is_err()
);
}
@@ -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");
}
}
@@ -0,0 +1,451 @@
//! The egress proxy against a fake resolver and a local echo server: every reply code, the host,
//! port and address checks, the byte copy both ways with half-close, the handshake deadline, the
//! connection limit, and `toolkit egress-proxy` as a program. No test needs a network. Do not edit.
mod support;
use std::collections::HashMap;
use std::io::{Read, Write};
use std::net::{Shutdown, SocketAddr, TcpListener, TcpStream};
use std::os::unix::net::{UnixListener, UnixStream};
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use support::TempDir;
use toolkit::egress::{
ADDRESS_TYPE_NOT_SUPPORTED, Allow, COMMAND_NOT_SUPPORTED, CONNECTION_REFUSED, Dial,
HOST_UNREACHABLE, MAX_CONNECTIONS, NOT_ALLOWED, Proxy,
};
/// Names resolve from a table; every connection goes to one local echo server, except to
/// addresses listed as refusing. Records every address it was asked to connect to.
struct FakeDial {
names: HashMap<String, Vec<SocketAddr>>,
refusing: Vec<SocketAddr>,
echo: SocketAddr,
connected: Mutex<Vec<SocketAddr>>,
}
impl Dial for FakeDial {
fn resolve(&self, host: &str, _port: u16) -> std::io::Result<Vec<SocketAddr>> {
self.names
.get(host)
.cloned()
.ok_or_else(|| std::io::Error::other("no such name"))
}
fn connect(&self, addr: SocketAddr, _timeout: Duration) -> std::io::Result<TcpStream> {
self.connected.lock().unwrap().push(addr);
if self.refusing.contains(&addr) {
return Err(std::io::Error::from(std::io::ErrorKind::ConnectionRefused));
}
TcpStream::connect(self.echo)
}
}
fn sa(s: &str) -> SocketAddr {
s.parse().unwrap()
}
/// An echo server: copies back what it reads, and closes its side after reading the end.
fn echo_server() -> SocketAddr {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let addr = listener.local_addr().unwrap();
std::thread::spawn(move || {
for stream in listener.incoming() {
let Ok(mut stream) = stream else { continue };
std::thread::spawn(move || {
let mut back = stream.try_clone().unwrap();
let _ = std::io::copy(&mut stream, &mut back);
let _ = back.shutdown(Shutdown::Write);
});
}
});
addr
}
fn dial() -> Arc<FakeDial> {
let mut names = HashMap::new();
names.insert("example.com".to_string(), vec![sa("93.184.216.34:443")]);
names.insert(
"www.example.org".to_string(),
vec![sa("[2606:2800::1]:443")],
);
names.insert(
"mixed.example.com".to_string(),
vec![
sa("10.0.0.1:443"),
sa("100.100.100.100:443"),
sa("1.1.1.1:443"),
],
);
names.insert(
"inside.example.com".to_string(),
vec![
sa("100.101.102.103:443"),
sa("127.0.0.1:443"),
sa("[::1]:443"),
],
);
names.insert("refusing.example.com".to_string(), vec![sa("8.8.8.8:443")]);
Arc::new(FakeDial {
names,
refusing: vec![sa("8.8.8.8:443")],
echo: echo_server(),
connected: Mutex::new(Vec::new()),
})
}
fn allow() -> Allow {
Allow::parse(
"example.com,*.example.org,mixed.example.com,inside.example.com,refusing.example.com,nowhere.example.com",
)
.unwrap()
}
/// A proxy on one end of a socket pair, handling that one connection on its own thread.
fn start(dial: Arc<FakeDial>) -> (UnixStream, std::thread::JoinHandle<()>) {
let (client, server) = UnixStream::pair().unwrap();
client
.set_read_timeout(Some(Duration::from_secs(5)))
.unwrap();
let proxy = Proxy::new(allow(), dial).with_handshake_timeout(Duration::from_millis(300));
let handle = std::thread::spawn(move || proxy.handle(server));
(client, handle)
}
fn request(host: &str, port: u16) -> Vec<u8> {
let mut bytes = vec![5, 1, 0, 3, u8::try_from(host.len()).unwrap()];
bytes.extend_from_slice(host.as_bytes());
bytes.extend_from_slice(&port.to_be_bytes());
bytes
}
fn read_exactly(client: &mut UnixStream, n: usize) -> Vec<u8> {
let mut buf = vec![0u8; n];
client.read_exact(&mut buf).unwrap();
buf
}
/// Read until the proxy closes; the bytes read. A close that leaves some of our bytes unread by
/// the proxy arrives as "connection reset" rather than as the end: both mean closed. A timeout
/// does not: the proxy did not close.
fn read_to_close(client: &mut UnixStream) -> Vec<u8> {
let mut rest = Vec::new();
let mut buf = [0u8; 4096];
loop {
match client.read(&mut buf) {
Ok(0) => return rest,
Ok(n) => rest.extend_from_slice(&buf[..n]),
Err(e) if e.kind() == std::io::ErrorKind::ConnectionReset => return rest,
Err(e) => panic!("the proxy did not close: {e}"),
}
}
}
/// Greeting, then `req`; the reply's code, and that the proxy then closed (for failures).
fn reply_code(req: &[u8]) -> u8 {
let (mut client, handle) = start(dial());
client.write_all(&[5, 1, 0]).unwrap();
assert_eq!(read_exactly(&mut client, 2), [5, 0]);
client.write_all(req).unwrap();
let reply = read_exactly(&mut client, 10);
assert_eq!(reply[0], 5);
assert_eq!(&reply[2..], &[0, 1, 0, 0, 0, 0, 0, 0]);
if reply[1] != 0 {
assert_eq!(
read_to_close(&mut client),
b"",
"a refusal is followed by the close"
);
handle.join().unwrap();
}
reply[1]
}
#[test]
fn an_allowed_host_is_connected_and_bytes_flow_both_ways_until_both_ends_close() {
let dial = dial();
let (mut client, handle) = start(Arc::clone(&dial));
client.write_all(&[5, 2, 2, 0]).unwrap();
assert_eq!(
read_exactly(&mut client, 2),
[5, 0],
"method 0 among others"
);
client.write_all(&request("example.com", 443)).unwrap();
assert_eq!(
read_exactly(&mut client, 10),
[5, 0, 0, 1, 0, 0, 0, 0, 0, 0]
);
client.write_all(b"hello through the tunnel").unwrap();
assert_eq!(read_exactly(&mut client, 24), b"hello through the tunnel");
client.shutdown(Shutdown::Write).unwrap();
assert_eq!(
read_to_close(&mut client),
b"",
"the half-close reached the server and back"
);
handle.join().unwrap();
assert_eq!(
*dial.connected.lock().unwrap(),
vec![sa("93.184.216.34:443")]
);
}
#[test]
fn a_wildcard_pattern_allows_a_name_under_it() {
assert_eq!(reply_code(&request("www.example.org", 443)), 0);
}
#[test]
fn a_greeting_without_method_zero_is_answered_ff_and_closed() {
let (mut client, handle) = start(dial());
client.write_all(&[5, 2, 1, 2]).unwrap();
assert_eq!(read_exactly(&mut client, 2), [5, 0xff]);
assert_eq!(read_to_close(&mut client), b"");
handle.join().unwrap();
}
#[test]
fn another_version_is_closed_without_a_word() {
let (mut client, handle) = start(dial());
client.write_all(&[4, 1, 0]).unwrap();
assert_eq!(read_to_close(&mut client), b"");
handle.join().unwrap();
}
#[test]
fn only_connect_by_name_to_an_allowed_host_on_443() {
let mut bind = request("example.com", 443);
bind[1] = 2;
assert_eq!(reply_code(&bind), COMMAND_NOT_SUPPORTED);
let mut udp = request("example.com", 443);
udp[1] = 3;
assert_eq!(reply_code(&udp), COMMAND_NOT_SUPPORTED);
assert_eq!(
reply_code(&[5, 1, 0, 1, 93, 184, 216, 34, 1, 187]),
ADDRESS_TYPE_NOT_SUPPORTED
);
let mut v6 = vec![5, 1, 0, 4];
v6.extend_from_slice(&[0; 16]);
v6.extend_from_slice(&443u16.to_be_bytes());
assert_eq!(reply_code(&v6), ADDRESS_TYPE_NOT_SUPPORTED);
for (host, port) in [
("example.com", 80),
("example.com", 8443),
("evil.test", 443),
("example.org", 443), // *.example.org does not cover example.org
("Example.com", 443), // not a valid host name
("example.com.", 443),
("127.0.0.1", 443), // an IP literal sent as a name
("wwwexample.com", 443),
] {
assert_eq!(
reply_code(&request(host, port)),
NOT_ALLOWED,
"{host}:{port}"
);
}
}
#[test]
fn an_empty_or_non_utf8_name_is_not_allowed() {
assert_eq!(reply_code(&[5, 1, 0, 3, 0, 1, 187]), NOT_ALLOWED);
assert_eq!(
reply_code(&[5, 1, 0, 3, 2, 0xff, 0xfe, 1, 187]),
NOT_ALLOWED
);
}
#[test]
fn only_public_addresses_are_used() {
let dial = dial();
let (mut client, handle) = start(Arc::clone(&dial));
client.write_all(&[5, 1, 0]).unwrap();
read_exactly(&mut client, 2);
client
.write_all(&request("mixed.example.com", 443))
.unwrap();
assert_eq!(read_exactly(&mut client, 10)[1], 0);
drop(client);
handle.join().unwrap();
assert_eq!(
*dial.connected.lock().unwrap(),
vec![sa("1.1.1.1:443")],
"the private and tailnet addresses were skipped, not tried"
);
}
#[test]
fn a_name_with_no_public_address_or_no_address_is_unreachable() {
assert_eq!(
reply_code(&request("inside.example.com", 443)),
HOST_UNREACHABLE
);
assert_eq!(
reply_code(&request("nowhere.example.com", 443)),
HOST_UNREACHABLE
);
}
#[test]
fn a_refused_connection_is_reply_5() {
assert_eq!(
reply_code(&request("refusing.example.com", 443)),
CONNECTION_REFUSED
);
}
#[test]
fn a_stalled_handshake_is_closed_at_the_deadline() {
let (mut client, handle) = start(dial());
client.write_all(&[5, 1, 0]).unwrap();
assert_eq!(read_exactly(&mut client, 2), [5, 0]);
client.write_all(&[5, 1]).unwrap(); // half a request, then nothing
let started = Instant::now();
assert_eq!(read_to_close(&mut client), b"");
let took = started.elapsed();
handle.join().unwrap();
assert!(
took < Duration::from_secs(2),
"one deadline for the handshake: {took:?}"
);
}
#[test]
fn a_handshake_that_trickles_still_ends_at_the_deadline() {
let (mut client, handle) = start(dial());
let started = Instant::now();
// One byte every 100 ms: each read is quick, but the handshake as a whole is not.
for byte in [5u8, 1, 0, 5, 1, 0, 3, 11] {
if client.write_all(&[byte]).is_err() {
break;
}
std::thread::sleep(Duration::from_millis(100));
}
let _ = read_to_close(&mut client);
handle.join().unwrap();
assert!(started.elapsed() < Duration::from_secs(2));
}
#[test]
fn allow_lists_parse_strictly() {
assert!(Allow::parse("example.com").is_ok());
assert!(Allow::parse("example.com,*.example.org").is_ok());
for bad in [
"",
",",
"example.com,",
"a.com,,b.com",
"Example.com",
"*",
"10.0.0.1",
] {
assert!(Allow::parse(bad).is_err(), "{bad:?}");
}
let allow = Allow::parse("*.example.org").unwrap();
assert!(allow.permits("a.example.org"));
assert!(!allow.permits("example.org"));
assert!(!allow.permits("A.example.org"));
}
#[test]
fn more_than_the_limit_of_connections_are_closed_at_once() {
let dir = TempDir::new("egress-limit");
let path = dir.path().join("egress.sock");
let listener = UnixListener::bind(&path).unwrap();
let proxy =
Arc::new(Proxy::new(allow(), dial()).with_handshake_timeout(Duration::from_secs(5)));
std::thread::spawn(move || {
let _ = proxy.serve(listener);
});
// MAX_CONNECTIONS clients that greet and then wait, holding their places.
let mut held = Vec::new();
for _ in 0..MAX_CONNECTIONS {
let mut c = UnixStream::connect(&path).unwrap();
c.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
c.write_all(&[5, 1, 0]).unwrap();
assert_eq!(read_exactly(&mut c, 2), [5, 0]);
held.push(c);
}
let mut extra = UnixStream::connect(&path).unwrap();
extra
.set_read_timeout(Some(Duration::from_secs(2)))
.unwrap();
let _ = extra.write_all(&[5, 1, 0]);
let mut buf = [0u8; 2];
assert!(
matches!(extra.read(&mut buf), Ok(0) | Err(_)),
"the connection over the limit gets no answer"
);
// When one place is freed, a new connection is served again.
drop(held.pop());
std::thread::sleep(Duration::from_millis(200));
let mut again = UnixStream::connect(&path).unwrap();
again
.set_read_timeout(Some(Duration::from_secs(5)))
.unwrap();
again.write_all(&[5, 1, 0]).unwrap();
assert_eq!(read_exactly(&mut again, 2), [5, 0]);
}
#[test]
fn the_program_listens_where_it_is_told_and_refuses_what_is_not_allowed() {
let dir = TempDir::new("egress-prog");
let path = dir.path().join("egress.sock");
let mut child = std::process::Command::new(env!("CARGO_BIN_EXE_toolkit"))
.args([
"egress-proxy",
"--socket",
path.to_str().unwrap(),
"--allow",
"example.com",
])
.spawn()
.unwrap();
let until = Instant::now() + Duration::from_secs(5);
let mut client = loop {
if let Ok(c) = UnixStream::connect(&path) {
break c;
}
assert!(Instant::now() < until, "the proxy never listened");
std::thread::sleep(Duration::from_millis(20));
};
client
.set_read_timeout(Some(Duration::from_secs(5)))
.unwrap();
client.write_all(&[5, 1, 0]).unwrap();
assert_eq!(read_exactly(&mut client, 2), [5, 0]);
client.write_all(&request("evil.test", 443)).unwrap();
assert_eq!(read_exactly(&mut client, 10)[1], NOT_ALLOWED);
child.kill().unwrap();
child.wait().unwrap();
}
#[test]
fn the_program_refuses_bad_arguments() {
let dir = TempDir::new("egress-args");
let path = dir.path().join("egress.sock");
let sock = path.to_str().unwrap();
std::fs::write(dir.path().join("taken"), "").unwrap();
let taken = dir.path().join("taken");
for args in [
vec!["egress-proxy", "--socket", sock, "--allow", "Example.com"],
vec!["egress-proxy", "--socket", sock, "--allow", ""],
vec![
"egress-proxy",
"--socket",
taken.to_str().unwrap(),
"--allow",
"example.com",
],
vec!["egress-proxy", "--socket", sock],
vec!["egress-proxy", "--allow", "example.com", "--socket", sock],
] {
let out = std::process::Command::new(env!("CARGO_BIN_EXE_toolkit"))
.args(&args)
.output()
.unwrap();
assert_eq!(out.status.code(), Some(2), "{args:?}");
}
}
@@ -0,0 +1,146 @@
//! `http_fetch`: the fixed `curl` arguments, and what `toolkit` makes of `curl`'s answer, against
//! fake `curl` scripts. Do not edit.
mod support;
use std::os::unix::fs::PermissionsExt;
use std::path::PathBuf;
use std::sync::{Mutex, MutexGuard};
use proto::tools::HttpFetchArgs;
use support::TempDir;
use toolkit::Exit;
use toolkit::fetch::{curl_args, fetch_with};
const URL: &str = "https://example.com/a?b=c";
/// Every test that starts a process takes its turn. Otherwise another test's fork can hold
/// the script open for writing at the moment it is run, and running it fails with "text file
/// busy" (ETXTBSY), which has nothing to do with the code under test.
static SERIAL: Mutex<()> = Mutex::new(());
fn serial() -> MutexGuard<'static, ()> {
SERIAL.lock().unwrap_or_else(|p| p.into_inner())
}
fn fake_curl(dir: &TempDir, body: &str) -> PathBuf {
let path = dir.path().join("curl");
std::fs::write(&path, format!("#!/bin/sh\n{body}\n")).unwrap();
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap();
path
}
fn args() -> HttpFetchArgs {
HttpFetchArgs {
url: URL.to_string(),
}
}
#[test]
fn the_argument_list_is_fixed_and_ends_with_the_url() {
let expected: Vec<&str> = vec![
"--silent",
"--show-error",
"--proto",
"=https",
"--proto-redir",
"=https",
"--location",
"--max-redirs",
"5",
"--max-time",
"50",
"--max-filesize",
"8388608",
"--cacert",
"/etc/ssl/certs/ca-certificates.crt",
"--proxy",
"socks5h://localhost/run/egress/egress.sock",
"--write-out",
"\n[http %{response_code}]",
"--url",
URL,
];
assert_eq!(curl_args(URL), expected);
}
#[test]
fn curl_is_given_exactly_those_arguments() {
let _serial = serial();
let dir = TempDir::new("fetch-args");
let curl = fake_curl(&dir, r#"for a in "$@"; do printf '%s|' "$a"; done"#);
let got = fetch_with(&curl, &args());
assert_eq!(got.exit, Exit::Done);
let expected: String = curl_args(URL).iter().map(|a| format!("{a}|")).collect();
assert_eq!(got.stdout, expected);
}
#[test]
fn a_successful_fetch_is_the_body_and_the_status_line() {
let _serial = serial();
let dir = TempDir::new("fetch-ok");
let curl = fake_curl(&dir, r#"printf 'hello\n\n[http 404]'"#);
let got = fetch_with(&curl, &args());
assert_eq!(
(got.exit, got.stdout.as_str()),
(Exit::Done, "hello\n\n[http 404]")
);
}
#[test]
fn a_failed_fetch_is_exit_1_with_curls_first_error_line() {
let _serial = serial();
let dir = TempDir::new("fetch-fail");
let curl = fake_curl(
&dir,
"printf 'partial'; printf '\\ncurl: (97) cannot complete SOCKS5 connection to evil.test. (2)\\nmore\\n' >&2; exit 97",
);
let got = fetch_with(&curl, &args());
assert_eq!(got.exit, Exit::ToolError);
assert_eq!(
got.stdout,
format!(
"http_fetch: {URL}: curl: (97) cannot complete SOCKS5 connection to evil.test. (2)"
)
);
}
#[test]
fn a_failure_without_a_message_names_the_exit() {
let _serial = serial();
let dir = TempDir::new("fetch-quiet");
let curl = fake_curl(&dir, "exit 28");
let got = fetch_with(&curl, &args());
assert_eq!(
(got.exit, got.stdout.as_str()),
(
Exit::ToolError,
"http_fetch: https://example.com/a?b=c: curl exited 28"
)
);
}
#[test]
fn a_curl_that_cannot_start_is_exit_1() {
let _serial = serial();
let got = fetch_with(&PathBuf::from("/no/such/curl"), &args());
assert_eq!(got.exit, Exit::ToolError);
assert!(
got.stdout
.starts_with("http_fetch: cannot start /no/such/curl: "),
"{}",
got.stdout
);
}
#[test]
fn much_output_on_both_streams_does_not_stop_curl() {
let _serial = serial();
let dir = TempDir::new("fetch-both");
let curl = fake_curl(
&dir,
"head -c 300000 /dev/zero | tr '\\0' e >&2; head -c 300000 /dev/zero | tr '\\0' o; exit 0",
);
let got = fetch_with(&curl, &args());
assert_eq!((got.exit, got.stdout.len()), (Exit::Done, 300000));
}
@@ -0,0 +1,159 @@
//! `toolkit read_file` and `toolkit write_file`, run as `brokerd` runs them. Do not edit.
mod support;
use std::os::unix::fs::PermissionsExt;
use proto::tools::{ReadFileArgs, WriteFileArgs};
use support::{TempDir, json, toolkit};
use toolkit::files::MAX_READ;
fn read(path: &str) -> support::Ran {
toolkit(
&["read_file"],
&json(&ReadFileArgs {
path: path.to_string(),
}),
)
}
fn write(path: &str, content: &str) -> support::Ran {
let args = WriteFileArgs {
path: path.to_string(),
content: content.to_string(),
};
toolkit(&["write_file"], &json(&args))
}
#[test]
fn a_file_is_read_exactly() {
let dir = TempDir::new("read");
let text = "line one\nline two, no newline at the end: ✓";
std::fs::write(dir.at("a.md"), text).unwrap();
let ran = read(&dir.at("a.md"));
assert_eq!((ran.code, ran.stdout.as_str()), (0, text));
assert_eq!(ran.stderr, "");
}
#[test]
fn an_empty_file_is_empty_output() {
let dir = TempDir::new("read-empty");
std::fs::write(dir.at("e"), "").unwrap();
let ran = read(&dir.at("e"));
assert_eq!((ran.code, ran.stdout.as_str()), (0, ""));
}
#[test]
fn what_cannot_be_read_is_exit_1_with_one_line_for_the_model() {
let dir = TempDir::new("read-bad");
std::fs::write(dir.at("bin"), [0xff, 0xfe, 0x00]).unwrap();
std::fs::write(dir.at("big"), vec![b'a'; MAX_READ + 1]).unwrap();
std::fs::write(dir.at("exact"), vec![b'a'; MAX_READ]).unwrap();
let cases = [
(dir.at("missing"), "no such file"),
(dir.at(""), "is a directory"),
(dir.at("bin"), "not UTF-8 text"),
(dir.at("big"), "larger than 1048576 bytes"),
];
for (path, why) in cases {
let ran = read(&path);
assert_eq!(ran.code, 1, "{path}");
assert_eq!(ran.stdout, format!("read_file: {path}: {why}"));
}
let ran = read(&dir.at("exact"));
assert_eq!(
(ran.code, ran.stdout.len()),
(0, MAX_READ),
"exactly the limit is fine"
);
}
#[test]
fn an_unreadable_file_names_the_error() {
if is_root() {
return; // root reads anything
}
let dir = TempDir::new("read-perm");
std::fs::write(dir.at("secret"), "x").unwrap();
std::fs::set_permissions(dir.at("secret"), std::fs::Permissions::from_mode(0o000)).unwrap();
let ran = read(&dir.at("secret"));
assert_eq!(ran.code, 1);
assert!(
ran.stdout
.starts_with(&format!("read_file: {}: ", dir.at("secret"))),
"{}",
ran.stdout
);
assert!(ran.stdout.contains("ermission denied"), "{}", ran.stdout);
}
#[test]
fn a_file_is_written_created_or_replaced() {
let dir = TempDir::new("write");
let ran = write(&dir.at("new.md"), "hello ✓\n");
assert_eq!(ran.code, 0);
assert_eq!(
ran.stdout,
format!("wrote 10 bytes to {}", dir.at("new.md"))
);
assert_eq!(
std::fs::read_to_string(dir.at("new.md")).unwrap(),
"hello ✓\n"
);
let ran = write(&dir.at("new.md"), "");
assert_eq!(ran.stdout, format!("wrote 0 bytes to {}", dir.at("new.md")));
assert_eq!(std::fs::read_to_string(dir.at("new.md")).unwrap(), "");
}
#[test]
fn what_cannot_be_written_is_exit_1() {
let dir = TempDir::new("write-bad");
std::fs::create_dir(dir.at("sub")).unwrap();
let cases = [
(dir.at("nope/a.md"), "the directory does not exist"),
(dir.at("sub"), "is a directory"),
];
for (path, why) in cases {
let ran = write(&path, "x");
assert_eq!(ran.code, 1, "{path}");
assert_eq!(ran.stdout, format!("write_file: {path}: {why}"));
}
assert!(!dir.path().join("nope").exists(), "no directory is created");
}
#[test]
fn misuse_is_exit_2_with_nothing_on_standard_output() {
let long = vec![b' '; toolkit::input::MAX_INPUT + 1];
let cases: [(&[&str], &[u8], &str); 7] = [
(
&["read_file"],
br#"{"path":"/a","mode":1}"#,
"the arguments do not parse",
),
(&["read_file"], b"", "the arguments do not parse"),
(
&["write_file"],
br#"{"path":"/a"}"#,
"the arguments do not parse",
),
(&["read_file"], &[0xff, 0xfe], "not UTF-8"),
(&["read_file"], &long, "larger than 2097152 bytes"),
(&["format_disk"], b"{}", "unknown tool"),
(&[], b"{}", "unknown tool"),
];
for (args, input, why) in cases {
let ran = toolkit(args, input);
assert_eq!(ran.code, 2, "{args:?}");
assert_eq!(
ran.stdout, "",
"{args:?}: the model sees nothing of a misuse"
);
assert!(ran.stderr.contains(why), "{args:?}: {}", ran.stderr);
}
}
fn is_root() -> bool {
std::fs::read_to_string("/proc/self/status")
.map(|s| s.lines().any(|l| l.starts_with("Uid:\t0\t")))
.unwrap_or(false)
}
@@ -0,0 +1,89 @@
//! `toolkit shell`, run as `brokerd` runs it, with the host's `/bin/sh`. Do not edit.
mod support;
use proto::tools::ShellArgs;
use support::{TempDir, json, toolkit};
use toolkit::shell::MAX_OUTPUT;
fn sh(command: &str, cwd: Option<&str>) -> support::Ran {
let args = ShellArgs {
command: command.to_string(),
cwd: cwd.map(str::to_string),
};
toolkit(&["shell"], &json(&args))
}
#[test]
fn output_and_errors_come_back_in_order_then_the_exit_status() {
let ran = sh("echo one; echo two >&2; echo three", None);
assert_eq!(ran.code, 0);
assert_eq!(ran.stdout, "one\ntwo\nthree\n\n[exit 0]");
}
#[test]
fn a_failing_command_is_still_a_result_with_its_status() {
let ran = sh("echo nope; exit 3", None);
assert_eq!((ran.code, ran.stdout.as_str()), (0, "nope\n\n[exit 3]"));
let ran = sh("true", None);
assert_eq!(ran.stdout, "\n[exit 0]");
}
#[test]
fn a_command_killed_by_a_signal_says_so() {
let ran = sh("kill -9 $$", None);
assert_eq!(
(ran.code, ran.stdout.as_str()),
(0, "\n[killed by signal 9]")
);
}
#[test]
fn the_command_runs_in_cwd_or_in_tmp() {
let dir = TempDir::new("cwd");
let here = dir.path().to_str().unwrap();
let ran = sh("pwd", Some(here));
assert_eq!(ran.stdout, format!("{here}\n\n[exit 0]"));
let ran = sh("pwd", None);
assert_eq!(ran.stdout, "/tmp\n\n[exit 0]");
}
#[test]
fn a_missing_cwd_is_exit_1() {
let ran = sh("pwd", Some("/no/such/dir"));
assert_eq!(
(ran.code, ran.stdout.as_str()),
(1, "shell: /no/such/dir: no such directory")
);
}
#[test]
fn standard_input_is_empty() {
let ran = sh("cat; echo done", None);
assert_eq!(ran.stdout, "done\n\n[exit 0]");
}
#[test]
fn output_past_the_limit_is_dropped_and_the_command_still_finishes() {
let ran = sh(
&format!(
"head -c {} /dev/zero | tr '\\0' a; echo; echo end >&2; exit 4",
MAX_OUTPUT + 5000
),
None,
);
assert_eq!(ran.code, 0);
let expected_tail = format!("\n[output after {MAX_OUTPUT} bytes dropped]\n[exit 4]");
assert!(
ran.stdout.ends_with(&expected_tail),
"{}",
&ran.stdout[ran.stdout.len() - 80..]
);
assert_eq!(ran.stdout.len(), MAX_OUTPUT + expected_tail.len());
}
#[test]
fn output_that_is_not_utf8_is_replaced_not_refused() {
let ran = sh("printf 'a\\377b'", None);
assert_eq!(ran.stdout, "a\u{fffd}b\n[exit 0]");
}
@@ -0,0 +1,68 @@
//! Running the `toolkit` binary as `brokerd` does: arguments on standard input. Do not edit.
#![allow(dead_code)] // each test file uses its own part
use std::io::Write;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::sync::atomic::{AtomicU32, Ordering};
static NEXT: AtomicU32 = AtomicU32::new(0);
/// A temporary directory, removed when dropped.
pub struct TempDir(pub PathBuf);
impl TempDir {
pub fn new(tag: &str) -> TempDir {
let n = NEXT.fetch_add(1, Ordering::SeqCst);
let path = std::env::temp_dir().join(format!("tk-{tag}-{}-{n}", std::process::id()));
let _ = std::fs::remove_dir_all(&path);
std::fs::create_dir_all(&path).unwrap();
TempDir(path)
}
pub fn path(&self) -> &Path {
&self.0
}
/// The path of `name` inside, as a string (the tools take strings).
pub fn at(&self, name: &str) -> String {
self.0.join(name).to_str().unwrap().to_string()
}
}
impl Drop for TempDir {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
pub struct Ran {
pub code: i32,
pub stdout: String,
pub stderr: String,
}
/// Run `toolkit <args…>` with `input` on standard input.
pub fn toolkit(args: &[&str], input: &[u8]) -> Ran {
let mut child = Command::new(env!("CARGO_BIN_EXE_toolkit"))
.args(args)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.unwrap();
let mut stdin = child.stdin.take().unwrap();
// A broken pipe here only means toolkit stopped reading, which some tests expect.
let _ = stdin.write_all(input);
drop(stdin);
let out = child.wait_with_output().unwrap();
Ran {
code: out.status.code().unwrap_or(-1),
stdout: String::from_utf8(out.stdout).unwrap(),
stderr: String::from_utf8_lossy(&out.stderr).into_owned(),
}
}
/// The JSON for one argument struct.
pub fn json<T: serde::Serialize>(value: &T) -> Vec<u8> {
serde_json::to_vec(value).unwrap()
}