Something went wrong. Try again.
atproto git client
Something went wrong. Try again.
Rust
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176//! Global debug mode: --debug flag or ATGC_DEBUG=1. Prints HTTP traffic and//! full error details to stderr.//!//! The loudest rung of [`crate::term::say`]'s ladder, which is where the flag and//! the env var are now read: `--debug` and `ATGC_LOG=debug` turn on the same//! thing, and `-qq` turns it off, because a user asking for silence should//! not have to know which of two switches a given line came out of. What//! stays here is the shape of that output — one `[debug]` per line, no//! topic — since these dumps are wire traffic and error structures rather//! than a message about any one subsystem.
pub fn enabled() -> bool { crate::term::say::debug_wanted()}
/// Print a message to stderr, one `[debug]` per line, scrubbed.////// The scrubbing is here rather than at the callers for the same reason [`Fp`]/// is a type rather than a habit: a rule a hundred call sites have to remember/// is not a rule. `oauth.jsonl` states that nothing secret is ever written to/// it and makes that structural; this channel stated nothing and checked/// nothing, and a token endpoint's error body reached stderr through/// [`dump_err`] on precisely the value the next line in `auth.rs` scrubbed on/// its way to the log. Every entry point into this module now scrubs, so the/// promise belongs to the channel and not to whoever wrote the call.////// [`Fp`]: crate::logging::file::Fppub fn log(msg: impl AsRef<str>) { if enabled() { for line in crate::logging::oauth::scrub_text(msg.as_ref()).lines() { eprintln!("[debug] {line}"); } }}
/// Dump an error's full structure (jacquard errors carry the XRPC error body).////// Which is the reason it is scrubbed. `RequestError::HttpStatusWithBody`/// formats the token endpoint's response body into both `Display` and/// `Debug`, and `OAuthError::Request` and `session::Error::ServerAgent` are/// both `#[error(transparent)]`, so a server that echoed the request/// parameters back into its error arrives here whole. Values are blanked and/// structure is kept: the flag exists to say what happened, and a dump with/// the shape taken out of it would be no use to anyone.////// Routed through [`log`] rather than printing directly, which also makes a/// `{:#?}` dump obey this module's one-`[debug]`-per-line rule; it used to/// prefix the first line and leave the rest bare.pub fn dump_err(context: &str, err: &impl std::fmt::Debug) { if enabled() { log(format!("{context}: {err:#?}")); }}
/// Pretty-print a record for a `>> putRecord`/`>> createRecord` log line.////// A jacquard-lexicon generated record's `#[serde(tag = "$type", ...)]`/// re-emits `$type` unconditionally, and its `#[serde(flatten)]` catch-all/// does not strip a `$type` a live read carried into it — so a record that/// was read back and mutated (`pr resubmit`/`pr edit`, `repo edit`)/// serializes with the key written twice if printed directly. Neither/// occurrence is wrong, but printing them side by side reads as corrupted/// JSON. Routing through [`serde_json::Value`] first collapses the two/// writes into the one key a `Map` can hold, the same as the actual wire/// write already does going through `record::put`/`Agent::create_record` —/// this only fixes what the log shows, not what was ever sent.pub fn pretty(value: &impl serde::Serialize) -> String { serde_json::to_value(value) .map(|v| serde_json::to_string_pretty(&v).unwrap_or_default()) .unwrap_or_default()}
/// Decode a JWT's payload segment for display. Debug aid only — no/// signature verification.////// It takes a live bearer token and is the obvious place for one to escape,/// so: only the second of the three segments is ever returned, the signature/// is never touched, and a token that does not decode comes back as/// `<not decodable>` rather than as itself. What is left is a service-auth/// token's `iss`, `aud`, `lxm` and `exp`, none of which is a credential —/// and it reaches stderr through [`log`], which scrubs.pub fn jwt_claims(token: &str) -> String { fn b64url(seg: &str) -> Option<Vec<u8>> { const ALPHABET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"; let mut out = Vec::new(); let mut acc: u32 = 0; let mut bits = 0; for &c in seg.as_bytes() { let v = ALPHABET.iter().position(|&a| a == c)? as u32; acc = (acc << 6) | v; bits += 6; if bits >= 8 { bits -= 8; out.push((acc >> bits) as u8); } } Some(out) } token .split('.') .nth(1) .and_then(b64url) .and_then(|b| String::from_utf8(b).ok()) .unwrap_or_else(|| "<not decodable>".to_string())}
#[cfg(test)]mod tests { use super::jwt_claims;
const HEADER: &str = "eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzI1NksifQ"; /// The claims of a service-auth token of the shape `repo create` mints /// before calling a knot: issuer, knot audience, and the lxm it is good /// for. Base64url, unpadded, as JWTs are. const PAYLOAD: &str = "eyJpc3MiOiJkaWQ6cGxjOm5sem1qeWZ2NmxvcXR4eXp2ZGN6bndnZiIsImF1ZCI6ImRpZDp3ZWI6a25vdDEudGFuZ2xlZC5zaCIsImx4bSI6InNoLnRhbmdsZWQucmVwby5jcmVhdGUiLCJleHAiOjE3NTQ0MDAwMDB9"; const CLAIMS: &str = r#"{"iss":"did:plc:nlzmjyfv6loqtxyzvdcznwgf","aud":"did:web:knot1.tangled.sh","lxm":"sh.tangled.repo.create","exp":1754400000}"#;
/// The decoder is hand-rolled — there is no base64 crate in the tree — /// and it is what `--debug` prints when a knot rejects a service-auth /// token. Getting the aud or lxm wrong on screen would send someone /// debugging the wrong end of the call. #[test] fn decodes_a_service_auth_payload() { let token = format!("{HEADER}.{PAYLOAD}.c2lnbmF0dXJl"); assert_eq!(jwt_claims(&token), CLAIMS); }
/// The payload segment, not the header, and not the signature — a token /// whose header decodes cleanly would otherwise look plausible. #[test] fn reads_the_second_segment() { let token = format!("{HEADER}.{PAYLOAD}.sig"); assert!(jwt_claims(&token).contains("\"lxm\"")); assert!(!jwt_claims(&token).contains("\"alg\"")); }
/// base64url's alphabet swaps `+/` for `-_`, and the decoder's table is /// the url-safe one. A payload containing those bytes is the only place /// the difference shows. #[test] fn uses_the_url_safe_alphabet() { // "\xfb\xff" encodes to "-_8" in base64url and "+/8" in standard. assert_eq!( jwt_claims("h.-_8.s"), "<not decodable>", "decodes, but not as utf-8" ); // Standard-alphabet characters are not in the table at all. assert_eq!(jwt_claims("h.+/8.s"), "<not decodable>"); }
#[test] fn says_so_rather_than_panicking_on_anything_else() { for token in [ "", "not-a-jwt", // One segment: there is no second one to read. HEADER, // Padded, which the alphabet has no entry for. JWTs are unpadded // by spec, so this is a malformed token rather than a gap. "h.eyJhIjoxfQ==.s", // Valid base64url, but not utf-8 underneath. "h.__________.s", ] { assert_eq!(jwt_claims(token), "<not decodable>", "input: {token:?}"); } }
/// Bytes are accumulated first and validated as utf-8 once at the end, /// so a multibyte claim survives instead of blanking the whole dump. #[test] fn keeps_multibyte_claims_intact() { assert_eq!(jwt_claims("h.eyJzdWIiOiLwn6esIn0.s"), r#"{"sub":"🧬"}"#); }}