Skip to content

Commit c9bb1ee

Browse files
committed
fix: 堵住 id 注入、修复英文答案空转、让教学漂移可被 CI 检测
三个真缺陷,都不是理论风险: 1. 练习 id 注入。id 直接取自文件名,有两个危险去向:d2x 把它拼进 shell 命令,我们把它写进生成的 TOML([targets.<id>])。带反引号、`]` 或引号 的文件名在任一处都能越界 —— 对社区课程仓库来说,一个恶意 PR 文件名就 足以在任何跑 checker 的人机器上执行命令。 在 discovery 源头做白名单校验并拒绝,而不是在两个下游各自转义:这类 文件名本就是笔误或恶意,与其想办法安全地传递,不如让作者改名。 实测:`99-evil`touch pwned_marker`.cpp` 被拒绝,命令未执行。 2. e2e.sh 把所有英文参考答案静默 SKIP。前缀剥离顺序错了 —— `${sol#en/}` 执行时 sol 已经以 "solutions/" 开头,匹配不到任何东西,是个静默 no-op, 于是每道英文练习都因找不到 solutions/en/... 而跳过。 这正是本脚本头部注释里说要防的那种空转,和旧 CI 一模一样的毛病。 除了修顺序,另加一道防线:pass==0 时直接判失败,杜绝「0 失败」蒙混。 实测修复后 en 也是 51/51 真验证(此前是 0 通过 / 52 跳过)。 3. d2x_assert_eq 的日志分支仍用裸 std::to_string,而上报分支已改用 SFINAE 安全的 show()。std::to_string 没有 std::string / const char* / scoped enum 的重载,下一个比较字符串或强类型枚举的练习会直接编译失败 —— show() 存在的意义就是避免这个,却只用了一半。 教学漂移(04-rvalue-references): C++17 起 prvalue 直接初始化目标(保证复制省略),-fno-elide-constructors 再也无法让 `Object obj = Object();` 产生移动构造。全仓库改按 c++23 编译后, 这节课的核心观测点被静默抹掉。 改成从具名对象 std::move —— 这在任何标准下都必然调用移动构造。更重要的是 补了一条 d2x_assert(move_ctor_calls >= 1):漂移之所以能静默发生,正是因为 从前没有任何断言检查它,输出少一行没人发现。现在它是 CI 可检测的事实。
1 parent 8b01ee8 commit c9bb1ee

4 files changed

Lines changed: 59 additions & 8 deletions

File tree

d2x/buildtools/mcpp/src/discovery.cppm

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,10 @@
44
// 是 PR #1355:edition 同时写在 rustc 参数和 rust-project.json 里,两边漂移
55
// 酿成 bug。任何独立声明文件都是第二套真相源。这里的真相只有两处,且都
66
// 无法漂移:目录结构,和练习文件自己的头部注释。
7+
module;
8+
9+
#include <cstdio> // stderr
10+
711
export module d2x.provider.discovery;
812

913
import std;
@@ -108,6 +112,21 @@ std::string humanize(std::string_view topic) {
108112
return out;
109113
}
110114

115+
// 练习 id 直接来自文件名,而它有两个危险去向:
116+
// 1. d2x 把它拼进一条 shell 命令(`<provider> check <id>`)
117+
// 2. 我们把它写进生成的 TOML(`[targets.<id>]`)
118+
// 带反引号、`]`、引号或换行的文件名能在任一处越界。
119+
//
120+
// 在源头挡住比在两个下游各自转义更可靠 —— 课程里本就不该出现这种文件名,
121+
// 与其想办法安全地传递它,不如明确拒绝并让作者改名。
122+
export bool valid_id(std::string_view id) {
123+
if (id.empty()) return false;
124+
return std::ranges::all_of(id, [](unsigned char c) {
125+
return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z')
126+
|| (c >= '0' && c <= '9') || c == '-' || c == '_' || c == '.';
127+
});
128+
}
129+
111130
// 扫描练习根目录。lang=zh 用 dslings/,lang=en 用 dslings/en/。
112131
export std::vector<Exercise> scan(const fs::path& repo_root, std::string_view lang) {
113132
fs::path base = repo_root / "dslings";
@@ -139,6 +158,15 @@ export std::vector<Exercise> scan(const fs::path& repo_root, std::string_view la
139158
ex.title = humanize(stem);
140159
ex.order = std_rank(std_dir) * 100'000;
141160
}
161+
162+
// 拒绝而不是转义:这类文件名是课程作者的笔误或恶意 PR,
163+
// 静默接受只会把问题推到下游。
164+
if (!valid_id(ex.id)) {
165+
std::println(stderr,
166+
"d2x-buildtools-mcpp: 跳过 '{}' —— 练习 id 只允许 [A-Za-z0-9._-]",
167+
ex.file.string());
168+
return;
169+
}
142170
found.push_back(std::move(ex));
143171
};
144172

d2x/buildtools/mcpp/tests/e2e.sh

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -44,9 +44,14 @@ for line in "${LINES[@]}"; do
4444
id=$(printf '%s' "$line" | grep -o '"id":"[^"]*"' | head -1 | cut -d'"' -f4)
4545
file=$(printf '%s' "$line" | grep -o '"files":\["[^"]*"' | head -1 | cut -d'"' -f4)
4646

