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