phostt 0.3.0

Local STT server powered by Zipformer-vi RNN-T — on-device Vietnamese speech recognition via ONNX Runtime
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
//! Model bundle download and management.
//!
//! Fetches the upstream sherpa-onnx Zipformer-vi RNN-T release tarball from
//! GitHub Releases, verifies its SHA-256, extracts the encoder, decoder,
//! joiner, BPE model and tokens vocabulary into `~/.phostt/models/`, then
//! discards the archive. The bundle ships with a `test_wavs/` directory
//! that we deliberately keep so e2e fixtures can replay the upstream
//! reference samples.
//!
//! Atomic-rename + `.partial` semantics carried over from the upstream
//! `gigastt` fetcher: a crash between download and SHA verification can
//! never leave a half-extracted model dir under the visible name.

use anyhow::{Context, Result};
use bzip2::read::BzDecoder;
use futures_util::StreamExt;
use sha2::{Digest, Sha256};
use std::path::Path;
use tar::Archive;
use tokio::io::AsyncWriteExt;

/// Simple download progress reporter (no external deps).
struct DownloadProgress {
    total: u64,
    current: u64,
    last_percent: u8,
}

impl DownloadProgress {
    fn new(total: u64) -> Self {
        Self {
            total,
            current: 0,
            last_percent: 0,
        }
    }

    fn update(&mut self, bytes: u64) {
        self.current += bytes;
        let percent = (self.current * 100)
            .checked_div(self.total)
            .map(|p| p as u8)
            .unwrap_or(0);
        if percent != self.last_percent {
            self.last_percent = percent;
            eprint!(
                "\rDownloading... {percent}% ({:.1}MB / {:.1}MB)",
                self.current as f64 / 1_048_576.0,
                self.total as f64 / 1_048_576.0
            );
        }
    }

    fn finish(&self) {
        eprintln!(
            "\rDownload complete ({:.1}MB)                    ",
            self.current as f64 / 1_048_576.0
        );
    }
}

/// GitHub release that hosts the bundle. The sherpa-onnx project re-uses a
/// single `asr-models` tag for every published ASR weight set, so the URL
/// is stable across model upgrades — only [`MODEL_BUNDLE_FILENAME`] and
/// [`MODEL_BUNDLE_SHA256`] need to move when we re-pin.
const MODEL_BUNDLE_REPO: &str = "k2-fsa/sherpa-onnx";
const MODEL_BUNDLE_RELEASE: &str = "asr-models";
const MODEL_BUNDLE_FILENAME: &str = "sherpa-onnx-zipformer-vi-int8-2025-04-20.tar.bz2";
/// Top-level directory inside the tarball. We strip this prefix on
/// extraction so phostt sees a flat `<model_dir>/encoder.int8.onnx` layout.
const MODEL_BUNDLE_TOP_DIR: &str = "sherpa-onnx-zipformer-vi-int8-2025-04-20";

/// SHA-256 of the published `*.tar.bz2`. Verified on 2026-05-05.
const MODEL_BUNDLE_SHA256: &str =
    "48d0fdc9b3515eb9b00c4dfec2883207ee5ebe5c95b1959e7afce87fc3391938";

/// Files we require under `<model_dir>/` after extraction. `model_files_exist`
/// uses this list to short-circuit subsequent runs without re-downloading.
pub const MODEL_FILES: &[&str] = &[
    "encoder.int8.onnx",
    "decoder.onnx",
    "joiner.int8.onnx",
    "bpe.model",
    "tokens.txt",
];

#[cfg(feature = "diarization")]
const SPEAKER_HF_REPO: &str = "onnx-community/wespeaker-voxceleb-resnet34-LM";
#[cfg(feature = "diarization")]
pub const SPEAKER_MODEL_FILE: &str = "wespeaker_resnet34.onnx";

/// SHA-256 of the upstream speaker-diarization model (`onnx/model.onnx` at
/// `onnx-community/wespeaker-voxceleb-resnet34-LM`, 26 535 549 bytes).
/// Verified against the canonical HuggingFace copy on 2026-04-20; if the
/// upstream model is ever rotated, update this constant alongside the
/// SPEAKER_MODEL_FILE bump.
#[cfg(feature = "diarization")]
const SPEAKER_MODEL_SHA256: &str =
    "3955447b0499dc9e0a4541a895df08b03c69098eba4e56c02b5603e9f7f4fcbb";

fn home_dir() -> Option<std::path::PathBuf> {
    #[cfg(unix)]
    {
        std::env::var_os("HOME").map(std::path::PathBuf::from)
    }
    #[cfg(windows)]
    {
        std::env::var_os("USERPROFILE").map(std::path::PathBuf::from)
    }
}

