From 7ca7113b3224958494327eb5fa080a2237dcadd1 Mon Sep 17 00:00:00 2001 From: Daniel Voshart <205798369+voshart@users.noreply.github.com> Date: Mon, 5 Oct 2026 21:43:11 -0400 Subject: [PATCH] Faces: model commands - list, inspect, install behind the licence, select, remove, on/off `faces.models.list / inspect / install / remove / select` and `faces.enable`, all reachable from the UI, CLI, control channel and MCP. Models live in a folder the host provides (`Session::face_models_dir`): one subfolder each with the model, its manifest and a record of what the user accepted, plus settings.json. - install needs `acknowledged: true` (the user has been shown the licence), copies the file under a .part name, checks the copy's SHA-256 against the original, then renames; a failure leaves nothing behind. - ids are validated before they become folder names (and a manifest that disagrees with its folder is ignored); remove only ever deletes a folder that has a model manifest. A manifest id can no longer be `.`, `..` or start with a dot. - inspect describes any .onnx without writing anything; unusable files are an answer ("unsupported" with the reason), not a crash. - the list says plainly that running a model is not built in yet. `faces::synthetic` makes a minimal valid ONNX for tests (and the later self-check). Co-Authored-By: Claude Sonnet 5.5 --- Cargo.lock | 1 + crates/engine/Cargo.toml | 1 + crates/engine/src/cmd/face_models.rs | 284 +++++++++++++++++++++++++ crates/engine/src/cmd/mod.rs | 2 + crates/engine/src/lib.rs | 5 + crates/engine/src/tests_face_models.rs | 173 +++++++++++++++ crates/faces/src/known.rs | 3 + crates/faces/src/lib.rs | 1 + crates/faces/src/manifest.rs | 19 +- crates/faces/src/onnx.rs | 65 +----- crates/faces/src/synthetic.rs | 70 ++++++ 11 files changed, 557 insertions(+), 67 deletions(-) create mode 100644 crates/engine/src/cmd/face_models.rs create mode 100644 crates/engine/src/tests_face_models.rs create mode 100644 crates/faces/src/synthetic.rs diff --git a/Cargo.lock b/Cargo.lock index 767cac84..4a8498ea 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2882,6 +2882,7 @@ dependencies = [ "lightcraft-codecs", "lightcraft-color", "lightcraft-develop", + "lightcraft-faces", "lightcraft-geom", "lightcraft-gpu", "lightcraft-merge", diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index a916d180..cef805f6 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -22,6 +22,7 @@ lightcraft-preview = { workspace = true } lightcraft-codecs = { workspace = true } lightcraft-raw = { workspace = true } lightcraft-meta = { workspace = true } +lightcraft-faces = { workspace = true } miniz_oxide = { workspace = true } lightcraft-merge = { workspace = true } rayon = { workspace = true } diff --git a/crates/engine/src/cmd/face_models.rs b/crates/engine/src/cmd/face_models.rs new file mode 100644 index 00000000..de4a8d22 --- /dev/null +++ b/crates/engine/src/cmd/face_models.rs @@ -0,0 +1,284 @@ +//! Face model commands (`faces.*`): which models exist, inspecting a file the user dropped in, installing +//! it once its licence is accepted, removing it, and the faces on/off setting. +//! +//! Models live in the folder the host provides (`Session::face_models_dir`): one subfolder per model with +//! `model.onnx`, `face-model.json` (its manifest) and `installed.json` (what the user accepted), plus +//! `settings.json` beside them. Model files and manifests are hostile input: sizes are capped, ids are +//! validated before they become folder names, and nothing outside a model's own folder is ever touched. + +use std::io::Read; +use std::path::{Path, PathBuf}; + +use lightcraft_faces::hash::sha256_file; +use lightcraft_faces::manifest::{self, MAX_MANIFEST_BYTES, MAX_MODEL_BYTES, ModelManifest, Role}; +use lightcraft_faces::suggest::{Suggestion, suggest}; +use lightcraft_faces::{known, onnx}; +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; + +use super::{CommandSpec, always, bad, cmd, str_param}; +use crate::{EngineError, Result, Session}; + +#[derive(Default, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +struct FaceSettings { + enabled: bool, + /// The installed recogniser in use. + embedder: Option, +} + +fn fail(what: &str, e: impl std::fmt::Display) -> EngineError { + EngineError::Other(format!("{what}: {e}")) +} + +fn models_dir(s: &Session, cmd: &str) -> Result { + s.face_models_dir.clone().ok_or_else(|| bad(cmd, "this build has nowhere to keep face models (the desktop app does)")) +} + +/// Read at most `max` bytes of a small file. +fn read_capped(path: &Path, max: usize) -> Option> { + let mut buf = Vec::new(); + std::fs::File::open(path).ok()?.take(max as u64 + 1).read_to_end(&mut buf).ok()?; + (buf.len() <= max).then_some(buf) +} + +fn read_settings(dir: &Path) -> FaceSettings { + read_capped(&dir.join("settings.json"), 4096).and_then(|b| serde_json::from_slice(&b).ok()).unwrap_or_default() +} + +fn write_atomic(path: &Path, bytes: &[u8]) -> Result<()> { + let part = path.with_extension("part"); + std::fs::write(&part, bytes).map_err(|e| fail(&format!("could not write {}", path.display()), e))?; + std::fs::rename(&part, path).map_err(|e| { + let _ = std::fs::remove_file(&part); + fail(&format!("could not write {}", path.display()), e) + }) +} + +fn write_settings(dir: &Path, st: &FaceSettings) -> Result<()> { + std::fs::create_dir_all(dir).map_err(|e| fail("could not create the face models folder", e))?; + write_atomic(&dir.join("settings.json"), &serde_json::to_vec_pretty(st).map_err(|e| fail("settings", e))?) +} + +struct Installed { + manifest: ModelManifest, + accepted: Value, +} + +/// The models in the folder. A folder that is not a valid model (wrong name, no model file, a manifest that +/// does not validate or does not match its folder) is ignored, never trusted. +fn installed_models(dir: &Path) -> Vec { + let Ok(entries) = std::fs::read_dir(dir) else { return Vec::new() }; + let mut out = Vec::new(); + for e in entries.flatten() { + let p = e.path(); + let Some(name) = p.file_name().and_then(|n| n.to_str()).map(str::to_string) else { continue }; + if !manifest::valid_id(&name) || !p.is_dir() || !p.join("model.onnx").is_file() { + continue; + } + let Some(m) = read_capped(&p.join("face-model.json"), MAX_MANIFEST_BYTES).and_then(|b| manifest::parse(&b).ok()) else { continue }; + if m.id != name { + continue; + } + let accepted = read_capped(&p.join("installed.json"), 4096).and_then(|b| serde_json::from_slice(&b).ok()).unwrap_or(Value::Null); + out.push(Installed { manifest: m, accepted }); + } + out.sort_by(|a, b| a.manifest.id.cmp(&b.manifest.id)); + out +} + +fn row(m: &ModelManifest, installed: bool, selected: bool, accepted: &Value) -> Value { + let bundled = known::BUNDLED.contains(&m.id.as_str()); + json!({ + "id": m.id, + "name": m.name, + "role": m.role, + "licence": m.licence, + "provenance": m.provenance, + "source": m.source, + "sizeBytes": m.size_bytes, + "sha256": m.sha256, + "known": known::all().iter().any(|k| k.id == m.id), + "bundled": bundled, + "installed": installed || bundled, + "selected": selected, + "accepted": accepted, + }) +} + +struct Inspection { + sha256: String, + size: u64, + file_name: String, + suggestion: Suggestion, +} + +fn inspect_file(path: &str, cmd: &str) -> Result { + let p = Path::new(path); + let meta = std::fs::metadata(p).map_err(|e| bad(cmd, format!("cannot read `{path}`: {e}")))?; + if !meta.is_file() { + return Err(bad(cmd, format!("`{path}` is not a file"))); + } + if meta.len() == 0 || meta.len() > MAX_MODEL_BYTES { + return Err(bad(cmd, "a model file must be between 1 byte and 4 GiB")); + } + let sha256 = sha256_file(p).map_err(|e| fail(&format!("could not read {path}"), e))?; + let file_name = p.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); + let suggestion = match onnx::probe_path(p) { + Ok(info) => suggest(&info, &sha256, meta.len(), &file_name), + Err(e) => Suggestion::Unsupported(e.to_string()), + }; + Ok(Inspection { sha256, size: meta.len(), file_name, suggestion }) +} + +fn list(s: &mut Session, _: &Value) -> Result { + let dir = s.face_models_dir.clone(); + let (settings, installed) = match &dir { + Some(d) => (read_settings(d), installed_models(d)), + None => (FaceSettings::default(), Vec::new()), + }; + let on_disk = |id: &str| installed.iter().find(|i| i.manifest.id == id); + let mut models: Vec = Vec::new(); + for k in known::all() { + let found = on_disk(&k.id); + models.push(row(&k, found.is_some(), settings.embedder.as_deref() == Some(k.id.as_str()), found.map_or(&Value::Null, |f| &f.accepted))); + } + for i in &installed { + if !known::all().iter().any(|k| k.id == i.manifest.id) { + models.push(row(&i.manifest, true, settings.embedder.as_deref() == Some(i.manifest.id.as_str()), &i.accepted)); + } + } + Ok(json!({ + "dir": dir.as_ref().map(|d| d.display().to_string()), + "enabled": settings.enabled, + "embedder": settings.embedder, + // running a model is not built in yet: models can be installed and chosen, not used + "runtime": false, + "models": models, + })) +} + +fn inspect(s: &mut Session, p: &Value) -> Result { + const C: &str = "faces.models.inspect"; + let path = str_param(p, "path").ok_or_else(|| bad(C, "missing `path`"))?; + let ins = inspect_file(path, C)?; + let installed = s.face_models_dir.as_deref().map(installed_models).unwrap_or_default(); + let (kind, model, assumptions, reason) = match &ins.suggestion { + Suggestion::Known(m) => ("known", Some(m), Vec::new(), None), + Suggestion::Draft { manifest, assumptions } => ("draft", Some(manifest), assumptions.clone(), None), + Suggestion::Unsupported(why) => ("unsupported", None, Vec::new(), Some(why.clone())), + }; + let already = model.is_some_and(|m| installed.iter().any(|i| i.manifest.sha256 == m.sha256 && i.manifest.id == m.id)); + Ok(json!({ + "path": path, + "fileName": ins.file_name, + "sizeBytes": ins.size, + "sha256": ins.sha256, + "kind": kind, + "model": model.map(|m| row(m, already, false, &Value::Null)), + "assumptions": assumptions, + "reason": reason, + "alreadyInstalled": already, + })) +} + +fn install(s: &mut Session, p: &Value) -> Result { + const C: &str = "faces.models.install"; + let path = str_param(p, "path").ok_or_else(|| bad(C, "missing `path`"))?; + let acknowledged = p.get("acknowledged").and_then(Value::as_bool).unwrap_or(false); + let dir = models_dir(s, C)?; + let ins = inspect_file(path, C)?; + let m = match ins.suggestion { + Suggestion::Known(m) | Suggestion::Draft { manifest: m, .. } => m, + Suggestion::Unsupported(why) => return Err(bad(C, format!("this model cannot be used yet: {why}"))), + }; + if known::BUNDLED.contains(&m.id.as_str()) { + return Err(bad(C, "this model is already included with LightCraft")); + } + if m.role != Role::Embedder { + return Err(bad(C, "only face recognition models can be installed for now")); + } + manifest::validate(&m).map_err(|e| bad(C, e.to_string()))?; + if !acknowledged { + return Err(bad(C, "the licence has not been accepted: show the user its terms and pass `acknowledged: true` once they agree")); + } + let home = dir.join(&m.id); + std::fs::create_dir_all(&home).map_err(|e| fail("could not create the model's folder", e))?; + let (part, final_path) = (home.join("model.onnx.part"), home.join("model.onnx")); + let copied = + std::fs::copy(path, &part).map_err(|e| fail("could not copy the model (is the disk full?)", e)).and_then(|_| match sha256_file(&part) { + Ok(h) if h == ins.sha256 => Ok(()), + Ok(_) => Err(EngineError::Other("the copy does not match the original (the file changed while copying?)".into())), + Err(e) => Err(fail("could not check the copy", e)), + }); + if let Err(e) = copied.and_then(|_| std::fs::rename(&part, &final_path).map_err(|e| fail("could not finish the copy", e))) { + let _ = std::fs::remove_file(&part); + return Err(e); + } + write_atomic(&home.join("face-model.json"), &serde_json::to_vec_pretty(&m).map_err(|e| fail("manifest", e))?)?; + let accepted = json!({"acceptedAt": (s.clock)(), "licence": m.licence.name, "commercial": m.licence.commercial, "fileName": ins.file_name}); + write_atomic(&home.join("installed.json"), &serde_json::to_vec_pretty(&accepted).map_err(|e| fail("record", e))?)?; + Ok(json!({"installed": row(&m, true, false, &accepted)})) +} + +fn remove(s: &mut Session, p: &Value) -> Result { + const C: &str = "faces.models.remove"; + let id = str_param(p, "id").ok_or_else(|| bad(C, "missing `id`"))?; + let dir = models_dir(s, C)?; + if !manifest::valid_id(id) { + return Err(bad(C, "not a model id")); + } + if known::BUNDLED.contains(&id) { + return Err(bad(C, "this model is part of LightCraft and cannot be removed")); + } + let home = dir.join(id); + // only a folder that is a model (it has a manifest) is ever deleted + if !home.join("face-model.json").is_file() { + return Err(bad(C, "that model is not installed")); + } + std::fs::remove_dir_all(&home).map_err(|e| fail("could not remove the model", e))?; + let mut st = read_settings(&dir); + if st.embedder.as_deref() == Some(id) { + st.embedder = None; + write_settings(&dir, &st)?; + } + Ok(json!({"removed": id})) +} + +fn select(s: &mut Session, p: &Value) -> Result { + const C: &str = "faces.models.select"; + let dir = models_dir(s, C)?; + let id = match p.get("id") { + None | Some(Value::Null) => None, + Some(v) => Some(v.as_str().ok_or_else(|| bad(C, "`id` must be a model id or null"))?.to_string()), + }; + if let Some(id) = &id + && !installed_models(&dir).iter().any(|i| &i.manifest.id == id && i.manifest.role == Role::Embedder) + { + return Err(bad(C, "that recognition model is not installed")); + } + let mut st = read_settings(&dir); + st.embedder = id; + write_settings(&dir, &st)?; + Ok(json!({"embedder": st.embedder})) +} + +fn enable(s: &mut Session, p: &Value) -> Result { + const C: &str = "faces.enable"; + let dir = models_dir(s, C)?; + let mut st = read_settings(&dir); + st.enabled = p.get("enabled").and_then(Value::as_bool).unwrap_or(!st.enabled); + write_settings(&dir, &st)?; + Ok(json!({"enabled": st.enabled})) +} + +pub fn specs() -> Vec { + vec![ + cmd!(query "faces.models.list", "Face Models", [], None, "{} → {dir, enabled, embedder, runtime, models: [{id, name, role, licence{name, commercial, url, notice}, provenance, source, sizeBytes, known, bundled, installed, selected}]}", always, list), + cmd!(query "faces.models.inspect", "Inspect Face Model File", [], None, "{path} → what a .onnx file is: {kind: known | draft | unsupported, model, assumptions, reason, alreadyInstalled}; installs nothing", always, inspect), + cmd!(query "faces.models.install", "Install Face Model", [], None, "{path, acknowledged: true} — copy a .onnx face recognition model into the models folder. `acknowledged` must be true: the user has been shown its licence (see inspect) and accepted it", always, install), + cmd!(query "faces.models.remove", "Remove Face Model", [], None, "{id} — delete an installed model", always, remove), + cmd!(query "faces.models.select", "Choose Face Recognition Model", [], None, "{id: installed recogniser | null}", always, select), + cmd!(query "faces.enable", "Face Recognition On/Off", [], None, "{enabled?: bool} (toggles when omitted)", always, enable), + ] +} diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 9fc83116..730fb811 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -13,6 +13,7 @@ pub mod curves; mod develop; mod edit; mod export; +mod face_models; pub mod filters; pub mod keywords; pub mod library; @@ -140,6 +141,7 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(missing::specs()); v.extend(metadata::specs()); v.extend(filters::specs()); + v.extend(face_models::specs()); v }) } diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index f26b4ca7..acbfa814 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -157,6 +157,8 @@ pub struct Session { /// Set by a command whose change must not rewrite the photo's XMP sidecar even with auto-write on /// (a catalog-only edit of data the sidecar writer does not emit); consumed when the command ends. pub(crate) skip_auto_write: bool, + /// Where the host keeps face models (one folder each); `None` where there is no file system (the web). + pub face_models_dir: Option, /// Copied develop settings (partial JSON) for Paste. pub clipboard: Option, /// The folder on disk the [`LibrarySource::Folder`] view browses. @@ -263,6 +265,7 @@ impl Session { redo: Vec::new(), interaction: None, skip_auto_write: false, + face_models_dir: None, clipboard: None, meta_clipboard: None, browse: None, @@ -787,6 +790,8 @@ mod tests_export; #[cfg(test)] mod tests_folders; #[cfg(test)] +mod tests_face_models; +#[cfg(test)] mod tests_forget_local; #[cfg(test)] mod tests_import; diff --git a/crates/engine/src/tests_face_models.rs b/crates/engine/src/tests_face_models.rs new file mode 100644 index 00000000..1296ac88 --- /dev/null +++ b/crates/engine/src/tests_face_models.rs @@ -0,0 +1,173 @@ +//! The `faces.*` model commands: list, inspect, install behind the licence, select, remove, the on/off +//! setting, and the refusals (no folder, licence not accepted, unsupported or hostile files and ids). + +use std::path::PathBuf; + +use serde_json::{Value, json}; + +use crate::Session; + +fn temp(name: &str) -> PathBuf { + let d = std::env::temp_dir().join(format!("lc-facemodels-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&d); + std::fs::create_dir_all(&d).unwrap(); + d +} + +fn session(dir: &std::path::Path) -> Session { + let mut s = Session::new(); + s.face_models_dir = Some(dir.join("models")); + s +} + +fn model_file(dir: &std::path::Path, name: &str, dim: u64) -> String { + let p = dir.join(name); + std::fs::write(&p, lightcraft_faces::synthetic::embedder_model(dim)).unwrap(); + p.to_string_lossy().into_owned() +} + +fn find<'a>(list: &'a Value, id: &str) -> &'a Value { + list["models"].as_array().unwrap().iter().find(|m| m["id"] == id).unwrap_or(&Value::Null) +} + +#[test] +fn the_list_knows_the_known_models_and_is_honest_about_the_runtime() { + let d = temp("list"); + let mut s = session(&d); + let l = s.execute("faces.models.list", &json!({})).unwrap(); + assert_eq!(l["enabled"], false); + assert_eq!(l["runtime"], false); + assert_eq!(find(&l, "yunet-2023mar")["bundled"], true); + assert_eq!(find(&l, "yunet-2023mar")["installed"], true); + assert_eq!(find(&l, "auraface-v1")["installed"], false); + assert_eq!(find(&l, "auraface-v1")["licence"]["commercial"], "yes"); + assert_eq!(find(&l, "sface-2021dec")["licence"]["commercial"], "unknown"); + // a build with no folder lists too, and cannot install + let mut web = Session::new(); + assert!(web.execute("faces.models.list", &json!({})).is_ok()); + let f = model_file(&d, "m.onnx", 512); + assert!(web.execute("faces.models.install", &json!({"path": f, "acknowledged": true})).is_err()); + assert!(web.execute("faces.enable", &json!({"enabled": true})).is_err()); + let _ = std::fs::remove_dir_all(&d); +} + +#[test] +fn inspect_describes_a_file_and_installs_nothing() { + let d = temp("inspect"); + let mut s = session(&d); + let f = model_file(&d, "My Model.onnx", 512); + let r = s.execute("faces.models.inspect", &json!({"path": f})).unwrap(); + assert_eq!(r["kind"], "draft"); + assert_eq!(r["model"]["licence"]["commercial"], "unknown"); + assert_eq!(r["model"]["known"], false); + assert!(r["assumptions"].as_array().unwrap().iter().any(|a| a.as_str().unwrap().contains("127.5"))); + assert_eq!(r["alreadyInstalled"], false); + assert!(!d.join("models").exists(), "inspecting writes nothing"); + // not a model: a plain answer, not an error + let junk = d.join("junk.onnx"); + std::fs::write(&junk, b"this is not a model").unwrap(); + let r = s.execute("faces.models.inspect", &json!({"path": junk.to_string_lossy()})).unwrap(); + assert_eq!(r["kind"], "unsupported"); + assert!(r["reason"].as_str().unwrap().len() > 5); + // missing and empty files and folders are errors + assert!(s.execute("faces.models.inspect", &json!({"path": d.join("nope.onnx").to_string_lossy()})).is_err()); + assert!(s.execute("faces.models.inspect", &json!({"path": d.to_string_lossy()})).is_err()); + let empty = d.join("empty.onnx"); + std::fs::write(&empty, b"").unwrap(); + assert!(s.execute("faces.models.inspect", &json!({"path": empty.to_string_lossy()})).is_err()); + assert!(s.execute("faces.models.inspect", &json!({})).is_err()); + let _ = std::fs::remove_dir_all(&d); +} + +#[test] +fn install_needs_the_licence_accepted_then_select_enable_and_remove_work() { + let d = temp("flow"); + let mut s = session(&d); + let f = model_file(&d, "Mine.onnx", 512); + assert!(s.execute("faces.models.install", &json!({"path": f})).is_err(), "no acknowledgement"); + assert!(s.execute("faces.models.install", &json!({"path": f, "acknowledged": false})).is_err()); + assert!(s.execute("faces.models.install", &json!({"path": f, "acknowledged": "yes"})).is_err(), "must be the boolean true"); + assert!(!d.join("models").exists(), "refused installs leave nothing behind"); + + let r = s.execute("faces.models.install", &json!({"path": f, "acknowledged": true})).unwrap(); + let id = r["installed"]["id"].as_str().unwrap().to_string(); + assert!(id.starts_with("custom-mine-")); + let home = d.join("models").join(&id); + assert!(home.join("model.onnx").is_file() && home.join("face-model.json").is_file() && home.join("installed.json").is_file()); + assert!(!home.join("model.onnx.part").exists()); + assert_eq!(std::fs::read(home.join("model.onnx")).unwrap(), std::fs::read(&f).unwrap()); + let rec: Value = serde_json::from_slice(&std::fs::read(home.join("installed.json")).unwrap()).unwrap(); + assert_eq!(rec["commercial"], "unknown"); + assert_eq!(rec["fileName"], "Mine.onnx"); + + // listed as installed and custom; installing again is harmless + let l = s.execute("faces.models.list", &json!({})).unwrap(); + assert_eq!(find(&l, &id)["installed"], true); + assert_eq!(find(&l, &id)["known"], false); + s.execute("faces.models.install", &json!({"path": f, "acknowledged": true})).unwrap(); + assert_eq!(s.execute("faces.models.inspect", &json!({"path": f})).unwrap()["alreadyInstalled"], true); + + // choose it; the choice and the on/off setting survive a new session on the same folder + assert!(s.execute("faces.models.select", &json!({"id": "auraface-v1"})).is_err(), "not installed"); + assert!(s.execute("faces.models.select", &json!({"id": "yunet-2023mar"})).is_err(), "a detector is not a recogniser"); + s.execute("faces.models.select", &json!({"id": id})).unwrap(); + assert_eq!(s.execute("faces.enable", &json!({"enabled": true})).unwrap()["enabled"], true); + let mut again = session(&d); + let l = again.execute("faces.models.list", &json!({})).unwrap(); + assert_eq!((l["enabled"].clone(), l["embedder"].clone()), (json!(true), json!(id))); + assert_eq!(again.execute("faces.enable", &json!({})).unwrap()["enabled"], false, "toggles when omitted"); + + // removing it clears the choice + assert_eq!(s.execute("faces.models.remove", &json!({"id": id})).unwrap()["removed"], id); + assert!(!home.exists()); + assert_eq!(s.execute("faces.models.list", &json!({})).unwrap()["embedder"], Value::Null); + assert!(s.execute("faces.models.remove", &json!({"id": id})).is_err(), "already gone"); + let _ = std::fs::remove_dir_all(&d); +} + +#[test] +fn hostile_ids_unsupported_models_and_odd_folders_are_handled() { + let d = temp("hostile"); + let mut s = session(&d); + let models = d.join("models"); + std::fs::create_dir_all(&models).unwrap(); + // something that is not a model sits in the folder: it is never listed and never deleted + let precious = models.join("precious"); + std::fs::create_dir_all(&precious).unwrap(); + std::fs::write(precious.join("keep.txt"), b"mine").unwrap(); + for id in ["precious", "..", ".", "", "../x", "a/b", r"a\b", ".hidden", "yunet-2023mar", "UPPER"] { + assert!(s.execute("faces.models.remove", &json!({"id": id})).is_err(), "{id:?}"); + } + assert!(precious.join("keep.txt").is_file()); + assert!(d.join("models").is_dir()); + // a folder whose manifest lies about its id is ignored + let liar = models.join("liar"); + std::fs::create_dir_all(&liar).unwrap(); + std::fs::write(liar.join("model.onnx"), lightcraft_faces::synthetic::embedder_model(64)).unwrap(); + let mut m = lightcraft_faces::known::auraface(); + m.id = "someone-else".into(); + std::fs::write(liar.join("face-model.json"), serde_json::to_vec(&m).unwrap()).unwrap(); + std::fs::write(models.join("settings.json"), b"{ not json").unwrap(); + let l = s.execute("faces.models.list", &json!({})).unwrap(); + assert!(find(&l, "someone-else").is_null() && find(&l, "liar").is_null()); + assert_eq!(l["enabled"], false, "a corrupt settings file is ignored"); + // not-a-model and wrong-shaped files cannot be installed + let junk = d.join("junk.onnx"); + std::fs::write(&junk, b"nonsense").unwrap(); + let e = s.execute("faces.models.install", &json!({"path": junk.to_string_lossy(), "acknowledged": true})).unwrap_err().to_string(); + assert!(e.contains("cannot be used yet"), "{e}"); + let tokens = d.join("tokens.onnx"); + { + use lightcraft_faces::synthetic::{len_field, value_info_bytes}; + let mut graph = Vec::new(); + len_field(11, &value_info_bytes("pixels", 1, &[Err("N"), Ok(3), Ok(224), Ok(224)]), &mut graph); + len_field(12, &value_info_bytes("tokens", 1, &[Err("N"), Ok(257), Ok(384)]), &mut graph); + let mut model = Vec::new(); + len_field(7, &graph, &mut model); + std::fs::write(&tokens, model).unwrap(); + } + assert!(s.execute("faces.models.install", &json!({"path": tokens.to_string_lossy(), "acknowledged": true})).is_err()); + assert!(s.execute("faces.models.select", &json!({"id": 5})).is_err()); + assert!(s.execute("faces.models.select", &json!({"id": ""})).is_err()); + let _ = std::fs::remove_dir_all(&d); +} diff --git a/crates/faces/src/known.rs b/crates/faces/src/known.rs index 2eda8753..a3e563ed 100644 --- a/crates/faces/src/known.rs +++ b/crates/faces/src/known.rs @@ -9,6 +9,9 @@ use crate::manifest::{Colour, Commercial, InputSpec, Licence, ModelManifest, Out /// Detector output decoders built into LightCraft. pub const DECODERS: &[&str] = &["yunet-v2"]; +/// Models that ship inside LightCraft (they are not installed by the user). +pub const BUNDLED: &[&str] = &["yunet-2023mar"]; + pub const YUNET_SHA256: &str = "8f2383e4dd3cfbb4553ea8718107fc0423210dc964f9f4280604804ed2552fa4"; pub const SFACE_SHA256: &str = "0ba9fbfa01b5270c96627c4ef784da859931e02f04419c829e83484087c34e79"; pub const AURAFACE_SHA256: &str = "a7933ea5330113b01c9b60351d8f4c33003f145d8470ac5f0e52ee2effe25c60"; diff --git a/crates/faces/src/lib.rs b/crates/faces/src/lib.rs index 0ac55b66..f7dfe926 100644 --- a/crates/faces/src/lib.rs +++ b/crates/faces/src/lib.rs @@ -14,5 +14,6 @@ pub mod known; pub mod manifest; pub mod onnx; pub mod suggest; +pub mod synthetic; pub use manifest::{Colour, Commercial, InputSpec, Licence, ManifestError, ModelManifest, OutputSpec, Resize, Role, Thresholds}; diff --git a/crates/faces/src/manifest.rs b/crates/faces/src/manifest.rs index 22d70ddf..36f8878a 100644 --- a/crates/faces/src/manifest.rs +++ b/crates/faces/src/manifest.rs @@ -145,6 +145,14 @@ fn bad(field: &'static str, why: impl Into) -> Result bool { + let mut bytes = id.bytes(); + let first_ok = bytes.next().is_some_and(|b| b.is_ascii_lowercase() || b.is_ascii_digit()); + first_ok && id.len() <= 64 && bytes.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || matches!(b, b'.' | b'_' | b'-')) +} + fn text(field: &'static str, s: &str, min: usize, max: usize) -> Result<(), ManifestError> { let n = s.chars().count(); if n < min || n > max { @@ -174,10 +182,8 @@ fn ratio(field: &'static str, v: Option, lo: f32, hi: f32) -> Result<(), Ma /// Check a manifest from any source. Everything the rest of LightCraft relies on (sizes it will allocate, /// numbers it will divide by, text it will show) is bounded here. pub fn validate(m: &ModelManifest) -> Result<(), ManifestError> { - let id_ok = - (1..=64).contains(&m.id.len()) && m.id.bytes().all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || matches!(b, b'.' | b'_' | b'-')); - if !id_ok { - return bad("id", "must be 1 to 64 characters: lowercase letters, digits, '.', '_' or '-'"); + if !valid_id(&m.id) { + return bad("id", "must be 1 to 64 characters: lowercase letters and digits, then also '.', '_' or '-'"); } text("name", &m.name, 1, 120)?; text("version", &m.version, 1, 40)?; @@ -278,6 +284,11 @@ mod tests { ("empty id", Box::new(|m| m.id = String::new())), ("uppercase id", Box::new(|m| m.id = "Bad".into())), ("path in id", Box::new(|m| m.id = "../etc".into())), + ("dot id", Box::new(|m| m.id = ".".into())), + ("dotdot id", Box::new(|m| m.id = "..".into())), + ("hidden id", Box::new(|m| m.id = ".hidden".into())), + ("slash id", Box::new(|m| m.id = "a/b".into())), + ("backslash id", Box::new(|m| m.id = r"a\b".into())), ("long id", Box::new(|m| m.id = "a".repeat(65))), ("empty name", Box::new(|m| m.name = String::new())), ("control in name", Box::new(|m| m.name = "a\u{7}b".into())), diff --git a/crates/faces/src/onnx.rs b/crates/faces/src/onnx.rs index 22e04dcf..f4c981b0 100644 --- a/crates/faces/src/onnx.rs +++ b/crates/faces/src/onnx.rs @@ -347,69 +347,8 @@ pub(crate) mod tests { use super::*; use std::io::Cursor; - fn varint(mut v: u64, out: &mut Vec) { - loop { - let b = (v & 0x7f) as u8; - v >>= 7; - if v == 0 { - out.push(b); - return; - } - out.push(b | 0x80); - } - } - fn len_field(n: u64, body: &[u8], out: &mut Vec) { - varint(n << 3 | 2, out); - varint(body.len() as u64, out); - out.extend_from_slice(body); - } - fn varint_field(n: u64, v: u64, out: &mut Vec) { - varint(n << 3, out); - varint(v, out); - } - fn value_info_bytes(name: &str, elem: u64, dims: &[std::result::Result]) -> Vec { - let mut shape = Vec::new(); - for d in dims { - let mut dim = Vec::new(); - match d { - Ok(v) => varint_field(1, *v, &mut dim), - Err(p) => len_field(2, p.as_bytes(), &mut dim), - } - len_field(1, &dim, &mut shape); - } - let mut tensor = Vec::new(); - varint_field(1, elem, &mut tensor); - len_field(2, &shape, &mut tensor); - let mut ty = Vec::new(); - len_field(1, &tensor, &mut ty); - let mut vi = Vec::new(); - len_field(1, name.as_bytes(), &mut vi); - len_field(2, &ty, &mut vi); - vi - } - - /// A small valid model: input `data` [N, 3, 112, 112] float, an old-style weight also listed as an - /// input, a weight with bulk data, output `emb` [N, 512], ai.onnx opset 17. - pub(crate) fn model_bytes(output_dim: u64) -> Vec { - let mut graph = Vec::new(); - len_field(11, &value_info_bytes("data", 1, &[Err("N"), Ok(3), Ok(112), Ok(112)]), &mut graph); - len_field(11, &value_info_bytes("w", 1, &[Ok(4)]), &mut graph); - let mut tensor = Vec::new(); - varint_field(1, 4, &mut tensor); - len_field(8, b"w", &mut tensor); - len_field(9, &[7u8; 5000], &mut tensor); - len_field(5, &tensor, &mut graph); - len_field(12, &value_info_bytes("emb", 1, &[Err("N"), Ok(output_dim)]), &mut graph); - let mut ops = Vec::new(); - len_field(1, b"", &mut ops); - varint_field(2, 17, &mut ops); - let mut model = Vec::new(); - varint_field(1, 8, &mut model); - len_field(2, b"pytorch", &mut model); - len_field(7, &graph, &mut model); - len_field(8, &ops, &mut model); - model - } + pub(crate) use crate::synthetic::embedder_model as model_bytes; + use crate::synthetic::{len_field, value_info_bytes, varint}; #[test] fn reads_inputs_outputs_and_skips_weights() { diff --git a/crates/faces/src/synthetic.rs b/crates/faces/src/synthetic.rs new file mode 100644 index 00000000..7df9acfe --- /dev/null +++ b/crates/faces/src/synthetic.rs @@ -0,0 +1,70 @@ +//! A minimal, valid ONNX model made in memory: for tests, and later for the install self-check. It is not a +//! working network (it has no layers), only a graph with the right inputs, outputs and a weight blob. + +pub(crate) fn varint(mut v: u64, out: &mut Vec) { + loop { + let b = (v & 0x7f) as u8; + v >>= 7; + if v == 0 { + out.push(b); + return; + } + out.push(b | 0x80); + } +} + +pub fn len_field(n: u64, body: &[u8], out: &mut Vec) { + varint(n << 3 | 2, out); + varint(body.len() as u64, out); + out.extend_from_slice(body); +} + +pub(crate) fn varint_field(n: u64, v: u64, out: &mut Vec) { + varint(n << 3, out); + varint(v, out); +} + +/// A graph input or output: `Ok(n)` is a fixed dimension, `Err(name)` a named (dynamic) one. +pub fn value_info_bytes(name: &str, elem: u64, dims: &[std::result::Result]) -> Vec { + let mut shape = Vec::new(); + for d in dims { + let mut dim = Vec::new(); + match d { + Ok(v) => varint_field(1, *v, &mut dim), + Err(p) => len_field(2, p.as_bytes(), &mut dim), + } + len_field(1, &dim, &mut shape); + } + let mut tensor = Vec::new(); + varint_field(1, elem, &mut tensor); + len_field(2, &shape, &mut tensor); + let mut ty = Vec::new(); + len_field(1, &tensor, &mut ty); + let mut vi = Vec::new(); + len_field(1, name.as_bytes(), &mut vi); + len_field(2, &ty, &mut vi); + vi +} + +/// A small valid model shaped like a face embedder: float input `data` `[N, 3, 112, 112]`, an old-style +/// weight also listed as an input, a weight with bulk data, float output `emb` `[N, output_dim]`, opset 17. +pub fn embedder_model(output_dim: u64) -> Vec { + let mut graph = Vec::new(); + len_field(11, &value_info_bytes("data", 1, &[Err("N"), Ok(3), Ok(112), Ok(112)]), &mut graph); + len_field(11, &value_info_bytes("w", 1, &[Ok(4)]), &mut graph); + let mut tensor = Vec::new(); + varint_field(1, 4, &mut tensor); + len_field(8, b"w", &mut tensor); + len_field(9, &[7u8; 5000], &mut tensor); + len_field(5, &tensor, &mut graph); + len_field(12, &value_info_bytes("emb", 1, &[Err("N"), Ok(output_dim)]), &mut graph); + let mut ops = Vec::new(); + len_field(1, b"", &mut ops); + varint_field(2, 17, &mut ops); + let mut model = Vec::new(); + varint_field(1, 8, &mut model); + len_field(2, b"pytorch", &mut model); + len_field(7, &graph, &mut model); + len_field(8, &ops, &mut model); + model +}