Skip to main content

codewhale_config/
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
23use std::fs;
24use std::path::{Path, PathBuf};
25
26use anyhow::{Context, Result};
27use serde::Serialize;
28
29#[cfg(unix)]
30use std::os::unix::fs::PermissionsExt;
31
32/// Restrictive file mode for setup-owned files (owner read/write only).
33#[cfg(unix)]
34const 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.
42pub 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.
77pub 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)]
94pub struct SetupTransaction {
95    writes: Vec<StagedWrite>,
96}
97
98#[derive(Debug, Clone)]
99struct 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.
106struct Snapshot {
107    path: PathBuf,
108    /// Original bytes, or `None` if the file did not exist before commit.
109    original: Option<Vec<u8>>,
110}
111
112impl 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.
213fn 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.
234const 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.
251const SECRET_TOKEN_PREFIXES: &[&str] = &["sk-", "sk_", "ghp_", "gho_", "xoxb-", "xoxp-", "pk-"];
252
253/// The placeholder substituted for any redacted secret value.
254pub 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]
264pub 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
287fn 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]
308pub 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).
323fn 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
360fn 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`.
382fn 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
422fn 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)]
429mod 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 = "\
550api_key = \"sk-supersecretvalue123\"
551provider = \"openai\"
552  \"token\": \"abc123def456ghi\",
553model = \"mimo-ultraspeed\"
554PASSWORD=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}