use std::borrow::Cow;
use std::collections::{HashMap, VecDeque};
use std::ffi::OsString;
use std::io::ErrorKind;
use std::path::{Component, Path, PathBuf};
use std::sync::Mutex;
use std::time::{SystemTime, UNIX_EPOCH};
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct FsError {
pub code: &'static str,
pub message: String,
}
impl FsError {
pub const FS_NOT_OBSERVED: &'static str = "FS_NOT_OBSERVED";
pub const FS_VERSION_CONFLICT: &'static str = "FS_VERSION_CONFLICT";
pub const FS_EXISTS: &'static str = "FS_EXISTS";
pub const FS_FENCE_DENIED: &'static str = "FS_FENCE_DENIED";
pub const FS_IO: &'static str = "FS_IO";
pub fn new(code: &'static str, message: impl Into<String>) -> Self {
Self {
code,
message: message.into(),
}
}
}
impl std::fmt::Display for FsError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}: {}", self.code, self.message)
}
}
impl std::error::Error for FsError {}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum WriteGuard {
Unconditional,
CreateIfAbsent,
ReplaceIfVersion { version: u64 },
}
fn version_fingerprint(metadata: &std::fs::Metadata) -> u64 {
let mtime = metadata
.modified()
.ok()
.and_then(|time| time.duration_since(UNIX_EPOCH).ok())
.map(|duration| duration.as_nanos() as u64)
.unwrap_or(0);
let size = metadata.len();
mtime ^ size
}
fn now_unix_millis() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|duration| duration.as_millis() as u64)
.unwrap_or(0)
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct AuditEntry {
pub ts_millis: u64,
pub path: PathBuf,
pub guard_kind: Option<WriteGuard>,
pub outcome: &'static str,
}
pub const FS_OK: &str = "FS_OK";
pub const AUDIT_CAPACITY: usize = 200;
const BLIND_WRITE_MODES: &[FenceMode] = &[FenceMode::Full];
#[derive(Debug, Default)]
struct FenceState {
observed: HashMap<PathBuf, u64>,
audit: VecDeque<AuditEntry>,
}
impl FenceState {
fn push_audit(&mut self, entry: AuditEntry) {
if self.audit.len() >= AUDIT_CAPACITY {
self.audit.pop_front();
}
self.audit.push_back(entry);
}
}
#[derive(Debug)]
pub struct Fence {
policy: FencePolicy,
state: Mutex<FenceState>,
}
impl Fence {
pub fn new(policy: FencePolicy) -> Self {
Self {
policy,
state: Mutex::new(FenceState::default()),
}
}
pub fn policy(&self) -> &FencePolicy {
&self.policy
}
pub fn fence_read(&self, raw: &Path) -> Result<(Option<std::fs::Metadata>, u64), FsError> {
let decision = check_path(&self.policy, raw, false);
let Some(resolved) = resolve_allowed(&self.policy, raw, &decision)? else {
self.record(raw, None, FsError::FS_FENCE_DENIED);
return Err(FsError::new(
FsError::FS_FENCE_DENIED,
decision.denied_reason().to_string(),
));
};
let (metadata, version) = match std::fs::metadata(&resolved) {
Ok(metadata) => {
let version = version_fingerprint(&metadata);
(Some(metadata), version)
}
Err(error) if error.kind() == ErrorKind::NotFound => (None, 0),
Err(error) => return Err(FsError::new(FsError::FS_IO, error.to_string())),
};
let mut state = self.lock_state();
state.observed.insert(resolved.clone(), version);
state.push_audit(AuditEntry {
ts_millis: now_unix_millis(),
path: resolved,
guard_kind: None,
outcome: FS_OK,
});
Ok((metadata, version))
}
pub fn fence_write(
&self,
raw: &Path,
guard: WriteGuard,
contents: &[u8],
) -> Result<(), FsError> {
let blind_ok = BLIND_WRITE_MODES.contains(&self.policy.mode);
let decision = check_path(&self.policy, raw, true);
let Some(resolved) = resolve_allowed(&self.policy, raw, &decision)? else {
let reason = decision.denied_reason().to_string();
self.record(raw, Some(guard), FsError::FS_FENCE_DENIED);
return Err(FsError::new(FsError::FS_FENCE_DENIED, reason));
};
let current = match std::fs::metadata(&resolved) {
Ok(metadata) => Some(version_fingerprint(&metadata)),
Err(error) if error.kind() == ErrorKind::NotFound => None,
Err(error) => {
self.record(&resolved, Some(guard), FsError::FS_IO);
return Err(FsError::new(FsError::FS_IO, error.to_string()));
}
};
let exists = current.is_some();
let holds_observation = self.lock_state().observed.contains_key(&resolved);
if !holds_observation && !blind_ok {
self.record(&resolved, Some(guard), FsError::FS_NOT_OBSERVED);
return Err(FsError::new(
FsError::FS_NOT_OBSERVED,
format!(
"no recorded read for {}; call fence_read first",
resolved.display()
),
));
}
match guard {
WriteGuard::CreateIfAbsent if exists => {
self.record(&resolved, Some(guard), FsError::FS_EXISTS);
return Err(FsError::new(
FsError::FS_EXISTS,
format!("create refused, path already exists: {}", resolved.display()),
));
}
WriteGuard::ReplaceIfVersion { version }
if !exists || current != Some(version) =>
{
self.record(&resolved, Some(guard), FsError::FS_VERSION_CONFLICT);
return Err(FsError::new(
FsError::FS_VERSION_CONFLICT,
if exists {
format!(
"file changed since it was read (expected {version}, found {})",
current.unwrap_or_default()
)
} else {
"file disappeared since it was read".to_string()
},
));
}
_ => {}
}
atomic_write(&resolved, contents).map_err(|error| {
self.record(&resolved, Some(guard), FsError::FS_IO);
FsError::new(FsError::FS_IO, error.to_string())
})?;
if let Ok(current) = std::fs::metadata(&resolved) {
let version = version_fingerprint(¤t);
let mut state = self.lock_state();
state.observed.insert(resolved.clone(), version);
}
self.record(&resolved, Some(guard), FS_OK);
Ok(())
}
pub fn audit_log(&self) -> Vec<AuditEntry> {
self.lock_state().audit.iter().cloned().collect()
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum FenceMode {
ReadOnly,
#[default]
WorkspaceWrite,
Full,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum FenceLayer {
L0Mode,
L1Boundary,
L2Blocklist,
L3WriteGuard,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum FenceDecision {
Allowed,
Denied { layer: FenceLayer, reason: String },
}
impl FenceDecision {
pub fn is_allowed(&self) -> bool {
matches!(self, FenceDecision::Allowed)
}
pub fn denied_reason(&self) -> &str {
match self {
FenceDecision::Allowed => "",
FenceDecision::Denied { reason, .. } => reason,
}
}
fn deny(layer: FenceLayer, reason: impl Into<String>) -> Self {
FenceDecision::Denied {
layer,
reason: reason.into(),
}
}
}
#[derive(Clone, Debug)]
pub struct FencePolicy {
pub mode: FenceMode,
pub workspace_root: PathBuf,
pub blocklist: Vec<String>,
}
impl FencePolicy {
pub fn new(
mode: FenceMode,
workspace_root: impl Into<PathBuf>,
blocklist: Vec<String>,
) -> Self {
Self {
mode,
workspace_root: workspace_root.into(),
blocklist,
}
}
pub fn check_read(&self, raw: &Path) -> FenceDecision {
check_path(self, raw, false)
}
pub fn check_write(&self, raw: &Path) -> FenceDecision {
check_path(self, raw, true)
}
}
pub fn check_path(policy: &FencePolicy, raw: &Path, write: bool) -> FenceDecision {
if write && policy.mode == FenceMode::ReadOnly {
return FenceDecision::deny(FenceLayer::L0Mode, "session is read-only");
}
let mut resolved: Option<PathBuf> = None;
if policy.mode != FenceMode::Full {
match resolve_against_root(policy, raw) {
Ok(path) => resolved = Some(path),
Err(reason) => return FenceDecision::deny(FenceLayer::L1Boundary, reason),
}
}
if let Some(hit) = first_blocklisted(raw, &policy.blocklist) {
return FenceDecision::deny(FenceLayer::L2Blocklist, format!("blocked name: {hit}"));
}
if let Some(resolved) = &resolved {
if let Some(hit) = first_blocklisted(resolved, &policy.blocklist) {
return FenceDecision::deny(FenceLayer::L2Blocklist, format!("blocked name: {hit}"));
}
}
FenceDecision::Allowed
}
impl Fence {
fn record(&self, path: &Path, guard_kind: Option<WriteGuard>, outcome: &'static str) {
let mut state = self.lock_state();
state.push_audit(AuditEntry {
ts_millis: now_unix_millis(),
path: path.to_path_buf(),
guard_kind,
outcome,
});
}
fn lock_state(&self) -> std::sync::MutexGuard<'_, FenceState> {
self.state
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
}
}
fn atomic_write(target: &Path, contents: &[u8]) -> std::io::Result<()> {
let dir = target.parent().unwrap_or_else(|| Path::new("."));
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|duration| duration.as_nanos())
.unwrap_or(0);
let tmp = dir.join(format!(".ares-fence-tmp-{}-{nanos}", std::process::id()));
let cleanup = |tmp: &Path| {
let _ = std::fs::remove_file(tmp);
};
#[cfg(unix)]
{
use std::io::Write;
use std::os::unix::fs::PermissionsExt;
let file = std::fs::File::create(&tmp)?;
file.set_permissions(std::fs::Permissions::from_mode(0o600))?;
let mut writer = std::io::BufWriter::new(file);
writer.write_all(contents)?;
writer.flush()?;
sync_parent_best_effort(dir);
}
#[cfg(not(unix))]
{
std::fs::write(&tmp, contents)?;
}
match std::fs::rename(&tmp, target) {
Ok(()) => Ok(()),
Err(error) => {
cleanup(&tmp);
Err(error)
}
}
}
#[cfg(unix)]
fn sync_parent_best_effort(dir: &Path) {
if let Ok(handle) = std::fs::File::open(dir) {
let _ = handle.sync_all();
}
}
#[cfg(not(unix))]
fn sync_parent_best_effort(_dir: &Path) {}
fn resolve_allowed(
policy: &FencePolicy,
raw: &Path,
decision: &FenceDecision,
) -> Result<Option<PathBuf>, FsError> {
if !decision.is_allowed() {
return Ok(None);
}
if policy.mode == FenceMode::Full {
return Ok(Some(raw.to_path_buf()));
}
resolve_against_root(policy, raw)
.map(Some)
.map_err(|reason| FsError::new(FsError::FS_FENCE_DENIED, reason))
}
fn resolve_against_root(policy: &FencePolicy, raw: &Path) -> Result<PathBuf, String> {
let joined = if raw.is_absolute() {
Cow::Borrowed(raw)
} else {
Cow::Owned(policy.workspace_root.join(raw))
};
let mut normalized = PathBuf::new();
for component in joined.components() {
match component {
Component::CurDir => {}
Component::ParentDir => {
if !normalized.pop() {
return Err("path climbs above the filesystem root".to_string());
}
}
other => normalized.push(other.as_os_str()),
}
}
let root = policy
.workspace_root
.canonicalize()
.map_err(|error| format!("workspace root cannot be resolved: {error}"))?;
let resolved =
resolve_chain(&normalized).map_err(|error| format!("path cannot be resolved: {error}"))?;
if !resolved.starts_with(&root) {
return Err(format!("path leaves the workspace: {}", resolved.display()));
}
Ok(resolved)
}
fn resolve_chain(candidate: &Path) -> std::io::Result<PathBuf> {
let mut tail: Vec<OsString> = Vec::new();
let mut probe = candidate.to_path_buf();
loop {
match probe.canonicalize() {
Ok(real) => {
let mut resolved = real;
for name in tail.iter().rev() {
resolved.push(name);
}
return Ok(resolved);
}
Err(error) if error.kind() == ErrorKind::NotFound => {
match (probe.file_name(), probe.parent()) {
(Some(name), Some(parent)) => {
tail.push(name.to_os_string());
probe = parent.to_path_buf();
}
_ => return Err(error),
}
}
Err(error) => return Err(error),
}
}
}
fn first_blocklisted(path: &Path, blocklist: &[String]) -> Option<String> {
for component in path.components() {
let Component::Normal(name) = component else {
continue;
};
let name = name.to_string_lossy();
for pattern in blocklist {
if glob_match(
pattern.to_lowercase().as_bytes(),
name.to_lowercase().as_bytes(),
) {
return Some(name.into_owned());
}
}
}
None
}
fn glob_match(pattern: &[u8], text: &[u8]) -> bool {
match (pattern.first(), text.first()) {
(None, None) => true,
(Some(b'*'), _) => {
glob_match(&pattern[1..], text) || (!text.is_empty() && glob_match(pattern, &text[1..]))
}
(Some(&p), Some(&t)) if p == b'?' || p == t => glob_match(&pattern[1..], &text[1..]),
_ => false,
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::time::{SystemTime, UNIX_EPOCH};
struct TempDir(PathBuf);
impl TempDir {
fn new(tag: &str) -> Self {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("clock runs")
.as_nanos();
let dir = std::env::temp_dir()
.join(format!("ares-fence-{tag}-{}-{nanos}", std::process::id()));
std::fs::create_dir_all(&dir).expect("create temp dir");
TempDir(dir)
}
fn path(&self) -> &Path {
&self.0
}
}
impl Drop for TempDir {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
fn policy(mode: FenceMode, root: &Path) -> FencePolicy {
FencePolicy::new(
mode,
root,
vec![
".env".to_string(),
".env.*".to_string(),
"*.pem".to_string(),
"*.key".to_string(),
],
)
}
#[test]
fn read_only_blocks_write_and_allows_read() {
let ws = TempDir::new("ro");
let file = ws.path().join("notes.txt");
std::fs::write(&file, b"hello").expect("seed file");
let fence = policy(FenceMode::ReadOnly, ws.path());
let write = fence.check_write(&file);
let read = fence.check_read(&file);
assert_eq!(
write,
FenceDecision::Denied {
layer: FenceLayer::L0Mode,
reason: write.expect_denied_reason(),
}
);
assert!(read.is_allowed());
}
#[test]
fn parent_traversal_escape_is_denied() {
let ws = TempDir::new("traversal");
let fence = policy(FenceMode::WorkspaceWrite, ws.path());
let escape = ws.path().join("..").join("..").join("etc-passwd.txt");
let decision = fence.check_write(&escape);
assert_eq!(decision.layer(), Some(FenceLayer::L1Boundary));
}
#[test]
fn missing_intermediate_directories_resolve() {
let ws = TempDir::new("fresh");
let fence = policy(FenceMode::WorkspaceWrite, ws.path());
let fresh = ws.path().join("new-dir").join("report.txt");
assert!(fence.check_write(&fresh).is_allowed());
assert!(fence.check_read(&fresh).is_allowed());
}
#[test]
#[cfg(unix)]
fn symlink_escape_is_denied() {
let ws = TempDir::new("sym-ws");
let outside = TempDir::new("sym-out");
let secret = outside.path().join("secret.txt");
std::fs::write(&secret, b"outside").expect("seed outside file");
let link = ws.path().join("jump");
std::os::unix::fs::symlink(&secret, &link).expect("create symlink");
let fence = policy(FenceMode::WorkspaceWrite, ws.path());
assert_eq!(
fence.check_read(&link).layer(),
Some(FenceLayer::L1Boundary)
);
assert_eq!(
fence.check_write(&link).layer(),
Some(FenceLayer::L1Boundary)
);
}
#[test]
fn blocklist_catches_env_at_any_depth() {
let ws = TempDir::new("envdepth");
let deep = ws.path().join("a").join("b");
std::fs::create_dir_all(&deep).expect("create nested dirs");
let fence = policy(FenceMode::WorkspaceWrite, ws.path());
assert_eq!(
fence.check_read(&deep.join(".env")).layer(),
Some(FenceLayer::L2Blocklist)
);
assert_eq!(
fence.check_write(&ws.path().join(".env.local")).layer(),
Some(FenceLayer::L2Blocklist)
);
}
#[test]
fn blocklist_matches_wildcards_case_insensitive() {
let ws = TempDir::new("pem");
std::fs::write(ws.path().join("Server.PEM"), b"cert").expect("seed pem");
let fence = policy(FenceMode::WorkspaceWrite, ws.path());
assert_eq!(
fence.check_read(&ws.path().join("Server.PEM")).layer(),
Some(FenceLayer::L2Blocklist)
);
assert_eq!(
fence.check_write(&ws.path().join("ca.key")).layer(),
Some(FenceLayer::L2Blocklist)
);
}
#[test]
fn full_mode_waives_the_workspace_boundary() {
let ws = TempDir::new("full-ws");
let outside = TempDir::new("full-out");
let fence = policy(FenceMode::Full, ws.path());
let target = outside.path().join("notes.txt");
assert!(fence.check_write(&target).is_allowed());
assert!(fence.check_read(&target).is_allowed());
}
#[test]
fn full_mode_still_enforces_the_blocklist() {
let ws = TempDir::new("full-l2");
let outside = TempDir::new("full-l2-out");
let fence = policy(FenceMode::Full, ws.path());
assert_eq!(
fence
.check_write(&outside.path().join("id_rsa_key.pem"))
.layer(),
Some(FenceLayer::L2Blocklist)
);
assert_eq!(
fence.check_read(&ws.path().join(".env")).layer(),
Some(FenceLayer::L2Blocklist)
);
}
fn fence(mode: FenceMode, root: &Path) -> Fence {
Fence::new(policy(mode, root))
}
fn observed_version(fence: &Fence, path: &Path, contents: &[u8]) -> u64 {
std::fs::write(path, contents).expect("seed file");
let (_, version) = fence.fence_read(path).expect("observe seeded file");
version
}
#[test]
fn write_requires_prior_read_in_l3() {
let ws = TempDir::new("l3-unobserved");
let target = ws.path().join("notes.txt");
let strict = fence(FenceMode::WorkspaceWrite, ws.path());
let unconditional = strict.fence_write(&target, WriteGuard::Unconditional, b"no");
let create = strict.fence_write(&target, WriteGuard::CreateIfAbsent, b"no");
let replace = strict.fence_write(
&target,
WriteGuard::ReplaceIfVersion { version: 0 },
b"no",
);
assert_eq!(unconditional.unwrap_err().code, FsError::FS_NOT_OBSERVED);
assert_eq!(create.unwrap_err().code, FsError::FS_NOT_OBSERVED);
assert_eq!(replace.unwrap_err().code, FsError::FS_NOT_OBSERVED);
assert!(!target.exists(), "refused writes must not touch disk");
let (_, version) = strict
.fence_read(&target)
.expect("observation of missing path");
assert_eq!(version, 0);
strict
.fence_write(&target, WriteGuard::CreateIfAbsent, b"created")
.expect("guarded create after observation");
assert_eq!(std::fs::read(&target).unwrap(), b"created");
let blind = fence(FenceMode::Full, ws.path());
blind
.fence_write(&target, WriteGuard::Unconditional, b"blind")
.expect("blind write allowed in Full mode");
assert_eq!(std::fs::read(&target).unwrap(), b"blind");
let frozen = fence(FenceMode::ReadOnly, ws.path());
let denied = frozen.fence_write(&target, WriteGuard::Unconditional, b"x");
assert_eq!(denied.unwrap_err().code, FsError::FS_FENCE_DENIED);
assert_eq!(
frozen.policy().mode,
FenceMode::ReadOnly,
"policy stays immutable"
);
}
#[test]
fn replace_if_version_conflicts_on_concurrent_change() {
let ws = TempDir::new("l3-conflict");
let target = ws.path().join("notes.txt");
let f = fence(FenceMode::WorkspaceWrite, ws.path());
let version = observed_version(&f, &target, b"first draft");
f.fence_write(
&target,
WriteGuard::ReplaceIfVersion { version },
b"second draft",
)
.expect("matching version writes");
std::thread::sleep(std::time::Duration::from_millis(20));
std::fs::write(&target, b"a concurrent writer got here first").expect("concurrent edit");
let conflict = f.fence_write(
&target,
WriteGuard::ReplaceIfVersion { version },
b"stale overwrite",
);
let error = conflict.expect_err("stale version must conflict");
assert_eq!(error.code, FsError::FS_VERSION_CONFLICT);
assert_eq!(
std::fs::read(&target).unwrap(),
b"a concurrent writer got here first",
"conflicted write must not clobber the concurrent change"
);
let (_, fresh) = f.fence_read(&target).expect("re-read");
f.fence_write(
&target,
WriteGuard::ReplaceIfVersion { version: fresh },
b"third draft",
)
.expect("fresh version writes");
assert_eq!(std::fs::read(&target).unwrap(), b"third draft");
}
#[test]
fn replace_if_version_refuses_disappeared_file() {
let ws = TempDir::new("l3-gone");
let target = ws.path().join("notes.txt");
let f = fence(FenceMode::WorkspaceWrite, ws.path());
let version = observed_version(&f, &target, b"do not lose me");
std::fs::remove_file(&target).expect("delete behind the fence");
let error = f
.fence_write(
&target,
WriteGuard::ReplaceIfVersion { version },
b"resurrect",
)
.expect_err("missing file must refuse replacement");
assert_eq!(error.code, FsError::FS_VERSION_CONFLICT);
}
#[test]
fn create_if_absent_refuses_overwrite() {
let ws = TempDir::new("l3-create");
let fresh = ws.path().join("fresh.txt");
let taken = ws.path().join("taken.txt");
let f = fence(FenceMode::WorkspaceWrite, ws.path());
f.fence_read(&fresh).expect("observe absent fresh path");
f.fence_write(&fresh, WriteGuard::CreateIfAbsent, b"v1")
.expect("create on absent path");
assert_eq!(std::fs::read(&fresh).unwrap(), b"v1");
let error = f
.fence_write(&fresh, WriteGuard::CreateIfAbsent, b"clobber")
.expect_err("second create must refuse");
assert_eq!(error.code, FsError::FS_EXISTS);
assert_eq!(std::fs::read(&fresh).unwrap(), b"v1", "content preserved");
let error = f
.fence_write(&taken, WriteGuard::CreateIfAbsent, b"no")
.expect_err("unobserved create needs an observation");
assert_eq!(error.code, FsError::FS_NOT_OBSERVED);
assert!(!taken.exists());
}
#[test]
fn audit_ring_trims_at_capacity() {
let ws = TempDir::new("l3-audit");
let target = ws.path().join("loop.txt");
let f = fence(FenceMode::Full, ws.path());
for round in 0..(AUDIT_CAPACITY + 25) {
f.fence_write(
&target,
WriteGuard::Unconditional,
format!("round {round}").as_bytes(),
)
.expect("blind writes run in Full mode");
}
let log = f.audit_log();
assert_eq!(log.len(), AUDIT_CAPACITY);
assert!(log.iter().all(|entry| entry.outcome == FS_OK));
assert!(log
.iter()
.all(|entry| entry.guard_kind == Some(WriteGuard::Unconditional)));
assert!(
log.first().expect("ring nonempty").ts_millis > 0,
"entries carry timestamps"
);
}
#[test]
fn l0_l2_paths_unchanged() {
let ws = TempDir::new("l0l2-regression");
let ro_policy = policy(FenceMode::ReadOnly, ws.path());
let victim = ws.path().join("victim.txt");
std::fs::write(&victim, b"keep").expect("seed victim");
assert!(!ro_policy.check_write(&victim).is_allowed());
assert!(ro_policy.check_read(&victim).is_allowed());
let frozen = fence(FenceMode::ReadOnly, ws.path());
let denied = frozen.fence_write(&victim, WriteGuard::Unconditional, b"x");
assert_eq!(denied.unwrap_err().code, FsError::FS_FENCE_DENIED);
assert_eq!(std::fs::read(&victim).unwrap(), b"keep", "L0 protects");
let escaper = fence(FenceMode::WorkspaceWrite, ws.path());
let escape = ws.path().join("..").join("escape.txt");
let error = escaper
.fence_write(&escape, WriteGuard::Unconditional, b"x")
.expect_err("escape refused");
assert_eq!(error.code, FsError::FS_FENCE_DENIED);
assert!(!escape.exists());
let full = fence(FenceMode::Full, ws.path());
let pem = ws.path().join("secret.key");
let error = full
.fence_write(&pem, WriteGuard::Unconditional, b"k")
.expect_err("blocklist hit refused");
assert_eq!(error.code, FsError::FS_FENCE_DENIED);
assert!(full.fence_read(&ws.path().join(".env")).is_err());
assert!(!pem.exists());
}
#[test]
fn atomic_write_leaves_no_temp_files_and_sets_private_mode() {
let ws = TempDir::new("l3-atomic");
let target = ws.path().join("private.txt");
let f = fence(FenceMode::Full, ws.path());
f.fence_write(&target, WriteGuard::Unconditional, b"payload")
.expect("write lands");
let leftovers: Vec<_> = std::fs::read_dir(ws.path())
.expect("list workspace")
.filter_map(|entry| entry.ok())
.map(|entry| entry.file_name().to_string_lossy().into_owned())
.collect();
assert_eq!(leftovers, vec!["private.txt".to_string()], "tmp renamed away");
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mode = std::fs::metadata(&target)
.expect("metadata")
.permissions()
.mode();
assert_eq!(mode & 0o777, 0o600, "best-effort chmod 0600 applied");
}
}
#[test]
fn audit_records_deny_and_success_outcomes() {
let ws = TempDir::new("l3-audit-mixed");
let good = ws.path().join("good.txt");
let bad = ws.path().join(".env");
let f = fence(FenceMode::WorkspaceWrite, ws.path());
let error = f.fence_read(&bad).expect_err("blocklisted read refused");
assert_eq!(error.code, FsError::FS_FENCE_DENIED);
let error = f
.fence_write(&good, WriteGuard::CreateIfAbsent, b"ok")
.expect_err("create without observation refused");
assert_eq!(error.code, FsError::FS_NOT_OBSERVED);
f.fence_read(&good).expect("observe absent path");
f.fence_write(&good, WriteGuard::CreateIfAbsent, b"ok")
.expect("guarded create after observation");
let log = f.audit_log();
let outcomes: Vec<&str> = log.iter().map(|entry| entry.outcome).collect();
assert_eq!(
outcomes,
vec![
FsError::FS_FENCE_DENIED,
FsError::FS_NOT_OBSERVED,
FS_OK,
FS_OK
]
);
assert_eq!(log[0].guard_kind, None);
assert_eq!(log[1].guard_kind, Some(WriteGuard::CreateIfAbsent));
}
impl FenceDecision {
fn expect_denied_reason(&self) -> String {
match self {
FenceDecision::Denied { reason, .. } => reason.clone(),
FenceDecision::Allowed => panic!("expected a denial"),
}
}
fn layer(&self) -> Option<FenceLayer> {
match self {
FenceDecision::Denied { layer, .. } => Some(*layer),
FenceDecision::Allowed => None,
}
}
}
}