Skip to main content

encypher_c2pa/
online.rs

1// Copyright 2026 Encypher Corporation
2// SPDX-License-Identifier: Apache-2.0
3
4//! Opt-in online checks.
5//!
6//! The verification kernel stays pure and offline. When a file references
7//! something only a server can settle - a manifest store held elsewhere, the
8//! revocation status of a signing certificate, the DID document of an identity
9//! issuer, content stored outside the asset - the kernel records the need and
10//! reports it. Nothing is fetched unless the caller said yes.
11//!
12//! A verification that fetches runs twice. Pass 1 is the ordinary offline
13//! verification and produces the list of needs. The fetcher retrieves what was
14//! allowed. Pass 2 is another ordinary offline verification, with the fetched
15//! material handed in as caller-supplied evidence. The same bytes and the same
16//! evidence therefore always produce the same verdict, and the network can
17//! only ever add evidence, never change how it is judged.
18//!
19//! The fetcher is deliberately narrow. It speaks https only, except to OCSP
20//! responders, whose answers are signed and verified before they count. Every
21//! hostname is resolved through a filter that rejects loopback, private,
22//! link-local, carrier-NAT, unique-local, multicast and documentation
23//! addresses, and the connection uses the address that was vetted, so a name
24//! that changes its answer between the check and the connection cannot reach
25//! an internal service. Asset bytes never leave the machine: an OCSP request
26//! carries a certificate serial number and issuer hashes, nothing else.
27
28use std::collections::HashMap;
29
30use serde::{Deserialize, Serialize};
31use serde_json::Value;
32
33use crate::online_consent::{
34    environment_online, interactive_online_consent_allowed, prompt_for_online_consent,
35    saved_online_preference, OnlinePreference,
36};
37use crate::VerifyOptions;
38
39/// Largest manifest store this SDK will accept from a server, matching the
40/// limit the offline entry points already apply to a caller-supplied store.
41const MAX_MANIFEST_BYTES: usize = 64 * 1024 * 1024;
42/// An OCSP response is a signed status for one certificate. Anything larger is
43/// not one.
44const MAX_OCSP_BYTES: usize = 64 * 1024;
45/// A DID document is a small JSON object.
46const MAX_DID_DOCUMENT_BYTES: usize = 256 * 1024;
47/// Externally stored assertion content, bounded like a manifest store.
48const MAX_EXTERNAL_DATA_BYTES: usize = 64 * 1024 * 1024;
49/// Total HTTP requests one verification may make, redirects included.
50const MAX_REQUESTS: usize = 16;
51
52pub(crate) use crate::c2pa_validate::{NetworkNeed, OcspPurpose};
53
54/// What a verification did, or could have done, on the network.
55///
56/// `needed` lists what a fetch could settle and is filled in whether or not
57/// online checks were allowed, so an offline caller can see exactly what
58/// turning them on would do. Its entries are the kernel's own description of
59/// each need. `requests` records every attempt, including the ones that were
60/// refused.
61#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
62#[serde(default)]
63pub struct NetworkReport {
64    /// Whether this verification was allowed to fetch.
65    pub enabled: bool,
66    /// Every network resource this asset references.
67    pub needed: Vec<Value>,
68    /// Every request that was attempted.
69    pub requests: Vec<NetworkRequest>,
70}
71
72impl NetworkReport {
73    /// Record that a body arrived but could not be used for what it was
74    /// fetched for. The request happened, so it is not removed; its outcome
75    /// stops being a success.
76    pub(crate) fn mark_unusable(&mut self, purpose: &str, detail: String) {
77        if let Some(request) = self
78            .requests
79            .iter_mut()
80            .find(|request| request.purpose == purpose && request.outcome == "fetched")
81        {
82            request.outcome = "failed".to_string();
83            request.detail = detail;
84        }
85    }
86}
87
88/// One attempted fetch.
89#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
90pub struct NetworkRequest {
91    /// `remote_manifest`, `ocsp.claim_signer`, `ocsp.cawg_identity`,
92    /// `did_document`, or `external_data`.
93    pub purpose: String,
94    /// The URL that was requested.
95    pub url: String,
96    /// `fetched`, `failed`, `blocked`, or `skipped`.
97    ///
98    /// `blocked` means this SDK refused: a forbidden address, a plaintext URL,
99    /// an oversized body, too many redirects. `failed` means the server or the
100    /// network did not deliver. `skipped` means the request was never made.
101    pub outcome: String,
102    /// Why, in one line.
103    pub detail: String,
104}
105
106/// Material fetched for pass 2, in the shape the offline entry points already
107/// accept from a caller who fetched it themselves.
108#[derive(Debug, Clone, Default, PartialEq, Eq)]
109pub(crate) struct OnlineEvidence {
110    /// A manifest store retrieved from the URI the asset declares.
111    pub(crate) manifest_store: Option<Vec<u8>>,
112    /// Certificate SHA-256 (lowercase hex) -> base64 DER OCSPResponse.
113    pub(crate) ocsp_responses: HashMap<String, String>,
114    /// Certificate SHA-256 (lowercase hex) whose responder gave no usable answer.
115    pub(crate) ocsp_unreachable: Vec<String>,
116    /// Need URI -> base64 content bytes.
117    pub(crate) external_data: HashMap<String, String>,
118    /// Primary DID -> DID document.
119    pub(crate) did_documents: HashMap<String, Value>,
120}
121
122impl OnlineEvidence {
123    pub(crate) fn is_empty(&self) -> bool {
124        self.manifest_store.is_none()
125            && self.ocsp_responses.is_empty()
126            && self.ocsp_unreachable.is_empty()
127            && self.external_data.is_empty()
128            && self.did_documents.is_empty()
129    }
130}
131
132impl NetworkNeed {
133    /// The token used in [`NetworkRequest::purpose`].
134    pub(crate) fn purpose_token(&self) -> &'static str {
135        match self {
136            Self::RemoteManifest { .. } => "remote_manifest",
137            Self::Ocsp {
138                purpose: OcspPurpose::ClaimSigner,
139                ..
140            } => "ocsp.claim_signer",
141            Self::Ocsp { .. } => "ocsp.cawg_identity",
142            Self::DidDocument { .. } => "did_document",
143            Self::ExternalData { .. } => "external_data",
144        }
145    }
146
147    /// The URL that would be contacted.
148    pub(crate) fn url(&self) -> &str {
149        match self {
150            Self::RemoteManifest { uri } => uri,
151            Self::Ocsp { responder_url, .. } => responder_url,
152            Self::DidDocument { url, .. } => url,
153            Self::ExternalData { uri, .. } => uri,
154        }
155    }
156
157    /// One prompt line: what this is for, and who would learn about the file.
158    fn consent_line(&self) -> String {
159        let what = match self {
160            Self::RemoteManifest { .. } => "the manifest store this file points to".to_string(),
161            Self::Ocsp {
162                purpose: OcspPurpose::ClaimSigner,
163                ..
164            } => "whether the signing certificate has been revoked".to_string(),
165            Self::Ocsp {
166                purpose: OcspPurpose::CawgIdentity { assertion_label },
167                ..
168            } => format!("whether the identity certificate in {assertion_label} has been revoked"),
169            Self::DidDocument { did, .. } => format!("the issuer document for {did}"),
170            Self::ExternalData {
171                assertion_label, ..
172            } => format!("content stored outside the file, for {assertion_label}"),
173        };
174        format!("{what}, from {}", host_of(self.url()))
175    }
176}
177
178/// The needs recorded by one kernel verification.
179pub(crate) fn network_needs(output: &crate::c2pa_validate::VerifyOutput) -> Vec<NetworkNeed> {
180    output.network_needs.clone()
181}
182
183/// Decide whether this verification may fetch, fetch what it may, and report.
184///
185/// The decision order is explicit option, then the `ENCYPHER_C2PA_ONLINE`
186/// operator override, then - on a terminal surface only - the saved choice and
187/// a prompt. A library call stops after the environment variable: it may be
188/// verifying an untrusted file on a server, where a choice somebody made at a
189/// laptop must not switch fetching on.
190pub(crate) fn gather(
191    options: &VerifyOptions,
192    needs: &[NetworkNeed],
193) -> (NetworkReport, OnlineEvidence) {
194    let needed: Vec<Value> = needs.iter().map(NetworkNeed::to_json).collect();
195    let enabled = resolve_online(options, needs);
196    if !enabled || needs.is_empty() {
197        return (
198            NetworkReport {
199                enabled,
200                needed,
201                requests: Vec::new(),
202            },
203            OnlineEvidence::default(),
204        );
205    }
206    let fetcher = new_fetcher(options.online_allow_private_networks);
207    let (requests, evidence) = fetch_all(fetcher.as_ref(), needs);
208    (
209        NetworkReport {
210            enabled: true,
211            needed,
212            requests,
213        },
214        evidence,
215    )
216}
217
218fn resolve_online(options: &VerifyOptions, needs: &[NetworkNeed]) -> bool {
219    if let Some(explicit) = options.online {
220        return explicit;
221    }
222    match environment_online() {
223        Ok(Some(enabled)) => return enabled,
224        Ok(None) => {}
225        // An operator typed something this SDK does not understand. Fetching is
226        // the side with consequences, so an unreadable override stays off.
227        Err(_) => return false,
228    }
229    if !interactive_online_consent_allowed() || needs.is_empty() {
230        return false;
231    }
232    match saved_online_preference() {
233        Ok(Some(OnlinePreference::On)) => true,
234        Ok(Some(OnlinePreference::Off)) => false,
235        Ok(Some(OnlinePreference::Ask)) | Ok(None) => {
236            let lines: Vec<String> = needs.iter().map(NetworkNeed::consent_line).collect();
237            prompt_for_online_consent(&lines)
238                .ok()
239                .flatten()
240                .unwrap_or(false)
241        }
242        Err(_) => false,
243    }
244}
245
246/// Build the options for pass 2 from what was fetched.
247pub(crate) fn apply_evidence(options: &VerifyOptions, evidence: &OnlineEvidence) -> VerifyOptions {
248    let mut next = options.clone();
249    // Pass 2 verifies with what pass 1 obtained. It never fetches again, so a
250    // server that answers differently on a second request cannot extend the
251    // work one verification does.
252    next.online = Some(false);
253    if !evidence.did_documents.is_empty() {
254        let mut documents = next.cawg_did_documents.unwrap_or_default();
255        documents.extend(
256            evidence
257                .did_documents
258                .iter()
259                .map(|(did, document)| (did.clone(), document.clone())),
260        );
261        next.cawg_did_documents = Some(documents);
262    }
263    if !evidence.ocsp_responses.is_empty() {
264        let mut responses = next.ocsp_responses.unwrap_or_default();
265        responses.extend(evidence.ocsp_responses.clone());
266        next.ocsp_responses = Some(responses);
267    }
268    if !evidence.ocsp_unreachable.is_empty() {
269        let mut unreachable = next.ocsp_unreachable.unwrap_or_default();
270        unreachable.extend(evidence.ocsp_unreachable.iter().cloned());
271        next.ocsp_unreachable = Some(unreachable);
272    }
273    if !evidence.external_data.is_empty() {
274        let mut data = next.external_data.unwrap_or_default();
275        data.extend(evidence.external_data.clone());
276        next.external_data = Some(data);
277    }
278    next
279}
280
281// ---------------------------------------------------------------------------
282// Fetching
283// ---------------------------------------------------------------------------
284
285#[derive(Debug, Clone, Copy, PartialEq, Eq)]
286pub(crate) enum FetchMethod {
287    Get,
288    Post,
289}
290
291/// One outbound request, fully described by policy rather than by the caller.
292#[cfg_attr(
293    not(feature = "online"),
294    allow(dead_code, reason = "only the native fetcher reads a request")
295)]
296pub(crate) struct FetchRequest<'a> {
297    pub(crate) url: &'a str,
298    pub(crate) method: FetchMethod,
299    pub(crate) body: Option<&'a [u8]>,
300    pub(crate) content_type: Option<&'a str>,
301    pub(crate) accept: Option<&'a str>,
302    /// Maximum body this request may accept.
303    pub(crate) limit: usize,
304    /// True only for OCSP, whose responses are signed and verified.
305    pub(crate) allow_http: bool,
306}
307
308#[derive(Debug)]
309pub(crate) struct FetchOk {
310    pub(crate) bytes: Vec<u8>,
311    pub(crate) requests_used: usize,
312}
313
314#[derive(Debug)]
315pub(crate) struct FetchErr {
316    /// True when this SDK refused, false when the network or server did.
317    pub(crate) blocked: bool,
318    pub(crate) detail: String,
319    pub(crate) requests_used: usize,
320}
321
322pub(crate) type FetchResult = Result<FetchOk, FetchErr>;
323
324pub(crate) trait Fetcher {
325    fn fetch(&self, request: &FetchRequest<'_>) -> FetchResult;
326}
327
328fn blocked(detail: impl Into<String>, requests_used: usize) -> FetchErr {
329    FetchErr {
330        blocked: true,
331        detail: detail.into(),
332        requests_used,
333    }
334}
335
336/// Run every need through `fetcher`, within the request budget.
337pub(crate) fn fetch_all(
338    fetcher: &dyn Fetcher,
339    needs: &[NetworkNeed],
340) -> (Vec<NetworkRequest>, OnlineEvidence) {
341    let mut requests = Vec::with_capacity(needs.len());
342    let mut evidence = OnlineEvidence::default();
343    let mut budget = MAX_REQUESTS;
344
345    for need in needs {
346        let purpose = need.purpose_token().to_string();
347        let url = need.url().to_string();
348        if budget == 0 {
349            requests.push(NetworkRequest {
350                purpose,
351                url,
352                outcome: "skipped".to_string(),
353                detail: format!("the {MAX_REQUESTS} request budget for one verification is spent"),
354            });
355            if let NetworkNeed::Ocsp {
356                certificate_sha256_hex,
357                ..
358            } = need
359            {
360                push_unreachable(&mut evidence, certificate_sha256_hex);
361            }
362            continue;
363        }
364
365        let request = match need {
366            NetworkNeed::RemoteManifest { uri } => FetchRequest {
367                url: uri,
368                method: FetchMethod::Get,
369                body: None,
370                content_type: None,
371                accept: Some("application/c2pa"),
372                limit: MAX_MANIFEST_BYTES,
373                allow_http: false,
374            },
375            NetworkNeed::Ocsp {
376                responder_url,
377                request_der,
378                ..
379            } => FetchRequest {
380                url: responder_url,
381                method: FetchMethod::Post,
382                body: Some(request_der),
383                content_type: Some("application/ocsp-request"),
384                accept: Some("application/ocsp-response"),
385                limit: MAX_OCSP_BYTES,
386                // RFC 6960 responders are reached over plain HTTP by design:
387                // the response is signed, and this SDK verifies that signature
388                // before the answer counts for anything.
389                allow_http: true,
390            },
391            NetworkNeed::DidDocument { url, .. } => FetchRequest {
392                url,
393                method: FetchMethod::Get,
394                body: None,
395                content_type: None,
396                accept: Some("application/did+json, application/json"),
397                limit: MAX_DID_DOCUMENT_BYTES,
398                allow_http: false,
399            },
400            NetworkNeed::ExternalData { uri, .. } => FetchRequest {
401                url: uri,
402                method: FetchMethod::Get,
403                body: None,
404                content_type: None,
405                accept: None,
406                limit: MAX_EXTERNAL_DATA_BYTES,
407                allow_http: false,
408            },
409        };
410
411        let (outcome, detail) = match fetcher.fetch(&request) {
412            Ok(FetchOk {
413                bytes,
414                requests_used,
415            }) => {
416                budget = budget.saturating_sub(requests_used.max(1));
417                accept_evidence(&mut evidence, need, bytes)
418            }
419            Err(FetchErr {
420                blocked,
421                detail,
422                requests_used,
423            }) => {
424                budget = budget.saturating_sub(requests_used.max(1));
425                if let NetworkNeed::Ocsp {
426                    certificate_sha256_hex,
427                    ..
428                } = need
429                {
430                    push_unreachable(&mut evidence, certificate_sha256_hex);
431                }
432                (
433                    if blocked { "blocked" } else { "failed" }.to_string(),
434                    detail,
435                )
436            }
437        };
438        requests.push(NetworkRequest {
439            purpose,
440            url,
441            outcome,
442            detail,
443        });
444    }
445
446    (requests, evidence)
447}
448
449fn push_unreachable(evidence: &mut OnlineEvidence, certificate_sha256_hex: &str) {
450    if !evidence
451        .ocsp_unreachable
452        .iter()
453        .any(|existing| existing == certificate_sha256_hex)
454    {
455        evidence
456            .ocsp_unreachable
457            .push(certificate_sha256_hex.to_string());
458    }
459}
460
461/// File one fetched body under the evidence it is, and say what happened.
462fn accept_evidence(
463    evidence: &mut OnlineEvidence,
464    need: &NetworkNeed,
465    bytes: Vec<u8>,
466) -> (String, String) {
467    let length = bytes.len();
468    match need {
469        NetworkNeed::RemoteManifest { .. } => {
470            evidence.manifest_store = Some(bytes);
471            ("fetched".to_string(), format!("{length} bytes"))
472        }
473        NetworkNeed::Ocsp {
474            certificate_sha256_hex,
475            ..
476        } => {
477            evidence
478                .ocsp_responses
479                .insert(certificate_sha256_hex.clone(), base64_encode(&bytes));
480            ("fetched".to_string(), format!("{length} bytes"))
481        }
482        NetworkNeed::DidDocument { did, .. } => match serde_json::from_slice::<Value>(&bytes) {
483            Ok(document) => {
484                evidence.did_documents.insert(did.clone(), document);
485                ("fetched".to_string(), format!("{length} bytes"))
486            }
487            Err(error) => (
488                "failed".to_string(),
489                format!("the DID document is not JSON: {error}"),
490            ),
491        },
492        NetworkNeed::ExternalData { uri, .. } => {
493            evidence
494                .external_data
495                .insert(uri.clone(), base64_encode(&bytes));
496            ("fetched".to_string(), format!("{length} bytes"))
497        }
498    }
499}
500
501// ---------------------------------------------------------------------------
502// URL handling
503// ---------------------------------------------------------------------------
504
505/// Split a URL into scheme, authority, and the rest. `None` when it is not an
506/// absolute URL this SDK can reason about.
507fn split_url(url: &str) -> Option<(&str, &str, &str)> {
508    let (scheme, remainder) = url.split_once("://")?;
509    if scheme.is_empty() || remainder.is_empty() {
510        return None;
511    }
512    let end = remainder.find(['/', '?', '#']).unwrap_or(remainder.len());
513    let (authority, rest) = remainder.split_at(end);
514    if authority.is_empty() {
515        return None;
516    }
517    Some((scheme, authority, rest))
518}
519
520/// The hostname a URL would contact, for the consent prompt.
521fn host_of(url: &str) -> String {
522    let Some((_, authority, _)) = split_url(url) else {
523        return url.to_string();
524    };
525    let authority = authority
526        .rsplit_once('@')
527        .map_or(authority, |(_, host)| host);
528    if let Some(end) = authority.find(']') {
529        return authority[..=end].to_string();
530    }
531    authority
532        .split_once(':')
533        .map_or(authority, |(host, _)| host)
534        .to_string()
535}
536
537// ---------------------------------------------------------------------------
538// Base64
539// ---------------------------------------------------------------------------
540
541/// Standard base64 (RFC 4648) with padding, the encoding the caller-supplied
542/// evidence options already use.
543fn base64_encode(input: &[u8]) -> String {
544    const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
545    let mut out = String::with_capacity(input.len().div_ceil(3) * 4);
546    for chunk in input.chunks(3) {
547        let b0 = u32::from(chunk[0]);
548        let b1 = chunk.get(1).copied().map_or(0, u32::from);
549        let b2 = chunk.get(2).copied().map_or(0, u32::from);
550        let triple = (b0 << 16) | (b1 << 8) | b2;
551        out.push(ALPHABET[(triple >> 18) as usize & 0x3f] as char);
552        out.push(ALPHABET[(triple >> 12) as usize & 0x3f] as char);
553        out.push(if chunk.len() > 1 {
554            ALPHABET[(triple >> 6) as usize & 0x3f] as char
555        } else {
556            '='
557        });
558        out.push(if chunk.len() > 2 {
559            ALPHABET[triple as usize & 0x3f] as char
560        } else {
561            '='
562        });
563    }
564    out
565}
566
567// ---------------------------------------------------------------------------
568// The native fetcher
569// ---------------------------------------------------------------------------
570
571#[cfg(feature = "online")]
572fn new_fetcher(allow_private_networks: bool) -> Box<dyn Fetcher> {
573    Box::new(native::NativeFetcher::new(allow_private_networks))
574}
575
576#[cfg(not(feature = "online"))]
577fn new_fetcher(_allow_private_networks: bool) -> Box<dyn Fetcher> {
578    Box::new(DisabledFetcher)
579}
580
581/// The fetcher of a build compiled without the `online` feature. It refuses
582/// every request rather than pretending none was needed.
583#[cfg(not(feature = "online"))]
584struct DisabledFetcher;
585
586#[cfg(not(feature = "online"))]
587impl Fetcher for DisabledFetcher {
588    fn fetch(&self, _request: &FetchRequest<'_>) -> FetchResult {
589        Err(blocked(
590            "this build was compiled without the online feature",
591            0,
592        ))
593    }
594}
595
596#[cfg(feature = "online")]
597pub(crate) mod native {
598    use std::io::Read;
599    use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr, ToSocketAddrs};
600    use std::time::Duration;
601    use std::{io, net};
602
603    use super::{
604        blocked, split_url, FetchErr, FetchMethod, FetchOk, FetchRequest, FetchResult, Fetcher,
605    };
606
607    /// Redirect hops allowed per request. Every hop is re-checked.
608    const MAX_REDIRECTS: usize = 3;
609    const CONNECT_TIMEOUT: Duration = Duration::from_secs(5);
610    const REQUEST_TIMEOUT: Duration = Duration::from_secs(10);
611    const USER_AGENT: &str = concat!("encypher-c2pa/", env!("CARGO_PKG_VERSION"));
612    /// Marker planted in the resolver's error so the fetcher can tell a refusal
613    /// by policy from a name that simply does not resolve.
614    const BLOCKED_ADDRESS_MARKER: &str = "address is not permitted";
615
616    fn failed(detail: impl Into<String>, requests_used: usize) -> FetchErr {
617        FetchErr {
618            blocked: false,
619            detail: detail.into(),
620            requests_used,
621        }
622    }
623
624    /// Why an address may not be contacted, or `None` when it may.
625    ///
626    /// This is the whole address policy in one readable function, so it can be
627    /// tested one range at a time.
628    pub(crate) fn blocked_reason(address: IpAddr) -> Option<&'static str> {
629        match address {
630            IpAddr::V4(address) => blocked_reason_v4(address),
631            IpAddr::V6(address) => blocked_reason_v6(address),
632        }
633    }
634
635    fn blocked_reason_v4(address: Ipv4Addr) -> Option<&'static str> {
636        let [a, b, c, _] = address.octets();
637        Some(match (a, b, c) {
638            (0, _, _) => "unspecified network",
639            (10, _, _) => "private network",
640            (100, 64..=127, _) => "carrier-grade NAT",
641            (127, _, _) => "loopback",
642            (169, 254, _) => "link-local, including the cloud metadata service",
643            (172, 16..=31, _) => "private network",
644            (192, 0, 0) => "IETF protocol assignment",
645            (192, 0, 2) => "documentation",
646            (192, 168, _) => "private network",
647            (198, 18..=19, _) => "benchmarking",
648            (198, 51, 100) => "documentation",
649            (203, 0, 113) => "documentation",
650            (224..=239, _, _) => "multicast",
651            (240..=255, _, _) => "reserved or broadcast",
652            _ => return None,
653        })
654    }
655
656    fn blocked_reason_v6(address: Ipv6Addr) -> Option<&'static str> {
657        // An address that carries an IPv4 one - mapped, compatible, 6to4,
658        // NAT64 - reaches the IPv4 host it names, so it is judged as that host.
659        if let Some(embedded) = embedded_ipv4(address) {
660            if let Some(reason) = blocked_reason_v4(embedded) {
661                return Some(reason);
662            }
663        }
664        let segments = address.segments();
665        if address.is_unspecified() {
666            return Some("unspecified address");
667        }
668        if address.is_loopback() {
669            return Some("loopback");
670        }
671        if address.is_multicast() {
672            return Some("multicast");
673        }
674        Some(match segments[0] {
675            first if first & 0xfe00 == 0xfc00 => "unique local",
676            first if first & 0xffc0 == 0xfe80 => "link-local",
677            0x2001 if segments[1] == 0x0db8 => "documentation",
678            0x0100 if segments[1] == 0 && segments[2] == 0 && segments[3] == 0 => "discard-only",
679            _ => return None,
680        })
681    }
682
683    fn embedded_ipv4(address: Ipv6Addr) -> Option<Ipv4Addr> {
684        let segments = address.segments();
685        let octets = address.octets();
686        let tail = Ipv4Addr::new(octets[12], octets[13], octets[14], octets[15]);
687        // ::ffff:a.b.c.d (mapped) and ::a.b.c.d (compatible, deprecated).
688        if segments[0..5] == [0, 0, 0, 0, 0] && (segments[5] == 0xffff || segments[5] == 0) {
689            return Some(tail);
690        }
691        // 2002:a.b.c.d::/16, the 6to4 prefix.
692        if segments[0] == 0x2002 {
693            return Some(Ipv4Addr::new(octets[2], octets[3], octets[4], octets[5]));
694        }
695        // 64:ff9b::/96 and 64:ff9b:1::/48, the NAT64 prefixes.
696        if segments[0] == 0x0064 && segments[1] == 0xff9b {
697            return Some(tail);
698        }
699        None
700    }
701
702    /// Resolve a `Location` header against the URL that produced it.
703    pub(crate) fn join_redirect(base: &str, location: &str) -> Option<String> {
704        let location = location.trim();
705        if location.is_empty() {
706            return None;
707        }
708        if location.contains("://") {
709            return Some(location.to_string());
710        }
711        let (scheme, authority, rest) = split_url(base)?;
712        if let Some(network_path) = location.strip_prefix("//") {
713            return Some(format!("{scheme}://{network_path}"));
714        }
715        if location.starts_with('/') {
716            return Some(format!("{scheme}://{authority}{location}"));
717        }
718        let path = rest.split(['?', '#']).next().unwrap_or("");
719        let directory = path.rfind('/').map_or("/", |index| &path[..=index]);
720        Some(format!("{scheme}://{authority}{directory}{location}"))
721    }
722
723    /// Resolves names and drops every address this SDK must not contact.
724    ///
725    /// The filter sits in the resolver rather than in front of it, so the
726    /// connection uses an address that was vetted. Checking a hostname and then
727    /// letting the stack resolve it again leaves a window in which the answer
728    /// can change to an internal one.
729    pub(crate) struct FilteringResolver {
730        pub(crate) allow_private_networks: bool,
731    }
732
733    impl ureq::Resolver for FilteringResolver {
734        fn resolve(&self, netloc: &str) -> io::Result<Vec<SocketAddr>> {
735            let resolved: Vec<SocketAddr> = netloc.to_socket_addrs()?.collect();
736            if self.allow_private_networks {
737                return Ok(resolved);
738            }
739            let mut refused: Option<(net::IpAddr, &'static str)> = None;
740            let permitted: Vec<SocketAddr> = resolved
741                .into_iter()
742                .filter(|address| match blocked_reason(address.ip()) {
743                    Some(reason) => {
744                        refused.get_or_insert((address.ip(), reason));
745                        false
746                    }
747                    None => true,
748                })
749                .collect();
750            if permitted.is_empty() {
751                let detail = match refused {
752                    Some((address, reason)) => format!(
753                        "{netloc} resolves to {address}, a {reason} {BLOCKED_ADDRESS_MARKER}"
754                    ),
755                    None => format!("{netloc} resolves to no address"),
756                };
757                return Err(io::Error::new(io::ErrorKind::PermissionDenied, detail));
758            }
759            Ok(permitted)
760        }
761    }
762
763    pub(crate) struct NativeFetcher {
764        agent: ureq::Agent,
765        /// Intranet mode: the address filter is off, and plaintext http is
766        /// allowed for every purpose rather than for OCSP alone. An operator
767        /// who sets it has declared this deployment's own network trusted; a
768        /// manifest repository on an internal host is routinely plain http.
769        intranet: bool,
770    }
771
772    impl NativeFetcher {
773        pub(crate) fn new(allow_private_networks: bool) -> Self {
774            Self {
775                agent: ureq::AgentBuilder::new()
776                    .timeout_connect(CONNECT_TIMEOUT)
777                    .timeout(REQUEST_TIMEOUT)
778                    // Redirects are followed here, one hop at a time, so that
779                    // every hop's scheme and address are checked afresh.
780                    .redirects(0)
781                    .user_agent(USER_AGENT)
782                    .resolver(FilteringResolver {
783                        allow_private_networks,
784                    })
785                    .build(),
786                intranet: allow_private_networks,
787            }
788        }
789    }
790
791    impl Fetcher for NativeFetcher {
792        fn fetch(&self, request: &FetchRequest<'_>) -> FetchResult {
793            let mut url = request.url.to_string();
794            let mut used = 0;
795
796            for hop in 0..=MAX_REDIRECTS {
797                match split_url(&url) {
798                    None => return Err(blocked(format!("{url} is not an absolute URL"), used)),
799                    Some((scheme, _, _)) if scheme.eq_ignore_ascii_case("https") => {}
800                    Some((scheme, _, _))
801                        if (request.allow_http || self.intranet)
802                            && scheme.eq_ignore_ascii_case("http") => {}
803                    Some((scheme, _, _)) => {
804                        return Err(blocked(
805                            format!("{scheme} is not allowed for this request; https is required"),
806                            used,
807                        ))
808                    }
809                }
810
811                used += 1;
812                let mut call = match request.method {
813                    FetchMethod::Get => self.agent.get(&url),
814                    FetchMethod::Post => self.agent.post(&url),
815                };
816                if let Some(content_type) = request.content_type {
817                    call = call.set("content-type", content_type);
818                }
819                if let Some(accept) = request.accept {
820                    call = call.set("accept", accept);
821                }
822                let response = match request.body {
823                    Some(body) => call.send_bytes(body),
824                    None => call.call(),
825                };
826                let response = match response {
827                    Ok(response) => response,
828                    Err(ureq::Error::Status(status, _)) => {
829                        return Err(failed(format!("the server answered HTTP {status}"), used))
830                    }
831                    Err(ureq::Error::Transport(transport)) => {
832                        let detail = transport.to_string();
833                        return Err(if detail.contains(BLOCKED_ADDRESS_MARKER) {
834                            blocked(detail, used)
835                        } else {
836                            failed(detail, used)
837                        });
838                    }
839                };
840
841                if (300..400).contains(&response.status()) {
842                    if hop == MAX_REDIRECTS {
843                        return Err(blocked(
844                            format!("more than {MAX_REDIRECTS} redirects"),
845                            used,
846                        ));
847                    }
848                    let Some(location) = response.header("location") else {
849                        return Err(failed("a redirect carried no location", used));
850                    };
851                    let Some(next) = join_redirect(&url, location) else {
852                        return Err(failed(format!("unusable redirect to {location}"), used));
853                    };
854                    url = next;
855                    continue;
856                }
857
858                return read_body(response, request.limit, used);
859            }
860            Err(blocked(
861                format!("more than {MAX_REDIRECTS} redirects"),
862                used,
863            ))
864        }
865    }
866
867    fn read_body(response: ureq::Response, limit: usize, used: usize) -> FetchResult {
868        let mut bytes = Vec::new();
869        let capped = u64::try_from(limit).unwrap_or(u64::MAX).saturating_add(1);
870        if let Err(error) = response.into_reader().take(capped).read_to_end(&mut bytes) {
871            return Err(failed(error.to_string(), used));
872        }
873        if bytes.len() > limit {
874            return Err(blocked(
875                format!("the response is larger than the {limit} byte limit"),
876                used,
877            ));
878        }
879        Ok(FetchOk {
880            bytes,
881            requests_used: used,
882        })
883    }
884}
885
886// Test-only: `online/tests.rs` carries `#![cfg(test)]` itself.
887mod tests;