v0.1.4-pre.003

This commit is contained in:
2026-08-16 10:21:15 +02:00
parent 4a68a54d85
commit fa6d4e1039
9 changed files with 517 additions and 35 deletions

View File

@@ -1,5 +1,5 @@
<!-- file: crates/ksp-config-lib/README.md -->
<!-- version: 2 -->
<!-- version: 3 -->
# ksp-config-lib
@@ -23,7 +23,7 @@ La crate centralise les documents JSON, leurs schemas, les profils et compositio
- la classification `Public`, `Internal`, `Secret` ;
- les représentations réelle et sûre/redacted ainsi que la provenance des valeurs résolues ;
- l'adapter du document Logging effectif vers `ksp_logging_lib::LoggingSettings` ;
- la surface de management pour inspecter les sources, modifier `std.logging.json`, consulter les rapports d'environnement, révéler explicitement une valeur réelle et modifier `.env` ;
- la surface de management pour inspecter et réparer les sources Config enregistrées, modifier `std.logging.json`, consulter les rapports d'environnement, révéler explicitement une valeur réelle et modifier `.env` ;
- les écritures atomiques JSON/`.env` et la protection des permissions `.env` ;
- les audits workspace empêchant les bypass d'ownership Config et les oublis dans `.env.example`.
@@ -39,6 +39,8 @@ schema.composite -> config/schemas/composite.schema.json
`ConfigFileRegistry::descriptors()` expose ces descripteurs en lecture seule et dans un ordre déterministe par `file_id`. Une application de management peut ainsi découvrir les fichiers connus sans maintenir une liste parallèle ni dépendre de leurs filenames physiques.
`ConfigManagement::read_source()` permet d'inspecter le texte brut d'un document Config enregistré même lorsque ce document est invalide. `save_source_candidate()` complète cette frontière : le candidat brut est parsé, validé contre son schema et les invariants sémantiques KSP, puis persisté atomiquement uniquement après validation complète. Le `file_id` doit appartenir au registre et désigner un document Config ; aucun path arbitraire n'est accepté.
`config/examples/composite.example.json` démontre le format composite sans créer de composite runtime fictif.
Le fichier local d'environnement est :

View File

@@ -1,5 +1,5 @@
<!-- file: crates/ksp-config-lib/TODO.md -->
<!-- version: 2 -->
<!-- version: 3 -->
# TODO ksp-config-lib
@@ -13,7 +13,7 @@ Les responsabilités prévues pour cette release sont implémentées et couverte
`0.1.4-pre.002` ajoute l'inventaire public read-only de `ConfigFileRegistry`, nécessaire au shell Documents de `ksp-app-config-desk`. Le registre reste propriétaire des descripteurs ; l'application n'entretient pas de liste parallèle de `file_id`.
La seconde lacune révélée pendant `pre.001` reste à traiter dans la tranche suivante : une API Config bornée permettant de soumettre le source corrigé d'un `file_id` connu, le valider puis le persister atomiquement sans accès filesystem direct de l'application.
`0.1.4-pre.003` ferme la seconde lacune révélée pendant `pre.001` : `ConfigManagement::save_source_candidate()` permet de soumettre le texte corrigé d'un `file_id` Config connu, de le parser et de le valider entièrement, puis de le persister atomiquement sans accès filesystem direct de l'application. Les deux extensions Config préalables au shell desktop sont donc traitées.
## Validation desktop de `0.1.4`

View File

