use unicode_normalization::UnicodeNormalization;
use crate::{
asset_io::{
AssetIO, C2paReader, C2paWriter, ObjectLocations, ObjectType, ReadSeek, ReadWriteSeek,
},
error::{Error, Result},
utils::io_utils::{stream_len, ReaderUtils},
};
const SUPPORTED_TYPES: [&str; 2] = ["txt", "text/plain"];
const PLACEHOLDER_STORE: &[u8] = b"placeholder manifest";
mod wrapper {
use crate::error::{Error, Result};
pub(super) const MAGIC: [u8; 8] = *b"C2PATXT\0";
pub(super) const VERSION: u8 = 1;
pub(super) const MARKER: char = '\u{FEFF}';
pub(super) const HEADER_LEN: usize = 13;
pub(super) struct Wrapper {
pub(super) payload: Vec<u8>,
pub(super) start: usize,
pub(super) length: usize,
}
pub(super) fn byte_to_vs(b: u8) -> char {
let cp = if b <= 15 {
0xfe00 + b as u32
} else {
0xe0100 + (b as u32 - 16)
};
char::from_u32(cp).unwrap_or('\u{FFFD}')
}
fn vs_to_byte(c: char) -> Option<u8> {
let cp = c as u32;
if (0xfe00..=0xfe0f).contains(&cp) {
Some((cp - 0xfe00) as u8)
} else if (0xe0100..=0xe01ef).contains(&cp) {
Some((cp - 0xe0100 + 16) as u8)
} else {
None
}
}
fn decode_run(s: &str) -> (Vec<u8>, usize) {
let mut bytes = Vec::new();
let mut consumed = 0;
for c in s.chars() {
match vs_to_byte(c) {
Some(b) => {
bytes.push(b);
consumed += c.len_utf8();
}
None => break,
}
}
(bytes, consumed)
}
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
}
fn encode_with_padding(payload: &[u8], padding: &[u8]) -> Result<String> {
let len = u32::try_from(payload.len()).map_err(|_| Error::EmbeddingError)?;
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 target_length(manifest_len: usize) -> usize {
3 + (HEADER_LEN + manifest_len) * 4 + 6
}
fn padding(gap: usize) -> Result<Vec<u8>> {
if gap == 0 {
return Ok(Vec::new());
}
let b = gap % 3;
if gap < 4 * b {
return Err(Error::EmbeddingError);
}
let a = (gap - 4 * b) / 3;
let mut out = vec![0x00u8; a];
out.extend(std::iter::repeat_n(0x10u8, b));
Ok(out)
}
pub(super) fn encode_padded(payload: &[u8]) -> Result<String> {
let target = target_length(payload.len());
let base = encode_with_padding(payload, &[])?;
let gap = target
.checked_sub(base.len())
.ok_or(Error::EmbeddingError)?;
encode_with_padding(payload, &padding(gap)?)
}
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 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(),
start,
length,
})
}
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;
}
}
pub(super) 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(super) fn extract(text: &str) -> Option<Wrapper> {
let mut found = locate_all(text);
(found.len() == 1).then(|| found.remove(0))
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used, clippy::expect_used)]
use super::*;
fn embed(text: &str, payload: &[u8]) -> String {
format!("{text}{}", encode_padded(payload).unwrap())
}
#[test]
fn byte_vs_round_trips_every_value() {
for b in 0..=255u8 {
assert_eq!(vs_to_byte(byte_to_vs(b)), Some(b), "byte {b}");
}
}
#[test]
fn round_trip_locates_the_payload() {
let asset = embed("Host text.", b"payload-bytes");
let w = extract(&asset).unwrap();
assert_eq!(w.payload, b"payload-bytes");
assert_eq!(w.start, "Host text.".len());
assert_eq!(w.start + w.length, asset.len());
}
#[test]
fn decodes_a_wrapper_produced_by_the_reference_implementation() {
let asset = "hello from c2pa-text \u{feff}\u{e0133}\u{e0122}\u{e0140}\u{e0131}\u{e0144}\u{e0148}\u{e0144}\u{fe00}\u{fe01}\u{fe00}\u{fe00}\u{fe00}\u{e0100}\u{fe00}\u{fe01}\u{fe02}\u{fe03}\u{fe04}\u{fe05}\u{fe06}\u{fe07}\u{fe08}\u{fe09}\u{fe0a}\u{fe0b}\u{fe0c}\u{fe0d}\u{fe0e}\u{fe0f}";
let w = extract(asset).unwrap();
assert_eq!(w.payload, (0u8..16u8).collect::<Vec<u8>>());
}
#[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 deterministic_padding_matches_a_hand_computed_case() {
let payload = b"c2pa-manifest-01";
let unpadded = encode_with_padding(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_and_many_wrappers_both_extract_to_none() {
assert!(extract("plain text, no wrapper").is_none());
let one = embed("host", b"a");
let two = embed(&one, b"b");
assert_eq!(locate_all(&two).len(), 2);
assert!(extract(&two).is_none());
}
#[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(b"c2pa-manifest-01");
let bad = carry(&framed);
let good = encode_with_padding(b"payload", &[]).unwrap();
let asset = format!("host{bad}{good}");
let w = extract(&asset).expect("the valid wrapper is still located");
assert_eq!(w.payload, b"payload");
assert_eq!(locate_all(&asset).len(), 1);
}
#[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}.",
"A stray zero-width joiner \u{200D} and no-break space \u{FEFF} alone.",
"\u{FEFF}A leading byte-order mark followed by ordinary prose.",
"",
];
for s in clean {
assert!(extract(s).is_none(), "hallucinated provenance in {s:?}");
assert!(locate_all(s).is_empty());
}
}
}
}
fn read_text_stream(mut reader: &mut dyn ReadSeek) -> Result<String> {
reader.rewind()?;
let len = stream_len(reader)?;
let bytes = reader.read_to_vec(len)?;
String::from_utf8(bytes)
.map_err(|_| Error::InvalidAsset("text asset is not valid UTF-8".to_string()))
}
fn hash_positions(full_len: usize, region_start: usize, region_len: usize) -> Vec<ObjectLocations> {
let region_end = region_start + region_len;
vec![
ObjectLocations {
offset: region_start as u64,
length: region_len as u64,
htype: ObjectType::C2pa,
},
ObjectLocations {
offset: 0,
length: region_start as u64,
htype: ObjectType::Other,
},
ObjectLocations {
offset: region_end as u64,
length: full_len.saturating_sub(region_end) as u64,
htype: ObjectType::Other,
},
]
}
fn strip_all_wrappers(text: &str) -> String {
let wrappers = wrapper::locate_all(text);
if wrappers.is_empty() {
return text.to_string();
}
let mut out = String::with_capacity(text.len());
let mut cursor = 0;
for w in &wrappers {
out.push_str(&text[cursor..w.start]);
cursor = w.start + w.length;
}
out.push_str(&text[cursor..]);
out
}
fn normalized_clean_text(text: &str) -> String {
strip_all_wrappers(text).nfc().collect()
}
pub struct PlainTextIO {
_asset_type: String,
}
impl C2paReader for PlainTextIO {
fn read_c2pa(&self, reader: &mut dyn ReadSeek) -> Result<Vec<u8>> {
let text = read_text_stream(reader)?;
let w = wrapper::extract(&text).ok_or(Error::JumbfNotFound)?;
if w.payload.is_empty() {
return Err(Error::JumbfNotFound);
}
Ok(w.payload)
}
fn read_xmp(&self, _asset_reader: &mut dyn ReadSeek) -> Option<String> {
None
}
}
impl C2paWriter for PlainTextIO {
fn write_c2pa(
&self,
input_stream: &mut dyn ReadSeek,
output_stream: &mut dyn ReadWriteSeek,
store_bytes: &[u8],
) -> Result<()> {
let text = read_text_stream(input_stream)?;
let normalized = normalized_clean_text(&text);
let framed = wrapper::encode_padded(store_bytes)?;
output_stream.rewind()?;
output_stream.write_all(normalized.as_bytes())?;
output_stream.write_all(framed.as_bytes())?;
Ok(())
}
fn get_object_locations(
&self,
input_stream: &mut dyn ReadSeek,
) -> Result<Vec<ObjectLocations>> {
let text = read_text_stream(input_stream)?;
let (full_len, region_start, region_len) = match wrapper::extract(&text) {
Some(w) => (text.len(), w.start, w.length),
None => {
let normalized = normalized_clean_text(&text);
let framed = wrapper::encode_padded(PLACEHOLDER_STORE)?;
let with_wrapper = format!("{normalized}{framed}");
let w = wrapper::extract(&with_wrapper).ok_or(Error::EmbeddingError)?;
(with_wrapper.len(), w.start, w.length)
}
};
Ok(hash_positions(full_len, region_start, region_len))
}
fn remove_c2pa(
&self,
input_stream: &mut dyn ReadSeek,
output_stream: &mut dyn ReadWriteSeek,
) -> Result<()> {
let text = read_text_stream(input_stream)?;
let cleaned = strip_all_wrappers(&text);
output_stream.rewind()?;
output_stream.write_all(cleaned.as_bytes())?;
Ok(())
}
}
impl AssetIO for PlainTextIO {
fn new(asset_type: &str) -> Self {
PlainTextIO {
_asset_type: asset_type.to_string(),
}
}
fn get_handler(&self, asset_type: &str) -> Box<dyn AssetIO> {
Box::new(PlainTextIO::new(asset_type))
}
fn get_reader(&self) -> &dyn C2paReader {
self
}
fn get_writer(&self, asset_type: &str) -> Option<Box<dyn C2paWriter>> {
Some(Box::new(PlainTextIO::new(asset_type)))
}
fn supported_types(&self) -> &[&str] {
&SUPPORTED_TYPES
}
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used)]
use std::io::Cursor;
use super::*;
use crate::asset_io::ObjectType;
fn embed(source: &str, store: &[u8]) -> String {
let io = PlainTextIO::new("txt");
let mut input = Cursor::new(source.as_bytes().to_vec());
let mut output = Cursor::new(Vec::new());
io.write_c2pa(&mut input, &mut output, store).unwrap();
String::from_utf8(output.into_inner()).unwrap()
}
fn read_back(text: &str) -> Result<Vec<u8>> {
let io = PlainTextIO::new("txt");
let mut input = Cursor::new(text.as_bytes().to_vec());
io.read_c2pa(&mut input)
}
#[test]
fn round_trips_a_plain_asset() {
let out = embed("Hello, provenance.", b"manifest store bytes");
assert_eq!(read_back(&out).unwrap(), b"manifest store bytes");
}
#[test]
fn unsigned_text_has_no_manifest() {
assert!(matches!(
read_back("Just an ordinary sentence."),
Err(Error::JumbfNotFound)
));
}
#[test]
fn object_locations_exclude_exactly_the_wrapper() {
let out = embed("Visible content.", b"store");
let io = PlainTextIO::new("txt");
let mut cursor = Cursor::new(out.clone().into_bytes());
let locations = io.get_object_locations(&mut cursor).unwrap();
let cai = locations
.iter()
.find(|p| p.htype == ObjectType::C2pa)
.unwrap();
assert_eq!(cai.offset, "Visible content.".len() as u64);
assert_eq!(cai.offset + cai.length, out.len() as u64);
let other_before = locations
.iter()
.find(|p| p.htype == ObjectType::Other && p.offset == 0)
.unwrap();
assert_eq!(other_before.length, "Visible content.".len() as u64);
}
#[test]
fn object_locations_with_no_manifest_yet_still_report_a_cai_region() {
let io = PlainTextIO::new("txt");
let mut cursor = Cursor::new(b"No manifest here.".to_vec());
let locations = io.get_object_locations(&mut cursor).unwrap();
let cai = locations
.iter()
.find(|p| p.htype == ObjectType::C2pa)
.unwrap();
assert!(cai.length > 0);
assert_eq!(cai.offset, "No manifest here.".len() as u64);
}
#[test]
fn replace_strips_the_old_wrapper_and_embeds_the_new_one() {
let first = embed("Body text.", b"first store");
let second = embed(&first, b"second store");
assert_eq!(read_back(&second).unwrap(), b"second store");
assert_eq!(wrapper::locate_all(&second).len(), 1);
}
#[test]
fn remove_strips_the_wrapper_and_leaves_visible_text_untouched() {
let out = embed("Body text.", b"store");
let io = PlainTextIO::new("txt");
let mut input = Cursor::new(out.into_bytes());
let mut output = Cursor::new(Vec::new());
io.remove_c2pa(&mut input, &mut output).unwrap();
assert_eq!(
String::from_utf8(output.into_inner()).unwrap(),
"Body text."
);
}
#[test]
fn nfc_and_nfd_input_produce_the_same_stored_visible_bytes() {
let nfc_host = "caf\u{00E9} r\u{00E9}sum\u{00E9}";
let nfd_host = "cafe\u{0301} re\u{0301}sume\u{0301}";
assert_ne!(
nfc_host.as_bytes(),
nfd_host.as_bytes(),
"fixture must actually differ"
);
let out_from_nfc = embed(nfc_host, b"store");
let out_from_nfd = embed(nfd_host, b"store");
let visible = |asset: &str| {
let w = wrapper::extract(asset).unwrap();
asset[..w.start].to_string()
};
assert_eq!(visible(&out_from_nfc), visible(&out_from_nfd));
assert_eq!(visible(&out_from_nfc), nfc_host);
}
#[test]
fn a_lone_corrupted_candidate_is_left_as_ordinary_content_on_write() {
let mut framed = wrapper::MAGIC.to_vec();
framed.push(wrapper::VERSION);
framed.extend_from_slice(&99u32.to_be_bytes()); framed.push(0x41);
let junk = format!(
"{}{}",
wrapper::MARKER,
framed
.iter()
.map(|&b| wrapper::byte_to_vs(b))
.collect::<String>()
);
let src = format!("Body{junk}");
let out = embed(&src, b"store");
assert_eq!(read_back(&out).unwrap(), b"store");
assert!(
out.contains(&junk),
"the corrupted candidate must survive untouched, not be silently dropped"
);
}
#[test]
fn wrong_extension_still_supports_plain_text_type() {
let io = PlainTextIO::new("text/plain");
assert!(io.supported_types().contains(&"text/plain"));
assert!(io.supported_types().contains(&"txt"));
}
}