use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Arc;
use path_clean::PathClean;
use wasmtime::component::{HasData, Resource, ResourceTable};
use wasmtime_wasi::filesystem::{WasiFilesystemCtx, WasiFilesystemCtxView};
use wasmtime_wasi::p2::bindings::filesystem::preopens;
use wasmtime_wasi::p2::bindings::filesystem::types::{
self, ErrorCode, HostDescriptor, HostDirectoryEntryStream,
};
use wasmtime_wasi::p2::{DynInputStream, DynOutputStream, FsError, FsResult};
use act_types::{Capabilities, MountType};
use act_policy::Decision;
use act_policy::consent::{ConsentAsk, ConsentPrompter, DecisionCache};
use act_policy::fs_matcher::FsAccess;
use act_policy::grant::PolicyMode;
use act_policy::provider::{CompiledCeiling, ResourceOp};
use crate::audit::{CapDecisionRecord, Decision4, emit_cap_decision};
#[derive(Debug, Clone, PartialEq)]
pub struct Preopen {
pub guest: String,
pub host: PathBuf,
}
#[derive(Debug, Clone, PartialEq)]
pub struct ResolvedMount {
pub kind: MountType,
pub guest: String,
pub host: Option<PathBuf>,
}
pub fn resolve_mounts(caps: &Capabilities, mode: PolicyMode) -> Vec<ResolvedMount> {
if mode == PolicyMode::Deny {
return Vec::new();
}
let declared = caps.fs_mounts().unwrap_or_else(|e| {
tracing::warn!(error = %e, "ignoring malformed wasi:filesystem mounts");
Vec::new()
});
let has_explicit_root = declared.iter().any(|m| m.kind == MountType::Root);
let mut out = Vec::new();
for m in &declared {
match m.kind {
MountType::Bind => {
if let (Some(g), Some(h)) = (m.guest.as_deref(), m.host.as_deref()) {
out.push(ResolvedMount {
kind: MountType::Bind,
guest: g.to_string(),
host: Some(expand_host_dir(h)),
});
}
}
MountType::Root => out.push(ResolvedMount {
kind: MountType::Root,
guest: m.guest.as_deref().unwrap_or("/").to_string(),
host: None,
}),
}
}
if !has_explicit_root
&& let Some(mr) = caps.fs_mount_root()
&& mr != "/"
&& !mr.is_empty()
{
out.push(ResolvedMount {
kind: MountType::Root,
guest: mr.to_string(),
host: None,
});
}
if out.is_empty() {
out.push(ResolvedMount {
kind: MountType::Root,
guest: "/".to_string(),
host: None,
});
}
out
}
fn expand_host_dir(s: &str) -> PathBuf {
let expanded = shellexpand::tilde(s).into_owned();
let p = PathBuf::from(&expanded);
if p.is_absolute() {
p
} else {
std::env::current_dir().map(|c| c.join(&p)).unwrap_or(p)
}
}
pub fn derive_preopens(mounts: &[ResolvedMount]) -> Vec<Preopen> {
let mut out = Vec::new();
for m in mounts {
match m.kind {
MountType::Bind => {
if let Some(host) = &m.host {
out.push(Preopen {
guest: m.guest.clone(),
host: host.clone(),
});
}
}
MountType::Root => out.extend(root_preopens_under(&m.guest)),
}
}
out
}
pub fn create_mount_dirs(mounts: &[ResolvedMount]) -> std::io::Result<()> {
for m in mounts {
if m.kind == MountType::Bind
&& let Some(host) = &m.host
{
std::fs::create_dir_all(host)?;
}
}
Ok(())
}
#[cfg(unix)]
fn root_preopens_under(guest: &str) -> Vec<Preopen> {
vec![Preopen {
guest: guest.to_string(),
host: PathBuf::from("/"),
}]
}
#[cfg(windows)]
fn root_preopens_under(guest: &str) -> Vec<Preopen> {
let base = guest.trim_end_matches('/');
let mut out = Vec::new();
for letter in b'A'..=b'Z' {
let c = letter as char;
let host = PathBuf::from(format!("{}:\\", c));
if std::fs::metadata(&host).is_ok() {
let g = if base.is_empty() {
format!("/{}", c.to_ascii_lowercase())
} else {
format!("{}/{}", base, c.to_ascii_lowercase())
};
out.push(Preopen { guest: g, host });
}
}
out
}
#[cfg(not(any(unix, windows)))]
fn root_preopens_under(guest: &str) -> Vec<Preopen> {
vec![Preopen {
guest: guest.to_string(),
host: PathBuf::from("/"),
}]
}
pub struct PolicyFilesystem;
impl HasData for PolicyFilesystem {
type Data<'a> = PolicyFilesystemCtxView<'a>;
}
pub struct PolicyFilesystemCtxView<'a> {
pub ctx: &'a mut WasiFilesystemCtx,
pub table: &'a mut ResourceTable,
pub ceiling: &'a Arc<dyn CompiledCeiling>,
pub fd_paths: &'a mut FdPathMap,
pub mode: PolicyMode,
pub prompter: Arc<dyn ConsentPrompter>,
pub cache: Arc<DecisionCache>,
}
#[derive(Default, Debug)]
pub struct FdPathMap {
pub preopens: Vec<(String, PathBuf)>,
pub by_rep: HashMap<u32, PathBuf>,
}
enum PathDecision {
Allow(PathBuf),
Ask {
canonical: PathBuf,
cache: Arc<DecisionCache>,
prompter: Arc<dyn ConsentPrompter>,
},
}
async fn resolve_ask(
cache: Arc<DecisionCache>,
prompter: Arc<dyn ConsentPrompter>,
canonical: PathBuf,
) -> FsResult<PathBuf> {
let path = canonical.display().to_string();
let has_channel = prompter.has_channel();
let allowed = cache
.decide_cached(
&*prompter,
ConsentAsk {
cap_id: act_types::constants::CAP_FILESYSTEM.to_string(),
key: path.clone(),
summary: format!("filesystem access: {path}"),
},
)
.await;
emit_cap_decision(&CapDecisionRecord::answered(
act_types::constants::CAP_FILESYSTEM,
&path,
allowed,
has_channel,
));
if allowed {
Ok(canonical)
} else {
Err(ErrorCode::NotPermitted.into())
}
}
impl PolicyFilesystemCtxView<'_> {
fn inner(&mut self) -> WasiFilesystemCtxView<'_> {
WasiFilesystemCtxView {
ctx: self.ctx,
table: self.table,
}
}
fn parent_path(&self, fd: &Resource<types::Descriptor>) -> Option<PathBuf> {
self.fd_paths.by_rep.get(&fd.rep()).cloned()
}
fn check_path(
&self,
parent_fd: &Resource<types::Descriptor>,
rel: &str,
access: FsAccess,
) -> impl Future<Output = FsResult<PathBuf>> + Send + 'static {
let decision = self.check_path_sync(parent_fd, rel, access);
async move {
match decision? {
PathDecision::Allow(canonical) => Ok(canonical),
PathDecision::Ask {
canonical,
cache,
prompter,
} => resolve_ask(cache, prompter, canonical).await,
}
}
}
fn check_path_sync(
&self,
parent_fd: &Resource<types::Descriptor>,
rel: &str,
access: FsAccess,
) -> FsResult<PathDecision> {
let Some(parent) = self.parent_path(parent_fd) else {
tracing::warn!(fd = parent_fd.rep(), "fs policy: untracked parent fd");
return Err(ErrorCode::NotPermitted.into());
};
let canonical = parent.join(rel).clean();
let op = ResourceOp {
cap_id: act_types::constants::CAP_FILESYSTEM.to_string(),
key: canonical.display().to_string(),
action: if access == FsAccess::Write {
"write".to_string()
} else {
"read".to_string()
},
attrs: serde_json::Value::Null,
};
let explained = self.ceiling.classify_explained(&op);
let mode = self.ceiling.effective_mode().to_string();
match explained.decision {
Decision::Allow => {
emit_cap_decision(&CapDecisionRecord::statik(
act_types::constants::CAP_FILESYSTEM,
&op.key,
&op.action,
Decision4::Allow,
&mode,
explained.rule,
));
Ok(PathDecision::Allow(canonical))
}
Decision::Deny => {
emit_cap_decision(&CapDecisionRecord::statik(
act_types::constants::CAP_FILESYSTEM,
&op.key,
&op.action,
Decision4::Deny,
&mode,
explained.rule,
));
Err(ErrorCode::NotPermitted.into())
}
Decision::Ask => Ok(PathDecision::Ask {
canonical,
cache: self.cache.clone(),
prompter: self.prompter.clone(),
}),
}
}
fn populate_preopens(&mut self, entries: &[(Resource<types::Descriptor>, String)]) {
for (res, guest_path) in entries {
if self.fd_paths.by_rep.contains_key(&res.rep()) {
continue;
}
let Some(host) = self
.fd_paths
.preopens
.iter()
.find(|(g, _)| g == guest_path)
.map(|(_, h)| h.clone())
else {
continue;
};
self.fd_paths.by_rep.insert(res.rep(), host);
}
}
}
impl preopens::Host for PolicyFilesystemCtxView<'_> {
fn get_directories(&mut self) -> wasmtime::Result<Vec<(Resource<types::Descriptor>, String)>> {
let entries = self.inner().get_directories()?;
self.populate_preopens(&entries);
Ok(entries)
}
}
impl types::Host for PolicyFilesystemCtxView<'_> {
fn convert_error_code(&mut self, err: FsError) -> wasmtime::Result<ErrorCode> {
self.inner().convert_error_code(err)
}
fn filesystem_error_code(
&mut self,
err: Resource<wasmtime::Error>,
) -> wasmtime::Result<Option<ErrorCode>> {
self.inner().filesystem_error_code(err)
}
}
impl HostDescriptor for PolicyFilesystemCtxView<'_> {
async fn advise(
&mut self,
fd: Resource<types::Descriptor>,
offset: types::Filesize,
len: types::Filesize,
advice: types::Advice,
) -> FsResult<()> {
self.inner().advise(fd, offset, len, advice).await
}
async fn sync_data(&mut self, fd: Resource<types::Descriptor>) -> FsResult<()> {
self.inner().sync_data(fd).await
}
async fn get_flags(
&mut self,
fd: Resource<types::Descriptor>,
) -> FsResult<types::DescriptorFlags> {
self.inner().get_flags(fd).await
}
async fn get_type(
&mut self,
fd: Resource<types::Descriptor>,
) -> FsResult<types::DescriptorType> {
self.inner().get_type(fd).await
}
async fn set_size(
&mut self,
fd: Resource<types::Descriptor>,
size: types::Filesize,
) -> FsResult<()> {
self.inner().set_size(fd, size).await
}
async fn set_times(
&mut self,
fd: Resource<types::Descriptor>,
atim: types::NewTimestamp,
mtim: types::NewTimestamp,
) -> FsResult<()> {
self.inner().set_times(fd, atim, mtim).await
}
async fn read(
&mut self,
fd: Resource<types::Descriptor>,
len: types::Filesize,
offset: types::Filesize,
) -> FsResult<(Vec<u8>, bool)> {
self.inner().read(fd, len, offset).await
}
async fn write(
&mut self,
fd: Resource<types::Descriptor>,
buf: Vec<u8>,
offset: types::Filesize,
) -> FsResult<types::Filesize> {
self.inner().write(fd, buf, offset).await
}
async fn read_directory(
&mut self,
fd: Resource<types::Descriptor>,
) -> FsResult<Resource<types::DirectoryEntryStream>> {
self.inner().read_directory(fd).await
}
async fn sync(&mut self, fd: Resource<types::Descriptor>) -> FsResult<()> {
self.inner().sync(fd).await
}
async fn create_directory_at(
&mut self,
fd: Resource<types::Descriptor>,
path: String,
) -> FsResult<()> {
let _checked = self.check_path(&fd, &path, FsAccess::Write).await?;
self.inner().create_directory_at(fd, path).await
}
async fn stat(&mut self, fd: Resource<types::Descriptor>) -> FsResult<types::DescriptorStat> {
self.inner().stat(fd).await
}
async fn stat_at(
&mut self,
fd: Resource<types::Descriptor>,
path_flags: types::PathFlags,
path: String,
) -> FsResult<types::DescriptorStat> {
let _checked = self.check_path(&fd, &path, FsAccess::Read).await?;
self.inner().stat_at(fd, path_flags, path).await
}
async fn set_times_at(
&mut self,
fd: Resource<types::Descriptor>,
path_flags: types::PathFlags,
path: String,
atim: types::NewTimestamp,
mtim: types::NewTimestamp,
) -> FsResult<()> {
let _checked = self.check_path(&fd, &path, FsAccess::Write).await?;
self.inner()
.set_times_at(fd, path_flags, path, atim, mtim)
.await
}
async fn link_at(
&mut self,
fd: Resource<types::Descriptor>,
old_path_flags: types::PathFlags,
old_path: String,
new_descriptor: Resource<types::Descriptor>,
new_path: String,
) -> FsResult<()> {
let _old = self.check_path(&fd, &old_path, FsAccess::Read).await?;
let _new = self
.check_path(&new_descriptor, &new_path, FsAccess::Write)
.await?;
self.inner()
.link_at(fd, old_path_flags, old_path, new_descriptor, new_path)
.await
}
async fn open_at(
&mut self,
fd: Resource<types::Descriptor>,
path_flags: types::PathFlags,
path: String,
oflags: types::OpenFlags,
flags: types::DescriptorFlags,
) -> FsResult<Resource<types::Descriptor>> {
let access = if flags.contains(types::DescriptorFlags::WRITE)
|| flags.contains(types::DescriptorFlags::MUTATE_DIRECTORY)
|| oflags.contains(types::OpenFlags::CREATE)
|| oflags.contains(types::OpenFlags::TRUNCATE)
|| oflags.contains(types::OpenFlags::EXCLUSIVE)
{
FsAccess::Write
} else {
FsAccess::Read
};
let canonical = self.check_path(&fd, &path, access).await?;
let new_fd = self
.inner()
.open_at(fd, path_flags, path, oflags, flags)
.await?;
self.fd_paths.by_rep.insert(new_fd.rep(), canonical);
Ok(new_fd)
}
fn drop(&mut self, fd: Resource<types::Descriptor>) -> wasmtime::Result<()> {
self.fd_paths.by_rep.remove(&fd.rep());
HostDescriptor::drop(&mut self.inner(), fd)
}
async fn readlink_at(
&mut self,
fd: Resource<types::Descriptor>,
path: String,
) -> FsResult<String> {
let _checked = self.check_path(&fd, &path, FsAccess::Read).await?;
self.inner().readlink_at(fd, path).await
}
async fn remove_directory_at(
&mut self,
fd: Resource<types::Descriptor>,
path: String,
) -> FsResult<()> {
let _checked = self.check_path(&fd, &path, FsAccess::Write).await?;
self.inner().remove_directory_at(fd, path).await
}
async fn rename_at(
&mut self,
fd: Resource<types::Descriptor>,
old_path: String,
new_fd: Resource<types::Descriptor>,
new_path: String,
) -> FsResult<()> {
let _old = self.check_path(&fd, &old_path, FsAccess::Write).await?;
let _new = self.check_path(&new_fd, &new_path, FsAccess::Write).await?;
self.inner().rename_at(fd, old_path, new_fd, new_path).await
}
async fn symlink_at(
&mut self,
fd: Resource<types::Descriptor>,
src_path: String,
dest_path: String,
) -> FsResult<()> {
let _checked = self.check_path(&fd, &dest_path, FsAccess::Write).await?;
self.inner().symlink_at(fd, src_path, dest_path).await
}
async fn unlink_file_at(
&mut self,
fd: Resource<types::Descriptor>,
path: String,
) -> FsResult<()> {
let _checked = self.check_path(&fd, &path, FsAccess::Write).await?;
self.inner().unlink_file_at(fd, path).await
}
fn read_via_stream(
&mut self,
fd: Resource<types::Descriptor>,
offset: types::Filesize,
) -> FsResult<Resource<DynInputStream>> {
self.inner().read_via_stream(fd, offset)
}
fn write_via_stream(
&mut self,
fd: Resource<types::Descriptor>,
offset: types::Filesize,
) -> FsResult<Resource<DynOutputStream>> {
self.inner().write_via_stream(fd, offset)
}
fn append_via_stream(
&mut self,
fd: Resource<types::Descriptor>,
) -> FsResult<Resource<DynOutputStream>> {
self.inner().append_via_stream(fd)
}
async fn is_same_object(
&mut self,
a: Resource<types::Descriptor>,
b: Resource<types::Descriptor>,
) -> wasmtime::Result<bool> {
self.inner().is_same_object(a, b).await
}
async fn metadata_hash(
&mut self,
fd: Resource<types::Descriptor>,
) -> FsResult<types::MetadataHashValue> {
self.inner().metadata_hash(fd).await
}
async fn metadata_hash_at(
&mut self,
fd: Resource<types::Descriptor>,
path_flags: types::PathFlags,
path: String,
) -> FsResult<types::MetadataHashValue> {
let _checked = self.check_path(&fd, &path, FsAccess::Read).await?;
self.inner().metadata_hash_at(fd, path_flags, path).await
}
}
impl wasmtime_wasi::p3::bindings::filesystem::preopens::Host for PolicyFilesystemCtxView<'_> {
fn get_directories(
&mut self,
) -> wasmtime::Result<
Vec<(
Resource<wasmtime_wasi::p3::bindings::filesystem::types::Descriptor>,
String,
)>,
> {
if self.mode != PolicyMode::Open {
tracing::warn!(
mode = ?self.mode,
"p3 wasi:filesystem/preopens: returning empty; p3 path ops can't be matcher-gated",
);
return Ok(vec![]);
}
let mut inner = WasiFilesystemCtxView {
ctx: self.ctx,
table: self.table,
};
<WasiFilesystemCtxView as wasmtime_wasi::p3::bindings::filesystem::preopens::Host>::get_directories(&mut inner)
}
}
impl HostDirectoryEntryStream for PolicyFilesystemCtxView<'_> {
async fn read_directory_entry(
&mut self,
stream: Resource<types::DirectoryEntryStream>,
) -> FsResult<Option<types::DirectoryEntry>> {
self.inner().read_directory_entry(stream).await
}
fn drop(&mut self, stream: Resource<types::DirectoryEntryStream>) -> wasmtime::Result<()> {
HostDirectoryEntryStream::drop(&mut self.inner(), stream)
}
}
#[cfg(test)]
mod mount_tests {
use super::*;
use act_policy::grant::PolicyMode;
use act_types::{Capabilities, CapabilityRequest, MountType};
use std::collections::BTreeMap;
fn caps_with_mounts(mounts: serde_json::Value) -> Capabilities {
let mut caps = Capabilities::default();
let mut params = BTreeMap::new();
params.insert("mounts".to_string(), mounts);
caps.0.insert(
"wasi:filesystem".into(),
CapabilityRequest {
params,
..Default::default()
},
);
caps
}
#[test]
fn deny_mode_yields_no_mounts() {
let caps = caps_with_mounts(serde_json::json!([{ "guest": "/ows", "host": "/tmp/x" }]));
assert!(resolve_mounts(&caps, PolicyMode::Deny).is_empty());
}
#[test]
fn bind_only_component_gets_just_the_bind_preopen() {
let caps = caps_with_mounts(serde_json::json!([{ "guest": "/ows", "host": "/tmp/x" }]));
let mounts = resolve_mounts(&caps, PolicyMode::Ask);
let pre = derive_preopens(&mounts);
assert_eq!(pre.len(), 1);
assert_eq!(pre[0].guest, "/ows");
assert_eq!(pre[0].host, std::path::PathBuf::from("/tmp/x"));
}
#[test]
fn no_mounts_declared_defaults_to_root() {
let caps = Capabilities::default();
let mounts = resolve_mounts(&caps, PolicyMode::Allowlist);
assert_eq!(mounts.len(), 1);
assert_eq!(mounts[0].kind, MountType::Root);
assert_eq!(mounts[0].guest, "/");
}
#[cfg(unix)]
#[test]
fn root_mount_preopens_the_filesystem_root() {
let caps = caps_with_mounts(serde_json::json!([{ "type": "root", "guest": "/" }]));
let pre = derive_preopens(&resolve_mounts(&caps, PolicyMode::Open));
assert_eq!(pre.len(), 1);
assert_eq!(pre[0].guest, "/");
assert_eq!(pre[0].host, std::path::PathBuf::from("/"));
}
#[test]
fn mount_root_sugar_becomes_a_root_mount() {
let mut caps = Capabilities::default();
let mut params = BTreeMap::new();
params.insert("mount-root".to_string(), serde_json::json!("/data"));
caps.0.insert(
"wasi:filesystem".into(),
CapabilityRequest {
params,
..Default::default()
},
);
let mounts = resolve_mounts(&caps, PolicyMode::Allowlist);
assert_eq!(mounts.len(), 1);
assert_eq!(mounts[0].kind, MountType::Root);
assert_eq!(mounts[0].guest, "/data");
}
#[test]
fn mount_root_slash_is_noop_with_binds() {
let mut caps = Capabilities::default();
let mut params = BTreeMap::new();
params.insert(
"mounts".to_string(),
serde_json::json!([{ "guest": "/ows", "host": "/tmp/x" }]),
);
params.insert("mount-root".to_string(), serde_json::json!("/"));
caps.0.insert(
"wasi:filesystem".into(),
CapabilityRequest {
params,
..Default::default()
},
);
let mounts = resolve_mounts(&caps, PolicyMode::Ask);
assert_eq!(mounts.len(), 1);
assert_eq!(mounts[0].kind, MountType::Bind);
assert_eq!(mounts[0].guest, "/ows");
}
#[test]
fn explicit_root_suppresses_mount_root_sugar() {
let mut caps = Capabilities::default();
let mut params = BTreeMap::new();
params.insert(
"mounts".to_string(),
serde_json::json!([{ "type": "root", "guest": "/x" }]),
);
params.insert("mount-root".to_string(), serde_json::json!("/data"));
caps.0.insert(
"wasi:filesystem".into(),
CapabilityRequest {
params,
..Default::default()
},
);
let mounts = resolve_mounts(&caps, PolicyMode::Allowlist);
assert_eq!(mounts.len(), 1);
assert_eq!(mounts[0].kind, MountType::Root);
assert_eq!(mounts[0].guest, "/x");
}
#[test]
fn tilde_in_bind_host_is_expanded() {
let caps = caps_with_mounts(serde_json::json!([{ "guest": "/ows", "host": "~/.ows" }]));
let mounts = resolve_mounts(&caps, PolicyMode::Ask);
let host = mounts[0].host.clone().unwrap();
assert!(host.is_absolute());
assert!(!host.to_string_lossy().starts_with('~'));
}
#[test]
fn create_mount_dirs_makes_bind_targets() {
let tmp = std::env::temp_dir().join(format!("act-mount-test-{}", std::process::id()));
let target = tmp.join("nested");
let mounts = vec![ResolvedMount {
kind: MountType::Bind,
guest: "/d".into(),
host: Some(target.clone()),
}];
create_mount_dirs(&mounts).unwrap();
assert!(target.is_dir());
std::fs::remove_dir_all(&tmp).ok();
}
}
#[cfg(test)]
mod tests {
#[test]
fn fs_records_carry_the_matched_rule_and_a_reason_on_deny() {
let allow = crate::audit::CapDecisionRecord::statik(
act_types::constants::CAP_FILESYSTEM,
"/data/app.db",
"read",
crate::audit::Decision4::Allow,
"allowlist",
Some("/data/**".into()),
);
assert_eq!(allow.cap_id, act_types::constants::CAP_FILESYSTEM);
assert_eq!(allow.rule.as_deref(), Some("/data/**"));
assert!(allow.reason.is_none());
let deny = crate::audit::CapDecisionRecord::statik(
act_types::constants::CAP_FILESYSTEM,
"/etc/passwd",
"read",
crate::audit::Decision4::Deny,
"allowlist",
None,
);
assert_eq!(deny.reason.as_deref(), Some("outside ceiling"));
assert_eq!(deny.actor, crate::audit::record::Actor::Static);
}
#[test]
fn ask_resolution_attributes_the_decision_to_the_user() {
let r = crate::audit::CapDecisionRecord::answered(
act_types::constants::CAP_FILESYSTEM,
"/home/u/.ssh/id_ed25519",
false,
true,
);
assert_eq!(r.decision, crate::audit::Decision4::AskDeny);
assert_eq!(r.actor, crate::audit::record::Actor::User);
assert_eq!(r.reason.as_deref(), Some("denied by user"));
let r = crate::audit::CapDecisionRecord::answered(
act_types::constants::CAP_FILESYSTEM,
"/home/u/notes.txt",
true,
true,
);
assert_eq!(r.decision, crate::audit::Decision4::AskAllow);
assert_eq!(r.actor, crate::audit::record::Actor::User);
}
#[test]
fn ask_resolution_with_no_channel_is_not_attributed_to_the_user() {
let r = crate::audit::CapDecisionRecord::answered(
act_types::constants::CAP_FILESYSTEM,
"/home/u/.ssh/id_ed25519",
false,
false,
);
assert_eq!(r.decision, crate::audit::Decision4::AskDeny);
assert_ne!(r.actor, crate::audit::record::Actor::User);
assert_eq!(r.reason.as_deref(), Some("no prompt channel"));
}
}
#[cfg(test)]
mod policy_tests {
use act_policy::Decision;
use act_policy::fs_matcher::{FsAccess, FsMatcher};
use act_policy::grant::{FsAllow, FsConfig, PolicyMode};
use act_types::FsMode;
use std::path::Path;
#[test]
fn ro_matcher_blocks_write_allows_read() {
let cfg = FsConfig {
mode: PolicyMode::Allowlist,
allow: vec![FsAllow {
glob: "/data/**".into(),
mode: FsMode::Ro,
}],
deny: vec![],
};
let matcher = FsMatcher::compile(&cfg).unwrap();
assert_eq!(
matcher.decide(Path::new("/data/x.db"), FsAccess::Read),
Decision::Allow
);
assert_eq!(
matcher.decide(Path::new("/data/x.db"), FsAccess::Write),
Decision::Deny
);
}
}