On Windows a canonicalized project root and an env-derived one are two identity keys for one directory, and the disclosure names a project the caller cannot match #159
Labels
No labels
code-review
correctness
dos
performance
security
severity/high
severity/low
severity/medium
tech-debt
Kind/Breaking
Kind/Bug
Kind/Documentation
Kind/Enhancement
Kind/Feature
Kind/Security
Kind/Testing
Priority
Critical
Priority
High
Priority
Low
Priority
Medium
Reviewed
Confirmed
Reviewed
Duplicate
Reviewed
Invalid
Reviewed
Won't Fix
Status
Abandoned
Status
Blocked
Status
Need More Info
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
h-dv/code-index#159
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Found on Windows 11 / MSVC by a session running the full workspace suite at
08a6eed. Two failures, one cause. Invisible on Linux forever, because the two spellings never diverge there.Measured
std::env::temp_dir()yieldsE:\Tempfrom the environment variable.canonicalize()yieldsE:\TEMPfrom the filesystem, viaGetFinalPathNameByHandle, which returns the case the directory entry actually holds. Windows paths are case-insensitive but case-preserving, so these are the same directory.The code compares a canonicalized path against a non-canonicalized one and uses both as identity keys:
Why this is the class we care most about
The disclosure names a root the caller cannot match to anything it holds.
basis_project_rootexists to say which project's answer was carried; here it names a key absent from the very map it is describing. A reader following it finds nothing and cannot tell whether the basis was wrong, the map was wrong, or the field means something else.That is worse than a wrong count. It is a pointer into a namespace the reader does not share.
Scope, stated carefully
It fires only where the environment variable's case differs from the on-disk case. That is ordinary — the reporting box is a stock setup, directory created
TEMP, variable writtenTemp— but not universal. Two eliminations already done:grepfinds noto_uppercaseincrates/indexer/src, so the divergence comes from canonicalization, not a deliberate fold.7b3fc7cand its single failure at08a6eedwas an unrelated TOML-forge bug, now fixed.So: a genuine Windows defect that one box happened to expose, not a CI blocker.
The repair is a policy decision, not a patch
Decide whether a project root is stored canonicalized or as given, and apply that at ONE boundary. Comparing the two forms is the bug; either form alone is fine.
Care needed — this repo has a scar here. A key derived from
canonicalize().unwrap_or(raw)moves when the disk moves, which broke the approval store once already and cost alegacy_project_keymigration. #151 chose the other direction deliberately: a randomworkspace-idsidecar inside the tree, never re-derived from a path. Whatever is chosen here should be consistent with that, and the two should not disagree about what identifies a project.Related
🤖 Generated with Claude Code
https://claude.ai/code/session_01K1zj5VcFJvJt3pQxe9259K
Triage 2026-09-06 at
f6a878a: CLOSING — and, unusually for a Windows-only defect, there is real Windows evidence, not just a Linux green.Verified against master, not against a lane report.
The repair, exactly as this issue specified it
The issue asked for a decision, not a patch: "Decide whether a project root is stored canonicalized or as given, and apply that at ONE boundary. Comparing the two forms is the bug; either form alone is fine."
That is what landed, in
8e6bcac:approval::recorded_project_root,crates/indexer/src/approval.rs:922, whose doc-comment opens "THE ONE SPELLING A PROJECT ROOT IS STORED, COMPARED AND DISCLOSED IN — #159, named so that nobody has to re-derive it."Three things in that doc are worth quoting back, because they are the parts that make this a close rather than a patch:
canonicalize()+approval::strip_verbatim, in that order. The verbatim strip is not incidental — on Windowscanonicalize()returns\\?\E:\proj, so normalising for case and stopping there just moves the mismatch onto a second axis.workspace-idsidecar as a project's identity, deliberately never re-derived from a path; this function is about the spelling a path is stored, compared and shown in. Different questions — the first must be path-independent, the second must have exactly one answer. That is the consistency this issue asked for.project_keyis deliberately NOT routed through it, and the reason is written atapproval::key_input: its input is what every approval record already on an operator's disk is filed under, and changing it would rename their grants out of existence. So this normalises what is stored and compared, never what is hashed — which is precisely thecanonicalize().unwrap_or(raw)scar this issue warned about, avoided rather than repeated.find_callersgives one production call site —approval.rs:2433, insideApprovalRecord::empty, which used to restate the logic inline — plus four test sites.The two failing tests
Both named in this issue still exist and are now routed through the helper:
two_other_projects_never_merge_and_the_basis_is_named—crates/indexer/tests/activation_offer.rs:780the_narrowest_basis_is_carried_not_the_first_by_hash—crates/indexer/tests/activation_offer.rs:1414with
approval::recorded_project_root(tmp.path())at:183,:1500and:1602, andcrates/mcp-server/tests/activation_offer_e2e.rs:213.Linux run (expected to be trivially green here — recorded for completeness):
Why I am not treating that Linux green as the proof
This issue is explicit that the defect is "invisible on Linux forever", so a Linux pass grades nothing. And this repo has a recorded scar exactly here — a
#[cfg(not(windows))]identity body leaves the Windows rule ungraded.recorded_project_rootcarries no cfg gate: it canonicalizes on every platform, so the Windows arm is the same code the Linux arm runs, and it is exercised by the same tests.The real evidence is a green native-Windows CI job on a commit that contains the fix. From
/api/v1/repos/h-dv/code-index/actions/tasks:095eba6fmt + clippy + build + test (windows)and
git merge-base --is-ancestor 8e6bcac 095eba6→ true, as is8e6bcac..f6a878a. That job runscargo test --workspace --no-fail-fast(.forgejo/workflows/ci-windows.yml:268), which includescrates/indexer/tests/activation_offer.rs. So both tests named in this issue have actually executed on Windows, with the fix, and passed.Residual, stated plainly
The native-Windows job is red on master right now (run 608 at
f6a878a, 15 s) — but it dies in the Free disk pre-flight step (ci-windows.yml:117) before compiling anything, which is #179 and is owned elsewhere. It is not a regression of this fix, and run 603 is the closest green ancestor that contains it. If #179's pre-flight turns out to have been masking a later failure in that job, this should be reopened — but nothing currently suggests that, since 603 ran the full suite.🤖 Triage lane, 2026-09-06, master
f6a878aREOPENING — I closed this an hour ago and I was wrong. There is a second boundary, it is verified in source, and this repo's own comment explains why it matters.
Correcting my own close rather than leaving it standing. Everything in the comment above about
approval::recorded_project_rootis accurate and still holds; what it missed is that this issue's rule applies to comparison as well as to storage, and comparison has three implementations, not one.The three implementations of "does this recorded root name this directory"
canonicalize()failscrates/indexer/src/approval.rs:2630roots_matchdeepest_real(recorded) == deepest_real(dir)— strips verbatim ✅crates/daemon/src/lifecycle.rs:711root_matches_ => recorded == dir— raw ❌crates/daemon/src/rpc_index.rs:810root_matches_ => r == expected— raw ❌Read at
f6a878aand unchanged at45cf6e4(that commit touches only CI and test files):rpc_index.rs:810is the same shape behind anOption.Why this is this issue and not a new one
approval.rsalready fixed exactly this, and says so. Its comment at:965-970records that its own last arm used to have this shape and what it cost:That is precisely the failure this issue defines — "Comparing the two forms is the bug" — and the two daemon copies still have the pre-fix shape. Since
recorded_project_rootnow guarantees the stored spelling is canonical-and-stripped, the recorded side is always normalised while the live side on that arm is raw, so the mismatch is now systematic rather than incidental.And the tree already knows the copies exist:
approval.rs:2628names "The daemon'slifecycle::root_matchesrule, which cannot be called from here" — noted, not closed.Second, smaller residual
The serde doors
de_recorded_root(approval.rs:927) andde_recorded_root_opt(:935) applyrecorded_root= strip_verbatim only, no canonicalize. So a record written by a pre-v0.26 build keeps its env-cased spelling when read back. This is acknowledged in-source at:830— "This fixes what THIS build writes and nothing that is already on disk" — so it is a known bound rather than an oversight, but on Windows it means a read root and a freshly minted root can still differ in case.What is NOT a residual, so nobody re-litigates it
project_key/key_input(approval.rs:721) is deliberately not routed throughrecorded_project_root, with the migration reason written at:690-720: its input is what every approval record already on an operator's disk is filed under. That is a documented decision, and correct.Also settled and not to be redone: the Windows arm is graded, not cfg-skipped.
approval.rs:812-819explicitly refuses the#[cfg(not(windows))]-identity shape this repo has been bitten by, andactivation.rs:1913/1969/2021run on both platform arms.activation_offer.rs:1600even reproduces the case divergence on Linux via a symlink, with anassert_ne!proving the two spellings really differ. Native-Windows CI ran green at095eba6with the fix in.What closing this now requires
Fold
lifecycle::root_matchesandrpc_index::root_matchesonto the same ruleapproval::roots_matchuses — one clause, not three copies — or state in-source why the daemon's two cannot reach it and grade the fallback arm directly. Given how this project prefers generic mechanisms over a fix per site, the first is the one that matches the rest of the tree.Reopened.
🤖 Triage lane, 2026-09-06, master
45cf6e4FIXED, merged as
a80eb61. Verified in the tree at1d81180:lifecycle.rs:745andrpc_index.rs:821both delegate tocode_index_indexer::approval::roots_match, which is nowpuband the only rule.The three implementations agreed on the easy arm and differed on the one that matters: where either side fails to canonicalize, the two daemon copies compared raw strings while the rule compares deepest-real forms. Since
recorded_project_rootguarantees the stored side is canonical-and-stripped, the raw comparison was systematically wrong rather than incidentally — the daemon calling its own database foreign.rpc_indexkeeps only what is genuinely its own:Nonemeans unverifiable, which is not a mismatch.The mutation that proves it is the fold and not a clause: un-folding the daemon copy back to its pre-fix body reproduces the same RED.
Two things found on the way, both worth more than the fix:
root_matches's doc had come unglued and was sitting ondigest_of— an FNV-1a hash documented as a path comparator. Rustdoc is happy: a doc comment is syntactically valid wherever it lands. Restored.1d81180. It planted a directory symlink with a bare.unwrap(), and a symlink is a privilege on Windows — the CI account has neither Developer Mode nor elevation. The knowledge now lives once, incode_index_test_support::symlink, returning a three-state outcome; and the test grades on Windows via NTFS case-insensitivity rather than skipping, because a skip that keeps the suite green is the same vacuity trap in a new costume.Closing.