Skip to content

Commit ded76e9

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 6acff68 commit ded76e9

5 files changed

Lines changed: 209 additions & 7 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: 50 additions & 7 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
@@ -47,7 +49,9 @@
4749
from ai.backend.storage import __version__
4850
from ai.backend.storage.dto.context import StorageRootCtx
4951
from ai.backend.storage.errors import (
52+
ChunkChecksumMismatchError,
5053
InvalidAPIParameters,
54+
InvalidUploadChecksumHeaderError,
5155
UploadChunkExceedsTotalSizeError,
5256
UploadOffsetMismatchError,
5357
)
@@ -415,6 +419,10 @@ class Params(TypedDict):
415419
f"Upload-Offset {client_offset} is out of range [0, {total_size}]"
416420
)
417421

422+
expected_checksum_hex = _parse_upload_checksum_header(
423+
request.headers.get("Upload-Checksum")
424+
)
425+
418426
async with ctx.get_volume(token_data["volume"]) as volume:
419427
session_dir = _resolve_tus_upload_session_dir(volume, token_data)
420428
if not session_dir.exists():
@@ -450,6 +458,10 @@ class Params(TypedDict):
450458
f"Chunk at offset {client_offset} with length {length} "
451459
f"exceeds declared size {total_size}"
452460
)
461+
if expected_checksum_hex is not None and expected_checksum_hex != sha256:
462+
raise ChunkChecksumMismatchError(
463+
f"Chunk at offset {client_offset} failed SHA-256 verification"
464+
)
453465
acceptance = await session.commit_chunk(
454466
offset=client_offset,
455467
chunk_path=temp_chunk.path,
@@ -481,7 +493,9 @@ def _resolve_tus_upload_session_dir(volume: AbstractVolume, token_data: UploadTo
481493
return volume.mangle_vfpath(token_data["vfid"]) / ".upload" / token_data["session"]
482494

483495

484-
_TUS_HEADER_LIST = "Tus-Resumable, Upload-Length, Upload-Metadata, Upload-Offset, Content-Type"
496+
_TUS_HEADER_LIST = (
497+
"Tus-Resumable, Upload-Length, Upload-Metadata, Upload-Offset, Upload-Checksum, Content-Type"
498+
)
485499

486500

487501
def _prepare_tus_session_headers(*, upload_offset: int, upload_length: int) -> dict[str, str]:
@@ -497,22 +511,51 @@ def _prepare_tus_session_headers(*, upload_offset: int, upload_length: int) -> d
497511
}
498512

499513

514+
def _parse_upload_checksum_header(raw: str | None) -> str | None:
515+
"""
516+
Parse a TUS Checksum extension header value into a hex SHA-256 digest.
517+
518+
Returns ``None`` if no header was sent. Raises
519+
``InvalidUploadChecksumHeaderError`` for malformed values or unsupported
520+
algorithms (only ``sha256`` is accepted).
521+
"""
522+
if raw is None:
523+
return None
524+
parts = raw.strip().split(None, 1)
525+
if len(parts) != 2:
526+
raise InvalidUploadChecksumHeaderError(
527+
f"Upload-Checksum must be '<algorithm> <base64>', got {raw!r}"
528+
)
529+
algorithm, encoded = parts
530+
if algorithm.lower() != "sha256":
531+
raise InvalidUploadChecksumHeaderError(
532+
f"Unsupported checksum algorithm: {algorithm}. Only 'sha256' is accepted."
533+
)
534+
try:
535+
digest = base64.b64decode(encoded, validate=True)
536+
except (binascii.Error, ValueError) as e:
537+
raise InvalidUploadChecksumHeaderError(
538+
f"Invalid base64 in Upload-Checksum: {encoded!r}"
539+
) from e
540+
if len(digest) != 32:
541+
raise InvalidUploadChecksumHeaderError(f"sha256 digest must be 32 bytes, got {len(digest)}")
542+
return digest.hex()
543+
544+
500545
async def tus_options(request: web.Request) -> web.Response:
501546
"""
502547
Let clients discover the supported features of our tus.io server-side implementation.
503548
"""
504549
ctx: RootContext = request.app["ctx"]
505550
headers = {}
506551
headers["Access-Control-Allow-Origin"] = "*"
507-
headers["Access-Control-Allow-Headers"] = (
508-
"Tus-Resumable, Upload-Length, Upload-Metadata, Upload-Offset, Content-Type"
509-
)
510-
headers["Access-Control-Expose-Headers"] = (
511-
"Tus-Resumable, Upload-Length, Upload-Metadata, Upload-Offset, Content-Type"
512-
)
552+
headers["Access-Control-Allow-Headers"] = _TUS_HEADER_LIST
553+
headers["Access-Control-Expose-Headers"] = _TUS_HEADER_LIST
513554
headers["Access-Control-Allow-Methods"] = "*"
514555
headers["Tus-Resumable"] = "1.0.0"
515556
headers["Tus-Version"] = "1.0.0"
557+
headers["Tus-Extension"] = "checksum"
558+
headers["Tus-Checksum-Algorithm"] = "sha256"
516559
headers["Tus-Max-Size"] = str(
517560
int(BinarySize.from_str(ctx.local_config.storage_proxy.max_upload_size)),
518561
)

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
UploadChunkExceedsTotalSizeError,
6870
UploadSessionCorruptedError,
6971
)
@@ -96,7 +98,9 @@
9698
"ServiceNotInitializedError",
9799
"UploadOffsetMismatchError",
98100
# upload
101+
"ChunkChecksumMismatchError",
99102
"ChunkConflictError",
103+
"InvalidUploadChecksumHeaderError",
100104
"UploadChunkExceedsTotalSizeError",
101105
"UploadSessionCorruptedError",
102106
# vfolder

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

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,3 +65,46 @@ def error_code(self) -> ErrorCode:
6565
operation=ErrorOperation.READ,
6666
error_detail=ErrorDetail.INTERNAL_ERROR,
6767
)
68+
69+
70+
class _ChecksumMismatch(web.HTTPClientError):
71+
"""
72+
TUS Checksum extension defines HTTP 460 for checksum mismatches.
73+
aiohttp does not ship a built-in class for this code, so we declare one.
74+
"""
75+
76+
status_code = 460
77+
78+
79+
class ChunkChecksumMismatchError(BackendAIError, _ChecksumMismatch):
80+
"""
81+
Raised when ``Upload-Checksum`` header does not match the SHA-256 digest
82+
of the received chunk body (HTTP 460 per TUS Checksum extension).
83+
"""
84+
85+
error_type = "https://api.backend.ai/probs/storage/chunk-checksum-mismatch"
86+
error_title = "Upload Chunk Checksum Mismatch"
87+
88+
def error_code(self) -> ErrorCode:
89+
return ErrorCode(
90+
domain=ErrorDomain.STORAGE_PROXY,
91+
operation=ErrorOperation.UPDATE,
92+
error_detail=ErrorDetail.MISMATCH,
93+
)
94+
95+
96+
class InvalidUploadChecksumHeaderError(BackendAIError, web.HTTPBadRequest):
97+
"""
98+
Raised when ``Upload-Checksum`` header is malformed or specifies an
99+
unsupported algorithm (only ``sha256`` is accepted).
100+
"""
101+
102+
error_type = "https://api.backend.ai/probs/storage/invalid-upload-checksum"
103+
error_title = "Invalid Upload-Checksum header"
104+
105+
def error_code(self) -> ErrorCode:
106+
return ErrorCode(
107+
domain=ErrorDomain.STORAGE_PROXY,
108+
operation=ErrorOperation.REQUEST,
109+
error_detail=ErrorDetail.INVALID_PARAMETERS,
110+
)

