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}