Skip to content

Commit eb196a2

Browse files
authored
fix(pm): install_path 跨命名空间返回了别的包的目录 (2026.8.6.1) (#364)
* test(e2e): cover the link-command SCALE axis, which no CI job reached (#346) Every mcpp CI job builds either mcpp itself (tens of TUs) or a synthetic e2e project (single digits). Link-line length, the response-file path and ninja graph size over a large object set therefore had no coverage at all, and the whole command-length defect family surfaced in the ecosystem instead: #274 ninja goals argv 50781 chars vs cmd.exe 8191 #247 Windows CreateProcess 32 KiB #345 POSIX MAX_ARG_STRLEN 128 KiB #360 link.exe LNK1170, response-file line capped at 128 KiB 190 asserts the shape of the generated rule at 25 objects. 191 asserts the scale: 1400 C TUs whose object list is 140 KiB, past the largest command-line ceiling in cmdlimits.cppm, required to link and to run. The regime is asserted rather than assumed. Object naming, the disambiguation prefix and the file count all influence how large the list actually is, so the response file's size is checked against the ceiling directly: if it ever falls back under, the test reports that it has stopped covering the axis instead of passing quietly. Verified to fail without the fix — reverting the generated cxx_link rule to its pre-#345 inline form on this project reproduces the original symptom verbatim: `ninja: fatal: posix_spawn: Argument list too long`. Cost: 1.3s wall on a developer machine. Padded file names carry the object paths to ~100 bytes so the ceiling is reached at a file count this small, bounded on the other side by Windows MAX_PATH. * fix(pm): install_path 跨命名空间返回了别的包的目录 (2026.8.6.1) `Fetcher::install_path(ns, shortName, version)` 回答的是"**这个**包装在哪"。它最后 那道 legacy 扫描却匹配任何以 `-x-<shortName>` 结尾的目录、不看命名空间,于是查 `ocornut:imgui@1.92.8` 会返回 `compat-x-imgui/1.92.8` —— 另一个仅仅短名相同的包。 调用方随后(a)认为"已安装"而跳过安装,(b)把那个包的源码树当作本包读取。 **危险形态是静默的。** 我是在一个 Form B 邻居上撞见的,所以错误的 verdir 里没有 mcpp.toml,构建停在 "index entry has no `mcpp = ...` field" —— 一个指向错误原因的 诊断。而当两个包的源码都在 verdir 里时,没有任何报错:构建**编译了另一个包的源码 并成功**。e2e 192 在旧二进制上正是这么红的: Compiling acme.widget v1.5.0 Finished dev [unoptimized + debuginfo] in 0.07s FAIL: build succeeded, so acme:widget was satisfied from somewhere — the only widget payload on disk belongs to compat. **为什么一直够不到。** install_path 同时匹配版本,所以同短名的两个包只有在版本也相 同的时候才会撞;而生态里 module 层用的是打包计数(`imgui@0.0.6`),compat 用的是上 游版本(`compat.imgui@1.92.8`),永远不撞。把 module 层的版本对齐到上游 (mcpp-index#163)之后它们就重合了 —— 这个 bug 是被那次对齐**逼出来**的,不是新引入的。 修法:bare `-x-<shortName>` 这一支现在要求目录自身的命名空间前缀是调用方确实问过的 (请求的 ns,或旧式 index-prefixed 布局里的 index 名)。`-x-<ns>.<name>` 那一支把命 名空间带在后缀里,无需前缀约束。这保住了它存在的两种旧布局,拒掉的正是它从不该服务 的那一种。 顺带修诊断:`<verdir>` 是个字面占位符,无法区分"包是 Form B 而你忘了写 mcpp 字段" 与"mcpp 解析到的 verdir 根本不是这个包的"。现在打印真实路径,并在后一种情况下点名。 验证: * 单测 4 条(tests/unit/test_fallback_legacy_dirs.cpp),含"仍要找得到自己命名空间的 短名布局"与"index-prefixed 旧布局"两条防止修过头。 * e2e 192,实测旧二进制红、修复后绿。 * 真实复现同环境 A/B:store 里有 compat-x-imgui/1.92.8 时,ocornut:imgui@1.92.8 在 旧二进制上失败、在修复后正常安装并构建。 * 59 个单测全过。
1 parent fd125ca commit eb196a2

7 files changed

Lines changed: 277 additions & 16 deletions

File tree

mcpp.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "mcpp"
3-
version = "2026.8.5.4"
3+
version = "2026.8.6.1"
44
description = "Modern C++ build & package management tool"
55
license = "Apache-2.0"
66
authors = ["mcpp-community"]

src/build/prepare.cppm

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2602,12 +2602,20 @@ prepare_build(bool print_fingerprint,
26022602
matches = mcpp::modgraph::expand_glob(verRoot, pat);
26032603
if (!matches.empty()) break;
26042604
}
2605+
// Name the directory actually searched. `<verdir>` was a literal
2606+
// placeholder, so the message could not distinguish "the package
2607+
// is Form B and you forgot the mcpp field" from "the verdir mcpp
2608+
// resolved is not this package's at all" — the second is what a
2609+
// cross-namespace install_path hit produces, and it sent this
2610+
// investigation down the wrong path for a while.
26052611
if (matches.empty()) return std::unexpected(std::format(
26062612
"dependency '{}': index entry has no `mcpp = ...` field, "
2607-
"and no mcpp.toml was found at <verdir>/mcpp.toml or "
2608-
"<verdir>/*/mcpp.toml — add an explicit `mcpp = \"<path>\"` "
2609-
"or `mcpp = {{ ... }}` block to the .lua descriptor.",
2610-
depName));
2613+
"and no mcpp.toml was found at '{}/mcpp.toml' or "
2614+
"'{}/*/mcpp.toml' — add an explicit `mcpp = \"<path>\"` "
2615+
"or `mcpp = {{ ... }}` block to the .lua descriptor. "
2616+
"(If that directory belongs to a DIFFERENT package, the "
2617+
"install step resolved the wrong verdir.)",
2618+
depName, verRoot.string(), verRoot.string()));
26112619
if (matches.size() > 1) return std::unexpected(std::format(
26122620
"dependency '{}': default mcpp.toml lookup matched {} "
26132621
"files; pin one with explicit `mcpp = \"<path>\"`.",

src/fallback/legacy_dirs.cppm

Lines changed: 49 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,31 +1,73 @@
11
// mcpp.fallback.legacy_dirs — legacy xpkg directory scan.
22
//
3-
// Last-resort fallback scan (COMPAT, remove in 1.0.0): walk xpkgs/
4-
// for any directory ending with -x-<qualifiedName> or -x-<shortName>.
3+
// Last-resort fallback scan (COMPAT, remove in 1.0.0): walk xpkgs/ for a
4+
// directory that holds the requested package under an older naming layout.
5+
//
6+
// WHY THE SCAN IS NAMESPACE-BOUND
7+
//
8+
// The bare `-x-<shortName>` arm used to match ANY prefix, so a lookup for
9+
// `ocornut:imgui` happily returned `compat-x-imgui` — a DIFFERENT package that
10+
// merely shares a short name. `Fetcher::install_path` then treats that verdir
11+
// as the requested package's, which means:
12+
//
13+
// * the install is skipped (the package "already exists"), and
14+
// * whatever is inside the other namespace's verdir is what gets read.
15+
//
16+
// It stayed invisible while the two packages carried unrelated versions —
17+
// `compat:imgui@1.92.8` next to `mcpplibs:imgui@0.0.6` never collide, because
18+
// install_path also matches on version. Aligning package versions to upstream
19+
// (mcpp-index#163: `imgui@0.0.6` really was ImGui 1.92.8) makes them coincide,
20+
// and the bug becomes reachable.
21+
//
22+
// The observed failure was loud only by luck: `compat.imgui` is a Form B
23+
// descriptor, so the wrong verdir had no mcpp.toml and the build stopped with
24+
// "index entry has no `mcpp = ...` field" — a diagnostic naming the wrong
25+
// cause. Between two Form A packages the wrong verdir DOES have a manifest,
26+
// and the build would have silently compiled the wrong package.
27+
//
28+
// So the shortName arm now requires the directory's own namespace prefix to be
29+
// one the caller actually asked for. That keeps the legacy layouts it exists
30+
// for (`<ns>-x-<shortName>`, `<index>-x-<shortName>`) and refuses the one it
31+
// was never meant to serve: some other namespace's package.
532

633
export module mcpp.fallback.legacy_dirs;
734

835
import std;
936

1037
export namespace mcpp::fallback {
1138

12-
// Scan the xpkgs base directory for a legacy install directory whose
13-
// name ends with "-x-<qualifiedName>" or "-x-<shortName>".
14-
// Returns the matching directory name (not the full path) if found.
39+
// Scan the xpkgs base directory for a legacy install directory holding
40+
// (namespace, shortName). Returns the matching directory name (not the full
41+
// path) if found.
42+
//
43+
// `acceptedPrefixes` are the directory prefixes (the part before `-x-`) that
44+
// may satisfy a bare short-name match — the requested namespace, and the index
45+
// name for the old index-prefixed layout. A fully-qualified `-x-<ns>.<name>`
46+
// match carries the namespace in the suffix itself and needs no prefix check.
1547
std::optional<std::string>
1648
scan_legacy_install_dirs(const std::filesystem::path& xpkgsBase,
1749
std::string_view qualifiedName,
18-
std::string_view shortName) {
50+
std::string_view shortName,
51+
const std::vector<std::string>& acceptedPrefixes) {
1952
std::error_code ec;
2053
std::string suffix1 = std::format("-x-{}", qualifiedName);
2154
std::string suffix2 = std::format("-x-{}", shortName);
2255

56+
auto prefix_accepted = [&](const std::string& dirname) {
57+
auto cut = dirname.size() - suffix2.size();
58+
std::string_view prefix{dirname.data(), cut};
59+
for (auto& p : acceptedPrefixes)
60+
if (prefix == p) return true;
61+
return false;
62+
};
63+
2364
for (auto& entry : std::filesystem::directory_iterator(xpkgsBase, ec)) {
2465
if (!entry.is_directory()) continue;
2566
auto dirname = entry.path().filename().string();
2667
if (dirname.ends_with(suffix1))
2768
return dirname;
28-
if (suffix2 != suffix1 && dirname.ends_with(suffix2))
69+
if (suffix2 != suffix1 && dirname.ends_with(suffix2)
70+
&& prefix_accepted(dirname))
2971
return dirname;
3072
}
3173
return std::nullopt;

src/pm/package_fetcher.cppm

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1173,10 +1173,22 @@ Fetcher::install_path(std::string_view ns, std::string_view shortName,
11731173
if (auto p = try_dir(dirName)) return *p;
11741174
}
11751175

1176-
// Last-resort fallback scan (COMPAT, remove in 1.0.0): walk xpkgs/ for
1177-
// any directory ending with -x-<qname> or -x-<shortName>.
1176+
// Last-resort fallback scan (COMPAT, remove in 1.0.0): walk xpkgs/ for a
1177+
// directory holding this package under an older layout.
1178+
//
1179+
// The bare `-x-<shortName>` arm is bound to the namespaces the caller
1180+
// actually asked for. Unbound, it returned ANY namespace's directory with
1181+
// a matching short name — `ocornut:imgui` resolving to `compat-x-imgui` —
1182+
// and install_path's contract is "the verdir for THIS package", so the
1183+
// install was skipped and another package's tree read in its place. See
1184+
// the header of mcpp.fallback.legacy_dirs for why version alignment is
1185+
// what made it reachable.
11781186
auto qname = mcpp::pm::compat::qualified_name(ns, shortName);
1179-
if (auto legacy = mcpp::fallback::scan_legacy_install_dirs(base, qname, shortName)) {
1187+
std::vector<std::string> acceptedPrefixes;
1188+
if (!ns.empty()) acceptedPrefixes.emplace_back(ns);
1189+
if (!cfg_.defaultIndex.empty()) acceptedPrefixes.emplace_back(cfg_.defaultIndex);
1190+
if (auto legacy = mcpp::fallback::scan_legacy_install_dirs(
1191+
base, qname, shortName, acceptedPrefixes)) {
11801192
if (auto p = try_dir(*legacy)) return *p;
11811193
}
11821194
return std::nullopt;

src/version.cppm

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,6 @@ import std;
3131

3232
export namespace mcpp {
3333

34-
inline constexpr std::string_view MCPP_VERSION = "2026.8.5.4";
34+
inline constexpr std::string_view MCPP_VERSION = "2026.8.6.1";
3535

3636
} // namespace mcpp
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
#!/usr/bin/env bash
2+
# requires: gcc fresh-sandbox
3+
# 192_install_path_namespace.sh — a package must never be satisfied from
4+
# ANOTHER namespace's install directory.
5+
#
6+
# `Fetcher::install_path(ns, shortName, version)` answers "where is THIS
7+
# package installed". Its last-resort legacy scan matched any directory ending
8+
# in `-x-<shortName>`, whatever namespace that directory belonged to, so a
9+
# lookup for `acme:widget@1.5.0` returned `compat-x-widget/1.5.0`. The caller
10+
# then skips the install ("already present") and reads the other package's
11+
# tree.
12+
#
13+
# WHY IT WAS UNREACHABLE UNTIL NOW
14+
#
15+
# install_path also matches on version, so two packages sharing a short name
16+
# collided only if they also shared a version — and the ecosystem's module
17+
# layers carried packaging counters (`imgui@0.0.6`) while the compat packages
18+
# carried upstream versions (`compat.imgui@1.92.8`). Aligning the module layers
19+
# to upstream (mcpp-index#163) makes them coincide, which is how this surfaced.
20+
#
21+
# WHY THE SILENT SHAPE IS THE ONE THAT MATTERS
22+
#
23+
# Discovered against a Form B neighbour, so the wrong verdir had no mcpp.toml
24+
# and the build stopped — with a diagnostic naming the wrong cause. Between two
25+
# packages whose sources live in the verdir, there is no error at all: the
26+
# build compiles the wrong package's source. That is what this test pins.
27+
set -e
28+
29+
TMP=$(mktemp -d)
30+
trap "rm -rf $TMP" EXIT
31+
32+
export MCPP_HOME="$TMP/mcpp-home"
33+
source "$(dirname "$0")/_inherit_toolchain.sh"
34+
35+
mkdir -p "$TMP/proj"
36+
cd "$TMP/proj"
37+
38+
# ── Two packages, same short name, same version, different namespaces ─────
39+
mkdir -p local-index/pkgs/a
40+
cat > local-index/pkgs/a/acme.widget.lua <<'EOF'
41+
package = {
42+
spec = "1",
43+
namespace = "acme",
44+
name = "widget",
45+
description = "acme's widget",
46+
licenses = {"MIT"},
47+
type = "package",
48+
xpm = {
49+
linux = { ["1.5.0"] = { url = "https://example.invalid/w.tar.gz", sha256 = "0000000000000000000000000000000000000000000000000000000000000000" } },
50+
macosx = { ["1.5.0"] = { url = "https://example.invalid/w.tar.gz", sha256 = "0000000000000000000000000000000000000000000000000000000000000000" } },
51+
windows = { ["1.5.0"] = { url = "https://example.invalid/w.zip", sha256 = "0000000000000000000000000000000000000000000000000000000000000000" } },
52+
},
53+
mcpp = {
54+
language = "c++23",
55+
import_std = false,
56+
sources = { "src/widget.cppm" },
57+
targets = { ["widget"] = { kind = "lib" } },
58+
deps = {},
59+
},
60+
}
61+
EOF
62+
63+
# ── Only the FOREIGN namespace's payload is on disk ───────────────────────
64+
# `compat:widget@1.5.0` is installed; `acme:widget@1.5.0` is not. Nothing may
65+
# hand acme's lookup this directory.
66+
#
67+
# It goes in the GLOBAL store ($MCPP_HOME/registry/data/xpkgs), which is what
68+
# `Fetcher::install_path` scans — the project's own .mcpp/.xlings tree is a
69+
# different root reached by `install_path_from_project_data`. Seeding the wrong
70+
# one makes this test pass on a broken binary, which is exactly what the first
71+
# draft did.
72+
mkdir -p "$MCPP_HOME/registry/data/xpkgs/compat-x-widget/1.5.0/src"
73+
cat > "$MCPP_HOME/registry/data/xpkgs/compat-x-widget/1.5.0/src/widget.cppm" <<'EOF'
74+
export module widget;
75+
// If this ever reaches a build that asked for acme:widget, the wrong package
76+
// was compiled. The value is the tell.
77+
export int widget_value() { return 1; }
78+
EOF
79+
80+
mkdir -p src
81+
cat > src/main.cpp <<'EOF'
82+
import widget;
83+
int main() { return widget_value() == 2 ? 0 : 1; }
84+
EOF
85+
86+
cat > mcpp.toml <<'EOF'
87+
[package]
88+
name = "consumer"
89+
version = "0.1.0"
90+
91+
[dependencies.acme]
92+
widget = "1.5.0"
93+
94+
[indices]
95+
acme = { path = "local-index" }
96+
EOF
97+
98+
# The build must NOT succeed: acme:widget is not installed and its url is
99+
# unreachable by design. What it must never do is satisfy the dependency from
100+
# compat's directory.
101+
if "$MCPP" build > b.log 2>&1; then
102+
echo "--- build log ---"; cat b.log
103+
echo "FAIL: build succeeded, so acme:widget was satisfied from somewhere —"
104+
echo " the only widget payload on disk belongs to compat."
105+
exit 1
106+
fi
107+
108+
# Distinguish "correctly refused" from "compiled the wrong package and then
109+
# failed for an unrelated reason": compat's source must never be compiled.
110+
if grep -qE "compat-x-widget/1\.5\.0/src/widget\.cppm" b.log; then
111+
grep -nE "compat-x-widget" b.log | head -5
112+
echo "FAIL: the build reached compat's source while resolving acme:widget"
113+
exit 1
114+
fi
115+
echo " ok: acme:widget was not satisfied from compat-x-widget"
116+
117+
# And the diagnostic has to name the directory it looked in — `<verdir>` as a
118+
# literal placeholder cannot distinguish a missing mcpp field from a wrong
119+
# verdir, which is what made this take a while to find.
120+
if grep -q "<verdir>" b.log; then
121+
echo "FAIL: diagnostic still prints the literal placeholder '<verdir>'"
122+
exit 1
123+
fi
124+
echo " ok: diagnostics name a real path"
125+
126+
echo "OK"
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
#include <gtest/gtest.h>
2+
3+
import std;
4+
import mcpp.fallback.legacy_dirs;
5+
6+
// The legacy install-dir scan must not cross namespaces.
7+
//
8+
// `Fetcher::install_path` contract is "the verdir for THIS package". Its
9+
// last-resort scan used to match any directory ending in `-x-<shortName>`,
10+
// regardless of which namespace that directory belongs to. A lookup for
11+
// `ocornut:imgui` therefore returned `compat-x-imgui` — a different package
12+
// that merely shares a short name — and the caller then (a) skipped the
13+
// install because the package "already exists" and (b) read the other
14+
// package's tree.
15+
//
16+
// It was unreachable while the two carried unrelated versions
17+
// (`compat:imgui@1.92.8` vs `mcpplibs:imgui@0.0.6`), because install_path also
18+
// matches on version. Aligning package versions to upstream (mcpp-index#163)
19+
// makes them coincide.
20+
21+
namespace {
22+
23+
std::filesystem::path make_xpkgs(std::initializer_list<std::string_view> dirs) {
24+
auto base = std::filesystem::temp_directory_path()
25+
/ std::format("legacy-dirs-{}",
26+
std::chrono::steady_clock::now()
27+
.time_since_epoch().count());
28+
for (auto d : dirs) std::filesystem::create_directories(base / std::string(d));
29+
return base;
30+
}
31+
32+
} // namespace
33+
34+
TEST(FallbackLegacyDirs, DoesNotReturnAnotherNamespacesDirectory) {
35+
auto base = make_xpkgs({"compat-x-imgui"});
36+
// Asking for ocornut:imgui. Only compat's directory exists, and it is NOT
37+
// an answer to this question.
38+
auto hit = mcpp::fallback::scan_legacy_install_dirs(
39+
base, "ocornut.imgui", "imgui", {"ocornut", "mcpplibs"});
40+
EXPECT_FALSE(hit.has_value())
41+
<< "returned '" << hit.value_or("") << "' for ocornut:imgui";
42+
std::filesystem::remove_all(base);
43+
}
44+
45+
TEST(FallbackLegacyDirs, StillFindsOwnNamespaceShortNameLayout) {
46+
auto base = make_xpkgs({"compat-x-imgui", "ocornut-x-imgui"});
47+
auto hit = mcpp::fallback::scan_legacy_install_dirs(
48+
base, "ocornut.imgui", "imgui", {"ocornut", "mcpplibs"});
49+
ASSERT_TRUE(hit.has_value());
50+
EXPECT_EQ(*hit, "ocornut-x-imgui");
51+
std::filesystem::remove_all(base);
52+
}
53+
54+
TEST(FallbackLegacyDirs, StillFindsIndexPrefixedLegacyLayout) {
55+
// The old layout this arm exists for: <index>-x-<shortName>.
56+
auto base = make_xpkgs({"mcpplibs-x-tinyhttps"});
57+
auto hit = mcpp::fallback::scan_legacy_install_dirs(
58+
base, "mcpplibs.tinyhttps", "tinyhttps", {"mcpplibs", "mcpplibs"});
59+
ASSERT_TRUE(hit.has_value());
60+
EXPECT_EQ(*hit, "mcpplibs-x-tinyhttps");
61+
std::filesystem::remove_all(base);
62+
}
63+
64+
TEST(FallbackLegacyDirs, QualifiedSuffixNeedsNoPrefixAllowance) {
65+
// `-x-<ns>.<name>` carries the namespace in the suffix itself, so it is
66+
// accepted whatever the prefix is — that is the legacy FQN layout.
67+
auto base = make_xpkgs({"someindex-x-ocornut.imgui"});
68+
auto hit = mcpp::fallback::scan_legacy_install_dirs(
69+
base, "ocornut.imgui", "imgui", {"ocornut"});
70+
ASSERT_TRUE(hit.has_value());
71+
EXPECT_EQ(*hit, "someindex-x-ocornut.imgui");
72+
std::filesystem::remove_all(base);
73+
}

0 commit comments

Comments
 (0)