fresh/services/plugins/embedded.rs
1//! Embedded plugins support
2//!
3//! When the `embed-plugins` feature is enabled, this module provides access to plugins
4//! that are compiled directly into the binary. This is useful for cargo-binstall
5//! distributions where the plugins directory would otherwise be missing.
6//!
7//! The plugins are extracted to a temporary directory at runtime and loaded from there.
8
9use include_dir::{include_dir, Dir};
10use std::path::PathBuf;
11use std::sync::OnceLock;
12
13/// The plugins directory embedded at compile time
14static EMBEDDED_PLUGINS: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/plugins");
15
16/// Cached path to the extracted plugins directory
17static EXTRACTED_PLUGINS_DIR: OnceLock<PathBuf> = OnceLock::new();
18
19/// Get the path to the embedded plugins directory.
20///
21/// On first call, this extracts the embedded plugins to a cache directory.
22/// The cache is content-addressed, so unchanged plugins are reused across runs.
23///
24/// Returns `None` if extraction fails.
25pub fn get_embedded_plugins_dir() -> Option<&'static PathBuf> {
26 EXTRACTED_PLUGINS_DIR.get_or_init(|| match extract_plugins() {
27 Ok(path) => path,
28 Err(e) => {
29 tracing::error!("Failed to extract embedded plugins: {}", e);
30 PathBuf::new()
31 }
32 });
33
34 let path = EXTRACTED_PLUGINS_DIR.get()?;
35 if path.exists()
36 && path
37 .read_dir()
38 .map(|mut d| d.next().is_some())
39 .unwrap_or(false)
40 {
41 Some(path)
42 } else {
43 None
44 }
45}
46
47/// Content hash of embedded plugins, computed at build time
48const PLUGINS_CONTENT_HASH: &str = include_str!(concat!(env!("OUT_DIR"), "/plugins_hash.txt"));
49
50/// Get the cache directory for extracted plugins
51fn get_cache_dir() -> Option<PathBuf> {
52 dirs::cache_dir().map(|p| p.join("fresh").join("embedded-plugins"))
53}
54
55/// Extract embedded plugins to the cache directory.
56///
57/// Concurrency contract: this function is called via a process-local
58/// `OnceLock`, but multiple test processes (e.g. cargo-nextest) may
59/// each call it concurrently against the same on-disk directory. We
60/// publish atomically: extract into a sibling `.pending.<pid>.<nanos>`
61/// directory, write a `.extracted` marker, then `rename` into place.
62/// `rename` over an existing non-empty directory fails on POSIX, so
63/// only one publisher wins; losers fall back to the winner's
64/// directory. Readers gate on the marker file so they never observe a
65/// half-extracted tree.
66fn extract_plugins() -> Result<PathBuf, std::io::Error> {
67 let cache_base = get_cache_dir().ok_or_else(|| {
68 std::io::Error::new(
69 std::io::ErrorKind::NotFound,
70 "Could not determine cache directory",
71 )
72 })?;
73
74 let content_hash = PLUGINS_CONTENT_HASH.trim();
75 let cache_dir = cache_base.join(content_hash);
76 let marker = cache_dir.join(".extracted");
77
78 if marker.exists() {
79 tracing::info!("Using cached embedded plugins from: {:?}", cache_dir);
80 return Ok(cache_dir);
81 }
82
83 tracing::info!("Extracting embedded plugins to: {:?}", cache_dir);
84 std::fs::create_dir_all(&cache_base)?;
85
86 let pid = std::process::id();
87 let nanos = std::time::SystemTime::now()
88 .duration_since(std::time::UNIX_EPOCH)
89 .map(|d| d.as_nanos())
90 .unwrap_or(0);
91 // tmp_dir name includes the current nanosecond timestamp, so it
92 // can't collide with any prior invocation — no pre-existing dir
93 // to clean up here.
94 let tmp_dir = cache_base.join(format!(".pending.{}.{}", pid, nanos));
95
96 extract_dir_recursive(&EMBEDDED_PLUGINS, &tmp_dir)?;
97 // Marker is written *inside* the temp dir so the rename publishes
98 // the directory and its completeness signal in one atomic step.
99 std::fs::write(tmp_dir.join(".extracted"), b"")?;
100
101 let publish = |tmp_dir: &std::path::Path| std::fs::rename(tmp_dir, &cache_dir);
102
103 let result = match publish(&tmp_dir) {
104 Ok(()) => Ok(cache_dir.clone()),
105 Err(_) if marker.exists() => {
106 // A concurrent publisher won the race; drop our partial work.
107 // tmp_dir has a unique name, so a leak here is unrecoverable
108 // disk noise but never reachable by future readers.
109 #[allow(clippy::let_underscore_must_use)]
110 let _ = std::fs::remove_dir_all(&tmp_dir);
111 Ok(cache_dir.clone())
112 }
113 Err(_) => {
114 // `cache_dir` exists but has no marker — a leftover from
115 // the pre-marker code path that could have written a
116 // half-extracted tree. Sweep it aside and retry once.
117 // Either rename succeeds (cache_dir gone, retry will
118 // publish), fails because cache_dir vanished (another
119 // process beat us — fine, retry will see the publish), or
120 // fails for an OS-level reason (we fall through to the
121 // marker check). All paths converge.
122 let stale = cache_base.join(format!(".stale.{}.{}", pid, nanos));
123 #[allow(clippy::let_underscore_must_use)]
124 let _ = std::fs::rename(&cache_dir, &stale);
125 // Best-effort: if the rename failed, `stale` may not exist
126 // (NotFound) — that's the no-op case. If it succeeded but
127 // remove fails, we leak disk space at a unique-named path
128 // nobody else references.
129 #[allow(clippy::let_underscore_must_use)]
130 let _ = std::fs::remove_dir_all(&stale);
131 match publish(&tmp_dir) {
132 Ok(()) => Ok(cache_dir.clone()),
133 Err(e) => {
134 // Same as the marker-exists arm above: tmp_dir has
135 // a unique name, leakage on cleanup failure is
136 // bounded and unobservable to readers.
137 #[allow(clippy::let_underscore_must_use)]
138 let _ = std::fs::remove_dir_all(&tmp_dir);
139 if marker.exists() {
140 Ok(cache_dir.clone())
141 } else {
142 Err(e)
143 }
144 }
145 }
146 }
147 };
148
149 if result.is_ok() {
150 tracing::info!(
151 "Successfully extracted {} embedded plugin files",
152 count_files(&EMBEDDED_PLUGINS)
153 );
154 // Clean up old cache versions (other content hashes) and stale
155 // pending/stale-rename leftovers from prior crashes. Only does
156 // anything once we've successfully committed our extraction.
157 if let Ok(entries) = std::fs::read_dir(&cache_base) {
158 for entry in entries.flatten() {
159 let name_os = entry.file_name();
160 let name = name_os.to_string_lossy();
161 if name == content_hash {
162 continue;
163 }
164 if name.starts_with(".pending.") || name.starts_with(".stale.") {
165 // Never trash a tmp dir that another live extractor
166 // might still own. We can't tell from here, so leave
167 // those alone — they're cheap and self-clean on the
168 // next successful publish from that process.
169 continue;
170 }
171 // Best-effort cleanup of old cache versions.
172 #[allow(clippy::let_underscore_must_use)]
173 let _ = trash::delete(entry.path());
174 }
175 }
176 }
177
178 result
179}
180
181/// Recursively extract a directory and its contents
182fn extract_dir_recursive(dir: &Dir<'_>, target_path: &std::path::Path) -> std::io::Result<()> {
183 std::fs::create_dir_all(target_path)?;
184
185 // Extract files
186 for file in dir.files() {
187 let file_path = target_path.join(file.path().file_name().unwrap_or_default());
188 std::fs::write(&file_path, file.contents())?;
189 tracing::debug!("Extracted: {:?}", file_path);
190 }
191
192 // Recursively extract subdirectories
193 for subdir in dir.dirs() {
194 let subdir_name = subdir.path().file_name().unwrap_or_default();
195 let subdir_path = target_path.join(subdir_name);
196 extract_dir_recursive(subdir, &subdir_path)?;
197 }
198
199 Ok(())
200}
201
202/// Count total files in embedded directory (for logging)
203fn count_files(dir: &Dir<'_>) -> usize {
204 let mut count = dir.files().count();
205 for subdir in dir.dirs() {
206 count += count_files(subdir);
207 }
208 count
209}
210
211#[cfg(test)]
212mod tests {
213 use super::*;
214
215 #[test]
216 fn test_embedded_plugins_exist() {
217 // Verify that plugins are embedded
218 assert!(EMBEDDED_PLUGINS.files().count() > 0 || EMBEDDED_PLUGINS.dirs().count() > 0);
219 }
220
221 #[test]
222 fn test_extract_plugins() {
223 let path = get_embedded_plugins_dir();
224 assert!(path.is_some());
225 let path = path.unwrap();
226 assert!(path.exists());
227 assert!(path.is_dir());
228
229 // Check that some plugin files exist
230 let entries: Vec<_> = std::fs::read_dir(path).unwrap().collect();
231 assert!(!entries.is_empty());
232 }
233}