class IsolateJob < ApplicationJob retry_on RuntimeError, wait: 0.1.seconds, attempts: 100 queue_as ENV["JUDGE0_VERSION"].to_sym STDIN_FILE_NAME = "stdin.txt" STDOUT_FILE_NAME = "stdout.txt" STDERR_FILE_NAME = "stderr.txt" METADATA_FILE_NAME = "metadata.txt" ADDITIONAL_FILES_ARCHIVE_FILE_NAME = "additional_files.zip" attr_reader :submission, :cgroups, :box_id, :workdir, :boxdir, :tmpdir, :source_file, :stdin_file, :stdout_file, :stderr_file, :metadata_file, :additional_files_archive_file def perform(submission_id) @submission = Submission.find(submission_id) submission.update(status: Status.process, started_at: DateTime.now, execution_host: ENV["HOSTNAME"]) time = [] memory = [] submission.number_of_runs.times do initialize_workdir if compile == :failure cleanup return end run verify time << submission.time memory << submission.memory cleanup break if submission.status != Status.ac end submission.time = time.inject(&:+).to_f / time.size submission.memory = memory.inject(&:+).to_f / memory.size submission.save rescue Exception => e raise e.message unless submission submission.update(message: e.message, status: Status.boxerr, finished_at: DateTime.now) cleanup(raise_exception = false) ensure call_callback end private def initialize_workdir @box_id = submission.id%2147483647 @cgroups = (!submission.enable_per_process_and_thread_time_limit || !submission.enable_per_process_and_thread_memory_limit) ? "--cg" : "" @workdir = `isolate #{cgroups} -b #{box_id} --init`.chomp @boxdir = workdir + "/box" @tmpdir = workdir + "/tmp" @source_file = boxdir + "/" + submission.language.source_file.to_s @stdin_file = workdir + "/" + STDIN_FILE_NAME @stdout_file = workdir + "/" + STDOUT_FILE_NAME @stderr_file = workdir + "/" + STDERR_FILE_NAME @metadata_file = workdir + "/" + METADATA_FILE_NAME @additional_files_archive_file = boxdir + "/" + ADDITIONAL_FILES_ARCHIVE_FILE_NAME [stdin_file, stdout_file, stderr_file, metadata_file].each do |f| initialize_file(f) end File.open(source_file, "wb") { |f| f.write(submission.source_code) } unless submission.is_project File.open(stdin_file, "wb") { |f| f.write(submission.stdin) } extract_archive end def initialize_file(file) `sudo touch #{file} && sudo chown $(whoami): #{file}` end def extract_archive return unless submission.additional_files? File.open(additional_files_archive_file, "wb") { |f| f.write(submission.additional_files) } command = "isolate #{cgroups} \ -s \ -b #{box_id} \ --stderr-to-stdout \ -t 2 \ -x 1 \ -w 4 \ -k #{Config::MAX_STACK_LIMIT} \ -p#{Config::MAX_MAX_PROCESSES_AND_OR_THREADS} \ #{submission.enable_per_process_and_thread_time_limit ? (cgroups.present? ? "--no-cg-timing" : "") : "--cg-timing"} \ #{submission.enable_per_process_and_thread_memory_limit ? "-m " : "--cg-mem="}#{Config::MAX_MEMORY_LIMIT} \ -f #{Config::MAX_EXTRACT_SIZE} \ --run \ -- /usr/bin/unzip -n -qq #{ADDITIONAL_FILES_ARCHIVE_FILE_NAME} \ " puts "[#{DateTime.now}] Extracting archive for submission #{submission.token} (#{submission.id}):" puts command.gsub(/\s+/, " ") puts `#{command}` File.delete(additional_files_archive_file) end def compile unless submission.is_project return :success unless submission.language.compile_cmd end compile_script = boxdir + "/" + "compile.sh" acceptable_project_compile_scripts = [compile_script, boxdir + "/" + "compile"] if submission.is_project compile_file_exists = false acceptable_project_compile_scripts.each do |f| if File.file?(f) compile_script = f compile_file_exists = true break end end unless compile_file_exists return :success # If compile script does not exist then this project does not need to be compiled. end else # gsub can be skipped if compile script is used, but is kept for additional security. compiler_options = submission.compiler_options.to_s.strip.encode("UTF-8", invalid: :replace).gsub(/[$&;<>|`]/, "") File.open(compile_script, "w") { |f| f.write("#{submission.language.compile_cmd % compiler_options}") } end compile_output_file = workdir + "/" + "compile_output.txt" initialize_file(compile_output_file) command = "isolate #{cgroups} \ -s \ -b #{box_id} \ -M #{metadata_file} \ --stderr-to-stdout \ -i /dev/null \ -t #{Config::MAX_CPU_TIME_LIMIT} \ -x 0 \ -w #{Config::MAX_WALL_TIME_LIMIT} \ -k #{Config::MAX_STACK_LIMIT} \ -p#{Config::MAX_MAX_PROCESSES_AND_OR_THREADS} \ #{submission.enable_per_process_and_thread_time_limit ? (cgroups.present? ? "--no-cg-timing" : "") : "--cg-timing"} \ #{submission.enable_per_process_and_thread_memory_limit ? "-m " : "--cg-mem="}#{Config::MAX_MEMORY_LIMIT} \ -f #{Config::MAX_MAX_FILE_SIZE} \ -E HOME=/tmp \ -E PATH=\"/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin\" \ -E LANG -E LANGUAGE -E LC_ALL -E JUDGE0_HOMEPAGE -E JUDGE0_SOURCE_CODE -E JUDGE0_MAINTAINER -E JUDGE0_VERSION \ -d /etc:noexec \ --run \ -- /bin/bash $(basename #{compile_script}) > #{compile_output_file} \ " puts "[#{DateTime.now}] Compiling submission #{submission.token} (#{submission.id}):" puts command.gsub(/\s+/, " ") puts `#{command}` process_status = $? compile_output = File.read(compile_output_file) compile_output = nil if compile_output.empty? submission.compile_output = compile_output metadata = get_metadata reset_metadata_file files_to_remove = [compile_output_file] files_to_remove << compile_script unless submission.is_project files_to_remove.each do |f| `sudo rm -rf #{f}` end return :success if process_status.success? if metadata[:status] == "TO" submission.compile_output = "Compilation time limit exceeded." end submission.finished_at = DateTime.now submission.time = nil submission.wall_time = nil submission.memory = nil submission.stdout = nil submission.stderr = nil submission.exit_code = nil submission.exit_signal = nil submission.message = nil submission.status = Status.ce submission.save return :failure end def run run_script = boxdir + "/" + "run.sh" acceptable_project_run_scripts = [run_script, boxdir + "/" + "run"] acceptable_project_run_scripts.each do |f| if File.file?(f) run_script = f break end end unless submission.is_project # gsub is mandatory! command_line_arguments = submission.command_line_arguments.to_s.strip.encode("UTF-8", invalid: :replace).gsub(/[$&;<>|`]/, "") File.open(run_script, "w") { |f| f.write("#{submission.language.run_cmd} #{command_line_arguments}")} end command = "isolate #{cgroups} \ -s \ -b #{box_id} \ -M #{metadata_file} \ #{submission.redirect_stderr_to_stdout ? "--stderr-to-stdout" : ""} \ #{submission.enable_network ? "--share-net" : ""} \ -t #{submission.cpu_time_limit} \ -x #{submission.cpu_extra_time} \ -w #{submission.wall_time_limit} \ -k #{submission.stack_limit} \ -p#{submission.max_processes_and_or_threads} \ #{submission.enable_per_process_and_thread_time_limit ? (cgroups.present? ? "--no-cg-timing" : "") : "--cg-timing"} \ #{submission.enable_per_process_and_thread_memory_limit ? "-m " : "--cg-mem="}#{submission.memory_limit} \ -f #{submission.max_file_size} \ -E HOME=/tmp \ -E PATH=\"/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin\" \ -E LANG -E LANGUAGE -E LC_ALL -E JUDGE0_HOMEPAGE -E JUDGE0_SOURCE_CODE -E JUDGE0_MAINTAINER -E JUDGE0_VERSION \ -d /etc:noexec \ --run \ -- /bin/bash $(basename #{run_script}) \ < #{stdin_file} > #{stdout_file} 2> #{stderr_file} \ " puts "[#{DateTime.now}] Running submission #{submission.token} (#{submission.id}):" puts command.gsub(/\s+/, " ") puts `#{command}` `sudo rm #{run_script}` unless submission.is_project end def verify submission.finished_at = DateTime.now metadata = get_metadata program_stdout = File.read(stdout_file) program_stdout = nil if program_stdout.empty? program_stderr = File.read(stderr_file) program_stderr = nil if program_stderr.empty? submission.time = metadata[:time] submission.wall_time = metadata[:"time-wall"] submission.memory = (cgroups.present? ? metadata[:"cg-mem"] : metadata[:"max-rss"]) submission.stdout = program_stdout submission.stderr = program_stderr submission.exit_code = metadata[:exitcode].try(:to_i) || 0 submission.exit_signal = metadata[:exitsig].try(:to_i) submission.message = metadata[:message] submission.status = determine_status(metadata[:status], submission.exit_signal) # After adding support for compiler_options and command_line_arguments # status "Exec Format Error" will no longer occur because compile and run # is done inside a dynamically created bash script, thus isolate doesn't call # execve directily on submission.language.compile_cmd or submission.langauge.run_cmd. # Consequence of running compile and run through bash script is that when # target binary is not found then submission gets status "Runtime Error (NZEC)". # # I think this is for now O.K. behaviour, but I will leave this if block # here until I am 100% sure that "Exec Format Error" can be deprecated. if submission.status == Status.boxerr && ( submission.message.to_s.match(/^execve\(.+\): Exec format error$/) || submission.message.to_s.match(/^execve\(.+\): No such file or directory$/) || submission.message.to_s.match(/^execve\(.+\): Permission denied$/) ) submission.status = Status.exeerr end end def cleanup(raise_exception = true) fix_permissions `sudo rm -rf #{boxdir}/* #{tmpdir}/*` [stdin_file, stdout_file, stderr_file, metadata_file].each do |f| `sudo rm -rf #{f}` end `isolate #{cgroups} -b #{box_id} --cleanup` raise "Cleanup of sandbox #{box_id} failed." if raise_exception && Dir.exists?(workdir) end def reset_metadata_file `sudo rm -rf #{metadata_file}` initialize_file(metadata_file) end def fix_permissions `sudo chown -R $(whoami): #{boxdir}` end def call_callback return unless submission.callback_url.present? serialized_submission = ActiveModelSerializers::SerializableResource.new( submission, { serializer: SubmissionSerializer, base64_encoded: true, fields: SubmissionSerializer.default_fields } ).to_json Config::CALLBACKS_MAX_TRIES.times do begin response = HTTParty.put( submission.callback_url, body: serialized_submission, headers: { "Content-Type" => "application/json" }, timeout: Config::CALLBACKS_TIMEOUT ) break rescue Exception => e end end rescue Exception => e end def get_metadata metadata = File.read(metadata_file).split("\n").collect do |e| { e.split(":").first.to_sym => e.split(":")[1..-1].join(":") } end.reduce({}, :merge) return metadata end def determine_status(status, exit_signal) if status == "TO" return Status.tle elsif status == "SG" return Status.find_runtime_error_by_status_code(exit_signal) elsif status == "RE" return Status.nzec elsif status == "XX" return Status.boxerr elsif submission.expected_output.nil? || strip(submission.expected_output) == strip(submission.stdout) return Status.ac else return Status.wa end end def strip(text) return nil unless text text.split("\n").collect(&:rstrip).join("\n").rstrip rescue ArgumentError return text end end