v0.1.3-pre.013

This commit is contained in:
2026-08-16 06:04:06 +02:00
parent a9405ec7ff
commit 9549303ed5
15 changed files with 1975 additions and 29 deletions

View File

@@ -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<ConfigJsonDocument> {
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<ConfigJsonDocument> {
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),

View File

@@ -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<Self> {
pub(crate) fn load_from_dotenv_path(dotenv_path: &std::path::Path) -> ksp_core_lib::Result<Self> {
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<String, String> {
return &self.process;
}
pub(crate) const fn dotenv_values(&self) -> &std::collections::BTreeMap<String, String> {
return &self.dotenv;
}
#[cfg(test)]
pub(crate) fn from_maps(process: std::collections::BTreeMap<String, String>, dotenv: std::collections::BTreeMap<String, String>) -> 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<std::collect
return parse_dotenv_content(path, content.as_str());
}
fn parse_dotenv_content(path: &std::path::Path, content: &str) -> ksp_core_lib::Result<std::collections::BTreeMap<String, String>> {
pub(crate) fn parse_dotenv_content(path: &std::path::Path, content: &str) -> ksp_core_lib::Result<std::collections::BTreeMap<String, String>> {
let mut output = std::collections::BTreeMap::<String, String>::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 };

View File

@@ -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");

View File

@@ -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.

File diff suppressed because it is too large Load Diff

View File

@@ -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<std::option::Option<std::fs::Permissions>> {
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<std::fs::Permissions>,
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 = <std::fs::Permissions as std::os::unix::fs::PermissionsExt>::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);
}

View File

@@ -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::<ksp_config_lib::ResolvedLoggingConfig>() > 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<std::option::Option<String>> =
ksp_config_lib::ConfigManagement::reveal_effective_environment_value;
let reveal_dotenv: fn(&ksp_config_lib::ConfigManagement, &str) -> ksp_core_lib::Result<std::option::Option<String>> =
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);
}

View File

@@ -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 = <std::fs::Permissions as std::os::unix::fs::PermissionsExt>::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::<String, String>::new();
process_before.insert("KSP_MODE".to_owned(), "process".to_owned());
let mut dotenv_before = std::collections::BTreeMap::<String, String>::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::<String, String>::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<ManagementFixture> {
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());
}
}