use std::ffi::{OsStr, OsString};
use std::path::PathBuf;
use super::error::{Result, SafetyLockError};
use super::roots::RootIdentity;
use super::schema::SafetyLockConfig;
use super::util::{decode_native_path, encode_native_path, PathProbe};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ForgetRequest {
pub argument: OsString,
}
impl ForgetRequest {
pub fn new(argument: impl Into<OsString>) -> Self {
Self {
argument: argument.into(),
}
}
}
#[derive(Debug, Clone)]
pub struct ForgetChange {
pub config: SafetyLockConfig,
pub removed: Option<RootIdentity>,
}
impl ForgetChange {
pub fn changed(&self) -> bool {
self.removed.is_some()
}
}
pub fn forget_root(
config: &SafetyLockConfig,
request: &ForgetRequest,
probe: &dyn PathProbe,
) -> Result<ForgetChange> {
let candidate = requested_path(&request.argument)?;
if !candidate.is_absolute() {
return Err(SafetyLockError::RelativeRootIdentity {
spelling: encode_native_path(&candidate),
});
}
let resolved = match probe.canonicalize(&candidate) {
Ok(canonical) => Some(RootIdentity::new(canonical)?),
Err(_) => None,
};
let target = match resolved {
Some(identity) if config.is_approved(&identity) => identity,
_ => RootIdentity::new(candidate)?,
};
let mut config = config.clone();
let removed = if config.is_approved(&target) {
config.roots.approved.retain(|held| *held != target);
Some(target)
} else {
None
};
config.validate()?;
Ok(ForgetChange { config, removed })
}
fn requested_path(argument: &OsStr) -> Result<PathBuf> {
match argument.to_str() {
Some(text) => decode_native_path(text),
None => Ok(PathBuf::from(argument)),
}
}
#[cfg(test)]
mod tests {
use std::os::unix::ffi::OsStringExt;
use super::super::schema::TrustedRootsSection;
use super::super::test_probe::{Entry, FakeProbe};
use super::super::util::OsPathProbe;
use super::*;
fn identity(path: &str) -> RootIdentity {
RootIdentity::new(path).unwrap()
}
fn non_unicode_path(suffix: &[u8]) -> PathBuf {
let mut bytes = b"/tmp/".to_vec();
bytes.extend_from_slice(suffix);
PathBuf::from(OsString::from_vec(bytes))
}
fn non_unicode_identity(suffix: &[u8]) -> RootIdentity {
RootIdentity::new(non_unicode_path(suffix)).unwrap()
}
fn approved(roots: impl IntoIterator<Item = RootIdentity>) -> SafetyLockConfig {
SafetyLockConfig {
roots: TrustedRootsSection {
approved: roots.into_iter().collect(),
},
}
}
fn forget(
config: &SafetyLockConfig,
argument: impl Into<OsString>,
probe: &dyn PathProbe,
) -> Result<ForgetChange> {
forget_root(config, &ForgetRequest::new(argument), probe)
}
#[test]
fn an_alias_of_a_live_root_selects_its_approval() {
let config = approved([identity("/srv/dots"), identity("/srv/other")]);
for (argument, probe) in [
("/srv/dots", FakeProbe::dir("/srv/dots")),
("/srv/dots/", FakeProbe::dir("/srv/dots")),
("/srv//dots", FakeProbe::dir("/srv/dots")),
("/srv/./dots", FakeProbe::dir("/srv/dots")),
(
"/srv/link",
FakeProbe::default().link("/srv/link", "/srv/dots"),
),
] {
let change = forget(&config, argument, &probe).unwrap();
assert!(change.changed(), "`{argument}` matched no approval");
assert_eq!(change.removed.unwrap(), identity("/srv/dots"));
assert_eq!(
change.config.roots.approved,
vec![identity("/srv/other")],
"`{argument}` disturbed an unrelated approval"
);
assert!(change.config.validate().is_ok());
}
}
#[test]
fn a_deleted_root_is_forgotten_by_its_exact_stored_spelling() {
let gone = identity("/srv/deleted");
let gone_non_unicode = non_unicode_identity(b"\x80dots");
let config = approved([gone.clone(), gone_non_unicode.clone()]);
let probe = FakeProbe::default();
for expected in [gone, gone_non_unicode.clone()] {
let change = forget(&config, expected.spelling(), &probe).unwrap();
assert_eq!(change.removed.as_ref(), Some(&expected));
assert_eq!(change.config.roots.approved.len(), 1);
}
let change = forget(&config, non_unicode_path(b"\x80dots"), &probe).unwrap();
assert_eq!(change.removed, Some(gone_non_unicode));
}
#[test]
fn moving_a_root_leaves_the_old_approval_revocable() {
let old = identity("/srv/dots");
let new = identity("/srv/moved/dots");
let config = approved([old.clone(), new.clone()]);
let probe = FakeProbe::dir("/srv/moved/dots");
let change = forget(&config, old.spelling(), &probe).unwrap();
assert_eq!(change.removed, Some(old));
assert_eq!(change.config.roots.approved, vec![new]);
}
#[test]
fn a_root_replaced_at_its_own_spelling_is_still_revocable() {
let replaced = identity("/srv/dots");
let config = approved([replaced.clone()]);
let probe = FakeProbe::default().link("/srv/dots", "/srv/elsewhere");
let change = forget(&config, "/srv/dots", &probe).unwrap();
assert_eq!(change.removed, Some(replaced));
assert!(change.config.roots.approved.is_empty());
}
#[test]
fn the_canonical_rule_is_tried_before_the_stored_spelling() {
let config = approved([identity("/srv/dots"), identity("/srv/elsewhere")]);
let probe = FakeProbe::default().link("/srv/dots", "/srv/elsewhere");
let change = forget(&config, "/srv/dots", &probe).unwrap();
assert_eq!(change.removed, Some(identity("/srv/elsewhere")));
assert_eq!(change.config.roots.approved, vec![identity("/srv/dots")]);
}
#[test]
fn forgetting_one_lossy_colliding_root_leaves_the_other() {
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 = forget(
&approved([one.clone(), other.clone()]),
one.spelling(),
&FakeProbe::default(),
)
.unwrap();
assert_eq!(change.removed, Some(one));
assert_eq!(change.config.roots.approved, vec![other]);
}
#[test]
fn an_argument_that_matches_nothing_is_reported_not_failed() {
let config = approved([identity("/srv/dots")]);
let probe = FakeProbe::dir("/srv/unapproved");
let change = forget(&config, "/srv/unapproved", &probe).unwrap();
assert!(!change.changed());
assert_eq!(change.config.roots.approved, config.roots.approved);
let removed = forget(&config, "/srv/dots", &FakeProbe::default()).unwrap();
let again = forget(&removed.config, "/srv/dots", &FakeProbe::default()).unwrap();
assert!(!again.changed());
}
#[test]
fn an_argument_that_cannot_name_a_root_says_so() {
let config = approved([identity("/srv/dots")]);
let probe = FakeProbe::dir("/srv/dots");
let error = forget(&config, "dots", &probe).unwrap_err();
assert!(
matches!(error, SafetyLockError::RelativeRootIdentity { ref spelling } if spelling == "dots"),
"unexpected error: {error}"
);
assert!(
probe.canonicalized().is_empty(),
"a relative argument reached the filesystem probe"
);
let error = forget(&config, "/srv/dots/../other", &probe).unwrap_err();
assert!(
matches!(error, SafetyLockError::NonCanonicalRootIdentity { .. }),
"unexpected error: {error}"
);
let error = forget(&config, "os-bytes:2f7", &probe).unwrap_err();
assert!(
matches!(error, SafetyLockError::UnreadableSpelling { .. }),
"unexpected error: {error}"
);
}
#[test]
fn a_duplicated_approval_is_repairable_by_forgetting_its_root() {
let duplicated = identity("/srv/dots");
let intact = identity("/srv/other");
let config = approved([duplicated.clone(), intact.clone(), duplicated.clone()]);
assert!(
config.validate().is_err(),
"test premise: this collection is unusable as given"
);
let change = forget(&config, "/srv/dots", &FakeProbe::default()).unwrap();
assert_eq!(change.removed, Some(duplicated));
assert_eq!(
change.config.roots.approved,
vec![intact],
"every copy of the duplicated approval must go, not just the first"
);
assert!(change.config.validate().is_ok());
}
#[test]
fn a_duplicated_trust_file_is_repaired_through_the_production_routes() {
let data_dir = tempfile::tempdir().unwrap();
let path = SafetyLockConfig::path_in(data_dir.path());
let duplicated = identity("/srv/dots");
let intact = identity("/srv/other");
std::fs::write(
&path,
"[roots]\napproved = [\"/srv/dots\", \"/srv/other\", \"/srv/dots\"]\n",
)
.unwrap();
assert!(
SafetyLockConfig::load_from(data_dir.path()).is_err(),
"the validated load accepted a duplicated trust file"
);
let config = SafetyLockConfig::load_for_revocation(data_dir.path()).unwrap();
assert_eq!(
config.roots.approved,
vec![duplicated.clone(), intact.clone(), duplicated.clone()],
"the revocation route did not read the file as written"
);
let change = forget(&config, duplicated.spelling(), &FakeProbe::default()).unwrap();
assert_eq!(change.removed, Some(duplicated));
std::fs::write(&path, toml::to_string(&change.config).unwrap()).unwrap();
let reloaded = SafetyLockConfig::load_from(data_dir.path()).unwrap();
assert_eq!(
reloaded.roots.approved,
vec![intact],
"the repaired file did not keep the untouched approval"
);
}
#[test]
fn an_unusable_collection_the_argument_does_not_repair_is_reported() {
let duplicated = identity("/srv/dots");
let config = approved([duplicated.clone(), duplicated]);
for argument in ["/srv/unapproved", "/srv/other"] {
let error = forget(&config, argument, &FakeProbe::default()).unwrap_err();
assert!(
matches!(error, SafetyLockError::DuplicateApprovedRoot { .. }),
"`{argument}`: unexpected error: {error}"
);
}
}
#[test]
fn an_unreadable_but_present_root_still_canonicalizes() {
let config = approved([identity("/srv/dots")]);
let probe = FakeProbe::default()
.link("/srv/link", "/srv/dots")
.add("/srv/dots", Entry::UnreadableDir);
let change = forget(&config, "/srv/link", &probe).unwrap();
assert_eq!(change.removed, Some(identity("/srv/dots")));
}
#[test]
fn aliases_resolve_against_a_real_filesystem() {
let home = tempfile::tempdir().unwrap();
let home = std::fs::canonicalize(home.path()).unwrap();
let root = home.join("dotfiles");
std::fs::create_dir(&root).unwrap();
let link = home.join("link-to-dotfiles");
std::os::unix::fs::symlink(&root, &link).unwrap();
let config = approved([RootIdentity::new(&root).unwrap()]);
for argument in [link.clone(), root.join("")] {
let change = forget(&config, argument.clone(), &OsPathProbe).unwrap();
assert_eq!(
change.removed.as_ref().map(RootIdentity::as_path),
Some(root.as_path()),
"`{}` did not select the approval at the canonical path",
argument.display()
);
}
std::fs::remove_dir(&root).unwrap();
let change = forget(&config, root.as_os_str(), &OsPathProbe).unwrap();
assert_eq!(
change.removed.map(|identity| identity.as_path().to_owned()),
Some(root)
);
}
#[test]
fn a_resolvable_parent_alias_revokes_what_it_resolves_to() {
let home = tempfile::tempdir().unwrap();
let home = std::fs::canonicalize(home.path()).unwrap();
let root = home.join("dotfiles");
let sibling = home.join("elsewhere");
std::fs::create_dir(&root).unwrap();
std::fs::create_dir(&sibling).unwrap();
let config = approved([RootIdentity::new(&root).unwrap()]);
let alias = sibling.join("..").join("dotfiles");
let change = forget(&config, alias.clone(), &OsPathProbe).unwrap();
assert_eq!(
change.removed.as_ref().map(RootIdentity::as_path),
Some(root.as_path()),
"`{}` did not select the approval it resolves to",
alias.display()
);
let unapproved = root.join("..").join("elsewhere");
let error = forget(&config, unapproved, &OsPathProbe).unwrap_err();
assert!(
matches!(error, SafetyLockError::NonCanonicalRootIdentity { .. }),
"unexpected error: {error}"
);
}
#[test]
fn a_request_keeps_the_argument_bytes_it_was_given() {
let raw = OsString::from_vec(b"/tmp/\x80dots".to_vec());
assert_eq!(ForgetRequest::new(raw.clone()).argument, raw);
assert_eq!(
ForgetRequest::new("os-bytes:2f746d702f80646f7473").argument,
OsString::from("os-bytes:2f746d702f80646f7473")
);
}
#[test]
fn a_change_reports_whether_it_removed_anything() {
let unchanged = ForgetChange {
config: SafetyLockConfig::default(),
removed: None,
};
let removed = ForgetChange {
config: SafetyLockConfig::default(),
removed: Some(RootIdentity::new("/home/alice/dotfiles").unwrap()),
};
assert!(!unchanged.changed());
assert!(removed.changed());
}
}