use std::{collections::HashMap, path::PathBuf, sync::Arc};
pub(crate) use detritus_protocol::schema::SchemaError;
pub use detritus_protocol::schema::SchemaKind;
use jsonschema::Validator;
use tokio::fs;
#[derive(Debug, Clone)]
pub struct ProjectSchemaEntry {
pub project: String,
pub kind: SchemaKind,
pub path: PathBuf,
}
#[derive(Clone)]
struct CompiledSchema(Arc<Validator>);
impl std::fmt::Debug for CompiledSchema {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("CompiledSchema").finish_non_exhaustive()
}
}
#[derive(Debug, Clone)]
pub struct SchemaRegistry {
schemas: HashMap<(String, SchemaKind), CompiledSchema>,
}
impl SchemaRegistry {
pub fn empty() -> Self {
Self {
schemas: HashMap::new(),
}
}
pub async fn load(entries: &[ProjectSchemaEntry]) -> Result<Self, SchemaError> {
let mut schemas = HashMap::with_capacity(entries.len());
for entry in entries {
let raw = fs::read_to_string(&entry.path)
.await
.map_err(|source| SchemaError::Io {
path: entry.path.clone(),
source,
})?;
let value: serde_json::Value =
serde_json::from_str(&raw).map_err(|source| SchemaError::Parse {
path: entry.path.clone(),
source,
})?;
let validator = Validator::new(&value).map_err(|err| SchemaError::Parse {
path: entry.path.clone(),
source: serde::de::Error::custom(err.to_string()),
})?;
schemas.insert(
(entry.project.clone(), entry.kind),
CompiledSchema(Arc::new(validator)),
);
}
Ok(Self { schemas })
}
pub fn validate(
&self,
project: &str,
kind: SchemaKind,
payload: &serde_json::Value,
) -> Result<(), SchemaError> {
let Some(compiled) = self.schemas.get(&(project.to_owned(), kind)) else {
return Ok(());
};
let errors: Vec<String> = compiled
.0
.iter_errors(payload)
.map(|e| e.to_string())
.collect();
if errors.is_empty() {
Ok(())
} else {
Err(SchemaError::Validation { kind, errors })
}
}
}
#[cfg(test)]
mod tests {
use std::path::Path;
use detritus_protocol::schema::SchemaKind;
use serde_json::json;
use tempfile::TempDir;
use super::{ProjectSchemaEntry, SchemaRegistry};
#[test]
fn empty_registry_validates_anything() {
let registry = SchemaRegistry::empty();
let payload = json!({"key": "value"});
assert!(
registry
.validate("acme", SchemaKind::CrashMetadata, &payload)
.is_ok(),
"empty registry should accept any payload",
);
assert!(registry.schemas.is_empty());
}
#[tokio::test]
async fn load_two_schemas_resolves_relative_paths() {
let fixtures = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/schemas");
let crash_path = fixtures.join("crash.schema.json");
let log_path = fixtures.join("log.schema.json");
let entries = vec![
ProjectSchemaEntry {
project: "acme".to_owned(),
kind: SchemaKind::CrashMetadata,
path: crash_path,
},
ProjectSchemaEntry {
project: "acme".to_owned(),
kind: SchemaKind::LogAttributes,
path: log_path,
},
];
let registry = SchemaRegistry::load(&entries)
.await
.expect("load should succeed");
assert_eq!(registry.schemas.len(), 2, "both schemas should be loaded");
let payload = json!({});
assert!(
registry
.validate("acme", SchemaKind::CrashMetadata, &payload)
.is_ok(),
"registered schema should validate (no-op Ok)",
);
assert!(
registry
.validate("acme", SchemaKind::LogAttributes, &payload)
.is_ok(),
"registered schema should validate (no-op Ok)",
);
}
#[tokio::test]
async fn unknown_project_accepts_by_default() {
let fixtures = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/schemas");
let entries = vec![ProjectSchemaEntry {
project: "known-project".to_owned(),
kind: SchemaKind::CrashMetadata,
path: fixtures.join("crash.schema.json"),
}];
let registry = SchemaRegistry::load(&entries)
.await
.expect("load should succeed");
let payload = json!({"x": 1});
assert!(
registry
.validate("nope", SchemaKind::CrashMetadata, &payload)
.is_ok(),
"unknown project should be accepted by default",
);
}
#[tokio::test]
async fn tokens_config_without_schemas_loads_empty_registry() {
use std::io::Write as _;
let dir = TempDir::new().expect("tempdir");
let tokens_path = dir.path().join("tokens.toml");
{
let mut f = std::fs::File::create(&tokens_path).expect("create tokens.toml");
writeln!(
f,
r#"
[[token]]
id = "t1"
secret = "$argon2id$v=19$m=19456,t=2,p=1$AAAAAAAAAAAAAAAAAAAAAA$bm90YXJlYWxoYXNoYnV0cGFzc2VzZm9ybWF0Y2hlY2s"
project = "proj"
source_prefix = "src/"
"#
)
.expect("write");
}
let config = crate::auth::load_security_config(&tokens_path)
.await
.expect("load_security_config should succeed");
assert!(
config.schema_registry.schemas.is_empty(),
"no [[schema]] entries → empty registry",
);
}
#[tokio::test]
async fn tokens_config_schema_project_mismatch_errors() {
use std::io::Write as _;
let dir = TempDir::new().expect("tempdir");
let schema_path = dir.path().join("crash.schema.json");
std::fs::write(&schema_path, r#"{"type":"object"}"#).expect("write schema");
let tokens_path = dir.path().join("tokens.toml");
{
let mut f = std::fs::File::create(&tokens_path).expect("create tokens.toml");
writeln!(
f,
r#"
[[token]]
id = "t1"
secret = "$argon2id$v=19$m=19456,t=2,p=1$AAAAAAAAAAAAAAAAAAAAAA$bm90YXJlYWxoYXNoYnV0cGFzc2VzZm9ybWF0Y2hlY2s"
project = "real-project"
source_prefix = "src/"
[[schema]]
project = "ghost-project"
kind = "crash_metadata"
path = "crash.schema.json"
"#
)
.expect("write");
}
let err = crate::auth::load_security_config(&tokens_path)
.await
.expect_err("mismatched project should fail");
assert!(
matches!(
err,
crate::auth::AuthConfigError::SchemaProjectMismatch { ref project, .. }
if project == "ghost-project"
),
"unexpected error: {err:?}",
);
}
}