Skip to main content

sandlock_core/
policy.rs

1use std::collections::HashMap;
2use std::path::PathBuf;
3use std::time::SystemTime;
4
5use serde::{Deserialize, Serialize};
6
7use crate::error::PolicyError;
8
9/// A byte size value.
10#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
11pub struct ByteSize(pub u64);
12
13impl ByteSize {
14    pub fn bytes(n: u64) -> Self {
15        ByteSize(n)
16    }
17
18    pub fn kib(n: u64) -> Self {
19        ByteSize(n * 1024)
20    }
21
22    pub fn mib(n: u64) -> Self {
23        ByteSize(n * 1024 * 1024)
24    }
25
26    pub fn gib(n: u64) -> Self {
27        ByteSize(n * 1024 * 1024 * 1024)
28    }
29
30    pub fn parse(s: &str) -> Result<Self, PolicyError> {
31        let s = s.trim();
32        if s.is_empty() {
33            return Err(PolicyError::Invalid("empty byte size string".into()));
34        }
35
36        // Check for suffix
37        let last = s.chars().last().unwrap();
38        if last.is_ascii_alphabetic() {
39            let (num_str, suffix) = s.split_at(s.len() - 1);
40            let n: u64 = num_str
41                .trim()
42                .parse()
43                .map_err(|_| PolicyError::Invalid(format!("invalid byte size: {}", s)))?;
44            match suffix.to_ascii_uppercase().as_str() {
45                "K" => Ok(ByteSize::kib(n)),
46                "M" => Ok(ByteSize::mib(n)),
47                "G" => Ok(ByteSize::gib(n)),
48                other => Err(PolicyError::Invalid(format!("unknown byte size suffix: {}", other))),
49            }
50        } else {
51            let n: u64 = s
52                .parse()
53                .map_err(|_| PolicyError::Invalid(format!("invalid byte size: {}", s)))?;
54            Ok(ByteSize(n))
55        }
56    }
57}
58
59/// Filesystem isolation mode.
60#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
61pub enum FsIsolation {
62    #[default]
63    None,
64    OverlayFs,
65    BranchFs,
66}
67
68/// Action to take on branch exit.
69#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
70pub enum BranchAction {
71    #[default]
72    Commit,
73    Abort,
74    Keep,
75}
76
77/// An HTTP access control rule.
78#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
79pub struct HttpRule {
80    pub method: String,
81    pub host: String,
82    pub path: String,
83}
84
85impl HttpRule {
86    /// Parse a rule from "METHOD host/path" format.
87    ///
88    /// Examples:
89    /// - `"GET api.example.com/v1/*"` → method="GET", host="api.example.com", path="/v1/*"
90    /// - `"* */admin/*"` → method="*", host="*", path="/admin/*"
91    /// - `"GET example.com"` → method="GET", host="example.com", path="/*"
92    pub fn parse(s: &str) -> Result<Self, PolicyError> {
93        let s = s.trim();
94        let (method, rest) = s
95            .split_once(char::is_whitespace)
96            .ok_or_else(|| PolicyError::Invalid(format!("invalid http rule: {}", s)))?;
97        let rest = rest.trim();
98        if rest.is_empty() {
99            return Err(PolicyError::Invalid(format!("invalid http rule: {}", s)));
100        }
101
102        let (host, path) = if let Some(pos) = rest.find('/') {
103            let (h, p) = rest.split_at(pos);
104            // Normalize the rule path, but preserve trailing * for glob matching.
105            let has_wildcard = p.ends_with('*');
106            let mut normalized = normalize_path(p);
107            if has_wildcard && !normalized.ends_with('*') {
108                normalized.push('*');
109            }
110            (h.to_string(), normalized)
111        } else {
112            (rest.to_string(), "/*".to_string())
113        };
114
115        Ok(HttpRule {
116            method: method.to_uppercase(),
117            host,
118            path,
119        })
120    }
121
122    /// Check whether this rule matches the given request parameters.
123    /// The request path is normalized before matching to prevent bypasses
124    /// via `//`, `/../`, `/.`, or percent-encoding.
125    pub fn matches(&self, method: &str, host: &str, path: &str) -> bool {
126        // Method match
127        if self.method != "*" && !self.method.eq_ignore_ascii_case(method) {
128            return false;
129        }
130        // Host match
131        if self.host != "*" && !self.host.eq_ignore_ascii_case(host) {
132            return false;
133        }
134        // Path match — normalize to prevent encoding/traversal bypasses
135        let normalized = normalize_path(path);
136        prefix_or_exact_match(&self.path, &normalized)
137    }
138}
139
140/// Normalize an HTTP path to prevent ACL bypasses via encoding tricks.
141///
142/// - Decodes percent-encoded characters (e.g. `%2F` → `/`, `%61` → `a`)
143/// - Collapses duplicate slashes (`//` → `/`)
144/// - Resolves `.` and `..` segments
145/// - Ensures the path starts with `/`
146pub fn normalize_path(path: &str) -> String {
147    // 1. Percent-decode
148    let mut decoded = String::with_capacity(path.len());
149    let mut chars = path.bytes();
150    while let Some(b) = chars.next() {
151        if b == b'%' {
152            let hi = chars.next();
153            let lo = chars.next();
154            if let (Some(h), Some(l)) = (hi, lo) {
155                let hex = [h, l];
156                if let Ok(s) = std::str::from_utf8(&hex) {
157                    if let Ok(val) = u8::from_str_radix(s, 16) {
158                        decoded.push(val as char);
159                        continue;
160                    }
161                }
162                // Malformed percent encoding — keep as-is
163                decoded.push(b as char);
164                decoded.push(h as char);
165                decoded.push(l as char);
166            } else {
167                decoded.push(b as char);
168            }
169        } else {
170            decoded.push(b as char);
171        }
172    }
173
174    // 2. Split into segments, resolve . and .., skip empty segments (collapses //)
175    let mut segments: Vec<&str> = Vec::new();
176    for seg in decoded.split('/') {
177        match seg {
178            "" | "." => {}
179            ".." => {
180                segments.pop();
181            }
182            s => segments.push(s),
183        }
184    }
185
186    // 3. Reconstruct with leading /
187    let mut result = String::with_capacity(decoded.len());
188    result.push('/');
189    result.push_str(&segments.join("/"));
190    result
191}
192
193/// Simple prefix or exact matching for paths. Supports trailing `*` as a prefix match.
194///
195/// Only supports:
196/// - `"/*"` or `"*"` matches everything
197/// - `"/v1/*"` matches "/v1/foo", "/v1/foo/bar" (prefix match)
198/// - `"/v1/models"` matches exactly "/v1/models" (exact match)
199///
200/// Does NOT support mid-pattern wildcards (e.g., "/v1/*/models").
201pub fn prefix_or_exact_match(pattern: &str, value: &str) -> bool {
202    if pattern == "/*" || pattern == "*" {
203        return true;
204    }
205    if let Some(prefix) = pattern.strip_suffix('*') {
206        value.starts_with(prefix)
207    } else {
208        pattern == value
209    }
210}
211
212/// Evaluate HTTP ACL rules against a request.
213///
214/// - Deny rules are checked first; if any match, return false.
215/// - Allow rules are checked next; if any match, return true.
216/// - If allow rules exist but none matched, return false (deny-by-default).
217/// - If no rules at all, return true (unrestricted).
218pub fn http_acl_check(
219    allow: &[HttpRule],
220    deny: &[HttpRule],
221    method: &str,
222    host: &str,
223    path: &str,
224) -> bool {
225    // Deny rules checked first
226    for rule in deny {
227        if rule.matches(method, host, path) {
228            return false;
229        }
230    }
231    // Allow rules checked next
232    if allow.is_empty() && deny.is_empty() {
233        return true; // unrestricted
234    }
235    if allow.is_empty() {
236        // Only deny rules exist; anything not denied is allowed
237        return true;
238    }
239    for rule in allow {
240        if rule.matches(method, host, path) {
241            return true;
242        }
243    }
244    false // allow rules exist but none matched
245}
246
247/// Sandbox policy configuration.
248#[derive(Clone, Serialize, Deserialize)]
249pub struct Policy {
250    // Filesystem access
251    pub fs_writable: Vec<PathBuf>,
252    pub fs_readable: Vec<PathBuf>,
253    pub fs_denied: Vec<PathBuf>,
254
255    // Syscall filtering
256    pub deny_syscalls: Option<Vec<String>>,
257    pub allow_syscalls: Option<Vec<String>>,
258
259    // Network
260    /// Allowed domain names.
261    ///
262    /// * `None` — unrestricted: the real `/etc/hosts` is visible and DNS is
263    ///   not virtualized.
264    /// * `Some(empty)` — deny all: `/etc/hosts` is virtualized to an empty
265    ///   map and the IP allowlist is empty (no hosts resolvable).
266    /// * `Some(nonempty)` — allowlist: only these domains are resolved and
267    ///   their IPs placed in the allowlist.
268    pub net_allow_hosts: Option<Vec<String>>,
269    pub net_bind: Vec<u16>,
270    pub net_connect: Vec<u16>,
271    pub no_raw_sockets: bool,
272    pub no_udp: bool,
273
274    // HTTP ACL
275    pub http_allow: Vec<HttpRule>,
276    pub http_deny: Vec<HttpRule>,
277    /// TCP ports to intercept for HTTP ACL. Defaults to [80] (plus 443 when
278    /// https_ca is set). Override with `http_ports` to intercept custom ports.
279    pub http_ports: Vec<u16>,
280    /// PEM CA cert for HTTPS MITM. When set, port 443 is also intercepted.
281    pub https_ca: Option<PathBuf>,
282    /// PEM CA key for HTTPS MITM. Required when https_ca is set.
283    pub https_key: Option<PathBuf>,
284
285    // Namespace isolation — always enabled, not user-configurable.
286
287    // Resource limits
288    pub max_memory: Option<ByteSize>,
289    pub max_processes: u32,
290    pub max_open_files: Option<u32>,
291    pub max_cpu: Option<u8>,
292
293    // Reproducibility
294    pub random_seed: Option<u64>,
295    pub time_start: Option<SystemTime>,
296    pub no_randomize_memory: bool,
297    pub no_huge_pages: bool,
298    pub no_coredump: bool,
299    pub deterministic_dirs: bool,
300    pub hostname: Option<String>,
301
302    // Filesystem branch
303    pub fs_isolation: FsIsolation,
304    pub workdir: Option<PathBuf>,
305    pub cwd: Option<PathBuf>,
306    pub fs_storage: Option<PathBuf>,
307    pub max_disk: Option<ByteSize>,
308    pub on_exit: BranchAction,
309    pub on_error: BranchAction,
310
311    // Mount mappings: (virtual_path_inside_chroot, host_path_on_disk)
312    pub fs_mount: Vec<(PathBuf, PathBuf)>,
313
314    // Environment
315    pub chroot: Option<PathBuf>,
316    pub clean_env: bool,
317    pub env: HashMap<String, String>,
318    // Devices
319    pub gpu_devices: Option<Vec<u32>>,
320
321    // CPU
322    pub cpu_cores: Option<Vec<u32>>,
323    pub num_cpus: Option<u32>,
324    pub port_remap: bool,
325
326    // User namespace
327    pub uid: Option<u32>,
328
329    // Dynamic policy callback
330    #[serde(skip)]
331    pub policy_fn: Option<crate::policy_fn::PolicyCallback>,
332}
333
334impl std::fmt::Debug for Policy {
335    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
336        f.debug_struct("Policy")
337            .field("fs_readable", &self.fs_readable)
338            .field("fs_writable", &self.fs_writable)
339            .field("max_memory", &self.max_memory)
340            .field("max_processes", &self.max_processes)
341            .field("policy_fn", &self.policy_fn.as_ref().map(|_| "<callback>"))
342            .finish_non_exhaustive()
343    }
344}
345
346impl Policy {
347    pub fn builder() -> PolicyBuilder {
348        PolicyBuilder::default()
349    }
350}
351
352/// Fluent builder for `Policy`.
353#[derive(Default)]
354pub struct PolicyBuilder {
355    fs_writable: Vec<PathBuf>,
356    fs_readable: Vec<PathBuf>,
357    fs_denied: Vec<PathBuf>,
358
359    deny_syscalls: Option<Vec<String>>,
360    allow_syscalls: Option<Vec<String>>,
361
362    net_allow_hosts: Option<Vec<String>>,
363    net_bind: Vec<u16>,
364    net_connect: Vec<u16>,
365    no_raw_sockets: Option<bool>,
366    no_udp: bool,
367
368    http_allow: Vec<String>,
369    http_deny: Vec<String>,
370    http_ports: Vec<u16>,
371    https_ca: Option<PathBuf>,
372    https_key: Option<PathBuf>,
373
374    max_memory: Option<ByteSize>,
375    max_processes: Option<u32>,
376    max_open_files: Option<u32>,
377    max_cpu: Option<u8>,
378
379    random_seed: Option<u64>,
380    time_start: Option<SystemTime>,
381    no_randomize_memory: bool,
382    no_huge_pages: bool,
383    no_coredump: bool,
384    deterministic_dirs: bool,
385    hostname: Option<String>,
386
387    fs_isolation: Option<FsIsolation>,
388    workdir: Option<PathBuf>,
389    cwd: Option<PathBuf>,
390    fs_storage: Option<PathBuf>,
391    max_disk: Option<ByteSize>,
392    on_exit: Option<BranchAction>,
393    on_error: Option<BranchAction>,
394
395    fs_mount: Vec<(PathBuf, PathBuf)>,
396    chroot: Option<PathBuf>,
397    clean_env: bool,
398    env: HashMap<String, String>,
399
400    gpu_devices: Option<Vec<u32>>,
401
402    cpu_cores: Option<Vec<u32>>,
403    num_cpus: Option<u32>,
404    port_remap: bool,
405
406    uid: Option<u32>,
407    policy_fn: Option<crate::policy_fn::PolicyCallback>,
408}
409
410impl PolicyBuilder {
411    pub fn fs_write(mut self, path: impl Into<PathBuf>) -> Self {
412        self.fs_writable.push(path.into());
413        self
414    }
415
416    pub fn fs_read(mut self, path: impl Into<PathBuf>) -> Self {
417        self.fs_readable.push(path.into());
418        self
419    }
420
421    pub fn fs_read_if_exists(self, path: impl Into<PathBuf>) -> Self {
422        let path = path.into();
423        if path.exists() {
424            self.fs_read(path)
425        } else {
426            self
427        }
428    }
429
430    pub fn fs_deny(mut self, path: impl Into<PathBuf>) -> Self {
431        self.fs_denied.push(path.into());
432        self
433    }
434
435    pub fn deny_syscalls(mut self, calls: Vec<String>) -> Self {
436        self.deny_syscalls = Some(calls);
437        self
438    }
439
440    pub fn allow_syscalls(mut self, calls: Vec<String>) -> Self {
441        self.allow_syscalls = Some(calls);
442        self
443    }
444
445    /// Add a host to the domain allowlist. Implicitly enables host
446    /// restriction (switches `net_allow_hosts` from `None` to `Some`).
447    pub fn net_allow_host(mut self, host: impl Into<String>) -> Self {
448        self.net_allow_hosts
449            .get_or_insert_with(Vec::new)
450            .push(host.into());
451        self
452    }
453
454    /// Enable host restriction without adding any hosts. The resulting
455    /// sandbox has an empty `/etc/hosts` and no resolvable domains —
456    /// equivalent to "deny all hosts".
457    pub fn net_restrict_hosts(mut self) -> Self {
458        self.net_allow_hosts.get_or_insert_with(Vec::new);
459        self
460    }
461
462    pub fn net_bind_port(mut self, port: u16) -> Self {
463        self.net_bind.push(port);
464        self
465    }
466
467    pub fn net_connect_port(mut self, port: u16) -> Self {
468        self.net_connect.push(port);
469        self
470    }
471
472    pub fn no_raw_sockets(mut self, v: bool) -> Self {
473        self.no_raw_sockets = Some(v);
474        self
475    }
476
477    pub fn no_udp(mut self, v: bool) -> Self {
478        self.no_udp = v;
479        self
480    }
481
482    pub fn http_allow(mut self, rule: &str) -> Self {
483        self.http_allow.push(rule.to_string());
484        self
485    }
486
487    pub fn http_deny(mut self, rule: &str) -> Self {
488        self.http_deny.push(rule.to_string());
489        self
490    }
491
492    pub fn http_port(mut self, port: u16) -> Self {
493        self.http_ports.push(port);
494        // HTTP ACL intercepts TCP connections on this port, so it must be
495        // in the Landlock net_connect allowlist too.
496        if !self.net_connect.contains(&port) {
497            self.net_connect.push(port);
498        }
499        self
500    }
501
502    pub fn https_ca(mut self, path: impl Into<PathBuf>) -> Self {
503        self.https_ca = Some(path.into());
504        self
505    }
506
507    pub fn https_key(mut self, path: impl Into<PathBuf>) -> Self {
508        self.https_key = Some(path.into());
509        self
510    }
511
512    pub fn max_memory(mut self, size: ByteSize) -> Self {
513        self.max_memory = Some(size);
514        self
515    }
516
517    pub fn max_processes(mut self, n: u32) -> Self {
518        self.max_processes = Some(n);
519        self
520    }
521
522    pub fn max_open_files(mut self, n: u32) -> Self {
523        self.max_open_files = Some(n);
524        self
525    }
526
527    pub fn max_cpu(mut self, pct: u8) -> Self {
528        self.max_cpu = Some(pct);
529        self
530    }
531
532    pub fn random_seed(mut self, seed: u64) -> Self {
533        self.random_seed = Some(seed);
534        self
535    }
536
537    pub fn time_start(mut self, t: SystemTime) -> Self {
538        self.time_start = Some(t);
539        self
540    }
541
542    pub fn no_randomize_memory(mut self, v: bool) -> Self {
543        self.no_randomize_memory = v;
544        self
545    }
546
547    pub fn no_huge_pages(mut self, v: bool) -> Self {
548        self.no_huge_pages = v;
549        self
550    }
551
552    pub fn no_coredump(mut self, v: bool) -> Self {
553        self.no_coredump = v;
554        self
555    }
556
557    pub fn deterministic_dirs(mut self, v: bool) -> Self {
558        self.deterministic_dirs = v;
559        self
560    }
561
562    pub fn hostname(mut self, name: impl Into<String>) -> Self {
563        self.hostname = Some(name.into());
564        self
565    }
566
567    pub fn fs_isolation(mut self, iso: FsIsolation) -> Self {
568        self.fs_isolation = Some(iso);
569        self
570    }
571
572    pub fn workdir(mut self, path: impl Into<PathBuf>) -> Self {
573        self.workdir = Some(path.into());
574        self
575    }
576
577    pub fn cwd(mut self, path: impl Into<PathBuf>) -> Self {
578        self.cwd = Some(path.into());
579        self
580    }
581
582    pub fn fs_storage(mut self, path: impl Into<PathBuf>) -> Self {
583        self.fs_storage = Some(path.into());
584        self
585    }
586
587    pub fn max_disk(mut self, size: ByteSize) -> Self {
588        self.max_disk = Some(size);
589        self
590    }
591
592    pub fn on_exit(mut self, action: BranchAction) -> Self {
593        self.on_exit = Some(action);
594        self
595    }
596
597    pub fn on_error(mut self, action: BranchAction) -> Self {
598        self.on_error = Some(action);
599        self
600    }
601
602    pub fn chroot(mut self, path: impl Into<PathBuf>) -> Self {
603        self.chroot = Some(path.into());
604        self
605    }
606
607    pub fn fs_mount(mut self, virtual_path: impl Into<PathBuf>, host_path: impl Into<PathBuf>) -> Self {
608        self.fs_mount.push((virtual_path.into(), host_path.into()));
609        self
610    }
611
612    pub fn clean_env(mut self, v: bool) -> Self {
613        self.clean_env = v;
614        self
615    }
616
617    pub fn env_var(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
618        self.env.insert(key.into(), value.into());
619        self
620    }
621
622
623    pub fn gpu_devices(mut self, devices: Vec<u32>) -> Self {
624        self.gpu_devices = Some(devices);
625        self
626    }
627
628    pub fn cpu_cores(mut self, cores: Vec<u32>) -> Self {
629        self.cpu_cores = Some(cores);
630        self
631    }
632
633    pub fn num_cpus(mut self, n: u32) -> Self {
634        self.num_cpus = Some(n);
635        self
636    }
637
638    pub fn port_remap(mut self, v: bool) -> Self {
639        self.port_remap = v;
640        self
641    }
642
643    pub fn policy_fn(
644        mut self,
645        f: impl Fn(crate::policy_fn::SyscallEvent, &mut crate::policy_fn::PolicyContext) -> crate::policy_fn::Verdict + Send + Sync + 'static,
646    ) -> Self {
647        self.policy_fn = Some(std::sync::Arc::new(f));
648        self
649    }
650
651    pub fn uid(mut self, id: u32) -> Self {
652        self.uid = Some(id);
653        self
654    }
655
656    pub fn build(self) -> Result<Policy, PolicyError> {
657        // Validate: deny_syscalls and allow_syscalls are mutually exclusive
658        if self.deny_syscalls.is_some() && self.allow_syscalls.is_some() {
659            return Err(PolicyError::MutuallyExclusiveSyscalls);
660        }
661
662        // Validate: max_cpu must be 1-100
663        if let Some(cpu) = self.max_cpu {
664            if cpu == 0 || cpu > 100 {
665                return Err(PolicyError::InvalidCpuPercent(cpu));
666            }
667        }
668
669        // Validate: https_ca and https_key must both be set or both unset
670        if self.https_ca.is_some() != self.https_key.is_some() {
671            return Err(PolicyError::Invalid(
672                "--https-ca and --https-key must both be provided together".into(),
673            ));
674        }
675
676        // Parse HTTP rules (deferred from builder methods to propagate errors)
677        let http_allow: Vec<HttpRule> = self
678            .http_allow
679            .iter()
680            .map(|s| HttpRule::parse(s))
681            .collect::<Result<_, _>>()?;
682        let http_deny: Vec<HttpRule> = self
683            .http_deny
684            .iter()
685            .map(|s| HttpRule::parse(s))
686            .collect::<Result<_, _>>()?;
687
688        // Default HTTP intercept ports: 80 always, 443 when HTTPS CA is configured.
689        let http_ports = if self.http_ports.is_empty() && (!http_allow.is_empty() || !http_deny.is_empty()) {
690            let mut ports = vec![80];
691            if self.https_ca.is_some() {
692                ports.push(443);
693            }
694            ports
695        } else {
696            self.http_ports
697        };
698
699        // Validate: fs_isolation != None requires workdir
700        let fs_isolation = self.fs_isolation.unwrap_or_default();
701        if fs_isolation != FsIsolation::None && self.workdir.is_none() {
702            return Err(PolicyError::FsIsolationRequiresWorkdir);
703        }
704
705        Ok(Policy {
706            fs_writable: self.fs_writable,
707            fs_readable: self.fs_readable,
708            fs_denied: self.fs_denied,
709            deny_syscalls: self.deny_syscalls,
710            allow_syscalls: self.allow_syscalls,
711            net_allow_hosts: self.net_allow_hosts,
712            net_bind: self.net_bind,
713            net_connect: self.net_connect,
714            no_raw_sockets: self.no_raw_sockets.unwrap_or(true),
715            no_udp: self.no_udp,
716            http_allow,
717            http_deny,
718            http_ports,
719            https_ca: self.https_ca,
720            https_key: self.https_key,
721            max_memory: self.max_memory,
722            max_processes: self.max_processes.unwrap_or(64),
723            max_open_files: self.max_open_files,
724            max_cpu: self.max_cpu,
725            random_seed: self.random_seed,
726            time_start: self.time_start,
727            no_randomize_memory: self.no_randomize_memory,
728            no_huge_pages: self.no_huge_pages,
729            no_coredump: self.no_coredump,
730            deterministic_dirs: self.deterministic_dirs,
731            hostname: self.hostname,
732            fs_isolation,
733            workdir: self.workdir,
734            cwd: self.cwd,
735            fs_storage: self.fs_storage,
736            max_disk: self.max_disk,
737            on_exit: self.on_exit.unwrap_or_default(),
738            on_error: self.on_error.unwrap_or_default(),
739            fs_mount: self.fs_mount,
740            chroot: self.chroot,
741            clean_env: self.clean_env,
742            env: self.env,
743            gpu_devices: self.gpu_devices,
744            cpu_cores: self.cpu_cores,
745            num_cpus: self.num_cpus,
746            port_remap: self.port_remap,
747            uid: self.uid,
748            policy_fn: self.policy_fn,
749        })
750    }
751}
752
753#[cfg(test)]
754mod http_rule_tests {
755    use super::*;
756
757    // --- HttpRule::parse tests ---
758
759    #[test]
760    fn parse_basic_get() {
761        let rule = HttpRule::parse("GET api.example.com/v1/*").unwrap();
762        assert_eq!(rule.method, "GET");
763        assert_eq!(rule.host, "api.example.com");
764        assert_eq!(rule.path, "/v1/*");
765    }
766
767    #[test]
768    fn parse_wildcard_method_and_host() {
769        let rule = HttpRule::parse("* */admin/*").unwrap();
770        assert_eq!(rule.method, "*");
771        assert_eq!(rule.host, "*");
772        assert_eq!(rule.path, "/admin/*");
773    }
774
775    #[test]
776    fn parse_post_with_exact_path() {
777        let rule = HttpRule::parse("POST example.com/upload").unwrap();
778        assert_eq!(rule.method, "POST");
779        assert_eq!(rule.host, "example.com");
780        assert_eq!(rule.path, "/upload");
781    }
782
783    #[test]
784    fn parse_no_path_defaults_to_wildcard() {
785        let rule = HttpRule::parse("GET example.com").unwrap();
786        assert_eq!(rule.method, "GET");
787        assert_eq!(rule.host, "example.com");
788        assert_eq!(rule.path, "/*");
789    }
790
791    #[test]
792    fn parse_method_uppercased() {
793        let rule = HttpRule::parse("get example.com/foo").unwrap();
794        assert_eq!(rule.method, "GET");
795    }
796
797    #[test]
798    fn parse_error_no_space() {
799        assert!(HttpRule::parse("GETexample.com").is_err());
800    }
801
802    #[test]
803    fn parse_error_empty_host() {
804        assert!(HttpRule::parse("GET  ").is_err());
805    }
806
807    // --- prefix_or_exact_match tests ---
808
809    #[test]
810    fn prefix_or_exact_match_wildcard_all() {
811        assert!(prefix_or_exact_match("/*", "/anything"));
812        assert!(prefix_or_exact_match("*", "/anything"));
813        assert!(prefix_or_exact_match("/*", "/"));
814    }
815
816    #[test]
817    fn prefix_or_exact_match_prefix() {
818        assert!(prefix_or_exact_match("/v1/*", "/v1/foo"));
819        assert!(prefix_or_exact_match("/v1/*", "/v1/foo/bar"));
820        assert!(prefix_or_exact_match("/v1/*", "/v1/"));
821        assert!(!prefix_or_exact_match("/v1/*", "/v2/foo"));
822    }
823
824    #[test]
825    fn prefix_or_exact_match_exact() {
826        assert!(prefix_or_exact_match("/v1/models", "/v1/models"));
827        assert!(!prefix_or_exact_match("/v1/models", "/v1/models/extra"));
828        assert!(!prefix_or_exact_match("/v1/models", "/v1/model"));
829    }
830
831    // --- HttpRule::matches tests ---
832
833    #[test]
834    fn matches_exact() {
835        let rule = HttpRule::parse("GET api.example.com/v1/models").unwrap();
836        assert!(rule.matches("GET", "api.example.com", "/v1/models"));
837        assert!(!rule.matches("POST", "api.example.com", "/v1/models"));
838        assert!(!rule.matches("GET", "other.com", "/v1/models"));
839        assert!(!rule.matches("GET", "api.example.com", "/v1/other"));
840    }
841
842    #[test]
843    fn matches_wildcard_method() {
844        let rule = HttpRule::parse("* api.example.com/v1/*").unwrap();
845        assert!(rule.matches("GET", "api.example.com", "/v1/foo"));
846        assert!(rule.matches("POST", "api.example.com", "/v1/bar"));
847    }
848
849    #[test]
850    fn matches_wildcard_host() {
851        let rule = HttpRule::parse("GET */v1/*").unwrap();
852        assert!(rule.matches("GET", "any.host.com", "/v1/foo"));
853    }
854
855    #[test]
856    fn matches_case_insensitive_method() {
857        let rule = HttpRule::parse("GET example.com/foo").unwrap();
858        assert!(rule.matches("get", "example.com", "/foo"));
859        assert!(rule.matches("Get", "example.com", "/foo"));
860    }
861
862    #[test]
863    fn matches_case_insensitive_host() {
864        let rule = HttpRule::parse("GET Example.COM/foo").unwrap();
865        assert!(rule.matches("GET", "example.com", "/foo"));
866    }
867
868    // --- http_acl_check tests ---
869
870    #[test]
871    fn acl_no_rules_allows_all() {
872        assert!(http_acl_check(&[], &[], "GET", "example.com", "/foo"));
873    }
874
875    #[test]
876    fn acl_allow_only_permits_matching() {
877        let allow = vec![HttpRule::parse("GET api.example.com/v1/*").unwrap()];
878        assert!(http_acl_check(&allow, &[], "GET", "api.example.com", "/v1/foo"));
879        assert!(!http_acl_check(&allow, &[], "POST", "api.example.com", "/v1/foo"));
880        assert!(!http_acl_check(&allow, &[], "GET", "other.com", "/v1/foo"));
881    }
882
883    #[test]
884    fn acl_deny_only_blocks_matching() {
885        let deny = vec![HttpRule::parse("* */admin/*").unwrap()];
886        assert!(!http_acl_check(&[], &deny, "GET", "example.com", "/admin/settings"));
887        assert!(http_acl_check(&[], &deny, "GET", "example.com", "/public/page"));
888    }
889
890    #[test]
891    fn acl_deny_takes_precedence_over_allow() {
892        let allow = vec![HttpRule::parse("* example.com/*").unwrap()];
893        let deny = vec![HttpRule::parse("* example.com/admin/*").unwrap()];
894        assert!(http_acl_check(&allow, &deny, "GET", "example.com", "/public"));
895        assert!(!http_acl_check(&allow, &deny, "GET", "example.com", "/admin/settings"));
896    }
897
898    #[test]
899    fn acl_allow_deny_by_default_when_no_match() {
900        let allow = vec![HttpRule::parse("GET api.example.com/v1/*").unwrap()];
901        // Different host, not matched by allow -> denied
902        assert!(!http_acl_check(&allow, &[], "GET", "evil.com", "/v1/foo"));
903    }
904
905    // --- PolicyBuilder integration ---
906
907    #[test]
908    fn builder_http_rules() {
909        let policy = Policy::builder()
910            .http_allow("GET api.example.com/v1/*")
911            .http_deny("* */admin/*")
912            .build()
913            .unwrap();
914        assert_eq!(policy.http_allow.len(), 1);
915        assert_eq!(policy.http_deny.len(), 1);
916        assert_eq!(policy.http_allow[0].method, "GET");
917        assert_eq!(policy.http_deny[0].host, "*");
918    }
919
920    #[test]
921    fn builder_invalid_http_allow_returns_error() {
922        let result = Policy::builder()
923            .http_allow("GETexample.com")
924            .build();
925        assert!(result.is_err());
926    }
927
928    #[test]
929    fn builder_invalid_http_deny_returns_error() {
930        let result = Policy::builder()
931            .http_deny("BADRULE")
932            .build();
933        assert!(result.is_err());
934    }
935
936    #[test]
937    fn builder_https_ca_without_key_returns_error() {
938        let result = Policy::builder()
939            .https_ca("/tmp/ca.pem")
940            .build();
941        assert!(result.is_err());
942    }
943
944    #[test]
945    fn builder_https_key_without_ca_returns_error() {
946        let result = Policy::builder()
947            .https_key("/tmp/key.pem")
948            .build();
949        assert!(result.is_err());
950    }
951
952    #[test]
953    fn builder_https_ca_and_key_together_ok() {
954        let policy = Policy::builder()
955            .https_ca("/tmp/ca.pem")
956            .https_key("/tmp/key.pem")
957            .build()
958            .unwrap();
959        assert!(policy.https_ca.is_some());
960        assert!(policy.https_key.is_some());
961    }
962
963    // --- normalize_path tests ---
964
965    #[test]
966    fn normalize_path_basic() {
967        assert_eq!(normalize_path("/foo/bar"), "/foo/bar");
968        assert_eq!(normalize_path("/"), "/");
969    }
970
971    #[test]
972    fn normalize_path_double_slashes() {
973        assert_eq!(normalize_path("/foo//bar"), "/foo/bar");
974        assert_eq!(normalize_path("//foo///bar//"), "/foo/bar");
975    }
976
977    #[test]
978    fn normalize_path_dot_segments() {
979        assert_eq!(normalize_path("/foo/./bar"), "/foo/bar");
980        assert_eq!(normalize_path("/foo/../bar"), "/bar");
981        assert_eq!(normalize_path("/foo/bar/../../baz"), "/baz");
982    }
983
984    #[test]
985    fn normalize_path_dotdot_at_root() {
986        assert_eq!(normalize_path("/../foo"), "/foo");
987        assert_eq!(normalize_path("/../../foo"), "/foo");
988    }
989
990    #[test]
991    fn normalize_path_percent_encoding() {
992        // %2F = /, %61 = a
993        assert_eq!(normalize_path("/foo%2Fbar"), "/foo/bar");
994        assert_eq!(normalize_path("/%61dmin/settings"), "/admin/settings");
995    }
996
997    #[test]
998    fn normalize_path_mixed_bypass_attempts() {
999        // Double-encoded traversal
1000        assert_eq!(normalize_path("/v1/./admin/settings"), "/v1/admin/settings");
1001        assert_eq!(normalize_path("/v1/../admin/settings"), "/admin/settings");
1002        assert_eq!(normalize_path("/v1//admin/settings"), "/v1/admin/settings");
1003        assert_eq!(normalize_path("/v1/%2e%2e/admin"), "/admin");
1004    }
1005
1006    // --- ACL bypass prevention tests ---
1007
1008    #[test]
1009    fn acl_deny_prevents_double_slash_bypass() {
1010        let deny = vec![HttpRule::parse("* */admin/*").unwrap()];
1011        // These should all be caught by the deny rule
1012        assert!(!http_acl_check(&[], &deny, "GET", "example.com", "/admin/settings"));
1013        assert!(!http_acl_check(&[], &deny, "GET", "example.com", "//admin/settings"));
1014        assert!(!http_acl_check(&[], &deny, "GET", "example.com", "/admin//settings"));
1015    }
1016
1017    #[test]
1018    fn acl_deny_prevents_dot_segment_bypass() {
1019        let deny = vec![HttpRule::parse("* */admin/*").unwrap()];
1020        assert!(!http_acl_check(&[], &deny, "GET", "example.com", "/./admin/settings"));
1021        assert!(!http_acl_check(&[], &deny, "GET", "example.com", "/public/../admin/settings"));
1022    }
1023
1024    #[test]
1025    fn acl_deny_prevents_percent_encoding_bypass() {
1026        let deny = vec![HttpRule::parse("* */admin/*").unwrap()];
1027        // %61dmin = admin
1028        assert!(!http_acl_check(&[], &deny, "GET", "example.com", "/%61dmin/settings"));
1029    }
1030
1031    #[test]
1032    fn acl_allow_normalized_path_still_works() {
1033        let allow = vec![HttpRule::parse("GET example.com/v1/models").unwrap()];
1034        assert!(http_acl_check(&allow, &[], "GET", "example.com", "/v1/models"));
1035        assert!(http_acl_check(&allow, &[], "GET", "example.com", "/v1/./models"));
1036        assert!(http_acl_check(&allow, &[], "GET", "example.com", "/v1//models"));
1037        // These resolve to different paths and should be denied
1038        assert!(!http_acl_check(&allow, &[], "GET", "example.com", "/v1/models/extra"));
1039        assert!(!http_acl_check(&allow, &[], "GET", "example.com", "/v2/models"));
1040    }
1041
1042    #[test]
1043    fn parse_normalizes_rule_path() {
1044        let rule = HttpRule::parse("GET example.com/v1/./models/*").unwrap();
1045        assert_eq!(rule.path, "/v1/models/*");
1046
1047        let rule = HttpRule::parse("GET example.com/v1//models").unwrap();
1048        assert_eq!(rule.path, "/v1/models");
1049    }
1050}