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:
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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::{
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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()
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user