diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1f54a2d..808d563 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -48,3 +48,12 @@ jobs: with: components: rustfmt - run: cargo fmt --all -- --check + + audit: + name: Security audit + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: rustsec/audit-check@v2 + with: + token: ${{ secrets.GITHUB_TOKEN }} diff --git a/src/cert/mod.rs b/src/cert/mod.rs index 4dcdfcd..4b10456 100644 --- a/src/cert/mod.rs +++ b/src/cert/mod.rs @@ -126,7 +126,7 @@ impl fmt::Display for CertTime { } /// Convert days since Unix epoch to (year, month, day) -fn days_to_ymd(days: i32) -> (i32, u8, u8) { +pub(crate) fn days_to_ymd(days: i32) -> (i32, u8, u8) { // Algorithm from http://howardhinnant.github.io/date_algorithms.html let z = days + 719468; let era = if z >= 0 { z } else { z - 146096 } / 146097; @@ -140,3 +140,106 @@ fn days_to_ymd(days: i32) -> (i32, u8, u8) { let y = if m <= 2 { y + 1 } else { y }; (y, m, d) } + +#[cfg(test)] +mod tests { + use super::*; + + // ── days_to_ymd ─────────────────────────────────────────────────────────── + + #[test] + fn test_days_to_ymd_epoch() { + // Day 0 = 1970-01-01 + assert_eq!(days_to_ymd(0), (1970, 1, 1)); + } + + #[test] + fn test_days_to_ymd_known_dates() { + // 2000-01-01 = 10957 days after epoch + assert_eq!(days_to_ymd(10957), (2000, 1, 1)); + // 19796 days from epoch = 2024-03-14 + assert_eq!(days_to_ymd(19796), (2024, 3, 14)); + } + + #[test] + fn test_days_to_ymd_leap_year_feb29() { + // 2000-02-29 (leap year) + assert_eq!(days_to_ymd(11016), (2000, 2, 29)); + } + + #[test] + fn test_days_to_ymd_end_of_year() { + // 2023-12-31 = 19722 days after epoch + assert_eq!(days_to_ymd(19722), (2023, 12, 31)); + } + + // ── CertTime::from_timestamp ────────────────────────────────────────────── + + #[test] + fn test_certtime_from_timestamp_epoch() { + let t = CertTime::from_timestamp(0); + assert_eq!(t.timestamp, 0); + assert_eq!(t.year, 1970); + assert_eq!(t.month, 1); + assert_eq!(t.day, 1); + assert_eq!(t.hour, 0); + assert_eq!(t.min, 0); + assert_eq!(t.sec, 0); + } + + #[test] + fn test_certtime_from_timestamp_known() { + // 1710502496 = 2024-03-15 11:34:56 UTC + let ts = 1710502496_i64; + let t = CertTime::from_timestamp(ts); + assert_eq!(t.year, 2024); + assert_eq!(t.month, 3); + assert_eq!(t.day, 15); + assert_eq!(t.hour, 11); + assert_eq!(t.min, 34); + assert_eq!(t.sec, 56); + } + + #[test] + fn test_certtime_from_timestamp_midnight() { + // A known midnight: 2020-01-01 00:00:00 = 1577836800 + let t = CertTime::from_timestamp(1577836800); + assert_eq!(t.year, 2020); + assert_eq!(t.month, 1); + assert_eq!(t.day, 1); + assert_eq!(t.hour, 0); + assert_eq!(t.min, 0); + assert_eq!(t.sec, 0); + } + + // ── CertTime::format ────────────────────────────────────────────────────── + + #[test] + fn test_certtime_format_iso() { + let t = CertTime::from_timestamp(0); + assert_eq!(t.format("%Y-%m-%d"), "1970-01-01"); + assert_eq!(t.format("%H:%M:%S"), "00:00:00"); + } + + #[test] + fn test_certtime_format_combined() { + let t = CertTime::from_timestamp(1710502496); + assert_eq!(t.format("%Y-%m-%d %H:%M:%S"), "2024-03-15 11:34:56"); + } + + #[test] + fn test_certtime_format_zero_padding() { + // 946857903 = 2000-01-03 00:05:03 UTC + let ts = 946_857_903_i64; + let t = CertTime::from_timestamp(ts); + let formatted = t.format("%Y-%m-%d %H:%M:%S"); + assert_eq!(&formatted[5..7], "01"); // month + assert_eq!(&formatted[8..10], "03"); // day + } + + #[test] + fn test_certtime_display() { + let t = CertTime::from_timestamp(0); + assert_eq!(t.to_string(), "1970-01-01 00:00:00"); + } +} diff --git a/src/cert/parser.rs b/src/cert/parser.rs index d6d56fa..4487149 100644 --- a/src/cert/parser.rs +++ b/src/cert/parser.rs @@ -15,17 +15,30 @@ fn format_hex(bytes: &[u8]) -> String { pub fn parse_cert_file(path: &str) -> Result> { let data = std::fs::read(path).with_context(|| format!("can't read {}", path))?; + parse_cert_data_with_source(&data, path) +} + +/// Parse certificate(s) from raw bytes (PEM or DER). +/// Use this when data is already in memory (e.g. read from stdin). +pub fn parse_cert_data(data: &[u8]) -> Result> { + parse_cert_data_with_source(data, "stdin") +} - if is_pem(&data) { - parse_pem_certs(&data) - } else if is_der(&data) { - parse_der_cert(&data).map(|c| vec![c]) +pub fn parse_cert_data_from(data: &[u8], source: &str) -> Result> { + parse_cert_data_with_source(data, source) +} + +fn parse_cert_data_with_source(data: &[u8], source: &str) -> Result> { + if is_pem(data) { + parse_pem_certs(data) + } else if is_der(data) { + parse_der_cert(data).map(|c| vec![c]) } else { bail!( "Unrecognized certificate format in '{}'. Expected PEM or DER.\n\ Hint: PEM files start with '-----BEGIN CERTIFICATE-----'\n\ - Hint: For PKCS12 (.p12/.pfx), use --pkcs12 flag", - path + Hint: For PKCS12 (.p12/.pfx), use the convert or extract command", + source ) } } @@ -231,3 +244,156 @@ fn sha256_hex(data: &[u8]) -> String { } // TODO: support PKCS8 encrypted private keys + +#[cfg(test)] +mod tests { + use super::*; + + // ── helpers ────────────────────────────────────────────────────────────── + + /// Build a minimal self-signed DER certificate using rcgen. + fn make_self_signed_der(cn: &str, sans: &[&str]) -> Vec { + use rcgen::{CertificateParams, DistinguishedName, DnType, SanType}; + + let mut params = CertificateParams::default(); + let mut dn = DistinguishedName::new(); + dn.push(DnType::CommonName, cn); + params.distinguished_name = dn; + params.subject_alt_names = sans + .iter() + .map(|s| SanType::DnsName(s.to_string().try_into().unwrap())) + .collect(); + + let kp = rcgen::KeyPair::generate().unwrap(); + let cert = params.self_signed(&kp).unwrap(); + cert.der().to_vec() + } + + /// Wrap DER bytes in a PEM block. + fn der_to_pem(der: &[u8]) -> Vec { + use base64::Engine; + let b64 = base64::engine::general_purpose::STANDARD.encode(der); + let mut pem = String::from("-----BEGIN CERTIFICATE-----\n"); + for chunk in b64.as_bytes().chunks(64) { + pem.push_str(std::str::from_utf8(chunk).unwrap()); + pem.push('\n'); + } + pem.push_str("-----END CERTIFICATE-----\n"); + pem.into_bytes() + } + + // ── parse_pem_certs ─────────────────────────────────────────────────────── + + #[test] + fn test_parse_pem_single_cert() { + let der = make_self_signed_der("example.com", &["example.com"]); + let pem = der_to_pem(&der); + + let certs = parse_pem_certs(&pem).unwrap(); + assert_eq!(certs.len(), 1); + assert!(certs[0].subject.contains("example.com")); + } + + #[test] + fn test_parse_pem_bundle_multiple_certs() { + let der1 = make_self_signed_der("first.example.com", &["first.example.com"]); + let der2 = make_self_signed_der("second.example.com", &["second.example.com"]); + let mut bundle = der_to_pem(&der1); + bundle.extend(der_to_pem(&der2)); + + let certs = parse_pem_certs(&bundle).unwrap(); + assert_eq!(certs.len(), 2); + } + + #[test] + fn test_parse_pem_empty_returns_error() { + let result = parse_pem_certs(b""); + assert!(result.is_err()); + let msg = result.unwrap_err().to_string(); + assert!(msg.contains("No certificates")); + } + + #[test] + fn test_parse_pem_invalid_base64_returns_error() { + let bad = + b"-----BEGIN CERTIFICATE-----\n!!!not-valid-base64!!!\n-----END CERTIFICATE-----\n"; + let result = parse_pem_certs(bad); + assert!(result.is_err()); + } + + // ── parse_der_cert ──────────────────────────────────────────────────────── + + #[test] + fn test_parse_der_cert_roundtrip() { + let der = make_self_signed_der("der-test.example.com", &["der-test.example.com"]); + let cert = parse_der_cert(&der).unwrap(); + + assert!(cert.subject.contains("der-test.example.com")); + assert!(cert.issuer.contains("der-test.example.com")); // self-signed + assert!(cert.sans.contains(&"der-test.example.com".to_string())); + assert!(!cert.sha256_fingerprint.is_empty()); + } + + #[test] + fn test_parse_der_cert_invalid_returns_error() { + let garbage = b"\x30\x00\x00\x00junk-data-that-is-not-a-cert"; + let result = parse_der_cert(garbage); + assert!(result.is_err()); + } + + // ── sha256_of ───────────────────────────────────────────────────────────── + + #[test] + fn test_sha256_of_known_input() { + // SHA-256("") = e3b0c44298fc1c149afbf4c8996fb924... + let hash = sha256_of(b""); + assert!(hash.starts_with("E3:B0:C4:42:98:FC:1C:14")); + } + + #[test] + fn test_sha256_of_hello_world() { + // SHA-256("hello world") starts with b94d27b9... + let hash = sha256_of(b"hello world"); + assert!(hash.starts_with("B9:4D:27:B9")); + } + + #[test] + fn test_sha256_format_is_colon_separated_uppercase_hex() { + let hash = sha256_of(b"test"); + let parts: Vec<&str> = hash.split(':').collect(); + assert_eq!(parts.len(), 32); // SHA-256 = 32 bytes + for part in parts { + assert_eq!(part.len(), 2); + assert!(part.chars().all(|c| c.is_ascii_hexdigit())); + } + } + + // ── base64_decode_str ───────────────────────────────────────────────────── + + #[test] + fn test_base64_decode_valid() { + // "hello" in base64 + let decoded = base64_decode_str("aGVsbG8=").unwrap(); + assert_eq!(decoded, b"hello"); + } + + #[test] + fn test_base64_decode_empty_string() { + let decoded = base64_decode_str("").unwrap(); + assert!(decoded.is_empty()); + } + + #[test] + fn test_base64_decode_invalid_returns_error() { + let result = base64_decode_str("!!!not-valid!!!"); + assert!(result.is_err()); + } + + #[test] + fn test_base64_decode_with_whitespace_padding() { + // Leading/trailing whitespace should be trimmed and still parse + let result = base64_decode_str(" aGVsbG8= "); + assert!(result.is_ok()); + assert_eq!(result.unwrap(), b"hello"); + } +} diff --git a/src/commands/decode.rs b/src/commands/decode.rs index cb7c971..973dd02 100644 --- a/src/commands/decode.rs +++ b/src/commands/decode.rs @@ -1,12 +1,17 @@ use anyhow::{Context, Result}; +use std::io::Read; use crate::output::{box_chars, colors}; pub fn run(input: &str, json: bool, no_color: bool) -> Result { let use_color = !no_color && !json && colors::should_color(); - // Check if input is a file path or inline string - let (data, source) = if std::path::Path::new(input).exists() { + // Check if input is stdin, a file path, or inline string + let (data, source) = if input == "-" { + let mut buf = Vec::new(); + std::io::stdin().read_to_end(&mut buf)?; + (buf, "".to_string()) + } else if std::path::Path::new(input).exists() { let data = std::fs::read(input).with_context(|| format!("Failed to read: {}", input))?; (data, input.to_string()) } else { @@ -18,7 +23,7 @@ pub fn run(input: &str, json: bool, no_color: bool) -> Result { // Try to detect the type if text.starts_with("-----BEGIN CERTIFICATE") { - return decode_as_cert(input, json, no_color, use_color, "PEM Certificate"); + return decode_as_cert(&data, &source, json, no_color, use_color, "PEM Certificate"); } if text.starts_with("-----BEGIN CERTIFICATE REQUEST") @@ -53,7 +58,7 @@ pub fn run(input: &str, json: bool, no_color: bool) -> Result { // DER certificate if !data.is_empty() && data[0] == 0x30 && source != "inline" { - return decode_as_cert(input, json, no_color, use_color, "DER Certificate"); + return decode_as_cert(&data, &source, json, no_color, use_color, "DER Certificate"); } if json { @@ -83,7 +88,8 @@ pub fn run(input: &str, json: bool, no_color: bool) -> Result { } fn decode_as_cert( - path: &str, + data: &[u8], + source: &str, json: bool, no_color: bool, use_color: bool, @@ -94,7 +100,8 @@ fn decode_as_cert( } else { println!("\n Detected: {}\n", label); } - crate::commands::inspect::run(path, json, no_color) + let certs = crate::cert::parser::parse_cert_data_from(data, source)?; + crate::commands::inspect::run_certs(&certs, json, no_color) } fn decode_as_csr(text: &str, json: bool, use_color: bool) -> Result { diff --git a/src/commands/grade.rs b/src/commands/grade.rs index b5e0623..152d684 100644 --- a/src/commands/grade.rs +++ b/src/commands/grade.rs @@ -334,7 +334,7 @@ struct Finding { detail: String, } -fn score_to_grade(score: i32) -> &'static str { +pub(crate) fn score_to_grade(score: i32) -> &'static str { match score { 95..=100 => "A+", 90..=94 => "A", @@ -344,3 +344,63 @@ fn score_to_grade(score: i32) -> &'static str { _ => "F", } } + +#[cfg(test)] +mod tests { + use super::*; + + // ── score_to_grade boundary values ──────────────────────────────────────── + + #[test] + fn test_grade_a_plus_boundaries() { + assert_eq!(score_to_grade(100), "A+"); + assert_eq!(score_to_grade(95), "A+"); + } + + #[test] + fn test_grade_a_boundaries() { + assert_eq!(score_to_grade(94), "A"); + assert_eq!(score_to_grade(90), "A"); + } + + #[test] + fn test_grade_b_boundaries() { + assert_eq!(score_to_grade(89), "B"); + assert_eq!(score_to_grade(80), "B"); + } + + #[test] + fn test_grade_c_boundaries() { + assert_eq!(score_to_grade(79), "C"); + assert_eq!(score_to_grade(70), "C"); + } + + #[test] + fn test_grade_d_boundaries() { + assert_eq!(score_to_grade(69), "D"); + assert_eq!(score_to_grade(60), "D"); + } + + #[test] + fn test_grade_f_boundaries() { + assert_eq!(score_to_grade(59), "F"); + assert_eq!(score_to_grade(0), "F"); + } + + #[test] + fn test_grade_f_negative_score() { + // score is clamped to 0 in run(), but the function itself should handle negatives + assert_eq!(score_to_grade(-1), "F"); + assert_eq!(score_to_grade(-100), "F"); + } + + #[test] + fn test_grade_midpoints() { + assert_eq!(score_to_grade(97), "A+"); + assert_eq!(score_to_grade(92), "A"); + assert_eq!(score_to_grade(85), "B"); + assert_eq!(score_to_grade(75), "C"); + assert_eq!(score_to_grade(65), "D"); + assert_eq!(score_to_grade(30), "F"); + } +} diff --git a/src/commands/inspect.rs b/src/commands/inspect.rs index fd73f3c..01f4bce 100644 --- a/src/commands/inspect.rs +++ b/src/commands/inspect.rs @@ -1,13 +1,25 @@ use anyhow::Result; +use std::io::Read; -use crate::cert::parser::parse_cert_file; +use crate::cert::parser::{parse_cert_data, parse_cert_file}; use crate::output::colors; use crate::output::json::JsonCert; use crate::output::json::JsonCertOutput; use crate::output::terminal; pub fn run(path: &str, json: bool, no_color: bool) -> Result { - let certs = parse_cert_file(path)?; + let certs = if path == "-" { + let mut buf = Vec::new(); + std::io::stdin().read_to_end(&mut buf)?; + parse_cert_data(&buf)? + } else { + parse_cert_file(path)? + }; + run_certs(&certs, json, no_color) +} + +/// Render already-parsed certs (used by `decode` to avoid re-reading the source). +pub fn run_certs(certs: &[crate::cert::CertInfo], json: bool, no_color: bool) -> Result { let use_color = !no_color && !json && colors::should_color(); if json { @@ -15,7 +27,7 @@ pub fn run(path: &str, json: bool, no_color: bool) -> Result { certificates: certs.iter().map(JsonCert::from).collect(), }; println!("{}", serde_json::to_string_pretty(&output)?); - return Ok(exit_code_for_certs(&certs)); + return Ok(exit_code_for_certs(certs)); } let total = certs.len(); @@ -26,7 +38,7 @@ pub fn run(path: &str, json: bool, no_color: bool) -> Result { } } - Ok(exit_code_for_certs(&certs)) + Ok(exit_code_for_certs(certs)) } fn exit_code_for_certs(certs: &[crate::cert::CertInfo]) -> i32 { diff --git a/src/main.rs b/src/main.rs index 21053f0..6e2493d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -14,6 +14,7 @@ use output::util::parse_host_port; version, after_help = "Examples:\n \ sslx inspect cert.pem Read a certificate file\n \ + sslx inspect - Read a certificate from stdin\n \ sslx connect google.com Test TLS connection\n \ sslx verify cert.pem --ca ca Validate certificate chain\n \ sslx generate --cn localhost Create a self-signed cert" @@ -35,7 +36,7 @@ struct Cli { enum Commands { /// Read and display certificate file details Inspect { - /// Path to certificate file (PEM, DER, or PKCS12) + /// Path to certificate file (PEM or DER), or - to read from stdin file: String, }, @@ -161,7 +162,7 @@ enum Commands { /// Auto-detect and decode any crypto file (cert, key, CSR, JWT) Decode { - /// File path or inline string (e.g., JWT token) + /// File path, inline string (e.g., JWT token), or - to read from stdin input: String, }, diff --git a/src/output/mod.rs b/src/output/mod.rs index 1a71d5d..e90ee9a 100644 --- a/src/output/mod.rs +++ b/src/output/mod.rs @@ -78,3 +78,114 @@ pub fn expiry_display(days_remaining: i64, use_color: bool) -> String { format!("{} {} [{}]", bar, label, icon) } } + +#[cfg(test)] +mod tests { + use super::*; + + // ── expiry_display (no-color mode for deterministic output) ─────────────── + + #[test] + fn test_expiry_display_expired() { + let out = expiry_display(-5, false); + assert!(out.contains("EXPIRED"), "expected 'EXPIRED' in: {}", out); + assert!( + out.contains("5 days ago"), + "expected '5 days ago' in: {}", + out + ); + assert!( + out.contains(box_chars::CROSS), + "expected CROSS icon in: {}", + out + ); + } + + #[test] + fn test_expiry_display_zero_days() { + let out = expiry_display(0, false); + // 0 days is within the <=7 threshold — critical expiry + assert!(out.contains("EXPIRING"), "expected 'EXPIRING' in: {}", out); + assert!(out.contains("0 days"), "expected '0 days' in: {}", out); + } + + #[test] + fn test_expiry_display_seven_days() { + let out = expiry_display(7, false); + assert!(out.contains("EXPIRING"), "expected 'EXPIRING' in: {}", out); + assert!(out.contains("7 days"), "expected '7 days' in: {}", out); + assert!( + out.contains(box_chars::WARNING), + "expected WARNING icon in: {}", + out + ); + } + + #[test] + fn test_expiry_display_thirty_days() { + let out = expiry_display(30, false); + assert!( + out.contains("in 30 days"), + "expected 'in 30 days' in: {}", + out + ); + assert!( + out.contains(box_chars::WARNING), + "expected WARNING icon in: {}", + out + ); + } + + #[test] + fn test_expiry_display_healthy() { + let out = expiry_display(365, false); + assert!( + out.contains("365 days remaining"), + "expected '365 days remaining' in: {}", + out + ); + assert!( + out.contains(box_chars::CHECK), + "expected CHECK icon in: {}", + out + ); + } + + #[test] + fn test_expiry_display_no_color_has_no_ansi_codes() { + for days in [-10, 0, 7, 30, 365] { + let out = expiry_display(days, false); + assert!( + !out.contains('\x1b'), + "no-color output must not contain ANSI escape codes: {}", + out + ); + } + } + + #[test] + fn test_expiry_display_color_contains_ansi_codes() { + for days in [-10, 0, 7, 30, 365] { + let out = expiry_display(days, true); + assert!( + out.contains('\x1b'), + "color output should contain ANSI escape codes: {}", + out + ); + } + } + + #[test] + fn test_expiry_display_progress_bar_length() { + // The progress bar is always 10 characters wide (█ or ░) + for days in [-10_i64, 0, 7, 30, 180, 365] { + let out = expiry_display(days, false); + let bar_chars = out.chars().filter(|&c| c == '█' || c == '░').count(); + assert_eq!( + bar_chars, 10, + "progress bar should be 10 chars for {} days", + days + ); + } + } +} diff --git a/tests/integration.rs b/tests/integration.rs index 93457af..bfc4d27 100644 --- a/tests/integration.rs +++ b/tests/integration.rs @@ -1,4 +1,4 @@ -use std::process::Command; +use std::process::{Command, Stdio}; fn sslx() -> Command { Command::new(env!("CARGO_BIN_EXE_sslx")) @@ -227,3 +227,282 @@ fn test_verify_self_signed() { "self-signed cert should verify against itself" ); } + +#[test] +#[ignore] +fn test_connect_live_host() { + let output = sslx() + .args(["connect", "google.com", "--no-color"]) + .output() + .expect("connect failed"); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(stdout.contains("TLS 1."), "should show TLS version"); + assert!(stdout.contains("google.com"), "should show hostname"); + assert!(output.status.success()); +} + +#[test] +#[ignore] +fn test_grade_live_host() { + let output = sslx() + .args(["grade", "google.com", "--no-color"]) + .output() + .expect("grade failed"); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(stdout.contains("Grade:"), "should show grade"); + assert!(output.status.success()); +} + +#[test] +#[ignore] +fn test_expiry_multiple_hosts() { + let output = sslx() + .args(["expiry", "google.com", "github.com", "--no-color"]) + .output() + .expect("expiry failed"); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(stdout.contains("google.com"), "should list google"); + assert!(stdout.contains("github.com"), "should list github"); + assert!(output.status.success()); +} + +#[test] +#[ignore] +fn test_connect_bad_host() { + let output = sslx() + .args(["connect", "nonexistent.invalid.host.example"]) + .output() + .expect("connect should run"); + assert!(!output.status.success(), "should fail for bad host"); +} + +#[test] +#[ignore] +fn test_grade_json() { + let output = sslx() + .args(["grade", "google.com", "--json"]) + .output() + .expect("grade failed"); + let stdout = String::from_utf8_lossy(&output.stdout); + let json: serde_json::Value = serde_json::from_str(&stdout).expect("should be valid json"); + assert!(json["grade"].is_string(), "should have grade field"); + assert!(json["score"].is_number(), "should have score field"); +} + +#[test] +fn test_decode_jwt_inline() { + let jwt = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4iLCJpYXQiOjE1MTYyMzkwMjJ9.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c"; + let output = sslx() + .args(["decode", jwt, "--no-color"]) + .output() + .expect("decode failed"); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(stdout.contains("JWT"), "should detect JWT"); + assert!(stdout.contains("John"), "should show payload"); +} + +#[test] +fn test_match_wrong_key() { + let dir1 = tempfile::tempdir().unwrap(); + let dir2 = tempfile::tempdir().unwrap(); + // generate two different certs + sslx() + .args([ + "generate", + "--cn", + "a.test", + "--out", + dir1.path().to_str().unwrap(), + ]) + .output() + .unwrap(); + sslx() + .args([ + "generate", + "--cn", + "b.test", + "--out", + dir2.path().to_str().unwrap(), + ]) + .output() + .unwrap(); + // try to match cert from one with key from other + let output = sslx() + .args([ + "match", + dir1.path().join("cert.pem").to_str().unwrap(), + dir2.path().join("key.pem").to_str().unwrap(), + "--no-color", + ]) + .output() + .unwrap(); + assert!(!output.status.success(), "mismatched cert+key should fail"); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + stdout.contains("DO NOT match"), + "should say they don't match" + ); +} + +#[test] +fn test_convert_pem_to_der_and_back() { + let dir = tempfile::tempdir().unwrap(); + let d = dir.path().to_str().unwrap(); + sslx() + .args(["generate", "--cn", "convert.test", "--out", d]) + .output() + .unwrap(); + + let cert_pem = dir.path().join("cert.pem"); + let cert_der = dir.path().join("cert.der"); + let cert_back = dir.path().join("cert_back.pem"); + + // PEM -> DER + sslx() + .args([ + "convert", + cert_pem.to_str().unwrap(), + "--to", + "der", + "--out", + cert_der.to_str().unwrap(), + ]) + .output() + .unwrap(); + assert!(cert_der.exists()); + + // DER -> PEM + sslx() + .args([ + "convert", + cert_der.to_str().unwrap(), + "--to", + "pem", + "--out", + cert_back.to_str().unwrap(), + ]) + .output() + .unwrap(); + assert!(cert_back.exists()); + + // inspect the round-tripped cert + let output = sslx() + .args(["inspect", cert_back.to_str().unwrap(), "--no-color"]) + .output() + .unwrap(); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(stdout.contains("convert.test")); +} + +#[test] +fn test_inspect_stdin_pem() { + // Generate a cert to a temp dir, then pipe its PEM contents via stdin. + let dir = tempfile::tempdir().expect("failed to create temp dir"); + let dir_path = dir.path().to_str().unwrap(); + + sslx() + .args(["generate", "--cn", "stdin.test", "--out", dir_path]) + .output() + .expect("failed to generate cert"); + + let cert_path = dir.path().join("cert.pem"); + let pem_bytes = std::fs::read(&cert_path).expect("failed to read cert.pem"); + + let mut child = sslx() + .args(["inspect", "-", "--no-color"]) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("failed to spawn sslx"); + + use std::io::Write; + child + .stdin + .take() + .unwrap() + .write_all(&pem_bytes) + .expect("failed to write stdin"); + + let output = child.wait_with_output().expect("failed to wait"); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(output.status.success(), "inspect - should succeed"); + assert!(stdout.contains("stdin.test"), "subject should appear"); + assert!(stdout.contains("days remaining"), "expiry should appear"); +} + +#[test] +fn test_inspect_stdin_json() { + let dir = tempfile::tempdir().expect("failed to create temp dir"); + let dir_path = dir.path().to_str().unwrap(); + + sslx() + .args(["generate", "--cn", "stdin-json.test", "--out", dir_path]) + .output() + .expect("failed to generate cert"); + + let pem_bytes = std::fs::read(dir.path().join("cert.pem")).expect("failed to read cert.pem"); + + let mut child = sslx() + .args(["inspect", "-", "--json"]) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("failed to spawn sslx"); + + use std::io::Write; + child + .stdin + .take() + .unwrap() + .write_all(&pem_bytes) + .expect("failed to write stdin"); + + let output = child.wait_with_output().expect("failed to wait"); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(output.status.success(), "inspect - --json should succeed"); + let json: serde_json::Value = serde_json::from_str(&stdout).expect("should be valid JSON"); + assert_eq!(json["certificates"][0]["subject"], "CN=stdin-json.test"); +} + +#[test] +fn test_decode_stdin_pem_cert() { + let dir = tempfile::tempdir().expect("failed to create temp dir"); + let dir_path = dir.path().to_str().unwrap(); + + sslx() + .args(["generate", "--cn", "decode-stdin.test", "--out", dir_path]) + .output() + .expect("failed to generate cert"); + + let pem_bytes = std::fs::read(dir.path().join("cert.pem")).expect("failed to read cert.pem"); + + let mut child = sslx() + .args(["decode", "-", "--no-color"]) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("failed to spawn sslx"); + + use std::io::Write; + child + .stdin + .take() + .unwrap() + .write_all(&pem_bytes) + .expect("failed to write stdin"); + + let output = child.wait_with_output().expect("failed to wait"); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(output.status.success(), "decode - should succeed"); + assert!( + stdout.contains("PEM Certificate"), + "should detect PEM Certificate" + ); + assert!( + stdout.contains("decode-stdin.test"), + "subject should appear" + ); +}