Files
lightcraft/crates/faces/src/onnx.rs
T
Daniel Voshart 24f87a91a0 LR-PREF-PEOPLE: update pure-Rust recognition and preserve valid model replacements
Keep current upstream UI behavior and the later detector contract checks. Validate candidate models before replacing working weights and records, with a synthetic regression that failed before the fix. Remove the unrelated GPU equivalence-test relaxation. All seven quality gates pass with the pinned hosted fonts.

Generated with Codex.
2026-10-08 22:29:31 -04:00

515 lines
18 KiB
Rust

//! A streaming probe of an ONNX file: the graph's inputs and outputs (name, element type, shape), without
//! running or even fully loading the model.
//!
//! ONNX is protobuf. We read only the few fields we need (the producer, the opset, and the graph's input,
//! output and initializer names) and seek past everything else, so a 260 MB model costs a few kilobytes and
//! a few milliseconds. The file is hostile input: every length is checked against its parent message and
//! the file size, the number of fields read is capped, strings are bounded, and nothing indexes or unwraps.
use std::collections::HashSet;
use std::io::{self, Read, Seek, SeekFrom};
use std::path::Path;
const MAX_FIELDS: u32 = 5_000_000;
const MAX_STRING: u64 = 4096;
const MAX_DIMS: usize = 16;
const MAX_TENSORS: usize = 64;
/// Older exporters list every weight as a graph input too; that many are read before the weights are filtered out.
const MAX_LISTED_INPUTS: usize = 200_000;
const MAX_INITIALIZERS: usize = 1_000_000;
#[derive(Clone, Debug, PartialEq)]
pub enum Dim {
Fixed(u64),
/// A named or unknown size (typically the batch).
Dynamic(String),
}
#[derive(Clone, Debug, PartialEq)]
pub struct TensorInfo {
pub name: String,
/// ONNX element type (1 = float32).
pub elem_type: u32,
pub shape: Vec<Dim>,
}
#[derive(Clone, Debug, Default, PartialEq)]
pub struct OnnxInfo {
pub producer: String,
/// The default-domain opset version.
pub opset: Option<u64>,
/// Real inputs only (weights that older exporters also list as inputs are left out).
pub inputs: Vec<TensorInfo>,
pub outputs: Vec<TensorInfo>,
}
#[derive(Debug, thiserror::Error)]
pub enum ProbeError {
#[error("not an ONNX model: {0}")]
Malformed(&'static str),
#[error("could not read the model: {0}")]
Io(#[from] io::Error),
}
pub(crate) type Result<T> = std::result::Result<T, ProbeError>;
pub(crate) enum Wire {
Varint(u64),
/// A length-delimited field: its bytes are `start..end` and the reader sits at `start`.
Len {
start: u64,
end: u64,
},
Fixed32(u32),
#[allow(dead_code)]
Fixed64(u64),
}
pub(crate) struct Walker<'a, R> {
pub(crate) r: &'a mut R,
pub(crate) total: u64,
pub(crate) fields: u32,
}
impl<R: Read + Seek> Walker<'_, R> {
pub(crate) fn pos(&mut self) -> Result<u64> {
Ok(self.r.stream_position()?)
}
pub(crate) fn seek_to(&mut self, at: u64) -> Result<()> {
self.r.seek(SeekFrom::Start(at))?;
Ok(())
}
pub(crate) fn byte(&mut self) -> Result<u8> {
let mut b = [0u8; 1];
self.r
.read_exact(&mut b)
.map_err(|e| if e.kind() == io::ErrorKind::UnexpectedEof { ProbeError::Malformed("file ends inside a field") } else { e.into() })?;
let [x] = b;
Ok(x)
}
pub(crate) fn varint(&mut self) -> Result<u64> {
let mut v = 0u64;
for shift in (0..70u32).step_by(7) {
let b = self.byte()?;
if shift == 63 && b > 1 {
return Err(ProbeError::Malformed("number overflows 64 bits"));
}
v |= u64::from(b & 0x7f) << shift;
if b & 0x80 == 0 {
return Ok(v);
}
}
Err(ProbeError::Malformed("number too long"))
}
/// The next field of the message that ends at `end`, or `None` at its end.
pub(crate) fn next(&mut self, end: u64) -> Result<Option<(u32, Wire)>> {
let here = self.pos()?;
if here >= end {
return Ok(None);
}
self.fields += 1;
if self.fields > MAX_FIELDS {
return Err(ProbeError::Malformed("too many fields"));
}
let tag = self.varint()?;
let number = u32::try_from(tag >> 3).map_err(|_| ProbeError::Malformed("field number too large"))?;
if number == 0 || number > 0x1fff_ffff {
return Err(ProbeError::Malformed("invalid field number"));
}
let wire = match tag & 7 {
0 => Wire::Varint(self.varint()?),
1 => {
let at = self.pos()?.checked_add(8).ok_or(ProbeError::Malformed("offset overflow"))?;
if at > end || at > self.total {
return Err(ProbeError::Malformed("field runs past its message"));
}
let mut b = [0u8; 8];
self.r.read_exact(&mut b).map_err(|_| ProbeError::Malformed("file ends inside a field"))?;
Wire::Fixed64(u64::from_le_bytes(b))
}
5 => {
let at = self.pos()?.checked_add(4).ok_or(ProbeError::Malformed("offset overflow"))?;
if at > end || at > self.total {
return Err(ProbeError::Malformed("field runs past its message"));
}
let mut b = [0u8; 4];
self.r.read_exact(&mut b).map_err(|_| ProbeError::Malformed("file ends inside a field"))?;
Wire::Fixed32(u32::from_le_bytes(b))
}
2 => {
let len = self.varint()?;
let start = self.pos()?;
let stop = start.checked_add(len).ok_or(ProbeError::Malformed("offset overflow"))?;
if stop > end || stop > self.total {
return Err(ProbeError::Malformed("field runs past its message"));
}
Wire::Len { start, end: stop }
}
_ => return Err(ProbeError::Malformed("unsupported field type")),
};
if self.pos()? > end {
return Err(ProbeError::Malformed("field runs past its message"));
}
Ok(Some((number, wire)))
}
/// A bounded UTF-8 string occupying `start..end`; never silently truncate graph names.
pub(crate) fn string(&mut self, start: u64, end: u64) -> Result<String> {
let len = end.saturating_sub(start);
if len > MAX_STRING {
return Err(ProbeError::Malformed("a string exceeds 4096 bytes"));
}
let mut buf = Vec::new();
self.r.by_ref().take(len).read_to_end(&mut buf)?;
String::from_utf8(buf).map_err(|_| ProbeError::Malformed("a string is not UTF-8"))
}
/// The bytes of `start..end`, which must be at most `max` long.
pub(crate) fn bytes(&mut self, start: u64, end: u64, max: u64) -> Result<Vec<u8>> {
let len = end.saturating_sub(start);
if len > max {
return Err(ProbeError::Malformed("a field is larger than allowed"));
}
let mut buf = Vec::new();
self.r.by_ref().take(len).read_to_end(&mut buf)?;
if buf.len() as u64 != len {
return Err(ProbeError::Malformed("file ends inside a field"));
}
Ok(buf)
}
}
fn dim<R: Read + Seek>(w: &mut Walker<R>, end: u64) -> Result<Option<Dim>> {
let mut out = None;
while let Some((n, f)) = w.next(end)? {
match (n, f) {
(1, Wire::Varint(v)) => out = Some(Dim::Fixed(v)),
(2, Wire::Len { start, end: e }) => {
out = Some(Dim::Dynamic(w.string(start, e)?));
w.seek_to(e)?;
}
(_, Wire::Len { end: e, .. }) => w.seek_to(e)?,
_ => {}
}
}
Ok(out)
}
fn shape<R: Read + Seek>(w: &mut Walker<R>, end: u64) -> Result<Vec<Dim>> {
let mut dims = Vec::new();
while let Some((n, f)) = w.next(end)? {
match (n, f) {
(1, Wire::Len { end: e, .. }) => {
if dims.len() >= MAX_DIMS {
return Err(ProbeError::Malformed("too many dimensions"));
}
dims.push(dim(w, e)?.unwrap_or(Dim::Dynamic(String::new())));
w.seek_to(e)?;
}
(_, Wire::Len { end: e, .. }) => w.seek_to(e)?,
_ => {}
}
}
Ok(dims)
}
fn tensor_type<R: Read + Seek>(w: &mut Walker<R>, end: u64, info: &mut TensorInfo) -> Result<()> {
while let Some((n, f)) = w.next(end)? {
match (n, f) {
(1, Wire::Varint(v)) => info.elem_type = u32::try_from(v).unwrap_or(0),
(2, Wire::Len { end: e, .. }) => {
info.shape = shape(w, e)?;
w.seek_to(e)?;
}
(_, Wire::Len { end: e, .. }) => w.seek_to(e)?,
_ => {}
}
}
Ok(())
}
pub(crate) fn type_proto<R: Read + Seek>(w: &mut Walker<R>, end: u64, info: &mut TensorInfo) -> Result<()> {
while let Some((n, f)) = w.next(end)? {
match (n, f) {
(1, Wire::Len { end: e, .. }) => {
tensor_type(w, e, info)?;
w.seek_to(e)?;
}
(_, Wire::Len { end: e, .. }) => w.seek_to(e)?,
_ => {}
}
}
Ok(())
}
fn value_info<R: Read + Seek>(w: &mut Walker<R>, end: u64) -> Result<TensorInfo> {
let mut info = TensorInfo { name: String::new(), elem_type: 0, shape: Vec::new() };
while let Some((n, f)) = w.next(end)? {
match (n, f) {
(1, Wire::Len { start, end: e }) => {
info.name = w.string(start, e)?;
w.seek_to(e)?;
}
(2, Wire::Len { end: e, .. }) => {
type_proto(w, e, &mut info)?;
w.seek_to(e)?;
}
(_, Wire::Len { end: e, .. }) => w.seek_to(e)?,
_ => {}
}
}
Ok(info)
}
/// The `name` (field 8) of a weight tensor; its bulk data is skipped, never read.
fn tensor_name<R: Read + Seek>(w: &mut Walker<R>, end: u64) -> Result<Option<String>> {
while let Some((n, f)) = w.next(end)? {
match (n, f) {
(8, Wire::Len { start, end: e }) => return Ok(Some(w.string(start, e)?)),
(_, Wire::Len { end: e, .. }) => w.seek_to(e)?,
_ => {}
}
}
Ok(None)
}
fn graph<R: Read + Seek>(w: &mut Walker<R>, end: u64, info: &mut OnnxInfo, weights: &mut HashSet<String>) -> Result<()> {
while let Some((n, f)) = w.next(end)? {
match (n, f) {
(5, Wire::Len { end: e, .. }) => {
if weights.len() < MAX_INITIALIZERS
&& let Some(name) = tensor_name(w, e)?
{
weights.insert(name);
}
w.seek_to(e)?;
}
(11, Wire::Len { end: e, .. }) => {
if info.inputs.len() >= MAX_LISTED_INPUTS {
return Err(ProbeError::Malformed("too many inputs"));
}
info.inputs.push(value_info(w, e)?);
w.seek_to(e)?;
}
(12, Wire::Len { end: e, .. }) => {
if info.outputs.len() >= MAX_TENSORS {
return Err(ProbeError::Malformed("too many outputs"));
}
info.outputs.push(value_info(w, e)?);
w.seek_to(e)?;
}
(_, Wire::Len { end: e, .. }) => w.seek_to(e)?,
_ => {}
}
}
Ok(())
}
fn opset<R: Read + Seek>(w: &mut Walker<R>, end: u64) -> Result<Option<u64>> {
let (mut domain, mut version) = (String::new(), None);
while let Some((n, f)) = w.next(end)? {
match (n, f) {
(1, Wire::Len { start, end: e }) => {
domain = w.string(start, e)?;
w.seek_to(e)?;
}
(2, Wire::Varint(v)) => version = Some(v),
(_, Wire::Len { end: e, .. }) => w.seek_to(e)?,
_ => {}
}
}
Ok(if domain.is_empty() || domain == "ai.onnx" { version } else { None })
}
/// Read an ONNX model's graph inputs and outputs from any seekable source.
pub fn probe_reader<R: Read + Seek>(r: &mut R) -> Result<OnnxInfo> {
let total = r.seek(SeekFrom::End(0))?;
if total > crate::manifest::MAX_MODEL_BYTES {
return Err(ProbeError::Malformed("the model exceeds 512 MiB"));
}
r.seek(SeekFrom::Start(0))?;
if total == 0 {
return Err(ProbeError::Malformed("the file is empty"));
}
let mut w = Walker { r, total, fields: 0 };
let (mut info, mut weights, mut saw_graph) = (OnnxInfo::default(), HashSet::new(), false);
while let Some((n, f)) = w.next(total)? {
match (n, f) {
(2, Wire::Len { start, end }) => {
info.producer = w.string(start, end)?;
w.seek_to(end)?;
}
(7, Wire::Len { end, .. }) => {
saw_graph = true;
graph(&mut w, end, &mut info, &mut weights)?;
w.seek_to(end)?;
}
(8, Wire::Len { end, .. }) => {
if let Some(v) = opset(&mut w, end)? {
info.opset = Some(v);
}
w.seek_to(end)?;
}
(_, Wire::Len { end, .. }) => w.seek_to(end)?,
_ => {}
}
}
if !saw_graph {
return Err(ProbeError::Malformed("no graph"));
}
info.inputs.retain(|t| !weights.contains(&t.name));
if info.inputs.len() > MAX_TENSORS {
return Err(ProbeError::Malformed("too many inputs"));
}
if info.inputs.is_empty() || info.outputs.is_empty() {
return Err(ProbeError::Malformed("the graph has no inputs or no outputs"));
}
Ok(info)
}
/// Read an ONNX file's graph inputs and outputs.
pub fn probe_path(path: &Path) -> Result<OnnxInfo> {
probe_reader(&mut io::BufReader::new(std::fs::File::open(path)?))
}
#[cfg(test)]
pub(crate) mod tests {
use super::*;
use std::io::Cursor;
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() {
let info = probe_reader(&mut Cursor::new(model_bytes(512))).unwrap();
assert_eq!(info.producer, "pytorch");
assert_eq!(info.opset, Some(17));
assert_eq!(info.inputs.len(), 1, "the weight listed as an input is left out");
assert_eq!(info.inputs[0].name, "data");
assert_eq!(info.inputs[0].elem_type, 1);
assert_eq!(info.inputs[0].shape, vec![Dim::Dynamic("N".into()), Dim::Fixed(3), Dim::Fixed(112), Dim::Fixed(112)]);
assert_eq!(info.outputs[0].shape, vec![Dim::Dynamic("N".into()), Dim::Fixed(512)]);
}
#[test]
fn old_style_files_listing_hundreds_of_weights_as_inputs_still_probe() {
let mut graph = Vec::new();
len_field(11, &value_info_bytes("data", 1, &[Err("N"), Ok(3), Ok(112), Ok(112)]), &mut graph);
for i in 0..500 {
let name = format!("weight{i}");
len_field(11, &value_info_bytes(&name, 1, &[Ok(4)]), &mut graph);
let mut tensor = Vec::new();
len_field(8, name.as_bytes(), &mut tensor);
len_field(5, &tensor, &mut graph);
}
len_field(12, &value_info_bytes("emb", 1, &[Err("N"), Ok(128)]), &mut graph);
let mut m = Vec::new();
len_field(7, &graph, &mut m);
let info = probe_reader(&mut Cursor::new(m)).unwrap();
assert_eq!(info.inputs.len(), 1);
assert_eq!(info.inputs[0].name, "data");
}
#[test]
fn many_real_inputs_are_refused() {
let mut graph = Vec::new();
for i in 0..70 {
len_field(11, &value_info_bytes(&format!("in{i}"), 1, &[Ok(4)]), &mut graph);
}
len_field(12, &value_info_bytes("emb", 1, &[Ok(4)]), &mut graph);
let mut m = Vec::new();
len_field(7, &graph, &mut m);
assert!(probe_reader(&mut Cursor::new(m)).is_err());
}
#[test]
fn truncated_at_every_length_never_panics() {
let bytes = model_bytes(128);
for n in 0..bytes.len() {
let _ = probe_reader(&mut Cursor::new(bytes[..n].to_vec()));
}
assert!(probe_reader(&mut Cursor::new(bytes)).is_ok());
}
#[test]
fn hostile_files_are_errors() {
let mut lying = Vec::new();
varint(7 << 3 | 2, &mut lying);
varint(1 << 40, &mut lying); // a graph 1 TB long in a 10-byte file
let mut overflow = Vec::new();
varint(7 << 3 | 2, &mut overflow);
varint(u64::MAX, &mut overflow);
let mut long_number = vec![0x08];
long_number.extend_from_slice(&[0xff; 11]);
for (what, bytes) in [
("empty", Vec::new()),
("zeros", vec![0u8; 64]),
("text", b"this is not an onnx model at all".to_vec()),
("lying length", lying),
("overflowing length", overflow),
("endless number", long_number),
("no graph", {
let mut m = Vec::new();
len_field(2, b"x", &mut m);
m
}),
("graph without io", {
let mut m = Vec::new();
len_field(7, &[], &mut m);
m
}),
] {
assert!(probe_reader(&mut Cursor::new(bytes)).is_err(), "{what}");
}
}
#[test]
fn too_many_dimensions_or_tensors_are_errors() {
let dims: Vec<std::result::Result<u64, &str>> = (0..40).map(Ok).collect();
let mut graph = Vec::new();
len_field(11, &value_info_bytes("data", 1, &dims), &mut graph);
len_field(12, &value_info_bytes("out", 1, &[Ok(1)]), &mut graph);
let mut m = Vec::new();
len_field(7, &graph, &mut m);
assert!(probe_reader(&mut Cursor::new(m)).is_err());
}
proptest::proptest! {
#[test]
fn arbitrary_bytes_never_panic(bytes in proptest::collection::vec(proptest::prelude::any::<u8>(), 0..400)) {
let _ = probe_reader(&mut Cursor::new(bytes));
}
#[test]
fn a_valid_model_with_flipped_bytes_never_panics(flips in proptest::collection::vec((0usize..400, proptest::prelude::any::<u8>()), 1..6)) {
let mut bytes = model_bytes(512);
for (at, v) in flips {
if let Some(b) = bytes.get_mut(at) {
*b = v;
}
}
let _ = probe_reader(&mut Cursor::new(bytes));
}
}
#[test]
fn oversized_models_are_rejected_without_reading_or_allocating() {
struct Oversized;
impl std::io::Read for Oversized {
fn read(&mut self, _: &mut [u8]) -> std::io::Result<usize> {
panic!("an oversized model must not be read")
}
}
impl std::io::Seek for Oversized {
fn seek(&mut self, _: std::io::SeekFrom) -> std::io::Result<u64> {
Ok(crate::manifest::MAX_MODEL_BYTES + 1)
}
}
assert!(probe_reader(&mut Oversized).is_err());
}
}