faultbox 0.1.1

Production black-box recorder: structured crash, corruption, and invariant-violation reports with a flight-recorder breadcrumb trail — debuggable from a report without reproduction or shipped symbols.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
// SPDX-License-Identifier: MIT OR Apache-2.0

//! The redaction contract `BasicRedactor` owes its adopters.
//!
//! `BasicRedactor` is the default a project reaches for when it wants reports
//! that are safe to bundle and submit, so its failure mode matters more than
//! its precision: leaving a diagnostic field masked costs a debugging session,
//! leaving a live credential unmasked ships it to whoever receives the report.
//!
//! These tests pin the shapes credentials actually arrive in — HTTP header
//! lines, JSON bodies echoed by a client's error string, `Debug` output of a
//! config struct, TOML-ish key/value dumps, environment variables, structured
//! breadcrumb fields — rather than the single `key=value` shape that is easy to
//! scan for. Each case asserts both the redacted rendering *and*, where a
//! credential is involved, that no fragment of the secret survives anywhere in
//! the output: the failure being guarded against is a silent partial mask, and
//! an equality assertion alone tends to be rewritten to match whatever the
//! implementation happens to emit.

use faultbox::{BasicRedactor, Redactor};

/// A redactor with a home directory that cannot match anything on the test
/// machine, so these cases exercise credential handling alone.
fn redactor() -> BasicRedactor {
    BasicRedactor::new().home("/nonexistent-home-for-tests")
}

/// The load-bearing assertion: the secret is *gone*, not merely shortened.
fn assert_no_trace(output: &str, secret: &str) {
    assert!(
        !output.contains(secret),
        "redacted output still carries the credential\n  secret: {secret}\n  output: {output}"
    );
}

#[test]
fn authorization_header_credentials_are_masked_past_the_scheme() {
    let r = redactor();

    // The shape an HTTP client's error string echoes. The credential follows a
    // scheme word, so a value that ends at the first space ends on `Bearer`.
    let out = r.redact("request failed: authorization: Bearer sk-live-4242deadbeef4242 rejected");
    assert_no_trace(&out, "sk-live-4242deadbeef4242");
    assert_eq!(
        out, "request failed: authorization: [redacted] rejected",
        "the scheme and its credential are one value; the surrounding prose stays readable"
    );

    let out = r.redact("authorization: Basic YWRhOmh1bnRlcjI=");
    assert_no_trace(&out, "YWRhOmh1bnRlcjI=");
}

