返回 CodeWhale
persistence.rs
根目录 / crates / config / src / persistence.rs
1 //! Transactional persistence, atomic writes, and secret redaction for the
2 //! v0.8.67 constitution-first setup lane (#3410).
3 //!
4 //! This is the safety layer under every setup step. A setup session may touch
5 //! several files (the setup-state sidecar, the user-global constitution, and —
6 //! through the existing comment-preserving `ConfigStore` — `config.toml`). The
7 //! contract this module guarantees:
8 //!
9 //! - **Preview writes nothing.** [`SetupTransaction::preview`] reports what
10 //! would change without touching the filesystem.
11 //! - **Cancel leaves files unchanged.** A staged transaction that is dropped
12 //! without [`SetupTransaction::commit`] never wrote anything.
13 //! - **Save is atomic.** Each file is written through a temp file + rename
14 //! ([`atomic_write`]); a multi-file commit either fully applies or fully
15 //! rolls back, so a partial failure never leaves a half-written file.
16 //! - **Secrets never leak.** [`redact_secrets`] masks secret-bearing values for
17 //! any report, log line, or diagnostic that might echo config text.
18 //!
19 //! This module deliberately owns only the write / rollback / secret contract.
20 //! Each setup step owns *which* fields it writes; see [`crate::setup_state`] and
21 //! [`crate::user_constitution`].
22
23 use std::fs;
24 use std::path::{Path, PathBuf};
25
26 use anyhow::{Context, Result};
27 use serde::Serialize;
28
29 #[cfg(unix)]
30 use std::os::unix::fs::PermissionsExt;
31
32 /// Restrictive file mode for setup-owned files (owner read/write only).
33 #[cfg(unix)]
34 const SETUP_FILE_MODE: u32 = 0o600;
35
36 /// Atomically write `bytes` to `path` via a sibling temp file + rename.
37 ///
38 /// The temp file is created in the same directory as `path` so the final
39 /// `rename` is atomic on the same filesystem. On Unix the file is created with
40 /// `0o600` so setup-owned state never lands world-readable. Parent directories
41 /// are created as needed.
42 pub fn atomic_write(path: &Path, bytes: &[u8]) -> Result<()> {
43 let parent = path.parent().filter(|p| !p.as_os_str().is_empty());
44 if let Some(parent) = parent {
45 fs::create_dir_all(parent)
46 .with_context(|| format!("failed to create directory {}", parent.display()))?;
47 }
48
49 let dir = parent.unwrap_or_else(|| Path::new("."));
50 let mut tmp = tempfile::NamedTempFile::new_in(dir)
51 .with_context(|| format!("failed to create temp file in {}", dir.display()))?;
52
53 use std::io::Write as _;
54 tmp.write_all(bytes)
55 .with_context(|| format!("failed to write temp file for {}", path.display()))?;
56 tmp.flush()
57 .with_context(|| format!("failed to flush temp file for {}", path.display()))?;
58
59 #[cfg(unix)]
60 {
61 let perms = fs::Permissions::from_mode(SETUP_FILE_MODE);
62 tmp.as_file()
63 .set_permissions(perms)
64 .with_context(|| format!("failed to set permissions for {}", path.display()))?;
65 }
66
67 tmp.persist(path)
68 .map_err(|e| e.error)
69 .with_context(|| format!("failed to persist {}", path.display()))?;
70 Ok(())
71 }
72
73 /// Atomically write `value` as pretty-printed JSON to `path`.
74 ///
75 /// A trailing newline is appended so the file is well-formed for line-oriented
76 /// tooling and diffs.
77 pub fn atomic_write_json<T: Serialize>(path: &Path, value: &T) -> Result<()> {
78 let mut body = serde_json::to_string_pretty(value)
79 .with_context(|| format!("failed to serialize JSON for {}", path.display()))?;
80 body.push('\n');
81 atomic_write(path, body.as_bytes())
82 }
83
84 /// A staged multi-file write that either fully applies or fully rolls back.
85 ///
86 /// Stage every file the setup step intends to write, then call [`commit`]. If
87 /// any single write fails, every already-applied write in the transaction is
88 /// restored to its pre-commit contents (or removed if it did not previously
89 /// exist), and the original error is returned. A transaction that is dropped
90 /// without committing leaves the filesystem untouched.
91 ///
92 /// [`commit`]: SetupTransaction::commit
93 #[derive(Debug, Default)]
94 pub struct SetupTransaction {
95 writes: Vec<StagedWrite>,
96 }
97
98 #[derive(Debug, Clone)]
99 struct StagedWrite {
100 path: PathBuf,
101 bytes: Vec<u8>,
102 }
103
104 /// A snapshot of a file's pre-commit state, captured so [`SetupTransaction`]
105 /// can restore it during rollback.
106 struct Snapshot {
107 path: PathBuf,
108 /// Original bytes, or `None` if the file did not exist before commit.
109 original: Option<Vec<u8>>,
110 }
111
112 impl SetupTransaction {
113 /// Create an empty transaction.
114 #[must_use]
115 pub fn new() -> Self {
116 Self::default()
117 }
118
119 /// Stage `bytes` to be written to `path` on [`commit`](Self::commit).
120 ///
121 /// Staging touches nothing on disk. A later stage for the same path
122 /// replaces an earlier one, so a step can revise its intended output before
123 /// committing.
124 pub fn stage(&mut self, path: impl Into<PathBuf>, bytes: impl Into<Vec<u8>>) -> &mut Self {
125 let path = path.into();
126 let bytes = bytes.into();
127 if let Some(existing) = self.writes.iter_mut().find(|w| w.path == path) {
128 existing.bytes = bytes;
129 } else {
130 self.writes.push(StagedWrite { path, bytes });
131 }
132 self
133 }
134
135 /// Stage `value` serialized as pretty JSON (with trailing newline).
136 pub fn stage_json<T: Serialize>(
137 &mut self,
138 path: impl Into<PathBuf>,
139 value: &T,
140 ) -> Result<&mut Self> {
141 let path = path.into();
142 let mut body = serde_json::to_string_pretty(value)
143 .with_context(|| format!("failed to serialize JSON for {}", path.display()))?;
144 body.push('\n');
145 Ok(self.stage(path, body.into_bytes()))
146 }
147
148 /// The paths that [`commit`](Self::commit) would write, in staging order.
149 /// Writes nothing — this is the preview surface.
150 #[must_use]
151 pub fn preview(&self) -> Vec<&Path> {
152 self.writes.iter().map(|w| w.path.as_path()).collect()
153 }
154
155 /// True when nothing is staged.
156 #[must_use]
157 pub fn is_empty(&self) -> bool {
158 self.writes.is_empty()
159 }
160
161 /// Apply every staged write atomically.
162 ///
163 /// On success all files are updated. On the first failure, every write that
164 /// already landed is rolled back to its captured pre-commit state and the
165 /// original error is returned (rollback failures are attached as context).
166 pub fn commit(self) -> Result<()> {
167 let mut snapshots: Vec<Snapshot> = Vec::with_capacity(self.writes.len());
168
169 for write in &self.writes {
170 // Capture the pre-commit state before mutating, so we can restore it.
171 let original = match fs::read(&write.path) {
172 Ok(bytes) => Some(bytes),
173 Err(e) if e.kind() == std::io::ErrorKind::NotFound => None,
174 Err(e) => {
175 rollback(&snapshots);
176 return Err(e).with_context(|| {
177 format!(
178 "failed to read existing {} before write; rolled back {} prior change(s)",
179 write.path.display(),
180 snapshots.len()
181 )
182 });
183 }
184 };
185
186 match atomic_write(&write.path, &write.bytes) {
187 Ok(()) => snapshots.push(Snapshot {
188 path: write.path.clone(),
189 original,
190 }),
191 Err(err) => {
192 // This write did not land (atomic_write is all-or-nothing),
193 // so roll back only the writes that came before it.
194 rollback(&snapshots);
195 return Err(err).with_context(|| {
196 format!(
197 "setup transaction failed writing {}; rolled back {} prior change(s)",
198 write.path.display(),
199 snapshots.len()
200 )
201 });
202 }
203 }
204 }
205
206 Ok(())
207 }
208 }
209
210 /// Restore every snapshot to its captured pre-commit state. Best-effort: a
211 /// rollback error is logged but does not abort the remaining restores, because
212 /// leaving as many files as possible in their original state is the goal.
213 fn rollback(snapshots: &[Snapshot]) {
214 for snap in snapshots.iter().rev() {
215 let result = match &snap.original {
216 Some(bytes) => atomic_write(&snap.path, bytes),
217 None => match fs::remove_file(&snap.path) {
218 Ok(()) => Ok(()),
219 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
220 Err(e) => Err(e.into()),
221 },
222 };
223 if let Err(e) = result {
224 tracing::error!(
225 target: "config::persistence",
226 "failed to roll back {} during setup transaction: {e:#}",
227 snap.path.display()
228 );
229 }
230 }
231 }
232
233 // FEAT-025 D4: the pure secret-redaction primitives moved to
234 // `codewhale-secrets::redact` so portable command helpers can share one
235 // implementation without depending on this crate. Re-exported here to keep
236 // the existing `codewhale_config::persistence::*` public API stable.
237 pub use codewhale_secrets::redact::{
238 REDACTED, RedactionPolicy, redact_json_model_bound_secrets, redact_json_secrets,
239 redact_model_bound_secrets, redact_secrets, redact_secrets_with,
240 };
241
242 #[cfg(test)]
243 mod tests {
244 use super::*;
245
246 fn read(path: &Path) -> String {
247 fs::read_to_string(path).unwrap()
248 }
249
250 fn synthetic_secret_fixture() -> String {
251 ["abc123", "def456", "ghi"].concat()
252 }
253
254 #[test]
255 fn atomic_write_creates_parent_dirs_and_content() {
256 let tmp = tempfile::tempdir().unwrap();
257 let path = tmp.path().join("nested/dir/state.json");
258 atomic_write(&path, b"hello").unwrap();
259 assert_eq!(read(&path), "hello");
260 }
261
262 #[cfg(unix)]
263 #[test]
264 fn atomic_write_uses_owner_only_permissions() {
265 let tmp = tempfile::tempdir().unwrap();
266 let path = tmp.path().join("state.json");
267 atomic_write(&path, b"x").unwrap();
268 let mode = fs::metadata(&path).unwrap().permissions().mode() & 0o777;
269 assert_eq!(mode, SETUP_FILE_MODE);
270 }
271
272 #[test]
273 fn atomic_write_replaces_existing_atomically() {
274 let tmp = tempfile::tempdir().unwrap();
275 let path = tmp.path().join("state.json");
276 atomic_write(&path, b"old").unwrap();
277 atomic_write(&path, b"new").unwrap();
278 assert_eq!(read(&path), "new");
279 // No stray temp files left behind.
280 let leftovers: Vec<_> = fs::read_dir(tmp.path())
281 .unwrap()
282 .filter_map(Result::ok)
283 .filter(|e| e.file_name() != "state.json")
284 .collect();
285 assert!(leftovers.is_empty(), "stray temp files: {leftovers:?}");
286 }
287
288 #[test]
289 fn transaction_preview_writes_nothing() {
290 let tmp = tempfile::tempdir().unwrap();
291 let a = tmp.path().join("a.json");
292 let b = tmp.path().join("b.json");
293 let mut tx = SetupTransaction::new();
294 tx.stage(a.clone(), b"1".to_vec())
295 .stage(b.clone(), b"2".to_vec());
296 let preview = tx.preview();
297 assert_eq!(preview, vec![a.as_path(), b.as_path()]);
298 assert!(!a.exists());
299 assert!(!b.exists());
300 }
301
302 #[test]
303 fn dropped_transaction_leaves_files_unchanged() {
304 let tmp = tempfile::tempdir().unwrap();
305 let a = tmp.path().join("a.json");
306 {
307 let mut tx = SetupTransaction::new();
308 tx.stage(a.clone(), b"staged".to_vec());
309 // tx dropped here without commit
310 }
311 assert!(!a.exists());
312 }
313
314 #[test]
315 fn transaction_commit_applies_all() {
316 let tmp = tempfile::tempdir().unwrap();
317 let a = tmp.path().join("a.json");
318 let b = tmp.path().join("sub/b.json");
319 let mut tx = SetupTransaction::new();
320 tx.stage(a.clone(), b"A".to_vec())
321 .stage(b.clone(), b"B".to_vec());
322 tx.commit().unwrap();
323 assert_eq!(read(&a), "A");
324 assert_eq!(read(&b), "B");
325 }
326
327 #[test]
328 fn transaction_rolls_back_on_partial_failure() {
329 let tmp = tempfile::tempdir().unwrap();
330 let good = tmp.path().join("good.json");
331 fs::write(&good, "ORIGINAL").unwrap();
332
333 // Second target is unwritable: a path whose parent is an existing file.
334 let blocker = tmp.path().join("blocker");
335 fs::write(&blocker, "i am a file").unwrap();
336 let bad = blocker.join("child.json"); // parent is a file → create_dir_all fails
337
338 let mut tx = SetupTransaction::new();
339 tx.stage(good.clone(), b"UPDATED".to_vec())
340 .stage(bad.clone(), b"NOPE".to_vec());
341 let err = tx.commit().unwrap_err();
342 assert!(format!("{err:#}").contains("rolled back"));
343
344 // The first file must be restored to its original contents.
345 assert_eq!(read(&good), "ORIGINAL");
346 assert!(!bad.exists());
347 }
348
349 #[test]
350 fn transaction_rollback_removes_newly_created_file() {
351 let tmp = tempfile::tempdir().unwrap();
352 let fresh = tmp.path().join("fresh.json"); // did not exist before
353 let blocker = tmp.path().join("blocker");
354 fs::write(&blocker, "file").unwrap();
355 let bad = blocker.join("child.json");
356
357 let mut tx = SetupTransaction::new();
358 tx.stage(fresh.clone(), b"created".to_vec())
359 .stage(bad, b"x".to_vec());
360 assert!(tx.commit().is_err());
361 // The newly created file must be removed on rollback, not left behind.
362 assert!(!fresh.exists());
363 }
364
365 #[test]
366 fn compact_json_and_query_strings_mask_their_credentials() {
367 // A synthetic, unsigned test value, assembled from its three
368 // segments so the source holds no complete token literal.
369 let jwt = [
370 "eyJhbGciOiJSUzI1NiJ9",
371 "eyJzdWIiOiJ1c2VyIn0",
372 "c2lnbmF0dXJlLXZhbHVl",
373 ]
374 .join(".");
375 let jwt = jwt.as_str();
376 let refresh = "rt_Q2x9fK3mZ7pL1vB8nH4sT6wY0aE5cR2d";
377 let cases = [
378 format!(
379 r#"{{"tokens":{{"id_token":"{jwt}","access_token":"{jwt}","refresh_token":"{refresh}"}}}}"#
380 ),
381 format!(r#"{{"token_type":"Bearer","access_token":"{jwt}"}}"#),
382 format!(r#"[{{"Authorization":"Bearer {jwt}"}}]"#),
383 format!("https://example.test/cb?state=abc&access_token={jwt}&x=1"),
384 ];
385 for policy in [RedactionPolicy::CredentialShaped, RedactionPolicy::KeyBased] {
386 for case in &cases {
387 let out = redact_secrets_with(case, policy);
388 assert!(!out.contains(jwt), "{policy:?} leaked a JWT: {out}");
389 assert!(
390 !out.contains(refresh),
391 "{policy:?} leaked a refresh token: {out}"
392 );
393 }
394 }
395 // Structure and harmless members stay byte-exact.
396 assert_eq!(
397 redact_model_bound_secrets(&cases[1]),
398 r#"{"token_type":"Bearer","access_token":"[redacted]"}"#
399 );
400 let json = serde_json::json!({
401 "summary": format!("{{\"access_token\":\"{jwt}\"}}"),
402 "stdout_summary": "token = make_token()",
403 "api_key": "short",
404 "access_token": jwt,
405 });
406 let masked = redact_json_model_bound_secrets(&json);
407 assert!(!masked.to_string().contains(jwt), "{masked}");
408 assert_eq!(masked["stdout_summary"], "token = make_token()");
409 // A sensitive key's value is masked wholesale, credential-shaped or not.
410 assert_eq!(masked["api_key"], REDACTED);
411 }
412
413 #[test]
414 fn model_bound_redaction_keeps_code_and_config_byte_exact() {
415 // Every line here is code or configuration that a model must be able
416 // to quote back for an exact-match edit (#5546).
417 let lines = [
418 " \"jsonwebtoken\": \"^9.0.2\",",
419 " \"@types/jsonwebtoken\": \"^9.0.5\",",
420 " \"password-validator\": \"^5.3.0\",",
421 " \"authorization\": \"1.0.0\",",
422 " password: credentials?.password,",
423 " token = generate_verification_token()",
424 " secret: process.env.NEXTAUTH_SECRET!,",
425 " password?: string;",
426 " token: string;",
427 " \"tokenizer\": \"gpt2\",",
428 "max tokens = 8192",
429 "export const AUTH_TOKEN_HEADER = 'x-auth-token';",
430 "{\"id\":1, \"password\": \"x\", \"language\": \"en\"}",
431 "{\"id\":1, \"password\":\"x\", \"language\":\"en\"}",
432 "password = hunter2",
433 "api_key = os.environ[\"OPENAI_API_KEY\"]",
434 "let token = ${TOKEN_FROM_ENV}",
435 "auth_url = https://example.test/oauth/token",
436 ];
437 for line in lines {
438 assert_eq!(
439 redact_model_bound_secrets(line),
440 line,
441 "line changed: {line}"
442 );
443 }
444 let file = lines.join("\n");
445 assert_eq!(redact_model_bound_secrets(&file), file);
446 }
447
448 #[test]
449 fn model_bound_redaction_masks_credential_shaped_values() {
450 let hex40 = ["0123456789abcdef", "0123456789abcdef", "01234567"].concat();
451 let sk = ["sk-", "abcdef1234567890abcdef"].concat();
452 let jwt = [
453 "eyJhbGciOiJIUzI1NiJ9",
454 ".",
455 "eyJzdWIiOiIxMjM0NTY3ODkwIn0",
456 ".",
457 "c2lnbmF0dXJlLXNpZ25hdHVyZQ",
458 ]
459 .concat();
460 let ya = ["ya29.", "a0AfH6SMBx1234567890abcdefghij"].concat();
461 let cases = [
462 (
463 format!("NEXTAUTH_SECRET={hex40}"),
464 "NEXTAUTH_SECRET=[redacted]".to_string(),
465 ),
466 (
467 format!("api_key = \"{sk}\""),
468 "api_key = \"[redacted]\"".to_string(),
469 ),
470 (
471 format!(" \"access_token\": \"{ya}\","),
472 " \"access_token\": \"[redacted]\",".to_string(),
473 ),
474 (
475 format!("Authorization: Bearer {jwt}"),
476 "Authorization: [redacted]".to_string(),
477 ),
478 (
479 format!("curl -H \"Authorization: Bearer {jwt}\" https://api.test"),
480 "curl -H \"Authorization: Bearer [redacted]\" https://api.test".to_string(),
481 ),
482 (
483 format!("password = {hex40}, retries = 3"),
484 "password = [redacted], retries = 3".to_string(),
485 ),
486 (
487 format!("found key {sk} in the log"),
488 "found key [redacted] in the log".to_string(),
489 ),
490 ];
491 for (input, expected) in cases {
492 assert_eq!(
493 redact_model_bound_secrets(&input),
494 expected,
495 "input: {input}"
496 );
497 }
498 }
499
500 #[test]
501 fn private_key_blocks_are_masked_between_pem_markers() {
502 // Assemble the PEM markers at runtime so the source file never
503 // contains a literal private-key header for a scanner to match; the
504 // runtime strings are identical to a real block.
505 let begin = ["-----BEGIN RSA", " PRIVATE KEY-----"].concat();
506 let end = ["-----END RSA", " PRIVATE KEY-----"].concat();
507 let body = "MIIEpAIBAAKCAQEA0Z3VS5JJcds3xfn\nabcdefghijklmnopqrstuvwxyz012345";
508 let pem = format!("{begin}\n{body}\n{end}\nnext_line = ok\n");
509 let expected = format!("{begin}\n[redacted]\n[redacted]\n{end}\nnext_line = ok\n");
510 assert_eq!(redact_model_bound_secrets(&pem), expected);
511 assert_eq!(redact_secrets(&pem), expected);
512 }
513
514 #[test]
515 fn key_based_policy_is_unchanged_by_the_model_bound_mode() {
516 // The broad scrubber for logs/previews keeps masking key-only hits.
517 assert_eq!(
518 redact_secrets(" password: credentials?.password,"),
519 " password: [redacted]"
520 );
521 assert_eq!(
522 redact_secrets(" \"password-validator\": \"^5.3.0\","),
523 " \"password-validator\": \"[redacted]\""
524 );
525 }
526
527 #[test]
528 fn redact_masks_keyed_secrets_toml_and_json() {
529 let synthetic_secret = synthetic_secret_fixture();
530 let input = format!(
531 "\
532 api_key = \"sk-supersecretvalue123\"
533 provider = \"openai\"
534 \"token\": \"{synthetic_secret}\",
535 model = \"mimo-ultraspeed\"
536 PASSWORD=hunter2hunter2"
537 );
538 let out = redact_secrets(&input);
539 assert!(!out.contains("sk-supersecretvalue123"), "{out}");
540 assert!(!out.contains(&synthetic_secret), "{out}");
541 assert!(!out.contains("hunter2hunter2"), "{out}");
542 // Non-secret values survive untouched.
543 assert!(out.contains("provider = \"openai\""));
544 assert!(out.contains("model = \"mimo-ultraspeed\""));
545 assert!(out.matches(REDACTED).count() >= 3, "{out}");
546 }
547
548 #[test]
549 fn redact_json_masks_camel_case_and_dotted_secret_keys() {
550 let synthetic_secret = synthetic_secret_fixture();
551 let input = serde_json::json!({
552 "accessToken": synthetic_secret.clone(),
553 "refreshToken": synthetic_secret_fixture(),
554 "oauth.token": synthetic_secret_fixture(),
555 "APIKey": synthetic_secret_fixture(),
556 "maxTokens": 8192,
557 "tokenBudget": 4096,
558 "tokenCount": 1024,
559 "token_count": 512,
560 "tokenizer": "sentencepiece",
561 });
562
563 let out = redact_json_secrets(&input);
564 for key in ["accessToken", "refreshToken", "oauth.token", "APIKey"] {
565 assert_eq!(out[key], REDACTED, "{key}: {out}");
566 }
567 assert_eq!(out["maxTokens"], 8192);
568 assert_eq!(out["tokenBudget"], 4096);
569 assert_eq!(out["tokenCount"], 1024);
570 assert_eq!(out["token_count"], 512);
571 assert_eq!(out["tokenizer"], "sentencepiece");
572 assert!(!out.to_string().contains(&synthetic_secret), "{out}");
573 }
574
575 #[test]
576 fn redact_json_truncates_pathological_nesting() {
577 let mut value = serde_json::Value::String("leaf".to_string());
578 for _ in 0..150 {
579 let mut map = serde_json::Map::new();
580 map.insert("t".to_string(), value);
581 value = serde_json::Value::Object(map);
582 }
583 let out = redact_json_secrets(&value);
584 let mut cursor = &out;
585 let mut descended = 0;
586 while let serde_json::Value::Object(map) = cursor {
587 cursor = map.values().next().expect("single-key nesting");
588 descended += 1;
589 }
590 assert_eq!(
591 cursor,
592 &serde_json::Value::String(REDACTED.to_string()),
593 "over-deep value must be redacted, not traversed"
594 );
595 assert!(descended < 150, "guard must fire before the leaf");
596 }
597
598 #[test]
599 fn redact_text_masks_camel_case_and_dotted_secret_assignments() {
600 let synthetic_secret = synthetic_secret_fixture();
601 for key in ["accessToken", "refreshToken", "oauth.token", "APIKey"] {
602 let out = redact_secrets(&format!("request failed: {key} = {synthetic_secret}"));
603 assert!(!out.contains(&synthetic_secret), "{key}: {out}");
604 assert!(out.contains(REDACTED), "{key}: {out}");
605 }
606 for key in ["tokenBudget", "tokenCount", "token_count"] {
607 let input = format!("model usage: {key} = 8192");
608 assert_eq!(redact_secrets(&input), input, "{key}");
609 }
610 }
611
612 #[test]
613 fn redact_masks_bare_token_prefixes() {
614 let out = redact_secrets("the leaked key sk-abcdef1234567890 appeared in a log");
615 assert!(!out.contains("sk-abcdef1234567890"), "{out}");
616 assert!(out.contains(REDACTED));
617 assert!(out.contains("appeared in a log"));
618 }
619
620 #[test]
621 fn redact_masks_inline_sensitive_assignments_after_prose_prefixes() {
622 let out = redact_secrets(
623 "Decision: use token=plain-secret-value and api_key:another-secret-value",
624 );
625 assert!(!out.contains("plain-secret-value"), "{out}");
626 assert!(!out.contains("another-secret-value"), "{out}");
627 assert_eq!(out.matches(REDACTED).count(), 2, "{out}");
628 assert!(out.starts_with("Decision: use "), "{out}");
629 }
630
631 #[test]
632 fn redact_masks_spaced_assignment_that_is_not_the_first_separator() {
633 // The shape `redact_secrets(&format!("{error:#}"))` produces: an
634 // anyhow chain puts prose and its own `: ` separators in front of the
635 // assignment, so the sensitive key never owns the line's first
636 // separator and the whole-line pass declines the line.
637 let out = redact_secrets("request failed: api_key = AIzaSyDeadBeefLeak");
638 assert!(!out.contains("AIzaSyDeadBeefLeak"), "{out}");
639 assert!(out.contains(REDACTED), "{out}");
640
641 let synthetic_secret = synthetic_secret_fixture();
642 let out = redact_secrets(&format!("note: the token = {synthetic_secret}"));
643 assert!(!out.contains(&synthetic_secret), "{out}");
644 assert!(out.contains(REDACTED), "{out}");
645 }
646
647 #[test]
648 fn redact_masks_whole_multi_word_value_of_a_spaced_assignment() {
649 // Assemble the placeholder at runtime so secret scanners do not
650 // mistake a redaction fixture for a committed credential.
651 let bearer = ["Bear", "er"].concat();
652 let credential = ["abc123", "def456", "ghi"].concat();
653 let out = redact_secrets(&format!(
654 "mcp call failed: authorization = {bearer} {credential}"
655 ));
656 assert!(!out.contains(&credential), "{out}");
657 assert!(!out.contains(&bearer), "{out}");
658 assert!(
659 out.starts_with("mcp call failed: authorization = "),
660 "{out}"
661 );
662 }
663
664 #[test]
665 fn redact_spaced_pass_leaves_ordinary_prose_alone() {
666 // No sensitive key, so the spaced-assignment state machine must not
667 // start swallowing the rest of the line.
668 let input = "the quick brown fox = jumps over the lazy dog";
669 assert_eq!(redact_secrets(input), input);
670 let input = "note: the model = deepseek-v4-pro and the seed = 7";
671 assert_eq!(redact_secrets(input), input);
672 }
673
674 #[test]
675 fn redact_leaves_token_count_diagnostics_intact() {
676 // "tokens" is the English plural of a usage metric, not a credential
677 // key. The spaced-assignment pass used to treat the "token" hint as a
678 // substring and then drop the rest of the line, which made the exact
679 // class of error people paste into issues unreadable.
680 for input in [
681 "stream error: max tokens = 8192 but budget = 4096",
682 "error: token expired",
683 "request failed: token count: 4096 exceeds the model limit",
684 "http 401: authorization header rejected",
685 "warning: password policy requires 12 characters",
686 "note: secret scanning found 3 issues",
687 ] {
688 assert_eq!(redact_secrets(input), input, "{input}");
689 }
690 }
691
692 #[test]
693 fn redact_still_masks_a_bearer_token_assignment() {
694 // Counterpart of the diagnostic test above: a real credential keyed
695 // as `token` (or `api_token`) must still be dropped, including a
696 // multi-word Bearer value that is not a known bare-token prefix.
697 // The JWT is assembled at runtime so no scanner-shaped literal sits
698 // in the source tree — same precedent as the AWS fixture in
699 // `crates/workflow/src/redaction.rs`.
700 let jwt = ["eyJhbGciOiJIUzI1NiJ9", "e30", "c2lnbmF0dXJl"].join(".");
701 let out = redact_secrets(&format!("stream error: token = Bearer {jwt}"));
702 assert!(!out.contains(&jwt), "{out}");
703 assert!(!out.contains("Bearer"), "{out}");
704 assert!(out.starts_with("stream error: token = "), "{out}");
705 assert!(out.contains(REDACTED), "{out}");
706
707 let synthetic_secret = synthetic_secret_fixture();
708 let out = redact_secrets(&format!("note: api_token = {synthetic_secret}"));
709 assert!(!out.contains(&synthetic_secret), "{out}");
710 assert!(out.contains(REDACTED), "{out}");
711 }
712
713 #[test]
714 fn redact_preserves_line_structure() {
715 let input = "line1\nsecret = \"xyzsecretvalue\"\nline3";
716 let out = redact_secrets(input);
717 let lines: Vec<&str> = out.lines().collect();
718 assert_eq!(lines.len(), 3);
719 assert_eq!(lines[0], "line1");
720 assert_eq!(lines[2], "line3");
721 assert!(lines[1].contains(REDACTED));
722 }
723
724 #[test]
725 fn redact_leaves_plain_text_untouched() {
726 let input = "the quick brown fox = jumps over";
727 // `fox` key has no sensitive hint → unchanged.
728 assert_eq!(redact_secrets(input), input);
729 }
730 }
731
731 lines RUST