Writing My First Rust App
Table of Contents
Introduction to Rust⌗
Rust is starting to become the go-to language for developing system-level code. It is fast, guarantees memory safety and thread safety and comes with a good package manager. It has also recently made inroads into the linux kernel, allowing the development of kernel programs in rust.
The ownership model of rust which guarantees memory safety without the use of a garbage collector is astounding. It means that you can write rust code and forget about remembering references or worrying about memory safety. So, let’s understand the rust ownership model briefly.
Ownership oversees how a rust program uses memory. During compile time, the rust compiler uses a set of rules to verify if the ownership model is used properly in the rust code. A program following this defined set of rules is guaranteed to be memory safe. As ownership is checked at compile time, the speed of the program is not affected during runtime while maintaining memory safety.
The ownership rules are as follows,
- Each value in rust has an owner
- There can only be one owner
- When the owner gets out of scope, the value will be dropped.
Let’s see an example of how this works,
First, Let’s take an example c code that requires us to call free everytime we allocate memory from the heap.
// A simple program that allocates memory dynamically
#include<stdio.h>
#include<stdlib.h>
// Free a pointer memory
void free_memory(int *ptr) {
free(ptr);
}
int main()
{
int* ptr; // A pointer
int n = 5;
printf("ptr location before malloc: %p\n", ptr); // This should be null/uninitialized
ptr = (int*)malloc(n*sizeof(int)); // Allocate memory from the heap
printf("ptr location after malloc: %p\n", ptr); // Prints memory location
free_memory(ptr); // Free the memory
printf("ptr location after free: %p\n", ptr); // ptr still holds the old memory location
printf("This should not be possible, %d\n", ptr[0]); // this is trouble
return 0;
}
which when compiled with options -ansi
using gcc
compiler outputs,
ptr location before malloc: (nil)
ptr location after malloc: 0x5558fcabd6b0
ptr location after free: 0x5558fcabd6b0
This should not be possible, 0
In such a scenario, it becomes tidious and difficult to track memory allocation leading to a memory leaks and vulnerabilities. By using the ownership model rust avoids the entire class of memory related issues.
Here are some rust examples demonstrating how the ownership model is helpful.
/// Function to print a string
fn print(msg: String) {
println!("{msg}");
}
/// The main function
fn main() {
let hello = String::from("hello"); // Assign a string "hello" to hello variable
print(hello); // Call the print function on the string
println!("{hello}"); // Compile error, as the string has been consumed by the print function.
}
Let’s go through the lines to better understand what’s happening.
At line 2
, we define a function print
which takes in a String
and prints it. It essentially takes ownership of that string to print it. So, when we define a string and call print on it, its ownership is transferred from main
, and it cannot be accessed afterward. As the execution of the print completes, the passed string goes out of scope, rust calls drop
, and the memory is freed. So, the compiler would prevent any attempts to access that memory region after drop
.
Let’s rewrite the code so that print doesn’t take ownership of our string, and we can use it afterward.
/// Function to print a string
fn print(msg: &String) {
println!("{msg}");
}
/// The main function
fn main() {
let hello = String::from("hello"); // Assign a string "hello" to hello variable
print(&hello); // Call the print function on the string
println!("{hello}"); // Compiles
}
If you look closely, there is only a minor difference in the code. It’s the use of &
in lines 2 and 9. &
means that we pass a reference to the object, and the ownership is not transferred. The ownership stays with main, so it can be accessed at line 10.
Let’s look at another example.
let hello = String::from("hello"); // Assign a string "hello" to hello variable
let hellotoo = hello; // Assign hello to hellotoo
println!("{hello}"); // Compile error as memory ref by hello has been transferred to hellotoo
Here, the rust compiler considers hello to be invalid once line 2 has been called. So, no call to drop occurs for variable hello when it goes out of scope i.e. rust effectively performs a move
operation where hello is moved into hellotoo.
So, a natural question comes into mind, how does rust do shallow
or deep
copy? To perform this rust provides a clone
trait. A trait is a function that defines functionality for a type and can be shared/implemented by multiple types. For stack only data structures, it provides a copy
trait that trivially copies data on the stack. Any data structure where drop has been implemented (on the heap) must use clone instead of copy. clone trait can do either shallow or deep copy based on its implementation.
// Copy on the heap
let hello = String::from("hello"); // Assign a string "hello" to hello variable
let hellotoo = hello.clone(); // Create a deep copy of hello and assign it to hellotoo
println!("{hello}, {hellotoo}"); // is valid
// Copy on the stack
let x = 7; // Assign 7 to x variable
let y = x; // here x is copied into y
println!("{x}, {y}"); // is valid
This simple ownership model effectively prevents an entire class of memory-related vulnerabilities. You can read more about rust’s ownership model in the rust book. The idea of having memory safety guaranteed by the rust compiler makes it easier to focus on the core logic of the app and has been one of my major attractions towards writing rust code.
The idea⌗
Initially, I didn’t have any basic ideas that I would have liked to try out with rust. I was searching for things that would allow me to try out concepts such as cli
, logging
, and file i/o
for my first rust app. While browsing old python code that can be converted into rust code, I stumbled across a script that generates a kml
file for a set of given images. I thought this would be the perfect example code to write out in rust. This would require me to take user input using cli, find images in the provided locations, read gps information using exif metadata for those images and then write a kml file for all those images. The result being for the given set of images, you can generate a kml file that can be opened in google earth to visualize where the images were captured.
Sounds interesting!
Writing the app⌗
Initializing the app with cargo⌗
cargo
is the official package manager for rust. It provides a simple command, cargo new
to init new rust packages.
To create a new binary application demo
,
cargo new demo
This would create a new basic layout for a rust app that would look like this,
demo
ββCargo.toml
ββsrc
ββmain.rs
Cargo.toml
is a manifest file that stores metadata information needed to compile the package. Any dependencies or package metadata go here.
The file would look something like,
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[package]
name = "demo"
version = "0.1.0"
edition = "2021"
[dependencies]
Writing a basic CLI⌗
So, the first step was to start with a basic cli using rust. I found crate clap
. This provided a simple and easy way to write a cli.
To add this new package as a dependency, use
cargo add clap
This will add clap
as a dependency and able you to use it inside your package.
A basic cli looks like this,
// Import clap
use clap::error::ErrorKind;
use clap::{CommandFactory, Parser};
// Define commands
// Derive allows us to use traits. Traits can be used to provide basic implementations such as Clone (Allows cloning)
#[derive(Parser)]
#[command(author, version, about, long_about = None)]
struct Cli {
#[arg(short, long, default_value_t = ("output.kml").to_string())]
/// The output kml location
kml_file: String, // This is an optional argument
/// The list of images locations can be a combination of files and directories
images: Vec<String>, // This is a positional argument
}
fn main() {
let cli = Cli::parse(); // Parse the inputs
// Verify that at least one image is provided
if cli.images.is_empty() {
let mut cmd = Cli::command();
cmd.error(
ErrorKind::MissingRequiredArgument,
"You must provide multiple images path or a directory of images or both",
)
.exit()
}
println!("Provided images arg: {:?}", cli.images); // Print the provided paths
}
Here, we expect image locations as positional inputs, and if nothing is provided, an error is thrown. This more or less summarizes the minimal cli we need for this app.
Adding logging⌗
So, next, let’s add logging. I tried a few crates namely, env_logger, simple_logger, flexi_logger and simplelog. I settled for simplelog
as it provided me the flexibility of using both console and file logging at the same time while being extremely simple (true to its name π).
For logging, I use the log
library crate from rust. It is only a facade (it doesn’t output anything π€·ββοΈ) so we need an external logging library that can output these log messages.
Writing the logic⌗
Finally, I worked on the logic. The logic is pretty simple; I need to find all image files in the provided locations. For that, I enumerate through the provided locations and check if a given path has a valid image extension. All paths with a valid extension are stored as a part of the vector. Once I have the paths, I enumerate all the paths and read the EXIF metadata using kamadak-exif
. I found it straightforward to use and provide support for different file formats and EXIF tags. Once I have the metadata, I only extract the gps information. Once, I have the GPS information I use quick-xml
to write the kml file as kml is xml data with special tags.
For the complete logic please refer to the code
That’s it. The app is ready. π
Checking the results⌗
We can use cargo run
to build and run our app in debug mode. For compiling an optimized version you can build it using cargo build --release
.
Running is as simple as,
cargo run -- DSCN0010.jpg
which would generate a kml file called output.kml
. A sample image, DSCN0010.jpg
was taken for which the kml looks like,
For all the CLI options, you can run cargo run -- --help
.
When building release binary, you can find the resulting binary at target/release
.
Packaging and publishing the app using crates.io π⌗
Now that the app is ready we can package it and upload it to crates.io. Packaging the app is very simple. Cargo.toml
makes it extremely easy to specify additional metadata information about the package. You need some basic metadata such as authors
, description
, readme
, license
, keywords
and categories
for app to be publishable to crates.io.
To test your app packaging you can run,
cargo build --release
This would build a release and optimized version of your app which you can run and test the functionality. We can also write tests but maybe I would cover that in another post.
To publish to cargo you need an account in crates.io
which would give you an API key. Once you have your API key publishing is effortless. Just run,
cargo publish
and your app would be visible in the crates index.
Conclusion⌗
It was enjoyable and sometimes frustrating to code in rust. As I mostly use python for my regular programming projects and my job it’s extremely difficult initially to specify types for everything. But to my help, the rust compiler is verbose and helped me fix my errors without searching everything on the internet. clippy also provides valuable suggestions that can help improve rust code. Once you have written some functions and they compile, you will find that rust code is straighforward to understand, and you get the hang of the language. Also, the rust-analyzer is a lifesaver when starting out with rust as it is outstanding in suggesting errors and almost always helped me write faster code.
The code for the app is available at GitHub
and is also installable through cargo using crates.io
. Star this project on github if you like it, and do follow me on GitHub and this blog for updates.
If you’re considering starting coding is rust, I hope this blog motivates you to write your first app in rust.
Cheers! π»