Skip to content

feat: Validate model downloads against expected size and SHA-256#61

Merged
FuJacob merged 4 commits into
mainfrom
feat/model-download-validation
Apr 28, 2026
Merged

feat: Validate model downloads against expected size and SHA-256#61
FuJacob merged 4 commits into
mainfrom
feat/model-download-validation

Conversation

@Jam-Cai

@Jam-Cai Jam-Cai commented Apr 23, 2026

Copy link
Copy Markdown
Collaborator

Summary

Second of three stacked PRs against #6. Adds size + SHA-256 validation for curated model downloads, with a stage-validate-swap so a corrupt download can't take out a working previous install.
image

Validation

xcodebuild test -project tabby.xcodeproj -scheme tabby \
  -destination 'platform=macOS' CODE_SIGNING_ALLOWED=NO
# ** TEST SUCCEEDED **  43 tests  0 failures

11 new tests in ModelFileValidatorTests, including a 3 MB streaming-guard fixture so a regression to single-shot read would break the suite.

Linked issues

Refs #6 (acceptance criteria 4, 5, 6: validate before installed, corrupt files show error, validation works for all curated models)

Risk / rollout notes

Jam-Cai added 2 commits April 23, 2026 14:56
Wires Swift Task cancellation through to URLSession so users can stop
a long model download from the UI. Until now, Task.cancel() only flipped
Task.isCancelled — the URLSession download kept running until natural
completion, wasting bytes and silently ignoring the user's intent.

Changes:
- ModelDownloadManager.cancel(filename:) — public, idempotent.
- ModelDownloadSessionDelegate.download(from:) wraps the continuation in
  withTaskCancellationHandler. The onCancel block calls .cancel() on the
  retained URLSessionDownloadTask, which fires didCompleteWithError with
  URLError.cancelled and resumes the continuation.
- New pure helper DownloadOutcomeClassifier distinguishes user-cancel
  (CancellationError or URLError.cancelled) from real failures, so
  performDownload's catch routes the two correctly:
    - cancel  -> restore .idle (or .downloaded if a prior copy is on disk)
    - failure -> .failed(message), shown to the user
- DownloadableModelRow renders an xmark.circle.fill cancel button next
  to the percentage during .downloading; tooltip explains.

Tests (7 new, 32 total):
- DownloadOutcomeClassifierTests covers cancel surfaces (CancellationError,
  URLError.cancelled), real failure surfaces (timedOut, notConnected,
  badServerResponse, generic NSError), and the LlamaRuntimeError case
  (must NOT be misclassified as cancel — would silently roll back a real
  failure to .idle).

Verified locally:
  xcodebuild test -project tabby.xcodeproj -scheme tabby \
    -destination 'platform=macOS' CODE_SIGNING_ALLOWED=NO
  # ** TEST SUCCEEDED **  32 tests  0 failures

First of three stacked PRs against #6:
1. (this PR) cancel + cleanup
2. size + SHA-256 validation
3. retry UX polish

Refs #6
Adds integrity checks for curated model downloads. After a download
finishes, the file is moved to a staging URL, validated against expected
size + SHA-256, and only then swapped into the install location. If
validation fails, the staged file is removed and the prior install (if
any) stays in place — a corrupt download can't take out a working copy.

Validation strategy: both size and SHA-256.
- Size is a fast pre-check that catches truncated downloads.
- SHA-256 is the integrity guarantee against silent corruption (bit flips,
  partial writes, mirror substitution).
- Either alone is insufficient; together they're cheap.

Catalog now carries `expectedSizeBytes` and `sha256` for the three curated
models. Values were captured from HuggingFace's `x-linked-size` and
`x-linked-etag` response headers — refresh procedure documented in the
catalog. Both fields are Optional; validators no-op when nil so future
catalog additions don't have to wait on metadata to land.

Streams the SHA-256 in 1 MB chunks so the largest curated model
(~4.5 GB) doesn't get loaded entirely into memory.

Tests (11 new, 43 total):
- validateSize: match, mismatch (both directions), nil expected, missing file
- validateSHA256: known-good hash, uppercase tolerance, mismatch, nil
  expected, missing file
- streaming guard: 3 MB fixture exercises the multi-chunk read loop so a
  regression to single-shot read would fail the test

Verified locally:
  xcodebuild test -project tabby.xcodeproj -scheme tabby \
    -destination 'platform=macOS' CODE_SIGNING_ALLOWED=NO
  # ** TEST SUCCEEDED **  43 tests  0 failures

Stacked on #60 (download cancel). Folds into #6.

Refs #6
@FuJacob

FuJacob commented Apr 28, 2026

Copy link
Copy Markdown
Owner

@copilot resolve the merge conflicts in this pull request

…alidation

# Conflicts:
#	tabby.xcodeproj/project.pbxproj
#	tabby/Services/Utilities/ModelDownloadManager.swift

Co-authored-by: FuJacob <141651335+FuJacob@users.noreply.github.com>

Copilot AI commented Apr 28, 2026

Copy link
Copy Markdown

@copilot resolve the merge conflicts in this pull request

Done in 4a181e8. Two conflicts resolved:

