//! 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 ACCOUNT_TOKEN: &str = "DIDBOT_ACCOUNT_TOKEN"; /// A file holding [`ACCOUNT_TOKEN`]'s value. pub const ACCOUNT_TOKEN_FILE: &str = "DIDBOT_ACCOUNT_TOKEN_FILE"; /// Every variable the dispatcher scrubs. pub const CREDENTIALS: &[&str] = &[ACCOUNT_TOKEN, ACCOUNT_TOKEN_FILE]; /// The server's origin, such as `https://pds.example`, for a command /// that names its server through the environment rather than /// `--server`. Not a credential, so the dispatcher passes it through. pub const PDS: &str = "DIDBOT_PDS"; /// The directory a host keeps its keys and account tokens in. A host /// that leaves it unset gets a default under `$XDG_STATE_HOME`. pub const STATE: &str = "DIDBOT_STATE"; /// The origin a server is reached at, from either spelling a person /// uses: a URL as given, and `https://` before a hostname. Every server /// this project runs terminates TLS, on a developer's machine as much /// as anywhere else, so there is one scheme. `--server` and [`PDS`] /// read the same way everywhere. /// /// A hostname with no port means 443, unless /// [`didbot_identity::did::RESOLVE_PORTS`] says that zone answers /// somewhere else — which is how several local servers run at once on /// one machine. #[must_use] pub fn server_origin(server: &str) -> String { let server = server.trim().trim_end_matches('/'); if server.contains("://") { return server.to_owned(); } if server != didbot_identity::did::host_without_port(server) { return format!("https://{server}"); } match didbot_identity::did::resolve_port(server) { Some(port) => format!("https://{server}:{port}"), None => format!("https://{server}"), } } #[cfg(test)] mod tests { use super::server_origin; /// A hostname, a loopback hostname with a port, and a URL each /// reach the same server whichever a person typed. #[test] fn a_server_is_named_by_hostname_or_by_url() { assert_eq!(server_origin("pds.example"), "https://pds.example"); assert_eq!( server_origin("nc.localhost:3413"), "https://nc.localhost:3413" ); assert_eq!( server_origin("https://nc.localhost:3413/"), "https://nc.localhost:3413" ); assert_eq!(server_origin("https://pds.example"), "https://pds.example"); } } } /// Where an account token lands on the machine that will be the account. /// /// `didbot register` and `didbot operate` both end with a session for the /// account they created, and `didbot-oauth` reads one back through /// [`env::ACCOUNT_TOKEN_FILE`]. One file per account, holding the token and /// nothing else, so the path is the whole hand-off. /// /// Behind the `tokens` feature, which the crates that store one turn on. /// The `didbot` dispatcher links this crate for the flag grammar and the /// verb table, and a router that can reach a credential writer is a reach /// nothing asks it for. #[cfg(feature = "tokens")] pub mod tokens { use std::io; use std::path::{Path, PathBuf}; /// The directory under the state base that a host's daemon owns. pub const SERVICE: &str = "agentd"; /// The state directory: [`super::env::STATE`] when set, else /// `$XDG_STATE_HOME/didbot/agentd`, else `~/.local/state/didbot/agentd`, /// and the current directory for a process with none of them. #[must_use] pub fn state_dir() -> PathBuf { std::env::var_os(super::env::STATE) .map(PathBuf::from) .filter(|path| !path.as_os_str().is_empty()) .unwrap_or_else(default_state_dir) } /// The state directory a process with no [`super::env::STATE`] uses. #[must_use] pub fn default_state_dir() -> PathBuf { std::env::var_os("XDG_STATE_HOME") .map(PathBuf::from) .filter(|path| !path.as_os_str().is_empty()) .or_else(|| { std::env::var_os("HOME") .map(PathBuf::from) .filter(|path| !path.as_os_str().is_empty()) .map(|home| home.join(".local/state")) }) .unwrap_or_else(|| PathBuf::from(".")) .join("didbot") .join(SERVICE) } /// The file the token for the account named `name` is kept in, under /// `state`. #[must_use] pub fn account_token_file(state: &Path, name: &str) -> PathBuf { state.join("accounts").join(format!("{name}.token")) } /// Writes `token` for `name` under `state`, readable by this user alone, /// and returns the file's path. pub fn store_account_token(state: &Path, name: &str, token: &str) -> io::Result { let path = account_token_file(state, name); let dir = path.parent().expect("the token file has a directory"); std::fs::create_dir_all(dir)?; #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700))?; } let mut body = token.to_owned(); body.push('\n'); std::fs::write(&path, body)?; #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600))?; } Ok(path) } #[cfg(test)] mod tests { use super::*; /// The file is what `DIDBOT_ACCOUNT_TOKEN_FILE` reads: the token, a /// newline, nothing else, and nobody else's to read. #[cfg(unix)] #[test] fn a_stored_token_is_the_file_didbot_oauth_reads() { use std::os::unix::fs::PermissionsExt; let state = std::env::temp_dir().join(format!("didbot-cli-tokens-{}", std::process::id())); let _ = std::fs::remove_dir_all(&state); let path = store_account_token(&state, "kestrel.pds.example", "tok-1").unwrap(); assert_eq!(path, state.join("accounts/kestrel.pds.example.token")); assert_eq!(std::fs::read_to_string(&path).unwrap(), "tok-1\n"); let mode = |p: &Path| std::fs::metadata(p).unwrap().permissions().mode() & 0o777; assert_eq!(mode(&path), 0o600); assert_eq!(mode(path.parent().unwrap()), 0o700); std::fs::remove_dir_all(&state).ok(); } } } /// ` `, 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 with the crate /// that ships it, 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, /// The crate `cargo install` takes to get the binary. pub package: &'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) } }