Files
Lumotia/crates/transcription/src/model_manager.rs
Jake 9f67ab2d86 agent: code-atomiser-fix — atomic model download + manifest (Rev-1, Rev-5)
Two reversibility kills in the model-download path both followed the
same pattern: SHA mismatch on an existing file triggered
`remove_file(&dest)` BEFORE the network round-trip. A network blip /
power loss between the unlink and the eventual `rename(.part, dest)`
left users with neither the old (corrupt-but-readable) model nor a
fresh one — 1.5-20 GB redownload from scratch with no fallback.

Rev-1 (crates/llm/src/model_manager.rs):
  - Extract the existing-file decision into `download_to`, drop the
    pre-emptive unlink. `download_impl` already writes via `.part`
    and atomically renames; the rename overwrites on success and
    leaves dest untouched on failure.
  - Regression test `download_failure_preserves_existing_file` plants
    a sentinel "OLD" file at dest, points at a 500-returning server,
    and asserts dest still exists with original contents after the
    failed download.

Rev-5 (crates/transcription/src/model_manager.rs):
  - Drop the pre-emptive unlink in the outer `download()` SHA-mismatch
    branch. Same atomic rename via `download_file`.
  - Make `write_verified_manifest` atomic: write to `.tmp`, fsync,
    rename. Previous direct `fs::write` truncates-then-writes, so a
    crash mid-write left an empty/torn manifest and triggered a full
    GB-sized redownload on next boot.
  - `download_file_failure_preserves_existing_dest_file` and
    `manifest_write_is_atomic` regression tests added.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 15:21:01 +01:00

