Skip to content

feat(windows): the SDK and the runtime get the axes the compiler already had - #448

Merged
Sunrisepeak merged 10 commits into
mainfrom
feat/windows-three-axes
Aug 16, 2026
Merged

feat(windows): the SDK and the runtime get the axes the compiler already had#448
Sunrisepeak merged 10 commits into
mainfrom
feat/windows-three-axes

Conversation

@Sunrisepeak

Copy link
Copy Markdown
Member

Supersedes #446, which carried the design alone. The goal was "one PR if
possible", so the design and its implementation land together.

Windows involves three independent questions, and until now only one of
them had been modelled:

axis question before
origin where did the compiler come from ✅ modelled — but the cost of the special case was spread across ~30 branches
SDK which headers / import libraries searched, no identity at all
runtime distribution does the artifact carry vcruntime140.dll refused outright for MSVC

Their answers vary independently — a managed toolset with the machine's SDK is
what xrgui's CI does today — so any design that binds them to one switch is
wrong. This makes each one a value that is declared and resolved once.


§2 — the SDK is bound, not searched

find_windows_sdk() scanned WindowsSdkDir → the sibling store → the
conventional roots, for both origins. So a pinned msvc@<toolset> was a
pin the environment could silently overwrite, and two machines could build one
manifest against two different SDKs with nothing in the log naming either.
That is not hypothetical: the LNK1104 this round chased was a half-unpacked
payload winning the scan on version number.

msvc@<toolset>  →  the windows-sdk payload installed WITH that toolset.
                   WindowsSdkDir / WindowsSdkVersion are ignored — out loud.
msvc@system     →  today's chain. A machine's things can only be found by
                   looking, and there a declared answer outranks a scan.

A managed toolset with no SDK payload beside it still falls back to the
machine's — working beats failing — and says so, because that build is no
longer reproducible and only that line records it.

§2.3 ucrt@<version> fills a slot reserved since the field existed

RuntimeBinding::runtimeId's own comment has read
"(glibc@…, macos_sdk@…, ucrt@…)" from the start, and nothing in the
repository ever wrote one — so the SDK version never reached
runtimeContractHash and two SDKs shared one build cache.

It is not isomorphic to glibc@, and the code says so where it will be
read:

glibc@2.39 ucrt@10.0.26100.0
binds a payload — headers and .so both in it
really bindable ✅ patchelf makes the artifact run on that copy ucrtbase.dll is an OS component; it cannot be swapped and must not be shipped
so the identity is a runtime binding a compatibility floor declaration

It is therefore not projected into libc, or the private-libc machinery
would go looking for a payload that was never meant to exist. The
starts_with("glibc@") gates become runtime_provider() dispatch, so another
provider reads as "no rules here" rather than "no identity".

§2.4 (a windows_sdk = "…" manifest key) is deliberately not done — see
the design doc. It would sit next to _WIN32_WINNT looking interchangeable
while managing a different thing.


§3.3 — toolchain-coupled now means something on PE

The refusal said the MSVC runtime "ships with the OS/redistributable, not
with the toolchain"
. True of ucrtbase.dll. False of
vcruntime140.dll / msvcp140.dll, which sit in VC\Redist\MSVC\… inside
every toolset — exactly the relationship gcc has to libstdc++.so.

So it takes the same contract. PE has no rpath, so the mechanism is a copy
beside the artifact rather than a search path — same contract, different
mechanism, which is what the three-layer model exists to express.

/MT stays a degradation and a genuine one: a static CRT leaves no DLL to
couple to, and the message says which side won.

The DLL set comes from vc_redist_dir() — the single criterion that
excludes debug_nonredist\, which may not be redistributed. A second,
name-shaped rule here could disagree with it, and disagreeing about that is
a licensing defect, not a bug.


§4 — read the import table; don't run the binary

mcpp pack refused Windows with #if defined(_WIN32), blaming POSIX-only
tools. That was the symptom. The closure came from

LD_TRACE_LOADED_OBJECTS=1 '<binary>'

