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
Original file line number Diff line number Diff line change
Expand Up @@ -300,6 +300,10 @@ LD_TRACE_LOADED_OBJECTS=1 '<binary>'

## 6.5 落地状态(2026-08-17,mcpp 2026.8.17.1)

> 落地过程中撞到的、不在计划里的三件事(clang 在两个目标上同时出问题、一处文档
> 自相矛盾、`--mode static` 覆盖用户 target),连同验证结果与遗留账,记在
> `2026-08-17-windows-three-axes-final-report.md`。

**§1 / §2 / §3 / §4 全部实现,§2.4 明确不做。** 逐条对应:

| 条目 | 状态 | 落点 |
Expand Down
288 changes: 288 additions & 0 deletions .agents/docs/2026-08-17-windows-three-axes-final-report.md

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion .xlings.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"workspace": {
"mcpp": "2026.8.15.1"
"mcpp": "2026.8.17.1"
}
}
12 changes: 9 additions & 3 deletions src/cli.cppm
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ void print_usage() {
std::println(" mcpp update [pkg] Re-resolve deps and rewrite mcpp.lock");
std::println(" mcpp search <keyword> Search packages in registries");
std::println(" mcpp publish [--dry-run] Publish package to default registry");
std::println(" mcpp pack [--mode <m>] Build + bundle a tarball (m: system|vendored|self-contained|static)");
std::println(" mcpp pack [--mode <m>] Build + bundle an archive (m: system|vendored|self-contained|static)");
std::println(" mcpp emit xpkg [-V VER] [-o FILE] Generate xpkg Lua entry");
std::println(" mcpp xpkg parse <file.lua> [--json] Validate an xpkg descriptor (resolver grammar)");
std::println("");
Expand Down Expand Up @@ -399,13 +399,19 @@ int run(int argc, char** argv) {
.option(cl::Option("allow-dirty").help("Allow uncommitted changes"))
.action(wrap_rc(cmd_publish)))
.subcommand(cl::App("pack")
.description("Build + bundle into a self-contained tarball")
// "archive", not "tarball": a Windows target produces a .zip, and
// the help said tarball while the code had already stopped
// agreeing. `--format tar` likewise 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.
.description("Build + bundle into a self-contained archive")
.option(cl::Option("mode").takes_value()
.help("system | vendored (default) | self-contained | static"))
.option(cl::Option("target").takes_value()
.help("Triple, e.g. x86_64-linux-musl"))
.option(cl::Option("format").takes_value()
.help("tar (default) | dir"))
.help("tar (default; .zip for a Windows target) | dir"))
.option(cl::Option("output").short_name('o').takes_value()
.help("Override output path"))
.action(wrap_rc(cmd_pack)))
Expand Down
17 changes: 15 additions & 2 deletions src/pack/binfmt.cppm
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,19 @@ std::optional<std::uint64_t> le64(std::string_view b, std::size_t off) {
return v;
}

// Do the bytes at `off` equal `lit`?
//
// NOT `b.substr(off, n) == lit`, and the difference is a crash.
// `std::string_view::substr` THROWS `std::out_of_range` when `pos > size()`,
// and `off` here comes from a field READ OUT OF THE FILE — a file starting
// with "MZ" whose `e_lfanew` is garbage is ordinary malformed input, not a
// reason to terminate. This module's contract is that it is total over
// nonsense; one unchecked `substr` was enough to break that promise.
bool has_at(std::string_view b, std::size_t off, std::string_view lit) {
if (off > b.size() || b.size() - off < lit.size()) return false;
return b.compare(off, lit.size(), lit) == 0;
}

