Something went wrong. Try again.
atproto git client
Something went wrong. Try again.
Rust
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059//! 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<PathBuf>, /// Why an `XDG_CONFIG_HOME` that was set did not decide the answer. rejected: Option<String>,}
/// 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<Choice> { 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<PathBuf> { Ok(choose( std::env::var_os("XDG_CONFIG_HOME").as_deref(), std::env::var_os("HOME").as_deref(), )? .dir)}
pub fn config_dir() -> Result<PathBuf> { 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: `<config>/dids/<method>/<id>/`.////// 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/<did>/`, 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<PathBuf> { 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<PathBuf> { 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<PathBuf> { 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<Settled> = 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<u32>,}
/// 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<u32> { 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::<u64>() { 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/<method>/<id>/` 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<PathBuf> { 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<Option<HashSet<PathBuf>>> = 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<std::fs::File> { 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::File> { 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( <std::fs::Permissions as std::os::unix::fs::PermissionsExt>::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" ); }}