use crate::c2pa_formats::util::base64_decode;
use crate::c2pa_formats::{DataHashExclusion, FormatError};
pub(crate) const BEGIN_CHECKPOINT: &str = "-----BEGIN C2PA CHECKPOINT-----";
pub(crate) const END_CHECKPOINT: &str = "-----END C2PA CHECKPOINT-----";
const JSONL_MEMBER: &str = "c2pa:checkpoint";
const JSONL_NEEDLE: &[u8] = b"\"c2pa:checkpoint\"";
const DATA_URI_PREFIX: &str = "data:application/c2pa;base64,";
const REFERENCE_WHITESPACE: [char; 4] = [' ', '\t', '\n', '\r'];
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum AppendOnlyFlavor {
PlainText,
JsonLines,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum SentinelReference {
Inline(Vec<u8>),
External(String),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct Sentinel {
pub(crate) start: usize,
pub(crate) length: usize,
pub(crate) reference: SentinelReference,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum Located {
None,
Malformed,
Active(Sentinel),
}
fn complete_lines(data: &[u8]) -> impl DoubleEndedIterator<Item = (usize, usize, usize)> + '_ {
let mut starts = Vec::new();
let mut start = 0usize;
for (index, byte) in data.iter().enumerate() {
if *byte == b'\n' {
let content_end = if index > start && data[index - 1] == b'\r' {
index - 1
} else {
index
};
starts.push((start, content_end, index + 1));
start = index + 1;
}
}
starts.into_iter()
}
fn is_candidate(line: &[u8], flavor: AppendOnlyFlavor) -> bool {
match flavor {
AppendOnlyFlavor::PlainText => line.starts_with(BEGIN_CHECKPOINT.as_bytes()),
AppendOnlyFlavor::JsonLines => line
.windows(JSONL_NEEDLE.len())
.any(|window| window == JSONL_NEEDLE),
}
}
fn reference_text(line: &[u8], flavor: AppendOnlyFlavor) -> Option<String> {
let text = std::str::from_utf8(line).ok()?;
match flavor {
AppendOnlyFlavor::PlainText => {
let inner = text
.strip_prefix(BEGIN_CHECKPOINT)?
.strip_suffix(END_CHECKPOINT)?;
Some(inner.to_string())
}
AppendOnlyFlavor::JsonLines => {
let value: serde_json::Value = serde_json::from_str(text).ok()?;
let object = value.as_object()?;
object.get(JSONL_MEMBER)?.as_str().map(str::to_string)
}
}
}
fn resolve(reference: &str) -> Option<SentinelReference> {
let reference = reference.trim_matches(REFERENCE_WHITESPACE.as_slice());
if reference.is_empty() {
return None;
}
if let Some(payload) = reference.strip_prefix(DATA_URI_PREFIX) {
let store = base64_decode(payload).filter(|store| !store.is_empty())?;
if store.len() > crate::MAX_MANIFEST_STORE_BYTES {
return None;
}
return Some(SentinelReference::Inline(store));
}
if reference
.get(..5)
.is_some_and(|scheme| scheme.eq_ignore_ascii_case("data:"))
{
return None;
}
let (scheme, rest) = reference.split_once(':')?;
let scheme_ok = scheme
.chars()
.next()
.is_some_and(|first| first.is_ascii_alphabetic())
&& scheme
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.'));
(scheme_ok && !rest.is_empty() && !reference.chars().any(char::is_whitespace))
.then(|| SentinelReference::External(reference.to_string()))
}
pub(crate) fn locate_active(data: &[u8], flavor: AppendOnlyFlavor) -> Located {
for (start, content_end, line_end) in complete_lines(data).rev() {
let line = &data[start..content_end];
if !is_candidate(line, flavor) {
continue;
}
return match reference_text(line, flavor).as_deref().and_then(resolve) {
Some(reference) => Located::Active(Sentinel {
start,
length: line_end - start,
reference,
}),
None => Located::Malformed,
};
}
Located::None
}
pub(crate) fn sentinel_at(data: &[u8], start: usize, flavor: AppendOnlyFlavor) -> Option<Sentinel> {
if start > data.len() || (start > 0 && data[start - 1] != b'\n') {
return None;
}
let newline = data[start..].iter().position(|byte| *byte == b'\n')?;
let line_end = start + newline + 1;
let mut content_end = start + newline;
if content_end > start && data[content_end - 1] == b'\r' {
content_end -= 1;
}
let line = &data[start..content_end];
if !is_candidate(line, flavor) {
return None;
}
let reference = resolve(&reference_text(line, flavor)?)?;
Some(Sentinel {
start,
length: line_end - start,
reference,
})
}
pub(crate) fn declares_checkpoints(data: &[u8], flavor: AppendOnlyFlavor) -> bool {
complete_lines(data)
.any(|(start, content_end, _)| is_candidate(&data[start..content_end], flavor))
}
pub(crate) fn extract(
data: &[u8],
flavor: AppendOnlyFlavor,
) -> Result<Option<Vec<u8>>, FormatError> {
Ok(match locate_active(data, flavor) {
Located::Active(Sentinel {
reference: SentinelReference::Inline(store),
..
}) => Some(store),
_ => None,
})
}
pub(crate) fn exclusions(
data: &[u8],
flavor: AppendOnlyFlavor,
) -> Result<Vec<DataHashExclusion>, FormatError> {
Ok(match locate_active(data, flavor) {
Located::Active(sentinel) => vec![DataHashExclusion {
start: sentinel.start,
length: sentinel.length,
}],
_ => Vec::new(),
})
}
#[cfg(test)]
mod tests {
use super::*;
const LF_VECTOR: &[u8] = b"alpha\nbravo\n-----BEGIN C2PA CHECKPOINT----- https://northwindtraders.com/provenance/chk1.c2pa -----END C2PA CHECKPOINT-----\n";
#[test]
fn spec_test_vector_geometry() {
let Located::Active(sentinel) = locate_active(LF_VECTOR, AppendOnlyFlavor::PlainText)
else {
panic!("sentinel located")
};
assert_eq!((sentinel.start, sentinel.length), (12, 112));
assert_eq!(
sentinel.reference,
SentinelReference::External(
"https://northwindtraders.com/provenance/chk1.c2pa".to_string()
)
);
assert_eq!(
sentinel_at(LF_VECTOR, 12, AppendOnlyFlavor::PlainText),
Some(sentinel)
);
let crlf = String::from_utf8(LF_VECTOR.to_vec())
.unwrap()
.replace('\n', "\r\n");
let Located::Active(sentinel) = locate_active(crlf.as_bytes(), AppendOnlyFlavor::PlainText)
else {
panic!("sentinel located")
};
assert_eq!((sentinel.start, sentinel.length), (14, 113));
}
#[test]
fn inline_references_decode_and_bad_ones_are_malformed() {
let ok = b"x\n-----BEGIN C2PA CHECKPOINT----- data:application/c2pa;base64,AQID -----END C2PA CHECKPOINT-----\n";
assert_eq!(
extract(ok, AppendOnlyFlavor::PlainText).unwrap(),
Some(vec![1, 2, 3])
);
for bad in [
&b"x\n-----BEGIN C2PA CHECKPOINT----- data:text/plain;base64,AQID -----END C2PA CHECKPOINT-----\n"[..],
b"x\n-----BEGIN C2PA CHECKPOINT----- data:application/c2pa;base64,*** -----END C2PA CHECKPOINT-----\n",
b"x\n-----BEGIN C2PA CHECKPOINT----- -----END C2PA CHECKPOINT-----\n",
b"x\n-----BEGIN C2PA CHECKPOINT----- https://a/b no end delimiter\n",
] {
assert_eq!(locate_active(bad, AppendOnlyFlavor::PlainText), Located::Malformed);
}
}
#[test]
fn only_the_last_candidate_counts_and_unterminated_lines_are_ignored() {
let text = format!(
"{}more\n-----BEGIN C2PA CHECKPOINT----- broken\n",
String::from_utf8(LF_VECTOR.to_vec()).unwrap()
);
assert_eq!(
locate_active(text.as_bytes(), AppendOnlyFlavor::PlainText),
Located::Malformed
);
let mut unterminated = LF_VECTOR.to_vec();
unterminated.extend_from_slice(
b"-----BEGIN C2PA CHECKPOINT----- https://x/y -----END C2PA CHECKPOINT-----",
);
assert!(matches!(
locate_active(&unterminated, AppendOnlyFlavor::PlainText),
Located::Active(Sentinel { start: 12, .. })
));
assert_eq!(
locate_active(b"no sentinel\n", AppendOnlyFlavor::PlainText),
Located::None
);
}
#[test]
fn jsonl_sentinels() {
let text = b"{\"a\":1}\n{\"c2pa:checkpoint\":\"https://e.example/c.c2pa\"}\n";
assert!(matches!(
locate_active(text, AppendOnlyFlavor::JsonLines),
Located::Active(Sentinel {
start: 8,
length: 47,
..
})
));
let not_string = b"{\"c2pa:checkpoint\":7}\n";
assert_eq!(
locate_active(not_string, AppendOnlyFlavor::JsonLines),
Located::Malformed
);
assert_eq!(
locate_active(LF_VECTOR, AppendOnlyFlavor::JsonLines),
Located::None
);
assert!(declares_checkpoints(LF_VECTOR, AppendOnlyFlavor::PlainText));
assert!(!declares_checkpoints(
b"plain\n",
AppendOnlyFlavor::PlainText
));
}
#[test]
fn sentinel_at_requires_a_line_start() {
assert_eq!(
sentinel_at(LF_VECTOR, 13, AppendOnlyFlavor::PlainText),
None
);
assert_eq!(sentinel_at(LF_VECTOR, 0, AppendOnlyFlavor::PlainText), None);
assert_eq!(
sentinel_at(LF_VECTOR, 999, AppendOnlyFlavor::PlainText),
None
);
}
}