use std::fmt;
use std::fs::File;
use std::io::{Read, Write};
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::{SystemTime, UNIX_EPOCH};
use sha2::{Digest, Sha256};
use crate::downloads::destination::{restrict, ARTIFACT_DIRECTORY_MODE, ARTIFACT_FILE_MODE};
use crate::downloads::naming::{
renamed_candidate, resolve_inside_root, sanitize_download_name, with_extension,
};
use crate::downloads::DownloadError;
const MAGIC_BYTES: usize = 8;
const MAX_RENAME_ATTEMPTS: usize = 1000;
const COPY_CHUNK: usize = 64 * 1024;
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum DownloadConflict {
#[default]
Rename,
Overwrite,
Error,
}
impl DownloadConflict {
pub fn as_str(&self) -> &'static str {
match self {
Self::Rename => "rename",
Self::Overwrite => "overwrite",
Self::Error => "error",
}
}
}
impl fmt::Display for DownloadConflict {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct DownloadSource {
pub path: PathBuf,
pub remove_source: bool,
}
impl DownloadSource {
pub fn at(path: impl Into<PathBuf>) -> Self {
Self {
path: path.into(),
remove_source: false,
}
}
pub fn staged(path: impl Into<PathBuf>) -> Self {
Self {
path: path.into(),
remove_source: true,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SavedDownload {
pub path: PathBuf,
pub bytes: u64,
pub checksum: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DownloadNaming {
pub suggested_filename: String,
pub mime_type: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DownloadCandidate {
pub path: PathBuf,
pub bytes: u64,
pub checksum: String,
pub mime_type: Option<String>,
pub suggested_filename: String,
}
pub type DownloadNamer = Arc<dyn Fn(&DownloadNaming) -> String + Send + Sync>;
pub type DownloadValidator =
Arc<dyn Fn(&DownloadCandidate) -> Result<bool, anyhow::Error> + Send + Sync>;
#[derive(Clone, Default)]
pub struct SaveRequest {
pub root: PathBuf,
pub source: DownloadSource,
pub suggested_filename: String,
pub mime_type: Option<String>,
pub conflict: DownloadConflict,
pub filename: Option<DownloadNamer>,
pub validate: Option<DownloadValidator>,
}
impl fmt::Debug for SaveRequest {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("SaveRequest")
.field("root", &self.root)
.field("source", &self.source)
.field("suggested_filename", &self.suggested_filename)
.field("mime_type", &self.mime_type)
.field("conflict", &self.conflict)
.field("filename", &self.filename.is_some())
.field("validate", &self.validate.is_some())
.finish()
}
}
struct WrittenBytes {
bytes: u64,
checksum: String,
head: Vec<u8>,
}
fn copy_to_partial(source: &Path, partial_path: &Path) -> Result<WrittenBytes, DownloadError> {
let io = |path: &Path| {
let path = path.to_path_buf();
move |source: std::io::Error| DownloadError::Io {
path: path.clone(),
source,
}
};
let mut reader = File::open(source).map_err(io(source))?;
let mut writer = File::create(partial_path).map_err(io(partial_path))?;
let _ = restrict(partial_path, ARTIFACT_FILE_MODE);
let mut hasher = Sha256::new();
let mut buffer = vec![0_u8; COPY_CHUNK];
let mut bytes = 0_u64;
let mut head = Vec::with_capacity(MAGIC_BYTES);
loop {
let read = reader.read(&mut buffer).map_err(io(source))?;
if read == 0 {
break;
}
let chunk = &buffer[..read];
hasher.update(chunk);
bytes += read as u64;
if head.len() < MAGIC_BYTES {
head.extend_from_slice(&chunk[..read.min(MAGIC_BYTES - head.len())]);
}
writer.write_all(chunk).map_err(io(partial_path))?;
}
writer.flush().map_err(io(partial_path))?;
Ok(WrittenBytes {
bytes,
checksum: hasher
.finalize()
.iter()
.map(|byte| format!("{byte:02x}"))
.collect(),
head,
})
}
pub fn resolve_final_path(
root: &Path,
name: &str,
conflict: DownloadConflict,
) -> Result<PathBuf, DownloadError> {
if conflict == DownloadConflict::Overwrite {
return resolve_inside_root(root, name);
}
for attempt in 0..MAX_RENAME_ATTEMPTS {
let candidate = resolve_inside_root(root, &renamed_candidate(name, attempt))?;
if !candidate.exists() {
return Ok(candidate);
}
if conflict == DownloadConflict::Error {
return Err(DownloadError::NameTaken { path: candidate });
}
}
Err(DownloadError::NoFreeName {
name: name.to_string(),
attempts: MAX_RENAME_ATTEMPTS,
})
}
fn partial_name(safe_name: &str) -> String {
let stamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|since| since.as_millis())
.unwrap_or_default();
format!("{safe_name}.{}.{stamp}.partial", std::process::id())
}
pub fn save_download(request: SaveRequest) -> Result<SavedDownload, DownloadError> {
let root = request.root.as_path();
std::fs::create_dir_all(root).map_err(|source| DownloadError::DirectoryNotCreated {
root: root.to_path_buf(),
source,
})?;
let _ = restrict(root, ARTIFACT_DIRECTORY_MODE);
let chosen = match &request.filename {
Some(namer) => namer(&DownloadNaming {
suggested_filename: request.suggested_filename.clone(),
mime_type: request.mime_type.clone(),
}),
None => request.suggested_filename.clone(),
};
let safe_name = sanitize_download_name(&chosen);
let partial_path = resolve_inside_root(root, &partial_name(&safe_name))?;
let placed = place(&request, &safe_name, &partial_path);
if placed.is_err() {
let _ = std::fs::remove_file(&partial_path);
}
if request.source.remove_source {
let _ = std::fs::remove_file(&request.source.path);
}
placed
}
fn place(
request: &SaveRequest,
safe_name: &str,
partial_path: &Path,
) -> Result<SavedDownload, DownloadError> {
let written = copy_to_partial(&request.source.path, partial_path)?;
if let Some(validate) = &request.validate {
let verdict = validate(&DownloadCandidate {
path: partial_path.to_path_buf(),
bytes: written.bytes,
checksum: written.checksum.clone(),
mime_type: request.mime_type.clone(),
suggested_filename: request.suggested_filename.clone(),
});
match verdict {
Ok(true) => {}
Ok(false) => {
return Err(DownloadError::Rejected {
name: safe_name.to_string(),
})
}
Err(error) => {
return Err(DownloadError::ValidationFailed {
name: safe_name.to_string(),
reason: error.to_string(),
})
}
}
}
let final_name = with_extension(safe_name, request.mime_type.as_deref(), &written.head);
let final_path = resolve_final_path(&request.root, &final_name, request.conflict)?;
std::fs::rename(partial_path, &final_path).map_err(|source| DownloadError::Io {
path: final_path.clone(),
source,
})?;
let _ = restrict(&final_path, ARTIFACT_FILE_MODE);
Ok(SavedDownload {
path: final_path,
bytes: written.bytes,
checksum: written.checksum,
})
}
pub fn clean_partials(root: &Path) -> Vec<PathBuf> {
let Ok(entries) = std::fs::read_dir(root) else {
return Vec::new();
};
let mut removed: Vec<PathBuf> = entries
.filter_map(Result::ok)
.map(|entry| entry.path())
.filter(|path| path.extension().is_some_and(|suffix| suffix == "partial"))
.filter(|path| std::fs::remove_file(path).is_ok())
.collect();
removed.sort();
removed
}
#[cfg(test)]
mod tests {
use super::*;
use crate::downloads::test_support::TempDir;
fn staged(temp: &TempDir, name: &str, contents: &[u8]) -> DownloadSource {
let path = temp.path().join(name);
std::fs::write(&path, contents).unwrap();
DownloadSource::at(path)
}
fn request(root: &Path, source: DownloadSource, suggested: &str) -> SaveRequest {
SaveRequest {
root: root.to_path_buf(),
source,
suggested_filename: suggested.to_string(),
..SaveRequest::default()
}
}
#[test]
fn saves_a_download_under_its_suggested_name() {
let temp = TempDir::new("bc-store");
let root = temp.path().join("downloads");
let source = staged(&temp, "engine-file", b"hello");
let saved = save_download(request(&root, source, "notes.txt")).unwrap();
assert_eq!(saved.path, root.join("notes.txt"));
assert_eq!(saved.bytes, 5);
assert_eq!(std::fs::read(&saved.path).unwrap(), b"hello");
assert_eq!(
saved.checksum,
"2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"
);
}
#[test]
fn names_a_bare_uuid_from_its_contents() {
let temp = TempDir::new("bc-store-uuid");
let root = temp.path().join("downloads");
let source = staged(&temp, "staged", b"%PDF-1.7\nbody");
let saved = save_download(request(&root, source, "7f1c9ab2-4ee1-4d62")).unwrap();
assert_eq!(saved.path, root.join("7f1c9ab2-4ee1-4d62.pdf"));
}
#[test]
fn lets_a_caller_choose_the_name_and_keeps_it_inside_the_root() {
let temp = TempDir::new("bc-store-named");
let root = temp.path().join("downloads");
let source = staged(&temp, "staged", b"%PDF-1.7");
let saved = save_download(SaveRequest {
filename: Some(Arc::new(|_naming| "../../escaped.pdf".to_string())),
..request(&root, source, "7f1c9ab2")
})
.unwrap();
assert_eq!(saved.path, root.join("escaped.pdf"));
}
#[test]
fn renames_rather_than_replacing_an_existing_file() {
let temp = TempDir::new("bc-store-conflict");
let root = temp.path().join("downloads");
let first =
save_download(request(&root, staged(&temp, "a", b"first"), "report.pdf")).unwrap();
let second =
save_download(request(&root, staged(&temp, "b", b"second"), "report.pdf")).unwrap();
assert_eq!(first.path, root.join("report.pdf"));
assert_eq!(second.path, root.join("report (2).pdf"));
assert_eq!(std::fs::read(&first.path).unwrap(), b"first");
}
#[test]
fn replaces_an_existing_file_when_the_caller_asked_for_that() {
let temp = TempDir::new("bc-store-overwrite");
let root = temp.path().join("downloads");
save_download(request(&root, staged(&temp, "a", b"first"), "report.pdf")).unwrap();
let second = save_download(SaveRequest {
conflict: DownloadConflict::Overwrite,
..request(&root, staged(&temp, "b", b"second"), "report.pdf")
})
.unwrap();
assert_eq!(second.path, root.join("report.pdf"));
assert_eq!(std::fs::read(&second.path).unwrap(), b"second");
}
#[test]
fn refuses_to_touch_an_existing_file_under_the_error_policy() {
let temp = TempDir::new("bc-store-error");
let root = temp.path().join("downloads");
save_download(request(&root, staged(&temp, "a", b"first"), "report.pdf")).unwrap();
let error = save_download(SaveRequest {
conflict: DownloadConflict::Error,
..request(&root, staged(&temp, "b", b"second"), "report.pdf")
})
.unwrap_err();
assert!(
error.to_string().contains("downloads.conflict is 'error'"),
"unexpected message: {error}"
);
assert_eq!(std::fs::read(root.join("report.pdf")).unwrap(), b"first");
assert!(
clean_partials(&root).is_empty(),
"a partial was left behind"
);
}
#[test]
fn leaves_nothing_behind_when_validation_rejects_a_download() {
let temp = TempDir::new("bc-store-reject");
let root = temp.path().join("downloads");
let error = save_download(SaveRequest {
validate: Some(Arc::new(|candidate: &DownloadCandidate| {
Ok(std::fs::read(&candidate.path)?.starts_with(b"%PDF"))
})),
..request(&root, staged(&temp, "a", b"<html>not a pdf"), "report.pdf")
})
.unwrap_err();
assert!(
error.to_string().contains("rejected by the caller"),
"unexpected message: {error}"
);
assert!(!root.join("report.pdf").exists());
let leftovers: Vec<_> = std::fs::read_dir(&root)
.unwrap()
.map(|entry| entry.unwrap().file_name())
.collect();
assert!(leftovers.is_empty(), "left behind {leftovers:?}");
}
#[test]
fn reports_a_validator_that_failed_rather_than_rejected() {
let temp = TempDir::new("bc-store-validator-error");
let root = temp.path().join("downloads");
let error = save_download(SaveRequest {
validate: Some(Arc::new(|_candidate| {
Err(anyhow::anyhow!("the schema service was unreachable"))
})),
..request(&root, staged(&temp, "a", b"body"), "report.pdf")
})
.unwrap_err();
assert!(
error
.to_string()
.contains("the schema service was unreachable"),
"unexpected message: {error}"
);
}
#[test]
fn removes_a_staged_file_once_it_has_been_placed() {
let temp = TempDir::new("bc-store-staged");
let root = temp.path().join("downloads");
let staging = temp.path().join("guid-1234");
std::fs::write(&staging, b"body").unwrap();
let saved = save_download(SaveRequest {
source: DownloadSource::staged(&staging),
..request(&root, DownloadSource::default(), "report.txt")
})
.unwrap();
assert!(saved.path.exists());
assert!(!staging.exists(), "the staged copy outlived the download");
}
#[test]
fn reports_a_download_whose_bytes_are_gone() {
let temp = TempDir::new("bc-store-missing");
let root = temp.path().join("downloads");
let error = save_download(request(
&root,
DownloadSource::at(temp.path().join("never-written")),
"report.pdf",
))
.unwrap_err();
assert!(
error.to_string().contains("never-written"),
"unexpected message: {error}"
);
}
#[test]
fn sweeps_up_partials_left_by_an_interrupted_run() {
let temp = TempDir::new("bc-store-partials");
let root = temp.path().join("downloads");
std::fs::create_dir_all(&root).unwrap();
std::fs::write(root.join("report.pdf.17.999.partial"), b"half").unwrap();
std::fs::write(root.join("report.pdf"), b"whole").unwrap();
let removed = clean_partials(&root);
assert_eq!(removed, vec![root.join("report.pdf.17.999.partial")]);
assert!(root.join("report.pdf").exists());
}
}