Skip to main content

dpm_model/run/
validation.rs

1//! Structural validation of caller-supplied run data, applied before anything is recorded.
2
3use super::{ActivityInput, RunProvenance, RunSession, RunSource, RunStart, RunTransition};
4use crate::{ValidationError, validation::invalid};
5
6/// Longest identifier, key or word a run record accepts.
7const MAX_IDENTIFIER_BYTES: usize = 256;
8
9/// Longest detail text a lifecycle transition accepts.
10pub const MAX_DETAIL_BYTES: usize = 2048;
11
12/// Most exact source references one run may carry.
13pub const MAX_SOURCES: usize = 8;
14
15/// Longest model or version a run's provenance accepts.
16pub const MAX_PROVENANCE_BYTES: usize = 128;
17
18fn identifier(
19    entity: &'static str,
20    id: &str,
21    field: &str,
22    value: &str,
23) -> Result<(), ValidationError> {
24    if value.trim().is_empty() || value.len() > MAX_IDENTIFIER_BYTES {
25        return Err(invalid(
26            entity,
27            id,
28            format!("{field} must be 1..={MAX_IDENTIFIER_BYTES} bytes and not blank"),
29        ));
30    }
31    if value.chars().any(char::is_control) {
32        return Err(invalid(
33            entity,
34            id,
35            format!("{field} must not contain control characters"),
36        ));
37    }
38    Ok(())
39}
40
41impl RunSession {
42    /// Check the provider word and the opaque identifiers.
43    pub fn validate(&self) -> Result<(), ValidationError> {
44        let word = !self.provider.is_empty()
45            && self.provider.len() <= 32
46            && self.provider.starts_with(|c: char| c.is_ascii_lowercase())
47            && self
48                .provider
49                .chars()
50                .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || c == '_');
51        if !word {
52            return Err(invalid(
53                "run session",
54                &self.provider,
55                "provider must be a lowercase word of up to 32 letters, digits, - or _",
56            ));
57        }
58        identifier("run session", &self.provider, "session", &self.session)?;
59        if let Some(turn) = &self.turn {
60            identifier("run session", &self.provider, "turn", turn)?;
61        }
62        self.provenance
63            .as_ref()
64            .map_or(Ok(()), |provenance| provenance.validate(&self.provider))
65    }
66}
67
68impl RunProvenance {
69    /// Check the bounds of each attested fact.
70    pub fn validate(&self, provider: &str) -> Result<(), ValidationError> {
71        for (name, value) in [
72            ("requested_model", &self.requested_model),
73            ("observed_model", &self.observed_model),
74            ("runtime_version", &self.runtime_version),
75        ] {
76            if let Some(value) = value {
77                let bad = value.trim().is_empty()
78                    || value.len() > MAX_PROVENANCE_BYTES
79                    || value.chars().any(char::is_control);
80                if bad {
81                    return Err(invalid(
82                        "run provenance",
83                        provider,
84                        format!(
85                            "{name} must be 1..={MAX_PROVENANCE_BYTES} bytes, not blank, without control characters"
86                        ),
87                    ));
88                }
89            }
90        }
91        match &self.configuration_digest {
92            Some(digest)
93                if !(digest.len() == 64
94                    && digest
95                        .chars()
96                        .all(|c| c.is_ascii_digit() || ('a'..='f').contains(&c))) =>
97            {
98                Err(invalid(
99                    "run provenance",
100                    provider,
101                    "configuration_digest must be 64 lowercase hexadecimal digits",
102                ))
103            }
104            _ => Ok(()),
105        }
106    }
107}
108
109/// Whether `commit` is a full lowercase hexadecimal object name of 40 or 64 digits.
110#[must_use]
111pub fn is_exact_commit(commit: &str) -> bool {
112    matches!(commit.len(), 40 | 64)
113        && commit
114            .chars()
115            .all(|c| c.is_ascii_digit() || ('a'..='f').contains(&c))
116}
117
118impl RunSource {
119    /// Check the form of the reference; whether it names something real is the plan's to say.
120    pub fn validate(&self) -> Result<(), ValidationError> {
121        let Self::GitCommit { commit, .. } = self else {
122            return Ok(());
123        };
124        if is_exact_commit(commit) {
125            Ok(())
126        } else {
127            Err(invalid(
128                "run source",
129                commit,
130                "a commit must be a full lowercase hexadecimal object name of 40 or 64 digits; a branch or abbreviated name is not an exact source",
131            ))
132        }
133    }
134}
135
136impl RunStart {
137    /// Check the request's own form, without the plan.
138    pub fn validate(&self) -> Result<(), ValidationError> {
139        if !self.id.is_time_ordered() {
140            return Err(invalid("run", self.id, "run id must be a version 7 UUID"));
141        }
142        if self.executor.name.trim().is_empty() {
143            return Err(invalid("run", self.id, "executor name must not be empty"));
144        }
145        if self.parent == Some(self.id) {
146            return Err(invalid("run", self.id, "a run cannot be its own parent"));
147        }
148        if let Some(session) = &self.session {
149            session.validate()?;
150        }
151        if self.sources.len() > MAX_SOURCES {
152            return Err(invalid(
153                "run",
154                self.id,
155                format!("at most {MAX_SOURCES} source references"),
156            ));
157        }
158        self.sources.iter().try_for_each(RunSource::validate)
159    }
160}
161
162impl RunTransition {
163    /// Check the request's own form.
164    pub fn validate(&self) -> Result<(), ValidationError> {
165        if !self.id.is_time_ordered() {
166            return Err(invalid(
167                "run transition",
168                self.id,
169                "event id must be a version 7 UUID",
170            ));
171        }
172        match &self.detail {
173            Some(detail) if detail.trim().is_empty() || detail.len() > MAX_DETAIL_BYTES => {
174                Err(invalid(
175                    "run transition",
176                    self.id,
177                    format!("detail must be 1..={MAX_DETAIL_BYTES} bytes and not blank when given"),
178                ))
179            }
180            _ => Ok(()),
181        }
182    }
183}
184
185impl ActivityInput {
186    /// Check the request's own form; over-long text is truncated, not refused.
187    pub fn validate(&self) -> Result<(), ValidationError> {
188        if self.source_sequence == 0 {
189            return Err(invalid(
190                "run activity",
191                self.run,
192                "source_sequence starts at 1; zero means no activity yet",
193            ));
194        }
195        Ok(())
196    }
197}