From 4d2dd3a504dd28a18fac5777f0b974bd79a0fec4 Mon Sep 17 00:00:00 2001 From: nonamexishere Date: Fri, 11 Sep 2026 01:36:33 +0300 Subject: [PATCH 1/2] feat(ai): add verified local model artifact store Content-addressed blobs under an injected root. SHA-256 must match before a model is ready. Interrupted staging and truncated files stay unlistable. Duplicate imports reuse one blob. No HTTP, no auto-download, no Tauri commands. --- src-tauri/Cargo.lock | 1 + src-tauri/Cargo.toml | 3 + src-tauri/src/ai/mod.rs | 1 + src-tauri/src/ai/store.rs | 323 +++++++++++++++ src-tauri/src/ai_store_tests.rs | 684 ++++++++++++++++++++++++++++++++ src-tauri/src/error.rs | 25 +- src-tauri/src/lib.rs | 2 + 7 files changed, 1037 insertions(+), 2 deletions(-) create mode 100644 src-tauri/src/ai/store.rs create mode 100644 src-tauri/src/ai_store_tests.rs diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 227957a..6afc9ce 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -2854,6 +2854,7 @@ dependencies = [ "lopdf", "serde", "serde_json", + "sha2", "tauri", "tauri-build", "tauri-plugin-dialog", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 1fba0fa..36d7401 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -22,6 +22,9 @@ tauri-plugin-opener = "2" serde = { version = "1", features = ["derive"] } serde_json = "1" +# SHA-256 for model-store integrity (issue #83). Not a runtime / HTTP crate. +sha2 = "0.10" + # Cross-platform available-disk-space query. fs2 = "0.4" diff --git a/src-tauri/src/ai/mod.rs b/src-tauri/src/ai/mod.rs index 5229c80..64076c1 100644 --- a/src-tauri/src/ai/mod.rs +++ b/src-tauri/src/ai/mod.rs @@ -9,6 +9,7 @@ //! document path and not file bytes. mod fake; +pub mod store; use crate::error::AppError; diff --git a/src-tauri/src/ai/store.rs b/src-tauri/src/ai/store.rs new file mode 100644 index 0000000..49e2ad5 --- /dev/null +++ b/src-tauri/src/ai/store.rs @@ -0,0 +1,323 @@ +//! Content-addressed on-disk model store. Injected root; no HTTP. +//! +//! Layout: `/{blobs,manifests,staging}/`. Identity is SHA-256. +//! Ready means a manifest and blob are both present and the blob re-hashes +//! to the checksum. Leftover `staging/` is never ready. + +use std::fs::{self, File}; +use std::io::{Read, Write}; +use std::path::{Path, PathBuf}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; + +use crate::error::AppError; + +const SCHEMA_VERSION: u32 = 1; +const CHECKSUM_LEN: usize = 64; +const LICENSE_PLACEHOLDER: &str = "imported"; +const RUNTIME_COMPAT_PLACEHOLDER: &str = "any"; + +/// Versioned model metadata. `schema_version` must be 1. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ModelManifest { + pub schema_version: u32, + pub size: u64, + pub license: String, + pub runtime_compat: String, + pub checksum: String, +} + +impl ModelManifest { + pub fn parse(json: &str) -> Result { + let parsed: Self = serde_json::from_str(json) + .map_err(|err| AppError::ai_model_invalid().with_details(err.to_string()))?; + if parsed.schema_version != SCHEMA_VERSION { + return Err(AppError::ai_model_invalid().with_details(format!( + "unsupported schema_version {}", + parsed.schema_version + ))); + } + validate_checksum(&parsed.checksum)?; + Ok(parsed) + } +} + +/// Persistent store under an injected root. Tests pass a temp path. +#[derive(Debug, Clone)] +pub struct ModelStore { + root: PathBuf, +} + +impl ModelStore { + pub fn open(root: &Path) -> Result { + let store = Self { + root: root.to_path_buf(), + }; + fs::create_dir_all(store.blobs_dir())?; + fs::create_dir_all(store.manifests_dir())?; + fs::create_dir_all(store.staging_dir())?; + Ok(store) + } + + pub fn install_from_reader( + &self, + r: &mut impl Read, + expected_sha256: &str, + ) -> Result { + let expected = validate_checksum(expected_sha256)?; + let staging = self.new_staging_dir()?; + let _guard = StagingGuard(staging.clone()); + let staged_blob = staging.join("blob"); + let (size, actual) = write_hashed(r, &staged_blob)?; + if actual != expected { + return Err(AppError::ai_model_invalid().with_details(format!( + "checksum mismatch: expected {expected}, got {actual}" + ))); + } + + let manifest = ModelManifest { + schema_version: SCHEMA_VERSION, + size, + license: LICENSE_PLACEHOLDER.to_string(), + runtime_compat: RUNTIME_COMPAT_PLACEHOLDER.to_string(), + checksum: expected.clone(), + }; + self.commit_ready(&manifest, &staged_blob)?; + Ok(manifest) + } + + pub fn import_file( + &self, + path: &Path, + expected_sha256: &str, + ) -> Result { + let mut file = File::open(path).map_err(|err| { + AppError::ai_model_invalid() + .with_details(format!("could not read {}: {err}", path.display())) + })?; + self.install_from_reader(&mut file, expected_sha256) + } + + pub fn list_ready(&self) -> Result, AppError> { + let mut ready = Vec::new(); + let dir = match fs::read_dir(self.manifests_dir()) { + Ok(entries) => entries, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => { + return Ok(ready); + } + Err(err) => return Err(err.into()), + }; + for entry in dir { + let entry = entry?; + let path = entry.path(); + if !path.is_file() { + continue; + } + let Some(stem) = path.file_stem().and_then(|s| s.to_str()) else { + continue; + }; + if path.extension().and_then(|e| e.to_str()) != Some("json") { + continue; + } + if validate_checksum(stem).is_err() { + continue; + } + if let Some(manifest) = self.ready_manifest(stem)? { + ready.push(manifest); + } + } + ready.sort_by(|a, b| a.checksum.cmp(&b.checksum)); + Ok(ready) + } + + pub fn get(&self, checksum: &str) -> Result { + let checksum = validate_checksum(checksum)?; + self.ready_manifest(&checksum)? + .ok_or_else(AppError::ai_model_not_found) + } + + pub fn loadable_path(&self, checksum: &str) -> Result { + let manifest = self.get(checksum)?; + Ok(self.blob_path(&manifest.checksum)) + } + + pub fn remove(&self, checksum: &str) -> Result<(), AppError> { + let checksum = validate_checksum(checksum)?; + let blob = self.blob_path(&checksum); + let manifest = self.manifest_path(&checksum); + let staging = self.staging_dir().join(&checksum); + let blob_exists = blob.exists(); + let manifest_exists = manifest.exists(); + let staging_exists = staging.exists(); + if !blob_exists && !manifest_exists && !staging_exists { + return Err(AppError::ai_model_not_found()); + } + if blob_exists { + fs::remove_file(&blob)?; + } + if manifest_exists { + fs::remove_file(&manifest)?; + } + if staging_exists { + fs::remove_dir_all(&staging)?; + } + Ok(()) + } + + fn blobs_dir(&self) -> PathBuf { + self.root.join("blobs") + } + + fn manifests_dir(&self) -> PathBuf { + self.root.join("manifests") + } + + fn staging_dir(&self) -> PathBuf { + self.root.join("staging") + } + + fn blob_path(&self, checksum: &str) -> PathBuf { + self.blobs_dir().join(checksum) + } + + fn manifest_path(&self, checksum: &str) -> PathBuf { + self.manifests_dir().join(format!("{checksum}.json")) + } + + fn new_staging_dir(&self) -> Result { + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0); + let dir = self + .staging_dir() + .join(format!("install-{}-{nanos}", std::process::id())); + fs::create_dir_all(&dir)?; + Ok(dir) + } + + fn commit_ready(&self, manifest: &ModelManifest, staged_blob: &Path) -> Result<(), AppError> { + fs::create_dir_all(self.blobs_dir())?; + fs::create_dir_all(self.manifests_dir())?; + let dest_blob = self.blob_path(&manifest.checksum); + if !self.blob_matches(&dest_blob, manifest)? { + if dest_blob.exists() { + fs::remove_file(&dest_blob)?; + } + fs::rename(staged_blob, &dest_blob)?; + } + let dest_manifest = self.manifest_path(&manifest.checksum); + let json = serde_json::to_string(manifest) + .map_err(|err| AppError::ai_model_invalid().with_details(err.to_string()))?; + let staged_manifest = staged_blob + .parent() + .unwrap_or_else(|| Path::new(".")) + .join("manifest.json"); + fs::write(&staged_manifest, json.as_bytes())?; + if dest_manifest.exists() { + fs::remove_file(&dest_manifest)?; + } + fs::rename(&staged_manifest, &dest_manifest)?; + Ok(()) + } + + fn ready_manifest(&self, checksum: &str) -> Result, AppError> { + let man_path = self.manifest_path(checksum); + if !man_path.is_file() { + return Ok(None); + } + let json = match fs::read_to_string(&man_path) { + Ok(text) => text, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(err) => return Err(err.into()), + }; + let manifest = match ModelManifest::parse(&json) { + Ok(parsed) => parsed, + Err(_) => return Ok(None), + }; + if manifest.checksum != checksum { + return Ok(None); + } + if !self.blob_matches(&self.blob_path(checksum), &manifest)? { + return Ok(None); + } + Ok(Some(manifest)) + } + + fn blob_matches(&self, path: &Path, manifest: &ModelManifest) -> Result { + if !path.is_file() { + return Ok(false); + } + let meta = fs::metadata(path)?; + if meta.len() != manifest.size { + return Ok(false); + } + let actual = hash_path(path)?; + Ok(actual == manifest.checksum) + } +} + +struct StagingGuard(PathBuf); + +impl Drop for StagingGuard { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } +} + +fn validate_checksum(value: &str) -> Result { + let valid = value.len() == CHECKSUM_LEN + && value + .bytes() + .all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f')); + if valid { + Ok(value.to_string()) + } else { + Err(AppError::ai_model_invalid() + .with_details("checksum must be 64 lowercase hex characters")) + } +} + +fn write_hashed(src: &mut impl Read, dest: &Path) -> Result<(u64, String), AppError> { + let mut file = File::create(dest)?; + let mut hasher = Sha256::new(); + let mut buf = [0u8; 64 * 1024]; + let mut size = 0u64; + loop { + let n = src.read(&mut buf)?; + if n == 0 { + break; + } + file.write_all(&buf[..n])?; + hasher.update(&buf[..n]); + size += n as u64; + } + file.flush()?; + Ok((size, hex_lower(&hasher.finalize()))) +} + +fn hash_path(path: &Path) -> Result { + let mut file = File::open(path)?; + let mut hasher = Sha256::new(); + let mut buf = [0u8; 64 * 1024]; + loop { + let n = file.read(&mut buf)?; + if n == 0 { + break; + } + hasher.update(&buf[..n]); + } + Ok(hex_lower(&hasher.finalize())) +} + +fn hex_lower(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 +} diff --git a/src-tauri/src/ai_store_tests.rs b/src-tauri/src/ai_store_tests.rs new file mode 100644 index 0000000..a182298 --- /dev/null +++ b/src-tauri/src/ai_store_tests.rs @@ -0,0 +1,684 @@ +//! Unit tests for `crate::ai::store` (issue #83). +//! +//! Locked Approach A names: `ModelStore::open`, `import_file`, +//! `install_from_reader`, `list_ready`, `get`, `loadable_path`, `remove`, +//! and the versioned `ModelManifest` JSON (`schema_version`, size, license, +//! `runtime_compat`, SHA-256 hex `checksum`). +//! The store module may be missing until impl; that compile failure is +//! fail-today. Do not edit `ai_tests.rs`. + +use std::collections::HashSet; +use std::path::{Path, PathBuf}; + +use crate::ai::store::{ModelManifest, ModelStore}; + +const FIXTURE: &[u8] = b"offpdf-store-fixture-v1"; +const FIXTURE_SHA256: &str = "9a32fd0b4e68056601b986dc617f8d3be37fe0af3903d98cdda02fff552c884e"; +const WRONG_SHA256: &str = "0000000000000000000000000000000000000000000000000000000000000000"; + +fn manifest_dir() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) +} + +fn ai_src_dir() -> PathBuf { + manifest_dir().join("src/ai") +} + +fn walk_files(dir: &Path, out: &mut Vec) { + let entries = match std::fs::read_dir(dir) { + Ok(entries) => entries, + Err(_) => return, + }; + for entry in entries { + let path = match entry { + Ok(entry) => entry.path(), + Err(_) => continue, + }; + if path.is_dir() { + walk_files(&path, out); + } else { + out.push(path); + } + } +} + +fn tree_size(dir: &Path) -> u64 { + let mut total = 0u64; + let entries = match std::fs::read_dir(dir) { + Ok(entries) => entries, + Err(_) => return 0, + }; + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + total += tree_size(&path); + } else if let Ok(meta) = entry.metadata() { + total += meta.len(); + } + } + total +} + +fn file_count(dir: &Path) -> usize { + let mut n = 0usize; + let entries = match std::fs::read_dir(dir) { + Ok(entries) => entries, + Err(_) => return 0, + }; + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + n += file_count(&path); + } else { + n += 1; + } + } + n +} + +fn child_names(dir: &Path) -> HashSet { + let mut names = HashSet::new(); + let entries = match std::fs::read_dir(dir) { + Ok(entries) => entries, + Err(_) => return names, + }; + for entry in entries.flatten() { + names.insert(entry.file_name().to_string_lossy().into_owned()); + } + names +} + +fn is_under(child: &Path, root: &Path) -> bool { + let child = match child.canonicalize() { + Ok(path) => path, + Err(_) => return false, + }; + let root = match root.canonicalize() { + Ok(path) => path, + Err(_) => return false, + }; + child.starts_with(root) +} + +fn assert_ai_model_error(err: &crate::error::AppError, must_id: &str) { + assert!( + err.code.starts_with("AI_MODEL_"), + "{must_id}: AppError.code must be AI_MODEL_*, got {}", + err.code + ); + assert_ne!( + err.code, "ENGINE_FAILED", + "{must_id}: must not reuse the PDF-engine code" + ); + assert_ne!( + err.code, "AI_FAILED", + "{must_id}: must not reuse the Fake generate-fail code" + ); + assert!( + !err.title.is_empty(), + "{must_id}: AppError.title must be non-empty" + ); + assert!( + !err.message.is_empty(), + "{must_id}: AppError.message must be non-empty" + ); +} + +fn valid_manifest_json() -> String { + format!( + r#"{{"schema_version":1,"size":{size},"license":"CC0-1.0","runtime_compat":"test","checksum":"{sum}"}}"#, + size = FIXTURE.len(), + sum = FIXTURE_SHA256 + ) +} + +struct Scratch(PathBuf); + +impl Scratch { + fn new(name: &str) -> Self { + let dir = std::env::temp_dir().join(format!( + "offpdf-store-{}-{}-{}", + name, + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0) + )); + std::fs::create_dir_all(&dir).unwrap(); + Self(dir) + } + + fn path(&self) -> &Path { + &self.0 + } +} + +impl Drop for Scratch { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} + +/// store-manifest-parse — valid fixture JSON parses; truncated / missing +/// fields / bad schema_version rejected. +#[test] +fn store_manifest_parse() { + let parsed = ModelManifest::parse(&valid_manifest_json()) + .unwrap_or_else(|err| panic!("store-manifest-parse: valid fixture JSON must parse: {err}")); + assert_eq!( + parsed.schema_version, 1, + "store-manifest-parse: schema_version must be 1" + ); + assert_eq!( + parsed.size, + FIXTURE.len() as u64, + "store-manifest-parse: size must match the fixture byte length" + ); + assert_eq!( + parsed.license, "CC0-1.0", + "store-manifest-parse: license must round-trip" + ); + assert_eq!( + parsed.runtime_compat, "test", + "store-manifest-parse: runtime_compat is an opaque string" + ); + assert_eq!( + parsed.checksum, FIXTURE_SHA256, + "store-manifest-parse: checksum must be the SHA-256 hex" + ); + + let truncated = r#"{"schema_version":1,"size":23,"license":"CC0-1.0""#; + let err = ModelManifest::parse(truncated) + .expect_err("store-manifest-parse: truncated JSON must be rejected"); + assert_ai_model_error(&err, "store-manifest-parse"); + + let not_json = "not-json"; + let err = ModelManifest::parse(not_json) + .expect_err("store-manifest-parse: non-JSON must be rejected"); + assert_ai_model_error(&err, "store-manifest-parse"); + + let missing = [ + r#"{"size":23,"license":"CC0-1.0","runtime_compat":"test","checksum":"9a32fd0b4e68056601b986dc617f8d3be37fe0af3903d98cdda02fff552c884e"}"#, + r#"{"schema_version":1,"license":"CC0-1.0","runtime_compat":"test","checksum":"9a32fd0b4e68056601b986dc617f8d3be37fe0af3903d98cdda02fff552c884e"}"#, + r#"{"schema_version":1,"size":23,"runtime_compat":"test","checksum":"9a32fd0b4e68056601b986dc617f8d3be37fe0af3903d98cdda02fff552c884e"}"#, + r#"{"schema_version":1,"size":23,"license":"CC0-1.0","checksum":"9a32fd0b4e68056601b986dc617f8d3be37fe0af3903d98cdda02fff552c884e"}"#, + r#"{"schema_version":1,"size":23,"license":"CC0-1.0","runtime_compat":"test"}"#, + ]; + for json in missing { + let err = ModelManifest::parse(json) + .expect_err("store-manifest-parse: JSON missing a required field must be rejected"); + assert_ai_model_error(&err, "store-manifest-parse"); + } + + for version in [0_u32, 999] { + let json = format!( + r#"{{"schema_version":{version},"size":23,"license":"CC0-1.0","runtime_compat":"test","checksum":"{FIXTURE_SHA256}"}}"# + ); + let err = ModelManifest::parse(&json).expect_err(&format!( + "store-manifest-parse: schema_version {version} must be rejected" + )); + assert_ai_model_error(&err, "store-manifest-parse"); + } +} + +/// store-import-ready — import FIXTURE + matching sha256 → only ready entry; +/// loadable_path under the injected root. +#[test] +fn store_import_ready() { + let root = Scratch::new("import-ready"); + let store = ModelStore::open(root.path()) + .unwrap_or_else(|err| panic!("store-import-ready: open injected root must succeed: {err}")); + + let mut reader = std::io::Cursor::new(FIXTURE); + let installed = store + .install_from_reader(&mut reader, FIXTURE_SHA256) + .unwrap_or_else(|err| { + panic!("store-import-ready: install_from_reader + matching sha256: {err}") + }); + assert_eq!( + installed.checksum, FIXTURE_SHA256, + "store-import-ready: checksum is the id" + ); + assert_eq!( + installed.size, + FIXTURE.len() as u64, + "store-import-ready: size must be the fixture length" + ); + + let ready = store + .list_ready() + .unwrap_or_else(|err| panic!("store-import-ready: list_ready: {err}")); + assert_eq!( + ready.len(), + 1, + "store-import-ready: matching import must be the only ready entry" + ); + assert_eq!(ready[0].checksum, FIXTURE_SHA256); + + let got = store + .get(FIXTURE_SHA256) + .unwrap_or_else(|err| panic!("store-import-ready: get(checksum) after import: {err}")); + assert_eq!(got.checksum, FIXTURE_SHA256); + assert_eq!(got.size, FIXTURE.len() as u64); + + let path = store + .loadable_path(FIXTURE_SHA256) + .unwrap_or_else(|err| panic!("store-import-ready: loadable_path: {err}")); + assert!( + is_under(&path, root.path()), + "store-import-ready: loadable_path {} must be under the injected root {}", + path.display(), + root.path().display() + ); + let bytes = std::fs::read(&path) + .unwrap_or_else(|err| panic!("store-import-ready: loadable blob must exist: {err}")); + assert_eq!( + bytes, FIXTURE, + "store-import-ready: loadable blob must be the fixture bytes" + ); + + let src_dir = Scratch::new("import-src"); + let src = src_dir.path().join("model-bytes"); + std::fs::write(&src, FIXTURE).unwrap(); + let names_before = child_names(src_dir.path()); + let imported = store + .import_file(&src, FIXTURE_SHA256) + .unwrap_or_else(|err| panic!("store-import-ready: import_file + matching sha256: {err}")); + assert_eq!(imported.checksum, FIXTURE_SHA256); + assert!( + src.exists(), + "store-import-ready: import_file copies, it must not move the source" + ); + assert_eq!( + child_names(src_dir.path()), + names_before, + "store-import-ready: import must not write next to the source file" + ); + let ready = store + .list_ready() + .unwrap_or_else(|err| panic!("store-import-ready: list_ready after file import: {err}")); + assert_eq!( + ready.len(), + 1, + "store-import-ready: same bytes via import_file stay one ready entry" + ); + let loadable = store + .loadable_path(FIXTURE_SHA256) + .unwrap_or_else(|err| panic!("store-import-ready: loadable_path after file import: {err}")); + assert!( + is_under(&loadable, root.path()), + "store-import-ready: file-import loadable_path must stay under the store root" + ); + assert_ne!( + loadable.canonicalize().ok(), + src.canonicalize().ok(), + "store-import-ready: loadable_path must not be the caller's source file" + ); +} + +/// store-checksum-mismatch-not-ready — wrong digest → AppError (AI_MODEL_*, +/// not ENGINE_FAILED), not in list_ready. +#[test] +fn store_checksum_mismatch_not_ready() { + let root = Scratch::new("checksum-mismatch"); + let store = ModelStore::open(root.path()) + .unwrap_or_else(|err| panic!("store-checksum-mismatch-not-ready: open: {err}")); + + let mut reader = std::io::Cursor::new(FIXTURE); + let err = store + .install_from_reader(&mut reader, WRONG_SHA256) + .expect_err("store-checksum-mismatch-not-ready: wrong digest must return AppError, not Ok"); + assert_ai_model_error(&err, "store-checksum-mismatch-not-ready"); + + let ready = store + .list_ready() + .unwrap_or_else(|err| panic!("store-checksum-mismatch-not-ready: list_ready: {err}")); + assert!( + ready.is_empty(), + "store-checksum-mismatch-not-ready: mismatched install must not appear in list_ready, got {} entries", + ready.len() + ); + assert!( + store.get(WRONG_SHA256).is_err(), + "store-checksum-mismatch-not-ready: wrong digest must not be get-able" + ); + assert!( + store.get(FIXTURE_SHA256).is_err(), + "store-checksum-mismatch-not-ready: fixture checksum must not be ready after a mismatch" + ); +} + +/// store-partial-not-ready — leftover staging/ or a truncated blob → not in +/// list_ready. +#[test] +fn store_partial_not_ready() { + let leftover = Scratch::new("partial-staging"); + std::fs::create_dir_all(leftover.path().join("staging").join("interrupted")).unwrap(); + std::fs::write( + leftover + .path() + .join("staging") + .join("interrupted") + .join("blob"), + FIXTURE, + ) + .unwrap(); + let store = ModelStore::open(leftover.path()) + .unwrap_or_else(|err| panic!("store-partial-not-ready: open leftover staging root: {err}")); + let ready = store.list_ready().unwrap_or_else(|err| { + panic!("store-partial-not-ready: list_ready on leftover staging: {err}") + }); + assert!( + ready.is_empty(), + "store-partial-not-ready: leftover staging/ must not appear in list_ready, got {} entries", + ready.len() + ); + + let truncated = Scratch::new("partial-trunc"); + let store = ModelStore::open(truncated.path()) + .unwrap_or_else(|err| panic!("store-partial-not-ready: open truncate root: {err}")); + let mut reader = std::io::Cursor::new(FIXTURE); + store + .install_from_reader(&mut reader, FIXTURE_SHA256) + .unwrap_or_else(|err| { + panic!("store-partial-not-ready: matching install before truncate: {err}") + }); + let blobs = truncated.path().join("blobs"); + assert!( + blobs.is_dir(), + "store-partial-not-ready: committed blob must live under /blobs" + ); + let mut blob_files = Vec::new(); + walk_files(&blobs, &mut blob_files); + assert!( + !blob_files.is_empty(), + "store-partial-not-ready: blobs/ must contain the committed fixture" + ); + for path in &blob_files { + std::fs::write(path, &FIXTURE[..5]).unwrap_or_else(|err| { + panic!( + "store-partial-not-ready: truncate {}: {err}", + path.display() + ) + }); + } + let store = ModelStore::open(truncated.path()) + .unwrap_or_else(|err| panic!("store-partial-not-ready: reopen after truncate: {err}")); + let ready = store.list_ready().unwrap_or_else(|err| { + panic!("store-partial-not-ready: list_ready after truncated blob: {err}") + }); + assert!( + ready.is_empty(), + "store-partial-not-ready: truncated blob must not appear in list_ready, got {} entries", + ready.len() + ); +} + +/// store-dedup-no-waste — import the same bytes twice → one blob; blob-tree +/// size does not double. +#[test] +fn store_dedup_no_waste() { + let root = Scratch::new("dedup"); + let store = ModelStore::open(root.path()) + .unwrap_or_else(|err| panic!("store-dedup-no-waste: open: {err}")); + + let mut first = std::io::Cursor::new(FIXTURE); + store + .install_from_reader(&mut first, FIXTURE_SHA256) + .unwrap_or_else(|err| panic!("store-dedup-no-waste: first install: {err}")); + let blobs = root.path().join("blobs"); + let size_after_first = tree_size(&blobs); + let files_after_first = file_count(&blobs); + assert!( + size_after_first >= FIXTURE.len() as u64, + "store-dedup-no-waste: blobs/ must hold at least the fixture after the first import" + ); + assert!( + files_after_first >= 1, + "store-dedup-no-waste: first import must create a blob file" + ); + + let mut second = std::io::Cursor::new(FIXTURE); + store + .install_from_reader(&mut second, FIXTURE_SHA256) + .unwrap_or_else(|err| panic!("store-dedup-no-waste: second install: {err}")); + + let ready = store + .list_ready() + .unwrap_or_else(|err| panic!("store-dedup-no-waste: list_ready: {err}")); + assert_eq!( + ready.len(), + 1, + "store-dedup-no-waste: same checksum is one ready model, got {} entries", + ready.len() + ); + + let size_after_second = tree_size(&blobs); + let files_after_second = file_count(&blobs); + assert_eq!( + files_after_second, files_after_first, + "store-dedup-no-waste: second import must not add another blob file" + ); + assert_eq!( + size_after_second, size_after_first, + "store-dedup-no-waste: blob-tree size must not grow on a duplicate import" + ); + assert!( + size_after_second < (FIXTURE.len() as u64).saturating_mul(2), + "store-dedup-no-waste: blob-tree size must not reach two copies of the fixture" + ); +} + +/// store-remove-all — remove deletes that model's manifest, blob, staging; +/// list_ready empty. Second remove is a structured not-found. +#[test] +fn store_remove_all() { + let root = Scratch::new("remove"); + let store = + ModelStore::open(root.path()).unwrap_or_else(|err| panic!("store-remove-all: open: {err}")); + + let mut reader = std::io::Cursor::new(FIXTURE); + store + .install_from_reader(&mut reader, FIXTURE_SHA256) + .unwrap_or_else(|err| panic!("store-remove-all: install: {err}")); + + std::fs::create_dir_all(root.path().join("staging").join(FIXTURE_SHA256)).unwrap(); + std::fs::write( + root.path() + .join("staging") + .join(FIXTURE_SHA256) + .join("leftover"), + FIXTURE, + ) + .unwrap(); + + store + .remove(FIXTURE_SHA256) + .unwrap_or_else(|err| panic!("store-remove-all: first remove: {err}")); + + let ready = store + .list_ready() + .unwrap_or_else(|err| panic!("store-remove-all: list_ready after remove: {err}")); + assert!( + ready.is_empty(), + "store-remove-all: list_ready must be empty after remove, got {} entries", + ready.len() + ); + assert_eq!( + tree_size(&root.path().join("blobs")), + 0, + "store-remove-all: blob for this checksum must be gone" + ); + assert_eq!( + tree_size(&root.path().join("manifests")), + 0, + "store-remove-all: manifest for this checksum must be gone" + ); + assert_eq!( + tree_size(&root.path().join("staging")), + 0, + "store-remove-all: leftover staging for this model must be gone" + ); + + let err = store + .remove(FIXTURE_SHA256) + .expect_err("store-remove-all: second remove must be a structured not-found, not Ok"); + assert_ai_model_error(&err, "store-remove-all"); +} + +/// store-no-auto-download — open empty root + list_ready: no URL, no default +/// model, no writes outside the injected root. +#[test] +fn store_no_auto_download() { + let parent = Scratch::new("no-auto-parent"); + let root = parent.path().join("store-root"); + std::fs::create_dir_all(&root).unwrap(); + let parent_before = child_names(parent.path()); + + let store = ModelStore::open(&root).unwrap_or_else(|err| { + panic!("store-no-auto-download: open empty root must not need a URL: {err}") + }); + let ready = store + .list_ready() + .unwrap_or_else(|err| panic!("store-no-auto-download: list_ready on empty root: {err}")); + assert!( + ready.is_empty(), + "store-no-auto-download: empty root must have no default model, got {} entries", + ready.len() + ); + + assert_eq!( + child_names(parent.path()), + parent_before, + "store-no-auto-download: open + list_ready must not write outside the injected root" + ); + assert!( + !root.ends_with("jobs"), + "store-no-auto-download: injected root must not be /jobs" + ); +} + +/// store-no-network-no-shell — `src-tauri/src/ai/**` still has no +/// reqwest/ureq/std::net/Command/XAI_API_KEY/cloud host. store.rs must exist. +#[test] +fn store_no_network_no_shell() { + let ai_dir = ai_src_dir(); + assert!( + ai_dir.is_dir(), + "store-no-network-no-shell: src-tauri/src/ai/ must exist so the source lock can scan it" + ); + + let store_rs = ai_dir.join("store.rs"); + let store_mod = ai_dir.join("store").join("mod.rs"); + assert!( + store_rs.is_file() || store_mod.is_file(), + "store-no-network-no-shell: src-tauri/src/ai/store.rs (or store/mod.rs) must exist" + ); + + let mut files = Vec::new(); + walk_files(&ai_dir, &mut files); + let rust_files: Vec = files + .into_iter() + .filter(|path| path.extension().is_some_and(|ext| ext == "rs")) + .collect(); + assert!( + !rust_files.is_empty(), + "store-no-network-no-shell: src-tauri/src/ai/ must contain Rust sources to scan" + ); + + const FORBIDDEN: &[&str] = &[ + "reqwest", + "ureq", + "std::net", + "std::process::Command", + "XAI_API_KEY", + "api.openai.com", + "api.x.ai", + "api.anthropic.com", + "generativelanguage.googleapis.com", + "api.groq.com", + ]; + + let mut hits = Vec::new(); + for path in &rust_files { + let src = std::fs::read_to_string(path).unwrap_or_else(|err| { + panic!("store-no-network-no-shell: read {}: {err}", path.display()) + }); + for token in FORBIDDEN { + if src.contains(token) { + hits.push(format!("{}: {token}", path.display())); + } + } + } + assert!( + hits.is_empty(), + "store-no-network-no-shell: src-tauri/src/ai/** must not contain network, shell, or cloud-host tokens; found {hits:?}" + ); +} + +/// store-no-gguf-in-clone — walk test sources / src-tauri: no `.gguf` and no +/// huge weight. Tests use in-memory FIXTURE. +#[test] +fn store_no_gguf_in_clone() { + assert_eq!( + FIXTURE, b"offpdf-store-fixture-v1", + "store-no-gguf-in-clone: tests must use the tiny in-memory FIXTURE" + ); + assert!( + FIXTURE.len() < 64, + "store-no-gguf-in-clone: FIXTURE must stay tiny, got {} bytes", + FIXTURE.len() + ); + + let crate_root = manifest_dir(); + let mut files = Vec::new(); + walk_src_tauri_lock(&crate_root, &mut files); + + let mut gguf = Vec::new(); + let mut huge = Vec::new(); + for path in &files { + let name = path.file_name().and_then(|n| n.to_str()).unwrap_or(""); + if name.to_ascii_lowercase().ends_with(".gguf") + || name.to_ascii_lowercase().ends_with(".ggml") + || name.to_ascii_lowercase().ends_with(".safetensors") + { + gguf.push(path.display().to_string()); + } + if let Ok(meta) = path.metadata() { + if meta.len() >= 1_000_000 { + huge.push(format!("{} ({} bytes)", path.display(), meta.len())); + } + } + } + assert!( + gguf.is_empty(), + "store-no-gguf-in-clone: src-tauri must not contain weight files; found {gguf:?}" + ); + assert!( + huge.is_empty(), + "store-no-gguf-in-clone: src-tauri/src and test fixtures must not add a multi-MB weight; found {huge:?}" + ); +} + +fn walk_src_tauri_lock(dir: &Path, out: &mut Vec) { + let entries = match std::fs::read_dir(dir) { + Ok(entries) => entries, + Err(_) => return, + }; + for entry in entries { + let path = match entry { + Ok(entry) => entry.path(), + Err(_) => continue, + }; + let name = path.file_name().and_then(|n| n.to_str()).unwrap_or(""); + if name == "target" || name == "vendor" || name == "resources" { + continue; + } + if path.is_dir() { + walk_src_tauri_lock(&path, out); + } else { + out.push(path); + } + } +} diff --git a/src-tauri/src/error.rs b/src-tauri/src/error.rs index 7913d6f..17de9ec 100644 --- a/src-tauri/src/error.rs +++ b/src-tauri/src/error.rs @@ -74,7 +74,9 @@ impl AppError { "Not enough disk space", "There may not be enough free space to complete this operation safely.", ) - .with_suggestion("Free up disk space, or choose an output folder on a drive with more room.") + .with_suggestion( + "Free up disk space, or choose an output folder on a drive with more room.", + ) } pub fn engine_failed(details: impl Into) -> Self { @@ -93,7 +95,9 @@ impl AppError { "PDF engine not found", "The bundled qpdf engine could not be located and qpdf is not on your PATH.", ) - .with_suggestion("Reinstall OffPDF, or install qpdf so it is available on your system PATH.") + .with_suggestion( + "Reinstall OffPDF, or install qpdf so it is available on your system PATH.", + ) } pub fn cancelled() -> Self { @@ -120,6 +124,23 @@ impl AppError { ) } + pub fn ai_model_invalid() -> Self { + Self::new( + "AI_MODEL_INVALID", + "Model is not valid", + "The model file or manifest failed verification.", + ) + .with_suggestion("Import the file again, or check that the checksum matches.") + } + + pub fn ai_model_not_found() -> Self { + Self::new( + "AI_MODEL_NOT_FOUND", + "Model not found", + "No installed model matches that checksum.", + ) + } + pub fn io(context: &str, err: impl std::fmt::Display) -> Self { Self::new( "IO_ERROR", diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 40e2955..a7ab2ce 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -38,6 +38,8 @@ mod models; mod pdf_engine; mod utils; +#[cfg(test)] +mod ai_store_tests; #[cfg(test)] mod ai_tests; From 155f857df03f9b5d541a8593060363264f2b750e Mon Sep 17 00:00:00 2001 From: nonamexishere Date: Fri, 11 Sep 2026 02:02:09 +0300 Subject: [PATCH 2/2] fix(ai): keep ready dest manifest and skip prepare dirs in weight lock Rename-over the dest manifest instead of deleting it first. The no-GGUF walk ignores gitignored bundle trees so local prepare scripts do not fail cargo test. --- src-tauri/Cargo.toml | 2 +- src-tauri/src/ai/store.rs | 12 +++++++++--- src-tauri/src/ai_store_tests.rs | 22 +++++++++++++++++----- 3 files changed, 27 insertions(+), 9 deletions(-) diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 36d7401..57a4650 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -22,7 +22,7 @@ tauri-plugin-opener = "2" serde = { version = "1", features = ["derive"] } serde_json = "1" -# SHA-256 for model-store integrity (issue #83). Not a runtime / HTTP crate. +# Content-addressed model checksums. sha2 = "0.10" # Cross-platform available-disk-space query. diff --git a/src-tauri/src/ai/store.rs b/src-tauri/src/ai/store.rs index 49e2ad5..b2bd876 100644 --- a/src-tauri/src/ai/store.rs +++ b/src-tauri/src/ai/store.rs @@ -209,6 +209,15 @@ impl ModelStore { fs::rename(staged_blob, &dest_blob)?; } let dest_manifest = self.manifest_path(&manifest.checksum); + if dest_manifest.exists() { + if let Ok(existing) = fs::read_to_string(&dest_manifest) { + if let Ok(parsed) = ModelManifest::parse(&existing) { + if parsed == *manifest { + return Ok(()); + } + } + } + } let json = serde_json::to_string(manifest) .map_err(|err| AppError::ai_model_invalid().with_details(err.to_string()))?; let staged_manifest = staged_blob @@ -216,9 +225,6 @@ impl ModelStore { .unwrap_or_else(|| Path::new(".")) .join("manifest.json"); fs::write(&staged_manifest, json.as_bytes())?; - if dest_manifest.exists() { - fs::remove_file(&dest_manifest)?; - } fs::rename(&staged_manifest, &dest_manifest)?; Ok(()) } diff --git a/src-tauri/src/ai_store_tests.rs b/src-tauri/src/ai_store_tests.rs index a182298..3bcfb27 100644 --- a/src-tauri/src/ai_store_tests.rs +++ b/src-tauri/src/ai_store_tests.rs @@ -4,8 +4,7 @@ //! `install_from_reader`, `list_ready`, `get`, `loadable_path`, `remove`, //! and the versioned `ModelManifest` JSON (`schema_version`, size, license, //! `runtime_compat`, SHA-256 hex `checksum`). -//! The store module may be missing until impl; that compile failure is -//! fail-today. Do not edit `ai_tests.rs`. +//! Do not edit `ai_tests.rs`. use std::collections::HashSet; use std::path::{Path, PathBuf}; @@ -617,8 +616,9 @@ fn store_no_network_no_shell() { ); } -/// store-no-gguf-in-clone — walk test sources / src-tauri: no `.gguf` and no -/// huge weight. Tests use in-memory FIXTURE. +/// store-no-gguf-in-clone — walk crate sources (skip gitignored bundle dirs): +/// no `.gguf` / `.ggml` / `.safetensors` and no huge weight. Tests use +/// in-memory FIXTURE. #[test] fn store_no_gguf_in_clone() { assert_eq!( @@ -672,7 +672,19 @@ fn walk_src_tauri_lock(dir: &Path, out: &mut Vec) { Err(_) => continue, }; let name = path.file_name().and_then(|n| n.to_str()).unwrap_or(""); - if name == "target" || name == "vendor" || name == "resources" { + // Local prepare-* trees drop multi-MB tools here; they are not weights. + if matches!( + name, + "binaries" + | "share" + | "tesseract" + | "libreoffice" + | "windows-runtime" + | "gen" + | "target" + | "vendor" + | "resources" + ) { continue; } if path.is_dir() {