Skip to main content

sidereon_core/sp3/
provenance.rs

1//! Stable, secret-free identity for the exact artifacts and policy of an SP3 merge.
2
3use 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
13/// Version of the canonical merged-SP3 input identity encoding.
14pub const SP3_MERGE_INPUT_SCHEMA_VERSION: u8 = 1;
15
16/// Prefix carried by every public merged-SP3 input identity.
17pub const SP3_MERGE_INPUT_ID_PREFIX: &str = "sidereon-sp3-merge-input-v1:";
18
19/// Reproducible identity of one verified artifact supplied to an SP3 merge.
20///
21/// Retrieval time, cache status, URLs, HTTP metadata, failures, credentials,
22/// and local paths do not belong here. They are observational acquisition
23/// facts and deliberately do not affect the stable merge-input identity.
24#[derive(Debug, Clone, PartialEq, Eq)]
25pub struct Sp3ArtifactIdentity {
26    /// Exact identity requested from the selected distributor.
27    pub requested_identity: ProductIdentity,
28    /// Identity resolved by parsing and validating the acquired bytes.
29    pub resolved_identity: ProductIdentity,
30    /// Explicit distributor that supplied the artifact.
31    pub distribution_source: DistributionSource,
32    /// Official decompressed product filename.
33    pub official_filename: String,
34    /// SHA-256 of the validated, decompressed product bytes.
35    pub product_sha256: String,
36    /// Length of the validated, decompressed product bytes.
37    pub product_byte_length: u64,
38    /// SHA-256 of the exact distributor archive bytes.
39    pub archive_sha256: String,
40    /// Length of the exact distributor archive bytes.
41    pub archive_byte_length: u64,
42    /// Compression applied to the distributor archive.
43    pub compression: ArchiveCompression,
44}
45
46/// Canonical identity of a complete SP3 merge input set and merge policy.
47#[derive(Debug, Clone, PartialEq, Eq)]
48pub struct Sp3MergeInputIdentity {
49    /// Canonical encoding version.
50    pub schema_version: u8,
51    /// Contributors in canonical order, independent of caller enumeration.
52    pub contributors: Vec<Sp3ArtifactIdentity>,
53    /// Ordered contributors when precedence combination makes order semantic.
54    /// `None` for mean and median combination.
55    pub precedence_contributors: Option<Vec<Sp3ArtifactIdentity>>,
56    /// Versioned SHA-256 identity of the contributors and complete merge policy.
57    pub stable_id: String,
58}
59
60/// Failure to build a complete merged-SP3 input identity.
61#[derive(Debug, thiserror::Error, PartialEq, Eq)]
62pub enum Sp3MergeInputIdentityError {
63    /// At least one verified contributor is required.
64    #[error("merged-SP3 input identity requires at least one contributor")]
65    EmptyContributors,
66    /// A contributor record is incomplete or internally inconsistent.
67    #[error("invalid merged-SP3 contributor {index}: {reason}")]
68    InvalidContributor {
69        /// Index in the caller-provided contributor list.
70        index: usize,
71        /// Stable failure description.
72        reason: &'static str,
73    },
74    /// One resolved product identity appeared more than once.
75    #[error("duplicate resolved product identity at contributor {index}")]
76    DuplicateContributor {
77        /// Index of the duplicate in the caller-provided list.
78        index: usize,
79    },
80    /// Merge controls cannot be represented as a valid executable policy.
81    #[error("invalid merged-SP3 policy: {0}")]
82    InvalidPolicy(&'static str),
83}
84
85impl Sp3MergeInputIdentity {
86    /// Validate, canonically order, and bind all contributors and merge controls.
87    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    /// Recompute and verify this persisted identity and its merge controls.
152    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    /// Recompute and compare against separately persisted contributor records
161    /// and merge controls.
162    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}