Blog · engineering
Never trust a model file until it’s verified
20 July 2026 · the gate between “downloaded” and “loaded”
A local model is the most dangerous input the app handles. It’s a multi-gigabyte binary, streamed from a public mirror you don’t control, and its destiny is to be memory-mapped and parsed by a native C++ loader (llama.cpp’s GGUF reader). Untrusted bytes going straight into a native parser is the textbook remote-code-execution surface. A truncated transfer, a poisoned mirror, a TLS-MITM, or — as it turned out — a plain HTTP error page, all reach the same parser. So there is a hard gate between downloaded and loaded, and nothing crosses it unverified.
The gate
Two checks. First, the GGUF magic header — the four bytes every real model starts with. Second, when the catalog pins one, the whole-file SHA-256. Magic alone rejects HTML error pages and obviously-wrong files instantly; the hash catches a silent substitution or a truncation that happens to keep the header:
static func verify(fileURL: URL, expectedSHA256: String?) throws {
let head = try readHead(fileURL, 4)
guard hasGGUFMagic(head) else { // 47 47 55 46 == "GGUF"
throw ModelIntegrityError.notGGUF(foundMagic: head.hex)
}
if let expected = expectedSHA256, !expected.isEmpty {
let actual = try sha256Hex(of: fileURL) // streamed, constant memory
guard actual.caseInsensitiveCompare(expected) == .orderedSame else {
throw ModelIntegrityError.checksumMismatch(expected: expected, actual: actual)
}
}
}The SHA is computed by streaming the file through the hasher a megabyte at a time, so verifying a 13 GB
model costs kilobytes of RAM, not gigabytes. The file is written to a .partial staging path
and only renamed into its real location after it verifies — because any consumer
that picks a model by “does this file exist” must never be able to see a half-written one.
The App Store reviewer found the hole for us
Here is the part we didn’t design for. One catalog entry — a Gemma model — pointed at a
repository that didn’t actually host that quantization. The URL 404’d. And the downloader, at that
point, wrote whatever the server sent to disk before anyone checked the status code. So Hugging
Face’s plain-text Entry not found body landed on disk, failed the GGUF magic check, and
surfaced to the user as a cryptic ModelIntegrityError error 0. It was the model
auto-recommended for a 16 GB MacBook Air — exactly the machine Apple reviews on. They
rejected the build with “the app delivered an error message upon the model downloading process,”
and they were right.
The integrity gate did its job — it refused to load a non-model — but the diagnosis was backwards: a network failure was being reported as a corruption failure. The fix is to reject a non-2xx response before writing a single byte, so an error page can never become a file to misdiagnose:
func urlSession(_ s: URLSession, dataTask: URLSessionDataTask,
didReceive response: URLResponse,
completionHandler: @escaping (URLSession.ResponseDisposition) -> Void) {
// 404 "Entry not found", 429 throttling, a gated-model wall — none of these are your file.
if let http = response as? HTTPURLResponse, !(200...299).contains(http.statusCode) {
completionHandler(.cancel)
finish(throwing: DownloadError.transport(reason: "the server returned HTTP \(http.statusCode)"))
return
}
// ...only now create the .partial file and start streaming bytes to it.
}Same download, three fixes: the status check above (so a server error reads as a server error), a real URL with a real pinned hash, and a human-readable message for the integrity errors that are real. The whole story, including the resubmission, is in the changelog.
The limitation: a pinned hash rots
A SHA-256 pin is a promise about a specific sequence of bytes, and it’s only as true as the day it
was made. Repositories get renamed. Quantizations get dropped and re-uploaded. main moves.
The moment any of that happens, the pin is pointing at bytes that no longer exist, and a check that was
protecting you is now the thing breaking your download.
Worse, the check we did have — a cross-platform parity test that all four implementations (iOS, Android, desktop, and the shared manifest) agree on the same URL and hash — gives false comfort here. It proves the twins agree. It says nothing about whether the URL is alive. All four agreeing on the same dead link still ships a dead link.
How to actually solve it
- Liveness in CI, not just parity. The immediate fix, already shipped: a job that does a one-byte ranged GET against every catalog URL and fails if any is dead or non-GGUF. It runs whenever a catalog file changes and on a schedule, so rot is caught by us, before a user’s multi-GB download 404s at the worst possible moment.
- Content-addressed fetches. The deeper fix is to stop trusting a path and
fetch by hash. Ask a CDN for the object whose content is
sha256:…; a content address can’t rot, because the name is the verification. Hugging Face’s Xet backend is already moving this way; leaning on it removes the “renamed repo” failure mode entirely. - Signed manifests. Pinning per-file hashes is fragile at scale. Sign the whole catalog manifest once, verify the signature on device, and trust the hashes transitively. That also closes the gap where an attacker who could rewrite the manifest could swap in their own “valid” hash.
None of this is paranoia. The threat model is simply honest about what the inputs are: bytes from a server you don’t own, going into a parser written in C. Verification is the cheapest insurance there is — and the failure that taught us to check the status code first cost one App Store round trip, which is a bargain compared to the alternative.