-
Notifications
You must be signed in to change notification settings - Fork 2
Expand file tree
/
Copy pathharness.py
More file actions
2456 lines (2099 loc) · 132 KB
/
Copy pathharness.py
File metadata and controls
2456 lines (2099 loc) · 132 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
"""Acceptance harness: drives a *running* CodeExecutorAPI server over HTTP.
python harness.py --api-url http://127.0.0.1:40003
python harness.py --list
Nothing here imports the API: every check talks to the server as a client does, which is what
lets it cover what only exists once podman, the bind mount and the XFS quota are real - a
symlink escape, an OOM kill, a quota refusal.
A check is an `async def check_*` registered in CHECKS with its group and the number of fresh
sessions the runner should create and destroy around it. Checks assert; the runner catches,
so one failure does not end the run (`-x` if you want it to).
Opt-in groups need a server whose limits are small enough to reach - a 20s EXECUTION_TIMEOUT
is not something a check can wait out - so each names its requirement in GROUPS and stays off
until its `--check-<group>` flag is passed. `Config` mirrors the server's limits rather than
discovering them, so a limit set in the server's environment must be passed here too; every
assertion derived from one names its flag on failure.
"""
import argparse
import asyncio
import contextlib
import dataclasses
import json
import pathlib
import re
import shutil
import subprocess
import time
import traceback
from typing import AbstractSet, AsyncIterator, Awaitable, Callable, Sequence
from urllib.parse import quote
import aiohttp
from yarl import URL
# Code runs attached to a PTY, so runtimes treat stdout as a terminal and may colourise it
# (Node renders booleans in yellow, for instance). That is intended - the sandbox ships
# lolcat and cmatrix - so assertions on output have to look past the escape sequences.
#
# Colour (SGR) is not the only thing that arrives: `dotnet` emits keypad-mode sequences
# (\x1b[?1h\x1b=) around its output, so this matches any CSI sequence and the two-character
# escapes, not just \x1b[...m.
_ANSI_ESCAPE = re.compile(r"\x1b(?:\[[0-9;?]*[ -/]*[@-~]|[=>])")
def _plain(text: str) -> str:
"""Strip terminal escapes and fold CRLF.
The PTY turns every newline into CRLF and only the trailing one is stripped server side,
so multi-line output compared as a whole would never match without this.
"""
return _ANSI_ESCAPE.sub("", text).replace("\r\n", "\n")
@dataclasses.dataclass(frozen=True, slots=True)
class Config:
"""The server's configured limits, mirrored so checks can size their probes.
Every field mirrors one environment variable from code_executor_api/config.py; the CLI
flag is the field name with underscores as dashes. Defaults match the server's own
defaults, so a default-configured server needs no flags at all. Nothing validates these
against the running server, so every assertion derived from one names the flag in its
failure message.
"""
max_session_size: int = dataclasses.field(default=104_857_600, metadata={
"env": "MAX_SESSION_SIZE", "help": "bytes; sizes the quota probe write"})
max_session_entries: int = dataclasses.field(default=32_768, metadata={
"env": "MAX_SESSION_ENTRIES", "help": "inodes; sizes the quota inode probe"})
max_result_attachments: int = dataclasses.field(default=256, metadata={
"env": "MAX_RESULT_ATTACHMENTS", "help": "used by the partial-result check"})
max_code_length: int = dataclasses.field(default=65_536, metadata={
"env": "MAX_CODE_LENGTH", "help": "bytes; sizes the oversized-code probe"})
max_output_size: int = dataclasses.field(default=10_485_760, metadata={
"env": "MAX_OUTPUT_SIZE", "help": "bytes; sizes the output-truncation probe"})
container_pids_limit: int = dataclasses.field(default=128, metadata={
"env": "CONTAINER_PIDS_LIMIT", "help": "sizes the process-creation probe"})
container_ulimit_nofile: int = dataclasses.field(default=1024, metadata={
"env": "CONTAINER_ULIMIT_NOFILE", "help": "the RLIMIT_NOFILE the container must report"})
max_file_size: int = dataclasses.field(default=268_435_456, metadata={
"env": "CONTAINER_ULIMIT_FSIZE", "help": "bytes; sizes the per-file 413 probe"})
execution_timeout: int = dataclasses.field(default=20, metadata={
"env": "EXECUTION_TIMEOUT", "help": "seconds; sizes the overrun probe"})
max_sessions: int = dataclasses.field(default=64, metadata={
"env": "MAX_SESSIONS", "help": "sizes the capacity probe"})
max_concurrent_executions: int = dataclasses.field(default=4, metadata={
"env": "MAX_CONCURRENT_EXECUTIONS", "help": "sizes the concurrency probe"})
lock_wait_timeout: int = dataclasses.field(default=30, metadata={
"env": "SESSION_LOCK_WAIT_TIMEOUT_SECONDS", "help": "seconds; how long a 409 should take"})
inactivity_timeout: int = dataclasses.field(default=1800, metadata={
"env": "SESSION_INACTIVITY_TIMEOUT_SECONDS", "help": "seconds; how long expiry takes"})
sweep_interval: int = dataclasses.field(default=60, metadata={
"env": "SESSION_SWEEP_INTERVAL_SECONDS", "help": "seconds; added to the expiry wait"})
session_root: str = dataclasses.field(default="", metadata={
"env": "SESSION_ROOT_DIRECTORY",
"help": "the server's session directory on this host, for the host-side checks"})
def _add_config_arguments(parser: argparse.ArgumentParser) -> None:
"""Generate one CLI flag per Config field, so adding a limit is a one-line edit."""
for config_field in dataclasses.fields(Config):
parser.add_argument(
f"--{config_field.name.replace('_', '-')}",
type=int if config_field.type is int else str, default=config_field.default,
help=f"The server's configured {config_field.metadata['env']} - "
f"{config_field.metadata['help']} (default: {config_field.default})",
)
def _config_from_args(args: argparse.Namespace) -> Config:
return Config(**{f.name: getattr(args, f.name) for f in dataclasses.fields(Config)})
async def _execute(
session: aiohttp.ClientSession,
api_url: str,
language: str,
code: str,
*,
session_id: str | None = None,
attachments: dict[str, bytes] | None = None,
expected_status: int = 200,
) -> dict | None:
form = aiohttp.FormData(default_to_multipart=True)
form.add_field("language", language)
form.add_field("code", code)
for filename, content in (attachments or {}).items():
form.add_field("attachments", content, filename=filename, content_type="application/octet-stream")
url = f"{api_url}/sessions/{session_id}/execute" if session_id is not None else f"{api_url}/execute"
async with session.post(url, data=form) as response:
if response.status != expected_status:
body = await response.text()
raise RuntimeError(f"{url} expected status {expected_status}, got {response.status}: {body}")
if response.status != 200:
return None
reader = aiohttp.MultipartReader.from_response(response)
result = None
files = {}
async for part in reader:
if part.headers.get(aiohttp.hdrs.CONTENT_TYPE) == "application/json":
result = json.loads(await part.read(decode=False))
else:
# part.filename resolves the RFC 5987 `filename*` parameter, so a nested
# attachment arrives as its real sub_path ("out/file.txt") with no decoding
# needed here.
files[part.filename] = await part.read(decode=False)
result["files"] = files
return result
async def _new_session(session: aiohttp.ClientSession, api_url: str) -> str:
async with session.post(f"{api_url}/sessions") as response:
assert response.status == 200, f"POST /sessions failed: {response.status}"
return (await response.json())["session_id"]
@contextlib.asynccontextmanager
async def owned_sessions(
session: aiohttp.ClientSession, api_url: str, count: int = 1,
) -> AsyncIterator[list[str]]:
"""Create `count` sessions and delete them all, whatever happens in between.
The runner wraps every check in this, so a check body never creates or destroys a
session of its own and a failed assertion still frees what it was given. A cleanup
DELETE that fails only warns: turning it into a failure would blame this check for a
broken DELETE, which `session_deletion` covers directly.
"""
session_ids: list[str] = []
try:
for _ in range(count):
session_ids.append(await _new_session(session, api_url))
yield session_ids
finally:
for session_id in reversed(session_ids):
try:
async with session.delete(f"{api_url}/sessions/{session_id}") as response:
if response.status not in (204, 404):
print(f"WARNING: cleanup DELETE {session_id} -> {response.status}")
except aiohttp.ClientError as exc:
print(f"WARNING: cleanup DELETE {session_id} raised {exc!r}")
async def check_health(session: aiohttp.ClientSession, api_url: str, cfg: Config) -> None:
async with session.get(f"{api_url}/health") as response:
assert response.status == 200, f"/health failed: {response.status}"
data = await response.json()
assert data.get("status") == "ok", f"Unexpected /health payload: {data!r}"
print("GET /health -> 200 ok")
async def check_seeded_session(session: aiohttp.ClientSession, api_url: str, cfg: Config) -> None:
form = aiohttp.FormData(default_to_multipart=True)
form.add_field("seed.txt", b"seeded content", filename="seed.txt", content_type="application/octet-stream")
async with session.post(f"{api_url}/sessions", data=form) as response:
assert response.status == 200, f"Seeded session create failed: {response.status}"
data = await response.json()
session_id = data["session_id"]
print(f"Created seeded session: {session_id}")
try:
async with session.get(f"{api_url}/sessions/{session_id}/files/seed.txt") as response:
assert response.status == 200, f"GET seed.txt failed: {response.status}"
content = await response.read()
assert content == b"seeded content", f"Unexpected seeded content: {content!r}"
print(f"GET seed.txt -> {content!r}")
finally:
async with session.delete(f"{api_url}/sessions/{session_id}") as response:
assert response.status == 204, f"DELETE seeded session failed: {response.status}"
async def check_file_lifecycle(session: aiohttp.ClientSession, api_url: str, cfg: Config, session_id: str) -> None:
async with session.put(f"{api_url}/sessions/{session_id}/files/hello.txt", data=b"hello world") as response:
assert response.status == 204, f"PUT failed: {response.status}"
print("PUT hello.txt -> 204")
async with session.get(f"{api_url}/sessions/{session_id}/files/hello.txt") as response:
assert response.status == 200, f"GET failed: {response.status}"
content = await response.read()
assert content == b"hello world", f"Unexpected content: {content!r}"
print(f"GET hello.txt -> {content!r}")
async with session.delete(f"{api_url}/sessions/{session_id}/files/hello.txt") as response:
assert response.status == 204, f"DELETE file failed: {response.status}"
print("DELETE hello.txt -> 204")
async with session.get(f"{api_url}/sessions/{session_id}/files/hello.txt") as response:
assert response.status == 404, f"Expected 404 after file deletion, got {response.status}"
print("GET hello.txt after delete -> 404 (as expected)")
async def check_session_deletion(session: aiohttp.ClientSession, api_url: str, cfg: Config) -> None:
session_id = await _new_session(session, api_url)
async with session.put(f"{api_url}/sessions/{session_id}/files/hello.txt", data=b"hello world") as response:
assert response.status == 204, f"PUT failed: {response.status}"
async with session.delete(f"{api_url}/sessions/{session_id}") as response:
assert response.status == 204, f"DELETE session failed: {response.status}"
print("DELETE session -> 204")
async with session.get(f"{api_url}/sessions/{session_id}/files/hello.txt") as response:
assert response.status == 404, f"Expected 404 after session deletion, got {response.status}"
print("GET after delete -> 404 (as expected)")
async def check_execute_persistence(session: aiohttp.ClientSession, api_url: str, cfg: Config, session_id: str) -> None:
async with session.put(f"{api_url}/sessions/{session_id}/files/hello.txt", data=b"hello world") as response:
assert response.status == 204, f"PUT hello.txt (fixture) failed: {response.status}"
result = await _execute(
session, api_url, "python",
"with open('hello.txt') as f:\n data = f.read()\nwith open('output.txt', 'w') as f:\n f.write(data.upper())\nprint('done')",
session_id=session_id,
)
print(f"Execute #1: output={result['output']!r} return_code={result['return_code']} "
f"files={list(result['files'])} deleted_files={result['deleted_files']}")
assert result["files"].get("output.txt") == b"HELLO WORLD", "output.txt was not persisted correctly"
result = await _execute(session, api_url, "python", "import os\nos.remove('output.txt')\nprint('removed')", session_id=session_id)
print(f"Execute #2: output={result['output']!r} deleted_files={result['deleted_files']}")
assert "output.txt" in result["deleted_files"], "output.txt deletion was not detected"
async def check_execute_attachments(session: aiohttp.ClientSession, api_url: str, cfg: Config, session_id: str) -> None:
result = await _execute(
session, api_url, "bash", "cat attached.txt | tr a-z A-Z > attached_upper.txt",
session_id=session_id, attachments={"attached.txt": b"attach me"},
)
print(f"Execute with attachment: output={result['output']!r} files={list(result['files'])}")
assert result["files"].get("attached_upper.txt") == b"ATTACH ME", "attachment was not processed correctly"
async with session.get(f"{api_url}/sessions/{session_id}/files/attached.txt") as response:
assert response.status == 200, f"GET attached.txt failed: {response.status}"
content = await response.read()
assert content == b"attach me", f"Unexpected attached.txt content: {content!r}"
print("GET attached.txt -> matches uploaded attachment")
async def check_symlink_attachment_excluded(session: aiohttp.ClientSession, api_url: str, cfg: Config, session_id: str) -> None:
result = await _execute(
session, api_url, "python", "import os\nos.symlink('/etc/passwd', 'leak')\nprint('linked')",
session_id=session_id,
)
print(f"Execute symlink creation: output={result['output']!r} files={list(result['files'])}")
assert "leak" not in result["files"], "a symlink was exposed as an execute attachment"
async with session.get(f"{api_url}/sessions/{session_id}/files/leak") as response:
assert response.status == 404, f"Expected 404 reading a symlink, got {response.status}"
print("GET symlinked file -> 404 (symlink not followed, as expected)")
async def check_symlink_directory_escape_blocked(session: aiohttp.ClientSession, api_url: str, cfg: Config, session_id: str) -> None:
result = await _execute(
session, api_url, "python", "import os\nos.symlink('/tmp', 'escapedir')\nprint('linked dir')",
session_id=session_id,
)
print(f"Execute symlinked-directory creation: output={result['output']!r} files={list(result['files'])}")
async with session.get(f"{api_url}/sessions/{session_id}/files/escapedir/whatever.txt") as response:
assert response.status == 404, f"Expected 404 reading through a symlinked directory, got {response.status}"
print("GET through symlinked directory -> 404 (as expected)")
async with session.put(f"{api_url}/sessions/{session_id}/files/escapedir/pwned.txt", data=b"pwned") as response:
assert response.status == 400, f"Expected 400 writing through a symlinked directory, got {response.status}"
print("PUT through symlinked directory -> 400 (as expected)")
async def check_readonly_root_filesystem(session: aiohttp.ClientSession, api_url: str, cfg: Config, session_id: str) -> None:
result = await _execute(session, api_url, "bash", "touch /pwned.txt", session_id=session_id)
print(f"Write outside /app: output={result['output']!r} return_code={result['return_code']}")
assert result["return_code"] != 0, "Writing outside the mounted /app directory unexpectedly succeeded"
assert "read-only" in result["output"].lower(), f"Unexpected failure mode: {result['output']!r}"
async def check_disk_quota_enforcement(session: aiohttp.ClientSession, api_url: str, cfg: Config, session_id: str) -> None:
"""Check that MAX_SESSION_SIZE stops both writers: executed code and the API itself.
The probe deliberately fills its session to the byte quota and leaves it there, which is
why every check gets its own session.
"""
result = await _execute(session, api_url, "bash", "dd if=/dev/zero of=small.bin bs=1M count=1 2>&1", session_id=session_id)
print(f"Disk quota probe (1MiB write): output={result['output']!r} return_code={result['return_code']}")
assert result["return_code"] == 0, f"A small write well under the quota unexpectedly failed: {result['output']!r}"
probe_mib = cfg.max_session_size // (1024 * 1024) + 16
result = await _execute(session, api_url, "bash", f"dd if=/dev/zero of=quota_probe.bin bs=1M count={probe_mib} 2>&1", session_id=session_id)
print(f"Disk quota probe ({probe_mib}MiB write): output={result['output']!r} return_code={result['return_code']}")
assert result["return_code"] != 0, (
"Writing well past --max-session-size from inside the container succeeded -- "
"the session directory does not appear to be under an XFS project quota "
"(check SESSION_QUOTA_MOUNTPOINT and that xfs_quota is usable by the API process)"
)
assert "no space" in result["output"].lower(), f"Unexpected failure mode, expected an ENOSPC error: {result['output']!r}"
# The session now sits at its byte quota, so a host-mediated write has to be refused
# too. The API keeps no byte accounting of its own - this 413 is the quota rejecting
# the write, so it is the only check covering that path.
upload_url = f"{api_url}/sessions/{session_id}/files/host_probe.bin"
async with session.put(upload_url, data=b"x" * 65536) as response:
body = await response.text()
assert response.status == 413, (
f"Expected 413 for a PUT into a session already at its byte quota, got {response.status}: {body}"
)
print(f"PUT into a session at its quota -> 413 {body}")
async with session.get(upload_url) as response:
assert response.status == 404, f"A rejected PUT left a file behind: {response.status}"
print("GET the rejected upload -> 404 (as expected, nothing was left behind)")
async def check_inode_quota_enforcement(session: aiohttp.ClientSession, api_url: str, cfg: Config, session_id: str) -> None:
probe = cfg.max_session_entries + 256
result = await _execute(
session, api_url, "bash",
f"mkdir -p flood && cd flood && for i in $(seq 1 {probe}); do : > \"f_$i\" || exit 1; done",
session_id=session_id,
)
print(f"Inode quota probe ({probe} empty files): return_code={result['return_code']} output={result['output']!r}")
assert result["return_code"] != 0, (
f"Creating {probe} files past --max-session-entries succeeded -- the session directory "
"does not appear to be under an XFS project inode quota (check SESSION_QUOTA_MOUNTPOINT, "
"that the filesystem is mounted prjquota rather than pqnoenforce, and that ihard was applied)"
)
assert "no space" in result["output"].lower(), (
f"Expected an ENOSPC error (XFS reports project quotas as ENOSPC, not EDQUOT): {result['output']!r}"
)
async def check_rejected_execute_leaves_no_attachment(session: aiohttp.ClientSession, api_url: str, cfg: Config, session_id: str) -> None:
await _execute(
session, api_url, "not-a-real-language", "irrelevant",
session_id=session_id, attachments={"should_not_persist.txt": b"should not persist"},
expected_status=400,
)
print("Execute with bad language + attachment -> 400 (as expected)")
async with session.get(f"{api_url}/sessions/{session_id}/files/should_not_persist.txt") as response:
assert response.status == 404, (
f"Expected the attachment from a rejected execute request to be absent, got {response.status}"
)
print("GET attachment from rejected execute -> 404 (as expected, nothing was left behind)")
async def check_ephemeral_execute(session: aiohttp.ClientSession, api_url: str, cfg: Config) -> None:
result = await _execute(session, api_url, "javascript", "console.log('from ephemeral session')")
print(f"Ephemeral /execute: output={result['output']!r} return_code={result['return_code']}")
assert result["return_code"] == 0, f"Ephemeral execute failed: {result}"
async def check_javascript_module_styles(session: aiohttp.ClientSession, api_url: str, cfg: Config, session_id: str) -> None:
snippets = {
"commonjs": "const os = require('node:os'); console.log('ok', typeof os.platform);",
"esm": "import os from 'node:os'; console.log('ok', typeof os.platform);",
"top-level await": "console.log('ok', await Promise.resolve('typeof'));",
}
for style, code in snippets.items():
result = await _execute(session, api_url, "javascript", code, session_id=session_id)
assert result["return_code"] == 0 and "ok" in _plain(result["output"]), f"javascript {style} failed: {result}"
print(f"Execute javascript ({style}) -> {result['output']!r}")
result = await _execute(
session, api_url, "javascript",
"const fs = require('node:fs'); console.log(fs.readFileSync('./sibling.txt', 'utf8').trim());",
session_id=session_id, attachments={"sibling.txt": b"read from the session directory"},
)
assert result["return_code"] == 0 and "read from the session directory" in result["output"], (
f"javascript could not read a session file through a relative path: {result}"
)
print(f"Execute javascript (relative path into the session dir) -> {result['output']!r}")
async def check_typescript(session: aiohttp.ClientSession, api_url: str, cfg: Config, session_id: str) -> None:
snippets = {
"annotations": "import os from 'node:os';\nconst platform: string = os.platform();\nconsole.log('ok', platform.length > 0);",
"commonjs": "const os = require('node:os');\ninterface Info { name: string }\nconst info: Info = { name: os.platform() };\nconsole.log('ok', info.name.length > 0);",
# `enum` is not erasable syntax, so it only works because tsx transpiles rather
# than relying on Node's native type stripping.
"enum": "enum Level { Low, High }\nconsole.log('ok', Level[Level.High] === 'High');",
}
for style, code in snippets.items():
result = await _execute(session, api_url, "typescript", code, session_id=session_id)
assert result["return_code"] == 0 and "ok true" in _plain(result["output"]), f"typescript {style} failed: {result}"
assert not result["files"], f"typescript {style} left files behind in the session: {sorted(result['files'])}"
print(f"Execute typescript ({style}) -> {result['output']!r}")
result = await _execute(
session, api_url, "typescript",
"import { greeting } from './greeter.ts';\nconsole.log(greeting);",
session_id=session_id, attachments={"greeter.ts": b"export const greeting: string = 'imported from the session';"},
)
assert result["return_code"] == 0 and "imported from the session" in result["output"], (
f"typescript could not import a session module through a relative specifier: {result}"
)
print(f"Execute typescript (relative import from the session dir) -> {result['output']!r}")
# The five languages that compile before they run, each through its own /executors/*.sh.
# `java` has to declare `Main`, because java.sh writes /tmp/Main.java and runs `java -cp /tmp Main`.
_COMPILED_HELLO: dict[str, str] = {
"c": '#include <stdio.h>\nint main(void) { printf("harness-ok\\n"); return 0; }',
"cpp": '#include <iostream>\nint main() { std::cout << "harness-ok" << std::endl; return 0; }',
"java": 'public class Main { public static void main(String[] args) { System.out.println("harness-ok"); } }',
"csharp": 'System.Console.WriteLine("harness-ok");',
"rust": 'fn main() { println!("harness-ok"); }',
}
_COMPILED_BROKEN: dict[str, str] = {
"c": "int main(void) { return no_such_symbol_here; }",
"cpp": "int main() { std::cout << no_such_symbol_here; }", # no <iostream> either
"java": 'public class Main { public static void main(String[] args) { int x = "not an int"; } }',
"csharp": 'int x = "not an int";',
"rust": 'fn main() { let x: i32 = "not an int"; }',
}
async def check_compiled_languages(session: aiohttp.ClientSession, api_url: str, cfg: Config, session_id: str) -> None:
"""Every compiled language runs a hello world and leaves the session untouched.
The second half matters as much as the first: each executor compiles into /tmp, which is
a per-run tmpfs, so a toolchain that starts writing into $HOME (the session mount) would
return its build noise to the caller as attachments. That is the regression d786243 fixed
for C# and 89dc6b3 fixed for TypeScript, and nothing has watched for it since.
"""
failures = []
for language, code in _COMPILED_HELLO.items():
result = await _execute(session, api_url, language, code, session_id=session_id)
output = _plain(result["output"])
print(f"Execute {language} (hello world) -> return_code={result['return_code']} output={output!r} "
f"files={sorted(result['files'])}")
if result["return_code"] != 0 or "harness-ok" not in output:
failures.append(f"{language}: return_code={result['return_code']} output={output!r}")
elif result["files"]:
failures.append(f"{language}: left files in the session: {sorted(result['files'])}")
assert not failures, "Compiled languages that did not run cleanly:\n " + "\n ".join(failures)
async def check_compiled_language_errors(session: aiohttp.ClientSession, api_url: str, cfg: Config, session_id: str) -> None:
"""A compile error must fail the run and surface the compiler's diagnostics.
Every executor script runs under `set -e`, so a non-zero compiler exit has to abort the
script rather than fall through to running a stale binary from a previous step.
"""
failures = []
for language, code in _COMPILED_BROKEN.items():
result = await _execute(session, api_url, language, code, session_id=session_id)
output = _plain(result["output"])
print(f"Execute {language} (compile error) -> return_code={result['return_code']} "
f"output={output[:120]!r}")
if result["return_code"] == 0:
failures.append(f"{language}: a compile error exited 0")
elif "error" not in output.lower():
failures.append(f"{language}: no diagnostics in the output: {output!r}")
assert not failures, "Compile errors that were not reported properly:\n " + "\n ".join(failures)
async def check_typescript_rejects_top_level_await(session: aiohttp.ClientSession, api_url: str, cfg: Config, session_id: str) -> None:
"""typescript runs as CommonJS, so top-level await is not available to it (README).
`javascript` accepts the same code - check_javascript_module_styles covers that - and the
asymmetry is the documented trade: --input-type=module would buy top-level await at the
cost of `require`.
"""
code = "console.log('ok', await Promise.resolve('x'));"
result = await _execute(session, api_url, "typescript", code, session_id=session_id)
output = _plain(result["output"])
print(f"Execute typescript (top-level await) -> return_code={result['return_code']} output={output[:200]!r}")
assert result["return_code"] != 0, (
f"typescript accepted top-level await, which the README documents as unsupported: {output!r}"
)
assert "await" in output.lower(), f"Expected a diagnostic mentioning await, got: {output!r}"
async def check_directory_listing(session: aiohttp.ClientSession, api_url: str, cfg: Config, session_id: str) -> None:
# This check owns everything it asserts on: a plain file, a symlink to a file and a
# symlink to a directory, all created here rather than left behind by earlier checks.
async with session.put(f"{api_url}/sessions/{session_id}/files/hello.txt", data=b"hello world") as response:
assert response.status == 204, f"PUT hello.txt (listing fixture) failed: {response.status}"
fixtures = await _execute(
session, api_url, "python",
"import os\nos.symlink('/etc/passwd', 'leak')\nos.symlink('/tmp', 'escapedir')\nprint('linked')",
session_id=session_id,
)
print(f"Listing fixtures: output={fixtures['output']!r} files={list(fixtures['files'])}")
async with session.get(f"{api_url}/sessions/{session_id}/files/") as response:
assert response.status == 200, f"Expected 200 listing the session root, got {response.status}"
listing = await response.json()
names = {entry["name"]: entry for entry in listing["entries"]}
print(f"Root listing: {sorted(names)}")
assert listing["path"] == "", f"Expected an empty path for the root listing, got {listing['path']!r}"
assert "hello.txt" in names, f"Root listing is missing a known file: {sorted(names)}"
assert names["hello.txt"]["type"] == "file", f"hello.txt typed as {names['hello.txt']['type']!r}"
assert names["hello.txt"]["size"] > 0, "hello.txt reported as empty"
assert names["leak"]["type"] == "symlink", (
f"A symlink must be reported as such and never followed, got {names['leak']['type']!r}"
)
# Unlike execution results, a listing hides nothing -- it is how a caller discovers files
# that /execute excluded or omitted.
await _execute(session, api_url, "bash", "mkdir -p listdir/.hidden && echo x > listdir/.hidden/secret.txt && echo y > listdir/plain.txt", session_id=session_id)
async with session.get(f"{api_url}/sessions/{session_id}/files/listdir") as response:
assert response.status == 200, f"Expected 200 listing a subdirectory, got {response.status}"
nested = await response.json()
nested_names = {entry["name"]: entry for entry in nested["entries"]}
assert nested["path"] == "listdir", f"Unexpected listing path: {nested['path']!r}"
assert sorted(nested_names) == [".hidden", "plain.txt"], f"Unexpected listing: {sorted(nested_names)}"
assert nested_names[".hidden"]["type"] == "directory", "A hidden directory must still be listed"
print(f"Subdirectory listing: {sorted(nested_names)} (hidden entries included)")
async with session.get(f"{api_url}/sessions/{session_id}/files/listdir/plain.txt") as response:
assert response.status == 200, f"Expected 200 reading a file, got {response.status}"
assert (await response.read()) == b"y\n", "A file GET must still return raw bytes, not a listing"
print("GET on a file still returns bytes (as expected)")
async with session.get(f"{api_url}/sessions/{session_id}/files/escapedir") as response:
assert response.status == 404, (
f"Expected 404 listing through a symlinked directory, got {response.status}"
)
print("GET listing on a symlinked directory -> 404 (as expected, not followed)")
async def check_result_attachment_cap(session: aiohttp.ClientSession, api_url: str, cfg: Config, session_id: str) -> None:
total = cfg.max_result_attachments + 50
result = await _execute(
session, api_url, "bash",
f"for i in $(seq 1 {total}); do printf 'content %s' \"$i\" > \"out_$i.txt\"; done",
session_id=session_id,
)
omitted = result["omitted_files"]
print(f"Created {total} files -> {len(result['files'])} attachments, {len(omitted)} omitted")
assert result["return_code"] == 0, f"Creating {total} files failed: {result['output']!r}"
assert len(result["files"]) == cfg.max_result_attachments, (
f"Expected exactly {cfg.max_result_attachments} attachments, got {len(result['files'])} -- "
"the server's MAX_RESULT_ATTACHMENTS probably differs from --max-result-attachments"
)
assert len(omitted) == total - cfg.max_result_attachments, (
f"Expected {total - cfg.max_result_attachments} omitted files, got {len(omitted)}"
)
assert not (set(result["files"]) & set(omitted)), "A file was both attached and omitted"
assert len(set(result["files"]) | set(omitted)) == total, "Some changed files were reported nowhere"
# An omitted file is not a lost file: it must still be retrievable individually.
probe = omitted[0]
async with session.get(f"{api_url}/sessions/{session_id}/files/{probe}") as response:
assert response.status == 200, f"Expected 200 fetching omitted file {probe}, got {response.status}"
assert (await response.read()).startswith(b"content "), f"Unexpected content for {probe}"
print(f"Omitted file {probe} still retrievable via the files API (as expected)")
# A session over the attachment cap must stay usable, not become permanently broken.
result = await _execute(session, api_url, "bash", "echo still-alive", session_id=session_id)
assert result["return_code"] == 0, f"Follow-up execute on a large session failed: {result}"
assert not result["files"] and not result["omitted_files"], (
f"A no-op run reported changes: {sorted(result['files'])} / {result['omitted_files']}"
)
print("Follow-up execute on the same over-cap session -> ok, nothing reported as changed")
async def check_change_detection_stability(session: aiohttp.ClientSession, api_url: str, cfg: Config, session_id: str) -> None:
async with session.put(f"{api_url}/sessions/{session_id}/files/uploaded.txt", data=b"uploaded") as response:
assert response.status == 204, f"PUT failed: {response.status}"
result = await _execute(
session, api_url, "bash",
"mkdir -p made/deeper && echo created > made/deeper/by_container.txt",
session_id=session_id,
)
assert "made/deeper/by_container.txt" in result["files"], (
f"A container-created file was not returned: {sorted(result['files'])}"
)
assert "uploaded.txt" not in result["files"], "An untouched upload was reported as changed"
# Both the API-created and the container-created file must now be treated as unchanged.
# Before identity mapping the container-created one was re-sent on every single run.
for attempt in (1, 2):
result = await _execute(session, api_url, "bash", "true", session_id=session_id)
assert not result["files"], (
f"No-op run {attempt} re-reported unchanged files: {sorted(result['files'])}"
)
assert not result["deleted_files"], f"No-op run {attempt} reported deletions: {result['deleted_files']}"
print("Repeated no-op runs report no changes for API- and container-created files (as expected)")
# Rewriting with different content must still be caught, in both locations.
result = await _execute(
session, api_url, "bash",
"echo rewritten > made/deeper/by_container.txt && echo rewritten > uploaded.txt",
session_id=session_id,
)
assert set(result["files"]) == {"made/deeper/by_container.txt", "uploaded.txt"}, (
f"Rewrites were not detected: {sorted(result['files'])}"
)
print("Rewrites detected for both file origins (as expected)")
result = await _execute(session, api_url, "bash", "rm uploaded.txt", session_id=session_id)
assert result["deleted_files"] == ["uploaded.txt"], f"Deletion not detected: {result['deleted_files']}"
print("Deletion still detected (as expected)")
async def check_home_directory_noise_excluded(session: aiohttp.ClientSession, api_url: str, cfg: Config, session_id: str) -> None:
# The session directory is the container's $HOME, so tool caches land in it. They must not
# drown the real results, but must remain visible through the files API.
result = await _execute(
session, api_url, "bash",
"mkdir -p .cache/pip .local/lib && echo junk > .cache/pip/blob && echo junk > .local/lib/mod.py && echo real > result.txt",
session_id=session_id,
)
assert sorted(result["files"]) == ["result.txt"], (
f"Hidden directories leaked into the attachments: {sorted(result['files'])}"
)
print(f"Hidden $HOME directories excluded from attachments (got {sorted(result['files'])})")
async with session.get(f"{api_url}/sessions/{session_id}/files/.cache/pip") as response:
assert response.status == 200, f"Excluded files must stay listable, got {response.status}"
names = [entry["name"] for entry in (await response.json())["entries"]]
assert names == ["blob"], f"Unexpected listing of an excluded directory: {names}"
print("Excluded directories are still reachable through the files API (as expected)")
# Names executed code can legally create that a Content-Disposition header cannot carry in a
# plain `filename` parameter. Quotes and newlines would terminate or inject header syntax; the
# slash would be stripped by a conforming client (RFC 6266); the rest are simply non-ASCII or
# reserved. All of them have to survive in the RFC 5987 `filename*` parameter instead.
_AWKWARD_NAMES: tuple[str, ...] = (
"sp ace.txt",
"café.txt",
"日本語.txt",
'we"ird.txt',
"per%cent.txt",
"semi;colon.txt",
"quo'te.txt",
"e=quals.txt",
"new\nline.txt",
"nested/dir/deep.txt",
)
# A newline cannot be carried in a URL path, so that one name is exercised as an attachment
# only - which is the half that matters, since it is the header it must not be able to break.
_RETRIEVABLE_AWKWARD_NAMES = tuple(name for name in _AWKWARD_NAMES if "\n" not in name)
async def check_awkward_result_filenames(session: aiohttp.ClientSession, api_url: str, cfg: Config, session_id: str) -> None:
"""A result attachment's sub_path survives the response header exactly.
This is what `_content_disposition` in file_helpers.py exists for, and nothing has tested
it beyond plain ASCII. `part.filename` prefers the RFC 5987 `filename*` parameter, so it
must come back byte-identical; the flattened ASCII `filename` fallback must simultaneously
be free of anything that could alter the header.
"""
# Written with Python rather than the shell so the names need no quoting gymnastics.
literals = ", ".join(repr(name) for name in _AWKWARD_NAMES)
code = (
"import pathlib\n"
f"names = [{literals}]\n"
"for name in names:\n"
" p = pathlib.Path(name)\n"
" p.parent.mkdir(parents=True, exist_ok=True)\n"
" p.write_text('content of ' + name)\n"
"print('wrote', len(names))"
)
result = await _execute(session, api_url, "python", code, session_id=session_id)
assert result["return_code"] == 0, f"Creating the awkward-named files failed: {result['output']!r}"
returned = set(result["files"])
print(f"Awkward names returned as attachments: {sorted(returned)}")
missing = [name for name in _AWKWARD_NAMES if name not in returned]
assert not missing, (
f"These names did not round-trip through Content-Disposition: {missing} -- "
f"got {sorted(returned)}"
)
for name in _AWKWARD_NAMES:
expected = f"content of {name}".encode()
assert result["files"][name] == expected, (
f"Attachment {name!r} carried the wrong bytes: {result['files'][name]!r}"
)
print("All awkward names round-tripped exactly, with matching content (as expected)")
# And each one is still addressable individually through the files API. The sub_path has
# to be percent-encoded into the URL - leaving a literal '%' in would make yarl read
# "per%cent.txt" as an escape sequence - so the request is built pre-encoded.
for name in _RETRIEVABLE_AWKWARD_NAMES:
url = URL(f"{api_url}/sessions/{session_id}/files/{quote(name, safe='/')}", encoded=True)
async with session.get(url) as response:
assert response.status == 200, f"GET {name!r} failed: {response.status}"
assert (await response.read()) == f"content of {name}".encode(), f"Wrong bytes for {name!r}"
print("All awkward names are individually retrievable through the files API (as expected)")
async def check_non_utf8_filename_omitted(session: aiohttp.ClientSession, api_url: str, cfg: Config, session_id: str) -> None:
"""A filename that is not valid UTF-8 is named in omitted_files, never a 500.
The name cannot go in a `filename*` parameter (percent-encoding it requires encoding it as
UTF-8 first), so the attachment is dropped - but the run succeeded, so the response must
still describe what changed. The listing is where such a file stays discoverable.
"""
result = await _execute(
session, api_url, "bash",
r"printf 'x' > $'bad\xff\xfe.txt' && echo readable > fine.txt && echo done",
session_id=session_id,
)
print(f"Non-UTF-8 filename run: return_code={result['return_code']} "
f"files={sorted(result['files'])} omitted={result['omitted_files']!r}")
assert result["return_code"] == 0, f"Creating a non-UTF-8 filename failed: {result['output']!r}"
# surrogateescape is how the server decoded the raw bytes, and json round-trips it exactly.
bad_name = "bad\udcff\udcfe.txt"
assert bad_name in result["omitted_files"], (
f"A non-UTF-8 filename must be named in omitted_files, got {result['omitted_files']!r}"
)
assert bad_name not in result["files"], "A non-UTF-8 filename must not be sent as an attachment"
assert result["files"].get("fine.txt") == b"readable\n", (
f"An unreadable sibling must not disturb the other attachments: {sorted(result['files'])}"
)
print(f"Non-UTF-8 filename -> omitted_files (as expected), siblings unaffected")
# Unlike execution results, the listing hides nothing - it is the only way to see it.
async with session.get(f"{api_url}/sessions/{session_id}/files/") as response:
assert response.status == 200, f"Expected 200 listing the session root, got {response.status}"
names = [entry["name"] for entry in (await response.json())["entries"]]
assert bad_name in names, f"The listing must still show a non-UTF-8 name, got {names!r}"
print("The directory listing still shows the non-UTF-8 name (as expected)")
async def check_deleted_files_not_truncated(session: aiohttp.ClientSession, api_url: str, cfg: Config, session_id: str) -> None:
"""deleted_files is never capped, unlike the attachment list.
MAX_RESULT_ATTACHMENTS bounds what the response *carries*; a deletion carries nothing, so
there is no reason to truncate it and the README says it is not.
"""
total = cfg.max_result_attachments + 50
result = await _execute(
session, api_url, "bash", f"for i in $(seq 1 {total}); do echo x > \"gone_$i.txt\"; done",
session_id=session_id,
)
assert result["return_code"] == 0, f"Creating {total} files failed: {result['output']!r}"
result = await _execute(session, api_url, "bash", "rm gone_*.txt", session_id=session_id)
print(f"Deleted {total} files -> {len(result['deleted_files'])} named in deleted_files")
assert result["return_code"] == 0, f"Deleting the files failed: {result['output']!r}"
assert len(result["deleted_files"]) == total, (
f"deleted_files was truncated: expected {total} names, got {len(result['deleted_files'])}"
)
assert set(result["deleted_files"]) == {f"gone_{i}.txt" for i in range(1, total + 1)}, (
"deleted_files did not name exactly the files that were removed"
)
print(f"All {total} deletions reported, past the {cfg.max_result_attachments} attachment cap (as expected)")
async def check_change_detection_adversarial(session: aiohttp.ClientSession, api_url: str, cfg: Config, session_id: str) -> None:
"""Changes that a naive (size, mtime) stamp would miss are still reported.
Change detection compares (inode, size, mtime, ctime) rather than hashing, which is what
makes these three cases interesting: each defeats one component and has to be caught by
another. ctime is the backstop, and it cannot be forged without privileges the container
does not have.
"""
async with session.put(f"{api_url}/sessions/{session_id}/files/probe.txt", data=b"AAAA") as response:
assert response.status == 204, f"PUT probe.txt failed: {response.status}"
# Same size, and mtime deliberately restored afterwards: only ctime moved.
result = await _execute(
session, api_url, "python",
"import os\nst = os.stat('probe.txt')\n"
"open('probe.txt', 'w').write('BBBB')\n"
"os.utime('probe.txt', ns=(st.st_atime_ns, st.st_mtime_ns))\n"
"print('rewritten with mtime restored')",
session_id=session_id,
)
print(f"Same-size rewrite with mtime restored -> files={sorted(result['files'])}")
assert result["files"].get("probe.txt") == b"BBBB", (
"A same-size rewrite with a restored mtime was not detected -- the ctime component of "
f"the change signature is not doing its job: {sorted(result['files'])}"
)
# Delete and recreate with identical content, size and mtime: only the inode moved.
result = await _execute(
session, api_url, "python",
"import os\nst = os.stat('probe.txt')\nos.remove('probe.txt')\n"
"open('probe.txt', 'w').write('BBBB')\n"
"os.utime('probe.txt', ns=(st.st_atime_ns, st.st_mtime_ns))\n"
"print('recreated identically')",
session_id=session_id,
)
print(f"Delete-and-recreate with identical content/size/mtime -> files={sorted(result['files'])} "
f"deleted={result['deleted_files']}")
assert "probe.txt" in result["files"], (
"A delete-and-recreate with identical metadata was not detected -- the inode component "
f"of the change signature is not doing its job: {sorted(result['files'])}"
)
assert not result["deleted_files"], (
f"A file that exists again at collect time must not be reported deleted: {result['deleted_files']}"
)
# A rename is one deletion plus one creation, carrying the original bytes.
result = await _execute(session, api_url, "bash", "mv probe.txt renamed.txt", session_id=session_id)
print(f"Rename -> files={sorted(result['files'])} deleted={result['deleted_files']}")
assert result["deleted_files"] == ["probe.txt"], f"Rename source not reported deleted: {result['deleted_files']}"
assert result["files"].get("renamed.txt") == b"BBBB", (
f"Rename destination not returned with the original bytes: {sorted(result['files'])}"
)
print("All three adversarial change cases detected (as expected)")
async def check_hidden_file_vs_hidden_directory(session: aiohttp.ClientSession, api_url: str, cfg: Config, session_id: str) -> None:
"""Only hidden *directories* are excluded from results; a hidden file is a real result.
The exclusion exists because the session directory doubles as $HOME and package managers
fill it with dot-directories. A single dotfile that executed code wrote deliberately is
not that, so it must come back.
"""
result = await _execute(
session, api_url, "bash",
"echo secret > .env && mkdir -p .cache && echo noise > .cache/blob && echo plain > visible.txt",
session_id=session_id,
)
print(f"Hidden file vs hidden directory -> files={sorted(result['files'])}")
assert result["files"].get(".env") == b"secret\n", (
f"A hidden file at the session root must be returned as a result: {sorted(result['files'])}"
)
assert not any(name.startswith(".cache/") for name in result["files"]), (
f"A hidden directory's contents must stay out of the results: {sorted(result['files'])}"
)
assert result["files"].get("visible.txt") == b"plain\n", f"Missing the plain file: {sorted(result['files'])}"
print("Hidden file returned, hidden directory excluded (as expected)")
async def _post_raw(
session: aiohttp.ClientSession, url: str, body: bytes, content_type: str,
) -> tuple[int, str]:
"""POST a hand-rolled body, for shapes aiohttp.FormData will not produce."""
async with session.post(url, data=body, headers={aiohttp.hdrs.CONTENT_TYPE: content_type}) as response:
return response.status, (await response.text())[:200]
_RAW_BOUNDARY = "harnessboundaryLQ8x3f"
def _raw_multipart(parts: Sequence[tuple[str, str | None, bytes]]) -> tuple[bytes, str]:
"""Build a multipart body by hand, as (body, content_type).
aiohttp's FormData cannot express two of the shapes worth testing: it substitutes the
field name when a bytes value is given no filename, and it percent-encodes a filename
into the RFC 5987 parameter, which turns "../escape.txt" into the harmless literal name
"..%2Fescape.txt". Both are sensible client behaviour and both hide what the server would
do with the raw thing, so these bodies are assembled directly.
"""
chunks = []
for name, filename, content in parts:
disposition = f'form-data; name="{name}"'
if filename is not None:
disposition += f'; filename="{filename}"'
chunks.append(
f"--{_RAW_BOUNDARY}\r\nContent-Disposition: {disposition}\r\n\r\n".encode()
+ content + b"\r\n"
)
chunks.append(f"--{_RAW_BOUNDARY}--\r\n".encode())
return b"".join(chunks), f"multipart/form-data; boundary={_RAW_BOUNDARY}"
async def check_execute_field_validation(session: aiohttp.ClientSession, api_url: str, cfg: Config, session_id: str) -> None:
"""Every malformed /execute request is rejected as invalid input, not as a server error.
The README's error table promises 400 for invalid input, and each of these is invalid
input. They are grouped because they share one property worth checking together: a
rejected request must never start a container.
"""
url = f"{api_url}/sessions/{session_id}/execute"
def form(**parts) -> aiohttp.FormData:
data = aiohttp.FormData(default_to_multipart=True)
for name, value in parts.items():
data.add_field(name, value)
return data
cases: list[tuple[str, aiohttp.FormData]] = []
unknown_field = form(language="python", code="print(1)")
unknown_field.add_field("surprise", "unexpected")
cases.append(("an unknown multipart field", unknown_field))
cases.append(("a missing language field", form(code="print(1)")))
cases.append(("a missing code field", form(language="python")))
cases.append(("a NUL byte in code", form(language="python", code="print('a\x00b')")))
failures = []
for description, data in cases:
async with session.post(url, data=data) as response:
status, body = response.status, (await response.text())[:160]
print(f"Execute with {description} -> {status} {body!r}")
if status != 400:
failures.append(f"{description}: expected 400, got {status} ({body!r})")
# These two have to go on the wire by hand: FormData would substitute the field name for
# the missing filename, and would percent-encode the traversal into a harmless literal.
raw_cases = [
("an attachments part with no filename",
[("language", None, b"python"), ("code", None, b"print(1)"),
("attachments", None, b"no filename on this part")]),
("an attachment escaping the session root",
[("language", None, b"python"), ("code", None, b"print(1)"),
("attachments", "../escape.txt", b"escape")]),
("an attachment escaping through a subdirectory",
[("language", None, b"python"), ("code", None, b"print(1)"),
("attachments", "a/../../outside.txt", b"escape")]),
]
for description, parts in raw_cases:
body_bytes, content_type = _raw_multipart(parts)
status, body = await _post_raw(session, url, body_bytes, content_type)
print(f"Execute with {description} -> {status} {body!r}")
if status != 400:
failures.append(f"{description}: expected 400, got {status} ({body!r})")
assert not failures, (
"Malformed /execute requests that were not rejected with 400:\n " + "\n ".join(failures)
)
print("All malformed /execute requests -> 400 (as expected)")
async def check_execute_oversized_code(session: aiohttp.ClientSession, api_url: str, cfg: Config, session_id: str) -> None:
"""Code past MAX_CODE_LENGTH is refused, and the attachment sent with it is discarded.
The size cap has to fire while the multipart stream is still being read, which is exactly
when an earlier attachment part has already been staged - so this is the case that proves
the staging cleanup runs on the size-limit path too, not just on validation failures.
"""
data = aiohttp.FormData(default_to_multipart=True)
data.add_field("attachments", b"should not persist", filename="oversized_probe.txt",
content_type="application/octet-stream")
data.add_field("language", "python")
data.add_field("code", "#" + "x" * (cfg.max_code_length + 4096))
async with session.post(f"{api_url}/sessions/{session_id}/execute", data=data) as response:
status, body = response.status, (await response.text())[:160]
print(f"Execute with code past MAX_CODE_LENGTH -> {status} {body!r}")
assert status == 413, (
f"Expected 413 for code past --max-code-length ({cfg.max_code_length}), got {status}: {body!r} -- "
"the server's MAX_CODE_LENGTH may differ from --max-code-length"
)
async with session.get(f"{api_url}/sessions/{session_id}/files/oversized_probe.txt") as response:
assert response.status == 404, (
f"The attachment from an oversized request was left behind: {response.status}"
)
print("GET the attachment from the oversized request -> 404 (as expected, nothing left behind)")
async def check_execute_malformed_body(session: aiohttp.ClientSession, api_url: str, cfg: Config, session_id: str) -> None:
"""A body that is not usable multipart is invalid input, so it must be a 400.
Neither shape reaches any of the API's own validation: both blow up inside aiohttp's
multipart reader while the request is still being parsed. That is what makes them worth
checking - an uncaught parser exception becomes a 500, which tells a client that the
server is broken rather than that the request was.
"""
url = f"{api_url}/sessions/{session_id}/execute"
failures = []