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()),
)
}