fast_down_api/core/state.rs
1use crate::{Config, PartialConfig};
2use fast_down::{FileId, ProgressEntry, UrlInfo};
3use inherit_config::{ConfigLayer, InheritConfig};
4use parking_lot::Mutex;
5use path_helper::tokio::safe_replace;
6use reqwest::Response;
7use std::{
8 path::{Path, PathBuf},
9 sync::{
10 Arc,
11 atomic::{AtomicBool, Ordering},
12 },
13 time::Duration,
14};
15use tokio::fs;
16use url::Url;
17
18/// Errors that can occur when an explicit [`resume`](crate::resume)
19/// cannot continue an interrupted download.
20///
21/// These are surfaced through [`crate::Event::ResumeError`] so the caller is notified
22/// instead of silently falling back to a full re-download.
23#[derive(Debug, thiserror::Error)]
24#[allow(clippy::large_enum_variant)]
25pub enum StateError {
26 /// Failed to open or read the `.fd` state file from disk.
27 ///
28 /// This usually means the file is missing or unreadable (permission error,
29 /// removed by another process, etc.).
30 #[error("failed to open .fd state file: {0}")]
31 Open(std::io::Error),
32 /// Failed to persist the `.fd` state file to disk.
33 ///
34 /// The state was computed but could not be written (disk full, permission
35 /// error, etc.).
36 #[error("failed to save .fd state file: {0}")]
37 Save(std::io::Error),
38 /// Failed to deserialize the `.fd` state file.
39 ///
40 /// The file was read but is not valid TOML, or its schema no longer matches
41 /// the current [`DownloadStateInner`] definition.
42 #[error("failed to decode .fd state file: {0}")]
43 Decode(#[from] toml::de::Error),
44 /// Failed to serialize the download state into the `.fd` state file.
45 ///
46 /// The state could not be encoded as TOML (e.g. a field holds a value that
47 /// has no valid TOML representation).
48 #[error("failed to encode .fd state file: {0}")]
49 Encode(#[from] toml::ser::Error),
50 /// The remote file changed (size / etag / last-modified mismatch), so resuming would corrupt the output.
51 #[error(
52 "remote file changed, cannot resume\n local: size={local_file_size}, id={local_file_id:?}\n remote: size={remote_file_size}, id={remote_file_id:?}"
53 )]
54 FileChanged {
55 local_file_id: FileId,
56 local_file_size: u64,
57 remote_file_id: FileId,
58 remote_file_size: u64,
59 },
60 /// The `.part` file is shorter than the progress recorded in the `.fd`, so
61 /// the recorded ranges no longer describe bytes that exist on disk.
62 ///
63 /// Continuing would let the sink extend the file with zeros across the
64 /// missing span and never fetch it, silently corrupting the output.
65 #[error(
66 "the .part file was truncated: it holds {actual_size} bytes but the .fd state records progress up to {recorded_size}"
67 )]
68 Truncated {
69 actual_size: u64,
70 recorded_size: u64,
71 },
72 /// The server does not support resumable (range) downloads.
73 #[error(
74 "server does not support resumable download\n url_info: {:?}\n url: {}\n status: {}\n headers: {:?}",
75 .0, .1.url(), .1.status(), .1.headers()
76 )]
77 NotResumable(UrlInfo, Response),
78 /// No URL is available to resume the download.
79 ///
80 /// `resume` was called with `url = None` and the persisted `.fd` state file
81 /// carries no resolvable initial URL (it was missing, or written by an older
82 /// build that did not store one). A URL is required to re-resolve the
83 /// resource, so the call reports this error instead of silently falling back
84 /// to a full re-download.
85 #[error(
86 "cannot resume without a URL: none was supplied and the .fd state has none (tmp_path = {0})"
87 )]
88 NoUrl(PathBuf),
89 /// `peek_resume` 收到的 `tmp_path` 不是 `.part` 文件。
90 ///
91 /// `peek_resume` 需要的是 `.part` 临时文件路径,其同名 `.fd` 才是保存的状态;
92 /// 传入其他扩展名的路径属于参数错误,而非文件读取失败。
93 #[error("peek_resume requires a .part file, got: {0}")]
94 NotAPartFile(PathBuf),
95}
96
97/// Full (resolved) download state that is serialized into the `.fd` file.
98///
99/// `DownloadStateInner` is the non-partial form of the persisted state: every
100/// field is present. It is encoded to TOML by [`DownloadState::store`] and read
101/// back as the generated partial [`PartialDownloadStateInner`] on resume. The
102/// `size` field is recorded for resume validation; older `.fd` files omit it and
103/// read back as `None` in the partial form.
104#[derive(Debug, Clone, InheritConfig)]
105pub struct DownloadStateInner {
106 #[config(default = Url::parse("about:blank").unwrap())]
107 pub url: Url,
108 pub etag: Option<Arc<str>>,
109 pub last_modified: Option<Arc<str>>,
110 #[config(nest)]
111 pub config: Config,
112 /// Total file size recorded at save time, compared against the server
113 /// `UrlInfo.size` during resume validation.
114 ///
115 /// An older `.fd` written without this field reads back as `None`, which
116 /// makes `validate` fail and safely fall back to a full re-download.
117 pub size: u64,
118 /// Total active download time accumulated across all resume runs.
119 ///
120 /// Persisted as a human-readable string (e.g. `"1h 30m 2s"`) via
121 /// `humantime_serde`, so the `.fd` file stays easy to inspect and edit by
122 /// hand. A zero elapsed is omitted from the file, and a missing `elapsed`
123 /// field on load is tolerated (treated as zero). A resumed download
124 /// continues this clock instead of restarting it from zero; the progress
125 /// reporter folds it into its average-speed calculation, giving a speed that
126 /// spans the whole download session rather than only the current run.
127 #[config(default = Duration::ZERO)]
128 #[config(partial_attr(serde(with = "humantime_serde::option")))]
129 #[config(partial_attr(serde(default)))]
130 pub elapsed: Duration,
131}
132
133/// On-disk state for an in-progress download, backing resume support.
134///
135/// `DownloadState` pairs a [`PartialDownloadStateInner`] (the persisted config,
136/// whose fields may be absent if the `.fd` file predates them) with the path of
137/// the `.fd` file and a dirty flag. It is the bridge between a saved `.fd` file
138/// and a fresh [`crate::PartialConfig`] handed to
139/// [`crate::resume`]: [`DownloadState::merge_config`] folds the
140/// loaded progress into the new request so a resumed download continues from the
141/// correct byte offset instead of restarting from zero.
142///
143/// `DownloadState` derefs to [`PartialDownloadStateInner`], so the saved `url`,
144/// `etag`, `last_modified`, `config` and `size` are reachable directly.
145///
146/// The inner state is wrapped in an `Arc<Mutex<PartialDownloadStateInner>>` to
147/// provide a single source of truth shared between the engine loop, the
148/// internal progress reporter, and any other readers/writers.
149///
150/// `DownloadState` is cheaply `Clone`able: the clone shares the same inner state
151/// and the same dirty flag (both behind `Arc`), so a download driver can hand a
152/// clone to a background task that performs the periodic disk persist without
153/// blocking the engine event loop.
154#[derive(Debug, Clone)]
155pub struct DownloadState {
156 inner: Arc<Mutex<PartialDownloadStateInner>>,
157 is_dirty: Arc<AtomicBool>,
158 pub config_path: PathBuf,
159}
160
161/// 已保存、可续传下载的只读快照,由 [`DownloadState::snapshot`] 产出。
162///
163/// 与引擎内部的 `DownloadState`(持有 `Mutex` 与脏标记、可持久化)不同,这是一个纯数据视图,
164/// 供 UI 等只读消费者在不持有内部 `MutexGuard` 的情况下读取进度元数据。
165#[derive(Debug, Clone)]
166pub struct ResumeInfo {
167 /// 持久化的初始 URL;旧 `.fd` 可能缺失,故为 `Option`。
168 pub url: Option<Url>,
169 /// 保存时记录的总大小;缺失时回退为 `0`。
170 pub size: u64,
171 /// 已写入 `.part` 的字节区间(续传进度的唯一真相)。
172 pub progress: Vec<ProgressEntry>,
173 /// 跨所有续传累计的活跃下载时长。
174 pub elapsed: Duration,
175}
176
177impl DownloadState {
178 /// Returns a reference to the shared inner state, locked for reading.
179 /// Prefer this over the previous `Deref`-based field access.
180 pub fn lock_inner(&self) -> parking_lot::MutexGuard<'_, PartialDownloadStateInner> {
181 self.inner.lock()
182 }
183
184 /// Returns a clone of the shared `Arc<Mutex<PartialDownloadStateInner>>`,
185 /// for sharing the authoritative state with other tasks
186 /// (e.g. the internal progress reporter).
187 #[must_use]
188 pub fn share_inner(&self) -> Arc<Mutex<PartialDownloadStateInner>> {
189 self.inner.clone()
190 }
191
192 /// Build a fresh, dirty download state from the initial prefetch metadata.
193 ///
194 /// `url` and `url_info` come from the prefetch step, `config` is the
195 /// caller-provided [`PartialConfig`], and `config_path` is where the `.fd`
196 /// file will be written. The returned state is marked dirty so the first
197 /// [`DownloadState::store`] persists it.
198 #[must_use]
199 pub fn new(url: &Url, url_info: &UrlInfo, config: &PartialConfig, config_path: &Path) -> Self {
200 Self {
201 inner: Arc::new(Mutex::new(PartialDownloadStateInner {
202 url: Some(url.clone()),
203 etag: Some(url_info.file_id.etag.clone()),
204 last_modified: Some(url_info.file_id.last_modified.clone()),
205 config: Some(config.clone()),
206 size: Some(url_info.size),
207 elapsed: Some(Duration::ZERO),
208 })),
209 is_dirty: Arc::new(AtomicBool::new(true)),
210 config_path: config_path.to_path_buf(),
211 }
212 }
213
214 /// Load a download state from disk.
215 ///
216 /// # Errors
217 /// Returns an error if the file cannot be read or deserialized.
218 #[allow(clippy::result_large_err)]
219 pub async fn load(config_path: &Path) -> Result<Self, StateError> {
220 let inner = fs::read(&config_path).await.map_err(StateError::Open)?;
221 let inner: PartialDownloadStateInner = toml::from_slice(&inner)?;
222 Ok(Self {
223 inner: Arc::new(Mutex::new(inner)),
224 is_dirty: Arc::new(AtomicBool::new(false)),
225 config_path: config_path.to_path_buf(),
226 })
227 }
228
229 /// Persist the download state to disk.
230 ///
231 /// This serializes a snapshot of the shared inner state and atomically
232 /// replaces the `.fd` file. It does **not** consult or clear the dirty flag;
233 /// callers decide *when* to persist (typically via [`DownloadState::take_dirty`]
234 /// on a fixed cadence, off the engine event loop). Safe to call from a
235 /// background task because the heavy `safe_replace` (disk write + fsync) runs
236 /// after the inner lock is released.
237 ///
238 /// # Errors
239 /// Returns an error if serializing or writing the state fails.
240 #[allow(clippy::result_large_err)]
241 pub async fn store(&self) -> Result<(), StateError> {
242 let serialized = {
243 let mut guard = self.inner.lock();
244 guard.simplify_from(&PartialDownloadStateInner::default());
245 toml::to_string_pretty(&*guard)?
246 };
247 safe_replace(&self.config_path, serialized.as_bytes())
248 .await
249 .map_err(StateError::Save)?;
250 Ok(())
251 }
252
253 /// Consume the dirty flag (swap to clean) and report whether it was dirty.
254 ///
255 /// Used by the engine loop on a fixed cadence: a `true` return means "a
256 /// persist is due, hand the work to a background task". Because the flag is
257 /// an `Arc<AtomicBool>` shared with any clones, the background task and the
258 /// loop observe the same flag.
259 #[must_use]
260 pub fn take_dirty(&self) -> bool {
261 self.is_dirty.swap(false, Ordering::SeqCst)
262 }
263
264 /// Mark the state dirty so the next persist cycle rewrites the `.fd`.
265 ///
266 /// Call this when a mutation lands (e.g. progress or identity changed) and
267 /// when a background [`DownloadState::store`] fails and should be retried.
268 pub fn mark_dirty(&self) {
269 self.is_dirty.store(true, Ordering::SeqCst);
270 }
271
272 /// Apply a mutation to the inner partial state and mark it dirty.
273 ///
274 /// Any change made through `cb` (e.g. updating `etag`/`last_modified` or the
275 /// nested `config`) schedules the next [`DownloadState::store`].
276 pub fn update<F: FnOnce(&mut PartialDownloadStateInner) -> R, R>(&self, cb: F) -> R {
277 let res = cb(&mut self.inner.lock());
278 self.mark_dirty();
279 res
280 }
281
282 /// Check whether the server-side file is still the same one this state was
283 /// saved for, so a resumed download continues from the correct offset.
284 ///
285 /// The comparison requires the recorded `size` to match and (unless both
286 /// sides are missing identity headers) the `FileId` (`etag` +
287 /// `last_modified`) to be equal. Resumable downloads also require the
288 /// server to support range requests.
289 ///
290 /// # Errors
291 #[allow(clippy::result_large_err)]
292 pub fn validate(&self, info: &UrlInfo) -> Result<(), StateError> {
293 let inner = self.inner.lock();
294 let local_file_id = FileId {
295 etag: inner.etag.clone().flatten(),
296 last_modified: inner.last_modified.clone().flatten(),
297 };
298 let local_file_size = inner.size.unwrap_or(0);
299 drop(inner);
300 let is_same = local_file_size == info.size && local_file_id == info.file_id;
301 if is_same {
302 Ok(())
303 } else {
304 Err(StateError::FileChanged {
305 local_file_id,
306 local_file_size,
307 remote_file_id: info.file_id.clone(),
308 remote_file_size: info.size,
309 })
310 }
311 }
312
313 /// Return the list of byte ranges already written to the `.part` file.
314 ///
315 /// This is the authoritative on-disk progress. It is empty for a brand new
316 /// download and grows as [`DownloadState::merge_progress`] is called.
317 #[must_use]
318 pub fn get_progress(&self) -> Vec<ProgressEntry> {
319 self.inner
320 .lock()
321 .config
322 .as_ref()
323 .and_then(|c| c.downloaded_chunk.clone())
324 .unwrap_or_default()
325 }
326
327 /// Total active download time accumulated so far, across all prior resume
328 /// runs. `Duration::ZERO` when nothing has been recorded yet.
329 #[must_use]
330 pub fn get_elapsed(&self) -> Duration {
331 self.inner.lock().elapsed.unwrap_or(Duration::ZERO)
332 }
333
334 /// 产出一份只读快照,供 UI 等只读消费者读取进度元数据,而无需持有内部 `MutexGuard`。
335 ///
336 /// 与 [`DownloadState::lock_inner`] 不同,这里返回的是拥有所有权的 [`ResumeInfo`],
337 /// 调用方可以随意读取 `url` / `size` / `progress` / `elapsed`,不会触及引擎内部的并发原语。
338 #[must_use]
339 pub fn snapshot(&self) -> ResumeInfo {
340 let guard = self.inner.lock();
341 ResumeInfo {
342 url: guard.url.clone(),
343 size: guard.size.unwrap_or(0),
344 progress: guard
345 .config
346 .as_ref()
347 .and_then(|c| c.downloaded_chunk.clone())
348 .unwrap_or_default(),
349 elapsed: guard.elapsed.unwrap_or(Duration::ZERO),
350 }
351 }
352
353 /// Set the total accumulated active download time (absolute, not additive).
354 ///
355 /// Called by the download driver as time accrues, so the value persisted on
356 /// the next [`DownloadState::store`] reflects the full session. Marks the
357 /// state dirty.
358 pub fn set_elapsed(&self, elapsed: Duration) {
359 self.update(|inner| inner.elapsed = Some(elapsed));
360 }
361
362 /// Merge a freshly-written byte range into the recorded progress.
363 ///
364 /// The progress list is the authoritative set of on-disk ranges; new ranges
365 /// are merged, de-duplicated and normalized. Marks the state dirty.
366 pub fn merge_progress(&self, range: ProgressEntry) {
367 self.update(|inner| inner.config.get_or_insert_default().merge_progress(range));
368 }
369
370 /// Fold a freshly-built [`PartialConfig`] into this loaded state for resume.
371 ///
372 /// This is the key bridge that preserves download progress across a resume:
373 /// the ranges already recorded in `self.config.downloaded_chunk` are merged
374 /// into `partial_config` first, so that even if `partial_config` starts from
375 /// an empty progress list the resumed download keeps the already-downloaded
376 /// bytes. `partial_config` is then layered on top via
377 /// `inherit_from`, and the merged result replaces the stored
378 /// config. Marks the state dirty.
379 pub fn merge_config(&self, partial_config: &PartialConfig) {
380 self.update(|inner| {
381 let mut pc = partial_config.clone();
382 if let Some(config) = &inner.config {
383 if let Some(downloaded_chunk) = &config.downloaded_chunk {
384 for i in downloaded_chunk {
385 pc.merge_progress(i.clone());
386 }
387 }
388 pc.inherit_from(config);
389 }
390 inner.config = Some(pc);
391 });
392 }
393
394 /// Refresh the recorded state for a new download session.
395 ///
396 /// Two fields are refreshed on every (re)start of a download:
397 ///
398 /// 1. **The durable initial URL.** The URL stored in the `.fd` is the
399 /// caller's *initial* URL — the one passed to `download`/`resume`. When the
400 /// caller supplies a new URL (the `Some` case) it takes priority and
401 /// replaces any previously recorded URL, so a later `resume(None)` still
402 /// re-resolves the resource the caller actually wants. When the URL is
403 /// itself read back from the `.fd` (a `resume(None)`), this assignment is a
404 /// no-op.
405 /// 2. **The content identity headers** (etag/last-modified/size), refreshed to
406 /// the fresh server values from the current prefetch.
407 ///
408 /// The redirect target (`info.final_url`) is deliberately **never** persisted:
409 /// such links are usually temporary signed/CDN URLs that expire, so writing one
410 /// into the `.fd` would poison the next resume. The download part targets
411 /// `info.final_url` directly for range requests, which also avoids a redirect
412 /// round-trip on every range request.
413 pub fn refresh_identity(&self, url: &Url, info: &UrlInfo) {
414 self.update(|inner| {
415 inner.url = Some(url.clone());
416 inner.etag = Some(info.file_id.etag.clone());
417 inner.last_modified = Some(info.file_id.last_modified.clone());
418 inner.size = Some(info.size);
419 });
420 }
421
422 /// Reconstruct the identity of the file this state was saved for.
423 ///
424 /// Returns a [`FileId`] from the stored `etag` / `last_modified`. Missing
425 /// headers collapse to `None`, which makes [`DownloadState::validate`] treat
426 /// "no identity on either side" as a match.
427 #[must_use]
428 pub fn file_id(&self) -> FileId {
429 let inner = self.inner.lock();
430 FileId {
431 etag: inner.etag.clone().flatten(),
432 last_modified: inner.last_modified.clone().flatten(),
433 }
434 }
435
436 /// Path of the partial (`.part`) output file paired with this state.
437 ///
438 /// Derived from `config_path` by swapping the extension to `.part`.
439 #[must_use]
440 pub fn tmp_path(&self) -> PathBuf {
441 self.config_path.with_extension("part")
442 }
443
444 /// Measure `tmp_path` against the progress recorded in this state.
445 ///
446 /// Returns `Some((actual_size, recorded_size))` when the file is shorter
447 /// than the highest recorded offset — the state claims bytes that are not
448 /// on disk, so resuming from it would leave that span filled with zeros
449 /// that are never fetched.
450 ///
451 /// Returns `None` when the file is long enough, is not a regular file, or
452 /// cannot be inspected; those cases surface later when the sink is built.
453 pub async fn part_shortfall(&self, tmp_path: &Path) -> Option<(u64, u64)> {
454 let metadata = fs::metadata(tmp_path).await.ok()?;
455 if !metadata.is_file() {
456 return None;
457 }
458 let actual_size = metadata.len();
459 // `downloaded_chunk` is normalized to ascending `start` order when the
460 // `.fd` is loaded, so the last entry's `end` is the highest recorded
461 // offset — the frontier a resume would continue from.
462 let recorded_size = self.get_progress().last().map_or(0, |r| r.end);
463 (actual_size < recorded_size).then_some((actual_size, recorded_size))
464 }
465}
466
467#[cfg(test)]
468mod tests {
469 use super::{DownloadState, PartialConfig, StateError};
470 use fast_down::{FileId, UrlInfo};
471 use std::path::Path;
472 use std::time::Duration;
473 use url::Url;
474
475 fn make_state(path: &Path) -> DownloadState {
476 let url = Url::parse("https://example.com/file.bin").unwrap();
477 let url_info = UrlInfo {
478 size: 1024,
479 raw_name: "file.bin".to_string(),
480 supports_range: true,
481 fast_download: true,
482 final_url: url.clone(),
483 file_id: FileId::new(Some("etag-1"), None),
484 content_type: Some("application/octet-stream".to_string()),
485 };
486 DownloadState::new(&url, &url_info, &PartialConfig::default(), path)
487 }
488
489 #[tokio::test]
490 async fn part_shortfall_reports_short_file_against_recorded_frontier() {
491 let dir = std::env::temp_dir().join(format!("fd_ps_short_{}", std::process::id()));
492 let _ = std::fs::create_dir_all(&dir);
493 let part = dir.join("a.part");
494 let state = make_state(Path::new("dummy.fd"));
495 state.merge_progress(0u64..600);
496 std::fs::write(&part, vec![0u8; 500]).unwrap();
497 assert_eq!(state.part_shortfall(&part).await, Some((500, 600)));
498 let _ = std::fs::remove_dir_all(&dir);
499 }
500
501 #[tokio::test]
502 async fn part_shortfall_is_none_when_file_covers_frontier() {
503 let dir = std::env::temp_dir().join(format!("fd_ps_ok_{}", std::process::id()));
504 let _ = std::fs::create_dir_all(&dir);
505 let part = dir.join("a.part");
506 let state = make_state(Path::new("dummy.fd"));
507 state.merge_progress(0u64..600);
508 std::fs::write(&part, vec![0u8; 700]).unwrap();
509 assert_eq!(state.part_shortfall(&part).await, None);
510 let _ = std::fs::remove_dir_all(&dir);
511 }
512
513 #[tokio::test]
514 async fn elapsed_stored_as_human_readable_string_and_round_trips() {
515 let path = std::env::temp_dir().join(format!(
516 "fd_elapsed_test_{}.fd",
517 std::time::SystemTime::now()
518 .duration_since(std::time::UNIX_EPOCH)
519 .unwrap()
520 .as_nanos()
521 ));
522 let state = make_state(&path);
523 let value = Duration::from_secs(3661); // 1h 1m 1s
524 state.set_elapsed(value);
525 state.store().await.unwrap();
526
527 let content = tokio::fs::read_to_string(&path).await.unwrap();
528 // Human-readable: a quoted string carrying h/m/s, not raw nanoseconds.
529 assert!(
530 content.contains("elapsed = \"")
531 && content.contains('h')
532 && content.contains('m')
533 && content.contains('s'),
534 "elapsed should be stored as a human-readable string, got:\n{content}"
535 );
536 assert!(
537 !content.contains("3661000000000"),
538 "elapsed must not be stored as raw nanoseconds, got:\n{content}"
539 );
540
541 let loaded = DownloadState::load(&path).await.unwrap();
542 assert_eq!(loaded.get_elapsed(), value);
543
544 let _ = std::fs::remove_file(&path);
545 }
546
547 #[tokio::test]
548 async fn elapsed_zero_is_not_persisted() {
549 let path = std::env::temp_dir().join(format!(
550 "fd_elapsed_zero_{}.fd",
551 std::time::SystemTime::now()
552 .duration_since(std::time::UNIX_EPOCH)
553 .unwrap()
554 .as_nanos()
555 ));
556 let state = make_state(&path); // fresh state => elapsed is ZERO
557 state.store().await.unwrap();
558
559 let content = tokio::fs::read_to_string(&path).await.unwrap();
560 assert!(
561 !content.contains("elapsed"),
562 "a zero elapsed should be omitted from the .fd file, got:\n{content}"
563 );
564
565 let loaded = DownloadState::load(&path).await.unwrap();
566 assert_eq!(loaded.get_elapsed(), Duration::ZERO);
567
568 let _ = std::fs::remove_file(&path);
569 }
570
571 #[tokio::test]
572 async fn merge_config_adopts_fresh_config_when_loaded_state_has_none() {
573 // Regression guard for the bug where a resumed `.fd` that deserializes with
574 // `inner.config = None` (which is reachable: `store` omits an all-default
575 // `[config]` table via `skip_serializing_if`) made `merge_config` a silent
576 // no-op — it marked the state dirty but never adopted the fresh request's
577 // config, so the user's resume settings were dropped. The fix makes the
578 // `None` arm adopt the fresh config (a real mutation), so the dirty flag is
579 // always earned. This test pins that behavior.
580 let path = std::env::temp_dir().join(format!(
581 "fd_merge_none_{}.fd",
582 std::time::SystemTime::now()
583 .duration_since(std::time::UNIX_EPOCH)
584 .unwrap()
585 .as_nanos()
586 ));
587 // A `.fd` with NO `[config]` section: only the fields a valid state needs.
588 tokio::fs::write(
589 &path,
590 "url = \"https://example.com/file.bin\"\nsize = 1024\n",
591 )
592 .await
593 .unwrap();
594
595 let state = DownloadState::load(&path).await.unwrap();
596 assert!(
597 state.lock_inner().config.is_none(),
598 "precondition: a .fd without [config] must load with inner.config = None"
599 );
600
601 let fresh = PartialConfig {
602 min_chunk_size: Some(1234),
603 ..Default::default()
604 };
605 state.merge_config(&fresh);
606
607 assert!(
608 state.lock_inner().config.is_some(),
609 "merge_config must adopt the fresh config even when the loaded state had none \
610 (regression: it used to be a silent no-op that dropped the resume request)"
611 );
612
613 let _ = std::fs::remove_file(&path);
614 }
615
616 #[tokio::test]
617 #[allow(clippy::single_range_in_vec_init)]
618 async fn merge_config_preserves_loaded_progress_into_fresh() {
619 // Regression guard for the `Some(downloaded_chunk)` arm of
620 // `merge_config` (state.rs lines 313-317): when the loaded `.fd` already
621 // records progress, that progress must be folded into the fresh request so a
622 // resumed download keeps the already-downloaded bytes, and the fresh
623 // request's own overrides (here `min_chunk_size`) must still apply.
624 let path = std::env::temp_dir().join(format!(
625 "fd_merge_progress_{}.fd",
626 std::time::SystemTime::now()
627 .duration_since(std::time::UNIX_EPOCH)
628 .unwrap()
629 .as_nanos()
630 ));
631 let state = make_state(&path);
632 // Simulate a loaded state that already recorded two contiguous ranges.
633 state.inner.lock().config = Some(PartialConfig {
634 downloaded_chunk: Some(vec![0u64..10, 10..20]),
635 ..Default::default()
636 });
637
638 let fresh = PartialConfig {
639 min_chunk_size: Some(2048),
640 ..Default::default()
641 };
642 state.merge_config(&fresh);
643
644 let merged = state.inner.lock().config.clone().unwrap();
645 assert_eq!(
646 merged.downloaded_chunk,
647 Some(vec![0u64..20]),
648 "loaded progress must be preserved and merged into the fresh config"
649 );
650 assert_eq!(
651 merged.min_chunk_size,
652 Some(2048),
653 "fresh request overrides must still be applied via inherit_from"
654 );
655
656 let _ = std::fs::remove_file(&path);
657 }
658
659 fn info_with(etag: Option<&str>, size: u64) -> UrlInfo {
660 UrlInfo {
661 size,
662 raw_name: "file.bin".to_string(),
663 supports_range: true,
664 fast_download: true,
665 final_url: Url::parse("https://example.com/file.bin").unwrap(),
666 file_id: FileId::new(etag, None),
667 content_type: Some("application/octet-stream".to_string()),
668 }
669 }
670
671 fn fd_path(name: &str) -> std::path::PathBuf {
672 std::env::temp_dir().join(format!(
673 "fd_validate_{name}_{}",
674 std::time::SystemTime::now()
675 .duration_since(std::time::UNIX_EPOCH)
676 .unwrap()
677 .as_nanos()
678 ))
679 }
680
681 #[test]
682 fn validate_ok_when_size_and_file_id_match() {
683 // validate (state.rs lines 247-261): identical size + etag must pass.
684 let state = make_state(&fd_path("ok"));
685 assert!(
686 state.validate(&info_with(Some("etag-1"), 1024)).is_ok(),
687 "matching size + etag must validate"
688 );
689 }
690
691 #[test]
692 fn validate_rejects_size_mismatch() {
693 // Same etag, different size => the remote file changed => FileChanged.
694 let state = make_state(&fd_path("size"));
695 assert!(matches!(
696 state.validate(&info_with(Some("etag-1"), 2048)),
697 Err(StateError::FileChanged { .. })
698 ));
699 }
700
701 #[test]
702 fn validate_rejects_etag_mismatch() {
703 // Same size, different etag => identity mismatch => FileChanged.
704 let state = make_state(&fd_path("etag"));
705 assert!(matches!(
706 state.validate(&info_with(Some("etag-2"), 1024)),
707 Err(StateError::FileChanged { .. })
708 ));
709 }
710
711 #[test]
712 fn refresh_identity_updates_url_to_caller_initial_url_not_final_url() {
713 // refresh_identity (state.rs): when a resumed download is called with a
714 // URL, that URL must be persisted as the `.fd`'s durable initial URL so a
715 // later `resume(None)` re-resolves the caller's resource. It must NOT
716 // persist the transient `info.final_url` (signed/CDN links expire). The
717 // content identity (etag/last-modified/size) is refreshed to the fresh
718 // prefetch values.
719 let path = fd_path("refresh_identity");
720 let state = make_state(&path); // url = https://example.com/file.bin, etag-1, size 1024
721 assert_eq!(
722 state.lock_inner().url.as_ref().unwrap().as_str(),
723 "https://example.com/file.bin",
724 "precondition: fresh state records the initial caller URL"
725 );
726
727 // A new caller URL (e.g. a rotated signed URL) and a fresh prefetch whose
728 // final_url is a different, transient redirect target.
729 let new_initial_url =
730 Url::parse("https://example.com/v2/signed-token-xyz/file.bin").unwrap();
731 let new_info = UrlInfo {
732 size: 2048,
733 raw_name: "file.bin".to_string(),
734 supports_range: true,
735 fast_download: true,
736 final_url: Url::parse("https://cdn.example.com/signed/token-abc/file.bin").unwrap(),
737 file_id: FileId::new(Some("etag-2"), Some("Mon, 02 Aug 2026 00:00:00 GMT")),
738 content_type: Some("application/octet-stream".to_string()),
739 };
740
741 // Caller passes the new initial URL: it must take priority over the stored one.
742 state.refresh_identity(&new_initial_url, &new_info);
743 let inner = state.lock_inner();
744 assert_eq!(
745 inner.url.as_ref().unwrap().as_str(),
746 "https://example.com/v2/signed-token-xyz/file.bin",
747 "refresh must update the .fd URL to the caller-supplied initial URL (priority)"
748 );
749 assert_ne!(
750 inner.url.as_ref().unwrap().as_str(),
751 "https://cdn.example.com/signed/token-abc/file.bin",
752 "refresh must NEVER store the transient final_url in the .fd"
753 );
754 assert_eq!(
755 inner.etag.as_ref().unwrap().as_deref(),
756 Some("etag-2"),
757 "refresh must update the etag"
758 );
759 assert_eq!(
760 inner.last_modified.as_ref().unwrap().as_deref(),
761 Some("Mon, 02 Aug 2026 00:00:00 GMT"),
762 "refresh must update last-modified"
763 );
764 assert_eq!(inner.size, Some(2048), "refresh must update size");
765 }
766}