Skip to main content

acdp_client/
data_ref.rs

1//! Data-reference fetching + hash verification (feature = "client").
2//!
3//! [`DataRef`] tells a consumer **where** a piece of underlying data
4//! lives; this module fetches it and verifies its integrity against the
5//! producer-signed `content_hash` (RFC-ACDP-0002 §6).
6//!
7//! Three pieces:
8//!
9//! - [`DataRefFetcher`] — trait that abstracts the fetch strategy. Native
10//!   async-fn-in-trait, so `impl DataRefFetcher` works directly in
11//!   generic positions. Wrap a custom impl in `Box<dyn …>` only if your
12//!   call site needs dynamic dispatch.
13//! - [`HttpsDataRefFetcher`] — concrete fetcher for `https://…` URIs.
14//!   The default [`acdp_safe_http::SsrfPolicy`] is HTTPS-only;
15//!   `http://` is rejected at the URL boundary before any socket
16//!   activity. A test SSRF policy with `allow_http: true` may relax
17//!   this. Caps response size at 16 MiB and has a 30 s timeout.
18//!   Structured locators are NOT handled — they need protocol-specific
19//!   knowledge.
20//! - [`fetch_and_verify_data_ref`] — convenience helper that wires a
21//!   fetcher to the declared `content_hash`, returning bytes only after
22//!   the SHA-256 matches.
23//!
24//! ## Embedded refs
25//!
26//! `fetch_and_verify_data_ref` short-circuits embedded refs without
27//! touching the fetcher — the bytes are already in the body. The
28//! embedded-hash check (RFC-ACDP-0003 §2.1 step 3) is the
29//! [`acdp_validation::verify_embedded_hash`] entry point.
30
31use sha2::{Digest, Sha256};
32
33use acdp_primitives::error::AcdpError;
34use acdp_safe_http::SsrfPolicy;
35use acdp_types::data_ref::{DataRef, Location};
36use acdp_types::primitives::ContentHash;
37
38/// Default response-size cap for an HTTPS data-ref fetch.
39///
40/// 16 MiB. Producers that need to publish larger payloads SHOULD use a
41/// chunked storage scheme (S3 multipart, IPFS, etc.) rather than serve
42/// raw HTTPS. The cap exists to bound consumer memory regardless of
43/// what the producer claimed in `size_bytes`.
44pub const DEFAULT_MAX_BYTES: u64 = 16 * 1024 * 1024;
45
46/// Pluggable fetch strategy for a [`DataRef`]. Implementations are
47/// responsible for SSRF defenses and response-size caps on URI fetches;
48/// structured locators are protocol-specific and likely need their own
49/// trait impl per scheme (`kafka.offset`, `ipfs.cid`, …).
50pub trait DataRefFetcher: Send + Sync {
51    /// Fetch raw bytes referenced by `location`. Implementations MAY
52    /// reject [`Location::Structured`] with a clear error rather than
53    /// implementing every scheme.
54    fn fetch(
55        &self,
56        location: &Location,
57    ) -> impl std::future::Future<Output = Result<Vec<u8>, AcdpError>> + Send;
58}
59
60/// Default HTTPS-only fetcher.
61///
62/// Enforces:
63/// - [`SsrfPolicy`] checks on every URL (HTTPS-only, IP-literal
64///   rejection, private-range blocking).
65/// - `Range`-free `GET` with a hard byte cap (default 16 MiB).
66/// - 30 s total timeout (matches RFC-ACDP-0006 §7.4 for registry RPCs).
67///
68/// Constructed via [`Self::new`] (default cap) or
69/// [`Self::with_max_bytes`].
70pub struct HttpsDataRefFetcher {
71    http: reqwest::Client,
72    ssrf_policy: SsrfPolicy,
73    max_bytes: u64,
74}
75
76impl Default for HttpsDataRefFetcher {
77    fn default() -> Self {
78        Self::new()
79    }
80}
81
82impl HttpsDataRefFetcher {
83    /// Build a fetcher with the default 16 MiB cap and the default
84    /// [`SsrfPolicy`] (HTTPS-only, no IP literals, no private ranges).
85    pub fn new() -> Self {
86        Self::with_max_bytes(DEFAULT_MAX_BYTES)
87    }
88
89    /// Build a fetcher with a custom response-size cap.
90    pub fn with_max_bytes(max_bytes: u64) -> Self {
91        let policy = SsrfPolicy::default();
92        let http = build_data_ref_http_client(&policy)
93            .expect("HttpsDataRefFetcher HTTP client build failed");
94        Self {
95            http,
96            ssrf_policy: policy,
97            max_bytes,
98        }
99    }
100
101    /// Replace the [`SsrfPolicy`] (useful for tests).
102    ///
103    /// SEC-02: this rebuilds the underlying `reqwest::Client` so the new
104    /// policy is actually applied at the DNS layer. The HTTP client
105    /// carries a [`SafeDnsResolver`](acdp_safe_http) hook, so the
106    /// resolver only takes effect on a client built *with* the policy —
107    /// mutating `ssrf_policy` alone would leave the old DNS filter wired
108    /// in.
109    pub fn with_ssrf_policy(mut self, policy: SsrfPolicy) -> Self {
110        self.http = build_data_ref_http_client(&policy)
111            .expect("rebuild HttpsDataRefFetcher HTTP client with new SSRF policy");
112        self.ssrf_policy = policy;
113        self
114    }
115}
116
117/// Build the `reqwest::Client` used by [`HttpsDataRefFetcher`].
118///
119/// SEC-02: mirrors `WebResolver`'s build path so a `DataRef` fetch gets
120/// the same SSRF defenses as DID resolution:
121///
122/// - `policy` is plumbed into reqwest's `dns_resolver` hook via
123///   [`SafeDnsResolver`](acdp_safe_http), so every resolved IP is
124///   filtered against the policy *before any TCP connect*. A
125///   producer-controlled `location` URL whose hostname resolves into a
126///   forbidden range (loopback, RFC 1918, link-local/IMDS, ULA, …) is
127///   refused at DNS time — defeating DNS rebinding (RFC-ACDP-0008 §4.8).
128/// - Redirects are capped at [`acdp_primitives::limits::MAX_REDIRECTS`] and must
129///   stay on the original request's authority; a cross-authority
130///   redirect is rejected.
131fn build_data_ref_http_client(policy: &SsrfPolicy) -> Result<reqwest::Client, AcdpError> {
132    use acdp_primitives::limits::MAX_REDIRECTS;
133
134    let redirect_policy = reqwest::redirect::Policy::custom(|attempt| {
135        if attempt.previous().len() >= MAX_REDIRECTS {
136            return attempt.error(format!(
137                "data_ref fetch: exceeded {MAX_REDIRECTS} redirects"
138            ));
139        }
140        // Same-authority enforcement (scheme + host + port) against the
141        // original request URL. RFC-ACDP-0008 §4.8.
142        let cross = attempt
143            .previous()
144            .first()
145            .filter(|orig| !acdp_safe_http::same_fetch_authority(orig, attempt.url()))
146            .map(|orig| (orig.to_string(), attempt.url().to_string()));
147        if let Some((from, to)) = cross {
148            return attempt.error(format!(
149                "data_ref fetch: cross-authority redirect rejected ({from} -> {to})"
150            ));
151        }
152        attempt.follow()
153    });
154
155    reqwest::Client::builder()
156        .use_rustls_tls()
157        .connect_timeout(std::time::Duration::from_secs(5))
158        .timeout(std::time::Duration::from_secs(30))
159        .redirect(redirect_policy)
160        .dns_resolver(acdp_safe_http::SafeDnsResolver::arc(policy.clone()))
161        .build()
162        .map_err(|e| AcdpError::Http(e.to_string()))
163}
164
165impl DataRefFetcher for HttpsDataRefFetcher {
166    async fn fetch(&self, location: &Location) -> Result<Vec<u8>, AcdpError> {
167        let uri = match location {
168            Location::Uri(s) => s,
169            Location::Structured(_) => {
170                return Err(AcdpError::NotImplemented(
171                    "HttpsDataRefFetcher does not handle structured locators \
172                     (kafka.offset, ipfs.cid, …) — implement DataRefFetcher \
173                     for the relevant scheme"
174                        .into(),
175                ));
176            }
177        };
178
179        // SSRF policy gate — RFC-ACDP-0006 §7.1/§7.2.
180        self.ssrf_policy.check_url(uri).map_err(|e| {
181            AcdpError::SchemaViolation(format!(
182                "{} data_ref location refused: {e}",
183                acdp_primitives::error::SSRF_POLICY_PREFIX
184            ))
185        })?;
186
187        let mut resp = self
188            .http
189            .get(uri)
190            .send()
191            .await
192            // `From<reqwest::Error>` keeps the source chain and classifies
193            // a DNS-time SSRF refusal as permanent (issue #321).
194            .map_err(AcdpError::from)?;
195
196        if !resp.status().is_success() {
197            return Err(AcdpError::Http(format!(
198                "data_ref fetch returned HTTP {}",
199                resp.status()
200            )));
201        }
202
203        // Cap response size as we stream — defends against a producer
204        // that claimed a small size_bytes but the server returns more.
205        let mut buf = Vec::with_capacity(8 * 1024);
206        while let Some(chunk) = resp.chunk().await.map_err(AcdpError::from)? {
207            if (buf.len() as u64).saturating_add(chunk.len() as u64) > self.max_bytes {
208                return Err(AcdpError::PayloadTooLarge(format!(
209                    "data_ref response exceeded {} bytes",
210                    self.max_bytes
211                )));
212            }
213            buf.extend_from_slice(&chunk);
214        }
215        Ok(buf)
216    }
217}
218
219/// Convenience: fetch a [`DataRef`] and verify its declared
220/// `content_hash`.
221///
222/// Behavior:
223/// - **Embedded ref:** returns the decoded bytes via
224///   [`acdp_validation::embedded_decoded_bytes`]. If the ref declares
225///   `embedded.content_hash` (RFC-ACDP-0002 §6.3/§6.6 Check 8 — the only
226///   field this obligation is scoped to; a root-level `content_hash` on
227///   an embedded ref carries no verification obligation and is not
228///   checked here, by design — see [`acdp_validation::verify_embedded_hash`]'s
229///   own doc comment), that function has already verified it at
230///   validation time; this function re-verifies as a defense-in-depth
231///   check.
232/// - **URI ref:** delegates to `fetcher` and recomputes SHA-256 over the
233///   returned bytes, checking against `dr.content_hash` when present.
234///   If `content_hash` is absent, returns the bytes unverified — the
235///   producer chose not to commit to a hash, so the consumer is on its own.
236/// - **Both URI and embedded:** rejected at validation; this function
237///   relies on that and assumes exactly one is present.
238pub async fn fetch_and_verify_data_ref(
239    dr: &DataRef,
240    fetcher: &impl DataRefFetcher,
241) -> Result<Vec<u8>, AcdpError> {
242    if let Some(emb) = &dr.embedded {
243        let bytes = acdp_validation::embedded_decoded_bytes(emb)?;
244        if dr.content_hash.is_some() || emb.content_hash.is_some() {
245            acdp_validation::verify_embedded_hash(dr)?;
246        }
247        return Ok(bytes);
248    }
249    let Some(location) = &dr.location else {
250        return Err(AcdpError::SchemaViolation(
251            "data_ref has neither embedded nor location — cannot fetch".into(),
252        ));
253    };
254    let bytes = fetcher.fetch(location).await?;
255    if let Some(declared) = &dr.content_hash {
256        check_sha256(&bytes, declared)?;
257    }
258    Ok(bytes)
259}
260
261fn check_sha256(bytes: &[u8], declared: &ContentHash) -> Result<(), AcdpError> {
262    let Some(declared_hex) = declared.as_str().strip_prefix("sha256:") else {
263        return Err(AcdpError::SchemaViolation(format!(
264            "data_ref content_hash must start with 'sha256:', got '{}'",
265            declared.as_str()
266        )));
267    };
268    let got = hex::encode(Sha256::digest(bytes));
269    if got != declared_hex {
270        // BUG-02: a content-hash mismatch on external data is a
271        // data-reference-level integrity failure, not a body-level hash
272        // failure and not a signature failure. `invalid_signature`
273        // implies the producer's Ed25519 signature didn't verify (a
274        // key/key-binding problem); `hash_mismatch` implies the whole
275        // body is unverifiable. Neither is true here — the body is
276        // fine, only the bytes at this one location have diverged
277        // (RFC-ACDP-0007 §5 "Distinguishing hash failures", data-ref-008).
278        return Err(AcdpError::DataRefHashMismatch(format!(
279            "data_ref content_hash mismatch: declared sha256:{declared_hex}, computed sha256:{got}"
280        )));
281    }
282    Ok(())
283}
284
285#[cfg(test)]
286mod tests {
287    use super::*;
288    use acdp_types::data_ref::{DataRefType, EmbeddedContent, EmbeddedEncoding};
289    use sha2::{Digest, Sha256};
290
291    /// Stub fetcher returning canned bytes — used to test the
292    /// fetch-and-verify wrapper without touching the network.
293    struct StubFetcher {
294        bytes: Vec<u8>,
295    }
296    impl DataRefFetcher for StubFetcher {
297        async fn fetch(&self, _location: &Location) -> Result<Vec<u8>, AcdpError> {
298            Ok(self.bytes.clone())
299        }
300    }
301
302    #[tokio::test]
303    async fn fetch_and_verify_uri_ref_passes_with_matching_hash() {
304        let bytes = b"hello-world".to_vec();
305        let hash = format!("sha256:{}", hex::encode(Sha256::digest(&bytes)));
306        let dr = DataRef::uri_verified(
307            DataRefType::RawData,
308            "https://example.com/data",
309            ContentHash(hash),
310        );
311        let got = fetch_and_verify_data_ref(
312            &dr,
313            &StubFetcher {
314                bytes: bytes.clone(),
315            },
316        )
317        .await
318        .unwrap();
319        assert_eq!(got, bytes);
320    }
321
322    #[tokio::test]
323    async fn fetch_and_verify_uri_ref_fails_on_hash_mismatch() {
324        let dr = DataRef::uri_verified(
325            DataRefType::RawData,
326            "https://example.com/data",
327            ContentHash(format!("sha256:{}", "0".repeat(64))),
328        );
329        let err = fetch_and_verify_data_ref(
330            &dr,
331            &StubFetcher {
332                bytes: b"different bytes".to_vec(),
333            },
334        )
335        .await
336        .unwrap_err();
337        // BUG-02: data-ref hash mismatch is a data-reference-level
338        // integrity failure — `data_ref_hash_mismatch`, distinct from
339        // body-level `hash_mismatch` and from `invalid_signature`.
340        assert!(
341            matches!(err, AcdpError::DataRefHashMismatch(_)),
342            "expected DataRefHashMismatch, got {err:?}"
343        );
344    }
345
346    #[tokio::test]
347    async fn fetch_and_verify_uri_ref_without_declared_hash_returns_bytes_unverified() {
348        let dr = DataRef::uri(DataRefType::RawData, "https://example.com/data");
349        let got = fetch_and_verify_data_ref(
350            &dr,
351            &StubFetcher {
352                bytes: b"unverified".to_vec(),
353            },
354        )
355        .await
356        .unwrap();
357        assert_eq!(got, b"unverified");
358    }
359
360    #[tokio::test]
361    async fn fetch_and_verify_embedded_ref_returns_decoded_bytes() {
362        use base64::{engine::general_purpose::STANDARD, Engine};
363        let payload = b"embedded-bytes";
364        let encoded = STANDARD.encode(payload);
365        let dr = DataRef {
366            ref_type: DataRefType::RawData,
367            description: None,
368            size_bytes: None,
369            format: None,
370            schema_version: None,
371            content_hash: None,
372            location: None,
373            embedded: Some(EmbeddedContent {
374                encoding: EmbeddedEncoding::Base64,
375                content: serde_json::json!(encoded),
376                content_hash: None,
377            }),
378            extensions: serde_json::Map::new(),
379        };
380        let got = fetch_and_verify_data_ref(&dr, &StubFetcher { bytes: vec![] })
381            .await
382            .unwrap();
383        assert_eq!(got, payload);
384    }
385
386    /// SSRF policy rejects HTTP-only URIs at the boundary, before the
387    /// stub fetcher ever runs. This verifies the fetcher-side gate; the
388    /// helper itself just defers to whatever the fetcher returns.
389    #[tokio::test]
390    async fn https_fetcher_rejects_http_uri() {
391        let f = HttpsDataRefFetcher::new();
392        let err = f
393            .fetch(&Location::Uri("http://insecure.example.com/x".into()))
394            .await
395            .unwrap_err();
396        assert!(matches!(err, AcdpError::SchemaViolation(_)));
397    }
398
399    /// Issue #321: a hostname that passes `check_url` but resolves into a
400    /// forbidden range (`localhost` → loopback) is refused at DNS time by
401    /// `SafeDnsResolver`. That refusal MUST surface as the permanent
402    /// `SchemaViolation` with the SSRF detail intact — not as a transient
403    /// `Http` carrying only reqwest's outer "error sending request" frame.
404    #[tokio::test]
405    async fn https_fetcher_classifies_dns_time_ssrf_refusal_as_permanent() {
406        let f = HttpsDataRefFetcher::new();
407        let err = f
408            .fetch(&Location::Uri("https://localhost:9/data.csv".into()))
409            .await
410            .unwrap_err();
411        let AcdpError::SchemaViolation(msg) = &err else {
412            panic!("expected SchemaViolation for a DNS-time SSRF refusal, got {err:?}");
413        };
414        assert!(
415            msg.contains("SSRF policy") && msg.contains("forbidden"),
416            "the SSRF detail from the source chain MUST reach the message, got: {msg}"
417        );
418        assert!(!err.is_transient());
419    }
420
421    /// Structured locators surface NotImplemented from the HTTPS fetcher
422    /// — a custom fetcher would override.
423    #[tokio::test]
424    async fn https_fetcher_rejects_structured_locator() {
425        let f = HttpsDataRefFetcher::new();
426        let mut m = serde_json::Map::new();
427        m.insert("scheme".into(), serde_json::json!("kafka.offset"));
428        let err = f.fetch(&Location::Structured(m)).await.unwrap_err();
429        assert!(matches!(err, AcdpError::NotImplemented(_)));
430    }
431
432    /// data-ref-ssrf-001 — an external `data_refs[].location` whose host
433    /// is an IP literal in a private / loopback / link-local / IMDS
434    /// range MUST be refused before any connection (RFC-ACDP-0008 §4.9).
435    /// The default `SsrfPolicy` rejects IP-literal URLs at `check_url`,
436    /// so no socket activity occurs.
437    #[tokio::test]
438    async fn https_fetcher_rejects_ip_literal_private_location() {
439        let f = HttpsDataRefFetcher::new();
440        for uri in [
441            "https://10.0.0.1/data.csv",
442            "https://127.0.0.1/data.csv",
443            "https://[::1]/data.csv",
444            "https://169.254.169.254/latest/meta-data/",
445            "https://192.168.1.10/export.parquet",
446        ] {
447            let err = f.fetch(&Location::Uri(uri.into())).await.unwrap_err();
448            assert!(
449                matches!(err, AcdpError::SchemaViolation(_)),
450                "data-ref-ssrf-001: '{uri}' must be refused by the SSRF policy, got {err:?}"
451            );
452        }
453    }
454
455    /// data-ref-ssrf-002 — an external `data_refs[].location` whose host
456    /// is a syntactically public DNS name that *resolves* to a loopback
457    /// address MUST be refused. The `SafeDnsResolver` DNS hook filters
458    /// the resolved IP before any TCP connect, defeating DNS rebinding.
459    /// `localhost` stands in for the fixture's synthetic hostname — it
460    /// always resolves to a loopback address.
461    #[tokio::test]
462    async fn https_fetcher_blocks_hostname_resolving_to_loopback() {
463        let f = HttpsDataRefFetcher::new();
464        let err = f
465            .fetch(&Location::Uri("https://localhost/data.csv".into()))
466            .await
467            .unwrap_err();
468        // The hostname passes `check_url` (not an IP literal); the
469        // SafeDnsResolver refuses the resolved loopback IP, surfacing as
470        // a transport error rather than a successful fetch.
471        assert!(
472            !matches!(err, AcdpError::NotImplemented(_)),
473            "data-ref-ssrf-002: loopback-resolving host must be blocked, got {err:?}"
474        );
475    }
476
477    /// data-ref-ssrf-002 escape hatch — a test harness MAY opt into
478    /// loopback via a non-default SSRF policy. With `allow_test_loopback`
479    /// the DNS filter no longer refuses `localhost`, so the fetch fails
480    /// only on the connection itself (nothing is listening) rather than
481    /// on policy — i.e. it is no longer an SSRF refusal.
482    #[cfg(feature = "test-transport")]
483    #[tokio::test]
484    async fn https_fetcher_allow_test_loopback_permits_localhost_dns() {
485        let f = HttpsDataRefFetcher::new()
486            .with_ssrf_policy(acdp_safe_http::SsrfPolicy::allow_test_loopback());
487        // No server is listening, so this still errors — but the point
488        // is that `with_ssrf_policy` rebuilt the client with the relaxed
489        // DNS resolver (SEC-02); the policy, not a stale resolver, now
490        // governs the fetch.
491        let _ = f
492            .fetch(&Location::Uri("https://localhost:1/data.csv".into()))
493            .await;
494    }
495}