fix(release): the Windows archive only Windows could open — v0.31.1 #293

Merged
buildagent merged 1 commit from fix-windows-archive-separator into master 2026-09-22 19:22:03 +02:00
Member

What happened

v0.31.0 shipped. A user ran the documented Windows install command against a clean machine within hours:

install.ps1: REFUSING THE ARCHIVE: `code-index-v0.31.0-windows-x86_64\.code-index.toml.example`
  contains a BACKSLASH. The zip format says forward slash, so a backslash is either a
  non-conforming writer or a traversal aimed at Windows specifically.

The installer was right. All seven members used a backslash separator — and so did v0.30.1's, and v0.30.0's. APPNOTE 4.4.17.1 says the separator is /. The supported Windows install path had been broken since v0.30.0, the release that added the refusal, and three releases went out over it with every gate green.

Why the writer produced them

release.yml packed the archive with ZipFile::CreateFromDirectory, having moved to it in #231 specifically to avoid backslashes, recording:

NOT Compress-Archive. Windows PowerShell 5.1's implementation has written entry names with backslash separators, which Expand-Archive tolerates and unzip on any other OS does not … ZipFile writes '/' unconditionally.

Every clause is true except the last, and only on one runtime. CreateFromDirectory normalises to / on .NET Core and .NET 5+; on .NET Framework it emits Path.DirectorySeparatorChar. All ten PowerShell steps declare shell: powershell — Windows PowerShell 5.1 — which is .NET Framework. The fix was real and the hazard correctly named; the premise was wrong about the runtime it ran on.

Why nothing saw it — the more useful half

Three instruments, each reasonable, all blind in the same direction:

  • windows-archive-smoke unpacked with Expand-Archive, the one unpacker that tolerates a backslash — named as such in the comment above, one screen earlier in the same file. Every path assertion after the unpack then passed, because the members had already been written out as a directory tree.
  • installer_ps1_e2e.rs grades install.ps1's refusals thoroughly — the backslash check has its own test, and its first mutation survived and was strengthened until it bit — but against fixture archives the test builds itself. The check and the malformed artefact were never in the same room.
  • The writer asserted nothing about what it had written.

No gate was missing an assertion. They were pointed at synthetic inputs, or made after a normalising step had destroyed the evidence.

The repairs

  1. Entry names are written by hand and read back. CreateEntry per file with "$name/$($m.Name)" — a literal /, never Join-Path. The step then reopens the finished zip with OpenRead and throws on a backslash or an unexpected root. Asserting what we meant to write would only restate the line that writes it.
  2. The smoke job looks before it unpacks. Entry names are read out of the shipped file first; Expand-Archive runs only after they pass. Order is the whole claim.
  3. The supported install path now runs against the shipped artefact. windows-archive-smoke checks out the tree and runs the repository's own install.ps1 against the archive this run just built (-BaseUrl takes a directory — the offline-install path), then runs each of the four installed binaries and matches --version against the tag. This is the step whose absence let it ship three times; it closes the class, not the instance.
  4. crates/indexer/tests/windows_archive_shape.rs (5 tests) moves all of it to cargo test. All four mutations run red.

Four gates caught my own change, and each was right

  • a_powershell_block_carries_no_byte_windows_powershell_would_misdecode — I used U+2500 box-drawing characters in a PowerShell run: block. 5.1 reads a BOM-less script as cp1252, so the byte does not survive; in a double-quoted string the file would not parse. The same family as the bug being fixed.
  • every_workflow_job_measures_free_disk_before_it_builds — adding a checkout put the smoke job in scope, and that rule keeps no exemption list on purpose.
  • every_file_that_names_this_release_is_a_declared_site — two of my new comments named the live version, making them version sites. (This gate, added last release, has now caught me four times.)
  • rustfmt.

Three further errors were mine, all one shape — reading prose as structure: the gate matched Expand-Archive in its own explanatory comment; its job-boundary scan stopped at a two-space comment ending in a colon; and contains("OpenRead") was satisfied by a mutation that left the word in a comment. Every assertion there now requires a line that runs. Separately, three steps share the name Package release archive, so the first draft graded the linux tar step.

