Skip to main content

pardarsh_core/
validation.rs

1//! Structural and semantic validation.
2//!
3//! The repository validates the *resulting state* of every command, so the
4//! same rules apply to creation, edits, imports and replays. Checks that need
5//! other records (reference targets) are done by the repository.
6
7use 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/// Size limits applied to every record. Generous for legitimate use, small
61/// enough to bound the cost of untrusted input.
62#[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    /// How far in the future an `occurred_at` may be (clock skew allowance).
74    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        // Redacted locators use the reserved urn:redacted: form.
173        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    /// Checks on event metadata that do not depend on the record state.
186    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}