Skip to content

Commit 94e4c6b

Browse files
committed
feat(pm): index.toml version contract — floor check at the index-open choke point
An index tree may carry index.toml ([index] spec / min_mcpp / latest_mcpp). The contract travels WITH the tree, so ONE check in read_identity_verified_xpkg_lua — the choke point every transport converges on (artifact snapshot, git clone, [indices] path, CI-restored cache) — covers all of them, offline included. own < min_mcpp → loud E0006 with the upgrade one-liner, once per index per process, then the resolve fails with the cause already printed. Missing index.toml → no constraint (back-compat, third-party indices). Malformed versions never brick a client. Escape hatch: MCPP_INDEX_FLOOR=ignore. Deviation from design D3 noted: staged-unpack + atomic swap for the artifact refresh lives in vendored xlings (separate codebase) — the open-time check is the mcpp-side enforcement; staging is a follow-up there. Verified: 3 unit tests (ordering incl. 0.0.90>0.0.85, malformed/absent tolerance, TOML round-trip); e2e on mcpp-index: min_mcpp=9.9.9 → E0006 + resolve stop; =0.0.84 → builds; MCPP_INDEX_FLOOR=ignore bypasses. Design: .agents/docs/2026-07-08-index-version-semantics-and-descriptor- grammar-design.md D3.
1 parent 9211fd4 commit 94e4c6b

4 files changed

Lines changed: 170 additions & 1 deletion

File tree

src/doctor.cppm

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -377,6 +377,12 @@ export int explain_code(std::string_view code) {
377377
"A cached BMI file referenced by manifest.txt is missing on disk. Run\n"
378378
"`mcpp cache prune --older-than 0d` to drop stale entries; the next build\n"
379379
"will repopulate."},
380+
{"E0006", "index requires a newer mcpp",
381+
"The package index declares (index.toml [index].min_mcpp) that its\n"
382+
"descriptors need a newer mcpp than this binary — parsing them would\n"
383+
"silently misbehave, so resolution stops instead. Upgrade mcpp:\n"
384+
" curl -fsSL https://github.com/mcpp-community/mcpp/releases/latest/download/install.sh | bash\n"
385+
"To bypass for debugging only: MCPP_INDEX_FLOOR=ignore mcpp build"},
380386
};
381387
for (auto& e : table) {
382388
if (e.code == code) {
@@ -387,7 +393,7 @@ export int explain_code(std::string_view code) {
387393
}
388394
}
389395
std::println(stderr, "error: unknown error code '{}'", code);
390-
std::println(stderr, " known codes: E0001..E0005");
396+
std::println(stderr, " known codes: E0001..E0006");
391397
return 2;
392398
}
393399

src/pm/index_contract.cppm

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
// mcpp.pm.index_contract — the index→client version contract.
2+
//
3+
// An index tree (a directory containing pkgs/) may carry an `index.toml`
4+
// at its root:
5+
//
6+
// [index]
7+
// spec = "1" # index layout spec
8+
// min_mcpp = "0.0.85" # oldest mcpp able to parse every descriptor
9+
// latest_mcpp = "0.0.85" # optional: newest known-good mcpp (hint)
10+
//
11+
// The contract travels WITH the tree (git checkout, unpacked artifact,
12+
// CI-restored cache, `[indices] path =` local dir), so one check at the
13+
// index-open choke point covers every transport, offline included.
14+
// Missing index.toml → no constraint (back-compat, third-party indices).
15+
//
16+
// Escape hatch: MCPP_INDEX_FLOOR=ignore (debugging).
17+
// Design: .agents/docs/2026-07-08-index-version-semantics-and-descriptor-
18+
// grammar-design.md (D3).
19+
20+
export module mcpp.pm.index_contract;
21+
22+
import std;
23+
import mcpp.libs.toml;
24+
import mcpp.version_req;
25+
import mcpp.toolchain.fingerprint; // MCPP_VERSION
26+
27+
export namespace mcpp::pm {
28+
29+
struct IndexContract {
30+
std::string spec; // index layout spec ("1")
31+
std::string minMcpp; // floor: oldest client able to parse the tree
32+
std::string latestMcpp; // optional upgrade hint
33+
};
34+
35+
// Read <indexRoot>/index.toml. nullopt when absent or unreadable
36+
// (absence is not an error — it simply means "no contract").
37+
std::optional<IndexContract>
38+
read_index_contract(const std::filesystem::path& indexRoot);
39+
40+
// Pure floor predicate: does `ownVersion` satisfy `minMcpp`?
41+
// Returns the violation message when it does not; nullopt when fine
42+
// (including unparsable versions — the contract must never brick a
43+
// client by being malformed).
44+
std::optional<std::string>
45+
floor_violation(std::string_view minMcpp, std::string_view ownVersion);
46+
47+
// Open-time check for an index tree. Combines read + floor + escape
48+
// hatch + once-per-root deduplication of the (expensive to spam) error.
49+
// Returns the violation message the FIRST time a too-new tree is opened;
50+
// nullopt otherwise.
51+
std::optional<std::string>
52+
check_index_floor(const std::filesystem::path& indexRoot);
53+
54+
} // namespace mcpp::pm
55+
56+
namespace mcpp::pm {
57+
58+
std::optional<IndexContract>
59+
read_index_contract(const std::filesystem::path& indexRoot)
60+
{
61+
std::error_code ec;
62+
auto file = indexRoot / "index.toml";
63+
if (!std::filesystem::exists(file, ec)) return std::nullopt;
64+
65+
std::ifstream is{file};
66+
if (!is) return std::nullopt;
67+
std::string body{std::istreambuf_iterator<char>(is), {}};
68+
69+
auto doc = mcpp::libs::toml::parse(body);
70+
if (!doc) return std::nullopt;
71+
72+
IndexContract c;
73+
if (auto v = doc->get_string("index.spec")) c.spec = *v;
74+
if (auto v = doc->get_string("index.min_mcpp")) c.minMcpp = *v;
75+
if (auto v = doc->get_string("index.latest_mcpp")) c.latestMcpp = *v;
76+
return c;
77+
}
78+
79+
std::optional<std::string>
80+
floor_violation(std::string_view minMcpp, std::string_view ownVersion)
81+
{
82+
if (minMcpp.empty()) return std::nullopt;
83+
auto need = mcpp::version_req::parse_version(minMcpp);
84+
auto have = mcpp::version_req::parse_version(ownVersion);
85+
if (!need || !have) return std::nullopt; // malformed contract never bricks
86+
if (*have >= *need) return std::nullopt;
87+
return std::format(
88+
"index requires mcpp >= {} but this is mcpp {} [E0006]\n"
89+
" Upgrade: curl -fsSL https://github.com/mcpp-community/mcpp/"
90+
"releases/latest/download/install.sh | bash\n"
91+
" Details: mcpp explain E0006 "
92+
"(override for debugging: MCPP_INDEX_FLOOR=ignore)",
93+
minMcpp, ownVersion);
94+
}
95+
96+
std::optional<std::string>
97+
check_index_floor(const std::filesystem::path& indexRoot)
98+
{
99+
if (const char* v = std::getenv("MCPP_INDEX_FLOOR");
100+
v && std::string_view(v) == "ignore")
101+
return std::nullopt;
102+
103+
// Once per root per process: the same index is opened many times in a
104+
// single resolve; report the violation once, stay quiet after.
105+
static std::set<std::filesystem::path> reported;
106+
auto c = read_index_contract(indexRoot);
107+
if (!c) return std::nullopt;
108+
auto violation = floor_violation(c->minMcpp, mcpp::toolchain::MCPP_VERSION);
109+
if (!violation) return std::nullopt;
110+
if (!reported.insert(indexRoot).second) return std::nullopt;
111+
return violation;
112+
}
113+
114+
} // namespace mcpp::pm

