use super::error::{Result, SafetyLockError};
use super::roots::{ResolvedRoot, RootIdentity};
use super::schema::SafetyLockConfig;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TrustDecision {
ExplicitlySelected,
AlreadyApproved,
ApprovalRequired,
}
impl TrustDecision {
pub fn permits_mutation(self) -> bool {
matches!(
self,
TrustDecision::ExplicitlySelected | TrustDecision::AlreadyApproved
)
}
pub fn needs_confirmation(self) -> bool {
matches!(self, TrustDecision::ApprovalRequired)
}
}
#[derive(Debug, Clone)]
pub struct ApprovalChange {
pub config: SafetyLockConfig,
pub identity: RootIdentity,
pub added: bool,
}
pub fn decide(root: &ResolvedRoot, config: &SafetyLockConfig) -> Result<TrustDecision> {
if !root.requires_approval() {
return Ok(TrustDecision::ExplicitlySelected);
}
config.validate()?;
Ok(if config.is_approved(root.identity()) {
TrustDecision::AlreadyApproved
} else {
TrustDecision::ApprovalRequired
})
}
pub fn approve(config: &SafetyLockConfig, root: &ResolvedRoot) -> Result<ApprovalChange> {
if !root.requires_approval() {
return Err(SafetyLockError::EnvironmentRootNotApprovable {
spelling: root.identity().spelling(),
});
}
config.validate()?;
let identity = root.identity().clone();
let mut config = config.clone();
let added = !config.is_approved(&identity);
if added {
config.roots.approved.push(identity.clone());
}
Ok(ApprovalChange {
config,
identity,
added,
})
}
#[cfg(test)]
mod tests {
use std::ffi::OsString;
use std::os::unix::ffi::OsStringExt;
use std::path::PathBuf;
use super::super::roots::RootSource;
use super::super::schema::TrustedRootsSection;
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()
}
fn approved(roots: impl IntoIterator<Item = RootIdentity>) -> SafetyLockConfig {
SafetyLockConfig {
roots: TrustedRootsSection {
approved: roots.into_iter().collect(),
},
}
}
fn implicit(identity: RootIdentity) -> ResolvedRoot {
ResolvedRoot::new(identity, RootSource::Git)
}
fn explicit(identity: RootIdentity) -> ResolvedRoot {
ResolvedRoot::new(identity, RootSource::Environment)
}
#[test]
fn an_implicit_root_is_approved_only_when_the_collection_holds_it() {
let root = identity("/home/alice/dotfiles");
assert_eq!(
decide(&implicit(root.clone()), &SafetyLockConfig::default()).unwrap(),
TrustDecision::ApprovalRequired
);
assert_eq!(
decide(&implicit(root.clone()), &approved([root.clone()])).unwrap(),
TrustDecision::AlreadyApproved
);
let from_cwd = ResolvedRoot::new(root.clone(), RootSource::CurrentDirectory);
assert_eq!(
decide(&from_cwd, &approved([root])).unwrap(),
TrustDecision::AlreadyApproved
);
}
#[test]
fn an_environment_root_is_deliberate_without_a_trust_record() {
let root = identity("/home/alice/dotfiles");
let config = SafetyLockConfig::default();
assert_eq!(
decide(&explicit(root.clone()), &config).unwrap(),
TrustDecision::ExplicitlySelected
);
let refusal = approve(&config, &explicit(root.clone())).unwrap_err();
assert!(
matches!(
refusal,
SafetyLockError::EnvironmentRootNotApprovable { ref spelling }
if spelling == "/home/alice/dotfiles"
),
"unexpected error: {refusal}"
);
assert!(
config.roots.approved.is_empty(),
"the environment root reached the collection"
);
let listed = approved([root.clone()]);
assert_eq!(
decide(&explicit(root.clone()), &listed).unwrap(),
TrustDecision::ExplicitlySelected
);
assert!(approve(&listed, &explicit(root)).is_err());
}
#[test]
fn independent_roots_are_approved_without_arbitration() {
let personal = identity("/home/alice/dotfiles");
let work = identity("/home/alice/work-dotfiles");
let change = approve(&SafetyLockConfig::default(), &implicit(personal.clone())).unwrap();
assert!(change.added);
let change = approve(&change.config, &implicit(work.clone())).unwrap();
assert!(change.added);
assert_eq!(
change.config.roots.approved,
vec![personal.clone(), work.clone()]
);
assert_eq!(
decide(&implicit(personal), &change.config).unwrap(),
TrustDecision::AlreadyApproved
);
assert_eq!(
decide(&implicit(work), &change.config).unwrap(),
TrustDecision::AlreadyApproved
);
assert!(change.config.validate().is_ok());
}
#[test]
fn approving_an_approved_root_changes_nothing() {
let root = identity("/home/alice/dotfiles");
let first = approve(&SafetyLockConfig::default(), &implicit(root.clone())).unwrap();
let second = approve(&first.config, &implicit(root.clone())).unwrap();
assert!(!second.added, "a repeated approval reported a change");
assert_eq!(second.identity, root);
assert_eq!(second.config.roots.approved, vec![root]);
assert!(second.config.validate().is_ok());
}
#[test]
fn an_alias_of_an_approved_root_is_already_approved() {
let config = approved([identity("/home/alice/dotfiles")]);
for alias in [
"/home/alice//dotfiles",
"/home/alice/dotfiles/",
"/home/alice/./dotfiles",
] {
let root = implicit(identity(alias));
assert_eq!(
decide(&root, &config).unwrap(),
TrustDecision::AlreadyApproved,
"`{alias}` was not recognized as the approved root"
);
assert!(
!approve(&config, &root).unwrap().added,
"`{alias}` was recorded as a second approval"
);
}
}
#[test]
fn a_moved_root_does_not_inherit_the_old_locations_approval() {
let config = approved([identity("/home/alice/dotfiles")]);
let moved = implicit(identity("/home/alice/archive/dotfiles"));
assert_eq!(
decide(&moved, &config).unwrap(),
TrustDecision::ApprovalRequired
);
for neighbour in ["/home/alice", "/home/alice/dotfiles/vim"] {
assert_eq!(
decide(&implicit(identity(neighbour)), &config).unwrap(),
TrustDecision::ApprovalRequired,
"`{neighbour}` inherited the approved root's standing"
);
}
}
#[test]
fn lossy_colliding_roots_are_approved_independently() {
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"
);
let change = approve(&SafetyLockConfig::default(), &implicit(one.clone())).unwrap();
assert_eq!(
decide(&implicit(one), &change.config).unwrap(),
TrustDecision::AlreadyApproved
);
assert_eq!(
decide(&implicit(other.clone()), &change.config).unwrap(),
TrustDecision::ApprovalRequired
);
let change = approve(&change.config, &implicit(other)).unwrap();
assert!(change.added);
assert_eq!(change.config.roots.approved.len(), 2);
assert!(change.config.validate().is_ok());
}
#[test]
fn unusable_trust_state_is_reported_rather_than_read_as_empty() {
let root = identity("/home/alice/dotfiles");
let duplicated = approved([root.clone(), root.clone()]);
for error in [
decide(&implicit(root.clone()), &duplicated).unwrap_err(),
approve(&duplicated, &implicit(root.clone())).unwrap_err(),
] {
assert!(
matches!(error, SafetyLockError::DuplicateApprovedRoot { ref spelling }
if spelling == "/home/alice/dotfiles"),
"unexpected error: {error}"
);
}
assert_eq!(
decide(&explicit(root.clone()), &duplicated).unwrap(),
TrustDecision::ExplicitlySelected,
"an environment root's standing was made to depend on the collection"
);
assert!(
matches!(
approve(&duplicated, &explicit(root)).unwrap_err(),
SafetyLockError::EnvironmentRootNotApprovable { .. }
),
"the collection was consulted before refusing an environment root"
);
}
#[test]
fn the_lifecycle_produces_collections_that_load_back_unchanged() {
use super::super::forget::{forget_root, ForgetRequest};
use super::super::list::list_roots;
use super::super::test_probe::FakeProbe;
let data_dir = tempfile::tempdir().unwrap();
let persist = |config: &SafetyLockConfig| {
std::fs::write(
SafetyLockConfig::path_in(data_dir.path()),
toml::to_string(config).unwrap(),
)
.unwrap();
SafetyLockConfig::load_from(data_dir.path()).unwrap()
};
let personal = identity("/home/alice/dotfiles");
let native = non_unicode_identity(b"\x80dots");
let mut config = SafetyLockConfig::default();
for root in [&personal, &native] {
config = persist(&approve(&config, &implicit(root.clone())).unwrap().config);
}
config = persist(
&approve(&config, &implicit(personal.clone()))
.unwrap()
.config,
);
assert_eq!(
config.roots.approved,
vec![personal.clone(), native.clone()]
);
let listing = list_roots(&config, &FakeProbe::default()).unwrap();
let spelling = listing.entries[1].spelling();
let config = persist(
&forget_root(
&config,
&ForgetRequest::new(spelling),
&FakeProbe::default(),
)
.unwrap()
.config,
);
assert_eq!(config.roots.approved, vec![personal.clone()]);
assert_eq!(
decide(&implicit(native), &config).unwrap(),
TrustDecision::ApprovalRequired,
"the forgotten root survived the round trip as approved"
);
assert_eq!(
decide(&implicit(personal), &config).unwrap(),
TrustDecision::AlreadyApproved
);
}
#[test]
fn only_an_unapproved_implicit_root_needs_confirmation() {
assert!(TrustDecision::ExplicitlySelected.permits_mutation());
assert!(TrustDecision::AlreadyApproved.permits_mutation());
assert!(!TrustDecision::ApprovalRequired.permits_mutation());
assert!(TrustDecision::ApprovalRequired.needs_confirmation());
assert!(!TrustDecision::AlreadyApproved.needs_confirmation());
assert!(!TrustDecision::ExplicitlySelected.needs_confirmation());
}
}