1use std::collections::HashSet;
8use std::fmt;
9
10use chrono::Duration;
11use serde::{Deserialize, Serialize};
12
13use crate::event::{Event, EventKind};
14use crate::extension::ExtensionRegistry;
15use crate::model::{Record, Timestamp};
16use crate::provenance::{ALLOWED_LOCATOR_SCHEMES, Derivation, SourceRef};
17use crate::relation::Certainty;
18use crate::schema::ENVELOPE_SCHEMA_VERSION;
19
20#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
21pub struct ValidationIssue {
22 pub path: String,
23 pub message: String,
24}
25
26impl ValidationIssue {
27 pub fn new(path: impl Into<String>, message: impl Into<String>) -> Self {
28 Self { path: path.into(), message: message.into() }
29 }
30}
31
32#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, thiserror::Error)]
33pub struct ValidationErrors {
34 pub issues: Vec<ValidationIssue>,
35}
36
37impl fmt::Display for ValidationErrors {
38 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
39 write!(f, "validation failed: ")?;
40 for (i, issue) in self.issues.iter().enumerate() {
41 if i > 0 {
42 write!(f, "; ")?;
43 }
44 write!(f, "{}: {}", issue.path, issue.message)?;
45 }
46 Ok(())
47 }
48}
49
50impl ValidationErrors {
51 pub fn single(path: impl Into<String>, message: impl Into<String>) -> Self {
52 Self { issues: vec![ValidationIssue::new(path, message)] }
53 }
54
55 fn check(issues: Vec<ValidationIssue>) -> Result<(), Self> {
56 if issues.is_empty() { Ok(()) } else { Err(Self { issues }) }
57 }
58}
59
60#[derive(Clone, Debug)]
63pub struct Limits {
64 pub title_chars: usize,
65 pub summary_chars: usize,
66 pub body_chars: usize,
67 pub reason_chars: usize,
68 pub locator_bytes: usize,
69 pub max_sources: usize,
70 pub max_relations: usize,
71 pub max_actors: usize,
72 pub max_idempotency_key: usize,
73 pub max_clock_skew: Duration,
75}
76
77impl Default for Limits {
78 fn default() -> Self {
79 Self {
80 title_chars: 300,
81 summary_chars: 2_000,
82 body_chars: 50_000,
83 reason_chars: 2_000,
84 locator_bytes: 2_048,
85 max_sources: 100,
86 max_relations: 1_000,
87 max_actors: 50,
88 max_idempotency_key: 128,
89 max_clock_skew: Duration::minutes(5),
90 }
91 }
92}
93
94#[derive(Clone, Debug, Default)]
95pub struct Validator {
96 pub registry: ExtensionRegistry,
97 pub limits: Limits,
98}
99
100impl Validator {
101 pub fn new(registry: ExtensionRegistry) -> Self {
102 Self { registry, limits: Limits::default() }
103 }
104
105 pub fn validate_record(&self, record: &Record) -> Result<(), ValidationErrors> {
106 let l = &self.limits;
107 let mut issues = Vec::new();
108 if !ENVELOPE_SCHEMA_VERSION.can_read(&record.schema_version) {
109 issues.push(ValidationIssue::new(
110 "schema_version",
111 format!(
112 "envelope version {} is not supported (reader {})",
113 record.schema_version, ENVELOPE_SCHEMA_VERSION
114 ),
115 ));
116 }
117 if record.title.trim().is_empty() {
118 issues.push(ValidationIssue::new("title", "must not be empty"));
119 }
120 text(&mut issues, "title", Some(&record.title), l.title_chars, false);
121 text(&mut issues, "summary", record.summary.as_deref(), l.summary_chars, true);
122 text(&mut issues, "body", record.body.as_deref(), l.body_chars, true);
123 if let (Some(from), Some(until)) = (record.times.effective_from, record.times.effective_until) {
124 if from > until {
125 issues.push(ValidationIssue::new("times.effective_until", "is before effective_from"));
126 }
127 }
128 if record.actors.len() > l.max_actors {
129 issues.push(ValidationIssue::new("actors", format!("more than {} actors", l.max_actors)));
130 }
131 if record.sources.len() > l.max_sources {
132 issues.push(ValidationIssue::new("sources", format!("more than {} sources", l.max_sources)));
133 }
134 let mut seen = HashSet::new();
135 for (i, s) in record.sources.iter().enumerate() {
136 if !seen.insert(s.id) {
137 issues.push(ValidationIssue::new(format!("sources[{i}].id"), "duplicate source id"));
138 }
139 self.validate_source(s, &format!("sources[{i}]"), &mut issues);
140 }
141 if record.relations.len() > l.max_relations {
142 issues.push(ValidationIssue::new("relations", format!("more than {} relations", l.max_relations)));
143 }
144 let mut seen = HashSet::new();
145 for (i, r) in record.relations.iter().enumerate() {
146 let p = format!("relations[{i}]");
147 if !seen.insert(r.id) {
148 issues.push(ValidationIssue::new(format!("{p}.id"), "duplicate relation id"));
149 }
150 if r.target == record.id {
151 issues.push(ValidationIssue::new(format!("{p}.target"), "a record cannot relate to itself"));
152 }
153 if r.certainty == Certainty::Sourced && r.sources.is_empty() {
154 issues.push(ValidationIssue::new(
155 format!("{p}.certainty"),
156 "sourced relations must cite at least one source",
157 ));
158 }
159 if let Some(loc) = &r.target_locator {
160 check_locator(loc, &format!("{p}.target_locator"), l.locator_bytes, &mut issues);
161 }
162 text(&mut issues, &format!("{p}.note"), r.note.as_deref(), l.summary_chars, true);
163 for (j, s) in r.sources.iter().enumerate() {
164 self.validate_source(s, &format!("{p}.sources[{j}]"), &mut issues);
165 }
166 }
167 self.registry.validate(record, &mut issues);
168 ValidationErrors::check(issues)
169 }
170
171 fn validate_source(&self, s: &SourceRef, path: &str, issues: &mut Vec<ValidationIssue>) {
172 if !s.locator.starts_with("urn:redacted:") {
174 check_locator(&s.locator, &format!("{path}.locator"), self.limits.locator_bytes, issues);
175 }
176 text(issues, &format!("{path}.title"), s.title.as_deref(), self.limits.title_chars, false);
177 text(issues, &format!("{path}.issuer"), s.issuer.as_deref(), self.limits.title_chars, false);
178 if let Derivation::Extracted { method } | Derivation::MachineGenerated { method, .. } = &s.derivation {
179 if method.trim().is_empty() {
180 issues.push(ValidationIssue::new(format!("{path}.derivation.method"), "must describe the method"));
181 }
182 }
183 }
184
185 pub fn validate_event(&self, event: &Event, now: Timestamp) -> Result<(), ValidationErrors> {
187 let l = &self.limits;
188 let mut issues = Vec::new();
189 if event.occurred_at > now + l.max_clock_skew {
190 issues.push(ValidationIssue::new("occurred_at", "is in the future"));
191 }
192 if event.occurred_at > event.recorded_at + l.max_clock_skew {
193 issues.push(ValidationIssue::new("occurred_at", "is after recorded_at"));
194 }
195 text(&mut issues, "reason", event.reason.as_deref(), l.reason_chars, true);
196 let needs_reason = matches!(
197 event.kind,
198 EventKind::Corrected { .. }
199 | EventKind::Retracted
200 | EventKind::Redacted { .. }
201 | EventKind::Superseded { .. }
202 );
203 if needs_reason && event.reason.as_deref().is_none_or(|r| r.trim().is_empty()) {
204 issues.push(ValidationIssue::new("reason", format!("a reason is required for {}", event.kind.name())));
205 }
206 if let Some(key) = &event.idempotency_key {
207 if key.is_empty()
208 || key.len() > l.max_idempotency_key
209 || !key.bytes().all(|b| b.is_ascii_alphanumeric() || b"-_.:".contains(&b))
210 {
211 issues.push(ValidationIssue::new("idempotency_key", "must be 1-128 characters of [A-Za-z0-9-_.:]"));
212 }
213 }
214 match &event.kind {
215 EventKind::Amended { changes } | EventKind::Corrected { changes, .. } if changes.is_empty() => {
216 issues.push(ValidationIssue::new("changes", "must not be empty"));
217 }
218 EventKind::Redacted { fields } if fields.is_empty() => {
219 issues.push(ValidationIssue::new("fields", "must not be empty"));
220 }
221 _ => {}
222 }
223 ValidationErrors::check(issues)
224 }
225}
226
227fn text(issues: &mut Vec<ValidationIssue>, path: &str, value: Option<&str>, max: usize, multiline: bool) {
228 let Some(v) = value else { return };
229 if v.chars().count() > max {
230 issues.push(ValidationIssue::new(path, format!("longer than {max} characters")));
231 }
232 if v.chars().any(|c| c.is_control() && !(multiline && (c == '\n' || c == '\r' || c == '\t'))) {
233 issues.push(ValidationIssue::new(path, "contains control characters"));
234 }
235}
236
237fn check_locator(loc: &str, path: &str, max: usize, issues: &mut Vec<ValidationIssue>) {
238 if loc.len() > max {
239 issues.push(ValidationIssue::new(path, format!("longer than {max} bytes")));
240 }
241 if loc.chars().any(|c| c.is_whitespace() || c.is_control()) {
242 issues.push(ValidationIssue::new(path, "must not contain whitespace"));
243 }
244 let scheme = loc.split_once(':').map(|(s, _)| s.to_ascii_lowercase());
245 match scheme {
246 Some(s) if ALLOWED_LOCATOR_SCHEMES.contains(&s.as_str()) => {
247 let rest = &loc[s.len() + 1..];
248 if (s == "http" || s == "https") && !rest.starts_with("//") || rest.len() < 3 {
249 issues.push(ValidationIssue::new(path, "malformed locator"));
250 }
251 }
252 _ => issues
253 .push(ValidationIssue::new(path, format!("scheme must be one of {}", ALLOWED_LOCATOR_SCHEMES.join(", ")))),
254 }
255}