780 lines
29 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use std::collections::HashSet;
use std::path::{Path, PathBuf};
use std::sync::{LazyLock, Mutex};
use lumotia_core::error::{Error, Result};
use lumotia_core::model_registry::{find_model, ModelFile};
use lumotia_core::types::{DownloadProgress, ModelId};
static ACTIVE_DOWNLOADS: LazyLock<Mutex<HashSet<String>>> =
LazyLock::new(|| Mutex::new(HashSet::new()));
struct DownloadReservation {
id: String,
}
impl DownloadReservation {
fn acquire(id: &ModelId) -> Result<Self> {
let id = id.as_str().to_string();
let mut active = ACTIVE_DOWNLOADS
.lock()
.map_err(|_| Error::DownloadFailed("download lock poisoned".into()))?;
if !active.insert(id.clone()) {
return Err(Error::DownloadFailed(format!(
"download already in progress for {id}"
)));
}
Ok(Self { id })
}
}
impl Drop for DownloadReservation {
fn drop(&mut self) {
if let Ok(mut active) = ACTIVE_DOWNLOADS.lock() {
active.remove(&self.id);
}
}
}
/// Resolve the models storage directory.
/// Windows: %LOCALAPPDATA%/lumotia/models
/// Unix: ~/.lumotia/models
pub fn models_dir() -> PathBuf {
lumotia_core::paths::app_paths().models_dir()
}
/// Get the directory path where a specific model's files are stored.
pub fn model_dir(id: &ModelId) -> PathBuf {
lumotia_core::paths::app_paths().speech_model_dir(id)
}
/// Check whether all files for a model have been downloaded.
pub fn is_downloaded(id: &ModelId) -> bool {
let entry = match find_model(id) {
Some(e) => e,
None => return false,
};
let dir = model_dir(id);
entry.files.iter().all(|f| dir.join(f.filename).exists())
&& verified_manifest_matches(entry, &dir)
}
/// List all downloaded model IDs.
pub fn list_downloaded() -> Vec<ModelId> {
lumotia_core::model_registry::all_models()
.iter()
.filter(|m| is_downloaded(&m.id))
.map(|m| m.id.clone())
.collect()
}
/// Download all files for a model, calling the progress callback per chunk.
/// Files are downloaded to a .part suffix and atomically renamed on completion.
///
/// For files that declare a `sha256` checksum we validate an existing
/// complete file before skipping the download — a truncated or
/// tampered file gets redownloaded automatically (pattern ported from
/// `lumotia-llm`'s model_manager, item #8 in the Whisper ecosystem brief).
pub async fn download(
id: &ModelId,
progress: impl Fn(DownloadProgress) + Send + 'static,
) -> Result<()> {
let _reservation = DownloadReservation::acquire(id)?;
let entry = find_model(id).ok_or_else(|| Error::ModelNotFound(id.clone()))?;
let dir = model_dir(id);
std::fs::create_dir_all(&dir)?;
for file in &entry.files {
let dest = dir.join(file.filename);
if dest.exists() {
// Validate the existing file. If the hash doesn't match,
// the file is corrupt (partial download, tampering, bit
// rot) and we must re-fetch it to avoid crashing on
// model load later.
match sha256_of_file(&dest) {
Ok(actual) if actual.eq_ignore_ascii_case(file.sha256) => continue,
Ok(_actual) => {
// Rev-5 reversibility kill (atomiser 2026-05-12):
// the previous implementation called
// `remove_file(&dest)` here before the network
// round-trip. A network blip / power loss between
// the unlink and `rename(tmp, dest)` in
// `download_file` left users with neither the old
// (corrupt) model file nor a fresh one — Whisper
// models are 1.53 GB and the user is offline by
// hypothesis.
//
// We now leave the existing file in place.
// `download_file` writes to a `.part` sibling and
// only `rename`s over `dest` once the SHA matches.
// The bad file is atomically replaced on success;
// on failure the user keeps what they had.
}
Err(e) => {
return Err(Error::DownloadFailed(format!(
"failed to verify existing {}: {e}",
file.filename
)));
}
}
}
download_file(file, &dest, id, &progress).await?;
}
write_verified_manifest(entry, &dir)?;
Ok(())
}
fn verified_manifest_path(dir: &Path) -> PathBuf {
dir.join(".lumotia-verified")
}
fn verified_manifest_matches(
entry: &lumotia_core::model_registry::ModelEntry,
dir: &Path,
) -> bool {
let manifest = match std::fs::read_to_string(verified_manifest_path(dir)) {
Ok(contents) => contents,
Err(_) => return false,
};
for file in &entry.files {
let path = dir.join(file.filename);
let size = match std::fs::metadata(&path) {
Ok(metadata) => metadata.len(),
Err(_) => return false,
};
let expected_line = format!("{}\t{}\t{}", file.filename, file.sha256, size);
if !manifest.lines().any(|line| line == expected_line) {
return false;
}
}
true
}
fn write_verified_manifest(
entry: &lumotia_core::model_registry::ModelEntry,
dir: &Path,
) -> std::io::Result<()> {
let mut lines = Vec::with_capacity(entry.files.len() + 1);
lines.push("version\t1".to_string());
for file in &entry.files {
let size = std::fs::metadata(dir.join(file.filename))?.len();
lines.push(format!("{}\t{}\t{}", file.filename, file.sha256, size));
}
let final_path = verified_manifest_path(dir);
let body = format!("{}\n", lines.join("\n"));
// Rev-5 atomicity (atomiser 2026-05-12): the previous
// implementation called `fs::write(final_path, body)` directly,
// which truncates-then-writes. A power loss or kill mid-write
// leaves the manifest empty or partial; on next boot
// `verified_manifest_matches` reports `false` and the loader
// re-downloads the model even though all the GB-sized files are
// intact on disk.
//
// Write to a sibling `.tmp` first, fsync the data, then `rename`
// — POSIX `rename` is atomic within a directory, so a reader sees
// either the old manifest or the new one, never a partial.
let tmp_path = final_path.with_extension("tmp");
{
use std::io::Write;
let mut tmp = std::fs::File::create(&tmp_path)?;
tmp.write_all(body.as_bytes())?;
tmp.flush()?;
// Best-effort fsync. If the platform/filesystem can't sync
// (e.g. some test runners on tmpfs), the rename is still
// atomic at the directory entry level, which is the property
// we actually care about for "no torn manifest".
let _ = tmp.sync_all();
}
std::fs::rename(&tmp_path, &final_path)
}
/// Non-streaming SHA256 of a file on disk. Used by `download()` to
/// validate an existing complete file before trusting it.
fn sha256_of_file(path: &Path) -> std::io::Result<String> {
use sha2::{Digest, Sha256};
let mut hasher = Sha256::new();
let mut file = std::fs::File::open(path)?;
let mut buffer = [0u8; 8192];
loop {
let n = std::io::Read::read(&mut file, &mut buffer)?;
if n == 0 {
break;
}
hasher.update(&buffer[..n]);
}
Ok(format!("{:x}", hasher.finalize()))
}
/// Download a single file with HTTP Range resume and optional SHA256 verification.
///
/// Resume pattern from Buzz (chidiwilliams/buzz): if a .part file exists,
/// send a Range header to resume from where we left off. SHA256 is checked
/// incrementally during download — no second pass over the file.
async fn download_file(
file: &ModelFile,
dest: &Path,
model_id: &ModelId,
progress: &(impl Fn(DownloadProgress) + Send),
) -> Result<()> {
use futures_util::StreamExt;
use sha2::{Digest, Sha256};
let part_path = dest.with_extension(
dest.extension()
.map(|e| format!("{}.part", e.to_string_lossy()))
.unwrap_or_else(|| "part".to_string()),
);
let client = reqwest::Client::builder()
.connect_timeout(std::time::Duration::from_secs(30))
.build()
.map_err(|e| Error::DownloadFailed(e.to_string()))?;
// Check for existing partial download (resume support)
let existing_bytes = if part_path.exists() {
std::fs::metadata(&part_path).map(|m| m.len()).unwrap_or(0)
} else {
0
};
let mut request = client.get(file.url);
let resuming = existing_bytes > 0;
if resuming {
request = request.header("Range", format!("bytes={existing_bytes}-"));
}
let response = request
.send()
.await
.map_err(|e| Error::DownloadFailed(e.to_string()))?;
// If we requested Range but the server returned 200 (full file), the
// server does not support resume. Rather than blindly appending a
// full file on top of our partial bytes (which would produce a
// corrupt result), restart cleanly. This mirrors the lumotia-llm
// ResumeUnsupported branch — item #8 of the brief.
//
// For the non-resume path, we still have to validate the status:
// reqwest does not error on 4xx/5xx by default, so without this
// check a 404 or 500 would be streamed into `.part` and renamed
// over the destination as if the download succeeded
// (2026-04-22 review MAJOR).
let actually_resuming = if resuming {
match response.status().as_u16() {
206 => true,
200 => {
// Server ignored our Range header — treat as fresh start.
// The old .part bytes are discarded below.
false
}
other => {
return Err(Error::DownloadFailed(format!(
"resume request returned unexpected status {other}"
)));
}
}
} else {
if !response.status().is_success() {
return Err(Error::DownloadFailed(format!(
"download returned HTTP {} for {}",
response.status(),
file.filename
)));
}
false
};
let total_bytes = if actually_resuming {
// Content-Range: bytes START-END/TOTAL — extract TOTAL
response
.headers()
.get("content-range")
.and_then(|v| v.to_str().ok())
.and_then(|s| s.rsplit('/').next())
.and_then(|s| s.parse::<u64>().ok())
.unwrap_or(0)
} else {
response.content_length().unwrap_or(0)
};
let mut stream = response.bytes_stream();
let mut downloaded: u64 = if actually_resuming { existing_bytes } else { 0 };
let mut last_percent: u8 = 0;
// Open file for append (resume) or create (fresh start)
let mut out = if actually_resuming {
std::fs::OpenOptions::new().append(true).open(&part_path)?
} else {
std::fs::File::create(&part_path)?
};
let mut hasher = Sha256::new();
if actually_resuming {
let mut partial = std::fs::File::open(&part_path)?;
let mut buffer = [0u8; 8192];
loop {
let n = std::io::Read::read(&mut partial, &mut buffer)?;
if n == 0 {
break;
}
hasher.update(&buffer[..n]);
}
}
while let Some(chunk) = stream.next().await {
let chunk = chunk.map_err(|e| Error::DownloadFailed(e.to_string()))?;
std::io::Write::write_all(&mut out, &chunk)?;
hasher.update(&chunk);
downloaded += chunk.len() as u64;
let percent = if total_bytes > 0 {
((downloaded as f64 / total_bytes as f64) * 100.0) as u8
} else {
0
};
if percent != last_percent {
last_percent = percent;
progress(DownloadProgress {
model_id: model_id.clone(),
file_name: file.filename.to_string(),
bytes_downloaded: downloaded,
total_bytes,
percent,
});
}
}
drop(out);
let actual = format!("{:x}", hasher.finalize());
if actual != file.sha256 {
let _ = std::fs::remove_file(&part_path);
return Err(Error::DownloadFailed(format!(
"SHA256 mismatch for {}: expected {}, got {}",
file.filename, file.sha256, actual
)));
}
// Atomic rename — file is complete and verified
std::fs::rename(&part_path, dest)?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use sha2::Digest;
use tempfile::tempdir;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpListener;
#[test]
fn model_dir_returns_correct_path() {
let id = ModelId::new("whisper-tiny-en");
let path = model_dir(&id);
assert!(path.to_string_lossy().contains("whisper-tiny-en"));
}
#[test]
fn is_downloaded_returns_false_for_missing_model() {
let id = ModelId::new("nonexistent-model");
assert!(!is_downloaded(&id));
}
#[test]
fn list_downloaded_returns_empty_when_no_models() {
let list = list_downloaded();
// In test environment, no models are downloaded
// This just verifies the function doesn't panic
assert!(list.len() <= lumotia_core::model_registry::all_models().len());
}
#[test]
fn sha256_of_file_matches_sha2() {
let dir = tempdir().unwrap();
let path = dir.path().join("f.bin");
std::fs::write(&path, b"hello world").unwrap();
let expected = format!("{:x}", sha2::Sha256::digest(b"hello world"));
assert_eq!(sha256_of_file(&path).unwrap(), expected);
}
/// A minimal HTTP server that sends a Range response when a Range
/// header is present and otherwise sends the full body. Ported from
/// crates/llm/src/model_manager.rs to give the transcription
/// download stack the same fixture-backed coverage.
async fn spawn_range_server(content: Vec<u8>) -> std::net::SocketAddr {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
let (mut socket, _) = listener.accept().await.unwrap();
let mut buf = vec![0u8; 2048];
let size = socket.read(&mut buf).await.unwrap();
let request = String::from_utf8_lossy(&buf[..size]).to_lowercase();
let range_start = request
.lines()
.find_map(|line| line.strip_prefix("range: bytes="))
.and_then(|line| line.strip_suffix('-'))
.and_then(|line| line.trim().parse::<usize>().ok());
if let Some(start) = range_start {
let body = &content[start..];
let response = format!(
"HTTP/1.1 206 Partial Content\r\n\
Content-Length: {}\r\n\
Content-Range: bytes {}-{}/{}\r\n\
Accept-Ranges: bytes\r\n\r\n",
body.len(),
start,
content.len() - 1,
content.len(),
);
socket.write_all(response.as_bytes()).await.unwrap();
socket.write_all(body).await.unwrap();
} else {
let response = format!(
"HTTP/1.1 200 OK\r\n\
Content-Length: {}\r\n\
Accept-Ranges: bytes\r\n\r\n",
content.len(),
);
socket.write_all(response.as_bytes()).await.unwrap();
socket.write_all(&content).await.unwrap();
}
});
addr
}
/// A minimal HTTP server that responds with 200 + full body **iff**
/// the request actually carries a `Range` header, and 400 otherwise.
/// This models a mirror / proxy that accepts Range requests but
/// refuses to honour them (returning a fresh full body), which is
/// exactly the ResumeUnsupported branch `download_file` needs to
/// handle. The 400-on-missing-Range behaviour is load-bearing for
/// the test: it turns "client never sent Range" into a download
/// failure, so deleting the resume-detection logic causes the test
/// to fail rather than pass coincidentally through File::create's
/// truncation semantics.
async fn spawn_no_range_server(content: Vec<u8>) -> std::net::SocketAddr {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
let (mut socket, _) = listener.accept().await.unwrap();
let mut buf = vec![0u8; 2048];
let size = socket.read(&mut buf).await.unwrap();
let request = String::from_utf8_lossy(&buf[..size]).to_lowercase();
let saw_range_header = request
.lines()
.any(|line| line.trim_start().starts_with("range:"));
if !saw_range_header {
let response = "HTTP/1.1 400 Bad Request\r\n\
Content-Length: 0\r\n\r\n";
socket.write_all(response.as_bytes()).await.unwrap();
return;
}
let response = format!(
"HTTP/1.1 200 OK\r\n\
Content-Length: {}\r\n\r\n",
content.len(),
);
socket.write_all(response.as_bytes()).await.unwrap();
socket.write_all(&content).await.unwrap();
});
addr
}
/// ModelFile stores `&'static str` fields, so we leak the strings
/// once per test — tests are one-shot, so the cost is noise.
fn leak(s: String) -> &'static str {
Box::leak(s.into_boxed_str())
}
#[tokio::test]
async fn download_file_resumes_from_partial_and_verifies_sha() {
let body = b"resumable transcription payload".to_vec();
let expected_sha = format!("{:x}", sha2::Sha256::digest(&body));
let addr = spawn_range_server(body.clone()).await;
let dir = tempdir().unwrap();
let dest = dir.path().join("fixture.bin");
let part = dest.with_extension("bin.part");
// Pretend we already downloaded the first 7 bytes.
std::fs::write(&part, &body[..7]).unwrap();
let file = ModelFile {
filename: leak(dest.file_name().unwrap().to_string_lossy().into_owned()),
url: leak(format!("http://{addr}/fixture.bin")),
size: lumotia_core::types::Megabytes(0),
sha256: leak(expected_sha.clone()),
};
let id = ModelId::new("test-fixture");
download_file(&file, &dest, &id, &|_| ()).await.unwrap();
let bytes = std::fs::read(&dest).unwrap();
assert_eq!(bytes, body);
assert!(!part.exists());
assert_eq!(sha256_of_file(&dest).unwrap(), expected_sha);
}
#[tokio::test]
async fn download_file_restarts_when_server_ignores_range() {
// Covers the ResumeUnsupported branch documented in `download_file`:
// when a partial `.part` file exists and the server returns 200
// (full body) to our Range request, we must discard the stale
// partial bytes and write the fresh body from offset zero rather
// than appending on top.
let body = b"fresh transcription payload that replaces any stale partial".to_vec();
let expected_sha = format!("{:x}", sha2::Sha256::digest(&body));
let addr = spawn_no_range_server(body.clone()).await;
let dir = tempdir().unwrap();
let dest = dir.path().join("fixture.bin");
let part = dest.with_extension("bin.part");
// Pretend a previous attempt downloaded 12 bytes of something
// entirely unrelated. If the client naively appended the 200
// body, the final file would start with these bytes.
std::fs::write(&part, b"STALE_BYTES1").unwrap();
let file = ModelFile {
filename: leak(dest.file_name().unwrap().to_string_lossy().into_owned()),
url: leak(format!("http://{addr}/fixture.bin")),
size: lumotia_core::types::Megabytes(0),
sha256: leak(expected_sha),
};
let id = ModelId::new("test-fixture");
download_file(&file, &dest, &id, &|_| ()).await.unwrap();
let bytes = std::fs::read(&dest).unwrap();
assert_eq!(
bytes, body,
"server returned 200 to Range — downloader must discard stale .part and rewrite from scratch"
);
assert!(!part.exists(), ".part → dest rename must run after restart");
}
/// Always returns HTTP 500 with a short error body. Used to verify
/// the non-resume download path validates status codes rather than
/// writing error bodies into `.part` and renaming them over the
/// destination.
async fn spawn_500_server() -> std::net::SocketAddr {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
let (mut socket, _) = listener.accept().await.unwrap();
let mut buf = vec![0u8; 2048];
let _ = socket.read(&mut buf).await.unwrap();
let body = b"internal error";
let response = format!(
"HTTP/1.1 500 Internal Server Error\r\n\
Content-Length: {}\r\n\r\n",
body.len()
);
socket.write_all(response.as_bytes()).await.unwrap();
socket.write_all(body).await.unwrap();
});
addr
}
#[tokio::test]
async fn download_file_rejects_5xx_on_non_resume_path() {
// Regression for the 2026-04-22 review: reqwest does not
// auto-error on 4xx/5xx, and the non-resume branch previously
// streamed any status' body into `.part` and renamed it over
// the destination.
let addr = spawn_500_server().await;
let dir = tempdir().unwrap();
let dest = dir.path().join("fixture.bin");
let part = dest.with_extension("bin.part");
let file = ModelFile {
filename: leak(dest.file_name().unwrap().to_string_lossy().into_owned()),
url: leak(format!("http://{addr}/fixture.bin")),
size: lumotia_core::types::Megabytes(0),
sha256: leak("0".repeat(64)),
};
let id = ModelId::new("test-fixture");
let err = download_file(&file, &dest, &id, &|_| ())
.await
.expect_err("5xx must fail");
let msg = err.to_string();
assert!(
msg.contains("HTTP 500"),
"error should name the HTTP status, got: {msg}"
);
assert!(!dest.exists(), "5xx must not leave a destination file");
assert!(!part.exists(), "5xx must not leave a .part file");
}
#[tokio::test]
async fn download_file_fails_on_sha_mismatch_and_cleans_part_file() {
let body = b"speech-to-text fixture body".to_vec();
let addr = spawn_range_server(body.clone()).await;
let dir = tempdir().unwrap();
let dest = dir.path().join("fixture.bin");
let file = ModelFile {
filename: leak(dest.file_name().unwrap().to_string_lossy().into_owned()),
url: leak(format!("http://{addr}/fixture.bin")),
size: lumotia_core::types::Megabytes(0),
sha256: leak("deadbeef".repeat(8)),
};
let id = ModelId::new("test-fixture");
let err = download_file(&file, &dest, &id, &|_| ())
.await
.expect_err("mismatched sha must fail");
let msg = err.to_string();
assert!(msg.contains("SHA256 mismatch"), "unexpected error: {msg}");
assert!(
!dest.exists(),
".part → dest rename must not run on mismatch"
);
let part = dest.with_extension("bin.part");
assert!(!part.exists(), "failed hash must clean up the .part file");
}
/// Rev-5 regression (atomiser 2026-05-12). The previous `download()`
/// loop called `remove_file(&dest)` on SHA mismatch BEFORE the
/// network call, so a failing redownload left the user with
/// neither the old (corrupt-but-readable) Whisper model nor a
/// fresh one — 1.53 GB redownload over an already-flaky network.
///
/// `download_file` is the per-file primitive that gets invoked
/// after the outer-loop decides to redownload. It already writes
/// via `.part` and atomically renames. This test asserts the
/// invariant the fix relies on: when `download_file` fails (5xx
/// or SHA mismatch), an existing sentinel file at `dest` is left
/// in place. The outer-loop no longer deletes pre-emptively, so
/// this proves end-to-end: a failed redownload preserves the old
/// file.
#[tokio::test]
async fn download_file_failure_preserves_existing_dest_file() {
let addr = spawn_500_server().await;
let dir = tempdir().unwrap();
let dest = dir.path().join("fixture.bin");
// Existing "old model" file the user already had on disk.
std::fs::write(&dest, b"OLD").unwrap();
let file = ModelFile {
filename: leak(dest.file_name().unwrap().to_string_lossy().into_owned()),
url: leak(format!("http://{addr}/fixture.bin")),
size: lumotia_core::types::Megabytes(0),
sha256: leak("0".repeat(64)),
};
let id = ModelId::new("test-fixture");
let _ = download_file(&file, &dest, &id, &|_| ())
.await
.expect_err("5xx must fail");
assert!(
dest.exists(),
"download failure must leave the existing dest in place"
);
let preserved = std::fs::read(&dest).unwrap();
assert_eq!(
preserved, b"OLD",
"existing file contents must be untouched on failed download"
);
}
/// Rev-5 atomic-manifest regression (atomiser 2026-05-12). The
/// previous `write_verified_manifest` called `fs::write` directly,
/// which truncates-then-writes. A power loss mid-write left an
/// empty or torn manifest; on next boot the loader couldn't match
/// the existing GB-sized model files and redownloaded everything.
///
/// The fix writes to a sibling `.tmp` first then `rename`s — POSIX
/// `rename` is atomic. This test exercises that path: write a
/// valid manifest, then prove a stale `.tmp` left behind from a
/// crashed previous attempt does NOT survive a fresh write (the
/// rename replaces it), and that the final manifest matches.
#[test]
fn manifest_write_is_atomic() {
use lumotia_core::model_registry::{
AccuracyTier, Engine, LanguageSupport, ModelEntry, ModelFile, SpeedTier,
};
use lumotia_core::types::Megabytes;
use sha2::Digest;
let dir = tempdir().unwrap();
let file_path = dir.path().join("model.bin");
std::fs::write(&file_path, b"payload").unwrap();
let entry = ModelEntry {
id: ModelId::new("test-manifest"),
engine: Engine::Whisper,
display_name: "test",
disk_size: Megabytes(0),
ram_required: Megabytes(0),
speed_tier: SpeedTier::Instant,
accuracy_tier: AccuracyTier::Great,
languages: LanguageSupport::EnglishOnly,
files: vec![ModelFile {
filename: leak(
file_path
.file_name()
.unwrap()
.to_string_lossy()
.into_owned(),
),
url: "http://example.invalid/model.bin",
size: Megabytes(0),
sha256: leak(format!("{:x}", sha2::Sha256::digest(b"payload"))),
}],
description: "",
};
let manifest_path = verified_manifest_path(dir.path());
let tmp_path = manifest_path.with_extension("tmp");
// Simulate a crashed previous write leaving a stale .tmp.
std::fs::write(&tmp_path, b"PARTIAL_GARBAGE").unwrap();
assert!(tmp_path.exists());
write_verified_manifest(&entry, dir.path()).expect("write manifest");
assert!(
manifest_path.exists(),
"final manifest must exist after atomic write"
);
assert!(
!tmp_path.exists(),
"stale .tmp must be removed by rename"
);
let body = std::fs::read_to_string(&manifest_path).unwrap();
assert!(body.starts_with("version\t1"));
assert!(
body.ends_with('\n'),
"manifest must be flushed completely with terminating newline"
);
assert!(
verified_manifest_matches(&entry, dir.path()),
"manifest written via tmp+rename must round-trip verify"
);
}
}