@@ -1,5 +1,5 @@
<!-- file: crates/ksp-config-lib/USAGE.md -->
<!-- version: 3 -->
<!-- version: 4 -->
# Utilisation de ksp-config-lib
@@ -248,7 +248,7 @@ if saved.source_changed() && saved.reload_required() {
La construction depuis zéro utilise les constructeurs publics `LoggingConfigDocument::new`, `LoggingProfileConfig::new`, `LoggingConsoleConfig::new`, `LoggingFileConfig::new`, `LoggingOutputFilterConfig::new` et `LoggingTargetFilterConfig::new`. Les mêmes contraintes schema/sémantiques sont appliquées au moment de `save_logging_document()`.
### 10.2 Inspecter un source enregistré sans contourner Config
### 10.2 Inspecter et réparer un source enregistré sans contourner Config
```rust
let source = match management.read_source(&file_id) {
@@ -261,7 +261,31 @@ let managed_path = source.path();
let raw_content = source.content();
```
Cette API est notamment destinée à une UI de réparation lorsque le document n'est plus validable. Elle n'autorise pas la lecture d'un chemin arbitraire.
Cette lecture est notamment destinée à une UI de réparation lorsque le document n'est plus validable. Elle n'autorise pas la lecture d'un chemin arbitraire.
Après édition du texte brut, le candidat est soumis à Config :
```rust
let saved = match management.save_source_candidate(&file_id, edited_source.as_str()) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
if saved.source_changed() && saved.reload_required() {
// Reload the affected Config consumer through the application lifecycle.
}
```
`save_source_candidate()` :
- accepte uniquement un `file_id` enregistré de kind `Config` ;
- parse le candidat comme JSON ;
- valide le schema enregistré et les invariants sémantiques KSP ;
- ne remplace aucune donnée lorsque l'une de ces validations échoue ;
- persiste atomiquement le texte brut validé sans reformattage implicite ;
- retourne `ConfigDocumentChangeReport` pour distinguer un changement réel d'un candidat identique.
Le path reste résolu exclusivement par `ConfigFileRegistry`; l'appelant ne fournit jamais de path physique.
### 10.3 Exploiter les rapports `.env`

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-config-lib/src/document.rs
// version: 4
// version: 5
/// A Config-managed JSON document that has passed syntax, schema and current semantic validation.
#[derive(Clone, Debug, PartialEq)]
@@ -85,6 +85,20 @@ impl ConfigDocumentEngine {
return self.validate_document(document, &schema_file_id);
}
pub(crate) fn validate_source_candidate(&self, file_id: &crate::ConfigFileId, source: &str) -> ksp_core_lib::Result<ConfigJsonDocument> {
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 value = serde_json::from_str::<serde_json::Value>(source);
let value = match value {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(json_syntax_error(file_id, path.as_path(), error)),
};
return self.validate_candidate(file_id, value);
}
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 {

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-config-lib/src/management.rs
// version: 1
// version: 2
/// Raw source of one registered Config document read for explicit management/correction.
#[derive(Clone, Eq, PartialEq)]
@@ -625,6 +625,29 @@ impl ConfigManagement {
};
}
/// Validates and atomically persists a raw source candidate for one registered Config document.
///
/// The candidate is parsed, schema-validated and checked against KSP semantic invariants before any destination bytes are replaced. The raw source text is
/// preserved exactly when persistence succeeds, allowing a management editor to repair an invalid document without bypassing Config ownership.
pub fn save_source_candidate(&self, file_id: &crate::ConfigFileId, source: &str) -> ksp_core_lib::Result<ConfigDocumentChangeReport> {
let descriptor = self.engine.registry().descriptor(file_id);
let descriptor = match descriptor {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
if descriptor.kind() != crate::ConfigFileKind::Config {
return std::result::Result::Err(
management_error("management source persistence requires a Config document").with_context("file_id", file_id.as_str()),
);
}
let validated = self.engine.validate_source_candidate(file_id, source);
let validated = match validated {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
return persist_document_source(file_id, validated.path(), source.as_bytes());
}
/// Loads the validated `std.logging.json` source into its typed management contract.
pub fn load_logging_document(&self) -> ksp_core_lib::Result<LoggingConfigDocument> {
let file_id = logging_file_id();
@@ -675,27 +698,7 @@ impl ConfigManagement {
},
};
serialized.push('\n');
let existing = std::fs::read(validated.path());
let existing = match existing {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) if error.kind() == std::io::ErrorKind::NotFound => std::vec::Vec::new(),
std::result::Result::Err(error) => {
return std::result::Result::Err(
ksp_core_lib::Error::new(crate::ERROR_CODE_JSON_FILE_READ_FAILED, "existing Config document cannot be read before persistence")
.with_context("file_id", file_id.as_str())
.with_context("path", validated.path().to_string_lossy().into_owned())
.with_source(error),
);
},
};
if existing.as_slice() == serialized.as_bytes() {
return std::result::Result::Ok(ConfigDocumentChangeReport { source_changed: false, reload_required: false });
}
let write = crate::persistence::atomic_write(validated.path(), serialized.as_bytes());
if let std::result::Result::Err(error) = write {
return std::result::Result::Err(error);
}
return std::result::Result::Ok(ConfigDocumentChangeReport { source_changed: true, reload_required: true });
return persist_document_source(&file_id, validated.path(), serialized.as_bytes());
}
/// Returns safe desired/effective/shadow reports for every KSP/KSPB variable present in process or `.env` sources.
@@ -999,6 +1002,30 @@ fn encode_dotenv_value(value: &str) -> String {
return encoded;
}
fn persist_document_source(file_id: &crate::ConfigFileId, path: &std::path::Path, source: &[u8]) -> ksp_core_lib::Result<ConfigDocumentChangeReport> {
let existing = std::fs::read(path);
let existing = match existing {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) if error.kind() == std::io::ErrorKind::NotFound => std::vec::Vec::new(),
std::result::Result::Err(error) => {
return std::result::Result::Err(
ksp_core_lib::Error::new(crate::ERROR_CODE_JSON_FILE_READ_FAILED, "existing Config document cannot be read before persistence")
.with_context("file_id", file_id.as_str())
.with_context("path", path.to_string_lossy().into_owned())
.with_source(error),
);
},
};
if existing.as_slice() == source {
return std::result::Result::Ok(ConfigDocumentChangeReport { source_changed: false, reload_required: false });
}
let write = crate::persistence::atomic_write(path, source);
if let std::result::Result::Err(error) = write {
return std::result::Result::Err(error);
}
return std::result::Result::Ok(ConfigDocumentChangeReport { source_changed: true, reload_required: true });
}
fn management_error(reason: &'static str) -> ksp_core_lib::Error {
return ksp_core_lib::Error::new(crate::ERROR_CODE_MANAGEMENT_OPERATION_INVALID, "Config management operation is invalid").with_context("reason", reason);
}

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-config-lib/tests/public_api.rs
// version: 11
// version: 12
//! Integration tests for the public `ksp-config-lib` bootstrap, registry, JSON/profile/composite, environment-resolution, sensitivity, Logging-adapter and
//! management contracts.
@@ -219,10 +219,15 @@ fn management_contracts_are_available_from_crate_root() {
assert_eq!(logging.profiles()[0].profile_id(), "local_dev");
}
}
let save_source_candidate: fn(
&ksp_config_lib::ConfigManagement,
&ksp_config_lib::ConfigFileId,
&str,
) -> ksp_core_lib::Result<ksp_config_lib::ConfigDocumentChangeReport> = ksp_config_lib::ConfigManagement::save_source_candidate;
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);
let _ = (save_source_candidate, reveal_effective, reveal_dotenv);
assert_ne!(ksp_config_lib::ERROR_CODE_MANAGEMENT_OPERATION_INVALID, ksp_config_lib::ERROR_CODE_PERSISTENCE_WRITE_FAILED);
}

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-config-lib/unit_tests/management.rs
// version: 1
// version: 2
static NEXT_FIXTURE_ID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
@@ -41,6 +41,176 @@ fn raw_management_read_remains_available_for_schema_invalid_source() {
cleanup_fixture(&fixture);
}
#[test]
fn valid_raw_source_candidate_is_persisted_exactly_and_reports_reload() {
let fixture = management_fixture();
let fixture = match fixture {
std::result::Result::Ok(value) => value,
std::result::Result::Err(_) => return,
};
let original = std::fs::read_to_string(fixture.config_path.as_path());
let original = match original {
std::result::Result::Ok(value) => value,
std::result::Result::Err(_) => {
cleanup_fixture(&fixture);
return;
},
};
let candidate = original.replace(" \"logs_directory\":", " \"logs_directory\":");
assert_ne!(candidate, original, "raw source candidate fixture must change the persisted bytes");
let corrupt = std::fs::write(fixture.config_path.as_path(), b"{\n");
assert!(corrupt.is_ok(), "existing managed source should be corruptible for repair test");
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 saved = fixture.management.save_source_candidate(&file_id, candidate.as_str());
assert!(saved.is_ok(), "valid raw source candidate should persist: {saved:?}");
if let std::result::Result::Ok(saved) = saved {
assert!(saved.source_changed());
assert!(saved.reload_required());
}
assert_eq!(std::fs::read_to_string(fixture.config_path.as_path()).ok(), std::option::Option::Some(candidate.clone()));
let reloaded = fixture.management.load_logging_document();
assert!(reloaded.is_ok(), "raw source persistence must leave a valid managed document: {reloaded:?}");
let unchanged = fixture.management.save_source_candidate(&file_id, candidate.as_str());
assert!(unchanged.is_ok(), "identical raw source candidate should succeed: {unchanged:?}");
if let std::result::Result::Ok(unchanged) = unchanged {
assert!(!unchanged.source_changed());
assert!(!unchanged.reload_required());
}
cleanup_fixture(&fixture);
}
#[test]
fn invalid_json_source_candidate_is_rejected_without_modifying_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 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 saved = fixture.management.save_source_candidate(&file_id, "{");
assert!(saved.is_err(), "invalid JSON candidate must be rejected");
if let std::result::Result::Err(error) = saved {
assert_eq!(error.code(), crate::ERROR_CODE_JSON_SYNTAX_INVALID);
}
assert_eq!(std::fs::read(fixture.config_path.as_path()).ok(), std::option::Option::Some(before));
cleanup_fixture(&fixture);
}
#[test]
fn schema_invalid_source_candidate_is_rejected_without_modifying_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 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 saved = fixture.management.save_source_candidate(&file_id, "{\"format_version\":1}");
assert!(saved.is_err(), "schema-invalid candidate must be rejected");
if let std::result::Result::Err(error) = saved {
assert_eq!(error.code(), crate::ERROR_CODE_SCHEMA_VALIDATION_FAILED);
}
assert_eq!(std::fs::read(fixture.config_path.as_path()).ok(), std::option::Option::Some(before));
cleanup_fixture(&fixture);
}
#[test]
fn semantic_invalid_source_candidate_is_rejected_without_modifying_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_to_string(fixture.config_path.as_path());
let before = match before {
std::result::Result::Ok(value) => value,
std::result::Result::Err(_) => {
cleanup_fixture(&fixture);
return;
},
};
let candidate = before.replace("\"default_profile\": \"local_dev\"", "\"default_profile\": \"missing\"");
assert_ne!(candidate, before, "semantic-invalid fixture must alter the default profile");
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 saved = fixture.management.save_source_candidate(&file_id, candidate.as_str());
assert!(saved.is_err(), "semantic-invalid candidate must be rejected");
if let std::result::Result::Err(error) = saved {
assert_eq!(error.code(), crate::ERROR_CODE_DOCUMENT_SEMANTIC_INVALID);
}
assert_eq!(std::fs::read_to_string(fixture.config_path.as_path()).ok(), std::option::Option::Some(before));
cleanup_fixture(&fixture);
}
#[test]
fn raw_source_candidate_rejects_schema_and_unknown_file_ids_without_filesystem_escape() {
let fixture = management_fixture();
let fixture = match fixture {
std::result::Result::Ok(value) => value,
std::result::Result::Err(_) => return,
};
let schema_file_id = crate::ConfigFileId::new(crate::FILE_ID_SCHEMA_STD_LOGGING);
if let std::result::Result::Ok(schema_file_id) = schema_file_id {
let saved = fixture.management.save_source_candidate(&schema_file_id, "{}");
assert!(saved.is_err(), "schema file_id must not be writable through Config source management");
if let std::result::Result::Err(error) = saved {
assert_eq!(error.code(), crate::ERROR_CODE_MANAGEMENT_OPERATION_INVALID);
}
}
let unknown_file_id = crate::ConfigFileId::new("cfg.not.registered");
if let std::result::Result::Ok(unknown_file_id) = unknown_file_id {
let saved = fixture.management.save_source_candidate(&unknown_file_id, "{}");
assert!(saved.is_err(), "unknown Config file_id must not become an arbitrary filesystem write");
if let std::result::Result::Err(error) = saved {
assert_eq!(error.code(), crate::ERROR_CODE_FILE_ID_UNKNOWN);
}
}
assert!(!fixture.root.join("not.registered").exists());
cleanup_fixture(&fixture);
}
#[test]
fn typed_logging_document_can_be_mutated_validated_and_persisted_atomically() {
let fixture = management_fixture();