ic_host_fs/read/beneath/mod.rs
1//! No-follow traversal relative to an explicitly supplied directory capability.
2
3use super::{
4 ArtifactError, admit_file,
5 entry::{entry_type, open_child, opened_type},
6};
7use rustix::fs::{FileType, Mode, OFlags};
8use std::{
9 fs::File,
10 io,
11 os::fd::{AsFd, BorrowedFd, OwnedFd},
12 path::{Component, Path},
13};
14
15/// Open an existing directory without following its final symlink.
16///
17/// DIRECTORY and NONBLOCK refuse non-directories without waiting on a FIFO.
18/// Ancestor custody and root selection remain caller-owned; use the returned
19/// capability with [`open_regular_file_beneath`] for descendant traversal.
20/// No entry is created or modified.
21///
22/// # Errors
23/// Returns native open failures, including final symlink/non-directory refusal.
24pub fn open_directory_no_follow(path: &Path) -> io::Result<File> {
25 rustix::fs::open(
26 path,
27 OFlags::RDONLY | OFlags::DIRECTORY | OFlags::NOFOLLOW | OFlags::NONBLOCK | OFlags::CLOEXEC,
28 Mode::empty(),
29 )
30 .map(File::from)
31 .map_err(io::Error::from)
32}
33
34/// One admitted root-relative entry with its original descriptor retained.
35#[derive(Debug)]
36pub enum OpenedEntry {
37 /// A regular file, opened without following any selected component.
38 RegularFile(File),
39 /// A directory, opened without following any selected component.
40 Directory(File),
41}
42
43/// Refusal or native failure when selecting a root-relative entry.
44#[derive(Debug, thiserror::Error)]
45pub enum OpenEntryError {
46 /// A native open or metadata operation failed.
47 #[error("root-relative opening failed: {0}")]
48 Io(#[from] io::Error),
49 /// The selected entry is neither a regular file nor a directory.
50 #[error("unsupported entry: {kind:?}")]
51 UnsupportedEntry {
52 /// Observed descriptor kind.
53 kind: FileType,
54 },
55}
56
57/// Open one regular file or directory beneath a caller-held root descriptor.
58///
59/// Every component must be normal and is selected through its held parent.
60/// Symlinks and special final entries refuse. A directory result composes with
61/// [`super::tree::walk_tree`]; a file result still needs a caller-selected byte
62/// allowance before reading. The caller owns root custody, stable content and
63/// protection against concurrent namespace mutation. An open descriptor does
64/// not prove that its original pathname still names the same entry.
65///
66/// # Errors
67/// Rejects invalid paths, observed symlinks and unsupported final entry types,
68/// or returns native traversal/open/metadata errors.
69pub fn open_entry_beneath(
70 root: BorrowedFd<'_>,
71 relative: &Path,
72) -> Result<OpenedEntry, OpenEntryError> {
73 let mut parts = relative.components().peekable();
74 if parts.peek().is_none() || parts.clone().any(|p| !matches!(p, Component::Normal(_))) {
75 return Err(io::Error::new(
76 io::ErrorKind::InvalidInput,
77 "relative normal components required",
78 )
79 .into());
80 }
81 let mut parent: Option<OwnedFd> = None;
82 while let Some(Component::Normal(name)) = parts.next() {
83 let directory = parent.as_ref().map_or(root, AsFd::as_fd);
84 let observed = entry_type(directory, name)?;
85 if observed == FileType::Symlink {
86 return Err(
87 io::Error::new(io::ErrorKind::InvalidInput, "symlink component refused").into(),
88 );
89 }
90 let final_component = parts.peek().is_none();
91 if final_component && !matches!(observed, FileType::RegularFile | FileType::Directory) {
92 return Err(OpenEntryError::UnsupportedEntry { kind: observed });
93 }
94 let opened = open_child(
95 directory,
96 name,
97 !final_component || observed == FileType::Directory,
98 )?;
99 if final_component {
100 let actual = opened_type(&opened)?;
101 let file = File::from(opened);
102 return match (observed, actual) {
103 (FileType::RegularFile, FileType::RegularFile) => {
104 Ok(OpenedEntry::RegularFile(file))
105 }
106 (FileType::Directory, FileType::Directory) => Ok(OpenedEntry::Directory(file)),
107 _ => Err(OpenEntryError::UnsupportedEntry { kind: actual }),
108 };
109 }
110 parent = Some(opened);
111 }
112 unreachable!("validated nonempty normal path");
113}
114
115/// Open one bounded regular file through a caller-owned directory descriptor.
116///
117/// Rejects empty/absolute paths, parent traversal and symlinks at every selected
118/// component. Each admitted parent descriptor is retained until its child opens;
119/// path replacement cannot redirect later traversal through an ancestor alias.
120/// Final opening uses NONBLOCK before type/size admission, so a FIFO without a
121/// writer is refused without waiting. Reads can use [`super::read_opened_file`].
122///
123/// The caller owns the root's admission/custody, relative-name bounds, credentials,
124/// deadlines and content validation. An opened inode is not immutable content;
125/// concurrent writers and hard links are not excluded. Directory renames retain
126/// the selected capability; this does not prove current pathname containment.
127/// No file or directory is created, modified, synced or removed.
128///
129/// # Errors
130/// Syntax and observed symlink refusals return an `InvalidInput` I/O cause.
131/// Other traversal/open errors retain their native cause. Opened type/size and
132/// metadata failures use the existing artifact admission errors.
133pub fn open_regular_file_beneath(
134 root: BorrowedFd<'_>,
135 relative: &Path,
136 max_bytes: u64,
137) -> Result<File, ArtifactError> {
138 let opened = open_entry_beneath(root, relative).map_err(|error| match error {
139 OpenEntryError::Io(error) => ArtifactError::Io(error),
140 OpenEntryError::UnsupportedEntry { .. } => ArtifactError::NotRegularFile,
141 })?;
142 match opened {
143 OpenedEntry::RegularFile(file) => {
144 admit_file(&file, max_bytes)?;
145 Ok(file)
146 }
147 OpenedEntry::Directory(_) => Err(ArtifactError::NotRegularFile),
148 }
149}
150
151#[cfg(test)]
152mod tests;