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