Skip to main content

dig_urn_resolver/
resolver.rs

1//! The resolver — orchestrates URN → data over the §5.3 node-first ladder.
2//!
3//! # Three outcomes, deliberately kept distinct
4//!
5//! A resolve returns `Result<`[`ResolveOutcome`]`, `[`ResolveError`]`>`:
6//!
7//! * [`ResolveOutcome::Success`] — verified, decrypted content (node path:
8//!   server-side decrypted under loopback trust; rpc path: client-verified against
9//!   the chain-anchored root then decrypted).
10//! * [`ResolveOutcome::IntegrityFailure`] — bytes WERE fetched but failed the merkle
11//!   inclusion / decrypt-verify (tampered / decoy / wrong root). A hard, fail-CLOSED
12//!   SECURITY outcome: the unverified bytes are discarded and NEVER returned.
13//! * [`ResolveOutcome::Unreachable`] — every transport tier was down; nothing was
14//!   fetched. A friendly, retryable network condition.
15//!
16//! `IntegrityFailure` (reached the network, bytes don't verify — security) and
17//! `Unreachable` (couldn't reach the network — retryable) are never conflated.
18//!
19//! A malformed URN, a not-found resource, and a reachable rpc PROTOCOL error remain
20//! hard [`ResolveError`]s.
21
22use crate::cache::{self, DiskArtifacts, MemoryCache};
23use crate::error::{ResolveError, Result};
24use crate::ladder::{self, Endpoint, EndpointKind};
25use crate::pages;
26use crate::transport::HttpTransport;
27use crate::urn::ParsedUrn;
28use crate::{node, rpc};
29// Used only by the disk-cache re-verify path (native std::fs OR wasm Node `fs`).
30#[cfg(any(feature = "native", feature = "wasm"))]
31use crate::{content_type, crypto};
32use std::cell::RefCell;
33
34/// The internal result of one endpoint fetch: the resolved data plus, when known,
35/// the CONCRETE content root (for the cache identity) and the verifiable artifacts
36/// (rpc path only) that let the disk cache re-verify a hit.
37pub(crate) struct Fetched {
38    /// The verified, decrypted resource.
39    pub data: ResolvedData,
40    /// The concrete resolved root (pinned root on the rpc path, `X-Dig-Root` on the
41    /// node path) — `None` when the tier did not expose it (then it is not cached).
42    pub root: Option<String>,
43    /// The verifiable rpc artifacts (ciphertext + proof + chunk lens) for the disk
44    /// cache; `None` when the bytes are not re-verifiable. Consumed by the disk cache
45    /// (native std::fs, or the wasm Node `fs` backend); ignored when no disk cache is
46    /// configured or when running clientside (browser has no filesystem).
47    #[cfg_attr(not(any(feature = "native", feature = "wasm")), allow(dead_code))]
48    pub artifacts: Option<DiskArtifacts>,
49}
50
51/// The resolved bytes plus their content type. Only ever the VERIFIED content of a
52/// [`ResolveOutcome::Success`], or the branded HTML of a rendered non-success
53/// outcome (see [`ResolveOutcome::render`]).
54#[derive(Debug, Clone, PartialEq, Eq)]
55pub struct ResolvedData {
56    /// The resource bytes.
57    pub bytes: Vec<u8>,
58    /// The MIME type.
59    pub content_type: String,
60}
61
62impl ResolvedData {
63    /// Construct resolved data.
64    pub fn new(bytes: Vec<u8>, content_type: String) -> Self {
65        ResolvedData {
66            bytes,
67            content_type,
68        }
69    }
70}
71
72/// The typed result of a resolve. The three cases are exhaustive and never
73/// conflated (see the module docs).
74#[derive(Debug, Clone, PartialEq, Eq)]
75pub enum ResolveOutcome {
76    /// Verified, decrypted content.
77    Success(ResolvedData),
78    /// The served bytes failed integrity verification — a hard, fail-closed
79    /// security failure. The unverified bytes are NEVER carried here.
80    IntegrityFailure,
81    /// Every transport tier was unreachable — a friendly, retryable network state.
82    Unreachable,
83}
84
85impl ResolveOutcome {
86    /// `true` iff this is verified content.
87    pub fn is_success(&self) -> bool {
88        matches!(self, ResolveOutcome::Success(_))
89    }
90
91    /// The verified data, if this is a success.
92    pub fn data(&self) -> Option<&ResolvedData> {
93        match self {
94            ResolveOutcome::Success(d) => Some(d),
95            _ => None,
96        }
97    }
98
99    /// A stable machine-readable tag: `"success"` / `"integrity_failure"` /
100    /// `"unreachable"` (for the wasm surface + logging).
101    pub fn kind(&self) -> &'static str {
102        match self {
103            ResolveOutcome::Success(_) => "success",
104            ResolveOutcome::IntegrityFailure => "integrity_failure",
105            ResolveOutcome::Unreachable => "unreachable",
106        }
107    }
108
109    /// The renderable payload for a webview: the verified content for a success, or
110    /// the appropriate branded `text/html` page for a non-success outcome. This is
111    /// the ONLY way a non-success outcome yields bytes — an integrity failure
112    /// renders the "Integrity Verification Failed" page, NEVER the unverified bytes.
113    pub fn render(&self, connect_url: &str) -> ResolvedData {
114        match self {
115            ResolveOutcome::Success(d) => d.clone(),
116            ResolveOutcome::IntegrityFailure => ResolvedData::new(
117                pages::integrity_failure_html().into_bytes(),
118                pages::HTML_CONTENT_TYPE.to_string(),
119            ),
120            ResolveOutcome::Unreachable => ResolvedData::new(
121                pages::unreachable_html(connect_url).into_bytes(),
122                pages::HTML_CONTENT_TYPE.to_string(),
123            ),
124        }
125    }
126}
127
128/// Options for a resolve. All optional; defaults follow §5.3.
129#[derive(Debug, Clone, Default)]
130pub struct ResolveOptions {
131    /// An explicit endpoint override. When set it WINS and skips the ladder (§5.3):
132    /// a loopback host may use the node path; any other host is a verified rpc endpoint.
133    pub endpoint: Option<String>,
134    /// Override the "Connect to Node" CTA target on the unreachable page. Defaults
135    /// to [`pages::DEFAULT_CONNECT_URL`].
136    pub connect_url: Option<String>,
137    /// Optional DISK cache directory. When set, verified rpc results are persisted
138    /// (as re-verifiable artifacts) and re-verified on read; absent ⇒ the in-memory
139    /// cache only. Backed by `std::fs` natively and by Node's `fs` in the wasm build
140    /// under Node.js; a no-op clientside (a browser has no filesystem), where the
141    /// in-memory cache still applies.
142    pub cache_path: Option<String>,
143}
144
145/// A URN resolver over an injected [`HttpTransport`]. The ladder plan and verified
146/// results are cached per instance.
147pub struct Resolver<T: HttpTransport + ?Sized> {
148    options: ResolveOptions,
149    plan_cache: RefCell<Option<Vec<Endpoint>>>,
150    memory: MemoryCache,
151    #[cfg(any(feature = "native", feature = "wasm"))]
152    disk: Option<cache::DiskCache>,
153    transport: T,
154}
155
156impl<T: HttpTransport> Resolver<T> {
157    /// Build a resolver with default options.
158    pub fn new(transport: T) -> Self {
159        Resolver::with_options(transport, ResolveOptions::default())
160    }
161
162    /// Build a resolver with explicit options.
163    pub fn with_options(transport: T, options: ResolveOptions) -> Self {
164        #[cfg(any(feature = "native", feature = "wasm"))]
165        let disk = options.cache_path.as_ref().map(cache::DiskCache::new);
166        Resolver {
167            plan_cache: RefCell::new(None),
168            memory: MemoryCache::new(cache::DEFAULT_MEMORY_ENTRIES, cache::DEFAULT_MEMORY_BYTES),
169            #[cfg(any(feature = "native", feature = "wasm"))]
170            disk,
171            options,
172            transport,
173        }
174    }
175}
176
177impl<T: HttpTransport + ?Sized> Resolver<T> {
178    /// The connect-CTA URL for the unreachable page.
179    pub fn connect_url(&self) -> &str {
180        self.options
181            .connect_url
182            .as_deref()
183            .unwrap_or(pages::DEFAULT_CONNECT_URL)
184    }
185
186    /// Resolve (and cache) the ordered try-plan for this instance.
187    async fn plan(&self) -> Vec<Endpoint> {
188        if let Some(plan) = self.plan_cache.borrow().as_ref() {
189            return plan.clone();
190        }
191        let plan = ladder::build_plan(&self.transport, self.options.endpoint.as_deref()).await;
192        *self.plan_cache.borrow_mut() = Some(plan.clone());
193        plan
194    }
195
196    /// Fetch a resource from one endpoint.
197    async fn fetch_from(&self, endpoint: &Endpoint, parsed: &ParsedUrn) -> Result<Fetched> {
198        match endpoint.kind {
199            EndpointKind::Node => node::fetch(&self.transport, &endpoint.base, parsed).await,
200            EndpointKind::Rpc => rpc::fetch(&self.transport, &endpoint.base, parsed).await,
201        }
202    }
203
204    /// The content-addressed cache identity for a resource at a CONCRETE root.
205    fn cache_id(parsed: &ParsedUrn, root: &str) -> String {
206        cache::content_id(
207            &parsed.store_id_hex(),
208            root,
209            parsed.resource_key(),
210            parsed.salt.as_deref(),
211        )
212    }
213
214    /// A disk-cache hit, RE-VERIFIED against the URN's pinned root before use. `None`
215    /// on miss (or a malformed entry). A tampered entry FAILS re-verification →
216    /// `Some(IntegrityFailure)` and the bad file is dropped — never serves bad bytes.
217    #[cfg(any(feature = "native", feature = "wasm"))]
218    fn disk_get_verified(&self, parsed: &ParsedUrn, id: &str) -> Option<ResolveOutcome> {
219        let disk = self.disk.as_ref()?;
220        let root = parsed.root_hex()?; // disk cache is rpc/root-pinned only
221        let art = disk.get(id)?;
222        match crypto::verify_and_decrypt(
223            parsed,
224            &art.ciphertext,
225            &art.proof_b64,
226            &root,
227            &art.chunk_lens,
228        ) {
229            Ok(bytes) => {
230                let ct = content_type::derive(parsed.resource_key(), &bytes);
231                Some(ResolveOutcome::Success(ResolvedData::new(bytes, ct)))
232            }
233            // Tampered on-disk artifacts → fail closed; drop the poisoned entry.
234            Err(ResolveError::VerifyFailed(_)) | Err(ResolveError::DecryptFailed) => {
235                disk.remove(id);
236                Some(ResolveOutcome::IntegrityFailure)
237            }
238            // Malformed → treat as a miss and drop it.
239            Err(_) => {
240                disk.remove(id);
241                None
242            }
243        }
244    }
245
246    #[cfg(not(any(feature = "native", feature = "wasm")))]
247    fn disk_get_verified(&self, _parsed: &ParsedUrn, _id: &str) -> Option<ResolveOutcome> {
248        None
249    }
250
251    /// Cache a verified `Success` — memory always; disk when the fetch produced
252    /// re-verifiable artifacts (rpc path) and a disk cache is configured.
253    fn cache_success(&self, parsed: &ParsedUrn, fetched: &Fetched) {
254        let Some(root) = fetched.root.as_deref() else {
255            return; // no concrete root ⇒ not content-addressable ⇒ do not cache
256        };
257        let id = Self::cache_id(parsed, root);
258        self.memory.put(id.clone(), fetched.data.clone());
259        #[cfg(any(feature = "native", feature = "wasm"))]
260        if let (Some(disk), Some(art)) = (self.disk.as_ref(), fetched.artifacts.as_ref()) {
261            disk.put(&id, art);
262        }
263        let _ = &id;
264    }
265
266    /// Resolve a DIG URN to a typed [`ResolveOutcome`].
267    ///
268    /// A cache layer sits IN FRONT of the network resolve but never weakens
269    /// fail-closed: a memory hit is process-trusted (only holds what this process
270    /// already verified); a disk hit is RE-VERIFIED against the URN's root (a
271    /// tampered file → `IntegrityFailure`). Only verified `Success` bytes are cached.
272    ///
273    /// On a miss it walks the ladder plan, falling through to the next tier on genuine
274    /// ABSENCE or unreachability but NEVER on an integrity failure:
275    /// * a tier's NOT-FOUND (content absent here) falls through; every tier not-found →
276    ///   one branded [`ResolveError::NotFound`] (the stranger's common case: the local
277    ///   node lacks it, the public gateway serves it).
278    /// * a tier's TRANSPORT failure falls through; the LAST tier transport-unreachable
279    ///   → [`ResolveOutcome::Unreachable`].
280    /// * a verify/decrypt failure at ANY tier → [`ResolveOutcome::IntegrityFailure`]
281    ///   IMMEDIATELY, aborting the whole ladder (never cascaded/masked/retried — a
282    ///   tampered tier must not become a silent retry on another, §5.4 fail-closed).
283    /// * a malformed URN / reachable rpc protocol error → a hard `Err`.
284    pub async fn resolve(&self, urn: &str) -> Result<ResolveOutcome> {
285        let parsed = ParsedUrn::parse(urn)?;
286
287        // Cache lookup ONLY when the content identity is known up-front (a pinned
288        // root). A rootless URN's concrete root is only known after resolving, so it
289        // is cached post-resolve (never a rootless→bytes mapping that could go stale).
290        let pinned_id = parsed.root_hex().map(|root| Self::cache_id(&parsed, &root));
291        if let Some(id) = &pinned_id {
292            if let Some(data) = self.memory.get(id) {
293                return Ok(ResolveOutcome::Success(data)); // process-trusted hit
294            }
295            if let Some(outcome) = self.disk_get_verified(&parsed, id) {
296                if let ResolveOutcome::Success(data) = &outcome {
297                    self.memory.put(id.clone(), data.clone());
298                }
299                return Ok(outcome);
300            }
301        }
302
303        let plan = self.plan().await;
304        let last = plan.len().saturating_sub(1);
305
306        for (i, endpoint) in plan.iter().enumerate() {
307            match self.fetch_from(endpoint, &parsed).await {
308                Ok(fetched) => {
309                    self.cache_success(&parsed, &fetched); // only verified Success is cached
310                    return Ok(ResolveOutcome::Success(fetched.data));
311                }
312                // Reached the endpoint, bytes failed integrity → hard security
313                // fail-closed. This aborts the WHOLE ladder IMMEDIATELY and is NEVER
314                // downgraded to try-next: falling through after a tampered response
315                // would let an attacker turn a poisoned tier into a silent retry that
316                // serves attacker-chosen bytes from another tier (§5.4). Only genuine
317                // absence / unreachability (below) may fall through — never integrity.
318                Err(ResolveError::VerifyFailed(_)) | Err(ResolveError::DecryptFailed) => {
319                    return Ok(ResolveOutcome::IntegrityFailure)
320                }
321                // Genuine ABSENCE at this tier: the content is simply not held here
322                // (a clean 404 / empty gateway result). The stranger's common case —
323                // the local node lacks it, the public gateway has it — so fall through
324                // to the next tier. Exhausted at the last tier → ONE branded NotFound.
325                Err(ResolveError::NotFound) => {
326                    if i == last {
327                        return Err(ResolveError::NotFound);
328                    }
329                }
330                // Transport-unreachable at this tier: fall through; at the last tier the
331                // whole network is down → the friendly, retryable unreachable outcome.
332                Err(ResolveError::Transport(_)) => {
333                    if i == last {
334                        return Ok(ResolveOutcome::Unreachable);
335                    }
336                }
337                // A reachable PROTOCOL error (malformed rpc response / rootless URN over
338                // the untrusted gateway) — not absence, not unreachable, not integrity.
339                // A hard, surfaced error.
340                Err(other) => return Err(other),
341            }
342        }
343
344        // build_plan always yields ≥1 tier; be explicit anyway.
345        Ok(ResolveOutcome::Unreachable)
346    }
347
348    /// Convenience for the webview/image path: resolve, then RENDER — verified
349    /// content for a success, or the appropriate branded `text/html` page for a
350    /// non-success outcome. An integrity failure renders the security page, NEVER
351    /// the unverified bytes.
352    pub async fn resolve_rendered(&self, urn: &str) -> Result<ResolvedData> {
353        Ok(self.resolve(urn).await?.render(self.connect_url()))
354    }
355}