// file: crates/ksp-config-lib/src/document.rs // version: 5 /// A Config-managed JSON document that has passed syntax, schema and current semantic validation. #[derive(Clone, Debug, PartialEq)] pub struct ConfigJsonDocument { file_id: crate::ConfigFileId, path: std::path::PathBuf, value: serde_json::Value, } impl ConfigJsonDocument { /// Returns the logical Config file identifier used to load this document. #[must_use] pub fn file_id(&self) -> &crate::ConfigFileId { return &self.file_id; } /// Returns the resolved physical path from which this document was loaded. #[must_use] pub fn path(&self) -> &std::path::Path { return self.path.as_path(); } /// Returns the validated JSON value without transferring Config ownership of file I/O or validation. #[must_use] pub fn value(&self) -> &serde_json::Value { return &self.value; } } /// Generic JSON/JSON Schema engine owned by `ksp-config-lib`. #[derive(Clone, Debug, Eq, PartialEq)] pub struct ConfigDocumentEngine { bootstrap: crate::ConfigBootstrapOptions, registry: crate::ConfigFileRegistry, } impl ConfigDocumentEngine { /// Creates a document engine from already validated bootstrap options and a logical file registry. #[must_use] pub fn new(bootstrap: crate::ConfigBootstrapOptions, registry: crate::ConfigFileRegistry) -> Self { return Self { bootstrap, registry }; } /// Returns the bootstrap roots used by this engine. #[must_use] pub const fn bootstrap(&self) -> &crate::ConfigBootstrapOptions { return &self.bootstrap; } /// Returns the logical file registry used by this engine. #[must_use] pub const fn registry(&self) -> &crate::ConfigFileRegistry { return &self.registry; } /// Loads one registered Config document and validates it against its registered JSON Schema and current KSP semantic invariants. pub fn load_validated_document(&self, file_id: &crate::ConfigFileId) -> ksp_core_lib::Result { let descriptor = self.registry.descriptor(file_id); let descriptor = match descriptor { std::result::Result::Ok(value) => value, std::result::Result::Err(error) => return std::result::Result::Err(error), }; if descriptor.kind() != crate::ConfigFileKind::Config { return std::result::Result::Err( ksp_core_lib::Error::new(crate::ERROR_CODE_FILE_MAPPING_INVALID, "requested file_id does not identify a Config document") .with_context("file_id", file_id.as_str()), ); } let schema_file_id = match descriptor.schema_file_id() { std::option::Option::Some(value) => value.clone(), std::option::Option::None => { return std::result::Result::Err( ksp_core_lib::Error::new(crate::ERROR_CODE_FILE_MAPPING_INVALID, "Config document has no registered validation schema") .with_context("file_id", file_id.as_str()), ); }, }; let document = self.load_json(file_id); let document = match document { std::result::Result::Ok(value) => value, std::result::Result::Err(error) => return std::result::Result::Err(error), }; 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 { 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::(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 { let descriptor = self.registry.descriptor(file_id); let descriptor = match descriptor { std::result::Result::Ok(value) => value, std::result::Result::Err(error) => return std::result::Result::Err(error), }; if descriptor.kind() != crate::ConfigFileKind::Config { return std::result::Result::Err( ksp_core_lib::Error::new(crate::ERROR_CODE_FILE_MAPPING_INVALID, "requested file_id does not identify a Config document") .with_context("file_id", file_id.as_str()), ); } let schema_file_id = match descriptor.schema_file_id() { std::option::Option::Some(value) => value.clone(), std::option::Option::None => { return std::result::Result::Err( ksp_core_lib::Error::new(crate::ERROR_CODE_FILE_MAPPING_INVALID, "Config document has no registered validation schema") .with_context("file_id", file_id.as_str()), ); }, }; let path = self.registry.resolve_path(&self.bootstrap, file_id); let path = match path { std::result::Result::Ok(value) => value, std::result::Result::Err(error) => return std::result::Result::Err(error), }; let document = ConfigJsonDocument { file_id: file_id.clone(), path, value }; return self.validate_document(document, &schema_file_id); } fn validate_document(&self, document: ConfigJsonDocument, schema_file_id: &crate::ConfigFileId) -> ksp_core_lib::Result { let schema = self.load_json(schema_file_id); let schema = match schema { std::result::Result::Ok(value) => value, std::result::Result::Err(error) => return std::result::Result::Err(error), }; let schema_validation = validate_schema_document(&schema); match schema_validation { std::result::Result::Ok(()) => {}, std::result::Result::Err(error) => return std::result::Result::Err(error), } let instance_validation = validate_instance(&document, &schema); match instance_validation { std::result::Result::Ok(()) => {}, std::result::Result::Err(error) => return std::result::Result::Err(error), } let semantic_validation = validate_document_semantics(self, &document); return match semantic_validation { std::result::Result::Ok(()) => std::result::Result::Ok(document), std::result::Result::Err(error) => std::result::Result::Err(error), }; } fn load_json(&self, file_id: &crate::ConfigFileId) -> ksp_core_lib::Result { 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 content = std::fs::read_to_string(path.as_path()); let content = match content { std::result::Result::Ok(value) => value, std::result::Result::Err(error) => return std::result::Result::Err(json_read_error(file_id, &path, error)), }; let value = serde_json::from_str::(content.as_str()); return match value { std::result::Result::Ok(value) => std::result::Result::Ok(ConfigJsonDocument { file_id: file_id.clone(), path, value }), std::result::Result::Err(error) => std::result::Result::Err(json_syntax_error(file_id, &path, error)), }; } } #[derive(serde::Deserialize)] #[serde(deny_unknown_fields)] struct LoggingDocumentSource { format_version: u32, logs_directory: String, default_profile: String, profiles: std::vec::Vec, } #[derive(serde::Deserialize)] #[serde(deny_unknown_fields)] struct LoggingProfileSource { profile_id: String, default_filter: String, span_events: String, console: LoggingConsoleSource, files: std::vec::Vec, target_filters: std::vec::Vec, } #[derive(serde::Deserialize)] #[serde(deny_unknown_fields)] struct LoggingConsoleSource { enabled: bool, output: String, ansi: bool, format: String, filter: LoggingOutputFilterSource, } #[derive(serde::Deserialize)] #[serde(deny_unknown_fields)] struct LoggingFileSource { output_id: String, enabled: bool, path: String, rotation: String, format: String, ansi: bool, filter: LoggingOutputFilterSource, } #[derive(serde::Deserialize)] #[serde(deny_unknown_fields)] struct LoggingOutputFilterSource { level: String, targets: std::vec::Vec, domains: std::vec::Vec, } #[derive(serde::Deserialize)] #[serde(deny_unknown_fields)] struct LoggingTargetFilterSource { target_prefix: String, level: String, } fn validate_schema_document(schema: &ConfigJsonDocument) -> ksp_core_lib::Result<()> { let validation = jsonschema::meta::validate(schema.value()); return match validation { std::result::Result::Ok(()) => std::result::Result::Ok(()), std::result::Result::Err(error) => std::result::Result::Err( ksp_core_lib::Error::new(crate::ERROR_CODE_SCHEMA_INVALID, "Config JSON Schema document is invalid") .with_context("file_id", schema.file_id().as_str()) .with_context("path", schema.path().to_string_lossy().into_owned()) .with_context("detail", error.to_string()), ), }; } fn validate_instance(document: &ConfigJsonDocument, schema: &ConfigJsonDocument) -> ksp_core_lib::Result<()> { let validation = jsonschema::draft202012::validate(schema.value(), document.value()); return match validation { std::result::Result::Ok(()) => std::result::Result::Ok(()), std::result::Result::Err(error) => std::result::Result::Err( ksp_core_lib::Error::new(crate::ERROR_CODE_SCHEMA_VALIDATION_FAILED, "Config document does not satisfy its registered JSON Schema") .with_context("file_id", document.file_id().as_str()) .with_context("schema_file_id", schema.file_id().as_str()) .with_context("path", document.path().to_string_lossy().into_owned()) .with_context("detail", error.to_string()), ), }; } fn validate_document_semantics(engine: &ConfigDocumentEngine, document: &ConfigJsonDocument) -> ksp_core_lib::Result<()> { let profile_validation = crate::profile::validate_document_profile_contract(document); if let std::result::Result::Err(error) = profile_validation { return std::result::Result::Err(error); } let composite_validation = crate::composite::validate_composite_document_contract(engine, document); if let std::result::Result::Err(error) = composite_validation { return std::result::Result::Err(error); } if document.file_id().as_str() != crate::FILE_ID_STD_LOGGING { return std::result::Result::Ok(()); } let parsed = serde_json::from_value::(document.value().clone()); let parsed = match parsed { std::result::Result::Ok(value) => value, std::result::Result::Err(error) => { return std::result::Result::Err( semantic_error(document, "schema-valid Logging document cannot be decoded into the KSP source contract").with_source(error), ); }, }; return validate_logging_document(document, &parsed); } fn validate_logging_document(document: &ConfigJsonDocument, source: &LoggingDocumentSource) -> ksp_core_lib::Result<()> { if source.format_version != 1 { return std::result::Result::Err(semantic_error(document, "unsupported Logging document format_version")); } if source.logs_directory.trim().is_empty() { return std::result::Result::Err(semantic_error(document, "logs_directory must not be empty")); } if source.default_profile.trim().is_empty() { return std::result::Result::Err(semantic_error(document, "default_profile must not be empty")); } for (profile_index, profile) in source.profiles.iter().enumerate() { let validation = validate_logging_profile(document, profile, profile_index); if let std::result::Result::Err(error) = validation { return std::result::Result::Err(error); } } return std::result::Result::Ok(()); } fn validate_logging_profile(document: &ConfigJsonDocument, profile: &LoggingProfileSource, profile_index: usize) -> ksp_core_lib::Result<()> { if profile.profile_id.trim().is_empty() || profile.default_filter.trim().is_empty() || profile.span_events.trim().is_empty() { return std::result::Result::Err( semantic_error(document, "Logging profile identity and base settings must not be empty").with_context("profile_index", profile_index.to_string()), ); } let console_validation = validate_logging_console(document, &profile.console, profile_index); if let std::result::Result::Err(error) = console_validation { return std::result::Result::Err(error); } for (file_index, file) in profile.files.iter().enumerate() { let file_validation = validate_logging_file(document, file, profile_index, file_index); if let std::result::Result::Err(error) = file_validation { return std::result::Result::Err(error); } for previous in &profile.files[..file_index] { if previous.output_id == file.output_id { return std::result::Result::Err( semantic_error(document, "Logging file output identifiers must be unique within a profile") .with_context("profile_index", profile_index.to_string()) .with_context("output_id", file.output_id.as_str()), ); } } } for (target_index, target_filter) in profile.target_filters.iter().enumerate() { if target_filter.target_prefix.trim().is_empty() || !target_filter.target_prefix.starts_with("ksp-") || target_filter.level.trim().is_empty() { return std::result::Result::Err( semantic_error(document, "Logging global target filter must identify a KSP-owned target") .with_context("profile_index", profile_index.to_string()) .with_context("target_filter_index", target_index.to_string()), ); } } return std::result::Result::Ok(()); } fn validate_logging_console(document: &ConfigJsonDocument, console: &LoggingConsoleSource, profile_index: usize) -> ksp_core_lib::Result<()> { let _enabled = console.enabled; if console.output.trim().is_empty() || console.format.trim().is_empty() { return std::result::Result::Err( semantic_error(document, "Logging console output and format must not be empty").with_context("profile_index", profile_index.to_string()), ); } if console.ansi && console.format == "json" { return std::result::Result::Err( semantic_error(document, "ANSI formatting is not compatible with JSON console output").with_context("profile_index", profile_index.to_string()), ); } return validate_logging_output_filter(document, &console.filter, profile_index, "console"); } fn validate_logging_file(document: &ConfigJsonDocument, file: &LoggingFileSource, profile_index: usize, file_index: usize) -> ksp_core_lib::Result<()> { let _enabled = file.enabled; if !valid_output_id(file.output_id.as_str()) { return std::result::Result::Err( semantic_error(document, "Logging file output_id is invalid") .with_context("profile_index", profile_index.to_string()) .with_context("file_index", file_index.to_string()) .with_context("output_id", file.output_id.as_str()), ); } if file.path.trim().is_empty() || !relative_log_path_is_valid(file.path.as_str()) { return std::result::Result::Err( semantic_error(document, "Logging file path must stay relative to logs_directory without traversal") .with_context("profile_index", profile_index.to_string()) .with_context("file_index", file_index.to_string()), ); } if file.rotation.trim().is_empty() || file.format.trim().is_empty() { return std::result::Result::Err( semantic_error(document, "Logging file rotation and format must not be empty").with_context("profile_index", profile_index.to_string()), ); } if file.ansi { return std::result::Result::Err( semantic_error(document, "ANSI sequences are not allowed in persistent Logging outputs").with_context("profile_index", profile_index.to_string()), ); } return validate_logging_output_filter(document, &file.filter, profile_index, file.output_id.as_str()); } fn validate_logging_output_filter( document: &ConfigJsonDocument, filter: &LoggingOutputFilterSource, profile_index: usize, output: &str, ) -> ksp_core_lib::Result<()> { if filter.level.trim().is_empty() { return std::result::Result::Err( semantic_error(document, "Logging output filter level must not be empty").with_context("profile_index", profile_index.to_string()), ); } let targets = validate_selectors(document, &filter.targets, true, profile_index, output, "targets"); if let std::result::Result::Err(error) = targets { return std::result::Result::Err(error); } return validate_selectors(document, &filter.domains, false, profile_index, output, "domains"); } fn validate_selectors( document: &ConfigJsonDocument, selectors: &[String], target_dimension: bool, profile_index: usize, output: &str, dimension: &'static str, ) -> ksp_core_lib::Result<()> { if selectors.is_empty() { return std::result::Result::Err(selector_error(document, profile_index, output, dimension, "selector list must not be empty")); } if selectors.len() > 1 && selectors.iter().any(|selector| -> bool { return selector == "*"; }) { return std::result::Result::Err(selector_error(document, profile_index, output, dimension, "wildcard selector must be used alone")); } for (index, selector) in selectors.iter().enumerate() { if selector.trim().is_empty() { return std::result::Result::Err( selector_error(document, profile_index, output, dimension, "selector must not be empty").with_context("selector_index", index.to_string()), ); } if target_dimension && selector != "*" && !selector.starts_with("ksp-") { return std::result::Result::Err( selector_error(document, profile_index, output, dimension, "target selector must identify a KSP-owned target") .with_context("selector_index", index.to_string()), ); } for previous in &selectors[..index] { if previous == selector { return std::result::Result::Err( selector_error(document, profile_index, output, dimension, "selectors must be unique").with_context("selector_index", index.to_string()), ); } } } return std::result::Result::Ok(()); } fn valid_output_id(output_id: &str) -> bool { let mut previous_was_separator = true; if output_id.is_empty() { return false; } for byte in output_id.bytes() { if byte == b'.' { if previous_was_separator { return false; } previous_was_separator = true; } else if byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'_' || byte == b'-' { previous_was_separator = false; } else { return false; } } return !previous_was_separator; } fn relative_log_path_is_valid(value: &str) -> bool { let path = std::path::Path::new(value); if path.is_absolute() { return false; } let mut has_normal_component = false; for component in path.components() { match component { std::path::Component::Normal(_) => has_normal_component = true, std::path::Component::CurDir | std::path::Component::ParentDir | std::path::Component::RootDir | std::path::Component::Prefix(_) => return false, } } return has_normal_component; } fn selector_error(document: &ConfigJsonDocument, profile_index: usize, output: &str, dimension: &'static str, reason: &'static str) -> ksp_core_lib::Error { return semantic_error(document, reason) .with_context("profile_index", profile_index.to_string()) .with_context("output", output) .with_context("dimension", dimension); } fn semantic_error(document: &ConfigJsonDocument, reason: &'static str) -> ksp_core_lib::Error { return ksp_core_lib::Error::new(crate::ERROR_CODE_DOCUMENT_SEMANTIC_INVALID, "Config document violates KSP semantic invariants") .with_context("file_id", document.file_id().as_str()) .with_context("path", document.path().to_string_lossy().into_owned()) .with_context("reason", reason); } fn json_read_error(file_id: &crate::ConfigFileId, path: &std::path::Path, source: std::io::Error) -> ksp_core_lib::Error { return ksp_core_lib::Error::new(crate::ERROR_CODE_JSON_FILE_READ_FAILED, "Config-managed JSON file cannot be read") .with_context("file_id", file_id.as_str()) .with_context("path", path.to_string_lossy().into_owned()) .with_source(source); } fn json_syntax_error(file_id: &crate::ConfigFileId, path: &std::path::Path, source: serde_json::Error) -> ksp_core_lib::Error { return ksp_core_lib::Error::new(crate::ERROR_CODE_JSON_SYNTAX_INVALID, "Config-managed file contains invalid JSON syntax") .with_context("file_id", file_id.as_str()) .with_context("path", path.to_string_lossy().into_owned()) .with_source(source); } #[cfg(test)] #[path = "../unit_tests/document.rs"] mod tests;