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. These helpers do not
8//! establish an equivalent power-loss durability guarantee on Windows.
9
10use std::io;
11use std::path::Path;
12
13/// POSIX `O_NOFOLLOW` open-flag value for the target platform. Used
14/// alongside `std::os::unix::fs::OpenOptionsExt::custom_flags` to
15/// refuse to follow a symlink at the file path being opened —
16/// closes the symlink-plant confused-deputy class where a co-tenant
17/// with write access to a spool directory pre-plants a symlink at
18/// a deterministic file path so the harness's authenticated append
19/// (running as the harness UID) is redirected to a file the
20/// attacker can then read or overwrite.
21///
22/// Rather than pull in a `libc` / `rustix` dependency for one
23/// constant this returns the POSIX-defined value directly; the
24/// values are stable ABI and are drawn from the libc crate's
25/// `constant.O_NOFOLLOW` documentation. On unknown Unix-like
26/// platforms we fall back to the Linux value (a conservative
27/// choice: a mismatch would surface as an `ELOOP`/`ENOTDIR`/`EACCES`
28/// at first symlink attempt, not silent success).
29///
30/// CAUTION (round-99 find): on Linux the fcntl bits are per-ARCH,
31/// not per-OS. PowerPC swaps the asm-generic layout: 0x20000 there
32/// is O_DIRECT — passing it forced direct-I/O alignment on every
33/// spool append (EINVAL, all sessions fail-closed on ppc64le) while
34/// silently NOT refusing symlinks. Alpha/sparc differ too; neither
35/// has a supported Rust target here.
36#[cfg(unix)]
37pub const fn unix_o_nofollow() -> i32 {
38    #[cfg(all(
39        target_os = "linux",
40        not(any(target_arch = "powerpc", target_arch = "powerpc64"))
41    ))]
42    {
43        0x20000
44    }
45    #[cfg(all(target_os = "linux", any(target_arch = "powerpc", target_arch = "powerpc64")))]
46    {
47        0x8000
48    }
49    #[cfg(any(
50        target_os = "macos",
51        target_os = "ios",
52        target_os = "freebsd",
53        target_os = "openbsd",
54        target_os = "netbsd",
55        target_os = "dragonfly"
56    ))]
57    {
58        0x100
59    }
60    #[cfg(not(any(
61        target_os = "linux",
62        target_os = "macos",
63        target_os = "ios",
64        target_os = "freebsd",
65        target_os = "openbsd",
66        target_os = "netbsd",
67        target_os = "dragonfly"
68    )))]
69    {
70        0x20000
71    }
72}
73
74/// `O_NONBLOCK` for `OpenOptions::custom_flags`. Opening a FIFO
75/// read-only WITHOUT this flag blocks until a writer appears — a
76/// pre-planted FIFO (swapped in after any pathname precheck; pathname
77/// checks cannot close that race) would hang the caller forever
78/// before the post-open handle check could refuse it. With the flag,
79/// the open returns immediately and the handle check sees a
80/// non-regular file. Regular files ignore `O_NONBLOCK` entirely, so
81/// the flag is free on the happy path. Same per-arch caution as
82/// `unix_o_nofollow` above: Linux asm-generic uses 0o4000; the
83/// PowerPC layout swaps it; BSD/macOS use 0x4.
84#[cfg(unix)]
85pub const fn unix_o_nonblock() -> i32 {
86    // Unlike O_NOFOLLOW, O_NONBLOCK is 0o4000 across Linux
87    // arches (asm-generic AND the PowerPC layout).
88    #[cfg(target_os = "linux")]
89    {
90        0x800
91    }
92    #[cfg(any(
93        target_os = "macos",
94        target_os = "ios",
95        target_os = "freebsd",
96        target_os = "openbsd",
97        target_os = "netbsd",
98        target_os = "dragonfly"
99    ))]
100    {
101        0x4
102    }
103    #[cfg(not(any(
104        target_os = "linux",
105        target_os = "macos",
106        target_os = "ios",
107        target_os = "freebsd",
108        target_os = "openbsd",
109        target_os = "netbsd",
110        target_os = "dragonfly"
111    )))]
112    {
113        0x800
114    }
115}
116
117/// Return just the file name of `path` as a `&str`,
118/// suitable for `path = %basename(&path)` in tracing macros.
119///
120/// The concern is downstream OTLP export. Every `tracing::warn!(path
121/// = %path.display(), ...)` in this workspace flows through
122/// `tracing_opentelemetry::layer` when the `otel` feature is on, so
123/// absolute deployment paths (`/var/lib/agentvisor-ai/spool/...`,
124/// custom outbox layouts, quarantine locations) land in whatever SIEM
125/// ingests OTLP — the same path-leak class already closed on the
126/// dashboard and on `%error` formatting of `reqwest::Error`,
127/// with a different producer / same sink. `basename` keeps enough
128/// context for operator triage (the file name usually encodes the
129/// session id or offset) without leaking the deployment topology.
130/// Non-UTF-8 file names or paths that end in `..` fall back to `?`;
131/// callers who need the full path server-side may still route it
132/// through a separate operator-only log channel.
133pub fn basename(path: &Path) -> &str {
134    path.file_name().and_then(|name| name.to_str()).unwrap_or("?")
135}
136
137/// Fsync a directory so its rename/create entries are durable after a crash.
138///
139/// On Unix this opens the directory and calls `sync_all` on the descriptor.
140/// On Windows it is a no-op (see module docs).
141pub fn sync_directory(path: &Path) -> io::Result<()> {
142    #[cfg(unix)]
143    {
144        std::fs::File::open(path)?.sync_all()
145    }
146    #[cfg(not(unix))]
147    {
148        let _ = path;
149        Ok(())
150    }
151}
152
153/// Create `path` and any missing ancestors, then confirm every parent entry
154/// in the resolved directory hierarchy with fsync. Existing directories need
155/// the same confirmation: they can remain visible after an earlier failed
156/// synchronization or a concurrent creator that has not synchronized yet.
157/// An empty path is a no-op. Symbolic links are resolved before synchronizing
158/// so the actual target hierarchy is confirmed, including relative paths.
159pub fn create_dir_all_synced(path: &Path) -> io::Result<()> {
160    create_dir_all_synced_with_directory_sync(path, sync_directory)
161}
162
163fn create_dir_all_synced_with_directory_sync(
164    path: &Path,
165    synchronize_parent: impl FnMut(&Path) -> io::Result<()>,
166) -> io::Result<()> {
167    if path.as_os_str().is_empty() {
168        return Ok(());
169    }
170    // Skip redundant mkdir syscalls for an existing directory, but never its
171    // durability check. A regular file or dangling symlink must still reach
172    // mkdir and report the collision instead of succeeding silently.
173    if !path.is_dir() {
174        // Pin new directories to 0700, independently of the operator's umask.
175        #[cfg(unix)]
176        {
177            use std::os::unix::fs::DirBuilderExt as _;
178            let mut builder = std::fs::DirBuilder::new();
179            builder.recursive(true).mode(0o700);
180            builder.create(path)?;
181        }
182        #[cfg(not(unix))]
183        {
184            std::fs::create_dir_all(path)?;
185        }
186    }
187    sync_directory_ancestors_with_directory_sync(path, synchronize_parent)
188}
189
190/// Confirm the parent entries of an existing directory without creating it.
191/// This includes every ancestor of the resolved target, highest parent first.
192/// The directory itself is not synchronized; callers persisting its contents
193/// must also call `sync_directory(path)`. Missing or non-directory paths fail.
194pub fn sync_directory_ancestors(path: &Path) -> io::Result<()> {
195    sync_directory_ancestors_with_directory_sync(path, sync_directory)
196}
197
198fn sync_directory_ancestors_with_directory_sync(
199    path: &Path,
200    mut synchronize_parent: impl FnMut(&Path) -> io::Result<()>,
201) -> io::Result<()> {
202    // Resolving the existing target handles relative paths, `..`, and existing
203    // symlinked ancestors such as macOS /tmp -> /private/tmp. Synchronizing
204    // only the lexical parents could miss the actual newly-created entries.
205    let resolved = std::fs::canonicalize(path)?;
206    if !std::fs::metadata(&resolved)?.is_dir() {
207        return Err(io::Error::new(
208            io::ErrorKind::NotADirectory,
209            "directory path no longer names a directory",
210        ));
211    }
212    let parents: Vec<_> = resolved.ancestors().skip(1).collect();
213    // Highest parent first. Do not cache success or infer it from existence:
214    // after a partial failure, every caller must confirm the chain itself.
215    // Retain all complete directories on error so a retry can repair them.
216    for parent in parents.into_iter().rev() {
217        synchronize_parent(parent)?;
218    }
219    Ok(())
220}
221
222/// Receipts JCS-canonicalize to a few hundred bytes; even a huge
223/// tool-call summary stays well under 16 MiB. Shared between the CLI
224/// (`avctl receipt-verify`) and the harness reconciler.
225pub const MAX_RECEIPT_BYTES: u64 = 16 * 1024 * 1024;
226
227/// ATIF trajectories can carry long transcripts; 64 MiB is generous
228/// (a 200k-token GPT-4 context in ASCII fits in ~800 KiB).
229pub const MAX_ATIF_BYTES: u64 = 64 * 1024 * 1024;
230
231/// Small-file caps for control-plane files (config sidecars, journal
232/// metadata, marker files, ack files). 1 MiB is well above any real
233/// legitimate content but small enough that a hostile plant cannot
234/// materialize an OOM before the parser complains.
235pub const MAX_CONTROL_BYTES: u64 = 1024 * 1024;
236
237/// Read a file into memory subject to a hard byte cap, refusing
238/// non-regular files. The size check runs on the OPEN handle (not
239/// the path — closes the TOCTOU race where a symlink target is
240/// swapped between `metadata()` and `read()`), and the read itself
241/// uses `Read::take` so a target that grows after the metadata
242/// check still cannot exceed the cap.
243///
244/// Shared between the CLI and the harness reconciler
245/// so both audit tools and the long-running server
246/// enforce identical resource bounds against on-disk tampering.
247pub fn read_capped(path: &Path, max_bytes: u64) -> io::Result<Vec<u8>> {
248    use std::io::Read as _;
249    // Pre-open type check: `File::open` on a FIFO blocks until a writer
250    // appears, so a special-file path hung the caller forever before
251    // the post-open handle check below could refuse it (observed:
252    // `avctl receipt-verify <fifo>` hanging indefinitely). `stat` does
253    // not open the file, so it cannot block; the post-open handle check
254    // remains the TOCTOU-safe authority.
255    let pre = std::fs::metadata(path)?;
256    if !pre.is_file() {
257        return Err(io::Error::new(
258            io::ErrorKind::InvalidInput,
259            format!(
260                "{} is not a regular file (type: {:?})",
261                basename(path),
262                pre.file_type()
263            ),
264        ));
265    }
266    // O_NONBLOCK closes the residual race the pathname precheck cannot:
267    // a FIFO swapped in between `metadata` and `open` no longer blocks
268    // the open — it returns immediately and the handle check below
269    // refuses it. Regular files are unaffected by the flag.
270    // (No O_NOFOLLOW here: `avctl` legitimately reads user-supplied
271    // paths through symlinks; the handle check operates on the resolved
272    // target, which is what the cap protects.)
273    let mut open_options = std::fs::OpenOptions::new();
274    open_options.read(true);
275    #[cfg(unix)]
276    {
277        use std::os::unix::fs::OpenOptionsExt as _;
278        open_options.custom_flags(unix_o_nonblock());
279    }
280    let mut file = open_options.open(path)?;
281    let metadata = file.metadata()?;
282    if !metadata.is_file() {
283        // Error messages must not embed the full
284        // absolute path — they surface into logs (defeating the
285        // basename discipline) and into HTTP close/promote error bodies.
286        return Err(io::Error::new(
287            io::ErrorKind::InvalidInput,
288            format!(
289                "{} is not a regular file (type: {:?})",
290                basename(path),
291                metadata.file_type()
292            ),
293        ));
294    }
295    if metadata.len() > max_bytes {
296        return Err(io::Error::new(
297            io::ErrorKind::InvalidData,
298            format!(
299                "{} is {} bytes; refusing to load more than {max_bytes}",
300                basename(path),
301                metadata.len()
302            ),
303        ));
304    }
305    let mut buf: Vec<u8> = Vec::with_capacity(usize::try_from(metadata.len()).unwrap_or(0));
306    (&mut file)
307        .take(max_bytes.saturating_add(1))
308        .read_to_end(&mut buf)?;
309    if buf.len() as u64 > max_bytes {
310        return Err(io::Error::new(
311            io::ErrorKind::InvalidData,
312            format!(
313                "{} grew past {max_bytes} bytes during read; refusing",
314                basename(path)
315            ),
316        ));
317    }
318    Ok(buf)
319}
320
321/// UTF-8 variant of [`read_capped`]. Used by the CLI for
322/// operator-supplied config / manifest / bearer token files where
323/// content is textual.
324pub fn read_capped_string(path: &Path, max_bytes: u64) -> io::Result<String> {
325    let bytes = read_capped(path, max_bytes)?;
326    String::from_utf8(bytes).map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))
327}
328
329/// Durably write `bytes` to `path` via create-tmp + fsync + rename + parent fsync.
330///
331/// The temporary file uses a UUIDv7-derived suffix to avoid collision with any
332/// concurrent writer targeting the same final path, and is opened with
333/// `create_new` so a stale suffix collision fails safely instead of clobbering.
334/// Callers that already hold the parent directory pass any `Path`; missing
335/// parents are created before the temp file is opened.
336///
337/// If any step between temp creation and rename fails, the temp file is
338/// unlinked via an RAII guard so we never leak zero-byte `.tmp` files into
339/// the spool. A repeatedly-failing writer would otherwise fill the inode
340/// table on ext4/xfs long before the disk is full — an operational silent
341/// death.
342///
343/// `Ok(())` means both the file and its parent directory were successfully
344/// synchronized on platforms supporting directory fsync. An error can occur
345/// *after* rename: the complete new file may already be visible, but its
346/// durability is uncertain. Callers must fail closed and retain that file for
347/// recovery or an idempotent retry; an error does not mean the path is absent.
348/// In particular, visibility alone is insufficient for an audit marker that
349/// must survive a crash before an upstream request may be sent.
350pub fn write_atomic(path: &Path, bytes: &[u8]) -> io::Result<()> {
351    write_atomic_with_directory_sync(path, bytes, sync_directory)
352}
353
354fn write_atomic_with_directory_sync(
355    path: &Path,
356    bytes: &[u8],
357    synchronize_parent: impl FnOnce(&Path) -> io::Result<()>,
358) -> io::Result<()> {
359    use std::io::Write as _;
360    // `Path::new("file.json").parent()` is `Some("")`, not `None` — the
361    // empty path fails `sync_directory` (and is not a valid directory for
362    // `create_dir_all` on all platforms). Normalize to `.` so relative
363    // leaf paths behave like `./file.json`.
364    let parent = path
365        .parent()
366        .filter(|p| !p.as_os_str().is_empty())
367        .unwrap_or_else(|| Path::new("."));
368    // Confirm the complete parent hierarchy even on retry. Existing
369    // directories may be visible remnants of a failed ancestor fsync;
370    // synchronizing only the leaf after rename cannot repair that gap.
371    create_dir_all_synced(parent)?;
372    let temporary = path.with_extension(format!("{}.tmp", crate::new_event_uid()));
373    let mut guard = TempPathGuard::new(temporary.clone());
374    // The temp file is `create_new` (exclusive create — refuses a
375    // pre-planted symlink, per the audit-integrity contract this
376    // module documents). Pair that with an EXPLICIT 0o600 mode on
377    // Unix so the file's confidentiality does not depend on the
378    // caller's umask. ATIF trajectories carry full prompt/response
379    // transcripts; receipts carry identity, cost, and instance_uid;
380    // `.session.json` sidecars carry auth session state. On a host
381    // where umask is the default 0022, a bare `create_new` produces
382    // 0644 — world-readable by any co-tenant with `execute` on the
383    // spool directory. The 0o600 mode closes that surface uniformly
384    // for every spool site that uses this helper.
385    let mut options = std::fs::OpenOptions::new();
386    options.write(true).create_new(true);
387    #[cfg(unix)]
388    {
389        use std::os::unix::fs::OpenOptionsExt as _;
390        options.mode(0o600);
391    }
392    let mut file = options.open(&temporary)?;
393    file.write_all(bytes)?;
394    file.sync_all()?;
395    std::fs::rename(&temporary, path)?;
396    guard.disarm();
397    // Do not remove the destination on failure: rename committed visibility,
398    // and recovery needs the complete file even when durability is uncertain.
399    synchronize_parent(parent)
400}
401
402/// RAII guard that unlinks a temp path unless [`disarm`](Self::disarm) is
403/// called. Used to prevent orphan `.tmp` files when an intermediate step
404/// between `File::create` and `rename` fails.
405///
406/// Public so callers with their own atomic-rename recipes (harness
407/// `install_seed_exclusive`, per-crate tmp files) can reuse the same
408/// unlink-on-drop discipline as [`write_atomic`].
409pub struct TempPathGuard {
410    path: Option<std::path::PathBuf>,
411}
412
413impl TempPathGuard {
414    /// Arm the guard: `path` will be unlinked when the guard drops
415    /// unless [`disarm`](Self::disarm) is called first.
416    pub fn new(path: std::path::PathBuf) -> Self {
417        Self { path: Some(path) }
418    }
419
420    /// Consume the guard without unlinking (call once the temp has been
421    /// successfully renamed into its final path).
422    pub fn disarm(&mut self) {
423        self.path = None;
424    }
425}
426
427impl Drop for TempPathGuard {
428    fn drop(&mut self) {
429        if let Some(path) = self.path.take() {
430            let _ = std::fs::remove_file(&path);
431        }
432    }
433}
434
435/// Remove crash-orphaned `*.tmp` files under `root`, recursively.
436///
437/// The RAII unlink in [`write_atomic`]/[`TempPathGuard`] cannot run when
438/// the process is SIGKILLed between `create_new` and `rename`, so crash
439/// loops accumulate orphaned temp files linearly forever (empirically:
440/// 16 stranded temps across a 19-SIGKILL stress run, surviving every
441/// recovery pass) — the exact inode-exhaustion class the module doc
442/// promises to prevent. Callers MUST invoke this only at boot, before
443/// any concurrent writer exists, because a live writer's in-flight temp
444/// is indistinguishable from an orphan. UUIDv7 suffixes guarantee a
445/// name deleted here can never be re-created by a later writer.
446///
447/// Returns the number of files removed. Errors on individual entries
448/// are skipped (a temp that cannot be removed is the pre-existing
449/// condition, not a boot failure); only the root read errors surface.
450pub fn sweep_orphaned_tmp(root: &Path) -> io::Result<u64> {
451    const MAX_SWEEP_DEPTH: usize = 8;
452    fn walk(dir: &Path, depth: usize, removed: &mut u64) {
453        if depth > MAX_SWEEP_DEPTH {
454            return;
455        }
456        let Ok(entries) = std::fs::read_dir(dir) else {
457            return;
458        };
459        for entry in entries.flatten() {
460            let path = entry.path();
461            let Ok(file_type) = entry.file_type() else {
462                continue;
463            };
464            if file_type.is_dir() {
465                walk(&path, depth + 1, removed);
466            } else if file_type.is_file()
467                && path
468                    .file_name()
469                    .and_then(std::ffi::OsStr::to_str)
470                    .is_some_and(|name| name.ends_with(".tmp"))
471                && std::fs::remove_file(&path).is_ok()
472            {
473                *removed += 1;
474            }
475        }
476    }
477    match std::fs::metadata(root) {
478        Ok(_) => {}
479        Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(0),
480        Err(error) => return Err(error),
481    }
482    let mut removed = 0u64;
483    walk(root, 0, &mut removed);
484    Ok(removed)
485}
486
487#[cfg(test)]
488mod tests {
489    #![allow(clippy::unwrap_used)]
490
491    use super::*;
492
493    /// Mutation-run hardening: `read_capped_string` (the manifest /
494    /// watermark / key-file read used at every control-plane trust
495    /// boundary) had no direct test — mutants returning a fixed string
496    /// survived. Pin content round-trip and the over-cap refusal.
497    #[test]
498    fn read_capped_string_round_trips_and_refuses_over_cap() {
499        let dir = tempfile::tempdir().unwrap();
500        let path = dir.path().join("value.txt");
501        std::fs::write(&path, "expected-content").unwrap();
502        assert_eq!(
503            read_capped_string(&path, MAX_CONTROL_BYTES).unwrap(),
504            "expected-content"
505        );
506        assert!(read_capped_string(&path, 4).is_err(), "over-cap read must refuse");
507    }
508
509    /// R18: spool files created via `write_atomic` MUST be 0o600 on
510    /// Unix. ATIF trajectories carry full user transcripts, receipts
511    /// carry identity + cost, journal metadata carries session
512    /// state. Any co-tenant with `execute` on the spool directory
513    /// used to be able to `cat` these files under the default 0022
514    /// umask; the explicit mode bit closes that surface uniformly.
515    #[test]
516    #[cfg(unix)]
517    fn write_atomic_produces_owner_only_files() {
518        use std::os::unix::fs::PermissionsExt as _;
519        let dir = tempfile::tempdir().unwrap();
520        // A subdirectory that doesn't yet exist forces
521        // `create_dir_all_synced` to run too.
522        let path = dir.path().join("nested").join("payload.bin");
523        write_atomic(&path, b"secret").unwrap();
524        let file_mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
525        assert_eq!(
526            file_mode, 0o600,
527            "spool files must be owner-only; got {file_mode:o}"
528        );
529        // And the newly-created ancestor dir must be 0o700 too, so a
530        // co-tenant with a permissive umask on the harness process
531        // still can't enumerate the deterministic
532        // sha256-of-session-id filename stems.
533        let dir_mode = std::fs::metadata(path.parent().unwrap())
534            .unwrap()
535            .permissions()
536            .mode()
537            & 0o777;
538        assert_eq!(
539            dir_mode, 0o700,
540            "spool subdirectories must be owner-only; got {dir_mode:o}"
541        );
542    }
543
544    /// R18: `write_atomic` MUST refuse to follow a symlink placed
545    /// at the destination path (the symlink-plant confused-deputy
546    /// class). The exclusive-create on the temp closes the temp
547    /// side; the destination side is closed because `rename` on
548    /// Unix REPLACES the target atomically (never follows). Verify
549    /// end-to-end: a symlink at `path` gets replaced by the new
550    /// file, and the symlink target is UNCHANGED.
551    #[test]
552    #[cfg(unix)]
553    fn write_atomic_replaces_symlink_without_following_it() {
554        let dir = tempfile::tempdir().unwrap();
555        let victim = dir.path().join("victim-should-be-untouched");
556        std::fs::write(&victim, b"original-target-contents").unwrap();
557        let spool_target = dir.path().join("payload.bin");
558        std::os::unix::fs::symlink(&victim, &spool_target).unwrap();
559        write_atomic(&spool_target, b"fresh").unwrap();
560        // The symlink was replaced (not followed).
561        assert_eq!(std::fs::read(&spool_target).unwrap(), b"fresh");
562        // The symlink's original target is untouched.
563        assert_eq!(
564            std::fs::read(&victim).unwrap(),
565            b"original-target-contents",
566            "write_atomic must not follow the symlink — it replaces at rename time"
567        );
568    }
569
570    /// R18: `unix_o_nofollow()` returns the platform's actual
571    /// `O_NOFOLLOW` value — verify by using it as a flag on
572    /// `OpenOptions::custom_flags` and confirming that a symlink
573    /// open FAILS while a regular file open succeeds. A wrong-value
574    /// constant would either silently follow symlinks (bug) or
575    /// error on regular files (also bug); this test catches both.
576    #[test]
577    #[cfg(unix)]
578    fn unix_o_nofollow_flag_refuses_symlinks_but_permits_regular_files() {
579        use std::os::unix::fs::OpenOptionsExt as _;
580        let dir = tempfile::tempdir().unwrap();
581        // Regular file with the flag: opens fine.
582        let regular = dir.path().join("regular");
583        std::fs::write(&regular, b"hi").unwrap();
584        let ok = std::fs::OpenOptions::new()
585            .read(true)
586            .custom_flags(unix_o_nofollow())
587            .open(&regular);
588        assert!(ok.is_ok(), "O_NOFOLLOW must permit regular files: {ok:?}");
589        // Symlink with the flag: MUST fail (ELOOP on most Unix).
590        let victim = dir.path().join("victim");
591        std::fs::write(&victim, b"target").unwrap();
592        let link = dir.path().join("link");
593        std::os::unix::fs::symlink(&victim, &link).unwrap();
594        let err = std::fs::OpenOptions::new()
595            .read(true)
596            .custom_flags(unix_o_nofollow())
597            .open(&link);
598        assert!(
599            err.is_err(),
600            "O_NOFOLLOW must refuse symlinks (guards the confused-deputy \
601             class); constant value {:#x} may be wrong for this platform",
602            unix_o_nofollow()
603        );
604    }
605
606    /// `unix_o_nonblock` must (a) leave regular-file opens working and
607    /// (b) make a read-only FIFO open return immediately instead of
608    /// blocking for a writer — the arm `read_capped` relies on to stay
609    /// un-hangable when a FIFO is swapped in after its pathname
610    /// precheck. A wrong constant would either hang this test (caught
611    /// by the harness timeout) or break every regular read.
612    #[test]
613    #[cfg(unix)]
614    fn unix_o_nonblock_flag_opens_fifos_without_blocking() {
615        use std::os::unix::fs::OpenOptionsExt as _;
616        let dir = tempfile::tempdir().unwrap();
617        let regular = dir.path().join("regular");
618        std::fs::write(&regular, b"hi").unwrap();
619        let ok = std::fs::OpenOptions::new()
620            .read(true)
621            .custom_flags(unix_o_nonblock())
622            .open(&regular);
623        assert!(ok.is_ok(), "O_NONBLOCK must permit regular files: {ok:?}");
624        let fifo = dir.path().join("fifo");
625        // mkfifo must be runnable on unix CI.
626        let status = std::process::Command::new("mkfifo").arg(&fifo).status().unwrap();
627        assert!(status.success(), "mkfifo failed");
628        // No writer exists: without the flag this open blocks forever.
629        let opened = std::fs::OpenOptions::new()
630            .read(true)
631            .custom_flags(unix_o_nonblock())
632            .open(&fifo)
633            .unwrap();
634        let metadata = opened.metadata().unwrap();
635        assert!(
636            !metadata.is_file(),
637            "handle check must classify the FIFO as non-regular"
638        );
639    }
640
641    /// the "path.display() in tracing → OTLP → SIEM leak" class.
642    /// Assert the property callers rely on: never returns the
643    /// parent directory, always the last segment.
644    #[test]
645    fn basename_returns_only_the_last_segment() {
646        assert_eq!(
647            basename(Path::new("/var/lib/agentvisor-ai/spool/foo.json")),
648            "foo.json"
649        );
650        assert_eq!(basename(Path::new("foo.json")), "foo.json");
651        assert_eq!(basename(Path::new("/tmp/")), "tmp");
652        // Empty path / root is meaningless in the caller context —
653        // fall back to `?` rather than panic.
654        assert_eq!(basename(Path::new("/")), "?");
655        // Non-UTF-8 path names fall back to `?` (safe default; the
656        // full path could be smuggled if we tried lossy conversion).
657        #[cfg(unix)]
658        {
659            use std::ffi::OsStr;
660            use std::os::unix::ffi::OsStrExt as _;
661            let raw = std::path::PathBuf::from(OsStr::from_bytes(b"/x/\xff\xfe"));
662            assert_eq!(basename(&raw), "?");
663        }
664    }
665
666    /// Existing directories skip mkdir but still confirm durability. A file
667    /// at the target or an ancestor must remain an error.
668    #[test]
669    fn create_dir_all_synced_existing_dir_is_ok_but_file_collision_errors() {
670        let dir = tempfile::tempdir().unwrap();
671        let target = dir.path().join("a/b");
672        create_dir_all_synced(&target).unwrap();
673        // Existing directories must also complete the durability check.
674        create_dir_all_synced(&target).unwrap();
675        assert!(target.is_dir());
676
677        let file = dir.path().join("occupied");
678        std::fs::write(&file, b"x").unwrap();
679        assert!(
680            create_dir_all_synced(&file).is_err(),
681            "a file squatting on the directory path must error"
682        );
683        assert!(
684            create_dir_all_synced(&file.join("child")).is_err(),
685            "a file squatting on an ANCESTOR must error"
686        );
687    }
688
689    #[test]
690    fn ancestor_sync_never_creates_missing_directories() {
691        let dir = tempfile::tempdir().unwrap();
692        let missing = dir.path().join("missing/leaf");
693        assert_eq!(
694            sync_directory_ancestors(&missing).unwrap_err().kind(),
695            io::ErrorKind::NotFound
696        );
697        assert!(!dir.path().join("missing").exists());
698        let file = dir.path().join("occupied");
699        std::fs::write(&file, b"file").unwrap();
700        assert_eq!(
701            sync_directory_ancestors(&file).unwrap_err().kind(),
702            io::ErrorKind::NotADirectory
703        );
704    }
705
706    #[test]
707    fn directory_sync_retry_confirms_ancestors_that_already_exist() {
708        let dir = tempfile::tempdir().unwrap();
709        let boundary = std::fs::canonicalize(dir.path()).unwrap();
710        let target = dir.path().join("a/b/c");
711        for _ in 0..2 {
712            let mut visited = Vec::new();
713            let error = create_dir_all_synced_with_directory_sync(&target, |parent| {
714                visited.push(parent.to_path_buf());
715                assert!(target.is_dir(), "mkdir completes before the sync attempt");
716                if parent == boundary {
717                    return Err(io::Error::other("injected ancestor sync failure"));
718                }
719                Ok(())
720            })
721            .unwrap_err();
722            assert_eq!(error.kind(), io::ErrorKind::Other);
723            assert_eq!(visited.last(), Some(&boundary));
724            assert!(
725                target.is_dir(),
726                "failed confirmation must retain the complete tree"
727            );
728        }
729        let resolved = std::fs::canonicalize(&target).unwrap();
730        let mut expected: Vec<_> = resolved.ancestors().skip(1).map(Path::to_path_buf).collect();
731        expected.reverse();
732        let mut confirmed = Vec::new();
733        create_dir_all_synced_with_directory_sync(&target, |parent| {
734            confirmed.push(parent.to_path_buf());
735            sync_directory(parent)
736        })
737        .unwrap();
738        assert_eq!(
739            confirmed, expected,
740            "retry must confirm the complete chain in order"
741        );
742    }
743
744    #[test]
745    fn concurrent_directory_creator_cannot_bypass_ancestor_confirmation() {
746        let dir = tempfile::tempdir().unwrap();
747        let boundary = std::fs::canonicalize(dir.path()).unwrap();
748        let target = dir.path().join("a/b/c");
749        let (entered_tx, entered_rx) = std::sync::mpsc::sync_channel(0);
750        let (release_tx, release_rx) = std::sync::mpsc::sync_channel(0);
751        let first_path = target.clone();
752        let first_boundary = boundary.clone();
753        let first = std::thread::spawn(move || {
754            create_dir_all_synced_with_directory_sync(&first_path, |parent| {
755                if parent == first_boundary {
756                    entered_tx.send(()).unwrap();
757                    release_rx.recv().unwrap();
758                    return Err(io::Error::other("first creator could not sync ancestor"));
759                }
760                Ok(())
761            })
762        });
763        entered_rx
764            .recv_timeout(std::time::Duration::from_secs(30))
765            .unwrap();
766        assert!(
767            target.is_dir(),
768            "the first caller has made the whole tree visible"
769        );
770        let mut second_reached_boundary = false;
771        let second = create_dir_all_synced_with_directory_sync(&target, |parent| {
772            if parent == boundary {
773                second_reached_boundary = true;
774                return Err(io::Error::other("second creator could not sync ancestor"));
775            }
776            Ok(())
777        });
778        release_tx.send(()).unwrap();
779        assert!(first.join().unwrap().is_err());
780        assert!(
781            second_reached_boundary,
782            "an existing directory is not proof of durability"
783        );
784        assert!(
785            second.is_err(),
786            "each caller must fail closed on its own sync error"
787        );
788        create_dir_all_synced(&target).unwrap();
789    }
790
791    #[test]
792    fn directory_sync_resolves_relative_paths_without_changing_working_directory() {
793        let dir = tempfile::Builder::new()
794            .prefix(".av-relative-directory-test-")
795            .tempdir_in(std::env::current_dir().unwrap())
796            .unwrap();
797        let relative = Path::new(dir.path().file_name().unwrap()).join("nested/./leaf");
798        let mut confirmed = Vec::new();
799        create_dir_all_synced_with_directory_sync(&relative, |parent| {
800            assert!(parent.is_absolute());
801            confirmed.push(parent.to_path_buf());
802            Ok(())
803        })
804        .unwrap();
805        let resolved = std::fs::canonicalize(dir.path().join("nested/leaf")).unwrap();
806        let mut expected: Vec<_> = resolved.ancestors().skip(1).map(Path::to_path_buf).collect();
807        expected.reverse();
808        assert_eq!(confirmed, expected);
809        assert!(dir.path().join("nested/leaf").is_dir());
810    }
811
812    #[cfg(unix)]
813    #[test]
814    fn directory_sync_confirms_symlink_target_ancestry_and_rejects_invalid_targets() {
815        use std::os::unix::fs::symlink;
816        let dir = tempfile::tempdir().unwrap();
817        let real = dir.path().join("real/parent");
818        std::fs::create_dir_all(&real).unwrap();
819        let alias = dir.path().join("alias");
820        symlink(&real, &alias).unwrap();
821        let mut confirmed = Vec::new();
822        create_dir_all_synced_with_directory_sync(&alias.join("child"), |parent| {
823            confirmed.push(parent.to_path_buf());
824            Ok(())
825        })
826        .unwrap();
827        let resolved = std::fs::canonicalize(real.join("child")).unwrap();
828        let mut expected: Vec<_> = resolved.ancestors().skip(1).map(Path::to_path_buf).collect();
829        expected.reverse();
830        assert_eq!(
831            confirmed, expected,
832            "sync the resolved target, not just alias parents"
833        );
834        let occupied = dir.path().join("occupied");
835        std::fs::write(&occupied, b"file").unwrap();
836        let file_alias = dir.path().join("file-alias");
837        symlink(&occupied, &file_alias).unwrap();
838        assert!(create_dir_all_synced(&file_alias).is_err());
839        let broken = dir.path().join("broken");
840        symlink(dir.path().join("absent"), &broken).unwrap();
841        assert!(create_dir_all_synced(&broken).is_err());
842    }
843
844    #[test]
845    fn write_atomic_creates_parent_and_writes_bytes() {
846        let dir = tempfile::tempdir().unwrap();
847        let target = dir.path().join("nested/dir/output.bin");
848        write_atomic(&target, b"hello").unwrap();
849        assert_eq!(std::fs::read(&target).unwrap(), b"hello");
850    }
851
852    #[test]
853    fn write_atomic_replaces_existing_file() {
854        let dir = tempfile::tempdir().unwrap();
855        let target = dir.path().join("output.bin");
856        std::fs::write(&target, b"old").unwrap();
857        write_atomic(&target, b"new").unwrap();
858        assert_eq!(std::fs::read(&target).unwrap(), b"new");
859    }
860
861    #[test]
862    fn write_atomic_reports_post_rename_sync_failure_and_retains_recoverable_file() {
863        let dir = tempfile::tempdir().unwrap();
864        let target = dir.path().join("audit-marker.json");
865        std::fs::write(&target, b"old").unwrap();
866        let error = write_atomic_with_directory_sync(&target, b"complete new marker", |parent| {
867            assert_eq!(parent, dir.path());
868            assert_eq!(std::fs::read(&target).unwrap(), b"complete new marker");
869            Err(io::Error::other("injected directory synchronization failure"))
870        })
871        .unwrap_err();
872        assert_eq!(error.kind(), io::ErrorKind::Other);
873        assert_eq!(std::fs::read(&target).unwrap(), b"complete new marker");
874        assert_eq!(std::fs::read_dir(dir.path()).unwrap().count(), 1);
875        // A retry makes the same artifact durable without creating duplicates.
876        write_atomic(&target, b"complete new marker").unwrap();
877        assert_eq!(std::fs::read(&target).unwrap(), b"complete new marker");
878        assert_eq!(std::fs::read_dir(dir.path()).unwrap().count(), 1);
879    }
880
881    #[test]
882    fn write_atomic_leaves_no_temp_files_on_success() {
883        let dir = tempfile::tempdir().unwrap();
884        let target = dir.path().join("output.bin");
885        for _ in 0..8 {
886            write_atomic(&target, b"payload").unwrap();
887        }
888        let residual: Vec<_> = std::fs::read_dir(dir.path())
889            .unwrap()
890            .filter_map(Result::ok)
891            .filter(|entry| entry.file_name().to_string_lossy().contains(".tmp"))
892            .collect();
893        assert!(residual.is_empty(), "stale temp files: {residual:?}");
894    }
895
896    /// The RAII guard must unlink the tmp file when a step after
897    /// creation panics (simulating `write_all`/`sync_all`/`rename`
898    /// failure). Without the guard, a spool directory can accumulate
899    /// millions of zero-byte `.tmp` files after a bad disk day and
900    /// blow through the ext4 inode table long before disk-full
901    /// triggers any alert.
902    #[test]
903    fn temp_path_guard_unlinks_when_dropped_armed() {
904        let dir = tempfile::tempdir().unwrap();
905        let tmp = dir.path().join("aborted.tmp");
906        std::fs::write(&tmp, b"partial").unwrap();
907        {
908            let _guard = TempPathGuard::new(tmp.clone());
909            // simulate an early return: guard drops without disarm
910        }
911        assert!(!tmp.exists(), "guard failed to unlink tmp on drop");
912    }
913
914    #[test]
915    fn temp_path_guard_leaves_file_alone_when_disarmed() {
916        let dir = tempfile::tempdir().unwrap();
917        let tmp = dir.path().join("kept.tmp");
918        std::fs::write(&tmp, b"kept").unwrap();
919        {
920            let mut guard = TempPathGuard::new(tmp.clone());
921            guard.disarm();
922        }
923        assert!(tmp.exists(), "disarmed guard must not touch the file");
924    }
925}
926
927#[cfg(test)]
928mod read_capped_tests {
929    #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
930
931    use super::*;
932
933    /// Mutation-run hardening: `read_capped -> Ok(vec![])`
934    /// survived — nothing asserted the reader actually returns the file
935    /// bytes or enforces its cap at the exact boundary.
936    #[test]
937    fn read_capped_roundtrips_and_enforces_the_exact_cap() {
938        let dir = tempfile::tempdir().unwrap();
939        let path = dir.path().join("payload.bin");
940        let content = b"agentvisor-read-capped-fixture".to_vec();
941        std::fs::write(&path, &content).unwrap();
942        // Content comes back verbatim…
943        assert_eq!(read_capped(&path, MAX_CONTROL_BYTES).unwrap(), content);
944        // …a cap of exactly the length succeeds…
945        assert_eq!(read_capped(&path, content.len() as u64).unwrap(), content);
946        // …and one byte under the length is refused, not truncated.
947        let under = read_capped(&path, content.len() as u64 - 1);
948        assert!(under.is_err(), "under-cap read must refuse, got {under:?}");
949        // Non-regular files are refused (directory).
950        assert!(read_capped(dir.path(), MAX_CONTROL_BYTES).is_err());
951    }
952
953    /// The workspace-wide byte caps are load-bearing resource bounds;
954    /// pin their values so arithmetic mutants can't silently shrink or
955    /// inflate them.
956    #[test]
957    fn byte_caps_are_pinned() {
958        assert_eq!(MAX_RECEIPT_BYTES, 16 * 1024 * 1024);
959        assert_eq!(MAX_ATIF_BYTES, 64 * 1024 * 1024);
960        assert_eq!(MAX_CONTROL_BYTES, 1024 * 1024);
961    }
962
963    #[test]
964    fn sweep_orphaned_tmp_removes_only_tmp_files_recursively() {
965        let dir = tempfile::tempdir().unwrap();
966        let nested = dir.path().join("receipts").join("deep");
967        std::fs::create_dir_all(&nested).unwrap();
968        std::fs::write(dir.path().join("a.json.0198.tmp"), b"orphan").unwrap();
969        std::fs::write(nested.join("b.receipt.0199.tmp"), b"").unwrap();
970        std::fs::write(dir.path().join("keep.json"), b"real").unwrap();
971        std::fs::write(nested.join("keep.ndjson"), b"real").unwrap();
972        let removed = sweep_orphaned_tmp(dir.path()).unwrap();
973        assert_eq!(removed, 2, "exactly the two .tmp orphans");
974        assert!(dir.path().join("keep.json").exists());
975        assert!(nested.join("keep.ndjson").exists());
976        assert!(!dir.path().join("a.json.0198.tmp").exists());
977        assert!(!nested.join("b.receipt.0199.tmp").exists());
978        // Missing root is a clean no-op (fresh install).
979        assert_eq!(sweep_orphaned_tmp(&dir.path().join("absent")).unwrap(), 0);
980    }
981
982    /// Mutation-run hardening (round 8): the MAX_SWEEP_DEPTH guard had
983    /// surviving `>` boundary and `depth + 1` arithmetic mutants — no
984    /// test placed orphans at the depth edge. The contract: an orphan
985    /// 8 directories down is still swept; 9 down is left alone (the
986    /// bound exists so a symlink-cycle-shaped tree cannot walk
987    /// forever).
988    #[test]
989    fn sweep_depth_bound_is_exact() {
990        let dir = tempfile::tempdir().unwrap();
991        let mut at_bound = dir.path().to_path_buf();
992        for i in 0..8 {
993            at_bound.push(format!("d{i}"));
994        }
995        std::fs::create_dir_all(&at_bound).unwrap();
996        std::fs::write(at_bound.join("edge.0200.tmp"), b"").unwrap();
997        let mut past_bound = at_bound.clone();
998        past_bound.push("d8");
999        std::fs::create_dir_all(&past_bound).unwrap();
1000        std::fs::write(past_bound.join("deep.0201.tmp"), b"").unwrap();
1001
1002        let removed = sweep_orphaned_tmp(dir.path()).unwrap();
1003        assert_eq!(removed, 1, "exactly the at-bound orphan");
1004        assert!(
1005            !at_bound.join("edge.0200.tmp").exists(),
1006            "an orphan at depth 8 (the bound) must be swept"
1007        );
1008        assert!(
1009            past_bound.join("deep.0201.tmp").exists(),
1010            "an orphan at depth 9 (past the bound) must be left alone"
1011        );
1012    }
1013
1014    /// Mutation-run hardening (round 8): only NotFound may downgrade to
1015    /// the Ok(0) no-op — a mutant widening the guard to every metadata
1016    /// error silently swallowed permission faults, hiding a broken
1017    /// spool from the boot sequence that relies on this sweep.
1018    #[cfg(unix)]
1019    #[test]
1020    fn sweep_surfaces_non_notfound_root_errors() {
1021        use std::os::unix::fs::PermissionsExt as _;
1022        let dir = tempfile::tempdir().unwrap();
1023        let root = dir.path().join("locked");
1024        std::fs::create_dir(&root).unwrap();
1025        std::fs::set_permissions(&root, std::fs::Permissions::from_mode(0o000)).unwrap();
1026        let denied = std::fs::metadata(root.join("probe")).is_err() && std::fs::read_dir(&root).is_err();
1027        if denied {
1028            // metadata(root) itself succeeds (the parent is traversable);
1029            // the sweep must not mask the unreadable directory as Ok —
1030            // walk() skips unreadable dirs by design, so assert only the
1031            // NotFound arm stays narrow: a root whose metadata errors
1032            // with EACCES must surface. Build that shape: a child of the
1033            // locked dir is unstattable.
1034            let unreachable_root = root.join("inner");
1035            assert!(
1036                sweep_orphaned_tmp(&unreachable_root).is_err(),
1037                "an EACCES root must surface as an error, not a silent Ok(0)"
1038            );
1039        }
1040        std::fs::set_permissions(&root, std::fs::Permissions::from_mode(0o755)).unwrap();
1041    }
1042}
1043
1044#[cfg(test)]
1045mod empty_path_tests {
1046    #![allow(clippy::unwrap_used)]
1047
1048    /// Preserve the empty-path no-op without attempting canonicalization or fsync.
1049    #[test]
1050    fn create_dir_all_synced_treats_empty_path_as_noop() {
1051        let mut called = false;
1052        super::create_dir_all_synced_with_directory_sync(std::path::Path::new(""), |_| {
1053            called = true;
1054            Ok(())
1055        })
1056        .unwrap();
1057        assert!(!called);
1058        assert!(
1059            super::create_dir_all_synced(std::path::Path::new("")).is_ok(),
1060            "empty path must be a no-op Ok, not an mkdir(\"\") error"
1061        );
1062    }
1063}