452 lines
19 KiB
Rust
452 lines
19 KiB
Rust
// file: crates/ksp-config-lib/src/document.rs
|
|
// version: 1
|
|
|
|
/// 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<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 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),
|
|
};
|
|
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(&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<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 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::<serde_json::Value>(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<LoggingProfileSource>,
|
|
}
|
|
|
|
#[derive(serde::Deserialize)]
|
|
#[serde(deny_unknown_fields)]
|
|
struct LoggingProfileSource {
|
|
profile_id: String,
|
|
default_filter: String,
|
|
span_events: String,
|
|
console: LoggingConsoleSource,
|
|
files: std::vec::Vec<LoggingFileSource>,
|
|
target_filters: std::vec::Vec<LoggingTargetFilterSource>,
|
|
}
|
|
|
|
#[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<String>,
|
|
domains: std::vec::Vec<String>,
|
|
}
|
|
|
|
#[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(document: &ConfigJsonDocument) -> ksp_core_lib::Result<()> {
|
|
if document.file_id().as_str() != crate::FILE_ID_STD_LOGGING {
|
|
return std::result::Result::Ok(());
|
|
}
|
|
let parsed = serde_json::from_value::<LoggingDocumentSource>(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;
|