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 <noreply@anthropic.com>
This commit is contained in:
Daniel Voshart 2026-10-05 21:43:11 -04:00
parent 39de0e6a1c
commit 7ca7113b32
11 changed files with 557 additions and 67 deletions

1
Cargo.lock generated
View File

@ -2882,6 +2882,7 @@ dependencies = [
"lightcraft-codecs",
"lightcraft-color",
"lightcraft-develop",
"lightcraft-faces",
"lightcraft-geom",
"lightcraft-gpu",
"lightcraft-merge",

View File

@ -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 }

View File

@ -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<String>,
}
fn fail(what: &str, e: impl std::fmt::Display) -> EngineError {
EngineError::Other(format!("{what}: {e}"))
}
fn models_dir(s: &Session, cmd: &str) -> Result<PathBuf> {
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<Vec<u8>> {
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<Installed> {
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<Inspection> {
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<Value> {
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<Value> = 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<Value> {
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<Value> {
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<Value> {
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<Value> {
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<Value> {
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<CommandSpec> {
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),
]
}

View File

@ -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
})
}

View File

@ -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<std::path::PathBuf>,
/// Copied develop settings (partial JSON) for Paste.
pub clipboard: Option<Value>,
/// 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;

View File

@ -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);
}

View File

@ -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";

View File

@ -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};

View File

@ -145,6 +145,14 @@ fn bad<T>(field: &'static str, why: impl Into<String>) -> Result<T, ManifestErro
Err(ManifestError::Field(field, why.into()))
}
/// A model id is a folder name: 1 to 64 characters, starting with a lowercase letter or digit, then
/// lowercase letters, digits, `.`, `_` or `-`. So it is never `.`, `..`, hidden, or a path.
pub fn valid_id(id: &str) -> 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<f32>, 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())),

View File

@ -347,69 +347,8 @@ pub(crate) mod tests {
use super::*;
use std::io::Cursor;
fn varint(mut v: u64, out: &mut Vec<u8>) {
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<u8>) {
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<u8>) {
varint(n << 3, out);
varint(v, out);
}
fn value_info_bytes(name: &str, elem: u64, dims: &[std::result::Result<u64, &str>]) -> Vec<u8> {
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<u8> {
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() {

View File

@ -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<u8>) {
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<u8>) {
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<u8>) {
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<u64, &str>]) -> Vec<u8> {
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<u8> {
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
}