diff --git a/packages/hurl/src/http/client.rs b/packages/hurl/src/http/client.rs index d975c0df52d..07f0b2aeb0e 100644 --- a/packages/hurl/src/http/client.rs +++ b/packages/hurl/src/http/client.rs @@ -31,6 +31,7 @@ use hurl_core::typing::Count; use crate::http::certificate::Certificate; use crate::http::core::*; +use crate::http::curl_cmd::CurlCmd; use crate::http::debug::log_body; use crate::http::header::{ HeaderVec, ACCEPT_ENCODING, AUTHORIZATION, CONTENT_TYPE, EXPECT, LOCATION, USER_AGENT, @@ -762,43 +763,9 @@ impl Client { output: Option<&Output>, options: &ClientOptions, ) -> String { - let mut arguments = vec!["curl".to_string()]; - arguments.append(&mut request_spec.curl_args(context_dir)); - - // We extract the last part of the arguments (the url) to insert it - // after all the options - let url = arguments.pop().unwrap(); - - let cookies = all_cookies(&self.cookie_storage(), request_spec); - if !cookies.is_empty() { - arguments.push("--cookie".to_string()); - arguments.push(format!( - "'{}'", - cookies - .iter() - .map(|c| c.to_string()) - .collect::>() - .join("; ") - )); - } - arguments.append(&mut options.curl_args()); - - // --output is not an option of the HTTP client, we deal with it here: - match output { - Some(Output::File(filename)) => { - let filename = context_dir.resolved_path(filename); - arguments.push("--output".to_string()); - arguments.push(filename.to_string_lossy().to_string()); - } - Some(Output::Stdout) => { - arguments.push("--output".to_string()); - arguments.push("-".to_string()); - } - None => {} - } - - arguments.push(url); - arguments.join(" ") + let cookies = self.cookie_storage(); + let cmd = CurlCmd::new(request_spec, &cookies, context_dir, output, options); + cmd.to_string() } /// Returns the SSL certificates information associated to this call. diff --git a/packages/hurl/src/http/curl_cmd.rs b/packages/hurl/src/http/curl_cmd.rs new file mode 100644 index 00000000000..fccd84ff60f --- /dev/null +++ b/packages/hurl/src/http/curl_cmd.rs @@ -0,0 +1,947 @@ +/* + * Hurl (https://hurl.dev) + * Copyright (C) 2024 Orange + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +use crate::http::client::all_cookies; +use crate::http::{ + Body, ClientOptions, Cookie, FileParam, Header, IpResolve, Method, MultipartParam, Param, + RequestSpec, RequestedHttpVersion, CONTENT_TYPE, +}; +use crate::runner::Output; +use crate::util::path::ContextDir; +use core::fmt; +use hurl_core::typing::Count; +use std::collections::HashMap; +use std::path::Path; + +/// Represents a curl command, with arguments. +pub struct CurlCmd { + /// The args of this command. + args: Vec, +} + +impl fmt::Display for CurlCmd { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + write!(f, "{}", self.args.join(" ")) + } +} + +impl CurlCmd { + /// Creates a new curl command, based on an HTTP request, cookies, a context directory, output + /// and runner options. + pub fn new( + request_spec: &RequestSpec, + cookies: &[Cookie], + context_dir: &ContextDir, + output: Option<&Output>, + options: &ClientOptions, + ) -> Self { + let mut args = vec!["curl".to_string()]; + + let mut params = method_params(request_spec); + args.append(&mut params); + + let mut params = headers_params(request_spec); + args.append(&mut params); + + let mut params = body_params(request_spec, context_dir); + args.append(&mut params); + + let mut params = cookies_params(request_spec, cookies); + args.append(&mut params); + + let mut params = other_options_params(context_dir, output, options); + args.append(&mut params); + + let mut params = url_param(request_spec); + args.append(&mut params); + + CurlCmd { args } + } +} + +/// Returns the curl args corresponding to the HTTP method, from a request spec. +fn method_params(request_spec: &RequestSpec) -> Vec { + let has_body = !request_spec.multipart.is_empty() + || !request_spec.form.is_empty() + || !request_spec.body.bytes().is_empty(); + request_spec.method.curl_args(has_body) +} + +/// Returns the curl args corresponding to the HTTP headers, from a request spec. +fn headers_params(request_spec: &RequestSpec) -> Vec { + let mut args = vec![]; + + for header in request_spec.headers.iter() { + args.append(&mut header.curl_args()); + } + + let has_explicit_content_type = request_spec.headers.contains_key(CONTENT_TYPE); + if has_explicit_content_type { + return args; + } + + if let Some(content_type) = &request_spec.implicit_content_type { + if content_type != "application/x-www-form-urlencoded" + && content_type != "multipart/form-data" + { + args.push("--header".to_string()); + args.push(format!("'{}: {content_type}'", CONTENT_TYPE)); + } + } else if !request_spec.body.bytes().is_empty() { + match request_spec.body { + Body::Text(_) => { + args.push("--header".to_string()); + args.push(format!("'{}:'", CONTENT_TYPE)); + } + Body::Binary(_) => { + args.push("--header".to_string()); + args.push(format!("'{}: application/octet-stream'", CONTENT_TYPE)); + } + Body::File(_, _) => { + args.push("--header".to_string()); + args.push(format!("'{}:'", CONTENT_TYPE)); + } + } + } + args +} + +/// Returns the curl args corresponding to the request body, from a request spec. +fn body_params(request_spec: &RequestSpec, context_dir: &ContextDir) -> Vec { + let mut args = vec![]; + + for param in request_spec.form.iter() { + args.push("--data".to_string()); + args.push(format!("'{}'", param.curl_arg_escape())); + } + for param in request_spec.multipart.iter() { + args.push("--form".to_string()); + args.push(format!("'{}'", param.curl_arg(context_dir))); + } + + if request_spec.body.bytes().is_empty() { + return args; + } + + // See and : + // + // > -d, --data + // > ... + // > If you start the data with the letter @, the rest should be a file name to read the + // > data from, or - if you want curl to read the data from stdin. Posting data from a + // > file named 'foobar' would thus be done with -d, --data @foobar. When -d, --data is + // > told to read from a file like that, carriage returns and newlines will be stripped + // > out. If you do not want the @ character to have a special interpretation use + // > --data-raw instead. + // > ... + // > --data-binary + // > + // > (HTTP) This posts data exactly as specified with no extra processing whatsoever. + // + // In summary: if the payload is a file (@foo.bin), we must use --data-binary option in + // order to curl to not process the data sent. + let param = match request_spec.body { + Body::File(_, _) => "--data-binary", + _ => "--data", + }; + args.push(param.to_string()); + args.push(request_spec.body.curl_arg(context_dir)); + + args +} + +/// Returns the curl args corresponding to a list of cookies. +fn cookies_params(request_spec: &RequestSpec, cookies: &[Cookie]) -> Vec { + let mut args = vec![]; + + let cookies = all_cookies(cookies, request_spec); + if !cookies.is_empty() { + args.push("--cookie".to_string()); + args.push(format!( + "'{}'", + cookies + .iter() + .map(|c| c.to_string()) + .collect::>() + .join("; ") + )); + } + args +} + +/// Returns the curl args corresponding to run options. +fn other_options_params( + context_dir: &ContextDir, + output: Option<&Output>, + options: &ClientOptions, +) -> Vec { + let mut args = options.curl_args(); + + // --output is not an option of the HTTP client, we deal with it here: + match output { + Some(Output::File(filename)) => { + let filename = context_dir.resolved_path(filename); + args.push("--output".to_string()); + args.push(filename.to_string_lossy().to_string()); + } + Some(Output::Stdout) => { + args.push("--output".to_string()); + args.push("-".to_string()); + } + None => {} + } + args +} + +/// Returns the curl args corresponding to the URL, from a request spec. +fn url_param(request_spec: &RequestSpec) -> Vec { + let mut args = vec![]; + + let querystring = if request_spec.querystring.is_empty() { + String::new() + } else { + let params = request_spec + .querystring + .iter() + .map(|p| p.curl_arg_escape()) + .collect::>(); + params.join("&") + }; + let url = if querystring.as_str() == "" { + request_spec.url.raw() + } else if request_spec.url.raw().contains('?') { + format!("{}&{}", request_spec.url.raw(), querystring) + } else { + format!("{}?{}", request_spec.url.raw(), querystring) + }; + let url = format!("'{url}'"); + + // curl support "globbing" + // {,},[,] have special meaning to curl, in order to support templating. + // We have two options: + // - either we encode {,},[,] to %7b,%7d,%5b,%%5d + // - or we let the url "as-it" and use curl [`--globoff`](https://curl.se/docs/manpage.html#-g) option. + // We're going with the second one! + if url.contains('{') || url.contains('}') || url.contains('[') || url.contains(']') { + args.push("--globoff".to_string()); + } + args.push(url); + args +} + +fn encode_byte(b: u8) -> String { + format!("\\x{b:02x}") +} + +/// Encode bytes to a shell string. +fn encode_bytes(bytes: &[u8]) -> String { + bytes.iter().map(|b| encode_byte(*b)).collect() +} + +impl Method { + /// Returns the curl args for HTTP method, given the request has a body or not. + fn curl_args(&self, has_body: bool) -> Vec { + match self.0.as_str() { + "GET" => { + if has_body { + vec!["--request".to_string(), "GET".to_string()] + } else { + vec![] + } + } + "HEAD" => vec!["--head".to_string()], + "POST" => { + if has_body { + vec![] + } else { + vec!["--request".to_string(), "POST".to_string()] + } + } + s => vec!["--request".to_string(), s.to_string()], + } + } +} + +impl Header { + fn curl_args(&self) -> Vec { + let name = &self.name; + let value = &self.value; + vec![ + "--header".to_string(), + encode_shell_string(&format!("{name}: {value}")), + ] + } +} + +impl Param { + fn curl_arg_escape(&self) -> String { + let name = &self.name; + let value = escape_url(&self.value); + format!("{name}={value}") + } + + fn curl_arg(&self) -> String { + let name = &self.name; + let value = &self.value; + format!("{name}={value}") + } +} + +impl MultipartParam { + fn curl_arg(&self, context_dir: &ContextDir) -> String { + match self { + MultipartParam::Param(param) => param.curl_arg(), + MultipartParam::FileParam(FileParam { + name, + filename, + content_type, + .. + }) => { + let path = context_dir.resolved_path(Path::new(filename)); + let value = format!("@{};type={}", path.to_string_lossy(), content_type); + format!("{name}={value}") + } + } + } +} + +impl Body { + fn curl_arg(&self, context_dir: &ContextDir) -> String { + match self { + Body::Text(s) => encode_shell_string(s), + Body::Binary(bytes) => format!("$'{}'", encode_bytes(bytes)), + Body::File(_, filename) => { + let path = context_dir.resolved_path(Path::new(filename)); + format!("'@{}'", path.to_string_lossy()) + } + } + } +} + +impl ClientOptions { + /// Returns the list of options for the curl command line equivalent to this [`ClientOptions`]. + fn curl_args(&self) -> Vec { + let mut arguments = vec![]; + + if let Some(ref aws_sigv4) = self.aws_sigv4 { + arguments.push("--aws-sigv4".to_string()); + arguments.push(aws_sigv4.clone()); + } + if let Some(ref cacert_file) = self.cacert_file { + arguments.push("--cacert".to_string()); + arguments.push(cacert_file.clone()); + } + if let Some(ref client_cert_file) = self.client_cert_file { + arguments.push("--cert".to_string()); + arguments.push(client_cert_file.clone()); + } + if let Some(ref client_key_file) = self.client_key_file { + arguments.push("--key".to_string()); + arguments.push(client_key_file.clone()); + } + if self.compressed { + arguments.push("--compressed".to_string()); + } + if self.connect_timeout != ClientOptions::default().connect_timeout { + arguments.push("--connect-timeout".to_string()); + arguments.push(self.connect_timeout.as_secs().to_string()); + } + for connect in self.connects_to.iter() { + arguments.push("--connect-to".to_string()); + arguments.push(connect.clone()); + } + if let Some(ref cookie_file) = self.cookie_input_file { + arguments.push("--cookie".to_string()); + arguments.push(cookie_file.clone()); + } + match self.http_version { + RequestedHttpVersion::Default => {} + RequestedHttpVersion::Http10 => arguments.push("--http1.0".to_string()), + RequestedHttpVersion::Http11 => arguments.push("--http1.1".to_string()), + RequestedHttpVersion::Http2 => arguments.push("--http2".to_string()), + RequestedHttpVersion::Http3 => arguments.push("--http3".to_string()), + } + if self.insecure { + arguments.push("--insecure".to_string()); + } + match self.ip_resolve { + IpResolve::Default => {} + IpResolve::IpV4 => arguments.push("--ipv4".to_string()), + IpResolve::IpV6 => arguments.push("--ipv6".to_string()), + } + if self.follow_location_trusted { + arguments.push("--location-trusted".to_string()); + } else if self.follow_location { + arguments.push("--location".to_string()); + } + if let Some(max_filesize) = self.max_filesize { + arguments.push("--max-filesize".to_string()); + arguments.push(max_filesize.to_string()); + } + if let Some(max_speed) = self.max_recv_speed { + arguments.push("--limit-rate".to_string()); + arguments.push(max_speed.to_string()); + } + // We don't implement --limit-rate for self.max_send_speed as curl limit-rate seems + // to limit both upload and download speed. There is no distinct option.. + if self.max_redirect != ClientOptions::default().max_redirect { + let max_redirect = match self.max_redirect { + Count::Finite(n) => n as i32, + Count::Infinite => -1, + }; + arguments.push("--max-redirs".to_string()); + arguments.push(max_redirect.to_string()); + } + if let Some(filename) = &self.netrc_file { + arguments.push("--netrc-file".to_string()); + arguments.push(format!("'{filename}'")); + } + if self.netrc_optional { + arguments.push("--netrc-optional".to_string()); + } + if self.netrc { + arguments.push("--netrc".to_string()); + } + if self.path_as_is { + arguments.push("--path-as-is".to_string()); + } + if let Some(ref proxy) = self.proxy { + arguments.push("--proxy".to_string()); + arguments.push(format!("'{proxy}'")); + } + for resolve in self.resolves.iter() { + arguments.push("--resolve".to_string()); + arguments.push(resolve.clone()); + } + if self.timeout != ClientOptions::default().timeout { + arguments.push("--timeout".to_string()); + arguments.push(self.timeout.as_secs().to_string()); + } + if let Some(ref unix_socket) = self.unix_socket { + arguments.push("--unix-socket".to_string()); + arguments.push(format!("'{unix_socket}'")); + } + if let Some(ref user) = self.user { + arguments.push("--user".to_string()); + arguments.push(format!("'{user}'")); + } + if let Some(ref user_agent) = self.user_agent { + arguments.push("--user-agent".to_string()); + arguments.push(format!("'{user_agent}'")); + } + arguments + } +} + +fn escape_url(s: &str) -> String { + percent_encoding::percent_encode(s.as_bytes(), percent_encoding::NON_ALPHANUMERIC).to_string() +} + +fn encode_shell_string(s: &str) -> String { + // $'...' form will be used to encode escaped sequence + if escape_mode(s) { + let escaped = escape_string(s); + format!("$'{escaped}'") + } else { + format!("'{s}'") + } +} + +// the shell string must be in escaped mode ($'...') +// if it contains \n, \t or ' +fn escape_mode(s: &str) -> bool { + for c in s.chars() { + if c == '\n' || c == '\t' || c == '\'' { + return true; + } + } + false +} + +fn escape_string(s: &str) -> String { + let mut escaped_sequences = HashMap::new(); + escaped_sequences.insert('\n', "\\n"); + escaped_sequences.insert('\t', "\\t"); + escaped_sequences.insert('\'', "\\'"); + escaped_sequences.insert('\\', "\\\\"); + + let mut escaped = String::new(); + for c in s.chars() { + match escaped_sequences.get(&c) { + None => escaped.push(c), + Some(escaped_seq) => escaped.push_str(escaped_seq), + } + } + escaped +} + +#[cfg(test)] +mod tests { + use crate::http::{HeaderVec, Url}; + use hurl_core::typing::BytesPerSec; + use std::path::Path; + use std::str::FromStr; + use std::time::Duration; + + use super::*; + + #[test] + fn hello_request_with_default_options() { + let mut request = RequestSpec { + method: Method("GET".to_string()), + url: Url::from_str("http://localhost:8000/hello").unwrap(), + ..Default::default() + }; + + let context_dir = &ContextDir::default(); + let cookies = vec![]; + let options = ClientOptions::default(); + let output = None; + + let cmd = CurlCmd::new(&request, &cookies, &context_dir, output.as_ref(), &options); + assert_eq!(cmd.to_string(), "curl 'http://localhost:8000/hello'"); + + // Same requests with some output: + let output = Some(Output::new("foo.out")); + let cmd = CurlCmd::new(&request, &cookies, &context_dir, output.as_ref(), &options); + assert_eq!( + cmd.to_string(), + "curl \ + --output foo.out \ + 'http://localhost:8000/hello'" + ); + + // With some headers + let mut headers = HeaderVec::new(); + headers.push(Header::new("User-Agent", "iPhone")); + headers.push(Header::new("Foo", "Bar")); + request.headers = headers; + let cmd = CurlCmd::new(&request, &cookies, &context_dir, output.as_ref(), &options); + assert_eq!( + cmd.to_string(), + "curl \ + --header 'User-Agent: iPhone' \ + --header 'Foo: Bar' \ + --output foo.out \ + 'http://localhost:8000/hello'" + ); + + // With some cookies: + let cookies = vec![ + Cookie { + domain: "localhost".to_string(), + include_subdomain: "TRUE".to_string(), + path: "/".to_string(), + https: "FALSE".to_string(), + expires: "0".to_string(), + name: "cookie1".to_string(), + value: "valueA".to_string(), + http_only: false, + }, + Cookie { + domain: "localhost".to_string(), + include_subdomain: "FALSE".to_string(), + path: "/".to_string(), + https: "FALSE".to_string(), + expires: "1".to_string(), + name: "cookie2".to_string(), + value: String::new(), + http_only: true, + }, + ]; + let cmd = CurlCmd::new(&request, &cookies, &context_dir, output.as_ref(), &options); + assert_eq!( + cmd.to_string(), + "curl \ + --header 'User-Agent: iPhone' \ + --header 'Foo: Bar' \ + --cookie 'cookie1=valueA' \ + --output foo.out \ + 'http://localhost:8000/hello'" + ); + } + + #[test] + fn hello_request_with_options() { + let request = RequestSpec { + method: Method("GET".to_string()), + url: Url::from_str("http://localhost:8000/hello").unwrap(), + ..Default::default() + }; + + let context_dir = &ContextDir::default(); + let cookies = vec![]; + let options = ClientOptions { + aws_sigv4: None, + cacert_file: None, + client_cert_file: None, + client_key_file: None, + compressed: true, + connect_timeout: Duration::from_secs(20), + connects_to: vec!["example.com:443:host-47.example.com:443".to_string()], + cookie_input_file: Some("cookie_file".to_string()), + follow_location: true, + follow_location_trusted: false, + http_version: RequestedHttpVersion::Http10, + insecure: true, + ip_resolve: IpResolve::IpV6, + max_filesize: None, + max_recv_speed: Some(BytesPerSec(8000)), + max_redirect: Count::Finite(10), + max_send_speed: Some(BytesPerSec(8000)), + netrc: false, + netrc_file: Some("/var/run/netrc".to_string()), + netrc_optional: true, + path_as_is: true, + proxy: Some("localhost:3128".to_string()), + no_proxy: None, + resolves: vec![ + "foo.com:80:192.168.0.1".to_string(), + "bar.com:443:127.0.0.1".to_string(), + ], + ssl_no_revoke: false, + timeout: Duration::from_secs(10), + unix_socket: Some("/var/run/example.sock".to_string()), + user: Some("user:password".to_string()), + user_agent: Some("my-useragent".to_string()), + verbosity: None, + }; + + let cmd = CurlCmd::new(&request, &cookies, &context_dir, None, &options); + assert_eq!( + cmd.to_string(), + "curl \ + --compressed \ + --connect-timeout 20 \ + --connect-to example.com:443:host-47.example.com:443 \ + --cookie cookie_file \ + --http1.0 \ + --insecure \ + --ipv6 \ + --location \ + --limit-rate 8000 \ + --max-redirs 10 \ + --netrc-file '/var/run/netrc' \ + --netrc-optional \ + --path-as-is \ + --proxy 'localhost:3128' \ + --resolve foo.com:80:192.168.0.1 \ + --resolve bar.com:443:127.0.0.1 \ + --timeout 10 \ + --unix-socket '/var/run/example.sock' \ + --user 'user:password' \ + --user-agent 'my-useragent' \ + 'http://localhost:8000/hello'" + ); + } + + #[test] + fn url_with_dot() { + let request = RequestSpec { + method: Method("GET".to_string()), + url: Url::from_str("https://example.org/hello/../to/../your/../file").unwrap(), + ..Default::default() + }; + + let context_dir = &ContextDir::default(); + let cookies = vec![]; + let options = ClientOptions::default(); + let output = None; + + let cmd = CurlCmd::new(&request, &cookies, &context_dir, output.as_ref(), &options); + assert_eq!( + cmd.to_string(), + "curl 'https://example.org/hello/../to/../your/../file'" + ); + } + + #[test] + fn url_with_curl_glob() { + let request = RequestSpec { + method: Method("GET".to_string()), + url: Url::from_str("http://foo.com?param1=value1¶m2={bar}").unwrap(), + ..Default::default() + }; + + let context_dir = &ContextDir::default(); + let cookies = vec![]; + let options = ClientOptions::default(); + let output = None; + + let cmd = CurlCmd::new(&request, &cookies, &context_dir, output.as_ref(), &options); + assert_eq!( + cmd.to_string(), + "curl \ + --globoff \ + 'http://foo.com?param1=value1¶m2={bar}'" + ); + } + + #[test] + fn query_request() { + let mut request = RequestSpec { + method: Method("GET".to_string()), + url: Url::from_str("http://localhost:8000/querystring-params").unwrap(), + querystring: vec![ + Param { + name: String::from("param1"), + value: String::from("value1"), + }, + Param { + name: String::from("param2"), + value: String::from("a b"), + }, + ], + ..Default::default() + }; + + let context_dir = &ContextDir::default(); + let cookies = vec![]; + let options = ClientOptions::default(); + let output = None; + + let cmd = CurlCmd::new(&request, &cookies, &context_dir, output.as_ref(), &options); + assert_eq!( + cmd.to_string(), + "curl 'http://localhost:8000/querystring-params?param1=value1¶m2=a%20b'", + ); + + // Add som query param in the URL + request.url = + Url::from_str("http://localhost:8000/querystring-params?param3=foo¶m4=bar") + .unwrap(); + let cmd = CurlCmd::new(&request, &cookies, &context_dir, output.as_ref(), &options); + assert_eq!( + cmd.to_string(), + "curl 'http://localhost:8000/querystring-params?param3=foo¶m4=bar¶m1=value1¶m2=a%20b'", + ); + } + + #[test] + fn form_request() { + let mut headers = HeaderVec::new(); + headers.push(Header::new( + "Content-Type", + "application/x-www-form-urlencoded", + )); + + let request = RequestSpec { + method: Method("POST".to_string()), + url: Url::from_str("http://localhost/form-params").unwrap(), + headers, + form: vec![Param::new("param1", "value1"), Param::new("param2", "a b")], + implicit_content_type: Some("multipart/form-data".to_string()), + ..Default::default() + }; + + let context_dir = &ContextDir::default(); + let cookies = vec![]; + let options = ClientOptions::default(); + let output = None; + + let cmd = CurlCmd::new(&request, &cookies, &context_dir, output.as_ref(), &options); + assert_eq!( + cmd.to_string(), + "curl \ + --header 'Content-Type: application/x-www-form-urlencoded' \ + --data 'param1=value1' \ + --data 'param2=a%20b' \ + 'http://localhost/form-params'" + ); + } + + #[test] + fn json_request() { + let mut headers = HeaderVec::new(); + headers.push(Header::new("content-type", "application/vnd.api+json")); + let mut request = RequestSpec { + method: Method("POST".to_string()), + url: Url::from_str("http://localhost/json").unwrap(), + headers, + body: Body::Text("".to_string()), + implicit_content_type: Some("application/json".to_string()), + ..Default::default() + }; + + let context_dir = &ContextDir::default(); + let cookies = vec![]; + let options = ClientOptions::default(); + let output = None; + + let cmd = CurlCmd::new(&request, &cookies, &context_dir, output.as_ref(), &options); + assert_eq!( + cmd.to_string(), + "curl \ + --request POST \ + --header 'content-type: application/vnd.api+json' \ + 'http://localhost/json'" + ); + + // Add a non-empty body + request.body = Body::Text("{\"foo\":\"bar\"}".to_string()); + let cmd = CurlCmd::new(&request, &cookies, &context_dir, output.as_ref(), &options); + assert_eq!( + cmd.to_string(), + "curl \ + --header 'content-type: application/vnd.api+json' \ + --data '{\"foo\":\"bar\"}' \ + 'http://localhost/json'" + ); + + // Change method + request.method = Method("PUT".to_string()); + let cmd = CurlCmd::new(&request, &cookies, &context_dir, output.as_ref(), &options); + assert_eq!( + cmd.to_string(), + "curl \ + --request PUT \ + --header 'content-type: application/vnd.api+json' \ + --data '{\"foo\":\"bar\"}' \ + 'http://localhost/json'" + ); + } + + #[test] + fn post_binary_file() { + let request = RequestSpec { + method: Method("POST".to_string()), + url: Url::from_str("http://localhost:8000/hello").unwrap(), + body: Body::File(b"Hello World!".to_vec(), "foo.bin".to_string()), + ..Default::default() + }; + + let context_dir = &ContextDir::default(); + let cookies = vec![]; + let options = ClientOptions::default(); + let output = None; + + let cmd = CurlCmd::new(&request, &cookies, &context_dir, output.as_ref(), &options); + assert_eq!( + cmd.to_string(), + "curl \ + --header 'Content-Type:' \ + --data-binary '@foo.bin' \ + 'http://localhost:8000/hello'" + ); + } + + #[test] + fn test_encode_byte() { + assert_eq!(encode_byte(1), "\\x01".to_string()); + assert_eq!(encode_byte(32), "\\x20".to_string()); + } + + #[test] + fn header_curl_args() { + assert_eq!( + Header::new("Host", "example.com").curl_args(), + vec!["--header".to_string(), "'Host: example.com'".to_string()] + ); + assert_eq!( + Header::new("If-Match", "\"e0023aa4e\"").curl_args(), + vec![ + "--header".to_string(), + "'If-Match: \"e0023aa4e\"'".to_string() + ] + ); + } + + #[test] + fn param_curl_args() { + assert_eq!( + Param { + name: "param1".to_string(), + value: "value1".to_string(), + } + .curl_arg(), + "param1=value1".to_string() + ); + assert_eq!( + Param { + name: "param2".to_string(), + value: String::new(), + } + .curl_arg(), + "param2=".to_string() + ); + assert_eq!( + Param { + name: "param3".to_string(), + value: "a=b".to_string(), + } + .curl_arg_escape(), + "param3=a%3Db".to_string() + ); + assert_eq!( + Param { + name: "param4".to_string(), + value: "1,2,3".to_string(), + } + .curl_arg_escape(), + "param4=1%2C2%2C3".to_string() + ); + } + + #[test] + fn test_encode_body() { + let current_dir = Path::new("/tmp"); + let file_root = Path::new("/tmp"); + let context_dir = ContextDir::new(current_dir, file_root); + assert_eq!( + Body::Text("hello".to_string()).curl_arg(&context_dir), + "'hello'".to_string() + ); + + if cfg!(unix) { + assert_eq!( + Body::File(vec![], "filename".to_string()).curl_arg(&context_dir), + "'@/tmp/filename'".to_string() + ); + } + + assert_eq!( + Body::Binary(vec![1, 2, 3]).curl_arg(&context_dir), + "$'\\x01\\x02\\x03'".to_string() + ); + } + + #[test] + fn test_encode_shell_string() { + assert_eq!(encode_shell_string("hello"), "'hello'"); + assert_eq!(encode_shell_string("\\n"), "'\\n'"); + assert_eq!(encode_shell_string("'"), "$'\\''"); + assert_eq!(encode_shell_string("\\'"), "$'\\\\\\''"); + assert_eq!(encode_shell_string("\n"), "$'\\n'"); + } + + #[test] + fn test_escape_string() { + assert_eq!(escape_string("hello"), "hello"); + assert_eq!(escape_string("\\n"), "\\\\n"); + assert_eq!(escape_string("'"), "\\'"); + assert_eq!(escape_string("\\'"), "\\\\\\'"); + assert_eq!(escape_string("\n"), "\\n"); + } + + #[test] + fn test_escape_mode() { + assert!(!escape_mode("hello")); + assert!(!escape_mode("\\")); + assert!(escape_mode("'")); + assert!(escape_mode("\n")); + } +} diff --git a/packages/hurl/src/http/mod.rs b/packages/hurl/src/http/mod.rs index a72d7f51781..c1730b40174 100644 --- a/packages/hurl/src/http/mod.rs +++ b/packages/hurl/src/http/mod.rs @@ -44,6 +44,7 @@ mod certificate; mod client; mod cookie; mod core; +mod curl_cmd; mod debug; mod easy_ext; mod error; @@ -53,7 +54,6 @@ mod mimetype; mod options; mod request; mod request_spec; -mod request_spec_curl_args; mod response; mod response_cookie; mod response_debug; diff --git a/packages/hurl/src/http/options.rs b/packages/hurl/src/http/options.rs index ba881912547..648c269a5cb 100644 --- a/packages/hurl/src/http/options.rs +++ b/packages/hurl/src/http/options.rs @@ -98,203 +98,3 @@ impl Default for ClientOptions { } } } - -impl ClientOptions { - /// Returns the list of options for the curl command line equivalent to this [`ClientOptions`]. - pub fn curl_args(&self) -> Vec { - let mut arguments = vec![]; - - if let Some(ref aws_sigv4) = self.aws_sigv4 { - arguments.push("--aws-sigv4".to_string()); - arguments.push(aws_sigv4.clone()); - } - if let Some(ref cacert_file) = self.cacert_file { - arguments.push("--cacert".to_string()); - arguments.push(cacert_file.clone()); - } - if let Some(ref client_cert_file) = self.client_cert_file { - arguments.push("--cert".to_string()); - arguments.push(client_cert_file.clone()); - } - if let Some(ref client_key_file) = self.client_key_file { - arguments.push("--key".to_string()); - arguments.push(client_key_file.clone()); - } - if self.compressed { - arguments.push("--compressed".to_string()); - } - if self.connect_timeout != ClientOptions::default().connect_timeout { - arguments.push("--connect-timeout".to_string()); - arguments.push(self.connect_timeout.as_secs().to_string()); - } - for connect in self.connects_to.iter() { - arguments.push("--connect-to".to_string()); - arguments.push(connect.clone()); - } - if let Some(ref cookie_file) = self.cookie_input_file { - arguments.push("--cookie".to_string()); - arguments.push(cookie_file.clone()); - } - match self.http_version { - RequestedHttpVersion::Default => {} - RequestedHttpVersion::Http10 => arguments.push("--http1.0".to_string()), - RequestedHttpVersion::Http11 => arguments.push("--http1.1".to_string()), - RequestedHttpVersion::Http2 => arguments.push("--http2".to_string()), - RequestedHttpVersion::Http3 => arguments.push("--http3".to_string()), - } - if self.insecure { - arguments.push("--insecure".to_string()); - } - match self.ip_resolve { - IpResolve::Default => {} - IpResolve::IpV4 => arguments.push("--ipv4".to_string()), - IpResolve::IpV6 => arguments.push("--ipv6".to_string()), - } - if self.follow_location_trusted { - arguments.push("--location-trusted".to_string()); - } else if self.follow_location { - arguments.push("--location".to_string()); - } - if let Some(max_filesize) = self.max_filesize { - arguments.push("--max-filesize".to_string()); - arguments.push(max_filesize.to_string()); - } - if let Some(max_speed) = self.max_recv_speed { - arguments.push("--limit-rate".to_string()); - arguments.push(max_speed.to_string()); - } - // We don't implement --limit-rate for self.max_send_speed as curl limit-rate seems - // to limit both upload and download speed. There is no distinct option.. - if self.max_redirect != ClientOptions::default().max_redirect { - let max_redirect = match self.max_redirect { - Count::Finite(n) => n as i32, - Count::Infinite => -1, - }; - arguments.push("--max-redirs".to_string()); - arguments.push(max_redirect.to_string()); - } - if let Some(filename) = &self.netrc_file { - arguments.push("--netrc-file".to_string()); - arguments.push(format!("'{filename}'")); - } - if self.netrc_optional { - arguments.push("--netrc-optional".to_string()); - } - if self.netrc { - arguments.push("--netrc".to_string()); - } - if self.path_as_is { - arguments.push("--path-as-is".to_string()); - } - if let Some(ref proxy) = self.proxy { - arguments.push("--proxy".to_string()); - arguments.push(format!("'{proxy}'")); - } - for resolve in self.resolves.iter() { - arguments.push("--resolve".to_string()); - arguments.push(resolve.clone()); - } - if self.timeout != ClientOptions::default().timeout { - arguments.push("--timeout".to_string()); - arguments.push(self.timeout.as_secs().to_string()); - } - if let Some(ref unix_socket) = self.unix_socket { - arguments.push("--unix-socket".to_string()); - arguments.push(format!("'{unix_socket}'")); - } - if let Some(ref user) = self.user { - arguments.push("--user".to_string()); - arguments.push(format!("'{user}'")); - } - if let Some(ref user_agent) = self.user_agent { - arguments.push("--user-agent".to_string()); - arguments.push(format!("'{user_agent}'")); - } - arguments - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_curl_args() { - assert!(ClientOptions::default().curl_args().is_empty()); - - assert_eq!( - ClientOptions { - aws_sigv4: None, - cacert_file: None, - client_cert_file: None, - client_key_file: None, - compressed: true, - connect_timeout: Duration::from_secs(20), - connects_to: vec!["example.com:443:host-47.example.com:443".to_string()], - cookie_input_file: Some("cookie_file".to_string()), - follow_location: true, - follow_location_trusted: false, - http_version: RequestedHttpVersion::Http10, - insecure: true, - ip_resolve: IpResolve::IpV6, - max_filesize: None, - max_recv_speed: Some(BytesPerSec(8000)), - max_redirect: Count::Finite(10), - max_send_speed: Some(BytesPerSec(8000)), - netrc: false, - netrc_file: Some("/var/run/netrc".to_string()), - netrc_optional: true, - path_as_is: true, - proxy: Some("localhost:3128".to_string()), - no_proxy: None, - resolves: vec![ - "foo.com:80:192.168.0.1".to_string(), - "bar.com:443:127.0.0.1".to_string(), - ], - ssl_no_revoke: false, - timeout: Duration::from_secs(10), - unix_socket: Some("/var/run/example.sock".to_string()), - user: Some("user:password".to_string()), - user_agent: Some("my-useragent".to_string()), - verbosity: None, - } - .curl_args(), - [ - "--compressed", - "--connect-timeout", - "20", - "--connect-to", - "example.com:443:host-47.example.com:443", - "--cookie", - "cookie_file", - "--http1.0", - "--insecure", - "--ipv6", - "--location", - "--limit-rate", - "8000", - "--max-redirs", - "10", - "--netrc-file", - "'/var/run/netrc'", - "--netrc-optional", - "--path-as-is", - "--proxy", - "'localhost:3128'", - "--resolve", - "foo.com:80:192.168.0.1", - "--resolve", - "bar.com:443:127.0.0.1", - "--timeout", - "10", - "--unix-socket", - "'/var/run/example.sock'", - "--user", - "'user:password'", - "--user-agent", - "'my-useragent'", - ] - .map(|a| a.to_string()) - ); - } -} diff --git a/packages/hurl/src/http/request_spec_curl_args.rs b/packages/hurl/src/http/request_spec_curl_args.rs deleted file mode 100644 index a2661e94fb0..00000000000 --- a/packages/hurl/src/http/request_spec_curl_args.rs +++ /dev/null @@ -1,524 +0,0 @@ -/* - * Hurl (https://hurl.dev) - * Copyright (C) 2024 Orange - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - * - */ -use std::collections::HashMap; -use std::path::Path; - -use crate::http::core::*; -use crate::http::*; -use crate::util::path::ContextDir; - -impl RequestSpec { - /// Returns this request as curl arguments. - /// It does not contain the requests cookies (they will be accessed from the client) - pub fn curl_args(&self, context_dir: &ContextDir) -> Vec { - let mut arguments = vec![]; - - let data = - !self.multipart.is_empty() || !self.form.is_empty() || !self.body.bytes().is_empty(); - arguments.append(&mut self.method.curl_args(data)); - - for header in self.headers.iter() { - arguments.append(&mut header.curl_args()); - } - - let has_explicit_content_type = self.headers.contains_key(CONTENT_TYPE); - if !has_explicit_content_type { - if let Some(content_type) = &self.implicit_content_type { - if content_type != "application/x-www-form-urlencoded" - && content_type != "multipart/form-data" - { - arguments.push("--header".to_string()); - arguments.push(format!("'{}: {content_type}'", CONTENT_TYPE)); - } - } else if !self.body.bytes().is_empty() { - match self.body { - Body::Text(_) => { - arguments.push("--header".to_string()); - arguments.push(format!("'{}:'", CONTENT_TYPE)); - } - Body::Binary(_) => { - arguments.push("--header".to_string()); - arguments.push(format!("'{}: application/octet-stream'", CONTENT_TYPE)); - } - Body::File(_, _) => { - arguments.push("--header".to_string()); - arguments.push(format!("'{}:'", CONTENT_TYPE)); - } - } - } - } - - for param in self.form.iter() { - arguments.push("--data".to_string()); - arguments.push(format!("'{}'", param.curl_arg_escape())); - } - for param in self.multipart.iter() { - arguments.push("--form".to_string()); - arguments.push(format!("'{}'", param.curl_arg(context_dir))); - } - - if !self.body.bytes().is_empty() { - // See and : - // - // > -d, --data - // > ... - // > If you start the data with the letter @, the rest should be a file name to read the - // > data from, or - if you want curl to read the data from stdin. Posting data from a - // > file named 'foobar' would thus be done with -d, --data @foobar. When -d, --data is - // > told to read from a file like that, carriage returns and newlines will be stripped - // > out. If you do not want the @ character to have a special interpretation use - // > --data-raw instead. - // > ... - // > --data-binary - // > - // > (HTTP) This posts data exactly as specified with no extra processing whatsoever. - // - // In summary: if the payload is a file (@foo.bin), we must use --data-binary option in - // order to curl to not process the data sent. - let param = match self.body { - Body::File(_, _) => "--data-binary", - _ => "--data", - }; - arguments.push(param.to_string()); - arguments.push(self.body.curl_arg(context_dir)); - } - - let querystring = if self.querystring.is_empty() { - String::new() - } else { - let params = self - .querystring - .iter() - .map(|p| p.curl_arg_escape()) - .collect::>(); - params.join("&") - }; - let url = if querystring.as_str() == "" { - self.url.raw() - } else if self.url.raw().contains('?') { - format!("{}&{}", self.url.raw(), querystring) - } else { - format!("{}?{}", self.url.raw(), querystring) - }; - arguments.push(format!("'{url}'")); - - arguments - } -} - -fn encode_byte(b: u8) -> String { - format!("\\x{b:02x}") -} - -fn encode_bytes(bytes: &[u8]) -> String { - bytes.iter().map(|b| encode_byte(*b)).collect() -} - -impl Method { - pub fn curl_args(&self, data: bool) -> Vec { - match self.0.as_str() { - "GET" => { - if data { - vec!["--request".to_string(), "GET".to_string()] - } else { - vec![] - } - } - "HEAD" => vec!["--head".to_string()], - "POST" => { - if data { - vec![] - } else { - vec!["--request".to_string(), "POST".to_string()] - } - } - s => vec!["--request".to_string(), s.to_string()], - } - } -} - -impl Header { - pub fn curl_args(&self) -> Vec { - let name = &self.name; - let value = &self.value; - vec![ - "--header".to_string(), - encode_shell_string(&format!("{name}: {value}")), - ] - } -} - -impl Param { - pub fn curl_arg_escape(&self) -> String { - let name = &self.name; - let value = escape_url(&self.value); - format!("{name}={value}") - } - - pub fn curl_arg(&self) -> String { - let name = &self.name; - let value = &self.value; - format!("{name}={value}") - } -} - -impl MultipartParam { - pub fn curl_arg(&self, context_dir: &ContextDir) -> String { - match self { - MultipartParam::Param(param) => param.curl_arg(), - MultipartParam::FileParam(FileParam { - name, - filename, - content_type, - .. - }) => { - let path = context_dir.resolved_path(Path::new(filename)); - let value = format!("@{};type={}", path.to_string_lossy(), content_type); - format!("{name}={value}") - } - } - } -} - -impl Body { - pub fn curl_arg(&self, context_dir: &ContextDir) -> String { - match self { - Body::Text(s) => encode_shell_string(s), - Body::Binary(bytes) => format!("$'{}'", encode_bytes(bytes)), - Body::File(_, filename) => { - let path = context_dir.resolved_path(Path::new(filename)); - format!("'@{}'", path.to_string_lossy()) - } - } - } -} - -fn escape_url(s: &str) -> String { - percent_encoding::percent_encode(s.as_bytes(), percent_encoding::NON_ALPHANUMERIC).to_string() -} - -fn encode_shell_string(s: &str) -> String { - // $'...' form will be used to encode escaped sequence - if escape_mode(s) { - let escaped = escape_string(s); - format!("$'{escaped}'") - } else { - format!("'{s}'") - } -} - -// the shell string must be in escaped mode ($'...') -// if it contains \n, \t or ' -fn escape_mode(s: &str) -> bool { - for c in s.chars() { - if c == '\n' || c == '\t' || c == '\'' { - return true; - } - } - false -} - -fn escape_string(s: &str) -> String { - let mut escaped_sequences = HashMap::new(); - escaped_sequences.insert('\n', "\\n"); - escaped_sequences.insert('\t', "\\t"); - escaped_sequences.insert('\'', "\\'"); - escaped_sequences.insert('\\', "\\\\"); - - let mut escaped = String::new(); - for c in s.chars() { - match escaped_sequences.get(&c) { - None => escaped.push(c), - Some(escaped_seq) => escaped.push_str(escaped_seq), - } - } - escaped -} - -#[cfg(test)] -pub mod tests { - use std::path::Path; - use std::str::FromStr; - - use super::*; - - fn form_http_request() -> RequestSpec { - let mut headers = HeaderVec::new(); - headers.push(Header::new( - "Content-Type", - "application/x-www-form-urlencoded", - )); - - RequestSpec { - method: Method("POST".to_string()), - url: Url::from_str("http://localhost/form-params").unwrap(), - headers, - form: vec![ - Param { - name: String::from("param1"), - value: String::from("value1"), - }, - Param { - name: String::from("param2"), - value: String::from("a b"), - }, - ], - implicit_content_type: Some("multipart/form-data".to_string()), - ..Default::default() - } - } - - fn json_request() -> RequestSpec { - let mut headers = HeaderVec::new(); - headers.push(Header::new("content-type", "application/vnd.api+json")); - RequestSpec { - method: Method("POST".to_string()), - url: Url::from_str("http://localhost/json").unwrap(), - headers, - body: Body::Text("{\"foo\":\"bar\"}".to_string()), - implicit_content_type: Some("application/json".to_string()), - ..Default::default() - } - } - - #[test] - fn test_encode_byte() { - assert_eq!(encode_byte(1), "\\x01".to_string()); - assert_eq!(encode_byte(32), "\\x20".to_string()); - } - - #[test] - fn method_curl_args() { - assert!(Method("GET".to_string()).curl_args(false).is_empty()); - assert_eq!( - Method("GET".to_string()).curl_args(true), - vec!["--request".to_string(), "GET".to_string()] - ); - - assert_eq!( - Method("POST".to_string()).curl_args(false), - vec!["--request".to_string(), "POST".to_string()] - ); - assert!(Method("POST".to_string()).curl_args(true).is_empty()); - - assert_eq!( - Method("PUT".to_string()).curl_args(false), - vec!["--request".to_string(), "PUT".to_string()] - ); - assert_eq!( - Method("PUT".to_string()).curl_args(true), - vec!["--request".to_string(), "PUT".to_string()] - ); - } - - #[test] - fn header_curl_args() { - assert_eq!( - Header::new("Host", "example.com").curl_args(), - vec!["--header".to_string(), "'Host: example.com'".to_string()] - ); - assert_eq!( - Header::new("If-Match", "\"e0023aa4e\"").curl_args(), - vec![ - "--header".to_string(), - "'If-Match: \"e0023aa4e\"'".to_string() - ] - ); - } - - #[test] - fn param_curl_args() { - assert_eq!( - Param { - name: "param1".to_string(), - value: "value1".to_string(), - } - .curl_arg(), - "param1=value1".to_string() - ); - assert_eq!( - Param { - name: "param2".to_string(), - value: String::new(), - } - .curl_arg(), - "param2=".to_string() - ); - assert_eq!( - Param { - name: "param3".to_string(), - value: "a=b".to_string(), - } - .curl_arg_escape(), - "param3=a%3Db".to_string() - ); - assert_eq!( - Param { - name: "param4".to_string(), - value: "1,2,3".to_string(), - } - .curl_arg_escape(), - "param4=1%2C2%2C3".to_string() - ); - } - - #[test] - fn requests_curl_args() { - let context_dir = &ContextDir::default(); - assert_eq!( - hello_http_request().curl_args(context_dir), - vec!["'http://localhost:8000/hello'".to_string()] - ); - assert_eq!( - custom_http_request().curl_args(context_dir), - vec![ - "--header".to_string(), - "'User-Agent: iPhone'".to_string(), - "--header".to_string(), - "'Foo: Bar'".to_string(), - "'http://localhost/custom'".to_string(), - ] - ); - assert_eq!( - query_http_request().curl_args(context_dir), - vec![ - "'http://localhost:8000/querystring-params?param1=value1¶m2=a%20b'".to_string() - ] - ); - assert_eq!( - form_http_request().curl_args(context_dir), - vec![ - "--header".to_string(), - "'Content-Type: application/x-www-form-urlencoded'".to_string(), - "--data".to_string(), - "'param1=value1'".to_string(), - "--data".to_string(), - "'param2=a%20b'".to_string(), - "'http://localhost/form-params'".to_string(), - ] - ); - assert_eq!( - json_request().curl_args(context_dir), - vec![ - "--header".to_string(), - "'content-type: application/vnd.api+json'".to_string(), - "--data".to_string(), - "'{\"foo\":\"bar\"}'".to_string(), - "'http://localhost/json'".to_string(), - ] - ); - - assert_eq!( - RequestSpec { - method: Method("GET".to_string()), - url: Url::from_str("http://localhost:8000/").unwrap(), - ..Default::default() - } - .curl_args(context_dir), - vec!["'http://localhost:8000/'".to_string(),] - ); - } - - #[test] - fn post_data_curl_args() { - let context_dir = &ContextDir::default(); - let req = RequestSpec { - method: Method("POST".to_string()), - url: Url::from_str("http://localhost:8000/hello").unwrap(), - body: Body::Text("foo".to_string()), - ..Default::default() - }; - assert_eq!( - req.curl_args(context_dir), - vec![ - "--header", - "'Content-Type:'", - "--data", - "'foo'", - "'http://localhost:8000/hello'" - ] - ); - - let context_dir = &ContextDir::default(); - let req = RequestSpec { - method: Method("POST".to_string()), - url: Url::from_str("http://localhost:8000/hello").unwrap(), - body: Body::File(b"Hello World!".to_vec(), "foo.bin".to_string()), - ..Default::default() - }; - assert_eq!( - req.curl_args(context_dir), - vec![ - "--header", - "'Content-Type:'", - "--data-binary", - "'@foo.bin'", - "'http://localhost:8000/hello'" - ] - ); - } - - #[test] - fn test_encode_body() { - let current_dir = Path::new("/tmp"); - let file_root = Path::new("/tmp"); - let context_dir = ContextDir::new(current_dir, file_root); - assert_eq!( - Body::Text("hello".to_string()).curl_arg(&context_dir), - "'hello'".to_string() - ); - - if cfg!(unix) { - assert_eq!( - Body::File(vec![], "filename".to_string()).curl_arg(&context_dir), - "'@/tmp/filename'".to_string() - ); - } - - assert_eq!( - Body::Binary(vec![1, 2, 3]).curl_arg(&context_dir), - "$'\\x01\\x02\\x03'".to_string() - ); - } - - #[test] - fn test_encode_shell_string() { - assert_eq!(encode_shell_string("hello"), "'hello'"); - assert_eq!(encode_shell_string("\\n"), "'\\n'"); - assert_eq!(encode_shell_string("'"), "$'\\''"); - assert_eq!(encode_shell_string("\\'"), "$'\\\\\\''"); - assert_eq!(encode_shell_string("\n"), "$'\\n'"); - } - - #[test] - fn test_escape_string() { - assert_eq!(escape_string("hello"), "hello"); - assert_eq!(escape_string("\\n"), "\\\\n"); - assert_eq!(escape_string("'"), "\\'"); - assert_eq!(escape_string("\\'"), "\\\\\\'"); - assert_eq!(escape_string("\n"), "\\n"); - } - - #[test] - fn test_escape_mode() { - assert!(!escape_mode("hello")); - assert!(!escape_mode("\\")); - assert!(escape_mode("'")); - assert!(escape_mode("\n")); - } -}