blockifier_test_utils 0.18.0-rc.0

Test utilities for the blockifier.
Documentation
use std::fs::{self, File};
use std::io::Write;
use std::path::{Path, PathBuf};
use std::process::{Command, Output, Stdio};

use apollo_infra_utils::cairo0_compiler::Cairo0Script;
use apollo_infra_utils::cairo0_compiler_test_utils::verify_cairo0_compiler_deps;
use apollo_infra_utils::cairo_compiler_version::CAIRO1_COMPILER_VERSION;
use apollo_infra_utils::path::{project_path, resolve_project_relative_path};
use tempfile::NamedTempFile;
use tracing::info;

pub enum CompilationArtifacts {
    Cairo0 { casm: Vec<u8> },
    Cairo1 { casm: Vec<u8>, sierra: Vec<u8> },
}

pub fn cairo1_compiler_tag() -> String {
    format!("v{CAIRO1_COMPILER_VERSION}")
}

/// Path to local compiler package directory, of the specified version.
fn cairo1_package_dir(version: &String) -> PathBuf {
    project_path().unwrap().join(format!("target/bin/cairo_package__{version}"))
}

/// Path to starknet-compile binary, of the specified version.
fn starknet_compile_binary_path(version: &String) -> PathBuf {
    cairo1_package_dir(version).join("cairo/bin/starknet-compile")
}

/// Path to starknet-sierra-compile binary, of the specified version.
fn starknet_sierra_compile_binary_path(version: &String) -> PathBuf {
    cairo1_package_dir(version).join("cairo/bin/starknet-sierra-compile")
}

/// Returns the path to the allowed_libfuncs.json file.
pub fn allowed_libfuncs_json_path() -> String {
    resolve_project_relative_path("crates/apollo_compile_to_casm/src/allowed_libfuncs.json")
        .unwrap()
        .to_string_lossy()
        .to_string()
}

/// Returns the path to the legacy-format allowed_libfuncs_legacy.json file (array of strings).
///
/// Older compiler versions (e.g. v2.1.0, v2.7.0) cannot parse the new map-based format. This
/// file is committed to the repo and kept in sync via an `expect_file!` test.
pub fn allowed_libfuncs_legacy_json_path() -> String {
    resolve_project_relative_path(
        "crates/blockifier_test_utils/resources/allowed_libfuncs_legacy.json",
    )
    .unwrap()
    .to_string_lossy()
    .to_string()
}

/// Converts the new-format allowed_libfuncs.json (map) to the legacy format (array of strings).
pub fn generate_allowed_libfuncs_legacy_json() -> String {
    let new_format_path = allowed_libfuncs_json_path();
    let contents = std::fs::read_to_string(&new_format_path)
        .unwrap_or_else(|err| panic!("Failed to read {new_format_path}: {err}"));
    let parsed: serde_json::Value = serde_json::from_str(&contents)
        .unwrap_or_else(|err| panic!("Failed to parse {new_format_path}: {err}"));
    let libfuncs_map = parsed["allowed_libfuncs"]
        .as_object()
        .unwrap_or_else(|| panic!("Expected 'allowed_libfuncs' to be a map in {new_format_path}"));
    let keys: Vec<&str> = libfuncs_map.keys().map(|k| k.as_str()).collect();
    serde_json::json!({"allowed_libfuncs": keys}).to_string()
}

/// Downloads the cairo package to the local directory.
/// Creates the directory if it does not exist.
fn download_cairo_package(version: &String) {
    let directory = cairo1_package_dir(version);
    info!("Downloading Cairo package to {directory:?}.");
    std::fs::create_dir_all(&directory).unwrap();

    // Download the artifact.
    let filename = "release-x86_64-unknown-linux-musl.tar.gz";
    let package_url =
        format!("https://github.com/starkware-libs/cairo/releases/download/v{version}/{filename}");
    let curl_result = run_and_verify_output(Command::new("curl").args(["-L", &package_url]));
    let mut tar_command = Command::new("tar")
        .args(["-xz", "-C", directory.to_str().unwrap()])
        .stdin(Stdio::piped())
        .spawn()
        .unwrap();
    let tar_command_stdin = tar_command.stdin.as_mut().unwrap();
    tar_command_stdin.write_all(&curl_result.stdout).unwrap();
    let output = tar_command.wait_with_output().unwrap();
    if !output.status.success() {
        let stderr_output = String::from_utf8(output.stderr).unwrap();
        panic!("{stderr_output}");
    }
    info!("Done.");
}

fn cairo1_package_exists(version: &String) -> bool {
    let cairo_compiler_path = starknet_compile_binary_path(version);
    let sierra_compiler_path = starknet_sierra_compile_binary_path(version);
    cairo_compiler_path.exists() && sierra_compiler_path.exists()
}

/// Appends `.lock` to a path, used by `with_file_lock` callers to derive a lock file path.
pub(crate) fn lock_path_for(path: &Path) -> PathBuf {
    PathBuf::from(format!("{}.lock", path.display()))
}

