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
//! What the audit could not assess, in the words the report uses (#5239, #5244).
//!
//! Why: DOC-67 §9 turns on one distinction — a dimension missing because a
//! stage failed must not look, on the page, like a dimension that came back
//! clean. The sweep already records every stage's fate ([`AuditSweepStats`]);
//! this module is where those records become sentences an acquirer's reviewer
//! reads, so the wording lives in one place instead of being formatted at the
//! call site.
//! What: [`sweep_gap_lines`] (one line per failed stage) and
//! [`DATA_HANDLING_NOTE`] (§10's placeholder attestation, #5244).
//! Test: `super::tests`.
//!
//! ## Redaction happens here, before the excerpt is cut
//!
//! A stage failure carries an `anyhow` cause chain the process did not author,
//! so it can quote a credential back at us. This module both scrubs and
//! truncates that text, in that order and in one function, because doing them
//! in the other order leaks: [`trusty_common::credentials::scrub_secrets`]
//! matches a credential's *whole* value, so a token that starts before the
//! excerpt boundary and ends after it survives the cut as an unmatchable prefix
//! fragment, and every later scrub — including
//! [`crate::report::dd_manifest::build_dd_manifest`]'s — sees only that
//! fragment and passes it through into `manifest.toml` and the delivered report.
//!
//! The invariant is therefore structural rather than a convention: the needle
//! set is a required argument of [`sweep_gap_lines`], so there is no way to
//! obtain a truncated stage message without having supplied the credentials to
//! remove from it first. `build_dd_manifest` still scrubs everything it emits —
//! that stays the manifest-wide guarantee, and re-scrubbing an already-clean
//! string is a no-op.
use BTreeMap;
use scrub_secrets;
use RepoIndexStatus;
use ;
/// Longest stage-failure message carried into the report, in characters.
///
/// Why: an `anyhow` cause chain can run to several hundred characters of
/// transport detail that means nothing to the report's reader, and the Gaps
/// section is read under time pressure. The full message is already on stderr
/// and in the sweep's own record; this is the reader's excerpt.
pub const MAX_REASON_CHARS: usize = 160;
/// The placeholder data-retention statement AUDIT carries until #5218 ships.
///
/// Why: DOC-67 §10 — an acquirer's counterparty asks what the tool retained
/// before granting access, and #5218 is the authoritative mechanism for that
/// answer. Until it ships, the report must say an attestation is *pending*
/// rather than assert one, and must not paraphrase a claim it cannot yet
/// enforce.
/// What: states that the formal attestation is pending, and states §10's
/// verified scope claim exactly as §10 words it — "no file content, diffs,
/// patches, hunks, or blobs", never the broader "no code", because free-text
/// columns can carry whatever an author pasted into them.
/// Test: `super::tests::data_handling_note_is_a_pending_claim`.
pub const DATA_HANDLING_NOTE: &str = "Data handling: a formal data-retention attestation for \
this run is pending (#5218) and is not asserted here. tga's database records commit, \
pull-request, and ticket metadata; it stores no file content, diffs, patches, hunks, or blobs. \
Free-text fields it does store — commit messages, pull-request and ticket titles — are retained \
verbatim and carry whatever their authors wrote into them.";
/// One Gaps & Caveats line per stage that did not complete, then one per
/// repository collected from stale local refs.
///
/// Why: a stage that failed took a whole class of data with it — no `dora` run
/// means no delivery-health figures, no `jira sync` means no ticket
/// correlation — and DOC-67 §9 requires that absence be stated, not inferred
/// from an empty table. The sweep deliberately does not abort on a stage
/// failure (§2, one shot), which is exactly why the failure has to reappear
/// here. A stale-refs fallback (#5321) is the same obligation one step
/// further in: the stage SUCCEEDED, so nothing about the data's age is visible
/// anywhere on the page unless it is stated here.
/// What: for each failure in execution order, a line naming the stage, a
/// redacted excerpt of the reason, and the fact that the affected area is
/// unassessed; then, for each unreachable remote, a line naming the repository,
/// the remote, and that its figures may be behind the true remote state; then,
/// for each leg the config declared absent (#6130), a line naming the leg and
/// why it was never attempted. Returns an empty vec when every stage succeeded,
/// every remote was reached, and every leg ran — a clean run adds no line.
///
/// `secrets` are the credential values to remove from each stage message —
/// [`crate::report::dd_manifest::configured_secrets`] derives the set the same
/// audit run's manifest uses. It is a required argument, not a convenience: see
/// the module docs for why truncating before scrubbing leaks a prefix fragment.
/// A fetch error is scrubbed on the same path, and needs it just as much: git2
/// quotes the remote URL back, which for an HTTPS remote carries whatever
/// credential was embedded in it.
/// Test: `super::tests::{sweep_gap_lines_name_each_failed_stage,
/// sweep_gap_lines_are_empty_for_a_clean_run,
/// a_repo_that_fell_back_to_stale_local_refs_is_named_in_the_gap_lines}`, and
/// `crate::report::dd_manifest_tests::a_token_straddling_the_excerpt_boundary_leaves_no_fragment`.
/// One Gaps & Caveats line per distinct reason a repository could not be
/// indexed (#5670).
///
/// Why: a repository trusty-search does not serve reaches the renderer's
/// fail-open path — `AnalyzeGap::NotIndexed`, one generic line, exit 0 — and the
/// operator learns only that the index was missing, never that the audit tried
/// to build it and why that failed. DOC-67 §9's rule for a per-repository
/// failure is exclude-and-name, so the cause is named here, beside the stage
/// failures, in the same words the rest of the section uses.
/// What: groups the failed outcomes by reason so one fault affecting an entire
/// org is one line rather than two hundred, names the affected repositories in
/// manifest order inside each line, and returns an empty vec when every
/// repository is indexed. Reasons are scrubbed and excerpted by
/// [`redacted_excerpt`] — a child's message is text this process did not author,
/// and the manifest's own scrub cannot repair a credential cut in half here.
/// Test: `super::tests::{one_repository_that_fails_to_index_does_not_stop_the_others,
/// a_missing_search_binary_is_named_and_the_run_continues,
/// index_gap_lines_are_empty_when_every_repository_is_served,
/// a_credential_in_an_index_failure_never_reaches_the_gap_line}`.
/// A single-line, credential-free excerpt of `msg`, capped at
/// [`MAX_REASON_CHARS`] characters.
///
/// Why: newlines would break the Gaps bullet, and the cap keeps one verbose
/// transport error from dominating the section. Redaction and truncation are
/// one operation rather than two so their order cannot be got wrong at a call
/// site — see the module docs.
/// What: scrubs `secrets` out of the raw message first, then flattens
/// whitespace, then truncates. Scrubbing precedes flattening because a needle
/// is matched against the text as the failing stage produced it; flattening only
/// collapses whitespace runs, so it can never rejoin a split credential. The cap
/// applies to the redacted text, so `[REDACTED]` being longer than what it
/// replaces cannot push the excerpt over budget. Truncation is by character, so
/// the same message always yields the same excerpt.
/// Test: `super::tests::long_stage_reasons_are_truncated`.