use std::ffi::{OsStr, OsString};
use std::path::{Path, PathBuf};
use serde::Serialize;
use crate::commands::MessageResult;
use crate::safety_lock::{
encode_native_path, forget_root, list_roots, ForgetRequest, PathProbe, SafetyLockConfig,
SafetyLockError, SafetyLockResult, TrustFileTransaction, NATIVE_BYTES_TAG,
};
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct TrustedRootRow {
pub path: String,
pub exists: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct RootsListResult {
pub roots: Vec<TrustedRootRow>,
pub state_path: String,
}
pub fn list(data_dir: &Path, probe: &dyn PathProbe) -> SafetyLockResult<RootsListResult> {
let config = SafetyLockConfig::load_from(data_dir)?;
let listing = list_roots(&config, probe)?;
Ok(RootsListResult {
roots: listing
.entries
.iter()
.map(|entry| TrustedRootRow {
path: entry.spelling(),
exists: entry.exists,
})
.collect(),
state_path: SafetyLockConfig::path_in(data_dir).display().to_string(),
})
}
pub fn forget(
data_dir: &Path,
invocation_dir: Option<&Path>,
argument: &OsStr,
probe: &dyn PathProbe,
) -> SafetyLockResult<MessageResult> {
let anchored = anchor(invocation_dir, argument)?;
let transaction = TrustFileTransaction::begin(data_dir)?;
let config = transaction.load_for_revocation()?;
let change = forget_root(&config, &ForgetRequest::new(anchored), probe)?;
let Some(removed) = change.removed.as_ref() else {
return Ok(MessageResult {
message: format!("No approved root matches `{}`.", argument.to_string_lossy()),
details: vec!["Run `dodot roots list` to see the approved roots.".into()],
});
};
let spelling = removed.spelling();
transaction.persist(&change.config)?;
Ok(MessageResult {
message: format!("Forgot {spelling}."),
details: vec![
"The next root-sensitive command run from that root asks for confirmation again."
.into(),
],
})
}
fn anchor(invocation_dir: Option<&Path>, argument: &OsStr) -> SafetyLockResult<OsString> {
if argument
.as_encoded_bytes()
.starts_with(NATIVE_BYTES_TAG.as_bytes())
{
return Ok(argument.to_os_string());
}
let candidate = PathBuf::from(argument);
if candidate.is_absolute() {
Ok(candidate.into_os_string())
} else {
let anchor_dir =
invocation_dir.ok_or_else(|| SafetyLockError::RelativeArgumentUnanchorable {
spelling: encode_native_path(&candidate),
})?;
Ok(anchor_dir.join(candidate).into_os_string())
}
}
#[cfg(test)]
mod tests {
use std::os::unix::ffi::OsStrExt;
use crate::safety_lock::{OsPathProbe, RootIdentity, SafetyLockError, TrustedRootsSection};
use super::*;
fn approved(data_dir: &Path, paths: &[&Path]) {
TrustFileTransaction::begin(data_dir)
.unwrap()
.persist(&SafetyLockConfig {
roots: TrustedRootsSection {
approved: paths
.iter()
.map(|path| RootIdentity::new(*path).unwrap())
.collect(),
},
})
.unwrap();
}
#[test]
fn an_absent_trust_file_lists_as_no_approvals() {
let data_dir = tempfile::tempdir().unwrap();
let result = list(data_dir.path(), &OsPathProbe).unwrap();
assert!(result.roots.is_empty());
assert!(result.state_path.ends_with("safety-lock.toml"));
}
#[test]
fn listing_reports_whether_each_approved_root_still_exists() {
let data_dir = tempfile::tempdir().unwrap();
let live = tempfile::tempdir().unwrap();
let live_path = std::fs::canonicalize(live.path()).unwrap();
let gone = live_path.join("moved-away");
approved(data_dir.path(), &[&live_path, &gone]);
let result = list(data_dir.path(), &OsPathProbe).unwrap();
assert_eq!(
result.roots,
vec![
TrustedRootRow {
path: live_path.display().to_string(),
exists: true,
},
TrustedRootRow {
path: gone.display().to_string(),
exists: false,
},
]
);
}
#[test]
fn listing_surfaces_an_unusable_trust_file_instead_of_showing_it_empty() {
let data_dir = tempfile::tempdir().unwrap();
std::fs::write(
SafetyLockConfig::path_in(data_dir.path()),
"[roots]\napproved = [\"relative/dotfiles\"]\n",
)
.unwrap();
let err = list(data_dir.path(), &OsPathProbe).unwrap_err();
assert!(
matches!(err, SafetyLockError::TrustStateUnusable { .. }),
"unexpected error: {err}"
);
}
#[test]
fn forgetting_removes_the_approval_and_leaves_the_others() {
let data_dir = tempfile::tempdir().unwrap();
let one = tempfile::tempdir().unwrap();
let other = tempfile::tempdir().unwrap();
let one_path = std::fs::canonicalize(one.path()).unwrap();
let other_path = std::fs::canonicalize(other.path()).unwrap();
approved(data_dir.path(), &[&one_path, &other_path]);
let result = forget(
data_dir.path(),
Some(Path::new("/")),
one_path.as_os_str(),
&OsPathProbe,
)
.unwrap();
assert!(result.message.contains(&one_path.display().to_string()));
assert_eq!(
list(data_dir.path(), &OsPathProbe).unwrap().roots,
vec![TrustedRootRow {
path: other_path.display().to_string(),
exists: true,
}]
);
}
#[test]
fn a_root_that_no_longer_exists_is_still_revocable_by_its_printed_spelling() {
let data_dir = tempfile::tempdir().unwrap();
let gone = PathBuf::from("/nonexistent/dotfiles");
approved(data_dir.path(), &[&gone]);
let printed = list(data_dir.path(), &OsPathProbe).unwrap().roots[0]
.path
.clone();
forget(
data_dir.path(),
Some(Path::new("/")),
OsStr::new(&printed),
&OsPathProbe,
)
.unwrap();
assert!(list(data_dir.path(), &OsPathProbe)
.unwrap()
.roots
.is_empty());
}
#[test]
fn a_relative_argument_is_anchored_to_the_captured_invocation_directory() {
let data_dir = tempfile::tempdir().unwrap();
let home = tempfile::tempdir().unwrap();
let root = std::fs::canonicalize(home.path()).unwrap().join("dotfiles");
std::fs::create_dir(&root).unwrap();
approved(data_dir.path(), &[&root]);
let invoked_from = std::fs::canonicalize(home.path()).unwrap();
forget(
data_dir.path(),
Some(invoked_from.as_path()),
OsStr::new("dotfiles"),
&OsPathProbe,
)
.unwrap();
assert!(list(data_dir.path(), &OsPathProbe)
.unwrap()
.roots
.is_empty());
}
#[test]
fn an_absolute_argument_revokes_without_an_invocation_directory() {
let data_dir = tempfile::tempdir().unwrap();
let root = tempfile::tempdir().unwrap();
let root_path = std::fs::canonicalize(root.path()).unwrap();
approved(data_dir.path(), &[&root_path]);
forget(data_dir.path(), None, root_path.as_os_str(), &OsPathProbe).unwrap();
assert!(list(data_dir.path(), &OsPathProbe)
.unwrap()
.roots
.is_empty());
}
#[test]
fn a_relative_argument_without_an_invocation_directory_fails_with_the_recovery_route() {
let data_dir = tempfile::tempdir().unwrap();
let err = forget(data_dir.path(), None, OsStr::new("dotfiles"), &OsPathProbe).unwrap_err();
assert!(
matches!(err, SafetyLockError::RelativeArgumentUnanchorable { .. }),
"unexpected error: {err}"
);
}
#[test]
fn an_argument_matching_nothing_is_reported_rather_than_failed() {
let data_dir = tempfile::tempdir().unwrap();
let held = tempfile::tempdir().unwrap();
let held_path = std::fs::canonicalize(held.path()).unwrap();
approved(data_dir.path(), &[&held_path]);
let result = forget(
data_dir.path(),
Some(Path::new("/")),
OsStr::new("/nowhere/at/all"),
&OsPathProbe,
)
.unwrap();
assert!(result.message.contains("No approved root matches"));
assert_eq!(list(data_dir.path(), &OsPathProbe).unwrap().roots.len(), 1);
}
#[test]
fn forgetting_repairs_a_duplicated_approval_that_every_other_route_refuses() {
let data_dir = tempfile::tempdir().unwrap();
let root = tempfile::tempdir().unwrap();
let root_path = std::fs::canonicalize(root.path()).unwrap();
std::fs::write(
SafetyLockConfig::path_in(data_dir.path()),
format!(
"[roots]\napproved = [\"{0}\", \"{0}\"]\n",
root_path.display()
),
)
.unwrap();
assert!(list(data_dir.path(), &OsPathProbe).is_err());
forget(
data_dir.path(),
Some(Path::new("/")),
root_path.as_os_str(),
&OsPathProbe,
)
.unwrap();
assert!(list(data_dir.path(), &OsPathProbe)
.unwrap()
.roots
.is_empty());
}
#[test]
fn lossy_colliding_roots_list_and_revoke_as_two_records() {
let data_dir = tempfile::tempdir().unwrap();
let one = PathBuf::from(OsStr::from_bytes(b"/nonexistent/\x80"));
let other = PathBuf::from(OsStr::from_bytes(b"/nonexistent/\x81"));
approved(data_dir.path(), &[&one, &other]);
let listed = list(data_dir.path(), &OsPathProbe).unwrap().roots;
assert_eq!(listed.len(), 2);
assert_ne!(listed[0].path, listed[1].path);
forget(
data_dir.path(),
Some(Path::new("/")),
OsStr::new(&listed[0].path),
&OsPathProbe,
)
.unwrap();
let remaining = list(data_dir.path(), &OsPathProbe).unwrap().roots;
assert_eq!(remaining.len(), 1);
assert_eq!(remaining[0].path, listed[1].path);
}
}