use std::fmt;
use std::path::{Component, Path, PathBuf};
use serde::de::{self, Visitor};
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use super::error::{Result, SafetyLockError};
use super::util::{decode_native_path, encode_native_path};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum RootSource {
Environment,
Git,
CurrentDirectory,
}
impl RootSource {
pub fn is_implicit(self) -> bool {
matches!(self, RootSource::Git | RootSource::CurrentDirectory)
}
pub fn label(self) -> &'static str {
match self {
RootSource::Environment => "DOTFILES_ROOT",
RootSource::Git => "git top-level",
RootSource::CurrentDirectory => "current directory",
}
}
}
impl fmt::Display for RootSource {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.label())
}
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct RootIdentity {
path: PathBuf,
}
impl RootIdentity {
pub fn new(path: impl Into<PathBuf>) -> Result<Self> {
let path = path.into();
if !path.is_absolute() {
return Err(SafetyLockError::RelativeRootIdentity {
spelling: encode_native_path(&path),
});
}
let mut canonical = PathBuf::new();
for component in path.components() {
if component == Component::ParentDir {
return Err(SafetyLockError::NonCanonicalRootIdentity {
spelling: encode_native_path(&path),
});
}
canonical.push(component.as_os_str());
}
Ok(Self { path: canonical })
}
pub fn parse(spelling: &str) -> Result<Self> {
Self::new(decode_native_path(spelling)?)
}
pub fn as_path(&self) -> &Path {
&self.path
}
pub fn spelling(&self) -> String {
encode_native_path(&self.path)
}
pub fn contains(&self, candidate: &Path) -> bool {
candidate.is_absolute()
&& !candidate
.components()
.any(|component| component == Component::ParentDir)
&& candidate.starts_with(&self.path)
}
}
impl fmt::Display for RootIdentity {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.spelling())
}
}
impl Serialize for RootIdentity {
fn serialize<S: Serializer>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error> {
serializer.serialize_str(&self.spelling())
}
}
impl<'de> Deserialize<'de> for RootIdentity {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> std::result::Result<Self, D::Error> {
struct SpellingVisitor;
impl Visitor<'_> for SpellingVisitor {
type Value = RootIdentity;
fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str("an absolute dotfiles-root path, or its `os-bytes:` spelling")
}
fn visit_str<E: de::Error>(self, value: &str) -> std::result::Result<Self::Value, E> {
RootIdentity::parse(value).map_err(E::custom)
}
}
deserializer.deserialize_str(SpellingVisitor)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct ResolvedRoot {
identity: RootIdentity,
source: RootSource,
}
impl ResolvedRoot {
pub fn new(identity: RootIdentity, source: RootSource) -> Self {
Self { identity, source }
}
pub fn identity(&self) -> &RootIdentity {
&self.identity
}
pub fn as_path(&self) -> &Path {
self.identity.as_path()
}
pub fn source(&self) -> RootSource {
self.source
}
pub fn requires_approval(&self) -> bool {
self.source.is_implicit()
}
pub fn into_identity(self) -> RootIdentity {
self.identity
}
}
#[cfg(test)]
mod tests {
use std::collections::HashSet;
use std::ffi::OsString;
use std::os::unix::ffi::OsStringExt;
use super::*;
fn identity(path: &str) -> RootIdentity {
RootIdentity::new(path).unwrap()
}
fn non_unicode_identity(suffix: &[u8]) -> RootIdentity {
let mut bytes = b"/tmp/".to_vec();
bytes.extend_from_slice(suffix);
RootIdentity::new(PathBuf::from(OsString::from_vec(bytes))).unwrap()
}
#[test]
fn relative_paths_are_not_identities() {
let err = RootIdentity::new("dotfiles").unwrap_err();
assert!(
matches!(err, SafetyLockError::RelativeRootIdentity { .. }),
"unexpected error: {err}"
);
}
#[test]
fn parent_dir_aliases_are_not_identities() {
for alias in ["/srv/dots/../other", "/srv/dots/..", "/../srv/dots"] {
let err = RootIdentity::new(alias).unwrap_err();
assert!(
matches!(err, SafetyLockError::NonCanonicalRootIdentity { .. }),
"`{alias}` was accepted as an identity: {err:?}"
);
}
assert!(RootIdentity::parse("/srv/dots/../other").is_err());
assert!(serde_json::from_str::<RootIdentity>("\"/srv/dots/../other\"").is_err());
}
#[test]
fn aliases_cannot_produce_a_second_identity_for_one_root() {
let root = identity("/srv/dots/other");
for alias in ["/srv//dots/other", "/srv/dots/other/", "/srv/./dots/other"] {
let from_alias = RootIdentity::new(alias).unwrap();
assert_eq!(from_alias, root, "`{alias}` became a second identity");
assert_eq!(from_alias.spelling(), root.spelling());
}
let set: HashSet<RootIdentity> =
["/srv//dots/other", "/srv/dots/other/", "/srv/dots/other"]
.into_iter()
.map(identity)
.collect();
assert_eq!(
set.len(),
1,
"spelling variants de-duplicated into one root"
);
}
#[test]
fn normalization_preserves_native_component_bytes() {
let original = non_unicode_identity(b"\x80dots");
let with_trailing_separator = {
let mut bytes = b"/tmp/".to_vec();
bytes.extend_from_slice(b"\x80dots/");
RootIdentity::new(PathBuf::from(OsString::from_vec(bytes))).unwrap()
};
assert_eq!(with_trailing_separator, original);
assert_eq!(original.spelling(), "os-bytes:2f746d702f80646f7473");
}
#[test]
fn identity_displays_its_reversible_spelling() {
assert_eq!(
identity("/home/alice/dotfiles").to_string(),
"/home/alice/dotfiles"
);
assert!(non_unicode_identity(b"\x80")
.to_string()
.starts_with("os-bytes:"));
}
#[test]
fn identity_parses_back_from_either_spelling() {
for original in [
identity("/home/alice/dotfiles"),
non_unicode_identity(b"\x80dots"),
] {
let parsed = RootIdentity::parse(&original.spelling()).unwrap();
assert_eq!(parsed, original);
}
}
#[test]
fn identities_never_collapse_on_a_lossy_rendering() {
let one = non_unicode_identity(b"\x80");
let other = non_unicode_identity(b"\x81");
assert_eq!(
one.as_path().to_string_lossy(),
other.as_path().to_string_lossy(),
"test premise: these two roots render identically when lossy"
);
assert_ne!(one, other);
let set: HashSet<RootIdentity> = [one, other].into_iter().collect();
assert_eq!(set.len(), 2);
}
#[test]
fn identity_containment_covers_the_root_itself_and_its_children() {
let root = identity("/home/alice/dotfiles");
assert!(root.contains(Path::new("/home/alice/dotfiles")));
assert!(root.contains(Path::new("/home/alice/dotfiles/vim/vimrc")));
assert!(!root.contains(Path::new("/home/alice/other/vimrc")));
assert!(!root.contains(Path::new("/home/alice/dotfiles-backup/vimrc")));
}
#[test]
fn containment_refuses_candidates_that_escape_through_parent_dirs() {
let root = identity("/home/alice/dotfiles");
assert!(
Path::new("/home/alice/dotfiles/../../../etc/passwd").starts_with(root.as_path()),
"test premise: a bare prefix test accepts this escape"
);
assert!(!root.contains(Path::new("/home/alice/dotfiles/../../../etc/passwd")));
assert!(!root.contains(Path::new("/home/alice/dotfiles/vim/../vimrc")));
assert!(!root.contains(Path::new("dotfiles/vim/vimrc")));
}
#[test]
fn only_implicit_sources_require_approval() {
let id = identity("/home/alice/dotfiles");
assert!(!ResolvedRoot::new(id.clone(), RootSource::Environment).requires_approval());
assert!(ResolvedRoot::new(id.clone(), RootSource::Git).requires_approval());
assert!(ResolvedRoot::new(id, RootSource::CurrentDirectory).requires_approval());
}
#[test]
fn source_labels_name_the_selecting_mechanism() {
assert_eq!(RootSource::Environment.to_string(), "DOTFILES_ROOT");
assert_eq!(RootSource::Git.to_string(), "git top-level");
assert_eq!(
RootSource::CurrentDirectory.to_string(),
"current directory"
);
}
#[test]
fn both_selection_paths_produce_one_resolved_root_type() {
let from_env = ResolvedRoot::new(identity("/srv/dots"), RootSource::Environment);
let from_git = ResolvedRoot::new(identity("/srv/dots"), RootSource::Git);
assert_eq!(from_env.identity(), from_git.identity());
assert_eq!(from_env.as_path(), Path::new("/srv/dots"));
assert_ne!(from_env, from_git);
assert_eq!(from_git.into_identity(), identity("/srv/dots"));
}
#[test]
fn identity_serializes_as_its_spelling() {
let plain = serde_json::to_string(&identity("/home/alice/dotfiles")).unwrap();
assert_eq!(plain, "\"/home/alice/dotfiles\"");
let tagged = serde_json::to_string(&non_unicode_identity(b"\x80")).unwrap();
assert_eq!(tagged, "\"os-bytes:2f746d702f80\"");
}
#[test]
fn identity_deserialization_rejects_relative_and_malformed_spellings() {
assert!(serde_json::from_str::<RootIdentity>("\"dotfiles\"").is_err());
assert!(serde_json::from_str::<RootIdentity>("\"os-bytes:2f7\"").is_err());
assert!(serde_json::from_str::<RootIdentity>("42").is_err());
}
}