use std::fs;
use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use serde::Serialize;
#[cfg(unix)]
use std::os::unix::fs::PermissionsExt;
#[cfg(unix)]
const SETUP_FILE_MODE: u32 = 0o600;
pub fn atomic_write(path: &Path, bytes: &[u8]) -> Result<()> {
let parent = path.parent().filter(|p| !p.as_os_str().is_empty());
if let Some(parent) = parent {
fs::create_dir_all(parent)
.with_context(|| format!("failed to create directory {}", parent.display()))?;
}
let dir = parent.unwrap_or_else(|| Path::new("."));
let mut tmp = tempfile::NamedTempFile::new_in(dir)
.with_context(|| format!("failed to create temp file in {}", dir.display()))?;
use std::io::Write as _;
tmp.write_all(bytes)
.with_context(|| format!("failed to write temp file for {}", path.display()))?;
tmp.flush()
.with_context(|| format!("failed to flush temp file for {}", path.display()))?;
#[cfg(unix)]
{
let perms = fs::Permissions::from_mode(SETUP_FILE_MODE);
tmp.as_file()
.set_permissions(perms)
.with_context(|| format!("failed to set permissions for {}", path.display()))?;
}
tmp.persist(path)
.map_err(|e| e.error)
.with_context(|| format!("failed to persist {}", path.display()))?;
Ok(())
}
pub fn atomic_write_json<T: Serialize>(path: &Path, value: &T) -> Result<()> {
let mut body = serde_json::to_string_pretty(value)
.with_context(|| format!("failed to serialize JSON for {}", path.display()))?;
body.push('\n');
atomic_write(path, body.as_bytes())
}
#[derive(Debug, Default)]
pub struct SetupTransaction {
writes: Vec<StagedWrite>,
}
#[derive(Debug, Clone)]
struct StagedWrite {
path: PathBuf,
bytes: Vec<u8>,
}
struct Snapshot {
path: PathBuf,
original: Option<Vec<u8>>,
}
impl SetupTransaction {
#[must_use]
pub fn new() -> Self {
Self::default()
}
pub fn stage(&mut self, path: impl Into<PathBuf>, bytes: impl Into<Vec<u8>>) -> &mut Self {
let path = path.into();
let bytes = bytes.into();
if let Some(existing) = self.writes.iter_mut().find(|w| w.path == path) {
existing.bytes = bytes;
} else {
self.writes.push(StagedWrite { path, bytes });
}
self
}
pub fn stage_json<T: Serialize>(
&mut self,
path: impl Into<PathBuf>,
value: &T,
) -> Result<&mut Self> {
let path = path.into();
let mut body = serde_json::to_string_pretty(value)
.with_context(|| format!("failed to serialize JSON for {}", path.display()))?;
body.push('\n');
Ok(self.stage(path, body.into_bytes()))
}
#[must_use]
pub fn preview(&self) -> Vec<&Path> {
self.writes.iter().map(|w| w.path.as_path()).collect()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.writes.is_empty()
}
pub fn commit(self) -> Result<()> {
let mut snapshots: Vec<Snapshot> = Vec::with_capacity(self.writes.len());
for write in &self.writes {
let original = match fs::read(&write.path) {
Ok(bytes) => Some(bytes),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => None,
Err(e) => {
rollback(&snapshots);
return Err(e).with_context(|| {
format!(
"failed to read existing {} before write; rolled back {} prior change(s)",
write.path.display(),
snapshots.len()
)
});
}
};
match atomic_write(&write.path, &write.bytes) {
Ok(()) => snapshots.push(Snapshot {
path: write.path.clone(),
original,
}),
Err(err) => {
rollback(&snapshots);
return Err(err).with_context(|| {
format!(
"setup transaction failed writing {}; rolled back {} prior change(s)",
write.path.display(),
snapshots.len()
)
});
}
}
}
Ok(())
}
}
fn rollback(snapshots: &[Snapshot]) {
for snap in snapshots.iter().rev() {
let result = match &snap.original {
Some(bytes) => atomic_write(&snap.path, bytes),
None => match fs::remove_file(&snap.path) {
Ok(()) => Ok(()),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(e) => Err(e.into()),
},
};
if let Err(e) = result {
tracing::error!(
target: "config::persistence",
"failed to roll back {} during setup transaction: {e:#}",
snap.path.display()
);
}
}
}
const SENSITIVE_KEY_HINTS: &[&str] = &[
"api_key",
"apikey",
"api-key",
"secret",
"token",
"password",
"passwd",
"authorization",
"auth_token",
"access_key",
"client_secret",
"private_key",
];
const SECRET_TOKEN_PREFIXES: &[&str] = &["sk-", "sk_", "ghp_", "gho_", "xoxb-", "xoxp-", "pk-"];
pub const REDACTED: &str = "[redacted]";
#[must_use]
pub fn redact_secrets(input: &str) -> String {
let mut out = String::with_capacity(input.len());
let mut first = true;
for line in input.split_inclusive('\n') {
if !first {
}
first = false;
out.push_str(&redact_line(line));
}
out
}
fn redact_line(line: &str) -> String {
let (body, newline) = match line.strip_suffix('\n') {
Some(rest) => (rest, "\n"),
None => (line, ""),
};
if let Some(redacted) = redact_keyed_assignment(body) {
return format!("{redacted}{newline}");
}
let mut changed = false;
let masked: Vec<String> = body
.split(' ')
.map(|word| {
let trimmed = word.trim_matches(|c| matches!(c, '"' | '\'' | ',' | ';'));
if !trimmed.is_empty() && looks_like_secret_token(trimmed) {
changed = true;
word.replace(trimmed, REDACTED)
} else {
word.to_string()
}
})
.collect();
if changed {
format!("{}{newline}", masked.join(" "))
} else {
format!("{body}{newline}")
}
}
fn redact_keyed_assignment(body: &str) -> Option<String> {
let sep_idx = body.find(['=', ':'])?;
let (raw_key, rest) = body.split_at(sep_idx);
let sep = &rest[..1];
let raw_value = &rest[1..];
let key_norm = raw_key
.trim()
.trim_matches(|c| matches!(c, '"' | '\'' | '[' | ']'))
.to_ascii_lowercase();
if key_norm.is_empty() || !SENSITIVE_KEY_HINTS.iter().any(|h| key_norm.contains(h)) {
return None;
}
let key_lead_ws: String = raw_key.chars().take_while(|c| c.is_whitespace()).collect();
let value_lead_ws: String = raw_value
.chars()
.take_while(|c| c.is_whitespace())
.collect();
let value_rest = raw_value.trim_start();
if value_rest.is_empty() {
return None;
}
let quoted = value_rest.starts_with('"') || value_rest.starts_with('\'');
let replacement = if quoted {
format!("\"{REDACTED}\"")
} else {
REDACTED.to_string()
};
Some(format!(
"{key_lead_ws}{}{sep}{value_lead_ws}{replacement}",
raw_key.trim()
))
}
fn looks_like_secret_token(word: &str) -> bool {
SECRET_TOKEN_PREFIXES
.iter()
.any(|p| word.len() > p.len() + 6 && word.starts_with(p))
}
#[cfg(test)]
mod tests {
use super::*;
fn read(path: &Path) -> String {
fs::read_to_string(path).unwrap()
}
#[test]
fn atomic_write_creates_parent_dirs_and_content() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("nested/dir/state.json");
atomic_write(&path, b"hello").unwrap();
assert_eq!(read(&path), "hello");
}
#[cfg(unix)]
#[test]
fn atomic_write_uses_owner_only_permissions() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("state.json");
atomic_write(&path, b"x").unwrap();
let mode = fs::metadata(&path).unwrap().permissions().mode() & 0o777;
assert_eq!(mode, SETUP_FILE_MODE);
}
#[test]
fn atomic_write_replaces_existing_atomically() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("state.json");
atomic_write(&path, b"old").unwrap();
atomic_write(&path, b"new").unwrap();
assert_eq!(read(&path), "new");
let leftovers: Vec<_> = fs::read_dir(tmp.path())
.unwrap()
.filter_map(Result::ok)
.filter(|e| e.file_name() != "state.json")
.collect();
assert!(leftovers.is_empty(), "stray temp files: {leftovers:?}");
}
#[test]
fn transaction_preview_writes_nothing() {
let tmp = tempfile::tempdir().unwrap();
let a = tmp.path().join("a.json");
let b = tmp.path().join("b.json");
let mut tx = SetupTransaction::new();
tx.stage(a.clone(), b"1".to_vec())
.stage(b.clone(), b"2".to_vec());
let preview = tx.preview();
assert_eq!(preview, vec![a.as_path(), b.as_path()]);
assert!(!a.exists());
assert!(!b.exists());
}
#[test]
fn dropped_transaction_leaves_files_unchanged() {
let tmp = tempfile::tempdir().unwrap();
let a = tmp.path().join("a.json");
{
let mut tx = SetupTransaction::new();
tx.stage(a.clone(), b"staged".to_vec());
}
assert!(!a.exists());
}
#[test]
fn transaction_commit_applies_all() {
let tmp = tempfile::tempdir().unwrap();
let a = tmp.path().join("a.json");
let b = tmp.path().join("sub/b.json");
let mut tx = SetupTransaction::new();
tx.stage(a.clone(), b"A".to_vec())
.stage(b.clone(), b"B".to_vec());
tx.commit().unwrap();
assert_eq!(read(&a), "A");
assert_eq!(read(&b), "B");
}
#[test]
fn transaction_rolls_back_on_partial_failure() {
let tmp = tempfile::tempdir().unwrap();
let good = tmp.path().join("good.json");
fs::write(&good, "ORIGINAL").unwrap();
let blocker = tmp.path().join("blocker");
fs::write(&blocker, "i am a file").unwrap();
let bad = blocker.join("child.json");
let mut tx = SetupTransaction::new();
tx.stage(good.clone(), b"UPDATED".to_vec())
.stage(bad.clone(), b"NOPE".to_vec());
let err = tx.commit().unwrap_err();
assert!(format!("{err:#}").contains("rolled back"));
assert_eq!(read(&good), "ORIGINAL");
assert!(!bad.exists());
}
#[test]
fn transaction_rollback_removes_newly_created_file() {
let tmp = tempfile::tempdir().unwrap();
let fresh = tmp.path().join("fresh.json"); let blocker = tmp.path().join("blocker");
fs::write(&blocker, "file").unwrap();
let bad = blocker.join("child.json");
let mut tx = SetupTransaction::new();
tx.stage(fresh.clone(), b"created".to_vec())
.stage(bad, b"x".to_vec());
assert!(tx.commit().is_err());
assert!(!fresh.exists());
}
#[test]
fn redact_masks_keyed_secrets_toml_and_json() {
let input = "\
api_key = \"sk-supersecretvalue123\"
provider = \"openai\"
\"token\": \"abc123def456ghi\",
model = \"mimo-ultraspeed\"
PASSWORD=hunter2hunter2";
let out = redact_secrets(input);
assert!(!out.contains("sk-supersecretvalue123"), "{out}");
assert!(!out.contains("abc123def456ghi"), "{out}");
assert!(!out.contains("hunter2hunter2"), "{out}");
assert!(out.contains("provider = \"openai\""));
assert!(out.contains("model = \"mimo-ultraspeed\""));
assert!(out.matches(REDACTED).count() >= 3, "{out}");
}
#[test]
fn redact_masks_bare_token_prefixes() {
let out = redact_secrets("the leaked key sk-abcdef1234567890 appeared in a log");
assert!(!out.contains("sk-abcdef1234567890"), "{out}");
assert!(out.contains(REDACTED));
assert!(out.contains("appeared in a log"));
}
#[test]
fn redact_preserves_line_structure() {
let input = "line1\nsecret = \"xyzsecretvalue\"\nline3";
let out = redact_secrets(input);
let lines: Vec<&str> = out.lines().collect();
assert_eq!(lines.len(), 3);
assert_eq!(lines[0], "line1");
assert_eq!(lines[2], "line3");
assert!(lines[1].contains(REDACTED));
}
#[test]
fn redact_leaves_plain_text_untouched() {
let input = "the quick brown fox = jumps over";
assert_eq!(redact_secrets(input), input);
}
}