Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
4764810
feat: /judge/init
w8385 Jan 27, 2026
cfc6d35
refactor: remove unnecessary IntoResponse implementations
w8385 Jan 27, 2026
2b84dcd
refactor: migrate judge components to grader module
w8385 Jan 27, 2026
7ce815f
feat: /judge/compile
w8385 Jan 27, 2026
3f3a929
feat: /judge/cleanup
w8385 Jan 27, 2026
72e6003
feat: /judge/execute
w8385 Jan 27, 2026
efdcad0
fix: add classpath option to Java execution configuration
w8385 Jan 27, 2026
4a08ead
refactor: add ExecuteConfig builders
w8385 Feb 2, 2026
423b696
feat: include output path in CompileResult
w8385 Feb 2, 2026
8636864
refactor: update copy functions to use string paths instead of Path
w8385 Feb 2, 2026
b048d58
refactor: replace PathBuf with String for box_root function and updat…
w8385 Feb 2, 2026
c938c50
feat: /judge/generator
w8385 Feb 2, 2026
75dde35
refactor: change arg method to &str instead of String
w8385 Feb 2, 2026
7b590e8
feat: /judge/checker
w8385 Feb 2, 2026
61cdecf
refactor: remove unused error variants from JudgeError enum
w8385 Feb 2, 2026
47c17be
refactor: remove unused fields from CompileConfig and ExecuteConfig s…
w8385 Feb 2, 2026
bd0387e
refactor: remove unused stdout field from compilation response
w8385 Feb 2, 2026
2a88bdc
refactor: remove unused stdout field from CompileResult struct
w8385 Feb 2, 2026
f30bff8
refactor: remove unused stdout field from CompileResult in compile.rs
w8385 Feb 2, 2026
577c92c
refactor: simplify docker cp operations by extracting helper functions
w8385 Feb 2, 2026
c573429
refactor: clean up Dockerfile by removing apt cache after installation
w8385 Feb 3, 2026
75ba7b4
refactor: handle Docker command spawn errors with proper error mapping
w8385 Feb 3, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ serde = { version = "1.0.219", features = ["derive"] }
serde_json = "1.0.133"
tokio = { version = "1.45.1", features = ["full"] }
uuid = { version = "1.17.0", features = ["v4"] }
async-trait = "0.1.89"

[dev-dependencies]
rstest = "0.25.0"
23 changes: 23 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
FROM ubuntu:22.04