which runs the artifact — so it crosses neither an OS nor an
architecture
. Porting tar would not have helped, and every tool the 2026-05
design proposed (dumpbin, ImageNtHeader, Compress-Archive) would have
reintroduced the obstacle one layer down, each existing only where the problem
had already gone away.

  • mcpp.pack.binfmt — ELF DT_NEEDED through the segment table; PE imports
    and delay-imports (a missing delay-load fails at the first call through
    it, not at startup — strictly worse to debug).
  • mcpp.pack.zip — mcpp writes the archive itself, because no zip tool exists
    on every host. Entries are stored, which is a real size cost and the
    honest trade: a DEFLATE encoder is the one part that could produce an
    archive that unpacks wrong rather than failing loudly. Deterministic — no
    timestamps are read, so a published checksum means something.
  • §4.3: pack now reads the Contract. It used to stop at compile/link
    flags, so the step deciding which files travel could not see what had been
    promised — on ELF ldd agreed by luck, on PE nothing did.

Deliberately not done, with the cost stated: the ELF closure still runs
the artifact. ldd returns resolved paths; DT_NEEDED returns only names,
and turning names into paths means reimplementing the loader's search order.
Rewriting that under a correct, e2e-covered path is more risk than it buys —
so cross-architecture ELF packing remains unsupported, which is the second
limit §4.1 names.


§1 — contain the origin axis; do not generalise it

  • gcc@system / llvm@system are refused where they are read, naming
    both things the user might have meant. They used to parse and then fail
    elsewhere as xim:gcc@system"no such package", sending the reader
    after a version that was never going to exist. msvc@system is a
    concession to one platform, not a capability the others lack; the
    family-less system escape hatch is untouched.
  • The spec was parsed twice from the same string a dozen lines apart, each
    site drawing its own conclusions. Once now, origin_of() dispatches.
  • resolve_managed_msvc() replaces two hand-written copies of "where does a
    managed toolset live, and why is the fetcher's root wrong for it"
    — the
    reason existed in only one of them.
  • needs_linux_sysroot_payloads() replaces two spellings of one rule whose
    comment claimed they mirrored each other. They did not — the PE term was
    missing from one. Unreachable today, which is how it survived.
  • The toolchain resolution order was documented twice, as "3 steps" and "4
    steps", naming five of the nine real inputs and disagreeing about two. One
    table now, keyed to TcOrigin enumerators so it cannot quietly drift.

Verification

32 new unit tests, and the two that matter most are written so that CI
going green cannot satisfy them:

  • WindowsSdkDirCannotOverrideAPinnedToolsetsSdk — the design's §6 acceptance
    criterion as a unit test. Point WindowsSdkDir somewhere else entirely; the
    payload's SDK must still win, and the note must say the variable was
    ignored. An override that is ignored silently is indistinguishable from one
    that was never set.
  • NothingElseAsksForStagedRuntimeFiles — the deploy flag reaches a copy
    step, so a stray true puts DLLs in an output tree on a platform that has
    no such thing. Swept over every (format × stdlib × contract × /MT ×
    explicit) cell.

e2e 240 — packing a PE from Linux, run in the mingw-cross job because
running it on Windows would prove nothing. It builds a real cross PE, drops a
stand-in for a DLL the EXE imports, packs, and has Python verify the
archive:

  • msvcrt.dll present — the positive half. A parser that read nothing could
    not have produced it.
  • kernel32.dll absent — the negative half. Alone it would also pass for a
    parser that read nothing; together they are decisive.
  • --mode system stages no DLL; toolchain-coupled + --mode system is
    refused, naming the contract and the way out.

Verified locally end to end: on this Linux box, mcpp pack --target x86_64-windows-gnu produced a zip that python3 -m zipfile and unzip -t
both accept, containing the exe and the resolved DLL and nothing else.


Compatibility

change user-visible format migration
ucrt@ in runtimeContractHash ⚠️ cache key changes Windows build caches rebuild once. Same class as any contract change.
SDK bound for managed toolsets behaviour, not format WindowsSdkDir goes from effective to ignored + reported on a pinned toolset
gcc@system refused ⚠️ previously "not implemented", now an explicit error the message gives both replacements
PE toolchain-coupled / PE pack new capability previously degraded / a hard error

