1use 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
38const MAGIC_BYTES: usize = 8;
40
41const MAX_RENAME_ATTEMPTS: usize = 1000;
43
44const COPY_CHUNK: usize = 64 * 1024;
46
47#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
49#[serde(rename_all = "lowercase")]
50pub enum DownloadConflict {
51 #[default]
53 Rename,
54 Overwrite,
56 Error,
58}
59
60impl DownloadConflict {
61 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#[derive(Debug, Clone, Default, PartialEq, Eq)]
79pub struct DownloadSource {
80 pub path: PathBuf,
82 pub remove_source: bool,
84}
85
86impl DownloadSource {
87 pub fn at(path: impl Into<PathBuf>) -> Self {
89 Self {
90 path: path.into(),
91 remove_source: false,
92 }
93 }
94
95 pub fn staged(path: impl Into<PathBuf>) -> Self {
97 Self {
98 path: path.into(),
99 remove_source: true,
100 }
101 }
102}
103
104#[derive(Debug, Clone, PartialEq, Eq)]
106pub struct SavedDownload {
107 pub path: PathBuf,
109 pub bytes: u64,
111 pub checksum: String,
113}
114
115#[derive(Debug, Clone, PartialEq, Eq)]
117pub struct DownloadNaming {
118 pub suggested_filename: String,
120 pub mime_type: Option<String>,
122}
123
124#[derive(Debug, Clone, PartialEq, Eq)]
126pub struct DownloadCandidate {
127 pub path: PathBuf,
129 pub bytes: u64,
131 pub checksum: String,
133 pub mime_type: Option<String>,
135 pub suggested_filename: String,
137}
138
139pub type DownloadNamer = Arc<dyn Fn(&DownloadNaming) -> String + Send + Sync>;
141
142pub type DownloadValidator =
147 Arc<dyn Fn(&DownloadCandidate) -> Result<bool, anyhow::Error> + Send + Sync>;
148
149#[derive(Clone, Default)]
151pub struct SaveRequest {
152 pub root: PathBuf,
154 pub source: DownloadSource,
156 pub suggested_filename: String,
158 pub mime_type: Option<String>,
160 pub conflict: DownloadConflict,
162 pub filename: Option<DownloadNamer>,
164 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
182struct WrittenBytes {
184 bytes: u64,
185 checksum: String,
186 head: Vec<u8>,
187}
188
189fn 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
238pub 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
280fn 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
289pub 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 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
335fn 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 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 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
390pub 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 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 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 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 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 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 assert!(root.join("report.pdf").exists());
632 }
633}