Skip to content

Commit 80b06c6

Browse files
jopemachineclaude
andcommitted
feat(BA-6158): support TUS Checksum extension
Opt-in per-chunk integrity verification on storage proxy uploads. - Parse \`Upload-Checksum: sha256 <base64-digest>\` on PATCH; compare to the sha256 computed while streaming the chunk body. On mismatch, raise \`ChunkChecksumMismatchError\` (HTTP 460 per TUS Checksum extension) and discard the temp chunk file. - Reject malformed header values (missing digest, non-sha256 algorithm, invalid base64, wrong digest length) with \`InvalidUploadChecksumHeaderError\` (400). - Advertise the extension in OPTIONS via \`Tus-Extension: checksum\` and \`Tus-Checksum-Algorithm: sha256\`. Add \`Upload-Checksum\` to the Access-Control-Allow-Headers / Expose-Headers lists. Clients that do not send the header observe no behavior change. Tests cover matching / mismatched / malformed header cases. Resolves BA-6158. Part of epic BA-6153 (implements BA-3974). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 583cb92 commit 80b06c6

5 files changed

Lines changed: 191 additions & 9 deletions

File tree

changes/11769.feature.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Support the TUS Checksum extension on storage proxy uploads: clients may now send `Upload-Checksum: sha256 <base64>` and the server rejects mismatched chunks with HTTP 460.

src/ai/backend/storage/api/client.py

Lines changed: 54 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55
from __future__ import annotations
66

77
import asyncio
8+
import base64
9+
import binascii
810
import logging
911
import os
1012
import urllib.parse
@@ -46,7 +48,12 @@
4648
from ai.backend.logging import BraceStyleAdapter
4749
from ai.backend.storage import __version__
4850
from ai.backend.storage.dto.context import StorageRootCtx
49-
from ai.backend.storage.errors import InvalidAPIParameters, UploadOffsetMismatchError
51+
from ai.backend.storage.errors import (
52+
ChunkChecksumMismatchError,
53+
InvalidAPIParameters,
54+
InvalidUploadChecksumHeaderError,
55+
UploadOffsetMismatchError,
56+
)
5057
from ai.backend.storage.services.file_stream.zip import (
5158
ZipArchiveStreamReader,
5259
)
@@ -404,6 +411,10 @@ class Params(TypedDict):
404411
f"Upload-Offset {client_offset} is out of range [0, {total_size}]"
405412
)
406413

414+
expected_checksum_hex = _parse_upload_checksum_header(
415+
request.headers.get("Upload-Checksum")
416+
)
417+
407418
async with ctx.get_volume(token_data["volume"]) as volume:
408419
session_dir = _resolve_session_dir(volume, token_data)
409420
if not session_dir.exists():
@@ -435,6 +446,10 @@ class Params(TypedDict):
435446
f"Chunk at offset {client_offset} with length {length} "
436447
f"exceeds declared size {total_size}"
437448
)
449+
if expected_checksum_hex is not None and expected_checksum_hex != sha256:
450+
raise ChunkChecksumMismatchError(
451+
f"Chunk at offset {client_offset} failed SHA-256 verification"
452+
)
438453
acceptance = await session.commit_chunk(
439454
offset=client_offset,
440455
chunk_path=temp_chunk.path,
@@ -466,7 +481,9 @@ def _resolve_session_dir(volume: AbstractVolume, token_data: UploadTokenData) ->
466481
return volume.mangle_vfpath(token_data["vfid"]) / ".upload" / token_data["session"]
467482

468483

469-
_TUS_HEADER_LIST = "Tus-Resumable, Upload-Length, Upload-Metadata, Upload-Offset, Content-Type"
484+
_TUS_HEADER_LIST = (
485+
"Tus-Resumable, Upload-Length, Upload-Metadata, Upload-Offset, Upload-Checksum, Content-Type"
486+
)
470487

471488

472489
def _tus_response_headers(*, upload_offset: int, upload_length: int) -> dict[str, str]:
@@ -482,22 +499,51 @@ def _tus_response_headers(*, upload_offset: int, upload_length: int) -> dict[str
482499
}
483500

484501

502+
def _parse_upload_checksum_header(raw: str | None) -> str | None:
503+
"""
504+
Parse a TUS Checksum extension header value into a hex SHA-256 digest.
505+
506+
Returns ``None`` if no header was sent. Raises
507+
``InvalidUploadChecksumHeaderError`` for malformed values or unsupported
508+
algorithms (only ``sha256`` is accepted).
509+
"""
510+
if raw is None:
511+
return None
512+
parts = raw.strip().split(None, 1)
513+
if len(parts) != 2:
514+
raise InvalidUploadChecksumHeaderError(
515+
f"Upload-Checksum must be '<algorithm> <base64>', got {raw!r}"
516+
)
517+
algorithm, encoded = parts
518+
if algorithm.lower() != "sha256":
519+
raise InvalidUploadChecksumHeaderError(
520+
f"Unsupported checksum algorithm: {algorithm}. Only 'sha256' is accepted."
521+
)
522+
try:
523+
digest = base64.b64decode(encoded, validate=True)
524+
except (binascii.Error, ValueError) as e:
525+
raise InvalidUploadChecksumHeaderError(
526+
f"Invalid base64 in Upload-Checksum: {encoded!r}"
527+
) from e
528+
if len(digest) != 32:
529+
raise InvalidUploadChecksumHeaderError(f"sha256 digest must be 32 bytes, got {len(digest)}")
530+
return digest.hex()
531+
532+
485533
async def tus_options(request: web.Request) -> web.Response:
486534
"""
487535
Let clients discover the supported features of our tus.io server-side implementation.
488536
"""
489537
ctx: RootContext = request.app["ctx"]
490538
headers = {}
491539
headers["Access-Control-Allow-Origin"] = "*"
492-
headers["Access-Control-Allow-Headers"] = (
493-
"Tus-Resumable, Upload-Length, Upload-Metadata, Upload-Offset, Content-Type"
494-
)
495-
headers["Access-Control-Expose-Headers"] = (
496-
"Tus-Resumable, Upload-Length, Upload-Metadata, Upload-Offset, Content-Type"
497-
)
540+
headers["Access-Control-Allow-Headers"] = _TUS_HEADER_LIST
541+
headers["Access-Control-Expose-Headers"] = _TUS_HEADER_LIST
498542
headers["Access-Control-Allow-Methods"] = "*"
499543
headers["Tus-Resumable"] = "1.0.0"
500544
headers["Tus-Version"] = "1.0.0"
545+
headers["Tus-Extension"] = "checksum"
546+
headers["Tus-Checksum-Algorithm"] = "sha256"
501547
headers["Tus-Max-Size"] = str(
502548
int(BinarySize.from_str(ctx.local_config.storage_proxy.max_upload_size)),
503549
)

src/ai/backend/storage/errors/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,9 @@
6363
QuotaTreeNotFoundError,
6464
)
6565
from .upload import (
66+
ChunkChecksumMismatchError,
6667
ChunkConflictError,
68+
InvalidUploadChecksumHeaderError,
6769
UploadSessionCorruptedError,
6870
)
6971
from .vfolder import (
@@ -95,7 +97,9 @@
9597
"ServiceNotInitializedError",
9698
"UploadOffsetMismatchError",
9799
# upload
100+
"ChunkChecksumMismatchError",
98101
"ChunkConflictError",
102+
"InvalidUploadChecksumHeaderError",
99103
"UploadSessionCorruptedError",
100104
# vfolder
101105
"VFolderNotFoundError",

src/ai/backend/storage/errors/upload.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,3 +47,46 @@ def error_code(self) -> ErrorCode:
4747
operation=ErrorOperation.READ,
4848
error_detail=ErrorDetail.INTERNAL_ERROR,
4949
)
50+
51+
52+
class _ChecksumMismatch(web.HTTPClientError):
53+
"""
54+
TUS Checksum extension defines HTTP 460 for checksum mismatches.
55+
aiohttp does not ship a built-in class for this code, so we declare one.
56+
"""
57+
58+
status_code = 460
59+
60+
61+
class ChunkChecksumMismatchError(BackendAIError, _ChecksumMismatch):
62+
"""
63+
Raised when ``Upload-Checksum`` header does not match the SHA-256 digest
64+
of the received chunk body (HTTP 460 per TUS Checksum extension).
65+
"""
66+
67+
error_type = "https://api.backend.ai/probs/storage/chunk-checksum-mismatch"
68+
error_title = "Upload Chunk Checksum Mismatch"
69+
70+
def error_code(self) -> ErrorCode:
71+
return ErrorCode(
72+
domain=ErrorDomain.STORAGE_PROXY,
73+
operation=ErrorOperation.UPDATE,
74+
error_detail=ErrorDetail.MISMATCH,
75+
)
76+
77+
78+
class InvalidUploadChecksumHeaderError(BackendAIError, web.HTTPBadRequest):
79+
"""
80+
Raised when ``Upload-Checksum`` header is malformed or specifies an
81+
unsupported algorithm (only ``sha256`` is accepted).
82+
"""
83+
84+
error_type = "https://api.backend.ai/probs/storage/invalid-upload-checksum"
85+
error_title = "Invalid Upload-Checksum header"
86+
87+
def error_code(self) -> ErrorCode:
88+
return ErrorCode(
89+
domain=ErrorDomain.STORAGE_PROXY,
90+
operation=ErrorOperation.REQUEST,
91+
error_detail=ErrorDetail.INVALID_PARAMETERS,
92+
)

tests/unit/storage/api/test_tus_upload.py

Lines changed: 89 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,9 @@
99

1010
from __future__ import annotations
1111

12+
import base64
1213
import dataclasses
14+
import hashlib
1315
from pathlib import Path
1416
from typing import Any
1517
from unittest.mock import AsyncMock, MagicMock, patch
@@ -18,7 +20,12 @@
1820
from aiohttp import web
1921

2022
from ai.backend.storage.api.client import tus_upload_part
21-
from ai.backend.storage.errors import InvalidAPIParameters, UploadOffsetMismatchError
23+
from ai.backend.storage.errors import (
24+
ChunkChecksumMismatchError,
25+
InvalidAPIParameters,
26+
InvalidUploadChecksumHeaderError,
27+
UploadOffsetMismatchError,
28+
)
2229

2330

2431
@dataclasses.dataclass(slots=True)
@@ -34,6 +41,7 @@ def _build_request(
3441
total_size: int,
3542
body: bytes | None,
3643
offset_header: str | None,
44+
checksum_header: str | None = None,
3745
) -> MagicMock:
3846
volume = MagicMock()
3947
volume.mangle_vfpath.return_value = vfpath
@@ -48,6 +56,8 @@ def _build_request(
4856
request.headers = {}
4957
if offset_header is not None:
5058
request.headers["Upload-Offset"] = offset_header
59+
if checksum_header is not None:
60+
request.headers["Upload-Checksum"] = checksum_header
5161
request.query = {"token": "test-token"}
5262

5363
if body is None:
@@ -298,3 +308,81 @@ async def test_chunk_exceeding_declared_size_raises(self, patch_env: _PatchEnv)
298308
chunks_dir = patch_env.session_dir / "chunks"
299309
if chunks_dir.exists():
300310
assert list(chunks_dir.glob("*.tmp")) == []
311+
312+
313+
def _sha256_b64(data: bytes) -> str:
314+
return base64.b64encode(hashlib.sha256(data).digest()).decode("ascii")
315+
316+
317+
class TestUploadChecksum:
318+
async def test_matching_checksum_accepted(self, patch_env: _PatchEnv) -> None:
319+
payload = b"X" * 1024
320+
token_data = _token_data(session_id="test-session", total_size=1024, relpath="result.bin")
321+
cp = _patch_handler_params(token_data)
322+
try:
323+
request = _build_request(
324+
vfpath=patch_env.vfpath,
325+
session_id="test-session",
326+
total_size=1024,
327+
body=payload,
328+
offset_header="0",
329+
checksum_header=f"sha256 {_sha256_b64(payload)}",
330+
)
331+
response = await tus_upload_part(request)
332+
finally:
333+
cp.stop()
334+
assert response.headers["Upload-Offset"] == "1024"
335+
assert (patch_env.vfpath / "result.bin").read_bytes() == payload
336+
337+
async def test_mismatched_checksum_rejected(self, patch_env: _PatchEnv) -> None:
338+
payload = b"X" * 1024
339+
wrong = _sha256_b64(b"different-payload")
340+
token_data = _token_data(session_id="test-session", total_size=1024, relpath="result.bin")
341+
cp = _patch_handler_params(token_data)
342+
try:
343+
request = _build_request(
344+
vfpath=patch_env.vfpath,
345+
session_id="test-session",
346+
total_size=1024,
347+
body=payload,
348+
offset_header="0",
349+
checksum_header=f"sha256 {wrong}",
350+
)
351+
with pytest.raises(ChunkChecksumMismatchError):
352+
await tus_upload_part(request)
353+
finally:
354+
cp.stop()
355+
356+
# The mismatched chunk must not be committed.
357+
assert not (patch_env.vfpath / "result.bin").exists()
358+
chunks_dir = patch_env.session_dir / "chunks"
359+
if chunks_dir.exists():
360+
assert list(chunks_dir.glob("*")) == []
361+
362+
@pytest.mark.parametrize(
363+
"header",
364+
[
365+
"sha256", # missing digest
366+
"sha1 abc", # unsupported algorithm
367+
"sha256 not_base64!!", # invalid base64
368+
"sha256 " + base64.b64encode(b"too-short").decode("ascii"), # wrong length
369+
],
370+
)
371+
async def test_malformed_checksum_header_rejected(
372+
self, patch_env: _PatchEnv, header: str
373+
) -> None:
374+
token_data = _token_data(session_id="test-session", total_size=1024, relpath="result.bin")
375+
cp = _patch_handler_params(token_data)
376+
try:
377+
request = _build_request(
378+
vfpath=patch_env.vfpath,
379+
session_id="test-session",
380+
total_size=1024,
381+
body=b"X" * 1024,
382+
offset_header="0",
383+
checksum_header=header,
384+
)
385+
with pytest.raises(InvalidUploadChecksumHeaderError):
386+
await tus_upload_part(request)
387+
finally:
388+
cp.stop()

0 commit comments

Comments
 (0)