1use std::ffi::{OsStr, OsString};
12use std::os::fd::OwnedFd;
13use std::os::unix::ffi::OsStrExt;
14use std::path::{Component, Path, PathBuf};
15use std::sync::Arc;
16
17use rustix::fs::{AtFlags, Dir, FileType, Mode, OFlags};
18use rustix::io::Errno;
19
20use crate::config::expand_home;
21use crate::error::{Error, Result};
22
23pub struct LocalScope {
24 roots: Vec<PathBuf>,
25 ids: Vec<Option<(u64, u64)>>,
28}
29
30#[derive(Debug, Clone)]
31pub struct LocalEntry {
32 pub name: String,
33 pub is_dir: bool,
34 pub size: u64,
35}
36
37impl LocalScope {
38 pub fn new(allowed: &[PathBuf]) -> Self {
42 let mut roots = Vec::with_capacity(allowed.len());
43 let mut ids = Vec::with_capacity(allowed.len());
44 for p in allowed {
45 let p = expand_home(p);
46 let (root, id) = match p.canonicalize() {
47 Ok(c) => {
48 let id = rustix::fs::stat(&c).ok().map(|st| identity(&st));
49 (c, id)
50 }
51 Err(_) => (p, None),
52 };
53 roots.push(root);
54 ids.push(id);
55 }
56 Self { roots, ids }
57 }
58
59 pub fn roots(&self) -> &[PathBuf] {
60 &self.roots
61 }
62
63 pub fn is_empty(&self) -> bool {
64 self.roots.is_empty()
65 }
66
67 pub fn resolve_existing(&self, raw: &str) -> Result<PathBuf> {
71 let p = self.absolute(raw)?;
72 let canon = p.canonicalize().map_err(|e| {
73 Error::Io(std::io::Error::new(
74 e.kind(),
75 format!("{}: {e}", p.display()),
76 ))
77 })?;
78 self.ensure_inside(&canon)?;
79 Ok(canon)
80 }
81
82 pub fn resolve_for_write(&self, raw: &str) -> Result<PathBuf> {
85 let p = self.absolute(raw)?;
86 if p.components().any(|c| matches!(c, Component::ParentDir)) {
87 return Err(Error::Denied(format!("`{raw}` contains `..`")));
88 }
89 let mut existing = p.as_path();
90 let mut tail = Vec::new();
91 loop {
92 if existing.exists() {
93 break;
94 }
95 match (existing.parent(), existing.file_name()) {
96 (Some(parent), Some(name)) => {
97 tail.push(name.to_owned());
98 existing = parent;
99 }
100 _ => return Err(Error::Invalid(format!("`{raw}` has no existing ancestor"))),
101 }
102 }
103 let mut canon = existing.canonicalize()?;
104 self.ensure_inside(&canon)?;
105 for name in tail.into_iter().rev() {
106 canon.push(name);
107 }
108 Ok(canon)
109 }
110
111 fn absolute(&self, raw: &str) -> Result<PathBuf> {
114 if self.roots.is_empty() {
115 return Err(Error::Denied(
116 "no local directories are allowed (local.allowed_dirs is empty)".into(),
117 ));
118 }
119 let p = expand_home(Path::new(raw));
120 if p.is_absolute() {
121 Ok(p)
122 } else if self.roots.len() == 1 {
123 Ok(self.roots[0].join(p))
124 } else {
125 Err(Error::Invalid(format!(
126 "`{raw}` is relative but several local directories are allowed; use an absolute \
127 path"
128 )))
129 }
130 }
131
132 fn ensure_inside(&self, canon: &Path) -> Result<()> {
133 if self.roots.iter().any(|r| canon.starts_with(r)) {
134 Ok(())
135 } else {
136 Err(self.outside(canon))
137 }
138 }
139
140 fn outside(&self, path: &Path) -> Error {
141 Error::Denied(format!(
142 "`{}` is outside the allowed local directories ({})",
143 path.display(),
144 self.roots
145 .iter()
146 .map(|r| r.display().to_string())
147 .collect::<Vec<_>>()
148 .join(", ")
149 ))
150 }
151
152 pub fn open_file(&self, raw: &str) -> Result<(PathBuf, std::fs::File)> {
154 let canon = self.resolve_existing(raw)?;
155 let file = self.open_resolved(&canon)?;
156 Ok((canon, file))
157 }
158
159 pub fn open_resolved(&self, path: &Path) -> Result<std::fs::File> {
163 let (dir, name) = self.walk(path, false)?;
164 let Some(name) = name else {
165 return Err(Error::Invalid(format!(
166 "`{}` is a directory",
167 path.display()
168 )));
169 };
170 let fd = rustix::fs::openat(
172 &dir,
173 name,
174 OFlags::RDONLY | OFlags::NOFOLLOW | OFlags::CLOEXEC | OFlags::NONBLOCK,
175 Mode::empty(),
176 )
177 .map_err(|e| refused(&dir, name, path, e))?;
178 let st = rustix::fs::fstat(&fd).map_err(std::io::Error::from)?;
179 match FileType::from_raw_mode(st.st_mode) {
180 FileType::RegularFile => Ok(std::fs::File::from(fd)),
181 FileType::Directory => Err(Error::Invalid(format!(
182 "`{}` is a directory",
183 path.display()
184 ))),
185 _ => Err(Error::Invalid(format!(
186 "`{}` is not a regular file",
187 path.display()
188 ))),
189 }
190 }
191
192 pub fn list(&self, dir: &Path) -> Result<Vec<LocalEntry>> {
194 let (parent, name) = self.walk(dir, false)?;
195 let fd = match name {
196 None => parent,
197 Some(n) => open_dir_at(&parent, n).map_err(|e| refused(&parent, n, dir, e))?,
198 };
199 let mut out = Vec::new();
200 for entry in Dir::read_from(&fd).map_err(std::io::Error::from)? {
201 let entry = entry.map_err(std::io::Error::from)?;
202 let name = entry.file_name();
203 if matches!(name.to_bytes(), b"." | b"..") {
204 continue;
205 }
206 let Ok(st) = rustix::fs::statat(&fd, name, AtFlags::SYMLINK_NOFOLLOW) else {
208 continue;
209 };
210 out.push(LocalEntry {
211 name: OsStr::from_bytes(name.to_bytes())
212 .to_string_lossy()
213 .into_owned(),
214 is_dir: FileType::from_raw_mode(st.st_mode) == FileType::Directory,
215 size: st.st_size as u64,
216 });
217 }
218 out.sort_by(|a, b| a.name.cmp(&b.name));
219 Ok(out)
220 }
221
222 pub fn create_part(&self, dest: &Path) -> Result<PartFile> {
227 let (dir, name) = self.walk(dest, true)?;
228 let Some(name) = name else {
229 return Err(Error::Invalid(format!(
230 "`{}` is a directory",
231 dest.display()
232 )));
233 };
234 let part = OsString::from(format!(
235 ".{}.{:016x}.farhand-part",
236 name.to_string_lossy(),
237 crate::remote::random_u64()
238 ));
239 let fd = rustix::fs::openat(
240 &dir,
241 &part,
242 OFlags::WRONLY | OFlags::CREATE | OFlags::EXCL | OFlags::NOFOLLOW | OFlags::CLOEXEC,
243 Mode::from_raw_mode(0o644),
244 )
245 .map_err(|e| {
246 Error::Io(std::io::Error::new(
247 std::io::Error::from(e).kind(),
248 format!(
249 "cannot create a temporary file next to {}: {e}",
250 dest.display()
251 ),
252 ))
253 })?;
254 Ok(PartFile {
255 dir,
256 part,
257 name: name.to_owned(),
258 file: Arc::new(std::fs::File::from(fd)),
259 dest: dest.to_path_buf(),
260 committed: false,
261 })
262 }
263
264 fn walk<'p>(&self, path: &'p Path, create: bool) -> Result<(OwnedFd, Option<&'p OsStr>)> {
269 let found = |known: bool| {
272 self.roots.iter().enumerate().find_map(|(i, r)| {
273 (self.ids[i].is_some() == known).then_some(())?;
274 Some((i, path.strip_prefix(r).ok()?))
275 })
276 };
277 let Some((i, rel)) = found(true).or_else(|| found(false)) else {
278 return Err(self.outside(path));
279 };
280 let mut names = Vec::new();
281 for c in rel.components() {
282 match c {
283 Component::Normal(n) => names.push(n),
284 _ => {
285 return Err(Error::Invalid(format!(
286 "`{}` is not a resolved path",
287 path.display()
288 )))
289 }
290 }
291 }
292 let mut dir = self.open_root(i)?;
293 let Some((last, parents)) = names.split_last() else {
294 return Ok((dir, None));
295 };
296 for n in parents {
297 dir = match open_dir_at(&dir, n) {
298 Ok(d) => d,
299 Err(Errno::NOENT) if create => {
300 match rustix::fs::mkdirat(&dir, *n, Mode::from_raw_mode(0o755)) {
301 Ok(()) | Err(Errno::EXIST) => {}
302 Err(e) => return Err(Error::Io(e.into())),
303 }
304 open_dir_at(&dir, n).map_err(|e| refused(&dir, n, path, e))?
305 }
306 Err(e) => return Err(refused(&dir, n, path, e)),
307 };
308 }
309 Ok((dir, Some(last)))
310 }
311
312 fn open_root(&self, i: usize) -> Result<OwnedFd> {
313 let root = &self.roots[i];
314 let gone = || {
315 Error::Denied(format!(
316 "the allowed folder `{}` did not exist or was replaced after FarHand started; \
317 restart the agent to pick it up",
318 root.display()
319 ))
320 };
321 let expected = self.ids[i].ok_or_else(gone)?;
322 let fd = rustix::fs::open(
323 root,
324 OFlags::RDONLY | OFlags::DIRECTORY | OFlags::CLOEXEC,
325 Mode::empty(),
326 )
327 .map_err(|_| gone())?;
328 let st = rustix::fs::fstat(&fd).map_err(std::io::Error::from)?;
329 if identity(&st) != expected {
330 return Err(gone());
331 }
332 Ok(fd)
333 }
334}
335
336pub struct PartFile {
340 dir: OwnedFd,
341 part: OsString,
342 name: OsString,
343 file: Arc<std::fs::File>,
344 dest: PathBuf,
345 committed: bool,
346}
347
348impl PartFile {
349 pub fn file(&self) -> &Arc<std::fs::File> {
350 &self.file
351 }
352
353 pub fn dest(&self) -> &Path {
354 &self.dest
355 }
356
357 pub fn commit(mut self) -> Result<()> {
361 self.file.sync_all()?;
362 if rustix::fs::statat(&self.dir, &self.name, AtFlags::SYMLINK_NOFOLLOW)
363 .is_ok_and(|st| FileType::from_raw_mode(st.st_mode) == FileType::Directory)
364 {
365 return Err(Error::Invalid(format!(
366 "`{}` is a directory",
367 self.dest.display()
368 )));
369 }
370 rustix::fs::renameat(&self.dir, &self.part, &self.dir, &self.name)
371 .map_err(std::io::Error::from)?;
372 self.committed = true;
373 Ok(())
374 }
375}
376
377impl Drop for PartFile {
378 fn drop(&mut self) {
379 if !self.committed {
380 let _ = rustix::fs::unlinkat(&self.dir, &self.part, AtFlags::empty());
381 }
382 }
383}
384
385#[allow(clippy::unnecessary_cast)]
387fn identity(st: &rustix::fs::Stat) -> (u64, u64) {
388 (st.st_dev as u64, st.st_ino as u64)
389}
390
391fn open_dir_at(dir: &OwnedFd, name: &OsStr) -> std::result::Result<OwnedFd, Errno> {
392 rustix::fs::openat(
393 dir,
394 name,
395 OFlags::RDONLY | OFlags::DIRECTORY | OFlags::NOFOLLOW | OFlags::CLOEXEC,
396 Mode::empty(),
397 )
398}
399
400fn refused(dir: &OwnedFd, name: &OsStr, path: &Path, e: Errno) -> Error {
403 let is_link = rustix::fs::statat(dir, name, AtFlags::SYMLINK_NOFOLLOW)
404 .is_ok_and(|st| FileType::from_raw_mode(st.st_mode) == FileType::Symlink);
405 if is_link {
406 Error::Denied(format!(
407 "`{}` changed while it was being opened: `{}` is now a symlink",
408 path.display(),
409 name.to_string_lossy()
410 ))
411 } else {
412 let e = std::io::Error::from(e);
413 Error::Io(std::io::Error::new(
414 e.kind(),
415 format!("{}: {e}", path.display()),
416 ))
417 }
418}
419
420#[cfg(test)]
421mod tests {
422 use super::*;
423 use std::io::{Read, Write};
424 use std::os::unix::fs::symlink;
425
426 #[test]
427 fn refuses_outside_and_symlink_escape() {
428 let tmp = tempfile::tempdir().unwrap();
429 let root = tmp.path().join("allowed");
430 let outside = tmp.path().join("outside");
431 std::fs::create_dir_all(&root).unwrap();
432 std::fs::create_dir_all(&outside).unwrap();
433 std::fs::write(outside.join("secret.txt"), "x").unwrap();
434 std::fs::write(root.join("ok.txt"), "x").unwrap();
435 symlink(outside.join("secret.txt"), root.join("link.txt")).unwrap();
436
437 let scope = LocalScope::new(std::slice::from_ref(&root));
438 assert!(scope
439 .resolve_existing(root.join("ok.txt").to_str().unwrap())
440 .is_ok());
441 assert!(scope.resolve_existing("ok.txt").is_ok());
442 assert!(scope
443 .resolve_existing(outside.join("secret.txt").to_str().unwrap())
444 .is_err());
445 assert!(scope.resolve_existing("../outside/secret.txt").is_err());
446 assert!(scope.resolve_existing("link.txt").is_err());
447 assert!(scope.open_file("link.txt").is_err());
448 }
449
450 #[test]
451 fn write_target_may_not_exist_yet() {
452 let tmp = tempfile::tempdir().unwrap();
453 let root = tmp.path().join("allowed");
454 std::fs::create_dir_all(&root).unwrap();
455 let scope = LocalScope::new(std::slice::from_ref(&root));
456 let p = scope.resolve_for_write("new/dir/file.bin").unwrap();
457 assert!(p.starts_with(root.canonicalize().unwrap()));
458 assert!(scope.resolve_for_write("../x").is_err());
459 assert!(scope.resolve_for_write("/etc/passwd").is_err());
460 }
461
462 #[test]
463 fn empty_scope_denies_everything() {
464 let scope = LocalScope::new(&[]);
465 assert!(matches!(
466 scope.resolve_existing("/tmp"),
467 Err(Error::Denied(_))
468 ));
469 }
470
471 fn tree() -> (tempfile::TempDir, PathBuf, PathBuf, LocalScope) {
474 let tmp = tempfile::tempdir().unwrap();
475 let root = tmp.path().canonicalize().unwrap().join("allowed");
476 let outside = tmp.path().canonicalize().unwrap().join("outside");
477 std::fs::create_dir_all(root.join("sub")).unwrap();
478 std::fs::create_dir_all(&outside).unwrap();
479 std::fs::write(root.join("sub/file.txt"), "inside").unwrap();
480 std::fs::write(outside.join("file.txt"), "secret").unwrap();
481 let scope = LocalScope::new(std::slice::from_ref(&root));
482 (tmp, root, outside, scope)
483 }
484
485 #[test]
486 fn a_file_swapped_for_a_link_after_the_check_is_refused() {
487 let (_t, root, outside, scope) = tree();
488 let (path, mut f) = scope.open_file("sub/file.txt").unwrap();
489 let mut s = String::new();
490 f.read_to_string(&mut s).unwrap();
491 assert_eq!(s, "inside");
492
493 std::fs::remove_file(root.join("sub/file.txt")).unwrap();
495 symlink(outside.join("file.txt"), root.join("sub/file.txt")).unwrap();
496 let err = scope.open_resolved(&path).unwrap_err();
497 assert!(matches!(err, Error::Denied(_)), "{err}");
498 }
499
500 #[test]
501 fn a_folder_swapped_for_a_link_after_the_check_is_refused() {
502 let (_t, root, outside, scope) = tree();
503 let path = scope.resolve_existing("sub/file.txt").unwrap();
504 std::fs::rename(root.join("sub"), root.join("old")).unwrap();
505 symlink(&outside, root.join("sub")).unwrap();
506 let err = scope.open_resolved(&path).unwrap_err();
507 assert!(matches!(err, Error::Denied(_)), "{err}");
508 let err = scope.list(&root.join("sub")).unwrap_err();
509 assert!(matches!(err, Error::Denied(_)), "{err}");
510
511 let dest = root.join("sub/new.txt");
513 assert!(matches!(scope.create_part(&dest), Err(Error::Denied(_))));
514 assert_eq!(std::fs::read_dir(&outside).unwrap().count(), 1);
515 }
516
517 #[test]
518 fn a_replaced_root_is_refused() {
519 let (_t, root, _outside, scope) = tree();
520 std::fs::rename(&root, root.with_extension("old")).unwrap();
521 std::fs::create_dir_all(root.join("sub")).unwrap();
522 std::fs::write(root.join("sub/file.txt"), "new").unwrap();
523 let err = scope.open_file("sub/file.txt").unwrap_err();
524 assert!(matches!(err, Error::Denied(_)), "{err}");
525 }
526
527 #[test]
528 fn listing_does_not_follow_links() {
529 let (_t, root, outside, scope) = tree();
530 symlink(&outside, root.join("out")).unwrap();
531 let entries = scope.list(&root).unwrap();
532 let names: Vec<_> = entries.iter().map(|e| e.name.as_str()).collect();
533 assert_eq!(names, ["out", "sub"]);
534 assert!(!entries[0].is_dir);
535 assert!(scope.open_file("out").is_err());
536 assert!(scope.open_file("sub").is_err());
537 }
538
539 #[test]
540 fn downloads_land_in_place_and_replace_a_planted_link() {
541 let (_t, root, outside, scope) = tree();
542 let dest = scope.resolve_for_write("a/b/new.txt").unwrap();
544 let part = scope.create_part(&dest).unwrap();
545 (&**part.file()).write_all(b"data").unwrap();
546 part.commit().unwrap();
547 assert_eq!(std::fs::read(root.join("a/b/new.txt")).unwrap(), b"data");
548
549 symlink(outside.join("file.txt"), root.join("sub/link.txt")).unwrap();
551 let part = scope.create_part(&root.join("sub/link.txt")).unwrap();
552 (&**part.file()).write_all(b"new").unwrap();
553 part.commit().unwrap();
554 assert_eq!(std::fs::read(outside.join("file.txt")).unwrap(), b"secret");
555 assert!(!std::fs::symlink_metadata(root.join("sub/link.txt"))
556 .unwrap()
557 .file_type()
558 .is_symlink());
559
560 let part = scope.create_part(&root.join("sub/gone.txt")).unwrap();
562 drop(part);
563 let names: Vec<_> = std::fs::read_dir(root.join("sub"))
564 .unwrap()
565 .map(|e| e.unwrap().file_name())
566 .collect();
567 assert_eq!(names.len(), 2, "{names:?}");
568 }
569}