Everything else is internal.

The architecture review found where the problems are. This is what to do
about them, written after a round of questions that overturned two shapes in
my own proposal.

Both corrections are recorded in §0.2, because the wrong version looked
equally reasonable and will otherwise be proposed again:

  - generalising `@system` to gcc/llvm is backwards. xlings is a user-space
    OS and mcpp minimises host dependence; `msvc@system` is a Windows
    concession, not a capability three families are missing.
  - a `windows_sdk = "..."` manifest key should not exist. The model already
    reserves the slot (`runtime_binding.cppm:26` documents `ucrt@...` and
    nothing populates it), and a version key would sit next to `_WIN32_WINNT`
    looking interchangeable while controlling a different thing — which is
    worse than not having it.

The design separates three axes that are currently entangled: where the
compiler came from, which SDK is used, and what the artifact ships. They take
values independently — a managed toolset with the machine's SDK is what
xrgui's CI does today — so any design that fuses them into one switch is
wrong.

It also records the finding that makes `mcpp pack` more than a missing
feature: `ldd_parse` computes the dependency closure by RUNNING the binary
(LD_TRACE_LOADED_OBJECTS), so it cannot cross an OS or an architecture by
construction. Reading imports statically is what makes cross-packaging fall
out rather than be added.

The acceptance criteria are written so that none of them can be met by CI
going green — two of them explicitly require a machine POORER than the CI
runner, because "the verification environment is richer than the target" is
the shape that produced most of this round's eleven defect layers.
…eady had

Implements §1 / §2 / §3 of
.agents/docs/2026-08-16-windows-toolchain-three-axes-design.md.

Three questions were entangled because only one of them had ever been
modelled. The compiler got a version axis in the last round; the headers it
compiles against and the runtime the artifact loads did not.

§2 THE SDK IS BOUND, NOT SEARCHED.

`find_windows_sdk()` scanned — WindowsSdkDir, then the sibling store, then
the conventional roots — for BOTH origins. So a pinned `msvc@<toolset>` was a
pin the environment could overwrite, and two machines could build one
manifest against two SDKs with nothing in the log naming either. It is now
resolved by origin: a managed toolset takes the SDK payload from its own
store and ignores WindowsSdkDir/WindowsSdkVersion *out loud*; `msvc@system`
keeps today's chain, because a machine's things can only be found by looking.

A managed toolset with no SDK payload beside it still falls back to the
machine's — working beats failing — and says so, because that build is no
longer reproducible and only that line records it.

§2.3 `ucrt@<version>` FILLS A SLOT THAT HAS BEEN RESERVED SINCE THE FIELD
EXISTED. `RuntimeBinding::runtimeId`'s comment has documented it from the
start and nothing ever wrote one, so the SDK version never reached
`runtimeContractHash` and two SDKs shared one build cache.

It is NOT isomorphic to `glibc@`, and the comment says so where it will be
read: glibc@ binds a payload (headers + .so, patchelf makes the artifact run
on that copy), ucrt@ declares a floor (ucrtbase.dll is an OS component from
Win10 on; mcpp's windows-sdk payload deliberately carries only half of ucrt
and no redistributable). It is therefore not projected into `libc`. The
`glibc@`-prefix gates become `runtime_provider()` dispatch, so another
provider reads as "no rules here" rather than "no identity".

§3.3 `toolchain-coupled` NOW MEANS SOMETHING ON PE. The refusal said the MSVC
runtime "ships with the OS/redistributable, not with the toolchain" — true of
ucrtbase.dll, false of vcruntime140.dll/msvcp140.dll, which sit in
VC\Redist\MSVC\ inside every toolset. That is the relationship gcc has to
libstdc++.so, so it takes the same contract; PE has no rpath, so the
mechanism is a copy beside the artifact rather than a search path. /MT stays
a degradation, and a genuine one: a static CRT leaves no DLL to couple to.

