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