dig_urn_protocol/resolve.rs
1//! The resolution INTERFACE — the [`UrnResolver`] trait and its typed outcomes/errors.
2//!
3//! This crate defines the CONTRACT, not the transport: a concrete resolver (in `dig-urn-resolver`,
4//! the node, the browser host) implements [`UrnResolver`] over its own I/O, while this module fixes
5//! the shape every implementation shares — the three exhaustive outcomes and the catalogued error
6//! taxonomy — so consumers can depend on one stable interface.
7//!
8//! # Three outcomes, deliberately kept distinct
9//!
10//! * [`ResolveOutcome::Success`] — verified, decrypted content.
11//! * [`ResolveOutcome::IntegrityFailure`] — bytes WERE fetched but failed merkle/decrypt
12//! verification (tampered / decoy / wrong root). A hard, fail-CLOSED security outcome; the
13//! unverified bytes are NEVER carried here.
14//! * [`ResolveOutcome::Unreachable`] — every transport tier was down; nothing was fetched. A
15//! friendly, retryable network state.
16//!
17//! `IntegrityFailure` (reached the network, bytes don't verify — security) and `Unreachable`
18//! (couldn't reach the network — retryable) are never conflated. A malformed URN, a not-found
19//! resource, and a reachable protocol error are hard [`ResolveError`]s.
20
21/// The resolved bytes plus their content type. Only ever the VERIFIED content of a
22/// [`ResolveOutcome::Success`].
23#[derive(Debug, Clone, PartialEq, Eq)]
24pub struct ResolvedData {
25 /// The verified, decrypted resource bytes.
26 pub bytes: Vec<u8>,
27 /// The MIME type.
28 pub content_type: String,
29}
30
31impl ResolvedData {
32 /// Construct resolved data.
33 pub fn new(bytes: Vec<u8>, content_type: String) -> Self {
34 ResolvedData {
35 bytes,
36 content_type,
37 }
38 }
39}
40
41/// The typed result of a resolve. The three cases are exhaustive and never conflated.
42#[derive(Debug, Clone, PartialEq, Eq)]
43pub enum ResolveOutcome {
44 /// Verified, decrypted content.
45 Success(ResolvedData),
46 /// The served bytes failed integrity verification — a hard, fail-closed security failure. The
47 /// unverified bytes are NEVER carried here.
48 IntegrityFailure,
49 /// Every transport tier was unreachable — a friendly, retryable network state.
50 Unreachable,
51}
52
53impl ResolveOutcome {
54 /// `true` iff this is verified content.
55 pub fn is_success(&self) -> bool {
56 matches!(self, ResolveOutcome::Success(_))
57 }
58
59 /// The verified data, if this is a success.
60 pub fn data(&self) -> Option<&ResolvedData> {
61 match self {
62 ResolveOutcome::Success(d) => Some(d),
63 _ => None,
64 }
65 }
66
67 /// A stable machine-readable tag: `"success"` / `"integrity_failure"` / `"unreachable"`.
68 pub fn kind(&self) -> &'static str {
69 match self {
70 ResolveOutcome::Success(_) => "success",
71 ResolveOutcome::IntegrityFailure => "integrity_failure",
72 ResolveOutcome::Unreachable => "unreachable",
73 }
74 }
75}
76
77/// Options for a resolve. All optional; a resolver applies its own §5.3-ladder defaults.
78#[derive(Debug, Clone, Default, PartialEq, Eq)]
79pub struct ResolveOptions {
80 /// An explicit endpoint override. When set it WINS and skips the ladder (§5.3): a loopback host
81 /// may use the node path; any other host is a verified rpc endpoint.
82 pub endpoint: Option<String>,
83 /// Override the "connect a node" CTA target the resolver renders for an unreachable outcome.
84 pub connect_url: Option<String>,
85}
86
87/// A hard, fail-closed resolution failure. Distinct from [`ResolveOutcome::Unreachable`] (the
88/// network-down state) and [`ResolveOutcome::IntegrityFailure`] (bytes fetched but unverifiable).
89#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
90pub enum ResolveError {
91 /// The input was not a syntactically valid DIG URN.
92 #[error("invalid DIG URN: {0}")]
93 Parse(String),
94
95 /// A transport-level failure talking to a specific endpoint (DNS, TLS, connection, timeout,
96 /// malformed HTTP). Not "every tier down" (that is [`ResolveOutcome::Unreachable`]).
97 #[error("transport error: {0}")]
98 Transport(String),
99
100 /// The RPC endpoint returned a protocol error, or a response the resolver cannot interpret.
101 #[error("rpc error: {0}")]
102 Rpc(String),
103
104 /// The resource does not exist in the store at the resolved root. A hard, fail-closed verdict.
105 #[error("resource not found")]
106 NotFound,
107
108 /// A rootless URN was resolved over the untrusted blind tier, where the trust root cannot be
109 /// established without trusting the gateway. Pin a root in the URN, or resolve via a loopback
110 /// node. Fail-closed — the resolver will NOT verify against a gateway-asserted root.
111 #[error(
112 "a root-pinned URN is required to verify over the public gateway \
113 (rootless URNs are not chain-verified there)"
114 )]
115 RootRequired,
116
117 /// The served ciphertext failed integrity verification against the chain-anchored root (tampered
118 /// bytes, a non-chaining proof, or a decoy from a wrong store). FAIL-CLOSED: bytes discarded.
119 #[error("inclusion verification failed: {0}")]
120 VerifyFailed(String),
121
122 /// The verified ciphertext did not decrypt under the URN's key (AEAD tag failure — wrong
123 /// key/salt or corruption), or an untrusted chunk-length plan was inconsistent. FAIL-CLOSED.
124 #[error("decryption failed (wrong key/salt or corrupt ciphertext)")]
125 DecryptFailed,
126}
127
128/// Result alias for resolution operations.
129pub type Result<T> = core::result::Result<T, ResolveError>;
130
131/// The resolution contract: turn a [`DigUrn`](crate::DigUrn) into a typed [`ResolveOutcome`].
132///
133/// A concrete implementation walks the §5.3 node-first ladder over its own transport, verifies via
134/// the [`crate::verify`] contract, and MUST honour the fail-closed outcome distinction above — never
135/// returning unverified bytes as a `Success`.
136#[allow(async_fn_in_trait)] // A contract crate; a boxed-future/`Send` bound is the caller's choice.
137pub trait UrnResolver {
138 /// Resolve a URN string to a typed outcome, or a hard [`ResolveError`].
139 async fn resolve(&self, urn: &str, opts: &ResolveOptions) -> Result<ResolveOutcome>;
140}
141
142#[cfg(test)]
143mod tests {
144 use super::*;
145
146 #[test]
147 fn outcome_accessors_are_exhaustive() {
148 let ok = ResolveOutcome::Success(ResolvedData::new(vec![1, 2], "text/plain".into()));
149 assert!(ok.is_success());
150 assert_eq!(ok.kind(), "success");
151 assert_eq!(ok.data().unwrap().bytes, vec![1, 2]);
152
153 assert_eq!(ResolveOutcome::IntegrityFailure.kind(), "integrity_failure");
154 assert!(!ResolveOutcome::IntegrityFailure.is_success());
155 assert!(ResolveOutcome::IntegrityFailure.data().is_none());
156
157 assert_eq!(ResolveOutcome::Unreachable.kind(), "unreachable");
158 assert!(ResolveOutcome::Unreachable.data().is_none());
159 }
160
161 #[test]
162 fn errors_render_stable_messages() {
163 assert!(ResolveError::RootRequired
164 .to_string()
165 .contains("root-pinned"));
166 assert_eq!(ResolveError::NotFound.to_string(), "resource not found");
167 }
168}