ctfy.sdk.resources.submissions
client.submissions — graded submit, oracle verify, QA challenges.
1"""``client.submissions`` — graded submit, oracle verify, QA challenges.""" 2 3from __future__ import annotations 4 5import base64 6import builtins 7from typing import Any 8 9from ctfy.core.judge_budget import player_wait_s 10from ctfy.core.state.models import SubmissionState 11from ctfy.sdk._helpers import PagedList, _extract_items, _raise_for_status 12from ctfy.sdk.base import RETRY_UNSENT, BaseHttpClient 13from ctfy.server.models import ( 14 CheckAnswerResponse, 15 DefenceSource, 16 PatchSubmissionInfo, 17 QaChallengeView, 18 SubmissionResponse, 19 VerifyAnswerResponse, 20) 21 22 23class SubmissionsResource: 24 """Answer submission (graded), oracle verification (no record), and QA.""" 25 26 def __init__(self, http: BaseHttpClient) -> None: 27 self._http = http 28 29 def submit( 30 self, 31 instance_id: str, 32 answer: str | builtins.list[str], 33 *, 34 question_id: str = "flag", 35 ) -> SubmissionResponse: 36 """Submit ``answer`` against ``question_id`` on ``instance_id``. 37 38 The server reads challenge_id, team_id and competition_id off 39 the instance row; the calling user must be on the same team 40 that started this instance (in the same competition). 41 42 ``question_id`` defaults to ``"flag"`` for single-question 43 challenges (the most common case). Multi-question challenges 44 (see :class:`ChallengeInfo.questions`) require an explicit id. 45 ``answer`` is a single string for dynamic / static / 46 single_select questions and a list of strings for multi_select. 47 """ 48 resp = self._http.request( 49 "POST", 50 "/submissions", 51 json={ 52 "instance_id": instance_id, 53 "question_id": question_id, 54 "answer": answer, 55 }, 56 # ⚠️ A judge-graded question runs its judge inside this request 57 # (#3169), for up to the node's ceiling: wait that out, and never 58 # re-send after a read timeout — the platform has the submission, 59 # and a second POST is a second judged (and capped) attempt. 60 timeout=player_wait_s(), 61 retry_on=RETRY_UNSENT, 62 ) 63 _raise_for_status(resp) 64 return SubmissionResponse.model_validate(resp.json()) 65 66 def verify( 67 self, 68 instance_id: str, 69 answer: str | builtins.list[str], 70 *, 71 question_id: str = "flag", 72 ) -> VerifyAnswerResponse: 73 """Server-side answer verification — no submission record. 74 75 Same grader as :meth:`submit` (mode-aware, 76 ``$ANSWER_<UPPER_ID>`` tolerance for dynamic, set-equal for 77 ``multi_select``, etc.) but writes nothing to the audit log. 78 Use for oracle-style probes; for graded competitions use 79 :meth:`submit`. 80 81 Returns :class:`VerifyAnswerResponse` with ``.correct`` and 82 ``.question_id`` (the latter echoed on a correct match). 83 """ 84 resp = self._http.request( 85 "POST", 86 f"/instances/{instance_id}/verify-answer", 87 json={"question_id": question_id, "answer": answer}, 88 ) 89 _raise_for_status(resp) 90 return VerifyAnswerResponse.model_validate(resp.json()) 91 92 def check( 93 self, 94 instance_id: str, 95 *, 96 question_id: str = "flag", 97 ) -> CheckAnswerResponse: 98 """Run the checker for a *checker-mode* question (proof-of-exploitation). 99 100 The platform execs the question's author-supplied checker inside 101 the trusted judge sidecar and records a solve iff the sidecar emits 102 this instance's minted token — the player submits no answer string. 103 Fails closed: a failed / timed-out / missing checker returns 104 ``passed=False`` and records nothing. 105 106 Returns :class:`CheckAnswerResponse` with ``.passed`` and, on a 107 fresh solve, ``.solve_rank`` / ``.challenge_fully_solved``. 108 """ 109 resp = self._http.request( 110 "POST", 111 f"/instances/{instance_id}/check", 112 json={"question_id": question_id}, 113 # The judge runs inside this request — see :meth:`submit` (#3169). 114 timeout=player_wait_s(), 115 retry_on=RETRY_UNSENT, 116 ) 117 _raise_for_status(resp) 118 return CheckAnswerResponse.model_validate(resp.json()) 119 120 def list( 121 self, challenge_id: str = "", offset: int = 0, limit: int = 50 122 ) -> PagedList[SubmissionState]: 123 """List current team's submissions.""" 124 params: dict[str, Any] = {"offset": offset, "limit": limit} 125 if challenge_id: 126 params["challenge_id"] = challenge_id 127 resp = self._http.request("GET", "/submissions", params=params) 128 _raise_for_status(resp) 129 return _extract_items(resp.json(), SubmissionState) 130 131 def submit_qa( 132 self, 133 challenge_id: str, 134 competition_id: str, 135 answer: str | builtins.list[str], 136 *, 137 question_id: str = "answer", 138 ) -> SubmissionResponse: 139 """Submit ``answer`` against a pure question-answer challenge. 140 141 Unlike :meth:`submit`, QA challenges have no instance — the 142 caller passes ``challenge_id`` + ``competition_id`` explicitly, 143 and the server resolves the team for the competition via the 144 standard auth helper. ``question_id`` defaults to ``"answer"`` 145 (the canonical id for MCQ items produced by ``ctfy-admin 146 challenge import``). 147 """ 148 resp = self._http.request( 149 "POST", 150 "/qa/submissions", 151 json={ 152 "challenge_id": challenge_id, 153 "competition_id": competition_id, 154 "question_id": question_id, 155 "answer": answer, 156 }, 157 ) 158 _raise_for_status(resp) 159 return SubmissionResponse.model_validate(resp.json()) 160 161 def list_qa_challenges(self, competition_id: str) -> builtins.list[QaChallengeView]: 162 """All QA challenges in scope of one competition + team status. 163 164 Returns one :class:`QaChallengeView` per challenge with the 165 calling team's per-question solve / wrong-attempt state 166 pre-joined. The quiz UI renders the full surface from this 167 single response. 168 """ 169 resp = self._http.request( 170 "GET", "/qa/challenges", params={"competition_id": competition_id} 171 ) 172 _raise_for_status(resp) 173 data = resp.json() 174 return [QaChallengeView.model_validate(item) for item in data] 175 176 177class PatchesResource: 178 """``client.patches`` — AWD+ defence submissions. 179 180 Submitting is not judging: the platform records the patch and a 181 worker builds and rules on it later, so :meth:`submit` returns a 182 ``pending`` row and :meth:`list` is how a client watches for the 183 verdict. 184 """ 185 186 def __init__(self, http: BaseHttpClient) -> None: 187 self._http = http 188 189 def source(self, challenge_id: str) -> dict[str, bytes]: 190 """The patchable source, decoded — what a defender starts from. 191 192 AWD+ gives every team the same vulnerable service *and its 193 source*; this is that. Returns exactly the challenge's 194 ``patch.targets``, keyed by challenge-relative path, and 195 deliberately **not** the benchmark tree — the reference exploit 196 and the checker live there too. 197 198 Decoded here so it pairs with :meth:`submit`, which takes bytes: 199 ``patches.submit(inst, patches.source(chal) | {p: fixed})`` is 200 the whole round trip. 201 202 Challenge-scoped, so it works before an instance exists and 203 after the event ends — which is what makes upsolve possible. 204 """ 205 resp = self._http.request("GET", f"/challenges/{challenge_id}/defence/source") 206 _raise_for_status(resp) 207 payload = DefenceSource.model_validate(resp.json()) 208 return {path: base64.b64decode(blob) for path, blob in payload.files.items()} 209 210 def submit(self, instance_id: str, files: dict[str, bytes]) -> PatchSubmissionInfo: 211 """Submit replacement files for ``instance_id``'s challenge. 212 213 ``files`` maps a **challenge-relative** path to its new bytes. 214 Only paths the challenge's ``patch.targets`` whitelist covers are 215 accepted; anything else is refused on this call rather than at 216 judge time, so the caller learns which file was rejected without 217 waiting for a build. 218 219 Encoding is handled here: the wire carries base64 because a 220 patch target may legitimately be binary. 221 """ 222 resp = self._http.request( 223 "POST", 224 "/patches", 225 json={ 226 "instance_id": instance_id, 227 "files": { 228 path: base64.b64encode(content).decode() for path, content in files.items() 229 }, 230 }, 231 ) 232 _raise_for_status(resp) 233 return PatchSubmissionInfo.model_validate(resp.json()) 234 235 def submit_for_challenge( 236 self, 237 challenge_id: str, 238 files: dict[str, bytes], 239 *, 240 competition_id: str = "", 241 ) -> PatchSubmissionInfo: 242 """Submit a patch with **no box running**. 243 244 :meth:`source` has always worked before an instance exists and 245 after the event ends — that is what its own docstring calls the 246 thing that makes upsolve possible — while :meth:`submit` 247 required an instance, so the round trip it advertises had a 248 broken second half. This is that half: upsolve, virtual 249 participation, and a defender fixing a challenge after their box 250 expired. 251 252 A separate method rather than an optional ``instance_id``: the 253 two are different addressings, not a default, and the split 254 keeps them as obvious as :meth:`submit` versus 255 :meth:`submit_live`. 256 257 The credited team comes from the caller's own membership for 258 ``competition_id`` — there is no team on the wire — so this is 259 no weaker than naming a box you own. 260 """ 261 resp = self._http.request( 262 "POST", 263 "/patches", 264 json={ 265 "challenge_id": challenge_id, 266 "competition_id": competition_id, 267 "files": { 268 path: base64.b64encode(content).decode() for path, content in files.items() 269 }, 270 }, 271 ) 272 _raise_for_status(resp) 273 return PatchSubmissionInfo.model_validate(resp.json()) 274 275 def submit_live(self, instance_id: str) -> PatchSubmissionInfo: 276 """Submit whatever is *currently in the box* — no payload. 277 278 The channel an AWD player actually uses: SSH in, edit in place, 279 then ask the platform to read the result back and judge it. The 280 platform decides which paths to read, from the challenge's 281 pristine source tree — the box never gets to nominate what is 282 graded. 283 284 Requires the challenge to declare a ``patch.live`` block; 285 without one the platform answers ``patch_live_unsupported`` and 286 the upload form is the only channel. 287 """ 288 resp = self._http.request( 289 "POST", 290 "/patches", 291 json={"instance_id": instance_id, "source": "live"}, 292 ) 293 _raise_for_status(resp) 294 return PatchSubmissionInfo.model_validate(resp.json()) 295 296 def list(self, *, challenge_id: str = "") -> builtins.list[PatchSubmissionInfo]: 297 """This team's defence history, newest first. 298 299 Carries each submission's status and verdict but not its code — 300 the stored row keeps the bytes so a verdict can be re-derived, 301 while this is a status view that gets polled. 302 """ 303 params = {"challenge_id": challenge_id} if challenge_id else {} 304 resp = self._http.request("GET", "/patches", params=params) 305 _raise_for_status(resp) 306 return [PatchSubmissionInfo.model_validate(row) for row in resp.json()]
24class SubmissionsResource: 25 """Answer submission (graded), oracle verification (no record), and QA.""" 26 27 def __init__(self, http: BaseHttpClient) -> None: 28 self._http = http 29 30 def submit( 31 self, 32 instance_id: str, 33 answer: str | builtins.list[str], 34 *, 35 question_id: str = "flag", 36 ) -> SubmissionResponse: 37 """Submit ``answer`` against ``question_id`` on ``instance_id``. 38 39 The server reads challenge_id, team_id and competition_id off 40 the instance row; the calling user must be on the same team 41 that started this instance (in the same competition). 42 43 ``question_id`` defaults to ``"flag"`` for single-question 44 challenges (the most common case). Multi-question challenges 45 (see :class:`ChallengeInfo.questions`) require an explicit id. 46 ``answer`` is a single string for dynamic / static / 47 single_select questions and a list of strings for multi_select. 48 """ 49 resp = self._http.request( 50 "POST", 51 "/submissions", 52 json={ 53 "instance_id": instance_id, 54 "question_id": question_id, 55 "answer": answer, 56 }, 57 # ⚠️ A judge-graded question runs its judge inside this request 58 # (#3169), for up to the node's ceiling: wait that out, and never 59 # re-send after a read timeout — the platform has the submission, 60 # and a second POST is a second judged (and capped) attempt. 61 timeout=player_wait_s(), 62 retry_on=RETRY_UNSENT, 63 ) 64 _raise_for_status(resp) 65 return SubmissionResponse.model_validate(resp.json()) 66 67 def verify( 68 self, 69 instance_id: str, 70 answer: str | builtins.list[str], 71 *, 72 question_id: str = "flag", 73 ) -> VerifyAnswerResponse: 74 """Server-side answer verification — no submission record. 75 76 Same grader as :meth:`submit` (mode-aware, 77 ``$ANSWER_<UPPER_ID>`` tolerance for dynamic, set-equal for 78 ``multi_select``, etc.) but writes nothing to the audit log. 79 Use for oracle-style probes; for graded competitions use 80 :meth:`submit`. 81 82 Returns :class:`VerifyAnswerResponse` with ``.correct`` and 83 ``.question_id`` (the latter echoed on a correct match). 84 """ 85 resp = self._http.request( 86 "POST", 87 f"/instances/{instance_id}/verify-answer", 88 json={"question_id": question_id, "answer": answer}, 89 ) 90 _raise_for_status(resp) 91 return VerifyAnswerResponse.model_validate(resp.json()) 92 93 def check( 94 self, 95 instance_id: str, 96 *, 97 question_id: str = "flag", 98 ) -> CheckAnswerResponse: 99 """Run the checker for a *checker-mode* question (proof-of-exploitation). 100 101 The platform execs the question's author-supplied checker inside 102 the trusted judge sidecar and records a solve iff the sidecar emits 103 this instance's minted token — the player submits no answer string. 104 Fails closed: a failed / timed-out / missing checker returns 105 ``passed=False`` and records nothing. 106 107 Returns :class:`CheckAnswerResponse` with ``.passed`` and, on a 108 fresh solve, ``.solve_rank`` / ``.challenge_fully_solved``. 109 """ 110 resp = self._http.request( 111 "POST", 112 f"/instances/{instance_id}/check", 113 json={"question_id": question_id}, 114 # The judge runs inside this request — see :meth:`submit` (#3169). 115 timeout=player_wait_s(), 116 retry_on=RETRY_UNSENT, 117 ) 118 _raise_for_status(resp) 119 return CheckAnswerResponse.model_validate(resp.json()) 120 121 def list( 122 self, challenge_id: str = "", offset: int = 0, limit: int = 50 123 ) -> PagedList[SubmissionState]: 124 """List current team's submissions.""" 125 params: dict[str, Any] = {"offset": offset, "limit": limit} 126 if challenge_id: 127 params["challenge_id"] = challenge_id 128 resp = self._http.request("GET", "/submissions", params=params) 129 _raise_for_status(resp) 130 return _extract_items(resp.json(), SubmissionState) 131 132 def submit_qa( 133 self, 134 challenge_id: str, 135 competition_id: str, 136 answer: str | builtins.list[str], 137 *, 138 question_id: str = "answer", 139 ) -> SubmissionResponse: 140 """Submit ``answer`` against a pure question-answer challenge. 141 142 Unlike :meth:`submit`, QA challenges have no instance — the 143 caller passes ``challenge_id`` + ``competition_id`` explicitly, 144 and the server resolves the team for the competition via the 145 standard auth helper. ``question_id`` defaults to ``"answer"`` 146 (the canonical id for MCQ items produced by ``ctfy-admin 147 challenge import``). 148 """ 149 resp = self._http.request( 150 "POST", 151 "/qa/submissions", 152 json={ 153 "challenge_id": challenge_id, 154 "competition_id": competition_id, 155 "question_id": question_id, 156 "answer": answer, 157 }, 158 ) 159 _raise_for_status(resp) 160 return SubmissionResponse.model_validate(resp.json()) 161 162 def list_qa_challenges(self, competition_id: str) -> builtins.list[QaChallengeView]: 163 """All QA challenges in scope of one competition + team status. 164 165 Returns one :class:`QaChallengeView` per challenge with the 166 calling team's per-question solve / wrong-attempt state 167 pre-joined. The quiz UI renders the full surface from this 168 single response. 169 """ 170 resp = self._http.request( 171 "GET", "/qa/challenges", params={"competition_id": competition_id} 172 ) 173 _raise_for_status(resp) 174 data = resp.json() 175 return [QaChallengeView.model_validate(item) for item in data]
Answer submission (graded), oracle verification (no record), and QA.
30 def submit( 31 self, 32 instance_id: str, 33 answer: str | builtins.list[str], 34 *, 35 question_id: str = "flag", 36 ) -> SubmissionResponse: 37 """Submit ``answer`` against ``question_id`` on ``instance_id``. 38 39 The server reads challenge_id, team_id and competition_id off 40 the instance row; the calling user must be on the same team 41 that started this instance (in the same competition). 42 43 ``question_id`` defaults to ``"flag"`` for single-question 44 challenges (the most common case). Multi-question challenges 45 (see :class:`ChallengeInfo.questions`) require an explicit id. 46 ``answer`` is a single string for dynamic / static / 47 single_select questions and a list of strings for multi_select. 48 """ 49 resp = self._http.request( 50 "POST", 51 "/submissions", 52 json={ 53 "instance_id": instance_id, 54 "question_id": question_id, 55 "answer": answer, 56 }, 57 # ⚠️ A judge-graded question runs its judge inside this request 58 # (#3169), for up to the node's ceiling: wait that out, and never 59 # re-send after a read timeout — the platform has the submission, 60 # and a second POST is a second judged (and capped) attempt. 61 timeout=player_wait_s(), 62 retry_on=RETRY_UNSENT, 63 ) 64 _raise_for_status(resp) 65 return SubmissionResponse.model_validate(resp.json())
Submit answer against question_id on instance_id.
The server reads challenge_id, team_id and competition_id off the instance row; the calling user must be on the same team that started this instance (in the same competition).
question_id defaults to "flag" for single-question
challenges (the most common case). Multi-question challenges
(see ChallengeInfo.questions) require an explicit id.
answer is a single string for dynamic / static /
single_select questions and a list of strings for multi_select.
67 def verify( 68 self, 69 instance_id: str, 70 answer: str | builtins.list[str], 71 *, 72 question_id: str = "flag", 73 ) -> VerifyAnswerResponse: 74 """Server-side answer verification — no submission record. 75 76 Same grader as :meth:`submit` (mode-aware, 77 ``$ANSWER_<UPPER_ID>`` tolerance for dynamic, set-equal for 78 ``multi_select``, etc.) but writes nothing to the audit log. 79 Use for oracle-style probes; for graded competitions use 80 :meth:`submit`. 81 82 Returns :class:`VerifyAnswerResponse` with ``.correct`` and 83 ``.question_id`` (the latter echoed on a correct match). 84 """ 85 resp = self._http.request( 86 "POST", 87 f"/instances/{instance_id}/verify-answer", 88 json={"question_id": question_id, "answer": answer}, 89 ) 90 _raise_for_status(resp) 91 return VerifyAnswerResponse.model_validate(resp.json())
Server-side answer verification — no submission record.
Same grader as submit() (mode-aware,
$ANSWER_<UPPER_ID> tolerance for dynamic, set-equal for
multi_select, etc.) but writes nothing to the audit log.
Use for oracle-style probes; for graded competitions use
submit().
Returns VerifyAnswerResponse with .correct and
.question_id (the latter echoed on a correct match).
93 def check( 94 self, 95 instance_id: str, 96 *, 97 question_id: str = "flag", 98 ) -> CheckAnswerResponse: 99 """Run the checker for a *checker-mode* question (proof-of-exploitation). 100 101 The platform execs the question's author-supplied checker inside 102 the trusted judge sidecar and records a solve iff the sidecar emits 103 this instance's minted token — the player submits no answer string. 104 Fails closed: a failed / timed-out / missing checker returns 105 ``passed=False`` and records nothing. 106 107 Returns :class:`CheckAnswerResponse` with ``.passed`` and, on a 108 fresh solve, ``.solve_rank`` / ``.challenge_fully_solved``. 109 """ 110 resp = self._http.request( 111 "POST", 112 f"/instances/{instance_id}/check", 113 json={"question_id": question_id}, 114 # The judge runs inside this request — see :meth:`submit` (#3169). 115 timeout=player_wait_s(), 116 retry_on=RETRY_UNSENT, 117 ) 118 _raise_for_status(resp) 119 return CheckAnswerResponse.model_validate(resp.json())
Run the checker for a checker-mode question (proof-of-exploitation).
The platform execs the question's author-supplied checker inside
the trusted judge sidecar and records a solve iff the sidecar emits
this instance's minted token — the player submits no answer string.
Fails closed: a failed / timed-out / missing checker returns
passed=False and records nothing.
Returns CheckAnswerResponse with .passed and, on a
fresh solve, .solve_rank / .challenge_fully_solved.
121 def list( 122 self, challenge_id: str = "", offset: int = 0, limit: int = 50 123 ) -> PagedList[SubmissionState]: 124 """List current team's submissions.""" 125 params: dict[str, Any] = {"offset": offset, "limit": limit} 126 if challenge_id: 127 params["challenge_id"] = challenge_id 128 resp = self._http.request("GET", "/submissions", params=params) 129 _raise_for_status(resp) 130 return _extract_items(resp.json(), SubmissionState)
List current team's submissions.
132 def submit_qa( 133 self, 134 challenge_id: str, 135 competition_id: str, 136 answer: str | builtins.list[str], 137 *, 138 question_id: str = "answer", 139 ) -> SubmissionResponse: 140 """Submit ``answer`` against a pure question-answer challenge. 141 142 Unlike :meth:`submit`, QA challenges have no instance — the 143 caller passes ``challenge_id`` + ``competition_id`` explicitly, 144 and the server resolves the team for the competition via the 145 standard auth helper. ``question_id`` defaults to ``"answer"`` 146 (the canonical id for MCQ items produced by ``ctfy-admin 147 challenge import``). 148 """ 149 resp = self._http.request( 150 "POST", 151 "/qa/submissions", 152 json={ 153 "challenge_id": challenge_id, 154 "competition_id": competition_id, 155 "question_id": question_id, 156 "answer": answer, 157 }, 158 ) 159 _raise_for_status(resp) 160 return SubmissionResponse.model_validate(resp.json())
Submit answer against a pure question-answer challenge.
Unlike submit(), QA challenges have no instance — the
caller passes challenge_id + competition_id explicitly,
and the server resolves the team for the competition via the
standard auth helper. question_id defaults to "answer"
(the canonical id for MCQ items produced by ctfy-admin
challenge import).
162 def list_qa_challenges(self, competition_id: str) -> builtins.list[QaChallengeView]: 163 """All QA challenges in scope of one competition + team status. 164 165 Returns one :class:`QaChallengeView` per challenge with the 166 calling team's per-question solve / wrong-attempt state 167 pre-joined. The quiz UI renders the full surface from this 168 single response. 169 """ 170 resp = self._http.request( 171 "GET", "/qa/challenges", params={"competition_id": competition_id} 172 ) 173 _raise_for_status(resp) 174 data = resp.json() 175 return [QaChallengeView.model_validate(item) for item in data]
All QA challenges in scope of one competition + team status.
Returns one QaChallengeView per challenge with the
calling team's per-question solve / wrong-attempt state
pre-joined. The quiz UI renders the full surface from this
single response.
178class PatchesResource: 179 """``client.patches`` — AWD+ defence submissions. 180 181 Submitting is not judging: the platform records the patch and a 182 worker builds and rules on it later, so :meth:`submit` returns a 183 ``pending`` row and :meth:`list` is how a client watches for the 184 verdict. 185 """ 186 187 def __init__(self, http: BaseHttpClient) -> None: 188 self._http = http 189 190 def source(self, challenge_id: str) -> dict[str, bytes]: 191 """The patchable source, decoded — what a defender starts from. 192 193 AWD+ gives every team the same vulnerable service *and its 194 source*; this is that. Returns exactly the challenge's 195 ``patch.targets``, keyed by challenge-relative path, and 196 deliberately **not** the benchmark tree — the reference exploit 197 and the checker live there too. 198 199 Decoded here so it pairs with :meth:`submit`, which takes bytes: 200 ``patches.submit(inst, patches.source(chal) | {p: fixed})`` is 201 the whole round trip. 202 203 Challenge-scoped, so it works before an instance exists and 204 after the event ends — which is what makes upsolve possible. 205 """ 206 resp = self._http.request("GET", f"/challenges/{challenge_id}/defence/source") 207 _raise_for_status(resp) 208 payload = DefenceSource.model_validate(resp.json()) 209 return {path: base64.b64decode(blob) for path, blob in payload.files.items()} 210 211 def submit(self, instance_id: str, files: dict[str, bytes]) -> PatchSubmissionInfo: 212 """Submit replacement files for ``instance_id``'s challenge. 213 214 ``files`` maps a **challenge-relative** path to its new bytes. 215 Only paths the challenge's ``patch.targets`` whitelist covers are 216 accepted; anything else is refused on this call rather than at 217 judge time, so the caller learns which file was rejected without 218 waiting for a build. 219 220 Encoding is handled here: the wire carries base64 because a 221 patch target may legitimately be binary. 222 """ 223 resp = self._http.request( 224 "POST", 225 "/patches", 226 json={ 227 "instance_id": instance_id, 228 "files": { 229 path: base64.b64encode(content).decode() for path, content in files.items() 230 }, 231 }, 232 ) 233 _raise_for_status(resp) 234 return PatchSubmissionInfo.model_validate(resp.json()) 235 236 def submit_for_challenge( 237 self, 238 challenge_id: str, 239 files: dict[str, bytes], 240 *, 241 competition_id: str = "", 242 ) -> PatchSubmissionInfo: 243 """Submit a patch with **no box running**. 244 245 :meth:`source` has always worked before an instance exists and 246 after the event ends — that is what its own docstring calls the 247 thing that makes upsolve possible — while :meth:`submit` 248 required an instance, so the round trip it advertises had a 249 broken second half. This is that half: upsolve, virtual 250 participation, and a defender fixing a challenge after their box 251 expired. 252 253 A separate method rather than an optional ``instance_id``: the 254 two are different addressings, not a default, and the split 255 keeps them as obvious as :meth:`submit` versus 256 :meth:`submit_live`. 257 258 The credited team comes from the caller's own membership for 259 ``competition_id`` — there is no team on the wire — so this is 260 no weaker than naming a box you own. 261 """ 262 resp = self._http.request( 263 "POST", 264 "/patches", 265 json={ 266 "challenge_id": challenge_id, 267 "competition_id": competition_id, 268 "files": { 269 path: base64.b64encode(content).decode() for path, content in files.items() 270 }, 271 }, 272 ) 273 _raise_for_status(resp) 274 return PatchSubmissionInfo.model_validate(resp.json()) 275 276 def submit_live(self, instance_id: str) -> PatchSubmissionInfo: 277 """Submit whatever is *currently in the box* — no payload. 278 279 The channel an AWD player actually uses: SSH in, edit in place, 280 then ask the platform to read the result back and judge it. The 281 platform decides which paths to read, from the challenge's 282 pristine source tree — the box never gets to nominate what is 283 graded. 284 285 Requires the challenge to declare a ``patch.live`` block; 286 without one the platform answers ``patch_live_unsupported`` and 287 the upload form is the only channel. 288 """ 289 resp = self._http.request( 290 "POST", 291 "/patches", 292 json={"instance_id": instance_id, "source": "live"}, 293 ) 294 _raise_for_status(resp) 295 return PatchSubmissionInfo.model_validate(resp.json()) 296 297 def list(self, *, challenge_id: str = "") -> builtins.list[PatchSubmissionInfo]: 298 """This team's defence history, newest first. 299 300 Carries each submission's status and verdict but not its code — 301 the stored row keeps the bytes so a verdict can be re-derived, 302 while this is a status view that gets polled. 303 """ 304 params = {"challenge_id": challenge_id} if challenge_id else {} 305 resp = self._http.request("GET", "/patches", params=params) 306 _raise_for_status(resp) 307 return [PatchSubmissionInfo.model_validate(row) for row in resp.json()]
client.patches — AWD+ defence submissions.
Submitting is not judging: the platform records the patch and a
worker builds and rules on it later, so submit() returns a
pending row and list() is how a client watches for the
verdict.
190 def source(self, challenge_id: str) -> dict[str, bytes]: 191 """The patchable source, decoded — what a defender starts from. 192 193 AWD+ gives every team the same vulnerable service *and its 194 source*; this is that. Returns exactly the challenge's 195 ``patch.targets``, keyed by challenge-relative path, and 196 deliberately **not** the benchmark tree — the reference exploit 197 and the checker live there too. 198 199 Decoded here so it pairs with :meth:`submit`, which takes bytes: 200 ``patches.submit(inst, patches.source(chal) | {p: fixed})`` is 201 the whole round trip. 202 203 Challenge-scoped, so it works before an instance exists and 204 after the event ends — which is what makes upsolve possible. 205 """ 206 resp = self._http.request("GET", f"/challenges/{challenge_id}/defence/source") 207 _raise_for_status(resp) 208 payload = DefenceSource.model_validate(resp.json()) 209 return {path: base64.b64decode(blob) for path, blob in payload.files.items()}
The patchable source, decoded — what a defender starts from.
AWD+ gives every team the same vulnerable service and its
source; this is that. Returns exactly the challenge's
patch.targets, keyed by challenge-relative path, and
deliberately not the benchmark tree — the reference exploit
and the checker live there too.
Decoded here so it pairs with submit(), which takes bytes:
patches.submit(inst, patches.source(chal) | {p: fixed}) is
the whole round trip.
Challenge-scoped, so it works before an instance exists and after the event ends — which is what makes upsolve possible.
211 def submit(self, instance_id: str, files: dict[str, bytes]) -> PatchSubmissionInfo: 212 """Submit replacement files for ``instance_id``'s challenge. 213 214 ``files`` maps a **challenge-relative** path to its new bytes. 215 Only paths the challenge's ``patch.targets`` whitelist covers are 216 accepted; anything else is refused on this call rather than at 217 judge time, so the caller learns which file was rejected without 218 waiting for a build. 219 220 Encoding is handled here: the wire carries base64 because a 221 patch target may legitimately be binary. 222 """ 223 resp = self._http.request( 224 "POST", 225 "/patches", 226 json={ 227 "instance_id": instance_id, 228 "files": { 229 path: base64.b64encode(content).decode() for path, content in files.items() 230 }, 231 }, 232 ) 233 _raise_for_status(resp) 234 return PatchSubmissionInfo.model_validate(resp.json())
Submit replacement files for instance_id's challenge.
files maps a challenge-relative path to its new bytes.
Only paths the challenge's patch.targets whitelist covers are
accepted; anything else is refused on this call rather than at
judge time, so the caller learns which file was rejected without
waiting for a build.
Encoding is handled here: the wire carries base64 because a patch target may legitimately be binary.
236 def submit_for_challenge( 237 self, 238 challenge_id: str, 239 files: dict[str, bytes], 240 *, 241 competition_id: str = "", 242 ) -> PatchSubmissionInfo: 243 """Submit a patch with **no box running**. 244 245 :meth:`source` has always worked before an instance exists and 246 after the event ends — that is what its own docstring calls the 247 thing that makes upsolve possible — while :meth:`submit` 248 required an instance, so the round trip it advertises had a 249 broken second half. This is that half: upsolve, virtual 250 participation, and a defender fixing a challenge after their box 251 expired. 252 253 A separate method rather than an optional ``instance_id``: the 254 two are different addressings, not a default, and the split 255 keeps them as obvious as :meth:`submit` versus 256 :meth:`submit_live`. 257 258 The credited team comes from the caller's own membership for 259 ``competition_id`` — there is no team on the wire — so this is 260 no weaker than naming a box you own. 261 """ 262 resp = self._http.request( 263 "POST", 264 "/patches", 265 json={ 266 "challenge_id": challenge_id, 267 "competition_id": competition_id, 268 "files": { 269 path: base64.b64encode(content).decode() for path, content in files.items() 270 }, 271 }, 272 ) 273 _raise_for_status(resp) 274 return PatchSubmissionInfo.model_validate(resp.json())
Submit a patch with no box running.
source() has always worked before an instance exists and
after the event ends — that is what its own docstring calls the
thing that makes upsolve possible — while submit()
required an instance, so the round trip it advertises had a
broken second half. This is that half: upsolve, virtual
participation, and a defender fixing a challenge after their box
expired.
A separate method rather than an optional instance_id: the
two are different addressings, not a default, and the split
keeps them as obvious as submit() versus
submit_live().
The credited team comes from the caller's own membership for
competition_id — there is no team on the wire — so this is
no weaker than naming a box you own.
276 def submit_live(self, instance_id: str) -> PatchSubmissionInfo: 277 """Submit whatever is *currently in the box* — no payload. 278 279 The channel an AWD player actually uses: SSH in, edit in place, 280 then ask the platform to read the result back and judge it. The 281 platform decides which paths to read, from the challenge's 282 pristine source tree — the box never gets to nominate what is 283 graded. 284 285 Requires the challenge to declare a ``patch.live`` block; 286 without one the platform answers ``patch_live_unsupported`` and 287 the upload form is the only channel. 288 """ 289 resp = self._http.request( 290 "POST", 291 "/patches", 292 json={"instance_id": instance_id, "source": "live"}, 293 ) 294 _raise_for_status(resp) 295 return PatchSubmissionInfo.model_validate(resp.json())
Submit whatever is currently in the box — no payload.
The channel an AWD player actually uses: SSH in, edit in place, then ask the platform to read the result back and judge it. The platform decides which paths to read, from the challenge's pristine source tree — the box never gets to nominate what is graded.
Requires the challenge to declare a patch.live block;
without one the platform answers patch_live_unsupported and
the upload form is the only channel.
297 def list(self, *, challenge_id: str = "") -> builtins.list[PatchSubmissionInfo]: 298 """This team's defence history, newest first. 299 300 Carries each submission's status and verdict but not its code — 301 the stored row keeps the bytes so a verdict can be re-derived, 302 while this is a status view that gets polled. 303 """ 304 params = {"challenge_id": challenge_id} if challenge_id else {} 305 resp = self._http.request("GET", "/patches", params=params) 306 _raise_for_status(resp) 307 return [PatchSubmissionInfo.model_validate(row) for row in resp.json()]
This team's defence history, newest first.
Carries each submission's status and verdict but not its code — the stored row keeps the bytes so a verdict can be re-derived, while this is a status view that gets polled.