use crate::error::Error;
use crate::vs::{byte_to_vs, decode_run, vs_to_byte};
pub const MAGIC: [u8; 8] = *b"C2PATXT\0";
pub const VERSION: u8 = 1;
pub const MARKER: char = '\u{FEFF}';
pub const HEADER_LEN: usize = 13;
#[cfg(feature = "checksum-v2")]
pub const VERSION_V2: u8 = 2;
#[cfg(feature = "checksum-v2")]
const CHECKSUM_LEN: usize = 4;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Wrapper {
pub payload: Vec<u8>,
pub version: u8,
pub start: usize,
pub length: usize,
}
impl Wrapper {
pub fn range(&self) -> core::ops::Range<usize> {
self.start..self.start + self.length
}
}
pub fn encode(payload: &[u8]) -> Result<String, Error> {
encode_with_padding(payload, &[])
}
fn encode_with_padding(payload: &[u8], padding: &[u8]) -> Result<String, Error> {
let len = u32::try_from(payload.len()).map_err(|_| Error::PayloadTooLarge(payload.len()))?;
let mut framed = Vec::with_capacity(HEADER_LEN + payload.len() + padding.len());
framed.extend_from_slice(&MAGIC);
framed.push(VERSION);
framed.extend_from_slice(&len.to_be_bytes());
framed.extend_from_slice(payload);
framed.extend_from_slice(padding);
Ok(carry(&framed))
}
fn carry(framed: &[u8]) -> String {
let mut out = String::with_capacity(1 + framed.len() * 4);
out.push(MARKER);
out.extend(framed.iter().map(|&b| byte_to_vs(b)));
out
}
pub fn embed(text: &str, payload: &[u8]) -> Result<String, Error> {
Ok(format!("{text}{}", encode(payload)?))
}
pub fn target_length(manifest_len: usize) -> usize {
3 + (HEADER_LEN + manifest_len) * 4 + 6
}
pub fn padding(gap: usize) -> Result<Vec<u8>, Error> {
if gap == 0 {
return Ok(Vec::new());
}
let b = gap % 3;
if gap < 4 * b {
return Err(Error::UnrepresentableGap(gap));
}
let a = (gap - 4 * b) / 3;
let mut out = vec![0x00u8; a];
out.extend(core::iter::repeat_n(0x10u8, b));
Ok(out)
}
pub fn encode_padded(payload: &[u8]) -> Result<String, Error> {
let target = target_length(payload.len());
let base = encode(payload)?;
let gap = target
.checked_sub(base.len())
.ok_or(Error::UnrepresentableGap(0))?;
encode_with_padding(payload, &padding(gap)?)
}
fn decode_frame(run: &[u8], start: usize, length: usize) -> Option<Wrapper> {
let (body_end, declared_ok) = frame_bounds(run)?;
if run[8] != VERSION || !declared_ok {
return None;
}
Some(Wrapper {
payload: run[HEADER_LEN..body_end].to_vec(),
version: VERSION,
start,
length,
})
}
fn frame_bounds(run: &[u8]) -> Option<(usize, bool)> {
if run.len() < HEADER_LEN || run[..MAGIC.len()] != MAGIC {
return None;
}
let declared = u32::from_be_bytes([run[9], run[10], run[11], run[12]]) as usize;
let body_end = HEADER_LEN.checked_add(declared)?;
Some((body_end, run.len() >= body_end))
}
fn scan(text: &str, mut visit: impl FnMut(&[u8], usize, usize)) {
let mut from = 0;
while let Some(rel) = text[from..].find(MARKER) {
let start = from + rel;
let run_start = start + MARKER.len_utf8();
let (run, consumed) = decode_run(&text[run_start..]);
let end = run_start + consumed;
visit(&run, start, end - start);
from = end.max(run_start);
}
}
pub fn locate_all(text: &str) -> Vec<Wrapper> {
let mut found = Vec::new();
scan(text, |run, start, length| {
if let Some(w) = decode_frame(run, start, length) {
found.push(w);
}
});
found
}
pub fn extract(text: &str) -> Result<Wrapper, Error> {
let mut found = locate_all(text);
match found.len() {
1 => Ok(found.remove(0)),
0 if has_candidate(text) => Err(Error::CorruptedWrapper),
0 => Err(Error::NotFound),
_ => Err(Error::MultipleWrappers),
}
}
fn has_candidate(text: &str) -> bool {
let mut seen = false;
scan(text, |run, _, _| {
if run.len() >= MAGIC.len() && run[..MAGIC.len()] == MAGIC {
seen = true;
}
});
seen
}
#[cfg(feature = "checksum-v2")]
pub mod v2 {
use super::{
carry, frame_bounds, has_candidate, scan, Error, Wrapper, CHECKSUM_LEN, HEADER_LEN, MAGIC,
VERSION_V2,
};
use crate::hardbinding::{Algorithm, Hasher};
fn framed(payload: &[u8], hasher: &impl Hasher) -> Result<Vec<u8>, Error> {
let len =
u32::try_from(payload.len()).map_err(|_| Error::PayloadTooLarge(payload.len()))?;
let mut v = Vec::with_capacity(HEADER_LEN + payload.len() + CHECKSUM_LEN);
v.extend_from_slice(&MAGIC);
v.push(VERSION_V2);
v.extend_from_slice(&len.to_be_bytes());
v.extend_from_slice(payload);
let sum = hasher.digest(Algorithm::Sha256, &v);
v.extend_from_slice(&sum[..CHECKSUM_LEN]);
Ok(v)
}
pub fn encode(payload: &[u8], hasher: &impl Hasher) -> Result<String, Error> {
Ok(carry(&framed(payload, hasher)?))
}
pub fn embed(text: &str, payload: &[u8], hasher: &impl Hasher) -> Result<String, Error> {
Ok(format!("{text}{}", encode(payload, hasher)?))
}
fn decode(run: &[u8], start: usize, length: usize, hasher: &impl Hasher) -> Option<Wrapper> {
let (body_end, _) = frame_bounds(run)?;
if run[8] != VERSION_V2 || run.len() < body_end + CHECKSUM_LEN {
return None;
}
let expected = hasher.digest(Algorithm::Sha256, &run[..body_end]);
if run[body_end..body_end + CHECKSUM_LEN] != expected[..CHECKSUM_LEN] {
return None;
}
Some(Wrapper {
payload: run[HEADER_LEN..body_end].to_vec(),
version: VERSION_V2,
start,
length,
})
}
pub fn locate_all(text: &str, hasher: &impl Hasher) -> Vec<Wrapper> {
let mut found = Vec::new();
scan(text, |run, start, length| {
if let Some(w) = decode(run, start, length, hasher) {
found.push(w);
}
});
found
}
pub fn extract_any(text: &str, hasher: &impl Hasher) -> Result<Wrapper, Error> {
match super::extract(text) {
Ok(w) => Ok(w),
Err(v1) => match extract(text, hasher) {
Ok(w) => Ok(w),
Err(Error::NotFound) => Err(v1),
Err(v2) => Err(v2),
},
}
}
pub fn extract(text: &str, hasher: &impl Hasher) -> Result<Wrapper, Error> {
let mut found = locate_all(text, hasher);
match found.len() {
1 => Ok(found.remove(0)),
0 if has_candidate(text) => Err(Error::CorruptedWrapper),
0 => Err(Error::NotFound),
_ => Err(Error::MultipleWrappers),
}
}
}
pub fn strip(text: &str, range: core::ops::Range<usize>) -> Result<String, Error> {
if range.end > text.len() || range.start > range.end {
return Err(Error::MalformedExclusion);
}
if !text.is_char_boundary(range.start) || !text.is_char_boundary(range.end) {
return Err(Error::MalformedExclusion);
}
let mut out = String::with_capacity(text.len() - (range.end - range.start));
out.push_str(&text[..range.start]);
out.push_str(&text[range.end..]);
Ok(out)
}
pub fn decode_exact(run: &str) -> Result<Vec<u8>, Error> {
run.chars()
.map(|c| vs_to_byte(c).ok_or(Error::CorruptedWrapper))
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
const HOST: &str = "This sentence carries an invisible C2PA text manifest wrapper at its end.";
const PAYLOAD: &[u8] = b"c2pa-manifest-01";
#[test]
fn round_trip_locates_the_payload_and_its_range() {
let asset = embed(HOST, PAYLOAD).unwrap();
let w = extract(&asset).unwrap();
assert_eq!(w.payload, PAYLOAD);
assert_eq!(w.version, VERSION);
assert_eq!(w.start, HOST.len());
assert_eq!(&asset[w.range()], &asset[HOST.len()..]);
assert!(asset[w.range()].starts_with(MARKER));
}
#[test]
fn stripping_the_range_leaves_the_visible_text() {
let asset = embed(HOST, PAYLOAD).unwrap();
let w = extract(&asset).unwrap();
assert_eq!(strip(&asset, w.range()).unwrap(), HOST);
}
#[test]
fn padding_uses_the_specified_decomposition() {
assert_eq!(padding(0).unwrap(), Vec::<u8>::new());
assert_eq!(padding(6).unwrap(), vec![0x00, 0x00]);
assert_eq!(padding(7).unwrap(), vec![0x00, 0x10]);
assert_eq!(padding(8).unwrap(), vec![0x10, 0x10]);
assert_eq!(padding(12).unwrap(), vec![0x00; 4]);
for gap in [1usize, 2, 5] {
assert!(padding(gap).is_err(), "gap {gap} should be rejected");
}
}
#[test]
fn padded_wrapper_hits_the_deterministic_target() {
for m in [0usize, 1, 16, 200] {
let payload = vec![0xABu8; m];
let padded = encode_padded(&payload).unwrap();
assert_eq!(padded.len(), target_length(m), "manifest of {m} bytes");
let w = extract(&format!("{HOST}{padded}")).unwrap();
assert_eq!(w.payload, payload);
}
}
#[test]
fn known_vector_matches_the_published_test_file() {
let unpadded = encode(PAYLOAD).unwrap();
assert_eq!(unpadded.len(), 114);
assert_eq!(target_length(PAYLOAD.len()), 125);
assert_eq!(padding(125 - 114).unwrap(), vec![0x00, 0x10, 0x10]);
assert_eq!(encode_padded(PAYLOAD).unwrap().len(), 125);
}
#[test]
fn no_wrapper_is_absence_but_many_is_a_reportable_failure() {
assert_eq!(extract(HOST), Err(Error::NotFound));
assert!(Error::NotFound.is_no_manifest_located());
let one = embed(HOST, PAYLOAD).unwrap();
let two = embed(&one, PAYLOAD).unwrap();
assert_eq!(extract(&two), Err(Error::MultipleWrappers));
assert_eq!(locate_all(&two).len(), 2);
assert!(!Error::MultipleWrappers.is_no_manifest_located());
assert_eq!(
Error::MultipleWrappers.code(),
Some("manifest.text.multipleWrappers")
);
}
#[test]
fn a_mangled_candidate_beside_a_valid_one_is_ignored() {
let mut framed = MAGIC.to_vec();
framed.push(9);
framed.extend_from_slice(&16u32.to_be_bytes());
framed.extend_from_slice(PAYLOAD);
let bad = carry(&framed);
let good = encode(PAYLOAD).unwrap();
let asset = format!("{HOST}{bad}{good}");
let w = extract(&asset).expect("the valid wrapper is still located");
assert_eq!(w.payload, PAYLOAD);
assert_eq!(locate_all(&asset).len(), 1);
}
#[test]
fn a_lone_mangled_candidate_reports_corruption_not_absence() {
let mut framed = MAGIC.to_vec();
framed.push(VERSION);
framed.extend_from_slice(&99u32.to_be_bytes()); framed.extend_from_slice(PAYLOAD);
let asset = format!("{HOST}{}", carry(&framed));
let err = extract(&asset).unwrap_err();
assert_eq!(err, Error::CorruptedWrapper);
assert!(!err.is_no_manifest_located());
assert_eq!(err.code(), Some("manifest.text.corruptedWrapper"));
}
#[test]
fn a_bad_magic_is_not_a_candidate_at_all() {
let mut v = b"C2PATXT\x01".to_vec();
v.push(VERSION);
v.extend_from_slice(&16u32.to_be_bytes());
v.extend_from_slice(PAYLOAD);
let asset = format!("{HOST}{}", carry(&v));
assert_eq!(extract(&asset), Err(Error::NotFound));
}
#[test]
fn payload_larger_than_the_length_field_is_rejected() {
assert!(u32::try_from(u32::MAX as usize).is_ok());
assert!(u32::try_from(u32::MAX as usize + 1).is_err());
}
#[test]
fn legitimate_selectors_in_clean_text_are_not_payloads() {
let clean = [
"A perfectly ordinary paragraph with no hidden provenance whatsoever.",
"Emoji carry legitimate variation selectors: a smiley \u{263A}\u{FE0F} and a heart \u{2764}\u{FE0F}.",
"CJK ideographic variation sequence: \u{845B}\u{E0100} is a valid rendering hint.",
"A stray zero-width joiner \u{200D} and no-break space \u{FEFF} without any magic.",
"\u{FEFF}A leading byte-order mark followed by ordinary prose.",
"\u{FEFF}\u{FE00}\u{FE01}",
"",
];
for s in clean {
assert_eq!(
extract(s),
Err(Error::NotFound),
"hallucinated provenance in {s:?}"
);
assert!(locate_all(s).is_empty());
}
}
#[test]
fn a_marker_inside_ordinary_text_does_not_shadow_a_real_wrapper() {
let host = "Quoting a BOM \u{FEFF} mid-sentence, and an emoji \u{2764}\u{FE0F}.";
let asset = embed(host, PAYLOAD).unwrap();
let w = extract(&asset).unwrap();
assert_eq!(w.payload, PAYLOAD);
assert_eq!(w.start, host.len());
}
#[cfg(feature = "checksum-v2")]
mod checksum_v2 {
use super::*;
use crate::hardbinding::{Algorithm, Hasher};
struct TestHasher;
impl Hasher for TestHasher {
fn digest(&self, _: Algorithm, data: &[u8]) -> Vec<u8> {
let mut acc: u32 = 0x811C_9DC5;
for &b in data {
acc = (acc ^ b as u32).wrapping_mul(0x0100_0193);
}
acc.to_be_bytes().to_vec()
}
}
#[test]
fn round_trips_and_reports_version_two() {
let asset = v2::embed(HOST, PAYLOAD, &TestHasher).unwrap();
let w = v2::extract(&asset, &TestHasher).unwrap();
assert_eq!(w.payload, PAYLOAD);
assert_eq!(w.version, VERSION_V2);
assert_eq!(strip(&asset, w.range()).unwrap(), HOST);
}
#[test]
fn a_corrupted_payload_is_rejected_rather_than_decoded() {
let asset = v2::embed(HOST, PAYLOAD, &TestHasher).unwrap();
let mut mutated = PAYLOAD.to_vec();
mutated[0] ^= 0x01;
let good = v2::encode(PAYLOAD, &TestHasher).unwrap();
let bad = v2::encode(&mutated, &TestHasher).unwrap();
let good_tail: String = good
.chars()
.rev()
.take(4)
.collect::<Vec<_>>()
.into_iter()
.rev()
.collect();
let bad_body: String = bad.chars().take(bad.chars().count() - 4).collect();
let spliced = format!("{HOST}{bad_body}{good_tail}");
assert_eq!(
v2::extract(&spliced, &TestHasher),
Err(Error::CorruptedWrapper),
"a stale checksum must fail closed"
);
assert!(!asset.is_empty());
}
#[test]
fn a_v1_wrapper_is_not_a_v2_wrapper_and_the_reverse() {
let v1 = embed(HOST, PAYLOAD).unwrap();
assert_eq!(v2::extract(&v1, &TestHasher), Err(Error::CorruptedWrapper));
let two = v2::embed(HOST, PAYLOAD, &TestHasher).unwrap();
assert_eq!(extract(&two), Err(Error::CorruptedWrapper));
}
#[test]
fn clean_text_is_still_not_a_payload() {
assert_eq!(v2::extract(HOST, &TestHasher), Err(Error::NotFound));
}
#[test]
fn extract_any_accepts_either_frame() {
let v1 = embed(HOST, PAYLOAD).unwrap();
let two = v2::embed(HOST, PAYLOAD, &TestHasher).unwrap();
for asset in [&v1, &two] {
let w = v2::extract_any(asset, &TestHasher).unwrap();
assert_eq!(w.payload, PAYLOAD);
}
assert_eq!(v2::extract_any(&v1, &TestHasher).unwrap().version, VERSION);
assert_eq!(
v2::extract_any(&two, &TestHasher).unwrap().version,
VERSION_V2
);
assert_eq!(
v2::extract_any(HOST, &TestHasher),
Err(Error::NotFound),
"clean text is absence, not corruption"
);
}
}
#[test]
fn strip_rejects_ranges_that_split_a_character() {
let asset = format!("café{}", encode(PAYLOAD).unwrap());
assert_eq!(
strip(&asset, 4..asset.len()),
Err(Error::MalformedExclusion)
);
assert_eq!(
strip(&asset, 0..asset.len() + 1),
Err(Error::MalformedExclusion)
);
}
}