Skip to main content

c2pa_structured_text/
hardbinding.rs

1// Copyright 2026 WritersLogic. All rights reserved.
2// Licensed under the Apache License, Version 2.0 or the MIT license,
3// at your option.
4
5//! The hard binding for structured text.
6//!
7//! # What the binding covers
8//!
9//! A claim generator that embeds a manifest block in a structured text file
10//! includes a `c2pa.hash.data` assertion with a **single exclusion range
11//! covering the entire manifest block**. The hash is computed over the raw
12//! bytes of the file with that range removed.
13//!
14//! Unlike the Unicode Variation Selector method for *unstructured* text, this
15//! binding applies **no Unicode normalization**. Structured text files are
16//! byte-stable on disk; normalizing to NFC would create false mismatches for
17//! files that legitimately contain NFD content (identifiers, YAML values,
18//! filenames). The byte boundaries of the manifest block are fixed by the ASCII
19//! delimiters, so the exclusion range is unambiguous. Files must therefore be
20//! read in binary mode, preserving exact line terminators. Bare CR (0x0D) line
21//! endings are unsupported and rejected.
22//!
23//! # Fragility and the soft-binding recovery path
24//!
25//! This is a *byte-exact* binding. Any change to the covered bytes -- including
26//! reformatting, re-indentation, transcoding, or an LF/CRLF conversion outside
27//! the manifest block -- breaks it, exactly as a hard binding is meant to. When
28//! durability across such transformations is required, pair it with the
29//! perceptual soft binding in `c2pa-text-binding`, which re-associates
30//! transformed content with its provenance after the hard binding is lost. Do
31//! not treat the structured-text hard binding as robust to editing; it is not.
32
33use crate::error::Error;
34use crate::extract::locate_block;
35
36/// A byte range excluded from the data hash, matching the `EXCLUSION_RANGE-map`
37/// CDDL (`start`, `length`).
38#[derive(Debug, Clone, Copy, PartialEq, Eq)]
39pub struct Exclusion {
40    pub start: usize,
41    pub length: usize,
42}
43
44/// The assertion label for the structured-text hard binding.
45pub const DATA_HASH_LABEL: &str = "c2pa.hash.data";
46
47/// Compute the single exclusion range covering the manifest block, per the
48/// placement rules in the C2PA specification (ยง *Hard Binding* for structured
49/// text).
50///
51/// Placement is derived from where the block sits in the file:
52/// - beginning: `start = 0`, `length` = block through its trailing terminator;
53/// - end: `start` = the newline preceding the block, `length` to end of file;
54/// - elsewhere (e.g. after a reserved header or inside front matter):
55///   `start` = first byte of the block line, `length` = the block line and its
56///   trailing terminator;
57/// - whole file is the block: `start = 0`, `length` = file length.
58pub fn manifest_exclusion(text: &str) -> Result<Exclusion, Error> {
59    reject_bare_cr(text.as_bytes())?;
60    let block = locate_block(text)?;
61    let len = text.len();
62    let bs = block.line_start;
63    let be = block.line_end;
64
65    let exclusion = if bs == 0 && be == len {
66        Exclusion {
67            start: 0,
68            length: len,
69        }
70    } else if bs == 0 {
71        Exclusion {
72            start: 0,
73            length: be,
74        }
75    } else if be == len {
76        // End placement: also exclude the line terminator that precedes the
77        // block so that removing the range leaves no dangling newline.
78        let bytes = text.as_bytes();
79        let mut start = bs - 1; // the LF; guaranteed present because bs > 0.
80        if start > 0 && bytes[start - 1] == b'\r' {
81            start -= 1;
82        }
83        Exclusion {
84            start,
85            length: len - start,
86        }
87    } else {
88        Exclusion {
89            start: bs,
90            length: be - bs,
91        }
92    };
93
94    Ok(exclusion)
95}
96
97/// Return the exact byte sequence that the data hash covers: the file with the
98/// manifest block excluded. This is the seam shared by hash computation and by
99/// the `c2pa-rs` validation bridge.
100pub fn hashed_bytes(text: &str) -> Result<Vec<u8>, Error> {
101    let exclusion = manifest_exclusion(text)?;
102    apply_exclusions(text.as_bytes(), &[exclusion])
103}
104
105/// Remove `exclusions` from `bytes`, validating that they are ordered,
106/// non-overlapping, and within bounds as a validator must (see *Validating a
107/// data hash*).
108pub(crate) fn apply_exclusions(bytes: &[u8], exclusions: &[Exclusion]) -> Result<Vec<u8>, Error> {
109    let mut cursor = 0usize;
110    let mut out = Vec::with_capacity(bytes.len());
111    for ex in exclusions {
112        let end = ex
113            .start
114            .checked_add(ex.length)
115            .ok_or(Error::MalformedExclusion)?;
116        if ex.start < cursor {
117            // Out of order or overlapping with a previous range.
118            return Err(Error::MalformedExclusion);
119        }
120        if end > bytes.len() {
121            // End of an exclusion range beyond the end of the asset.
122            return Err(Error::HashMismatch);
123        }
124        out.extend_from_slice(&bytes[cursor..ex.start]);
125        cursor = end;
126    }
127    out.extend_from_slice(&bytes[cursor..]);
128    Ok(out)
129}
130
131fn reject_bare_cr(bytes: &[u8]) -> Result<(), Error> {
132    let mut i = 0;
133    while i < bytes.len() {
134        if bytes[i] == b'\r' {
135            if bytes.get(i + 1) != Some(&b'\n') {
136                return Err(Error::BareCarriageReturn);
137            }
138            i += 2;
139        } else {
140            i += 1;
141        }
142    }
143    Ok(())
144}
145
146#[cfg(feature = "hard-binding")]
147mod hashing {
148    use super::{apply_exclusions, manifest_exclusion, reject_bare_cr, Exclusion, DATA_HASH_LABEL};
149    use crate::codec;
150    use crate::error::Error;
151    use sha2::{Digest, Sha256, Sha384, Sha512};
152
153    /// A C2PA-allowed hash algorithm for the data hash (SHA2-256/384/512).
154    #[derive(Debug, Clone, Copy, PartialEq, Eq)]
155    pub enum Algorithm {
156        Sha256,
157        Sha384,
158        Sha512,
159    }
160
161    impl Algorithm {
162        /// The C2PA algorithm identifier string used in the `alg` field.
163        pub fn id(self) -> &'static str {
164            match self {
165                Algorithm::Sha256 => "sha256",
166                Algorithm::Sha384 => "sha384",
167                Algorithm::Sha512 => "sha512",
168            }
169        }
170
171        pub fn from_id(id: &str) -> Result<Self, Error> {
172            match id {
173                "sha256" => Ok(Algorithm::Sha256),
174                "sha384" => Ok(Algorithm::Sha384),
175                "sha512" => Ok(Algorithm::Sha512),
176                other => Err(Error::UnsupportedAlgorithm(other.to_string())),
177            }
178        }
179
180        fn hash(self, data: &[u8]) -> Vec<u8> {
181            match self {
182                Algorithm::Sha256 => Sha256::digest(data).to_vec(),
183                Algorithm::Sha384 => Sha384::digest(data).to_vec(),
184                Algorithm::Sha512 => Sha512::digest(data).to_vec(),
185            }
186        }
187    }
188
189    /// A computed `c2pa.hash.data` assertion for a structured text asset.
190    #[derive(Debug, Clone, PartialEq, Eq)]
191    pub struct DataHash {
192        pub exclusions: Vec<Exclusion>,
193        pub alg: String,
194        pub hash: Vec<u8>,
195        pub name: Option<String>,
196    }
197
198    impl DataHash {
199        /// The assertion label, `c2pa.hash.data`.
200        pub fn label(&self) -> &'static str {
201            DATA_HASH_LABEL
202        }
203
204        /// Serialise to the JSON shape consumed by `c2pa-rs` when building a
205        /// manifest, with the hash as standard Base64. Hand-built to keep the
206        /// crate dependency-light; the field set matches the `data-hash-map`
207        /// CDDL.
208        pub fn to_json(&self) -> String {
209            let ranges: Vec<String> = self
210                .exclusions
211                .iter()
212                .map(|e| format!("{{\"start\":{},\"length\":{}}}", e.start, e.length))
213                .collect();
214            let mut json = format!(
215                "{{\"exclusions\":[{}],\"alg\":\"{}\",\"hash\":\"{}\"",
216                ranges.join(","),
217                self.alg,
218                codec::encode(&self.hash)
219            );
220            if let Some(name) = &self.name {
221                json.push_str(&format!(",\"name\":\"{}\"", name));
222            }
223            json.push('}');
224            json
225        }
226    }
227
228    /// Compute the structured-text hard binding for `text`: locate the manifest
229    /// block, exclude it, and hash the remaining raw bytes with `alg`.
230    pub fn compute_data_hash(text: &str, alg: Algorithm) -> Result<DataHash, Error> {
231        let exclusion = manifest_exclusion(text)?;
232        let covered = apply_exclusions(text.as_bytes(), &[exclusion])?;
233        Ok(DataHash {
234            exclusions: vec![exclusion],
235            alg: alg.id().to_string(),
236            hash: alg.hash(&covered),
237            name: None,
238        })
239    }
240
241    /// Verify a `c2pa.hash.data` binding against `text`, following the validator
242    /// procedure: apply the assertion's own exclusion ranges to the raw bytes,
243    /// recompute the hash, and compare.
244    ///
245    /// Returns [`Error::HashMismatch`] on a content mismatch,
246    /// [`Error::MalformedExclusion`] on out-of-order or overlapping ranges, and
247    /// [`Error::UnsupportedAlgorithm`] if `alg` is outside the allowed list.
248    pub fn verify_data_hash(text: &str, data_hash: &DataHash) -> Result<(), Error> {
249        reject_bare_cr(text.as_bytes())?;
250        let alg = Algorithm::from_id(&data_hash.alg)?;
251        if data_hash.exclusions.is_empty() {
252            return Err(Error::MalformedExclusion);
253        }
254        let covered = apply_exclusions(text.as_bytes(), &data_hash.exclusions)?;
255        if alg.hash(&covered) == data_hash.hash {
256            Ok(())
257        } else {
258            Err(Error::HashMismatch)
259        }
260    }
261}
262
263#[cfg(feature = "hard-binding")]
264pub use hashing::{compute_data_hash, verify_data_hash, Algorithm, DataHash};
265
266#[cfg(test)]
267mod tests {
268    use super::*;
269    use crate::embed::{embed_front_matter, embed_manifest, embed_manifest_at_end, ManifestRef};
270
271    const URL: &str = "https://example.com/m.c2pa";
272
273    #[test]
274    fn exclusion_at_beginning() {
275        let embedded = embed_manifest("print('hi')\n", ManifestRef::Url(URL), "#", None);
276        let ex = manifest_exclusion(&embedded).unwrap();
277        assert_eq!(ex.start, 0);
278        // Removing the exclusion recovers the original content exactly.
279        assert_eq!(hashed_bytes(&embedded).unwrap(), b"print('hi')\n");
280    }
281
282    #[test]
283    fn exclusion_at_end_excludes_preceding_newline() {
284        let embedded = embed_manifest_at_end("print('hi')\n", ManifestRef::Url(URL), "#", None);
285        let ex = manifest_exclusion(&embedded).unwrap();
286        // The block sits at EOF; the newline before it is part of the range.
287        assert_eq!(ex.start + ex.length, embedded.len());
288        assert_eq!(hashed_bytes(&embedded).unwrap(), b"print('hi')");
289    }
290
291    #[test]
292    fn exclusion_elsewhere_after_header() {
293        // A reserved first line (shebang) with the block on line two, content after.
294        let text =
295            "#!/bin/sh\n# -----BEGIN C2PA MANIFEST----- u -----END C2PA MANIFEST-----\necho hi\n";
296        let ex = manifest_exclusion(text).unwrap();
297        assert_eq!(ex.start, "#!/bin/sh\n".len());
298        assert_eq!(hashed_bytes(text).unwrap(), b"#!/bin/sh\necho hi\n");
299    }
300
301    #[test]
302    fn exclusion_front_matter_keeps_fences() {
303        let embedded = embed_front_matter("title: doc\n", ManifestRef::Url(URL), "---");
304        // The `---` fences are preserved; only BEGIN..END lines are excluded.
305        let covered = String::from_utf8(hashed_bytes(&embedded).unwrap()).unwrap();
306        assert!(covered.starts_with("---\n"));
307        assert!(covered.contains("title: doc"));
308        assert!(!covered.contains("BEGIN C2PA MANIFEST"));
309    }
310
311    #[test]
312    fn exclusion_whole_file_is_block() {
313        let text = "# -----BEGIN C2PA MANIFEST----- u -----END C2PA MANIFEST-----\n";
314        let ex = manifest_exclusion(text).unwrap();
315        assert_eq!(ex.start, 0);
316        assert_eq!(ex.length, text.len());
317        assert_eq!(hashed_bytes(text).unwrap(), b"");
318    }
319
320    #[test]
321    fn crlf_is_supported() {
322        let text = "# -----BEGIN C2PA MANIFEST----- u -----END C2PA MANIFEST-----\r\nprint()\r\n";
323        assert_eq!(hashed_bytes(text).unwrap(), b"print()\r\n");
324    }
325
326    #[test]
327    fn bare_cr_is_rejected() {
328        let text = "# -----BEGIN C2PA MANIFEST----- u -----END C2PA MANIFEST-----\rprint()";
329        assert!(matches!(
330            manifest_exclusion(text),
331            Err(Error::BareCarriageReturn)
332        ));
333    }
334
335    #[test]
336    fn apply_exclusions_rejects_out_of_order() {
337        let bytes = b"0123456789";
338        let ranges = [
339            Exclusion {
340                start: 5,
341                length: 2,
342            },
343            Exclusion {
344                start: 1,
345                length: 2,
346            },
347        ];
348        assert!(matches!(
349            apply_exclusions(bytes, &ranges),
350            Err(Error::MalformedExclusion)
351        ));
352    }
353
354    #[test]
355    fn apply_exclusions_rejects_beyond_end() {
356        let bytes = b"0123456789";
357        let ranges = [Exclusion {
358            start: 8,
359            length: 5,
360        }];
361        assert!(matches!(
362            apply_exclusions(bytes, &ranges),
363            Err(Error::HashMismatch)
364        ));
365    }
366}