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