Skip to content

Commit 6e02697

Browse files
committed
feat(binoc): bundle first-party format packs into the fat wheel + ABI canary
Compile the first-party format packs (Excel, Parquet, Avro, DBF, XML, Shapefile, binformats, stat-binary, row-reorder) into the `binoc` wheel, registered in-process through the shared `register_bundled_correspondence_rules` seam so `binoc.diff`/`extract` and the CLI are equally fat. Each pack is behind a default-on cargo feature aggregated by `bundled`, so a slim wheel remains available via `--no-default-features`. SQLite is excluded from the default set (its bundled rusqlite C build is paused). Adds the `binoc-abi-canary` crate: a real renderer exported via `export_plugin!` and built as a cdylib, whose test builds that cdylib and loads it over the C ABI (libloading) to prove description + render round-trip. This keeps the renderer ABI boundary compiler/linker-enforced even though format packs are linked in statically.
1 parent e6b32e4 commit 6e02697

9 files changed

Lines changed: 296 additions & 12 deletions

File tree

Cargo.lock

Lines changed: 18 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ members = [
55
"binoc-stdlib",
66
"binoc-cli",
77
"binoc-python",
8+
"binoc-abi-canary",
89
"model-plugins/binoc-sqlite",
910
"model-plugins/binoc-row-reorder",
1011
"model-plugins/binoc-stat-binary",
@@ -21,6 +22,7 @@ default-members = [
2122
"binoc-core",
2223
"binoc-stdlib",
2324
"binoc-cli",
25+
"binoc-abi-canary",
2426
"model-plugins/binoc-sqlite",
2527
"model-plugins/binoc-row-reorder",
2628
"model-plugins/binoc-stat-binary",

binoc-abi-canary/Cargo.toml

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
[package]
2+
name = "binoc-abi-canary"
3+
version.workspace = true
4+
edition.workspace = true
5+
rust-version.workspace = true
6+
license.workspace = true
7+
repository.workspace = true
8+
homepage.workspace = true
9+
documentation.workspace = true
10+
description = "ABI canary: a native renderer built as a cdylib and loaded over the plugin C ABI in tests, so the boundary stays compiler/linker-enforced"
11+
publish = false
12+
13+
[lib]
14+
name = "binoc_abi_canary"
15+
crate-type = ["cdylib", "rlib"]
16+
17+
# `export_plugin!` emits a `#[cfg(feature = "python")]` pymodule. This crate
18+
# deliberately has no `python` feature (no pyo3); tell check-cfg the value is
19+
# expected rather than declaring a feature that would fail to build if enabled.
20+
[lints.rust]
21+
unexpected_cfgs = { level = "warn", check-cfg = ['cfg(feature, values("python"))'] }
22+
23+
[dependencies]
24+
binoc-sdk = { workspace = true }
25+
serde_json = { workspace = true }
26+
27+
[dev-dependencies]
28+
libloading = "0.9.0"

binoc-abi-canary/src/lib.rs

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
//! ABI canary: a native renderer exported over the plugin C ABI.
2+
//!
3+
//! This crate exists to keep the renderer ABI boundary *honest*. It is a real,
4+
//! separately-compiled `cdylib` that goes through [`binoc_sdk::export_plugin!`],
5+
//! and [`tests/abi_crossing.rs`](../tests/abi_crossing.rs) loads the built
6+
//! artifact over `libloading` — the same path `binoc-python` uses — and asserts
7+
//! a render round-trips.
8+
//!
9+
//! Why this matters: compiling plugins in-process (the fat-`binoc` wheel) means
10+
//! the compiler no longer forces plugin-facing types to be expressible across a
11+
//! process boundary. This crate restores that guarantee for the stable
12+
//! (renderer) tier: if the renderer ABI drifts to something that cannot cross
13+
//! `extern "C"` + JSON, `export_plugin!` fails to compile here, and if the wire
14+
//! contract drifts, the crossing test fails to load or round-trip. It is also
15+
//! the template each rule family must satisfy when it graduates into
16+
//! `plugin_abi`. See
17+
//! `docs/adr/2026-06-30-fat_binoc_distribution_and_abi_canary.md`.
18+
19+
use binoc_sdk::{BinocResult, Changeset, Renderer, RendererDescriptor};
20+
21+
/// A trivial renderer that echoes a few open-vocabulary IR fields back as JSON.
22+
/// Its only job is to exercise the real `cdylib` → `libloading` crossing.
23+
#[derive(Default)]
24+
pub struct EchoRenderer;
25+
26+
impl Renderer for EchoRenderer {
27+
fn descriptor(&self) -> RendererDescriptor {
28+
RendererDescriptor::new("canary.echo", "json")
29+
}
30+
31+
fn render(&self, changesets: &[Changeset], config: &serde_json::Value) -> BinocResult<String> {
32+
let root_action = changesets
33+
.first()
34+
.and_then(|changeset| changeset.root.as_ref())
35+
.map(|root| root.action.clone())
36+
.unwrap_or_else(|| "none".to_string());
37+
Ok(serde_json::json!({
38+
"changesets": changesets.len(),
39+
"root_action": root_action,
40+
"mode": config.get("mode"),
41+
})
42+
.to_string())
43+
}
44+
}
45+
46+
binoc_sdk::export_plugin! {
47+
module: binoc_abi_canary,
48+
renderers: [EchoRenderer],
49+
}
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
//! The ABI canary crossing test.
2+
//!
3+
//! Builds this crate's `cdylib`, loads it over `libloading` (exactly as
4+
//! `binoc-python`'s native-plugin loader does), and asserts that plugin
5+
//! description and a render round-trip across the `extern "C"` + JSON boundary.
6+
//! If the renderer ABI or its wire contract drifts, this fails to load or
7+
//! round-trip — the property the fat in-process build would otherwise give up.
8+
9+
use std::ffi::{CStr, CString};
10+
use std::os::raw::c_char;
11+
use std::path::PathBuf;
12+
use std::process::Command;
13+
14+
use binoc_sdk::plugin_abi::{PluginDescription, RenderRequest, RenderResponse};
15+
use binoc_sdk::{Changeset, DiffNode, SDK_VERSION};
16+
17+
type DescribeFn = unsafe extern "C" fn() -> *mut c_char;
18+
type RenderFn = unsafe extern "C" fn(u32, *const c_char) -> *mut c_char;
19+
type FreeFn = unsafe extern "C" fn(*mut c_char);
20+
21+
/// Build the canary `cdylib` and return the path to the built artifact.
22+
///
23+
/// The nested `cargo build` guarantees the `cdylib` exists (a plain
24+
/// `cargo test` links the crate's rlib but may not emit the `cdylib`); it is a
25+
/// no-op when the artifact is already current. The artifact lives in the
26+
/// profile directory two levels up from the test binary
27+
/// (`<target>/<profile>/deps/<test>` → `<target>/<profile>/`).
28+
fn build_and_locate_cdylib() -> PathBuf {
29+
let status = Command::new(env!("CARGO"))
30+
.args(["build", "-p", "binoc-abi-canary"])
31+
.status()
32+
.expect("run cargo build for the canary cdylib");
33+
assert!(
34+
status.success(),
35+
"failed to build the binoc-abi-canary cdylib"
36+
);
37+
38+
let mut dir = std::env::current_exe().expect("locate the test executable");
39+
dir.pop(); // drop the test binary file name
40+
if dir.ends_with("deps") {
41+
dir.pop(); // drop `deps`, landing in the profile directory
42+
}
43+
44+
let file_name = format!(
45+
"{}binoc_abi_canary{}",
46+
std::env::consts::DLL_PREFIX,
47+
std::env::consts::DLL_SUFFIX
48+
);
49+
let path = dir.join(&file_name);
50+
assert!(
51+
path.exists(),
52+
"canary cdylib not found at {} (looked for {file_name})",
53+
path.display()
54+
);
55+
path
56+
}
57+
58+
/// Take ownership of a C string produced by the plugin and free it via the
59+
/// plugin's own `_binoc_free_string`, mirroring the host loader's contract.
60+
unsafe fn take_owned(ptr: *mut c_char, free_fn: FreeFn) -> String {
61+
assert!(!ptr.is_null(), "plugin returned a null string");
62+
let value = CStr::from_ptr(ptr)
63+
.to_str()
64+
.expect("plugin string is valid UTF-8")
65+
.to_string();
66+
free_fn(ptr);
67+
value
68+
}
69+
70+
#[test]
71+
fn native_renderer_crosses_the_c_abi() {
72+
let path = build_and_locate_cdylib();
73+
74+
unsafe {
75+
let lib = libloading::Library::new(&path).expect("dlopen the canary cdylib");
76+
77+
let describe_fn: DescribeFn = *lib
78+
.get::<DescribeFn>(b"_binoc_plugin_describe")
79+
.expect("_binoc_plugin_describe symbol");
80+
let render_fn: RenderFn = *lib
81+
.get::<RenderFn>(b"_binoc_renderer_render")
82+
.expect("_binoc_renderer_render symbol");
83+
let free_fn: FreeFn = *lib
84+
.get::<FreeFn>(b"_binoc_free_string")
85+
.expect("_binoc_free_string symbol");
86+
87+
// ── Description crosses, and the plugin agrees with the host SDK. ──
88+
let description_json = take_owned(describe_fn(), free_fn);
89+
let description: PluginDescription =
90+
serde_json::from_str(&description_json).expect("parse PluginDescription");
91+
assert_eq!(
92+
description.sdk_version, SDK_VERSION,
93+
"plugin was built against a different binoc-sdk than the host"
94+
);
95+
assert_eq!(description.renderers.len(), 1);
96+
assert_eq!(description.renderers[0].name, "canary.echo");
97+
98+
// ── A render round-trips over the wire. ──
99+
let node = DiffNode::new("modify", "file", "data.bin");
100+
let request = RenderRequest {
101+
changesets: vec![Changeset::new("left", "right", Some(node))],
102+
config: serde_json::json!({ "mode": "canary" }),
103+
};
104+
let request_json = serde_json::to_string(&request).expect("serialize RenderRequest");
105+
let request_c = CString::new(request_json).expect("request has no interior nul");
106+
107+
let response_json = take_owned(render_fn(0, request_c.as_ptr()), free_fn);
108+
let response: RenderResponse =
109+
serde_json::from_str(&response_json).expect("parse RenderResponse");
110+
111+
match response {
112+
RenderResponse::Ok { output } => {
113+
let echoed: serde_json::Value =
114+
serde_json::from_str(&output).expect("renderer output is JSON");
115+
assert_eq!(echoed["root_action"], "modify");
116+
assert_eq!(echoed["changesets"], 1);
117+
assert_eq!(echoed["mode"], "canary");
118+
}
119+
RenderResponse::Error { message } => panic!("renderer returned an error: {message}"),
120+
}
121+
}
122+
}

binoc-cli/Cargo.toml

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,13 +27,45 @@ binoc-sdk = { workspace = true }
2727
binoc-core = { workspace = true }
2828
binoc-stdlib = { workspace = true }
2929
binoc-sqlite = { path = "../model-plugins/binoc-sqlite", optional = true }
30+
binoc-excel = { path = "../model-plugins/binoc-excel", optional = true }
31+
binoc-parquet = { path = "../model-plugins/binoc-parquet", optional = true }
32+
binoc-avro = { path = "../model-plugins/binoc-avro", optional = true }
33+
binoc-dbf = { path = "../model-plugins/binoc-dbf", optional = true }
34+
binoc-xml = { path = "../model-plugins/binoc-xml", optional = true }
35+
binoc-shapefile = { path = "../model-plugins/binoc-shapefile", optional = true }
36+
binoc-binformats = { path = "../model-plugins/binoc-binformats", optional = true }
37+
binoc-stat-binary = { path = "../model-plugins/binoc-stat-binary", optional = true }
38+
binoc-row-reorder = { path = "../model-plugins/binoc-row-reorder", optional = true }
3039
clap = { version = "4.6.1", features = ["derive"] }
3140
clap-markdown = "0.1.5"
3241
serde_json = { workspace = true }
3342

3443
[features]
3544
default = []
3645
sqlite = ["dep:binoc-sqlite"]
46+
excel = ["dep:binoc-excel"]
47+
parquet = ["dep:binoc-parquet"]
48+
avro = ["dep:binoc-avro"]
49+
dbf = ["dep:binoc-dbf"]
50+
xml = ["dep:binoc-xml"]
51+
shapefile = ["dep:binoc-shapefile"]
52+
binformats = ["dep:binoc-binformats"]
53+
stat-binary = ["dep:binoc-stat-binary"]
54+
row-reorder = ["dep:binoc-row-reorder"]
55+
# The default fat-binoc bundle. SQLite is intentionally excluded (its bundled
56+
# rusqlite C build is paused for now); enable `sqlite` explicitly if needed.
57+
# See docs/adr/2026-06-30-fat_binoc_distribution_and_abi_canary.md.
58+
bundled = [
59+
"excel",
60+
"parquet",
61+
"avro",
62+
"dbf",
63+
"xml",
64+
"shapefile",
65+
"binformats",
66+
"stat-binary",
67+
"row-reorder",
68+
]
3769

3870
[dev-dependencies]
3971
assert_cmd = "2.2.2"

binoc-cli/src/lib.rs

Lines changed: 30 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -307,18 +307,42 @@ fn diff_controller(config: &DatasetConfig) -> Controller {
307307
fn correspondence_engine_config(config: &DatasetConfig) -> CorrespondenceEngineConfig {
308308
let mut engine =
309309
binoc_stdlib::correspondence::engine_config_for_dataset_config(&config.dataset);
310-
register_optional_correspondence_rules(&mut engine);
310+
register_bundled_correspondence_rules(&mut engine);
311311
engine
312312
}
313313

314-
#[cfg(feature = "sqlite")]
315-
fn register_optional_correspondence_rules(config: &mut CorrespondenceEngineConfig) {
314+
/// Register the first-party format packs that were compiled in via cargo
315+
/// features (the fat-binoc bundle). Shared by the standalone CLI and the Python
316+
/// host (`binoc-python`) so both honour the same in-process registration seam
317+
/// rather than any privileged shortcut.
318+
///
319+
/// See docs/adr/2026-06-30-fat_binoc_distribution_and_abi_canary.md.
320+
pub fn register_bundled_correspondence_rules(config: &mut CorrespondenceEngineConfig) {
321+
// Keep `config` "used" even when no format features are enabled (e.g. the
322+
// lean standalone CLI build with `default = []`).
323+
let _ = &mut *config;
324+
#[cfg(feature = "sqlite")]
316325
binoc_sqlite::register_correspondence_rules(config);
326+
#[cfg(feature = "excel")]
327+
binoc_excel::register_correspondence_rules(config);
328+
#[cfg(feature = "parquet")]
329+
binoc_parquet::register_correspondence_rules(config);
330+
#[cfg(feature = "avro")]
331+
binoc_avro::register_correspondence_rules(config);
332+
#[cfg(feature = "dbf")]
333+
binoc_dbf::register_correspondence_rules(config);
334+
#[cfg(feature = "xml")]
335+
binoc_xml::register_correspondence_rules(config);
336+
#[cfg(feature = "shapefile")]
337+
binoc_shapefile::register_correspondence_rules(config);
338+
#[cfg(feature = "binformats")]
339+
binoc_binformats::register_correspondence_rules(config);
340+
#[cfg(feature = "stat-binary")]
341+
binoc_stat_binary::register_correspondence_rules(config);
342+
#[cfg(feature = "row-reorder")]
343+
binoc_row_reorder::register_correspondence_rules(config);
317344
}
318345

319-
#[cfg(not(feature = "sqlite"))]
320-
fn register_optional_correspondence_rules(_config: &mut CorrespondenceEngineConfig) {}
321-
322346
/// Return the underlying `clap::Command` tree so external tooling (e.g. the
323347
/// `emit-cli-markdown` binary that regenerates `docs/users/reference/cli.md`) can
324348
/// walk it without depending on private types.

binoc-python/Cargo.toml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,3 +22,10 @@ binoc-cli = { workspace = true }
2222
libloading = "0.9.0"
2323
pyo3 = { workspace = true }
2424
serde_json = { workspace = true }
25+
26+
[features]
27+
# Re-export the fat bundle so a slim wheel can be built with
28+
# `--no-default-features`. See
29+
# docs/adr/2026-06-30-fat_binoc_distribution_and_abi_canary.md.
30+
default = ["bundled"]
31+
bundled = ["binoc-cli/bundled"]

binoc-python/src/lib.rs

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1080,12 +1080,14 @@ fn build_controller(
10801080
config: &PyConfig,
10811081
_registry: Option<&PyPluginRegistry>,
10821082
) -> PyResult<Controller> {
1083-
Ok(Controller::new(
1084-
binoc_stdlib::correspondence::engine_config_for_dataset_config(
1085-
&config.dataset_config.dataset,
1086-
),
1087-
)
1088-
.with_dataset_config(config.dataset_config.dataset.clone()))
1083+
let mut engine = binoc_stdlib::correspondence::engine_config_for_dataset_config(
1084+
&config.dataset_config.dataset,
1085+
);
1086+
// Register the fat-binoc format bundle through the same in-process seam the
1087+
// CLI uses, so `diff`/`extract` and the `binoc` CLI are equally fat.
1088+
// See docs/adr/2026-06-30-fat_binoc_distribution_and_abi_canary.md.
1089+
binoc_cli::register_bundled_correspondence_rules(&mut engine);
1090+
Ok(Controller::new(engine).with_dataset_config(config.dataset_config.dataset.clone()))
10891091
}
10901092

10911093
/// Diff two snapshots and return the resulting :class:`Changeset`.

0 commit comments

Comments
 (0)