regy 0.1.0

Private-by-default desktop agent for the Regy web interface
use std::fs::{self, File};
use std::io::{self, Write};
use std::path::{Path, PathBuf};

use tempfile::NamedTempFile;
use url::Url;

use serde::Deserialize;

use super::app::{
    CURRENT_SCHEMA_VERSION, DEFAULT_FRONTEND_ORIGIN, StoredConfig, canonicalize_config,
    validate_config,
};
use crate::domain::errors::{AgentError, AgentResult, ErrorCode};

#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct FileConfigStore {
    path: PathBuf,
}

impl FileConfigStore {
    pub(crate) fn new(path: impl Into<PathBuf>) -> Self {
        Self { path: path.into() }
    }

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

    pub(crate) fn load(&self) -> AgentResult<StoredConfig> {
        let contents = match fs::read_to_string(&self.path) {
            Ok(contents) => contents,
            Err(error) if error.kind() == io::ErrorKind::NotFound => {
                return Ok(StoredConfig::default());
            }
            Err(error) => {
                return Err(config_error(
                    &self.path,
                    format!("could not be read: {error}"),
                ));
            }
        };
        let schema = toml::from_str::<SchemaHeader>(&contents)
            .map_err(|_| config_error(&self.path, "contains malformed or unsupported TOML"))?;
        let (mut config, migrated) = match schema.schema_version {
            1 => (
                migrate_schema_one_config(&contents).map_err(|_| {
                    config_error(&self.path, "contains malformed or unsupported TOML")
                })?,
                true,
            ),
            2 => (
                migrate_schema_two_config(&contents).map_err(|_| {
                    config_error(&self.path, "contains malformed or unsupported TOML")
                })?,
                true,
            ),
            CURRENT_SCHEMA_VERSION => (
                toml::from_str::<StoredConfig>(&contents).map_err(|_| {
                    config_error(&self.path, "contains malformed or unsupported TOML")
                })?,
                false,
            ),
            version => {
                return Err(config_error(
                    &self.path,
                    format!(
                        "uses unsupported schema version {version} (expected {CURRENT_SCHEMA_VERSION})"
                    ),
                ));
            }
        };
        let defaulted_frontend_origin = config.frontend_origin.is_none();
        if defaulted_frontend_origin {
            config.frontend_origin = Some(DEFAULT_FRONTEND_ORIGIN.to_owned());
        }
        canonicalize_config(&mut config)
            .map_err(|_| config_error(&self.path, "contains invalid relay configuration"))?;
        validate_config(&config)
            .map_err(|_| config_error(&self.path, "contains invalid configuration"))?;
        if migrated || defaulted_frontend_origin {
            self.save(&config)?;
        }
        Ok(config)
    }

    pub(crate) fn save(&self, config: &StoredConfig) -> AgentResult<()> {
        let mut config = config.clone();
        canonicalize_config(&mut config)
            .map_err(|error| config_error(&self.path, error.message()))?;
        validate_config(&config).map_err(|error| config_error(&self.path, error.message()))?;
        let serialized = toml::to_string_pretty(&config)
            .map_err(|_| config_error(&self.path, "could not be serialized as TOML"))?;
        let parent = match self.path.parent() {
            Some(parent) if parent.as_os_str().is_empty() => Path::new("."),
            Some(parent) => parent,
            None => return Err(config_error(&self.path, "must have a parent directory")),
        };
        let parent_existed = parent.try_exists().map_err(|error| {
            config_error(
                parent,
                format!("could not inspect configuration directory: {error}"),
            )
        })?;

        fs::create_dir_all(parent).map_err(|error| {
            config_error(
                parent,
                format!("could not create private directory: {error}"),
            )
        })?;
        if !parent_existed {
            set_directory_permissions(parent)?;
        }

        let mut temporary = NamedTempFile::new_in(parent).map_err(|error| {
            config_error(parent, format!("could not create temporary file: {error}"))
        })?;
        temporary
            .write_all(serialized.as_bytes())
            .map_err(|error| {
                config_error(
                    &self.path,
                    format!("could not write temporary file: {error}"),
                )
            })?;
        set_file_permissions(temporary.as_file(), &self.path)?;
        temporary.as_file().sync_all().map_err(|error| {
            config_error(
                &self.path,
                format!("could not sync temporary file: {error}"),
            )
        })?;
        temporary.persist(&self.path).map_err(|error| {
            config_error(
                &self.path,
                format!("could not atomically replace: {}", error.error),
            )
        })?;
        File::open(parent)
            .and_then(|directory| directory.sync_all())
            .map_err(|error| config_error(parent, format!("could not sync directory: {error}")))?;
        Ok(())
    }
}

