use std::fs::File;
use std::io::Read;
use std::path::Path;
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::time::Duration;
use indicatif::ProgressBar;
use sha2::{Digest, Sha256};
use crate::commands::keyring::bucket::client;
use crate::commands::keyring::bucket::store::BucketConfig;
use crate::core::retry::retry_with_backoff;
const DOWNLOAD_RETRIES: usize = 3;
const DOWNLOAD_RETRY_BACKOFF: Duration = Duration::from_secs(2);
const ANNOUNCE_DOWNLOAD_THRESHOLD_BYTES: u64 = 50 * 1024 * 1024;
pub(crate) const MIN_FREE_DISK_BYTES: u64 = 2 * 1024 * 1024 * 1024;
fn format_mb(bytes: u64) -> String {
format!("{:.1} MB", bytes as f64 / (1024.0 * 1024.0))
}
fn should_announce_download(size: u64) -> bool {
size >= ANNOUNCE_DOWNLOAD_THRESHOLD_BYTES
}
#[derive(Clone)]
pub(crate) struct DownloadAnnounce {
bar: ProgressBar,
in_flight: Arc<AtomicUsize>,
}
impl DownloadAnnounce {
pub(crate) fn new(bar: ProgressBar) -> Self {
DownloadAnnounce {
bar,
in_flight: Arc::new(AtomicUsize::new(0)),
}
}
fn start(&self, key: &str, size: u64) {
self.in_flight.fetch_add(1, Ordering::SeqCst);
self.bar
.set_message(format!("Downloading {key} ({})...", format_mb(size)));
}
fn finish(&self) {
if self.in_flight.fetch_sub(1, Ordering::SeqCst) == 1 {
self.bar.set_message("");
}
}
}
pub(crate) fn available_disk_space(path: &Path) -> Option<u64> {
let target = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
let disks = sysinfo::Disks::new_with_refreshed_list();
disks
.list()
.iter()
.filter(|disk| target.starts_with(disk.mount_point()))
.max_by_key(|disk| disk.mount_point().as_os_str().len())
.map(|disk| disk.available_space())
}
pub(crate) fn check_disk_space(path: &Path, needed: u64) -> Result<(), String> {
let Some(available) = available_disk_space(path) else {
return Ok(());
};
let required = needed.max(MIN_FREE_DISK_BYTES);
if available < required {
return Err(format!(
"not enough disk space: {} available, need at least {}",
format_mb(available),
format_mb(required)
));
}
Ok(())
}
pub(crate) async fn download_with_retry(
bucket_config: &BucketConfig,
secret: &str,
key: &str,
size: u64,
dest_path: &Path,
announce: &DownloadAnnounce,
) -> Result<(), String> {
let announced = should_announce_download(size);
if announced {
announce.start(key, size);
}
let result = retry_with_backoff(DOWNLOAD_RETRIES, DOWNLOAD_RETRY_BACKOFF, || {
client::download_object_to_file(bucket_config, secret, key, dest_path)
})
.await;
if announced {
announce.finish();
}
result?;
Ok(())
}
pub(crate) fn sha256_hex(bytes: &[u8]) -> String {
let mut hasher = Sha256::new();
hasher.update(bytes);
hex::encode(hasher.finalize())
}
pub(crate) fn sha256_file(path: &Path) -> Result<String, String> {
let mut file =
File::open(path).map_err(|err| format!("failed to open {}: {err}", path.display()))?;
let mut hasher = Sha256::new();
let mut buf = [0u8; 64 * 1024];
loop {
let read = file
.read(&mut buf)
.map_err(|err| format!("failed to read {}: {err}", path.display()))?;
if read == 0 {
break;
}
hasher.update(&buf[..read]);
}
Ok(hex::encode(hasher.finalize()))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn should_announce_download_is_false_below_the_threshold() {
assert!(!should_announce_download(
ANNOUNCE_DOWNLOAD_THRESHOLD_BYTES - 1
));
assert!(!should_announce_download(1024));
}
#[test]
fn should_announce_download_is_true_at_and_above_the_threshold() {
assert!(should_announce_download(ANNOUNCE_DOWNLOAD_THRESHOLD_BYTES));
assert!(should_announce_download(
ANNOUNCE_DOWNLOAD_THRESHOLD_BYTES + 1
));
}
#[test]
fn download_announce_clears_the_message_only_once_every_in_flight_download_finishes() {
let announce = DownloadAnnounce::new(ProgressBar::hidden());
announce.start("a.zip", 100 * 1024 * 1024);
announce.start("b.zip", 200 * 1024 * 1024);
assert!(announce.bar.message().contains("b.zip"));
announce.finish();
assert_ne!(announce.bar.message(), "");
announce.finish();
assert_eq!(announce.bar.message(), "");
}
#[test]
fn format_mb_renders_one_decimal_place() {
assert_eq!(format_mb(50 * 1024 * 1024), "50.0 MB");
assert_eq!(format_mb(1024 * 1024 + 512 * 1024), "1.5 MB");
}
#[test]
fn sha256_file_matches_sha256_hex_for_identical_content() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("a.bin");
std::fs::write(&path, b"hello world").unwrap();
assert_eq!(sha256_file(&path).unwrap(), sha256_hex(b"hello world"));
}
#[test]
fn available_disk_space_finds_a_positive_value_for_the_current_directory() {
let dir = tempfile::tempdir().unwrap();
let available = available_disk_space(dir.path());
assert!(available.unwrap_or(0) > 0);
}
#[test]
fn check_disk_space_fails_when_required_exceeds_available() {
let dir = tempfile::tempdir().unwrap();
let result = check_disk_space(dir.path(), u64::MAX / 2);
assert!(result.is_err());
}
#[test]
fn check_disk_space_succeeds_for_a_small_requirement() {
let dir = tempfile::tempdir().unwrap();
assert!(check_disk_space(dir.path(), 1).is_ok());
}
}