#[cfg(feature = "schema")]
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
use crate::annotations::Annotations;
use crate::document::CadIr;
use crate::native::NativeConvertError;
use crate::report::DecodeReport;
use crate::unknown::UnknownRecord;
pub const SOURCE_FIDELITY_VERSION: &str = "3";
pub const DECODE_SIDECAR_VERSION: &str = "2";
pub const DECODE_SIDECAR_VERSION_V1: &str = "1";
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(JsonSchema))]
pub struct DecodeSidecar {
version: String,
pub ir_sha256: String,
pub report: DecodeReport,
pub fidelity: SourceFidelity,
}
impl DecodeSidecar {
pub fn bind(ir_bytes: &[u8], report: DecodeReport, fidelity: SourceFidelity) -> Self {
Self::bind_sha256(crate::hash::sha256_hex(ir_bytes), report, fidelity)
}
pub fn bind_sha256(
ir_sha256: impl Into<String>,
report: DecodeReport,
mut fidelity: SourceFidelity,
) -> Self {
fidelity.finalize();
Self {
version: DECODE_SIDECAR_VERSION.into(),
ir_sha256: ir_sha256.into(),
report,
fidelity,
}
}
pub fn version(&self) -> &str {
&self.version
}
pub fn matches(&self, ir_bytes: &[u8]) -> bool {
self.ir_sha256 == crate::hash::sha256_hex(ir_bytes)
}
pub fn to_canonical_json(&self) -> Result<String, serde_json::Error> {
let mut canonical = self.clone();
canonical.fidelity.finalize();
serde_json::to_string(&canonical)
}
pub fn from_json(text: &str) -> Result<Self, DecodeSidecarParseError> {
let mut value: serde_json::Value =
serde_json::from_str(text).map_err(DecodeSidecarParseError::Json)?;
let version = value.get("version").and_then(|v| v.as_str()).unwrap_or("");
match version {
DECODE_SIDECAR_VERSION_V1 => {
migrate_sidecar_v1_to_v2(&mut value)?;
}
DECODE_SIDECAR_VERSION => {}
found => {
return Err(DecodeSidecarParseError::Version {
found: found.to_owned(),
});
}
}
let sidecar: Self = serde_json::from_value(value).map_err(DecodeSidecarParseError::Json)?;
if sidecar.version != DECODE_SIDECAR_VERSION {
return Err(DecodeSidecarParseError::Version {
found: sidecar.version,
});
}
sidecar
.fidelity
.validate()
.map_err(DecodeSidecarParseError::Fidelity)?;
Ok(sidecar)
}
}
fn migrate_sidecar_v1_to_v2(value: &mut serde_json::Value) -> Result<(), DecodeSidecarParseError> {
value["version"] = serde_json::Value::String(DECODE_SIDECAR_VERSION.into());
let Some(losses) = value
.pointer_mut("/report/losses")
.and_then(|losses| losses.as_array_mut())
else {
return Ok(());
};
for loss in losses {
let Some(code) = loss.get("code") else {
continue;
};
if code.is_object() {
continue;
}
let Some(text) = code.as_str() else {
return Err(DecodeSidecarParseError::Migrate(format!(
"v1 loss code must be a string, found {code}"
)));
};
let kind = crate::LossKind::from_v1_str(text).ok_or_else(|| {
DecodeSidecarParseError::Migrate(format!("unsupported v1 loss code {text:?}"))
})?;
loss["code"] = serde_json::to_value(kind).map_err(DecodeSidecarParseError::Json)?;
}
Ok(())
}
pub fn decode_sidecar_path(path: &Path) -> PathBuf {
let file_name = path
.file_name()
.and_then(|name| name.to_str())
.unwrap_or("");
let stem = file_name.strip_suffix(".json").unwrap_or(file_name);
path.with_file_name(format!("{stem}.fidelity.json"))
}
#[derive(Debug, thiserror::Error)]
pub enum DecodeSidecarParseError {
#[error("invalid decode-sidecar JSON: {0}")]
Json(serde_json::Error),
#[error("unsupported decode-sidecar version: {found}")]
Version {
found: String,
},
#[error("decode-sidecar v1→v2 migration failed: {0}")]
Migrate(String),
#[error(transparent)]
Fidelity(FidelityError),
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(JsonSchema))]
pub struct RetainedSourceRecord {
pub id: String,
pub stream: String,
pub offset: u64,
pub byte_len: u64,
pub sha256: String,
#[serde(
default,
skip_serializing_if = "Option::is_none",
with = "crate::bytes::option"
)]
#[cfg_attr(feature = "schema", schemars(with = "Option<String>"))]
pub data: Option<Vec<u8>>,
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum FidelityError {
#[error("unsupported source-fidelity version: {found}")]
Version {
found: String,
},
#[error("duplicate retained source record: {id}")]
DuplicateRecord {
id: String,
},
#[error("retained source record {id} declares {declared} bytes but contains {actual}")]
Length {
id: String,
declared: u64,
actual: u64,
},
#[error("retained source record {id} does not match its SHA-256 digest")]
Digest {
id: String,
},
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(JsonSchema))]
pub struct SourceFidelity {
version: String,
#[serde(default)]
pub annotations: Annotations,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub retained_records: Vec<RetainedSourceRecord>,
}
impl Default for SourceFidelity {
fn default() -> Self {
Self {
version: SOURCE_FIDELITY_VERSION.into(),
annotations: Annotations::default(),
retained_records: Vec::new(),
}
}
}
impl SourceFidelity {
pub fn version(&self) -> &str {
&self.version
}
pub fn with_annotations(annotations: Annotations) -> Self {
Self {
version: SOURCE_FIDELITY_VERSION.into(),
annotations,
retained_records: Vec::new(),
}
}
pub fn finalize(&mut self) {
self.retained_records.sort_by(|left, right| {
(&left.stream, left.offset, &left.id).cmp(&(&right.stream, right.offset, &right.id))
});
}
pub fn retained_record(&self, id: &str) -> Option<&RetainedSourceRecord> {
self.retained_records.iter().find(|record| record.id == id)
}
pub fn retain_unknown_records(
&mut self,
stream: &str,
records: impl IntoIterator<Item = UnknownRecord>,
) {
self.retained_records
.extend(records.into_iter().map(|record| RetainedSourceRecord {
id: record.id.0,
stream: stream.into(),
offset: record.offset,
byte_len: record.byte_len,
sha256: record.sha256,
data: record.data,
}));
}
pub fn attach_native_unknown_records(
&mut self,
ir: &mut CadIr,
format: &str,
records: impl IntoIterator<Item = UnknownRecord>,
) -> Result<(), NativeConvertError> {
let Self {
annotations,
retained_records,
..
} = self;
ir.set_native_unknowns_from(
format,
records.into_iter().map(|record| {
let stream = annotations
.provenance
.get(&record.id.0)
.and_then(|provenance| annotations.streams.get(provenance.stream as usize))
.cloned()
.unwrap_or_else(|| "source".into());
let product = crate::NativeUnknownRecord {
id: record.id.clone(),
links: record.links,
};
retained_records.push(RetainedSourceRecord {
id: record.id.0,
stream,
offset: record.offset,
byte_len: record.byte_len,
sha256: record.sha256,
data: record.data,
});
product
}),
)
}
pub fn validate(&self) -> Result<(), FidelityError> {
if self.version != SOURCE_FIDELITY_VERSION {
return Err(FidelityError::Version {
found: self.version.clone(),
});
}
let mut ids = std::collections::BTreeSet::new();
for record in &self.retained_records {
if !ids.insert(&record.id) {
return Err(FidelityError::DuplicateRecord {
id: record.id.clone(),
});
}
if let Some(data) = &record.data {
let actual = data.len() as u64;
if actual != record.byte_len {
return Err(FidelityError::Length {
id: record.id.clone(),
declared: record.byte_len,
actual,
});
}
if crate::hash::sha256_hex(data) != record.sha256 {
return Err(FidelityError::Digest {
id: record.id.clone(),
});
}
}
}
Ok(())
}
pub fn from_json(text: &str) -> Result<Self, SourceFidelityParseError> {
let sidecar: Self = serde_json::from_str(text).map_err(SourceFidelityParseError::Json)?;
sidecar
.validate()
.map_err(SourceFidelityParseError::Fidelity)?;
Ok(sidecar)
}
}
#[derive(Debug, thiserror::Error)]
pub enum SourceFidelityParseError {
#[error("invalid source-fidelity JSON: {0}")]
Json(serde_json::Error),
#[error(transparent)]
Fidelity(FidelityError),
}
#[cfg(test)]
mod tests {
use super::*;
fn record(id: &str, data: &[u8]) -> RetainedSourceRecord {
RetainedSourceRecord {
id: id.into(),
stream: "source".into(),
offset: 0,
byte_len: data.len() as u64,
sha256: crate::hash::sha256_hex(data),
data: Some(data.to_vec()),
}
}
#[test]
fn finalize_orders_retained_records() {
let mut sidecar = SourceFidelity {
retained_records: vec![record("b", &[2]), record("a", &[1])],
..SourceFidelity::default()
};
sidecar.finalize();
assert_eq!(sidecar.retained_records[0].id, "a");
}
#[test]
fn validation_rejects_false_payload_metadata() {
let mut sidecar = SourceFidelity::default();
sidecar.retained_records.push(record("a", &[1, 2]));
sidecar.retained_records[0].sha256 = crate::hash::sha256_hex(&[2, 1]);
assert!(matches!(
sidecar.validate(),
Err(FidelityError::Digest { .. })
));
}
#[test]
fn decode_sidecar_binds_exact_ir_bytes_and_validates_versions() {
let report = DecodeReport {
format: "test".into(),
container_only: false,
geometry_transferred: true,
coverage: std::collections::BTreeMap::default(),
transfer_ledger: crate::report::TransferLedger::default(),
losses: Vec::new(),
notes: Vec::new(),
};
let sidecar = DecodeSidecar::bind(b"cad-ir", report, SourceFidelity::default());
assert!(sidecar.matches(b"cad-ir"));
assert!(!sidecar.matches(b"changed"));
let digest_sidecar = DecodeSidecar::bind_sha256(
crate::hash::sha256_hex(b"cad-ir"),
sidecar.report.clone(),
sidecar.fidelity.clone(),
);
assert_eq!(digest_sidecar, sidecar);
let json = sidecar.to_canonical_json().expect("serialize sidecar");
assert_eq!(DecodeSidecar::from_json(&json).unwrap(), sidecar);
assert!(json.contains("\"version\":\"2\""));
let wrong_version = json.replacen("\"version\":\"2\"", "\"version\":\"9\"", 1);
assert!(matches!(
DecodeSidecar::from_json(&wrong_version),
Err(DecodeSidecarParseError::Version { .. })
));
}
#[test]
fn decode_sidecar_migrates_v1_losses_to_namespaced_v2() {
let v1 = include_str!("../tests/fixtures/decode_sidecar_v1_with_losses.json");
let sidecar = DecodeSidecar::from_json(v1).expect("migrate v1 sidecar");
assert_eq!(sidecar.version(), DECODE_SIDECAR_VERSION);
assert_eq!(sidecar.report.losses.len(), 1);
let loss = &sidecar.report.losses[0];
assert_eq!(loss.code.namespace(), crate::SHARED_LOSS_NAMESPACE);
assert_eq!(loss.code.local_code(), "metadata_not_transferred");
assert_eq!(
loss.code.taxonomy(),
crate::LossTaxonomy::MetadataNotTransferred
);
assert_eq!(loss.message, "thumbnail");
}
#[test]
fn decode_sidecar_path_replaces_only_a_trailing_json_suffix() {
assert_eq!(
decode_sidecar_path(Path::new("part.cadir.json")),
PathBuf::from("part.cadir.fidelity.json")
);
assert_eq!(
decode_sidecar_path(Path::new("part.cadir")),
PathBuf::from("part.cadir.fidelity.json")
);
}
}