/// Return the default model directory path (`~/.phostt/models/`).
///
/// Falls back to `.phostt/models` if the home directory cannot be determined.
pub fn default_model_dir() -> String {
    home_dir()
        .map(|h| {
            h.join(".phostt")
                .join("models")
                .to_string_lossy()
                .into_owned()
        })
        .unwrap_or_else(|| ".phostt/models".into())
}

/// Ensure model files exist in `model_dir`, downloading and extracting the
/// upstream sherpa-onnx Zipformer-vi bundle if missing.
pub async fn ensure_model(model_dir: &str) -> Result<()> {
    let dir = Path::new(model_dir);

    if model_files_exist(dir) {
        tracing::info!("Model found at {model_dir}");
        return Ok(());
    }

    tracing::info!("Model not found, downloading Zipformer-vi bundle...");
    std::fs::create_dir_all(dir).context("Failed to create model directory")?;

    let archive_dest = dir.join(MODEL_BUNDLE_FILENAME);
    let url = format!(
        "https://github.com/{MODEL_BUNDLE_REPO}/releases/download/{MODEL_BUNDLE_RELEASE}/{MODEL_BUNDLE_FILENAME}"
    );
    stream_to_partial_then_finalize(
        &url,
        &archive_dest,
        Some(MODEL_BUNDLE_SHA256),
        MODEL_BUNDLE_FILENAME,
    )
    .await?;

    tracing::info!("Extracting bundle into {}", dir.display());
    extract_bundle(&archive_dest, dir)?;
    // Discard the archive once contents are in place — it is large and
    // re-downloads cheaply if we ever need a fresh copy.
    let _ = std::fs::remove_file(&archive_dest);

    // sherpa-onnx bundles may ship epoch-specific filenames (e.g.
    // `encoder-epoch-12-avg-8.int8.onnx`). Normalize to stable names so the
    // engine loader does not need to glob.
    normalize_model_filenames(dir)?;

    if !model_files_exist(dir) {
        anyhow::bail!(
            "Bundle extracted but expected files are still missing under {}",
            dir.display()
        );
    }
    tracing::info!("Model bundle ready");
    Ok(())
}

/// Ensure the speaker diarization model exists in `model_dir`, downloading from HuggingFace if missing.
///
/// Downloads `wespeaker_resnet34.onnx` from `onnx-community/wespeaker-voxceleb-resnet34-LM`
/// into `<model_dir>/wespeaker_resnet34.onnx.partial`, verifies its SHA-256 against
/// `SPEAKER_MODEL_SHA256`, and atomically renames it into place. On checksum mismatch or
/// crash the final path is never observable, so a subsequent `ensure_speaker_model` call
/// will re-download from scratch rather than loading a tampered model.
#[cfg(feature = "diarization")]
pub async fn ensure_speaker_model(model_dir: &str) -> Result<()> {
    let dir = Path::new(model_dir);
    let final_dest = dir.join(SPEAKER_MODEL_FILE);

    if final_dest.exists() {
        tracing::info!("Speaker model found at {}", final_dest.display());
        return Ok(());
    }

    tracing::info!("Speaker model not found, downloading from HuggingFace...");
    std::fs::create_dir_all(dir).context("Failed to create model directory")?;

    let url = format!("https://huggingface.co/{SPEAKER_HF_REPO}/resolve/main/onnx/model.onnx");
    stream_to_partial_then_finalize(
        &url,
        &final_dest,
        Some(SPEAKER_MODEL_SHA256),
        SPEAKER_MODEL_FILE,
    )
    .await
}

fn model_files_exist(dir: &Path) -> bool {
    MODEL_FILES.iter().all(|f| dir.join(f).exists())
}

/// Append `.partial` to a path; used as the staging location for downloads
/// so a crash between the write and the SHA verification never leaves a
/// half-written file under the final name that `model_files_exist` accepts.
fn partial_path(final_path: &Path) -> std::path::PathBuf {
    let mut s: std::ffi::OsString = final_path.as_os_str().to_owned();
    s.push(".partial");
    std::path::PathBuf::from(s)
}

/// Compute SHA-256 for a file synchronously, returning the lowercase hex digest.
/// Uses a buffered reader to avoid loading the entire file into memory.
fn sha256_file(path: &Path) -> Result<String> {
    use std::io::Read;
    let file = std::fs::File::open(path)
        .with_context(|| format!("Failed to open file for verification: {}", path.display()))?;
    let mut reader = std::io::BufReader::new(file);
    let mut hasher = Sha256::new();
    let mut buf = [0u8; 64 * 1024];
    loop {
        let n = reader.read(&mut buf).context("Read error during SHA-256")?;
        if n == 0 {
            break;
        }
        hasher.update(&buf[..n]);
    }
    Ok(format!("{:x}", hasher.finalize()))
}

