From 313af0570165c05bc2e51f65452669ffb151eb05 Mon Sep 17 00:00:00 2001 From: "@permadeath.com" Date: Tue, 15 Sep 2026 11:25:19 -0400 Subject: [PATCH] feat(cli): add the didbot dispatcher and the didbot-cli library `didbot ` runs the verb's binary from PATH with argv and a scrubbed environment. First-party verbs are a table, so a verb whose binary is absent answers "install didbot-operator" rather than "unknown command". Co-Authored-By: Claude Fable 5.1 Change-Id: I80f7a0f55a519b55f53a886314d3a22a5d4b5c3d --- Cargo.lock | 137 +++++++++++ Cargo.toml | 5 + crates/didbot-cli/Cargo.toml | 17 ++ crates/didbot-cli/src/lib.rs | 197 ++++++++++++++++ crates/didbot-dispatch/Cargo.toml | 25 ++ crates/didbot-dispatch/src/main.rs | 164 +++++++++++++ crates/didbot-dispatch/src/path.rs | 55 +++++ crates/didbot-dispatch/src/run.rs | 92 ++++++++ crates/didbot-dispatch/src/verbs.rs | 63 +++++ crates/didbot-dispatch/tests/dispatch.rs | 284 +++++++++++++++++++++++ 10 files changed, 1039 insertions(+) create mode 100644 crates/didbot-cli/Cargo.toml create mode 100644 crates/didbot-cli/src/lib.rs create mode 100644 crates/didbot-dispatch/Cargo.toml create mode 100644 crates/didbot-dispatch/src/main.rs create mode 100644 crates/didbot-dispatch/src/path.rs create mode 100644 crates/didbot-dispatch/src/run.rs create mode 100644 crates/didbot-dispatch/src/verbs.rs create mode 100644 crates/didbot-dispatch/tests/dispatch.rs diff --git a/Cargo.lock b/Cargo.lock index 07251090..258030b3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -47,6 +47,56 @@ dependencies = [ "libc", ] +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + [[package]] name = "ar_archive_writer" version = "0.5.3" @@ -622,6 +672,46 @@ dependencies = [ "unsigned-varint", ] +[[package]] +name = "clap" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa8876b300ab35ba921adea3dfd70157a46249b33f95c9084ae5709785478946" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0797fb7aeb1406c84efac526901f7ec3ead2124f946b494e72879d4b54704d" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9c751b79415d4e559e3d1fcf128e09e720eb673a06d26cf6f392d37d75b66e0" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "clap_lex" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c133bc6a41be0d194c306b5506d15e6feeea7b1d6604bd3f8310dfb2ca96486" + [[package]] name = "cobs" version = "0.3.0" @@ -631,6 +721,12 @@ dependencies = [ "thiserror 2.0.20", ] +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + [[package]] name = "combine" version = "4.6.8" @@ -1098,6 +1194,15 @@ dependencies = [ "time", ] +[[package]] +name = "didbot-cli" +version = "0.1.0" +dependencies = [ + "clap", + "serde", + "serde_json", +] + [[package]] name = "didbot-config" version = "0.1.0" @@ -1117,6 +1222,14 @@ dependencies = [ "thiserror 2.0.20", ] +[[package]] +name = "didbot-dispatch" +version = "0.1.0" +dependencies = [ + "clap", + "didbot-cli", +] + [[package]] name = "didbot-dns" version = "0.1.0" @@ -2011,6 +2124,12 @@ dependencies = [ "stable_deref_trait", ] +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + [[package]] name = "hex" version = "0.4.3" @@ -2364,6 +2483,12 @@ version = "2.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6a756c3fac73139e83f14c2d742155dd2b78d3ee56597b419a0579b7bdd6dd78" +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + [[package]] name = "itertools" version = "0.14.0" @@ -3165,6 +3290,12 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + [[package]] name = "openssl-probe" version = "0.2.1" @@ -4893,6 +5024,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + [[package]] name = "uuid" version = "1.26.1" diff --git a/Cargo.toml b/Cargo.toml index 63bb93b2..07c8b1d3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -30,6 +30,7 @@ didbot-policy-cedar = { version = "0.1.0", path = "crates/didbot-policy-cedar" } didbot-policy-records = { version = "0.1.0", path = "crates/didbot-policy-records" } didbot-serve = { version = "0.1.0", path = "crates/didbot-serve" } didbot-operator = { version = "0.1.0", path = "crates/didbot-operator" } +didbot-cli = { version = "0.1.0", path = "crates/didbot-cli" } didbot-swarm = { version = "0.1.0", path = "crates/didbot-swarm" } didbot-identity = { version = "0.1.0", path = "crates/didbot-identity" } didbot-key = { version = "0.1.0", path = "crates/didbot-key" } @@ -107,6 +108,10 @@ serde = { version = "1", features = ["derive"] } serde_json = "1" subtle = "2" thiserror = "2" +# Every command-line surface parses through this one crate, via +# `didbot-cli`, so help, usage errors and exit statuses read the same +# whichever binary answered. +clap = { version = "4", features = ["derive"] } tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "signal", "time", "sync", "io-util"] } tower-http = { version = "0.6", features = ["trace", "cors", "timeout"] } tracing = "0.1" diff --git a/crates/didbot-cli/Cargo.toml b/crates/didbot-cli/Cargo.toml new file mode 100644 index 00000000..4f191f16 --- /dev/null +++ b/crates/didbot-cli/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "didbot-cli" +description = "What every didbot command shares: argument parsing, the --server and --json flags, exit statuses, one way to render an error, and the verb table the didbot dispatcher reads." +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +publish.workspace = true + +[dependencies] +clap.workspace = true +serde.workspace = true +serde_json.workspace = true + +[lints] +workspace = true diff --git a/crates/didbot-cli/src/lib.rs b/crates/didbot-cli/src/lib.rs new file mode 100644 index 00000000..347911a6 --- /dev/null +++ b/crates/didbot-cli/src/lib.rs @@ -0,0 +1,197 @@ +//! What every didbot command shares, so that verbs shipped as separate +//! binaries read as one tool. +//! +//! `didbot …` is a dispatcher: it finds `didbot-` on `PATH` +//! and hands it the rest of the command line. Each binary parses for itself, +//! and this crate is how they all parse the same way: [`parse`] over the +//! workspace's one `clap`, [`Server`] and [`Json`] for the two flags every +//! verb takes, [`Exit`] for the three statuses a script can rely on, and +//! [`finish`] for the one line an error is written as. +//! +//! [`FirstPartyVerb`] is the row type of the table the dispatcher carries. +//! It lives here rather than in the dispatcher so a verb crate's tests can +//! read the same type the dispatcher does. + +#![forbid(unsafe_code)] + +use std::fmt; +use std::process::ExitCode; + +use clap::Args; +use serde::Serialize; + +/// The environment variables a didbot command reads a credential from. +/// +/// The dispatcher removes every one of these before handing off to a verb, +/// so nothing reached through `didbot ` inherits a credential the +/// caller's shell happened to hold. A verb authenticates for itself. +pub mod env { + /// An agent account's own token, for a host with no daemon. + pub const AGENT_TOKEN: &str = "DIDBOT_AGENT_TOKEN"; + /// A file holding [`AGENT_TOKEN`]'s value. + pub const AGENT_TOKEN_FILE: &str = "DIDBOT_AGENT_TOKEN_FILE"; + /// Every variable the dispatcher scrubs. + pub const CREDENTIALS: &[&str] = &[AGENT_TOKEN, AGENT_TOKEN_FILE]; +} + +/// ` `, as the binary this expands in answers `--version`. +/// +/// A macro rather than a function because the version has to be the +/// calling crate's: `didbot --list` shows one line per installed binary, and +/// a line that reported this library's version would hide the skew it +/// exists to show. +#[macro_export] +macro_rules! version { + () => { + concat!(env!("CARGO_BIN_NAME"), " ", env!("CARGO_PKG_VERSION")) + }; +} + +/// How a didbot command exits. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[repr(u8)] +pub enum Exit { + /// Did what was asked. + Ok = 0, + /// Understood the request and could not, or would not, do it. + Failed = 1, + /// Did not understand the request. + Usage = 2, +} + +impl From for ExitCode { + fn from(exit: Exit) -> Self { + ExitCode::from(exit as u8) + } +} + +/// Why a command stopped, and how it exits. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Refusal { + /// The status to exit with. + pub exit: Exit, + /// One line for the person, without the program name. + pub message: String, +} + +impl Refusal { + /// A request this command understood and did not carry out. + pub fn failed(message: impl fmt::Display) -> Self { + Self { + exit: Exit::Failed, + message: message.to_string(), + } + } + + /// A request this command did not understand. + pub fn usage(message: impl fmt::Display) -> Self { + Self { + exit: Exit::Usage, + message: message.to_string(), + } + } +} + +impl fmt::Display for Refusal { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.message) + } +} + +/// Renders an outcome the way every didbot command does. +/// +/// Success is silent here — the verb printed its answer — and a refusal is +/// one line on stderr, `: `, with the exit status +/// [`Refusal::exit`] names. `program` is the spelling the person typed, so a +/// verb reached as `didbot login` says `didbot login:`. +pub fn finish(program: &str, result: Result<(), Refusal>) -> ExitCode { + match result { + Ok(()) => Exit::Ok.into(), + Err(refusal) => { + eprintln!("{program}: {refusal}"); + refusal.exit.into() + } + } +} + +/// Parses the command line as `T`. +/// +/// `--help` and `--version` answer on stdout and exit 0; a command line clap +/// cannot read is reported on stderr and exits [`Exit::Usage`]. This is +/// clap's own behaviour, wrapped so every verb goes through one call and a +/// change to how a usage error reads is made once. +pub fn parse() -> T { + match T::try_parse() { + Ok(parsed) => parsed, + Err(error) => error.exit(), + } +} + +/// `--server `: which deployment a verb acts on. +/// +/// Global, so it reads the same before or after the verb. Optional at parse +/// time because not every verb needs it; [`Server::hostname`] is the check +/// for one that does. +#[derive(Args, Debug, Clone, Default)] +pub struct Server { + /// The deployment to act on, as its own hostname + #[arg(long, global = true, value_name = "HOSTNAME")] + pub server: Option, +} + +impl Server { + /// The hostname, or the usage refusal a verb that needs one gives. + pub fn hostname(&self) -> Result<&str, Refusal> { + self.server + .as_deref() + .ok_or_else(|| Refusal::usage("--server is required")) + } +} + +/// `--json`: the answer as one line of JSON on stdout, for a script. +#[derive(Args, Debug, Clone, Copy, Default)] +pub struct Json { + /// Print the answer as JSON + #[arg(long, global = true)] + pub json: bool, +} + +impl Json { + /// Prints `value` as JSON, or the text `plain` renders from it. + pub fn print(self, value: &T, plain: impl FnOnce(&T) -> String) { + if self.json { + println!( + "{}", + serde_json::to_string(value).expect("an answer serializes") + ); + } else { + println!("{}", plain(value)); + } + } +} + +/// A verb the dispatcher knows by name, whether or not its binary is +/// installed. +/// +/// `didbot …` runs ` …`: the binary found on `PATH`, +/// then [`FirstPartyVerb::argv`], then what followed the verb on the +/// command line. A binary that is not installed is named, so the answer is +/// "install this" rather than "unknown command". +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct FirstPartyVerb { + /// What the person types after `didbot`. + pub verb: &'static str, + /// The binary that answers it. + pub binary: &'static str, + /// What precedes the caller's arguments when the binary is run. + pub argv: &'static [&'static str], + /// One line for `didbot --help`. + pub about: &'static str, +} + +impl FirstPartyVerb { + /// The row for `verb` in `table`, if there is one. + pub fn lookup<'a>(table: &'a [Self], verb: &str) -> Option<&'a Self> { + table.iter().find(|row| row.verb == verb) + } +} diff --git a/crates/didbot-dispatch/Cargo.toml b/crates/didbot-dispatch/Cargo.toml new file mode 100644 index 00000000..f5016499 --- /dev/null +++ b/crates/didbot-dispatch/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "didbot-dispatch" +description = "The didbot command: finds the didbot-* binary a verb belongs to and hands it the command line, passing nothing else." +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +publish.workspace = true + +# The crate is named for what it does; the binary is named for what a person +# types. Nothing else in the workspace links this crate, and it links nothing +# of the workspace but `didbot-cli`: a dispatcher that carried the server, or +# the operator's sign-in, would be the single binary the plan decided +# against. +[[bin]] +name = "didbot" +path = "src/main.rs" + +[dependencies] +clap.workspace = true +didbot-cli.workspace = true + +[lints] +workspace = true diff --git a/crates/didbot-dispatch/src/main.rs b/crates/didbot-dispatch/src/main.rs new file mode 100644 index 00000000..a03af2e7 --- /dev/null +++ b/crates/didbot-dispatch/src/main.rs @@ -0,0 +1,164 @@ +//! `didbot` — the one command a person types, and nothing more than a +//! router. +//! +//! `didbot …` finds the binary that answers `` and replaces +//! itself with it, handing over the rest of the command line. The +//! first-party verbs are in [`verbs::FIRST_PARTY`], each naming its binary, +//! so a verb whose binary is not installed is answered with what to install. +//! Any other word is `didbot-` on `PATH`, which is how someone adds a +//! verb without touching this repository. +//! +//! The dispatcher passes argv and nothing else. It adds nothing to the +//! environment and removes every variable in [`didbot_cli::env::CREDENTIALS`] +//! before handing off: a verb reached this way authenticates for itself, and +//! a program reachable by a typo on `didbot-` inherits no credential from the +//! shell that ran it. +//! +//! `didbot --list` scans `PATH` for `didbot-*`, asks each for `--version` and +//! prints what it found. Every binary is versioned on its own, so a mix of +//! versions shows up here and fails nowhere. + +#![forbid(unsafe_code)] + +mod path; +mod run; +mod verbs; + +use std::ffi::OsString; +use std::process::ExitCode; + +use clap::{CommandFactory, Parser, Subcommand}; +use didbot_cli::{finish, FirstPartyVerb, Refusal}; + +use crate::verbs::FIRST_PARTY; + +/// The spelling every refusal from this binary is prefixed with. +const PROGRAM: &str = "didbot"; + +#[derive(Parser)] +#[command( + name = PROGRAM, + version, + about = "The didbot commands, one entry point", + long_about = "Runs `didbot-` from PATH with the rest of the command line, and \ + nothing else: no credential from this shell reaches the verb.", + after_help = verbs::help_table(), + allow_external_subcommands = true, + disable_help_subcommand = true, + arg_required_else_help = true, + args_conflicts_with_subcommands = true +)] +struct Cli { + /// Every didbot-* on PATH, with its version + #[arg(long)] + list: bool, + #[command(subcommand)] + verb: Option, +} + +#[derive(Subcommand)] +enum Verb { + #[command(external_subcommand)] + External(Vec), +} + +fn main() -> ExitCode { + let cli: Cli = didbot_cli::parse(); + if cli.list { + return finish(PROGRAM, list()); + } + let Some(Verb::External(words)) = cli.verb else { + // Unreachable through clap: `arg_required_else_help` answers the + // empty command line, and `--list` returned above. + return finish( + PROGRAM, + Err(Refusal::usage("a verb is required; see --help")), + ); + }; + let (verb, rest) = words + .split_first() + .expect("an external subcommand has a name"); + let verb = verb.to_string_lossy(); + + // `didbot help ` is `didbot --help`, resolved the same way. + if verb == "help" { + return match rest.split_first() { + None => { + let _ = Cli::command().print_help(); + ExitCode::SUCCESS + } + Some((asked, _)) => { + let asked = asked.to_string_lossy(); + finish(PROGRAM, dispatch(&asked, &[OsString::from("--help")])) + } + }; + } + finish(PROGRAM, dispatch(&verb, rest)) +} + +/// Finds the binary for `verb` and runs it with `argv`. +/// +/// Only returns when the binary is not there, or could not be started: on +/// success the verb's process replaces this one. +fn dispatch(verb: &str, argv: &[OsString]) -> Result<(), Refusal> { + match FirstPartyVerb::lookup(FIRST_PARTY, verb) { + Some(row) => { + let binary = path::find(row.binary).ok_or_else(|| { + Refusal::failed(format!( + "`{verb}` is answered by {}, which is not on PATH; install {}", + row.binary, row.binary + )) + })?; + let leading: Vec = row.argv.iter().map(OsString::from).collect(); + run::exec(&binary, leading.iter().chain(argv)) + } + None => { + let name = format!("didbot-{verb}"); + let binary = path::find(&name).ok_or_else(|| { + Refusal::usage(format!( + "unknown command `{verb}`; `didbot --list` shows what is installed" + )) + })?; + run::exec(&binary, argv) + } + } +} + +/// `--list`: this binary, then every `didbot-*` on `PATH` with what it says +/// to `--version`, then the first-party binaries that are not installed. +fn list() -> Result<(), Refusal> { + let own = std::env::current_exe() + .map_err(|error| Refusal::failed(format!("cannot tell where this binary is: {error}")))?; + let mut rows = vec![( + PROGRAM.to_owned(), + env!("CARGO_PKG_VERSION").to_owned(), + own.display().to_string(), + )]; + for (name, binary) in path::installed() { + let version = run::version_of(&binary) + .and_then(|line| line.split_whitespace().nth(1).map(str::to_owned)) + .unwrap_or_else(|| "?".to_owned()); + rows.push((name, version, binary.display().to_string())); + } + let name_width = rows.iter().map(|row| row.0.len()).max().unwrap_or(0); + let version_width = rows.iter().map(|row| row.1.len()).max().unwrap_or(0); + for (name, version, location) in &rows { + println!("{name: = FIRST_PARTY + .iter() + .map(|row| row.binary) + .filter(|binary| !rows.iter().any(|row| row.0 == *binary)) + .collect(); + missing.dedup(); + for binary in missing { + let verbs: Vec<&str> = FIRST_PARTY + .iter() + .filter(|row| row.binary == binary) + .map(|row| row.verb) + .collect(); + println!("{binary}: not installed ({})", verbs.join(", ")); + } + Ok(()) +} diff --git a/crates/didbot-dispatch/src/path.rs b/crates/didbot-dispatch/src/path.rs new file mode 100644 index 00000000..68ace33a --- /dev/null +++ b/crates/didbot-dispatch/src/path.rs @@ -0,0 +1,55 @@ +//! Finding binaries on `PATH`. + +use std::collections::BTreeMap; +use std::path::PathBuf; + +/// The prefix every verb binary carries. +const PREFIX: &str = "didbot-"; + +/// The first executable named `name` on `PATH`, if there is one. +pub fn find(name: &str) -> Option { + let path = std::env::var_os("PATH")?; + std::env::split_paths(&path) + .map(|dir| dir.join(name)) + .find(|candidate| executable(candidate)) +} + +/// Every `didbot-*` on `PATH`, by name, the first on `PATH` winning — the +/// same one [`find`] would run. +pub fn installed() -> BTreeMap { + let mut found = BTreeMap::new(); + let Some(path) = std::env::var_os("PATH") else { + return found; + }; + for dir in std::env::split_paths(&path) { + let Ok(entries) = std::fs::read_dir(&dir) else { + continue; + }; + for entry in entries.flatten() { + let name = entry.file_name().to_string_lossy().into_owned(); + if name.len() > PREFIX.len() && name.starts_with(PREFIX) && executable(&entry.path()) { + found.entry(name).or_insert_with(|| entry.path()); + } + } + } + found +} + +/// A regular file this process may run. +fn executable(candidate: &std::path::Path) -> bool { + let Ok(metadata) = std::fs::metadata(candidate) else { + return false; + }; + if !metadata.is_file() { + return false; + } + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + metadata.permissions().mode() & 0o111 != 0 + } + #[cfg(not(unix))] + { + true + } +} diff --git a/crates/didbot-dispatch/src/run.rs b/crates/didbot-dispatch/src/run.rs new file mode 100644 index 00000000..2beb713d --- /dev/null +++ b/crates/didbot-dispatch/src/run.rs @@ -0,0 +1,92 @@ +//! Running a verb's binary: the hand-off, and the version probe `--list` +//! makes. + +use std::ffi::OsStr; +use std::path::Path; +use std::process::{Command, Stdio}; +use std::time::{Duration, Instant}; + +use didbot_cli::Refusal; + +/// How long `--list` waits for one binary to answer `--version`. A verb +/// that starts doing work instead of answering is killed, so one stray +/// binary on `PATH` cannot hang the listing. +const VERSION_PATIENCE: Duration = Duration::from_secs(3); + +/// The command as a verb receives it: `binary argv…`, this process's +/// environment minus every credential variable, and nothing added. +fn command(binary: &Path, argv: I) -> Command +where + I: IntoIterator, + S: AsRef, +{ + let mut command = Command::new(binary); + command.args(argv); + for variable in didbot_cli::env::CREDENTIALS { + command.env_remove(variable); + } + command +} + +/// Replaces this process with `binary argv…`. +/// +/// Returns only when the binary could not be started. On unix the verb's +/// exit status is then the shell's directly; elsewhere this waits and +/// exits with the same status. +pub fn exec(binary: &Path, argv: I) -> Result<(), Refusal> +where + I: IntoIterator, + S: AsRef, +{ + let mut command = command(binary, argv); + let could_not_start = |error: std::io::Error| { + Refusal::failed(format!("could not run {}: {error}", binary.display())) + }; + #[cfg(unix)] + { + use std::os::unix::process::CommandExt; + Err(could_not_start(command.exec())) + } + #[cfg(not(unix))] + { + let status = command.status().map_err(could_not_start)?; + std::process::exit(status.code().unwrap_or(1)); + } +} + +/// What `binary --version` prints on its first line, if it answers with +/// exit 0 inside [`VERSION_PATIENCE`]. +pub fn version_of(binary: &Path) -> Option { + let mut child = command(binary, ["--version"]) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn() + .ok()?; + let started = Instant::now(); + let status = loop { + match child.try_wait() { + Ok(Some(status)) => break status, + Ok(None) if started.elapsed() < VERSION_PATIENCE => { + std::thread::sleep(Duration::from_millis(20)); + } + _ => { + let _ = child.kill(); + let _ = child.wait(); + return None; + } + } + }; + if !status.success() { + return None; + } + let mut stdout = child.stdout.take()?; + let mut output = String::new(); + std::io::Read::read_to_string(&mut stdout, &mut output).ok()?; + output + .lines() + .next() + .map(str::trim) + .filter(|line| !line.is_empty()) + .map(str::to_owned) +} diff --git a/crates/didbot-dispatch/src/verbs.rs b/crates/didbot-dispatch/src/verbs.rs new file mode 100644 index 00000000..074f5e10 --- /dev/null +++ b/crates/didbot-dispatch/src/verbs.rs @@ -0,0 +1,63 @@ +//! The verbs `didbot` knows by name. + +use didbot_cli::FirstPartyVerb; + +/// Every first-party verb, and the binary that answers it. +/// +/// `didbot-operator` takes the verb as its own subcommand; `didbot-oauth` +/// has subcommands of its own, so `oauth` is dropped and the rest is passed +/// as typed. +pub const FIRST_PARTY: &[FirstPartyVerb] = &[ + FirstPartyVerb { + verb: "operate", + binary: "didbot-operator", + argv: &["operate"], + about: "claim a server as yours, or check whether it can be claimed", + }, + FirstPartyVerb { + verb: "login", + binary: "didbot-operator", + argv: &["login"], + about: "sign in as a deployment's operator", + }, + FirstPartyVerb { + verb: "estop", + binary: "didbot-operator", + argv: &["estop"], + about: "read or set a deployment's emergency stop", + }, + FirstPartyVerb { + verb: "announce", + binary: "didbot-operator", + argv: &["announce"], + about: "ask the relay to crawl a deployment", + }, + FirstPartyVerb { + verb: "oauth", + binary: "didbot-oauth", + argv: &[], + about: "answer sign-ins as an agent: pending, show, approve, decline", + }, +]; + +/// The verb table as `--help` shows it. +pub fn help_table() -> String { + let width = FIRST_PARTY + .iter() + .map(|row| row.verb.len()) + .max() + .unwrap_or(0); + let mut out = String::from("Verbs:\n"); + for row in FIRST_PARTY { + out.push_str(&format!( + " {: didbot- from PATH, with the rest of the command line\n\ + \n\ + `didbot help ` is `didbot --help`.", + ); + out +} diff --git a/crates/didbot-dispatch/tests/dispatch.rs b/crates/didbot-dispatch/tests/dispatch.rs new file mode 100644 index 00000000..dc12d85b --- /dev/null +++ b/crates/didbot-dispatch/tests/dispatch.rs @@ -0,0 +1,284 @@ +//! The dispatcher's contract, held against stand-in verb binaries on a +//! `PATH` of the test's making: what a verb receives, what it does not +//! receive, and what a person is told when nothing answers. + +#![cfg(unix)] + +use std::path::{Path, PathBuf}; +use std::process::{Command, Output}; + +/// A directory on `PATH` holding shell scripts that stand in for verb +/// binaries. Each prints its arguments and every `DIDBOT_` variable it +/// sees, and answers `--version` with a version of its own. +struct Fixture { + dir: PathBuf, +} + +impl Fixture { + fn new(name: &str) -> Self { + let dir = + std::env::temp_dir().join(format!("didbot-dispatch-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).expect("a fixture directory"); + Self { dir } + } + + fn script(&self, binary: &str, version: &str) -> &Self { + use std::os::unix::fs::PermissionsExt; + let body = format!( + "#!/bin/sh\n\ + if [ \"$1\" = \"--version\" ]; then echo \"{binary} {version}\"; exit 0; fi\n\ + echo \"argv: $*\"\n\ + env | grep '^DIDBOT_' | sort\n" + ); + let path = self.dir.join(binary); + std::fs::write(&path, body).expect("the script writes"); + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).expect("chmod"); + self + } + + /// One that refuses `--version` the way a binary with no such flag + /// would. + fn mute(&self, binary: &str) -> &Self { + use std::os::unix::fs::PermissionsExt; + let path = self.dir.join(binary); + std::fs::write(&path, "#!/bin/sh\necho \"no such flag: $1\" >&2\nexit 2\n") + .expect("the script writes"); + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).expect("chmod"); + self + } + + fn didbot(&self, args: &[&str]) -> Output { + Command::new(env!("CARGO_BIN_EXE_didbot")) + .args(args) + .env("PATH", format!("{}:/usr/bin:/bin", self.dir.display())) + .env("DIDBOT_AGENT_TOKEN", "a-secret") + .env("DIDBOT_AGENT_TOKEN_FILE", "/run/secrets/token") + .env("DIDBOT_PDS", "pds.example") + .output() + .expect("run didbot") + } +} + +impl Drop for Fixture { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.dir); + } +} + +fn stdout(output: &Output) -> String { + String::from_utf8_lossy(&output.stdout).into_owned() +} + +fn stderr(output: &Output) -> String { + String::from_utf8_lossy(&output.stderr).into_owned() +} + +/// The whole reason the dispatcher exists as a separate process: a verb +/// gets the command line and its caller's environment minus every +/// credential variable, and nothing is added. +#[test] +fn a_verb_receives_argv_and_no_credential() { + let fixture = Fixture::new("scrub"); + fixture.script("didbot-operator", "1.0.0"); + + let output = fixture.didbot(&["estop", "--server", "pds.example", "--pause"]); + + assert!(output.status.success(), "{}", stderr(&output)); + let seen = stdout(&output); + assert!( + seen.starts_with("argv: estop --server pds.example --pause\n"), + "the verb did not get the command line as typed:\n{seen}" + ); + assert!( + !seen.contains("DIDBOT_AGENT_TOKEN"), + "a credential variable reached the verb:\n{seen}" + ); + assert!( + seen.contains("DIDBOT_PDS=pds.example"), + "a variable that carries no credential was scrubbed too:\n{seen}" + ); +} + +/// `didbot-oauth` has subcommands of its own, so `oauth` is dropped rather +/// than forwarded. +#[test] +fn oauth_forwards_what_follows_the_verb() { + let fixture = Fixture::new("oauth"); + fixture.script("didbot-oauth", "1.0.0"); + + let output = fixture.didbot(&["oauth", "approve", "tok-1"]); + + assert!(output.status.success(), "{}", stderr(&output)); + assert!( + stdout(&output).starts_with("argv: approve tok-1\n"), + "{}", + stdout(&output) + ); +} + +/// An unknown word is `didbot-` on `PATH`, scrubbed the same way. +#[test] +fn another_word_runs_the_binary_of_that_name() { + let fixture = Fixture::new("external"); + fixture.script("didbot-echo", "0.0.1"); + + let output = fixture.didbot(&["echo", "one", "--two"]); + + assert!(output.status.success(), "{}", stderr(&output)); + let seen = stdout(&output); + assert!(seen.starts_with("argv: one --two\n"), "{seen}"); + assert!( + !seen.contains("DIDBOT_AGENT_TOKEN"), + "a credential variable reached an external verb:\n{seen}" + ); +} + +/// The first-party verbs are known even when their code is absent: the +/// answer names the binary to install, and is a failure rather than a usage +/// error because the command line was fine. +#[test] +fn a_first_party_verb_without_its_binary_says_what_to_install() { + let fixture = Fixture::new("missing"); + + for verb in ["operate", "login", "estop", "announce"] { + let output = fixture.didbot(&[verb, "--server", "pds.example"]); + assert_eq!(output.status.code(), Some(1), "{verb}: {}", stderr(&output)); + assert!( + stderr(&output).contains("install didbot-operator"), + "{verb}: {}", + stderr(&output) + ); + } + let output = fixture.didbot(&["oauth", "pending"]); + assert_eq!(output.status.code(), Some(1)); + assert!(stderr(&output).contains("install didbot-oauth")); +} + +#[test] +fn an_unknown_word_with_no_binary_is_a_usage_error() { + let fixture = Fixture::new("unknown"); + + let output = fixture.didbot(&["frobnicate"]); + + assert_eq!(output.status.code(), Some(2), "{}", stderr(&output)); + let said = stderr(&output); + assert!(said.contains("unknown command `frobnicate`"), "{said}"); + assert!(said.contains("didbot --list"), "{said}"); +} + +/// One line per binary — name, version, path — and the version is whatever +/// the binary itself says, so two binaries at different versions show it. +#[test] +fn list_shows_name_version_and_path() { + let fixture = Fixture::new("list"); + fixture + .script("didbot-operator", "7.7.7") + .script("didbot-oauth", "5.5.5") + .mute("didbot-swarm"); + + let output = fixture.didbot(&["--list"]); + + assert!(output.status.success(), "{}", stderr(&output)); + let listed = stdout(&output); + let rows: Vec> = listed + .lines() + .map(|line| line.split_whitespace().collect()) + .collect(); + assert_eq!( + rows[0][..2], + ["didbot", env!("CARGO_PKG_VERSION")], + "the dispatcher lists itself first:\n{listed}" + ); + let row = |name: &str| { + rows.iter() + .find(|row| row[0] == name) + .unwrap_or_else(|| panic!("{name} is not listed:\n{listed}")) + .clone() + }; + let in_fixture = |path: &str| Path::new(path).starts_with(&fixture.dir); + assert_eq!(row("didbot-operator")[1], "7.7.7"); + assert!(in_fixture(row("didbot-operator")[2])); + assert_eq!(row("didbot-oauth")[1], "5.5.5"); + assert_eq!( + row("didbot-swarm")[1], + "?", + "a binary that does not answer --version is listed, not dropped:\n{listed}" + ); + assert!( + !listed.contains("not installed"), + "every first-party binary is present:\n{listed}" + ); +} + +#[test] +fn list_names_the_first_party_binaries_that_are_absent() { + let fixture = Fixture::new("list-absent"); + + let output = fixture.didbot(&["--list"]); + + assert!(output.status.success(), "{}", stderr(&output)); + let listed = stdout(&output); + assert!( + listed.contains("didbot-operator: not installed (operate, login, estop, announce)"), + "{listed}" + ); + assert!( + listed.contains("didbot-oauth: not installed (oauth)"), + "{listed}" + ); +} + +/// `didbot help ` reaches the verb's own `--help`, through the same +/// table. +#[test] +fn help_verb_asks_the_binary() { + let fixture = Fixture::new("help"); + fixture + .script("didbot-operator", "1.0.0") + .script("didbot-echo", "1.0.0"); + + let output = fixture.didbot(&["help", "estop"]); + assert!( + stdout(&output).starts_with("argv: estop --help\n"), + "{}", + stdout(&output) + ); + + let output = fixture.didbot(&["help", "echo"]); + assert!( + stdout(&output).starts_with("argv: --help\n"), + "{}", + stdout(&output) + ); +} + +#[test] +fn help_and_version_answer_on_stdout() { + let fixture = Fixture::new("own-help"); + + let output = fixture.didbot(&["--help"]); + assert!(output.status.success()); + let help = stdout(&output); + for verb in ["operate", "login", "estop", "announce", "oauth", "--list"] { + assert!( + help.contains(verb), + "--help does not mention {verb}:\n{help}" + ); + } + assert!(stderr(&output).is_empty()); + + let output = fixture.didbot(&["--version"]); + assert!(output.status.success()); + assert_eq!( + stdout(&output).trim(), + format!("didbot {}", env!("CARGO_PKG_VERSION")) + ); + + let output = fixture.didbot(&[]); + assert_eq!( + output.status.code(), + Some(2), + "an empty command line is a usage error" + ); +} -- 2.51.2