/// Executes `action` at most once, guarded by a file lock at `lock_path`.
///
/// Uses double-checked locking: if `is_done()` returns `true` before acquiring the lock the
/// call is a no-op. After acquiring the lock `is_done()` is re-checked so that concurrent
/// callers that lost the race also skip `action`.
pub(crate) fn with_file_lock(lock_path: &Path, is_done: impl Fn() -> bool, action: impl FnOnce()) {
    if is_done() {
        return;
    }

    fs::create_dir_all(lock_path.parent().unwrap())
        .unwrap_or_else(|e| panic!("Failed to create directory for lock file: {e}"));
    let lock_file = File::create(lock_path)
        .unwrap_or_else(|e| panic!("Failed to create lock file {lock_path:?}: {e}"));
    lock_file.lock().unwrap_or_else(|e| panic!("Failed to acquire lock on {lock_path:?}: {e}"));

    if is_done() {
        return;
    }

    action();
}

/// Verifies that the Cairo1 package (of the given version) is available.
/// Attempts to download it if not. Uses a per-version file lock so concurrent callers (threads
/// or processes) never download the same package redundantly.
pub fn verify_cairo1_package(version: &String) {
    let dir = cairo1_package_dir(version);
    with_file_lock(
        &lock_path_for(&dir),
        || cairo1_package_exists(version),
        || {
            eprintln!(
                "[cairo_compile] Cairo 1 compiler v{version} not found locally, downloading..."
            );
            download_cairo_package(version);
            eprintln!("[cairo_compile] Cairo 1 compiler v{version} downloaded successfully.");
            assert!(cairo1_package_exists(version));
        },
    );
}

/// Runs a command. If it has succeeded, it returns the command's output; otherwise, it panics with
/// stderr output.
fn run_and_verify_output(command: &mut Command) -> Output {
    let output = command.output().unwrap();
    if !output.status.success() {
        let stderr_output = String::from_utf8(output.stderr).unwrap();
        panic!("{stderr_output}");
    }
    output
}

/// Compiles a Cairo0 program using the deprecated compiler.
pub fn cairo0_compile(
    path: String,
    extra_arg: Option<String>,
    debug_info: bool,
) -> CompilationArtifacts {
    let script_type = Cairo0Script::StarknetCompileDeprecated;
    verify_cairo0_compiler_deps(&script_type);
    let mut command = Command::new(script_type.script_name());
    command.arg(&path);
    if let Some(extra_arg) = extra_arg {
        command.arg(extra_arg);
    }
    if !debug_info {
        command.arg("--no_debug_info");
    }
    let compile_output = command.output().unwrap();
    let stderr_output = String::from_utf8(compile_output.stderr).unwrap();
    assert!(compile_output.status.success(), "{stderr_output}");
    CompilationArtifacts::Cairo0 { casm: compile_output.stdout }
}

pub enum LibfuncArg {
    ListName(String),
    ListFile(String),
}

impl LibfuncArg {
    pub fn add_to_command<'a>(&self, command: &'a mut Command) -> &'a mut Command {
        match self {
            Self::ListName(name) => command.args(["--allowed-libfuncs-list-name", name]),
            Self::ListFile(file) => command.args(["--allowed-libfuncs-list-file", file]),
        }
    }

    /// Returns the file path if this is a `ListFile` variant.
    pub fn file_path(&self) -> &str {
        match self {
            Self::ListFile(path) => path,
            Self::ListName(_) => panic!("LibfuncArg::ListName has no file path"),
        }
    }
}

/// Compiles a Cairo1 program using the compiler version set in the Cargo.toml.
pub fn cairo1_compile(
    path: String,
    version: String,
    libfunc_list_arg: LibfuncArg,
) -> CompilationArtifacts {
    assert!(cairo1_package_exists(&version));

    let sierra_output = starknet_compile(path, &version, &libfunc_list_arg);

    let mut temp_file = NamedTempFile::new().unwrap();
    temp_file.write_all(&sierra_output).unwrap();
    let temp_path_str = temp_file.into_temp_path();

    // Sierra -> CASM.
    let casm_output = starknet_sierra_compile(
        temp_path_str.to_str().unwrap().to_string(),
        &version,
        &libfunc_list_arg,
    );

    CompilationArtifacts::Cairo1 { casm: casm_output, sierra: sierra_output }
}

/// Compiles Cairo1 contracts into their Sierra version using the given compiler version.
/// Assumes the relevant compiler version was already downloaded.
pub fn starknet_compile(path: String, version: &String, libfunc_list_arg: &LibfuncArg) -> Vec<u8> {
    let mut starknet_compile_commmand = Command::new(starknet_compile_binary_path(version));
    starknet_compile_commmand.args(["--single-file", &path]);
    libfunc_list_arg.add_to_command(&mut starknet_compile_commmand);
    let sierra_output = run_and_verify_output(&mut starknet_compile_commmand);

    sierra_output.stdout
}

/// Compiles Sierra code into CASM using the given compiler version.
/// Assumes the relevant compiler version was already downloaded.
fn starknet_sierra_compile(
    path: String,
    version: &String,
    libfunc_list_arg: &LibfuncArg,
) -> Vec<u8> {
    let mut sierra_compile_command = Command::new(starknet_sierra_compile_binary_path(version));
    sierra_compile_command.args([&path]);
    libfunc_list_arg.add_to_command(&mut sierra_compile_command);
    let casm_output = run_and_verify_output(&mut sierra_compile_command);
    casm_output.stdout
}