//! The directory atgc keeps its own state in, and how it writes there. //! //! `$XDG_CONFIG_HOME/atgc`, or `~/.config/atgc` when that is unset: the //! account registry, jacquard's session store and both logs. [`config_dir`] //! is the one place that path is spelled, [`write_atomic`] is the one way a //! file in it is replaced, and the directory's own mode is decided here //! rather than left to whatever umask launched the process. //! //! These lived in `auth.rs` because sessions.json needed them first. Nothing //! about either is about OAuth: the log rotation, the account registry and //! the lock all reach for them too, and reaching for them through the OAuth //! module was the only thing tying those callers to it. use anyhow::{Context, Result}; use std::ffi::OsStr; use std::path::{Path, PathBuf}; /// How private the directory has to be, and what a repair narrows it back to. /// /// Owner-only. Every file atgc puts in here is already 0600, and a directory /// looser than its contents leaks the rest of the picture: which accounts /// exist, when each was last touched, how far each log has grown. The XDG /// base directory specification asks for this same mode on a directory an /// implementation creates for itself. const DIR_MODE: u32 = 0o700; /// The directory whose presence means "an account is logged in here", for /// [`migration_note`]. One per identity lives under it, so its existence is /// the same signal a single `sessions.json` used to be. const STORE: &str = "dids"; /// Which directory, and what choosing it turned up worth saying. #[derive(Debug)] struct Choice { dir: PathBuf, /// `$HOME/.config/atgc`, when `XDG_CONFIG_HOME` moved the directory off /// it. `None` when that *is* the directory, or when there is no `$HOME` /// for it to have moved off. displaced: Option, /// Why an `XDG_CONFIG_HOME` that was set did not decide the answer. rejected: Option, } /// Where the state lives, from the two variables that decide it. /// /// Pure, and takes both values as arguments, because `HOME` cannot be /// redirected inside a test in this crate — `set_var` is unsafe and the crate /// forbids unsafe — so the part that reads the environment is the one line in /// [`config_dir`] that no test covers (see [`crate::docs::testing`]). /// /// `XDG_CONFIG_HOME` decides it when it holds an absolute path. Unset or /// empty falls back to `$HOME/.config` as the specification says, and so does /// a **relative** value: the spec tells an implementation that meets one to /// "consider the path invalid and ignore it", and joining it would put /// credentials wherever the process happened to be standing. Ignoring it is /// what the spec asks; ignoring it *silently* is not what somebody who set /// the variable wants, so the reason comes back to be said out loud. /// /// The `dirs` crate is deliberately not used for this. Its `config_dir()` /// answers `~/Library/Application Support` on macOS — a defensible platform /// answer, and one that would move every existing user's session store out /// from under them, which is a worse bug than the one being fixed. fn choose(xdg: Option<&OsStr>, home: Option<&OsStr>) -> Result { let under_home = || { home.map(|h| Path::new(h).join(".config/atgc")) .context("HOME is not set, so there is nowhere to keep atgc's state") }; match xdg.filter(|value| !value.is_empty()).map(Path::new) { Some(root) if root.is_absolute() => { let dir = root.join("atgc"); Ok(Choice { displaced: under_home().ok().filter(|old| *old != dir), dir, rejected: None, }) } Some(root) => { let dir = under_home()?; let rejected = Some(format!( "XDG_CONFIG_HOME is {}, which is a relative path; the XDG base directory \ specification says to ignore one, so atgc is using {}", root.display(), dir.display() )); Ok(Choice { dir, displaced: None, rejected, }) } None => Ok(Choice { dir: under_home()?, displaced: None, rejected: None, }), } } /// The directory atgc keeps its state in, created 0700 if it is not there and /// narrowed to 0700 if it is there and wider. /// /// `$XDG_CONFIG_HOME/atgc` when that variable holds an absolute path, /// `$HOME/.config/atgc` otherwise; [`choose`] has the rules and why they are /// not the `dirs` crate's. /// /// # Why it repairs a directory it did not create /// /// This used to be `create_dir_all`, which takes the umask, so every install /// made before this line changed has the directory at whatever umask the /// shell had. On the machine it was written on that is 0775, with every file /// inside it 0600 — nothing exposed, but only because `~/.config` happens to /// be 0700 and the primary group happens to have one member. The mode was the /// parent's luck rather than a choice atgc had made. /// /// Creating it right fixes that for nobody, since `create_dir_all` succeeds /// on a directory that already exists without touching its mode, and every /// existing install is in exactly that state. So an existing directory with /// any group or other bit set is chmodded back to 0700 — and **says so**, /// once, on stderr. Changing permissions under somebody's home directory /// without mentioning it is the kind of helpfulness that is indistinguishable /// from an intruder in hindsight; one line naming the directory, the mode it /// had and the mode it has now puts the change in the scrollback of whichever /// command triggered it, and [`repaired_from`] keeps it for `doctor`'s row. /// /// A *narrower* mode is left alone. 0500 will break atgc's own writes, but it /// is not a leak, and widening somebody's directory is not this function's /// business under any reading. /// Where the configuration directory *would* be, without creating it. /// /// For messages. Naming a path is not a reason to make one, and an error /// formatter that creates a directory as a side effect of describing itself /// leaves state behind on exactly the runs that went wrong. pub(crate) fn config_dir_path() -> Result { Ok(choose( std::env::var_os("XDG_CONFIG_HOME").as_deref(), std::env::var_os("HOME").as_deref(), )? .dir) } pub fn config_dir() -> Result { let choice = choose( std::env::var_os("XDG_CONFIG_HOME").as_deref(), std::env::var_os("HOME").as_deref(), )?; create_private(&choice.dir) .with_context(|| format!("failed to create {}", choice.dir.display()))?; // Once per process. The inspection is a `stat`, but everything it can // produce is a one-time event, and this function is called by every // module that keeps a file here. SETTLED.get_or_init(|| settle(&choice)); Ok(choice.dir) } /// Everything atgc keeps for one identity: `/dids///`. /// /// One directory per DID, holding its ssh keys, its session and whatever /// per-identity state comes later, so that provisioning an identity is /// creating a directory and disposing of one is removing it. This replaces /// `agents/keys//`, which was the same idea for keys alone. /// /// The shape is deliberate in three ways. /// /// **The method is a directory, not a prefix.** `dids/web/alice.example` /// rather than `dids/did-web-alice.example`. Inferring the method from the /// name instead — a period means `did:web` — very nearly works, and fails on /// `did:web:localhost`, which [`crate::lexicon::identity`] accepts on purpose /// for development. Stating it costs one directory level and depends on /// nothing. /// /// **The leaf is the method-specific id, not the whole DID.** A path segment /// is capped at 255 bytes and a domain may be 253, so `did:web:` in front of /// one is 261 and does not fit. Without the prefix it does, with room, and /// no truncation or hashing is needed to make it. /// /// **Nothing is transformed.** A `did:plc` id is 24 characters of `[a-z2-7]` /// and a `did:web` host is a domain plus at most a percent-encoded port, so /// both are already unique within their method and already legal filenames. /// Case cannot collide the way it could before [`crate::clients::atproto::did`] /// began checking that a document claims the DID it was fetched for: two /// spellings of one host resolve to one document declaring one `id`, so only /// one of them can hold a session and reach a path here. /// /// The `did` file inside records the whole DID, so a directory says what it /// is without anyone reassembling it from the path. pub fn identity_dir(did: &str) -> Result { identity_dir_in(&config_dir()?, did) } /// [`identity_dir`], with the configuration directory passed in. /// /// Split for the reason [`crate::config::lock::lock_path_in`] is: `HOME` /// cannot be redirected inside a test in this crate, so the half that does not /// read the environment takes its input as an argument and is checkable. pub fn identity_dir_in(config_dir: &Path, did: &str) -> Result { let dir = identity_path_in(config_dir, did)?; create_private(&dir).with_context(|| format!("failed to create {}", dir.display()))?; // Through the same function the session writer uses, so there is one // implementation of "leave this directory saying what it is" rather than // two that have to be broken together before anything notices. label_identity_dir(&dir, did); Ok(dir) } /// Where [`identity_dir`] would put an identity, without creating anything. /// /// The read half. A reader that called the creating one would make a /// directory as a side effect of looking, and an empty directory for an /// account nobody has logged into is indistinguishable from one whose files /// were lost — so the two are separate functions rather than a flag. pub fn identity_path_in(config_dir: &Path, did: &str) -> Result { let (method, id) = split_did(did)?; Ok(config_dir.join("dids").join(method).join(id)) } /// The method and identifier halves of a DID, refusing anything that would /// not be one path segment each. /// /// The colon check is the guard this scheme rests on. Every DID atgc can /// resolve has an identifier free of `:` — `validate_web` refuses path-based /// `did:web:example.com:user:alice`, and a `did:plc` identifier is base32 — /// so nothing reachable today trips it. A method that allowed path segments /// would put a separator inside what has to be one directory name, and that /// must be a refusal here rather than a surprise two levels down. fn split_did(did: &str) -> Result<(&str, &str)> { let rest = did .strip_prefix("did:") .with_context(|| format!("{did:?} is not a DID, so it has no directory here"))?; let (method, id) = rest .split_once(':') .with_context(|| format!("{did:?} names no method, so it has no directory here"))?; for (what, part) in [("method", method), ("identifier", id)] { if part.is_empty() || part.contains(':') || part.contains('/') || part.starts_with('.') { anyhow::bail!( "{did:?} cannot be a directory here: its {what} is empty, or holds a \ separator, or begins with a dot" ); } // A path segment, not a path. `NAME_MAX` is 255 on Linux and macOS. if part.len() > 255 { anyhow::bail!( "{did:?} cannot be a directory here: its {what} is {} bytes, over the \ 255 a filename may be", part.len() ); } } Ok((method, id)) } /// What this process found the directory in, filled in by the first /// [`config_dir`] call to get there. static SETTLED: std::sync::OnceLock = std::sync::OnceLock::new(); #[derive(Debug, Clone, Copy)] struct Settled { /// The mode the directory already had, when it was wider than /// [`DIR_MODE`] and this process narrowed it. `None` when it was already /// private, when the chmod was refused, or off unix. repaired_from: Option, } /// The mode this process found the config directory at and narrowed, if it /// narrowed one. /// /// For `doctor`'s permissions row, which would otherwise report a clean 0700 /// on precisely the installs the repair had just rescued: resolving this /// directory is what narrows it, and every command resolves it before any /// check can look, so the mode on disk by the time one does is always /// [`DIR_MODE`]. What was found is the finding. pub(crate) fn repaired_from() -> Option { SETTLED.get().and_then(|settled| settled.repaired_from) } /// Create the directory, and any missing parent of it, at [`DIR_MODE`]. /// /// `DirBuilder::mode` rather than `create_dir_all`, which takes the umask — /// the difference between a mode atgc chose and a mode the shell that /// launched it chose. The mode applies to the intermediate directories too, /// which is what the specification asks for: if the destination directory /// does not exist, "an attempt should be made to create it with permission /// 0700". #[cfg(unix)] pub(crate) fn create_private_dir(dir: &Path) -> std::io::Result<()> { create_private(dir) } /// Widen the read-modify-write window, for a test that needs the race to /// happen rather than to be possible. /// /// **Not compiled into a release build**, and inert without /// `ATGC_TEST_HOLD_MS`, so the only way to reach it is a debug binary that was /// asked for it. It exists because the failure it makes visible — one /// process's write landing on a map another had already changed — is a lost /// update, and a lost update needs a particular interleaving. Waiting for /// that interleaving to occur on its own is how a concurrency test becomes a /// flaky one; a test that cannot make the bad thing happen is not evidence /// the bad thing cannot happen. /// /// Here rather than around the lock: the window that matters is between the /// read and the write, which is exactly what the lock has to span. Shared by /// every read-modify-write that a second process can race -- the session /// store's, and the settings file's. #[cfg(debug_assertions)] pub(crate) fn hold_for_test() { if let Ok(ms) = std::env::var("ATGC_TEST_HOLD_MS") && let Ok(ms) = ms.parse::() { std::thread::sleep(std::time::Duration::from_millis(ms)); } } #[cfg(not(debug_assertions))] pub(crate) fn hold_for_test() {} /// Make an identity's directory and leave it saying which identity it is. /// /// Every path that writes something into `dids///` goes through /// here — a session, an account entry, a lock — so a directory is never left /// unlabelled. Nothing *reads* the marker to enumerate: the DID is rebuilt /// from the path, which is why the scheme hashes nothing. The file is for /// whoever opens the directory expecting it to say what it is. pub(crate) fn make_identity_dir(did: &str) -> Result { identity_dir(did) } /// Leave an identity's directory saying which identity it is. /// /// Takes the directory rather than resolving one, so a caller that was handed /// a path writes beside *that* path. Best-effort: nothing reads the marker to /// find an account — the DID is rebuilt from the path — so failing to write /// it must not fail the write it accompanies. pub(crate) fn label_identity_dir(dir: &Path, did: &str) { let marker = dir.join("did"); if std::fs::read_to_string(&marker).ok().as_deref() == Some(did) { return; } if let Err(e) = write_atomic(&marker, did.as_bytes(), FILE_MODE) { crate::logging::debug::log(format!("could not label {}: {e}", dir.display())); } } #[cfg(unix)] fn create_private(dir: &Path) -> std::io::Result<()> { use std::os::unix::fs::DirBuilderExt; std::fs::DirBuilder::new() .recursive(true) .mode(DIR_MODE) .create(dir) } #[cfg(not(unix))] fn create_private(dir: &Path) -> std::io::Result<()> { std::fs::DirBuilder::new().recursive(true).create(dir) } /// What narrowing an already-existing directory came to. #[derive(Debug)] enum Narrowed { /// It was already owner-only, or its mode could not be read at all. Nothing, /// It was this mode, and is now [`DIR_MODE`]. From(u32), /// It was this mode and the chmod was refused — a read-only filesystem, /// or a directory somebody else owns. Different advice entirely: atgc /// cannot fix this one and has to hand it back. Refused { from: u32, why: std::io::Error }, } /// Narrow the directory if any group or other bit is set on it. /// /// Split from the sentence it produces so the decision can be driven against /// a throwaway directory, which is the only part of this worth a test — see /// [`crate::docs::testing`] on why `$HOME` itself cannot be. #[cfg(unix)] fn narrow_if_wide(dir: &Path) -> Narrowed { use std::os::unix::fs::PermissionsExt; let Ok(metadata) = std::fs::metadata(dir) else { return Narrowed::Nothing; }; let from = metadata.permissions().mode() & 0o777; if from & 0o077 == 0 { return Narrowed::Nothing; } match std::fs::set_permissions(dir, std::fs::Permissions::from_mode(DIR_MODE)) { Ok(()) => Narrowed::From(from), Err(why) => Narrowed::Refused { from, why }, } } #[cfg(not(unix))] fn narrow_if_wide(_dir: &Path) -> Narrowed { Narrowed::Nothing } /// Everything worth saying about the directory, said once, and what /// [`repaired_from`] answers with afterwards. fn settle(choice: &Choice) -> Settled { if let Some(reason) = &choice.rejected { crate::term::say::warning!(Auth, "{reason}"); } if let Some(displaced) = &choice.displaced { migration_note(&choice.dir, displaced); } let dir = choice.dir.display(); let repaired_from = match narrow_if_wide(&choice.dir) { Narrowed::Nothing => None, Narrowed::From(from) => { crate::term::say::warning!( Auth, "{dir} was mode {from:04o}; narrowed it to {DIR_MODE:04o}\n\ it holds your OAuth tokens and the private key that signs with them, and an \ older atgc created it at the shell's umask rather than at a mode of its own", ); Some(from) } // Refused is not repaired: `doctor` will read the mode it still has // and report it as the finding, which is the right shape for // something atgc cannot fix on the user's behalf. Narrowed::Refused { from, why } => { crate::term::say::warning!( Auth, "{dir} is mode {from:04o} and atgc could not narrow it: {why}\n\ it holds your OAuth tokens and the private key that signs with them; \ run chmod {DIR_MODE:o} {dir}", ); None } }; Settled { repaired_from } } /// The mode a file in this directory must not be wider than. const FILE_MODE: u32 = 0o600; /// Narrow one file in the config directory if any group or other bit is set /// on it, and say so. /// /// The directory's own repair, applied to a file, and here rather than at its /// caller for the reason the directory repair is here: it is the same policy /// about the same directory, and every module keeping a file in it should /// reach the same routine. `sessions.json` was the one site, where /// `oauth::sessions::auth_store` chmodded it to 0600 unconditionally on every call. /// /// Three differences from what that did: /// /// - **Only when it is wide.** An unconditional chmod is a syscall on every /// invocation for a file that is almost always already right, and it /// silently *widens* a store somebody deliberately left at 0400. /// - **It says so.** Changing permissions under somebody's home directory /// without mentioning it is the kind of helpfulness that is /// indistinguishable from an intruder in hindsight, which is the argument /// [`config_dir`] already makes at length about the directory. /// - **A refusal is not a failure.** The old code returned `Err` and killed /// the command, so a session store on a read-only mount could not be /// *read*. A file that could not be narrowed is a confidentiality problem /// to report, not a reason to stop. /// /// Once per path per process: this runs from `auth_store`, which several /// commands call more than once, and one line about one file is the whole /// point. pub fn narrow_file_if_wide(path: &Path) { #[cfg(unix)] { use std::collections::HashSet; use std::os::unix::fs::PermissionsExt; use std::sync::Mutex; static SAID: Mutex>> = Mutex::new(None); let Ok(metadata) = std::fs::metadata(path) else { return; }; let from = metadata.permissions().mode() & 0o7777; if from & 0o077 == 0 { return; } { let mut said = SAID.lock().unwrap_or_else(|e| e.into_inner()); if !said.get_or_insert_default().insert(path.to_path_buf()) { return; } } let shown = path.display(); // The owner's own bits are kept: `0o600` would hand a deliberately // read-only store a write bit, which is a different decision than // the one being made here. let narrowed = from & !0o077; // Through an open handle rather than the path, so the file whose mode // is changed is the file that was inspected. A path-named `chmod` // decides its target twice, and the gap between the two is exactly // when this runs: the directory is wide -- that is the whole reason // this code is executing -- so something else may be able to put a // symlink where the store was between the check and the change, and // have atgc widen or narrow a file of its choosing. `File::open` then // `set_permissions` is `fchmod(2)` on the handle, which can only // affect the object already opened. let handle = match std::fs::File::open(path) { Ok(handle) => handle, Err(why) => { crate::term::say::warning!( Auth, "{shown} is mode {from:04o} and atgc could not open it to narrow it: {why}\n\ it holds your OAuth tokens and the private key that signs with them; \ run chmod {FILE_MODE:o} {shown}", ); return; } }; match handle.set_permissions(std::fs::Permissions::from_mode(narrowed)) { Ok(()) => crate::term::say::warning!( Auth, "{shown} was mode {from:04o}; narrowed it to {narrowed:04o}\n\ it holds your OAuth tokens and the private key that signs with them", ), Err(why) => crate::term::say::warning!( Auth, "{shown} is mode {from:04o} and atgc could not narrow it: {why}\n\ it holds your OAuth tokens and the private key that signs with them; \ run chmod {FILE_MODE:o} {shown}", ), } } #[cfg(not(unix))] let _ = path; } /// Say, once, when `XDG_CONFIG_HOME` has moved atgc off a directory that /// still holds an account. /// /// The failure this heads off: somebody exports `XDG_CONFIG_HOME` months /// after logging in, runs any atgc command, and is told no account is logged /// in. Nothing has been lost and nothing is broken, but the only visible fact /// is "logged out", and the obvious response — log in again — leaves two /// session stores on the machine, each holding live credentials with /// `rpc:sh.tangled.repo.delete` in their scopes. /// /// Only when the new directory holds no session store and the old one does, /// so it is silent for anybody who has already moved across and silent for a /// first install that had the variable set all along. fn migration_note(dir: &Path, displaced: &Path) { if dir.join(STORE).exists() || !displaced.join(STORE).exists() { return; } crate::term::say::note!( Auth, "XDG_CONFIG_HOME points atgc at {}, which holds no accounts yet\n\ the accounts you logged in before it was set are still in {}: move {STORE}/ and \ config.toml across, or unset the variable", dir.display(), displaced.display(), ); } /// Process-local disambiguator, folded into every temp name `write_atomic` /// picks. Wall-clock nanoseconds are enough entropy on their own, but only /// if the clock actually has nanosecond resolution — some sandboxes and VMs /// coarsen it to a millisecond or worse. The counter makes two writes from /// *this* process collide only if it wraps `u64`, independent of the clock. static ATOMIC_WRITE_COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); /// Write `contents` to `path` atomically and with `mode`, for the files under /// `config_dir()` that hold account state: an identity's `session.json` and /// `account.json`, and the settings beside them. /// /// The method is the standard one: stage the bytes in a sibling temp file /// in the same directory (so the final `rename` is same-filesystem and /// therefore atomic), `fsync` it, then rename it over `path`. A reader that /// opens `path` at any point during this sees either the complete old file /// or the complete new one — never a truncated or partially-written one, /// which is what a bare `fs::write` risks if the process dies to a crash, /// an OOM kill or a full disk between the open and the last write. /// /// `mode` is passed to the temp file's `open(2)` call directly, so it is in /// effect from the instant the file is created — before a single byte of /// `contents` reaches disk. That matters for `session.json`: it holds /// OAuth tokens and is deliberately 0600, and creating it at the platform /// default (typically 0644) and `chmod`ing afterward — which is what both /// call sites did before this function existed — leaves the token bytes /// briefly world-readable on a shared machine, in the window between the /// write and the `chmod`. Creating it at the right mode from the first /// syscall has no such window. /// /// Naming the temp file: there is no file locking anywhere in this codebase /// (see `read_store`'s doc comment on the unlocked read-modify-write pair), /// so two `atgc` invocations can call this for the same `path` at the same /// moment. A fixed temp name — `path` plus a constant suffix — would let /// those two invocations collide on *that* file instead: one process's /// partially-written temp file could become the rename source for the /// other's write, which is a new race this function exists to avoid, not /// introduce. The name below folds in the pid, wall-clock nanoseconds and /// the per-process counter above, and the temp file is opened with /// `create_new` (`O_EXCL`), so a collision cannot silently clobber another /// process's in-flight write — it fails loudly instead. /// /// This closes the truncation window. It does not touch the *separate* /// unlocked read-modify-write race on `sessions.json` that `read_store` and /// `write_store` describe: replacing a file atomically says nothing about /// whether the bytes being written reflect the latest state on disk, only /// that whatever is written arrives whole. pub(crate) fn write_atomic(path: &Path, contents: &[u8], mode: u32) -> Result<()> { use std::io::Write; let dir = path.parent().unwrap_or_else(|| Path::new(".")); let file_name = path .file_name() .and_then(|n| n.to_str()) .unwrap_or("atgc-state"); let nanos = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map(|d| d.as_nanos()) .unwrap_or(0); let n = ATOMIC_WRITE_COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed); let tmp_path = dir.join(format!( ".{file_name}.tmp.{}.{nanos}.{n}", std::process::id() )); let write_result = (|| -> std::io::Result<()> { let mut file = new_state_file(&tmp_path, mode)?; file.write_all(contents)?; file.sync_all() })(); if let Err(e) = write_result { // Best effort: the write already failed, and a failed cleanup here // is not a reason to hide the original error behind it. let _ = std::fs::remove_file(&tmp_path); return Err(e).with_context(|| format!("failed to write {}", tmp_path.display())); } std::fs::rename(&tmp_path, path).with_context(|| { format!( "failed to replace {} with the freshly-written {}", path.display(), tmp_path.display() ) })?; sync_dir(dir); sweep_abandoned_temps(dir); Ok(()) } /// Make the rename itself survive a crash, not just the bytes it renamed. /// /// `sync_all` on the temp file puts the *contents* on the disk. It says /// nothing about the directory entry that the rename then rewrote, and on a /// crash before that entry is written back the directory can still name the /// old file -- so a session store whose bytes were safely on disk reverts to /// the version before the write. For this store that is not a lost edit but a /// spent one: the refresh token in the older file has already been used, the /// authorization server answers `invalid_grant` for it, and that is classified /// as a dead grant, so the account is logged out. Durability of the rename is /// what stops a power loss from costing somebody every session on the machine. /// /// Best effort, deliberately. By the time this runs the rename has already /// happened and every reader sees the new file; failing the write here would /// report a failure that did not occur, and a caller acting on it -- a login /// retrying, a refresh giving up -- would do more harm than the missing /// durability. Platforms that refuse an `fsync` on a directory land here too. fn sync_dir(dir: &Path) { match std::fs::File::open(dir).and_then(|handle| handle.sync_all()) { Ok(()) => {} Err(e) => crate::logging::debug::log(format!( "could not flush the directory entry for {}: {e}", dir.display() )), } } /// Remove the staging files a killed process left in this directory. /// /// Here rather than on any one command, because here is the only place that /// is *certainly* visited: the sweep used to run on `auth login`, and an /// agent logs in once and then works for days, so its litter outlived /// everything that might have cleared it. A write to a directory is the /// moment somebody is already in it. /// /// An hour, so a temp belonging to a write happening right now in another /// process is never taken: those live for a `write_all` and a `sync_all`. /// Best-effort throughout — failing to clear somebody else's litter is not a /// reason to fail the write that just succeeded. fn sweep_abandoned_temps(dir: &Path) { const ABANDONED: std::time::Duration = std::time::Duration::from_secs(60 * 60); let Ok(entries) = std::fs::read_dir(dir) else { return; }; for entry in entries.flatten() { let name = entry.file_name(); let Some(name) = name.to_str() else { continue }; if !name.starts_with('.') || !name.contains(".tmp.") { continue; } let abandoned = entry .metadata() .and_then(|m| m.modified()) .map(|t| t.elapsed().map(|age| age > ABANDONED).unwrap_or(false)) .unwrap_or(false); if abandoned { let _ = std::fs::remove_file(entry.path()); } } } #[cfg(unix)] fn new_state_file(path: &Path, mode: u32) -> std::io::Result { use std::os::unix::fs::OpenOptionsExt; std::fs::OpenOptions::new() .write(true) .create_new(true) .mode(mode) .open(path) } #[cfg(not(unix))] fn new_state_file(path: &Path, _mode: u32) -> std::io::Result { std::fs::OpenOptions::new() .write(true) .create_new(true) .open(path) } #[cfg(test)] mod tests { use super::*; fn os(s: &str) -> &OsStr { OsStr::new(s) } /// A throwaway directory this module may chmod at will, named for the /// test so a leftover from a killed run says which one made it. It is /// deleted when the returned value drops, so hold it for the length of /// the test. fn temp_dir(label: &str) -> tempfile::TempDir { tempfile::Builder::new() .prefix(&format!("atgc-config-dir-{label}-")) // 0o700, because these stand in for ~/.config/atgc, which // production creates owner-only. `tempfile` defaults a // directory to 0o777 & ~umask, which would quietly make // every mode assertion below weaker than the real thing. .permissions( ::from_mode(0o700), ) .tempdir() .unwrap() } /// A store found world-readable is narrowed; one already private is not /// touched; owner bits are preserved either way. /// /// The old repair chmodded to exactly 0600 on every call, whatever it /// found. `narrow_file_if_wide` reads first and takes only the group and /// other bits away, so a store deliberately left read-only keeps its /// mode rather than quietly gaining a write bit. #[cfg(unix)] #[test] fn a_store_file_is_narrowed_only_when_it_is_wide() { use std::os::unix::fs::PermissionsExt; let dir = temp_dir("narrow-file"); let dir = dir.path(); let mode_of = |p: &Path| std::fs::metadata(p).unwrap().permissions().mode() & 0o7777; let plant = |name: &str, mode: u32| { let path = dir.join(name); std::fs::write(&path, b"{}").unwrap(); std::fs::set_permissions(&path, std::fs::Permissions::from_mode(mode)).unwrap(); path }; let wide = plant("wide.json", 0o644); narrow_file_if_wide(&wide); assert_eq!(mode_of(&wide), 0o600); // Already private: nothing to do, and nothing said. Asserted as a // mode rather than as an absence of output, but the mode is what // would change if the condition were dropped. let private = plant("private.json", 0o600); narrow_file_if_wide(&private); assert_eq!(mode_of(&private), 0o600); // Read-only for the owner and readable by the group: the group bit // goes, the missing owner write bit does not come back. let readonly = plant("readonly.json", 0o440); narrow_file_if_wide(&readonly); assert_eq!(mode_of(&readonly), 0o400); // A path that is not there at all is not an error: the store is // created lazily, so this runs before the file exists on a first // login. narrow_file_if_wide(&dir.join("absent.json")); std::fs::remove_dir_all(dir).ok(); } /// The rule, in the order it is applied: an absolute `XDG_CONFIG_HOME` /// decides, and `atgc` is appended to it rather than `.config/atgc` — /// the variable already *is* the config root. /// /// The displaced path comes back with it so that a user who set the /// variable after logging in can be told where their accounts went, /// rather than only that there are none here. #[test] fn an_absolute_xdg_config_home_decides_where_state_lives() { let chosen = choose(Some(os("/srv/conf")), Some(os("/home/someone"))).unwrap(); assert_eq!(chosen.dir, PathBuf::from("/srv/conf/atgc")); assert_eq!( chosen.displaced, Some(PathBuf::from("/home/someone/.config/atgc")) ); assert_eq!(chosen.rejected, None); // Pointed at the place it would have picked anyway, there is nothing // displaced and nothing to say about it. let same = choose(Some(os("/home/someone/.config")), Some(os("/home/someone"))).unwrap(); assert_eq!(same.dir, PathBuf::from("/home/someone/.config/atgc")); assert_eq!(same.displaced, None); // And with no `HOME` at all it still answers, which is the case a // daemon or a container gets. let no_home = choose(Some(os("/srv/conf")), None).unwrap(); assert_eq!(no_home.dir, PathBuf::from("/srv/conf/atgc")); assert_eq!(no_home.displaced, None); } /// Unset, empty, or relative all fall back to `$HOME/.config/atgc`. /// /// The relative case is the specification's: "if an implementation /// encounters a relative path it should consider the path invalid and /// ignore it". Joining it against the working directory would scatter /// session stores through whatever tree a command was run in. It is /// ignored, and — unlike the other two, which are the ordinary way to /// not use the variable — it is ignored out loud, because somebody who /// set it meant something by it. #[test] fn an_unusable_xdg_config_home_falls_back_to_home() { let home = Some(os("/home/someone")); let expected = PathBuf::from("/home/someone/.config/atgc"); for unset in [None, Some(os(""))] { let chosen = choose(unset, home).unwrap(); assert_eq!(chosen.dir, expected); assert_eq!(chosen.rejected, None, "{unset:?} is not worth a complaint"); } let relative = choose(Some(os("conf")), home).unwrap(); assert_eq!(relative.dir, expected); assert_eq!(relative.displaced, None, "nothing was displaced"); let said = relative.rejected.expect("a rejected relative path is said"); assert!(said.contains("conf"), "{said}"); assert!(said.contains("relative"), "{said}"); // It has to name where atgc went instead, or the complaint leaves the // reader knowing only that they were overruled. assert!(said.contains("/home/someone/.config/atgc"), "{said}"); } /// With neither variable there is no answer, and saying so is better than /// picking a directory in the filesystem root. #[test] fn without_home_or_an_absolute_xdg_there_is_no_directory() { assert!(choose(None, None).is_err()); assert!(choose(Some(os("")), None).is_err()); assert!(choose(Some(os("conf")), None).is_err()); } /// The bug this was changed for: `create_dir_all` takes the umask, so the /// directory holding OAuth tokens and their DPoP signing key came out /// 0775 on an ordinary Linux install while every file in it was 0600. /// Nothing leaked only because `~/.config` above it was 0700. /// /// Driven against a temp directory rather than `$HOME`, which cannot be /// redirected in this crate's tests (see [`crate::docs::testing`]). #[cfg(unix)] #[test] fn a_created_directory_is_private_whatever_the_umask_is() { use std::os::unix::fs::PermissionsExt; let root = temp_dir("create"); let dir = root.path().join("nested/atgc"); create_private(&dir).expect("create it"); let mode = |p: &Path| std::fs::metadata(p).unwrap().permissions().mode() & 0o777; assert_eq!(mode(&dir), 0o700, "the state directory is not owner-only"); // The intermediate directories too — an `XDG_CONFIG_HOME` pointing // somewhere unbuilt is the case, and the specification asks for 0700 // on what an implementation creates for itself. assert_eq!( mode(dir.parent().unwrap()), 0o700, "an intermediate is wide" ); // Creating over an existing directory succeeds and does not touch its // mode, which is exactly why the repair below has to exist. std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o775)).unwrap(); create_private(&dir).expect("create it again"); assert_eq!(mode(&dir), 0o775, "create_private narrowed on its own"); } /// The repair, which is the half that reaches an install that already /// exists: every user who has ever run atgc has the wide directory, and a /// fix that only applied at creation would reach none of them. /// /// A narrower mode is left where it is. 0500 breaks atgc's own writes, /// but it is somebody's choice and it is not a leak, and this function /// widening a directory would be a worse bug than the one it fixes. #[cfg(unix)] #[test] fn an_existing_directory_is_narrowed_but_never_widened() { use std::os::unix::fs::PermissionsExt; // The directory must not exist yet: `create_private` applies its // mode only to a directory it creates, and `tempfile` hands back one // that already exists at 0o777 & ~umask. Creating a child of the // throwaway root is what the pid-named helper used to give for free. let root = temp_dir("narrow"); let dir = &root.path().join("atgc"); create_private(dir).expect("create it"); let mode = || std::fs::metadata(dir).unwrap().permissions().mode() & 0o777; let set = |m: u32| std::fs::set_permissions(dir, std::fs::Permissions::from_mode(m)).unwrap(); assert!(matches!(narrow_if_wide(dir), Narrowed::Nothing)); set(0o775); assert!( matches!(narrow_if_wide(dir), Narrowed::From(0o775)), "a group-writable directory was not narrowed" ); assert_eq!(mode(), 0o700); // One other-bit is enough: 0701 hands the directory to every user on // the machine, and 0640 is the group half of the same thing. for wide in [0o701, 0o750, 0o770] { set(wide); assert!(matches!(narrow_if_wide(dir), Narrowed::From(m) if m == wide)); assert_eq!(mode(), 0o700, "{wide:04o} was not narrowed"); } set(0o500); assert!(matches!(narrow_if_wide(dir), Narrowed::Nothing)); assert_eq!(mode(), 0o500, "a narrower mode was widened"); // Back to writable, or the directory cannot be cleaned up. set(0o700); } /// `write_atomic` exists because `sessions.json` used to be created at /// the platform default mode and `chmod`ed to 0600 only after the /// tokens were already on disk — a real window, on a shared machine, /// where another local user could read them. This is the part of the /// fix checkable without `$HOME` (see [`crate::docs::testing`]): pass an explicit /// path in a throwaway directory and confirm 0600 lands on the file /// that `path` resolves to, both when the file is new and when an /// atomic write is replacing one that already exists — the second /// case is the one a `create()`-instead-of-`create_new()` slip would /// get wrong, by having the new file inherit the old one's mode /// instead of the one just asked for. Also checks that a successful /// write leaves nothing but the destination behind: a stray `.tmp` /// file is the failure mode of a cleanup step that only fires on /// error. #[cfg(unix)] #[test] fn write_atomic_lands_the_requested_mode_new_or_replacing() { use std::os::unix::fs::PermissionsExt; let dir = tempfile::tempdir().unwrap(); let dir = dir.path(); let path = dir.join("state.json"); write_atomic(&path, b"{\"a\":1}", 0o600).expect("first write"); let mode = |p: &Path| std::fs::metadata(p).unwrap().permissions().mode() & 0o777; assert_eq!(mode(&path), 0o600, "a new file did not land at 0600"); assert_eq!(std::fs::read_to_string(&path).unwrap(), "{\"a\":1}"); // Widen the mode by hand, the way a file created before this fix // existed might sit on disk, then confirm a further atomic write // narrows it back rather than leaving the old mode in place. std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap(); write_atomic(&path, b"{\"b\":2}", 0o600).expect("replacing write"); assert_eq!( mode(&path), 0o600, "replacing the file did not restore 0600" ); assert_eq!(std::fs::read_to_string(&path).unwrap(), "{\"b\":2}"); // Only the destination remains — no leftover temp file from either // write. let entries: Vec<_> = std::fs::read_dir(dir) .unwrap() .map(|e| e.unwrap().file_name()) .collect(); assert_eq!(entries, vec![std::ffi::OsString::from("state.json")]); } /// The two DIDs atgc actually resolves land where the scheme says, and /// the directory says which one it is without the path being parsed. #[test] fn an_identity_directory_is_the_method_then_the_identifier() { let root = tempfile::tempdir().expect("a temp dir"); for (did, tail) in [ ( "did:plc:nlzmjyfv6loqtxyzvdcznwgf", "dids/plc/nlzmjyfv6loqtxyzvdcznwgf", ), ("did:web:alice.example", "dids/web/alice.example"), // The case the "a period means did:web" shortcut would have got // wrong, and the reason the method is a directory. ("did:web:localhost%3A3000", "dids/web/localhost%3A3000"), ] { let dir = identity_dir_in(root.path(), did).expect("a directory"); assert_eq!(dir, root.path().join(tail), "{did}"); assert_eq!( std::fs::read_to_string(dir.join("did")).expect("a marker"), did, "{did} did not say what it is" ); assert_eq!( identity_path_in(root.path(), did).expect("a path"), dir, "the reader and the writer disagree about {did}" ); } } /// Nothing that would not be one directory name gets one. /// /// The colon case is the guard the whole scheme rests on: a path-based /// `did:web` would put a separator inside what has to be a single /// segment. `validate_web` refuses those, so this is unreachable today /// and is here so that it stays a refusal if that ever loosens. #[test] fn a_did_that_is_not_two_path_segments_is_refused() { let root = tempfile::tempdir().expect("a temp dir"); for bad in [ "did:web:example.com:user:alice", "did:web:../../etc", "did:web:.hidden", "notadid", "did:web", "did::alice.example", "did:web:", ] { assert!( identity_path_in(root.path(), bad).is_err(), "{bad:?} was given a directory" ); } let long = format!("did:web:{}", "a".repeat(256)); assert!( identity_path_in(root.path(), &long).is_err(), "over NAME_MAX" ); // 253 is the longest a domain may be, and it has to fit. let ok = format!("did:web:{}", "a".repeat(253)); assert!( identity_path_in(root.path(), &ok).is_ok(), "a legal domain did not fit" ); } }