/// Verify a staged `.partial` file against `expected_sha256` (when provided)
/// and atomically rename it into `final_path`. On mismatch the partial is
/// removed so a corrupt artefact cannot be mistaken for a good download on
/// restart. On success the partial no longer exists and `final_path` is the
/// only visible artefact.
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(())
}

/// Streaming download with SHA-256 verification and atomic rename.
async fn stream_to_partial_then_finalize(
    url: &str,
    final_dest: &Path,
    expected_sha256: Option<&str>,
    label: &str,
) -> Result<()> {
    let partial = partial_path(final_dest);

    if partial.exists() {
        let _ = tokio::fs::remove_file(&partial).await;
    }

    tracing::info!("Downloading {label}...");

    let client = reqwest::Client::builder()
        .connect_timeout(std::time::Duration::from_secs(30))
        .timeout(std::time::Duration::from_secs(600))
        .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);

    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")?;
        file.write_all(&chunk)
            .await
            .context("Failed to write chunk")?;
        downloaded += chunk.len() as u64;
        progress.update(chunk.len() as u64);
    }

    file.flush().await?;
    drop(file);
    progress.finish();
    tracing::info!("Wrote partial {} ({downloaded} bytes)", partial.display());

    finalize_download(&partial, final_dest, expected_sha256, label)?;
    tracing::info!("Saved {label}");

    Ok(())
}

/// After extraction, rename encoder/decoder/joiner ONNX files from
/// sherpa-onnx epoch-specific names (e.g. `encoder-epoch-12-avg-8.int8.onnx`)
/// to the stable names expected by the engine (`encoder.int8.onnx`, etc.).
fn normalize_model_filenames(dir: &Path) -> Result<()> {
    let renames: &[(&str, &str)] = &[
        ("encoder", "encoder.int8.onnx"),
        ("decoder", "decoder.onnx"),
        ("joiner", "joiner.int8.onnx"),
    ];
    for (prefix, target_name) in renames {
        let target = dir.join(target_name);
        if target.exists() {
            continue;
        }
        let mut candidates: Vec<_> = std::fs::read_dir(dir)
            .with_context(|| {
                format!(
                    "Failed to read model dir {} for normalization",
                    dir.display()
                )
            })?
            .filter_map(|e| e.ok())
            .filter(|e| {
                let name = e.file_name().to_string_lossy().to_lowercase();
                name.starts_with(prefix) && name.ends_with(".onnx")
            })
            .collect();
        // Prefer the shortest name (most generic) if multiple match.
        candidates.sort_by_key(|e| e.file_name().len());
        if let Some(entry) = candidates.first() {
            tracing::info!(
                "Renaming {} -> {}",
                entry.file_name().to_string_lossy(),
                target_name
            );
            std::fs::rename(entry.path(), &target).with_context(|| {
                format!(
                    "Failed to rename {} -> {}",
                    entry.path().display(),
                    target.display()
                )
            })?;
        }
    }
    Ok(())
}

