Embedding Ruby Code in an Executable Using Artichoke

Recently, I explored how to embed Ruby code within an executable binary using Artichoke, an implementation of the Ruby language written in Rust. In this post, I’ll share my experience and provide a step-by-step guide so you can do the same. By the end, you’ll be able to create your own executable that includes and runs embedded Ruby code.

Note: The steps in this tutorial are based on guidance provided by the creator of Artichoke in this comment on a related GitHub issue

Prerequisites

To follow along, you’ll need to have Rust installed. Visit rustup.rs for installation instructions. In this tutorial, I used Ubuntu Linux, but you can adapt the commands to your operating system.

Setting Up the Project

Start by creating a new Rust project:

cargo new binrubyscript
cd binrubyscript

This command creates a new directory called binrubyscript with a basic Rust project template.

Adding Dependencies

We need to add two dependencies to our project:

  • artichoke: The Ruby interpreter written in Rust.
  • rust-embed: A tool to embed static files into the binary.

Add rust-embed to your Cargo.toml:

cargo add rust-embed

Then, add Artichoke directly from the Git repository:

cargo add --git https://github.com/artichoke/artichoke artichoke

Note: Artichoke is still in pre-release, so we’re using the version directly from the Git repository.

Your Cargo.toml should now look like this:

[package]
name = "binrubyscript"
version = "0.1.0"
edition = "2021"

[dependencies]
rust-embed = "8.5.0"
artichoke = { git = "https://github.com/artichoke/artichoke" }

Embedding Ruby Files into the Binary

Creating the Ruby Files

Create a directory to store your embedded Ruby files:

mkdir -p src/embedded_ruby

Create the file src/embedded_ruby/binrubyscript.rb with the following content:

#!/usr/bin/env ruby

puts "Hello World from embedded Ruby script using Artichoke! (#{__FILE__})"

Configuring Rust Embed

In the src/main.rs file, add the following code to configure Rust Embed and embed the Ruby files:

use rust_embed::RustEmbed;

// Define the Ruby files to be embedded
#[derive(RustEmbed)]
#[folder = "src/embedded_ruby"]
struct Sources;

This code defines a Sources struct that will embed all files in the src/embedded_ruby directory into the binary.

Initializing the Artichoke Interpreter

In src/main.rs, import the Artichoke prelude and instantiate the interpreter:

use artichoke::prelude::*;

fn main() -> Result<(), Box<dyn std::error::Error>> {
    println!("Hello World from pure Rust!");

    let mut interp = artichoke::interpreter()?;
    Ok(())
}

Here, we modify the main function to return a Result, which allows us to use the ? operator for error handling. Then, we instantiate the Artichoke interpreter.

Loading the Embedded Ruby Files into the Interpreter

Add an init function to load the embedded files into the Artichoke virtual file system:

// Function to load the embedded Ruby files into the Artichoke interpreter
fn init(interp: &mut Artichoke) -> Result<(), Error> {
    for source in Sources::iter() {
        if let Some(content) = Sources::get(&source) {
            interp.def_rb_source_file(&*source, content.data)?;
        }
    }
    Ok(())
}

fn main() -> Result<(), Box<dyn std::error::Error>> {
    println!("Hello World from pure Rust!");

    let mut interp = artichoke::interpreter()?;
    init(&mut interp)?;
    Ok(())
}

Here, the init function iterates over all embedded files and registers them with the interpreter.

Loading and Executing the Ruby Script

Now, let’s modify the main function to load and execute the embedded Ruby script:

use std::path::Path;

// ...

fn main() -> Result<(), Box<dyn std::error::Error>> {

    // ...

    // Load and execute the embedded Ruby script
    match interp.eval_file(Path::new("binrubyscript.rb")) {
        Ok(_) => (),
        Err(err) => {
            eprintln!("Error executing Ruby script: {}", err);
        }
    }

    Ok(())
}

Here, we use interp.eval_file to evaluate the binrubyscript.rb file that was embedded.

Handling Errors from the Ruby Script

To ensure the program exits with an error code if the Ruby script fails, let’s modify the main function:

use std::process;

fn main() -> Result<(), Box<dyn std::error::Error>> {

    // ...

    // Load and execute the embedded Ruby script
    match interp.eval_file(Path::new("binrubyscript.rb")) {
        Ok(_) => (),
        Err(err) => {
            eprintln!("Error executing Ruby script: {}", err);
            process::exit(1); // Exit with error code 1
        }
    }

    Ok(())
}

Now, if an error occurs while executing the Ruby script, the program will exit with a non-zero error code.

Final main.rs File

use artichoke::prelude::*;
use rust_embed::RustEmbed;
use std::path::Path; // Import Path
use std::process;

// The Ruby source code to be embedded
#[derive(Debug, Clone, Copy, Hash, PartialEq, Eq, PartialOrd, Ord, RustEmbed)]
#[folder = "src/embedded_ruby"]
pub struct Sources;

/// Load Ruby sources into the Artichoke virtual file system.
///
/// # Errors
///
/// If an exception is raised on the Artichoke interpreter, it is returned.
pub fn init(interp: &mut Artichoke) -> Result<(), Error> {
    for source in Sources::iter() {
        if let Some(content) = Sources::get(&source) {
            interp.def_rb_source_file(&*source, content.data)?;
        }
    }
    Ok(())
}

fn main() -> Result<(), Box<dyn std::error::Error>> {
    println!("Hello World from pure Rust!");

    let mut interp = artichoke::interpreter()?;
    init(&mut interp)?;

    // Now load and run the Ruby script
    match interp.eval_file(Path::new("binrubyscript.rb")) {
        Ok(_) => (),
        Err(err) => {
            eprintln!("Error executing Ruby script: {}", err);
            process::exit(1); // Exit with code 1
        }
    }

    Ok(())
}

Adding a Secondary Ruby File

To test whether multiple files are correctly embedded and loaded, let’s add a secondary Ruby file.

Creating the Secondary File

Create the file src/embedded_ruby/lib/secondary.rb with the following content:

puts "Hello World from secondary.rb (#{__FILE__})"

Modify binrubyscript.rb to require this file:

#!/usr/bin/env ruby

require_relative "lib/secondary.rb"

puts "Hello World from embedded Ruby script using Artichoke! (#{__FILE__})"

Testing the Project

Now we can compile and run our project to see everything in action:

cargo build --release
cargo run

The expected output should be similar to:

Hello World from pure Rust!
Hello World from secondary.rb (lib/secondary.rb)
Hello World from embedded Ruby script using Artichoke! (binrubyscript.rb)

This confirms that the embedded Ruby script was successfully executed, including the required secondary file.

Testing Error Handling

To check how the program handles errors in the Ruby script, modify binrubyscript.rb by adding a line that raises an exception:

raise "An intentional error for testing."

Run the program again:

cargo run

The output should contain the message:

Error executing Ruby script: Ruby exception: An intentional error for testing.

And the program’s exit code will be non-zero, indicating that an error occurred.

Conclusion

By following these steps, you’ve learned how to embed and execute Ruby code within a Rust executable using Artichoke. This process involved adding dependencies, configuring file embedding, initializing the Artichoke interpreter, loading and executing the Ruby code, and handling errors.

Important: The embedded Ruby scripts are included in their original, plain-text form within the binary. They are neither obfuscated nor compiled into bytecode.

Complete Source Code

The complete source code for this project is available on GitHub:

Feel free to clone the repository and experiment on your own.

References


I hope this guide has been helpful. If you have any questions or suggestions, please leave a comment below!

Deixe uma resposta

Esse site utiliza o Akismet para reduzir spam. Aprenda como seus dados de comentários são processados.