version_bump.sh called a complete bump incomplete

rusqlite sits at 0.31.0, so after all thirteen of our crates correctly moved to 0.31.1, Cargo.lock still contained the string and the self-check exited 1. Lockfiles are now exempt from that half only — they must still state the new version, so a lockfile cargo failed to rewrite is still caught. Verified by a round-trip bump in a copy of the tree. Second time that one coincidence has had to be designed around; the first is why cargo owns the lockfiles at all.

Gates

Green on one unchanged tree (hash recorded before and after):

gate result
cargo fmt --all --check 0
cargo clippy --workspace --all-targets -D warnings 0
cargo doc (-D warnings, --document-private-items) 0
workspace tests 374 suites, 4008 passed, 0 failed
e2e, daemon leg 73 suites, 840 passed, 0 failed
guest gates (--locked) 0

For users on v0.31.0 or earlier

Expand-Archive tolerates the malformed archive, so a manual unpack works and the published .sha256 still verifies the bytes. v0.31.1 installs normally.

No schema migration, no plugin activation change, no change to any published catalog field.

🤖 Generated with Claude Code

https://claude.ai/code/session_0126PDDLB4wNHxKXvWM1VNmu

## What happened v0.31.0 shipped. A user ran the documented Windows install command against a clean machine within hours: ``` install.ps1: REFUSING THE ARCHIVE: `code-index-v0.31.0-windows-x86_64\.code-index.toml.example` contains a BACKSLASH. The zip format says forward slash, so a backslash is either a non-conforming writer or a traversal aimed at Windows specifically. ``` **The installer was right.** All seven members used a backslash separator — and so did v0.30.1's, and v0.30.0's. APPNOTE 4.4.17.1 says the separator is `/`. The supported Windows install path had been broken since **v0.30.0, the release that added the refusal**, and three releases went out over it with every gate green. ## Why the writer produced them `release.yml` packed the archive with `ZipFile::CreateFromDirectory`, having moved to it in #231 **specifically to avoid backslashes**, recording: > NOT `Compress-Archive`. Windows PowerShell 5.1's implementation has written entry names with backslash separators, which `Expand-Archive` tolerates and `unzip` on any other OS does not … `ZipFile` writes '/' unconditionally. Every clause is true except the last, and only on one runtime. `CreateFromDirectory` normalises to `/` on .NET Core and .NET 5+; on **.NET Framework** it emits `Path.DirectorySeparatorChar`. All ten PowerShell steps declare `shell: powershell` — Windows PowerShell 5.1 — which is .NET Framework. The fix was real and the hazard correctly named; the premise was wrong about the runtime it ran on. ## Why nothing saw it — the more useful half Three instruments, each reasonable, all blind in the same direction: - **`windows-archive-smoke`** unpacked with `Expand-Archive`, the one unpacker that *tolerates* a backslash — named as such in the comment above, one screen earlier in the same file. Every path assertion after the unpack then passed, because the members had already been written out as a directory tree. - **`installer_ps1_e2e.rs`** grades install.ps1's refusals thoroughly — the backslash check has its own test, and its first mutation survived and was strengthened until it bit — but against **fixture** archives the test builds itself. The check and the malformed artefact were never in the same room. - **The writer** asserted nothing about what it had written. No gate was missing an assertion. They were pointed at synthetic inputs, or made after a normalising step had destroyed the evidence. ## The repairs 1. **Entry names are written by hand and read back.** `CreateEntry` per file with `"$name/$($m.Name)"` — a literal `/`, never `Join-Path`. The step then reopens the finished zip with `OpenRead` and throws on a backslash or an unexpected root. Asserting what we *meant* to write would only restate the line that writes it. 2. **The smoke job looks before it unpacks.** Entry names are read out of the shipped file first; `Expand-Archive` runs only after they pass. Order is the whole claim. 3. **The supported install path now runs against the shipped artefact.** `windows-archive-smoke` checks out the tree and runs the repository's own `install.ps1` against the archive this run just built (`-BaseUrl` takes a directory — the offline-install path), then **runs** each of the four installed binaries and matches `--version` against the tag. This is the step whose absence let it ship three times; it closes the class, not the instance. 4. **`crates/indexer/tests/windows_archive_shape.rs`** (5 tests) moves all of it to `cargo test`. All four mutations run red. ## Four gates caught my own change, and each was right - **`a_powershell_block_carries_no_byte_windows_powershell_would_misdecode`** — I used U+2500 box-drawing characters in a PowerShell `run:` block. 5.1 reads a BOM-less script as cp1252, so the byte does not survive; in a double-quoted string the file would not parse. The same family as the bug being fixed. - **`every_workflow_job_measures_free_disk_before_it_builds`** — adding a checkout put the smoke job in scope, and that rule keeps no exemption list on purpose. - **`every_file_that_names_this_release_is_a_declared_site`** — two of my new comments named the live version, making them version sites. (This gate, added last release, has now caught me four times.) - **rustfmt.** Three further errors were mine, all one shape — **reading prose as structure**: the gate matched `Expand-Archive` in its own explanatory comment; its job-boundary scan stopped at a two-space comment ending in a colon; and `contains("OpenRead")` was satisfied by a mutation that left the word in a comment. Every assertion there now requires a line that **runs**. Separately, three steps share the name `Package release archive`, so the first draft graded the **linux `tar`** step. ## `version_bump.sh` called a complete bump incomplete `rusqlite` sits at 0.31.0, so after all thirteen of our crates correctly moved to 0.31.1, `Cargo.lock` still contained the string and the self-check exited 1. Lockfiles are now exempt from that half **only** — they must still state the *new* version, so a lockfile cargo failed to rewrite is still caught. Verified by a round-trip bump in a copy of the tree. Second time that one coincidence has had to be designed around; the first is why cargo owns the lockfiles at all. ## Gates Green on one unchanged tree (hash recorded before and after): | gate | result | |---|---| | `cargo fmt --all --check` | 0 | | `cargo clippy --workspace --all-targets -D warnings` | 0 | | `cargo doc` (`-D warnings`, `--document-private-items`) | 0 | | workspace tests | 374 suites, **4008 passed, 0 failed** | | e2e, daemon leg | 73 suites, **840 passed, 0 failed** | | guest gates (`--locked`) | 0 | ## For users on v0.31.0 or earlier `Expand-Archive` tolerates the malformed archive, so a manual unpack works and the published `.sha256` still verifies the bytes. v0.31.1 installs normally. No schema migration, no plugin activation change, no change to any published catalog field. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_0126PDDLB4wNHxKXvWM1VNmu
fix(release): the Windows archive only Windows could open (v0.31.1)
All checks were successful
CI / cargo fmt (pull_request) Successful in 48s
CI / OSS corpus tier-3 scale (nightly) (pull_request) Has been skipped
CI / Grammar rebuild from source (nightly) (pull_request) Has been skipped
CI / CI lane wall-clock headroom (pull_request) Successful in 51s
CI / guest crates (fmt, clippy, doc) (pull_request) Successful in 1m11s
CI / cargo doc (intra-doc links) (pull_request) Successful in 4m30s
CI / cargo check (MSRV 1.98) (pull_request) Successful in 5m44s
CI / cargo test (abi, 32-bit + wasm32) (pull_request) Successful in 6m1s
CI / cargo clippy (pull_request) Successful in 6m4s
CI / cargo deny (pull_request) Successful in 6m11s
CI / cargo check (windows-gnu) (pull_request) Successful in 6m27s
CI / OSS corpus (tier 1) (pull_request) Successful in 26m30s
CI / cargo test (pull_request) Successful in 26m29s
CI / cargo test (daemon transport) (pull_request) Successful in 8m55s
CI / Plugin path cost + pool throughput (nightly) (pull_request) Has been skipped
CI (Windows) / fmt + clippy + build + test (windows) (pull_request) Successful in 56m14s
bcf0648e0e
v0.31.0 shipped, and a user ran the documented Windows install command
against a clean machine within hours:

    install.ps1: REFUSING THE ARCHIVE:
      `code-index-v0.31.0-windows-x86_64\.code-index.toml.example`
      contains a BACKSLASH.

