Skip to main content

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}