From 62846bec9fcc30f798666bd6d2b755a4f24ff55e Mon Sep 17 00:00:00 2001 From: share121 Date: Wed, 12 Aug 2026 08:43:50 +0800 Subject: [PATCH 1/3] =?UTF-8?q?wip:=20=E5=BC=80=E5=8F=91=E5=88=B0=E4=B8=80?= =?UTF-8?q?=E5=8D=8A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: share121 --- Cargo.lock | 16 ++++- crates/fast-down-api/Cargo.toml | 3 +- crates/fast-down-api/README.md | 69 +++++++++++++++---- crates/fast-down-api/src/config.rs | 33 ++++++++- crates/fast-down-api/src/core/download/mod.rs | 41 ++++------- crates/fast-down-api/src/event.rs | 44 ++++++++++++ crates/fast-down-api/src/lib.rs | 14 +--- .../src/utils/filename_template.rs | 9 +-- crates/fast-down-api/src/utils/gen_path.rs | 6 +- 9 files changed, 171 insertions(+), 64 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 03639ce..57b321a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -437,6 +437,7 @@ dependencies = [ "chrono", "crossfire", "fast-down", + "file_alloc", "futures", "http-body-util", "humantime-serde", @@ -486,6 +487,17 @@ version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" +[[package]] +name = "file_alloc" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "152d511127cc6e0cf61912eb7f7f1a7e5c910c8c43d43f1f0ba7eb26d674f511" +dependencies = [ + "rustix", + "tokio", + "windows-sys 0.61.2", +] + [[package]] name = "find-msvc-tools" version = "0.1.9" @@ -1222,9 +1234,9 @@ dependencies = [ [[package]] name = "path_helper" -version = "0.1.9" +version = "0.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1cf0534c5a78bd12bd2c8d9ad3231f22ef07fc2205b5093e63e40cab4c4a8fc" +checksum = "5c1b0eb8451e0718ff71661887df7206b717b33a17ebebe3bb74771ff5e8c1e1" dependencies = [ "mime_guess", "sanitize-filename", diff --git a/crates/fast-down-api/Cargo.toml b/crates/fast-down-api/Cargo.toml index f3d676b..c8d392b 100644 --- a/crates/fast-down-api/Cargo.toml +++ b/crates/fast-down-api/Cargo.toml @@ -40,11 +40,12 @@ reqwest = { version = "0.13.4", default-features = false, features = [ thiserror.workspace = true crossfire.workspace = true anyhow = "1.0.103" -path_helper = { version = "0.1.9", features = [ +path_helper = { version = "0.1.10", features = [ "auto_ext", "sanitize", "tokio", ] } +file_alloc = "0.2" chrono = "0.4.45" urlencoding = "2.1.3" soft-canonicalize = "0.5.6" diff --git a/crates/fast-down-api/README.md b/crates/fast-down-api/README.md index 4b9cbff..674f5e0 100644 --- a/crates/fast-down-api/README.md +++ b/crates/fast-down-api/README.md @@ -12,10 +12,10 @@ that turns the pull/push engine into a few lines of async code: spawn a download drain progress events, resume after interruption, and cancel cooperatively. - **Concurrent, resumable downloads** powered by the `fast-down` engine (work-stealing, range requests). -- **Two entry points**: `download` (auto-resume when possible) and `resume` (hard error if it can't continue). -- **Event stream**: a single channel carries prefetch, per-worker progress, rename, and error events. +- **Two layers of entry points**: the fire-and-forget `download` / `resume` wrappers, and the lower-level `plan` / `plan_resume` pair that prefetches the remote and inspects the disk _without writing a single byte_ — so you can preview the outcome and decide before committing. +- **Event stream**: a single channel carries prefetch, disk allocation, per-worker progress, resume, rename, and lifecycle events. Every run ends with exactly one `Event::Terminated(TerminationReason)`. - **Cooperative cancellation**: cancelling mid-flight preserves the `.part` / `.fd` files so you can resume later. -- **Configurable**: threads, chunk size, write method (`Mmap` / `Std`), proxies, headers, retries, and more via `PartialConfig`. +- **Configurable**: threads, chunk size, write method (`Mmap` / `Std`), proxies, headers, retries, disk pre-allocation, and more via `PartialConfig`. ## Quick start @@ -79,6 +79,12 @@ async fn main() -> anyhow::Result<()> { break; } Event::ResumeError(e) => eprintln!("resume error: {e}"), + Event::Allocating(size) => println!("pre-allocating {size} bytes on disk"), + Event::AllocError(e) => eprintln!("pre-allocation failed (continuing): {e}"), + Event::Terminated(reason) => { + println!("terminated: {reason:?}"); + break; + } _ => {} } } @@ -115,17 +121,51 @@ resume( token.cancel(); // stops fetching, keeps .part / .fd so you can resume later ``` +### Two-phase planning (inspect before you commit) + +`plan` and `plan_resume` do everything `download` / `resume` do _except_ touch +the disk: they prefetch the remote metadata, resolve the output path, and probe +the `.fd` / `.part` pair left by a previous run. The returned [`DownloadPlan`] +tells you what starting it would do — [`DownloadPlan::resume_outcome`] reports +`Resumable`, `Fresh`, or `Mismatch` — and nothing is created until you call one +of its `start` methods. Dropping the plan abandons the download with no side +effects. + +```rust,ignore +let plan = plan(url, config.clone(), tx.clone(), token.clone()).await?; + +match plan.resume_outcome() { + ResumeOutcome::Resumable { .. } => println!("will continue from a previous run"), + ResumeOutcome::Fresh => println!("will download the whole file"), + ResumeOutcome::Mismatch(e) => println!("stale state: {e} (use start_forced_resume)"), +} + +// Commit when you're ready. Each start method emits exactly one `Event::Terminated`. +plan.start().await; // resume if possible, else fresh (or refuse for plan_resume) +// plan.start_fresh().await; // ignore any saved progress and re-download +// plan.start_forced_resume().await; // continue from a mismatched state when only identity changed +``` + +`plan_resume` takes a `.part` path instead of a URL and hard-refuses a +`Mismatch` (sending `Event::ResumeError` + `TerminationReason::Failed`) rather +than restarting — because the caller asked to continue one specific file, not to +fetch it again. Pass a `url` to re-fetch the metadata, or `None` to reuse the URL +recorded in the `.fd`. + ## API overview -| Item | Purpose | -| ------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| [`download`](https://docs.rs/fast-down-api/latest/fast_down_api/fn.download.html) | Start a download; auto-resume when a valid `.fd` + `.part` exist, else fresh. Observe completion by draining the `Rx` from `create_channel` until it disconnects. | -| [`resume`](https://docs.rs/fast-down-api/latest/fast_down_api/fn.resume.html) | Resume a specific `.part` file; hard-error (`Event::ResumeError`) if it can't. Completion is observed the same way, by draining `Rx`. | -| [`create_channel`](https://docs.rs/fast-down-api/latest/fast_down_api/fn.create_channel.html) | Create the `(Tx, Rx)` event channel. | -| [`create_cancellation_token`](https://docs.rs/fast-down-api/latest/fast_down_api/fn.create_cancellation_token.html) | Create a `CancellationToken` for cooperative cancellation. | -| [`Event`](https://docs.rs/fast-down-api/latest/fast_down_api/enum.Event.html) | The event enum delivered over the channel. | -| [`PartialConfig`](https://docs.rs/fast-down-api/latest/fast_down_api/struct.PartialConfig.html) | Layered, optional configuration for a download. | -| [`StateError`](https://docs.rs/fast-down-api/latest/fast_down_api/enum.StateError.html) | Errors surfaced via `Event::ResumeError`. | +| Item | Purpose | +| ------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| [`download`](https://docs.rs/fast-down-api/latest/fast_down_api/fn.download.html) | Start a download; auto-resume when a valid `.fd` + `.part` exist, else fresh. Observe completion by draining the `Rx` from `create_channel` until it disconnects. | +| [`resume`](https://docs.rs/fast-down-api/latest/fast_down_api/fn.resume.html) | Resume a specific `.part` file; hard-error (`Event::ResumeError`) if it can't. Completion is observed the same way, by draining `Rx`. | +| [`plan`](https://docs.rs/fast-down-api/latest/fast_down_api/fn.plan.html) | Prefetch + probe the disk and return a [`DownloadPlan`](https://docs.rs/fast-down-api/latest/fast_down_api/struct.DownloadPlan.html) **without writing anything**; start it only when ready. | +| [`plan_resume`](https://docs.rs/fast-down-api/latest/fast_down_api/fn.plan_resume.html) | Like `plan` but targets a specific `.part`; refuses a `Mismatch` instead of re-downloading. | +| [`DownloadPlan`](https://docs.rs/fast-down-api/latest/fast_down_api/struct.DownloadPlan.html) | A prepared, not-yet-started download. Inspect with `resume_outcome`, then call `start` / `start_fresh` / `start_forced_resume`. | +| [`create_channel`](https://docs.rs/fast-down-api/latest/fast_down_api/fn.create_channel.html) | Create the `(Tx, Rx)` event channel. | +| [`create_cancellation_token`](https://docs.rs/fast-down-api/latest/fast_down_api/fn.create_cancellation_token.html) | Create a `CancellationToken` for cooperative cancellation. | +| [`Event`](https://docs.rs/fast-down-api/latest/fast_down_api/enum.Event.html) | The event enum delivered over the channel. | +| [`PartialConfig`](https://docs.rs/fast-down-api/latest/fast_down_api/struct.PartialConfig.html) | Layered, optional configuration for a download. | +| [`StateError`](https://docs.rs/fast-down-api/latest/fast_down_api/enum.StateError.html) | Errors surfaced via `Event::ResumeError`. | ## How resume works @@ -140,6 +180,11 @@ size). On the next run: Cancellation leaves both files in place, so a later `resume` (or `download`) can pick up exactly where it stopped. +Every run — whether it completes, is cancelled, stops incomplete, or fails — +ends with exactly one `Event::Terminated(TerminationReason)` as the last event +on the channel, so draining `Rx` until `Terminated` is the reliable way to know +a run has finished. + ## License MIT — see [LICENSE](https://github.com/fast-down/core/blob/main/LICENSE). diff --git a/crates/fast-down-api/src/config.rs b/crates/fast-down-api/src/config.rs index 0fbc9c0..393e243 100644 --- a/crates/fast-down-api/src/config.rs +++ b/crates/fast-down-api/src/config.rs @@ -29,9 +29,6 @@ pub struct Config { /// 文件名 pub filename: String, - /// 用于在 prefetch 阶段生成占位文件名 - pub gid: String, - /// Number of threads. Recommended: `32` / `16` / `8`. More threads does not always mean faster. #[config(default = 32)] pub threads: usize, @@ -54,6 +51,21 @@ pub struct Config { /// Set to `true` only if you need to power off immediately after download. pub sync_all: bool, + /// Reserve the whole file size on disk before downloading. Recommended: `false` + /// + /// Claiming the space up front keeps the file in fewer fragments and turns + /// a full disk into an error before any bytes are fetched instead of + /// halfway through. The cost depends on the platform: `fallocate` on Unix + /// and `SetFileValidData` on Windows reserve the space instantly, but + /// without them the fallback writes zeros across the whole file, which + /// takes as long as a full-size write pass. + /// + /// `SetFileValidData` needs the `SeManageVolumePrivilege`, which is + /// enabled automatically when available; without it Windows falls back to + /// the zero-fill path. Failure to reserve is reported as + /// [`crate::Event::AllocError`] and does not stop the download. + pub pre_alloc: bool, + /// Write buffer size in bytes. Recommended: `16 * 1024 * 1024` /// /// - Only effective for [`WriteMethod::Std`]. Reduces the number of `write` syscalls @@ -325,6 +337,11 @@ mod range_list { } ranges.push(start..end_inclusive.saturating_add(1)); } + // Normalize to ascending `start` order on load so downstream + // consumers (resume gap computation, `part_shortfall`, ...) can + // rely on the sorted invariant even when the `.fd` was hand-edited + // or written by an older build that stored chunks out of order. + ranges.sort_by_key(|r| r.start); Ok(Some(ranges)) } } @@ -505,4 +522,14 @@ mod range_list_tests { "wrapping end must be rejected with a clear error, got: {msg}" ); } + + /// Deserialize must normalize chunks to ascending `start` order so downstream + /// consumers can rely on the sorted invariant even for hand-edited `.fd` files. + #[test] + #[allow(clippy::single_range_in_vec_init)] + fn downloaded_chunk_deserialize_normalizes_order() { + let toml = "downloaded_chunk = \"5-9,1-3,100-100\"\n"; + let pc: PartialConfig = toml::from_str(toml).unwrap(); + assert_eq!(pc.downloaded_chunk, Some(vec![1..4, 5..10, 100..101])); + } } diff --git a/crates/fast-down-api/src/core/download/mod.rs b/crates/fast-down-api/src/core/download/mod.rs index a16277d..1fe3051 100644 --- a/crates/fast-down-api/src/core/download/mod.rs +++ b/crates/fast-down-api/src/core/download/mod.rs @@ -1,11 +1,7 @@ -use crate::core::download::overwrite::OverwriteOption; use crate::utils::ForceSendExt; use crate::{DownloadState, Event, StateError}; -use crate::{PartialConfig, Tx, prefetch, tx_err, utils::gen_path}; +use crate::{PartialConfig, TerminationReason, Tx}; use fast_down::UrlInfo; -use inherit_config::ConfigLayer; -use overwrite::overwrite; -use path_helper::IterStemExt; use std::path::Path; use tokio::fs::{self, OpenOptions}; use tokio_util::sync::CancellationToken; @@ -13,8 +9,11 @@ use url::Url; mod overwrite; mod pipeline; +mod plan; mod progress_reporter; +pub use plan::*; + fn open_existing() -> OpenOptions { let mut o = OpenOptions::new(); o.read(true).write(true).truncate(false).create(false); @@ -33,12 +32,14 @@ fn open_create_new() -> OpenOptions { /// Attempt to load and validate a resume state from disk. /// -/// This helper consolidates the resume logic shared between `run_download` (overwrite and non-overwrite branches) -/// and `run_resume`. It checks if both `.fd` and `.part` exist, validates the state against the current server info, -/// and merges the new config into the loaded state. +/// This checks that both the `.fd` and `.part` exist, validates the state +/// against the current server info, and merges the new config into the loaded +/// state. /// -/// Returns `Ok(Some(state))` if resume is possible, `Ok(None)` if no resume state exists (caller should start fresh), -/// or `Err(StateError)` if the state exists but is invalid. +/// Returns `Ok(Some(state))` if resume is possible, `Ok(None)` if there is +/// nothing usable to resume from (the pair is incomplete, or the `.part` is +/// shorter than the recorded progress), or `Err(StateError)` if the state exists +/// but does not describe the current remote file. #[allow(clippy::result_large_err)] async fn try_load_resume_state( url: &Url, @@ -61,22 +62,10 @@ async fn try_load_resume_state( // Validate the state against current server info state.validate(info)?; - // Check that the .part file size is consistent with the recorded progress. - // Only applies to regular files — directories or other special files are not a - // valid .part and will fail later when build_pipeline tries to open them. - if let Ok(metadata) = fs::metadata(tmp_path).await - && metadata.is_file() - { - let actual_size = metadata.len(); - let recorded_progress = state.get_progress(); - let max_recorded_end = recorded_progress.iter().map(|r| r.end).max().unwrap_or(0); - - if actual_size < max_recorded_end { - // The .part file is smaller than what we think is already downloaded. - // This could lead to data corruption if we continue with resume. - // Treat this as if no valid state exists and start fresh. - return Ok(None); - } + // A `.part` shorter than the recorded progress claims bytes that are not on + // disk; continuing would leave that span zero-filled and never fetched. + if state.part_shortfall(tmp_path).await.is_some() { + return Ok(None); } // Merge the new config into the loaded state diff --git a/crates/fast-down-api/src/event.rs b/crates/fast-down-api/src/event.rs index 2dd4a4b..6119bf2 100644 --- a/crates/fast-down-api/src/event.rs +++ b/crates/fast-down-api/src/event.rs @@ -12,6 +12,10 @@ use std::{path::PathBuf, time::Duration}; /// and completion ([`Event::Renamed`]). Error variants (`*Error`) report failures /// without aborting the stream, so a consumer can decide whether to retry, /// cancel, or surface them in a UI. +/// +/// Every run ends with exactly one [`Event::Terminated`], which is always the +/// last event on the channel. A consumer that only needs the outcome can wait +/// for it instead of draining until the channel disconnects. #[allow(clippy::large_enum_variant)] #[derive(Debug)] pub enum Event { @@ -38,6 +42,20 @@ pub enum Event { BuildClientError(reqwest::Error), /// Creating the output sink — opening the `.part` file — failed. BuildPusherError(std::io::Error), + /// Disk space for the whole file is about to be reserved, carrying the + /// target size in bytes. + /// + /// Only emitted when [`crate::Config::pre_alloc`] is enabled and the remote + /// size is known. Where the platform has no fast-reservation path this is + /// followed by a full-size zero-fill pass, which can take a while — this + /// event exists so a UI can say so instead of appearing frozen. + Allocating(u64), + /// Reserving disk space failed. + /// + /// Non-fatal: the download continues and the file grows on demand. The + /// trade-off is more fragmentation and the chance of running out of space + /// mid-download rather than up front. + AllocError(std::io::Error), /// The final rename of the `.part` file to its destination failed. /// /// The success counterpart is [`Event::Renamed`]. The bytes are already on @@ -115,6 +133,32 @@ pub enum Event { FlushError(anyhow::Error), /// Worker `id` completed its assigned range and exited. Finished(WorkerId), + + /// The run has ended. Sent exactly once, as the last event on the channel. + /// + /// Preceding `*Error` events carry the details of whatever went wrong; this + /// one only reports the outcome. + Terminated(TerminationReason), +} + +/// How a download run ended, carried by [`Event::Terminated`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TerminationReason { + /// Every byte was written and the `.part` file was renamed into place. + /// [`Event::Renamed`] carries the path it landed on. + Completed, + /// The [`CancellationToken`](crate::create_cancellation_token) was + /// triggered. The `.part` and `.fd` files are left on disk so a later + /// resume can pick up where this run stopped. + Cancelled, + /// The run stopped on its own without completing the file — every worker + /// gave up, for example because the connection kept failing. Like + /// [`TerminationReason::Cancelled`], the `.part` and `.fd` files are left + /// on disk. + Incomplete, + /// A fatal error ended the run: metadata could not be fetched, the output + /// file could not be opened, the rename failed, and so on. + Failed, } /// Computed aggregate view of the current download progress, carried by diff --git a/crates/fast-down-api/src/lib.rs b/crates/fast-down-api/src/lib.rs index e3b0f09..cb2405a 100644 --- a/crates/fast-down-api/src/lib.rs +++ b/crates/fast-down-api/src/lib.rs @@ -11,7 +11,7 @@ pub use event::*; pub use fast_down; -use tokio_util::sync::CancellationToken; +pub use tokio_util::sync::CancellationToken; /// Sender half of the event channel, used to push [`Event`]s from the download task. pub type Tx = crossfire::MTx>; @@ -25,15 +25,3 @@ pub type Rx = crossfire::MAsyncRx>; pub fn create_channel() -> (Tx, Rx) { crossfire::mpmc::unbounded_async() } - -/// Create a new cancellation token for use with download tasks. -/// -/// Pass the token to [`download`] or [`resume`] -/// to cancel the download at any time. Cancellation is cooperative: the running -/// task stops fetching, leaves the `.part`/`.fd` files in place, and returns -/// without renaming — so a later [`resume`] call can continue -/// from where it stopped. -#[must_use] -pub fn create_cancellation_token() -> CancellationToken { - CancellationToken::new() -} diff --git a/crates/fast-down-api/src/utils/filename_template.rs b/crates/fast-down-api/src/utils/filename_template.rs index de53085..8f4c995 100644 --- a/crates/fast-down-api/src/utils/filename_template.rs +++ b/crates/fast-down-api/src/utils/filename_template.rs @@ -3,9 +3,9 @@ use path_helper::sanitize_filename; use std::panic; use url::Url; -pub fn parse_filename_template(template: String, url: &Url, filename: &str) -> String { - let template = - panic::catch_unwind(|| Local::now().format(&template).to_string()).unwrap_or(template); +pub fn parse_filename_template(template: &str, url: &Url, filename: &str) -> String { + let template = panic::catch_unwind(|| Local::now().format(template).to_string()) + .unwrap_or(template.to_string()); let host = sanitize_filename(url.host_str().unwrap_or("unknown"), 255); let mut parent_path: Vec<_> = url .path_segments() @@ -13,11 +13,12 @@ pub fn parse_filename_template(template: String, url: &Url, filename: &str) -> S .flat_map(|segments| { segments.map(|seg| { let decoded = urlencoding::decode_binary(seg.as_bytes()); - sanitize_filename(String::from_utf8_lossy(&decoded), 255) + sanitize_filename(String::from_utf8_lossy(&decoded).as_ref(), 255) }) }) .collect(); parent_path.pop(); + todo!("是否会导致两个连续的分隔符?"); let parent_path = if parent_path.is_empty() { ".".to_string() } else { diff --git a/crates/fast-down-api/src/utils/gen_path.rs b/crates/fast-down-api/src/utils/gen_path.rs index df13c44..9ccaa5a 100644 --- a/crates/fast-down-api/src/utils/gen_path.rs +++ b/crates/fast-down-api/src/utils/gen_path.rs @@ -12,7 +12,8 @@ pub async fn gen_path(url: &Url, info: &UrlInfo, config: &Config) -> std::io::Re auto_ext(&info.raw_name, info.content_type.as_deref()) } else { Cow::Borrowed(config.filename.as_str()) - }, + } + .as_ref(), 248, ); let mut save_dir = soft_canonicalize::soft_canonicalize(&config.save_dir)?; @@ -23,7 +24,7 @@ pub async fn gen_path(url: &Url, info: &UrlInfo, config: &Config) -> std::io::Re &filename, )); if let Some(s) = path.file_name() { - filename = sanitize_filename(s.to_string_lossy(), 248); + filename = sanitize_filename(s.to_string_lossy().as_ref(), 248); } if let Some(parent_path) = path.parent() && let Ok(new_save_dir) = soft_canonicalize(save_dir.join(sanitize_path(parent_path))) @@ -32,7 +33,6 @@ pub async fn gen_path(url: &Url, info: &UrlInfo, config: &Config) -> std::io::Re save_dir = new_save_dir; } } - fs::create_dir_all(&save_dir).await?; Ok(save_dir.join(&filename)) } From e4a26f07747e5070e1552b281f962b52c85eaa42 Mon Sep 17 00:00:00 2001 From: share121 Date: Wed, 12 Aug 2026 08:56:22 +0800 Subject: [PATCH 2/3] =?UTF-8?q?fix:=20=E9=98=B2=E6=AD=A2=E7=A9=BA=E6=AE=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: share121 --- crates/fast-down-api/src/utils/filename_template.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/fast-down-api/src/utils/filename_template.rs b/crates/fast-down-api/src/utils/filename_template.rs index 8f4c995..61e01e6 100644 --- a/crates/fast-down-api/src/utils/filename_template.rs +++ b/crates/fast-down-api/src/utils/filename_template.rs @@ -18,7 +18,7 @@ pub fn parse_filename_template(template: &str, url: &Url, filename: &str) -> Str }) .collect(); parent_path.pop(); - todo!("是否会导致两个连续的分隔符?"); + parent_path.retain(|segment| !segment.is_empty()); let parent_path = if parent_path.is_empty() { ".".to_string() } else { From c3158c708384101cbce5fabda52d3597130e128d Mon Sep 17 00:00:00 2001 From: share121 Date: Wed, 12 Aug 2026 15:12:18 +0800 Subject: [PATCH 3/3] =?UTF-8?q?wip:=20=E5=86=99=E4=B8=80=E5=8D=8A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: share121 --- crates/fast-down-api/src/lib.rs | 11 ++++ .../src/utils/filename_template.rs | 24 ++++----- crates/fast-down-api/src/utils/gen_path.rs | 17 ++++--- crates/fast-down-api/tests/resume.rs | 50 +++++++++++++++++++ 4 files changed, 79 insertions(+), 23 deletions(-) diff --git a/crates/fast-down-api/src/lib.rs b/crates/fast-down-api/src/lib.rs index cb2405a..aa1a83c 100644 --- a/crates/fast-down-api/src/lib.rs +++ b/crates/fast-down-api/src/lib.rs @@ -25,3 +25,14 @@ pub type Rx = crossfire::MAsyncRx>; pub fn create_channel() -> (Tx, Rx) { crossfire::mpmc::unbounded_async() } + +/// Create a new [`CancellationToken`] for cooperative cancellation of a download. +/// +/// Pass the returned token to [`download`] (or a related entry +/// point) and call +/// [`CancellationToken::cancel`](tokio_util::sync::CancellationToken::cancel) to +/// abort the in-flight download. +#[must_use] +pub fn create_cancellation_token() -> CancellationToken { + CancellationToken::new() +} diff --git a/crates/fast-down-api/src/utils/filename_template.rs b/crates/fast-down-api/src/utils/filename_template.rs index 61e01e6..71f56ce 100644 --- a/crates/fast-down-api/src/utils/filename_template.rs +++ b/crates/fast-down-api/src/utils/filename_template.rs @@ -5,7 +5,7 @@ use url::Url; pub fn parse_filename_template(template: &str, url: &Url, filename: &str) -> String { let template = panic::catch_unwind(|| Local::now().format(template).to_string()) - .unwrap_or(template.to_string()); + .unwrap_or_else(|_| template.to_string()); let host = sanitize_filename(url.host_str().unwrap_or("unknown"), 255); let mut parent_path: Vec<_> = url .path_segments() @@ -46,7 +46,7 @@ mod tests { fn all_placeholders() { let url = Url::parse("https://example.com/path/to/file.txt").unwrap(); let t = "{host}/{parent_path}/{file_name}_{file_stem}{file_ext}"; - let out = parse_filename_template(t.to_string(), &url, "file.txt"); + let out = parse_filename_template(t, &url, "file.txt"); assert!(out.starts_with("example.com")); assert!(out.contains("path")); assert!(out.contains("to")); @@ -56,26 +56,20 @@ mod tests { #[test] fn no_placeholders_passthrough() { let url = Url::parse("https://example.com/x").unwrap(); - assert_eq!( - parse_filename_template("plain".to_string(), &url, "f.txt"), - "plain" - ); + assert_eq!(parse_filename_template("plain", &url, "f.txt"), "plain"); } #[test] fn host_unknown_when_no_host() { let url = Url::parse("file:///etc/hosts").unwrap(); - assert_eq!( - parse_filename_template("{host}".to_string(), &url, "hosts"), - "unknown" - ); + assert_eq!(parse_filename_template("{host}", &url, "hosts"), "unknown"); } #[test] fn parent_path_root_when_no_dir() { let url = Url::parse("https://example.com/file.txt").unwrap(); assert_eq!( - parse_filename_template("{parent_path}".to_string(), &url, "file.txt"), + parse_filename_template("{parent_path}", &url, "file.txt"), "." ); } @@ -83,14 +77,14 @@ mod tests { #[test] fn file_ext_includes_dot() { let url = Url::parse("https://example.com/a/b.tar.gz").unwrap(); - let out = parse_filename_template("{file_stem}{file_ext}".to_string(), &url, "b.tar.gz"); + let out = parse_filename_template("{file_stem}{file_ext}", &url, "b.tar.gz"); assert_eq!(out, "b.tar.gz"); } #[test] fn no_dot_file_has_empty_ext() { let url = Url::parse("https://example.com/README").unwrap(); - let out = parse_filename_template("{file_stem}|{file_ext}".to_string(), &url, "README"); + let out = parse_filename_template("{file_stem}|{file_ext}", &url, "README"); assert_eq!(out, "README|"); } @@ -99,7 +93,7 @@ mod tests { // `mailto:` URLs are cannot-be-a-base, so `path_segments()` is `None` and // the parent path collapses to "." (filename_template.rs lines 10-25). let url = Url::parse("mailto:foo@x").unwrap(); - let out = parse_filename_template("{parent_path}/{file_name}".to_string(), &url, "foo.txt"); + let out = parse_filename_template("{parent_path}/{file_name}", &url, "foo.txt"); assert_eq!(out, "./foo.txt"); } @@ -108,7 +102,7 @@ mod tests { // A leading `%Y` is a chrono format spec expanded by `Local::now().format` // before the `{...}` placeholders are substituted (filename_template.rs line 7). let url = Url::parse("https://example.com/file.txt").unwrap(); - let out = parse_filename_template("%Y/file.txt".to_string(), &url, "file.txt"); + let out = parse_filename_template("%Y/file.txt", &url, "file.txt"); let year = chrono::Local::now().format("%Y").to_string(); assert_eq!(out, format!("{year}/file.txt")); } diff --git a/crates/fast-down-api/src/utils/gen_path.rs b/crates/fast-down-api/src/utils/gen_path.rs index 9ccaa5a..1406e19 100644 --- a/crates/fast-down-api/src/utils/gen_path.rs +++ b/crates/fast-down-api/src/utils/gen_path.rs @@ -3,7 +3,6 @@ use fast_down::UrlInfo; use path_helper::{auto_ext, sanitize_filename, sanitize_path}; use soft_canonicalize::soft_canonicalize; use std::{borrow::Cow, path::PathBuf}; -use tokio::fs; use url::Url; pub async fn gen_path(url: &Url, info: &UrlInfo, config: &Config) -> std::io::Result { @@ -18,11 +17,7 @@ pub async fn gen_path(url: &Url, info: &UrlInfo, config: &Config) -> std::io::Re ); let mut save_dir = soft_canonicalize::soft_canonicalize(&config.save_dir)?; if config.parse_filename && !config.filename.is_empty() { - let path = PathBuf::from(parse_filename_template( - config.filename.clone(), - url, - &filename, - )); + let path = PathBuf::from(parse_filename_template(&config.filename, url, &filename)); if let Some(s) = path.file_name() { filename = sanitize_filename(s.to_string_lossy().as_ref(), 248); } @@ -106,8 +101,14 @@ mod tests { let p = gen_path(&url, &info, &cfg).await.unwrap(); // parent_path of /a/b/data.bin is "a/b", so the resolved path ends with it. assert!(p.ends_with("a/b/data.bin"), "unexpected path: {p:?}"); - // The synthesized parent directory must have been created by gen_path. - assert!(p.parent().is_some_and(std::path::Path::exists)); + // `gen_path` only computes the path; it must NOT create the directory. + // Directory creation is the download executor's job (`claim_and_run`), + // covered by the integration test `download_creates_template_subdir` + // in tests/resume.rs. + assert!( + !p.parent().unwrap().exists(), + "gen_path must not touch the filesystem" + ); } #[tokio::test] diff --git a/crates/fast-down-api/tests/resume.rs b/crates/fast-down-api/tests/resume.rs index 317e871..230ff34 100644 --- a/crates/fast-down-api/tests/resume.rs +++ b/crates/fast-down-api/tests/resume.rs @@ -2271,3 +2271,53 @@ async fn test_resume_rejects_non_part_extension() { "should not rename when tmp_path has wrong extension" ); } + +/// A filename template that expands into a subdirectory of `save_dir` (e.g. +/// `{parent_path}/{file_name}`) must make the download executor create that +/// subdirectory before writing, so the file lands inside it. +/// +/// This is the integration-level counterpart of the pure-`gen_path` contract: +/// `gen_path` only computes the path and must not touch the filesystem, while +/// `claim_and_run` creates the parent directory right before opening the file. +#[tokio::test] +async fn download_creates_template_subdir() { + let dir = temp_dir("template_subdir"); + let (_server, base) = start_server(original_bytes(), "orig", "LM-A", true).await; + // A multi-segment URL path so `{parent_path}` expands to a non-empty subdir. + let url = format!("{base}/a/b/data.bin"); + + let cfg = PartialConfig { + save_dir: Some(dir.clone()), + filename: Some("{parent_path}/{file_name}".to_string()), + parse_filename: Some(true), + overwrite: Some(true), + write_method: Some(WriteMethod::Mmap), + min_chunk_size: Some(1024 * 1024), + threads: Some(32), + cache_high_watermark: Some(1), + cache_low_watermark: Some(0), + write_buffer_size: Some(1), + ..Default::default() + }; + let (tx, rx) = create_channel(); + let cancel = create_cancellation_token(); + download(Url::parse(&url).expect("valid url"), cfg, tx, cancel); + let events = drain(rx).await; + + assert!( + events.iter().any(|e| matches!(e, Event::Renamed(_))), + "a template-subdir download must complete with Renamed" + ); + + let final_path = dir.join("a").join("b").join("data.bin"); + assert!( + final_path.exists(), + "the template subdir must be created and the file must land inside it" + ); + let got = tokio::fs::read(&final_path).await.expect("read final file"); + assert_eq!( + got, + original_bytes(), + "downloaded content must match source" + ); +}