Skip to main content

eggserve_core/primitives/
secure_root.rs

1use std::fmt;
2use std::path::{Path, PathBuf};
3use std::sync::Arc;
4
5use crate::fs::RootGuard;
6use crate::path::{ConfinedPath, PathPolicy, PathRejection};
7use crate::policy::StaticPolicy;
8use crate::primitives::body::{BodySource, BodySourceError};
9use crate::primitives::http::ReadOnlyMethod;
10use crate::primitives::response::{BodyPlan, FileRange, StaticResponsePlan};
11
12#[derive(Debug)]
13pub enum ResourceDeniedReason {
14    SymlinkDenied,
15    DotfileDenied,
16    RootEscapeDenied,
17    PolicyDenied(PathRejection),
18}
19
20impl fmt::Display for ResourceDeniedReason {
21    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
22        match self {
23            Self::SymlinkDenied => write!(f, "symlink denied"),
24            Self::DotfileDenied => write!(f, "dotfile denied"),
25            Self::RootEscapeDenied => write!(f, "root escape denied"),
26            Self::PolicyDenied(inner) => write!(f, "{}", inner),
27        }
28    }
29}
30
31impl std::error::Error for ResourceDeniedReason {
32    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
33        match self {
34            Self::PolicyDenied(inner) => Some(inner),
35            _ => None,
36        }
37    }
38}
39
40impl From<PathRejection> for ResourceDeniedReason {
41    fn from(rejection: PathRejection) -> Self {
42        match rejection {
43            PathRejection::SymlinkDenied => ResourceDeniedReason::SymlinkDenied,
44            PathRejection::DotfileDenied => ResourceDeniedReason::DotfileDenied,
45            PathRejection::RootEscapeDenied => ResourceDeniedReason::RootEscapeDenied,
46            other => ResourceDeniedReason::PolicyDenied(other),
47        }
48    }
49}
50
51/// A resolved file capability created by the filesystem resolver.
52///
53/// This type cannot be constructed directly by external callers; it is only
54/// created through the resolver's path confinement pipeline.
55#[derive(Debug)]
56/// A file capability produced by [`SecureRoot::resolve`] or [`resolve_and_plan`].
57///
58/// All public construction paths verify that the file was opened through the
59/// path confinement pipeline. An internal-only constructor
60/// (`from_parts`, behind the `python-bindings-internal` feature) exists for
61/// cross-crate Python bindings where the file was already resolved through a
62/// secure path. This constructor does not re-verify provenance.
63pub struct ResolvedFile {
64    inner: crate::fs::ResolvedFile,
65}
66
67impl ResolvedFile {
68    #[allow(dead_code)]
69    pub fn len(&self) -> u64 {
70        self.inner.metadata.len()
71    }
72
73    #[allow(dead_code)]
74    pub fn is_empty(&self) -> bool {
75        self.len() == 0
76    }
77
78    #[allow(dead_code)]
79    pub fn modified(&self) -> Option<std::time::SystemTime> {
80        self.inner.metadata.modified().ok()
81    }
82
83    #[allow(dead_code)]
84    pub fn content_type(&self) -> &'static str {
85        let path: PathBuf = self.inner.safe_relative_components.iter().collect();
86        crate::mime::mime_for_path(&path)
87    }
88
89    /// Plan a response using the metadata of this already-resolved file.
90    /// This keeps directory-index resolution on the originating capability.
91    pub fn plan_response(
92        &self,
93        method: ReadOnlyMethod,
94        if_none_match: Option<&str>,
95        if_modified_since: Option<&str>,
96        range_header: Option<&str>,
97        if_range: Option<&str>,
98    ) -> StaticResponsePlan {
99        crate::primitives::planner::plan_file_response(
100            method,
101            &self.inner.metadata,
102            self.content_type(),
103            if_none_match,
104            if_modified_since,
105            range_header,
106            if_range,
107        )
108    }
109
110    #[allow(dead_code)]
111    pub fn safe_relative_components(&self) -> &[String] {
112        &self.inner.safe_relative_components
113    }
114
115    /// Extract the underlying `std::fs::File`.
116    ///
117    /// # Security note
118    ///
119    /// After extraction, the confinement guarantee no longer applies to the
120    /// raw file handle. This is intended for internal bridging to the Python
121    /// bindings layer.
122    #[cfg(feature = "python-bindings-internal")]
123    #[allow(dead_code)]
124    pub fn into_std_file(self) -> std::fs::File {
125        self.inner.file
126    }
127
128    /// Extract the underlying file and metadata.
129    ///
130    /// # Security note
131    ///
132    /// After extraction, the confinement guarantee no longer applies to the
133    /// raw file handle. This is intended for internal bridging to the Python
134    /// bindings layer.
135    #[cfg(feature = "python-bindings-internal")]
136    #[allow(dead_code)]
137    pub fn into_parts(self) -> (std::fs::File, std::fs::Metadata) {
138        (self.inner.file, self.inner.metadata)
139    }
140
141    /// Reconstruct a `ResolvedFile` from raw components.
142    ///
143    /// # Security note
144    ///
145    /// This constructor does not verify that the file was opened through the
146    /// path confinement pipeline. It is intended for internal use by the
147    /// Python bindings where the file was already resolved through a secure
148    /// path. External Rust consumers should use [`SecureRoot::resolve`] or
149    /// [`resolve_and_plan`] instead.
150    #[cfg(feature = "python-bindings-internal")]
151    #[allow(dead_code)]
152    pub fn from_parts(
153        file: std::fs::File,
154        metadata: std::fs::Metadata,
155        safe_relative_components: Vec<String>,
156    ) -> Self {
157        Self {
158            inner: crate::fs::ResolvedFile {
159                file,
160                metadata,
161                safe_relative_components,
162            },
163        }
164    }
165
166    #[allow(dead_code)]
167    pub fn into_body(self, plan: &StaticResponsePlan) -> Result<BodySource, BodySourceError> {
168        match &plan.body {
169            BodyPlan::Empty => Ok(BodySource::Empty),
170            BodyPlan::FullBytes(b) => Ok(BodySource::Bytes(b.clone())),
171            BodyPlan::FileFull => {
172                let len = self.inner.metadata.len();
173                let path: PathBuf = self.inner.safe_relative_components.iter().collect();
174                let mime = crate::mime::mime_for_path(&path);
175                Ok(BodySource::FileFull {
176                    file: self.inner.file,
177                    len,
178                    mime,
179                })
180            }
181            BodyPlan::FileRange {
182                start,
183                end_inclusive,
184            } => {
185                let total_len = self.inner.metadata.len();
186                if *end_inclusive < *start || *end_inclusive >= total_len {
187                    return Err(BodySourceError::InvalidRange);
188                }
189                let range = FileRange::new(*start, *end_inclusive);
190                let path: PathBuf = self.inner.safe_relative_components.iter().collect();
191                let mime = crate::mime::mime_for_path(&path);
192                Ok(BodySource::FileRange {
193                    file: self.inner.file,
194                    range,
195                    total_len,
196                    mime,
197                })
198            }
199        }
200    }
201
202    #[allow(dead_code)]
203    pub fn into_range_body(
204        self,
205        start: u64,
206        end_inclusive: u64,
207    ) -> Result<BodySource, BodySourceError> {
208        let total_len = self.inner.metadata.len();
209        if end_inclusive < start || end_inclusive >= total_len {
210            return Err(BodySourceError::InvalidRange);
211        }
212        let range = FileRange::new(start, end_inclusive);
213        let path: PathBuf = self.inner.safe_relative_components.iter().collect();
214        let mime = crate::mime::mime_for_path(&path);
215        Ok(BodySource::FileRange {
216            file: self.inner.file,
217            range,
218            total_len,
219            mime,
220        })
221    }
222}
223
224#[derive(Debug)]
225pub struct ResolvedDirectory {
226    inner: crate::fs::ResolvedDirectory,
227}
228
229impl ResolvedDirectory {
230    #[allow(dead_code)]
231    pub fn components(&self) -> &[String] {
232        &self.inner.components
233    }
234
235    #[allow(dead_code)]
236    pub fn resolve_child(&self, child: &str, root: &SecureRoot) -> ResolvedResource {
237        let guard = RootGuard::new(&root.pinned);
238        guard.resolve_child(&self.inner, child, &root.policy).into()
239    }
240
241    #[allow(dead_code)]
242    pub fn list(
243        &self,
244        root: &SecureRoot,
245        max_entries: usize,
246    ) -> Result<Vec<(String, bool)>, std::io::Error> {
247        let guard = RootGuard::new(&root.pinned);
248        guard.list_directory(&self.inner, &root.policy, max_entries)
249    }
250}
251
252#[derive(Debug)]
253pub enum ResolvedResource {
254    File(ResolvedFile),
255    Directory(ResolvedDirectory),
256    NotFound,
257    Denied(ResourceDeniedReason),
258}
259
260impl ResolvedResource {
261    #[allow(dead_code)]
262    pub fn is_file(&self) -> bool {
263        matches!(self, Self::File(_))
264    }
265
266    #[allow(dead_code)]
267    pub fn is_directory(&self) -> bool {
268        matches!(self, Self::Directory(_))
269    }
270
271    #[allow(dead_code)]
272    pub fn is_not_found(&self) -> bool {
273        matches!(self, Self::NotFound)
274    }
275
276    #[allow(dead_code)]
277    pub fn is_denied(&self) -> bool {
278        matches!(self, Self::Denied(_))
279    }
280
281    #[allow(dead_code)]
282    pub fn as_file(&self) -> Option<&ResolvedFile> {
283        match self {
284            Self::File(f) => Some(f),
285            _ => None,
286        }
287    }
288
289    #[allow(dead_code)]
290    pub fn as_directory(&self) -> Option<&ResolvedDirectory> {
291        match self {
292            Self::Directory(d) => Some(d),
293            _ => None,
294        }
295    }
296
297    #[allow(dead_code)]
298    pub fn into_file(self) -> Option<ResolvedFile> {
299        match self {
300            Self::File(f) => Some(f),
301            _ => None,
302        }
303    }
304
305    #[allow(dead_code)]
306    pub fn into_directory(self) -> Option<ResolvedDirectory> {
307        match self {
308            Self::Directory(d) => Some(d),
309            _ => None,
310        }
311    }
312}
313
314impl From<crate::fs::ResolvedResource> for ResolvedResource {
315    fn from(inner: crate::fs::ResolvedResource) -> Self {
316        match inner {
317            crate::fs::ResolvedResource::File(f) => {
318                ResolvedResource::File(ResolvedFile { inner: f })
319            }
320            crate::fs::ResolvedResource::Directory(d) => {
321                ResolvedResource::Directory(ResolvedDirectory { inner: d })
322            }
323            crate::fs::ResolvedResource::NotFound => ResolvedResource::NotFound,
324            crate::fs::ResolvedResource::Denied(r) => ResolvedResource::Denied(r.into()),
325        }
326    }
327}
328
329#[derive(Debug, Clone)]
330pub struct SecureRoot {
331    pinned: Arc<crate::fs::PinnedRoot>,
332    policy: StaticPolicy,
333}
334
335impl SecureRoot {
336    pub fn new(root: impl AsRef<Path>, policy: StaticPolicy) -> Result<Self, std::io::Error> {
337        let pinned = Arc::new(crate::fs::PinnedRoot::new(root.as_ref())?);
338        Ok(Self { pinned, policy })
339    }
340
341    #[allow(dead_code)]
342    pub fn policy(&self) -> &StaticPolicy {
343        &self.policy
344    }
345
346    #[allow(dead_code)]
347    pub fn root_path(&self) -> &Path {
348        self.pinned.canonical_root()
349    }
350
351    #[allow(dead_code)]
352    pub fn resolve(&self, path: &ConfinedPath) -> ResolvedResource {
353        let guard = RootGuard::new(&self.pinned);
354        guard.resolve(path, &self.policy).into()
355    }
356
357    #[allow(dead_code)]
358    pub fn resolve_uri(&self, uri: &str) -> Result<ResolvedResource, PathRejection> {
359        let path_policy = PathPolicy {
360            dotfiles: match self.policy.dotfiles {
361                crate::policy::DotfilePolicy::Denied => PathPolicy::default().dotfiles,
362                crate::policy::DotfilePolicy::Serve => crate::path::DotfilePolicy::Allow,
363            },
364            reject_backslash: true,
365        };
366        let confined = ConfinedPath::parse(uri, &path_policy)?;
367        Ok(self.resolve(&confined))
368    }
369}
370
371/// Resolve a confined path against a [`SecureRoot`] and, if the result is a
372/// file, plan the response and produce a [`BodySource`] in one call.
373///
374/// This exists because [`plan_file_response`] requires `&Metadata` (which is
375/// only available inside [`ResolvedFile`]) and [`ResolvedFile::into_body`]
376/// consumes the file.  Combining both steps here avoids exposing the internal
377/// `Metadata` field across crate boundaries.
378///
379/// Returns `(StaticResponsePlan, BodySource)` on success, or a
380/// [`ResourceDeniedReason`] / [`PathRejection`] on failure.
381#[allow(dead_code)]
382pub fn resolve_and_plan(
383    root: &SecureRoot,
384    path: &ConfinedPath,
385    method: ReadOnlyMethod,
386    if_none_match: Option<&str>,
387    if_modified_since: Option<&str>,
388    range_header: Option<&str>,
389    if_range: Option<&str>,
390) -> Result<(StaticResponsePlan, BodySource), ResolveAndPlanError> {
391    let resource = root.resolve(path);
392    match resource {
393        ResolvedResource::File(file) => {
394            let content_type = file.content_type();
395            let plan = crate::primitives::planner::plan_file_response(
396                method,
397                &file.inner.metadata,
398                content_type,
399                if_none_match,
400                if_modified_since,
401                range_header,
402                if_range,
403            );
404            let body = file.into_body(&plan)?;
405            Ok((plan, body))
406        }
407        ResolvedResource::Directory(_) => Err(ResolveAndPlanError::IsDirectory),
408        ResolvedResource::NotFound => Err(ResolveAndPlanError::NotFound),
409        ResolvedResource::Denied(reason) => Err(ResolveAndPlanError::Denied(reason)),
410    }
411}
412
413/// Errors returned by [`resolve_and_plan`].
414#[derive(Debug)]
415#[allow(dead_code)]
416pub enum ResolveAndPlanError {
417    NotFound,
418    IsDirectory,
419    Denied(ResourceDeniedReason),
420    Body(BodySourceError),
421}
422
423impl fmt::Display for ResolveAndPlanError {
424    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
425        match self {
426            Self::NotFound => write!(f, "not found"),
427            Self::IsDirectory => write!(f, "is a directory"),
428            Self::Denied(r) => write!(f, "{}", r),
429            Self::Body(e) => write!(f, "{}", e),
430        }
431    }
432}
433
434impl std::error::Error for ResolveAndPlanError {
435    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
436        match self {
437            Self::Denied(r) => Some(r),
438            Self::Body(e) => Some(e),
439            _ => None,
440        }
441    }
442}
443
444impl From<BodySourceError> for ResolveAndPlanError {
445    fn from(e: BodySourceError) -> Self {
446        Self::Body(e)
447    }
448}
449
450#[cfg(test)]
451mod tests {
452    use super::*;
453    use crate::path::PathPolicy as PP;
454    use crate::policy::{DirectoryListingPolicy, DotfilePolicy, SymlinkPolicy};
455    use crate::primitives::body::BodyKind;
456    use std::fs;
457    use tempfile::TempDir;
458
459    fn setup() -> (TempDir, SecureRoot) {
460        let tmp = TempDir::new().unwrap();
461        fs::write(tmp.path().join("hello.txt"), "hello").unwrap();
462        fs::write(tmp.path().join(".env"), "secret").unwrap();
463        fs::write(tmp.path().join("empty.txt"), "").unwrap();
464        fs::create_dir(tmp.path().join("subdir")).unwrap();
465        fs::write(tmp.path().join("subdir").join("file.txt"), "file").unwrap();
466        fs::write(
467            tmp.path().join("subdir").join("index.html"),
468            "<html>hi</html>",
469        )
470        .unwrap();
471        let root = SecureRoot::new(tmp.path(), StaticPolicy::safe_default()).unwrap();
472        (tmp, root)
473    }
474
475    fn parse(raw: &str) -> ConfinedPath {
476        ConfinedPath::parse(raw, &PP::default()).unwrap()
477    }
478
479    // ── Basic construction ──────────────────────────────────────────
480
481    #[test]
482    fn secure_root_new_accepts_existing_directory() {
483        let tmp = TempDir::new().unwrap();
484        let result = SecureRoot::new(tmp.path(), StaticPolicy::safe_default());
485        assert!(result.is_ok());
486    }
487
488    #[test]
489    fn secure_root_new_rejects_missing_path() {
490        let result = SecureRoot::new(
491            "/nonexistent/path/that/does/not/exist",
492            StaticPolicy::safe_default(),
493        );
494        assert!(result.is_err());
495    }
496
497    #[test]
498    fn secure_root_new_rejects_file_not_directory() {
499        let tmp = TempDir::new().unwrap();
500        let path = tmp.path().join("file.txt");
501        fs::write(&path, "content").unwrap();
502        let err = SecureRoot::new(&path, StaticPolicy::safe_default()).unwrap_err();
503        assert_eq!(err.kind(), std::io::ErrorKind::NotADirectory);
504    }
505
506    #[test]
507    fn secure_root_policy_returns_configured_policy() {
508        let (_tmp, root) = setup();
509        let policy = root.policy();
510        assert_eq!(policy.symlinks, SymlinkPolicy::Denied);
511        assert_eq!(policy.dotfiles, DotfilePolicy::Denied);
512        assert_eq!(policy.directory_listing, DirectoryListingPolicy::Disabled);
513    }
514
515    #[test]
516    fn secure_root_root_path_is_canonical() {
517        let tmp = TempDir::new().unwrap();
518        let subdir = tmp.path().join("a").join("b");
519        fs::create_dir_all(&subdir).unwrap();
520        let root = SecureRoot::new(&subdir, StaticPolicy::safe_default()).unwrap();
521        let rp = root.root_path();
522        assert!(rp.is_absolute());
523        assert!(rp.exists());
524        assert_eq!(rp, fs::canonicalize(&subdir).unwrap());
525    }
526
527    // ── File resolution ─────────────────────────────────────────────
528
529    #[test]
530    fn resolve_normal_file() {
531        let (_tmp, root) = setup();
532        let result = root.resolve(&parse("/hello.txt"));
533        assert!(result.is_file());
534    }
535
536    #[test]
537    fn resolve_file_is_empty() {
538        let (_tmp, root) = setup();
539        let file = root.resolve(&parse("/empty.txt")).into_file().unwrap();
540        assert!(file.is_empty());
541        assert_eq!(file.len(), 0);
542    }
543
544    #[test]
545    fn resolve_file_non_empty() {
546        let (_tmp, root) = setup();
547        let file = root.resolve(&parse("/hello.txt")).into_file().unwrap();
548        assert!(!file.is_empty());
549        assert_eq!(file.len(), 5);
550    }
551
552    #[test]
553    fn resolve_file_modified_returns_some() {
554        let (_tmp, root) = setup();
555        let file = root.resolve(&parse("/hello.txt")).into_file().unwrap();
556        assert!(file.modified().is_some());
557    }
558
559    #[test]
560    fn resolve_file_content_type_matches_extension() {
561        let (_tmp, root) = setup();
562        let file = root.resolve(&parse("/hello.txt")).into_file().unwrap();
563        assert_eq!(file.content_type(), "text/plain; charset=utf-8");
564    }
565
566    #[test]
567    fn resolve_file_safe_relative_components() {
568        let (_tmp, root) = setup();
569        let file = root.resolve(&parse("/hello.txt")).into_file().unwrap();
570        assert_eq!(file.safe_relative_components(), &["hello.txt"]);
571    }
572
573    // ── Directory resolution ────────────────────────────────────────
574
575    #[test]
576    fn resolve_normal_directory() {
577        let (_tmp, root) = setup();
578        let result = root.resolve(&parse("/subdir"));
579        assert!(result.is_directory());
580    }
581
582    #[test]
583    fn resolve_root_path_returns_directory() {
584        let (_tmp, root) = setup();
585        let result = root.resolve(&parse("/"));
586        assert!(result.is_directory());
587    }
588
589    #[test]
590    fn resolve_directory_components() {
591        let (_tmp, root) = setup();
592        let dir = root.resolve(&parse("/subdir")).into_directory().unwrap();
593        assert_eq!(dir.components(), &["subdir"]);
594    }
595
596    // ── NotFound ────────────────────────────────────────────────────
597
598    #[test]
599    fn resolve_missing_path() {
600        let (_tmp, root) = setup();
601        let result = root.resolve(&parse("/nonexistent.txt"));
602        assert!(result.is_not_found());
603    }
604
605    // ── Dotfile policy ──────────────────────────────────────────────
606
607    #[test]
608    fn resolve_dotfile_denied_under_defaults() {
609        let (_tmp, root) = setup();
610        let pp = PP {
611            dotfiles: crate::path::DotfilePolicy::Allow,
612            ..PP::default()
613        };
614        let path = ConfinedPath::parse("/.env", &pp).unwrap();
615        let result = root.resolve(&path);
616        match result {
617            ResolvedResource::Denied(reason) => {
618                assert!(matches!(reason, ResourceDeniedReason::DotfileDenied));
619            }
620            other => panic!("expected Denied(DotfileDenied), got {:?}", other),
621        }
622    }
623
624    #[test]
625    fn resolve_dotfile_allowed_when_policy_permits() {
626        let tmp = TempDir::new().unwrap();
627        fs::write(tmp.path().join(".env"), "secret").unwrap();
628        let mut policy = StaticPolicy::safe_default();
629        policy.dotfiles = DotfilePolicy::Serve;
630        let root = SecureRoot::new(tmp.path(), policy).unwrap();
631
632        let pp = PP {
633            dotfiles: crate::path::DotfilePolicy::Allow,
634            ..PP::default()
635        };
636        let path = ConfinedPath::parse("/.env", &pp).unwrap();
637        let result = root.resolve(&path);
638        assert!(result.is_file());
639    }
640
641    // ── Symlink policy (cfg(unix)) ──────────────────────────────────
642
643    #[cfg(unix)]
644    #[test]
645    fn resolve_symlink_denied_under_defaults() {
646        let tmp = TempDir::new().unwrap();
647        fs::write(tmp.path().join("real.txt"), "real").unwrap();
648        std::os::unix::fs::symlink(tmp.path().join("real.txt"), tmp.path().join("link.txt"))
649            .unwrap();
650
651        let root = SecureRoot::new(tmp.path(), StaticPolicy::safe_default()).unwrap();
652        let result = root.resolve(&parse("/link.txt"));
653        match result {
654            ResolvedResource::Denied(reason) => {
655                assert!(matches!(reason, ResourceDeniedReason::SymlinkDenied));
656            }
657            other => panic!("expected Denied(SymlinkDenied), got {:?}", other),
658        }
659    }
660
661    #[cfg(unix)]
662    #[test]
663    fn resolve_symlink_allowed_when_follow_enabled() {
664        let tmp = TempDir::new().unwrap();
665        fs::write(tmp.path().join("real.txt"), "real").unwrap();
666        std::os::unix::fs::symlink(tmp.path().join("real.txt"), tmp.path().join("link.txt"))
667            .unwrap();
668
669        let mut policy = StaticPolicy::safe_default();
670        policy.symlinks = SymlinkPolicy::Follow;
671        let root = SecureRoot::new(tmp.path(), policy).unwrap();
672
673        let pp = PP {
674            dotfiles: crate::path::DotfilePolicy::Allow,
675            ..PP::default()
676        };
677        let path = ConfinedPath::parse("/link.txt", &pp).unwrap();
678        let result = root.resolve(&path);
679        assert!(result.is_file());
680    }
681
682    #[cfg(unix)]
683    #[test]
684    fn resolve_intermediate_symlink_denied() {
685        let tmp = TempDir::new().unwrap();
686        fs::create_dir(tmp.path().join("real_dir")).unwrap();
687        fs::write(tmp.path().join("real_dir").join("file.txt"), "content").unwrap();
688        std::os::unix::fs::symlink(tmp.path().join("real_dir"), tmp.path().join("link_dir"))
689            .unwrap();
690
691        let root = SecureRoot::new(tmp.path(), StaticPolicy::safe_default()).unwrap();
692        let result = root.resolve(&parse("/link_dir/file.txt"));
693        match result {
694            ResolvedResource::Denied(reason) => {
695                assert!(matches!(reason, ResourceDeniedReason::SymlinkDenied));
696            }
697            other => panic!("expected Denied(SymlinkDenied), got {:?}", other),
698        }
699    }
700
701    #[cfg(unix)]
702    #[test]
703    fn resolve_outside_root_symlink_denied_when_follow_enabled() {
704        let tmp_root = TempDir::new().unwrap();
705        let tmp_outside = TempDir::new().unwrap();
706        fs::write(tmp_outside.path().join("secret.txt"), "leaked").unwrap();
707        std::os::unix::fs::symlink(
708            tmp_outside.path().join("secret.txt"),
709            tmp_root.path().join("escape.txt"),
710        )
711        .unwrap();
712
713        let mut policy = StaticPolicy::safe_default();
714        policy.symlinks = SymlinkPolicy::Follow;
715        let root = SecureRoot::new(tmp_root.path(), policy).unwrap();
716
717        let pp = PP {
718            dotfiles: crate::path::DotfilePolicy::Allow,
719            ..PP::default()
720        };
721        let path = ConfinedPath::parse("/escape.txt", &pp).unwrap();
722        let result = root.resolve(&path);
723        match result {
724            ResolvedResource::Denied(reason) => {
725                assert!(matches!(reason, ResourceDeniedReason::RootEscapeDenied));
726            }
727            other => panic!("expected Denied(RootEscapeDenied), got {:?}", other),
728        }
729    }
730
731    #[cfg(unix)]
732    #[test]
733    fn directory_listing_hides_symlinks_under_defaults() {
734        let tmp = TempDir::new().unwrap();
735        fs::write(tmp.path().join("real.txt"), "real").unwrap();
736        std::os::unix::fs::symlink(tmp.path().join("real.txt"), tmp.path().join("link.txt"))
737            .unwrap();
738
739        let root = SecureRoot::new(tmp.path(), StaticPolicy::safe_default()).unwrap();
740        let dir = root.resolve(&parse("/")).into_directory().unwrap();
741        let entries = dir
742            .list(&root, crate::limits::DEFAULT_MAX_LISTING_ENTRIES)
743            .unwrap();
744        let names: Vec<&str> = entries.iter().map(|(n, _)| n.as_str()).collect();
745        assert!(names.contains(&"real.txt"));
746        assert!(!names.contains(&"link.txt"));
747    }
748
749    // ── Directory child resolution ──────────────────────────────────
750
751    #[test]
752    fn directory_resolve_child_index() {
753        let (_tmp, root) = setup();
754        let dir = root.resolve(&parse("/subdir")).into_directory().unwrap();
755        let result = dir.resolve_child("index.html", &root);
756        assert!(result.is_file());
757    }
758
759    #[test]
760    fn directory_resolve_child_missing() {
761        let (_tmp, root) = setup();
762        let dir = root.resolve(&parse("/subdir")).into_directory().unwrap();
763        let result = dir.resolve_child("missing.txt", &root);
764        assert!(result.is_not_found());
765    }
766
767    #[test]
768    fn directory_resolve_child_dotfile_denied() {
769        let (_tmp, root) = setup();
770        fs::write(root.root_path().join("subdir").join(".env"), "secret").unwrap();
771        let dir = root.resolve(&parse("/subdir")).into_directory().unwrap();
772        let result = dir.resolve_child(".env", &root);
773        match result {
774            ResolvedResource::Denied(reason) => {
775                assert!(matches!(reason, ResourceDeniedReason::DotfileDenied));
776            }
777            other => panic!("expected Denied(DotfileDenied), got {:?}", other),
778        }
779    }
780
781    // ── Directory listing ───────────────────────────────────────────
782
783    #[test]
784    #[ignore = "Directory listing uses path-based traversal; needs Windows handle-based qualification"]
785    fn directory_list_returns_entries() {
786        let (_tmp, root) = setup();
787        let dir = root.resolve(&parse("/subdir")).into_directory().unwrap();
788        let entries = dir
789            .list(&root, crate::limits::DEFAULT_MAX_LISTING_ENTRIES)
790            .unwrap();
791        let names: Vec<&str> = entries.iter().map(|(n, _)| n.as_str()).collect();
792        assert!(names.contains(&"file.txt"));
793        assert!(names.contains(&"index.html"));
794    }
795
796    #[test]
797    #[ignore = "Directory listing uses path-based traversal; needs Windows handle-based qualification"]
798    fn directory_list_hides_dotfiles_under_defaults() {
799        let (_tmp, root) = setup();
800        fs::write(root.root_path().join("subdir").join(".env"), "secret").unwrap();
801        let dir = root.resolve(&parse("/subdir")).into_directory().unwrap();
802        let entries = dir
803            .list(&root, crate::limits::DEFAULT_MAX_LISTING_ENTRIES)
804            .unwrap();
805        let names: Vec<&str> = entries.iter().map(|(n, _)| n.as_str()).collect();
806        assert!(!names.contains(&".env"));
807    }
808
809    // ── URI resolution ──────────────────────────────────────────────
810
811    #[test]
812    fn resolve_uri_simple_file() {
813        let (_tmp, root) = setup();
814        let result = root.resolve_uri("/hello.txt").unwrap();
815        assert!(result.is_file());
816    }
817
818    #[test]
819    fn resolve_uri_rejects_dotfile_path() {
820        let (_tmp, root) = setup();
821        let result = root.resolve_uri("/.env");
822        assert!(result.is_err());
823        assert_eq!(result.unwrap_err(), PathRejection::DotfileDenied);
824    }
825
826    #[test]
827    fn resolve_uri_rejects_traversal() {
828        let (_tmp, root) = setup();
829        let result = root.resolve_uri("/../etc/passwd");
830        assert!(result.is_err());
831        assert_eq!(result.unwrap_err(), PathRejection::ParentComponent);
832    }
833
834    // ── Resource accessors ──────────────────────────────────────────
835
836    #[test]
837    fn resolved_resource_is_file() {
838        let (_tmp, root) = setup();
839        let r = root.resolve(&parse("/hello.txt"));
840        assert!(r.is_file());
841        assert!(!r.is_directory());
842        assert!(!r.is_not_found());
843        assert!(!r.is_denied());
844    }
845
846    #[test]
847    fn resolved_resource_is_directory() {
848        let (_tmp, root) = setup();
849        let r = root.resolve(&parse("/subdir"));
850        assert!(r.is_directory());
851        assert!(!r.is_file());
852        assert!(!r.is_not_found());
853        assert!(!r.is_denied());
854    }
855
856    #[test]
857    fn resolved_resource_is_not_found() {
858        let (_tmp, root) = setup();
859        let r = root.resolve(&parse("/missing.txt"));
860        assert!(r.is_not_found());
861        assert!(!r.is_file());
862        assert!(!r.is_directory());
863        assert!(!r.is_denied());
864    }
865
866    #[test]
867    fn resolved_resource_is_denied() {
868        let tmp = TempDir::new().unwrap();
869        fs::write(tmp.path().join(".env"), "secret").unwrap();
870        let root = SecureRoot::new(tmp.path(), StaticPolicy::safe_default()).unwrap();
871        let pp = PP {
872            dotfiles: crate::path::DotfilePolicy::Allow,
873            ..PP::default()
874        };
875        let path = ConfinedPath::parse("/.env", &pp).unwrap();
876        let r = root.resolve(&path);
877        assert!(r.is_denied());
878        assert!(!r.is_file());
879        assert!(!r.is_directory());
880        assert!(!r.is_not_found());
881    }
882
883    #[test]
884    fn resolved_resource_as_file() {
885        let (_tmp, root) = setup();
886        let r = root.resolve(&parse("/hello.txt"));
887        assert!(r.as_file().is_some());
888        assert!(r.as_directory().is_none());
889    }
890
891    #[test]
892    fn resolved_resource_as_directory() {
893        let (_tmp, root) = setup();
894        let r = root.resolve(&parse("/subdir"));
895        assert!(r.as_directory().is_some());
896        assert!(r.as_file().is_none());
897    }
898
899    #[test]
900    fn resolved_resource_into_file() {
901        let (_tmp, root) = setup();
902        let r = root.resolve(&parse("/hello.txt"));
903        assert!(r.into_file().is_some());
904    }
905
906    #[test]
907    fn resolved_resource_into_file_returns_none_for_directory() {
908        let (_tmp, root) = setup();
909        let r = root.resolve(&parse("/subdir"));
910        assert!(r.into_file().is_none());
911    }
912
913    #[test]
914    fn resolved_resource_into_directory() {
915        let (_tmp, root) = setup();
916        let r = root.resolve(&parse("/subdir"));
917        assert!(r.into_directory().is_some());
918    }
919
920    #[test]
921    fn resolved_resource_into_directory_returns_none_for_file() {
922        let (_tmp, root) = setup();
923        let r = root.resolve(&parse("/hello.txt"));
924        assert!(r.into_directory().is_none());
925    }
926
927    // ── ResourceDeniedReason ────────────────────────────────────────
928
929    #[test]
930    fn resource_denied_reason_display() {
931        let cases: &[(ResourceDeniedReason, &str)] = &[
932            (ResourceDeniedReason::SymlinkDenied, "symlink denied"),
933            (ResourceDeniedReason::DotfileDenied, "dotfile denied"),
934            (ResourceDeniedReason::RootEscapeDenied, "root escape denied"),
935            (
936                ResourceDeniedReason::PolicyDenied(PathRejection::Empty),
937                "empty path",
938            ),
939        ];
940        for (reason, expected) in cases {
941            assert_eq!(reason.to_string(), *expected);
942        }
943    }
944
945    #[test]
946    fn resource_denied_reason_from_path_rejection() {
947        let cases: &[(PathRejection, ResourceDeniedReason)] = &[
948            (
949                PathRejection::SymlinkDenied,
950                ResourceDeniedReason::SymlinkDenied,
951            ),
952            (
953                PathRejection::DotfileDenied,
954                ResourceDeniedReason::DotfileDenied,
955            ),
956            (
957                PathRejection::RootEscapeDenied,
958                ResourceDeniedReason::RootEscapeDenied,
959            ),
960        ];
961        for (rejection, expected) in cases {
962            let reason: ResourceDeniedReason = (*rejection).into();
963            assert!(std::mem::discriminant(&reason) == std::mem::discriminant(expected));
964        }
965
966        // Non-special rejections become PolicyDenied
967        let reason: ResourceDeniedReason = PathRejection::ParentComponent.into();
968        assert!(matches!(
969            reason,
970            ResourceDeniedReason::PolicyDenied(PathRejection::ParentComponent)
971        ));
972    }
973
974    // ── File parts (python-bindings-internal only) ───────────────────
975
976    #[test]
977    #[cfg(feature = "python-bindings-internal")]
978    fn resolved_file_into_std_file() {
979        let (_tmp, root) = setup();
980        let file = root.resolve(&parse("/hello.txt")).into_file().unwrap();
981        let std_file = file.into_std_file();
982        let mut contents = String::new();
983        std::io::Read::read_to_string(&mut std::io::BufReader::new(std_file), &mut contents)
984            .unwrap();
985        assert_eq!(contents, "hello");
986    }
987
988    #[test]
989    #[cfg(feature = "python-bindings-internal")]
990    fn resolved_file_into_parts() {
991        let (_tmp, root) = setup();
992        let file = root.resolve(&parse("/hello.txt")).into_file().unwrap();
993        let (std_file, metadata) = file.into_parts();
994        assert!(metadata.is_file());
995        let mut contents = String::new();
996        std::io::Read::read_to_string(&mut std::io::BufReader::new(std_file), &mut contents)
997            .unwrap();
998        assert_eq!(contents, "hello");
999    }
1000
1001    // ── Body source conversion ─────────────────────────────────────
1002
1003    #[test]
1004    fn into_body_file_full() {
1005        let (_tmp, root) = setup();
1006        let file = root.resolve(&parse("/hello.txt")).into_file().unwrap();
1007        let plan = StaticResponsePlan {
1008            status: crate::primitives::response::ResponseStatus::OK,
1009            headers: crate::primitives::response::HeaderMapPlan::new(),
1010            body: BodyPlan::FileFull,
1011        };
1012        let mut body = file.into_body(&plan).unwrap();
1013        assert_eq!(body.kind(), BodyKind::FileFull);
1014        assert_eq!(body.len(), 5);
1015        assert_eq!(body.read_all().unwrap(), b"hello");
1016    }
1017
1018    #[test]
1019    fn into_body_empty() {
1020        let (_tmp, root) = setup();
1021        let file = root.resolve(&parse("/hello.txt")).into_file().unwrap();
1022        let plan = StaticResponsePlan {
1023            status: crate::primitives::response::ResponseStatus::OK,
1024            headers: crate::primitives::response::HeaderMapPlan::new(),
1025            body: BodyPlan::Empty,
1026        };
1027        let body = file.into_body(&plan).unwrap();
1028        assert_eq!(body.kind(), BodyKind::Empty);
1029        assert!(body.is_empty());
1030    }
1031
1032    #[test]
1033    fn into_body_file_range() {
1034        let (_tmp, root) = setup();
1035        let file = root.resolve(&parse("/hello.txt")).into_file().unwrap();
1036        let plan = StaticResponsePlan {
1037            status: crate::primitives::response::ResponseStatus::PARTIAL_CONTENT,
1038            headers: crate::primitives::response::HeaderMapPlan::new(),
1039            body: BodyPlan::FileRange {
1040                start: 0,
1041                end_inclusive: 2,
1042            },
1043        };
1044        let mut body = file.into_body(&plan).unwrap();
1045        assert_eq!(body.kind(), BodyKind::FileRange);
1046        assert_eq!(body.len(), 3);
1047        assert_eq!(body.read_all().unwrap(), b"hel");
1048    }
1049
1050    #[test]
1051    fn into_body_file_range_invalid() {
1052        let (_tmp, root) = setup();
1053        let file = root.resolve(&parse("/hello.txt")).into_file().unwrap();
1054        let plan = StaticResponsePlan {
1055            status: crate::primitives::response::ResponseStatus::PARTIAL_CONTENT,
1056            headers: crate::primitives::response::HeaderMapPlan::new(),
1057            body: BodyPlan::FileRange {
1058                start: 0,
1059                end_inclusive: 999,
1060            },
1061        };
1062        let err = file.into_body(&plan).unwrap_err();
1063        assert!(matches!(err, BodySourceError::InvalidRange));
1064    }
1065
1066    #[test]
1067    fn into_range_body_valid() {
1068        let (_tmp, root) = setup();
1069        let file = root.resolve(&parse("/hello.txt")).into_file().unwrap();
1070        let mut body = file.into_range_body(1, 3).unwrap();
1071        assert_eq!(body.kind(), BodyKind::FileRange);
1072        assert_eq!(body.len(), 3);
1073        assert_eq!(body.read_all().unwrap(), b"ell");
1074    }
1075
1076    #[test]
1077    fn into_range_body_invalid() {
1078        let (_tmp, root) = setup();
1079        let file = root.resolve(&parse("/hello.txt")).into_file().unwrap();
1080        let err = file.into_range_body(0, 999).unwrap_err();
1081        assert!(matches!(err, BodySourceError::InvalidRange));
1082    }
1083
1084    #[test]
1085    fn into_range_body_reversed_invalid() {
1086        let (_tmp, root) = setup();
1087        let file = root.resolve(&parse("/hello.txt")).into_file().unwrap();
1088        let err = file.into_range_body(3, 1).unwrap_err();
1089        assert!(matches!(err, BodySourceError::InvalidRange));
1090    }
1091
1092    #[test]
1093    fn into_body_prevents_double_use() {
1094        let (_tmp, root) = setup();
1095        let file = root.resolve(&parse("/hello.txt")).into_file().unwrap();
1096        let plan = StaticResponsePlan {
1097            status: crate::primitives::response::ResponseStatus::OK,
1098            headers: crate::primitives::response::HeaderMapPlan::new(),
1099            body: BodyPlan::FileFull,
1100        };
1101        let _body = file.into_body(&plan).unwrap();
1102        // file is consumed — cannot use again
1103    }
1104
1105    #[test]
1106    fn into_body_bytes_variant() {
1107        let (_tmp, root) = setup();
1108        let file = root.resolve(&parse("/hello.txt")).into_file().unwrap();
1109        let plan = StaticResponsePlan {
1110            status: crate::primitives::response::ResponseStatus::OK,
1111            headers: crate::primitives::response::HeaderMapPlan::new(),
1112            body: BodyPlan::FullBytes(b"custom".to_vec()),
1113        };
1114        let mut body = file.into_body(&plan).unwrap();
1115        assert_eq!(body.kind(), BodyKind::Bytes);
1116        assert_eq!(body.read_all().unwrap(), b"custom");
1117    }
1118
1119    // ── ResolvedDirectory.resolve_child edge cases ──────────────────
1120
1121    #[test]
1122    fn directory_resolve_child_with_dot_component() {
1123        let (_tmp, root) = setup();
1124        let dir = root.resolve(&parse("/subdir")).into_directory().unwrap();
1125        let result = dir.resolve_child(".", &root);
1126        assert!(matches!(
1127            result,
1128            ResolvedResource::Denied(ResourceDeniedReason::PolicyDenied(
1129                PathRejection::CurrentComponent
1130            ))
1131        ));
1132    }
1133
1134    #[test]
1135    fn directory_resolve_child_with_dotdot_component() {
1136        let (_tmp, root) = setup();
1137        let dir = root.resolve(&parse("/subdir")).into_directory().unwrap();
1138        let result = dir.resolve_child("..", &root);
1139        assert!(matches!(
1140            result,
1141            ResolvedResource::Denied(ResourceDeniedReason::PolicyDenied(
1142                PathRejection::ParentComponent
1143            ))
1144        ));
1145    }
1146
1147    #[test]
1148    fn directory_resolve_child_empty_name() {
1149        let (_tmp, root) = setup();
1150        let dir = root.resolve(&parse("/subdir")).into_directory().unwrap();
1151        let result = dir.resolve_child("", &root);
1152        assert!(matches!(
1153            result,
1154            ResolvedResource::Denied(ResourceDeniedReason::PolicyDenied(PathRejection::Empty))
1155        ));
1156    }
1157
1158    #[test]
1159    fn directory_resolve_child_nul_byte() {
1160        let (_tmp, root) = setup();
1161        let dir = root.resolve(&parse("/subdir")).into_directory().unwrap();
1162        let result = dir.resolve_child("foo\0bar", &root);
1163        assert!(matches!(
1164            result,
1165            ResolvedResource::Denied(ResourceDeniedReason::PolicyDenied(PathRejection::NulByte))
1166        ));
1167    }
1168
1169    #[cfg(unix)]
1170    #[test]
1171    fn directory_resolve_child_backslash() {
1172        let (_tmp, root) = setup();
1173        let dir = root.resolve(&parse("/subdir")).into_directory().unwrap();
1174        let result = dir.resolve_child("foo\\bar", &root);
1175        assert!(matches!(
1176            result,
1177            ResolvedResource::Denied(ResourceDeniedReason::PolicyDenied(
1178                PathRejection::SeparatorAmbiguity
1179            ))
1180        ));
1181    }
1182
1183    #[test]
1184    fn directory_resolve_child_valid_name() {
1185        let (_tmp, root) = setup();
1186        let dir = root.resolve(&parse("/subdir")).into_directory().unwrap();
1187        let result = dir.resolve_child("file.txt", &root);
1188        assert!(result.is_file());
1189    }
1190}