diff --git a/README.md b/README.md index 9e58f8d..cc4c64d 100644 --- a/README.md +++ b/README.md @@ -133,6 +133,40 @@ The build script resolves the C library in this order: Set `ZVEC_AUTO_BUILD=0` to disable steps 5 and 6. +### Strict External Library Mode + +`ZVEC_STRICT_LIB=1` requires the native library to come from an explicitly +configured `ZVEC_LIB_DIR` and disables every substitute: + +1. `ZVEC_LIB_DIR` must be set and must contain the platform library: + `libzvec_c_api.so` on Linux, `libzvec_c_api.dylib` on macOS/iOS, and the + import library (`zvec_c_api.lib` for MSVC, or one of `libzvec_c_api.dll.a`, + the import library, or the DLL for MinGW) on Windows. +2. Steps 2–6 above are skipped: no sibling checkout, no submodule, no vendor + directory, no cached or downloaded prebuilt SDK, and no source build. +3. A missing, invalid or incomplete `ZVEC_LIB_DIR` fails the build script with + the expected file name and target instead of falling back to another copy. + +Use it when a build must link exactly the library the environment provides — +reproducible packaging, auditing, or offline builds — and a silent fallback to a +different copy would be a defect. Because the failure is raised when the build +script runs, `cargo check` fails as well while strict mode is on and the library +is absent. + +The mode is opt-in, and only `1` or the word `true` enable it; `true` is matched +case-insensitively (`TRUE`, `True`), because those are plainly the same word, and +every other value — including an empty one and the `yes`/`on` that other +toolchains accept — keeps the resolution order and behavior above unchanged. +`DOCS_RS` builds still skip native library resolution entirely, because they do +not link. + +Strict mode constrains the library, not the include path: +`ZVEC_INCLUDE_DIR` resolution is unchanged, so a strict build can still take its +headers from a sibling checkout or the submodule. That decides what downstream +build scripts see rather than what the linker uses, which is why it is not part +of this mode — set `ZVEC_INCLUDE_DIR` explicitly when the headers have to be +pinned to the same directory. + ### Prebuilt Downloads and Failure Handling Prebuilt SDKs remain cached at `$OUT_DIR/zvec-prebuilt`, under Cargo's `target` diff --git a/zvec-sys/README.md b/zvec-sys/README.md index 14923f8..33cf55c 100644 --- a/zvec-sys/README.md +++ b/zvec-sys/README.md @@ -25,6 +25,12 @@ This crate requires the zvec C library (`libzvec_c_api`) to be available at link 3. **Vendor directory**: `vendor/lib` 4. **Auto-build** (experimental): Clones and builds zvec from source +Set `ZVEC_STRICT_LIB=1` to accept step 1 only: the library must then come from a +`ZVEC_LIB_DIR` that contains the platform library, and the build script fails +with the expected file name instead of falling back to steps 2–4, a cached or +downloaded prebuilt SDK, or a source build. See the +[main README](../README.md) for details. + See the [main README](../README.md) for detailed setup instructions. ## Usage diff --git a/zvec-sys/build.rs b/zvec-sys/build.rs index bc372c3..dc6d9b9 100644 --- a/zvec-sys/build.rs +++ b/zvec-sys/build.rs @@ -11,6 +11,7 @@ fn main() { "ZVEC_LIB_DIR", "ZVEC_INCLUDE_DIR", "ZVEC_AUTO_BUILD", + "ZVEC_STRICT_LIB", "ZVEC_REPO_URL", "ZVEC_PREBUILT_URL", "DOCS_RS", @@ -49,6 +50,15 @@ fn main() { &auto_build_dir, ); if lib_dir.is_none() { + // A strict build accepts no substitute, so the explicit configuration + // was the only thing that could have resolved: report what is missing + // instead of letting the acquisition settings decide the outcome. + if strict_lib_dir_required() { + panic!( + "{}", + strict_lib_failure_message(env::var_os("ZVEC_LIB_DIR").map(PathBuf::from)) + ); + } // Only fail the build when the script was allowed to acquire the // library itself and every method failed. With acquisition disabled the // caller is expected to supply the library (for example a system @@ -106,17 +116,30 @@ fn resolve_lib_dir( prebuilt_cache_dir: &Path, auto_build_dir: &Path, ) -> Option { + let strict = strict_lib_dir_required(); + // 1. Environment variable (highest priority — for advanced users) if let Some(custom) = env::var_os("ZVEC_LIB_DIR") { let path = PathBuf::from(&custom); if has_zvec_lib(&path) { return Some(path); } - println!( - "cargo:warning=ZVEC_LIB_DIR={} does not contain the required native library; \ - continuing library resolution.", - path.display() - ); + if !strict { + println!( + "cargo:warning=ZVEC_LIB_DIR={} does not contain the required native library; \ + continuing library resolution.", + path.display() + ); + } + } + + // Everything below substitutes another copy of the native library for the + // configured one: a sibling checkout, the submodule, the vendor directory, + // a cached or downloaded prebuilt SDK, or a source build. A strict build + // forbids exactly that, so it stops here and leaves the reporting to the + // caller, which knows whether the configuration was explicit. + if strict { + return None; } // 2. Sibling zvec checkout @@ -165,6 +188,41 @@ fn acquisition_enabled() -> bool { cfg!(feature = "bundled") && env::var("ZVEC_AUTO_BUILD").unwrap_or_default() != "0" } +/// Whether the build script must use the explicitly configured native library +/// and nothing else. +/// +/// Opt in with `ZVEC_STRICT_LIB=1` (`true` is accepted as well). Every other +/// value, including an empty one, leaves the resolution order unchanged, so the +/// mode cannot be enabled by accident. +fn strict_lib_dir_required() -> bool { + let value = env::var("ZVEC_STRICT_LIB").unwrap_or_default(); + value == "1" || value.eq_ignore_ascii_case("true") +} + +/// Explains why a strict build could not resolve the configured native +/// library. +fn strict_lib_failure_message(configured: Option) -> String { + let target = env::var("TARGET").unwrap_or_default(); + let expected = describe_expected_lib_file_names(); + let no_fallback = "A strict build never falls back to a sibling zvec/ checkout, the \ + submodule, the vendor directory, a cached or downloaded prebuilt SDK, or \ + a source build."; + match configured { + Some(dir) => format!( + "ZVEC_STRICT_LIB is set, but ZVEC_LIB_DIR={} does not contain the native library for \ + {target}: expected {expected}. {no_fallback} Point ZVEC_LIB_DIR at a directory that \ + contains {expected}, or unset ZVEC_STRICT_LIB to use the normal resolution order.", + dir.display() + ), + None => format!( + "ZVEC_STRICT_LIB is set, but ZVEC_LIB_DIR is not set. The native library for {target} \ + must be configured explicitly, in a directory that contains {expected}. \ + {no_fallback} Set ZVEC_LIB_DIR, or unset ZVEC_STRICT_LIB to use the normal \ + resolution order." + ), + } +} + fn resolve_include_dir( sibling_zvec: &Option, submodule_zvec: &Path, @@ -201,27 +259,43 @@ fn resolve_include_dir( None } -fn has_zvec_lib(dir: &Path) -> bool { +/// Names of the native library files the linker can use for the current +/// target. +fn expected_lib_file_names() -> &'static [&'static str] { let target_os = env::var("CARGO_CFG_TARGET_OS").unwrap_or_default(); match target_os.as_str() { - "macos" | "ios" => dir.join("libzvec_c_api.dylib").is_file(), + "macos" | "ios" => &["libzvec_c_api.dylib"], "windows" => { // 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").is_file() + &["zvec_c_api.lib"] } else { - dir.join("libzvec_c_api.dll.a").is_file() - || dir.join("zvec_c_api.lib").is_file() - || dir.join("zvec_c_api.dll").is_file() + &["libzvec_c_api.dll.a", "zvec_c_api.lib", "zvec_c_api.dll"] } } - _ => dir.join("libzvec_c_api.so").is_file(), + _ => &["libzvec_c_api.so"], } } +/// The expected library names as a phrase the linker's rule matches: one name +/// where the platform accepts one artifact, and "one of a, b, c" where it +/// accepts several, so the message cannot read as if all of them are required. +fn describe_expected_lib_file_names() -> String { + match expected_lib_file_names() { + [only] => (*only).to_owned(), + several => format!("one of {}", several.join(", ")), + } +} + +fn has_zvec_lib(dir: &Path) -> bool { + expected_lib_file_names() + .iter() + .any(|name| dir.join(name).is_file()) +} + fn auto_build_zvec(build_dir: &Path) -> Option { let zvec_src = build_dir.join("zvec"); diff --git a/zvec-sys/tests/build_script.rs b/zvec-sys/tests/build_script.rs index 7cb137c..3a2f76a 100644 --- a/zvec-sys/tests/build_script.rs +++ b/zvec-sys/tests/build_script.rs @@ -18,12 +18,14 @@ mod build_script { "TARGET", "CARGO_PKG_VERSION", "CARGO_CFG_TARGET_OS", + "CARGO_CFG_TARGET_ENV", "CARGO_MANIFEST_DIR", "OUT_DIR", "ZVEC_PREBUILT_URL", "ZVEC_LIB_DIR", "ZVEC_INCLUDE_DIR", "ZVEC_AUTO_BUILD", + "ZVEC_STRICT_LIB", "ZVEC_REPO_URL", "DOCS_RS", "ZVEC_TEST_LOG", @@ -114,6 +116,23 @@ mod build_script { .map(|entry| entry.unwrap().path()) .collect() } + + /// A valid native library in the vendor directory the build script + /// resolves for this fixture, without being told about it. + fn install_vendor_library(&self) -> PathBuf { + let vendor_lib = self.root.join("workspace/vendor/lib"); + fs::create_dir_all(&vendor_lib).unwrap(); + fs::write(vendor_lib.join("libzvec_c_api.so"), "native library").unwrap(); + vendor_lib + } + + /// A valid native library directory to configure explicitly. + fn install_explicit_library(&self) -> PathBuf { + let lib_dir = self.root.join("native"); + fs::create_dir_all(&lib_dir).unwrap(); + fs::write(lib_dir.join("libzvec_c_api.so"), "native library").unwrap(); + lib_dir + } } impl Drop for Fixture { @@ -308,6 +327,225 @@ mod build_script { assert!(fixture.log().is_empty()); } + /// Text of a panic payload raised by the build script. + fn panic_message(payload: Box) -> String { + if let Some(message) = payload.downcast_ref::() { + message.clone() + } else if let Some(message) = payload.downcast_ref::<&str>() { + (*message).to_string() + } else { + String::new() + } + } + + #[test] + fn strict_mode_requires_an_explicit_library_directory() { + let fixture = Fixture::new(); + env::set_var("ZVEC_STRICT_LIB", "1"); + let vendor_lib = fixture.install_vendor_library(); + + let message = panic_message(std::panic::catch_unwind(main).unwrap_err()); + + assert!(message.contains("ZVEC_STRICT_LIB is set")); + assert!(message.contains("ZVEC_LIB_DIR is not set")); + assert!(message.contains("libzvec_c_api.so")); + // A substitute exists but was neither used nor touched, and nothing + // tried to download or build one. + assert!(!fixture.cache_dir().exists()); + assert!(fixture.log().is_empty()); + assert_eq!( + fs::read_to_string(vendor_lib.join("libzvec_c_api.so")).unwrap(), + "native library" + ); + assert_eq!( + resolve_lib_dir( + &None, + &fixture.root.join("submodule"), + &fixture.root.join("workspace/vendor"), + &fixture.cache_dir(), + &fixture.root.join("auto-build"), + ), + None + ); + } + + #[test] + fn strict_mode_rejects_an_incomplete_directory_and_never_falls_back() { + let fixture = Fixture::new(); + env::set_var("ZVEC_STRICT_LIB", "1"); + let vendor_lib = fixture.install_vendor_library(); + let incomplete = fixture.root.join("incomplete"); + fs::create_dir_all(&incomplete).unwrap(); + fs::write(incomplete.join("libzvec_c_api.so.tmp"), "partial").unwrap(); + env::set_var("ZVEC_LIB_DIR", &incomplete); + + let message = panic_message(std::panic::catch_unwind(main).unwrap_err()); + + assert!(message.contains("ZVEC_STRICT_LIB is set")); + assert!(message.contains(&incomplete.display().to_string())); + assert!(!fixture.cache_dir().exists()); + assert!(fixture.log().is_empty()); + assert!(vendor_lib.join("libzvec_c_api.so").is_file()); + } + + #[test] + fn strict_mode_accepts_an_explicit_library_directory() { + let fixture = Fixture::new(); + env::set_var("ZVEC_STRICT_LIB", "1"); + let lib_dir = fixture.install_explicit_library(); + env::set_var("ZVEC_LIB_DIR", &lib_dir); + + main(); + + assert_eq!( + resolve_lib_dir( + &None, + &fixture.root.join("submodule"), + &fixture.root.join("workspace/vendor"), + &fixture.cache_dir(), + &fixture.root.join("auto-build"), + ), + Some(lib_dir) + ); + assert!(!fixture.cache_dir().exists()); + assert!(fixture.log().is_empty()); + } + + #[test] + fn strict_mode_stays_off_for_every_other_value() { + let fixture = Fixture::new(); + let vendor_lib = fixture.install_vendor_library(); + let incomplete = fixture.root.join("incomplete"); + fs::create_dir_all(&incomplete).unwrap(); + env::set_var("ZVEC_LIB_DIR", &incomplete); + + for value in ["", "0", "false", "no"] { + env::set_var("ZVEC_STRICT_LIB", value); + assert!( + !strict_lib_dir_required(), + "value {value:?} must not enable strict mode" + ); + main(); + assert_eq!( + resolve_lib_dir( + &None, + &fixture.root.join("submodule"), + &fixture.root.join("workspace/vendor"), + &fixture.cache_dir(), + &fixture.root.join("auto-build"), + ), + Some(vendor_lib.clone()) + ); + } + assert!(fixture.log().is_empty()); + } + + #[test] + fn strict_is_enabled_by_one_or_the_word_true_however_it_is_cased() { + // Held for its environment isolation and restored on drop; the + // assertions below read process environment, not fixture state. + let _fixture = Fixture::new(); + + for value in ["1", "true", "TRUE", "True"] { + env::set_var("ZVEC_STRICT_LIB", value); + assert!( + strict_lib_dir_required(), + "value {value:?} must enable strict mode" + ); + } + // Values that read as "yes" in some other toolchains stay off, so the + // word this contract accepts is exactly `true`. + for value in ["yes", "on", "2", " true"] { + env::set_var("ZVEC_STRICT_LIB", value); + assert!( + !strict_lib_dir_required(), + "value {value:?} must not enable strict mode" + ); + } + } + + #[test] + fn the_expected_names_read_as_one_of_where_several_are_valid() { + // Held for its environment isolation and restored on drop. + let _fixture = Fixture::new(); + + env::set_var("CARGO_CFG_TARGET_OS", "linux"); + assert_eq!(describe_expected_lib_file_names(), "libzvec_c_api.so"); + + env::set_var("CARGO_CFG_TARGET_OS", "windows"); + env::set_var("CARGO_CFG_TARGET_ENV", "gnu"); + assert_eq!( + describe_expected_lib_file_names(), + "one of libzvec_c_api.dll.a, zvec_c_api.lib, zvec_c_api.dll" + ); + let message = strict_lib_failure_message(None); + assert!( + message.contains("one of libzvec_c_api.dll.a, zvec_c_api.lib, zvec_c_api.dll"), + "the message must not read as if every name were required: {message}" + ); + + // MSVC accepts exactly one artifact, so it reads as a plain name. + env::set_var("CARGO_CFG_TARGET_ENV", "msvc"); + assert_eq!(describe_expected_lib_file_names(), "zvec_c_api.lib"); + } + + #[test] + fn strict_mode_never_reaches_acquisition() { + let fixture = Fixture::new(); + env::set_var("ZVEC_STRICT_LIB", "1"); + // Acquisition is enabled and both a working download path and a + // substitute that would otherwise resolve are in place; the clone + // URL keeps a wrong implementation offline instead of fetching a + // real repository. + env::set_var("ZVEC_AUTO_BUILD", "1"); + env::set_var("ZVEC_REPO_URL", "/nonexistent/zvec.git"); + fixture.successful_download("curl", "-o"); + fixture.successful_extraction(); + fixture.install_vendor_library(); + + let message = panic_message(std::panic::catch_unwind(main).unwrap_err()); + + assert!(message.contains("ZVEC_STRICT_LIB is set")); + assert!(!message.contains("every acquisition method failed")); + // No download tool ran, nothing was published, and no source + // checkout was created. + assert!(fixture.log().is_empty()); + assert!(!fixture.cache_dir().exists()); + assert!(!fixture.root.join("out/zvec-build/zvec").exists()); + } + + #[test] + fn platform_library_names_are_honoured() { + let fixture = Fixture::new(); + let lib_dir = fixture.root.join("native"); + fs::create_dir(&lib_dir).unwrap(); + + // Linux, the fixture default. + assert_eq!(expected_lib_file_names(), &["libzvec_c_api.so"]); + + env::set_var("CARGO_CFG_TARGET_OS", "macos"); + assert_eq!(expected_lib_file_names(), &["libzvec_c_api.dylib"]); + assert!(!has_zvec_lib(&lib_dir)); + fs::write(lib_dir.join("libzvec_c_api.dylib"), "native library").unwrap(); + assert!(has_zvec_lib(&lib_dir)); + + env::set_var("CARGO_CFG_TARGET_OS", "windows"); + env::set_var("CARGO_CFG_TARGET_ENV", "msvc"); + assert_eq!(expected_lib_file_names(), &["zvec_c_api.lib"]); + assert!(!has_zvec_lib(&lib_dir)); + + env::set_var("CARGO_CFG_TARGET_ENV", "gnu"); + assert_eq!( + expected_lib_file_names(), + &["libzvec_c_api.dll.a", "zvec_c_api.lib", "zvec_c_api.dll"] + ); + fs::write(lib_dir.join("zvec_c_api.dll"), "native library").unwrap(); + assert!(has_zvec_lib(&lib_dir)); + + env::set_var("CARGO_CFG_TARGET_OS", "linux"); + assert_eq!(expected_lib_file_names(), &["libzvec_c_api.so"]); + } + #[test] fn docs_rs_skips_native_library_acquisition() { let fixture = Fixture::new();