use std::ffi::{OsStr, OsString};
use std::os::unix::ffi::OsStrExt;
use std::path::{Path, PathBuf};
use super::error::{Result, SafetyLockError};
use super::roots::{ResolvedRoot, RootSource};
use super::util::{canonical_root_identity, encode_native_path, PathProbe, UnusableRoot};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct EnvironmentRootInput {
pub raw_value: Option<OsString>,
pub current_dir: PathBuf,
pub home_dir: PathBuf,
}
impl EnvironmentRootInput {
pub fn unset(current_dir: impl Into<PathBuf>, home_dir: impl Into<PathBuf>) -> Self {
Self {
raw_value: None,
current_dir: current_dir.into(),
home_dir: home_dir.into(),
}
}
pub fn set(
current_dir: impl Into<PathBuf>,
home_dir: impl Into<PathBuf>,
raw_value: impl Into<OsString>,
) -> Self {
Self {
raw_value: Some(raw_value.into()),
current_dir: current_dir.into(),
home_dir: home_dir.into(),
}
}
pub fn is_set(&self) -> bool {
self.raw_value.is_some()
}
}
pub fn resolve_environment_root(
input: &EnvironmentRootInput,
probe: &dyn PathProbe,
) -> Result<Option<ResolvedRoot>> {
let Some(raw_value) = input.raw_value.as_deref() else {
return Ok(None);
};
if raw_value.is_empty() {
return Err(SafetyLockError::EnvironmentRootEmpty);
}
let expanded = expand_leading_tilde(raw_value, &input.home_dir);
let candidate = if expanded.is_absolute() {
expanded
} else {
input.current_dir.join(expanded)
};
let unusable = |reason: String| SafetyLockError::EnvironmentRootUnusable {
spelling: encode_native_path(Path::new(raw_value)),
reason,
};
let named = if candidate.as_os_str() == raw_value {
"it".to_owned()
} else {
format!("`{}`", encode_native_path(&candidate))
};
let identity = canonical_root_identity(&candidate, probe).map_err(|failure| match failure {
UnusableRoot::Relative => unusable(format!(
"{named} is relative and the invocation directory `{}` is not \
absolute, so it cannot be anchored",
encode_native_path(&input.current_dir)
)),
UnusableRoot::Missing => unusable(format!("{named} does not exist")),
UnusableRoot::Unresolvable(err) => {
unusable(format!("{named} could not be resolved: {err}"))
}
UnusableRoot::NotADirectory => unusable(format!("{named} is not a directory")),
UnusableRoot::Unreadable => unusable(format!(
"{named} is a directory whose contents cannot be read"
)),
UnusableRoot::NotAnIdentity(err) => {
unusable(format!("{named} did not resolve to a usable root: {err}"))
}
})?;
Ok(Some(ResolvedRoot::new(identity, RootSource::Environment)))
}
fn expand_leading_tilde(raw_value: &OsStr, home_dir: &Path) -> PathBuf {
let bytes = raw_value.as_bytes();
if bytes == b"~" {
return home_dir.to_path_buf();
}
let Some(rest) = bytes.strip_prefix(b"~/") else {
return PathBuf::from(raw_value);
};
let rest = match rest.iter().position(|byte| *byte != b'/') {
Some(start) => &rest[start..],
None => return home_dir.to_path_buf(),
};
home_dir.join(Path::new(OsStr::from_bytes(rest)))
}
#[cfg(test)]
mod tests {
use std::collections::HashSet;
use std::io;
use std::os::unix::ffi::OsStringExt;
use std::sync::Mutex;
use super::super::roots::RootIdentity;
use super::super::test_probe::{Entry, FakeProbe};
use super::*;
const CWD: &str = "/home/alice/work";
const HOME: &str = "/home/alice";
fn resolve(raw_value: impl Into<OsString>, probe: &FakeProbe) -> Result<Option<ResolvedRoot>> {
resolve_environment_root(&EnvironmentRootInput::set(CWD, HOME, raw_value), probe)
}
fn unusable_reason(error: SafetyLockError) -> String {
match error {
SafetyLockError::EnvironmentRootUnusable { reason, .. } => reason,
other => panic!("expected an unusable-value error, got: {other}"),
}
}
#[test]
fn presence_is_independent_of_usability() {
assert!(!EnvironmentRootInput::unset(CWD, HOME).is_set());
assert!(EnvironmentRootInput::set(CWD, HOME, "").is_set());
assert!(EnvironmentRootInput::set(CWD, HOME, "/srv/dots").is_set());
}
#[test]
fn a_captured_value_keeps_its_native_bytes() {
let raw = OsString::from_vec(b"/tmp/\x80dots".to_vec());
let input = EnvironmentRootInput::set(CWD, HOME, raw.clone());
assert_eq!(input.raw_value.as_deref(), Some(raw.as_os_str()));
}
#[test]
fn an_unset_variable_leaves_selection_to_implicit_discovery() {
let probe = FakeProbe::default();
let resolved =
resolve_environment_root(&EnvironmentRootInput::unset(CWD, HOME), &probe).unwrap();
assert_eq!(resolved, None);
assert!(
probe.canonicalized().is_empty(),
"an unset variable touched the filesystem"
);
}
#[test]
fn an_absolute_value_selects_that_root_with_environment_provenance() {
let root = resolve("/srv/dots", &FakeProbe::dir("/srv/dots"))
.unwrap()
.expect("a valid value selects a root");
assert_eq!(root.as_path(), Path::new("/srv/dots"));
assert_eq!(root.source(), RootSource::Environment);
}
#[test]
fn a_value_is_canonicalized_once_into_the_identity() {
let probe = FakeProbe::default().link("/srv/link", "/srv/dots");
let root = resolve("/srv/link", &probe).unwrap().unwrap();
assert_eq!(root.identity(), &RootIdentity::new("/srv/dots").unwrap());
assert_eq!(probe.canonicalized(), vec![PathBuf::from("/srv/link")]);
}
#[test]
fn a_relative_value_resolves_against_the_injected_invocation_directory() {
let probe = FakeProbe::dir("/home/alice/work/dots");
let root = resolve("dots", &probe).unwrap().unwrap();
assert_eq!(root.as_path(), Path::new("/home/alice/work/dots"));
assert_eq!(
probe.canonicalized(),
vec![PathBuf::from("/home/alice/work/dots")],
"the probe saw a path anchored somewhere other than the injected cwd"
);
}
#[test]
fn a_leading_tilde_expands_against_the_injected_home_directory() {
for (value, expected) in [
("~", "/home/alice"),
("~/", "/home/alice"),
("~/dots", "/home/alice/dots"),
("~//dots", "/home/alice/dots"),
] {
let root = resolve(value, &FakeProbe::dir(expected)).unwrap().unwrap();
assert_eq!(
root.as_path(),
Path::new(expected),
"`{value}` expanded wrong"
);
}
}
#[test]
fn a_tilde_user_value_is_not_expanded() {
let probe = FakeProbe::dir("/home/alice/work/~bob/dots");
let root = resolve("~bob/dots", &probe).unwrap().unwrap();
assert_eq!(root.as_path(), Path::new("/home/alice/work/~bob/dots"));
}
#[test]
fn a_non_unicode_value_keeps_its_native_form() {
let raw = OsString::from_vec(b"/srv/\x80dots".to_vec());
let probe = FakeProbe::default().add(PathBuf::from(raw.clone()), Entry::ReadableDir);
let root = resolve(raw.clone(), &probe).unwrap().unwrap();
assert_eq!(root.as_path(), Path::new(&raw));
assert_eq!(root.identity().spelling(), "os-bytes:2f7372762f80646f7473");
assert_eq!(root.source(), RootSource::Environment);
}
#[test]
fn a_non_unicode_failure_is_named_reversibly() {
let raw = OsString::from_vec(b"/srv/\x80dots".to_vec());
let error = resolve(raw, &FakeProbe::default()).unwrap_err();
let SafetyLockError::EnvironmentRootUnusable { spelling, .. } = error else {
panic!("expected an unusable-value error");
};
assert_eq!(spelling, "os-bytes:2f7372762f80646f7473");
}
#[test]
fn an_empty_value_is_a_hard_error() {
let error = resolve("", &FakeProbe::default()).unwrap_err();
assert!(
matches!(error, SafetyLockError::EnvironmentRootEmpty),
"unexpected error: {error}"
);
}
#[test]
fn each_unusable_class_produces_its_own_diagnostic() {
let missing = unusable_reason(resolve("/srv/dots", &FakeProbe::default()).unwrap_err());
let not_a_dir = unusable_reason(
resolve(
"/srv/dots",
&FakeProbe::with("/srv/dots", Entry::NotADirectory),
)
.unwrap_err(),
);
let unreadable = unusable_reason(
resolve(
"/srv/dots",
&FakeProbe::with("/srv/dots", Entry::UnreadableDir),
)
.unwrap_err(),
);
let uncanonicalizable = unusable_reason(
resolve(
"/srv/dots",
&FakeProbe::with(
"/srv/dots",
Entry::Unresolvable(io::ErrorKind::PermissionDenied),
),
)
.unwrap_err(),
);
assert!(missing.contains("does not exist"), "{missing}");
assert!(not_a_dir.contains("is not a directory"), "{not_a_dir}");
assert!(unreadable.contains("cannot be read"), "{unreadable}");
assert!(
uncanonicalizable.contains("could not be resolved"),
"{uncanonicalizable}"
);
let distinct: HashSet<String> = [missing, not_a_dir, unreadable, uncanonicalizable]
.into_iter()
.collect();
assert_eq!(distinct.len(), 4, "two classes share one diagnostic");
}
#[test]
fn a_failing_expanded_value_names_the_path_it_expanded_to() {
let error = resolve("~/dots", &FakeProbe::default()).unwrap_err();
assert_eq!(
error.to_string(),
"DOTFILES_ROOT is set to `~/dots` but `/home/alice/dots` does not exist"
);
}
#[test]
fn a_relative_value_is_refused_when_the_invocation_directory_is_relative() {
let probe = FakeProbe::dir("work/dots");
let input = EnvironmentRootInput::set("work", HOME, "dots");
let reason = unusable_reason(resolve_environment_root(&input, &probe).unwrap_err());
assert!(reason.contains("cannot be anchored"), "{reason}");
assert!(
probe.canonicalized().is_empty(),
"a relative path reached the filesystem probe"
);
}
#[test]
fn no_failing_value_ever_reaches_implicit_discovery() {
struct Selection {
implicit_calls: Mutex<usize>,
}
impl Selection {
fn select(&self, input: &EnvironmentRootInput, probe: &dyn PathProbe) -> Result<()> {
if resolve_environment_root(input, probe)?.is_some() {
return Ok(());
}
*self.implicit_calls.lock().unwrap() += 1;
Ok(())
}
}
let failing: Vec<(&str, FakeProbe)> = vec![
("", FakeProbe::default()),
("/srv/missing", FakeProbe::default()),
(
"/srv/dots",
FakeProbe::with("/srv/dots", Entry::NotADirectory),
),
(
"/srv/dots",
FakeProbe::with("/srv/dots", Entry::UnreadableDir),
),
(
"/srv/dots",
FakeProbe::with(
"/srv/dots",
Entry::Unresolvable(io::ErrorKind::PermissionDenied),
),
),
("~/dots", FakeProbe::default()),
("dots", FakeProbe::default()),
];
let selection = Selection {
implicit_calls: Mutex::new(0),
};
for (value, probe) in &failing {
let input = EnvironmentRootInput::set(CWD, HOME, *value);
assert!(
selection.select(&input, probe).is_err(),
"`{value}` did not fail"
);
}
assert_eq!(
*selection.implicit_calls.lock().unwrap(),
0,
"a failing DOTFILES_ROOT fell through to implicit discovery"
);
selection
.select(
&EnvironmentRootInput::unset(CWD, HOME),
&FakeProbe::default(),
)
.unwrap();
assert_eq!(*selection.implicit_calls.lock().unwrap(), 1);
}
#[test]
fn an_environment_root_is_invocation_local_and_needs_no_approval() {
let root = resolve("/srv/dots", &FakeProbe::dir("/srv/dots"))
.unwrap()
.unwrap();
assert!(!root.requires_approval());
assert!(!root.source().is_implicit());
}
#[test]
fn a_real_directory_resolves_through_the_os_probe() {
use super::super::util::OsPathProbe;
let home = tempfile::tempdir().unwrap();
let root = home.path().join("dotfiles");
std::fs::create_dir(&root).unwrap();
let canonical_root = std::fs::canonicalize(&root).unwrap();
let input = EnvironmentRootInput::set(home.path(), home.path(), "~/dotfiles");
let resolved = resolve_environment_root(&input, &OsPathProbe)
.unwrap()
.unwrap();
assert_eq!(resolved.as_path(), canonical_root);
assert_eq!(resolved.source(), RootSource::Environment);
let file = home.path().join("not-a-root");
std::fs::write(&file, b"").unwrap();
let input = EnvironmentRootInput::set(home.path(), home.path(), file);
let reason = unusable_reason(resolve_environment_root(&input, &OsPathProbe).unwrap_err());
assert!(reason.contains("is not a directory"), "{reason}");
}
}