use std::path::{Component, Path, PathBuf};
use super::roots::RootIdentity;
use super::util::PathProbe;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OutOfRootReason {
OutsideRoot,
Missing,
Stale,
Uncanonicalizable,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct OutOfRootTarget {
pub source_path: PathBuf,
pub reason: OutOfRootReason,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ScopeOutcome {
InRoot(PathBuf),
OutOfRoot(OutOfRootTarget),
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct MutationScope {
outcomes: Vec<ScopeOutcome>,
}
impl MutationScope {
pub fn new(outcomes: Vec<ScopeOutcome>) -> Self {
Self { outcomes }
}
pub fn outcomes(&self) -> &[ScopeOutcome] {
&self.outcomes
}
pub fn in_root(&self) -> impl Iterator<Item = &Path> {
self.outcomes.iter().filter_map(|outcome| match outcome {
ScopeOutcome::InRoot(canonical) => Some(canonical.as_path()),
ScopeOutcome::OutOfRoot(_) => None,
})
}
pub fn out_of_root(&self) -> impl Iterator<Item = &OutOfRootTarget> {
self.outcomes.iter().filter_map(|outcome| match outcome {
ScopeOutcome::OutOfRoot(target) => Some(target),
ScopeOutcome::InRoot(_) => None,
})
}
pub fn has_out_of_root(&self) -> bool {
self.out_of_root().next().is_some()
}
}
pub fn scope_to_root(
root: &RootIdentity,
candidates: &[PathBuf],
probe: &dyn PathProbe,
) -> MutationScope {
MutationScope::new(
candidates
.iter()
.map(|candidate| classify(root, candidate, probe))
.collect(),
)
}
fn classify(root: &RootIdentity, candidate: &Path, probe: &dyn PathProbe) -> ScopeOutcome {
let excluded = |reason| {
ScopeOutcome::OutOfRoot(OutOfRootTarget {
source_path: candidate.to_path_buf(),
reason,
})
};
if !candidate.is_absolute() {
return excluded(OutOfRootReason::Stale);
}
let canonical = match probe.canonicalize(candidate) {
Ok(canonical) => canonical,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
return excluded(OutOfRootReason::Missing)
}
Err(_) => return excluded(OutOfRootReason::Uncanonicalizable),
};
if !is_placeable(&canonical) {
return excluded(OutOfRootReason::Uncanonicalizable);
}
if root.contains(&canonical) {
ScopeOutcome::InRoot(canonical)
} else {
excluded(OutOfRootReason::OutsideRoot)
}
}
fn is_placeable(canonical: &Path) -> bool {
canonical.is_absolute()
&& !canonical
.components()
.any(|component| component == Component::ParentDir)
}
#[cfg(test)]
mod tests {
use super::super::test_probe::{Entry, FakeProbe};
use super::*;
fn root() -> RootIdentity {
RootIdentity::new("/srv/dots").unwrap()
}
fn scope(candidates: &[&str], probe: &dyn PathProbe) -> MutationScope {
let candidates: Vec<PathBuf> = candidates.iter().map(PathBuf::from).collect();
scope_to_root(&root(), &candidates, probe)
}
fn reasons(scope: &MutationScope) -> Vec<OutOfRootReason> {
scope.out_of_root().map(|target| target.reason).collect()
}
fn in_root(scope: &MutationScope) -> Vec<PathBuf> {
scope.in_root().map(Path::to_path_buf).collect()
}
#[test]
fn an_empty_scope_authorizes_and_reports_nothing() {
let scope = MutationScope::default();
assert!(scope.in_root().next().is_none());
assert!(!scope.has_out_of_root());
}
#[test]
fn sources_inside_the_root_are_the_mutation_set() {
let probe = FakeProbe::default()
.add("/srv/dots/vim/vimrc", Entry::NotADirectory)
.add("/srv/dots", Entry::ReadableDir);
let scope = scope(&["/srv/dots/vim/vimrc", "/srv/dots"], &probe);
assert_eq!(
in_root(&scope),
vec![
PathBuf::from("/srv/dots/vim/vimrc"),
PathBuf::from("/srv/dots"),
]
);
assert!(!scope.has_out_of_root());
}
#[test]
fn another_roots_source_is_reported_not_written() {
let probe = FakeProbe::default()
.add("/srv/dots/vim/vimrc", Entry::NotADirectory)
.add("/other/dots/vim/vimrc", Entry::NotADirectory);
let scope = scope(&["/srv/dots/vim/vimrc", "/other/dots/vim/vimrc"], &probe);
assert_eq!(in_root(&scope), vec![PathBuf::from("/srv/dots/vim/vimrc")]);
assert_eq!(reasons(&scope), vec![OutOfRootReason::OutsideRoot]);
assert_eq!(
scope.out_of_root().next().unwrap().source_path,
PathBuf::from("/other/dots/vim/vimrc"),
"the report names the path the cache stored"
);
}
#[test]
fn a_symlink_out_of_the_root_escapes_the_mutation_set() {
let probe = FakeProbe::default().link("/srv/dots/vim/vimrc", "/other/dots/vim/vimrc");
let scope = scope(&["/srv/dots/vim/vimrc"], &probe);
assert!(in_root(&scope).is_empty());
assert_eq!(reasons(&scope), vec![OutOfRootReason::OutsideRoot]);
}
#[test]
fn an_in_root_symlink_is_authorized_at_its_resolved_path() {
let probe = FakeProbe::default().link("/srv/dots/vim/vimrc", "/srv/dots/shared/vimrc");
let scope = scope(&["/srv/dots/vim/vimrc"], &probe);
assert_eq!(
in_root(&scope),
vec![PathBuf::from("/srv/dots/shared/vimrc")]
);
}
#[test]
fn prefix_confusable_siblings_are_not_descendants() {
let probe = FakeProbe::default()
.add("/srv/dots-backup/vim/vimrc", Entry::NotADirectory)
.add("/srv/dotsomething", Entry::NotADirectory);
let scope = scope(&["/srv/dots-backup/vim/vimrc", "/srv/dotsomething"], &probe);
assert!(in_root(&scope).is_empty());
assert_eq!(
reasons(&scope),
vec![OutOfRootReason::OutsideRoot, OutOfRootReason::OutsideRoot]
);
}
#[test]
fn a_missing_source_is_reported_as_missing() {
let probe = FakeProbe::default();
let scope = scope(&["/srv/dots/vim/vimrc"], &probe);
assert_eq!(reasons(&scope), vec![OutOfRootReason::Missing]);
}
#[test]
fn a_baseline_with_no_source_path_is_stale_and_never_probed() {
let probe = FakeProbe::default();
let scope = scope(&[""], &probe);
assert_eq!(reasons(&scope), vec![OutOfRootReason::Stale]);
assert!(
probe.canonicalized().is_empty(),
"a path with no fixed meaning reached the filesystem probe"
);
}
#[test]
fn a_relative_source_path_is_stale_and_never_probed() {
let probe = FakeProbe::default();
let scope = scope(&["vim/vimrc"], &probe);
assert_eq!(reasons(&scope), vec![OutOfRootReason::Stale]);
assert!(probe.canonicalized().is_empty());
}
#[test]
fn a_source_that_cannot_be_resolved_is_reported_as_uncanonicalizable() {
let probe = FakeProbe::default().add(
"/srv/dots/vim/vimrc",
Entry::Unresolvable(std::io::ErrorKind::PermissionDenied),
);
let scope = scope(&["/srv/dots/vim/vimrc"], &probe);
assert_eq!(reasons(&scope), vec![OutOfRootReason::Uncanonicalizable]);
}
#[test]
fn a_resolution_that_is_not_placeable_is_refused() {
let probe = FakeProbe::default().link("/srv/dots/vim/vimrc", "/srv/dots/../etc/passwd");
let scope = scope(&["/srv/dots/vim/vimrc"], &probe);
assert!(in_root(&scope).is_empty());
assert_eq!(reasons(&scope), vec![OutOfRootReason::Uncanonicalizable]);
}
#[test]
fn outcomes_stay_aligned_with_the_candidates() {
let probe = FakeProbe::default()
.add("/srv/dots/a", Entry::NotADirectory)
.add("/other/dots/b", Entry::NotADirectory)
.add("/srv/dots/c", Entry::NotADirectory);
let scope = scope(
&["/srv/dots/a", "/other/dots/b", "/srv/dots/c", "/gone/d"],
&probe,
);
assert_eq!(
scope.outcomes(),
&[
ScopeOutcome::InRoot(PathBuf::from("/srv/dots/a")),
ScopeOutcome::OutOfRoot(OutOfRootTarget {
source_path: PathBuf::from("/other/dots/b"),
reason: OutOfRootReason::OutsideRoot,
}),
ScopeOutcome::InRoot(PathBuf::from("/srv/dots/c")),
ScopeOutcome::OutOfRoot(OutOfRootTarget {
source_path: PathBuf::from("/gone/d"),
reason: OutOfRootReason::Missing,
}),
]
);
assert!(scope.has_out_of_root());
}
#[test]
fn authorizing_either_root_never_reaches_the_other() {
let probe = FakeProbe::default()
.add("/srv/dots/vim/vimrc", Entry::NotADirectory)
.add("/other/dots/vim/vimrc", Entry::NotADirectory);
let cache = vec![
PathBuf::from("/srv/dots/vim/vimrc"),
PathBuf::from("/other/dots/vim/vimrc"),
];
let here = scope_to_root(&RootIdentity::new("/srv/dots").unwrap(), &cache, &probe);
let there = scope_to_root(&RootIdentity::new("/other/dots").unwrap(), &cache, &probe);
assert_eq!(in_root(&here), vec![PathBuf::from("/srv/dots/vim/vimrc")]);
assert_eq!(
in_root(&there),
vec![PathBuf::from("/other/dots/vim/vimrc")]
);
}
#[test]
fn the_os_probe_scopes_a_real_directory() {
use super::super::util::OsPathProbe;
let temp = tempfile::tempdir().unwrap();
let base = std::fs::canonicalize(temp.path()).unwrap();
let root = base.join("dots");
let other = base.join("other");
std::fs::create_dir_all(root.join("vim")).unwrap();
std::fs::create_dir_all(&other).unwrap();
std::fs::write(root.join("vim/vimrc"), b"set nocompatible").unwrap();
std::fs::write(other.join("vimrc"), b"elsewhere").unwrap();
std::os::unix::fs::symlink(other.join("vimrc"), root.join("vim/escape")).unwrap();
let candidates = vec![
root.join("vim/vimrc"),
root.join("vim/escape"),
other.join("vimrc"),
root.join("vim/gone"),
];
let scope = scope_to_root(
&RootIdentity::new(&root).unwrap(),
&candidates,
&OsPathProbe,
);
assert_eq!(in_root(&scope), vec![root.join("vim/vimrc")]);
assert_eq!(
reasons(&scope),
vec![
OutOfRootReason::OutsideRoot,
OutOfRootReason::OutsideRoot,
OutOfRootReason::Missing,
]
);
}
}