src/pm/package_fetcher.cppm

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ import mcpp.log;
1818
import mcpp.manifest; // xpkg_lua_identity_matches — descriptor identity gate
1919
import mcpp.pm.compat;
2020
import mcpp.pm.dep_spec;
21+
import mcpp.pm.index_contract; // index.toml floor check (E0006)
22+
import mcpp.ui;
2123
import mcpp.pm.index_spec;
2224
import mcpp.xlings;
2325
import mcpp.libs.toml; // re-used for tiny JSON-ish parsing? no — stick with manual
@@ -431,6 +433,16 @@ read_identity_verified_xpkg_lua(const std::filesystem::path& pkgsDir,
431433
const std::vector<std::string>& filenames,
432434
std::string_view indexDefaultNs = {})
433435
{
436+
// Index→client version contract: the tree carries its own floor
437+
// (<indexRoot>/index.toml min_mcpp). Checked here — the single choke
438+
// point every transport converges on (artifact snapshot, git clone,
439+
// [indices] path, CI-restored cache). Loud once per index; the
440+
// resolve then fails as not-found with the cause already printed.
441+
if (auto violation = mcpp::pm::check_index_floor(pkgsDir.parent_path())) {
442+
mcpp::ui::error(*violation);
443+
return std::nullopt;
444+
}
445+
434446
std::error_code ec;
435447
if (!std::filesystem::exists(pkgsDir, ec)) return std::nullopt;
436448
for (auto& fname : filenames) {

tests/unit/test_index_contract.cpp

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
#include <gtest/gtest.h>
2+
3+
import std;
4+
import mcpp.pm.index_contract;
5+
6+
TEST(IndexContract, FloorViolationOrdering) {
7+
using mcpp::pm::floor_violation;
8+
EXPECT_FALSE(floor_violation("0.0.85", "0.0.85").has_value());
9+
EXPECT_FALSE(floor_violation("0.0.85", "0.0.90").has_value());
10+
EXPECT_FALSE(floor_violation("0.0.85", "1.0.0").has_value());
11+
auto v = floor_violation("0.0.85", "0.0.84");
12+
ASSERT_TRUE(v.has_value());
13+
EXPECT_NE(v->find("E0006"), std::string::npos);
14+
EXPECT_NE(v->find("0.0.85"), std::string::npos);
15+
}
16+
17+
TEST(IndexContract, EmptyOrMalformedNeverBricks) {
18+
using mcpp::pm::floor_violation;
19+
EXPECT_FALSE(floor_violation("", "0.0.84").has_value());
20+
EXPECT_FALSE(floor_violation("not-a-version", "0.0.84").has_value());
21+
}
22+
23+
TEST(IndexContract, ReadContractRoundTrip) {
24+
auto dir = std::filesystem::temp_directory_path() / "mcpp_ic_test";
25+
std::filesystem::create_directories(dir);
26+
{
27+
std::ofstream os(dir / "index.toml");
28+
os << "[index]\nspec = \"1\"\nmin_mcpp = \"0.0.85\"\nlatest_mcpp = \"0.0.86\"\n";
29+
}
30+
auto c = mcpp::pm::read_index_contract(dir);
31+
ASSERT_TRUE(c.has_value());
32+
EXPECT_EQ(c->spec, "1");
33+
EXPECT_EQ(c->minMcpp, "0.0.85");
34+
EXPECT_EQ(c->latestMcpp, "0.0.86");
35+
std::filesystem::remove_all(dir);
36+
EXPECT_FALSE(mcpp::pm::read_index_contract(dir).has_value());
37+
}

0 commit comments

Comments
 (0)