ccadb_csv_fetch/
lib.rs

1use std::error::Error;
2use std::fs::File;
3use std::io::{Read, Take};
4use std::path::Path;
5use std::sync::Arc;
6use std::time::Duration;
7use std::{fmt, io, result};
8
9/// Convenience type for functions that return a `T` on success or a [`FetchError`] otherwise.
10pub type Result<T> = result::Result<T, FetchError>;
11
12/// An error that can occur while fetching or parsing a CCADB data source.
13#[derive(Debug)]
14#[non_exhaustive]
15pub enum FetchError {
16    /// An HTTP level error fetching the CSV data from the CCADB API.
17    #[non_exhaustive]
18    Api { source: Box<ureq::Error> },
19
20    /// An error that occurred while processing CCADB CSV data.
21    #[non_exhaustive]
22    DataSource {
23        source: Box<ccadb_csv::DataSourceError>,
24    },
25
26    /// An error writing CCADB CSV to disk.
27    #[non_exhaustive]
28    File { source: io::Error },
29
30    /// An unknown report type was requested.
31    #[non_exhaustive]
32    UnknownReport { name: String },
33}
34
35impl fmt::Display for FetchError {
36    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
37        match self {
38            FetchError::Api { source } => {
39                write!(f, "failed to fetch CCADB CSV: {source}")
40            }
41            FetchError::DataSource { source } => {
42                write!(f, "failed to decode fetched CCADB CSV: {source}")
43            }
44            FetchError::File { source } => {
45                write!(f, "failed to write fetched CCADB CSV: {source}")
46            }
47            FetchError::UnknownReport { name } => {
48                write!(f, "unknown report type: {name}")
49            }
50        }
51    }
52}
53
54impl Error for FetchError {
55    fn source(&self) -> Option<&(dyn Error + 'static)> {
56        match self {
57            FetchError::Api { source } => Some(source),
58            FetchError::DataSource { source } => Some(source),
59            FetchError::File { source } => Some(source),
60            FetchError::UnknownReport { .. } => None,
61        }
62    }
63}
64
65impl From<ureq::Error> for FetchError {
66    fn from(source: ureq::Error) -> Self {
67        let source = Box::new(source);
68        FetchError::Api { source }
69    }
70}
71
72impl From<ccadb_csv::DataSourceError> for FetchError {
73    fn from(source: ccadb_csv::DataSourceError) -> Self {
74        let source = Box::new(source);
75        FetchError::DataSource { source }
76    }
77}
78
79impl From<io::Error> for FetchError {
80    fn from(source: io::Error) -> Self {
81        FetchError::File { source }
82    }
83}
84
85/// Types of CCADB CSV reports that can be fetched.
86pub enum ReportType {
87    /// Metadata report for all certificates (roots and intermediates) in the CCADB.
88    AllCertRecords,
89
90    /// Metadata report for Mozilla included root certificates in the CCADB (with PEM).
91    MozillaIncludedRoots,
92}
93
94impl ReportType {
95    #[must_use]
96    pub fn url(&self) -> &str {
97        match self {
98            ReportType::AllCertRecords => ccadb_csv::all_cert_records::URL,
99            ReportType::MozillaIncludedRoots => ccadb_csv::mozilla_included_roots::URL,
100        }
101    }
102}
103
104impl fmt::Display for ReportType {
105    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
106        match self {
107            ReportType::AllCertRecords => write!(f, "all-cert-records"),
108            ReportType::MozillaIncludedRoots => write!(f, "mozilla-included-roots"),
109        }
110    }
111}
112
113impl TryFrom<&str> for ReportType {
114    type Error = FetchError;
115
116    fn try_from(report_type: &str) -> result::Result<Self, Self::Error> {
117        match report_type {
118            "all-cert-records" => Ok(ReportType::AllCertRecords),
119            "mozilla-included-roots" => Ok(ReportType::MozillaIncludedRoots),
120            _ => Err(FetchError::UnknownReport {
121                name: report_type.to_owned(),
122            }),
123        }
124    }
125}
126
127/// Fetch the provided report type's CSV from CCADB, writing the result to output.
128///
129/// # Errors
130///
131/// Returns an error if the output file can't be created, if the report URL can't be downloaded,
132/// or if the report CSV can't be parsed.
133pub fn fetch_report(report_type: &ReportType, output: impl AsRef<Path>) -> Result<u64> {
134    let mut output_file = File::create(output)?;
135    let mut csv_reader = read_csv_url(report_type.url())?;
136    Ok(io::copy(&mut csv_reader, &mut output_file)?)
137}
138
139fn read_csv_url(url: &str) -> Result<Take<Box<dyn Read + Send + Sync>>> {
140    let agent = ureq::builder()
141        .tls_config(Arc::new(tls_config()))
142        .timeout_read(Duration::from_secs(60))
143        .user_agent(format!("ccadb-csv-fetch/{VERSION}").as_ref())
144        .build();
145
146    Ok(agent.get(url).call()?.into_reader().take(READ_LIMIT))
147}
148
149// builds a TLS ClientConfig that has only one trust anchor, the vendored
150// CCADB_API_ROOT.
151fn tls_config() -> rustls::ClientConfig {
152    let anchor_der = rustls::pki_types::CertificateDer::from(CCADB_API_ROOT);
153    let anchor = webpki::anchor_from_trusted_cert(&anchor_der)
154        .unwrap()
155        .to_owned();
156    let root_store = rustls::RootCertStore {
157        roots: vec![anchor].into_iter().collect(),
158    };
159    rustls::ClientConfig::builder()
160        .with_root_certificates(root_store)
161        .with_no_client_auth()
162}
163
164const VERSION: &str = env!("CARGO_PKG_VERSION");
165
166/// `READ_LIMIT` prevents the server from exhausting our memory with a huge response. It should
167/// be larger than all of the CCADB CSV file sizes.
168const READ_LIMIT: u64 = 25_000_000; // 25 MB (SI).
169
170/// Root certificate used to anchor the certificate chain offered by the CCADB API endpoints.
171/// This is hardcoded to a vendored copy of the DER encoding of the root certificate to allow
172/// the ccadb-utils to be used to generate a webpki-roots compatible root store without
173/// depending on a platform root store, or a webpki-roots dependency.
174///
175/// If the Salesforce API certificate chain changes we will have to update this root certificate.
176///
177/// Sourced out-of-band from <https://cacerts.digicert.com/DigiCertGlobalRootCA.crt>
178const CCADB_API_ROOT: &[u8] = include_bytes!("DigiCertGlobalRootCA.crt");
179
180#[cfg(test)]
181mod tests {
182    use crate::{read_csv_url, ReportType};
183    use std::io::{BufRead, BufReader};
184
185    /// Quick-n-dirty test to see if the upstream data has changed format.
186    /// We do this in this crate instead of ccadb-csv because we already
187    /// have the machinery to download the report CSV here.
188    #[test]
189    fn csv_header_check() {
190        // Keep in-sync with ccadb-csv/src/lib.rs.
191        let expected_headers = [
192            (
193                ReportType::AllCertRecords,
194                r#""CA Owner","Salesforce Record ID","Certificate Name","Parent Salesforce Record ID","Parent Certificate Name","Certificate Record Type","Revocation Status","SHA-256 Fingerprint","Parent SHA-256 Fingerprint","Audits Same as Parent?","Auditor","Standard Audit URL","Standard Audit Type","Standard Audit Statement Date","Standard Audit Period Start Date","Standard Audit Period End Date","NetSec Audit URL","NetSec Audit Type","NetSec Audit Statement Date","NetSec Audit Period Start Date","NetSec Audit Period End Date","TLS BR Audit URL","TLS BR Audit Type","TLS BR Audit Statement Date","TLS BR Audit Period Start Date","TLS BR Audit Period End Date","TLS EVG Audit URL","TLS EVG Audit Type","TLS EVG Audit Statement Date","TLS EVG Audit Period Start Date","TLS EVG Audit Period End Date","Code Signing Audit URL","Code Signing Audit Type","Code Signing Audit Statement Date","Code Signing Audit Period Start Date","Code Signing Audit Period End Date","S/MIME BR Audit URL","S/MIME BR Audit Type","S/MIME BR Audit Statement Date","S/MIME BR Audit Period Start Date","S/MIME BR Audit Period End Date","CP/CPS Same as Parent?","Certificate Policy (CP) URL","Certificate Practice Statement (CPS) URL","CP/CPS Last Updated Date","Test Website URL - Valid","Test Website URL - Expired","Test Website URL - Revoked","Technically Constrained","Subordinate CA Owner","Full CRL Issued By This CA","JSON Array of Partitioned CRLs","Valid From (GMT)","Valid To (GMT)","Derived Trust Bits","Chrome Status","Microsoft Status","Mozilla Status","Status of Root Cert","Authority Key Identifier","Subject Key Identifier","Country","TLS Capable","TLS EV Capable","Code Signing Capable","S/MIME Capable""#,
195            ),
196            (
197                ReportType::MozillaIncludedRoots,
198                r#""Owner","Certificate Issuer Organization","Certificate Issuer Organizational Unit","Common Name or Certificate Name","Certificate Serial Number","SHA-256 Fingerprint","Subject + SPKI SHA256","Valid From [GMT]","Valid To [GMT]","Public Key Algorithm","Signature Hash Algorithm","Trust Bits","Distrust for TLS After Date","Distrust for S/MIME After Date","EV Policy OID(s)","Approval Bug","NSS Release When First Included","Firefox Release When First Included","Test Website - Valid","Test Website - Expired","Test Website - Revoked","Mozilla Applied Constraints","Company Website","Geographic Focus","Certificate Policy (CP)","Certification Practice Statement (CPS)","Standard Audit","BR Audit","EV Audit","Auditor","Standard Audit Type","Standard Audit Statement Dt","PEM Info""#,
199            ),
200        ];
201
202        for (report_type, expected_header) in expected_headers {
203            let report_data = read_csv_url(report_type.url()).expect("CSV URL fetch failed");
204
205            let mut buf_reader = BufReader::new(report_data);
206            let mut first_line = String::new();
207            buf_reader
208                .read_line(&mut first_line)
209                .expect("CSV missing header line");
210
211            assert_eq!(first_line.trim(), expected_header);
212        }
213    }
214}