1use std::collections::HashSet;
4use std::fmt::Write as _;
5
6use sha2::{Digest, Sha256};
7
8use crate::data::{ArchiveCompression, DistributionSource, ProductIdentity, ProductType};
9use crate::tolerances::WHOLE_SECOND_EPS_S;
10
11use super::{MergeCombine, MergeOptions, MergePrecedenceScope};
12
13pub const SP3_MERGE_INPUT_SCHEMA_VERSION: u8 = 1;
15
16pub const SP3_MERGE_INPUT_ID_PREFIX: &str = "sidereon-sp3-merge-input-v1:";
18
19#[derive(Debug, Clone, PartialEq, Eq)]
25pub struct Sp3ArtifactIdentity {
26 pub requested_identity: ProductIdentity,
28 pub resolved_identity: ProductIdentity,
30 pub distribution_source: DistributionSource,
32 pub official_filename: String,
34 pub product_sha256: String,
36 pub product_byte_length: u64,
38 pub archive_sha256: String,
40 pub archive_byte_length: u64,
42 pub compression: ArchiveCompression,
44}
45
46#[derive(Debug, Clone, PartialEq, Eq)]
48pub struct Sp3MergeInputIdentity {
49 pub schema_version: u8,
51 pub contributors: Vec<Sp3ArtifactIdentity>,
53 pub precedence_contributors: Option<Vec<Sp3ArtifactIdentity>>,
56 pub stable_id: String,
58}
59
60#[derive(Debug, thiserror::Error, PartialEq, Eq)]
62pub enum Sp3MergeInputIdentityError {
63 #[error("merged-SP3 input identity requires at least one contributor")]
65 EmptyContributors,
66 #[error("invalid merged-SP3 contributor {index}: {reason}")]
68 InvalidContributor {
69 index: usize,
71 reason: &'static str,
73 },
74 #[error("duplicate resolved product identity at contributor {index}")]
76 DuplicateContributor {
77 index: usize,
79 },
80 #[error("invalid merged-SP3 policy: {0}")]
82 InvalidPolicy(&'static str),
83}
84
85impl Sp3MergeInputIdentity {
86 pub fn new(
88 contributors: &[Sp3ArtifactIdentity],
89 policy: &MergeOptions,
90 ) -> Result<Self, Sp3MergeInputIdentityError> {
91 if contributors.is_empty() {
92 return Err(Sp3MergeInputIdentityError::EmptyContributors);
93 }
94
95 validate_policy(policy)?;
96 let mut seen = HashSet::new();
97 let mut canonical = Vec::with_capacity(contributors.len());
98 for (index, contributor) in contributors.iter().enumerate() {
99 let bytes = canonical_contributor_bytes(contributor, index)?;
100 let resolved = contributor
101 .resolved_identity
102 .canonical_bytes()
103 .map_err(|_| invalid_contributor(index, "resolved identity"))?;
104 if !seen.insert(resolved) {
105 return Err(Sp3MergeInputIdentityError::DuplicateContributor { index });
106 }
107 canonical.push((bytes, contributor.clone()));
108 }
109 let (precedence, precedence_contributors) = if policy.combine == MergeCombine::Precedence {
110 (
111 canonical
112 .iter()
113 .map(|(encoded, _)| encoded.clone())
114 .collect(),
115 Some(
116 canonical
117 .iter()
118 .map(|(_, contributor)| contributor.clone())
119 .collect(),
120 ),
121 )
122 } else {
123 (Vec::new(), None)
124 };
125 canonical.sort_by(|left, right| left.0.cmp(&right.0));
126
127 let mut bytes = Vec::new();
128 put_field(&mut bytes, b"sidereon.sp3.merge-input");
129 bytes.push(SP3_MERGE_INPUT_SCHEMA_VERSION);
130 put_u64(&mut bytes, canonical.len() as u64);
131 for (encoded, _) in &canonical {
132 put_field(&mut bytes, encoded);
133 }
134 put_field(&mut bytes, &canonical_policy_bytes(policy, &precedence));
135
136 let digest = Sha256::digest(bytes);
137 let mut encoded = String::with_capacity(SP3_MERGE_INPUT_ID_PREFIX.len() + 64);
138 encoded.push_str(SP3_MERGE_INPUT_ID_PREFIX);
139 for byte in digest {
140 write!(&mut encoded, "{byte:02x}").expect("writing to String cannot fail");
141 }
142
143 Ok(Self {
144 schema_version: SP3_MERGE_INPUT_SCHEMA_VERSION,
145 contributors: canonical.into_iter().map(|(_, value)| value).collect(),
146 precedence_contributors,
147 stable_id: encoded,
148 })
149 }
150
151 pub fn verify(&self, policy: &MergeOptions) -> Result<bool, Sp3MergeInputIdentityError> {
153 let contributors = self
154 .precedence_contributors
155 .as_deref()
156 .unwrap_or(&self.contributors);
157 self.verify_against(contributors, policy)
158 }
159
160 pub fn verify_against(
163 &self,
164 contributors: &[Sp3ArtifactIdentity],
165 policy: &MergeOptions,
166 ) -> Result<bool, Sp3MergeInputIdentityError> {
167 let rebuilt = Self::new(contributors, policy)?;
168 Ok(self.schema_version == rebuilt.schema_version
169 && self.contributors == rebuilt.contributors
170 && self.precedence_contributors == rebuilt.precedence_contributors
171 && self.stable_id == rebuilt.stable_id)
172 }
173}
174
175fn canonical_contributor_bytes(
176 contributor: &Sp3ArtifactIdentity,
177 index: usize,
178) -> Result<Vec<u8>, Sp3MergeInputIdentityError> {
179 contributor
180 .requested_identity
181 .validate()
182 .map_err(|_| invalid_contributor(index, "requested identity"))?;
183 contributor
184 .resolved_identity
185 .validate()
186 .map_err(|_| invalid_contributor(index, "resolved identity"))?;
187 if contributor.requested_identity.family != ProductType::Sp3
188 || contributor.resolved_identity.family != ProductType::Sp3
189 {
190 return Err(invalid_contributor(index, "product family is not SP3"));
191 }
192 if contributor
193 .resolved_identity
194 .format_version
195 .as_deref()
196 .is_none_or(|version| version.trim().is_empty())
197 {
198 return Err(invalid_contributor(index, "resolved format version"));
199 }
200 if contributor.requested_identity.format_version.is_some()
201 && contributor.requested_identity.format_version
202 != contributor.resolved_identity.format_version
203 {
204 return Err(invalid_contributor(
205 index,
206 "resolved format version does not match requested version",
207 ));
208 }
209
210 let mut requested_without_revision = contributor.requested_identity.clone();
211 requested_without_revision.format_version = None;
212 let mut resolved_without_revision = contributor.resolved_identity.clone();
213 resolved_without_revision.format_version = None;
214 if requested_without_revision != resolved_without_revision {
215 return Err(invalid_contributor(
216 index,
217 "resolved identity does not match requested identity",
218 ));
219 }
220 if contributor.official_filename != contributor.requested_identity.official_filename
221 || contributor.official_filename != contributor.resolved_identity.official_filename
222 {
223 return Err(invalid_contributor(index, "official filename"));
224 }
225 if !valid_sha256(&contributor.product_sha256) {
226 return Err(invalid_contributor(index, "product SHA-256"));
227 }
228 if !valid_sha256(&contributor.archive_sha256) {
229 return Err(invalid_contributor(index, "archive SHA-256"));
230 }
231 if contributor.product_byte_length == 0 {
232 return Err(invalid_contributor(index, "product byte length"));
233 }
234 if contributor.archive_byte_length == 0 {
235 return Err(invalid_contributor(index, "archive byte length"));
236 }
237
238 let mut bytes = Vec::new();
239 put_field(
240 &mut bytes,
241 &contributor
242 .requested_identity
243 .canonical_bytes()
244 .map_err(|_| invalid_contributor(index, "requested identity"))?,
245 );
246 put_field(
247 &mut bytes,
248 &contributor
249 .resolved_identity
250 .canonical_bytes()
251 .map_err(|_| invalid_contributor(index, "resolved identity"))?,
252 );
253 put_field(
254 &mut bytes,
255 contributor.distribution_source.code().as_bytes(),
256 );
257 put_field(&mut bytes, contributor.official_filename.as_bytes());
258 put_field(&mut bytes, contributor.product_sha256.as_bytes());
259 put_u64(&mut bytes, contributor.product_byte_length);
260 put_field(&mut bytes, contributor.archive_sha256.as_bytes());
261 put_u64(&mut bytes, contributor.archive_byte_length);
262 put_field(&mut bytes, contributor.compression.as_str().as_bytes());
263 Ok(bytes)
264}
265
266fn validate_policy(policy: &MergeOptions) -> Result<(), Sp3MergeInputIdentityError> {
267 if !finite_nonnegative(policy.position_tolerance_m) {
268 return Err(Sp3MergeInputIdentityError::InvalidPolicy(
269 "position tolerance",
270 ));
271 }
272 if !finite_nonnegative(policy.clock_tolerance_s) {
273 return Err(Sp3MergeInputIdentityError::InvalidPolicy("clock tolerance"));
274 }
275 if policy.min_agree == 0 {
276 return Err(Sp3MergeInputIdentityError::InvalidPolicy(
277 "minimum agreement",
278 ));
279 }
280 if policy.clock_min_common == 0 {
281 return Err(Sp3MergeInputIdentityError::InvalidPolicy(
282 "minimum common clocks",
283 ));
284 }
285 if let Some(value) = policy.outlier_reject {
286 if !finite_nonnegative(value.position_tolerance_m)
287 || !finite_nonnegative(value.clock_tolerance_s)
288 {
289 return Err(Sp3MergeInputIdentityError::InvalidPolicy(
290 "outlier rejection",
291 ));
292 }
293 }
294 if let Some(value) = policy.target_epoch_interval_s {
295 if !value.is_finite()
296 || (value - value.round()).abs() > WHOLE_SECOND_EPS_S
297 || value.round() < 1.0
298 {
299 return Err(Sp3MergeInputIdentityError::InvalidPolicy(
300 "target epoch interval",
301 ));
302 }
303 }
304 if policy
305 .systems
306 .as_ref()
307 .is_some_and(|systems| systems.is_empty())
308 {
309 return Err(Sp3MergeInputIdentityError::InvalidPolicy("systems filter"));
310 }
311 for labels in &policy.frame_reconciliation.asserted_equivalent_label_sets {
312 if labels.labels.len() < 2 || labels.labels.iter().any(|label| label.trim().is_empty()) {
313 return Err(Sp3MergeInputIdentityError::InvalidPolicy(
314 "asserted frame label set",
315 ));
316 }
317 }
318 Ok(())
319}
320
321fn canonical_policy_bytes(policy: &MergeOptions, precedence: &[Vec<u8>]) -> Vec<u8> {
322 let mut bytes = Vec::new();
323 put_u64(&mut bytes, policy.position_tolerance_m.to_bits());
324 put_u64(&mut bytes, policy.clock_tolerance_s.to_bits());
325 put_u64(&mut bytes, policy.min_agree as u64);
326 put_u64(&mut bytes, policy.clock_min_common as u64);
327 bytes.push(match policy.combine {
328 MergeCombine::Mean => 0,
329 MergeCombine::Median => 1,
330 MergeCombine::Precedence => 2,
331 });
332 bytes.push(match policy.precedence_scope {
333 MergePrecedenceScope::Cell => 0,
334 MergePrecedenceScope::SatelliteArc => 1,
335 });
336 match policy.outlier_reject {
337 Some(value) => {
338 bytes.push(1);
339 put_u64(&mut bytes, value.position_tolerance_m.to_bits());
340 put_u64(&mut bytes, value.clock_tolerance_s.to_bits());
341 }
342 None => bytes.push(0),
343 }
344 match policy.target_epoch_interval_s {
345 Some(value) => {
346 bytes.push(1);
347 put_u64(&mut bytes, value.to_bits());
348 }
349 None => bytes.push(0),
350 }
351
352 match &policy.systems {
353 Some(systems) => {
354 bytes.push(1);
355 put_u64(&mut bytes, systems.len() as u64);
356 for system in systems {
357 bytes.push(system.letter() as u8);
358 }
359 }
360 None => bytes.push(0),
361 }
362
363 let mut label_sets: Vec<Vec<u8>> = policy
364 .frame_reconciliation
365 .asserted_equivalent_label_sets
366 .iter()
367 .map(|set| {
368 let mut encoded = Vec::new();
369 put_u64(&mut encoded, set.labels.len() as u64);
370 for label in &set.labels {
371 put_field(&mut encoded, label.as_bytes());
372 }
373 encoded
374 })
375 .collect();
376 label_sets.sort();
377 put_u64(&mut bytes, label_sets.len() as u64);
378 for labels in label_sets {
379 put_field(&mut bytes, &labels);
380 }
381 bytes.push(u8::from(policy.frame_reconciliation.helmert));
382 put_u64(&mut bytes, precedence.len() as u64);
383 for contributor in precedence {
384 put_field(&mut bytes, contributor);
385 }
386 bytes
387}
388
389fn finite_nonnegative(value: f64) -> bool {
390 value.is_finite() && value >= 0.0
391}
392
393fn valid_sha256(value: &str) -> bool {
394 value.len() == 64
395 && value
396 .bytes()
397 .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
398}
399
400fn invalid_contributor(index: usize, reason: &'static str) -> Sp3MergeInputIdentityError {
401 Sp3MergeInputIdentityError::InvalidContributor { index, reason }
402}
403
404fn put_field(output: &mut Vec<u8>, value: &[u8]) {
405 put_u64(output, value.len() as u64);
406 output.extend_from_slice(value);
407}
408
409fn put_u64(output: &mut Vec<u8>, value: u64) {
410 output.extend_from_slice(&value.to_be_bytes());
411}