Skip to content

test: win32 の fs semantics を windows-latest で実測する probe を足す - #503

Merged
ymnao merged 7 commits into
mainfrom
test/win32-fs-semantics-probe
Aug 16, 2026
Merged

test: win32 の fs semantics を windows-latest で実測する probe を足す#503
ymnao merged 7 commits into
mainfrom
test/win32-fs-semantics-probe

Conversation

@ymnao

@ymnao ymnao commented Aug 16, 2026

Copy link
Copy Markdown
Owner

概要

win32 の fs semantics を実測する probe test を足し、windows-latest でそれだけを回す最小 job を CI に追加する。Windows は出荷対象 platform(electron-builder.yml の nsis / release.yml の build matrix)でありながら、CI に test job が 1 つも無かった。

#451O_NOFOLLOW が無い platform 向けに lstat エミュレーションを入れたが、そこで pin できたのは「flag が落ちた platform だとしたらこう振る舞う」までで、win32 の fs semantics 自体は 1 つも実測していなかった

関連 Issue

closes #500

移行 Stage

  • Stage 0: 雛形 + フロント表示確認
  • Stage 1: ファイル I/O
  • Stage 2: ワークスペース・ファイルツリー・ファイル監視
  • Stage 3: 全文検索(ripgrep sidecar)
  • Stage 4: Git Sync
  • Stage 5: OGP / PDF / アップデート
  • Stage 6: 仕上げ・配布・切り替え

実測結果(windows-latest, Node 22.13)

