Skip to main content

c2pa_structured_text/
extract.rs

1// Copyright 2026 WritersLogic. All rights reserved.
2// Licensed under the Apache License, Version 2.0 or the MIT license,
3// at your option.
4
5//! Locating and extracting the manifest block, and classifying its reference.
6
7use crate::codec;
8use crate::error::Error;
9
10/// The opening ASCII armour delimiter for a C2PA manifest block.
11pub const BEGIN: &str = "-----BEGIN C2PA MANIFEST-----";
12/// The closing ASCII armour delimiter for a C2PA manifest block.
13pub const END: &str = "-----END C2PA MANIFEST-----";
14/// The URI prefix marking an inline (embedded) C2PA Manifest Store.
15pub const DATA_URI_PREFIX: &str = "data:application/c2pa;base64,";
16
17/// The located manifest block: the reference between the delimiters and the
18/// byte span of the block's line(s) within the file.
19///
20/// `line_start` is the offset of the first byte of the line carrying the
21/// `-----BEGIN C2PA MANIFEST-----` delimiter (including any host comment
22/// prefix). `line_end` is the offset one past the trailing line terminator of
23/// the line carrying `-----END C2PA MANIFEST-----`, or the end of the file if
24/// that line has no terminator. For front matter, this spans only the delimiter
25/// lines, never the surrounding `---`/`+++` fences.
26pub(crate) struct Block {
27    pub reference: String,
28    pub line_start: usize,
29    pub line_end: usize,
30}
31
32pub(crate) fn locate_block(text: &str) -> Result<Block, Error> {
33    let bytes = text.as_bytes();
34
35    let begin_pos = find_delimiter(bytes, BEGIN).ok_or(Error::NotFound)?;
36    let after_begin = begin_pos + BEGIN.len();
37
38    let end_pos = find_delimiter(&bytes[after_begin..], END)
39        .map(|pos| after_begin + pos)
40        .ok_or(Error::NotFound)?;
41
42    let after_end = end_pos + END.len();
43    if find_delimiter(&bytes[after_end..], BEGIN).is_some() {
44        return Err(Error::MultipleBlocks);
45    }
46
47    let reference = text[after_begin..end_pos].trim().to_string();
48    if reference.is_empty() {
49        return Err(Error::EmptyReference);
50    }
51
52    let line_start = text[..begin_pos].rfind('\n').map_or(0, |p| p + 1);
53    let line_end = text[after_end..]
54        .find('\n')
55        .map_or(text.len(), |p| after_end + p + 1);
56
57    Ok(Block {
58        reference,
59        line_start,
60        line_end,
61    })
62}
63
64/// The result of extracting a manifest block: the reference plus the byte offset
65/// and length of the block's line(s) in the source text.
66#[derive(Debug)]
67pub struct ExtractionResult {
68    pub reference: String,
69    pub offset: usize,
70    pub length: usize,
71}
72
73/// Locate and extract the single manifest block from structured text.
74///
75/// Returns [`Error::NotFound`] if no block is present, [`Error::MultipleBlocks`]
76/// if more than one is present, and [`Error::EmptyReference`] if the reference
77/// between the delimiters is empty.
78pub fn extract_manifest(text: &str) -> Result<ExtractionResult, Error> {
79    let block = locate_block(text)?;
80    Ok(ExtractionResult {
81        reference: block.reference,
82        offset: block.line_start,
83        length: block.line_end - block.line_start,
84    })
85}
86
87/// A classified manifest reference: an external URI or an embedded C2PA Manifest
88/// Store decoded from a `data:application/c2pa;base64,` URI.
89#[derive(Debug, Clone, PartialEq, Eq)]
90pub enum Reference {
91    Url(String),
92    Embedded(Vec<u8>),
93}
94
95/// Classify and resolve a reference string as extracted from a manifest block.
96///
97/// A `data:application/c2pa;base64,` URI is decoded to the manifest bytes. Any
98/// other value is treated as an external URI and returned verbatim; resolving it
99/// over the network is the caller's responsibility (see the `remote` feature).
100pub fn classify_reference(reference: &str) -> Result<Reference, Error> {
101    if let Some(b64) = reference.strip_prefix(DATA_URI_PREFIX) {
102        let bytes = codec::decode(b64).map_err(Error::ManifestDecode)?;
103        Ok(Reference::Embedded(bytes))
104    } else if looks_like_uri(reference) {
105        Ok(Reference::Url(reference.to_string()))
106    } else {
107        Err(Error::MalformedReference(reference.to_string()))
108    }
109}
110
111fn looks_like_uri(reference: &str) -> bool {
112    // A minimal scheme check: `scheme:` where scheme is ALPHA *( ALPHA / DIGIT
113    // / "+" / "-" / "." ) per RFC 3986. Enough to reject stray text without
114    // pulling a URI-parsing dependency.
115    match reference.find(':') {
116        Some(0) | None => false,
117        Some(colon) => {
118            let scheme = &reference.as_bytes()[..colon];
119            scheme[0].is_ascii_alphabetic()
120                && scheme
121                    .iter()
122                    .all(|&c| c.is_ascii_alphanumeric() || matches!(c, b'+' | b'-' | b'.'))
123        }
124    }
125}
126
127/// Locate the first occurrence of `needle` in `haystack` by a byte-window
128/// search, returning its start offset.
129pub fn find_delimiter(haystack: &[u8], needle: &str) -> Option<usize> {
130    let needle = needle.as_bytes();
131    haystack.windows(needle.len()).position(|w| w == needle)
132}
133
134#[cfg(test)]
135mod tests {
136    use super::*;
137
138    #[test]
139    fn single_line_python() {
140        let text = "# -----BEGIN C2PA MANIFEST----- https://example.com/m.c2pa -----END C2PA MANIFEST-----\nprint('hello')\n";
141        let result = extract_manifest(text).unwrap();
142        assert_eq!(result.reference, "https://example.com/m.c2pa");
143        assert_eq!(result.offset, 0);
144    }
145
146    #[test]
147    fn front_matter() {
148        let text = "---\n-----BEGIN C2PA MANIFEST-----\nhttps://example.com/m.c2pa\n-----END C2PA MANIFEST-----\ntitle: doc\n---\n";
149        let result = extract_manifest(text).unwrap();
150        assert_eq!(result.reference, "https://example.com/m.c2pa");
151        // The excluded span starts after the opening `---` fence, not at 0.
152        assert_eq!(result.offset, 4);
153    }
154
155    #[test]
156    fn not_found() {
157        assert!(matches!(
158            extract_manifest("no manifest here"),
159            Err(Error::NotFound)
160        ));
161    }
162
163    #[test]
164    fn empty_reference() {
165        let text = "# -----BEGIN C2PA MANIFEST-----  -----END C2PA MANIFEST-----\n";
166        assert!(matches!(extract_manifest(text), Err(Error::EmptyReference)));
167    }
168
169    #[test]
170    fn multiple_blocks() {
171        let text = "# -----BEGIN C2PA MANIFEST----- https://a.com -----END C2PA MANIFEST-----\n# -----BEGIN C2PA MANIFEST----- https://b.com -----END C2PA MANIFEST-----\n";
172        assert!(matches!(extract_manifest(text), Err(Error::MultipleBlocks)));
173    }
174
175    #[test]
176    fn classify_url() {
177        assert_eq!(
178            classify_reference("https://example.com/m.c2pa").unwrap(),
179            Reference::Url("https://example.com/m.c2pa".to_string())
180        );
181    }
182
183    #[test]
184    fn classify_data_uri() {
185        // "foobar" base64-encoded.
186        let r = classify_reference("data:application/c2pa;base64,Zm9vYmFy").unwrap();
187        assert_eq!(r, Reference::Embedded(b"foobar".to_vec()));
188    }
189
190    #[test]
191    fn classify_rejects_bare_text() {
192        assert!(matches!(
193            classify_reference("not a reference"),
194            Err(Error::MalformedReference(_))
195        ));
196    }
197}