use crate::fs::Fs;
use crate::gates::HostFacts;
use super::check::{decide, TrustDecision};
use super::error::Result;
use super::inventory::{build_inventory, RootInventory};
use super::roots::ResolvedRoot;
use super::schema::SafetyLockConfig;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum RootOperation {
RootSensitiveMutation,
ReadOnly,
DryRun,
RootIndependentMutation,
}
impl RootOperation {
pub fn requires_trusted_root(self) -> bool {
matches!(self, RootOperation::RootSensitiveMutation)
}
pub fn label(self) -> &'static str {
match self {
RootOperation::RootSensitiveMutation => "root-sensitive mutation",
RootOperation::ReadOnly => "read-only",
RootOperation::DryRun => "dry run",
RootOperation::RootIndependentMutation => "root-independent mutation",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum GateOutcome {
NotRootSensitive,
Permitted(TrustDecision),
ConfirmationRequired {
inventory: RootInventory,
},
}
impl GateOutcome {
pub fn permits_operation(&self) -> bool {
!matches!(self, GateOutcome::ConfirmationRequired { .. })
}
pub fn inventory(&self) -> Option<&RootInventory> {
match self {
GateOutcome::ConfirmationRequired { inventory } => Some(inventory),
_ => None,
}
}
}
pub fn authorize(
operation: RootOperation,
root: &ResolvedRoot,
config: &SafetyLockConfig,
fs: &dyn Fs,
host: &HostFacts,
) -> Result<GateOutcome> {
if !operation.requires_trusted_root() {
return Ok(GateOutcome::NotRootSensitive);
}
match decide(root, config)? {
TrustDecision::ApprovalRequired => Ok(GateOutcome::ConfirmationRequired {
inventory: build_inventory(root, fs, host)?,
}),
permitted => Ok(GateOutcome::Permitted(permitted)),
}
}
#[cfg(test)]
mod tests {
use std::path::{Path, PathBuf};
use crate::fs::OsFs;
use crate::testing::TempEnvironment;
use super::super::error::SafetyLockError;
use super::super::inventory::InventoryCategory;
use super::super::roots::{RootIdentity, RootSource};
use super::super::schema::TrustedRootsSection;
use super::*;
const IMPLICIT: [RootSource; 2] = [RootSource::Git, RootSource::CurrentDirectory];
fn host() -> HostFacts {
HostFacts::for_tests("darwin", "arm64")
}
fn root(path: &Path, source: RootSource) -> ResolvedRoot {
ResolvedRoot::new(
RootIdentity::new(std::fs::canonicalize(path).unwrap()).unwrap(),
source,
)
}
fn approved(paths: impl IntoIterator<Item = PathBuf>) -> SafetyLockConfig {
SafetyLockConfig {
roots: TrustedRootsSection {
approved: paths
.into_iter()
.map(|path| RootIdentity::new(std::fs::canonicalize(path).unwrap()).unwrap())
.collect(),
},
}
}
fn absent_root(source: RootSource) -> ResolvedRoot {
ResolvedRoot::new(
RootIdentity::new("/nonexistent/dotfiles-root").unwrap(),
source,
)
}
fn environment() -> TempEnvironment {
TempEnvironment::builder()
.pack("zsh")
.file("aliases.sh", "alias g=git")
.done()
.pack("vim")
.file("vimrc", "set nocompatible")
.done()
.build()
}
#[test]
fn the_gate_stops_exactly_unapproved_implicit_root_sensitive_mutation() {
let env = environment();
let empty = SafetyLockConfig::default();
let trusted = approved([env.dotfiles_root.clone()]);
for source in IMPLICIT {
let implicit = root(&env.dotfiles_root, source);
assert!(
matches!(
authorize(
RootOperation::RootSensitiveMutation,
&implicit,
&empty,
env.fs.as_ref(),
&host()
)
.unwrap(),
GateOutcome::ConfirmationRequired { .. }
),
"an unapproved {source} root reached mutation unasked"
);
assert_eq!(
authorize(
RootOperation::RootSensitiveMutation,
&implicit,
&trusted,
env.fs.as_ref(),
&host()
)
.unwrap(),
GateOutcome::Permitted(TrustDecision::AlreadyApproved),
"an approved {source} root was asked about again"
);
}
assert_eq!(
authorize(
RootOperation::RootSensitiveMutation,
&root(&env.dotfiles_root, RootSource::Environment),
&empty,
env.fs.as_ref(),
&host()
)
.unwrap(),
GateOutcome::Permitted(TrustDecision::ExplicitlySelected),
);
for operation in [
RootOperation::ReadOnly,
RootOperation::DryRun,
RootOperation::RootIndependentMutation,
] {
for source in [
RootSource::Environment,
RootSource::Git,
RootSource::CurrentDirectory,
] {
assert_eq!(
authorize(
operation,
&root(&env.dotfiles_root, source),
&empty,
env.fs.as_ref(),
&host()
)
.unwrap(),
GateOutcome::NotRootSensitive,
"{} on a {source} root crossed the gate",
operation.label()
);
}
}
}
#[test]
fn a_non_root_sensitive_operation_consults_no_trust_state() {
let env = environment();
let identity =
RootIdentity::new(std::fs::canonicalize(&env.dotfiles_root).unwrap()).unwrap();
let duplicated = SafetyLockConfig {
roots: TrustedRootsSection {
approved: vec![identity.clone(), identity],
},
};
for operation in [
RootOperation::ReadOnly,
RootOperation::DryRun,
RootOperation::RootIndependentMutation,
] {
let outcome = authorize(
operation,
&root(&env.dotfiles_root, RootSource::Git),
&duplicated,
env.fs.as_ref(),
&host(),
)
.unwrap();
assert_eq!(outcome, GateOutcome::NotRootSensitive);
assert!(
outcome.inventory().is_none(),
"{} built a prompt inventory",
operation.label()
);
}
assert!(matches!(
authorize(
RootOperation::RootSensitiveMutation,
&root(&env.dotfiles_root, RootSource::Git),
&duplicated,
env.fs.as_ref(),
&host(),
)
.unwrap_err(),
SafetyLockError::DuplicateApprovedRoot { .. }
));
}
#[test]
fn passing_the_gate_never_establishes_trust() {
let env = environment();
let config = SafetyLockConfig::default();
for operation in [
RootOperation::ReadOnly,
RootOperation::DryRun,
RootOperation::RootIndependentMutation,
RootOperation::RootSensitiveMutation,
] {
let _ = authorize(
operation,
&root(&env.dotfiles_root, RootSource::Git),
&config,
env.fs.as_ref(),
&host(),
);
}
assert!(
config.roots.approved.is_empty(),
"the gate wrote an approval"
);
assert!(
!authorize(
RootOperation::RootSensitiveMutation,
&root(&env.dotfiles_root, RootSource::Git),
&config,
env.fs.as_ref(),
&host(),
)
.unwrap()
.permits_operation(),
"an earlier pass through the gate trusted the root"
);
}
#[test]
fn a_permitted_root_is_never_inventoried() {
let fs = OsFs::new();
for (root, config) in [
(
absent_root(RootSource::Environment),
SafetyLockConfig::default(),
),
(
absent_root(RootSource::Git),
SafetyLockConfig {
roots: TrustedRootsSection {
approved: vec![RootIdentity::new("/nonexistent/dotfiles-root").unwrap()],
},
},
),
] {
let outcome = authorize(
RootOperation::RootSensitiveMutation,
&root,
&config,
&fs,
&host(),
)
.unwrap();
assert!(outcome.permits_operation());
assert!(outcome.inventory().is_none());
}
}
#[test]
fn the_confirmation_inventory_is_root_wide_not_command_scoped() {
let env = environment();
let outcome = authorize(
RootOperation::RootSensitiveMutation,
&root(&env.dotfiles_root, RootSource::Git),
&SafetyLockConfig::default(),
env.fs.as_ref(),
&host(),
)
.unwrap();
let inventory = outcome.inventory().expect("no inventory to show the user");
assert_eq!(inventory.total_files(), 2);
assert_eq!(
inventory
.sample
.iter()
.map(|entry| (entry.category, entry.relative_path.to_str().unwrap()))
.collect::<Vec<_>>(),
[
(InventoryCategory::Shell, "zsh/aliases.sh"),
(InventoryCategory::Link, "vim/vimrc"),
],
"the inventory did not cover every pack under the root"
);
}
#[test]
fn invalid_configuration_yields_no_approvable_outcome() {
let env = TempEnvironment::builder()
.pack("vim")
.config("this is not valid toml")
.file("vimrc", "set nocompatible")
.done()
.build();
let error = authorize(
RootOperation::RootSensitiveMutation,
&root(&env.dotfiles_root, RootSource::Git),
&SafetyLockConfig::default(),
env.fs.as_ref(),
&host(),
)
.unwrap_err();
assert!(
matches!(
&error,
SafetyLockError::DotfilesConfigUnusable { config_file, .. }
if config_file == &std::fs::canonicalize(&env.dotfiles_root).unwrap().join("vim").join(".dodot.toml")
),
"unexpected error: {error}"
);
}
#[test]
fn only_root_sensitive_mutation_requires_trust() {
assert!(RootOperation::RootSensitiveMutation.requires_trusted_root());
for operation in [
RootOperation::ReadOnly,
RootOperation::DryRun,
RootOperation::RootIndependentMutation,
] {
assert!(
!operation.requires_trusted_root(),
"{} requires trust",
operation.label()
);
}
}
#[test]
fn operation_labels_name_the_kind() {
assert_eq!(
[
RootOperation::RootSensitiveMutation,
RootOperation::ReadOnly,
RootOperation::DryRun,
RootOperation::RootIndependentMutation,
]
.map(RootOperation::label),
[
"root-sensitive mutation",
"read-only",
"dry run",
"root-independent mutation",
]
);
}
}