From 67e0c06674fd03184f126ceb645d9f9ec765686b Mon Sep 17 00:00:00 2001 From: Idogwu Chinonso Date: Thu, 20 Aug 2026 16:56:16 +0100 Subject: [PATCH 1/9] fix(build): link libcmt alongside libcma so the C parser resolves libcma.a archives only libcma's own objects. Its parser_impl.o references libcmt's C ABI helpers (cmt_abi_*, cmt_buf_*), and those symbols are not in the archive, but build.rs emitted only `-lcma -lstdc++`. Static archive members are pulled lazily and nothing in this crate calls the C parser -- parser.rs is pure Rust over alloy -- so parser_impl.o was never pulled and the gap stayed invisible. Any downstream consumer calling cma_parser_decode_advance/inspect or cma_parser_encode_voucher hit a dozen `undefined symbol: cmt_abi_*` at link time. Link libcmt, respecting how upstream's Makefile treats it per arch: - host-real: build build/host/libcmt.a from the vendored guest-tools sources (mock io backend) and link it statically; - riscv64: emit a plain `-lcmt`, since upstream deliberately does not build it there (its real io backend needs the cartesi kernel headers) and the machine-guest-tools package in the app image supplies it. Also self-heal a partially-staged third-party/libcmt. That make target is a DIRECTORY, whose mtime is trivially newer than the tarball it came from, so make treats any existing copy as up to date and never repairs it. A checkout staged by an older revision keeps a headers-only directory forever and the libcmt compile dies on `third-party/libcmt/src/buf.c: No such file or directory`. Guard both halves with tests that force parser_impl.o into the link: tests/c_parser_link.rs on the host (also checks the C parser agrees with the Rust port on the canonical ether-deposit vector), and a real riscv64 binary link in the riscv-link-check CI job, which until now only ar-inspected the archive and could never have caught this. --- .github/workflows/test.yml | 39 +++++++++++ build.rs | 72 ++++++++++++++++++--- tests/c_parser_link.rs | 128 +++++++++++++++++++++++++++++++++++++ 3 files changed, 230 insertions(+), 9 deletions(-) create mode 100644 tests/c_parser_link.rs diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 1c29c86..8829640 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -129,6 +129,45 @@ jobs: file "$obj" | grep -F 'RISC-V' rm -f "$obj" + # Inspecting the archive is not enough: libcma.a's parser_impl.o references libcmt's + # cmt_abi_*/cmt_buf_* symbols, which live OUTSIDE the archive. Archive members are pulled + # lazily, so a missing libcmt only shows up when something actually calls the C parser. + # This links a real riscv64 binary that does, against the same `-lcma -lcmt` that build.rs + # emits for the riscv64 backend, inside the builder image where the machine-guest-tools + # package supplies libcmt. tests/c_parser_link.rs is the host-side counterpart. + - name: Link a real riscv64 binary against libcma + libcmt + working-directory: third_party/machine-asset-tools + run: | + set -euo pipefail + cat > /tmp/cmt_link_probe.c <<'PROBE' + #include + #include + int main(void) { + unsigned char payload[52]; + memset(payload, 0, sizeof payload); + payload[19] = 1; /* sender */ + payload[51] = 4; /* value */ + cmt_rollup_advance_t advance; + memset(&advance, 0, sizeof advance); + advance.payload.length = sizeof payload; + advance.payload.data = payload; + cma_parser_input_t out; + memset(&out, 0, sizeof out); + /* Forces parser_impl.o (and therefore libcmt) into the link. */ + return cma_parser_decode_advance( + CMA_PARSER_INPUT_TYPE_ETHER_DEPOSIT, &advance, &out) == 0 ? 0 : 1; + } + PROBE + docker run --platform linux/riscv64 --rm \ + -v "$PWD":/mnt/app -v /tmp/cmt_link_probe.c:/tmp/cmt_link_probe.c \ + -w /mnt/app libcma-builder \ + bash -euxc ' + g++ -std=c++20 -Iinclude -Ithird-party -x c++ /tmp/cmt_link_probe.c \ + -L build/riscv64 -lcma -lcmt -lstdc++ -o /tmp/cmt_link_probe + file /tmp/cmt_link_probe | grep -F "RISC-V" + /tmp/cmt_link_probe + ' + # =========================================================================== # TODO(cross-arch-differential): the ULTIMATE host<->machine reproducibility # invariant — not yet implemented (heavy: needs a riscv64 build AND running it diff --git a/build.rs b/build.rs index 95ee99c..ab6fb3d 100644 --- a/build.rs +++ b/build.rs @@ -101,6 +101,25 @@ fn main() { let obj_subdir = if host { "build/host" } else { "build/riscv64" }; let lib_dir = mat.join(obj_subdir); let lib_path = lib_dir.join("libcma.a"); + // libcma.a archives ONLY libcma's own objects (ledger*, parser*, utils). `parser_impl.o` + // references libcmt's C ABI helpers (cmt_abi_*, cmt_buf_*) and those symbols are NOT in + // the archive, so libcmt must be linked alongside it. Where libcmt comes from is + // arch-dependent — see `cmt_lib_path` below. + // + // This is easy to miss because archive members are pulled lazily: nothing in this crate's + // own code path calls the C parser (parser.rs is pure Rust over alloy), so `parser_impl.o` + // is never pulled and the missing symbols stay invisible until a DOWNSTREAM consumer calls + // cma_parser_decode_advance/inspect or cma_parser_encode_voucher — at which point the link + // fails with a dozen `undefined symbol: cmt_abi_*`. tests/c_parser_link.rs forces exactly + // that pull so the regression cannot come back unnoticed. + // + // riscv64: upstream deliberately does NOT build libcmt from source (its real io backend + // needs the cartesi Linux kernel headers, which exist only inside the guest). It comes + // from the machine-guest-tools package installed in the app image, so we emit a plain + // `-lcmt` and let the image supply it. + // host: the Makefile builds it from the vendored guest-tools sources with the mock io + // backend, so we build and statically link that archive. + let cmt_lib_path = lib_dir.join("libcmt.a"); // Build libcma.a from source if it isn't already present. This is what lets the crate be // consumed as a plain `git`/`crates.io` dependency WITHOUT vendoring a prebuilt archive. @@ -110,7 +129,7 @@ fn main() { // - riscv64: the RISC-V GCC 14 cross toolchain (g++-14-riscv64-linux-gnu / gcc-14-…). // - host-real: a host C++ toolchain with g++ >= 14 (C++20/C++23) and Boost is fetched. // Override the compiler names with CMA_RISCV64_CXX/CC or CMA_HOST_CXX/CC. - if !lib_path.exists() { + if !lib_path.exists() || (host && !cmt_lib_path.exists()) { let (toolchain_prefix, cxx, cc, ar) = if host { ( String::new(), @@ -151,6 +170,19 @@ fn main() { // corrupted cache is caught too. A mismatch is a hard, un-ignorable build failure. verify_sha256(&nlohmann, NLOHMANN_JSON_SHA256); + // Self-heal a partially-staged libcmt. Upstream's `third-party/libcmt` make target is a + // DIRECTORY, and a directory's mtime is trivially newer than the tarball it was + // extracted from, so make considers any existing copy up to date and never repairs it. + // A checkout that was staged by an older revision therefore keeps a headers-only + // directory forever, and the libcmt compile dies on + // `third-party/libcmt/src/buf.c: No such file or directory`. Removing it forces a clean + // re-extraction of both the headers and the sources. + let staged_cmt = mat.join("third-party/libcmt"); + if staged_cmt.exists() && !staged_cmt.join("src/buf.c").exists() { + std::fs::remove_dir_all(&staged_cmt) + .expect("failed to clear the partially-staged third-party/libcmt directory"); + } + // Download + stage the third-party deps, then compile the static archive. The Makefile // hardcodes `libcma_OBJDIR := build/riscv64`; override it so the host build lands in its // own dir (command-line assignments beat the Makefile's `:=`). @@ -171,16 +203,24 @@ fn main() { ], &mat, ); + // On the host, build libcmt.a in the same invocation. On riscv64 the Makefile leaves + // `libcmt_LIB` empty on purpose (see the comment on `cmt_lib_path`), so asking for the + // target there would fail with "no rule to make target". + let mut targets = vec![format!("{obj_subdir}/libcma.a")]; + if host { + targets.push(format!("{obj_subdir}/libcmt.a")); + } + let mut make_args: Vec = targets; + make_args.extend([ + format!("libcma_OBJDIR={obj_subdir}"), + format!("TOOLCHAIN_PREFIX={toolchain_prefix}"), + format!("CXX={cxx}"), + format!("CC={cc}"), + format!("AR={ar}"), + ]); run( "make", - &[ - &format!("{obj_subdir}/libcma.a"), - &format!("libcma_OBJDIR={obj_subdir}"), - &format!("TOOLCHAIN_PREFIX={toolchain_prefix}"), - &format!("CXX={cxx}"), - &format!("CC={cc}"), - &format!("AR={ar}"), - ], + &make_args.iter().map(String::as_str).collect::>(), &mat, ); assert!( @@ -188,6 +228,11 @@ fn main() { "libcma.a build did not produce {}", lib_path.display() ); + assert!( + !host || cmt_lib_path.exists(), + "libcmt.a build did not produce {}", + cmt_lib_path.display() + ); } println!( @@ -195,6 +240,15 @@ fn main() { lib_dir.canonicalize().unwrap().display() ); println!("cargo:rustc-link-lib=static=cma"); + // MUST come after `cma`: static archives are resolved left-to-right, and it is libcma's + // parser_impl.o that references the cmt_abi_*/cmt_buf_* symbols, not the other way round. + if host { + println!("cargo:rustc-link-lib=static=cmt"); + } else { + // Plain `-lcmt`, matching what upstream's own sample apps pass: resolves against the + // libcmt shipped by the machine-guest-tools package in the Cartesi app image. + println!("cargo:rustc-link-lib=cmt"); + } // libcma is C++ (Boost.Interprocess/Unordered); link the C++ runtime after it so its // vtables / __cxxabiv1 ABI symbols resolve. Dynamic so the cross gcc locates libstdc++.so // automatically (the machine rootfs must provide libstdc++6). diff --git a/tests/c_parser_link.rs b/tests/c_parser_link.rs new file mode 100644 index 0000000..880a51e --- /dev/null +++ b/tests/c_parser_link.rs @@ -0,0 +1,128 @@ +//! Link + behaviour regression test for the **C** parser surface of libcma. +//! +//! Why this file exists: `libcma.a` archives only libcma's own objects, and its +//! `parser_impl.o` references libcmt's C ABI helpers (`cmt_abi_*`, `cmt_buf_*`). Those +//! symbols are not in the archive, so libcmt has to be linked alongside it. Static archive +//! members are pulled lazily, and nothing else in this crate calls the C parser — `parser.rs` +//! is pure Rust over `alloy` — so `parser_impl.o` never got pulled and the missing symbols +//! stayed invisible. A downstream consumer that called `cma_parser_decode_advance` hit a wall +//! of `undefined symbol: cmt_abi_*` at link time. +//! +//! Calling the C entry points below forces `parser_impl.o` into every link of this test +//! binary. If the libcmt link is ever dropped from `build.rs`, this file fails to link — which +//! is the point. It is a compile-time guard first and a behavioural test second. +//! +//! Run with: +//! +//! ```sh +//! cargo test --no-default-features --features host-real --test c_parser_link +//! ``` +#![cfg(feature = "host-real")] + +use libcma_binding_rust::bindings; + +/// Build a `cmt_rollup_advance_t` whose payload borrows `payload`. +/// +/// The returned struct holds a raw pointer into `payload`, so `payload` must outlive it — +/// enforced here by the shared lifetime on the borrow. +fn advance_with_payload(payload: &mut [u8]) -> bindings::cmt_rollup_advance_t { + let mut advance = unsafe { std::mem::zeroed::() }; + advance.msg_sender = bindings::cmt_abi_address_t { + data: { + let mut a = [0u8; 20]; + a[19] = 1; + a + }, + }; + advance.payload = bindings::cmt_abi_bytes_t { + length: payload.len(), + data: payload.as_mut_ptr().cast(), + }; + advance +} + +/// The canonical ether-deposit vector from `third_party/machine-asset-tools/tests/parser.c`, +/// decoded by the **C** parser this time (`parser_vectors.rs` runs the same bytes through the +/// Rust one). Establishes that libcma's parser links, runs, and agrees with our port. +#[test] +fn c_parser_decodes_ether_deposit_vector() { + // sender (20 bytes) ++ value (32 bytes, = 4), no exec-layer data. + let mut payload = hex::decode(concat!( + "0000000000000000000000000000000000000001", + "0000000000000000000000000000000000000000000000000000000000000004" + )) + .expect("vector is valid hex"); + + let advance = advance_with_payload(&mut payload); + let mut out = unsafe { std::mem::zeroed::() }; + + let rc = unsafe { + bindings::cma_parser_decode_advance( + bindings::cma_parser_input_type_t_CMA_PARSER_INPUT_TYPE_ETHER_DEPOSIT, + &advance, + &mut out, + ) + }; + assert_eq!(rc, 0, "C parser rejected the canonical ether-deposit vector"); + assert_eq!( + out.type_, + bindings::cma_parser_input_type_t_CMA_PARSER_INPUT_TYPE_ETHER_DEPOSIT + ); + + let deposit = unsafe { out.__bindgen_anon_1.ether_deposit }; + + let mut expected_sender = [0u8; 20]; + expected_sender[19] = 1; + assert_eq!(deposit.sender.data, expected_sender); + + // cma_amount_t is a big-endian 32-byte word; the vector's value is 4. + let mut expected_amount = [0u8; 32]; + expected_amount[31] = 4; + assert_eq!(deposit.amount.data, expected_amount); + assert_eq!(deposit.exec_layer_data.length, 0); +} + +/// A rejected input must come back as an error code rather than a crash, and the error-message +/// accessor (which lives in `parser.o`, a different archive member) must be callable too. +#[test] +fn c_parser_reports_error_on_truncated_payload() { + let mut payload = vec![0u8; 8]; // far too short for an ether deposit + let advance = advance_with_payload(&mut payload); + let mut out = unsafe { std::mem::zeroed::() }; + + let rc = unsafe { + bindings::cma_parser_decode_advance( + bindings::cma_parser_input_type_t_CMA_PARSER_INPUT_TYPE_ETHER_DEPOSIT, + &advance, + &mut out, + ) + }; + assert_ne!(rc, 0, "truncated payload must be rejected"); + + // Pulls parser.o and proves the accessor is linked; the message itself is libcma's wording, + // so only its presence is asserted. + let msg = unsafe { bindings::cma_parser_get_last_error_message() }; + assert!(!msg.is_null(), "libcma should report an error message"); +} + +/// `cma_parser_encode_voucher` is a third entry point into `parser_impl.o` and the one a +/// withdrawal flow reaches. Referencing it keeps it in the link even if the decode paths above +/// are ever changed. +#[test] +fn c_parser_encode_voucher_is_linked() { + let app_address = bindings::cma_abi_address_t { data: [0u8; 20] }; + let request = unsafe { std::mem::zeroed::() }; + let mut voucher = unsafe { std::mem::zeroed::() }; + + // Type NONE is not encodable; the call must fail cleanly rather than crash. What matters + // for this test is that the symbol resolved at link time. + let rc = unsafe { + bindings::cma_parser_encode_voucher( + bindings::cma_parser_voucher_type_t_CMA_PARSER_VOUCHER_TYPE_NONE, + &app_address, + &request, + &mut voucher, + ) + }; + assert_ne!(rc, 0, "an unset voucher type must not encode successfully"); +} From 44236aac6df5b8b52362372436f02f7752b2bebf Mon Sep 17 00:00:00 2001 From: Idogwu Chinonso Date: Thu, 20 Aug 2026 16:58:11 +0100 Subject: [PATCH 2/9] fix(build): invalidate the prebuilt libcma archive when its inputs change build.rs cached the C++ build on `if !lib_path.exists()`. Nothing about libcma.a records which revision of machine-asset-tools it was compiled from, so bumping the submodule left a stale archive in place and silently linked yesterday's libcma against today's headers. The bindgen layout assertions cannot catch that -- they only ever see the headers. Stamp the object directory with the inputs that determine its contents (both submodule revisions, the target arch, and the compiler overrides) and rebuild when they change. On a mismatch the whole object dir is removed first: make cannot be trusted to notice, because a `git checkout` of a submodule can leave sources with OLDER mtimes than objects built from the previous revision, so an incremental make would consider them up to date. The stamp is written only after the archives exist, so an interrupted build leaves none behind and the next run rebuilds instead of trusting a half-built directory. --- build.rs | 86 +++++++++++++++++++++++++++++++++++++++++- tests/c_parser_link.rs | 5 ++- 2 files changed, 89 insertions(+), 2 deletions(-) diff --git a/build.rs b/build.rs index ab6fb3d..992eeb7 100644 --- a/build.rs +++ b/build.rs @@ -129,7 +129,34 @@ fn main() { // - riscv64: the RISC-V GCC 14 cross toolchain (g++-14-riscv64-linux-gnu / gcc-14-…). // - host-real: a host C++ toolchain with g++ >= 14 (C++20/C++23) and Boost is fetched. // Override the compiler names with CMA_RISCV64_CXX/CC or CMA_HOST_CXX/CC. - if !lib_path.exists() || (host && !cmt_lib_path.exists()) { + // + // Presence alone is NOT a sufficient cache key. `libcma.a` is compiled from the + // machine-asset-tools submodule, but nothing about the archive records WHICH revision it + // came from, so bumping the submodule used to leave a stale archive in place and silently + // link yesterday's libcma against today's headers — exactly the kind of skew the bindgen + // layout assertions cannot catch, because they only ever see the headers. The stamp below + // records the inputs that determine the archive's contents; any change forces a rebuild. + let stamp_path = lib_dir.join(".cma-build-stamp"); + let stamp = build_stamp(&manifest_dir, host); + let stamp_matches = std::fs::read_to_string(&stamp_path) + .map(|recorded| recorded.trim() == stamp.trim()) + .unwrap_or(false); + let artifacts_present = lib_path.exists() && (!host || cmt_lib_path.exists()); + + if !artifacts_present || !stamp_matches { + // A stale stamp means the tree the objects were compiled from is gone. Make cannot be + // trusted to notice: a `git checkout` of the submodule can leave source files with + // OLDER mtimes than the objects built from the previous revision, so an incremental + // make would consider them up to date. Drop the whole object dir and rebuild clean. + if lib_dir.exists() && !stamp_matches { + println!( + "cargo:warning=libcma_binding_rust: build inputs changed since {} was compiled \ + — rebuilding from scratch.", + lib_dir.display() + ); + std::fs::remove_dir_all(&lib_dir) + .expect("failed to clear the stale libcma object directory"); + } let (toolchain_prefix, cxx, cc, ar) = if host { ( String::new(), @@ -233,6 +260,10 @@ fn main() { "libcmt.a build did not produce {}", cmt_lib_path.display() ); + + // Written only after the archives exist, so an interrupted or failed build leaves no + // stamp and the next run rebuilds rather than trusting a half-built object dir. + std::fs::write(&stamp_path, &stamp).expect("failed to write the libcma build stamp"); } println!( @@ -260,6 +291,59 @@ fn main() { println!("cargo:rerun-if-changed=third_party/machine-guest-tools/sys-utils/libcmt/include/"); } +/// Identity of everything that determines the contents of the prebuilt `libcma.a`/`libcmt.a`. +/// +/// Compared against the stamp stored beside the archives to decide whether a cached build is +/// still valid. Covers: +/// - the checked-out revision of each submodule the C++ is compiled from (the reason this +/// exists: a submodule bump must invalidate the archive); +/// - the target arch, since host and cross objects are not interchangeable; +/// - the compiler overrides, since switching toolchains changes the objects. +/// +/// The submodule revisions come from `git -C rev-parse HEAD` rather than from the +/// gitlink, so a locally checked-out or dirty submodule is also distinguished. A checkout with +/// no usable git (a vendored tarball, say) reports `unknown`, which keeps the stamp stable and +/// falls back to plain presence-caching rather than rebuilding on every single run. +fn build_stamp(manifest_dir: &Path, host: bool) -> String { + let rev = |sub: &str| -> String { + Command::new("git") + .args([ + "-C", + manifest_dir.join(sub).to_str().unwrap_or("."), + "rev-parse", + "HEAD", + ]) + .output() + .ok() + .filter(|o| o.status.success()) + .and_then(|o| String::from_utf8(o.stdout).ok()) + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| "unknown".to_string()) + }; + + let (cxx, cc) = if host { + ( + env::var("CMA_HOST_CXX").unwrap_or_else(|_| "g++".into()), + env::var("CMA_HOST_CC").unwrap_or_else(|_| "gcc".into()), + ) + } else { + ( + env::var("CMA_RISCV64_CXX").unwrap_or_else(|_| "riscv64-linux-gnu-g++-14".into()), + env::var("CMA_RISCV64_CC").unwrap_or_else(|_| "riscv64-linux-gnu-gcc-14".into()), + ) + }; + + // Line-oriented and human-readable on purpose: when a rebuild is triggered unexpectedly, the + // stamp file is the first thing anyone will `cat`. + format!( + "machine-asset-tools={}\nmachine-guest-tools={}\narch={}\ncxx={cxx}\ncc={cc}\n", + rev("third_party/machine-asset-tools"), + rev("third_party/machine-guest-tools"), + if host { "host" } else { "riscv64" }, + ) +} + /// Return the directory holding GCC's builtin headers (stdbool.h, stddef.h…), if discoverable. /// Used as a fallback so bindgen works even when libclang ships without its own resource headers. fn gcc_builtin_include() -> Option { diff --git a/tests/c_parser_link.rs b/tests/c_parser_link.rs index 880a51e..95b488d 100644 --- a/tests/c_parser_link.rs +++ b/tests/c_parser_link.rs @@ -63,7 +63,10 @@ fn c_parser_decodes_ether_deposit_vector() { &mut out, ) }; - assert_eq!(rc, 0, "C parser rejected the canonical ether-deposit vector"); + assert_eq!( + rc, 0, + "C parser rejected the canonical ether-deposit vector" + ); assert_eq!( out.type_, bindings::cma_parser_input_type_t_CMA_PARSER_INPUT_TYPE_ETHER_DEPOSIT From 6e76750c1fa718a7a5048166b4de05d03dc563c2 Mon Sep 17 00:00:00 2001 From: Idogwu Chinonso Date: Thu, 20 Aug 2026 17:02:46 +0100 Subject: [PATCH 3/9] deps: bump libcma to v0.1.0-alpha.10 and libcmt to v0.18.0 machine-asset-tools alpha.10 is a single commit whose only substance is bumping the libcmt it builds against, 0.17.2 -> 0.18.0. libcma's own API and implementation are untouched: the tag-to-tag diff has zero changes under include/, ledger/ or parser/. It also adds ioctl.h to the extracted libcmt header set (0.18.0 vendors the cmio kernel UAPI structs so libcmt cross-compiles without the cartesi kernel-headers package) and makes `make docker-image` pass MACHINE_GUEST_TOOLS_VERSION as a build-arg, so the image's libcmt can no longer drift from the staged headers. Both submodules move together on purpose. bindgen reads its headers from our machine-guest-tools submodule while libcma compiles against the tarball machine-asset-tools downloads itself, so bumping one alone puts 0.17.2 headers behind 0.18.0 objects. The libcmt changes reaching bindgen are renames only, with no size, alignment or field-offset change: cmt_rollup_t.finish_root_hash -> finish_outputs_merkle_root struct tag cmt_rollup_finish -> cmt_rollup_finish_s (_t typedef stable) HTIF_YIELD_REASON_{ADVANCE,INSPECT} -> ..._{ADVANCE,INSPECT}_STATE Regenerated bindings differ by 54 lines, all renames; the 50 bindgen layout assertions and the full 100-test host-real suite pass with no source change. The Cartesi SDK 12 application templates also ship guest-tools 0.18.0, so this keeps the crate aligned with the runtime its applications execute in. DEPENDENCIES.lock now records BOTH submodules and is asserted against the actual pins in CI. It had drifted: it claimed machine-guest-tools v0.17.2 (3d838a2) while the submodule sat on a later main commit (20eba47). Also corrects two CHANGELOG claims that did not match the code: build.rs checksums the nlohmann/json header, not "the vendored/built libcma source", and the uint96 entry cited the e4bfc24 feature branch rather than the v0.1.0-alpha.9 tag actually pinned. --- .github/workflows/test.yml | 25 +++++++++++++++ CHANGELOG.md | 54 +++++++++++++++++++++++++++++++-- DEPENDENCIES.lock | 3 +- SECURITY.md | 14 +++++++++ third_party/machine-asset-tools | 2 +- third_party/machine-guest-tools | 2 +- 6 files changed, 95 insertions(+), 5 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 8829640..9d42050 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -17,6 +17,31 @@ jobs: - uses: dtolnay/rust-toolchain@stable + # DEPENDENCIES.lock is the human-readable record of which upstream revisions the + # submodules are pinned to. It drifted from the actual pins before (it named + # machine-guest-tools v0.17.2 while the submodule sat on a later main commit), and a + # wrong record is worse than none when the question is "which libcmt is this built + # against?". Cheap to assert, so assert it on every push. + - name: Verify DEPENDENCIES.lock matches the submodule pins + run: | + set -euo pipefail + status=0 + while read -r repo tag sha; do + [[ -z "${repo:-}" || "${repo:0:1}" == "#" ]] && continue + path="third_party/${repo##*/}" + actual=$(git ls-tree HEAD "$path" | awk '{print $3}') + if [[ -z "$actual" ]]; then + echo "::error::DEPENDENCIES.lock names $repo but $path is not a submodule of this tree" + status=1 + elif [[ "$actual" != "$sha" ]]; then + echo "::error::$repo ($tag): DEPENDENCIES.lock says $sha, $path is pinned to $actual" + status=1 + else + echo "ok: $repo $tag $sha" + fi + done < DEPENDENCIES.lock + exit $status + - name: Install libclang for bindgen run: sudo apt-get update && sudo apt-get install -y clang libclang-dev diff --git a/CHANGELOG.md b/CHANGELOG.md index 1dec807..d0d0779 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,10 +19,31 @@ While the crate is pre-1.0 (`0.0.x`), any release may contain breaking changes. (MSRV `1.74`), a `documentation` link, and `[package.metadata.docs.rs]` (an offline `mock`-only docs build). Added `CHANGELOG.md`, `CONTRIBUTING.md`, `SECURITY.md`, `deny.toml`, `rust-toolchain.toml`, and `rustfmt.toml`. +- **`tests/c_parser_link.rs`** — forces libcma's `parser_impl.o` into the link so + the libcmt dependency below cannot silently regress, and checks the C parser + agrees with the Rust port on the canonical ether-deposit vector. The + `riscv-link-check` CI job gained the equivalent guard: it now links a real + riscv64 binary that calls the C parser instead of only `ar`-inspecting the + archive. +- CI now asserts `DEPENDENCIES.lock` matches the actual submodule pins. ### Changed +- **Vendored `libcma` bumped to `v0.1.0-alpha.10` and `libcmt` to `v0.18.0`.** + libcma's own API and implementation are unchanged between `alpha.9` and + `alpha.10` — the release only bumps the libcmt it builds against (0.17.2 → + 0.18.0), adds `ioctl.h` to the extracted libcmt header set, and makes + `make docker-image` pass `MACHINE_GUEST_TOOLS_VERSION` as a build-arg so the + image's libcmt can no longer drift from the staged headers. The libcmt changes + that reach bindgen are renames only, with no size, alignment, or field-offset + change: `cmt_rollup_t.finish_root_hash` → `finish_outputs_merkle_root`, struct + tag `cmt_rollup_finish` → `cmt_rollup_finish_s` (the `_t` typedef is + unchanged), and `HTIF_YIELD_REASON_{ADVANCE,INSPECT}` → + `..._{ADVANCE,INSPECT}_STATE`. No source change was needed in this crate. + Note the Cartesi SDK 12 application templates also ship guest-tools 0.18.0, so + this keeps the crate aligned with the runtime its apps execute in. - **Vendored `libcma` bumped to the uint96 single-asset format (drive format v2; - machine-asset-tools `e4bfc24`).** The single-asset drive record widened its + machine-asset-tools `v0.1.0-alpha.9`, `19a1e5e` — the merge of the `e4bfc24` + feature branch).** The single-asset drive record widened its balance from `uint64` to `uint96`, consuming the former 4-byte pad: the 32-byte record is now `balance_lo (u64 LE) | balance_hi (u32 LE) | owner (20B)`, with the owner moved from offset 8 to **offset 12**. Total supply and virtual (internal @@ -46,12 +67,41 @@ While the crate is pre-1.0 (`0.0.x`), any release may contain breaking changes. - **Relocation safety for `cma_ledger_t`.** The self-referential C++ ledger is now boxed so it is not moved after construction, preventing dangling internal self-pointers. +- **`libcmt` is now linked alongside `libcma` on the real backends.** `libcma.a` + archives only libcma's own objects; its `parser_impl.o` references libcmt's C + ABI helpers (`cmt_abi_*`, `cmt_buf_*`), which are not in the archive, but + `build.rs` emitted only `-lcma -lstdc++`. Because archive members are pulled + lazily and nothing in this crate calls the C parser (`parser.rs` is pure Rust + over `alloy`), the gap was invisible here and only broke DOWNSTREAM consumers + that called `cma_parser_decode_advance`/`_inspect` or `cma_parser_encode_voucher` + — with a dozen `undefined symbol: cmt_abi_*` at link time. `host-real` now + builds and statically links `libcmt.a`; `riscv64` emits a plain `-lcmt`, + resolved by the machine-guest-tools package in the application image (upstream + deliberately does not build libcmt for riscv64, as its real io backend needs + the cartesi kernel headers). +- **Stale prebuilt archives are no longer reused across submodule bumps.** + `build.rs` cached the C++ build on the mere existence of `libcma.a`, so a bump + of `machine-asset-tools` silently linked the previously-compiled archive + against the new headers. The object directory is now stamped with the inputs + that determine it (both submodule revisions, target arch, compiler overrides) + and rebuilt from scratch when they change. +- **A partially-staged `third-party/libcmt` is now repaired automatically.** + Upstream's staging target is a directory, whose mtime is always newer than the + tarball it came from, so make treats an incomplete copy as up to date forever + and the libcmt compile dies on a missing `src/buf.c`. ### Security / hardening - Mutual-exclusion guards: enabling more than one backend feature now fails at compile time (`compile_error!`) instead of silently letting the mock win in a real build. -- `build.rs` verifies a checksum of the vendored/built libcma source. +- `build.rs` verifies the nlohmann/json header it downloads against a SHA-256 + pinned in that file — it is the one build input fetched over the network + rather than pinned as a submodule. (Earlier wording here claimed a checksum + over "the vendored/built libcma source"; libcma is pinned by submodule commit + SHA, not checksummed.) +- `SECURITY.md` documents why the C/C++ dependencies are pinned by commit SHA + rather than tag name: upstream tags are mutable, and machine-asset-tools' + `v0.1.0-alpha.8` has already been force-moved once. ### Changed (breaking) - **Migrated off the EOL `ethers-rs` onto `alloy`** (`alloy-primitives` + diff --git a/DEPENDENCIES.lock b/DEPENDENCIES.lock index d3845c0..e6c0bf2 100644 --- a/DEPENDENCIES.lock +++ b/DEPENDENCIES.lock @@ -1 +1,2 @@ -cartesi/machine-guest-tools v0.17.2 3d838a2f80cd8fa614e38ea93efcfabe8ec65bea +Mugen-Builders/machine-asset-tools v0.1.0-alpha.10 25d32cd895031b0e22015dbc5b91ba8821762830 +cartesi/machine-guest-tools v0.18.0 5222250c69371f7cbe96e6082699a8ca22b5969c diff --git a/SECURITY.md b/SECURITY.md index fe7fb4f..f4be628 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -23,6 +23,20 @@ Please include a description, the affected version(s), and reproduction steps where possible. You can expect an initial acknowledgement within a reasonable time frame; we will then coordinate a fix and a disclosure timeline with you. +## Vendored C/C++ dependencies + +The real backends compile libcma (and, on the host, libcmt) from the sources in +`third_party/`, which are git submodules pinned by **commit SHA** — never by tag +name. This is deliberate: upstream tags are mutable. `v0.1.0-alpha.8` of +machine-asset-tools has already been force-moved once, and a fetch of a +re-pointed tag would silently change what gets compiled if we tracked tag names. +`DEPENDENCIES.lock` records the pinned revisions in readable form and CI asserts +it matches the actual submodule pins. + +The one dependency fetched over the network at build time rather than pinned as +a submodule is the nlohmann/json single header, so `build.rs` verifies it +against a SHA-256 pinned in that file. A mismatch fails the build. + ## Scope note The default `mock` backend is an in-memory stub for development and testing — it diff --git a/third_party/machine-asset-tools b/third_party/machine-asset-tools index 19a1e5e..25d32cd 160000 --- a/third_party/machine-asset-tools +++ b/third_party/machine-asset-tools @@ -1 +1 @@ -Subproject commit 19a1e5efa599622f4bebbab6bb15faf29aa82b2d +Subproject commit 25d32cd895031b0e22015dbc5b91ba8821762830 diff --git a/third_party/machine-guest-tools b/third_party/machine-guest-tools index 20eba47..5222250 160000 --- a/third_party/machine-guest-tools +++ b/third_party/machine-guest-tools @@ -1 +1 @@ -Subproject commit 20eba47631a1b0938c6e0f2c4af1ac88a172c90d +Subproject commit 5222250c69371f7cbe96e6082699a8ca22b5969c From 1fd51e6888ce0a0797008f31dda7636993ffa780 Mon Sep 17 00:00:00 2001 From: Idogwu Chinonso Date: Thu, 20 Aug 2026 17:12:31 +0100 Subject: [PATCH 4/9] docs(readme): document the riscv64 libcmt requirement for applications libcma.a contains only libcma's own objects, so build.rs emits `-lcmt` for the riscv64 backend and libcmt.a must be on the linker's search path in whichever stage cross-links the application binary. Upstream does not build libcmt for riscv64 (its real io backend needs the cartesi kernel headers), so it has to come from the released machine-guest-tools package. Installing machine-guest-tools in the runtime stage -- which the stock Cartesi templates already do -- does NOT satisfy this: by then the binary is linked. Document staging it into the cross sysroot of the build stage instead, at the same version the runtime stage installs, along with the GCC >= 14 cross compiler overrides libcma's C++ needs. Derived from wiring the crate into a real `cartesi create` application; both are the steps that application actually required. --- README.md | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/README.md b/README.md index 3da7e68..5427c8b 100644 --- a/README.md +++ b/README.md @@ -80,6 +80,40 @@ If you forget `default-features = false`, enabling `host-real` or `riscv64` alongside the default `mock` trips the mutual-exclusivity `compile_error!` — read its message; the fix is to disable default features. +### `riscv64`: your application must supply `libcmt` + +`libcma.a` contains only libcma's own objects. Its parser references libcmt's C +ABI helpers (`cmt_abi_*`, `cmt_buf_*`), so `build.rs` emits `-lcmt` for the +`riscv64` backend and **`libcmt.a` must be on the linker's search path in the +stage that cross-links your binary**. Upstream deliberately does not build libcmt +for riscv64 — its real io backend needs the Cartesi Linux kernel headers, which +exist only inside the guest — so it comes from the released `machine-guest-tools` +package. (`host-real` needs nothing extra: `build.rs` builds and statically links +`libcmt.a` itself, from the vendored sources with the mock io backend.) + +Installing machine-guest-tools in your application's *runtime* stage is **not +enough** — by then the binary is already linked. Stage it into the cross sysroot +of the build stage, using the same guest-tools version your runtime stage +installs, so you link and run against one libcmt ABI: + +```dockerfile +# in the cross-build stage, before `cargo build` +ARG MACHINE_GUEST_TOOLS_VERSION +ADD https://github.com/cartesi/machine-guest-tools/releases/download/v${MACHINE_GUEST_TOOLS_VERSION}/machine-guest-tools_riscv64.deb /tmp/gt.deb +RUN dpkg-deb -x /tmp/gt.deb /tmp/gt && \ + cp -a /tmp/gt/usr/lib/libcmt.a /usr/riscv64-linux-gnu/lib/ && \ + cp -a /tmp/gt/usr/include/libcmt /usr/riscv64-linux-gnu/include/ + +# libcma is C++20/23, so it needs GCC >= 14; the stock template installs 13. +ENV CMA_RISCV64_CXX=riscv64-linux-gnu-g++-14 \ + CMA_RISCV64_CC=riscv64-linux-gnu-gcc-14 +``` + +Symptom if this is missing: the C++ compile succeeds and the failure appears only +at the final link, as `undefined symbol: cmt_abi_get_uint`, `cmt_buf_split`, and +friends. It surfaces only once something calls the C parser bindings, because +static archive members are pulled lazily. + ### Determinism / reproducibility `host-real` and `riscv64` compile the C++ `libcma` with SIMD-free / generic flags From 7d56db1b83bf872cd94d91f8ab57faaf453d3d56 Mon Sep 17 00:00:00 2001 From: Idogwu Chinonso Date: Thu, 20 Aug 2026 17:14:22 +0100 Subject: [PATCH 5/9] fix(build): keep reusing externally-supplied libcma archives The stamp check as first written deleted any object directory whose stamp did not match -- including one with NO stamp at all. That would have broken a documented workflow: cross-building build/riscv64/libcma.a out of band (e.g. `make docker`) and then running cargo, which is how anyone without a local RISC-V toolchain uses the riscv64 backend. build.rs would have deleted the archive and tried to rebuild it natively. Distinguish the three cases instead: - stamp present and DIFFERENT -> we built it and the inputs moved: rebuild; - no stamp, archives present -> supplied from elsewhere: reuse, but warn that its provenance cannot be checked against the current pins; - no archives -> build. This keeps the actual fix (our own stale rebuilds are caught) without taking away the prebuilt-archive path. --- build.rs | 28 +++++++++++++++++++++++----- 1 file changed, 23 insertions(+), 5 deletions(-) diff --git a/build.rs b/build.rs index 992eeb7..36cceed 100644 --- a/build.rs +++ b/build.rs @@ -138,17 +138,35 @@ fn main() { // records the inputs that determine the archive's contents; any change forces a rebuild. let stamp_path = lib_dir.join(".cma-build-stamp"); let stamp = build_stamp(&manifest_dir, host); - let stamp_matches = std::fs::read_to_string(&stamp_path) - .map(|recorded| recorded.trim() == stamp.trim()) - .unwrap_or(false); + let recorded_stamp = std::fs::read_to_string(&stamp_path).ok(); let artifacts_present = lib_path.exists() && (!host || cmt_lib_path.exists()); - if !artifacts_present || !stamp_matches { + // Three cases, deliberately distinguished: + // - a stamp that DIFFERS → we built this archive and its inputs moved: rebuild. + // - NO stamp but archives present → the archive came from somewhere else (an + // out-of-band `make docker` cross-build, a CI cache, a vendored artifact). That is a + // supported workflow, so it is left alone — deleting it would strand anyone without a + // local cross toolchain. Its provenance cannot be checked, so say so out loud. + // - no archives → build. + let stale = recorded_stamp + .as_deref() + .is_some_and(|recorded| recorded.trim() != stamp.trim()); + + if artifacts_present && recorded_stamp.is_none() { + println!( + "cargo:warning=libcma_binding_rust: reusing the prebuilt {} — it carries no build \ + stamp, so it cannot be checked against the current submodule revisions. Delete \ + the directory to force a rebuild from source.", + lib_dir.display() + ); + } + + if !artifacts_present || stale { // A stale stamp means the tree the objects were compiled from is gone. Make cannot be // trusted to notice: a `git checkout` of the submodule can leave source files with // OLDER mtimes than the objects built from the previous revision, so an incremental // make would consider them up to date. Drop the whole object dir and rebuild clean. - if lib_dir.exists() && !stamp_matches { + if lib_dir.exists() && stale { println!( "cargo:warning=libcma_binding_rust: build inputs changed since {} was compiled \ — rebuilding from scratch.", From 427198ff2cfea5e008b80c8006176802f918d249 Mon Sep 17 00:00:00 2001 From: Idogwu Chinonso Date: Thu, 20 Aug 2026 19:05:58 +0100 Subject: [PATCH 6/9] fix(build): link the C++ runtime statically under +crt-static The Cartesi Rust application template sets `-C target-feature=+crt-static`, and that flag IS honoured for riscv64gc-unknown-linux-gnu: glibc gets linked in statically. build.rs, however, always requested libstdc++ as a dylib. The resulting application had a static libc and exactly one dynamic dependency, libstdc++.so.6 -- and with it an ELF interpreter of /lib/ld.so.1, which does not exist in the machine's rootfs (Ubuntu riscv64 installs the loader as /lib/ld-linux-riscv64-lp64d.so.1). The machine could not exec the application at all: WARN rollup_http_server::dapp_process] throwing exception because dapp failed to start with No such file or directory (os error 2) an error naming neither the loader nor libstdc++, followed by the machine aborting with "no snapshot to rollback to". So the crate was effectively unusable in a stock Cartesi Rust application, and nothing in the existing test suite could see it: the host backend does not set crt-static, and CI never linked a riscv64 binary. Under +crt-static, bind libstdc++ and libcmt statically as well. Both archives live in the gcc-cross directories and the cross sysroot, which the linker searches but rustc does not, so `static=` alone fails with "could not find native static library". Each is located with ` -print-file-name=`, so the paths come from the toolchain that built libcma rather than being guessed, and the directory is added to rustc's search path. Note `-l:libfoo.a` as a raw link-arg does NOT work here: rustc emits link-args BEFORE the native libraries, so the archive is already past by the time libcma's undefined __cxa_* references are seen. Verified by building a `cartesi create --template rust` application against this crate and booting it on a real Cartesi machine: the binary now has no PT_INTERP and no NEEDED entries, and the in-machine self-test passes the real C++ ledger (deposit/transfer/withdraw arithmetic and the records-prefix layout), the C parser through libcmt, and the Rust parser. --- CHANGELOG.md | 13 +++++ README.md | 31 ++++++++++++ build.rs | 137 +++++++++++++++++++++++++++++++++++++++++---------- 3 files changed, 155 insertions(+), 26 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d0d0779..9c8ad51 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -85,6 +85,19 @@ While the crate is pre-1.0 (`0.0.x`), any release may contain breaking changes. against the new headers. The object directory is now stamped with the inputs that determine it (both submodule revisions, target arch, compiler overrides) and rebuilt from scratch when they change. +- **`+crt-static` targets now link the C++ runtime statically too.** The Cartesi + Rust application template sets `-C target-feature=+crt-static`, which IS + honoured for `riscv64gc-unknown-linux-gnu`: glibc links in statically. But + `build.rs` requested libstdc++ as a *dylib*, leaving the application with a + static libc, a lone `NEEDED libstdc++.so.6`, and therefore an interpreter of + `/lib/ld.so.1` — a path that does not exist in the machine rootfs (Ubuntu + riscv64 ships the loader as `/lib/ld-linux-riscv64-lp64d.so.1`). The machine + could not exec the application at all, reporting only `dapp failed to start + with No such file or directory`, which names neither the loader nor libstdc++. + Under `+crt-static`, libstdc++ and libcmt are now bound statically, with each + archive located via ` -print-file-name=` so the paths come from the + toolchain. Verified end to end by booting a `cartesi create --template rust` + application on a real Cartesi machine. - **A partially-staged `third-party/libcmt` is now repaired automatically.** Upstream's staging target is a directory, whose mtime is always newer than the tarball it came from, so make treats an incomplete copy as up to date forever diff --git a/README.md b/README.md index 5427c8b..06e60e0 100644 --- a/README.md +++ b/README.md @@ -114,6 +114,37 @@ at the final link, as `undefined symbol: cmt_abi_get_uint`, `cmt_buf_split`, and friends. It surfaces only once something calls the C parser bindings, because static archive members are pulled lazily. +#### `+crt-static` and the C++ runtime + +The Cartesi Rust template builds with `-C target-feature=+crt-static`, and that +flag **is** honoured for `riscv64gc-unknown-linux-gnu` — glibc is linked in +statically. A pure-Rust application therefore produces a fully static binary with +no `PT_INTERP` at all, which is what the machine's rootfs expects. + +Pulling in a C++ library changes that. If libstdc++ were linked dynamically, the +binary would keep a single `NEEDED libstdc++.so.6` and, with it, an interpreter +of `/lib/ld.so.1` — a path that does **not** exist in the machine rootfs (Ubuntu +riscv64 installs the loader as `/lib/ld-linux-riscv64-lp64d.so.1`). The machine +then cannot exec the application and reports only: + +``` +WARN rollup_http_server::dapp_process] throwing exception because dapp failed to + start with No such file or directory (os error 2) +``` + +which names neither the loader nor libstdc++. + +So when `+crt-static` is set, `build.rs` binds libstdc++ and libcmt statically as +well, locating each archive through ` -print-file-name=` so the paths +come from the toolchain rather than being guessed. Nothing is required of the +application beyond providing a C++ toolchain that ships `libstdc++.a` — the +`g++-14-riscv64-linux-gnu` package does. Verify with: + +```console +$ riscv64-linux-gnu-readelf -l dapp | grep -i interpreter # expect no output +$ riscv64-linux-gnu-readelf -d dapp | grep NEEDED # expect no output +``` + ### Determinism / reproducibility `host-real` and `riscv64` compile the C++ `libcma` with SIMD-free / generic flags diff --git a/build.rs b/build.rs index 36cceed..69119f3 100644 --- a/build.rs +++ b/build.rs @@ -161,6 +161,24 @@ fn main() { ); } + // Hoisted out of the build block below: the link step also needs `cxx`, to ask the very + // compiler that built libcma where its matching libstdc++.a lives. + let (toolchain_prefix, cxx, cc, ar) = if host { + ( + String::new(), + env::var("CMA_HOST_CXX").unwrap_or_else(|_| "g++".into()), + env::var("CMA_HOST_CC").unwrap_or_else(|_| "gcc".into()), + "ar".to_string(), + ) + } else { + ( + "riscv64-linux-gnu-".to_string(), + env::var("CMA_RISCV64_CXX").unwrap_or_else(|_| "riscv64-linux-gnu-g++-14".into()), + env::var("CMA_RISCV64_CC").unwrap_or_else(|_| "riscv64-linux-gnu-gcc-14".into()), + "riscv64-linux-gnu-ar".to_string(), + ) + }; + if !artifacts_present || stale { // A stale stamp means the tree the objects were compiled from is gone. Make cannot be // trusted to notice: a `git checkout` of the submodule can leave source files with @@ -175,23 +193,6 @@ fn main() { std::fs::remove_dir_all(&lib_dir) .expect("failed to clear the stale libcma object directory"); } - let (toolchain_prefix, cxx, cc, ar) = if host { - ( - String::new(), - env::var("CMA_HOST_CXX").unwrap_or_else(|_| "g++".into()), - env::var("CMA_HOST_CC").unwrap_or_else(|_| "gcc".into()), - "ar".to_string(), - ) - } else { - ( - "riscv64-linux-gnu-".to_string(), - env::var("CMA_RISCV64_CXX") - .unwrap_or_else(|_| "riscv64-linux-gnu-g++-14".into()), - env::var("CMA_RISCV64_CC") - .unwrap_or_else(|_| "riscv64-linux-gnu-gcc-14".into()), - "riscv64-linux-gnu-ar".to_string(), - ) - }; // machine-asset-tools' `third-party` target fetches Boost/emulator/guest-tools but not // nlohmann/json, so fetch that single header first. @@ -291,17 +292,76 @@ fn main() { println!("cargo:rustc-link-lib=static=cma"); // MUST come after `cma`: static archives are resolved left-to-right, and it is libcma's // parser_impl.o that references the cmt_abi_*/cmt_buf_* symbols, not the other way round. - if host { - println!("cargo:rustc-link-lib=static=cmt"); + // Is the target being built with a static CRT? The Cartesi Rust application template sets + // `-C target-feature=+crt-static` in .cargo/config.toml, and it IS honoured for + // riscv64gc-unknown-linux-gnu: glibc gets linked in statically. + let crt_static = env::var("CARGO_CFG_TARGET_FEATURE") + .map(|f| f.split(',').any(|t| t == "crt-static")) + .unwrap_or(false); + + if crt_static { + // Every remaining native dependency must be bound statically too, or the executable + // ends up with a static libc and a DYNAMIC libstdc++ — which still carries + // `INTERP /lib/ld.so.1`. That interpreter does not exist in the Cartesi machine's + // rootfs (Ubuntu riscv64 ships the loader as `/lib/ld-linux-riscv64-lp64d.so.1`), so + // the machine cannot exec the application and reports only + // `dapp failed to start with No such file or directory` — an error that names neither + // the loader nor libstdc++. + // + // libstdc++.a lives in the gcc-cross directories, which the LINKER searches but rustc + // does not — so `static=stdc++` alone fails with "could not find native static + // library". Ask the compiler that built libcma where its own libstdc++.a is and put + // that directory on rustc's search path, so the `static=` kind resolves AND lands in + // the correct place on the link line (after libcma.a, which references it). + // + // Passing `-l:libstdc++.a` as a raw link-arg does not work: rustc emits link-args + // BEFORE the native libraries, so the archive is already past by the time libcma's + // undefined `__cxa_*` references are seen, and the link fails. + match static_lib_dir(&cxx, "libstdc++.a") { + Some(dir) => { + println!("cargo:rustc-link-search=native={dir}"); + println!("cargo:rustc-link-lib=static=stdc++"); + } + None => panic!( + "the target is built with +crt-static but `{cxx} -print-file-name=libstdc++.a` \ + did not locate a static libstdc++. Install the matching C++ toolchain (for \ + riscv64: g++-14-riscv64-linux-gnu), or drop `-C target-feature=+crt-static` \ + and ensure the runtime image provides libstdc++6." + ), + } + if !host { + // libcmt.a comes from the machine-guest-tools package staged into the cross + // sysroot. `-l:libcmt.a` would hit the same ordering problem, so bind it by + // directory too, taken from the sysroot the compiler reports. + match static_lib_dir(&cxx, "libcmt.a") { + Some(dir) => { + println!("cargo:rustc-link-search=native={dir}"); + println!("cargo:rustc-link-lib=static=cmt"); + } + None => panic!( + "the target is built with +crt-static but `{cxx} \ + -print-file-name=libcmt.a` did not locate a static libcmt. Stage \ + libcmt.a from the machine-guest-tools riscv64 package into the cross \ + sysroot — see the README section on the riscv64 libcmt requirement." + ), + } + } else { + println!("cargo:rustc-link-lib=static=cmt"); + } } else { - // Plain `-lcmt`, matching what upstream's own sample apps pass: resolves against the - // libcmt shipped by the machine-guest-tools package in the Cartesi app image. - println!("cargo:rustc-link-lib=cmt"); + if host { + // We built this archive ourselves, in lib_dir, which is on rustc's search path. + println!("cargo:rustc-link-lib=static=cmt"); + } else { + // Plain `-lcmt`, matching what upstream's own sample apps pass: resolves against + // the libcmt shipped by the machine-guest-tools package in the application image. + println!("cargo:rustc-link-lib=cmt"); + } + // libcma is C++ (Boost.Interprocess/Unordered); link the C++ runtime after it so its + // vtables / __cxxabiv1 ABI symbols resolve. Dynamic so the cross gcc locates + // libstdc++.so automatically (the rootfs must then provide libstdc++6). + println!("cargo:rustc-link-lib=dylib=stdc++"); } - // libcma is C++ (Boost.Interprocess/Unordered); link the C++ runtime after it so its - // vtables / __cxxabiv1 ABI symbols resolve. Dynamic so the cross gcc locates libstdc++.so - // automatically (the machine rootfs must provide libstdc++6). - println!("cargo:rustc-link-lib=dylib=stdc++"); } println!("cargo:rerun-if-changed=wrapper.h"); @@ -362,6 +422,31 @@ fn build_stamp(manifest_dir: &Path, host: bool) -> String { ) } +/// Directory containing the static library `archive` (e.g. `libstdc++.a`), as reported by the +/// compiler driver `cc` via `-print-file-name`. +/// +/// The driver searches its own library directories AND its sysroot, which is exactly where the +/// C++ runtime and the staged libcmt live — and where rustc, which only knows its own +/// `-L` paths, would never look. When the driver cannot find the file it echoes the bare name +/// back unchanged, so a result without a directory component means "not found". +fn static_lib_dir(cc: &str, archive: &str) -> Option { + let out = Command::new(cc) + .arg(format!("-print-file-name={archive}")) + .output() + .ok()?; + if !out.status.success() { + return None; + } + let path = PathBuf::from(String::from_utf8(out.stdout).ok()?.trim()); + if !path.is_absolute() || !path.exists() { + return None; + } + // Resolve `.../gcc-cross/riscv64-linux-gnu/14/../../../../riscv64-linux-gnu/lib/…` style + // answers to something rustc can use verbatim. + let path = path.canonicalize().ok()?; + Some(path.parent()?.to_str()?.to_string()) +} + /// Return the directory holding GCC's builtin headers (stdbool.h, stddef.h…), if discoverable. /// Used as a fallback so bindgen works even when libclang ships without its own resource headers. fn gcc_builtin_include() -> Option { From 712fe77626a590a18d2af1f436e1d349eb2f6bdb Mon Sep 17 00:00:00 2001 From: Idogwu Chinonso Date: Sun, 23 Aug 2026 12:23:38 +0100 Subject: [PATCH 7/9] ci: cross-link a fully static riscv64 binary as a regression guard Nothing in CI ever linked a riscv64 EXECUTABLE, which is why the crt-static / libstdc++ defect fixed in 3117a4c reached a real application untouched: `cargo build` on a library only emits an rlib, and rlib members are pulled lazily, so a library build cannot see a missing or wrongly-bound native dependency. Add examples/link_probe.rs, a small binary that touches every native symbol group the crate links against -- the C++ ledger (pulling ledger*.o and libstdc++) and the C parser (pulling parser_impl.o and libcmt's cmt_abi_*). It is meant to be LINKED rather than to be useful. CI now cross-builds it for riscv64gc-unknown-linux-gnu with +crt-static, staging libcmt.a from the machine-guest-tools package into the cross sysroot exactly as an application Dockerfile must, and asserts the result has neither PT_INTERP nor any NEEDED entry. That is precisely the property the Cartesi machine requires and precisely what regressed. Also stop bind-mounting the C probe from /tmp in the riscv link check: a second `-v /tmp/...` mount is refused outright by Docker Desktop, which does not share /tmp. Writing the probe into the already-mounted working directory keeps the step runnable locally as well as on a runner. --- .github/workflows/test.yml | 55 ++++++++++++++++++++++++++--- examples/link_probe.rs | 72 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 123 insertions(+), 4 deletions(-) create mode 100644 examples/link_probe.rs diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 9d42050..0b7b4a3 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -164,7 +164,7 @@ jobs: working-directory: third_party/machine-asset-tools run: | set -euo pipefail - cat > /tmp/cmt_link_probe.c <<'PROBE' + cat > cmt_link_probe.c <<'PROBE' #include #include int main(void) { @@ -183,16 +183,63 @@ jobs: CMA_PARSER_INPUT_TYPE_ETHER_DEPOSIT, &advance, &out) == 0 ? 0 : 1; } PROBE + # The probe is written into the working directory rather than /tmp so that the single + # existing mount carries it — a second `-v /tmp/...` bind is refused outright by Docker + # Desktop, which does not share /tmp by default. + trap 'rm -f cmt_link_probe.c' EXIT docker run --platform linux/riscv64 --rm \ - -v "$PWD":/mnt/app -v /tmp/cmt_link_probe.c:/tmp/cmt_link_probe.c \ - -w /mnt/app libcma-builder \ + -v "$PWD":/mnt/app -w /mnt/app libcma-builder \ bash -euxc ' - g++ -std=c++20 -Iinclude -Ithird-party -x c++ /tmp/cmt_link_probe.c \ + g++ -std=c++20 -Iinclude -Ithird-party -x c++ cmt_link_probe.c \ -L build/riscv64 -lcma -lcmt -lstdc++ -o /tmp/cmt_link_probe file /tmp/cmt_link_probe | grep -F "RISC-V" /tmp/cmt_link_probe ' + # The above proves the C side links. This proves what a real Cartesi application needs: + # that the CRATE cross-links into a FULLY STATIC riscv64 executable. + # + # It has to be an executable — `cargo build` on a library only emits an rlib, whose members + # are pulled lazily, so a library build cannot see a missing or wrongly-bound native + # dependency. And it has to be static: the Cartesi machine's rootfs has no `/lib/ld.so.1`, + # so an application retaining any NEEDED entry cannot be exec'd, and the machine says only + # `dapp failed to start with No such file or directory`. A dynamically-bound libstdc++ used + # to slip through exactly here. + - name: Cross-link a fully static riscv64 Rust binary + env: + CMA_RISCV64_CXX: riscv64-linux-gnu-g++-14 + CMA_RISCV64_CC: riscv64-linux-gnu-gcc-14 + MACHINE_GUEST_TOOLS_VERSION: "0.18.0" + run: | + set -euxo pipefail + sudo apt-get install -y --no-install-recommends \ + g++-14-riscv64-linux-gnu gcc-14-riscv64-linux-gnu binutils-riscv64-linux-gnu + rustup target add riscv64gc-unknown-linux-gnu + + # Stage libcmt.a from the machine-guest-tools package into the cross sysroot, the same + # way an application Dockerfile must (see the README). + curl -fsSL -o /tmp/gt.deb \ + "https://github.com/cartesi/machine-guest-tools/releases/download/v${MACHINE_GUEST_TOOLS_VERSION}/machine-guest-tools_riscv64.deb" + dpkg-deb -x /tmp/gt.deb /tmp/gt + sudo cp -a /tmp/gt/usr/lib/libcmt.a /usr/riscv64-linux-gnu/lib/ + sudo cp -a /tmp/gt/usr/include/libcmt /usr/riscv64-linux-gnu/include/ + + RUSTFLAGS="-C target-feature=+crt-static -C linker=riscv64-linux-gnu-g++-14" \ + cargo build --release --no-default-features --features riscv64 \ + --target riscv64gc-unknown-linux-gnu --example link_probe + + bin=target/riscv64gc-unknown-linux-gnu/release/examples/link_probe + file "$bin" | grep -F 'RISC-V' + if riscv64-linux-gnu-readelf -l "$bin" | grep -i 'interpreter'; then + echo "::error::riscv64 binary requests an ELF interpreter; the machine rootfs has no /lib/ld.so.1" + exit 1 + fi + if riscv64-linux-gnu-readelf -d "$bin" 2>/dev/null | grep 'NEEDED'; then + echo "::error::riscv64 binary has dynamic dependencies; it must be fully static" + exit 1 + fi + echo "ok: fully static riscv64 executable, no interpreter and no NEEDED entries" + # =========================================================================== # TODO(cross-arch-differential): the ULTIMATE host<->machine reproducibility # invariant — not yet implemented (heavy: needs a riscv64 build AND running it diff --git a/examples/link_probe.rs b/examples/link_probe.rs new file mode 100644 index 0000000..67ca50f --- /dev/null +++ b/examples/link_probe.rs @@ -0,0 +1,72 @@ +//! A minimal binary that touches every native symbol group this crate links against. +//! +//! Its purpose is to be *linked*, not to be useful. `cargo build` on a library only ever +//! produces an rlib, and rlib members — like static archive members — are pulled lazily, so a +//! library build cannot detect a missing or wrongly-bound native dependency. Only linking an +//! executable does. +//! +//! CI cross-builds this for `riscv64gc-unknown-linux-gnu` with `+crt-static` and then asserts +//! the result has no `PT_INTERP` and no `NEEDED` entries. That combination is what the Cartesi +//! machine requires: its rootfs has no `/lib/ld.so.1`, so an application with any dynamic +//! dependency left cannot be exec'd at all — and the machine reports that as nothing more +//! informative than `dapp failed to start with No such file or directory`. +//! +//! Run it anywhere to check the same things dynamically: +//! +//! ```sh +//! cargo run --example link_probe --no-default-features --features host-real +//! ``` + +use libcma_binding_rust::ledger::{Ledger, LedgerAsset}; +use libcma_binding_rust::{bindings, Address, U256}; + +fn main() { + // 1. The C++ ledger: pulls ledger.o / ledger_impl.o and, with them, libstdc++. + let token: Address = "0x88A2120B7068E78692C8fd12E751d610B6377E4d" + .parse() + .unwrap(); + let holder: Address = "0x1111111111111111111111111111111111111111" + .parse() + .unwrap(); + + let mut buf = vec![0u8; 4 * 1024 * 1024]; + let mut ledger = Ledger::new().expect("ledger init"); + ledger + .init_single_from_buffer(&mut buf, 4096, LedgerAsset::Erc20(token)) + .expect("init single-asset ledger"); + let asset = ledger + .retrieve_erc20_asset_via_address(token) + .expect("asset"); + let account = ledger + .retrieve_account_via_address(holder) + .expect("account"); + ledger + .deposit(asset, account, U256::from(7)) + .expect("deposit"); + let balance = ledger.get_balance(asset, account).expect("balance"); + assert_eq!(balance, U256::from(7)); + + // 2. The C parser: pulls parser_impl.o and, with it, libcmt's cmt_abi_* / cmt_buf_*. + let mut payload = [0u8; 52]; + payload[19] = 1; // sender + payload[51] = 4; // value + let mut advance = unsafe { std::mem::zeroed::() }; + advance.payload = bindings::cmt_abi_bytes_t { + length: payload.len(), + data: payload.as_mut_ptr().cast(), + }; + let mut decoded = unsafe { std::mem::zeroed::() }; + let rc = unsafe { + bindings::cma_parser_decode_advance( + bindings::cma_parser_input_type_t_CMA_PARSER_INPUT_TYPE_ETHER_DEPOSIT, + &advance, + &mut decoded, + ) + }; + assert_eq!( + rc, 0, + "C parser rejected the canonical ether-deposit vector" + ); + + println!("link probe OK: balance={balance}, C parser rc={rc}"); +} From bdf5cd018112aee8ae8c41c9e7d32509bfb29c33 Mon Sep 17 00:00:00 2001 From: Idogwu Chinonso Date: Sun, 23 Aug 2026 12:33:26 +0100 Subject: [PATCH 8/9] ci: use readelf instead of file(1) inside the riscv64 builder image The riscv64 link probe itself passed on the first CI run -- g++ linked cmt_link_probe.c against libcma + libcmt cleanly, proving the C parser resolves. The step then failed on its own assertion: `file` is not installed in the libcma-builder image. The idiom was lifted from the neighbouring artifact check, which gets away with it because that one runs on the runner rather than inside the container. Use readelf, which the image does have (it ships binutils alongside g++). That also let the "Cross-link a fully static riscv64 Rust binary" step be skipped, so it has still never executed; this unblocks it. --- .github/workflows/test.yml | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 0b7b4a3..e3ed744 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -192,7 +192,12 @@ jobs: bash -euxc ' g++ -std=c++20 -Iinclude -Ithird-party -x c++ cmt_link_probe.c \ -L build/riscv64 -lcma -lcmt -lstdc++ -o /tmp/cmt_link_probe - file /tmp/cmt_link_probe | grep -F "RISC-V" + # `readelf`, not `file`: the builder image ships binutils (it has g++) but no + # file(1). The neighbouring artifact check can use file(1) because it runs on the + # runner rather than inside this container. + readelf -h /tmp/cmt_link_probe | grep -F "RISC-V" + # Runs natively here (the container IS riscv64), so this also proves the linked + # C parser actually decodes the vector rather than merely resolving symbols. /tmp/cmt_link_probe ' From 4c04a6b31cce5b395cf8a1cb7b349c975e544e45 Mon Sep 17 00:00:00 2001 From: Idogwu Chinonso Date: Sun, 23 Aug 2026 12:59:07 +0100 Subject: [PATCH 9/9] ci: split the static riscv64 cross-link into its own QEMU-free job The fully-static cross-link assertion was riding along in riscv-link-check, which runs a riscv64 container under QEMU. None of it needs emulation: build.rs cross-compiles libcma natively with the RISC-V GCC 14 toolchain, and the link and ELF assertions are ordinary host-side work. Running it as its own job makes the two run in parallel, so the wall clock is the slower of the pair rather than their sum. It also isolates the failure modes -- a broken QEMU container build no longer skips the crt-static guard, which is exactly what happened on the first CI run here. Cache the machine-asset-tools download directory too, since build.rs fetches Boost (~210 MB) through it on a runner that has no prebuilt archive. --- .github/workflows/test.yml | 69 +++++++++++++++++++++++++++----------- 1 file changed, 50 insertions(+), 19 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index e3ed744..29a837d 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -201,34 +201,65 @@ jobs: /tmp/cmt_link_probe ' - # The above proves the C side links. This proves what a real Cartesi application needs: - # that the CRATE cross-links into a FULLY STATIC riscv64 executable. - # - # It has to be an executable — `cargo build` on a library only emits an rlib, whose members - # are pulled lazily, so a library build cannot see a missing or wrongly-bound native - # dependency. And it has to be static: the Cartesi machine's rootfs has no `/lib/ld.so.1`, - # so an application retaining any NEEDED entry cannot be exec'd, and the machine says only - # `dapp failed to start with No such file or directory`. A dynamically-bound libstdc++ used - # to slip through exactly here. - - name: Cross-link a fully static riscv64 Rust binary - env: - CMA_RISCV64_CXX: riscv64-linux-gnu-g++-14 - CMA_RISCV64_CC: riscv64-linux-gnu-gcc-14 - MACHINE_GUEST_TOOLS_VERSION: "0.18.0" + + # The riscv64 backend as an APPLICATION consumes it: cross-compiled and linked into a + # fully static executable. Split out of riscv-link-check deliberately — that job runs a + # riscv64 container under QEMU, which is slow, and none of this needs emulation. build.rs + # cross-compiles libcma natively with the RISC-V GCC 14 toolchain, so the two jobs run in + # parallel and the wall clock is the slower of the two rather than their sum. + # + # Why an executable: `cargo build` on a library only emits an rlib, whose members are pulled + # lazily, so a library build cannot see a missing or wrongly-bound native dependency. + # Why static: the Cartesi machine rootfs has no `/lib/ld.so.1`, so an application retaining + # any NEEDED entry cannot be exec'd, and the machine reports only + # `dapp failed to start with No such file or directory`. A dynamically-bound libstdc++ used + # to slip through exactly here. + riscv-rust-link: + runs-on: ubuntu-latest + env: + CMA_RISCV64_CXX: riscv64-linux-gnu-g++-14 + CMA_RISCV64_CC: riscv64-linux-gnu-gcc-14 + # Must match the version the application runtime image installs, so the app links and + # runs against one libcmt ABI. Keep in step with DEPENDENCIES.lock. + MACHINE_GUEST_TOOLS_VERSION: "0.18.0" + steps: + - uses: actions/checkout@v4 + with: + submodules: recursive + + - uses: dtolnay/rust-toolchain@stable + + - name: Install the RISC-V GCC 14 cross toolchain + bindgen deps run: | - set -euxo pipefail + sudo apt-get update sudo apt-get install -y --no-install-recommends \ - g++-14-riscv64-linux-gnu gcc-14-riscv64-linux-gnu binutils-riscv64-linux-gnu - rustup target add riscv64gc-unknown-linux-gnu + g++-14-riscv64-linux-gnu gcc-14-riscv64-linux-gnu binutils-riscv64-linux-gnu \ + clang libclang-dev build-essential make wget curl - # Stage libcmt.a from the machine-guest-tools package into the cross sysroot, the same - # way an application Dockerfile must (see the README). + # Boost is ~210 MB and build.rs fetches it through machine-asset-tools' Makefile. Cache + # the download so only the first run on a given Boost version pays for it. + - name: Cache third-party downloads + uses: actions/cache@v4 + with: + path: third_party/machine-asset-tools/third-party/downloads + key: mat-downloads-${{ hashFiles('third_party/machine-asset-tools/Makefile') }} + + - name: Stage riscv64 libcmt into the cross sysroot + run: | + set -euxo pipefail + # Exactly what an application Dockerfile must do — see the README section on the + # riscv64 libcmt requirement. libcma.a does not contain libcmt's cmt_abi_*/cmt_buf_*. curl -fsSL -o /tmp/gt.deb \ "https://github.com/cartesi/machine-guest-tools/releases/download/v${MACHINE_GUEST_TOOLS_VERSION}/machine-guest-tools_riscv64.deb" dpkg-deb -x /tmp/gt.deb /tmp/gt sudo cp -a /tmp/gt/usr/lib/libcmt.a /usr/riscv64-linux-gnu/lib/ sudo cp -a /tmp/gt/usr/include/libcmt /usr/riscv64-linux-gnu/include/ + - name: Cross-link a fully static riscv64 Rust binary + run: | + set -euxo pipefail + rustup target add riscv64gc-unknown-linux-gnu + RUSTFLAGS="-C target-feature=+crt-static -C linker=riscv64-linux-gnu-g++-14" \ cargo build --release --no-default-features --features riscv64 \ --target riscv64gc-unknown-linux-gnu --example link_probe