1use 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#[cfg(unix)]
34const SETUP_FILE_MODE: u32 = 0o600;
35
36pub 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
73pub 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#[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
104struct Snapshot {
107 path: PathBuf,
108 original: Option<Vec<u8>>,
110}
111
112impl SetupTransaction {
113 #[must_use]
115 pub fn new() -> Self {
116 Self::default()
117 }
118
119 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 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 #[must_use]
151 pub fn preview(&self) -> Vec<&Path> {
152 self.writes.iter().map(|w| w.path.as_path()).collect()
153 }
154
155 #[must_use]
157 pub fn is_empty(&self) -> bool {
158 self.writes.is_empty()
159 }
160
161 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 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 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
210fn 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
233const 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
249const SECRET_TOKEN_PREFIXES: &[&str] = &["sk-", "sk_", "ghp_", "gho_", "xoxb-", "xoxp-", "pk-"];
252
253pub const REDACTED: &str = "[redacted]";
255
256#[must_use]
272pub fn redact_secrets(input: &str) -> String {
273 let mut out = String::with_capacity(input.len());
274 let mut first = true;
275 for line in input.split_inclusive('\n') {
276 if !first {
277 }
280 first = false;
281 out.push_str(&redact_line(line));
282 }
283 out
284}
285
286fn redact_line(line: &str) -> String {
288 let (body, newline) = match line.strip_suffix('\n') {
290 Some(rest) => (rest, "\n"),
291 None => (line, ""),
292 };
293
294 if let Some(redacted) = redact_keyed_assignment(body) {
295 return format!("{redacted}{newline}");
296 }
297
298 let mut changed = false;
300 let masked: Vec<String> = body
301 .split(' ')
302 .map(|word| {
303 let trimmed = word.trim_matches(|c| matches!(c, '"' | '\'' | ',' | ';'));
304 if !trimmed.is_empty() && looks_like_secret_token(trimmed) {
305 changed = true;
306 word.replace(trimmed, REDACTED)
307 } else {
308 word.to_string()
309 }
310 })
311 .collect();
312
313 if changed {
314 format!("{}{newline}", masked.join(" "))
315 } else {
316 format!("{body}{newline}")
317 }
318}
319
320fn redact_keyed_assignment(body: &str) -> Option<String> {
323 let sep_idx = body.find(['=', ':'])?;
325 let (raw_key, rest) = body.split_at(sep_idx);
326 let sep = &rest[..1];
327 let raw_value = &rest[1..];
328
329 let key_norm = raw_key
330 .trim()
331 .trim_matches(|c| matches!(c, '"' | '\'' | '[' | ']'))
332 .to_ascii_lowercase();
333 if key_norm.is_empty() || !SENSITIVE_KEY_HINTS.iter().any(|h| key_norm.contains(h)) {
334 return None;
335 }
336
337 let key_lead_ws: String = raw_key.chars().take_while(|c| c.is_whitespace()).collect();
340 let value_lead_ws: String = raw_value
341 .chars()
342 .take_while(|c| c.is_whitespace())
343 .collect();
344 let value_rest = raw_value.trim_start();
345 if value_rest.is_empty() {
347 return None;
348 }
349 let quoted = value_rest.starts_with('"') || value_rest.starts_with('\'');
351 let replacement = if quoted {
352 format!("\"{REDACTED}\"")
353 } else {
354 REDACTED.to_string()
355 };
356 Some(format!(
357 "{key_lead_ws}{}{sep}{value_lead_ws}{replacement}",
358 raw_key.trim()
359 ))
360}
361
362fn looks_like_secret_token(word: &str) -> bool {
363 SECRET_TOKEN_PREFIXES
364 .iter()
365 .any(|p| word.len() > p.len() + 6 && word.starts_with(p))
366}
367
368#[cfg(test)]
369mod tests {
370 use super::*;
371
372 fn read(path: &Path) -> String {
373 fs::read_to_string(path).unwrap()
374 }
375
376 #[test]
377 fn atomic_write_creates_parent_dirs_and_content() {
378 let tmp = tempfile::tempdir().unwrap();
379 let path = tmp.path().join("nested/dir/state.json");
380 atomic_write(&path, b"hello").unwrap();
381 assert_eq!(read(&path), "hello");
382 }
383
384 #[cfg(unix)]
385 #[test]
386 fn atomic_write_uses_owner_only_permissions() {
387 let tmp = tempfile::tempdir().unwrap();
388 let path = tmp.path().join("state.json");
389 atomic_write(&path, b"x").unwrap();
390 let mode = fs::metadata(&path).unwrap().permissions().mode() & 0o777;
391 assert_eq!(mode, SETUP_FILE_MODE);
392 }
393
394 #[test]
395 fn atomic_write_replaces_existing_atomically() {
396 let tmp = tempfile::tempdir().unwrap();
397 let path = tmp.path().join("state.json");
398 atomic_write(&path, b"old").unwrap();
399 atomic_write(&path, b"new").unwrap();
400 assert_eq!(read(&path), "new");
401 let leftovers: Vec<_> = fs::read_dir(tmp.path())
403 .unwrap()
404 .filter_map(Result::ok)
405 .filter(|e| e.file_name() != "state.json")
406 .collect();
407 assert!(leftovers.is_empty(), "stray temp files: {leftovers:?}");
408 }
409
410 #[test]
411 fn transaction_preview_writes_nothing() {
412 let tmp = tempfile::tempdir().unwrap();
413 let a = tmp.path().join("a.json");
414 let b = tmp.path().join("b.json");
415 let mut tx = SetupTransaction::new();
416 tx.stage(a.clone(), b"1".to_vec())
417 .stage(b.clone(), b"2".to_vec());
418 let preview = tx.preview();
419 assert_eq!(preview, vec![a.as_path(), b.as_path()]);
420 assert!(!a.exists());
421 assert!(!b.exists());
422 }
423
424 #[test]
425 fn dropped_transaction_leaves_files_unchanged() {
426 let tmp = tempfile::tempdir().unwrap();
427 let a = tmp.path().join("a.json");
428 {
429 let mut tx = SetupTransaction::new();
430 tx.stage(a.clone(), b"staged".to_vec());
431 }
433 assert!(!a.exists());
434 }
435
436 #[test]
437 fn transaction_commit_applies_all() {
438 let tmp = tempfile::tempdir().unwrap();
439 let a = tmp.path().join("a.json");
440 let b = tmp.path().join("sub/b.json");
441 let mut tx = SetupTransaction::new();
442 tx.stage(a.clone(), b"A".to_vec())
443 .stage(b.clone(), b"B".to_vec());
444 tx.commit().unwrap();
445 assert_eq!(read(&a), "A");
446 assert_eq!(read(&b), "B");
447 }
448
449 #[test]
450 fn transaction_rolls_back_on_partial_failure() {
451 let tmp = tempfile::tempdir().unwrap();
452 let good = tmp.path().join("good.json");
453 fs::write(&good, "ORIGINAL").unwrap();
454
455 let blocker = tmp.path().join("blocker");
457 fs::write(&blocker, "i am a file").unwrap();
458 let bad = blocker.join("child.json"); let mut tx = SetupTransaction::new();
461 tx.stage(good.clone(), b"UPDATED".to_vec())
462 .stage(bad.clone(), b"NOPE".to_vec());
463 let err = tx.commit().unwrap_err();
464 assert!(format!("{err:#}").contains("rolled back"));
465
466 assert_eq!(read(&good), "ORIGINAL");
468 assert!(!bad.exists());
469 }
470
471 #[test]
472 fn transaction_rollback_removes_newly_created_file() {
473 let tmp = tempfile::tempdir().unwrap();
474 let fresh = tmp.path().join("fresh.json"); let blocker = tmp.path().join("blocker");
476 fs::write(&blocker, "file").unwrap();
477 let bad = blocker.join("child.json");
478
479 let mut tx = SetupTransaction::new();
480 tx.stage(fresh.clone(), b"created".to_vec())
481 .stage(bad, b"x".to_vec());
482 assert!(tx.commit().is_err());
483 assert!(!fresh.exists());
485 }
486
487 #[test]
488 fn redact_masks_keyed_secrets_toml_and_json() {
489 let input = "\
490api_key = \"sk-supersecretvalue123\"
491provider = \"openai\"
492 \"token\": \"abc123def456ghi\",
493model = \"mimo-ultraspeed\"
494PASSWORD=hunter2hunter2";
495 let out = redact_secrets(input);
496 assert!(!out.contains("sk-supersecretvalue123"), "{out}");
497 assert!(!out.contains("abc123def456ghi"), "{out}");
498 assert!(!out.contains("hunter2hunter2"), "{out}");
499 assert!(out.contains("provider = \"openai\""));
501 assert!(out.contains("model = \"mimo-ultraspeed\""));
502 assert!(out.matches(REDACTED).count() >= 3, "{out}");
503 }
504
505 #[test]
506 fn redact_masks_bare_token_prefixes() {
507 let out = redact_secrets("the leaked key sk-abcdef1234567890 appeared in a log");
508 assert!(!out.contains("sk-abcdef1234567890"), "{out}");
509 assert!(out.contains(REDACTED));
510 assert!(out.contains("appeared in a log"));
511 }
512
513 #[test]
514 fn redact_preserves_line_structure() {
515 let input = "line1\nsecret = \"xyzsecretvalue\"\nline3";
516 let out = redact_secrets(input);
517 let lines: Vec<&str> = out.lines().collect();
518 assert_eq!(lines.len(), 3);
519 assert_eq!(lines[0], "line1");
520 assert_eq!(lines[2], "line3");
521 assert!(lines[1].contains(REDACTED));
522 }
523
524 #[test]
525 fn redact_leaves_plain_text_untouched() {
526 let input = "the quick brown fox = jumps over";
527 assert_eq!(redact_secrets(input), input);
529 }
530}