Skip to main content

reliary_core/
fs_safe.rs

1//! File I/O helpers that are correct by default.
2//!
3//! These wrappers prevent the bug classes that keep recurring in audits:
4//! - Non-atomic writes (data corruption on crash)
5//! - Unbounded reads (OOM on huge files)
6//! - Stdin reads (OOM on huge input)
7//! - SQLite opens without PRAGMAs (slow, not crash-safe)
8//!
9//! Every file I/O call in the binary should go through these helpers
10//! unless there's a documented reason not to. The pre-commit and CI
11//! guardrails (scripts/ci_guards.py) detect direct `std::fs::write`,
12//! `read_to_string`, and `read_to_string(&mut stdin buf)` calls so
13//! these helpers stay the easy default.
14
15use std::fs;
16use std::io::{Read, Write};
17use std::path::Path;
18use std::process;
19use tracing::warn;
20
21/// Maximum file size we read into memory (10 MB). Files larger than this
22/// are rejected with an error rather than read.
23pub const MAX_FILE_SIZE: u64 = 10_000_000;
24
25/// Atomic file write: write to temp file, fsync, then rename. Prevents
26/// partial-write corruption on crash or power loss.
27/// D31: Single handle — write, sync, rename in one file open.
28pub fn atomic_write(path: &str, content: &str) -> Result<(), String> {
29    let tmp = format!("{}.tmp.{}", path, process::id());
30    // Write + sync using a single handle (avoids double-open).
31    let result = (|| -> Result<(), String> {
32        let mut f = fs::File::create(&tmp).map_err(|e| format!("atomic_write: create {}: {}", tmp, e))?;
33        f.write_all(content.as_bytes()).map_err(|e| format!("atomic_write: write {}: {}", tmp, e))?;
34        // D34: Propagate sync error instead of swallowing.
35        f.sync_all().map_err(|e| format!("atomic_write: sync {}: {}", tmp, e))?;
36        drop(f);
37        fs::rename(&tmp, path).map_err(|e| format!("atomic_write: rename {} -> {}: {}", tmp, path, e))?;
38        Ok(())
39    })();
40    if result.is_err() {
41        let _ = fs::remove_file(&tmp);
42    }
43    result
44}
45
46/// Read a file with size cap. Returns an error string if the file is
47/// missing, unreadable, or larger than `MAX_FILE_SIZE`.
48pub fn safe_read(path: &str) -> Result<String, String> {
49    let p = Path::new(path);
50    match p.metadata() {
51        Ok(meta) => {
52            if meta.len() > MAX_FILE_SIZE {
53                return Err(format!(
54                    "safe_read: {} is {} bytes, exceeds max {} bytes",
55                    path, meta.len(), MAX_FILE_SIZE
56                ));
57            }
58        }
59        Err(e) => {
60            if e.kind() == std::io::ErrorKind::NotFound {
61                return Err(format!("safe_read: {} does not exist", path));
62            }
63            return Err(format!("safe_read: {}: {}", path, e));
64        }
65    }
66    fs::read_to_string(path).map_err(|e| format!("safe_read: {}: {}", path, e))
67}
68
69/// Read from stdin with a size cap. Reads up to `MAX_FILE_SIZE` bytes then
70/// errors. Prevents OOM on `cat huge.log | reliary-agent ...`.
71pub fn safe_read_stdin() -> Result<String, String> {
72    let mut buf = Vec::with_capacity(4096);
73    let mut handle = std::io::stdin().take(MAX_FILE_SIZE + 1);
74    if let Err(e) = handle.read_to_end(&mut buf) {
75        return Err(format!("safe_read_stdin: read failed: {}", e));
76    }
77    if buf.len() as u64 > MAX_FILE_SIZE {
78        return Err(format!(
79            "safe_read_stdin: input exceeds {} bytes",
80            MAX_FILE_SIZE
81        ));
82    }
83    String::from_utf8(buf).map_err(|e| format!("safe_read_stdin: invalid UTF-8: {}", e))
84}
85
86/// Open a SQLite database with WAL + NORMAL sync for crash recovery.
87/// D33: Merged safe_open_db and safe_open_db_query into one function.
88pub fn safe_open_db(path: &str) -> Result<rusqlite::Connection, String> {
89    let db = rusqlite::Connection::open(path)
90        .map_err(|e| format!("safe_open_db: open {}: {}", path, e))?;
91    if let Err(e) = db.execute_batch(
92        "PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL;",
93    ) {
94        warn!("safe_open_db: PRAGMA set failed: {}", e);
95    }
96    Ok(db)
97}
98
99#[cfg(test)]
100mod tests {
101    use super::*;
102
103    #[test]
104    #[allow(unused_imports)]
105    fn test_atomic_write_creates_file() {
106        let dir = std::env::temp_dir().join("reliary_fs_safe_atomic");
107        let _ = fs::create_dir_all(&dir);
108        let path = dir.join("test.txt");
109        atomic_write(path.to_str().unwrap(), "hello").unwrap();
110        let content = fs::read_to_string(&path).unwrap();
111        assert_eq!(content, "hello");
112        let _ = fs::remove_dir_all(&dir);
113    }
114
115    #[test]
116    fn test_atomic_write_cleans_tmp_on_rename_failure() {
117        // Try to write to a path whose parent doesn't exist
118        let bad_path = "/nonexistent/directory/test.txt";
119        let result = atomic_write(bad_path, "content");
120        assert!(result.is_err(), "should fail when parent dir doesn't exist");
121        // Verify no tmp file leaked (we can't easily verify but should at least not crash)
122    }
123
124    #[test]
125    fn test_safe_read_size_cap() {
126        let dir = std::env::temp_dir().join("reliary_fs_safe_read");
127        let _ = fs::create_dir_all(&dir);
128        let path = dir.join("small.txt");
129        fs::write(&path, "small content").unwrap();
130        let result = safe_read(path.to_str().unwrap());
131        assert!(result.is_ok());
132        assert_eq!(result.unwrap(), "small content");
133        let _ = fs::remove_dir_all(&dir);
134    }
135
136    #[test]
137    fn test_safe_read_missing_file() {
138        let result = safe_read("/nonexistent/path/file.txt");
139        assert!(result.is_err());
140    }
141}