A Tupfile could name an output outside the build root on Windows and have it
accepted. pup::path split on '/' alone, so `..\victim.txt` was one opaque
component that was never `..`, and the containment check saw nothing to
refuse -- the #385 failure with the separator swapped. Three more spellings
escaped the same way, unrecognised as rooted at all: the UNC
`\\host\share\victim.txt`, whose target is another machine; the
drive-relative `C:victim.txt`, which carries no separator to fix; and the
root-relative `\victim.txt`.
The module already knew half of this: root_length has read `C:\` as a drive
root since it was written, while every splitter beside it read only '/'. The
fix is to stop disagreeing -- on Windows the separator alphabet is both
bytes and the root forms are the platform's, so the containment predicate
refuses all four spellings without being touched. src/graph/builder.cpp is
not in this diff. POSIX is unchanged, where a backslash is an ordinary
character in a filename and accepting `..\victim.txt` is correct; both
halves are pinned, because a rule pinned on one platform reads as a defect
report against the other.
The same disagreement recorded a nested output as one component while
Windows created two on disk, so the record's key and the file it named were
different paths. That is repaired here too, with its own pin and its own
discharge.
Teaching root_length the separator-less root `C:` then split the module
against itself a second time, one function over: filename, parent and join
locate the root by searching for a separator byte, which every other root
contains and that one does not. filename returned the whole `C:a`, parent
returned nothing where `/a` yields `/`, and join emitted `C:/a` -- silently
promoting a drive-relative path to a drive-absolute one. The two splitters
now clamp to root_length, and join shares append_root's predicate rather
than restating it, so the two cannot drift apart. stem and extension inherit
through filename, which a pin proves rather than assumes.
Rejected: rejecting backslash paths in the lexer instead. That guards one
door of a many-door room -- env-carried values and generated rule text reach
the module without passing it -- and it does not repair the recorded-key
defect at all.
INDEX_VERSION 22 -> 23: recorded path spellings change meaning on Windows.
Verified by cross-compiling the suite with clang-cl and running it under
wine: every pin was observed failing against the pre-fix module and then
passing, including the drive-relative promotion. That is a real Windows
binary executing real Win32 path semantics, but it is wine rather than
Windows, and it is the one place here where the evidence is a stand-in for
the thing itself. The POSIX pins were shown to discriminate by implementing
the naive fix and watching them fail. Linux 170755 assertions in 844 cases;
Windows 170415 in 436 under the tag filter CI uses; format, tidy, iwyu and
spec-check each at exit 0.
A share-aware UNC root, which would stop `..` popping the host and share,
was built first and refuted by the module's own laws test: `//a/.` makes "."
the share and normalize emits a path that is not in normal form. The result
stays rooted and therefore stays refused, so the residue is the spelling of
an already-rejected path; it is filed as #411 with that input as the
constraint.
Written by the team's builder across two phases against an architect's
ruling, with three cross-model reviews. The third found that the
split/rejoin law added here does not catch the drive-relative regression on
its own -- parent's empty result and join's empty-left arm cancel exactly --
and the added assertion that an absolute path's parent stays absolute is
what closes it. Both halves were proven by probe.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014wx1bWwRf23eFT82y9D641
On Windows a Tupfile could declare an output that silently writes outside the build root, by four spellings.
pup::pathsplit on/alone, so..\victim.txtwas a single opaque component that was never..; and three spellings were not recognised as rooted at all — the UNC\\host\share\victim.txt, whose target is another machine, the drive-relativeC:victim.txt, which carries no separator for a separator fix to reach, and the root-relative\victim.txt.The fix is that the module stops disagreeing with the platform it runs on: on Windows the separator alphabet is both bytes and the root forms are Win32's own. The containment predicate in
src/graph/builder.cppis not in this diff — it refuses all four spellings unchanged. POSIX behaviour is unchanged, where a backslash is an ordinary filename character and accepting..\victim.txtis correct, and both halves of that asymmetry are pinned.A second live defect is repaired here. The same disagreement recorded a nested output as one component while Windows created two on disk, so the record's key and the file it named were different paths. It has its own pin and its own
discharge:line onREQ-OUTPUT-CANONICAL, not a footnote.A third, introduced by the fix itself and caught in review before commit. Teaching
root_lengththe separator-less rootC:split the module against itself one function over:filename,parentandjoinlocate a root by searching for a separator byte, whichC:does not contain.join("C:","a")emittedC:/a, silently promoting a drive-relative path to a drive-absolute one. The splitters now clamp toroot_length, andjoinsharesappend_root's predicate rather than restating it.Index version
INDEX_VERSION22 → 23, because recorded path spellings change meaning on Windows. Two consequences worth stating plainly:INDEX_LAYOUT_FLOORis untouched (no struct changed size), so the recovery read still admits old records whose\-bearing paths were never right: pre-existing wrongness, not widened. One consequence of that reach: on the first build after upgrading, a v22 record's flatsub\gen.txtis compared against a v23 graph's two components, which cannot make the ownership guard false-fire — an absent-but-known path is skipped, not rejected — so the worst outcome is one redundant delete-and-regenerate of a regenerable artifact, once.Verification
Cross-compiled with clang-cl and run under wine: every pin was observed failing against the pre-fix module and then passing, including the drive-relative promotion. This is a real Windows binary executing real Win32 path semantics, but it is wine rather than Windows — the one place where the evidence is a stand-in for the thing itself. The POSIX pins were shown to discriminate by implementing the naive fix and watching them fail.
~[e2e]~[shell](the filter Windows CI uses): 170415 in 436format,tidy,iwyu,spec-check: exit 0 eachEvery new pin is non-
[e2e], deliberately:[e2e]is excluded on Windows CI, so an E2E pin would not run where it matters.Known residue
A share-aware UNC root — which would stop
..popping the host and share — was built first and refuted by the module's own laws property test://a/.makes.the share andnormalizeemits a path not in normal form. The result stays rooted and therefore stays refused, so the residue is confined to the spelling of an already-rejected path. Tracked separately in #411, carrying that input as the constraint any candidate fix must satisfy.The call-site inventory required for this change found 82 splitter call sites, 7 of them pattern-shaped. All seven are safe: putup's pattern alphabet gives
\no meaning, and there is no regex engine insrc/at all, so upstream tup's PCRE exclusions do not apply here.Fixes #388