# 前提 実測
1 fs.constants.O_NOFOLLOW は win32 で undefined か ✅ undefined。NOFOLLOW_EMULATED が true になる
2 lstat は file symlink を isSymbolicLink()=true で報告するか ✅ true(isFile() は false)
3 plain open は file symlink を follow するか ✅ follow して解決先の内容を返す(= #451 のバグの再現)
4 directory junction は readdir({withFileTypes:true}) でどう出るか isDirectory()=false / isSymbolicLink()=truewalkMdFilesent.isDirectory() でのみ再帰するので junction 経由の外部 tree 混入は無い
5 エミュレーションの ELOOP 拒否は win32 実機で効くか ✅ read / write とも ELOOP で拒否し、write は解決先を書き換えない
6 rename(2) は destination の末端 symlink を follow しないか ✅ symlink 自身を置き換える。解決先は無傷
7 O_EXCL は既存 symlink を EEXIST で拒否するか ⚠️ live は EEXIST、dangling は拒否せず解決先に file を作る#504

GitHub の windows runner では fs.symlink の file symlink 作成が特権エラーにならなかった(1-7 すべてが実際に symlink を作って測れている)。junction も同様。

#7 の扱い

open-nofollow.tsNOFOLLOW_CREATE_EXCLUSIVE_FLAGS は「O_EXCL は既存 entry があれば symlink であっても EEXIST で落ちるので、tmp 名が衝突しても攻撃者が仕込んだ symlink を掴まされることはない」という根拠に乗っていた。この根拠は win32 の dangling symlink に対して偽だった(Windows は reparse point を follow したうえで解決先が無いため CREATE_NEW が通る)。

本 PR では 事実の記録に留める(doc を実測に合わせ、probe の期待値を実測値で pin し、#504 を参照)。受容するか tmp path に lstat を挟むかの production 判断は #504。実害は writeFileAtomicNoFollow の tmp 名(48 bit 乱数)への先回りが要るため実用上は極小。

変更内容

  • electron/main/utils/win32-fs-semantics.test.ts(新規)— win32 でのみ走る probe 9 本(上表に対応)
  • .github/workflows/ci.ymlwin32-fs-probe job を追加
  • electron/main/utils/open-nofollow.tsO_EXCL の doc に win32 の dangling 例外を追記(win32 では O_EXCL が dangling symlink を follow して解決先に file を作る #504 参照)
  • electron/main/utils/open-nofollow.test.ts / docs/adr/0011-search-visibility-follows-fs-read.md — 「win32 実機では検証できない」「CI で実測していない」が実測により stale になったので更新

設計判断

  • 既存の skipIf(win32) test は un-skip しないopen-nofollow.test.ts / path-guard.test.ts の symlink 系は open 由来の ELOOP・stat().ino の同一性・mode 継承といった POSIX 前提の assertion で書かれており、win32 では拒否経路が emulation(lstat)に替わるため assertion 側の platform 分岐が広範囲に要る。測りたい前提だけを独立した probe file に置く
  • symlink 作成の失敗を skip に倒さない — 特権エラーで skip すると「測れなかった」と「測って問題なかった」が区別できない vacuous pass になる。fsp.symlink は握らずそのまま await する
  • live / dangling は別の it に分ける — 最初 1 本のループにまとめて実測したところ、どちらが割れたのか結果から読めなかった。probe の目的は差の同定なので分けた
  • この job は required check にしない — windows runner 固有の path / 権限まわりの不安定さを merge gate に持ち込まないため。失敗は PR の checks 一覧で見える。ci-skip.yml は required job のみを stub する設計なので、そちらへの追加も不要
  • branch protection への登録はこの PR に含まれない(GitHub UI 側の操作)。required にしないという決定なので追加操作は不要

動作確認

  • ユニットテスト全件 pass(probe は darwin では skip される)
  • probe の assert が POSIX で成立することを実 node で確認(Windows で赤くなれば test の誤りではなく platform 差、という前提の担保)
  • windows-latest で実測完了win32-fs-probe job pass 57s、9 tests passed)
検証エビデンス

リスク分類

tier: high — ci-config: .github/workflows/ci.yml

実行した検証

種別 コマンド 結果
テスト env -u GIT_CONFIG_COUNT -u GIT_CONFIG_KEY_0 -u GIT_CONFIG_VALUE_0 -u GIT_CONFIG_KEY_1 -u GIT_CONFIG_VALUE_1 ./node_modules/.bin/vitest run PASS 139 files passed / 1 skipped、3159 tests passed / 11 skipped
テスト(win32 実測) CI win32-fs-probe job(pnpm exec vitest run --project main electron/main/utils/win32-fs-semantics.test.ts 初回 8 中 1 failed(O_EXCL)→ live/dangling 分割で dangling と同定 → 実測値で pin → PASS 9 tests, 57s
probe の形 node <scratchpad>/probe-shape.mjs(rename / O_EXCL の assert を darwin で再現) PASS(outside 不変 / link が非 symlink / live・dangling とも EEXIST)
Lint ./node_modules/.bin/biome check --write <変更した 3 file> PASS(No fixes applied)
型チェック ./node_modules/.bin/tsc --noEmit -p tsconfig.node.json / -p tsconfig.web.json / -p tsconfig.e2e.json PASS(3 本すべて)
CI 全体 gh pr checks 503 --watch 全 check PASS(lint / typecheck / test / e2e / electron-e2e / build / dependency-review / CodeQL / win32-fs-probe)
レビュー codex-review shell-senior PASS(0 findings)
レビュー codex-review security PASS(0 findings)
レビュー codex-review qa-fixture 1 finding(1 confirmed / 1 fixed / 0 report-only)→ confirm run PASS
レビュー code-reviewer サブエージェント(fable)2 周 1 周目 Suggestion 4 / 2 周目 Warning 1 + Suggestion 3
レビュー /simplify 4 観点(reuse / simplification / efficiency / altitude) reuse 1 / simplification 2 / efficiency 1 / altitude 0

レビュー指摘と対応

codex-review:

LOW/90 electron/main/utils/win32-fs-semantics.test.ts:35 — beforeEach の makeCanonicalTempDir が失敗した場合も afterEach が未初期化の dir を fsp.rm に渡し、元のセットアップエラーを別の引数エラーで覆い隠す → CONFIRMED+FIXED (ad3610c)

code-reviewer / simplify(fix したもの):

Finding 対応
4 つの it が byte-identical な symlink fixture を作っている createFileSymlink helper に集約(a7c84bf)
cleanup が maxRetries 無しで、Windows の遅延解放系エラーで probe が infra 由来の赤になりうる git.test.ts の先例に合わせ maxRetries: 5, retryDelay: 100(a7c84bf)、さらに mkdtemp 失敗回の二次エラーを避けるため dirsToCleanup 配列へ(ad3610c)
コメント「Why not skip に倒さない」が二重否定で逆に読める 「Why not skip」に修正(a7c84bf)
ci.yml のコメント「job 全体の支配項は electron バイナリ」が未実測の断定 機構の事実だけを残す形に書き直し(4e238ad)
writeFileAtomicNoFollow の rename / O_EXCL semantics が win32 未実測 probe に it を追加(e6b2bb1、user 承認済み)→ これが #504 の発見に繋がった

追跡先

backlog delta: 起票 1 件(#504)/ 本 PR で close 1 件(#500)/ 現在 open 16 件

起票理由: 実測で open-nofollow.ts のセキュリティ根拠が win32 の dangling symlink に対して偽と判明した。probe の期待値を実測値へ書き換える以上、production 側の判断(受容 / lstat 追加)を追跡先なしに残すと、事実だけがテストに固定されて判断が保留のまま定着する。

Finding (file:line — summary) 行き先 URL / 記録
open-nofollow.ts:73 — O_EXCL が win32 の dangling を通す (b) issue #504
.github/workflows/ci.yml:60 — 使わない electron バイナリを毎回取得 (c) 対応しない 追跡しない (user 指示: 分類表を承認。「忘れたら手遅れ」を満たさず、CI を回すたび再浮上する。--ignore-scripts 案は allowBuilds の esbuild と katex 生成を巻き込み Windows CI でしか検証できない)

Draft 判定

ymnao and others added 6 commits August 16, 2026 18:33
- #451 の symlink 境界は「flag が落ちた platform だとしたら」までしか pin
  できておらず、win32 の fs semantics は 1 つも実測されていなかった
- 既存の skipIf(win32) test は un-skip しない。POSIX 前提の assertion
  (open 由来 ELOOP / ino 同一性 / mode 継承) の platform 分岐が広範囲に
  要るため、測りたい前提だけを独立した probe file に置く
- symlink 作成の失敗は skip に倒さない。「測れなかった」と「測って問題が
  なかった」を区別できない vacuous pass になるため
- windows job は required check にしない。runner 固有の不安定さを merge
  gate に持ち込まないため

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- 4 つの it が byte-identical な symlink fixture を作っていたので
  createFileSymlink に寄せる (simplify 指摘)
- Windows は close 済み handle の解放が遅延しうるため、cleanup を
  git.test.ts の先例に合わせ maxRetries 付きの fsp.rm にする。infra 由来の
  EPERM / EBUSY を probe の赤にしないため
- コメントの「Why not skip に倒さない」は二重否定で逆に読めるので修正
- ci.yml の恒常コスト記述を実態に合わせる。支配項は electron バイナリの
  毎回取得であり、probe file 1 本に絞ってもそこは削れていない

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- 「job 全体の支配項は electron バイナリ」と書いていたが、この job は
  まだ 1 度も走っておらず step timing の実測が存在しない。Windows runner の
  node_modules link I/O が上回る可能性を排除できない
- 機構の事実 (postinstall 経由で毎回取得する / その cache は cache: pnpm の
  対象外 / probe はそれを使わない) だけを残す
- --ignore-scripts 保留の理由に katex css 生成も併記する

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- beforeEach が落ちた回でも afterEach は走るため、未代入の dir が
  fsp.rm に渡って TypeError になり、probe が出したい一次エラーに
  二次エラーが重なっていた
- git.test.ts の dirsToCleanup 配列パターンに揃える。作成に成功した分
  だけが配列に入るので、失敗回は no-op になる
- codex-review (qa-fixture) と code-reviewer が独立に同じ機構を指摘

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- rename(2) が末端 symlink を follow しない / O_EXCL が既存 symlink を
  EEXIST で拒否する、という 2 つの根拠は POSIX の probe でしか確かめて
  おらず、win32 の rename は MoveFileEx 系の別実装なので自動では移送
  されない。pdf:export として Windows にも出荷される経路
- probe の形は darwin で実行して assert が成立することを確認済み。
  Windows で赤くなれば test の誤りではなく platform 差になる

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- 初回の windows 実測でこの 1 本だけ落ちたが、ループで 2 ケースを回して
  いたため、どちらが EEXIST を返さなかったのかが結果から読めなかった
- probe の目的は platform 差の同定なので it を分ける

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- windows-latest の実測で 9 本中 8 本は想定どおりだったが、O_EXCL の
  dangling symlink だけ POSIX と割れた。win32 は reparse point を follow
  したうえで解決先が無いため CREATE_NEW が通り、解決先に file が作られる
- open-nofollow.ts の「symlink であっても EEXIST」はこのケースで偽なので
  事実を追記する。受容するか lstat を挟むかの判断は #504
- probe の期待値は実測値で pin する。production 側の判断が別 issue に
  分かれているため、そちらが入ったら追随させる
- 実測が入ったことで stale になった記述を更新: open-nofollow.test.ts の
  「win32 実機では検証できない」と ADR-0011 の Windows bullet

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@ymnao
ymnao merged commit 9d5d671 into main Aug 16, 2026
18 checks passed
@ymnao
ymnao deleted the test/win32-fs-semantics-probe branch August 16, 2026 13:01
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

symlink 系 test を windows-latest で回して win32 の fs semantics を実測する

1 participant