Skip to main content

kipuka_dogtag/
enroll.rs

1//! Certificate enrollment via Dogtag CA REST API.
2//!
3//! Implements profile-based certificate enrollment using PKCS#10 CSRs.
4//! Supports both synchronous enrollment (certificate returned immediately)
5//! and asynchronous enrollment (request ID returned for later polling),
6//! corresponding to EST's standard and Disconnected modes respectively.
7
8use serde::{Deserialize, Serialize};
9use tracing::debug;
10
11use crate::client::DogtagClient;
12use crate::{DogtagError, DogtagResult};
13
14/// Result of a certificate enrollment request.
15#[derive(Debug, Clone)]
16pub struct EnrollResult {
17    /// Dogtag certificate request ID.
18    pub request_id: String,
19    /// Current status of the enrollment request.
20    pub status: EnrollStatus,
21    /// DER-encoded certificate, if issued synchronously.
22    pub certificate_der: Option<Vec<u8>>,
23}
24
25/// Status of a certificate enrollment request.
26#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
27#[serde(rename_all = "lowercase")]
28pub enum EnrollStatus {
29    /// Request completed, certificate issued.
30    Complete,
31    /// Request is pending agent approval.
32    Pending,
33    /// Request was rejected.
34    Rejected,
35    /// Request was canceled.
36    Canceled,
37}
38
39/// Result of a server-side key generation request.
40#[derive(Debug, Clone)]
41pub struct ServerKeygenResult {
42    pub request_id: String,
43    pub certificate_der: Option<Vec<u8>>,
44    pub pkcs12_b64: Option<String>,
45}
46
47/// Enrollment request body sent to Dogtag CA.
48///
49/// Maps to the JSON payload for `POST /ca/rest/certrequests`.
50#[derive(Serialize)]
51struct EnrollmentRequest {
52    #[serde(rename = "ProfileID")]
53    profile_id: String,
54    #[serde(rename = "Renewal")]
55    renewal: bool,
56    #[serde(rename = "Input")]
57    input: Vec<ProfileInput>,
58}
59
60#[derive(Serialize)]
61struct ProfileInput {
62    #[serde(rename = "ClassID")]
63    class_id: String,
64    #[serde(rename = "Attribute")]
65    attributes: Vec<ProfileAttribute>,
66}
67
68#[derive(Serialize)]
69struct ProfileAttribute {
70    name: String,
71    #[serde(rename = "Value")]
72    value: String,
73}
74
75/// Response from Dogtag certificate enrollment.
76#[derive(Deserialize)]
77struct EnrollmentResponse {
78    #[serde(default)]
79    entries: Vec<EnrollmentEntry>,
80}
81
82#[derive(Deserialize)]
83struct EnrollmentEntry {
84    #[serde(rename = "requestId")]
85    request_id: Option<String>,
86    #[serde(rename = "requestStatus")]
87    request_status: Option<String>,
88    #[serde(default, rename = "certId")]
89    cert_id: Option<String>,
90}
91
92/// Certificate data response.
93#[derive(Deserialize)]
94struct CertDataResponse {
95    #[serde(default, alias = "Encoded")]
96    encoded: Option<String>,
97}
98
99impl DogtagClient {
100    /// Enroll a certificate using a PKCS#10 CSR and enrollment profile.
101    ///
102    /// Sends `POST /ca/rest/certrequests` with the CSR embedded in the
103    /// specified enrollment profile. The profile controls certificate
104    /// extensions, key usage, validity period, and approval workflow.
105    ///
106    /// # Arguments
107    ///
108    /// * `csr_pem` - PEM-encoded PKCS#10 certificate signing request.
109    /// * `profile_id` - Dogtag enrollment profile ID (e.g., "caServerCert").
110    ///
111    /// # Returns
112    ///
113    /// An [`EnrollResult`] containing the request ID, status, and the
114    /// DER-encoded certificate if the profile uses auto-approval.
115    /// If the profile requires agent approval, the status will be
116    /// [`EnrollStatus::Pending`] and the certificate will be `None`.
117    pub async fn enroll_certificate(
118        &self,
119        csr_pem: &str,
120        profile_id: &str,
121    ) -> DogtagResult<EnrollResult> {
122        self.enroll_certificate_with_type(csr_pem, profile_id, "pkcs10")
123            .await
124    }
125
126    /// Enroll a certificate with an explicit request type (`pkcs10` or `crmf`).
127    pub async fn enroll_certificate_with_type(
128        &self,
129        request_data: &str,
130        profile_id: &str,
131        request_type: &str,
132    ) -> DogtagResult<EnrollResult> {
133        debug!(
134            profile = profile_id,
135            request_type, "Submitting enrollment request"
136        );
137
138        // Login to establish a session — SessionAuthentication profiles
139        // (e.g. acmeServerCert) require a valid JSESSIONID cookie.
140        let login_resp = self
141            .post_json("/ca/rest/account/login", &serde_json::json!({}))
142            .await;
143        match &login_resp {
144            Ok(r) if r.status().is_success() => {
145                tracing::info!("Dogtag session login succeeded");
146            }
147            Ok(r) => {
148                tracing::warn!(
149                    status = r.status().as_u16(),
150                    "Dogtag session login returned non-200 (continuing)"
151                );
152            }
153            Err(e) => {
154                tracing::warn!(error = %e, "Dogtag session login failed (continuing without session)");
155            }
156        }
157
158        let request = EnrollmentRequest {
159            profile_id: profile_id.to_owned(),
160            renewal: false,
161            input: vec![
162                ProfileInput {
163                    class_id: "certReqInputImpl".to_owned(),
164                    attributes: vec![
165                        ProfileAttribute {
166                            name: "cert_request_type".to_owned(),
167                            value: request_type.to_owned(),
168                        },
169                        ProfileAttribute {
170                            name: "cert_request".to_owned(),
171                            value: request_data.to_owned(),
172                        },
173                    ],
174                },
175                ProfileInput {
176                    class_id: "submitterInfoInputImpl".to_owned(),
177                    attributes: vec![
178                        ProfileAttribute {
179                            name: "requestor_name".to_owned(),
180                            value: "kipuka EST Server".to_owned(),
181                        },
182                        ProfileAttribute {
183                            name: "requestor_email".to_owned(),
184                            value: String::new(),
185                        },
186                        ProfileAttribute {
187                            name: "requestor_phone".to_owned(),
188                            value: String::new(),
189                        },
190                    ],
191                },
192            ],
193        };
194
195        let resp = self.post_json("/ca/rest/certrequests", &request).await?;
196        let enrollment: EnrollmentResponse = Self::json_response(resp).await?;
197
198        let entry = enrollment
199            .entries
200            .into_iter()
201            .next()
202            .ok_or_else(|| DogtagError::ParseError("Empty enrollment response".into()))?;
203
204        let request_id = entry
205            .request_id
206            .ok_or_else(|| DogtagError::ParseError("Missing request_id in response".into()))?;
207
208        let status = match entry.request_status.as_deref() {
209            Some("complete") => EnrollStatus::Complete,
210            Some("pending") => EnrollStatus::Pending,
211            Some("rejected") => EnrollStatus::Rejected,
212            Some("canceled") => EnrollStatus::Canceled,
213            Some(other) => {
214                return Err(DogtagError::ParseError(format!(
215                    "Unknown request status: {other}"
216                )));
217            }
218            None => {
219                return Err(DogtagError::ParseError(
220                    "Missing request_status in response".into(),
221                ));
222            }
223        };
224
225        // Auto-approve pending requests: two-step Dogtag REST flow.
226        // Step 1: GET the review response (populates requestId in the body)
227        // Step 2: POST the review response back to the approve endpoint
228        // Sending an empty body causes NullPointerException in Dogtag's
229        // RequestProcessor because CertReviewResponse.getRequestId() is null.
230        let (status, certificate_der) = if status == EnrollStatus::Pending {
231            tracing::info!(request_id = %request_id, "auto-approving pending enrollment");
232
233            // Step 1: GET the review form (must include Accept: application/json
234            // or Dogtag returns XML which fails JSON parsing)
235            let review_url = format!("/ca/rest/agent/certrequests/{request_id}");
236            let review_resp = self.get(&review_url).await;
237
238            let approve_resp = match review_resp {
239                Ok(resp) => {
240                    let resp_status = resp.status();
241                    if !resp_status.is_success() {
242                        let body = crate::bounded_text(resp).await?;
243                        tracing::error!(
244                            status = resp_status.as_u16(),
245                            body_preview = %crate::truncate_str(&body, 200),
246                            "GET review form failed"
247                        );
248                        self.post_json(
249                            &format!("/ca/rest/agent/certrequests/{request_id}/approve"),
250                            &serde_json::json!({}),
251                        )
252                        .await
253                    } else {
254                        let review_body: serde_json::Value = match Self::json_response(resp).await {
255                            Ok(body) => {
256                                tracing::info!("review form retrieved, posting approval");
257                                body
258                            }
259                            Err(e) => {
260                                tracing::error!(error = %e, "review form not valid JSON (missing Accept header?)");
261                                serde_json::json!({})
262                            }
263                        };
264                        self.post_json(
265                            &format!("/ca/rest/agent/certrequests/{request_id}/approve"),
266                            &review_body,
267                        )
268                        .await
269                    }
270                }
271                Err(e) => {
272                    tracing::error!(error = %e, "failed to GET review form");
273                    self.post_json(
274                        &format!("/ca/rest/agent/certrequests/{request_id}/approve"),
275                        &serde_json::json!({}),
276                    )
277                    .await
278                }
279            };
280
281            match approve_resp {
282                Ok(resp) => {
283                    let resp_status = resp.status();
284                    if !resp_status.is_success() {
285                        let body = crate::bounded_text(resp).await?;
286                        tracing::error!(
287                            status = resp_status.as_u16(),
288                            body_preview = %crate::truncate_str(&body, 300),
289                            "approve POST returned error"
290                        );
291                        (EnrollStatus::Pending, None)
292                    } else {
293                        // Approve may return JSON (CertReviewResponse) or HTML.
294                        // If JSON parsing fails, fall back to polling the request.
295                        let approved: serde_json::Value = match serde_json::from_slice(
296                            &crate::bounded_bytes(resp).await?,
297                        ) {
298                            Ok(v) => v,
299                            Err(e) => {
300                                tracing::warn!(error = %e, "approve response not JSON, polling request status");
301                                let poll = self.get_enrollment_status(&request_id).await?;
302                                let status = poll.status;
303                                let cert = poll.certificate_der;
304                                return Ok(EnrollResult {
305                                    request_id: request_id.to_owned(),
306                                    status,
307                                    certificate_der: cert,
308                                });
309                            }
310                        };
311                        let status_str = approved
312                            .get("requestStatus")
313                            .or_else(|| approved.get("RequestStatus"))
314                            .and_then(|v| v.as_str())
315                            .unwrap_or("unknown");
316                        let cert_id = approved
317                            .get("certId")
318                            .or_else(|| approved.get("CertId"))
319                            .and_then(|v| v.as_str())
320                            .map(String::from);
321
322                        tracing::info!(request_id = %request_id, status = %status_str, "approve result");
323
324                        let new_status = match status_str {
325                            "complete" => EnrollStatus::Complete,
326                            "pending" => EnrollStatus::Pending,
327                            other => {
328                                tracing::warn!(request_id = %request_id, status = %other, "unexpected status after approval");
329                                EnrollStatus::Pending
330                            }
331                        };
332                        let cert = if new_status == EnrollStatus::Complete {
333                            if let Some(ref cid) = cert_id {
334                                Some(self.fetch_cert_der(cid).await?)
335                            } else {
336                                // Try to find cert ID in the response
337                                let alt_id = approved
338                                    .get("certificateId")
339                                    .or_else(|| approved.get("CertificateID"))
340                                    .and_then(|v| v.as_str());
341                                if let Some(aid) = alt_id {
342                                    Some(self.fetch_cert_der(aid).await?)
343                                } else {
344                                    tracing::warn!(
345                                        "approve succeeded but no cert ID found in response"
346                                    );
347                                    None
348                                }
349                            }
350                        } else {
351                            None
352                        };
353                        (new_status, cert)
354                    }
355                }
356                Err(e) => {
357                    tracing::error!(error = %e, "auto-approve POST failed");
358                    (EnrollStatus::Pending, None)
359                }
360            }
361        } else if status == EnrollStatus::Complete {
362            let cert = if let Some(cert_id) = &entry.cert_id {
363                Some(self.fetch_cert_der(cert_id).await?)
364            } else {
365                None
366            };
367            (status, cert)
368        } else {
369            (status, None)
370        };
371
372        Ok(EnrollResult {
373            request_id,
374            status,
375            certificate_der,
376        })
377    }
378
379    /// Server-side key generation via Dogtag's `caServerKeygen_UserCert` profile.
380    ///
381    /// Dogtag CA + KRA generate the key pair internally. The CA archives
382    /// the private key via the KRA (using ML-KEM if the transport cert is
383    /// ML-KEM-1024) and returns the cert + PKCS#12.
384    pub async fn server_keygen(
385        &self,
386        profile_id: &str,
387        subject_uid: &str,
388        subject_cn: &str,
389        p12_password: &str,
390        key_type: &str,
391        key_size: u32,
392    ) -> DogtagResult<ServerKeygenResult> {
393        debug!(
394            profile_id,
395            subject_cn, key_type, key_size, "Server-side key generation via SSKG profile"
396        );
397
398        // Login for session — try v2 GET first, fall back to v1 POST
399        match self.get("/ca/v2/account/login").await {
400            Ok(r) if r.status().is_success() => {
401                tracing::info!("CA session login succeeded (v2 GET)");
402            }
403            _ => {
404                let _ = self
405                    .post_json("/ca/rest/account/login", &serde_json::json!({}))
406                    .await;
407            }
408        }
409
410        let request = EnrollmentRequest {
411            profile_id: profile_id.to_owned(),
412            renewal: false,
413            input: vec![
414                ProfileInput {
415                    class_id: "serverKeygenInputImpl".to_owned(),
416                    attributes: vec![
417                        ProfileAttribute {
418                            name: "serverSideKeygenP12Passwd".to_owned(),
419                            value: p12_password.to_owned(),
420                        },
421                        ProfileAttribute {
422                            name: "keyType".to_owned(),
423                            value: key_type.to_owned(),
424                        },
425                        ProfileAttribute {
426                            name: "keySize".to_owned(),
427                            value: key_size.to_string(),
428                        },
429                    ],
430                },
431                ProfileInput {
432                    class_id: "subjectNameInputImpl".to_owned(),
433                    attributes: vec![
434                        ProfileAttribute {
435                            name: "sn_uid".to_owned(),
436                            value: subject_uid.to_owned(),
437                        },
438                        ProfileAttribute {
439                            name: "sn_cn".to_owned(),
440                            value: subject_cn.to_owned(),
441                        },
442                    ],
443                },
444                ProfileInput {
445                    class_id: "submitterInfoInputImpl".to_owned(),
446                    attributes: vec![
447                        ProfileAttribute {
448                            name: "requestor_name".to_owned(),
449                            value: "kipuka EST Server".to_owned(),
450                        },
451                        ProfileAttribute {
452                            name: "requestor_email".to_owned(),
453                            value: String::new(),
454                        },
455                        ProfileAttribute {
456                            name: "requestor_phone".to_owned(),
457                            value: String::new(),
458                        },
459                    ],
460                },
461            ],
462        };
463
464        let resp = self.post_json("/ca/rest/certrequests", &request).await?;
465        let resp_body: serde_json::Value = Self::json_response(resp).await?;
466
467        // The server-keygen response includes the PKCS#12 in the output
468        let entries = resp_body
469            .get("entries")
470            .and_then(|v| v.as_array())
471            .ok_or_else(|| {
472                DogtagError::ParseError("No entries in server-keygen response".into())
473            })?;
474
475        let entry = entries.first().ok_or_else(|| {
476            DogtagError::ParseError("Empty entries in server-keygen response".into())
477        })?;
478
479        let status = entry
480            .get("requestStatus")
481            .and_then(|v| v.as_str())
482            .unwrap_or("unknown")
483            .to_owned();
484
485        let request_id = entry
486            .get("requestID")
487            .or_else(|| entry.get("requestId"))
488            .and_then(|v| v.as_str())
489            .unwrap_or("")
490            .to_owned();
491
492        // Auto-approve pending SSKG requests via the agent servlet.
493        //
494        // The profileProcess servlet renders pkcs12OutputImpl in the approval
495        // response — the REST agent endpoint does not. Flow:
496        //   1. GET /ca/agent/ca/profileReview?requestId=X&xml=true → review XML
497        //   2. Parse defId/defVal + inputId/inputVal pairs from the XML
498        //   3. POST /ca/agent/ca/profileProcess with all fields + op=approve
499        //   4. Response body is the raw PKCS#12 binary
500        if status == "pending" && !request_id.is_empty() {
501            tracing::info!(request_id = %request_id, "SSKG: approving via agent servlet (P12 in response)");
502
503            let review_url = format!("/ca/agent/ca/profileReview?requestId={request_id}&xml=true");
504            let review_resp = self.get_raw(&review_url).await?;
505            let review_xml = crate::bounded_text(review_resp).await?;
506
507            if review_xml.contains("errorReason") && !review_xml.contains("<defId>") {
508                return Err(DogtagError::EnrollmentRejected {
509                    reason: format!(
510                        "SSKG review failed: {}",
511                        crate::truncate_str(&review_xml, 200)
512                    ),
513                });
514            }
515
516            let form_body = build_approval_form(&review_xml, &request_id);
517            tracing::info!(
518                form_len = form_body.len(),
519                "SSKG: posting approval to profileProcess"
520            );
521
522            let approve_resp = self
523                .post_bytes(
524                    "/ca/agent/ca/profileProcess",
525                    form_body.into_bytes(),
526                    "application/x-www-form-urlencoded",
527                )
528                .await?;
529
530            let resp_status = approve_resp.status();
531            let p12_bytes = crate::bounded_bytes(approve_resp).await?;
532
533            if !resp_status.is_success() || p12_bytes.len() < 100 {
534                let preview = String::from_utf8_lossy(&p12_bytes[..p12_bytes.len().min(300)]);
535                return Err(DogtagError::EnrollmentRejected {
536                    reason: format!("SSKG approval failed (HTTP {resp_status}): {preview}"),
537                });
538            }
539
540            tracing::info!(
541                p12_len = p12_bytes.len(),
542                "SSKG: P12 received from profileProcess"
543            );
544
545            use base64::Engine;
546            return Ok(ServerKeygenResult {
547                request_id,
548                certificate_der: None,
549                pkcs12_b64: Some(base64::engine::general_purpose::STANDARD.encode(&p12_bytes)),
550            });
551        }
552
553        if status != "complete" {
554            let error_msg = entry
555                .get("errorMessage")
556                .and_then(|v| v.as_str())
557                .unwrap_or("unknown error");
558            return Err(DogtagError::EnrollmentRejected {
559                reason: format!("status={status}: {error_msg}"),
560            });
561        }
562
563        // Auto-approved path (SessionAuthentication): cert in response, no P12.
564        let cert_id_str = entry
565            .get("certId")
566            .and_then(|v| v.as_str())
567            .map(|s| s.to_owned())
568            .or_else(|| {
569                entry
570                    .get("certURL")
571                    .and_then(|u| u.as_str())
572                    .and_then(|u| u.rsplit('/').next())
573                    .map(|s| s.to_owned())
574            })
575            .unwrap_or_default();
576        let cert_id = cert_id_str.as_str();
577
578        let certificate_der = if !cert_id.is_empty() {
579            Some(self.fetch_cert_der(cert_id).await?)
580        } else {
581            None
582        };
583
584        Ok(ServerKeygenResult {
585            request_id,
586            certificate_der,
587            pkcs12_b64: None,
588        })
589    }
590
591    /// Poll the status of an enrollment request.
592    ///
593    /// Sends `GET /ca/rest/certrequests/{request_id}` to check whether
594    /// a pending enrollment has been approved or rejected. Used for
595    /// EST Disconnected mode (RFC 7030 S4.4.2) where the CA requires
596    /// out-of-band approval before issuing the certificate.
597    pub async fn get_enrollment_status(&self, request_id: &str) -> DogtagResult<EnrollResult> {
598        debug!(request_id, "Checking enrollment status");
599
600        let resp = self
601            .get(&format!("/ca/rest/certrequests/{request_id}"))
602            .await?;
603        let entry: EnrollmentEntry = Self::json_response(resp).await?;
604
605        let status = match entry.request_status.as_deref() {
606            Some("complete") => EnrollStatus::Complete,
607            Some("pending") => EnrollStatus::Pending,
608            Some("rejected") => EnrollStatus::Rejected,
609            Some("canceled") => EnrollStatus::Canceled,
610            other => {
611                return Err(DogtagError::ParseError(format!(
612                    "Unknown request status: {other:?}"
613                )));
614            }
615        };
616
617        let certificate_der = if status == EnrollStatus::Complete {
618            if let Some(cert_id) = &entry.cert_id {
619                Some(self.fetch_cert_der(cert_id).await?)
620            } else {
621                None
622            }
623        } else {
624            None
625        };
626
627        Ok(EnrollResult {
628            request_id: request_id.to_owned(),
629            status,
630            certificate_der,
631        })
632    }
633
634    /// Fetch DER-encoded certificate by serial/cert ID.
635    async fn fetch_cert_der(&self, cert_id: &str) -> DogtagResult<Vec<u8>> {
636        let resp = self.get(&format!("/ca/rest/certs/{cert_id}")).await?;
637        let status = resp.status();
638        let body = crate::bounded_text(resp).await?;
639
640        if !status.is_success() {
641            return Err(DogtagError::ApiError {
642                status: status.as_u16(),
643                body,
644            });
645        }
646
647        // Try JSON first (normal path).
648        if let Ok(cert_data) = serde_json::from_str::<CertDataResponse>(&body)
649            && let Some(ref encoded) = cert_data.encoded
650        {
651            return Self::decode_pem_to_der(encoded);
652        }
653
654        // Fallback: raw PEM certificate in the response body.
655        if body.contains("-----BEGIN CERTIFICATE-----") {
656            tracing::debug!("response is raw PEM, extracting directly");
657            return Self::decode_pem_to_der(&body);
658        }
659
660        // Fallback: XML response with <Encoded> or <encoded> tag.
661        if let Some(pem) = Self::extract_cert_from_xml(&body) {
662            tracing::debug!("extracted certificate from XML response");
663            return Self::decode_pem_to_der(&pem);
664        }
665
666        Err(DogtagError::ParseError(format!(
667            "cannot extract certificate from response; body: {}",
668            crate::truncate_str(&body, 500)
669        )))
670    }
671
672    fn decode_pem_to_der(pem: &str) -> DogtagResult<Vec<u8>> {
673        let b64: String = pem.lines().filter(|l| !l.starts_with("-----")).collect();
674        use base64::Engine;
675        base64::engine::general_purpose::STANDARD
676            .decode(&b64)
677            .map_err(|e| DogtagError::ParseError(format!("invalid base64 in certificate: {e}")))
678    }
679
680    fn extract_cert_from_xml(xml: &str) -> Option<String> {
681        for (open, close) in [("<Encoded>", "</Encoded>"), ("<encoded>", "</encoded>")] {
682            if let Some(start) = xml.find(open) {
683                let start = start + open.len();
684                if let Some(end) = xml[start..].find(close) {
685                    return Some(xml[start..start + end].to_owned());
686                }
687            }
688        }
689        None
690    }
691}
692
693/// Parse the profileReview XML and build a form-encoded approval body.
694///
695/// Extracts `<defId>/<defVal>` pairs (policy defaults) and
696/// `<inputId>/<inputVal>` pairs (enrollment inputs) from the review
697/// XML, then appends the approval operation fields.
698fn build_approval_form(xml: &str, request_id: &str) -> String {
699    let mut pairs: Vec<(String, String)> = Vec::new();
700
701    // Extract defId/defVal pairs
702    for (id_tag, val_tag) in [("defId", "defVal"), ("inputId", "inputVal")] {
703        let open_id = format!("<{id_tag}>");
704        let close_id = format!("</{id_tag}>");
705        let open_val = format!("<{val_tag}>");
706        let close_val = format!("</{val_tag}>");
707
708        let mut pos = 0;
709        while let Some(id_start) = xml[pos..].find(&open_id) {
710            let id_start = pos + id_start + open_id.len();
711            let Some(id_end) = xml[id_start..].find(&close_id) else {
712                break;
713            };
714            let name = xml[id_start..id_start + id_end].trim().to_owned();
715
716            let search_from = id_start + id_end;
717            let value = if let Some(val_start) = xml[search_from..].find(&open_val) {
718                let val_start = search_from + val_start + open_val.len();
719                if let Some(val_end) = xml[val_start..].find(&close_val) {
720                    xml[val_start..val_start + val_end].trim().to_owned()
721                } else {
722                    String::new()
723                }
724            } else {
725                String::new()
726            };
727
728            if !name.is_empty() {
729                pairs.push((name, value));
730            }
731            pos = id_start + id_end;
732        }
733    }
734
735    pairs.push(("requestId".into(), request_id.to_owned()));
736    pairs.push(("op".into(), "approve".into()));
737    pairs.push(("submit".into(), "submit".into()));
738    pairs.push(("xml".into(), "true".into()));
739
740    pairs
741        .iter()
742        .map(|(k, v)| format!("{}={}", urlencoded(k), urlencoded(v)))
743        .collect::<Vec<_>>()
744        .join("&")
745}
746
747fn urlencoded(s: &str) -> String {
748    let mut out = String::with_capacity(s.len() * 2);
749    for b in s.bytes() {
750        match b {
751            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
752                out.push(b as char);
753            }
754            b' ' => out.push('+'),
755            _ => {
756                out.push('%');
757                out.push(char::from(b"0123456789ABCDEF"[(b >> 4) as usize]));
758                out.push(char::from(b"0123456789ABCDEF"[(b & 0xf) as usize]));
759            }
760        }
761    }
762    out
763}