diff --git a/crates/rota-daemon/src/backends/namecheap/ca.rs b/crates/rota-daemon/src/backends/namecheap/ca.rs index 30022be..2241251 100644 --- a/crates/rota-daemon/src/backends/namecheap/ca.rs +++ b/crates/rota-daemon/src/backends/namecheap/ca.rs @@ -81,8 +81,21 @@ impl NamecheapCa { let replaced_by = resp .first_attribute("SSLGetInfoResult", "ReplacedBy") .and_then(|s| s.parse::().ok()); - let cert_pem = resp.first_text("CertificateReturned").unwrap_or_default(); - let chain_pem = resp.first_text("CACertificate").unwrap_or_default(); + // `Returncertificate=true&Returntype=Individual` packs the leaf + // and chain inside nested `` elements that no naive + // element-name lookup can disambiguate. The actual PEM armor is + // unique enough to scan for directly. Document order: the first + // CERTIFICATE block is the leaf, subsequent ones are the chain + // (issuer-up); the CSR present in the same response carries the + // `CERTIFICATE REQUEST` label and is skipped automatically. + let pem_blocks = resp.pem_blocks("CERTIFICATE"); + let cert_pem = pem_blocks.first().cloned().unwrap_or_default(); + let chain_pem = pem_blocks + .iter() + .skip(1) + .cloned() + .collect::>() + .join("\n"); Ok(NamecheapCertInfo { status, diff --git a/crates/rota-daemon/src/backends/namecheap/xml.rs b/crates/rota-daemon/src/backends/namecheap/xml.rs index 1ae6dfd..4c5c94c 100644 --- a/crates/rota-daemon/src/backends/namecheap/xml.rs +++ b/crates/rota-daemon/src/backends/namecheap/xml.rs @@ -84,6 +84,38 @@ impl ApiResponse { } } + /// Find every PEM block of the given label (e.g. `"CERTIFICATE"`) + /// in the raw response and return them in document order. + /// + /// Cuts through Namecheap's nested-element wrapping for cert + /// payloads: `namecheap.ssl.getInfo&Returncertificate=true` packs + /// the leaf as `` + /// and EACH chain cert under + /// ` + /// `, + /// which `first_text` can't disambiguate by element name alone. + /// Scanning for the literal PEM armor avoids walking that mess. + /// + /// `BEGIN CERTIFICATE-----` does NOT match `BEGIN CERTIFICATE REQUEST-----` + /// (the trailing `-----` differs), so a CSR present in the same + /// response is safely skipped when querying for `"CERTIFICATE"`. + pub fn pem_blocks(&self, label: &str) -> Vec { + let begin = format!("-----BEGIN {label}-----"); + let end = format!("-----END {label}-----"); + let mut out = Vec::new(); + let mut rest = self.raw.as_str(); + while let Some(b) = rest.find(&begin) { + let after_begin = &rest[b..]; + let Some(e) = after_begin.find(&end) else { + break; + }; + let block_end = e + end.len(); + out.push(after_begin[..block_end].to_owned()); + rest = &after_begin[block_end..]; + } + out + } + /// Find the first occurrence of an element by name and return one /// of its attribute values. pub fn first_attribute(&self, element: &str, attr: &str) -> Option { @@ -223,6 +255,76 @@ mod tests { ); } + #[test] + fn pem_blocks_extracts_leaf_plus_chain_in_document_order() { + let body = r#" + + + + + + + + + + + + + + + + + + + +"#; + let resp = parse_response(body).unwrap(); + let blocks = resp.pem_blocks("CERTIFICATE"); + assert_eq!(blocks.len(), 3, "leaf + 2 intermediates, CSR skipped"); + assert!(blocks[0].contains("LEAF_BODY"), "first block is the leaf"); + assert!( + blocks[1].contains("INT1_BODY"), + "second block is intermediate 1" + ); + assert!( + blocks[2].contains("INT2_BODY"), + "third block is intermediate 2" + ); + // CSR has the CERTIFICATE REQUEST label and must NOT be picked up + // when querying for the CERTIFICATE label. + assert!(!blocks.iter().any(|b| b.contains("CSR_BODY"))); + } + + #[test] + fn pem_blocks_does_not_match_csr_label() { + let body = r#" + + +"#; + let resp = parse_response(body).unwrap(); + let blocks = resp.pem_blocks("CERTIFICATE"); + assert!( + blocks.is_empty(), + "BEGIN CERTIFICATE----- has trailing dashes immediately after CERTIFICATE; BEGIN CERTIFICATE REQUEST----- does not match" + ); + } + + #[test] + fn pem_blocks_returns_empty_when_no_match() { + let resp = parse_response("").unwrap(); + assert!(resp.pem_blocks("CERTIFICATE").is_empty()); + } + #[test] fn parses_error_envelope() { let body = r#"