1use crate::error::Error;
34use crate::extract::locate_block;
35
36#[derive(Debug, Clone, Copy, PartialEq, Eq)]
39pub struct Exclusion {
40 pub start: usize,
41 pub length: usize,
42}
43
44pub const DATA_HASH_LABEL: &str = "c2pa.hash.data";
46
47pub 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 let bytes = text.as_bytes();
79 let mut start = bs - 1; 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
97pub fn hashed_bytes(text: &str) -> Result<Vec<u8>, Error> {
101 let exclusion = manifest_exclusion(text)?;
102 apply_exclusions(text.as_bytes(), &[exclusion])
103}
104
105pub(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 return Err(Error::MalformedExclusion);
119 }
120 if end > bytes.len() {
121 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 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
155 pub enum Algorithm {
156 Sha256,
157 Sha384,
158 Sha512,
159 }
160
161 impl Algorithm {
162 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 #[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 pub fn label(&self) -> &'static str {
201 DATA_HASH_LABEL
202 }
203
204 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 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 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 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 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 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 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}