prns-config 0.3.4

Config discovery, parsing, and reconciliation for the Personal Reticulum daemon
Documentation
use std::fmt;
use std::fs;
use std::io::{self, Write};
use std::path::{Path, PathBuf};

use tempfile::NamedTempFile;

use crate::configobj::{ConfigDocument, ConfigError};

use super::document::EditedConfig;

#[derive(Debug, Clone)]
pub struct ConfigFile {
    path: PathBuf,
    original: Option<Vec<u8>>,
    document: ConfigDocument,
}

impl ConfigFile {
    pub fn load(path: impl Into<PathBuf>, fallback: &str) -> Result<Self, ConfigFileError> {
        let path = path.into();
        let (original, source) = match fs::read(&path) {
            Ok(bytes) => {
                let source = String::from_utf8(bytes.clone()).map_err(ConfigFileError::Encoding)?;
                (Some(bytes), source)
            }
            Err(error) if error.kind() == io::ErrorKind::NotFound => (None, fallback.to_string()),
            Err(source) => {
                return Err(ConfigFileError::Io {
                    operation: ConfigFileOperation::Read,
                    source,
                })
            }
        };
        let document = ConfigDocument::parse(&source).map_err(ConfigFileError::Syntax)?;
        Ok(Self {
            path,
            original,
            document,
        })
    }

    pub fn path(&self) -> &Path {
        &self.path
    }

    pub fn document(&self) -> &ConfigDocument {
        &self.document
    }

    pub fn is_materialized(&self) -> bool {
        self.original.is_some()
    }

    pub fn write(&self, edited: &EditedConfig) -> Result<ConfigWriteReceipt, ConfigFileError> {
        if edited.original() != self.document.source() {
            return Err(ConfigFileError::CandidateSourceMismatch);
        }
        let parent = self.path.parent().ok_or(ConfigFileError::MissingParent)?;
        fs::create_dir_all(parent).map_err(|source| ConfigFileError::Io {
            operation: ConfigFileOperation::CreateDirectory,
            source,
        })?;
        let current = match fs::read(&self.path) {
            Ok(bytes) => Some(bytes),
            Err(error) if error.kind() == io::ErrorKind::NotFound => None,
            Err(source) => {
                return Err(ConfigFileError::Io {
                    operation: ConfigFileOperation::ReadBeforeWrite,
                    source,
                })
            }
        };
        if current != self.original {
            return Err(ConfigFileError::ConcurrentModification);
        }
        let permissions = current
            .as_ref()
            .and_then(|_| fs::metadata(&self.path).ok())
            .map(|metadata| metadata.permissions());
        let backup = match current.as_ref() {
            Some(bytes) => {
                let backup = backup_path(&self.path);
                atomic_write(&backup, bytes, permissions.clone())?;
                Some(backup)
            }
            None => None,
        };
        let installed = edited.candidate().as_bytes().to_vec();
        atomic_write(&self.path, &installed, permissions)?;
        Ok(ConfigWriteReceipt {
            path: self.path.clone(),
            backup,
            previous: current,
            installed,
        })
    }
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ConfigWriteReceipt {
    path: PathBuf,
    backup: Option<PathBuf>,
    previous: Option<Vec<u8>>,
    installed: Vec<u8>,
}

impl ConfigWriteReceipt {
    pub fn path(&self) -> &Path {
        &self.path
    }

    pub fn backup(&self) -> Option<&Path> {
        self.backup.as_deref()
    }

    pub const fn created(&self) -> bool {
        self.previous.is_none()
    }

