-
Notifications
You must be signed in to change notification settings - Fork 361
/
Sandbox.py
1457 lines (1166 loc) · 51.8 KB
/
Sandbox.py
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
#!/usr/bin/env python3
# Contest Management System - http://cms-dev.github.io/
# Copyright © 2010-2015 Giovanni Mascellani <mascellani@poisson.phc.unipi.it>
# Copyright © 2010-2018 Stefano Maggiolo <s.maggiolo@gmail.com>
# Copyright © 2010-2012 Matteo Boscariol <boscarim@hotmail.com>
# Copyright © 2014 Luca Wehrstedt <luca.wehrstedt@gmail.com>
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as
# published by the Free Software Foundation, either version 3 of the
# License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
import io
import logging
import os
import resource
import select
import stat
import tempfile
import time
from abc import ABCMeta, abstractmethod
from functools import wraps, partial
import gevent
from gevent import subprocess
from cms import config, rmtree
from cmscommon.commands import pretty_print_cmdline
logger = logging.getLogger(__name__)
class SandboxInterfaceException(Exception):
pass
def with_log(func):
"""Decorator for presuming that the logs are present.
"""
@wraps(func)
def newfunc(self, *args, **kwargs):
"""If they are not present, get the logs.
"""
if self.log is None:
self.get_log()
return func(self, *args, **kwargs)
return newfunc
def wait_without_std(procs):
"""Wait for the conclusion of the processes in the list, avoiding
starving for input and output.
procs (list): a list of processes as returned by Popen.
return (list): a list of return codes.
"""
def get_to_consume():
"""Amongst stdout and stderr of list of processes, find the
ones that are alive and not closed (i.e., that may still want
to write to).
return (list): a list of open streams.
"""
to_consume = []
for process in procs:
if process.poll() is None: # If the process is alive.
if process.stdout and not process.stdout.closed:
to_consume.append(process.stdout)
if process.stderr and not process.stderr.closed:
to_consume.append(process.stderr)
return to_consume
# Close stdin; just saying stdin=None isn't ok, because the
# standard input would be obtained from the application stdin,
# that could interfere with the child process behaviour
for process in procs:
if process.stdin:
process.stdin.close()
# Read stdout and stderr to the end without having to block
# because of insufficient buffering (and without allocating too
# much memory). Unix specific.
to_consume = get_to_consume()
while len(to_consume) > 0:
to_read = select.select(to_consume, [], [], 1.0)[0]
for file_ in to_read:
file_.read(8 * 1024)
to_consume = get_to_consume()
return [process.wait() for process in procs]
class Truncator(io.RawIOBase):
"""Wrap a file-like object to simulate truncation.
This file-like object provides read-only access to a limited prefix
of a wrapped file-like object. It provides a truncated version of
the file without ever touching the object on the filesystem.
This class is only able to wrap binary streams as it relies on the
readinto method which isn't provided by text (unicode) streams.
"""
def __init__(self, fobj, size):
"""Wrap fobj and give access to its first size bytes.
fobj (fileobj): a file-like object to wrap.
size (int): the number of bytes that will be accessible.
"""
self.fobj = fobj
self.size = size
def close(self):
"""See io.IOBase.close."""
self.fobj.close()
@property
def closed(self):
"""See io.IOBase.closed."""
return self.fobj.closed
def readable(self):
"""See io.IOBase.readable."""
return True
def seekable(self):
"""See io.IOBase.seekable."""
return True
def readinto(self, b):
"""See io.RawIOBase.readinto."""
# This is the main "trick": we clip (i.e. mask, reduce, slice)
# the given buffer so that it doesn't overflow into the area we
# want to hide (that is, out of the prefix) and then we forward
# it to the wrapped file-like object.
b = memoryview(b)[:max(0, self.size - self.fobj.tell())]
return self.fobj.readinto(b)
def seek(self, offset, whence=io.SEEK_SET):
"""See io.IOBase.seek."""
# We have to catch seeks relative to the end of the file and
# adjust them to the new "imposed" size.
if whence == io.SEEK_END:
if self.fobj.seek(0, io.SEEK_END) > self.size:
self.fobj.seek(self.size, io.SEEK_SET)
return self.fobj.seek(offset, io.SEEK_CUR)
else:
return self.fobj.seek(offset, whence)
def tell(self):
"""See io.IOBase.tell."""
return self.fobj.tell()
def write(self, _):
"""See io.RawIOBase.write."""
raise io.UnsupportedOperation('write')
class SandboxBase(metaclass=ABCMeta):
"""A base class for all sandboxes, meant to contain common
resources.
"""
EXIT_SANDBOX_ERROR = 'sandbox error'
EXIT_OK = 'ok'
EXIT_SIGNAL = 'signal'
EXIT_TIMEOUT = 'timeout'
EXIT_TIMEOUT_WALL = 'wall timeout'
EXIT_NONZERO_RETURN = 'nonzero return'
def __init__(self, file_cacher, name=None, temp_dir=None):
"""Initialization.
file_cacher (FileCacher): an instance of the FileCacher class
(to interact with FS), if the sandbox needs it.
name (string|None): name of the sandbox, which might appear in the
path and in system logs.
temp_dir (unicode|None): temporary directory to use; if None, use the
default temporary directory specified in the configuration.
"""
self.file_cacher = file_cacher
self.name = name if name is not None else "unnamed"
self.temp_dir = temp_dir if temp_dir is not None else config.temp_dir
self.cmd_file = "commands.log"
# These are not necessarily used, but are here for API compatibility
# TODO: move all other common properties here.
self.box_id = 0
self.fsize = None
self.cgroup = False
self.dirs = []
self.preserve_env = False
self.inherit_env = []
self.set_env = {}
self.verbosity = 0
self.max_processes = 1
# Set common environment variables.
# Specifically needed by Python, that searches the home for
# packages.
self.set_env["HOME"] = "./"
def set_multiprocess(self, multiprocess):
"""Set the sandbox to (dis-)allow multiple threads and processes.
multiprocess (bool): whether to allow multiple thread/processes or not.
"""
if multiprocess:
# Max processes is set to 1000 to limit the effect of fork bombs.
self.max_processes = 1000
else:
self.max_processes = 1
def get_stats(self):
"""Return a human-readable string representing execution time
and memory usage.
return (string): human-readable stats.
"""
execution_time = self.get_execution_time()
if execution_time is not None:
time_str = "%.3f sec" % (execution_time)
else:
time_str = "(time unknown)"
memory_used = self.get_memory_used()
if memory_used is not None:
mem_str = "%.2f MB" % (memory_used / (1024 * 1024))
else:
mem_str = "(memory usage unknown)"
return "[%s - %s]" % (time_str, mem_str)
@abstractmethod
def get_root_path(self):
"""Return the toplevel path of the sandbox.
return (string): the root path.
"""
pass
@abstractmethod
def get_execution_time(self):
"""Return the time spent in the sandbox.
return (float): time spent in the sandbox.
"""
pass
@abstractmethod
def get_memory_used(self):
"""Return the memory used by the sandbox.
return (int): memory used by the sandbox (in bytes).
"""
pass
@abstractmethod
def get_killing_signal(self):
"""Return the signal that killed the sandboxed process.
return (int): offending signal, or 0.
"""
pass
@abstractmethod
def get_exit_status(self):
"""Get information about how the sandbox terminated.
return (string): the main reason why the sandbox terminated.
"""
pass
@abstractmethod
def get_exit_code(self):
"""Return the exit code of the sandboxed process.
return (float): exitcode, or 0.
"""
pass
@abstractmethod
def get_human_exit_description(self):
"""Get the status of the sandbox and return a human-readable
string describing it.
return (string): human-readable explaination of why the
sandbox terminated.
"""
pass
def relative_path(self, path):
"""Translate from a relative path inside the sandbox to a
system path.
path (string): relative path of the file inside the sandbox.
return (string): the absolute path.
"""
return os.path.join(self.get_root_path(), path)
def create_file(self, path, executable=False):
"""Create an empty file in the sandbox and open it in write
binary mode.
path (string): relative path of the file inside the sandbox.
executable (bool): to set permissions.
return (file): the file opened in write binary mode.
"""
if executable:
logger.debug("Creating executable file %s in sandbox.", path)
else:
logger.debug("Creating plain file %s in sandbox.", path)
real_path = self.relative_path(path)
try:
file_fd = os.open(real_path, os.O_CREAT | os.O_EXCL | os.O_WRONLY)
file_ = open(file_fd, "wb")
except OSError as e:
logger.error("Failed create file %s in sandbox. Unable to "
"evalulate this submission. This may be due to "
"cheating. %s", real_path, e, exc_info=True)
raise
mod = stat.S_IRUSR | stat.S_IRGRP | stat.S_IROTH | stat.S_IWUSR
if executable:
mod |= stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH
os.chmod(real_path, mod)
return file_
def create_file_from_storage(self, path, digest, executable=False):
"""Write a file taken from FS in the sandbox.
path (string): relative path of the file inside the sandbox.
digest (string): digest of the file in FS.
executable (bool): to set permissions.
"""
with self.create_file(path, executable) as dest_fobj:
self.file_cacher.get_file_to_fobj(digest, dest_fobj)
def create_file_from_string(self, path, content, executable=False):
"""Write some data to a file in the sandbox.
path (string): relative path of the file inside the sandbox.
content (string): what to write in the file.
executable (bool): to set permissions.
"""
with self.create_file(path, executable) as dest_fobj:
dest_fobj.write(content)
def get_file(self, path, trunc_len=None):
"""Open a file in the sandbox given its relative path.
path (str): relative path of the file inside the sandbox.
trunc_len (int|None): if None, does nothing; otherwise, before
returning truncate it at the specified length.
return (file): the file opened in read binary mode.
"""
logger.debug("Retrieving file %s from sandbox.", path)
real_path = self.relative_path(path)
file_ = open(real_path, "rb")
if trunc_len is not None:
file_ = Truncator(file_, trunc_len)
return file_
def get_file_text(self, path, trunc_len=None):
"""Open a file in the sandbox given its relative path, in text mode.
Assumes encoding is UTF-8. The caller must handle decoding errors.
path (str): relative path of the file inside the sandbox.
trunc_len (int|None): if None, does nothing; otherwise, before
returning truncate it at the specified length.
return (file): the file opened in read binary mode.
"""
logger.debug("Retrieving text file %s from sandbox.", path)
real_path = self.relative_path(path)
file_ = open(real_path, "rt", encoding="utf-8")
if trunc_len is not None:
file_ = Truncator(file_, trunc_len)
return file_
def get_file_to_string(self, path, maxlen=1024):
"""Return the content of a file in the sandbox given its
relative path.
path (str): relative path of the file inside the sandbox.
maxlen (int): maximum number of bytes to read, or None if no
limit.
return (string): the content of the file up to maxlen bytes.
"""
with self.get_file(path) as file_:
if maxlen is None:
return file_.read()
else:
return file_.read(maxlen)
def get_file_to_storage(self, path, description="", trunc_len=None):
"""Put a sandbox file in FS and return its digest.
path (str): relative path of the file inside the sandbox.
description (str): the description for FS.
trunc_len (int|None): if None, does nothing; otherwise, before
returning truncate it at the specified length.
return (str): the digest of the file.
"""
with self.get_file(path, trunc_len=trunc_len) as file_:
return self.file_cacher.put_file_from_fobj(file_, description)
def stat_file(self, path):
"""Return the stats of a file in the sandbox.
path (string): relative path of the file inside the sandbox.
return (stat_result): the stat results.
"""
return os.stat(self.relative_path(path))
def file_exists(self, path):
"""Return if a file exists in the sandbox.
path (string): relative path of the file inside the sandbox.
return (bool): if the file exists.
"""
return os.path.exists(self.relative_path(path))
def remove_file(self, path):
"""Delete a file in the sandbox.
path (string): relative path of the file inside the sandbox.
"""
os.remove(self.relative_path(path))
@abstractmethod
def execute_without_std(self, command, wait=False):
"""Execute the given command in the sandbox using
subprocess.Popen and discarding standard input, output and
error. More specifically, the standard input gets closed just
after the execution has started; standard output and error are
read until the end, in a way that prevents the execution from
being blocked because of insufficient buffering.
command ([string]): executable filename and arguments of the
command.
wait (bool): True if this call is blocking, False otherwise
return (bool|Popen): if the call is blocking, then return True
if the sandbox didn't report errors (caused by the sandbox
itself), False otherwise; if the call is not blocking,
return the Popen object from subprocess.
"""
pass
@abstractmethod
def translate_box_exitcode(self, _):
"""Translate the sandbox exit code to a boolean sandbox success.
_ (int): the exit code of the sandbox.
return (bool): False if the sandbox had an error, True if it
terminated correctly (regardless of what the internal process
did).
"""
pass
@abstractmethod
def cleanup(self, delete=False):
"""Cleanup the sandbox.
To be called at the end of the execution, regardless of
whether the sandbox should be deleted or not.
delete (bool): if True, also delete get_root_path() and everything it
contains.
"""
pass
class StupidSandbox(SandboxBase):
"""A stupid sandbox implementation. It has very few features and
is not secure against things like box escaping and fork
bombs. Yet, it is very portable and has no dependencies, so it's
very useful for testing. Using in real contests is strongly
discouraged.
"""
def __init__(self, file_cacher, name=None, temp_dir=None):
"""Initialization.
For arguments documentation, see SandboxBase.__init__.
"""
SandboxBase.__init__(self, file_cacher, name, temp_dir)
# Make box directory
self._path = tempfile.mkdtemp(
dir=self.temp_dir,
prefix="cms-%s-" % (self.name))
self.exec_num = -1
self.popen = None
self.popen_time = None
self.exec_time = None
logger.debug("Sandbox in `%s' created, using stupid box.", self._path)
# Box parameters
self.chdir = self._path
self.stdin_file = None
self.stdout_file = None
self.stderr_file = None
self.stack_space = None
self.address_space = None
self.timeout = None
self.wallclock_timeout = None
self.extra_timeout = None
def get_root_path(self):
"""Return the toplevel path of the sandbox.
return (string): the root path.
"""
return self._path
# TODO - It returns wall clock time, because I have no way to
# check CPU time (libev doesn't have wait4() support)
def get_execution_time(self):
"""Return the time spent in the sandbox.
return (float): time spent in the sandbox.
"""
return self.get_execution_wall_clock_time()
# TODO - It returns the best known approximation of wall clock
# time; unfortunately I have no way to compute wall clock time
# just after the child terminates, because I have no guarantee
# about how the control will come back to this class
def get_execution_wall_clock_time(self):
"""Return the total time from the start of the sandbox to the
conclusion of the task.
return (float): total time the sandbox was alive.
"""
if self.exec_time:
return self.exec_time
if self.popen_time:
self.exec_time = time.monotonic() - self.popen_time
return self.exec_time
return None
# TODO - It always returns None, since I have no way to check
# memory usage (libev doesn't have wait4() support)
def get_memory_used(self):
"""Return the memory used by the sandbox.
return (int): memory used by the sandbox (in bytes).
"""
return None
def get_killing_signal(self):
"""Return the signal that killed the sandboxed process.
return (int): offending signal, or 0.
"""
if self.popen.returncode < 0:
return -self.popen.returncode
return 0
# This sandbox only discriminates between processes terminating
# properly or being killed with a signal; all other exceptional
# conditions (RAM or CPU limitations, ...) result in some signal
# being delivered to the process
def get_exit_status(self):
"""Get information about how the sandbox terminated.
return (string): the main reason why the sandbox terminated.
"""
if self.popen.returncode >= 0:
return self.EXIT_OK
else:
return self.EXIT_SIGNAL
def get_exit_code(self):
"""Return the exit code of the sandboxed process.
return (float): exitcode, or 0.
"""
return self.popen.returncode
def get_human_exit_description(self):
"""Get the status of the sandbox and return a human-readable
string describing it.
return (string): human-readable explaination of why the
sandbox terminated.
"""
status = self.get_exit_status()
if status == self.EXIT_OK:
return "Execution successfully finished (with exit code %d)" % \
self.get_exit_code()
elif status == self.EXIT_SIGNAL:
return "Execution killed with signal %s" % \
self.get_killing_signal()
def _popen(self, command,
stdin=None, stdout=None, stderr=None,
preexec_fn=None, close_fds=True):
"""Execute the given command in the sandbox using
subprocess.Popen, assigning the corresponding standard file
descriptors.
command ([string]): executable filename and arguments of the
command.
stdin (file|None): a file descriptor/object or None.
stdout (file|None): a file descriptor/object or None.
stderr (file|None): a file descriptor/object or None.
preexec_fn (function|None): to be called just before execve()
or None.
close_fds (bool): close all file descriptor before executing.
return (object): popen object.
"""
self.exec_time = None
self.exec_num += 1
logger.debug("Executing program in sandbox with command: `%s'.",
" ".join(command))
with open(self.relative_path(self.cmd_file),
'at', encoding="utf-8") as commands:
commands.write("%s\n" % (pretty_print_cmdline(command)))
try:
p = subprocess.Popen(command,
stdin=stdin, stdout=stdout, stderr=stderr,
preexec_fn=preexec_fn, close_fds=close_fds)
except OSError:
logger.critical("Failed to execute program in sandbox "
"with command: `%s'.",
" ".join(command), exc_info=True)
raise
return p
def execute_without_std(self, command, wait=False):
"""Execute the given command in the sandbox using
subprocess.Popen and discarding standard input, output and
error. More specifically, the standard input gets closed just
after the execution has started; standard output and error are
read until the end, in a way that prevents the execution from
being blocked because of insufficient buffering.
command ([string]): executable filename and arguments of the
command.
return (bool): True if the sandbox didn't report errors
(caused by the sandbox itself), False otherwise
"""
def preexec_fn(self):
"""Set limits for the child process.
"""
if self.chdir:
os.chdir(self.chdir)
# TODO - We're not checking that setrlimit() returns
# successfully (they may try to set to higher limits than
# allowed to); anyway, this is just for testing
# environment, not for real contests, so who cares.
if self.timeout:
rlimit_cpu = self.timeout
if self.extra_timeout:
rlimit_cpu += self.extra_timeout
rlimit_cpu = int(rlimit_cpu) + 1
resource.setrlimit(resource.RLIMIT_CPU,
(rlimit_cpu, rlimit_cpu))
if self.address_space:
rlimit_data = self.address_space
resource.setrlimit(resource.RLIMIT_DATA,
(rlimit_data, rlimit_data))
if self.stack_space:
rlimit_stack = self.stack_space
resource.setrlimit(resource.RLIMIT_STACK,
(rlimit_stack, rlimit_stack))
# TODO - Doesn't work as expected
# resource.setrlimit(resource.RLIMIT_NPROC, (1, 1))
# Setup std*** redirection
if self.stdin_file:
stdin_fd = os.open(os.path.join(self._path, self.stdin_file),
os.O_RDONLY)
else:
stdin_fd = subprocess.PIPE
if self.stdout_file:
stdout_fd = os.open(os.path.join(self._path, self.stdout_file),
os.O_WRONLY | os.O_TRUNC | os.O_CREAT,
stat.S_IRUSR | stat.S_IRGRP |
stat.S_IROTH | stat.S_IWUSR)
else:
stdout_fd = subprocess.PIPE
if self.stderr_file:
stderr_fd = os.open(os.path.join(self._path, self.stderr_file),
os.O_WRONLY | os.O_TRUNC | os.O_CREAT,
stat.S_IRUSR | stat.S_IRGRP |
stat.S_IROTH | stat.S_IWUSR)
else:
stderr_fd = subprocess.PIPE
# Note down execution time
self.popen_time = time.monotonic()
# Actually call the Popen
self.popen = self._popen(command,
stdin=stdin_fd,
stdout=stdout_fd,
stderr=stderr_fd,
preexec_fn=partial(preexec_fn, self),
close_fds=True)
# Close file descriptors passed to the child
if self.stdin_file:
os.close(stdin_fd)
if self.stdout_file:
os.close(stdout_fd)
if self.stderr_file:
os.close(stderr_fd)
if self.wallclock_timeout:
# Kill the process after the wall clock time passed
def timed_killer(timeout, popen):
gevent.sleep(timeout)
# TODO - Here we risk to kill some other process that gets
# the same PID in the meantime; I don't know how to
# properly solve this problem
try:
popen.kill()
except OSError:
# The process had died by itself
pass
# Setup the killer
full_wallclock_timeout = self.wallclock_timeout
if self.extra_timeout:
full_wallclock_timeout += self.extra_timeout
gevent.spawn(timed_killer, full_wallclock_timeout, self.popen)
# If the caller wants us to wait for completion, we also avoid
# std*** to interfere with command. Otherwise we let the
# caller handle these issues.
if wait:
return self.translate_box_exitcode(
wait_without_std([self.popen])[0])
else:
return self.popen
def translate_box_exitcode(self, _):
"""Translate the sandbox exit code to a boolean sandbox success.
This sandbox never fails.
"""
return True
def cleanup(self, delete=False):
"""See Sandbox.cleanup()."""
# This sandbox doesn't have any cleanup, but we might want to delete.
if delete:
logger.debug("Deleting sandbox in %s.", self._path)
rmtree(self._path)
class IsolateSandbox(SandboxBase):
"""This class creates, deletes and manages the interaction with a
sandbox. The sandbox doesn't support concurrent operation, not
even for reading.
The Sandbox offers API for retrieving and storing file, as well as
executing programs in a controlled environment. There are anyway a
few files reserved for use by the Sandbox itself:
* commands.log: a text file with the commands ran into this
Sandbox, one for each line;
* run.log.N: for each N, the log produced by the sandbox when running
command number N.
"""
next_id = 0
# If the command line starts with this command name, we are just
# going to execute it without sandboxing, and with all permissions
# on the current directory.
SECURE_COMMANDS = ["/bin/cp", "/bin/mv", "/usr/bin/zip", "/usr/bin/unzip"]
def __init__(self, file_cacher, name=None, temp_dir=None):
"""Initialization.
For arguments documentation, see SandboxBase.__init__.
"""
SandboxBase.__init__(self, file_cacher, name, temp_dir)
# Isolate only accepts ids between 0 and 999 (by default). We assign
# the range [(shard+1)*10, (shard+2)*10) to each Worker and keep the
# range [0, 10) for other uses (command-line scripts like cmsMake or
# direct console users of isolate). Inside each range ids are assigned
# sequentially, with a wrap-around.
# FIXME This is the only use of FileCacher.service, and it's an
# improper use! Avoid it!
if file_cacher is not None and file_cacher.service is not None:
box_id = ((file_cacher.service.shard + 1) * 10
+ (IsolateSandbox.next_id % 10)) % 1000
else:
box_id = IsolateSandbox.next_id % 10
IsolateSandbox.next_id += 1
# We create a directory "home" inside the outer temporary directory,
# that will be bind-mounted to "/tmp" inside the sandbox (some
# compilers need "/tmp" to exist, and this is a cheap shortcut to
# create it). The sandbox also runs code as a different user, and so
# we need to ensure that they can read and write to the directory.
# But we don't want everybody on the system to, which is why the
# outer directory exists with no read permissions.
self._outer_dir = tempfile.mkdtemp(dir=self.temp_dir,
prefix="cms-%s-" % (self.name))
self._home = os.path.join(self._outer_dir, "home")
self._home_dest = "/tmp"
os.mkdir(self._home)
self.allow_writing_all()
self.exec_name = 'isolate'
self.box_exec = self.detect_box_executable()
# Used for -M - the meta file ends up in the outer directory. The
# actual filename will be <info_basename>.<execution_number>.
self.info_basename = os.path.join(self._outer_dir, "run.log")
self.log = None
self.exec_num = -1
self.cmd_file = os.path.join(self._outer_dir, "commands.log")
logger.debug("Sandbox in `%s' created, using box `%s'.",
self._home, self.box_exec)
# Default parameters for isolate
self.box_id = box_id # -b
self.cgroup = config.use_cgroups # --cg
self.chdir = self._home_dest # -c
self.dirs = [] # -d
self.preserve_env = False # -e
self.inherit_env = [] # -E
self.set_env = {} # -E
self.fsize = None # -f
self.stdin_file = None # -i
self.stack_space = None # -k
self.address_space = None # -m
self.stdout_file = None # -o
self.stderr_file = None # -r
self.timeout = None # -t
self.verbosity = 0 # -v
self.wallclock_timeout = None # -w
self.extra_timeout = None # -x
self.add_mapped_directory(
self._home, dest=self._home_dest, options="rw")
# Set common environment variables.
# Specifically needed by Python, that searches the home for
# packages.
self.set_env["HOME"] = self._home_dest
# Needed on Ubuntu by PHP (and more), since /usr/bin only contains a
# symlink to one out of many alternatives.
self.maybe_add_mapped_directory("/etc/alternatives")
# Likewise, needed by C# programs. The Mono runtime looks in
# /etc/mono/config to obtain the default DllMap, which includes, in
# particular, the System.Native assembly.
self.maybe_add_mapped_directory("/etc/mono", options="noexec")
# Tell isolate to get the sandbox ready. We do our best to cleanup
# after ourselves, but we might have missed something if a previous
# worker was interrupted in the middle of an execution, so we issue an
# idempotent cleanup.
self.cleanup()
self.initialize_isolate()
def add_mapped_directory(self, src, dest=None, options=None,
ignore_if_not_existing=False):
"""Add src to the directory to be mapped inside the sandbox.
src (str): directory to make visible.
dest (str|None): if not None, the path where to bind src.
options (str|None): if not None, isolate's directory rule options.
ignore_if_not_existing (bool): if True, ignore the mapping when src
does not exist (instead of having isolate terminate with an
error).
"""
if dest is None:
dest = src
if ignore_if_not_existing and not os.path.exists(src):
return
self.dirs.append((src, dest, options))
def maybe_add_mapped_directory(self, src, dest=None, options=None):
"""Same as add_mapped_directory, with ignore_if_not_existing."""
return self.add_mapped_directory(src, dest, options,
ignore_if_not_existing=True)
def allow_writing_all(self):
"""Set permissions in such a way that any operation is allowed.
"""
os.chmod(self._home, 0o777)
for filename in os.listdir(self._home):
os.chmod(os.path.join(self._home, filename), 0o777)
def allow_writing_none(self):
"""Set permissions in such a way that the user cannot write anything.
"""
os.chmod(self._home, 0o755)
for filename in os.listdir(self._home):
os.chmod(os.path.join(self._home, filename), 0o755)
def allow_writing_only(self, inner_paths):
"""Set permissions in so that the user can write only some paths.
By default the user can only write to the home directory. This
method further restricts permissions so that it can only write
to some files inside the home directory.
inner_paths ([str]): the only paths that the user is allowed to
write to; they should be "inner" paths (from the perspective
of the sandboxed process, not of the host system); they can
be absolute or relative (in which case they are interpreted
relative to the home directory); paths that point to a file
outside the home directory are ignored.
"""
outer_paths = []
for inner_path in inner_paths:
abs_inner_path = \
os.path.realpath(os.path.join(self._home_dest, inner_path))