Skip to main content

repolith_core/
paths.rs

1//! Run-time path containment — the filesystem half of the two-stage
2//! validation used by actions that resolve user-supplied relative paths
3//! (`docker`'s `dockerfile`/`context`, federation's `manifest`).
4//!
5//! The lexical half lives in the manifest validator
6//! (`check_contained_path`): relative-only, no `..` component, no leading
7//! dash, no control characters. That check cannot see symlinks — `sub`
8//! pointing at `/etc` is lexically clean. `contain_within` closes the
9//! hole by canonicalizing and requiring the result to stay inside the
10//! canonicalized base.
11
12use crate::types::BuildError;
13use std::path::{Path, PathBuf};
14
15/// Resolve `relative` against `base` and require the canonicalized result
16/// to stay inside canonicalized `base`.
17///
18/// # Errors
19/// - [`BuildError::Io`] when either canonicalization fails (missing file,
20///   permission) or when the resolved path escapes `base` (symlink
21///   traversal).
22pub fn contain_within(base: &Path, relative: &Path) -> Result<PathBuf, BuildError> {
23    let base_canon = base
24        .canonicalize()
25        .map_err(|e| BuildError::Io(format!("canonicalize base {}: {e}", base.display())))?;
26    let joined = base_canon.join(relative);
27    let canon = joined
28        .canonicalize()
29        .map_err(|e| BuildError::Io(format!("canonicalize {}: {e}", joined.display())))?;
30    if !canon.starts_with(&base_canon) {
31        return Err(BuildError::Io(format!(
32            "path {} escapes the node checkout {} (symlink traversal?)",
33            canon.display(),
34            base_canon.display()
35        )));
36    }
37    Ok(canon)
38}
39
40#[cfg(test)]
41mod tests {
42    use super::*;
43
44    #[test]
45    fn accepts_inner_path() {
46        let dir = tempfile::tempdir().unwrap();
47        std::fs::create_dir(dir.path().join("sub")).unwrap();
48        let got = contain_within(dir.path(), Path::new("sub")).unwrap();
49        assert!(got.ends_with("sub"));
50    }
51
52    #[test]
53    fn rejects_missing_path() {
54        let dir = tempfile::tempdir().unwrap();
55        assert!(contain_within(dir.path(), Path::new("absent")).is_err());
56    }
57
58    #[cfg(unix)]
59    #[test]
60    fn rejects_symlink_escape() {
61        let outside = tempfile::tempdir().unwrap();
62        let dir = tempfile::tempdir().unwrap();
63        std::os::unix::fs::symlink(outside.path(), dir.path().join("evil")).unwrap();
64        let err = contain_within(dir.path(), Path::new("evil")).unwrap_err();
65        assert!(err.to_string().contains("escapes"), "got: {err}");
66    }
67
68    #[cfg(unix)]
69    #[test]
70    fn rejects_symlinked_file_escape() {
71        let outside = tempfile::tempdir().unwrap();
72        let secret = outside.path().join("secret");
73        std::fs::write(&secret, b"x").unwrap();
74        let dir = tempfile::tempdir().unwrap();
75        std::os::unix::fs::symlink(&secret, dir.path().join("Dockerfile")).unwrap();
76        let err = contain_within(dir.path(), Path::new("Dockerfile")).unwrap_err();
77        assert!(err.to_string().contains("escapes"), "got: {err}");
78    }
79}