#0. 导言:为什么用判题系统来教计算机
#0.1 项目是什么
这是 CS101 课程自己的本机判题服务:按 data/openjudge/catalog.json 现场核算(2026-08-27), 有 1,851 条目录记录,其中 1,833 条备有测试数据(共 37,451 组)。目录记录包含不同题库/别名, 同一全局题号可能对应多条记录。学生在网页上写代码、选语言、提交,服务器在沙箱里执行不可信代码 并给出裁定。
浏览器(零外部依赖:手写语法高亮、括号匹配、双向拖拽)
│ HTTP/1.1 + JSON
v
Python 标准库 HTTP 服务器 server.py(2000 行)
路由 → 认证 → 限频 → 并发闸 → 判题
│ fork/exec
v
沙箱 judge.py(400 行)
RLIMIT_CPU / AS / FSIZE + env 白名单 + 临时目录
│
v
SQLite(账号、提交记录、设置)+ 每日异地备份
整个系统只有两个 Python 文件(server.py 2033 行、judge.py 403 行), 零第三方依赖 —— 没有 Flask、没有 Django、没有 ORM。 每一次系统调用、每一条资源限制、每一个 HTTP 状态码都直接暴露在源码里可读、可改、可调试。
#0.2 为什么它特别适合教学
- 学生已经是它的用户。 他们每天用它交作业。「为什么我本地过了这里 WA」
「为什么选 PyPy3 就不超时了」——这些问题的答案就在 judge.py 里, 而且是他们自己能读完的 400 行。
- 它必须执行不可信代码,所以安全不是选修。 全班的代码都是潜在的攻击者。
_limits() 是唯一一道防线,讲清它就是讲清进程隔离、资源限制与最小权限。
- 规模小到能读完,复杂到有真问题。 2400 行代码里有:竞态、时序攻击面、
fail-open、旁路泄露、监听队列溢出、fd 耗尽、CPU 超售导致误判。 这些「课本讲过但没见过」的东西,这里都有带日期的真实事故记录(见 collab/PLAN.md)。
- 正确性有客观标准。 判题系统判错了,学生会来申诉。这给了工程纪律一个
不容含糊的理由:你的 bug 会变成别人的成绩。
#0.3 技术栈速览
| 层 | 技术 | 关键文件 |
|---|---|---|
| HTTP 服务 | Python 标准库 ThreadingHTTPServer |
server.py · Server |
| 页面 | 服务端填模板,零框架、零构建 | submit.html + server.py · submission_page |
| 前端交互 | 手写语法高亮/括号匹配/拖拽,零依赖 | submit.html 内联 <script> |
| 沙箱 | subprocess + resource.setrlimit |
judge.py · _limits / _run |
| 持久化 | SQLite(标准库 sqlite3) |
server.py(参数化查询) |
| 备份 | SQLite 在线备份 API + 异地校验 | tools/backup_db.py |
| 部署 | systemd unit + timer | deploy/*.service, tools/release.sh |
| 测试 | unittest,129 项 |
tests/(约 2300 行) |
测试代码约 2300 行,产品代码约 2400 行 —— 这个比例本身就是一堂课。
#0.4 怎么读这份手册
每节结构统一:知识点 → 代码锚点 → 解释 → 📎 课堂讨论 / 🧪 实验建议。 教师可直接抽取任意一节作为一次课的案例。带 ⚠️ 的段落是真实事故, 适合作为「反面教材」直接投屏。
#1. 课程知识点全景图
| 课程 | 本项目中的落点 | 章节 |
|---|---|---|
| 计算概论 | stdin/stdout、退出码、token 比对语义、浮点与整数 | 2 |
| 数据结构 | 哈希集合、滑动窗口、缓存失效、栈式括号匹配 | 3 |
| 算法 | 词法扫描(一次扫描多用途)、前缀/贪心、复杂度与限时 | 3 |
| 操作系统 | 进程、fork/exec、rlimit、信号、fd、管道、进程组 | 4 |
| 计算机网络 | TCP accept 队列、HTTP 语义、状态码、反向代理、SSRF | 5 |
| 数据库 | 事务、参数化查询、在线备份、完整性校验、缓存一致性 | 6 |
| 计算机安全 | 口令哈希与盐、恒定时间比较、会话、限频、旁路泄露、fail-open | 7 |
| 软件工程 | 契约测试、变异自检、「永不会红的检查」、评审心理学 | 8 |
| 运维/SRE | systemd、备份与恢复演练、告警、压测方法论 | 9 |
#2. 计算概论与程序设计基础
#2.1 一个程序的「接口」是什么
锚点:judge.py · judge() 的判定循环
学生写的每份代码,接口只有三样:标准输入、标准输出、退出码。判题器据此裁定:
if result.returncode != 0: # 非零 → Runtime Error
if actual.split() != expected.split(): # token 不等 → Wrong Answer
这是很多学生第一次意识到:print 不是「显示在屏幕上」,而是往 fd 1 写字节; 程序 return 0 不是形式,而是给调用者的信号。
📎 课堂讨论:为什么 input() 读不到数据时会抛异常而不是返回空串? 这和判题器把它归为 Runtime Error 有什么关系?
#2.2 token 比对:为什么空格不影响判定
锚点:judge.py · judge(),actual.split() != expected.split()
str.split() 不带参数时按任意连续空白切分并丢弃首尾空白。于是:
| 输出 | 判定 |
|---|---|
3 |
✅ |
3 \n\n |
✅ |
3 + Windows 换行 |
✅(judge() 先把 \r\n 归一化) |
3.0 |
❌ token 内容不同 |
3 4 而答案是 4 3 |
❌ 顺序是语义的一部分 |
⚠️ 已知局限(真实记录):题号 31042「简化版 Git Diff」用行首空格表达 「未修改的行」。token 比对看不见缩进差异,本站会判 Accepted 而平台判 WA。 这条在 2026-07-27 被发现,人拍板不改 —— 收紧比对语义会影响全部 505 份已交付数据, 用一处已知宽松换 505 份数据的判定语义不变,是划算的。记录在案,不假装不存在。
📎 课堂讨论:如果要支持「行首空格有意义」的题,你会怎么改? 全局改比对语义 vs 给题目加一个标志位 —— 各自的代价是什么?
#2.3 时间限制不是一个数
锚点:judge.py:39 LANGUAGE_TIME_MULTIPLIER、judge.py · case_seconds / total_budget_seconds
同一道题,不同语言的限时不同:
Python3 ×10 PyPy3 ×3 C/C++/Swift/ObjC ×1 C#/F#/VB.NET ×2
单组 CPU 时间 = clamp(题面限时 × 倍率, 4s, 20s);整次提交墙钟预算硬顶 300 秒 (TOTAL_HARD_CAP_S)。
这里有一个容易被忽略的区分:CPU 时间 vs 墙钟时间。 _run() 同时设了两道闸 —— RLIMIT_CPU(只算 CPU)和 timeout=cpu_seconds+1(墙钟)。 一个 time.sleep(30) 的程序 CPU 用量几乎为零,只有墙钟能拦住它。
🧪 实验建议:写两份都会超时的程序 —— 一份死循环(烧 CPU), 一份 sleep(不烧 CPU)。观察它们分别被哪道闸拦下(提示:看返回的 message)。 tests/test_judge.py 里有对应的确定性用例。
#3. 数据结构与算法
#3.1 一次扫描,三种用途
锚点:submit.html · function scan(约第 404 行)
网页编辑器的语法高亮、括号匹配、自动缩进共用同一次词法扫描。 为什么不各扫一遍?因为它们必须看到同一份切分:
// 括号匹配要跳过字符串和注释里的括号,
// 缺了这层共享就会把 "(" 里的括号也配上。
SPECS 是一张「语言 → 规则表」,用粘性正则(/y 标志,lastIndex 从当前位置匹配) 逐 token 推进。这是一个完整的、200 行能读完的手写词法分析器。
📎 课堂讨论:为什么用粘性正则而不是 String.indexOf 循环? 如果不用正则,你会用什么数据结构实现关键字识别(提示:Trie)?
#3.2 括号匹配:不用栈也能做
锚点:submit.html · function bracketMatch(约第 429 行)
教科书解法是压栈。这里因为只需要匹配光标处那一个括号,用的是深度计数:
let depth = 0;
for (let k = at; k >= 0 && k < code.length; k += step) {
if (inToken(tokens, k)) continue; // 跳过字符串/注释
if (code[k] === ch) depth++;
else if (code[k] === want) { depth--; if (!depth) return [at, k].sort(...); }
}
单向扫描、O(n) 时间、O(1) 额外空间。
📎 课堂讨论:什么时候必须用栈?(提示:要同时校验整份文档的括号是否配平时。) 这是「按需求选数据结构」而不是「背最优解」的好例子。
#3.3 滑动窗口限频
锚点:server.py:178 · quota_retry_after
stamps = [t for t in QUOTA_HISTORY.get(key, []) if now - t < window]
if len(stamps) >= limit:
return max(1, round(window - (now - stamps[0])))
stamps.append(now)
一个列表 + 一次过滤,就是滑动窗口计数器。返回值是还要等几秒, 而不是简单的布尔 —— 因为要告诉用户什么时候能再试。
⚠️ 真实事故:这个函数最初漏了「清理过期时间戳」这一步。 后果不是「限得松了」而是用满额度的用户被永久挡住,直到进程重启。 更值得记的是:端到端测试抓不住它(跑不了 5 分钟去看窗口是否滑动), 是变异自检发现的,最后靠一条直接测纯函数、把窗口缩到 1 秒的用例才盖住。
📎 课堂讨论:滑动窗口 vs 令牌桶 vs 固定窗口,三者在「突发流量」下的行为差异? 为什么这里选滑动窗口?
#3.4 缓存与失效
锚点:server.py · catalog_raw、judge.py:323 · problem_exists
catalog.json 有 4.1 MB,每次解析约 45 ms。两处都用 mtime 作为版本号:
mtime_ns = catalog_path.stat().st_mtime_ns
if PROBLEM_KEYS_CACHE is None or mtime_ns != PROBLEM_KEYS_MTIME_NS:
...重新解析...
problem_exists 只缓存 (book, id) 键集合而不是整份解析结果 —— 缓存该存什么, 取决于你要回答什么问题。实测:首次 68.6 ms → 命中 0.02 ms,约 3343×。
📎 课堂讨论:mtime 作为版本号有什么风险?(提示:同一秒内的两次写入; 时钟回拨;文件系统的 mtime 精度。)什么时候该改用内容哈希?
#4. 操作系统
这一章是本项目最厚的部分:判题器就是一个微型的、能读完的进程管理器。
#4.1 资源限制:唯一的一道防线
def _limits(cpu_seconds, address_space_bytes, file_size_bytes):
resource.setrlimit(resource.RLIMIT_CPU, (cpu_seconds, cpu_seconds))
resource.setrlimit(resource.RLIMIT_FSIZE, (file_size_bytes, file_size_bytes))
resource.setrlimit(resource.RLIMIT_AS, (address_space_bytes, address_space_bytes))
| 限制 | 值 | 拦住什么 |
|---|---|---|
RLIMIT_CPU |
4–20 秒 | 死循环(超限收到 SIGXCPU) |
RLIMIT_AS |
768 MB | [0] * 10**10 这类内存炸弹 |
RLIMIT_FSIZE |
2 MB | 往磁盘狂写 |
这三条通过 preexec_fn 在 fork 之后、exec 之前设置 —— 这个时机是关键: 子进程还没变成用户代码,但已经是独立进程了。
📎 课堂讨论:为什么设的是 RLIMIT_AS(地址空间)而不是 RSS(常驻内存)? 两者差别在哪?(提示:mmap 一大片但不触碰。)
🧪 实验建议:分别写出触发这三条限制的程序,观察返回的 status 有何不同。 再试着绕过它们 —— 例如 fork 出子进程能否规避 CPU 限制?(提示:看 §4.4)
#4.2 子进程环境:白名单而非黑名单
锚点:judge.py:107 CHILD_PATH、judge.py:163 · _run
env={"PATH": CHILD_PATH, "HOME": str(cwd)}
子进程的环境变量恰好只有两个。不是「删掉危险的」,而是「只留必需的」。 CHILD_PATH 固定为 /usr/local/bin:/usr/bin:/bin。
注释里写着理由:*"用户代码就跑在那个子进程里,PATH 多一个目录就多一片可执行面"*。
⚠️ 真实事故(2026-07-26):shutil.which("pypy3") 查的是本进程的 PATH, 而子进程拿的是上面那份受限 PATH。两者不一致时,裸名字会 FileNotFoundError, 而 judge() 不接这个异常 → 服务端 500。 修法是把解释器解析成绝对路径再交给子进程,而不是往子进程 PATH 里塞目录 —— 后者会扩大攻击面。
📎 课堂讨论:这是一个「修复方案有两个,选哪个」的真实案例。 往 PATH 加目录 vs 传绝对路径,各自的安全含义?
#4.3 信号:程序是怎么被杀死的
if result.returncode in {-signal.SIGXCPU, -signal.SIGKILL}:
return {"status": "Time Limit Exceeded", ...}
Python 的 returncode 为负数时,绝对值是杀死进程的信号编号。 超 CPU 限制 → SIGXCPU;超墙钟被 subprocess 强杀 → SIGKILL。
📎 课堂讨论:SIGKILL 和 SIGTERM 的区别是什么?为什么前者不能被捕获? systemd 的 TimeoutStopSec=20 正是「先 TERM,20 秒后 KILL」。
#4.4 进程组:别留下孤儿
锚点:deploy/cs101.service 的 KillMode=control-group
判题会把用户代码拉起成子进程。服务停止时如果只杀主进程, 正在跑的用户程序会变成孤儿继续占 CPU。KillMode=control-group 让 systemd 通过 cgroup 带走整棵进程树。
🧪 实验建议:提交一份死循环程序,在判题过程中 systemctl stop cs101, 用 ps 观察子进程是否被一并回收。把 KillMode 改掉再试一次。
#4.5 文件描述符:一种会耗尽的资源
⚠️ 真实事故(2026-07-28 压测):每个并发判题要占 1 个客户端连接 + 3 个子进程管道 + 临时文件。100 并发实测用到 644 个 fd, 而 systemd 服务的默认软上限是 1024。用满之后 accept() 失败, 客户端看到「连上了又被断开」,而服务端日志里什么都没有 —— 那些连接根本没被 accept,自然没有日志。
修复:LimitNOFILE=65536。
📎 课堂讨论:为什么「日志里什么都没有」反而是重要线索? 如果一个故障在日志里无痕,你会从哪里下手?(提示:/proc/<pid>/fd、ss、strace)
#4.6 管道缓冲区
锚点:tests/test_server.py · setUpClass
测试用 subprocess.PIPE 捕获服务端输出,而从不排空它。 管道缓冲区约 64 KiB,写满后服务端会阻塞在 write 上。
实测一轮测试写出约 14 KiB(占 22%),当前安全 —— 但用例只会越来越多。 现已改为写入固定路径的文件,既避免了这颗雷,也保住了故障现场。
🧪 实验建议:写一个子进程疯狂 print,父进程 PIPE 但不读,观察死锁。 再改成 stdout=open(file) 或起一个排空线程,对比行为。
#5. 计算机网络
#5.1 监听队列:TCP 握手排不上队会怎样
⚠️ 真实事故(2026-07-28 压测) —— 本项目最有教学价值的一个发现。
锚点:server.py:2009 · class Server
Python socketserver 的默认 request_queue_size 是 5。这是 listen(backlog) 的参数 —— 内核为已完成三次握手但尚未被 accept() 的连接准备的队列长度。
同时到达的连接超过 5 个,多余的被内核直接 RST。实测:
| 并发 | 失败数 |
|---|---|
| 30 | 0 |
| 60 | 8 个连接重置 |
| 100 | 31 个连接重置 |
一个 100 人的班同时提交,约三分之一交不上去,而且服务端日志一片空白。
修复:request_queue_size = min(512, socket.SOMAXCONN)。
📎 课堂讨论:画出 TCP 三次握手与 SYN 队列 / accept 队列的关系。 backlog 满了之后内核的行为(RST vs 丢弃 SYN)取决于什么? (提示:net.ipv4.tcp_abort_on_overflow)
🧪 实验建议:把 request_queue_size 改回 5,用 scripts/loadtest_judge.py --levels 60 复现,再改回来。这是能亲手复现的经典网络故障。
#5.2 修好一个瓶颈,会露出下一个
接上节:连接问题修好后,请求都进得来了,于是全挤在一起判题:
| 100 并发 | AC | 失败 |
|---|---|---|
| 无并发闸 | 84 | 16(含 5 个 TLE 误判) |
| 并发闸 = 核数(32) | 100 | 0 |
判题是 CPU 密集的:并发超过核数不提高吞吐,只拉长墙钟 —— 而墙钟正是超时判据。超售换来的不是「慢一点」,而是正确代码被判 TLE, 还会作为真实裁定写进学生记录。
有意思的是:开闸后中位墙钟反而更短(18.3s vs 22.4s)—— 少了争抢,吞吐更好。
📎 课堂讨论:这是「利特尔法则」和「排队论」的直观演示。 为什么限制并发反而更快?(提示:上下文切换、缓存失效、内存带宽争抢。)
#5.3 HTTP 状态码不是装饰
锚点:server.py 的路由
| 场景 | 状态码 | 为什么 |
|---|---|---|
| 未登录提交 | 401 | 缺认证 |
| 非管理员改设置 | 403 | 已认证但无权限 |
| 题号不存在 | 404 | 资源不存在 |
| 超出限频 | 429 | 语义精确:Too Many Requests |
| 参数非法 | 400 | 客户端错误 |
⚠️ 真实观察:上游 cs101.openjudge.cn 对不存在的路径自己就返回 200(软 404)。 这直接影响了本项目的测试写法 —— 静态白名单测试必须断言响应内容而不是状态码, 因为未命中会落到上游代理,而代理的状态码不可信。这不是将就,是上游行为决定的。
📎 课堂讨论:软 404 对搜索引擎、监控、客户端重试逻辑各有什么危害?
#5.4 反向代理与 SSRF 边界
锚点:server.py 的上游回源分支
未命中本地镜像的 GET 会回源真实站点。两条纪律:
- 只带 User-Agent,不转发本地 cookie —— 否则学生的会话凭据会被送到第三方。
- 请求路径由用户控制,拼进 URL 时要问:能不能被引到别的主机?
🧪 实验建议:这是一次完整的 SSRF 检验演练。尝试构造 GET @evil.com/ HTTP/1.1,看 "http://cs101.openjudge.cn" + self.path 会拼出什么。 (本项目实测 urllib 不会被这个 userinfo 技巧引走,但这个结论是测出来的,不是想当然的。)
#6. 数据库与持久化
#6.1 参数化查询
锚点:server.py 全部 SQL
db.execute("select username from users where lower(username) = lower(?)", (username,))
全库没有一处字符串拼接 SQL。这是 SQL 注入防御的唯一正确姿势 —— 不是「过滤危险字符」,而是让数据永远不进入语法位置。
📎 课堂讨论:为什么「转义单引号」不是可靠方案? (提示:字符集、多字节编码、LIKE 通配符、标识符位置无法参数化。)
#6.2 在线备份:为什么不能用 cp
锚点:tools/backup_db.py · make_backup
with sqlite3.connect(f"file:{SOURCE}?mode=ro", uri=True) as src, \
sqlite3.connect(staging) as dst:
src.backup(dst)
服务一直在写库,cp 可能拷到写了一半的页。SQLite 的 Connection.backup() 走的是数据库自己的备份接口,能拿到一致快照。
#6.3 备份必须验证
同上文件 · verify():备份完立刻对备份文件本身跑 PRAGMA integrity_check, 再把关键表行数和源库比一遍。
一堆没验过的文件不叫备份,叫假设。
⚠️ 写测试时踩的坑,值得整节课:第一次做损坏测试用的是空库, 抹掉 60 字节后 integrity_check 照样返回 ok —— 因为那片是未使用的页空间。 差一点就得出「校验没用」的错误结论。换成有真实内容的库、抹掉整整一页才验出来。
损坏测试必须打在有真实内容的数据上。 这条写进了 tests/test_backup.py 的模块注释。
🧪 实验建议:用 tests/test_backup.py 的手法造一个库, 分别抹掉「未使用页」和「数据页」,观察 integrity_check 的不同反应。 理解 SQLite 的页结构与 freelist。
#6.4 恢复演练
锚点:docs/管理员手册.md §5
备份链路做完后做过一次真实恢复演练:假装主机没了,从异地拉回备份 → 校验 → 还原 → 核对 8 个账号一个不少。
一份从没恢复过的备份,只是一个假设。
📎 课堂讨论:RPO(能丢多少数据)与 RTO(多久能恢复)分别是多少? 每日备份的 RPO 是 24 小时 —— 考试当天这意味着什么?
#7. 计算机安全
本章每一节都对应一个真实修复过的漏洞。 建议按顺序讲, 它们串起来是一部「一个系统如何逐步变安全」的实录。
#7.1 口令哈希:盐为什么必须每人不同
锚点:server.py:1284 · password_hash
⚠️ 修复前的代码:
hashlib.pbkdf2_hmac("sha256", password.encode(), b"cs101-local-user", 120000)
# ^^^^^^^^^^^^^^^^^^ 全库共用,且写在源码里
三重后果:
- 同口令 → 同哈希。谁和谁用了一样的密码,扫一眼库就知道。
- 盐是公开的(源码在 GitHub),且全库只有一份 → 一张彩虹表打穿所有账号。
- 这个洞之上,
data/course.db一度可以直接从 HTTP 下载(见 §7.5)。
修复后:每用户 16 字节随机盐,存成 pbkdf2$轮数$盐$哈希。 老格式仍能登录并在下次登录时透明升级 —— 否则升级当天全班登不进来。
🧪 实验建议:算两次同口令的哈希,观察修复前后的差异。 估算一张针对固定盐的彩虹表需要多少存储(提示:120000 轮 PBKDF2 让这件事变贵,但不是不可能)。
#7.2 恒定时间比较
锚点:server.py:1304 · valid_password
return hmac.compare_digest(candidate, digest) # 不是 ==
== 在第一个不同字节就返回,耗时泄露了「猜对了多少前缀」。 compare_digest 无论如何都比完全部字节。
📎 课堂讨论:对 hex 摘要做时序攻击在网络上现实吗? (多数情况不现实 —— 但这是一行代码的成本换掉一整类攻击面,没有理由不换。)
#7.3 改密码必须能踢人下线
锚点:server.py:1358 · revoke_sessions
⚠️ 修复前实测:拿着被盗的 cookie,受害者改完密码后照样能用。
「号被盗了赶紧改密码」是所有人的直觉补救,而这个系统里的改密根本不构成补救。 现在改密/重置会吊销该用户的其它会话(保留当前这条,免得把用户自己踢出去)。
📎 课堂讨论:无状态 JWT 怎么实现「立即吊销」?(这正是 JWT 的经典难题。)
#7.4 限频的位置本身是安全语义
锚点:server.py 的 /api/user/forgot 分支
找回密码接口故意做到「邮箱存不存在都回同一个 {ok:true}」,以免泄露谁注册过。
⚠️ 如果按最自然的写法把限频放在查库之后(先查到人,再决定发不发信), 那么只有真实邮箱会被限住 —— 429 本身就成了「这个邮箱注册过」的信号, 你新加的防护把原有的保护拆穿了。
所以限频必须写在查库之前,按请求里写的邮箱计数,存不存在都记一笔。
📎 课堂讨论:这是「旁路信道」的绝佳入门例子 —— 泄露不来自返回内容, 而来自行为差异(时序、状态码、是否限频)。还能举出哪些?
#7.5 fail-open:故障时该关门还是开门
⚠️ 两个同形的真实缺陷:
未配 SMTP 时,找回密码接口会把重置链接直接返回给匿名调用者。 不是「泄露谁注册过」,是知道某人邮箱就能接管他的账号。
触发条件是悄悄降级:SMTP 没配、变量名打错、新克隆缺配置文件, 都会掉进这条路径,没有任何告警。
出故障的方向应该是把权限关上,而不是放开。
修复:必须显式设 CS101_SHOW_ACCOUNT_LINKS=1 才返回链接,否则只写日志。 注册的激活链接同形,一并收紧。
📎 课堂讨论:还能想到哪些常见的 fail-open? (提示:鉴权服务超时后「放行」;证书校验失败后「继续」;WAF 挂了「直连」。)
#7.6 静态文件分发:.gitignore 挡的是 git,不是 HTTP
⚠️ 修复前:静态兜底路由是 ROOT / 用户给的路径,判据只有 ROOT in file.parents —— 那防的是「逃出 ROOT」,防不住「ROOT 里的东西不该全公开」。实测:
GET /data/course.db → 200,整个 SQLite 库(口令哈希 + 全部提交记录)
data/.admin_password 走的是同一条路径。这三个文件都在 .gitignore 里 —— 但 .gitignore 是给 git 看的,HTTP 不认它。
修复:白名单 —— 只发首页和 static/ 下的白名单后缀。白名单而非黑名单: 排除法每加一种新文件就要记得再排一次,迟早漏掉一个。
🧪 实验建议:这是最适合动手的一节。把白名单改回旧逻辑, 用 curl 把数据库拖下来,再用 sqlite3 打开它看看里面有什么。 然后思考:为什么这个洞存在了很久没人发现?(因为没人去看。)
#7.7 限频要按什么计数
锚点:server.py:178 · quota_retry_after
| 端点 | 计数依据 | 为什么 |
|---|---|---|
| 运行/提交 | 用户名 | 已登录,有身份 |
| 登录失败 | 用户名 | 按 IP 会把整个机房/班级一起锁掉 |
| 注册/找回密码 | 来源地址 | 未登录,没有身份可依据 |
⚠️ 按 IP 计数的风险不是理论:加上注册限频后整个测试套件立刻变红 —— 46 个用例全从 127.0.0.1 注册,20 次/10 分钟当场用尽。 那正是一间教室后面一个 NAT 的样子。默认因此放宽到 100 次/10 分钟,并做成管理页可调。
📎 课堂讨论:如果攻击者有一个 IP 池,按 IP 限频还有意义吗? 限频到底在防什么 —— 精准攻击还是规模化滥用?
#8. 软件工程:如何知道自己没错
这一章讲的不是「怎么写代码」,而是怎么知道自己写对了。 本项目在这方面有大量成文纪律,全部来自真实教训。
#8.1 标准立了但没有机械判据,等于没立
「判题沙箱未放宽」这句话,曾经在十几轮交接文档里手写声明,从来没人能验。
现在它是 tests/test_judge.py · SandboxContractTests:限额值逐项钉死, 并且验证每一条执行路径都真的经过 _limits。改任何一项都会红。
📎 课堂讨论:你的项目里有哪些「声明」是没有机械判据的? (「我们不会记录密码」「所有接口都鉴权了」……怎么把它们变成会失败的检查?)
#8.2 变异自检:证明测试真的会红
一条通过的测试什么也没证明 —— 除非你见过它失败。本项目的纪律是: 每加一条重要断言,就故意把代码改坏,确认它变红。
⚠️ 两个前提,缺一不可(都是被真实失败换来的):
- 基线必须是绿的。 曾经做变异自检时两条都红,差点据此下结论 ——
回头发现补丁脚本因断言失败根本没写入,基线本身就是红的。那次自检什么也没证明。
- 变异必须真的改变行为。 曾出现「空操作变异」:替换的代码落在别题分支或死代码上,
0/21 组生效,测试当然不红。
#8.3 「永远不会红的检查」
这是本项目反复抓到的同一种失败形状,值得单独讲:
| 案例 | 表现 |
|---|---|
| 约束反例记成占位串 | 反例是 deliberate invalid input,永远不会翻红 |
约束退回 input is present |
这个谓词对任何输入都成立 |
| 样例首行安全检查 | 检查的是截断后的输出,而截断恰好把它要防的情况削成空串 |
最后一条尤其精妙:那个检查要防「输出段首行是 #」, 但它调用的解析函数默认会截断 # 之后的内容 —— 于是首行永远不以 # 开头,检查永远不会红。 种一道真实的缺陷题进去:修复前零告警,修复后正确报出。
📎 课堂讨论:怎么系统性地发现「永不会红的检查」? (提示:变异测试、覆盖率、故意种缺陷 / fault injection。)
#8.4 点名风险会让没被点名的地方无人复核
⚠️ 一次很有教育意义的失败:
重构 judge.py 的 prepare_program 时,用正则批量把 return {...} 改成 return None, {...}。 正则锚在行首,而 gcc/g++ 分支那句写成一行 if rc: return {...} —— 漏改了。
后果不是「裁定不对」,而是服务端 500:调用方按 command, failure = ... 解包一个 两键字典,拿到的是它的键,judge() 最终返回字符串 "message"。 C/C++ 编译错误的学生拿不到任何裁定,线上生效了整整一轮。
三层防线为什么都没拦住:
- 测试:编译错误用例只覆盖 python/pypy3 —— 两者走解释器分支,而那条分支改对了。
编译型语言分支一条编译错误用例都没有。
- 作者自审:我确实做了逐行 diff —— 但只 diff 了 .NET 那段,
因为我判断「.NET 本机没装 SDK、测不到」是最大风险。
- 同伴复核:复核者回报「.NET 分支逐行核对无漂移」—— 这句完全正确,
它核的正是被点名的那一段。
「我最没把握的是 X」这句话,会让 X 之外的地方无人复核。
修复后补了两条用例:一条覆盖每一类语言的编译错误; 一条走 AST 检查每条 return 都是二元组 —— 特意不用文本匹配, 因为原缺陷就是文本形状造成的,用「行首是不是 return」去防它会连同一个盲点一起继承。
📎 课堂讨论:代码评审时,如何对抗「注意力被引导」? (提示:checklist、随机抽查、结对时交换关注区、自动化覆盖率门槛。)
#8.5 静默跳过是最危险的通过
tests/test_judge.py 里 .NET / Swift / Objective-C 的用例都带 @skipUnless(shutil.which(...)) —— 在没装工具链的机器上静默跳过。 闸门全绿,从不代表那几条分支被验过。
⚠️ 真实后果:改用 systemd 托管后,PyPy3 静默失效了(见 §9.2)。 单元测试跳过、冒烟脚本也跳过 —— 没有任何地方会报告某种语言停止工作了。
修复:scripts/smoke_languages.py --require-all,工具链缺失算失败而不是跳过, 并绑进发版流程。
📎 课堂讨论:skip 和 fail 的语义差别?CI 里应该怎么对待 skip 数量的变化?
#8.6 协作脚手架
锚点:collab/
两个 AI(Claude 与 Codex)交替开发本项目,靠的不是记忆而是共享事实源:
| 文件 | 作用 |
|---|---|
PLAN.md |
唯一任务清单 + 决策记录 |
HANDOFF.md |
每次「我做完了,轮到你」的交接记录 |
NOTES-*.md |
双方留给对方的话 |
交接硬性要求:必须附一次真正跑完的验证结果,不接受「我觉得没问题」。
📎 课堂讨论:这套东西和人类团队的 code review / RFC / 事故复盘有什么异同? 为什么「书面痕迹」在多方协作中不可替代?
#9. 系统运维与可靠性
#9.1 「部署成功」和「配置生效」是两件事
⚠️ 真实事故:给 systemd 单元加 LimitNOFILE 时用了朴素字符串替换, 而 Restart=on-failure 这段文字同时出现在一条注释里,替换命中了注释那处。 结果 LimitNOFILE 落进了 [Unit](systemd 直接忽略)。
服务照常 active、冒烟全过、限制根本没生效。
systemd 在 journal 里说得清清楚楚:
cs101.service:10: Unknown key name 'LimitNOFILE' in section 'Unit', ignoring.
但没有任何东西逼人去看。现在有 tests/test_units.py 解析每个单元, 检查指令在不在能生效的 section 里。
这个测试的第一版犯了它要防的同一个错:断言原文不含 StandardOutput=append:, 结果命中的正是那句「不要用它」的注释。已改为只比对解析出来的指令。
📎 课堂讨论:配置管理里「声明了」与「生效了」的鸿沟还有哪些形态? (Kubernetes 的 spec vs status;防火墙规则顺序;被后一条覆盖的配置。)
#9.2 换部署方式会悄悄改变程序能看见什么
⚠️ 真实事故:从「tmux 里手工 nohup」改为 systemd 托管后, PyPy3 静默失效。代码一行没动 —— 但 systemd 不继承登录 shell 的 PATH, 而 pypy3 装在 ~/.local/bin,判题器 shutil.which("pypy3") 就找不到它了。
学生选 PyPy3 只会得到 Language Unavailable,而 PyPy3 正是 Python 超时时 唯一的补救档(倍率 ×3 对 ×10)。
📎 课堂讨论:还有哪些「环境差异」会让同一份代码行为不同? (locale、时区、umask、cgroup 限制、容器里的 /proc。)
#9.3 自动恢复不该等于无人知晓
Restart=on-failure 会把服务拉起来 —— 好事,但也让崩溃循环变成静默的。 OnFailure= 因此挂上告警。
一个从没响过的告警,就是一个假设。
#9.4 压测方法论
⚠️ 第一次压测得出了完全相反的结论(「并发闸更差」),原因是各档之间只 sleep 3 秒, 上一档没跑完的判题混进了下一档。证据是高并发档出现了 Busy —— 而每档用的都是不同账号,Busy 只可能来自上一档的残留。
修正后的纪律:
- 每档之间等系统排空,不是 sleep 固定秒数
- 开测前逐项核对前置条件(backlog、fd 上限、起始 fd 数),不满足就拒测
- 提交的一律是能 AC 的代码,任何非 Accepted 都是被测的误判
如果压测本身不可信,基于它的结论也不可信。
🧪 实验建议:这是一次完整的性能实验设计课。让学生先预测「100 人同时提交会怎样」, 再实测,最后解释预测与实测的差异。(多数人会预测 CPU 不够 —— 实际先撞的是 backlog=5。)
#10. 课程实验与作业设计
#10.1 入门级(计算概论 / 程序设计)
- 读懂裁定:给出 5 份有缺陷的代码,让学生预测判定结果(WA/TLE/RE/CE),
再提交验证。重点理解 token 比对与语言倍率。
- 卡在限时上:同一道题分别用 Python 与 PyPy3 提交同一份代码,
解释为什么倍率是 ×10 和 ×3,以及什么情况下换语言救不了(算法复杂度问题)。
#10.2 数据结构与算法
- 实现一个词法高亮器:参照
submit.html · scan,为一门新语言(如 Java)加高亮规则。 - 括号匹配两种解法:分别用栈和深度计数实现,比较空间复杂度与适用场景。
- 滑动窗口限频器:实现并用单元测试证明「窗口真的会滑动」——
这一条比想象中难,见 §3.3 的真实教训。
#10.3 操作系统
- 打破沙箱:在
_limits的三条限制下,尝试写出能耗尽资源的程序。
(提示:fork 炸弹会怎样?RLIMIT_NPROC 为什么没设?这是一个开放问题。)
- 孤儿进程回收:改掉
KillMode,观察停服后残留的判题进程。
#10.4 计算机网络
- 复现 backlog 故障:把
request_queue_size改回 5,用压测脚本复现连接重置,
用 ss -tln 观察 Send-Q,再改回来。
- 抓包看握手:用
tcpdump观察 accept 队列满时内核发出的 RST。
#10.5 计算机安全
- 拖库演练:把静态白名单改回旧逻辑,用
curl下载data/course.db,
用 sqlite3 打开它。然后写一份「事故报告」。
- 旁路泄露:给找回密码接口把限频挪到查库之后,
写一个脚本通过 429 与否枚举出哪些邮箱已注册。
- 彩虹表估算:针对固定盐 + PBKDF2 120000 轮,估算破解 8 位数字密码的成本。
#10.6 软件工程(推荐作为期末项目)
- 种缺陷 / 找缺陷:两人一组,各自在对方的代码里种一个缺陷,
看对方的测试套件能不能抓住。抓不住的,一起写出能抓住的测试。
- 给一条「声明」加机械判据:从项目里挑一条只存在于文档里的承诺,
把它变成一条会失败的测试。
#11. 术语表与延伸阅读
| 术语 | 含义 | 本项目锚点 |
|---|---|---|
| token 比对 | 按空白切分后逐项比较 | judge.py · judge() |
| 沙箱 | 限制不可信代码能力的执行环境 | judge.py · _limits |
| backlog | listen() 的 accept 队列长度 |
server.py · Server |
| 滑动窗口限频 | 按最近时间窗内的请求数限流 | server.py · quota_retry_after |
| 恒定时间比较 | 耗时与内容无关的比较 | hmac.compare_digest |
| fail-open / fail-closed | 故障时放开 / 关上权限 | §7.5 |
| 旁路信道 | 通过行为差异而非返回内容泄露信息 | §7.4 |
| 变异自检 | 故意改坏代码以验证测试会红 | §8.2 |
| 机械判据 | 能自动执行、会失败的检查 | §8.1 |
| RPO / RTO | 可容忍的数据丢失 / 恢复时长 | §6.4 |
延伸阅读
- docs/管理员手册.md —— 部署、配置、排障
- docs/用户手册.md —— 学生视角
- collab/PLAN.md —— 全部事故与决策的原始记录,本手册的一手来源
- README.md —— 数据构建与验证流程
本手册描述的是一个真在跑、真有学生在用的系统。 它的每一个安全结论背后都有一次真实的失败,每一条工程纪律都是被踩出来的。 如果读完只记住一句话,希望是这句: 一个从没失败过的检查,和没有检查是一样的。