The DLL set comes from `vc_redist_dir()` — the single criterion that excludes
`debug_nonredist\`, which may not be redistributed. A second, name-shaped
rule here could disagree with it, and disagreeing about that is a licensing
defect rather than a bug.

§1 THE ORIGIN AXIS IS CONTAINED, NOT GENERALISED.

- `gcc@system` / `llvm@system` are refused where they are read, naming both
  things the user might have meant. They used to parse and then fail
  elsewhere as `xim:gcc@system` → "no such package", sending the reader after
  a version that was never going to exist. `msvc@system` is a concession to
  one platform, not a capability the other families lack; the family-less
  `system` escape hatch is untouched.
- `resolve_managed_msvc()` replaces two hand-written copies of "where does a
  managed toolset live, and why is the fetcher's `root` wrong for it" — the
  reason existed in only one of them.
- `needs_linux_sysroot_payloads()` replaces two spellings of one rule whose
  comment claimed they mirrored each other. They did not: the PE term was
  missing from one. Unreachable today, which is how it survived.
- The toolchain resolution order was documented twice, as "3 steps" and "4
  steps", naming five of the nine inputs and disagreeing about two. One table
  now, keyed to `TcOrigin` enumerators so it cannot quietly stop matching.

`dist::Format` is derived from the target triple before falling back to the
host, which only ADDS answers — and makes a Windows contract assertable on
the Linux runner that reviews most of this.

Tests: 22 new. The SDK-override criterion is the design doc's §6 acceptance
test as a unit test (point WindowsSdkDir elsewhere; the payload SDK must
still win, and the note must say the variable was ignored); the deploy tests
assert reachability twice over, since a copy edge nothing asks for never runs
under explicit ninja goals.
`mcpp pack` refused Windows with `#if defined(_WIN32)`, and the reason given
was that the tools were POSIX-only. That was the symptom. The cause is one
layer down: the dependency closure comes from

    LD_TRACE_LOADED_OBJECTS=1 '<binary>'

which RUNS the artifact — so it can cross neither an OS (a Linux box cannot
execute a PE) nor an ARCHITECTURE (an x86_64 box cannot execute an aarch64
ELF, same OS or not). Porting `tar` would not have helped, and every tool the
2026-05-19 design proposed — dumpbin, ImageNtHeader, Compress-Archive — would
have reintroduced the obstacle one layer down, because each exists only on the
platform where the problem had already gone away.

mcpp.pack.binfmt reads it out of the file instead: ELF DT_NEEDED through the
segment table, PE imports AND delay-imports (a missing delay-load does not
fail at startup — it fails at the first call through it, which is strictly
worse to debug). Cross-OS packaging is then not a feature that had to be
added; it is what remains once nothing has to be executed.

mcpp.pack.zip writes the archive, for the same reason: no zip tool exists on
every host (GNU tar cannot write zip, `zip(1)` is often absent,
Compress-Archive is Windows-only). Entries are STORED, which is a real size
cost and the honest trade — a DEFLATE encoder is the one part that could
produce an archive that unpacks WRONG rather than failing loudly, and mcpp has
no zlib to borrow one from. Deterministic by construction: no timestamps are
read, so a published checksum means something.

THE CONTRACT NOW REACHES PACKAGING (§4.3). `cxx_runtime` used to stop at the
compile and link flags, so the step that decides which files actually travel
could not see what had been promised — on ELF the `ldd` closure agreed with it
by luck, on PE nothing did. It is now an input:

  toolchain-coupled  the toolchain's runtime directory joins the search set
  host-coupled       it stays OUT, so a vcruntime140.dll in the toolset is not
                     silently swept into a package that promised the host
                     would provide it
  --mode system/static + toolchain-coupled → refused, naming the way out

PE layout is flat, and that is the relocation mechanism rather than a style:
the Win32 loader resolves a DLL from the directory of the executable, and
there is no rpath to point elsewhere. Windows' own DLLs are never bundled —
two of something that must be unique is a broken program, not a heavier one —
but `force_bundle` overrides that, as it always did on ELF.

Verified on a Linux host against a real cross-built PE, not only synthesised
fixtures: e2e 240 builds a mingw target, drops a stand-in for a DLL the EXE
imports, packs, and has PYTHON verify the archive. The msvcrt.dll assertion is
the positive half (a parser that read nothing could not have produced it) and
the kernel32.dll assertion the negative half; together they are decisive. It
runs in the mingw-cross job because running it on Windows would prove nothing.

