Skip to main content

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}