Skip to main content

rama_http/convert/
curl.rs

1//! Convert HTTP requests with or without their payload into a valid curl
2//! command.
3//!
4//! Proxy export reads the middleware-visible
5//! [`ProxyRoute`]. Compose
6//! [`ProxyRoutesLayer`][rama_net::client::ProxyRoutesLayer] after all proxy
7//! route selectors and before exporting when inputs may carry an ordered route
8//! plan.
9
10use std::borrow::Cow;
11use std::fmt::{self, Write};
12use std::process::{Command, Stdio};
13
14use base64::Engine as _;
15
16use crate::header::ACCEPT_ENCODING;
17use crate::headers::{HeaderEncode, ProxyAuthorization};
18use crate::{HeaderName, Method, Version};
19
20use rama_core::bytes::Bytes;
21use rama_http_types::HttpRequestParts;
22use rama_net::client::ProxyRoute;
23use rama_net::mode::{ConnectIpMode, DnsResolveIpMode};
24use rama_net::uri::Uri;
25use rama_net::user::ProxyCredential;
26use rama_net::{AuthorityInputExt, ProtocolInputExt};
27
28/// Policies used while converting an HTTP request into curl arguments.
29///
30/// The default favors faithful request replay without requiring shell-specific
31/// helper programs: methods and HTTP versions are explicit, an explicit `Host`
32/// is preserved, curl manages body framing, and encoded responses are
33/// decompressed when the request contains `Accept-Encoding`.
34#[derive(Debug, Clone, Copy, PartialEq, Eq)]
35#[must_use]
36pub struct CurlExportOptions {
37    response_decompression: bool,
38    http_version: bool,
39    explicit_method: bool,
40    preserve_host_header: bool,
41    preserve_framing_headers: bool,
42    script_compatibility: CurlScriptCompatibility,
43}
44
45impl CurlExportOptions {
46    /// Options favoring faithful request replay and curl-only command strings.
47    pub const fn faithful() -> Self {
48        Self {
49            response_decompression: true,
50            http_version: true,
51            explicit_method: true,
52            preserve_host_header: true,
53            preserve_framing_headers: false,
54            script_compatibility: CurlScriptCompatibility::CrossPlatform,
55        }
56    }
57
58    rama_utils::macros::generate_set_and_with! {
59        /// Enable or disable curl's automatic response decompression.
60        pub fn response_decompression(mut self, enabled: bool) -> Self {
61            self.response_decompression = enabled;
62            self
63        }
64    }
65
66    rama_utils::macros::generate_set_and_with! {
67        /// Enable or disable communicating the request's recorded HTTP version to curl.
68        ///
69        /// HTTP/0.9 only permits matching responses; HTTPS HTTP/2 remains negotiated.
70        pub fn http_version(mut self, enabled: bool) -> Self {
71            self.http_version = enabled;
72            self
73        }
74    }
75
76    rama_utils::macros::generate_set_and_with! {
77        /// Enable or disable always rendering the request method explicitly.
78        ///
79        /// Methods that curl would otherwise change because a body is present are
80        /// still rendered explicitly when this option is disabled.
81        pub fn explicit_method(mut self, enabled: bool) -> Self {
82            self.explicit_method = enabled;
83            self
84        }
85    }
86
87    rama_utils::macros::generate_set_and_with! {
88        /// Enable or disable preserving an explicit `Host` header.
89        pub fn host_header(mut self, enabled: bool) -> Self {
90            self.preserve_host_header = enabled;
91            self
92        }
93    }
94
95    rama_utils::macros::generate_set_and_with! {
96        /// Enable or disable preserving `Content-Length` and `Transfer-Encoding`.
97        ///
98        /// Disabled by default because curl should normally derive framing from the
99        /// selected body source.
100        pub fn framing_headers(mut self, enabled: bool) -> Self {
101            self.preserve_framing_headers = enabled;
102            self
103        }
104    }
105
106    rama_utils::macros::generate_set_and_with! {
107        /// Select the compatibility target used when rendering a command string.
108        ///
109        /// This option does not affect the shell-independent [`Command`] APIs.
110        pub fn script_compatibility(
111            mut self,
112            compatibility: CurlScriptCompatibility,
113        ) -> Self {
114            self.script_compatibility = compatibility;
115            self
116        }
117    }
118}
119
120impl Default for CurlExportOptions {
121    fn default() -> Self {
122        Self::faithful()
123    }
124}
125
126/// Compatibility target for an exported curl command string.
127///
128/// This controls shell syntax and whether a self-contained script may use
129/// platform-specific facilities to preserve arbitrary payload bytes.
130#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
131#[non_exhaustive]
132pub enum CurlScriptCompatibility {
133    /// Emit a pure curl invocation without shell-specific helper programs.
134    ///
135    /// "Cross-platform" refers to executable dependencies: shell quoting itself
136    /// is not universal. Select a concrete target when its syntax matters, or
137    /// use the [`Command`] APIs to avoid a shell entirely.
138    ///
139    /// Inline payloads must be UTF-8 and cannot contain NUL. Use
140    /// [`CurlScriptPayloadMode::Stdin`] or [`CurlScriptPayloadMode::File`] for
141    /// arbitrary bytes.
142    #[default]
143    CrossPlatform,
144    /// Target a Unix shell and embed inline payload bytes through `base64`.
145    Unix,
146    /// Target PowerShell and embed inline payload bytes through a temporary file.
147    PowerShell,
148}
149
150/// How a request payload is referenced by an exported curl command string.
151#[derive(Debug, Clone, PartialEq, Eq, Default)]
152#[non_exhaustive]
153pub enum CurlScriptPayloadMode {
154    /// Embed the payload in the exported script.
155    ///
156    /// With [`CurlScriptCompatibility::CrossPlatform`], this uses `--data-raw`
157    /// and rejects NUL or invalid UTF-8. Platform-specific targets can use
158    /// their native facilities to embed arbitrary bytes losslessly.
159    #[default]
160    Inline,
161    /// Read exact payload bytes from stdin using `--data-binary @-`.
162    Stdin,
163    /// Read exact payload bytes from a caller-managed sidecar file.
164    File(String),
165}
166
167impl CurlScriptPayloadMode {
168    /// Create a sidecar-file payload mode for the given command-visible path.
169    pub fn file(path: impl Into<String>) -> Self {
170        Self::File(path.into())
171    }
172}
173
174/// Create a `curl` command string for the given [`HttpRequestParts`].
175pub fn cmd_string_for_request_parts(
176    parts: &(impl HttpRequestParts + AuthorityInputExt + ProtocolInputExt),
177) -> String {
178    cmd_string_for_request_parts_with_options(parts, CurlExportOptions::default())
179}
180
181/// Create a `curl` command string using explicit export policies.
182pub fn cmd_string_for_request_parts_with_options(
183    parts: &(impl HttpRequestParts + AuthorityInputExt + ProtocolInputExt),
184    options: CurlExportOptions,
185) -> String {
186    let mut cmd = CurlScriptWriter::new(
187        curl_script_program(options.script_compatibility),
188        options.script_compatibility,
189    );
190    write_curl_command_for_request_parts(&mut cmd, parts, CurlPayload::None, options);
191    cmd.finish()
192}
193
194/// Create a pure `curl` command string for the given request and inline payload.
195///
196/// This function refuses payloads that cannot be represented losslessly as a
197/// command-line argument. Use [`prepare_cmd_for_request_parts_and_payload`] for
198/// arbitrary bytes without a shell, or use
199/// [`try_cmd_string_for_request_parts_and_payload_with_options`] to select a
200/// platform-specific script target or external payload source.
201pub fn try_cmd_string_for_request_parts_and_payload(
202    parts: &(impl HttpRequestParts + AuthorityInputExt + ProtocolInputExt),
203    payload: &Bytes,
204) -> Result<String, CurlPayloadRequiresStdin> {
205    try_cmd_string_for_request_parts_and_payload_with_options(
206        parts,
207        payload,
208        CurlExportOptions::default(),
209        &CurlScriptPayloadMode::default(),
210    )
211}
212
213/// Create a curl command string using explicit request and payload policies.
214pub fn try_cmd_string_for_request_parts_and_payload_with_options(
215    parts: &(impl HttpRequestParts + AuthorityInputExt + ProtocolInputExt),
216    payload: &Bytes,
217    options: CurlExportOptions,
218    payload_mode: &CurlScriptPayloadMode,
219) -> Result<String, CurlPayloadRequiresStdin> {
220    let (prefix, payload_source, suffix) = match (payload.is_empty(), payload_mode) {
221        (true, _) => (
222            Cow::Borrowed(curl_script_program(options.script_compatibility)),
223            CurlPayload::None,
224            None,
225        ),
226        (false, CurlScriptPayloadMode::Stdin) => (
227            Cow::Borrowed(curl_script_program(options.script_compatibility)),
228            CurlPayload::Stdin,
229            None,
230        ),
231        (false, CurlScriptPayloadMode::File(path)) => (
232            Cow::Borrowed(curl_script_program(options.script_compatibility)),
233            CurlPayload::File(path.as_str()),
234            None,
235        ),
236        (false, CurlScriptPayloadMode::Inline) => match options.script_compatibility {
237            CurlScriptCompatibility::CrossPlatform => {
238                if payload.contains(&0) || std::str::from_utf8(payload).is_err() {
239                    return Err(CurlPayloadRequiresStdin);
240                }
241                (Cow::Borrowed("curl"), CurlPayload::Inline(payload), None)
242            }
243            CurlScriptCompatibility::Unix => {
244                if !payload.contains(&0) && std::str::from_utf8(payload).is_ok() {
245                    (Cow::Borrowed("curl"), CurlPayload::Inline(payload), None)
246                } else {
247                    let mut prefix = "printf %s ".to_owned();
248                    write_unix_shell_single_quoted(
249                        &mut prefix,
250                        base64::engine::general_purpose::STANDARD.encode(payload),
251                    );
252                    // `-d` is supported by GNU, BSD/macOS and BusyBox base64.
253                    prefix.push_str(" | base64 -d | curl");
254                    (Cow::Owned(prefix), CurlPayload::Stdin, None)
255                }
256            }
257            CurlScriptCompatibility::PowerShell => {
258                if !payload.contains(&0) && std::str::from_utf8(payload).is_ok() {
259                    (
260                        Cow::Borrowed(curl_script_program(CurlScriptCompatibility::PowerShell)),
261                        CurlPayload::Inline(payload),
262                        None,
263                    )
264                } else {
265                    let encoded = base64::engine::general_purpose::STANDARD.encode(payload);
266                    let prefix = format!(
267                        "$__ramaCurlPayload = [IO.Path]::GetTempFileName(); try {{ \
268                     [IO.File]::WriteAllBytes($__ramaCurlPayload, \
269                     [Convert]::FromBase64String('{encoded}')); {}",
270                        curl_script_program(CurlScriptCompatibility::PowerShell),
271                    );
272                    (
273                        Cow::Owned(prefix),
274                        CurlPayload::PowerShellTempFile,
275                        Some(" } finally { Remove-Item -LiteralPath $__ramaCurlPayload }"),
276                    )
277                }
278            }
279        },
280    };
281    let mut cmd = CurlScriptWriter::new(prefix, options.script_compatibility);
282    write_curl_command_for_request_parts(&mut cmd, parts, payload_source, options);
283    let mut cmd = cmd.finish();
284    if let Some(suffix) = suffix {
285        cmd.push_str(suffix);
286    }
287    Ok(cmd)
288}
289
290/// Create a `curl` [`Command`] for the given [`HttpRequestParts`].
291pub fn cmd_for_request_parts(
292    parts: &(impl HttpRequestParts + AuthorityInputExt + ProtocolInputExt),
293) -> Command {
294    cmd_for_request_parts_with_options(parts, CurlExportOptions::default())
295}
296
297/// Create a `curl` [`Command`] using explicit export policies.
298pub fn cmd_for_request_parts_with_options(
299    parts: &(impl HttpRequestParts + AuthorityInputExt + ProtocolInputExt),
300    options: CurlExportOptions,
301) -> Command {
302    let mut cmd = Command::new("curl");
303    write_curl_command_for_request_parts(&mut cmd, parts, CurlPayload::None, options);
304    cmd
305}
306
307/// Create a `curl` [`Command`] for the given [`HttpRequestParts`] and payload bytes.
308///
309/// Invalid UTF-8 is replaced lossily for backwards compatibility. Prefer
310/// [`prepare_cmd_for_request_parts_and_payload`] for arbitrary body bytes.
311pub fn cmd_for_request_parts_and_payload(
312    parts: &(impl HttpRequestParts + AuthorityInputExt + ProtocolInputExt),
313    payload: &Bytes,
314) -> Command {
315    cmd_for_request_parts_and_payload_with_options(parts, payload, CurlExportOptions::default())
316}
317
318/// Create a `curl` [`Command`] with an inline payload and explicit policies.
319pub fn cmd_for_request_parts_and_payload_with_options(
320    parts: &(impl HttpRequestParts + AuthorityInputExt + ProtocolInputExt),
321    payload: &Bytes,
322    options: CurlExportOptions,
323) -> Command {
324    let mut cmd = Command::new("curl");
325    write_curl_command_for_request_parts(&mut cmd, parts, CurlPayload::Inline(payload), options);
326    cmd
327}
328
329/// A `curl` process command together with bytes that must be written to its stdin.
330///
331/// This representation keeps arbitrary request bodies out of process arguments,
332/// which cannot contain NUL bytes. It is shell-independent and cross-platform.
333#[must_use]
334pub struct PreparedCurlCommand {
335    command: Command,
336    stdin: Option<Bytes>,
337}
338
339impl PreparedCurlCommand {
340    /// Return the configured `curl` process command.
341    pub fn command(&self) -> &Command {
342        &self.command
343    }
344
345    /// Return the configured `curl` process command mutably.
346    pub fn command_mut(&mut self) -> &mut Command {
347        &mut self.command
348    }
349
350    /// Return bytes that must be written to the spawned child's stdin.
351    pub fn stdin(&self) -> Option<&Bytes> {
352        self.stdin.as_ref()
353    }
354
355    /// Split into the process command and its optional stdin payload.
356    pub fn into_parts(self) -> (Command, Option<Bytes>) {
357        (self.command, self.stdin)
358    }
359}
360
361/// Prepare a shell-independent `curl` command that receives its payload on stdin.
362///
363/// Non-empty payloads are represented as `--data-binary @-`; callers must write
364/// [`PreparedCurlCommand::stdin`] to the spawned child's piped stdin.
365pub fn prepare_cmd_for_request_parts_and_payload(
366    parts: &(impl HttpRequestParts + AuthorityInputExt + ProtocolInputExt),
367    payload: &Bytes,
368) -> PreparedCurlCommand {
369    prepare_cmd_for_request_parts_and_payload_with_options(
370        parts,
371        payload,
372        CurlExportOptions::default(),
373    )
374}
375
376/// Prepare a shell-independent curl command using explicit export policies.
377pub fn prepare_cmd_for_request_parts_and_payload_with_options(
378    parts: &(impl HttpRequestParts + AuthorityInputExt + ProtocolInputExt),
379    payload: &Bytes,
380    options: CurlExportOptions,
381) -> PreparedCurlCommand {
382    let mut command = Command::new("curl");
383    let stdin = if payload.is_empty() {
384        write_curl_command_for_request_parts(&mut command, parts, CurlPayload::None, options);
385        None
386    } else {
387        command.stdin(Stdio::piped());
388        write_curl_command_for_request_parts(&mut command, parts, CurlPayload::Stdin, options);
389        Some(payload.clone())
390    };
391    PreparedCurlCommand { command, stdin }
392}
393
394/// Error returned when the selected script policies cannot embed a payload
395/// losslessly.
396#[derive(Debug, Clone, Copy, PartialEq, Eq)]
397pub struct CurlPayloadRequiresStdin;
398
399impl fmt::Display for CurlPayloadRequiresStdin {
400    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
401        f.write_str("curl payload requires stdin, a file, or a platform-specific target")
402    }
403}
404
405impl std::error::Error for CurlPayloadRequiresStdin {}
406
407#[derive(Clone, Copy)]
408enum CurlPayload<'a> {
409    None,
410    Inline(&'a Bytes),
411    Stdin,
412    File(&'a str),
413    PowerShellTempFile,
414}
415
416fn curl_script_program(compatibility: CurlScriptCompatibility) -> &'static str {
417    match compatibility {
418        CurlScriptCompatibility::PowerShell => {
419            "& (Get-Command curl -CommandType Application).Source"
420        }
421        CurlScriptCompatibility::CrossPlatform | CurlScriptCompatibility::Unix => "curl",
422    }
423}
424
425impl CurlPayload<'_> {
426    fn is_non_empty(self) -> bool {
427        match self {
428            Self::None => false,
429            Self::Inline(payload) => !payload.is_empty(),
430            Self::Stdin | Self::File(_) | Self::PowerShellTempFile => true,
431        }
432    }
433}
434
435struct CurlScriptWriter {
436    output: String,
437    compatibility: CurlScriptCompatibility,
438}
439
440impl CurlScriptWriter {
441    fn new(output: impl Into<String>, compatibility: CurlScriptCompatibility) -> Self {
442        Self {
443            output: output.into(),
444            compatibility,
445        }
446    }
447
448    fn finish(self) -> String {
449        self.output
450    }
451
452    fn write_separator(&mut self) {
453        match self.compatibility {
454            CurlScriptCompatibility::PowerShell => {
455                _ = write!(self.output, " `{}  ", rama_utils::str::NATIVE_NEWLINE);
456            }
457            CurlScriptCompatibility::CrossPlatform | CurlScriptCompatibility::Unix => {
458                _ = write!(self.output, " \\{}  ", rama_utils::str::NATIVE_NEWLINE);
459            }
460        }
461    }
462
463    fn write_quoted(&mut self, value: impl fmt::Display) {
464        match self.compatibility {
465            CurlScriptCompatibility::PowerShell => {
466                write_powershell_single_quoted(&mut self.output, value);
467            }
468            CurlScriptCompatibility::CrossPlatform | CurlScriptCompatibility::Unix => {
469                write_unix_shell_single_quoted(&mut self.output, value);
470            }
471        }
472    }
473}
474
475trait CurlCommandWriter {
476    fn write_uri(&mut self, uri: Uri) -> &mut Self;
477    fn write_single(&mut self, one: impl fmt::Display) -> &mut Self;
478    fn write_tuple(
479        &mut self,
480        one: impl fmt::Display,
481        two: impl fmt::Display,
482        quote_value: bool,
483    ) -> &mut Self;
484    fn write_header(&mut self, key: HeaderName, value: Cow<'_, str>) -> &mut Self;
485}
486
487impl CurlCommandWriter for Command {
488    // `Command::arg` passes each value as a distinct argv element straight to
489    // the curl process (no shell), so values must NOT be quoted: any surrounding
490    // quotes would become literal bytes of the argument. This is the dual of the
491    // script writer below, which builds shell text and therefore must quote.
492    fn write_uri(&mut self, uri: Uri) -> &mut Self {
493        self.arg(uri.to_string())
494    }
495
496    fn write_single(&mut self, one: impl fmt::Display) -> &mut Self {
497        self.arg(one.to_string())
498    }
499
500    fn write_tuple(
501        &mut self,
502        one: impl fmt::Display,
503        two: impl fmt::Display,
504        _quote_value: bool,
505    ) -> &mut Self {
506        // `quote_value` only governs shell-text quoting; argv needs none.
507        self.arg(one.to_string()).arg(two.to_string())
508    }
509
510    fn write_header(&mut self, key: HeaderName, value: Cow<'_, str>) -> &mut Self {
511        self.arg("-H").arg(format!("{key}: {value}"))
512    }
513}
514
515impl CurlCommandWriter for CurlScriptWriter {
516    fn write_uri(&mut self, uri: Uri) -> &mut Self {
517        self.output.push(' ');
518        self.write_quoted(uri);
519        self
520    }
521
522    fn write_single(&mut self, one: impl fmt::Display) -> &mut Self {
523        self.write_separator();
524        _ = write!(self.output, "{one}");
525        self
526    }
527
528    fn write_tuple(
529        &mut self,
530        one: impl fmt::Display,
531        two: impl fmt::Display,
532        quote_value: bool,
533    ) -> &mut Self {
534        self.write_separator();
535        _ = write!(self.output, "{one} ");
536        if quote_value {
537            self.write_quoted(two);
538        } else {
539            _ = write!(self.output, "{two}");
540        }
541        self
542    }
543
544    fn write_header(&mut self, key: HeaderName, value: Cow<'_, str>) -> &mut Self {
545        self.write_separator();
546        self.output.push_str("-H ");
547        self.write_quoted(format_args!("{key}: {value}"));
548        self
549    }
550}
551
552fn write_unix_shell_single_quoted(out: &mut String, value: impl fmt::Display) {
553    struct ShellSingleQuoted<'a>(&'a mut String);
554
555    impl fmt::Write for ShellSingleQuoted<'_> {
556        fn write_str(&mut self, value: &str) -> fmt::Result {
557            let mut start = 0;
558            for (idx, ch) in value.char_indices() {
559                if ch == '\'' {
560                    self.0.push_str(&value[start..idx]);
561                    self.0.push_str("'\\''");
562                    start = idx + ch.len_utf8();
563                }
564            }
565            self.0.push_str(&value[start..]);
566            Ok(())
567        }
568    }
569
570    out.push('\'');
571    _ = write!(&mut ShellSingleQuoted(out), "{value}");
572    out.push('\'');
573}
574
575fn write_powershell_single_quoted(out: &mut String, value: impl fmt::Display) {
576    struct PowerShellSingleQuoted<'a>(&'a mut String);
577
578    impl fmt::Write for PowerShellSingleQuoted<'_> {
579        fn write_str(&mut self, value: &str) -> fmt::Result {
580            let mut start = 0;
581            for (idx, ch) in value.char_indices() {
582                if ch == '\'' {
583                    self.0.push_str(&value[start..idx]);
584                    self.0.push_str("''");
585                    start = idx + ch.len_utf8();
586                }
587            }
588            self.0.push_str(&value[start..]);
589            Ok(())
590        }
591    }
592
593    out.push('\'');
594    _ = write!(&mut PowerShellSingleQuoted(out), "{value}");
595    out.push('\'');
596}
597
598fn write_curl_command_for_request_parts(
599    writer: &mut impl CurlCommandWriter,
600    parts: &(impl HttpRequestParts + AuthorityInputExt + ProtocolInputExt),
601    payload: CurlPayload<'_>,
602    options: CurlExportOptions,
603) {
604    let mut uri = parts.uri().clone();
605    // Origin-form requests carry only a path; reconstruct the full URL for curl
606    // from the request context's authority (+ scheme). Requests that already
607    // carry an authority (absolute- or authority-form) are rendered as-is.
608    if uri.authority().is_none()
609        && let Some(authority) = parts.authority()
610    {
611        let protocol = parts.protocol();
612        uri.set_authority(authority.without_default_port_for(protocol).into());
613        if uri.scheme().is_none()
614            && let Some(protocol) = protocol
615        {
616            uri.set_scheme(protocol.clone());
617        }
618    }
619    writer.write_uri(uri);
620
621    if options.response_decompression && parts.headers().contains_key(ACCEPT_ENCODING) {
622        writer.write_single("--compressed");
623    }
624
625    if parts.method() == Method::HEAD {
626        // Unlike `-X HEAD`, `--head` also tells curl not to wait for a response
627        // body that a conforming HEAD server will never send.
628        writer.write_single("--head");
629    } else if options.explicit_method || parts.method() != Method::GET || payload.is_non_empty() {
630        writer.write_tuple("-X", parts.method(), false);
631    }
632
633    if options.http_version {
634        match parts.version() {
635            Version::HTTP_09 => {
636                writer.write_single("--http0.9");
637            }
638            Version::HTTP_10 => {
639                writer.write_single("--http1.0");
640            }
641            Version::HTTP_11 => {
642                writer.write_single("--http1.1");
643            }
644            Version::HTTP_2 => {
645                writer.write_single("--http2-prior-knowledge");
646            }
647            Version::HTTP_3 => {
648                writer.write_single("--http3-only");
649            }
650        }
651    }
652
653    if let Some(route) = parts.extensions().get_ref::<ProxyRoute>()
654        && let Some(proxy_addr) = route.proxy_address()
655    {
656        writer.write_tuple("-x", proxy_addr, true);
657        if let Some(ProxyCredential::Bearer(bearer)) = &proxy_addr.credential
658            && let Some(value) = ProxyAuthorization(bearer.clone()).encode_to_value()
659        {
660            let s_value = String::from_utf8_lossy(value.as_bytes());
661            writer.write_tuple(
662                "--proxy-header",
663                format_args!("{}: {s_value}", crate::header::PROXY_AUTHORIZATION),
664                true,
665            );
666        }
667    }
668
669    match (
670        parts.extensions().get_ref::<DnsResolveIpMode>(),
671        parts.extensions().get_ref::<ConnectIpMode>(),
672    ) {
673        (Some(DnsResolveIpMode::SingleIpV4), _)
674        | (
675            None | Some(DnsResolveIpMode::DualPreferIpV4 | DnsResolveIpMode::Dual),
676            Some(ConnectIpMode::Ipv4),
677        ) => {
678            // force ipv4
679            writer.write_single("-4");
680        }
681        (Some(DnsResolveIpMode::SingleIpV6), _)
682        | (
683            None | Some(DnsResolveIpMode::DualPreferIpV4 | DnsResolveIpMode::Dual),
684            Some(ConnectIpMode::Ipv6),
685        ) => {
686            // force ipv6
687            writer.write_single("-6");
688        }
689        _ => (), // nothing that can be done
690    }
691
692    for (key, value) in parts.headers().ordered_iter() {
693        let skip_header = match key.standard() {
694            Some(crate::header::StandardHeader::Host) => !options.preserve_host_header,
695            Some(
696                crate::header::StandardHeader::ContentLength
697                | crate::header::StandardHeader::TransferEncoding,
698            ) => !options.preserve_framing_headers,
699            _ => false,
700        };
701        if skip_header {
702            // Let curl derive the authority and body framing from the URL and
703            // selected payload source; replaying captured framing can conflict.
704            continue;
705        }
706
707        let s_value = String::from_utf8_lossy(value.as_bytes());
708        writer.write_header(key.clone(), s_value);
709    }
710
711    match payload {
712        CurlPayload::Inline(payload) if !payload.is_empty() => {
713            writer.write_tuple(
714                "--data-raw",
715                String::from_utf8_lossy(payload.as_ref()),
716                true,
717            );
718        }
719        CurlPayload::Stdin => {
720            writer.write_tuple("--data-binary", "@-", true);
721        }
722        CurlPayload::File(path) => {
723            writer.write_tuple("--data-binary", format_args!("@{path}"), true);
724        }
725        CurlPayload::PowerShellTempFile => {
726            writer.write_tuple("--data-binary", "('@' + $__ramaCurlPayload)", false);
727        }
728        CurlPayload::None | CurlPayload::Inline(_) => {}
729    }
730}
731
732#[cfg(test)]
733mod tests {
734    use rama_net::Protocol;
735    use rama_net::address::{HostWithPort, ProxyAddress};
736    use rama_net::client::ProxyRoute;
737    use rama_net::user::credentials::{basic, bearer};
738
739    use crate::body::util::BodyExt;
740    use crate::layer::har;
741
742    use super::*;
743
744    #[tokio::test]
745    async fn test_cmd_string_for_request_parts_from_har() {
746        struct TestCase {
747            description: &'static str,
748            input_har_request: &'static str,
749            expected_cmd_string: String,
750        }
751
752        for test_case in [
753            TestCase {
754                description: "GET example.com",
755                input_har_request: r##"{
756    "bodySize": 0,
757    "method": "GET",
758    "url": "https://example.com/",
759    "httpVersion": "HTTP/2",
760    "headers": [
761        {
762            "name": "Host",
763            "value": "example.com"
764        },
765        {
766            "name": "User-Agent",
767            "value": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:142.0) Gecko/20100101 Firefox/142.0"
768        },
769        {
770            "name": "Accept",
771            "value": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8"
772        },
773        {
774            "name": "Accept-Language",
775            "value": "en-US,en;q=0.5"
776        },
777        {
778            "name": "Accept-Encoding",
779            "value": "gzip, deflate, br, zstd"
780        },
781        {
782            "name": "Sec-GPC",
783            "value": "1"
784        },
785        {
786            "name": "Upgrade-Insecure-Requests",
787            "value": "1"
788        },
789        {
790            "name": "Connection",
791            "value": "keep-alive"
792        },
793        {
794            "name": "Sec-Fetch-Dest",
795            "value": "document"
796        },
797        {
798            "name": "Sec-Fetch-Mode",
799            "value": "navigate"
800        },
801        {
802            "name": "Sec-Fetch-Site",
803            "value": "none"
804        },
805        {
806            "name": "Sec-Fetch-User",
807            "value": "?1"
808        },
809        {
810            "name": "Priority",
811            "value": "u=0, i"
812        },
813        {
814            "name": "Pragma",
815            "value": "no-cache"
816        },
817        {
818            "name": "Cache-Control",
819            "value": "no-cache"
820        }
821    ],
822    "cookies": [],
823    "queryString": [],
824    "headersSize": 504
825}"##,
826                expected_cmd_string: format!(
827                    r##"curl 'https://example.com/' \{NL}  --compressed \{NL}  -X GET \{NL}  --http2-prior-knowledge \{NL}  -H 'Host: example.com' \{NL}  -H 'User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:142.0) Gecko/20100101 Firefox/142.0' \{NL}  -H 'Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8' \{NL}  -H 'Accept-Language: en-US,en;q=0.5' \{NL}  -H 'Accept-Encoding: gzip, deflate, br, zstd' \{NL}  -H 'Sec-GPC: 1' \{NL}  -H 'Upgrade-Insecure-Requests: 1' \{NL}  -H 'Connection: keep-alive' \{NL}  -H 'Sec-Fetch-Dest: document' \{NL}  -H 'Sec-Fetch-Mode: navigate' \{NL}  -H 'Sec-Fetch-Site: none' \{NL}  -H 'Sec-Fetch-User: ?1' \{NL}  -H 'Priority: u=0, i' \{NL}  -H 'Pragma: no-cache' \{NL}  -H 'Cache-Control: no-cache'"##,
828                    NL = rama_utils::str::NATIVE_NEWLINE
829                ),
830            },
831            TestCase {
832                description: "POST form request for ramaproxy FP",
833                input_har_request: r##"{
834    "bodySize": 19,
835    "method": "POST",
836    "url": "https://fp.ramaproxy.org/form",
837    "httpVersion": "HTTP/2",
838    "headers": [
839    {
840        "name": "Host",
841        "value": "fp.ramaproxy.org"
842    },
843    {
844        "name": "User-Agent",
845        "value": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:142.0) Gecko/20100101 Firefox/142.0"
846    },
847    {
848        "name": "Accept",
849        "value": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8"
850    },
851    {
852        "name": "Accept-Language",
853        "value": "en-US,en;q=0.5"
854    },
855    {
856        "name": "Accept-Encoding",
857        "value": "gzip, deflate, br, zstd"
858    },
859    {
860        "name": "Content-Type",
861        "value": "application/x-www-form-urlencoded"
862    },
863    {
864        "name": "Content-Length",
865        "value": "19"
866    },
867    {
868        "name": "Origin",
869        "value": "https://fp.ramaproxy.org"
870    },
871    {
872        "name": "Sec-GPC",
873        "value": "1"
874    },
875    {
876        "name": "Connection",
877        "value": "keep-alive"
878    },
879    {
880        "name": "Referer",
881        "value": "https://fp.ramaproxy.org/report"
882    },
883    {
884        "name": "Cookie",
885        "value": "rama-fp=ready"
886    },
887    {
888        "name": "Upgrade-Insecure-Requests",
889        "value": "1"
890    },
891    {
892        "name": "Sec-Fetch-Dest",
893        "value": "document"
894    },
895    {
896        "name": "Sec-Fetch-Mode",
897        "value": "navigate"
898    },
899    {
900        "name": "Sec-Fetch-Site",
901        "value": "same-origin"
902    },
903    {
904        "name": "Sec-Fetch-User",
905        "value": "?1"
906    },
907    {
908        "name": "Priority",
909        "value": "u=0, i"
910    },
911    {
912        "name": "Pragma",
913        "value": "no-cache"
914    },
915    {
916        "name": "Cache-Control",
917        "value": "no-cache"
918    },
919    {
920        "name": "TE",
921        "value": "trailers"
922    }
923    ],
924    "cookies": [
925    {
926        "name": "rama-fp",
927        "value": "ready"
928    }
929    ],
930    "queryString": [],
931    "headersSize": 689,
932    "postData": {
933    "mimeType": "application/x-www-form-urlencoded",
934    "params": [
935        {
936        "name": "source",
937        "value": "web"
938        },
939        {
940        "name": "rating",
941        "value": "3"
942        }
943    ],
944    "text": "source=web&rating=3"
945    }
946}"##,
947                expected_cmd_string: format!(
948                    r##"curl 'https://fp.ramaproxy.org/form' \{NL}  --compressed \{NL}  -X POST \{NL}  --http2-prior-knowledge \{NL}  -H 'Host: fp.ramaproxy.org' \{NL}  -H 'User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:142.0) Gecko/20100101 Firefox/142.0' \{NL}  -H 'Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8' \{NL}  -H 'Accept-Language: en-US,en;q=0.5' \{NL}  -H 'Accept-Encoding: gzip, deflate, br, zstd' \{NL}  -H 'Content-Type: application/x-www-form-urlencoded' \{NL}  -H 'Origin: https://fp.ramaproxy.org' \{NL}  -H 'Sec-GPC: 1' \{NL}  -H 'Connection: keep-alive' \{NL}  -H 'Referer: https://fp.ramaproxy.org/report' \{NL}  -H 'Cookie: rama-fp=ready' \{NL}  -H 'Upgrade-Insecure-Requests: 1' \{NL}  -H 'Sec-Fetch-Dest: document' \{NL}  -H 'Sec-Fetch-Mode: navigate' \{NL}  -H 'Sec-Fetch-Site: same-origin' \{NL}  -H 'Sec-Fetch-User: ?1' \{NL}  -H 'Priority: u=0, i' \{NL}  -H 'Pragma: no-cache' \{NL}  -H 'Cache-Control: no-cache' \{NL}  -H 'TE: trailers' \{NL}  --data-raw 'source=web&rating=3'"##,
949                    NL = rama_utils::str::NATIVE_NEWLINE
950                ),
951            },
952        ] {
953            // put input together
954            let har_request: har::spec::Request = serde_json::from_str(test_case.input_har_request)
955                .unwrap_or_else(|err| {
956                    panic!(
957                        "expect testcase '{}' har request to deserialize: {err}",
958                        test_case.description
959                    )
960                });
961            let request: crate::Request = har_request.try_into().unwrap_or_else(|err| {
962                panic!(
963                    "expect testcase '{}' har request to convert into a http request: {err}",
964                    test_case.description
965                )
966            });
967
968            let (parts, body) = request.into_parts();
969            let payload = body.collect().await.unwrap().to_bytes();
970
971            let cmd_string = if payload.is_empty() {
972                cmd_string_for_request_parts(&parts)
973            } else {
974                try_cmd_string_for_request_parts_and_payload(&parts, &payload).unwrap()
975            };
976
977            assert_eq!(
978                test_case.expected_cmd_string, cmd_string,
979                "testcase '{}'",
980                test_case.description
981            );
982        }
983    }
984
985    #[test]
986    fn test_cmd_string_for_request_with_http_proxy_no_auth() {
987        let (parts, _) = crate::Request::builder()
988            .uri(Uri::parse_authority_form("example.com").unwrap())
989            .body(())
990            .unwrap()
991            .into_parts();
992
993        parts.extensions.insert(ProxyRoute::Proxy(ProxyAddress {
994            protocol: None,
995            address: HostWithPort::local_ipv4(8080),
996            credential: None,
997        }));
998
999        let s = cmd_string_for_request_parts(&&parts);
1000        assert_eq!(
1001            s,
1002            format!(
1003                r##"curl 'example.com' \{NL}  -X GET \{NL}  --http1.1 \{NL}  -x '127.0.0.1:8080'"##,
1004                NL = rama_utils::str::NATIVE_NEWLINE
1005            ),
1006        );
1007    }
1008
1009    #[test]
1010    fn test_cmd_string_for_request_with_ipv4_preference() {
1011        let (parts, _) = crate::Request::builder()
1012            .uri(Uri::parse_authority_form("example.com").unwrap())
1013            .body(())
1014            .unwrap()
1015            .into_parts();
1016
1017        parts.extensions.insert(DnsResolveIpMode::SingleIpV4);
1018
1019        let s = cmd_string_for_request_parts(&&parts);
1020        assert_eq!(
1021            s,
1022            format!(
1023                r##"curl 'example.com' \{NL}  -X GET \{NL}  --http1.1 \{NL}  -4"##,
1024                NL = rama_utils::str::NATIVE_NEWLINE
1025            ),
1026        );
1027    }
1028
1029    #[test]
1030    fn test_cmd_string_for_request_with_ipv6_preference() {
1031        let (parts, _) = crate::Request::builder()
1032            .uri(Uri::parse_authority_form("example.com").unwrap())
1033            .body(())
1034            .unwrap()
1035            .into_parts();
1036
1037        parts.extensions.insert(DnsResolveIpMode::SingleIpV6);
1038
1039        let s = cmd_string_for_request_parts(&&parts);
1040        assert_eq!(
1041            s,
1042            format!(
1043                r##"curl 'example.com' \{NL}  -X GET \{NL}  --http1.1 \{NL}  -6"##,
1044                NL = rama_utils::str::NATIVE_NEWLINE
1045            ),
1046        );
1047    }
1048
1049    #[test]
1050    fn test_cmd_string_for_request_with_http_proxy_with_auth_basic_only_username() {
1051        let (parts, _) = crate::Request::builder()
1052            .uri(Uri::parse_authority_form("example.com").unwrap())
1053            .body(())
1054            .unwrap()
1055            .into_parts();
1056
1057        parts.extensions.insert(ProxyRoute::Proxy(ProxyAddress {
1058            protocol: None,
1059            address: HostWithPort::local_ipv4(8080),
1060            credential: Some(ProxyCredential::Basic(basic!("john"))),
1061        }));
1062
1063        let s = cmd_string_for_request_parts(&&parts);
1064        assert_eq!(
1065            s,
1066            format!(
1067                r##"curl 'example.com' \{NL}  -X GET \{NL}  --http1.1 \{NL}  -x 'john@127.0.0.1:8080'"##,
1068                NL = rama_utils::str::NATIVE_NEWLINE
1069            ),
1070        );
1071    }
1072
1073    #[test]
1074    fn test_cmd_string_for_request_with_http_proxy_with_auth_basic() {
1075        let (parts, _) = crate::Request::builder()
1076            .uri(Uri::parse_authority_form("example.com").unwrap())
1077            .body(())
1078            .unwrap()
1079            .into_parts();
1080
1081        parts.extensions.insert(ProxyRoute::Proxy(ProxyAddress {
1082            protocol: None,
1083            address: HostWithPort::local_ipv4(8080),
1084            credential: Some(ProxyCredential::Basic(basic!("john", "secret"))),
1085        }));
1086
1087        let s = cmd_string_for_request_parts(&&parts);
1088        assert_eq!(
1089            s,
1090            format!(
1091                r##"curl 'example.com' \{NL}  -X GET \{NL}  --http1.1 \{NL}  -x 'john:secret@127.0.0.1:8080'"##,
1092                NL = rama_utils::str::NATIVE_NEWLINE
1093            ),
1094        )
1095    }
1096
1097    #[test]
1098    fn test_cmd_string_for_request_with_http_proxy_with_auth_bearer() {
1099        let (parts, _) = crate::Request::builder()
1100            .uri(Uri::parse_authority_form("example.com").unwrap())
1101            .body(())
1102            .unwrap()
1103            .into_parts();
1104
1105        parts.extensions.insert(ProxyRoute::Proxy(ProxyAddress {
1106            protocol: None,
1107            address: HostWithPort::local_ipv4(8080),
1108            credential: Some(ProxyCredential::Bearer(bearer!("abc123"))),
1109        }));
1110
1111        let s = cmd_string_for_request_parts(&&parts);
1112        assert_eq!(
1113            s,
1114            format!(
1115                r##"curl 'example.com' \{NL}  -X GET \{NL}  --http1.1 \{NL}  -x '127.0.0.1:8080' \{NL}  --proxy-header 'proxy-authorization: Bearer abc123'"##,
1116                NL = rama_utils::str::NATIVE_NEWLINE
1117            ),
1118        );
1119    }
1120
1121    #[test]
1122    fn test_cmd_string_for_request_with_socks5_proxy() {
1123        let (parts, _) = crate::Request::builder()
1124            .version(Version::HTTP_3)
1125            .uri(Uri::parse_authority_form("example.com").unwrap())
1126            .body(())
1127            .unwrap()
1128            .into_parts();
1129
1130        parts.extensions.insert(ProxyRoute::Proxy(ProxyAddress {
1131            protocol: Some(Protocol::SOCKS5),
1132            address: HostWithPort::local_ipv4(8080),
1133            credential: Some(ProxyCredential::Basic(basic!("user", "pass"))),
1134        }));
1135
1136        let s = cmd_string_for_request_parts(&&parts);
1137        assert_eq!(
1138            s,
1139            format!(
1140                r##"curl 'example.com' \{NL}  -X GET \{NL}  --http3-only \{NL}  -x 'socks5://user:pass@127.0.0.1:8080'"##,
1141                NL = rama_utils::str::NATIVE_NEWLINE
1142            ),
1143        );
1144    }
1145
1146    #[test]
1147    fn test_cmd_string_for_request_shell_escapes_quoted_values() {
1148        let (parts, _) = crate::Request::builder()
1149            .method(Method::POST)
1150            .uri("https://example.com/")
1151            .header("x-test", "a'b")
1152            .body(())
1153            .unwrap()
1154            .into_parts();
1155
1156        let payload = Bytes::from_static(b"source='web'&rating=3");
1157        let s = try_cmd_string_for_request_parts_and_payload(&parts, &payload).unwrap();
1158
1159        assert_eq!(
1160            s,
1161            format!(
1162                r##"curl 'https://example.com/' \{NL}  -X POST \{NL}  --http1.1 \{NL}  -H 'x-test: a'\''b' \{NL}  --data-raw 'source='\''web'\''&rating=3'"##,
1163                NL = rama_utils::str::NATIVE_NEWLINE
1164            ),
1165        );
1166    }
1167
1168    #[test]
1169    fn test_cmd_string_for_get_with_payload_preserves_method() {
1170        let (parts, _) = crate::Request::builder()
1171            .method(Method::GET)
1172            .uri("https://example.com/search")
1173            .body(())
1174            .unwrap()
1175            .into_parts();
1176
1177        let s = try_cmd_string_for_request_parts_and_payload(
1178            &parts,
1179            &Bytes::from_static(br#"{"query":"rama"}"#),
1180        )
1181        .unwrap();
1182
1183        assert_eq!(
1184            s,
1185            format!(
1186                r##"curl 'https://example.com/search' \{NL}  -X GET \{NL}  --http1.1 \{NL}  --data-raw '{{"query":"rama"}}'"##,
1187                NL = rama_utils::str::NATIVE_NEWLINE
1188            ),
1189        );
1190    }
1191
1192    #[test]
1193    fn test_try_cmd_string_rejects_payloads_that_require_stdin() {
1194        let (parts, _) = crate::Request::builder()
1195            .method(Method::POST)
1196            .uri("https://example.com/upload")
1197            .body(())
1198            .unwrap()
1199            .into_parts();
1200
1201        assert_eq!(
1202            try_cmd_string_for_request_parts_and_payload(
1203                &parts,
1204                &Bytes::from_static(b"invalid: \xff"),
1205            ),
1206            Err(CurlPayloadRequiresStdin),
1207        );
1208        assert_eq!(
1209            try_cmd_string_for_request_parts_and_payload(
1210                &parts,
1211                &Bytes::from_static(b"contains\0nul"),
1212            ),
1213            Err(CurlPayloadRequiresStdin),
1214        );
1215
1216        let text = Bytes::from_static(b"source=web&rating=3");
1217        assert!(
1218            try_cmd_string_for_request_parts_and_payload(&parts, &text)
1219                .unwrap()
1220                .contains("--data-raw 'source=web&rating=3'")
1221        );
1222    }
1223
1224    #[test]
1225    fn test_default_export_options_are_faithful_and_configurable() {
1226        assert_eq!(CurlExportOptions::default(), CurlExportOptions::faithful());
1227
1228        let (parts, _) = crate::Request::builder()
1229            .method(Method::GET)
1230            .version(Version::HTTP_2)
1231            .uri("https://example.com/path")
1232            .header("accept-encoding", "gzip")
1233            .header("host", "virtual.example")
1234            .header("content-length", "7")
1235            .header("transfer-encoding", "chunked")
1236            .body(())
1237            .unwrap()
1238            .into_parts();
1239
1240        let faithful = cmd_string_for_request_parts(&parts);
1241        assert!(faithful.contains("-X GET"));
1242        assert!(faithful.contains("--http2-prior-knowledge"));
1243        assert!(faithful.contains("host: virtual.example"));
1244        assert!(faithful.contains("--compressed"));
1245        assert!(!faithful.contains("content-length:"));
1246        assert!(!faithful.contains("transfer-encoding:"));
1247
1248        let mut customized_options = CurlExportOptions::default()
1249            .with_response_decompression(false)
1250            .with_http_version(false)
1251            .with_explicit_method(false);
1252        customized_options
1253            .set_host_header(false)
1254            .set_framing_headers(true);
1255        let customized = cmd_string_for_request_parts_with_options(&parts, customized_options);
1256        assert!(!customized.contains("--compressed"));
1257        assert!(!customized.contains("-X GET"));
1258        assert!(!customized.contains("--http2-prior-knowledge"));
1259        assert!(!customized.contains("host: virtual.example"));
1260        assert!(customized.contains("content-length: 7"));
1261        assert!(customized.contains("transfer-encoding: chunked"));
1262    }
1263
1264    #[test]
1265    fn test_script_payload_modes_cover_stdin_and_file() {
1266        let (parts, _) = crate::Request::builder()
1267            .method(Method::POST)
1268            .uri("https://example.com/upload")
1269            .body(())
1270            .unwrap()
1271            .into_parts();
1272        let payload = Bytes::from_static(b"\0\xfftail\n");
1273        let options = CurlExportOptions::default();
1274
1275        let stdin = try_cmd_string_for_request_parts_and_payload_with_options(
1276            &parts,
1277            &payload,
1278            options,
1279            &CurlScriptPayloadMode::Stdin,
1280        )
1281        .unwrap();
1282        assert!(stdin.starts_with("curl "));
1283        assert!(stdin.contains("--data-binary '@-'"));
1284        assert!(!stdin.contains("base64"));
1285
1286        let file = try_cmd_string_for_request_parts_and_payload_with_options(
1287            &parts,
1288            &payload,
1289            options,
1290            &CurlScriptPayloadMode::file("flow's body.bin"),
1291        )
1292        .unwrap();
1293        assert!(file.starts_with("curl "));
1294        assert!(file.contains("--data-binary '@flow'\\''s body.bin'"));
1295        assert!(!file.contains("base64"));
1296    }
1297
1298    #[test]
1299    fn test_unix_compatibility_embeds_inline_payload_as_base64() {
1300        let (parts, _) = crate::Request::builder()
1301            .method(Method::POST)
1302            .uri("https://example.com/upload")
1303            .body(())
1304            .unwrap()
1305            .into_parts();
1306        let payload = Bytes::from_static(b"\0\xfftail\n");
1307        let unix = try_cmd_string_for_request_parts_and_payload_with_options(
1308            &parts,
1309            &payload,
1310            CurlExportOptions::default().with_script_compatibility(CurlScriptCompatibility::Unix),
1311            &CurlScriptPayloadMode::Inline,
1312        )
1313        .unwrap();
1314        assert!(unix.starts_with("printf %s 'AP90YWlsCg==' | base64 -d | curl "));
1315        assert!(unix.contains("--data-binary '@-'"));
1316        assert!(!unix.contains('\u{fffd}'));
1317    }
1318
1319    #[test]
1320    fn test_platform_scripts_keep_text_payloads_readable() {
1321        let (parts, _) = crate::Request::builder()
1322            .method(Method::POST)
1323            .uri("https://example.com/upload")
1324            .body(())
1325            .unwrap()
1326            .into_parts();
1327        let payload = Bytes::from_static(b"plain text");
1328
1329        for compatibility in [
1330            CurlScriptCompatibility::Unix,
1331            CurlScriptCompatibility::PowerShell,
1332        ] {
1333            let script = try_cmd_string_for_request_parts_and_payload_with_options(
1334                &parts,
1335                &payload,
1336                CurlExportOptions::default().with_script_compatibility(compatibility),
1337                &CurlScriptPayloadMode::Inline,
1338            )
1339            .unwrap();
1340            assert!(script.contains("--data-raw 'plain text'"));
1341            assert!(!script.contains("base64"));
1342            assert!(!script.contains("GetTempFileName"));
1343        }
1344    }
1345
1346    #[test]
1347    fn test_head_uses_curl_head_mode_instead_of_custom_method() {
1348        let (parts, _) = crate::Request::builder()
1349            .method(Method::HEAD)
1350            .uri("https://example.com/health")
1351            .body(())
1352            .unwrap()
1353            .into_parts();
1354
1355        let script = cmd_string_for_request_parts(&parts);
1356        assert!(script.contains("--head"));
1357        assert!(!script.contains("-X HEAD"));
1358
1359        let command = cmd_for_request_parts(&parts);
1360        let args: Vec<_> = command
1361            .get_args()
1362            .map(|arg| arg.to_string_lossy().into_owned())
1363            .collect();
1364        assert!(args.iter().any(|arg| arg == "--head"));
1365        assert!(!args.windows(2).any(|args| args == ["-X", "HEAD"]));
1366    }
1367
1368    #[test]
1369    fn test_powershell_compatibility_uses_native_syntax_and_exact_payload() {
1370        let (parts, _) = crate::Request::builder()
1371            .method(Method::POST)
1372            .uri("https://example.com/a'b")
1373            .header("x-test", "a'b")
1374            .body(())
1375            .unwrap()
1376            .into_parts();
1377        let payload = Bytes::from_static(b"\0\xfftail\n");
1378        let powershell = try_cmd_string_for_request_parts_and_payload_with_options(
1379            &parts,
1380            &payload,
1381            CurlExportOptions::default()
1382                .with_script_compatibility(CurlScriptCompatibility::PowerShell),
1383            &CurlScriptPayloadMode::Inline,
1384        )
1385        .unwrap();
1386
1387        assert!(powershell.starts_with(
1388            "$__ramaCurlPayload = [IO.Path]::GetTempFileName(); try { \
1389             [IO.File]::WriteAllBytes($__ramaCurlPayload, \
1390             [Convert]::FromBase64String('AP90YWlsCg==')); \
1391             & (Get-Command curl -CommandType Application).Source 'https://example.com/a''b'"
1392        ));
1393        assert!(powershell.contains(&format!(
1394            " `{}  -H 'x-test: a''b'",
1395            rama_utils::str::NATIVE_NEWLINE
1396        )));
1397        assert!(powershell.contains("--data-binary ('@' + $__ramaCurlPayload)"));
1398        assert!(powershell.ends_with(" } finally { Remove-Item -LiteralPath $__ramaCurlPayload }"));
1399        assert!(!powershell.contains('\u{fffd}'));
1400    }
1401
1402    #[cfg(unix)]
1403    #[test]
1404    fn test_unix_compatibility_round_trips_exact_bytes() {
1405        let (parts, _) = crate::Request::builder()
1406            .method(Method::POST)
1407            .uri("https://example.com/upload")
1408            .body(())
1409            .unwrap()
1410            .into_parts();
1411        let payload = Bytes::from_static(b"\0\xfftail\n");
1412        let curl = try_cmd_string_for_request_parts_and_payload_with_options(
1413            &parts,
1414            &payload,
1415            CurlExportOptions::default().with_script_compatibility(CurlScriptCompatibility::Unix),
1416            &CurlScriptPayloadMode::Inline,
1417        )
1418        .unwrap();
1419        let dir = tempfile::tempdir().unwrap();
1420        let body_path = dir.path().join("body.bin");
1421
1422        // Shadow curl so the opt-in Unix pipeline can be tested without making
1423        // a network request.
1424        let script = format!("curl() {{ cat > \"$CAPTURED_BODY\"; }}\n{curl}");
1425        let output = Command::new("sh")
1426            .arg("-c")
1427            .arg(script)
1428            .env("CAPTURED_BODY", &body_path)
1429            .output()
1430            .unwrap();
1431
1432        assert!(
1433            output.status.success(),
1434            "generated command failed: {}",
1435            String::from_utf8_lossy(&output.stderr),
1436        );
1437        assert_eq!(std::fs::read(body_path).unwrap(), payload);
1438    }
1439
1440    #[test]
1441    fn test_prepared_cmd_streams_arbitrary_payload_without_a_shell() {
1442        let (parts, _) = crate::Request::builder()
1443            .method(Method::GET)
1444            .uri("https://example.com/upload")
1445            .body(())
1446            .unwrap()
1447            .into_parts();
1448        let payload = Bytes::from_static(b"\0\xfftail\n");
1449
1450        let prepared = prepare_cmd_for_request_parts_and_payload(&parts, &payload);
1451        assert_eq!(prepared.command().get_program(), "curl");
1452        assert_eq!(prepared.stdin(), Some(&payload));
1453
1454        let args: Vec<String> = prepared
1455            .command()
1456            .get_args()
1457            .map(|arg| arg.to_string_lossy().into_owned())
1458            .collect();
1459        assert!(
1460            args.windows(2)
1461                .any(|pair| pair[0] == "-X" && pair[1] == "GET"),
1462            "GET must remain explicit for a streamed payload, got {args:?}",
1463        );
1464        assert!(
1465            args.windows(2)
1466                .any(|pair| pair[0] == "--data-binary" && pair[1] == "@-"),
1467            "stdin payload must use curl's byte-preserving input, got {args:?}",
1468        );
1469        assert!(
1470            args.iter().all(|arg| !arg.contains('\u{fffd}')),
1471            "payload bytes must never be converted lossily, got {args:?}",
1472        );
1473    }
1474
1475    #[test]
1476    fn test_cmd_string_for_request_shell_escapes_uri() {
1477        // A single quote is a valid RFC 3986 sub-delim and reaches the URI
1478        // writer verbatim, so it must be shell-escaped like any other value.
1479        let (parts, _) = crate::Request::builder()
1480            .uri("http://example.com/a'b?x=y'z")
1481            .body(())
1482            .unwrap()
1483            .into_parts();
1484
1485        let s = cmd_string_for_request_parts(&&parts);
1486        assert_eq!(
1487            s,
1488            format!(
1489                r##"curl 'http://example.com/a'\''b?x=y'\''z' \{NL}  -X GET \{NL}  --http1.1"##,
1490                NL = rama_utils::str::NATIVE_NEWLINE
1491            ),
1492        );
1493    }
1494
1495    #[test]
1496    fn test_cmd_for_request_passes_unquoted_argv() {
1497        // The `Command` path executes curl directly (no shell), so values must
1498        // be passed as bare argv elements: surrounding shell quotes would become
1499        // literal bytes of the argument and break curl.
1500        let (parts, _) = crate::Request::builder()
1501            .method(Method::POST)
1502            .uri("https://example.com/path?q=a'b")
1503            .header("x-test", "a'b")
1504            .body(())
1505            .unwrap()
1506            .into_parts();
1507
1508        let cmd = cmd_for_request_parts_and_payload(&parts, &Bytes::from_static(b"source='web'"));
1509        let args: Vec<String> = cmd
1510            .get_args()
1511            .map(|a| a.to_string_lossy().into_owned())
1512            .collect();
1513
1514        // URI, header value and --data-raw payload are all unquoted argv elements.
1515        assert!(
1516            args.contains(&"https://example.com/path?q=a'b".to_owned()),
1517            "uri must be a bare argv element, got {args:?}",
1518        );
1519        assert!(
1520            args.contains(&"x-test: a'b".to_owned()),
1521            "header must be a bare argv element, got {args:?}",
1522        );
1523        assert!(
1524            args.contains(&"source='web'".to_owned()),
1525            "payload must be a bare argv element, got {args:?}",
1526        );
1527        // No argv element should be wrapped in literal shell quotes.
1528        assert!(
1529            !args
1530                .iter()
1531                .any(|a| a.starts_with('\'') && a.ends_with('\'')),
1532            "no argv element may be shell-quoted, got {args:?}",
1533        );
1534    }
1535
1536    #[test]
1537    fn test_cmd_for_get_with_payload_preserves_method() {
1538        let (parts, _) = crate::Request::builder()
1539            .method(Method::GET)
1540            .uri("https://example.com/search")
1541            .body(())
1542            .unwrap()
1543            .into_parts();
1544
1545        let cmd = cmd_for_request_parts_and_payload(&parts, &Bytes::from_static(b"query=rama"));
1546        let args: Vec<String> = cmd
1547            .get_args()
1548            .map(|arg| arg.to_string_lossy().into_owned())
1549            .collect();
1550
1551        assert!(
1552            args.windows(2)
1553                .any(|pair| pair == ["-X".to_owned(), "GET".to_owned()]),
1554            "GET must stay explicit when curl receives a payload, got {args:?}",
1555        );
1556    }
1557}