返回 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 /// Substrings that mark a config/JSON/env key as carrying a secret value.
234 const SENSITIVE_KEY_HINTS: &[&str] = &[
235 "api_key",
236 "apikey",
237 "api-key",
238 "secret",
239 "token",
240 "password",
241 "passwd",
242 "authorization",
243 "auth_token",
244 "access_key",
245 "client_secret",
246 "private_key",
247 ];
248
249 /// Known opaque-token prefixes worth masking even when they appear bare (not as
250 /// `key = value`). Conservative on purpose: only well-known provider/key shapes.
251 const SECRET_TOKEN_PREFIXES: &[&str] = &["sk-", "sk_", "ghp_", "gho_", "xoxb-", "xoxp-", "pk-"];
252
253 /// The placeholder substituted for any redacted secret value.
254 pub const REDACTED: &str = "[redacted]";
255
256 /// Return a copy of a JSON value with secret-bearing data removed.
257 ///
258 /// Object values whose key contains a sensitive hint are replaced wholesale,
259 /// while all other objects and arrays are traversed recursively. String leaves
260 /// still pass through [`redact_secrets`] so bare provider tokens and embedded
261 /// assignments remain covered without treating the serialized JSON document as
262 /// one flat keyed assignment.
263 #[must_use]
264 pub fn redact_json_secrets(value: &serde_json::Value) -> serde_json::Value {
265 match value {
266 serde_json::Value::Object(object) => serde_json::Value::Object(
267 object
268 .iter()
269 .map(|(key, value)| {
270 let value = if sensitive_json_key(key) {
271 serde_json::Value::String(REDACTED.to_string())
272 } else {
273 redact_json_secrets(value)
274 };
275 (key.clone(), value)
276 })
277 .collect(),
278 ),
279 serde_json::Value::Array(items) => {
280 serde_json::Value::Array(items.iter().map(redact_json_secrets).collect())
281 }
282 serde_json::Value::String(text) => serde_json::Value::String(redact_secrets(text)),
283 scalar => scalar.clone(),
284 }
285 }
286
287 fn sensitive_json_key(key: &str) -> bool {
288 let key = key.to_ascii_lowercase();
289 SENSITIVE_KEY_HINTS.iter().any(|hint| key.contains(hint))
290 }
291
292 /// Redact secret-bearing values from arbitrary text so it is safe to put in a
293 /// setup report, log line, error message, or test snapshot.
294 ///
295 /// Two passes, both dependency-free:
296 ///
297 /// 1. **Keyed assignments.** Lines or whitespace-delimited inline tokens shaped
298 /// like `key = value`, `key: value`, or `key=value` whose key
299 /// (case-insensitively, ignoring quotes) contains a `SENSITIVE_KEY_HINTS`
300 /// substring have their value replaced with [`REDACTED`].
301 /// 2. **Bare tokens.** Whitespace-delimited words beginning with a known
302 /// `SECRET_TOKEN_PREFIXES` are replaced wholesale.
303 ///
304 /// The goal is defense in depth: setup state and reports are built from safe
305 /// summaries that never include secrets in the first place, and this is the
306 /// backstop for anything that echoes raw config text.
307 #[must_use]
308 pub fn redact_secrets(input: &str) -> String {
309 let mut out = String::with_capacity(input.len());
310 let mut first = true;
311 for line in input.split_inclusive('\n') {
312 if !first {
313 // split_inclusive keeps the newline on the previous chunk, so we do
314 // not need to re-add separators here.
315 }
316 first = false;
317 out.push_str(&redact_line(line));
318 }
319 out
320 }
321
322 /// Redact a single line (which may include a trailing newline).
323 fn redact_line(line: &str) -> String {
324 // Preserve any trailing newline so callers keep their line structure.
325 let (body, newline) = match line.strip_suffix('\n') {
326 Some(rest) => (rest, "\n"),
327 None => (line, ""),
328 };
329
330 if let Some(redacted) = redact_keyed_assignment(body) {
331 return format!("{redacted}{newline}");
332 }
333
334 // Inline-assignment / bare-token pass: mask any whitespace-delimited word
335 // carrying a sensitive keyed value or a known bare secret prefix.
336 let mut changed = false;
337 let masked: Vec<String> = body
338 .split(' ')
339 .map(|word| {
340 let trimmed = word.trim_matches(|c| matches!(c, '"' | '\'' | ',' | ';'));
341 if let Some(redacted) = redact_inline_keyed_assignment(trimmed) {
342 changed = true;
343 word.replace(trimmed, &redacted)
344 } else if !trimmed.is_empty() && looks_like_secret_token(trimmed) {
345 changed = true;
346 word.replace(trimmed, REDACTED)
347 } else {
348 word.to_string()
349 }
350 })
351 .collect();
352
353 if changed {
354 format!("{}{newline}", masked.join(" "))
355 } else {
356 format!("{body}{newline}")
357 }
358 }
359
360 fn redact_inline_keyed_assignment(word: &str) -> Option<String> {
361 let sep_idx = word.find(['=', ':'])?;
362 let (raw_key, rest) = word.split_at(sep_idx);
363 let raw_value = &rest[1..];
364 if raw_value.is_empty() {
365 return None;
366 }
367 let key_norm = raw_key
368 .trim_matches(|c: char| !c.is_ascii_alphanumeric() && c != '_' && c != '-')
369 .to_ascii_lowercase();
370 if key_norm.is_empty()
371 || !SENSITIVE_KEY_HINTS
372 .iter()
373 .any(|hint| key_norm.contains(hint))
374 {
375 return None;
376 }
377 Some(format!("{}{}{}", raw_key, &rest[..1], REDACTED))
378 }
379
380 /// If `body` is a `key <sep> value` assignment with a sensitive key, return the
381 /// line with the value redacted; otherwise `None`.
382 fn redact_keyed_assignment(body: &str) -> Option<String> {
383 // Find the first `=` or `:` that separates a key from a value.
384 let sep_idx = body.find(['=', ':'])?;
385 let (raw_key, rest) = body.split_at(sep_idx);
386 let sep = &rest[..1];
387 let raw_value = &rest[1..];
388
389 let key_norm = raw_key
390 .trim()
391 .trim_matches(|c| matches!(c, '"' | '\'' | '[' | ']'))
392 .to_ascii_lowercase();
393 if key_norm.is_empty() || !SENSITIVE_KEY_HINTS.iter().any(|h| key_norm.contains(h)) {
394 return None;
395 }
396
397 // Keep leading whitespace of the key and the original separator spacing so
398 // the redacted line reads naturally.
399 let key_lead_ws: String = raw_key.chars().take_while(|c| c.is_whitespace()).collect();
400 let value_lead_ws: String = raw_value
401 .chars()
402 .take_while(|c| c.is_whitespace())
403 .collect();
404 let value_rest = raw_value.trim_start();
405 // If the value is empty, there is nothing to hide.
406 if value_rest.is_empty() {
407 return None;
408 }
409 // Preserve surrounding quotes so structured files stay parseable-looking.
410 let quoted = value_rest.starts_with('"') || value_rest.starts_with('\'');
411 let replacement = if quoted {
412 format!("\"{REDACTED}\"")
413 } else {
414 REDACTED.to_string()
415 };
416 Some(format!(
417 "{key_lead_ws}{}{sep}{value_lead_ws}{replacement}",
418 raw_key.trim()
419 ))
420 }
421
422 fn looks_like_secret_token(word: &str) -> bool {
423 SECRET_TOKEN_PREFIXES
424 .iter()
425 .any(|p| word.len() > p.len() + 6 && word.starts_with(p))
426 }
427
428 #[cfg(test)]
429 mod tests {
430 use super::*;
431
432 fn read(path: &Path) -> String {
433 fs::read_to_string(path).unwrap()
434 }
435
436 #[test]
437 fn atomic_write_creates_parent_dirs_and_content() {
438 let tmp = tempfile::tempdir().unwrap();
439 let path = tmp.path().join("nested/dir/state.json");
440 atomic_write(&path, b"hello").unwrap();
441 assert_eq!(read(&path), "hello");
442 }
443
444 #[cfg(unix)]
445 #[test]
446 fn atomic_write_uses_owner_only_permissions() {
447 let tmp = tempfile::tempdir().unwrap();
448 let path = tmp.path().join("state.json");
449 atomic_write(&path, b"x").unwrap();
450 let mode = fs::metadata(&path).unwrap().permissions().mode() & 0o777;
451 assert_eq!(mode, SETUP_FILE_MODE);
452 }
453
454 #[test]
455 fn atomic_write_replaces_existing_atomically() {
456 let tmp = tempfile::tempdir().unwrap();
457 let path = tmp.path().join("state.json");
458 atomic_write(&path, b"old").unwrap();
459 atomic_write(&path, b"new").unwrap();
460 assert_eq!(read(&path), "new");
461 // No stray temp files left behind.
462 let leftovers: Vec<_> = fs::read_dir(tmp.path())
463 .unwrap()
464 .filter_map(Result::ok)
465 .filter(|e| e.file_name() != "state.json")
466 .collect();
467 assert!(leftovers.is_empty(), "stray temp files: {leftovers:?}");
468 }
469
470 #[test]
471 fn transaction_preview_writes_nothing() {
472 let tmp = tempfile::tempdir().unwrap();
473 let a = tmp.path().join("a.json");
474 let b = tmp.path().join("b.json");
475 let mut tx = SetupTransaction::new();
476 tx.stage(a.clone(), b"1".to_vec())
477 .stage(b.clone(), b"2".to_vec());
478 let preview = tx.preview();
479 assert_eq!(preview, vec![a.as_path(), b.as_path()]);
480 assert!(!a.exists());
481 assert!(!b.exists());
482 }
483
484 #[test]
485 fn dropped_transaction_leaves_files_unchanged() {
486 let tmp = tempfile::tempdir().unwrap();
487 let a = tmp.path().join("a.json");
488 {
489 let mut tx = SetupTransaction::new();
490 tx.stage(a.clone(), b"staged".to_vec());
491 // tx dropped here without commit
492 }
493 assert!(!a.exists());
494 }
495
496 #[test]
497 fn transaction_commit_applies_all() {
498 let tmp = tempfile::tempdir().unwrap();
499 let a = tmp.path().join("a.json");
500 let b = tmp.path().join("sub/b.json");
501 let mut tx = SetupTransaction::new();
502 tx.stage(a.clone(), b"A".to_vec())
503 .stage(b.clone(), b"B".to_vec());
504 tx.commit().unwrap();
505 assert_eq!(read(&a), "A");
506 assert_eq!(read(&b), "B");
507 }
508
509 #[test]
510 fn transaction_rolls_back_on_partial_failure() {
511 let tmp = tempfile::tempdir().unwrap();
512 let good = tmp.path().join("good.json");
513 fs::write(&good, "ORIGINAL").unwrap();
514
515 // Second target is unwritable: a path whose parent is an existing file.
516 let blocker = tmp.path().join("blocker");
517 fs::write(&blocker, "i am a file").unwrap();
518 let bad = blocker.join("child.json"); // parent is a file → create_dir_all fails
519
520 let mut tx = SetupTransaction::new();
521 tx.stage(good.clone(), b"UPDATED".to_vec())
522 .stage(bad.clone(), b"NOPE".to_vec());
523 let err = tx.commit().unwrap_err();
524 assert!(format!("{err:#}").contains("rolled back"));
525
526 // The first file must be restored to its original contents.
527 assert_eq!(read(&good), "ORIGINAL");
528 assert!(!bad.exists());
529 }
530
531 #[test]
532 fn transaction_rollback_removes_newly_created_file() {
533 let tmp = tempfile::tempdir().unwrap();
534 let fresh = tmp.path().join("fresh.json"); // did not exist before
535 let blocker = tmp.path().join("blocker");
536 fs::write(&blocker, "file").unwrap();
537 let bad = blocker.join("child.json");
538
539 let mut tx = SetupTransaction::new();
540 tx.stage(fresh.clone(), b"created".to_vec())
541 .stage(bad, b"x".to_vec());
542 assert!(tx.commit().is_err());
543 // The newly created file must be removed on rollback, not left behind.
544 assert!(!fresh.exists());
545 }
546
547 #[test]
548 fn redact_masks_keyed_secrets_toml_and_json() {
549 let input = "\
550 api_key = \"sk-supersecretvalue123\"
551 provider = \"openai\"
552 \"token\": \"abc123def456ghi\",
553 model = \"mimo-ultraspeed\"
554 PASSWORD=hunter2hunter2";
555 let out = redact_secrets(input);
556 assert!(!out.contains("sk-supersecretvalue123"), "{out}");
557 assert!(!out.contains("abc123def456ghi"), "{out}");
558 assert!(!out.contains("hunter2hunter2"), "{out}");
559 // Non-secret values survive untouched.
560 assert!(out.contains("provider = \"openai\""));
561 assert!(out.contains("model = \"mimo-ultraspeed\""));
562 assert!(out.matches(REDACTED).count() >= 3, "{out}");
563 }
564
565 #[test]
566 fn redact_masks_bare_token_prefixes() {
567 let out = redact_secrets("the leaked key sk-abcdef1234567890 appeared in a log");
568 assert!(!out.contains("sk-abcdef1234567890"), "{out}");
569 assert!(out.contains(REDACTED));
570 assert!(out.contains("appeared in a log"));
571 }
572
573 #[test]
574 fn redact_masks_inline_sensitive_assignments_after_prose_prefixes() {
575 let out = redact_secrets(
576 "Decision: use token=plain-secret-value and api_key:another-secret-value",
577 );
578 assert!(!out.contains("plain-secret-value"), "{out}");
579 assert!(!out.contains("another-secret-value"), "{out}");
580 assert_eq!(out.matches(REDACTED).count(), 2, "{out}");
581 assert!(out.starts_with("Decision: use "), "{out}");
582 }
583
584 #[test]
585 fn redact_preserves_line_structure() {
586 let input = "line1\nsecret = \"xyzsecretvalue\"\nline3";
587 let out = redact_secrets(input);
588 let lines: Vec<&str> = out.lines().collect();
589 assert_eq!(lines.len(), 3);
590 assert_eq!(lines[0], "line1");
591 assert_eq!(lines[2], "line3");
592 assert!(lines[1].contains(REDACTED));
593 }
594
595 #[test]
596 fn redact_leaves_plain_text_untouched() {
597 let input = "the quick brown fox = jumps over";
598 // `fox` key has no sensitive hint → unchanged.
599 assert_eq!(redact_secrets(input), input);
600 }
601 }
602
602 lines RUST