use std::fs;
use std::io;
use std::path::{Path, PathBuf};
use std::time::Duration;
use reqwest::blocking::Client;
use sha2::{Digest, Sha256};
use crate::session::CeraError;
const DOWNLOAD_TIMEOUT: Duration = Duration::from_secs(600);
const HEAD_TIMEOUT: Duration = Duration::from_secs(30);
pub(crate) struct HeadInfo {
pub content_length: Option<u64>,
pub linked_sha256: Option<String>,
}
pub(crate) fn sidecar_path(dest: &Path) -> PathBuf {
let mut s = dest.as_os_str().to_owned();
s.push(".sha256");
PathBuf::from(s)
}
pub(crate) fn read_sidecar(dest: &Path) -> Option<String> {
let text = fs::read_to_string(sidecar_path(dest)).ok()?;
let hex = text.trim();
if hex.len() == 64 && hex.chars().all(|c| c.is_ascii_hexdigit()) {
Some(hex.to_ascii_lowercase())
} else {
None
}
}
pub(crate) fn write_sidecar(dest: &Path, sha256_hex: &str) {
let _ = fs::write(sidecar_path(dest), sha256_hex);
}
pub(crate) fn head_info(client: &Client, url: &str) -> HeadInfo {
let none = HeadInfo {
content_length: None,
linked_sha256: None,
};
let Ok(resp) = client
.head(url)
.timeout(HEAD_TIMEOUT)
.send()
.and_then(|r| r.error_for_status())
else {
return none;
};
let headers = resp.headers();
let content_length = headers
.get("x-linked-size")
.and_then(|v| v.to_str().ok())
.and_then(|s| s.parse::<u64>().ok())
.or_else(|| {
headers
.get(reqwest::header::CONTENT_LENGTH)
.and_then(|v| v.to_str().ok())
.and_then(|s| s.parse::<u64>().ok())
});
let linked_sha256 = extract_linked_sha256(headers);
HeadInfo {
content_length,
linked_sha256,
}
}
fn extract_linked_sha256(headers: &reqwest::header::HeaderMap) -> Option<String> {
headers
.get("x-linked-etag")
.and_then(|v| v.to_str().ok())
.map(|s| s.trim_matches('"'))
.and_then(|s| s.strip_prefix("sha256:"))
.map(|h| h.to_ascii_lowercase())
}
pub(crate) fn sha256_file(path: &Path) -> io::Result<String> {
let mut file = fs::File::open(path)?;
let mut hasher = Sha256::new();
io::copy(&mut file, &mut hasher)?;
Ok(hex_encode(&hasher.finalize()))
}
pub(crate) fn download_to(
client: &Client,
url: &str,
dest: &Path,
expected_sha256: Option<&str>,
total_bytes_hint: Option<u64>,
progress: Option<&dyn crate::bundle::DownloadProgress>,
) -> Result<(), CeraError> {
let mut resp = client
.get(url)
.timeout(DOWNLOAD_TIMEOUT)
.send()
.map_err(|e| CeraError::Backend(format!("GET {url}: {e}")))?
.error_for_status()
.map_err(|e| CeraError::Backend(format!("GET {url}: {e}")))?;
let total_bytes = total_bytes_hint.or_else(|| resp.content_length());
let server_hash = extract_linked_sha256(resp.headers());
let expected = expected_sha256
.map(|s| s.to_ascii_lowercase())
.or(server_hash);
let mut partial_name = dest.as_os_str().to_owned();
partial_name.push(format!(
".partial.{}.{}",
std::process::id(),
unique_suffix()
));
let partial = PathBuf::from(partial_name);
let copy_result: Result<String, CeraError> = {
let mut file = fs::File::create(&partial)?;
let mut hashing = HashingWriter {
inner: &mut file,
hasher: Sha256::new(),
};
let (final_bytes, last_in_loop_callback_at) = {
let mut counting = ProgressingWriter::new(&mut hashing, progress, url, total_bytes);
io::copy(&mut resp, &mut counting)
.map_err(|e| CeraError::Backend(format!("write {}: {e}", partial.display())))?;
(counting.bytes_written, counting.last_callback_at)
};
if let Some(p) = progress
&& final_bytes != last_in_loop_callback_at
{
p.on_progress(url, final_bytes, total_bytes);
}
let digest = hashing.hasher.finalize();
file.sync_all()?;
Ok(hex_encode(&digest))
};
let actual_hex = match copy_result {
Ok(h) => h,
Err(e) => {
let _ = fs::remove_file(&partial);
return Err(e);
}
};
if let Some(exp) = expected.as_deref() {
if exp != actual_hex {
let _ = fs::remove_file(&partial);
return Err(CeraError::Backend(format!(
"integrity check failed for {url}: expected sha256:{exp}, got sha256:{actual_hex}"
)));
}
}
let _ = fs::remove_file(dest);
if let Err(e) = fs::rename(&partial, dest) {
let _ = fs::remove_file(&partial);
return Err(e.into());
}
write_sidecar(dest, &actual_hex);
Ok(())
}
fn unique_suffix() -> u64 {
use std::hash::{Hash, Hasher};
use std::time::SystemTime;
let mut h = std::collections::hash_map::DefaultHasher::new();
std::thread::current().id().hash(&mut h);
SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.map(|d| d.as_nanos() as u64)
.unwrap_or(0)
.hash(&mut h);
h.finish()
}
struct HashingWriter<'a, W: io::Write> {
inner: &'a mut W,
hasher: Sha256,
}
impl<W: io::Write> io::Write for HashingWriter<'_, W> {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
let n = self.inner.write(buf)?;
self.hasher.update(&buf[..n]);
Ok(n)
}
fn flush(&mut self) -> io::Result<()> {
self.inner.flush()
}
}
struct ProgressingWriter<'a, W: io::Write> {
inner: &'a mut W,
progress: Option<&'a dyn crate::bundle::DownloadProgress>,
url: &'a str,
total_bytes: Option<u64>,
bytes_written: u64,
last_callback_at: u64,
}
const PROGRESS_THROTTLE_BYTES: u64 = 256 * 1024;
impl<'a, W: io::Write> ProgressingWriter<'a, W> {
fn new(
inner: &'a mut W,
progress: Option<&'a dyn crate::bundle::DownloadProgress>,
url: &'a str,
total_bytes: Option<u64>,
) -> Self {
Self {
inner,
progress,
url,
total_bytes,
bytes_written: 0,
last_callback_at: 0,
}
}
}
impl<W: io::Write> io::Write for ProgressingWriter<'_, W> {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
let n = self.inner.write(buf)?;
self.bytes_written += n as u64;
if let Some(p) = self.progress
&& self.bytes_written - self.last_callback_at >= PROGRESS_THROTTLE_BYTES
{
p.on_progress(self.url, self.bytes_written, self.total_bytes);
self.last_callback_at = self.bytes_written;
}
Ok(n)
}
fn flush(&mut self) -> io::Result<()> {
self.inner.flush()
}
}
fn hex_encode(bytes: &[u8]) -> String {
const HEX: &[u8; 16] = b"0123456789abcdef";
let mut out = String::with_capacity(bytes.len() * 2);
for b in bytes {
out.push(HEX[(b >> 4) as usize] as char);
out.push(HEX[(b & 0x0f) as usize] as char);
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn sidecar_path_appends_extension() {
assert_eq!(
sidecar_path(Path::new("/cache/x.gguf")),
PathBuf::from("/cache/x.gguf.sha256")
);
}
#[test]
fn progressing_writer_throttles_callback() {
use crate::bundle::DownloadProgress;
use std::io::Write as _;
use std::sync::Mutex;
#[derive(Debug, Default)]
struct Recorder {
calls: Mutex<Vec<(String, u64, Option<u64>)>>,
}
impl DownloadProgress for Recorder {
fn on_progress(&self, url: &str, bytes: u64, total: Option<u64>) {
self.calls
.lock()
.unwrap()
.push((url.to_string(), bytes, total));
}
}
let recorder = Recorder::default();
let mut sink = std::io::sink();
let mut writer = ProgressingWriter::new(
&mut sink,
Some(&recorder as &dyn DownloadProgress),
"https://example.com/foo.gguf",
Some(1024 * 1024),
);
writer.write_all(&[0u8; 1024]).unwrap();
assert_eq!(recorder.calls.lock().unwrap().len(), 0);
writer.write_all(&[0u8; 300 * 1024]).unwrap();
let calls_after_big = recorder.calls.lock().unwrap().clone();
assert_eq!(calls_after_big.len(), 1);
assert_eq!(calls_after_big[0].0, "https://example.com/foo.gguf");
assert_eq!(calls_after_big[0].1, (1 + 300) * 1024);
assert_eq!(calls_after_big[0].2, Some(1024 * 1024));
writer.write_all(&[0u8; 1024]).unwrap();
assert_eq!(recorder.calls.lock().unwrap().len(), 1);
assert_eq!(writer.bytes_written, (1 + 300 + 1) * 1024);
}
#[test]
fn progressing_writer_last_callback_at_equals_bytes_at_threshold_boundary() {
use crate::bundle::DownloadProgress;
use std::io::Write as _;
use std::sync::Mutex;
#[derive(Debug, Default)]
struct Recorder {
calls: Mutex<Vec<u64>>,
}
impl DownloadProgress for Recorder {
fn on_progress(&self, _: &str, b: u64, _: Option<u64>) {
self.calls.lock().unwrap().push(b);
}
}
let recorder = Recorder::default();
let mut sink = std::io::sink();
let mut writer = ProgressingWriter::new(
&mut sink,
Some(&recorder as &dyn DownloadProgress),
"https://example.com/exact.bin",
Some(PROGRESS_THROTTLE_BYTES),
);
writer
.write_all(&vec![0u8; PROGRESS_THROTTLE_BYTES as usize])
.unwrap();
assert_eq!(writer.bytes_written, PROGRESS_THROTTLE_BYTES);
assert_eq!(writer.last_callback_at, PROGRESS_THROTTLE_BYTES);
let calls = recorder.calls.lock().unwrap();
assert_eq!(
calls.len(),
1,
"exactly one in-loop callback at the boundary"
);
assert_eq!(calls[0], PROGRESS_THROTTLE_BYTES);
}
#[test]
fn progressing_writer_with_none_progress_is_silent() {
use std::io::Write as _;
let mut sink = std::io::sink();
let mut writer = ProgressingWriter::new(&mut sink, None, "https://example.com/x", None);
writer.write_all(&[0u8; 1024 * 1024]).unwrap();
assert_eq!(writer.bytes_written, 1024 * 1024);
}
#[test]
fn read_sidecar_accepts_valid_hex() {
let dir = tempfile::tempdir().unwrap();
let dest = dir.path().join("x.gguf");
let expected = "a".repeat(64);
fs::write(sidecar_path(&dest), &expected).unwrap();
assert_eq!(read_sidecar(&dest).as_deref(), Some(expected.as_str()));
}
#[test]
fn read_sidecar_normalizes_case_and_whitespace() {
let dir = tempfile::tempdir().unwrap();
let dest = dir.path().join("x.gguf");
let hex = "AbCdEf".repeat(8) + "AbCdEfAbCdEfAbCd";
assert_eq!(hex.len(), 64);
fs::write(sidecar_path(&dest), format!(" {hex} \n")).unwrap();
assert_eq!(read_sidecar(&dest), Some(hex.to_ascii_lowercase()));
}
#[test]
fn read_sidecar_rejects_wrong_length() {
let dir = tempfile::tempdir().unwrap();
let dest = dir.path().join("x.gguf");
fs::write(sidecar_path(&dest), "deadbeef").unwrap();
assert!(read_sidecar(&dest).is_none());
}
#[test]
fn read_sidecar_rejects_non_hex() {
let dir = tempfile::tempdir().unwrap();
let dest = dir.path().join("x.gguf");
let garbage = "z".repeat(64);
fs::write(sidecar_path(&dest), garbage).unwrap();
assert!(read_sidecar(&dest).is_none());
}
#[test]
fn read_sidecar_missing_file_returns_none() {
let dir = tempfile::tempdir().unwrap();
let dest = dir.path().join("nonexistent.gguf");
assert!(read_sidecar(&dest).is_none());
}
#[test]
fn sha256_file_round_trips_known_input() {
let dir = tempfile::tempdir().unwrap();
let p = dir.path().join("x.bin");
fs::write(&p, b"hello").unwrap();
assert_eq!(
sha256_file(&p).unwrap(),
"2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"
);
}
}