必修 1:不配对括号字面量把 r2 的「全文净深度合并」带偏,文件尾部永久不可见
- r2 用「方括号净深度」决定续行合并,但 ['[', '(']、"[2"、starts_with("[2") 这类字面量
会让深度永久偏离 0(实测 601 文件里 10 个扫到 EOF 仍非 0),于是该文件后面的一切都被跳过:
在 gate.rs 尾部追加作用域 allow,r2 门禁 exit 0(假绿)
- 换成逐字符词法器:普通 / 行注释 / 块注释(可嵌套)/ 字符串 / raw 字符串 / 字符字面量,
只在普通态认 `#` 或 `#!` 紧跟 `[` 的属性起点,只在这一个属性内部累计到 `]` 配平;
数组字面量跨行不再参与合并
必修 2:`#![expect(unsafe_code)]` 既绕过门禁也绕过 workspace 的 deny
- 判据从 allow 放宽到 (allow|expect):expect 同样放行 unsafe(rustc -D unsafe_code 实测通过)
必修 3:块注释里的属性文本被当成第二个落点(与「注释刻意不算」的承诺相反)
- 词法器带块注释状态机(含嵌套),行注释/块注释/字符串里的属性文本都不算
顺带
- 已知边界对齐事实:宏体里直接写出的属性**会**被扫到;真正漏的是「宏在别处定义、这里只调用」;
补两条:注释/字符串里的属性文本刻意不算、manifest 侧 [lints.rust] unsafe_code 不在扫描面
- 候选文件用 git grep -l unsafe_code 预筛(标识符不会被空白拆开,不会漏),整个 job 约 1~2 秒
337 lines
19 KiB
YAML
337 lines
19 KiB
YAML
name: Guards
|
||
|
||
# 架构与合规守门(与 fmt/clippy 同级,不允许 continue-on-error):
|
||
# 1. core-no-ui —— crates/core 零 UI 依赖(D3 铁律),依赖表 + cargo tree 双查
|
||
# 2. presenter-no-ui —— crates/presenter 零 UI 依赖(D26 / M85):Slint 版与 iced 版共用的宿主层
|
||
# 3. design-zero-dep —— crates/design 零第三方依赖(M61 / M87):依赖表(含 dev/build)必须为空 + 依赖树只允许出现自身
|
||
# 4. file-size —— 全仓代码/文档单文件 ≤400 行(用户硬约束)
|
||
# 5. licenses —— cargo-deny:许可证白名单 + GPL 依赖拦截 + 供应链来源校验
|
||
# 6. unsafe-scope —— 全仓 `allow(unsafe_code)` 的文件集合必须恰好只有一个(D36 / M91)
|
||
|
||
on:
|
||
push:
|
||
branches: [main]
|
||
pull_request:
|
||
workflow_dispatch:
|
||
|
||
env:
|
||
CARGO_TERM_COLOR: always
|
||
|
||
jobs:
|
||
core-no-ui:
|
||
name: core 零 UI 守门
|
||
runs-on: ubuntu-latest
|
||
steps:
|
||
- uses: actions/checkout@v4
|
||
- uses: dtolnay/rust-toolchain@stable
|
||
- name: 依赖表直查(crates/core/Cargo.toml 不得出现 UI 框架)
|
||
run: |
|
||
set -uo pipefail
|
||
forbidden='slint|egui|eframe|iced|winit|gtk|javafx|tauri|fltk|druid|relm'
|
||
# 只看非注释行:manifest 里说明「不许出现 slint」的注释本身不该触发守门
|
||
if grep -vE '^[[:space:]]*#' crates/core/Cargo.toml | grep -nEi "$forbidden"; then
|
||
echo "::error file=crates/core/Cargo.toml::core 不得依赖 UI 框架(见 AGENTS.md 硬约束)"
|
||
exit 1
|
||
fi
|
||
echo "OK: crates/core/Cargo.toml 无 UI 依赖"
|
||
- name: 依赖树泄漏检查(cargo tree -p unlucky-core,排除自身行)
|
||
run: |
|
||
set -uo pipefail
|
||
forbidden='slint|egui|eframe|iced|winit|gtk|javafx|tauri|fltk|druid|relm'
|
||
tree=$(cargo tree -p unlucky-core --edges normal --prefix none)
|
||
echo "$tree"
|
||
if echo "$tree" | grep -vE '^unlucky-core v' | grep -qiE "$forbidden"; then
|
||
echo "::error::core 的依赖树出现 UI 框架 —— 传递依赖也不允许"
|
||
exit 1
|
||
fi
|
||
echo "OK: unlucky-core 依赖树零 UI"
|
||
|
||
# presenter 是 Slint 版(crates/app)与 iced 版(crates/app-iced)共用的唯一业务层(D26)。
|
||
# 一旦 slint/iced/winit 被拉进来,分层就废了,而编译与单测仍会全绿 —— 所以必须由 CI 守。
|
||
# 与 core-no-ui 同样是「依赖表 + cargo tree」双查,名单保持一致;脚本刻意复制而非抽公共脚本,
|
||
# 理由是 CI 守门脚本自身挂掉的排查成本远高于这几行重复。
|
||
presenter-no-ui:
|
||
name: presenter 零 UI 守门
|
||
runs-on: ubuntu-latest
|
||
steps:
|
||
- uses: actions/checkout@v4
|
||
- uses: dtolnay/rust-toolchain@stable
|
||
- name: 依赖表直查(crates/presenter/Cargo.toml 的依赖表不得出现 UI 框架)
|
||
run: |
|
||
set -uo pipefail
|
||
forbidden='slint|egui|eframe|iced|winit|gtk|javafx|tauri|fltk|druid|relm'
|
||
# 与 core-no-ui 的整文件 grep 不同:本 crate 的 description 合法地写着
|
||
# 「Slint 版与 iced 版共用」,整文件 grep 会把它当越线。所以先按表头切出依赖表
|
||
# (含 target.*.dependencies 与 dev/build 表),再去掉注释行,只查键名。
|
||
# 子表写法 `[dependencies.slint]` / `[target.'cfg(windows)'.dependencies.slint]`
|
||
# 是合法的等长写法,且表头本身带着包名,所以表头也要一并落进检查范围——
|
||
# 否则「子表 + optional + 非默认 feature」这种组合能同时骗过依赖表与 cargo tree。
|
||
awk '
|
||
/^[[:space:]]*\[/ {
|
||
in_deps = ($0 ~ /^[[:space:]]*\[(target\..*\.)?(dependencies|dev-dependencies|build-dependencies)(\.[^]]*)?\][[:space:]]*$/)
|
||
if (in_deps && $0 ~ /\.[^]]*\]/) printf "%d:%s\n", NR, $0
|
||
next
|
||
}
|
||
in_deps && !/^[[:space:]]*#/ && !/^[[:space:]]*$/ { printf "%d:%s\n", NR, $0 }
|
||
' crates/presenter/Cargo.toml > /tmp/presenter-deps.txt
|
||
cat /tmp/presenter-deps.txt
|
||
if grep -iE "$forbidden" /tmp/presenter-deps.txt; then
|
||
echo "::error file=crates/presenter/Cargo.toml::presenter 不得依赖 UI 框架(D26)。窗口创建 / 渲染 / 图片解码属于 app 与 app-iced 侧;需要跨前端共用的类型请放进 presenter 的 dto。"
|
||
exit 1
|
||
fi
|
||
echo "OK: crates/presenter/Cargo.toml 依赖表无 UI 依赖"
|
||
- name: 依赖树泄漏检查(cargo tree -p unlucky-presenter,排除自身行)
|
||
run: |
|
||
set -uo pipefail
|
||
forbidden='slint|egui|eframe|iced|winit|gtk|javafx|tauri|fltk|druid|relm'
|
||
# 注意:cargo tree 命中 UI 框架时**退出码仍是 0**,所以只能抓输出再判断。
|
||
if ! tree=$(cargo tree -p unlucky-presenter --edges normal --prefix none); then
|
||
echo "::error::cargo tree -p unlucky-presenter 执行失败 —— crate 是否仍叫 unlucky-presenter?"
|
||
exit 1
|
||
fi
|
||
echo "$tree"
|
||
hits=$(printf '%s\n' "$tree" | grep -vE '^unlucky-presenter v' | grep -iE "$forbidden")
|
||
if [ -n "$hits" ]; then
|
||
echo "::error::presenter 的依赖树出现 UI 框架(传递依赖也不允许),本次命中:"
|
||
printf '%s\n' "$hits" | sed 's/^/ - /'
|
||
echo "::error::presenter 是 Slint 版与 iced 版共用的宿主层:把窗口 / 渲染 / 图片解码留在 app 侧,presenter 只持有与框架无关的 DTO、状态 reducer 与 host 装配。"
|
||
exit 1
|
||
fi
|
||
echo "OK: unlucky-presenter 依赖树零 UI"
|
||
|
||
# unlucky-design 是「纯数据 + 纯函数、零第三方依赖」的设计令牌层,这是 README 明写的卖点
|
||
# (连 serde 都不加),但目前只靠人肉守。零依赖的判据比 UI 名单更硬:依赖树只允许有自己一行。
|
||
# 注意 `cargo tree --edges normal` 看不到 dev/build 依赖,所以另设一步按表头切依赖表逐个查空。
|
||
design-zero-dep:
|
||
name: design 零第三方依赖守门
|
||
runs-on: ubuntu-latest
|
||
steps:
|
||
- uses: actions/checkout@v4
|
||
- uses: dtolnay/rust-toolchain@stable
|
||
- name: 依赖表必须为空(dependencies / dev / build 表一律不许有键,含子表写法)
|
||
run: |
|
||
set -uo pipefail
|
||
# design 承诺的是「零第三方依赖」,不分 normal / dev / build —— 而 cargo tree
|
||
# --edges normal 对 dev/build 是瞎的。所以先按表头切出全部依赖表(含
|
||
# `[dependencies.serde]` 这类子表写法,表头本身携带包名),再看是否为空。
|
||
awk '
|
||
/^[[:space:]]*\[/ {
|
||
in_deps = ($0 ~ /^[[:space:]]*\[(target\..*\.)?(dependencies|dev-dependencies|build-dependencies)(\.[^]]*)?\][[:space:]]*$/)
|
||
if (in_deps && $0 ~ /\.[^]]*\]/) printf "%d:%s\n", NR, $0
|
||
next
|
||
}
|
||
in_deps && !/^[[:space:]]*#/ && !/^[[:space:]]*$/ { printf "%d:%s\n", NR, $0 }
|
||
' crates/design/Cargo.toml > /tmp/design-deps.txt
|
||
if [ -s /tmp/design-deps.txt ]; then
|
||
cat /tmp/design-deps.txt
|
||
echo "::error file=crates/design/Cargo.toml::unlucky-design 的依赖表必须为空(normal / dev / build 都算),本次命中:"
|
||
sed 's/^/ - /' /tmp/design-deps.txt
|
||
echo "::error::design 是纯数据 + 纯函数的设计令牌,README 对外承诺零第三方依赖(连 serde 都不加):色彩 / 排版 / 形状 / 高程 / 动效请不要引入任何 crate,测试与构建辅助依赖同样不许加。序列化与运行时能力放到消费方(app / app-iced / presenter)。"
|
||
exit 1
|
||
fi
|
||
echo "OK: crates/design/Cargo.toml 依赖表全空(含 dev / build)"
|
||
- name: 依赖树只允许出现自身(cargo tree -p unlucky-design --edges normal)
|
||
run: |
|
||
set -uo pipefail
|
||
if ! tree=$(cargo tree -p unlucky-design --edges normal --prefix none); then
|
||
echo "::error::cargo tree -p unlucky-design 执行失败 —— crate 是否仍叫 unlucky-design?"
|
||
exit 1
|
||
fi
|
||
echo "$tree"
|
||
# 空行不算,剩下的每一行都必须是自己:正常时整个输出只有一行
|
||
# `unlucky-design v0.1.0 (/path/to/crates/design)`。
|
||
lines=$(printf '%s\n' "$tree" | grep -cE '[^[:space:]]' || true)
|
||
others=$(printf '%s\n' "$tree" | grep -vE '^[[:space:]]*$' | grep -vE '^unlucky-design v' || true)
|
||
if [ -n "$others" ] || [ "$lines" -ne 1 ]; then
|
||
echo "::error file=crates/design/Cargo.toml::unlucky-design 必须零第三方依赖,本应只有自身一行,实际 $lines 行;越线的包:"
|
||
printf '%s\n' "$others" | sed 's/^/ - /'
|
||
echo "::error::design 是纯数据 + 纯函数的设计令牌:色彩 / 排版 / 形状 / 高程 / 动效请不要引入 serde、rand、once_cell 等任何 crate。序列化与运行时能力放到消费方(app / app-iced / presenter)。"
|
||
exit 1
|
||
fi
|
||
echo "OK: unlucky-design 依赖树只有自身,零第三方依赖"
|
||
|
||
# D36 定案:整窗 α 是全仓**唯一**需要 `unsafe` 的能力(Win32 的
|
||
# `SetLayeredWindowAttributes` 没有安全替代),落点收在一个文件里。
|
||
# 判据必须是**集合相等**而不是「黑名单命中」:
|
||
# - 新增第二个显式放行 unsafe 的文件 → 红(黑名单写法在这里会假绿);
|
||
# - 落点文件把放行删掉 / 改名 / 搬走 → 红(否则门禁守着一条已经不存在的路径);
|
||
# - 多行属性、同行合并属性、多行 `cfg_attr`、`expect(unsafe_code)` 都已覆盖
|
||
# (r2 前的行级正则对前两种会假绿,当时只能靠 ci.yml 的 fmt job 兜底;r3 起由词法器直接抓)。
|
||
unsafe-scope:
|
||
name: unsafe 落点守门(集合必须恰好一个文件)
|
||
runs-on: ubuntu-latest
|
||
steps:
|
||
- uses: actions/checkout@v4
|
||
- name: 全仓显式放行 unsafe 的文件集合 == 唯一落点
|
||
run: |
|
||
set -uo pipefail
|
||
EXPECTED='crates/app-iced/src/platform/win32/transparency.rs'
|
||
# 扫描口径(M91 r3):一个小词法器,而不是正则。
|
||
#
|
||
# 为什么必须用词法器:r2 的写法是「按全文方括号净深度合并续行」,但 Rust 里
|
||
# `['[', '(']`、`"[2"`、`starts_with("[2")` 这类**不配对括号字面量**会让深度永久跑偏,
|
||
# 于是该文件后面的一切(包括一个真的 `#[allow(unsafe_code)]`)都再也不会被扫到——
|
||
# 实测本仓 601 个文件里有 10 个扫到 EOF 仍非 0,`gate.rs` 就是其中之一(r3 复审实证:
|
||
# 在它尾部加作用域 allow,r2 门禁 exit 0 假绿)。
|
||
# 现在的做法:逐字符状态机(普通 / 行注释 / 块注释(可嵌套)/ 字符串 / raw 字符串 /
|
||
# 字符字面量),只在「普通态」里认 `#` 或 `#!` 紧跟 `[` 的属性起点,然后只在这一个
|
||
# 属性内部累计到它的 `]` 配平——数组字面量跨行不再参与合并,也就不会带偏。
|
||
#
|
||
# 放行的两种拼法都算:`allow(...)` 与 `expect(...)`。`expect(unsafe_code)` 同样会
|
||
# 放行 unsafe 代码(rustc 实测:`#![expect(unsafe_code)]` + `pub unsafe fn f() {}` 在
|
||
# `-D unsafe_code` 下编译通过),所以它必须被同一条判据抓住,不能只认 allow。
|
||
#
|
||
# 仍然抓不住的(已知边界,别当全覆盖):
|
||
# - 落点文件**内部**多开别的 unsafe 能力(本门禁只看「有没有放行属性」,不看 unsafe 块个数);
|
||
# - **在别处定义、这里只调用**的宏展开出来的 allow:宏体里直接写出的属性在源码文本里,
|
||
# 会被扫到;但「宏定义在别的文件、调用点展开出 allow」时,本文件源码里没有那个属性;
|
||
# - 注释(行注释/块注释)与字符串(普通/raw)里的属性文本**刻意不算**:它们不构成放行;
|
||
# 代价是「真把 allow 写进字符串」这种写法不会被当作落点(它也确实不放行任何代码);
|
||
# - manifest 侧 `[lints.rust] unsafe_code = "allow"`:本门禁只扫 `*.rs`,看不见 Cargo.toml;
|
||
scan() {
|
||
awk -v path="$1" '
|
||
function lex( s, line, n, i, c, nx, state, depth, hashes, j, ok, k, ch, ch2, adepth, attr, q) {
|
||
q = "\047"
|
||
s = ""
|
||
while ((getline line < path) > 0) s = s line "\n"
|
||
close(path)
|
||
n = length(s)
|
||
state = 0; depth = 0; hashes = 0; found = 0
|
||
for (i = 1; i <= n; i++) {
|
||
c = substr(s, i, 1)
|
||
if (state == 1) { if (c == "\n") state = 0; continue }
|
||
if (state == 2) {
|
||
if (c == "/" && substr(s, i + 1, 1) == "*") { depth++; i++; continue }
|
||
if (c == "*" && substr(s, i + 1, 1) == "/") { depth--; i++; if (depth <= 0) state = 0; continue }
|
||
continue
|
||
}
|
||
if (state == 3) {
|
||
if (c == "\\") { i++; continue }
|
||
if (c == "\"" || c == "\n") state = 0
|
||
continue
|
||
}
|
||
if (state == 4) {
|
||
if (c == "\"") {
|
||
ok = 1
|
||
for (j = 1; j <= hashes; j++) if (substr(s, i + j, 1) != "#") ok = 0
|
||
if (ok) { state = 0; i += hashes }
|
||
}
|
||
continue
|
||
}
|
||
if (state == 5) {
|
||
if (c == "\\") { i++; continue }
|
||
if (c == q || c == "\n") state = 0
|
||
continue
|
||
}
|
||
if (c == "/" && substr(s, i + 1, 1) == "/") { state = 1; i++; continue }
|
||
if (c == "/" && substr(s, i + 1, 1) == "*") { state = 2; depth = 1; i++; continue }
|
||
if (c == "\"") { state = 3; continue }
|
||
if (c == q) {
|
||
nx = substr(s, i + 1, 1)
|
||
if (nx == "\\" || substr(s, i + 2, 1) == q) state = 5
|
||
continue
|
||
}
|
||
if (c == "r" || c == "b") {
|
||
j = i + 1
|
||
if (c == "b") {
|
||
if (substr(s, j, 1) == "\"") { state = 3; i = j; continue }
|
||
if (substr(s, j, 1) == q) { state = 5; i = j; continue }
|
||
if (substr(s, j, 1) != "r") continue
|
||
j++
|
||
}
|
||
hashes = 0
|
||
while (substr(s, j, 1) == "#") { hashes++; j++ }
|
||
if (substr(s, j, 1) == "\"") { state = 4; i = j; continue }
|
||
continue
|
||
}
|
||
if (c == "#") {
|
||
j = i + 1
|
||
if (substr(s, j, 1) == "!") j++
|
||
if (substr(s, j, 1) != "[") continue
|
||
attr = ""; adepth = 0; k = j
|
||
while (k <= n) {
|
||
ch = substr(s, k, 1)
|
||
if (ch == "\"") {
|
||
k++
|
||
while (k <= n) {
|
||
ch2 = substr(s, k, 1)
|
||
if (ch2 == "\\") { k += 2; continue }
|
||
if (ch2 == "\"") break
|
||
k++
|
||
}
|
||
} else if (ch == "[") { adepth++ }
|
||
else if (ch == "]") { adepth--; if (adepth == 0) break }
|
||
attr = attr ch
|
||
k++
|
||
}
|
||
if (attr ~ /(allow|expect)\([^]]*unsafe_code/) found = 1
|
||
i = k
|
||
continue
|
||
}
|
||
}
|
||
return found
|
||
}
|
||
BEGIN { exit(lex() ? 0 : 1) }
|
||
'
|
||
}
|
||
# 只对「文本里出现过 unsafe_code」的文件跑词法器(标识符不可能被空白拆开,
|
||
# 所以这个预筛不会漏)。601 个文件 → 个位数个候选,整个 job 约 2 秒。
|
||
actual=$(git grep -l 'unsafe_code' -- '*.rs' 2>/dev/null | LC_ALL=C sort | while IFS= read -r f; do
|
||
if scan "$f"; then printf '%s\n' "$f"; fi
|
||
done | LC_ALL=C sort)
|
||
echo "期望(恰好这一个):$EXPECTED"
|
||
echo "实际扫到的集合:"
|
||
if [ -n "$actual" ]; then printf '%s\n' "$actual" | sed 's/^/ - /'; else echo " (空)"; fi
|
||
if [ "$actual" != "$EXPECTED" ]; then
|
||
extra=$(printf '%s\n' "$actual" | grep -vxF "$EXPECTED" || true)
|
||
if [ -n "$extra" ]; then
|
||
echo "::error::多出来的 unsafe 落点(D36 只允许一个):"
|
||
printf '%s\n' "$extra" | sed 's/^/ - /'
|
||
fi
|
||
if ! printf '%s\n' "$actual" | grep -qxF "$EXPECTED"; then
|
||
echo "::error file=$EXPECTED::这个文件里没有显式放行 unsafe(allow / expect(unsafe_code))—— 落点被搬走/改名/删了放行?守门路径已失效。"
|
||
fi
|
||
echo "::error::整窗 α 的 FFI 只允许住在 $EXPECTED(D36)。需要新的 unsafe 能力请先立 mission:改这条守门 + 更新 D36 的定案,不要顺手放行。"
|
||
exit 1
|
||
fi
|
||
echo "OK: 全仓显式放行 unsafe 的文件恰好只有 $EXPECTED"
|
||
|
||
file-size:
|
||
name: 文件行数 ≤400 守门
|
||
runs-on: ubuntu-latest
|
||
steps:
|
||
- uses: actions/checkout@v4
|
||
- name: 检查单文件行数
|
||
run: |
|
||
set -uo pipefail
|
||
LIMIT=400
|
||
failed=0
|
||
while IFS= read -r f; do
|
||
case "$f" in
|
||
LICENSE|*/LICENSE|LICENSE-*|*/LICENSE-*) continue ;;
|
||
CHANGELOG*|*/CHANGELOG*) continue ;;
|
||
esac
|
||
n=$(wc -l < "$f")
|
||
if [ "$n" -gt "$LIMIT" ]; then
|
||
echo "::error file=$f::$f 有 $n 行,超过 $LIMIT 行上限,请按模块拆分"
|
||
failed=$((failed + 1))
|
||
fi
|
||
# 覆盖范围含脚本(*.py/*.sh):packaging/ 下的构建辅助脚本同样受 400 行约束;
|
||
# Cargo.lock 等机器生成文件刻意不在列(否则一生成就超限)
|
||
done < <(git ls-files '*.rs' '*.md' '*.slint' '*.yml' '*.yaml' '*.toml' '*.json' '*.py' '*.sh')
|
||
if [ "$failed" -ne 0 ]; then
|
||
echo "共 $failed 个文件超限"
|
||
exit 1
|
||
fi
|
||
echo "OK: 全部受检文件 ≤$LIMIT 行"
|
||
|
||
licenses:
|
||
name: 依赖许可与来源(cargo-deny)
|
||
runs-on: ubuntu-latest
|
||
steps:
|
||
- uses: actions/checkout@v4
|
||
- uses: EmbarkStudios/cargo-deny-action@v2
|
||
with:
|
||
# 只跑许可/禁用/来源三类,advisories 由后续安全轮次单独接管
|
||
command: check licenses bans sources
|