@@ -8,7 +8,7 @@ use std::time::Duration;
88
99use anyhow:: { Context as _, bail} ;
1010use camino:: { Utf8Path , Utf8PathBuf } ;
11- use picky:: pem:: parse_pem;
11+ use picky:: pem:: { PemError , parse_pem, read_pem } ;
1212use picky:: x509:: Cert ;
1313use picky_asn1_x509:: { ExtensionView , GeneralName } ;
1414use rcgen:: { CertificateParams , DnType , ExtendedKeyUsagePurpose , IsCa , KeyPair , KeyUsagePurpose , SanType } ;
@@ -32,29 +32,36 @@ fn cert_pem_to_der(pem_str: &str) -> anyhow::Result<Vec<u8>> {
3232/// Parse one or more PEM-encoded certificates into `rustls` certificate types.
3333///
3434/// A PEM file can carry multiple concatenated CERTIFICATE blocks (chain). We
35- /// iterate block-by-block with [`parse_pem`], check each label, and wrap the
36- /// DER bytes in [`rustls_pki_types::CertificateDer`] — the only type the
37- /// rustls/quinn TLS builders accept.
35+ /// use [`read_pem`] in a loop — each call consumes one block; `HeaderNotFound`
36+ /// signals "no more blocks left", which is the termination condition. Each
37+ /// block's label is verified, then the DER bytes are wrapped in
38+ /// [`rustls_pki_types::CertificateDer`] — the type the rustls/quinn TLS
39+ /// builders accept.
3840fn read_cert_chain ( pem_str : & str ) -> anyhow:: Result < Vec < rustls:: pki_types:: CertificateDer < ' static > > > {
41+ use std:: io:: BufReader ;
42+
43+ let mut reader = BufReader :: new ( pem_str. as_bytes ( ) ) ;
3944 let mut chain = Vec :: new ( ) ;
40- let mut remaining = pem_str;
41- while let Some ( start) = remaining. find ( "-----BEGIN " ) {
42- let block_end = remaining[ start..]
43- . find ( "-----END " )
44- . and_then ( |e| {
45- remaining[ start + e..]
46- . find ( "-----\n " )
47- . map ( |n| start + e + n + "-----\n " . len ( ) )
48- } )
49- . context ( "malformed PEM block (no END tag)" ) ?;
50-
51- let block = & remaining[ start..block_end] ;
52- let pem = parse_pem ( block) . context ( "parse PEM block" ) ?;
53- if pem. label ( ) != PEM_LABEL_CERTIFICATE {
54- bail ! ( "expected {PEM_LABEL_CERTIFICATE} PEM, got {}" , pem. label( ) ) ;
45+
46+ loop {
47+ match read_pem ( & mut reader) {
48+ Ok ( pem) => {
49+ if pem. label ( ) != PEM_LABEL_CERTIFICATE {
50+ bail ! (
51+ "expected {PEM_LABEL_CERTIFICATE} PEM at index {}, got {}" ,
52+ chain. len( ) ,
53+ pem. label( )
54+ ) ;
55+ }
56+ // `into_data().into_owned()` consumes the `Pem` and avoids the
57+ // copy that `pem.data().to_vec()` would force.
58+ chain. push ( rustls:: pki_types:: CertificateDer :: from ( pem. into_data ( ) . into_owned ( ) ) ) ;
59+ }
60+ Err ( PemError :: HeaderNotFound ) => break ,
61+ Err ( e) => {
62+ return Err ( anyhow:: Error :: new ( e) . context ( format ! ( "parse PEM block at index {}" , chain. len( ) ) ) ) ;
63+ }
5564 }
56- chain. push ( rustls:: pki_types:: CertificateDer :: from ( pem. data ( ) . to_vec ( ) ) ) ;
57- remaining = & remaining[ block_end..] ;
5865 }
5966
6067 if chain. is_empty ( ) {
@@ -605,4 +612,78 @@ mod tests {
605612
606613 let _ = std:: fs:: remove_dir_all ( & temp_dir) ;
607614 }
615+
616+ // ---------------------------------------------------------------------
617+ // read_cert_chain — regression coverage for PEM whitespace / multi-block
618+ // ---------------------------------------------------------------------
619+
620+ /// Mint a real CA + signed end-entity cert and return (ca_pem, leaf_pem).
621+ /// Uses `CaManager::load_or_generate` so we don't reimplement PEM emission
622+ /// in the test — whatever rcgen produces here is what the runtime sees.
623+ fn make_cert_pair ( ) -> ( String , String ) {
624+ let temp_dir = std:: env:: temp_dir ( ) . join ( format ! ( "dgw-cert-chain-test-{}" , Uuid :: new_v4( ) ) ) ;
625+ let data_dir = Utf8PathBuf :: from_path_buf ( temp_dir. clone ( ) ) . expect ( "temp path UTF-8" ) ;
626+ let ca = CaManager :: load_or_generate ( & data_dir) . expect ( "CA generation" ) ;
627+ let ( _key_pair, csr_pem) = generate_test_csr ( "chain-test-agent" ) ;
628+ let signed = ca
629+ . sign_agent_csr ( Uuid :: new_v4 ( ) , "chain-test-agent" , & csr_pem, None )
630+ . expect ( "sign CSR" ) ;
631+ let ca_pem = ca. ca_cert_pem ( ) . to_owned ( ) ;
632+ let _ = std:: fs:: remove_dir_all ( & temp_dir) ;
633+ ( ca_pem, signed. client_cert_pem )
634+ }
635+
636+ #[ test]
637+ fn read_cert_chain_single_block ( ) {
638+ let ( ca_pem, _) = make_cert_pair ( ) ;
639+ let chain = read_cert_chain ( & ca_pem) . expect ( "parse single-block chain" ) ;
640+ assert_eq ! ( chain. len( ) , 1 ) ;
641+ }
642+
643+ #[ test]
644+ fn read_cert_chain_multi_block ( ) {
645+ let ( ca_pem, leaf_pem) = make_cert_pair ( ) ;
646+ let combined = format ! ( "{leaf_pem}{ca_pem}" ) ;
647+ let chain = read_cert_chain ( & combined) . expect ( "parse multi-block chain" ) ;
648+ assert_eq ! ( chain. len( ) , 2 , "leaf + CA should both be parsed" ) ;
649+ }
650+
651+ #[ test]
652+ fn read_cert_chain_handles_crlf_line_endings ( ) {
653+ let ( ca_pem, _) = make_cert_pair ( ) ;
654+ let crlf = ca_pem. replace ( '\n' , "\r \n " ) ;
655+ let chain = read_cert_chain ( & crlf) . expect ( "parse CRLF chain" ) ;
656+ assert_eq ! ( chain. len( ) , 1 ) ;
657+ }
658+
659+ #[ test]
660+ fn read_cert_chain_handles_trailing_whitespace_between_blocks ( ) {
661+ let ( ca_pem, leaf_pem) = make_cert_pair ( ) ;
662+ // Insert blank lines and trailing spaces between concatenated blocks —
663+ // the kind of noise hand-edited or shell-concatenated PEM files end up
664+ // with. The original hand-rolled scanner choked on this; `read_pem`
665+ // handles it.
666+ let combined = format ! ( "{leaf_pem}\n \n \t \n {ca_pem}\n \n " ) ;
667+ let chain = read_cert_chain ( & combined) . expect ( "parse whitespace-padded chain" ) ;
668+ assert_eq ! ( chain. len( ) , 2 ) ;
669+ }
670+
671+ #[ test]
672+ fn read_cert_chain_rejects_empty_input ( ) {
673+ let err = read_cert_chain ( "" ) . expect_err ( "empty input should fail" ) ;
674+ let msg = format ! ( "{err:#}" ) ;
675+ assert ! ( msg. contains( "no CERTIFICATE blocks" ) , "got: {msg}" ) ;
676+ }
677+
678+ #[ test]
679+ fn read_cert_chain_rejects_wrong_label ( ) {
680+ let ( _, leaf_pem) = make_cert_pair ( ) ;
681+ // Build a 2-block input where the second block has the wrong label.
682+ let private_key_block = "-----BEGIN PRIVATE KEY-----\n MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQg\n -----END PRIVATE KEY-----\n " ;
683+ let combined = format ! ( "{leaf_pem}{private_key_block}" ) ;
684+ let err = read_cert_chain ( & combined) . expect_err ( "wrong label should fail" ) ;
685+ let msg = format ! ( "{err:#}" ) ;
686+ // The error context must point at the failing block index, not just say "parse PEM".
687+ assert ! ( msg. contains( "index 1" ) , "error should locate the bad block: {msg}" ) ;
688+ }
608689}
0 commit comments