Skip to main content

kime_model/
pack.rs

1//! The `.kime` packed format: one file holding a model's tensors and config files, laid out so the
2//! tensors can be mapped and used in place.
3//!
4//! ```text
5//! 0      4 KiB header block
6//!          0..8    magic "KIME\x01\0\0\0"
7//!          8..12   format version, u32 LE, 1
8//!          12..16  zero
9//!          16..24  JSON length, u64 LE
10//!          24..32  data start, u64 LE, a multiple of 4 KiB
11//!          32..40  data length, u64 LE, running to the end of the file
12//!          40..72  blake3 of the JSON
13//!          72..    zero
14//! 4096   JSON: format, family, id, hash, tensors, files
15//! data   tensors, then files, each starting on a 4 KiB boundary, offsets relative to data start
16//! ```
17//!
18//! `hash` in the JSON is the blake3 of the whole data section and is the model hash kime reports.
19//! The JSON's own hash sits in the header block, so a reader can trust the index before touching
20//! the data, and checking the model hash is a separate, optional pass at memory speed.
21//!
22//! Every number is checked before use, as for safetensors: a header that points outside the file
23//! is an error and never a read.
24
25use std::io::Write;
26
27use kime_tensor::{Blob, DType};
28use serde_json::{Map, Value, json};
29
30use crate::error::{Error, Result};
31use crate::safetensors::MAX_HEADER;
32use crate::tensors::{Entry, Tensors, byte_len, check_disjoint};
33
34/// The first eight bytes of every `.kime` file.
35pub const MAGIC: [u8; 8] = *b"KIME\x01\0\0\0";
36/// The format version this build reads and writes.
37pub const VERSION: u32 = 1;
38/// Alignment of the header block, the data section and every tensor.
39pub const ALIGN: usize = 4096;
40const FIXED: usize = 72;
41
42/// A file carried inside a `.kime`, such as a config or the tokenizer.
43#[derive(Debug, Clone, PartialEq, Eq)]
44pub struct FileEntry {
45    /// Path relative to the model directory, with forward slashes.
46    pub name: String,
47    /// First byte, absolute.
48    pub start: usize,
49    /// One past the last byte, absolute.
50    pub end: usize,
51}
52
53/// A parsed and checked `.kime` index.
54#[derive(Debug, Clone, PartialEq)]
55pub struct Index {
56    /// The whole JSON, for fields this module does not interpret.
57    pub json: Map<String, Value>,
58    /// The model family, `laya` for now.
59    pub family: String,
60    /// The model id.
61    pub id: String,
62    /// blake3 of the data section, as 64 hex digits.
63    pub hash: String,
64    /// Tensors, in their original order, with absolute offsets.
65    pub tensors: Vec<Entry>,
66    /// Carried files, with absolute offsets.
67    pub files: Vec<FileEntry>,
68    /// Where the data section starts.
69    pub data_start: usize,
70}
71
72fn u64_at(b: &[u8], at: usize) -> u64 {
73    u64::from_le_bytes(b[at..at + 8].try_into().expect("8 bytes"))
74}
75
76/// Whether `bytes` starts like a `.kime` file.
77#[must_use]
78pub fn is_kime(bytes: &[u8]) -> bool {
79    bytes.starts_with(&MAGIC)
80}
81
82/// Parses and checks the index of a `.kime` file. The data section is not hashed here, see
83/// [`verify`].
84///
85/// # Errors
86///
87/// [`Error::Format`] naming the first problem found.
88///
89/// # Panics
90///
91/// Never. Fixed header fields are read only after the length check.
92pub fn parse(bytes: &[u8]) -> Result<Index> {
93    let bad = |msg: String| Error::Format(format!(".kime: {msg}"));
94    if bytes.len() < ALIGN {
95        return Err(bad(format!("{} bytes is shorter than the header block", bytes.len())));
96    }
97    if !is_kime(bytes) {
98        return Err(bad("wrong magic".into()));
99    }
100    let version = u32::from_le_bytes(bytes[8..12].try_into().expect("4 bytes"));
101    if version != VERSION {
102        return Err(bad(format!("format version {version}, this build reads {VERSION}")));
103    }
104    if bytes[12..16].iter().chain(&bytes[FIXED..ALIGN]).any(|&b| b != 0) {
105        return Err(bad("reserved header bytes are not zero".into()));
106    }
107    let (json_len, data_start, data_len) =
108        (u64_at(bytes, 16), u64_at(bytes, 24), u64_at(bytes, 32));
109    if json_len > MAX_HEADER as u64 {
110        return Err(bad(format!("index of {json_len} bytes is over the limit")));
111    }
112    let json_end = ALIGN + json_len as usize;
113    if !data_start.is_multiple_of(ALIGN as u64)
114        || data_start < json_end as u64
115        || data_start.checked_add(data_len) != Some(bytes.len() as u64)
116    {
117        return Err(bad(format!(
118            "data section {data_start}+{data_len} does not follow the index and end the {} byte file",
119            bytes.len()
120        )));
121    }
122    let data_start = data_start as usize;
123    let data_len = data_len as usize;
124    let json_bytes = &bytes[ALIGN..json_end];
125    if blake3::hash(json_bytes).as_bytes() != &bytes[40..72] {
126        return Err(bad("index does not match its hash".into()));
127    }
128    let Ok(Value::Object(json)) = serde_json::from_slice::<Value>(json_bytes) else {
129        return Err(bad("index is not a JSON object".into()));
130    };
131    let str_field = |k: &str| {
132        json.get(k)
133            .and_then(Value::as_str)
134            .map(str::to_string)
135            .ok_or_else(|| bad(format!("index has no {k:?}")))
136    };
137    if str_field("format")? != "kime/1" {
138        return Err(bad("index format is not kime/1".into()));
139    }
140    let hash = str_field("hash")?;
141    if hash.len() != 64 || !hash.bytes().all(|b| b.is_ascii_hexdigit()) {
142        return Err(bad("hash is not 64 hex digits".into()));
143    }
144    let range = |item: &Map<String, Value>, what: &str| -> Result<(usize, usize)> {
145        let num = |k: &str| {
146            item.get(k)
147                .and_then(Value::as_u64)
148                .and_then(|n| usize::try_from(n).ok())
149                .ok_or_else(|| bad(format!("{what}: {k:?} is not a non negative integer")))
150        };
151        let (off, len) = (num("offset")?, num("len")?);
152        match off.checked_add(len) {
153            Some(end) if end <= data_len => Ok((data_start + off, data_start + end)),
154            _ => Err(bad(format!("{what}: {off}+{len} falls outside {data_len} data bytes"))),
155        }
156    };
157    let list = |k: &str| {
158        json.get(k).and_then(Value::as_array).ok_or_else(|| bad(format!("index has no {k:?} list")))
159    };
160    let mut tensors = Vec::new();
161    for t in list("tensors")? {
162        let t = t.as_object().ok_or_else(|| bad("a tensor entry is not an object".into()))?;
163        let name = t
164            .get("name")
165            .and_then(Value::as_str)
166            .ok_or_else(|| bad("a tensor has no name".into()))?
167            .to_string();
168        let what = format!("tensor {name:?}");
169        let dtype = t
170            .get("dtype")
171            .and_then(Value::as_str)
172            .and_then(DType::from_name)
173            .ok_or_else(|| bad(format!("{what}: missing or unknown dtype")))?;
174        let shape = t
175            .get("shape")
176            .and_then(Value::as_array)
177            .and_then(|a| {
178                a.iter().map(|d| usize::try_from(d.as_u64()?).ok()).collect::<Option<Vec<_>>>()
179            })
180            .ok_or_else(|| bad(format!("{what}: shape must be a list of non negative integers")))?;
181        let (start, end) = range(t, &what)?;
182        if !(start - data_start).is_multiple_of(ALIGN) {
183            return Err(bad(format!("{what}: offset is not aligned to {ALIGN}")));
184        }
185        let want =
186            byte_len(dtype, &shape).ok_or_else(|| bad(format!("{what}: shape overflows")))?;
187        if end - start != want {
188            return Err(bad(format!(
189                "{what}: holds {} bytes but {dtype} {shape:?} needs {want}",
190                end - start
191            )));
192        }
193        tensors.push(Entry { name, dtype, shape, start, end });
194    }
195    let mut files = Vec::new();
196    for f in list("files")? {
197        let f = f.as_object().ok_or_else(|| bad("a file entry is not an object".into()))?;
198        let name = f
199            .get("name")
200            .and_then(Value::as_str)
201            .filter(|n| safe_name(n))
202            .ok_or_else(|| bad("a file has no name or an unsafe one".into()))?
203            .to_string();
204        let (start, end) = range(f, &format!("file {name:?}"))?;
205        files.push(FileEntry { name, start, end });
206    }
207    let mut ranges: Vec<_> = tensors
208        .iter()
209        .map(|e| (e.start, e.end, e.name.as_str()))
210        .chain(files.iter().map(|f| (f.start, f.end, f.name.as_str())))
211        .collect();
212    check_disjoint(&mut ranges).map_err(|e| bad(e.to_string()))?;
213    Ok(Index {
214        family: str_field("family")?,
215        id: str_field("id")?,
216        hash,
217        tensors,
218        files,
219        data_start,
220        json,
221    })
222}
223
224/// A carried file name must stay inside the directory it is unpacked to.
225fn safe_name(name: &str) -> bool {
226    !name.is_empty()
227        && !name.starts_with('/')
228        && !name.contains('\\')
229        && name.split('/').all(|part| !part.is_empty() && part != "." && part != "..")
230}
231
232/// Hashes the data section and compares it with the index. This reads every byte, on all cores, so
233/// it runs at memory or disk speed.
234///
235/// # Errors
236///
237/// [`Error::Format`] if the data does not match.
238pub fn verify(bytes: &[u8], index: &Index) -> Result<()> {
239    let got = blake3::Hasher::new().update_rayon(&bytes[index.data_start..]).finalize().to_hex();
240    if got.as_str() == index.hash {
241        Ok(())
242    } else {
243        Err(Error::Format(format!(".kime: data hash is {got}, the index says {}", index.hash)))
244    }
245}
246
247/// What to pack.
248#[derive(Debug)]
249pub struct Contents<'a> {
250    /// Model family.
251    pub family: &'a str,
252    /// Model id.
253    pub id: &'a str,
254    /// The tensors, packed in their order.
255    pub tensors: &'a Tensors,
256    /// Files to carry, by relative name.
257    pub files: Vec<(&'a str, &'a [u8])>,
258    /// Extra index fields, such as the source safetensors metadata.
259    pub extra: Map<String, Value>,
260}
261
262/// Writes a `.kime` file and returns its model hash.
263///
264/// # Errors
265///
266/// Any error from `out`, or [`Error::Format`] for a file name that could escape its directory.
267///
268/// # Panics
269///
270/// Never. The index holds only strings and numbers, which always serialize.
271pub fn write(c: &Contents<'_>, out: &mut impl Write) -> Result<String> {
272    let io = |e: std::io::Error| Error::Io("<output>".into(), e);
273    for (name, _) in &c.files {
274        if !safe_name(name) {
275            return Err(Error::Format(format!("refusing to pack file name {name:?}")));
276        }
277    }
278    // Lay out the data section, then hash it by streaming the same bytes the writer will write.
279    let pieces: Vec<&[u8]> = (0..c.tensors.entries().len())
280        .map(|i| c.tensors.view(i).bytes)
281        .chain(c.files.iter().map(|(_, b)| *b))
282        .collect();
283    let mut offsets = Vec::with_capacity(pieces.len());
284    let mut at = 0usize;
285    for p in &pieces {
286        offsets.push(at);
287        at = (at + p.len()).next_multiple_of(ALIGN);
288    }
289    let data_len = at;
290    let zeros = [0u8; ALIGN];
291    let mut hasher = blake3::Hasher::new();
292    let mut written = 0;
293    for (p, &off) in pieces.iter().zip(&offsets) {
294        hasher.update(&zeros[..off - written]);
295        hasher.update(p);
296        written = off + p.len();
297    }
298    hasher.update(&zeros[..data_len - written]);
299    let hash = hasher.finalize().to_hex().to_string();
300
301    let n = c.tensors.entries().len();
302    let tensors: Vec<Value> = c
303        .tensors
304        .entries()
305        .iter()
306        .zip(&offsets)
307        .map(|(e, &off)| {
308            json!({"name": e.name, "dtype": e.dtype.name(), "shape": e.shape, "offset": off, "len": e.end - e.start})
309        })
310        .collect();
311    let files: Vec<Value> = c
312        .files
313        .iter()
314        .zip(&offsets[n..])
315        .map(|((name, b), &off)| json!({"name": name, "offset": off, "len": b.len()}))
316        .collect();
317    let mut index = Map::new();
318    index.insert("format".into(), "kime/1".into());
319    index.insert("family".into(), c.family.into());
320    index.insert("id".into(), c.id.into());
321    index.insert("hash".into(), hash.clone().into());
322    for (k, v) in &c.extra {
323        index.insert(k.clone(), v.clone());
324    }
325    index.insert("tensors".into(), tensors.into());
326    index.insert("files".into(), files.into());
327    let json = serde_json::to_vec(&Value::Object(index)).expect("plain values");
328    let data_start = (ALIGN + json.len()).next_multiple_of(ALIGN);
329
330    let mut head = vec![0u8; ALIGN];
331    head[..8].copy_from_slice(&MAGIC);
332    head[8..12].copy_from_slice(&VERSION.to_le_bytes());
333    head[16..24].copy_from_slice(&(json.len() as u64).to_le_bytes());
334    head[24..32].copy_from_slice(&(data_start as u64).to_le_bytes());
335    head[32..40].copy_from_slice(&(data_len as u64).to_le_bytes());
336    head[40..72].copy_from_slice(blake3::hash(&json).as_bytes());
337    out.write_all(&head).map_err(io)?;
338    out.write_all(&json).map_err(io)?;
339    out.write_all(&zeros[..data_start - ALIGN - json.len()]).map_err(io)?;
340    let mut written = 0;
341    for (p, &off) in pieces.iter().zip(&offsets) {
342        out.write_all(&zeros[..off - written]).map_err(io)?;
343        out.write_all(p).map_err(io)?;
344        written = off + p.len();
345    }
346    out.write_all(&zeros[..data_len - written]).map_err(io)?;
347    Ok(hash)
348}
349
350/// Opens the tensors of a `.kime` file held in `blob`.
351///
352/// # Errors
353///
354/// [`Error::Format`] if the index is invalid.
355pub fn load(blob: Blob) -> Result<(Index, Tensors)> {
356    let index = parse(&blob)?;
357    let tensors = Tensors::new(blob, index.tensors.clone())?;
358    Ok((index, tensors))
359}
360
361#[cfg(test)]
362mod tests {
363    use super::*;
364    use crate::safetensors;
365
366    fn sample() -> Tensors {
367        let header = r#"{"a":{"dtype":"F32","shape":[2],"data_offsets":[0,8]},"b":{"dtype":"F16","shape":[3],"data_offsets":[8,14]}}"#;
368        let mut v = (header.len() as u64).to_le_bytes().to_vec();
369        v.extend_from_slice(header.as_bytes());
370        v.extend_from_slice(&[1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14]);
371        safetensors::load(Blob::owned(v)).unwrap().0
372    }
373
374    fn pack(t: &Tensors) -> Vec<u8> {
375        let c = Contents {
376            family: "laya",
377            id: "test",
378            tensors: t,
379            files: vec![("encoder/config.json", b"{}")],
380            extra: Map::new(),
381        };
382        let mut out = Vec::new();
383        write(&c, &mut out).unwrap();
384        out
385    }
386
387    #[test]
388    fn round_trip() {
389        let t = sample();
390        let bytes = pack(&t);
391        assert_eq!(bytes.len() % ALIGN, 0);
392        let index = parse(&bytes).unwrap();
393        verify(&bytes, &index).unwrap();
394        assert_eq!(index.files[0].name, "encoder/config.json");
395        assert_eq!(&bytes[index.files[0].start..index.files[0].end], b"{}");
396        let (_, t2) = load(Blob::owned(bytes)).unwrap();
397        for (a, b) in t.entries().iter().zip(t2.entries()) {
398            assert_eq!((&a.name, a.dtype, &a.shape), (&b.name, b.dtype, &b.shape));
399            assert_eq!(t2.blob()[b.start..b.end], t.blob()[a.start..a.end]);
400            assert_eq!(b.start % ALIGN, 0);
401        }
402    }
403
404    #[test]
405    fn rejects_damage() {
406        let good = pack(&sample());
407        let mut bad = good.clone();
408        let n = bad.len();
409        bad[n - 1] ^= 1;
410        let index = parse(&bad).unwrap();
411        assert!(verify(&bad, &index).unwrap_err().to_string().contains("data hash"));
412        let mut bad = good.clone();
413        bad[ALIGN + 3] ^= 1;
414        assert!(parse(&bad).unwrap_err().to_string().contains("does not match its hash"));
415        let mut bad = good.clone();
416        bad[100] = 1;
417        assert!(parse(&bad).unwrap_err().to_string().contains("reserved"));
418        let mut bad = good.clone();
419        bad[32..40].copy_from_slice(&u64::MAX.to_le_bytes());
420        assert!(parse(&bad).unwrap_err().to_string().contains("data section"));
421        assert!(parse(&good[..ALIGN - 1]).is_err());
422        assert!(
423            !safe_name("../x") && !safe_name("a//b") && !safe_name("/etc") && safe_name("a/b.json")
424        );
425    }
426}