Skip to content

Commit 695c90f

Browse files
committed
fix(api): accept real ZIP submissions (raw-bytes fallback)
1 parent 3c74bbe commit 695c90f

2 files changed

Lines changed: 147 additions & 2 deletions

File tree

src/prism_challenge/routes.py

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from typing import SupportsFloat, SupportsInt, cast
55

66
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
7+
from pydantic import ValidationError
78

89
from .auth import authenticate_miner
910
from .models import (
@@ -13,7 +14,6 @@
1314
GpuStatusSummary,
1415
LeaderboardEntry,
1516
LeaderboardResponse,
16-
SubmissionCreate,
1717
SubmissionHistoryBucket,
1818
SubmissionResponse,
1919
SubmissionStatusResponse,
@@ -33,10 +33,20 @@ def repo_from_request(request: Request) -> PrismRepository:
3333
@router.post("/submissions", response_model=SubmissionResponse)
3434
async def submit_model(
3535
request: Request,
36-
request_body: SubmissionCreate,
3736
hotkey: str = Depends(authenticate_miner),
3837
repository: PrismRepository = Depends(repo_from_request),
3938
) -> SubmissionResponse:
39+
from .app import _bridge_submission_create
40+
41+
body = await request.body()
42+
try:
43+
request_body = _bridge_submission_create(
44+
body=body,
45+
content_type=request.headers.get("content-type", ""),
46+
filename=request.headers.get("x-submission-filename"),
47+
)
48+
except ValidationError as exc:
49+
raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, exc.errors()) from exc
4050
if len(request_body.code.encode()) > request.app.state.settings.max_code_bytes:
4151
raise HTTPException(status.HTTP_413_REQUEST_ENTITY_TOO_LARGE, "submission too large")
4252
return await repository.create_submission(hotkey, request_body)

tests/test_api.py

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,141 @@ def test_rejects_bad_signature(client):
113113
assert response.status_code == 401
114114

115115

116+
def _zip_submission_bytes() -> bytes:
117+
stream = io.BytesIO()
118+
with zipfile.ZipFile(stream, "w") as archive:
119+
archive.writestr("model.py", VALID_CODE)
120+
return stream.getvalue()
121+
122+
123+
def _read_submission_row(client: TestClient, submission_id: str) -> dict:
124+
import anyio
125+
126+
stored = client.app.state.repository
127+
128+
async def read_code():
129+
async with stored.database.connect() as conn:
130+
rows = await conn.execute_fetchall(
131+
"SELECT code, filename FROM submissions WHERE id=?", (submission_id,)
132+
)
133+
return dict(rows[0])
134+
135+
return anyio.run(read_code)
136+
137+
138+
def test_public_submission_accepts_raw_zip(client):
139+
raw = _zip_submission_bytes()
140+
response = client.post(
141+
"/v1/submissions",
142+
content=raw,
143+
headers={
144+
**signed_headers("secret", raw),
145+
"Content-Type": "application/zip",
146+
"X-Submission-Filename": "project.zip",
147+
},
148+
)
149+
assert response.status_code == 200, response.text
150+
submission_id = response.json()["id"]
151+
row = _read_submission_row(client, submission_id)
152+
assert row["filename"] == "project.zip"
153+
# Signature contract: bytes consumed by the handler must equal the bytes
154+
# authenticate_miner signed over (Starlette caches request.body()).
155+
assert base64.b64decode(row["code"]) == raw
156+
157+
158+
def test_public_submission_raw_zip_signature_contract(client):
159+
raw = _zip_submission_bytes()
160+
good = client.post(
161+
"/v1/submissions",
162+
content=raw,
163+
headers={**signed_headers("secret", raw), "Content-Type": "application/zip"},
164+
)
165+
assert good.status_code == 200, good.text
166+
row = _read_submission_row(client, good.json()["id"])
167+
assert row["filename"] == "submission.zip"
168+
assert base64.b64decode(row["code"]) == raw
169+
170+
bad = client.post(
171+
"/v1/submissions",
172+
content=raw,
173+
headers={
174+
"X-Hotkey": "hk",
175+
"X-Signature": "deadbeef",
176+
"X-Nonce": "n-bad",
177+
"X-Timestamp": signed_headers("secret", raw)["X-Timestamp"],
178+
"Content-Type": "application/zip",
179+
},
180+
)
181+
assert bad.status_code == 401, bad.text
182+
183+
184+
def test_public_submission_accepts_raw_python_octet_stream(client):
185+
raw = VALID_CODE.encode()
186+
response = client.post(
187+
"/v1/submissions",
188+
content=raw,
189+
headers={
190+
**signed_headers("secret", raw),
191+
"Content-Type": "application/octet-stream",
192+
"X-Submission-Filename": "entry.py",
193+
},
194+
)
195+
assert response.status_code == 200, response.text
196+
row = _read_submission_row(client, response.json()["id"])
197+
assert row["filename"] == "entry.py"
198+
assert base64.b64decode(row["code"]) == raw
199+
200+
201+
def test_public_submission_json_still_accepted(client):
202+
payload = {"code": VALID_CODE, "filename": "model.py"}
203+
body = json.dumps(payload, separators=(",", ":")).encode()
204+
response = client.post(
205+
"/v1/submissions",
206+
content=body,
207+
headers={**signed_headers("secret", body), "Content-Type": "application/json"},
208+
)
209+
assert response.status_code == 200, response.text
210+
assert response.json()["hotkey"] == "hk"
211+
212+
213+
def test_public_submission_rejects_traversal_filename(client):
214+
raw = _zip_submission_bytes()
215+
response = client.post(
216+
"/v1/submissions",
217+
content=raw,
218+
headers={
219+
**signed_headers("secret", raw),
220+
"Content-Type": "application/zip",
221+
"X-Submission-Filename": "../escape.py",
222+
},
223+
)
224+
assert response.status_code == 422, response.text
225+
assert response.status_code != 500
226+
227+
228+
def test_public_submission_malformed_json_no_500(client):
229+
raw = b"{not valid json"
230+
response = client.post(
231+
"/v1/submissions",
232+
content=raw,
233+
headers={**signed_headers("secret", raw), "Content-Type": "application/json"},
234+
)
235+
assert response.status_code == 400, response.text
236+
assert response.status_code != 500
237+
238+
239+
def test_public_submission_oversized_raw_zip_413(small_cap_client):
240+
cap = small_cap_client.app.state.settings.max_code_bytes
241+
raw = b"P" * (cap + 1)
242+
response = small_cap_client.post(
243+
"/v1/submissions",
244+
content=raw,
245+
headers={**signed_headers("secret", raw), "Content-Type": "application/zip"},
246+
)
247+
assert response.status_code == 413, response.text
248+
assert response.json()["detail"] == "submission too large"
249+
250+
116251
def test_internal_bridge_accepts_raw_zip_submission(client):
117252
stream = io.BytesIO()
118253
with zipfile.ZipFile(stream, "w") as archive:

0 commit comments

Comments
 (0)