Skip to main content

urma_node/
retrieval.rs

1//! On-demand content acquisition. Sources supply untrusted packages; the local
2//! engine supplies the independently selected chain view. Missing peers do not
3//! imply missing chain data: a known locator can fall back to full witness blocks.
4use crate::{Error, engine::Engine, model::RecordPackage};
5use bitcoin::Txid;
6use std::{
7    collections::BTreeMap,
8    path::Path,
9    sync::atomic::{AtomicBool, Ordering},
10    time::{Duration, Instant},
11};
12use urma_core::multipart::VerifiedRecord;
13use urma_runtime::multipart::{
14    Candidate, FetchError, MultipartSource, RecordRequest, RecoveredObject, RecoveryLimits,
15};
16
17/// A data source, not a chain verifier or completeness authority. Implementations
18/// must bound their own I/O; the caller's deadline is checked between callbacks.
19pub trait RecordSource {
20    fn fetch(&self, txid: Txid) -> Result<Option<RecordPackage>, Error>;
21}
22
23/// Explicit acquisition budgets, independent of protocol geometry limits.
24#[derive(Clone, Copy)]
25pub struct FetchLimits {
26    pub sources: usize,
27    pub timeout: Duration,
28}
29impl Default for FetchLimits {
30    fn default() -> Self {
31        Self {
32            sources: 4,
33            timeout: Duration::from_secs(30),
34        }
35    }
36}
37
38fn verify_cached(
39    engine: &Engine,
40    expected: Txid,
41    package: &RecordPackage,
42) -> Result<VerifiedRecord, Error> {
43    let chain = engine.chain()?;
44    let reveal = urma_chain::decode::transaction(&package.reveal.transaction, chain)
45        .map_err(|e| Error::Invalid(e.to_string()))?;
46    let commit = urma_chain::decode::transaction(&package.commit, chain)
47        .map_err(|e| Error::Invalid(e.to_string()))?;
48    VerifiedRecord::verify(expected, &reveal, &commit).map_err(|e| Error::Invalid(e.to_string()))
49}
50
51/// Fetch one structurally valid V0 record. This does not check a signed parent
52/// reference hash: multipart reconstruction performs that check AFTER proof.
53/// A returned record is relative to the local selected view at retrieval time.
54pub fn fetch_record(
55    engine: &Engine,
56    txid: Txid,
57    sources: &[&dyn RecordSource],
58    limits: FetchLimits,
59) -> Result<Option<VerifiedRecord>, Error> {
60    let began = Instant::now();
61    let deadline = began
62        .checked_add(limits.timeout)
63        .ok_or_else(|| Error::Invalid("record deadline overflow".into()))?;
64    crate::engine::check_deadline(deadline)?;
65    if let Some(record) = engine.record(txid)? {
66        let verified = verify_cached(engine, txid, &record.package)?;
67        crate::engine::check_deadline(deadline)?;
68        return Ok(Some(verified));
69    }
70    let mut last = None;
71    for source in sources.iter().take(limits.sources) {
72        if began.elapsed() >= limits.timeout {
73            return Err(Error::Busy("record acquisition deadline".into()));
74        }
75        let package = match source.fetch(txid) {
76            Ok(Some(package)) => package,
77            Ok(None) => continue,
78            Err(error) => {
79                last = Some(error);
80                continue;
81            }
82        };
83        if began.elapsed() >= limits.timeout {
84            return Err(Error::Busy("record acquisition deadline".into()));
85        }
86        // Bind the requested identifier before admitting any package. Valid data
87        // for an unrelated TXID cannot satisfy this request or pollute its cache.
88        match verify_cached(engine, txid, &package) {
89            Err(error) => {
90                last = Some(error);
91                continue;
92            }
93            Ok(verified) => match engine.admit_record_before(package, Some(deadline)) {
94                Ok(_) => return Ok(Some(verified)),
95                Err(error) => {
96                    // A peer's stale block claim must not veto another source.
97                    // Admission still checks tickets; recover checks its epoch.
98                    last = Some(error);
99                }
100            },
101        }
102    }
103    if began.elapsed() >= limits.timeout {
104        return Err(Error::Busy("record acquisition deadline".into()));
105    }
106    let fallback = engine.retrieve_located_record_before(txid, deadline);
107    crate::engine::check_deadline(deadline)?;
108    match fallback {
109        Ok(Some(record)) => {
110            if began.elapsed() >= limits.timeout {
111                return Err(Error::Busy("record acquisition deadline".into()));
112            }
113            let verified = verify_cached(engine, txid, &record.package)?;
114            crate::engine::check_deadline(deadline)?;
115            Ok(Some(verified))
116        }
117        Err(error) => Err(error),
118        Ok(None) => match last {
119            Some(error) => Err(error),
120            None => Ok(None),
121        },
122    }
123}
124
125/// Recover an object without exporting partial output. This reuses the existing
126/// root/leaf/data signed-reference and final length/digest checks. Reorg during
127/// recovery invalidates the result; cached immutable bytes remain reusable.
128/// The scratch directory belongs to the caller. No plaintext decryption occurs.
129pub fn recover(
130    engine: &Engine,
131    root: Txid,
132    sources: &[&dyn RecordSource],
133    limits: RecoveryLimits,
134    scratch: &Path,
135) -> Result<RecoveredObject, Error> {
136    recover_inner(
137        engine,
138        root,
139        sources,
140        limits,
141        scratch,
142        false,
143        &AtomicBool::new(false),
144    )
145}
146
147/// Recover and protect all exact members in the same completion transaction.
148/// On failure, no new completion or pin is published; independently verified
149/// partial cache entries may remain. No user decryption key is required.
150pub fn recover_and_pin(
151    engine: &Engine,
152    root: Txid,
153    sources: &[&dyn RecordSource],
154    limits: RecoveryLimits,
155    scratch: &Path,
156) -> Result<RecoveredObject, Error> {
157    recover_inner(
158        engine,
159        root,
160        sources,
161        limits,
162        scratch,
163        true,
164        &AtomicBool::new(false),
165    )
166}
167
168pub(crate) fn recover_inner(
169    engine: &Engine,
170    root: Txid,
171    sources: &[&dyn RecordSource],
172    limits: RecoveryLimits,
173    scratch: &Path,
174    pin: bool,
175    cancelled: &AtomicBool,
176) -> Result<RecoveredObject, Error> {
177    check_cancelled(cancelled)?;
178    let generation = engine.status()?.generation;
179    let began = Instant::now();
180    let deadline = began
181        .checked_add(limits.timeout)
182        .ok_or_else(|| Error::Invalid("object deadline overflow".into()))?;
183    let fetch_limits = FetchLimits {
184        sources: limits.attempts.max(1) as usize,
185        timeout: limits.timeout,
186    };
187    let root = fetch_record(engine, root, sources, fetch_limits)?
188        .ok_or_else(|| Error::Invalid("root is not available from the supplied sources".into()))?;
189    check_cancelled(cancelled)?;
190    let mut remaining = limits;
191    remaining.timeout = limits.timeout.saturating_sub(began.elapsed());
192    let mut adapter = Adapter {
193        engine,
194        sources,
195        fetch_limits,
196        began,
197        members: BTreeMap::from([(root.txid(), crate::archive::ObjectMember::from(&root))]),
198        cancelled,
199    };
200    let object = urma_runtime::multipart::reconstruct(&root, &mut adapter, remaining, scratch)
201        .map_err(|e| Error::Source(Box::new(e)))?;
202    check_cancelled(cancelled)?;
203    // Completion atomically rechecks the epoch and retained identities. A
204    // concurrent prune may refuse completion, never create false availability.
205    engine.complete_object(
206        &object,
207        generation,
208        &adapter.members.into_values().collect::<Vec<_>>(),
209        pin,
210        deadline,
211        cancelled,
212    )?;
213    Ok(object)
214}
215
216struct Adapter<'a> {
217    engine: &'a Engine,
218    sources: &'a [&'a dyn RecordSource],
219    fetch_limits: FetchLimits,
220    began: Instant,
221    members: BTreeMap<Txid, crate::archive::ObjectMember>,
222    cancelled: &'a AtomicBool,
223}
224impl MultipartSource for Adapter<'_> {
225    fn fetch(
226        &mut self,
227        request: &RecordRequest,
228        _rejected: &[String],
229    ) -> Result<Candidate, FetchError> {
230        if self.cancelled.load(Ordering::Acquire) {
231            return Err(FetchError::Source(urma_runtime::error::Error::Io(
232                std::io::Error::other("object retention cancelled"),
233            )));
234        }
235        // Source-level proof failures have already tried the bounded source list.
236        // A fully authenticated wrong reference hash remains terminal in reconstruct.
237        match fetch_record(
238            self.engine,
239            request.reference.txid,
240            self.sources,
241            FetchLimits {
242                timeout: self
243                    .fetch_limits
244                    .timeout
245                    .saturating_sub(self.began.elapsed()),
246                ..self.fetch_limits
247            },
248        ) {
249            Ok(Some(record)) => {
250                self.members
251                    .insert(record.txid(), crate::archive::ObjectMember::from(&record));
252                Ok(Candidate {
253                    origin: "node-selected-view".into(),
254                    record,
255                })
256            }
257            Ok(None) => Err(FetchError::Unavailable),
258            Err(error) => Err(FetchError::Source(urma_runtime::error::Error::Io(
259                std::io::Error::other(error.to_string()),
260            ))),
261        }
262    }
263}
264
265fn check_cancelled(cancelled: &AtomicBool) -> Result<(), Error> {
266    if cancelled.load(Ordering::Acquire) {
267        Err(Error::Busy("object retention cancelled".into()))
268    } else {
269        Ok(())
270    }
271}