Skip to content

Commit cb7f3df

Browse files
committed
fix(build): verify staging by content, and route the last in-place copy through it
Windows CI caught two things the Linux-only self-checks structurally could not. 1. `rule runtime_alias` was a SECOND `Copy-Item -Force`. PE has no soname symlink, so an alias is a copy of a freshly built DLL — the same hazard as BMI staging (a program still running from a previous `mcpp run` holds the old one). Its Windows branch now goes through `$mcpp stage` too; POSIX keeps `ln -s`, where the symlink is semantics and not merely how the file is written. The 'no Copy-Item anywhere' assertion is a no-op on Linux, which is why only the Windows job could find this. 2. Size-only equivalence was unsound for exactly those DLL payloads: PE section padding makes 'genuinely rebuilt, identical size' ordinary, so a stale DLL could survive in the build dir. Content comparison is unconditionally correct — a destination equal to the source needs no write — and it only runs when ninja has already decided the edge is dirty. It is now the default; `--verify size` / MCPP_STAGE_VERIFY=size stays for callers that know the source is fingerprint-scoped, and an unrecognized value falls back to the SAFE mode rather than the fast one. Also from CI: a read-only destination is replaceable on POSIX (rename rewrites the directory entry) but not on Windows, so that test now asserts both outcomes per platform instead of one; the e2e scripts unescape ninja node names (a Windows drive letter arrives as `C$:/Users/...`) and use BSD `stat -f` when GNU `stat -c` is absent (macOS).
1 parent 8bd5380 commit cb7f3df

9 files changed

Lines changed: 128 additions & 50 deletions

.agents/docs/2026-07-30-issue311-bmi-staging-and-cache-root-design.md

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -195,7 +195,7 @@ mcpp stage --output <dst> <src>
195195
|| 条件 | 行为 |
196196
|---|---|---|
197197
| 1 | `src` 不存在 | 报错退出 1(这是真 bug,不容忍) |
198-
| 2 | `dst` 存在 且 `size(dst) == size(src)``--verify content` 时字节亦相等) | **一个字节都不写也不动任何时间戳**退出 0 |
198+
| 2 | `dst` 存在 且 `size(dst) == size(src)`**内容逐字节相等**(默认;`--verify size` 时只比 size) | **一个字节都不写,也不动任何时间戳**;退出 0 |
199199
| 3 | 否则 |`dst.tmp.<pid>`(同目录)→ `rename` 覆盖 `dst` |
200200
| 4 | 步 3 失败 | 退化为原地覆写(`copy_file` overwrite)——赢下"可写但不可删"的持有者形态 |
201201
| 5 | 步 3+4 都失败 | 退避重试 3 次(100/300/900 ms)后仍失败 → 结构化诊断 + 退出 1 |
@@ -220,9 +220,14 @@ mcpp stage --output <dst> <src>
220220
也就是说:**std staging 是全仓库唯一强制覆写的那一处**,这个不对称本身就是缺陷。
221221
S1 把它拉回一致。
222222

223-
内容校验默认关闭(只比 size):上面的 fp 判据已经足够强,逐字节比较要多读两遍 31 MB。
224-
留开关是为了排障(`--verify content` / `MCPP_STAGE_VERIFY=content`)。选逐字节比较而不是
225-
hash:同样的 I/O 成本,但没有碰撞面,且能提前退出。
223+
**内容校验是默认,不是开关**(这一条被 Windows CI 纠正过一次)。初稿让 size 相等即视为已
224+
staged,理由是 fp 判据;但同一条 rule 还搬 **DLL**(`runtimeDeployFiles`、以及 Windows 上的
225+
`runtime_alias`),而 PE 的节对齐让「真的重建了、大小却一模一样」十分常见 —— size-only 会把
226+
一个过期的 DLL 留在 build dir 里。逐字节比较则**无条件正确**:内容相同就是不需要写。
227+
228+
成本可接受,因为这个判断只在 ninja 已经认定 edge 脏了才会跑(罕见),此时多读两遍 31 MB 换
229+
的是正确性。`--verify size` / `MCPP_STAGE_VERIFY=size` 保留给「调用方确知源是 fp 作用域」的
230+
快路径。选逐字节比较而不是 hash:同样的 I/O 成本,但没有碰撞面,且能提前退出。
226231

227232
**为什么步 2 连时间戳都不碰**(这一条实测修正过一次):
228233

