1use 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#[derive(Clone, Debug, PartialEq, Eq)]
31pub struct FsError {
32 pub code: &'static str,
34 pub message: String,
35}
36
37impl FsError {
38 pub const FS_NOT_OBSERVED: &'static str = "FS_NOT_OBSERVED";
41 pub const FS_VERSION_CONFLICT: &'static str = "FS_VERSION_CONFLICT";
43 pub const FS_EXISTS: &'static str = "FS_EXISTS";
45 pub const FS_FENCE_DENIED: &'static str = "FS_FENCE_DENIED";
47 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#[derive(Clone, Copy, Debug, PartialEq, Eq)]
68pub enum WriteGuard {
69 Unconditional,
71 CreateIfAbsent,
73 ReplaceIfVersion { version: u64 },
76}
77
78fn 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#[derive(Clone, Debug, PartialEq, Eq)]
102pub struct AuditEntry {
103 pub ts_millis: u64,
104 pub path: PathBuf,
106 pub guard_kind: Option<WriteGuard>,
108 pub outcome: &'static str,
110}
111
112pub const FS_OK: &str = "FS_OK";
114
115pub const AUDIT_CAPACITY: usize = 200;
117
118const BLIND_WRITE_MODES: &[FenceMode] = &[FenceMode::Full];
119
120#[derive(Debug, Default)]
121struct FenceState {
122 observed: HashMap<PathBuf, u64>,
124 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#[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 pub fn policy(&self) -> &FencePolicy {
157 &self.policy
158 }
159
160 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 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 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 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 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 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 if let Ok(current) = std::fs::metadata(&resolved) {
292 let version = version_fingerprint(¤t);
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 pub fn audit_log(&self) -> Vec<AuditEntry> {
303 self.lock_state().audit.iter().cloned().collect()
304 }
305}
306
307#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
309pub enum FenceMode {
310 ReadOnly,
312 #[default]
314 WorkspaceWrite,
315 Full,
317}
318
319#[derive(Clone, Copy, Debug, PartialEq, Eq)]
321pub enum FenceLayer {
322 L0Mode,
324 L1Boundary,
326 L2Blocklist,
328 L3WriteGuard,
330}
331
332#[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 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#[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 pub fn check_read(&self, raw: &Path) -> FenceDecision {
383 check_path(self, raw, false)
384 }
385
386 pub fn check_write(&self, raw: &Path) -> FenceDecision {
389 check_path(self, raw, true)
390 }
391}
392
393pub fn check_path(policy: &FencePolicy, raw: &Path, write: bool) -> FenceDecision {
398 if write && policy.mode == FenceMode::ReadOnly {
400 return FenceDecision::deny(FenceLayer::L0Mode, "session is read-only");
401 }
402
403 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 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 self.state
441 .lock()
442 .unwrap_or_else(|poisoned| poisoned.into_inner())
443 }
444}
445
446fn 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
496fn 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 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
519fn 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 if !resolved.starts_with(&root) {
553 return Err(format!("path leaves the workspace: {}", resolved.display()));
554 }
555 Ok(resolved)
556}
557
558fn 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 _ => return Err(error),
581 }
582 }
583 Err(error) => return Err(error),
584 }
585 }
586}
587
588fn 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
608fn 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 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 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 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 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 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 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 f.fence_write(
861 &target,
862 WriteGuard::ReplaceIfVersion { version },
863 b"second draft",
864 )
865 .expect("matching version writes");
866
867 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 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 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 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 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 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 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 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 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 let error = f.fence_read(&bad).expect_err("blocklisted read refused");
1042 assert_eq!(error.code, FsError::FS_FENCE_DENIED);
1043
1044 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 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 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}