Skip to main content

ralph/
fsutil.rs

1//! Filesystem helpers for temp directories, atomic writes, path normalization, and safeguard dumps.
2//!
3//! Responsibilities:
4//! - Create and clean Ralph temp directories.
5//! - Write files atomically and sync parent directories best-effort.
6//! - Persist safeguard dumps for troubleshooting output.
7//! - Redact sensitive data in safeguard dumps by default (secrets, API keys, tokens).
8//! - Expand tilde (`~`) to the user's home directory for Unix-style paths.
9//!
10//! Not handled here:
11//! - Directory locks or lock ownership metadata (see `crate::lock`).
12//! - Cross-device file moves or distributed filesystem semantics.
13//! - Retry/backoff behavior beyond the current best-effort operations.
14//! - Redaction logic itself (see `crate::redaction`).
15//!
16//! Invariants/assumptions:
17//! - Callers provide valid paths; `write_atomic` requires a parent directory.
18//! - Temp cleanup is best-effort and may skip entries on IO errors.
19//! - `safeguard_text_dump` requires explicit opt-in (env var or debug mode) to write raw content.
20//! - `safeguard_text_dump_redacted` is the default and safe choice for error dumps.
21//! - `expand_tilde` only handles leading `~` or `~/...`, not `~user/...` or nested tildes.
22
23use crate::constants::paths::{LEGACY_PROMPT_PREFIX, RALPH_TEMP_DIR_NAME};
24
25// Re-export for tests and external use
26pub use crate::constants::paths::RALPH_TEMP_PREFIX;
27use anyhow::{Context, Result};
28use std::fs;
29use std::io::Write;
30use std::path::{Path, PathBuf};
31use std::time::{Duration, SystemTime};
32
33/// Expands a leading `~` to the user's home directory (`$HOME`) for Unix-style paths.
34///
35/// Supported:
36/// - `~` → `$HOME`
37/// - `~/...` → `$HOME/...`
38///
39/// Not handled (intentionally):
40/// - `~user/...` (username-based expansion)
41/// - `.../~/...` (nested tilde)
42/// - Windows `%USERPROFILE%` expansion (callers should supply absolute paths)
43///
44/// If `$HOME` is unset or empty, the input path is returned unchanged.
45pub fn expand_tilde(path: &Path) -> PathBuf {
46    let raw = path.to_string_lossy();
47
48    let home = std::env::var("HOME")
49        .ok()
50        .map(|v| v.trim().to_string())
51        .filter(|v| !v.is_empty());
52
53    let Some(home) = home else {
54        log::debug!(
55            "HOME environment variable not set; skipping tilde expansion for path: {}",
56            raw
57        );
58        return path.to_path_buf();
59    };
60
61    if raw == "~" {
62        return PathBuf::from(home);
63    }
64
65    if let Some(rest) = raw.strip_prefix("~/") {
66        // Avoid `PathBuf::join` treating `rest` as absolute if user wrote "~//foo".
67        let rest = rest.trim_start_matches(&['/', '\\'][..]);
68        return PathBuf::from(home).join(rest);
69    }
70
71    path.to_path_buf()
72}
73
74pub fn ralph_temp_root() -> PathBuf {
75    std::env::temp_dir().join(RALPH_TEMP_DIR_NAME)
76}
77
78pub fn cleanup_stale_temp_entries(
79    base: &Path,
80    prefixes: &[&str],
81    retention: Duration,
82) -> Result<usize> {
83    if !base.exists() {
84        return Ok(0);
85    }
86
87    let now = SystemTime::now();
88    let mut removed = 0usize;
89
90    for entry in fs::read_dir(base).with_context(|| format!("read temp dir {}", base.display()))? {
91        let entry = entry.with_context(|| format!("read temp dir entry in {}", base.display()))?;
92        let path = entry.path();
93        let name = entry.file_name();
94        let name = name.to_string_lossy();
95
96        if !prefixes.iter().any(|prefix| name.starts_with(prefix)) {
97            continue;
98        }
99
100        let metadata = match entry.metadata() {
101            Ok(metadata) => metadata,
102            Err(err) => {
103                log::warn!(
104                    "unable to read temp metadata for {}: {}",
105                    path.display(),
106                    err
107                );
108                continue;
109            }
110        };
111
112        let modified = match metadata.modified() {
113            Ok(time) => time,
114            Err(err) => {
115                log::warn!(
116                    "unable to read temp modified time for {}: {}",
117                    path.display(),
118                    err
119                );
120                continue;
121            }
122        };
123
124        let age = match now.duration_since(modified) {
125            Ok(age) => age,
126            Err(_) => continue,
127        };
128
129        if age < retention {
130            continue;
131        }
132
133        if metadata.is_dir() {
134            if fs::remove_dir_all(&path).is_ok() {
135                removed += 1;
136            } else {
137                log::warn!("failed to remove temp dir {}", path.display());
138            }
139        } else if fs::remove_file(&path).is_ok() {
140            removed += 1;
141        } else {
142            log::warn!("failed to remove temp file {}", path.display());
143        }
144    }
145
146    Ok(removed)
147}
148
149pub fn cleanup_stale_temp_dirs(base: &Path, retention: Duration) -> Result<usize> {
150    cleanup_stale_temp_entries(base, &[RALPH_TEMP_PREFIX], retention)
151}
152
153pub fn cleanup_default_temp_dirs(retention: Duration) -> Result<usize> {
154    let mut removed = 0usize;
155    removed += cleanup_stale_temp_dirs(&ralph_temp_root(), retention)?;
156    removed +=
157        cleanup_stale_temp_entries(&std::env::temp_dir(), &[LEGACY_PROMPT_PREFIX], retention)?;
158    Ok(removed)
159}
160
161pub fn create_ralph_temp_dir(label: &str) -> Result<tempfile::TempDir> {
162    let base = ralph_temp_root();
163    fs::create_dir_all(&base).with_context(|| format!("create temp dir {}", base.display()))?;
164    let prefix = format!(
165        "{prefix}{label}_",
166        prefix = RALPH_TEMP_PREFIX,
167        label = label.trim()
168    );
169    let dir = tempfile::Builder::new()
170        .prefix(&prefix)
171        .tempdir_in(&base)
172        .with_context(|| format!("create temp dir in {}", base.display()))?;
173    Ok(dir)
174}
175
176/// Creates a NamedTempFile in the ralph temp directory with the ralph_ prefix.
177/// This ensures the file will be caught by cleanup_default_temp_dirs().
178pub fn create_ralph_temp_file(label: &str) -> Result<tempfile::NamedTempFile> {
179    let base = ralph_temp_root();
180    fs::create_dir_all(&base).with_context(|| format!("create temp dir {}", base.display()))?;
181    let prefix = format!(
182        "{prefix}{label}_",
183        prefix = RALPH_TEMP_PREFIX,
184        label = label.trim()
185    );
186    tempfile::Builder::new()
187        .prefix(&prefix)
188        .suffix(".tmp")
189        .tempfile_in(&base)
190        .with_context(|| format!("create temp file in {}", base.display()))
191}
192
193use crate::constants::paths::ENV_RAW_DUMP;
194
195/// Writes a safeguard dump with redaction applied to sensitive content.
196///
197/// This is the recommended default for error dumps. Secrets like API keys,
198/// bearer tokens, AWS keys, and SSH keys are masked before writing.
199///
200/// Returns the path to the written file.
201pub fn safeguard_text_dump_redacted(label: &str, content: &str) -> Result<PathBuf> {
202    use crate::redaction::redact_text;
203    let redacted_content = redact_text(content);
204    safeguard_text_dump_internal(label, &redacted_content, true)
205}
206
207/// Writes a safeguard dump with raw (non-redacted) content.
208///
209/// SECURITY WARNING: This function writes raw content that may contain secrets.
210/// It requires explicit opt-in via either:
211/// - Setting the `RALPH_RAW_DUMP=1` environment variable
212/// - Passing `is_debug_mode=true` (e.g., when `--debug` flag is used)
213///
214/// If opt-in is not provided, this function returns an error.
215/// For safe dumping, use `safeguard_text_dump_redacted` instead.
216pub fn safeguard_text_dump(label: &str, content: &str, is_debug_mode: bool) -> Result<PathBuf> {
217    let raw_dump_enabled = std::env::var(ENV_RAW_DUMP)
218        .map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
219        .unwrap_or(false);
220
221    if !raw_dump_enabled && !is_debug_mode {
222        anyhow::bail!(
223            "Raw safeguard dumps require explicit opt-in. \
224             Set {}=1 or use --debug mode. \
225             Consider using safeguard_text_dump_redacted() for safe dumping.",
226            ENV_RAW_DUMP
227        );
228    }
229
230    if raw_dump_enabled {
231        log::warn!(
232            "SECURITY: Writing raw safeguard dump ({}=1). Secrets may be written to disk.",
233            ENV_RAW_DUMP
234        );
235    }
236
237    safeguard_text_dump_internal(label, content, false)
238}
239
240fn safeguard_text_dump_internal(label: &str, content: &str, _is_redacted: bool) -> Result<PathBuf> {
241    let temp_dir = create_ralph_temp_dir(label)?;
242    let output_path = temp_dir.path().join("output.txt");
243    fs::write(&output_path, content)
244        .with_context(|| format!("write safeguard dump to {}", output_path.display()))?;
245
246    // Persist the temp dir so it's not deleted when the TempDir object is dropped.
247    let dir_path = temp_dir.keep();
248    Ok(dir_path.join("output.txt"))
249}
250
251pub fn write_atomic(path: &Path, contents: &[u8]) -> Result<()> {
252    log::debug!("atomic write: {}", path.display());
253    let dir = path
254        .parent()
255        .context("atomic write requires a parent directory")?;
256    fs::create_dir_all(dir).with_context(|| format!("create directory {}", dir.display()))?;
257
258    let mut tmp = tempfile::NamedTempFile::new_in(dir)
259        .with_context(|| format!("create temp file in {}", dir.display()))?;
260    tmp.write_all(contents).context("write temp file")?;
261    tmp.flush().context("flush temp file")?;
262    tmp.as_file().sync_all().context("sync temp file")?;
263
264    match tmp.persist(path) {
265        Ok(_) => {}
266        Err(err) => {
267            // Explicitly drop the temp file to ensure cleanup on persist failure.
268            // PersistError contains both the error and the NamedTempFile handle;
269            // we must extract and drop the file handle to prevent temp file leaks.
270            let _temp_file = err.file;
271            drop(_temp_file);
272            return Err(err.error).with_context(|| format!("persist {}", path.display()));
273        }
274    }
275
276    sync_dir_best_effort(dir);
277    Ok(())
278}
279
280pub(crate) fn sync_dir_best_effort(dir: &Path) {
281    #[cfg(unix)]
282    {
283        log::debug!("syncing directory: {}", dir.display());
284        if let Ok(file) = fs::File::open(dir) {
285            let _ = file.sync_all();
286        }
287    }
288
289    #[cfg(not(unix))]
290    {
291        let _ = dir;
292    }
293}
294
295#[cfg(test)]
296mod tests {
297    use super::*;
298    use std::sync::{Mutex, OnceLock};
299
300    fn env_lock() -> &'static Mutex<()> {
301        static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
302        LOCK.get_or_init(|| Mutex::new(()))
303    }
304
305    #[test]
306    fn safeguard_text_dump_redacted_masks_secrets() {
307        let content = "API_KEY=sk-abc123xyz789\nAuthorization: Bearer secret_token_12345";
308        let path = safeguard_text_dump_redacted("test_redacted", content).unwrap();
309
310        let written = fs::read_to_string(&path).unwrap();
311
312        assert!(
313            !written.contains("sk-abc123xyz789"),
314            "API key should be redacted"
315        );
316        assert!(
317            !written.contains("secret_token_12345"),
318            "Bearer token should be redacted"
319        );
320        assert!(
321            written.contains("[REDACTED]"),
322            "Should contain redaction marker"
323        );
324
325        // Cleanup
326        let _ = fs::remove_file(&path);
327        if let Some(parent) = path.parent() {
328            let _ = fs::remove_dir(parent);
329        }
330    }
331
332    #[test]
333    fn safeguard_text_dump_requires_opt_in_without_debug() {
334        let _guard = env_lock().lock().expect("env lock");
335
336        // Ensure env var is not set
337        unsafe { std::env::remove_var(ENV_RAW_DUMP) }
338
339        let content = "sensitive data";
340        let result = safeguard_text_dump("test_raw", content, false);
341
342        assert!(result.is_err(), "Raw dump should fail without opt-in");
343        let err_msg = result.unwrap_err().to_string();
344        assert!(
345            err_msg.contains("RALPH_RAW_DUMP"),
346            "Error should mention env var"
347        );
348    }
349
350    #[test]
351    fn safeguard_text_dump_allows_raw_with_env_var() {
352        let _guard = env_lock().lock().expect("env lock");
353
354        unsafe { std::env::set_var(ENV_RAW_DUMP, "1") };
355
356        let content = "raw secret data";
357        let path = safeguard_text_dump("test_raw_env", content, false).unwrap();
358
359        let written = fs::read_to_string(&path).unwrap();
360        assert_eq!(written, content, "Raw content should be written unchanged");
361
362        // Cleanup
363        unsafe { std::env::remove_var(ENV_RAW_DUMP) }
364        let _ = fs::remove_file(&path);
365        if let Some(parent) = path.parent() {
366            let _ = fs::remove_dir(parent);
367        }
368    }
369
370    #[test]
371    fn safeguard_text_dump_allows_raw_with_debug_mode() {
372        let _guard = env_lock().lock().expect("env lock");
373
374        // Ensure env var is not set
375        unsafe { std::env::remove_var(ENV_RAW_DUMP) }
376
377        let content = "debug mode secret";
378        let path = safeguard_text_dump("test_raw_debug", content, true).unwrap();
379
380        let written = fs::read_to_string(&path).unwrap();
381        assert_eq!(
382            written, content,
383            "Raw content should be written in debug mode"
384        );
385
386        // Cleanup
387        let _ = fs::remove_file(&path);
388        if let Some(parent) = path.parent() {
389            let _ = fs::remove_dir(parent);
390        }
391    }
392
393    #[test]
394    fn safeguard_text_dump_preserves_non_sensitive_content() {
395        let content = "This is normal log output without secrets";
396        let path = safeguard_text_dump_redacted("test_normal", content).unwrap();
397
398        let written = fs::read_to_string(&path).unwrap();
399        assert_eq!(
400            written, content,
401            "Non-sensitive content should be preserved"
402        );
403
404        // Cleanup
405        let _ = fs::remove_file(&path);
406        if let Some(parent) = path.parent() {
407            let _ = fs::remove_dir(parent);
408        }
409    }
410
411    #[test]
412    fn safeguard_text_dump_redacts_aws_keys() {
413        let content = "AWS Access Key: AKIAIOSFODNN7EXAMPLE";
414        let path = safeguard_text_dump_redacted("test_aws", content).unwrap();
415
416        let written = fs::read_to_string(&path).unwrap();
417        assert!(
418            !written.contains("AKIAIOSFODNN7EXAMPLE"),
419            "AWS key should be redacted"
420        );
421        assert!(
422            written.contains("[REDACTED]"),
423            "Should contain redaction marker"
424        );
425
426        // Cleanup
427        let _ = fs::remove_file(&path);
428        if let Some(parent) = path.parent() {
429            let _ = fs::remove_dir(parent);
430        }
431    }
432
433    #[test]
434    fn safeguard_text_dump_redacts_ssh_keys() {
435        let content = "SSH Key:\n-----BEGIN OPENSSH PRIVATE KEY-----\nabc123\n-----END OPENSSH PRIVATE KEY-----";
436        let path = safeguard_text_dump_redacted("test_ssh", content).unwrap();
437
438        let written = fs::read_to_string(&path).unwrap();
439        assert!(
440            !written.contains("abc123"),
441            "SSH key content should be redacted"
442        );
443        assert!(
444            written.contains("[REDACTED]"),
445            "Should contain redaction marker"
446        );
447
448        // Cleanup
449        let _ = fs::remove_file(&path);
450        if let Some(parent) = path.parent() {
451            let _ = fs::remove_dir(parent);
452        }
453    }
454
455    #[test]
456    #[cfg(unix)]
457    fn write_atomic_cleans_up_temp_file_on_persist_failure() {
458        use std::os::unix::fs::PermissionsExt;
459
460        let temp_dir = tempfile::TempDir::new().unwrap();
461        let target_dir = temp_dir.path().join("readonly");
462        fs::create_dir(&target_dir).unwrap();
463
464        // Create a file inside the directory first (so we have something to "persist to")
465        // then make the directory read-only. This prevents new file creation/replacement.
466        let existing_file = target_dir.join("existing.txt");
467        fs::write(&existing_file, "existing content").unwrap();
468
469        // Make directory read-only (removes write permission)
470        let mut perms = fs::metadata(&target_dir).unwrap().permissions();
471        perms.set_mode(0o555); // read + execute only
472        fs::set_permissions(&target_dir, perms).unwrap();
473
474        // Attempt to write to a new file in the read-only directory
475        let target_file = target_dir.join("test.txt");
476        let result = write_atomic(&target_file, b"test content");
477
478        // Should fail due to permission denied
479        assert!(
480            result.is_err(),
481            "write_atomic should fail in read-only directory"
482        );
483
484        // Should not leave temp files behind in the target directory
485        let entries: Vec<_> = fs::read_dir(&target_dir)
486            .unwrap()
487            .filter_map(|e| e.ok())
488            .filter(|e| {
489                let name = e.file_name().to_string_lossy().to_string();
490                name.starts_with(".") || name.starts_with("tmp") || name.starts_with("ralph")
491            })
492            .collect();
493        assert!(
494            entries.is_empty(),
495            "Temp files should be cleaned up, found: {:?}",
496            entries.iter().map(|e| e.file_name()).collect::<Vec<_>>()
497        );
498
499        // Restore permissions for cleanup
500        let mut perms = fs::metadata(&target_dir).unwrap().permissions();
501        perms.set_mode(0o755);
502        fs::set_permissions(&target_dir, perms).unwrap();
503    }
504
505    #[test]
506    fn create_ralph_temp_file_uses_ralph_prefix() {
507        let temp = create_ralph_temp_file("test").unwrap();
508        let name = temp.path().file_name().unwrap().to_string_lossy();
509        assert!(
510            name.starts_with("ralph_test_"),
511            "temp file should have ralph prefix, got: {}",
512            name
513        );
514        let parent = temp.path().parent().unwrap();
515        assert!(
516            parent.ends_with("ralph"),
517            "temp file should be in ralph temp directory, got: {}",
518            parent.display()
519        );
520    }
521
522    #[test]
523    fn create_ralph_temp_file_is_cleaned_on_drop() {
524        let path;
525        {
526            let temp = create_ralph_temp_file("test").unwrap();
527            path = temp.path().to_path_buf();
528            assert!(path.exists(), "temp file should exist while held");
529        }
530        // After drop, file should be removed
531        assert!(!path.exists(), "temp file should be removed on drop");
532    }
533
534    #[test]
535    fn create_ralph_temp_file_accepts_content() {
536        use std::io::Write;
537
538        let mut temp = create_ralph_temp_file("test").unwrap();
539        temp.write_all(b"test content").unwrap();
540        temp.flush().unwrap();
541
542        let content = fs::read_to_string(temp.path()).unwrap();
543        assert_eq!(content, "test content");
544    }
545}