Something went wrong. Try again.
Identities for entities did.bot
agent llm did
Something went wrong. Try again.
13 kB · 355 lines
Rust
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356//! What every didbot command shares, so that verbs shipped as separate//! binaries read as one tool.//!//! `didbot <verb> …` is a dispatcher: it finds `didbot-<binary>` 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 <verb>` 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<PathBuf> { 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(); } }}
/// `<binary> <version>`, 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<Exit> 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, `<program>: <message>`, 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: clap::Parser>() -> T { match T::try_parse() { Ok(parsed) => parsed, Err(error) => error.exit(), }}
/// `--server <HOSTNAME>`: 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<String>,}
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 <HOSTNAME> 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<T: Serialize>(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 <verb> …` runs `<binary> <argv…> …`: 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) }}