Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@

## [Unreleased]

- 统一 Schema v1 特殊 Judge 路径栅栏:lint、正式打包和 local Judge 共同拒绝绝对路径、`..`、符号链接、junction/reparse point、非普通文件和题目目录外目标,并保留 `judge.type: checker` 兼容别名。
- 新增共享 Checker/Interactor Core 运行层,供 local Judge、stress 与数据生成复用;结果分离 verdict、execution status、failure kind、责任方、终止原因、双方资源/流量证据与清理结果,沙箱缓存 Schema 升至 7。
- Checker feedback 同时保留有界诊断与简短正式失败原因;Interactor 非普通 feedback、资源异常、启动失败、取消和清理失败均返回一致的结构化语义,清理失败不会再被成功 verdict 或取消状态掩盖。

## [0.6.5] - 2026-08-04

- Schema v1 题面—Validator 约束对账新增多组数据累计约束检查:保守识别 LaTeX 求和、中文“所有测试用例之和”表述,以及 Validator 中直接 `+=` / `acc = acc + term` 累加和后续 `ensuref` 上限;统一规范化为 `sum:n`、`sum:len:s`、`sum:n+m` 等主体。
Expand Down
2 changes: 1 addition & 1 deletion probhub/calibration.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@

CALIBRATION_SCHEMA_VERSION = 2
CALIBRATION_STRATEGY_VERSION = 2
SANDBOX_CACHE_SCHEMA_VERSION = 6
SANDBOX_CACHE_SCHEMA_VERSION = 7
EVIDENCE_FILENAME = "judge-evidence-v2.json"
EVIDENCE_LOCK_FILENAME = "judge-evidence.lock"
DEFAULT_ACCEPTED_TIME_MULTIPLIER = 3.0
Expand Down
36 changes: 24 additions & 12 deletions probhub/datagen.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,8 @@
from .errors import ProbHubError
from .io import normalize_newlines as _normalize_newlines, read_yaml, write_json
from .process_control import DEFAULT_PROCESS_LIMIT
from .stressing import _compare_custom, _prepare_program, _run
from .special_judges import run_checker_to_files
from .stressing import _prepare_program, _run
from .transactions import (
TRANSACTION_PHASE_COMMITTED,
TRANSACTION_PHASE_PREPARED,
Expand Down Expand Up @@ -694,26 +695,37 @@ def generator_command(relative):
checker_answer = checker_dir / "answer.ans"
checker_input.write_bytes(input_bytes)
checker_answer.write_bytes(answer_bytes)
checked = _compare_custom(
checker_env = os.environ.copy()
checker_env["PYTHONIOENCODING"] = "utf-8"
checked = run_checker_to_files(
checker_cmd,
checker_input,
checker_answer,
answer_bytes,
tool_timeout,
checker_dir,
TOOL_MEMORY_LIMIT_MB,
output_limit,
process_limit,
checker_answer,
timeout=tool_timeout,
cwd=checker_dir,
memory_limit_mb=TOOL_MEMORY_LIMIT_MB,
output_limit_bytes=int(output_limit * 1024 * 1024),
process_limit=process_limit,
env=checker_env,
)
if checked["status"] != "AC":
if checked.get("verdict") != "AC":
failure = {
"case": case,
"stage": "checker",
"status": checked["status"],
"status": checked.get("verdict") or "FAIL",
"message": checked.get("message") or "checker rejected the generated answer",
}
if checked.get("execution_status"):
failure["execution_status"] = checked["execution_status"]
legacy_execution_status = {
"time_limit": "TLE",
"memory_limit": "MLE",
"output_limit": "OLE",
"process_limit": "RE",
"start_error": "RE",
"output_control_error": "FAIL",
}.get(checked.get("execution_status"))
if legacy_execution_status:
failure["execution_status"] = legacy_execution_status
failures.append(failure)
continue

Expand Down
70 changes: 24 additions & 46 deletions probhub/linting.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
import json
import math
import re
from pathlib import Path, PureWindowsPath
from pathlib import Path

from .builder_fingerprint import (
BUILD_MANIFEST_SCHEMA_VERSION,
Expand All @@ -16,6 +16,7 @@
from .errors import ProbHubError
from .hashing import files_under, hash_file, hash_paths
from .metadata import build_meta, normalize_display_name
from .problem_paths import ProblemPathError, resolve_problem_regular_file
from .solutions import analyze_solution_verification
from .statement import parse_statement
from .statement_consistency import analyze_constraint_consistency, reconcile_constraints
Expand Down Expand Up @@ -70,54 +71,17 @@ def _problem_relative_path(problem_dir, value):
return candidate


def _is_link_like(path):
try:
return path.is_symlink() or (
hasattr(path, "is_junction") and path.is_junction()
)
except OSError:
return False


def _problem_regular_file_path(problem_dir, value):
"""Resolve a configured problem-local file without accepting links.

Returns ``(path, reason)`` where reason is one of ``invalid``, ``outside``,
``symlink``, or ``missing``. The explicit reason lets lint keep stable,
field-specific diagnostics without ever opening an escaped path.
"""

if not isinstance(value, str) or not value.strip():
return None, "invalid"
problem_dir = Path(problem_dir).resolve()
value = value.strip()
relative = Path(value)
if relative.is_absolute() or PureWindowsPath(value).is_absolute():
return None, "outside"
if ".." in relative.parts or ".." in PureWindowsPath(value).parts:
return None, "outside"
candidate = problem_dir / relative
current = problem_dir
try:
for part in relative.parts:
if part in {"", "."}:
continue
current = current / part
if _is_link_like(current):
return None, "symlink"
resolved = candidate.resolve()
resolved.relative_to(problem_dir)
except (OSError, RuntimeError, ValueError):
return None, "outside"
if not resolved.is_file():
return None, "missing"
return resolved, None
return resolve_problem_regular_file(problem_dir, value), None
except ProblemPathError as exc:
return None, exc.reason


def _configured_file_error(field, value, reason, missing_label):
if reason == "outside":
return f"{field} must stay inside the problem directory: {value}"
if reason == "symlink":
if reason == "link":
return f"{field} must be a regular non-symlink file: {value}"
if reason == "invalid":
return f"{field} must be a non-empty relative path"
Expand Down Expand Up @@ -390,15 +354,29 @@ def lint_problem(root, workspace, entry):
if judge_type == "custom":
if not checker:
errors.append("judge.checker is required for custom judging")
elif not (problem_dir / checker).is_file():
errors.append(f"checker not found: {checker}")
else:
_, checker_reason = _problem_regular_file_path(problem_dir, checker)
if checker_reason:
errors.append(_configured_file_error(
"judge.checker",
checker,
checker_reason,
"checker",
))
if interactor:
errors.append("judge.interactor is only valid for interactive judging")
elif judge_type == "interactive":
if not interactor:
errors.append("judge.interactor is required for interactive judging")
elif not (problem_dir / interactor).is_file():
errors.append(f"interactor not found: {interactor}")
else:
_, interactor_reason = _problem_regular_file_path(problem_dir, interactor)
if interactor_reason:
errors.append(_configured_file_error(
"judge.interactor",
interactor,
interactor_reason,
"interactor",
))
if checker:
errors.append("judge.checker is not used for interactive judging")
interactive = judge.get("interactive") or {}
Expand Down
42 changes: 10 additions & 32 deletions probhub/package_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@

from .errors import ProbHubError
from .io import write_yaml
from .problem_paths import ProblemPathError, resolve_problem_regular_file
from .process_control import DEFAULT_PROCESS_LIMIT, ProcessCancelled, run_managed_to_files

PACKAGE_ROOT = Path(__file__).resolve().parents[1]
Expand Down Expand Up @@ -280,37 +281,16 @@ def _expected_validation(config):


def _problem_regular_file(problem_dir, relative, label):
if not isinstance(relative, str) or not relative:
raise ProbHubError(f"{label} is required", code="unsafe_package_source")
problem_dir = Path(problem_dir).resolve()
relative_path = Path(relative)
if relative_path.is_absolute() or relative_path.drive or ".." in relative_path.parts:
raise ProbHubError(
f"{label} must stay inside the problem directory: {relative}",
code="unsafe_package_source",
)
candidate = problem_dir / relative_path
current = problem_dir
has_symlink = False
for part in relative_path.parts:
current = current / part
if current.is_symlink():
has_symlink = True
break
if has_symlink or not candidate.is_file():
raise ProbHubError(
f"{label} must be a regular file: {relative}",
code="unsafe_package_source",
)
resolved = candidate.resolve()
try:
resolved.relative_to(problem_dir)
except ValueError as exc:
raise ProbHubError(
f"{label} must stay inside the problem directory: {relative}",
code="unsafe_package_source",
) from exc
return resolved
return resolve_problem_regular_file(problem_dir, relative)
except ProblemPathError as exc:
if exc.reason == "invalid":
message = f"{label} is required"
elif exc.reason == "outside":
message = f"{label} must stay inside the problem directory: {relative}"
else:
message = f"{label} must be a regular file: {relative}"
raise ProbHubError(message, code="unsafe_package_source") from exc


def _problem_directory(problem_dir, relative, label):
Expand Down Expand Up @@ -358,8 +338,6 @@ def prepare_output_validator(problem_dir, config):
return None

source_value = judge.get(source_key)
if not source_value:
raise ProbHubError(f"judge.{source_key} is required for {judge_type} judging")
source = _problem_regular_file(problem_dir, source_value, f"judge.{source_key}")
if not TESTLIB_PATH.is_file():
raise ProbHubError(f"testlib.h not found: {TESTLIB_PATH}")
Expand Down
84 changes: 84 additions & 0 deletions probhub/problem_paths.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import os
import stat
from pathlib import Path, PurePosixPath, PureWindowsPath


_FILE_ATTRIBUTE_REPARSE_POINT = getattr(
stat,
"FILE_ATTRIBUTE_REPARSE_POINT",
0x400,
)


class ProblemPathError(ValueError):
"""A configured problem-local path failed a stable safety check."""

def __init__(self, reason):
self.reason = reason
super().__init__(reason)


def resolve_problem_regular_file(problem_dir, value):
"""Resolve a problem-local regular file without traversing link-like paths."""

if not isinstance(value, str) or not value.strip():
raise ProblemPathError("invalid")

normalized = value.strip().replace("\\", "/")
posix_path = PurePosixPath(normalized)
windows_path = PureWindowsPath(normalized)
if posix_path.is_absolute() or windows_path.is_absolute() or windows_path.drive:
raise ProblemPathError("outside")
if ".." in posix_path.parts:
raise ProblemPathError("outside")

try:
access_root = Path(os.path.abspath(os.fspath(problem_dir)))
problem_root = access_root.resolve(strict=True)
except (TypeError, ValueError) as exc:
raise ProblemPathError("invalid") from exc
except (OSError, RuntimeError) as exc:
raise ProblemPathError("missing") from exc
if not problem_root.is_dir():
raise ProblemPathError("non_regular")

parts = posix_path.parts
if not parts:
raise ProblemPathError("non_regular")

current = access_root
for index, part in enumerate(parts):
current = current / part
try:
info = os.lstat(current)
except ValueError as exc:
raise ProblemPathError("invalid") from exc
except NotADirectoryError as exc:
raise ProblemPathError("non_regular") from exc
except OSError as exc:
raise ProblemPathError("missing") from exc

attributes = getattr(info, "st_file_attributes", 0)
if stat.S_ISLNK(info.st_mode) or attributes & _FILE_ATTRIBUTE_REPARSE_POINT:
raise ProblemPathError("link")
if index < len(parts) - 1:
if not stat.S_ISDIR(info.st_mode):
raise ProblemPathError("non_regular")
elif not stat.S_ISREG(info.st_mode):
raise ProblemPathError("non_regular")

try:
resolved = current.resolve(strict=True)
except FileNotFoundError as exc:
raise ProblemPathError("missing") from exc
except RuntimeError as exc:
raise ProblemPathError("link") from exc
except OSError as exc:
raise ProblemPathError("missing") from exc
try:
resolved.relative_to(problem_root)
except ValueError as exc:
raise ProblemPathError("outside") from exc
# Keep the caller's absolute spelling (for example Windows 8.3 or SUBST
# aliases) after validating the canonical target against problem_root.
return current
Loading
Loading