use std::fs;
use std::io::Write;
use std::path::{Path, PathBuf};
#[cfg(not(target_os = "linux"))]
use std::collections::HashSet;
use eyre::{Result, WrapErr, bail, eyre};
use indexmap::IndexMap;
use path_absolutize::Absolutize;
use serde::{Deserialize, Serialize};
use crate::config::{Config, ConfigMap};
use crate::system::resources::{ResourceAction, ResourceId, ResourceOrigin, ResourcePlan};
#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
pub(crate) struct ManagedFileTomlConfig {
pub source: Option<String>,
pub content: Option<String>,
pub owner: Option<String>,
pub group: Option<String>,
pub mode: Option<String>,
#[serde(default)]
pub template: bool,
#[serde(default)]
pub state: ManagedState,
#[serde(default)]
pub replace: bool,
#[serde(default)]
pub notify: Vec<String>,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
pub(crate) struct ManagedDirectoryTomlConfig {
pub owner: Option<String>,
pub group: Option<String>,
pub mode: Option<String>,
#[serde(default)]
pub state: ManagedState,
#[serde(default)]
pub recursive: bool,
#[serde(default)]
pub replace: bool,
#[serde(default)]
pub notify: Vec<String>,
}
#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq)]
#[serde(rename_all = "snake_case")]
pub(crate) enum ManagedState {
#[default]
Present,
Absent,
}
#[derive(Clone, Debug)]
pub(crate) struct ManagedFileRequest {
pub path: PathBuf,
pub content: Option<String>,
pub owner: Option<String>,
pub group: Option<String>,
pub mode: u32,
pub state: ManagedState,
pub replace: bool,
pub notify: Vec<String>,
pub origin: ResourceOrigin,
inspection: Option<PathInspection>,
}
#[derive(Clone, Debug)]
pub(crate) struct ManagedDirectoryRequest {
pub path: PathBuf,
pub owner: Option<String>,
pub group: Option<String>,
pub mode: u32,
pub state: ManagedState,
pub recursive: bool,
pub replace: bool,
pub notify: Vec<String>,
pub origin: ResourceOrigin,
inspection: Option<PathInspection>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub(crate) enum PrivilegedAction {
WriteFile {
path: PathBuf,
content: String,
owner: Option<String>,
group: Option<String>,
mode: u32,
replace: bool,
},
RemoveFile {
path: PathBuf,
},
CreateDirectory {
path: PathBuf,
owner: Option<String>,
group: Option<String>,
mode: u32,
replace: bool,
},
RemoveDirectory {
path: PathBuf,
recursive: bool,
},
}
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
pub(crate) struct PrivilegedPlan {
pub actions: Vec<PrivilegedAction>,
}
#[derive(Clone, Debug, Default)]
pub(crate) struct ApplyReport {
pub notified_services: super::services::ServiceNotifications,
}
pub(crate) fn pending_notifications(
files: &[ManagedFileRequest],
directories: &[ManagedDirectoryRequest],
) -> Result<super::services::ServiceNotifications> {
let mut notifications = super::services::ServiceNotifications::default();
for directory in directories {
if matches!(
directory.plan()?.action,
ResourceAction::Create | ResourceAction::Update | ResourceAction::Remove
) {
notifications.notify_directory(&directory.path, &directory.notify);
}
}
for file in files {
if matches!(
file.plan()?.action,
ResourceAction::Create | ResourceAction::Update | ResourceAction::Remove
) {
notifications.notify_file(&file.path, &file.notify);
}
}
Ok(notifications)
}
#[derive(Clone, Debug, Serialize, Deserialize)]
struct PrivilegedInspectionPlan {
paths: Vec<PrivilegedPathInspection>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
struct PrivilegedPathInspection {
path: PathBuf,
expected_content: Option<String>,
owner: Option<String>,
group: Option<String>,
mode: u32,
check_metadata: bool,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(tag = "state", rename_all = "snake_case")]
enum PathInspection {
Missing,
Present {
kind: ManagedPathKind,
current: String,
metadata_matches: bool,
content_matches: Option<bool>,
},
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
enum ManagedPathKind {
File,
Directory,
Symlink,
Other,
}
pub(crate) fn requests_from_config(
config: &Config,
secrets: &super::secrets::SecretValues,
) -> Result<(Vec<ManagedFileRequest>, Vec<ManagedDirectoryRequest>)> {
let (mut files, mut directories) = prepare_requests_from_config(config, secrets)?;
inspect_requests(&mut files, &mut directories)?;
Ok((files, directories))
}
pub(crate) fn status_requests_from_config(
config: &Config,
secrets: &super::secrets::SecretValues,
) -> Result<(
Vec<ManagedFileRequest>,
Vec<ManagedDirectoryRequest>,
Vec<ResourcePlan>,
)> {
let mut files = vec![];
let mut unavailable = vec![];
let mut directories = directories_from_config(config)?;
let directory_states = directories
.iter()
.map(|directory| (directory.path.as_path(), directory.state))
.collect::<std::collections::HashMap<_, _>>();
for (path, (file, base, origin)) in merged_files_from_config(config)? {
let state = file.state;
match ManagedFileRequest::from_toml(
config,
path.clone(),
file,
&base,
origin.clone(),
secrets,
) {
Ok(file) => files.push(file),
Err(error) if super::secrets::is_unavailable(&error) => {
if directory_states.contains_key(path.as_path()) {
bail!(
"managed system path '{}' is declared as both a file and a directory",
path.display()
);
}
validate_present_ancestors(&path, state, &directory_states)?;
unavailable.push(
ResourcePlan::new(
ResourceId::new("file", path.to_string_lossy().into_owned()),
"not inspected: required secret unavailable",
"template rendered",
ResourceAction::Unknown,
)
.with_origin(origin),
);
}
Err(error) => return Err(error),
}
}
ignore_non_linux_account_principals(config, &mut files, &mut directories);
validate_requests(&files, &directories)?;
inspect_paths(&mut files, &mut directories)?;
Ok((files, directories, unavailable))
}
pub(crate) fn prepare_requests_from_config(
config: &Config,
secrets: &super::secrets::SecretValues,
) -> Result<(Vec<ManagedFileRequest>, Vec<ManagedDirectoryRequest>)> {
let mut files = files_from_config(config, secrets)?;
let mut directories = directories_from_config(config)?;
ignore_non_linux_account_principals(config, &mut files, &mut directories);
validate_requests(&files, &directories)?;
Ok((files, directories))
}
#[cfg(target_os = "linux")]
fn ignore_non_linux_account_principals(
_config: &Config,
_files: &mut [ManagedFileRequest],
_directories: &mut [ManagedDirectoryRequest],
) {
}
#[cfg(not(target_os = "linux"))]
fn ignore_non_linux_account_principals(
config: &Config,
files: &mut [ManagedFileRequest],
directories: &mut [ManagedDirectoryRequest],
) {
let mut users = HashSet::new();
let mut groups = HashSet::new();
for cf in config.config_files.values() {
if let Some(bootstrap) = cf.bootstrap_config() {
users.extend(bootstrap.users.into_keys());
groups.extend(bootstrap.groups.into_keys());
}
}
clear_ignored_principals(files, directories, &users, &groups);
}
#[cfg(any(not(target_os = "linux"), test))]
fn clear_ignored_principals(
files: &mut [ManagedFileRequest],
directories: &mut [ManagedDirectoryRequest],
users: &std::collections::HashSet<String>,
groups: &std::collections::HashSet<String>,
) {
for (kind, path, owner, group) in files
.iter_mut()
.map(|request| {
(
"file",
request.path.as_path(),
&mut request.owner,
&mut request.group,
)
})
.chain(directories.iter_mut().map(|request| {
(
"directory",
request.path.as_path(),
&mut request.owner,
&mut request.group,
)
}))
{
if owner.as_ref().is_some_and(|owner| users.contains(owner)) {
warn!(
"ignoring owner '{}' for managed {kind} '{}' because [bootstrap.users] is Linux-only",
owner.as_deref().expect("matching owner is present"),
path.display()
);
*owner = None;
}
if group.as_ref().is_some_and(|group| groups.contains(group)) {
warn!(
"ignoring group '{}' for managed {kind} '{}' because [bootstrap.groups] is Linux-only",
group.as_deref().expect("matching group is present"),
path.display()
);
*group = None;
}
}
}
pub(crate) fn inspect_requests(
files: &mut [ManagedFileRequest],
directories: &mut [ManagedDirectoryRequest],
) -> Result<()> {
inspect_paths(files, directories)
}
fn files_from_config(
config: &Config,
secrets: &super::secrets::SecretValues,
) -> Result<Vec<ManagedFileRequest>> {
merged_files_from_config(config)?
.into_iter()
.map(|(path, (file, base, origin))| {
ManagedFileRequest::from_toml(config, path, file, &base, origin, secrets)
})
.collect()
}
fn merged_files_from_config(
config: &Config,
) -> Result<IndexMap<PathBuf, (ManagedFileTomlConfig, PathBuf, ResourceOrigin)>> {
let mut composed: IndexMap<PathBuf, (ManagedFileTomlConfig, PathBuf, ResourceOrigin)> =
IndexMap::new();
for config_files in config.bootstrap_config_maps() {
for (path, declaration) in merged_files_from_config_files(config_files)? {
if let Some(existing) = composed.get(&path) {
if managed_file_declarations_match(existing, &declaration) {
continue;
}
bail!(
"conflicting managed file declarations for {}\n\n first:\n {}\n\n second:\n {}",
path.display(),
existing.2.conflict_description(),
declaration.2.conflict_description(),
);
}
composed.insert(path, declaration);
}
}
Ok(composed)
}
fn managed_file_declarations_match(
first: &(ManagedFileTomlConfig, PathBuf, ResourceOrigin),
second: &(ManagedFileTomlConfig, PathBuf, ResourceOrigin),
) -> bool {
first.0 == second.0
&& first.2.source == second.2.source
&& (!first.0.template || first.1 == second.1)
}
fn merged_files_from_config_files(
config_files: &ConfigMap,
) -> Result<IndexMap<PathBuf, (ManagedFileTomlConfig, PathBuf, ResourceOrigin)>> {
let mut merged = IndexMap::new();
for cf in config_files.values() {
if let Some(bootstrap) = cf.bootstrap_config() {
let mut layer_paths = IndexMap::new();
let base = cf
.get_path()
.parent()
.unwrap_or_else(|| Path::new("."))
.to_path_buf();
let origin = ResourceOrigin {
config: cf.get_path().to_path_buf(),
config_root: cf.config_root(),
environment: crate::config::environments_for_config_path(cf.get_path()),
source: None,
};
for (path, file) in bootstrap.files {
let target = absolute_target(&path)?;
if let Some(previous) = layer_paths.insert(target.clone(), path.clone()) {
bail!(
"managed file paths '{previous}' and '{path}' normalize to the same target '{}'",
target.display()
);
}
let mut file_origin = origin.clone();
file_origin.source = file
.source
.as_deref()
.map(|source| resolve_source_path(&base, source));
merged
.entry(target)
.or_insert_with(|| (file, base.clone(), file_origin));
}
}
}
Ok(merged)
}
fn directories_from_config(config: &Config) -> Result<Vec<ManagedDirectoryRequest>> {
let mut composed: IndexMap<PathBuf, (ManagedDirectoryTomlConfig, ResourceOrigin)> =
IndexMap::new();
for config_files in config.bootstrap_config_maps() {
for (path, declaration) in directories_from_config_files(config_files)? {
if let Some(existing) = composed.get(&path) {
if existing.0 == declaration.0 {
continue;
}
bail!(
"conflicting managed directory declarations for {}\n\n first:\n {}\n\n second:\n {}",
path.display(),
existing.1.conflict_description(),
declaration.1.conflict_description(),
);
}
composed.insert(path, declaration);
}
}
composed
.into_iter()
.map(|(path, (config, origin))| ManagedDirectoryRequest::from_toml(path, config, origin))
.collect()
}
fn directories_from_config_files(
config_files: &ConfigMap,
) -> Result<IndexMap<PathBuf, (ManagedDirectoryTomlConfig, ResourceOrigin)>> {
let mut merged = IndexMap::new();
for cf in config_files.values() {
if let Some(bootstrap) = cf.bootstrap_config() {
let origin = ResourceOrigin {
config: cf.get_path().to_path_buf(),
config_root: cf.config_root(),
environment: crate::config::environments_for_config_path(cf.get_path()),
source: None,
};
let mut layer_paths = IndexMap::new();
for (path, directory) in bootstrap.directories {
let target = absolute_target(&path)?;
if let Some(previous) = layer_paths.insert(target.clone(), path.clone()) {
bail!(
"managed directory paths '{previous}' and '{path}' normalize to the same target '{}'",
target.display()
);
}
merged
.entry(target)
.or_insert_with(|| (directory, origin.clone()));
}
}
}
Ok(merged)
}
fn validate_requests(
files: &[ManagedFileRequest],
directories: &[ManagedDirectoryRequest],
) -> Result<()> {
let directory_states = directories
.iter()
.map(|directory| (directory.path.as_path(), directory.state))
.collect::<std::collections::HashMap<_, _>>();
for file in files {
if directory_states.contains_key(file.path.as_path()) {
bail!(
"managed system path '{}' is declared as both a file and a directory",
file.path.display()
);
}
validate_present_ancestors(&file.path, file.state, &directory_states)?;
}
for directory in directories {
validate_present_ancestors(&directory.path, directory.state, &directory_states)?;
}
Ok(())
}
fn validate_present_ancestors(
path: &Path,
state: ManagedState,
directory_states: &std::collections::HashMap<&Path, ManagedState>,
) -> Result<()> {
if state != ManagedState::Present {
return Ok(());
}
if let Some(parent) = path
.ancestors()
.skip(1)
.find(|parent| directory_states.get(parent) == Some(&ManagedState::Absent))
{
bail!(
"managed path '{}' cannot be present while managed ancestor '{}' is absent",
path.display(),
parent.display()
);
}
Ok(())
}
impl ManagedFileRequest {
fn from_toml(
root_config: &Config,
path: PathBuf,
config: ManagedFileTomlConfig,
base: &Path,
origin: ResourceOrigin,
secrets: &super::secrets::SecretValues,
) -> Result<Self> {
let owner = nonempty("owner", config.owner)?;
let group = nonempty("group", config.group)?;
let mode = parse_mode(config.mode.as_deref(), 0o644)?;
let mut content = match (config.source, config.content, config.state) {
(Some(_), Some(_), _) => {
bail!(
"[bootstrap.files].\"{}\": source and content are mutually exclusive",
path.display()
)
}
(Some(source), None, ManagedState::Present) => {
let source = resolve_source_path(base, &source);
Some(fs::read_to_string(&source).wrap_err_with(|| {
format!(
"[bootstrap.files].\"{}\": failed to read source {}",
path.display(),
source.display()
)
})?)
}
(None, Some(content), ManagedState::Present) => Some(content),
(None, None, ManagedState::Present) => bail!(
"[bootstrap.files].\"{}\": present files require source or content",
path.display()
),
(None, None, ManagedState::Absent) => None,
(_, _, ManagedState::Absent) => bail!(
"[bootstrap.files].\"{}\": absent files must not declare source or content",
path.display()
),
};
if config.template {
let rendered = content
.as_deref()
.map(|content| secrets.render(root_config, content, base, &path))
.transpose()
.wrap_err_with(|| {
format!(
"[bootstrap.files].\"{}\": failed to render template",
path.display()
)
})?;
content = rendered;
}
Ok(Self {
path,
content,
owner,
group,
mode,
state: config.state,
replace: config.replace,
notify: config.notify,
origin,
inspection: None,
})
}
pub(crate) fn plan(&self) -> Result<ResourcePlan> {
plan_file(self).map(|plan| plan.with_origin(self.origin.clone()))
}
fn operation(&self) -> Result<Option<PrivilegedAction>> {
match self.plan()?.action {
ResourceAction::Noop => return Ok(None),
ResourceAction::Unknown if self.state == ManagedState::Absent => bail!(
"refusing to remove directory {} as a file; declare it in [bootstrap.directories]",
self.path.display()
),
ResourceAction::Unknown => bail!(
"refusing to replace non-file path {}; set replace = true to allow replacement",
self.path.display()
),
_ => {}
}
Ok(Some(match self.state {
ManagedState::Present => PrivilegedAction::WriteFile {
path: self.path.clone(),
content: self.content.clone().expect("present file has content"),
owner: self.owner.clone(),
group: self.group.clone(),
mode: self.mode,
replace: self.replace,
},
ManagedState::Absent => PrivilegedAction::RemoveFile {
path: self.path.clone(),
},
}))
}
}
fn resolve_source_path(base: &Path, source: &str) -> PathBuf {
let source = Path::new(source);
if source.is_absolute() {
source.to_path_buf()
} else {
base.join(source)
}
}
impl ManagedDirectoryRequest {
fn from_toml(
path: PathBuf,
config: ManagedDirectoryTomlConfig,
origin: ResourceOrigin,
) -> Result<Self> {
if config.state == ManagedState::Present && config.recursive {
bail!(
"[bootstrap.directories].\"{}\": recursive is only valid with state = \"absent\"",
path.display()
);
}
Ok(Self {
path,
owner: nonempty("owner", config.owner)?,
group: nonempty("group", config.group)?,
mode: parse_mode(config.mode.as_deref(), 0o755)?,
state: config.state,
recursive: config.recursive,
replace: config.replace,
notify: config.notify,
origin,
inspection: None,
})
}
pub(crate) fn plan(&self) -> Result<ResourcePlan> {
plan_directory(self).map(|plan| plan.with_origin(self.origin.clone()))
}
fn operation(&self) -> Result<Option<PrivilegedAction>> {
match self.plan()?.action {
ResourceAction::Noop => return Ok(None),
ResourceAction::Unknown if self.state == ManagedState::Absent => bail!(
"refusing to remove non-directory path {} as a directory; declare it in [bootstrap.files]",
self.path.display()
),
ResourceAction::Unknown => bail!(
"refusing to replace non-directory path {}; set replace = true to allow replacement",
self.path.display()
),
_ => {}
}
Ok(Some(match self.state {
ManagedState::Present => PrivilegedAction::CreateDirectory {
path: self.path.clone(),
owner: self.owner.clone(),
group: self.group.clone(),
mode: self.mode,
replace: self.replace,
},
ManagedState::Absent => PrivilegedAction::RemoveDirectory {
path: self.path.clone(),
recursive: self.recursive,
},
}))
}
}
impl PrivilegedPlan {
fn apply_until_elevation_required(self) -> Result<Self> {
let mut actions = self.actions.into_iter();
while let Some(action) = actions.next() {
if action.requires_preemptive_elevation()? {
return Ok(Self {
actions: std::iter::once(action).chain(actions).collect(),
});
}
match action.apply() {
Ok(()) => {}
Err(error) if is_permission_denied(&error) => {
return Ok(Self {
actions: std::iter::once(action).chain(actions).collect(),
});
}
Err(error) => return Err(error),
}
}
Ok(Self::default())
}
}
pub(crate) fn apply_with_accounts(
files: &[ManagedFileRequest],
directories: &[ManagedDirectoryRequest],
accounts: Option<&super::accounts::AccountRequests>,
allow_pending_accounts: bool,
dry_run: bool,
yes: bool,
) -> Result<ApplyReport> {
validate_principals(files, directories, accounts, allow_pending_accounts)?;
let mut plan = PrivilegedPlan::default();
let mut report = ApplyReport::default();
let mut unknown = vec![];
let mut present_directories = directories
.iter()
.filter(|request| request.state == ManagedState::Present)
.collect::<Vec<_>>();
present_directories.sort_by_key(|request| request.path.components().count());
for directory in present_directories {
let resource = directory.plan()?;
if dry_run && resource.action == ResourceAction::Unknown {
unknown.push(resource);
continue;
}
if let Some(action) = directory.operation()? {
plan.actions.push(action);
report
.notified_services
.notify_directory(&directory.path, &directory.notify);
}
}
for file in files {
let resource = file.plan()?;
if dry_run && resource.action == ResourceAction::Unknown {
unknown.push(resource);
continue;
}
if let Some(action) = file.operation()? {
plan.actions.push(action);
report
.notified_services
.notify_file(&file.path, &file.notify);
}
}
let mut absent_directories = directories
.iter()
.filter(|request| request.state == ManagedState::Absent)
.collect::<Vec<_>>();
absent_directories.sort_by_key(|request| std::cmp::Reverse(request.path.components().count()));
for directory in absent_directories {
let resource = directory.plan()?;
if dry_run && resource.action == ResourceAction::Unknown {
unknown.push(resource);
continue;
}
if let Some(action) = directory.operation()? {
plan.actions.push(action);
report
.notified_services
.notify_directory(&directory.path, &directory.notify);
}
}
let descriptions = plan
.actions
.iter()
.map(PrivilegedAction::description)
.collect::<Vec<_>>();
if dry_run {
for description in descriptions {
miseprintln!("would {description}");
}
for resource in &unknown {
warn!(
"would not change {}: current {}, desired {} (manual action required)",
resource.id, resource.current, resource.desired
);
}
if plan.actions.is_empty() && unknown.is_empty() {
info!("system files: already converged");
}
return Ok(report);
}
if plan.actions.is_empty() {
info!("system files: already converged");
return Ok(report);
}
if !yes
&& console::user_attended_stderr()
&& !crate::ui::prompt::confirm(format!(
"system files: apply {} change(s)?",
plan.actions.len()
))?
.is_yes()
{
info!("system files: skipped");
return Ok(ApplyReport::default());
}
let change_count = plan.actions.len();
let privileged_plan = plan.apply_until_elevation_required()?;
if !privileged_plan.actions.is_empty() {
let input = serde_json::to_vec(&privileged_plan)?;
let executable = std::env::current_exe()?.to_string_lossy().to_string();
crate::system::sudo::run_with_input(
&executable,
&[
"--no-config".to_string(),
"--no-env".to_string(),
"--no-hooks".to_string(),
"bootstrap".to_string(),
"__apply-system-plan".to_string(),
],
&input,
)?;
}
info!("system files: applied {change_count} change(s)");
Ok(report)
}
#[cfg(unix)]
pub(crate) fn validate_principals(
files: &[ManagedFileRequest],
directories: &[ManagedDirectoryRequest],
accounts: Option<&super::accounts::AccountRequests>,
allow_pending_accounts: bool,
) -> Result<()> {
for (owner, group) in files
.iter()
.filter(|request| request.state == ManagedState::Present)
.map(|request| (request.owner.as_deref(), request.group.as_deref()))
.chain(
directories
.iter()
.filter(|request| request.state == ManagedState::Present)
.map(|request| (request.owner.as_deref(), request.group.as_deref())),
)
{
if let Some(owner) = owner {
match accounts
.and_then(|accounts| accounts.users.iter().find(|request| request.name == owner))
{
Some(request) if request.state == super::accounts::AccountState::Absent => bail!(
"managed system files require owner '{owner}', but that bootstrap user is absent"
),
Some(request)
if allow_pending_accounts
&& request.plan().action == ResourceAction::Unknown =>
{
bail!(
"managed system files require owner '{owner}', but that bootstrap user cannot be safely converged"
)
}
Some(_) if allow_pending_accounts => {}
Some(_) | None => {
resolve_user(owner)?;
}
}
}
if let Some(group) = group {
match accounts
.and_then(|accounts| accounts.groups.iter().find(|request| request.name == group))
{
Some(request) if request.state == super::accounts::AccountState::Absent => bail!(
"managed system files require group '{group}', but that bootstrap group is absent"
),
Some(request)
if allow_pending_accounts
&& request.plan().action == ResourceAction::Unknown =>
{
bail!(
"managed system files require group '{group}', but that bootstrap group cannot be safely converged"
)
}
Some(_) if allow_pending_accounts => {}
Some(_) | None => {
resolve_group(group)?;
}
}
}
}
Ok(())
}
#[cfg(not(unix))]
pub(crate) fn validate_principals(
files: &[ManagedFileRequest],
directories: &[ManagedDirectoryRequest],
_accounts: Option<&super::accounts::AccountRequests>,
_allow_pending_accounts: bool,
) -> Result<()> {
if files.is_empty() && directories.is_empty() {
return Ok(());
}
bail!("managed system files are only supported on Unix")
}
impl PrivilegedAction {
fn requires_preemptive_elevation(&self) -> Result<bool> {
match self {
Self::WriteFile {
path,
owner,
group,
replace,
..
} => Ok(owner.is_some()
|| group.is_some()
|| (*replace && replacement_is_not(path, ManagedPathKind::File)?)),
Self::CreateDirectory {
path,
owner,
group,
replace,
..
} => Ok(owner.is_some()
|| group.is_some()
|| (*replace && replacement_is_not(path, ManagedPathKind::Directory)?)),
Self::RemoveDirectory {
recursive: true, ..
} => Ok(true),
Self::RemoveFile { .. } | Self::RemoveDirectory { .. } => Ok(false),
}
}
fn description(&self) -> String {
match self {
Self::WriteFile { path, .. } => format!("write file {}", path.display()),
Self::RemoveFile { path } => format!("remove file {}", path.display()),
Self::CreateDirectory { path, .. } => format!("create directory {}", path.display()),
Self::RemoveDirectory { path, recursive } => format!(
"remove directory {}{}",
path.display(),
if *recursive { " recursively" } else { "" }
),
}
}
}
fn replacement_is_not(path: &Path, expected: ManagedPathKind) -> Result<bool> {
match fs::symlink_metadata(path) {
Ok(metadata) => Ok(ManagedPathKind::from_metadata(&metadata) != expected),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false),
Err(error) if error.kind() == std::io::ErrorKind::PermissionDenied => Ok(true),
Err(error) => Err(error.into()),
}
}
pub(crate) fn apply_privileged_plan_from_stdin() -> Result<()> {
let plan: PrivilegedPlan = serde_json::from_reader(std::io::stdin().lock())?;
for action in plan.actions {
action.apply()?;
}
Ok(())
}
pub(crate) fn inspect_privileged_files_from_stdin() -> Result<()> {
let plan: PrivilegedInspectionPlan = serde_json::from_reader(std::io::stdin().lock())?;
let inspections = plan
.paths
.into_iter()
.map(inspect_path)
.collect::<Result<Vec<_>>>()?;
serde_json::to_writer(std::io::stdout().lock(), &inspections)?;
Ok(())
}
impl PrivilegedAction {
fn apply(&self) -> Result<()> {
match self {
Self::WriteFile {
path,
content,
owner,
group,
mode,
replace,
} => write_file(
&validate_privileged_target(path)?,
content.as_bytes(),
owner.as_deref(),
group.as_deref(),
*mode,
*replace,
),
Self::RemoveFile { path } => remove_file(&validate_privileged_target(path)?),
Self::CreateDirectory {
path,
owner,
group,
mode,
replace,
} => create_directory(
&validate_privileged_target(path)?,
owner.as_deref(),
group.as_deref(),
*mode,
*replace,
),
Self::RemoveDirectory { path, recursive } => {
remove_directory(&validate_privileged_target(path)?, *recursive)
}
}
}
}
fn plan_file(request: &ManagedFileRequest) -> Result<ResourcePlan> {
let desired = match request.state {
ManagedState::Present => desired_metadata(
"file",
request.mode,
request.owner.as_deref(),
request.group.as_deref(),
),
ManagedState::Absent => "absent".to_string(),
};
let id = ResourceId::new("file", request.path.to_string_lossy());
let inspection = request
.inspection
.as_ref()
.ok_or_else(|| eyre!("managed file was not inspected: {}", request.path.display()))?;
match (request.state, inspection) {
(ManagedState::Absent, PathInspection::Missing) => Ok(ResourcePlan::new(
id,
"absent",
desired,
ResourceAction::Noop,
)),
(ManagedState::Absent, PathInspection::Present { kind, current, .. }) => {
Ok(ResourcePlan::new(
id,
current,
desired,
if *kind == ManagedPathKind::Directory {
ResourceAction::Unknown
} else {
ResourceAction::Remove
},
))
}
(ManagedState::Present, PathInspection::Missing) => Ok(ResourcePlan::new(
id,
"absent",
desired,
ResourceAction::Create,
)),
(
ManagedState::Present,
PathInspection::Present {
kind,
current,
metadata_matches,
content_matches,
},
) => Ok(ResourcePlan::new(
id,
current,
desired,
if *kind != ManagedPathKind::File && !request.replace {
ResourceAction::Unknown
} else if *kind == ManagedPathKind::File
&& content_matches == &Some(true)
&& *metadata_matches
{
ResourceAction::Noop
} else {
ResourceAction::Update
},
)),
}
}
fn inspect_paths(
files: &mut [ManagedFileRequest],
directories: &mut [ManagedDirectoryRequest],
) -> Result<()> {
enum Target {
File(usize),
Directory(usize),
}
let mut privileged = vec![];
let mut targets = vec![];
for (index, file) in files.iter_mut().enumerate() {
let request = PrivilegedPathInspection {
path: file.path.clone(),
expected_content: (file.state == ManagedState::Present)
.then(|| file.content.clone().expect("present file has content")),
owner: file.owner.clone(),
group: file.group.clone(),
mode: file.mode,
check_metadata: file.state == ManagedState::Present,
};
match inspect_path(request.clone()) {
Ok(inspection) => file.inspection = Some(inspection),
Err(error) if is_permission_denied(&error) => {
privileged.push(request);
targets.push(Target::File(index));
}
Err(error) => return Err(error),
}
}
for (index, directory) in directories.iter_mut().enumerate() {
let request = PrivilegedPathInspection {
path: directory.path.clone(),
expected_content: None,
owner: directory.owner.clone(),
group: directory.group.clone(),
mode: directory.mode,
check_metadata: directory.state == ManagedState::Present,
};
match inspect_path(request.clone()) {
Ok(inspection) => directory.inspection = Some(inspection),
Err(error) if is_permission_denied(&error) => {
privileged.push(request);
targets.push(Target::Directory(index));
}
Err(error) => return Err(error),
}
}
if privileged.is_empty() {
return Ok(());
}
let input = serde_json::to_vec(&PrivilegedInspectionPlan { paths: privileged })?;
let executable = std::env::current_exe()?.to_string_lossy().to_string();
let output = crate::system::sudo::run_with_input_output(
&executable,
&[
"--no-config".to_string(),
"--no-env".to_string(),
"--no-hooks".to_string(),
"bootstrap".to_string(),
"__inspect-system-files".to_string(),
],
&input,
)?;
let inspections: Vec<PathInspection> = serde_json::from_slice(&output)?;
if inspections.len() != targets.len() {
bail!("privileged path inspection returned an unexpected result count");
}
for (target, inspection) in targets.into_iter().zip(inspections) {
match target {
Target::File(index) => files[index].inspection = Some(inspection),
Target::Directory(index) => directories[index].inspection = Some(inspection),
}
}
Ok(())
}
fn plan_directory(request: &ManagedDirectoryRequest) -> Result<ResourcePlan> {
let desired = match request.state {
ManagedState::Present => desired_metadata(
"directory",
request.mode,
request.owner.as_deref(),
request.group.as_deref(),
),
ManagedState::Absent => "absent".to_string(),
};
let id = ResourceId::new("directory", request.path.to_string_lossy());
let inspection = request.inspection.as_ref().ok_or_else(|| {
eyre!(
"managed directory was not inspected: {}",
request.path.display()
)
})?;
match (request.state, inspection) {
(ManagedState::Absent, PathInspection::Missing) => Ok(ResourcePlan::new(
id,
"absent",
desired,
ResourceAction::Noop,
)),
(ManagedState::Absent, PathInspection::Present { kind, current, .. }) => {
Ok(ResourcePlan::new(
id,
current,
desired,
if *kind == ManagedPathKind::Directory {
ResourceAction::Remove
} else {
ResourceAction::Unknown
},
))
}
(ManagedState::Present, PathInspection::Missing) => Ok(ResourcePlan::new(
id,
"absent",
desired,
ResourceAction::Create,
)),
(
ManagedState::Present,
PathInspection::Present {
kind,
current,
metadata_matches,
..
},
) => Ok(ResourcePlan::new(
id,
current,
desired,
if *kind != ManagedPathKind::Directory && !request.replace {
ResourceAction::Unknown
} else if *kind == ManagedPathKind::Directory && *metadata_matches {
ResourceAction::Noop
} else {
ResourceAction::Update
},
)),
}
}
fn inspect_path(request: PrivilegedPathInspection) -> Result<PathInspection> {
let path = validate_privileged_target(&request.path)?;
let metadata = match fs::symlink_metadata(&path) {
Ok(metadata) => metadata,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
return Ok(PathInspection::Missing);
}
Err(error) => return Err(error.into()),
};
let kind = ManagedPathKind::from_metadata(&metadata);
let content_matches = match (request.expected_content, kind) {
(Some(expected), ManagedPathKind::File) => Some(fs::read(&path)? == expected.as_bytes()),
_ => None,
};
let metadata_matches = !request.check_metadata
|| metadata_matches(
&metadata,
request.mode,
request.owner.as_deref(),
request.group.as_deref(),
)?;
Ok(PathInspection::Present {
kind,
current: describe_metadata_value(&metadata),
metadata_matches,
content_matches,
})
}
fn is_permission_denied(error: &eyre::Report) -> bool {
error.chain().any(|error| {
let io_permission_denied = error
.downcast_ref::<std::io::Error>()
.is_some_and(|error| error.kind() == std::io::ErrorKind::PermissionDenied);
#[cfg(unix)]
let platform_permission_denied =
error
.downcast_ref::<nix::errno::Errno>()
.is_some_and(|error| {
matches!(error, nix::errno::Errno::EACCES | nix::errno::Errno::EPERM)
});
#[cfg(not(unix))]
let platform_permission_denied = false;
io_permission_denied || platform_permission_denied
})
}
impl ManagedPathKind {
fn from_metadata(metadata: &fs::Metadata) -> Self {
if metadata.file_type().is_file() {
Self::File
} else if metadata.file_type().is_dir() {
Self::Directory
} else if metadata.file_type().is_symlink() {
Self::Symlink
} else {
Self::Other
}
}
}
fn absolute_target(path: &str) -> Result<PathBuf> {
let path = crate::file::replace_path(Path::new(path));
validate_privileged_target(&path)
}
fn validate_privileged_target(path: &Path) -> Result<PathBuf> {
if !path.is_absolute() {
bail!("managed system path must be absolute: {}", path.display());
}
let path = path.absolutize()?.to_path_buf();
if path == Path::new("/") {
bail!("refusing to manage the filesystem root");
}
Ok(path)
}
fn parse_mode(mode: Option<&str>, default: u32) -> Result<u32> {
let Some(mode) = mode else {
return Ok(default);
};
let mode = mode.strip_prefix("0o").unwrap_or(mode);
let parsed = u32::from_str_radix(mode, 8).wrap_err("mode must be an octal string")?;
if parsed > 0o7777 {
bail!("mode must be between 0000 and 7777");
}
Ok(parsed)
}
fn nonempty(field: &str, value: Option<String>) -> Result<Option<String>> {
match value {
Some(value) if value.trim().is_empty() => bail!("{field} must not be empty"),
Some(value) => Ok(Some(value)),
None => Ok(None),
}
}
fn desired_metadata(kind: &str, mode: u32, owner: Option<&str>, group: Option<&str>) -> String {
let mut desired = format!("{kind} mode {mode:04o}");
if let Some(owner) = owner {
desired.push_str(&format!(" owner {owner}"));
}
if let Some(group) = group {
desired.push_str(&format!(" group {group}"));
}
desired
}
#[cfg(unix)]
fn metadata_matches(
metadata: &fs::Metadata,
mode: u32,
owner: Option<&str>,
group: Option<&str>,
) -> Result<bool> {
use std::os::unix::fs::{MetadataExt, PermissionsExt};
let owner_matches = match owner {
Some(owner) => lookup_user(owner)?.is_some_and(|uid| metadata.uid() == uid),
None => true,
};
let group_matches = match group {
Some(group) => lookup_group(group)?.is_some_and(|gid| metadata.gid() == gid),
None => true,
};
Ok(metadata.permissions().mode() & 0o7777 == mode && owner_matches && group_matches)
}
#[cfg(not(unix))]
fn metadata_matches(
_metadata: &fs::Metadata,
_mode: u32,
_owner: Option<&str>,
_group: Option<&str>,
) -> Result<bool> {
bail!("managed system files are only supported on Unix")
}
#[cfg(unix)]
fn describe_metadata_value(metadata: &fs::Metadata) -> String {
use std::os::unix::fs::{MetadataExt, PermissionsExt};
format!(
"{} mode {:04o} uid {} gid {}",
describe_file_type(metadata),
metadata.permissions().mode() & 0o7777,
metadata.uid(),
metadata.gid(),
)
}
#[cfg(not(unix))]
fn describe_metadata_value(metadata: &fs::Metadata) -> String {
describe_file_type(metadata)
}
fn describe_file_type(metadata: &fs::Metadata) -> String {
if metadata.file_type().is_file() {
"file"
} else if metadata.file_type().is_dir() {
"directory"
} else if metadata.file_type().is_symlink() {
"symlink"
} else {
"other"
}
.to_string()
}
#[cfg(unix)]
fn resolve_user(name: &str) -> Result<u32> {
lookup_user(name)?.ok_or_else(|| eyre!("user '{name}' does not exist"))
}
#[cfg(unix)]
fn resolve_group(name: &str) -> Result<u32> {
lookup_group(name)?.ok_or_else(|| eyre!("group '{name}' does not exist"))
}
#[cfg(unix)]
fn lookup_user(name: &str) -> Result<Option<u32>> {
Ok(nix::unistd::User::from_name(name)?.map(|user| user.uid.as_raw()))
}
#[cfg(unix)]
fn lookup_group(name: &str) -> Result<Option<u32>> {
Ok(nix::unistd::Group::from_name(name)?.map(|group| group.gid.as_raw()))
}
#[cfg(unix)]
fn set_metadata(path: &Path, owner: Option<&str>, group: Option<&str>, mode: u32) -> Result<()> {
use std::os::unix::fs::PermissionsExt;
let uid = owner
.map(resolve_user)
.transpose()?
.map(nix::unistd::Uid::from_raw);
let gid = group
.map(resolve_group)
.transpose()?
.map(nix::unistd::Gid::from_raw);
nix::unistd::chown(path, uid, gid)?;
fs::set_permissions(path, fs::Permissions::from_mode(mode))?;
Ok(())
}
#[cfg(not(unix))]
fn set_metadata(
_path: &Path,
_owner: Option<&str>,
_group: Option<&str>,
_mode: u32,
) -> Result<()> {
bail!("managed system files are only supported on Unix")
}
fn write_file(
path: &Path,
content: &[u8],
owner: Option<&str>,
group: Option<&str>,
mode: u32,
replace: bool,
) -> Result<()> {
let parent = path
.parent()
.ok_or_else(|| eyre!("managed file has no parent: {}", path.display()))?;
match fs::metadata(parent) {
Ok(metadata) if metadata.is_dir() => {}
Ok(_) => bail!(
"managed file parent is not a directory: {}",
parent.display()
),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
bail!("managed file parent does not exist: {}", parent.display())
}
Err(error) => return Err(error.into()),
}
let mut temporary = tempfile::NamedTempFile::new_in(parent)?;
temporary.write_all(content)?;
set_metadata(temporary.path(), owner, group, mode)?;
temporary.as_file_mut().sync_all()?;
match fs::symlink_metadata(path) {
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
Err(error) => return Err(error.into()),
Ok(metadata) if metadata.file_type().is_file() => {}
Ok(_) if !replace => {
bail!("refusing to replace non-file path: {}", path.display())
}
Ok(metadata) if metadata.file_type().is_dir() => {
fs::remove_dir(path).wrap_err_with(|| {
format!(
"refusing to replace non-empty directory with file: {}",
path.display()
)
})?
}
Ok(_) => fs::remove_file(path)?,
}
temporary
.persist(path)
.map_err(|error| error.error)
.wrap_err_with(|| format!("failed to atomically replace {}", path.display()))?;
fs::File::open(parent)?.sync_all()?;
Ok(())
}
fn remove_file(path: &Path) -> Result<()> {
match fs::symlink_metadata(path) {
Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(err) => Err(err.into()),
Ok(metadata) if metadata.file_type().is_dir() => {
bail!("refusing to remove directory as a file: {}", path.display())
}
Ok(_) => fs::remove_file(path).map_err(Into::into),
}
}
fn create_directory(
path: &Path,
owner: Option<&str>,
group: Option<&str>,
mode: u32,
replace: bool,
) -> Result<()> {
match fs::symlink_metadata(path) {
Err(error)
if matches!(
error.kind(),
std::io::ErrorKind::NotFound | std::io::ErrorKind::NotADirectory
) => {}
Err(error) => {
return Err(error)
.wrap_err_with(|| format!("failed to inspect directory {}", path.display()));
}
Ok(metadata) if metadata.file_type().is_dir() => {}
Ok(_) if !replace => {
bail!("refusing to replace non-directory path: {}", path.display())
}
Ok(_) => {
fs::remove_file(path).wrap_err_with(|| {
format!(
"failed to remove existing path before creating directory {}",
path.display()
)
})?;
}
}
#[cfg(unix)]
{
let directory = open_or_create_directory_tree(path)
.wrap_err_with(|| format!("failed to create directory {}", path.display()))?;
set_directory_metadata(&directory, owner, group, mode)
.wrap_err_with(|| format!("failed to set metadata on directory {}", path.display()))
}
#[cfg(not(unix))]
{
fs::create_dir_all(path)
.wrap_err_with(|| format!("failed to create directory {}", path.display()))?;
set_metadata(path, owner, group, mode)
.wrap_err_with(|| format!("failed to set metadata on directory {}", path.display()))
}
}
#[cfg(unix)]
fn open_or_create_directory_tree(path: &Path) -> Result<std::os::fd::OwnedFd> {
open_or_create_directory_tree_inner(path, 0)
}
#[cfg(unix)]
fn open_or_create_directory_tree_inner(
path: &Path,
followed_symlinks: usize,
) -> Result<std::os::fd::OwnedFd> {
use nix::fcntl::{AtFlags, OFlag, open, openat};
use nix::sys::stat::{Mode, SFlag, fstat, fstatat, mkdirat};
if followed_symlinks > 40 {
bail!(
"too many symbolic links in managed directory {}",
path.display()
);
}
let components = path
.strip_prefix(Path::new("/"))
.wrap_err_with(|| format!("managed directory must be absolute: {}", path.display()))?
.components()
.map(|component| match component {
std::path::Component::Normal(name) => Ok(name.to_os_string()),
_ => bail!("invalid managed directory path: {}", path.display()),
})
.collect::<Result<Vec<_>>>()?;
let flags = OFlag::O_RDONLY | OFlag::O_DIRECTORY | OFlag::O_NOFOLLOW;
let mut directory = open(Path::new("/"), flags, Mode::empty())?;
let mut current = PathBuf::from("/");
for (index, name) in components.iter().enumerate() {
let component_path = current.join(name);
directory = match openat(&directory, name.as_os_str(), flags, Mode::empty()) {
Ok(directory) => directory,
Err(open_error) => {
let metadata = fstatat(&directory, name.as_os_str(), AtFlags::AT_SYMLINK_NOFOLLOW);
if metadata.is_ok_and(|metadata| {
SFlag::from_bits_truncate(metadata.st_mode).contains(SFlag::S_IFLNK)
}) {
let parent = fstat(&directory)?;
if parent.st_uid != 0 || parent.st_mode & 0o022 != 0 {
bail!(
"refusing to follow symlink {} from an untrusted parent directory",
component_path.display()
);
}
let target = nix::fcntl::readlinkat(&directory, name.as_os_str())?;
let mut resolved = if Path::new(&target).is_absolute() {
PathBuf::from(target)
} else {
current.join(target)
};
resolved.extend(components.iter().skip(index + 1));
let resolved = resolved.absolutize()?.to_path_buf();
if resolved == Path::new("/") {
bail!(
"refusing to resolve managed directory {} to the filesystem root",
path.display()
);
}
return open_or_create_directory_tree_inner(&resolved, followed_symlinks + 1);
}
if open_error != nix::errno::Errno::ENOENT {
return Err(open_error).wrap_err_with(|| {
format!(
"failed to open path component {} without following symlinks",
component_path.display()
)
});
}
let created_by_us = match mkdirat(
&directory,
name.as_os_str(),
Mode::from_bits_truncate(0o777),
) {
Ok(()) => true,
Err(nix::errno::Errno::EEXIST) => false,
Err(error) => {
return Err(error).wrap_err_with(|| {
format!(
"failed to create path component {}",
component_path.display()
)
});
}
};
let created = openat(&directory, name.as_os_str(), flags, Mode::empty())
.wrap_err_with(|| {
format!(
"failed to open newly available path component {} without following symlinks",
component_path.display()
)
})?;
let stat = nix::sys::stat::fstat(&created)?;
if stat.st_uid != nix::unistd::geteuid().as_raw() {
if created_by_us {
bail!(
"created path component {} was replaced before it could be opened",
component_path.display()
);
} else {
bail!(
"path component {} was concurrently created by another user",
component_path.display()
);
}
}
created
}
};
current.push(name);
}
Ok(directory)
}
#[cfg(unix)]
fn set_directory_metadata(
directory: &std::os::fd::OwnedFd,
owner: Option<&str>,
group: Option<&str>,
mode: u32,
) -> Result<()> {
let uid = owner
.map(resolve_user)
.transpose()?
.map(nix::unistd::Uid::from_raw);
let gid = group
.map(resolve_group)
.transpose()?
.map(nix::unistd::Gid::from_raw);
nix::unistd::fchown(directory, uid, gid)?;
let platform_mode = [
(0o4000, nix::sys::stat::Mode::S_ISUID),
(0o2000, nix::sys::stat::Mode::S_ISGID),
(0o1000, nix::sys::stat::Mode::S_ISVTX),
(0o0400, nix::sys::stat::Mode::S_IRUSR),
(0o0200, nix::sys::stat::Mode::S_IWUSR),
(0o0100, nix::sys::stat::Mode::S_IXUSR),
(0o0040, nix::sys::stat::Mode::S_IRGRP),
(0o0020, nix::sys::stat::Mode::S_IWGRP),
(0o0010, nix::sys::stat::Mode::S_IXGRP),
(0o0004, nix::sys::stat::Mode::S_IROTH),
(0o0002, nix::sys::stat::Mode::S_IWOTH),
(0o0001, nix::sys::stat::Mode::S_IXOTH),
]
.into_iter()
.filter_map(|(bit, flag)| (mode & bit != 0).then_some(flag))
.fold(nix::sys::stat::Mode::empty(), |mode, flag| mode | flag);
nix::sys::stat::fchmod(directory, platform_mode)?;
Ok(())
}
fn remove_directory(path: &Path, recursive: bool) -> Result<()> {
match fs::symlink_metadata(path) {
Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(err) => Err(err.into()),
Ok(metadata) if !metadata.file_type().is_dir() => {
bail!("refusing to remove non-directory path: {}", path.display())
}
Ok(_) if recursive => fs::remove_dir_all(path).map_err(Into::into),
Ok(_) => fs::remove_dir(path).map_err(Into::into),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn file(path: &str, state: ManagedState) -> ManagedFileRequest {
ManagedFileRequest {
path: PathBuf::from(path),
content: (state == ManagedState::Present).then(|| "content".to_string()),
owner: None,
group: None,
mode: 0o644,
state,
replace: false,
notify: vec![],
origin: ResourceOrigin {
config: PathBuf::from("/mise.toml"),
config_root: PathBuf::from("/"),
environment: vec![],
source: None,
},
inspection: None,
}
}
fn directory(path: &str, state: ManagedState) -> ManagedDirectoryRequest {
ManagedDirectoryRequest {
path: PathBuf::from(path),
owner: None,
group: None,
mode: 0o755,
state,
recursive: false,
replace: false,
notify: vec![],
origin: ResourceOrigin {
config: PathBuf::from("/mise.toml"),
config_root: PathBuf::from("/"),
environment: vec![],
source: None,
},
inspection: None,
}
}
#[test]
fn rejects_relative_and_root_targets() {
assert!(absolute_target("etc/example").is_err());
assert!(absolute_target("/").is_err());
assert!(absolute_target("/tmp/..").is_err());
assert!(absolute_target("/tmp/../..").is_err());
}
#[test]
fn parses_octal_modes() {
assert_eq!(parse_mode(Some("0600"), 0).unwrap(), 0o600);
assert_eq!(parse_mode(Some("0o1750"), 0).unwrap(), 0o1750);
assert!(parse_mode(Some("888"), 0).is_err());
}
#[test]
fn detects_permission_errors_through_context() {
let io_error = Err::<(), _>(std::io::Error::from(std::io::ErrorKind::PermissionDenied))
.wrap_err("wrapped permission error")
.unwrap_err();
assert!(is_permission_denied(&io_error));
#[cfg(unix)]
{
let nix_error: eyre::Report = nix::errno::Errno::EPERM.into();
assert!(is_permission_denied(&nix_error));
}
let other_error: eyre::Report = std::io::Error::from(std::io::ErrorKind::NotFound).into();
assert!(!is_permission_denied(&other_error));
}
#[test]
fn elevates_before_destructive_composite_actions() {
let temp = tempfile::tempdir().unwrap();
let file_path = temp.path().join("file");
fs::write(&file_path, "content").unwrap();
let directory_path = temp.path().join("directory");
fs::create_dir(&directory_path).unwrap();
let replace_file_with_directory = PrivilegedAction::CreateDirectory {
path: file_path,
owner: None,
group: None,
mode: 0o755,
replace: true,
};
assert!(
replace_file_with_directory
.requires_preemptive_elevation()
.unwrap()
);
let replace_directory_with_file = PrivilegedAction::WriteFile {
path: directory_path,
content: "content".to_string(),
owner: None,
group: None,
mode: 0o644,
replace: true,
};
assert!(
replace_directory_with_file
.requires_preemptive_elevation()
.unwrap()
);
let recursive_removal = PrivilegedAction::RemoveDirectory {
path: temp.path().join("tree"),
recursive: true,
};
assert!(recursive_removal.requires_preemptive_elevation().unwrap());
let ordinary_write = PrivilegedAction::WriteFile {
path: temp.path().join("ordinary"),
content: "content".to_string(),
owner: None,
group: None,
mode: 0o644,
replace: false,
};
assert!(!ordinary_write.requires_preemptive_elevation().unwrap());
}
#[cfg(unix)]
#[test]
fn permission_failure_preserves_remaining_action_order() {
use std::os::unix::fs::PermissionsExt;
if nix::unistd::geteuid().is_root() {
return;
}
let temp = tempfile::tempdir().unwrap();
let first = temp.path().join("first");
let blocked_parent = temp.path().join("blocked");
let blocked = blocked_parent.join("second");
let remaining = temp.path().join("third");
fs::create_dir(&blocked_parent).unwrap();
fs::set_permissions(&blocked_parent, fs::Permissions::from_mode(0o555)).unwrap();
let write = |path: PathBuf| PrivilegedAction::WriteFile {
path,
content: "content".to_string(),
owner: None,
group: None,
mode: 0o644,
replace: false,
};
let pending = PrivilegedPlan {
actions: vec![
write(first.clone()),
write(blocked.clone()),
write(remaining.clone()),
],
}
.apply_until_elevation_required()
.unwrap();
fs::set_permissions(&blocked_parent, fs::Permissions::from_mode(0o755)).unwrap();
assert!(first.is_file());
assert!(!blocked.exists());
assert!(!remaining.exists());
assert_eq!(pending.actions.len(), 2);
assert!(matches!(
&pending.actions[0],
PrivilegedAction::WriteFile { path, .. } if path == &blocked
));
assert!(matches!(
&pending.actions[1],
PrivilegedAction::WriteFile { path, .. } if path == &remaining
));
}
#[cfg(unix)]
#[test]
fn atomically_writes_and_updates_files() {
let temp = tempfile::tempdir().unwrap();
let path = temp.path().join("config");
write_file(&path, b"first", None, None, 0o640, false).unwrap();
write_file(&path, b"second", None, None, 0o600, false).unwrap();
assert_eq!(fs::read_to_string(&path).unwrap(), "second");
use std::os::unix::fs::PermissionsExt;
assert_eq!(
fs::metadata(path).unwrap().permissions().mode() & 0o777,
0o600
);
}
#[test]
fn recursive_removal_must_be_explicit() {
let temp = tempfile::tempdir().unwrap();
let directory = temp.path().join("directory");
fs::create_dir(&directory).unwrap();
fs::write(directory.join("child"), "content").unwrap();
assert!(remove_directory(&directory, false).is_err());
remove_directory(&directory, true).unwrap();
assert!(!directory.exists());
}
#[test]
fn type_replacement_must_be_explicit() {
let mut present_file = file("/opt/example", ManagedState::Present);
present_file.inspection = Some(PathInspection::Present {
kind: ManagedPathKind::Directory,
current: "directory".to_string(),
metadata_matches: false,
content_matches: None,
});
assert_eq!(present_file.plan().unwrap().action, ResourceAction::Unknown);
present_file.replace = true;
assert_eq!(present_file.plan().unwrap().action, ResourceAction::Update);
let mut present_directory = directory("/opt/example", ManagedState::Present);
present_directory.inspection = Some(PathInspection::Present {
kind: ManagedPathKind::File,
current: "file".to_string(),
metadata_matches: false,
content_matches: None,
});
assert_eq!(
present_directory.plan().unwrap().action,
ResourceAction::Unknown
);
present_directory.replace = true;
assert_eq!(
present_directory.plan().unwrap().action,
ResourceAction::Update
);
let mut absent_file = file("/opt/example", ManagedState::Absent);
absent_file.inspection = Some(PathInspection::Present {
kind: ManagedPathKind::Directory,
current: "directory".to_string(),
metadata_matches: true,
content_matches: None,
});
assert_eq!(absent_file.plan().unwrap().action, ResourceAction::Unknown);
assert!(absent_file.operation().is_err());
let mut absent_directory = directory("/opt/example", ManagedState::Absent);
absent_directory.inspection = Some(PathInspection::Present {
kind: ManagedPathKind::File,
current: "file".to_string(),
metadata_matches: true,
content_matches: None,
});
assert_eq!(
absent_directory.plan().unwrap().action,
ResourceAction::Unknown
);
assert!(absent_directory.operation().is_err());
}
#[test]
fn only_actionable_file_changes_notify_services() {
let mut changed = file("/opt/changed", ManagedState::Present);
changed.notify.push("example".to_string());
changed.inspection = Some(PathInspection::Missing);
let mut unsafe_change = file("/opt/unsafe", ManagedState::Present);
unsafe_change.notify.push("ignored".to_string());
unsafe_change.inspection = Some(PathInspection::Present {
kind: ManagedPathKind::Directory,
current: "directory".to_string(),
metadata_matches: false,
content_matches: None,
});
let notifications = pending_notifications(&[changed, unsafe_change], &[]).unwrap();
assert!(notifications.contains("example"));
assert!(!notifications.contains("ignored"));
}
#[cfg(unix)]
#[test]
fn replaces_wrong_types_and_creates_missing_parents() {
let temp = tempfile::tempdir().unwrap();
let file_path = temp.path().join("file");
fs::create_dir(&file_path).unwrap();
assert!(write_file(&file_path, b"content", None, None, 0o600, false).is_err());
write_file(&file_path, b"content", None, None, 0o600, true).unwrap();
assert_eq!(fs::read_to_string(&file_path).unwrap(), "content");
let directory_path = temp.path().join("directory");
fs::write(&directory_path, "content").unwrap();
assert!(create_directory(&directory_path, None, None, 0o700, false).is_err());
create_directory(&directory_path, None, None, 0o700, true).unwrap();
assert!(directory_path.is_dir());
let undeclared_parent = temp.path().join("undeclared");
let nested = undeclared_parent.join("nested");
create_directory(&nested, None, None, 0o700, false).unwrap();
assert!(undeclared_parent.is_dir());
assert!(nested.is_dir());
use std::os::unix::fs::PermissionsExt;
assert_eq!(
fs::metadata(nested).unwrap().permissions().mode() & 0o777,
0o700
);
let blocking_file = temp.path().join("blocking-file");
fs::write(&blocking_file, "content").unwrap();
let blocked = blocking_file.join("nested");
let error = create_directory(&blocked, None, None, 0o755, false).unwrap_err();
assert!(
format!("{error:#}")
.contains(&format!("failed to create directory {}", blocked.display())),
"unexpected error: {error:#}"
);
if !nix::unistd::geteuid().is_root() {
let external = tempfile::tempdir().unwrap();
let symlink_parent = temp.path().join("symlink-parent");
std::os::unix::fs::symlink(external.path(), &symlink_parent).unwrap();
let escaped = symlink_parent.join("nested");
let error = create_directory(&escaped, None, None, 0o755, false).unwrap_err();
assert!(
format!("{error:#}").contains("refusing to follow symlink"),
"unexpected error: {error:#}"
);
assert!(!external.path().join("nested").exists());
}
}
#[test]
fn rejects_a_path_declared_as_both_file_and_directory() {
assert!(
validate_requests(
&[file("/opt/example", ManagedState::Present)],
&[directory("/opt/example", ManagedState::Present)],
)
.is_err()
);
}
#[test]
fn rejects_present_resources_below_an_absent_managed_ancestor() {
assert!(
validate_requests(
&[file("/opt/example/nested/config", ManagedState::Present)],
&[directory("/opt/example", ManagedState::Absent)],
)
.is_err()
);
assert!(
validate_requests(
&[],
&[
directory("/opt/example", ManagedState::Absent),
directory("/opt/example/nested", ManagedState::Present),
],
)
.is_err()
);
}
#[test]
fn clears_only_ignored_account_principals() {
let mut files = vec![file("/opt/example/config", ManagedState::Present)];
files[0].owner = Some("service-user".to_string());
files[0].group = Some("local-group".to_string());
let mut directories = vec![directory("/opt/example", ManagedState::Present)];
directories[0].owner = Some("local-user".to_string());
directories[0].group = Some("service-group".to_string());
clear_ignored_principals(
&mut files,
&mut directories,
&std::collections::HashSet::from(["service-user".to_string()]),
&std::collections::HashSet::from(["service-group".to_string()]),
);
assert_eq!(files[0].owner, None);
assert_eq!(files[0].group.as_deref(), Some("local-group"));
assert_eq!(directories[0].owner.as_deref(), Some("local-user"));
assert_eq!(directories[0].group, None);
}
}