The installer was right. Every one of the archive's seven members used a
backslash separator, and so did v0.30.1's, and v0.30.0's. APPNOTE
4.4.17.1 says the separator is `/`. The supported Windows install path
had been broken since v0.30.0 -- the release that ADDED the refusal --
and three releases went out over it with every gate green.

WHY THE WRITER PRODUCED THEM. `release.yml` packed the archive with
`ZipFile::CreateFromDirectory`, having moved to it in #231 SPECIFICALLY
to avoid backslashes, recording that `ZipFile` "writes '/'
unconditionally". True on .NET Core and .NET 5+. Every PowerShell step
in that file declares `shell: powershell` -- Windows PowerShell 5.1, on
.NET FRAMEWORK -- where it emits `Path.DirectorySeparatorChar`. The fix
was real, the hazard was named correctly, and the premise was wrong
about the runtime it ran on.

WHY NOTHING SAW IT, which is the more useful half. Three instruments,
each reasonable, all blind the same way:

  * `windows-archive-smoke` unpacked with `Expand-Archive` -- the ONE
    unpacker that tolerates a backslash, named as such in the comment
    above, one screen earlier in the same file. Every path assertion
    after the unpack then passed, because the members had already been
    written out as a directory tree.
  * `installer_ps1_e2e.rs` grades install.ps1's refusals thoroughly, but
    against FIXTURE archives it builds itself. The check and the
    malformed artefact were never in the same room.
  * The writer asserted nothing about what it had written.

