// file: crates/ksp-config-lib/src/environment.rs // version: 6 /// Default local environment file read by Config from the process launch directory. pub const DEFAULT_DOTENV_PATH: &str = ".env"; /// Versioned environment contract template expected at the repository/runtime root. pub const DEFAULT_DOTENV_EXAMPLE_PATH: &str = ".env.example"; const LOGGING_DOMAIN: &str = "config.environment"; /// Source that supplied one resolved Config environment variable. #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum ConfigEnvironmentSource { /// Value was present in the environment inherited by the current process. Process, /// Value was absent from the process environment and came from the local `.env` file. DotEnv, /// Value was absent from both external sources and came from the placeholder/API fallback. Fallback, } /// One resolved Config environment variable with real/safe values, sensitivity and its winning source. #[derive(Clone, Eq, PartialEq)] pub struct ConfigEnvironmentValue { variable_name: String, value: String, safe_value: String, sensitivity: crate::ConfigSensitivity, source: ConfigEnvironmentSource, } impl ConfigEnvironmentValue { /// Returns the resolved variable name. #[must_use] pub fn variable_name(&self) -> &str { return self.variable_name.as_str(); } /// Returns the real resolved value. #[must_use] pub fn value(&self) -> &str { return self.value.as_str(); } /// Returns the representation safe for ordinary diagnostics. #[must_use] pub fn safe_value(&self) -> &str { return self.safe_value.as_str(); } /// Returns the sensitivity derived from the variable namespace. #[must_use] pub const fn sensitivity(&self) -> crate::ConfigSensitivity { return self.sensitivity; } /// Returns the source that won process > `.env` > fallback resolution. #[must_use] pub const fn source(&self) -> ConfigEnvironmentSource { return self.source; } /// Returns provenance without embedding the resolved value. #[must_use] pub fn provenance(&self) -> crate::ConfigValueProvenance { return environment_provenance(self.variable_name.as_str(), self.source); } } impl std::fmt::Debug for ConfigEnvironmentValue { fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { return formatter .debug_struct("ConfigEnvironmentValue") .field("variable_name", &self.variable_name) .field("safe_value", &self.safe_value) .field("sensitivity", &self.sensitivity) .field("source", &self.source) .finish(); } } /// Config-owned snapshot of KSP/KSPB process environment values and the local `.env` file. /// /// The process environment is captured first and always has priority over `.env`. An absent `.env` file is equivalent to an empty local environment source. /// Config never mutates the parent/process environment through this type. #[derive(Clone, Eq, PartialEq)] pub struct ConfigEnvironment { process: std::collections::BTreeMap, dotenv: std::collections::BTreeMap, dotenv_path: std::path::PathBuf, } impl ConfigEnvironment { /// Captures supported variables from the current process and reads `./.env` when it exists. pub fn load() -> ksp_core_lib::Result { return Self::load_from_dotenv_path(std::path::Path::new(DEFAULT_DOTENV_PATH)); } /// Returns the local `.env` path used by this environment snapshot. #[must_use] pub fn dotenv_path(&self) -> &std::path::Path { return self.dotenv_path.as_path(); } /// Resolves one KSP/KSPB variable using process > `.env` > fallback priority. /// /// The fallback is used only when the variable is absent. An explicitly defined empty string is a real value and therefore wins over the fallback. pub fn resolve_variable(&self, variable_name: &str, fallback: std::option::Option<&str>) -> ksp_core_lib::Result { let validation = validate_supported_variable_name(variable_name); if let std::result::Result::Err(error) = validation { return std::result::Result::Err(error); } if let std::option::Option::Some(value) = self.process.get(variable_name) { return resolved_environment_value(variable_name, value.as_str(), ConfigEnvironmentSource::Process); } if let std::option::Option::Some(value) = self.dotenv.get(variable_name) { return resolved_environment_value(variable_name, value.as_str(), ConfigEnvironmentSource::DotEnv); } if let std::option::Option::Some(value) = fallback { return resolved_environment_value(variable_name, value, ConfigEnvironmentSource::Fallback); } emit_missing_variable_warning(variable_name); return std::result::Result::Err(missing_variable_error(variable_name)); } /// Resolves `${NAME}` and `${NAME:-fallback}` placeholders embedded in one UTF-8 string. /// /// This compatibility helper returns only the real runtime string. Use [`Self::resolve_text_detailed`] when safe value, sensitivity or provenance are /// needed. pub fn resolve_text(&self, source: &str) -> ksp_core_lib::Result { let resolved = self.resolve_text_detailed(source); return match resolved { std::result::Result::Ok(value) => std::result::Result::Ok(value.value().to_owned()), std::result::Result::Err(error) => std::result::Result::Err(error), }; } /// Resolves one UTF-8 string while preserving real/safe representations, strongest sensitivity and ordered provenance. /// /// Multiple placeholders are supported. Fallback text is literal and inherits the sensitivity of the referenced variable. Literal-only strings are /// classified as `Internal`; when placeholders are present, the result sensitivity is the strongest placeholder sensitivity. pub fn resolve_text_detailed(&self, source: &str) -> ksp_core_lib::Result { let mut value = String::new(); let mut safe_value = String::new(); let mut provenance = std::vec::Vec::::new(); let mut sensitivity = crate::ConfigSensitivity::Public; let mut saw_placeholder = false; let mut remaining = source; loop { let start = remaining.find("${"); let start = match start { std::option::Option::Some(value) => value, std::option::Option::None => { if !remaining.is_empty() { value.push_str(remaining); safe_value.push_str(remaining); provenance.push(crate::ConfigValueProvenance::DocumentLiteral); } if !saw_placeholder { sensitivity = crate::ConfigSensitivity::Internal; } return std::result::Result::Ok(crate::ResolvedConfigText::new(value, safe_value, sensitivity, provenance)); }, }; let literal = &remaining[..start]; if !literal.is_empty() { value.push_str(literal); safe_value.push_str(literal); provenance.push(crate::ConfigValueProvenance::DocumentLiteral); } let expression_and_tail = &remaining[start + 2..]; let end = expression_and_tail.find('}'); let end = match end { std::option::Option::Some(value) => value, std::option::Option::None => return std::result::Result::Err(invalid_placeholder_error("placeholder is missing its closing '}'")), }; let expression = &expression_and_tail[..end]; let parsed = parse_placeholder_expression(expression); let (variable_name, fallback) = match parsed { std::result::Result::Ok(value) => value, std::result::Result::Err(error) => return std::result::Result::Err(error), }; let resolved = self.resolve_variable(variable_name, fallback); let resolved = match resolved { std::result::Result::Ok(value) => value, std::result::Result::Err(error) => return std::result::Result::Err(error), }; saw_placeholder = true; sensitivity = sensitivity.strongest(resolved.sensitivity()); value.push_str(resolved.value()); safe_value.push_str(resolved.safe_value()); provenance.push(resolved.provenance()); remaining = &expression_and_tail[end + 1..]; } } /// Recursively resolves environment placeholders in JSON values and returns only the real runtime tree. /// /// Use [`Self::resolve_json_detailed`] when safe value, sensitivity or per-location provenance are needed. pub fn resolve_json(&self, source: &serde_json::Value) -> ksp_core_lib::Result { let resolved = self.resolve_json_detailed(source); return match resolved { std::result::Result::Ok(value) => std::result::Result::Ok(value.value().clone()), std::result::Result::Err(error) => std::result::Result::Err(error), }; } /// Recursively resolves environment placeholders in JSON values while preserving real/safe trees, strongest sensitivity and JSON-Pointer provenance. pub fn resolve_json_detailed(&self, source: &serde_json::Value) -> ksp_core_lib::Result { let mut provenance = std::collections::BTreeMap::>::new(); let resolved = resolve_json_node(self, source, "", &mut provenance); let (value, safe_value, sensitivity) = match resolved { std::result::Result::Ok(value) => value, std::result::Result::Err(error) => return std::result::Result::Err(error), }; return std::result::Result::Ok(crate::ResolvedConfigJson::new(value, safe_value, sensitivity, provenance)); } /// Recursively resolves environment placeholders in one JSON object map while leaving map keys unchanged. pub fn resolve_map(&self, source: &serde_json::Map) -> ksp_core_lib::Result> { let resolved = self.resolve_json(&serde_json::Value::Object(source.clone())); let resolved = match resolved { std::result::Result::Ok(value) => value, std::result::Result::Err(error) => return std::result::Result::Err(error), }; return match resolved { serde_json::Value::Object(value) => std::result::Result::Ok(value), _ => std::result::Result::Err(invalid_placeholder_error("resolved JSON object changed shape")), }; } pub(crate) fn load_from_dotenv_path(dotenv_path: &std::path::Path) -> ksp_core_lib::Result { let process = collect_process_environment(std::env::vars_os()); let process = match process { std::result::Result::Ok(value) => value, std::result::Result::Err(error) => return std::result::Result::Err(error), }; let dotenv = load_dotenv_file(dotenv_path); let dotenv = match dotenv { std::result::Result::Ok(value) => value, std::result::Result::Err(error) => return std::result::Result::Err(error), }; return std::result::Result::Ok(Self { process, dotenv, dotenv_path: dotenv_path.to_path_buf() }); } pub(crate) const fn process_values(&self) -> &std::collections::BTreeMap { return &self.process; } pub(crate) const fn dotenv_values(&self) -> &std::collections::BTreeMap { return &self.dotenv; } #[cfg(test)] pub(crate) fn from_maps(process: std::collections::BTreeMap, dotenv: std::collections::BTreeMap) -> Self { return Self { process, dotenv, dotenv_path: std::path::PathBuf::from(DEFAULT_DOTENV_PATH) }; } } pub(crate) fn parse_dotenv_content(path: &std::path::Path, content: &str) -> ksp_core_lib::Result> { let mut output = std::collections::BTreeMap::::new(); for (line_index, raw_line) in content.lines().enumerate() { let raw_line = if line_index == 0 { raw_line.trim_start_matches('\u{feff}') } else { raw_line }; let line = raw_line.trim(); if line.is_empty() || line.starts_with('#') { continue; } let assignment = match line.strip_prefix("export ") { std::option::Option::Some(value) => value.trim_start(), std::option::Option::None => line, }; let separator = assignment.find('='); let separator = match separator { std::option::Option::Some(value) => value, std::option::Option::None => return std::result::Result::Err(dotenv_syntax_error(path, line_index + 1, "assignment is missing '='")), }; let variable_name = assignment[..separator].trim(); if !is_generic_dotenv_name(variable_name) { return std::result::Result::Err(dotenv_syntax_error(path, line_index + 1, "variable name is invalid")); } let raw_value = assignment[separator + 1..].trim(); let value = parse_dotenv_value(path, line_index + 1, raw_value); let value = match value { std::result::Result::Ok(value) => value, std::result::Result::Err(error) => return std::result::Result::Err(error), }; if !has_supported_namespace(variable_name) { continue; } let validation = validate_supported_variable_name(variable_name); if let std::result::Result::Err(error) = validation { return std::result::Result::Err(error); } let entry = output.entry(variable_name.to_owned()); match entry { std::collections::btree_map::Entry::Occupied(_) => { return std::result::Result::Err(dotenv_duplicate_error(path, line_index + 1, variable_name)); }, std::collections::btree_map::Entry::Vacant(entry) => { entry.insert(value); }, } } return std::result::Result::Ok(output); } pub(crate) fn validate_supported_variable_name(variable_name: &str) -> ksp_core_lib::Result<()> { if !has_supported_namespace(variable_name) { return std::result::Result::Err(invalid_variable_error(variable_name, "variable must use the KSP_ or KSPB_ namespace")); } let prefix_length = if variable_name.starts_with("KSPB_") { 5 } else { 4 }; if variable_name.len() <= prefix_length { return std::result::Result::Err(invalid_variable_error(variable_name, "variable namespace must be followed by a name")); } for byte in variable_name.bytes() { let valid = byte.is_ascii_uppercase() || byte.is_ascii_digit() || byte == b'_'; if !valid { return std::result::Result::Err(invalid_variable_error(variable_name, "variable names use uppercase ASCII letters, digits and underscores")); } } return std::result::Result::Ok(()); } fn collect_process_environment(values: I) -> ksp_core_lib::Result> where I: std::iter::IntoIterator, { let mut output = std::collections::BTreeMap::::new(); for (name, value) in values { let name = match name.to_str() { std::option::Option::Some(value) => value, std::option::Option::None => continue, }; if !has_supported_namespace(name) { continue; } let validation = validate_supported_variable_name(name); if let std::result::Result::Err(error) = validation { return std::result::Result::Err(error); } let value = value.into_string(); let value = match value { std::result::Result::Ok(value) => value, std::result::Result::Err(_) => return std::result::Result::Err(invalid_environment_value_error(name)), }; output.insert(name.to_owned(), value); } return std::result::Result::Ok(output); } fn load_dotenv_file(path: &std::path::Path) -> ksp_core_lib::Result> { let content = std::fs::read_to_string(path); let content = match content { std::result::Result::Ok(value) => value, std::result::Result::Err(error) if error.kind() == std::io::ErrorKind::NotFound => return std::result::Result::Ok(std::collections::BTreeMap::new()), std::result::Result::Err(error) => return std::result::Result::Err(dotenv_read_error(path, error)), }; return crate::parse_dotenv_content(path, content.as_str()); } fn parse_dotenv_value(path: &std::path::Path, line_number: usize, raw_value: &str) -> ksp_core_lib::Result { if raw_value.starts_with('\'') && (raw_value.len() < 2 || !raw_value.ends_with('\'')) { return std::result::Result::Err(dotenv_syntax_error(path, line_number, "single-quoted value is not terminated")); } if raw_value.starts_with('\'') { return std::result::Result::Ok(raw_value[1..raw_value.len() - 1].to_owned()); } if raw_value.starts_with('"') && (raw_value.len() < 2 || !raw_value.ends_with('"')) { return std::result::Result::Err(dotenv_syntax_error(path, line_number, "double-quoted value is not terminated")); } if raw_value.starts_with('"') { return parse_double_quoted_value(path, line_number, &raw_value[1..raw_value.len() - 1]); } let inline_comment = raw_value.find(" #"); let value = match inline_comment { std::option::Option::Some(index) => raw_value[..index].trim_end(), std::option::Option::None => raw_value, }; return std::result::Result::Ok(value.to_owned()); } fn parse_double_quoted_value(path: &std::path::Path, line_number: usize, source: &str) -> ksp_core_lib::Result { let mut output = String::new(); let mut escaped = false; for character in source.chars() { if escaped { let mapped = match character { 'n' => '\n', 'r' => '\r', 't' => '\t', '\\' => '\\', '"' => '"', _ => return std::result::Result::Err(dotenv_syntax_error(path, line_number, "double-quoted value contains an unsupported escape")), }; output.push(mapped); escaped = false; } else if character == '\\' { escaped = true; } else { output.push(character); } } if escaped { return std::result::Result::Err(dotenv_syntax_error(path, line_number, "double-quoted value ends with an incomplete escape")); } return std::result::Result::Ok(output); } fn parse_placeholder_expression(expression: &str) -> ksp_core_lib::Result<(&str, std::option::Option<&str>)> { if expression.is_empty() || expression.contains("${") { return std::result::Result::Err(invalid_placeholder_error("placeholder expression is empty or nested")); } let fallback_separator = expression.find(":-"); let (variable_name, fallback) = match fallback_separator { std::option::Option::Some(index) => (&expression[..index], std::option::Option::Some(&expression[index + 2..])), std::option::Option::None => (expression, std::option::Option::None), }; let validation = validate_supported_variable_name(variable_name); if let std::result::Result::Err(error) = validation { return std::result::Result::Err(error); } return std::result::Result::Ok((variable_name, fallback)); } fn resolved_environment_value(variable_name: &str, value: &str, source: ConfigEnvironmentSource) -> ksp_core_lib::Result { let sensitivity = crate::ConfigSensitivity::from_variable_name(variable_name); let sensitivity = match sensitivity { std::result::Result::Ok(value) => value, std::result::Result::Err(error) => return std::result::Result::Err(error), }; let safe_value = if sensitivity.is_secret() { crate::REDACTED_CONFIG_VALUE.to_owned() } else { value.to_owned() }; return std::result::Result::Ok(ConfigEnvironmentValue { variable_name: variable_name.to_owned(), value: value.to_owned(), safe_value, sensitivity, source, }); } fn environment_provenance(variable_name: &str, source: ConfigEnvironmentSource) -> crate::ConfigValueProvenance { return match source { ConfigEnvironmentSource::Process => crate::ConfigValueProvenance::EnvironmentProcess { variable_name: variable_name.to_owned() }, ConfigEnvironmentSource::DotEnv => crate::ConfigValueProvenance::EnvironmentDotEnv { variable_name: variable_name.to_owned() }, ConfigEnvironmentSource::Fallback => crate::ConfigValueProvenance::EnvironmentFallback { variable_name: variable_name.to_owned() }, }; } fn resolve_json_node( environment: &ConfigEnvironment, source: &serde_json::Value, pointer: &str, provenance: &mut std::collections::BTreeMap>, ) -> ksp_core_lib::Result<(serde_json::Value, serde_json::Value, crate::ConfigSensitivity)> { return match source { serde_json::Value::Null | serde_json::Value::Bool(_) | serde_json::Value::Number(_) => { provenance.insert(pointer.to_owned(), vec![crate::ConfigValueProvenance::DocumentLiteral]); std::result::Result::Ok((source.clone(), source.clone(), crate::ConfigSensitivity::Internal)) }, serde_json::Value::String(value) => { let resolved = environment.resolve_text_detailed(value.as_str()); let resolved = match resolved { std::result::Result::Ok(value) => value, std::result::Result::Err(error) => return std::result::Result::Err(error), }; provenance.insert(pointer.to_owned(), resolved.provenance().to_vec()); std::result::Result::Ok(( serde_json::Value::String(resolved.value().to_owned()), serde_json::Value::String(resolved.safe_value().to_owned()), resolved.sensitivity(), )) }, serde_json::Value::Array(values) => resolve_json_array(environment, values, pointer, provenance), serde_json::Value::Object(values) => resolve_json_object(environment, values, pointer, provenance), }; } fn resolve_json_array( environment: &ConfigEnvironment, source: &[serde_json::Value], pointer: &str, provenance: &mut std::collections::BTreeMap>, ) -> ksp_core_lib::Result<(serde_json::Value, serde_json::Value, crate::ConfigSensitivity)> { let mut value = std::vec::Vec::::with_capacity(source.len()); let mut safe_value = std::vec::Vec::::with_capacity(source.len()); let mut sensitivity = crate::ConfigSensitivity::Public; if source.is_empty() { sensitivity = crate::ConfigSensitivity::Internal; } for (index, item) in source.iter().enumerate() { let child_pointer = format!("{pointer}/{index}"); let resolved = resolve_json_node(environment, item, child_pointer.as_str(), provenance); let (resolved_value, resolved_safe_value, resolved_sensitivity) = match resolved { std::result::Result::Ok(value) => value, std::result::Result::Err(error) => return std::result::Result::Err(error), }; value.push(resolved_value); safe_value.push(resolved_safe_value); sensitivity = sensitivity.strongest(resolved_sensitivity); } return std::result::Result::Ok((serde_json::Value::Array(value), serde_json::Value::Array(safe_value), sensitivity)); } fn resolve_json_object( environment: &ConfigEnvironment, source: &serde_json::Map, pointer: &str, provenance: &mut std::collections::BTreeMap>, ) -> ksp_core_lib::Result<(serde_json::Value, serde_json::Value, crate::ConfigSensitivity)> { let mut value = serde_json::Map::::new(); let mut safe_value = serde_json::Map::::new(); let mut sensitivity = crate::ConfigSensitivity::Public; if source.is_empty() { sensitivity = crate::ConfigSensitivity::Internal; } for (key, item) in source { let escaped_key = escape_json_pointer_token(key.as_str()); let child_pointer = format!("{pointer}/{escaped_key}"); let resolved = resolve_json_node(environment, item, child_pointer.as_str(), provenance); let (resolved_value, resolved_safe_value, resolved_sensitivity) = match resolved { std::result::Result::Ok(value) => value, std::result::Result::Err(error) => return std::result::Result::Err(error), }; value.insert(key.clone(), resolved_value); safe_value.insert(key.clone(), resolved_safe_value); sensitivity = sensitivity.strongest(resolved_sensitivity); } return std::result::Result::Ok((serde_json::Value::Object(value), serde_json::Value::Object(safe_value), sensitivity)); } fn escape_json_pointer_token(value: &str) -> String { return value.replace('~', "~0").replace('/', "~1"); } fn has_supported_namespace(variable_name: &str) -> bool { return variable_name.starts_with("KSP_") || variable_name.starts_with("KSPB_"); } fn is_generic_dotenv_name(variable_name: &str) -> bool { let mut bytes = variable_name.bytes(); let first = match bytes.next() { std::option::Option::Some(value) => value, std::option::Option::None => return false, }; if !(first.is_ascii_alphabetic() || first == b'_') { return false; } for byte in bytes { if !(byte.is_ascii_alphanumeric() || byte == b'_') { return false; } } return true; } fn emit_missing_variable_warning(variable_name: &str) { ksp_logging_lib::warn!(target: crate::TRACING_TARGET, domain = LOGGING_DOMAIN, variable_name = variable_name, "Config environment variable is missing"); } fn missing_variable_error(variable_name: &str) -> ksp_core_lib::Error { return ksp_core_lib::Error::new(crate::ERROR_CODE_ENVIRONMENT_VARIABLE_MISSING, "required Config environment variable is missing") .with_context("variable_name", variable_name); } fn invalid_variable_error(variable_name: &str, reason: &'static str) -> ksp_core_lib::Error { return ksp_core_lib::Error::new(crate::ERROR_CODE_ENVIRONMENT_VARIABLE_INVALID, "Config environment variable name is invalid") .with_context("variable_name", variable_name) .with_context("reason", reason); } fn invalid_environment_value_error(variable_name: &str) -> ksp_core_lib::Error { return ksp_core_lib::Error::new(crate::ERROR_CODE_ENVIRONMENT_VALUE_INVALID, "Config environment variable value is not valid UTF-8") .with_context("variable_name", variable_name); } fn invalid_placeholder_error(reason: &'static str) -> ksp_core_lib::Error { return ksp_core_lib::Error::new(crate::ERROR_CODE_ENVIRONMENT_PLACEHOLDER_INVALID, "Config environment placeholder is invalid") .with_context("reason", reason); } fn dotenv_read_error(path: &std::path::Path, source: std::io::Error) -> ksp_core_lib::Error { return ksp_core_lib::Error::new(crate::ERROR_CODE_DOTENV_FILE_READ_FAILED, "Config cannot read the local .env file") .with_context("path", path.to_string_lossy().into_owned()) .with_source(source); } fn dotenv_syntax_error(path: &std::path::Path, line_number: usize, reason: &'static str) -> ksp_core_lib::Error { return ksp_core_lib::Error::new(crate::ERROR_CODE_DOTENV_SYNTAX_INVALID, "Config local .env syntax is invalid") .with_context("path", path.to_string_lossy().into_owned()) .with_context("line", line_number.to_string()) .with_context("reason", reason); } fn dotenv_duplicate_error(path: &std::path::Path, line_number: usize, variable_name: &str) -> ksp_core_lib::Error { return ksp_core_lib::Error::new(crate::ERROR_CODE_DOTENV_SYNTAX_INVALID, "Config local .env contains a duplicate KSP variable") .with_context("path", path.to_string_lossy().into_owned()) .with_context("line", line_number.to_string()) .with_context("variable_name", variable_name); } #[cfg(test)] #[path = "../unit_tests/environment.rs"] mod tests;