use std::io::Write as _;
use std::path::{Path, PathBuf};
use tracing::{info, warn};
use crate::error::ServerError;
use crate::state::{
BOOT_REQUIRED_FIELD_DEFAULTS, NOT_UPGRADE_HEALABLE, RequiredFieldDefault, boot_required_probe,
};
use super::{CliOverrides, ServerConfig, aion_home, env, file, home};
const HEAL_VERSION: &str = env!("CARGO_PKG_VERSION");
#[derive(Clone, Debug, Eq, PartialEq)]
pub(crate) struct InsertedField {
pub(crate) path: &'static str,
pub(crate) value: &'static str,
}
#[derive(Debug, Default)]
pub(crate) struct HealOutcome {
pub(crate) config_path: Option<PathBuf>,
pub(crate) backup_path: Option<PathBuf>,
pub(crate) inserted: Vec<InsertedField>,
}
pub(crate) fn heal_boot_config(cli: &CliOverrides) -> Result<HealOutcome, ServerError> {
let aion_home = aion_home()?;
let working_dir = std::env::current_dir().map_err(|source| ServerError::Config {
message: format!("failed to resolve the current directory for config discovery: {source}"),
})?;
let explicit = cli
.config_path
.as_deref()
.map(home::expand_tilde)
.transpose()?;
let env_vars: Vec<(String, String)> = std::env::vars().collect();
heal_discovered(
explicit.as_deref(),
&aion_home.path,
&working_dir,
&env_vars,
)
}
pub(super) fn heal_discovered(
explicit: Option<&Path>,
aion_home: &Path,
working_dir: &Path,
env_vars: &[(String, String)],
) -> Result<HealOutcome, ServerError> {
match file::discover_path(explicit, aion_home, working_dir)? {
Some((path, _source)) => {
let outcome = heal_config_file(&path, env_vars)?;
log_outcome(&outcome, env_vars);
Ok(outcome)
}
None => Ok(HealOutcome::default()),
}
}
pub(super) fn heal_config_file(
path: &Path,
env_vars: &[(String, String)],
) -> Result<HealOutcome, ServerError> {
let Some(target) = resolve_link_target(path)? else {
return Ok(HealOutcome::default());
};
let Some((original, text)) = readable_utf8(&target) else {
return Ok(HealOutcome::default());
};
let Ok(file_view) = ServerConfig::parse_unresolved(&original) else {
return Ok(HealOutcome::default());
};
let mut resolved_view = file_view.clone();
if env::overlay_vars(&mut resolved_view, env_vars.iter().cloned()).is_err() {
return Ok(HealOutcome::default());
}
let mut working = file_view;
working.store.backend = resolved_view.store.backend;
if boot_required_probe(&working).is_ok() {
return Ok(HealOutcome::default());
}
let mut document: toml_edit::DocumentMut = match text.parse() {
Ok(document) => document,
Err(error) => {
return Err(heal_refusal(
&target,
&absent_fields(&working),
&format!("the file could not be re-parsed for comment-preserving editing: {error}"),
));
}
};
let inserted = plan_and_insert(&target, &mut document, &working)?;
if inserted.is_empty() {
return Ok(HealOutcome::default());
}
let healed = restore_line_endings(&text, document.to_string());
let backup_path = write_backup(&target, &original)
.map_err(|detail| heal_refusal(&target, &field_names(&inserted), &detail))?;
write_replace(&target, healed.as_bytes())
.map_err(|detail| heal_refusal(&target, &field_names(&inserted), &detail))?;
Ok(HealOutcome {
config_path: Some(target),
backup_path: Some(backup_path),
inserted,
})
}
fn resolve_link_target(path: &Path) -> Result<Option<PathBuf>, ServerError> {
let Ok(metadata) = std::fs::symlink_metadata(path) else {
return Ok(None);
};
match std::fs::canonicalize(path) {
Ok(target) => {
if metadata.file_type().is_symlink() {
info!(
link = %path.display(),
target = %target.display(),
"config heal resolved the discovered config link to its target"
);
}
Ok(Some(target))
}
Err(error) => {
let named_target = std::fs::read_link(path)
.map_or_else(|_| "unresolvable".to_owned(), |t| t.display().to_string());
Err(ServerError::Config {
message: format!(
"config heal cannot resolve `{}`: {error}. The path is a link whose \
target (`{named_target}`) cannot be reached, so neither the heal nor \
the load that follows can act on it; fix or remove the link",
path.display()
),
})
}
}
}
fn readable_utf8(path: &Path) -> Option<(Vec<u8>, String)> {
let bytes = std::fs::read(path).ok()?;
let text = std::str::from_utf8(&bytes).ok()?.to_owned();
Some((bytes, text))
}
fn restore_line_endings(original: &str, healed: String) -> String {
let crlf = original.matches("\r\n").count();
let lf_only = original.matches('\n').count() - crlf;
if crlf > lf_only {
healed.replace("\r\n", "\n").replace('\n', "\r\n")
} else {
healed
}
}
fn plan_and_insert(
path: &Path,
document: &mut toml_edit::DocumentMut,
applicable_view: &ServerConfig,
) -> Result<Vec<InsertedField>, ServerError> {
let mut working = applicable_view.clone();
let mut stepped_over: Vec<&'static str> = Vec::new();
let mut inserted: Vec<InsertedField> = Vec::new();
while let Err(ServerError::Config { message }) = boot_required_probe(&working) {
let Some(entry) = BOOT_REQUIRED_FIELD_DEFAULTS
.iter()
.find(|entry| entry.requirement_message == message)
else {
if let Some((_, reason)) = NOT_UPGRADE_HEALABLE
.iter()
.find(|(unhealable, _)| *unhealable == message)
{
info!(
reason,
"config heal: the refused requirement is declared \
not-upgrade-healable; the boot refuses with the \
requirement's own message"
);
}
break;
};
if stepped_over.contains(&entry.path) {
break;
}
let absent = (entry.is_absent)(&working);
(entry.satisfy_in_memory)(&mut working);
stepped_over.push(entry.path);
if absent {
insert_field(document, entry)
.map_err(|detail| heal_refusal(path, &[entry.path.to_owned()], &detail))?;
inserted.push(InsertedField {
path: entry.path,
value: entry.default_toml,
});
}
}
if !inserted.is_empty() {
let edited = document.to_string();
let reparsed = ServerConfig::parse_unresolved(edited.as_bytes()).map_err(|_| {
heal_refusal(
path,
&field_names(&inserted),
"the healed document no longer parses as a server config; \
nothing was written",
)
})?;
for field in &inserted {
let Some(entry) = BOOT_REQUIRED_FIELD_DEFAULTS
.iter()
.find(|entry| entry.path == field.path)
else {
continue;
};
if (entry.is_absent)(&reparsed) {
return Err(heal_refusal(
path,
&field_names(&inserted),
&format!(
"{} was inserted but the loader does not see it in the \
healed document; nothing was written",
field.path
),
));
}
}
}
Ok(inserted)
}
fn insert_field(
document: &mut toml_edit::DocumentMut,
entry: &RequiredFieldDefault,
) -> Result<(), String> {
let value: toml_edit::Value = entry.default_toml.parse().map_err(|error| {
format!(
"the declared default `{}` for {} is not a TOML value: {error}",
entry.default_toml, entry.path
)
})?;
let fresh_section = document.get(entry.section).is_none();
let item = document
.entry(entry.section)
.or_insert_with(toml_edit::table);
match item {
toml_edit::Item::Table(table) => {
if fresh_section {
table.decor_mut().set_prefix("\n");
}
let mut spaced = value;
spaced.decor_mut().set_prefix(" ");
table.insert(entry.key, toml_edit::Item::Value(spaced));
let Some(mut key) = table.key_mut(entry.key) else {
return Err(format!(
"{} was inserted into [{}] but its key cannot be decorated",
entry.path, entry.section
));
};
key.leaf_decor_mut().set_prefix(comment_block(entry));
Ok(())
}
toml_edit::Item::Value(toml_edit::Value::InlineTable(inline)) => {
let previous_last = inline.iter().last().map(|(key, _)| key.to_owned());
if let Some(previous_last) = previous_last
&& let Some(previous) = inline.get_mut(&previous_last)
{
previous.decor_mut().set_suffix("");
}
inline.insert(entry.key, value);
if let Some(mut key) = inline.key_mut(entry.key) {
key.leaf_decor_mut().set_prefix(" ");
key.leaf_decor_mut().set_suffix(" ");
}
if let Some(spaced) = inline.get_mut(entry.key) {
spaced.decor_mut().set_prefix(" ");
spaced.decor_mut().set_suffix(" ");
}
Ok(())
}
other => Err(format!(
"`{}` exists but is not a table (found {}), so {} cannot be inserted",
entry.section,
other.type_name(),
entry.path
)),
}
}
fn comment_block(entry: &RequiredFieldDefault) -> String {
let mut block = String::new();
for line in entry.teaching_comment {
block.push_str("# ");
block.push_str(line);
block.push('\n');
}
block.push_str("# added by aion ");
block.push_str(HEAL_VERSION);
block.push_str(" config heal\n");
block
}
fn write_backup(path: &Path, original: &[u8]) -> Result<PathBuf, String> {
let file_name = path
.file_name()
.and_then(|name| name.to_str())
.ok_or_else(|| format!("`{}` has no file name to back up beside", path.display()))?;
let permissions = std::fs::metadata(path)
.map_err(|error| format!("cannot read `{}` metadata: {error}", path.display()))?
.permissions();
let mut attempt: u64 = 1;
loop {
let candidate_name = if attempt == 1 {
format!("{file_name}.pre-{HEAL_VERSION}")
} else {
format!("{file_name}.pre-{HEAL_VERSION}.{attempt}")
};
let candidate = path.with_file_name(&candidate_name);
match std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&candidate)
{
Ok(mut backup) => {
backup
.write_all(original)
.and_then(|()| backup.sync_all())
.map_err(|error| {
format!("cannot write backup `{}`: {error}", candidate.display())
})?;
std::fs::set_permissions(&candidate, permissions).map_err(|error| {
format!(
"cannot set backup `{}` permissions: {error}",
candidate.display()
)
})?;
sync_parent_dir(&candidate)?;
return Ok(candidate);
}
Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {
attempt = attempt.saturating_add(1);
}
Err(error) => {
return Err(format!(
"cannot create backup `{}`: {error}",
candidate.display()
));
}
}
}
}
fn write_replace(path: &Path, healed: &[u8]) -> Result<(), String> {
let file_name = path
.file_name()
.and_then(|name| name.to_str())
.ok_or_else(|| format!("`{}` has no file name to replace", path.display()))?;
let permissions = std::fs::metadata(path)
.map_err(|error| format!("cannot read `{}` metadata: {error}", path.display()))?
.permissions();
let (staging, mut file) = claim_staging_file(path, file_name)?;
let write_staged = |file: &mut std::fs::File| -> Result<(), String> {
file.write_all(healed)
.and_then(|()| file.sync_all())
.map_err(|error| format!("cannot write `{}`: {error}", staging.display()))?;
std::fs::set_permissions(&staging, permissions.clone())
.map_err(|error| format!("cannot set `{}` permissions: {error}", staging.display()))?;
std::fs::rename(&staging, path).map_err(|error| {
format!(
"cannot move `{}` over `{}`: {error}",
staging.display(),
path.display()
)
})?;
sync_parent_dir(path)
};
let result = write_staged(&mut file);
drop(file);
result.map_err(|detail| match std::fs::remove_file(&staging) {
Ok(()) => detail,
Err(cleanup) if cleanup.kind() == std::io::ErrorKind::NotFound => detail,
Err(cleanup) => format!(
"{detail}; the staging file `{}` also could not be removed: {cleanup}",
staging.display()
),
})
}
fn claim_staging_file(path: &Path, file_name: &str) -> Result<(PathBuf, std::fs::File), String> {
let pid = std::process::id();
let mut attempt: u64 = 1;
loop {
let candidate = path.with_file_name(format!("{file_name}.heal-staging-{pid}.{attempt}"));
match std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&candidate)
{
Ok(file) => return Ok((candidate, file)),
Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {
attempt = attempt.saturating_add(1);
}
Err(error) => {
return Err(format!(
"cannot create staging file `{}`: {error}",
candidate.display()
));
}
}
}
}
#[cfg(unix)]
fn sync_parent_dir(path: &Path) -> Result<(), String> {
let parent = path
.parent()
.ok_or_else(|| format!("`{}` has no parent directory to fsync", path.display()))?;
std::fs::File::open(parent)
.and_then(|directory| directory.sync_all())
.map_err(|error| format!("cannot fsync directory `{}`: {error}", parent.display()))
}
#[cfg(not(unix))]
fn sync_parent_dir(_path: &Path) -> Result<(), String> {
Ok(())
}
fn heal_refusal(path: &Path, missing: &[String], detail: &str) -> ServerError {
let fields = if missing.is_empty() {
"unknown (the probe refused before any field was planned)".to_owned()
} else {
missing.join(", ")
};
ServerError::Config {
message: format!(
"config heal failed for `{}`: {detail}. The config is missing required \
fields ({fields}) and the server does not boot on an unhealed config; \
fix the write path, or add the fields by hand",
path.display()
),
}
}
fn absent_fields(config: &ServerConfig) -> Vec<String> {
BOOT_REQUIRED_FIELD_DEFAULTS
.iter()
.filter(|entry| (entry.is_absent)(config))
.map(|entry| entry.path.to_owned())
.collect()
}
fn field_names(inserted: &[InsertedField]) -> Vec<String> {
inserted.iter().map(|field| field.path.to_owned()).collect()
}
fn log_outcome(outcome: &HealOutcome, env_vars: &[(String, String)]) {
if outcome.inserted.is_empty() {
return;
}
let config = outcome
.config_path
.as_deref()
.map_or_else(String::new, |path| path.display().to_string());
for field in &outcome.inserted {
info!(
field = field.path,
value = field.value,
config = %config,
"config heal inserted a missing required field with its declared default"
);
let Some(entry) = BOOT_REQUIRED_FIELD_DEFAULTS
.iter()
.find(|entry| entry.path == field.path)
else {
continue;
};
if let Some((variable, env_value)) = env_vars
.iter()
.find(|(name, _)| name.as_str() == entry.env_override)
{
warn!(
field = field.path,
override_variable = %variable,
file_value = field.value,
effective_runtime_value = %env_value,
"config heal minted a field whose environment override rules at \
runtime: the file now carries the declared default, but this \
server runs the override's value"
);
}
}
let backup = outcome
.backup_path
.as_deref()
.map_or_else(String::new, |path| path.display().to_string());
info!(
config = %config,
backup = %backup,
inserted_field_count = outcome.inserted.len(),
"config heal wrote the healed config; the previous file is preserved beside it"
);
}
#[cfg(test)]
#[path = "heal_tests.rs"]
mod tests;