Skip to main content

c2pa_http/
link.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//! The `c2pa-manifest` link relation, parsed and serialised per {RFC 8288}.
6//!
7//! When an asset is retrieved over HTTP, a validator should look for a `Link`
8//! header carrying `rel="c2pa-manifest"`; its target is where the C2PA Manifest
9//! Store can be retrieved. The target may also name a Manifest Store *already
10//! embedded* in the asset, through a JUMBF URI fragment.
11//!
12//! This module is dependency-free and operates on `&str`, so it works under any
13//! HTTP stack. The Tower integration lives in [`crate::layer`].
14//!
15//! # Grammar
16//!
17//! ```text
18//! Link       = #link-value
19//! link-value = "<" URI-Reference ">" *( OWS ";" OWS link-param )
20//! link-param = token BWS "=" BWS ( token / quoted-string )
21//! ```
22//!
23//! Commas separate link-values and semicolons separate parameters, but both may
24//! appear inside a `<target>` or a quoted string — a query string with `?a=1,2`
25//! is entirely legal. Splitting naively on those characters is the classic way
26//! to mis-parse this header, so the scanner here tracks both contexts.
27
28use crate::error::Error;
29
30/// The IANA-registered link relation naming a C2PA Manifest Store.
31pub const REL: &str = "c2pa-manifest";
32
33/// The JUMBF URI fragment prefix that names an embedded Manifest Store.
34const JUMBF_PREFIX: &str = "jumbf=";
35
36/// A located `c2pa-manifest` link.
37#[derive(Debug, Clone, PartialEq, Eq)]
38pub struct ManifestLink {
39    /// The target URI.
40    ///
41    /// When the target carried a JUMBF fragment, any `childlabel` portion has
42    /// been removed: the specification permits referencing only the Manifest
43    /// Store superbox, and requires a validator to ignore a deeper reference.
44    pub uri: String,
45    /// The JUMBF superbox label when the target names an *embedded* Manifest
46    /// Store rather than a separate resource — normally `c2pa`.
47    pub jumbf: Option<String>,
48}
49
50impl ManifestLink {
51    /// Whether the target names a Manifest Store embedded in the asset itself,
52    /// as opposed to a resource to be fetched.
53    pub fn is_embedded(&self) -> bool {
54        self.jumbf.is_some()
55    }
56}
57
58/// Every `c2pa-manifest` link across the given `Link` header values, in order.
59///
60/// A response may carry several `Link` header fields, and each may carry
61/// several comma-separated link-values; all are searched.
62pub fn locate_all<'a>(values: impl IntoIterator<Item = &'a str>) -> Vec<ManifestLink> {
63    let mut out = Vec::new();
64    for value in values {
65        for raw in split_unquoted(value, b',') {
66            let Some((target, params)) = parse_link_value(raw) else {
67                continue;
68            };
69            // RFC 8288: occurrences of `rel` after the first MUST be ignored.
70            let Some(rel) = params.iter().find(|(k, _)| k == "rel").map(|(_, v)| v) else {
71                continue;
72            };
73            if !rel
74                .split(|c: char| c.is_ascii_whitespace())
75                .any(|t| t.eq_ignore_ascii_case(REL))
76            {
77                continue;
78            }
79            let (uri, jumbf) = split_jumbf(target);
80            out.push(ManifestLink { uri, jumbf });
81        }
82    }
83    out
84}
85
86/// The single `c2pa-manifest` link advertised by a response.
87///
88/// Duplicate links naming the same target collapse to one; genuinely competing
89/// targets are [`Error::MultipleLinks`], since the specification defines no
90/// precedence between them.
91pub fn extract<'a>(values: impl IntoIterator<Item = &'a str>) -> Result<ManifestLink, Error> {
92    let mut found = locate_all(values);
93    found.dedup_by(|a, b| a == b);
94    if found.len() > 1 {
95        // Only distinct targets conflict; the same target repeated does not.
96        let first = &found[0];
97        if found.iter().any(|l| l != first) {
98            return Err(Error::MultipleLinks);
99        }
100        found.truncate(1);
101    }
102    found.pop().ok_or(Error::NotFound)
103}
104
105/// Build a `Link` header value advertising `uri` as the C2PA Manifest Store.
106///
107/// The target is percent-encoded by [`encode_target`], so *any* input yields a
108/// well-formed, injection-free header. Only an empty target is an error: there
109/// is nothing to advertise.
110///
111/// Because encoding is applied, [`extract`] reads back the *encoded* form —
112/// `a b` goes out as `a%20b` and comes back as `a%20b`. That is the URI, and it
113/// is what a validator will fetch. Use [`format_strict`] when you would rather
114/// be told that your input needed repairing.
115pub fn format(uri: &str) -> Result<String, Error> {
116    if uri.is_empty() {
117        return Err(Error::Malformed("target URI is empty"));
118    }
119    Ok(std::format!("<{}>; rel=\"{REL}\"", encode_target(uri)))
120}
121
122/// As [`format`], but fails rather than repairing a target that is not already
123/// a valid URI reference.
124///
125/// Useful where the target comes from configuration and silently rewriting it
126/// would hide a mistake: a stray space in a deployment variable becomes `%20`
127/// and a 404 at validation time, rather than an error at startup.
128pub fn format_strict(uri: &str) -> Result<String, Error> {
129    if uri.is_empty() {
130        return Err(Error::Malformed("target URI is empty"));
131    }
132    if uri.as_bytes().iter().copied().any(must_encode) {
133        return Err(Error::Malformed(
134            "target URI contains characters that a URI must percent-encode",
135        ));
136    }
137    Ok(std::format!("<{uri}>; rel=\"{REL}\""))
138}
139
140/// Whether a byte cannot appear literally in a URI reference.
141///
142/// {RFC 3986} excludes the control characters, space, and `" < > \ ^ ` { | }`
143/// from a URI. Bytes at or above `0x7F` are excluded too: a URI is ASCII, and
144/// non-ASCII text travels as percent-encoded UTF-8.
145///
146/// `%` is deliberately *not* in this set, so a URI that is already encoded is
147/// not encoded a second time.
148fn must_encode(b: u8) -> bool {
149    b <= 0x20
150        || b >= 0x7F
151        || matches!(
152            b,
153            b'"' | b'<' | b'>' | b'\\' | b'^' | b'`' | b'{' | b'|' | b'}'
154        )
155}
156
157/// Percent-encode the bytes that cannot appear literally in a URI reference.
158///
159/// A string carrying a raw CR, LF, space, or angle bracket is not a URI to be
160/// rejected — it is a URI that has not been encoded yet, and encoding it is
161/// what {RFC 3986} requires. That this also makes response-header injection
162/// impossible is a consequence rather than a separate mechanism: a CR becomes
163/// `%0D` and can no longer terminate the field, and a `>` becomes `%3E` and can
164/// no longer close the target early.
165///
166/// Already-encoded input passes through unchanged, because `%` is left alone.
167/// The delimiters a URI needs — `? # / : @ & = +` and the other sub-delims —
168/// are all legal and preserved, so a query string or fragment survives intact.
169pub fn encode_target(uri: &str) -> String {
170    const HEX: &[u8; 16] = b"0123456789ABCDEF";
171    let mut out = String::with_capacity(uri.len());
172    for &b in uri.as_bytes() {
173        if must_encode(b) {
174            out.push('%');
175            out.push(HEX[(b >> 4) as usize] as char);
176            out.push(HEX[(b & 0x0F) as usize] as char);
177        } else {
178            // Every byte reaching here is printable ASCII.
179            out.push(b as char);
180        }
181    }
182    out
183}
184
185/// Split on `sep`, ignoring separators inside `<...>` or a quoted string.
186fn split_unquoted(s: &str, sep: u8) -> Vec<&str> {
187    let b = s.as_bytes();
188    let mut out = Vec::new();
189    let (mut start, mut i) = (0usize, 0usize);
190    let (mut in_angle, mut in_quote) = (false, false);
191    while i < b.len() {
192        match b[i] {
193            b'\\' if in_quote => i += 1, // the next byte is escaped
194            b'"' => in_quote = !in_quote,
195            b'<' if !in_quote => in_angle = true,
196            b'>' if !in_quote => in_angle = false,
197            c if c == sep && !in_quote && !in_angle => {
198                out.push(&s[start..i]);
199                start = i + 1;
200            }
201            _ => {}
202        }
203        i += 1;
204    }
205    out.push(&s[start..]);
206    out
207}
208
209/// Parse one link-value into its target and parameters. Parameter names are
210/// ASCII-lowercased; values are unquoted.
211fn parse_link_value(value: &str) -> Option<(&str, Vec<(String, String)>)> {
212    let value = value.trim();
213    let open = value.find('<')?;
214    let close = open + 1 + value[open + 1..].find('>')?;
215    let target = value[open + 1..close].trim();
216    if target.is_empty() {
217        return None;
218    }
219
220    let mut params = Vec::new();
221    for param in split_unquoted(&value[close + 1..], b';') {
222        let param = param.trim();
223        if param.is_empty() {
224            continue;
225        }
226        match param.find('=') {
227            Some(eq) => params.push((
228                param[..eq].trim().to_ascii_lowercase(),
229                unquote(param[eq + 1..].trim()),
230            )),
231            None => params.push((param.to_ascii_lowercase(), String::new())),
232        }
233    }
234    Some((target, params))
235}
236
237/// Strip surrounding quotes and resolve backslash escapes.
238fn unquote(s: &str) -> String {
239    let Some(inner) = s
240        .strip_prefix('"')
241        .and_then(|r| r.strip_suffix('"'))
242        .filter(|_| s.len() >= 2)
243    else {
244        return s.to_string();
245    };
246    let mut out = String::with_capacity(inner.len());
247    let mut chars = inner.chars();
248    while let Some(c) = chars.next() {
249        match c {
250            '\\' => out.extend(chars.next()),
251            _ => out.push(c),
252        }
253    }
254    out
255}
256
257/// Separate a JUMBF fragment from the target, discarding any childlabel.
258fn split_jumbf(target: &str) -> (String, Option<String>) {
259    let Some(hash) = target.find('#') else {
260        return (target.to_string(), None);
261    };
262    let (base, fragment) = (&target[..hash], &target[hash + 1..]);
263    if fragment.len() < JUMBF_PREFIX.len()
264        || !fragment[..JUMBF_PREFIX.len()].eq_ignore_ascii_case(JUMBF_PREFIX)
265    {
266        return (target.to_string(), None);
267    }
268    // The Manifest Store superbox is the whole reference; anything after the
269    // first `/` addresses a manifest inside it, which is not permitted.
270    let store = fragment[JUMBF_PREFIX.len()..]
271        .split('/')
272        .next()
273        .unwrap_or_default();
274    if store.is_empty() {
275        return (target.to_string(), None);
276    }
277    (
278        std::format!("{base}#{JUMBF_PREFIX}{store}"),
279        Some(store.to_string()),
280    )
281}
282
283#[cfg(test)]
284mod tests {
285    use super::*;
286
287    fn one(header: &str) -> ManifestLink {
288        extract([header]).expect("expected exactly one c2pa-manifest link")
289    }
290
291    #[test]
292    fn parses_a_quoted_relation() {
293        let l = one(r#"<https://a.example/m.c2pa>; rel="c2pa-manifest""#);
294        assert_eq!(l.uri, "https://a.example/m.c2pa");
295        assert_eq!(l.jumbf, None);
296        assert!(!l.is_embedded());
297    }
298
299    #[test]
300    fn parses_an_unquoted_relation() {
301        assert_eq!(
302            one("<https://a.example/m.c2pa>; rel=c2pa-manifest").uri,
303            "https://a.example/m.c2pa"
304        );
305    }
306
307    #[test]
308    fn relation_matching_is_case_insensitive() {
309        assert_eq!(
310            one(r#"<m.c2pa>; REL="C2PA-Manifest""#).uri,
311            "m.c2pa",
312            "rel name and value are both case-insensitive"
313        );
314    }
315
316    #[test]
317    fn a_relation_token_list_containing_the_relation_matches() {
318        assert_eq!(
319            one(r#"<m.c2pa>; rel="preload c2pa-manifest""#).uri,
320            "m.c2pa"
321        );
322    }
323
324    #[test]
325    fn a_near_miss_relation_is_not_a_match() {
326        for header in [
327            r#"<m.c2pa>; rel="c2pa-manifest-x""#,
328            r#"<m.c2pa>; rel="x-c2pa-manifest""#,
329            r#"<m.c2pa>; rel="stylesheet""#,
330            "<m.c2pa>",
331        ] {
332            assert_eq!(extract([header]), Err(Error::NotFound), "{header}");
333        }
334    }
335
336    #[test]
337    fn picks_the_c2pa_link_out_of_a_multi_value_header() {
338        let h = r#"</style.css>; rel=preload, <https://a.example/m.c2pa>; rel="c2pa-manifest", </next>; rel=next"#;
339        assert_eq!(one(h).uri, "https://a.example/m.c2pa");
340    }
341
342    #[test]
343    fn searches_across_several_header_fields() {
344        let l = extract(["</a>; rel=preload", r#"<m.c2pa>; rel="c2pa-manifest""#]).unwrap();
345        assert_eq!(l.uri, "m.c2pa");
346    }
347
348    #[test]
349    fn a_comma_inside_the_target_does_not_split_the_value() {
350        // A query string may legally contain commas.
351        let h = r#"<https://a.example/m.c2pa?ids=1,2,3>; rel="c2pa-manifest""#;
352        assert_eq!(one(h).uri, "https://a.example/m.c2pa?ids=1,2,3");
353    }
354
355    #[test]
356    fn a_comma_or_semicolon_inside_a_quoted_param_does_not_split() {
357        let h = r#"<m.c2pa>; title="a, b; c"; rel="c2pa-manifest""#;
358        assert_eq!(one(h).uri, "m.c2pa");
359    }
360
361    #[test]
362    fn an_escaped_quote_inside_a_param_is_handled() {
363        let h = r#"<m.c2pa>; title="say \"hi\", ok"; rel="c2pa-manifest""#;
364        assert_eq!(one(h).uri, "m.c2pa");
365    }
366
367    #[test]
368    fn only_the_first_rel_parameter_counts() {
369        // RFC 8288: later occurrences MUST be ignored.
370        assert_eq!(
371            one(r#"<m.c2pa>; rel="c2pa-manifest"; rel="next""#).uri,
372            "m.c2pa"
373        );
374        assert_eq!(
375            extract([r#"<m.c2pa>; rel="next"; rel="c2pa-manifest""#]),
376            Err(Error::NotFound),
377            "a later rel must not rescue a non-matching first one"
378        );
379    }
380
381    #[test]
382    fn a_jumbf_fragment_names_an_embedded_store() {
383        let l = one(r#"<https://a.example/image.jpg#jumbf=c2pa>; rel="c2pa-manifest""#);
384        assert_eq!(l.uri, "https://a.example/image.jpg#jumbf=c2pa");
385        assert_eq!(l.jumbf.as_deref(), Some("c2pa"));
386        assert!(l.is_embedded());
387    }
388
389    #[test]
390    fn a_jumbf_childlabel_is_discarded() {
391        // Referencing a specific manifest inside the store is not permitted, and
392        // the validator shall ignore the childlabel portion.
393        let l = one(
394            r#"<https://a.example/i.jpg#jumbf=c2pa/urn:uuid:1234/c2pa.assertions>; rel="c2pa-manifest""#,
395        );
396        assert_eq!(l.uri, "https://a.example/i.jpg#jumbf=c2pa");
397        assert_eq!(l.jumbf.as_deref(), Some("c2pa"));
398    }
399
400    #[test]
401    fn a_non_jumbf_fragment_is_left_alone() {
402        let l = one(r#"<https://a.example/m.c2pa#section>; rel="c2pa-manifest""#);
403        assert_eq!(l.uri, "https://a.example/m.c2pa#section");
404        assert_eq!(l.jumbf, None);
405    }
406
407    #[test]
408    fn duplicate_identical_links_are_not_a_conflict() {
409        let h = r#"<m.c2pa>; rel="c2pa-manifest", <m.c2pa>; rel="c2pa-manifest""#;
410        assert_eq!(one(h).uri, "m.c2pa");
411    }
412
413    #[test]
414    fn competing_targets_are_rejected() {
415        // No precedence is defined between them, so choosing would invent a rule.
416        let h = r#"<a.c2pa>; rel="c2pa-manifest", <b.c2pa>; rel="c2pa-manifest""#;
417        assert_eq!(extract([h]), Err(Error::MultipleLinks));
418        assert_eq!(locate_all([h]).len(), 2);
419    }
420
421    #[test]
422    fn malformed_values_are_skipped_not_fatal() {
423        // A neighbouring link that does not parse must not hide a good one.
424        let h = r#"no-brackets; rel=whatever, <m.c2pa>; rel="c2pa-manifest""#;
425        assert_eq!(one(h).uri, "m.c2pa");
426        assert_eq!(
427            extract(["<unterminated; rel=c2pa-manifest"]),
428            Err(Error::NotFound)
429        );
430        assert_eq!(extract(["<>; rel=c2pa-manifest"]), Err(Error::NotFound));
431        assert_eq!(extract([""]), Err(Error::NotFound));
432    }
433
434    #[test]
435    fn whitespace_around_the_delimiters_is_tolerated() {
436        let h = "  <m.c2pa>  ;  rel  =  c2pa-manifest  ";
437        assert_eq!(one(h).uri, "m.c2pa");
438    }
439
440    #[test]
441    fn format_round_trips_through_the_parser() {
442        let header = format("https://a.example/m.c2pa").unwrap();
443        assert_eq!(header, r#"<https://a.example/m.c2pa>; rel="c2pa-manifest""#);
444        assert_eq!(one(&header).uri, "https://a.example/m.c2pa");
445    }
446
447    #[test]
448    fn format_neutralises_header_injection_rather_than_refusing() {
449        // Each of these would split the header or close the target early if it
450        // reached the wire raw. Encoding makes them inert while keeping them.
451        for hostile in [
452            "https://a.example/\r\nX-Injected: yes",
453            "https://a.example/\nX-Injected: yes",
454            "https://a.example/\r",
455            "https://a.example/m>; rel=\"evil\", <b",
456            "https://a.example/\u{7}bell",
457            "https://a.example/a b",
458        ] {
459            let header = format(hostile).expect("encoding must never reject");
460            assert!(
461                !header.contains('\r') && !header.contains('\n'),
462                "a line break survived: {header:?}"
463            );
464            // Exactly one target, so nothing closed it early and started a
465            // second link-value.
466            assert_eq!(header.matches('<').count(), 1, "{header:?}");
467            assert_eq!(header.matches('>').count(), 1, "{header:?}");
468            // And it still parses back to exactly one link.
469            assert_eq!(locate_all([header.as_str()]).len(), 1, "{header:?}");
470        }
471        assert!(matches!(format(""), Err(Error::Malformed(_))));
472    }
473
474    #[test]
475    fn an_injected_header_name_becomes_part_of_the_uri() {
476        // The payload is preserved, not silently dropped — it is simply no
477        // longer a header of its own.
478        let header = format("https://a.example/\r\nX-Injected: yes").unwrap();
479        assert!(header.contains("%0D%0A"), "{header}");
480        let found = extract([header.as_str()]).unwrap();
481        assert_eq!(found.uri, "https://a.example/%0D%0AX-Injected:%20yes");
482    }
483
484    #[test]
485    fn encoding_covers_exactly_the_characters_a_uri_excludes() {
486        assert_eq!(encode_target("a b"), "a%20b");
487        assert_eq!(encode_target("a\r\nb"), "a%0D%0Ab");
488        assert_eq!(encode_target("a<b>c"), "a%3Cb%3Ec");
489        assert_eq!(
490            encode_target("a\"b\\c^d`e{f|g}h"),
491            "a%22b%5Cc%5Ed%60e%7Bf%7Cg%7Dh"
492        );
493        assert_eq!(encode_target("a\u{7F}b"), "a%7Fb");
494        // Non-ASCII travels as percent-encoded UTF-8.
495        assert_eq!(encode_target("café"), "caf%C3%A9");
496    }
497
498    #[test]
499    fn encoding_preserves_a_uri_that_is_already_correct() {
500        // Every delimiter a URI needs must survive untouched, or a query string
501        // or fragment would be corrupted.
502        for good in [
503            "https://a.example/m.c2pa",
504            "https://user@a.example:8443/p/q?x=1&y=2#frag",
505            "https://a.example/i.jpg#jumbf=c2pa",
506            "https://a.example/a~b_c-d.e!$&'()*+,;=:@/f",
507        ] {
508            assert_eq!(encode_target(good), good, "mangled a valid URI");
509        }
510    }
511
512    #[test]
513    fn encoding_is_idempotent() {
514        // `%` is left alone, so an already-encoded target is not double-encoded
515        // into `%2520`.
516        let once = encode_target("a b");
517        assert_eq!(encode_target(&once), once);
518        assert_eq!(encode_target("%20"), "%20");
519    }
520
521    #[test]
522    fn format_strict_reports_what_format_would_have_repaired() {
523        assert!(format_strict("https://a.example/m.c2pa").is_ok());
524        for needs_repair in ["https://a.example/a b", "https://a.example/\r\n", "café"] {
525            assert!(
526                matches!(format_strict(needs_repair), Err(Error::Malformed(_))),
527                "strict mode accepted {needs_repair:?}"
528            );
529            // What strict rejects, lenient repairs.
530            assert!(format(needs_repair).is_ok());
531        }
532        assert!(matches!(format_strict(""), Err(Error::Malformed(_))));
533    }
534
535    #[test]
536    fn format_accepts_a_jumbf_target() {
537        let header = format("https://a.example/i.jpg#jumbf=c2pa").unwrap();
538        assert!(one(&header).is_embedded());
539    }
540
541    #[test]
542    fn the_scanner_terminates_on_adversarial_input() {
543        // Unbalanced delimiters must not loop or panic.
544        for h in [
545            "<<<<",
546            "\"\"\"",
547            "<a\"b>; rel=c2pa-manifest",
548            ";;;;",
549            ",,,,",
550            "<a>;rel=",
551            "\\",
552            "<a>; rel=\"unterminated",
553        ] {
554            let _ = locate_all([h]);
555        }
556    }
557
558    #[test]
559    fn multibyte_targets_do_not_split_a_character() {
560        let h = "<https://a.example/café/münchen.c2pa>; rel=c2pa-manifest";
561        assert_eq!(one(h).uri, "https://a.example/café/münchen.c2pa");
562    }
563}