use std::collections::BTreeMap;
use std::fmt::Write as _;
use std::io::Write as _;
use serde::Serialize;
use sha2::{Digest, Sha256};
use crate::document::{CadIr, SortedModel, SourceMeta};
use crate::native::{Native, NativeNamespace, NativeRecord};
use crate::units::{Tolerances, Units};
pub fn sha256_hex(bytes: &[u8]) -> String {
encode_hex(&Sha256::digest(bytes))
}
pub fn canonical_json_sha256<T: Serialize>(value: &T) -> String {
let mut hasher = Sha256::new();
let mut writer = std::io::BufWriter::new(DigestWriter(&mut hasher));
serde_json::to_writer_pretty(&mut writer, value).expect("canonical JSON serialization");
writer.flush().expect("a digest accepts every byte");
drop(writer);
encode_hex(&hasher.finalize())
}
pub const DOCUMENT_LOCAL_DIGEST_ATTRIBUTE: &str = "document_local_sha256";
pub fn document_local_sha256(ir: &CadIr, format: &str, source_image_id: &str) -> String {
let unknowns = reduced_unknowns(ir, format, source_image_id);
canonical_json_sha256(&NormalizedDocument {
ir_version: ir.ir_version(),
source: ir.source.as_ref().map(|source| {
let mut source = source.clone();
source.attributes.remove("document_local_sha256");
source
}),
units: &ir.units,
tolerances: &ir.tolerances,
model: ir.model.sorted(),
native: normalized_native(&ir.native, format, &unknowns),
})
}
fn reduced_unknowns(ir: &CadIr, format: &str, source_image_id: &str) -> Vec<NativeRecord> {
let mut unreadable = false;
let mut projected = NativeNamespace::default();
projected
.set_arena_from(
"unknowns",
ir.native_unknowns_iter(format)
.map_while(|record| record.inspect_err(|_| unreadable = true).ok())
.filter(|record| record.id.0 != source_image_id),
)
.expect("unknown records serialize");
if unreadable {
return Vec::new();
}
projected
.arenas
.remove("unknowns")
.expect("the unknown arena was just set")
}
#[derive(Serialize)]
struct NormalizedDocument<'a> {
ir_version: &'a str,
#[serde(skip_serializing_if = "Option::is_none")]
source: Option<SourceMeta>,
units: &'a Units,
tolerances: &'a Tolerances,
model: SortedModel<'a>,
native: NormalizedNative<'a>,
}
#[derive(Serialize)]
#[serde(transparent)]
struct NormalizedNative<'a> {
namespaces: BTreeMap<&'a str, NormalizedNamespace<'a>>,
}
#[derive(Serialize)]
struct NormalizedNamespace<'a> {
version: u32,
arenas: BTreeMap<&'a str, Vec<&'a NativeRecord>>,
}
fn normalized_native<'a>(
native: &'a Native,
format: &'a str,
unknowns: &'a [NativeRecord],
) -> NormalizedNative<'a> {
let mut namespaces = native
.0
.iter()
.map(|(name, namespace)| {
let arenas = namespace
.arenas
.iter()
.map(|(arena, records)| (arena.as_str(), sorted_records(records)))
.collect();
(
name.as_str(),
NormalizedNamespace {
version: namespace.version,
arenas,
},
)
})
.collect::<BTreeMap<_, _>>();
let namespace = namespaces
.entry(format)
.or_insert_with(|| NormalizedNamespace {
version: 0,
arenas: BTreeMap::new(),
});
if namespace.version == 0 {
namespace.version = 1;
}
namespace
.arenas
.insert("unknowns", unknowns.iter().collect());
NormalizedNative { namespaces }
}
fn sorted_records(records: &[NativeRecord]) -> Vec<&NativeRecord> {
let mut refs = records.iter().collect::<Vec<_>>();
refs.sort_by(|left, right| left.id().cmp(right.id()));
refs
}
struct DigestWriter<'a>(&'a mut Sha256);
impl std::io::Write for DigestWriter<'_> {
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
self.0.update(buf);
Ok(buf.len())
}
fn flush(&mut self) -> std::io::Result<()> {
Ok(())
}
}
fn encode_hex(digest: &[u8]) -> String {
let mut encoded = String::with_capacity(digest.len() * 2);
for byte in digest {
write!(encoded, "{byte:02x}").expect("writing to a String cannot fail");
}
encoded
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used)]
use super::{canonical_json_sha256, document_local_sha256, sha256_hex};
use crate::document::CadIr;
use crate::examples::unit_cube;
use crate::ids::UnknownId;
use crate::native::{Native, NativeRecord};
use crate::units::Units;
use crate::unknown::UnknownRecord;
#[test]
fn encodes_sha256_as_lowercase_hexadecimal() {
assert_eq!(
sha256_hex(b"abc"),
"ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
);
}
fn pinned_record() -> NativeRecord {
let serde_json::Value::Object(fields) = serde_json::json!({
"zeta": null,
"alpha": [-1, 0, 1.5, 2.0, 1e10, 1e-7],
"beta": {
"nested": {"deep": [true, false]},
"empty_array": [],
"empty_object": {}
},
"escaped": "quote\" backslash\\ slash/ newline\n tab\t bell\u{7} accent é",
"gamma": 9_007_199_254_740_993_u64,
"delta": -9_007_199_254_740_993_i64
}) else {
panic!("the pinned record literal is a JSON object");
};
NativeRecord::new("pin:record#0", fields)
}
fn pinned_native() -> Native {
let mut native = Native::default();
let namespace = native.namespace_mut("pin");
namespace.version = 3;
namespace
.arenas
.insert("records".into(), vec![pinned_record()]);
native
}
#[test]
fn pins_pretty_printed_native_record_bytes() {
let expected = r#"{
"id": "pin:record#0",
"alpha": [
-1,
0,
1.5,
2.0,
10000000000.0,
1e-7
],
"beta": {
"empty_array": [],
"empty_object": {},
"nested": {
"deep": [
true,
false
]
}
},
"delta": -9007199254740993,
"escaped": "quote\" backslash\\ slash/ newline\n tab\t bell\u0007 accent é",
"gamma": 9007199254740993,
"zeta": null
}"#;
assert_eq!(
serde_json::to_string_pretty(&pinned_record()).unwrap(),
expected
);
}
#[test]
fn pins_pretty_printed_native_arena_bytes() {
let expected = r#"{
"pin": {
"version": 3,
"arenas": {
"records": [
{
"id": "pin:record#0",
"alpha": [
-1,
0,
1.5,
2.0,
10000000000.0,
1e-7
],
"beta": {
"empty_array": [],
"empty_object": {},
"nested": {
"deep": [
true,
false
]
}
},
"delta": -9007199254740993,
"escaped": "quote\" backslash\\ slash/ newline\n tab\t bell\u0007 accent é",
"gamma": 9007199254740993,
"zeta": null
}
]
}
}
}"#;
assert_eq!(
serde_json::to_string_pretty(&pinned_native()).unwrap(),
expected
);
}
#[test]
fn pins_native_arena_digest() {
assert_eq!(
canonical_json_sha256(&pinned_native()),
"acc1d88751dcb143ca47618c3f7a8ce14865edff0a26ab95748d2a0314ee8df0"
);
}
fn pinned_unknown(id: &str, links: &[&str]) -> NativeRecord {
let serde_json::Value::Object(fields) = serde_json::json!({
"links": links,
"offset": 4096,
"byte_len": 12,
"sha256": "0000000000000000000000000000000000000000000000000000000000000000",
"data": "AQID"
}) else {
panic!("the pinned unknown literal is a JSON object");
};
NativeRecord::new(id, fields)
}
fn pinned_document() -> CadIr {
let mut ir = CadIr::empty(Units::default());
ir.native = pinned_native();
let namespace = ir.native.namespace_mut("pin");
namespace.arenas.insert(
"unknowns".into(),
vec![
pinned_unknown("pin:source-image#0", &[]),
pinned_unknown("pin:unknown#0", &["pin:record#0", "pin:unknown#1"]),
],
);
ir.finalize();
ir
}
#[test]
fn pins_document_digests() {
let ir = pinned_document();
assert_eq!(
canonical_json_sha256(&ir),
"00ac254ce62cf446f1d1dcea56ded050bd9a1ef2a53c846a8e7c588cd99bb071"
);
assert_eq!(
document_local_sha256(&ir, "pin", "pin:source-image#0"),
"83fa753fb39360b9e51859c9c07ddac6ff23ec17b179fa548cf33c4331170180"
);
}
fn pinned_document_with_source() -> CadIr {
let mut ir = pinned_document();
ir.source = Some(crate::document::SourceMeta {
format: "pin".into(),
attributes: [
("document_local_sha256".to_owned(), "stale".to_owned()),
("file_size".to_owned(), "4096".to_owned()),
]
.into_iter()
.collect(),
});
ir
}
#[test]
fn pins_document_digest_over_source_metadata() {
let ir = pinned_document_with_source();
assert_eq!(
document_local_sha256(&ir, "pin", "pin:source-image#0"),
"3750864814cc4d83c355df4e8c6942c3b7c682dc15c193b5c836540ad8c07d64"
);
}
#[test]
fn round_tripped_document_hashes_identically() {
let ir = pinned_document();
let json = ir.to_canonical_json().unwrap();
let mut reparsed = CadIr::from_json(&json).unwrap();
reparsed.finalize();
assert_eq!(canonical_json_sha256(&ir), canonical_json_sha256(&reparsed));
assert_eq!(ir.to_canonical_json().unwrap(), json);
}
fn cloned_local_digest(ir: &CadIr, format: &str, source_image_id: &str) -> String {
let mut normalized = ir.clone();
normalized.finalize();
normalized.source = ir.source.as_ref().map(|source| {
let mut source = source.clone();
source.attributes.remove("document_local_sha256");
source
});
let unknowns = ir
.native_unknowns(format)
.unwrap_or_default()
.into_iter()
.filter(|record| record.id.0 != source_image_id)
.collect::<Vec<_>>();
normalized.set_native_unknowns(format, &unknowns).unwrap();
crate::hash::sha256_hex(normalized.to_canonical_json().unwrap().as_bytes())
}
fn local_digest_fixture() -> CadIr {
let mut ir = unit_cube();
ir.model.faces.reverse();
ir.model.surfaces.reverse();
ir.source = Some(crate::SourceMeta {
format: "synthetic".into(),
attributes: [
("document_local_sha256".to_owned(), "stale".to_owned()),
("active_brep".to_owned(), "body#0".to_owned()),
]
.into_iter()
.collect(),
});
ir.set_native_unknowns_owned(
"synthetic",
vec![
UnknownRecord {
id: UnknownId("synthetic:file:source-image#0".into()),
offset: 0,
byte_len: 3,
sha256: "00".into(),
data: Some(vec![1, 2, 3]),
links: Vec::new(),
},
UnknownRecord {
id: UnknownId("synthetic:record#1".into()),
offset: 8,
byte_len: 2,
sha256: "11".into(),
data: Some(vec![4, 5]),
links: vec!["cube:body#0".into()],
},
],
);
let namespace = ir.native.namespace_mut("other");
namespace.version = 3;
namespace.arenas.insert(
"records".into(),
vec![NativeRecord::new("other:record#0", serde_json::Map::new())],
);
ir
}
#[test]
fn document_local_sha256_matches_the_cloned_normalization() {
let ir = local_digest_fixture();
let source_image = "synthetic:file:source-image#0";
assert_eq!(
crate::hash::document_local_sha256(&ir, "synthetic", source_image),
cloned_local_digest(&ir, "synthetic", source_image)
);
assert_eq!(
crate::hash::document_local_sha256(&ir, "absent", source_image),
cloned_local_digest(&ir, "absent", source_image)
);
}
#[test]
fn document_local_sha256_ignores_the_recorded_digest_and_retained_bytes() {
let source_image = "synthetic:file:source-image#0";
let ir = local_digest_fixture();
let hash = crate::hash::document_local_sha256(&ir, "synthetic", source_image);
let mut recorded = local_digest_fixture();
recorded
.source
.as_mut()
.unwrap()
.attributes
.insert("document_local_sha256".into(), hash.clone());
assert_eq!(
crate::hash::document_local_sha256(&recorded, "synthetic", source_image),
hash
);
let mut repacked = local_digest_fixture();
let mut records = repacked
.native
.namespace("synthetic")
.unwrap()
.arenas
.get("unknowns")
.unwrap()
.clone();
records.retain(|record| record.id() != source_image);
records.push(
UnknownRecord {
id: UnknownId(source_image.into()),
offset: 4,
byte_len: 1,
sha256: "22".into(),
data: Some(vec![9]),
links: vec!["cube:body#0".into()],
}
.into_native_record(),
);
repacked
.native
.namespace_mut("synthetic")
.arenas
.insert("unknowns".into(), records);
assert_eq!(
crate::hash::document_local_sha256(&repacked, "synthetic", source_image),
hash
);
}
}