harn-vm 0.10.29

Async bytecode virtual machine for the Harn programming language
Documentation
use std::collections::BTreeMap;

use crate::stdlib::macros::harn_builtin;
use crate::value::{VmDictExt, VmError, VmValue};

use super::{
    io_error_value, io_error_value_with_kind, queue_file_edited_for, resolve_fs_path, result_err,
    result_ok, FILE_TEXT_CACHE,
};

fn option_error(message: impl AsRef<str>) -> VmValue {
    io_error_value_with_kind("invalid_input", message)
}

fn parse_options(
    args: &[VmValue],
) -> Result<crate::conditional_replace::ConditionalReplaceOptions, VmValue> {
    let mut options = crate::conditional_replace::ConditionalReplaceOptions {
        durability: crate::atomic_io::AtomicWriteDurability::Namespace,
        ..crate::conditional_replace::ConditionalReplaceOptions::default()
    };
    let Some(value) = args.get(2) else {
        return Ok(options);
    };
    if matches!(value, VmValue::Nil) {
        return Ok(options);
    }
    let VmValue::Dict(values) = value else {
        return Err(option_error("replace options must be a dict"));
    };
    for key in values.keys() {
        if !matches!(
            key.as_str(),
            "expected_sha256" | "create" | "overwrite" | "create_parents" | "durability"
        ) {
            return Err(option_error(format!("unknown replacement option '{key}'")));
        }
    }
    if let Some(value) = values.get("expected_sha256") {
        let VmValue::String(digest) = value else {
            return Err(option_error("expected_sha256 must be a string"));
        };
        let hex = digest.strip_prefix("sha256:").unwrap_or_default();
        if hex.len() != 64
            || !hex
                .bytes()
                .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
        {
            return Err(option_error(
                "expected_sha256 must use lowercase sha256:<64 hex digits>",
            ));
        }
        options.expected_sha256 = Some(digest.to_string());
    }
    for (key, target) in [
        ("create", &mut options.create),
        ("overwrite", &mut options.overwrite),
        ("create_parents", &mut options.create_parents),
    ] {
        if let Some(value) = values.get(key) {
            let VmValue::Bool(value) = value else {
                return Err(option_error(format!("{key} must be a bool")));
            };
            *target = *value;
        }
    }
    if let Some(value) = values.get("durability") {
        let VmValue::String(value) = value else {
            return Err(option_error("durability must be a string"));
        };
        options.durability = match value.as_str() {
            "namespace" => crate::atomic_io::AtomicWriteDurability::Namespace,
            "flush" => crate::atomic_io::AtomicWriteDurability::Flush,
            _ => {
                return Err(option_error("durability must be 'namespace' or 'flush'"));
            }
        };
    }
    Ok(options)
}

fn receipt_value(
    receipt: crate::conditional_replace::ConditionalReplaceReceipt,
    durability: crate::atomic_io::AtomicWriteDurability,
) -> VmValue {
    let mut value = BTreeMap::new();
    value.put_str("status", receipt.status.as_str());
    value.put_bool("before_exists", receipt.before_exists);
    value.put_str("before_sha256", receipt.before_sha256);
    value.put_str("after_sha256", receipt.after_sha256);
    value.put_opt_str("expected_sha256", receipt.expected_sha256);
    value.put_int(
        "bytes_written",
        i64::try_from(receipt.bytes_written).unwrap_or(i64::MAX),
    );
    value.put_str(
        "durability",
        match durability {
            crate::atomic_io::AtomicWriteDurability::Namespace => "namespace",
            crate::atomic_io::AtomicWriteDurability::Flush => "flush",
        },
    );
    value.put_bool("file_synced", receipt.file_synced);
    value.put_bool("namespace_synced", receipt.namespace_synced);
    VmValue::dict(value)
}

fn replace_value(builtin: &str, args: &[VmValue], bytes: &[u8]) -> Result<VmValue, VmValue> {
    let path = args.first().map(VmValue::display).unwrap_or_default();
    let resolved = resolve_fs_path(&path);
    crate::stdlib::sandbox::enforce_fs_path(
        builtin,
        &resolved,
        crate::stdlib::sandbox::FsAccess::Write,
    )
    .map_err(|error| match error {
        VmError::Thrown(value) => value,
        VmError::CategorizedError { message, category } => {
            let mut failure = BTreeMap::new();
            failure.put_str("error", "io_error");
            failure.put_str("kind", "sandbox_denied");
            failure.put_str("message", message);
            failure.put_str("category", category.as_str());
            VmValue::dict(failure)
        }
        other => option_error(other.to_string()),
    })?;
    let options = parse_options(args)?;
    let receipt =
        crate::testbench::overlay_fs::helpers::replace_scoped(builtin, &resolved, bytes, &options)
            .map_err(|error| {
                io_error_value(
                    format!("Failed to replace file {}: {error}", resolved.display()),
                    &error,
                )
            })?;
    if matches!(
        receipt.status,
        crate::conditional_replace::ConditionalReplaceStatus::Created
            | crate::conditional_replace::ConditionalReplaceStatus::Replaced
    ) {
        FILE_TEXT_CACHE.with(|cache| {
            cache.borrow_mut().remove(&resolved);
        });
        queue_file_edited_for(&resolved, "replace", receipt.bytes_written);
    }
    Ok(receipt_value(receipt, options.durability))
}

fn replace_text_value(builtin: &str, args: &[VmValue]) -> Result<VmValue, VmValue> {
    let content = args.get(1).map(VmValue::display).unwrap_or_default();
    replace_value(builtin, args, content.as_bytes())
}

fn replace_bytes_value(builtin: &str, args: &[VmValue]) -> Result<VmValue, VmValue> {
    let Some(VmValue::Bytes(content)) = args.get(1) else {
        return Err(option_error("replacement content must be bytes"));
    };
    replace_value(builtin, args, content)
}

#[harn_builtin(
    sig = "replace_file(path: string, content: string, options?: dict) -> dict",
    category = "fs",
    doc = "Atomically replace text when an optional observed digest still matches."
)]
fn replace_file_builtin(args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
    replace_text_value("replace_file", args).map_err(VmError::Thrown)
}

#[harn_builtin(
    sig = "replace_file_result(path: string, content: string, options?: dict) -> Result<dict, dict>",
    category = "fs",
    doc = "Result form of replace_file."
)]
fn replace_file_result_builtin(args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
    Ok(match replace_text_value("replace_file_result", args) {
        Ok(receipt) => result_ok(receipt),
        Err(failure) => result_err(failure),
    })
}

#[harn_builtin(
    sig = "replace_file_bytes(path: string, content: bytes, options?: dict) -> dict",
    category = "fs",
    doc = "Atomically replace bytes when an optional observed digest still matches."
)]
fn replace_file_bytes_builtin(args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
    replace_bytes_value("replace_file_bytes", args).map_err(VmError::Thrown)
}

#[harn_builtin(
    sig = "replace_file_bytes_result(path: string, content: bytes, options?: dict) -> Result<dict, dict>",
    category = "fs",
    doc = "Result form of replace_file_bytes."
)]
fn replace_file_bytes_result_builtin(
    args: &[VmValue],
    _out: &mut String,
) -> Result<VmValue, VmError> {
    Ok(
        match replace_bytes_value("replace_file_bytes_result", args) {
            Ok(receipt) => result_ok(receipt),
            Err(failure) => result_err(failure),
        },
    )
}