#[derive(Deserialize)]
struct SchemaHeader {
    #[serde(default = "legacy_schema_version")]
    schema_version: u32,
}

const fn legacy_schema_version() -> u32 {
    1
}

fn migrate_schema_one_config(contents: &str) -> Result<StoredConfig, ()> {
    let mut legacy = toml::from_str::<toml::Table>(contents).map_err(|_| ())?;
    legacy.remove(&format!(
        "{}_quick_tunnel_consent",
        ["cloud", "flare"].concat()
    ));
    if let Some(toml::Value::Table(tools)) = legacy.get_mut("tools") {
        tools.remove(&format!("{}_command", ["cloud", "flared"].concat()));
    }
    migrate_schema_two_table(legacy)
}

fn migrate_schema_two_config(contents: &str) -> Result<StoredConfig, ()> {
    let legacy = toml::from_str::<toml::Table>(contents).map_err(|_| ())?;
    migrate_schema_two_table(legacy)
}

fn migrate_schema_two_table(mut legacy: toml::Table) -> Result<StoredConfig, ()> {
    remove_legacy_localhost_origin(&mut legacy);
    migrate_legacy_table_to_schema_three(legacy)
}

fn remove_legacy_localhost_origin(legacy: &mut toml::Table) {
    let should_remove = legacy
        .get("frontend_origin")
        .and_then(toml::Value::as_str)
        .and_then(|origin| Url::parse(origin).ok().map(|url| (origin, url)))
        .is_some_and(|(origin, url)| {
            url.scheme() == "http"
                && url.host_str() == Some("localhost")
                && url.origin().ascii_serialization() == origin
                && url.username().is_empty()
                && url.password().is_none()
                && url.query().is_none()
                && url.fragment().is_none()
        });
    if should_remove {
        legacy.remove("frontend_origin");
    }
}

fn migrate_legacy_table_to_schema_three(mut legacy: toml::Table) -> Result<StoredConfig, ()> {
    legacy.remove("network_mode");
    if let Some(toml::Value::Table(server)) = legacy.get_mut("server") {
        server.remove("local_port");
    }
    legacy.insert(
        "schema_version".to_owned(),
        toml::Value::Integer(CURRENT_SCHEMA_VERSION.into()),
    );
    let migrated = toml::to_string(&legacy).map_err(|_| ())?;
    toml::from_str(&migrated).map_err(|_| ())
}

#[cfg(unix)]
fn set_directory_permissions(path: &Path) -> AgentResult<()> {
    use std::os::unix::fs::PermissionsExt;

    fs::set_permissions(path, fs::Permissions::from_mode(0o700))
        .map_err(|error| config_error(path, format!("could not set private permissions: {error}")))
}

#[cfg(not(unix))]
fn set_directory_permissions(_path: &Path) -> AgentResult<()> {
    Ok(())
}

#[cfg(unix)]
fn set_file_permissions(file: &File, path: &Path) -> AgentResult<()> {
    use std::os::unix::fs::PermissionsExt;

    file.set_permissions(fs::Permissions::from_mode(0o600))
        .map_err(|error| config_error(path, format!("could not set private permissions: {error}")))
}

#[cfg(not(unix))]
fn set_file_permissions(_file: &File, _path: &Path) -> AgentResult<()> {
    Ok(())
}

fn config_error(path: &Path, cause: impl AsRef<str>) -> AgentError {
    AgentError::new(
        ErrorCode::InvalidMessage,
        format!("configuration {} {}", path.display(), cause.as_ref()),
    )
}