use std::collections::BTreeMap;
use saddle_core::json_pointer::JsonPointer;
use serde::Deserialize;
#[derive(Clone, Debug, Eq, PartialEq, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct ContextLoggingConfig {
pub config_version: u32,
pub context_schema_major: u32,
pub full: FullContextProfile,
pub summaries: Vec<SummaryProfile>,
}
impl Default for ContextLoggingConfig {
fn default() -> Self { Self { config_version: 1, context_schema_major: 1, full: FullContextProfile::default(), summaries: Vec::new() } }
}
#[derive(Clone, Debug, Eq, PartialEq, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct FullContextProfile { pub enabled: bool, pub format: JsonFormat }
impl Default for FullContextProfile {
fn default() -> Self { Self { enabled: true, format: JsonFormat::Json } }
}
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum JsonFormat { #[default] Json }
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum SummaryFormat { #[default] Json, Text }
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum MissingField { #[default] Null, Omit }
#[derive(Clone, Debug, Eq, PartialEq, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct SummaryProfile {
pub name: String,
#[serde(default)] pub enabled: bool,
#[serde(default)] pub format: SummaryFormat,
pub fields: BTreeMap<String, String>,
#[serde(default)] pub missing: MissingField,
pub template: Option<String>,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum ContextLoggingError { Version, ProfileCount, Name, DuplicateName, FieldCount, Path, Template }
impl std::fmt::Display for ContextLoggingError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "invalid context logging configuration: {self:?}") }
}
impl std::error::Error for ContextLoggingError {}
impl ContextLoggingConfig {
pub fn validate(&self) -> Result<(), ContextLoggingError> {
use ContextLoggingError as E;
if self.config_version != 1 || self.context_schema_major != 1 { return Err(E::Version); }
if self.summaries.len() > 8 { return Err(E::ProfileCount); }
for (index, profile) in self.summaries.iter().enumerate() {
if profile.name.is_empty() || !profile.name.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-') { return Err(E::Name); }
if self.summaries[..index].iter().any(|old| old.name == profile.name) { return Err(E::DuplicateName); }
if profile.fields.len() > 64 { return Err(E::FieldCount); }
for path in profile.fields.values() {
if path.len() > 1024 || JsonPointer::parse(path).is_err() || !known_record_path(path) { return Err(E::Path); }
}
match (profile.format, profile.template.as_deref()) {
(SummaryFormat::Json, None) => {},
(SummaryFormat::Text, Some(template)) if template.len() <= 16 * 1024 => {
let mut remainder = template;
while let Some(start) = remainder.find("${") {
remainder = &remainder[start + 2..];
let end = remainder.find('}').ok_or(E::Template)?;
if !profile.fields.contains_key(&remainder[..end]) { return Err(E::Template); }
remainder = &remainder[end + 1..];
}
},
_ => return Err(E::Template),
}
}
Ok(())
}
}
fn known_record_path(path: &str) -> bool {
saddle_core::request_context::is_known_context_record_path(path)
}
#[cfg(test)]
mod tests {
use super::*;
fn profile(path: &str) -> SummaryProfile {
SummaryProfile { name: "business-summary".into(), enabled: true, format: SummaryFormat::Json,
fields: BTreeMap::from([("v".into(), path.into())]), missing: MissingField::Null, template: None }
}
#[test]
fn profile_bounds_apply_even_when_outputs_are_disabled() {
let mut config = ContextLoggingConfig::default();
config.full.enabled = false;
config.summaries = (0..8).map(|n| {
let mut p = profile("/context/business/future"); p.name = format!("profile_{n}"); p.enabled = false; p
}).collect();
assert_eq!(config.validate(), Ok(()));
config.summaries.push(profile("/context/business"));
assert_eq!(config.validate(), Err(ContextLoggingError::ProfileCount));
config.summaries.pop();
config.summaries[1].name = config.summaries[0].name.clone();
assert_eq!(config.validate(), Err(ContextLoggingError::DuplicateName));
config.summaries.truncate(1);
config.summaries[0].fields = (0..64).map(|n| (format!("field{n}"), "/context/business/future".into())).collect();
assert_eq!(config.validate(), Ok(()));
config.summaries[0].fields.insert("overflow".into(), "/context/business".into());
assert_eq!(config.validate(), Err(ContextLoggingError::FieldCount));
config.summaries[0].fields = BTreeMap::from([("v".into(), format!("/context/business/{}", "a".repeat(1024)))]);
assert_eq!(config.validate(), Err(ContextLoggingError::Path));
}
#[test]
fn profiles_reject_unsafe_names_fixed_typos_and_template_expressions() {
let mut config = ContextLoggingConfig::default();
assert!(config.full.enabled);
for path in ["/context/business/a~1b/0", "/context/inputInfo/requestData/unknown", "/context/interfaceInfo/module", "/context/profuseContext/future", "/context/_meta/redactions/~1business"] {
config.summaries = vec![profile(path)]; assert_eq!(config.validate(), Ok(()));
}
for path in ["/context/requestInfo/requestID", "/context/interfaceinfo", "/context/business/~2", "/context/executionInfo/taskId/unknown"] {
config.summaries = vec![profile(path)]; assert_eq!(config.validate(), Err(ContextLoggingError::Path));
}
config.summaries = vec![profile("/context/business")];
config.summaries[0].name = "../outside".into(); assert_eq!(config.validate(), Err(ContextLoggingError::Name));
config.summaries[0].name = "safe".into();
config.summaries[0].format = SummaryFormat::Text;
config.summaries[0].template = Some("value=${v}".into()); assert_eq!(config.validate(), Ok(()));
config.summaries[0].template = Some("${v.raw}".into()); assert_eq!(config.validate(), Err(ContextLoggingError::Template));
}
}