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:
@@ -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"
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
[runner]
|
||||
podman = "podman"
|
||||
@@ -0,0 +1,2 @@
|
||||
[runner]
|
||||
image = "localhost/boxmaker-tools:latest"
|
||||
+3
@@ -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
|
||||
+16
@@ -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(),
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user