440 lines
20 KiB
Rust
440 lines
20 KiB
Rust
// file: crates/ksp-config-lib/src/registry.rs
|
|
// version: 5
|
|
|
|
/// Bootstrap argument used to replace a known Config filename mapping.
|
|
pub const ARG_FILE_MAP: &str = "--filemap";
|
|
/// Logical file identifier for the standard Logging configuration document.
|
|
pub const FILE_ID_STD_LOGGING: &str = "cfg.std.logging";
|
|
/// Logical file identifier for the standard Logging JSON Schema document.
|
|
pub const FILE_ID_SCHEMA_STD_LOGGING: &str = "schema.std.logging";
|
|
/// Logical file identifier for the standard HTTP Transport configuration document.
|
|
pub const FILE_ID_STD_TRANSPORT: &str = "cfg.std.transport";
|
|
/// Logical file identifier for the standard HTTP Transport JSON Schema document.
|
|
pub const FILE_ID_SCHEMA_STD_TRANSPORT: &str = "schema.std.transport";
|
|
/// Logical file identifier for the generic composite JSON Schema document.
|
|
pub const FILE_ID_SCHEMA_COMPOSITE: &str = "schema.composite";
|
|
/// Default physical filename for the standard Logging configuration document.
|
|
pub const DEFAULT_STD_LOGGING_FILENAME: &str = "std.logging.json";
|
|
/// Default physical filename for the standard Logging JSON Schema document.
|
|
pub const DEFAULT_STD_LOGGING_SCHEMA_FILENAME: &str = "std.logging.schema.json";
|
|
/// Default physical filename for the standard HTTP Transport configuration document.
|
|
pub const DEFAULT_STD_TRANSPORT_FILENAME: &str = "std.transport.json";
|
|
/// Default physical filename for the standard HTTP Transport JSON Schema document.
|
|
pub const DEFAULT_STD_TRANSPORT_SCHEMA_FILENAME: &str = "std.transport.schema.json";
|
|
/// Default physical filename for the generic composite JSON Schema document.
|
|
pub const DEFAULT_COMPOSITE_SCHEMA_FILENAME: &str = "composite.schema.json";
|
|
|
|
/// Stable logical identifier for a Config-managed file.
|
|
#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)]
|
|
pub struct ConfigFileId(String);
|
|
|
|
impl ConfigFileId {
|
|
/// Creates and validates a logical Config file identifier.
|
|
pub fn new(value: impl std::convert::Into<String>) -> ksp_core_lib::Result<Self> {
|
|
let value = value.into();
|
|
let validated = validate_file_id(value.as_str());
|
|
return match validated {
|
|
std::result::Result::Ok(()) => std::result::Result::Ok(Self(value)),
|
|
std::result::Result::Err(error) => std::result::Result::Err(error),
|
|
};
|
|
}
|
|
|
|
/// Returns the logical identifier as text.
|
|
#[must_use]
|
|
pub fn as_str(&self) -> &str {
|
|
return self.0.as_str();
|
|
}
|
|
}
|
|
|
|
/// Physical root category used to resolve a Config-managed file.
|
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
|
pub enum ConfigFileKind {
|
|
/// Runtime configuration document resolved below `cfgpath`.
|
|
Config,
|
|
/// JSON Schema document resolved below `schemapath`.
|
|
Schema,
|
|
}
|
|
|
|
/// Logical descriptor associating a stable file identifier with its physical filename, root category and optional validation schema.
|
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
|
pub struct ConfigFileDescriptor {
|
|
file_id: ConfigFileId,
|
|
kind: ConfigFileKind,
|
|
filename: std::path::PathBuf,
|
|
schema_file_id: std::option::Option<ConfigFileId>,
|
|
}
|
|
|
|
impl ConfigFileDescriptor {
|
|
/// Returns the stable logical identifier.
|
|
#[must_use]
|
|
pub fn file_id(&self) -> &ConfigFileId {
|
|
return &self.file_id;
|
|
}
|
|
|
|
/// Returns the root category used when resolving the file.
|
|
#[must_use]
|
|
pub fn kind(&self) -> ConfigFileKind {
|
|
return self.kind;
|
|
}
|
|
|
|
/// Returns the relative physical filename currently mapped to the identifier.
|
|
#[must_use]
|
|
pub fn filename(&self) -> &std::path::Path {
|
|
return self.filename.as_path();
|
|
}
|
|
|
|
/// Returns the logical schema identifier associated with this Config document when one is declared.
|
|
#[must_use]
|
|
pub fn schema_file_id(&self) -> std::option::Option<&ConfigFileId> {
|
|
return self.schema_file_id.as_ref();
|
|
}
|
|
|
|
pub(crate) fn new(
|
|
file_id: &'static str,
|
|
kind: ConfigFileKind,
|
|
filename: &'static str,
|
|
schema_file_id: std::option::Option<&'static str>,
|
|
) -> ksp_core_lib::Result<Self> {
|
|
let file_id = ConfigFileId::new(file_id);
|
|
let file_id = match file_id {
|
|
std::result::Result::Ok(value) => value,
|
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
|
};
|
|
let kind_validation = validate_kind_prefix(&file_id, kind);
|
|
match kind_validation {
|
|
std::result::Result::Ok(()) => {},
|
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
|
}
|
|
let filename = validate_relative_filename(&file_id, std::path::PathBuf::from(filename));
|
|
let filename = match filename {
|
|
std::result::Result::Ok(value) => value,
|
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
|
};
|
|
let schema_file_id = parse_schema_file_id(&file_id, kind, schema_file_id);
|
|
return match schema_file_id {
|
|
std::result::Result::Ok(value) => std::result::Result::Ok(Self { file_id, kind, filename, schema_file_id: value }),
|
|
std::result::Result::Err(error) => std::result::Result::Err(error),
|
|
};
|
|
}
|
|
}
|
|
|
|
/// Registry of KSP-known logical Config files and their replaceable physical filenames.
|
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
|
pub struct ConfigFileRegistry {
|
|
descriptors: std::collections::BTreeMap<ConfigFileId, ConfigFileDescriptor>,
|
|
}
|
|
|
|
impl ConfigFileRegistry {
|
|
/// Creates the registry containing the KSP default file mappings known in the current release.
|
|
pub fn defaults() -> ksp_core_lib::Result<Self> {
|
|
let logging = ConfigFileDescriptor::new(
|
|
FILE_ID_STD_LOGGING,
|
|
ConfigFileKind::Config,
|
|
DEFAULT_STD_LOGGING_FILENAME,
|
|
std::option::Option::Some(FILE_ID_SCHEMA_STD_LOGGING),
|
|
);
|
|
let logging = match logging {
|
|
std::result::Result::Ok(value) => value,
|
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
|
};
|
|
let logging_schema =
|
|
ConfigFileDescriptor::new(FILE_ID_SCHEMA_STD_LOGGING, ConfigFileKind::Schema, DEFAULT_STD_LOGGING_SCHEMA_FILENAME, std::option::Option::None);
|
|
let logging_schema = match logging_schema {
|
|
std::result::Result::Ok(value) => value,
|
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
|
};
|
|
let transport = ConfigFileDescriptor::new(
|
|
FILE_ID_STD_TRANSPORT,
|
|
ConfigFileKind::Config,
|
|
DEFAULT_STD_TRANSPORT_FILENAME,
|
|
std::option::Option::Some(FILE_ID_SCHEMA_STD_TRANSPORT),
|
|
);
|
|
let transport = match transport {
|
|
std::result::Result::Ok(value) => value,
|
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
|
};
|
|
let transport_schema =
|
|
ConfigFileDescriptor::new(FILE_ID_SCHEMA_STD_TRANSPORT, ConfigFileKind::Schema, DEFAULT_STD_TRANSPORT_SCHEMA_FILENAME, std::option::Option::None);
|
|
let transport_schema = match transport_schema {
|
|
std::result::Result::Ok(value) => value,
|
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
|
};
|
|
let composite_schema =
|
|
ConfigFileDescriptor::new(FILE_ID_SCHEMA_COMPOSITE, ConfigFileKind::Schema, DEFAULT_COMPOSITE_SCHEMA_FILENAME, std::option::Option::None);
|
|
let composite_schema = match composite_schema {
|
|
std::result::Result::Ok(value) => value,
|
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
|
};
|
|
return build_registry([logging, logging_schema, transport, transport_schema, composite_schema]);
|
|
}
|
|
|
|
/// Creates the default registry and applies repeatable `--filemap=<file_id>=<filename>` overrides from raw process arguments.
|
|
///
|
|
/// Unrelated arguments are ignored. A repeated mapping for the same known `file_id` is accepted and the last mapping wins.
|
|
pub fn from_args(args: &[std::ffi::OsString]) -> ksp_core_lib::Result<Self> {
|
|
let registry = Self::defaults();
|
|
let mut registry = match registry {
|
|
std::result::Result::Ok(value) => value,
|
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
|
};
|
|
let mut index: usize = 0;
|
|
while index < args.len() {
|
|
let application = apply_file_map_argument(&mut registry, &args[index]);
|
|
match application {
|
|
std::result::Result::Ok(()) => {},
|
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
|
}
|
|
index += 1;
|
|
}
|
|
return std::result::Result::Ok(registry);
|
|
}
|
|
|
|
/// Iterates over all registered descriptors in deterministic logical `file_id` order.
|
|
///
|
|
/// The returned view is read-only and reflects filename overrides already applied to this registry.
|
|
pub fn descriptors(&self) -> impl std::iter::Iterator<Item = &ConfigFileDescriptor> + '_ {
|
|
return self.descriptors.values();
|
|
}
|
|
|
|
/// Returns the descriptor associated with a known logical file identifier.
|
|
pub fn descriptor(&self, file_id: &ConfigFileId) -> ksp_core_lib::Result<&ConfigFileDescriptor> {
|
|
return match self.descriptors.get(file_id) {
|
|
std::option::Option::Some(descriptor) => std::result::Result::Ok(descriptor),
|
|
std::option::Option::None => std::result::Result::Err(unknown_file_id_error(file_id.as_str())),
|
|
};
|
|
}
|
|
|
|
/// Resolves a known logical file identifier below the bootstrap root selected by its descriptor kind.
|
|
pub fn resolve_path(&self, bootstrap: &crate::ConfigBootstrapOptions, file_id: &ConfigFileId) -> ksp_core_lib::Result<std::path::PathBuf> {
|
|
let descriptor = self.descriptor(file_id);
|
|
let descriptor = match descriptor {
|
|
std::result::Result::Ok(value) => value,
|
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
|
};
|
|
let root = match descriptor.kind() {
|
|
ConfigFileKind::Config => bootstrap.cfg_path(),
|
|
ConfigFileKind::Schema => bootstrap.schema_path(),
|
|
};
|
|
return std::result::Result::Ok(root.join(descriptor.filename()));
|
|
}
|
|
|
|
/// Replaces the physical filename of one known logical identifier while preserving its kind, schema association and logical identity.
|
|
pub fn with_filename_override(mut self, file_id: &ConfigFileId, filename: impl std::convert::Into<std::path::PathBuf>) -> ksp_core_lib::Result<Self> {
|
|
let update = self.set_filename_override(file_id, filename.into());
|
|
return match update {
|
|
std::result::Result::Ok(()) => std::result::Result::Ok(self),
|
|
std::result::Result::Err(error) => std::result::Result::Err(error),
|
|
};
|
|
}
|
|
|
|
fn set_filename_override(&mut self, file_id: &ConfigFileId, filename: std::path::PathBuf) -> ksp_core_lib::Result<()> {
|
|
let descriptor = self.descriptors.get(file_id);
|
|
let descriptor = match descriptor {
|
|
std::option::Option::Some(value) => value.clone(),
|
|
std::option::Option::None => return std::result::Result::Err(unknown_file_id_error(file_id.as_str())),
|
|
};
|
|
let filename = validate_relative_filename(file_id, filename);
|
|
let filename = match filename {
|
|
std::result::Result::Ok(value) => value,
|
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
|
};
|
|
let updated = ConfigFileDescriptor {
|
|
file_id: descriptor.file_id.clone(),
|
|
kind: descriptor.kind,
|
|
filename,
|
|
schema_file_id: descriptor.schema_file_id.clone(),
|
|
};
|
|
self.descriptors.insert(file_id.clone(), updated);
|
|
return std::result::Result::Ok(());
|
|
}
|
|
}
|
|
|
|
pub(crate) fn build_registry<const N: usize>(descriptors: [ConfigFileDescriptor; N]) -> ksp_core_lib::Result<ConfigFileRegistry> {
|
|
let mut registry = ConfigFileRegistry { descriptors: std::collections::BTreeMap::new() };
|
|
for descriptor in descriptors {
|
|
let file_id = descriptor.file_id.clone();
|
|
let duplicate_id = file_id.clone();
|
|
let previous = registry.descriptors.insert(file_id, descriptor);
|
|
if previous.is_some() {
|
|
return std::result::Result::Err(duplicate_file_id_error(duplicate_id.as_str()));
|
|
}
|
|
}
|
|
let associations = validate_schema_associations(®istry);
|
|
return match associations {
|
|
std::result::Result::Ok(()) => std::result::Result::Ok(registry),
|
|
std::result::Result::Err(error) => std::result::Result::Err(error),
|
|
};
|
|
}
|
|
|
|
fn parse_schema_file_id(
|
|
file_id: &ConfigFileId,
|
|
kind: ConfigFileKind,
|
|
schema_file_id: std::option::Option<&'static str>,
|
|
) -> ksp_core_lib::Result<std::option::Option<ConfigFileId>> {
|
|
return match (kind, schema_file_id) {
|
|
(ConfigFileKind::Schema, std::option::Option::Some(_)) => {
|
|
std::result::Result::Err(invalid_file_mapping_with_id_error(file_id.as_str(), "schema descriptors cannot declare another validation schema"))
|
|
},
|
|
(ConfigFileKind::Schema, std::option::Option::None) | (ConfigFileKind::Config, std::option::Option::None) => {
|
|
std::result::Result::Ok(std::option::Option::None)
|
|
},
|
|
(ConfigFileKind::Config, std::option::Option::Some(value)) => {
|
|
let schema_id = ConfigFileId::new(value);
|
|
match schema_id {
|
|
std::result::Result::Ok(schema_id) => {
|
|
if schema_id.as_str().starts_with("schema.") {
|
|
std::result::Result::Ok(std::option::Option::Some(schema_id))
|
|
} else {
|
|
std::result::Result::Err(invalid_file_mapping_with_id_error(
|
|
file_id.as_str(),
|
|
"validation schema file_id must use the schema namespace",
|
|
))
|
|
}
|
|
},
|
|
std::result::Result::Err(error) => std::result::Result::Err(error),
|
|
}
|
|
},
|
|
};
|
|
}
|
|
|
|
fn validate_schema_associations(registry: &ConfigFileRegistry) -> ksp_core_lib::Result<()> {
|
|
for descriptor in registry.descriptors.values() {
|
|
let schema_file_id = match descriptor.schema_file_id() {
|
|
std::option::Option::Some(value) => value,
|
|
std::option::Option::None => continue,
|
|
};
|
|
let schema = registry.descriptors.get(schema_file_id);
|
|
let schema = match schema {
|
|
std::option::Option::Some(value) => value,
|
|
std::option::Option::None => {
|
|
return std::result::Result::Err(invalid_file_mapping_with_id_error(
|
|
descriptor.file_id().as_str(),
|
|
"validation schema file_id is not registered",
|
|
));
|
|
},
|
|
};
|
|
if schema.kind() != ConfigFileKind::Schema {
|
|
return std::result::Result::Err(invalid_file_mapping_with_id_error(
|
|
descriptor.file_id().as_str(),
|
|
"validation schema descriptor must have schema kind",
|
|
));
|
|
}
|
|
}
|
|
return std::result::Result::Ok(());
|
|
}
|
|
|
|
fn apply_file_map_argument(registry: &mut ConfigFileRegistry, argument: &std::ffi::OsStr) -> ksp_core_lib::Result<()> {
|
|
let text = match argument.to_str() {
|
|
std::option::Option::Some(value) => value,
|
|
std::option::Option::None => return std::result::Result::Ok(()),
|
|
};
|
|
if text == ARG_FILE_MAP {
|
|
return std::result::Result::Err(invalid_file_mapping_error("--filemap requires the inline form --filemap=<file_id>=<filename>"));
|
|
}
|
|
let prefix = "--filemap=";
|
|
let mapping = match text.strip_prefix(prefix) {
|
|
std::option::Option::Some(value) => value,
|
|
std::option::Option::None => return std::result::Result::Ok(()),
|
|
};
|
|
let separator = mapping.find('=');
|
|
let separator = match separator {
|
|
std::option::Option::Some(value) => value,
|
|
std::option::Option::None => {
|
|
return std::result::Result::Err(invalid_file_mapping_error("file mapping must contain a file_id and filename separated by '='"));
|
|
},
|
|
};
|
|
let file_id_text = &mapping[..separator];
|
|
let filename_text = &mapping[separator + 1..];
|
|
if file_id_text.is_empty() || filename_text.is_empty() {
|
|
return std::result::Result::Err(invalid_file_mapping_error("file mapping requires non-empty file_id and filename values"));
|
|
}
|
|
let file_id = ConfigFileId::new(file_id_text);
|
|
let file_id = match file_id {
|
|
std::result::Result::Ok(value) => value,
|
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
|
};
|
|
return registry.set_filename_override(&file_id, std::path::PathBuf::from(filename_text));
|
|
}
|
|
|
|
fn validate_file_id(value: &str) -> ksp_core_lib::Result<()> {
|
|
if value.is_empty() || value.starts_with('.') || value.ends_with('.') || value.contains("..") {
|
|
return std::result::Result::Err(invalid_file_id_error(value));
|
|
}
|
|
let mut valid = true;
|
|
for byte in value.bytes() {
|
|
let allowed = byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'.' || byte == b'_' || byte == b'-';
|
|
if !allowed {
|
|
valid = false;
|
|
}
|
|
}
|
|
if !valid {
|
|
return std::result::Result::Err(invalid_file_id_error(value));
|
|
}
|
|
return std::result::Result::Ok(());
|
|
}
|
|
|
|
fn validate_kind_prefix(file_id: &ConfigFileId, kind: ConfigFileKind) -> ksp_core_lib::Result<()> {
|
|
let valid = match kind {
|
|
ConfigFileKind::Config => file_id.as_str().starts_with("cfg."),
|
|
ConfigFileKind::Schema => file_id.as_str().starts_with("schema."),
|
|
};
|
|
if !valid {
|
|
return std::result::Result::Err(invalid_file_mapping_with_id_error(file_id.as_str(), "file_id prefix does not match descriptor kind"));
|
|
}
|
|
return std::result::Result::Ok(());
|
|
}
|
|
|
|
fn validate_relative_filename(file_id: &ConfigFileId, filename: std::path::PathBuf) -> ksp_core_lib::Result<std::path::PathBuf> {
|
|
if filename.as_os_str().is_empty() || filename.is_absolute() {
|
|
return std::result::Result::Err(invalid_filename_error(file_id.as_str(), &filename));
|
|
}
|
|
let mut has_normal_component = false;
|
|
for component in filename.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 std::result::Result::Err(invalid_filename_error(file_id.as_str(), &filename));
|
|
},
|
|
}
|
|
}
|
|
if !has_normal_component {
|
|
return std::result::Result::Err(invalid_filename_error(file_id.as_str(), &filename));
|
|
}
|
|
return std::result::Result::Ok(filename);
|
|
}
|
|
|
|
fn invalid_file_id_error(file_id: &str) -> ksp_core_lib::Error {
|
|
return ksp_core_lib::Error::new(crate::ERROR_CODE_FILE_ID_INVALID, "Config file_id is invalid")
|
|
.with_context("file_id", file_id)
|
|
.with_context("reason", "expected lowercase ASCII segments separated by single dots");
|
|
}
|
|
|
|
fn unknown_file_id_error(file_id: &str) -> ksp_core_lib::Error {
|
|
return ksp_core_lib::Error::new(crate::ERROR_CODE_FILE_ID_UNKNOWN, "Config file_id is not registered").with_context("file_id", file_id);
|
|
}
|
|
|
|
fn duplicate_file_id_error(file_id: &str) -> ksp_core_lib::Error {
|
|
return ksp_core_lib::Error::new(crate::ERROR_CODE_FILE_ID_DUPLICATE, "Config file_id is registered more than once").with_context("file_id", file_id);
|
|
}
|
|
|
|
fn invalid_file_mapping_error(reason: &'static str) -> ksp_core_lib::Error {
|
|
return ksp_core_lib::Error::new(crate::ERROR_CODE_FILE_MAPPING_INVALID, "Config file mapping is invalid").with_context("reason", reason);
|
|
}
|
|
|
|
fn invalid_file_mapping_with_id_error(file_id: &str, reason: &'static str) -> ksp_core_lib::Error {
|
|
return ksp_core_lib::Error::new(crate::ERROR_CODE_FILE_MAPPING_INVALID, "Config file mapping is invalid")
|
|
.with_context("file_id", file_id)
|
|
.with_context("reason", reason);
|
|
}
|
|
|
|
fn invalid_filename_error(file_id: &str, filename: &std::path::Path) -> ksp_core_lib::Error {
|
|
return ksp_core_lib::Error::new(crate::ERROR_CODE_FILE_MAPPING_INVALID, "Config mapped filename is invalid")
|
|
.with_context("file_id", file_id)
|
|
.with_context("filename", filename.to_string_lossy().into_owned())
|
|
.with_context("reason", "filename must stay relative to its Config-owned root without traversal components");
|
|
}
|
|
|
|
#[cfg(test)]
|
|
#[path = "../unit_tests/registry.rs"]
|
|
mod tests;
|