diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 1c29c86..29a837d 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 @@ -129,6 +154,128 @@ 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 > 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 + # 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 -w /mnt/app libcma-builder \ + 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 + # `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 + ' + + + # 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: | + 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 \ + clang libclang-dev build-essential make wget curl + + # 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 + + 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/CHANGELOG.md b/CHANGELOG.md index 1dec807..9c8ad51 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,54 @@ 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. +- **`+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 + 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/README.md b/README.md index 3da7e68..06e60e0 100644 --- a/README.md +++ b/README.md @@ -80,6 +80,71 @@ 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. + +#### `+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/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/build.rs b/build.rs index 95ee99c..69119f3 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,24 +129,70 @@ 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() { - 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(), - ) - }; + // + // 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 recorded_stamp = std::fs::read_to_string(&stamp_path).ok(); + let artifacts_present = lib_path.exists() && (!host || cmt_lib_path.exists()); + + // 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() + ); + } + + // 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 + // 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() && stale { + 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"); + } // machine-asset-tools' `third-party` target fetches Boost/emulator/guest-tools but not // nlohmann/json, so fetch that single header first. @@ -151,6 +216,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 +249,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 +274,15 @@ 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() + ); + + // 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!( @@ -195,10 +290,78 @@ fn main() { lib_dir.canonicalize().unwrap().display() ); println!("cargo:rustc-link-lib=static=cma"); - // 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++"); + // 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. + // 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 { + 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++"); + } } println!("cargo:rerun-if-changed=wrapper.h"); @@ -206,6 +369,84 @@ 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" }, + ) +} + +/// 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 { 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}"); +} diff --git a/tests/c_parser_link.rs b/tests/c_parser_link.rs new file mode 100644 index 0000000..95b488d --- /dev/null +++ b/tests/c_parser_link.rs @@ -0,0 +1,131 @@ +//! 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"); +} 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