Skip to content

Harden model download integrity (pre-release audit, wave 2)#536

Merged
FuJacob merged 1 commit into
mainfrom
fix/download-integrity
Jun 2, 2026
Merged

Harden model download integrity (pre-release audit, wave 2)#536
FuJacob merged 1 commit into
mainfrom
fix/download-integrity

Conversation

@FuJacob
Copy link
Copy Markdown
Owner

@FuJacob FuJacob commented Jun 2, 2026

Summary

Wave 2 of the pre-release audit: model-download integrity. Every curated catalog model ships with nil expectedSizeBytes/sha256, so validateSize/validateSHA256 no-op and the only integrity gate was the HTTP 2xx check. A body the server truncated while ending the transfer "cleanly" (an HTTP/2 stream reset, a proxy closing the connection) was therefore promoted as a complete model and later failed to load with an opaque llama.cpp error, with no way for the user to tell it was a bad download.

  • Completeness check. New ModelFileValidator.validateCompleteness compares the staged file's byte size to the server's declared Content-Length (no-op when unknown). Catches truncation without needing catalog metadata.
  • Temp/staging cleanup. The rescued temp file is now removed on every pre-staging failure (user cancel, non-2xx response — exactly when HuggingFace returns a small HTML error page, or the staging move failing), and the staged file on every validation/promotion failure. Both previously leaked.
  • Atomic promotion. Promote with replaceItemAt when a model already exists, so a failure mid-promotion can never destroy the existing good model before the replacement lands (the old delete-then-move could leave nothing installed).

Validation

xcodebuild ... test ... -only-testing:CotabbyTests/ModelFileValidatorTests
# ** TEST SUCCEEDED **  (14 tests; new: completeness passes on match, throws on truncation, no-ops when length unknown)
swiftlint --strict   # exit 0

The download manager's filesystem/network flow is not unit-tested (the repo pattern); the restructure was verified by trace, and the new validator is unit-tested.

Linked issues

None. Pre-release hardening, wave 2.

Risk / rollout notes

  • The completeness check only fires when the server sends a Content-Length; HuggingFace's CDN does, so truncated downloads are now caught before promotion. A legitimately complete download is unaffected.
  • Atomic promotion via replaceItemAt works because staging and the install location share the runtime directory (same volume).
  • Follow-up not in this PR: a corrupt file that is already installed still reads as "installed" (a fileExists check), so it blocks redownload; with this PR truncated files no longer get promoted in the first place, but recovering an already-bad install (e.g. exposing delete for the selected model) is a separate UX change.

Greptile Summary

This PR hardens the model download pipeline across three fronts: a validateCompleteness check that catches HTTP-cleanly-truncated bodies by comparing on-disk size to Content-Length, deterministic cleanup of both the temp and staging files on every failure path, and an atomic replaceItemAt-based promotion that prevents a crash from leaving the install location empty.

  • ModelFileValidator.validateCompleteness delegates to the existing validateSize, no-ops when Content-Length is absent or zero, and is backed by three new unit tests.
  • Cleanup discipline in performSingleFileDownload covers temp-file leaks on pre-staging failures (user cancel, non-2xx response, move error) and staging-file leaks on validation or promotion failures.
  • promoteStagedFile replaces the old delete-then-move with replaceItemAt when the destination exists and moveItem when it does not, though the conditional introduces a TOCTOU that replaceItemAt alone would eliminate.

Confidence Score: 4/5

Safe to merge; the cleanup and completeness-check logic is correct, and the only actionable finding is a narrow race in promoteStagedFile that causes a spurious download failure rather than data loss.

The promotion path has a fileExists-then-moveItem check that can fail if the destination is created between the two calls; the consequence is a failed download the user must retry, not a corrupted install. No data loss path was identified. The missing over-size test in ModelFileValidatorTests is the only other gap.

The promoteStagedFile method in ModelDownloadManager.swift and the validateCompleteness test block in ModelFileValidatorTests.swift would benefit from a second look.

Important Files Changed

Filename Overview
Cotabby/Services/Utilities/ModelDownloadManager.swift Restructures download pipeline with temp/staging cleanup on every failure path, completeness validation, and atomic promotion via replaceItemAt; a subtle TOCTOU in promoteStagedFile can cause a spurious failure when the destination is concurrently created.
Cotabby/Services/Utilities/ModelFileValidator.swift Adds validateCompleteness, which delegates to validateSize with the server's Content-Length; logic is correct and the fileManager injection is properly forwarded.
CotabbyTests/ModelFileValidatorTests.swift Adds three tests for validateCompleteness (match, truncation, unknown length); missing a test for a file that is larger than the declared Content-Length, which is a valid failure case.

Sequence Diagram

