diff --git a/server/src/lib.rs b/server/src/lib.rs index 1b57ddb..608d72e 100644 --- a/server/src/lib.rs +++ b/server/src/lib.rs @@ -32,3 +32,4 @@ pub mod enroll; pub mod format; pub mod netif; pub mod private; +pub mod xdg; diff --git a/server/src/xdg.rs b/server/src/xdg.rs new file mode 100644 index 0000000..5d08e19 --- /dev/null +++ b/server/src/xdg.rs @@ -0,0 +1,94 @@ +//! Where each project keeps the state that must not live in its repo. +//! +//! Both servers hold the same three things outside their checkout -- the +//! config with its token hashes, the CA's private key, and whatever state +//! the product itself keeps -- and both resolve the location the same way. +//! That is not a coincidence of style: it is forced by the arrangement the +//! two projects share. +//! +//! The repo is a virtiofs mount shared between a machine and a VM at +//! *different* absolute paths, so a config inside it would record paths +//! that resolve on only one side -- which is exactly how dev-updater came +//! to show "not built" for every project on one of them. And the mount is +//! writable by the untrusted side, so a CA private key inside it would let +//! that side mint a leaf the pinned app trusts, which voids the pinning +//! the rest of this crate exists to provide. +//! +//! Per machine, therefore, and under the XDG directories rather than a +//! path of our own choosing, so that a person's existing backup and +//! sync rules already cover it. + +use std::ffi::OsString; +use std::path::PathBuf; + +/// `$XDG_CONFIG_HOME/`, or `~/.config/`. +/// +/// Holds `config.ron` and `certs/`. `product` is a parameter rather than +/// a constant because the whole point is that two products keep their +/// state apart while resolving it identically. +pub fn config_home(product: &str) -> PathBuf { + xdg_dir(std::env::var_os("XDG_CONFIG_HOME"), ".config", product) +} + +/// `$XDG_DATA_HOME/`, or `~/.local/share/`. +/// +/// Holds whatever the product accumulates rather than is configured with +/// -- ai-app's session transcripts and attachments, dev-updater's builds. +pub fn data_home(product: &str) -> PathBuf { + xdg_dir(std::env::var_os("XDG_DATA_HOME"), ".local/share", product) +} + +/// The resolution both of the above use, with the environment handed in. +/// +/// Split out so the rules can be tested without setting process-wide +/// environment variables, which two tests running in parallel cannot do +/// without racing each other. +/// +/// A relative value is ignored rather than resolved, per the XDG spec: a +/// server started from a different working directory would otherwise look +/// for its token hashes somewhere new and generate a fresh enrollment, +/// silently locking out the phone that was already enrolled. +fn xdg_dir(base: Option, fallback: &str, product: &str) -> PathBuf { + base.map(PathBuf::from) + .filter(|path| path.is_absolute()) + .unwrap_or_else(|| { + std::env::home_dir() + .unwrap_or_else(|| PathBuf::from(".")) + .join(fallback) + }) + .join(product) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn an_absolute_setting_is_used_and_namespaced_by_product() { + assert_eq!( + xdg_dir(Some("/somewhere".into()), ".config", "ai-app"), + PathBuf::from("/somewhere/ai-app"), + ); + assert_eq!( + xdg_dir(Some("/somewhere".into()), ".config", "dev-updater"), + PathBuf::from("/somewhere/dev-updater"), + ); + } + + #[test] + fn an_unset_value_falls_back_under_home() { + let dir = xdg_dir(None, ".local/share", "ai-app"); + assert!(dir.ends_with("ai-app")); + assert!(dir.parent().expect("parent").ends_with("share")); + } + + /// A relative setting lands on the same path as no setting at all, + /// rather than on something that moves with the working directory. + #[test] + fn a_relative_setting_is_ignored_rather_than_resolved() { + assert_eq!( + xdg_dir(Some("relative/path".into()), ".config", "ai-app"), + xdg_dir(None, ".config", "ai-app"), + ); + } +}