Introduction
In this post I will leave a note at the end of some sections linking to the latest code up to that point. It will look like this:
That link points to the latest code from the last post.
Table of Contents
Some missing things
After publishing the first post I realized I missed a positive test, a test which checks that everything went ok. Let's write that.
#[test]
fn should_succeed() {
let args = vec!["a-file".to_owned()];
let validator = FakeValidator { is_file: true };
let result = execute(validator, args);
assert!(result.is_ok());
}
It's a good idea to see it fail first. Let's change a behaviour it expects by making it return an error if the file does exist:
- if !validator.is_file(path) {
+ if validator.is_file(path) {
return Err(
...
Now it will fail:
β¦ β cargo -q test should_succeed
running 1 test
F
failures:
---- test::should_succeed stdout ----
thread 'test::should_succeed' panicked at 'assertion failed: result.is_ok()', src/main.rs:98:9
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
failures:
test::should_succeed
test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 2 filtered out
error: test failed, to rerun pass '--bin cert-decode'
And if we revert our change:
- if validator.is_file(path) {
+ if !validator.is_file(path) {
...
It will pass:
β¦ β cargo -q test should_succeed
running 1 test
.
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 2 filtered out
Great! Now for some new things.
Read a certificate
Time to read a certificate. The x509-parser crate will allow us to do this. Add as a dependency to Cargo.toml
.
[package]
name = "cert-decode"
version = "0.1.0"
authors = ["Stephen OBrien <wayofthepie@users.noreply.github.com>"]
edition = "2018"
[dependencies]
x509-parser = "0.7.0"
π Note
I use cargo-edit to update
Cargo.toml
. You can install it by running:β¦ β cargo install cargo-edit
Then to add a dependency you just need to run:
β¦ β cargo add x509-parser Updating 'https://github.com/rust-lang/crates.io-index' index Adding x509-parser v0.7.0 to dependencies
See the README for more details. The code blocks in this note have
incorrect formatting, they are centered. I raised a bug about this, see thepracticaldev/dev.to#8767.
It's a good idea to rebuild when adding a new dependency.
β¦ β cargo build
Updating crates.io index
Compiling autocfg v1.0.0
Compiling bitflags v1.2.1
Compiling ryu v1.0.5
Compiling lexical-core v0.7.4
Compiling memchr v2.3.3
Compiling version_check v0.9.2
Compiling arrayvec v0.5.1
Compiling static_assertions v1.1.0
Compiling cfg-if v0.1.10
Compiling libc v0.2.71
Compiling base64 v0.11.0
Compiling nom v5.1.2
Compiling num-traits v0.2.12
Compiling num-integer v0.1.43
Compiling num-bigint v0.2.6
Compiling time v01.3
Compiling rusticata-macros v2.1.0
Compiling der-parser v3.0.4
Compiling x509-parser v0.7.0
Compiling cert-decode v0.1.0 (/home/chaospie/repos/blog-cert-decode/cert-decode)
Finished dev [unoptimize + debuginfo] target(s) in 8.70s
The x509-parser
crate has pulled in a bunch of transitive dependencies. Not too many though. When working on a larger project you may want to view the overall dependency hierarchy. You can do this with cargo tree
. For example:
β¦ β cargo tree
cert-decode v0.1.0 (/home/chaospie/repos/blog-cert-decode/cert-decode)
βββ x509-parser v0.7.0
βββ base64 v0.11.0
βββ der-parser v3.0.4
β βββ nom v5.1.2
β β βββ lexical-core v0.7.4
β β β βββ arrayvec v0.5.1
β β β βββ bitflags v1.2.1
β β β βββ cfg-if v0.1.10
β β β βββ ryu v1.0.5
β β β βββ static_assertions v1.1.0
β β βββ memchr v2.3.3
β β [build-dependencies]
β β βββ version_check v0.9.2
β βββ num-bigint v0.2.6
β β βββ num-integer v0.1.43
β β β βββ num-traits v0.2.12
β β β [build-dependencies]
β β β βββ autocfg v1.0.0
β β β [build-dependencies]
β β β βββ autocfg v1.0.0
β β βββ num-traits v0.2.12 (*)
β β [build-dependencies]
β β βββ autocfg v1.0.0
β βββ rusticata-macros v2.1.0
β βββ nom v5.1.2 (*)
βββ nom v5.1.2 (*)
βββ num-bigint v0.2.6 (*)
βββ rusticata-macros v2.1.0 (*)
βββ time v0.1.43
βββ libc v0.2.71
π Note
As of Rust 1.44.0
cargo tree
is part ofcargo
if you are using a version before that you will need to install cargo-tree. You should update to the latest Rust if there is no reason to be on a version less than 1.44.0.
I've used the x509-parser
crate in the past so I know a bit about it's API. But let's do some exploration anyway. First, let's set a goal and make a tiny test list. Our goal is simply to be able to print certificate details. Up to now, we have verified the argument we pass is a file, so I think what we should do next is:
Read and print certificate
- β Validate file is a certificate
- β Print the certificate
Let's dive into the docs for x509-parser
.
x509-parser API
All crates published to crates.io should have docs on docs.rs. These docs will contain the public API of the crate and whatever further documentation the author added. We're using version 0.7.0 of the x509-parser
crate, the docs for this are here. The very first example in these docs does almost what we need:
use x509_parser::parse_x509_der;
static IGCA_DER: &'static [u8] = include_bytes!("../assets/IGC_A.der");
let res = parse_x509_der(IGCA_DER);
match res {
Ok((rem, cert)) => {
assert!(rem.is_empty());
//
assert_eq!(cert.tbs_certificate.version, 2);
},
_ => panic!("x509 parsing failed: {:?}", res),
}
It seems we could use the parse_x509_der function to parse our certificate. Our certificate should be in PEM format however, that was a constraint we set in the initial post. Is there anything that can deal directly with PEM certificates in this API?
There is! The x509_parser::pem module has functionality for doing just this. The second example in that modules docs does just what we want, it uses the pem_to_der function to convert a PEM encoded certificate into DER (Distinguished Encoding Rules) and then calls parse_x509_der on that DER to build a X509Certificate. Here is the example:
use x509_parser::pem::pem_to_der;
use x509_parser::parse_x509_der;
static IGCA_PEM: &'static [u8] = include_bytes!("../assets/IGC_A.pem");
let res = pem_to_der(IGCA_PEM);
match res {
Ok((rem, pem)) => {
assert!(rem.is_empty());
//
assert_eq!(pem.label, String::from("CERTIFICATE"));
//
let res_x509 = parse_x509_der(&pem.contents);
assert!(res_x509.is_ok());
},
_ => panic!("PEM parsing failed: {:?}", res),
}
Don't worry if you don't understand everything in this example, it's not too important for this post. Let's look closer at pem_to_der.
pub fn pem_to_der<'a>(i: &'a [u8]) -> IResult<&'a [u8], Pem, PEMError>
It takes a &'a [u8]
, a slice of bytes with a lifetime1 of 'a
, and returns IResult<&'a [u8], Pem, PEMError>
. In short lifetimes tell the compiler how long a reference lives. In this specific case, the slice i
which the function takes as an argument must live as long as the slice returned in the IResult
return type, as both have a lifetime of 'a
. This tells us the slice in the return type must either be i
or a subslice of i
. For more information see Generic Types, Traits, and Lifetimes.
Glossing over some other details which are outside the scope of this post, this IResult
is effectively just a Result type which we saw in the first post. It can return Ok
with some value or Err
with an error. In this case, the type of the value in Err
will be PEMError.
Validate the file is a certificate
Now we have enough knowledge to write a test in the case our cert is not PEM encoded. First, let's get a cert and save it so we can use that in our tests.
β openssl s_client -connect google.com:443 2>/dev/null < /dev/null \
> | sed -n '/BEGIN CERTIFICATE/,/END CERTIFICATE/p' > google.com.crt
β cat google.com.crt
-----BEGIN CERTIFICATE-----
MIIJTzCCCDegAwIBAgIQVvrczQ6+8BwIAAAAAENV5zANBgkqhkiG9w0BAQsFADBC
MQswCQYDVQQGEwJVUzEeMBwGA1UEChMVR29vZ2xlIFRydXN0IFNlcnZpY2VzMRMw
...
d5JOd+lJOypPGs0/p5OrR8B84Y7wyKFD/EXaKYVMZ4RUXnoAi5DF5RLKNAmnt7R9
V6z8Kz2boaY5oZ0gvrA49R6T+u3yrstte931N49lwpaVsoA=
-----END CERTIFICATE-----
I've stored this cert in the repo here. The test is as follows:
#[test]
fn should_error_if_given_argument_is_not_a_pem_encoded_certificate() {
let args = vec!["real-cert".to_owned()];
let validator = FakeValidator { is_file: true };
let result = execute(validator, args);
assert!(result.is_err())
}
The update to the execute
function will need a bit of refactoring, but first, let's implement it in the simplest way possible.
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.is_file(path) {
return Err(
"Error: path given is not a regular file, please update to point to a certificate."
.to_owned(),
);
}
// read file to string
let cert = std::fs::read_to_string(path).unwrap();
// pem to der
let _ = pem_to_der(cert.as_bytes()).unwrap();
Ok(())
}
We use std::fs::read_to_string to read the file path we pass as an argument directly to a string. This call returns a Result
as it can fail if the path does not exist. But we know it does exist at this point, so we just unwrap the value, giving us our cert as a string. Then we pass that string as bytes, by calling the as_bytes function on it, to pem_to_der
. This can fail and because here we just call unwrap
this will panic if pem_to_der
returns an Err
value instead of and Ok
value.
To see what I mean, update the test so it reads Cargo.toml
.
#[test]
fn should_error_if_given_argument_is_not_a_pem_encoded_certificate() {
let args = vec!["Cargo.toml".to_owned()];
let validator = FakeValidator { is_file: true };
let result = execute(validator, args);
assert!(result.is_err())
}
It will fail as follows because Cargo.toml
is not PEM encoded:
β cargo -q test pem
running 1 test
F
failures:
---- test::should_error_if_given_argument_is_not_a_pem_encoded_certificate stdout ----
thread 'test::should_error_if_given_argument_is_not_a_pem_encoded_certificate' panicked at 'called `Result::unwrap()` on an `Err` value: Error(MissingHeader)', src/main.rs:33:13
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
failures:
test::should_error_if_given_argument_is_not_a_pem_encoded_certificate
test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 3 filtered out
error: test failed, to rerun pass '--bin cert-decode'
Even though it did error, it didn't do so in a way we could handle in our test. It would be better to not call unwrap on the return of pem_to_der
. To do so, we need to change the return type of execute
so it allows us to return both our existing String
errors and the PEMError
which pem_to_der
returns.
Error handling
There are many different ways to handle errors in Rust. A lot is going on in this space currently in regards libaries and discussions in the language itself. How you handle errors in a library vs in an application can vary wildly too. I don't want to add any more dependencies here and I also want to keep this simple, so we'll use the most general type for handling errors, Box<dyn std::error::Error>
2.
Box is a simple way of allocating something on the heap in Rust. Box<dyn std::error::Error>
is a trait object3, this allows us to return a value of any type that implements the std::error::Error trait.
Let's refactor. First, update execute
as follows.
-fn execute(validator: impl PathValidator, args: Vec<String>) -> Result<(), String> {
+fn execute(
+ validator: impl PathValidator,
+ args: Vec<String>,
+) -> Result<(), Box<dyn std::error::Error>> {
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);
+ return Err(error.into());
}
let path = &args[0];
if !validator.is_file(path) {
return Err(
"Error: path given is not a regular file, please update to point to a certificate."
- .to_owned(),
+ .into(),
);
}
let cert = std::fs::read_to_string(path).unwrap();
- let _ = pem_to_der(cert.as_bytes()).unwrap();
+ let _ = pem_to_der(cert.as_bytes())?;
Ok(())
}
We change the return type to of execute
to Result<(), Box<dyn std::error::Error>>
. We were previously returning String
's from our custom errors, we can call into
on our strings and this will convert them into Box<dyn std::error::Error>
. There is an instance of From for converting a String to a Box<dyn std::error::Error>, because of this we get an Into instance for automatically.
Finally, we add a ?
4 to immediately return the error if pem_to_der
returns an error. Next update main
.
-fn main() -> Result<(), String> {
+fn main() -> Result<(), Box<dyn std::error::Error>> {
let args = std::env::args().skip(1).collect();
let validator = CertValidator;
execute(validator, args)
}
We just change the return type here. Finally, update the tests.
#[cfg(test)]
mod test {
...
#[test]
fn should_error_if_not_given_a_single_argument() {
...
assert!(result.is_err());
assert_eq!(
- result.err().unwrap(),
+ format!("{}", 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_regular_file() {
...
assert!(result.is_err());
assert_eq!(
- result.err().unwrap(),
+ format!("{}", result.err().unwrap()),
"Error: path given is not a regular file, please update to point to a certificate."
);
}
#[test]
fn should_error_if_given_argument_is_not_a_pem_encoded_certificate() {
...
}
#[test]
fn should_succeed() {
- let args = vec!["a-file".to_owned()];
+ let args = vec!["resources/google.com.crt".to_owned()];
let validator = FakeValidator { is_file: true };
let result = execute(validator, args);
assert!(result.is_ok());
}
We call format on the error message to turn the Box<dyn std::error::Error>
in to a string. We also change the should_succeed
test to read the real cert. This does IO, but that's ok for now. Re-run and the tests should be green.
β¦ β cargo -q test
running 4 tests
....
test result: ok. 4 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out
Refactor
Now we have refactored to allow returning different types of errors, read, and decoded the PEM certificate into DER format. Let's clean things up a little. We are doing IO again, so let's tackle that first. Right now we are passing an implementation of PathValidator
to execute
. It would make sense to expand what this trait does, but we should rename it. Let's call it FileProcessor
. Implementations will have is_file
and read_to_string
so this makes sense. Let's also rename CertValidator
to CertProcessor
.
use std::path::Path;
use x509_parser::pem::pem_to_der;
-trait PathValidator {
+trait FileProcessor {
fn is_file(&self, path: &str) -> bool;
}
-struct CertValidator;
+struct CertProcessor;
-impl PathValidator for CertValidator {
+impl FileProcessor for CertProcessor {
fn is_file(&self, path: &str) -> bool {
Path::new(path).is_file()
}
}
fn execute(
- validator: impl PathValidator,
+ validator: impl FileProcessor,
args: Vec<String>,
) -> Result<(), Box<dyn std::error::Error>> {
...
Ok(())
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
let args = std::env::args().skip(1).collect();
- let validator = CertValidator;
+ let validator = CertProcessor;
execute(validator, args)
}
#[cfg(test)]
mod test {
- use crate::{execute, PathValidator};
+ use crate::{execute, FileProcessor};
- struct FakeValidator {
+ struct FakeProcessor {
is_file: bool,
}
- impl PathValidator for FakeValidator {
+ impl FileProcessor for FakeProcessor {
fn is_file(&self, _: &str) -> bool {
self.is_file
}
}
#[test]
fn should_error_if_not_given_a_single_argument() {
// arrange
let args = Vec::new();
- let validator = FakeValidator { is_file: false };
+ let validator = FakeProcessor { is_file: false };
...
}
#[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_file: false };
+ let validator = FakeProcessor { is_file: false };
...
}
#[test]
fn should_error_if_given_argument_is_not_a_pem_encoded_certificate() {
let args = vec!["Cargo.toml".to_owned()];
- let validator = FakeValidator { is_file: true };
+ let validator = FakeProcessor { is_file: true };
let result = execute(validator, args);
assert!(result.is_err())
}
#[test]
fn should_succeed() {
let args = vec!["resources/google.com.crt".to_owned()];
- let validator = FakeValidator { is_file: true };
+ let validator = FakeProcessor { is_file: true };
let result = execute(validator, args);
assert!(result.is_ok());
}
}
π Note
You may have noticed I forgot to update the name of the variables fromvalidator
to something more appropriate likeprocessor
!
This was indeed a mistake. I added a small refactor section near the end of the post which fixes this.
Now we can add a read_to_string
method to the FileProcessor
trait and implement.
use std::path::Path;
use x509_parser::pem::pem_to_der;
trait FileProcessor {
fn is_file(&self, path: &str) -> bool;
+ fn read_to_string(&self, path: &str) -> Result<String, Box<dyn std::error::Error>>;
}
struct CertProcessor;
impl FileProcessor for CertProcessor {
fn is_file(&self, path: &str) -> bool {
Path::new(path).is_file()
}
+ fn read_to_string(&self, path: &str) -> Result<String, Box<dyn std::error::Error>> {
+ Ok(std::fs::read_to_string(path)?)
+ }
}
fn execute(
- validator: impl FileProcessor,
+ processor: impl FileProcessor,
args: Vec<String>,
) -> Result<(), Box<dyn std::error::Error>> {
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.into());
}
let path = &args[0];
- if !validator.is_file(path) {
+ if !processor.is_file(path) {
return Err(
"Error: path given is not a regular file, please update to point to a certificate."
.into(),
);
}
- let cert = std::fs::read_to_string(path).unwrap();
+ let cert = processor.read_to_string(path)?;
let _ = pem_to_der(cert.as_bytes())?;
Ok(())
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
let args = std::env::args().skip(1).collect();
let validator = CertProcessor;
execute(validator, args)
}
#[cfg(test)]
mod test {
use crate::{execute, FileProcessor};
struct FakeProcessor {
is_file: bool,
+ file_str: String,
}
impl FileProcessor for FakeProcessor {
fn is_file(&self, _: &str) -> bool {
self.is_file
}
+ fn read_to_string(&self, _: &str) -> Result<String, Box<dyn std::error::Error>> {
+ Ok(self.file_str.clone())
+ }
}
#[test]
fn should_error_if_not_given_a_single_argument() {
// arrange
let args = Vec::new();
- let validator = FakeProcessor { is_file: false };
+ let validator = FakeProcessor {
+ is_file: false,
+ file_str: "".to_owned(),
+ };
// act
let result = execute(validator, args);
// assert
assert!(result.is_err());
assert_eq!(
format!("{}", 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_regular_file() {
// arrange
let args = vec!["not-a-regular-file".to_owned()];
- let validator = FakeProcessor { is_file: false };
+ let validator = FakeProcessor {
+ is_file: false,
+ file_str: "".to_owned(),
+ };
// act
let result = execute(validator, args);
// assert
assert!(result.is_err());
assert_eq!(
format!("{}", result.err().unwrap()),
"Error: path given is not a regular file, please update to point to a certificate."
);
}
#[test]
fn should_error_if_given_argument_is_not_a_pem_encoded_certificate() {
let args = vec!["Cargo.toml".to_owned()];
- let validator = FakeProcessor { is_file: true };
+ let validator = FakeProcessor {
+ is_file: true,
+ file_str: "".to_owned(),
+ };
let result = execute(validator, args);
assert!(result.is_err())
}
#[test]
fn should_succeed() {
- let args = vec!["resources/google.com.crt".to_owned()];
- let validator = FakeProcessor { is_file: true };
+ let cert = include_str!("../resources/google.com.crt");
+ let args = vec!["doesnt-really-matter".to_owned()];
+ let validator = FakeProcessor {
+ is_file: true,
+ file_str: cert.to_owned(),
+ };
let result = execute(validator, args);
assert!(result.is_ok());
}
π Note
In the
should_succeed
test we use the include_str macro to read the real cert at compile time. This is cleaner than pasting the cert directly in the test.
We can improve the tests by deriving5 Default for our FakeProcessor
. This will give us a basic implementation of FakeProcessor
, defaulting all the fields to the value of the Default
implementation for their type. For example, the default for bool
is false
and for String
is the empty string.
#[cfg(test)]
mod test {
use crate::{execute, FileProcessor};
...
-
+ #[derive(Default)]
struct FakeProcessor {
is_file: bool,
file_str: String,
}
#[test]
fn should_error_if_not_given_a_single_argument() {
- // arrange
let args = Vec::new();
- let validator = FakeProcessor {
- is_file: false,
- file_str: "".to_owned(),
- };
-
- // act
+ let validator = FakeProcessor::default();
let result = execute(validator, args);
-
- // assert
assert!(result.is_err());
assert_eq!(
format!("{}", 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_regular_file() {
- // arrange
let args = vec!["not-a-regular-file".to_owned()];
- let validator = FakeProcessor {
- is_file: false,
- file_str: "".to_owned(),
- };
-
- // act
+ let validator = FakeProcessor::default();
let result = execute(validator, args);
-
- // assert
assert!(result.is_err());
assert_eq!(
format!("{}", result.err().unwrap()),
"Error: path given is not a regular file, please update to point to a certificate."
);
}
#[test]
fn should_error_if_given_argument_is_not_a_pem_encoded_certificate() {
let args = vec!["Cargo.toml".to_owned()];
let validator = FakeProcessor {
is_file: true,
- file_str: "".to_owned(),
+ ..FakeProcessor::default()
};
let result = execute(validator, args);
assert!(result.is_err())
}
...
}
π Note
In a test above we used struct update syntax,..FakeProcessor::default()
. This will "fill in" any fields we do not explicitly set. It will allow us to add more fields toFileProcessor
if needed and not have to update all tests.
After each change, you should run the tests! If you run them now they should still be green.
β¦ β cargo -q test
running 4 tests
....
test result: ok. 4 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out
Parse the der encoded cert
Let's parse the DER bytes into an X509Certificate
. In the x509 parser API section we saw an example of this using the parse_x509_der function. It can fail, so first, a test.
#[test]
fn should_error_if_argument_is_not_a_valid_certificate() {
let cert = include_str!("../resources/bad.crt");
let args = vec!["doesnt-really-matter".to_owned()];
let processor = FakeProcessor {
is_file: true,
file_str: cert.to_owned(),
};
let result = execute(processor, args);
assert!(result.is_err());
}
I have added a file called bad.crt
to the resources folder. This just contains a base64 encoded string, which is not a valid certificate. So it will succeed in the pem_to_der
call but calling parse_x509_der
should return an error. First, let's see this test fail.
β¦ β cargo -q test
running 5 tests
...F.
failures:
---- test::should_error_if_argument_is_not_a_valid_certificate stdout ----
thread 'test::should_error_if_argument_is_not_a_valid_certificate' panicked at 'assertion failed: result.is_err()', src/main.rs:118:9
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
failures:
test::should_error_if_argument_is_not_a_valid_certificate
test result: FAILED. 4 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out
error: test failed, to rerun pass '--bin cert-decode'
Great! Now, let's parse the DER we get.
fn execute(
processor: impl FileProcessor,
args: Vec<String>,
) -> Result<(), Box<dyn std::error::Error>> {
// stripped out irrelevant code
...
let cert = processor.read_to_string(path)?;
let (_, pem) = pem_to_der(cert.as_bytes())?;
let _ = parse_x509_der(&pem.contents)?;
Ok(())
}
And re-run the tests.
β¦ β cargo -q test
running 5 tests
.....
test result: ok. 5 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out
Great! We didn't need to update the should_succeed
test, meaning it is reading our real certificate correctly. There are few things we can improve here, but first, let's mark off the first item in our test list.
Read and print certificate
- βοΈ Validate file is a certificate
- β Print the certificate
Print the certificate
It turns out we will have to do a bit more processing to get a human-readable output format, so I'm going to cheat here! On success, parse_x509_der
returns a tuple with remaining bytes and an X509Certificate
. The X509Certificate
type implements Debug so we can print its debug format.
fn execute(
processor: impl FileProcessor,
args: Vec<String>,
) -> Result<(), Box<dyn std::error::Error>> {
...
let cert = processor.read_to_string(path)?;
let (_, pem) = pem_to_der(cert.as_bytes())?;
- let _ = parse_x509_der(&pem.contents)?;
+ let (_, cert) = parse_x509_der(&pem.contents)?;
+ let output = format!("{:#?}", cert.tbs_certificate);
+ println!("{}", output);
Ok(())
}
...
We use the debug format specifier {:?}
in the format
macro. We also add a #
to pretty print it, {:#?}
. The only thing we print here is the tbs_certificate
field as that contains all the details we will need. To test this, let's run the actual cli.
π Note
From the above change you might be thinking "Why create a pre-formatted string and pass that toprintln!
? Couldn't you just use the debug format specifier directly inprintln!
?". You can do this, try it and docargo -q run -- resources/google.com.crt
. Now do it again with a pipe -cargo -q run -- resources/google.com.crt | head -n20
- it will fail. I may do a short post on why this happens.
β¦ β cargo -q run -- resources/google.com.crt | head -n20
TbsCertificate {
version: 2,
serial: BigUint {
data: [
4412903,
134217728,
247394332,
1459281101,
],
},
signature: AlgorithmIdentifier {
algorithm: OID(1.2.840.113549.1.1.11),
parameters: BerObject {
class: 0,
structured: 0,
tag: EndOfContent,
content: ContextSpecific(
EndOfContent,
Some(
BerObject {
Pretty unreadable! But it's a start, we're making some headway. In the next post, we'll clean this up.
Read and print certificate
- βοΈ Validate file is a certificate
- βοΈ Print the certificate
Tiny refactor
I realized I misnamed a few things. I missed them when refactoring. In the tests, most of the FakeProcessor
variables are still called validator
. Similarly in main
. Let's update those.
...
fn main() -> Result<(), Box<dyn std::error::Error>> {
let args = std::env::args().skip(1).collect();
- let validator = CertProcessor;
- execute(validator, args)
+ let processor = CertProcessor;
+ execute(processor, args)
}
#[cfg(test)]
mod test {
...
#[test]
fn should_error_if_not_given_a_single_argument() {
let args = Vec::new();
- let validator = FakeProcessor::default();
- let result = execute(validator, args);
+ let processor = FakeProcessor::default();
+ let result = execute(processor, args);
assert!(result.is_err());
assert_eq!(
format!("{}", 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_regular_file() {
let args = vec!["not-a-regular-file".to_owned()];
- let validator = FakeProcessor::default();
- let result = execute(validator, args);
+ let processor = FakeProcessor::default();
+ let result = execute(processor, args);
assert!(result.is_err());
assert_eq!(
format!("{}", result.err().unwrap()),
"Error: path given is not a regular file, please update to point to a certificate."
);
}
#[test]
fn should_error_if_given_argument_is_not_a_pem_encoded_certificate() {
let args = vec!["Cargo.toml".to_owned()];
- let validator = FakeProcessor {
+ let processor = FakeProcessor {
is_file: true,
..FakeProcessor::default()
};
- let result = execute(validator, args);
+ let result = execute(processor, args);
assert!(result.is_err())
}
...
#[test]
fn should_succeed() {
let cert = include_str!("../resources/google.com.crt");
let args = vec!["doesnt-really-matter".to_owned()];
- let validator = FakeProcessor {
+ let processor = FakeProcessor {
is_file: true,
file_str: cert.to_owned(),
};
- let result = execute(validator, args);
+ let result = execute(processor, args);
println!("{:#?}", result);
assert!(result.is_ok());
}
}
Conclusion
There were a few things I glossed over that appeared in this post. I will take note of them and make sure they appear in one of the next posts. For example lifetimes, a better explanation of Box
, and that println
issue I mentioned in a note in the last section.
There is also a small display issue when an error occurs. For example, if we pass no argument:
β¦ β cargo -q run --
Error: "Error: did not receive a single argument, please invoke cert-decoder as follows: ./cert-decoder /path/to/cert."
It repeats the word "Error" and also wraps our error string in quotes. We will fix this too in the next post!
-
See Generic Types, Traits, and Lifetimes in the Rust book.Β β©
-
Error handling in Rust is a big topic, to get started see the Error Handling chapter in the Rust book. For more on boxed errors see Boxing errors.dΒ β©
-
See Using Trait Objects That Allow for Values of Different Types in the Rust book.Β β©
-
For more on how the
?
works see The ? operator for easier error handling.Β β©
Top comments (2)
Hi,
x509-parser
author here :)Thanks for your very detailed post! It gives many examples and details.
If you have feedback on the provided API, or suggested features, they are welcome!
Hi Pierre,
Thanks, I have been working with the x509-parser crate for a while now, it is great thanks for building it! I'm building a few things around the certificate transparency logs with this crate so if I come across anything I will definitely open an issue.