sequenceDiagram
    participant DM as ModelDownloadManager
    participant Delegate as ModelDownloadSessionDelegate
    participant FS as FileSystem
    participant Validator as ModelFileValidator

    DM->>Delegate: download(from: url)
    Delegate-->>DM: DownloadResult(temporaryURL, response)

    alt Task cancelled or non-2xx response
        DM->>FS: removeItem(temporaryURL) [cleanup]
        DM-->>DM: throw error
    end

    DM->>FS: moveItem(temporaryURL → stagingURL)
    alt Move fails
        DM->>FS: removeItem(temporaryURL) [cleanup]
        DM-->>DM: throw error
    end

    DM->>Validator: validateCompleteness(stagingURL, Content-Length)
    DM->>Validator: validateSize(stagingURL, expectedSizeBytes)
    DM->>Validator: validateSHA256(stagingURL, sha256)

    alt Any validation fails
        DM->>FS: removeItem(stagingURL) [cleanup]
        DM-->>DM: throw error
    end

    alt Destination exists
        DM->>FS: replaceItemAt(destinationURL, withItemAt: stagingURL)
    else Destination absent
        DM->>FS: moveItem(stagingURL → destinationURL)
    end

    alt Promotion fails
        DM->>FS: removeItem(stagingURL) [cleanup]
        DM-->>DM: throw error
    end

    DM-->>DM: "modelState = .downloaded"
Loading

Fix All in Codex Fix All in Claude Code

Reviews (1): Last reviewed commit: "Harden model download integrity (pre-rel..." | Re-trigger Greptile

Greptile also left 2 inline comments on this PR.

Every curated catalog model ships with nil expectedSizeBytes/sha256, so those
validators no-op and the only integrity gate was the HTTP 2xx check. A body the
server truncated while ending the transfer cleanly (HTTP/2 reset, proxy close)
was therefore promoted as a complete model and later failed to load with an
opaque llama.cpp error.

- Add ModelFileValidator.validateCompleteness, comparing the staged file's size
  to the server's declared Content-Length (no-op when unknown). This catches
  truncation without needing catalog metadata.
- Clean up the rescued temp file on every pre-staging failure (cancel, non-2xx,
  staging-move) and the staged file on every validation/promotion failure; both
  previously leaked into the temp/runtime directory.
- Promote atomically with replaceItemAt when a model already exists, so a failure
  mid-promotion can never destroy the existing good model before the replacement
  lands (the old delete-then-move could leave nothing installed).
Comment on lines 364 to 368
if fileManager.fileExists(atPath: destinationURL.path) {
try fileManager.removeItem(at: destinationURL)
_ = try fileManager.replaceItemAt(destinationURL, withItemAt: stagingURL)
} else {
try fileManager.moveItem(at: stagingURL, to: destinationURL)
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 TOCTOU in promoteStagedFile: fileExists is checked, then moveItem is called in the else branch. If another process or a concurrent download creates the destination between those two calls, moveItem throws "file already exists," the staging file is cleaned up, and the download is surfaced as failed — even though the data is fully valid. FileManager.replaceItemAt handles both the "destination exists" and "destination absent" cases atomically, so the fileExists branch is unnecessary.

Suggested change
if fileManager.fileExists(atPath: destinationURL.path) {
try fileManager.removeItem(at: destinationURL)
_ = try fileManager.replaceItemAt(destinationURL, withItemAt: stagingURL)
} else {
try fileManager.moveItem(at: stagingURL, to: destinationURL)
}
_ = try fileManager.replaceItemAt(destinationURL, withItemAt: stagingURL)

Fix in Codex Fix in Claude Code

}
}

func test_validateCompleteness_noOpsWhenLengthUnknown() throws {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 validateCompleteness rejects any size mismatch (not just truncation), but the test suite only covers the under-size case. A file that is larger than the declared Content-Length — e.g., a response with appended garbage bytes — also triggers sizeMismatch via validateSize, and having an explicit test documents that guarantee and guards against a future refactor that changes the comparison direction.

Suggested change
func test_validateCompleteness_noOpsWhenLengthUnknown() throws {
func test_validateCompleteness_throwsWhenFileLargerThanDeclaredLength() throws {
let url = try makeFixture(contents: Data(repeating: 0xAB, count: 120))
XCTAssertThrowsError(try ModelFileValidator.validateCompleteness(of: url, declaredContentLength: 100)) { error in
guard case ModelFileValidator.ValidationError.sizeMismatch = error else {
XCTFail("Expected sizeMismatch, got \(error)")
return
}
}
}
func test_validateCompleteness_noOpsWhenLengthUnknown() throws {

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Fix in Codex Fix in Claude Code

@FuJacob FuJacob merged commit fce9974 into main Jun 2, 2026
4 checks passed
@FuJacob FuJacob deleted the fix/download-integrity branch June 2, 2026 05:59
@FuJacob FuJacob mentioned this pull request Jun 2, 2026
Merged
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant