Skip to main content

stoffel/
vm.rs

1//! StoffelVM execution integration.
2//!
3//! Clear execution embeds the VM directly. Local MPC execution delegates to the
4//! real localhost coordinator/party runner in `stoffel-vm`, preserving the
5//! PRD's non-simulated local network behavior.
6
7use std::collections::HashSet;
8use std::path::{Path, PathBuf};
9use std::time::Duration;
10
11use crate::config::MpcBackend;
12use crate::error::{Error, Result};
13use crate::runtime::StoffelRuntime;
14use crate::types::Value;
15use stoffel_vm_types::core_types::{ShareType, TableRef, Value as VmValue};
16
17pub fn execute_clear(runtime: &StoffelRuntime, function_name: &str) -> Result<Vec<Value>> {
18    let args = runtime.input_values_for_function(function_name)?;
19    execute_clear_with_sdk_args(runtime, function_name, &args)
20}
21
22pub(crate) fn execute_clear_with_sdk_args(
23    runtime: &StoffelRuntime,
24    function_name: &str,
25    args: &[Value],
26) -> Result<Vec<Value>> {
27    runtime
28        .program()
29        .validate_function_args(function_name, args)?;
30
31    let mut vm = stoffel_vm::core_vm::VirtualMachine::try_new()
32        .map_err(|error| Error::Computation(error.to_string()))?;
33    for function in runtime
34        .program()
35        .binary()
36        .try_to_vm_functions()
37        .map_err(|error| Error::Bytecode(format!("{error:?}")))?
38    {
39        vm.try_register_function(function)
40            .map_err(|error| Error::Computation(error.to_string()))?;
41    }
42
43    let vm_args = args
44        .iter()
45        .map(|value| sdk_value_to_vm_value(&mut vm, value))
46        .collect::<Result<Vec<_>>>()?;
47    let value = vm
48        .execute_with_args(function_name, &vm_args)
49        .map_err(|error| Error::Computation(error.to_string()))?;
50    let value = sdk_value_from_vm_value(&mut vm, value, &mut HashSet::new(), 0)?;
51    match value {
52        Value::List(values) => Ok(values),
53        value => Ok(vec![value]),
54    }
55}
56
57pub fn execute_clear_with_args(
58    runtime: &StoffelRuntime,
59    function_name: &str,
60    args: &[stoffel_vm_types::core_types::Value],
61) -> Result<Vec<Value>> {
62    let sdk_args = args
63        .iter()
64        .filter_map(|value| Value::from_vm_value(value.clone()))
65        .collect::<Vec<_>>();
66    if sdk_args.len() == args.len() {
67        runtime
68            .program()
69            .validate_function_args(function_name, &sdk_args)?;
70    } else if runtime.program().function(function_name).is_none() {
71        return Err(Error::FunctionNotFound(function_name.to_owned()));
72    }
73
74    let mut vm = stoffel_vm::core_vm::VirtualMachine::try_new()
75        .map_err(|error| Error::Computation(error.to_string()))?;
76    for function in runtime
77        .program()
78        .binary()
79        .try_to_vm_functions()
80        .map_err(|error| Error::Bytecode(format!("{error:?}")))?
81    {
82        vm.try_register_function(function)
83            .map_err(|error| Error::Computation(error.to_string()))?;
84    }
85
86    let value = vm
87        .execute_with_args(function_name, args)
88        .map_err(|error| Error::Computation(error.to_string()))?;
89    let value = sdk_value_from_vm_value(&mut vm, value, &mut HashSet::new(), 0)?;
90    match value {
91        Value::List(values) => Ok(values),
92        value => Ok(vec![value]),
93    }
94}
95
96pub async fn execute_local(runtime: &StoffelRuntime, function_name: &str) -> Result<Vec<Value>> {
97    execute_local_with_options(runtime, function_name, LocalExecutionOptions::default()).await
98}
99
100#[derive(Debug, Clone, Default)]
101pub(crate) struct LocalExecutionOptions {
102    pub(crate) runner_path: Option<PathBuf>,
103    pub(crate) timeout: Option<Duration>,
104}
105
106/// A client's reconstructed output values, received via `send_to_client` and
107/// reconstructed by the off-chain client — the actual client-side result, not
108/// a public reveal to the compute nodes.
109///
110/// `values` are decoded through the loaded program's client-IO manifest, so a
111/// 1-bit secret int comes back as [`Value::Bool`], a wider secret int as
112/// [`Value::I64`], an unsigned secret int as [`Value::U64`], and a fixed-point
113/// share as [`Value::Float`]. Output positions the manifest does not describe
114/// (e.g. a developer-specified count with no static schema) fall back to
115/// [`Value::U64`]. The untyped reconstructed field values remain available via
116/// `raw` for callers that need them.
117#[derive(Debug, Clone, PartialEq)]
118pub struct LocalClientOutput {
119    pub client_slot: u64,
120    pub values: Vec<Value>,
121    pub raw: Vec<u64>,
122}
123
124impl LocalClientOutput {
125    /// Pack boolean outputs into bytes, LSB-first within each byte (output bit
126    /// `i` becomes bit `i % 8` of byte `i / 8`). This is the inverse of the
127    /// LSB-first bit layout AES and other bit-decomposed circuits use, so a
128    /// 128-bit ciphertext block round-trips straight back to its 16 bytes.
129    ///
130    /// Non-boolean outputs are treated as set when non-zero; the trailing
131    /// partial byte (when the output count is not a multiple of 8) is
132    /// zero-padded in its high bits.
133    pub fn bytes(&self) -> Vec<u8> {
134        let mut out = vec![0u8; self.values.len().div_ceil(8)];
135        for (index, value) in self.values.iter().enumerate() {
136            if value_is_set(value) {
137                out[index / 8] |= 1 << (index % 8);
138            }
139        }
140        out
141    }
142
143    /// The outputs as booleans (non-zero ⇒ `true`), in order.
144    pub fn bools(&self) -> Vec<bool> {
145        self.values.iter().map(value_is_set).collect()
146    }
147}
148
149fn value_is_set(value: &Value) -> bool {
150    match value {
151        Value::Bool(bit) => *bit,
152        Value::I64(value) => *value != 0,
153        Value::U64(value) => *value != 0,
154        _ => false,
155    }
156}
157
158/// Decode one reconstructed field value into a typed SDK [`Value`] using the
159/// share type the manifest declared for that output position.
160fn decode_client_output_value(raw: u64, share_type: ShareType) -> Value {
161    match share_type {
162        ShareType::SecretInt { bit_length: 1 } => Value::Bool(raw != 0),
163        ShareType::SecretInt { .. } => Value::I64(raw as i64),
164        ShareType::SecretUInt { .. } => Value::U64(raw),
165        ShareType::SecretFixedPoint { precision } => {
166            let scale = (1u128 << precision.fractional_bits()) as f64;
167            Value::Float((raw as i64) as f64 / scale)
168        }
169    }
170}
171
172pub(crate) async fn execute_local_with_options(
173    runtime: &StoffelRuntime,
174    function_name: &str,
175    options: LocalExecutionOptions,
176) -> Result<Vec<Value>> {
177    let (returned, _program_output, _client_outputs) =
178        execute_local_capturing_with_options(runtime, function_name, options).await?;
179    Ok(returned)
180}
181
182/// Like [`execute_local_with_options`] but also returns the program's printed
183/// output (everything the program emitted via `print`, with the VM's internal
184/// `Program returned:` markers stripped). Used to surface client-facing results
185/// that a returned aggregate (e.g. a `list`) only exposes as an opaque handle.
186pub(crate) async fn execute_local_capturing_with_options(
187    runtime: &StoffelRuntime,
188    function_name: &str,
189    options: LocalExecutionOptions,
190) -> Result<(Vec<Value>, String, Vec<LocalClientOutput>)> {
191    if runtime.program().function(function_name).is_none() {
192        return Err(Error::FunctionNotFound(function_name.to_owned()));
193    }
194    let vm_inputs = runtime.input_values_for_function(function_name)?;
195    if !vm_inputs.is_empty() {
196        return Err(Error::Unsupported(
197            "SDK local coordinator execution does not support direct function parameters; use a no-argument entrypoint and `with_client_input` for ClientStore values"
198                .to_owned(),
199        ));
200    }
201
202    let mpc_config = runtime
203        .mpc_config()
204        .ok_or_else(|| Error::Configuration("MPC configuration is required".to_owned()))?;
205    let flattened_client_inputs = flatten_local_client_inputs(runtime.client_inputs())?;
206    validate_flattened_local_client_inputs(runtime, &flattened_client_inputs)?;
207    if matches!(
208        mpc_config.backend,
209        MpcBackend::Avss {
210            curve: crate::config::Curve::Bn254
211                | crate::config::Curve::Curve25519
212                | crate::config::Curve::Ed25519
213        }
214    ) && !runtime.client_inputs().is_empty()
215    {
216        return Err(Error::Unsupported(
217            "SDK local coordinator execution currently supports AVSS local client inputs only for bls12_381"
218                .to_owned(),
219        ));
220    }
221    let local_client_inputs = flattened_client_inputs
222        .iter()
223        .map(|(client_slot, values)| {
224            Ok(stoffel_vm_runner::LocalClientInput::raw(
225                *client_slot,
226                values
227                    .iter()
228                    .map(local_client_input_value)
229                    .collect::<Result<Vec<_>>>()?,
230            ))
231        })
232        .collect::<Result<Vec<_>>>()?;
233    let runner_path = resolve_stoffel_run_binary(
234        options
235            .runner_path
236            .as_deref()
237            .or_else(|| runtime.local_runner_binary_path()),
238    )?;
239    eprintln!(
240        "[stoffel] using stoffel-run binary: {}",
241        runner_path.display()
242    );
243
244    let mut runner = stoffel_vm_runner::LocalCoordinatorRunner::builder(
245        runner_path,
246        runtime.program().binary().clone(),
247    )
248    .entry(function_name)
249    .backend(local_runner_backend(mpc_config.backend))
250    .curve(local_runner_curve(mpc_config.backend))
251    .parties(mpc_config.parties)
252    .threshold(mpc_config.threshold);
253    if let Some(timeout) = options.timeout {
254        runner = runner.timeout(timeout);
255    }
256    if let Some(expected_clients) = runtime.configured_expected_clients() {
257        runner = runner.expected_output_clients(expected_clients);
258    }
259    for (client_slot, count) in runtime.client_output_counts() {
260        runner = runner.client_output_count(*client_slot, *count);
261    }
262    runner = runner.client_inputs(local_client_inputs);
263
264    let output = runner
265        .build()
266        .map_err(|error| Error::Configuration(error.to_string()))?
267        .run()
268        .await
269        .map_err(|error| Error::Computation(error.to_string()))?;
270
271    let program_output = local_program_output(&output);
272    print!("{program_output}");
273
274    let returned = output.consistent_returned_values().map_err(|error| {
275        Error::Computation(format!(
276            "local coordinator run did not produce consistent VM return values: {error}\noutput:\n{}",
277            output.combined_output
278        ))
279    })?;
280    let values = returned
281        .iter()
282        .map(|value| parse_runner_return_value(value))
283        .collect::<Result<Vec<_>>>()?;
284    let manifest = runtime.program().client_io_manifest();
285    let client_outputs = output
286        .client_outputs
287        .iter()
288        .map(|record| {
289            let output_types = manifest
290                .clients
291                .iter()
292                .find(|client| client.client_slot == record.client_slot)
293                .map(|client| client.outputs.as_slice())
294                .unwrap_or(&[]);
295            let typed = record
296                .values
297                .iter()
298                .enumerate()
299                .map(|(index, &raw)| match output_types.get(index) {
300                    Some(share_type) => decode_client_output_value(raw, *share_type),
301                    None => Value::U64(raw),
302                })
303                .collect();
304            LocalClientOutput {
305                client_slot: record.client_slot,
306                values: typed,
307                raw: record.values.clone(),
308            }
309        })
310        .collect();
311    Ok((values, program_output, client_outputs))
312}
313
314/// The first party's printed program output, with `Program returned:` markers
315/// removed. Empty when no party produced output.
316fn local_program_output(output: &stoffel_vm_runner::LocalCoordinatorRunOutput) -> String {
317    let Some(first_party) = output.party_outputs.first() else {
318        return String::new();
319    };
320    local_program_output_without_return_markers(&first_party.stdout)
321}
322
323fn local_program_output_without_return_markers(stdout: &str) -> String {
324    let mut output = String::new();
325    for line in stdout.lines() {
326        if line.trim().starts_with("Program returned: ") {
327            continue;
328        }
329        output.push_str(line);
330        output.push('\n');
331    }
332    output
333}
334
335fn local_runner_backend(backend: MpcBackend) -> stoffel_vm::net::MpcBackendKind {
336    match backend {
337        MpcBackend::HoneyBadger => stoffel_vm::net::MpcBackendKind::HoneyBadger,
338        MpcBackend::Avss { .. } => stoffel_vm::net::MpcBackendKind::Avss,
339    }
340}
341
342fn local_runner_curve(backend: MpcBackend) -> stoffel_vm::net::MpcCurveConfig {
343    match backend.curve() {
344        None | Some(crate::config::Curve::Bls12_381) => stoffel_vm::net::MpcCurveConfig::Bls12_381,
345        Some(crate::config::Curve::Bn254) => stoffel_vm::net::MpcCurveConfig::Bn254,
346        Some(crate::config::Curve::Curve25519) => stoffel_vm::net::MpcCurveConfig::Curve25519,
347        Some(crate::config::Curve::Ed25519) => stoffel_vm::net::MpcCurveConfig::Ed25519,
348        Some(crate::config::Curve::Secp256k1) => stoffel_vm::net::MpcCurveConfig::Secp256k1,
349        Some(crate::config::Curve::Secp256r1) => stoffel_vm::net::MpcCurveConfig::Secp256r1,
350    }
351}
352
353fn validate_flattened_local_client_inputs(
354    runtime: &StoffelRuntime,
355    flattened_client_inputs: &[(u64, Vec<Value>)],
356) -> Result<()> {
357    runtime
358        .program()
359        .validate_owned_client_inputs(flattened_client_inputs)
360}
361
362fn flatten_local_client_inputs(inputs: &[(u64, Vec<Value>)]) -> Result<Vec<(u64, Vec<Value>)>> {
363    inputs
364        .iter()
365        .map(|(client_slot, values)| {
366            let mut flattened = Vec::new();
367            for value in values {
368                flatten_local_client_input_value(value, &mut flattened)?;
369            }
370            Ok((*client_slot, flattened))
371        })
372        .collect()
373}
374
375fn flatten_local_client_input_value(value: &Value, out: &mut Vec<Value>) -> Result<()> {
376    match value {
377        Value::List(values) => {
378            for value in values {
379                flatten_local_client_input_value(value, out)?;
380            }
381            Ok(())
382        }
383        Value::Object(_) => Err(Error::InvalidInput(
384            "local coordinator client inputs cannot directly encode objects; pass their scalar secret fields or use typed lowering"
385                .to_owned(),
386        )),
387        Value::I64(_)
388        | Value::U64(_)
389        | Value::Bool(_)
390        | Value::Bytes(_)
391        | Value::Float(_)
392        | Value::String(_)
393        | Value::Unit => {
394            out.push(value.clone());
395            Ok(())
396        }
397    }
398}
399
400fn local_client_input_value(value: &Value) -> Result<String> {
401    match value {
402        Value::I64(value) => Ok(value.to_string()),
403        Value::U64(value) if i64::try_from(*value).is_ok() => Ok(value.to_string()),
404        Value::U64(value) => Ok(format!("0x{value:x}")),
405        Value::Bool(value) => Ok(if *value { "1" } else { "0" }.to_owned()),
406        Value::Bytes(value) => Ok(format!("0x{}", hex_encode(value))),
407        Value::Float(_) | Value::String(_) | Value::List(_) | Value::Object(_) | Value::Unit => {
408            Err(Error::InvalidInput(
409                "local coordinator client inputs support integers, booleans, 0x-prefixed hex bytes, and lists of those values"
410                    .to_owned(),
411            ))
412        }
413    }
414}
415
416fn resolve_stoffel_run_binary(explicit_path: Option<&Path>) -> Result<PathBuf> {
417    if let Some(path) = explicit_path {
418        return resolve_existing_runner_path(path).ok_or_else(|| {
419            Error::Unsupported(format!(
420                "SDK local coordinator execution requires an existing stoffel-run binary; configured path does not exist: {}",
421                path.display()
422            ))
423        });
424    }
425
426    if let Some(path) = std::env::var_os("STOFFEL_RUN_BIN").map(PathBuf::from) {
427        return resolve_existing_runner_path(&path).ok_or_else(|| {
428            Error::Unsupported(format!(
429                "SDK local coordinator execution requires an existing stoffel-run binary; STOFFEL_RUN_BIN points to a missing path: {}",
430                path.display()
431            ))
432        });
433    }
434
435    if let Some(path) = built_workspace_runner() {
436        return Ok(path);
437    }
438
439    if let Some(path) = sibling_runner() {
440        return Ok(path);
441    }
442
443    if !running_from_workspace_target() {
444        if let Some(path) = path_runner() {
445            return Ok(path);
446        }
447    }
448
449    if let Some(workspace_root) = workspace_root() {
450        return Err(Error::Unsupported(format!(
451            "SDK local coordinator execution requires a built workspace stoffel-run binary at {}; build it with `cargo build -p stoffel-vm-runner --bin stoffel-run`, set STOFFEL_RUN_BIN, or call `local_runner_path`",
452            workspace_root
453                .join("target")
454                .join(if cfg!(debug_assertions) { "debug" } else { "release" })
455                .join(format!("stoffel-run{}", std::env::consts::EXE_SUFFIX))
456                .display()
457        )));
458    }
459
460    if let Some(path) = path_runner() {
461        return Ok(path);
462    }
463
464    Err(Error::Unsupported(
465        "SDK local coordinator execution requires a stoffel-run binary; install it with `cargo install --path crates/stoffel-vm-runner` (or `cargo install stoffel-vm-runner`) so it lands on your PATH, set STOFFEL_RUN_BIN, call `local_runner_path`, or build `cargo build -p stoffel-vm-runner --bin stoffel-run`"
466            .to_owned(),
467    ))
468}
469
470/// Look for a `stoffel-run` binary sitting next to the current executable.
471/// This is the case after the `stoffel` CLI installer, which drops both
472/// `stoffel` and `stoffel-run` into the same directory (e.g. `~/.local/bin`).
473fn sibling_runner() -> Option<PathBuf> {
474    let exe = std::env::current_exe().ok()?;
475    let candidate = exe.with_file_name("stoffel-run");
476    candidate.exists().then_some(candidate)
477}
478
479/// Look for a `stoffel-run` binary anywhere on the user's PATH.
480fn path_runner() -> Option<PathBuf> {
481    find_binary_on_path("stoffel-run")
482}
483
484fn built_workspace_runner() -> Option<PathBuf> {
485    let workspace_root = workspace_root()?;
486    let mut candidates = Vec::new();
487
488    if let Some(profile_dir) = current_target_profile_dir() {
489        candidates.push(profile_dir.join(format!("stoffel-run{}", std::env::consts::EXE_SUFFIX)));
490    }
491
492    candidates.push(
493        workspace_root
494            .join("target")
495            .join(if cfg!(debug_assertions) {
496                "debug"
497            } else {
498                "release"
499            })
500            .join(format!("stoffel-run{}", std::env::consts::EXE_SUFFIX)),
501    );
502
503    candidates.push(
504        workspace_root
505            .join("target")
506            .join("debug")
507            .join(format!("stoffel-run{}", std::env::consts::EXE_SUFFIX)),
508    );
509
510    candidates
511        .into_iter()
512        .find(|candidate| is_executable_file(candidate))
513}
514
515fn current_target_profile_dir() -> Option<PathBuf> {
516    let exe = std::env::current_exe().ok()?;
517    target_profile_dir_from_exe(&exe)
518}
519
520fn running_from_workspace_target() -> bool {
521    let (Some(workspace_root), Ok(exe)) = (workspace_root(), std::env::current_exe()) else {
522        return false;
523    };
524    path_is_under(&exe, &workspace_root.join("target"))
525}
526
527fn path_is_under(path: &Path, ancestor: &Path) -> bool {
528    path.ancestors().any(|candidate| candidate == ancestor)
529}
530
531fn target_profile_dir_from_exe(exe: &Path) -> Option<PathBuf> {
532    let parent = exe.parent()?;
533    let profile_dir = if parent.file_name().is_some_and(|name| name == "deps") {
534        parent.parent()?
535    } else {
536        parent
537    };
538    profile_dir
539        .parent()
540        .and_then(Path::file_name)
541        .is_some_and(|name| name == "target")
542        .then(|| profile_dir.to_path_buf())
543}
544
545fn find_binary_on_path(binary_name: &str) -> Option<PathBuf> {
546    let path = std::env::var_os("PATH")?;
547
548    find_binary_in_path(binary_name, &path)
549}
550
551fn find_binary_in_path(binary_name: &str, path: &std::ffi::OsStr) -> Option<PathBuf> {
552    let binary_name = format!("{binary_name}{}", std::env::consts::EXE_SUFFIX);
553
554    std::env::split_paths(&path)
555        .map(|dir| dir.join(&binary_name))
556        .find(|candidate| is_executable_file(candidate))
557}
558
559#[cfg(unix)]
560fn is_executable_file(path: &Path) -> bool {
561    use std::os::unix::fs::PermissionsExt;
562
563    path.is_file()
564        && path
565            .metadata()
566            .map(|metadata| metadata.permissions().mode() & 0o111 != 0)
567            .unwrap_or(false)
568}
569
570#[cfg(not(unix))]
571fn is_executable_file(path: &Path) -> bool {
572    path.is_file()
573}
574
575fn resolve_existing_runner_path(path: &Path) -> Option<PathBuf> {
576    if path.exists() {
577        return Some(path.to_path_buf());
578    }
579    if path.is_absolute() {
580        return None;
581    }
582    workspace_root()
583        .map(|root| root.join(path))
584        .filter(|candidate| candidate.exists())
585}
586
587fn workspace_root() -> Option<PathBuf> {
588    PathBuf::from(env!("CARGO_MANIFEST_DIR"))
589        .parent()
590        .and_then(|path| path.parent())
591        .map(Path::to_path_buf)
592}
593
594fn parse_runner_return_value(value: &str) -> Result<Value> {
595    let value = value.trim();
596    if value == "true" {
597        return Ok(Value::Bool(true));
598    }
599    if value == "false" {
600        return Ok(Value::Bool(false));
601    }
602    if value == "()" || value == "Unit" {
603        return Ok(Value::Unit);
604    }
605    if let Ok(value) = value.parse::<i64>() {
606        return Ok(Value::I64(value));
607    }
608    if let Ok(value) = value.parse::<u64>() {
609        return Ok(Value::U64(value));
610    }
611    if let Ok(value) = value.parse::<f64>() {
612        return Ok(Value::Float(value));
613    }
614    Ok(Value::String(value.to_owned()))
615}
616
617fn hex_encode(bytes: &[u8]) -> String {
618    const HEX: &[u8; 16] = b"0123456789abcdef";
619    let mut out = String::with_capacity(bytes.len() * 2);
620    for byte in bytes {
621        out.push(HEX[(byte >> 4) as usize] as char);
622        out.push(HEX[(byte & 0x0f) as usize] as char);
623    }
624    out
625}
626
627fn sdk_value_to_vm_value(
628    vm: &mut stoffel_vm::core_vm::VirtualMachine,
629    value: &Value,
630) -> Result<VmValue> {
631    match value {
632        Value::I64(value) => Ok(VmValue::I64(*value)),
633        Value::U64(value) => Ok(VmValue::U64(*value)),
634        Value::Bool(value) => Ok(VmValue::Bool(*value)),
635        Value::Float(value) => Ok(VmValue::Float((*value).into())),
636        Value::String(value) => Ok(VmValue::String(value.clone())),
637        Value::Bytes(_) => Err(Error::InvalidInput(
638            "byte inputs are only supported for local coordinator client inputs".to_owned(),
639        )),
640        Value::List(values) => {
641            let array_ref = vm
642                .create_array_ref(values.len())
643                .map_err(|error| Error::Computation(error.to_string()))?;
644            let values = values
645                .iter()
646                .map(|value| sdk_value_to_vm_value(vm, value))
647                .collect::<Result<Vec<_>>>()?;
648            vm.push_array_ref_values(array_ref, &values)
649                .map_err(|error| Error::Computation(error.to_string()))?;
650            Ok(VmValue::from(array_ref))
651        }
652        Value::Object(fields) => {
653            let object_ref = vm
654                .create_object_ref()
655                .map_err(|error| Error::Computation(error.to_string()))?;
656            let table_ref = TableRef::from(object_ref);
657            for (name, field_value) in fields {
658                let field_value = sdk_value_to_vm_value(vm, field_value)?;
659                vm.set_table_field(table_ref, VmValue::String(name.clone()), field_value)
660                    .map_err(|error| Error::Computation(error.to_string()))?;
661            }
662            Ok(VmValue::from(object_ref))
663        }
664        Value::Unit => Ok(VmValue::Unit),
665    }
666}
667
668fn sdk_value_from_vm_value(
669    vm: &mut stoffel_vm::core_vm::VirtualMachine,
670    value: VmValue,
671    active_tables: &mut HashSet<TableRef>,
672    depth: usize,
673) -> Result<Value> {
674    const MAX_TABLE_DEPTH: usize = 32;
675
676    match value {
677        VmValue::Array(array_ref) => {
678            if depth >= MAX_TABLE_DEPTH {
679                return Err(Error::Computation(format!(
680                    "VM array output exceeds maximum SDK conversion depth of {MAX_TABLE_DEPTH}"
681                )));
682            }
683            let table_ref = TableRef::from(array_ref);
684            if !active_tables.insert(table_ref) {
685                return Err(Error::Computation(format!(
686                    "VM array output contains a cycle at array ref {}",
687                    array_ref.id()
688                )));
689            }
690            let len = vm
691                .read_array_ref_len(array_ref)
692                .map_err(|error| Error::Computation(error.to_string()))?;
693            let mut values = Vec::with_capacity(len);
694            for index in 0..len {
695                let index = i64::try_from(index).map_err(|_| {
696                    Error::Computation("VM array index cannot be represented as int64".to_owned())
697                })?;
698                let item = vm
699                    .read_table_field(TableRef::from(array_ref), &VmValue::I64(index))
700                    .map_err(|error| Error::Computation(error.to_string()))?
701                    .ok_or_else(|| {
702                        Error::Computation(format!("VM array is missing element at index {index}"))
703                    })?;
704                values.push(sdk_value_from_vm_value(vm, item, active_tables, depth + 1)?);
705            }
706            active_tables.remove(&table_ref);
707            Ok(Value::List(values))
708        }
709        VmValue::Object(object_ref) => {
710            if depth >= MAX_TABLE_DEPTH {
711                return Err(Error::Computation(format!(
712                    "VM object output exceeds maximum SDK conversion depth of {MAX_TABLE_DEPTH}"
713                )));
714            }
715            let table_ref = TableRef::from(object_ref);
716            if !active_tables.insert(table_ref) {
717                return Err(Error::Computation(format!(
718                    "VM object output contains a cycle at object ref {}",
719                    object_ref.id()
720                )));
721            }
722            let len = vm
723                .read_object_ref_len(object_ref)
724                .map_err(|error| Error::Computation(error.to_string()))?;
725            let entries = vm
726                .read_object_ref_entries(object_ref, len)
727                .map_err(|error| Error::Computation(error.to_string()))?;
728            let mut fields = std::collections::BTreeMap::new();
729            for (key, value) in entries {
730                let VmValue::String(key) = key else {
731                    return Err(Error::Computation(format!(
732                        "VM object output contains a non-string field key: {key:?}"
733                    )));
734                };
735                fields.insert(
736                    key,
737                    sdk_value_from_vm_value(vm, value, active_tables, depth + 1)?,
738                );
739            }
740            active_tables.remove(&table_ref);
741            Ok(Value::Object(fields))
742        }
743        value => Value::from_vm_value(value).ok_or_else(|| {
744            Error::Computation(
745                "VM returned a value that cannot be represented by the public SDK Value type"
746                    .to_owned(),
747            )
748        }),
749    }
750}
751
752#[cfg(test)]
753mod tests {
754    use super::{
755        find_binary_in_path, local_program_output_without_return_markers, path_is_under,
756        target_profile_dir_from_exe,
757    };
758    use std::path::PathBuf;
759
760    #[test]
761    fn local_program_output_filter_removes_runner_return_markers() {
762        let stdout = "polynomial p\nProgram returned: ()\n";
763
764        assert_eq!(
765            local_program_output_without_return_markers(stdout),
766            "polynomial p\n"
767        );
768    }
769
770    #[test]
771    fn find_binary_in_path_finds_executable_file() {
772        let missing_dir = tempfile::tempdir().unwrap();
773        let bin_dir = tempfile::tempdir().unwrap();
774        let binary = bin_dir
775            .path()
776            .join(format!("stoffel-run{}", std::env::consts::EXE_SUFFIX));
777        std::fs::write(&binary, b"#!/bin/sh\n").unwrap();
778
779        #[cfg(unix)]
780        {
781            use std::os::unix::fs::PermissionsExt;
782
783            let mut permissions = std::fs::metadata(&binary).unwrap().permissions();
784            permissions.set_mode(0o755);
785            std::fs::set_permissions(&binary, permissions).unwrap();
786        }
787
788        let path = std::env::join_paths([missing_dir.path(), bin_dir.path()]).unwrap();
789
790        assert_eq!(find_binary_in_path("stoffel-run", &path), Some(binary));
791    }
792
793    #[test]
794    fn target_profile_dir_from_test_exe_uses_parent_of_deps_dir() {
795        let exe: PathBuf = ["workspace", "target", "debug", "deps", "sdk_usage-abc"]
796            .iter()
797            .collect();
798
799        assert_eq!(
800            target_profile_dir_from_exe(&exe),
801            Some(["workspace", "target", "debug"].iter().collect())
802        );
803    }
804
805    #[test]
806    fn path_is_under_detects_workspace_target_executables() {
807        let exe: PathBuf = ["workspace", "target", "debug", "deps", "sdk_usage-abc"]
808            .iter()
809            .collect();
810        let target: PathBuf = ["workspace", "target"].iter().collect();
811        let other: PathBuf = ["workspace", "other-target"].iter().collect();
812
813        assert!(path_is_under(&exe, &target));
814        assert!(!path_is_under(&exe, &other));
815    }
816}