Copilot AI requested a review from FuJacob April 28, 2026 16:03
FuJacob added a commit that referenced this pull request Apr 28, 2026
* feat: Add cancel control to in-flight model downloads (#6)

Wires Swift Task cancellation through to URLSession so users can stop
a long model download from the UI. Until now, Task.cancel() only flipped
Task.isCancelled — the URLSession download kept running until natural
completion, wasting bytes and silently ignoring the user's intent.

Changes:
- ModelDownloadManager.cancel(filename:) — public, idempotent.
- ModelDownloadSessionDelegate.download(from:) wraps the continuation in
  withTaskCancellationHandler. The onCancel block calls .cancel() on the
  retained URLSessionDownloadTask, which fires didCompleteWithError with
  URLError.cancelled and resumes the continuation.
- New pure helper DownloadOutcomeClassifier distinguishes user-cancel
  (CancellationError or URLError.cancelled) from real failures, so
  performDownload's catch routes the two correctly:
    - cancel  -> restore .idle (or .downloaded if a prior copy is on disk)
    - failure -> .failed(message), shown to the user
- DownloadableModelRow renders an xmark.circle.fill cancel button next
  to the percentage during .downloading; tooltip explains.

Tests (7 new, 32 total):
- DownloadOutcomeClassifierTests covers cancel surfaces (CancellationError,
  URLError.cancelled), real failure surfaces (timedOut, notConnected,
  badServerResponse, generic NSError), and the LlamaRuntimeError case
  (must NOT be misclassified as cancel — would silently roll back a real
  failure to .idle).

Verified locally:
  xcodebuild test -project tabby.xcodeproj -scheme tabby \
    -destination 'platform=macOS' CODE_SIGNING_ALLOWED=NO
  # ** TEST SUCCEEDED **  32 tests  0 failures

First of three stacked PRs against #6:
1. (this PR) cancel + cleanup
2. size + SHA-256 validation
3. retry UX polish

Refs #6

* feat: Validate model downloads against expected size and SHA-256 (#6)

Adds integrity checks for curated model downloads. After a download
finishes, the file is moved to a staging URL, validated against expected
size + SHA-256, and only then swapped into the install location. If
validation fails, the staged file is removed and the prior install (if
any) stays in place — a corrupt download can't take out a working copy.

Validation strategy: both size and SHA-256.
- Size is a fast pre-check that catches truncated downloads.
- SHA-256 is the integrity guarantee against silent corruption (bit flips,
  partial writes, mirror substitution).
- Either alone is insufficient; together they're cheap.

Catalog now carries `expectedSizeBytes` and `sha256` for the three curated
models. Values were captured from HuggingFace's `x-linked-size` and
`x-linked-etag` response headers — refresh procedure documented in the
catalog. Both fields are Optional; validators no-op when nil so future
catalog additions don't have to wait on metadata to land.

Streams the SHA-256 in 1 MB chunks so the largest curated model
(~4.5 GB) doesn't get loaded entirely into memory.

Tests (11 new, 43 total):
- validateSize: match, mismatch (both directions), nil expected, missing file
- validateSHA256: known-good hash, uppercase tolerance, mismatch, nil
  expected, missing file
- streaming guard: 3 MB fixture exercises the multi-chunk read loop so a
  regression to single-shot read would fail the test

Verified locally:
  xcodebuild test -project tabby.xcodeproj -scheme tabby \
    -destination 'platform=macOS' CODE_SIGNING_ALLOWED=NO
  # ** TEST SUCCEEDED **  43 tests  0 failures

Stacked on #60 (download cancel). Folds into #6.

Refs #6

* feat: Make download failure messages legible in the model catalog (#6)

Failure messages now have their own wrapping row below the row body,
with a warning icon. Validation errors include exact byte counts and
partial checksum prefixes — fitting them inline with the size label
clipped them on smaller windows.

Changes:
- New `failureMessageRow(message:)` renders the localized error in red
  with `exclamationmark.triangle.fill`. Pinned to fixedSize(vertical:)
  so SwiftUI allocates space for the wrap rather than truncating.
- Metadata text in `.failed` state now shows "Download failed" instead
  of the full statusText — the full message lives in the dedicated row.
- Retry button gets `arrow.counterclockwise` symbol; pairs with the
  warning row to reinforce "something went wrong, try again."

No new tests — pure SwiftUI tweak. Verified the row still builds:
  xcodebuild build -project tabby.xcodeproj -scheme tabby \
    -configuration Debug -destination 'platform=macOS' \
    CODE_SIGNING_ALLOWED=NO
  # ** BUILD SUCCEEDED **
And the existing 43 tests still pass.

Stacked on #61 (validation) and #60 (cancel). Closes the last
acceptance criterion of #6 — failed downloads can be retried from the
same UI with a legible error.

Refs #6

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: FuJacob <141651335+FuJacob@users.noreply.github.com>
@FuJacob FuJacob merged commit cd6a73d into main Apr 28, 2026
3 checks passed
@Jam-Cai Jam-Cai deleted the feat/model-download-validation branch April 29, 2026 02:59
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.

3 participants