use std::fs;
use std::path::{Path, PathBuf};
use openmoat_core::PathResolver;
use crate::context::path_string;
const MAX_LINK_HOPS: u8 = 64;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Resolution {
Itself,
Elsewhere(String),
Unresolvable,
}
#[derive(Debug)]
pub struct FsPathResolver;
impl FsPathResolver {
#[must_use]
pub fn resolution(path: &str) -> Resolution {
match real_path(Path::new(path), MAX_LINK_HOPS) {
None => Resolution::Unresolvable,
Some(real) => {
let real = path_string(&real);
if real == path {
Resolution::Itself
} else {
Resolution::Elsewhere(real)
}
}
}
}
}
impl PathResolver for FsPathResolver {
fn resolve(&self, path: &str) -> Option<String> {
match Self::resolution(path) {
Resolution::Elsewhere(real) => Some(real),
Resolution::Itself | Resolution::Unresolvable => None,
}
}
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);
assert_eq!(
FsPathResolver::resolution(&format!("{p}/a")),
Resolution::Unresolvable
);
assert_eq!(
FsPathResolver::resolution(&format!("{p}/src")),
Resolution::Itself
);
}
#[cfg(unix)]
fn chain(p: &str, n: usize, target: &str) -> String {
for i in 0..n {
let to = if i + 1 == n {
target.to_owned()
} else {
format!("{p}/l{}", i + 1)
};
symlink(to, format!("{p}/l{i}")).unwrap();
}
format!("{p}/l0")
}
#[cfg(unix)]
#[test]
fn long_dangling_chains_resolve_to_their_end() {
let (_dir, h, p) = setup();
let keys = format!("{h}/.ssh/authorized_keys");
let start = chain(&p, 40, &keys);
assert_eq!(FsPathResolver.resolve(&start), Some(keys.clone()));
assert_eq!(
FsPathResolver::resolution(&start),
Resolution::Elsewhere(keys)
);
}
#[cfg(unix)]
#[test]
fn the_default_policy_denies_a_write_through_a_long_chain() {
use openmoat_core::{Action, CompiledPolicy, EvalContext, NoResolver, Policy, Verdict};
let (_dir, h, p) = setup();
let start = chain(&p, 12, &format!("{h}/.ssh/authorized_keys"));
let policy = Policy::parse(openmoat_core::DEFAULT_POLICY).unwrap();
let ctx = EvalContext {
home: h.clone(),
project: Some(p.clone()),
real_home: None,
real_project: None,
moved_dirs: Vec::new(),
cwd: p.clone(),
case_insensitive_paths: false,
};
let compiled = CompiledPolicy::compile(&policy, &ctx).unwrap();
let write = Action::FsWrite { path: start };
let decision = compiled.decide_with(&write, &NoResolver, &FsPathResolver);
assert_eq!(decision.verdict, Verdict::Deny, "{decision:?}");
let plain = Action::FsWrite {
path: format!("{p}/src/lib.rs"),
};
let decision = compiled.decide_with(&plain, &NoResolver, &FsPathResolver);
assert_ne!(decision.verdict, Verdict::Deny, "{decision:?}");
}
#[cfg(unix)]
#[test]
fn a_chain_past_the_limit_is_unresolvable() {
let (_dir, h, p) = setup();
let start = chain(&p, usize::from(MAX_LINK_HOPS) + 2, &format!("{h}/.ssh/x"));
assert_eq!(FsPathResolver::resolution(&start), Resolution::Unresolvable);
assert_eq!(FsPathResolver.resolve(&start), None);
}
}