47-
rel="${file#"$REPO_ROOT"/}" # dslings/cpp11/xx.cpp
48-
sol="solutions/${rel#dslings/}" # solutions/cpp11/xx.cpp
49-
sol="${sol#en/}" # en 目录共用同一份参考答案
47+
# en/ 必须先剥,再拼 solutions/ —— 顺序反了的话 sol 已经以 "solutions/"
48+
# 开头,`${sol#en/}` 匹配不到任何东西,是个静默 no-op,结果所有英文练习
49+
# 都因为找不到 solutions/en/... 而被 SKIP,测试全绿却一个都没验。
50+
# 这正是本脚本头部注释里说要防的那种「空转」。
51+
rel="${file#"$REPO_ROOT"/}" # dslings[/en]/cpp11/xx.cpp
52+
rel="${rel#dslings/en/}" # en 镜像共用同一份参考答案
53+
rel="${rel#dslings/}" # cpp11/xx.cpp
54+
sol="solutions/${rel}" # solutions/cpp11/xx.cpp
5055

5156
# 1) 未完成态必须不通过
5257
got=$(outcome_of "$id")
@@ -71,4 +76,13 @@ done
7176

7277
echo
7378
echo "==> 参考答案通过 $pass · 失败 $fail · 跳过 $skipped"
79+
80+
# 防空转:一个参考答案都没验到时必须红,而不是「0 失败」蒙混过关。
81+
# 旧 CI 就是这么绿了很久的 —— 它只挑 -ref 目标,而 solutions/ 早被注释掉,
82+
# 循环一次都没进,job 照样退出 0。
83+
if [ "$pass" -eq 0 ]; then
84+
echo "FAIL: 没有验证到任何参考答案 —— 测试本身失效了,不是「全部通过」"
85+
exit 1
86+
fi
87+
7488
[ "$fail" -eq 0 ]

dslings/harness/include/d2x/cpp/common.hpp

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,10 +51,10 @@ inline std::string show(const T& v) {
5151
__FILE__, __LINE__); \
5252
if (!d2x_ok_) {\
5353
HONLY_LOGW("❌ | %s == %s (%s == %s)", \
54-
#a, #b, std::to_string(a).c_str(), std::to_string(b).c_str()); \
54+
#a, #b, d2x::detail::show(a).c_str(), d2x::detail::show(b).c_str()); \
5555
} else {\
5656
HONLY_LOGI_P("✅ | %s == %s (%s == %s)", \
57-
#a, #b, std::to_string(a).c_str(), std::to_string(b).c_str()); \
57+
#a, #b, d2x::detail::show(a).c_str(), d2x::detail::show(b).c_str()); \
5858
} \
5959
}
6060

solutions/cpp11/04-rvalue-references.cpp

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313

1414
struct Object;
1515
static Object * object_address = nullptr;
16+
static int move_ctor_calls = 0; // 移动构造被调用的次数, 供断言检查
1617

1718
struct Object {
1819
int data = 0;
@@ -21,16 +22,21 @@ struct Object {
2122
object_address = this;
2223
}
2324
Object(const Object&) { std::cout << "Object(const Object&):" << this << std::endl; }
24-
Object(Object&&) { std::cout << "Object(Object&&):" << this << std::endl; }
25+
Object(Object&&) { ++move_ctor_calls; std::cout << "Object(Object&&):" << this << std::endl; }
2526
~Object() { std::cout << "~Object():" << this << std::endl; }
2627
};
2728

2829
int main() { // 关闭编译器优化
2930
{
3031
std::cout << "----> 临时对像 - 右值1" << std::endl;
3132
Object();
32-
std::cout << "----> 临时对像 - 右值2" << std::endl;
33-
Object obj = Object();
33+
std::cout << "----> 临时对像 - 右值2(具名对象 + std::move)" << std::endl;
34+
// 注意: 不能写 `Object obj = Object();` 来观察移动构造 ——
35+
// C++17 起 prvalue 直接初始化目标, 保证复制省略, 连
36+
// -fno-elide-constructors 也无法让那次移动发生。
37+
// 从具名对象 std::move 才是标准无关的观察方式。
38+
Object named;
39+
Object obj = std::move(named);
3440
(void)obj;
3541

3642
std::cout << "--------代码可修改区域-开始--------" << std::endl;
@@ -43,6 +49,9 @@ int main() { // 关闭编译器优化
4349
objRef.data = 1; // 修改被延长生命周期的临时对象的值(不要直接改动这行代码)
4450
std::cout << "objRef.data = " << objRef.data << " - " << &objRef << std::endl;
4551
d2x_assert((&objRef == object_address));
52+
// 钉住移动构造确实发生过。教学漂移之所以能静默发生, 正是因为
53+
// 从前没有任何断言检查它 —— 输出少了一行, 没人发现。
54+
d2x_assert((move_ctor_calls >= 1));
4655
}
4756

4857
return 0;

0 commit comments

Comments
 (0)