diff options
Diffstat (limited to 'crates/config/src/lib.rs')
| -rw-r--r-- | crates/config/src/lib.rs | 332 |
1 files changed, 332 insertions, 0 deletions
diff --git a/crates/config/src/lib.rs b/crates/config/src/lib.rs new file mode 100644 index 0000000..0a648a2 --- /dev/null +++ b/crates/config/src/lib.rs @@ -0,0 +1,332 @@ +// Copyright (C) 2026 Doan Luu Duc Tai <doan@luuduct.ai> +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see <https://www.gnu.org/licenses/>. + +#![doc = "Validated root environment configuration and public-only template rendering."] + +use anyhow::{Context, Result, ensure}; +use std::collections::BTreeMap; +use std::fmt; +use std::fs; +use std::io::Write; +use std::net::SocketAddr; +use std::os::unix::fs::PermissionsExt; +use std::path::{Component, Path}; + +const EXAMPLE: &str = include_str!("../../../.env.example"); +const HOSTS: &[&str] = &[ + "BASE_DOMAIN", + "SITE_HOST", + "GIT_HOST", + "SEARCH_HOST", + "WKD_HOST", + "CHAT_HOST", +]; +const PUBLIC: &[&str] = &[ + "BASE_DOMAIN", + "SITE_HOST", + "GIT_HOST", + "SEARCH_HOST", + "WKD_HOST", + "CHAT_HOST", + "HTTP_PORT", + "HTTPS_PORT", + "LOCAL_HTTP_PORT", + "CGIT_UPSTREAM", + "SEARXNG_UPSTREAM", + "CHAT_BIND", + "CHAT_ORIGIN", + "BACKUP_RECIPIENT", + "BACKUP_PUBLIC_KEY", +]; + +#[doc = "One installation's configuration; formatting deliberately excludes all values."] +pub struct Config { + values: BTreeMap<String, String>, + explicit: BTreeMap<String, String>, +} + +impl fmt::Debug for Config { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.debug_struct("Config").finish_non_exhaustive() + } +} + +fn defaults() -> Result<BTreeMap<String, String>> { + Ok(dotenvy::from_read_iter(EXAMPLE.as_bytes()).collect::<std::result::Result<_, _>>()?) +} + +fn hostname(value: &str) -> bool { + value.len() <= 253 + && value.split('.').all(|label| { + !label.is_empty() + && label.len() <= 63 + && !label.starts_with('-') + && !label.ends_with('-') + && label + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-') + }) +} + +fn endpoint(value: &str) -> bool { + if value.parse::<SocketAddr>().is_ok() { + return true; + } + value.rsplit_once(':').is_some_and(|(host, port)| { + hostname(host) && port.parse::<u16>().is_ok_and(|number| number > 0) + }) +} + +impl Config { + #[doc = "Loads the single root .env over public defaults from the published example."] + #[doc = "# Errors"] + #[doc = "Rejects malformed files and invalid public deployment values without printing secrets."] + pub fn load(root: &Path) -> Result<Self> { + let path = root.join(".env"); + let explicit: BTreeMap<String, String> = if path.exists() { + dotenvy::from_path_iter(path) + .context("Cannot parse root .env")? + .collect::<std::result::Result<_, _>>() + .map_err(|_| anyhow::anyhow!("Cannot parse root .env"))? + } else { + BTreeMap::new() + }; + let mut values = defaults()?; + values.retain(|key, _| PUBLIC.contains(&key.as_str())); + values.extend(explicit.clone()); + let config = Self { values, explicit }; + config.validate()?; + Ok(config) + } + + #[doc = "Returns a configured value; callers must not log private values."] + #[doc = "# Errors"] + #[doc = "Returns only the missing key name when a value is unavailable."] + pub fn value(&self, key: &str) -> Result<&str> { + self.values + .get(key) + .map(String::as_str) + .with_context(|| format!("Missing {key} in root .env")) + } + + #[doc = "Returns whether a setting is present in the installation file rather than defaults."] + #[must_use] + pub fn is_explicit(&self, key: &str) -> bool { + self.explicit.contains_key(key) + } + + #[doc = "Reads a nonzero TCP/UDP port."] + #[doc = "# Errors"] + #[doc = "Rejects invalid ports without displaying their raw values."] + pub fn port(&self, key: &str) -> Result<u16> { + let port = self + .value(key)? + .parse::<u16>() + .with_context(|| format!("Invalid {key} in root .env"))?; + ensure!(port > 0, "Invalid {key} in root .env"); + Ok(port) + } + + #[doc = "Reads the configured local chat socket address."] + #[doc = "# Errors"] + #[doc = "Rejects malformed socket addresses."] + pub fn chat_bind(&self) -> Result<SocketAddr> { + self.value("CHAT_BIND")? + .parse() + .context("Invalid CHAT_BIND in root .env") + } + + fn validate(&self) -> Result<()> { + for key in HOSTS { + ensure!(hostname(self.value(key)?), "Invalid {key} in root .env"); + } + ensure!( + self.port("HTTP_PORT")? != self.port("HTTPS_PORT")?, + "HTTP_PORT and HTTPS_PORT must differ" + ); + ensure!( + (1024..=65_534).contains(&self.port("LOCAL_HTTP_PORT")?), + "LOCAL_HTTP_PORT must be between 1024 and 65534" + ); + for key in ["CGIT_UPSTREAM", "SEARXNG_UPSTREAM"] { + ensure!(endpoint(self.value(key)?), "Invalid {key} in root .env"); + } + self.chat_bind()?; + let origin = self.value("CHAT_ORIGIN")?; + ensure!( + origin.len() <= 256 + && (origin.starts_with("http://") || origin.starts_with("https://")) + && origin + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || b":/.-[]".contains(&byte)), + "Invalid CHAT_ORIGIN in root .env" + ); + let recipient = self.value("BACKUP_RECIPIENT")?; + ensure!( + [40, 64].contains(&recipient.len()) + && recipient.bytes().all(|byte| byte.is_ascii_hexdigit()), + "Invalid BACKUP_RECIPIENT in root .env" + ); + let key = Path::new(self.value("BACKUP_PUBLIC_KEY")?); + ensure!( + !key.as_os_str().is_empty() + && self + .value("BACKUP_PUBLIC_KEY")? + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || b"/._-".contains(&byte)) + && key + .components() + .all(|part| matches!(part, Component::Normal(_))), + "BACKUP_PUBLIC_KEY must be project-relative" + ); + Ok(()) + } + + #[doc = "Builds the explicit public-only template map for production or a local HTTP port."] + #[doc = "# Errors"] + #[doc = "Rejects invalid configuration or port arithmetic overflow."] + pub fn public_values(&self, local: Option<u16>) -> Result<BTreeMap<String, String>> { + let mut values = PUBLIC + .iter() + .map(|key| Ok(((*key).to_owned(), self.value(key)?.to_owned()))) + .collect::<Result<BTreeMap<_, _>>>()?; + let https = self.port("HTTPS_PORT")?; + let suffix = if https == 443 { + String::new() + } else { + format!(":{https}") + }; + for (name, key) in [ + ("SITE_ORIGIN", "SITE_HOST"), + ("GIT_ORIGIN", "GIT_HOST"), + ("SEARCH_ORIGIN", "SEARCH_HOST"), + ("WKD_ORIGIN", "WKD_HOST"), + ("CHAT_PUBLIC_ORIGIN", "CHAT_HOST"), + ] { + values.insert(name.into(), format!("https://{}{suffix}", self.value(key)?)); + } + if let Some(port) = local { + values.insert("SITE_ORIGIN".into(), format!("http://localhost:{port}")); + values.insert("GIT_ORIGIN".into(), "/git".into()); + values.insert("SEARCH_ORIGIN".into(), "/search".into()); + values.insert("WKD_ORIGIN".into(), String::new()); + values.insert( + "CHAT_PUBLIC_ORIGIN".into(), + self.value("CHAT_ORIGIN")?.into(), + ); + } + Ok(values) + } + + #[doc = "Renders only whitelisted public variables; private or unknown placeholders fail closed."] + #[doc = "# Errors"] + #[doc = "Rejects unknown placeholders without substituting private configuration."] + pub fn render(&self, source: &str, local: Option<u16>) -> Result<String> { + render(source, &self.public_values(local)?) + } +} + +#[doc = "Substitutes a previously filtered public configuration map into a source template."] +#[doc = "# Errors"] +#[doc = "Rejects remaining unknown placeholders."] +pub fn render(source: &str, public: &BTreeMap<String, String>) -> Result<String> { + let mut result = source.to_owned(); + for (key, value) in public { + result = result.replace(&format!("{{{{{key}}}}}"), value); + } + ensure!( + !result.contains("{{"), + "Unknown or private configuration placeholder" + ); + Ok(result) +} + +#[doc = "Appends missing public settings to the same root .env, preserving existing values."] +#[doc = "# Errors"] +#[doc = "Returns a filesystem/configuration error without printing environment contents."] +pub fn initialize_public(root: &Path) -> Result<()> { + let path = root.join(".env"); + let existing = if path.exists() { + fs::read_to_string(&path)? + } else { + String::new() + }; + let config = Config::load(root)?; + let mut appended = String::new(); + for (key, value) in defaults()? { + if PUBLIC.contains(&key.as_str()) && !config.is_explicit(&key) { + use std::fmt::Write as _; + writeln!(appended, "{key}={value}")?; + } + } + if appended.is_empty() { + return Ok(()); + } + let mut file = tempfile::NamedTempFile::new_in(root)?; + file.as_file() + .set_permissions(fs::Permissions::from_mode(0o600))?; + write!(file, "{existing}\n{appended}")?; + file.as_file().sync_all()?; + file.persist(path).map_err(|error| error.error)?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn public_rendering_rejects_secrets_and_host_injection() -> Result<()> { + let root = tempfile::tempdir()?; + fs::write( + root.path().join(".env"), + "CHAT_SETUP_TOKEN=\"private-malformed-sentinel\n", + )?; + let error = Config::load(root.path()) + .err() + .context("Malformed secret must fail")?; + assert!(!format!("{error:#}").contains("private-malformed-sentinel")); + fs::write( + root.path().join(".env"), + "SITE_HOST=home.example.net\nGIT_HOST=source.example.net\nHTTPS_PORT=8443\nLOCAL_HTTP_PORT=8180\nCHAT_SETUP_TOKEN=private-token-sentinel\nSEARXNG_SECRET=private-search-sentinel\n", + )?; + let config = Config::load(root.path())?; + assert_eq!( + config.render("{{SITE_ORIGIN}} {{GIT_ORIGIN}}", None)?, + "https://home.example.net:8443 https://source.example.net:8443" + ); + assert_eq!( + config.render("{{SITE_ORIGIN}} {{GIT_ORIGIN}}", Some(8180))?, + "http://localhost:8180 /git" + ); + assert!(config.render("{{CHAT_SETUP_TOKEN}}", None).is_err()); + assert!(!format!("{config:?}").contains("private-token")); + initialize_public(root.path())?; + assert_eq!( + Config::load(root.path())?.value("SITE_HOST")?, + "home.example.net" + ); + assert_eq!( + Config::load(root.path())?.value("CHAT_SETUP_TOKEN")?, + "private-token-sentinel" + ); + fs::write( + root.path().join(".env"), + "SITE_HOST=\"valid.example { respond bad }\"\n", + )?; + assert!(Config::load(root.path()).is_err()); + Ok(()) + } +} |
