Blog · engineering

Never trust a model file until it’s verified

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

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.