tests/unit/storage/api/test_tus_upload.py

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,9 @@
1010

1111
from __future__ import annotations
1212

13+
import base64
1314
import dataclasses
15+
import hashlib
1416
import secrets
1517
from collections.abc import AsyncIterator
1618
from pathlib import Path
@@ -29,7 +31,9 @@
2931
from ai.backend.common.types import RedisConnectionInfo, ValkeyTarget
3032
from ai.backend.storage.api.client import tus_upload_part
3133
from ai.backend.storage.errors import (
34+
ChunkChecksumMismatchError,
3235
InvalidAPIParameters,
36+
InvalidUploadChecksumHeaderError,
3337
UploadChunkExceedsTotalSizeError,
3438
UploadOffsetMismatchError,
3539
)
@@ -87,6 +91,7 @@ def _build_request(
8791
offset_header: str | None,
8892
valkey_client: ValkeyTusClient,
8993
lock_factory: DistributedLockFactory,
94+
checksum_header: str | None = None,
9095
) -> MagicMock:
9196
volume = MagicMock()
9297
volume.mangle_vfpath.return_value = vfpath
@@ -103,6 +108,8 @@ def _build_request(
103108
request.headers = {}
104109
if offset_header is not None:
105110
request.headers["Upload-Offset"] = offset_header
111+
if checksum_header is not None:
112+
request.headers["Upload-Checksum"] = checksum_header
106113
request.query = {"token": "test-token"}
107114

108115
if body is None:
@@ -426,3 +433,107 @@ async def test_chunk_exceeding_declared_size_raises(
426433
chunks_dir = patch_env.session_dir / "chunks"
427434
if chunks_dir.exists():
428435
assert list(chunks_dir.glob("*.tmp")) == []
436+
437+
438+
def _sha256_b64(data: bytes) -> str:
439+
return base64.b64encode(hashlib.sha256(data).digest()).decode("ascii")
440+
441+
442+
class TestUploadChecksum:
443+
async def test_matching_checksum_accepted(
444+
self,
445+
patch_env: _PatchEnv,
446+
valkey_tus_client: ValkeyTusClient,
447+
tus_lock_factory: DistributedLockFactory,
448+
) -> None:
449+
payload = b"X" * 1024
450+
token_data = _token_data(
451+
session_id=patch_env.session_id, total_size=1024, relpath="result.bin"
452+
)
453+
cp = _patch_handler_params(token_data)
454+
try:
455+
request = _build_request(
456+
vfpath=patch_env.vfpath,
457+
session_id=patch_env.session_id,
458+
total_size=1024,
459+
body=payload,
460+
offset_header="0",
461+
valkey_client=valkey_tus_client,
462+
lock_factory=tus_lock_factory,
463+
checksum_header=f"sha256 {_sha256_b64(payload)}",
464+
)
465+
response = await tus_upload_part(request)
466+
finally:
467+
cp.stop()
468+
assert response.headers["Upload-Offset"] == "1024"
469+
assert (patch_env.vfpath / "result.bin").read_bytes() == payload
470+
471+
async def test_mismatched_checksum_rejected(
472+
self,
473+
patch_env: _PatchEnv,
474+
valkey_tus_client: ValkeyTusClient,
475+
tus_lock_factory: DistributedLockFactory,
476+
) -> None:
477+
payload = b"X" * 1024
478+
wrong = _sha256_b64(b"different-payload")
479+
token_data = _token_data(
480+
session_id=patch_env.session_id, total_size=1024, relpath="result.bin"
481+
)
482+
cp = _patch_handler_params(token_data)
483+
try:
484+
request = _build_request(
485+
vfpath=patch_env.vfpath,
486+
session_id=patch_env.session_id,
487+
total_size=1024,
488+
body=payload,
489+
offset_header="0",
490+
valkey_client=valkey_tus_client,
491+
lock_factory=tus_lock_factory,
492+
checksum_header=f"sha256 {wrong}",
493+
)
494+
with pytest.raises(ChunkChecksumMismatchError):
495+
await tus_upload_part(request)
496+
finally:
497+
cp.stop()
498+
499+
# The mismatched chunk must not be committed.
500+
assert not (patch_env.vfpath / "result.bin").exists()
501+
chunks_dir = patch_env.session_dir / "chunks"
502+
if chunks_dir.exists():
503+
assert list(chunks_dir.glob("*")) == []
504+
505+
@pytest.mark.parametrize(
506+
"header",
507+
[
508+
"sha256", # missing digest
509+
"sha1 abc", # unsupported algorithm
510+
"sha256 not_base64!!", # invalid base64
511+
"sha256 " + base64.b64encode(b"too-short").decode("ascii"), # wrong length
512+
],
513+
)
514+
async def test_malformed_checksum_header_rejected(
515+
self,
516+
patch_env: _PatchEnv,
517+
valkey_tus_client: ValkeyTusClient,
518+
tus_lock_factory: DistributedLockFactory,
519+
header: str,
520+
) -> None:
521+
token_data = _token_data(
522+
session_id=patch_env.session_id, total_size=1024, relpath="result.bin"
523+
)
524+
cp = _patch_handler_params(token_data)
525+
try:
526+
request = _build_request(
527+
vfpath=patch_env.vfpath,
528+
session_id=patch_env.session_id,
529+
total_size=1024,
530+
body=b"X" * 1024,
531+
offset_header="0",
532+
valkey_client=valkey_tus_client,
533+
lock_factory=tus_lock_factory,
534+
checksum_header=header,
535+
)
536+
with pytest.raises(InvalidUploadChecksumHeaderError):
537+
await tus_upload_part(request)
538+
finally:
539+
cp.stop()

0 commit comments

Comments
 (0)