use std::fs::{self, File, OpenOptions};
use std::io::{Read, Write};
use std::os::unix::fs::{MetadataExt, OpenOptionsExt, PermissionsExt};
use std::path::Path;
use std::time::SystemTime;
use rustix::fs::{Access, AtFlags, CWD, accessat};
use serde_json::Value;
pub(super) struct Plan {
pub bytes: Vec<u8>,
pub names: Vec<String>,
}
pub(super) fn plan(path: &Path, original: &[u8]) -> Result<Plan, String> {
plan_bytes(original)
.map_err(|error| format!("door_config_upgrade_failed: {}: {error}", path.display()))
}
fn plan_bytes(original: &[u8]) -> Result<Plan, Box<dyn std::error::Error>> {
let Ok(mut value) = serde_json::from_slice::<Value>(original) else {
return Ok(Plan {
bytes: original.to_vec(),
names: Vec::new(),
});
};
let mut names = Vec::new();
if let Some(children) = value.get_mut("children").and_then(Value::as_array_mut) {
for child in children {
if let Some(child) = child.as_object_mut()
&& !child.contains_key("give_up_after")
{
let name = child
.get("name")
.and_then(Value::as_str)
.ok_or("a child missing give_up_after must name itself")?
.to_owned();
child.insert("give_up_after".to_owned(), Value::from(3));
names.push(name);
}
}
}
let bytes = if names.is_empty() {
original.to_vec()
} else {
serde_json::to_vec_pretty(&value)?
};
Ok(Plan { bytes, names })
}
pub(super) fn apply(path: &Path, original: &[u8], plan: &Plan) -> Result<(), String> {
check_apply(path, plan)?;
if plan.names.is_empty() {
return Ok(());
}
let mode = metadata(path)?.permissions().mode() & 0o7777;
apply_file(path, original, plan, mode)
.map_err(|error| format!("door_config_upgrade_failed: {}: {error}", path.display()))
}
pub(super) fn check_apply(path: &Path, plan: &Plan) -> Result<(), String> {
if plan.names.is_empty() {
return Ok(());
}
let writable = || -> Result<(), Box<dyn std::error::Error>> {
let parent = path
.parent()
.ok_or("configuration has no parent directory")?;
let folder = fs::metadata(parent)?;
if !folder.is_dir() {
return Err("configuration parent must be a folder".into());
}
accessat(
CWD,
parent,
Access::WRITE_OK | Access::EXEC_OK,
AtFlags::EACCESS,
)?;
let user = effective_uid();
if folder.mode() & 0o1000 != 0 && metadata(path)?.uid() != user && folder.uid() != user {
return Err("sticky configuration folder forbids replacing another user's file".into());
}
Ok(())
};
writable().map_err(|error| format!("door_config_upgrade_failed: {}: {error}", path.display()))
}
fn effective_uid() -> u32 {
#[cfg(test)]
{
permission_tests::EFFECTIVE_UID.with(std::cell::Cell::get)
}
#[cfg(not(test))]
{
rustix::process::geteuid().as_raw()
}
}
fn apply_file(
path: &Path,
original: &[u8],
plan: &Plan,
mode: u32,
) -> Result<(), Box<dyn std::error::Error>> {
let parent = path
.parent()
.ok_or("configuration has no parent directory")?;
let filename = path
.file_name()
.ok_or("configuration has no file name")?
.to_string_lossy();
let stamp =
chrono::DateTime::<chrono::Utc>::from(SystemTime::now()).format("%Y%m%dT%H%M%S%.9fZ");
let backup = parent.join(format!("{filename}.before-give-up-after-{stamp}"));
let temporary = parent.join(format!(".{filename}.give-up-after-{stamp}.tmp"));
write_new(&backup, original, 0o600)?;
let result = (|| -> Result<(), Box<dyn std::error::Error>> {
write_new(&temporary, &plan.bytes, mode)?;
if fs::read(&temporary)? != plan.bytes {
return Err("written configuration did not read back byte-identical".into());
}
if read_source(path)? != original {
return Err("configuration changed during upgrade; it was left unchanged".into());
}
sync_file(&File::open(parent)?)?;
fs::rename(&temporary, path)?;
sync_file(&File::open(parent)?)?;
Ok(())
})();
if result.is_err()
&& temporary.exists()
&& let Err(error) = fs::remove_file(&temporary)
{
return Err(format!(
"{}; temporary cleanup failed: {error}",
result.err().ok_or("upgrade error missing")?
)
.into());
}
result?;
eprintln!(
"door_config_upgraded: give_up_after=3 written for {}; a child that exits three times in a row before printing ready is failing at its configuration or its store, not crashing once in passing; the file as it stood is kept at {}",
plan.names.join(", "),
backup.display()
);
Ok(())
}
fn write_new(path: &Path, bytes: &[u8], mode: u32) -> std::io::Result<()> {
let mut file = OpenOptions::new()
.write(true)
.create_new(true)
.mode(mode)
.open(path)?;
file.write_all(bytes)?;
file.set_permissions(fs::Permissions::from_mode(mode))?;
sync_file(&file)
}
fn sync_file(file: &File) -> std::io::Result<()> {
file.sync_all()
}
pub(super) fn check_path(path: &Path) -> Result<(), String> {
metadata(path).map(|_| ())
}
fn metadata(path: &Path) -> Result<fs::Metadata, String> {
let metadata = fs::symlink_metadata(path)
.map_err(|error| format!("door_config_upgrade_failed: {}: {error}", path.display()))?;
if metadata.file_type().is_symlink() {
return Err(format!(
"door_config_upgrade_failed: configuration is a symlink: {}",
path.display()
));
}
Ok(metadata)
}
pub(super) fn open_source(path: &Path) -> Result<File, String> {
check_path(path)?;
let flags = i32::try_from(rustix::fs::OFlags::NOFOLLOW.bits())
.map_err(|error| format!("door_config_upgrade_failed: {error}"))?;
OpenOptions::new()
.read(true)
.custom_flags(flags)
.open(path)
.map_err(|error| format!("door_config_upgrade_failed: {}: {error}", path.display()))
}
fn read_source(path: &Path) -> Result<Vec<u8>, String> {
let mut bytes = Vec::new();
open_source(path)?
.read_to_end(&mut bytes)
.map_err(|error| format!("door_config_upgrade_failed: {}: {error}", path.display()))?;
Ok(bytes)
}
#[cfg(test)]
#[path = "config_upgrade_permission_tests.rs"]
mod permission_tests;