Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions docs/adr/0011-search-visibility-follows-fs-read.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,9 +63,9 @@ errno は**見分けない**。ELOOP 以外の失敗(EACCES / ENOENT 等)も
- **FileTree との非対称は残る**。`listDirectory` は readdir の結果をそのまま返すので、workspace 外を指す symlink は**一覧には出る**(クリックすると従来どおり開けない)。揃えるには entry ごとの realpath コストを新規に背負うことになるため、本 ADR のスコープ外とし別判断とする
- **Windows ではゲート未評価経路の境界が効かない**。`fs.constants.O_NOFOLLOW` が無く flag が 0 に落ちるため、分岐 3(index 未提供 / index 無効 / 既に index 済みで valid)では末端 symlink が検出されず、従来どおり内容が結果に出る。一方 **分岐 2 は realpath ベースの判定なので Windows でも効く**(index 稼働中の未 index file は外部 symlink が落ちる)。結果として Windows では index の状態によって結果集合が変わる非一貫性が残る。#412 で受容済みの posture と同じ(Windows の symlink 作成は既定で管理者特権または開発者モードを要するため、攻撃前提が成立しにくい)
- **hard link alias は検出できない**(#416 Finding 2 / [#416](https://github.com/ymnao/scripta/issues/416))。hard link は O_NOFOLLOW でも realpath でも素通りするため、本 ADR の境界の対象外
- **retarget 直後は両者が一時的にズレる**。scan 側の `resolveInsideRoot` は realpath cache を通さず毎回 fresh に解決する(#406 Finding 1)のに対し、fs:read 側の `assertPathAllowed` は `realpathBestEffort` 経由で **LRU 256 件の `realpathCache`** を使い、symlink の retarget に対する明示的な invalidation を持たない(`path-guard.ts` の cache doc に受容として記載済み)。したがって symlink を張り替えた直後、その path が cache に載っていると「scan は新しい解決先で判定し、fs:read は古い判定を返す」窓が開く。本 ADR の不変条件は **fs:read 側の cache が fresh な範囲で** 成立する。窓を閉じるには user-IPC 側の realpath 鮮度を見直す必要がある。[#418](https://github.com/ymnao/scripta/issues/418) は同じ層の O_NOFOLLOW 整合だけを扱い、cache 鮮度は受容のまま [#453](https://github.com/ymnao/scripta/issues/453) に分離した
- ~~**retarget 直後は両者が一時的にズレる**~~ → **[#453](https://github.com/ymnao/scripta/issues/453) で解消**。本 ADR の時点では、scan 側の `resolveInsideRoot` が毎回 fresh に解決する(#406 Finding 1)のに対し、fs:read 側の `assertPathAllowed` は **LRU 256 件の `realpathCache`** を使い retarget への invalidation を持たなかったため、symlink を張り替えた直後にその path が cache に載っていると「scan は新しい解決先で判定し、fs:read は古い判定を返す」窓が開いた(本 ADR の不変条件は fs:read 側の cache が fresh な範囲でしか成立していなかった)。#453 で **user-IPC 認可の realpath cache を撤去**し、`assertPathAllowed` / `assertWritePathAllowed` / `isPathAllowed` / `isPathWithinAnyAllowedRoot` も毎回 fresh に解決するようにしたため、**不変条件が cache の温度という前提条件を失った**(本 bullet が受容していたズレは解消。ただし本節の他 bullet が挙げる例外 — L2 hit 経路 / Windows / FileTree 非対称 — は引き続き残る)。判断の根拠は「cache key が成功した path 自身なので鮮度を保存するのは同一 path の 2 回目以降だけだった」「invalidation の通知経路が無いのは `resolveInsideRoot` が cache を通さない理由と同根」「削減できていたのは認可 1 回あたり realpath 1 回 = 実測 ~22µs」の 3 点(詳細は `path-guard.ts` の `realpathBestEffort` doc)
- **L2 hit 経路はゲートを評価した pass でしか可視範囲を判定できない**。「通常 file として L2 に載った後、watcher が拾えない retarget で workspace 外 symlink 化した」path は、その pass で index ゲートが走れば(既に払った realpath の再利用で)結果から落ちるが、ゲートを評価しない pass(index 無効 / 既に valid / index 未提供)では L2 の内容がそのまま返る。返るのは認可済みだった過去の実体の内容なので**外部内容の漏洩ではない**が、「結果に出るのにクリックすると開けない」状態は L2 entry が evict されるまで残る。判定材料を得るには L2 hit ごとに realpath を払うことになり、L2 hit の存在意義(read を省く)を打ち消すため受容する
- ~~**swap 窓では検索が fs:read より厳しくなる**~~ → **[#418](https://github.com/ymnao/scripta/issues/418) で解消**。本 ADR の時点では `fs:read`(`readFileImpl`)が canonical を plain open していたため、末端を swap された瞬間に「検索結果から落ちるが fs:read は開ける」状態になり得た(落とす側に倒れるので境界は破れない非対称)。#418 で `fs:read` / `fs:read-base64` / `fs:write` も O_NOFOLLOW 付き fd I/O に揃え、さらに ELOOP を受けたら realpath cache を捨てて再認可するようにしたため、**末端 swap 後の判定材料が scan 側と同じ「fresh な realpath 1 回」になった**(in-root なら解決先を読む / 外なら拒否)。残る差は 2 つ: ① scan の分岐 1(index ゲート評価済みかつ indexable)は ELOOP を解決し直さずその pass を skip するため、swap が起きた瞬間の 1 pass だけ検索結果から落ちる(in-root への swap なら次の pass で解決先から読まれて戻る。workspace 外への swap は次 pass 以降も落ち続けるが、それは fs:read の拒否と一致している) ② win32 では O_NOFOLLOW が効かないので両者とも従来挙動(#451)
- ~~**swap 窓では検索が fs:read より厳しくなる**~~ → **[#418](https://github.com/ymnao/scripta/issues/418) で解消**。本 ADR の時点では `fs:read`(`readFileImpl`)が canonical を plain open していたため、末端を swap された瞬間に「検索結果から落ちるが fs:read は開ける」状態になり得た(落とす側に倒れるので境界は破れない非対称)。#418 で `fs:read` / `fs:read-base64` / `fs:write` も O_NOFOLLOW 付き fd I/O に揃え、**末端 swap 後の判定材料が scan 側と同じ「fresh な realpath 1 回」になった**(in-root なら解決先を読む / 外なら拒否)。当時は cache stale と真の swap を切り分けるため ELOOP を受けたら cache を捨てて再認可していたが、#453 で cache 自体を撤去したので認可が最初から fresh になり、ELOOP は「realpath が解決できない symlink(dangling / 循環)」か「認可から open の間の真の race」だけを意味する fail-closed なシグナルに純化した(後者のうち解決先が workspace 内のものは再試行で通りうるが、窓が handler 1 実行内の await 1 回分まで縮んだため専用の再試行機構は持たない)。残る差は 2 つ: ① scan の分岐 1(index ゲート評価済みかつ indexable)は ELOOP を解決し直さずその pass を skip するため、swap が起きた瞬間の 1 pass だけ検索結果から落ちる(in-root への swap なら次の pass で解決先から読まれて戻る。workspace 外への swap は次 pass 以降も落ち続けるが、それは fs:read の拒否と一致している) ② win32 では O_NOFOLLOW が効かないので両者とも従来挙動(#451)
- #412 で受容した「認可後に末端を swap された file はその pass の結果から落ちる」の影響範囲が広がる。従来は次の pass で plain read されて結果に戻っていたが、本 ADR 以降は **swap が戻されるまで結果に出ない**(fs:read も同じ理由で拒否するので一貫している)

### 関連する将来の検討事項
Expand Down
6 changes: 4 additions & 2 deletions docs/migration-plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -143,5 +143,7 @@ Chromium 固定環境での挙動一貫性を優先し、Electron への全書
- **全文検索 / scanUnresolvedWikilinks の並列化**: `Promise.all` でファイル単位の
read を並列化。FD / メモリ上限を考慮した pool ベース化が要る。
- **collectMdFiles のキャッシング**: ワークスペース変更ベースで invalidate。
- **realpath の async 化**: `cachedRealpathSync` の同期 I/O を解消。`assertPathAllowed`
/ `canonicalize` / 全 fs IPC ハンドラまで波及する API 変更を伴うため引き続き先送り。
- ~~**realpath の async 化**~~: 本項目が書かれた後、別途 `cachedRealpathSync` を
`fs/promises` の `realpath` に置き換えた時点で解消済み(#453 とは無関係)。
その async な cache 層自体も鮮度の判断で撤去した(#453)ため、現在
`assertPathAllowed` / `canonicalize` は毎回 async な `realpath` を呼ぶ。
64 changes: 39 additions & 25 deletions electron/main/ipc/fs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -581,15 +581,26 @@ describe("deleteEntryImpl", () => {
});
});

// #418: 認可 (assertPathAllowed / assertWritePathAllowed) から実 I/O までの間に末端 component を
// symlink へ差し替えられる窓を O_NOFOLLOW で閉じたことの pin。
// #418 / #453: 末端 component が symlink である path を fs IPC がどう扱うかの pin。2 つの性質を
// 分けて押さえる:
//
// race そのものは再現せず、「認可後に swap された **終状態**」を disk 上に作って決定的に検証する
// (open-nofollow.test.ts と同方針)。canonical の末端が symlink のまま残るのは realpathBestEffort が
// 祖先 fall-through した場合、すなわち **dangling symlink** のときだけなので、それを fixture にする。
// 1. **realpath で解決できない symlink は O_NOFOLLOW が拒否する (#418)**。認可時点で canonical の
// 末端が symlink のまま残るのは realpathBestEffort が祖先 fall-through した場合、すなわち
// realpath がその path を解決できなかったとき (dangling / 循環 symlink 等)。以下の fixture は
// dangling を使う。plain な open だと write が解決先 (workspace 外) を新規作成してしまう
// 2. **前回の認可を持ち越さない (#453)**。realpath cache を撤去したので、一度 read / write を
// 通した path でも、その後 symlink へ retarget されれば次の認可は新しい解決先で判定する。
// cache があった頃はここが「ガードは古い canonical を通すが末端は今 symlink」という
// swap 窓の終状態になり、O_NOFOLLOW + 再認可で受け止めていた
//
// race そのものは再現せず、終状態を disk 上に作って決定的に検証する (open-nofollow.test.ts と
// 同方針)。2 の test が「一度呼んでから retarget する」形なのは、**前回の認可結果が残らないこと**
// 自体を pin するため (cache を再導入すると、stale な canonical の末端が今は symlink なので
// O_NOFOLLOW open が ELOOP を返し、期待している PATH_OUTSIDE_WORKSPACE / 解決先の内容に届かない
// = 実測で 6 本とも落ちる)。
//
// win32 は O_NOFOLLOW が無く flag が 0 に落ちるため拒否 assert が成立しない (#451 で追跡)。
describe.skipIf(process.platform === "win32")("末端 symlink の境界 (#418)", () => {
describe.skipIf(process.platform === "win32")("末端 symlink の境界 (#418 / #453)", () => {
let outside: TempWorkspace;

beforeEach(async () => {
Expand All @@ -611,8 +622,8 @@ describe.skipIf(process.platform === "win32")("末端 symlink の境界 (#418)",
});

it("dangling symlink の read は ELOOP で失敗する", async () => {
// 再認可しても realpath は解決できず同じ canonical に戻るため、2 度目の ELOOP が
// そのまま出る。plain open だと ENOENT になるので errno まで pin する。
// realpath が解決できず canonical が symlink 自身の path になるため O_NOFOLLOW が
// 発火する。plain open だと ENOENT になるので errno まで pin する。
const link = join(workspaceDir, "dangling.md");
await symlink(join(outside.dir, "nope.md"), link);
const err = await readFileImpl(TEST_WIN, link).catch((e: NodeJS.ErrnoException) => e);
Expand All @@ -628,11 +639,13 @@ describe.skipIf(process.platform === "win32")("末端 symlink の境界 (#418)",
await expect(readFileImpl(TEST_WIN, link)).rejects.toThrow(/outside workspace/);
});

it("認可後に workspace 外を指す symlink へ swap されたら外部内容を返さない", async () => {
// swap 窓 (T1 認可 → T2 I/O) を決定的に再現する。realpathCache は invalidation を
// 持たない (#453) ので、1 度読んで cache に載せた path を symlink へ差し替えると
// 「ガードは古い canonical を通すが、その canonical の末端は今 symlink」という
// 終状態を作れる。plain open ではここで外部内容が返る。
it("一度 read した path が workspace 外への symlink へ retarget されたら外部内容を返さない", async () => {
// **前回の認可を持ち越さないこと**の pin (#453)。realpath cache があった頃は、1 度読んで
// cache に載せた path を symlink へ差し替えると「ガードは古い canonical を通すが、その
// canonical の末端は今 symlink」という swap 窓の終状態になり、O_NOFOLLOW の ELOOP →
// cache 破棄 → 再認可でようやく拒否に届いていた。cache 撤去後は 2 度目の認可が fresh に
// 解決するので、ガード自身が外部を検出して落とす。cache を再導入すると canonical が
// stale になり、ELOOP (≠ PATH_OUTSIDE_WORKSPACE) が出てこの test は落ちる。
const secret = join(outside.dir, "secret.md");
await writeFile(secret, "SECRET", "utf8");
const path = join(workspaceDir, "note.md");
Expand All @@ -642,17 +655,18 @@ describe.skipIf(process.platform === "win32")("末端 symlink の境界 (#418)",
await rm(path);
await symlink(secret, path);

// 主 assert は「外部内容を返さないこと」。ELOOP を受けて cache を捨てて再認可するので、
// 表に出るのは errno ではなく認可エラーになる。
// 主 assert は「外部内容を返さないこと」。表に出るのは errno ではなく認可エラーになる。
const err = await readFileImpl(TEST_WIN, path).catch((e: unknown) => e);
expect(err).toBeInstanceOf(Error);
expect((err as Error).message).not.toContain("SECRET");
expect(err).toMatchObject({ kind: "PATH_OUTSIDE_WORKSPACE" });
});

it("認可後に workspace 内の実体を指す alias へ swap されたら解決先を読む", async () => {
// 同じ swap 窓でも、解決先が workspace 内なら**正当な alias 化**。cache の温度で
// 成否が変わらないよう、ELOOP から cache を捨てて再認可する経路が拾う。
it("一度 read した path が workspace 内の alias へ retarget されたら解決先を読む", async () => {
// 同じ retarget でも、解決先が workspace 内なら**正当な alias 化** (git checkout /
// 同期クライアント等)。成否が「その path を過去に読んだか」で変わらないことを pin する。
// cache を再導入すると canonical は cache 時点の note.md のままで、その末端が今は
// symlink なので O_NOFOLLOW open が ELOOP を返して落ちる。
const real = join(workspaceDir, "real.md");
await writeFile(real, "real body", "utf8");
const path = join(workspaceDir, "note.md");
Expand All @@ -672,7 +686,7 @@ describe.skipIf(process.platform === "win32")("末端 symlink の境界 (#418)",
expect((err as NodeJS.ErrnoException).code).toBe("ELOOP");
});

it("read-base64 も swap 後に外部内容を返さない", async () => {
it("read-base64 も retarget 後に外部内容を返さない", async () => {
// readFileImpl と同じ終状態を data URI 埋め込み経路 (#314) でも pin する。
const secret = join(outside.dir, "secret.png");
await writeFile(secret, Buffer.from([0xde, 0xad]));
Expand Down Expand Up @@ -705,7 +719,7 @@ describe.skipIf(process.platform === "win32")("末端 symlink の境界 (#418)",
await expect(stat(escapeTarget)).rejects.toMatchObject({ code: "ENOENT" });
});

it("認可後に末端を symlink へ swap されたら外部 file を上書きしない", async () => {
it("一度 write した path が外部 file への symlink へ retarget されたら上書きしない", async () => {
// read 側と同じ終状態を write で作る。dangling symlink 経由の escape が「新規作成」
// だけなのに対し、この経路は **既存の外部 file の上書き**まで届く。
const victim = join(outside.dir, "victim.md");
Expand All @@ -723,14 +737,14 @@ describe.skipIf(process.platform === "win32")("末端 symlink の境界 (#418)",
expect(await readFile(victim, "utf8")).toBe("original");
});

it("transient write path が swap されたら再認可が拒否し、capability も消費しない", async () => {
// SaveDialog 由来の transient は canonical 一致でしか通らない。ELOOP 起点の再認可は
// fresh な realpath を使うので、swap 後の解決先は登録済み canonical と一致せず拒否される。
it("transient write path が retarget されたら次の認可が拒否し、capability も消費しない", async () => {
// SaveDialog 由来の transient は canonical 一致でしか通らない。認可は毎回 fresh に
// realpath するので、retarget 後の解決先は登録済み canonical と一致せず拒否される。
// consume は write 成功後だけなので capability は残る(renderer の withRetry 契約)。
const target = join(outside.dir, "exported.md");
await writeFile(target, "original", "utf8");
await registerTransientWritePath(TEST_WIN, target);
// cache を warm にしてから、target を別の外部 file への alias へ差し替える
// 一度 write を通してから、target を別の外部 file への alias へ差し替える
await writeFileImpl(TEST_WIN, target, "exported");
await registerTransientWritePath(TEST_WIN, target);
const other = join(outside.dir, "other.md");
Expand All @@ -745,7 +759,7 @@ describe.skipIf(process.platform === "win32")("末端 symlink の境界 (#418)",
expect(getTransientWritePathsForWindow(TEST_WIN)).toHaveLength(1);
});

it("認可後に workspace 内の実体を指す alias へ swap されたら解決先へ書く", async () => {
it("一度 write した path が workspace 内の alias へ retarget されたら解決先へ書く", async () => {
const real = join(workspaceDir, "real.md");
await writeFile(real, "before", "utf8");
const path = join(workspaceDir, "note.md");
Expand Down
Loading
Loading