1use crate::error::Error;
13use crate::vs::{byte_to_vs, decode_run, vs_to_byte};
14
15pub const MAGIC: [u8; 8] = *b"C2PATXT\0";
17pub const VERSION: u8 = 1;
19pub const MARKER: char = '\u{FEFF}';
21pub const HEADER_LEN: usize = 13;
23
24#[cfg(feature = "checksum-v2")]
28pub const VERSION_V2: u8 = 2;
29#[cfg(feature = "checksum-v2")]
30const CHECKSUM_LEN: usize = 4;
31
32#[derive(Debug, Clone, PartialEq, Eq)]
34pub struct Wrapper {
35 pub payload: Vec<u8>,
37 pub version: u8,
39 pub start: usize,
42 pub length: usize,
45}
46
47impl Wrapper {
48 pub fn range(&self) -> core::ops::Range<usize> {
50 self.start..self.start + self.length
51 }
52}
53
54pub fn encode(payload: &[u8]) -> Result<String, Error> {
56 encode_with_padding(payload, &[])
57}
58
59fn encode_with_padding(payload: &[u8], padding: &[u8]) -> Result<String, Error> {
60 let len = u32::try_from(payload.len()).map_err(|_| Error::PayloadTooLarge(payload.len()))?;
61 let mut framed = Vec::with_capacity(HEADER_LEN + payload.len() + padding.len());
62 framed.extend_from_slice(&MAGIC);
63 framed.push(VERSION);
64 framed.extend_from_slice(&len.to_be_bytes());
65 framed.extend_from_slice(payload);
66 framed.extend_from_slice(padding);
67 Ok(carry(&framed))
68}
69
70fn carry(framed: &[u8]) -> String {
71 let mut out = String::with_capacity(1 + framed.len() * 4);
72 out.push(MARKER);
73 out.extend(framed.iter().map(|&b| byte_to_vs(b)));
74 out
75}
76
77pub fn embed(text: &str, payload: &[u8]) -> Result<String, Error> {
80 Ok(format!("{text}{}", encode(payload)?))
81}
82
83pub fn target_length(manifest_len: usize) -> usize {
89 3 + (HEADER_LEN + manifest_len) * 4 + 6
90}
91
92pub fn padding(gap: usize) -> Result<Vec<u8>, Error> {
98 if gap == 0 {
99 return Ok(Vec::new());
100 }
101 let b = gap % 3;
103 if gap < 4 * b {
104 return Err(Error::UnrepresentableGap(gap));
106 }
107 let a = (gap - 4 * b) / 3;
108 let mut out = vec![0x00u8; a];
109 out.extend(core::iter::repeat_n(0x10u8, b));
110 Ok(out)
111}
112
113pub fn encode_padded(payload: &[u8]) -> Result<String, Error> {
116 let target = target_length(payload.len());
117 let base = encode(payload)?;
118 let gap = target
119 .checked_sub(base.len())
120 .ok_or(Error::UnrepresentableGap(0))?;
121 encode_with_padding(payload, &padding(gap)?)
122}
123
124fn decode_frame(run: &[u8], start: usize, length: usize) -> Option<Wrapper> {
126 let (body_end, declared_ok) = frame_bounds(run)?;
127 if run[8] != VERSION || !declared_ok {
128 return None;
129 }
130 Some(Wrapper {
131 payload: run[HEADER_LEN..body_end].to_vec(),
132 version: VERSION,
133 start,
134 length,
135 })
136}
137
138fn frame_bounds(run: &[u8]) -> Option<(usize, bool)> {
141 if run.len() < HEADER_LEN || run[..MAGIC.len()] != MAGIC {
142 return None;
143 }
144 let declared = u32::from_be_bytes([run[9], run[10], run[11], run[12]]) as usize;
145 let body_end = HEADER_LEN.checked_add(declared)?;
146 Some((body_end, run.len() >= body_end))
147}
148
149fn scan(text: &str, mut visit: impl FnMut(&[u8], usize, usize)) {
151 let mut from = 0;
152 while let Some(rel) = text[from..].find(MARKER) {
153 let start = from + rel;
154 let run_start = start + MARKER.len_utf8();
155 let (run, consumed) = decode_run(&text[run_start..]);
156 let end = run_start + consumed;
157 visit(&run, start, end - start);
158 from = end.max(run_start);
160 }
161}
162
163pub fn locate_all(text: &str) -> Vec<Wrapper> {
169 let mut found = Vec::new();
170 scan(text, |run, start, length| {
171 if let Some(w) = decode_frame(run, start, length) {
172 found.push(w);
173 }
174 });
175 found
176}
177
178pub fn extract(text: &str) -> Result<Wrapper, Error> {
193 let mut found = locate_all(text);
194 match found.len() {
195 1 => Ok(found.remove(0)),
196 0 if has_candidate(text) => Err(Error::CorruptedWrapper),
197 0 => Err(Error::NotFound),
198 _ => Err(Error::MultipleWrappers),
199 }
200}
201
202fn has_candidate(text: &str) -> bool {
205 let mut seen = false;
206 scan(text, |run, _, _| {
207 if run.len() >= MAGIC.len() && run[..MAGIC.len()] == MAGIC {
208 seen = true;
209 }
210 });
211 seen
212}
213
214#[cfg(feature = "checksum-v2")]
226pub mod v2 {
227 use super::{
228 carry, frame_bounds, has_candidate, scan, Error, Wrapper, CHECKSUM_LEN, HEADER_LEN, MAGIC,
229 VERSION_V2,
230 };
231 use crate::hardbinding::{Algorithm, Hasher};
232
233 fn framed(payload: &[u8], hasher: &impl Hasher) -> Result<Vec<u8>, Error> {
234 let len =
235 u32::try_from(payload.len()).map_err(|_| Error::PayloadTooLarge(payload.len()))?;
236 let mut v = Vec::with_capacity(HEADER_LEN + payload.len() + CHECKSUM_LEN);
237 v.extend_from_slice(&MAGIC);
238 v.push(VERSION_V2);
239 v.extend_from_slice(&len.to_be_bytes());
240 v.extend_from_slice(payload);
241 let sum = hasher.digest(Algorithm::Sha256, &v);
242 v.extend_from_slice(&sum[..CHECKSUM_LEN]);
243 Ok(v)
244 }
245
246 pub fn encode(payload: &[u8], hasher: &impl Hasher) -> Result<String, Error> {
248 Ok(carry(&framed(payload, hasher)?))
249 }
250
251 pub fn embed(text: &str, payload: &[u8], hasher: &impl Hasher) -> Result<String, Error> {
253 Ok(format!("{text}{}", encode(payload, hasher)?))
254 }
255
256 fn decode(run: &[u8], start: usize, length: usize, hasher: &impl Hasher) -> Option<Wrapper> {
257 let (body_end, _) = frame_bounds(run)?;
258 if run[8] != VERSION_V2 || run.len() < body_end + CHECKSUM_LEN {
259 return None;
260 }
261 let expected = hasher.digest(Algorithm::Sha256, &run[..body_end]);
262 if run[body_end..body_end + CHECKSUM_LEN] != expected[..CHECKSUM_LEN] {
263 return None;
264 }
265 Some(Wrapper {
266 payload: run[HEADER_LEN..body_end].to_vec(),
267 version: VERSION_V2,
268 start,
269 length,
270 })
271 }
272
273 pub fn locate_all(text: &str, hasher: &impl Hasher) -> Vec<Wrapper> {
275 let mut found = Vec::new();
276 scan(text, |run, start, length| {
277 if let Some(w) = decode(run, start, length, hasher) {
278 found.push(w);
279 }
280 });
281 found
282 }
283
284 pub fn extract_any(text: &str, hasher: &impl Hasher) -> Result<Wrapper, Error> {
290 match super::extract(text) {
291 Ok(w) => Ok(w),
292 Err(v1) => match extract(text, hasher) {
293 Ok(w) => Ok(w),
294 Err(Error::NotFound) => Err(v1),
296 Err(v2) => Err(v2),
297 },
298 }
299 }
300
301 pub fn extract(text: &str, hasher: &impl Hasher) -> Result<Wrapper, Error> {
306 let mut found = locate_all(text, hasher);
307 match found.len() {
308 1 => Ok(found.remove(0)),
309 0 if has_candidate(text) => Err(Error::CorruptedWrapper),
310 0 => Err(Error::NotFound),
311 _ => Err(Error::MultipleWrappers),
312 }
313 }
314}
315
316pub fn strip(text: &str, range: core::ops::Range<usize>) -> Result<String, Error> {
319 if range.end > text.len() || range.start > range.end {
320 return Err(Error::MalformedExclusion);
321 }
322 if !text.is_char_boundary(range.start) || !text.is_char_boundary(range.end) {
323 return Err(Error::MalformedExclusion);
324 }
325 let mut out = String::with_capacity(text.len() - (range.end - range.start));
326 out.push_str(&text[..range.start]);
327 out.push_str(&text[range.end..]);
328 Ok(out)
329}
330
331pub fn decode_exact(run: &str) -> Result<Vec<u8>, Error> {
333 run.chars()
334 .map(|c| vs_to_byte(c).ok_or(Error::CorruptedWrapper))
335 .collect()
336}
337
338#[cfg(test)]
339mod tests {
340 use super::*;
341
342 const HOST: &str = "This sentence carries an invisible C2PA text manifest wrapper at its end.";
343 const PAYLOAD: &[u8] = b"c2pa-manifest-01";
344
345 #[test]
346 fn round_trip_locates_the_payload_and_its_range() {
347 let asset = embed(HOST, PAYLOAD).unwrap();
348 let w = extract(&asset).unwrap();
349 assert_eq!(w.payload, PAYLOAD);
350 assert_eq!(w.version, VERSION);
351 assert_eq!(w.start, HOST.len());
352 assert_eq!(&asset[w.range()], &asset[HOST.len()..]);
353 assert!(asset[w.range()].starts_with(MARKER));
355 }
356
357 #[test]
358 fn stripping_the_range_leaves_the_visible_text() {
359 let asset = embed(HOST, PAYLOAD).unwrap();
360 let w = extract(&asset).unwrap();
361 assert_eq!(strip(&asset, w.range()).unwrap(), HOST);
362 }
363
364 #[test]
365 fn padding_uses_the_specified_decomposition() {
366 assert_eq!(padding(0).unwrap(), Vec::<u8>::new());
367 assert_eq!(padding(6).unwrap(), vec![0x00, 0x00]);
368 assert_eq!(padding(7).unwrap(), vec![0x00, 0x10]);
369 assert_eq!(padding(8).unwrap(), vec![0x10, 0x10]);
370 assert_eq!(padding(12).unwrap(), vec![0x00; 4]);
373 for gap in [1usize, 2, 5] {
374 assert!(padding(gap).is_err(), "gap {gap} should be rejected");
375 }
376 }
377
378 #[test]
379 fn padded_wrapper_hits_the_deterministic_target() {
380 for m in [0usize, 1, 16, 200] {
381 let payload = vec![0xABu8; m];
382 let padded = encode_padded(&payload).unwrap();
383 assert_eq!(padded.len(), target_length(m), "manifest of {m} bytes");
384 let w = extract(&format!("{HOST}{padded}")).unwrap();
386 assert_eq!(w.payload, payload);
387 }
388 }
389
390 #[test]
391 fn known_vector_matches_the_published_test_file() {
392 let unpadded = encode(PAYLOAD).unwrap();
394 assert_eq!(unpadded.len(), 114);
395 assert_eq!(target_length(PAYLOAD.len()), 125);
396 assert_eq!(padding(125 - 114).unwrap(), vec![0x00, 0x10, 0x10]);
397 assert_eq!(encode_padded(PAYLOAD).unwrap().len(), 125);
398 }
399
400 #[test]
401 fn no_wrapper_is_absence_but_many_is_a_reportable_failure() {
402 assert_eq!(extract(HOST), Err(Error::NotFound));
403 assert!(Error::NotFound.is_no_manifest_located());
404
405 let one = embed(HOST, PAYLOAD).unwrap();
406 let two = embed(&one, PAYLOAD).unwrap();
407 assert_eq!(extract(&two), Err(Error::MultipleWrappers));
408 assert_eq!(locate_all(&two).len(), 2);
409 assert!(!Error::MultipleWrappers.is_no_manifest_located());
411 assert_eq!(
412 Error::MultipleWrappers.code(),
413 Some("manifest.text.multipleWrappers")
414 );
415 }
416
417 #[test]
418 fn a_mangled_candidate_beside_a_valid_one_is_ignored() {
419 let mut framed = MAGIC.to_vec();
421 framed.push(9);
422 framed.extend_from_slice(&16u32.to_be_bytes());
423 framed.extend_from_slice(PAYLOAD);
424 let bad = carry(&framed);
425 let good = encode(PAYLOAD).unwrap();
426 let asset = format!("{HOST}{bad}{good}");
427 let w = extract(&asset).expect("the valid wrapper is still located");
428 assert_eq!(w.payload, PAYLOAD);
429 assert_eq!(locate_all(&asset).len(), 1);
430 }
431
432 #[test]
433 fn a_lone_mangled_candidate_reports_corruption_not_absence() {
434 let mut framed = MAGIC.to_vec();
435 framed.push(VERSION);
436 framed.extend_from_slice(&99u32.to_be_bytes()); framed.extend_from_slice(PAYLOAD);
438 let asset = format!("{HOST}{}", carry(&framed));
439 let err = extract(&asset).unwrap_err();
440 assert_eq!(err, Error::CorruptedWrapper);
441 assert!(!err.is_no_manifest_located());
444 assert_eq!(err.code(), Some("manifest.text.corruptedWrapper"));
445 }
446
447 #[test]
448 fn a_bad_magic_is_not_a_candidate_at_all() {
449 let mut v = b"C2PATXT\x01".to_vec();
451 v.push(VERSION);
452 v.extend_from_slice(&16u32.to_be_bytes());
453 v.extend_from_slice(PAYLOAD);
454 let asset = format!("{HOST}{}", carry(&v));
455 assert_eq!(extract(&asset), Err(Error::NotFound));
457 }
458
459 #[test]
460 fn payload_larger_than_the_length_field_is_rejected() {
461 assert!(u32::try_from(u32::MAX as usize).is_ok());
463 assert!(u32::try_from(u32::MAX as usize + 1).is_err());
464 }
465
466 #[test]
471 fn legitimate_selectors_in_clean_text_are_not_payloads() {
472 let clean = [
473 "A perfectly ordinary paragraph with no hidden provenance whatsoever.",
474 "Emoji carry legitimate variation selectors: a smiley \u{263A}\u{FE0F} and a heart \u{2764}\u{FE0F}.",
475 "CJK ideographic variation sequence: \u{845B}\u{E0100} is a valid rendering hint.",
476 "A stray zero-width joiner \u{200D} and no-break space \u{FEFF} without any magic.",
477 "\u{FEFF}A leading byte-order mark followed by ordinary prose.",
478 "\u{FEFF}\u{FE00}\u{FE01}",
480 "",
481 ];
482 for s in clean {
483 assert_eq!(
484 extract(s),
485 Err(Error::NotFound),
486 "hallucinated provenance in {s:?}"
487 );
488 assert!(locate_all(s).is_empty());
489 }
490 }
491
492 #[test]
493 fn a_marker_inside_ordinary_text_does_not_shadow_a_real_wrapper() {
494 let host = "Quoting a BOM \u{FEFF} mid-sentence, and an emoji \u{2764}\u{FE0F}.";
495 let asset = embed(host, PAYLOAD).unwrap();
496 let w = extract(&asset).unwrap();
497 assert_eq!(w.payload, PAYLOAD);
498 assert_eq!(w.start, host.len());
499 }
500
501 #[cfg(feature = "checksum-v2")]
502 mod checksum_v2 {
503 use super::*;
504 use crate::hardbinding::{Algorithm, Hasher};
505
506 struct TestHasher;
509 impl Hasher for TestHasher {
510 fn digest(&self, _: Algorithm, data: &[u8]) -> Vec<u8> {
511 let mut acc: u32 = 0x811C_9DC5;
512 for &b in data {
513 acc = (acc ^ b as u32).wrapping_mul(0x0100_0193);
514 }
515 acc.to_be_bytes().to_vec()
516 }
517 }
518
519 #[test]
520 fn round_trips_and_reports_version_two() {
521 let asset = v2::embed(HOST, PAYLOAD, &TestHasher).unwrap();
522 let w = v2::extract(&asset, &TestHasher).unwrap();
523 assert_eq!(w.payload, PAYLOAD);
524 assert_eq!(w.version, VERSION_V2);
525 assert_eq!(strip(&asset, w.range()).unwrap(), HOST);
526 }
527
528 #[test]
529 fn a_corrupted_payload_is_rejected_rather_than_decoded() {
530 let asset = v2::embed(HOST, PAYLOAD, &TestHasher).unwrap();
531 let mut mutated = PAYLOAD.to_vec();
534 mutated[0] ^= 0x01;
535 let good = v2::encode(PAYLOAD, &TestHasher).unwrap();
536 let bad = v2::encode(&mutated, &TestHasher).unwrap();
537 let good_tail: String = good
539 .chars()
540 .rev()
541 .take(4)
542 .collect::<Vec<_>>()
543 .into_iter()
544 .rev()
545 .collect();
546 let bad_body: String = bad.chars().take(bad.chars().count() - 4).collect();
547 let spliced = format!("{HOST}{bad_body}{good_tail}");
548 assert_eq!(
549 v2::extract(&spliced, &TestHasher),
550 Err(Error::CorruptedWrapper),
551 "a stale checksum must fail closed"
552 );
553 assert!(!asset.is_empty());
554 }
555
556 #[test]
557 fn a_v1_wrapper_is_not_a_v2_wrapper_and_the_reverse() {
558 let v1 = embed(HOST, PAYLOAD).unwrap();
559 assert_eq!(v2::extract(&v1, &TestHasher), Err(Error::CorruptedWrapper));
560 let two = v2::embed(HOST, PAYLOAD, &TestHasher).unwrap();
561 assert_eq!(extract(&two), Err(Error::CorruptedWrapper));
563 }
564
565 #[test]
566 fn clean_text_is_still_not_a_payload() {
567 assert_eq!(v2::extract(HOST, &TestHasher), Err(Error::NotFound));
568 }
569
570 #[test]
571 fn extract_any_accepts_either_frame() {
572 let v1 = embed(HOST, PAYLOAD).unwrap();
573 let two = v2::embed(HOST, PAYLOAD, &TestHasher).unwrap();
574 for asset in [&v1, &two] {
575 let w = v2::extract_any(asset, &TestHasher).unwrap();
576 assert_eq!(w.payload, PAYLOAD);
577 }
578 assert_eq!(v2::extract_any(&v1, &TestHasher).unwrap().version, VERSION);
579 assert_eq!(
580 v2::extract_any(&two, &TestHasher).unwrap().version,
581 VERSION_V2
582 );
583 assert_eq!(
584 v2::extract_any(HOST, &TestHasher),
585 Err(Error::NotFound),
586 "clean text is absence, not corruption"
587 );
588 }
589 }
590
591 #[test]
592 fn strip_rejects_ranges_that_split_a_character() {
593 let asset = format!("café{}", encode(PAYLOAD).unwrap());
594 assert_eq!(
596 strip(&asset, 4..asset.len()),
597 Err(Error::MalformedExclusion)
598 );
599 assert_eq!(
600 strip(&asset, 0..asset.len() + 1),
601 Err(Error::MalformedExclusion)
602 );
603 }
604}