1use std::collections::HashMap;
30use std::path::PathBuf;
31use std::sync::Arc;
32
33use path_clean::PathClean;
34use wasmtime::component::{HasData, Resource, ResourceTable};
35use wasmtime_wasi::filesystem::{WasiFilesystemCtx, WasiFilesystemCtxView};
36use wasmtime_wasi::p2::bindings::filesystem::preopens;
37use wasmtime_wasi::p2::bindings::filesystem::types::{
38 self, ErrorCode, HostDescriptor, HostDirectoryEntryStream,
39};
40use wasmtime_wasi::p2::{DynInputStream, DynOutputStream, FsError, FsResult};
41
42use act_types::{Capabilities, MountType};
43
44use act_policy::Decision;
45use act_policy::consent::{ConsentAsk, ConsentPrompter, DecisionCache};
46use act_policy::fs_matcher::FsAccess;
47use act_policy::grant::PolicyMode;
48use act_policy::provider::{CompiledCeiling, ResourceOp};
49
50use crate::audit::{CapDecisionRecord, Decision4, emit_cap_decision};
51
52#[derive(Debug, Clone, PartialEq)]
62pub struct Preopen {
63 pub guest: String,
64 pub host: PathBuf,
65}
66
67#[derive(Debug, Clone, PartialEq)]
69pub struct ResolvedMount {
70 pub kind: MountType,
71 pub guest: String,
72 pub host: Option<PathBuf>,
74}
75
76pub fn resolve_mounts(caps: &Capabilities, mode: PolicyMode) -> Vec<ResolvedMount> {
83 if mode == PolicyMode::Deny {
84 return Vec::new();
85 }
86 let declared = caps.fs_mounts().unwrap_or_else(|e| {
87 tracing::warn!(error = %e, "ignoring malformed wasi:filesystem mounts");
88 Vec::new()
89 });
90 let has_explicit_root = declared.iter().any(|m| m.kind == MountType::Root);
91
92 let mut out = Vec::new();
93 for m in &declared {
94 match m.kind {
95 MountType::Bind => {
96 if let (Some(g), Some(h)) = (m.guest.as_deref(), m.host.as_deref()) {
97 out.push(ResolvedMount {
98 kind: MountType::Bind,
99 guest: g.to_string(),
100 host: Some(expand_host_dir(h)),
101 });
102 }
103 }
104 MountType::Root => out.push(ResolvedMount {
105 kind: MountType::Root,
106 guest: m.guest.as_deref().unwrap_or("/").to_string(),
107 host: None,
108 }),
109 }
110 }
111
112 if !has_explicit_root
117 && let Some(mr) = caps.fs_mount_root()
118 && mr != "/"
119 && !mr.is_empty()
120 {
121 out.push(ResolvedMount {
122 kind: MountType::Root,
123 guest: mr.to_string(),
124 host: None,
125 });
126 }
127
128 if out.is_empty() {
129 out.push(ResolvedMount {
130 kind: MountType::Root,
131 guest: "/".to_string(),
132 host: None,
133 });
134 }
135 out
136}
137
138fn expand_host_dir(s: &str) -> PathBuf {
141 let expanded = shellexpand::tilde(s).into_owned();
142 let p = PathBuf::from(&expanded);
143 if p.is_absolute() {
144 p
145 } else {
146 std::env::current_dir().map(|c| c.join(&p)).unwrap_or(p)
147 }
148}
149
150pub fn derive_preopens(mounts: &[ResolvedMount]) -> Vec<Preopen> {
152 let mut out = Vec::new();
153 for m in mounts {
154 match m.kind {
155 MountType::Bind => {
156 if let Some(host) = &m.host {
157 out.push(Preopen {
158 guest: m.guest.clone(),
159 host: host.clone(),
160 });
161 }
162 }
163 MountType::Root => out.extend(root_preopens_under(&m.guest)),
164 }
165 }
166 out
167}
168
169pub fn create_mount_dirs(mounts: &[ResolvedMount]) -> std::io::Result<()> {
172 for m in mounts {
173 if m.kind == MountType::Bind
174 && let Some(host) = &m.host
175 {
176 std::fs::create_dir_all(host)?;
177 }
178 }
179 Ok(())
180}
181
182#[cfg(unix)]
183fn root_preopens_under(guest: &str) -> Vec<Preopen> {
184 vec![Preopen {
185 guest: guest.to_string(),
186 host: PathBuf::from("/"),
187 }]
188}
189
190#[cfg(windows)]
191fn root_preopens_under(guest: &str) -> Vec<Preopen> {
192 let base = guest.trim_end_matches('/');
193 let mut out = Vec::new();
194 for letter in b'A'..=b'Z' {
195 let c = letter as char;
196 let host = PathBuf::from(format!("{}:\\", c));
197 if std::fs::metadata(&host).is_ok() {
200 let g = if base.is_empty() {
201 format!("/{}", c.to_ascii_lowercase())
202 } else {
203 format!("{}/{}", base, c.to_ascii_lowercase())
204 };
205 out.push(Preopen { guest: g, host });
206 }
207 }
208 out
209}
210
211#[cfg(not(any(unix, windows)))]
212fn root_preopens_under(guest: &str) -> Vec<Preopen> {
213 vec![Preopen {
214 guest: guest.to_string(),
215 host: PathBuf::from("/"),
216 }]
217}
218
219pub struct PolicyFilesystem;
223
224impl HasData for PolicyFilesystem {
225 type Data<'a> = PolicyFilesystemCtxView<'a>;
226}
227
228pub struct PolicyFilesystemCtxView<'a> {
230 pub ctx: &'a mut WasiFilesystemCtx,
231 pub table: &'a mut ResourceTable,
232 pub ceiling: &'a Arc<dyn CompiledCeiling>,
233 pub fd_paths: &'a mut FdPathMap,
234 pub mode: PolicyMode,
239 pub prompter: Arc<dyn ConsentPrompter>,
242 pub cache: Arc<DecisionCache>,
244}
245
246#[derive(Default, Debug)]
250pub struct FdPathMap {
251 pub preopens: Vec<(String, PathBuf)>,
252 pub by_rep: HashMap<u32, PathBuf>,
253}
254
255enum PathDecision {
259 Allow(PathBuf),
260 Ask {
261 canonical: PathBuf,
262 cache: Arc<DecisionCache>,
263 prompter: Arc<dyn ConsentPrompter>,
264 },
265}
266
267async fn resolve_ask(
271 cache: Arc<DecisionCache>,
272 prompter: Arc<dyn ConsentPrompter>,
273 canonical: PathBuf,
274) -> FsResult<PathBuf> {
275 let path = canonical.display().to_string();
276 let has_channel = prompter.has_channel();
277 let allowed = cache
278 .decide_cached(
279 &*prompter,
280 ConsentAsk {
281 cap_id: act_types::constants::CAP_FILESYSTEM.to_string(),
282 key: path.clone(),
283 summary: format!("filesystem access: {path}"),
284 },
285 )
286 .await;
287 emit_cap_decision(&CapDecisionRecord::answered(
288 act_types::constants::CAP_FILESYSTEM,
289 &path,
290 allowed,
291 has_channel,
292 ));
293 if allowed {
294 Ok(canonical)
295 } else {
296 Err(ErrorCode::NotPermitted.into())
297 }
298}
299
300impl PolicyFilesystemCtxView<'_> {
301 fn inner(&mut self) -> WasiFilesystemCtxView<'_> {
302 WasiFilesystemCtxView {
303 ctx: self.ctx,
304 table: self.table,
305 }
306 }
307
308 fn parent_path(&self, fd: &Resource<types::Descriptor>) -> Option<PathBuf> {
309 self.fd_paths.by_rep.get(&fd.rep()).cloned()
310 }
311
312 fn check_path(
326 &self,
327 parent_fd: &Resource<types::Descriptor>,
328 rel: &str,
329 access: FsAccess,
330 ) -> impl Future<Output = FsResult<PathBuf>> + Send + 'static {
331 let decision = self.check_path_sync(parent_fd, rel, access);
332 async move {
333 match decision? {
334 PathDecision::Allow(canonical) => Ok(canonical),
335 PathDecision::Ask {
336 canonical,
337 cache,
338 prompter,
339 } => resolve_ask(cache, prompter, canonical).await,
340 }
341 }
342 }
343
344 fn check_path_sync(
347 &self,
348 parent_fd: &Resource<types::Descriptor>,
349 rel: &str,
350 access: FsAccess,
351 ) -> FsResult<PathDecision> {
352 let Some(parent) = self.parent_path(parent_fd) else {
353 tracing::warn!(fd = parent_fd.rep(), "fs policy: untracked parent fd");
356 return Err(ErrorCode::NotPermitted.into());
357 };
358 let canonical = parent.join(rel).clean();
359 let op = ResourceOp {
360 cap_id: act_types::constants::CAP_FILESYSTEM.to_string(),
361 key: canonical.display().to_string(),
362 action: if access == FsAccess::Write {
363 "write".to_string()
364 } else {
365 "read".to_string()
366 },
367 attrs: serde_json::Value::Null,
368 };
369 let explained = self.ceiling.classify_explained(&op);
370 let mode = self.ceiling.effective_mode().to_string();
371 match explained.decision {
372 Decision::Allow => {
373 emit_cap_decision(&CapDecisionRecord::statik(
374 act_types::constants::CAP_FILESYSTEM,
375 &op.key,
376 &op.action,
377 Decision4::Allow,
378 &mode,
379 explained.rule,
380 ));
381 Ok(PathDecision::Allow(canonical))
382 }
383 Decision::Deny => {
384 emit_cap_decision(&CapDecisionRecord::statik(
385 act_types::constants::CAP_FILESYSTEM,
386 &op.key,
387 &op.action,
388 Decision4::Deny,
389 &mode,
390 explained.rule,
391 ));
392 Err(ErrorCode::NotPermitted.into())
393 }
394 Decision::Ask => Ok(PathDecision::Ask {
397 canonical,
398 cache: self.cache.clone(),
399 prompter: self.prompter.clone(),
400 }),
401 }
402 }
403
404 fn populate_preopens(&mut self, entries: &[(Resource<types::Descriptor>, String)]) {
407 for (res, guest_path) in entries {
408 if self.fd_paths.by_rep.contains_key(&res.rep()) {
409 continue;
410 }
411 let Some(host) = self
412 .fd_paths
413 .preopens
414 .iter()
415 .find(|(g, _)| g == guest_path)
416 .map(|(_, h)| h.clone())
417 else {
418 continue;
419 };
420 self.fd_paths.by_rep.insert(res.rep(), host);
421 }
422 }
423}
424
425impl preopens::Host for PolicyFilesystemCtxView<'_> {
428 fn get_directories(&mut self) -> wasmtime::Result<Vec<(Resource<types::Descriptor>, String)>> {
429 let entries = self.inner().get_directories()?;
430 self.populate_preopens(&entries);
431 Ok(entries)
432 }
433}
434
435impl types::Host for PolicyFilesystemCtxView<'_> {
438 fn convert_error_code(&mut self, err: FsError) -> wasmtime::Result<ErrorCode> {
439 self.inner().convert_error_code(err)
440 }
441 fn filesystem_error_code(
442 &mut self,
443 err: Resource<wasmtime::Error>,
444 ) -> wasmtime::Result<Option<ErrorCode>> {
445 self.inner().filesystem_error_code(err)
446 }
447}
448
449impl HostDescriptor for PolicyFilesystemCtxView<'_> {
457 async fn advise(
458 &mut self,
459 fd: Resource<types::Descriptor>,
460 offset: types::Filesize,
461 len: types::Filesize,
462 advice: types::Advice,
463 ) -> FsResult<()> {
464 self.inner().advise(fd, offset, len, advice).await
465 }
466
467 async fn sync_data(&mut self, fd: Resource<types::Descriptor>) -> FsResult<()> {
468 self.inner().sync_data(fd).await
469 }
470
471 async fn get_flags(
472 &mut self,
473 fd: Resource<types::Descriptor>,
474 ) -> FsResult<types::DescriptorFlags> {
475 self.inner().get_flags(fd).await
476 }
477
478 async fn get_type(
479 &mut self,
480 fd: Resource<types::Descriptor>,
481 ) -> FsResult<types::DescriptorType> {
482 self.inner().get_type(fd).await
483 }
484
485 async fn set_size(
486 &mut self,
487 fd: Resource<types::Descriptor>,
488 size: types::Filesize,
489 ) -> FsResult<()> {
490 self.inner().set_size(fd, size).await
491 }
492
493 async fn set_times(
494 &mut self,
495 fd: Resource<types::Descriptor>,
496 atim: types::NewTimestamp,
497 mtim: types::NewTimestamp,
498 ) -> FsResult<()> {
499 self.inner().set_times(fd, atim, mtim).await
500 }
501
502 async fn read(
503 &mut self,
504 fd: Resource<types::Descriptor>,
505 len: types::Filesize,
506 offset: types::Filesize,
507 ) -> FsResult<(Vec<u8>, bool)> {
508 self.inner().read(fd, len, offset).await
509 }
510
511 async fn write(
512 &mut self,
513 fd: Resource<types::Descriptor>,
514 buf: Vec<u8>,
515 offset: types::Filesize,
516 ) -> FsResult<types::Filesize> {
517 self.inner().write(fd, buf, offset).await
518 }
519
520 async fn read_directory(
521 &mut self,
522 fd: Resource<types::Descriptor>,
523 ) -> FsResult<Resource<types::DirectoryEntryStream>> {
524 self.inner().read_directory(fd).await
525 }
526
527 async fn sync(&mut self, fd: Resource<types::Descriptor>) -> FsResult<()> {
528 self.inner().sync(fd).await
529 }
530
531 async fn create_directory_at(
532 &mut self,
533 fd: Resource<types::Descriptor>,
534 path: String,
535 ) -> FsResult<()> {
536 let _checked = self.check_path(&fd, &path, FsAccess::Write).await?;
537 self.inner().create_directory_at(fd, path).await
538 }
539
540 async fn stat(&mut self, fd: Resource<types::Descriptor>) -> FsResult<types::DescriptorStat> {
541 self.inner().stat(fd).await
542 }
543
544 async fn stat_at(
545 &mut self,
546 fd: Resource<types::Descriptor>,
547 path_flags: types::PathFlags,
548 path: String,
549 ) -> FsResult<types::DescriptorStat> {
550 let _checked = self.check_path(&fd, &path, FsAccess::Read).await?;
551 self.inner().stat_at(fd, path_flags, path).await
552 }
553
554 async fn set_times_at(
555 &mut self,
556 fd: Resource<types::Descriptor>,
557 path_flags: types::PathFlags,
558 path: String,
559 atim: types::NewTimestamp,
560 mtim: types::NewTimestamp,
561 ) -> FsResult<()> {
562 let _checked = self.check_path(&fd, &path, FsAccess::Write).await?;
563 self.inner()
564 .set_times_at(fd, path_flags, path, atim, mtim)
565 .await
566 }
567
568 async fn link_at(
569 &mut self,
570 fd: Resource<types::Descriptor>,
571 old_path_flags: types::PathFlags,
572 old_path: String,
573 new_descriptor: Resource<types::Descriptor>,
574 new_path: String,
575 ) -> FsResult<()> {
576 let _old = self.check_path(&fd, &old_path, FsAccess::Read).await?;
577 let _new = self
578 .check_path(&new_descriptor, &new_path, FsAccess::Write)
579 .await?;
580 self.inner()
581 .link_at(fd, old_path_flags, old_path, new_descriptor, new_path)
582 .await
583 }
584
585 async fn open_at(
586 &mut self,
587 fd: Resource<types::Descriptor>,
588 path_flags: types::PathFlags,
589 path: String,
590 oflags: types::OpenFlags,
591 flags: types::DescriptorFlags,
592 ) -> FsResult<Resource<types::Descriptor>> {
593 let access = if flags.contains(types::DescriptorFlags::WRITE)
594 || flags.contains(types::DescriptorFlags::MUTATE_DIRECTORY)
595 || oflags.contains(types::OpenFlags::CREATE)
596 || oflags.contains(types::OpenFlags::TRUNCATE)
597 || oflags.contains(types::OpenFlags::EXCLUSIVE)
598 {
599 FsAccess::Write
600 } else {
601 FsAccess::Read
602 };
603 let canonical = self.check_path(&fd, &path, access).await?;
604 let new_fd = self
605 .inner()
606 .open_at(fd, path_flags, path, oflags, flags)
607 .await?;
608 self.fd_paths.by_rep.insert(new_fd.rep(), canonical);
609 Ok(new_fd)
610 }
611
612 fn drop(&mut self, fd: Resource<types::Descriptor>) -> wasmtime::Result<()> {
613 self.fd_paths.by_rep.remove(&fd.rep());
614 HostDescriptor::drop(&mut self.inner(), fd)
615 }
616
617 async fn readlink_at(
618 &mut self,
619 fd: Resource<types::Descriptor>,
620 path: String,
621 ) -> FsResult<String> {
622 let _checked = self.check_path(&fd, &path, FsAccess::Read).await?;
623 self.inner().readlink_at(fd, path).await
624 }
625
626 async fn remove_directory_at(
627 &mut self,
628 fd: Resource<types::Descriptor>,
629 path: String,
630 ) -> FsResult<()> {
631 let _checked = self.check_path(&fd, &path, FsAccess::Write).await?;
632 self.inner().remove_directory_at(fd, path).await
633 }
634
635 async fn rename_at(
636 &mut self,
637 fd: Resource<types::Descriptor>,
638 old_path: String,
639 new_fd: Resource<types::Descriptor>,
640 new_path: String,
641 ) -> FsResult<()> {
642 let _old = self.check_path(&fd, &old_path, FsAccess::Write).await?;
643 let _new = self.check_path(&new_fd, &new_path, FsAccess::Write).await?;
644 self.inner().rename_at(fd, old_path, new_fd, new_path).await
645 }
646
647 async fn symlink_at(
648 &mut self,
649 fd: Resource<types::Descriptor>,
650 src_path: String,
651 dest_path: String,
652 ) -> FsResult<()> {
653 let _checked = self.check_path(&fd, &dest_path, FsAccess::Write).await?;
654 self.inner().symlink_at(fd, src_path, dest_path).await
655 }
656
657 async fn unlink_file_at(
658 &mut self,
659 fd: Resource<types::Descriptor>,
660 path: String,
661 ) -> FsResult<()> {
662 let _checked = self.check_path(&fd, &path, FsAccess::Write).await?;
663 self.inner().unlink_file_at(fd, path).await
664 }
665
666 fn read_via_stream(
667 &mut self,
668 fd: Resource<types::Descriptor>,
669 offset: types::Filesize,
670 ) -> FsResult<Resource<DynInputStream>> {
671 self.inner().read_via_stream(fd, offset)
672 }
673
674 fn write_via_stream(
675 &mut self,
676 fd: Resource<types::Descriptor>,
677 offset: types::Filesize,
678 ) -> FsResult<Resource<DynOutputStream>> {
679 self.inner().write_via_stream(fd, offset)
680 }
681
682 fn append_via_stream(
683 &mut self,
684 fd: Resource<types::Descriptor>,
685 ) -> FsResult<Resource<DynOutputStream>> {
686 self.inner().append_via_stream(fd)
687 }
688
689 async fn is_same_object(
690 &mut self,
691 a: Resource<types::Descriptor>,
692 b: Resource<types::Descriptor>,
693 ) -> wasmtime::Result<bool> {
694 self.inner().is_same_object(a, b).await
695 }
696
697 async fn metadata_hash(
698 &mut self,
699 fd: Resource<types::Descriptor>,
700 ) -> FsResult<types::MetadataHashValue> {
701 self.inner().metadata_hash(fd).await
702 }
703
704 async fn metadata_hash_at(
705 &mut self,
706 fd: Resource<types::Descriptor>,
707 path_flags: types::PathFlags,
708 path: String,
709 ) -> FsResult<types::MetadataHashValue> {
710 let _checked = self.check_path(&fd, &path, FsAccess::Read).await?;
711 self.inner().metadata_hash_at(fd, path_flags, path).await
712 }
713}
714
715impl wasmtime_wasi::p3::bindings::filesystem::preopens::Host for PolicyFilesystemCtxView<'_> {
730 fn get_directories(
731 &mut self,
732 ) -> wasmtime::Result<
733 Vec<(
734 Resource<wasmtime_wasi::p3::bindings::filesystem::types::Descriptor>,
735 String,
736 )>,
737 > {
738 if self.mode != PolicyMode::Open {
739 tracing::warn!(
740 mode = ?self.mode,
741 "p3 wasi:filesystem/preopens: returning empty; p3 path ops can't be matcher-gated",
742 );
743 return Ok(vec![]);
744 }
745 let mut inner = WasiFilesystemCtxView {
746 ctx: self.ctx,
747 table: self.table,
748 };
749 <WasiFilesystemCtxView as wasmtime_wasi::p3::bindings::filesystem::preopens::Host>::get_directories(&mut inner)
750 }
751}
752
753impl HostDirectoryEntryStream for PolicyFilesystemCtxView<'_> {
756 async fn read_directory_entry(
757 &mut self,
758 stream: Resource<types::DirectoryEntryStream>,
759 ) -> FsResult<Option<types::DirectoryEntry>> {
760 self.inner().read_directory_entry(stream).await
761 }
762
763 fn drop(&mut self, stream: Resource<types::DirectoryEntryStream>) -> wasmtime::Result<()> {
764 HostDirectoryEntryStream::drop(&mut self.inner(), stream)
765 }
766}
767
768#[cfg(test)]
769mod mount_tests {
770 use super::*;
771 use act_policy::grant::PolicyMode;
772 use act_types::{Capabilities, CapabilityRequest, MountType};
773 use std::collections::BTreeMap;
774
775 fn caps_with_mounts(mounts: serde_json::Value) -> Capabilities {
776 let mut caps = Capabilities::default();
777 let mut params = BTreeMap::new();
778 params.insert("mounts".to_string(), mounts);
779 caps.0.insert(
780 "wasi:filesystem".into(),
781 CapabilityRequest {
782 params,
783 ..Default::default()
784 },
785 );
786 caps
787 }
788
789 #[test]
790 fn deny_mode_yields_no_mounts() {
791 let caps = caps_with_mounts(serde_json::json!([{ "guest": "/ows", "host": "/tmp/x" }]));
792 assert!(resolve_mounts(&caps, PolicyMode::Deny).is_empty());
793 }
794
795 #[test]
796 fn bind_only_component_gets_just_the_bind_preopen() {
797 let caps = caps_with_mounts(serde_json::json!([{ "guest": "/ows", "host": "/tmp/x" }]));
798 let mounts = resolve_mounts(&caps, PolicyMode::Ask);
799 let pre = derive_preopens(&mounts);
800 assert_eq!(pre.len(), 1);
801 assert_eq!(pre[0].guest, "/ows");
802 assert_eq!(pre[0].host, std::path::PathBuf::from("/tmp/x"));
803 }
804
805 #[test]
806 fn no_mounts_declared_defaults_to_root() {
807 let caps = Capabilities::default();
808 let mounts = resolve_mounts(&caps, PolicyMode::Allowlist);
809 assert_eq!(mounts.len(), 1);
810 assert_eq!(mounts[0].kind, MountType::Root);
811 assert_eq!(mounts[0].guest, "/");
812 }
813
814 #[cfg(unix)]
815 #[test]
816 fn root_mount_preopens_the_filesystem_root() {
817 let caps = caps_with_mounts(serde_json::json!([{ "type": "root", "guest": "/" }]));
818 let pre = derive_preopens(&resolve_mounts(&caps, PolicyMode::Open));
819 assert_eq!(pre.len(), 1);
820 assert_eq!(pre[0].guest, "/");
821 assert_eq!(pre[0].host, std::path::PathBuf::from("/"));
822 }
823
824 #[test]
825 fn mount_root_sugar_becomes_a_root_mount() {
826 let mut caps = Capabilities::default();
827 let mut params = BTreeMap::new();
828 params.insert("mount-root".to_string(), serde_json::json!("/data"));
829 caps.0.insert(
830 "wasi:filesystem".into(),
831 CapabilityRequest {
832 params,
833 ..Default::default()
834 },
835 );
836 let mounts = resolve_mounts(&caps, PolicyMode::Allowlist);
837 assert_eq!(mounts.len(), 1);
838 assert_eq!(mounts[0].kind, MountType::Root);
839 assert_eq!(mounts[0].guest, "/data");
840 }
841
842 #[test]
843 fn mount_root_slash_is_noop_with_binds() {
844 let mut caps = Capabilities::default();
847 let mut params = BTreeMap::new();
848 params.insert(
849 "mounts".to_string(),
850 serde_json::json!([{ "guest": "/ows", "host": "/tmp/x" }]),
851 );
852 params.insert("mount-root".to_string(), serde_json::json!("/"));
853 caps.0.insert(
854 "wasi:filesystem".into(),
855 CapabilityRequest {
856 params,
857 ..Default::default()
858 },
859 );
860 let mounts = resolve_mounts(&caps, PolicyMode::Ask);
861 assert_eq!(mounts.len(), 1);
862 assert_eq!(mounts[0].kind, MountType::Bind);
863 assert_eq!(mounts[0].guest, "/ows");
864 }
865
866 #[test]
867 fn explicit_root_suppresses_mount_root_sugar() {
868 let mut caps = Capabilities::default();
871 let mut params = BTreeMap::new();
872 params.insert(
873 "mounts".to_string(),
874 serde_json::json!([{ "type": "root", "guest": "/x" }]),
875 );
876 params.insert("mount-root".to_string(), serde_json::json!("/data"));
877 caps.0.insert(
878 "wasi:filesystem".into(),
879 CapabilityRequest {
880 params,
881 ..Default::default()
882 },
883 );
884 let mounts = resolve_mounts(&caps, PolicyMode::Allowlist);
885 assert_eq!(mounts.len(), 1);
886 assert_eq!(mounts[0].kind, MountType::Root);
887 assert_eq!(mounts[0].guest, "/x");
888 }
889
890 #[test]
891 fn tilde_in_bind_host_is_expanded() {
892 let caps = caps_with_mounts(serde_json::json!([{ "guest": "/ows", "host": "~/.ows" }]));
893 let mounts = resolve_mounts(&caps, PolicyMode::Ask);
894 let host = mounts[0].host.clone().unwrap();
895 assert!(host.is_absolute());
896 assert!(!host.to_string_lossy().starts_with('~'));
897 }
898
899 #[test]
900 fn create_mount_dirs_makes_bind_targets() {
901 let tmp = std::env::temp_dir().join(format!("act-mount-test-{}", std::process::id()));
902 let target = tmp.join("nested");
903 let mounts = vec![ResolvedMount {
904 kind: MountType::Bind,
905 guest: "/d".into(),
906 host: Some(target.clone()),
907 }];
908 create_mount_dirs(&mounts).unwrap();
909 assert!(target.is_dir());
910 std::fs::remove_dir_all(&tmp).ok();
911 }
912}
913
914#[cfg(test)]
915mod tests {
916 #[test]
917 fn fs_records_carry_the_matched_rule_and_a_reason_on_deny() {
918 let allow = crate::audit::CapDecisionRecord::statik(
920 act_types::constants::CAP_FILESYSTEM,
921 "/data/app.db",
922 "read",
923 crate::audit::Decision4::Allow,
924 "allowlist",
925 Some("/data/**".into()),
926 );
927 assert_eq!(allow.cap_id, act_types::constants::CAP_FILESYSTEM);
928 assert_eq!(allow.rule.as_deref(), Some("/data/**"));
929 assert!(allow.reason.is_none());
930
931 let deny = crate::audit::CapDecisionRecord::statik(
932 act_types::constants::CAP_FILESYSTEM,
933 "/etc/passwd",
934 "read",
935 crate::audit::Decision4::Deny,
936 "allowlist",
937 None,
938 );
939 assert_eq!(deny.reason.as_deref(), Some("outside ceiling"));
940 assert_eq!(deny.actor, crate::audit::record::Actor::Static);
941 }
942
943 #[test]
944 fn ask_resolution_attributes_the_decision_to_the_user() {
945 let r = crate::audit::CapDecisionRecord::answered(
946 act_types::constants::CAP_FILESYSTEM,
947 "/home/u/.ssh/id_ed25519",
948 false,
949 true,
950 );
951 assert_eq!(r.decision, crate::audit::Decision4::AskDeny);
952 assert_eq!(r.actor, crate::audit::record::Actor::User);
953 assert_eq!(r.reason.as_deref(), Some("denied by user"));
954
955 let r = crate::audit::CapDecisionRecord::answered(
956 act_types::constants::CAP_FILESYSTEM,
957 "/home/u/notes.txt",
958 true,
959 true,
960 );
961 assert_eq!(r.decision, crate::audit::Decision4::AskAllow);
962 assert_eq!(r.actor, crate::audit::record::Actor::User);
963 }
964
965 #[test]
966 fn ask_resolution_with_no_channel_is_not_attributed_to_the_user() {
967 let r = crate::audit::CapDecisionRecord::answered(
973 act_types::constants::CAP_FILESYSTEM,
974 "/home/u/.ssh/id_ed25519",
975 false,
976 false,
977 );
978 assert_eq!(r.decision, crate::audit::Decision4::AskDeny);
979 assert_ne!(r.actor, crate::audit::record::Actor::User);
980 assert_eq!(r.reason.as_deref(), Some("no prompt channel"));
981 }
982}
983
984#[cfg(test)]
985mod policy_tests {
986 use act_policy::Decision;
987 use act_policy::fs_matcher::{FsAccess, FsMatcher};
988 use act_policy::grant::{FsAllow, FsConfig, PolicyMode};
989 use act_types::FsMode;
990 use std::path::Path;
991
992 #[test]
993 fn ro_matcher_blocks_write_allows_read() {
994 let cfg = FsConfig {
995 mode: PolicyMode::Allowlist,
996 allow: vec![FsAllow {
997 glob: "/data/**".into(),
998 mode: FsMode::Ro,
999 }],
1000 deny: vec![],
1001 };
1002 let matcher = FsMatcher::compile(&cfg).unwrap();
1003 assert_eq!(
1004 matcher.decide(Path::new("/data/x.db"), FsAccess::Read),
1005 Decision::Allow
1006 );
1007 assert_eq!(
1008 matcher.decide(Path::new("/data/x.db"), FsAccess::Write),
1009 Decision::Deny
1010 );
1011 }
1012}