use anyhow::{Context, Result};
use futures_util::StreamExt;
use std::path::Path;
use tokio::io::AsyncWriteExt;
use crate::sha256::{Sha256, hex_lower};
use super::super::progress::{DownloadProgress, ProgressEvent, ProgressSink};
use super::super::variant::ModelVariant;
#[cfg(feature = "net")]
pub(crate) fn partial_path_unique(final_path: &Path) -> std::path::PathBuf {
let stamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_nanos();
let mut s: std::ffi::OsString = final_path.as_os_str().to_owned();
s.push(format!(".partial.{}.{}", std::process::id(), stamp));
std::path::PathBuf::from(s)
}
#[cfg(feature = "net")]
pub(crate) struct PartialFileGuard(Option<std::path::PathBuf>);
#[cfg(feature = "net")]
impl PartialFileGuard {
pub(crate) fn new(path: std::path::PathBuf) -> Self {
Self(Some(path))
}
pub(crate) fn disarm(mut self) {
self.0 = None;
}
}
#[cfg(feature = "net")]
impl Drop for PartialFileGuard {
fn drop(&mut self) {
if let Some(path) = self.0.take() {
let _ = std::fs::remove_file(path);
}
}
}
#[cfg(feature = "net")]
pub(crate) fn sha256_file(path: &Path) -> Result<String> {
let data = std::fs::read(path)
.with_context(|| format!("Failed to read file for verification: {}", path.display()))?;
let mut hasher = Sha256::new();
hasher.update(&data);
Ok(hex_lower(&hasher.finalize()))
}
#[cfg(feature = "net")]
pub(crate) fn finalize_download(
partial_path: &Path,
final_path: &Path,
expected_sha256: Option<&str>,
label: &str,
) -> Result<()> {
if let Some(expected) = expected_sha256 {
let actual = sha256_file(partial_path)?;
if actual != expected {
let _ = std::fs::remove_file(partial_path);
anyhow::bail!("SHA-256 mismatch for {label}: expected {expected}, got {actual}");
}
tracing::info!("SHA-256 verified: {label}");
}
std::fs::rename(partial_path, final_path).with_context(|| {
format!(
"Failed to rename {} -> {}",
partial_path.display(),
final_path.display()
)
})?;
Ok(())
}
#[cfg(feature = "net")]
pub(crate) async fn download_file(variant: ModelVariant, filename: &str, dir: &Path) -> Result<()> {
let url = format!(
"https://huggingface.co/{}/resolve/main/{filename}",
variant.hf_repo()
);
let final_dest = dir.join(filename);
let expected = variant.checksum(filename);
stream_to_partial_then_finalize(&url, &final_dest, expected, filename).await
}
#[cfg(feature = "net")]
fn offline_mode() -> bool {
std::env::var_os("GIGASTT_OFFLINE").is_some_and(|v| !v.is_empty() && v != "0")
}
#[cfg(feature = "net")]
pub(crate) const MAX_DOWNLOAD_BYTES: u64 = 2 * 1024 * 1024 * 1024;
#[cfg(feature = "net")]
pub(crate) fn reject_if_over_download_cap(so_far: u64, extra: u64) -> Result<()> {
if so_far.saturating_add(extra) > MAX_DOWNLOAD_BYTES {
anyhow::bail!(
"download exceeded size cap of {MAX_DOWNLOAD_BYTES} bytes \
({so_far} already written, +{extra})"
);
}
Ok(())
}
#[cfg(feature = "net")]
pub(crate) async fn stream_to_partial_then_finalize(
url: &str,
final_dest: &Path,
expected_sha256: Option<&str>,
label: &str,
) -> Result<()> {
stream_to_partial_then_finalize_with_sink(
url,
final_dest,
expected_sha256,
label,
&ProgressSink::global(),
)
.await
}
#[cfg(feature = "net")]
pub(crate) async fn stream_to_partial_then_finalize_with_sink(
url: &str,
final_dest: &Path,
expected_sha256: Option<&str>,
label: &str,
sink: &ProgressSink,
) -> Result<()> {
if offline_mode() {
anyhow::bail!(
"offline mode (GIGASTT_OFFLINE=1): refusing to download {label}; \
place the file at {} manually (see docs/deployment.md, \
\"Air-gapped / offline installation\")",
final_dest.display()
);
}
let partial = partial_path_unique(final_dest);
let cleanup = PartialFileGuard::new(partial.clone());
tracing::info!("Downloading {label}...");
let client = reqwest::Client::builder()
.connect_timeout(std::time::Duration::from_secs(30))
.read_timeout(std::time::Duration::from_secs(300))
.redirect(reqwest::redirect::Policy::limited(5))
.build()
.context("Failed to build HTTP client")?;
let response = client
.get(url)
.send()
.await
.context("HTTP request failed")?;
let status = response.status();
if !status.is_success() {
anyhow::bail!("Download failed for {label}: HTTP {status}");
}
let total_size = response.content_length().unwrap_or(0);
if total_size > 0 {
reject_if_over_download_cap(0, total_size)?;
}
let mut progress = DownloadProgress::new(total_size);
let mut file = tokio::fs::File::create(&partial)
.await
.context("Failed to create partial model file")?;
let mut stream = response.bytes_stream();
let mut downloaded: u64 = 0;
while let Some(chunk) = stream.next().await {
let chunk = chunk.context("Download stream error")?;
reject_if_over_download_cap(downloaded, chunk.len() as u64)?;
file.write_all(&chunk)
.await
.context("Failed to write chunk")?;
downloaded += chunk.len() as u64;
progress.update(chunk.len() as u64, sink, label);
}
file.flush().await?;
drop(file);
progress.finish(sink, label);
tracing::info!("Wrote partial {} ({downloaded} bytes)", partial.display());
if expected_sha256.is_some() {
sink.event(&ProgressEvent::Verify {
file: label.to_string(),
});
}
finalize_download(&partial, final_dest, expected_sha256, label)?;
cleanup.disarm();
tracing::info!("Saved {label}");
Ok(())
}