Skip to main content

yosh_plugin_manager/
precompile.rs

1//! Eager precompile of `.wasm` Component Model plugins to `.cwasm` artifacts
2//! plus a sidecar `<basename>.cwasm.meta` describing the four-tuple cache
3//! key the host validates at startup.
4//!
5//! See `docs/superpowers/specs/2026-04-27-wasm-plugin-runtime-design.md` §5
6//! "cwasm trust model" / §7 "Plugin manager pipeline" for the full design.
7//!
8//! The four-tuple recorded both in `plugins.lock` and the sidecar is:
9//!
10//! ```text
11//! (wasm_sha256, wasmtime_version, target_triple, engine_config_hash)
12//! ```
13//!
14//! At shell startup the host computes the same tuple from its live engine
15//! and rejects the cwasm if any field differs (`src/plugin/cache.rs` →
16//! `validate_cwasm`). When that happens the host falls back to in-memory
17//! `Component::new`, so a stale cwasm is never a hard failure — just a
18//! perf regression until `yosh-plugin sync` re-runs.
19
20use std::path::{Path, PathBuf};
21
22use serde::{Deserialize, Serialize};
23use sha2::{Digest, Sha256};
24
25/// Wasmtime version used for precompile. Hardcoded to match the pin in
26/// `Cargo.toml`. MUST equal `src/plugin/cache.rs::WASMTIME_VERSION` so the
27/// host's cache validator accepts cwasm files written here. Bumping the
28/// wasmtime dep requires bumping this constant in lockstep.
29///
30/// `wasmtime::VERSION` is private in the 27.x crate, so we cannot derive
31/// this at compile time without a build script that runs cargo metadata.
32pub const WASMTIME_VERSION: &str = "27";
33
34/// Target triple this binary was built for. Sourced from cargo's `TARGET`
35/// env var captured by `build.rs` and re-emitted as a `rustc-env` entry.
36/// Falls back to `"host"` if the build script could not determine it
37/// (mirrors the host's same-name fallback).
38pub fn target_triple() -> &'static str {
39    option_env!("TARGET").unwrap_or("host")
40}
41
42/// Canonical engine-config fingerprint. MUST match what the shell's
43/// `PluginManager::new()` computes (`src/plugin/mod.rs`) for the manager
44/// to write a cwasm the host will accept. Both sides use this string
45/// verbatim and feed it through `engine_config_hash`.
46pub const ENGINE_FINGERPRINT: &str =
47    "v2;component_model=true;async=false;fuel=false;epoch=true;cranelift";
48
49/// Hex-encoded SHA-256 of the engine fingerprint string. Same algorithm
50/// the host uses; same input string => same hash. Reusing the canonical
51/// digest here keeps both producers in lockstep without sharing code.
52pub fn engine_config_hash(fingerprint: &str) -> String {
53    let mut hasher = Sha256::new();
54    hasher.update(fingerprint.as_bytes());
55    hex::encode(hasher.finalize())
56}
57
58/// SHA-256 of arbitrary bytes, hex-encoded.
59pub fn sha256_hex(bytes: &[u8]) -> String {
60    let mut hasher = Sha256::new();
61    hasher.update(bytes);
62    hex::encode(hasher.finalize())
63}
64
65/// Four-tuple cache key. Same shape as `src/plugin/cache.rs::CacheKey`;
66/// duplicated here because the manager and the host live in different
67/// crates and we want neither to depend on the other.
68#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
69pub struct CacheKey {
70    /// SHA-256 (hex) of the source `.wasm` file.
71    pub wasm_sha256: String,
72    /// Wasmtime version string. See `WASMTIME_VERSION`.
73    pub wasmtime_version: String,
74    /// Target triple, e.g. `aarch64-apple-darwin`.
75    pub target_triple: String,
76    /// Hex-encoded `engine_config_hash`.
77    pub engine_config_hash: String,
78}
79
80impl CacheKey {
81    /// Construct a cache key for the manager's precompile path.
82    pub fn for_precompile(wasm_sha256: impl Into<String>) -> Self {
83        CacheKey {
84            wasm_sha256: wasm_sha256.into(),
85            wasmtime_version: WASMTIME_VERSION.to_string(),
86            target_triple: target_triple().to_string(),
87            engine_config_hash: engine_config_hash(ENGINE_FINGERPRINT),
88        }
89    }
90}
91
92/// Sidecar layout. Identical to `src/plugin/cache.rs::SidecarMeta` so the
93/// host's `read_from` parses files written here.
94#[derive(Debug, Clone, Serialize, Deserialize)]
95pub struct SidecarMeta {
96    pub schema: u32,
97    pub key: CacheKey,
98}
99
100impl SidecarMeta {
101    pub const SCHEMA_VERSION: u32 = 1;
102
103    pub fn new(key: CacheKey) -> Self {
104        SidecarMeta {
105            schema: Self::SCHEMA_VERSION,
106            key,
107        }
108    }
109
110    pub fn write_to(&self, path: &Path) -> Result<(), String> {
111        let s = toml::to_string(self)
112            .map_err(|e| format!("serialize cwasm sidecar {}: {}", path.display(), e))?;
113        std::fs::write(path, s)
114            .map_err(|e| format!("write cwasm sidecar {}: {}", path.display(), e))
115    }
116}
117
118/// Build a wasmtime `Engine` configured the same way the shell does at
119/// startup. Used in two places:
120///
121/// 1. `precompile()` callers — the produced cwasm must match the host's
122///    engine flags exactly so it deserializes without re-precompilation.
123/// 2. `metadata_extract` — runs each plugin's `metadata()` once behind a
124///    one-shot watchdog (1-tick deadline + 5-second detached epoch bump)
125///    to time-bound malformed components.
126///
127/// `epoch_interruption` is ON to match the host (`src/plugin/mod.rs`),
128/// which uses it to bound the wall-clock time of `pre_prompt` hooks.
129/// All three sites share these flags so cwasm artefacts are universally
130/// loadable. Per-call timeout semantics differ between sites (host =
131/// per-invocation deadline, metadata = one-shot watchdog), but the
132/// engine config itself is shared.
133pub fn make_engine() -> Result<wasmtime::Engine, String> {
134    let mut config = wasmtime::Config::new();
135    config.wasm_component_model(true);
136    config.async_support(false);
137    config.consume_fuel(false);
138    config.epoch_interruption(true);
139    wasmtime::Engine::new(&config).map_err(|e| format!("wasmtime Engine::new: {}", e))
140}
141
142/// Result of a successful precompile.
143#[derive(Debug, Clone)]
144pub struct PrecompileOutput {
145    /// Absolute path to the written `.cwasm` file.
146    pub cwasm_path: PathBuf,
147    /// Absolute path to the written `<basename>.cwasm.meta` sidecar.
148    pub sidecar_path: PathBuf,
149    /// Cache key tuple. Caller persists this in `plugins.lock`.
150    pub cache_key: CacheKey,
151}
152
153/// Precompile a `.wasm` component to `<cache_dir>/<stem>.cwasm` plus a
154/// `<stem>.cwasm.meta` sidecar.
155///
156/// `cache_dir` is created with mode 0700 if missing (matches the host's
157/// trust check). `cwasm` and sidecar files are written with mode 0600.
158///
159/// On any failure the cwasm or sidecar may not exist; callers should
160/// treat that as "no cache" — the host will re-precompile in-memory at
161/// startup.
162pub fn precompile(
163    wasm_path: &Path,
164    cache_dir: &Path,
165    engine: &wasmtime::Engine,
166) -> Result<PrecompileOutput, String> {
167    let wasm_bytes =
168        std::fs::read(wasm_path).map_err(|e| format!("read {}: {}", wasm_path.display(), e))?;
169    let wasm_sha = sha256_hex(&wasm_bytes);
170
171    // Ensure the cache directory exists with the right permissions.
172    ensure_cache_dir(cache_dir)?;
173
174    let stem = wasm_path
175        .file_stem()
176        .and_then(|s| s.to_str())
177        .ok_or_else(|| format!("invalid wasm filename: {}", wasm_path.display()))?;
178    let cwasm_path = cache_dir.join(format!("{}.cwasm", stem));
179    let sidecar_path = cache_dir.join(format!("{}.cwasm.meta", stem));
180
181    // `precompile_component` returns the same byte stream the host's
182    // `Component::deserialize` consumes. The host re-validates the
183    // four-tuple before deserialize, so a stale cwasm is rejected without
184    // crossing the unsafe boundary.
185    let serialized = engine
186        .precompile_component(&wasm_bytes)
187        .map_err(|e| format!("precompile_component {}: {}", wasm_path.display(), e))?;
188
189    write_with_mode(&cwasm_path, &serialized, 0o600)?;
190
191    let cache_key = CacheKey::for_precompile(wasm_sha);
192    SidecarMeta::new(cache_key.clone()).write_to(&sidecar_path)?;
193    set_mode(&sidecar_path, 0o600)?;
194
195    Ok(PrecompileOutput {
196        cwasm_path,
197        sidecar_path,
198        cache_key,
199    })
200}
201
202/// Create the cache directory if missing and ensure mode 0700 (Unix only;
203/// other platforms fall back to existence check).
204fn ensure_cache_dir(dir: &Path) -> Result<(), String> {
205    std::fs::create_dir_all(dir)
206        .map_err(|e| format!("create cache dir {}: {}", dir.display(), e))?;
207    set_mode(dir, 0o700)?;
208    Ok(())
209}
210
211#[cfg(unix)]
212fn set_mode(path: &Path, mode: u32) -> Result<(), String> {
213    use std::os::unix::fs::PermissionsExt;
214    std::fs::set_permissions(path, std::fs::Permissions::from_mode(mode))
215        .map_err(|e| format!("chmod {}: {}", path.display(), e))
216}
217
218#[cfg(not(unix))]
219fn set_mode(_path: &Path, _mode: u32) -> Result<(), String> {
220    Ok(())
221}
222
223#[cfg(unix)]
224fn write_with_mode(path: &Path, bytes: &[u8], mode: u32) -> Result<(), String> {
225    use std::io::Write;
226    use std::os::unix::fs::OpenOptionsExt;
227    let mut f = std::fs::OpenOptions::new()
228        .write(true)
229        .create(true)
230        .truncate(true)
231        .mode(mode)
232        .open(path)
233        .map_err(|e| format!("open {}: {}", path.display(), e))?;
234    f.write_all(bytes)
235        .map_err(|e| format!("write {}: {}", path.display(), e))?;
236    Ok(())
237}
238
239#[cfg(not(unix))]
240fn write_with_mode(path: &Path, bytes: &[u8], _mode: u32) -> Result<(), String> {
241    std::fs::write(path, bytes).map_err(|e| format!("write {}: {}", path.display(), e))
242}
243
244#[cfg(test)]
245mod tests {
246    use super::*;
247
248    #[test]
249    fn engine_config_hash_is_deterministic() {
250        let a = engine_config_hash(ENGINE_FINGERPRINT);
251        let b = engine_config_hash(ENGINE_FINGERPRINT);
252        assert_eq!(a, b);
253    }
254
255    #[test]
256    fn engine_config_hash_differs_for_different_fingerprints() {
257        let a = engine_config_hash("a");
258        let b = engine_config_hash("b");
259        assert_ne!(a, b);
260    }
261
262    #[test]
263    fn cache_key_for_precompile_uses_pinned_constants() {
264        let k = CacheKey::for_precompile("abc");
265        assert_eq!(k.wasm_sha256, "abc");
266        assert_eq!(k.wasmtime_version, WASMTIME_VERSION);
267        assert_eq!(k.engine_config_hash, engine_config_hash(ENGINE_FINGERPRINT));
268    }
269
270    #[test]
271    fn sidecar_round_trip() {
272        let dir = tempfile::tempdir().unwrap();
273        let path = dir.path().join("plugin.cwasm.meta");
274        let key = CacheKey::for_precompile("deadbeef");
275        let meta = SidecarMeta::new(key.clone());
276        meta.write_to(&path).unwrap();
277        let bytes = std::fs::read_to_string(&path).unwrap();
278        let parsed: SidecarMeta = toml::from_str(&bytes).unwrap();
279        assert_eq!(parsed.schema, SidecarMeta::SCHEMA_VERSION);
280        assert_eq!(parsed.key, key);
281    }
282
283    #[test]
284    fn make_engine_succeeds() {
285        let _engine = make_engine().expect("engine");
286    }
287}