Skip to main content

rustyhdf5_format/
provenance.rs

1//! SHINES provenance: SHA-256 content hashing, provenance attributes, and
2//! data-integrity verification.
3//!
4//! Enable with the `provenance` Cargo feature (on by default).
5
6#[cfg(not(feature = "std"))]
7use alloc::{format, string::String, vec::Vec};
8
9use sha2::{Digest, Sha256};
10
11use crate::attribute::AttributeMessage;
12use crate::data_layout::DataLayout;
13use crate::data_read::read_raw_data;
14use crate::dataspace::Dataspace;
15use crate::datatype::Datatype;
16use crate::error::FormatError;
17use crate::object_header::ObjectHeader;
18use crate::type_builders::{build_attr_message, AttrValue};
19
20// ---- Attribute name constants ----
21
22/// SHA-256 hex digest of the raw dataset bytes.
23pub const ATTR_SHA256: &str = "_provenance_sha256";
24/// Creator identifier (tool/user).
25pub const ATTR_CREATOR: &str = "_provenance_creator";
26/// ISO-8601 timestamp when the dataset was written.
27pub const ATTR_TIMESTAMP: &str = "_provenance_timestamp";
28/// Optional free-form description of the data source.
29pub const ATTR_SOURCE: &str = "_provenance_source";
30
31// ---- SHA-256 hashing ----
32
33/// Compute the SHA-256 digest of `data` and return the lowercase hex string.
34pub fn sha256_hex(data: &[u8]) -> String {
35    let hash = Sha256::digest(data);
36    let mut hex = String::with_capacity(64);
37    for byte in hash.iter() {
38        hex.push_str(&format!("{byte:02x}"));
39    }
40    hex
41}
42
43// ---- Provenance metadata builder ----
44
45/// Collects provenance information to be stored as HDF5 attributes.
46pub struct Provenance {
47    pub creator: String,
48    pub timestamp: String,
49    pub source: Option<String>,
50}
51
52impl Provenance {
53    /// Build provenance attribute messages for the given raw dataset bytes.
54    ///
55    /// Returns a `Vec<AttributeMessage>` containing:
56    /// - `_provenance_sha256`   — hex digest of `raw_data`
57    /// - `_provenance_creator`  — the creator string
58    /// - `_provenance_timestamp` — the timestamp string
59    /// - `_provenance_source`   — (optional) source description
60    pub fn build_attrs(&self, raw_data: &[u8]) -> Vec<AttributeMessage> {
61        let hash = sha256_hex(raw_data);
62        let mut attrs = Vec::with_capacity(4);
63        let hash_val = AttrValue::String(hash);
64        attrs.push(build_attr_message(ATTR_SHA256, &hash_val));
65        let creator_val = AttrValue::String(self.creator.clone());
66        attrs.push(build_attr_message(ATTR_CREATOR, &creator_val));
67        let ts_val = AttrValue::String(self.timestamp.clone());
68        attrs.push(build_attr_message(ATTR_TIMESTAMP, &ts_val));
69        if let Some(ref src) = self.source {
70            let src_val = AttrValue::String(src.clone());
71            attrs.push(build_attr_message(ATTR_SOURCE, &src_val));
72        }
73        attrs
74    }
75}
76
77// ---- Verification ----
78
79/// Result of a provenance verification check.
80#[derive(Debug, Clone, PartialEq, Eq)]
81pub enum VerifyResult {
82    /// Hash matches — data integrity confirmed.
83    Ok,
84    /// Hash mismatch — stored vs computed.
85    Mismatch { stored: String, computed: String },
86    /// No provenance hash attribute found on this dataset.
87    NoHash,
88}
89
90/// Verify the integrity of a dataset by recomputing its SHA-256 hash and
91/// comparing it against the stored `_provenance_sha256` attribute.
92///
93/// `file_data` is the entire HDF5 file bytes; `header` is the parsed object
94/// header for the dataset of interest.
95pub fn verify_dataset(
96    file_data: &[u8],
97    header: &ObjectHeader,
98    offset_size: u8,
99    length_size: u8,
100) -> Result<VerifyResult, FormatError> {
101    // 1. Extract all attributes (compact + dense).
102    let attrs = crate::attribute::extract_attributes_full(
103        file_data,
104        header,
105        offset_size,
106        length_size,
107    )?;
108
109    // 2. Find the stored hash.
110    let stored_hash = attrs
111        .iter()
112        .find(|a| a.name == ATTR_SHA256)
113        .and_then(|a| core::str::from_utf8(&a.raw_data).ok())
114        .map(|s| s.trim_end_matches('\0').to_string());
115
116    let stored_hash = match stored_hash {
117        Some(h) => h,
118        None => return Ok(VerifyResult::NoHash),
119    };
120
121    // 3. Read the raw dataset data.
122    let dt_msg = header
123        .messages
124        .iter()
125        .find(|m| m.msg_type == crate::message_type::MessageType::Datatype)
126        .ok_or_else(|| FormatError::SerializationError("missing Datatype message".into()))?;
127    let ds_msg = header
128        .messages
129        .iter()
130        .find(|m| m.msg_type == crate::message_type::MessageType::Dataspace)
131        .ok_or_else(|| FormatError::SerializationError("missing Dataspace message".into()))?;
132    let dl_msg = header
133        .messages
134        .iter()
135        .find(|m| m.msg_type == crate::message_type::MessageType::DataLayout)
136        .ok_or_else(|| FormatError::SerializationError("missing DataLayout message".into()))?;
137
138    let (dt, _) = Datatype::parse(&dt_msg.data)?;
139    let ds = Dataspace::parse(&ds_msg.data, length_size)?;
140    let dl = DataLayout::parse(&dl_msg.data, offset_size, length_size)?;
141
142    let pipeline = header
143        .messages
144        .iter()
145        .find(|m| m.msg_type == crate::message_type::MessageType::FilterPipeline)
146        .map(|m| crate::filter_pipeline::FilterPipeline::parse(&m.data))
147        .transpose()?;
148
149    let raw = match &dl {
150        DataLayout::Chunked { .. } => crate::chunked_read::read_chunked_data(
151            file_data,
152            &dl,
153            &ds,
154            &dt,
155            pipeline.as_ref(),
156            offset_size,
157            length_size,
158        )?,
159        _ => read_raw_data(file_data, &dl, &ds, &dt)?,
160    };
161
162    // 4. Compare.
163    let computed = sha256_hex(&raw);
164    if computed == stored_hash {
165        Ok(VerifyResult::Ok)
166    } else {
167        Ok(VerifyResult::Mismatch {
168            stored: stored_hash,
169            computed,
170        })
171    }
172}
173
174// ---- Tests ----
175
176#[cfg(test)]
177mod tests {
178    use super::*;
179
180    #[test]
181    fn sha256_empty() {
182        // Well-known: SHA-256 of empty input
183        let h = sha256_hex(b"");
184        assert_eq!(
185            h,
186            "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
187        );
188    }
189
190    #[test]
191    fn sha256_hello() {
192        let h = sha256_hex(b"hello");
193        assert_eq!(
194            h,
195            "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"
196        );
197    }
198
199    #[test]
200    fn provenance_builds_attrs_without_source() {
201        let prov = Provenance {
202            creator: "rustyhdf5".into(),
203            timestamp: "2026-02-19T00:00:00Z".into(),
204            source: None,
205        };
206        let attrs = prov.build_attrs(b"hello");
207        assert_eq!(attrs.len(), 3);
208        assert_eq!(attrs[0].name, ATTR_SHA256);
209        assert_eq!(attrs[1].name, ATTR_CREATOR);
210        assert_eq!(attrs[2].name, ATTR_TIMESTAMP);
211    }
212
213    #[test]
214    fn provenance_builds_attrs_with_source() {
215        let prov = Provenance {
216            creator: "test".into(),
217            timestamp: "2026-01-01T00:00:00Z".into(),
218            source: Some("sensor_42".into()),
219        };
220        let attrs = prov.build_attrs(b"data");
221        assert_eq!(attrs.len(), 4);
222        assert_eq!(attrs[3].name, ATTR_SOURCE);
223    }
224
225    #[test]
226    fn roundtrip_provenance_on_file() {
227        use crate::file_writer::FileWriter;
228        use crate::group_v2::resolve_path_any;
229        use crate::signature;
230        use crate::superblock::Superblock;
231
232        let raw_data: Vec<u8> = (0..24u64)
233            .flat_map(|v| (v as f64).to_le_bytes())
234            .collect();
235        let expected_hash = sha256_hex(&raw_data);
236
237        let mut fw = FileWriter::new();
238        let ds = fw.create_dataset("sensor");
239        ds.with_f64_data(
240            &(0..24).map(|v| v as f64).collect::<Vec<_>>(),
241        );
242        ds.set_attr(ATTR_SHA256, AttrValue::String(expected_hash.clone()));
243        ds.set_attr(
244            ATTR_CREATOR,
245            AttrValue::String("test-suite".into()),
246        );
247        ds.set_attr(
248            ATTR_TIMESTAMP,
249            AttrValue::String("2026-02-19T12:00:00Z".into()),
250        );
251        let bytes = fw.finish().unwrap();
252
253        // Verify round-trip
254        let sig = signature::find_signature(&bytes).unwrap();
255        let sb = Superblock::parse(&bytes, sig).unwrap();
256        let addr = resolve_path_any(&bytes, &sb, "sensor").unwrap();
257        let hdr = crate::object_header::ObjectHeader::parse(
258            &bytes,
259            addr as usize,
260            sb.offset_size,
261            sb.length_size,
262        )
263        .unwrap();
264
265        let result = verify_dataset(&bytes, &hdr, sb.offset_size, sb.length_size).unwrap();
266        assert_eq!(result, VerifyResult::Ok);
267    }
268
269    #[test]
270    fn verify_no_hash_attribute() {
271        use crate::file_writer::FileWriter;
272        use crate::group_v2::resolve_path_any;
273        use crate::signature;
274        use crate::superblock::Superblock;
275
276        let mut fw = FileWriter::new();
277        fw.create_dataset("plain").with_f64_data(&[1.0, 2.0]);
278        let bytes = fw.finish().unwrap();
279
280        let sig = signature::find_signature(&bytes).unwrap();
281        let sb = Superblock::parse(&bytes, sig).unwrap();
282        let addr = resolve_path_any(&bytes, &sb, "plain").unwrap();
283        let hdr = crate::object_header::ObjectHeader::parse(
284            &bytes,
285            addr as usize,
286            sb.offset_size,
287            sb.length_size,
288        )
289        .unwrap();
290
291        let result = verify_dataset(&bytes, &hdr, sb.offset_size, sb.length_size).unwrap();
292        assert_eq!(result, VerifyResult::NoHash);
293    }
294}