From cec59f55a89f29a2d79cfc3d62a41a23813c7b24 Mon Sep 17 00:00:00 2001 From: Alejandro Cabeza Romero Date: Mon, 18 May 2026 15:25:33 +0200 Subject: [PATCH 1/4] Add symbol conflict test. --- rust/Cargo.lock | 8 +++++++ rust/Cargo.toml | 1 + .../Cargo.toml | 10 ++++++++ .../src/lib.rs | 22 ++++++++++++++++++ .../tests/conflicts.rs | 23 +++++++++++++++++++ 5 files changed, 64 insertions(+) create mode 100644 rust/logos-blockchain-circuits-tests/Cargo.toml create mode 100644 rust/logos-blockchain-circuits-tests/src/lib.rs create mode 100644 rust/logos-blockchain-circuits-tests/tests/conflicts.rs diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 597742d..a73e5ad 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -237,6 +237,14 @@ dependencies = [ "logos-blockchain-circuits-types", ] +[[package]] +name = "logos-blockchain-circuits-tests" +version = "0.5.0" +dependencies = [ + "logos-blockchain-circuits-pol-sys", + "logos-blockchain-circuits-poq-sys", +] + [[package]] name = "logos-blockchain-circuits-types" version = "0.5.0" diff --git a/rust/Cargo.toml b/rust/Cargo.toml index ef61e69..4c72d98 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -15,6 +15,7 @@ members = [ "logos-blockchain-circuits-pol-sys", "logos-blockchain-circuits-poq-sys", "logos-blockchain-circuits-signature-sys", + "logos-blockchain-circuits-tests", "logos-blockchain-circuits-types", "logos-blockchain-circuits-common", ] diff --git a/rust/logos-blockchain-circuits-tests/Cargo.toml b/rust/logos-blockchain-circuits-tests/Cargo.toml new file mode 100644 index 0000000..8f4ac9b --- /dev/null +++ b/rust/logos-blockchain-circuits-tests/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "logos-blockchain-circuits-tests" +edition.workspace = true +license.workspace = true +version.workspace = true +publish = false + +[dev-dependencies] +lbc-pol-sys = { workspace = true, features = ["prebuilt"] } +lbc-poq-sys = { workspace = true, features = ["prebuilt"] } diff --git a/rust/logos-blockchain-circuits-tests/src/lib.rs b/rust/logos-blockchain-circuits-tests/src/lib.rs new file mode 100644 index 0000000..5aaee20 --- /dev/null +++ b/rust/logos-blockchain-circuits-tests/src/lib.rs @@ -0,0 +1,22 @@ +pub mod roots { + use std::path::{Path, PathBuf}; + use std::sync::LazyLock; + + pub static TESTS: LazyLock = + LazyLock::new(|| PathBuf::from(env!("CARGO_MANIFEST_DIR"))); + pub static REPOSITORY: LazyLock<&Path> = + LazyLock::new(|| TESTS.parent().expect("Failed to find the repository root.")); + pub static POL: LazyLock = + LazyLock::new(|| REPOSITORY.join("logos-blockchain-circuits-pol-sys")); + pub static POQ: LazyLock = + LazyLock::new(|| REPOSITORY.join("logos-blockchain-circuits-poq-sys")); +} + +pub mod inputs { + use super::roots; + use std::path::PathBuf; + use std::sync::LazyLock; + + pub static POL: LazyLock = LazyLock::new(|| roots::POL.join("sample.input.json")); + pub static POQ: LazyLock = LazyLock::new(|| roots::POQ.join("sample.input.json")); +} diff --git a/rust/logos-blockchain-circuits-tests/tests/conflicts.rs b/rust/logos-blockchain-circuits-tests/tests/conflicts.rs new file mode 100644 index 0000000..6933b11 --- /dev/null +++ b/rust/logos-blockchain-circuits-tests/tests/conflicts.rs @@ -0,0 +1,23 @@ +#[cfg(test)] +mod tests { + use lbc_poq_sys::PoqWitnessInput; + use logos_blockchain_circuits_tests::inputs; + + #[test] + fn test_both_circuits_generate_witness() { + let pol_inputs_raw = std::fs::read_to_string(inputs::POL.as_path()).unwrap(); + let pol_witness_input = lbc_pol_sys::PolWitnessInput::new(pol_inputs_raw).unwrap(); + + // Each sys crate compiles a copy of the same C++ runtime (loadCircuit, get_size_of_witness, …) + // under identical symbol names. When two crates are linked into the same binary, the linker + // silently keeps one definition of each symbol, so one circuit ends up using the other's + // size constants — corrupting dat parsing and causing a SIGSEGV. + // This test reproduces the conflict by calling generate_witness on both circuits in the same binary. + let _pol_witness = lbc_pol_sys::generate_witness(&pol_witness_input); + + let inputs_json_raw = std::fs::read_to_string(inputs::POQ.as_path()).unwrap(); + let inputs_json = PoqWitnessInput::new(inputs_json_raw).unwrap(); + let poq_result = lbc_poq_sys::generate_witness(&inputs_json); + assert!(poq_result.is_ok()); + } +} From 2529c88fab12b7c12e9ae78ac6b27421ebaf3b31 Mon Sep 17 00:00:00 2001 From: Alejandro Cabeza Romero Date: Mon, 18 May 2026 15:28:35 +0200 Subject: [PATCH 2/4] Fix dots. --- .../tests/conflicts.rs | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/rust/logos-blockchain-circuits-tests/tests/conflicts.rs b/rust/logos-blockchain-circuits-tests/tests/conflicts.rs index 6933b11..131bc91 100644 --- a/rust/logos-blockchain-circuits-tests/tests/conflicts.rs +++ b/rust/logos-blockchain-circuits-tests/tests/conflicts.rs @@ -8,11 +8,12 @@ mod tests { let pol_inputs_raw = std::fs::read_to_string(inputs::POL.as_path()).unwrap(); let pol_witness_input = lbc_pol_sys::PolWitnessInput::new(pol_inputs_raw).unwrap(); - // Each sys crate compiles a copy of the same C++ runtime (loadCircuit, get_size_of_witness, …) - // under identical symbol names. When two crates are linked into the same binary, the linker - // silently keeps one definition of each symbol, so one circuit ends up using the other's - // size constants — corrupting dat parsing and causing a SIGSEGV. - // This test reproduces the conflict by calling generate_witness on both circuits in the same binary. + // Each sys crate compiles a copy of the same C++ runtime (loadCircuit, get_size_of_witness, + // ...) under identical symbol names. When two crates are linked into the same binary, the + // linker silently keeps one definition of each symbol, so one circuit ends up using the + // other's size constants — corrupting dat parsing and causing a SIGSEGV. + // This test reproduces the conflict by calling generate_witness on both circuits in the + // same binary. let _pol_witness = lbc_pol_sys::generate_witness(&pol_witness_input); let inputs_json_raw = std::fs::read_to_string(inputs::POQ.as_path()).unwrap(); From a7a666f9279c12d4fa239249c97459032801aebb Mon Sep 17 00:00:00 2001 From: Alejandro Cabeza Romero Date: Mon, 18 May 2026 16:41:34 +0200 Subject: [PATCH 3/4] Localize internal C++ symbols to avoid resolution conflicts. --- .github/resources/witness-generator/Makefile | 21 ++++++++- CONTRIBUTING.md | 49 ++++++++++++++++++++ 2 files changed, 68 insertions(+), 2 deletions(-) diff --git a/.github/resources/witness-generator/Makefile b/.github/resources/witness-generator/Makefile index 5733fb3..adffcf7 100644 --- a/.github/resources/witness-generator/Makefile +++ b/.github/resources/witness-generator/Makefile @@ -52,15 +52,32 @@ windows: $(BIN) windows-lib: CXXFLAGS=$(CXXFLAGS_COMMON) -fPIC -I/include -Duint="unsigned int" windows-lib: $(LIB) +# Localizes internal C++ symbols so multiple circuit libraries can coexist in the same binary without symbol conflicts. +# See CONTRIBUTING.md § "Symbol Isolation". +# Default derived from PROJECT; override with PUBLIC_SYMBOLS= to skip localization. +PUBLIC_SYMBOLS ?= $(PROJECT)_generate_witness $(PROJECT)_generate_witness_from_files +LOCAL_OBJ := $(PROJECT)_local.o # Intermediate object file for symbol localization + +UNAME := $(shell uname -s) + # ---- Rules ---- $(BIN): $(COMMON_OBJS) $(CXX) $(LDFLAGS) $^ $(LDLIBS) -o $@ $(LIB): $(LIB_OBJS) - ar rcs $@ $^ +ifeq ($(strip $(PUBLIC_SYMBOLS)),) + ar rcs $@ $^ # Localization disabled +else ifeq ($(UNAME),Darwin) + ar rcs $@ $^ # On macOS there already is a two-level namespace so conflicts don't occur +else + ld -r -o $(LOCAL_OBJ) $^ + objcopy $(foreach s,$(PUBLIC_SYMBOLS),--keep-global-symbol=$(s)) $(LOCAL_OBJ) + ar rcs $@ $(LOCAL_OBJ) + rm $(LOCAL_OBJ) +endif %.o: %.cpp $(DEPS_HPP) $(CXX) $(CXXFLAGS) -c $< -o $@ clean: - rm -f $(COMMON_OBJS) $(LIB_ONLY_OBJS) $(BIN) $(LIB) + rm -f $(COMMON_OBJS) $(LIB_ONLY_OBJS) $(BIN) $(LIB) $(LOCAL_OBJ) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 884bd75..e5db59d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -37,6 +37,55 @@ When bumping the stable toolchain, update `channel` in `rust-toolchain.toml`. Th --- +## Symbol Isolation in Circuit Libraries + +Each circuit (PoQ, PoL, PoC, Signature) is compiled into a static archive (`libpoq.a`, `libpol.a`, etc.). +All four archives share the same internal C++ runtime — `loadCircuit`, `get_size_of_witness`, the `fr_*` field +arithmetic functions, `calcwit_*` functions, and others. They are compiled from the same source files but with +**different constant values per circuit** (e.g. `get_size_of_witness()` returns 18149 for PoQ and 20531 for PoL). + +### The Problem + +When two or more circuit libraries are linked into the same binary, the GNU linker silently picks the first definition +it encounters for each symbol and discards the rest. +No error, no warning. +The result is that one circuit's constants end up hardwired into functions shared by both circuits, corrupting witness +parsing. +In practice: the wrong `get_size_of_witness()` value causes `loadCircuit` to compute an incorrect buffer size, `pu32` +walks off the end of the buffer, reads garbage as a length field, and the subsequent `memcpy` reads past the stack guard +page, which results in a **SIGSEGV**. + +### The Fix + +The Makefile's `$(LIB)` rule uses a two-step process on Linux and Windows to localize all internal symbols before +archiving: + +1. **Partial link** (`ld -r`): merges all `.o` files into a single relocatable object without producing a final +executable. +No symbols are resolved yet; this is consolidation only. +2. **Symbol localization** (`objcopy --keep-global-symbol`): demotes every global symbol to local *except* the circuit's +two public FFI entry points. +Local symbols are invisible to the final linker, so each archive retains a private copy of every internal symbol. This +means no conflict is possible regardless of how many circuits are linked together. + +The public symbols are derived automatically from `PROJECT`: a circuit built with `PROJECT=poq` keeps +`poq_generate_witness` and `poq_generate_witness_from_files` global and localizes everything else. + +> To skip localization for a specific build (e.g. for debugging), pass `PUBLIC_SYMBOLS=` explicitly on the `make` command +> line. + +On macOS, localization is skipped because `objcopy` is a GNU Binutils tool unavailable by default there. +This is safe: macOS uses a two-level namespace by default, meaning symbols are qualified by which library they come +from, so the conflict does not arise. + +### Maintenance + +`PUBLIC_SYMBOLS` defaults to `$(PROJECT)_generate_witness` and `$(PROJECT)_generate_witness_from_files`. +If the public FFI API ever changes, meaning the entrypoints are renamed or new ones added, the Makefile default must be +updated, otherwise the affected symbols will be localized and linking will fail. + +--- + ## Triggering a New Release for Logos Blockchain Circuits To trigger a release build: From 48667f0537f48d533c9baa7503fae78be961bc00 Mon Sep 17 00:00:00 2001 From: Alejandro Cabeza Romero Date: Mon, 18 May 2026 17:06:32 +0200 Subject: [PATCH 4/4] Invert localization: Define private symbols. --- .github/resources/witness-generator/Makefile | 23 +++++++++++--------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/.github/resources/witness-generator/Makefile b/.github/resources/witness-generator/Makefile index adffcf7..d79e1d0 100644 --- a/.github/resources/witness-generator/Makefile +++ b/.github/resources/witness-generator/Makefile @@ -52,11 +52,15 @@ windows: $(BIN) windows-lib: CXXFLAGS=$(CXXFLAGS_COMMON) -fPIC -I/include -Duint="unsigned int" windows-lib: $(LIB) -# Localizes internal C++ symbols so multiple circuit libraries can coexist in the same binary without symbol conflicts. -# See CONTRIBUTING.md § "Symbol Isolation". -# Default derived from PROJECT; override with PUBLIC_SYMBOLS= to skip localization. -PUBLIC_SYMBOLS ?= $(PROJECT)_generate_witness $(PROJECT)_generate_witness_from_files -LOCAL_OBJ := $(PROJECT)_local.o # Intermediate object file for symbol localization +# Localizes circuit-specific symbols so multiple circuit libraries can coexist in the +# same binary without symbol conflicts. See CONTRIBUTING.md § "Symbol Isolation". +# Only the 9 constants in $(PROJECT).cpp differ per circuit — everything else is +# identical across circuits and can be safely deduplicated by the linker as normal. +# Override with LOCALIZE_SYMS= to skip localization. +LOCALIZE_SYMS ?= get_size_of_witness get_size_of_constants get_size_of_input_hashmap \ + get_main_input_signal_no get_main_input_signal_start get_total_signal_no \ + get_number_of_components get_size_of_io_map get_size_of_bus_field_map +LOCAL_OBJ := $(PROJECT)_local.o UNAME := $(shell uname -s) @@ -65,14 +69,13 @@ $(BIN): $(COMMON_OBJS) $(CXX) $(LDFLAGS) $^ $(LDLIBS) -o $@ $(LIB): $(LIB_OBJS) -ifeq ($(strip $(PUBLIC_SYMBOLS)),) +ifeq ($(strip $(LOCALIZE_SYMS)),) ar rcs $@ $^ # Localization disabled else ifeq ($(UNAME),Darwin) - ar rcs $@ $^ # On macOS there already is a two-level namespace so conflicts don't occur + ar rcs $@ $^ # On macOS two-level namespace, conflicts don't arise else - ld -r -o $(LOCAL_OBJ) $^ - objcopy $(foreach s,$(PUBLIC_SYMBOLS),--keep-global-symbol=$(s)) $(LOCAL_OBJ) - ar rcs $@ $(LOCAL_OBJ) + objcopy $(foreach s,$(LOCALIZE_SYMS),--localize-symbol=$(s)) $(PROJECT).o $(LOCAL_OBJ) + ar rcs $@ $(filter-out $(PROJECT).o,$^) $(LOCAL_OBJ) rm $(LOCAL_OBJ) endif