Skip to main content

ferrijs_permissions/
lib.rs

1//! What a script is allowed to do, decided in one place.
2//!
3//! `QuickJS` itself has no ambient authority: a fresh realm can compute
4//! and nothing else. Everything a script can reach beyond that — a file,
5//! a socket, an environment variable, a fact about the host — is a
6//! capability the embedding runtime installed, and every one of those
7//! installs asks this crate before acting. Deny is the default for each
8//! kind; a host grants the least it can.
9//!
10//! The model is Deno's and Node's, which are the two that survived:
11//!
12//! - [`Permissions`] is the policy: five kinds ([`Kind`]), each a
13//!   [`Allow`] of none, everything, or a list, plus a [`Deny`] list per
14//!   kind that overrides the allow (`read: all, deny read: /etc`).
15//! - [`Container`] holds the policy for ONE realm for the realm's whole
16//!   life. It can only ever narrow ([`Container::revoke`], irreversible,
17//!   like Node's `process.permission.drop` and Deno's `revoke`). It also
18//!   carries a [`Hook`] the host may install to grant on demand (a
19//!   prompt) and an [`Audit`] that sees every decision.
20//!
21//! What the model deliberately does NOT have is a dynamic scope: no
22//! "narrow the policy around this call and carry it into the callbacks
23//! it registers". That is Java's stack-inspection Security Manager,
24//! removed by JEP 411 as brittle, slow, and impossible to keep complete
25//! across an API surface. A host with two trust levels runs them in two
26//! realms, each with its own container, the way workerd gives each
27//! isolate its own bindings.
28//!
29//! Paths are checked twice: as written, after lexical normalisation, and
30//! as the filesystem will actually resolve them, after following every
31//! symlink in the longest existing prefix. Both must fall under a
32//! granted root, so a link planted inside an allowed directory cannot
33//! point out of it. Node documents the opposite (links are followed out)
34//! as a hazard the operator must avoid; this does not leave it to them.
35
36use std::borrow::Cow;
37use std::fmt;
38use std::net::IpAddr;
39use std::path::{Component, Path, PathBuf};
40use std::sync::Arc;
41
42/// One capability kind.
43#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
44#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
45#[cfg_attr(feature = "serde", serde(rename_all = "lowercase"))]
46pub enum Kind {
47  /// Reading a file or directory, including `stat` and `readdir`.
48  Read,
49  /// Creating, writing, renaming, removing or changing the mode of a
50  /// file or directory.
51  Write,
52  /// Opening a connection to a host.
53  Net,
54  /// Reading an environment variable.
55  Env,
56  /// Learning something about the host: its name, addresses, users,
57  /// load, memory.
58  Sys,
59}
60
61impl Kind {
62  #[must_use]
63  pub fn as_str(self) -> &'static str {
64    match self {
65      Self::Read => "read",
66      Self::Write => "write",
67      Self::Net => "net",
68      Self::Env => "env",
69      Self::Sys => "sys",
70    }
71  }
72}
73
74impl fmt::Display for Kind {
75  fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
76    f.write_str(self.as_str())
77  }
78}
79
80/// A grant for one kind: nothing, everything, or exactly these.
81#[derive(Debug, Clone, PartialEq, Eq, Default)]
82pub enum Allow<T> {
83  /// Every request of this kind is refused.
84  #[default]
85  None,
86  /// Every request of this kind is granted.
87  All,
88  /// A request is granted when one entry matches it.
89  Only(Vec<T>),
90}
91
92impl<T> Allow<T> {
93  #[must_use]
94  pub fn is_all(&self) -> bool {
95    matches!(self, Self::All)
96  }
97
98  #[must_use]
99  pub fn is_none(&self) -> bool {
100    matches!(self, Self::None)
101  }
102
103  /// The entries of an `Only`, empty for the other two.
104  #[must_use]
105  pub fn entries(&self) -> &[T] {
106    match self {
107      Self::Only(list) => list,
108      _ => &[],
109    }
110  }
111
112  fn map<U>(self, f: impl FnMut(T) -> U) -> Allow<U> {
113    match self {
114      Self::None => Allow::None,
115      Self::All => Allow::All,
116      Self::Only(list) => Allow::Only(list.into_iter().map(f).collect()),
117    }
118  }
119
120  /// A grant no wider than either side.
121  ///
122  /// Two lists intersect by keeping, from each side, the entries the
123  /// other side covers in full; `subsumes(a, b)` says whether `a` grants
124  /// everything `b` grants.
125  fn intersect_with(&self, other: &Self, subsumes: impl Fn(&T, &T) -> bool) -> Self
126  where
127    T: Clone + PartialEq,
128  {
129    match (self, other) {
130      (Self::None, _) | (_, Self::None) => Self::None,
131      (Self::All, o) => o.clone(),
132      (s, Self::All) => s.clone(),
133      (Self::Only(mine), Self::Only(theirs)) => {
134        let mut kept: Vec<T> = mine
135          .iter()
136          .filter(|e| theirs.iter().any(|t| subsumes(t, e)))
137          .cloned()
138          .collect();
139        for e in theirs {
140          if mine.iter().any(|m| subsumes(m, e)) && !kept.contains(e) {
141            kept.push(e.clone());
142          }
143        }
144        Self::Only(kept)
145      },
146    }
147  }
148}
149
150#[cfg(feature = "serde")]
151impl<T: serde::Serialize> serde::Serialize for Allow<T> {
152  fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
153    match self {
154      Self::None => s.serialize_bool(false),
155      Self::All => s.serialize_bool(true),
156      Self::Only(list) => list.serialize(s),
157    }
158  }
159}
160
161#[cfg(feature = "serde")]
162impl<'de, T: serde::Deserialize<'de>> serde::Deserialize<'de> for Allow<T> {
163  fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
164    #[derive(serde::Deserialize)]
165    #[serde(untagged)]
166    enum Repr<T> {
167      Flag(bool),
168      List(Vec<T>),
169    }
170    Ok(match Repr::<T>::deserialize(d)? {
171      Repr::Flag(true) => Self::All,
172      Repr::Flag(false) => Self::None,
173      Repr::List(list) => Self::Only(list),
174    })
175  }
176}
177
178/// A directory (or file) a `read` / `write` grant covers, with
179/// everything under it.
180#[derive(Debug, Clone, PartialEq, Eq)]
181pub struct PathRule {
182  /// The root as the host wrote it, made absolute and normalised.
183  lexical: PathBuf,
184  /// The root as the filesystem resolves it. Equal to `lexical` when no
185  /// symlink is involved or the root does not exist yet.
186  resolved: PathBuf,
187}
188
189impl PathRule {
190  /// Anchor a root. A relative path is taken against the current
191  /// directory at construction time, so a policy read from a config
192  /// file means the same thing for the process's whole life.
193  #[must_use]
194  pub fn new(root: impl AsRef<Path>) -> Self {
195    let lexical = normalize(&absolute(root.as_ref()));
196    let resolved = resolve_existing(&lexical);
197    Self { lexical, resolved }
198  }
199
200  #[must_use]
201  pub fn root(&self) -> &Path {
202    &self.lexical
203  }
204
205  fn covers(&self, candidate: &CheckedPath) -> bool {
206    candidate.lexical.starts_with(&self.lexical) && candidate.resolved.starts_with(&self.resolved)
207  }
208}
209
210impl<P: AsRef<Path>> From<P> for PathRule {
211  fn from(p: P) -> Self {
212    Self::new(p)
213  }
214}
215
216#[cfg(feature = "serde")]
217impl serde::Serialize for PathRule {
218  fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
219    self.lexical.serialize(s)
220  }
221}
222
223#[cfg(feature = "serde")]
224impl<'de> serde::Deserialize<'de> for PathRule {
225  fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
226    PathBuf::deserialize(d).map(Self::new)
227  }
228}
229
230/// A path as a check sees it: both spellings a rule must cover.
231struct CheckedPath {
232  lexical: PathBuf,
233  resolved: PathBuf,
234}
235
236impl CheckedPath {
237  fn new(path: &Path) -> Self {
238    let lexical = normalize(&absolute(path));
239    let resolved = resolve_existing(&lexical);
240    Self { lexical, resolved }
241  }
242}
243
244fn absolute(path: &Path) -> PathBuf {
245  if path.is_absolute() {
246    path.to_path_buf()
247  } else {
248    std::env::current_dir().map_or_else(|_| path.to_path_buf(), |cwd| cwd.join(path))
249  }
250}
251
252/// Collapse `.` and `..` without touching the filesystem. A `..` that
253/// would climb above the root stays at the root, which is what the OS
254/// does too.
255fn normalize(path: &Path) -> PathBuf {
256  let mut out = PathBuf::new();
257  for component in path.components() {
258    match component {
259      Component::CurDir => {},
260      Component::ParentDir => {
261        if !matches!(
262          out.components().next_back(),
263          Some(Component::RootDir | Component::Prefix(_)) | None
264        ) {
265          out.pop();
266        }
267      },
268      other => out.push(other.as_os_str()),
269    }
270  }
271  out
272}
273
274/// Canonicalise the longest prefix that exists and re-append the rest,
275/// so a path that does not exist yet (a file about to be written) is
276/// still judged by where its directory really is.
277fn resolve_existing(path: &Path) -> PathBuf {
278  let mut existing = path.to_path_buf();
279  let mut rest: Vec<std::ffi::OsString> = Vec::new();
280  loop {
281    if let Ok(canonical) = std::fs::canonicalize(&existing) {
282      let mut out = canonical;
283      for part in rest.iter().rev() {
284        out.push(part);
285      }
286      return out;
287    }
288    match existing.file_name() {
289      Some(name) => {
290        rest.push(name.to_os_string());
291        if !existing.pop() {
292          break;
293        }
294      },
295      None => break,
296    }
297  }
298  path.to_path_buf()
299}
300
301/// A host (and optionally a port) a `net` grant covers.
302///
303/// Spelled the way an allow-list entry is written: `api.example.com`,
304/// `*.example.com` (which also covers the bare `example.com`),
305/// `127.0.0.1:8080`, `[::1]:443`. No port means any port.
306#[derive(Debug, Clone, PartialEq, Eq)]
307pub struct NetRule {
308  host: String,
309  port: Option<u16>,
310}
311
312impl NetRule {
313  /// Parse an allow-list entry.
314  ///
315  /// # Errors
316  ///
317  /// An empty host, a port that is not a number, or a wildcard with no
318  /// suffix.
319  pub fn parse(entry: &str) -> Result<Self, String> {
320    let entry = entry.trim();
321    if entry.is_empty() {
322      return Err("empty network rule".to_string());
323    }
324    let (host, port) = if let Some(rest) = entry.strip_prefix('[') {
325      // Bracketed IPv6, optionally followed by `:port`.
326      let (addr, after) = rest
327        .split_once(']')
328        .ok_or_else(|| format!("network rule `{entry}`: unterminated IPv6 literal"))?;
329      let port = match after.strip_prefix(':') {
330        Some(p) => Some(parse_port(entry, p)?),
331        None if after.is_empty() => None,
332        None => return Err(format!("network rule `{entry}`: unexpected `{after}` after address")),
333      };
334      (addr.to_string(), port)
335    } else if entry.matches(':').count() > 1 {
336      // Bare IPv6 with no port.
337      (entry.to_string(), None)
338    } else if let Some((host, port)) = entry.rsplit_once(':') {
339      (host.to_string(), Some(parse_port(entry, port)?))
340    } else {
341      (entry.to_string(), None)
342    };
343    let host = host.to_ascii_lowercase();
344    if host.is_empty() || host == "*." {
345      return Err(format!("network rule `{entry}`: empty host"));
346    }
347    Ok(Self { host, port })
348  }
349
350  #[must_use]
351  pub fn host(&self) -> &str {
352    &self.host
353  }
354
355  #[must_use]
356  pub fn port(&self) -> Option<u16> {
357    self.port
358  }
359
360  fn covers(&self, host: &str, port: Option<u16>) -> bool {
361    let host_ok = if self.host == host {
362      true
363    } else if let Some(suffix) = self.host.strip_prefix("*.") {
364      host == suffix || host.strip_suffix(suffix).is_some_and(|prefix| prefix.ends_with('.'))
365    } else {
366      false
367    };
368    host_ok && self.port.is_none_or(|p| port == Some(p))
369  }
370
371  /// Whether `other` would be granted by this rule in full, which is
372  /// what makes it a subset when intersecting two lists.
373  fn subsumes(&self, other: &Self) -> bool {
374    let host_ok = if self.host == other.host {
375      true
376    } else if let Some(suffix) = self.host.strip_prefix("*.") {
377      other.host == suffix
378        || other
379          .host
380          .strip_suffix(suffix)
381          .is_some_and(|prefix| prefix.ends_with('.'))
382        || other.host.strip_prefix("*.").is_some_and(|other_suffix| {
383          other_suffix == suffix
384            || other_suffix
385              .strip_suffix(suffix)
386              .is_some_and(|prefix| prefix.ends_with('.'))
387        })
388    } else {
389      false
390    };
391    host_ok && (self.port.is_none() || self.port == other.port)
392  }
393}
394
395fn parse_port(entry: &str, port: &str) -> Result<u16, String> {
396  port
397    .parse::<u16>()
398    .map_err(|_| format!("network rule `{entry}`: `{port}` is not a port"))
399}
400
401impl fmt::Display for NetRule {
402  fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
403    let bracket = self.host.contains(':');
404    match (bracket, self.port) {
405      (true, Some(p)) => write!(f, "[{}]:{p}", self.host),
406      (true, None) => write!(f, "{}", self.host),
407      (false, Some(p)) => write!(f, "{}:{p}", self.host),
408      (false, None) => f.write_str(&self.host),
409    }
410  }
411}
412
413impl std::str::FromStr for NetRule {
414  type Err = String;
415
416  fn from_str(s: &str) -> Result<Self, Self::Err> {
417    Self::parse(s)
418  }
419}
420
421#[cfg(feature = "serde")]
422impl serde::Serialize for NetRule {
423  fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
424    s.collect_str(self)
425  }
426}
427
428#[cfg(feature = "serde")]
429impl<'de> serde::Deserialize<'de> for NetRule {
430  fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
431    let s = String::deserialize(d)?;
432    Self::parse(&s).map_err(serde::de::Error::custom)
433  }
434}
435
436/// One fact about the host a `sys` grant can cover.
437#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
438#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
439#[cfg_attr(feature = "serde", serde(rename_all = "camelCase"))]
440pub enum SysInfo {
441  Hostname,
442  OsRelease,
443  OsUptime,
444  LoadAvg,
445  NetworkInterfaces,
446  SystemMemory,
447  Uid,
448  Gid,
449  Username,
450  Cpus,
451  HomeDir,
452  /// Reading or changing scheduling priority.
453  Priority,
454}
455
456impl SysInfo {
457  #[must_use]
458  pub fn as_str(self) -> &'static str {
459    match self {
460      Self::Hostname => "hostname",
461      Self::OsRelease => "osRelease",
462      Self::OsUptime => "osUptime",
463      Self::LoadAvg => "loadavg",
464      Self::NetworkInterfaces => "networkInterfaces",
465      Self::SystemMemory => "systemMemoryInfo",
466      Self::Uid => "uid",
467      Self::Gid => "gid",
468      Self::Username => "username",
469      Self::Cpus => "cpus",
470      Self::HomeDir => "homedir",
471      Self::Priority => "priority",
472    }
473  }
474}
475
476impl fmt::Display for SysInfo {
477  fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
478    f.write_str(self.as_str())
479  }
480}
481
482/// What a grant carves out. A denial wins over any allow of the same
483/// kind, so a broad grant can exclude its sensitive corners:
484/// `read: All` with `deny.read: ["/etc"]`.
485#[derive(Debug, Clone, PartialEq, Eq, Default)]
486#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
487#[cfg_attr(feature = "serde", serde(default, rename_all = "camelCase"))]
488pub struct Deny {
489  pub read: Vec<PathRule>,
490  pub write: Vec<PathRule>,
491  pub net: Vec<NetRule>,
492  pub env: Vec<String>,
493  pub sys: Vec<SysInfo>,
494}
495
496impl Deny {
497  #[must_use]
498  pub fn is_empty(&self) -> bool {
499    self.read.is_empty() && self.write.is_empty() && self.net.is_empty() && self.env.is_empty() && self.sys.is_empty()
500  }
501
502  fn merged(&self, other: &Self) -> Self {
503    fn union<T: Clone + PartialEq>(a: &[T], b: &[T]) -> Vec<T> {
504      let mut out = a.to_vec();
505      for item in b {
506        if !out.contains(item) {
507          out.push(item.clone());
508        }
509      }
510      out
511    }
512    Self {
513      read: union(&self.read, &other.read),
514      write: union(&self.write, &other.write),
515      net: union(&self.net, &other.net),
516      env: union(&self.env, &other.env),
517      sys: union(&self.sys, &other.sys),
518    }
519  }
520}
521
522/// The policy for one realm.
523///
524/// `Default` grants nothing. [`Permissions::all`] grants everything, for
525/// a host whose scripts are as trusted as the host itself.
526#[derive(Debug, Clone, PartialEq, Eq, Default)]
527#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
528#[cfg_attr(feature = "serde", serde(default, rename_all = "camelCase"))]
529pub struct Permissions {
530  pub read: Allow<PathRule>,
531  pub write: Allow<PathRule>,
532  pub net: Allow<NetRule>,
533  pub env: Allow<String>,
534  pub sys: Allow<SysInfo>,
535  #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Deny::is_empty"))]
536  pub deny: Deny,
537}
538
539impl Permissions {
540  /// Nothing granted.
541  #[must_use]
542  pub fn none() -> Self {
543    Self::default()
544  }
545
546  /// Everything granted.
547  #[must_use]
548  pub fn all() -> Self {
549    Self {
550      read: Allow::All,
551      write: Allow::All,
552      net: Allow::All,
553      env: Allow::All,
554      sys: Allow::All,
555      deny: Deny::default(),
556    }
557  }
558
559  #[must_use]
560  pub fn allow_read<I, P>(mut self, roots: I) -> Self
561  where
562    I: IntoIterator<Item = P>,
563    P: AsRef<Path>,
564  {
565    self.read = Allow::Only(roots.into_iter().map(PathRule::new).collect());
566    self
567  }
568
569  #[must_use]
570  pub fn allow_write<I, P>(mut self, roots: I) -> Self
571  where
572    I: IntoIterator<Item = P>,
573    P: AsRef<Path>,
574  {
575    self.write = Allow::Only(roots.into_iter().map(PathRule::new).collect());
576    self
577  }
578
579  /// Grant these hosts. An entry that does not parse is reported rather
580  /// than dropped: a typo in an allow-list must not widen or narrow it
581  /// silently.
582  ///
583  /// # Errors
584  ///
585  /// The first entry [`NetRule::parse`] refuses.
586  pub fn allow_net<I, S>(mut self, hosts: I) -> Result<Self, String>
587  where
588    I: IntoIterator<Item = S>,
589    S: AsRef<str>,
590  {
591    let rules = hosts
592      .into_iter()
593      .map(|h| NetRule::parse(h.as_ref()))
594      .collect::<Result<Vec<_>, _>>()?;
595    self.net = Allow::Only(rules);
596    Ok(self)
597  }
598
599  #[must_use]
600  pub fn allow_env<I, S>(mut self, names: I) -> Self
601  where
602    I: IntoIterator<Item = S>,
603    S: Into<String>,
604  {
605    self.env = Allow::Only(names.into_iter().map(Into::into).collect());
606    self
607  }
608
609  #[must_use]
610  pub fn allow_sys<I>(mut self, items: I) -> Self
611  where
612    I: IntoIterator<Item = SysInfo>,
613  {
614    self.sys = Allow::Only(items.into_iter().collect());
615    self
616  }
617
618  #[must_use]
619  pub fn allow_all_read(mut self) -> Self {
620    self.read = Allow::All;
621    self
622  }
623
624  #[must_use]
625  pub fn allow_all_write(mut self) -> Self {
626    self.write = Allow::All;
627    self
628  }
629
630  #[must_use]
631  pub fn allow_all_net(mut self) -> Self {
632    self.net = Allow::All;
633    self
634  }
635
636  #[must_use]
637  pub fn allow_all_env(mut self) -> Self {
638    self.env = Allow::All;
639    self
640  }
641
642  #[must_use]
643  pub fn allow_all_sys(mut self) -> Self {
644    self.sys = Allow::All;
645    self
646  }
647
648  #[must_use]
649  pub fn deny_read<I, P>(mut self, roots: I) -> Self
650  where
651    I: IntoIterator<Item = P>,
652    P: AsRef<Path>,
653  {
654    self.deny.read.extend(roots.into_iter().map(PathRule::new));
655    self
656  }
657
658  #[must_use]
659  pub fn deny_write<I, P>(mut self, roots: I) -> Self
660  where
661    I: IntoIterator<Item = P>,
662    P: AsRef<Path>,
663  {
664    self.deny.write.extend(roots.into_iter().map(PathRule::new));
665    self
666  }
667
668  /// # Errors
669  ///
670  /// The first entry [`NetRule::parse`] refuses.
671  pub fn deny_net<I, S>(mut self, hosts: I) -> Result<Self, String>
672  where
673    I: IntoIterator<Item = S>,
674    S: AsRef<str>,
675  {
676    for host in hosts {
677      self.deny.net.push(NetRule::parse(host.as_ref())?);
678    }
679    Ok(self)
680  }
681
682  #[must_use]
683  pub fn deny_env<I, S>(mut self, names: I) -> Self
684  where
685    I: IntoIterator<Item = S>,
686    S: Into<String>,
687  {
688    self.deny.env.extend(names.into_iter().map(Into::into));
689    self
690  }
691
692  #[must_use]
693  pub fn deny_sys<I>(mut self, items: I) -> Self
694  where
695    I: IntoIterator<Item = SysInfo>,
696  {
697    self.deny.sys.extend(items);
698    self
699  }
700
701  /// The grant for one kind, as an untyped view for reporting.
702  #[must_use]
703  pub fn describe(&self, kind: Kind) -> String {
704    fn render<T: fmt::Display>(allow: &Allow<T>) -> String {
705      match allow {
706        Allow::None => "none".to_string(),
707        Allow::All => "all".to_string(),
708        Allow::Only(list) => list.iter().map(ToString::to_string).collect::<Vec<_>>().join(", "),
709      }
710    }
711    match kind {
712      Kind::Read => render(&self.read.clone().map(|r| r.lexical.display().to_string())),
713      Kind::Write => render(&self.write.clone().map(|r| r.lexical.display().to_string())),
714      Kind::Net => render(&self.net),
715      Kind::Env => render(&self.env),
716      Kind::Sys => render(&self.sys),
717    }
718  }
719
720  /// A policy no wider than either side, kind by kind.
721  #[must_use]
722  pub fn intersect(&self, other: &Self) -> Self {
723    let path_subsumes =
724      |a: &PathRule, b: &PathRule| b.lexical.starts_with(&a.lexical) && b.resolved.starts_with(&a.resolved);
725    Self {
726      read: self.read.intersect_with(&other.read, path_subsumes),
727      write: self.write.intersect_with(&other.write, path_subsumes),
728      net: self.net.intersect_with(&other.net, NetRule::subsumes),
729      env: self.env.intersect_with(&other.env, |a, b| a == b),
730      sys: self.sys.intersect_with(&other.sys, |a, b| a == b),
731      deny: self.deny.merged(&other.deny),
732    }
733  }
734
735  /// # Errors
736  ///
737  /// [`Denied`] naming the path.
738  pub fn check_read(&self, path: &Path) -> Result<(), Denied> {
739    check_path(Kind::Read, &self.read, &self.deny.read, path)
740  }
741
742  /// # Errors
743  ///
744  /// [`Denied`] naming the path.
745  pub fn check_write(&self, path: &Path) -> Result<(), Denied> {
746    check_path(Kind::Write, &self.write, &self.deny.write, path)
747  }
748
749  /// `port` is the port the connection will use, so a caller passes the
750  /// scheme default when the URL names none.
751  ///
752  /// # Errors
753  ///
754  /// [`Denied`] naming `host:port`.
755  pub fn check_net(&self, host: &str, port: Option<u16>) -> Result<(), Denied> {
756    let host = host.to_ascii_lowercase();
757    let host = host.trim_start_matches('[').trim_end_matches(']');
758    let denied = self.deny.net.iter().any(|r| r.covers(host, port));
759    let granted = !denied
760      && match &self.net {
761        Allow::None => false,
762        Allow::All => true,
763        Allow::Only(rules) => rules.iter().any(|r| r.covers(host, port)),
764      };
765    if granted {
766      Ok(())
767    } else {
768      let resource = match port {
769        Some(p) if host.contains(':') => format!("[{host}]:{p}"),
770        Some(p) => format!("{host}:{p}"),
771        None => host.to_string(),
772      };
773      Err(Denied::new(Kind::Net, resource))
774    }
775  }
776
777  /// # Errors
778  ///
779  /// [`Denied`] naming the variable.
780  pub fn check_env(&self, name: &str) -> Result<(), Denied> {
781    let granted = !self.deny.env.iter().any(|n| n == name)
782      && match &self.env {
783        Allow::None => false,
784        Allow::All => true,
785        Allow::Only(names) => names.iter().any(|n| n == name),
786      };
787    if granted {
788      Ok(())
789    } else {
790      Err(Denied::new(Kind::Env, name))
791    }
792  }
793
794  /// # Errors
795  ///
796  /// [`Denied`] naming the item.
797  pub fn check_sys(&self, item: SysInfo) -> Result<(), Denied> {
798    let granted = !self.deny.sys.contains(&item)
799      && match &self.sys {
800        Allow::None => false,
801        Allow::All => true,
802        Allow::Only(items) => items.contains(&item),
803      };
804    if granted {
805      Ok(())
806    } else {
807      Err(Denied::new(Kind::Sys, item.as_str()))
808    }
809  }
810
811  /// The process environment reduced to this policy's `env` grant, in
812  /// name order. What a host hands to `process.env`.
813  #[must_use]
814  pub fn env_snapshot(&self) -> Vec<(String, String)> {
815    let mut out: Vec<(String, String)> = match &self.env {
816      Allow::None => Vec::new(),
817      Allow::All => std::env::vars().collect(),
818      Allow::Only(names) => names
819        .iter()
820        .filter_map(|n| std::env::var(n).ok().map(|v| (n.clone(), v)))
821        .collect(),
822    };
823    out.retain(|(name, _)| !self.deny.env.contains(name));
824    out.sort();
825    out.dedup_by(|a, b| a.0 == b.0);
826    out
827  }
828}
829
830fn check_path(kind: Kind, allow: &Allow<PathRule>, deny: &[PathRule], path: &Path) -> Result<(), Denied> {
831  let granted = match allow {
832    Allow::None => false,
833    Allow::All if deny.is_empty() => true,
834    _ => {
835      let candidate = CheckedPath::new(path);
836      let denied = deny.iter().any(|r| r.covers(&candidate));
837      !denied
838        && match allow {
839          Allow::All => true,
840          Allow::Only(rules) => rules.iter().any(|r| r.covers(&candidate)),
841          Allow::None => false,
842        }
843    },
844  };
845  if granted {
846    Ok(())
847  } else {
848    Err(Denied::new(kind, path.display().to_string()))
849  }
850}
851
852/// A refused request. Carries what Node's `ERR_ACCESS_DENIED` carries:
853/// the kind and the resource, so a host can render either its own
854/// message or Node's.
855#[derive(Debug, Clone, PartialEq, Eq)]
856pub struct Denied {
857  pub kind: Kind,
858  pub resource: String,
859}
860
861impl Denied {
862  #[must_use]
863  pub fn new(kind: Kind, resource: impl Into<String>) -> Self {
864    Self {
865      kind,
866      resource: resource.into(),
867    }
868  }
869
870  /// Node's error code for a permission-model refusal.
871  pub const CODE: &'static str = "ERR_ACCESS_DENIED";
872
873  /// The name the thrown JS error carries.
874  pub const NAME: &'static str = "PermissionDeniedError";
875}
876
877impl fmt::Display for Denied {
878  fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
879    let what = match self.kind {
880      Kind::Read => "read access to",
881      Kind::Write => "write access to",
882      Kind::Net => "network access to",
883      Kind::Env => "access to environment variable",
884      Kind::Sys => "access to host information",
885    };
886    write!(
887      f,
888      "permission denied: {what} \"{}\" (grant `{}` to allow it)",
889      self.resource, self.kind
890    )
891  }
892}
893
894impl std::error::Error for Denied {}
895
896/// A request the policy refused, offered to the [`Hook`].
897#[derive(Debug, Clone, PartialEq, Eq)]
898pub struct Request<'a> {
899  pub kind: Kind,
900  pub resource: Cow<'a, str>,
901}
902
903/// What a [`Hook`] answers.
904#[derive(Debug, Clone, Copy, PartialEq, Eq)]
905pub enum Decision {
906  Allow,
907  Deny,
908}
909
910/// A host's say in a request the static policy refused: a prompt, a
911/// policy computed at call time, a one-shot grant. Called on the VM
912/// thread, synchronously, inside the operation that asked, so it must
913/// answer quickly.
914pub trait Hook: Send + Sync {
915  fn decide(&self, request: &Request<'_>) -> Decision;
916}
917
918impl<F> Hook for F
919where
920  F: Fn(&Request<'_>) -> Decision + Send + Sync,
921{
922  fn decide(&self, request: &Request<'_>) -> Decision {
923    self(request)
924  }
925}
926
927/// Sees every decision, granted or not.
928pub trait Audit: Send + Sync {
929  fn record(&self, request: &Request<'_>, granted: bool);
930}
931
932impl<F> Audit for F
933where
934  F: Fn(&Request<'_>, bool) + Send + Sync,
935{
936  fn record(&self, request: &Request<'_>, granted: bool) {
937    self(request, granted);
938  }
939}
940
941/// The policy for one realm, plus the hook and audit the host attached.
942///
943/// Every check goes through here. The policy can only ever get
944/// narrower: [`Container::revoke`] intersects it with what remains,
945/// and nothing widens it except the [`Hook`], case by case.
946pub struct Container {
947  policy: std::sync::RwLock<Arc<Permissions>>,
948  hook: Option<Arc<dyn Hook>>,
949  audit: Option<Arc<dyn Audit>>,
950}
951
952impl fmt::Debug for Container {
953  fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
954    f.debug_struct("Container")
955      .field("policy", &self.permissions())
956      .field("hook", &self.hook.as_ref().map(|_| "..."))
957      .field("audit", &self.audit.as_ref().map(|_| "..."))
958      .finish()
959  }
960}
961
962impl Container {
963  #[must_use]
964  pub fn new(policy: Permissions) -> Self {
965    Self {
966      policy: std::sync::RwLock::new(Arc::new(policy)),
967      hook: None,
968      audit: None,
969    }
970  }
971
972  #[must_use]
973  pub fn with_hook(mut self, hook: Arc<dyn Hook>) -> Self {
974    self.hook = Some(hook);
975    self
976  }
977
978  #[must_use]
979  pub fn with_audit(mut self, audit: Arc<dyn Audit>) -> Self {
980    self.audit = Some(audit);
981    self
982  }
983
984  /// The policy in force. A snapshot: a later [`Self::revoke`] does not
985  /// change the `Arc` handed out.
986  #[must_use]
987  pub fn permissions(&self) -> Arc<Permissions> {
988    Arc::clone(&self.policy.read().unwrap_or_else(std::sync::PoisonError::into_inner))
989  }
990
991  /// Narrow the policy to what it and `remaining` both grant.
992  /// Irreversible: there is no call that widens.
993  pub fn revoke(&self, remaining: &Permissions) {
994    let mut guard = self.policy.write().unwrap_or_else(std::sync::PoisonError::into_inner);
995    *guard = Arc::new(guard.intersect(remaining));
996  }
997
998  /// Carve `resource` out of `kind` for good. `resource` is a path for
999  /// `read` / `write`, a host rule for `net`, a name for `env`, a
1000  /// [`SysInfo`] name for `sys`.
1001  ///
1002  /// # Errors
1003  ///
1004  /// A `net` rule or `sys` name that does not parse.
1005  pub fn deny(&self, kind: Kind, resource: &str) -> Result<(), String> {
1006    let mut guard = self.policy.write().unwrap_or_else(std::sync::PoisonError::into_inner);
1007    let mut next = (**guard).clone();
1008    match kind {
1009      Kind::Read => next.deny.read.push(PathRule::new(resource)),
1010      Kind::Write => next.deny.write.push(PathRule::new(resource)),
1011      Kind::Net => next.deny.net.push(NetRule::parse(resource)?),
1012      Kind::Env => next.deny.env.push(resource.to_string()),
1013      Kind::Sys => next.deny.sys.push(sys_info_from_str(resource)?),
1014    }
1015    *guard = Arc::new(next);
1016    Ok(())
1017  }
1018
1019  fn decide(&self, kind: Kind, resource: &str, statically: Result<(), Denied>) -> Result<(), Denied> {
1020    let request = Request {
1021      kind,
1022      resource: Cow::Borrowed(resource),
1023    };
1024    let outcome = match statically {
1025      Ok(()) => Ok(()),
1026      Err(denied) => match &self.hook {
1027        Some(hook) if hook.decide(&request) == Decision::Allow => Ok(()),
1028        _ => Err(denied),
1029      },
1030    };
1031    if let Some(audit) = &self.audit {
1032      audit.record(&request, outcome.is_ok());
1033    }
1034    outcome
1035  }
1036
1037  /// # Errors
1038  ///
1039  /// [`Denied`] when neither the policy nor the hook grants it.
1040  pub fn check_read(&self, path: &Path) -> Result<(), Denied> {
1041    let result = self.permissions().check_read(path);
1042    self.decide(Kind::Read, &path.to_string_lossy(), result)
1043  }
1044
1045  /// # Errors
1046  ///
1047  /// [`Denied`] when neither the policy nor the hook grants it.
1048  pub fn check_write(&self, path: &Path) -> Result<(), Denied> {
1049    let result = self.permissions().check_write(path);
1050    self.decide(Kind::Write, &path.to_string_lossy(), result)
1051  }
1052
1053  /// # Errors
1054  ///
1055  /// [`Denied`] when neither the policy nor the hook grants it.
1056  pub fn check_net(&self, host: &str, port: Option<u16>) -> Result<(), Denied> {
1057    let result = self.permissions().check_net(host, port);
1058    let resource = match port {
1059      Some(p) => format!("{host}:{p}"),
1060      None => host.to_string(),
1061    };
1062    self.decide(Kind::Net, &resource, result)
1063  }
1064
1065  /// # Errors
1066  ///
1067  /// [`Denied`] when neither the policy nor the hook grants it.
1068  pub fn check_env(&self, name: &str) -> Result<(), Denied> {
1069    let result = self.permissions().check_env(name);
1070    self.decide(Kind::Env, name, result)
1071  }
1072
1073  /// # Errors
1074  ///
1075  /// [`Denied`] when neither the policy nor the hook grants it.
1076  pub fn check_sys(&self, item: SysInfo) -> Result<(), Denied> {
1077    let result = self.permissions().check_sys(item);
1078    self.decide(Kind::Sys, item.as_str(), result)
1079  }
1080
1081  /// Whether `kind` covers `resource` right now, without consulting the
1082  /// hook or the audit: what a `has()`-style query answers. `None`
1083  /// asks about the kind as a whole (granted in full).
1084  ///
1085  /// # Errors
1086  ///
1087  /// A `net` rule or `sys` name that does not parse.
1088  pub fn has(&self, kind: Kind, resource: Option<&str>) -> Result<bool, String> {
1089    let policy = self.permissions();
1090    Ok(match (kind, resource) {
1091      (Kind::Read, None) => policy.read.is_all() && policy.deny.read.is_empty(),
1092      (Kind::Write, None) => policy.write.is_all() && policy.deny.write.is_empty(),
1093      (Kind::Net, None) => policy.net.is_all() && policy.deny.net.is_empty(),
1094      (Kind::Env, None) => policy.env.is_all() && policy.deny.env.is_empty(),
1095      (Kind::Sys, None) => policy.sys.is_all() && policy.deny.sys.is_empty(),
1096      (Kind::Read, Some(r)) => policy.check_read(Path::new(r)).is_ok(),
1097      (Kind::Write, Some(r)) => policy.check_write(Path::new(r)).is_ok(),
1098      (Kind::Net, Some(r)) => {
1099        let rule = NetRule::parse(r)?;
1100        policy.check_net(rule.host(), rule.port()).is_ok()
1101      },
1102      (Kind::Env, Some(r)) => policy.check_env(r).is_ok(),
1103      (Kind::Sys, Some(r)) => policy.check_sys(sys_info_from_str(r)?).is_ok(),
1104    })
1105  }
1106}
1107
1108fn sys_info_from_str(name: &str) -> Result<SysInfo, String> {
1109  [
1110    SysInfo::Hostname,
1111    SysInfo::OsRelease,
1112    SysInfo::OsUptime,
1113    SysInfo::LoadAvg,
1114    SysInfo::NetworkInterfaces,
1115    SysInfo::SystemMemory,
1116    SysInfo::Uid,
1117    SysInfo::Gid,
1118    SysInfo::Username,
1119    SysInfo::Cpus,
1120    SysInfo::HomeDir,
1121    SysInfo::Priority,
1122  ]
1123  .into_iter()
1124  .find(|item| item.as_str() == name)
1125  .ok_or_else(|| format!("`{name}` is not a sys permission"))
1126}
1127
1128impl std::str::FromStr for Kind {
1129  type Err = String;
1130
1131  fn from_str(s: &str) -> Result<Self, Self::Err> {
1132    match s {
1133      "read" => Ok(Self::Read),
1134      "write" => Ok(Self::Write),
1135      "net" => Ok(Self::Net),
1136      "env" => Ok(Self::Env),
1137      "sys" => Ok(Self::Sys),
1138      other => Err(format!(
1139        "`{other}` is not a permission kind (read, write, net, env, sys)"
1140      )),
1141    }
1142  }
1143}
1144
1145impl std::str::FromStr for SysInfo {
1146  type Err = String;
1147
1148  fn from_str(s: &str) -> Result<Self, Self::Err> {
1149    sys_info_from_str(s)
1150  }
1151}
1152
1153/// Whether an address is one of the cloud instance-metadata endpoints
1154/// (AWS/GCP/Azure/OpenStack IMDS on IPv4, the AWS IPv6 IMDS). They have
1155/// no legitimate use from a script and are the canonical SSRF target.
1156#[must_use]
1157pub fn is_metadata_ip(ip: IpAddr) -> bool {
1158  match canon_ip(ip) {
1159    IpAddr::V4(v4) => v4 == std::net::Ipv4Addr::new(169, 254, 169, 254),
1160    IpAddr::V6(v6) => v6 == std::net::Ipv6Addr::new(0xfd00, 0x0ec2, 0, 0, 0, 0, 0, 0x0254),
1161  }
1162}
1163
1164/// Loopback, private, link-local, unique-local, carrier-grade NAT and
1165/// unspecified: the "internal network" set a host may choose to keep a
1166/// script away from.
1167#[must_use]
1168pub fn is_private_ip(ip: IpAddr) -> bool {
1169  match canon_ip(ip) {
1170    IpAddr::V4(v4) => {
1171      v4.is_loopback()
1172        || v4.is_private()
1173        || v4.is_link_local()
1174        || v4.is_unspecified()
1175        || v4.is_broadcast()
1176        || v4.octets()[0] == 0
1177        || (v4.octets()[0] == 100 && (64..=127).contains(&v4.octets()[1]))
1178    },
1179    IpAddr::V6(v6) => {
1180      v6.is_loopback()
1181        || v6.is_unspecified()
1182        || (v6.segments()[0] & 0xfe00) == 0xfc00
1183        || (v6.segments()[0] & 0xffc0) == 0xfe80
1184    },
1185  }
1186}
1187
1188/// An IPv4-mapped IPv6 address down to its IPv4 form, so range checks
1189/// see the real address.
1190fn canon_ip(ip: IpAddr) -> IpAddr {
1191  match ip {
1192    IpAddr::V6(v6) => v6.to_ipv4_mapped().map_or(IpAddr::V6(v6), IpAddr::V4),
1193    v4 @ IpAddr::V4(_) => v4,
1194  }
1195}
1196
1197#[cfg(test)]
1198mod tests {
1199  use super::*;
1200
1201  #[test]
1202  fn default_denies_everything() {
1203    let p = Permissions::none();
1204    assert!(p.check_read(Path::new("/etc/hosts")).is_err());
1205    assert!(p.check_write(Path::new("/tmp/x")).is_err());
1206    assert!(p.check_net("example.com", Some(443)).is_err());
1207    assert!(p.check_env("HOME").is_err());
1208    assert!(p.check_sys(SysInfo::Hostname).is_err());
1209  }
1210
1211  #[test]
1212  fn all_grants_everything() {
1213    let p = Permissions::all();
1214    assert!(p.check_read(Path::new("/etc/hosts")).is_ok());
1215    assert!(p.check_net("example.com", None).is_ok());
1216    assert!(p.check_env("HOME").is_ok());
1217    assert!(p.check_sys(SysInfo::Uid).is_ok());
1218  }
1219
1220  #[test]
1221  fn read_is_scoped_to_the_granted_root() {
1222    let dir = tempfile::tempdir().unwrap();
1223    let root = dir.path().join("data");
1224    std::fs::create_dir_all(&root).unwrap();
1225    let p = Permissions::none().allow_read([&root]);
1226    assert!(p.check_read(&root.join("a.txt")).is_ok());
1227    assert!(p.check_read(&root.join("sub/deep/b.txt")).is_ok());
1228    assert!(p.check_read(&root.join("../escape.txt")).is_err());
1229    assert!(p.check_read(dir.path()).is_err());
1230    // A sibling whose name merely starts with the root's name is not under it.
1231    assert!(p.check_read(&dir.path().join("data-other/x")).is_err());
1232  }
1233
1234  #[test]
1235  fn a_relative_path_resolves_against_cwd() {
1236    let cwd = std::env::current_dir().unwrap();
1237    let p = Permissions::none().allow_read([&cwd]);
1238    assert!(p.check_read(Path::new("Cargo.toml")).is_ok());
1239    assert!(p.check_read(Path::new("../../../../../../etc/passwd")).is_err());
1240  }
1241
1242  #[cfg(unix)]
1243  #[test]
1244  fn a_symlink_inside_the_root_cannot_point_out_of_it() {
1245    let dir = tempfile::tempdir().unwrap();
1246    let root = dir.path().join("root");
1247    let outside = dir.path().join("outside");
1248    std::fs::create_dir_all(&root).unwrap();
1249    std::fs::create_dir_all(&outside).unwrap();
1250    std::fs::write(outside.join("secret"), b"x").unwrap();
1251    std::os::unix::fs::symlink(&outside, root.join("link")).unwrap();
1252    let p = Permissions::none().allow_read([&root]);
1253    // Lexically inside, physically outside.
1254    assert!(p.check_read(&root.join("link/secret")).is_err());
1255    // A file that does not exist yet is judged by its real directory.
1256    assert!(p.check_read(&root.join("link/new-file")).is_err());
1257    assert!(p.check_read(&root.join("plain")).is_ok());
1258  }
1259
1260  #[cfg(unix)]
1261  #[test]
1262  fn a_root_that_is_itself_a_symlink_grants_its_target() {
1263    let dir = tempfile::tempdir().unwrap();
1264    let real = dir.path().join("real");
1265    std::fs::create_dir_all(&real).unwrap();
1266    let link = dir.path().join("link");
1267    std::os::unix::fs::symlink(&real, &link).unwrap();
1268    let p = Permissions::none().allow_read([&link]);
1269    assert!(p.check_read(&link.join("f")).is_ok());
1270  }
1271
1272  #[test]
1273  fn net_rules_match_host_wildcard_and_port() {
1274    let p = Permissions::none()
1275      .allow_net(["api.acme.com", "*.cdn.com", "127.0.0.1:8080", "[::1]:9"])
1276      .unwrap();
1277    assert!(p.check_net("api.acme.com", Some(443)).is_ok());
1278    assert!(p.check_net("API.ACME.COM", None).is_ok());
1279    assert!(p.check_net("cdn.com", Some(80)).is_ok());
1280    assert!(p.check_net("a.b.cdn.com", Some(80)).is_ok());
1281    assert!(p.check_net("evilcdn.com", Some(80)).is_err());
1282    assert!(p.check_net("acme.com", Some(443)).is_err());
1283    assert!(p.check_net("127.0.0.1", Some(8080)).is_ok());
1284    assert!(p.check_net("127.0.0.1", Some(8081)).is_err());
1285    assert!(p.check_net("::1", Some(9)).is_ok());
1286    assert!(p.check_net("[::1]", Some(9)).is_ok());
1287    assert!(p.check_net("::1", Some(10)).is_err());
1288  }
1289
1290  #[test]
1291  fn net_rule_parse_rejects_garbage() {
1292    assert!(NetRule::parse("").is_err());
1293    assert!(NetRule::parse("host:notaport").is_err());
1294    assert!(NetRule::parse("*.").is_err());
1295    assert!(NetRule::parse("[::1").is_err());
1296    assert_eq!(NetRule::parse("[::1]:80").unwrap().to_string(), "[::1]:80");
1297    assert_eq!(NetRule::parse("Example.COM").unwrap().to_string(), "example.com");
1298  }
1299
1300  #[test]
1301  fn intersect_never_widens() {
1302    let base = Permissions::none()
1303      .allow_net(["*.acme.com", "cdn.com:443"])
1304      .unwrap()
1305      .allow_env(["HOME", "USER"])
1306      .allow_all_read();
1307    let declared = Permissions::none()
1308      .allow_net(["api.acme.com", "evil.com", "cdn.com"])
1309      .unwrap()
1310      .allow_env(["USER", "SECRET"])
1311      .allow_read(["/srv"]);
1312    let narrowed = base.intersect(&declared);
1313    assert!(narrowed.check_net("api.acme.com", Some(443)).is_ok());
1314    assert!(narrowed.check_net("evil.com", Some(443)).is_err());
1315    // `cdn.com` on any port, against `cdn.com:443`: the one port both grant.
1316    assert!(narrowed.check_net("cdn.com", Some(443)).is_ok());
1317    assert!(narrowed.check_net("cdn.com", Some(80)).is_err());
1318    assert!(narrowed.check_env("USER").is_ok());
1319    assert!(narrowed.check_env("HOME").is_err());
1320    assert!(narrowed.check_env("SECRET").is_err());
1321    assert!(narrowed.check_read(Path::new("/srv/x")).is_ok());
1322    assert!(narrowed.check_read(Path::new("/etc/x")).is_err());
1323    // All ∩ All stays All; None ∩ anything is None.
1324    assert!(Permissions::all().intersect(&Permissions::all()).write.is_all());
1325    assert!(Permissions::all().intersect(&Permissions::none()).write.is_none());
1326  }
1327
1328  #[test]
1329  fn deny_overrides_allow() {
1330    let p = Permissions::all()
1331      .deny_read(["/etc"])
1332      .deny_net(["*.internal"])
1333      .unwrap()
1334      .deny_env(["SECRET"])
1335      .deny_sys([SysInfo::Username]);
1336    assert!(p.check_read(Path::new("/etc/passwd")).is_err());
1337    assert!(p.check_read(Path::new("/var/log")).is_ok());
1338    assert!(p.check_net("db.internal", Some(5432)).is_err());
1339    assert!(p.check_net("example.com", Some(443)).is_ok());
1340    assert!(p.check_env("SECRET").is_err());
1341    assert!(p.check_env("HOME").is_ok());
1342    assert!(p.check_sys(SysInfo::Username).is_err());
1343    assert!(p.check_sys(SysInfo::Hostname).is_ok());
1344    assert!(!p.env_snapshot().iter().any(|(k, _)| k == "SECRET"));
1345  }
1346
1347  #[test]
1348  fn a_container_only_narrows() {
1349    let c = Container::new(Permissions::all());
1350    assert!(c.check_env("ANY").is_ok());
1351    assert_eq!(c.has(Kind::Env, None), Ok(true));
1352    c.revoke(&Permissions::all().allow_env(["ONLY"]));
1353    assert!(c.check_env("ONLY").is_ok());
1354    assert!(c.check_env("ANY").is_err());
1355    assert_eq!(c.has(Kind::Env, None), Ok(false));
1356    assert_eq!(c.has(Kind::Env, Some("ONLY")), Ok(true));
1357    // Revoking with a wider policy changes nothing.
1358    c.revoke(&Permissions::all());
1359    assert!(c.check_env("ANY").is_err());
1360    c.deny(Kind::Env, "ONLY").unwrap();
1361    assert!(c.check_env("ONLY").is_err());
1362    c.deny(Kind::Net, "*.internal").unwrap();
1363    assert!(c.check_net("x.internal", Some(80)).is_err());
1364    assert!(c.check_net("example.com", Some(80)).is_ok());
1365    assert_eq!(c.has(Kind::Net, Some("example.com:80")), Ok(true));
1366    assert!(c.deny(Kind::Sys, "nope").is_err());
1367  }
1368
1369  #[test]
1370  fn hook_can_grant_and_audit_sees_both() {
1371    use std::sync::atomic::{AtomicUsize, Ordering};
1372    let seen = Arc::new(AtomicUsize::new(0));
1373    let seen2 = Arc::clone(&seen);
1374    let c = Container::new(Permissions::none())
1375      .with_hook(Arc::new(|req: &Request<'_>| {
1376        if req.kind == Kind::Env && req.resource == "PROMPTED" {
1377          Decision::Allow
1378        } else {
1379          Decision::Deny
1380        }
1381      }))
1382      .with_audit(Arc::new(move |_req: &Request<'_>, _granted: bool| {
1383        seen2.fetch_add(1, Ordering::Relaxed);
1384      }));
1385    assert!(c.check_env("PROMPTED").is_ok());
1386    assert!(c.check_env("OTHER").is_err());
1387    assert_eq!(seen.load(Ordering::Relaxed), 2);
1388  }
1389
1390  #[test]
1391  fn denied_renders_kind_and_resource() {
1392    let d = Denied::new(Kind::Net, "evil.com:443");
1393    assert_eq!(
1394      d.to_string(),
1395      "permission denied: network access to \"evil.com:443\" (grant `net` to allow it)"
1396    );
1397  }
1398
1399  #[test]
1400  fn metadata_and_private_ranges() {
1401    assert!(is_metadata_ip("169.254.169.254".parse().unwrap()));
1402    assert!(is_metadata_ip("::ffff:169.254.169.254".parse().unwrap()));
1403    assert!(is_metadata_ip("fd00:ec2::254".parse().unwrap()));
1404    assert!(!is_metadata_ip("93.184.216.34".parse().unwrap()));
1405    for ip in [
1406      "127.0.0.1",
1407      "10.0.0.1",
1408      "192.168.1.1",
1409      "172.16.0.1",
1410      "100.64.0.1",
1411      "::1",
1412      "fe80::1",
1413      "fc00::1",
1414    ] {
1415      assert!(is_private_ip(ip.parse().unwrap()), "{ip}");
1416    }
1417    assert!(!is_private_ip("8.8.8.8".parse().unwrap()));
1418  }
1419
1420  #[test]
1421  fn env_snapshot_filters_to_the_grant() {
1422    let p = Permissions::none().allow_env(["PATH", "FERRIJS_SURELY_UNSET_VAR"]);
1423    let snap = p.env_snapshot();
1424    assert!(snap.iter().any(|(k, _)| k == "PATH"));
1425    assert!(!snap.iter().any(|(k, _)| k == "FERRIJS_SURELY_UNSET_VAR"));
1426    assert!(Permissions::none().env_snapshot().is_empty());
1427  }
1428
1429  #[cfg(feature = "serde")]
1430  #[test]
1431  fn serde_shape_is_bool_or_list() {
1432    let doc = r#"{"read": true, "write": ["/srv/out"], "net": ["*.acme.com:443"], "env": false, "sys": ["hostname"], "deny": {"read": ["/etc"]}}"#;
1433    let p: Permissions = serde_json::from_str(doc).unwrap();
1434    assert!(p.read.is_all());
1435    assert_eq!(p.write.entries().len(), 1);
1436    assert!(p.env.is_none());
1437    assert_eq!(p.sys.entries(), &[SysInfo::Hostname]);
1438    assert_eq!(p.deny.read.len(), 1);
1439    assert!(p.check_read(Path::new("/etc/hosts")).is_err());
1440    let back = serde_json::to_value(&p).unwrap();
1441    assert_eq!(back["read"], serde_json::Value::Bool(true));
1442    assert_eq!(back["net"][0], "*.acme.com:443");
1443    assert_eq!(back["deny"]["read"][0], "/etc");
1444    let plain: Permissions = serde_json::from_str(r#"{"read": true}"#).unwrap();
1445    assert!(serde_json::to_value(&plain).unwrap().get("deny").is_none());
1446  }
1447}