greentic_deployer/
path_safety.rs1use std::path::{Path, PathBuf};
2
3use anyhow::{Context, Result};
4use thiserror::Error;
5
6#[derive(Debug, Error)]
11pub enum PathSafetyError {
12 #[error("path component `{}` is a symlink (escape risk)", .path.display())]
13 SymlinkAncestor { path: PathBuf },
14 #[error("io error on `{}`: {source}", .path.display())]
15 Io {
16 path: PathBuf,
17 #[source]
18 source: std::io::Error,
19 },
20}
21
22pub fn assert_no_symlink_ancestors(root: &Path, target: &Path) -> Result<(), PathSafetyError> {
35 let suffix = match target.strip_prefix(root) {
36 Ok(s) => s,
37 Err(_) => return Ok(()),
38 };
39 let mut current = root.to_path_buf();
40 for component in suffix.components() {
41 current.push(component);
42 match std::fs::symlink_metadata(¤t) {
43 Ok(meta) if meta.is_symlink() => {
44 return Err(PathSafetyError::SymlinkAncestor { path: current });
45 }
46 Ok(_) => {}
47 Err(e) if e.kind() == std::io::ErrorKind::NotFound => break,
48 Err(e) => {
49 return Err(PathSafetyError::Io {
50 path: current,
51 source: e,
52 });
53 }
54 }
55 }
56 Ok(())
57}
58
59pub fn normalize_under_root(root: &Path, candidate: &Path) -> Result<PathBuf> {
62 if candidate.is_absolute() {
63 anyhow::bail!("absolute paths are not allowed: {}", candidate.display());
64 }
65
66 let root_canon = root
67 .canonicalize()
68 .with_context(|| format!("failed to canonicalize {}", root.display()))?;
69 let joined = root_canon.join(candidate);
70 let canon = joined
71 .canonicalize()
72 .with_context(|| format!("failed to canonicalize {}", joined.display()))?;
73
74 if !canon.starts_with(&root_canon) {
75 anyhow::bail!(
76 "path escapes root ({}): {}",
77 root_canon.display(),
78 canon.display()
79 );
80 }
81
82 Ok(canon)
83}
84
85#[cfg(test)]
86mod tests {
87 use super::*;
88 use tempfile::tempdir;
89
90 #[test]
91 fn assert_no_symlink_ancestors_passes_on_plain_dirs() {
92 let root = tempdir().unwrap();
93 let nested = root.path().join("a").join("b");
94 std::fs::create_dir_all(&nested).unwrap();
95 let target = nested.join("c.txt");
96 assert!(assert_no_symlink_ancestors(root.path(), &target).is_ok());
97 }
98
99 #[test]
100 fn assert_no_symlink_ancestors_passes_on_nonexistent_tail() {
101 let root = tempdir().unwrap();
102 let target = root.path().join("a").join("b").join("c.txt");
104 assert!(assert_no_symlink_ancestors(root.path(), &target).is_ok());
105 }
106
107 #[cfg(unix)]
108 #[test]
109 fn assert_no_symlink_ancestors_rejects_symlink_component() {
110 use std::os::unix::fs::symlink;
111 let root = tempdir().unwrap();
112 let elsewhere = tempdir().unwrap();
113 symlink(elsewhere.path(), root.path().join("rules")).unwrap();
115 let target = root.path().join("rules").join("aws-ecs").join("policy.tf");
116 let err = assert_no_symlink_ancestors(root.path(), &target).unwrap_err();
117 assert!(
118 matches!(err, PathSafetyError::SymlinkAncestor { ref path } if path.ends_with("rules")),
119 "expected SymlinkAncestor at the `rules` segment, got {err:?}"
120 );
121 }
122
123 #[test]
124 fn assert_no_symlink_ancestors_noop_when_target_outside_root() {
125 let root = tempdir().unwrap();
128 let other = tempdir().unwrap();
129 assert!(assert_no_symlink_ancestors(root.path(), other.path()).is_ok());
130 }
131}