loading...
Cover image for A Rust CLI for decoding certs

A Rust CLI for decoding certs

wayofthepie profile image Stephen O'Brien Updated on ・16 min read

TDD and Rust: A CLI for decoding certs (3 Part Series)

1) A Rust CLI for decoding certs 2) Rust and GitHub Actions 3) Decode a certificate

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 test2 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 be Some if the value of the Result is Err. This Some will contain the same value Err contained. The Option will be None if the Result is Ok.

In this case we know for certain the Result's value is Err as we asserted it before with is_err. So the Option returned from result.err() will be Some and we can safely call unwrap on that Some 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 to execute, Rust can infer its type which is Vec<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 the execute function is impl PathValidator. This simply says validator can be a value of any type which implements PathValidator. 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.


  1. PEM encoding - see Section 2 of RFC 7468

  2. The -q in cargo -q test is short for --quiet.  

TDD and Rust: A CLI for decoding certs (3 Part Series)

1) A Rust CLI for decoding certs 2) Rust and GitHub Actions 3) Decode a certificate

Posted on Jun 2 by:

Discussion

markdown guide