use std::path::{Path, PathBuf};
use clapfig::{Clapfig, ClapfigBuilder, SearchPath};
use confique::Config;
use serde::{Deserialize, Serialize};
use super::error::{Result, SafetyLockError};
use super::roots::RootIdentity;
pub const SAFETY_LOCK_FILE_NAME: &str = "safety-lock.toml";
pub const SAFETY_LOCK_LOCK_FILE_NAME: &str = "safety-lock.toml.lock";
pub const SAFETY_LOCK_PERSIST_SCOPE: &str = "data";
#[derive(Config, Debug, Clone, Serialize, Deserialize)]
pub struct SafetyLockConfig {
#[config(nested)]
pub roots: TrustedRootsSection,
}
#[derive(Config, Debug, Clone, Serialize, Deserialize)]
pub struct TrustedRootsSection {
#[config(default = [])]
pub approved: Vec<RootIdentity>,
}
impl SafetyLockConfig {
pub fn path_in(data_dir: &Path) -> PathBuf {
data_dir.join(SAFETY_LOCK_FILE_NAME)
}
pub fn store_in(data_dir: &Path) -> ClapfigBuilder<Self> {
Self::coordinates(data_dir)
.post_validate(|config: &Self| config.validate().map_err(|err| err.to_string()))
}
fn coordinates(data_dir: &Path) -> ClapfigBuilder<Self> {
Clapfig::builder::<Self>()
.app_name("dodot")
.file_name(SAFETY_LOCK_FILE_NAME)
.search_paths(vec![SearchPath::Path(data_dir.to_path_buf())])
.persist_scope(
SAFETY_LOCK_PERSIST_SCOPE,
SearchPath::Path(data_dir.to_path_buf()),
)
.no_env()
}
pub fn load_from(data_dir: &Path) -> Result<Self> {
Self::load_through(Self::store_in(data_dir), data_dir)
}
pub fn load_for_revocation(data_dir: &Path) -> Result<Self> {
Self::load_through(Self::coordinates(data_dir), data_dir)
}
fn load_through(store: ClapfigBuilder<Self>, data_dir: &Path) -> Result<Self> {
store
.load()
.map_err(|err| SafetyLockError::TrustStateUnusable {
path: Self::path_in(data_dir),
reason: err.to_string(),
})
}
pub fn is_approved(&self, identity: &RootIdentity) -> bool {
self.roots.approved.contains(identity)
}
pub fn validate(&self) -> Result<()> {
let approved = &self.roots.approved;
for (index, identity) in approved.iter().enumerate() {
if approved[..index].contains(identity) {
return Err(SafetyLockError::DuplicateApprovedRoot {
spelling: identity.spelling(),
});
}
}
Ok(())
}
}
pub struct TrustFileTransaction {
data_dir: PathBuf,
_lock: std::fs::File,
}
impl TrustFileTransaction {
pub fn begin(data_dir: &Path) -> Result<Self> {
let not_writable = |reason: String| SafetyLockError::TrustStateNotWritable {
path: SafetyLockConfig::path_in(data_dir),
reason,
};
std::fs::create_dir_all(data_dir).map_err(|err| not_writable(err.to_string()))?;
let lock = std::fs::OpenOptions::new()
.create(true)
.truncate(false)
.write(true)
.open(data_dir.join(SAFETY_LOCK_LOCK_FILE_NAME))
.map_err(|err| not_writable(format!("cannot open the writer lock: {err}")))?;
lock.lock()
.map_err(|err| not_writable(format!("cannot acquire the writer lock: {err}")))?;
Ok(Self {
data_dir: data_dir.to_path_buf(),
_lock: lock,
})
}
pub fn load(&self) -> Result<SafetyLockConfig> {
SafetyLockConfig::load_from(&self.data_dir)
}
pub fn load_for_revocation(&self) -> Result<SafetyLockConfig> {
SafetyLockConfig::load_for_revocation(&self.data_dir)
}
pub fn persist(&self, config: &SafetyLockConfig) -> Result<()> {
let path = SafetyLockConfig::path_in(&self.data_dir);
let not_writable = |reason: String| SafetyLockError::TrustStateNotWritable {
path: path.clone(),
reason,
};
let document = toml::to_string(config).map_err(|err| not_writable(err.to_string()))?;
let mut scratch = tempfile::Builder::new()
.prefix(SAFETY_LOCK_FILE_NAME)
.suffix(".new")
.tempfile_in(&self.data_dir)
.map_err(|err| not_writable(err.to_string()))?;
std::io::Write::write_all(scratch.as_file_mut(), document.as_bytes())
.map_err(|err| not_writable(err.to_string()))?;
scratch
.persist(&path)
.map_err(|err| not_writable(err.to_string()))?;
Ok(())
}
}
impl Default for SafetyLockConfig {
fn default() -> Self {
Self {
roots: TrustedRootsSection {
approved: Vec::new(),
},
}
}
}
#[cfg(test)]
mod tests {
use std::ffi::OsString;
use std::os::unix::ffi::OsStringExt;
use clapfig::types::ConfigAction;
use clapfig::ConfigResult;
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 load(data_dir: &Path) -> SafetyLockConfig {
SafetyLockConfig::load_from(data_dir).unwrap()
}
fn persist(config: &SafetyLockConfig, data_dir: &Path) {
TrustFileTransaction::begin(data_dir)
.unwrap()
.persist(config)
.unwrap();
}
fn write_trust_file(data_dir: &Path, content: &str) {
std::fs::write(SafetyLockConfig::path_in(data_dir), content).unwrap();
}
#[test]
fn the_trust_file_is_one_document_under_the_data_dir() {
assert_eq!(
SafetyLockConfig::path_in(Path::new("/u/.local/share/dodot")),
PathBuf::from("/u/.local/share/dodot/safety-lock.toml")
);
}
#[test]
fn absent_file_loads_an_empty_collection() {
let data_dir = tempfile::tempdir().unwrap();
let config = load(data_dir.path());
assert!(config.roots.approved.is_empty());
assert!(!SafetyLockConfig::path_in(data_dir.path()).exists());
assert!(config.validate().is_ok());
}
#[test]
fn the_default_matches_what_an_absent_file_loads() {
let data_dir = tempfile::tempdir().unwrap();
assert_eq!(
load(data_dir.path()).roots.approved,
SafetyLockConfig::default().roots.approved
);
}
#[test]
fn multiple_roots_round_trip_through_the_file() {
let data_dir = tempfile::tempdir().unwrap();
let approved = vec![
identity("/home/alice/dotfiles"),
identity("/home/alice/work-dotfiles"),
non_unicode_identity(b"\x80dots"),
];
let written = toml::to_string(&SafetyLockConfig {
roots: TrustedRootsSection {
approved: approved.clone(),
},
})
.unwrap();
write_trust_file(data_dir.path(), &written);
assert!(written.contains("\"/home/alice/dotfiles\""));
assert!(written.contains("\"os-bytes:2f746d702f80646f7473\""));
let loaded = load(data_dir.path());
assert_eq!(loaded.roots.approved, approved);
assert!(loaded.validate().is_ok());
}
#[test]
fn lossy_colliding_roots_persist_as_two_records() {
let data_dir = tempfile::tempdir().unwrap();
let one = non_unicode_identity(b"\x80");
let other = non_unicode_identity(b"\x81");
let config = SafetyLockConfig {
roots: TrustedRootsSection {
approved: vec![one.clone(), other.clone()],
},
};
write_trust_file(data_dir.path(), &toml::to_string(&config).unwrap());
let loaded = load(data_dir.path());
assert_eq!(loaded.roots.approved, vec![one.clone(), other.clone()]);
assert!(loaded.is_approved(&one));
assert!(loaded.is_approved(&other));
assert!(loaded.validate().is_ok());
}
#[test]
fn approval_lookup_is_exact() {
let config = SafetyLockConfig {
roots: TrustedRootsSection {
approved: vec![identity("/home/alice/dotfiles")],
},
};
assert!(config.is_approved(&identity("/home/alice/dotfiles")));
assert!(!config.is_approved(&identity("/home/alice/dotfiles/vim")));
assert!(!config.is_approved(&identity("/home/alice/other")));
assert!(!SafetyLockConfig::default().is_approved(&identity("/home/alice/dotfiles")));
}
#[test]
fn a_root_listed_twice_is_rejected() {
let config = SafetyLockConfig {
roots: TrustedRootsSection {
approved: vec![
identity("/home/alice/dotfiles"),
identity("/home/alice/dotfiles"),
],
},
};
let err = config.validate().unwrap_err();
assert!(
matches!(err, SafetyLockError::DuplicateApprovedRoot { ref spelling } if spelling == "/home/alice/dotfiles"),
"unexpected error: {err}"
);
}
#[test]
fn a_malformed_entry_fails_the_load_instead_of_reading_as_empty() {
for entry in ["relative/dotfiles", "/home/alice/dotfiles/../other"] {
let data_dir = tempfile::tempdir().unwrap();
write_trust_file(
data_dir.path(),
&format!("[roots]\napproved = [\"{entry}\"]\n"),
);
assert!(
SafetyLockConfig::load_from(data_dir.path()).is_err(),
"`{entry}` loaded as a usable approval"
);
}
}
#[test]
fn duplicate_roots_in_the_file_fail_the_production_load() {
let data_dir = tempfile::tempdir().unwrap();
write_trust_file(
data_dir.path(),
"[roots]\napproved = [\"/home/alice/dotfiles\", \"/home/alice/dotfiles\"]\n",
);
let err = SafetyLockConfig::load_from(data_dir.path()).unwrap_err();
assert!(
matches!(err, SafetyLockError::TrustStateUnusable { .. }),
"unexpected error: {err}"
);
assert!(
err.to_string().contains("/home/alice/dotfiles"),
"the diagnostic must name the offending root: {err}"
);
assert!(SafetyLockConfig::store_in(data_dir.path()).load().is_err());
}
#[test]
fn the_revocation_route_relaxes_the_duplicate_invariant_and_nothing_else() {
let data_dir = tempfile::tempdir().unwrap();
write_trust_file(
data_dir.path(),
"[roots]\napproved = [\"/home/alice/dotfiles\", \"/home/alice//dotfiles/\"]\n",
);
let recoverable = SafetyLockConfig::load_for_revocation(data_dir.path()).unwrap();
assert_eq!(
recoverable.roots.approved,
vec![
identity("/home/alice/dotfiles"),
identity("/home/alice/dotfiles")
]
);
assert!(
recoverable.validate().is_err(),
"the route must hand back the unusable state, not quietly repair it"
);
for content in [
"[roots]\napproved = [\"/home/alice/dotfiles\"",
"[roots]\napproved = [\"relative/dotfiles\"]\n",
"[roots]\napproved = [\"/home/alice/dotfiles/../other\"]\n",
"[roots]\napproved = \"not-a-list\"\n",
] {
let data_dir = tempfile::tempdir().unwrap();
write_trust_file(data_dir.path(), content);
assert!(
SafetyLockConfig::load_for_revocation(data_dir.path()).is_err(),
"the revocation route accepted `{content}`"
);
}
let empty = tempfile::tempdir().unwrap();
assert!(SafetyLockConfig::load_for_revocation(empty.path())
.unwrap()
.roots
.approved
.is_empty());
}
#[test]
fn spelling_variants_of_one_root_are_caught_as_duplicates() {
let data_dir = tempfile::tempdir().unwrap();
write_trust_file(
data_dir.path(),
"[roots]\napproved = [\"/home/alice/dotfiles\", \"/home/alice//dotfiles/\"]\n",
);
assert!(SafetyLockConfig::load_from(data_dir.path()).is_err());
}
#[test]
fn persisted_state_loads_back_byte_for_byte() {
let data_dir = tempfile::tempdir().unwrap();
let approved = vec![
identity("/home/alice/dotfiles"),
non_unicode_identity(b"\x80dots"),
];
persist(
&SafetyLockConfig {
roots: TrustedRootsSection {
approved: approved.clone(),
},
},
data_dir.path(),
);
assert_eq!(load(data_dir.path()).roots.approved, approved);
}
#[test]
fn persisting_creates_the_data_directory() {
let parent = tempfile::tempdir().unwrap();
let data_dir = parent.path().join("share").join("dodot");
persist(&SafetyLockConfig::default(), &data_dir);
assert!(SafetyLockConfig::path_in(&data_dir).is_file());
}
#[test]
fn a_failed_write_leaves_the_previous_approvals_intact() {
use std::os::unix::fs::PermissionsExt;
let data_dir = tempfile::tempdir().unwrap();
let approved = vec![identity("/home/alice/dotfiles")];
persist(
&SafetyLockConfig {
roots: TrustedRootsSection {
approved: approved.clone(),
},
},
data_dir.path(),
);
let transaction = TrustFileTransaction::begin(data_dir.path()).unwrap();
let set_mode = |mode: u32| {
std::fs::set_permissions(data_dir.path(), std::fs::Permissions::from_mode(mode))
.unwrap();
};
set_mode(0o555);
let err = transaction
.persist(&SafetyLockConfig::default())
.unwrap_err();
set_mode(0o755);
assert!(
matches!(err, SafetyLockError::TrustStateNotWritable { .. }),
"unexpected error: {err}"
);
assert_eq!(load(data_dir.path()).roots.approved, approved);
}
#[test]
fn racing_transactions_lose_no_update() {
let data_dir = tempfile::tempdir().unwrap();
let writers: Vec<_> = (0..8)
.map(|index| {
let data_dir = data_dir.path().to_path_buf();
std::thread::spawn(move || {
let transaction = TrustFileTransaction::begin(&data_dir).unwrap();
let mut config = transaction.load().unwrap();
config
.roots
.approved
.push(identity(&format!("/home/alice/dots-{index}")));
transaction.persist(&config).unwrap();
})
})
.collect();
for writer in writers {
writer.join().unwrap();
}
let approved = load(data_dir.path()).roots.approved;
assert_eq!(approved.len(), 8);
for index in 0..8 {
assert!(
approved.contains(&identity(&format!("/home/alice/dots-{index}"))),
"writer {index}'s update was lost: {approved:?}"
);
}
}
#[test]
fn the_generated_template_documents_the_approved_collection() {
let data_dir = tempfile::tempdir().unwrap();
let ConfigResult::Template(template) = SafetyLockConfig::store_in(data_dir.path())
.handle(&ConfigAction::Gen { output: None })
.unwrap()
else {
panic!("expected a generated template");
};
assert!(template.contains("[roots]"), "template:\n{template}");
assert!(template.contains("approved"), "template:\n{template}");
assert!(
template.contains("dodot roots forget"),
"the doc comment explaining how approvals are removed did not \
reach the template:\n{template}"
);
}
}