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