Skip to main content

runner_manager_platform/
logging.rs

1// owner: d1-platform-core
2
3//! The structured log sink, and the redaction that is not optional.
4//!
5//! `07-security.md`'s threat table, on *"Logs disclose repository secrets"*:
6//! **"Structured allowlist logging with unconditional redaction of tokens,
7//! headers, JIT blobs, and paths."** Its security gate is a *secret-injection
8//! log scan*, and its release gate is that *"the user access token and the
9//! encoded JIT configuration are absent from logs, databases, snapshots, crash
10//! reports, and CLI output"*.
11//!
12//! # Allowlist, not denylist — and why that is the whole design
13//!
14//! A denylist redacts the fields somebody remembered to name. It is correct on
15//! the day it is written and wrong on the day a later task adds a field, which
16//! is the day it matters. So this sink inverts the default: **a field whose
17//! name is not in [`ALLOWED_FIELDS`] has its value replaced outright.** A task
18//! that adds `runner_token` to a log call gets `[redacted]` with no review, no
19//! ceremony, and no leak. Adding a field to the allowlist is a deliberate edit
20//! to this file that shows up in a diff.
21//!
22//! The field name itself is kept. Knowing that an event carried a
23//! `runner_token` field is useful, and the name is not the secret.
24//!
25//! # Two layers, because one is not enough
26//!
27//! The allowlist protects *fields*. It cannot protect the message body, which
28//! has to be allowed or the logs say nothing — and a message body is exactly
29//! where a secret ends up when somebody writes
30//! `info!("failed with {authorization}")`. So every string that survives the
31//! allowlist is then scrubbed by value shape ([`redact`]): GitHub token
32//! prefixes, credential header names, long opaque runs such as an encoded JIT
33//! configuration, and filesystem paths.
34//!
35//! Scrubbing by shape over-redacts, deliberately. Some of that is worth knowing
36//! about before it surprises somebody:
37//!
38//! - A slash-rooted word is treated as a filesystem path, and a URL *path* on
39//!   its own looks exactly like one. Log a full URL — `https://api.github.com/…`
40//!   — and it survives intact; log a bare `/repos/owner/repo` and it becomes
41//!   `[path]`.
42//! - Any unbroken run of 40 or more base64, base64url, or hex characters is
43//!   treated as an opaque secret. There is exactly one carve-out: a value that
44//!   is precisely 64 lowercase hex characters is a SHA-256 digest, and renders
45//!   as a labelled 12-character prefix rather than disappearing. A digest is
46//!   not a secret, and `07-security.md` makes checksum verification a security
47//!   gate whose most useful diagnostic is expected-versus-actual.
48//! - A JSON Web Token is redacted whole. Its `.` separators split it into runs
49//!   the opaque-run rule is too short-sighted to catch, so it is recognised by
50//!   shape instead: two or three base64url segments whose header begins `eyJ`.
51//! - A URL keeps its scheme, host and path and loses everything that
52//!   authenticates it: the `user:password@` userinfo, the query string, and the
53//!   fragment. The userinfo matters more than it looks — a
54//!   token-authenticated git remote is
55//!   `https://x-access-token:ghu_…@github.com/owner/repo.git`, so it is the
56//!   shape a clone or fetch failure arrives in. The path stays diagnosable but
57//!   is not exempt: each segment goes through the same shape rules on its own,
58//!   because a token in `…/raw/ghu_…/f` is a token, and the alternative was the
59//!   one place the belt never ran. A 40-character git object name is opaque
60//!   enough to go with it.
61//! - A word ending in `:` or `=` whose stem is a credential header name causes
62//!   the next two words to be redacted, so `Authorization: Bearer ghu_…` loses
63//!   both the scheme and the token.
64//!
65//! A word is cut on structural punctuation — `,`, `;`, `{`, `}`, `[`, `]`,
66//! `<`, `>` and `&` — before any of that runs, and each fragment is then judged
67//! the way a whole word is: unwrapped, judged on its core, and re-emitted with
68//! its punctuation put back. Without that cut only the *first* key/value pair
69//! in a compact structure is ever examined, and redaction becomes a function of
70//! field order: `{"encoded_jit_config":"…"}` was caught and
71//! `{"runner_id":42,"encoded_jit_config":"…"}` was not, while
72//! `serde_json::to_string` is what decides which of the two an error body is.
73//! Nesting, a form-encoded body, a `;`-separated connection string and a plist
74//! element are all that same defect in different punctuation. So is an array
75//! element, one step further down — a fragment judged *with* its quote still
76//! attached matches no shape rule at all, and an array element is the one
77//! fragment that has no key of its own to give it away.
78//!
79//! Because the cut is flat, a credential's value does not have to be in the
80//! same fragment as the key that names it — `{"password":["hunter2"]}`,
81//! `{"password":{"v":"hunter2"}}` and a plist's
82//! `<key>password</key><string>hunter2</string>` all put it one or more
83//! fragments away — so a *carry* is threaded along the fragments to say that a
84//! key is still waiting for its value, or that a quoted value was cut before
85//! its closing quote. It steps over element names, because markup is not the
86//! value a key named, and it stops at the punctuation that visibly closes the
87//! value, because a redaction reported where no secret was is a false signal in
88//! the one log a reader consults to find out whether anything leaked.
89//!
90//! A URL is cut out of the text around it rather than being allowed to own the
91//! rest of the word. Its scheme is the run of scheme characters immediately
92//! before the `://`, and it ends at the first character that cannot appear in a
93//! URL — plus, *ahead of its query string only*, at a `;` or an `&`, which are
94//! structural characters everywhere else and are query syntax after the `?`.
95//! Everything on either side goes back through the rules. So
96//! `{"documentation_url":"https://…","token":"ghu_…"}` — which is what a GitHub
97//! REST error body looks like — keeps the URL and redacts the token, rather
98//! than the URL swallowing the token, and
99//! `Server=https://vault.local/api;Password=…` keeps the URL and redacts the
100//! password rather than the URL swallowing the `;` that separates them.
101//!
102//! **The text after a URL is iterated over, not recursed into.** That is a
103//! memory-safety property, not a style: recursing there made stack depth linear
104//! in the length of the message, and ~86 KB of URL-carrying JSON exited
105//! `STATUS_STACK_OVERFLOW`. A stack overflow is not catchable and takes the
106//! process with it, so an attacker-influenceable error body could kill the
107//! agent from inside its own log sink — and a sink that is not running redacts
108//! nothing at all. For the same reason every search in `split_url` is bounded
109//! by the URL's own end: a pass that never returns is as effective a denial as
110//! one that overflows.
111//!
112//! A key is also trimmed of backslashes, which a value never is: `Debug` on a
113//! `String` escapes the quotes inside it, so a body reached through
114//! `error!(reason = ?err)` spells its keys `\"password\"`. The `trim_key`
115//! function documents why the same trim must not be applied to a value; it is
116//! named rather than linked because it is private and this module's
117//! documentation is not.
118//!
119//! Every one of those is a case where being wrong costs a slightly less
120//! readable log line, against a case where being wrong the other way costs a
121//! disclosed credential.
122//!
123//! # What this does not do
124//!
125//! It does not stop a caller printing to standard output, and it does not
126//! redact a `Debug` derive somewhere else in the program. Those are held by
127//! different controls: `secrecy::SecretString` for the two values that matter
128//! (`07-security.md`'s credential inventory), and
129//! [`crate::process::SpawnSpec::spawn_with_handoff`] for the command line.
130
131use std::fmt;
132use std::io::Write;
133use std::path::{Path, PathBuf};
134
135use serde_json::{Map, Value};
136use tracing::field::{Field, Visit};
137use tracing::{Event, Subscriber};
138use tracing_subscriber::layer::{Context, Layer};
139use tracing_subscriber::registry::LookupSpan;
140
141/// What replaces a value this sink will not emit.
142pub const REDACTION: &str = "[redacted]";
143
144/// What replaces something that was recognisably a filesystem path. Distinct
145/// from [`REDACTION`] so a reader can tell "a path was here" from "a secret was
146/// here" without either being disclosed.
147pub const PATH_REDACTION: &str = "[path]";
148
149/// The field names this sink emits verbatim. Everything else is replaced with
150/// [`REDACTION`].
151///
152/// ---
153///
154/// **Read this before adding a name.** A field on this list still passes
155/// through [`redact`], so adding one does not switch redaction off — but it
156/// does mean the field's value reaches the scrubber instead of being discarded,
157/// and the scrubber only recognises shapes it was taught. Add a name only when
158/// the value is structurally incapable of carrying a credential: an
159/// identifier, an enumerated state, a count, a duration. Never add a name whose
160/// value is free text supplied by GitHub or by a workflow.
161///
162/// Kept sorted, and a test enforces that, so an addition is one line in a diff
163/// rather than a name buried in the middle of a list.
164pub const ALLOWED_FIELDS: &[&str] = &[
165    "arch",
166    "attempt",
167    "attempt_id",
168    "attempt_state",
169    "capacity",
170    "count",
171    // Seconds a rate-limited client waits before its next request.
172    "delay_secs",
173    "demand",
174    "desired",
175    "duration_ms",
176    "elapsed_ms",
177    "error_kind",
178    "event",
179    "exit_code",
180    "headroom",
181    "host_id",
182    "http_status",
183    "installation_id",
184    "job_id",
185    // A closed enum's `Display`, such as a rate limit's primary/secondary kind.
186    "kind",
187    "label",
188    "lock",
189    "lock_wait_ms",
190    "materialize_ms",
191    "message",
192    "mode",
193    "os",
194    "outcome",
195    "package_copy_ms",
196    "package_downloaded",
197    "package_install_ms",
198    "package_verify_ms",
199    "package_wait_ms",
200    "pid",
201    "policy_id",
202    "policy_state",
203    "prepare_ms",
204    "reason",
205    "register_ms",
206    // GitHub's `x-ratelimit-remaining`: a request count.
207    "remaining",
208    "retry_in_ms",
209    "runner_id",
210    "scope",
211    "spawn_ms",
212    "start_mode",
213    "state",
214    "target",
215    "turn_wait_ms",
216    "version",
217];
218
219/// Header and parameter names whose value is a credential.
220///
221/// Compared case-insensitively with `-` and `_` treated as the same character,
222/// because the same header arrives as `Authorization`, `authorization`, and
223/// `x_api_key` depending on who wrote the line.
224const CREDENTIAL_KEYS: &[&str] = &[
225    "access.token",
226    "api.key",
227    "apikey",
228    "auth",
229    "authorization",
230    "client.secret",
231    "cookie",
232    "credential",
233    "encoded.jit.config",
234    "jit",
235    "jit.config",
236    "jitconfig",
237    "password",
238    "private.token",
239    "proxy.authorization",
240    "refresh.token",
241    "secret",
242    "set.cookie",
243    "token",
244    "www.authenticate",
245    "x.api.key",
246    "x.auth.token",
247    "x.github.token",
248    "x.hub.signature",
249    "x.hub.signature.256",
250];
251
252/// Authentication scheme words. Whatever follows one of these is the
253/// credential.
254const SCHEME_WORDS: &[&str] = &["basic", "bearer", "digest", "negotiate", "token"];
255
256/// Prefixes GitHub gives its credentials. Present as a belt on top of the
257/// opaque-run rule below, because a short-lived or truncated token can be under
258/// the length threshold while still being a live credential.
259const TOKEN_PREFIXES: &[&str] = &["ghp_", "gho_", "ghu_", "ghs_", "ghr_", "github_pat_", "gh_"];
260
261/// How long an unbroken run of opaque characters has to be before it is assumed
262/// to be a secret.
263///
264/// 40 rather than something shorter so that ordinary identifiers, hyphenated
265/// words, and UUIDs (36 characters, and not secret) survive; short enough that
266/// every GitHub credential format and every encoded JIT configuration is well
267/// past it.
268const OPAQUE_RUN_THRESHOLD: usize = 40;
269
270/// Characters that make up base64, base64url, and hex.
271fn is_opaque_char(c: char) -> bool {
272    c.is_ascii_alphanumeric() || matches!(c, '+' | '/' | '=' | '_' | '-')
273}
274
275/// Punctuation that may wrap a value without being part of it.
276const WRAPPERS: &[char] = &[
277    '"', '\'', '`', '(', ')', '[', ']', '{', '}', '<', '>', ',', ';', '.', '!', '?',
278];
279
280/// Punctuation that separates one key/value pair from the next *inside* a
281/// single whitespace-delimited word.
282///
283/// [`redact_core`] judges a fragment by splitting it once, on the first `=` or
284/// `:` it finds. That is enough for `key=value` and for a one-field object,
285/// and it is why every shape this module was tested against put the credential
286/// in the first pair. It is not enough for anything `serde_json::to_string`
287/// actually emits: a struct with two fields becomes
288/// `{"runner_id":42,"encoded_jit_config":"…"}`, where the only pair ever
289/// examined is `runner_id`. Nesting is the same defect one level down, and a
290/// form-encoded body is the same defect spelled with `&`.
291///
292/// So a word is cut on these before any of that runs, and each fragment is
293/// judged on its own, by [`redact_fragment`]. The separators go back verbatim,
294/// because the structure around a redaction is what keeps the line diagnosable.
295///
296/// `;` is here for the reason `&` is: it separates the pairs of a Windows
297/// connection string (`Server=host;Database=x;Password=…`), of a credential
298/// string, and of a cookie header written without a space after the separator.
299/// It was already in [`WRAPPERS`] — recognised as punctuation, and so never
300/// used to cut — which left `Set-Cookie: theme=dark; session=…` safe only
301/// because of the space. `d2` logs keychain, DPAPI and libsecret failures, and
302/// that is the shape they arrive in.
303///
304/// `<` and `>` are here because [`split_wrappers`] strips only the *outermost*
305/// pair, so `<string>ghu_…</string>` reached the rules as
306/// `string>ghu_…</string`, which is on no list and matches no shape. `d3`'s
307/// installers handle launchd plists, which is where that shape comes from.
308const STRUCTURAL: &[char] = &[',', ';', '{', '}', '[', ']', '<', '>', '&'];
309
310/// Whether this sink will emit a field's value rather than replacing it.
311#[must_use]
312pub fn is_field_allowed(name: &str) -> bool {
313    ALLOWED_FIELDS.binary_search(&name).is_ok()
314}
315
316/// Trims the punctuation a *key* can arrive wrapped in: [`WRAPPERS`], plus the
317/// backslash.
318///
319/// The backslash is here rather than in [`WRAPPERS`] on purpose, and the
320/// distinction is load-bearing in both directions.
321///
322/// It has to be trimmed somewhere. `tracing::error!(reason = ?err)` reaches
323/// this module through `record_debug` and `format!("{:?}")`, and `Debug` on a
324/// `String` escapes the quotes inside it — so an error whose `Debug` embeds an
325/// HTTP body arrives with its keys spelled `\"password\"`. Trimming only
326/// [`WRAPPERS`] leaves the backslash welded on, and `\"password\` is on no
327/// list. That is `d2`'s shape: a secret-store failure carrying the body it was
328/// handed.
329///
330/// It must not be trimmed unconditionally. [`split_wrappers`] runs before
331/// [`looks_like_path`], so a `\` in [`WRAPPERS`] would strip the leading
332/// `\\` that UNC detection keys on, and `\\server\share` would stop being
333/// recognised as a path. A key is the one place a backslash can never be part
334/// of the value, so it is the one place the trim is unconditional;
335/// [`trim_start_wrappers`] takes the same backslash off a *value* only when it
336/// is escaping punctuation.
337fn trim_key(key: &str) -> &str {
338    key.trim()
339        .trim_matches(|c: char| c == '\\' || WRAPPERS.contains(&c))
340}
341
342fn normalise_key(key: &str) -> String {
343    trim_key(key).to_ascii_lowercase().replace(['-', '_'], ".")
344}
345
346fn is_credential_key(key: &str) -> bool {
347    let key = normalise_key(key);
348    CREDENTIAL_KEYS.contains(&key.as_str())
349}
350
351fn is_scheme_word(word: &str) -> bool {
352    SCHEME_WORDS.contains(&word.to_ascii_lowercase().as_str())
353}
354
355/// Scrubs a string of anything that looks like a credential, an encoded JIT
356/// configuration, or a filesystem path.
357///
358/// Applied to every string this sink emits, including the ones whose field name
359/// is on [`ALLOWED_FIELDS`]. Also public so that the TUI and the CLI can put a
360/// value through the same rules before showing it, rather than inventing a
361/// second, differently wrong set.
362#[must_use]
363pub fn redact(text: &str) -> String {
364    let mut out = String::with_capacity(text.len());
365    // How many upcoming words to replace outright, because a credential key or
366    // an authentication scheme word said the value comes next.
367    let mut pending: u32 = 0;
368
369    for chunk in text.split_inclusive(char::is_whitespace) {
370        let (word, whitespace) = split_trailing_whitespace(chunk);
371
372        if word.is_empty() {
373            out.push_str(whitespace);
374            continue;
375        }
376
377        if pending > 0 {
378            pending -= 1;
379            out.push_str(REDACTION);
380            out.push_str(whitespace);
381            // A header value ends at a comma or semicolon; anything after that
382            // is the next header's name and is not a secret.
383            if word.ends_with([',', ';']) {
384                pending = 0;
385            }
386            continue;
387        }
388
389        let (rendered, follow_on) = redact_word(word);
390        out.push_str(&rendered);
391        out.push_str(whitespace);
392        pending = follow_on;
393    }
394
395    out
396}
397
398/// Splits a chunk produced by `split_inclusive` into its word and the single
399/// whitespace character that terminated it, if any.
400fn split_trailing_whitespace(chunk: &str) -> (&str, &str) {
401    match chunk.char_indices().next_back() {
402        Some((index, last)) if last.is_whitespace() => chunk.split_at(index),
403        _ => (chunk, ""),
404    }
405}
406
407/// Splits a fragment into its leading wrapping punctuation, its core, and its
408/// trailing wrapping punctuation.
409///
410/// Shared by [`redact_word`] and [`redact_core`], because both have to judge a
411/// fragment on its core while emitting it with its punctuation intact.
412///
413/// [`WRAPPERS`], and an *escaped* quote as well. Escaped text is exactly what
414/// a `Debug` rendering of a string is, and in it every quote arrives with a
415/// backslash welded on — so a value spelled `\"ghu_…\"` is wrapped in the
416/// same way `"ghu_…"` is, and none of the shape rules can see the token until
417/// the wrapper comes off. Trimming the key alone is not enough: `runner_token`
418/// is deliberately *not* on [`CREDENTIAL_KEYS`], because a name is not what
419/// makes a value a secret, so that pair is caught by the token-prefix rule
420/// reading its value or it is not caught at all.
421fn split_wrappers(fragment: &str) -> (&str, &str, &str) {
422    let leading = fragment.len() - trim_start_wrappers(fragment).len();
423    let (prefix, rest) = fragment.split_at(leading);
424    let core_len = trim_end_wrappers(rest).len();
425    let (core, suffix) = rest.split_at(core_len);
426    (prefix, core, suffix)
427}
428
429/// Trims wrapping punctuation from the front of a fragment.
430///
431/// A backslash counts as punctuation **only when it escapes punctuation**, and
432/// that restriction is the whole of what keeps this safe. `\"value` is a
433/// quoted value spelled the way an escaped rendering spells it, and the
434/// backslash is not part of the value. `\\server\share` is a UNC path whose
435/// leading backslashes escape nothing and *are* the value — trimming them is
436/// exactly what would stop [`looks_like_path`] recognising it, and this runs
437/// before [`looks_like_path`] does. `\\` is not a backslash-escaping-a-
438/// wrapper, so the two cases separate cleanly.
439fn trim_start_wrappers(fragment: &str) -> &str {
440    let mut rest = fragment;
441    loop {
442        let trimmed = rest.trim_start_matches(WRAPPERS);
443        let trimmed = match trimmed.strip_prefix('\\') {
444            Some(after) if after.starts_with(WRAPPERS) => after,
445            _ => trimmed,
446        };
447        if trimmed.len() == rest.len() {
448            return rest;
449        }
450        rest = trimmed;
451    }
452}
453
454/// Trims wrapping punctuation from the end of a fragment.
455///
456/// A trailing backslash goes with it, and needs no adjacency test: trimming
457/// runs right to left, so a backslash that has reached the end is one whose
458/// quote has already been taken off. A path that ends in a separator is still
459/// a path without it.
460fn trim_end_wrappers(fragment: &str) -> &str {
461    let mut rest = fragment;
462    loop {
463        let trimmed = rest.trim_end_matches(WRAPPERS).trim_end_matches('\\');
464        if trimmed.len() == rest.len() {
465            return rest;
466        }
467        rest = trimmed;
468    }
469}
470
471/// What a credential key left outstanding at the end of the text that named it.
472///
473/// The structural cut is flat and a credential's value does not have to be in
474/// the same fragment as the key that names it, so something has to carry the
475/// key across the cut. Nothing did, which is [`redact_core`]'s empty-value
476/// case: the comment there claimed the value would be reached "in the next
477/// fragment, where the structural cut reaches it", and no such thing happened.
478///
479/// The two variants are not the same claim, and collapsing them over-redacts:
480///
481/// - [`Carry::Expecting`] is *"a key named a value that has not appeared yet"*.
482///   It has to step over markup, because `<key>password</key><string>…</string>`
483///   puts `/key` and `string` between the key and its value.
484/// - [`Carry::Unclosed`] is *"a quoted value was redacted and its closing quote
485///   is not in this fragment"*, so the value continues past the cut. Inside an
486///   open quote `<` and `>` are literal text rather than markup, so this one
487///   must *not* step over anything.
488#[derive(Clone, Copy, PartialEq, Eq, Debug)]
489enum Carry {
490    /// Nothing outstanding.
491    None,
492    /// A credential key supplied no value of its own.
493    Expecting,
494    /// A redacted credential value was cut before its closing quote.
495    Unclosed,
496}
497
498/// Whether wrapping punctuation closes the value it followed.
499///
500/// A quote closes a string and a bracket closes a structure, so either one
501/// means the credential value ended here and a carry must stop. `,` and `;` are
502/// on the list for the reason [`redact`] already stops its word-level follow-on
503/// at them: they end a header value, and what comes after is the next header's
504/// name.
505///
506/// `>` is deliberately absent. It closes a *tag*, not a value —
507/// `<key>password</key>` ends in one with the credential's value still to come
508/// — and treating it as a terminator is what would leave the multi-word plist
509/// spelling leaking.
510fn closes_a_value(wrappers: &str) -> bool {
511    wrappers.contains(['"', '\'', '`', ',', ';', ']', '}', ')'])
512}
513
514/// Whether a value opened a quote that its own fragment did not close.
515fn opens_an_unclosed_quote(lead: &str, trail: &str) -> bool {
516    const QUOTES: [char; 3] = ['"', '\'', '`'];
517    lead.contains(QUOTES) && !trail.contains(QUOTES)
518}
519
520/// Whether a fragment sits where an element *name* goes rather than where its
521/// content does.
522///
523/// `<` and `>` are in [`STRUCTURAL`] because `<string>ghu_…</string>` reached
524/// the rules as `string>ghu_…</string` and matched nothing. Having cut on them,
525/// the loop knows which side of a tag it is on, and a [`Carry::Expecting`] must
526/// not be spent on `/key` or `string` when the value it is waiting for is the
527/// element's content. A tag name is still judged by every other rule — this
528/// says only that it is not the value a credential key named.
529fn is_tag_name(preceding: Option<char>, following: Option<char>) -> bool {
530    preceding == Some('<') && following == Some('>')
531}
532
533/// Redacts one whitespace-delimited word, and says how many following words the
534/// word implicates.
535fn redact_word(word: &str) -> (String, u32) {
536    // Wrapping punctuation is kept so that JSON-ish and prose context survives
537    // — `("ghu_…")` should become `("[redacted]")`, not `[redacted]`.
538    let (prefix, core, suffix) = split_wrappers(word);
539
540    if core.is_empty() {
541        return (word.to_string(), 0);
542    }
543
544    // A bare credential key or scheme word: the value is the next word.
545    //
546    // The stem is unwrapped before it is judged: spaced JSON writes the key as
547    // `"password":`, which leaves `password"` welded to a quote, and that is on
548    // no list. A long value survived this anyway by way of the opaque-run rule,
549    // so the gap only ever showed on a short one.
550    let stem = core.trim_end_matches([':', '=']);
551    if stem.len() < core.len() && is_credential_key(stem) {
552        // Two, so that `Authorization: Bearer <token>` loses the scheme and the
553        // token rather than only the scheme.
554        return (word.to_string(), 2);
555    }
556    if is_scheme_word(core) {
557        return (word.to_string(), 1);
558    }
559
560    let (rendered, carry) = redact_core(core, suffix);
561
562    // A value the word did not finish is the *next* word's problem, which is
563    // the follow-on rule the word level already has, reached from one level
564    // down. A pretty-printed plist is why it is needed: `<key>password</key>`
565    // and `<string>hunter2</string>` are on separate lines, so the key and its
566    // value are not even in the same word.
567    //
568    // The two carries ask for different amounts, for the same reason the stem
569    // rule above asks for two words and not one:
570    //
571    // - [`Carry::Expecting`] means the value has not been seen *at all*, so it
572    //   may be introduced by a word of its own — `<string>correct` and then
573    //   `horse</string>`. Two, exactly as `Authorization: Bearer <token>` needs
574    //   two.
575    // - [`Carry::Unclosed`] means the value has already started and was cut, so
576    //   only its remainder is outstanding. One.
577    //
578    // Trailing punctuation that closes the value withdraws the claim, for the
579    // same reason pass one declines an empty value: `{"password":""}` names a
580    // credential and supplies nothing, and redacting the next word there would
581    // report a secret where none was.
582    let follow_on = if closes_a_value(suffix) {
583        0
584    } else {
585        match carry {
586            Carry::None => 0,
587            Carry::Unclosed => 1,
588            Carry::Expecting => 2,
589        }
590    };
591    (format!("{prefix}{rendered}{suffix}"), follow_on)
592}
593
594/// Redacts one fragment of a cut-up word, judging it on its core.
595///
596/// [`redact_core`] used to recurse on the *raw* fragment, and its terminal
597/// fallback then handed that fragment to [`redact_value`] with its wrapping
598/// punctuation still attached. A fragment carrying no `:` or `=` of its own —
599/// which is exactly what an array element is — therefore reached the shape
600/// rules as `"ghu_…`, where `starts_with("ghu_")` fails, [`is_opaque_char`]
601/// fails on the quote, the `eyJ` test fails, and [`looks_like_path`] never gets
602/// a clean look at `"C:\Users\…`.
603///
604/// That is the defect the structural cut was written to close, one step further
605/// down: *"only the first key/value pair is ever examined"* became *"a value
606/// with no key of its own is never examined"*. It survived a round because the
607/// secret-injection scan had no array among its shapes, which is the same
608/// lesson in a different place — a check that cannot fail proves nothing.
609///
610/// So a fragment is treated the way [`redact_word`] treats a word: split,
611/// judge the core, put the punctuation back.
612///
613/// The *word-level* follow-on rule is still not repeated here, and the reason
614/// stands: *"the value is the next word"* is a statement about whitespace, and
615/// there is no next word inside a fragment. What that argument does not cover —
616/// and what an earlier spelling of it wrongly took itself to have settled — is
617/// the *fragment-level* claim: **the value is the next fragment**. That one is
618/// a statement about the structural cut, it is true, and nothing implemented
619/// it, which is why `{"password":["hunter2"]}` and
620/// `<key>password</key><string>hunter2</string>` went out whole. [`Carry`] is
621/// that claim, threaded through the loop in [`redact_core`].
622///
623/// `trailing` says this is the last fragment of its core, and it is what keeps
624/// the carry from escaping a word it has already been spent inside: consuming
625/// a claim in the middle of a core settles it, while consuming it at the end
626/// leaves open the possibility that the value runs on into the next word.
627fn redact_fragment(
628    fragment: &str,
629    carry: Carry,
630    tag_name: bool,
631    trailing: bool,
632) -> (String, Carry) {
633    let (prefix, core, suffix) = split_wrappers(fragment);
634    if core.is_empty() {
635        return (fragment.to_string(), carry);
636    }
637
638    let claimed = match carry {
639        Carry::None => false,
640        // Markup is not the value a credential key named.
641        Carry::Expecting => !tag_name,
642        // Inside an open quote there is no markup, only text.
643        Carry::Unclosed => true,
644    };
645
646    if claimed {
647        // Two different reasons to think the value has more to come, and one
648        // answer: it is `Unclosed` from here, never the `Expecting` it may have
649        // arrived as, because the value has now started.
650        //
651        // - `carry == Unclosed` — still inside an open quote, so the value runs
652        //   into the next fragment as well.
653        // - `trailing` — a claim spent on the *last* fragment of a core is one
654        //   whose value reached the end of the word with nothing closing it, so
655        //   it may continue into the next word.
656        //   `<key>password</key><string>` + `correct horse</string>` is that
657        //   shape.
658        //
659        // Anything else was spent in the middle of a core, where the value was
660        // and is done — and punctuation that closes a string or a structure
661        // settles it either way.
662        let next = if !closes_a_value(suffix) && (carry == Carry::Unclosed || trailing) {
663            Carry::Unclosed
664        } else {
665            Carry::None
666        };
667        return (format!("{prefix}{REDACTION}{suffix}"), next);
668    }
669
670    let (rendered, next) = redact_core(core, suffix);
671    let next = if closes_a_value(suffix) {
672        Carry::None
673    } else if next == Carry::None {
674        // **An unclaimed fragment preserves the claim rather than clearing
675        // it.** This is the whole of what lets a plist work: `/key` and
676        // `string` sit between `<key>password</key>` and the `<string>` content
677        // that is the value, they are tag names so they do not spend the claim,
678        // and a fragment that neither spends nor arms one must leave it exactly
679        // as it found it. Overwriting with this fragment's own (empty) result
680        // is how `<key>password</key><string>hunter2</string>` kept leaking
681        // after the carry existed.
682        carry
683    } else {
684        next
685    };
686    (format!("{prefix}{rendered}{suffix}"), next)
687}
688
689/// Redacts one word with its wrapping punctuation already removed, and reports
690/// what it left outstanding for whatever follows it.
691///
692/// `closing` is the wrapping punctuation the caller stripped off the end of
693/// this core. It is not emitted here — the caller puts it back — but the loop
694/// below has to see it, because the character that follows the *last* fragment
695/// is what says whether that fragment is an element name or an element's
696/// content.
697fn redact_core(core: &str, closing: &str) -> (String, Carry) {
698    // A URL survives, minus everything on it that carries a credential. The
699    // scheme, host and path are what makes a log line diagnosable.
700    //
701    // The URL is *cut out* of the text around it rather than being allowed to
702    // own the rest of the fragment, and both sides of the cut come back through
703    // here. [`split_url`] documents what the old unbounded `split_once("://")`
704    // cost. This still runs ahead of the structural cut, because a query string
705    // is `redact_url`'s to own: `?` and `#` are not structural characters, and
706    // an OAuth implicit-flow response puts the token after one of them.
707    //
708    // **The tail is iterated, not recursed into, and that is a memory-safety
709    // property rather than a matter of taste.** An earlier spelling called
710    // `redact_core` on the remainder, and argued only that the recursion
711    // *terminates*: every call is on a strictly shorter slice, which is true
712    // and is not enough. Termination says nothing about **depth**, and depth
713    // was linear in the length of the input — one frame per URL. A message of
714    // ~2000 `{"url":"https://…"},` items, about 86 KB, exited
715    // `0xc00000fd STATUS_STACK_OVERFLOW`.
716    //
717    // A stack overflow is not a `panic`: it is not catchable, it does not
718    // unwind, and it takes the process with it. A large HTTP error body is
719    // content an attacker can influence, so that was a way to kill the agent
720    // from inside its own log sink — and a sink that is not running redacts
721    // nothing at all, which is worse than any single leak.
722    //
723    // With the loop, every remaining re-entry is depth-bounded by construction
724    // rather than by argument:
725    //
726    // - `prefix` holds everything before the *first* `://`, so it contains no
727    //   `://` and cannot reach this branch again.
728    // - `rest` is what is left when the loop finds no further `://`, for the
729    //   same reason.
730    // - the structural branch below calls [`redact_fragment`], whose fragments
731    //   contain no structural character by construction and so cannot reach it
732    //   again either.
733    //
734    // Three levels, whatever the input is. Anything added here that recurses on
735    // a slice whose length is not bounded by a constant re-opens this.
736    if let Some((prefix, scheme, url, remainder)) = split_url(core) {
737        let mut out = String::with_capacity(core.len());
738        if !prefix.is_empty() {
739            out.push_str(&redact_core(prefix, "").0);
740        }
741        out.push_str(&redact_url(scheme, url));
742
743        let mut rest = remainder;
744        while let Some((prefix, scheme, url, remainder)) = split_url(rest) {
745            if !prefix.is_empty() {
746                out.push_str(&redact_core(prefix, "").0);
747            }
748            out.push_str(&redact_url(scheme, url));
749            rest = remainder;
750        }
751        if !rest.is_empty() {
752            out.push_str(&redact_core(rest, "").0);
753        }
754
755        // A URL is a complete value: it carries its own credentials in its own
756        // places, and `redact_url` has already dealt with them. Nothing is
757        // outstanding, which is also what keeps `{"token":"https://…"}` from
758        // arming a claim against whatever follows the URL.
759        return (out, Carry::None);
760    }
761
762    // A Windows drive path is `key:value`-shaped by accident, and
763    // `looks_like_path` only recognises the drive letter at position zero.
764    // Splitting such a path on its colon would hand the tail to a rule that
765    // matches nothing, so it has to be judged whole, before the separators
766    // get at it.
767    if looks_like_path(core) {
768        return (PATH_REDACTION.to_string(), Carry::None);
769    }
770
771    // A compact structure holds more than one key/value pair, and the rules
772    // below examine exactly one of them: `split_once` stops at the first
773    // separator it finds. So `{"encoded_jit_config":"…"}` was caught and
774    // `{"runner_id":42,"encoded_jit_config":"…"}` was not — the two differ by
775    // field order and by nothing else, and `serde_json::to_string` is what
776    // chooses the order. Nesting was the same defect: the value recursion
777    // below ends at `redact_value`, which is a leaf and never comes back
778    // here, so an object inside an object went out whole. A form-encoded body
779    // was the same defect again, with `&` as a separator nothing knew.
780    //
781    // Cutting on [`STRUCTURAL`] first turns all three into the shape the rules
782    // already handle, and does it at any depth: the cut is flat, so
783    // `{"a":{"b":{"token":"…"}}}` yields the same fragments a one-level object
784    // would.
785    //
786    // Each fragment goes through [`redact_fragment`] rather than straight back
787    // in here, because a fragment carries its own wrapping punctuation and a
788    // value judged with its quote attached matches nothing at all.
789    //
790    // The recursion terminates *and is depth-bounded*: a fragment contains no
791    // structural character by construction, so `redact_fragment` cannot reach
792    // this branch again, and the URL branch above iterates over its tail rather
793    // than recursing into it. The loop here is a loop for the same reason — a
794    // fragment per separator, at whatever length the input has.
795    //
796    // A [`Carry`] is threaded along it because the cut is flat and a
797    // credential's value need not be in the same fragment as its key. Without
798    // it the empty-value skip in pass one was a promise nothing kept: the value
799    // was said to be reachable "in the next fragment", and no next fragment
800    // ever heard about the key.
801    if core.contains(STRUCTURAL) {
802        let mut out = String::with_capacity(core.len());
803        let mut carry = Carry::None;
804        let mut rest = core;
805        // The separator on each side of a fragment is what says whether it is
806        // an element name or an element's content.
807        let mut preceding: Option<char> = None;
808        while let Some(index) = rest.find(STRUCTURAL) {
809            let (fragment, tail) = rest.split_at(index);
810            // Every character in `STRUCTURAL` is ASCII, so the separator is
811            // one byte, this cannot split a code point, and the byte is the
812            // whole character.
813            let separator = char::from(tail.as_bytes()[0]);
814            if !fragment.is_empty() {
815                let (rendered, next) = redact_fragment(
816                    fragment,
817                    carry,
818                    is_tag_name(preceding, Some(separator)),
819                    false,
820                );
821                out.push_str(&rendered);
822                carry = next;
823            }
824            out.push_str(&tail[..1]);
825            preceding = Some(separator);
826            rest = &tail[1..];
827        }
828        if !rest.is_empty() {
829            // The last fragment's *following* character is the caller's closing
830            // punctuation: in `<key>password</key>` the final `/key` is a tag
831            // name only because the `>` that ends it was stripped as a wrapper
832            // before this ran.
833            let following = closing.chars().next();
834            let (rendered, next) =
835                redact_fragment(rest, carry, is_tag_name(preceding, following), true);
836            out.push_str(&rendered);
837            carry = next;
838        }
839        return (out, carry);
840    }
841
842    // `key=value` and `key:value` in a single fragment.
843    //
844    // Pass one asks only whether either separator names a credential, and it
845    // runs to completion before any value is inspected, because the *first*
846    // separator in a fragment is not necessarily the one that names the key.
847    //
848    // The witness is a webhook signature header in its compact-JSON spelling,
849    // `{"x-hub-signature-256":"sha256=<hmac>"}`, and
850    // `a_credential_header_loses_its_scheme_and_its_value` holds it. The `=`
851    // comes first and names nothing, but what follows it is a whole HMAC —
852    // *non-empty*, so a merged pass inspects that value, renders it as a
853    // digest prefix, and returns without ever reaching the `:` that names
854    // `x-hub-signature-256`. `{"password":"a=b"}` is the same witness with
855    // nothing else going on: merged, it comes out whole.
856    //
857    // Two earlier spellings of this comment named
858    // `{"encoded_jit_config":"eyJ…In0="}` and then
859    // `{"encoded_jit_config":"eyJ…In0=","runner_id":42}`, and neither
860    // demonstrates the invariant: base64 padding is trailing-only, so the `=`
861    // split yields a value that `split_wrappers` trims to empty, and the
862    // `value.is_empty()` guard in pass two falls through to the `:` anyway. A
863    // comment naming a case that does not demonstrate its own invariant is how
864    // the invariant gets deleted by the next person, so the witness above is
865    // one that reds a named test when the two passes are merged.
866    //
867    // Both halves are judged through `trim_key`: compact JSON welds a quote to
868    // each, so the key arrives as `encoded_jit_config"`, and a `Debug`
869    // rendering welds a backslash as well.
870    // Whether pass one saw a credential key and was given nothing to redact.
871    let mut names_a_credential = false;
872
873    for separator in ['=', ':'] {
874        if let Some((key, raw_value)) = core.split_once(separator)
875            && is_credential_key(key)
876        {
877            // The value's own wrapping punctuation goes back, exactly as pass
878            // two puts it back. Dropping it emitted `{"password":[redacted]"}`
879            // — an unbalanced quote in a line this module argues, correctly,
880            // has to stay diagnosable, and a reader who cannot parse the line
881            // cannot tell a redaction from a truncation.
882            let (lead, value, trail) = split_wrappers(raw_value);
883
884            // An empty value is nothing to redact, and pass two declines one
885            // for the same reason. Claiming `[redacted]` here says a secret was
886            // somewhere none was, which is a false signal in the one log a
887            // reader consults to find out whether anything leaked.
888            //
889            // It is also load-bearing for the URL branch above, which sends
890            // the text before a URL back through here: `{"token":"https://…"}`
891            // leaves this pass a key of `token` and a value of nothing, and
892            // redacting that emitted `token":"[redacted]https://…` — the URL
893            // still standing behind the redaction meant to have replaced it.
894            //
895            // **What it is not is a reason to forget the key.** An earlier
896            // spelling of this comment said the value would be found "in the
897            // next fragment or the next word, where the structural cut and the
898            // follow-on rule reach it", and half of that was false: the
899            // follow-on rule does reach the next *word*, and nothing whatever
900            // reached the next *fragment*, because `redact_fragment`
901            // deliberately carries no follow-on. So `{"password":["hunter2"]}`,
902            // `{"password":{"v":"hunter2"}}` and `Password=;hunter2` — a
903            // credential key with its value one fragment away, which is what an
904            // array, a nested object and an empty pair all are — went out
905            // whole, with the key sitting in plain sight next to them.
906            //
907            // That is the failure this module warns about two comments down: a
908            // comment naming a case that does not demonstrate its own
909            // invariant. The claim is now true because [`Carry::Expecting`]
910            // implements it, and `the_fragment_carry_stops_where_the_value_does`
911            // holds both halves — that the claim is made, and that it is
912            // withdrawn where the value visibly ended.
913            if value.is_empty() {
914                names_a_credential = true;
915                continue;
916            }
917
918            // A quoted value whose closing quote is not in this fragment is a
919            // value the cut ran through the middle of: `{"password":"p&ss"}`
920            // reaches here as `password":"p`, and `ss` is the rest of the
921            // secret. Adding `;`, `<` and `>` to `STRUCTURAL` this round made
922            // three more characters able to do that, and punctuated passwords
923            // are ordinary.
924            let carry = if opens_an_unclosed_quote(lead, trail) {
925                Carry::Unclosed
926            } else {
927                Carry::None
928            };
929            return (format!("{key}{separator}{lead}{REDACTION}{trail}"), carry);
930        }
931    }
932
933    // Pass two: not a credential key, but the value may still be a path or a
934    // token: `runtime=/var/lib/runner-manager/…`. Applied to `:` as well as
935    // to `=`, because `:` is what compact JSON and a bare `key:value` use,
936    // and recursing for `=` alone is what let an encoded JIT configuration
937    // and a `runner_token:ghu_…` pair through verbatim.
938    for separator in ['=', ':'] {
939        if let Some((key, raw_value)) = core.split_once(separator) {
940            let (lead, value, trail) = split_wrappers(raw_value);
941            if value.is_empty() {
942                continue;
943            }
944            let redacted = redact_value(value);
945            // `as_sha256_digest` re-attaches its own `sha256:` label, so an
946            // already-labelled digest would come back doubled as
947            // `sha256:sha256:9f86d081884c…`. Dropping the redundant label
948            // keeps the caller's own key and separator, and leaves the digest
949            // truncated -- which the unrecursed `:` path never did, and which
950            // is worth having, because an HMAC-SHA256 signature has exactly a
951            // digest's shape and was previously printed in full.
952            if trim_key(key).eq_ignore_ascii_case("sha256")
953                && let Some(bare) = redacted.strip_prefix("sha256:")
954            {
955                return (format!("{key}{separator}{lead}{bare}{trail}"), Carry::None);
956            }
957            return (
958                format!("{key}{separator}{lead}{redacted}{trail}"),
959                Carry::None,
960            );
961        }
962    }
963
964    // A credential key with no value at all — either `password:` with nothing
965    // after it, or a bare `password` between a plist's tags. Both name a value
966    // that is somewhere else, and the caller is the only one who can see where.
967    let carry = if names_a_credential || is_credential_key(core) {
968        Carry::Expecting
969    } else {
970        Carry::None
971    };
972    (redact_value(core), carry)
973}
974
975/// Characters a URL scheme is made of: RFC 3986 allows a letter followed by
976/// letters, digits, `+`, `-` and `.`.
977fn is_scheme_byte(byte: u8) -> bool {
978    byte.is_ascii_alphanumeric() || matches!(byte, b'+' | b'-' | b'.')
979}
980
981/// Characters that cannot appear inside a URL, and therefore end one.
982///
983/// `\` is deliberately absent, even though it cannot appear in a URL either.
984/// The shape it would have been there for is a `Debug`-escaped body, which
985/// spells its quotes `\"` — and the quote already ends the URL. Making the
986/// backslash a terminator as well costs a real redaction: a Windows path used
987/// as a URL password, `https://x-access-token:C:\Users\…@github.com/o/r.git`,
988/// would then end its URL at the first `\`, which puts the `@` that identifies
989/// the userinfo *outside* the span, and [`redact_url`] echoes what it is given.
990/// Measured: with `\` a terminator, that line emits the path verbatim; without
991/// it, the userinfo is replaced as it should be.
992fn is_url_terminator(c: char) -> bool {
993    c.is_whitespace() || matches!(c, '"' | ',' | '{' | '}' | '[' | ']' | '<' | '>')
994}
995
996/// The same, plus the two [`STRUCTURAL`] characters that end a URL only *ahead
997/// of its query string*.
998///
999/// [`STRUCTURAL`] has nine characters and [`is_url_terminator`] recognises seven
1000/// of them; `;` and `&` were missing. Because the URL branch runs ahead of the
1001/// structural cut, a URL earlier in a word made [`split_url`] swallow
1002/// everything to the next terminator — including the separator that would have
1003/// cut the word — and [`redact_url`]'s terminal arm echoes what it is given. So
1004/// `Server=https://vault.local/api;Password=hunter2` and
1005/// `cb=https://a.com/x&access_token=ghu_…` went out whole: the fix that bounds
1006/// a URL and the fix that cuts on `;` and `&` each worked alone and did not
1007/// compose.
1008///
1009/// **Adding them to [`is_url_terminator`] outright is the obvious fix and the
1010/// wrong one**, which is why there are two predicates rather than one extended
1011/// list. `&` is what separates the parameters *inside* a query string, and
1012/// [`redact_url`] replaces a query wholesale precisely because a token can be
1013/// in any parameter and this module does not guess which. Measured:
1014/// `https://a.com/cb?code=1&state=hunter2` goes from `?[redacted]` to
1015/// `?[redacted]&state=hunter2`. `;` is the same story — it is a legal query
1016/// separator too.
1017///
1018/// Ahead of the `?` there is no such conflict: a `;` or an `&` in an authority
1019/// or a path is not URL syntax this module needs to keep, and something else in
1020/// the word is much the likelier reading.
1021fn is_url_head_terminator(c: char) -> bool {
1022    is_url_terminator(c) || matches!(c, ';' | '&')
1023}
1024
1025/// Finds the first URL in a fragment and cuts it out of the text around it.
1026///
1027/// Returns the text before the URL, its scheme, the URL itself with the `://`
1028/// removed, and the text after it — or `None` when the fragment holds no URL.
1029///
1030/// **Both bounds are the point.** `split_once("://")` treated *everything*
1031/// before the separator as the scheme and everything after it as the URL, and
1032/// the terminal arm of [`redact_url`] echoes both verbatim — so a single URL
1033/// anywhere in a word put the whole of the rest of that word beyond every rule
1034/// below it. `documentation_url` is in essentially every GitHub REST error
1035/// body, so that was *any* such body logged alongside a credential:
1036///
1037/// ```text
1038/// {"documentation_url":"https://docs.github.com/rest","token":"ghu_…"}
1039/// ```
1040///
1041/// came out intact. The reverse order leaked for the mirror reason —
1042/// everything before the `://` became the "scheme" — and a nested object behind
1043/// a URL was missed even when the URL's own userinfo was caught, because the
1044/// miss and the catch happened in the same call. The only thing that saved the
1045/// shape at all was a `?` or `#` *inside* the URL, which made [`redact_url`]
1046/// replace the tail.
1047///
1048/// An empty scheme run means the `://` is not introducing a URL, and the
1049/// fragment is left to the rules below rather than handed over as a URL with no
1050/// scheme.
1051fn split_url(fragment: &str) -> Option<(&str, &str, &str, &str)> {
1052    let separator = fragment.find("://")?;
1053    let before = &fragment[..separator];
1054
1055    // Scheme characters are ASCII, so a byte count is a character boundary.
1056    let scheme_len = before
1057        .bytes()
1058        .rev()
1059        .take_while(|byte| is_scheme_byte(*byte))
1060        .count();
1061    if scheme_len == 0 {
1062        return None;
1063    }
1064    let (prefix, scheme) = before.split_at(before.len() - scheme_len);
1065
1066    let after = &fragment[separator + "://".len()..];
1067
1068    // Where the URL ends no matter what, and **the bound every other search
1069    // here is taken inside**. Searching the rest of the fragment first is a
1070    // correct answer computed quadratically: a body of *n* URLs would scan the
1071    // whole remaining text once per URL looking for a `?` that is not there,
1072    // which on the 860 KB regression case in
1073    // `a_large_message_does_not_overflow_the_stack` is 17 billion character
1074    // comparisons. That test is a guard against this module taking the process
1075    // down, and a redaction pass that never returns takes it down just as
1076    // effectively as an overflow does.
1077    let end = after.find(is_url_terminator).unwrap_or(after.len());
1078    let url = &after[..end];
1079
1080    // The head is the authority and path — everything ahead of the first `?` or
1081    // `#`. Only there do `;` and `&` end the URL; inside a query string they
1082    // are query syntax, and the query is `redact_url`'s to replace wholesale.
1083    let query = url.find(['?', '#']).unwrap_or(url.len());
1084    let end = url[..query].find(is_url_head_terminator).unwrap_or(end);
1085    let (url, remainder) = after.split_at(end);
1086
1087    Some((prefix, scheme, url, remainder))
1088}
1089
1090/// Redacts the three places a URL can carry a credential, keeping the rest.
1091///
1092/// `scheme` is everything before `://` and `rest` everything after it.
1093///
1094/// The **userinfo** is the one this module used to miss, and it is not an
1095/// exotic shape: `https://x-access-token:ghu_…@github.com/owner/repo.git` is
1096/// the canonical token-authenticated git remote, so it is what `e2` and `e3`
1097/// will have in hand when a clone or a download fails and they log the error
1098/// they were given. The old `://` branch returned before the token-prefix and
1099/// opaque-run rules could run, so that URL came out intact.
1100///
1101/// A **fragment** is stripped for the same reason a query string is: an OAuth
1102/// implicit-flow response puts the token after the `#`, and the fragment is
1103/// never load-bearing for diagnosing an HTTP call.
1104fn redact_url(scheme: &str, rest: &str) -> String {
1105    // The authority runs to the first `/`, `?` or `#`.
1106    let authority_end = rest.find(['/', '?', '#']).unwrap_or(rest.len());
1107    let (authority, tail) = rest.split_at(authority_end);
1108
1109    // `rsplit_once`, not `split_once`: a password may itself contain an `@`,
1110    // and the host is what follows the *last* one.
1111    let host = match authority.rsplit_once('@') {
1112        // Replaced rather than deleted. That the request carried credentials at
1113        // all is diagnostic — it is often the answer to "why did this 401?" —
1114        // and it is the credential, not its existence, that must not be here.
1115        Some((_userinfo, host)) => format!("{REDACTION}@{host}"),
1116        None => authority.to_string(),
1117    };
1118
1119    // Whichever of `?` and `#` comes first ends the diagnosable part; anything
1120    // after it is replaced wholesale rather than parsed, because a token can be
1121    // in any parameter and this module does not guess which.
1122    match tail.find(['?', '#']) {
1123        Some(cut) => {
1124            let (path, query) = tail.split_at(cut);
1125            let separator = &query[..1];
1126            format!(
1127                "{scheme}://{host}{}{separator}{REDACTION}",
1128                redact_path(path)
1129            )
1130        }
1131        None => format!("{scheme}://{host}{}", redact_path(tail)),
1132    }
1133}
1134
1135/// Applies the shape rules to a URL path, one segment at a time.
1136///
1137/// The path is the diagnosable part of a URL and stays that way: a segment is
1138/// judged on its own, and an ordinary one — `repos`, `owner`, `actions-runner-
1139/// linux-x64-2.330.0.tar.gz` — is not a secret and is not touched. What this
1140/// closes is that [`redact_url`] previously applied *no* rule to a path at all,
1141/// so `https://github.com/o/r/raw/ghu_…/f` was echoed whole while the identical
1142/// token one character to the left of the `/` would have been replaced. The
1143/// token-prefix rule is documented as a belt that catches a credential
1144/// *anywhere*, and a path was the one place it never ran.
1145///
1146/// Segment at a time rather than whole, because [`redact_value`] would
1147/// otherwise see the `/` and hand the whole path to [`looks_like_path`], which
1148/// is exactly the over-redaction the module documentation promises a full URL
1149/// escapes.
1150///
1151/// Worth knowing: [`OPAQUE_RUN_THRESHOLD`] is 40, and a git object name written
1152/// as hex is exactly 40 characters, so a `…/raw/<sha1>/f` URL loses its commit
1153/// to `[redacted]`. That is the module's standing trade — a less readable line
1154/// against a disclosed credential — and it is called out here because a commit
1155/// SHA is the one path segment somebody may miss.
1156fn redact_path(path: &str) -> String {
1157    let mut out = String::with_capacity(path.len());
1158    for (index, segment) in path.split('/').enumerate() {
1159        if index > 0 {
1160            out.push('/');
1161        }
1162        if !segment.is_empty() {
1163            out.push_str(&redact_value(segment));
1164        }
1165    }
1166    out
1167}
1168
1169/// The shape rules, applied to a bare value.
1170fn redact_value(value: &str) -> String {
1171    let lower = value.to_ascii_lowercase();
1172    if TOKEN_PREFIXES
1173        .iter()
1174        .any(|prefix| lower.starts_with(prefix))
1175    {
1176        return REDACTION.to_string();
1177    }
1178
1179    if looks_like_path(value) {
1180        return PATH_REDACTION.to_string();
1181    }
1182
1183    // A digest is not a secret, and `07-security.md` makes checksum
1184    // verification a security gate. The most useful thing `e2` can write when
1185    // that gate fails is expected-versus-actual, and until this carve-out
1186    // existed both sides came out as `[redacted]` — a gate that reports it
1187    // failed and refuses to say how.
1188    if let Some(digest) = as_sha256_digest(value) {
1189        return digest;
1190    }
1191
1192    if looks_like_jwt(value) {
1193        return REDACTION.to_string();
1194    }
1195
1196    if value.len() >= OPAQUE_RUN_THRESHOLD && value.chars().all(is_opaque_char) {
1197        return REDACTION.to_string();
1198    }
1199
1200    value.to_string()
1201}
1202
1203/// Fields whose values are this product's own closed vocabulary: `snake_case`
1204/// constants such as `late_ephemeral_workspace_could_not_be_removed`.
1205const CLOSED_VOCABULARY_FIELDS: &[&str] = &["kind", "reason"];
1206
1207/// Whether a closed-vocabulary field's value is emitted as it is.
1208///
1209/// The long `reason` constants are over [`OPAQUE_RUN_THRESHOLD`], so the
1210/// opaque-run rule redacted the very field that says what went wrong. Exempt
1211/// only in these fields, never in a message or any other field: there a long
1212/// run is still treated as a secret. Within them, a lowercase `snake_case`
1213/// identifier that does not start with a token prefix is not something a
1214/// credential or a key generator produces.
1215fn is_closed_vocabulary(name: &str, value: &str) -> bool {
1216    CLOSED_VOCABULARY_FIELDS.contains(&name)
1217        && !TOKEN_PREFIXES
1218            .iter()
1219            .any(|prefix| value.starts_with(prefix))
1220        && is_snake_case_identifier(value)
1221}
1222
1223/// Whether a value is lowercase words joined by single underscores.
1224fn is_snake_case_identifier(value: &str) -> bool {
1225    value.contains('_')
1226        && value
1227            .split('_')
1228            .all(|word| !word.is_empty() && word.chars().all(|c| c.is_ascii_lowercase()))
1229}
1230
1231/// The length of a SHA-256 digest written as lowercase hex.
1232const SHA256_HEX_LEN: usize = 64;
1233
1234/// How much of a digest is shown.
1235///
1236/// 12 is the short-digest convention git and the OCI tooling use: 48 bits, far
1237/// more than enough to tell an expected digest from the one that was actually
1238/// computed, which is the only comparison a checksum failure needs. Truncating
1239/// is also what makes this carve-out safe to have at all — a 64-character
1240/// lowercase hex run is *usually* a digest, but an HMAC-SHA256 signature has
1241/// the same shape, and 12 of its 64 characters are of no use to anybody.
1242const DIGEST_PREFIX_LEN: usize = 12;
1243
1244/// Renders a value that is exactly a lowercase SHA-256 digest as a labelled
1245/// prefix, or `None` when it is not one.
1246///
1247/// Deliberately strict: exactly 64 characters, and lowercase only. An uppercase
1248/// or mixed-case run falls through to the opaque-run rule and is redacted,
1249/// because the narrower this exception is, the less there is to reason about.
1250fn as_sha256_digest(value: &str) -> Option<String> {
1251    let is_digest = value.len() == SHA256_HEX_LEN
1252        && value
1253            .chars()
1254            .all(|c| c.is_ascii_digit() || c.is_ascii_lowercase() && c.is_ascii_hexdigit());
1255
1256    is_digest.then(|| format!("sha256:{}…", &value[..DIGEST_PREFIX_LEN]))
1257}
1258
1259/// The most `.`-separated segments a JSON Web Token has: header, payload,
1260/// signature.
1261const JWT_SEGMENTS: usize = 3;
1262
1263/// Whether a value is a JSON Web Token.
1264///
1265/// The opaque-run rule cannot see one. [`is_opaque_char`] excludes `.`, and a
1266/// JWT is base64url runs joined by two of them, so a 100-character credential
1267/// arrives as three runs that are each under the threshold and prints
1268/// verbatim. `Authorization: Bearer <jwt>` is caught by the scheme-word rule
1269/// and a credential-keyed one by the key rule, so this only ever bit a token
1270/// logged bare or under a name nobody listed — which is what a GitHub App
1271/// installation assertion is when it reaches a log at all.
1272///
1273/// Narrow deliberately, because the obvious fix is the wrong one: adding `.`
1274/// to [`is_opaque_char`] would swallow every long dotted word there is —
1275/// package names, dated filenames, dotted identifiers. A JWT header is
1276/// base64url of `{"alg":…`, which always begins `eyJ`, and that is a
1277/// discriminator ordinary text does not have.
1278fn looks_like_jwt(value: &str) -> bool {
1279    if value.len() < OPAQUE_RUN_THRESHOLD || !value.starts_with("eyJ") {
1280        return false;
1281    }
1282
1283    let mut segments = 0usize;
1284    for segment in value.split('.') {
1285        segments += 1;
1286        if !segment.chars().all(is_opaque_char) {
1287            return false;
1288        }
1289    }
1290
1291    // Two segments as well as three: an unsigned token has an empty signature,
1292    // and the trailing `.` that would have made the third is trimmed as
1293    // wrapping punctuation before this is reached.
1294    (2..=JWT_SEGMENTS).contains(&segments)
1295}
1296
1297fn looks_like_path(value: &str) -> bool {
1298    let bytes = value.as_bytes();
1299
1300    // `C:\Users\…` and `C:/Users/…`.
1301    if bytes.len() >= 3
1302        && bytes[0].is_ascii_alphabetic()
1303        && bytes[1] == b':'
1304        && (bytes[2] == b'\\' || bytes[2] == b'/')
1305    {
1306        return true;
1307    }
1308
1309    // `\\server\share\…`.
1310    if value.starts_with("\\\\") {
1311        return true;
1312    }
1313
1314    // `~/…`.
1315    if value.starts_with("~/") || value.starts_with("~\\") {
1316        return true;
1317    }
1318
1319    // `/var/lib/…`. Two segments required, so a lone `/` and a bare `/tmp`
1320    // stay readable; see the module documentation for why a URL path is caught
1321    // by this too.
1322    if let Some(rest) = value.strip_prefix('/') {
1323        return rest.contains('/') && !rest.is_empty();
1324    }
1325
1326    false
1327}
1328
1329// ---------------------------------------------------------------------------
1330// The tracing layer
1331// ---------------------------------------------------------------------------
1332
1333/// The redacted fields recorded on a span, stashed in the span's extensions so
1334/// that an event inside the span can carry them.
1335#[derive(Debug, Clone)]
1336struct SpanFields(Map<String, Value>);
1337
1338/// Collects a `tracing` record into JSON, applying the allowlist and the
1339/// scrubber as it goes.
1340#[derive(Debug, Default)]
1341struct RedactingVisitor {
1342    fields: Map<String, Value>,
1343}
1344
1345impl RedactingVisitor {
1346    fn put_str(&mut self, name: &str, value: &str) {
1347        let rendered = if !is_field_allowed(name) {
1348            REDACTION.to_string()
1349        } else if is_closed_vocabulary(name, value) {
1350            value.to_string()
1351        } else {
1352            redact(value)
1353        };
1354        self.fields
1355            .insert(name.to_string(), Value::String(rendered));
1356    }
1357
1358    fn put_value(&mut self, name: &str, value: Value) {
1359        if is_field_allowed(name) {
1360            self.fields.insert(name.to_string(), value);
1361        } else {
1362            // Numbers and booleans go the same way as strings. A credential is
1363            // never an `i64`, but the rule that makes this sink trustworthy is
1364            // that it has *no* exception for a name nobody listed.
1365            self.fields
1366                .insert(name.to_string(), Value::String(REDACTION.to_string()));
1367        }
1368    }
1369}
1370
1371impl Visit for RedactingVisitor {
1372    fn record_str(&mut self, field: &Field, value: &str) {
1373        self.put_str(field.name(), value);
1374    }
1375
1376    fn record_debug(&mut self, field: &Field, value: &dyn fmt::Debug) {
1377        // The event message arrives here, as `format_args!` output.
1378        self.put_str(field.name(), &format!("{value:?}"));
1379    }
1380
1381    fn record_error(&mut self, field: &Field, value: &(dyn std::error::Error + 'static)) {
1382        self.put_str(field.name(), &value.to_string());
1383    }
1384
1385    fn record_bool(&mut self, field: &Field, value: bool) {
1386        self.put_value(field.name(), Value::Bool(value));
1387    }
1388
1389    fn record_i64(&mut self, field: &Field, value: i64) {
1390        self.put_value(field.name(), Value::from(value));
1391    }
1392
1393    fn record_u64(&mut self, field: &Field, value: u64) {
1394        self.put_value(field.name(), Value::from(value));
1395    }
1396
1397    fn record_f64(&mut self, field: &Field, value: f64) {
1398        self.put_value(field.name(), Value::from(value));
1399    }
1400
1401    fn record_i128(&mut self, field: &Field, value: i128) {
1402        self.put_str(field.name(), &value.to_string());
1403    }
1404
1405    fn record_u128(&mut self, field: &Field, value: u128) {
1406        self.put_str(field.name(), &value.to_string());
1407    }
1408}
1409
1410/// A `tracing` layer that writes one redacted JSON object per event.
1411///
1412/// Written here rather than assembled from `tracing_subscriber::fmt` because
1413/// redaction has to happen *before* formatting, and a `tracing::Event`'s fields
1414/// cannot be rewritten for a downstream formatter to pick up. Owning the
1415/// formatting is what makes "unconditional" true: there is no path from an
1416/// event to the output that does not go through
1417/// [`RedactingVisitor`].
1418#[derive(Debug, Clone)]
1419pub struct RedactingLayer<W> {
1420    writer: W,
1421}
1422
1423impl<W> RedactingLayer<W> {
1424    /// Wraps a writer factory — a file appender, a capture buffer, or
1425    /// `std::io::stderr`.
1426    pub const fn new(writer: W) -> Self {
1427        Self { writer }
1428    }
1429}
1430
1431impl<S, W> Layer<S> for RedactingLayer<W>
1432where
1433    S: Subscriber + for<'a> LookupSpan<'a>,
1434    W: for<'a> tracing_subscriber::fmt::MakeWriter<'a> + 'static,
1435{
1436    fn on_new_span(
1437        &self,
1438        attrs: &tracing::span::Attributes<'_>,
1439        id: &tracing::span::Id,
1440        ctx: Context<'_, S>,
1441    ) {
1442        let mut visitor = RedactingVisitor::default();
1443        attrs.record(&mut visitor);
1444        if let Some(span) = ctx.span(id) {
1445            span.extensions_mut().insert(SpanFields(visitor.fields));
1446        }
1447    }
1448
1449    fn on_record(
1450        &self,
1451        id: &tracing::span::Id,
1452        values: &tracing::span::Record<'_>,
1453        ctx: Context<'_, S>,
1454    ) {
1455        let mut visitor = RedactingVisitor::default();
1456        values.record(&mut visitor);
1457        if let Some(span) = ctx.span(id) {
1458            let mut extensions = span.extensions_mut();
1459            if let Some(existing) = extensions.get_mut::<SpanFields>() {
1460                existing.0.extend(visitor.fields);
1461            } else {
1462                extensions.insert(SpanFields(visitor.fields));
1463            }
1464        }
1465    }
1466
1467    fn on_event(&self, event: &Event<'_>, ctx: Context<'_, S>) {
1468        let mut visitor = RedactingVisitor::default();
1469        event.record(&mut visitor);
1470
1471        let mut record = Map::new();
1472        record.insert(
1473            "timestamp".to_string(),
1474            Value::String(chrono::Utc::now().to_rfc3339()),
1475        );
1476        record.insert(
1477            "level".to_string(),
1478            Value::String(event.metadata().level().to_string()),
1479        );
1480        // Named `logger` and not `target`: `target` is also a domain field —
1481        // the repository or organization a policy points at — and two different
1482        // things under one key is how a diagnostic becomes a puzzle.
1483        record.insert(
1484            "logger".to_string(),
1485            Value::String(event.metadata().target().to_string()),
1486        );
1487        record.insert("fields".to_string(), Value::Object(visitor.fields));
1488
1489        let spans: Vec<Value> = ctx
1490            .event_scope(event)
1491            .into_iter()
1492            .flat_map(tracing_subscriber::registry::Scope::from_root)
1493            .map(|span| {
1494                let mut entry = Map::new();
1495                entry.insert("name".to_string(), Value::String(span.name().to_string()));
1496                if let Some(fields) = span.extensions().get::<SpanFields>() {
1497                    entry.insert("fields".to_string(), Value::Object(fields.0.clone()));
1498                }
1499                Value::Object(entry)
1500            })
1501            .collect();
1502        if !spans.is_empty() {
1503            record.insert("spans".to_string(), Value::Array(spans));
1504        }
1505
1506        let mut line = serde_json::to_string(&Value::Object(record)).unwrap_or_else(|_| {
1507            String::from(
1508                r#"{"level":"ERROR","fields":{"message":"a log record could not be encoded"}}"#,
1509            )
1510        });
1511        line.push('\n');
1512
1513        // A failed write to the diagnostics file must not take the agent down,
1514        // and must not be reported through `tracing` either — that would
1515        // recurse straight back into this method.
1516        let mut writer = self.writer.make_writer();
1517        let _ = writer.write_all(line.as_bytes());
1518        let _ = writer.flush();
1519    }
1520}
1521
1522// ---------------------------------------------------------------------------
1523// Installing the sink
1524// ---------------------------------------------------------------------------
1525
1526/// Whose diagnostics these are, and therefore which file they go in.
1527///
1528/// # Why the daemon does not share the operator's file
1529///
1530/// On the two Unixes a boot-mode registration runs the daemon as `root` while
1531/// the four application-data directories stay in the operator's profile —
1532/// `05-infrastructure.md` puts them there and `service install` records those
1533/// paths into the plist. So two accounts write into one `logs/` directory, and
1534/// the appender creates its file with the umask default, `0644`. Whichever
1535/// account opened today's file first owns it, and if that was `root` the
1536/// operator's own `runner-manager status` can no longer append to it.
1537///
1538/// That was not a degraded log. `tracing_appender::rolling::daily` **panics**
1539/// when it cannot open the file, so every CLI command on such a host died with
1540/// a backtrace before it did anything — reported on 0.1.17, on a host whose
1541/// daemon had rolled the file over at midnight as `root`:
1542///
1543/// ```text
1544/// thread 'main' panicked at rolling.rs:156:14:
1545/// initializing rolling file appender failed: InitError { context: "failed to
1546/// create log file", source: Os { code: 13, kind: PermissionDenied } }
1547/// ```
1548///
1549/// [`install`] no longer panics — see there — but not panicking would only have
1550/// turned a crash into an operator who never gets diagnostics again. The two
1551/// writers are separated instead, so neither can take the other's file: the
1552/// account that runs the daemon owns [`SERVICE_LOG_STEM`] and the operator owns
1553/// [`OPERATOR_LOG_STEM`], for as long as the registration lives.
1554#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
1555pub enum LogRole {
1556    /// A command the operator ran, or a daemon they started in the foreground.
1557    Operator,
1558    /// The daemon a service manager started, which on a boot-mode host is a
1559    /// different account from the operator's.
1560    Service,
1561}
1562
1563/// The file stem [`LogRole::Operator`] writes.
1564pub const OPERATOR_LOG_STEM: &str = "runner-manager.log";
1565/// The file stem [`LogRole::Service`] writes, and what `service status`
1566/// reports as the daemon's log.
1567pub const SERVICE_LOG_STEM: &str = "runner-manager.service.log";
1568
1569impl LogRole {
1570    /// The file stem this role writes, before the appender's date suffix.
1571    #[must_use]
1572    pub const fn file_stem(self) -> &'static str {
1573        match self {
1574            Self::Operator => OPERATOR_LOG_STEM,
1575            Self::Service => SERVICE_LOG_STEM,
1576        }
1577    }
1578}
1579
1580impl fmt::Display for LogRole {
1581    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1582        f.write_str(match self {
1583            Self::Operator => "operator",
1584            Self::Service => "service",
1585        })
1586    }
1587}
1588
1589/// The sink could not be installed.
1590#[derive(Debug, thiserror::Error)]
1591pub enum LoggingError {
1592    /// The application-data directories could not be created.
1593    ///
1594    /// Carries a [`crate::paths::PathsError`] rather than a bare
1595    /// [`std::io::Error`] because [`install`] creates `logs/` through
1596    /// [`crate::paths::AppPaths::create_all`], which is what applies the `0700`
1597    /// restriction; the source therefore names whichever of the four
1598    /// directories actually failed, and that need not be `logs/`.
1599    #[error("cannot create the log directory {}: {source}", directory.display())]
1600    Directory {
1601        /// The diagnostics directory [`install`] was asked to write into.
1602        directory: PathBuf,
1603        /// The underlying error.
1604        #[source]
1605        source: crate::paths::PathsError,
1606    },
1607
1608    /// No diagnostics file in `logs/` could be opened for appending.
1609    ///
1610    /// Both stems are named because [`install`] tries two — the role's own, and
1611    /// then one qualified by the account — and an operator who is told only
1612    /// about the second would go looking for a file this process never reached
1613    /// for first.
1614    ///
1615    /// The two attempts are composed into one string rather than carried as
1616    /// four fields. As four this variant reached 128 bytes on Windows — where
1617    /// `PathBuf` is wider than on the Unixes — and tripped
1618    /// `clippy::result_large_err`, which is a lint about every *success* path
1619    /// paying for the size of a failure. The message is the same either way,
1620    /// and only this error is ever built.
1621    #[error("cannot open a diagnostics file in {}: {attempts}", directory.display())]
1622    Appender {
1623        /// The diagnostics directory.
1624        directory: PathBuf,
1625        /// Both stems that were tried, and why each was refused.
1626        attempts: String,
1627    },
1628
1629    /// A global subscriber was already installed.
1630    #[error("a tracing subscriber is already installed for this process: {message}")]
1631    AlreadyInstalled {
1632        /// What `tracing_subscriber` said.
1633        message: String,
1634    },
1635}
1636
1637/// Keeps the background log-writing thread alive.
1638///
1639/// Dropping it flushes and stops that thread, so the value must be held for as
1640/// long as the program expects its diagnostics to be written. Losing it is a
1641/// silent, total loss of logs, which is why it is `#[must_use]`.
1642#[must_use = "dropping the guard stops the log writer and silently discards later diagnostics"]
1643#[derive(Debug)]
1644pub struct LoggingGuard {
1645    _worker: tracing_appender::non_blocking::WorkerGuard,
1646}
1647
1648/// Installs the redacting sink as this process's global subscriber, writing
1649/// daily-rotating files into `logs/`.
1650///
1651/// `role` decides the file, for the reason [`LogRole`] gives. `default_filter`
1652/// applies when `RUST_LOG` is unset or unparseable.
1653///
1654/// # Errors
1655///
1656/// [`LoggingError::Directory`], [`LoggingError::Appender`] and
1657/// [`LoggingError::AlreadyInstalled`]. **None of them is fatal to the caller**,
1658/// and the CLI treats all three as a warning: a `host show` that refused to
1659/// print a capacity because a log file could not be opened would be a worse
1660/// failure than the one it was reporting.
1661pub fn install(
1662    paths: &crate::paths::AppPaths,
1663    role: LogRole,
1664    default_filter: &str,
1665) -> Result<LoggingGuard, LoggingError> {
1666    use tracing_subscriber::layer::SubscriberExt as _;
1667    use tracing_subscriber::util::SubscriberInitExt as _;
1668
1669    // `AppPaths::create_all`, not `create_dir_all`. The claim `create_all`
1670    // documents — that a diagnostics file is not readable by other local
1671    // accounts — is only true if `logs/` is created at `0700`, and this is the
1672    // path a running daemon actually takes. Creating the directory here with a
1673    // bare `create_dir_all` left it at the umask default whenever `install`
1674    // won the race to create it, and `tracing_appender` then wrote 0644 files
1675    // into it: the invariant held in the test that asserts it and nowhere else.
1676    let directory = paths.logs_dir().to_path_buf();
1677    paths
1678        .create_all()
1679        .map_err(|source| LoggingError::Directory {
1680            directory: directory.clone(),
1681            source,
1682        })?;
1683
1684    let appender = open_appender(&directory, role)?;
1685    let (writer, worker) = tracing_appender::non_blocking(appender);
1686
1687    let filter = tracing_subscriber::EnvFilter::try_from_default_env()
1688        .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new(default_filter));
1689
1690    tracing_subscriber::registry()
1691        .with(filter)
1692        .with(RedactingLayer::new(writer))
1693        .try_init()
1694        .map_err(|error| LoggingError::AlreadyInstalled {
1695            message: error.to_string(),
1696        })?;
1697
1698    Ok(LoggingGuard { _worker: worker })
1699}
1700
1701/// Opens today's diagnostics file, falling back to one only this account can
1702/// have created.
1703///
1704/// # Why there is a second attempt at all
1705///
1706/// [`LogRole`] keeps the daemon and the operator apart, which is enough on a
1707/// host where nothing else ever writes here. Two things still put a file the
1708/// caller cannot append to under the stem it wants:
1709///
1710/// 1. **A privileged command.** Signing in to the machine-scoped store is
1711///    `sudo runner-manager auth login`, and that run writes the *operator's*
1712///    stem as `root`. Every unprivileged command for the rest of that day then
1713///    finds a `root`-owned file under it.
1714/// 2. **A host upgraded into this change.** Files written before the roles were
1715///    split are all under [`OPERATOR_LOG_STEM`], and on a boot-mode host the
1716///    ones from the last few days belong to `root`.
1717///
1718/// Neither is worth losing diagnostics over, and neither can be repaired from
1719/// inside an unprivileged process — it may not chown the file, may not change
1720/// its mode, and must not delete an existing log. So it writes beside it, under
1721/// a stem carrying this account's identity, which no other account will choose.
1722///
1723/// The fallback is deliberately *not* the first choice. A file named after
1724/// whoever happened to open it first is a file an operator has to go looking
1725/// for; the plain stem stays the plain stem, and the qualified one appears only
1726/// on a host that has the collision.
1727fn open_appender(
1728    directory: &Path,
1729    role: LogRole,
1730) -> Result<tracing_appender::rolling::RollingFileAppender, LoggingError> {
1731    let stem = role.file_stem();
1732    let first = match build_appender(directory, stem) {
1733        Ok(appender) => return Ok(appender),
1734        Err(error) => error,
1735    };
1736
1737    let qualified = account_qualified_stem(stem);
1738    match build_appender(directory, &qualified) {
1739        Ok(appender) => Ok(appender),
1740        // Both stems are named. An operator told only about the second would go
1741        // looking for a file this process never reached for first.
1742        Err(second) => Err(LoggingError::Appender {
1743            directory: directory.to_path_buf(),
1744            attempts: format!(
1745                "neither {stem}.<date> ({first}) nor {qualified}.<date> ({second}) could be \
1746                 appended to"
1747            ),
1748        }),
1749    }
1750}
1751
1752/// One attempt, through the constructor that **returns** its failure.
1753///
1754/// `tracing_appender::rolling::daily` is the same thing with an `.expect` on
1755/// the end, and that `.expect` is a panic in the middle of an operator's `status`
1756/// command. The whole reason this function exists is that the builder hands the
1757/// error back instead.
1758fn build_appender(
1759    directory: &Path,
1760    stem: &str,
1761) -> Result<tracing_appender::rolling::RollingFileAppender, String> {
1762    tracing_appender::rolling::RollingFileAppender::builder()
1763        .rotation(tracing_appender::rolling::Rotation::DAILY)
1764        .filename_prefix(stem)
1765        .max_log_files(30)
1766        .build(directory)
1767        .map_err(|error| error.to_string())
1768}
1769
1770/// `runner-manager.log` becomes `runner-manager.<account>.log`.
1771///
1772/// The stem carries its own `.log`, so the account goes before it rather than
1773/// after: the appender appends `.<date>`, and `runner-manager.log.uid-501` would
1774/// read as a rotated file rather than as another account's.
1775fn account_qualified_stem(stem: &str) -> String {
1776    let account = account_tag();
1777    match stem.rsplit_once('.') {
1778        Some((head, tail)) => format!("{head}.{account}.{tail}"),
1779        None => format!("{stem}.{account}"),
1780    }
1781}
1782
1783/// Something stable, filename-safe, and different for every local account.
1784///
1785/// The numeric effective user id rather than a name: it needs no lookup, cannot
1786/// contain a path separator, and is the identity the filesystem actually
1787/// compared when it refused the open.
1788#[cfg(unix)]
1789fn account_tag() -> String {
1790    // SAFETY: `geteuid` takes no argument, touches no memory this process owns,
1791    // and is documented never to fail.
1792    let uid = unsafe { libc::geteuid() };
1793    format!("uid-{uid}")
1794}
1795
1796/// As the Unix half, from the one identity Windows exposes without a lookup.
1797///
1798/// Sanitized rather than trusted: this becomes a file name, and `USERNAME` is
1799/// an ordinary environment variable that a caller may set to anything at all,
1800/// including something holding a path separator.
1801#[cfg(windows)]
1802fn account_tag() -> String {
1803    let raw = std::env::var("USERNAME").unwrap_or_default();
1804    let safe: String = raw
1805        .chars()
1806        .filter(|c| c.is_ascii_alphanumeric() || *c == '-' || *c == '_')
1807        .take(32)
1808        .collect();
1809    if safe.is_empty() {
1810        "other-account".to_string()
1811    } else {
1812        format!("user-{safe}")
1813    }
1814}
1815
1816#[cfg(test)]
1817mod tests {
1818    use super::*;
1819
1820    use std::sync::{Arc, Mutex};
1821
1822    use tracing_subscriber::layer::SubscriberExt as _;
1823
1824    #[test]
1825    fn opening_the_sink_bounds_rotated_logs_without_pruning_attempt_summaries() {
1826        let root = tempfile::tempdir().unwrap();
1827        let today = chrono::Utc::now();
1828        for days in 1..=35 {
1829            let date = (today - chrono::Duration::days(days)).format("%Y-%m-%d");
1830            std::fs::write(
1831                root.path().join(format!("{SERVICE_LOG_STEM}.{date}")),
1832                b"old",
1833            )
1834            .unwrap();
1835        }
1836        let summary = root
1837            .path()
1838            .join(format!("{}.log", uuid::Uuid::from_u128(1)));
1839        std::fs::write(&summary, b"attempt summary").unwrap();
1840        let mut appender = build_appender(root.path(), SERVICE_LOG_STEM).unwrap();
1841        appender.write_all(b"current diagnostics\n").unwrap();
1842        appender.flush().unwrap();
1843        let rotated = std::fs::read_dir(root.path())
1844            .unwrap()
1845            .map(Result::unwrap)
1846            .filter(|entry| {
1847                entry
1848                    .file_name()
1849                    .to_string_lossy()
1850                    .starts_with(SERVICE_LOG_STEM)
1851            })
1852            .count();
1853        assert_eq!(rotated, 30);
1854        assert_eq!(std::fs::read(summary).unwrap(), b"attempt summary");
1855    }
1856
1857    // -----------------------------------------------------------------------
1858    // Capturing what the sink actually wrote
1859    // -----------------------------------------------------------------------
1860
1861    #[derive(Clone, Default)]
1862    struct Capture(Arc<Mutex<Vec<u8>>>);
1863
1864    impl Capture {
1865        fn text(&self) -> String {
1866            String::from_utf8_lossy(&self.0.lock().expect("not poisoned")).into_owned()
1867        }
1868    }
1869
1870    struct CaptureWriter(Arc<Mutex<Vec<u8>>>);
1871
1872    impl Write for CaptureWriter {
1873        fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
1874            self.0.lock().expect("not poisoned").extend_from_slice(buf);
1875            Ok(buf.len())
1876        }
1877
1878        fn flush(&mut self) -> std::io::Result<()> {
1879            Ok(())
1880        }
1881    }
1882
1883    impl<'a> tracing_subscriber::fmt::MakeWriter<'a> for Capture {
1884        type Writer = CaptureWriter;
1885
1886        fn make_writer(&'a self) -> Self::Writer {
1887            CaptureWriter(Arc::clone(&self.0))
1888        }
1889    }
1890
1891    /// A sink with the same plumbing and no redaction at all.
1892    ///
1893    /// Exists so that `the_scan_catches_a_sink_that_does_not_redact` can prove
1894    /// the secret-injection scan below is capable of failing. Without it, a scan
1895    /// that found nothing would be indistinguishable from a scan that looked
1896    /// nowhere.
1897    #[derive(Debug, Clone)]
1898    struct PassthroughLayer<W>(W);
1899
1900    #[derive(Default)]
1901    struct PassthroughVisitor(Map<String, Value>);
1902
1903    impl Visit for PassthroughVisitor {
1904        fn record_str(&mut self, field: &Field, value: &str) {
1905            self.0
1906                .insert(field.name().to_string(), Value::String(value.to_string()));
1907        }
1908
1909        fn record_debug(&mut self, field: &Field, value: &dyn fmt::Debug) {
1910            self.0.insert(
1911                field.name().to_string(),
1912                Value::String(format!("{value:?}")),
1913            );
1914        }
1915    }
1916
1917    impl<S, W> Layer<S> for PassthroughLayer<W>
1918    where
1919        S: Subscriber + for<'a> LookupSpan<'a>,
1920        W: for<'a> tracing_subscriber::fmt::MakeWriter<'a> + 'static,
1921    {
1922        fn on_event(&self, event: &Event<'_>, _ctx: Context<'_, S>) {
1923            let mut visitor = PassthroughVisitor::default();
1924            event.record(&mut visitor);
1925            let mut line = serde_json::to_string(&Value::Object(visitor.0)).unwrap_or_default();
1926            line.push('\n');
1927            let _ = self.0.make_writer().write_all(line.as_bytes());
1928        }
1929    }
1930
1931    // -----------------------------------------------------------------------
1932    // The secret-injection log scan (`07-security.md`, security gate)
1933    // -----------------------------------------------------------------------
1934
1935    /// A user access token, in GitHub's user-to-server format.
1936    const USER_TOKEN: &str = "ghu_16C7e42F292c6912E7710c838347Ae178B4a";
1937    /// An installation-style token, to prove the prefix rule is not one-off.
1938    const SERVER_TOKEN: &str = "ghs_1CGGYnBAtn5ov3M0aTHhP7l3ZKuMhIB3pnPd";
1939    /// A fine-grained personal access token.
1940    const FINE_GRAINED: &str =
1941        "github_pat_11ABCDEFG0abcdefghijkl_ZYXWVUTSRQPONMLKJIHGFEDCBA9876543210zyxwv";
1942    /// Stands in for an encoded JIT configuration: base64, and long.
1943    const JIT_BLOB: &str = "eyJhZ2VudE5hbWUiOiJydW5uZXItbWFuYWdlciIsImVuY29kZWQiOiJhYmNkZWZnaGlqa2xtbm9wcXJzdHV2d3h5ejAxMjM0NTY3ODkrLz09In0=";
1944    /// A workspace path, which `07-security.md` also requires redacted.
1945    const WORKSPACE: &str = "/var/lib/runner-manager/runtime/9f2c/attempt-1";
1946    /// The same, on the platform the persona is least likely to be using but
1947    /// the CI matrix definitely is.
1948    const WINDOWS_WORKSPACE: &str = r"C:\Users\operator\AppData\Local\runner-manager\runtime\9f2c";
1949    /// A GitHub App installation assertion: a JSON Web Token.
1950    ///
1951    /// Its two `.` separators are the point. `is_opaque_char` excludes `.`, so
1952    /// the opaque-run rule sees three short runs rather than one long one, and
1953    /// every other secret here is caught by that rule when it stands alone.
1954    const JWT: &str = "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.\
1955                       eyJpc3MiOiIxMjM0NTYiLCJpYXQiOjE3MDAwMDAwMDAsImV4cCI6MTcwMDAwMDYwMH0.\
1956                       c2lnbmF0dXJlLXRoYXQtaXMtb3BhcXVlLWFuZC1sb25nLWVub3VnaC10by1tYXR0ZXI";
1957
1958    /// An error whose `Debug` carries the HTTP body it was given.
1959    ///
1960    /// This is the shape `d2`'s secret store and `d3`'s installer will hand to
1961    /// `tracing::error!(reason = ?err)`, and it is not the same shape as any of
1962    /// the JSON above: `Debug` on a `String` escapes the quotes inside it, so
1963    /// the keys arrive spelled `\"password\"` rather than `"password"`.
1964    #[derive(Debug)]
1965    struct StoreError {
1966        body: String,
1967    }
1968
1969    fn secrets() -> Vec<&'static str> {
1970        vec![
1971            USER_TOKEN,
1972            SERVER_TOKEN,
1973            FINE_GRAINED,
1974            JIT_BLOB,
1975            JWT,
1976            WORKSPACE,
1977            WINDOWS_WORKSPACE,
1978        ]
1979    }
1980
1981    /// Routes every secret through `sink` in every shape a caller might use,
1982    /// and reports the first one that came out the other side.
1983    ///
1984    /// A helper returning `Result` rather than a test body, because the same
1985    /// injection has to be run against a deliberately non-redacting sink to
1986    /// show that it is capable of finding anything at all.
1987    fn scan_for_leaks<L>(layer: L, capture: &Capture) -> Result<String, String>
1988    where
1989        L: Layer<tracing_subscriber::Registry> + Send + Sync + 'static,
1990    {
1991        let subscriber = tracing_subscriber::registry().with(layer);
1992
1993        tracing::subscriber::with_default(subscriber, || {
1994            for secret in secrets() {
1995                // 1. In the message body, which is the one field that must be
1996                //    allowed and therefore cannot be protected by the allowlist.
1997                tracing::info!("starting runner with {secret}");
1998
1999                // 2. As a credential header in the message body, scheme and all.
2000                tracing::info!("request failed; Authorization: Bearer {secret}");
2001                tracing::warn!("retrying with x-api-key={secret}");
2002
2003                // 3. In a field nobody listed — the case that must be safe with
2004                //    no edit to this file.
2005                tracing::info!(runner_token = %secret, "registered");
2006
2007                // 4. In a field that *is* listed, which is where the allowlist
2008                //    alone would not save anything.
2009                tracing::info!(outcome = %secret, event = "started", "registered");
2010                tracing::info!(message = %secret, "ignored");
2011
2012                // 5. Through `Debug` rather than `Display`.
2013                tracing::info!(?secret, "debug shaped");
2014
2015                // 6. Embedded in a structured value rather than standing
2016                //    alone. Every shape above presents the secret as its
2017                //    own whitespace-delimited word, which is the one shape
2018                //    the opaque-run rule catches for free -- so the scan
2019                //    could pass while the sink leaked anything embedded.
2020                //    Compact JSON is what `serde_json::to_string` emits and
2021                //    what an HTTP error body arrives as, and it was emitted
2022                //    verbatim: `redact_core` recursed into the value for
2023                //    `=` only, and the JSON quote left the key spelled
2024                //    `encoded_jit_config"`, which is not on CREDENTIAL_KEYS.
2025                for key in ["encoded_jit_config", "runner_token", "pat"] {
2026                    tracing::error!("registration failed: {{\"{key}\":\"{secret}\"}}");
2027                    tracing::error!("registration failed: {{ \"{key}\": \"{secret}\" }}");
2028                    tracing::error!("registration failed: {key}:{secret}");
2029                }
2030
2031                // 7. An `Authorization` header value, in the same three
2032                //    embedded shapes.
2033                tracing::error!("response: {{\"authorization\":\"Bearer {secret}\"}}");
2034                tracing::error!("response: {{ \"authorization\": \"Bearer {secret}\" }}");
2035                tracing::error!("response: authorization:Bearer {secret}");
2036
2037                // 9. A compact object with **more than one field**, the
2038                //    credential key second. Shape 6 above differs from this by
2039                //    field order and by nothing else, and `serde_json::to_string`
2040                //    is what chooses the order -- so shape 6 could pass while an
2041                //    error body from a struct with two fields leaked. The rules
2042                //    split a word once, on the first separator they find, so
2043                //    only `runner_id` was ever examined.
2044                for key in ["encoded_jit_config", "runner_token", "access_token"] {
2045                    tracing::error!(
2046                        "registration failed: {{\"runner_id\":42,\"{key}\":\"{secret}\"}}"
2047                    );
2048                    tracing::error!(
2049                        "registration failed: {{\"status\":422,\"message\":\"bad\",\"{key}\":\"{secret}\"}}"
2050                    );
2051                    // The spaced spelling of the same thing, which was already
2052                    // safe -- by accident, because the value is its own word.
2053                    tracing::error!(
2054                        "registration failed: {{ \"runner_id\": 42, \"{key}\": \"{secret}\" }}"
2055                    );
2056                }
2057
2058                // 10. Nested, one level and two. The value recursion goes to
2059                //     `redact_value`, which is a leaf and never re-enters
2060                //     `redact_core`, so an object inside an object was emitted
2061                //     whole.
2062                tracing::error!("response: {{\"body\":{{\"runner_token\":\"{secret}\"}}}}");
2063                tracing::error!(
2064                    "response: {{\"error\":{{\"status\":422,\"body\":{{\"encoded_jit_config\":\"{secret}\"}}}}}}"
2065                );
2066
2067                // 11. A form-encoded body with the credential parameter second.
2068                //     `&` was not a separator this module knew, so the whole
2069                //     body was one word and only `scope` was ever judged.
2070                tracing::error!("token exchange failed: scope=repo&access_token={secret}");
2071                tracing::error!(
2072                    "token exchange failed: grant_type=refresh&refresh_token={secret}&scope=repo"
2073                );
2074
2075                // 12. Backslash-escaped JSON, through `Debug` rather than
2076                //     `Display`. Shape 5 is also `Debug`, but it puts the
2077                //     secret on an *unlisted* field, which is dropped wholesale
2078                //     -- so the `Debug` lens and the compact-JSON lens were both
2079                //     here and had never been crossed. `reason` is allowed, so
2080                //     this one reaches the scrubber, with its keys spelled
2081                //     `\"runner_token\"`.
2082                let failure = StoreError {
2083                    body: format!("{{\"runner_token\":\"{secret}\"}}"),
2084                };
2085                tracing::error!(reason = ?failure, "the secret store rejected the request");
2086
2087                // 13. A URL earlier in the same word. `redact_core` split on the
2088                //     first `://` and handed everything before it to
2089                //     `redact_url` as a "scheme" and everything after it as
2090                //     the URL -- and the terminal arm of `redact_url` echoes
2091                //     both verbatim. So one URL anywhere in a word put the
2092                //     whole of the rest of that word beyond every rule below.
2093                //     `documentation_url` is in essentially every GitHub REST
2094                //     error body, which makes that "any error body logged
2095                //     alongside a credential".
2096                tracing::error!(
2097                    "api failed: {{\"message\":\"Bad credentials\",\"documentation_url\":\"https://docs.github.com/rest\",\"token\":\"{secret}\"}}"
2098                );
2099                // The reverse order leaked for the mirror reason: everything
2100                // before the `://` became the scheme, and was echoed.
2101                tracing::error!(
2102                    "api failed: {{\"token\":\"{secret}\",\"documentation_url\":\"https://docs.github.com/rest\"}}"
2103                );
2104                // A nested object behind a URL, which is the shape a clone
2105                // failure arrives in.
2106                tracing::error!(
2107                    "clone failed: {{\"remote\":\"https://github.com/o/r.git\",\"body\":{{\"password\":\"{secret}\"}}}}"
2108                );
2109
2110                // 14. A value with no key of its own: an array element. The
2111                //     structural cut recursed on the *raw* fragment, and the
2112                //     terminal fallback then handed it to `redact_value` with
2113                //     its quote still attached -- so `"ghu_…` failed
2114                //     `starts_with("ghu_")`, failed `is_opaque_char` on the
2115                //     quote, failed the `eyJ` test, and failed
2116                //     `looks_like_path`. This is shape 9's defect one step
2117                //     down: "only the first pair is examined" became "a value
2118                //     with no key of its own is never examined", and it
2119                //     survived because there was no array among the twelve
2120                //     shapes above.
2121                tracing::error!("registration failed: {{\"tokens\":[\"{secret}\",\"x\"]}}");
2122                tracing::error!("registration failed: {{\"tokens\":[\"x\",\"{secret}\"]}}");
2123                tracing::error!("registration failed: [\"x\",\"{secret}\"]");
2124                tracing::error!("registration failed: [{{\"id\":1}},[\"{secret}\"]]");
2125                let listed = StoreError {
2126                    body: format!("{{\"tokens\":[\"x\",\"{secret}\"]}}"),
2127                };
2128                tracing::error!(reason = ?listed, "the secret store rejected the list");
2129
2130                // 15. `;` as the separator. It is in `WRAPPERS`, so it was
2131                //     recognised as punctuation, and it was not in
2132                //     `STRUCTURAL`, so it never cut a word. It is what
2133                //     separates the pairs of a Windows connection string, of a
2134                //     credential string, and of a cookie header written
2135                //     without a space -- `d2`'s territory exactly.
2136                tracing::error!("store rejected: Server=host;Database=x;Password={secret};");
2137                tracing::error!("store rejected: user=operator;password={secret}");
2138                tracing::error!("store rejected: theme=dark;session={secret}");
2139
2140                // 16. Wrapped in an element rather than in a quote.
2141                //     `split_wrappers` strips only the *outermost* `<` and
2142                //     `>`, so `<string>…</string>` arrived as
2143                //     `string>…</string`, which matches no rule at all. `d3`'s
2144                //     installers handle launchd plists, which is where this
2145                //     shape comes from.
2146                tracing::error!("plist rejected: <string>{secret}</string>");
2147                tracing::error!("plist rejected: <key>token</key><string>{secret}</string>");
2148
2149                // 17. A `;` or an `&` *after a URL in the same word*. This is
2150                //     shape 15 and shape 11 with one thing changed: a URL
2151                //     earlier in the word. `STRUCTURAL` has nine characters and
2152                //     `is_url_terminator` recognised seven of them -- `;` and
2153                //     `&` were missing -- and the URL branch runs *ahead* of
2154                //     the structural cut. So `split_url` swallowed everything
2155                //     to the next terminator, including the separator that
2156                //     would have cut the word, and `redact_url`'s terminal arm
2157                //     echoed it verbatim.
2158                //
2159                //     The L3 fix (bounding the URL) and the L1 fix (cutting on
2160                //     `;` and `&`) each work alone and did not compose: this
2161                //     commit added `;` to `STRUCTURAL` *for connection strings*
2162                //     while leaving the path a URL opens straight through it.
2163                //     A connection string whose `Server=` is a URL is exactly
2164                //     `d2`'s shape, and a systemd `Environment=` line is
2165                //     `d3`'s.
2166                tracing::error!(
2167                    "store rejected: Server=https://vault.local/api;Password={secret};"
2168                );
2169                tracing::error!("keychain error: url=https://kc.local;secret={secret}");
2170                tracing::error!("unit rejected: Environment=API=https://a.com/v1;TOKEN={secret}");
2171                tracing::error!("token exchange failed: cb=https://a.com/x&access_token={secret}");
2172                tracing::error!(
2173                    "dsn rejected: dsn=https://sentry.local/1;password={secret};user=x"
2174                );
2175                tracing::error!(
2176                    "callback failed: redirect=https://a.com/cb&state=1&access_token={secret}"
2177                );
2178                let routed = StoreError {
2179                    body: format!("Server=https://vault.local/api;Password={secret};"),
2180                };
2181                tracing::error!(reason = ?routed, "the secret store rejected the connection string");
2182
2183                // 18. A credential key whose value is not in its own fragment.
2184                //     The empty-value skip is right, but its justification --
2185                //     "the value is in the next fragment, where the structural
2186                //     cut reaches it" -- was false: `redact_fragment` carried
2187                //     nothing across the cut, so nothing ever reached it. An
2188                //     array, a nested object, an empty pair and a plist
2189                //     key/value pair are four spellings of the same gap.
2190                tracing::error!("store rejected: {{\"password\":[\"{secret}\"]}}");
2191                tracing::error!("store rejected: {{\"password\":{{\"v\":\"{secret}\"}}}}");
2192                tracing::error!("store rejected: Password=;{secret}");
2193                tracing::error!("plist rejected: <key>password</key><string>{secret}</string>");
2194
2195                // 19. A structural character *inside* a credential value. `,`
2196                //     and `&` could already split a secret; this commit added
2197                //     `;`, `<` and `>`, so each of those became a new character
2198                //     that can cut a punctuated password in half and leave the
2199                //     tail standing.
2200                tracing::error!("store rejected: {{\"password\":\"a<{secret}>b\"}}");
2201                tracing::error!("store rejected: {{\"password\":\"a&{secret},b\"}}");
2202
2203                // 20. A secret in a URL *path*. `redact_url` applies no shape
2204                //     rule to the path at all, so a token that reaches one is
2205                //     echoed whole -- and the token-prefix rule is documented
2206                //     as a belt that catches a credential anywhere.
2207                //
2208                //     The two workspace paths are excluded, and the exclusion
2209                //     is a real limitation rather than a convenience. A path is
2210                //     judged one segment at a time, because judging the whole
2211                //     of a URL path with `looks_like_path` would redact every
2212                //     URL with two segments in it -- which is the
2213                //     over-redaction the module documentation explicitly
2214                //     promises a full URL escapes. A filesystem path pasted
2215                //     into a URL path is therefore indistinguishable from an
2216                //     ordinary deep URL path: `/var/lib/…` and `/repos/o/r/…`
2217                //     are the same shape, segment by segment. A *credential* in
2218                //     a path is caught, because a credential has a shape of its
2219                //     own; a path in a path is not.
2220                if !looks_like_path(secret) {
2221                    tracing::error!("download failed: https://github.com/o/r/raw/{secret}/f");
2222                    tracing::error!("download failed: https://github.com/o/r/raw/{secret}");
2223                }
2224
2225                // 8. Carried on a span rather than on the event.
2226                let span = tracing::info_span!("attempt", jit_config = %secret);
2227                let _entered = span.enter();
2228                tracing::info!(event = "inside_span", "in a span");
2229            }
2230        });
2231
2232        let output = capture.text();
2233        for secret in secrets() {
2234            for needle in needles(secret) {
2235                if output.contains(&needle) {
2236                    return Err(format!(
2237                        "the sink emitted a secret verbatim: {secret}\n\
2238                         (found as: {needle})\n--- output ---\n{output}"
2239                    ));
2240                }
2241            }
2242        }
2243        Ok(output)
2244    }
2245
2246    /// Every spelling a secret can have in the sink's output.
2247    ///
2248    /// The sink writes JSON, so a secret containing a character `serde_json`
2249    /// escapes never appears in the output as it was written. `WINDOWS_WORKSPACE`
2250    /// is a raw string with single backslashes and is emitted with doubled ones,
2251    /// so scanning for the literal could not have matched it — not even against
2252    /// `PassthroughLayer`, which redacts nothing at all. The path really is
2253    /// redacted, so this was a hole in the coverage rather than a leak, but a
2254    /// needle that cannot match is a check that cannot fail.
2255    fn needles(secret: &str) -> Vec<String> {
2256        // Strip the quotes `to_string` adds; what is left is the text exactly
2257        // as it appears inside a JSON string literal.
2258        let escape = |text: &str| {
2259            let json = serde_json::to_string(text).expect("a string is serialisable");
2260            json[1..json.len() - 1].to_string()
2261        };
2262
2263        let once = escape(secret);
2264        // Twice, because a secret can be escaped twice on the way out, and
2265        // shape 12 is where that happens: `Debug` on a `String` escapes the
2266        // quotes *and* the backslashes inside it, and the sink then
2267        // JSON-encodes what `Debug` produced. A Windows path arrives in that
2268        // line with four backslashes where the secret has one, so the
2269        // once-escaped needle cannot match it there -- not even against
2270        // `PassthroughLayer`, which redacts nothing at all. That cell of the
2271        // matrix was a check incapable of failing, which is the same defect
2272        // this helper was written to fix one level down.
2273        let twice = escape(&once);
2274
2275        let mut spellings = vec![secret.to_string(), once, twice];
2276        // Consecutive-only is enough: each level of escaping is a superset of
2277        // the last, so equal spellings are always adjacent.
2278        spellings.dedup();
2279        spellings
2280    }
2281
2282    #[test]
2283    fn the_scan_looks_for_a_needle_that_can_actually_occur() {
2284        // Guards the helper above: if `needles` ever stops producing the
2285        // JSON-escaped spelling, the Windows workspace silently stops being
2286        // scanned for and every assertion about it becomes vacuous.
2287        let windows = needles(WINDOWS_WORKSPACE);
2288        assert_eq!(
2289            windows.len(),
2290            3,
2291            "a backslash path has three spellings, one per level of escaping it \
2292             can pass through on the way out: {windows:?}"
2293        );
2294        assert!(
2295            windows[1].contains(r"\\Users\\operator"),
2296            "the once-escaped spelling is what an ordinary JSON line contains: {windows:?}"
2297        );
2298        assert!(
2299            windows[2].contains(r"\\\\Users\\\\operator"),
2300            "the twice-escaped spelling is what a Debug rendering inside a JSON \
2301             line contains: {windows:?}"
2302        );
2303
2304        // The third spelling is not hypothetical: it is exactly what the sink
2305        // writes for shape 12, and without it that cell of the scan was a
2306        // check that could not fail.
2307        let shape_twelve = serde_json::to_string(&Value::String(format!(
2308            "{:?}",
2309            StoreError {
2310                body: format!("{{\"runner_token\":\"{WINDOWS_WORKSPACE}\"}}"),
2311            }
2312        )))
2313        .expect("serialisable");
2314        assert!(
2315            shape_twelve.contains(&windows[2]),
2316            "the twice-escaped needle must be findable in what shape 12 emits: {shape_twelve}"
2317        );
2318        assert!(
2319            !shape_twelve.contains(&windows[1]),
2320            "and the once-escaped one must not be, or the gap was never there: {shape_twelve}"
2321        );
2322
2323        // Confirms the premise: the raw spelling genuinely cannot occur in the
2324        // sink's output, which is why scanning only for it proved nothing.
2325        let rendered = serde_json::to_string(&Value::String(WINDOWS_WORKSPACE.to_string()))
2326            .expect("serialisable");
2327        assert!(
2328            !rendered.contains(WINDOWS_WORKSPACE),
2329            "if this ever contains the raw path, the original scan was fine after all: {rendered}"
2330        );
2331        assert!(rendered.contains(&windows[1]), "{rendered}");
2332
2333        // A secret with nothing to escape has exactly one spelling.
2334        assert_eq!(needles(USER_TOKEN), vec![USER_TOKEN.to_string()]);
2335    }
2336
2337    #[test]
2338    fn the_secret_injection_scan_finds_nothing() {
2339        let capture = Capture::default();
2340        let output = scan_for_leaks(RedactingLayer::new(capture.clone()), &capture)
2341            .unwrap_or_else(|complaint| panic!("{complaint}"));
2342
2343        // Something must have been written, or "no secrets found" is trivially
2344        // true and means nothing.
2345        assert!(!output.trim().is_empty(), "the sink wrote nothing at all");
2346        assert!(
2347            output.contains(REDACTION),
2348            "nothing was redacted, so nothing was routed through the sink:\n{output}"
2349        );
2350    }
2351
2352    #[test]
2353    fn the_scan_catches_a_sink_that_does_not_redact() {
2354        let capture = Capture::default();
2355        let complaint = scan_for_leaks(PassthroughLayer(capture.clone()), &capture)
2356            .expect_err("a sink with no redaction must be caught");
2357        assert!(
2358            complaint.contains("emitted a secret verbatim"),
2359            "the complaint must name the failure mode: {complaint}"
2360        );
2361    }
2362
2363    // -----------------------------------------------------------------------
2364    // The allowlist
2365    // -----------------------------------------------------------------------
2366
2367    fn emit(capture: &Capture, body: impl FnOnce()) -> String {
2368        let subscriber = tracing_subscriber::registry().with(RedactingLayer::new(capture.clone()));
2369        tracing::subscriber::with_default(subscriber, body);
2370        capture.text()
2371    }
2372
2373    #[test]
2374    fn a_field_nobody_listed_is_redacted_by_default() {
2375        let capture = Capture::default();
2376        let output = emit(&capture, || {
2377            tracing::info!(
2378                a_field_added_by_a_later_task = "supersecret-value",
2379                "an event"
2380            );
2381        });
2382
2383        assert!(
2384            !output.contains("supersecret-value"),
2385            "an unlisted field leaked its value:\n{output}"
2386        );
2387        assert!(output.contains(REDACTION), "{output}");
2388        // The name survives: knowing the field was there is useful, and a field
2389        // name is not a credential.
2390        assert!(
2391            output.contains("a_field_added_by_a_later_task"),
2392            "the field name should be kept:\n{output}"
2393        );
2394    }
2395
2396    #[test]
2397    fn an_unlisted_numeric_field_is_redacted_too() {
2398        // The rule has no exception for a type, because "a credential is never
2399        // an integer" is exactly the kind of reasoning that stops being true
2400        // once somebody logs an installation-scoped identifier they should not.
2401        let capture = Capture::default();
2402        let output = emit(&capture, || {
2403            tracing::info!(unlisted_number = 8_675_309_u64, "an event");
2404        });
2405        assert!(!output.contains("8675309"), "{output}");
2406        assert!(output.contains(REDACTION), "{output}");
2407    }
2408
2409    #[test]
2410    fn listed_fields_survive() {
2411        let capture = Capture::default();
2412        let output = emit(&capture, || {
2413            tracing::info!(
2414                event = "reconciled",
2415                policy_id = "9f2c1a44-0000-4000-8000-000000000001",
2416                count = 3,
2417                outcome = "started",
2418                "reconciliation finished"
2419            );
2420        });
2421
2422        for expected in [
2423            "reconciled",
2424            "9f2c1a44-0000-4000-8000-000000000001",
2425            "started",
2426            "reconciliation finished",
2427        ] {
2428            assert!(
2429                output.contains(expected),
2430                "{expected} missing from:\n{output}"
2431            );
2432        }
2433        assert!(output.contains("\"count\":3"), "{output}");
2434    }
2435
2436    /// The rate-limit warning's fields and a long closed-vocabulary reason
2437    /// reach the log, rather than `[redacted]` in place of the diagnosis.
2438    #[test]
2439    fn non_secret_diagnostic_fields_survive() {
2440        let capture = Capture::default();
2441        let output = emit(&capture, || {
2442            tracing::warn!(
2443                kind = "secondary",
2444                delay_secs = 61_u64,
2445                remaining = 0_u64,
2446                reason = "late_ephemeral_workspace_could_not_be_removed",
2447                "rate limited"
2448            );
2449        });
2450        for expected in [
2451            "\"kind\":\"secondary\"",
2452            "\"delay_secs\":61",
2453            "\"remaining\":0",
2454            "\"reason\":\"late_ephemeral_workspace_could_not_be_removed\"",
2455        ] {
2456            assert!(
2457                output.contains(expected),
2458                "{expected} missing from:
2459{output}"
2460            );
2461        }
2462    }
2463
2464    /// The carve-out is for identifiers in closed-vocabulary fields, not for
2465    /// anything long with an underscore in it, and not for message text.
2466    #[test]
2467    fn only_a_snake_case_reason_field_escapes_the_opaque_rule() {
2468        let reason = "late_ephemeral_workspace_could_not_be_removed";
2469        assert!(is_closed_vocabulary("reason", reason));
2470        assert!(!is_closed_vocabulary("message", reason));
2471        assert_eq!(redact(reason), REDACTION, "free text kept a long run");
2472        for opaque in [
2473            // A capital, a digit, a doubled or trailing underscore, and no
2474            // underscore at all: each is a shape a secret can take.
2475            "late_ephemeral_workspace_could_not_be_Removed",
2476            "late_ephemeral_workspace_could_not_be_removed2",
2477            "late_ephemeral__workspace_could_not_be_removed",
2478            "late_ephemeral_workspace_could_not_be_removed_",
2479            "lateephemeralworkspacecouldnotberemovedquite",
2480            "ghs_ephemeral_workspace_could_not_be_removed",
2481        ] {
2482            assert!(!is_closed_vocabulary("reason", opaque), "{opaque}");
2483        }
2484    }
2485
2486    #[test]
2487    fn the_allowlist_is_sorted_and_has_no_duplicates() {
2488        // `is_field_allowed` uses a binary search, so an unsorted list would not
2489        // merely be untidy — it would silently start redacting fields that are
2490        // on it.
2491        let mut sorted = ALLOWED_FIELDS.to_vec();
2492        sorted.sort_unstable();
2493        assert_eq!(
2494            ALLOWED_FIELDS,
2495            &sorted[..],
2496            "ALLOWED_FIELDS must stay sorted"
2497        );
2498
2499        let mut deduped = sorted.clone();
2500        deduped.dedup();
2501        assert_eq!(sorted.len(), deduped.len(), "ALLOWED_FIELDS has duplicates");
2502
2503        for name in ALLOWED_FIELDS {
2504            assert!(is_field_allowed(name), "{name} is listed but not allowed");
2505        }
2506        assert!(!is_field_allowed("authorization"));
2507        assert!(!is_field_allowed("runner_token"));
2508    }
2509
2510    #[test]
2511    fn every_record_is_one_parseable_json_object_per_line() {
2512        let capture = Capture::default();
2513        let output = emit(&capture, || {
2514            tracing::info!(event = "one", "first");
2515            tracing::warn!(event = "two", "second");
2516        });
2517
2518        let lines: Vec<&str> = output
2519            .lines()
2520            .filter(|line| !line.trim().is_empty())
2521            .collect();
2522        assert_eq!(lines.len(), 2, "{output}");
2523        for line in lines {
2524            let value: Value = serde_json::from_str(line)
2525                .unwrap_or_else(|error| panic!("not JSON: {error}\n{line}"));
2526            assert!(value.get("timestamp").is_some(), "{line}");
2527            assert!(value.get("level").is_some(), "{line}");
2528            assert!(value.get("logger").is_some(), "{line}");
2529            assert!(value.get("fields").is_some(), "{line}");
2530        }
2531    }
2532
2533    #[test]
2534    fn span_fields_are_redacted_and_carried() {
2535        let capture = Capture::default();
2536        let output = emit(&capture, || {
2537            let span = tracing::info_span!("attempt", attempt_id = "abc-123", jit = %JIT_BLOB);
2538            let _entered = span.enter();
2539            tracing::info!(event = "inside", "in the span");
2540        });
2541
2542        assert!(output.contains("\"name\":\"attempt\""), "{output}");
2543        assert!(
2544            output.contains("abc-123"),
2545            "the listed span field survives:\n{output}"
2546        );
2547        assert!(!output.contains(JIT_BLOB), "a span field leaked:\n{output}");
2548    }
2549
2550    /// Asserts that [`install`] created its directories the restrictive way.
2551    ///
2552    /// `install` used to call `std::fs::create_dir_all(logs_dir)` directly.
2553    /// That is invisible on Windows and *nearly* invisible on Unix — the
2554    /// directory exists either way, and `AppPaths::create_all`'s own test still
2555    /// passed, because it tests `create_all` rather than the path a running
2556    /// daemon actually takes. So this checks the two things that distinguish
2557    /// them:
2558    ///
2559    /// 1. **All four directories exist.** `create_dir_all(logs_dir)` makes
2560    ///    exactly one. This half runs on every platform, which matters because
2561    ///    Windows is the leg most likely to be run locally.
2562    /// 2. **On Unix the mode is `0700`.** This is the invariant `create_all`
2563    ///    documents — that a diagnostics file is not readable by other local
2564    ///    accounts — and `tracing_appender` writes 0644 files into whatever
2565    ///    directory it is given, so a `0755` `logs/` defeats it entirely.
2566    fn assert_install_created_restricted_directories(paths: &crate::paths::AppPaths) {
2567        for (purpose, path) in paths.all() {
2568            assert!(
2569                path.is_dir(),
2570                "install must create the {purpose} directory too: going through \
2571                 AppPaths::create_all is what applies the restriction, and creating only \
2572                 logs/ is the bug this asserts against ({})",
2573                path.display()
2574            );
2575
2576            #[cfg(unix)]
2577            {
2578                use std::os::unix::fs::PermissionsExt as _;
2579
2580                let mode = std::fs::metadata(path)
2581                    .expect("the directory exists")
2582                    .permissions()
2583                    .mode()
2584                    & 0o777;
2585                assert_eq!(
2586                    mode, 0o700,
2587                    "the {purpose} directory is mode {mode:04o}; a diagnostics file under a \
2588                     group- or world-readable directory is readable by other local accounts"
2589                );
2590            }
2591        }
2592    }
2593
2594    /// The one test that exercises [`install`] rather than assembling a
2595    /// subscriber by hand: the rolling file appender, the non-blocking writer,
2596    /// and the guard that has to be held for any of it to reach disk.
2597    ///
2598    /// It installs a *global* subscriber, which a process can only do once, so
2599    /// it is the only test here that may. Everything else uses
2600    /// `with_default`, which is thread-local and does not conflict — including
2601    /// on threads that run after this one.
2602    #[test]
2603    #[serial_test::serial(global_subscriber)]
2604    fn install_writes_redacted_json_into_the_logs_directory() {
2605        // `install` honours `RUST_LOG`, and a developer who has set it to
2606        // something restrictive would otherwise see this fail for a reason that
2607        // has nothing to do with the code. CI sets no such variable.
2608        if std::env::var_os("RUST_LOG").is_some() {
2609            return;
2610        }
2611
2612        let root = tempfile::tempdir().expect("a temporary directory");
2613        let paths = crate::paths::AppPaths::rooted_at(root.path());
2614
2615        let outcome = install(&paths, LogRole::Operator, "trace");
2616
2617        // Asserted before the early return below, because `install` creates the
2618        // directories before it touches the global subscriber: this holds
2619        // whether or not this test won the race to install one.
2620        assert_install_created_restricted_directories(&paths);
2621
2622        let Ok(guard) = outcome else {
2623            // Another test binary in the same process already installed one.
2624            // Not this test's failure, and not worth making the suite
2625            // order-dependent over.
2626            return;
2627        };
2628
2629        tracing::info!(event = "installed", runner_token = %USER_TOKEN, "hello from the sink");
2630
2631        // Dropping the guard flushes and stops the background writer, which is
2632        // the documented way to be sure the line reached disk.
2633        drop(guard);
2634
2635        let written: Vec<PathBuf> = std::fs::read_dir(paths.logs_dir())
2636            .expect("the log directory was created")
2637            .filter_map(Result::ok)
2638            .map(|entry| entry.path())
2639            .collect();
2640        assert_eq!(
2641            written.len(),
2642            1,
2643            "expected one rotating log file: {written:?}"
2644        );
2645
2646        let contents = std::fs::read_to_string(&written[0]).expect("readable");
2647        assert!(contents.contains("hello from the sink"), "{contents}");
2648        assert!(
2649            !contents.contains(USER_TOKEN),
2650            "the file sink must redact exactly like the in-memory one:\n{contents}"
2651        );
2652        assert!(contents.contains(REDACTION), "{contents}");
2653        serde_json::from_str::<Value>(contents.lines().next().expect("a line"))
2654            .expect("each line is a JSON object");
2655    }
2656
2657    // -----------------------------------------------------------------------
2658    // Two accounts, one logs/ directory
2659    // -----------------------------------------------------------------------
2660
2661    /// The daemon and the operator do not reach for the same file.
2662    ///
2663    /// Cheap, and it is the whole of the separation: everything else about
2664    /// [`LogRole`] follows from these two names being different.
2665    #[test]
2666    fn the_daemon_and_the_operator_write_different_files() {
2667        assert_ne!(
2668            LogRole::Service.file_stem(),
2669            LogRole::Operator.file_stem(),
2670            "a boot-mode daemon runs as another account and creates its file 0644; sharing a \
2671             stem hands whichever of them opened it first the day's file and locks the other out"
2672        );
2673        assert_eq!(LogRole::Operator.file_stem(), OPERATOR_LOG_STEM);
2674        assert_eq!(LogRole::Service.file_stem(), SERVICE_LOG_STEM);
2675    }
2676
2677    /// A file this account may not append to is written *beside*, not panicked
2678    /// on.
2679    ///
2680    /// This is the reported 0.1.17 crash, reproduced at the mode that caused
2681    /// it. On the host it was ownership — `root` had rolled the file over at
2682    /// midnight — and an unprivileged test cannot make a `root`-owned file, so
2683    /// it makes an unwritable one instead: the appender's `OpenOptions` refuse
2684    /// both with the same `EACCES`, and refusing was what
2685    /// `tracing_appender::rolling::daily` turned into a panic.
2686    #[cfg(unix)]
2687    #[test]
2688    fn a_log_file_this_account_cannot_append_to_is_written_beside() {
2689        use std::os::unix::fs::PermissionsExt as _;
2690
2691        // `root` is not subject to the mode bits, so it would sail through the
2692        // very open this test needs to fail.
2693        if account_tag() == "uid-0" {
2694            return;
2695        }
2696
2697        let root = tempfile::tempdir().expect("a temporary directory");
2698        let paths = crate::paths::AppPaths::rooted_at(root.path());
2699        paths
2700            .create_all()
2701            .expect("the four directories are created");
2702
2703        // Every date the appender might choose, so the test cannot flake on a
2704        // run that straddles midnight UTC.
2705        let logs = paths.logs_dir().to_path_buf();
2706        let today = chrono::Utc::now();
2707        for date in [
2708            (today - chrono::Duration::days(1))
2709                .format("%Y-%m-%d")
2710                .to_string(),
2711            today.format("%Y-%m-%d").to_string(),
2712            (today + chrono::Duration::days(1))
2713                .format("%Y-%m-%d")
2714                .to_string(),
2715        ] {
2716            let taken = logs.join(format!("{OPERATOR_LOG_STEM}.{date}"));
2717            std::fs::write(&taken, b"another account's diagnostics").expect("writable");
2718            std::fs::set_permissions(&taken, std::fs::Permissions::from_mode(0o444))
2719                .expect("the mode is applied");
2720        }
2721
2722        let mut appender = open_appender(&logs, LogRole::Operator)
2723            .expect("a diagnostics file is opened beside the one this account may not append to");
2724        appender
2725            .write_all(b"a line\n")
2726            .expect("the line is written");
2727        appender.flush().expect("the line is flushed");
2728
2729        let qualified = account_qualified_stem(OPERATOR_LOG_STEM);
2730        let written: Vec<String> = std::fs::read_dir(&logs)
2731            .expect("the directory is readable")
2732            .flatten()
2733            .map(|entry| entry.file_name().to_string_lossy().into_owned())
2734            .collect();
2735        assert!(
2736            written.iter().any(|name| name.starts_with(&qualified)),
2737            "expected a file under {qualified:?} beside the unwritable ones, and found {written:?}"
2738        );
2739        for name in &written {
2740            if name.starts_with(&qualified) {
2741                continue;
2742            }
2743            assert_eq!(
2744                std::fs::read(logs.join(name)).expect("readable"),
2745                b"another account's diagnostics",
2746                "{name} belongs to another account and must not have been touched"
2747            );
2748        }
2749    }
2750
2751    // -----------------------------------------------------------------------
2752    // The scrubber, in isolation
2753    // -----------------------------------------------------------------------
2754
2755    #[test]
2756    fn tokens_are_redacted_wherever_they_appear() {
2757        for token in [USER_TOKEN, SERVER_TOKEN, FINE_GRAINED] {
2758            for shape in [
2759                token.to_string(),
2760                format!("using {token} now"),
2761                format!("(\"{token}\")"),
2762                format!("token={token}"),
2763                format!("Authorization: Bearer {token}"),
2764                format!("authorization={token}"),
2765                // A URL's userinfo. This is the canonical token-authenticated
2766                // git remote, so it is what a clone or fetch error carries, and
2767                // it is the shape the `://` branch used to hand back verbatim.
2768                format!("https://x-access-token:{token}@github.com/owner/repo.git"),
2769                format!("fatal: could not read from https://{token}@github.com/o/r"),
2770                // Userinfo with no password half.
2771                format!("https://{token}@github.com/o/r.git"),
2772                // A fragment, which used to be stripped only when it was a
2773                // query string.
2774                format!("https://github.com/login/oauth#access_token={token}"),
2775                format!("https://github.com/x?a=1#token={token}"),
2776            ] {
2777                let redacted = redact(&shape);
2778                assert!(
2779                    !redacted.contains(token),
2780                    "{shape:?} survived redaction as {redacted:?}"
2781                );
2782            }
2783        }
2784    }
2785
2786    #[test]
2787    fn a_url_keeps_what_diagnoses_it_and_loses_what_authenticates_it() {
2788        // The host and the path are the diagnosable part and must survive, or
2789        // nobody keeps this redaction.
2790        assert_eq!(
2791            redact(
2792                "https://x-access-token:ghu_16C7e42F292c6912E7710c838347Ae178B4a@github.com/owner/repo.git"
2793            ),
2794            format!("https://{REDACTION}@github.com/owner/repo.git")
2795        );
2796        assert_eq!(
2797            redact("https://user:hunter2@api.github.com/repos/o/r?page=2"),
2798            format!("https://{REDACTION}@api.github.com/repos/o/r?{REDACTION}")
2799        );
2800        // A password containing an `@`: the host is what follows the last one.
2801        assert_eq!(
2802            redact("https://user:p@ss@github.com/o/r"),
2803            format!("https://{REDACTION}@github.com/o/r")
2804        );
2805        // Userinfo on a bare authority, with no path at all.
2806        assert_eq!(
2807            redact("https://token@github.com"),
2808            format!("https://{REDACTION}@github.com")
2809        );
2810        // An `@` after the first `/` is part of the path, not userinfo.
2811        assert_eq!(
2812            redact("https://github.com/@owner/repo"),
2813            "https://github.com/@owner/repo"
2814        );
2815    }
2816
2817    #[test]
2818    fn an_encoded_jit_configuration_is_redacted() {
2819        assert_eq!(redact(JIT_BLOB), REDACTION);
2820        let sentence = format!("handing off {JIT_BLOB} to the runner");
2821        let redacted = redact(&sentence);
2822        assert!(!redacted.contains(JIT_BLOB), "{redacted}");
2823        assert!(redacted.starts_with("handing off "), "{redacted}");
2824        assert!(redacted.ends_with(" to the runner"), "{redacted}");
2825    }
2826
2827    #[test]
2828    fn a_secret_embedded_in_a_structured_value_is_redacted() {
2829        // Compact JSON is what `serde_json::to_string` emits and what an
2830        // HTTP error body arrives as; spaced JSON is the only shape that
2831        // used to be safe, and it was safe by accident -- the value is its
2832        // own word there, so the opaque-run rule caught it without the key
2833        // ever being recognised.
2834        for shape in [
2835            format!("{{\"encoded_jit_config\":\"{JIT_BLOB}\"}}"),
2836            format!("{{ \"encoded_jit_config\": \"{JIT_BLOB}\" }}"),
2837            format!("encoded_jit_config:{JIT_BLOB}"),
2838            format!("{{\"runner_token\":\"{USER_TOKEN}\"}}"),
2839            format!("runner_token:{USER_TOKEN}"),
2840            format!("pat:{USER_TOKEN}"),
2841            format!("{{\"authorization\":\"Bearer {USER_TOKEN}\"}}"),
2842        ] {
2843            let redacted = redact(&shape);
2844            assert!(
2845                !redacted.contains(JIT_BLOB) && !redacted.contains(USER_TOKEN),
2846                "leaked from {shape}:\n{redacted}"
2847            );
2848        }
2849
2850        // A credential short enough to clear the opaque-run threshold and
2851        // without a GitHub prefix has nothing but the key to give it away,
2852        // so it leaked from the spaced shape too: `\"password\":` left the
2853        // key spelled `password\"`, which is on no list.
2854        for shape in [
2855            "{\"password\":\"hunter2\"}",
2856            "{ \"password\": \"hunter2\" }",
2857        ] {
2858            let redacted = redact(shape);
2859            assert!(
2860                !redacted.contains("hunter2"),
2861                "leaked from {shape}: {redacted}"
2862            );
2863        }
2864
2865        // The neighbouring context still survives: this is a scrubber, not
2866        // a deleter.
2867        let body = format!("registration failed: {{\"encoded_jit_config\":\"{JIT_BLOB}\"}}");
2868        let redacted = redact(&body);
2869        assert!(redacted.starts_with("registration failed: "), "{redacted}");
2870        assert!(redacted.contains("encoded_jit_config"), "{redacted}");
2871    }
2872
2873    #[test]
2874    fn a_credential_key_is_found_wherever_it_sits_in_a_compact_structure() {
2875        // Every shape the test above covers puts the credential in the
2876        // *first* key/value pair, and the rules split a word once, on the
2877        // first separator they find. So redaction was a function of field
2878        // order -- and `serde_json::to_string` is what decides field order for
2879        // any struct with more than one field, which is what an error body is.
2880        //
2881        // Nesting and a form-encoded body are the same defect wearing
2882        // different punctuation: the value recursion ends at `redact_value`,
2883        // which is a leaf, and `&` was not a separator at all.
2884        for shape in [
2885            // The credential key second, in a compact object.
2886            format!("{{\"runner_id\":42,\"encoded_jit_config\":\"{JIT_BLOB}\"}}"),
2887            format!("{{\"status\":422,\"message\":\"bad\",\"runner_token\":\"{USER_TOKEN}\"}}"),
2888            // Nested one level, and two.
2889            format!("{{\"body\":{{\"runner_token\":\"{USER_TOKEN}\"}}}}"),
2890            format!(
2891                "{{\"error\":{{\"status\":422,\"body\":{{\"encoded_jit_config\":\"{JIT_BLOB}\"}}}}}}"
2892            ),
2893            // An array of objects, which is what a list endpoint returns.
2894            format!("[{{\"id\":1}},{{\"access_token\":\"{USER_TOKEN}\"}}]"),
2895            // Form-encoded, credential second and in the middle.
2896            format!("scope=repo&access_token={USER_TOKEN}"),
2897            format!("grant_type=refresh&refresh_token={USER_TOKEN}&scope=repo"),
2898        ] {
2899            let redacted = redact(&shape);
2900            assert!(
2901                !redacted.contains(JIT_BLOB) && !redacted.contains(USER_TOKEN),
2902                "leaked from {shape}:\n{redacted}"
2903            );
2904            assert!(redacted.contains(REDACTION), "{shape} -> {redacted}");
2905        }
2906
2907        // The pairs around it stay legible. A body whose every field came back
2908        // `[redacted]` diagnoses nothing, and is how a redaction gets turned
2909        // off rather than fixed.
2910        let redacted = redact(&format!(
2911            "failed: {{\"runner_id\":42,\"encoded_jit_config\":\"{JIT_BLOB}\"}}"
2912        ));
2913        assert!(redacted.starts_with("failed: "), "{redacted}");
2914        assert!(redacted.contains("\"runner_id\":42"), "{redacted}");
2915        assert!(redacted.contains("encoded_jit_config"), "{redacted}");
2916
2917        // A credential short enough to clear the opaque-run threshold and
2918        // carrying no GitHub prefix has nothing but its key to give it away,
2919        // so it is the case the shape rules cannot rescue.
2920        for shape in [
2921            "{\"user\":\"operator\",\"password\":\"hunter2\"}",
2922            "{\"body\":{\"password\":\"hunter2\"}}",
2923            "user=operator&password=hunter2",
2924        ] {
2925            assert!(
2926                !redact(shape).contains("hunter2"),
2927                "leaked from {shape}: {}",
2928                redact(shape)
2929            );
2930        }
2931
2932        // Ordinary punctuation still survives the cut, or the structure that
2933        // makes a log line readable is gone with the secret.
2934        assert_eq!(
2935            redact("desired 3, active 1, headroom 2"),
2936            "desired 3, active 1, headroom 2"
2937        );
2938        assert_eq!(
2939            redact("labels=[linux,x64,self-hosted]"),
2940            "labels=[linux,x64,self-hosted]"
2941        );
2942    }
2943
2944    #[test]
2945    fn backslash_escaped_json_reaches_the_key_rules_and_the_value_rules() {
2946        // `tracing::error!(reason = ?err)` reaches this module through
2947        // `record_debug` and `format!("{:?}")`, and `Debug` on a `String`
2948        // escapes the quotes inside it. So an error whose `Debug` embeds an
2949        // HTTP body arrives with its keys spelled `\"password\"` -- and `\`
2950        // is not in `WRAPPERS`, so `trim_matches(WRAPPERS)` left it welded on
2951        // and no list contained the result. `reason` is on ALLOWED_FIELDS, so
2952        // this reaches the scrubber rather than being dropped.
2953        let failure = StoreError {
2954            body: "{\"password\":\"hunter2\"}".to_string(),
2955        };
2956        // The fixture carries the secret before anything has looked at it. A
2957        // premise, asserted rather than assumed: a fixture that quietly
2958        // stopped carrying one would make every assertion below vacuously
2959        // true.
2960        assert!(failure.body.contains("hunter2"), "{}", failure.body);
2961        let rendered = format!("{failure:?}");
2962        // The premise, asserted rather than assumed: `Debug` really does
2963        // produce the escaped spelling. If it ever stops, this test is
2964        // measuring something else.
2965        assert!(
2966            rendered.contains("\\\"password\\\""),
2967            "the premise is that Debug escapes the quotes: {rendered}"
2968        );
2969        assert!(
2970            !redact(&rendered).contains("hunter2"),
2971            "leaked: {}",
2972            redact(&rendered)
2973        );
2974
2975        for shape in [
2976            "{\\\"password\\\":\\\"hunter2\\\"}",
2977            "Error { body: \"{\\\"access_token\\\":\\\"hunter2\\\"}\" }",
2978        ] {
2979            assert!(
2980                !redact(shape).contains("hunter2"),
2981                "leaked from {shape}: {}",
2982                redact(shape)
2983            );
2984        }
2985
2986        // The escaped spelling of a long secret leaked too, and for the same
2987        // reason: the key was unrecognised, so nothing below it ever ran.
2988        let escaped = format!("{{\\\"encoded_jit_config\\\":\\\"{JIT_BLOB}\\\"}}");
2989        assert!(!redact(&escaped).contains(JIT_BLOB), "{}", redact(&escaped));
2990
2991        // Trimming the key is only half of it, and it is the half that is
2992        // easy to mistake for the whole. `runner_token` is deliberately *not*
2993        // on CREDENTIAL_KEYS -- a field name is not what makes a value a
2994        // secret, and this module's own documentation says so -- so that pair
2995        // is caught by the token-prefix rule reading its *value*, or it is not
2996        // caught at all. The escaped quote hid the value from that rule
2997        // exactly as it hid the key from the key rules, and a key-only fix
2998        // leaves this one leaking through the sink.
2999        assert!(
3000            !is_credential_key("runner_token"),
3001            "the premise of this case is an unlisted key; once it is listed, \
3002             this stops exercising the value side at all"
3003        );
3004        let escaped_value = format!("{{\\\"runner_token\\\":\\\"{USER_TOKEN}\\\"}}");
3005        assert!(
3006            !redact(&escaped_value).contains(USER_TOKEN),
3007            "the value side leaked: {}",
3008            redact(&escaped_value)
3009        );
3010
3011        // The trim is unconditional on a key and conditional on a value: a
3012        // backslash comes off a value only where it is escaping punctuation.
3013        // `split_wrappers` runs before `looks_like_path`, so putting `\` in
3014        // `WRAPPERS` would trim away the leading `\\` that UNC detection keys
3015        // on and turn a share path back into ordinary text.
3016        assert_eq!(redact(r"\\fileserver\share\jit"), PATH_REDACTION);
3017        assert_eq!(redact(r"\\?\C:\Users\operator\runtime"), PATH_REDACTION);
3018        // And the escaped spelling of a share path is still a share path:
3019        // `\\` escapes nothing, so it survives the trim that `\"` does not.
3020        assert_eq!(redact(r"\\\\fileserver\\share\\jit"), PATH_REDACTION);
3021        // A path ending in its own separator is still a path without it. The
3022        // separator is punctuation, so it is trimmed, judged, and put back.
3023        let trailing = redact("C:\\Users\\operator\\runtime\\");
3024        assert!(trailing.starts_with(PATH_REDACTION), "{trailing}");
3025        assert!(!trailing.contains("operator"), "{trailing}");
3026    }
3027
3028    #[test]
3029    fn a_url_does_not_make_the_rest_of_its_word_unredactable() {
3030        // `redact_core` split on the first `://` and handed *everything*
3031        // before it to `redact_url` as a scheme and everything after it as a
3032        // URL. The terminal arm of `redact_url` echoes both verbatim, so a
3033        // single URL anywhere in a word turned the whole of the rest of that
3034        // word into text no rule below could reach. The only thing that saved
3035        // the shape was a `?` or `#` inside the URL, which made `redact_url`
3036        // replace the tail.
3037        //
3038        // `documentation_url` is in essentially every GitHub REST error body,
3039        // so this was any such body logged alongside a credential.
3040        let body = format!(
3041            "{{\"message\":\"Bad credentials\",\"documentation_url\":\"https://docs.github.com/rest\",\"token\":\"{USER_TOKEN}\"}}"
3042        );
3043        let redacted = redact(&body);
3044        assert!(!redacted.contains(USER_TOKEN), "leaked: {redacted}");
3045        // The URL is the diagnosable part and must survive, or nobody keeps
3046        // this redaction.
3047        assert!(
3048            redacted.contains("https://docs.github.com/rest"),
3049            "the URL should survive: {redacted}"
3050        );
3051
3052        // The mirror order: everything before the `://` became the "scheme".
3053        let reversed = format!(
3054            "{{\"token\":\"{USER_TOKEN}\",\"documentation_url\":\"https://docs.github.com/rest\"}}"
3055        );
3056        let redacted = redact(&reversed);
3057        assert!(!redacted.contains(USER_TOKEN), "leaked: {redacted}");
3058
3059        // A nested object behind a URL was missed even when the URL's own
3060        // userinfo was caught, because the miss and the catch happened in the
3061        // same call.
3062        let clone = format!(
3063            "{{\"remote\":\"https://x-access-token:{USER_TOKEN}@github.com/o/r.git\",\"body\":{{\"password\":\"hunter2\"}}}}"
3064        );
3065        let redacted = redact(&clone);
3066        assert!(!redacted.contains(USER_TOKEN), "leaked: {redacted}");
3067        assert!(!redacted.contains("hunter2"), "leaked: {redacted}");
3068        assert!(
3069            redacted.contains("@github.com/o/r.git"),
3070            "the remote should stay diagnosable: {redacted}"
3071        );
3072
3073        // A URL still owns its own query string, which is why the URL check
3074        // sits ahead of the structural cut: `?` and `#` are not structural
3075        // characters, and an OAuth response puts the token after one of them.
3076        assert_eq!(
3077            redact(&format!(
3078                "{{\"url\":\"https://api.github.com/x?token={USER_TOKEN}\"}}"
3079            )),
3080            format!("{{\"url\":\"https://api.github.com/x?{REDACTION}\"}}")
3081        );
3082        // And a URL standing on its own is untouched.
3083        assert_eq!(
3084            redact("GET https://api.github.com/repos/o/r/actions/runners"),
3085            "GET https://api.github.com/repos/o/r/actions/runners"
3086        );
3087
3088        // A URL that is a credential key's *own* value keeps its scheme, host
3089        // and path like every other URL -- a URL is not a token, and this
3090        // module keeps exactly that much of one everywhere else. What must not
3091        // happen is the key's empty value being redacted on the way past,
3092        // which put the URL out behind a `[redacted]` that had replaced
3093        // nothing: `token=[redacted]https://evil.example/x`. Bounding the URL
3094        // is what created that shape, and pass one declining an empty value is
3095        // what closes it.
3096        assert_eq!(
3097            redact("token=https://evil.example/x"),
3098            "token=https://evil.example/x"
3099        );
3100        assert_eq!(
3101            redact("{\"token\":\"https://evil.example/x\"}"),
3102            "{\"token\":\"https://evil.example/x\"}"
3103        );
3104        // An empty credential value is nothing to redact wherever it sits, and
3105        // saying otherwise reports a secret in a place none was.
3106        assert_eq!(redact("{\"password\":\"\"}"), "{\"password\":\"\"}");
3107    }
3108
3109    #[test]
3110    fn an_array_element_is_judged_without_the_quote_that_wraps_it() {
3111        // The structural cut recursed on the *raw* fragment, and the terminal
3112        // fallback then called `redact_value` with the wrappers still on. A
3113        // fragment carrying no `:` or `=` of its own -- which is exactly what
3114        // an array element is -- therefore reached the shape rules as
3115        // `"ghu_…`: `starts_with("ghu_")` fails, `is_opaque_char` fails on the
3116        // quote, `starts_with("eyJ")` fails, and `looks_like_path` never gets
3117        // a clean look at `"C:\Users\…`.
3118        //
3119        // This is the defect the structural cut was written to close, one step
3120        // further down: "only the first key/value pair is ever examined"
3121        // became "a value with no key of its own is never examined". It
3122        // survived a round because `scan_for_leaks` had no array among its
3123        // twelve shapes.
3124        for shape in [
3125            format!("{{\"tokens\":[\"{USER_TOKEN}\",\"x\"]}}"),
3126            format!("{{\"tokens\":[\"x\",\"{USER_TOKEN}\"]}}"),
3127            format!("[\"x\",\"{USER_TOKEN}\"]"),
3128            format!("[{{\"id\":1}},[\"{USER_TOKEN}\"]]"),
3129            // The `Debug`-escaped spelling of the same thing.
3130            format!("{{\\\"tokens\\\":[\\\"x\\\",\\\"{USER_TOKEN}\\\"]}}"),
3131        ] {
3132            let redacted = redact(&shape);
3133            assert!(
3134                !redacted.contains(USER_TOKEN),
3135                "leaked from {shape}: {redacted}"
3136            );
3137            assert!(redacted.contains(REDACTION), "{shape} -> {redacted}");
3138        }
3139
3140        // Every other shape rule was reachable the same way.
3141        let jit = format!("{{\"items\":[\"{JIT_BLOB}\"]}}");
3142        assert!(!redact(&jit).contains(JIT_BLOB), "{}", redact(&jit));
3143        let assertions = format!("{{\"assertions\":[\"{JWT}\"]}}");
3144        assert!(
3145            !redact(&assertions).contains(JWT),
3146            "{}",
3147            redact(&assertions)
3148        );
3149        let opaque = "ZYXWVUTSRQPONMLKJIHGFEDCBA9876543210zyxwvut";
3150        assert!(opaque.len() > OPAQUE_RUN_THRESHOLD, "{}", opaque.len());
3151        let listed = format!("[\"x\",\"{opaque}\"]");
3152        assert!(!redact(&listed).contains(opaque), "{}", redact(&listed));
3153
3154        // A path in an array. `07-security.md` requires paths redacted, and
3155        // only `looks_like_path` can see one -- with a clean look at the value
3156        // and not before.
3157        let roots = format!("{{\"roots\":[\"x\",\"{WINDOWS_WORKSPACE}\"]}}");
3158        let redacted = redact(&roots);
3159        assert!(!redacted.contains(WINDOWS_WORKSPACE), "leaked: {redacted}");
3160        assert!(redacted.contains(PATH_REDACTION), "{redacted}");
3161
3162        // The array survives being scrubbed: this is a scrubber, not a
3163        // deleter.
3164        assert_eq!(
3165            redact("labels=[\"linux\",\"x64\"]"),
3166            "labels=[\"linux\",\"x64\"]"
3167        );
3168    }
3169
3170    #[test]
3171    fn a_semicolon_cuts_a_word_the_way_a_comma_does() {
3172        // `;` was in `WRAPPERS` -- recognised as punctuation -- and not in
3173        // `STRUCTURAL`, so it never cut a word. It is the separator for a
3174        // Windows connection string, for a credential string, and for cookie
3175        // pairs written without a space, which is `d2`'s territory exactly.
3176        // `Set-Cookie: theme=dark; session=…` was safe only because of the
3177        // space after the `;`.
3178        for shape in [
3179            "store rejected: Server=host;Database=x;Password=hunter2;",
3180            "store rejected: user=operator;password=hunter2",
3181            "Server=host;Password=hunter2;Database=x",
3182        ] {
3183            let redacted = redact(shape);
3184            assert!(
3185                !redacted.contains("hunter2"),
3186                "leaked from {shape}: {redacted}"
3187            );
3188        }
3189
3190        let cookies = format!("theme=dark;session={USER_TOKEN}");
3191        let redacted = redact(&cookies);
3192        assert!(!redacted.contains(USER_TOKEN), "leaked: {redacted}");
3193
3194        // Separators go back verbatim, so ordinary prose is unaffected.
3195        assert_eq!(
3196            redact("started; then reconciled; then idled"),
3197            "started; then reconciled; then idled"
3198        );
3199        assert_eq!(
3200            redact("desired 3;active 1;headroom 2"),
3201            "desired 3;active 1;headroom 2"
3202        );
3203    }
3204
3205    #[test]
3206    fn an_element_wrapped_value_is_redacted() {
3207        // `split_wrappers` strips only the *outermost* `<` and `>`, so
3208        // `<string>ghu_…</string>` arrived as `string>ghu_…</string`, which
3209        // matches no rule at all. `d3`'s installers handle launchd plists,
3210        // which is where this shape comes from; a systemd unit line is the
3211        // `key=value` spelling and was already covered.
3212        for shape in [
3213            format!("<string>{USER_TOKEN}</string>"),
3214            format!("<key>token</key><string>{USER_TOKEN}</string>"),
3215            format!("<dict><key>Token</key><string>{USER_TOKEN}</string></dict>"),
3216        ] {
3217            let redacted = redact(&shape);
3218            assert!(
3219                !redacted.contains(USER_TOKEN),
3220                "leaked from {shape}: {redacted}"
3221            );
3222            assert!(redacted.contains(REDACTION), "{shape} -> {redacted}");
3223        }
3224
3225        // The element names survive: they are what says the line was about a
3226        // plist at all.
3227        let plist = format!("<key>token</key><string>{JIT_BLOB}</string>");
3228        let redacted = redact(&plist);
3229        assert!(!redacted.contains(JIT_BLOB), "leaked: {redacted}");
3230        assert!(redacted.contains("<key>token</key>"), "{redacted}");
3231
3232        // An angle bracket in ordinary text is re-emitted verbatim.
3233        assert_eq!(redact("Custom<Io>"), "Custom<Io>");
3234    }
3235
3236    #[test]
3237    fn a_redacted_value_keeps_the_punctuation_that_wrapped_it() {
3238        // Pass one returned `format!("{key}{separator}{REDACTION}")` and
3239        // dropped the value's trailing wrapper, so a redacted object came out
3240        // with an unbalanced quote: `{"password":[redacted]"}`. Pass two
3241        // already put `lead` and `trail` back.
3242        //
3243        // Worth fixing because this module argues, correctly, that the
3244        // structure around a redaction is what keeps a line diagnosable -- and
3245        // a reader who cannot parse the line cannot tell a redaction from a
3246        // truncation.
3247        assert_eq!(
3248            redact("{\"password\":\"hunter2\"}"),
3249            format!("{{\"password\":\"{REDACTION}\"}}")
3250        );
3251        assert_eq!(
3252            redact(&format!("{{\"access_token\":\"{USER_TOKEN}\"}}")),
3253            format!("{{\"access_token\":\"{REDACTION}\"}}")
3254        );
3255        assert_eq!(
3256            redact(&format!(
3257                "[{{\"id\":1}},{{\"access_token\":\"{USER_TOKEN}\"}}]"
3258            )),
3259            format!("[{{\"id\":1}},{{\"access_token\":\"{REDACTION}\"}}]")
3260        );
3261        // The `Debug`-escaped spelling keeps its escaped quotes.
3262        assert_eq!(
3263            redact("{\\\"password\\\":\\\"hunter2\\\"}"),
3264            format!("{{\\\"password\\\":\\\"{REDACTION}\\\"}}")
3265        );
3266
3267        // The point of all of it: what the sink writes is still the JSON it
3268        // was handed, minus the secret.
3269        let line = redact(&format!(
3270            "{{\"runner_id\":42,\"access_token\":\"{USER_TOKEN}\"}}"
3271        ));
3272        serde_json::from_str::<Value>(&line)
3273            .unwrap_or_else(|error| panic!("a redacted body must still parse: {line} ({error})"));
3274    }
3275
3276    #[test]
3277    fn a_bare_jwt_is_redacted_despite_its_dots() {
3278        // `is_opaque_char` excludes `.`, so a JWT is three opaque runs rather
3279        // than one, each under the threshold, and a 100-character credential
3280        // printed verbatim. `Authorization: Bearer <jwt>` is caught by the
3281        // scheme rule and a credential-keyed one by the key rule, so this bit
3282        // only where a token stood alone or under a name nobody listed.
3283        assert!(
3284            JWT.len() > 100,
3285            "the premise is a long token: {}",
3286            JWT.len()
3287        );
3288        assert_eq!(redact(JWT), REDACTION);
3289        assert_eq!(
3290            redact(&format!("minted {JWT} for the installation")),
3291            format!("minted {REDACTION} for the installation")
3292        );
3293        // Sentence-final punctuation is trimmed as a wrapper first, so the
3294        // token is still recognised.
3295        assert!(!redact(&format!("minted {JWT}.")).contains(JWT));
3296        // Under a key nobody listed.
3297        assert!(!redact(&format!("assertion={JWT}")).contains(JWT));
3298        assert!(!redact(&format!("{{\"assertion\":\"{JWT}\"}}")).contains(JWT));
3299
3300        // Narrow on purpose. Adding `.` to `is_opaque_char` is the obvious fix
3301        // and the wrong one: it swallows every long dotted word there is.
3302        for ordinary in [
3303            "com.example.runner.manager.platform.process.identity.token",
3304            "runner-manager.2026.08.21.log",
3305            "api.github.com",
3306            "9f2c1a44-0000-4000-8000-000000000001.attempt.json",
3307        ] {
3308            assert_eq!(redact(ordinary), ordinary, "over-redacted {ordinary}");
3309        }
3310    }
3311
3312    #[test]
3313    fn the_sink_redacts_a_short_credential_that_only_its_key_gives_away() {
3314        // `scan_for_leaks` cannot carry this case. A credential short enough
3315        // to clear the opaque-run threshold and carrying no GitHub prefix is
3316        // indistinguishable from an ordinary word when it is logged bare, so
3317        // injecting it through shape 1 would demand a redaction no shape rule
3318        // can deliver. Here the key is always present, which is the situation
3319        // `d2`'s secret store is actually in -- and it is through the sink,
3320        // not through `redact` alone, because that distinction is why these
3321        // shapes survived a round.
3322        let capture = Capture::default();
3323        let output = emit(&capture, || {
3324            tracing::error!("store rejected: {{\"user\":\"operator\",\"password\":\"hunter2\"}}");
3325            tracing::error!("store rejected: {{\"body\":{{\"password\":\"hunter2\"}}}}");
3326            tracing::error!("store rejected: user=operator&password=hunter2");
3327            let failure = StoreError {
3328                body: "{\"password\":\"hunter2\"}".to_string(),
3329            };
3330            tracing::error!(reason = ?failure, "store rejected the request");
3331
3332            // A `;` or an `&` after a URL in the same word. The URL branch runs
3333            // ahead of the structural cut and `split_url` did not stop at
3334            // either character, so the separator that would have cut the word
3335            // was swallowed into the URL and echoed.
3336            tracing::error!("store rejected: Server=https://vault.local/api;Password=hunter2;");
3337            tracing::error!("keychain error: url=https://kc.local;password=hunter2");
3338            tracing::error!("unit rejected: Environment=API=https://a.com/v1;PASSWORD=hunter2");
3339            tracing::error!("token exchange failed: cb=https://a.com/x&password=hunter2");
3340
3341            // A credential key whose value is in a *later* fragment. The
3342            // empty-value skip is right; the claim that the structural cut
3343            // reaches such a value was not, because nothing carried the key
3344            // across the cut.
3345            tracing::error!("store rejected: {{\"password\":[\"hunter2\"]}}");
3346            tracing::error!("store rejected: {{\"password\":{{\"v\":\"hunter2\"}}}}");
3347            tracing::error!("store rejected: Password=;hunter2");
3348            tracing::error!("plist rejected: <key>password</key><string>hunter2</string>");
3349            tracing::error!(
3350                "plist rejected: <dict><key>password</key><string>hunter2</string></dict>"
3351            );
3352
3353            // A structural character inside the value. `,` and `&` could
3354            // already split a secret; this commit added `;`, `<` and `>`.
3355            //
3356            // The pieces are spelled so that none of them is a substring of
3357            // anything the line legitimately keeps -- `ss` would have "found" a
3358            // leak in the surviving key `password`, which is a check that fails
3359            // for the wrong reason and is no better than one that cannot fail.
3360            tracing::error!("store rejected: {{\"password\":\"qq<vv>xx\"}}");
3361            tracing::error!("store rejected: {{\"password\":\"j1&k2,m3\"}}");
3362            tracing::error!("store rejected: {{\"password\":\"n4;p5<q6>r7,t8\"}}");
3363        });
3364
3365        assert!(
3366            !output.contains("hunter2"),
3367            "a short credential leaked through the sink:\n{output}"
3368        );
3369        // The punctuated passwords: every piece a structural character can cut
3370        // one into has to go, not just the piece that kept the key company.
3371        for piece in [
3372            "qq", "vv", "xx", "j1", "k2", "m3", "n4", "p5", "q6", "r7", "t8",
3373        ] {
3374            assert!(
3375                !output.contains(piece),
3376                "a structural character split a secret and the tail survived \
3377                 ({piece}):\n{output}"
3378            );
3379        }
3380        assert!(output.contains(REDACTION), "{output}");
3381        // Sixteen lines, or the sink was never reached and "no secret found" is
3382        // true of an empty string.
3383        assert_eq!(
3384            output
3385                .lines()
3386                .filter(|line| !line.trim().is_empty())
3387                .count(),
3388            16,
3389            "{output}"
3390        );
3391    }
3392
3393    /// The multi-word plist spelling, which is worse than the one-word one:
3394    /// the credential key and the value are in the same word, but the value
3395    /// itself is two words, so closing it needs the fragment carry to reach the
3396    /// word-level follow-on rule.
3397    #[test]
3398    fn a_credential_value_that_runs_past_its_own_word_is_still_redacted() {
3399        let capture = Capture::default();
3400        let output = emit(&capture, || {
3401            tracing::error!("plist rejected: <key>password</key><string>correct horse</string>");
3402            tracing::error!("plist rejected: <key>password</key> <string>correct horse</string>");
3403        });
3404        for piece in ["correct", "horse"] {
3405            assert!(
3406                !output.contains(piece),
3407                "a multi-word credential value leaked ({piece}):\n{output}"
3408            );
3409        }
3410    }
3411
3412    /// The other half of the fragment carry: it must not swallow the ordinary,
3413    /// *closed* pairs sitting next to a credential.
3414    ///
3415    /// Every shape here is one the carry has an opportunity to over-redact —
3416    /// a closed empty value, a neighbouring key, the pairs of a connection
3417    /// string, and the element names of a plist. Redaction that eats the
3418    /// diagnostics is redaction nobody keeps, and a carry with no terminator
3419    /// is exactly how that happens.
3420    #[test]
3421    fn the_fragment_carry_stops_where_the_value_does() {
3422        for (input, expected) in [
3423            (
3424                "{\"password\":\"abc\",\"user\":\"bob\"}",
3425                format!("{{\"password\":\"{REDACTION}\",\"user\":\"bob\"}}"),
3426            ),
3427            (
3428                "Server=host;Password=hunter2;User=bob",
3429                format!("Server=host;Password={REDACTION};User=bob"),
3430            ),
3431            (
3432                "{\"password\":\"\",\"user\":\"bob\"}",
3433                "{\"password\":\"\",\"user\":\"bob\"}".to_string(),
3434            ),
3435            (
3436                "<dict><key>password</key><string>hunter2</string></dict>",
3437                format!("<dict><key>password</key><string>{REDACTION}</string></dict>"),
3438            ),
3439            (
3440                "{\"password\":\"qq<vv>xx\"}",
3441                format!("{{\"password\":\"{REDACTION}<{REDACTION}>{REDACTION}\"}}"),
3442            ),
3443        ] {
3444            assert_eq!(redact(input), expected, "from {input}");
3445        }
3446
3447        // A closed value ends the claim, so the word after it is ordinary text.
3448        assert_eq!(
3449            redact("{\"password\":\"x\"} ok"),
3450            format!("{{\"password\":\"{REDACTION}\"}} ok")
3451        );
3452        assert_eq!(
3453            redact("Server=host;Password=hunter2; ok"),
3454            format!("Server=host;Password={REDACTION}; ok")
3455        );
3456
3457        // `{"password":""} ok` is *not* on that list, and the reason is worth
3458        // recording rather than asserting away. `split_wrappers` strips the
3459        // empty value with the rest of the trailing punctuation, so the word
3460        // reaches `redact_word` as the core `password":` — a bare credential
3461        // key, which the stem rule has always read as "the value is the next
3462        // word". That rule predates the carry, is untouched by it, and fires
3463        // first; the empty-value skip below it never gets a look. So the `ok`
3464        // is redacted, exactly as it was before any of this.
3465        assert_eq!(
3466            redact("{\"password\":\"\"} ok"),
3467            format!("{{\"password\":\"\"}} {REDACTION}")
3468        );
3469        // What the empty-value skip does still guarantee is the thing it was
3470        // written for: no `[redacted]` is invented *inside* the pair itself.
3471        assert_eq!(redact("{\"password\":\"\"}"), "{\"password\":\"\"}");
3472
3473        // Ordinary prose is untouched: the carry is only ever armed by a
3474        // credential key.
3475        assert_eq!(
3476            redact("started; then reconciled; then idled"),
3477            "started; then reconciled; then idled"
3478        );
3479        assert_eq!(redact("Custom<Io>"), "Custom<Io>");
3480    }
3481
3482    #[test]
3483    fn paths_are_redacted_on_both_families() {
3484        assert_eq!(redact(WORKSPACE), PATH_REDACTION);
3485        assert_eq!(redact(WINDOWS_WORKSPACE), PATH_REDACTION);
3486        assert_eq!(redact("\\\\fileserver\\share\\jit"), PATH_REDACTION);
3487        assert_eq!(
3488            redact("~/Library/Application Support/x"),
3489            format!("{PATH_REDACTION} Support/x")
3490        );
3491        assert_eq!(
3492            redact("runtime=/var/lib/runner-manager/runtime"),
3493            format!("runtime={PATH_REDACTION}")
3494        );
3495    }
3496
3497    #[test]
3498    fn a_url_survives_but_its_query_string_and_fragment_do_not() {
3499        assert_eq!(
3500            redact("GET https://api.github.com/repos/owner/repo/actions/runners"),
3501            "GET https://api.github.com/repos/owner/repo/actions/runners"
3502        );
3503        assert_eq!(
3504            redact("https://api.github.com/x?access_token=ghu_secret"),
3505            format!("https://api.github.com/x?{REDACTION}")
3506        );
3507        // A fragment carries a token in an OAuth implicit-flow response, and
3508        // is never needed to diagnose an HTTP call.
3509        assert_eq!(
3510            redact("https://api.github.com/x#access_token=ghu_secret"),
3511            format!("https://api.github.com/x#{REDACTION}")
3512        );
3513        // Whichever comes first ends the diagnosable part.
3514        assert_eq!(
3515            redact("https://api.github.com/x?page=2#access_token=ghu_secret"),
3516            format!("https://api.github.com/x?{REDACTION}")
3517        );
3518    }
3519
3520    /// `;` and `&` end a URL, but only ahead of its query string.
3521    ///
3522    /// `STRUCTURAL` has nine characters and `is_url_terminator` recognised
3523    /// seven of them. Because the URL branch runs *ahead* of the structural
3524    /// cut, a URL earlier in a word made `split_url` swallow everything to the
3525    /// next terminator — including the `;` or `&` that would have cut the word
3526    /// — and `redact_url`'s terminal arm echoes what it is given. So the L3 fix
3527    /// (bounding a URL) and the L1 fix (cutting on `;` and `&`) did not
3528    /// compose, and this commit added `;` to `STRUCTURAL` for connection
3529    /// strings while leaving the path a URL opens straight through it.
3530    #[test]
3531    fn a_separator_after_a_url_still_cuts_the_word() {
3532        for shape in [
3533            format!("store rejected: Server=https://vault.local/api;Password={USER_TOKEN};"),
3534            format!("keychain error: url=https://kc.local;secret={USER_TOKEN}"),
3535            format!("unit rejected: Environment=API=https://a.com/v1;TOKEN={USER_TOKEN}"),
3536            format!("token exchange failed: cb=https://a.com/x&access_token={USER_TOKEN}"),
3537            format!("dsn rejected: dsn=https://sentry.local/1;password={USER_TOKEN};user=x"),
3538        ] {
3539            let redacted = redact(&shape);
3540            assert!(
3541                !redacted.contains(USER_TOKEN),
3542                "leaked from {shape}:\n{redacted}"
3543            );
3544        }
3545
3546        // The URL itself still survives, or nobody keeps this redaction.
3547        assert_eq!(
3548            redact("store rejected: Server=https://vault.local/api;Password=hunter2;"),
3549            format!("store rejected: Server=https://vault.local/api;Password={REDACTION};")
3550        );
3551
3552        // The obvious fix is the wrong one, and this is what it costs. Making
3553        // `;` and `&` terminators everywhere ends a URL *inside* its own query
3554        // string, and `redact_url` replaces a query wholesale precisely because
3555        // a token can be in any parameter and this module does not guess which:
3556        // `?[redacted]` would become `?[redacted]&state=hunter2`. So the cut is
3557        // restricted to the part of the URL ahead of the first `?` or `#`.
3558        assert_eq!(
3559            redact("https://a.com/cb?code=1&state=hunter2"),
3560            format!("https://a.com/cb?{REDACTION}")
3561        );
3562        assert_eq!(
3563            redact("https://a.com/cb#code=1&state=hunter2"),
3564            format!("https://a.com/cb#{REDACTION}")
3565        );
3566        assert_eq!(
3567            redact(&format!(
3568                "https://a.com/cb?a=1;b=2&access_token={USER_TOKEN}"
3569            )),
3570            format!("https://a.com/cb?{REDACTION}")
3571        );
3572
3573        // And leaving `\` out of the terminators is still right: a Windows
3574        // path used as a URL password puts the `@` that identifies the
3575        // userinfo behind the first backslash, and `redact_url` echoes what it
3576        // is given.
3577        assert_eq!(
3578            redact(r"https://x-access-token:C:\Users\op\p@github.com/o/r.git"),
3579            format!("https://{REDACTION}@github.com/o/r.git")
3580        );
3581    }
3582
3583    /// A secret in a URL *path* is echoed verbatim.
3584    ///
3585    /// `redact_url` applied no shape rule to the path at all. The design
3586    /// intends a path to be diagnosable — and it still is — but the
3587    /// token-prefix rule is documented as a belt that catches a credential
3588    /// *anywhere*, and a path was the one place it never ran.
3589    #[test]
3590    fn a_secret_in_a_url_path_is_redacted_and_the_rest_of_the_path_is_not() {
3591        for shape in [
3592            format!("https://github.com/o/r/raw/{USER_TOKEN}/f"),
3593            format!("https://api.github.com/{JIT_BLOB}"),
3594            format!("https://a.com/x/{JWT}?page=2"),
3595        ] {
3596            let redacted = redact(&shape);
3597            assert!(
3598                !redacted.contains(USER_TOKEN)
3599                    && !redacted.contains(JIT_BLOB)
3600                    && !redacted.contains(JWT),
3601                "leaked from {shape}:\n{redacted}"
3602            );
3603        }
3604
3605        assert_eq!(
3606            redact(&format!("https://github.com/o/r/raw/{USER_TOKEN}/f")),
3607            format!("https://github.com/o/r/raw/{REDACTION}/f")
3608        );
3609
3610        // The rest of the path is exactly as diagnosable as it was: a segment
3611        // is judged on its own, and an ordinary one is not a secret.
3612        for url in [
3613            "https://api.github.com/repos/owner/repo/actions/runners",
3614            "https://github.com/actions/runner/releases/download/v2.330.0/actions-runner-linux-x64-2.330.0.tar.gz",
3615            "https://github.com/o/r.git",
3616            "https://github.com/@owner/repo",
3617            "https://api.github.com/repos/o/r/actions/runners/42",
3618        ] {
3619            assert_eq!(redact(url), url, "over-redacted a diagnosable path: {url}");
3620        }
3621    }
3622
3623    /// A large message must not take the process down.
3624    ///
3625    /// The URL branch recursed into `redact_core` on both sides of the cut. The
3626    /// termination argument was sound — every call is on a strictly shorter
3627    /// slice — and it was silent on *depth*, which was linear in the length of
3628    /// the input. A message of ~2000 URL-carrying items, about 86 KB, exited
3629    /// `0xc00000fd STATUS_STACK_OVERFLOW`.
3630    ///
3631    /// **A stack overflow is not catchable — it aborts the process.** A large
3632    /// HTTP error body is attacker-influenceable content, so this was a way to
3633    /// kill the agent from inside its own log sink, which is worse than any
3634    /// leak: a sink that is not running redacts nothing.
3635    ///
3636    /// Note what this test can and cannot do. An overflow aborts the test
3637    /// binary rather than failing an assertion, so the value of this test is
3638    /// that **the suite completes at all**; the assertions below only confirm
3639    /// it did the work rather than skipping it.
3640    #[test]
3641    fn a_large_message_does_not_overflow_the_stack() {
3642        // 20000 items is ~860 KB and an order of magnitude past the ~2000 that
3643        // was measured to abort. It is also what the commit *before* the URL
3644        // branch survived, because that branch returned without recursing --
3645        // so this is a regression guard with the margin the regression had.
3646        const ITEMS: usize = 20_000;
3647        let body = r#"{"url":"https://api.github.com/repos/o/r"},"#.repeat(ITEMS);
3648        assert!(body.len() > 800_000, "the premise is a large body");
3649
3650        let redacted = redact(&body);
3651        assert!(
3652            redacted.contains("https://api.github.com/repos/o/r"),
3653            "the URLs are the diagnosable part and must survive"
3654        );
3655
3656        // The same depth, reached through the structural cut and through a
3657        // credential value rather than through a bare URL.
3658        let nested = format!("{{\"error\":{{\"body\":{{\"list\":[{}]}}}}}}", body);
3659        assert!(!redact(&nested).is_empty());
3660
3661        // A long run with no URL in it at all, so the structural loop is
3662        // exercised on its own.
3663        let flat = "{\"a\":1},".repeat(ITEMS);
3664        assert!(!redact(&flat).is_empty());
3665
3666        // And one long unbroken URL, where the *path* loop is what iterates.
3667        let long_path = format!("https://a.com/{}", "seg/".repeat(ITEMS));
3668        assert!(!redact(&long_path).is_empty());
3669    }
3670
3671    #[test]
3672    fn a_credential_header_loses_its_scheme_and_its_value() {
3673        assert_eq!(
3674            redact("Authorization: Bearer abc123"),
3675            format!("Authorization: {REDACTION} {REDACTION}")
3676        );
3677        assert_eq!(
3678            redact("Authorization: Bearer abc123, Accept: application/json"),
3679            // The comma ends the header value, so the next header's name is not
3680            // swallowed.
3681            format!("Authorization: {REDACTION} {REDACTION} Accept: application/json")
3682        );
3683        assert_eq!(
3684            redact("x-api-key=hunter2"),
3685            format!("x-api-key={REDACTION}")
3686        );
3687        assert_eq!(
3688            redact("Cookie: session=abc"),
3689            format!("Cookie: {REDACTION}")
3690        );
3691
3692        // A webhook signature is not a credential the way a token is -- it is
3693        // derived, and it verifies rather than authenticates -- but it has a
3694        // SHA-256's exact shape, so the digest carve-out rendered it as a
3695        // labelled 12-character prefix rather than redacting it. Twelve hex
3696        // characters of an HMAC are of no use to anybody, which is why the
3697        // carve-out is safe in general; the header is on the list anyway,
3698        // because there is no diagnostic worth having in a signature and one
3699        // entry is cheaper than the argument.
3700        let hmac = "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08";
3701        assert_eq!(
3702            redact(&format!("X-Hub-Signature-256: sha256={hmac}")),
3703            format!("X-Hub-Signature-256: {REDACTION}")
3704        );
3705        // Pass one puts the value's wrapping quote back, so what comes out is
3706        // still the object that went in. It used to emit
3707        // `{"x-hub-signature-256":[redacted]"}` -- an unbalanced quote in a
3708        // line this module argues has to stay diagnosable.
3709        //
3710        // This case is also `redact_core`'s two-pass witness, and the comment
3711        // there names it: the `=` comes first and names nothing, but what
3712        // follows it is a whole HMAC -- non-empty -- so a merged pass inspects
3713        // that value and returns before ever reaching the `:` that names
3714        // `x-hub-signature-256`.
3715        assert_eq!(
3716            redact(&format!("{{\"x-hub-signature-256\":\"sha256={hmac}\"}}")),
3717            format!("{{\"x-hub-signature-256\":\"{REDACTION}\"}}")
3718        );
3719        // The SHA-1 spelling GitHub still sends alongside it.
3720        assert_eq!(
3721            redact(&format!("X-Hub-Signature: sha1={hmac}")),
3722            format!("X-Hub-Signature: {REDACTION}")
3723        );
3724        // The carve-out itself is untouched: a bare digest still renders as a
3725        // prefix, because a checksum gate that cannot say which digest it got
3726        // is a gate nobody can act on.
3727        assert_eq!(redact(hmac), "sha256:9f86d081884c…");
3728    }
3729
3730    #[test]
3731    fn ordinary_text_survives_intact() {
3732        // Redaction that eats the diagnostics is redaction nobody keeps. These
3733        // are the shapes this product's own log lines actually take.
3734        for text in [
3735            "reconciliation finished",
3736            "runner exited idle without work",
3737            "desired 3, active 1, headroom 2",
3738            "attempt 9f2c1a44-0000-4000-8000-000000000001 is busy",
3739            "http 403 after 2 retries",
3740            "windows/x64 is a documented pair",
3741        ] {
3742            assert_eq!(redact(text), text, "redaction damaged an ordinary message");
3743        }
3744    }
3745
3746    /// A SHA-256 is 64 opaque characters and would otherwise be swallowed by
3747    /// the opaque-run rule. `07-security.md` makes checksum verification a
3748    /// security gate, and a gate that cannot say *which* digest it got is a
3749    /// gate nobody can act on, so a digest renders as a labelled prefix.
3750    #[test]
3751    fn a_digest_renders_as_a_prefix_rather_than_disappearing() {
3752        let digest = "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08";
3753
3754        assert_eq!(redact(digest), "sha256:9f86d081884c…");
3755        // The point of the change: expected-versus-actual stays legible.
3756        let other = "60303ae22b998861bce3b28f33eec1be758a213c86c93c076dbe9f558c11c752";
3757        assert_ne!(
3758            redact(digest),
3759            redact(other),
3760            "two digests must stay distinguishable"
3761        );
3762
3763        // In the sentence shape `e2` will actually write.
3764        assert_eq!(
3765            redact(&format!("checksum mismatch: expected {digest} got {other}")),
3766            "checksum mismatch: expected sha256:9f86d081884c… got sha256:60303ae22b99…"
3767        );
3768
3769        // A prefix a caller logged itself is below the threshold and untouched.
3770        assert_eq!(redact(&digest[..12]), digest[..12].to_string());
3771
3772        // An already-labelled digest is labelled once, not twice, and is
3773        // truncated like a bare one.
3774        //
3775        // Before `redact_core` recursed into a `:` value this arrived at
3776        // `redact_value` whole, matched nothing there -- a `:` is not an
3777        // opaque character -- and was printed in full. Recursing hands
3778        // `as_sha256_digest` the digest on its own, which re-attaches its own
3779        // label, so the redundant one is dropped rather than doubled.
3780        //
3781        // The truncation is the part worth having: a 64-character lowercase
3782        // hex run is only *usually* a digest, an HMAC-SHA256 signature has
3783        // the same shape, and `sha256:<hmac>` used to be emitted whole.
3784        assert_eq!(redact(&format!("sha256:{digest}")), "sha256:9f86d081884c…");
3785        // The `=` spelling had the same doubling and is fixed with it; the
3786        // caller's own separator survives either way.
3787        assert_eq!(redact(&format!("sha256={digest}")), "sha256=9f86d081884c…");
3788    }
3789
3790    #[test]
3791    fn the_digest_exception_is_exactly_sixty_four_lowercase_hex_and_nothing_else() {
3792        // The narrower this carve-out is, the less there is to reason about.
3793        // Anything that is not precisely a lowercase SHA-256 stays redacted by
3794        // the opaque-run rule.
3795        let digest = "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08";
3796
3797        assert_eq!(
3798            redact(&digest.to_ascii_uppercase()),
3799            REDACTION,
3800            "uppercase hex is not the shape this exception recognises"
3801        );
3802        assert_eq!(
3803            redact(&format!("{digest}0")),
3804            REDACTION,
3805            "65 characters is not a SHA-256"
3806        );
3807        // 64 characters of base64, which is what an encoded secret of that
3808        // length looks like: not hex, so not exempt.
3809        let base64ish = "ZYXWVUTSRQPONMLKJIHGFEDCBA9876543210zyxwvutsrqponmlkjihgfedcba98";
3810        assert_eq!(base64ish.len(), 64);
3811        assert_eq!(redact(base64ish), REDACTION);
3812
3813        // And the JIT blob, which is the thing the opaque-run rule exists for,
3814        // must not have been weakened by any of this.
3815        assert_eq!(redact(JIT_BLOB), REDACTION);
3816    }
3817
3818    #[test]
3819    fn a_uuid_survives() {
3820        // 36 characters, below the opaque-run threshold on purpose: every
3821        // identifier in this product's domain model is a UUID, and redacting
3822        // them would make the logs useless.
3823        let id = "9f2c1a44-0000-4000-8000-000000000001";
3824        assert_eq!(redact(id), id);
3825        assert!(id.len() < OPAQUE_RUN_THRESHOLD);
3826    }
3827
3828    #[test]
3829    fn redaction_preserves_whitespace_and_line_structure() {
3830        let input = "line one\nAuthorization: Bearer x\nline three";
3831        let redacted = redact(input);
3832        assert_eq!(redacted.lines().count(), 3, "{redacted}");
3833        assert!(redacted.starts_with("line one\n"), "{redacted}");
3834        assert!(redacted.ends_with("\nline three"), "{redacted}");
3835    }
3836}