dpm_model/run/
validation.rs1use super::{ActivityInput, RunProvenance, RunSession, RunSource, RunStart, RunTransition};
4use crate::{ValidationError, validation::invalid};
5
6const MAX_IDENTIFIER_BYTES: usize = 256;
8
9pub const MAX_DETAIL_BYTES: usize = 2048;
11
12pub const MAX_SOURCES: usize = 8;
14
15pub 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 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 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#[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 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 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 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 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}