Docs: the Windows layout, the cross-host story and both size/determinism
consequences in 02-pack-and-release (en+zh); the MSVC half of `cxx_runtime` in
05-mcpp-toml (en+zh), replacing a claim about /MT that stopped being true;
SDK-by-origin and the `@system` rule in 03-toolchains (en; the zh MSVC section
was rewritten — it still described msvc as a system-only toolchain and
`msvc@19.44` as a pin-verify). 2026-05-19-pack-windows-design.md is marked
superseded with what it got wrong and why, since the mistake is instructive.
…came

The design doc gains a status section per item, including the two places the
PLAN was corrected by the implementation and the one half of §4 that was
deliberately not done:

- `dist::Format` now reads the target triple before falling back to the host.
  Everything but MinGW used to ask the host, which made a Windows contract
  unassertable on the Linux runner where most of this gets reviewed. It only
  ADDS answers, so no existing build changes.
- `force_bundle` had to reach the PE system list too. ELF always worked that
  way; making the PE exclusion unconditional would have turned an explicitly
  written decision into decoration.
- The ELF closure still runs the artifact. `ldd` hands back RESOLVED PATHS
  while `DT_NEEDED` gives only names, and turning names into paths means
  reimplementing the loader's search order ($ORIGIN, DT_RPATH before
  LD_LIBRARY_PATH before DT_RUNPATH before ld.so.cache, hwcaps). Rewriting
  that under a correct, e2e-covered path is more risk than it buys — and the
  cost is stated rather than left to be discovered: cross-ARCHITECTURE ELF
  packing is still unsupported, which is the second limit §4.1 names.

`mcpp self doctor` reports the Windows SDK PER ORIGIN. One unlabelled line was
the same "one question, two answerers" shape this axis exists to close: a user
reading it would believe it applied to their pinned build, and it did not.
Every Windows job and the macOS job went red on the same new code, in two
different ways, while gcc was green everywhere:

  Windows  clang 20.1.7 (MSVC ABI) segfaulted COMPILING mcpp.pack —
           0xC0000005, no diagnostic, five jobs at once
  macOS    test_pack_binfmt died with SIGSEGV at RUN time, inside the PE
           import-table test

The parser is not the problem, and that was measured rather than assumed: the
same code is clean under ASan+UBSan with clang 22.1.8 + libc++, and correct
when compiled AS A CLANG MODULE on x86_64 Linux at both -O0 and -O2. What is
left is the compiler, on the two targets neither of those probes covers.

This codebase has been here before. hostflags.cppm exists because adding an
UNUSED helper to a module's anonymous namespace miscompiled a NEIGHBOURING
function under clang + C++20 modules + -O2, and its verdict was "mechanism
unknown, reproduction solid; the cheap response is to not grow that
namespace". Same response here — remove the shapes, keep the behaviour:

  mcpp.pack       a scoped enum from another module as a defaulted member of
                  an EXPORTED struct (`dist::Contract` in `Options`) → a plain
                  bool, since only one of the three values changes anything
                  here; a ranges projection over an imported type's member →
                  std::sort with a comparator
  mcpp.pack.zip   `std::span<const Entry>` across the boundary → the vector by
                  const reference (one caller)
  mcpp.pack.binfmt  `template <typename T> le(...)` in the module purview →
                  four concrete le8/le16/le32/le64; `constexpr std::array` via
                  `std::to_array<>` → plain arrays; ranges algorithms over
                  them → loops

WHICH ONE IT WAS IS NOT ESTABLISHED, and the comments say so rather than
inventing a finding — they were removed together because each CI round costs
minutes and none of the replacements is worse than what it replaced. Every
one is also simpler, so nothing is being paid for the avoidance.

