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