Skip to main content

cadmpeg_ir/
source_fidelity.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Source annotations and retained native records produced during decode.
3
4use schemars::JsonSchema;
5use serde::{Deserialize, Serialize};
6
7use crate::annotations::Annotations;
8use crate::document::CadIr;
9use crate::native::NativeConvertError;
10use crate::unknown::UnknownRecord;
11
12/// Current serialized sidecar version.
13pub const SOURCE_FIDELITY_VERSION: &str = "3";
14
15/// Source bytes retained for native recovery or replay.
16#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
17pub struct RetainedSourceRecord {
18    /// Stable record identifier.
19    pub id: String,
20    /// Source stream containing the record.
21    pub stream: String,
22    /// First byte offset in the source stream.
23    pub offset: u64,
24    /// Number of source bytes.
25    pub byte_len: u64,
26    /// Lowercase hexadecimal SHA-256 of the source bytes.
27    pub sha256: String,
28    /// Retained bytes, when available.
29    #[serde(
30        default,
31        skip_serializing_if = "Option::is_none",
32        with = "crate::bytes::option"
33    )]
34    #[schemars(with = "Option<String>")]
35    pub data: Option<Vec<u8>>,
36}
37
38/// Validation failure in source metadata.
39#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
40pub enum FidelityError {
41    /// The sidecar declares an unsupported schema version.
42    #[error("unsupported source-fidelity version: {found}")]
43    Version {
44        /// The version found in the sidecar.
45        found: String,
46    },
47    /// Two retained records share an identifier.
48    #[error("duplicate retained source record: {id}")]
49    DuplicateRecord {
50        /// The repeated identifier.
51        id: String,
52    },
53    /// Retained data has the wrong length.
54    #[error("retained source record {id} declares {declared} bytes but contains {actual}")]
55    Length {
56        /// The record identifier.
57        id: String,
58        /// Declared byte length.
59        declared: u64,
60        /// Actual retained byte length.
61        actual: u64,
62    },
63    /// Retained data has the wrong digest.
64    #[error("retained source record {id} does not match its SHA-256 digest")]
65    Digest {
66        /// The record identifier.
67        id: String,
68    },
69}
70
71/// Decode-time source annotations and retained native records.
72#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
73pub struct SourceFidelity {
74    /// Serialized representation version.
75    pub version: String,
76    /// Sparse source locations and conversion exactness.
77    #[serde(default)]
78    pub annotations: Annotations,
79    /// Native records retained for recovery or replay.
80    #[serde(default, skip_serializing_if = "Vec::is_empty")]
81    pub retained_records: Vec<RetainedSourceRecord>,
82}
83
84impl Default for SourceFidelity {
85    fn default() -> Self {
86        Self {
87            version: SOURCE_FIDELITY_VERSION.into(),
88            annotations: Annotations::default(),
89            retained_records: Vec::new(),
90        }
91    }
92}
93
94impl SourceFidelity {
95    /// Sorts retained records into canonical source order.
96    pub fn finalize(&mut self) {
97        self.retained_records.sort_by(|left, right| {
98            (&left.stream, left.offset, &left.id).cmp(&(&right.stream, right.offset, &right.id))
99        });
100    }
101
102    /// Finds a retained source record by identifier.
103    pub fn retained_record(&self, id: &str) -> Option<&RetainedSourceRecord> {
104        self.retained_records.iter().find(|record| record.id == id)
105    }
106
107    /// Retains source records without adding them to the product model.
108    pub fn retain_unknown_records(&mut self, stream: &str, records: &[UnknownRecord]) {
109        self.retained_records
110            .extend(records.iter().map(|record| RetainedSourceRecord {
111                id: record.id.to_string(),
112                stream: stream.into(),
113                offset: record.offset,
114                byte_len: record.byte_len,
115                sha256: record.sha256.clone(),
116                data: record.data.clone(),
117            }));
118    }
119
120    /// Stores source bytes in the sidecar and references in the product model.
121    pub fn attach_native_unknown_records(
122        &mut self,
123        ir: &mut CadIr,
124        format: &str,
125        records: &[UnknownRecord],
126    ) -> Result<(), NativeConvertError> {
127        self.retained_records.extend(records.iter().map(|record| {
128            let stream = self
129                .annotations
130                .provenance
131                .get(&record.id.0)
132                .and_then(|provenance| self.annotations.streams.get(provenance.stream as usize))
133                .cloned()
134                .unwrap_or_else(|| "source".into());
135            RetainedSourceRecord {
136                id: record.id.to_string(),
137                stream,
138                offset: record.offset,
139                byte_len: record.byte_len,
140                sha256: record.sha256.clone(),
141                data: record.data.clone(),
142            }
143        }));
144        let product_records = records
145            .iter()
146            .map(crate::NativeUnknownRecord::from)
147            .collect::<Vec<_>>();
148        ir.set_native_unknowns(format, &product_records)
149    }
150
151    /// Joins product references with retained source records.
152    pub fn native_unknown_records(
153        &self,
154        ir: &CadIr,
155        format: &str,
156    ) -> Result<Vec<UnknownRecord>, NativeConvertError> {
157        ir.native_unknowns(format)?
158            .into_iter()
159            .map(|reference| {
160                let retained = self.retained_record(&reference.id.0).ok_or_else(|| {
161                    NativeConvertError::MissingRetainedSourceRecord(reference.id.0.clone())
162                })?;
163                Ok(UnknownRecord {
164                    id: reference.id,
165                    offset: retained.offset,
166                    byte_len: retained.byte_len,
167                    sha256: retained.sha256.clone(),
168                    data: retained.data.clone(),
169                    links: reference.links,
170                })
171            })
172            .collect()
173    }
174
175    /// Validates retained record identity and payload integrity.
176    pub fn validate(&self) -> Result<(), FidelityError> {
177        if self.version != SOURCE_FIDELITY_VERSION {
178            return Err(FidelityError::Version {
179                found: self.version.clone(),
180            });
181        }
182        let mut ids = std::collections::BTreeSet::new();
183        for record in &self.retained_records {
184            if !ids.insert(&record.id) {
185                return Err(FidelityError::DuplicateRecord {
186                    id: record.id.clone(),
187                });
188            }
189            if let Some(data) = &record.data {
190                let actual = data.len() as u64;
191                if actual != record.byte_len {
192                    return Err(FidelityError::Length {
193                        id: record.id.clone(),
194                        declared: record.byte_len,
195                        actual,
196                    });
197                }
198                if crate::hash::sha256_hex(data) != record.sha256 {
199                    return Err(FidelityError::Digest {
200                        id: record.id.clone(),
201                    });
202                }
203            }
204        }
205        Ok(())
206    }
207
208    /// Serializes the canonical sidecar as compact JSON.
209    pub fn to_canonical_json(&self) -> Result<String, serde_json::Error> {
210        let mut canonical = self.clone();
211        canonical.finalize();
212        serde_json::to_string(&canonical)
213    }
214
215    /// Parses and validates a sidecar.
216    pub fn from_json(text: &str) -> Result<Self, SourceFidelityParseError> {
217        let sidecar: Self = serde_json::from_str(text).map_err(SourceFidelityParseError::Json)?;
218        sidecar
219            .validate()
220            .map_err(SourceFidelityParseError::Fidelity)?;
221        Ok(sidecar)
222    }
223}
224
225/// Failure parsing source metadata.
226#[derive(Debug, thiserror::Error)]
227pub enum SourceFidelityParseError {
228    /// Invalid JSON.
229    #[error("invalid source-fidelity JSON: {0}")]
230    Json(serde_json::Error),
231    /// Invalid source metadata.
232    #[error(transparent)]
233    Fidelity(FidelityError),
234}
235
236#[cfg(test)]
237mod tests {
238    use super::*;
239
240    fn record(id: &str, data: &[u8]) -> RetainedSourceRecord {
241        RetainedSourceRecord {
242            id: id.into(),
243            stream: "source".into(),
244            offset: 0,
245            byte_len: data.len() as u64,
246            sha256: crate::hash::sha256_hex(data),
247            data: Some(data.to_vec()),
248        }
249    }
250
251    #[test]
252    fn canonical_json_orders_retained_records() {
253        let sidecar = SourceFidelity {
254            retained_records: vec![record("b", &[2]), record("a", &[1])],
255            ..SourceFidelity::default()
256        };
257        let json = sidecar.to_canonical_json().expect("serialize sidecar");
258        let parsed = SourceFidelity::from_json(&json).expect("parse sidecar");
259        assert_eq!(parsed.retained_records[0].id, "a");
260    }
261
262    #[test]
263    fn validation_rejects_false_payload_metadata() {
264        let mut sidecar = SourceFidelity::default();
265        sidecar.retained_records.push(record("a", &[1, 2]));
266        sidecar.retained_records[0].sha256 = crate::hash::sha256_hex(&[2, 1]);
267        assert!(matches!(
268            sidecar.validate(),
269            Err(FidelityError::Digest { .. })
270        ));
271    }
272}