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}