1use super::{IncludeRequest, IncludeResolver, InputSource, split_fragment};
7use crate::error::{Error, Result};
8use crate::prelude::*;
9
10#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
20#[non_exhaustive]
21pub enum SymlinkPolicy {
22 #[default]
26 FollowWithinRoot,
27 Reject,
30}
31
32#[derive(Debug, Clone)]
89pub struct SafeFileResolver {
90 symlink_policy: SymlinkPolicy,
91 capability: Arc<RootCapability>,
92}
93
94#[derive(Debug)]
95enum RootCapability {
96 #[cfg(unix)]
97 Ready {
98 dir: std::fs::File,
99 canonical_root: std::path::PathBuf,
100 },
101 #[cfg(not(unix))]
102 Ready {
103 canonical_root: std::path::PathBuf,
104 },
105 Failed(String),
106}
107
108impl SafeFileResolver {
109 #[must_use]
124 pub fn new(root: impl Into<std::path::PathBuf>) -> Self {
125 let root = root.into();
126 let capability = match open_root_capability(&root) {
127 Ok(capability) => capability,
128 Err(error) => RootCapability::Failed(error.to_string()),
129 };
130 Self {
131 symlink_policy: SymlinkPolicy::default(),
132 capability: Arc::new(capability),
133 }
134 }
135
136 #[must_use]
138 pub fn symlink_policy(mut self, policy: SymlinkPolicy) -> Self {
139 self.symlink_policy = policy;
140 self
141 }
142
143 #[must_use]
146 pub fn into_resolver(self) -> IncludeResolver {
147 let this = self;
148 IncludeResolver::new(move |req: IncludeRequest<'_>| this.resolve(req))
149 }
150
151 fn resolve(&self, req: IncludeRequest<'_>) -> Result<InputSource> {
152 let (path_part, _fragment) = split_fragment(req.spec);
155 let relative = normalize_relative_path(path_part).map_err(|message| {
156 Error::Custom(format!("include resolver: `{path_part}` {message}"))
157 })?;
158 let capability = self.root_capability()?;
159 let (file, name) =
160 open_from_root(capability, &relative, self.symlink_policy).map_err(|error| {
161 Error::Custom(format!(
162 "include resolver: `{}` escapes sandbox root, contains a rejected symlink, or cannot read securely: {error}",
163 slash_path(&relative)
164 ))
165 })?;
166 let bytes = read_regular_file(file, req.max_bytes).map_err(|error| {
167 Error::Custom(format!(
168 "include resolver: cannot read `{}`: {error}",
169 slash_path(&name)
170 ))
171 })?;
172 Ok(InputSource::new(slash_path(&name), bytes))
173 }
174
175 fn root_capability(&self) -> Result<&RootCapability> {
176 match self.capability.as_ref() {
177 ready @ RootCapability::Ready { .. } => Ok(ready),
178 RootCapability::Failed(error) => Err(Error::Custom(format!(
179 "include resolver: cannot open root: {error}"
180 ))),
181 }
182 }
183}
184
185fn read_regular_file(file: std::fs::File, max_bytes: usize) -> std::io::Result<String> {
189 use std::io::Read as _;
190
191 if !file.metadata()?.is_file() {
192 return Err(std::io::Error::new(
193 std::io::ErrorKind::InvalidInput,
194 "not a regular file",
195 ));
196 }
197 let limit = u64::try_from(max_bytes)
198 .unwrap_or(u64::MAX)
199 .saturating_add(1);
200 let mut bytes = String::new();
201 let _read = file.take(limit).read_to_string(&mut bytes)?;
202 Ok(bytes)
203}
204
205fn slash_path(path: &std::path::Path) -> String {
208 let parts: Vec<_> = path
209 .components()
210 .map(|c| c.as_os_str().to_string_lossy())
211 .collect();
212 parts.join("/")
213}
214
215fn normalize_relative_path(path: &str) -> core::result::Result<std::path::PathBuf, &'static str> {
216 use std::path::Component;
217
218 let mut normalized = std::path::PathBuf::new();
219 for component in std::path::Path::new(path).components() {
220 match component {
221 Component::Prefix(_) | Component::RootDir => {
222 return Err("must be relative to the root");
223 }
224 Component::CurDir => {}
225 Component::ParentDir => {
226 if !normalized.pop() {
227 return Err("escapes sandbox root");
228 }
229 }
230 Component::Normal(part) => normalized.push(part),
231 }
232 }
233 Ok(normalized)
234}
235
236#[cfg(unix)]
237fn open_root_capability(root: &std::path::Path) -> std::io::Result<RootCapability> {
238 use rustix::fs::{Mode, OFlags};
239
240 let fd = rustix::fs::open(
241 root,
242 OFlags::RDONLY | OFlags::DIRECTORY | OFlags::CLOEXEC,
243 Mode::empty(),
244 )?;
245 let dir = std::fs::File::from(fd);
246 #[cfg(target_vendor = "apple")]
247 let canonical_root = {
248 use std::os::unix::ffi::OsStringExt as _;
249
250 let path = rustix::fs::getpath(&dir)?;
251 std::path::PathBuf::from(std::ffi::OsString::from_vec(path.into_bytes()))
252 };
253 #[cfg(not(target_vendor = "apple"))]
254 let canonical_root = std::fs::canonicalize(root)?;
255
256 Ok(RootCapability::Ready {
257 dir,
258 canonical_root,
259 })
260}
261
262#[cfg(not(unix))]
263fn open_root_capability(root: &std::path::Path) -> std::io::Result<RootCapability> {
264 Ok(RootCapability::Ready {
265 canonical_root: std::fs::canonicalize(root)?,
266 })
267}
268
269#[cfg(unix)]
270fn open_from_root(
271 capability: &RootCapability,
272 relative: &std::path::Path,
273 policy: SymlinkPolicy,
274) -> std::io::Result<(std::fs::File, std::path::PathBuf)> {
275 let RootCapability::Ready {
276 dir,
277 canonical_root,
278 } = capability
279 else {
280 unreachable!("failed root capabilities are rejected before open")
281 };
282 let active_root = current_root_path(dir).unwrap_or_else(|_| canonical_root.clone());
283 let open_path = if policy == SymlinkPolicy::FollowWithinRoot {
284 let canonical = std::fs::canonicalize(active_root.join(relative))?;
285 canonical
286 .strip_prefix(&active_root)
287 .map_err(|_| {
288 std::io::Error::new(
289 std::io::ErrorKind::PermissionDenied,
290 "resolved path escapes sandbox root",
291 )
292 })?
293 .to_path_buf()
294 } else {
295 relative.to_path_buf()
296 };
297 let file = open_relative_nofollow(dir, &open_path)?;
298 Ok((file, open_path))
302}
303
304#[cfg(all(unix, target_vendor = "apple"))]
305fn current_root_path(root: &std::fs::File) -> std::io::Result<std::path::PathBuf> {
306 use std::os::unix::ffi::OsStringExt as _;
307
308 let path = rustix::fs::getpath(root)?;
309 Ok(std::path::PathBuf::from(std::ffi::OsString::from_vec(
310 path.into_bytes(),
311 )))
312}
313
314#[cfg(all(unix, any(target_os = "linux", target_os = "android")))]
315fn current_root_path(root: &std::fs::File) -> std::io::Result<std::path::PathBuf> {
316 use std::os::fd::AsRawFd as _;
317
318 std::fs::read_link(format!("/proc/self/fd/{}", root.as_raw_fd()))
319}
320
321#[cfg(all(
322 unix,
323 not(any(target_vendor = "apple", target_os = "linux", target_os = "android"))
324))]
325fn current_root_path(_root: &std::fs::File) -> std::io::Result<std::path::PathBuf> {
326 Err(std::io::Error::new(
327 std::io::ErrorKind::Unsupported,
328 "the target cannot recover a path from a directory handle",
329 ))
330}
331
332#[cfg(not(unix))]
333fn open_from_root(
334 capability: &RootCapability,
335 relative: &std::path::Path,
336 policy: SymlinkPolicy,
337) -> std::io::Result<(std::fs::File, std::path::PathBuf)> {
338 let RootCapability::Ready { canonical_root } = capability else {
339 unreachable!("failed root capabilities are rejected before open")
340 };
341 let canonical = std::fs::canonicalize(canonical_root.join(relative))?;
342 let Ok(beneath) = canonical.strip_prefix(canonical_root) else {
343 return Err(std::io::Error::new(
344 std::io::ErrorKind::PermissionDenied,
345 "resolved path escapes sandbox root",
346 ));
347 };
348 if policy == SymlinkPolicy::Reject && path_contains_symlink(canonical_root, relative)? {
349 return Err(std::io::Error::new(
350 std::io::ErrorKind::PermissionDenied,
351 "symlink rejected by policy",
352 ));
353 }
354 Ok((std::fs::File::open(&canonical)?, beneath.to_path_buf()))
355}
356
357#[cfg(unix)]
358fn open_relative_nofollow(
359 root: &std::fs::File,
360 relative: &std::path::Path,
361) -> std::io::Result<std::fs::File> {
362 use rustix::fs::{Mode, OFlags};
363
364 let file_name = relative.file_name().ok_or_else(|| {
365 std::io::Error::new(
366 std::io::ErrorKind::InvalidInput,
367 "include path names no file",
368 )
369 })?;
370 let mut parent = rustix::fs::openat(
371 root,
372 ".",
373 OFlags::RDONLY | OFlags::DIRECTORY | OFlags::NOFOLLOW | OFlags::CLOEXEC,
374 Mode::empty(),
375 )?;
376 if let Some(ancestors) = relative.parent() {
377 for component in ancestors.components() {
378 parent = rustix::fs::openat(
379 &parent,
380 component.as_os_str(),
381 OFlags::RDONLY | OFlags::DIRECTORY | OFlags::NOFOLLOW | OFlags::CLOEXEC,
382 Mode::empty(),
383 )?;
384 }
385 }
386 let fd = rustix::fs::openat(
390 &parent,
391 file_name,
392 OFlags::RDONLY | OFlags::NOFOLLOW | OFlags::CLOEXEC | OFlags::NONBLOCK,
393 Mode::empty(),
394 )?;
395 Ok(std::fs::File::from(fd))
396}
397
398#[cfg(not(unix))]
399fn path_contains_symlink(
400 root: &std::path::Path,
401 relative: &std::path::Path,
402) -> std::io::Result<bool> {
403 let mut candidate = root.to_path_buf();
404 for component in relative.components() {
405 candidate.push(component.as_os_str());
406 if std::fs::symlink_metadata(&candidate)?
407 .file_type()
408 .is_symlink()
409 {
410 return Ok(true);
411 }
412 }
413 Ok(false)
414}
415
416#[cfg(test)]
417mod tests {
418 use super::slash_path;
419 use std::path::Path;
420
421 #[test]
422 fn source_names_use_forward_slashes_on_every_platform() {
423 let nested = Path::new("sub").join("deeper").join("a.yaml");
426 assert_eq!(slash_path(&nested), "sub/deeper/a.yaml");
427 assert_eq!(slash_path(Path::new("a.yaml")), "a.yaml");
428 }
429}