diff --git a/Cargo.lock b/Cargo.lock index aa6c75cd..94dde183 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -596,6 +596,7 @@ dependencies = [ "decodal", "decodal-language-service", "decodal-language-tools", + "minijinja", "pretty_assertions", "serde", "serde_json", @@ -6059,6 +6060,7 @@ dependencies = [ "chrono", "clap", "client", + "config-source", "dotenv", "flow", "fs4", diff --git a/crates/config-source/Cargo.toml b/crates/config-source/Cargo.toml index ead1f4ce..b9ee3ecf 100644 --- a/crates/config-source/Cargo.toml +++ b/crates/config-source/Cargo.toml @@ -9,6 +9,7 @@ publish = false decodal.workspace = true decodal-language-service.workspace = true decodal-language-tools.workspace = true +minijinja = "2.19.0" serde = { workspace = true, features = ["derive"] } serde_json.workspace = true serde_yaml.workspace = true diff --git a/crates/config-source/src/lib.rs b/crates/config-source/src/lib.rs index f3f227a2..2923b2a1 100644 --- a/crates/config-source/src/lib.rs +++ b/crates/config-source/src/lib.rs @@ -470,6 +470,17 @@ impl ConfigTreeChange { } } +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ts_rs::TS)] +#[serde(tag = "kind", rename_all = "snake_case")] +#[ts(export)] +pub enum ConfigProjectionValidator { + StaticTemplateCatalog { + namespace: String, + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + key_aliases: BTreeMap, + }, +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ts_rs::TS)] #[ts(export)] pub struct ConfigSchemaContribution { @@ -477,6 +488,8 @@ pub struct ConfigSchemaContribution { pub namespace: String, pub version: String, pub source: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub projection_validator: Option, pub source_digest: String, } @@ -517,9 +530,15 @@ impl ConfigSchemaContribution { version, source_digest: digest_bytes(source.as_bytes()), source, + projection_validator: None, }) } + pub fn with_projection_validator(mut self, validator: ConfigProjectionValidator) -> Self { + self.projection_validator = Some(validator); + self + } + fn validate(&self) -> Result<(), ConfigTreeError> { let expected = digest_bytes(self.source.as_bytes()); if self.source_digest != expected { @@ -585,6 +604,7 @@ impl WorkspaceConfigSchemaBundle { contribution.namespace.as_str(), contribution.version.as_str(), contribution.source_digest.as_str(), + contribution.projection_validator.as_ref(), ) }) .collect::>(), @@ -869,6 +889,15 @@ impl SnapshotEnvironment { )] })?; let data_json = decodal_data_to_json(&data); + if let Err(message) = + validate_projection_contracts(&data_json, &contract.schema_bundle.contributions) + { + return Err(vec![self.config_error( + entrypoint.clone(), + "projection_validation", + message, + )]); + } let projection_digest = digest_bytes( serde_json::to_vec(&data_json) .expect("Decodal projection serializes") @@ -1223,6 +1252,163 @@ fn decodal_data_to_json(data: &Data) -> serde_json::Value { } } +fn validate_projection_contracts( + projection: &serde_json::Value, + contributions: &[ConfigSchemaContribution], +) -> Result<(), String> { + for contribution in contributions { + let Some(ConfigProjectionValidator::StaticTemplateCatalog { + namespace, + key_aliases, + }) = &contribution.projection_validator + else { + continue; + }; + let value = projection + .get(namespace) + .ok_or_else(|| format!("projection has no '{namespace}' template namespace"))?; + let mut templates = BTreeMap::new(); + flatten_string_catalog("", value, &mut templates)?; + for (source, target) in key_aliases { + if let Some(value) = templates.remove(source) { + if templates.insert(target.clone(), value).is_some() { + return Err(format!( + "template catalog alias '{source}' collides with '{target}'" + )); + } + } + } + validate_static_template_catalog(&templates)?; + } + Ok(()) +} + +fn flatten_string_catalog( + prefix: &str, + value: &serde_json::Value, + output: &mut BTreeMap, +) -> Result<(), String> { + match value { + serde_json::Value::String(source) if !prefix.is_empty() => { + output.insert(prefix.to_string(), source.clone()); + Ok(()) + } + serde_json::Value::Object(fields) => { + for (name, value) in fields { + let key = if prefix.is_empty() { + name.clone() + } else { + format!("{prefix}.{name}") + }; + flatten_string_catalog(&key, value, output)?; + } + Ok(()) + } + _ => Err(format!( + "template catalog leaf '{}' must be a string", + if prefix.is_empty() { "" } else { prefix } + )), + } +} + +pub fn validate_static_template_catalog( + templates: &BTreeMap, +) -> Result<(), String> { + if templates.is_empty() { + return Err("template catalog is empty".to_string()); + } + let mut environment = minijinja::Environment::new(); + environment.set_undefined_behavior(minijinja::UndefinedBehavior::Strict); + let mut graph = BTreeMap::new(); + for (name, source) in templates { + environment + .add_template_owned(name.clone(), source.clone()) + .map_err(|error| format!("template '{name}' does not compile: {error}"))?; + let includes = parse_static_template_includes(name, source)?; + for target in &includes { + if !templates.contains_key(target) { + return Err(format!( + "template '{name}' includes missing target '{target}'" + )); + } + } + graph.insert(name.clone(), includes); + } + fn visit( + node: &str, + graph: &BTreeMap>, + visiting: &mut Vec, + visited: &mut BTreeSet, + ) -> Result<(), String> { + if let Some(position) = visiting.iter().position(|entry| entry == node) { + let mut cycle = visiting[position..].to_vec(); + cycle.push(node.to_string()); + return Err(format!("template include cycle: {}", cycle.join(" -> "))); + } + if visited.contains(node) { + return Ok(()); + } + visiting.push(node.to_string()); + for target in &graph[node] { + visit(target, graph, visiting, visited)?; + } + visiting.pop(); + visited.insert(node.to_string()); + Ok(()) + } + let mut visited = BTreeSet::new(); + for node in graph.keys() { + visit(node, &graph, &mut Vec::new(), &mut visited)?; + } + Ok(()) +} + +fn parse_static_template_includes(template: &str, source: &str) -> Result, String> { + let mut includes = Vec::new(); + let mut rest = source; + while let Some(open) = rest.find("{%") { + let after_open = &rest[open + 2..]; + let Some(close) = after_open.find("%}") else { + break; + }; + let body = after_open[..close].trim(); + if body.starts_with("include") { + let argument = body["include".len()..].trim(); + let bytes = argument.as_bytes(); + if bytes.len() < 2 + || !matches!(bytes[0], b'\'' | b'"') + || bytes[bytes.len() - 1] != bytes[0] + { + return Err(format!( + "template '{template}' include target must be one exact quoted dotted name" + )); + } + let target = &argument[1..argument.len() - 1]; + if target.is_empty() + || target.contains('/') + || target.contains('\\') + || target.contains('$') + || target.ends_with(".md") + || target.split('.').any(|segment| { + segment.is_empty() + || !segment.chars().all(|character| { + character.is_ascii_lowercase() + || character.is_ascii_digit() + || character == '_' + }) + }) + { + return Err(format!( + "template '{template}' has invalid catalog-root include target '{target}'" + )); + } + includes.push(target.to_string()); + } + rest = &after_open[close + 2..]; + } + Ok(includes) +} + fn snapshot_digest(entries: &BTreeMap) -> String { let mut hasher = Sha256::new(); hasher.update(b"yoi-config-tree-v1\0"); diff --git a/web/workspace/src/lib/workspace/config-source/generated/types/ConfigProjectionValidator.ts b/web/workspace/src/lib/workspace/config-source/generated/types/ConfigProjectionValidator.ts new file mode 100644 index 00000000..04cd5395 --- /dev/null +++ b/web/workspace/src/lib/workspace/config-source/generated/types/ConfigProjectionValidator.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type ConfigProjectionValidator = { "kind": "static_template_catalog", namespace: string, key_aliases?: { [key in string]: string }, }; diff --git a/web/workspace/src/lib/workspace/config-source/generated/types/ConfigSchemaContribution.ts b/web/workspace/src/lib/workspace/config-source/generated/types/ConfigSchemaContribution.ts index 4e89881b..69d5a595 100644 --- a/web/workspace/src/lib/workspace/config-source/generated/types/ConfigSchemaContribution.ts +++ b/web/workspace/src/lib/workspace/config-source/generated/types/ConfigSchemaContribution.ts @@ -1,3 +1,4 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { ConfigProjectionValidator } from "./ConfigProjectionValidator"; -export type ConfigSchemaContribution = { provider_id: string, namespace: string, version: string, source: string, source_digest: string, }; +export type ConfigSchemaContribution = { provider_id: string, namespace: string, version: string, source: string, projection_validator?: ConfigProjectionValidator | null, source_digest: string, };