use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, Ordering};
use serde::{Deserialize, Serialize};
static INSECURE_PERMISSIONS: AtomicBool = AtomicBool::new(false);
pub fn set_insecure_permissions(allow: bool) {
INSECURE_PERMISSIONS.store(allow, Ordering::Relaxed);
}
pub fn insecure_permissions() -> bool {
INSECURE_PERMISSIONS.load(Ordering::Relaxed)
}
#[derive(Debug, Default, Clone, Serialize, Deserialize)]
pub struct ConfigFile {
#[serde(default)]
pub default: Profile,
#[serde(default)]
pub profiles: BTreeMap<String, Profile>,
}
#[derive(Debug, Default, Clone, Serialize, Deserialize)]
pub struct Profile {
#[serde(default)]
pub endpoint: Option<String>,
#[serde(default)]
pub identity: Option<PathBuf>,
#[serde(default)]
pub netdb: Option<PathBuf>,
#[serde(default)]
pub default_timeout_ms: Option<u64>,
#[serde(default)]
pub ice_signature_threshold: Option<usize>,
#[serde(default)]
pub psk_hex: Option<String>,
#[serde(default)]
pub node_addr: Option<String>,
#[serde(default)]
pub node_pubkey: Option<String>,
#[serde(default)]
pub node_id: Option<String>,
}
impl ConfigFile {
pub fn profile(&self, name: &str) -> Profile {
if name == "default" {
return self.default.clone();
}
self.profiles.get(name).cloned().unwrap_or_default()
}
pub async fn load(path: Option<&Path>) -> Result<Self, ConfigError> {
Self::load_with(path, insecure_permissions()).await
}
pub async fn load_with(path: Option<&Path>, allow_insecure: bool) -> Result<Self, ConfigError> {
let path = match path {
Some(p) => p.to_path_buf(),
None => match default_path() {
Some(p) => p,
None => return Ok(Self::default()),
},
};
let gate_path = path.clone();
let gated = tokio::task::spawn_blocking(move || {
::net::adapter::net::secret_file::read_secret_file_to_string(&gate_path, allow_insecure)
})
.await
.map_err(|e| ConfigError::Io {
path: path.clone(),
source: std::io::Error::other(e),
})?;
let text = match gated {
Ok(t) => t,
Err(::net::adapter::net::secret_file::SecretFileError::Io { source, .. })
if source.kind() == std::io::ErrorKind::NotFound =>
{
return Ok(Self::default())
}
Err(::net::adapter::net::secret_file::SecretFileError::Io { source, .. }) => {
return Err(ConfigError::Io {
path: path.clone(),
source,
})
}
Err(e) => {
return Err(ConfigError::Permissions {
path: path.clone(),
source: e,
})
}
};
toml::from_str(&text).map_err(|_| ConfigError::Parse { path: path.clone() })
}
}
pub fn default_path() -> Option<PathBuf> {
dirs::config_dir().map(|d| d.join("net-mesh").join("config.toml"))
}
#[derive(Debug, thiserror::Error)]
pub enum ConfigError {
#[error("config file at {path}: {source}")]
Io {
path: PathBuf,
#[source]
source: std::io::Error,
},
#[error("{source}")]
Permissions {
path: PathBuf,
#[source]
source: ::net::adapter::net::secret_file::SecretFileError,
},
#[error("config file at {path} is not valid TOML (kind: parse_error)")]
Parse { path: PathBuf },
}
#[cfg(test)]
mod tests {
use super::*;
fn make_owner_only(path: &Path) {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))
.expect("chmod 600");
}
#[cfg(not(unix))]
let _ = path;
}
#[cfg(unix)]
#[tokio::test]
async fn a_world_readable_profile_is_refused_but_the_override_admits_it() {
use std::os::unix::fs::PermissionsExt;
let dir = std::env::temp_dir().join(format!("net-sec05-cfg-{}", std::process::id()));
std::fs::create_dir_all(&dir).expect("temp dir");
let path = dir.join("config.toml");
std::fs::write(&path, "[default]\npsk_hex = \"abcd\"\n").expect("write config");
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).expect("chmod 644");
match ConfigFile::load(Some(&path)).await {
Err(ConfigError::Permissions { source, .. }) => {
assert_eq!(source.kind(), "permissive_mode", "got {source}");
}
Err(other) => panic!("expected a permission refusal, got {other:?}"),
Ok(_) => panic!("a world-readable profile holding a PSK was read"),
}
let previously = insecure_permissions();
set_insecure_permissions(true);
let loaded = ConfigFile::load(Some(&path)).await;
set_insecure_permissions(previously);
let cfg = loaded.expect("--insecure-config-permissions must admit the file");
assert_eq!(
cfg.profile("default").psk_hex.as_deref(),
Some("abcd"),
"the override admitted the file but did not actually parse it"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[tokio::test]
async fn a_malformed_psk_line_is_not_reproduced_in_the_parse_error() {
const SENTINEL: &str = "PSK_SENTINEL_0123456789abcdef";
let dir = std::env::temp_dir().join(format!("net-sec06-cfg-{}", std::process::id()));
std::fs::create_dir_all(&dir).expect("temp dir");
let path = dir.join("config.toml");
std::fs::write(
&path,
format!("listen = \"127.0.0.1:0\"\npsk_hex = \"{SENTINEL}\" trailing\n"),
)
.expect("write config");
make_owner_only(&path);
let err = ConfigFile::load(Some(&path))
.await
.expect_err("malformed TOML must fail to parse");
let rendered = format!("{err} | {err:?}");
assert!(
!rendered.contains(SENTINEL),
"the PSK was reproduced in the parse error: {rendered}"
);
assert!(
rendered.contains("config.toml") && rendered.contains("parse_error"),
"the sanitized error dropped the path or the category: {rendered}"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[tokio::test]
async fn a_well_formed_profile_still_parses() {
let dir = std::env::temp_dir().join(format!("net-sec06-ok-{}", std::process::id()));
std::fs::create_dir_all(&dir).expect("temp dir");
let path = dir.join("config.toml");
std::fs::write(&path, "[default]\npsk_hex = \"abcd\"\n").expect("write config");
make_owner_only(&path);
let cfg = ConfigFile::load(Some(&path))
.await
.expect("valid TOML loads");
assert_eq!(cfg.profile("default").psk_hex.as_deref(), Some("abcd"));
let _ = std::fs::remove_dir_all(&dir);
}
}