@@ -297,6 +302,12 @@ staging rule 加 `restat = 1`。这是 S1 步 2"不写字节"能真正省掉级
297302
部署复用的是同一条 rule(`ninja_backend.cppm:1077-1083`)。**DLL 部署是同一类失败的第二个
298303
受害者**(往正在运行的进程/调试器已加载的 DLL 上 `Copy-Item -Force` 同样失败),S1 一并治好。
299304
305+
还有**第三个**:`rule runtime_alias`(`ninja_backend.cppm:758-764`)在 Windows 上也是一条
306+
`Copy-Item -Force` —— PE 没有 soname 符号链接,别名就是刚建出来的 DLL 的副本,同一危险类。
307+
Windows 分支一并改走 `$mcpp stage`(+ `restat`),**POSIX 分支保持 `ln -s` 不变**:那里符号
308+
链接是语义而不只是写法(改成拷贝会改变打包产物)。这一处是 Windows CI 抓出来的 —— 初稿的
309+
「`Copy-Item` 归零」自查项在本地(Linux)恒为真,永远不会失败。
310+
300311
另有一处必改:`mcpp = <exe>` 这个 ninja 变量目前只在 `if (dyndep)` 里绑定
301312
(`ninja_backend.cppm:403-409`)。新 rule 在**所有**配置下都要用它,必须把绑定提到该
302313
条件之外,否则 GCC 非 dyndep 路径会生成 `$mcpp` 为空的命令。
@@ -335,7 +346,7 @@ staging rule 加 `restat = 1`。这是 S1 步 2"不写字节"能真正省掉级
335346
336347
| 风险 | 缓解 |
337348
|---|---|
338-
| size 相等但内容不同S1 步 2 误跳过 | fp 判据 + `--verify=hash` 开关;e2e 用"改一个字节但保持长度"的负例锁住 hash 模式 |
349+
| size 相等但内容不同(S1 步 2 误跳过) | **默认逐字节比较**,单测双向锁住(默认必拷贝 / `--verify size` 才跳过) |
339350
| `restat = 1` 引入意外的"永不重建" | 单测锁 rule 文本;e2e 正例:改 std 源/换 toolchain ⇒ fp 变 ⇒ 新 build dir,不受影响 |
340351
| 收敛缓存根导致老用户一次性重编 std | 只影响 self-contained 与 Windows 两种形态;一次 10–60 s;CHANGELOG 写明,doctor 提示遗留目录 |
341352
| `mcpp` 变量提取出 `if (dyndep)` 后 ninja 文本变化 | `tests/unit/test_ninja_backend.cpp` 断言两种配置(dyndep on/off)下都绑定 |

.agents/docs/2026-07-30-issue311-implementation-plan.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,7 @@ std::expected<StageResult, StageError> stage_file(
8484
hint 必须点名 clangd 与 `compile_commands.json` 的因果)。
8585
8. 每次退出前清理残留 `dst.tmp.<pid>`
8686

87-
`Verify::Content` `MCPP_STAGE_VERIFY=content``--verify content` 打开;实现是分块
87+
`Verify::Content` **默认**`--verify size` / `MCPP_STAGE_VERIFY=size` 才退回只比 size。实现是分块
8888
逐字节比较(同 I/O 成本、无碰撞面、可提前退出),不引入 hash 依赖。
8989

9090
**CLI 接线**(照 `dyndep` 的形状):

CHANGELOG.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,9 @@
1313

1414
staging 改为走 mcpp 自己的内部子命令 `mcpp stage`(形态对齐既有的 `mcpp dyndep`):**目标已等价就一个字节都不写**,否则先写同目录临时文件再 rename,再退化为原地覆写,失败按退避重试,最终失败时给出点名文件与可能持有者(clangd / 编辑器索引 / 杀软 / 上一次 `mcpp run` 还在跑的程序)的诊断。**绝不降级为 warning** —— staged BMI 过期或缺失会变成难以归因的 `module 'std' not found`,或者更糟:旧 BMI 配新 `std.o` 静默链接。
1515

