Move the tools' arguments and the host rules to proto

Implemented-By: OpenCode session (model recorded in docs/implementer-log.md)
This commit is contained in:
2026-09-22 22:59:40 -07:00
parent 655683c9e0
commit f095cca1ee
7 changed files with 213 additions and 87 deletions
+3 -87
View File
@@ -4,7 +4,7 @@
//! path, a host or a URL is well formed. The module is pure: no I/O, no clock. Everything it reads
//! was written by the model, so it is treated as hostile.
use serde::{Deserialize, Serialize};
use proto::tools::{HttpFetchArgs, ReadFileArgs, ShellArgs, WriteFileArgs};
use std::fmt;
/// Maximum length, in bytes, of a path.
@@ -142,34 +142,6 @@ impl std::error::Error for ArgsError {
}
}
/// The argument shape for one tool, decoded then re-serialised.
#[derive(Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct ReadFileArgs {
path: String,
}
#[derive(Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct WriteFileArgs {
path: String,
content: String,
}
#[derive(Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct ShellArgs {
command: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
cwd: Option<String>,
}
#[derive(Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct HttpFetchArgs {
url: String,
}
/// Decode `arguments` for `tool` into a typed value.
pub fn parse(tool: ToolName, arguments: &str) -> Result<ToolArgs, ArgsError> {
match tool {
@@ -257,64 +229,8 @@ pub fn inside(grant_path: &str, path: &str) -> bool {
}
}
/// A host label is 1 to 63 bytes of `a-z`, `0-9` or `-`, and neither starts nor ends with `-`.
fn valid_label(label: &str) -> bool {
if !(1..=63).contains(&label.len()) {
return false;
}
let bytes = label.as_bytes();
if bytes.iter().next() == Some(&b'-') || bytes.iter().last() == Some(&b'-') {
return false;
}
bytes
.iter()
.all(|&byte| matches!(byte, b'a'..=b'z' | b'0'..=b'9' | b'-'))
}
/// A host is 1 to 253 bytes of dot-separated labels, each a whole non-dot name, and the last label
/// starts with a letter. That last rule keeps out every spelling of an IPv4 address.
pub fn valid_host(host: &str) -> bool {
if !(1..=253).contains(&host.len()) {
return false;
}
let labels: Vec<&str> = host.split('.').collect();
if labels.len() < 2 {
return false;
}
for label in &labels {
if !valid_label(label) {
return false;
}
}
match labels.iter().last() {
Some(last) => matches!(last.bytes().next(), Some(byte) if byte.is_ascii_lowercase()),
None => false,
}
}
/// A host, or `*.` followed by a host. Nothing else.
pub fn valid_host_pattern(pattern: &str) -> bool {
match pattern.strip_prefix("*.") {
Some(base) => valid_host(base),
None => valid_host(pattern),
}
}
/// Does `pattern` match `host`? Without `*.` the strings must be equal; with `*.base` the host must
/// end in `.base` with something before the dot.
pub fn host_matches(pattern: &str, host: &str) -> bool {
let base = match pattern.strip_prefix("*.") {
Some(base) => base,
None => return pattern == host,
};
match host.strip_suffix(base) {
Some(before) => match before.strip_suffix('.') {
Some(prefix) => !prefix.is_empty(),
None => false,
},
None => false,
}
}
/// Moved to `proto::hosts` in M3b, so `toolkit` checks hosts with the same rules.
pub use proto::hosts::{host_matches, valid_host, valid_host_pattern};
/// A character that may appear in a host name.
fn host_char(c: char) -> bool {
+61
View File
@@ -0,0 +1,61 @@
//! Host names and patterns, shared by `brokerd` and the egress proxy so both check hosts with the
//! same rules.
/// A host label is 1 to 63 bytes of `a-z`, `0-9` or `-`, and neither starts nor ends with `-`.
fn valid_label(label: &str) -> bool {
if !(1..=63).contains(&label.len()) {
return false;
}
let bytes = label.as_bytes();
if bytes.iter().next() == Some(&b'-') || bytes.iter().last() == Some(&b'-') {
return false;
}
bytes
.iter()
.all(|&byte| matches!(byte, b'a'..=b'z' | b'0'..=b'9' | b'-'))
}
/// A host is 1 to 253 bytes of dot-separated labels, each a whole non-dot name, and the last label
/// starts with a letter. That last rule keeps out every spelling of an IPv4 address.
pub fn valid_host(host: &str) -> bool {
if !(1..=253).contains(&host.len()) {
return false;
}
let labels: Vec<&str> = host.split('.').collect();
if labels.len() < 2 {
return false;
}
for label in &labels {
if !valid_label(label) {
return false;
}
}
match labels.iter().last() {
Some(last) => matches!(last.bytes().next(), Some(byte) if byte.is_ascii_lowercase()),
None => false,
}
}
/// A host, or `*.` followed by a host. Nothing else.
pub fn valid_host_pattern(pattern: &str) -> bool {
match pattern.strip_prefix("*.") {
Some(base) => valid_host(base),
None => valid_host(pattern),
}
}
/// Does `pattern` match `host`? Without `*.` the strings must be equal; with `*.base` the host must
/// end in `.base` with something before the dot.
pub fn host_matches(pattern: &str, host: &str) -> bool {
let base = match pattern.strip_prefix("*.") {
Some(base) => base,
None => return pattern == host,
};
match host.strip_suffix(base) {
Some(before) => match before.strip_suffix('.') {
Some(prefix) => !prefix.is_empty(),
None => false,
},
None => false,
}
}
+2
View File
@@ -6,8 +6,10 @@ pub mod class;
pub mod frame;
pub mod grant;
pub mod hash;
pub mod hosts;
pub mod ids;
pub mod log;
pub mod tools;
pub mod wire;
pub use audit::{
+36
View File
@@ -0,0 +1,36 @@
//! The four tools' arguments, decoded from the model's JSON then re-serialised. Shared by
//! `brokerd` (which validates them) and `toolkit` (inside the container, which runs them), so the
//! two must agree on one definition.
use serde::{Deserialize, Serialize};
/// The arguments for `read_file`.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ReadFileArgs {
pub path: String,
}
/// The arguments for `write_file`.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct WriteFileArgs {
pub path: String,
pub content: String,
}
/// The arguments for `shell`.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ShellArgs {
pub command: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cwd: Option<String>,
}
/// The arguments for `http_fetch`.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct HttpFetchArgs {
pub url: String,
}
+50
View File
@@ -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"));
}
+60
View File
@@ -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()
);
}