1use std::path::{Path, PathBuf};
6
7use crate::config::expand_home;
8use crate::error::{Error, Result};
9
10pub struct LocalScope {
11 roots: Vec<PathBuf>,
12}
13
14#[derive(Debug, Clone)]
15pub struct LocalEntry {
16 pub name: String,
17 pub is_dir: bool,
18 pub size: u64,
19}
20
21impl LocalScope {
22 pub fn new(allowed: &[PathBuf]) -> Self {
26 let roots = allowed
27 .iter()
28 .map(|p| {
29 let p = expand_home(p);
30 p.canonicalize().unwrap_or(p)
31 })
32 .collect();
33 Self { roots }
34 }
35
36 pub fn roots(&self) -> &[PathBuf] {
37 &self.roots
38 }
39
40 pub fn is_empty(&self) -> bool {
41 self.roots.is_empty()
42 }
43
44 pub fn resolve_existing(&self, raw: &str) -> Result<PathBuf> {
48 if self.roots.is_empty() {
49 return Err(Error::Denied(
50 "no local directories are allowed (local.allowed_dirs is empty)".into(),
51 ));
52 }
53 let p = expand_home(Path::new(raw));
54 let p = if p.is_absolute() {
55 p
56 } else if self.roots.len() == 1 {
57 self.roots[0].join(p)
58 } else {
59 return Err(Error::Invalid(format!(
60 "`{raw}` is relative but several local directories are allowed; use an absolute \
61 path"
62 )));
63 };
64 let canon = p.canonicalize().map_err(|e| {
65 Error::Io(std::io::Error::new(
66 e.kind(),
67 format!("{}: {e}", p.display()),
68 ))
69 })?;
70 self.ensure_inside(&canon)?;
71 Ok(canon)
72 }
73
74 pub fn resolve_for_write(&self, raw: &str) -> Result<PathBuf> {
77 if self.roots.is_empty() {
78 return Err(Error::Denied(
79 "no local directories are allowed (local.allowed_dirs is empty)".into(),
80 ));
81 }
82 let p = expand_home(Path::new(raw));
83 let p = if p.is_absolute() {
84 p
85 } else if self.roots.len() == 1 {
86 self.roots[0].join(p)
87 } else {
88 return Err(Error::Invalid(format!(
89 "`{raw}` is relative but several local directories are allowed; use an absolute \
90 path"
91 )));
92 };
93 if p.components()
94 .any(|c| matches!(c, std::path::Component::ParentDir))
95 {
96 return Err(Error::Denied(format!("`{raw}` contains `..`")));
97 }
98 let mut existing = p.as_path();
99 let mut tail = Vec::new();
100 loop {
101 if existing.exists() {
102 break;
103 }
104 match (existing.parent(), existing.file_name()) {
105 (Some(parent), Some(name)) => {
106 tail.push(name.to_owned());
107 existing = parent;
108 }
109 _ => return Err(Error::Invalid(format!("`{raw}` has no existing ancestor"))),
110 }
111 }
112 let mut canon = existing.canonicalize()?;
113 self.ensure_inside(&canon)?;
114 for name in tail.into_iter().rev() {
115 canon.push(name);
116 }
117 Ok(canon)
118 }
119
120 fn ensure_inside(&self, canon: &Path) -> Result<()> {
121 if self.roots.iter().any(|r| canon.starts_with(r)) {
122 Ok(())
123 } else {
124 Err(Error::Denied(format!(
125 "`{}` is outside the allowed local directories ({})",
126 canon.display(),
127 self.roots
128 .iter()
129 .map(|r| r.display().to_string())
130 .collect::<Vec<_>>()
131 .join(", ")
132 )))
133 }
134 }
135
136 pub fn list(&self, dir: &Path) -> Result<Vec<LocalEntry>> {
137 let mut out = Vec::new();
138 for entry in std::fs::read_dir(dir)? {
139 let entry = entry?;
140 let meta = entry.metadata()?;
141 out.push(LocalEntry {
142 name: entry.file_name().to_string_lossy().into_owned(),
143 is_dir: meta.is_dir(),
144 size: meta.len(),
145 });
146 }
147 out.sort_by(|a, b| a.name.cmp(&b.name));
148 Ok(out)
149 }
150}
151
152#[cfg(test)]
153mod tests {
154 use super::*;
155
156 #[test]
157 fn refuses_outside_and_symlink_escape() {
158 let tmp = tempfile::tempdir().unwrap();
159 let root = tmp.path().join("allowed");
160 let outside = tmp.path().join("outside");
161 std::fs::create_dir_all(&root).unwrap();
162 std::fs::create_dir_all(&outside).unwrap();
163 std::fs::write(outside.join("secret.txt"), "x").unwrap();
164 std::fs::write(root.join("ok.txt"), "x").unwrap();
165 #[cfg(unix)]
166 std::os::unix::fs::symlink(outside.join("secret.txt"), root.join("link.txt")).unwrap();
167
168 let scope = LocalScope::new(std::slice::from_ref(&root));
169 assert!(scope
170 .resolve_existing(root.join("ok.txt").to_str().unwrap())
171 .is_ok());
172 assert!(scope.resolve_existing("ok.txt").is_ok());
173 assert!(scope
174 .resolve_existing(outside.join("secret.txt").to_str().unwrap())
175 .is_err());
176 assert!(scope.resolve_existing("../outside/secret.txt").is_err());
177 #[cfg(unix)]
178 assert!(scope.resolve_existing("link.txt").is_err());
179 }
180
181 #[test]
182 fn write_target_may_not_exist_yet() {
183 let tmp = tempfile::tempdir().unwrap();
184 let root = tmp.path().join("allowed");
185 std::fs::create_dir_all(&root).unwrap();
186 let scope = LocalScope::new(std::slice::from_ref(&root));
187 let p = scope.resolve_for_write("new/dir/file.bin").unwrap();
188 assert!(p.starts_with(root.canonicalize().unwrap()));
189 assert!(scope.resolve_for_write("../x").is_err());
190 assert!(scope.resolve_for_write("/etc/passwd").is_err());
191 }
192
193 #[test]
194 fn empty_scope_denies_everything() {
195 let scope = LocalScope::new(&[]);
196 assert!(matches!(
197 scope.resolve_existing("/tmp"),
198 Err(Error::Denied(_))
199 ));
200 }
201}