Skip to main content

nub/
lib.rs

1//! Nub: the JAR v3 microkernel — uniform caller-facing handle.
2//!
3//! The [`Nub`] handle is the API callers (chain runtime, tests, RPC,
4//! `jar-apply`) link against. It hides the choice of substrate behind
5//! a single invoke surface, dispatching to one of two backends:
6//!
7//! - **Local**: runs the byte-PVM interpreter directly in-process via
8//!   `nub_arch_local::run_instance`. Used for tests, deterministic
9//!   replay, and any host that doesn't need real ring-0 isolation.
10//! - **Hyperlight**: ships the invocation as an RPC into a
11//!   `nub-arch-x86` guest binary running inside a Hyperlight
12//!   sandbox. The actual `Kernel<HyperlightArch>` lives guest-side;
13//!   the host holds only the sandbox + a state cache.
14//!
15//! Both backends share the same typed publish/invoke surface — the
16//! caller publishes a `Cap::Image` (and optionally a `Cap::CNode`),
17//! publishes a `Cap::Instance` referencing them, and then invokes by
18//! the resulting instance hash.
19
20use anyhow::Result;
21use javm_cap::{CacheDirectory, CapHashOrRef, cap::Cap};
22use nub_arch_local::LocalArch;
23use nub_host_kvm::sandbox::{
24    GuestBinary, MultiUseSandbox, SandboxConfiguration, UninitializedSandbox,
25};
26use nub_kernel::Kernel;
27
28#[cfg(feature = "heap-diag")]
29use nub_arch_x86_abi::FN_ID_NUB_HEAP_STATS;
30use nub_arch_x86_abi::{
31    ArchivedInvocationResult, FN_ID_NUB_INVOKE_CACHED, FN_ID_NUB_SMOKE, InvokePacket,
32};
33pub use nub_arch_x86_abi::{CapHash as AbiCapHash, InvocationResult};
34pub use nub_kernel::{CapHash, InstanceRef, InvokeOptions, InvokeOutcome};
35
36use rkyv::primitive::ArchivedU64;
37use rkyv::util::AlignedVec;
38
39/// Snapshot of the guest's talc allocation state. Returned by
40/// [`Nub::heap_stats`].
41#[cfg(feature = "heap-diag")]
42#[derive(Debug, Clone, Copy)]
43pub struct HeapStats {
44    pub allocated_bytes: u64,
45    pub allocation_count: u64,
46    pub fragment_count: u64,
47    pub available_bytes: u64,
48}
49
50/// Path to the cross-compiled Hyperlight guest blob. Set by
51/// `build.rs` via [`nub_build::build`].
52const NUB_ARCH_X86_BLOB_PATH: &str = env!("NUB_ARCH_X86_BLOB");
53
54/// Uniform handle to the nub microkernel.
55pub struct Nub {
56    backend: Backend,
57    /// In-process typed cache, used by the Local backend to back its
58    /// publish_*/invoke_cached path. Always present (even on the
59    /// Hyperlight backend) so tests can construct a Nub and not care
60    /// which backend they hit. On Hyperlight the local cache is unused
61    /// at runtime — the shared-memory cache in `sandbox.cache()` is
62    /// the source of truth.
63    local_cache: CacheDirectory,
64}
65
66enum Backend {
67    Local(Kernel<LocalArch>),
68    Hyperlight(Box<HyperlightDriver>),
69}
70
71/// Host-side RPC stub for the Hyperlight backend. The real kernel
72/// lives guest-side; this wrapper just ships invocations into the
73/// sandbox.
74struct HyperlightDriver {
75    sandbox: MultiUseSandbox,
76    state_root_cache: CapHash,
77}
78
79impl Nub {
80    /// Construct a Nub backed by the in-process [`LocalArch`].
81    pub fn new_local() -> Self {
82        Self {
83            backend: Backend::Local(Kernel::new(LocalArch::new())),
84            local_cache: CacheDirectory::new(),
85        }
86    }
87
88    /// Construct a Nub backed by a fresh Hyperlight sandbox loaded
89    /// from the `nub-arch-x86` guest blob.
90    pub fn new_hyperlight() -> Result<Self> {
91        let mut cfg = SandboxConfiguration::default();
92        cfg.set_scratch_size(512 * 1024 * 1024);
93        cfg.set_input_data_size(16 * 1024 * 1024);
94        cfg.set_output_data_size(16 * 1024 * 1024);
95        cfg.set_heap_size(256 * 1024 * 1024);
96        let uninit = UninitializedSandbox::new(
97            GuestBinary::FilePath(NUB_ARCH_X86_BLOB_PATH.to_string()),
98            Some(cfg),
99        )?;
100        let sandbox = uninit.evolve()?;
101        Ok(Self {
102            backend: Backend::Hyperlight(Box::new(HyperlightDriver {
103                sandbox,
104                state_root_cache: [0; 32],
105            })),
106            local_cache: CacheDirectory::new(),
107        })
108    }
109
110    /// Invoke `endpoint` on `target` with `args`. Kernel-style entry
111    /// point — currently a skeleton returning 42 from both backends.
112    pub fn invoke(
113        &mut self,
114        target: InstanceRef,
115        endpoint: u16,
116        args: &[u8],
117        opts: InvokeOptions,
118    ) -> Result<InvokeOutcome> {
119        match &mut self.backend {
120            Backend::Local(k) => Ok(k
121                .invoke(target, endpoint, args, opts)
122                .expect("LocalArch::Error is uninhabited")),
123            Backend::Hyperlight(h) => h.invoke(target, endpoint, args, opts),
124        }
125    }
126
127    /// Current state root.
128    pub fn state_root(&self) -> CapHash {
129        match &self.backend {
130            Backend::Local(k) => k.state_root(),
131            Backend::Hyperlight(h) => h.state_root_cache,
132        }
133    }
134
135    /// Diagnostic: read the guest's talc allocation counters.
136    /// Hyperlight backend only. Requires the `heap-diag` feature.
137    #[cfg(feature = "heap-diag")]
138    pub fn heap_stats(&mut self) -> Result<HeapStats> {
139        match &mut self.backend {
140            Backend::Local(_) => Err(anyhow::anyhow!(
141                "heap_stats: Local backend has no guest heap"
142            )),
143            Backend::Hyperlight(h) => {
144                let raw: Vec<u8> = h.sandbox.call_raw(FN_ID_NUB_HEAP_STATS, &[])?;
145                if raw.len() != 32 {
146                    return Err(anyhow::anyhow!(
147                        "heap_stats: expected 32 bytes, got {}",
148                        raw.len()
149                    ));
150                }
151                let parse = |off: usize| u64::from_le_bytes(raw[off..off + 8].try_into().unwrap());
152                Ok(HeapStats {
153                    allocated_bytes: parse(0),
154                    allocation_count: parse(8),
155                    fragment_count: parse(16),
156                    available_bytes: parse(24),
157                })
158            }
159        }
160    }
161
162    // --- New publish surface (caller-built `Cap`) ---
163
164    /// Put a caller-built `Cap` into the active cache. Computes
165    /// the cap's content hash and either clones the cap on first put or
166    /// bumps refcount on idempotent re-put. Returns the cap's content hash.
167    pub fn put_cap(&mut self, cap: &javm_cap::Cap) -> Result<AbiCapHash> {
168        match &mut self.backend {
169            Backend::Local(_) => self
170                .local_cache
171                .put_cap(cap)
172                .map_err(|e| anyhow::anyhow!("put_cap (local): {e}")),
173            Backend::Hyperlight(h) => h
174                .sandbox
175                .put_cap(cap)
176                .map_err(|e| anyhow::anyhow!("put_cap: {e}")),
177        }
178    }
179
180    /// Pre-hashed variant. Caller computed `ssz::hash_tree_root(cap)`
181    /// at warmup and passes it explicitly; on the hot idempotent
182    /// path this lets both backends skip the SSZ merkleize entirely.
183    /// Debug-asserts the claimed hash matches the cap; release trusts
184    /// the caller.
185    ///
186    /// Hyperlight backend: short-circuits to a host-side
187    /// `GuestCacheReader::contains(hash)` check against the guest's
188    /// heap-resident `CacheDirectory` (mapped at the host's matching
189    /// VA via the snapshot mapping). On a hit, no RPC roundtrip and
190    /// no guest-side merkle walk — the typical bench / replay
191    /// workload re-publishes the same cap graph every iteration and
192    /// pays only one host-side `HashMap::contains_key`.
193    pub fn put_cap_with_hash(&mut self, hash: AbiCapHash, cap: &javm_cap::Cap) -> Result<()> {
194        match &mut self.backend {
195            Backend::Local(_) => self
196                .local_cache
197                .put_cap_with_hash(hash, cap)
198                .map_err(|e| anyhow::anyhow!("put_cap_with_hash (local): {e}")),
199            Backend::Hyperlight(h) => h
200                .sandbox
201                .put_cap_with_hash(hash, cap)
202                .map_err(|e| anyhow::anyhow!("put_cap_with_hash: {e}")),
203        }
204    }
205
206    /// Invoke a previously-published `Cap::Instance` by hash. V0 args
207    /// are 4 u64s laid into φ[7..=10] on top of the published
208    /// endpoint's `initial_regs` baseline.
209    pub fn invoke_cached(
210        &mut self,
211        instance_hash: AbiCapHash,
212        endpoint_idx: u8,
213        args: [u64; 4],
214        initial_gas: u64,
215    ) -> Result<InvocationResult> {
216        match &mut self.backend {
217            Backend::Local(_) => {
218                // Resolve the instance + image from the in-process
219                // cache and drive the byte-PVM interpreter.
220                let instance_cap = self
221                    .local_cache
222                    .get(CapHashOrRef::Hash(instance_hash))
223                    .ok_or_else(|| anyhow::anyhow!("invoke_cached: instance not published"))?;
224                let inst = match &*instance_cap {
225                    Cap::Instance(i) => i.clone(),
226                    _ => {
227                        return Err(anyhow::anyhow!(
228                            "invoke_cached: cap at hash is not an Instance"
229                        ));
230                    }
231                };
232                let image_cap = self
233                    .local_cache
234                    .get(CapHashOrRef::Hash(inst.image_hash))
235                    .ok_or_else(|| anyhow::anyhow!("invoke_cached: image not in cache"))?;
236                let img = match &*image_cap {
237                    Cap::Image(i) => i.clone(),
238                    _ => {
239                        return Err(anyhow::anyhow!(
240                            "invoke_cached: cap at image_hash is not an Image"
241                        ));
242                    }
243                };
244                Ok(nub_arch_local::run_instance(
245                    &inst,
246                    &img,
247                    endpoint_idx,
248                    args,
249                    initial_gas,
250                ))
251            }
252            Backend::Hyperlight(h) => {
253                // No host-side pin/unpin — the cap is owned by the
254                // guest's heap-resident DIRECTORY; there's nothing for
255                // the host to lock against (the guest doesn't evict).
256                let packet = InvokePacket {
257                    instance_hash,
258                    endpoint_idx: endpoint_idx as u32,
259                    _pad: 0,
260                    args,
261                    initial_gas,
262                };
263                let result_bytes = h
264                    .sandbox
265                    .call_raw(FN_ID_NUB_INVOKE_CACHED, packet.as_bytes())?;
266
267                let mut aligned = AlignedVec::<16>::with_capacity(result_bytes.len());
268                aligned.extend_from_slice(&result_bytes);
269                let archived = rkyv::access::<ArchivedInvocationResult, rkyv::rancor::Error>(
270                    aligned.as_slice(),
271                )
272                .map_err(|e| anyhow::anyhow!("rkyv-access InvocationResult: {e}"))?;
273                Ok(InvocationResult {
274                    exit_reason: archived.exit_reason.to_native(),
275                    exit_arg: archived.exit_arg.to_native(),
276                    return_value: archived.return_value.to_native(),
277                    gas_remaining: archived.gas_remaining.to_native(),
278                })
279            }
280        }
281    }
282}
283
284impl HyperlightDriver {
285    fn invoke(
286        &mut self,
287        _target: InstanceRef,
288        _endpoint: u16,
289        _args: &[u8],
290        _opts: InvokeOptions,
291    ) -> Result<InvokeOutcome> {
292        // Skeleton: ship a fixed RPC into the guest. The guest's
293        // `nub_smoke` returns 42, matching `LocalArch`'s stub.
294        let result_bytes = self.sandbox.call_raw(FN_ID_NUB_SMOKE, &[])?;
295        let mut aligned = AlignedVec::<16>::with_capacity(result_bytes.len());
296        aligned.extend_from_slice(&result_bytes);
297        let archived = rkyv::access::<ArchivedU64, rkyv::rancor::Error>(aligned.as_slice())
298            .map_err(|e| anyhow::anyhow!("rkyv-access u64 from nub_smoke: {e}"))?;
299        Ok(InvokeOutcome {
300            return_value: archived.to_native(),
301            gas_used: 0,
302        })
303    }
304}