diff --git a/upki-mirror/src/main.rs b/upki-mirror/src/main.rs index 8663d31..dce19c9 100644 --- a/upki-mirror/src/main.rs +++ b/upki-mirror/src/main.rs @@ -7,7 +7,7 @@ use std::time::SystemTime; use aws_lc_rs::digest::{SHA256, digest}; use clap::{Parser, ValueEnum}; use eyre::{Context, Report, anyhow}; -use upki::{Filter, Manifest}; +use upki::revocation::{Filter, Manifest}; mod mozilla; diff --git a/upki/src/config.rs b/upki/src/config.rs deleted file mode 100644 index e482abb..0000000 --- a/upki/src/config.rs +++ /dev/null @@ -1,139 +0,0 @@ -use std::fs; -use std::path::{Path, PathBuf}; - -use eyre::{Context, Report}; -use serde::{Deserialize, Serialize}; - -/// `upki` configuration. -#[derive(Debug, Deserialize, Serialize)] -#[serde(rename_all = "kebab-case", deny_unknown_fields)] -pub struct Config { - /// Where to store cache files. - cache_dir: PathBuf, - - /// Configuration for crlite-style revocation. - pub revocation: RevocationConfig, -} - -impl Config { - /// Load the configuration data from a file at `path`. - /// - /// If no file exists at `path`, a default configuration is returned. - pub fn from_file_or_default(path: &ConfigPath) -> Result { - match path { - ConfigPath::Default(path) if path.exists() => Self::from_file(path), - ConfigPath::Default(_) => Self::try_default(), - ConfigPath::Specified(path) => Self::from_file(path), - } - } - - /// Load the configuration data from a file at `path`. - pub fn from_file(path: &Path) -> Result { - let config_content = fs::read_to_string(path) - .wrap_err_with(|| format!("cannot load configuration file from {path:?}"))?; - toml::from_str(&config_content) - .wrap_err_with(|| format!("cannot parse configuration file at {path:?}")) - } - - /// Return a sensible default configuration. - pub fn try_default() -> Result { - Ok(Self { - cache_dir: platform::default_cache_dir()?, - revocation: RevocationConfig { - fetch_url: "https://upki.rustls.dev/".into(), - }, - }) - } - - pub(crate) fn revocation_cache_dir(&self) -> PathBuf { - self.cache_dir.join("revocation") - } -} - -/// Details about crlite-style revocation. -#[derive(Debug, Deserialize, Serialize)] -#[serde(rename_all = "kebab-case", deny_unknown_fields)] -pub struct RevocationConfig { - /// Where to fetch revocation data files. - pub fetch_url: String, -} - -pub enum ConfigPath { - Specified(PathBuf), - Default(PathBuf), -} - -impl ConfigPath { - /// Return the path of a configuration file. - /// - /// If `specified` is supplied, this path is returned -- no searching is performed - /// and this function does not return an error. - /// - /// This function prefers to return paths to existing files. If no files exist - /// according to the (platform-specific) search logic, then a suggested location - /// is returned where a configuration file can be created if desired. - /// - /// This function fails for platform-specific reasons, typically if `$HOME` is not - /// set, or another XDG environment variable is malformed. - pub fn new(specified: Option) -> Result { - match specified { - Some(f) => Ok(Self::Specified(f)), - None => platform::find_config_file().map(ConfigPath::Default), - } - } -} - -impl AsRef for ConfigPath { - fn as_ref(&self) -> &Path { - match self { - Self::Specified(path) => path.as_ref(), - Self::Default(path) => path.as_ref(), - } - } -} - -#[cfg(target_os = "linux")] -mod platform { - use eyre::OptionExt; - use xdg::BaseDirectories; - - use super::*; - - pub(super) fn find_config_file() -> Result { - let bd = BaseDirectories::with_prefix(PREFIX); - bd.find_config_file(CONFIG_FILE) - .or_else(|| bd.get_config_file(CONFIG_FILE)) - .ok_or_eyre("cannot determine config file location") - } - - pub(super) fn default_cache_dir() -> Result { - BaseDirectories::with_prefix(PREFIX) - .get_cache_home() - .ok_or_eyre("cannot determine default cache directory") - } -} - -#[cfg(not(target_os = "linux"))] -mod platform { - use directories::ProjectDirs; - - use super::*; - - pub(super) fn find_config_file() -> Result { - Ok(project_dirs()? - .config_dir() - .join(CONFIG_FILE)) - } - - pub(super) fn default_cache_dir() -> Result { - Ok(project_dirs()?.cache_dir().to_owned()) - } - - fn project_dirs() -> Result { - ProjectDirs::from("dev", "rustls", PREFIX) - .ok_or_else(|| eyre::eyre!("cannot determine project directory")) - } -} - -const PREFIX: &str = "upki"; -const CONFIG_FILE: &str = "config.toml"; diff --git a/upki/src/lib.rs b/upki/src/lib.rs index 6de8f23..50ea40d 100644 --- a/upki/src/lib.rs +++ b/upki/src/lib.rs @@ -1,236 +1,133 @@ -use core::error::Error as StdError; -use core::fmt; -use core::str::FromStr; -use std::fs::{self, File}; -use std::io::BufReader; -use std::process::ExitCode; - -use base64::Engine; -use base64::prelude::BASE64_STANDARD; -use chrono::{DateTime, Utc}; -use clubcard_crlite::{CRLiteClubcard, CRLiteKey, CRLiteStatus}; -use eyre::{Context, Report, eyre}; -use serde::{Deserialize, Serialize}; -use tracing::info; - -mod config; -pub use config::{Config, ConfigPath, RevocationConfig}; - -mod fetch; -pub use fetch::fetch; +use std::fs; +use std::path::{Path, PathBuf}; -use crate::fetch::Plan; +use eyre::{Context, Report}; +use serde::{Deserialize, Serialize}; -/// The structure contained in a manifest.json -#[derive(Clone, Debug, Deserialize, Serialize)] -pub struct Manifest { - /// When this file was generated. - /// - /// UNIX timestamp in seconds. - pub generated_at: u64, +use crate::revocation::RevocationConfig; - /// Some human-readable text. - pub comment: String, +/// `upki` configuration. +#[derive(Debug, Deserialize, Serialize)] +#[serde(rename_all = "kebab-case", deny_unknown_fields)] +pub struct Config { + /// Where to store cache files. + cache_dir: PathBuf, - /// List of filter files. - pub filters: Vec, + /// Configuration for crlite-style revocation. + pub revocation: RevocationConfig, } -impl Manifest { - pub fn from_config(config: &Config) -> Result { - let mut file_name = config.revocation_cache_dir(); - file_name.push("manifest.json"); - serde_json::from_reader( - File::open(&file_name) - .map(BufReader::new) - .wrap_err_with(|| format!("cannot open manifest JSON {file_name:?}"))?, - ) - .wrap_err("cannot parse manifest JSON") - } - - /// This function does a low-level revocation check. - /// - /// It is assumed the caller has already done a path verification, and now wants to - /// check the revocation status of the end-entity certificate. +impl Config { + /// Load the configuration data from a file at `path`. /// - /// On success, this returns a [`RevocationStatus`] saying whether the certificate - /// is revoked, not revoked, or whether the data set cannot make that determination. - pub fn check( - &self, - input: &RevocationCheckInput, - config: &Config, - ) -> Result { - let key = input.key(); - let cache_dir = config.revocation_cache_dir(); - for f in &self.filters { - let bytes = fs::read(cache_dir.join(&f.filename)) - .wrap_err_with(|| format!("cannot read filter file {}", f.filename))?; - - let filter = - CRLiteClubcard::from_bytes(&bytes).map_err(|_| Error::CorruptCrliteFilter)?; - - match filter.contains( - &key, - input - .sct_timestamps - .iter() - .map(|ct_ts| (&ct_ts.log_id, ct_ts.timestamp)), - ) { - CRLiteStatus::Revoked => return Ok(RevocationStatus::CertainlyRevoked), - CRLiteStatus::Good => return Ok(RevocationStatus::NotRevoked), - CRLiteStatus::NotEnrolled | CRLiteStatus::NotCovered => continue, - } + /// If no file exists at `path`, a default configuration is returned. + pub fn from_file_or_default(path: &ConfigPath) -> Result { + match path { + ConfigPath::Default(path) if path.exists() => Self::from_file(path), + ConfigPath::Default(_) => Self::try_default(), + ConfigPath::Specified(path) => Self::from_file(path), } - - Ok(RevocationStatus::NotCoveredByRevocationData) } - pub fn verify(&self, config: &Config) -> Result { - self.introduce()?; - let plan = Plan::construct(self, "https://.../", &config.revocation_cache_dir())?; - match plan.download_bytes() { - 0 => Ok(ExitCode::SUCCESS), - bytes => Err(eyre!( - "fixing the local cache requires downloading {bytes} bytes" - )), - } + /// Load the configuration data from a file at `path`. + pub fn from_file(path: &Path) -> Result { + let config_content = fs::read_to_string(path) + .wrap_err_with(|| format!("cannot load configuration file from {path:?}"))?; + toml::from_str(&config_content) + .wrap_err_with(|| format!("cannot parse configuration file at {path:?}")) } - pub fn introduce(&self) -> Result<(), Report> { - let dt = match DateTime::::from_timestamp(self.generated_at as i64, 0) { - Some(dt) => dt.to_rfc3339(), - None => return Err(eyre!("manifest has invalid timestamp")), - }; - - info!(comment = self.comment, date = dt, "parsed manifest"); - Ok(()) + /// Return a sensible default configuration. + pub fn try_default() -> Result { + Ok(Self { + cache_dir: platform::default_cache_dir()?, + revocation: RevocationConfig::default(), + }) } -} - -#[derive(Clone, Debug, Deserialize, Serialize)] -pub struct Filter { - /// Relative filename. - /// - /// This is also the suggested local filename. - pub filename: String, - - /// File size, indicative. Allows a fetcher to predict data usage. - pub size: usize, - - /// SHA256 hash of file contents. - #[serde(with = "hex::serde")] - pub hash: Vec, -} - -/// Input parameters for a revocation check. -#[derive(Debug)] -pub struct RevocationCheckInput { - /// Big-endian bytes encoding of the end-entity certificate serial number. - pub cert_serial: CertSerial, - /// SHA256 hash of the `SubjectPublicKeyInfo` of the issuer of the end-entity certificate. - pub issuer_spki_hash: IssuerSpkiHash, - /// CT log IDs and inclusion timestamps present in the end-entity certificate. - pub sct_timestamps: Vec, -} -impl RevocationCheckInput { - fn key(&self) -> CRLiteKey<'_> { - CRLiteKey::new(&self.issuer_spki_hash.0, &self.cert_serial.0) + pub(crate) fn revocation_cache_dir(&self) -> PathBuf { + self.cache_dir.join("revocation") } } -#[derive(Clone, Debug)] -pub struct CertSerial(pub Vec); - -impl FromStr for CertSerial { - type Err = Report; +pub enum ConfigPath { + Specified(PathBuf), + Default(PathBuf), +} - fn from_str(value: &str) -> Result { - match BASE64_STANDARD.decode(value) { - Ok(bytes) => Ok(Self(bytes)), - Err(e) => Err(e).wrap_err("cannot parse base64 serial number"), +impl ConfigPath { + /// Return the path of a configuration file. + /// + /// If `specified` is supplied, this path is returned -- no searching is performed + /// and this function does not return an error. + /// + /// This function prefers to return paths to existing files. If no files exist + /// according to the (platform-specific) search logic, then a suggested location + /// is returned where a configuration file can be created if desired. + /// + /// This function fails for platform-specific reasons, typically if `$HOME` is not + /// set, or another XDG environment variable is malformed. + pub fn new(specified: Option) -> Result { + match specified { + Some(f) => Ok(Self::Specified(f)), + None => platform::find_config_file().map(ConfigPath::Default), } } } -#[derive(Clone, Debug)] -pub struct IssuerSpkiHash(pub [u8; 32]); - -impl FromStr for IssuerSpkiHash { - type Err = Report; - - fn from_str(value: &str) -> Result { - Ok(Self( - BASE64_STANDARD - .decode(value) - .wrap_err("cannot parse issuer SPKI hash")? - .try_into() - .map_err(|b: Vec| { - eyre!("issuer SPKI hash is wrong length (was {} bytes)", b.len()) - })?, - )) +impl AsRef for ConfigPath { + fn as_ref(&self) -> &Path { + match self { + Self::Specified(path) => path.as_ref(), + Self::Default(path) => path.as_ref(), + } } } -#[derive(Clone, Debug)] -pub struct CtTimestamp { - pub log_id: [u8; 32], - pub timestamp: u64, -} +#[cfg(target_os = "linux")] +mod platform { + use eyre::OptionExt; + use xdg::BaseDirectories; -impl FromStr for CtTimestamp { - type Err = Report; + use super::*; - fn from_str(value: &str) -> Result { - let Some((log_id, issuance_timestamp)) = value.split_once(":") else { - return Err(eyre!("missing colon in CT timestamp")); - }; + pub(super) fn find_config_file() -> Result { + let bd = BaseDirectories::with_prefix(PREFIX); + bd.find_config_file(CONFIG_FILE) + .or_else(|| bd.get_config_file(CONFIG_FILE)) + .ok_or_eyre("cannot determine config file location") + } - Ok(Self { - log_id: BASE64_STANDARD - .decode(log_id) - .wrap_err("cannot parse CT log ID")? - .try_into() - .map_err(|wrong: Vec| { - eyre!("CT log ID is wrong length (was {} bytes)", wrong.len()) - })?, - timestamp: issuance_timestamp - .parse() - .wrap_err("cannot parse CT timestamp")?, - }) + pub(super) fn default_cache_dir() -> Result { + BaseDirectories::with_prefix(PREFIX) + .get_cache_home() + .ok_or_eyre("cannot determine default cache directory") } } -/// The successful outcome of a revocation check. -/// -/// Look at a value of this type to determine whether a certificate was revoked or not. -#[derive(Debug, PartialEq)] -#[must_use] -pub enum RevocationStatus { - /// We couldn't determine the revocation status. - /// - /// Most likely, this certificate is very new and is not covered by the current filter dataset. - NotCoveredByRevocationData, +#[cfg(not(target_os = "linux"))] +mod platform { + use directories::ProjectDirs; - /// This certificate has been revoked. - CertainlyRevoked, + use super::*; - /// This certificate was covered by revocation data, and it is not currently revoked. - NotRevoked, -} + pub(super) fn find_config_file() -> Result { + Ok(project_dirs()? + .config_dir() + .join(CONFIG_FILE)) + } -#[derive(Debug)] -pub enum Error { - /// `crlite_clubcard::CRLiteClubcard` couldn't deserialize the filter data. - CorruptCrliteFilter, -} + pub(super) fn default_cache_dir() -> Result { + Ok(project_dirs()?.cache_dir().to_owned()) + } -impl fmt::Display for Error { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::CorruptCrliteFilter => write!(f, "corrupt CRLite filter data"), - } + fn project_dirs() -> Result { + ProjectDirs::from("dev", "rustls", PREFIX) + .ok_or_else(|| eyre::eyre!("cannot determine project directory")) } } -impl StdError for Error {} +const PREFIX: &str = "upki"; +const CONFIG_FILE: &str = "config.toml"; + +pub mod revocation; diff --git a/upki/src/main.rs b/upki/src/main.rs index 0b47850..b67ea04 100644 --- a/upki/src/main.rs +++ b/upki/src/main.rs @@ -5,10 +5,11 @@ use std::process::ExitCode; use clap::{Parser, Subcommand}; use eyre::{Context, Report}; -use upki::{ - CertSerial, Config, ConfigPath, CtTimestamp, IssuerSpkiHash, Manifest, RevocationCheckInput, - RevocationStatus, fetch, +use upki::revocation::{ + CertSerial, CtTimestamp, IssuerSpkiHash, Manifest, RevocationCheckInput, RevocationStatus, + fetch, }; +use upki::{Config, ConfigPath}; #[tokio::main(flavor = "current_thread")] async fn main() -> Result { diff --git a/upki/src/fetch.rs b/upki/src/revocation/fetch.rs similarity index 99% rename from upki/src/fetch.rs rename to upki/src/revocation/fetch.rs index f97e8f4..8a998d7 100644 --- a/upki/src/fetch.rs +++ b/upki/src/revocation/fetch.rs @@ -20,7 +20,8 @@ use eyre::{Context, Report, eyre}; use tempfile::NamedTempFile; use tracing::{debug, info}; -use crate::{Config, Filter, Manifest}; +use crate::Config; +use crate::revocation::{Filter, Manifest}; pub async fn fetch(dry_run: bool, config: &Config) -> Result { let cache_dir = config.revocation_cache_dir(); diff --git a/upki/src/revocation/mod.rs b/upki/src/revocation/mod.rs new file mode 100644 index 0000000..e2f2a59 --- /dev/null +++ b/upki/src/revocation/mod.rs @@ -0,0 +1,250 @@ +use core::error::Error as StdError; +use core::fmt; +use core::str::FromStr; +use std::fs::{self, File}; +use std::io::BufReader; +use std::process::ExitCode; + +use base64::Engine; +use base64::prelude::BASE64_STANDARD; +use chrono::{DateTime, Utc}; +use clubcard_crlite::{CRLiteClubcard, CRLiteKey, CRLiteStatus}; +use eyre::{Context, Report, eyre}; +use serde::{Deserialize, Serialize}; +use tracing::info; + +use crate::Config; + +mod fetch; +use fetch::Plan; +pub use fetch::fetch; + +/// The structure contained in a manifest.json +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct Manifest { + /// When this file was generated. + /// + /// UNIX timestamp in seconds. + pub generated_at: u64, + + /// Some human-readable text. + pub comment: String, + + /// List of filter files. + pub filters: Vec, +} + +impl Manifest { + pub fn from_config(config: &Config) -> Result { + let mut file_name = config.revocation_cache_dir(); + file_name.push("manifest.json"); + serde_json::from_reader( + File::open(&file_name) + .map(BufReader::new) + .wrap_err_with(|| format!("cannot open manifest JSON {file_name:?}"))?, + ) + .wrap_err("cannot parse manifest JSON") + } + + /// This function does a low-level revocation check. + /// + /// It is assumed the caller has already done a path verification, and now wants to + /// check the revocation status of the end-entity certificate. + /// + /// On success, this returns a [`RevocationStatus`] saying whether the certificate + /// is revoked, not revoked, or whether the data set cannot make that determination. + pub fn check( + &self, + input: &RevocationCheckInput, + config: &Config, + ) -> Result { + let key = input.key(); + let cache_dir = config.revocation_cache_dir(); + for f in &self.filters { + let bytes = fs::read(cache_dir.join(&f.filename)) + .wrap_err_with(|| format!("cannot read filter file {}", f.filename))?; + + let filter = + CRLiteClubcard::from_bytes(&bytes).map_err(|_| Error::CorruptCrliteFilter)?; + + match filter.contains( + &key, + input + .sct_timestamps + .iter() + .map(|ct_ts| (&ct_ts.log_id, ct_ts.timestamp)), + ) { + CRLiteStatus::Revoked => return Ok(RevocationStatus::CertainlyRevoked), + CRLiteStatus::Good => return Ok(RevocationStatus::NotRevoked), + CRLiteStatus::NotEnrolled | CRLiteStatus::NotCovered => continue, + } + } + + Ok(RevocationStatus::NotCoveredByRevocationData) + } + + pub fn verify(&self, config: &Config) -> Result { + self.introduce()?; + let plan = Plan::construct(self, "https://.../", &config.revocation_cache_dir())?; + match plan.download_bytes() { + 0 => Ok(ExitCode::SUCCESS), + bytes => Err(eyre!( + "fixing the local cache requires downloading {bytes} bytes" + )), + } + } + + pub fn introduce(&self) -> Result<(), Report> { + let dt = match DateTime::::from_timestamp(self.generated_at as i64, 0) { + Some(dt) => dt.to_rfc3339(), + None => return Err(eyre!("manifest has invalid timestamp")), + }; + + info!(comment = self.comment, date = dt, "parsed manifest"); + Ok(()) + } +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct Filter { + /// Relative filename. + /// + /// This is also the suggested local filename. + pub filename: String, + + /// File size, indicative. Allows a fetcher to predict data usage. + pub size: usize, + + /// SHA256 hash of file contents. + #[serde(with = "hex::serde")] + pub hash: Vec, +} + +/// Input parameters for a revocation check. +#[derive(Debug)] +pub struct RevocationCheckInput { + /// Big-endian bytes encoding of the end-entity certificate serial number. + pub cert_serial: CertSerial, + /// SHA256 hash of the `SubjectPublicKeyInfo` of the issuer of the end-entity certificate. + pub issuer_spki_hash: IssuerSpkiHash, + /// CT log IDs and inclusion timestamps present in the end-entity certificate. + pub sct_timestamps: Vec, +} + +impl RevocationCheckInput { + fn key(&self) -> CRLiteKey<'_> { + CRLiteKey::new(&self.issuer_spki_hash.0, &self.cert_serial.0) + } +} + +#[derive(Clone, Debug)] +pub struct CertSerial(pub Vec); + +impl FromStr for CertSerial { + type Err = Report; + + fn from_str(value: &str) -> Result { + match BASE64_STANDARD.decode(value) { + Ok(bytes) => Ok(Self(bytes)), + Err(e) => Err(e).wrap_err("cannot parse base64 serial number"), + } + } +} + +#[derive(Clone, Debug)] +pub struct IssuerSpkiHash(pub [u8; 32]); + +impl FromStr for IssuerSpkiHash { + type Err = Report; + + fn from_str(value: &str) -> Result { + Ok(Self( + BASE64_STANDARD + .decode(value) + .wrap_err("cannot parse issuer SPKI hash")? + .try_into() + .map_err(|b: Vec| { + eyre!("issuer SPKI hash is wrong length (was {} bytes)", b.len()) + })?, + )) + } +} + +#[derive(Clone, Debug)] +pub struct CtTimestamp { + pub log_id: [u8; 32], + pub timestamp: u64, +} + +impl FromStr for CtTimestamp { + type Err = Report; + + fn from_str(value: &str) -> Result { + let Some((log_id, issuance_timestamp)) = value.split_once(":") else { + return Err(eyre!("missing colon in CT timestamp")); + }; + + Ok(Self { + log_id: BASE64_STANDARD + .decode(log_id) + .wrap_err("cannot parse CT log ID")? + .try_into() + .map_err(|wrong: Vec| { + eyre!("CT log ID is wrong length (was {} bytes)", wrong.len()) + })?, + timestamp: issuance_timestamp + .parse() + .wrap_err("cannot parse CT timestamp")?, + }) + } +} + +/// The successful outcome of a revocation check. +/// +/// Look at a value of this type to determine whether a certificate was revoked or not. +#[derive(Debug, PartialEq)] +#[must_use] +pub enum RevocationStatus { + /// We couldn't determine the revocation status. + /// + /// Most likely, this certificate is very new and is not covered by the current filter dataset. + NotCoveredByRevocationData, + + /// This certificate has been revoked. + CertainlyRevoked, + + /// This certificate was covered by revocation data, and it is not currently revoked. + NotRevoked, +} + +#[derive(Debug)] +pub(crate) enum Error { + /// `crlite_clubcard::CRLiteClubcard` couldn't deserialize the filter data. + CorruptCrliteFilter, +} + +impl fmt::Display for Error { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::CorruptCrliteFilter => write!(f, "corrupt CRLite filter data"), + } + } +} + +impl StdError for Error {} + +/// Details about crlite-style revocation. +#[derive(Debug, Deserialize, Serialize)] +#[serde(rename_all = "kebab-case", deny_unknown_fields)] +pub struct RevocationConfig { + /// Where to fetch revocation data files. + fetch_url: String, +} + +impl Default for RevocationConfig { + fn default() -> Self { + Self { + fetch_url: "https://upki.rustls.dev/".into(), + } + } +} diff --git a/upki/tests/integration.rs b/upki/tests/integration.rs index b7fa05e..4adf7e6 100644 --- a/upki/tests/integration.rs +++ b/upki/tests/integration.rs @@ -36,7 +36,7 @@ fn config_unknown_fields() { .arg("--config-file") .arg("tests/data/config_unknown_fields/config.toml") .arg("show-config"), - @r#" + @r###" success: false exit_code: 1 ----- stdout ----- @@ -49,12 +49,12 @@ fn config_unknown_fields() { | 1 | cache_dir = "tests/data/config_unknown_fields/" | ^^^^^^^^^ - unknown field `cache_dir`, expected `revocation` + unknown field `cache_dir`, expected `cache-dir` or `revocation` Location: - upki/src/config.rs:[LINE]:[COLUMN] - "#); + upki/src/lib.rs:[LINE]:[COLUMN] + "###); } #[test] @@ -104,7 +104,7 @@ fn verify_of_non_existent_dir() { .arg("--config-file") .arg("tests/data/verify_non_existent_dir/config.toml") .arg("verify"), - @r#" + @r###" success: false exit_code: 1 ----- stdout ----- @@ -116,8 +116,8 @@ fn verify_of_non_existent_dir() { No such file or directory (os error 2) Location: - upki/src/lib.rs:[LINE]:[COLUMN] - "#); + upki/src/revocation/mod.rs:[LINE]:[COLUMN] + "###); } #[test]