// NUL-terminated string at `off`, bounded by the file end.
std::optional<std::string> cstr(std::string_view b, std::size_t off) {
if (off >= b.size()) return std::nullopt;
Expand Down Expand Up @@ -309,7 +322,7 @@ pe_needed(std::string_view b) {
auto lfanew = le32(b, 0x3C);
if (!lfanew) return std::unexpected("PE: no e_lfanew");
const std::size_t nt = *lfanew;
if (b.substr(nt, 4) != std::string_view("PE\0\0", 4))
if (!has_at(b, nt, std::string_view("PE\0\0", 4)))
return std::unexpected("PE: no PE\\0\\0 signature at e_lfanew");

auto numSections = le16(b, nt + 6);
Expand Down Expand Up @@ -445,7 +458,7 @@ Ident identify(const std::filesystem::path& binary) {
// Saying "PE" for a file that has none would send the caller into a
// parser that cannot succeed.
if (auto lfanew = detail::le32(b, 0x3C)) {
if (b.substr(*lfanew, 4) == std::string_view("PE\0\0", 4)) {
if (detail::has_at(b, *lfanew, std::string_view("PE\0\0", 4))) {
id.format = Format::Pe;
if (auto m = detail::le16(b, *lfanew + 4))
id.arch = detail::pe_arch(*m);
Expand Down
72 changes: 72 additions & 0 deletions tests/e2e/241_windows_ucrt_runtime_identity.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
#!/usr/bin/env bash
# requires: msvc python3
# 241_windows_ucrt_runtime_identity.sh — the Windows SDK has an identity, and
# it reaches the build's runtime contract.
#
# `RuntimeBinding::runtimeId`'s own comment has documented `ucrt@…` since the
# field existed, and nothing ever wrote one. The cost was not cosmetic: the
# SDK version never reached `runtimeContractHash`, which keys the build cache,
# so TWO SDKs shared ONE cache key — the version axis simply stopped existing
# one layer below the compiler.
#
# The unit tests pin the hash function (two versions → 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 asserts the VALUE.
#
# WHY IT PINS msvc@system EXPLICITLY. The identity is produced where mcpp
# RESOLVES the SDK itself, which is the native cl.exe path. Windows' default
# toolchain is clang targeting the MSVC ABI, and there clang finds its own SDK
# — mcpp does not know which one, so there is honestly nothing to declare. A
# test that took the default would therefore assert an empty identity and pass
# for the wrong reason.
set -e

TMP=$(mktemp -d)
trap "rm -rf $TMP" EXIT
cd "$TMP"

"$MCPP" new ucrtid > /dev/null
cd ucrtid
cat >> mcpp.toml <<'EOF'

[toolchain]
windows = "msvc@system"
EOF

"$MCPP" build > build.log 2>&1 || { cat build.log; exit 1; }

RES="$(find target -name resolution.json | head -1)"
[[ -n "$RES" ]] || { echo "FAIL: no resolution.json"; exit 1; }

python3 - "$RES" <<'PY'
import json, sys
d = json.load(open(sys.argv[1]))
b = d.get("runtime", {}).get("binding", {})
rid = b.get("runtime_id", "")

assert rid.startswith("ucrt@"), (
"the Windows runtime identity is not filled in: runtime_id="
f"{rid!r}. The SDK version never reaches runtimeContractHash, so two "
"SDKs share one build-cache key.")

version = rid[len("ucrt@"):]
assert version and version[0].isdigit(), f"implausible SDK version in {rid!r}"

# The identity is only worth anything if it PARTICIPATES. An empty contract
# hash would mean the value was recorded and then not used for anything.
assert b.get("contract_hash"), "runtime binding has no contract hash"

# ...and it must NOT have been projected into the private-libc field. That
# field is read by the loader/patchelf machinery, and ucrt has no payload for
# it to name — `ucrtbase.dll` is a Windows component. See
# mcpp.platform.runtime_binding on why the two providers are not isomorphic.
assert not b.get("libc"), (
f"ucrt was projected into `libc` ({b.get('libc')!r}); that field names a "
"private libc PAYLOAD, and there is no such thing for ucrt")

print(f"OK: runtime identity {rid}, contract {b['contract_hash']}")
PY

echo "OK"
21 changes: 21 additions & 0 deletions tests/unit/test_pack_binfmt.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -324,6 +324,27 @@ TEST(PackBinfmt, ADosStubWithoutAPeSignatureIsNotAPe) {
EXPECT_FALSE(bf::needed_names(f.path).has_value());
}

TEST(PackBinfmt, AGarbageELfanewIsRejectedAndDoesNotThrow) {
// An "MZ" file whose `e_lfanew` points past the end is ordinary malformed
// input: a truncated download, a DOS stub, a text file named `.exe`.
//
// This crashed. `std::string_view::substr` THROWS `std::out_of_range` when
// `pos > size()`, and the offset comes straight out of the file — so
// `identify()`, which is documented as never throwing, terminated the
// process instead of answering Unknown. Bounds-checked comparison now.
for (std::uint32_t lfanew : {0xFFFFFFFFu, 0x7FFFFFFFu, 0x10000u, 0x101u}) {
std::string b(0x100, '\0');
b[0] = 'M'; b[1] = 'Z';
put(b, 0x3C, lfanew, 4);
TempFile f{"mzjunk", b};
EXPECT_NO_THROW({
EXPECT_EQ(bf::identify(f.path).format, bf::Format::Unknown)
<< "e_lfanew=" << lfanew;
EXPECT_FALSE(bf::needed_names(f.path).has_value());
}) << "e_lfanew=" << lfanew;
}
}

TEST(PackBinfmt, TruncatedInputIsRejectedRatherThanRead) {
// Malformed input is ordinary: a half-downloaded file, a text file named
// `.exe`. Every read is bounds-checked, so the parser is total over it.
Expand Down
Loading