返回 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_secrets, redact_model_bound_secrets, redact_secrets,
239 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 model_bound_redaction_keeps_code_and_config_byte_exact() {
367 // Every line here is code or configuration that a model must be able
368 // to quote back for an exact-match edit (#5546).
369 let lines = [
370 " \"jsonwebtoken\": \"^9.0.2\",",
371 " \"@types/jsonwebtoken\": \"^9.0.5\",",
372 " \"password-validator\": \"^5.3.0\",",
373 " \"authorization\": \"1.0.0\",",
374 " password: credentials?.password,",
375 " token = generate_verification_token()",
376 " secret: process.env.NEXTAUTH_SECRET!,",
377 " password?: string;",
378 " token: string;",
379 " \"tokenizer\": \"gpt2\",",
380 "max tokens = 8192",
381 "export const AUTH_TOKEN_HEADER = 'x-auth-token';",
382 "{\"id\":1, \"password\": \"x\", \"language\": \"en\"}",
383 "{\"id\":1, \"password\":\"x\", \"language\":\"en\"}",
384 "password = hunter2",
385 "api_key = os.environ[\"OPENAI_API_KEY\"]",
386 "let token = ${TOKEN_FROM_ENV}",
387 "auth_url = https://example.test/oauth/token",
388 ];
389 for line in lines {
390 assert_eq!(
391 redact_model_bound_secrets(line),
392 line,
393 "line changed: {line}"
394 );
395 }
396 let file = lines.join("\n");
397 assert_eq!(redact_model_bound_secrets(&file), file);
398 }
399
400 #[test]
401 fn model_bound_redaction_masks_credential_shaped_values() {
402 let hex40 = ["0123456789abcdef", "0123456789abcdef", "01234567"].concat();
403 let sk = ["sk-", "abcdef1234567890abcdef"].concat();
404 let jwt = [
405 "eyJhbGciOiJIUzI1NiJ9",
406 ".",
407 "eyJzdWIiOiIxMjM0NTY3ODkwIn0",
408 ".",
409 "c2lnbmF0dXJlLXNpZ25hdHVyZQ",
410 ]
411 .concat();
412 let ya = ["ya29.", "a0AfH6SMBx1234567890abcdefghij"].concat();
413 let cases = [
414 (
415 format!("NEXTAUTH_SECRET={hex40}"),
416 "NEXTAUTH_SECRET=[redacted]".to_string(),
417 ),
418 (
419 format!("api_key = \"{sk}\""),
420 "api_key = \"[redacted]\"".to_string(),
421 ),
422 (
423 format!(" \"access_token\": \"{ya}\","),
424 " \"access_token\": \"[redacted]\",".to_string(),
425 ),
426 (
427 format!("Authorization: Bearer {jwt}"),
428 "Authorization: [redacted]".to_string(),
429 ),
430 (
431 format!("curl -H \"Authorization: Bearer {jwt}\" https://api.test"),
432 "curl -H \"Authorization: Bearer [redacted]\" https://api.test".to_string(),
433 ),
434 (
435 format!("password = {hex40}, retries = 3"),
436 "password = [redacted], retries = 3".to_string(),
437 ),
438 (
439 format!("found key {sk} in the log"),
440 "found key [redacted] in the log".to_string(),
441 ),
442 ];
443 for (input, expected) in cases {
444 assert_eq!(
445 redact_model_bound_secrets(&input),
446 expected,
447 "input: {input}"
448 );
449 }
450 }
451
452 #[test]
453 fn private_key_blocks_are_masked_between_pem_markers() {
454 // Assemble the PEM markers at runtime so the source file never
455 // contains a literal private-key header for a scanner to match; the
456 // runtime strings are identical to a real block.
457 let begin = ["-----BEGIN RSA", " PRIVATE KEY-----"].concat();
458 let end = ["-----END RSA", " PRIVATE KEY-----"].concat();
459 let body = "MIIEpAIBAAKCAQEA0Z3VS5JJcds3xfn\nabcdefghijklmnopqrstuvwxyz012345";
460 let pem = format!("{begin}\n{body}\n{end}\nnext_line = ok\n");
461 let expected = format!("{begin}\n[redacted]\n[redacted]\n{end}\nnext_line = ok\n");
462 assert_eq!(redact_model_bound_secrets(&pem), expected);
463 assert_eq!(redact_secrets(&pem), expected);
464 }
465
466 #[test]
467 fn key_based_policy_is_unchanged_by_the_model_bound_mode() {
468 // The broad scrubber for logs/previews keeps masking key-only hits.
469 assert_eq!(
470 redact_secrets(" password: credentials?.password,"),
471 " password: [redacted]"
472 );
473 assert_eq!(
474 redact_secrets(" \"password-validator\": \"^5.3.0\","),
475 " \"password-validator\": \"[redacted]\""
476 );
477 }
478
479 #[test]
480 fn redact_masks_keyed_secrets_toml_and_json() {
481 let synthetic_secret = synthetic_secret_fixture();
482 let input = format!(
483 "\
484 api_key = \"sk-supersecretvalue123\"
485 provider = \"openai\"
486 \"token\": \"{synthetic_secret}\",
487 model = \"mimo-ultraspeed\"
488 PASSWORD=hunter2hunter2"
489 );
490 let out = redact_secrets(&input);
491 assert!(!out.contains("sk-supersecretvalue123"), "{out}");
492 assert!(!out.contains(&synthetic_secret), "{out}");
493 assert!(!out.contains("hunter2hunter2"), "{out}");
494 // Non-secret values survive untouched.
495 assert!(out.contains("provider = \"openai\""));
496 assert!(out.contains("model = \"mimo-ultraspeed\""));
497 assert!(out.matches(REDACTED).count() >= 3, "{out}");
498 }
499
500 #[test]
501 fn redact_json_masks_camel_case_and_dotted_secret_keys() {
502 let synthetic_secret = synthetic_secret_fixture();
503 let input = serde_json::json!({
504 "accessToken": synthetic_secret.clone(),
505 "refreshToken": synthetic_secret_fixture(),
506 "oauth.token": synthetic_secret_fixture(),
507 "APIKey": synthetic_secret_fixture(),
508 "maxTokens": 8192,
509 "tokenBudget": 4096,
510 "tokenCount": 1024,
511 "token_count": 512,
512 "tokenizer": "sentencepiece",
513 });
514
515 let out = redact_json_secrets(&input);
516 for key in ["accessToken", "refreshToken", "oauth.token", "APIKey"] {
517 assert_eq!(out[key], REDACTED, "{key}: {out}");
518 }
519 assert_eq!(out["maxTokens"], 8192);
520 assert_eq!(out["tokenBudget"], 4096);
521 assert_eq!(out["tokenCount"], 1024);
522 assert_eq!(out["token_count"], 512);
523 assert_eq!(out["tokenizer"], "sentencepiece");
524 assert!(!out.to_string().contains(&synthetic_secret), "{out}");
525 }
526
527 #[test]
528 fn redact_json_truncates_pathological_nesting() {
529 let mut value = serde_json::Value::String("leaf".to_string());
530 for _ in 0..150 {
531 let mut map = serde_json::Map::new();
532 map.insert("t".to_string(), value);
533 value = serde_json::Value::Object(map);
534 }
535 let out = redact_json_secrets(&value);
536 let mut cursor = &out;
537 let mut descended = 0;
538 while let serde_json::Value::Object(map) = cursor {
539 cursor = map.values().next().expect("single-key nesting");
540 descended += 1;
541 }
542 assert_eq!(
543 cursor,
544 &serde_json::Value::String(REDACTED.to_string()),
545 "over-deep value must be redacted, not traversed"
546 );
547 assert!(descended < 150, "guard must fire before the leaf");
548 }
549
550 #[test]
551 fn redact_text_masks_camel_case_and_dotted_secret_assignments() {
552 let synthetic_secret = synthetic_secret_fixture();
553 for key in ["accessToken", "refreshToken", "oauth.token", "APIKey"] {
554 let out = redact_secrets(&format!("request failed: {key} = {synthetic_secret}"));
555 assert!(!out.contains(&synthetic_secret), "{key}: {out}");
556 assert!(out.contains(REDACTED), "{key}: {out}");
557 }
558 for key in ["tokenBudget", "tokenCount", "token_count"] {
559 let input = format!("model usage: {key} = 8192");
560 assert_eq!(redact_secrets(&input), input, "{key}");
561 }
562 }
563
564 #[test]
565 fn redact_masks_bare_token_prefixes() {
566 let out = redact_secrets("the leaked key sk-abcdef1234567890 appeared in a log");
567 assert!(!out.contains("sk-abcdef1234567890"), "{out}");
568 assert!(out.contains(REDACTED));
569 assert!(out.contains("appeared in a log"));
570 }
571
572 #[test]
573 fn redact_masks_inline_sensitive_assignments_after_prose_prefixes() {
574 let out = redact_secrets(
575 "Decision: use token=plain-secret-value and api_key:another-secret-value",
576 );
577 assert!(!out.contains("plain-secret-value"), "{out}");
578 assert!(!out.contains("another-secret-value"), "{out}");
579 assert_eq!(out.matches(REDACTED).count(), 2, "{out}");
580 assert!(out.starts_with("Decision: use "), "{out}");
581 }
582
583 #[test]
584 fn redact_masks_spaced_assignment_that_is_not_the_first_separator() {
585 // The shape `redact_secrets(&format!("{error:#}"))` produces: an
586 // anyhow chain puts prose and its own `: ` separators in front of the
587 // assignment, so the sensitive key never owns the line's first
588 // separator and the whole-line pass declines the line.
589 let out = redact_secrets("request failed: api_key = AIzaSyDeadBeefLeak");
590 assert!(!out.contains("AIzaSyDeadBeefLeak"), "{out}");
591 assert!(out.contains(REDACTED), "{out}");
592
593 let synthetic_secret = synthetic_secret_fixture();
594 let out = redact_secrets(&format!("note: the token = {synthetic_secret}"));
595 assert!(!out.contains(&synthetic_secret), "{out}");
596 assert!(out.contains(REDACTED), "{out}");
597 }
598
599 #[test]
600 fn redact_masks_whole_multi_word_value_of_a_spaced_assignment() {
601 // Assemble the placeholder at runtime so secret scanners do not
602 // mistake a redaction fixture for a committed credential.
603 let bearer = ["Bear", "er"].concat();
604 let credential = ["abc123", "def456", "ghi"].concat();
605 let out = redact_secrets(&format!(
606 "mcp call failed: authorization = {bearer} {credential}"
607 ));
608 assert!(!out.contains(&credential), "{out}");
609 assert!(!out.contains(&bearer), "{out}");
610 assert!(
611 out.starts_with("mcp call failed: authorization = "),
612 "{out}"
613 );
614 }
615
616 #[test]
617 fn redact_spaced_pass_leaves_ordinary_prose_alone() {
618 // No sensitive key, so the spaced-assignment state machine must not
619 // start swallowing the rest of the line.
620 let input = "the quick brown fox = jumps over the lazy dog";
621 assert_eq!(redact_secrets(input), input);
622 let input = "note: the model = deepseek-v4-pro and the seed = 7";
623 assert_eq!(redact_secrets(input), input);
624 }
625
626 #[test]
627 fn redact_leaves_token_count_diagnostics_intact() {
628 // "tokens" is the English plural of a usage metric, not a credential
629 // key. The spaced-assignment pass used to treat the "token" hint as a
630 // substring and then drop the rest of the line, which made the exact
631 // class of error people paste into issues unreadable.
632 for input in [
633 "stream error: max tokens = 8192 but budget = 4096",
634 "error: token expired",
635 "request failed: token count: 4096 exceeds the model limit",
636 "http 401: authorization header rejected",
637 "warning: password policy requires 12 characters",
638 "note: secret scanning found 3 issues",
639 ] {
640 assert_eq!(redact_secrets(input), input, "{input}");
641 }
642 }
643
644 #[test]
645 fn redact_still_masks_a_bearer_token_assignment() {
646 // Counterpart of the diagnostic test above: a real credential keyed
647 // as `token` (or `api_token`) must still be dropped, including a
648 // multi-word Bearer value that is not a known bare-token prefix.
649 // The JWT is assembled at runtime so no scanner-shaped literal sits
650 // in the source tree — same precedent as the AWS fixture in
651 // `crates/workflow/src/redaction.rs`.
652 let jwt = ["eyJhbGciOiJIUzI1NiJ9", "e30", "c2lnbmF0dXJl"].join(".");
653 let out = redact_secrets(&format!("stream error: token = Bearer {jwt}"));
654 assert!(!out.contains(&jwt), "{out}");
655 assert!(!out.contains("Bearer"), "{out}");
656 assert!(out.starts_with("stream error: token = "), "{out}");
657 assert!(out.contains(REDACTED), "{out}");
658
659 let synthetic_secret = synthetic_secret_fixture();
660 let out = redact_secrets(&format!("note: api_token = {synthetic_secret}"));
661 assert!(!out.contains(&synthetic_secret), "{out}");
662 assert!(out.contains(REDACTED), "{out}");
663 }
664
665 #[test]
666 fn redact_preserves_line_structure() {
667 let input = "line1\nsecret = \"xyzsecretvalue\"\nline3";
668 let out = redact_secrets(input);
669 let lines: Vec<&str> = out.lines().collect();
670 assert_eq!(lines.len(), 3);
671 assert_eq!(lines[0], "line1");
672 assert_eq!(lines[2], "line3");
673 assert!(lines[1].contains(REDACTED));
674 }
675
676 #[test]
677 fn redact_leaves_plain_text_untouched() {
678 let input = "the quick brown fox = jumps over";
679 // `fox` key has no sensitive hint → unchanged.
680 assert_eq!(redact_secrets(input), input);
681 }
682 }
683
683 lines RUST