Something went wrong. Try again.
atproto git client
Something went wrong. Try again.
Rust
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792//! The append-only JSONL substrate both of atgc's logs are built on.//!//! # Why this is separate from the logs themselves//!//! [`crate::logging::oauth`] was written first and alone, and every decision in it//! about *how* to put a line on disk — the atomic-append size cap, the//! rotation, the per-invocation identity, the fingerprint type — turned out//! to be a decision about logging rather than about OAuth. A second log//! needs all of them and needs them to agree: a person reading one log during//! an incident and the other a minute later is lining the two up by eye, and//! two files that rotate differently, clip differently or number their events//! differently would make that lining-up wrong in ways nothing announces.//!//! So the mechanism lives here once, and a log is that mechanism pointed at//! its own [`Event`](crate::logging::oauth::Event) enum. What each log still owns//! is the only thing that should differ: what it considers worth recording.//!//! # One invocation, two logs//!//! [`invocation`] is process-wide and computed once, independently of whether//! any log is switched on. Every log stamps every line with the same `inv`,//! and each writes its own `invocation` head line carrying the same pid,//! user, cwd, version and subcommand. That redundancy is the point: either//! file read on its own says who ran what, and two read together join on//! `inv` without a lookup table.//!//! # Secrets//!//! [`Fp`] is the reason this file is worth auditing. It is the only way to//! record that two sightings are of the same value without recording the//! value, and it has no constructor that takes a string verbatim — including//! on the way *back in*, where its hand-written [`Deserialize`] refuses//! anything that is not already eight hex characters. Every field in a log//! that could carry a secret or a credential is typed `Fp`, so putting the//! real thing there does not compile.
use serde::{Deserialize, Serialize};use sha2::{Digest, Sha256};use std::io::Write;use std::path::{Path, PathBuf};use std::sync::OnceLock;use std::sync::atomic::{AtomicU64, Ordering};
/// Hard cap on one serialized line, newline included.////// This is the whole multi-writer story. Several atgc processes genuinely run/// at once on this machine, and an interleaved or torn line would destroy a/// log's value at exactly the moment it matters. The file is opened/// `O_APPEND` and each event is written with a single `write(2)`; on Linux,/// an `O_APPEND` write to a local file that is at most `PIPE_BUF` (4096)/// bytes lands atomically, so concurrent writers cannot interleave and no/// locking is needed.////// The guarantee has two edges, and both are handled rather than hoped for:////// - **Over-length lines lose it.** Past `PIPE_BUF` the kernel may split the/// write. So variable-length fields are clipped at construction (see/// [`clip`]), and [`Log::emit`] refuses any record that still exceeds this/// cap, substituting the log's own `oversize` event — a short line that/// says what was dropped. A gap in a log is always explained by a line in/// that log./// - **Network filesystems lose it.** NFS has no atomic append; the guarantee/// is for a local filesystem. `~/.config` on NFS would need real locking./// Not handled, because that is not this machine and a silent half-measure/// would be worse than a documented limit.pub const MAX_LINE: usize = 4096;
/// Longest a single variable-length string field may be before clipping./// Chosen so that even several long fields in one record stay well under/// [`MAX_LINE`].pub const MAX_FIELD: usize = 512;
/// Rotate when a log passes this size, keeping one previous generation.////// An append-only file grows forever, and "the user will prune it" is not a/// plan. At roughly 200 bytes an event and a few dozen events an invocation,/// 8 MiB is on the order of a thousand invocations — months of ordinary use,/// and far more than the hours-long window any incident investigation cares/// about. On rotation `<name>.jsonl` becomes `<name>.jsonl.1`, replacing the/// previous `.1`, so each log is bounded at about 16 MiB total.////// The rotation is deliberately dumb, and its races are bounded and/// documented rather than locked against: it happens once, at process start,/// before this process writes anything, so it can never split an invocation's/// own events across two files. If two processes rotate simultaneously one/// `.1` may be lost, and a concurrent writer holding an fd on the old inode/// keeps appending into `.1` rather than the new file. Both cost a little/// history at a moment when the log is already megabytes old; neither can/// corrupt a line.pub const ROTATE_BYTES: u64 = 8 * 1024 * 1024;
/// A non-reversible fingerprint of a value that must not be written down.////// The only way to build one is [`Fp::of`], which hashes. There is no/// constructor that takes a value verbatim, which is what makes an audit of/// the two logs tractable: any field typed `Fp` cannot hold the thing it/// fingerprints, whatever a future caller does.////// Eight hex characters is 32 bits of a preimage-resistant hash: enough to/// match two sightings of one value against each other, useless for/// recovering it, and far too short to brute-force a match against a value/// you do not already hold.#[derive(Debug, Clone, PartialEq, Eq, Serialize)]#[serde(transparent)]pub struct Fp(String);
impl Fp { /// First 8 hex characters of the SHA-256 of `value`. pub fn of(value: &str) -> Self { Self::of_bytes(value.as_bytes()) }
/// The same, for a value that is not text. /// /// A blob is megabytes of gzip or PNG and there is no `&str` to hand; /// fingerprinting it is still how a `pr resubmit` that uploaded the same /// patch twice is told from one that uploaded two different patches. pub fn of_bytes(value: &[u8]) -> Self { let digest = Sha256::digest(value); let mut out = String::with_capacity(16); for byte in &digest[..4] { out.push_str(&format!("{byte:02x}")); } Fp(out) }
pub fn opt(value: Option<&str>) -> Option<Self> { value.map(Fp::of) }
/// The fingerprint itself, for a reader to print. Public because it is /// already public: it is eight characters of a hash and goes on screen. pub fn as_str(&self) -> &str { &self.0 }}
/// Reading a fingerprint back out of a log.////// Written by hand rather than derived, and this is the whole reason:/// `#[derive(Deserialize)]` on a `#[serde(transparent)]` newtype would hand/// every caller a way to put an arbitrary string inside an `Fp` —/// `serde_json::from_str::<Fp>("\"a-real-refresh-token\"")` — and the claim/// this type exists to make is that no such way exists. So the only value/// this accepts is one that already looks like a fingerprint: exactly eight/// lowercase hex characters, which is what [`Fp::of`] produces and is far too/// little room to hide a credential in. The readers read real fingerprints/// and a hand-edited or torn line is rejected as a malformed record, which is/// the behaviour they want anyway.impl<'de> Deserialize<'de> for Fp { fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> { let raw = String::deserialize(deserializer)?; if raw.len() != 8 || !raw.bytes().all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f')) { return Err(serde::de::Error::custom( "not a fingerprint: expected 8 lowercase hex characters", )); } Ok(Fp(raw)) }}
/// Clip a string to `max` bytes on a character boundary, marking the cut.////// The marker is not decoration: a reader has to be able to tell "the server/// said exactly this" from "the server said this and more", or a truncated/// error body reads as a complete one.pub fn clip(s: &str, max: usize) -> String { if s.len() <= max { return s.to_string(); } let mut end = max; while end > 0 && !s.is_char_boundary(end) { end -= 1; } format!("{}…[+{}B]", &s[..end], s.len() - end)}
// ===========================================================================// The invocation// ===========================================================================
/// The per-invocation identity, computed once at startup.#[derive(Debug)]pub struct Invocation { pub id: String, pub pid: u32,}
impl Invocation { /// `<unix-millis-hex>-<pid>-<random>`. /// /// The timestamp orders invocations at a glance and the pid names the /// process, but neither is enough: two atgc processes can start in the /// same millisecond, and a pid is reused within a boot. The third field /// is 32 bits from `RandomState`, which std seeds per process from the /// operating system's RNG for exactly this kind of collision resistance. /// Using it costs no dependency, and the alternative — adding `uuid` or /// `rand` as a direct dependency for one value — would have to clear /// `deny.toml`'s license allowlist for no gain. fn new() -> Self { use std::hash::{BuildHasher, Hasher}; let millis = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map(|d| d.as_millis()) .unwrap_or(0); let pid = std::process::id(); let entropy = std::collections::hash_map::RandomState::new() .build_hasher() .finish() as u32; Invocation { id: format!("{millis:x}-{pid}-{entropy:08x}"), pid, } }}
static INVOCATION: OnceLock<Invocation> = OnceLock::new();
/// This process's invocation, shared by every log.////// Computed on first use rather than in `main`, and deliberately not gated on/// any log being open: with both logs switched off the id is still the thing/// a reader compares against to say "(this command)", and a log switched on/// halfway through the process's life would otherwise have no identity to/// stamp. It costs one clock read and one hash.pub fn invocation() -> &'static Invocation { INVOCATION.get_or_init(Invocation::new)}
/// The facts about this run that every log's head line repeats.////// Repeating them per log rather than writing them once somewhere shared is/// the point: each file has to be readable on its own, by a person who was/// handed that file and nothing else.#[derive(Debug)]pub struct Head { pub pid: u32, pub user: Option<String>, pub cwd: Option<String>, pub version: &'static str, pub subcommand: String, pub rotated_from_bytes: Option<u64>,}
/// The leading positional words of a command line.////// Stops at the first `-`-prefixed argument, which is what keeps flag/// *values* — `--body "…"`, `--title "…"` — out of the logs entirely. Capped/// at three words so a long positional cannot dominate the line.pub fn subcommand_path(args: &[String]) -> String { args.iter() .skip(1) .take_while(|a| !a.starts_with('-')) .take(3) .map(|a| clip(a, 64)) .collect::<Vec<_>>() .join(" ")}
// ===========================================================================// The file// ===========================================================================
/// The open log, or nothing if it could not be opened or was switched off.struct Sink { file: std::fs::File, seq: AtomicU64,}
/// One log file: where it lives, whether it is on, and the fd once it is.////// Declared as a `static` per log ([`crate::logging::oauth::LOG`]) rather than/// passed around, because the writers are called from inside jacquard's/// transport and from `Drop` impls, where there is no atgc value in scope to/// hang a handle off.pub struct Log { /// The bare file name inside the config directory, e.g. `oauth.jsonl`. file_name: &'static str, /// The environment variable that redirects or disables it. env: &'static str, sink: OnceLock<Option<Sink>>, rotated: OnceLock<Option<u64>>,}
impl Log { pub const fn new(file_name: &'static str, env: &'static str) -> Self { Log { file_name, env, sink: OnceLock::new(), rotated: OnceLock::new(), } }
/// The name of the variable that turns this log off, for a reader's /// advice line. pub fn env_var(&self) -> &'static str { self.env }
/// Where this log lives, given a config directory. /// /// Split out from `HOME` resolution on purpose: `HOME` cannot be /// redirected inside a test (`set_var` is unsafe and this crate forbids /// unsafe), so the only way to test path handling at all is for the part /// that does not read the environment to take its input as an argument. pub fn path_in(&self, config_dir: &Path) -> PathBuf { config_dir.join(self.file_name) }
/// The configured path, or `None` when this log is switched off. /// /// The environment variable names an explicit file; any value meaning /// "off" — `0`, `off`, `false`, `no` or empty — disables it. That list is /// [`crate::env::is_off`], the one reading every boolean atgc takes from /// the environment, so the word that switches this log off is the word /// that switches `ATGC_USE_BOBBIN` off. /// /// Unset is not the same as off, which is why this reads the value rather /// than calling [`crate::env::switch`]: unset means the default path /// under the config directory, and off means no file at all. pub fn resolve_path(&self) -> Option<PathBuf> { match std::env::var(self.env) { Ok(v) => { let v = v.trim(); if crate::env::is_off(v) { None } else { Some(PathBuf::from(v)) } } Err(_) => crate::config::dir::config_dir() .ok() .map(|d| self.path_in(&d)), } }
/// Open the file and note whether opening rotated it. /// /// Every failure here is swallowed: a user filing a PR should never have /// the command die because a log file could not be opened. pub fn open(&self) { let _ = self.sink.set(self.open_sink()); }
fn open_sink(&self) -> Option<Sink> { let path = self.resolve_path()?; let _ = self.rotated.set(rotate_if_large(&path, ROTATE_BYTES)); open_sink_at(&path) }
/// Open this log at an explicit path, for the one thing a test cannot /// otherwise reach. /// /// A writer's whole chain — the event it builds, the redaction it applies, /// the bytes that land — can only be asserted against a real file, and /// the production way to name one is the `ATGC_*_LOG` variable, which /// cannot be set inside the test process: `set_var` is unsafe and this /// crate forbids unsafe. Without this the most that could be checked is /// that an event serializes, which is the half that was never in doubt. /// /// One-shot per process, like [`Log::open`], because it is the same /// `OnceLock`: the first caller wins and every later write in that test /// binary appends to the same file. #[cfg(test)] pub fn open_at(&self, path: &Path) { let _ = self.rotated.set(None); let _ = self.sink.set(open_sink_at(path)); }
fn sink(&self) -> Option<&Sink> { self.sink.get().and_then(|s| s.as_ref()) }
/// Whether anything is listening. /// /// The other two logs never ask, and are right not to: their observation /// is a value they were already holding, so an `emit` that drops it costs /// nothing. [`crate::logging::git`]'s costs a subprocess — reading where /// `HEAD` stood either side of a command that can move it — and running /// `git rev-parse` twice for a line nobody will write is the one case /// where asking first is cheaper than emitting and discarding. pub fn is_open(&self) -> bool { self.sink().is_some() }
/// The facts for this log's head line, once [`Log::open`] has run. /// /// `None` when the log is off, so a caller writes no head line at all /// rather than one describing a file nobody opened. pub fn head(&self, args: &[String]) -> Option<Head> { self.sink()?; Some(Head { pid: invocation().pid, user: std::env::var("USER") .or_else(|_| std::env::var("LOGNAME")) .ok(), cwd: std::env::current_dir() .ok() .map(|p| clip(&p.display().to_string(), MAX_FIELD)), version: env!("CARGO_PKG_VERSION"), subcommand: subcommand_path(args), rotated_from_bytes: self.rotated.get().copied().flatten(), }) }
/// Append one event. Never fails, never blocks a command. /// /// `oversize` builds the log's own replacement event for a record that /// came out too long to append atomically. It is a closure rather than a /// shared type because the substitute has to deserialize back through the /// same enum as everything else in that file — a reader that had to know /// about a foreign event type would be a second description of the /// format. pub fn emit<E, F>(&self, event: E, oversize: F) where E: Serialize, F: FnOnce(String, usize) -> E, { let Some(sink) = self.sink() else { return }; let seq = sink.seq.fetch_add(1, Ordering::Relaxed); // Microsecond precision, because one question these logs have to // answer is whether two processes overlapped, and at second // resolution they always appear to. let ts = chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Micros, true); let inv = &invocation().id; let mut line = match serde_json::to_vec(&Record { ts: &ts, inv, seq, event: &event, }) { Ok(line) => line, Err(_) => return, }; if line.len() >= MAX_LINE { // Everything variable-length is clipped at construction, so // reaching here means a field grew past its own bound or a Vec // got long. Say so in a line short enough to be safe, rather than // emitting a long one and silently forfeiting append atomicity. let of = serde_json::to_value(&event) .ok() .and_then(|v| v.get("event").and_then(|t| t.as_str()).map(String::from)) .unwrap_or_else(|| "unknown".into()); let bytes = line.len(); line = match serde_json::to_vec(&Record { ts: &ts, inv, seq, event: &oversize(of, bytes), }) { Ok(line) if line.len() < MAX_LINE => line, _ => return, }; } line.push(b'\n'); // Exactly one `write(2)`, of at most MAX_LINE bytes, to a file opened // O_APPEND. `write_all` would be wrong here: it loops on a short // write, and two loop iterations are two syscalls that another // process can interleave between. A short write on a regular file // under PIPE_BUF does not happen in practice, and if it did the right // answer is to lose the line, not to tear it. let _ = (&sink.file).write(&line); }}
/// Open one log file for appending, at the mode a log must have.////// Two halves, because `OpenOptions::mode` only covers one of them: it is/// passed to `open(2)` as the creation mode and is *ignored outright* when/// the file already exists. A log created by this function is 0600 forever;/// one that already existed keeps whatever mode it had, and/// `ATGC_OAUTH_LOG=/some/existing/file` is a supported way to make that/// happen. The contents name DIDs, working directories and the local user,/// so [`narrow_if_wide`] closes it after the fact.fn open_sink_at(path: &Path) -> Option<Sink> { let mut options = std::fs::OpenOptions::new(); options.create(true).append(true); // 0600 at creation rather than a chmod afterwards: for a file this call // creates, there is then no window at all in which it is readable by // anyone else. #[cfg(unix)] { use std::os::unix::fs::OpenOptionsExt; options.mode(0o600); } let file = options.open(path).ok()?; #[cfg(unix)] narrow_if_wide(&file); Some(Sink { file, seq: AtomicU64::new(0), })}
/// Take away group and other permissions on an already-open log file.////// Only when they are actually set: an unconditional `chmod` would rewrite/// the mode of every log file on every invocation, which turns a no-op into a/// syscall and, worse, would silently *widen* nothing but would still clobber/// a setgid bit or an owner's deliberate 0400. Reading first and writing only/// on a difference means the ordinary case touches nothing.////// Through the file descriptor rather than the path. `chmod` on a path that/// was just opened is a different object if anything swapped it in between,/// which is the ordinary symlink race; `fchmod` can only ever affect the file/// this function is already writing to. `File::set_permissions` is/// `fchmod(2)`.////// Failures are swallowed, like every other failure in this module: a log/// whose mode could not be narrowed is a log, and no command should die for/// it. The owner bits are preserved rather than forced to `rw-`, so a file/// deliberately left read-only stays read-only — appending to it was already/// going to fail, and this is not the place to argue about it.#[cfg(unix)]fn narrow_if_wide(file: &std::fs::File) { use std::os::unix::fs::PermissionsExt; let Ok(meta) = file.metadata() else { return; }; let mode = meta.permissions().mode(); let narrowed = mode & !0o077; if narrowed == mode { return; } crate::logging::debug::log(format!( "narrowing log file mode from {:04o} to {:04o}", mode & 0o7777, narrowed & 0o7777 )); let _ = file.set_permissions(std::fs::Permissions::from_mode(narrowed));}
/// The envelope every line of every log carries.////// # This shape is public////// `atgc logs <name> --json` prints these lines verbatim (see/// `docs/output.md`, and `crate::cmd::logs::render`'s `write_json_line` for/// why it prints the bytes rather than a re-serialization). `ts`, `inv` and/// `seq`, and the flattened fields of whichever `Event` enum this is over,/// are therefore a public interface with the same stability rules as any/// other `--json` payload: adding a field is a `feat`, removing or renaming/// one is a breaking `!`.////// The type being private to this module is about who may *write* a line, not/// about who may read one. Renaming a field here is a change to the CLI's/// output contract even though nothing outside this crate names the type.#[derive(Serialize)]struct Record<'a, E> { ts: &'a str, inv: &'a str, seq: u64, #[serde(flatten)] event: &'a E,}
/// The exact bytes one event would occupy on disk, with a worst-case/// envelope.////// Exposed to the logs' own test modules. Whether a variant fits inside/// [`MAX_LINE`] is a fact about that variant's fields, so the assertion/// belongs beside the enum that declares them — but the envelope it has to/// fit inside belongs here, and a second hand-written copy of it in each log/// would be the drift this module exists to prevent.#[cfg(test)]pub fn line_for_test<E: Serialize>(event: &E) -> Vec<u8> { let mut line = serde_json::to_vec(&Record { // The widest values any of these fields can take: a far-future // timestamp, a full-width invocation id, a saturated sequence number. ts: "2026-08-06T00:00:00.000000Z", inv: "ffffffffffff-4194304-ffffffff", seq: u64::MAX, event, }) .expect("an event serializes"); line.push(b'\n'); line}
/// Rotate if the log has grown past `cap`. Returns the size that triggered/// it, for the head line.fn rotate_if_large(path: &Path, cap: u64) -> Option<u64> { let size = std::fs::metadata(path).ok()?.len(); if size < cap { return None; } let mut previous = path.as_os_str().to_owned(); previous.push(".1"); std::fs::rename(path, PathBuf::from(previous)).ok()?; Some(size)}
#[cfg(test)]mod tests { #[cfg(unix)] use super::open_sink_at; use super::{Fp, Invocation, MAX_FIELD, MAX_LINE, clip, line_for_test, rotate_if_large};
#[test] fn fingerprint_is_short_stable_and_not_the_input() { let fp = Fp::of("a-refresh-token"); assert_eq!(fp.as_str().len(), 8); assert_eq!(fp, Fp::of("a-refresh-token")); assert_ne!(fp, Fp::of("a-refresh-token ")); assert!(!fp.as_str().contains("refresh")); // Pinned so a change of hash or prefix length is a deliberate act: // fingerprints in an old log must stay comparable with new ones. assert_eq!(Fp::of("").as_str(), "e3b0c442"); }
/// Bytes and text of one value fingerprint alike, so a blob uploaded /// twice is recognisable however the caller had it to hand. #[test] fn bytes_and_text_agree() { assert_eq!(Fp::of("hello"), Fp::of_bytes(b"hello")); }
/// The readers deserialize into these types, and that must not become a /// way to put a secret inside an [`Fp`]. Only something already shaped /// like a fingerprint is accepted. #[test] fn a_fingerprint_can_only_be_read_back_as_a_fingerprint() { let fp = Fp::of("a-refresh-token"); let json = serde_json::to_string(&fp).unwrap(); assert_eq!(serde_json::from_str::<Fp>(&json).unwrap(), fp);
for not_a_fingerprint in [ "\"a-real-refresh-token\"", "\"E3B0C442\"", // uppercase is not what `of` produces "\"e3b0c44\"", // too short "\"e3b0c4422\"", // too long "\"\"", "\"zzzzzzzz\"", "12345678", ] { assert!( serde_json::from_str::<Fp>(not_a_fingerprint).is_err(), "{not_a_fingerprint} was accepted as a fingerprint" ); } }
#[test] fn clip_marks_the_cut_and_never_splits_a_char() { assert_eq!(clip("short", 10), "short"); assert_eq!(clip("abcdefghij", 4), "abcd…[+6B]"); // A 3-byte char straddling the limit is dropped whole, not halved. let s = "aa€bb"; let out = clip(s, 3); assert!(out.starts_with("aa…"), "{out}"); assert!(std::str::from_utf8(out.as_bytes()).is_ok()); }
/// The rule that keeps a PR body out of both logs: a flag stops the walk, /// so no flag value is ever recorded. #[test] fn subcommand_stops_at_the_first_flag() { use super::subcommand_path; let args: Vec<String> = ["atgc", "pr", "create", "--title", "secret title"] .iter() .map(|s| s.to_string()) .collect(); assert_eq!(subcommand_path(&args), "pr create"); let args: Vec<String> = ["atgc", "auth", "login", "alice.example.com"] .iter() .map(|s| s.to_string()) .collect(); assert_eq!(subcommand_path(&args), "auth login alice.example.com"); assert_eq!(subcommand_path(&["atgc".to_string()]), ""); }
#[test] fn invocation_ids_differ_within_one_millisecond() { // The claim that matters: two processes starting at the same instant // get different ids. Same-process construction is the strictest // version of that, since the timestamp and pid are guaranteed equal. let a = Invocation::new(); let b = Invocation::new(); assert_ne!(a.id, b.id); assert!(a.id.contains(&std::process::id().to_string())); }
/// One invocation, however many logs. Both files stamp the same `inv`, so /// joining them is a string comparison and not a guess from timestamps. #[test] fn the_invocation_is_shared_and_computed_once() { assert_eq!(super::invocation().id, super::invocation().id); assert_eq!(super::invocation().pid, std::process::id()); }
#[test] fn rotation_keeps_one_generation_and_leaves_a_small_log_alone() { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("oauth.jsonl");
std::fs::write(&path, b"first\n").unwrap(); assert_eq!(rotate_if_large(&path, 1024), None, "small log left alone"); assert!(!path.with_extension("jsonl.1").exists());
// Over the cap: the log moves aside and a fresh one starts. std::fs::write(&path, vec![b'x'; 2048]).unwrap(); assert_eq!(rotate_if_large(&path, 1024), Some(2048)); assert!(!path.exists(), "the live log is out of the way"); assert_eq!( std::fs::metadata(path.with_extension("jsonl.1")) .unwrap() .len(), 2048 );
// A second rotation replaces `.1` rather than accumulating // generations, which is what bounds total size. std::fs::write(&path, vec![b'y'; 4096]).unwrap(); assert_eq!(rotate_if_large(&path, 1024), Some(4096)); assert_eq!( std::fs::metadata(path.with_extension("jsonl.1")) .unwrap() .len(), 4096 ); assert!(!path.with_extension("jsonl.2").exists());
// A log that does not exist yet is not an error. std::fs::remove_file(path.with_extension("jsonl.1")).unwrap(); assert_eq!(rotate_if_large(&path, 1), None); }
/// A log inherited at a wide mode is narrowed, and one at a narrow mode /// is not touched. /// /// `OpenOptions::mode` is the creation mode: `open(2)` ignores it /// entirely when the file already exists, which `ATGC_OAUTH_LOG` pointed /// at an existing file is a supported way to arrange. The file names /// DIDs, working directories and the local user, so a 0644 inherited once /// used to stay 0644 for good. #[cfg(unix)] #[test] fn a_log_inherited_world_readable_is_narrowed_on_open() { use std::os::unix::fs::PermissionsExt; let dir = tempfile::tempdir().unwrap(); let dir = dir.path(); let mode_of = |p: &std::path::Path| std::fs::metadata(p).unwrap().permissions().mode() & 0o7777;
// Inherited at 0644, as an existing file under `ATGC_OAUTH_LOG` // would be. Opening it takes the group and other bits away. let wide = dir.join("wide.jsonl"); std::fs::write(&wide, b"").unwrap(); std::fs::set_permissions(&wide, std::fs::Permissions::from_mode(0o644)).unwrap(); assert!(open_sink_at(&wide).is_some()); assert_eq!(mode_of(&wide), 0o600);
// Created by this call: 0600 from the start, with no window in which // it was anything else. let fresh = dir.join("fresh.jsonl"); assert!(open_sink_at(&fresh).is_some()); assert_eq!(mode_of(&fresh), 0o600);
// Owner bits are preserved rather than forced to `rw-`. Append-only // for the owner is a real thing to want from a log, and narrowing // must not quietly hand the owner a read bit it did not have. let append_only = dir.join("append-only.jsonl"); std::fs::write(&append_only, b"").unwrap(); std::fs::set_permissions(&append_only, std::fs::Permissions::from_mode(0o244)).unwrap(); assert!(open_sink_at(&append_only).is_some()); assert_eq!(mode_of(&append_only), 0o200);
// A file that cannot be opened at all is left exactly as it was: // there is no descriptor to `fchmod`, and reaching for the path // instead is the symlink race this deliberately avoids. let unopenable = dir.join("unopenable.jsonl"); std::fs::write(&unopenable, b"").unwrap(); std::fs::set_permissions(&unopenable, std::fs::Permissions::from_mode(0o444)).unwrap(); assert!(open_sink_at(&unopenable).is_none()); assert_eq!(mode_of(&unopenable), 0o444);
std::fs::remove_dir_all(dir).ok(); }
/// The envelope itself has to leave room. A test-only event of entirely /// fixed width would prove nothing; this pins that the four wrapper /// fields, at their widest, are a small fraction of the budget the logs' /// own variants are measured against. #[test] fn the_envelope_is_a_small_part_of_the_budget() { let bare = line_for_test(&serde_json::json!({"event": "x"})); assert!(bare.len() < MAX_LINE / 8, "{} bytes", bare.len()); assert!(bare.len() + MAX_FIELD * 4 < MAX_LINE); }}