diff --git a/packages/hurl/src/http/certificate.rs b/packages/hurl/src/http/certificate.rs index b091772a4..81e20818b 100644 --- a/packages/hurl/src/http/certificate.rs +++ b/packages/hurl/src/http/certificate.rs @@ -16,7 +16,9 @@ * */ -use chrono::{DateTime, Utc}; +use crate::http::easy_ext::CertInfo; +use chrono::{DateTime, NaiveDateTime, Utc}; +use std::collections::HashMap; #[derive(Clone, Debug, PartialEq, Eq)] pub struct Certificate { @@ -26,3 +28,160 @@ pub struct Certificate { pub expire_date: DateTime, pub serial_number: String, } + +impl TryFrom for Certificate { + type Error = String; + + /// parse `cert_info` + /// support different "formats" in cert info + /// - attribute name: "Start date" vs "Start Date" + /// - date format: "Jan 10 08:29:52 2023 GMT" vs "2023-01-10 08:29:52 GMT" + fn try_from(cert_info: CertInfo) -> Result { + let attributes = parse_attributes(&cert_info.data); + let subject = parse_subject(&attributes)?; + let issuer = parse_issuer(&attributes)?; + let start_date = parse_start_date(&attributes)?; + let expire_date = parse_expire_date(&attributes)?; + let serial_number = parse_serial_number(&attributes)?; + Ok(Certificate { + subject, + issuer, + start_date, + expire_date, + serial_number, + }) + } +} + +fn parse_subject(attributes: &HashMap) -> Result { + attributes + .get("subject") + .cloned() + .ok_or(format!("missing Subject attribute in {attributes:?}")) +} + +fn parse_issuer(attributes: &HashMap) -> Result { + attributes + .get("issuer") + .cloned() + .ok_or(format!("missing issuer attribute in {attributes:?}")) +} + +fn parse_start_date(attributes: &HashMap) -> Result, String> { + match attributes.get("start date") { + None => Err(format!("missing start date attribute in {attributes:?}")), + Some(value) => Ok(parse_date(value)?), + } +} + +fn parse_expire_date(attributes: &HashMap) -> Result, String> { + match attributes.get("expire date") { + None => Err("missing expire date attribute".to_string()), + Some(value) => Ok(parse_date(value)?), + } +} + +fn parse_date(value: &str) -> Result, String> { + let naive_date_time = match NaiveDateTime::parse_from_str(value, "%b %d %H:%M:%S %Y GMT") { + Ok(d) => d, + Err(_) => NaiveDateTime::parse_from_str(value, "%Y-%m-%d %H:%M:%S GMT") + .map_err(|_| format!("can not parse date <{value}>"))?, + }; + Ok(naive_date_time.and_local_timezone(Utc).unwrap()) +} + +fn parse_serial_number(attributes: &HashMap) -> Result { + attributes + .get("serial number") + .cloned() + .ok_or(format!("Missing serial number attribute in {attributes:?}")) +} + +fn parse_attributes(data: &Vec) -> HashMap { + let mut map = HashMap::new(); + for s in data { + if let Some((name, value)) = parse_attribute(s) { + map.insert(name.to_lowercase(), value); + } + } + map +} + +fn parse_attribute(s: &str) -> Option<(String, String)> { + if let Some(index) = s.find(':') { + let (name, value) = s.split_at(index); + Some((name.to_string(), value[1..].to_string())) + } else { + None + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::http::certificate::Certificate; + use crate::http::easy_ext::CertInfo; + + #[test] + fn test_parse_start_date() { + let mut attributes = HashMap::new(); + attributes.insert( + "start date".to_string(), + "Jan 10 08:29:52 2023 GMT".to_string(), + ); + assert_eq!( + parse_start_date(&attributes).unwrap(), + chrono::DateTime::parse_from_rfc2822("Tue, 10 Jan 2023 08:29:52 GMT") + .unwrap() + .with_timezone(&chrono::Utc) + ); + + let mut attributes = HashMap::new(); + attributes.insert( + "start date".to_string(), + "2023-01-10 08:29:52 GMT".to_string(), + ); + assert_eq!( + parse_start_date(&attributes).unwrap(), + chrono::DateTime::parse_from_rfc2822("Tue, 10 Jan 2023 08:29:52 GMT") + .unwrap() + .with_timezone(&chrono::Utc) + ) + } + + #[test] + fn test_try_from() { + assert_eq!( + Certificate::try_from(CertInfo { + data: vec![ + "Subject:C = US, ST = Denial, L = Springfield, O = Dis, CN = localhost" + .to_string(), + "Issuer:C = US, ST = Denial, L = Springfield, O = Dis, CN = localhost" + .to_string(), + "Serial Number:1ee8b17f1b64d8d6b3de870103d2a4f533535ab0".to_string(), + "Start date:Jan 10 08:29:52 2023 GMT".to_string(), + "Expire date:Oct 30 08:29:52 2025 GMT".to_string(), + ] + }) + .unwrap(), + Certificate { + subject: "C = US, ST = Denial, L = Springfield, O = Dis, CN = localhost" + .to_string(), + issuer: "C = US, ST = Denial, L = Springfield, O = Dis, CN = localhost".to_string(), + start_date: chrono::DateTime::parse_from_rfc2822("Tue, 10 Jan 2023 08:29:52 GMT") + .unwrap() + .with_timezone(&chrono::Utc), + expire_date: chrono::DateTime::parse_from_rfc2822("Thu, 30 Oct 2025 08:29:52 GMT") + .unwrap() + .with_timezone(&chrono::Utc), + serial_number: "1ee8b17f1b64d8d6b3de870103d2a4f533535ab0".to_string() + } + ); + assert_eq!( + Certificate::try_from(CertInfo { data: vec![] }) + .err() + .unwrap(), + "missing Subject attribute in {}".to_string() + ); + } +} diff --git a/packages/hurl/src/http/client.rs b/packages/hurl/src/http/client.rs index a5f8c70a1..f63f1c47a 100644 --- a/packages/hurl/src/http/client.rs +++ b/packages/hurl/src/http/client.rs @@ -29,6 +29,7 @@ use super::request::*; use super::request_spec::*; use super::response::*; use super::{Header, HttpError, Verbosity}; +use crate::http::certificate::Certificate; use crate::http::{easy_ext, ContextDir}; use crate::util::logger::Logger; use base64::engine::general_purpose; @@ -314,8 +315,17 @@ impl Client { let headers = self.parse_response_headers(&response_headers); let duration = start.elapsed(); let length = response_body.len(); - let certificate = None; - let _certinfo = easy_ext::get_certinfo(&self.handle)?; + let certificate = if let Some(cert_info) = easy_ext::get_certinfo(&self.handle)? { + match Certificate::try_from(cert_info) { + Ok(value) => Some(value), + Err(message) => { + logger.error(format!("can not parse certificate - {message}").as_str()); + None + } + } + } else { + None + }; self.handle.reset(); let request = Request { diff --git a/packages/hurl/src/http/easy_ext.rs b/packages/hurl/src/http/easy_ext.rs index 46a5a4f94..ba0cc09fb 100644 --- a/packages/hurl/src/http/easy_ext.rs +++ b/packages/hurl/src/http/easy_ext.rs @@ -23,6 +23,7 @@ use std::ptr; /// Represents certificate information. /// `data` has format "name:content"; +#[derive(Clone)] pub struct CertInfo { pub data: Vec, } diff --git a/packages/hurl/src/http/mod.rs b/packages/hurl/src/http/mod.rs index 5f4e49154..2e0e71d6f 100644 --- a/packages/hurl/src/http/mod.rs +++ b/packages/hurl/src/http/mod.rs @@ -16,6 +16,7 @@ * */ +pub use self::certificate::Certificate; pub use self::client::Client; pub use self::context_dir::ContextDir; pub use self::cookie::{CookieAttribute, ResponseCookie}; diff --git a/packages/hurl/src/json/result.rs b/packages/hurl/src/json/result.rs index e93bba251..9e196408b 100644 --- a/packages/hurl/src/json/result.rs +++ b/packages/hurl/src/json/result.rs @@ -16,10 +16,11 @@ * */ use crate::http::{ - Cookie, Header, Param, Request, RequestCookie, Response, ResponseCookie, Version, + Certificate, Cookie, Header, Param, Request, RequestCookie, Response, ResponseCookie, Version, }; use crate::runner::{AssertResult, Call, CaptureResult, EntryResult, HurlResult}; use crate::util::logger; +use chrono::{DateTime, Utc}; impl HurlResult { /// Serializes an [`HurlResult`] to a JSON representation. @@ -125,6 +126,9 @@ impl Response { map.insert("headers".to_string(), headers); let cookies = self.cookies().iter().map(|e| e.to_json()).collect(); map.insert("cookies".to_string(), serde_json::Value::Array(cookies)); + if let Some(certificate) = &self.certificate { + map.insert("certificate".to_string(), certificate.to_json()); + } serde_json::Value::Object(map) } } @@ -237,6 +241,27 @@ impl ResponseCookie { } } +impl Certificate { + fn to_json(&self) -> serde_json::Value { + let mut map = serde_json::Map::new(); + map.insert( + "subject".to_string(), + serde_json::Value::String(self.subject.clone()), + ); + map.insert( + "issue".to_string(), + serde_json::Value::String(self.issuer.clone()), + ); + map.insert("start_date".to_string(), json_date(self.start_date)); + map.insert("expire_date".to_string(), json_date(self.expire_date)); + map.insert( + "serial_number".to_string(), + serde_json::Value::String(self.serial_number.clone()), + ); + serde_json::Value::Object(map) + } +} + impl CaptureResult { fn to_json(&self) -> serde_json::Value { let mut map = serde_json::Map::new(); @@ -303,3 +328,7 @@ impl Cookie { serde_json::Value::Object(map) } } + +fn json_date(value: DateTime) -> serde_json::Value { + serde_json::Value::String(value.to_string()) +} diff --git a/packages/hurl/tests/libcurl.rs b/packages/hurl/tests/libcurl.rs index 6044fdec0..76a1946ae 100644 --- a/packages/hurl/tests/libcurl.rs +++ b/packages/hurl/tests/libcurl.rs @@ -613,6 +613,45 @@ fn test_cacert() { let request_spec = default_get_request("https://localhost:8001/hello"); let (_, response) = client.execute(&request_spec, &options, &logger).unwrap(); assert_eq!(response.status, 200); + + let certificate = response.certificate.unwrap(); + + let issuer = certificate.issuer; + let issuers = [ + "C = US, ST = Denial, L = Springfield, O = Dis, CN = localhost".to_string(), + "C=US, ST=Denial, L=Springfield, O=Dis, CN=localhost".to_string(), + ]; + assert!(issuers.contains(&issuer), "actual issuer is {issuer}"); + + let subject = certificate.subject; + let subjects = [ + "C = US, ST = Denial, L = Springfield, O = Dis, CN = localhost".to_string(), + "C=US, ST=Denial, L=Springfield, O=Dis, CN=localhost".to_string(), + ]; + assert!(subjects.contains(&subject), "actual subject is {subject}"); + + assert_eq!( + certificate.start_date, + chrono::DateTime::parse_from_rfc2822("Tue, 10 Jan 2023 08:29:52 GMT") + .unwrap() + .with_timezone(&chrono::Utc) + ); + assert_eq!( + certificate.expire_date, + chrono::DateTime::parse_from_rfc2822("Thu, 30 Oct 2025 08:29:52 GMT") + .unwrap() + .with_timezone(&chrono::Utc) + ); + + let serial_number = certificate.serial_number; + let serial_numbers = [ + "1ee8b17f1b64d8d6b3de870103d2a4f533535ab0".to_string(), + "1e:e8:b1:7f:1b:64:d8:d6:b3:de:87:01:03:d2:a4:f5:33:53:5a:b0:".to_string(), + ]; + assert!( + serial_numbers.contains(&serial_number), + "actual serial_number is {serial_number}" + ); } #[test]