fidius_test/dylib.rs
1// Copyright 2026 Colliery, Inc.
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7// http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15//! Build-and-cache helpers for plugin cdylib fixtures.
16//!
17//! Integration tests frequently need to invoke `cargo build` on a plugin
18//! crate, locate the produced `.dylib`/`.so`/`.dll`, and point a
19//! `PluginHost` at its containing directory. Doing this from scratch in
20//! every test is noisy and slow — each test re-builds the plugin even
21//! though the source hasn't changed.
22//!
23//! [`dylib_fixture`] returns a process-wide cached build result: the first
24//! call builds the plugin; subsequent calls in the same test binary return
25//! the existing path without re-running cargo. Fresh `cargo test`
26//! invocations still rebuild (on cache miss in cargo's own target dir).
27//!
28//! # Example
29//!
30//! ```ignore
31//! let fixture = dylib_fixture("./path/to/my-plugin").build();
32//! let host = PluginHost::builder()
33//! .search_path(fixture.dir())
34//! .build()?;
35//! ```
36
37use std::collections::HashMap;
38use std::path::{Path, PathBuf};
39use std::process::Command;
40use std::sync::{Mutex, OnceLock};
41
42use ed25519_dalek::SigningKey;
43
44use crate::signing::sign_dylib;
45
46/// Start building a dylib fixture for the plugin crate at `plugin_dir`.
47///
48/// `plugin_dir` must contain a `Cargo.toml` with `crate-type = ["cdylib"]`.
49/// The resulting fixture caches the build across the current test binary
50/// process; subsequent calls with the same `plugin_dir` return the cached
51/// fixture without re-running cargo.
52pub fn dylib_fixture(plugin_dir: impl Into<PathBuf>) -> DylibFixtureBuilder {
53 DylibFixtureBuilder {
54 plugin_dir: plugin_dir.into(),
55 release: !cfg!(debug_assertions),
56 signing_key: None,
57 }
58}
59
60/// Builder for [`DylibFixture`]. See [`dylib_fixture`].
61pub struct DylibFixtureBuilder {
62 plugin_dir: PathBuf,
63 release: bool,
64 signing_key: Option<SigningKey>,
65}
66
67impl DylibFixtureBuilder {
68 /// Build in release mode. Defaults to the test binary's own profile
69 /// (release if tests are built with `--release`, otherwise debug).
70 pub fn with_release(mut self, release: bool) -> Self {
71 self.release = release;
72 self
73 }
74
75 /// Sign the produced dylib with `key`, writing a `.sig` file alongside it.
76 ///
77 /// Only takes effect on the first (un-cached) build — subsequent cached
78 /// fixtures are returned unchanged. For tests that need re-signing,
79 /// re-sign via [`crate::signing::sign_dylib`] on the returned
80 /// [`DylibFixture::dylib_path`].
81 pub fn signed_with(mut self, key: &SigningKey) -> Self {
82 self.signing_key = Some(key.clone());
83 self
84 }
85
86 /// Execute the build (or return cached result) and produce the fixture.
87 ///
88 /// Panics on build failure — tests should not attempt recovery from a
89 /// plugin that won't compile.
90 pub fn build(self) -> DylibFixture {
91 let cache = cache();
92 let key = CacheKey {
93 plugin_dir: self.plugin_dir.clone(),
94 release: self.release,
95 };
96
97 {
98 let guard = cache.lock().expect("dylib fixture cache poisoned");
99 if let Some(existing) = guard.get(&key) {
100 // Cached. Signing was handled on first build; ignore new key.
101 return existing.clone();
102 }
103 }
104
105 let fixture = build_uncached(&self.plugin_dir, self.release);
106 if let Some(signing_key) = &self.signing_key {
107 sign_dylib(&fixture.dylib_path, signing_key)
108 .expect("sign_dylib failed for fixture plugin");
109 }
110
111 cache
112 .lock()
113 .expect("dylib fixture cache poisoned")
114 .insert(key, fixture.clone());
115 fixture
116 }
117}
118
119/// A built plugin ready to be loaded by a `PluginHost`.
120#[derive(Debug, Clone)]
121pub struct DylibFixture {
122 /// Directory containing the built dylib. Pass this to
123 /// `PluginHost::builder().search_path(...)`.
124 plugin_output_dir: PathBuf,
125 /// Full path to the built dylib file itself.
126 dylib_path: PathBuf,
127}
128
129impl DylibFixture {
130 /// Directory containing the built dylib — `search_path` for `PluginHost`.
131 pub fn dir(&self) -> &Path {
132 &self.plugin_output_dir
133 }
134
135 /// Full path to the dylib file itself. Use this to sign, inspect, or load
136 /// the dylib directly (e.g., `fidius_host::loader::load_library`).
137 pub fn dylib_path(&self) -> &Path {
138 &self.dylib_path
139 }
140}
141
142// ─── Internal ────────────────────────────────────────────────────────────────
143
144#[derive(Hash, PartialEq, Eq, Clone)]
145struct CacheKey {
146 plugin_dir: PathBuf,
147 release: bool,
148}
149
150fn cache() -> &'static Mutex<HashMap<CacheKey, DylibFixture>> {
151 static CACHE: OnceLock<Mutex<HashMap<CacheKey, DylibFixture>>> = OnceLock::new();
152 CACHE.get_or_init(|| Mutex::new(HashMap::new()))
153}
154
155fn dylib_extension() -> &'static str {
156 if cfg!(target_os = "macos") {
157 "dylib"
158 } else if cfg!(target_os = "windows") {
159 "dll"
160 } else {
161 "so"
162 }
163}
164
165fn build_uncached(plugin_dir: &Path, release: bool) -> DylibFixture {
166 let manifest = plugin_dir.join("Cargo.toml");
167 assert!(manifest.exists(), "no Cargo.toml at {}", manifest.display());
168
169 let mut cmd = Command::new("cargo");
170 cmd.arg("build").arg("--manifest-path").arg(&manifest);
171 if release {
172 cmd.arg("--release");
173 }
174
175 let output = cmd.output().expect("failed to spawn cargo build");
176 assert!(
177 output.status.success(),
178 "cargo build of {} failed:\n{}",
179 plugin_dir.display(),
180 String::from_utf8_lossy(&output.stderr),
181 );
182
183 let profile = if release { "release" } else { "debug" };
184 let plugin_output_dir = plugin_dir.join("target").join(profile);
185
186 // Find the dylib — first file with the right extension
187 let ext = dylib_extension();
188 let dylib_path = std::fs::read_dir(&plugin_output_dir)
189 .unwrap_or_else(|e| panic!("read_dir {}: {e}", plugin_output_dir.display()))
190 .filter_map(|e| e.ok())
191 .map(|e| e.path())
192 .find(|p| p.extension().and_then(|s| s.to_str()) == Some(ext))
193 .unwrap_or_else(|| {
194 panic!(
195 "build succeeded but no .{} file found in {}",
196 ext,
197 plugin_output_dir.display()
198 )
199 });
200
201 DylibFixture {
202 plugin_output_dir,
203 dylib_path,
204 }
205}