No gate was missing an assertion. They were pointed at synthetic inputs,
or made after a normalising step had destroyed the evidence.

THE REPAIRS:

  * Entry names are built by hand as `"$name/$($m.Name)"` -- a literal
    `/`, never `Join-Path` -- and the step then REOPENS the finished zip
    and throws on a backslash or an unexpected root. Asserting what we
    meant to write would only restate the line that writes it.
  * The smoke job reads the shipped entry names BEFORE `Expand-Archive`.
    Order is the whole claim.
  * `windows-archive-smoke` now checks out the tree and runs the
    repository's own `install.ps1` against the archive this run just
    built (`-BaseUrl` takes a directory), then RUNS each installed
    binary and matches `--version` against the tag. This is the step
    whose absence let it ship three times.
  * `crates/indexer/tests/windows_archive_shape.rs` moves all of it to
    `cargo test`. All four mutations run red.

FOUR GATES CAUGHT MY OWN CHANGE, and each was right:

  * `a_powershell_block_carries_no_byte_windows_powershell_would_misdecode`
    -- I put U+2500 box-drawing characters in a PowerShell `run:` block.
    5.1 reads a BOM-less script as cp1252. The same family of bug as the
    one being fixed.
  * `every_workflow_job_measures_free_disk_before_it_builds` -- adding a
    checkout put the smoke job in scope, and that rule keeps no
    exemption list on purpose.
  * `every_file_that_names_this_release_is_a_declared_site` -- two of my
    comments named the live version, making them version sites.
  * rustfmt.

And three errors of my own in the new gate, all one shape -- reading
prose as structure: it matched `Expand-Archive` in its own explanatory
comment, its job-boundary scan stopped at a two-space comment ending in
a colon, and `contains("OpenRead")` was satisfied by a mutation that
left the word in a comment. Every assertion there now requires a line
that RUNS. Separately, three steps share the name `Package release
archive`, so the first draft graded the LINUX `tar` step.

`version_bump.sh` also called a complete bump INCOMPLETE: `rusqlite`
sits at 0.31.0, so after all thirteen of our crates had moved to 0.31.1
the lockfile still contained the string. Lockfiles are now exempt from
that half only -- they must still state the NEW version. Verified by a
round-trip bump in a copy of the tree.

Gates, green on one unchanged tree: fmt, clippy -D warnings, rustdoc
-D warnings, 374 workspace suites / 4008 tests, 73 e2e suites / 840
tests on the daemon leg, guest gates under --locked.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0126PDDLB4wNHxKXvWM1VNmu
buildagent deleted branch fix-windows-archive-separator 2026-09-22 19:22:03 +02:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
h-dv/code-index!293
No description provided.