#[test]
fn quoted_values_are_masked_to_the_closing_quote() {
    let r = redactor();

    // `Debug` output of a config whose secret contains a space.
    let out = r.redact(r#"Config { password: "hunter 2", port: 5432 }"#);
    assert_no_trace(&out, "hunter 2");
    assert_no_trace(&out, " 2");
    assert_eq!(
        out, r#"Config { password: "[redacted]", port: 5432 }"#,
        "the quotes delimit the value; the diagnostic field after it survives"
    );

    let out = r.redact("secret = 'multi word value'");
    assert_no_trace(&out, "multi word value");
    assert_no_trace(&out, "word value");
}

#[test]
fn a_quote_inside_an_unquoted_value_is_content_not_the_end_of_it() {
    let r = redactor();

    // The same failure one byte further down: a value that is not *delimited*
    // by quotes may still contain one, and ending it there leaks the tail.
    let out = r.redact("secret=don't-tell-anyone");
    assert_no_trace(&out, "t-tell-anyone");
    assert_eq!(out, "secret=[redacted]");
}

#[test]
fn quoted_keys_are_recognised() {
    let r = redactor();

    // A JSON body or header map rendered into an error string: the key is
    // quoted, so it no longer ends immediately before the separator.
    let out = r.redact(r#"{"api_key":"sk-plain-1234"}"#);
    assert_no_trace(&out, "sk-plain-1234");
    assert_eq!(out, r#"{"api_key":"[redacted]"}"#);

    let out = r.redact(r#"upstream said: {"authorization": "Bearer sk-live-9999"}"#);
    assert_no_trace(&out, "sk-live-9999");
}

#[test]
fn whitespace_around_the_separator_does_not_defeat_matching() {
    let r = redactor();

    // TOML and most hand-written config dumps space the separator out.
    let out = r.redact(r#"token = "sk-toml-1234""#);
    assert_no_trace(&out, "sk-toml-1234");
    assert_eq!(out, r#"token = "[redacted]""#);

    let out = r.redact("Authorization : Bearer sk-spaced-1234");
    assert_no_trace(&out, "sk-spaced-1234");
}

/// A composite value is masked in full, not just its first element.
///
/// `Option<String>` is the ordinary shape for an optional API key, and a stray
/// `{:?}` of the config struct holding it is exactly the accident a redactor
/// exists to absorb — but the wrapper is only the smallest version of the
/// problem. Whenever the value is built out of more than one piece, masking the
/// first and stopping prints the `[redacted]` marker *beside* the surviving
/// secret, which reads as handled and is worse than an obvious miss.
#[test]
fn composite_values_are_masked_through_their_structure() {
    let r = redactor();

    let out =
        r.redact(r#"LlmConfig { api_key: Some("sk-azure-9f8e7d6c"), endpoint: "https://x" }"#);
    assert_no_trace(&out, "sk-azure-9f8e7d6c");
    assert_eq!(
        out, r#"LlmConfig { api_key: Some("[redacted]"), endpoint: "https://x" }"#,
        "the wrapper is structure worth keeping, and a sibling field is not the secret"
    );

    // An unquoted sibling outside the group is not swept up with it.
    let out = r.redact(r#"Config { token: Some("sk-opt-1234"), retries: 3 }"#);
    assert_no_trace(&out, "sk-opt-1234");
    assert_eq!(out, r#"Config { token: Some("[redacted]"), retries: 3 }"#);

    // Every element, not the first one.
    let out = r.redact(r#"token: Credentials("ada", "hunter2")"#);
    assert_no_trace(&out, "hunter2");
    assert_eq!(out, r#"token: Credentials("[redacted]", "[redacted]")"#);

    let out = r.redact(r#"token: ["aaa", "bbb"]"#);
    assert_no_trace(&out, "bbb");

    // A braced `Debug` struct spaces its bracket out, and nests field names.
    let out = r.redact(r#"password: Secret { inner: "hunter2", tries: 3 }"#);
    assert_no_trace(&out, "hunter2");
    assert_eq!(
        out, r#"password: Secret { inner: "[redacted]", tries: [redacted] }"#,
        "under a key that means credential outright, everything inside is suspect"
    );

    let out = r.redact(r#"api_key: Some(Secret("hunter2"))"#);
    assert_no_trace(&out, "hunter2");
}

#[test]
fn a_composite_under_a_credential_adjacent_key_is_judged_field_by_field() {
    let r = redactor();

    // A weak key must not mask a whole structure on the strength of its own
    // name — `grouping_key` is what a report is fingerprinted by. Its members
    // are judged on their own names instead, so the nested credential is still
    // caught.
    let out = r.redact(r#"grouping_key: Key { kind: "0x09", password: "hunter2" }"#);
    assert_no_trace(&out, "hunter2");
    assert_eq!(
        out,
        r#"grouping_key: Key { kind: "0x09", password: "[redacted]" }"#
    );
}

#[test]
fn hyphenated_header_keys_are_recognised() {
    let r = redactor();

    let out = r.redact("x-api-key: sk-header-1234");
    assert_no_trace(&out, "sk-header-1234");
    assert_eq!(out, "x-api-key: [redacted]");

    let out = r.redact("proxy-authorization: Bearer sk-proxy-1234");
    assert_no_trace(&out, "sk-proxy-1234");
}

#[test]
fn credential_bearing_key_suffixes_are_recognised() {
    let r = redactor();

    // Vendor environment variables name the credential by suffix, never by the
    // exact word a fixed key list happens to contain.
    let out = r.redact("AZURE_OPENAI_KEY=sk-azure-abcdef123456");
    assert_no_trace(&out, "sk-azure-abcdef123456");
    assert_eq!(out, "AZURE_OPENAI_KEY=[redacted]");

    let out = r.redact("GITHUB_ACCESS_TOKEN=ghp_abcdefghijklmnopqrstuvwxyz0123456789");
    assert_no_trace(&out, "ghp_abcdefghijklmnopqrstuvwxyz0123456789");

    let out = r.redact("stripe_secret_key=sk_live_abcdefghijklmnop");
    assert_no_trace(&out, "sk_live_abcdefghijklmnop");
}

#[test]
fn structural_keys_that_merely_end_in_key_stay_readable() {
    let r = redactor();

    // The cost of matching suffixes is over-redaction of ordinary diagnostic
    // fields, which are the whole point of a report. A key that names a
    // position rather than a credential keeps its value: nothing about these
    // is credential-shaped.
    assert_eq!(r.redact("grouping_key=kind=0x09"), "grouping_key=kind=0x09");
    assert_eq!(r.redact("sort_key=page_id"), "sort_key=page_id");
    assert_eq!(r.redact("partition_key=7"), "partition_key=7");
}

#[test]
fn json_object_keys_naming_a_secret_are_masked() {
    let r = redactor();

    // Breadcrumb fields and `DomainContext` payloads reach the writer as JSON,
    // where the key lives in the object rather than in the string, so a
    // per-string scan for `key=value` never sees it.
    let mut v = serde_json::json!({
        "authorization": "Bearer sk-json-1234",
        "page_id": 828,
        "upstream": { "password": "hunter2" },
        "headers": [ { "x-api-key": "sk-array-1234" } ],
    });
    r.redact_json(&mut v);

    let rendered = v.to_string();
    assert_no_trace(&rendered, "sk-json-1234");
    assert_no_trace(&rendered, "hunter2");
    assert_no_trace(&rendered, "sk-array-1234");
    assert_eq!(v["page_id"], 828, "structural fields survive untouched");
}

#[test]
fn every_address_in_a_list_is_masked() {
    let r = redactor();

    // A comma-joined recipient list is one whitespace-delimited token, so
    // splitting on spaces alone sees a single malformed address and lets both
    // real ones through.
    let out = r.redact("owners ada@example.com,grace@example.com lost the write");
    assert_no_trace(&out, "ada@example.com");
    assert_no_trace(&out, "grace@example.com");
    assert_eq!(out, "owners [email],[email] lost the write");

    let out = r.redact("from <ada@example.com>; to <grace@example.com>");
    assert_no_trace(&out, "ada@example.com");
    assert_no_trace(&out, "grace@example.com");
}

#[test]
fn addresses_delimited_by_other_whitespace_do_not_swallow_the_line() {
    let r = redactor();

    // Error chains and `Debug` output are multi-line and tab-aligned. Treating
    // a space as the only separator makes the whole surrounding line part of
    // the address, and it is replaced wholesale — destroying the diagnostic
    // that the report exists to carry.
    assert_eq!(
        r.redact("caused by:\nada@example.com lost the write"),
        "caused by:\n[email] lost the write"
    );
    assert_eq!(
        r.redact("owner\tada@example.com\tstate=open"),
        "owner\t[email]\tstate=open"
    );
}

#[test]
fn url_query_secrets_end_at_the_parameter_boundary() {
    let r = redactor();

    // `&` separates query parameters, so a value that runs to the end of the
    // string swallows every remaining diagnostic after the credential.
    let out = r.redact("GET https://api.example.com/v1?api_key=sk-url-1234&page=3 -> 401");
    assert_no_trace(&out, "sk-url-1234");
    assert_eq!(
        out,
        "GET https://api.example.com/v1?api_key=[redacted]&page=3 -> 401"
    );
}

#[test]
fn plain_assignments_and_lookalike_keys_keep_their_existing_behaviour() {
    let r = redactor();

    // The shapes that already worked must keep working: a masked credential
    // must not start eating the diagnostic fields that follow it.
    assert_eq!(
        r.redact("connect failed token=sk-abc123 retries=3"),
        "connect failed token=[redacted] retries=3"
    );
    assert_eq!(r.redact("broken_tokens=4"), "broken_tokens=4");
    assert_eq!(r.redact("page_id=828"), "page_id=828");
    assert_eq!(r.redact("user@localhost"), "user@localhost");
}

/// An unquoted value is one token, and the message around it survives.
///
/// The alternative — running the value on to the next delimiter — masks a
/// multi-word secret, but it cannot tell a secret from prose, because nothing
/// in the text says which is which. It turns the messages below into a bare
/// `[redacted]`, destroying exactly what a report is for. A value carrying
/// whitespace that is neither quoted nor scheme-introduced is not reliably
/// delimited for any reader; quoting it or naming a scheme is what makes it
/// one, and both of those are covered. What closes the remaining gap is not a
/// wider value rule but keyless recognition: a credential that *looks* like
/// one is masked wherever it sits, as the last case here shows.
#[test]
fn an_unquoted_value_is_one_token_and_the_message_survives() {
    let r = redactor();

    assert_eq!(
        r.redact("authorization: header missing"),
        "authorization: [redacted] missing"
    );
    assert_eq!(
        r.redact("password: incorrect for user 828"),
        "password: [redacted] for user 828"
    );

    // The safety net: no key needed, so a real credential is caught even where
    // the value rule stops early.
    let out = r.redact("password: was sk-live-4242deadbeef4242 all along");
    assert_no_trace(&out, "sk-live-4242deadbeef4242");
    assert_eq!(out, "password: [redacted] [redacted] all along");
}

#[test]
fn credentials_are_masked_even_with_no_key_in_front_of_them() {
    let r = redactor();

    // A retry log, a stack trace, a URL — the key is simply not written down.
    let out = r.redact("retrying with sk-live-4242deadbeef4242 after 401");
    assert_no_trace(&out, "sk-live-4242deadbeef4242");
    assert_eq!(out, "retrying with [redacted] after 401");

    let jwt = "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dBjftJeZ4CVPmB92K27uhbUJ";
    let out = r.redact(&format!("GET /v1/session?t={jwt} -> 401"));
    assert_no_trace(&out, jwt);

    let out = r
        .redact("-----BEGIN RSA PRIVATE KEY-----\nMIIEpQIBAAKCAQEA\n-----END RSA PRIVATE KEY-----");
    assert_no_trace(&out, "MIIEpQIBAAKCAQEA");
    assert!(
        out.contains("-----BEGIN RSA PRIVATE KEY-----"),
        "that a key was present is itself diagnostic: {out}"
    );
}

#[test]
fn identifiers_that_make_a_report_readable_are_not_mistaken_for_credentials() {
    let r = redactor();

    // The cost of hunting keyless credentials is eating the identifiers a
    // report is symbolicated and triaged by. Recognition is by issuer
    // evidence, never by entropy, precisely so these survive.
    for input in [
        "build_id=9f8e7d6c5b4a392817069f8e7d6c5b4a39281706",
        "fingerprint 5b1e0c9a4f2d8e7b6a3c1d0f9e8b7a6c",
        "at 0x00007f9c8b2a4e10 in libstore.so",
        "trace_id=4bf92f3577b34da6a3ce929d0e0e4736",
    ] {
        assert_eq!(r.redact(input), input, "{input}");
    }
}

#[test]
fn credential_adjacent_keys_mask_on_the_value_not_the_name() {
    let r = redactor();

    // `auth` names a mode as often as it names a credential.
    assert_eq!(r.redact("auth: none, retries=3"), "auth: none, retries=3");
    assert_eq!(r.redact("auth_attempts=3"), "auth_attempts=3");

    let out = r.redact("auth=sk-live-4242deadbeef4242");
    assert_no_trace(&out, "sk-live-4242deadbeef4242");
}

#[test]
fn the_home_directory_survives_case_separator_and_boundary_differences() {
    let unix = BasicRedactor::new().home("/home/ada");
    assert_eq!(
        unix.redact("failed to open /home/ada/db/main.db"),
        "failed to open ~/db/main.db",
        "the path shape a maintainer needs survives; the username does not"
    );
    // A username that prefixes another one must not corrupt that other path.
    assert_eq!(
        BasicRedactor::new()
            .home("/home/ad")
            .redact("/home/adam/db"),
        "/home/adam/db"
    );
    // The username is identifying wherever it appears, not only under $HOME.
    assert_eq!(
        unix.redact("spilled to /var/log/ada/store.log"),
        "spilled to /var/log/~user/store.log"
    );

    // One directory, several spellings on one machine.
    let windows = BasicRedactor::new().home("C:\\Users\\Ada");
    assert_eq!(
        windows.redact("open c:/users/ada/db/main.db"),
        "open ~/db/main.db"
    );
    assert_eq!(windows.redact("open C:\\Users\\Ada\\db"), "open ~\\db");
}

#[test]
fn map_keys_made_of_user_data_are_masked() {
    let r = redactor();

    // A breadcrumb field keyed by the record it describes: the key is content,
    // and nothing about walking values would ever reach it.
    let mut v = serde_json::json!({
        "ada@example.com": { "writes": 3 },
        "grace@example.com": { "writes": 4 },
    });
    r.redact_json(&mut v);

    let rendered = v.to_string();
    assert_no_trace(&rendered, "ada@example.com");
    assert_no_trace(&rendered, "grace@example.com");
    assert_eq!(
        v.as_object().unwrap().len(),
        2,
        "colliding masked keys must not silently discard a member"
    );
}

/// Redaction runs inside the panic hook, so a slicing bug in it turns a
/// reportable panic into an abort with no report at all. Nothing about the
/// input is trustworthy at that point: it is whatever the failing program was
/// holding.
#[test]
fn redaction_never_panics_and_always_settles_on_arbitrary_input() {
    let r = redactor();

    // A deterministic generator over exactly the bytes that steer the scanners
    // — separators, quotes, wrappers, delimiters, multi-byte characters, and
    // the markers redaction itself emits — beats a random corpus that would
    // spend its budget on inert text.
    const ALPHABET: &[&str] = &[
        "=",
        ":",
        "\"",
        "'",
        " ",
        "\t",
        "\n",
        ",",
        ";",
        "&",
        "(",
        ")",
        "[",
        "]",
        "{",
        "}",
        "/",
        "\\",
        "@",
        ".",
        "-",
        "_",
        "~",
        "token",
        "password",
        "authorization",
        "api_key",
        "grouping_key",
        "auth",
        "Bearer",
        "Some",
        "sk-live-4242deadbeef",
        "ada@example.com",
        "[redacted]",
        "[email]",
        "café",
        "",
        "日本語",
        "0x09",
        "828",
        "\u{0}",
    ];

    // A linear congruential generator: deterministic, so a failure here is
    // reproducible from the seed alone.
    let mut state: u64 = 0x2545_F491_4F6C_DD1D;
    let mut next = move || {
        state = state
            .wrapping_mul(6364136223846793005)
            .wrapping_add(1442695040888963407);
        (state >> 33) as usize
    };

    for _ in 0..20_000 {
        let mut input = String::new();
        for _ in 0..(next() % 24) {
            input.push_str(ALPHABET[next() % ALPHABET.len()]);
        }
        let once = r.redact(&input);
        assert_eq!(
            r.redact(&once),
            once,
            "redaction must reach a fixed point, or repeated passes corrupt \
             the report: {input:?}"
        );
        assert!(
            !starts_a_word(&once, CREDENTIAL),
            "a credential survived: {input:?} -> {once:?}"
        );
    }
}

/// The credential the generator plants, and the only one it must never leak.
const CREDENTIAL: &str = "sk-live-4242deadbeef";

/// Does the credential begin a word somewhere in `input`?
///
/// A credential glued directly onto preceding alphanumerics (`…0x09sk-live-…`)
/// is not recoverable: `my-task-manager` contains `sk-` too, and masking on that
/// basis would shred ordinary identifiers for no gain. Detection is therefore
/// anchored to word starts, and that is what the generator may legitimately
/// expect of it.
fn starts_a_word(input: &str, credential: &str) -> bool {
    input.match_indices(credential).any(|(at, _)| {
        at == 0
            || !input[..at]
                .chars()
                .next_back()
                .is_some_and(|c| c.is_ascii_alphanumeric())
    })
}

#[test]
fn redacting_an_already_redacted_string_changes_nothing() {
    let r = redactor();

    // Crumbs are redacted on the way into the ring and again on the way into a
    // report, so every shape above passes through twice.
    for input in [
        "request failed: authorization: Bearer sk-live-4242deadbeef4242 rejected",
        r#"Config { password: "hunter 2", port: 5432 }"#,
        r#"{"api_key":"sk-plain-1234"}"#,
        r#"token = "sk-toml-1234""#,
        r#"Config { token: Some("sk-opt-1234"), retries: 3 }"#,
        "x-api-key: sk-header-1234",
        "AZURE_OPENAI_KEY=sk-azure-abcdef123456",
        "owners ada@example.com,grace@example.com lost the write",
        "GET https://api.example.com/v1?api_key=sk-url-1234&page=3 -> 401",
        "connect failed token=sk-abc123 retries=3",
    ] {
        let once = r.redact(input);
        assert_eq!(
            r.redact(&once),
            once,
            "re-redacting must not corrupt: {input}"
        );
    }
}