/// Extract a `.tar.bz2` archive into `dest_dir`, stripping the top-level
/// directory ([`MODEL_BUNDLE_TOP_DIR`]) so files land at
/// `<dest_dir>/<file>` instead of
/// `<dest_dir>/<bundle_name>/<file>`. Refuses to write outside `dest_dir`
/// (rejects `..` and absolute paths) — the upstream tar is trusted but the
/// guard costs nothing.
fn extract_bundle(archive: &Path, dest_dir: &Path) -> Result<()> {
    let file = std::fs::File::open(archive)
        .with_context(|| format!("Failed to open archive {}", archive.display()))?;
    let bz = BzDecoder::new(file);
    let mut tar = Archive::new(bz);

    for entry in tar.entries().context("Failed to read tar entries")? {
        let mut entry = entry.context("Tar entry read error")?;
        let path = entry.path().context("Tar entry has no path")?.into_owned();

        // Strip the well-known top directory; skip the bare directory entry.
        let relative = match path.strip_prefix(MODEL_BUNDLE_TOP_DIR) {
            Ok(rel) if rel.as_os_str().is_empty() => continue,
            Ok(rel) => rel.to_path_buf(),
            Err(_) => path.clone(),
        };

        // Reject path traversal — `tar::Entry::unpack_in` already enforces
        // this when handed a base, but we materialise the destination
        // ourselves to keep the strip-prefix behaviour explicit.
        if relative
            .components()
            .any(|c| matches!(c, std::path::Component::ParentDir))
        {
            anyhow::bail!(
                "Refusing to extract {}: parent-dir component in archive entry",
                relative.display()
            );
        }
        if relative.is_absolute() {
            anyhow::bail!(
                "Refusing to extract {}: absolute path in archive entry",
                relative.display()
            );
        }

        // Reject symlinks and hard links — they can escape dest_dir even when
        // the path itself looks relative.
        let entry_type = entry.header().entry_type();
        if entry_type.is_symlink() || entry_type.is_hard_link() {
            anyhow::bail!(
                "Refusing to extract {}: symlink/hardlink entries are not allowed",
                relative.display()
            );
        }

        let target = dest_dir.join(&relative);
        if let Some(parent) = target.parent() {
            std::fs::create_dir_all(parent)
                .with_context(|| format!("Failed to create directory {}", parent.display()))?;
        }
        entry
            .unpack(&target)
            .with_context(|| format!("Failed to unpack {}", target.display()))?;
    }
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::io::Write;

    #[test]
    fn test_home_dir_returns_some() {
        assert!(
            home_dir().is_some(),
            "home_dir() must return Some on this platform"
        );
    }

    #[test]
    fn test_default_model_dir_contains_phostt() {
        let dir = default_model_dir();
        assert!(
            dir.contains(".phostt"),
            "default_model_dir() should contain \".phostt\", got: {dir}"
        );
    }

    #[test]
    fn test_download_progress_basic() {
        let mut progress = DownloadProgress::new(1_000_000);
        progress.update(500_000);
        assert_eq!(progress.current, 500_000);
        assert_eq!(progress.last_percent, 50);
        progress.finish();
    }

    #[test]
    fn test_download_progress_zero_total() {
        let mut progress = DownloadProgress::new(0);
        progress.update(100);
        assert_eq!(progress.last_percent, 0);
        progress.finish();
    }

    fn sha256_hex(bytes: &[u8]) -> String {
        let mut hasher = Sha256::new();
        hasher.update(bytes);
        format!("{:x}", hasher.finalize())
    }

    fn stage_partial(final_path: &Path, bytes: &[u8]) -> std::path::PathBuf {
        let partial = partial_path(final_path);
        let mut f = std::fs::File::create(&partial).expect("create partial");
        f.write_all(bytes).expect("write partial");
        f.sync_all().expect("sync partial");
        partial
    }

    #[test]
    fn test_partial_path_appends_suffix() {
        let p = partial_path(Path::new("/tmp/phostt/encoder.onnx"));
        assert_eq!(
            p,
            std::path::PathBuf::from("/tmp/phostt/encoder.onnx.partial"),
        );
    }

    #[test]
    fn test_download_writes_partial_then_renames() {
        let tmp = tempfile::tempdir().expect("tempdir");
        let final_path = tmp.path().join("encoder.onnx");
        let payload = b"fake encoder weights";
        let expected = sha256_hex(payload);

        let partial = stage_partial(&final_path, payload);
        assert!(partial.exists(), "precondition: partial is present");
        assert!(!final_path.exists(), "precondition: final is absent");

        finalize_download(&partial, &final_path, Some(&expected), "encoder.onnx")
            .expect("finalize should succeed");

        assert!(
            !partial.exists(),
            "partial must be gone after atomic rename"
        );
        assert!(
            final_path.exists(),
            "final path must exist after atomic rename"
        );
        assert_eq!(std::fs::read(&final_path).unwrap(), payload);
    }

    #[test]
    fn test_download_crash_before_rename_leaves_no_final_file() {
        let tmp = tempfile::tempdir().expect("tempdir");
        let final_path = tmp.path().join("encoder.onnx");
        let partial = stage_partial(&final_path, b"half-written junk");

        assert!(partial.exists(), "partial must exist to simulate crash");
        assert!(
            !final_path.exists(),
            "crash before rename must never leave the final artefact visible"
        );

        assert!(
            !model_files_exist(tmp.path()),
            "model_files_exist must not accept a staged partial"
        );
    }

    #[test]
    fn test_download_rejects_sha256_mismatch() {
        let tmp = tempfile::tempdir().expect("tempdir");
        let final_path = tmp.path().join("decoder.onnx");
        let payload = b"real bytes";
        let wrong_expected = sha256_hex(b"different bytes");

        let partial = stage_partial(&final_path, payload);

        let err = finalize_download(&partial, &final_path, Some(&wrong_expected), "decoder.onnx")
            .expect_err("mismatch must error");
        let msg = format!("{err}");
        assert!(msg.contains("SHA-256 mismatch"), "unexpected error: {msg}");

        assert!(!partial.exists(), "partial must be removed on SHA mismatch");
        assert!(
            !final_path.exists(),
            "final must never appear on SHA mismatch"
        );
    }

    #[test]
    fn test_download_atomic_on_success_without_checksum() {
        let tmp = tempfile::tempdir().expect("tempdir");
        let final_path = tmp.path().join("vocab.txt");
        let payload = b"token0\ntoken1\n";

        let partial = stage_partial(&final_path, payload);

        finalize_download(&partial, &final_path, None, "vocab.txt")
            .expect("no-checksum finalize should succeed");

        assert!(!partial.exists(), "partial must be gone after rename");
        assert!(final_path.exists(), "final path must exist");
        assert_eq!(std::fs::read(&final_path).unwrap(), payload);
    }

    #[test]
    fn test_sha256_file_matches_in_memory_hash() {
        let tmp = tempfile::tempdir().expect("tempdir");
        let p = tmp.path().join("blob");
        let payload = b"phostt-model-bytes";
        std::fs::write(&p, payload).unwrap();

        let got = sha256_file(&p).expect("sha256_file");
        let want = sha256_hex(payload);
        assert_eq!(got, want);
    }

    #[test]
    fn test_model_bundle_sha256_shape() {
        assert_eq!(
            MODEL_BUNDLE_SHA256.len(),
            64,
            "MODEL_BUNDLE_SHA256 must be a 64-char hex digest"
        );
        assert!(
            MODEL_BUNDLE_SHA256
                .chars()
                .all(|c| c.is_ascii_digit() || ('a'..='f').contains(&c)),
            "MODEL_BUNDLE_SHA256 must be lowercase hex; got: {MODEL_BUNDLE_SHA256}"
        );
    }

    #[test]
    fn test_model_files_list_matches_required_layout() {
        // Sanity-check the constant rather than the runtime extractor: every
        // file the engine needs must appear in MODEL_FILES so a missed entry
        // surfaces as a unit-test failure instead of a runtime mystery.
        for required in [
            "encoder.int8.onnx",
            "decoder.onnx",
            "joiner.int8.onnx",
            "bpe.model",
            "tokens.txt",
        ] {
            assert!(
                MODEL_FILES.contains(&required),
                "MODEL_FILES is missing required entry {required}"
            );
        }
    }

    #[test]
    fn test_extract_bundle_strips_top_dir_and_rejects_traversal() {
        // Build a tiny in-memory tar.bz2 that mirrors the upstream layout
        // (top-level dir + a few files) so we exercise the strip-prefix
        // and traversal-guard logic without touching the network.
        use bzip2::Compression;
        use bzip2::write::BzEncoder;
        use std::io::Cursor;
        use tar::Header;

        fn append(builder: &mut tar::Builder<&mut Vec<u8>>, path: &str, data: &[u8]) {
            let mut header = Header::new_gnu();
            header.set_size(data.len() as u64);
            header.set_mode(0o644);
            header.set_cksum();
            builder
                .append_data(&mut header, path, Cursor::new(data))
                .unwrap();
        }

        // Happy path: top-dir prefix is stripped.
        let tmp = tempfile::tempdir().expect("tempdir");
        let archive_path = tmp.path().join("bundle.tar.bz2");
        {
            let mut tar_buf = Vec::new();
            {
                let mut builder = tar::Builder::new(&mut tar_buf);
                append(
                    &mut builder,
                    &format!("{MODEL_BUNDLE_TOP_DIR}/encoder.int8.onnx"),
                    b"encoder-bytes",
                );
                append(
                    &mut builder,
                    &format!("{MODEL_BUNDLE_TOP_DIR}/test_wavs/0.wav"),
                    b"wav-bytes",
                );
                builder.finish().unwrap();
            }
            let mut bz = BzEncoder::new(
                std::fs::File::create(&archive_path).unwrap(),
                Compression::fast(),
            );
            std::io::copy(&mut Cursor::new(tar_buf), &mut bz).unwrap();
            bz.finish().unwrap();
        }
        let dest = tmp.path().join("out");
        std::fs::create_dir_all(&dest).unwrap();
        extract_bundle(&archive_path, &dest).expect("happy-path extract");
        assert!(dest.join("encoder.int8.onnx").exists());
        assert!(dest.join("test_wavs").join("0.wav").exists());
        assert!(
            !dest.join(MODEL_BUNDLE_TOP_DIR).exists(),
            "top dir must be stripped, not nested"
        );
    }
}