//! 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::Fp pub fn log(msg: impl AsRef) { 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 /// `` 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> { 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(|| "".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"), "", "decodes, but not as utf-8" ); // Standard-alphabet characters are not in the table at all. assert_eq!(jwt_claims("h.+/8.s"), ""); } #[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), "", "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":"🧬"}"#); } }