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,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"

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());
}
}

348
deltas/0.1.3/pre.013.md Normal file
View File

@@ -0,0 +1,348 @@
<!-- file: deltas/0.1.3/pre.013.md -->
<!-- version: 1 -->
# 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.

View File

@@ -1,5 +1,5 @@
<!-- file: docs/plans/000-README.md -->
<!-- version: 19 -->
<!-- version: 20 -->
# 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.

View File

@@ -1,5 +1,5 @@
<!-- file: docs/plans/002-FUNCTIONAL_RELEASE_SEQUENCE.md -->
<!-- version: 17 -->
<!-- version: 18 -->
# 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 1520 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

View File

@@ -1,11 +1,11 @@
<!-- file: docs/plans/005-V0_1_3_CONFIG_FOUNDATION_PLAN.md -->
<!-- version: 15 -->
<!-- version: 16 -->
# 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

View File

@@ -1,5 +1,5 @@
<!-- file: docs/rules/FILE_CONTRACTS.md -->
<!-- version: 13 -->
<!-- version: 14 -->
# 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.

View File

@@ -1,5 +1,5 @@
<!-- file: docs/rules/RULES_KSP.md -->
<!-- version: 17 -->
<!-- version: 18 -->
# 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