1use std::path::{Path, PathBuf};
18
19use serde::Serialize;
20
21use crate::host_paths::{self, DeclaredHostPath, HostPath, HostPathAccess, HostPathContext};
22use crate::process_driver::{Description, HarnessNeeds, PathAccess};
23use crate::types::CapabilityPolicy;
24
25pub struct HarnessGrants {
34 pub report: HarnessReport,
35 pub granted_paths: Vec<HostPath>,
39 pub refusal: Option<crate::errors::RuntimeError>,
42}
43
44#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
48pub struct HarnessReport {
49 pub driver: String,
51 pub binary: Option<HarnessBinaryReport>,
54 pub paths: Vec<HarnessPathReport>,
56 pub hosts: Vec<HarnessHostReport>,
58 pub env: Vec<HarnessEnvReport>,
60 pub unresolved: Option<String>,
63}
64
65#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
67pub struct HarnessBinaryReport {
68 pub path: String,
69 pub interpreter: Option<String>,
72}
73
74#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
76pub struct HarnessPathReport {
77 pub need: String,
79 pub path: Option<String>,
81 pub access: &'static str,
83 pub allowed_by: Option<String>,
86 pub status: &'static str,
89 pub reason: Option<String>,
91}
92
93#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
95pub struct HarnessHostReport {
96 pub need: String,
98 pub allowed_by: Option<String>,
100 pub status: &'static str,
102}
103
104#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
106pub struct HarnessEnvReport {
107 pub name: String,
108 pub status: &'static str,
110}
111
112const STATUS_GRANTED: &str = "granted";
113const STATUS_MISSING: &str = "missing";
114const STATUS_REFUSED: &str = "refused";
115const STATUS_ALLOWED: &str = "allowed";
116
117#[derive(Debug, Clone)]
119pub struct HarnessBinaryInfo {
120 pub path: PathBuf,
121 pub interpreter: Option<PathBuf>,
123 pub launcher: Option<PathBuf>,
126}
127
128impl HarnessBinaryInfo {
129 #[must_use]
131 pub fn read(binary: &Path) -> Self {
132 let shebang = read_shebang(binary, &harness_path_env());
133 let (launcher, interpreter) = match shebang {
134 Some(shebang) => (shebang.launcher, Some(shebang.interpreter)),
135 None => (None, None),
136 };
137 Self {
138 path: binary.to_path_buf(),
139 interpreter,
140 launcher,
141 }
142 }
143
144 #[must_use]
148 pub fn exec_files(&self) -> Vec<PathBuf> {
149 std::iter::once(self.path.clone())
150 .chain(self.launcher.clone())
151 .chain(self.interpreter.clone())
152 .collect()
153 }
154}
155
156pub(crate) fn harness_path_env() -> String {
160 std::env::var("PATH").unwrap_or_else(|_| "/usr/bin:/bin".to_string())
161}
162
163pub(crate) struct ResolvedHarness {
166 pub(crate) grants: HarnessGrants,
167 pub(crate) binary: Option<HarnessBinaryInfo>,
169}
170
171pub(crate) fn resolve_capsule_harness(
176 driver_ref: &str,
177 description: &Description,
178 binary: Option<&Path>,
179 policy: &CapabilityPolicy,
180 workdir: &Path,
181) -> ResolvedHarness {
182 let binary = binary.map(HarnessBinaryInfo::read);
183 let home = crate::murmur_home::home_path().ok();
184 let murmur_home = home.as_ref().map(|home| home.join(".murmur"));
185 let context = HostPathContext {
186 home: home.as_deref(),
187 murmur_home: murmur_home.as_deref(),
188 workdir,
189 };
190 let grants = resolve_harness_grants(
191 driver_ref,
192 &description.harness_needs,
193 &policy.harness_paths,
194 &policy.network_allow,
195 &description.required_env,
196 &policy.env_allow,
197 binary.as_ref(),
198 &context,
199 );
200 ResolvedHarness { grants, binary }
201}
202
203pub(crate) fn validate_harness_needs(needs: &HarnessNeeds) -> Result<(), String> {
207 for need in &needs.paths {
208 validate_need_path(&need.path)?;
209 }
210 for host in &needs.hosts {
211 crate::network_policy::NetworkAllowRule::parse(host).map_err(|error| {
212 format!("names the host need {host:?}, which is not a capabilities.network.allow entry: {error}")
213 })?;
214 }
215 Ok(())
216}
217
218fn validate_need_path(path: &str) -> Result<(), String> {
222 if path.is_empty() {
223 return Err(format!("names the path need {path:?}, which is empty"));
224 }
225 match murmur_artifact::harness_path_shape_error(path) {
226 Some(reason) => Err(format!("names the path need {path:?}, which {reason}")),
227 None => Ok(()),
228 }
229}
230
231#[allow(clippy::too_many_arguments)]
240pub fn resolve_harness_grants(
241 driver_ref: &str,
242 needs: &HarnessNeeds,
243 manifest_paths: &[murmur_artifact::HarnessPathEntry],
244 network_allow: &[String],
245 required_env: &[String],
246 env_allow: &[String],
247 binary: Option<&HarnessBinaryInfo>,
248 context: &HostPathContext<'_>,
249) -> HarnessGrants {
250 let mut refusals: Vec<String> = Vec::new();
251 let mut granted_paths: Vec<HostPath> = Vec::new();
252
253 let home = context.home;
256 let manifest = fold_manifest_paths(manifest_paths, home);
257
258 let mut path_reports = Vec::with_capacity(needs.paths.len());
259 for need in &needs.paths {
260 let access = host_path_access(need.access);
261 let folded = match host_paths::expand_and_fold(&need.path, home) {
262 Ok(folded) => folded,
263 Err(reason) => {
266 refusals.push(format!("{} ({}): {reason}", need.path, access.wire_name()));
267 path_reports.push(HarnessPathReport {
268 need: need.path.clone(),
269 path: None,
270 access: access.wire_name(),
271 allowed_by: None,
272 status: STATUS_REFUSED,
273 reason: Some(reason),
274 });
275 continue;
276 }
277 };
278 match allow_path(&manifest, &folded, access) {
279 PathAllow::Allowed {
280 allowed_by,
281 entry_path,
282 } => {
283 let declared = DeclaredHostPath {
285 index: 0,
286 declared: need.path.clone(),
287 expanded: folded,
288 access,
289 };
290 let resolved =
291 host_paths::resolve_host_paths(std::slice::from_ref(&declared), context)
292 .and_then(|resolved| {
293 let host_path = resolved.into_iter().next().expect("one in, one out");
294 escapes_allowing_entry(&host_path, &entry_path, &allowed_by)
295 .map_or(Ok(host_path), Err)
296 });
297 match resolved {
298 Ok(host_path) => {
299 path_reports.push(HarnessPathReport {
300 need: need.path.clone(),
301 path: Some(host_path.path.display().to_string()),
302 access: access.wire_name(),
303 allowed_by: Some(allowed_by),
304 status: STATUS_GRANTED,
305 reason: None,
306 });
307 granted_paths.push(host_path);
308 }
309 Err(refusal) if refusal.missing => {
310 path_reports.push(HarnessPathReport {
312 need: need.path.clone(),
313 path: None,
314 access: access.wire_name(),
315 allowed_by: Some(allowed_by),
316 status: STATUS_MISSING,
317 reason: None,
318 });
319 }
320 Err(refusal) => {
321 let reason = refusal.reason.clone();
322 refusals.push(format!("{} ({}): {reason}", need.path, access.wire_name()));
323 path_reports.push(HarnessPathReport {
324 need: need.path.clone(),
325 path: refusal.resolved.map(|p| p.display().to_string()),
326 access: access.wire_name(),
327 allowed_by: Some(allowed_by),
328 status: STATUS_REFUSED,
329 reason: Some(reason),
330 });
331 }
332 }
333 }
334 PathAllow::NotAllowed { reason } => {
335 refusals.push(format!("{} ({}): {reason}", need.path, access.wire_name()));
336 path_reports.push(HarnessPathReport {
337 need: need.path.clone(),
338 path: None,
339 access: access.wire_name(),
340 allowed_by: None,
341 status: STATUS_REFUSED,
342 reason: Some(reason),
343 });
344 }
345 }
346 }
347
348 let network_rules =
349 crate::network_policy::parse_network_allow_rules(network_allow).unwrap_or_default();
350 let mut host_reports = Vec::with_capacity(needs.hosts.len());
351 for host in &needs.hosts {
352 match allow_host(host, network_allow, &network_rules) {
353 Some(allowed_by) => host_reports.push(HarnessHostReport {
354 need: host.clone(),
355 allowed_by: Some(allowed_by),
356 status: STATUS_ALLOWED,
357 }),
358 None => {
359 refusals.push(format!(
360 "{host}: no capabilities.network.allow entry admits it"
361 ));
362 host_reports.push(HarnessHostReport {
363 need: host.clone(),
364 allowed_by: None,
365 status: STATUS_REFUSED,
366 });
367 }
368 }
369 }
370
371 let env = required_env
372 .iter()
373 .map(|name| HarnessEnvReport {
374 name: name.clone(),
375 status: if env_allow.iter().any(|allowed| allowed == name) {
376 STATUS_ALLOWED
377 } else {
378 STATUS_REFUSED
379 },
380 })
381 .collect();
382
383 let report = HarnessReport {
384 driver: driver_ref.to_string(),
385 binary: binary.map(|info| HarnessBinaryReport {
386 path: info.path.display().to_string(),
387 interpreter: info.interpreter.as_ref().map(|p| p.display().to_string()),
388 }),
389 paths: path_reports,
390 hosts: host_reports,
391 env,
392 unresolved: None,
393 };
394
395 let refusal =
396 (!refusals.is_empty()).then(|| crate::errors::RuntimeError::HarnessNeedsNotAllowed {
397 driver: driver_ref.to_string(),
398 refusals,
399 });
400
401 HarnessGrants {
402 report,
403 granted_paths,
404 refusal,
405 }
406}
407
408#[must_use]
411pub fn unresolved_report(driver_ref: &str, reason: String) -> HarnessReport {
412 HarnessReport {
413 driver: driver_ref.to_string(),
414 binary: None,
415 paths: Vec::new(),
416 hosts: Vec::new(),
417 env: Vec::new(),
418 unresolved: Some(reason),
419 }
420}
421
422#[must_use]
427pub fn render_harness_block(report: &HarnessReport) -> String {
428 let mut out = format!("Process harness ({})\n", report.driver);
429 if let Some(reason) = &report.unresolved {
430 out.push_str(&format!(" driver not read: {reason}\n"));
431 return out;
432 }
433 match &report.binary {
434 Some(binary) => {
435 out.push_str(&format!(" binary {}\n", binary.path));
436 if let Some(interpreter) = &binary.interpreter {
437 out.push_str(&format!(" interpreter {interpreter}\n"));
438 }
439 }
440 None => out.push_str(" binary <unresolved>\n"),
441 }
442 for path in &report.paths {
443 let line = match path.status {
444 STATUS_GRANTED => format!(
445 " path {} → {} ({}), allowed by {}",
446 path.need,
447 path.path.as_deref().unwrap_or("?"),
448 path.access,
449 path.allowed_by.as_deref().unwrap_or("?"),
450 ),
451 STATUS_MISSING => format!(
452 " path {} ({}): missing on this host, allowed by {}",
453 path.need,
454 path.access,
455 path.allowed_by.as_deref().unwrap_or("?"),
456 ),
457 _ => format!(
458 " path {} ({}): refused — {}",
459 path.need,
460 path.access,
461 path.reason.as_deref().unwrap_or(""),
462 ),
463 };
464 out.push_str(&line);
465 out.push('\n');
466 }
467 for host in &report.hosts {
468 let line = match host.status {
469 STATUS_ALLOWED => format!(
470 " host {}, allowed by {}",
471 host.need,
472 host.allowed_by.as_deref().unwrap_or("?"),
473 ),
474 _ => format!(" host {}: refused", host.need),
475 };
476 out.push_str(&line);
477 out.push('\n');
478 }
479 for env in &report.env {
480 out.push_str(&format!(" env {} ({})\n", env.name, env.status));
481 }
482 out
483}
484
485struct FoldedManifestPath {
487 declared: String,
488 expanded: PathBuf,
489 access: HostPathAccess,
490}
491
492fn fold_manifest_paths(
493 entries: &[murmur_artifact::HarnessPathEntry],
494 home: Option<&Path>,
495) -> Vec<FoldedManifestPath> {
496 entries
497 .iter()
498 .filter_map(|entry| {
499 let expanded = host_paths::expand_and_fold(&entry.path, home).ok()?;
500 Some(FoldedManifestPath {
501 declared: entry.path.clone(),
502 expanded,
503 access: manifest_access(entry.access),
504 })
505 })
506 .collect()
507}
508
509enum PathAllow {
510 Allowed {
512 allowed_by: String,
513 entry_path: PathBuf,
514 },
515 NotAllowed {
516 reason: String,
517 },
518}
519
520fn escapes_allowing_entry(
525 host_path: &HostPath,
526 entry_path: &Path,
527 allowed_by: &str,
528) -> Option<host_paths::HostPathRefusal> {
529 let refuse = |reason: String| host_paths::HostPathRefusal {
530 index: 0,
531 declared: host_path.declared.clone(),
532 resolved: Some(host_path.path.clone()),
533 reason,
534 missing: false,
535 };
536 match std::fs::canonicalize(entry_path) {
537 Ok(entry) if host_path.path.starts_with(&entry) => None,
538 Ok(entry) => Some(refuse(format!(
539 "resolves to {}, outside {} where the capabilities.harness.paths entry {allowed_by:?} \
540 that allows it resolves; a symlink on the way leads out of the granted folder",
541 host_path.path.display(),
542 entry.display(),
543 ))),
544 Err(error) => Some(refuse(format!(
545 "the capabilities.harness.paths entry {allowed_by:?} that allows it cannot be \
546 resolved on this host: {error}"
547 ))),
548 }
549}
550
551fn allow_path(
555 manifest: &[FoldedManifestPath],
556 folded: &Path,
557 need_access: HostPathAccess,
558) -> PathAllow {
559 let mut shortfall: Option<&FoldedManifestPath> = None;
560 for entry in manifest {
561 if folded == entry.expanded || folded.starts_with(&entry.expanded) {
562 if access_at_least(entry.access, need_access) {
563 return PathAllow::Allowed {
564 allowed_by: entry.declared.clone(),
565 entry_path: entry.expanded.clone(),
566 };
567 }
568 shortfall.get_or_insert(entry);
569 }
570 }
571 let reason = match shortfall {
572 Some(entry) => format!(
573 "capabilities.harness.paths entry {:?} covers it at access {}, lower than the need's \
574 {}",
575 entry.declared,
576 entry.access.wire_name(),
577 need_access.wire_name()
578 ),
579 None => "no capabilities.harness.paths entry names it or an ancestor of it".to_string(),
580 };
581 PathAllow::NotAllowed { reason }
582}
583
584fn access_at_least(have: HostPathAccess, want: HostPathAccess) -> bool {
586 matches!(
587 (have, want),
588 (HostPathAccess::ReadWrite, _) | (HostPathAccess::Read, HostPathAccess::Read)
589 )
590}
591
592fn allow_host(
596 need: &str,
597 network_allow: &[String],
598 rules: &[crate::network_policy::NetworkAllowRule],
599) -> Option<String> {
600 let need_rule = crate::network_policy::NetworkAllowRule::parse(need).ok()?;
601 let need_ports: Vec<u16> = crate::egress_proxy::implied_tcp_ports(&need_rule).collect();
603 for (rule, declared) in rules.iter().zip(network_allow.iter()) {
604 if rule.host != need_rule.host {
605 continue;
606 }
607 let covers_every_port = need_ports.iter().all(|&port| {
608 crate::egress_proxy::implied_tcp_ports(rule).any(|allowed| allowed == port)
609 });
610 if covers_every_port {
611 return Some(declared.clone());
612 }
613 }
614 None
615}
616
617fn host_path_access(access: PathAccess) -> HostPathAccess {
618 match access {
619 PathAccess::Read => HostPathAccess::Read,
620 PathAccess::ReadWrite => HostPathAccess::ReadWrite,
621 }
622}
623
624fn manifest_access(access: murmur_artifact::HarnessPathAccess) -> HostPathAccess {
625 match access {
626 murmur_artifact::HarnessPathAccess::Read => HostPathAccess::Read,
627 murmur_artifact::HarnessPathAccess::ReadWrite => HostPathAccess::ReadWrite,
628 }
629}
630
631struct Shebang {
634 launcher: Option<PathBuf>,
635 interpreter: PathBuf,
636}
637
638const SHEBANG_MAX: u64 = 256;
640
641fn read_shebang(binary: &Path, path_env: &str) -> Option<Shebang> {
645 use std::io::Read;
646 let mut head = Vec::with_capacity(SHEBANG_MAX as usize);
647 std::fs::File::open(binary)
648 .ok()?
649 .take(SHEBANG_MAX)
650 .read_to_end(&mut head)
651 .ok()?;
652 let line = head.strip_prefix(b"#!")?.split(|&b| b == b'\n').next()?;
653 let line = std::str::from_utf8(line).ok()?;
654 let mut parts = line.split_whitespace();
655 let first = parts.next()?;
656 let program = std::fs::canonicalize(first).ok()?;
657 if Path::new(first).file_name().and_then(|n| n.to_str()) == Some("env") {
658 let name = parts.next()?;
659 return Some(Shebang {
660 launcher: Some(program),
661 interpreter: resolve_on_path(name, path_env)?,
662 });
663 }
664 Some(Shebang {
665 launcher: None,
666 interpreter: program,
667 })
668}
669
670fn resolve_on_path(name: &str, path_env: &str) -> Option<PathBuf> {
672 if name.contains('/') {
673 return std::fs::canonicalize(name).ok();
674 }
675 for dir in path_env.split(':').filter(|d| !d.is_empty()) {
676 let candidate = Path::new(dir).join(name);
677 if candidate.is_file() {
678 return std::fs::canonicalize(&candidate).ok();
679 }
680 }
681 None
682}
683
684#[cfg(test)]
685mod tests {
686 use super::*;
687 use crate::process_driver::PathNeed;
688 use murmur_artifact::{HarnessPathAccess, HarnessPathEntry};
689
690 fn needs(paths: &[(&str, PathAccess)], hosts: &[&str]) -> HarnessNeeds {
691 HarnessNeeds {
692 paths: paths
693 .iter()
694 .map(|(path, access)| PathNeed {
695 path: (*path).to_string(),
696 access: *access,
697 })
698 .collect(),
699 hosts: hosts.iter().map(|host| (*host).to_string()).collect(),
700 }
701 }
702
703 fn grant(path: &str, access: HarnessPathAccess) -> HarnessPathEntry {
704 HarnessPathEntry {
705 path: path.to_string(),
706 access,
707 }
708 }
709
710 struct Host {
713 _root: tempfile::TempDir,
714 home: PathBuf,
715 murmur_home: PathBuf,
716 workdir: PathBuf,
717 }
718
719 impl Host {
720 fn new() -> Self {
721 let root =
722 tempfile::tempdir_in(Path::new(env!("CARGO_MANIFEST_DIR")).join("../../target"))
723 .unwrap();
724 let home = std::fs::canonicalize(root.path()).unwrap().join("home");
725 let murmur_home = home.join(".murmur");
726 let workdir = home.join("project");
727 std::fs::create_dir_all(&murmur_home).unwrap();
728 std::fs::create_dir_all(&workdir).unwrap();
729 Self {
730 _root: root,
731 home,
732 murmur_home,
733 workdir,
734 }
735 }
736
737 fn context(&self) -> HostPathContext<'_> {
738 HostPathContext {
739 home: Some(&self.home),
740 murmur_home: Some(&self.murmur_home),
741 workdir: &self.workdir,
742 }
743 }
744
745 fn resolve(
746 &self,
747 needs: &HarnessNeeds,
748 manifest: &[HarnessPathEntry],
749 network_allow: &[&str],
750 ) -> HarnessGrants {
751 let network_allow: Vec<String> = network_allow
752 .iter()
753 .map(|rule| (*rule).to_string())
754 .collect();
755 resolve_harness_grants(
756 "local/d@1.0.0",
757 needs,
758 manifest,
759 &network_allow,
760 &[],
761 &[],
762 None,
763 &self.context(),
764 )
765 }
766 }
767
768 fn refusal_text(grants: &HarnessGrants) -> String {
769 grants
770 .refusal
771 .as_ref()
772 .expect("the launch is refused")
773 .to_string()
774 }
775
776 #[test]
777 fn well_formed_and_empty_needs_pass_validation() {
778 assert!(validate_harness_needs(&needs(&[], &[])).is_ok());
779 assert!(validate_harness_needs(&needs(
780 &[
781 ("~/.claude", PathAccess::ReadWrite),
782 ("/opt/harness/", PathAccess::Read),
783 ],
784 &["api.anthropic.com", "example.invalid:443"],
785 ))
786 .is_ok());
787 }
788
789 #[test]
792 fn a_malformed_need_is_refused_naming_it() {
793 for (path, expected) in [
794 ("", "is empty"),
795 ("relative/dir", "is relative"),
796 ("~", "names the whole home folder"),
797 ("~/", "names the whole home folder"),
798 ("/", "names /, the whole host filesystem"),
799 ("~/a/../b", "has a . or .. segment"),
800 ("/opt/./x", "has a . or .. segment"),
801 ("/opt//x", "has an empty path segment"),
802 ] {
803 let reason = validate_harness_needs(&needs(&[(path, PathAccess::Read)], &[]))
804 .expect_err("a malformed path need is refused");
805 assert!(reason.contains(&format!("{path:?}")), "{path}: {reason}");
806 assert!(reason.contains(expected), "{path}: {reason}");
807 }
808 let reason = validate_harness_needs(&needs(&[], &["http://example.com/x"]))
809 .expect_err("a host the network.allow parser refuses is refused");
810 assert!(reason.contains("\"http://example.com/x\""), "{reason}");
811 assert!(
812 reason.contains("not a capabilities.network.allow entry"),
813 "{reason}"
814 );
815 }
816
817 #[test]
818 fn an_allowed_need_under_home_is_granted_at_its_own_path_and_access() {
819 let host = Host::new();
820 std::fs::create_dir_all(host.home.join("state/sessions")).unwrap();
821 let grants = host.resolve(
822 &needs(&[("~/state/sessions", PathAccess::ReadWrite)], &[]),
823 &[grant("~/state", HarnessPathAccess::ReadWrite)],
824 &[],
825 );
826 assert!(grants.refusal.is_none(), "{:?}", grants.refusal);
827 assert_eq!(grants.granted_paths.len(), 1);
828 assert_eq!(
829 grants.granted_paths[0].path,
830 host.home.join("state/sessions")
831 );
832 assert_eq!(grants.granted_paths[0].access, HostPathAccess::ReadWrite);
833 let row = &grants.report.paths[0];
834 assert_eq!(row.status, "granted");
835 assert_eq!(row.allowed_by.as_deref(), Some("~/state"));
836 assert_eq!(row.access, "read-write");
837 }
838
839 #[test]
840 fn an_entry_below_the_need_access_names_the_shortfall() {
841 let host = Host::new();
842 std::fs::create_dir_all(host.home.join("state")).unwrap();
843 let grants = host.resolve(
844 &needs(&[("~/state", PathAccess::ReadWrite)], &[]),
845 &[grant("~/state", HarnessPathAccess::Read)],
846 &[],
847 );
848 let message = refusal_text(&grants);
849 assert!(message.contains("'local/d@1.0.0'"), "{message}");
850 assert!(message.contains("~/state (read-write)"), "{message}");
851 assert!(
852 message.contains(
853 "entry \"~/state\" covers it at access read, lower than the need's read-write"
854 ),
855 "{message}"
856 );
857 assert!(grants.granted_paths.is_empty());
858 assert_eq!(grants.report.paths[0].status, "refused");
859 }
860
861 #[test]
862 fn every_disallowed_need_is_named_in_one_refusal() {
863 let host = Host::new();
864 let grants = host.resolve(
865 &needs(
866 &[("~/a", PathAccess::Read), ("/srv/b", PathAccess::ReadWrite)],
867 &["example.invalid:443"],
868 ),
869 &[],
870 &[],
871 );
872 let message = refusal_text(&grants);
873 for need in [
874 "~/a (read)",
875 "/srv/b (read-write)",
876 "example.invalid:443: no capabilities.network.allow",
877 ] {
878 assert!(message.contains(need), "{need}: {message}");
879 }
880 assert!(
881 !message.contains('\n'),
882 "one line, so doctor keeps every need: {message}"
883 );
884 }
885
886 #[test]
887 fn an_allowed_need_absent_on_this_host_is_missing_and_never_refuses() {
888 let host = Host::new();
889 let grants = host.resolve(
890 &needs(&[("~/absent", PathAccess::ReadWrite)], &[]),
891 &[grant("~/absent", HarnessPathAccess::ReadWrite)],
892 &[],
893 );
894 assert!(grants.refusal.is_none(), "{:?}", grants.refusal);
895 assert!(grants.granted_paths.is_empty());
896 let row = &grants.report.paths[0];
897 assert_eq!(row.status, "missing");
898 assert_eq!(row.path, None);
899 assert_eq!(row.allowed_by.as_deref(), Some("~/absent"));
900 }
901
902 #[test]
905 fn a_read_write_need_over_murmurs_own_home_is_refused_and_a_sibling_is_not() {
906 let host = Host::new();
907 std::fs::create_dir_all(host.home.join("sibling")).unwrap();
908 let grants = host.resolve(
909 &needs(
910 &[
911 ("~/.murmur", PathAccess::ReadWrite),
912 ("~/sibling", PathAccess::ReadWrite),
913 ],
914 &[],
915 ),
916 &[
917 grant("~/.murmur", HarnessPathAccess::ReadWrite),
918 grant("~/sibling", HarnessPathAccess::ReadWrite),
919 ],
920 &[],
921 );
922 let message = refusal_text(&grants);
923 assert!(message.contains("~/.murmur (read-write)"), "{message}");
924 assert!(
925 message.contains("read-write over murmur's own home"),
926 "{message}"
927 );
928 assert!(!message.contains("~/sibling"), "{message}");
929 assert_eq!(grants.report.paths[1].status, "granted");
930 }
931
932 #[test]
935 fn a_need_that_a_symlink_leads_outside_its_entry_is_refused() {
936 let host = Host::new();
937 let parent = host.home.join("parent");
938 let elsewhere = host.home.join("elsewhere");
939 std::fs::create_dir_all(&parent).unwrap();
940 std::fs::create_dir_all(&elsewhere).unwrap();
941 std::os::unix::fs::symlink(&elsewhere, parent.join("sessions")).unwrap();
942 let grants = host.resolve(
943 &needs(&[("~/parent/sessions", PathAccess::ReadWrite)], &[]),
944 &[grant("~/parent", HarnessPathAccess::ReadWrite)],
945 &[],
946 );
947 let message = refusal_text(&grants);
948 assert!(
949 message.contains("~/parent/sessions (read-write)"),
950 "{message}"
951 );
952 assert!(
953 message.contains("a symlink on the way leads out"),
954 "{message}"
955 );
956 assert!(
957 !message.contains(" "),
958 "one space between words: {message}"
959 );
960 assert!(grants.granted_paths.is_empty());
961 let row = &grants.report.paths[0];
962 assert_eq!(row.status, "refused");
963 assert_eq!(row.path.as_deref(), Some(elsewhere.to_str().unwrap()));
964
965 let inside = parent.join("real");
967 std::fs::create_dir_all(&inside).unwrap();
968 std::os::unix::fs::symlink(&inside, parent.join("linked")).unwrap();
969 let grants = host.resolve(
970 &needs(&[("~/parent/linked", PathAccess::ReadWrite)], &[]),
971 &[grant("~/parent", HarnessPathAccess::ReadWrite)],
972 &[],
973 );
974 assert!(grants.refusal.is_none(), "{:?}", grants.refusal);
975 assert_eq!(grants.granted_paths[0].path, inside);
976 }
977
978 #[test]
981 fn a_host_need_is_allowed_only_when_every_port_it_spans_is_admitted() {
982 let host = Host::new();
983 let resolve = |need: &str, allow: &[&str]| {
984 host.resolve(&needs(&[], &[need]), &[], allow).report.hosts[0].clone()
985 };
986 let row = resolve("example.invalid:443", &["example.invalid"]);
987 assert_eq!(
988 (row.status, row.allowed_by.as_deref()),
989 ("allowed", Some("example.invalid"))
990 );
991 let row = resolve("example.invalid:443", &["example.invalid:443"]);
992 assert_eq!(row.status, "allowed");
993 assert_eq!(
994 resolve("example.invalid", &["example.invalid:443"]).status,
995 "refused"
996 );
997 assert_eq!(
998 resolve("example.invalid:8443", &["example.invalid"]).status,
999 "refused"
1000 );
1001 assert_eq!(
1002 resolve("example.invalid:443", &["other.invalid"]).status,
1003 "refused"
1004 );
1005 }
1006
1007 #[test]
1008 fn a_shebang_names_its_interpreter_and_env_both() {
1009 let host = Host::new();
1010 let script = host.home.join("harness");
1011 std::fs::write(&script, "#!/bin/sh\necho hi\n").unwrap();
1012 let info = HarnessBinaryInfo::read(&script);
1013 let sh = std::fs::canonicalize("/bin/sh").unwrap();
1014 assert_eq!(info.interpreter.as_ref(), Some(&sh));
1015 assert_eq!(info.launcher, None);
1016 assert_eq!(info.exec_files(), vec![script.clone(), sh.clone()]);
1017
1018 std::fs::write(&script, "#!/usr/bin/env sh\necho hi\n").unwrap();
1019 let info = HarnessBinaryInfo::read(&script);
1020 let env = std::fs::canonicalize("/usr/bin/env").unwrap();
1021 assert_eq!(info.launcher.as_ref(), Some(&env));
1022 assert_eq!(info.exec_files().len(), 3, "{:?}", info.exec_files());
1023 assert!(info.exec_files().contains(&env));
1024
1025 std::fs::write(&script, b"\x7fELF\x02\x01").unwrap();
1026 let info = HarnessBinaryInfo::read(&script);
1027 assert_eq!((info.interpreter, info.launcher), (None, None));
1028 }
1029}