Behaviour is unchanged: the same 10 unit tests and e2e 240 pass.
The combined test died with SIGSEGV on the macOS ARM64 runner and nowhere
else — not under ASan+UBSan with clang 22 + libc++, not as a clang module on
x86_64 Linux, not under gcc. A single test that builds a fixture, identifies
it and parses it cannot say which of the three it was, so each hypothesis
costs a CI round. Three tests now: the fixture is well-formed, identify(),
and needed_names().
The MinGW section says the key does not exist and is silently ignored; the
MSVC section two hundred lines down showed exactly that form as the way to
select `/MT`. Found by writing it out and watching mcpp print "unsupported
key 'linkage' (ignored)" — which is also what a user following the page would
have got, without the page telling them why nothing changed.

`linkage` is exact-triple only (`[target.<triple>]`, or `--static`). Both
language editions now show that, and say which section each key lives in —
the zh page had it right before this round and lost it when the MSVC section
was rewritten from the English one.
…dies

Splitting the test localised the macOS ARM64 SIGSEGV to
ThePeFixtureItselfIsWellFormed — fixture code that calls no module at all. So
it is not mcpp.pack.binfmt, and the three probes that came back clean (ASan +
UBSan under clang 22 + libc++, the same module compiled by clang on x86_64
Linux, gcc everywhere) were looking in the right place for the wrong thing.

The fixture used two `std::span` parameters and two lambdas that mutated a
captured string through a captured cursor. It now takes vectors, indexes
explicitly, and captures nothing — and traces each phase to stderr, so if it
moves again the log names the step instead of costing another CI round.

`#include <cstdio>` is not redundant next to `import std;`: `stderr` is a
macro, and a module cannot export one.
`--mode static` on its own has always meant "the musl-static ELF", and that
is unchanged. But the re-prepare that enforces it ignored `opts.targetTriple`,
so `--mode static --target x86_64-windows-gnu` silently produced a LINUX
build. That was invisible while PE packaging did not exist — there was no
Windows package to notice was missing — and it is a wrong answer now that
there is.

Measured both ways: with an explicit target the output is
`…-x86_64-w64-mingw32-static.zip`; without one it is still
`…-x86_64-linux-musl-static.tar.gz`.
It did its job — the plain rewrite is green on macOS ARM64 — and 24 lines of
stderr in every CI run forever is a poor trade for a crash that is currently
fixed. `MCPP_TEST_TRACE=1` brings it back, which keeps a recurrence one CI
round to localise instead of the four this one cost.
@Sunrisepeak
Sunrisepeak merged commit dc6f903 into main Aug 16, 2026
19 checks passed
Sunrisepeak pushed a commit that referenced this pull request Aug 16, 2026
It says what it produces, and since #448 that is a .zip for a Windows target.
`--format tar` selects "an archive rather than a plain directory"; WHICH
archive follows the artifact, because a .tar.gz full of DLLs is a package most
Windows users cannot open without installing something first.

