Skip to main content

av_core/
fsutil.rs

1//! Cross-platform filesystem helpers.
2//!
3//! `sync_directory` durably persists directory entries on platforms where the
4//! operating system supports it (Unix `fsync` on a directory file descriptor).
5//! On Windows there is no supported way to flush a directory handle
6//! (`FlushFileBuffers` requires a file, and opening a directory for `File::open`
7//! yields `PermissionDenied`), so this is a no-op there — NTFS journals
8//! metadata changes so a rename is durable once the containing file has been
9//! `sync_all`'d.
10
11use std::io;
12use std::path::Path;
13
14/// Round-36 F1: return just the file name of `path` as a `&str`,
15/// suitable for `path = %basename(&path)` in tracing macros.
16///
17/// The concern is downstream OTLP export. Every `tracing::warn!(path
18/// = %path.display(), ...)` in this workspace flows through
19/// `tracing_opentelemetry::layer` when the `otel` feature is on, so
20/// absolute deployment paths (`/var/lib/agentvisor-ai/spool/...`,
21/// custom outbox layouts, quarantine locations) land in whatever SIEM
22/// ingests OTLP — same class of leak round-27 F6 closed on the
23/// dashboard and round-35 F1/F2 closed on `%error` on `reqwest::Error`,
24/// with a different producer / same sink. `basename` keeps enough
25/// context for operator triage (the file name usually encodes the
26/// session id or offset) without leaking the deployment topology.
27/// Non-UTF-8 file names or paths that end in `..` fall back to `?`;
28/// callers who need the full path server-side may still route it
29/// through a separate operator-only log channel.
30pub fn basename(path: &Path) -> &str {
31    path.file_name().and_then(|name| name.to_str()).unwrap_or("?")
32}
33
34/// Fsync a directory so its rename/create entries are durable after a crash.
35///
36/// On Unix this opens the directory and calls `sync_all` on the descriptor.
37/// On Windows it is a no-op (see module docs).
38pub fn sync_directory(path: &Path) -> io::Result<()> {
39    #[cfg(unix)]
40    {
41        std::fs::File::open(path)?.sync_all()
42    }
43    #[cfg(not(unix))]
44    {
45        let _ = path;
46        Ok(())
47    }
48}
49
50/// Receipts JCS-canonicalize to a few hundred bytes; even a huge
51/// tool-call summary stays well under 16 MiB. Shared between the CLI
52/// (`avctl receipt-verify`) and the harness reconciler (round-17 F3).
53pub const MAX_RECEIPT_BYTES: u64 = 16 * 1024 * 1024;
54
55/// ATIF trajectories can carry long transcripts; 64 MiB is generous
56/// (a 200k-token GPT-4 context in ASCII fits in ~800 KiB).
57pub const MAX_ATIF_BYTES: u64 = 64 * 1024 * 1024;
58
59/// Small-file caps for control-plane files (config sidecars, journal
60/// metadata, marker files, ack files). 1 MiB is well above any real
61/// legitimate content but small enough that a hostile plant cannot
62/// materialize an OOM before the parser complains.
63pub const MAX_CONTROL_BYTES: u64 = 1024 * 1024;
64
65/// Read a file into memory subject to a hard byte cap, refusing
66/// non-regular files. The size check runs on the OPEN handle (not
67/// the path — closes the TOCTOU race where a symlink target is
68/// swapped between `metadata()` and `read()`), and the read itself
69/// uses `Read::take` so a target that grows after the metadata
70/// check still cannot exceed the cap.
71///
72/// Shared between the CLI (round-16 F5) and the harness reconciler
73/// (round-17 F3) so both audit tools and the long-running server
74/// enforce identical resource bounds against on-disk tampering.
75pub fn read_capped(path: &Path, max_bytes: u64) -> io::Result<Vec<u8>> {
76    use std::io::Read as _;
77    let mut file = std::fs::File::open(path)?;
78    let metadata = file.metadata()?;
79    if !metadata.is_file() {
80        return Err(io::Error::new(
81            io::ErrorKind::InvalidInput,
82            format!(
83                "{} is not a regular file (type: {:?})",
84                path.display(),
85                metadata.file_type()
86            ),
87        ));
88    }
89    if metadata.len() > max_bytes {
90        return Err(io::Error::new(
91            io::ErrorKind::InvalidData,
92            format!(
93                "{} is {} bytes; refusing to load more than {max_bytes}",
94                path.display(),
95                metadata.len()
96            ),
97        ));
98    }
99    let mut buf: Vec<u8> = Vec::with_capacity(usize::try_from(metadata.len()).unwrap_or(0));
100    (&mut file)
101        .take(max_bytes.saturating_add(1))
102        .read_to_end(&mut buf)?;
103    if buf.len() as u64 > max_bytes {
104        return Err(io::Error::new(
105            io::ErrorKind::InvalidData,
106            format!(
107                "{} grew past {max_bytes} bytes during read; refusing",
108                path.display()
109            ),
110        ));
111    }
112    Ok(buf)
113}
114
115/// UTF-8 variant of [`read_capped`]. Used by the CLI for
116/// operator-supplied config / manifest / bearer token files where
117/// content is textual (round-17 F6).
118pub fn read_capped_string(path: &Path, max_bytes: u64) -> io::Result<String> {
119    let bytes = read_capped(path, max_bytes)?;
120    String::from_utf8(bytes).map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))
121}
122
123/// Durably write `bytes` to `path` via create-tmp + fsync + rename + parent fsync.
124///
125/// The temporary file uses a UUIDv7-derived suffix to avoid collision with any
126/// concurrent writer targeting the same final path, and is opened with
127/// `create_new` so a stale suffix collision fails safely instead of clobbering.
128/// Callers that already hold the parent directory pass any `Path`; missing
129/// parents are created before the temp file is opened.
130///
131/// If any step between temp creation and rename fails, the temp file is
132/// unlinked via an RAII guard so we never leak zero-byte `.tmp` files into
133/// the spool. A repeatedly-failing writer would otherwise fill the inode
134/// table on ext4/xfs long before the disk is full — an operational silent
135/// death.
136///
137/// **Semantics of `Ok(())` vs `Err(...)` after `rename`:** once the tmp file
138/// has been atomically renamed onto `path`, the caller can consider the
139/// data durably visible. A post-rename `sync_directory` failure means the
140/// dirent may not survive an *immediate* power loss on POSIX-conformant
141/// filesystems (xfs, btrfs, ext4 with `data=ordered`), but the file is
142/// present and readable for every observer running now. Historically this
143/// function still returned `Err` in that case (round-12 F5), which
144/// misled callers whose retry logic assumes "Err → not present": they
145/// would either double-write (harmless but wasted IO) or, worse, treat
146/// the write as failed and skip session-state advancement while the
147/// file was in fact readable — producing a hard split between on-disk
148/// state and in-registry accounting.
149///
150/// Fix: post-rename `sync_directory` failure now becomes a
151/// `tracing::warn!` (best-effort) and `Ok(())` is returned. Callers
152/// that need a stronger guarantee should call `sync_directory` again
153/// after their own operation completes. A dedicated counter is not
154/// registered here because `fsutil` cannot depend on `av-core`'s
155/// metrics registry without a cycle; harness-level callers can wrap
156/// this with their own counter if needed.
157pub fn write_atomic(path: &Path, bytes: &[u8]) -> io::Result<()> {
158    use std::io::Write as _;
159    let parent = path.parent().unwrap_or_else(|| Path::new("."));
160    std::fs::create_dir_all(parent)?;
161    let temporary = path.with_extension(format!("{}.tmp", crate::new_event_uid()));
162    let mut guard = TempPathGuard::new(temporary.clone());
163    let mut file = std::fs::OpenOptions::new()
164        .write(true)
165        .create_new(true)
166        .open(&temporary)?;
167    file.write_all(bytes)?;
168    file.sync_all()?;
169    std::fs::rename(&temporary, path)?;
170    guard.disarm();
171    if let Err(error) = sync_directory(parent) {
172        // Best-effort — the rename already succeeded so `path` is
173        // observable. Log the failure so operators can investigate
174        // filesystem or disk issues; do NOT return Err, which would
175        // wrongly steer callers into "the file is not there" retry
176        // logic.
177        tracing::warn!(
178            path = %basename(path),
179            error = %error,
180            "post-rename directory fsync failed; file is visible but its dirent may not survive an immediate power loss"
181        );
182    }
183    Ok(())
184}
185
186/// RAII guard that unlinks a temp path unless [`disarm`](Self::disarm) is
187/// called. Used to prevent orphan `.tmp` files when an intermediate step
188/// between `File::create` and `rename` fails.
189///
190/// Public so callers with their own atomic-rename recipes (harness
191/// `install_seed_exclusive`, per-crate tmp files) can reuse the same
192/// unlink-on-drop discipline as [`write_atomic`].
193pub struct TempPathGuard {
194    path: Option<std::path::PathBuf>,
195}
196
197impl TempPathGuard {
198    /// Arm the guard: `path` will be unlinked when the guard drops
199    /// unless [`disarm`](Self::disarm) is called first.
200    pub fn new(path: std::path::PathBuf) -> Self {
201        Self { path: Some(path) }
202    }
203
204    /// Consume the guard without unlinking (call once the temp has been
205    /// successfully renamed into its final path).
206    pub fn disarm(&mut self) {
207        self.path = None;
208    }
209}
210
211impl Drop for TempPathGuard {
212    fn drop(&mut self) {
213        if let Some(path) = self.path.take() {
214            let _ = std::fs::remove_file(&path);
215        }
216    }
217}
218
219#[cfg(test)]
220mod tests {
221    #![allow(clippy::unwrap_used)]
222
223    use super::*;
224
225    /// Round-36 F1: `basename` is a canonical name for the fix to
226    /// the "path.display() in tracing → OTLP → SIEM leak" class.
227    /// Assert the property callers rely on: never returns the
228    /// parent directory, always the last segment.
229    #[test]
230    fn basename_returns_only_the_last_segment() {
231        assert_eq!(
232            basename(Path::new("/var/lib/agentvisor-ai/spool/foo.json")),
233            "foo.json"
234        );
235        assert_eq!(basename(Path::new("foo.json")), "foo.json");
236        assert_eq!(basename(Path::new("/tmp/")), "tmp");
237        // Empty path / root is meaningless in the caller context —
238        // fall back to `?` rather than panic.
239        assert_eq!(basename(Path::new("/")), "?");
240        // Non-UTF-8 path names fall back to `?` (safe default; the
241        // full path could be smuggled if we tried lossy conversion).
242        #[cfg(unix)]
243        {
244            use std::ffi::OsStr;
245            use std::os::unix::ffi::OsStrExt as _;
246            let raw = std::path::PathBuf::from(OsStr::from_bytes(b"/x/\xff\xfe"));
247            assert_eq!(basename(&raw), "?");
248        }
249    }
250
251    #[test]
252    fn write_atomic_creates_parent_and_writes_bytes() {
253        let dir = tempfile::tempdir().unwrap();
254        let target = dir.path().join("nested/dir/output.bin");
255        write_atomic(&target, b"hello").unwrap();
256        assert_eq!(std::fs::read(&target).unwrap(), b"hello");
257    }
258
259    #[test]
260    fn write_atomic_replaces_existing_file() {
261        let dir = tempfile::tempdir().unwrap();
262        let target = dir.path().join("output.bin");
263        std::fs::write(&target, b"old").unwrap();
264        write_atomic(&target, b"new").unwrap();
265        assert_eq!(std::fs::read(&target).unwrap(), b"new");
266    }
267
268    #[test]
269    fn write_atomic_leaves_no_temp_files_on_success() {
270        let dir = tempfile::tempdir().unwrap();
271        let target = dir.path().join("output.bin");
272        for _ in 0..8 {
273            write_atomic(&target, b"payload").unwrap();
274        }
275        let residual: Vec<_> = std::fs::read_dir(dir.path())
276            .unwrap()
277            .filter_map(Result::ok)
278            .filter(|entry| entry.file_name().to_string_lossy().contains(".tmp"))
279            .collect();
280        assert!(residual.is_empty(), "stale temp files: {residual:?}");
281    }
282
283    /// The RAII guard must unlink the tmp file when a step after
284    /// creation panics (simulating `write_all`/`sync_all`/`rename`
285    /// failure). Without the guard, a spool directory can accumulate
286    /// millions of zero-byte `.tmp` files after a bad disk day and
287    /// blow through the ext4 inode table long before disk-full
288    /// triggers any alert.
289    #[test]
290    fn temp_path_guard_unlinks_when_dropped_armed() {
291        let dir = tempfile::tempdir().unwrap();
292        let tmp = dir.path().join("aborted.tmp");
293        std::fs::write(&tmp, b"partial").unwrap();
294        {
295            let _guard = TempPathGuard::new(tmp.clone());
296            // simulate an early return: guard drops without disarm
297        }
298        assert!(!tmp.exists(), "guard failed to unlink tmp on drop");
299    }
300
301    #[test]
302    fn temp_path_guard_leaves_file_alone_when_disarmed() {
303        let dir = tempfile::tempdir().unwrap();
304        let tmp = dir.path().join("kept.tmp");
305        std::fs::write(&tmp, b"kept").unwrap();
306        {
307            let mut guard = TempPathGuard::new(tmp.clone());
308            guard.disarm();
309        }
310        assert!(tmp.exists(), "disarmed guard must not touch the file");
311    }
312}
313
314#[cfg(test)]
315mod read_capped_tests {
316    #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
317
318    use super::*;
319
320    /// Mutation-run hardening (round 12): `read_capped -> Ok(vec![])`
321    /// survived — nothing asserted the reader actually returns the file
322    /// bytes or enforces its cap at the exact boundary.
323    #[test]
324    fn read_capped_roundtrips_and_enforces_the_exact_cap() {
325        let dir = tempfile::tempdir().unwrap();
326        let path = dir.path().join("payload.bin");
327        let content = b"agentvisor-read-capped-fixture".to_vec();
328        std::fs::write(&path, &content).unwrap();
329        // Content comes back verbatim…
330        assert_eq!(read_capped(&path, MAX_CONTROL_BYTES).unwrap(), content);
331        // …a cap of exactly the length succeeds…
332        assert_eq!(read_capped(&path, content.len() as u64).unwrap(), content);
333        // …and one byte under the length is refused, not truncated.
334        let under = read_capped(&path, content.len() as u64 - 1);
335        assert!(under.is_err(), "under-cap read must refuse, got {under:?}");
336        // Non-regular files are refused (directory).
337        assert!(read_capped(dir.path(), MAX_CONTROL_BYTES).is_err());
338    }
339
340    /// The workspace-wide byte caps are load-bearing resource bounds;
341    /// pin their values so arithmetic mutants can't silently shrink or
342    /// inflate them.
343    #[test]
344    fn byte_caps_are_pinned() {
345        assert_eq!(MAX_RECEIPT_BYTES, 16 * 1024 * 1024);
346        assert_eq!(MAX_ATIF_BYTES, 64 * 1024 * 1024);
347        assert_eq!(MAX_CONTROL_BYTES, 1024 * 1024);
348    }
349}