Skip to main content

browser_commander/downloads/
store.rs

1//! Writing a download into the managed directory (issue #88).
2//!
3//! The rule the whole module exists to keep: a file that appears under its
4//! final name is complete and has passed validation. Everything else lives
5//! under a `.partial` name and is removed.
6//!
7//! # Example
8//!
9//! ```rust,no_run
10//! use browser_commander::downloads::{save_download, DownloadSource, SaveRequest};
11//!
12//! let saved = save_download(SaveRequest {
13//!     root: "/tmp/bc-downloads".into(),
14//!     source: DownloadSource::at("/tmp/staging/7f1c9ab2"),
15//!     suggested_filename: "7f1c9ab2".into(),
16//!     ..SaveRequest::default()
17//! })?;
18//! // A bare UUID carrying PDF bytes lands under a name a person can open.
19//! assert!(saved.path.ends_with("7f1c9ab2.pdf"));
20//! # Ok::<(), browser_commander::downloads::DownloadError>(())
21//! ```
22
23use std::fmt;
24use std::fs::File;
25use std::io::{Read, Write};
26use std::path::{Path, PathBuf};
27use std::sync::Arc;
28use std::time::{SystemTime, UNIX_EPOCH};
29
30use sha2::{Digest, Sha256};
31
32use crate::downloads::destination::{restrict, ARTIFACT_DIRECTORY_MODE, ARTIFACT_FILE_MODE};
33use crate::downloads::naming::{
34    renamed_candidate, resolve_inside_root, sanitize_download_name, with_extension,
35};
36use crate::downloads::DownloadError;
37
38/// How many bytes are kept to sniff a format from.
39const MAGIC_BYTES: usize = 8;
40
41/// Highest rename attempt before giving up rather than looping forever.
42const MAX_RENAME_ATTEMPTS: usize = 1000;
43
44/// How much of the file is copied at a time.
45const COPY_CHUNK: usize = 64 * 1024;
46
47/// How a name that is already taken is resolved.
48#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
49#[serde(rename_all = "lowercase")]
50pub enum DownloadConflict {
51    /// Save alongside the existing file as `report (2).pdf`.
52    #[default]
53    Rename,
54    /// Replace the existing file.
55    Overwrite,
56    /// Refuse to save rather than touch the existing file.
57    Error,
58}
59
60impl DownloadConflict {
61    /// The name this policy is known by in every language the library ships in.
62    pub fn as_str(&self) -> &'static str {
63        match self {
64            Self::Rename => "rename",
65            Self::Overwrite => "overwrite",
66            Self::Error => "error",
67        }
68    }
69}
70
71impl fmt::Display for DownloadConflict {
72    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
73        f.write_str(self.as_str())
74    }
75}
76
77/// Where the engine left the bytes of a finished download.
78#[derive(Debug, Clone, Default, PartialEq, Eq)]
79pub struct DownloadSource {
80    /// File the engine wrote.
81    pub path: PathBuf,
82    /// Whether the engine's copy is ours to delete once it has been placed.
83    pub remove_source: bool,
84}
85
86impl DownloadSource {
87    /// A source the engine still owns.
88    pub fn at(path: impl Into<PathBuf>) -> Self {
89        Self {
90            path: path.into(),
91            remove_source: false,
92        }
93    }
94
95    /// A source in our own staging directory, which is removed after placement.
96    pub fn staged(path: impl Into<PathBuf>) -> Self {
97        Self {
98            path: path.into(),
99            remove_source: true,
100        }
101    }
102}
103
104/// What a download was saved as.
105#[derive(Debug, Clone, PartialEq, Eq)]
106pub struct SavedDownload {
107    /// Final path, inside the managed directory.
108    pub path: PathBuf,
109    /// Size in bytes.
110    pub bytes: u64,
111    /// Hex-encoded SHA-256 of the saved bytes.
112    pub checksum: String,
113}
114
115/// What a naming callback is told about the download it is naming.
116#[derive(Debug, Clone, PartialEq, Eq)]
117pub struct DownloadNaming {
118    /// Name the page suggested.
119    pub suggested_filename: String,
120    /// MIME type declared by the server, when there is one.
121    pub mime_type: Option<String>,
122}
123
124/// What a validator is shown before the file is published.
125#[derive(Debug, Clone, PartialEq, Eq)]
126pub struct DownloadCandidate {
127    /// The partial file, which no caller can pick up under this name.
128    pub path: PathBuf,
129    /// Size in bytes.
130    pub bytes: u64,
131    /// Hex-encoded SHA-256 of the bytes.
132    pub checksum: String,
133    /// MIME type declared by the server, when there is one.
134    pub mime_type: Option<String>,
135    /// Name the page suggested.
136    pub suggested_filename: String,
137}
138
139/// Names one download, overriding what the page suggested.
140pub type DownloadNamer = Arc<dyn Fn(&DownloadNaming) -> String + Send + Sync>;
141
142/// Decides whether a download may be published.
143///
144/// `Ok(true)` accepts, `Ok(false)` rejects, and an error rejects with the
145/// validator's own message.
146pub type DownloadValidator =
147    Arc<dyn Fn(&DownloadCandidate) -> Result<bool, anyhow::Error> + Send + Sync>;
148
149/// Everything [`save_download`] needs to place one download.
150#[derive(Clone, Default)]
151pub struct SaveRequest {
152    /// Managed download directory.
153    pub root: PathBuf,
154    /// Where the engine left the bytes.
155    pub source: DownloadSource,
156    /// Name the page suggested.
157    pub suggested_filename: String,
158    /// MIME type declared by the server, when there is one.
159    pub mime_type: Option<String>,
160    /// How a name that is already taken is resolved.
161    pub conflict: DownloadConflict,
162    /// Caller naming callback.
163    pub filename: Option<DownloadNamer>,
164    /// Validation run before the file is published.
165    pub validate: Option<DownloadValidator>,
166}
167
168impl fmt::Debug for SaveRequest {
169    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
170        f.debug_struct("SaveRequest")
171            .field("root", &self.root)
172            .field("source", &self.source)
173            .field("suggested_filename", &self.suggested_filename)
174            .field("mime_type", &self.mime_type)
175            .field("conflict", &self.conflict)
176            .field("filename", &self.filename.is_some())
177            .field("validate", &self.validate.is_some())
178            .finish()
179    }
180}
181
182/// What a copy into the staging file produced.
183struct WrittenBytes {
184    bytes: u64,
185    checksum: String,
186    head: Vec<u8>,
187}
188
189/// Copy the download into a partial file, hashing it on the way through.
190///
191/// Hashing during the copy means the bytes are read once: reading the file a
192/// second time to checksum it would double the I/O and leave a window where the
193/// file could change between the two reads.
194fn copy_to_partial(source: &Path, partial_path: &Path) -> Result<WrittenBytes, DownloadError> {
195    let io = |path: &Path| {
196        let path = path.to_path_buf();
197        move |source: std::io::Error| DownloadError::Io {
198            path: path.clone(),
199            source,
200        }
201    };
202
203    let mut reader = File::open(source).map_err(io(source))?;
204    let mut writer = File::create(partial_path).map_err(io(partial_path))?;
205    let _ = restrict(partial_path, ARTIFACT_FILE_MODE);
206
207    let mut hasher = Sha256::new();
208    let mut buffer = vec![0_u8; COPY_CHUNK];
209    let mut bytes = 0_u64;
210    let mut head = Vec::with_capacity(MAGIC_BYTES);
211
212    loop {
213        let read = reader.read(&mut buffer).map_err(io(source))?;
214        if read == 0 {
215            break;
216        }
217        let chunk = &buffer[..read];
218        hasher.update(chunk);
219        bytes += read as u64;
220        if head.len() < MAGIC_BYTES {
221            head.extend_from_slice(&chunk[..read.min(MAGIC_BYTES - head.len())]);
222        }
223        writer.write_all(chunk).map_err(io(partial_path))?;
224    }
225    writer.flush().map_err(io(partial_path))?;
226
227    Ok(WrittenBytes {
228        bytes,
229        checksum: hasher
230            .finalize()
231            .iter()
232            .map(|byte| format!("{byte:02x}"))
233            .collect(),
234        head,
235    })
236}
237
238/// Choose the final path for a completed download.
239///
240/// # Arguments
241///
242/// * `root` - Managed download directory
243/// * `name` - Sanitized file name
244/// * `conflict` - How a name that is already taken is resolved
245///
246/// # Returns
247///
248/// The absolute path the bytes may be published under.
249///
250/// # Errors
251///
252/// Returns [`DownloadError::NameTaken`] when the name exists and the policy is
253/// [`DownloadConflict::Error`], or [`DownloadError::NoFreeName`] when renaming
254/// ran out of attempts.
255pub fn resolve_final_path(
256    root: &Path,
257    name: &str,
258    conflict: DownloadConflict,
259) -> Result<PathBuf, DownloadError> {
260    if conflict == DownloadConflict::Overwrite {
261        return resolve_inside_root(root, name);
262    }
263
264    for attempt in 0..MAX_RENAME_ATTEMPTS {
265        let candidate = resolve_inside_root(root, &renamed_candidate(name, attempt))?;
266        if !candidate.exists() {
267            return Ok(candidate);
268        }
269        if conflict == DownloadConflict::Error {
270            return Err(DownloadError::NameTaken { path: candidate });
271        }
272    }
273
274    Err(DownloadError::NoFreeName {
275        name: name.to_string(),
276        attempts: MAX_RENAME_ATTEMPTS,
277    })
278}
279
280/// Build the name of the partial file this save writes into.
281fn partial_name(safe_name: &str) -> String {
282    let stamp = SystemTime::now()
283        .duration_since(UNIX_EPOCH)
284        .map(|since| since.as_millis())
285        .unwrap_or_default();
286    format!("{safe_name}.{}.{stamp}.partial", std::process::id())
287}
288
289/// Save a download into the managed directory.
290///
291/// # Arguments
292///
293/// * `request` - Everything about this download and how to place it
294///
295/// # Returns
296///
297/// Where the bytes ended up, how many there were, and their checksum.
298///
299/// # Errors
300///
301/// Returns a [`DownloadError`] when the bytes cannot be copied, the caller's
302/// validation rejects them, or no name is available under the conflict policy.
303/// In every one of those cases the partial file is removed, so a failed
304/// download never leaves anything behind.
305pub fn save_download(request: SaveRequest) -> Result<SavedDownload, DownloadError> {
306    let root = request.root.as_path();
307    std::fs::create_dir_all(root).map_err(|source| DownloadError::DirectoryNotCreated {
308        root: root.to_path_buf(),
309        source,
310    })?;
311    let _ = restrict(root, ARTIFACT_DIRECTORY_MODE);
312
313    // A caller callback runs *before* sanitization, never instead of it: it is
314    // a naming preference, not a grant of write access outside the root.
315    let chosen = match &request.filename {
316        Some(namer) => namer(&DownloadNaming {
317            suggested_filename: request.suggested_filename.clone(),
318            mime_type: request.mime_type.clone(),
319        }),
320        None => request.suggested_filename.clone(),
321    };
322    let safe_name = sanitize_download_name(&chosen);
323    let partial_path = resolve_inside_root(root, &partial_name(&safe_name))?;
324
325    let placed = place(&request, &safe_name, &partial_path);
326    if placed.is_err() {
327        let _ = std::fs::remove_file(&partial_path);
328    }
329    if request.source.remove_source {
330        let _ = std::fs::remove_file(&request.source.path);
331    }
332    placed
333}
334
335/// Copy, validate and publish one download.
336///
337/// Split out so that [`save_download`] has exactly one place where a failed
338/// download's partial file is removed.
339fn place(
340    request: &SaveRequest,
341    safe_name: &str,
342    partial_path: &Path,
343) -> Result<SavedDownload, DownloadError> {
344    let written = copy_to_partial(&request.source.path, partial_path)?;
345
346    if let Some(validate) = &request.validate {
347        // Validation sees the partial file, so a rejected download never
348        // exists under the name a caller would pick it up by.
349        let verdict = validate(&DownloadCandidate {
350            path: partial_path.to_path_buf(),
351            bytes: written.bytes,
352            checksum: written.checksum.clone(),
353            mime_type: request.mime_type.clone(),
354            suggested_filename: request.suggested_filename.clone(),
355        });
356        match verdict {
357            Ok(true) => {}
358            Ok(false) => {
359                return Err(DownloadError::Rejected {
360                    name: safe_name.to_string(),
361                })
362            }
363            Err(error) => {
364                return Err(DownloadError::ValidationFailed {
365                    name: safe_name.to_string(),
366                    reason: error.to_string(),
367                })
368            }
369        }
370    }
371
372    let final_name = with_extension(safe_name, request.mime_type.as_deref(), &written.head);
373    let final_path = resolve_final_path(&request.root, &final_name, request.conflict)?;
374
375    // Rename rather than copy: within one filesystem it is atomic, so a reader
376    // watching the directory never sees a half-written file under this name.
377    std::fs::rename(partial_path, &final_path).map_err(|source| DownloadError::Io {
378        path: final_path.clone(),
379        source,
380    })?;
381    let _ = restrict(&final_path, ARTIFACT_FILE_MODE);
382
383    Ok(SavedDownload {
384        path: final_path,
385        bytes: written.bytes,
386        checksum: written.checksum,
387    })
388}
389
390/// Remove every partial file left behind in a download directory.
391///
392/// # Arguments
393///
394/// * `root` - Managed download directory
395///
396/// # Returns
397///
398/// The paths that were removed.
399pub fn clean_partials(root: &Path) -> Vec<PathBuf> {
400    let Ok(entries) = std::fs::read_dir(root) else {
401        return Vec::new();
402    };
403
404    let mut removed: Vec<PathBuf> = entries
405        .filter_map(Result::ok)
406        .map(|entry| entry.path())
407        .filter(|path| path.extension().is_some_and(|suffix| suffix == "partial"))
408        .filter(|path| std::fs::remove_file(path).is_ok())
409        .collect();
410    removed.sort();
411    removed
412}
413
414#[cfg(test)]
415mod tests {
416    use super::*;
417    use crate::downloads::test_support::TempDir;
418
419    /// A staged file holding `contents`, as an engine would have left it.
420    fn staged(temp: &TempDir, name: &str, contents: &[u8]) -> DownloadSource {
421        let path = temp.path().join(name);
422        std::fs::write(&path, contents).unwrap();
423        DownloadSource::at(path)
424    }
425
426    /// A request that saves `source` into `root` under `suggested`.
427    fn request(root: &Path, source: DownloadSource, suggested: &str) -> SaveRequest {
428        SaveRequest {
429            root: root.to_path_buf(),
430            source,
431            suggested_filename: suggested.to_string(),
432            ..SaveRequest::default()
433        }
434    }
435
436    #[test]
437    fn saves_a_download_under_its_suggested_name() {
438        let temp = TempDir::new("bc-store");
439        let root = temp.path().join("downloads");
440        let source = staged(&temp, "engine-file", b"hello");
441
442        let saved = save_download(request(&root, source, "notes.txt")).unwrap();
443
444        assert_eq!(saved.path, root.join("notes.txt"));
445        assert_eq!(saved.bytes, 5);
446        assert_eq!(std::fs::read(&saved.path).unwrap(), b"hello");
447        // The checksum is over the saved bytes, so a caller can prove the file
448        // is the one the browser downloaded.
449        assert_eq!(
450            saved.checksum,
451            "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"
452        );
453    }
454
455    #[test]
456    fn names_a_bare_uuid_from_its_contents() {
457        let temp = TempDir::new("bc-store-uuid");
458        let root = temp.path().join("downloads");
459        let source = staged(&temp, "staged", b"%PDF-1.7\nbody");
460
461        let saved = save_download(request(&root, source, "7f1c9ab2-4ee1-4d62")).unwrap();
462
463        assert_eq!(saved.path, root.join("7f1c9ab2-4ee1-4d62.pdf"));
464    }
465
466    #[test]
467    fn lets_a_caller_choose_the_name_and_keeps_it_inside_the_root() {
468        let temp = TempDir::new("bc-store-named");
469        let root = temp.path().join("downloads");
470        let source = staged(&temp, "staged", b"%PDF-1.7");
471
472        let saved = save_download(SaveRequest {
473            filename: Some(Arc::new(|_naming| "../../escaped.pdf".to_string())),
474            ..request(&root, source, "7f1c9ab2")
475        })
476        .unwrap();
477
478        // The caller picked the name; the module picked the directory.
479        assert_eq!(saved.path, root.join("escaped.pdf"));
480    }
481
482    #[test]
483    fn renames_rather_than_replacing_an_existing_file() {
484        let temp = TempDir::new("bc-store-conflict");
485        let root = temp.path().join("downloads");
486
487        let first =
488            save_download(request(&root, staged(&temp, "a", b"first"), "report.pdf")).unwrap();
489        let second =
490            save_download(request(&root, staged(&temp, "b", b"second"), "report.pdf")).unwrap();
491
492        assert_eq!(first.path, root.join("report.pdf"));
493        assert_eq!(second.path, root.join("report (2).pdf"));
494        assert_eq!(std::fs::read(&first.path).unwrap(), b"first");
495    }
496
497    #[test]
498    fn replaces_an_existing_file_when_the_caller_asked_for_that() {
499        let temp = TempDir::new("bc-store-overwrite");
500        let root = temp.path().join("downloads");
501
502        save_download(request(&root, staged(&temp, "a", b"first"), "report.pdf")).unwrap();
503        let second = save_download(SaveRequest {
504            conflict: DownloadConflict::Overwrite,
505            ..request(&root, staged(&temp, "b", b"second"), "report.pdf")
506        })
507        .unwrap();
508
509        assert_eq!(second.path, root.join("report.pdf"));
510        assert_eq!(std::fs::read(&second.path).unwrap(), b"second");
511    }
512
513    #[test]
514    fn refuses_to_touch_an_existing_file_under_the_error_policy() {
515        let temp = TempDir::new("bc-store-error");
516        let root = temp.path().join("downloads");
517
518        save_download(request(&root, staged(&temp, "a", b"first"), "report.pdf")).unwrap();
519        let error = save_download(SaveRequest {
520            conflict: DownloadConflict::Error,
521            ..request(&root, staged(&temp, "b", b"second"), "report.pdf")
522        })
523        .unwrap_err();
524
525        assert!(
526            error.to_string().contains("downloads.conflict is 'error'"),
527            "unexpected message: {error}"
528        );
529        assert_eq!(std::fs::read(root.join("report.pdf")).unwrap(), b"first");
530        assert!(
531            clean_partials(&root).is_empty(),
532            "a partial was left behind"
533        );
534    }
535
536    #[test]
537    fn leaves_nothing_behind_when_validation_rejects_a_download() {
538        let temp = TempDir::new("bc-store-reject");
539        let root = temp.path().join("downloads");
540
541        let error = save_download(SaveRequest {
542            validate: Some(Arc::new(|candidate: &DownloadCandidate| {
543                // The validator sees the bytes before anybody else can.
544                Ok(std::fs::read(&candidate.path)?.starts_with(b"%PDF"))
545            })),
546            ..request(&root, staged(&temp, "a", b"<html>not a pdf"), "report.pdf")
547        })
548        .unwrap_err();
549
550        assert!(
551            error.to_string().contains("rejected by the caller"),
552            "unexpected message: {error}"
553        );
554        assert!(!root.join("report.pdf").exists());
555        let leftovers: Vec<_> = std::fs::read_dir(&root)
556            .unwrap()
557            .map(|entry| entry.unwrap().file_name())
558            .collect();
559        assert!(leftovers.is_empty(), "left behind {leftovers:?}");
560    }
561
562    #[test]
563    fn reports_a_validator_that_failed_rather_than_rejected() {
564        let temp = TempDir::new("bc-store-validator-error");
565        let root = temp.path().join("downloads");
566
567        let error = save_download(SaveRequest {
568            validate: Some(Arc::new(|_candidate| {
569                Err(anyhow::anyhow!("the schema service was unreachable"))
570            })),
571            ..request(&root, staged(&temp, "a", b"body"), "report.pdf")
572        })
573        .unwrap_err();
574
575        assert!(
576            error
577                .to_string()
578                .contains("the schema service was unreachable"),
579            "unexpected message: {error}"
580        );
581    }
582
583    #[test]
584    fn removes_a_staged_file_once_it_has_been_placed() {
585        let temp = TempDir::new("bc-store-staged");
586        let root = temp.path().join("downloads");
587        let staging = temp.path().join("guid-1234");
588        std::fs::write(&staging, b"body").unwrap();
589
590        let saved = save_download(SaveRequest {
591            source: DownloadSource::staged(&staging),
592            ..request(&root, DownloadSource::default(), "report.txt")
593        })
594        .unwrap();
595
596        assert!(saved.path.exists());
597        assert!(!staging.exists(), "the staged copy outlived the download");
598    }
599
600    #[test]
601    fn reports_a_download_whose_bytes_are_gone() {
602        let temp = TempDir::new("bc-store-missing");
603        let root = temp.path().join("downloads");
604
605        let error = save_download(request(
606            &root,
607            DownloadSource::at(temp.path().join("never-written")),
608            "report.pdf",
609        ))
610        .unwrap_err();
611
612        assert!(
613            error.to_string().contains("never-written"),
614            "unexpected message: {error}"
615        );
616    }
617
618    #[test]
619    fn sweeps_up_partials_left_by_an_interrupted_run() {
620        let temp = TempDir::new("bc-store-partials");
621        let root = temp.path().join("downloads");
622        std::fs::create_dir_all(&root).unwrap();
623        std::fs::write(root.join("report.pdf.17.999.partial"), b"half").unwrap();
624        std::fs::write(root.join("report.pdf"), b"whole").unwrap();
625
626        let removed = clean_partials(&root);
627
628        assert_eq!(removed, vec![root.join("report.pdf.17.999.partial")]);
629        // A completed download is a user's file, and sweeping is not a reason
630        // to remove one.
631        assert!(root.join("report.pdf").exists());
632    }
633}