1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
//! The identity of the BYTES a face embedder was loaded from.
//!
//! An [`crate::embeddings::face::EmbeddingSpace`] is a claim about which
//! function produced a vector, and the trained parameters are most of that
//! function. Everything else the manifest carries — the width, the
//! preprocessing, the feature names — is schema: two unrelated artifacts are
//! free to agree on all of it, and their cosine would then be returned rather
//! than refused. This module closes that by hashing the artifact directory
//! itself, so the space a vector carries names the weights it came out of.
//!
//! # Identity is of the BYTES, not of the load
//!
//! A token minted per `load` would be simpler and would be wrong here.
//! `&self` inference means fan-out is one [`crate::embeddings::face::FaceEmbedder`]
//! per worker over the same artifact, so the same space is legitimately
//! produced by more than one producer, and a per-load token would refuse
//! exactly the cross-worker comparisons those workers exist to make. A digest
//! of the bytes is equal across workers, across processes and across machines,
//! which is what the identity has to be.
//!
//! It is also not caller-forgeable in the way that matters: the caller chooses
//! which artifact to load, not what its digest is, and [`ArtifactDigest`] has
//! no public constructor.
//!
//! # The digest names the bytes at `load`, and quiescence is a PRECONDITION
//!
//! One walk, taken from the path handed to [`crate::Model::load`]. The value it
//! produces identifies the bytes **as read at `load`**, and under this crate's
//! threat model that is also the bytes every later prediction runs on:
//!
//! > **The artifact must not be modified in place while a
//! > [`crate::embeddings::face::FaceEmbedder`] holds it.** That is the same
//! > precondition CoreML itself has for a model it has mapped. A model is
//! > replaced by an atomic `rename` followed by loading a new embedder — the
//! > live mapping keeps the old inode's bytes, which is what every macOS
//! > updater relies on.
//!
//! A digest is not a defence against a hostile artifact or a hostile
//! filesystem, and neither is in this library's scope: whoever can rewrite the
//! bundle under a running process can already choose which bytes it loads. What
//! the digest is for is *confusion* — vectors from one set of weights scored
//! against vectors from another — and one walk at `load` catches that.
use ;
use ;
use crate;
/// How deep [`digest_artifact`] will walk before refusing.
///
/// **A plain resource cap, and no longer a safety mechanism.** It used to be
/// the only thing standing between the walk and an unbounded one, because
/// directory symlinks were followed and a link pointing at one of its own
/// parents is a cycle. A depth cap is not a bound on a GRAPH: two links per
/// level over ~25 physical levels expand to ~33 million logical leaves while
/// staying far inside this number. Directory symlinks are refused now (see
/// [`digest_artifact`]), so the walk is linear in the PHYSICAL tree and this
/// is a backstop against a pathologically nested real directory rather than
/// against a DAG. A real compiled bundle nests two or three levels.
const MAX_DEPTH: usize = 64;
/// How many directory entries [`digest_artifact`] will visit before refusing.
///
/// The second of the two plain resource caps on a walk that is now linear in
/// the physical tree — [`MAX_DEPTH`] bounds it downwards, this one bounds its
/// total size. Every entry the walk meets counts, whether it turns out to be a
/// file, a directory or neither. A compiled bundle holds a handful of files,
/// so 4 096 is generous by three orders of magnitude.
///
/// It REFUSES rather than truncating, like every other failure here: a digest
/// standing for "the first 4 096 files" would be an identity for bytes nobody
/// has.
const MAX_ENTRIES: usize = 4096;
/// Bytes read per `read` call while hashing one file. A compiled model's
/// `weights/weight.bin` can be hundreds of megabytes, so it is streamed rather
/// than read whole.
const READ_CHUNK: usize = 1 << 16;
/// The SHA-256 identity of one compiled model artifact's bytes.
///
/// Produced only by [`crate::embeddings::face::FaceEmbedder::load`], from the
/// path it loads. **There is no public constructor**: a caller picks the
/// artifact, and this value is then a fact about it rather than a claim about
/// it.
///
/// Two `FaceEmbedder`s on different machines that loaded byte-identical
/// bundles hold equal digests, which is what lets their embeddings be
/// compared. Two that loaded different bundles do not, and
/// [`crate::embeddings::face::FaceEmbedding::dot`] refuses across them.
///
/// ```compile_fail,E0599
/// use coremlit::embeddings::face::ArtifactDigest;
/// // There is no public constructor: a digest is a fact about the bytes
/// // `FaceEmbedder::load` read, not a value a caller gets to state.
/// let _ = ArtifactDigest::from_raw([0u8; 32]);
/// ```
;
/// The [`ArtifactDigest`] of everything under `root`.
///
/// # The encoding, stated exactly
///
/// Let the ENTRIES be every regular file reachable from `root`, each written
/// as a pair `(relative, SHA-256(file contents))` where `relative` is the
/// file's path below `root` with its components joined by a single `/`
/// (`0x2F`) and no leading separator. Sort the entries by `relative`, compared
/// as raw bytes. The digest is then
///
/// ```text
/// SHA-256( for each entry in order: u64_le(relative.len()) ‖ relative ‖ sha256(file) )
/// ```
///
/// The length prefix is what makes that encoding injective: without it
/// `("ab", h₁), ("c", h₂)` and `("a", h₁'), ("bc", h₂')` could serialise to the
/// same bytes, and two different trees would hash the same. Sorting is what
/// makes it deterministic — `read_dir` order is the filesystem's business, not
/// the artifact's.
///
/// Four rules about what counts, each of which a gate in `tests.rs` pins:
///
/// - **regular files only.** A directory contributes only through the files
/// under it, so an empty directory is invisible; anything that is neither a
/// directory nor a regular file (a socket, a device node) carries no
/// artifact bytes and is skipped.
/// - **every regular file, with NO exemption by name.** A dot-prefixed child
/// is hashed exactly like any other. The rule used to skip them so that a
/// `.DS_Store` would not move the digest, and that was an enumeration of
/// "what does not matter" with a case missing: a CoreML ML Program can name
/// an external `BLOBFILE` by path, and `@model_path/.weights/weight.bin` is
/// a legal one — so two bundles agreeing on every visible file and
/// differing in their hidden weights had ONE digest, and vectors from one
/// were scored against vectors from the other. Sparing `.weights` next would
/// be the next enumeration; no rule over NAMES separates the model from the
/// noise, because the filesystem does not record that distinction. **The consequence, stated rather than dodged:** a
/// bundle a Finder window has been opened on is a different artifact from
/// the same bundle on a worker that never browsed it, and their embeddings
/// do not compare until the `.DS_Store` is removed. That is the honest
/// answer — the artifact is a different set of bytes — and it is the rule
/// `MODELS_LOCK` already applies to bundle bytes everywhere else here.
/// - **FILE symlinks followed, DIRECTORY symlinks refused.** A link to a file
/// is hashed as the bytes it resolves to, which is what a bundle assembled
/// out of links needs — a Hugging Face cache snapshot is a directory of file
/// links into `blobs/`, and it must work. A link to a DIRECTORY is
/// [`Error::ArtifactDigest`] naming the link, because recursing through one
/// makes this a walk of a GRAPH rather than of a tree: two links per level
/// over ~25 physical levels expand to ~33 million logical leaves while
/// staying far inside [`MAX_DEPTH`], so the walk exhausts memory long before
/// it exhausts its depth. No recursion through a link means no DAG, which is
/// why both caps below are plain resource caps on a walk that is linear in
/// the PHYSICAL tree. A BROKEN file link is an error rather than a skip: it
/// cannot be followed, and a bundle with one is not a bundle whose bytes are
/// known.
/// - **`root` may be a regular file**, in which case there is exactly one
/// entry and its `relative` is empty. [`crate::Model::load`] accepts any
/// path CoreML accepts, and a compiled `.mlmodelc` is a directory in
/// practice, but hashing what was actually loaded must not depend on that.
/// `root` may also itself be a symlink to a directory: it is the path the
/// caller chose rather than something found inside the bundle, it is
/// resolved exactly once, and nothing recurses through it.
///
/// # Every allocation here is bounded by a constant
///
/// [`crate::embeddings::face::embed`]'s rule is that a length known only at
/// run time is reserved fallibly. Nothing on this walk has one. The entry list
/// grows by `push` and [`MAX_ENTRIES`] refuses the 4 097th before it is
/// reached; each `relative` path is bounded by [`MAX_DEPTH`] names the
/// filesystem has already capped; the read buffer is exactly [`READ_CHUNK`],
/// which is why a multi-hundred-megabyte `weight.bin` is streamed rather than
/// read whole; and the only [`std::path::PathBuf`] built is on a refusal, from
/// a path that already exists. So the walk allocates infallibly, and no number
/// out of the artifact can move what it asks for.
///
/// # Errors
/// [`Error::ArtifactDigest`] naming the path that failed, for any I/O failure
/// while walking or reading, for a `root` that is neither a directory nor a
/// regular file, for a symlink to a directory anywhere under `root`, and for a
/// tree that exceeds [`MAX_DEPTH`] or [`MAX_ENTRIES`]. It fails closed: there
/// is no digest that stands for "some of the bytes".
pub
/// Sorts the `(relative path, file digest)` entries and folds them into the
/// artifact digest.
///
/// Split out of [`digest_artifact`] because both of its properties are
/// properties of THIS function and of nothing else, and one of them cannot be
/// tested through a filesystem at all:
///
/// - **the sort** is what makes the digest independent of `read_dir` order,
/// which is the filesystem's business rather than the artifact's;
/// - **the length prefix** is what makes the concatenation injective. Without
/// it the entry lists `[("x", Hx), ("y", Hy)]` and `[("x"‖Hx‖"y", Hy)]`
/// serialise to the same bytes, and two different artifacts get one
/// identity. That collision needs a file NAME holding the raw bytes of a
/// SHA-256, which APFS refuses (`EILSEQ`: a name must be valid UTF-8) — so
/// the gate feeds the two lists in here directly rather than staging them.
/// Unreachable through one filesystem is not the same as absent from the
/// encoding, and the encoding is what this crate defines.
/// Appends every regular file under `directory` to `entries`, with `prefix` as
/// its path below the artifact root.
///
/// `visited` counts every entry the whole walk has met — files, directories
/// and everything else — against [`MAX_ENTRIES`]. It is threaded rather than
/// derived from `entries.len()` because the cap is on the WALK, and a tree can
/// be arbitrarily large in directories that contribute no file at all.
/// SHA-256 of one file's contents, streamed.
/// One I/O failure, named by the path it happened on.