diff --git a/Dockerfile b/Dockerfile index b1689ad4..b56a3e05 100644 --- a/Dockerfile +++ b/Dockerfile @@ -13,6 +13,8 @@ WORKDIR /var/www/html RUN apk add --no-cache \ git \ curl \ + wget \ + ca-certificates \ libpng-dev \ libjpeg-turbo-dev \ freetype-dev \ @@ -26,7 +28,69 @@ RUN apk add --no-cache \ linux-headers \ $PHPIZE_DEPS \ # Supervisor for queue workers - supervisor + supervisor \ + # Compilers for autojudge -- this list must cover every language + # Language::getDefaultLanguages() marks is_active by default, or admins + # can select a language in the Contest Wizard that the judge then can't + # actually compile/run. + gcc \ + g++ \ + make \ + python3 \ + openjdk21-jdk \ + nodejs \ + npm \ + dotnet8-sdk \ + go \ + rust \ + ruby \ + # AutoJudgeService::runCustomScript()/runCompareScript() invoke problem + # compile/run/compare scripts via `bash`, which Alpine doesn't ship by default + bash + +# TypeScript (npx tsc, used by the "TypeScript (Node 24)" language) +RUN npm install -g typescript + +# Kotlin has no Alpine package; JetBrains only ships it as a GitHub release +# zip. This is the same install method used by every other Kotlin judge +# container (it's pure JVM + a launcher script, so it's arch-independent +# given the JDK above). +ARG KOTLIN_VERSION=2.4.20 +RUN wget -q "https://github.com/JetBrains/kotlin/releases/download/v${KOTLIN_VERSION}/kotlin-compiler-${KOTLIN_VERSION}.zip" -O /tmp/kotlin.zip \ + && unzip -q /tmp/kotlin.zip -d /opt \ + && rm /tmp/kotlin.zip \ + && ln -s /opt/kotlinc/bin/kotlinc /usr/local/bin/kotlinc \ + && ln -s /opt/kotlinc/bin/kotlin /usr/local/bin/kotlin + +# Free Pascal has no Alpine package either. Its official releases ship a +# pre-built compiler binary per architecture (no compilation needed); we +# only need the compiler + RTL units, not the full units-* extras or docs. +# The SourceForge download occasionally serves a truncated file under +# automated tools, hence the retry loop with byte-count verification. +ARG FPC_VERSION=3.2.2 +RUN set -eu; \ + arch="$(apk --print-arch)"; \ + case "$arch" in \ + x86_64) fpc_arch=x86_64-linux; fpc_bin=ppcx64 ;; \ + aarch64) fpc_arch=aarch64-linux; fpc_bin=ppca64 ;; \ + *) echo "Unsupported arch for FPC: $arch" >&2; exit 1 ;; \ + esac; \ + url="https://sourceforge.net/projects/freepascal/files/Linux/${FPC_VERSION}/fpc-${FPC_VERSION}.${fpc_arch}.tar/download"; \ + tries=0; \ + until wget -q -c "$url" -O /tmp/fpc.tar && tar -tf /tmp/fpc.tar >/dev/null 2>&1; do \ + tries=$((tries + 1)); \ + [ "$tries" -ge 6 ] && { echo "Failed to download a valid FPC tarball after $tries attempts" >&2; exit 1; }; \ + rm -f /tmp/fpc.tar; \ + done; \ + mkdir -p /tmp/fpcx && tar -xf /tmp/fpc.tar -C /tmp/fpcx; \ + cd "/tmp/fpcx/fpc-${FPC_VERSION}.${fpc_arch}"; \ + tar -xf "binary.${fpc_arch}.tar" "base.${fpc_arch}.tar.gz"; \ + mkdir -p "/opt/fpc/${FPC_VERSION}"; \ + tar -xzf "base.${fpc_arch}.tar.gz" -C "/opt/fpc/${FPC_VERSION}"; \ + install -m 755 "/opt/fpc/${FPC_VERSION}/lib/fpc/${FPC_VERSION}/${fpc_bin}" /usr/local/bin/fpc; \ + printf -- '-Fu/opt/fpc/%s/lib/fpc/%s/units/%s/rtl\n-Fl/opt/fpc/%s/lib/fpc/%s/units/%s/rtl\n' \ + "$FPC_VERSION" "$FPC_VERSION" "$fpc_arch" "$FPC_VERSION" "$FPC_VERSION" "$fpc_arch" > /etc/fpc.cfg; \ + rm -rf /tmp/fpc.tar /tmp/fpcx # Configure and install PHP extensions RUN docker-php-ext-configure gd --with-freetype --with-jpeg \ diff --git a/Dockerfile.dev b/Dockerfile.dev index 01736a1f..0cdc6605 100644 --- a/Dockerfile.dev +++ b/Dockerfile.dev @@ -12,6 +12,8 @@ WORKDIR /var/www/html RUN apk add --no-cache \ git \ curl \ + wget \ + ca-certificates \ libpng-dev \ libjpeg-turbo-dev \ freetype-dev \ @@ -24,15 +26,69 @@ RUN apk add --no-cache \ postgresql-dev \ linux-headers \ $PHPIZE_DEPS \ - # Compilers for autojudge + # Compilers for autojudge -- this list must cover every language + # Language::getDefaultLanguages() marks is_active by default, or admins + # can select a language in the Contest Wizard that the judge then can't + # actually compile/run. gcc \ g++ \ make \ python3 \ + openjdk21-jdk \ + nodejs \ + npm \ + dotnet8-sdk \ + go \ + rust \ + ruby \ # AutoJudgeService::runCustomScript()/runCompareScript() invoke problem # compile/run/compare scripts via `bash`, which Alpine doesn't ship by default bash +# TypeScript (npx tsc, used by the "TypeScript (Node 24)" language) +RUN npm install -g typescript + +# Kotlin has no Alpine package; JetBrains only ships it as a GitHub release +# zip. This is the same install method used by every other Kotlin judge +# container (it's pure JVM + a launcher script, so it's arch-independent +# given the JDK above). +ARG KOTLIN_VERSION=2.4.20 +RUN wget -q "https://github.com/JetBrains/kotlin/releases/download/v${KOTLIN_VERSION}/kotlin-compiler-${KOTLIN_VERSION}.zip" -O /tmp/kotlin.zip \ + && unzip -q /tmp/kotlin.zip -d /opt \ + && rm /tmp/kotlin.zip \ + && ln -s /opt/kotlinc/bin/kotlinc /usr/local/bin/kotlinc \ + && ln -s /opt/kotlinc/bin/kotlin /usr/local/bin/kotlin + +# Free Pascal has no Alpine package either. Its official releases ship a +# pre-built compiler binary per architecture (no compilation needed); we +# only need the compiler + RTL units, not the full units-* extras or docs. +# The SourceForge download occasionally serves a truncated file under +# automated tools, hence the retry loop with byte-count verification. +ARG FPC_VERSION=3.2.2 +RUN set -eu; \ + arch="$(apk --print-arch)"; \ + case "$arch" in \ + x86_64) fpc_arch=x86_64-linux; fpc_bin=ppcx64 ;; \ + aarch64) fpc_arch=aarch64-linux; fpc_bin=ppca64 ;; \ + *) echo "Unsupported arch for FPC: $arch" >&2; exit 1 ;; \ + esac; \ + url="https://sourceforge.net/projects/freepascal/files/Linux/${FPC_VERSION}/fpc-${FPC_VERSION}.${fpc_arch}.tar/download"; \ + tries=0; \ + until wget -q -c "$url" -O /tmp/fpc.tar && tar -tf /tmp/fpc.tar >/dev/null 2>&1; do \ + tries=$((tries + 1)); \ + [ "$tries" -ge 6 ] && { echo "Failed to download a valid FPC tarball after $tries attempts" >&2; exit 1; }; \ + rm -f /tmp/fpc.tar; \ + done; \ + mkdir -p /tmp/fpcx && tar -xf /tmp/fpc.tar -C /tmp/fpcx; \ + cd "/tmp/fpcx/fpc-${FPC_VERSION}.${fpc_arch}"; \ + tar -xf "binary.${fpc_arch}.tar" "base.${fpc_arch}.tar.gz"; \ + mkdir -p "/opt/fpc/${FPC_VERSION}"; \ + tar -xzf "base.${fpc_arch}.tar.gz" -C "/opt/fpc/${FPC_VERSION}"; \ + install -m 755 "/opt/fpc/${FPC_VERSION}/lib/fpc/${FPC_VERSION}/${fpc_bin}" /usr/local/bin/fpc; \ + printf -- '-Fu/opt/fpc/%s/lib/fpc/%s/units/%s/rtl\n-Fl/opt/fpc/%s/lib/fpc/%s/units/%s/rtl\n' \ + "$FPC_VERSION" "$FPC_VERSION" "$fpc_arch" "$FPC_VERSION" "$FPC_VERSION" "$fpc_arch" > /etc/fpc.cfg; \ + rm -rf /tmp/fpc.tar /tmp/fpcx + # Configure and install PHP extensions RUN docker-php-ext-configure gd --with-freetype --with-jpeg \ && docker-php-ext-install -j$(nproc) \ diff --git a/Dockerfile.judge b/Dockerfile.judge index 37b4da27..471f31b6 100644 --- a/Dockerfile.judge +++ b/Dockerfile.judge @@ -8,11 +8,16 @@ LABEL description="MicroHelium Auto-Judge Worker" WORKDIR /var/www/html -# Install system dependencies for compilation +# Install system dependencies for compilation -- this list must cover every +# language Language::getDefaultLanguages() marks is_active (see +# Dockerfile/Dockerfile.dev, which install the identical set for the +# queue-worker judging path; this image is the standalone autojudge:start +# worker's, and must not silently drift out of sync with it). RUN apk add --no-cache \ git \ curl \ wget \ + ca-certificates \ libpng-dev \ libjpeg-turbo-dev \ freetype-dev \ @@ -33,15 +38,20 @@ RUN apk add --no-cache \ libc-dev \ libstdc++ \ # Java (for Java, Kotlin) - openjdk17-jdk \ + openjdk21-jdk \ # Python 3 python3 \ py3-pip \ + # Node.js / TypeScript -- a single real runtime, not nvm: see + # Dockerfile.dev's comment on why multiple Node majors aren't installed + nodejs \ + npm \ + # .NET / C# + dotnet8-sdk \ # Go go \ # Rust rust \ - cargo \ # Ruby ruby \ # Process management @@ -50,53 +60,48 @@ RUN apk add --no-cache \ coreutils \ # Memory limiting util-linux \ - # Bash for scripts and nvm - bash \ - # Required for nvm - shadow - -# Install nvm (Node Version Manager) and multiple Node.js versions -ENV NVM_DIR=/opt/nvm -ENV NODE_VERSION_DEFAULT=20 -ENV NODE_VERSIONS="18 20 22" - -RUN mkdir -p $NVM_DIR && \ - curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.1/install.sh | bash && \ - . $NVM_DIR/nvm.sh && \ - # Install multiple Node.js versions - for version in $NODE_VERSIONS; do \ - nvm install $version; \ - done && \ - # Set default version - nvm alias default $NODE_VERSION_DEFAULT && \ - nvm use default && \ - # Install TypeScript globally for default version - npm install -g typescript && \ - # Create wrapper scripts for node and npm - ln -sf $NVM_DIR/versions/node/v$(nvm version default | tr -d 'v')/bin/node /usr/local/bin/node && \ - ln -sf $NVM_DIR/versions/node/v$(nvm version default | tr -d 'v')/bin/npm /usr/local/bin/npm && \ - ln -sf $NVM_DIR/versions/node/v$(nvm version default | tr -d 'v')/bin/npx /usr/local/bin/npx && \ - ln -sf $NVM_DIR/versions/node/v$(nvm version default | tr -d 'v')/bin/tsc /usr/local/bin/tsc - -# Create nvm helper script for runtime version switching -RUN echo '#!/bin/bash\n\ -source $NVM_DIR/nvm.sh\n\ -nvm use $1 > /dev/null 2>&1\n\ -shift\n\ -exec node "$@"' > /usr/local/bin/node-version && \ - chmod +x /usr/local/bin/node-version - -# nvm environment setup -ENV PATH=$NVM_DIR/versions/node/v20/bin:$PATH - -# Install Kotlin compiler -ENV KOTLIN_VERSION=1.9.22 -RUN cd /tmp && \ - wget -q "https://github.com/JetBrains/kotlin/releases/download/v${KOTLIN_VERSION}/kotlin-compiler-${KOTLIN_VERSION}.zip" && \ - unzip -q kotlin-compiler-${KOTLIN_VERSION}.zip -d /opt && \ - rm kotlin-compiler-${KOTLIN_VERSION}.zip && \ - ln -s /opt/kotlinc/bin/kotlin /usr/local/bin/kotlin && \ - ln -s /opt/kotlinc/bin/kotlinc /usr/local/bin/kotlinc + # Bash for scripts + bash + +RUN npm install -g typescript + +# Kotlin has no Alpine package; JetBrains only ships it as a GitHub release +# zip (pure JVM + a launcher script, arch-independent given the JDK above). +ARG KOTLIN_VERSION=2.4.20 +RUN wget -q "https://github.com/JetBrains/kotlin/releases/download/v${KOTLIN_VERSION}/kotlin-compiler-${KOTLIN_VERSION}.zip" -O /tmp/kotlin.zip \ + && unzip -q /tmp/kotlin.zip -d /opt \ + && rm /tmp/kotlin.zip \ + && ln -s /opt/kotlinc/bin/kotlinc /usr/local/bin/kotlinc \ + && ln -s /opt/kotlinc/bin/kotlin /usr/local/bin/kotlin + +# Free Pascal has no Alpine package either; installed from the official +# per-architecture SourceForge binary tarball. See Dockerfile.dev for the +# detailed rationale (retry loop: the SourceForge download occasionally +# serves a truncated file under automated tools). +ARG FPC_VERSION=3.2.2 +RUN set -eu; \ + arch="$(apk --print-arch)"; \ + case "$arch" in \ + x86_64) fpc_arch=x86_64-linux; fpc_bin=ppcx64 ;; \ + aarch64) fpc_arch=aarch64-linux; fpc_bin=ppca64 ;; \ + *) echo "Unsupported arch for FPC: $arch" >&2; exit 1 ;; \ + esac; \ + url="https://sourceforge.net/projects/freepascal/files/Linux/${FPC_VERSION}/fpc-${FPC_VERSION}.${fpc_arch}.tar/download"; \ + tries=0; \ + until wget -q -c "$url" -O /tmp/fpc.tar && tar -tf /tmp/fpc.tar >/dev/null 2>&1; do \ + tries=$((tries + 1)); \ + [ "$tries" -ge 6 ] && { echo "Failed to download a valid FPC tarball after $tries attempts" >&2; exit 1; }; \ + rm -f /tmp/fpc.tar; \ + done; \ + mkdir -p /tmp/fpcx && tar -xf /tmp/fpc.tar -C /tmp/fpcx; \ + cd "/tmp/fpcx/fpc-${FPC_VERSION}.${fpc_arch}"; \ + tar -xf "binary.${fpc_arch}.tar" "base.${fpc_arch}.tar.gz"; \ + mkdir -p "/opt/fpc/${FPC_VERSION}"; \ + tar -xzf "base.${fpc_arch}.tar.gz" -C "/opt/fpc/${FPC_VERSION}"; \ + install -m 755 "/opt/fpc/${FPC_VERSION}/lib/fpc/${FPC_VERSION}/${fpc_bin}" /usr/local/bin/fpc; \ + printf -- '-Fu/opt/fpc/%s/lib/fpc/%s/units/%s/rtl\n-Fl/opt/fpc/%s/lib/fpc/%s/units/%s/rtl\n' \ + "$FPC_VERSION" "$FPC_VERSION" "$fpc_arch" "$FPC_VERSION" "$FPC_VERSION" "$fpc_arch" > /etc/fpc.cfg; \ + rm -rf /tmp/fpc.tar /tmp/fpcx # Set Go environment ENV GOPATH=/go diff --git a/app/Models/Language.php b/app/Models/Language.php index 59488520..1407fc92 100644 --- a/app/Models/Language.php +++ b/app/Models/Language.php @@ -57,23 +57,37 @@ public static function getDefaultLanguages(): array // Python ['name' => 'Python 3.12', 'extension' => 'py3', 'file_ext' => 'py', 'compile_command' => 'python3 -m py_compile {source}', 'run_command' => 'python3 {source}', 'is_active' => true, 'category' => 'interpreted'], - ['name' => 'Python 3 (PyPy)', 'extension' => 'pypy3', 'file_ext' => 'py', 'compile_command' => 'pypy3 -m py_compile {source}', 'run_command' => 'pypy3 {source}', 'is_active' => true, 'category' => 'interpreted'], + // PyPy has no musl/Alpine build (upstream only ships glibc + // binaries), so it's left inactive rather than silently failing + // every submission; CPython 3.12 above covers the language. + ['name' => 'Python 3 (PyPy)', 'extension' => 'pypy3', 'file_ext' => 'py', 'compile_command' => 'pypy3 -m py_compile {source}', 'run_command' => 'pypy3 {source}', 'is_active' => false, 'category' => 'interpreted'], ['name' => 'Python 2.7', 'extension' => 'py2', 'file_ext' => 'py', 'compile_command' => 'python2 -m py_compile {source}', 'run_command' => 'python2 {source}', 'is_active' => false, 'category' => 'interpreted'], - // JavaScript / Node.js (using NVM) - ['name' => 'JavaScript (Node 24)', 'extension' => 'js_node24', 'file_ext' => 'js', 'compile_command' => 'node --check {source}', 'run_command' => 'source ~/.nvm/nvm.sh && nvm use 24 && node {source}', 'is_active' => true, 'category' => 'interpreted'], - ['name' => 'JavaScript (Node 22)', 'extension' => 'js_node22', 'file_ext' => 'js', 'compile_command' => 'node --check {source}', 'run_command' => 'source ~/.nvm/nvm.sh && nvm use 22 && node {source}', 'is_active' => true, 'category' => 'interpreted'], - ['name' => 'JavaScript (Node 20 LTS)', 'extension' => 'js_node20', 'file_ext' => 'js', 'compile_command' => 'node --check {source}', 'run_command' => 'source ~/.nvm/nvm.sh && nvm use 20 && node {source}', 'is_active' => true, 'category' => 'interpreted'], - ['name' => 'TypeScript (Node 22)', 'extension' => 'ts', 'file_ext' => 'ts', 'compile_command' => 'npx tsc --strict --outFile {output}.js {source}', 'run_command' => 'source ~/.nvm/nvm.sh && nvm use 22 && node {executable}.js', 'is_active' => true, 'category' => 'compiled'], + // JavaScript / Node.js -- the container only installs a single + // Node runtime (the current LTS, via Alpine's `nodejs` package), + // so only one Node entry is real; the others are kept in the + // catalog for a future multi-version (nvm-based) setup but left + // inactive since selecting them today would silently fail. + ['name' => 'JavaScript (Node 24 LTS)', 'extension' => 'js_node24', 'file_ext' => 'js', 'compile_command' => 'node --check {source}', 'run_command' => 'node {source}', 'is_active' => true, 'category' => 'interpreted'], + ['name' => 'JavaScript (Node 22)', 'extension' => 'js_node22', 'file_ext' => 'js', 'compile_command' => 'node --check {source}', 'run_command' => 'node {source}', 'is_active' => false, 'category' => 'interpreted'], + ['name' => 'JavaScript (Node 20 LTS)', 'extension' => 'js_node20', 'file_ext' => 'js', 'compile_command' => 'node --check {source}', 'run_command' => 'node {source}', 'is_active' => false, 'category' => 'interpreted'], + ['name' => 'TypeScript (Node 24)', 'extension' => 'ts', 'file_ext' => 'ts', 'compile_command' => 'npx tsc --strict --module commonjs {source}', 'run_command' => 'node {executable}.js', 'is_active' => true, 'category' => 'compiled'], // JVM Languages - ['name' => 'Kotlin (1.9)', 'extension' => 'kt', 'file_ext' => 'kt', 'compile_command' => 'kotlinc {source} -include-runtime -d {output}.jar', 'run_command' => 'java -jar {executable}.jar', 'is_active' => true, 'category' => 'compiled'], + ['name' => 'Kotlin (2.4)', 'extension' => 'kt', 'file_ext' => 'kt', 'compile_command' => 'kotlinc {source} -include-runtime -d {output}.jar', 'run_command' => 'java -jar {executable}.jar', 'is_active' => true, 'category' => 'compiled'], ['name' => 'Scala 3', 'extension' => 'scala', 'file_ext' => 'scala', 'compile_command' => 'scalac {source}', 'run_command' => 'scala {classname}', 'is_active' => false, 'category' => 'compiled'], ['name' => 'Groovy 4', 'extension' => 'groovy', 'file_ext' => 'groovy', 'compile_command' => 'groovyc {source}', 'run_command' => 'groovy {source}', 'is_active' => false, 'category' => 'interpreted'], ['name' => 'Clojure', 'extension' => 'clj', 'file_ext' => 'clj', 'compile_command' => 'clojure -M --main clojure.main --eval "(compile \'main)"', 'run_command' => 'clojure {source}', 'is_active' => false, 'category' => 'interpreted'], // .NET Languages - ['name' => 'C# (.NET 8)', 'extension' => 'cs_dotnet', 'file_ext' => 'cs', 'compile_command' => 'dotnet build', 'run_command' => 'dotnet run', 'is_active' => true, 'category' => 'compiled'], + // `dotnet build`/`dotnet run` need a project, not a bare .cs + // file, so C# uses wrapper scripts that scaffold a throwaway + // console project around the submitted source before building + // it (see resources/judge-runtime/csharp/). {judge_runtime} is + // resolved by AutoJudgeService, not here -- this array must stay + // callable from contexts where the app isn't booted yet (e.g. + // PHPUnit data providers), where base_path() isn't available. + ['name' => 'C# (.NET 8)', 'extension' => 'cs_dotnet', 'file_ext' => 'cs', 'compile_command' => 'bash {judge_runtime}/csharp/compile.sh {source} {output}', 'run_command' => 'bash {judge_runtime}/csharp/run.sh {executable}', 'is_active' => true, 'category' => 'compiled'], ['name' => 'C# (Mono)', 'extension' => 'cs_mono', 'file_ext' => 'cs', 'compile_command' => 'mcs -out:{output}.exe {source}', 'run_command' => 'mono {executable}.exe', 'is_active' => false, 'category' => 'compiled'], ['name' => 'F# (.NET 8)', 'extension' => 'fs_dotnet', 'file_ext' => 'fs', 'compile_command' => 'dotnet build', 'run_command' => 'dotnet run', 'is_active' => false, 'category' => 'compiled'], ['name' => 'Visual Basic (.NET 8)', 'extension' => 'vb', 'file_ext' => 'vb', 'compile_command' => 'dotnet build', 'run_command' => 'dotnet run', 'is_active' => false, 'category' => 'compiled'], diff --git a/app/Services/AutoJudgeService.php b/app/Services/AutoJudgeService.php index d1b1802a..1ce6b7cc 100644 --- a/app/Services/AutoJudgeService.php +++ b/app/Services/AutoJudgeService.php @@ -117,8 +117,14 @@ protected function compile(Run $run, string $runDir): array // Use default compile command $compileCommand = $this->buildCompileCommand($language, $runDir, $run->filename); + // npm/npx (TypeScript), dotnet and go all write caches under $HOME; + // PHP-FPM doesn't set HOME for the worker user, so without this + // they'd try to write to a HOME they can't access (or none at all) + // and fail with a permission/lookup error unrelated to the + // submitted code. $runDir is already writable and unique per run. $result = Process::timeout($this->defaultTimeLimit * 2) ->path($runDir) + ->env(['HOME' => $runDir]) ->run($compileCommand); return [ @@ -134,6 +140,7 @@ protected function buildCompileCommand(object $language, string $runDir, string $command = $language->compile_command; $basename = pathinfo($filename, PATHINFO_FILENAME); + $command = str_replace('{judge_runtime}', base_path('resources/judge-runtime'), $command); $command = str_replace('{source}', $filename, $command); $command = str_replace('{output}', $basename, $command); $command = str_replace('{basename}', $basename, $command); @@ -187,6 +194,7 @@ protected function executeProgram(Run $run, string $runDir, string $inputFile, s $command = "bash {$runScript} {$basename} {$inputFile} {$timeLimit} 1 {$memoryLimit} 1024"; } else { $runCommand = $language->run_command; + $runCommand = str_replace('{judge_runtime}', base_path('resources/judge-runtime'), $runCommand); $runCommand = str_replace('{executable}', $basename, $runCommand); $runCommand = str_replace('{classname}', $basename, $runCommand); $runCommand = str_replace('{source}', $run->filename, $runCommand); @@ -202,6 +210,7 @@ protected function executeProgram(Run $run, string $runDir, string $inputFile, s $result = Process::timeout($timeLimit + 5) ->path($runDir) + ->env(['HOME' => $runDir]) ->run($command); $exitCode = $result->exitCode(); diff --git a/resources/judge-runtime/csharp/compile.sh b/resources/judge-runtime/csharp/compile.sh new file mode 100755 index 00000000..2b77632f --- /dev/null +++ b/resources/judge-runtime/csharp/compile.sh @@ -0,0 +1,16 @@ +#!/bin/bash +# C# submissions are a single .cs file, but `dotnet build` requires a +# project. This wraps the submitted source in a throwaway console project +# and builds it. Invoked as: compile.sh +# from within the run's working directory (AutoJudgeService::compile()). +set -e + +SOURCE="$1" +OUTPUT="$2" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +mkdir -p proj +cp "$SCRIPT_DIR/proj.csproj.template" proj/proj.csproj +cp "$SOURCE" proj/Program.cs + +dotnet build proj -c Release -o "${OUTPUT}_bin" diff --git a/resources/judge-runtime/csharp/proj.csproj.template b/resources/judge-runtime/csharp/proj.csproj.template new file mode 100644 index 00000000..cc1e7761 --- /dev/null +++ b/resources/judge-runtime/csharp/proj.csproj.template @@ -0,0 +1,9 @@ + + + Exe + net8.0 + enable + enable + true + + diff --git a/resources/judge-runtime/csharp/run.sh b/resources/judge-runtime/csharp/run.sh new file mode 100755 index 00000000..60b1f3ba --- /dev/null +++ b/resources/judge-runtime/csharp/run.sh @@ -0,0 +1,6 @@ +#!/bin/bash +# Runs a submission built by compile.sh. Invoked as: run.sh +set -e + +OUTPUT="$1" +exec dotnet "${OUTPUT}_bin/proj.dll" diff --git a/tests/E2E/MultiLanguageJudgingTest.php b/tests/E2E/MultiLanguageJudgingTest.php new file mode 100644 index 00000000..70557a61 --- /dev/null +++ b/tests/E2E/MultiLanguageJudgingTest.php @@ -0,0 +1,154 @@ + ['file' => 'solution.c', 'source' => "#include \nint main(){int a,b;scanf(\"%d %d\",&a,&b);printf(\"%d\\n\",a+b);return 0;}\n"], + 'cpp_gpp13' => ['file' => 'solution.cpp', 'source' => "#include \nint main(){int a,b;std::cin>>a>>b;std::cout< ['file' => 'solution.cpp', 'source' => "#include \nint main(){int a,b;std::cin>>a>>b;std::cout< ['file' => 'Main.java', 'source' => "import java.util.Scanner;\npublic class Main {\n public static void main(String[] args) {\n Scanner sc = new Scanner(System.in);\n System.out.println(sc.nextInt() + sc.nextInt());\n }\n}\n"], + 'py3' => ['file' => 'solution.py', 'source' => "a, b = map(int, input().split())\nprint(a + b)\n"], + 'js_node24' => ['file' => 'solution.js', 'source' => "const data = require('fs').readFileSync(0, 'utf8').trim().split(/\\s+/).map(Number);\nconsole.log(data[0] + data[1]);\n"], + // `declare function require` avoids needing @types/node (not + // installed globally) just to read stdin in a strict-mode file. + 'ts' => ['file' => 'solution.ts', 'source' => "declare function require(name: string): any;\nconst data: number[] = require('fs').readFileSync(0, 'utf8').trim().split(/\\s+/).map(Number);\nconsole.log(data[0] + data[1]);\n"], + 'kt' => ['file' => 'solution.kt', 'source' => "fun main() {\n val (a, b) = readLine()!!.trim().split(\" \").map { it.toInt() }\n println(a + b)\n}\n"], + 'cs_dotnet' => ['file' => 'solution.cs', 'source' => "using System;\nclass Program {\n static void Main() {\n var p = Console.ReadLine().Split(' ');\n Console.WriteLine(int.Parse(p[0]) + int.Parse(p[1]));\n }\n}\n"], + 'rs' => ['file' => 'solution.rs', 'source' => "use std::io::*;\nfn main() {\n let mut s = String::new();\n stdin().read_line(&mut s).unwrap();\n let v: Vec = s.trim().split_whitespace().map(|x| x.parse().unwrap()).collect();\n println!(\"{}\", v[0] + v[1]);\n}\n"], + 'go' => ['file' => 'solution.go', 'source' => "package main\nimport \"fmt\"\nfunc main() {\n var a, b int\n fmt.Scan(&a, &b)\n fmt.Println(a + b)\n}\n"], + 'php' => ['file' => 'solution.php', 'source' => " ['file' => 'solution.rb', 'source' => "a, b = gets.split.map(&:to_i)\nputs a + b\n"], + 'pas_fpc' => ['file' => 'solution.pas', 'source' => "program Solution;\nvar a, b: integer;\nbegin\n readln(a, b);\n writeln(a + b);\nend.\n"], + ]; + + $active = collect(Language::getDefaultLanguages())->where('is_active', true)->pluck('extension'); + + $cases = []; + foreach ($active as $extension) { + if (isset($solutions[$extension])) { + $cases[$extension] = [$extension, $solutions[$extension]['file'], $solutions[$extension]['source']]; + } + } + + return $cases; + } + + /** + * activeLanguages() silently skips any is_active language missing from + * its $solutions map (a data provider can't fail loudly per-case), so + * this is the actual regression net: if someone flips another language + * to is_active without adding a solution, this fails instead of the + * suite quietly going green while that language ships untested. + */ + public function test_every_active_language_has_a_solution_fixture_covering_it() + { + $active = collect(Language::getDefaultLanguages())->where('is_active', true)->pluck('extension'); + $covered = array_keys(self::activeLanguages()); + + $missing = $active->diff($covered)->values()->all(); + + $this->assertEmpty($missing, 'is_active languages with no MultiLanguageJudgingTest fixture: ' . implode(', ', $missing)); + } + + #[DataProvider('activeLanguages')] + public function test_active_language_compiles_and_judges_a_correct_solution_as_accepted(string $extension, string $filename, string $source) + { + $contest = Contest::factory()->create(['is_active' => true, 'start_time' => now()->subMinutes(5), 'duration' => 300]); + $site = Site::factory()->create(['contest_id' => $contest->id]); + + foreach (Language::getDefaultLanguages() as $lang) { + Language::create(array_merge($lang, ['contest_id' => $contest->id])); + } + $language = Language::where('contest_id', $contest->id)->where('extension', $extension)->firstOrFail(); + + foreach ([ + ['name' => 'Accepted', 'short_name' => 'AC', 'is_accepted' => true], + ['name' => 'Wrong Answer', 'short_name' => 'WA', 'is_accepted' => false], + ['name' => 'Compilation Error', 'short_name' => 'CE', 'is_accepted' => false], + ['name' => 'Runtime Error', 'short_name' => 'RE', 'is_accepted' => false], + ['name' => 'Contest Stopped', 'short_name' => 'CS', 'is_accepted' => false], + ] as $answer) { + Answer::create(array_merge($answer, ['contest_id' => $contest->id])); + } + + $problem = Problem::factory()->create(['contest_id' => $contest->id]); + + $inputRelative = "problems/{$contest->id}/{$problem->id}/input/1"; + $outputRelative = "problems/{$contest->id}/{$problem->id}/output/1"; + \Illuminate\Support\Facades\Storage::disk('local')->put($inputRelative, "3 5\n"); + \Illuminate\Support\Facades\Storage::disk('local')->put($outputRelative, "8\n"); + + ProblemTestCase::create([ + 'problem_id' => $problem->id, + 'number' => 1, + 'input_file' => $inputRelative, + 'output_file' => $outputRelative, + 'input_hash' => hash('sha256', "3 5\n"), + 'output_hash' => hash('sha256', "8\n"), + 'is_sample' => true, + ]); + + $team = User::create([ + 'fullname' => 'Lang Test Team', + 'username' => 'lang_test_' . $extension, + 'email' => "lang-test-{$extension}@example.com", + 'password' => bcrypt('password'), + 'user_type' => 'team', + 'is_enabled' => true, + 'contest_id' => $contest->id, + 'site_id' => $site->id, + ]); + + \Illuminate\Support\Facades\Bus::fake(); + + $file = UploadedFile::fake()->createWithContent($filename, $source); + + $submitResponse = $this->actingAs($team)->post("/submit/{$problem->id}", [ + 'language_id' => $language->id, + 'source_file' => $file, + ]); + + $submitResponse->assertRedirect(); + + $run = Run::where('contest_id', $contest->id)->where('user_id', $team->user_id)->firstOrFail(); + + app(AutoJudgeService::class)->judge($run->fresh()); + + $run->refresh(); + $this->assertSame('judged', $run->status); + $this->assertNotNull($run->answer_id, "no verdict produced for {$extension} -- stderr: {$run->auto_judge_stderr}"); + $this->assertTrue( + $run->answer->is_accepted, + "expected AC for {$extension}, got '{$run->answer?->short_name}': {$run->auto_judge_result}\nstdout: {$run->auto_judge_stdout}\nstderr: {$run->auto_judge_stderr}" + ); + } +}