    pub fn rollback(self) -> Result<(), ConfigFileError> {
        let current = match fs::read(&self.path) {
            Ok(bytes) => Some(bytes),
            Err(error) if error.kind() == io::ErrorKind::NotFound => None,
            Err(source) => {
                return Err(ConfigFileError::Io {
                    operation: ConfigFileOperation::ReadBeforeRollback,
                    source,
                })
            }
        };
        if current.as_deref() != Some(self.installed.as_slice()) {
            return Err(ConfigFileError::ConcurrentModification);
        }
        let parent = self.path.parent().ok_or(ConfigFileError::MissingParent)?;
        match self.previous {
            Some(previous) => {
                let permissions = fs::metadata(&self.path)
                    .ok()
                    .map(|metadata| metadata.permissions());
                atomic_write(&self.path, &previous, permissions)
            }
            None => {
                fs::remove_file(&self.path).map_err(|source| ConfigFileError::Io {
                    operation: ConfigFileOperation::Remove,
                    source,
                })?;
                sync_parent(parent)
            }
        }
    }
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ConfigFileOperation {
    Read,
    CreateDirectory,
    ReadBeforeWrite,
    ReadBeforeRollback,
    CreateTemporary,
    SetPermissions,
    WriteTemporary,
    Persist,
    Remove,
    SyncDirectory,
}

impl fmt::Display for ConfigFileOperation {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        formatter.write_str(match self {
            Self::Read => "read configuration",
            Self::CreateDirectory => "create configuration directory",
            Self::ReadBeforeWrite => "re-read configuration before writing",
            Self::ReadBeforeRollback => "re-read configuration before restoring it",
            Self::CreateTemporary => "create temporary configuration",
            Self::SetPermissions => "set configuration permissions",
            Self::WriteTemporary => "write temporary configuration",
            Self::Persist => "replace configuration",
            Self::Remove => "remove newly created configuration",
            Self::SyncDirectory => "sync configuration directory",
        })
    }
}

#[derive(Debug)]
pub enum ConfigFileError {
    Io {
        operation: ConfigFileOperation,
        source: io::Error,
    },
    Encoding(std::string::FromUtf8Error),
    Syntax(ConfigError),
    MissingParent,
    CandidateSourceMismatch,
    ConcurrentModification,
}

impl fmt::Display for ConfigFileError {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Self::Io { operation, source } => write!(formatter, "could not {operation}: {source}"),
            Self::Encoding(_) => formatter.write_str("configuration is not valid UTF-8"),
            Self::Syntax(error) => error.fmt(formatter),
            Self::MissingParent => {
                formatter.write_str("configuration path has no parent directory")
            }
            Self::CandidateSourceMismatch => {
                formatter.write_str("edited configuration does not belong to the loaded source")
            }
            Self::ConcurrentModification => formatter
                .write_str("configuration changed after it was loaded; no file was overwritten"),
        }
    }
}

impl std::error::Error for ConfigFileError {
    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
        match self {
            Self::Io { source, .. } => Some(source),
            Self::Encoding(source) => Some(source),
            Self::Syntax(source) => Some(source),
            Self::MissingParent | Self::CandidateSourceMismatch | Self::ConcurrentModification => {
                None
            }
        }
    }
}

fn backup_path(path: &Path) -> PathBuf {
    let file_name = path.file_name().unwrap_or_default().to_string_lossy();
    path.with_file_name(format!("{file_name}.prns-backup"))
}

fn atomic_write(
    path: &Path,
    bytes: &[u8],
    permissions: Option<fs::Permissions>,
) -> Result<(), ConfigFileError> {
    let parent = path.parent().ok_or(ConfigFileError::MissingParent)?;
    let mut temporary = NamedTempFile::new_in(parent).map_err(|source| ConfigFileError::Io {
        operation: ConfigFileOperation::CreateTemporary,
        source,
    })?;
    if let Some(permissions) = permissions {
        temporary
            .as_file()
            .set_permissions(permissions)
            .map_err(|source| ConfigFileError::Io {
                operation: ConfigFileOperation::SetPermissions,
                source,
            })?;
    } else {
        protect_new_file(temporary.as_file())?;
    }
    temporary
        .write_all(bytes)
        .and_then(|()| temporary.as_file().sync_all())
        .map_err(|source| ConfigFileError::Io {
            operation: ConfigFileOperation::WriteTemporary,
            source,
        })?;
    temporary
        .persist(path)
        .map_err(|error| ConfigFileError::Io {
            operation: ConfigFileOperation::Persist,
            source: error.error,
        })?;
    sync_parent(parent)
}

#[cfg(unix)]
fn protect_new_file(file: &fs::File) -> Result<(), ConfigFileError> {
    use std::os::unix::fs::PermissionsExt;
    file.set_permissions(fs::Permissions::from_mode(0o600))
        .map_err(|source| ConfigFileError::Io {
            operation: ConfigFileOperation::SetPermissions,
            source,
        })
}

#[cfg(not(unix))]
fn protect_new_file(_file: &fs::File) -> Result<(), ConfigFileError> {
    Ok(())
}

#[cfg(unix)]
fn sync_parent(parent: &Path) -> Result<(), ConfigFileError> {
    fs::File::open(parent)
        .and_then(|directory| directory.sync_all())
        .map_err(|source| ConfigFileError::Io {
            operation: ConfigFileOperation::SyncDirectory,
            source,
        })
}

#[cfg(not(unix))]
fn sync_parent(_parent: &Path) -> Result<(), ConfigFileError> {
    Ok(())
}