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    pub net_allow_hosts: Vec<String>,
261    pub net_bind: Vec<u16>,
262    pub net_connect: Vec<u16>,
263    pub no_raw_sockets: bool,
264    pub no_udp: bool,
265
266    // HTTP ACL
267    pub http_allow: Vec<HttpRule>,
268    pub http_deny: Vec<HttpRule>,
269    /// TCP ports to intercept for HTTP ACL. Defaults to [80] (plus 443 when
270    /// https_ca is set). Override with `http_ports` to intercept custom ports.
271    pub http_ports: Vec<u16>,
272    /// PEM CA cert for HTTPS MITM. When set, port 443 is also intercepted.
273    pub https_ca: Option<PathBuf>,
274    /// PEM CA key for HTTPS MITM. Required when https_ca is set.
275    pub https_key: Option<PathBuf>,
276
277    // Namespace isolation — always enabled, not user-configurable.
278
279    // Resource limits
280    pub max_memory: Option<ByteSize>,
281    pub max_processes: u32,
282    pub max_open_files: Option<u32>,
283    pub max_cpu: Option<u8>,
284
285    // Reproducibility
286    pub random_seed: Option<u64>,
287    pub time_start: Option<SystemTime>,
288    pub no_randomize_memory: bool,
289    pub no_huge_pages: bool,
290    pub no_coredump: bool,
291    pub deterministic_dirs: bool,
292    pub hostname: Option<String>,
293
294    // Filesystem branch
295    pub fs_isolation: FsIsolation,
296    pub workdir: Option<PathBuf>,
297    pub cwd: Option<PathBuf>,
298    pub fs_storage: Option<PathBuf>,
299    pub max_disk: Option<ByteSize>,
300    pub on_exit: BranchAction,
301    pub on_error: BranchAction,
302
303    // Mount mappings: (virtual_path_inside_chroot, host_path_on_disk)
304    pub fs_mount: Vec<(PathBuf, PathBuf)>,
305
306    // Environment
307    pub chroot: Option<PathBuf>,
308    pub clean_env: bool,
309    pub env: HashMap<String, String>,
310    // Devices
311    pub gpu_devices: Option<Vec<u32>>,
312
313    // CPU
314    pub cpu_cores: Option<Vec<u32>>,
315    pub num_cpus: Option<u32>,
316    pub port_remap: bool,
317
318    // User namespace
319    pub uid: Option<u32>,
320
321    // Dynamic policy callback
322    #[serde(skip)]
323    pub policy_fn: Option<crate::policy_fn::PolicyCallback>,
324}
325
326impl std::fmt::Debug for Policy {
327    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
328        f.debug_struct("Policy")
329            .field("fs_readable", &self.fs_readable)
330            .field("fs_writable", &self.fs_writable)
331            .field("max_memory", &self.max_memory)
332            .field("max_processes", &self.max_processes)
333            .field("policy_fn", &self.policy_fn.as_ref().map(|_| "<callback>"))
334            .finish_non_exhaustive()
335    }
336}
337
338impl Policy {
339    pub fn builder() -> PolicyBuilder {
340        PolicyBuilder::default()
341    }
342}
343
344/// Fluent builder for `Policy`.
345#[derive(Default)]
346pub struct PolicyBuilder {
347    fs_writable: Vec<PathBuf>,
348    fs_readable: Vec<PathBuf>,
349    fs_denied: Vec<PathBuf>,
350
351    deny_syscalls: Option<Vec<String>>,
352    allow_syscalls: Option<Vec<String>>,
353
354    net_allow_hosts: Vec<String>,
355    net_bind: Vec<u16>,
356    net_connect: Vec<u16>,
357    no_raw_sockets: Option<bool>,
358    no_udp: bool,
359
360    http_allow: Vec<String>,
361    http_deny: Vec<String>,
362    http_ports: Vec<u16>,
363    https_ca: Option<PathBuf>,
364    https_key: Option<PathBuf>,
365
366    max_memory: Option<ByteSize>,
367    max_processes: Option<u32>,
368    max_open_files: Option<u32>,
369    max_cpu: Option<u8>,
370
371    random_seed: Option<u64>,
372    time_start: Option<SystemTime>,
373    no_randomize_memory: bool,
374    no_huge_pages: bool,
375    no_coredump: bool,
376    deterministic_dirs: bool,
377    hostname: Option<String>,
378
379    fs_isolation: Option<FsIsolation>,
380    workdir: Option<PathBuf>,
381    cwd: Option<PathBuf>,
382    fs_storage: Option<PathBuf>,
383    max_disk: Option<ByteSize>,
384    on_exit: Option<BranchAction>,
385    on_error: Option<BranchAction>,
386
387    fs_mount: Vec<(PathBuf, PathBuf)>,
388    chroot: Option<PathBuf>,
389    clean_env: bool,
390    env: HashMap<String, String>,
391
392    gpu_devices: Option<Vec<u32>>,
393
394    cpu_cores: Option<Vec<u32>>,
395    num_cpus: Option<u32>,
396    port_remap: bool,
397
398    uid: Option<u32>,
399    policy_fn: Option<crate::policy_fn::PolicyCallback>,
400}
401
402impl PolicyBuilder {
403    pub fn fs_write(mut self, path: impl Into<PathBuf>) -> Self {
404        self.fs_writable.push(path.into());
405        self
406    }
407
408    pub fn fs_read(mut self, path: impl Into<PathBuf>) -> Self {
409        self.fs_readable.push(path.into());
410        self
411    }
412
413    pub fn fs_deny(mut self, path: impl Into<PathBuf>) -> Self {
414        self.fs_denied.push(path.into());
415        self
416    }
417
418    pub fn deny_syscalls(mut self, calls: Vec<String>) -> Self {
419        self.deny_syscalls = Some(calls);
420        self
421    }
422
423    pub fn allow_syscalls(mut self, calls: Vec<String>) -> Self {
424        self.allow_syscalls = Some(calls);
425        self
426    }
427
428    pub fn net_allow_host(mut self, host: impl Into<String>) -> Self {
429        self.net_allow_hosts.push(host.into());
430        self
431    }
432
433    pub fn net_bind_port(mut self, port: u16) -> Self {
434        self.net_bind.push(port);
435        self
436    }
437
438    pub fn net_connect_port(mut self, port: u16) -> Self {
439        self.net_connect.push(port);
440        self
441    }
442
443    pub fn no_raw_sockets(mut self, v: bool) -> Self {
444        self.no_raw_sockets = Some(v);
445        self
446    }
447
448    pub fn no_udp(mut self, v: bool) -> Self {
449        self.no_udp = v;
450        self
451    }
452
453    pub fn http_allow(mut self, rule: &str) -> Self {
454        self.http_allow.push(rule.to_string());
455        self
456    }
457
458    pub fn http_deny(mut self, rule: &str) -> Self {
459        self.http_deny.push(rule.to_string());
460        self
461    }
462
463    pub fn http_port(mut self, port: u16) -> Self {
464        self.http_ports.push(port);
465        // HTTP ACL intercepts TCP connections on this port, so it must be
466        // in the Landlock net_connect allowlist too.
467        if !self.net_connect.contains(&port) {
468            self.net_connect.push(port);
469        }
470        self
471    }
472
473    pub fn https_ca(mut self, path: impl Into<PathBuf>) -> Self {
474        self.https_ca = Some(path.into());
475        self
476    }
477
478    pub fn https_key(mut self, path: impl Into<PathBuf>) -> Self {
479        self.https_key = Some(path.into());
480        self
481    }
482
483    pub fn max_memory(mut self, size: ByteSize) -> Self {
484        self.max_memory = Some(size);
485        self
486    }
487
488    pub fn max_processes(mut self, n: u32) -> Self {
489        self.max_processes = Some(n);
490        self
491    }
492
493    pub fn max_open_files(mut self, n: u32) -> Self {
494        self.max_open_files = Some(n);
495        self
496    }
497
498    pub fn max_cpu(mut self, pct: u8) -> Self {
499        self.max_cpu = Some(pct);
500        self
501    }
502
503    pub fn random_seed(mut self, seed: u64) -> Self {
504        self.random_seed = Some(seed);
505        self
506    }
507
508    pub fn time_start(mut self, t: SystemTime) -> Self {
509        self.time_start = Some(t);
510        self
511    }
512
513    pub fn no_randomize_memory(mut self, v: bool) -> Self {
514        self.no_randomize_memory = v;
515        self
516    }
517
518    pub fn no_huge_pages(mut self, v: bool) -> Self {
519        self.no_huge_pages = v;
520        self
521    }
522
523    pub fn no_coredump(mut self, v: bool) -> Self {
524        self.no_coredump = v;
525        self
526    }
527
528    pub fn deterministic_dirs(mut self, v: bool) -> Self {
529        self.deterministic_dirs = v;
530        self
531    }
532
533    pub fn hostname(mut self, name: impl Into<String>) -> Self {
534        self.hostname = Some(name.into());
535        self
536    }
537
538    pub fn fs_isolation(mut self, iso: FsIsolation) -> Self {
539        self.fs_isolation = Some(iso);
540        self
541    }
542
543    pub fn workdir(mut self, path: impl Into<PathBuf>) -> Self {
544        self.workdir = Some(path.into());
545        self
546    }
547
548    pub fn cwd(mut self, path: impl Into<PathBuf>) -> Self {
549        self.cwd = Some(path.into());
550        self
551    }
552
553    pub fn fs_storage(mut self, path: impl Into<PathBuf>) -> Self {
554        self.fs_storage = Some(path.into());
555        self
556    }
557
558    pub fn max_disk(mut self, size: ByteSize) -> Self {
559        self.max_disk = Some(size);
560        self
561    }
562
563    pub fn on_exit(mut self, action: BranchAction) -> Self {
564        self.on_exit = Some(action);
565        self
566    }
567
568    pub fn on_error(mut self, action: BranchAction) -> Self {
569        self.on_error = Some(action);
570        self
571    }
572
573    pub fn chroot(mut self, path: impl Into<PathBuf>) -> Self {
574        self.chroot = Some(path.into());
575        self
576    }
577
578    pub fn fs_mount(mut self, virtual_path: impl Into<PathBuf>, host_path: impl Into<PathBuf>) -> Self {
579        self.fs_mount.push((virtual_path.into(), host_path.into()));
580        self
581    }
582
583    pub fn clean_env(mut self, v: bool) -> Self {
584        self.clean_env = v;
585        self
586    }
587
588    pub fn env_var(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
589        self.env.insert(key.into(), value.into());
590        self
591    }
592
593
594    pub fn gpu_devices(mut self, devices: Vec<u32>) -> Self {
595        self.gpu_devices = Some(devices);
596        self
597    }
598
599    pub fn cpu_cores(mut self, cores: Vec<u32>) -> Self {
600        self.cpu_cores = Some(cores);
601        self
602    }
603
604    pub fn num_cpus(mut self, n: u32) -> Self {
605        self.num_cpus = Some(n);
606        self
607    }
608
609    pub fn port_remap(mut self, v: bool) -> Self {
610        self.port_remap = v;
611        self
612    }
613
614    pub fn policy_fn(
615        mut self,
616        f: impl Fn(crate::policy_fn::SyscallEvent, &mut crate::policy_fn::PolicyContext) -> crate::policy_fn::Verdict + Send + Sync + 'static,
617    ) -> Self {
618        self.policy_fn = Some(std::sync::Arc::new(f));
619        self
620    }
621
622    pub fn uid(mut self, id: u32) -> Self {
623        self.uid = Some(id);
624        self
625    }
626
627    pub fn build(self) -> Result<Policy, PolicyError> {
628        // Validate: deny_syscalls and allow_syscalls are mutually exclusive
629        if self.deny_syscalls.is_some() && self.allow_syscalls.is_some() {
630            return Err(PolicyError::MutuallyExclusiveSyscalls);
631        }
632
633        // Validate: max_cpu must be 1-100
634        if let Some(cpu) = self.max_cpu {
635            if cpu == 0 || cpu > 100 {
636                return Err(PolicyError::InvalidCpuPercent(cpu));
637            }
638        }
639
640        // Validate: https_ca and https_key must both be set or both unset
641        if self.https_ca.is_some() != self.https_key.is_some() {
642            return Err(PolicyError::Invalid(
643                "--https-ca and --https-key must both be provided together".into(),
644            ));
645        }
646
647        // Parse HTTP rules (deferred from builder methods to propagate errors)
648        let http_allow: Vec<HttpRule> = self
649            .http_allow
650            .iter()
651            .map(|s| HttpRule::parse(s))
652            .collect::<Result<_, _>>()?;
653        let http_deny: Vec<HttpRule> = self
654            .http_deny
655            .iter()
656            .map(|s| HttpRule::parse(s))
657            .collect::<Result<_, _>>()?;
658
659        // Default HTTP intercept ports: 80 always, 443 when HTTPS CA is configured.
660        let http_ports = if self.http_ports.is_empty() && (!http_allow.is_empty() || !http_deny.is_empty()) {
661            let mut ports = vec![80];
662            if self.https_ca.is_some() {
663                ports.push(443);
664            }
665            ports
666        } else {
667            self.http_ports
668        };
669
670        // Validate: fs_isolation != None requires workdir
671        let fs_isolation = self.fs_isolation.unwrap_or_default();
672        if fs_isolation != FsIsolation::None && self.workdir.is_none() {
673            return Err(PolicyError::FsIsolationRequiresWorkdir);
674        }
675
676        Ok(Policy {
677            fs_writable: self.fs_writable,
678            fs_readable: self.fs_readable,
679            fs_denied: self.fs_denied,
680            deny_syscalls: self.deny_syscalls,
681            allow_syscalls: self.allow_syscalls,
682            net_allow_hosts: self.net_allow_hosts,
683            net_bind: self.net_bind,
684            net_connect: self.net_connect,
685            no_raw_sockets: self.no_raw_sockets.unwrap_or(true),
686            no_udp: self.no_udp,
687            http_allow,
688            http_deny,
689            http_ports,
690            https_ca: self.https_ca,
691            https_key: self.https_key,
692            max_memory: self.max_memory,
693            max_processes: self.max_processes.unwrap_or(64),
694            max_open_files: self.max_open_files,
695            max_cpu: self.max_cpu,
696            random_seed: self.random_seed,
697            time_start: self.time_start,
698            no_randomize_memory: self.no_randomize_memory,
699            no_huge_pages: self.no_huge_pages,
700            no_coredump: self.no_coredump,
701            deterministic_dirs: self.deterministic_dirs,
702            hostname: self.hostname,
703            fs_isolation,
704            workdir: self.workdir,
705            cwd: self.cwd,
706            fs_storage: self.fs_storage,
707            max_disk: self.max_disk,
708            on_exit: self.on_exit.unwrap_or_default(),
709            on_error: self.on_error.unwrap_or_default(),
710            fs_mount: self.fs_mount,
711            chroot: self.chroot,
712            clean_env: self.clean_env,
713            env: self.env,
714            gpu_devices: self.gpu_devices,
715            cpu_cores: self.cpu_cores,
716            num_cpus: self.num_cpus,
717            port_remap: self.port_remap,
718            uid: self.uid,
719            policy_fn: self.policy_fn,
720        })
721    }
722}
723
724#[cfg(test)]
725mod http_rule_tests {
726    use super::*;
727
728    // --- HttpRule::parse tests ---
729
730    #[test]
731    fn parse_basic_get() {
732        let rule = HttpRule::parse("GET api.example.com/v1/*").unwrap();
733        assert_eq!(rule.method, "GET");
734        assert_eq!(rule.host, "api.example.com");
735        assert_eq!(rule.path, "/v1/*");
736    }
737
738    #[test]
739    fn parse_wildcard_method_and_host() {
740        let rule = HttpRule::parse("* */admin/*").unwrap();
741        assert_eq!(rule.method, "*");
742        assert_eq!(rule.host, "*");
743        assert_eq!(rule.path, "/admin/*");
744    }
745
746    #[test]
747    fn parse_post_with_exact_path() {
748        let rule = HttpRule::parse("POST example.com/upload").unwrap();
749        assert_eq!(rule.method, "POST");
750        assert_eq!(rule.host, "example.com");
751        assert_eq!(rule.path, "/upload");
752    }
753
754    #[test]
755    fn parse_no_path_defaults_to_wildcard() {
756        let rule = HttpRule::parse("GET example.com").unwrap();
757        assert_eq!(rule.method, "GET");
758        assert_eq!(rule.host, "example.com");
759        assert_eq!(rule.path, "/*");
760    }
761
762    #[test]
763    fn parse_method_uppercased() {
764        let rule = HttpRule::parse("get example.com/foo").unwrap();
765        assert_eq!(rule.method, "GET");
766    }
767
768    #[test]
769    fn parse_error_no_space() {
770        assert!(HttpRule::parse("GETexample.com").is_err());
771    }
772
773    #[test]
774    fn parse_error_empty_host() {
775        assert!(HttpRule::parse("GET  ").is_err());
776    }
777
778    // --- prefix_or_exact_match tests ---
779
780    #[test]
781    fn prefix_or_exact_match_wildcard_all() {
782        assert!(prefix_or_exact_match("/*", "/anything"));
783        assert!(prefix_or_exact_match("*", "/anything"));
784        assert!(prefix_or_exact_match("/*", "/"));
785    }
786
787    #[test]
788    fn prefix_or_exact_match_prefix() {
789        assert!(prefix_or_exact_match("/v1/*", "/v1/foo"));
790        assert!(prefix_or_exact_match("/v1/*", "/v1/foo/bar"));
791        assert!(prefix_or_exact_match("/v1/*", "/v1/"));
792        assert!(!prefix_or_exact_match("/v1/*", "/v2/foo"));
793    }
794
795    #[test]
796    fn prefix_or_exact_match_exact() {
797        assert!(prefix_or_exact_match("/v1/models", "/v1/models"));
798        assert!(!prefix_or_exact_match("/v1/models", "/v1/models/extra"));
799        assert!(!prefix_or_exact_match("/v1/models", "/v1/model"));
800    }
801
802    // --- HttpRule::matches tests ---
803
804    #[test]
805    fn matches_exact() {
806        let rule = HttpRule::parse("GET api.example.com/v1/models").unwrap();
807        assert!(rule.matches("GET", "api.example.com", "/v1/models"));
808        assert!(!rule.matches("POST", "api.example.com", "/v1/models"));
809        assert!(!rule.matches("GET", "other.com", "/v1/models"));
810        assert!(!rule.matches("GET", "api.example.com", "/v1/other"));
811    }
812
813    #[test]
814    fn matches_wildcard_method() {
815        let rule = HttpRule::parse("* api.example.com/v1/*").unwrap();
816        assert!(rule.matches("GET", "api.example.com", "/v1/foo"));
817        assert!(rule.matches("POST", "api.example.com", "/v1/bar"));
818    }
819
820    #[test]
821    fn matches_wildcard_host() {
822        let rule = HttpRule::parse("GET */v1/*").unwrap();
823        assert!(rule.matches("GET", "any.host.com", "/v1/foo"));
824    }
825
826    #[test]
827    fn matches_case_insensitive_method() {
828        let rule = HttpRule::parse("GET example.com/foo").unwrap();
829        assert!(rule.matches("get", "example.com", "/foo"));
830        assert!(rule.matches("Get", "example.com", "/foo"));
831    }
832
833    #[test]
834    fn matches_case_insensitive_host() {
835        let rule = HttpRule::parse("GET Example.COM/foo").unwrap();
836        assert!(rule.matches("GET", "example.com", "/foo"));
837    }
838
839    // --- http_acl_check tests ---
840
841    #[test]
842    fn acl_no_rules_allows_all() {
843        assert!(http_acl_check(&[], &[], "GET", "example.com", "/foo"));
844    }
845
846    #[test]
847    fn acl_allow_only_permits_matching() {
848        let allow = vec![HttpRule::parse("GET api.example.com/v1/*").unwrap()];
849        assert!(http_acl_check(&allow, &[], "GET", "api.example.com", "/v1/foo"));
850        assert!(!http_acl_check(&allow, &[], "POST", "api.example.com", "/v1/foo"));
851        assert!(!http_acl_check(&allow, &[], "GET", "other.com", "/v1/foo"));
852    }
853
854    #[test]
855    fn acl_deny_only_blocks_matching() {
856        let deny = vec![HttpRule::parse("* */admin/*").unwrap()];
857        assert!(!http_acl_check(&[], &deny, "GET", "example.com", "/admin/settings"));
858        assert!(http_acl_check(&[], &deny, "GET", "example.com", "/public/page"));
859    }
860
861    #[test]
862    fn acl_deny_takes_precedence_over_allow() {
863        let allow = vec![HttpRule::parse("* example.com/*").unwrap()];
864        let deny = vec![HttpRule::parse("* example.com/admin/*").unwrap()];
865        assert!(http_acl_check(&allow, &deny, "GET", "example.com", "/public"));
866        assert!(!http_acl_check(&allow, &deny, "GET", "example.com", "/admin/settings"));
867    }
868
869    #[test]
870    fn acl_allow_deny_by_default_when_no_match() {
871        let allow = vec![HttpRule::parse("GET api.example.com/v1/*").unwrap()];
872        // Different host, not matched by allow -> denied
873        assert!(!http_acl_check(&allow, &[], "GET", "evil.com", "/v1/foo"));
874    }
875
876    // --- PolicyBuilder integration ---
877
878    #[test]
879    fn builder_http_rules() {
880        let policy = Policy::builder()
881            .http_allow("GET api.example.com/v1/*")
882            .http_deny("* */admin/*")
883            .build()
884            .unwrap();
885        assert_eq!(policy.http_allow.len(), 1);
886        assert_eq!(policy.http_deny.len(), 1);
887        assert_eq!(policy.http_allow[0].method, "GET");
888        assert_eq!(policy.http_deny[0].host, "*");
889    }
890
891    #[test]
892    fn builder_invalid_http_allow_returns_error() {
893        let result = Policy::builder()
894            .http_allow("GETexample.com")
895            .build();
896        assert!(result.is_err());
897    }
898
899    #[test]
900    fn builder_invalid_http_deny_returns_error() {
901        let result = Policy::builder()
902            .http_deny("BADRULE")
903            .build();
904        assert!(result.is_err());
905    }
906
907    #[test]
908    fn builder_https_ca_without_key_returns_error() {
909        let result = Policy::builder()
910            .https_ca("/tmp/ca.pem")
911            .build();
912        assert!(result.is_err());
913    }
914
915    #[test]
916    fn builder_https_key_without_ca_returns_error() {
917        let result = Policy::builder()
918            .https_key("/tmp/key.pem")
919            .build();
920        assert!(result.is_err());
921    }
922
923    #[test]
924    fn builder_https_ca_and_key_together_ok() {
925        let policy = Policy::builder()
926            .https_ca("/tmp/ca.pem")
927            .https_key("/tmp/key.pem")
928            .build()
929            .unwrap();
930        assert!(policy.https_ca.is_some());
931        assert!(policy.https_key.is_some());
932    }
933
934    // --- normalize_path tests ---
935
936    #[test]
937    fn normalize_path_basic() {
938        assert_eq!(normalize_path("/foo/bar"), "/foo/bar");
939        assert_eq!(normalize_path("/"), "/");
940    }
941
942    #[test]
943    fn normalize_path_double_slashes() {
944        assert_eq!(normalize_path("/foo//bar"), "/foo/bar");
945        assert_eq!(normalize_path("//foo///bar//"), "/foo/bar");
946    }
947
948    #[test]
949    fn normalize_path_dot_segments() {
950        assert_eq!(normalize_path("/foo/./bar"), "/foo/bar");
951        assert_eq!(normalize_path("/foo/../bar"), "/bar");
952        assert_eq!(normalize_path("/foo/bar/../../baz"), "/baz");
953    }
954
955    #[test]
956    fn normalize_path_dotdot_at_root() {
957        assert_eq!(normalize_path("/../foo"), "/foo");
958        assert_eq!(normalize_path("/../../foo"), "/foo");
959    }
960
961    #[test]
962    fn normalize_path_percent_encoding() {
963        // %2F = /, %61 = a
964        assert_eq!(normalize_path("/foo%2Fbar"), "/foo/bar");
965        assert_eq!(normalize_path("/%61dmin/settings"), "/admin/settings");
966    }
967
968    #[test]
969    fn normalize_path_mixed_bypass_attempts() {
970        // Double-encoded traversal
971        assert_eq!(normalize_path("/v1/./admin/settings"), "/v1/admin/settings");
972        assert_eq!(normalize_path("/v1/../admin/settings"), "/admin/settings");
973        assert_eq!(normalize_path("/v1//admin/settings"), "/v1/admin/settings");
974        assert_eq!(normalize_path("/v1/%2e%2e/admin"), "/admin");
975    }
976
977    // --- ACL bypass prevention tests ---
978
979    #[test]
980    fn acl_deny_prevents_double_slash_bypass() {
981        let deny = vec![HttpRule::parse("* */admin/*").unwrap()];
982        // These should all be caught by the deny rule
983        assert!(!http_acl_check(&[], &deny, "GET", "example.com", "/admin/settings"));
984        assert!(!http_acl_check(&[], &deny, "GET", "example.com", "//admin/settings"));
985        assert!(!http_acl_check(&[], &deny, "GET", "example.com", "/admin//settings"));
986    }
987
988    #[test]
989    fn acl_deny_prevents_dot_segment_bypass() {
990        let deny = vec![HttpRule::parse("* */admin/*").unwrap()];
991        assert!(!http_acl_check(&[], &deny, "GET", "example.com", "/./admin/settings"));
992        assert!(!http_acl_check(&[], &deny, "GET", "example.com", "/public/../admin/settings"));
993    }
994
995    #[test]
996    fn acl_deny_prevents_percent_encoding_bypass() {
997        let deny = vec![HttpRule::parse("* */admin/*").unwrap()];
998        // %61dmin = admin
999        assert!(!http_acl_check(&[], &deny, "GET", "example.com", "/%61dmin/settings"));
1000    }
1001
1002    #[test]
1003    fn acl_allow_normalized_path_still_works() {
1004        let allow = vec![HttpRule::parse("GET example.com/v1/models").unwrap()];
1005        assert!(http_acl_check(&allow, &[], "GET", "example.com", "/v1/models"));
1006        assert!(http_acl_check(&allow, &[], "GET", "example.com", "/v1/./models"));
1007        assert!(http_acl_check(&allow, &[], "GET", "example.com", "/v1//models"));
1008        // These resolve to different paths and should be denied
1009        assert!(!http_acl_check(&allow, &[], "GET", "example.com", "/v1/models/extra"));
1010        assert!(!http_acl_check(&allow, &[], "GET", "example.com", "/v2/models"));
1011    }
1012
1013    #[test]
1014    fn parse_normalizes_rule_path() {
1015        let rule = HttpRule::parse("GET example.com/v1/./models/*").unwrap();
1016        assert_eq!(rule.path, "/v1/models/*");
1017
1018        let rule = HttpRule::parse("GET example.com/v1//models").unwrap();
1019        assert_eq!(rule.path, "/v1/models");
1020    }
1021}