use crate::c2pa_formats::{AssetFormat, FormatError};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TextMethod {
Unstructured,
Structured,
Html,
}
impl TextMethod {
fn fmt(self) -> AssetFormat {
match self {
TextMethod::Unstructured => AssetFormat::TextUnstructured,
TextMethod::Structured => AssetFormat::TextStructured {
comment_prefix: "",
comment_suffix: "",
},
TextMethod::Html => AssetFormat::TextHtml,
}
}
fn invalid_utf8(self) -> FormatError {
FormatError::InvalidStructure {
format: self.fmt(),
detail: "text asset is not valid UTF-8",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum TextDiagnostic {
CorruptedWrapper,
StructuredNoManifest,
StructuredEmptyReference,
StructuredMalformedReference,
StructuredMultipleReferences,
StructuredNoResolutionPath,
}
impl TextDiagnostic {
pub(crate) fn code(self) -> &'static str {
match self {
Self::CorruptedWrapper => "manifest.text.corruptedWrapper",
Self::StructuredNoManifest => "manifest.structuredText.noManifest",
Self::StructuredEmptyReference => "manifest.structuredText.emptyReference",
Self::StructuredMalformedReference => "manifest.structuredText.malformedReference",
Self::StructuredMultipleReferences => "manifest.structuredText.multipleReferences",
Self::StructuredNoResolutionPath => "manifest.structuredText.noResolutionPath",
}
}
}
fn uri_shape_valid(reference: &str) -> bool {
let Some((scheme, rest)) = reference.split_once(':') else {
return false;
};
!scheme.is_empty()
&& scheme.bytes().enumerate().all(|(index, byte)| {
byte.is_ascii_alphabetic()
|| (index > 0 && (byte.is_ascii_digit() || matches!(byte, b'+' | b'-' | b'.')))
})
&& !rest.is_empty()
&& !reference.bytes().any(|byte| byte.is_ascii_whitespace())
}
#[derive(Debug, Clone, Copy)]
struct UnstructuredWrapper {
start: usize,
length: usize,
manifest_start: usize,
manifest_length: usize,
}
fn unstructured_wrappers(text: &str) -> Vec<UnstructuredWrapper> {
const HEADER_LEN: usize = 13;
const MAGIC: &[u8; 8] = b"C2PATXT\0";
const VERSION: u8 = 1;
let mut wrappers = Vec::new();
let mut search_from = 0usize;
while let Some(relative) = text[search_from..].find('\u{feff}') {
let start = search_from + relative;
let selectors_start = start + '\u{feff}'.len_utf8();
let mut run_end = selectors_start;
let mut selector_count = 0usize;
let mut header = [0u8; HEADER_LEN];
for character in text[selectors_start..].chars() {
let Some(byte) = crate::c2pa_formats::text_standard::vs_to_byte(character) else {
break;
};
if selector_count < HEADER_LEN {
header[selector_count] = byte;
}
selector_count += 1;
run_end += character.len_utf8();
}
search_from = run_end.max(selectors_start);
if selector_count < HEADER_LEN || &header[..8] != MAGIC || header[8] != VERSION {
continue;
}
let manifest_length =
u32::from_be_bytes([header[9], header[10], header[11], header[12]]) as usize;
if manifest_length > crate::MAX_MANIFEST_STORE_BYTES
|| selector_count < HEADER_LEN + manifest_length
{
continue;
}
let mut position = selectors_start;
let mut manifest_start = selectors_start;
for (index, character) in text[selectors_start..].chars().enumerate() {
if index == HEADER_LEN {
manifest_start = position;
}
if index == HEADER_LEN + manifest_length {
break;
}
position += character.len_utf8();
}
let padded_bound = start
.checked_add(c2pa_text::worst_case_wrapper_byte_length(manifest_length))
.unwrap_or(usize::MAX);
for character in text[position..run_end].chars() {
match crate::c2pa_formats::text_standard::vs_to_byte(character) {
Some(0x00 | 0xff) if position + character.len_utf8() <= padded_bound => {
position += character.len_utf8();
}
_ => break,
}
}
wrappers.push(UnstructuredWrapper {
start,
length: position - start,
manifest_start,
manifest_length,
});
if search_from <= start {
search_from = selectors_start;
}
}
wrappers
}
pub(crate) fn unstructured_wrapper_spans(
data: &[u8],
) -> Result<Vec<DataHashExclusion>, FormatError> {
let text = std::str::from_utf8(data).map_err(|_| TextMethod::Unstructured.invalid_utf8())?;
Ok(unstructured_wrappers(text)
.into_iter()
.map(|wrapper| DataHashExclusion {
start: wrapper.start,
length: wrapper.length,
})
.collect())
}
pub(crate) fn diagnostic(method: TextMethod, data: &[u8]) -> Option<TextDiagnostic> {
let text = match std::str::from_utf8(data) {
Ok(text) => text,
Err(_) => return None,
};
match method {
TextMethod::Unstructured => {
let validation = c2pa_text::validate_text(text);
match validation.primary_code() {
c2pa_text::ValidationCode::Valid => None,
c2pa_text::ValidationCode::CorruptedWrapper => {
Some(TextDiagnostic::CorruptedWrapper)
}
c2pa_text::ValidationCode::MultipleWrappers => None,
}
}
TextMethod::Structured => match c2pa_text::structured::extract_structured(text) {
Err(c2pa_text::structured::StructuredError::NoManifest) => {
Some(TextDiagnostic::StructuredNoManifest)
}
Err(c2pa_text::structured::StructuredError::EmptyReference) => {
Some(TextDiagnostic::StructuredEmptyReference)
}
Err(c2pa_text::structured::StructuredError::MultipleReferences) => {
Some(TextDiagnostic::StructuredMultipleReferences)
}
Ok(extracted) if extracted.manifest.is_some() => None,
Ok(extracted) if extracted.reference.starts_with("data:") => {
Some(TextDiagnostic::StructuredMalformedReference)
}
Ok(extracted) if !uri_shape_valid(&extracted.reference) => {
Some(TextDiagnostic::StructuredMalformedReference)
}
Ok(_) => Some(TextDiagnostic::StructuredNoResolutionPath),
},
TextMethod::Html => None,
}
}
pub(crate) fn extract(method: TextMethod, data: &[u8]) -> Result<Option<Vec<u8>>, FormatError> {
let text = std::str::from_utf8(data).map_err(|_| method.invalid_utf8())?;
match method {
TextMethod::Unstructured => {
let Some(wrapper) = unstructured_wrappers(text).into_iter().next() else {
return Ok(None);
};
let manifest = text[wrapper.manifest_start..]
.chars()
.take(wrapper.manifest_length)
.filter_map(crate::c2pa_formats::text_standard::vs_to_byte)
.collect();
Ok(Some(manifest))
}
TextMethod::Structured => match c2pa_text::structured::extract_structured(text) {
Ok(ext) => Ok(ext.manifest),
Err(_) => Ok(None),
},
TextMethod::Html => match c2pa_text::html::extract_html(text) {
Ok(Some(ext)) => Ok(ext.manifest),
Ok(None) => Ok(None),
Err(_) => Ok(None),
},
}
}
#[cfg(test)]
pub(crate) fn embed(
method: TextMethod,
asset: &[u8],
manifest_store: &[u8],
) -> Result<Vec<u8>, FormatError> {
let text = std::str::from_utf8(asset).map_err(|_| method.invalid_utf8())?;
let out = match method {
TextMethod::Unstructured => {
use unicode_normalization::UnicodeNormalization;
let normalized: String = text.nfc().collect();
let target = c2pa_text::worst_case_wrapper_byte_length(manifest_store.len());
let wrapper =
c2pa_text::encode_wrapper_padded(manifest_store, target).map_err(|_| {
FormatError::InvalidStructure {
format: AssetFormat::TextUnstructured,
detail: "padded wrapper encoding failed",
}
})?;
format!("{normalized}{wrapper}")
}
TextMethod::Structured => structured_text(text, manifest_store, "", ""),
TextMethod::Html => {
c2pa_text::html::embed_html_inline(text, manifest_store, "\n")
.map_err(|_| FormatError::InvalidStructure {
format: AssetFormat::TextHtml,
detail: "html inline embed failed (document has no </head>)",
})?
.html
}
};
Ok(out.into_bytes())
}
#[cfg(test)]
fn structured_text(
text: &str,
manifest_store: &[u8],
comment_prefix: &str,
comment_suffix: &str,
) -> String {
let reference = c2pa_text::structured::encode_data_uri(manifest_store);
c2pa_text::structured::embed_structured(
text,
&reference,
comment_prefix,
comment_suffix,
c2pa_text::structured::Placement::End,
"\n",
)
.text
}
#[cfg(test)]
pub(crate) fn embed_structured(
asset: &[u8],
manifest_store: &[u8],
comment_prefix: &str,
comment_suffix: &str,
) -> Result<Vec<u8>, FormatError> {
let text = std::str::from_utf8(asset).map_err(|_| TextMethod::Structured.invalid_utf8())?;
Ok(structured_text(text, manifest_store, comment_prefix, comment_suffix).into_bytes())
}
use crate::c2pa_formats::DataHashExclusion;
pub(crate) fn exclusions(
method: TextMethod,
data: &[u8],
) -> Result<Vec<DataHashExclusion>, FormatError> {
let text = std::str::from_utf8(data).map_err(|_| method.invalid_utf8())?;
let range = match method {
TextMethod::Unstructured => unstructured_wrappers(text)
.into_iter()
.next()
.map(|wrapper| (wrapper.start, wrapper.length)),
TextMethod::Structured => structured_block_span(text),
TextMethod::Html => locate_span(text, "<script type=\"application/c2pa\"", "</script>"),
};
Ok(range
.into_iter()
.map(|(start, length)| DataHashExclusion { start, length })
.collect())
}
fn locate_span(text: &str, begin: &str, end: &str) -> Option<(usize, usize)> {
let start = text.find(begin)?;
let after_begin = start + begin.len();
let end_rel = text[after_begin..].find(end)?;
let end_abs = after_begin + end_rel + end.len();
Some((start, end_abs - start))
}
fn structured_block_span(text: &str) -> Option<(usize, usize)> {
let (start, len) = locate_span(
text,
c2pa_text::structured::BEGIN_DELIMITER,
c2pa_text::structured::END_DELIMITER,
)?;
let bytes = text.as_bytes();
let mut block_start = start;
while block_start > 0 && bytes[block_start - 1] != b'\n' {
block_start -= 1;
}
let mut block_end = start + len;
while block_end < bytes.len() && bytes[block_end] != b'\n' {
block_end += 1;
}
Some((block_start, block_end - block_start))
}
#[cfg(test)]
mod tests {
use super::*;
fn fake_store() -> Vec<u8> {
(0u8..64)
.map(|i| i.wrapping_mul(7).wrapping_add(3))
.collect()
}
#[test]
fn unstructured_diagnostics_reject_corrupt_but_defer_multiple_wrappers() {
let mut corrupted = c2pa_text::encode_wrapper(&[1, 2, 3]);
corrupted.pop();
assert_eq!(
diagnostic(TextMethod::Unstructured, corrupted.as_bytes()),
Some(TextDiagnostic::CorruptedWrapper)
);
let wrapper = c2pa_text::encode_wrapper(&[1]);
let multiple = format!("{wrapper}text{wrapper}");
assert_eq!(
diagnostic(TextMethod::Unstructured, multiple.as_bytes()),
None,
"the data-hash exclusions select the matching wrapper"
);
}
#[test]
fn structured_diagnostics_keep_failure_reasons_distinct() {
use c2pa_text::structured::{BEGIN_DELIMITER as BEGIN, END_DELIMITER as END};
assert_eq!(
diagnostic(TextMethod::Structured, b"no manifest"),
Some(TextDiagnostic::StructuredNoManifest)
);
assert_eq!(
diagnostic(
TextMethod::Structured,
format!("{BEGIN} {END}").as_bytes()
),
Some(TextDiagnostic::StructuredEmptyReference)
);
assert_eq!(
diagnostic(
TextMethod::Structured,
format!("{BEGIN} not a uri {END}").as_bytes()
),
Some(TextDiagnostic::StructuredMalformedReference)
);
assert_eq!(
diagnostic(
TextMethod::Structured,
format!("{BEGIN} https://example.invalid/manifest.c2pa {END}").as_bytes()
),
Some(TextDiagnostic::StructuredNoResolutionPath)
);
assert_eq!(
diagnostic(
TextMethod::Structured,
format!("{BEGIN} data:application/c2pa;base64,AA== {END}{BEGIN} data:application/c2pa;base64,AQ== {END}")
.as_bytes()
),
Some(TextDiagnostic::StructuredMultipleReferences)
);
}
fn unhex(s: &str) -> Vec<u8> {
(0..s.len())
.step_by(2)
.map(|i| u8::from_str_radix(&s[i..i + 2], 16).unwrap())
.collect()
}
#[test]
fn golden_unstructured_ascii_small() {
let manifest = unhex("deadbeef");
let expected = unhex("68656c6c6f20776f726c64efbbbff3a084b3f3a084a2f3a08580f3a084b1f3a08584f3a08588f3a08584efb880efb881efb880efb880efb880efb884f3a0878ef3a0869df3a086aef3a0879f");
let ssot = c2pa_text::embed_manifest("hello world", &manifest);
assert_eq!(
ssot.into_bytes(),
expected,
"SSOT VS wrapper must match golden"
);
let engine = embed(TextMethod::Unstructured, b"hello world", &manifest).unwrap();
assert_eq!(
extract(TextMethod::Unstructured, &engine).unwrap(),
Some(manifest)
);
}
#[test]
fn golden_html_inline_small() {
let manifest = unhex("deadbeef");
let html = "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\">\n<title>Example</title>\n</head>\n<body>\n<p>Content here.</p>\n</body>\n</html>\n";
let expected = unhex("3c21444f43545950452068746d6c3e0a3c68746d6c206c616e673d22656e223e0a3c686561643e0a3c6d65746120636861727365743d227574662d38223e0a3c7469746c653e4578616d706c653c2f7469746c653e0a3c73637269707420747970653d226170706c69636174696f6e2f63327061223e3371322b37773d3d3c2f7363726970743e0a3c2f686561643e0a3c626f64793e0a3c703e436f6e74656e7420686572652e3c2f703e0a3c2f626f64793e0a3c2f68746d6c3e0a");
let out = embed(TextMethod::Html, html.as_bytes(), &manifest).unwrap();
assert_eq!(
out, expected,
"HTML inline embed must match c2pa-text golden"
);
}
#[test]
fn unstructured_round_trips() {
let store = fake_store();
let embedded = embed(TextMethod::Unstructured, b"a social post", &store).unwrap();
let out = extract(TextMethod::Unstructured, &embedded).unwrap();
assert_eq!(out, Some(store));
assert!(String::from_utf8(embedded)
.unwrap()
.starts_with("a social post"));
}
#[test]
fn structured_round_trips() {
let store = fake_store();
let src = "# Title\n\nsome markdown body\n";
let embedded = embed(TextMethod::Structured, src.as_bytes(), &store).unwrap();
let text = String::from_utf8(embedded.clone()).unwrap();
assert!(text.contains("BEGIN C2PA MANIFEST"));
let out = extract(TextMethod::Structured, &embedded).unwrap();
assert_eq!(out, Some(store));
}
#[test]
fn html_round_trips() {
let store = fake_store();
let html = "<html><head><title>x</title></head><body>hi</body></html>";
let embedded = embed(TextMethod::Html, html.as_bytes(), &store).unwrap();
let text = String::from_utf8(embedded.clone()).unwrap();
assert!(text.contains("application/c2pa"));
let out = extract(TextMethod::Html, &embedded).unwrap();
assert_eq!(out, Some(store));
}
#[test]
fn no_manifest_is_none_not_error() {
assert_eq!(
extract(TextMethod::Unstructured, b"plain text").unwrap(),
None
);
assert_eq!(
extract(TextMethod::Structured, b"plain text").unwrap(),
None
);
assert_eq!(extract(TextMethod::Html, b"<html></html>").unwrap(), None);
}
#[test]
fn invalid_utf8_errors() {
assert!(extract(TextMethod::Unstructured, &[0xff, 0xfe]).is_err());
}
#[test]
fn structured_comment_wrapped_round_trips() {
let store = fake_store();
let src = "body { color: red }\n";
let embedded = embed_structured(src.as_bytes(), &store, "/*", "*/").unwrap();
let text = String::from_utf8(embedded.clone()).unwrap();
assert!(text.contains("/* -----BEGIN C2PA MANIFEST-----"));
assert!(text.trim_end().ends_with("*/"));
assert!(text.starts_with(src));
assert_eq!(
extract(TextMethod::Structured, &embedded).unwrap(),
Some(store)
);
let ex = exclusions(TextMethod::Structured, &embedded).unwrap();
assert_eq!(ex.len(), 1);
let excluded = &embedded[ex[0].start..ex[0].start + ex[0].length];
assert!(excluded.starts_with(b"/* -----BEGIN C2PA MANIFEST-----"));
assert!(excluded.ends_with(b"*/"));
assert!(ex[0].start >= src.len());
assert_eq!(&embedded[..src.len()], src.as_bytes());
}
}