16-
已等价就不写」的判据是 fingerprint:build dir(`target/<triple>/<fp>`)与缓存目录(`$MCPP_HOME/bmi/<fp>`)共享同一个 fp,而 fp 已覆盖编译器身份/target triple/stdlib/std 源哈希/标准与方言 flag。dep BMI 缓存一直就是这么做的(`bmi_cache.cppm`: *"Existing project outputs are left untouched"*)—— std staging 是全仓库唯一强制覆写的那一处,这个不对称本身就是缺陷。
16+
已等价」由**逐字节比较**判定 —— 无条件正确:内容相同就是不需要写。这个判断只在 ninja 已经认定 edge 脏了才会跑,所以代价可以忽略。(`--verify size` 保留给确知源是 fingerprint 作用域的调用方:build dir 与缓存目录共享同一个 fp,而 fp 已覆盖编译器身份/target triple/stdlib/std 源哈希/标准与方言 flag。但它**不是默认** —— 同一条 rule 还搬 DLL,而 PE 的节对齐让「真的重建了、大小却一样」十分常见。)dep BMI 缓存一直就是「已存在就不动」的(`bmi_cache.cppm`: *"Existing project outputs are left untouched"*)—— std staging 是全仓库唯一强制覆写的那一处,这个不对称本身就是缺陷。
1717

18-
同一条 rule 也用于 **Windows 运行期 DLL 部署**,因此「往上一次 `mcpp run` 还加载着的 DLL 上覆写」这个同类失败一并治好。
18+
同一条 rule 也用于 **Windows 运行期 DLL 部署**,以及 Windows 上的 `runtime_alias`(PE 没有 soname 符号链接,别名就是刚建出来的 DLL 的副本),因此「往上一次 `mcpp run` 还加载着的 DLL 上覆写」这个同类失败一并治好。POSIX 的 `runtime_alias` 保持符号链接不变 —— 那里符号链接是语义,不只是写法
1919

2020
- **重新 stage 一个未变的 std BMI 不再重编整张模块图。** staged BMI 是每个 importer 的 implicit input,而 staging rule 既不保留 mtime 也没有 `restat`,于是缓存侧 BMI 只要 mtime 变新(在下面那个缓存根缺陷下,**换个 cwd 跑就会发生**),所有 `import std` 的 TU 全部重编 —— 即使字节完全相同。
2121

