Skip to main content

ares_tools/
fence.rs

1//! Policy core of the tenant filesystem permission fence.
2//!
3//! This module holds the decision types, the L0/L1/L2 path checks, and the L3
4//! write guards behind [`Fence`]. The full design lives in
5//! `docs/src/platform/tenant-fs-fence.md`.
6//!
7//! Layers, checked in order. A path passes only when every active layer passes:
8//! - L0 mode: `ReadOnly` denies every write.
9//! - L1 boundary: the resolved path stays inside `workspace_root`. `Full`
10//!   mode waives this layer.
11//! - L2 blocklist: a blocked name denies reads and writes in every mode.
12//! - L3 write guards: [`Fence`] records which paths a session observed
13//!   through [`Fence::fence_read`] and gates [`Fence::fence_write`] on that
14//!   record, unless the mode allows blind writes. Writes land through a
15//!   temporary file and an atomic rename.
16//!
17//! `check_path`, `check_read`, and `check_write` stay pure path checks
18//! (L0-L2). Only the [`Fence`] methods touch file contents.
19
20use std::borrow::Cow;
21use std::collections::{HashMap, VecDeque};
22use std::ffi::OsString;
23use std::io::ErrorKind;
24use std::path::{Component, Path, PathBuf};
25use std::sync::Mutex;
26use std::time::{SystemTime, UNIX_EPOCH};
27
28/// Stable `FS_*` code for a filesystem error. Callers match on the code, so
29/// agent-facing messages stay machine-readable.
30#[derive(Clone, Debug, PartialEq, Eq)]
31pub struct FsError {
32    /// One of the `FS_*` constants on this module's types.
33    pub code: &'static str,
34    pub message: String,
35}
36
37impl FsError {
38    /// The path was never observed through [`Fence::fence_read`], so the
39    /// session cannot prove it edits what it saw.
40    pub const FS_NOT_OBSERVED: &'static str = "FS_NOT_OBSERVED";
41    /// The file changed between the recorded observation and the write.
42    pub const FS_VERSION_CONFLICT: &'static str = "FS_VERSION_CONFLICT";
43    /// A guard demanded absence and the path already exists.
44    pub const FS_EXISTS: &'static str = "FS_EXISTS";
45    /// The fence layers refused the operation.
46    pub const FS_FENCE_DENIED: &'static str = "FS_FENCE_DENIED";
47    /// The underlying filesystem call failed.
48    pub const FS_IO: &'static str = "FS_IO";
49
50    pub fn new(code: &'static str, message: impl Into<String>) -> Self {
51        Self {
52            code,
53            message: message.into(),
54        }
55    }
56}
57
58impl std::fmt::Display for FsError {
59    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
60        write!(f, "{}: {}", self.code, self.message)
61    }
62}
63
64impl std::error::Error for FsError {}
65
66/// Optimistic-concurrency contract for one write.
67#[derive(Clone, Copy, Debug, PartialEq, Eq)]
68pub enum WriteGuard {
69    /// Skip read-before-edit. Only modes that allow blind writes accept it.
70    Unconditional,
71    /// Create the path; refuse when it already exists.
72    CreateIfAbsent,
73    /// Overwrite only when the file still matches the version captured at
74    /// observation time.
75    ReplaceIfVersion { version: u64 },
76}
77
78/// Cheap change fingerprint: `mtime_nanos ^ size`, saturating on clock
79/// values outside the u64 range. Two writes that both move `mtime` and
80/// `size` collide only in the same way a hash would; the guard treats any
81/// difference as a concurrent modification.
82fn version_fingerprint(metadata: &std::fs::Metadata) -> u64 {
83    let mtime = metadata
84        .modified()
85        .ok()
86        .and_then(|time| time.duration_since(UNIX_EPOCH).ok())
87        .map(|duration| duration.as_nanos() as u64)
88        .unwrap_or(0);
89    let size = metadata.len();
90    mtime ^ size
91}
92
93fn now_unix_millis() -> u64 {
94    SystemTime::now()
95        .duration_since(UNIX_EPOCH)
96        .map(|duration| duration.as_millis() as u64)
97        .unwrap_or(0)
98}
99
100/// One audit record from [`Fence::audit_log`].
101#[derive(Clone, Debug, PartialEq, Eq)]
102pub struct AuditEntry {
103    pub ts_millis: u64,
104    /// Canonical path the operation targeted.
105    pub path: PathBuf,
106    /// Guard contract requested for the write (`None` for reads).
107    pub guard_kind: Option<WriteGuard>,
108    /// Stable `FS_*` code: `FS_OK` on success.
109    pub outcome: &'static str,
110}
111
112/// Outcome code for successful fence operations in the audit ring.
113pub const FS_OK: &str = "FS_OK";
114
115/// Capacity of the bounded audit ring. Older entries drop first.
116pub const AUDIT_CAPACITY: usize = 200;
117
118const BLIND_WRITE_MODES: &[FenceMode] = &[FenceMode::Full];
119
120#[derive(Debug, Default)]
121struct FenceState {
122    /// Canonical paths observed through `fence_read`, with their versions.
123    observed: HashMap<PathBuf, u64>,
124    /// Bounded audit ring; oldest entries leave first.
125    audit: VecDeque<AuditEntry>,
126}
127
128impl FenceState {
129    fn push_audit(&mut self, entry: AuditEntry) {
130        if self.audit.len() >= AUDIT_CAPACITY {
131            self.audit.pop_front();
132        }
133        self.audit.push_back(entry);
134    }
135}
136
137/// Session-level L3 enforcement over a [`FencePolicy`].
138///
139/// The policy value stays shareable and pure; one `Fence` per session owns
140/// the mutable observed-set and the audit ring behind a mutex.
141#[derive(Debug)]
142pub struct Fence {
143    policy: FencePolicy,
144    state: Mutex<FenceState>,
145}
146
147impl Fence {
148    pub fn new(policy: FencePolicy) -> Self {
149        Self {
150            policy,
151            state: Mutex::new(FenceState::default()),
152        }
153    }
154
155    /// The immutable layer policy this fence enforces.
156    pub fn policy(&self) -> &FencePolicy {
157        &self.policy
158    }
159
160    /// Observe a path for a future guarded write. Runs L0-L2 first; a
161    /// denied observation never enters the observed-set. Existing files
162    /// record their version fingerprint; missing paths record version `0`.
163    ///
164    /// Returns `(metadata, version)`; metadata is `None` for missing paths.
165    pub fn fence_read(&self, raw: &Path) -> Result<(Option<std::fs::Metadata>, u64), FsError> {
166        let decision = check_path(&self.policy, raw, false);
167        let Some(resolved) = resolve_allowed(&self.policy, raw, &decision)? else {
168            self.record(raw, None, FsError::FS_FENCE_DENIED);
169            return Err(FsError::new(
170                FsError::FS_FENCE_DENIED,
171                decision.denied_reason().to_string(),
172            ));
173        };
174
175        let (metadata, version) = match std::fs::metadata(&resolved) {
176            Ok(metadata) => {
177                let version = version_fingerprint(&metadata);
178                (Some(metadata), version)
179            }
180            // Reads of not-yet-existing paths are legal observations: they
181            // register intent so CreateIfAbsent can later prove absence.
182            Err(error) if error.kind() == ErrorKind::NotFound => (None, 0),
183            Err(error) => return Err(FsError::new(FsError::FS_IO, error.to_string())),
184        };
185
186        let mut state = self.lock_state();
187        state.observed.insert(resolved.clone(), version);
188        state.push_audit(AuditEntry {
189            ts_millis: now_unix_millis(),
190            path: resolved,
191            guard_kind: None,
192            outcome: FS_OK,
193        });
194        Ok((metadata, version))
195    }
196
197    /// Write file contents under the L0-L2 layers plus the L3 guards.
198    ///
199    /// - Every write names a guard contract ([`WriteGuard`]).
200    /// - In modes without blind-write allowance (`ReadOnly`,
201    ///   `WorkspaceWrite`) the canonical path must have been observed
202    ///   through [`Fence::fence_read`] first, or the write fails with
203    ///   `FS_NOT_OBSERVED`. This covers every contract, including
204    ///   [`WriteGuard::Unconditional`] and creating brand-new files:
205    ///   only a mode that allows blind writes skips the ledger.
206    /// - [`WriteGuard::CreateIfAbsent`] fails with `FS_EXISTS` when the
207    ///   canonical path already exists.
208    /// - [`WriteGuard::ReplaceIfVersion`] fails with `FS_VERSION_CONFLICT`
209    ///   when the file is gone or its fingerprint differs from the version
210    ///   captured at observation time.
211    /// - Bytes land in a sibling temporary file renamed into place, so an
212    ///   interrupted write leaves no torn file behind.
213    pub fn fence_write(
214        &self,
215        raw: &Path,
216        guard: WriteGuard,
217        contents: &[u8],
218    ) -> Result<(), FsError> {
219        let blind_ok = BLIND_WRITE_MODES.contains(&self.policy.mode);
220
221        // L0-L2 first, unchanged semantics.
222        let decision = check_path(&self.policy, raw, true);
223        let Some(resolved) = resolve_allowed(&self.policy, raw, &decision)? else {
224            let reason = decision.denied_reason().to_string();
225            self.record(raw, Some(guard), FsError::FS_FENCE_DENIED);
226            return Err(FsError::new(FsError::FS_FENCE_DENIED, reason));
227        };
228
229        let current = match std::fs::metadata(&resolved) {
230            Ok(metadata) => Some(version_fingerprint(&metadata)),
231            Err(error) if error.kind() == ErrorKind::NotFound => None,
232            Err(error) => {
233                self.record(&resolved, Some(guard), FsError::FS_IO);
234                return Err(FsError::new(FsError::FS_IO, error.to_string()));
235            }
236        };
237        let exists = current.is_some();
238
239        // L3 guard #1, read-before-edit: the session must hold an
240        // observation of this exact canonical path unless the mode allows
241        // blind writes. It runs first, so a never-read path always reports
242        // FS_NOT_OBSERVED rather than a confusing contract mismatch.
243        let holds_observation = self.lock_state().observed.contains_key(&resolved);
244        if !holds_observation && !blind_ok {
245            self.record(&resolved, Some(guard), FsError::FS_NOT_OBSERVED);
246            return Err(FsError::new(
247                FsError::FS_NOT_OBSERVED,
248                format!(
249                    "no recorded read for {}; call fence_read first",
250                    resolved.display()
251                ),
252            ));
253        }
254
255        // Guard contract checks against the live filesystem view.
256        match guard {
257            WriteGuard::CreateIfAbsent if exists => {
258                self.record(&resolved, Some(guard), FsError::FS_EXISTS);
259                return Err(FsError::new(
260                    FsError::FS_EXISTS,
261                    format!("create refused, path already exists: {}", resolved.display()),
262                ));
263            }
264            WriteGuard::ReplaceIfVersion { version }
265                if !exists || current != Some(version) =>
266            {
267                self.record(&resolved, Some(guard), FsError::FS_VERSION_CONFLICT);
268                return Err(FsError::new(
269                    FsError::FS_VERSION_CONFLICT,
270                    if exists {
271                        format!(
272                            "file changed since it was read (expected {version}, found {})",
273                            current.unwrap_or_default()
274                        )
275                    } else {
276                        "file disappeared since it was read".to_string()
277                    },
278                ));
279            }
280            _ => {}
281        }
282
283        atomic_write(&resolved, contents).map_err(|error| {
284            self.record(&resolved, Some(guard), FsError::FS_IO);
285            FsError::new(FsError::FS_IO, error.to_string())
286        })?;
287
288        // A successful write becomes the new observed version, so a follow-up
289        // ReplaceIfVersion write chains off our own output instead of
290        // conflicting with it.
291        if let Ok(current) = std::fs::metadata(&resolved) {
292            let version = version_fingerprint(&current);
293            let mut state = self.lock_state();
294            state.observed.insert(resolved.clone(), version);
295        }
296
297        self.record(&resolved, Some(guard), FS_OK);
298        Ok(())
299    }
300
301    /// Snapshot of the bounded audit ring, oldest first.
302    pub fn audit_log(&self) -> Vec<AuditEntry> {
303        self.lock_state().audit.iter().cloned().collect()
304    }
305}
306
307/// Sandbox mode for one session. Sessions switch modes at runtime.
308#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
309pub enum FenceMode {
310    /// Deny every write. Reads still pass L1 and L2.
311    ReadOnly,
312    /// Default mode. Writes stay below the workspace root.
313    #[default]
314    WorkspaceWrite,
315    /// Writes go anywhere that L2 allows. L1 steps aside in this mode.
316    Full,
317}
318
319/// The fence layer that produced a denial. Callers put this name in errors.
320#[derive(Clone, Copy, Debug, PartialEq, Eq)]
321pub enum FenceLayer {
322    /// Session sandbox mode refused the operation.
323    L0Mode,
324    /// The path left the tenant workspace.
325    L1Boundary,
326    /// The path named a protected file.
327    L2Blocklist,
328    /// A write-time guard refused the operation (planned, see module docs).
329    L3WriteGuard,
330}
331
332/// Verdict for one path check.
333#[derive(Clone, Debug, PartialEq, Eq)]
334pub enum FenceDecision {
335    Allowed,
336    Denied { layer: FenceLayer, reason: String },
337}
338
339impl FenceDecision {
340    pub fn is_allowed(&self) -> bool {
341        matches!(self, FenceDecision::Allowed)
342    }
343
344    /// The human-readable cause behind a denial; empty for `Allowed`.
345    pub fn denied_reason(&self) -> &str {
346        match self {
347            FenceDecision::Allowed => "",
348            FenceDecision::Denied { reason, .. } => reason,
349        }
350    }
351
352    fn deny(layer: FenceLayer, reason: impl Into<String>) -> Self {
353        FenceDecision::Denied {
354            layer,
355            reason: reason.into(),
356        }
357    }
358}
359
360/// One tenant's fence configuration. Attach one instance per session.
361#[derive(Clone, Debug)]
362pub struct FencePolicy {
363    pub mode: FenceMode,
364    pub workspace_root: PathBuf,
365    pub blocklist: Vec<String>,
366}
367
368impl FencePolicy {
369    pub fn new(
370        mode: FenceMode,
371        workspace_root: impl Into<PathBuf>,
372        blocklist: Vec<String>,
373    ) -> Self {
374        Self {
375            mode,
376            workspace_root: workspace_root.into(),
377            blocklist,
378        }
379    }
380
381    /// Judge a read. Runs L0, L1, and L2.
382    pub fn check_read(&self, raw: &Path) -> FenceDecision {
383        check_path(self, raw, false)
384    }
385
386    /// Judge a write. Runs L0, L1, and L2 today. L3 guards join at wiring
387    /// time, once file tools carry the read-hash ledger.
388    pub fn check_write(&self, raw: &Path) -> FenceDecision {
389        check_path(self, raw, true)
390    }
391}
392
393/// Core check. Judges `raw` for a read (`write == false`) or a write.
394///
395/// Layer order is fixed: L0, then L1, then L2. The first failing layer wins,
396/// so the reported layer is deterministic for tests and for agent errors.
397pub fn check_path(policy: &FencePolicy, raw: &Path, write: bool) -> FenceDecision {
398    // L0: the session mode gate. Cheap, no filesystem access.
399    if write && policy.mode == FenceMode::ReadOnly {
400        return FenceDecision::deny(FenceLayer::L0Mode, "session is read-only");
401    }
402
403    // L1: the workspace boundary. Waived only in Full mode.
404    let mut resolved: Option<PathBuf> = None;
405    if policy.mode != FenceMode::Full {
406        match resolve_against_root(policy, raw) {
407            Ok(path) => resolved = Some(path),
408            Err(reason) => return FenceDecision::deny(FenceLayer::L1Boundary, reason),
409        }
410    }
411
412    // L2: the sensitive-name blocklist. Scans the raw spelling first, then
413    // the canonical spelling, so a symlink cannot smuggle a protected name.
414    if let Some(hit) = first_blocklisted(raw, &policy.blocklist) {
415        return FenceDecision::deny(FenceLayer::L2Blocklist, format!("blocked name: {hit}"));
416    }
417    if let Some(resolved) = &resolved {
418        if let Some(hit) = first_blocklisted(resolved, &policy.blocklist) {
419            return FenceDecision::deny(FenceLayer::L2Blocklist, format!("blocked name: {hit}"));
420        }
421    }
422
423    FenceDecision::Allowed
424}
425
426impl Fence {
427    fn record(&self, path: &Path, guard_kind: Option<WriteGuard>, outcome: &'static str) {
428        let mut state = self.lock_state();
429        state.push_audit(AuditEntry {
430            ts_millis: now_unix_millis(),
431            path: path.to_path_buf(),
432            guard_kind,
433            outcome,
434        });
435    }
436
437    fn lock_state(&self) -> std::sync::MutexGuard<'_, FenceState> {
438        // A panic while holding the fence mutex poisons it; recover to keep
439        // the audit ring and observed-set usable for later calls.
440        self.state
441            .lock()
442            .unwrap_or_else(|poisoned| poisoned.into_inner())
443    }
444}
445
446/// Write `contents` through a unique temporary file next to `target`, then
447/// rename over it. Best-effort `0600` on unix keeps tenant files private
448/// even when the process umask is permissive.
449fn atomic_write(target: &Path, contents: &[u8]) -> std::io::Result<()> {
450    let dir = target.parent().unwrap_or_else(|| Path::new("."));
451    let nanos = SystemTime::now()
452        .duration_since(UNIX_EPOCH)
453        .map(|duration| duration.as_nanos())
454        .unwrap_or(0);
455    let tmp = dir.join(format!(".ares-fence-tmp-{}-{nanos}", std::process::id()));
456    let cleanup = |tmp: &Path| {
457        let _ = std::fs::remove_file(tmp);
458    };
459
460    #[cfg(unix)]
461    {
462        use std::io::Write;
463        use std::os::unix::fs::PermissionsExt;
464
465        let file = std::fs::File::create(&tmp)?;
466        file.set_permissions(std::fs::Permissions::from_mode(0o600))?;
467        let mut writer = std::io::BufWriter::new(file);
468        writer.write_all(contents)?;
469        writer.flush()?;
470        sync_parent_best_effort(dir);
471    }
472    #[cfg(not(unix))]
473    {
474        std::fs::write(&tmp, contents)?;
475    }
476
477    match std::fs::rename(&tmp, target) {
478        Ok(()) => Ok(()),
479        Err(error) => {
480            cleanup(&tmp);
481            Err(error)
482        }
483    }
484}
485
486#[cfg(unix)]
487fn sync_parent_best_effort(dir: &Path) {
488    if let Ok(handle) = std::fs::File::open(dir) {
489        let _ = handle.sync_all();
490    }
491}
492
493#[cfg(not(unix))]
494fn sync_parent_best_effort(_dir: &Path) {}
495
496/// Canonical target path for an already-allowed decision. `None` means the
497/// decision was a denial; an `Err` is the L1 reason for a denial that only
498/// surfaces during resolution (workspace root unresolvable).
499fn resolve_allowed(
500    policy: &FencePolicy,
501    raw: &Path,
502    decision: &FenceDecision,
503) -> Result<Option<PathBuf>, FsError> {
504    if !decision.is_allowed() {
505        return Ok(None);
506    }
507    // Full mode waives L1, so the raw spelling is the write target. Every
508    // other mode resolves against the workspace root exactly like check_path
509    // did during judgment; that resolution cannot fail here without the
510    // decision having failed first.
511    if policy.mode == FenceMode::Full {
512        return Ok(Some(raw.to_path_buf()));
513    }
514    resolve_against_root(policy, raw)
515        .map(Some)
516        .map_err(|reason| FsError::new(FsError::FS_FENCE_DENIED, reason))
517}
518
519/// Anchor `raw` to the workspace root when relative, collapse `.` and `..`
520/// lexically, then canonicalize the deepest existing ancestor and rejoin the
521/// missing tail. Returns the path in canonical form.
522fn resolve_against_root(policy: &FencePolicy, raw: &Path) -> Result<PathBuf, String> {
523    let joined = if raw.is_absolute() {
524        Cow::Borrowed(raw)
525    } else {
526        Cow::Owned(policy.workspace_root.join(raw))
527    };
528
529    let mut normalized = PathBuf::new();
530    for component in joined.components() {
531        match component {
532            Component::CurDir => {}
533            Component::ParentDir => {
534                if !normalized.pop() {
535                    return Err("path climbs above the filesystem root".to_string());
536                }
537            }
538            other => normalized.push(other.as_os_str()),
539        }
540    }
541
542    let root = policy
543        .workspace_root
544        .canonicalize()
545        .map_err(|error| format!("workspace root cannot be resolved: {error}"))?;
546
547    let resolved =
548        resolve_chain(&normalized).map_err(|error| format!("path cannot be resolved: {error}"))?;
549
550    // Component-wise prefix test. Lexical neighbors such as `/root` and
551    // `/root-evil` never satisfy `starts_with`.
552    if !resolved.starts_with(&root) {
553        return Err(format!("path leaves the workspace: {}", resolved.display()));
554    }
555    Ok(resolved)
556}
557
558/// Canonicalize the deepest existing ancestor of `candidate`, then append the
559/// components that do not exist yet. Tail parts are fresh names, so they are
560/// not symlinks and need no resolution.
561fn resolve_chain(candidate: &Path) -> std::io::Result<PathBuf> {
562    let mut tail: Vec<OsString> = Vec::new();
563    let mut probe = candidate.to_path_buf();
564    loop {
565        match probe.canonicalize() {
566            Ok(real) => {
567                let mut resolved = real;
568                for name in tail.iter().rev() {
569                    resolved.push(name);
570                }
571                return Ok(resolved);
572            }
573            Err(error) if error.kind() == ErrorKind::NotFound => {
574                match (probe.file_name(), probe.parent()) {
575                    (Some(name), Some(parent)) => {
576                        tail.push(name.to_os_string());
577                        probe = parent.to_path_buf();
578                    }
579                    // No parent left and still not found: report as-is.
580                    _ => return Err(error),
581                }
582            }
583            Err(error) => return Err(error),
584        }
585    }
586}
587
588/// Return the first component that matches one blocklist pattern.
589/// Matching is case-insensitive and supports `*` and `?` wildcards.
590fn first_blocklisted(path: &Path, blocklist: &[String]) -> Option<String> {
591    for component in path.components() {
592        let Component::Normal(name) = component else {
593            continue;
594        };
595        let name = name.to_string_lossy();
596        for pattern in blocklist {
597            if glob_match(
598                pattern.to_lowercase().as_bytes(),
599                name.to_lowercase().as_bytes(),
600            ) {
601                return Some(name.into_owned());
602            }
603        }
604    }
605    None
606}
607
608/// Hand-rolled fnmatch-style matcher. Supports the `*` and `?` wildcards,
609/// which cover every pattern shape the fence documents.
610fn glob_match(pattern: &[u8], text: &[u8]) -> bool {
611    match (pattern.first(), text.first()) {
612        (None, None) => true,
613        (Some(b'*'), _) => {
614            glob_match(&pattern[1..], text) || (!text.is_empty() && glob_match(pattern, &text[1..]))
615        }
616        (Some(&p), Some(&t)) if p == b'?' || p == t => glob_match(&pattern[1..], &text[1..]),
617        _ => false,
618    }
619}
620
621#[cfg(test)]
622mod tests {
623    use super::*;
624    use std::time::{SystemTime, UNIX_EPOCH};
625
626    /// Temporary directory with manual cleanup. The crate has no dev
627    /// dependency on a temp-file helper, so this guard fills that role.
628    struct TempDir(PathBuf);
629
630    impl TempDir {
631        fn new(tag: &str) -> Self {
632            let nanos = SystemTime::now()
633                .duration_since(UNIX_EPOCH)
634                .expect("clock runs")
635                .as_nanos();
636            let dir = std::env::temp_dir()
637                .join(format!("ares-fence-{tag}-{}-{nanos}", std::process::id()));
638            std::fs::create_dir_all(&dir).expect("create temp dir");
639            TempDir(dir)
640        }
641
642        fn path(&self) -> &Path {
643            &self.0
644        }
645    }
646
647    impl Drop for TempDir {
648        fn drop(&mut self) {
649            let _ = std::fs::remove_dir_all(&self.0);
650        }
651    }
652
653    fn policy(mode: FenceMode, root: &Path) -> FencePolicy {
654        FencePolicy::new(
655            mode,
656            root,
657            vec![
658                ".env".to_string(),
659                ".env.*".to_string(),
660                "*.pem".to_string(),
661                "*.key".to_string(),
662            ],
663        )
664    }
665
666    #[test]
667    fn read_only_blocks_write_and_allows_read() {
668        let ws = TempDir::new("ro");
669        let file = ws.path().join("notes.txt");
670        std::fs::write(&file, b"hello").expect("seed file");
671
672        let fence = policy(FenceMode::ReadOnly, ws.path());
673        let write = fence.check_write(&file);
674        let read = fence.check_read(&file);
675
676        assert_eq!(
677            write,
678            FenceDecision::Denied {
679                layer: FenceLayer::L0Mode,
680                reason: write.expect_denied_reason(),
681            }
682        );
683        assert!(read.is_allowed());
684    }
685
686    #[test]
687    fn parent_traversal_escape_is_denied() {
688        let ws = TempDir::new("traversal");
689        let fence = policy(FenceMode::WorkspaceWrite, ws.path());
690
691        let escape = ws.path().join("..").join("..").join("etc-passwd.txt");
692        let decision = fence.check_write(&escape);
693
694        assert_eq!(decision.layer(), Some(FenceLayer::L1Boundary));
695    }
696
697    #[test]
698    fn missing_intermediate_directories_resolve() {
699        let ws = TempDir::new("fresh");
700        let fence = policy(FenceMode::WorkspaceWrite, ws.path());
701
702        let fresh = ws.path().join("new-dir").join("report.txt");
703        assert!(fence.check_write(&fresh).is_allowed());
704        assert!(fence.check_read(&fresh).is_allowed());
705    }
706
707    #[test]
708    #[cfg(unix)]
709    fn symlink_escape_is_denied() {
710        let ws = TempDir::new("sym-ws");
711        let outside = TempDir::new("sym-out");
712        let secret = outside.path().join("secret.txt");
713        std::fs::write(&secret, b"outside").expect("seed outside file");
714
715        let link = ws.path().join("jump");
716        std::os::unix::fs::symlink(&secret, &link).expect("create symlink");
717
718        let fence = policy(FenceMode::WorkspaceWrite, ws.path());
719        assert_eq!(
720            fence.check_read(&link).layer(),
721            Some(FenceLayer::L1Boundary)
722        );
723        assert_eq!(
724            fence.check_write(&link).layer(),
725            Some(FenceLayer::L1Boundary)
726        );
727    }
728
729    #[test]
730    fn blocklist_catches_env_at_any_depth() {
731        let ws = TempDir::new("envdepth");
732        let deep = ws.path().join("a").join("b");
733        std::fs::create_dir_all(&deep).expect("create nested dirs");
734
735        let fence = policy(FenceMode::WorkspaceWrite, ws.path());
736        assert_eq!(
737            fence.check_read(&deep.join(".env")).layer(),
738            Some(FenceLayer::L2Blocklist)
739        );
740        assert_eq!(
741            fence.check_write(&ws.path().join(".env.local")).layer(),
742            Some(FenceLayer::L2Blocklist)
743        );
744    }
745
746    #[test]
747    fn blocklist_matches_wildcards_case_insensitive() {
748        let ws = TempDir::new("pem");
749        std::fs::write(ws.path().join("Server.PEM"), b"cert").expect("seed pem");
750
751        let fence = policy(FenceMode::WorkspaceWrite, ws.path());
752        assert_eq!(
753            fence.check_read(&ws.path().join("Server.PEM")).layer(),
754            Some(FenceLayer::L2Blocklist)
755        );
756        assert_eq!(
757            fence.check_write(&ws.path().join("ca.key")).layer(),
758            Some(FenceLayer::L2Blocklist)
759        );
760    }
761
762    #[test]
763    fn full_mode_waives_the_workspace_boundary() {
764        let ws = TempDir::new("full-ws");
765        let outside = TempDir::new("full-out");
766        let fence = policy(FenceMode::Full, ws.path());
767
768        let target = outside.path().join("notes.txt");
769        assert!(fence.check_write(&target).is_allowed());
770        assert!(fence.check_read(&target).is_allowed());
771    }
772
773    #[test]
774    fn full_mode_still_enforces_the_blocklist() {
775        let ws = TempDir::new("full-l2");
776        let outside = TempDir::new("full-l2-out");
777        let fence = policy(FenceMode::Full, ws.path());
778
779        assert_eq!(
780            fence
781                .check_write(&outside.path().join("id_rsa_key.pem"))
782                .layer(),
783            Some(FenceLayer::L2Blocklist)
784        );
785        assert_eq!(
786            fence.check_read(&ws.path().join(".env")).layer(),
787            Some(FenceLayer::L2Blocklist)
788        );
789    }
790
791    fn fence(mode: FenceMode, root: &Path) -> Fence {
792        Fence::new(policy(mode, root))
793    }
794
795    /// Seed `contents` at `path` and record an observation through the fence,
796    /// returning the version fingerprint for ReplaceIfVersion guards.
797    fn observed_version(fence: &Fence, path: &Path, contents: &[u8]) -> u64 {
798        std::fs::write(path, contents).expect("seed file");
799        let (_, version) = fence.fence_read(path).expect("observe seeded file");
800        version
801    }
802
803    #[test]
804    fn write_requires_prior_read_in_l3() {
805        let ws = TempDir::new("l3-unobserved");
806        let target = ws.path().join("notes.txt");
807
808        // WorkspaceWrite demands read-before-edit for every guard contract.
809        let strict = fence(FenceMode::WorkspaceWrite, ws.path());
810        let unconditional = strict.fence_write(&target, WriteGuard::Unconditional, b"no");
811        let create = strict.fence_write(&target, WriteGuard::CreateIfAbsent, b"no");
812        let replace = strict.fence_write(
813            &target,
814            WriteGuard::ReplaceIfVersion { version: 0 },
815            b"no",
816        );
817        assert_eq!(unconditional.unwrap_err().code, FsError::FS_NOT_OBSERVED);
818        assert_eq!(create.unwrap_err().code, FsError::FS_NOT_OBSERVED);
819        assert_eq!(replace.unwrap_err().code, FsError::FS_NOT_OBSERVED);
820        assert!(!target.exists(), "refused writes must not touch disk");
821
822        // After an observation the guarded create goes through. The
823        // missing path records version 0.
824        let (_, version) = strict
825            .fence_read(&target)
826            .expect("observation of missing path");
827        assert_eq!(version, 0);
828        strict
829            .fence_write(&target, WriteGuard::CreateIfAbsent, b"created")
830            .expect("guarded create after observation");
831        assert_eq!(std::fs::read(&target).unwrap(), b"created");
832
833        // Full mode allows blind writes without any observation.
834        let blind = fence(FenceMode::Full, ws.path());
835        blind
836            .fence_write(&target, WriteGuard::Unconditional, b"blind")
837            .expect("blind write allowed in Full mode");
838        assert_eq!(std::fs::read(&target).unwrap(), b"blind");
839
840        // ReadOnly still denies at L0 before any guard runs.
841        let frozen = fence(FenceMode::ReadOnly, ws.path());
842        let denied = frozen.fence_write(&target, WriteGuard::Unconditional, b"x");
843        assert_eq!(denied.unwrap_err().code, FsError::FS_FENCE_DENIED);
844        assert_eq!(
845            frozen.policy().mode,
846            FenceMode::ReadOnly,
847            "policy stays immutable"
848        );
849    }
850
851    #[test]
852    fn replace_if_version_conflicts_on_concurrent_change() {
853        let ws = TempDir::new("l3-conflict");
854        let target = ws.path().join("notes.txt");
855        let f = fence(FenceMode::WorkspaceWrite, ws.path());
856
857        let version = observed_version(&f, &target, b"first draft");
858
859        // Same version passes and lands atomically.
860        f.fence_write(
861            &target,
862            WriteGuard::ReplaceIfVersion { version },
863            b"second draft",
864        )
865        .expect("matching version writes");
866
867        // A concurrent writer moves mtime+size after our observation.
868        std::thread::sleep(std::time::Duration::from_millis(20));
869        std::fs::write(&target, b"a concurrent writer got here first").expect("concurrent edit");
870
871        let conflict = f.fence_write(
872            &target,
873            WriteGuard::ReplaceIfVersion { version },
874            b"stale overwrite",
875        );
876        let error = conflict.expect_err("stale version must conflict");
877        assert_eq!(error.code, FsError::FS_VERSION_CONFLICT);
878        assert_eq!(
879            std::fs::read(&target).unwrap(),
880            b"a concurrent writer got here first",
881            "conflicted write must not clobber the concurrent change"
882        );
883
884        // The successful chained write refreshed the observation, so a second
885        // edit with a freshly captured version applies cleanly.
886        let (_, fresh) = f.fence_read(&target).expect("re-read");
887        f.fence_write(
888            &target,
889            WriteGuard::ReplaceIfVersion { version: fresh },
890            b"third draft",
891        )
892        .expect("fresh version writes");
893        assert_eq!(std::fs::read(&target).unwrap(), b"third draft");
894    }
895
896    #[test]
897    fn replace_if_version_refuses_disappeared_file() {
898        let ws = TempDir::new("l3-gone");
899        let target = ws.path().join("notes.txt");
900        let f = fence(FenceMode::WorkspaceWrite, ws.path());
901
902        let version = observed_version(&f, &target, b"do not lose me");
903        std::fs::remove_file(&target).expect("delete behind the fence");
904
905        let error = f
906            .fence_write(
907                &target,
908                WriteGuard::ReplaceIfVersion { version },
909                b"resurrect",
910            )
911            .expect_err("missing file must refuse replacement");
912        assert_eq!(error.code, FsError::FS_VERSION_CONFLICT);
913    }
914
915    #[test]
916    fn create_if_absent_refuses_overwrite() {
917        let ws = TempDir::new("l3-create");
918        let fresh = ws.path().join("fresh.txt");
919        let taken = ws.path().join("taken.txt");
920        let f = fence(FenceMode::WorkspaceWrite, ws.path());
921
922        f.fence_read(&fresh).expect("observe absent fresh path");
923        f.fence_write(&fresh, WriteGuard::CreateIfAbsent, b"v1")
924            .expect("create on absent path");
925        assert_eq!(std::fs::read(&fresh).unwrap(), b"v1");
926
927        // Overwrite attempt fails with FS_EXISTS even though we observed it.
928        let error = f
929            .fence_write(&fresh, WriteGuard::CreateIfAbsent, b"clobber")
930            .expect_err("second create must refuse");
931        assert_eq!(error.code, FsError::FS_EXISTS);
932        assert_eq!(std::fs::read(&fresh).unwrap(), b"v1", "content preserved");
933
934        // Unobserved creation still requires read-before-edit in this mode.
935        let error = f
936            .fence_write(&taken, WriteGuard::CreateIfAbsent, b"no")
937            .expect_err("unobserved create needs an observation");
938        assert_eq!(error.code, FsError::FS_NOT_OBSERVED);
939        assert!(!taken.exists());
940    }
941
942    #[test]
943    fn audit_ring_trims_at_capacity() {
944        let ws = TempDir::new("l3-audit");
945        let target = ws.path().join("loop.txt");
946        let f = fence(FenceMode::Full, ws.path());
947
948        for round in 0..(AUDIT_CAPACITY + 25) {
949            f.fence_write(
950                &target,
951                WriteGuard::Unconditional,
952                format!("round {round}").as_bytes(),
953            )
954            .expect("blind writes run in Full mode");
955        }
956
957        let log = f.audit_log();
958        assert_eq!(log.len(), AUDIT_CAPACITY);
959        // The oldest 25 entries left; the ring holds the last 200 writes.
960        assert!(log.iter().all(|entry| entry.outcome == FS_OK));
961        assert!(log
962            .iter()
963            .all(|entry| entry.guard_kind == Some(WriteGuard::Unconditional)));
964        assert_eq!(
965            log.first().expect("ring nonempty").ts_millis > 0,
966            true,
967            "entries carry timestamps"
968        );
969    }
970
971    #[test]
972    fn l0_l2_paths_unchanged() {
973        let ws = TempDir::new("l0l2-regression");
974
975        // L0: pure path check still denies ReadOnly writes...
976        let ro_policy = policy(FenceMode::ReadOnly, ws.path());
977        let victim = ws.path().join("victim.txt");
978        std::fs::write(&victim, b"keep").expect("seed victim");
979        assert!(!ro_policy.check_write(&victim).is_allowed());
980        assert!(ro_policy.check_read(&victim).is_allowed());
981        // ...and the Fence path refuses before any L3 logic or IO.
982        let frozen = fence(FenceMode::ReadOnly, ws.path());
983        let denied = frozen.fence_write(&victim, WriteGuard::Unconditional, b"x");
984        assert_eq!(denied.unwrap_err().code, FsError::FS_FENCE_DENIED);
985        assert_eq!(std::fs::read(&victim).unwrap(), b"keep", "L0 protects");
986
987        // L1: traversal escape still denied through the Fence.
988        let escaper = fence(FenceMode::WorkspaceWrite, ws.path());
989        let escape = ws.path().join("..").join("escape.txt");
990        let error = escaper
991            .fence_write(&escape, WriteGuard::Unconditional, b"x")
992            .expect_err("escape refused");
993        assert_eq!(error.code, FsError::FS_FENCE_DENIED);
994        assert!(!escape.exists());
995
996        // L2: blocklist still wins over everything, including Full mode.
997        let full = fence(FenceMode::Full, ws.path());
998        let pem = ws.path().join("secret.key");
999        let error = full
1000            .fence_write(&pem, WriteGuard::Unconditional, b"k")
1001            .expect_err("blocklist hit refused");
1002        assert_eq!(error.code, FsError::FS_FENCE_DENIED);
1003        assert!(full.fence_read(&ws.path().join(".env")).is_err());
1004        assert!(!pem.exists());
1005    }
1006
1007    #[test]
1008    fn atomic_write_leaves_no_temp_files_and_sets_private_mode() {
1009        let ws = TempDir::new("l3-atomic");
1010        let target = ws.path().join("private.txt");
1011        let f = fence(FenceMode::Full, ws.path());
1012
1013        f.fence_write(&target, WriteGuard::Unconditional, b"payload")
1014            .expect("write lands");
1015        let leftovers: Vec<_> = std::fs::read_dir(ws.path())
1016            .expect("list workspace")
1017            .filter_map(|entry| entry.ok())
1018            .map(|entry| entry.file_name().to_string_lossy().into_owned())
1019            .collect();
1020        assert_eq!(leftovers, vec!["private.txt".to_string()], "tmp renamed away");
1021
1022        #[cfg(unix)]
1023        {
1024            use std::os::unix::fs::PermissionsExt;
1025            let mode = std::fs::metadata(&target)
1026                .expect("metadata")
1027                .permissions()
1028                .mode();
1029            assert_eq!(mode & 0o777, 0o600, "best-effort chmod 0600 applied");
1030        }
1031    }
1032
1033    #[test]
1034    fn audit_records_deny_and_success_outcomes() {
1035        let ws = TempDir::new("l3-audit-mixed");
1036        let good = ws.path().join("good.txt");
1037        let bad = ws.path().join(".env");
1038        let f = fence(FenceMode::WorkspaceWrite, ws.path());
1039
1040        // A blocklisted read is refused before any observation.
1041        let error = f.fence_read(&bad).expect_err("blocklisted read refused");
1042        assert_eq!(error.code, FsError::FS_FENCE_DENIED);
1043
1044        // An unobserved guarded create lands in the ring as FS_NOT_OBSERVED.
1045        let error = f
1046            .fence_write(&good, WriteGuard::CreateIfAbsent, b"ok")
1047            .expect_err("create without observation refused");
1048        assert_eq!(error.code, FsError::FS_NOT_OBSERVED);
1049
1050        // Observe, then succeed.
1051        f.fence_read(&good).expect("observe absent path");
1052        f.fence_write(&good, WriteGuard::CreateIfAbsent, b"ok")
1053            .expect("guarded create after observation");
1054
1055        let log = f.audit_log();
1056        let outcomes: Vec<&str> = log.iter().map(|entry| entry.outcome).collect();
1057        assert_eq!(
1058            outcomes,
1059            vec![
1060                FsError::FS_FENCE_DENIED,
1061                FsError::FS_NOT_OBSERVED,
1062                FS_OK,
1063                FS_OK
1064            ]
1065        );
1066        // Reads carry no guard contract; writes do.
1067        assert_eq!(log[0].guard_kind, None);
1068        assert_eq!(log[1].guard_kind, Some(WriteGuard::CreateIfAbsent));
1069    }
1070
1071    impl FenceDecision {
1072        fn expect_denied_reason(&self) -> String {
1073            match self {
1074                FenceDecision::Denied { reason, .. } => reason.clone(),
1075                FenceDecision::Allowed => panic!("expected a denial"),
1076            }
1077        }
1078
1079        fn layer(&self) -> Option<FenceLayer> {
1080            match self {
1081                FenceDecision::Denied { layer, .. } => Some(*layer),
1082                FenceDecision::Allowed => None,
1083            }
1084        }
1085    }
1086}