use std::fs;
use std::path::{Path, PathBuf};
use openmoat_core::PathResolver;
use crate::context::path_string;
const MAX_LINK_HOPS: u8 = 8;
#[derive(Debug)]
pub struct FsPathResolver;
impl PathResolver for FsPathResolver {
fn resolve(&self, path: &str) -> Option<String> {
let real = path_string(&real_path(Path::new(path), MAX_LINK_HOPS)?);
(real != path).then_some(real)
}
fn read_dir(&self, dir: &str) -> Vec<String> {
fs::read_dir(dir).map_or_else(
|_| Vec::new(),
|entries| {
entries
.flatten()
.map(|e| e.file_name().to_string_lossy().into_owned())
.collect()
},
)
}
}
fn real_path(path: &Path, hops: u8) -> Option<PathBuf> {
if path.parent().is_none() {
return Some(path.to_path_buf());
}
if let Ok(real) = fs::canonicalize(path) {
return Some(real);
}
let parent = path.parent()?;
if let Ok(target) = fs::read_link(path) {
return real_path(&parent.join(target), hops.checked_sub(1)?);
}
Some(real_path(parent, hops)?.join(path.file_name()?))
}
#[cfg(test)]
mod tests {
#[cfg(unix)]
use std::os::unix::fs::symlink;
use super::*;
#[test]
fn missing_paths_keep_their_root() {
assert_eq!(
FsPathResolver.resolve("/moat-missing-root/src/lib.rs"),
None
);
}
#[cfg(unix)]
fn setup() -> (tempfile::TempDir, String, String) {
let dir = tempfile::tempdir().unwrap();
let home = fs::canonicalize(dir.path()).unwrap().join("home");
let project = home.join("proj");
fs::create_dir_all(home.join(".ssh")).unwrap();
fs::create_dir_all(project.join("src")).unwrap();
fs::write(home.join(".ssh/id_rsa"), "key").unwrap();
(dir, path_string(&home), path_string(&project))
}
#[cfg(unix)]
#[test]
fn plain_paths_inside_the_project_resolve_to_themselves() {
let (_dir, _, p) = setup();
let r = FsPathResolver;
assert_eq!(r.resolve(&format!("{p}/src")), None);
assert_eq!(r.resolve(&format!("{p}/src/new/file.rs")), None);
}
#[cfg(unix)]
#[test]
fn linked_directories_and_files_resolve_to_their_target() {
let (_dir, h, p) = setup();
let r = FsPathResolver;
symlink(format!("{h}/.ssh"), format!("{p}/s")).unwrap();
assert_eq!(
r.resolve(&format!("{p}/s/id_rsa")),
Some(format!("{h}/.ssh/id_rsa"))
);
assert_eq!(
r.resolve(&format!("{p}/s/authorized_keys")),
Some(format!("{h}/.ssh/authorized_keys")),
"a file that does not exist yet resolves through its directory"
);
symlink("../.ssh/known_hosts", format!("{p}/k")).unwrap();
assert_eq!(
r.resolve(&format!("{p}/k")),
Some(format!("{h}/.ssh/known_hosts")),
"a dangling relative link resolves through its target"
);
}
#[cfg(unix)]
#[test]
fn directories_list_their_names_through_links() {
let (_dir, h, p) = setup();
symlink(format!("{h}/.ssh"), format!("{p}/s")).unwrap();
let mut names = FsPathResolver.read_dir(&p);
names.sort();
assert_eq!(names, ["s", "src"]);
assert_eq!(FsPathResolver.read_dir(&format!("{p}/s")), ["id_rsa"]);
assert!(
FsPathResolver
.read_dir(&format!("{h}/.ssh/id_rsa"))
.is_empty()
);
assert!(FsPathResolver.read_dir(&format!("{p}/missing")).is_empty());
}
#[cfg(unix)]
#[test]
fn link_loops_give_up() {
let (_dir, _, p) = setup();
let r = FsPathResolver;
symlink(format!("{p}/b"), format!("{p}/a")).unwrap();
symlink(format!("{p}/a"), format!("{p}/b")).unwrap();
assert_eq!(r.resolve(&format!("{p}/a")), None);
}
}