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#[derive(Debug)]
56pub 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 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 #[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 #[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 #[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#[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#[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 let reason: ResourceDeniedReason = PathRejection::ParentComponent.into();
968 assert!(matches!(
969 reason,
970 ResourceDeniedReason::PolicyDenied(PathRejection::ParentComponent)
971 ));
972 }
973
974 #[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 #[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 }
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 #[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}