diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a1ca4ab..2c9dc1f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -218,6 +218,22 @@ jobs: run: cargo run --example basic shell: bash + - name: Downstream rpath smoke (self-contained binary) + # Build a downstream binary that wires zvec-rust-build from its build.rs, + # then execute it directly with every dylib search-path variable + # cleared. This fails unless the emitted rpath (@executable_path / + # $ORIGIN) plus the staged shared library make the executable + # self-contained — the case plain `cargo run` cannot prove, because + # Cargo injects the library directory into its own child's environment. + run: | + cargo build -p zvec-rpath-smoke + BIN="target/debug/zvec-rpath-smoke" + if [ "${{ runner.os }}" = "Windows" ]; then + BIN="target/debug/zvec-rpath-smoke.exe" + fi + env -u DYLD_LIBRARY_PATH -u DYLD_FALLBACK_LIBRARY_PATH -u LD_LIBRARY_PATH "$BIN" + shell: bash + audit: name: Security Audit needs: check-and-test diff --git a/.github/workflows/publish-crates.yml b/.github/workflows/publish-crates.yml index acf27c0..10eea0f 100644 --- a/.github/workflows/publish-crates.yml +++ b/.github/workflows/publish-crates.yml @@ -38,17 +38,31 @@ jobs: SYS_VERSION=$(extract_version zvec-sys/Cargo.toml) SDK_VERSION=$(extract_version zvec/Cargo.toml) + BUILD_VERSION=$(extract_version zvec-build/Cargo.toml) - if [ "$SYS_VERSION" != "$VERSION" ] || [ "$SDK_VERSION" != "$VERSION" ]; then + if [ "$SYS_VERSION" != "$VERSION" ] || [ "$SDK_VERSION" != "$VERSION" ] || [ "$BUILD_VERSION" != "$VERSION" ]; then echo "Version mismatch!" echo " Tag: $VERSION" echo " zvec-rust-sys: $SYS_VERSION" echo " zvec-rust: $SDK_VERSION" + echo " zvec-rust-build: $BUILD_VERSION" exit 1 fi echo "All versions match: $VERSION" shell: bash + - name: Dry-run publish zvec-rust-build + # Pure build-script helper with no dependencies and no link to the C + # library, so it can be published first and independently. + run: cargo publish -p zvec-rust-build --dry-run + shell: bash + + - name: Publish zvec-rust-build to crates.io + env: + CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} + run: cargo publish -p zvec-rust-build + shell: bash + - name: Dry-run publish zvec-rust-sys run: cargo publish -p zvec-rust-sys --dry-run shell: bash diff --git a/Cargo.lock b/Cargo.lock index ed19307..c1da9d0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -913,6 +913,14 @@ version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" +[[package]] +name = "zvec-rpath-smoke" +version = "0.7.1" +dependencies = [ + "zvec-rust", + "zvec-rust-build", +] + [[package]] name = "zvec-rust" version = "0.7.1" @@ -923,6 +931,10 @@ dependencies = [ "zvec-rust-sys", ] +[[package]] +name = "zvec-rust-build" +version = "0.7.1" + [[package]] name = "zvec-rust-sys" version = "0.7.1" diff --git a/Cargo.toml b/Cargo.toml index 81c87cb..66a2d5c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,5 @@ [workspace] -members = ["zvec-sys", "zvec"] +members = ["zvec-sys", "zvec", "zvec-build", "zvec-rpath-smoke"] exclude = ["fuzz"] resolver = "2" diff --git a/README.md b/README.md index 8a3b11e..c074147 100644 --- a/README.md +++ b/README.md @@ -54,13 +54,39 @@ The Rust SDK depends on the zvec C library (`libzvec_c_api`). Choose one of the ### Option 1: Bundled Prebuilt Library (Zero Setup) -Add `zvec-rust` to your `Cargo.toml`. The default `bundled` feature automatically downloads the prebuilt `libzvec_c_api` for your platform from [GitHub Releases](https://github.com/zvec-ai/zvec-rust/releases) and sets up the library path via `rpath`: +Add `zvec-rust` to your `Cargo.toml`. The default `bundled` feature automatically downloads the prebuilt `libzvec_c_api` for your platform from [GitHub Releases](https://github.com/zvec-ai/zvec-rust/releases): ```toml [dependencies] zvec-rust = "0.7.1" ``` +`cargo run` / `cargo test` work out of the box because Cargo passes the +resolved library directory to the linker for you. A **directly executed** or +**deployed** binary, however, needs a runtime search path (`rpath`) pointing at +the shared library — Cargo does not add one automatically. Use the +[`zvec-rust-build`](https://crates.io/crates/zvec-rust-build) build-script +helper from your binary crate to emit the `rpath` and stage the library beside +the executable: + +```toml +[dependencies] +zvec-rust = "0.7.1" + +[build-dependencies] +zvec-rust-build = "0.7.1" +``` + +```rust +// build.rs +fn main() { + zvec_rust_build::configure(); +} +``` + +Without the helper you must instead set `DYLD_LIBRARY_PATH` (macOS) / +`LD_LIBRARY_PATH` (Linux) at runtime. + ### Option 2: Custom Build If you want to build the zvec C library yourself (e.g., for a custom configuration or unsupported platform), set the `ZVEC_LIB_DIR` environment variable: diff --git a/README_CN.md b/README_CN.md index d47d26a..0848022 100644 --- a/README_CN.md +++ b/README_CN.md @@ -54,13 +54,36 @@ Rust SDK 依赖 zvec C 库(`libzvec_c_api`)。可通过以下任一方式提 ### 方案一: bundled 预编译库(零配置) -在 `Cargo.toml` 中添加 `zvec-rust`。默认启用的 `bundled` feature 会自动从 [GitHub Releases](https://github.com/zvec-ai/zvec-rust/releases) 下载适合你平台的预编译 `libzvec_c_api`,并通过 `rpath` 设置库路径: +在 `Cargo.toml` 中添加 `zvec-rust`。默认启用的 `bundled` feature 会自动从 [GitHub Releases](https://github.com/zvec-ai/zvec-rust/releases) 下载适合你平台的预编译 `libzvec_c_api`: ```toml [dependencies] zvec-rust = "0.7.1" ``` +`cargo run` / `cargo test` 可开箱即用,因为 Cargo 会把解析出的库目录传给链接器。 +但**直接执行**或**部署后**的二进制需要一条指向共享库的运行时搜索路径(`rpath`), +而 Cargo 不会自动添加。请在你的二进制 crate 中使用 +[`zvec-rust-build`](https://crates.io/crates/zvec-rust-build) 构建脚本助手, +它会生成 `rpath` 并把共享库暂存到可执行文件旁边: + +```toml +[dependencies] +zvec-rust = "0.7.1" + +[build-dependencies] +zvec-rust-build = "0.7.1" +``` + +```rust +// build.rs +fn main() { + zvec_rust_build::configure(); +} +``` + +若不使用该助手,则需在运行时设置 `DYLD_LIBRARY_PATH`(macOS)/ `LD_LIBRARY_PATH`(Linux)。 + ### 方案二:自行编译 如果你需要自行编译 zvec C 库(例如自定义配置或不支持的平台),设置 `ZVEC_LIB_DIR` 环境变量: diff --git a/zvec-build/Cargo.toml b/zvec-build/Cargo.toml new file mode 100644 index 0000000..b25c657 --- /dev/null +++ b/zvec-build/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "zvec-rust-build" +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true +rust-version.workspace = true +description = "Build-script helper for downstream binaries linking against zvec-rust: emits the runtime rpath and stages the shared library." +readme = "README.md" +keywords = ["build-dependencies", "rpath", "zvec", "ffi", "bindings"] +categories = ["development-tools::build-utils"] diff --git a/zvec-build/README.md b/zvec-build/README.md new file mode 100644 index 0000000..071686b --- /dev/null +++ b/zvec-build/README.md @@ -0,0 +1,42 @@ +# zvec-rust-build + +Build-script helper for downstream **binary** crates that link against +[`zvec-rust`](https://crates.io/crates/zvec-rust). + +`zvec-rust` loads the `libzvec_c_api` shared library at runtime. Cargo does not +configure a runtime search path (`rpath`) on the final executable by itself, so +without help the binary can only find the library through `DYLD_LIBRARY_PATH` +(macOS) or `LD_LIBRARY_PATH` (Linux). + +Add this crate as a build dependency and call it from your `build.rs`: + +```toml +[build-dependencies] +zvec-rust-build = "0.7" +``` + +```rust +// build.rs +fn main() { + zvec_rust_build::configure(); +} +``` + +`configure()`: + +- Emits `rpath` linker arguments so the executable finds the shared library + both next to itself (development: `target//`) and in a sibling + `../lib` directory (deployment layout `bin/` + `lib/`). +- Stages the shared library beside the executable in `target//`, so + `./target//` runs with no environment variables set. + +On Windows there is no `rpath`; the loader searches the executable's directory, +so staging the DLL beside the executable is what makes development runs work, +and packaging must ship `zvec_c_api.dll` in the same directory as the +executable. + +## Packaging helpers + +`lib_dir()` returns the resolved library directory and +`copy_runtime_libs_to(dst)` stages the runtime shared library into an arbitrary +directory — useful for assembling a distribution tree. diff --git a/zvec-build/src/lib.rs b/zvec-build/src/lib.rs new file mode 100644 index 0000000..cde857a --- /dev/null +++ b/zvec-build/src/lib.rs @@ -0,0 +1,361 @@ +//! Build-script helper for downstream binaries that link against +//! [`zvec-rust`](https://crates.io/crates/zvec-rust). +//! +//! `zvec-rust` links against the `libzvec_c_api` shared library at runtime. +//! Cargo makes the resolved library directory available to a binary crate's +//! build script (as `DEP_ZVEC_RUST_LIB_DIR`), but it does **not** configure a +//! runtime search path on the final executable — a `cargo:rustc-link-arg` +//! emitted by the `-sys` crate only affects that crate's own targets, never a +//! downstream binary. Without help, the executable can only find the shared +//! library through `DYLD_LIBRARY_PATH` / `LD_LIBRARY_PATH`. +//! +//! Call [`configure`] from the binary crate's `build.rs`: +//! +//! ```no_run +//! // build.rs +//! zvec_rust_build::configure(); +//! ``` +//! +//! It performs two platform-aware steps: +//! 1. Emits `rpath` linker arguments so the built artifacts find the shared +//! library both in their own directory (development: `target//`) +//! and in a sibling `../lib` directory (the recommended deployment layout of +//! `bin/` + `lib/`). The flags are emitted with +//! `cargo:rustc-link-arg`, which applies to every linked target kind of the +//! calling package — binaries, integration tests, examples, and benches — so +//! `cargo test` also runs without any dylib search-path environment vars. +//! 2. Stages the shared library (and, when present, the `data/jieba_dict` +//! directory the FTS `jieba` tokenizer discovers relative to the loaded +//! library) next to the built artifacts in `target//` so +//! `./target//` runs with no environment variables set. +//! +//! On Windows there is no rpath; the loader searches the executable's own +//! directory, so step 2 (staging the DLL beside the executable) is what makes +//! development runs work, and packaging must ship the DLL in the same +//! directory as the executable. + +use std::env; +use std::fs; +use std::io; +use std::path::{Path, PathBuf}; + +/// Target operating system, as reported to the build script by Cargo. +fn target_os() -> String { + env::var("CARGO_CFG_TARGET_OS").unwrap_or_default() +} + +/// Resolves the directory that contains the `libzvec_c_api` shared library. +/// +/// Resolution order: +/// 1. `DEP_ZVEC_RUST_LIB_DIR` — forwarded by `zvec-rust` from the `-sys` +/// crate's build script. Present automatically for any crate that depends +/// directly on `zvec-rust`. +/// 2. `ZVEC_LIB_DIR` — explicit override for advanced/offline setups. +/// +/// Returns `None` when neither is available (for example when the metadata +/// channel is unavailable); callers should treat this as best-effort. +/// +/// Caveat: when cross-compiling, the `ZVEC_LIB_DIR` fallback is not +/// target-aware — it points at whatever directory the caller exported, which +/// may hold a host-architecture library. Prefer the `DEP_ZVEC_RUST_LIB_DIR` +/// channel (resolved per-target by `zvec-rust-sys`) for cross builds. +#[must_use] +pub fn lib_dir() -> Option { + for key in ["DEP_ZVEC_RUST_LIB_DIR", "ZVEC_LIB_DIR"] { + if let Ok(value) = env::var(key) { + if !value.is_empty() { + let path = PathBuf::from(value); + if path.is_dir() { + return Some(path); + } + } + } + } + None +} + +/// File names of the runtime shared library for the given target, in the order +/// they should be searched. Import libraries (`.lib`, `.dll.a`) are excluded: +/// only files needed at *runtime* are listed. +#[must_use] +pub fn runtime_lib_file_names(os: &str) -> &'static [&'static str] { + match os { + "macos" | "ios" => &["libzvec_c_api.dylib"], + "windows" => &["zvec_c_api.dll"], + _ => &["libzvec_c_api.so"], + } +} + +/// Relative path (from the shared library's directory) of the cppjieba dict +/// directory that the FTS `jieba` tokenizer discovers at runtime, and the two +/// files it must contain. +const JIEBA_DICT_SUBDIR: [&str; 2] = ["data", "jieba_dict"]; +const JIEBA_DICT_FILES: [&str; 2] = ["jieba.dict.utf8", "hmm_model.utf8"]; + +/// Copies the runtime assets from the resolved [`lib_dir`] into `dst_dir`, +/// creating directories as needed. Returns the destination paths that were +/// written. A no-op returning an empty vec when the library cannot be located. +/// +/// Staged assets: +/// - the runtime shared library ([`runtime_lib_file_names`]); +/// - the `data/jieba_dict` directory, when present next to the library, so the +/// FTS `jieba` tokenizer's runtime discovery (`/data/jieba_dict`, +/// located via `dladdr` / `GetModuleHandleEx`) also succeeds in a +/// self-contained deployment. +/// +/// # Errors +/// Returns any I/O error from creating a directory or copying a file. +pub fn copy_runtime_libs_to(dst_dir: &Path) -> io::Result> { + let Some(source_dir) = lib_dir() else { + return Ok(Vec::new()); + }; + let mut copied = Vec::new(); + for name in runtime_lib_file_names(&target_os()) { + let source = source_dir.join(name); + if source.is_file() { + fs::create_dir_all(dst_dir)?; + let destination = dst_dir.join(name); + stage_file(&source, &destination)?; + copied.push(destination); + } + } + copied.extend(copy_jieba_dict(&source_dir, dst_dir)?); + Ok(copied) +} + +/// Copies `source` onto `destination` unless they are the same filesystem +/// object. `fs::copy` opens the destination for truncating write *before* +/// reading the source, so copying a file onto itself truncates it to 0 bytes +/// (reproduced on macOS). This happens when `ZVEC_LIB_DIR` already points at +/// the binary output directory. Canonicalizing both paths also collapses +/// symlinked layouts, so a symlink pointing back at the source is detected too. +fn stage_file(source: &Path, destination: &Path) -> io::Result<()> { + if is_same_file(source, destination) { + return Ok(()); + } + fs::copy(source, destination)?; + Ok(()) +} + +/// Whether `a` and `b` resolve to the same filesystem object, following +/// symlinks. Returns `false` if either path cannot be canonicalized — most +/// commonly a destination that does not exist yet. +fn is_same_file(a: &Path, b: &Path) -> bool { + match (fs::canonicalize(a), fs::canonicalize(b)) { + (Ok(a), Ok(b)) => a == b, + _ => false, + } +} + +/// Stages the `data/jieba_dict` directory from `source_dir` into `dst_dir`, +/// preserving the relative layout the runtime discovery expects. No-op (empty +/// vec) when the dict is not shipped alongside the library. +fn copy_jieba_dict(source_dir: &Path, dst_dir: &Path) -> io::Result> { + let subdir: PathBuf = JIEBA_DICT_SUBDIR.iter().collect(); + let dict_src = source_dir.join(&subdir); + if !JIEBA_DICT_FILES.iter().all(|f| dict_src.join(f).is_file()) { + return Ok(Vec::new()); + } + let dict_dst = dst_dir.join(&subdir); + fs::create_dir_all(&dict_dst)?; + let mut copied = Vec::new(); + for name in JIEBA_DICT_FILES { + let destination = dict_dst.join(name); + stage_file(&dict_src.join(name), &destination)?; + copied.push(destination); + } + Ok(copied) +} + +/// Directory that holds the crate's built binaries (`target//`, or +/// `target///` when cross-compiling), derived from `OUT_DIR`. +/// +/// `OUT_DIR` is `<...>//build/-/out`, so the binary +/// output directory is three levels up. +fn binary_output_dir() -> Option { + let out_dir = env::var_os("OUT_DIR")?; + binary_output_dir_from(Path::new(&out_dir)) +} + +/// Pure form of [`binary_output_dir`]: pops the three trailing components +/// (`build/-/out`) of an `OUT_DIR` path. +fn binary_output_dir_from(out_dir: &Path) -> Option { + let mut path = out_dir.to_path_buf(); + for _ in 0..3 { + if !path.pop() { + return None; + } + } + Some(path) +} + +fn emit_rpath(flag: &str) { + // Plain `rustc-link-arg` (not the `-bins` variant) so the rpath is applied + // to every linked target kind of the calling package — binaries, tests, + // examples, and benches — letting `cargo test` run without env vars too. + println!("cargo:rustc-link-arg=-Wl,-rpath,{flag}"); +} + +/// Configures the downstream binary so it finds `libzvec_c_api` at runtime with +/// no environment variables. Call from the binary crate's `build.rs`. +/// +/// See the [crate-level documentation](crate) for details. +pub fn configure() { + let os = target_os(); + + // 1. Runtime search paths (rpath). Unix only; Windows uses the executable + // directory and has no rpath concept. + match os.as_str() { + "macos" | "ios" => { + // The executable's own directory (development) and a sibling + // `../lib` (deployment: `bin/` + `lib/`). + emit_rpath("@executable_path"); + emit_rpath("@executable_path/../lib"); + emit_rpath("@loader_path"); + emit_rpath("@loader_path/../lib"); + } + "windows" => { + // No rpath; the loader searches the executable directory. + } + _ => { + // $ORIGIN is expanded by the dynamic loader, not the shell. Cargo + // execs the linker directly, so it is passed through literally. + emit_rpath("$ORIGIN"); + emit_rpath("$ORIGIN/../lib"); + } + } + + // In debug builds also record an absolute rpath to the resolved library + // directory, so a binary run in place still works even if the shared + // library was not staged. Release builds omit it to keep machine-specific + // paths out of shipped artifacts. + let is_release = env::var("PROFILE").as_deref() == Ok("release"); + if !is_release && os != "windows" { + if let Some(dir) = lib_dir() { + emit_rpath(&dir.display().to_string()); + } + } + + // 2. Stage the runtime assets next to the built artifacts so development + // runs (`./target//`) need no environment variables. Best + // effort: a failure here must not break the build (Cargo's link-search + // still lets `cargo run` work). + if let Some(bin_dir) = binary_output_dir() { + match copy_runtime_libs_to(&bin_dir) { + Ok(copied) if copied.is_empty() => { + println!( + "cargo:warning=zvec-rust-build: shared library not located; \ + `./{}` may require DYLD_LIBRARY_PATH/LD_LIBRARY_PATH", + runtime_lib_file_names(&os) + .first() + .copied() + .unwrap_or("libzvec_c_api") + ); + } + Ok(_) => {} + Err(error) => { + println!("cargo:warning=zvec-rust-build: failed to stage shared library: {error}"); + } + } + } + + println!("cargo:rerun-if-env-changed=ZVEC_LIB_DIR"); + // Emitting any `rerun-if-*` key opts the calling package out of Cargo's + // default "re-run the build script if any file in the package changed" + // policy. Declare the build script itself so edits still trigger a re-run. + println!("cargo:rerun-if-changed=build.rs"); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn runtime_lib_file_names_are_per_os() { + assert_eq!(runtime_lib_file_names("macos"), &["libzvec_c_api.dylib"]); + assert_eq!(runtime_lib_file_names("ios"), &["libzvec_c_api.dylib"]); + assert_eq!(runtime_lib_file_names("windows"), &["zvec_c_api.dll"]); + assert_eq!(runtime_lib_file_names("linux"), &["libzvec_c_api.so"]); + // Unknown targets fall back to the ELF/`.so` convention. + assert_eq!(runtime_lib_file_names("freebsd"), &["libzvec_c_api.so"]); + } + + #[test] + fn binary_output_dir_pops_build_crate_out() { + let out_dir = Path::new("/w/target/release/build/zg-abc123/out"); + assert_eq!( + binary_output_dir_from(out_dir), + Some(PathBuf::from("/w/target/release")), + ); + } + + #[test] + fn binary_output_dir_handles_cross_target_triple() { + let out_dir = Path::new("/w/target/aarch64-unknown-linux-gnu/debug/build/zg-abc/out"); + assert_eq!( + binary_output_dir_from(out_dir), + Some(PathBuf::from("/w/target/aarch64-unknown-linux-gnu/debug")), + ); + } + + #[test] + fn binary_output_dir_none_when_too_shallow() { + assert_eq!(binary_output_dir_from(Path::new("out")), None); + } + + fn unique_temp_dir(tag: &str) -> PathBuf { + use std::sync::atomic::{AtomicU32, Ordering}; + static COUNTER: AtomicU32 = AtomicU32::new(0); + let n = COUNTER.fetch_add(1, Ordering::Relaxed); + let dir = std::env::temp_dir().join(format!("zvec-build-{tag}-{}-{n}", std::process::id())); + fs::create_dir_all(&dir).expect("create temp dir"); + dir + } + + #[test] + fn stage_file_no_ops_on_identical_path_and_preserves_contents() { + let dir = unique_temp_dir("same"); + let file = dir.join("libzvec_c_api.so"); + fs::write(&file, b"payload").expect("write"); + // The core of the bug: copying a file onto itself must not truncate it. + stage_file(&file, &file).expect("stage_file same path"); + assert_eq!(fs::read(&file).expect("read"), b"payload"); + fs::remove_dir_all(&dir).ok(); + } + + #[test] + fn stage_file_copies_to_a_new_destination() { + let dir = unique_temp_dir("copy"); + let src = dir.join("src.so"); + let dst = dir.join("dst.so"); + fs::write(&src, b"payload").expect("write"); + stage_file(&src, &dst).expect("stage_file copy"); + assert_eq!(fs::read(&dst).expect("read"), b"payload"); + fs::remove_dir_all(&dir).ok(); + } + + #[test] + fn is_same_file_is_false_for_missing_destination() { + let dir = unique_temp_dir("missing"); + let src = dir.join("src.so"); + fs::write(&src, b"x").expect("write"); + assert!(is_same_file(&src, &src)); + assert!(!is_same_file(&src, &dir.join("nope.so"))); + fs::remove_dir_all(&dir).ok(); + } + + #[cfg(unix)] + #[test] + fn stage_file_detects_symlink_back_to_source() { + let dir = unique_temp_dir("symlink"); + let src = dir.join("real.so"); + fs::write(&src, b"payload").expect("write"); + let link = dir.join("link.so"); + std::os::unix::fs::symlink(&src, &link).expect("symlink"); + // Destination symlinks to the source: canonicalization must collapse + // them so the file is left untouched rather than truncated. + stage_file(&src, &link).expect("stage_file symlink"); + assert_eq!(fs::read(&src).expect("read"), b"payload"); + fs::remove_dir_all(&dir).ok(); + } +} diff --git a/zvec-rpath-smoke/Cargo.toml b/zvec-rpath-smoke/Cargo.toml new file mode 100644 index 0000000..264a0fb --- /dev/null +++ b/zvec-rpath-smoke/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "zvec-rpath-smoke" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +publish = false +description = "Internal smoke binary: proves a downstream executable finds libzvec_c_api at runtime with no dylib search-path env vars, using the zvec-rust-build helper." + +[dependencies] +zvec-rust = { path = "../zvec" } + +[build-dependencies] +zvec-rust-build = { path = "../zvec-build" } diff --git a/zvec-rpath-smoke/build.rs b/zvec-rpath-smoke/build.rs new file mode 100644 index 0000000..af5e917 --- /dev/null +++ b/zvec-rpath-smoke/build.rs @@ -0,0 +1,3 @@ +fn main() { + zvec_rust_build::configure(); +} diff --git a/zvec-rpath-smoke/src/main.rs b/zvec-rpath-smoke/src/main.rs new file mode 100644 index 0000000..175836e --- /dev/null +++ b/zvec-rpath-smoke/src/main.rs @@ -0,0 +1,16 @@ +//! Downstream smoke test for `zvec-rust-build`. +//! +//! Built and then executed directly (with every dylib search-path environment +//! variable cleared) by CI to prove that the rpath emitted by +//! `zvec_rust_build::configure()` and the staged shared library make the binary +//! self-contained. Loading the library is what exercises the runtime path: +//! `initialize` dereferences symbols from `libzvec_c_api`. + +fn main() -> zvec_rust::Result<()> { + zvec_rust::initialize(None)?; + let version = zvec_rust::version(); + assert!(!version.is_empty(), "zvec version string was empty"); + zvec_rust::shutdown()?; + println!("zvec-rpath-smoke ok: zvec {version}"); + Ok(()) +} diff --git a/zvec-sys/build.rs b/zvec-sys/build.rs index 2a429f2..0e9c121 100644 --- a/zvec-sys/build.rs +++ b/zvec-sys/build.rs @@ -34,10 +34,19 @@ fn main() { resolve_include_dir(&sibling_zvec, &submodule_zvec, &vendor_dir, &auto_build_dir); if let Some(ref dir) = lib_dir { + let dir = dir.canonicalize().unwrap_or_else(|_| dir.clone()); println!("cargo:rustc-link-search=native={}", dir.display()); if dir.exists() { println!("cargo:rerun-if-changed={}", dir.display()); } + // Publish the resolved library directory as `links` metadata. Cargo + // exposes it to *direct* dependents' build scripts as + // `DEP_ZVEC_C_API_LIB_DIR`. This is the only reliable way to make the + // path available further down the graph: a `cargo:rustc-link-arg` + // rpath emitted here would apply solely to this rlib's own targets and + // never reach a downstream executable. Downstream binaries must set + // their own rpath (see the `zvec-rust-build` helper crate). + println!("cargo:lib_dir={}", dir.display()); } if let Some(ref dir) = include_dir { println!("cargo:include={}", dir.display()); @@ -46,20 +55,6 @@ fn main() { } } - // Set rpath so the dynamic library can be found at runtime - let target_os = env::var("CARGO_CFG_TARGET_OS").unwrap_or_default(); - if let Some(ref dir) = lib_dir { - match target_os.as_str() { - "macos" => { - println!("cargo:rustc-link-arg=-Wl,-rpath,{}", dir.display()); - } - "linux" => { - println!("cargo:rustc-link-arg=-Wl,-rpath,{}", dir.display()); - } - _ => {} - } - } - // Discover the cppjieba dict directory (jieba.dict.utf8 + hmm_model.utf8) // near the resolved library so the high-level crate can auto-register the // default jieba dict dir for the `jieba` FTS tokenizer. @@ -180,9 +175,17 @@ fn has_zvec_lib(dir: &Path) -> bool { match target_os.as_str() { "macos" | "ios" => dir.join("libzvec_c_api.dylib").exists(), "windows" => { - // MSVC dynamic linking requires the .lib import library; - // the .dll alone is not enough for the linker. - dir.join("zvec_c_api.lib").exists() || dir.join("zvec_c_api.dll").exists() + // MSVC dynamic linking requires the .lib import library; the .dll + // alone is not enough for the linker. The GNU/MinGW toolchain can + // link directly against the .dll (or a .dll.a import lib). + let target_env = env::var("CARGO_CFG_TARGET_ENV").unwrap_or_default(); + if target_env == "msvc" { + dir.join("zvec_c_api.lib").exists() + } else { + dir.join("libzvec_c_api.dll.a").exists() + || dir.join("zvec_c_api.lib").exists() + || dir.join("zvec_c_api.dll").exists() + } } _ => dir.join("libzvec_c_api.so").exists(), } diff --git a/zvec/Cargo.toml b/zvec/Cargo.toml index e8d42b4..286872f 100644 --- a/zvec/Cargo.toml +++ b/zvec/Cargo.toml @@ -9,6 +9,7 @@ description = "Safe Rust bindings for the zvec vector database" readme = "../README.md" keywords = ["vector-database", "zvec", "similarity-search", "embeddings", "hnsw"] categories = ["database", "api-bindings"] +links = "zvec_rust" [dependencies] zvec-rust-sys = { path = "../zvec-sys", version = "0.7.1" } diff --git a/zvec/build.rs b/zvec/build.rs index 201df4a..24420bb 100644 --- a/zvec/build.rs +++ b/zvec/build.rs @@ -7,5 +7,17 @@ fn main() { if env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("linux") { println!("cargo:rustc-link-lib=dylib=dl"); } + + // Forward the native library directory resolved by `zvec-rust-sys` one hop + // further down the graph. `zvec-rust-sys` (links = "zvec_c_api") exposes it + // to this build script as `DEP_ZVEC_C_API_LIB_DIR`; by re-publishing it as + // our own `links = "zvec_rust"` metadata, crates that depend directly on + // `zvec-rust` receive it as `DEP_ZVEC_RUST_LIB_DIR`. This lets a downstream + // binary set the runtime rpath to the shared library it will ship (see the + // `zvec-rust-build` helper crate). + if let Ok(lib_dir) = env::var("DEP_ZVEC_C_API_LIB_DIR") { + println!("cargo:lib_dir={lib_dir}"); + } + println!("cargo:rerun-if-changed=build.rs"); }