From 274428bd2f6152cf03f77e66e63a21592d90d29a Mon Sep 17 00:00:00 2001 From: SinuS Von SifriduS Date: Sun, 16 Aug 2026 04:35:23 +0200 Subject: [PATCH] v0.1.3-pre.011 --- Cargo.toml | 4 +- crates/ksp-config-lib/src/environment.rs | 272 +++++++++++---- crates/ksp-config-lib/src/lib.rs | 20 +- crates/ksp-config-lib/src/profile.rs | 32 +- crates/ksp-config-lib/src/sensitivity.rs | 209 +++++++++++ crates/ksp-config-lib/tests/public_api.rs | 29 +- .../ksp-config-lib/unit_tests/environment.rs | 128 ++++++- .../ksp-config-lib/unit_tests/sensitivity.rs | 31 ++ deltas/0.1.3/pre.011.md | 329 ++++++++++++++++++ docs/plans/000-README.md | 4 +- docs/plans/002-FUNCTIONAL_RELEASE_SEQUENCE.md | 4 +- .../005-V0_1_3_CONFIG_FOUNDATION_PLAN.md | 28 +- docs/rules/RULES_KSP.md | 6 +- 13 files changed, 1005 insertions(+), 91 deletions(-) create mode 100644 crates/ksp-config-lib/src/sensitivity.rs create mode 100644 crates/ksp-config-lib/unit_tests/sensitivity.rs create mode 100644 deltas/0.1.3/pre.011.md diff --git a/Cargo.toml b/Cargo.toml index 55aaacb..2faf921 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,12 +1,12 @@ # file: Cargo.toml -# version: 53 +# version: 54 [workspace] resolver = "3" members = ["crates/ksp-config-lib", "crates/ksp-core-lib", "crates/ksp-logging-lib"] [workspace.package] -version = "0.1.3-pre.10.fix.1" +version = "0.1.3-pre.11" edition = "2024" license = "MIT" repository = "https://git.sasedev.com/Sasedev/khadhroony-solana-project" diff --git a/crates/ksp-config-lib/src/environment.rs b/crates/ksp-config-lib/src/environment.rs index f159420..2ee6904 100644 --- a/crates/ksp-config-lib/src/environment.rs +++ b/crates/ksp-config-lib/src/environment.rs @@ -1,5 +1,5 @@ // file: crates/ksp-config-lib/src/environment.rs -// version: 1 +// version: 2 /// Default local environment file read by Config from the process launch directory. pub const DEFAULT_DOTENV_PATH: &str = ".env"; @@ -21,14 +21,13 @@ pub enum ConfigEnvironmentSource { Fallback, } -/// One resolved Config environment variable and its winning source. -/// -/// This type intentionally does not implement `Debug`: environment values may contain secrets. Sensitivity-aware safe representations are added by the next -/// bounded Config prerelease. +/// 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, } @@ -45,11 +44,41 @@ impl ConfigEnvironmentValue { 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. @@ -84,25 +113,13 @@ impl ConfigEnvironment { return std::result::Result::Err(error); } if let std::option::Option::Some(value) = self.process.get(variable_name) { - return std::result::Result::Ok(ConfigEnvironmentValue { - variable_name: variable_name.to_owned(), - value: value.clone(), - source: ConfigEnvironmentSource::Process, - }); + return resolved_environment_value(variable_name, value.as_str(), ConfigEnvironmentSource::Process); } if let std::option::Option::Some(value) = self.dotenv.get(variable_name) { - return std::result::Result::Ok(ConfigEnvironmentValue { - variable_name: variable_name.to_owned(), - value: value.clone(), - source: ConfigEnvironmentSource::DotEnv, - }); + return resolved_environment_value(variable_name, value.as_str(), ConfigEnvironmentSource::DotEnv); } if let std::option::Option::Some(value) = fallback { - return std::result::Result::Ok(ConfigEnvironmentValue { - variable_name: variable_name.to_owned(), - value: value.to_owned(), - source: ConfigEnvironmentSource::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)); @@ -110,20 +127,49 @@ impl ConfigEnvironment { /// Resolves `${NAME}` and `${NAME:-fallback}` placeholders embedded in one UTF-8 string. /// - /// Multiple placeholders are supported. Fallback text is literal in this prerelease and is not recursively interpreted as another placeholder expression. + /// 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 mut output = String::new(); + 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 => { - output.push_str(remaining); - return std::result::Result::Ok(output); + 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)); }, }; - output.push_str(&remaining[..start]); + 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 { @@ -141,45 +187,48 @@ impl ConfigEnvironment { std::result::Result::Ok(value) => value, std::result::Result::Err(error) => return std::result::Result::Err(error), }; - output.push_str(resolved.value()); + 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 string values while preserving keys and non-string JSON values. + /// 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 { - return match source { - serde_json::Value::Null | serde_json::Value::Bool(_) | serde_json::Value::Number(_) => std::result::Result::Ok(source.clone()), - serde_json::Value::String(value) => { - let resolved = self.resolve_text(value.as_str()); - match resolved { - std::result::Result::Ok(value) => std::result::Result::Ok(serde_json::Value::String(value)), - std::result::Result::Err(error) => std::result::Result::Err(error), - } - }, - serde_json::Value::Array(values) => resolve_array(self, values), - serde_json::Value::Object(values) => { - let resolved = self.resolve_map(values); - match resolved { - std::result::Result::Ok(value) => std::result::Result::Ok(serde_json::Value::Object(value)), - std::result::Result::Err(error) => std::result::Result::Err(error), - } - }, + 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 one JSON object map while leaving the map keys unchanged. + /// 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 mut output = serde_json::Map::::new(); - for (key, value) in source { - let resolved = self.resolve_json(value); - let resolved = match resolved { - std::result::Result::Ok(value) => value, - std::result::Result::Err(error) => return std::result::Result::Err(error), - }; - output.insert(key.clone(), resolved); - } - return std::result::Result::Ok(output); + 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")), + }; } fn load_from_dotenv_path(dotenv_path: &std::path::Path) -> ksp_core_lib::Result { @@ -350,20 +399,117 @@ fn parse_placeholder_expression(expression: &str) -> ksp_core_lib::Result<(&str, return std::result::Result::Ok((variable_name, fallback)); } -fn resolve_array(environment: &ConfigEnvironment, source: &[serde_json::Value]) -> ksp_core_lib::Result { - let mut output = std::vec::Vec::::with_capacity(source.len()); - for value in source { - let resolved = environment.resolve_json(value); - let resolved = match resolved { +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), }; - output.push(resolved); + value.push(resolved_value); + safe_value.push(resolved_safe_value); + sensitivity = sensitivity.strongest(resolved_sensitivity); } - return std::result::Result::Ok(serde_json::Value::Array(output)); + return std::result::Result::Ok((serde_json::Value::Array(value), serde_json::Value::Array(safe_value), sensitivity)); } -fn validate_supported_variable_name(variable_name: &str) -> ksp_core_lib::Result<()> { +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"); +} + +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")); } diff --git a/crates/ksp-config-lib/src/lib.rs b/crates/ksp-config-lib/src/lib.rs index 3072a26..1f76447 100644 --- a/crates/ksp-config-lib/src/lib.rs +++ b/crates/ksp-config-lib/src/lib.rs @@ -1,14 +1,15 @@ // file: crates/ksp-config-lib/src/lib.rs -// version: 6 +// version: 7 #![warn(missing_docs)] #![deny(unreachable_pub)] #![forbid(unsafe_code)] //! KSP-owned application configuration facade. //! -//! `0.1.3-pre.010` owns bootstrap roots, the logical file registry, generic JSON/JSON Schema loading, standard-document profile resolution, generic composite +//! `0.1.3-pre.011` owns bootstrap roots, the logical file registry, generic JSON/JSON Schema loading, standard-document profile resolution, generic composite //! resolution and KSP/KSPB environment resolution through process + `.env` + fallback precedence. The standard Logging document remains the first registered -//! runtime document. Sensitivity-aware safe values and persistence remain in later bounded prereleases. +//! runtime document. Environment-derived values now preserve real/safe representations, sensitivity and provenance; persistence remains in a later bounded +//! prerelease. mod bootstrap; mod composite; @@ -17,6 +18,7 @@ mod environment; mod error; mod profile; mod registry; +mod sensitivity; /// Bootstrap argument used to replace the configuration document root. pub use self::bootstrap::ARG_CFG_PATH; @@ -40,7 +42,7 @@ pub use self::document::ConfigJsonDocument; pub use self::environment::ConfigEnvironment; /// Source that supplied one resolved Config environment variable. pub use self::environment::ConfigEnvironmentSource; -/// One resolved Config environment variable and its winning source. +/// One resolved Config environment variable with real/safe values, sensitivity and its winning source. pub use self::environment::ConfigEnvironmentValue; /// Versioned environment contract template expected at the repository/runtime root. pub use self::environment::DEFAULT_DOTENV_EXAMPLE_PATH; @@ -112,3 +114,13 @@ pub use self::registry::FILE_ID_SCHEMA_COMPOSITE; pub use self::registry::FILE_ID_SCHEMA_STD_LOGGING; /// Logical file identifier for the standard Logging configuration document. pub use self::registry::FILE_ID_STD_LOGGING; +/// Sensitivity assigned to one Config value after environment resolution. +pub use self::sensitivity::ConfigSensitivity; +/// Provenance segment participating in one resolved Config value. +pub use self::sensitivity::ConfigValueProvenance; +/// Replacement used for secret environment fragments in safe diagnostic representations. +pub use self::sensitivity::REDACTED_CONFIG_VALUE; +/// Recursively resolved JSON value preserving real/safe trees and provenance. +pub use self::sensitivity::ResolvedConfigJson; +/// One resolved Config string preserving real/safe representations and provenance. +pub use self::sensitivity::ResolvedConfigText; diff --git a/crates/ksp-config-lib/src/profile.rs b/crates/ksp-config-lib/src/profile.rs index cad361c..5a89b56 100644 --- a/crates/ksp-config-lib/src/profile.rs +++ b/crates/ksp-config-lib/src/profile.rs @@ -1,5 +1,5 @@ // file: crates/ksp-config-lib/src/profile.rs -// version: 3 +// version: 4 /// Origin of one top-level value in a resolved standard Config profile. #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -83,9 +83,31 @@ impl ResolvedConfigProfile { return self.origins.get(key).copied(); } - /// Resolves environment placeholders in the effective view while preserving this source profile and its Global/Profile provenance unchanged. + /// Resolves environment placeholders in the effective view and returns only the real runtime map. + /// + /// Use [`Self::resolve_effective_environment_detailed`] when safe value, sensitivity and environment provenance are required. Global/Profile provenance on + /// this source profile remains unchanged in both cases. pub fn resolve_effective_environment(&self, environment: &crate::ConfigEnvironment) -> ksp_core_lib::Result> { - return environment.resolve_map(&self.effective); + let resolved = self.resolve_effective_environment_detailed(environment); + let resolved = match resolved { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + return match resolved.value() { + serde_json::Value::Object(value) => std::result::Result::Ok(value.clone()), + _ => std::result::Result::Err(ksp_core_lib::Error::new( + crate::ERROR_CODE_DOCUMENT_SEMANTIC_INVALID, + "resolved Config profile effective view changed JSON shape", + )), + }; + } + + /// Resolves environment placeholders while preserving real/safe JSON trees, strongest sensitivity and JSON-Pointer environment provenance. + /// + /// Top-level Global/Profile provenance remains available through [`Self::origin`]; the returned value adds literal/process/`.env`/fallback provenance for + /// the environment-resolution stage. + pub fn resolve_effective_environment_detailed(&self, environment: &crate::ConfigEnvironment) -> ksp_core_lib::Result { + return environment.resolve_json_detailed(&serde_json::Value::Object(self.effective.clone())); } } @@ -93,8 +115,8 @@ impl crate::ConfigDocumentEngine { /// Loads, validates and resolves one standard Config document to its default or explicitly requested profile. /// /// Passing `None` selects the autonomous `default_profile` declared by the document. Passing `Some(profile_id)` selects that profile explicitly. - /// Environment interpolation is intentionally not applied implicitly by profile selection. Call `ResolvedConfigProfile::resolve_effective_environment` with - /// a Config-owned environment snapshot when an effective runtime view is required. + /// Environment interpolation is intentionally not applied implicitly by profile selection. Call `ResolvedConfigProfile::resolve_effective_environment` for + /// a real runtime map or `ResolvedConfigProfile::resolve_effective_environment_detailed` when safe value, sensitivity and provenance are also required. pub fn load_resolved_profile( &self, file_id: &crate::ConfigFileId, diff --git a/crates/ksp-config-lib/src/sensitivity.rs b/crates/ksp-config-lib/src/sensitivity.rs new file mode 100644 index 0000000..cb86e91 --- /dev/null +++ b/crates/ksp-config-lib/src/sensitivity.rs @@ -0,0 +1,209 @@ +// file: crates/ksp-config-lib/src/sensitivity.rs +// version: 1 + +/// Replacement used for secret environment fragments in safe diagnostic representations. +pub const REDACTED_CONFIG_VALUE: &str = "********"; + +/// Sensitivity assigned to one Config value after environment resolution. +#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)] +pub enum ConfigSensitivity { + /// Value may be exposed by a public projection when the consumer contract allows it. + Public, + /// Value is available to the runtime but is not generically public. + Internal, + /// Value must remain available to legitimate runtime/management consumers while being redacted from ordinary diagnostics. + Secret, +} + +impl ConfigSensitivity { + /// Classifies one supported KSP/KSPB environment variable by its namespace. + pub fn from_variable_name(variable_name: &str) -> ksp_core_lib::Result { + let validation = crate::environment::validate_supported_variable_name(variable_name); + if let std::result::Result::Err(error) = validation { + return std::result::Result::Err(error); + } + if variable_name.starts_with("KSP_SECRET_") || variable_name.starts_with("KSPB_SECRET_") { + return std::result::Result::Ok(Self::Secret); + } + if variable_name.starts_with("KSP_PUBLIC_") || variable_name.starts_with("KSPB_PUBLIC_") { + return std::result::Result::Ok(Self::Public); + } + return std::result::Result::Ok(Self::Internal); + } + + /// Returns the strongest of two sensitivities using `Secret > Internal > Public`. + #[must_use] + pub const fn strongest(self, other: Self) -> Self { + if self as u8 >= other as u8 { + return self; + } + return other; + } + + /// Returns whether this sensitivity requires ordinary diagnostic redaction. + #[must_use] + pub const fn is_secret(self) -> bool { + return matches!(self, Self::Secret); + } +} + +/// Provenance segment participating in one resolved Config value. +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum ConfigValueProvenance { + /// Literal text/value came directly from the Config document. + DocumentLiteral, + /// Environment substitution came from the inherited process environment. + EnvironmentProcess { + /// Referenced variable name; never its value. + variable_name: String, + }, + /// Environment substitution came from the local `.env` file. + EnvironmentDotEnv { + /// Referenced variable name; never its value. + variable_name: String, + }, + /// Environment substitution used the placeholder/API fallback. + EnvironmentFallback { + /// Referenced variable name; never its value. + variable_name: String, + }, +} + +impl ConfigValueProvenance { + /// Returns the referenced variable name for environment provenance. + #[must_use] + pub fn variable_name(&self) -> std::option::Option<&str> { + return match self { + Self::DocumentLiteral => std::option::Option::None, + Self::EnvironmentProcess { variable_name } | Self::EnvironmentDotEnv { variable_name } | Self::EnvironmentFallback { variable_name } => { + std::option::Option::Some(variable_name.as_str()) + }, + }; + } + + /// Returns the environment source represented by this provenance segment when applicable. + #[must_use] + pub const fn environment_source(&self) -> std::option::Option { + return match self { + Self::DocumentLiteral => std::option::Option::None, + Self::EnvironmentProcess { .. } => std::option::Option::Some(crate::ConfigEnvironmentSource::Process), + Self::EnvironmentDotEnv { .. } => std::option::Option::Some(crate::ConfigEnvironmentSource::DotEnv), + Self::EnvironmentFallback { .. } => std::option::Option::Some(crate::ConfigEnvironmentSource::Fallback), + }; + } +} + +/// One resolved Config string with real/safe representations, sensitivity and ordered provenance. +#[derive(Clone, Eq, PartialEq)] +pub struct ResolvedConfigText { + value: String, + safe_value: String, + sensitivity: ConfigSensitivity, + provenance: std::vec::Vec, +} + +impl ResolvedConfigText { + pub(crate) fn new(value: String, safe_value: String, sensitivity: ConfigSensitivity, provenance: std::vec::Vec) -> Self { + return Self { value, safe_value, sensitivity, provenance }; + } + + /// Returns the real runtime 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 strongest sensitivity contributed by referenced environment placeholders. + #[must_use] + pub const fn sensitivity(&self) -> ConfigSensitivity { + return self.sensitivity; + } + + /// Returns ordered provenance segments participating in the resolved string. + #[must_use] + pub fn provenance(&self) -> &[ConfigValueProvenance] { + return self.provenance.as_slice(); + } +} + +impl std::fmt::Debug for ResolvedConfigText { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + return formatter + .debug_struct("ResolvedConfigText") + .field("safe_value", &self.safe_value) + .field("sensitivity", &self.sensitivity) + .field("provenance", &self.provenance) + .finish(); + } +} + +/// Recursively resolved JSON value preserving a real tree, a safe tree and provenance indexed by JSON Pointer. +#[derive(Clone, Eq, PartialEq)] +pub struct ResolvedConfigJson { + value: serde_json::Value, + safe_value: serde_json::Value, + sensitivity: ConfigSensitivity, + provenance: std::collections::BTreeMap>, +} + +impl ResolvedConfigJson { + pub(crate) fn new( + value: serde_json::Value, + safe_value: serde_json::Value, + sensitivity: ConfigSensitivity, + provenance: std::collections::BTreeMap>, + ) -> Self { + return Self { value, safe_value, sensitivity, provenance }; + } + + /// Returns the real JSON tree intended for legitimate runtime consumers. + #[must_use] + pub const fn value(&self) -> &serde_json::Value { + return &self.value; + } + + /// Returns the JSON tree safe for ordinary diagnostics. + #[must_use] + pub const fn safe_value(&self) -> &serde_json::Value { + return &self.safe_value; + } + + /// Returns the strongest sensitivity found anywhere in the resolved JSON tree. + #[must_use] + pub const fn sensitivity(&self) -> ConfigSensitivity { + return self.sensitivity; + } + + /// Returns provenance indexed by RFC 6901 JSON Pointer strings. + #[must_use] + pub const fn provenance(&self) -> &std::collections::BTreeMap> { + return &self.provenance; + } + + /// Returns provenance for one JSON Pointer when the resolved value recorded that location. + #[must_use] + pub fn provenance_at(&self, json_pointer: &str) -> std::option::Option<&[ConfigValueProvenance]> { + return self.provenance.get(json_pointer).map(std::vec::Vec::as_slice); + } +} + +impl std::fmt::Debug for ResolvedConfigJson { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + return formatter + .debug_struct("ResolvedConfigJson") + .field("safe_value", &self.safe_value) + .field("sensitivity", &self.sensitivity) + .field("provenance", &self.provenance) + .finish(); + } +} + +#[cfg(test)] +#[path = "../unit_tests/sensitivity.rs"] +mod tests; diff --git a/crates/ksp-config-lib/tests/public_api.rs b/crates/ksp-config-lib/tests/public_api.rs index 72c2673..fabc2e5 100644 --- a/crates/ksp-config-lib/tests/public_api.rs +++ b/crates/ksp-config-lib/tests/public_api.rs @@ -1,7 +1,7 @@ // file: crates/ksp-config-lib/tests/public_api.rs -// version: 7 +// version: 8 -//! Integration tests for the public `ksp-config-lib` bootstrap, registry, JSON/profile/composite and environment-resolution contracts. +//! Integration tests for the public `ksp-config-lib` bootstrap, registry, JSON/profile/composite, environment-resolution and sensitivity contracts. #[test] fn bootstrap_contract_is_available_from_crate_root() { @@ -143,3 +143,28 @@ fn environment_resolution_contract_is_available_from_crate_root() { assert_ne!(ksp_config_lib::ConfigEnvironmentSource::Process, ksp_config_lib::ConfigEnvironmentSource::DotEnv); assert_ne!(ksp_config_lib::ConfigEnvironmentSource::DotEnv, ksp_config_lib::ConfigEnvironmentSource::Fallback); } + +#[test] +fn sensitivity_and_safe_resolution_contracts_are_available_from_crate_root() { + assert_eq!( + ksp_config_lib::ConfigSensitivity::from_variable_name("KSP_PUBLIC_HOST").ok(), + std::option::Option::Some(ksp_config_lib::ConfigSensitivity::Public), + ); + assert_eq!( + ksp_config_lib::ConfigSensitivity::from_variable_name("KSP_MODE").ok(), + std::option::Option::Some(ksp_config_lib::ConfigSensitivity::Internal) + ); + assert_eq!( + ksp_config_lib::ConfigSensitivity::from_variable_name("KSP_SECRET_TOKEN").ok(), + std::option::Option::Some(ksp_config_lib::ConfigSensitivity::Secret), + ); + assert_eq!(ksp_config_lib::REDACTED_CONFIG_VALUE, "********"); + let detailed_text: fn(&ksp_config_lib::ConfigEnvironment, &str) -> ksp_core_lib::Result = + ksp_config_lib::ConfigEnvironment::resolve_text_detailed; + let detailed_json: fn(&ksp_config_lib::ConfigEnvironment, &serde_json::Value) -> ksp_core_lib::Result = + ksp_config_lib::ConfigEnvironment::resolve_json_detailed; + let _ = (detailed_text, detailed_json); + let provenance = ksp_config_lib::ConfigValueProvenance::EnvironmentFallback { variable_name: "KSP_SECRET_TOKEN".to_owned() }; + assert_eq!(provenance.variable_name(), std::option::Option::Some("KSP_SECRET_TOKEN")); + assert_eq!(provenance.environment_source(), std::option::Option::Some(ksp_config_lib::ConfigEnvironmentSource::Fallback)); +} diff --git a/crates/ksp-config-lib/unit_tests/environment.rs b/crates/ksp-config-lib/unit_tests/environment.rs index 9289852..182ec4f 100644 --- a/crates/ksp-config-lib/unit_tests/environment.rs +++ b/crates/ksp-config-lib/unit_tests/environment.rs @@ -1,5 +1,5 @@ // file: crates/ksp-config-lib/unit_tests/environment.rs -// version: 2 +// version: 3 #[test] fn process_environment_wins_over_dotenv_and_fallback_even_when_empty() { @@ -234,3 +234,129 @@ fn env_example_inventory_contains_current_runtime_variable_with_preceding_commen fn workspace_root() -> std::path::PathBuf { return std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../.."); } + +#[test] +fn secret_environment_value_keeps_real_value_but_redacts_safe_and_debug_views() { + let canary = "KSP_SECRET_CANARY_91b7c6"; + let mut process = std::collections::BTreeMap::::new(); + process.insert("KSP_SECRET_TEST_TOKEN".to_owned(), canary.to_owned()); + let environment = super::ConfigEnvironment::from_maps(process, std::collections::BTreeMap::new()); + let resolved = environment.resolve_variable("KSP_SECRET_TEST_TOKEN", std::option::Option::None); + assert!(resolved.is_ok(), "secret process value should resolve"); + let resolved = match resolved { + std::result::Result::Ok(value) => value, + std::result::Result::Err(_) => return, + }; + assert_eq!(resolved.value(), canary); + assert_eq!(resolved.safe_value(), crate::REDACTED_CONFIG_VALUE); + assert_eq!(resolved.sensitivity(), crate::ConfigSensitivity::Secret); + assert_eq!(resolved.source(), super::ConfigEnvironmentSource::Process); + let debug = format!("{resolved:?}"); + assert!(!debug.contains(canary), "Debug must not reveal the secret canary"); + assert!(debug.contains(crate::REDACTED_CONFIG_VALUE)); +} + +#[test] +fn detailed_text_redacts_only_secret_segments_and_keeps_ordered_provenance() { + let secret = "secret-canary-4a62"; + let mut process = std::collections::BTreeMap::::new(); + process.insert("KSP_PUBLIC_HOST".to_owned(), "rpc.example.test".to_owned()); + process.insert("KSP_SECRET_TOKEN".to_owned(), secret.to_owned()); + let environment = super::ConfigEnvironment::from_maps(process, std::collections::BTreeMap::new()); + let resolved = environment.resolve_text_detailed("https://${KSP_PUBLIC_HOST}/?token=${KSP_SECRET_TOKEN}"); + assert!(resolved.is_ok(), "composed secret URL should resolve"); + let resolved = match resolved { + std::result::Result::Ok(value) => value, + std::result::Result::Err(_) => return, + }; + assert_eq!(resolved.value(), "https://rpc.example.test/?token=secret-canary-4a62"); + assert_eq!(resolved.safe_value(), "https://rpc.example.test/?token=********"); + assert_eq!(resolved.sensitivity(), crate::ConfigSensitivity::Secret); + assert_eq!(resolved.provenance().len(), 4); + assert_eq!(resolved.provenance()[0], crate::ConfigValueProvenance::DocumentLiteral); + assert_eq!(resolved.provenance()[1].variable_name(), std::option::Option::Some("KSP_PUBLIC_HOST")); + assert_eq!(resolved.provenance()[2], crate::ConfigValueProvenance::DocumentLiteral); + assert_eq!(resolved.provenance()[3].variable_name(), std::option::Option::Some("KSP_SECRET_TOKEN")); + let debug = format!("{resolved:?}"); + assert!(!debug.contains(secret), "resolved text Debug must not reveal a secret segment"); +} + +#[test] +fn secret_fallback_inherits_secret_sensitivity_and_is_redacted() { + let environment = super::ConfigEnvironment::from_maps(std::collections::BTreeMap::new(), std::collections::BTreeMap::new()); + let resolved = environment.resolve_text_detailed("token=${KSP_SECRET_TOKEN:-false-secret}"); + assert!(resolved.is_ok(), "secret fallback should resolve"); + let resolved = match resolved { + std::result::Result::Ok(value) => value, + std::result::Result::Err(_) => return, + }; + assert_eq!(resolved.value(), "token=false-secret"); + assert_eq!(resolved.safe_value(), "token=********"); + assert_eq!(resolved.sensitivity(), crate::ConfigSensitivity::Secret); + assert_eq!(resolved.provenance()[1].environment_source(), std::option::Option::Some(super::ConfigEnvironmentSource::Fallback)); +} + +#[test] +fn detailed_json_preserves_safe_tree_sensitivity_and_pointer_provenance() { + let secret = "nested-secret-canary-2d11"; + let mut dotenv = std::collections::BTreeMap::::new(); + dotenv.insert("KSP_SECRET_TOKEN".to_owned(), secret.to_owned()); + let environment = super::ConfigEnvironment::from_maps(std::collections::BTreeMap::new(), dotenv); + let source = serde_json::json!({"transport": {"url": "https://host/?token=${KSP_SECRET_TOKEN}"}, "items": ["plain", 7]}); + let resolved = environment.resolve_json_detailed(&source); + assert!(resolved.is_ok(), "detailed JSON should resolve"); + let resolved = match resolved { + std::result::Result::Ok(value) => value, + std::result::Result::Err(_) => return, + }; + assert_eq!(resolved.value()["transport"]["url"], serde_json::Value::String(format!("https://host/?token={secret}"))); + assert_eq!(resolved.safe_value()["transport"]["url"], serde_json::Value::String("https://host/?token=********".to_owned())); + assert_eq!(resolved.sensitivity(), crate::ConfigSensitivity::Secret); + let provenance = resolved.provenance_at("/transport/url"); + assert!(provenance.is_some(), "JSON pointer provenance should exist"); + if let std::option::Option::Some(provenance) = provenance { + assert_eq!(provenance.last().and_then(crate::ConfigValueProvenance::variable_name), std::option::Option::Some("KSP_SECRET_TOKEN")); + } + let debug = format!("{resolved:?}"); + assert!(!debug.contains(secret), "resolved JSON Debug must not reveal a secret canary"); +} + +#[test] +fn detailed_profile_environment_keeps_global_origin_and_adds_environment_provenance() { + let workspace = workspace_root(); + let bootstrap = crate::ConfigBootstrapOptions::from_paths(workspace.join("config"), workspace.join("config/schemas")); + let bootstrap = match bootstrap { + std::result::Result::Ok(value) => value, + std::result::Result::Err(_) => return, + }; + let registry = crate::ConfigFileRegistry::defaults(); + let registry = match registry { + std::result::Result::Ok(value) => value, + std::result::Result::Err(_) => return, + }; + let file_id = crate::ConfigFileId::new(crate::FILE_ID_STD_LOGGING); + let file_id = match file_id { + std::result::Result::Ok(value) => value, + std::result::Result::Err(_) => return, + }; + let engine = crate::ConfigDocumentEngine::new(bootstrap, registry); + let profile = engine.load_resolved_profile(&file_id, std::option::Option::None); + let profile = match profile { + std::result::Result::Ok(value) => value, + std::result::Result::Err(_) => return, + }; + let environment = super::ConfigEnvironment::from_maps(std::collections::BTreeMap::new(), std::collections::BTreeMap::new()); + let effective = profile.resolve_effective_environment_detailed(&environment); + assert!(effective.is_ok(), "detailed committed Logging profile should resolve"); + let effective = match effective { + std::result::Result::Ok(value) => value, + std::result::Result::Err(_) => return, + }; + assert_eq!(profile.origin("logs_directory"), std::option::Option::Some(crate::ConfigValueOrigin::Global)); + assert_eq!(effective.value()["logs_directory"], serde_json::Value::String("logs".to_owned())); + assert_eq!(effective.safe_value()["logs_directory"], serde_json::Value::String("logs".to_owned())); + assert_eq!( + effective.provenance_at("/logs_directory").and_then(|items| items.last()).and_then(crate::ConfigValueProvenance::variable_name), + std::option::Option::Some("KSP_LOGS_DIRECTORY"), + ); +} diff --git a/crates/ksp-config-lib/unit_tests/sensitivity.rs b/crates/ksp-config-lib/unit_tests/sensitivity.rs new file mode 100644 index 0000000..23da105 --- /dev/null +++ b/crates/ksp-config-lib/unit_tests/sensitivity.rs @@ -0,0 +1,31 @@ +// file: crates/ksp-config-lib/unit_tests/sensitivity.rs +// version: 1 + +#[test] +fn environment_names_map_to_expected_sensitivity() { + assert_eq!(super::ConfigSensitivity::from_variable_name("KSP_PUBLIC_ENDPOINT").ok(), std::option::Option::Some(super::ConfigSensitivity::Public)); + assert_eq!(super::ConfigSensitivity::from_variable_name("KSP_MODE").ok(), std::option::Option::Some(super::ConfigSensitivity::Internal)); + assert_eq!(super::ConfigSensitivity::from_variable_name("KSP_SECRET_PASSWORD").ok(), std::option::Option::Some(super::ConfigSensitivity::Secret)); + assert_eq!(super::ConfigSensitivity::from_variable_name("KSPB_PUBLIC_ENDPOINT").ok(), std::option::Option::Some(super::ConfigSensitivity::Public)); + assert_eq!(super::ConfigSensitivity::from_variable_name("KSPB_MODE").ok(), std::option::Option::Some(super::ConfigSensitivity::Internal)); + assert_eq!(super::ConfigSensitivity::from_variable_name("KSPB_SECRET_PASSWORD").ok(), std::option::Option::Some(super::ConfigSensitivity::Secret)); +} + +#[test] +fn strongest_sensitivity_follows_secret_internal_public_order() { + assert_eq!(super::ConfigSensitivity::Public.strongest(super::ConfigSensitivity::Internal), super::ConfigSensitivity::Internal); + assert_eq!(super::ConfigSensitivity::Internal.strongest(super::ConfigSensitivity::Secret), super::ConfigSensitivity::Secret); + assert_eq!(super::ConfigSensitivity::Secret.strongest(super::ConfigSensitivity::Public), super::ConfigSensitivity::Secret); +} + +#[test] +fn provenance_exposes_names_and_sources_without_values() { + let process = super::ConfigValueProvenance::EnvironmentProcess { variable_name: "KSP_SECRET_TOKEN".to_owned() }; + let dotenv = super::ConfigValueProvenance::EnvironmentDotEnv { variable_name: "KSP_MODE".to_owned() }; + let fallback = super::ConfigValueProvenance::EnvironmentFallback { variable_name: "KSP_PUBLIC_HOST".to_owned() }; + assert_eq!(process.variable_name(), std::option::Option::Some("KSP_SECRET_TOKEN")); + assert_eq!(process.environment_source(), std::option::Option::Some(crate::ConfigEnvironmentSource::Process)); + assert_eq!(dotenv.environment_source(), std::option::Option::Some(crate::ConfigEnvironmentSource::DotEnv)); + assert_eq!(fallback.environment_source(), std::option::Option::Some(crate::ConfigEnvironmentSource::Fallback)); + assert_eq!(super::ConfigValueProvenance::DocumentLiteral.variable_name(), std::option::Option::None); +} diff --git a/deltas/0.1.3/pre.011.md b/deltas/0.1.3/pre.011.md new file mode 100644 index 0000000..e200bfc --- /dev/null +++ b/deltas/0.1.3/pre.011.md @@ -0,0 +1,329 @@ + + + +# Delta 0.1.3-pre.011 + +## Base requise + +Livraison précédente validée : + +```text +0.1.3-pre.010-fix.001 +``` + +Version technique de cette base : + +```text +workspace.package.version = "0.1.3-pre.10.fix.1" +Cargo.toml header version = 53 +``` + +Validations utilisateur exécutées le 2026-08-15 : + +```text +cargo fmt --all OK +cargo check --workspace OK +cargo clippy --workspace --all-targets OK +cargo test --workspace OK +cargo tree -p ksp-config-lib OK +cargo tree -p ksp-config-lib -d OK, doublon transitif syn 2/3 déjà connu +cargo tree -p ksp-config-lib -e features OK +``` + +`cargo test --workspace` confirme notamment 53 tests unitaires + 8 tests publics pour `ksp-config-lib`. + +## Objet de pre.011 + +Ajouter la couche de sécurité/provenance qui manquait au resolver environnemental : + +```text +Config source string / JSON + + +process > .env > fallback + -> +real runtime value +safe diagnostic value +Public | Internal | Secret +ordered / JSON-Pointer provenance +``` + +Cette tranche ne modifie pas encore les documents Config, ne construit pas `LoggingSettings` et ne persiste rien. + +## Classification de sensibilité + +`ConfigSensitivity` expose : + +```text +Public +Internal +Secret +``` + +Classification nominale : + +```text +KSP_SECRET_* / KSPB_SECRET_* -> Secret +KSP_PUBLIC_* / KSPB_PUBLIC_* -> Public +autre KSP_* / KSPB_* -> Internal +``` + +Ordre : + +```text +Secret > Internal > Public +``` + +La sensibilité d'une chaîne contenant des placeholders est la sensibilité la plus forte des placeholders effectivement référencés. Une chaîne littérale sans placeholder est `Internal`. + +Un fallback hérite toujours de la sensibilité du nom de variable référencé. Ainsi : + +```text +${KSP_SECRET_TOKEN:-false-secret} +``` + +reste `Secret` même si la valeur réelle vient du fallback. + +## Valeur réelle et représentation sûre + +`ConfigEnvironmentValue` conserve désormais : + +```text +variable_name +value réel runtime +safe_value diagnostic sûr +sensitivity +source Process | DotEnv | Fallback +provenance +``` + +Pour `Secret` : + +```text +value = valeur réelle +safe_value = ******** +``` + +Pour `Public` et `Internal`, la représentation sûre conserve la valeur réelle dans cette tranche. + +`ConfigEnvironmentValue` possède un `Debug` manuel qui n'affiche jamais `value`; il affiche `safe_value`, sensibilité, source et nom de variable. + +## Chaînes composées + +Nouveau contrat : + +```text +ResolvedConfigText +``` + +Il conserve : + +```text +value +safe_value +sensitivity +provenance[] +``` + +Exemple : + +```text +source: +https://${KSP_PUBLIC_HOST}/?token=${KSP_SECRET_TOKEN} + +real: +https://rpc.example.test/?token=secret-canary + +safe: +https://rpc.example.test/?token=******** +``` + +La redaction est réalisée par segment de substitution. Les fragments littéraux et non secrets restent visibles dans la représentation sûre. + +`ConfigEnvironment::resolve_text_detailed()` expose ce contrat. + +L'API existante : + +```text +resolve_text(...) +``` + +reste compatible et retourne uniquement la valeur réelle. + +## Provenance + +`ConfigValueProvenance` distingue : + +```text +DocumentLiteral +EnvironmentProcess { variable_name } +EnvironmentDotEnv { variable_name } +EnvironmentFallback { variable_name } +``` + +La provenance n'embarque jamais la valeur d'environnement elle-même. + +Une chaîne composée conserve l'ordre des segments/références ayant participé à sa construction. + +## JSON détaillé + +Nouveau contrat : + +```text +ResolvedConfigJson +``` + +Il conserve : + +```text +value arbre JSON réel +safe_value arbre JSON redacted +sensitivity plus forte sensibilité contenue +provenance map JSON Pointer -> provenance[] +``` + +`ConfigEnvironment::resolve_json_detailed()` parcourt récursivement objets et tableaux sans modifier les clés. + +Les JSON Pointer suivent RFC 6901 pour les clés contenant `~` ou `/`. + +L'API existante : + +```text +resolve_json(...) +resolve_map(...) +``` + +reste compatible et retourne uniquement la valeur réelle. + +## Profil effectif + +`ResolvedConfigProfile` ajoute : + +```text +resolve_effective_environment_detailed(...) +``` + +La provenance top-level déjà existante : + +```text +Global +Profile +``` + +reste attachée au profil source via `origin(key)`. + +La résolution détaillée ajoute séparément la provenance de la couche environnement : + +```text +DocumentLiteral +Process +DotEnv +Fallback +``` + +L'ancienne méthode `resolve_effective_environment()` reste disponible et retourne seulement la map réelle. + +## Non-divulgation + +Les tests canary vérifient notamment : + +- secret venant du process : réel disponible, safe redacted, `Debug` sans canary ; +- secret venant d'un fallback : fallback réel disponible mais safe redacted ; +- URL composée public + secret : seul le segment secret est masqué ; +- JSON imbriqué : arbre réel complet, arbre safe redacted, `Debug` sans canary ; +- provenance de la variable sans transport de la valeur elle-même. + +`ksp-logging-lib` ne reçoit aucune logique générique de redaction de messages arbitraires. La valeur sûre est construite par Config avant qu'un diagnostic ne l'utilise. + +## Clarification `KSP_LOGS_DIRECTORY` + +La décision préparatoire de l'adapter `pre.012` est enregistrée dans le plan : + +- après interpolation, `logs_directory` pourra être absolu ou relatif ; +- un chemin relatif sera interprété relativement au current working directory du processus qui initialise Logging, pas automatiquement au répertoire du binaire ; +- le fallback `logs` s'applique seulement si `KSP_LOGS_DIRECTORY` est absent ; +- une valeur explicitement présente mais invalide ne déclenche pas le fallback et devra produire un diagnostic de configuration effective invalide ; +- les paths de sinks restent relatifs sous `logs_directory` sans traversal. + +Aucun code de validation/conversion Logging correspondant n'est ajouté dans `pre.011`; cette responsabilité appartient à `pre.012`. + +## `.env.example` + +Aucune nouvelle variable runtime n'est introduite. + +`.env.example` reste donc inchangé et contient toujours la seule variable actuellement utilisée : + +```text +KSP_LOGS_DIRECTORY=logs +``` + +## Dépendances + +Aucune dépendance ou feature Cargo n'est ajoutée/modifiée. + +La direction reste : + +```text +ksp-config-lib -> ksp-logging-lib -> ksp-core-lib +ksp-config-lib -> ksp-core-lib +``` + +Logging ne dépend toujours pas de Config. + +## Version technique + +La prerelease devient : + +```text +workspace.package.version = "0.1.3-pre.11" +Cargo.toml header version = 54 +``` + +## Fichiers ajoutés + +```text +crates/ksp-config-lib/src/sensitivity.rs +crates/ksp-config-lib/unit_tests/sensitivity.rs +deltas/0.1.3/pre.011.md +``` + +## Fichiers modifiés + +```text +Cargo.toml +crates/ksp-config-lib/src/environment.rs +crates/ksp-config-lib/src/lib.rs +crates/ksp-config-lib/src/profile.rs +crates/ksp-config-lib/tests/public_api.rs +crates/ksp-config-lib/unit_tests/environment.rs +docs/plans/000-README.md +docs/plans/002-FUNCTIONAL_RELEASE_SEQUENCE.md +docs/plans/005-V0_1_3_CONFIG_FOUNDATION_PLAN.md +docs/rules/RULES_KSP.md +``` + +## Fichiers supprimés + +Aucun. + +## Contrôles exécutés dans l'environnement de génération + +- comparaison statique avec la base validée `pre.010-fix.001` ; +- absence de nouvelle dépendance/feature Cargo ; +- absence de nouvelle variable runtime nécessitant une modification de `.env.example` ; +- audit statique du nouveau code Config contre `unsafe`, `unwrap`, `expect`, `panic!` et opérateur `?` de production ; +- contrôle des headers/version ; +- contrôle des lignes Rust à 160 colonnes maximum ; +- contrôle canary dans les tests : les représentations safe/Debug attendues ne contiennent pas le secret ; +- reproduction du delta sur la base précédente avant packaging. + +Le toolchain Rust n'est pas disponible dans l'environnement de génération. `cargo fmt/check/clippy/test` doivent donc être exécutés par l'utilisateur. + +## Étape suivante + +Après validation utilisateur : + +```text +0.1.3-pre.012 — adapter Config -> Logging +``` + +Cette tranche devra notamment valider/résoudre `logs_directory`, convertir le profil Logging effectif vers les contrats publics `ksp_logging_lib::*` et démontrer `initialize/reinitialize` sans créer de dépendance Logging -> Config. diff --git a/docs/plans/000-README.md b/docs/plans/000-README.md index d4d1ec1..b46b4c5 100644 --- a/docs/plans/000-README.md +++ b/docs/plans/000-README.md @@ -1,5 +1,5 @@ - + # Plans KSP @@ -13,7 +13,7 @@ Un plan décrit le périmètre, les décisions déjà acquises, les questions ou - [`002-FUNCTIONAL_RELEASE_SEQUENCE.md`](002-FUNCTIONAL_RELEASE_SEQUENCE.md) — séquence active de référence des premières releases fonctionnelles ; - [`003-V0_1_1_CORE_FOUNDATION_PLAN.md`](003-V0_1_1_CORE_FOUNDATION_PLAN.md) — plan historique clôturé de la release stable `0.1.1`, établi par `0.1.1-pre.001` puis consolidé jusqu'à `0.1.1-rel.001`. - [`004-V0_1_2_LOGGING_FOUNDATION_PLAN.md`](004-V0_1_2_LOGGING_FOUNDATION_PLAN.md) — plan historique clôturé de la release stable `0.1.2`, établi par `0.1.2-pre.001` puis consolidé jusqu'à `0.1.2-rel.001`. -- [`005-V0_1_3_CONFIG_FOUNDATION_PLAN.md`](005-V0_1_3_CONFIG_FOUNDATION_PLAN.md) — plan actif de `0.1.3 — Configuration foundation`, établi par `0.1.3-pre.001`, corrigé par `pre.001-fix.001`, complété par `pre.001-fix.002` pour le registre `file_id`/bootstrap/non-régression Logging, regranularisé par `pre.001-fix.003`, puis rescindé pendant `pre.005`; `pre.006` a fermé le routing structuré `domain`, `pre.007` le moteur JSON/JSON Schema et le premier `std.logging.json`, `pre.008` la résolution globals/profils/`default_profile`, puis `pre.009` les compositions génériques par `file_id`; `pre.010` a livré `.env`, process env, `.env.example` et le resolver `${...}`; `pre.011` poursuivra avec sensibilité, valeur réelle/sûre et provenance. +- [`005-V0_1_3_CONFIG_FOUNDATION_PLAN.md`](005-V0_1_3_CONFIG_FOUNDATION_PLAN.md) — plan actif de `0.1.3 — Configuration foundation`, établi par `0.1.3-pre.001`, corrigé par `pre.001-fix.001`, complété par `pre.001-fix.002` pour le registre `file_id`/bootstrap/non-régression Logging, regranularisé par `pre.001-fix.003`, puis rescindé pendant `pre.005`; `pre.006` a fermé le routing structuré `domain`, `pre.007` le moteur JSON/JSON Schema et le premier `std.logging.json`, `pre.008` la résolution globals/profils/`default_profile`, puis `pre.009` les compositions génériques par `file_id`; `pre.010` a livré `.env`, process env, `.env.example` et le resolver `${...}`; `pre.011` ajoute sensibilité, valeur réelle/sûre, redaction et provenance; `pre.012` poursuivra avec l'adapter Config -> Logging. Le `pre.001` de chaque release fonctionnelle peut introduire son propre plan détaillé lorsque la release s'ouvre. diff --git a/docs/plans/002-FUNCTIONAL_RELEASE_SEQUENCE.md b/docs/plans/002-FUNCTIONAL_RELEASE_SEQUENCE.md index 57869c6..c762818 100644 --- a/docs/plans/002-FUNCTIONAL_RELEASE_SEQUENCE.md +++ b/docs/plans/002-FUNCTIONAL_RELEASE_SEQUENCE.md @@ -1,5 +1,5 @@ - + # Séquence des releases fonctionnelles KSP @@ -219,7 +219,7 @@ pre.015 clôture Cette prévision n'est pas un plafond : chaque prerelease doit rester une petite tranche, avec scission explicite si l'objectif dépasse environ 15–20 minutes de travail effectif. -`pre.006` a fermé le routing Logging structuré `domain`; `pre.007` a livré le moteur JSON/JSON Schema et `std.logging.json`; `pre.008` a ajouté la résolution générique globals/profils/`default_profile`; `pre.009` a ajouté les compositions génériques par `file_id`, avec `schema.composite` mais sans composite runtime fictif; `pre.010` ajoute le snapshot process + `.env`, `.env.example` et le resolver `${...}`. Après validation utilisateur, `pre.011` ajoutera sensibilité, valeur réelle/sûre et provenance enrichie. +`pre.006` a fermé le routing Logging structuré `domain`; `pre.007` a livré le moteur JSON/JSON Schema et `std.logging.json`; `pre.008` a ajouté la résolution générique globals/profils/`default_profile`; `pre.009` a ajouté les compositions génériques par `file_id`, avec `schema.composite` mais sans composite runtime fictif; `pre.010` a ajouté le snapshot process + `.env`, `.env.example` et le resolver `${...}`; `pre.011` ajoute sensibilité, valeurs réelle/sûre, redaction et provenance enrichie. Après validation utilisateur, `pre.012` construira l'adapter Config -> Logging. ## `0.1.4` — Config desktop par défaut diff --git a/docs/plans/005-V0_1_3_CONFIG_FOUNDATION_PLAN.md b/docs/plans/005-V0_1_3_CONFIG_FOUNDATION_PLAN.md index 0489193..0c7adaf 100644 --- a/docs/plans/005-V0_1_3_CONFIG_FOUNDATION_PLAN.md +++ b/docs/plans/005-V0_1_3_CONFIG_FOUNDATION_PLAN.md @@ -1,11 +1,11 @@ - + # Plan `0.1.3` — Configuration foundation ## 1. Statut et objectif -Ce plan a été établi par `0.1.3-pre.001`, corrigé par `0.1.3-pre.001-fix.001/.002/.003`, puis exécuté par petites tranches. `pre.002` a livré le bootstrap Config, `pre.003` le registre `file_id`, `pre.004` les contrats publics multi-output de Logging, `pre.005` le runtime multi-sink sur niveau/target/formats, `pre.006` le routing structuré `domain`, `pre.007` le moteur JSON/JSON Schema, `pre.008` la résolution des globals/profils/`default_profile`, `pre.009` les compositions génériques par `file_id` et `pre.010` le snapshot process + `.env` et le resolver `${...}`. La prochaine tranche est `pre.011` pour sensibilité, valeur réelle/sûre et provenance enrichie. +Ce plan a été établi par `0.1.3-pre.001`, corrigé par `0.1.3-pre.001-fix.001/.002/.003`, puis exécuté par petites tranches. `pre.002` a livré le bootstrap Config, `pre.003` le registre `file_id`, `pre.004` les contrats publics multi-output de Logging, `pre.005` le runtime multi-sink sur niveau/target/formats, `pre.006` le routing structuré `domain`, `pre.007` le moteur JSON/JSON Schema, `pre.008` la résolution des globals/profils/`default_profile`, `pre.009` les compositions génériques par `file_id`, `pre.010` le snapshot process + `.env` et le resolver `${...}`, puis `pre.011` la sensibilité et les représentations real/safe/provenance. La prochaine tranche est `pre.012` pour l'adapter Config -> Logging. La base auditée reste la release stable `v0.1.2`. @@ -583,6 +583,8 @@ Décisions : - `logs_directory` est global, hors profils ; - son fallback est exprimé dans le document au point d'usage ; +- après interpolation, `logs_directory` peut être absolu ou relatif ; un chemin relatif est interprété relativement au current working directory du processus qui initialise Logging, et non automatiquement relativement au répertoire du binaire ; +- un `KSP_LOGS_DIRECTORY` explicitement présent mais invalide n'active jamais le fallback : le fallback reste réservé à l'absence de la variable et l'adapter Config -> Logging doit retourner un diagnostic de configuration effective invalide ; - `default_profile` est global et référence un `profile_id` existant ; - `profile_id` est obligatoire et unique dans le document ; - `default_filter` gouverne le comportement général du profil ; @@ -1802,15 +1804,23 @@ Tranche livrée : - la règle durable impose désormais d'ajouter toute nouvelle variable runtime KSP/KSPB à `.env.example`, avec commentaire d'usage, dans le même delta que sa première utilisation ; - `.gitignore` possédait déjà la règle correcte `.env`, `.env.*`, `!.env.example`; aucun changement n'est nécessaire. +La validation utilisateur de `pre.010-fix.001` est acquise : `fmt/check/clippy/test` passent, les 53 tests unitaires Config et 8 tests publics passent, et le graphe Cargo reste conforme avec le doublon transitif `syn 2`/`syn 3` déjà connu via `jsonschema`. + ### `0.1.3-pre.011` — sensibilité + valeurs real/safe/provenance -- `Public/Internal/Secret` ; -- propagation de sensibilité par chaîne composée ; -- redaction par segment ; -- contrat `ResolvedValue` ou équivalent ; -- valeur réelle + représentation sûre + provenance ; -- interdiction des secrets dans logs/diagnostics ordinaires ; -- tests canary de non-divulgation. +Tranche livrée : + +- `ConfigSensitivity::{Public, Internal, Secret}` dérive la classification directement des namespaces KSP/KSPB et conserve l'ordre `Secret > Internal > Public` ; +- `ConfigEnvironmentValue` conserve désormais valeur réelle, valeur sûre, sensibilité, source et provenance ; son `Debug` utilise uniquement la valeur sûre ; +- `ResolvedConfigText` conserve la chaîne réelle, sa représentation sûre, la sensibilité la plus forte et une provenance ordonnée ; +- les segments issus de `KSP_SECRET_*`/`KSPB_SECRET_*` sont remplacés par `********` dans la représentation sûre, y compris lorsqu'ils viennent d'un fallback ; +- les chaînes composées propagent la sensibilité la plus forte des placeholders utilisés ; une chaîne sans placeholder est `Internal` ; +- `ConfigValueProvenance` distingue `DocumentLiteral`, `EnvironmentProcess`, `EnvironmentDotEnv` et `EnvironmentFallback` sans embarquer les valeurs elles-mêmes ; +- `ResolvedConfigJson` conserve arbres réel/sûr, sensibilité agrégée et provenance indexée par JSON Pointer ; +- les APIs existantes `resolve_text`, `resolve_json` et `resolve_effective_environment` restent compatibles et retournent uniquement les valeurs réelles ; les variantes `*_detailed` exposent le contrat enrichi ; +- la provenance Global/Profile déjà portée par `ResolvedConfigProfile` reste distincte et inchangée ; `resolve_effective_environment_detailed` ajoute la provenance de la couche environnement ; +- des canaries de tests vérifient qu'une valeur secrète process, fallback ou imbriquée dans un JSON n'apparait pas dans `safe_value` ni dans `Debug` ; +- aucune nouvelle dépendance Cargo et aucune nouvelle variable d'environnement ne sont introduites dans cette tranche. ### `0.1.3-pre.012` — adapter Config -> Logging diff --git a/docs/rules/RULES_KSP.md b/docs/rules/RULES_KSP.md index 0ee01f3..3e778d7 100644 --- a/docs/rules/RULES_KSP.md +++ b/docs/rules/RULES_KSP.md @@ -1,5 +1,5 @@ - + # Règles spécifiques à KSP @@ -36,6 +36,10 @@ - **KSP-CONFIG-005** — `.env.example` est versionné à la racine et inventorie toutes les variables d'environnement runtime KSP/KSPB utilisées par les fichiers Config ou le code ; toute nouvelle variable y est ajoutée dans le même delta que sa première utilisation. - **KSP-CONFIG-006** — Chaque entrée de `.env.example` est précédée d'un commentaire décrivant son usage/utilité. Sa valeur peut être un défaut sûr, une valeur générique non secrète ou une entrée commentée ; aucun vrai secret n'y est enregistré. - **KSP-CONFIG-007** — Les placeholders Config utilisent `${NAME}` ou `${NAME:-fallback}`. Le fallback s'applique uniquement si la variable est absente ; l'interpolation appartient à `ksp-config-lib` et non aux consumers. +- **KSP-CONFIG-008** — La sensibilité d'une variable est dérivée de son nom : `KSP_SECRET_*`/`KSPB_SECRET_*` -> `Secret`, `KSP_PUBLIC_*`/`KSPB_PUBLIC_*` -> `Public`, les autres variables KSP/KSPB -> `Internal`, avec l'ordre `Secret > Internal > Public`. +- **KSP-CONFIG-009** — Toute valeur Config contenant un segment `Secret` conserve une valeur réelle pour le runtime légitime et une représentation sûre où chaque segment secret est remplacé par `********`; les `Debug` génériques ne révèlent jamais la valeur réelle secrète. +- **KSP-CONFIG-010** — La sensibilité d'une chaîne composée est la plus forte des placeholders utilisés. Un fallback d'une variable `Secret` reste `Secret` et doit être redacted même si la valeur provient du fallback. +- **KSP-CONFIG-011** — La provenance de résolution n'embarque jamais la valeur d'environnement elle-même ; elle distingue document literal, process, `.env` et fallback avec le nom de variable concerné. ## Programmes et exécution