use crate::{
asset_handlers::text_common::{
self, encode_data_uri, parse_manifest_reference, ManifestReference, BEGIN_DELIMITER,
END_DELIMITER,
},
asset_io::{AssetIO, C2paReader, C2paWriter, ObjectLocations, ReadSeek, ReadWriteSeek},
error::{Error, Result},
};
#[derive(Clone, Copy)]
enum CommentStyle {
Line(&'static str),
Block(&'static str, &'static str),
}
static SUPPORTED_TYPES: [&str; 28] = [
"atom",
"application/atom+xml",
"css",
"text/css",
"ini",
"js",
"mjs",
"text/javascript",
"application/javascript",
"md",
"markdown",
"text/markdown",
"py",
"text/x-python",
"rss",
"application/rss+xml",
"sql",
"application/sql",
"tex",
"application/x-tex",
"toml",
"application/toml",
"vtt",
"text/vtt",
"yaml",
"yml",
"application/yaml",
"text/yaml",
];
const PLACEHOLDER_STORE: &[u8] = b"placeholder manifest";
fn comment_style(asset_type: &str) -> Option<CommentStyle> {
Some(match asset_type.to_lowercase().as_str() {
"md"
| "markdown"
| "text/markdown"
| "rss"
| "application/rss+xml"
| "atom"
| "application/atom+xml" => CommentStyle::Block("<!--", "-->"),
"yaml" | "yml" | "application/yaml" | "text/yaml" | "toml" | "application/toml" | "py"
| "text/x-python" => CommentStyle::Line("#"),
"ini" => CommentStyle::Line(";"),
"js" | "mjs" | "text/javascript" | "application/javascript" => CommentStyle::Line("//"),
"css" | "text/css" => CommentStyle::Block("/*", "*/"),
"sql" | "application/sql" => CommentStyle::Line("--"),
"tex" | "application/x-tex" => CommentStyle::Line("%"),
"vtt" | "text/vtt" => CommentStyle::Line("NOTE"),
_ => return None,
})
}
pub struct StructuredTextIO {
asset_type: String,
}
struct BlockInfo {
start: usize,
end: usize,
reference: String,
multiline: bool,
}
fn find_blocks(text: &str) -> Vec<BlockInfo> {
let mut blocks = Vec::new();
let mut search = 0;
while let Some(rel) = text[search..].find(BEGIN_DELIMITER) {
let begin = search + rel;
let after_begin = begin + BEGIN_DELIMITER.len();
let Some(end_rel) = text[after_begin..].find(END_DELIMITER) else {
break;
};
let end_delim_start = after_begin + end_rel;
let past_end = end_delim_start + END_DELIMITER.len();
let reference = text[after_begin..end_delim_start].trim();
if text_common::is_manifest_reference(reference) {
let line_start = text[..begin].rfind('\n').map(|i| i + 1).unwrap_or(0);
let line_end = text[past_end..]
.find('\n')
.map(|i| past_end + i + 1)
.unwrap_or(text.len());
blocks.push(BlockInfo {
start: line_start,
end: line_end,
reference: reference.to_string(),
multiline: text[after_begin..end_delim_start].contains('\n'),
});
}
search = past_end;
}
blocks
}
fn count_blocks(text: &str) -> usize {
find_blocks(text).len()
}
fn locate_block(text: &str) -> Option<BlockInfo> {
find_blocks(text).into_iter().next()
}
fn strip_blocks(text: &str) -> String {
let blocks = find_blocks(text);
if blocks.is_empty() {
return text.to_string();
}
let mut out = String::with_capacity(text.len());
let mut cursor = 0;
for b in &blocks {
out.push_str(&text[cursor..b.start]);
cursor = b.end;
}
out.push_str(&text[cursor..]);
out
}
fn exclusion_span(text: &str, block: &BlockInfo) -> (usize, usize) {
let mut start = block.start;
if block.end == text.len() && start > 0 {
let bytes = text.as_bytes();
if bytes[start - 1] == b'\n' {
start -= 1;
if start > 0 && bytes[start - 1] == b'\r' {
start -= 1;
}
}
}
(start, block.end - start)
}
fn build_block_line(style: CommentStyle, reference: &str) -> String {
match style {
CommentStyle::Line(prefix) => {
format!("{prefix} {BEGIN_DELIMITER} {reference} {END_DELIMITER}")
}
CommentStyle::Block(prefix, suffix) => {
format!("{prefix} {BEGIN_DELIMITER} {reference} {END_DELIMITER} {suffix}")
}
}
}
fn build_multiline_block(reference: &str, le: &str) -> String {
format!("{BEGIN_DELIMITER}{le}{reference}{le}{END_DELIMITER}")
}
fn supports_front_matter(lower_asset_type: &str) -> bool {
matches!(lower_asset_type, "md" | "markdown" | "text/markdown")
}
fn front_matter_insert_point(text: &str) -> Option<usize> {
let fence = ["---", "+++"].into_iter().find(|f| text.starts_with(f))?;
let first_nl = text.find('\n')?;
if text[..first_nl].trim_end() != fence {
return None;
}
let after_open = first_nl + 1;
text[after_open..]
.split_inclusive('\n')
.any(|line| line.trim_end() == fence)
.then_some(after_open)
}
fn insert_block(cleaned: &str, reference: &str, style: CommentStyle, asset_type: &str) -> String {
let le = if cleaned.contains("\r\n") {
"\r\n"
} else {
"\n"
};
let lower = asset_type.to_lowercase();
if supports_front_matter(&lower) {
if let Some(at) = front_matter_insert_point(cleaned) {
let block = build_multiline_block(reference, le);
return format!("{}{block}{le}{}", &cleaned[..at], &cleaned[at..]);
}
}
let block_line = build_block_line(style, reference);
if lower == "vtt" || lower == "text/vtt" || cleaned.starts_with("WEBVTT") {
if let Some(nl) = cleaned.find('\n') {
let (head, rest) = cleaned.split_at(nl + 1);
let rest = rest.trim_start_matches(['\r', '\n']);
return format!("{head}{le}{block_line}{le}{le}{rest}");
}
}
let trimmed = cleaned.trim_start();
if trimmed.starts_with("#!") || trimmed.starts_with("<?xml") {
let base = cleaned.trim_end_matches(['\r', '\n']);
return format!("{base}{le}{block_line}");
}
format!("{block_line}{le}{cleaned}")
}
impl C2paReader for StructuredTextIO {
fn read_c2pa(&self, reader: &mut dyn ReadSeek) -> Result<Vec<u8>> {
let text = text_common::read_text_stream(reader)?;
if count_blocks(&text) != 1 {
return Err(Error::JumbfNotFound);
}
let block = locate_block(&text).ok_or(Error::JumbfNotFound)?;
match parse_manifest_reference(&block.reference)? {
ManifestReference::External => Err(Error::JumbfNotFound),
ManifestReference::Embedded(bytes) if !bytes.is_empty() => Ok(bytes),
ManifestReference::Embedded(_) => Err(Error::JumbfNotFound),
}
}
fn read_xmp(&self, _asset_reader: &mut dyn ReadSeek) -> Option<String> {
None
}
}
impl C2paWriter for StructuredTextIO {
fn write_c2pa(
&self,
input_stream: &mut dyn ReadSeek,
output_stream: &mut dyn ReadWriteSeek,
store_bytes: &[u8],
) -> Result<()> {
let style = comment_style(&self.asset_type).ok_or(Error::UnsupportedType)?;
let text = text_common::read_text_stream(input_stream)?;
let reference = encode_data_uri(store_bytes);
let result = if count_blocks(&text) == 1 {
let b = locate_block(&text).ok_or(Error::EmbeddingError)?;
let bytes = text.as_bytes();
let terminator = if b.end >= 2 && &bytes[b.end - 2..b.end] == b"\r\n" {
"\r\n"
} else if b.end >= 1 && bytes[b.end - 1] == b'\n' {
"\n"
} else {
""
};
let replacement = if b.multiline {
let le = if terminator.is_empty() {
"\n"
} else {
terminator
};
build_multiline_block(&reference, le)
} else {
build_block_line(style, &reference)
};
let mut out = String::with_capacity(text.len() + replacement.len());
out.push_str(&text[..b.start]);
out.push_str(&replacement);
out.push_str(terminator);
out.push_str(&text[b.end..]);
out
} else {
insert_block(&strip_blocks(&text), &reference, style, &self.asset_type)
};
output_stream.rewind()?;
output_stream.write_all(result.as_bytes())?;
Ok(())
}
fn get_object_locations(
&self,
input_stream: &mut dyn ReadSeek,
) -> Result<Vec<ObjectLocations>> {
let text = text_common::read_text_stream(input_stream)?;
let (full_len, block_start, block_len) = match locate_block(&text) {
Some(b) => {
let (start, len) = exclusion_span(&text, &b);
(text.len(), start, len)
}
None => {
let style = comment_style(&self.asset_type).ok_or(Error::UnsupportedType)?;
let reference = encode_data_uri(PLACEHOLDER_STORE);
let with_block = insert_block(&text, &reference, style, &self.asset_type);
let b = locate_block(&with_block).ok_or(Error::EmbeddingError)?;
let (start, len) = exclusion_span(&with_block, &b);
(with_block.len(), start, len)
}
};
Ok(text_common::hash_positions(
full_len,
block_start,
block_len,
))
}
fn remove_c2pa(
&self,
input_stream: &mut dyn ReadSeek,
output_stream: &mut dyn ReadWriteSeek,
) -> Result<()> {
let text = text_common::read_text_stream(input_stream)?;
let cleaned = strip_blocks(&text);
output_stream.rewind()?;
output_stream.write_all(cleaned.as_bytes())?;
Ok(())
}
}
impl AssetIO for StructuredTextIO {
fn new(asset_type: &str) -> Self {
StructuredTextIO {
asset_type: asset_type.to_string(),
}
}
fn get_handler(&self, asset_type: &str) -> Box<dyn AssetIO> {
Box::new(StructuredTextIO::new(asset_type))
}
fn get_reader(&self) -> &dyn C2paReader {
self
}
fn get_writer(&self, asset_type: &str) -> Option<Box<dyn C2paWriter>> {
Some(Box::new(StructuredTextIO::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(asset_type: &str, source: &str, store: &[u8]) -> String {
let io = StructuredTextIO::new(asset_type);
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(asset_type: &str, text: &str) -> Result<Vec<u8>> {
let io = StructuredTextIO::new(asset_type);
let mut input = Cursor::new(text.as_bytes().to_vec());
io.read_c2pa(&mut input)
}
#[test]
fn markdown_uses_html_comment_and_round_trips() {
let store = b"markdown store bytes";
let out = embed("md", "# Title\n\nBody text.\n", store);
assert!(out.contains("<!-- -----BEGIN C2PA MANIFEST-----"));
assert!(out.contains("-----END C2PA MANIFEST----- -->"));
assert_eq!(read_back("md", &out).unwrap(), store);
}
#[test]
fn yaml_uses_hash_comment() {
let out = embed("yaml", "key: value\n", b"yaml store");
assert!(out.starts_with("# -----BEGIN C2PA MANIFEST-----"));
assert_eq!(read_back("yaml", &out).unwrap(), b"yaml store");
}
#[test]
fn full_range_covers_more_than_md_yaml_toml() {
for (t, needle) in [
("js", "// -----BEGIN"),
("css", "/* -----BEGIN"),
("sql", "-- -----BEGIN"),
("tex", "% -----BEGIN"),
("py", "# -----BEGIN"),
] {
let out = embed(t, "content\n", b"store");
assert!(out.contains(needle), "{t} should use {needle}");
assert_eq!(read_back(t, &out).unwrap(), b"store");
}
}
#[test]
fn shebang_forces_block_to_end_of_file() {
let out = embed("py", "#!/usr/bin/env python\nprint('hi')\n", b"store");
assert!(out.starts_with("#!/usr/bin/env python"));
assert!(out.trim_end().ends_with("-----END C2PA MANIFEST-----"));
assert_eq!(read_back("py", &out).unwrap(), b"store");
}
#[test]
fn end_placed_block_exclusion_starts_at_preceding_newline() {
let out = embed("py", "#!/usr/bin/env python\nprint('hi')\n", b"store");
let io = StructuredTextIO::new("py");
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();
let excluded =
&out.as_bytes()[(cai.offset as usize)..(cai.offset as usize) + (cai.length as usize)];
assert_eq!(
excluded[0], b'\n',
"exclusion must begin at the preceding newline"
);
assert_eq!(
(cai.offset as usize) + (cai.length as usize),
out.len(),
"exclusion runs to EOF"
);
let excluded = std::str::from_utf8(excluded).unwrap();
assert!(excluded.contains(BEGIN_DELIMITER) && excluded.contains(END_DELIMITER));
}
#[test]
fn webvtt_block_follows_header() {
let out = embed(
"vtt",
"WEBVTT\n\n00:00:00.000 --> 00:00:05.000\nHello.\n",
b"vtt store",
);
assert!(out.starts_with("WEBVTT"));
assert!(out.contains("NOTE -----BEGIN C2PA MANIFEST-----"));
assert_eq!(read_back("vtt", &out).unwrap(), b"vtt store");
}
#[test]
fn external_url_reference_is_not_an_embedded_store() {
let text = "// -----BEGIN C2PA MANIFEST----- https://example.com/a.c2pa -----END C2PA MANIFEST-----\ncode\n";
assert!(matches!(read_back("js", text), Err(Error::JumbfNotFound)));
}
#[test]
fn multiple_blocks_treated_as_no_manifest() {
let mut text = embed("yaml", "a: 1\n", b"first");
text.push_str("# -----BEGIN C2PA MANIFEST----- data:application/c2pa;base64,AAAA -----END C2PA MANIFEST-----\n");
assert!(matches!(
read_back("yaml", &text),
Err(Error::JumbfNotFound)
));
}
#[test]
fn replace_and_remove() {
let first = embed("toml", "a = 1\n", b"first store");
let io = StructuredTextIO::new("toml");
let mut input = Cursor::new(first.into_bytes());
let mut output = Cursor::new(Vec::new());
io.write_c2pa(&mut input, &mut output, b"second").unwrap();
let replaced = String::from_utf8(output.into_inner()).unwrap();
assert_eq!(count_blocks(&replaced), 1);
assert_eq!(read_back("toml", &replaced).unwrap(), b"second");
let mut input = Cursor::new(replaced.into_bytes());
let mut output = Cursor::new(Vec::new());
io.remove_c2pa(&mut input, &mut output).unwrap();
let removed = String::from_utf8(output.into_inner()).unwrap();
assert_eq!(removed, "a = 1\n");
}
#[test]
fn replace_preserves_block_offset() {
let first = embed("py", "#!/usr/bin/env python\nprint('x')\n", b"AAAA");
let offset_before = first.find(BEGIN_DELIMITER).unwrap();
let io = StructuredTextIO::new("py");
let mut input = Cursor::new(first.into_bytes());
let mut output = Cursor::new(Vec::new());
io.write_c2pa(&mut input, &mut output, b"BBBB").unwrap();
let replaced = String::from_utf8(output.into_inner()).unwrap();
assert_eq!(count_blocks(&replaced), 1);
assert_eq!(
replaced.find(BEGIN_DELIMITER).unwrap(),
offset_before,
"block offset must be preserved on replace (update manifests)"
);
assert_eq!(read_back("py", &replaced).unwrap(), b"BBBB");
}
#[test]
fn object_locations_exclude_exactly_the_block() {
let out = embed("md", "# Doc\n", b"store bytes");
let io = StructuredTextIO::new("md");
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();
let excluded =
&out.as_bytes()[(cai.offset as usize)..(cai.offset as usize) + (cai.length as usize)];
let excluded = std::str::from_utf8(excluded).unwrap();
assert!(excluded.contains(BEGIN_DELIMITER) && excluded.contains(END_DELIMITER));
}
#[test]
fn unterminated_begin_is_content_not_a_block() {
let src = format!("# Doc\n\nSee {BEGIN_DELIMITER} for the format.\n");
let out = embed("md", &src, b"store");
assert_eq!(read_back("md", &out).unwrap(), b"store");
assert!(
out.contains(&format!("See {BEGIN_DELIMITER} for the format.")),
"the prose line must survive untouched"
);
}
#[test]
fn stray_begin_does_not_hide_the_manifest() {
let signed = embed("md", "# Doc\n", b"first");
let with_stray = format!("{signed}stray {BEGIN_DELIMITER} text\n");
let out = embed("md", &with_stray, b"second");
assert_eq!(count_blocks(&out), 1);
assert_eq!(read_back("md", &out).unwrap(), b"second");
}
#[test]
fn prose_between_delimiters_is_preserved() {
let src = format!("# Doc\n\n{BEGIN_DELIMITER} not a reference {END_DELIMITER}\n\ntail\n");
let out = embed("md", &src, b"store");
assert!(out.contains("not a reference"), "prose must be preserved");
assert!(out.contains("tail"));
assert_eq!(read_back("md", &out).unwrap(), b"store");
}
#[test]
fn crlf_file_round_trips() {
let out = embed("yaml", "a: 1\r\nb: 2\r\n", b"crlf store");
assert!(out.contains("\r\n"), "CRLF must be preserved");
assert_eq!(read_back("yaml", &out).unwrap(), b"crlf store");
let io = StructuredTextIO::new("yaml");
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();
let excluded = &out[(cai.offset as usize)..(cai.offset as usize) + (cai.length as usize)];
assert!(excluded.contains(BEGIN_DELIMITER) && excluded.contains(END_DELIMITER));
assert!(
!out[(cai.offset as usize) + (cai.length as usize)..].contains(BEGIN_DELIMITER),
"exclusion must cover the whole block"
);
}
#[test]
fn markdown_front_matter_holds_the_block() {
let src = "---\ntitle: My Document\n---\n\n# Body\n";
let out = embed("md", src, b"front matter store");
assert!(out.starts_with("---\n"), "opening fence must stay first");
assert!(out.contains("title: My Document"));
assert!(out.contains("# Body"));
assert_eq!(read_back("md", &out).unwrap(), b"front matter store");
assert!(out.contains(&format!("\n{BEGIN_DELIMITER}\n")));
assert!(out.contains(&format!("\n{END_DELIMITER}\n")));
assert!(
!out.contains("<!--"),
"front matter form takes no comment prefix"
);
let block_at = out.find(BEGIN_DELIMITER).unwrap();
let closing_fence = out[4..].find("\n---").unwrap() + 4;
assert!(block_at > 3 && block_at < closing_fence);
}
#[test]
fn front_matter_exclusion_omits_the_fences() {
let out = embed("md", "---\ntitle: T\n---\n\nbody\n", b"store");
let io = StructuredTextIO::new("md");
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();
let excluded = &out[(cai.offset as usize)..(cai.offset as usize) + (cai.length as usize)];
assert!(excluded.starts_with(BEGIN_DELIMITER), "got {excluded:?}");
assert!(excluded.trim_end().ends_with(END_DELIMITER));
assert!(
out[..(cai.offset as usize)].starts_with("---\n"),
"opening fence precedes the exclusion"
);
assert!(out[(cai.offset as usize) + (cai.length as usize)..].contains("---"));
assert!(!excluded.contains("title: T"));
}
#[test]
fn front_matter_replace_keeps_form() {
let first = embed("md", "---\ntitle: T\n---\n\nbody\n", b"AAAA");
let io = StructuredTextIO::new("md");
let mut input = Cursor::new(first.clone().into_bytes());
let mut output = Cursor::new(Vec::new());
io.write_c2pa(&mut input, &mut output, b"BBBB").unwrap();
let replaced = String::from_utf8(output.into_inner()).unwrap();
assert_eq!(count_blocks(&replaced), 1);
assert_eq!(read_back("md", &replaced).unwrap(), b"BBBB");
assert!(replaced.contains(&format!("\n{BEGIN_DELIMITER}\n")));
assert!(!replaced.contains("<!--"));
assert!(replaced.contains("title: T"));
assert_eq!(
replaced.find(BEGIN_DELIMITER),
first.find(BEGIN_DELIMITER),
"offset must be preserved on replace"
);
}
#[test]
fn leading_dashes_only_count_for_markdown_front_matter() {
let md = embed("md", "# No front matter\n", b"store");
assert!(md.starts_with("<!-- "), "got {md:?}");
let yaml = embed("yaml", "---\nkey: value\n", b"store");
assert!(
yaml.starts_with("# "),
"YAML keeps the comment form, got {yaml:?}"
);
assert_eq!(read_back("yaml", &yaml).unwrap(), b"store");
}
#[test]
fn external_url_block_is_still_a_block() {
let text =
format!("// {BEGIN_DELIMITER} https://example.com/a.c2pa {END_DELIMITER}\ncode\n");
assert_eq!(count_blocks(&text), 1);
let out = embed("js", &text, b"store");
assert_eq!(count_blocks(&out), 1);
assert_eq!(read_back("js", &out).unwrap(), b"store");
}
}