RUN apt-get update
RUN apt-get install -y --no-install-recommends \
gcc g++ \
python3 pypy3 \
openjdk-8-jdk-headless \
git make pkg-config libcap-dev libsystemd-dev asciidoc \
curl jq \
&& rm -rf /var/lib/apt/lists/*

# config isolate
RUN git clone https://github.com/ioi/isolate.git
RUN cd isolate && make install
Comment on lines +13 to +14
Copy link

Copilot AI Feb 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The RUN git clone https://github.com/ioi/isolate.git step fetches and installs a third-party tool directly from a mutable Git branch without pinning to a specific commit or verifying integrity, which exposes the build to supply-chain compromise if the repository or network is tampered with. An attacker who gains control over that Git ref could inject arbitrary code into the image and thus the grading environment. Pin this dependency to an immutable commit or signed release and add integrity verification (or vendor it) instead of cloning the moving default branch at build time.

Copilot uses AI. Check for mistakes.
RUN rm -rf isolate

# install testlib
RUN curl -L https://raw.githubusercontent.com/MikeMirzayanov/testlib/master/testlib.h \
-o /usr/include/testlib.h
Comment on lines +18 to +19
Copy link

Copilot AI Feb 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The RUN curl -L https://raw.githubusercontent.com/MikeMirzayanov/testlib/master/testlib.h step downloads source code from a mutable remote branch without any checksum or signature verification, creating a supply-chain risk if that URL or branch is compromised. Because this header will be compiled into all judged programs, a malicious change upstream could result in arbitrary code execution inside your grading environment. Pin this asset to an immutable version (e.g., a specific commit) and verify its integrity via a known hash or signature instead of relying on the moving master branch.

Copilot uses AI. Check for mistakes.

# create judge user
RUN useradd -m judge
WORKDIR /home/judge
10 changes: 10 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
services:
grader:
build:
context: .
privileged: true
container_name: coduck-grader
entrypoint: [ "sleep", "infinity" ]
restart: unless-stopped
volumes:
- ./uploads:/home/judge/uploads
49 changes: 49 additions & 0 deletions src/docker/cp.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
use tokio::process::Command;

use crate::errors::DockerError;

async fn ensure_exists_in_container(container: &str, path: &str) -> Result<(), DockerError> {
let status = Command::new("docker")
.args(["exec", container, "test", "-e", path])
.status()
.await
.map_err(|_| DockerError::Spawn)?;

if !status.success() {
return Err(DockerError::FileNotFound(path.to_string()));
}

Ok(())
}

async fn cp_container(src: &str, dst: &str) -> Result<(), DockerError> {
Command::new("docker")
.arg("cp")
.arg(src)
.arg(dst)
.output()
.await
.map_err(|_| DockerError::Spawn)?;

Ok(())
}

pub async fn to_container(
container: &str,
local_src: &str,
container_dst: &str,
) -> Result<(), DockerError> {
cp_container(local_src, &format!("{}:{}", container, container_dst)).await?;
ensure_exists_in_container(container, container_dst).await?;
Ok(())
}

pub async fn from_container(
container: &str,
container_src: &str,
local_dst: &str,
) -> Result<(), DockerError> {
ensure_exists_in_container(container, container_src).await?;
cp_container(&format!("{}:{}", container, container_src), local_dst).await?;
Ok(())
}
37 changes: 37 additions & 0 deletions src/docker/exec.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
use crate::errors::DockerError;
use std::process::Stdio;
use tokio::process::Command;

pub struct CommandOutput {
pub status: std::process::ExitStatus,
pub stdout: String,
pub stderr: String,
}

impl CommandOutput {
pub fn ensure_success<E>(self, map_err: impl FnOnce(&CommandOutput) -> E) -> Result<Self, E> {
if self.status.success() {
Ok(self)
} else {
Err(map_err(&self))
}
}
}

pub async fn exec(container: &str, args: &[&str]) -> Result<CommandOutput, DockerError> {
let output = Command::new("docker")
.arg("exec")
.args(["-u", "judge"])
.arg(container)
.args(args)
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.output()
.await
.map_err(|_| DockerError::Spawn)?;
Ok(CommandOutput {
status: output.status,
stdout: String::from_utf8_lossy(&output.stdout).to_string(),
stderr: String::from_utf8_lossy(&output.stderr).to_string(),
})
}
2 changes: 2 additions & 0 deletions src/docker/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
pub mod cp;
pub mod exec;
23 changes: 23 additions & 0 deletions src/errors/docker.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
use serde::{Deserialize, Serialize};
use std::fmt::{Display, Formatter, Result};

#[derive(Debug, PartialEq, Serialize, Deserialize)]
pub enum DockerError {
UnsupportedExtension(String),
InvalidFilename,
Spawn,
FileNotFound(String),
}

impl Display for DockerError {
fn fmt(&self, f: &mut Formatter<'_>) -> Result {
match self {
DockerError::UnsupportedExtension(extension) => {
write!(f, "Unsupported file extension: {extension}")
}
DockerError::InvalidFilename => write!(f, "Invalid filename"),
DockerError::Spawn => write!(f, "Failed to spawn docker process"),
DockerError::FileNotFound(filename) => write!(f, "File not found: {filename}"),
}
}
}
37 changes: 37 additions & 0 deletions src/errors/isolate.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
use crate::errors::DockerError;
use serde::{Deserialize, Serialize};

#[derive(Debug, PartialEq, Serialize, Deserialize)]
pub enum IsolateError {
Docker(DockerError),
UnsupportedExtension(String),
InvalidFilename,
InvalidBoxId(i32),
InitFailed(String),
CompileFailed(String),
CleanupFailed(String),
ExecuteFailed(String),
}

impl std::fmt::Display for IsolateError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
IsolateError::Docker(err) => write!(f, "Docker error: {}", err),
IsolateError::UnsupportedExtension(extension) => {
write!(f, "Unsupported file extension: {extension}")
}
IsolateError::InvalidFilename => write!(f, "Invalid filename"),
IsolateError::InitFailed(msg) => write!(f, "Isolate initialization failed: {msg}"),
IsolateError::InvalidBoxId(id) => write!(f, "Invalid box ID returned by isolate: {id}"),
IsolateError::CompileFailed(msg) => write!(f, "Isolate compile error: {msg}"),
IsolateError::ExecuteFailed(msg) => write!(f, "Isolate execution failed: {msg}"),
IsolateError::CleanupFailed(msg) => write!(f, "Isolate cleanup failed: {msg}"),
}
}
}

impl From<DockerError> for IsolateError {
fn from(e: DockerError) -> Self {
IsolateError::Docker(e)
}
}
41 changes: 41 additions & 0 deletions src/errors/judge.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
use crate::errors::IsolateError;
use axum::response::{IntoResponse, Response};
use axum::Json;
use reqwest::StatusCode;

#[derive(Debug)]
pub enum JudgeError {
Isolate(IsolateError),
NotFound(String),
}

impl std::fmt::Display for JudgeError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
JudgeError::Isolate(err) => write!(f, "Isolate error: {}", err),
JudgeError::NotFound(msg) => write!(f, "Not found: {}", msg),
}
}
}

impl From<IsolateError> for JudgeError {
fn from(e: IsolateError) -> Self {
JudgeError::Isolate(e)
}
}

impl IntoResponse for JudgeError {
fn into_response(self) -> Response {
match self {
JudgeError::Isolate(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({ "error" : e.to_string() })),
),
JudgeError::NotFound(msg) => (
StatusCode::NOT_FOUND,
Json(serde_json::json!({ "error" : msg })),
),
}
.into_response()
}
}
6 changes: 6 additions & 0 deletions src/errors/mod.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,9 @@
mod docker;
mod isolate;
mod judge;
mod language;

pub(crate) use docker::*;
pub(crate) use isolate::*;
pub(crate) use judge::*;
pub(crate) use language::*;
109 changes: 109 additions & 0 deletions src/grader/config.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
use crate::file_manager::Language;

#[derive(Debug)]
pub struct CompileConfig {
pub output_path: String,
pub argv: Vec<String>,
}

#[derive(Debug)]
pub struct ExecuteConfig {
pub argv: Vec<String>,
}

impl CompileConfig {
pub fn new(category: &str, filename: &str, language: Language) -> CompileConfig {
let source_path = format!("{}/{}", category, filename);
let output_path = format!("{}/{}", category, filename.split('.').next().unwrap());
Copy link

Copilot AI Feb 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The .unwrap() call on filename.split('.').next() could panic if the filename is empty or doesn't contain the expected format. Although this might be validated earlier, consider adding explicit error handling or a comment explaining why this is safe.

Copilot uses AI. Check for mistakes.

let python_temp = format!(
"\"import py_compile; py_compile.compile(r'{}')\"",
source_path
);
let argv = match language {
Language::Cpp => vec![
"--processes=4",
"--",
"/usr/bin/g++",
&source_path,
"-o",
&output_path,
"-O2",
"-Wall",
"-lm",
"-static",
"-std=gnu++17",
],
Language::Python => {
vec!["--", "/usr/bin/pypy3", "-W", "ignore", "-c", &python_temp]
}
Language::Java => vec![
"--processes=32",
"--",
"/usr/lib/jvm/java-8-openjdk-amd64/bin/javac",
"-J-Xms1024m",
"-J-Xmx1920m",
"-J-Xss512m",
"-encoding",
"UTF-8",
&source_path,
],
_ => vec![],
Copy link

Copilot AI Feb 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The match arms for unsupported languages return an empty vector. This could lead to silent failures where no compilation or execution occurs. Consider returning an error for unsupported languages instead of silently doing nothing.

Copilot uses AI. Check for mistakes.
}
.iter()
.map(|s| s.to_string())
.collect();

CompileConfig { output_path, argv }
}
}

impl ExecuteConfig {
pub fn new(category: &str, filename: &str, language: Language) -> Self {
let executable = format!("{}/{}", category, filename);
let argv = match language {
Language::Cpp => vec!["--", &executable],
Language::Python => vec!["--", "/usr/bin/python3", &executable],
Language::Java => vec![
"--processes=32",
"--",
"/usr/lib/jvm/java-8-openjdk-amd64/bin/java",
"-cp",
&category,
"-Xms1024m",
"-Xmx1920m",
"-Xss512m",
"Main",
],
_ => vec![],
Copy link

Copilot AI Feb 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The match arms for unsupported languages return an empty vector. This could lead to silent failures where no execution occurs. Consider returning an error for unsupported languages instead of silently doing nothing.

Copilot uses AI. Check for mistakes.
}
.iter()
.map(|s| s.to_string())
.collect();

Self { argv }
}

pub fn arg(mut self, arg: &str) -> Self {
self.argv.push(arg.to_string());
self
}

pub fn stdin(mut self, input_path: &str) -> Self {
self.argv = vec!["-i", input_path]
.iter()
.map(|s| s.to_string())
.chain(self.argv.iter().cloned())
.collect();
self
Comment on lines +92 to +98
Copy link

Copilot AI Feb 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The stdin and stdout methods prepend arguments to the argv vector which reverses the expected order. This implementation is correct but unconventional. Consider adding a comment explaining that these methods prepend rather than append, or refactor to use a clearer pattern (e.g., storing stdin/stdout separately and building argv in the execute function).

Copilot uses AI. Check for mistakes.
}

pub fn stdout(mut self, output_path: &str) -> Self {
self.argv = vec!["-o", output_path]
.iter()
.map(|s| s.to_string())
.chain(self.argv.iter().cloned())
.collect();
self
}
}
Loading
Loading