diff --git a/Cargo.toml b/Cargo.toml index 2e30394..55841aa 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,12 +1,12 @@ # file: Cargo.toml -# version: 56 +# version: 57 [workspace] resolver = "3" members = ["crates/ksp-config-lib", "crates/ksp-core-lib", "crates/ksp-logging-lib"] [workspace.package] -version = "0.1.3-pre.12" +version = "0.1.3-pre.13" edition = "2024" license = "MIT" repository = "https://git.sasedev.com/Sasedev/khadhroony-solana-project" diff --git a/crates/ksp-config-lib/src/document.rs b/crates/ksp-config-lib/src/document.rs index 5543ccf..3ec7e67 100644 --- a/crates/ksp-config-lib/src/document.rs +++ b/crates/ksp-config-lib/src/document.rs @@ -1,5 +1,5 @@ // file: crates/ksp-config-lib/src/document.rs -// version: 3 +// version: 4 /// A Config-managed JSON document that has passed syntax, schema and current semantic validation. #[derive(Clone, Debug, PartialEq)] @@ -82,7 +82,41 @@ impl ConfigDocumentEngine { std::result::Result::Ok(value) => value, std::result::Result::Err(error) => return std::result::Result::Err(error), }; - let schema = self.load_json(&schema_file_id); + return self.validate_document(document, &schema_file_id); + } + + pub(crate) fn validate_candidate(&self, file_id: &crate::ConfigFileId, value: serde_json::Value) -> ksp_core_lib::Result { + let descriptor = self.registry.descriptor(file_id); + let descriptor = match descriptor { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + if descriptor.kind() != crate::ConfigFileKind::Config { + return std::result::Result::Err( + ksp_core_lib::Error::new(crate::ERROR_CODE_FILE_MAPPING_INVALID, "requested file_id does not identify a Config document") + .with_context("file_id", file_id.as_str()), + ); + } + let schema_file_id = match descriptor.schema_file_id() { + std::option::Option::Some(value) => value.clone(), + std::option::Option::None => { + return std::result::Result::Err( + ksp_core_lib::Error::new(crate::ERROR_CODE_FILE_MAPPING_INVALID, "Config document has no registered validation schema") + .with_context("file_id", file_id.as_str()), + ); + }, + }; + let path = self.registry.resolve_path(&self.bootstrap, file_id); + let path = match path { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let document = ConfigJsonDocument { file_id: file_id.clone(), path, value }; + return self.validate_document(document, &schema_file_id); + } + + fn validate_document(&self, document: ConfigJsonDocument, schema_file_id: &crate::ConfigFileId) -> ksp_core_lib::Result { + let schema = self.load_json(schema_file_id); let schema = match schema { std::result::Result::Ok(value) => value, std::result::Result::Err(error) => return std::result::Result::Err(error), diff --git a/crates/ksp-config-lib/src/environment.rs b/crates/ksp-config-lib/src/environment.rs index 1476ce7..cf34eb8 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: 3 +// version: 4 /// Default local environment file read by Config from the process launch directory. pub const DEFAULT_DOTENV_PATH: &str = ".env"; @@ -231,7 +231,7 @@ impl ConfigEnvironment { }; } - fn load_from_dotenv_path(dotenv_path: &std::path::Path) -> ksp_core_lib::Result { + 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, @@ -245,6 +245,14 @@ impl ConfigEnvironment { 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) }; @@ -288,7 +296,7 @@ fn load_dotenv_file(path: &std::path::Path) -> ksp_core_lib::Result ksp_core_lib::Result> { +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 }; diff --git a/crates/ksp-config-lib/src/error.rs b/crates/ksp-config-lib/src/error.rs index de5e455..15f0738 100644 --- a/crates/ksp-config-lib/src/error.rs +++ b/crates/ksp-config-lib/src/error.rs @@ -1,5 +1,5 @@ // file: crates/ksp-config-lib/src/error.rs -// version: 7 +// version: 8 /// Error code used when a Config bootstrap argument is missing its value. pub const ERROR_CODE_BOOTSTRAP_ARGUMENT_MISSING_VALUE: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("config", "bootstrap_argument_missing_value"); @@ -60,3 +60,9 @@ pub const ERROR_CODE_ENVIRONMENT_PLACEHOLDER_INVALID: ksp_core_lib::ErrorCode = /// Error code used when an environment-resolved Config cannot be mapped safely to a runtime consumer contract. pub const ERROR_CODE_EFFECTIVE_CONFIG_INVALID: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("config", "effective_config_invalid"); + +/// Error code used when an explicit Config management operation is unsupported or targets the wrong managed resource kind. +pub const ERROR_CODE_MANAGEMENT_OPERATION_INVALID: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("config", "management_operation_invalid"); + +/// Error code used when an atomic managed Config or `.env` persistence operation fails before commit. +pub const ERROR_CODE_PERSISTENCE_WRITE_FAILED: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("config", "persistence_write_failed"); diff --git a/crates/ksp-config-lib/src/lib.rs b/crates/ksp-config-lib/src/lib.rs index b588fa0..3f7c77f 100644 --- a/crates/ksp-config-lib/src/lib.rs +++ b/crates/ksp-config-lib/src/lib.rs @@ -1,15 +1,15 @@ // file: crates/ksp-config-lib/src/lib.rs -// version: 8 +// version: 9 #![warn(missing_docs)] #![deny(unreachable_pub)] #![forbid(unsafe_code)] //! KSP-owned application configuration facade. //! -//! `0.1.3-pre.012` owns bootstrap roots, the logical file registry, generic JSON/JSON Schema loading, standard-document profile resolution, generic composite +//! `0.1.3-pre.013` 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. Environment-derived values preserve real/safe representations, sensitivity and provenance; the standard Logging profile can now be -//! mapped explicitly to `ksp_logging_lib::LoggingSettings`. Persistence remains in a later bounded prerelease. +//! mapped explicitly to `ksp_logging_lib::LoggingSettings`. Explicit management now owns typed Logging mutation, safe environment reports, privileged reveal calls and atomic JSON/`.env` persistence. mod bootstrap; mod composite; @@ -17,6 +17,8 @@ mod document; mod environment; mod error; mod logging; +mod management; +mod persistence; mod profile; mod registry; mod sensitivity; @@ -83,6 +85,10 @@ pub use self::error::ERROR_CODE_FILE_MAPPING_INVALID; pub use self::error::ERROR_CODE_JSON_FILE_READ_FAILED; /// Error code used when a Config-managed file contains invalid JSON syntax. pub use self::error::ERROR_CODE_JSON_SYNTAX_INVALID; +/// Error code used when an explicit management operation is unsupported or targets the wrong managed resource kind. +pub use self::error::ERROR_CODE_MANAGEMENT_OPERATION_INVALID; +/// Error code used when atomic managed Config or `.env` persistence fails before commit. +pub use self::error::ERROR_CODE_PERSISTENCE_WRITE_FAILED; /// Error code used when an explicitly requested Config profile does not exist. pub use self::error::ERROR_CODE_PROFILE_NOT_FOUND; /// Error code used when a JSON Schema document is itself invalid. @@ -91,6 +97,28 @@ pub use self::error::ERROR_CODE_SCHEMA_INVALID; pub use self::error::ERROR_CODE_SCHEMA_VALIDATION_FAILED; /// Effective standard Logging configuration mapped to `ksp_logging_lib::LoggingSettings`. pub use self::logging::ResolvedLoggingConfig; +/// Result of one validated Config document persistence operation. +pub use self::management::ConfigDocumentChangeReport; +/// Result of one persistent `.env` mutation. +pub use self::management::ConfigEnvironmentChangeReport; +/// Safe desired/effective/shadow view of one KSP/KSPB environment variable. +pub use self::management::ConfigEnvironmentReport; +/// Raw source of one registered Config document read through the explicit management surface. +pub use self::management::ConfigManagedSource; +/// Explicit Config management facade for source inspection and validated persistent mutations. +pub use self::management::ConfigManagement; +/// Typed source contract for `config/std.logging.json`. +pub use self::management::LoggingConfigDocument; +/// Typed source contract for the standard Logging console output. +pub use self::management::LoggingConsoleConfig; +/// Typed source contract for one persistent Logging file output. +pub use self::management::LoggingFileConfig; +/// Typed source contract for one Logging sink selector/filter. +pub use self::management::LoggingOutputFilterConfig; +/// Typed source contract for one profile in `std.logging.json`. +pub use self::management::LoggingProfileConfig; +/// Typed source contract for one global Logging target override. +pub use self::management::LoggingTargetFilterConfig; /// Source that selected an effective standard Config profile. pub use self::profile::ConfigProfileSelectionSource; /// Origin of one top-level value in a resolved standard Config profile. diff --git a/crates/ksp-config-lib/src/management.rs b/crates/ksp-config-lib/src/management.rs new file mode 100644 index 0000000..37b4325 --- /dev/null +++ b/crates/ksp-config-lib/src/management.rs @@ -0,0 +1,1008 @@ +// file: crates/ksp-config-lib/src/management.rs +// version: 1 + +/// Raw source of one registered Config document read for explicit management/correction. +#[derive(Clone, Eq, PartialEq)] +pub struct ConfigManagedSource { + file_id: crate::ConfigFileId, + path: std::path::PathBuf, + content: String, +} + +impl ConfigManagedSource { + /// Returns the stable Config file identifier. + #[must_use] + pub const fn file_id(&self) -> &crate::ConfigFileId { + return &self.file_id; + } + + /// Returns the resolved managed path. + #[must_use] + pub fn path(&self) -> &std::path::Path { + return self.path.as_path(); + } + + /// Returns the raw source text through the explicit management surface. + #[must_use] + pub fn content(&self) -> &str { + return self.content.as_str(); + } +} + +impl std::fmt::Debug for ConfigManagedSource { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + return formatter.debug_struct("ConfigManagedSource").field("file_id", &self.file_id).field("path", &self.path).finish_non_exhaustive(); + } +} + +/// Safe desired/effective view of one KSP/KSPB environment variable for management diagnostics. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ConfigEnvironmentReport { + variable_name: String, + sensitivity: crate::ConfigSensitivity, + desired_safe_value: std::option::Option, + effective_safe_value: std::option::Option, + effective_source: std::option::Option, + shadowed_by_process_environment: bool, +} + +impl ConfigEnvironmentReport { + /// Returns the environment variable name. + #[must_use] + pub fn variable_name(&self) -> &str { + return self.variable_name.as_str(); + } + + /// Returns the variable sensitivity derived from its namespace. + #[must_use] + pub const fn sensitivity(&self) -> crate::ConfigSensitivity { + return self.sensitivity; + } + + /// Returns the persisted `.env` value in safe/redacted form when configured. + #[must_use] + pub fn desired_safe_value(&self) -> std::option::Option<&str> { + return self.desired_safe_value.as_deref(); + } + + /// Returns the current process-or-`.env` effective value in safe/redacted form when configured. + #[must_use] + pub fn effective_safe_value(&self) -> std::option::Option<&str> { + return self.effective_safe_value.as_deref(); + } + + /// Returns the source currently winning before any placeholder fallback is considered. + #[must_use] + pub const fn effective_source(&self) -> std::option::Option { + return self.effective_source; + } + + /// Returns whether a persisted `.env` value is currently shadowed by the inherited process environment. + #[must_use] + pub const fn shadowed_by_process_environment(&self) -> bool { + return self.shadowed_by_process_environment; + } +} + +/// Result of one persistent `.env` mutation. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct ConfigEnvironmentChangeReport { + source_changed: bool, + effective_changed: bool, + shadowed_by_process_environment: bool, + reload_required: bool, +} + +impl ConfigEnvironmentChangeReport { + /// Returns whether the persisted `.env` source changed. + #[must_use] + pub const fn source_changed(&self) -> bool { + return self.source_changed; + } + + /// Returns whether the effective process-or-`.env` value changed for the current process. + #[must_use] + pub const fn effective_changed(&self) -> bool { + return self.effective_changed; + } + + /// Returns whether the new persisted `.env` state is shadowed by the inherited process environment. + #[must_use] + pub const fn shadowed_by_process_environment(&self) -> bool { + return self.shadowed_by_process_environment; + } + + /// Returns whether Config consumers must reload their environment snapshot to observe the effective change. + #[must_use] + pub const fn reload_required(&self) -> bool { + return self.reload_required; + } +} + +/// Result of one validated Config document persistence operation. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct ConfigDocumentChangeReport { + source_changed: bool, + reload_required: bool, +} + +impl ConfigDocumentChangeReport { + /// Returns whether the persisted document bytes changed. + #[must_use] + pub const fn source_changed(&self) -> bool { + return self.source_changed; + } + + /// Returns whether consumers must reload the Config document to observe the change. + #[must_use] + pub const fn reload_required(&self) -> bool { + return self.reload_required; + } +} + +/// Typed source contract for `config/std.logging.json`. +#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)] +#[serde(deny_unknown_fields)] +pub struct LoggingConfigDocument { + format_version: u32, + logs_directory: String, + default_profile: String, + profiles: std::vec::Vec, +} + +impl LoggingConfigDocument { + /// Creates a version-1 Logging Config document candidate. + #[must_use] + pub fn new( + logs_directory: impl std::convert::Into, + default_profile: impl std::convert::Into, + profiles: std::vec::Vec, + ) -> Self { + return Self { + format_version: 1, + logs_directory: logs_directory.into(), + default_profile: default_profile.into(), + profiles, + }; + } + + /// Returns the source format version. + #[must_use] + pub const fn format_version(&self) -> u32 { + return self.format_version; + } + + /// Returns the source Logging root expression/path. + #[must_use] + pub fn logs_directory(&self) -> &str { + return self.logs_directory.as_str(); + } + + /// Replaces the source Logging root expression/path. + pub fn set_logs_directory(&mut self, value: impl std::convert::Into) { + self.logs_directory = value.into(); + } + + /// Returns the autonomous default profile identifier. + #[must_use] + pub fn default_profile(&self) -> &str { + return self.default_profile.as_str(); + } + + /// Replaces the autonomous default profile identifier. + pub fn set_default_profile(&mut self, value: impl std::convert::Into) { + self.default_profile = value.into(); + } + + /// Returns the source Logging profiles. + #[must_use] + pub fn profiles(&self) -> &[LoggingProfileConfig] { + return self.profiles.as_slice(); + } + + /// Returns mutable source Logging profiles; persistence validates the complete candidate before commit. + pub fn profiles_mut(&mut self) -> &mut std::vec::Vec { + return &mut self.profiles; + } +} + +/// Typed source contract for one profile in `std.logging.json`. +#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)] +#[serde(deny_unknown_fields)] +pub struct LoggingProfileConfig { + profile_id: String, + default_filter: String, + span_events: String, + console: LoggingConsoleConfig, + files: std::vec::Vec, + target_filters: std::vec::Vec, +} + +impl LoggingProfileConfig { + /// Creates one Logging profile candidate. + #[must_use] + pub fn new( + profile_id: impl std::convert::Into, + default_filter: impl std::convert::Into, + span_events: impl std::convert::Into, + console: LoggingConsoleConfig, + files: std::vec::Vec, + target_filters: std::vec::Vec, + ) -> Self { + return Self { + profile_id: profile_id.into(), + default_filter: default_filter.into(), + span_events: span_events.into(), + console, + files, + target_filters, + }; + } + + /// Returns the stable profile identifier. + #[must_use] + pub fn profile_id(&self) -> &str { + return self.profile_id.as_str(); + } + + /// Replaces the profile identifier. + pub fn set_profile_id(&mut self, value: impl std::convert::Into) { + self.profile_id = value.into(); + } + + /// Returns the profile default filter text. + #[must_use] + pub fn default_filter(&self) -> &str { + return self.default_filter.as_str(); + } + + /// Replaces the profile default filter text. + pub fn set_default_filter(&mut self, value: impl std::convert::Into) { + self.default_filter = value.into(); + } + + /// Returns the span lifecycle setting text. + #[must_use] + pub fn span_events(&self) -> &str { + return self.span_events.as_str(); + } + + /// Replaces the span lifecycle setting text. + pub fn set_span_events(&mut self, value: impl std::convert::Into) { + self.span_events = value.into(); + } + + /// Returns the console source configuration. + #[must_use] + pub const fn console(&self) -> &LoggingConsoleConfig { + return &self.console; + } + + /// Returns mutable console source configuration. + pub fn console_mut(&mut self) -> &mut LoggingConsoleConfig { + return &mut self.console; + } + + /// Returns the persistent file source configurations. + #[must_use] + pub fn files(&self) -> &[LoggingFileConfig] { + return self.files.as_slice(); + } + + /// Returns mutable persistent file source configurations. + pub fn files_mut(&mut self) -> &mut std::vec::Vec { + return &mut self.files; + } + + /// Returns the global target overrides. + #[must_use] + pub fn target_filters(&self) -> &[LoggingTargetFilterConfig] { + return self.target_filters.as_slice(); + } + + /// Returns mutable global target overrides. + pub fn target_filters_mut(&mut self) -> &mut std::vec::Vec { + return &mut self.target_filters; + } +} + +/// Typed source contract for the Logging console output. +#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)] +#[serde(deny_unknown_fields)] +pub struct LoggingConsoleConfig { + enabled: bool, + output: String, + ansi: bool, + format: String, + filter: LoggingOutputFilterConfig, +} + +impl LoggingConsoleConfig { + /// Creates a console source candidate. + #[must_use] + pub fn new( + enabled: bool, + output: impl std::convert::Into, + ansi: bool, + format: impl std::convert::Into, + filter: LoggingOutputFilterConfig, + ) -> Self { + return Self { enabled, output: output.into(), ansi, format: format.into(), filter }; + } + + /// Returns whether the console output is enabled. + #[must_use] + pub const fn enabled(&self) -> bool { + return self.enabled; + } + + /// Sets whether the console output is enabled. + pub fn set_enabled(&mut self, value: bool) { + self.enabled = value; + } + + /// Returns `stdout` or `stderr` source text. + #[must_use] + pub fn output(&self) -> &str { + return self.output.as_str(); + } + + /// Replaces the console output source text. + pub fn set_output(&mut self, value: impl std::convert::Into) { + self.output = value.into(); + } + + /// Returns whether ANSI output is requested. + #[must_use] + pub const fn ansi(&self) -> bool { + return self.ansi; + } + + /// Sets whether ANSI output is requested. + pub fn set_ansi(&mut self, value: bool) { + self.ansi = value; + } + + /// Returns the console format source text. + #[must_use] + pub fn format(&self) -> &str { + return self.format.as_str(); + } + + /// Replaces the console format source text. + pub fn set_format(&mut self, value: impl std::convert::Into) { + self.format = value.into(); + } + + /// Returns the console output filter. + #[must_use] + pub const fn filter(&self) -> &LoggingOutputFilterConfig { + return &self.filter; + } + + /// Returns the mutable console output filter. + pub fn filter_mut(&mut self) -> &mut LoggingOutputFilterConfig { + return &mut self.filter; + } +} + +/// Typed source contract for one persistent Logging file output. +#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)] +#[serde(deny_unknown_fields)] +pub struct LoggingFileConfig { + output_id: String, + enabled: bool, + path: String, + rotation: String, + format: String, + ansi: bool, + filter: LoggingOutputFilterConfig, +} + +impl LoggingFileConfig { + /// Creates one persistent Logging source candidate. Persistent ANSI defaults to `false` as required by the schema. + #[must_use] + pub fn new( + output_id: impl std::convert::Into, + enabled: bool, + path: impl std::convert::Into, + rotation: impl std::convert::Into, + format: impl std::convert::Into, + filter: LoggingOutputFilterConfig, + ) -> Self { + return Self { + output_id: output_id.into(), + enabled, + path: path.into(), + rotation: rotation.into(), + format: format.into(), + ansi: false, + filter, + }; + } + + /// Returns the stable sink identifier. + #[must_use] + pub fn output_id(&self) -> &str { + return self.output_id.as_str(); + } + + /// Replaces the sink identifier. + pub fn set_output_id(&mut self, value: impl std::convert::Into) { + self.output_id = value.into(); + } + + /// Returns whether the sink is enabled. + #[must_use] + pub const fn enabled(&self) -> bool { + return self.enabled; + } + + /// Sets whether the sink is enabled. + pub fn set_enabled(&mut self, value: bool) { + self.enabled = value; + } + + /// Returns the source path relative to `logs_directory`. + #[must_use] + pub fn path(&self) -> &str { + return self.path.as_str(); + } + + /// Replaces the source path relative to `logs_directory`. + pub fn set_path(&mut self, value: impl std::convert::Into) { + self.path = value.into(); + } + + /// Returns the rotation source text. + #[must_use] + pub fn rotation(&self) -> &str { + return self.rotation.as_str(); + } + + /// Replaces the rotation source text. + pub fn set_rotation(&mut self, value: impl std::convert::Into) { + self.rotation = value.into(); + } + + /// Returns the format source text. + #[must_use] + pub fn format(&self) -> &str { + return self.format.as_str(); + } + + /// Replaces the format source text. + pub fn set_format(&mut self, value: impl std::convert::Into) { + self.format = value.into(); + } + + /// Returns the output filter. + #[must_use] + pub const fn filter(&self) -> &LoggingOutputFilterConfig { + return &self.filter; + } + + /// Returns the mutable output filter. + pub fn filter_mut(&mut self) -> &mut LoggingOutputFilterConfig { + return &mut self.filter; + } +} + +/// Typed source contract for one Logging sink selector/filter. +#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)] +#[serde(deny_unknown_fields)] +pub struct LoggingOutputFilterConfig { + level: String, + targets: std::vec::Vec, + domains: std::vec::Vec, +} + +impl LoggingOutputFilterConfig { + /// Creates one Logging output filter candidate. + #[must_use] + pub fn new(level: impl std::convert::Into, targets: std::vec::Vec, domains: std::vec::Vec) -> Self { + return Self { level: level.into(), targets, domains }; + } + + /// Returns the filter level source text. + #[must_use] + pub fn level(&self) -> &str { + return self.level.as_str(); + } + + /// Replaces the filter level source text. + pub fn set_level(&mut self, value: impl std::convert::Into) { + self.level = value.into(); + } + + /// Returns target selectors. + #[must_use] + pub fn targets(&self) -> &[String] { + return self.targets.as_slice(); + } + + /// Returns mutable target selectors. + pub fn targets_mut(&mut self) -> &mut std::vec::Vec { + return &mut self.targets; + } + + /// Returns structured domain selectors. + #[must_use] + pub fn domains(&self) -> &[String] { + return self.domains.as_slice(); + } + + /// Returns mutable structured domain selectors. + pub fn domains_mut(&mut self) -> &mut std::vec::Vec { + return &mut self.domains; + } +} + +/// Typed source contract for one global Logging target override. +#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)] +#[serde(deny_unknown_fields)] +pub struct LoggingTargetFilterConfig { + target_prefix: String, + level: String, +} + +impl LoggingTargetFilterConfig { + /// Creates one global target override candidate. + #[must_use] + pub fn new(target_prefix: impl std::convert::Into, level: impl std::convert::Into) -> Self { + return Self { target_prefix: target_prefix.into(), level: level.into() }; + } + + /// Returns the KSP target prefix. + #[must_use] + pub fn target_prefix(&self) -> &str { + return self.target_prefix.as_str(); + } + + /// Replaces the KSP target prefix. + pub fn set_target_prefix(&mut self, value: impl std::convert::Into) { + self.target_prefix = value.into(); + } + + /// Returns the override level source text. + #[must_use] + pub fn level(&self) -> &str { + return self.level.as_str(); + } + + /// Replaces the override level source text. + pub fn set_level(&mut self, value: impl std::convert::Into) { + self.level = value.into(); + } +} + +/// Explicit Config management facade for source inspection and validated persistent mutations. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ConfigManagement { + engine: crate::ConfigDocumentEngine, + dotenv_path: std::path::PathBuf, +} + +impl ConfigManagement { + /// Creates a management facade using the conventional process-launch-root `./.env`. + #[must_use] + pub fn new(engine: crate::ConfigDocumentEngine) -> Self { + return Self { engine, dotenv_path: std::path::PathBuf::from(crate::DEFAULT_DOTENV_PATH) }; + } + + /// Returns the Config document engine used by this management facade. + #[must_use] + pub const fn engine(&self) -> &crate::ConfigDocumentEngine { + return &self.engine; + } + + /// Reads raw source text for one registered Config document without requiring schema validity. + /// + /// This explicit management-only read allows a future editor to inspect/correct an invalid document while still preventing arbitrary filesystem paths. + pub fn read_source(&self, file_id: &crate::ConfigFileId) -> ksp_core_lib::Result { + let descriptor = self.engine.registry().descriptor(file_id); + let descriptor = match descriptor { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + if descriptor.kind() != crate::ConfigFileKind::Config { + return std::result::Result::Err(management_error("management source read requires a Config document").with_context("file_id", file_id.as_str())); + } + let path = self.engine.registry().resolve_path(self.engine.bootstrap(), file_id); + let path = match path { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let content = std::fs::read_to_string(path.as_path()); + return match content { + std::result::Result::Ok(content) => std::result::Result::Ok(ConfigManagedSource { file_id: file_id.clone(), path, content }), + std::result::Result::Err(error) => std::result::Result::Err( + ksp_core_lib::Error::new(crate::ERROR_CODE_JSON_FILE_READ_FAILED, "Config-managed JSON file cannot be read") + .with_context("file_id", file_id.as_str()) + .with_context("path", path.to_string_lossy().into_owned()) + .with_source(error), + ), + }; + } + + /// Loads the validated `std.logging.json` source into its typed management contract. + pub fn load_logging_document(&self) -> ksp_core_lib::Result { + let file_id = logging_file_id(); + let file_id = match file_id { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let document = self.engine.load_validated_document(&file_id); + let document = match document { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let parsed = serde_json::from_value::(document.value().clone()); + return match parsed { + std::result::Result::Ok(value) => std::result::Result::Ok(value), + std::result::Result::Err(error) => { + std::result::Result::Err(management_error("validated Logging source cannot be decoded into management contract").with_source(error)) + }, + }; + } + + /// Validates and atomically persists one typed `std.logging.json` candidate. + /// + /// Validation occurs fully before the destination is replaced. The persisted JSON is pretty-printed and terminated by one newline. + pub fn save_logging_document(&self, candidate: &LoggingConfigDocument) -> ksp_core_lib::Result { + let file_id = logging_file_id(); + let file_id = match file_id { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let value = serde_json::to_value(candidate); + let value = match value { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => { + return std::result::Result::Err(management_error("Logging management candidate cannot be serialized").with_source(error)); + }, + }; + let validated = self.engine.validate_candidate(&file_id, value); + let validated = match validated { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let serialized = serde_json::to_string_pretty(validated.value()); + let mut serialized = match serialized { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => { + return std::result::Result::Err(management_error("validated Logging candidate cannot be serialized").with_source(error)); + }, + }; + serialized.push('\n'); + let existing = std::fs::read(validated.path()); + let existing = match existing { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) if error.kind() == std::io::ErrorKind::NotFound => std::vec::Vec::new(), + std::result::Result::Err(error) => { + return std::result::Result::Err( + ksp_core_lib::Error::new(crate::ERROR_CODE_JSON_FILE_READ_FAILED, "existing Config document cannot be read before persistence") + .with_context("file_id", file_id.as_str()) + .with_context("path", validated.path().to_string_lossy().into_owned()) + .with_source(error), + ); + }, + }; + if existing.as_slice() == serialized.as_bytes() { + return std::result::Result::Ok(ConfigDocumentChangeReport { source_changed: false, reload_required: false }); + } + let write = crate::persistence::atomic_write(validated.path(), serialized.as_bytes()); + if let std::result::Result::Err(error) = write { + return std::result::Result::Err(error); + } + return std::result::Result::Ok(ConfigDocumentChangeReport { source_changed: true, reload_required: true }); + } + + /// Returns safe desired/effective/shadow reports for every KSP/KSPB variable present in process or `.env` sources. + pub fn environment_report(&self) -> ksp_core_lib::Result> { + let environment = self.load_environment(); + let environment = match environment { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + return build_environment_reports(&environment); + } + + /// Explicitly reveals the real effective process-or-`.env` value for management display/editing. + /// + /// Authentication/authorization of the human user belongs to the calling application. Calling this method is the explicit Config boundary that opts into + /// real-value access; the returned value must never be logged. + pub fn reveal_effective_environment_value(&self, 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); + } + let environment = self.load_environment(); + let environment = match environment { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + if let std::option::Option::Some(value) = environment.process_values().get(variable_name) { + return std::result::Result::Ok(std::option::Option::Some(value.clone())); + } + return std::result::Result::Ok(environment.dotenv_values().get(variable_name).cloned()); + } + + /// Explicitly reveals the real persisted `.env` value for management display/editing. + pub fn reveal_dotenv_value(&self, 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); + } + let environment = self.load_environment(); + let environment = match environment { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + return std::result::Result::Ok(environment.dotenv_values().get(variable_name).cloned()); + } + + /// Creates or updates one supported KSP/KSPB `.env` entry atomically without mutating the inherited process environment. + pub fn set_dotenv_value(&self, variable_name: &str, value: &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); + } + let before = self.load_environment(); + let before = match before { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + if before.dotenv_values().get(variable_name).map(String::as_str) == std::option::Option::Some(value) { + return std::result::Result::Ok(environment_change_report(variable_name, &before, &before)); + } + let content = read_dotenv_source(self.dotenv_path.as_path()); + let content = match content { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let candidate = update_dotenv_source(self.dotenv_path.as_path(), content.as_str(), variable_name, std::option::Option::Some(value)); + let candidate = match candidate { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + if candidate != content { + let write = crate::persistence::atomic_write_private(self.dotenv_path.as_path(), candidate.as_bytes()); + match write { + std::result::Result::Ok(()) => {}, + std::result::Result::Err(error) => return std::result::Result::Err(error), + } + } + let after = self.load_environment(); + let after = match after { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + return std::result::Result::Ok(environment_change_report(variable_name, &before, &after)); + } + + /// Removes one supported KSP/KSPB `.env` entry atomically without mutating the inherited process environment. + pub fn remove_dotenv_value(&self, 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); + } + let before = self.load_environment(); + let before = match before { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + if !before.dotenv_values().contains_key(variable_name) { + return std::result::Result::Ok(environment_change_report(variable_name, &before, &before)); + } + let content = read_dotenv_source(self.dotenv_path.as_path()); + let content = match content { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let candidate = update_dotenv_source(self.dotenv_path.as_path(), content.as_str(), variable_name, std::option::Option::None); + let candidate = match candidate { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + if candidate != content { + let write = crate::persistence::atomic_write_private(self.dotenv_path.as_path(), candidate.as_bytes()); + match write { + std::result::Result::Ok(()) => {}, + std::result::Result::Err(error) => return std::result::Result::Err(error), + } + } + let after = self.load_environment(); + let after = match after { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + return std::result::Result::Ok(environment_change_report(variable_name, &before, &after)); + } + + fn load_environment(&self) -> ksp_core_lib::Result { + return crate::ConfigEnvironment::load_from_dotenv_path(self.dotenv_path.as_path()); + } + + #[cfg(test)] + pub(crate) fn with_dotenv_path(engine: crate::ConfigDocumentEngine, dotenv_path: std::path::PathBuf) -> Self { + return Self { engine, dotenv_path }; + } +} + +fn logging_file_id() -> ksp_core_lib::Result { + return crate::ConfigFileId::new(crate::FILE_ID_STD_LOGGING); +} + +fn build_environment_reports(environment: &crate::ConfigEnvironment) -> ksp_core_lib::Result> { + let mut names = std::collections::BTreeSet::::new(); + for name in environment.process_values().keys() { + names.insert(name.clone()); + } + for name in environment.dotenv_values().keys() { + names.insert(name.clone()); + } + let mut reports = std::vec::Vec::::with_capacity(names.len()); + for variable_name in names { + let sensitivity = crate::ConfigSensitivity::from_variable_name(variable_name.as_str()); + let sensitivity = match sensitivity { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let desired = environment.dotenv_values().get(variable_name.as_str()); + let process = environment.process_values().get(variable_name.as_str()); + let (effective, effective_source) = match process { + std::option::Option::Some(value) => (std::option::Option::Some(value), std::option::Option::Some(crate::ConfigEnvironmentSource::Process)), + std::option::Option::None => match desired { + std::option::Option::Some(value) => (std::option::Option::Some(value), std::option::Option::Some(crate::ConfigEnvironmentSource::DotEnv)), + std::option::Option::None => (std::option::Option::None, std::option::Option::None), + }, + }; + reports.push(ConfigEnvironmentReport { + variable_name, + sensitivity, + desired_safe_value: desired.map(|value| -> String { + return safe_environment_text(sensitivity, value.as_str()); + }), + effective_safe_value: effective.map(|value| -> String { + return safe_environment_text(sensitivity, value.as_str()); + }), + effective_source, + shadowed_by_process_environment: process.is_some() && desired.is_some(), + }); + } + return std::result::Result::Ok(reports); +} + +fn safe_environment_text(sensitivity: crate::ConfigSensitivity, value: &str) -> String { + if sensitivity.is_secret() { + return crate::REDACTED_CONFIG_VALUE.to_owned(); + } + return value.to_owned(); +} + +fn environment_change_report(variable_name: &str, before: &crate::ConfigEnvironment, after: &crate::ConfigEnvironment) -> ConfigEnvironmentChangeReport { + let before_desired = before.dotenv_values().get(variable_name); + let after_desired = after.dotenv_values().get(variable_name); + let before_effective = effective_environment_value(before, variable_name); + let after_effective = effective_environment_value(after, variable_name); + let source_changed = before_desired != after_desired; + let effective_changed = before_effective != after_effective; + return ConfigEnvironmentChangeReport { + source_changed, + effective_changed, + shadowed_by_process_environment: after.process_values().contains_key(variable_name) && after.dotenv_values().contains_key(variable_name), + reload_required: effective_changed, + }; +} + +fn effective_environment_value<'a>(environment: &'a crate::ConfigEnvironment, variable_name: &str) -> std::option::Option<&'a String> { + if let std::option::Option::Some(value) = environment.process_values().get(variable_name) { + return std::option::Option::Some(value); + } + return environment.dotenv_values().get(variable_name); +} + +fn read_dotenv_source(path: &std::path::Path) -> ksp_core_lib::Result { + let content = std::fs::read_to_string(path); + return match content { + std::result::Result::Ok(value) => { + let validation = crate::environment::parse_dotenv_content(path, value.as_str()); + match validation { + std::result::Result::Ok(_) => std::result::Result::Ok(value), + std::result::Result::Err(error) => std::result::Result::Err(error), + } + }, + std::result::Result::Err(error) if error.kind() == std::io::ErrorKind::NotFound => std::result::Result::Ok(String::new()), + std::result::Result::Err(error) => std::result::Result::Err( + 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(error), + ), + }; +} + +fn update_dotenv_source(path: &std::path::Path, source: &str, variable_name: &str, replacement: std::option::Option<&str>) -> ksp_core_lib::Result { + let validation = crate::environment::parse_dotenv_content(path, source); + if let std::result::Result::Err(error) = validation { + return std::result::Result::Err(error); + } + let mut lines = source.lines().map(str::to_owned).collect::>(); + let mut match_index: std::option::Option = std::option::Option::None; + for (index, line) in lines.iter().enumerate() { + if dotenv_assignment_name(line.as_str()) == std::option::Option::Some(variable_name) { + match_index = std::option::Option::Some(index); + break; + } + } + match replacement { + std::option::Option::Some(value) => { + let assignment = format!("{variable_name}={}", encode_dotenv_value(value)); + match match_index { + std::option::Option::Some(index) => lines[index] = assignment, + std::option::Option::None => lines.push(assignment), + } + }, + std::option::Option::None => { + if let std::option::Option::Some(index) = match_index { + lines.remove(index); + } + }, + } + let mut candidate = lines.join("\n"); + if !candidate.is_empty() { + candidate.push('\n'); + } + let candidate_validation = crate::environment::parse_dotenv_content(path, candidate.as_str()); + return match candidate_validation { + std::result::Result::Ok(_) => std::result::Result::Ok(candidate), + std::result::Result::Err(error) => std::result::Result::Err(error), + }; +} + +fn dotenv_assignment_name(line: &str) -> std::option::Option<&str> { + let line = line.trim(); + if line.is_empty() || line.starts_with('#') { + return std::option::Option::None; + } + 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::option::Option::None, + }; + return std::option::Option::Some(assignment[..separator].trim()); +} + +fn encode_dotenv_value(value: &str) -> String { + let simple = value.chars().all(|character| -> bool { + return character.is_ascii_alphanumeric() || matches!(character, '_' | '-' | '.' | '/' | ':' | '@' | '+'); + }); + if simple { + return value.to_owned(); + } + let mut encoded = String::from("\""); + for character in value.chars() { + match character { + '\\' => encoded.push_str("\\\\"), + '"' => encoded.push_str("\\\""), + '\n' => encoded.push_str("\\n"), + '\r' => encoded.push_str("\\r"), + '\t' => encoded.push_str("\\t"), + _ => encoded.push(character), + } + } + encoded.push('"'); + return encoded; +} + +fn management_error(reason: &'static str) -> ksp_core_lib::Error { + return ksp_core_lib::Error::new(crate::ERROR_CODE_MANAGEMENT_OPERATION_INVALID, "Config management operation is invalid").with_context("reason", reason); +} + +#[cfg(test)] +#[path = "../unit_tests/management.rs"] +mod tests; diff --git a/crates/ksp-config-lib/src/persistence.rs b/crates/ksp-config-lib/src/persistence.rs new file mode 100644 index 0000000..18b076b --- /dev/null +++ b/crates/ksp-config-lib/src/persistence.rs @@ -0,0 +1,113 @@ +// file: crates/ksp-config-lib/src/persistence.rs +// version: 1 + +static NEXT_TEMPORARY_FILE_ID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1); + +pub(crate) fn atomic_write(path: &std::path::Path, content: &[u8]) -> ksp_core_lib::Result<()> { + return atomic_write_with_policy(path, content, false); +} + +pub(crate) fn atomic_write_private(path: &std::path::Path, content: &[u8]) -> ksp_core_lib::Result<()> { + return atomic_write_with_policy(path, content, true); +} + +fn atomic_write_with_policy(path: &std::path::Path, content: &[u8], private_when_new: bool) -> ksp_core_lib::Result<()> { + let parent = match path.parent() { + std::option::Option::Some(value) if !value.as_os_str().is_empty() => value, + _ => std::path::Path::new("."), + }; + let filename = match path.file_name().and_then(std::ffi::OsStr::to_str) { + std::option::Option::Some(value) if !value.is_empty() => value, + _ => return std::result::Result::Err(persistence_error(path, "managed Config path has no UTF-8 file name")), + }; + let existing_permissions = destination_permissions(path); + let existing_permissions = match existing_permissions { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let temporary_id = NEXT_TEMPORARY_FILE_ID.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let temporary_name = format!(".{filename}.ksp-tmp-{}-{temporary_id}", std::process::id()); + let temporary_path = parent.join(temporary_name); + let opened = std::fs::OpenOptions::new().write(true).create_new(true).open(temporary_path.as_path()); + let mut file = match opened { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(persistence_io_error(path, "temporary Config file cannot be created", error)), + }; + let permissions = apply_temporary_permissions(&file, existing_permissions, private_when_new); + if let std::result::Result::Err(error) = permissions { + cleanup_temporary_file(temporary_path.as_path()); + return std::result::Result::Err(persistence_io_error(path, "temporary Config file permissions cannot be applied", error)); + } + let write = std::io::Write::write_all(&mut file, content); + if let std::result::Result::Err(error) = write { + cleanup_temporary_file(temporary_path.as_path()); + return std::result::Result::Err(persistence_io_error(path, "temporary Config file cannot be written", error)); + } + let sync = file.sync_all(); + if let std::result::Result::Err(error) = sync { + cleanup_temporary_file(temporary_path.as_path()); + return std::result::Result::Err(persistence_io_error(path, "temporary Config file cannot be synchronized", error)); + } + drop(file); + let rename = std::fs::rename(temporary_path.as_path(), path); + if let std::result::Result::Err(error) = rename { + cleanup_temporary_file(temporary_path.as_path()); + return std::result::Result::Err(persistence_io_error(path, "atomic Config file replacement failed", error)); + } + return std::result::Result::Ok(()); +} + +fn destination_permissions(path: &std::path::Path) -> ksp_core_lib::Result> { + let metadata = std::fs::metadata(path); + return match metadata { + std::result::Result::Ok(value) => std::result::Result::Ok(std::option::Option::Some(value.permissions())), + std::result::Result::Err(error) if error.kind() == std::io::ErrorKind::NotFound => std::result::Result::Ok(std::option::Option::None), + std::result::Result::Err(error) => { + std::result::Result::Err(persistence_io_error(path, "managed Config file metadata cannot be read before replacement", error)) + }, + }; +} + +fn apply_temporary_permissions( + file: &std::fs::File, + existing_permissions: std::option::Option, + private_when_new: bool, +) -> std::io::Result<()> { + if let std::option::Option::Some(permissions) = existing_permissions { + return file.set_permissions(permissions); + } + return apply_new_file_permissions(file, private_when_new); +} + +#[cfg(unix)] +fn apply_new_file_permissions(file: &std::fs::File, private_when_new: bool) -> std::io::Result<()> { + if private_when_new { + let permissions = ::from_mode(0o600); + return file.set_permissions(permissions); + } + return std::result::Result::Ok(()); +} + +#[cfg(not(unix))] +fn apply_new_file_permissions(_file: &std::fs::File, _private_when_new: bool) -> std::io::Result<()> { + return std::result::Result::Ok(()); +} + +fn cleanup_temporary_file(path: &std::path::Path) { + let removal = std::fs::remove_file(path); + if let std::result::Result::Err(error) = removal + && error.kind() != std::io::ErrorKind::NotFound + { + ksp_logging_lib::warn!(target: "ksp-config-lib", domain: "config.persistence", path = %path.to_string_lossy(), error = %error, "unable to cleanup temporary Config file"); + } +} + +fn persistence_error(path: &std::path::Path, reason: &'static str) -> ksp_core_lib::Error { + return ksp_core_lib::Error::new(crate::ERROR_CODE_PERSISTENCE_WRITE_FAILED, "Config persistence failed") + .with_context("path", path.to_string_lossy().into_owned()) + .with_context("reason", reason); +} + +fn persistence_io_error(path: &std::path::Path, reason: &'static str, source: std::io::Error) -> ksp_core_lib::Error { + return persistence_error(path, reason).with_source(source); +} diff --git a/crates/ksp-config-lib/tests/public_api.rs b/crates/ksp-config-lib/tests/public_api.rs index fcdf020..b09d0b6 100644 --- a/crates/ksp-config-lib/tests/public_api.rs +++ b/crates/ksp-config-lib/tests/public_api.rs @@ -1,8 +1,8 @@ // file: crates/ksp-config-lib/tests/public_api.rs -// version: 9 +// version: 10 -//! Integration tests for the public `ksp-config-lib` bootstrap, registry, JSON/profile/composite, environment-resolution, sensitivity and -//! Logging-adapter contracts. +//! Integration tests for the public `ksp-config-lib` bootstrap, registry, JSON/profile/composite, environment-resolution, sensitivity, Logging-adapter and +//! management contracts. #[test] fn bootstrap_contract_is_available_from_crate_root() { @@ -178,3 +178,32 @@ fn logging_adapter_contract_is_available_from_crate_root() { assert_eq!(ksp_config_lib::ERROR_CODE_EFFECTIVE_CONFIG_INVALID.code(), "effective_config_invalid"); assert!(std::mem::size_of::() > 0); } + +#[test] +fn management_contracts_are_available_from_crate_root() { + let workspace = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../.."); + let bootstrap = ksp_config_lib::ConfigBootstrapOptions::from_paths(workspace.join("config"), workspace.join("config/schemas")); + let registry = ksp_config_lib::ConfigFileRegistry::defaults(); + assert!(bootstrap.is_ok(), "public bootstrap should accept committed Config roots: {bootstrap:?}"); + assert!(registry.is_ok(), "public registry should remain constructible: {registry:?}"); + if let (std::result::Result::Ok(bootstrap), std::result::Result::Ok(registry)) = (bootstrap, registry) { + let engine = ksp_config_lib::ConfigDocumentEngine::new(bootstrap, registry); + let management = ksp_config_lib::ConfigManagement::new(engine); + let logging = management.load_logging_document(); + assert!(logging.is_ok(), "public typed Logging management contract should load committed source: {logging:?}"); + if let std::result::Result::Ok(mut logging) = logging { + assert_eq!(logging.format_version(), 1); + assert_eq!(logging.default_profile(), "local_dev"); + logging.set_logs_directory("public-api-management-test"); + assert_eq!(logging.logs_directory(), "public-api-management-test"); + assert_eq!(logging.profiles().len(), 1); + assert_eq!(logging.profiles()[0].profile_id(), "local_dev"); + } + } + let reveal_effective: fn(&ksp_config_lib::ConfigManagement, &str) -> ksp_core_lib::Result> = + ksp_config_lib::ConfigManagement::reveal_effective_environment_value; + let reveal_dotenv: fn(&ksp_config_lib::ConfigManagement, &str) -> ksp_core_lib::Result> = + ksp_config_lib::ConfigManagement::reveal_dotenv_value; + let _ = (reveal_effective, reveal_dotenv); + assert_ne!(ksp_config_lib::ERROR_CODE_MANAGEMENT_OPERATION_INVALID, ksp_config_lib::ERROR_CODE_PERSISTENCE_WRITE_FAILED); +} diff --git a/crates/ksp-config-lib/unit_tests/management.rs b/crates/ksp-config-lib/unit_tests/management.rs new file mode 100644 index 0000000..b3bce61 --- /dev/null +++ b/crates/ksp-config-lib/unit_tests/management.rs @@ -0,0 +1,357 @@ +// file: crates/ksp-config-lib/unit_tests/management.rs +// version: 1 + +static NEXT_FIXTURE_ID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1); + +struct ManagementFixture { + root: std::path::PathBuf, + config_path: std::path::PathBuf, + dotenv_path: std::path::PathBuf, + management: crate::ConfigManagement, +} + +#[test] +fn raw_management_read_remains_available_for_schema_invalid_source() { + let fixture = management_fixture(); + let fixture = match fixture { + std::result::Result::Ok(value) => value, + std::result::Result::Err(_) => return, + }; + let invalid = "{\n \"format_version\": 1\n}\n"; + let write = std::fs::write(fixture.config_path.as_path(), invalid.as_bytes()); + assert!(write.is_ok(), "schema-invalid management fixture should be written"); + 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(_) => { + cleanup_fixture(&fixture); + return; + }, + }; + let raw = fixture.management.read_source(&file_id); + assert!(raw.is_ok(), "raw management read should not require schema validity: {raw:?}"); + if let std::result::Result::Ok(raw) = raw { + assert_eq!(raw.content(), invalid); + assert_eq!(raw.file_id(), &file_id); + let debug = format!("{raw:?}"); + assert!(!debug.contains("format_version"), "raw source content must not be exposed by Debug"); + } + let typed = fixture.management.load_logging_document(); + assert!(typed.is_err(), "typed management load must still require a valid source document"); + cleanup_fixture(&fixture); +} + +#[test] +fn typed_logging_document_can_be_mutated_validated_and_persisted_atomically() { + let fixture = management_fixture(); + let fixture = match fixture { + std::result::Result::Ok(value) => value, + std::result::Result::Err(_) => return, + }; + let document = fixture.management.load_logging_document(); + assert!(document.is_ok(), "committed Logging document should load through management: {document:?}"); + let mut document = match document { + std::result::Result::Ok(value) => value, + std::result::Result::Err(_) => { + cleanup_fixture(&fixture); + return; + }, + }; + assert_eq!(document.format_version(), 1); + assert_eq!(document.default_profile(), "local_dev"); + assert_eq!(document.profiles().len(), 1); + document.set_logs_directory("managed-logs"); + if let std::option::Option::Some(profile) = document.profiles_mut().first_mut() { + profile.set_default_filter("info"); + } + let report = fixture.management.save_logging_document(&document); + assert!(report.is_ok(), "valid typed Logging mutation should persist: {report:?}"); + if let std::result::Result::Ok(report) = report { + assert!(report.source_changed()); + assert!(report.reload_required()); + } + let persisted = std::fs::read_to_string(fixture.config_path.as_path()); + assert!(persisted.is_ok(), "persisted Logging document should remain readable"); + if let std::result::Result::Ok(persisted) = persisted { + assert!(persisted.ends_with('\n')); + assert!(persisted.contains("\"logs_directory\": \"managed-logs\"")); + assert!(persisted.contains("\"default_filter\": \"info\"")); + } + let reloaded = fixture.management.load_logging_document(); + assert!(reloaded.is_ok(), "persisted Logging document should remain valid: {reloaded:?}"); + if let std::result::Result::Ok(reloaded) = reloaded { + assert_eq!(reloaded.logs_directory(), "managed-logs"); + assert_eq!(reloaded.profiles()[0].default_filter(), "info"); + } + cleanup_fixture(&fixture); +} + +#[test] +fn invalid_logging_candidate_does_not_modify_existing_file() { + let fixture = management_fixture(); + let fixture = match fixture { + std::result::Result::Ok(value) => value, + std::result::Result::Err(_) => return, + }; + let before = std::fs::read(fixture.config_path.as_path()); + let before = match before { + std::result::Result::Ok(value) => value, + std::result::Result::Err(_) => { + cleanup_fixture(&fixture); + return; + }, + }; + let document = fixture.management.load_logging_document(); + let mut document = match document { + std::result::Result::Ok(value) => value, + std::result::Result::Err(_) => { + cleanup_fixture(&fixture); + return; + }, + }; + document.set_logs_directory(""); + let save = fixture.management.save_logging_document(&document); + assert!(save.is_err(), "schema-invalid candidate must be rejected before persistence"); + let after = std::fs::read(fixture.config_path.as_path()); + assert_eq!(after.ok(), std::option::Option::Some(before)); + cleanup_fixture(&fixture); +} + +#[test] +fn unchanged_logging_candidate_reports_no_reload() { + let fixture = management_fixture(); + let fixture = match fixture { + std::result::Result::Ok(value) => value, + std::result::Result::Err(_) => return, + }; + let document = fixture.management.load_logging_document(); + let document = match document { + std::result::Result::Ok(value) => value, + std::result::Result::Err(_) => { + cleanup_fixture(&fixture); + return; + }, + }; + let first = fixture.management.save_logging_document(&document); + assert!(first.is_ok(), "normalization save should succeed: {first:?}"); + let second = fixture.management.save_logging_document(&document); + assert!(second.is_ok(), "second identical save should succeed: {second:?}"); + if let std::result::Result::Ok(second) = second { + assert!(!second.source_changed()); + assert!(!second.reload_required()); + } + cleanup_fixture(&fixture); +} + +#[test] +fn dotenv_create_update_and_remove_round_trip_through_management() { + let fixture = management_fixture(); + let fixture = match fixture { + std::result::Result::Ok(value) => value, + std::result::Result::Err(_) => return, + }; + let create = fixture.management.set_dotenv_value("KSP_PRE013_MANAGED_TEST_VALUE", "alpha"); + assert!(create.is_ok(), "managed .env create should succeed: {create:?}"); + if let std::result::Result::Ok(create) = create { + assert!(create.source_changed()); + assert!(create.effective_changed()); + assert!(!create.shadowed_by_process_environment()); + assert!(create.reload_required()); + } + let update = fixture.management.set_dotenv_value("KSP_PRE013_MANAGED_TEST_VALUE", "hello world # 2"); + assert!(update.is_ok(), "managed .env update should support quoting: {update:?}"); + let revealed = fixture.management.reveal_dotenv_value("KSP_PRE013_MANAGED_TEST_VALUE"); + assert_eq!(revealed.ok(), std::option::Option::Some(std::option::Option::Some("hello world # 2".to_owned()))); + #[cfg(unix)] + { + let metadata = std::fs::metadata(fixture.dotenv_path.as_path()); + assert!(metadata.is_ok(), "new managed .env permissions should be inspectable"); + if let std::result::Result::Ok(metadata) = metadata { + let mode = ::mode(&metadata.permissions()); + assert_eq!(mode & 0o777, 0o600, "new .env must be private on Unix"); + } + } + let content = std::fs::read_to_string(fixture.dotenv_path.as_path()); + assert!(content.is_ok(), "managed .env should be readable"); + if let std::result::Result::Ok(content) = content { + assert!(content.contains("KSP_PRE013_MANAGED_TEST_VALUE=\"hello world # 2\"")); + } + let remove = fixture.management.remove_dotenv_value("KSP_PRE013_MANAGED_TEST_VALUE"); + assert!(remove.is_ok(), "managed .env remove should succeed: {remove:?}"); + if let std::result::Result::Ok(remove) = remove { + assert!(remove.source_changed()); + assert!(remove.effective_changed()); + assert!(remove.reload_required()); + } + assert_eq!(fixture.management.reveal_dotenv_value("KSP_PRE013_MANAGED_TEST_VALUE").ok(), std::option::Option::Some(std::option::Option::None)); + cleanup_fixture(&fixture); +} + +#[test] +fn dotenv_comments_and_unrelated_entries_survive_targeted_mutation() { + let fixture = management_fixture(); + let fixture = match fixture { + std::result::Result::Ok(value) => value, + std::result::Result::Err(_) => return, + }; + let source = "# local header\nEXTERNAL_VALUE=keep\n# managed comment\nKSP_PRE013_MANAGED_TEST_VALUE=before\n"; + let write = std::fs::write(fixture.dotenv_path.as_path(), source.as_bytes()); + assert!(write.is_ok(), "dotenv preservation fixture should be written"); + let update = fixture.management.set_dotenv_value("KSP_PRE013_MANAGED_TEST_VALUE", "after"); + assert!(update.is_ok(), "targeted .env update should succeed: {update:?}"); + let content = std::fs::read_to_string(fixture.dotenv_path.as_path()); + assert!(content.is_ok(), "updated .env should remain readable"); + if let std::result::Result::Ok(content) = content { + assert!(content.contains("# local header")); + assert!(content.contains("EXTERNAL_VALUE=keep")); + assert!(content.contains("# managed comment")); + assert!(content.contains("KSP_PRE013_MANAGED_TEST_VALUE=after")); + } + cleanup_fixture(&fixture); +} + +#[test] +fn invalid_existing_dotenv_is_not_modified_by_management() { + let fixture = management_fixture(); + let fixture = match fixture { + std::result::Result::Ok(value) => value, + std::result::Result::Err(_) => return, + }; + let invalid = "KSP_BROKEN\n"; + let write = std::fs::write(fixture.dotenv_path.as_path(), invalid.as_bytes()); + assert!(write.is_ok(), "invalid .env fixture should be written"); + let update = fixture.management.set_dotenv_value("KSP_PRE013_MANAGED_TEST_VALUE", "value"); + assert!(update.is_err(), "management must reject mutation when existing .env syntax is invalid"); + assert_eq!(std::fs::read_to_string(fixture.dotenv_path.as_path()).ok(), std::option::Option::Some(invalid.to_owned())); + cleanup_fixture(&fixture); +} + +#[test] +fn unsupported_environment_name_is_rejected_without_creating_dotenv() { + let fixture = management_fixture(); + let fixture = match fixture { + std::result::Result::Ok(value) => value, + std::result::Result::Err(_) => return, + }; + let update = fixture.management.set_dotenv_value("OTHER_TOKEN", "value"); + assert!(update.is_err(), "non-KSP variable must be rejected"); + assert!(!fixture.dotenv_path.exists(), "rejected mutation must not create .env"); + cleanup_fixture(&fixture); +} + +#[test] +fn management_report_redacts_secret_but_explicit_reveal_returns_real_value() { + let fixture = management_fixture(); + let fixture = match fixture { + std::result::Result::Ok(value) => value, + std::result::Result::Err(_) => return, + }; + let canary = "SECRET-CANARY-PRE013"; + let write = std::fs::write(fixture.dotenv_path.as_path(), format!("KSP_SECRET_PRE013_MANAGED_TEST_TOKEN={canary}\n")); + assert!(write.is_ok(), "secret management fixture should be written"); + let reports = fixture.management.environment_report(); + assert!(reports.is_ok(), "safe environment report should load: {reports:?}"); + if let std::result::Result::Ok(reports) = reports { + let report = reports.iter().find(|report| -> bool { + return report.variable_name() == "KSP_SECRET_PRE013_MANAGED_TEST_TOKEN"; + }); + assert!(report.is_some(), "secret entry should appear in management report"); + if let std::option::Option::Some(report) = report { + assert_eq!(report.sensitivity(), crate::ConfigSensitivity::Secret); + assert_eq!(report.desired_safe_value(), std::option::Option::Some(crate::REDACTED_CONFIG_VALUE)); + assert_eq!(report.effective_safe_value(), std::option::Option::Some(crate::REDACTED_CONFIG_VALUE)); + assert!(!report.shadowed_by_process_environment()); + assert!(!format!("{report:?}").contains(canary)); + } + } + let reveal = fixture.management.reveal_effective_environment_value("KSP_SECRET_PRE013_MANAGED_TEST_TOKEN"); + assert_eq!(reveal.ok(), std::option::Option::Some(std::option::Option::Some(canary.to_owned()))); + cleanup_fixture(&fixture); +} + +#[test] +fn process_shadowing_report_distinguishes_desired_and_effective_changes() { + let mut process_before = std::collections::BTreeMap::::new(); + process_before.insert("KSP_MODE".to_owned(), "process".to_owned()); + let mut dotenv_before = std::collections::BTreeMap::::new(); + dotenv_before.insert("KSP_MODE".to_owned(), "desired-a".to_owned()); + let before = crate::ConfigEnvironment::from_maps(process_before.clone(), dotenv_before); + let mut dotenv_after = std::collections::BTreeMap::::new(); + dotenv_after.insert("KSP_MODE".to_owned(), "desired-b".to_owned()); + let after = crate::ConfigEnvironment::from_maps(process_before, dotenv_after); + let report = super::environment_change_report("KSP_MODE", &before, &after); + assert!(report.source_changed()); + assert!(!report.effective_changed()); + assert!(report.shadowed_by_process_environment()); + assert!(!report.reload_required()); + let reports = super::build_environment_reports(&after); + assert!(reports.is_ok(), "shadow report fixture should build: {reports:?}"); + if let std::result::Result::Ok(reports) = reports { + assert_eq!(reports.len(), 1); + assert_eq!(reports[0].desired_safe_value(), std::option::Option::Some("desired-b")); + assert_eq!(reports[0].effective_safe_value(), std::option::Option::Some("process")); + assert_eq!(reports[0].effective_source(), std::option::Option::Some(crate::ConfigEnvironmentSource::Process)); + assert!(reports[0].shadowed_by_process_environment()); + } +} + +fn management_fixture() -> ksp_core_lib::Result { + let fixture_id = NEXT_FIXTURE_ID.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let root = std::env::temp_dir().join(format!("ksp-pre013-management-{}-{fixture_id}", std::process::id())); + let cleanup = std::fs::remove_dir_all(root.as_path()); + if let std::result::Result::Err(error) = cleanup + && error.kind() != std::io::ErrorKind::NotFound + { + return std::result::Result::Err( + ksp_core_lib::Error::new(crate::ERROR_CODE_PERSISTENCE_WRITE_FAILED, "unable to cleanup previous management fixture").with_source(error), + ); + } + let config_root = root.join("config"); + let schema_root = config_root.join("schemas"); + let create = std::fs::create_dir_all(schema_root.as_path()); + if let std::result::Result::Err(error) = create { + return std::result::Result::Err( + ksp_core_lib::Error::new(crate::ERROR_CODE_PERSISTENCE_WRITE_FAILED, "unable to create management fixture").with_source(error), + ); + } + let workspace = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../.."); + let source_config = workspace.join("config/std.logging.json"); + let source_schema = workspace.join("config/schemas/std.logging.schema.json"); + let config_path = config_root.join("std.logging.json"); + let schema_path = schema_root.join("std.logging.schema.json"); + let copy_config = std::fs::copy(source_config.as_path(), config_path.as_path()); + if let std::result::Result::Err(error) = copy_config { + return std::result::Result::Err( + ksp_core_lib::Error::new(crate::ERROR_CODE_PERSISTENCE_WRITE_FAILED, "unable to copy management Config fixture").with_source(error), + ); + } + let copy_schema = std::fs::copy(source_schema.as_path(), schema_path.as_path()); + if let std::result::Result::Err(error) = copy_schema { + return std::result::Result::Err( + ksp_core_lib::Error::new(crate::ERROR_CODE_PERSISTENCE_WRITE_FAILED, "unable to copy management schema fixture").with_source(error), + ); + } + let bootstrap = crate::ConfigBootstrapOptions::from_paths(config_root.as_path(), schema_root.as_path()); + let bootstrap = match bootstrap { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let registry = crate::ConfigFileRegistry::defaults(); + let registry = match registry { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let engine = crate::ConfigDocumentEngine::new(bootstrap, registry); + let dotenv_path = root.join(".env"); + let management = crate::ConfigManagement::with_dotenv_path(engine, dotenv_path.clone()); + return std::result::Result::Ok(ManagementFixture { root, config_path, dotenv_path, management }); +} + +fn cleanup_fixture(fixture: &ManagementFixture) { + let cleanup = std::fs::remove_dir_all(fixture.root.as_path()); + if let std::result::Result::Err(error) = cleanup + && error.kind() != std::io::ErrorKind::NotFound + { + eprintln!("unable to cleanup Config management fixture {}: {error}", fixture.root.display()); + } +} diff --git a/deltas/0.1.3/pre.013.md b/deltas/0.1.3/pre.013.md new file mode 100644 index 0000000..f2a1608 --- /dev/null +++ b/deltas/0.1.3/pre.013.md @@ -0,0 +1,348 @@ + + + +# Delta 0.1.3-pre.013 + +## Base requise + +Livraison précédente validée : + +```text +0.1.3-pre.012 +``` + +Version technique de cette base : + +```text +workspace.package.version = "0.1.3-pre.12" +Cargo.toml header version = 56 +``` + +Validations utilisateur exécutées le 2026-08-16 : + +```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 tree -p ksp-logging-lib -d OK, aucun doublon +``` + +`cargo test --workspace` confirme notamment 70 tests unitaires + 10 tests publics pour `ksp-config-lib` et 34 tests unitaires Logging avec toutes ses intégrations vertes. + +## Objet de pre.013 + +Fermer la première surface de management/persistence Config sans introduire Tauri ni permettre aux applications de contourner `ksp-config-lib` : + +```text +registered file_id + -> management source read + -> typed std.logging mutation + -> candidate validation + -> atomic persistence + +process env (read-only) + .env (read/write) + -> desired/effective/shadow report + -> explicit reveal when UI management intentionally needs the real value +``` + +## `ConfigManagement` + +Nouveau contrat public : + +```text +ConfigManagement +``` + +Il possède un `ConfigDocumentEngine` et utilise par défaut le `.env` conventionnel : + +```text +./.env +``` + +Il n'ajoute aucun path runtime Config supplémentaire et ne modifie jamais l'environnement hérité du processus. + +L'authentification/autorisation de l'utilisateur final reste une responsabilité de la future application `ksp-app-config-desk`. Config fournit une frontière explicite qui évite l'exposition accidentelle mais ne prétend pas être un système d'authentification intra-processus. + +## Lecture management + +`ConfigManagement::read_source(file_id)` lit le texte brut d'un document **Config enregistré** par son `file_id`. + +Cette lecture : + +- ne prend jamais un path arbitraire fourni par le caller ; +- refuse un `file_id` de kind Schema ; +- reste possible lorsque le document est syntaxiquement/schema/sémantiquement invalide, afin qu'une UI de management puisse afficher et corriger sa source ; +- retourne `ConfigManagedSource`, dont `Debug` n'expose pas le contenu brut. + +Aucune primitive publique générique « save raw JSON to path » n'est ajoutée. + +## Mutation typée de `std.logging.json` + +Nouveaux contrats source publics : + +```text +LoggingConfigDocument +LoggingProfileConfig +LoggingConsoleConfig +LoggingFileConfig +LoggingOutputFilterConfig +LoggingTargetFilterConfig +``` + +Ils représentent la forme source de `cfg.std.logging` et permettent une mutation structurée en mémoire. + +La persistance suit obligatoirement : + +```text +LoggingConfigDocument +-> serde_json candidate +-> registered std.logging schema +-> KSP profile/logging semantic invariants +-> pretty JSON + newline final +-> atomic commit +``` + +Un candidate invalide retourne l'erreur de validation existante et ne touche pas au fichier destination. + +`ConfigDocumentEngine` reçoit uniquement un helper interne `validate_candidate(...)`; la validation reste possédée par Config et n'est pas dupliquée dans la couche management. + +## Persistence atomique + +Nouveau module privé : + +```text +src/persistence.rs +``` + +Stratégie : + +1. créer un fichier temporaire avec `create_new` dans le même répertoire que la destination ; +2. appliquer les permissions requises ; +3. écrire tout le contenu ; +4. `sync_all` le fichier temporaire ; +5. fermer le handle ; +6. remplacer la destination par `rename` ; +7. nettoyer le temporaire si une étape pré-commit échoue. + +La destination reste donc l'ancien fichier complet jusqu'au commit final. + +Les permissions du fichier existant sont conservées. Sur Unix, un nouveau `.env` créé par Config reçoit explicitement : + +```text +0600 +``` + +Les documents JSON ne reçoivent pas artificiellement ce mode privé ; un fichier existant conserve son mode. + +Nouveau code d'erreur : + +```text +config.persistence_write_failed +``` + +## Management du `.env` + +Nouvelles opérations : + +```text +set_dotenv_value(name, value) +remove_dotenv_value(name) +reveal_dotenv_value(name) +reveal_effective_environment_value(name) +environment_report() +``` + +Mutation autorisée uniquement pour les namespaces déjà possédés par Config : + +```text +KSP_* +KSPB_* +``` + +Une mutation ne touche jamais `std::env::set_var/remove_var` et ne prétend donc jamais modifier le shell parent, systemd, Docker/Kubernetes ou l'environnement déjà hérité du processus. + +Avant mutation, le `.env` existant doit être interprétable par la grammaire Config actuelle. Un `.env` invalide est refusé sans altération. + +L'éditeur ciblé : + +- conserve les lignes/commentaires non concernés ; +- remplace uniquement l'assignment ciblé ; +- encode les valeurs nécessitant espaces, `#`, quotes, backslash ou contrôles avec la forme double-quoted déjà comprise par Config ; +- normalise un fichier modifié avec newline final ; +- ne réécrit pas un assignment si la valeur persistée est déjà identique ; +- ne crée pas le fichier lors d'un remove d'une clé absente. + +## Desired / effective / shadow + +`ConfigEnvironmentReport` n'embarque aucune valeur réelle secrète. + +Il expose : + +```text +variable_name +sensitivity +desired_safe_value # valeur .env persistée +effective_safe_value # process sinon .env +effective_source +shadowed_by_process_environment +``` + +Une entrée `.env` est `desired`; la valeur héritée du process reste prioritaire et peut donc la shadow. + +`ConfigEnvironmentChangeReport` expose après create/update/remove : + +```text +source_changed +effective_changed +shadowed_by_process_environment +reload_required +``` + +Si le process shadow une modification `.env`, `source_changed = true` mais `effective_changed = false` et `reload_required = false` pour le processus courant. + +## Reveal explicite et secrets + +Les rapports management ordinaires utilisent la représentation sûre : + +```text +KSP_SECRET_* / KSPB_SECRET_* -> ******** +``` + +La valeur réelle n'est accessible qu'en appelant explicitement : + +```text +reveal_dotenv_value(...) +reveal_effective_environment_value(...) +``` + +Ces méthodes sont destinées à une surface UI management légitimement autorisée. Elles ne changent pas les règles suivantes : + +- jamais de secret réel dans `Debug` ; +- jamais de secret réel dans les logs ; +- jamais de secret réel dans les diagnostics génériques ; +- l'application reste propriétaire de l'autorisation utilisateur. + +## Règles durables + +Ajout de : + +```text +KSP-CONFIG-014 +KSP-CONFIG-015 +KSP-CONFIG-016 +``` + +pour figer respectivement : + +- la persistence validée/atomique bornée aux ressources Config connues ; +- la distinction process read-only / `.env` read-write et le reporting shadow ; +- la séparation report sûr / reveal explicite des valeurs sensibles. + +`FILE_CONTRACTS.md` documente également la stratégie de persistence, le newline JSON, la preservation des permissions et le mode `0600` d'un nouveau `.env` sur Unix. + +## `.env.example` + +Aucune nouvelle variable runtime n'est introduite par cette tranche. + +`.env.example` reste donc inchangé : + +```text +KSP_LOGS_DIRECTORY=logs +``` + +Les variables utilisées uniquement comme canaries/tests ne font pas partie de l'inventaire runtime. + +## Dépendances + +Aucune nouvelle dépendance Cargo. + +La direction reste : + +```text +ksp-config-lib -> ksp-core-lib +ksp-config-lib -> ksp-logging-lib +ksp-logging-lib -X-> ksp-config-lib +``` + +## Tests ajoutés + +Le nouveau module `unit_tests/management.rs` ajoute 10 tests couvrant notamment : + +- lecture raw d'un source schema-invalide ; +- mutation typée + persistance + reload de `std.logging.json` ; +- candidate Logging invalide sans altération du fichier ; +- save identique sans reload ; +- create/update/remove `.env` ; +- quoting et preservation de commentaires/lignes externes ; +- `.env` invalide non modifié ; +- namespace externe refusé sans création de `.env` ; +- report secret redacted + reveal explicite du canary ; +- process shadowing desired `.env` sans faux changement effectif ; +- mode `0600` du nouveau `.env` sur Unix. + +La surface publique ajoute un test d'adressabilité des contrats management. + +Après ajout, `ksp-config-lib` doit compter : + +```text +80 tests unitaires +11 tests publics +``` + +à exécuter chez l'utilisateur. + +## Fichiers ajoutés + +```text +crates/ksp-config-lib/src/management.rs +crates/ksp-config-lib/src/persistence.rs +crates/ksp-config-lib/unit_tests/management.rs +deltas/0.1.3/pre.013.md +``` + +## Fichiers modifiés + +```text +Cargo.toml +crates/ksp-config-lib/src/document.rs +crates/ksp-config-lib/src/environment.rs +crates/ksp-config-lib/src/error.rs +crates/ksp-config-lib/src/lib.rs +crates/ksp-config-lib/tests/public_api.rs +docs/rules/RULES_KSP.md +docs/rules/FILE_CONTRACTS.md +docs/plans/000-README.md +docs/plans/002-FUNCTIONAL_RELEASE_SEQUENCE.md +docs/plans/005-V0_1_3_CONFIG_FOUNDATION_PLAN.md +``` + +## Hors scope + +Toujours hors `pre.013` : + +- application desktop Config/Tauri ; +- watcher automatique de fichiers ; +- rechargement automatique du runtime après save ; +- mutation générique de documents arbitraires ; +- management d'un environnement parent/systemd/container ; +- autres documents standard Store/Wallet/Transport ; +- audit global empêchant tous les futurs contournements Config hors crate, prévu en `pre.014`. + +## Validation demandée + +```bash +cargo fmt --all +cargo check --workspace +cargo clippy --workspace --all-targets +cargo test --workspace +cargo tree -p ksp-config-lib +cargo tree -p ksp-config-lib -d +cargo tree -p ksp-config-lib -e features +cargo tree -p ksp-logging-lib -d +``` + +Aucune validation Cargo locale n'est revendiquée dans l'environnement de génération de ce delta. diff --git a/docs/plans/000-README.md b/docs/plans/000-README.md index 5f9304f..ea7e35b 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` ajoute sensibilité, valeur réelle/sûre, redaction et provenance; `pre.012` livre l'adapter Config -> Logging et la validation effective des chemins Logging; `pre.013` poursuivra avec management/persistence. +- [`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` a livré l'adapter Config -> Logging et la validation effective des chemins Logging; `pre.013` livre management/persistence JSON/.env; `pre.014` poursuivra avec ownership audits/robustesse. 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 8fd6c88..c6e4ba7 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` a ajouté le snapshot process + `.env`, `.env.example` et le resolver `${...}`; `pre.011` a ajouté sensibilité, valeurs réelle/sûre, redaction et provenance enrichie; `pre.012` livre l'adapter Config -> Logging, la validation effective de `logs_directory`/`files[].path` et le contrat de non-usage des secrets par Logging. Après validation utilisateur, `pre.013` ouvrira management + persistence JSON/.env. +`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` a ajouté sensibilité, valeurs réelle/sûre, redaction et provenance enrichie; `pre.012` a livré l'adapter Config -> Logging, la validation effective de `logs_directory`/`files[].path` et le contrat de non-usage des secrets par Logging. `pre.013` livre management + persistence JSON/.env avec source typée Logging, reports desired/effective/shadow, reveal explicite et écriture atomique. Après validation utilisateur, `pre.014` ouvrira ownership audits + robustesse. ## `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 feaf418..e324624 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`, `pre.010` le snapshot process + `.env` et le resolver `${...}`, puis `pre.011` la sensibilité et les représentations real/safe/provenance. `pre.012` livre maintenant l'adapter Config -> Logging, avec validation effective des paths et frontière anti-secret. La prochaine tranche est `pre.013` pour management + persistence JSON/.env. +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 `${...}`, `pre.011` la sensibilité et les représentations real/safe/provenance, puis `pre.012` l'adapter Config -> Logging. `pre.013` livre maintenant management + persistence JSON/.env. La prochaine tranche est `pre.014` pour les ownership audits et la robustesse. La base auditée reste la release stable `v0.1.2`. @@ -1844,13 +1844,23 @@ La validation utilisateur de `pre.011-fix.001` est acquise le 2026-08-16 : `fmt/ ### `0.1.3-pre.013` — management + persistence JSON/.env -- lecture management ; -- reveal secret explicite ; -- mutation typée de `std.logging.json` ; -- create/update/remove `.env` ; -- persistence atomique ; -- desired/effective/shadow report ; -- refus des mutations non autorisées. +Livré : + +- `ConfigManagement` comme façade explicite de management, sans dépendance Tauri ; +- lecture source brute d'un document enregistré par `file_id`, même si le document est schema-invalide, afin de permettre sa correction sans ouvrir un path arbitraire ; +- contrat source typé `LoggingConfigDocument` + sous-structures pour `std.logging.json`, avec mutation en mémoire puis validation schema/sémantique complète avant commit ; +- serialization JSON lisible avec newline final ; +- persistence atomique par fichier temporaire dans le même répertoire + `sync_all` + rename ; +- preservation des permissions existantes et mode `0600` pour un `.env` nouvellement créé sur Unix ; +- `environment_report()` exposant desired `.env`, effective process/`.env`, sensitivity, source et shadowing uniquement sous forme sûre/redacted ; +- `reveal_effective_environment_value()` / `reveal_dotenv_value()` comme opt-in explicite au réel pour une UI management autorisée ; +- create/update/remove `.env` pour les namespaces KSP/KSPB seulement, sans aucune mutation du process env ; +- `ConfigEnvironmentChangeReport` avec `source_changed`, `effective_changed`, `shadowed_by_process_environment`, `reload_required` ; +- preservation des lignes/commentaires `.env` non ciblés et encodage sûr des valeurs modifiées ; +- un `.env` syntaxiquement invalide ou un candidate JSON invalide est refusé avant commit et conserve le fichier précédent ; +- aucune nouvelle dépendance ni variable d'environnement. + +La validation utilisateur de `pre.012` est acquise le 2026-08-16 : `fmt/check/clippy/test` passent, `ksp-config-lib` compte 70 tests unitaires + 10 tests publics, `ksp-logging-lib` 34 tests unitaires + ses intégrations, et son graphe `-d` reste sans doublon. Le seul doublon Config reste le `syn 2`/`syn 3` transitif déjà connu via `jsonschema`. ### `0.1.3-pre.014` — ownership audits + robustesse diff --git a/docs/rules/FILE_CONTRACTS.md b/docs/rules/FILE_CONTRACTS.md index 0f5fa2c..7f76c7c 100644 --- a/docs/rules/FILE_CONTRACTS.md +++ b/docs/rules/FILE_CONTRACTS.md @@ -1,5 +1,5 @@ - + # Contrats des fichiers @@ -51,6 +51,8 @@ Les noms physiques sont remplaçables via le registre Config lorsque le contrat Le fichier runtime d'environnement est toujours `./.env` pour `0.1.3`. Il n'est ni un document `config/` ni une source de bootstrap de `cfgpath`/`schemapath`. Le template `.env.example` est la référence versionnée permettant de créer localement `.env` et d'identifier par diff les nouvelles clés attendues. +La persistance management des documents Config et du `.env` utilise un fichier temporaire créé dans le même répertoire puis un remplacement par rename après validation/écriture/synchronisation. Les documents JSON sont sérialisés lisiblement avec newline final. Le writer préserve les permissions d'un fichier existant ; sur Unix, un `.env` nouvellement créé par Config reçoit le mode `0600`. Les mutations `.env` ciblées préservent les lignes/commentaires non concernés et ne modifient jamais l'environnement du processus. + Pour `config/std.logging.json`, `logs_directory` accepte un chemin absolu ou relatif. Après interpolation, un chemin relatif est ancré sur le current working directory du processus au moment où Config construit `LoggingSettings`. Une valeur explicitement définie mais vide/invalide ne retombe jamais sur le fallback `logs`. Les `files[].path` restent relatifs sous ce root et sont revalidés après interpolation afin d'interdire un chemin absolu ou un traversal introduit dynamiquement. Pour un document standard profilé, `default_profile` et `profiles` sont des clés structurelles réservées. Les autres propriétés top-level sont des valeurs globales. Chaque entrée de `profiles` possède un `profile_id` unique ; `default_profile` référence obligatoirement l'un de ces identifiants. La résolution Config peut sélectionner le profil par défaut ou un profil explicite et conserve séparément la provenance `Global` / `Profile` de la vue effective. Les consumers ne reconstituent jamais eux-mêmes cette fusion. diff --git a/docs/rules/RULES_KSP.md b/docs/rules/RULES_KSP.md index 651ad80..ce086bd 100644 --- a/docs/rules/RULES_KSP.md +++ b/docs/rules/RULES_KSP.md @@ -1,5 +1,5 @@ - + # Règles spécifiques à KSP @@ -42,6 +42,9 @@ - **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é. - **KSP-CONFIG-012** — Pour `std.logging`, `logs_directory` peut être absolu ou relatif ; un chemin relatif est ancré sur le current working directory du processus lors de la construction des settings. Le fallback du placeholder ne s'applique que si la variable est absente ; une valeur explicitement présente mais invalide provoque une erreur de configuration effective. - **KSP-CONFIG-013** — Le document standard Logging ne consomme pas de variable classée `Secret`. L'adapter Config -> Logging rejette une configuration effective `Secret` afin qu'aucune valeur secrète ne soit transmise aux diagnostics runtime/filesystem de Logging. +- **KSP-CONFIG-014** — La persistence Config n'expose pas de primitive publique d'écriture vers un chemin arbitraire. Un document connu est muté via son contrat source typé, validé complètement puis remplacé atomiquement dans le path résolu par son `file_id`; un échec avant commit conserve l'ancien fichier. +- **KSP-CONFIG-015** — L'environnement du processus reste read-only. La surface management peut créer/modifier/supprimer uniquement des entrées KSP/KSPB du `.env`; chaque mutation rapporte séparément changement de source persistée, changement effectif courant, shadowing par le process et besoin de reload. +- **KSP-CONFIG-016** — Les rapports management ordinaires n'exposent que des valeurs sûres/redacted. L'accès en clair à une valeur d'environnement passe par un appel `reveal_*` explicite; l'authentification/autorisation de l'utilisateur final appartient à l'application et cette révélation n'autorise jamais le secret dans les logs/`Debug`/diagnostics génériques. ## Programmes et exécution