use sha2::{Digest, Sha256};
pub fn sha256_hex(bytes: &[u8]) -> String {
let mut h = Sha256::new();
h.update(bytes);
h.finalize().iter().map(|b| format!("{b:02x}")).collect()
}
pub fn zstd_decode(input: &[u8]) -> Result<Vec<u8>, String> {
use std::io::Read;
let mut dec = ruzstd::StreamingDecoder::new(input).map_err(|e| format!("zstd init: {e}"))?;
let mut out = Vec::new();
dec.read_to_end(&mut out)
.map_err(|e| format!("zstd decode: {e}"))?;
Ok(out)
}
pub fn verify_and_decompress(
compressed: &[u8],
expected_sha256_hex: Option<&str>,
) -> Result<Vec<u8>, String> {
let bytes = zstd_decode(compressed)?;
if let Some(expected) = expected_sha256_hex {
let got = sha256_hex(&bytes);
if got != expected {
return Err(format!(
"hash mismatch after decompress: got {got}, expected {expected}"
));
}
}
Ok(bytes)
}
#[cfg(feature = "download")]
mod storage {
use std::cell::Cell;
use std::fs;
use std::io::Write;
use std::path::{Path, PathBuf};
use super::{sha256_hex, zstd_decode};
use crate::fetch::{download_retrying, Fetch, RetryPolicy};
use crate::remote::Record;
fn verify_hash(record: &Record, bytes: Vec<u8>) -> Result<Vec<u8>, String> {
if let Some(expected) = &record.decompressed_hash {
let got = sha256_hex(&bytes);
if &got != expected {
return Err(format!(
"hash mismatch for {} after download: got {got}, expected {expected}",
record.name
));
}
}
Ok(bytes)
}
fn render_progress(name: &str, done: u64, total: Option<u64>) {
const MIB: f64 = 1024.0 * 1024.0;
let mut err = std::io::stderr();
let line = match total {
Some(t) if t > 0 => {
let pct = (done as f64 / t as f64 * 100.0).round() as u64;
format!(
" {name}: {:.1} / {:.1} MiB ({pct}%)",
done as f64 / MIB,
t as f64 / MIB
)
}
_ => format!(" {name}: {:.1} MiB", done as f64 / MIB),
};
let _ = write!(err, "\r{line:<60}");
let _ = err.flush();
}
#[derive(Clone, Debug)]
pub struct ModelFiles {
pub model: PathBuf,
pub src_vocab: PathBuf,
pub trg_vocab: PathBuf,
pub lex: Option<PathBuf>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct CachedPair {
pub name: String,
pub dir: PathBuf,
pub bytes: u64,
}
pub fn dir_size(dir: &Path) -> Result<u64, String> {
if !dir.exists() {
return Ok(0);
}
let mut total = 0;
for entry in fs::read_dir(dir).map_err(|e| e.to_string())? {
let entry = entry.map_err(|e| e.to_string())?;
if entry.file_name().to_string_lossy().starts_with('.') {
continue;
}
let meta = entry.metadata().map_err(|e| e.to_string())?;
if meta.is_dir() {
total += dir_size(&entry.path())?;
} else if meta.is_file() {
total += meta.len();
}
}
Ok(total)
}
pub struct Cache {
root: PathBuf,
retry: RetryPolicy,
show_progress: bool,
}
impl Cache {
pub fn locate() -> Cache {
let base = dirs::cache_dir().unwrap_or_else(|| PathBuf::from(".fxtranslate-cache"));
Cache::with_root(base.join("fxtranslate").join("models"))
}
pub fn with_root(root: impl Into<PathBuf>) -> Cache {
Cache {
root: root.into(),
retry: RetryPolicy::default(),
show_progress: false,
}
}
pub fn with_progress(mut self, show: bool) -> Cache {
self.show_progress = show;
self
}
pub fn with_retry(mut self, policy: RetryPolicy) -> Cache {
self.retry = policy;
self
}
pub fn root(&self) -> &Path {
&self.root
}
pub fn pair_dir(&self, src: &str, trg: &str) -> PathBuf {
self.root.join(format!("{src}-{trg}"))
}
fn pair_path(&self, name: &str) -> Result<PathBuf, String> {
let mut comps = Path::new(name).components();
match (comps.next(), comps.next()) {
(Some(std::path::Component::Normal(_)), None) => Ok(self.root.join(name)),
_ => Err(format!("invalid model name `{name}`")),
}
}
pub fn list_cached(&self) -> Result<Vec<CachedPair>, String> {
if !self.root.exists() {
return Ok(Vec::new());
}
let mut cached = Vec::new();
for entry in fs::read_dir(&self.root).map_err(|e| e.to_string())? {
let entry = entry.map_err(|e| e.to_string())?;
if !entry.file_type().map_err(|e| e.to_string())?.is_dir() {
continue;
}
let name = entry.file_name().to_string_lossy().into_owned();
if name.starts_with('.') {
continue;
}
let dir = entry.path();
let bytes = dir_size(&dir)?;
cached.push(CachedPair { name, dir, bytes });
}
cached.sort_by(|a, b| a.name.cmp(&b.name));
Ok(cached)
}
pub fn pair_files(&self, name: &str) -> Result<Vec<(String, u64, PathBuf)>, String> {
let dir = self.pair_path(name)?;
if !dir.exists() {
return Ok(Vec::new());
}
let mut files = Vec::new();
for entry in fs::read_dir(&dir).map_err(|e| e.to_string())? {
let entry = entry.map_err(|e| e.to_string())?;
let fname = entry.file_name().to_string_lossy().into_owned();
if fname.starts_with('.') {
continue;
}
let meta = entry.metadata().map_err(|e| e.to_string())?;
if meta.is_file() {
files.push((fname, meta.len(), entry.path()));
}
}
files.sort_by(|a, b| a.0.cmp(&b.0));
Ok(files)
}
pub fn remove_pair(&self, name: &str) -> Result<bool, String> {
let dir = self.pair_path(name)?;
if !dir.exists() {
return Ok(false);
}
fs::remove_dir_all(&dir).map_err(|e| e.to_string())?;
Ok(true)
}
pub fn cached_model(&self, src: &str, trg: &str) -> Option<ModelFiles> {
let (mut model, mut vocab, mut src_vocab, mut trg_vocab, mut lex) =
(None, None, None, None, None);
for entry in fs::read_dir(self.pair_dir(src, trg)).ok()?.flatten() {
let name = entry.file_name().to_string_lossy().into_owned();
let slot = if name.starts_with("model.") {
&mut model
} else if name.starts_with("srcvocab.") {
&mut src_vocab
} else if name.starts_with("trgvocab.") {
&mut trg_vocab
} else if name.starts_with("vocab.") {
&mut vocab
} else if name.starts_with("lex.") {
&mut lex
} else {
continue; };
*slot = Some(entry.path());
}
let (src_vocab, trg_vocab) = match vocab {
Some(v) => (v.clone(), v),
None => (src_vocab?, trg_vocab?),
};
Some(ModelFiles {
model: model?,
src_vocab,
trg_vocab,
lex,
})
}
pub fn ensure(&self, fetch: &dyn Fetch, record: &Record) -> Result<PathBuf, String> {
let dir = self.pair_dir(&record.src, &record.trg);
let dest = dir.join(&record.name);
if dest.is_file() {
match &record.decompressed_hash {
Some(expected) => {
let got = sha256_hex(&fs::read(&dest).map_err(|e| e.to_string())?);
if &got == expected {
return Ok(dest);
}
eprintln!(
"[cache] {} hash mismatch (have {}…, want {}…); re-fetching",
record.name,
&got[..8.min(got.len())],
&expected[..8.min(expected.len())]
);
}
None => return Ok(dest), }
}
fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
let url = record.cdn_url();
let download = dir.join(format!(".{}.download", record.name));
let show = self.show_progress;
let name = &record.name;
let drew = Cell::new(false);
let mut on_progress = |done: u64, total: Option<u64>| {
if show {
render_progress(name, done, total);
drew.set(true);
}
};
let mut healed = false;
let bytes = loop {
let stats =
download_retrying(fetch, &url, &download, &self.retry, &mut on_progress);
if drew.replace(false) {
eprintln!(); }
let stats = stats?;
let assembled = fs::read(&download)
.map_err(|e| e.to_string())
.and_then(|compressed| zstd_decode(&compressed))
.and_then(|bytes| verify_hash(record, bytes));
match assembled {
Ok(bytes) => break bytes,
Err(e) => {
let _ = fs::remove_file(&download);
if stats.attempts > 1 && !healed {
healed = true;
eprintln!(
"[cache] {}: assembled download failed verification; re-fetching cleanly",
record.name
);
continue;
}
return Err(e);
}
}
};
let _ = fs::remove_file(&download);
let tmp = dir.join(format!(".{}.partial", record.name));
fs::write(&tmp, &bytes).map_err(|e| e.to_string())?;
fs::rename(&tmp, &dest).map_err(|e| e.to_string())?;
Ok(dest)
}
}
pub fn ensure_model(
fetch: &dyn Fetch,
cache: &Cache,
records: &[Record],
src: &str,
trg: &str,
) -> Result<ModelFiles, String> {
use crate::remote::pick;
let model = pick(records, "model", src, trg)
.ok_or_else(|| format!("no model for {src}-{trg} in Remote Settings"))?;
let model_path = cache.ensure(fetch, model)?;
let (src_vocab, trg_vocab) = if let Some(v) = pick(records, "vocab", src, trg) {
let p = cache.ensure(fetch, v)?;
(p.clone(), p)
} else {
let sv = pick(records, "srcvocab", src, trg)
.ok_or_else(|| format!("no vocab/srcvocab for {src}-{trg}"))?;
let tv = pick(records, "trgvocab", src, trg)
.ok_or_else(|| format!("no trgvocab for {src}-{trg}"))?;
(cache.ensure(fetch, sv)?, cache.ensure(fetch, tv)?)
};
let lex = match pick(records, "lex", src, trg) {
Some(l) => Some(cache.ensure(fetch, l)?),
None => None,
};
Ok(ModelFiles {
model: model_path,
src_vocab,
trg_vocab,
lex,
})
}
}
#[cfg(feature = "download")]
pub use storage::*;