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            // V80: reject non-regular files. A FIFO/character device reports
53            // len() == 0 (so the size cap passed) and `read_to_string` then
54            // blocks forever — `reliary risk /dev/zero` or a named pipe hung
55            // the process. Only regular files are readable here.
56            if !meta.is_file() {
57                return Err(format!("safe_read: {} is not a regular file", path));
58            }
59            if meta.len() > MAX_FILE_SIZE {
60                return Err(format!(
61                    "safe_read: {} is {} bytes, exceeds max {} bytes",
62                    path, meta.len(), MAX_FILE_SIZE
63                ));
64            }
65        }
66        Err(e) => {
67            if e.kind() == std::io::ErrorKind::NotFound {
68                return Err(format!("safe_read: {} does not exist", path));
69            }
70            return Err(format!("safe_read: {}: {}", path, e));
71        }
72    }
73    fs::read_to_string(path).map_err(|e| format!("safe_read: {}: {}", path, e))
74}
75
76/// Read from stdin with a size cap. Reads up to `MAX_FILE_SIZE` bytes then
77/// errors. Prevents OOM on `cat huge.log | reliary-agent ...`.
78pub fn safe_read_stdin() -> Result<String, String> {
79    let mut buf = Vec::with_capacity(4096);
80    let mut handle = std::io::stdin().take(MAX_FILE_SIZE + 1);
81    if let Err(e) = handle.read_to_end(&mut buf) {
82        return Err(format!("safe_read_stdin: read failed: {}", e));
83    }
84    if buf.len() as u64 > MAX_FILE_SIZE {
85        return Err(format!(
86            "safe_read_stdin: input exceeds {} bytes",
87            MAX_FILE_SIZE
88        ));
89    }
90    String::from_utf8(buf).map_err(|e| format!("safe_read_stdin: invalid UTF-8: {}", e))
91}
92
93/// Open a SQLite database with WAL + NORMAL sync for crash recovery.
94/// D33: Merged safe_open_db and safe_open_db_query into one function.
95pub fn safe_open_db(path: &str) -> Result<rusqlite::Connection, String> {
96    let db = rusqlite::Connection::open(path)
97        .map_err(|e| format!("safe_open_db: open {}: {}", path, e))?;
98    if let Err(e) = db.execute_batch(
99        "PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL;",
100    ) {
101        warn!("safe_open_db: PRAGMA set failed: {}", e);
102    }
103    Ok(db)
104}
105
106#[cfg(test)]
107mod tests {
108    use super::*;
109
110    #[test]
111    #[allow(unused_imports)]
112    fn test_atomic_write_creates_file() {
113        let dir = std::env::temp_dir().join("reliary_fs_safe_atomic");
114        let _ = fs::create_dir_all(&dir);
115        let path = dir.join("test.txt");
116        atomic_write(path.to_str().unwrap(), "hello").unwrap();
117        let content = fs::read_to_string(&path).unwrap();
118        assert_eq!(content, "hello");
119        let _ = fs::remove_dir_all(&dir);
120    }
121
122    #[test]
123    fn test_atomic_write_cleans_tmp_on_rename_failure() {
124        // Try to write to a path whose parent doesn't exist
125        let bad_path = "/nonexistent/directory/test.txt";
126        let result = atomic_write(bad_path, "content");
127        assert!(result.is_err(), "should fail when parent dir doesn't exist");
128        // Verify no tmp file leaked (we can't easily verify but should at least not crash)
129    }
130
131    #[test]
132    fn test_safe_read_size_cap() {
133        let dir = std::env::temp_dir().join("reliary_fs_safe_read");
134        let _ = fs::create_dir_all(&dir);
135        let path = dir.join("small.txt");
136        fs::write(&path, "small content").unwrap();
137        let result = safe_read(path.to_str().unwrap());
138        assert!(result.is_ok());
139        assert_eq!(result.unwrap(), "small content");
140        let _ = fs::remove_dir_all(&dir);
141    }
142
143    #[test]
144    fn test_safe_read_missing_file() {
145        let result = safe_read("/nonexistent/path/file.txt");
146        assert!(result.is_err());
147    }
148}