src/build/ninja_backend.cppm

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -757,11 +757,21 @@ std::string emit_ninja_string(const BuildPlan& plan) {
757757

758758
append("rule runtime_alias\n");
759759
if constexpr (mcpp::platform::is_windows) {
760-
append(" command = powershell -NoProfile -Command \"Copy-Item -Force '$in' -Destination '$out'\"\n");
760+
// PE has no soname symlink, so the alias is a copy — and a copy of a
761+
// just-rebuilt DLL is exactly the hazard #311 is about (a program still
762+
// running from a previous `mcpp run` holds the old one). Route it
763+
// through the same staging primitive rather than a second in-place
764+
// PowerShell copy. Content-verified, so a same-size rebuild still
765+
// refreshes the alias.
766+
append(" command = $mcpp stage --output $out $in\n");
761767
} else {
762768
append(" command = mkdir -p $$(dirname $out) && rm -f $out && ln -s $$(basename $in) $out\n");
763769
}
764-
append(" description = ALIAS $out\n\n");
770+
append(" description = ALIAS $out\n");
771+
if constexpr (mcpp::platform::is_windows) {
772+
append(" restat = 1\n");
773+
}
774+
append("\n");
765775

766776
if (dyndep) {
767777
// Scan rule: produce P1689 .ddi for one TU.

src/build/stage.cppm

Lines changed: 19 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -16,14 +16,17 @@
1616
//
1717
// The rules this module implements:
1818
//
19-
// 1. Never write bytes we don't have to. The destination and the source live
20-
// under the SAME build fingerprint (target/<triple>/<fp> vs
21-
// $MCPP_HOME/bmi/<fp>), and the fingerprint already covers compiler
22-
// identity, target triple, stdlib, std module source hash and the
23-
// standard/dialect flags. Same fingerprint + same size ⇒ equivalent ⇒
24-
// the file is already staged. This mirrors what the dep BMI cache has
25-
// always done (bmi_cache.cppm: "Existing project outputs are left
26-
// untouched").
19+
// 1. Never write bytes that are already there. Equivalence is decided by
20+
// COMPARING CONTENT, which is unconditionally correct — a destination
21+
// identical to the source needs no write, whatever the reason. Size alone
22+
// is only a heuristic and is available as `--verify size` for the paths
23+
// where the caller knows better (a std BMI is fingerprint-scoped: the
24+
// cache dir and the build dir share the fp that covers compiler identity,
25+
// target triple, stdlib, std source hash and the dialect flags). It is
26+
// NOT the default, because staging also carries .dll payloads whose PE
27+
// section padding makes "same size, different bytes" ordinary.
28+
// Skipping mirrors what the dep BMI cache has always done
29+
// (bmi_cache.cppm: "Existing project outputs are left untouched").
2730
// 2. When we do write, write out of place and rename — atomic for readers,
2831
// and it survives a transient sharing violation (antivirus, indexer)
2932
// that an in-place overwrite would lose to.
@@ -49,12 +52,12 @@ export namespace mcpp::build::stage {
4952

5053
// How hard to look before declaring the destination already-staged.
5154
enum class Verify {
52-
Size, // default: size match under an identical build fingerprint
53-
Content, // byte-for-byte compare (MCPP_STAGE_VERIFY=content / --verify content)
55+
Content, // default: byte-for-byte compare — always correct
56+
Size, // size match only (MCPP_STAGE_VERIFY=size / --verify size)
5457
};
5558

5659
struct StageOptions {
57-
Verify verify = Verify::Size;
60+
Verify verify = Verify::Content;
5861
int retries = 3; // attempts after the first
5962
std::chrono::milliseconds backoff{100}; // ×3 per retry: 100/300/900ms
6063
};
@@ -76,7 +79,8 @@ std::expected<StageOutcome, StageError> stage_file(const std::filesystem::path&
7679
// unreadable or the sizes differ.
7780
bool same_content(const std::filesystem::path& a, const std::filesystem::path& b);
7881

79-
// Parse a --verify / MCPP_STAGE_VERIFY value. Unknown values fall back to Size.
82+
// Parse a --verify / MCPP_STAGE_VERIFY value. Unknown values fall back to the
83+
// safe default (Content).
8084
Verify parse_verify(std::string_view value);
8185

8286
} // namespace mcpp::build::stage
@@ -171,7 +175,7 @@ bool same_content(const std::filesystem::path& a, const std::filesystem::path& b
171175
}
172176

173177
Verify parse_verify(std::string_view value) {
174-
return value == "content" ? Verify::Content : Verify::Size;
178+
return value == "size" ? Verify::Size : Verify::Content;
175179
}
176180

177181
std::expected<StageOutcome, StageError> stage_file(const std::filesystem::path& src,
@@ -193,7 +197,8 @@ std::expected<StageOutcome, StageError> stage_file(const std::filesystem::path&
193197
}
194198
}
195199

196-
// Already staged? (see module comment for why size is a sound default)
200+
// Already staged? Content by default — size is the caller-opt-in shortcut
201+
// (see module comment).
197202
std::error_code sec;
198203
if (std::filesystem::is_regular_file(dst, sec)) {
199204
std::error_code se1, se2;

tests/e2e/170_bmi_staging_no_cascade.sh

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,14 @@
1717
# Toolchain-neutral: only needs a package that says `import std;`.
1818
set -e
1919

20+
# build.ninja node names are ninja-ESCAPED: on Windows a drive letter arrives as
21+
# `C$:/Users/...`. Unescape before touching the filesystem (the Windows job
22+
# failed on exactly this).
23+
unescape_ninja() { printf '%s' "$1" | sed 's/\$:/:/g; s/\$\$/$/g'; }
24+
# Compare paths across the Windows fork: MCPP_HOME is `C:\Users\...` while ninja
25+
# writes forward slashes.
26+
norm_path() { printf '%s' "$1" | tr '\\' '/'; }
27+
2028
TMP=$(mktemp -d)
2129
trap "rm -rf $TMP" EXIT
2230
cd "$TMP"
@@ -44,13 +52,13 @@ EDGE=$(grep -E '^build [^ ]+ : stage_file ' "$NINJA" | head -1)
4452
[[ -n "$EDGE" ]] || {
4553
grep -n "std" "$NINJA" | head -20
4654
echo "FAIL: no stage_file edge for the std BMI (did import std resolve?)"; exit 1; }
47-
DST=$(echo "$EDGE" | awk '{print $2}')
48-
SRC=$(echo "$EDGE" | awk '{print $NF}')
55+
DST=$(unescape_ninja "$(echo "$EDGE" | awk '{print $2}')")
56+
SRC=$(unescape_ninja "$(echo "$EDGE" | awk '{print $NF}')")
4957
echo "staging: $SRC -> $DST"
5058

5159
# ── invariant 2: the cache root is $MCPP_HOME/bmi, never a cwd-local dir ──
52-
HOME_ROOT="${MCPP_HOME:-$HOME/.mcpp}"
53-
case "$SRC" in
60+
HOME_ROOT=$(norm_path "${MCPP_HOME:-$HOME/.mcpp}")
61+
case "$(norm_path "$SRC")" in
5462
"$HOME_ROOT"/bmi/*) ;;
5563
*) echo "FAIL: std BMI cache is '$SRC', expected under '$HOME_ROOT/bmi'"; exit 1 ;;
5664
esac

tests/e2e/171_bmi_staging_locked_dest.sh

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,14 @@
2020
# runs as root.
2121
set -e
2222

23+
# build.ninja node names are ninja-ESCAPED: on Windows a drive letter arrives as
24+
# `C$:/Users/...`. Unescape before touching the filesystem (the Windows job
25+
# failed on exactly this).
26+
unescape_ninja() { printf '%s' "$1" | sed 's/\$:/:/g; s/\$\$/$/g'; }
27+
# Compare paths across the Windows fork: MCPP_HOME is `C:\Users\...` while ninja
28+
# writes forward slashes.
29+
norm_path() { printf '%s' "$1" | tr '\\' '/'; }
30+
2331
TMP=$(mktemp -d)
2432
trap "rm -rf $TMP" EXIT
2533
cd "$TMP"
@@ -41,11 +49,14 @@ cd app
4149
NINJA=$(find target -name build.ninja | head -1)
4250
EDGE=$(grep -E '^build [^ ]+ : stage_file ' "$NINJA" | head -1)
4351
[[ -n "$EDGE" ]] || { echo "FAIL: no stage_file edge for the std BMI"; exit 1; }
44-
DST="$(dirname "$NINJA")/$(echo "$EDGE" | awk '{print $2}')"
45-
SRC=$(echo "$EDGE" | awk '{print $NF}')
52+
DST="$(dirname "$NINJA")/$(unescape_ninja "$(echo "$EDGE" | awk '{print $2}')")"
53+
SRC=$(unescape_ninja "$(echo "$EDGE" | awk '{print $NF}')")
4654
[[ -f "$DST" ]] || { echo "FAIL: staged BMI missing at $DST"; exit 1; }
4755

48-
identity() { stat -c '%i %Y %s' "$1"; }
56+
# GNU stat vs BSD/macOS stat (the macOS job failed on `stat -c`).
57+
identity() {
58+
stat -c '%i %Y %s' "$1" 2>/dev/null || stat -f '%i %m %z' "$1"
59+
}
4960
BEFORE=$(identity "$DST")
5061

5162
# Dirty the staging edge without touching the shared cache's CONTENT.

tests/unit/test_build_stage.cpp

Lines changed: 29 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ std::string read_file(const std::filesystem::path& p) {
3535

3636
// Retries would only slow the failure tests down; the behaviour under test is
3737
// the decision, not the backoff.
38-
StageOptions no_retry(Verify v = Verify::Size) {
38+
StageOptions no_retry(Verify v = Verify::Content) {
3939
return StageOptions{.verify = v, .retries = 0, .backoff = std::chrono::milliseconds{0}};
4040
}
4141

@@ -75,33 +75,35 @@ TEST(BuildStage, EquivalentDestinationIsNotTouched) {
7575
EXPECT_EQ(std::filesystem::last_write_time(dst), recorded);
7676
}
7777

78-
// Same size, different bytes: the size-only default deliberately treats this as
79-
// already-staged (destination and source share a build fingerprint, which
80-
// covers compiler identity, stdlib and std source hash).
81-
TEST(BuildStage, SameSizeDifferentBytesIsSkippedUnderSizeVerify) {
78+
// Same size, different bytes must be copied by DEFAULT. Staging also carries
79+
// .dll payloads, where PE section padding makes equal sizes across a real
80+
// rebuild ordinary — so size-only equivalence cannot be the default.
81+
TEST(BuildStage, SameSizeDifferentBytesIsCopiedByDefault) {
8282
Tmp tmp;
8383
auto src = tmp.path / "src.bin";
8484
auto dst = tmp.path / "dst.bin";
8585
write_file(src, "AAAA");
8686
write_file(dst, "BBBB");
8787

88-
auto r = stage_file(src, dst, no_retry(Verify::Size));
88+
auto r = stage_file(src, dst, no_retry());
8989
ASSERT_TRUE(r.has_value());
90-
EXPECT_FALSE(r->copied);
91-
EXPECT_EQ(read_file(dst), "BBBB");
90+
EXPECT_TRUE(r->copied);
91+
EXPECT_EQ(read_file(dst), "AAAA");
9292
}
9393

94-
TEST(BuildStage, SameSizeDifferentBytesIsCopiedUnderContentVerify) {
94+
// `--verify size` is the opt-in shortcut for callers that know the source is
95+
// fingerprint-scoped; it accepts the same-size destination as already staged.
96+
TEST(BuildStage, SameSizeDifferentBytesIsSkippedUnderSizeVerify) {
9597
Tmp tmp;
9698
auto src = tmp.path / "src.bin";
9799
auto dst = tmp.path / "dst.bin";
98100
write_file(src, "AAAA");
99101
write_file(dst, "BBBB");
100102

101-
auto r = stage_file(src, dst, no_retry(Verify::Content));
103+
auto r = stage_file(src, dst, no_retry(Verify::Size));
102104
ASSERT_TRUE(r.has_value());
103-
EXPECT_TRUE(r->copied);
104-
EXPECT_EQ(read_file(dst), "AAAA");
105+
EXPECT_FALSE(r->copied);
106+
EXPECT_EQ(read_file(dst), "BBBB");
105107
}
106108

107109
TEST(BuildStage, DifferentSizeIsAlwaysCopied) {
@@ -124,10 +126,12 @@ TEST(BuildStage, MissingSourceIsAnError) {
124126
EXPECT_NE(r.error().message.find("nope.bin"), std::string::npos);
125127
}
126128

127-
// A read-only destination is the closest POSIX analogue of the Windows holder:
128-
// an in-place overwrite cannot open it, so staging must go through the
129-
// temp-file + rename path.
130-
TEST(BuildStage, ReadOnlyDestinationIsReplacedViaRename) {
129+
// A read-only destination that must actually be replaced: POSIX can do it
130+
// (the directory entry is what gets rewritten, so temp-file + rename wins),
131+
// Windows cannot (replacing a read-only file is denied however you spell it) —
132+
// and there the contract is the loud failure, not a silent skip. Asserted per
133+
// platform rather than skipped, so neither side can rot unnoticed.
134+
TEST(BuildStage, ReadOnlyDestinationOutcomeIsPlatformDefined) {
131135
Tmp tmp;
132136
auto src = tmp.path / "src.bin";
133137
auto dst = tmp.path / "dst.bin";
@@ -136,9 +140,15 @@ TEST(BuildStage, ReadOnlyDestinationIsReplacedViaRename) {
136140
std::filesystem::permissions(dst, std::filesystem::perms::owner_read);
137141

138142
auto r = stage_file(src, dst, no_retry());
143+
#if defined(_WIN32)
144+
ASSERT_FALSE(r.has_value());
145+
EXPECT_NE(r.error().message.find("hint:"), std::string::npos);
146+
EXPECT_EQ(read_file(dst), "old");
147+
#else
139148
ASSERT_TRUE(r.has_value()) << r.error().message;
140149
EXPECT_TRUE(r->copied);
141150
EXPECT_EQ(read_file(dst), "new-content");
151+
#endif
142152
}
143153

144154
// When staging genuinely cannot proceed, the failure must name the file and
@@ -176,6 +186,7 @@ TEST(BuildStage, SameContentComparesBytesNotJustSize) {
176186
TEST(BuildStage, VerifyModeParsing) {
177187
EXPECT_EQ(parse_verify("content"), Verify::Content);
178188
EXPECT_EQ(parse_verify("size"), Verify::Size);
179-
EXPECT_EQ(parse_verify("nonsense"), Verify::Size);
180-
EXPECT_EQ(parse_verify(""), Verify::Size);
189+
// Anything unrecognized must land on the SAFE mode, not the fast one.
190+
EXPECT_EQ(parse_verify("nonsense"), Verify::Content);
191+
EXPECT_EQ(parse_verify(""), Verify::Content);
181192
}

0 commit comments

Comments
 (0)