Skip to main content

dig_download/
progress.rs

1//! Progress reporting + resume state.
2//!
3//! Two things a caller / a crash-restart needs to see:
4//!
5//! - **Progress** — a live [`DownloadEvent`] stream (bytes done, per-range completions, source
6//!   health, pause/resume, terminal outcome) plus a coalesced [`DownloadProgress`] snapshot, so a UI
7//!   or an agent can watch a download without polling.
8//! - **Resume state** — a durable [`DownloadState`] (which ranges are complete + verified, and the
9//!   resource commitment) written to a [`StateStore`] as the download makes progress, so
10//!   [`resume`](crate::DownloadHandle) — after a pause OR a crash — re-fetches only the still-missing
11//!   ranges and NEVER a completed+verified one.
12
13use std::collections::BTreeSet;
14use std::time::Duration;
15
16use async_trait::async_trait;
17use serde::{Deserialize, Serialize};
18
19use crate::error::DownloadError;
20
21/// A coalesced snapshot of a download's progress — the "how far along" view.
22#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
23pub struct DownloadProgress {
24    /// Verified bytes written to the sink so far.
25    pub bytes_done: u64,
26    /// Total resource ciphertext length (0 until the commitment is established).
27    pub total_length: u64,
28    /// Ranges completed + verified.
29    pub ranges_done: usize,
30    /// Total ranges in the plan (0 until planned).
31    pub ranges_total: usize,
32    /// Distinct providers with a range currently in flight.
33    pub active_sources: usize,
34}
35
36impl DownloadProgress {
37    /// Fraction complete in `[0.0, 1.0]` by bytes (0 until the total length is known).
38    pub fn fraction(&self) -> f64 {
39        if self.total_length == 0 {
40            0.0
41        } else {
42            self.bytes_done as f64 / self.total_length as f64
43        }
44    }
45
46    /// Whether every planned range is done (and the plan is non-trivial).
47    pub fn is_complete(&self) -> bool {
48        self.ranges_total > 0 && self.ranges_done == self.ranges_total
49    }
50}
51
52/// A live event emitted as a download progresses. Delivered on the handle's event stream.
53#[derive(Debug, Clone)]
54pub enum DownloadEvent {
55    /// The resource was located + planned: this many ranges over this total length.
56    Planned {
57        /// Total ranges in the plan.
58        ranges_total: usize,
59        /// Total resource ciphertext length.
60        total_length: u64,
61    },
62    /// A range was fetched, verified, and written. Carries the updated coalesced snapshot.
63    RangeCompleted {
64        /// The range index that completed.
65        range: usize,
66        /// The provider (64-hex `peer_id`) that served it.
67        provider: String,
68        /// The progress snapshot after this completion.
69        progress: DownloadProgress,
70    },
71    /// A range fetch from a provider failed (transport or verify) and will be retried elsewhere.
72    RangeFailed {
73        /// The range index that failed.
74        range: usize,
75        /// The provider that failed to serve it.
76        provider: String,
77        /// A short reason (stable text).
78        reason: String,
79    },
80    /// The provider set was refreshed (a `find_providers` re-run) because ranges were running out of
81    /// live sources.
82    ProvidersRefreshed {
83        /// The number of providers now known.
84        providers: usize,
85    },
86    /// The download was paused (no new range fetches will be issued until resumed).
87    Paused,
88    /// The download was resumed after a pause.
89    Resumed,
90    /// The download finished successfully — every range verified + written + finalized.
91    Completed {
92        /// The total verified bytes written.
93        total_length: u64,
94    },
95    /// The download ended in failure (terminal). Carries the reason text.
96    Failed {
97        /// The terminal failure reason.
98        reason: String,
99    },
100}
101
102/// Durable resume state for one download: the resource commitment metadata + the set of ranges
103/// already completed + verified. Serialized to a [`StateStore`] so a paused OR crashed download
104/// resumes without re-fetching a verified range.
105#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
106pub struct DownloadState {
107    /// The download key (a stable id for this content target — see
108    /// [`crate::orchestrator::download_key`]).
109    pub key: String,
110    /// Total resource ciphertext length (once the commitment is established; 0 before).
111    pub total_length: u64,
112    /// The per-chunk lengths (the commitment's `chunk_lens`), so a resume re-plans identically.
113    pub chunk_lens: Vec<u64>,
114    /// The chain-anchored generation root (64-hex), if known.
115    pub root: Option<String>,
116    /// The whole-resource inclusion proof (base64), if known.
117    pub inclusion_proof: Option<String>,
118    /// Range indices already completed + verified (never re-fetched on resume).
119    pub done_ranges: BTreeSet<usize>,
120}
121
122impl DownloadState {
123    /// A fresh, empty state for `key` (nothing planned or done yet).
124    pub fn new(key: impl Into<String>) -> Self {
125        DownloadState {
126            key: key.into(),
127            total_length: 0,
128            chunk_lens: Vec::new(),
129            root: None,
130            inclusion_proof: None,
131            done_ranges: BTreeSet::new(),
132        }
133    }
134
135    /// Whether the resource commitment has been established (chunk layout known).
136    pub fn has_commitment(&self) -> bool {
137        !self.chunk_lens.is_empty()
138    }
139
140    /// Mark range `index` complete.
141    pub fn mark_done(&mut self, index: usize) {
142        self.done_ranges.insert(index);
143    }
144
145    /// Whether range `index` is already complete (and must not be re-fetched).
146    pub fn is_done(&self, index: usize) -> bool {
147        self.done_ranges.contains(&index)
148    }
149}
150
151/// How long a persisted bad-descriptor verdict keeps a holder out of the DESCRIPTOR role.
152///
153/// Reputation decays because a verdict is evidence about a moment, not a permanent label: a holder can
154/// be reinstalled, fixed, or have served a stale generation. A verdict that never expired would turn
155/// one bad answer into permanent exclusion — and, aggregated, into a denial primitive.
156pub const BAD_DESCRIPTOR_TTL: Duration = Duration::from_secs(24 * 60 * 60);
157
158/// The most bad-descriptor verdicts retained per target.
159///
160/// Reputation is written in response to peer behaviour, so it must not itself be a growth vector: the
161/// oldest verdict is evicted once the cap is reached. A capsule with more than this many distinct
162/// lying holders is not a case reputation can help with.
163pub const MAX_BAD_DESCRIPTOR_PEERS: usize = 32;
164
165/// One persisted "this peer served a bad module descriptor" verdict, with when it was recorded (unix
166/// seconds) so it can decay.
167#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
168pub struct BadDescriptorVerdict {
169    /// The 64-hex `peer_id` of the holder whose descriptor failed a final gate.
170    pub peer_id: String,
171    /// When the verdict was recorded, in unix seconds.
172    pub recorded_at_unix: u64,
173}
174
175/// The current unix time in seconds (0 if the clock is before the epoch — a clock that absurd only
176/// costs the caller a prematurely-expired verdict).
177pub fn unix_now() -> u64 {
178    std::time::SystemTime::now()
179        .duration_since(std::time::UNIX_EPOCH)
180        .map(|d| d.as_secs())
181        .unwrap_or(0)
182}
183
184/// Whether `peer_id` is a well-formed 64-hex holder id.
185///
186/// A `peer_id` arrives off the wire as free-form text, and reputation is the one place where such a
187/// string would be PERSISTED and later matched against. Only ids of the canonical shape are stored, so
188/// no peer-supplied text can shape a stored key (#1603/#1609).
189fn is_hex_peer_id(peer_id: &str) -> bool {
190    peer_id.len() == 64 && peer_id.bytes().all(|b| b.is_ascii_hexdigit())
191}
192
193/// Record `peer_id`'s verdict in `verdicts`, dropping expired ones, de-duplicating (a repeat verdict
194/// refreshes the timestamp), and evicting the oldest once [`MAX_BAD_DESCRIPTOR_PEERS`] is reached.
195///
196/// A malformed (non-64-hex) id is IGNORED rather than stored — see [`is_hex_peer_id`].
197fn record_verdict(verdicts: &mut Vec<BadDescriptorVerdict>, peer_id: &str, now: u64) {
198    if !is_hex_peer_id(peer_id) {
199        return;
200    }
201    verdicts.retain(|v| !is_expired(v, now) && v.peer_id != peer_id);
202    verdicts.push(BadDescriptorVerdict {
203        peer_id: peer_id.to_string(),
204        recorded_at_unix: now,
205    });
206    while verdicts.len() > MAX_BAD_DESCRIPTOR_PEERS {
207        verdicts.remove(0); // oldest first — the vec stays in insertion order
208    }
209}
210
211/// The still-live verdicts' peer ids, expired ones dropped.
212fn live_peers(verdicts: &[BadDescriptorVerdict], now: u64) -> Vec<String> {
213    verdicts
214        .iter()
215        .filter(|v| !is_expired(v, now))
216        .map(|v| v.peer_id.clone())
217        .collect()
218}
219
220/// Whether a verdict has aged past [`BAD_DESCRIPTOR_TTL`] (a verdict timestamped in the future — a
221/// clock step — is treated as current, never as eternally valid).
222fn is_expired(verdict: &BadDescriptorVerdict, now: u64) -> bool {
223    now.saturating_sub(verdict.recorded_at_unix) > BAD_DESCRIPTOR_TTL.as_secs()
224}
225
226/// Persists [`DownloadState`] so a download resumes across pause + process restart.
227///
228/// The orchestrator checkpoints after each range completes and on pause. A resume loads the state and
229/// re-plans; only the ranges NOT in `done_ranges` are fetched. The trait abstracts the medium so
230/// tests use an [`InMemoryStateStore`] and a node uses [`FileStateStore`] (or a store-backed one).
231#[async_trait]
232pub trait StateStore: Send + Sync {
233    /// Load the persisted state for `key`, or `None` if there is no checkpoint yet.
234    async fn load(&self, key: &str) -> Result<Option<DownloadState>, DownloadError>;
235
236    /// Persist `state` (overwriting any prior checkpoint for its key).
237    async fn save(&self, state: &DownloadState) -> Result<(), DownloadError>;
238
239    /// Delete the checkpoint for `key` (called after a successful, finalized download).
240    async fn clear(&self, key: &str) -> Result<(), DownloadError>;
241
242    /// Remember that `peer_id` supplied a descriptor for `target_key` that failed a final integrity
243    /// gate, so a later call — or a later PROCESS — does not pay for the same lie again.
244    ///
245    /// Demotion within one pull is not enough: the holder order is deterministic, so a fresh call, a
246    /// retry, or a restart re-asks the same liars from scratch, each paying up to
247    /// `MAX_DESCRIPTOR_ATTEMPTS` full pull attempts in bandwidth and staging disk (#1611).
248    ///
249    /// A verdict is advisory and decays ([`BAD_DESCRIPTOR_TTL`]); it must never become a denial
250    /// primitive, so a caller consults it to ORDER/FILTER descriptor sources and falls back to the
251    /// full holder set rather than giving up. Demoted holders stay fully usable for CHUNK fetches —
252    /// chunk bytes are independently hash-attributed, so excluding them would cost availability for no
253    /// integrity gain.
254    ///
255    /// The default is a no-op, so an existing [`StateStore`] keeps compiling and simply forgets
256    /// reputation between calls (the pre-#1611 behaviour — an efficiency loss, never a correctness one).
257    async fn record_bad_descriptor(
258        &self,
259        target_key: &str,
260        peer_id: &str,
261    ) -> Result<(), DownloadError> {
262        let _ = (target_key, peer_id);
263        Ok(())
264    }
265
266    /// The peers with a still-live [`record_bad_descriptor`](Self::record_bad_descriptor) verdict for
267    /// `target_key`. The default returns none (this store keeps no reputation).
268    async fn bad_descriptor_peers(&self, target_key: &str) -> Result<Vec<String>, DownloadError> {
269        let _ = target_key;
270        Ok(Vec::new())
271    }
272}
273
274/// An in-memory [`StateStore`] — the test store, and the default when no persistence is wanted (a
275/// pause+resume within one process still works; a crash loses it). Thread-safe.
276#[derive(Debug, Default)]
277pub struct InMemoryStateStore {
278    inner: tokio::sync::Mutex<std::collections::HashMap<String, DownloadState>>,
279    /// Bad-descriptor verdicts per target key — reputation that survives a repeat CALL, though not (by
280    /// construction) a process restart. [`FileStateStore`] is the durable one.
281    reputation: tokio::sync::Mutex<std::collections::HashMap<String, Vec<BadDescriptorVerdict>>>,
282}
283
284impl InMemoryStateStore {
285    /// A new, empty in-memory state store.
286    pub fn new() -> Self {
287        InMemoryStateStore::default()
288    }
289}
290
291#[async_trait]
292impl StateStore for InMemoryStateStore {
293    async fn load(&self, key: &str) -> Result<Option<DownloadState>, DownloadError> {
294        Ok(self.inner.lock().await.get(key).cloned())
295    }
296
297    async fn save(&self, state: &DownloadState) -> Result<(), DownloadError> {
298        self.inner
299            .lock()
300            .await
301            .insert(state.key.clone(), state.clone());
302        Ok(())
303    }
304
305    async fn clear(&self, key: &str) -> Result<(), DownloadError> {
306        self.inner.lock().await.remove(key);
307        Ok(())
308    }
309
310    async fn record_bad_descriptor(
311        &self,
312        target_key: &str,
313        peer_id: &str,
314    ) -> Result<(), DownloadError> {
315        let mut reputation = self.reputation.lock().await;
316        let verdicts = reputation.entry(target_key.to_string()).or_default();
317        record_verdict(verdicts, peer_id, unix_now());
318        Ok(())
319    }
320
321    async fn bad_descriptor_peers(&self, target_key: &str) -> Result<Vec<String>, DownloadError> {
322        let reputation = self.reputation.lock().await;
323        Ok(reputation
324            .get(target_key)
325            .map(|v| live_peers(v, unix_now()))
326            .unwrap_or_default())
327    }
328}
329
330/// The fixed-width, path-safe file stem a download key maps to: `SHA-256(key)` in lower hex.
331///
332/// Always exactly [`CHECKPOINT_STEM_LEN`] characters, for every key. See [`FileStateStore::file_for`]
333/// for why this is a digest and not the key's own bytes.
334fn checkpoint_file_stem(key: &str) -> String {
335    use sha2::Digest;
336    crate::module::hex_of(sha2::Sha256::digest(key.as_bytes()))
337}
338
339/// Length in characters of every [`checkpoint_file_stem`] — a SHA-256 digest in lower hex.
340const CHECKPOINT_STEM_LEN: usize = 64;
341
342/// A file-backed [`StateStore`]: one JSON checkpoint file per download key, under a directory. A
343/// crashed download resumes by re-reading its checkpoint. The filename is a fixed-width SHA-256
344/// DIGEST of the key, so it is both filesystem-safe and bounded in length whatever the key is.
345#[derive(Debug, Clone)]
346pub struct FileStateStore {
347    dir: std::path::PathBuf,
348}
349
350impl FileStateStore {
351    /// A file state store writing checkpoints under `dir` (created on first save if missing).
352    pub fn new(dir: impl Into<std::path::PathBuf>) -> Self {
353        FileStateStore { dir: dir.into() }
354    }
355
356    fn path_for(&self, key: &str) -> std::path::PathBuf {
357        self.file_for(key, ".json")
358    }
359
360    /// The reputation sidecar beside a target's checkpoint. Kept SEPARATE from the checkpoint because
361    /// the two have different lifetimes: a checkpoint is cleared the moment a download completes, while
362    /// what a holder did must outlive that success.
363    fn reputation_path_for(&self, key: &str) -> std::path::PathBuf {
364        self.file_for(key, ".holders.json")
365    }
366
367    /// `<sha256_hex(key)><suffix>` under this store's directory.
368    ///
369    /// The key is DIGESTED rather than hex-encoded (#38). Hex encoding gave path-safety — no key text
370    /// can shape a path — but its output grows with the key, and the real keys are long: a module
371    /// checkpoint key is `module:<64hex>:<64hex>` = 136 bytes, which hex-encodes to 272 characters and
372    /// with `.json` reaches 277 — past Linux's `NAME_MAX` of 255. Every capsule checkpoint write on
373    /// Linux therefore failed with `File name too long (os error 36)`, deterministically.
374    ///
375    /// A digest keeps BOTH properties the hex encoding was chosen for and adds the missing one:
376    /// the output alphabet is still `[0-9a-f]` (path-safe by construction, no key text reaches the
377    /// path), distinct keys still get distinct names (collision resistance, unlike a truncation, which
378    /// would silently alias two capsules onto one checkpoint and corrupt resume state), and the name is
379    /// now FIXED-WIDTH at 64 characters however long the key is. The longest name this can produce is
380    /// 64 + `".holders.json".len()` = 77 characters, asserted by [`digest_name_is_bounded`].
381    ///
382    /// [`digest_name_is_bounded`]: tests::digest_name_is_bounded
383    fn file_for(&self, key: &str, suffix: &str) -> std::path::PathBuf {
384        let mut name = checkpoint_file_stem(key);
385        debug_assert_eq!(name.len(), CHECKPOINT_STEM_LEN);
386        name.reserve_exact(suffix.len());
387        name.push_str(suffix);
388        self.dir.join(name)
389    }
390
391    /// The verdicts persisted for `key` (an absent or unreadable sidecar reads as none — reputation is
392    /// advisory, so it must never fail a download).
393    fn read_verdicts(&self, key: &str) -> Vec<BadDescriptorVerdict> {
394        std::fs::read(self.reputation_path_for(key))
395            .ok()
396            .and_then(|bytes| serde_json::from_slice(&bytes).ok())
397            .unwrap_or_default()
398    }
399}
400
401#[async_trait]
402impl StateStore for FileStateStore {
403    async fn load(&self, key: &str) -> Result<Option<DownloadState>, DownloadError> {
404        let path = self.path_for(key);
405        match std::fs::read(&path) {
406            Ok(bytes) => {
407                let state = serde_json::from_slice(&bytes).map_err(DownloadError::state)?;
408                Ok(Some(state))
409            }
410            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
411            Err(e) => Err(DownloadError::state(e)),
412        }
413    }
414
415    async fn save(&self, state: &DownloadState) -> Result<(), DownloadError> {
416        std::fs::create_dir_all(&self.dir).map_err(DownloadError::state)?;
417        let bytes = serde_json::to_vec(state).map_err(DownloadError::state)?;
418        std::fs::write(self.path_for(&state.key), bytes).map_err(DownloadError::state)
419    }
420
421    async fn clear(&self, key: &str) -> Result<(), DownloadError> {
422        match std::fs::remove_file(self.path_for(key)) {
423            Ok(()) => Ok(()),
424            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
425            Err(e) => Err(DownloadError::state(e)),
426        }
427    }
428
429    async fn record_bad_descriptor(
430        &self,
431        target_key: &str,
432        peer_id: &str,
433    ) -> Result<(), DownloadError> {
434        let mut verdicts = self.read_verdicts(target_key);
435        record_verdict(&mut verdicts, peer_id, unix_now());
436        std::fs::create_dir_all(&self.dir).map_err(DownloadError::state)?;
437        let bytes = serde_json::to_vec(&verdicts).map_err(DownloadError::state)?;
438        std::fs::write(self.reputation_path_for(target_key), bytes).map_err(DownloadError::state)
439    }
440
441    async fn bad_descriptor_peers(&self, target_key: &str) -> Result<Vec<String>, DownloadError> {
442        Ok(live_peers(&self.read_verdicts(target_key), unix_now()))
443    }
444}
445
446#[cfg(test)]
447mod tests {
448    use super::*;
449
450    #[test]
451    fn progress_fraction_and_complete() {
452        let mut p = DownloadProgress {
453            total_length: 100,
454            bytes_done: 25,
455            ranges_total: 4,
456            ranges_done: 1,
457            active_sources: 2,
458        };
459        assert!((p.fraction() - 0.25).abs() < 1e-9);
460        assert!(!p.is_complete());
461        p.ranges_done = 4;
462        p.bytes_done = 100;
463        assert!(p.is_complete());
464        assert_eq!(DownloadProgress::default().fraction(), 0.0);
465    }
466
467    #[test]
468    fn state_marks_and_queries_done() {
469        let mut s = DownloadState::new("k");
470        assert!(!s.is_done(2));
471        s.mark_done(2);
472        assert!(s.is_done(2));
473        assert_eq!(s.done_ranges.len(), 1);
474    }
475
476    #[tokio::test]
477    async fn in_memory_store_round_trips() {
478        let store = InMemoryStateStore::new();
479        assert!(store.load("k").await.unwrap().is_none());
480        let mut s = DownloadState::new("k");
481        s.mark_done(1);
482        s.total_length = 42;
483        store.save(&s).await.unwrap();
484        assert_eq!(store.load("k").await.unwrap().unwrap(), s);
485        store.clear("k").await.unwrap();
486        assert!(store.load("k").await.unwrap().is_none());
487    }
488
489    #[tokio::test]
490    async fn file_store_round_trips_and_survives_reload() {
491        let dir = std::env::temp_dir().join(format!(
492            "dig-download-test-{}-{}",
493            std::process::id(),
494            std::time::SystemTime::now()
495                .duration_since(std::time::UNIX_EPOCH)
496                .unwrap()
497                .as_nanos()
498        ));
499        let store = FileStateStore::new(&dir);
500        assert!(store.load("abc").await.unwrap().is_none());
501
502        let mut s = DownloadState::new("abc");
503        s.total_length = 100;
504        s.chunk_lens = vec![10, 20];
505        s.root = Some("aa".repeat(32));
506        s.mark_done(0);
507        store.save(&s).await.unwrap();
508
509        // A brand-new store instance (simulating a process restart) reads the same checkpoint.
510        let reloaded = FileStateStore::new(&dir);
511        assert_eq!(reloaded.load("abc").await.unwrap().unwrap(), s);
512
513        store.clear("abc").await.unwrap();
514        assert!(store.load("abc").await.unwrap().is_none());
515        // clear on a missing key is a no-op.
516        store.clear("abc").await.unwrap();
517        let _ = std::fs::remove_dir_all(&dir);
518    }
519
520    /// The longest name `FileStateStore` can produce must fit in a filename, on every platform.
521    ///
522    /// `NAME_MAX` is 255 on Linux (and 255 UTF-16 units on Windows/NTFS), so the bound is checked
523    /// against 255 rather than a `cfg`-gated constant. This is the ARITHMETIC half of #38: it holds
524    /// for ANY key the key constructors can emit, because the stem is a fixed-width digest and the
525    /// only variable left is the suffix.
526    #[test]
527    fn digest_name_is_bounded() {
528        const NAME_MAX: usize = 255;
529        let long_key = crate::module::module_download_key(&"ab".repeat(32), &"cd".repeat(32));
530        assert_eq!(
531            long_key.len(),
532            136,
533            "the production key really is this long"
534        );
535
536        // Measured on what `FileStateStore` ACTUALLY produces — not on the stem helper — so a
537        // regression in `file_for` itself is visible here and not only in the round-trip test. The
538        // reputation sidecar carries the longest suffix any call site passes: the worst case.
539        let store = FileStateStore::new("dir");
540        for produced in [
541            store.path_for(&long_key),
542            store.reputation_path_for(&long_key),
543        ] {
544            let name = produced.file_name().unwrap().to_string_lossy().into_owned();
545            assert!(
546                name.chars().count() < NAME_MAX,
547                "{} chars exceeds NAME_MAX: {name}",
548                name.chars().count()
549            );
550        }
551        assert_eq!(
552            store
553                .reputation_path_for(&long_key)
554                .file_name()
555                .unwrap()
556                .to_string_lossy()
557                .chars()
558                .count(),
559            CHECKPOINT_STEM_LEN + ".holders.json".len(),
560        );
561
562        // The bound holds for ANY key because the stem is fixed-width: the 136-byte production key
563        // and a 3-byte key produce the same-length stem.
564        assert_eq!(checkpoint_file_stem(&long_key).len(), CHECKPOINT_STEM_LEN);
565        assert_eq!(checkpoint_file_stem("abc").len(), CHECKPOINT_STEM_LEN);
566        // Distinct keys stay distinct — the property a truncation would have destroyed.
567        assert_ne!(
568            checkpoint_file_stem(&long_key),
569            checkpoint_file_stem(&crate::module::module_download_key(
570                &"ab".repeat(32),
571                &"ce".repeat(32)
572            ))
573        );
574    }
575
576    /// #38: a checkpoint under a REAL `module:<64hex>:<64hex>` key must round-trip through
577    /// `FileStateStore`.
578    ///
579    /// The suite could not see this defect before: every `module.rs` test uses `InMemoryStateStore`,
580    /// which has no filename at all, and the one `FileStateStore` test used a 3-character key. The
581    /// old hex-encoding scheme turned this 136-byte key into a 277-character name and every write
582    /// failed on Linux with `File name too long (os error 36)`.
583    #[tokio::test]
584    async fn file_store_round_trips_a_real_module_download_key() {
585        let dir = std::env::temp_dir().join(format!(
586            "dig-download-modkey-{}-{}",
587            std::process::id(),
588            std::time::SystemTime::now()
589                .duration_since(std::time::UNIX_EPOCH)
590                .unwrap()
591                .as_nanos()
592        ));
593        let key = crate::module::module_download_key(&"ab".repeat(32), &"cd".repeat(32));
594        let store = FileStateStore::new(&dir);
595
596        let mut s = DownloadState::new(&key);
597        s.total_length = 100;
598        s.chunk_lens = vec![10, 20];
599        s.mark_done(0);
600        // The write is where `os error 36` struck.
601        store.save(&s).await.unwrap();
602
603        // A restarted process reads the same checkpoint back under the same long key.
604        assert_eq!(
605            FileStateStore::new(&dir).load(&key).await.unwrap().unwrap(),
606            s
607        );
608
609        // The reputation sidecar carries the longest suffix, so it is the worst case — exercise it too.
610        let peer = "ef".repeat(32);
611        store.record_bad_descriptor(&key, &peer).await.unwrap();
612        assert_eq!(
613            FileStateStore::new(&dir)
614                .bad_descriptor_peers(&key)
615                .await
616                .unwrap(),
617            vec![peer]
618        );
619
620        // Nothing on disk exceeds the filename limit.
621        for entry in std::fs::read_dir(&dir).unwrap() {
622            let name = entry.unwrap().file_name();
623            assert!(
624                name.to_string_lossy().chars().count() < 255,
625                "checkpoint filename must fit NAME_MAX: {name:?}"
626            );
627        }
628
629        store.clear(&key).await.unwrap();
630        assert!(store.load(&key).await.unwrap().is_none());
631        let _ = std::fs::remove_dir_all(&dir);
632    }
633
634    #[test]
635    fn download_event_variants_construct() {
636        // Smoke: the event shapes build (exercised richly in the orchestrator tests).
637        let _ = DownloadEvent::Planned {
638            ranges_total: 3,
639            total_length: 30,
640        };
641        let _ = DownloadEvent::Paused;
642        let _ = DownloadEvent::Resumed;
643        let _ = DownloadEvent::ProvidersRefreshed { providers: 2 };
644        let _ = DownloadEvent::Completed { total_length: 30 };
645        let _ = DownloadEvent::Failed { reason: "x".into() };
646    }
647
648    #[tokio::test]
649    async fn in_memory_store_remembers_a_bad_descriptor_verdict() {
650        let store = InMemoryStateStore::new();
651        let peer = "ab".repeat(32);
652        assert!(store.bad_descriptor_peers("k").await.unwrap().is_empty());
653        store.record_bad_descriptor("k", &peer).await.unwrap();
654        assert_eq!(store.bad_descriptor_peers("k").await.unwrap(), vec![peer]);
655        // Reputation is per TARGET: another capsule's holders are unaffected.
656        assert!(store
657            .bad_descriptor_peers("other")
658            .await
659            .unwrap()
660            .is_empty());
661    }
662
663    #[tokio::test]
664    async fn file_store_reputation_survives_a_process_restart_and_outlives_the_checkpoint() {
665        let dir = std::env::temp_dir().join(format!(
666            "dig-download-rep-{}-{}",
667            std::process::id(),
668            std::time::SystemTime::now()
669                .duration_since(std::time::UNIX_EPOCH)
670                .unwrap()
671                .as_nanos()
672        ));
673        let store = FileStateStore::new(&dir);
674        let peer = "cd".repeat(32);
675        store
676            .record_bad_descriptor("module:x", &peer)
677            .await
678            .unwrap();
679
680        // A brand-new instance (a restarted process) still sees the verdict — the durability the
681        // in-call `demoted` vec never had.
682        let restarted = FileStateStore::new(&dir);
683        assert_eq!(
684            restarted.bad_descriptor_peers("module:x").await.unwrap(),
685            vec![peer.clone()]
686        );
687
688        // Clearing the CHECKPOINT must not forget what a holder did: the two have different lifetimes.
689        restarted.clear("module:x").await.unwrap();
690        assert_eq!(
691            restarted.bad_descriptor_peers("module:x").await.unwrap(),
692            vec![peer]
693        );
694
695        let _ = std::fs::remove_dir_all(&dir);
696    }
697
698    /// A verdict DECAYS: one bad answer must not exclude a holder forever (holders get fixed, and a
699    /// verdict can be the record of a stale generation).
700    #[test]
701    fn a_verdict_expires_after_its_ttl() {
702        let now = 10_000_000u64;
703        let fresh = BadDescriptorVerdict {
704            peer_id: "ab".repeat(32),
705            recorded_at_unix: now - 60,
706        };
707        let stale = BadDescriptorVerdict {
708            peer_id: "cd".repeat(32),
709            recorded_at_unix: now - BAD_DESCRIPTOR_TTL.as_secs() - 1,
710        };
711        assert_eq!(
712            live_peers(&[fresh.clone(), stale], now),
713            vec![fresh.peer_id],
714            "only the un-expired verdict is live"
715        );
716    }
717
718    /// Reputation is written in response to peer behaviour, so it must not itself grow without bound:
719    /// the record is capped and a repeat verdict refreshes rather than duplicates.
720    #[test]
721    fn the_verdict_record_is_bounded_and_deduplicated() {
722        let now = 10_000_000u64;
723        let mut verdicts = Vec::new();
724        for i in 0..(MAX_BAD_DESCRIPTOR_PEERS + 10) {
725            record_verdict(&mut verdicts, &format!("{i:064x}"), now);
726        }
727        assert_eq!(verdicts.len(), MAX_BAD_DESCRIPTOR_PEERS, "capped");
728        assert!(
729            !verdicts.iter().any(|v| v.peer_id == format!("{:064x}", 0)),
730            "the oldest verdicts were evicted first"
731        );
732
733        let repeat = format!("{:064x}", MAX_BAD_DESCRIPTOR_PEERS + 9);
734        record_verdict(&mut verdicts, &repeat, now + 5);
735        assert_eq!(
736            verdicts.iter().filter(|v| v.peer_id == repeat).count(),
737            1,
738            "a repeat verdict refreshes the entry instead of duplicating it"
739        );
740    }
741
742    /// A `peer_id` is free-form text off the wire, and reputation is the one place it would be
743    /// PERSISTED and later matched: a malformed id is dropped, never stored.
744    #[test]
745    fn a_malformed_peer_id_is_never_recorded() {
746        let mut verdicts = Vec::new();
747        record_verdict(&mut verdicts, "../../etc/passwd", 1);
748        record_verdict(&mut verdicts, "not-hex", 1);
749        record_verdict(&mut verdicts, &"ab".repeat(31), 1); // too short
750        assert!(
751            verdicts.is_empty(),
752            "only 64-hex ids are stored: {verdicts:?}"
753        );
754    }
755}