Introduction
In this series of posts, we'll start to build a simple cli in Rust for decoding X.509 certificates. I'll try to keep it as beginner-friendly as possible, by explaining things as best I can when they may be unclear. Some basic Rust knowledge should hopefully be all you need. Feel free to ask any questions in the comments below!
📝 Note
If you are completely new to Rust and don't even have it installed, you can install it using rustup. You should hopefully still be able to follow along.
This first post will mainly focus on a Test Driven Development workflow. I wrote this as I was developing so mistakes or things which I overlooked and ended up factoring out afterward are all kept in.
Table of Contents
What are we building?
I generally use the openssl
cli for pulling information out of certificates. Specifically the x509
command, for example:
# get certificate
$ openssl s_client -connect google.com:443 2>/dev/null < /dev/null \
| sed -n '/BEGIN CERTIFICATE/,/END CERTIFICATE/p' > google.com.crt
# extract info
$ openssl x509 -text -noout -in google.com.crt | head -n 5
Certificate:
Data:
Version: 3 (0x2)
Serial Number:
1a:86:8b:0d:af:9b:c7:34:08:00:00:00:00:3e:bd:97
All this does is print out a certificate as a human-readable string. I've written about what information certificates contain here if interested. What we want to do in the next few posts is to see if we can build something similar to the openssl x509 -text
output from above in a Rust cli.
Some constraints to simplify the cli
Let's think about the API for this cli, how a user will call it. To keep things simple, it should just take a file path to a single certificate. So we should end up with a call like so:
$ cert-decoder /path/to/some/cert.pem
Another constraint to keep this simple at the beginning is the encoding of the certificate. Let's say the certificate must be PEM 1 (Privacy Enhanced Mail) encoded. This is the encoding you generally see certificates with. It's a string which begins with a line comprised of -----BEGIN CERTIFICATE-----
, then some base64 encoded DER (Distinguished Encoding Rules) and ends with a line comprised of -----END CERTIFICATE-----
. It looks as follows, with some of the base64 string stripped out and replaced with ...
:
-----BEGIN CERTIFICATE-----
MIIJTzCCCDegAwIBAgIQGoaLDa+bxzQIAAAAAD69lzANBgkqhkiG9w0BAQsFADBC
...
DMCTA95gzVKezFCaUidRU9UyHOFzltfYDt7HRlp7MwWoPLM=
-----END CERTIFICATE-----
Test list
We have a basic API and some constraints now. Let's make a test list before we do anything else. This is simply a list of tests we should write to cover some piece of functionality. Let's just cover the most simple thing initially, validating the arguments.
Validate args
- ☐ Only allow a single argument
- ☐ Argument is a path that exists
- ☐ Argument should be a single file
Implementing this we can start to use it and see where we should go next.
Validate args
First, create a new project with cargo new cert-decoder
. main.rs
should look as follows initially.
fn main() {
println!("Hello, world!")
}
Let's create a test for the first item on our test list.
fn main() {
println!("Hello, world!")
}
#[cfg(test)]
mod test {
#[test]
fn should_error_if_not_given_a_single_argument() {
// create fake args list, with no args
// run function with args
// check that it returns an error
}
}
Above I've outlined what we need to do in this test in comments. It's not easy to test this through the main
function as main
does not take any arguments in its signature. In Rust to read program arguments you use the function std::env::args, more on that later.
Let's create a new function called execute
. We know we want this function to return an error so we will make it return a Result.
fn execute(args: Vec<String>) -> Result<(), ()> {
Ok(())
}
fn main() {
println!("Hello, world!")
}
#[cfg(test)]
mod test {
#[test]
fn should_error_if_not_given_a_single_argument() {
// create fake args list, with no args
// run function with args
// check that it returns an error
}
}
For now execute just returns an Ok
result with a value of (). This is called the unit type. Now let's write our test.
fn execute(args: Vec<String>) -> Result<(), ()> {
Ok(())
}
fn main() {
println!("Hello, world!")
}
#[cfg(test)]
mod test {
use crate::execute;
#[test]
fn should_error_if_not_given_a_single_argument() {
let args = Vec::new();
let result = execute(args);
assert!(result.is_err());
}
}
If we run this with cargo -q test
2 it will fail. There will also be a warning about the args
parameter in execute
not being used, but we can ignore that for now.
➜ cargo -q test
...
running 1 test
F
failures:
---- test::should_error_if_not_given_a_single_argument stdout ----
thread 'test::should_error_if_not_given_a_single_argument' panicked at 'assertion failed: result.is_err()', src/ma
in.rs:18:9
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
failures:
test::should_error_if_not_given_a_single_argument
test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out
error: test failed, to rerun pass '--bin cert-decode'
Let's make it pass by making a tiny change.
fn execute(args: Vec<String>) -> Result<(), ()> {
Err(())
}
fn main() {
println!("Hello, world!")
}
#[cfg(test)]
mod test {
use crate::execute;
#[test]
fn should_error_if_not_given_a_single_argument() {
let args = Vec::new();
let result = execute(args);
assert!(result.is_err());
}
}
Let's re-run and it should be green.
➜ cargo -q test
...
running 1 test
.
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out
Great! Now we just need to make it return an error only in the case we mention in our first test list item. When we do not receive just a single argument.
fn execute(args: Vec<String>) -> Result<(), ()> {
if args.len() != 1 {
return Err(());
}
Ok(())
}
Re-run and it should still be green.
➜ cargo -q test
running 1 test
.
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out
Awesome! We should change this to return an error message. But first a quick note about this workflow.
A note on this workflow
This is how I generally write code. What's the smallest useful change I can make to add functionality? In this case, to get started it was argument validation. With that in mind, I decomposed this further into items that look like individually testable parts of the behavior we want in argument validation. I made a list of them so I can tick them off as I go. This helps to keep the focus on a single small piece of functionality. It's especially useful when decomposing complex changes.
To implement, write a test that specifies the behavior of one of the items. Then, make it fail, make it pass with any code, change to implement the real behavior, and finally refactor where appropriate.
Add an error message
Let's improve the argument length check by returning a useful error message. One thing I always try to keep in mind is to make the error message actionable. For example, here we could just say "Error: did not receive a single argument.". However, it would be better to also add an action - "Error: did not receive a single argument. Please invoke cert-decoder as follows: './cert-decoder /path/to/cert'.". Not only are we telling a user what went wrong, but we are also telling them how to fix it.
Let's update our test.
#[test]
fn should_error_if_not_given_a_single_argument() {
// arrange
let args = Vec::new();
// act
let result = execute(args);
// assert
assert!(result.is_err());
assert_eq!(
result.err().unwrap(),
format!(
"{}{}",
"Error: did not receive a single argument, ",
"please invoke cert-decoder as follows: ./cert-decoder /path/to/cert."
)
);
}
📝 Note
result.err() will return an Option. The
Option
will beSome
if the value of theResult
isErr
. ThisSome
will contain the same valueErr
contained. TheOption
will beNone
if theResult
isOk
.In this case we know for certain the
Result
's value isErr
as we asserted it before with is_err. So theOption
returned fromresult.err()
will beSome
and we can safely call unwrap on thatSome
to get the value it contains.
This won't compile because we are trying to compare two different types now. result.err().unwrap()
will return ()
and we are trying to compare this to a string. So we need to update the return type of execute
. We could also add the correct return value, the string we are asserting execute
returns. But I like to do things in tiny changes so let's just update the type first and return an empty string.
fn execute(args: Vec<String>) -> Result<(), String> {
if args.len() != 1 {
return Err("".to_owned());
}
Ok(())
}
Now if we run cargo -q test
it will compile but the test will fail.
➜ cargo -q test
running 1 test
F
failures:
---- test::should_error_if_not_given_a_single_argument stdout ----
thread 'test::should_error_if_not_given_a_single_argument' panicked at 'assertion failed: `(left == right)`
left: `""`,
right: `"Error: did not receive a single argument, please invoke cert-decoder as follows: ./cert-decoder /path/to/cert"`', src/main.rs:27:9
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
...
So let's make some changes.
fn execute(args: Vec<String>) -> Result<(), String> {
if args.len() != 1 {
let error = format!(
"{}{}",
"Error: did not receive a single argument, ",
"please invoke cert-decoder as follows: ./cert-decoder /path/to/cert."
);
return Err(error);
}
Ok(())
}
And re-run.
➜ cargo -q test
running 1 test
.
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out
Great! Our test is green and we have an argument length check with a nice error message. It won't do anything if we try to run it though. To fix that, let's update main
.
fn main() -> Result<(), String> {
let args = std::env::args().skip(1).collect();
execute(args)
}
Here we add a real call to get the args in main
, std::env::args().skip(1).collect()
. The name of the binary will be part of the args, that is why we skip one argument.
📝 Note
Let's breakdown the
std::env::args().skip(1).collect()
. If you understood it you can skip this note.
std::env::args()
will return a value of type Args.Args
implements the Iterator trait, which means it has skip and collect methods. We skip one item, which is the binary name and collect the rest. Because we are using this as an argument toexecute
,Rust
can infer its type which isVec<String>
.
Now we can run with no argument using cargo run
and it will give an error.
➜ cargo -q run
Error: "Error: did not receive a single argument, please invoke cert-decoder as follows: ./cert-decoder /path/to/c
ert."
And if we pass an argument it will print nothing.
➜ cargo -q run -- something
Anything after the --
will be passed to our program as arguments. We can check off the first item on our test list now.
Validate args
- ✔️ Only allow a single argument
- ☐ Argument is a path that exists
- ☐ Argument should be a single file
Check argument is a path that exists
First, a test.
#[test]
fn should_error_if_argument_is_not_a_path_which_exists() {
// arrange
let args = vec!["does-not-exist".to_owned()];
// act
let result = execute(args);
// assert
assert!(result.is_err());
assert_eq!(
result.err().unwrap(),
"Error: path given as argument does not exist, it must be a path to a certificate!"
);
}
As always, run the test to see it fail first. It will fail because the result will not be an error. Let's make it return an error.
use std::path::Path;
fn execute(args: Vec<String>) -> Result<(), String> {
if args.len() != 1 {
let error = format!(
"{}{}",
"Error: did not receive a single argument, ",
"please invoke cert-decoder as follows: ./cert-decoder /path/to/cert."
);
return Err(error);
}
let path = Path::new(&args[0]);
if !path.exists() {
return Err(
"Error: path given as argument does not exist, it must be a path to a certificate!"
.to_owned(),
);
}
Ok(())
}
Now the test will pass. Path contains many operations for interacting with filesystems. The call to exists above will check the real filesystem to see if the path we give as an argument exists. This is generally not something you want to do in your tests.
Before we factor out this IO, let's tick this item off on our test list as it is working.
Validate args
- ✔️ Only allow a single argument
- ✔️ Argument is a path that exists
- ☐ Argument should be a single file
The code up to this point can be seen here.
Factor out IO
Almost any code you write is going to do some kind of IO, e.g. network calls, file reads. Normally we don't want this to happen in a test. Beyond just keeping IO out of tests, making it clearer where IO does happen is very useful for readability and refactoring. Especially as a system grows.
Right now what we want to do is make our code testable under different scenarios without touching the real filesystem. There are a few ways to do this, but I generally use traits and have a real and fake implementation of the trait. Let's define a trait that has an exists
method, just like Path
.
trait PathValidator {
fn exists(&self, path: &str) -> bool;
}
It took me a while to come up with a name I was somewhat happy with for this trait. Naming is hard. Now that we have a trait, let's implement it for the real case.
struct CertValidator;
impl PathValidator for CertValidator {
fn exists(&self, path: &str) -> bool {
Path::new(path).exists()
}
}
Now our tests need a version of this also.
#[cfg(test)]
mod test {
use crate::{execute, PathValidator};
struct FakeValidator {
is_path: bool,
}
impl PathValidator for FakeValidator {
fn exists(&self, _: &str) -> bool {
self.is_path
}
}
...
}
Finally, we need to refactor the execute
function to take something that implements PathValidator
as a parameter and update our tests to reflect this. The change as a whole is as follows, with some notes in comments.
use std::path::Path;
trait PathValidator {
fn exists(&self, path: &str) -> bool;
}
struct CertValidator;
impl PathValidator for CertValidator {
fn exists(&self, path: &str) -> bool {
Path::new(path).exists()
}
}
// Here we change the signature of execute to also take a value of type `impl PathValidator`,
// see the note after this code block for more information.
fn execute(validator: impl PathValidator, args: Vec<String>) -> Result<(), String> {
if args.len() != 1 {
let error = format!(
"{}{}",
"Error: did not receive a single argument, ",
"please invoke cert-decoder as follows: ./cert-decoder /path/to/cert."
);
return Err(error);
}
let path = &args[0];
// Instead of calling Path's exists method, we call exists on our
// PathValidator implementation.
if !validator.exists(path) {
return Err(
"Error: path given as argument does not exist, it must be a path to a certificate!"
.to_owned(),
);
}
Ok(())
}
fn main() -> Result<(), String> {
let args = std::env::args().skip(1).collect();
// Here we create our real PathValidator implementation,
// CertValidator, which touches the filesystem.
let validator = CertValidator;
execute(validator, args)
}
#[cfg(test)]
mod test {
use crate::{execute, PathValidator};
struct FakeValidator {
is_path: bool,
}
impl PathValidator for FakeValidator {
fn exists(&self, _: &str) -> bool {
self.is_path
}
}
#[test]
fn should_error_if_not_given_a_single_argument() {
// arrange
let args = Vec::new();
// We construct a FakeValidator that says all paths exist.
// It will never be called in this test, however.
let validator = FakeValidator { is_path: true };
// act
let result = execute(validator, args);
// assert
assert!(result.is_err());
assert_eq!(
result.err().unwrap(),
format!(
"{}{}",
"Error: did not receive a single argument, ",
"please invoke cert-decoder as follows: ./cert-decoder /path/to/cert."
)
);
}
#[test]
fn should_error_if_argument_is_not_a_path_which_exists() {
// arrange
let args = vec!["does-not-exist".to_owned()];
// We construct a validator that says no path exists.
// This will cause our test to fail and return the error we want,
// mimicing a path which does not exist without touching the real
// filesystem.
let validator = FakeValidator { is_path: false };
// act
let result = execute(validator, args);
// assert
assert!(result.is_err());
assert_eq!(
result.err().unwrap(),
"Error: path given as argument does not exist, it must be a path to a certificate!"
);
}
}
📝 Note
The type of
validator
in theexecute
function isimpl PathValidator
. This simply saysvalidator
can be a value of any type which implementsPathValidator
. You can read more about this in the Rust book in the section on Traits as Parameters.
Run the tests again and they should still be green! If you run with cargo run
as we did earlier, it should still work as expected. The code up to this point can be seen here.
Check argument is a file
What's left on our test list?
Validate args
- ✔️ Only allow a single argument
- ✔️ Argument is a path that exists
- ☐ Argument should be a single file
Right now the argument we pass can be several file types other than a regular file, for example, a directory. As we only validate if the given path exists, not what that path points to. So let's validate it is also a file. First, a test.
#[cfg(test)]
mod test {
use crate::{execute, PathValidator};
struct FakeValidator {
is_path: bool,
is_file: bool,
}
impl PathValidator for FakeValidator {
fn exists(&self, _: &str) -> bool {
self.is_path
}
fn is_file(&self, _: &str) -> bool {
self.is_file
}
}
...
#[test]
fn should_error_if_argument_is_not_a_regular_file() {
// arrange
let args = vec!["not-a-regular-file".to_owned()];
let validator = FakeValidator {
is_path: true,
is_file: false,
};
// act
let result = execute(validator, args);
// assert
assert!(result.is_err());
assert_eq!(
result.err().unwrap(),
"Error: path given is not a regular file, please update to point to a certificate."
);
}
}
This won't compile as there is no is_file
method on our PathValidator
trait. Here I've also updated the FakeValidator
in the other test, setting is_file
to false
. Let's update that and implement the real version next.
trait PathValidator {
fn exists(&self, path: &str) -> bool;
fn is_file(&self, path: &str) -> bool;
}
struct CertValidator;
impl PathValidator for CertValidator {
fn exists(&self, path: &str) -> bool {
Path::new(path).exists()
}
fn is_file(&self, path: &str) -> bool {
Path::new(path).is_file()
}
}
Now we have a new is_file
method. If we re-run the test it should fail as we are not returning an error. You can run a single test with cargo test
by passing its name, or a string that is contained in its name.
➜ cargo -q test should_error_if_argument_is_not_a_regular_file
running 1 test
F
failures:
---- test::should_error_if_argument_is_not_a_regular_file stdout ----
thread 'test::should_error_if_argument_is_not_a_regular_file' panicked at 'assertion failed: result.is_err()', src
/main.rs:122:9
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
failures:
test::should_error_if_argument_is_not_a_regular_file
test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 2 filtered out
error: test failed, to rerun pass '--bin cert-decode'
Let's make it return an error if the path given is not a regular file.
fn execute(validator: impl PathValidator, args: Vec<String>) -> Result<(), String> {
if args.len() != 1 {
let error = format!(
"{}{}",
"Error: did not receive a single argument, ",
"please invoke cert-decoder as follows: ./cert-decoder /path/to/cert."
);
return Err(error);
}
let path = &args[0];
if !validator.exists(path) {
return Err(
"Error: path given as argument does not exist, it must be a path to a certificate!"
.to_owned(),
);
}
if !validator.is_file(path) {
return Err(
"Error: path given is not a regular file, please update to point to a certificate."
.to_owned(),
);
}
Ok(())
}
Re-run and all tests should be green.
➜ cargo -q test
running 3 tests
...
test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out
Great! The code up to this point can be seen here. We can now tick this off on the test list.
Validate args
- ✔️ Only allow a single argument
- ✔️ Argument is a path that exists
- ✔️ Argument should be a single file
Another small refactor
We can refactor this a bit. If we have a regular file then it must be a path that exists. This means we can get rid of the exists check and its test entirely. Here is a diff of this change.
diff --git a/src/main.rs b/src/main.rs
index 171545b..2ec2d92 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -1,17 +1,12 @@
use std::path::Path;
trait PathValidator {
- fn exists(&self, path: &str) -> bool;
fn is_file(&self, path: &str) -> bool;
}
struct CertValidator;
impl PathValidator for CertValidator {
- fn exists(&self, path: &str) -> bool {
- Path::new(path).exists()
- }
-
fn is_file(&self, path: &str) -> bool {
Path::new(path).is_file()
}
@@ -27,12 +22,6 @@ fn execute(validator: impl PathValidator, args: Vec<String>) -> Result<(), Strin
return Err(error);
}
let path = &args[0];
- if !validator.exists(path) {
- return Err(
- "Error: path given as argument does not exist, it must be a path to a certificate!"
- .to_owned(),
- );
- }
if !validator.is_file(path) {
return Err(
"Error: path given is not a regular file, please update to point to a certificate."
@@ -54,15 +43,10 @@ mod test {
use crate::{execute, PathValidator};
struct FakeValidator {
- is_path: bool,
is_file: bool,
}
impl PathValidator for FakeValidator {
- fn exists(&self, _: &str) -> bool {
- self.is_path
- }
-
fn is_file(&self, _: &str) -> bool {
self.is_file
}
@@ -72,10 +56,7 @@ mod test {
fn should_error_if_not_given_a_single_argument() {
// arrange
let args = Vec::new();
- let validator = FakeValidator {
- is_path: true,
- is_file: false,
- };
+ let validator = FakeValidator { is_file: false };
// act
let result = execute(validator, args);
@@ -92,34 +73,11 @@ mod test {
);
}
- #[test]
- fn should_error_if_argument_is_not_a_path_which_exists() {
- // arrange
- let args = vec!["does-not-exist".to_owned()];
- let validator = FakeValidator {
- is_path: false,
- is_file: false,
- };
-
- // act
- let result = execute(validator, args);
-
- // assert
- assert!(result.is_err());
- assert_eq!(
- result.err().unwrap(),
- "Error: path given as argument does not exist, it must be a path to a certificate!"
- );
- }
-
#[test]
fn should_error_if_argument_is_not_a_regular_file() {
// arrange
let args = vec!["not-a-regular-file".to_owned()];
- let validator = FakeValidator {
- is_path: true,
- is_file: false,
- };
+ let validator = FakeValidator { is_file: false };
// act
let result = execute(validator, args);
The code up to this point can be seen here.
In the next post
In the next few posts, we'll look at reading information out of the certificate using the x509_parser crate. We'll also switch to using structopt for argument parsing. I didn't use structopt
here as I wanted to keep things simple and mainly focus on the workflow, show how we can evolve functionality in small testable steps. I'm hoping the value of this workflow will be more apparent as we add more functionality to this cli, and things get more complex.
There are also a couple of things implemented here which you would not see in general Rust code. For example, returning a String
as the Err
value of a Result
. This is ok, we will refactor them as we go. It's better to start with something small, get it working, then refactor than to try to make it perfect right away.
Latest comments (0)