chore(hardening): tighten security and footprint defaults

This commit is contained in:
2026-04-24 19:03:57 +01:00
parent 55b34d8ffc
commit b333c6229e
36 changed files with 8702 additions and 254 deletions

View File

@@ -21,6 +21,13 @@ use kon_core::types::AudioSamples;
/// input silently returned `Ok` with whatever had decoded before the
/// failure — flagged by the 2026-04-22 review (RB-09).
pub fn decode_audio_file(path: &Path) -> Result<AudioSamples> {
decode_audio_file_limited(path, None)
}
pub fn decode_audio_file_limited(
path: &Path,
max_duration_secs: Option<f64>,
) -> Result<AudioSamples> {
let file = File::open(path)
.map_err(|e| KonError::AudioDecodeFailed(format!("Cannot open file: {e}")))?;
let mss = MediaSourceStream::new(Box::new(file), Default::default());
@@ -30,13 +37,48 @@ pub fn decode_audio_file(path: &Path) -> Result<AudioSamples> {
hint.with_extension(ext);
}
decode_media_stream(mss, &hint)
decode_media_stream(mss, &hint, max_duration_secs)
}
pub fn probe_audio_duration_secs(path: &Path) -> Result<Option<f64>> {
let file = File::open(path)
.map_err(|e| KonError::AudioDecodeFailed(format!("Cannot open file: {e}")))?;
let mss = MediaSourceStream::new(Box::new(file), Default::default());
let mut hint = Hint::new();
if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
hint.with_extension(ext);
}
let probed = symphonia::default::get_probe()
.format(
&hint,
mss,
&FormatOptions::default(),
&MetadataOptions::default(),
)
.map_err(|e| KonError::AudioDecodeFailed(format!("Unsupported format: {e}")))?;
let track = probed
.format
.default_track()
.ok_or_else(|| KonError::AudioDecodeFailed("No audio track found".into()))?;
let sample_rate = track
.codec_params
.sample_rate
.ok_or_else(|| KonError::AudioDecodeFailed("Unknown sample rate".into()))?;
Ok(track
.codec_params
.n_frames
.map(|frames| frames as f64 / sample_rate as f64))
}
/// Decode from an already-constructed `MediaSourceStream`. Split out so
/// tests can inject a custom `MediaSource` (for example, one that
/// returns a mid-stream I/O error) to verify error propagation.
fn decode_media_stream(mss: MediaSourceStream, hint: &Hint) -> Result<AudioSamples> {
fn decode_media_stream(
mss: MediaSourceStream,
hint: &Hint,
max_duration_secs: Option<f64>,
) -> Result<AudioSamples> {
let probed = symphonia::default::get_probe()
.format(
hint,
@@ -61,6 +103,7 @@ fn decode_media_stream(mss: MediaSourceStream, hint: &Hint) -> Result<AudioSampl
}
let track_id = track.id;
let max_samples = max_duration_secs.map(|secs| (secs * sample_rate as f64).ceil() as usize);
let mut decoder = symphonia::default::get_codecs()
.make(&track.codec_params, &DecoderOptions::default())
@@ -111,6 +154,15 @@ fn decode_media_stream(mss: MediaSourceStream, hint: &Hint) -> Result<AudioSampl
samples.push(sum / channels as f32);
}
}
if max_samples
.map(|limit| samples.len() > limit)
.unwrap_or(false)
{
return Err(KonError::AudioDecodeFailed(format!(
"Audio is longer than the {:.0} minute import limit",
max_duration_secs.unwrap_or(0.0) / 60.0
)));
}
}
if samples.is_empty() {
@@ -222,7 +274,7 @@ mod tests {
let mut hint = Hint::new();
hint.with_extension("wav");
let result = decode_media_stream(mss, &hint);
let result = decode_media_stream(mss, &hint, None);
assert!(
result.is_err(),
"mid-stream I/O error must surface, got: {result:?}"

View File

@@ -8,7 +8,7 @@ pub mod wav;
pub use capture::{AudioChunk, CaptureRuntimeError, DeviceInfo, MicrophoneCapture};
pub use concurrency::decode_and_resample;
pub use decode::decode_audio_file;
pub use decode::{decode_audio_file, decode_audio_file_limited, probe_audio_duration_secs};
pub use resample::resample_to_16khz;
pub use streaming_resample::StreamingResampler;
pub use vad::SpeechDetector;

View File

@@ -2,6 +2,7 @@ pub mod constants;
pub mod error;
pub mod hardware;
pub mod model_registry;
pub mod paths;
pub mod process_watch;
pub mod recommendation;
pub mod types;

View File

@@ -40,8 +40,8 @@ pub struct ModelFile {
pub filename: &'static str,
pub url: &'static str,
pub size: Megabytes,
/// SHA256 hex digest for integrity verification. None to skip check.
pub sha256: Option<&'static str>,
/// SHA256 hex digest for integrity verification.
pub sha256: &'static str,
}
/// All metadata for a single downloadable model.
@@ -74,27 +74,27 @@ static ALL_MODELS: LazyLock<Vec<ModelEntry>> = LazyLock::new(|| {
files: vec![
ModelFile {
filename: "encoder-model.int8.onnx",
url: "https://huggingface.co/istupakov/parakeet-tdt-0.6b-v2-onnx/resolve/main/encoder-model.int8.onnx",
url: "https://huggingface.co/istupakov/parakeet-tdt-0.6b-v2-onnx/resolve/0bbb45a3365852604aef28b538a8f066f4ccaa85/encoder-model.int8.onnx",
size: Megabytes(620),
sha256: None,
sha256: "3e0581fda6ab843888b51e56d7ee78b6d5bc3237ec113af1f732d1d5286aa155",
},
ModelFile {
filename: "decoder_joint-model.int8.onnx",
url: "https://huggingface.co/istupakov/parakeet-tdt-0.6b-v2-onnx/resolve/main/decoder_joint-model.int8.onnx",
url: "https://huggingface.co/istupakov/parakeet-tdt-0.6b-v2-onnx/resolve/0bbb45a3365852604aef28b538a8f066f4ccaa85/decoder_joint-model.int8.onnx",
size: Megabytes(3),
sha256: None,
sha256: "a449f49acd68979d418651dd2dcb737cc0f1bf0225e009e29ee326354edbf7d3",
},
ModelFile {
filename: "nemo128.onnx",
url: "https://huggingface.co/istupakov/parakeet-tdt-0.6b-v2-onnx/resolve/main/nemo128.onnx",
url: "https://huggingface.co/istupakov/parakeet-tdt-0.6b-v2-onnx/resolve/0bbb45a3365852604aef28b538a8f066f4ccaa85/nemo128.onnx",
size: Megabytes(1),
sha256: None,
sha256: "a9fde1486ebfcc08f328d75ad4610c67835fea58c73ba57e3209a6f6cf019e9f",
},
ModelFile {
filename: "vocab.txt",
url: "https://huggingface.co/istupakov/parakeet-tdt-0.6b-v2-onnx/resolve/main/vocab.txt",
url: "https://huggingface.co/istupakov/parakeet-tdt-0.6b-v2-onnx/resolve/0bbb45a3365852604aef28b538a8f066f4ccaa85/vocab.txt",
size: Megabytes(1),
sha256: None,
sha256: "ec182b70dd42113aff6c5372c75cac58c952443eb22322f57bbd7f53977d497d",
},
],
description: "Fastest local model — near-instant transcription",
@@ -110,9 +110,9 @@ static ALL_MODELS: LazyLock<Vec<ModelEntry>> = LazyLock::new(|| {
languages: LanguageSupport::EnglishOnly,
files: vec![ModelFile {
filename: "ggml-tiny.en.bin",
url: "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-tiny.en.bin",
url: "https://huggingface.co/ggerganov/whisper.cpp/resolve/5359861c739e955e79d9a303bcbc70fb988958b1/ggml-tiny.en.bin",
size: Megabytes(75),
sha256: None,
sha256: "921e4cf8686fdd993dcd081a5da5b6c365bfde1162e72b08d75ac75289920b1f",
}],
description: "Bundled with app — works instantly",
},
@@ -127,9 +127,9 @@ static ALL_MODELS: LazyLock<Vec<ModelEntry>> = LazyLock::new(|| {
languages: LanguageSupport::EnglishOnly,
files: vec![ModelFile {
filename: "ggml-base.en.bin",
url: "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-base.en.bin",
url: "https://huggingface.co/ggerganov/whisper.cpp/resolve/5359861c739e955e79d9a303bcbc70fb988958b1/ggml-base.en.bin",
size: Megabytes(142),
sha256: None,
sha256: "a03779c86df3323075f5e796cb2ce5029f00ec8869eee3fdfb897afe36c6d002",
}],
description: "Good balance of speed and accuracy",
},
@@ -144,9 +144,9 @@ static ALL_MODELS: LazyLock<Vec<ModelEntry>> = LazyLock::new(|| {
languages: LanguageSupport::EnglishOnly,
files: vec![ModelFile {
filename: "ggml-small.en.bin",
url: "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-small.en.bin",
url: "https://huggingface.co/ggerganov/whisper.cpp/resolve/5359861c739e955e79d9a303bcbc70fb988958b1/ggml-small.en.bin",
size: Megabytes(466),
sha256: None,
sha256: "c6138d6d58ecc8322097e0f987c32f1be8bb0a18532a3f88f734d1bbf9c41e5d",
}],
description: "Accuracy-first English transcription",
},
@@ -161,9 +161,9 @@ static ALL_MODELS: LazyLock<Vec<ModelEntry>> = LazyLock::new(|| {
languages: LanguageSupport::EnglishOnly,
files: vec![ModelFile {
filename: "ggml-distil-small.en.bin",
url: "https://huggingface.co/distil-whisper/distil-small.en/resolve/main/ggml-distil-small.en.bin",
url: "https://huggingface.co/distil-whisper/distil-small.en/resolve/9e4a67ca4569c30be43a3fe7fba1621e504f0093/ggml-distil-small.en.bin",
size: Megabytes(336),
sha256: None,
sha256: "7691eb11167ab7aaf6b3e05d8266f2fd9ad89c550e433f86ac266ebdee6c970a",
}],
description: "Small accuracy, ~6\u{00d7} faster — distilled variant",
},
@@ -178,9 +178,9 @@ static ALL_MODELS: LazyLock<Vec<ModelEntry>> = LazyLock::new(|| {
languages: LanguageSupport::EnglishOnly,
files: vec![ModelFile {
filename: "ggml-medium.en.bin",
url: "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-medium.en.bin",
url: "https://huggingface.co/ggerganov/whisper.cpp/resolve/5359861c739e955e79d9a303bcbc70fb988958b1/ggml-medium.en.bin",
size: Megabytes(1500),
sha256: None,
sha256: "cc37e93478338ec7700281a7ac30a10128929eb8f427dda2e865faa8f6da4356",
}],
description: "Best Whisper accuracy — needs 4+ GB RAM",
},
@@ -195,9 +195,9 @@ static ALL_MODELS: LazyLock<Vec<ModelEntry>> = LazyLock::new(|| {
languages: LanguageSupport::EnglishOnly,
files: vec![ModelFile {
filename: "ggml-distil-large-v3.bin",
url: "https://huggingface.co/distil-whisper/distil-large-v3-ggml/resolve/main/ggml-distil-large-v3.bin",
url: "https://huggingface.co/distil-whisper/distil-large-v3-ggml/resolve/0d78dd96ed9fc152325f63b53788fec3b43de031/ggml-distil-large-v3.bin",
size: Megabytes(1550),
sha256: None,
sha256: "2883a11b90fb10ed592d826edeaee7d2929bf1ab985109fe9e1e7b4d2b69a298",
}],
description: "Near large-v3 accuracy at ~6\u{00d7} the speed",
},
@@ -213,3 +213,35 @@ pub fn all_models() -> &'static [ModelEntry] {
pub fn find_model(id: &ModelId) -> Option<&'static ModelEntry> {
ALL_MODELS.iter().find(|m| &m.id == id)
}
#[cfg(test)]
mod tests {
use super::all_models;
#[test]
fn every_model_file_has_sha256_and_pinned_url() {
for model in all_models() {
for file in &model.files {
assert_eq!(
file.sha256.len(),
64,
"{} / {} must carry a SHA256 digest",
model.id,
file.filename
);
assert!(
file.sha256.chars().all(|c| c.is_ascii_hexdigit()),
"{} / {} SHA256 must be hex",
model.id,
file.filename
);
assert!(
!file.url.contains("/resolve/main/"),
"{} / {} must pin a Hugging Face revision",
model.id,
file.filename
);
}
}
}
}

125
crates/core/src/paths.rs Normal file
View File

@@ -0,0 +1,125 @@
use std::path::PathBuf;
use crate::types::ModelId;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AppPaths {
app_data_dir: PathBuf,
}
impl AppPaths {
pub fn current() -> Self {
Self {
app_data_dir: resolve_app_data_dir(),
}
}
pub fn app_data_dir(&self) -> PathBuf {
self.app_data_dir.clone()
}
pub fn database_path(&self) -> PathBuf {
self.app_data_dir.join("kon.db")
}
pub fn recordings_dir(&self) -> PathBuf {
self.app_data_dir.join("recordings")
}
pub fn crashes_dir(&self) -> PathBuf {
self.app_data_dir.join("crashes")
}
pub fn logs_dir(&self) -> PathBuf {
self.app_data_dir.join("logs")
}
pub fn diagnostic_reports_dir(&self) -> PathBuf {
self.app_data_dir.join("diagnostic-reports")
}
pub fn models_dir(&self) -> PathBuf {
self.app_data_dir.join("models")
}
pub fn speech_model_dir(&self, id: &ModelId) -> PathBuf {
self.models_dir().join(id.as_str())
}
pub fn llm_models_dir(&self) -> PathBuf {
self.models_dir().join("llm")
}
pub fn migration_sentinel(&self, name: &str) -> PathBuf {
self.app_data_dir.join(format!(".{name}.sentinel"))
}
}
pub fn app_paths() -> AppPaths {
AppPaths::current()
}
pub fn app_data_dir() -> PathBuf {
app_paths().app_data_dir()
}
fn resolve_app_data_dir() -> PathBuf {
#[cfg(target_os = "windows")]
{
let local_app_data = std::env::var("LOCALAPPDATA").unwrap_or_else(|_| ".".to_string());
return PathBuf::from(local_app_data).join("kon");
}
#[cfg(target_os = "macos")]
{
let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".to_string());
return PathBuf::from(home)
.join("Library")
.join("Application Support")
.join("Kon");
}
#[cfg(target_os = "linux")]
{
let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".to_string());
let legacy = PathBuf::from(&home).join(".kon");
if legacy.exists() {
return legacy;
}
if let Ok(xdg) = std::env::var("XDG_DATA_HOME") {
if !xdg.is_empty() {
return PathBuf::from(xdg).join("kon");
}
}
PathBuf::from(home).join(".local").join("share").join("kon")
}
#[cfg(not(any(target_os = "windows", target_os = "macos", target_os = "linux")))]
{
let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".to_string());
PathBuf::from(home).join(".kon")
}
}
#[cfg(test)]
mod tests {
use super::AppPaths;
use crate::types::ModelId;
use std::path::PathBuf;
#[test]
fn derives_all_paths_from_one_base() {
let paths = AppPaths {
app_data_dir: PathBuf::from("/tmp/kon-test"),
};
assert_eq!(paths.database_path(), PathBuf::from("/tmp/kon-test/kon.db"));
assert_eq!(
paths.speech_model_dir(&ModelId::new("whisper-base-en")),
PathBuf::from("/tmp/kon-test/models/whisper-base-en")
);
assert_eq!(
paths.llm_models_dir(),
PathBuf::from("/tmp/kon-test/models/llm")
);
}
}

View File

@@ -4,7 +4,7 @@ version = "0.1.0"
edition = "2021"
[dependencies]
dirs = "6"
kon-core = { path = "../core" }
encoding_rs = "0.8"
futures-util = "0.3"
llama-cpp-2 = { version = "0.1.144", default-features = false, features = ["openmp", "vulkan"] }

View File

@@ -2,6 +2,7 @@ use std::fmt;
use std::io;
use std::path::{Path, PathBuf};
use std::str::FromStr;
use std::sync::{LazyLock, Mutex};
use futures_util::StreamExt;
use serde::{Deserialize, Serialize};
@@ -158,6 +159,36 @@ const ALL_MODELS: &[LlmModelId] = &[
LlmModelId::Qwen3_14BQ5,
];
static ACTIVE_DOWNLOADS: LazyLock<Mutex<std::collections::HashSet<LlmModelId>>> =
LazyLock::new(|| Mutex::new(std::collections::HashSet::new()));
struct DownloadReservation {
id: LlmModelId,
}
impl DownloadReservation {
fn acquire(id: LlmModelId) -> Result<Self, DownloadError> {
let mut active = ACTIVE_DOWNLOADS
.lock()
.map_err(|_| DownloadError::Http("download lock poisoned".into()))?;
if !active.insert(id) {
return Err(DownloadError::Http(format!(
"download already in progress for {}",
id.as_str()
)));
}
Ok(Self { id })
}
}
impl Drop for DownloadReservation {
fn drop(&mut self) {
if let Ok(mut active) = ACTIVE_DOWNLOADS.lock() {
active.remove(&self.id);
}
}
}
pub fn all_models() -> &'static [LlmModelId] {
ALL_MODELS
}
@@ -189,20 +220,7 @@ pub fn recommend_tier(total_ram_bytes: u64, total_vram_bytes: Option<u64>) -> Ll
}
pub fn model_dir() -> PathBuf {
if cfg!(target_os = "windows") {
std::env::var("LOCALAPPDATA")
.map(PathBuf::from)
.unwrap_or_else(|_| PathBuf::from("."))
.join("kon")
.join("models")
.join("llm")
} else {
dirs::home_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join(".kon")
.join("models")
.join("llm")
}
kon_core::paths::app_paths().llm_models_dir()
}
pub fn model_path(id: LlmModelId) -> PathBuf {
@@ -235,6 +253,7 @@ pub async fn download_model<F>(id: LlmModelId, on_progress: F) -> Result<(), Dow
where
F: FnMut(u64, u64) + Send + 'static,
{
let _reservation = DownloadReservation::acquire(id)?;
let dest = model_path(id);
tokio::fs::create_dir_all(model_dir()).await?;

View File

@@ -1,79 +1,28 @@
use std::path::PathBuf;
/// Resolve the per-user app data directory, following each OS's convention:
///
/// - Windows: `%LOCALAPPDATA%\kon\` — e.g. `C:\Users\Jake\AppData\Local\kon`
/// - macOS: `~/Library/Application Support/Kon/`
/// - Linux: `$XDG_DATA_HOME/kon` or `~/.local/share/kon` (XDG Base Directory),
/// with a fallback to the legacy `~/.kon/` if it already exists, so
/// existing installs keep working.
/// - Other Unix: `~/.kon/`
///
/// TODO: Consolidate with `crates/transcription/src/model_manager.rs::dirs_path()`
/// into a shared helper in `crates/core/` to avoid duplicating platform-specific
/// path logic across crates.
pub fn app_data_dir() -> PathBuf {
#[cfg(target_os = "windows")]
{
let local_app_data = std::env::var("LOCALAPPDATA").unwrap_or_else(|_| ".".to_string());
return PathBuf::from(local_app_data).join("kon");
}
#[cfg(target_os = "macos")]
{
let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".to_string());
return PathBuf::from(home)
.join("Library")
.join("Application Support")
.join("Kon");
}
#[cfg(target_os = "linux")]
{
let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".to_string());
// Honour the legacy ~/.kon/ if it exists on disk so existing
// installs are not orphaned. New installs follow XDG.
let legacy = PathBuf::from(&home).join(".kon");
if legacy.exists() {
return legacy;
}
// XDG Base Directory: $XDG_DATA_HOME/kon or default ~/.local/share/kon
if let Ok(xdg) = std::env::var("XDG_DATA_HOME") {
if !xdg.is_empty() {
return PathBuf::from(xdg).join("kon");
}
}
PathBuf::from(home).join(".local").join("share").join("kon")
}
#[cfg(not(any(target_os = "windows", target_os = "macos", target_os = "linux")))]
{
let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".to_string());
PathBuf::from(home).join(".kon")
}
kon_core::paths::app_paths().app_data_dir()
}
/// Path to the SQLite database file.
pub fn database_path() -> PathBuf {
app_data_dir().join("kon.db")
kon_core::paths::app_paths().database_path()
}
/// Directory for saved audio recordings.
pub fn recordings_dir() -> PathBuf {
app_data_dir().join("recordings")
kon_core::paths::app_paths().recordings_dir()
}
/// Directory for crash dumps written by the Rust panic hook.
/// Each crash is a single text file: `<unix-ts>-<short-id>.crash`.
/// Used by the diagnostic-report bundler in Settings → About.
pub fn crashes_dir() -> PathBuf {
app_data_dir().join("crashes")
kon_core::paths::app_paths().crashes_dir()
}
/// Directory for the rolling Rust log file (kon.log + rotated kon.log.1, etc).
/// Subscribers configured in src-tauri/src/lib.rs at startup.
pub fn logs_dir() -> PathBuf {
app_data_dir().join("logs")
kon_core::paths::app_paths().logs_dir()
}

View File

@@ -24,7 +24,7 @@ transcribe-rs = { version = "0.3", default-features = false, features = ["onnx"]
tokio = { version = "1", features = ["rt", "sync"] }
# Model downloads
reqwest = { version = "0.12", features = ["stream"] }
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "stream"] }
futures-util = "0.3"
# Download integrity verification

View File

@@ -1,34 +1,51 @@
use std::collections::HashSet;
use std::path::{Path, PathBuf};
use std::sync::{LazyLock, Mutex};
use kon_core::error::{KonError, Result};
use kon_core::model_registry::{find_model, ModelFile};
use kon_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(|_| KonError::DownloadFailed("download lock poisoned".into()))?;
if !active.insert(id.clone()) {
return Err(KonError::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%/kon/models
/// Unix: ~/.kon/models
pub fn models_dir() -> PathBuf {
if cfg!(target_os = "windows") {
let local_app_data = std::env::var("LOCALAPPDATA").unwrap_or_else(|_| ".".to_string());
PathBuf::from(local_app_data).join("kon").join("models")
} else {
dirs_path().join("models")
}
}
fn dirs_path() -> PathBuf {
if cfg!(target_os = "windows") {
let local_app_data = std::env::var("LOCALAPPDATA").unwrap_or_else(|_| ".".to_string());
PathBuf::from(local_app_data).join("kon")
} else {
let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".to_string());
PathBuf::from(home).join(".kon")
}
kon_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 {
models_dir().join(id.as_str())
kon_core::paths::app_paths().speech_model_dir(id)
}
/// Check whether all files for a model have been downloaded.
@@ -39,6 +56,7 @@ pub fn is_downloaded(id: &ModelId) -> bool {
};
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.
@@ -61,6 +79,7 @@ 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(|| KonError::ModelNotFound(id.clone()))?;
let dir = model_dir(id);
@@ -69,36 +88,70 @@ pub async fn download(
for file in &entry.files {
let dest = dir.join(file.filename);
if dest.exists() {
if let Some(expected_sha) = file.sha256 {
// 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(expected_sha) => continue,
Ok(_actual) => {
// Corrupt — remove + fall through to re-download.
let _ = std::fs::remove_file(&dest);
}
Err(e) => {
return Err(KonError::DownloadFailed(format!(
"failed to verify existing {}: {e}",
file.filename
)));
}
// 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) => {
let _ = std::fs::remove_file(&dest);
}
Err(e) => {
return Err(KonError::DownloadFailed(format!(
"failed to verify existing {}: {e}",
file.filename
)));
}
} else {
// No checksum — honour the existing file as-is; the
// engine will barf on load if it's broken.
continue;
}
}
download_file(file, &dest, id, &progress).await?;
}
write_verified_manifest(entry, &dir)?;
Ok(())
}
fn verified_manifest_path(dir: &Path) -> PathBuf {
dir.join(".kon-verified")
}
fn verified_manifest_matches(entry: &kon_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: &kon_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));
}
std::fs::write(
verified_manifest_path(dir),
format!("{}\n", lines.join("\n")),
)
}
/// 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> {
@@ -151,9 +204,7 @@ async fn download_file(
let mut request = client.get(file.url);
// If we have a partial file and no SHA256 to verify (can't verify partial),
// request a range resume. If SHA256 is set, we restart to ensure integrity.
let resuming = existing_bytes > 0 && file.sha256.is_none();
let resuming = existing_bytes > 0;
if resuming {
request = request.header("Range", format!("bytes={existing_bytes}-"));
}
@@ -223,19 +274,23 @@ async fn download_file(
std::fs::File::create(&part_path)?
};
// Incremental SHA256 — only when a checksum is provided
let mut hasher = file.sha256.map(|_| Sha256::new());
// If resuming without SHA256, we can't hash the already-downloaded portion,
// but we also don't need to — we only hash when sha256 is set, and we
// restart from scratch in that case.
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| KonError::DownloadFailed(e.to_string()))?;
std::io::Write::write_all(&mut out, &chunk)?;
if let Some(ref mut h) = hasher {
h.update(&chunk);
}
hasher.update(&chunk);
downloaded += chunk.len() as u64;
let percent = if total_bytes > 0 {
@@ -258,17 +313,13 @@ async fn download_file(
drop(out);
// Verify SHA256 if provided
if let (Some(expected), Some(hasher)) = (file.sha256, hasher) {
let actual = format!("{:x}", hasher.finalize());
if actual != expected {
// Delete corrupt file so next attempt starts fresh
let _ = std::fs::remove_file(&part_path);
return Err(KonError::DownloadFailed(format!(
"SHA256 mismatch for {}: expected {}, got {}",
file.filename, expected, actual
)));
}
let actual = format!("{:x}", hasher.finalize());
if actual != file.sha256 {
let _ = std::fs::remove_file(&part_path);
return Err(KonError::DownloadFailed(format!(
"SHA256 mismatch for {}: expected {}, got {}",
file.filename, file.sha256, actual
)));
}
// Atomic rename — file is complete and verified
@@ -428,7 +479,7 @@ mod tests {
filename: leak(dest.file_name().unwrap().to_string_lossy().into_owned()),
url: leak(format!("http://{addr}/fixture.bin")),
size: kon_core::types::Megabytes(0),
sha256: None, // resume path only kicks in when sha256 is absent
sha256: leak(expected_sha.clone()),
};
let id = ModelId::new("test-fixture");
@@ -437,9 +488,6 @@ mod tests {
let bytes = std::fs::read(&dest).unwrap();
assert_eq!(bytes, body);
assert!(!part.exists());
// Confirm the full file hash matches what we would have got via
// a clean download — gives the resume path indirect integrity
// coverage even when the ModelFile has no sha256 set.
assert_eq!(sha256_of_file(&dest).unwrap(), expected_sha);
}
@@ -451,6 +499,7 @@ mod tests {
// 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();
@@ -465,7 +514,7 @@ mod tests {
filename: leak(dest.file_name().unwrap().to_string_lossy().into_owned()),
url: leak(format!("http://{addr}/fixture.bin")),
size: kon_core::types::Megabytes(0),
sha256: None,
sha256: leak(expected_sha),
};
let id = ModelId::new("test-fixture");
@@ -520,7 +569,7 @@ mod tests {
filename: leak(dest.file_name().unwrap().to_string_lossy().into_owned()),
url: leak(format!("http://{addr}/fixture.bin")),
size: kon_core::types::Megabytes(0),
sha256: None,
sha256: leak("0".repeat(64)),
};
let id = ModelId::new("test-fixture");
@@ -548,7 +597,7 @@ mod tests {
filename: leak(dest.file_name().unwrap().to_string_lossy().into_owned()),
url: leak(format!("http://{addr}/fixture.bin")),
size: kon_core::types::Megabytes(0),
sha256: Some(leak("deadbeef".repeat(8))),
sha256: leak("deadbeef".repeat(8)),
};
let id = ModelId::new("test-fixture");