Found by smoke-testing the RELEASED binary — the behaviour shipped and its
help did not follow.
Sunrisepeak added a commit that referenced this pull request Aug 16, 2026
…s the self-review found (#449)

* ci: bootstrap pin -> 2026.8.17.1, and the round's report

The pin moves only now, in the order the release checklist requires:
released → mirrored (GitHub + GitCode, both GET-verified byte-for-byte) →
in the index (openxlings/xim-pkgindex#643, merged). A pin ahead of what the
index actually has fails every CI job's bootstrap with "package not found".

The report records what shipped, what was deliberately not done and why, and
the three things this round ran into that were not in the plan — including a
clang crash on two targets at once, and a page of the docs that contradicted
itself about a key that does not exist.

* docs: record the ecosystem verification against the released binary

Not the local build and not this machine: `xlings install mcpp@2026.8.17.1`
from the index, into a SubOS created for it, every command through
`--sandbox`. Including the one that matters most — a Windows zip packed from
inside that Linux sandbox, verified by Python's own zipfile reader.

Also records that the local-`gtc` gitcode fill was not needed this time: the
mirror leg passed on its own and was re-checked with a real GET.

* docs: one more finding from the ecosystem run, with its provenance

`mcpp new` + `mcpp add compat.gtest@1.15.2` does not build: the scaffolded
test carries its own main() and the dependency links gtest_main.o into every
test target. Found while verifying the ecosystem, and reproduced on the
PREVIOUS release (2026.8.16.3) in the same sandbox — so it is recorded as
pre-existing, not as something this round did.

* test(windows): assert the ucrt identity actually reaches a real build

The unit tests pin the hash function — two SDK versions produce two hashes.
What they cannot see is whether the value ever gets there on a real Windows
build, and a green Windows CI does not distinguish "the identity is filled
in" from "the code path ran and produced nothing": an empty string flows
through every one of those jobs without a complaint.

So this reads resolution.json and asserts the VALUE — `runtime_id` starts
with `ucrt@`, the contract hash is non-empty (an identity that participates
in nothing is decoration), and it was NOT projected into `libc`, which names
a private libc PAYLOAD that has no ucrt equivalent.

It pins `msvc@system` explicitly. Windows' default toolchain is clang
targeting the MSVC ABI, where clang finds its own SDK and mcpp honestly has
nothing to declare — a test that took the default would assert an empty
identity and pass for the wrong reason.

* docs: the report was missing e2e 241, and the design had no forward pointer

The report described the unit tests and e2e 240 but not the assertion added
after it was written — the one that says the ucrt identity reaches a real
Windows build rather than merely being computable. A report that omits the
test which closes an acceptance criterion is a report that overstates the
other evidence.

The design doc now points forward to it, so a reader who starts at the plan
finds what actually happened.

* fix(pack): a garbage e_lfanew threw instead of answering "not a PE"

`identify()` is documented as never throwing and, for one input, terminated
the process. `std::string_view::substr` throws `std::out_of_range` when
`pos > size()`, and the position came straight out of the file:

    if (b.substr(*lfanew, 4) == "PE\0\0")

A file that starts with "MZ" and has garbage at 0x3C is ordinary malformed
input — a truncated download, a DOS stub, a text file named `.exe` — and
`mcpp pack` calls this on every artifact it packages. Measured rather than
argued: `std::string_view{256 bytes}.substr(0xFFFFFFFF, 4)` throws
`string_view::substr` under libc++.

Every other read in the module goes through a bounds-checked accessor; these
two comparisons were the exceptions, which is exactly how a module whose
stated contract is "total over nonsense" stopped being one. `has_at()` now
does the same job with the same check as the rest.

Found by re-reading the file after it was already merged and green — the
existing truncation test never produced an `e_lfanew` PAST the end, only one
exactly AT it, where `substr` is well-defined and returns empty.

* docs: the report said three findings; the self-review made it four

The fourth is the one the report would most have been improved by hiding: a
crash in code written this round, found by re-reading it AFTER it was merged
and green. Recorded with what the existing test missed and why.

* docs: state the crash's exposure precisely rather than dramatically

The examples I reached for — a half-downloaded file, a DOS stub, a text file
named .exe — are inputs mcpp is never handed: `identify()` receives a LINK
OUTPUT, so the realistic path is a truncated or killed link, not arbitrary
user input. The finding stands on what it is (a function documented as never
throwing did), not on a frequency it does not have.

* docs(cli): `mcpp pack --help` still said tarball

It says what it produces, and since #448 that is a .zip for a Windows target.
`--format tar` selects "an archive rather than a plain directory"; WHICH
archive follows the artifact, because a .tar.gz full of DLLs is a package most
Windows users cannot open without installing something first.

Found by smoke-testing the RELEASED binary — the behaviour shipped and its
help did not follow.

* docs: release verification found SHA256SUMS covers one platform of four

Checking the four published archives against the file whose name promises to
cover them: only `linux-x86_64` is in it — the aarch64, macOS and Windows
archives are absent, and have been for at least v2026.8.16.3 and v2026.8.15.1
too, so this is not something this round did.

The mechanism is structural rather than flaky: `build-release` (linux
x86_64) runs first and writes the file; the other three platforms upload
their own `.sha256` sidecars and nothing appends. Verification IS possible —
the sidecars are there — but not through the file that says SHA256SUMS.

Recorded as outstanding, not fixed: it is the release pipeline, not one of
the three axes, and this PR is already the post-release tail.

---------

Co-authored-by: speak-agent <248744407+speak-agent@users.noreply.github.com>
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.

2 participants