1use 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#[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 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 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 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 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 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 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 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
131#[non_exhaustive]
132pub enum CurlScriptCompatibility {
133 #[default]
143 CrossPlatform,
144 Unix,
146 PowerShell,
148}
149
150#[derive(Debug, Clone, PartialEq, Eq, Default)]
152#[non_exhaustive]
153pub enum CurlScriptPayloadMode {
154 #[default]
160 Inline,
161 Stdin,
163 File(String),
165}
166
167impl CurlScriptPayloadMode {
168 pub fn file(path: impl Into<String>) -> Self {
170 Self::File(path.into())
171 }
172}
173
174pub 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
181pub 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
194pub 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
213pub 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 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
290pub 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
297pub 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
307pub 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
318pub 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#[must_use]
334pub struct PreparedCurlCommand {
335 command: Command,
336 stdin: Option<Bytes>,
337}
338
339impl PreparedCurlCommand {
340 pub fn command(&self) -> &Command {
342 &self.command
343 }
344
345 pub fn command_mut(&mut self) -> &mut Command {
347 &mut self.command
348 }
349
350 pub fn stdin(&self) -> Option<&Bytes> {
352 self.stdin.as_ref()
353 }
354
355 pub fn into_parts(self) -> (Command, Option<Bytes>) {
357 (self.command, self.stdin)
358 }
359}
360
361pub 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
376pub 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#[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 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 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 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 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 writer.write_single("-4");
680 }
681 (Some(DnsResolveIpMode::SingleIpV6), _)
682 | (
683 None | Some(DnsResolveIpMode::DualPreferIpV4 | DnsResolveIpMode::Dual),
684 Some(ConnectIpMode::Ipv6),
685 ) => {
686 writer.write_single("-6");
688 }
689 _ => (), }
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 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 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 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 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 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 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 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}