Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support duplicate header keys in session files #313

Merged
merged 15 commits into from
Jun 17, 2023
9 changes: 6 additions & 3 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -321,9 +321,12 @@ fn run(args: Cli) -> Result<i32> {

if let Some(ref mut s) = session {
auth = s.auth()?;
for (key, value) in s.headers()?.iter() {
headers.entry(key).or_insert_with(|| value.clone());
}

headers = {
let mut session_headers = s.headers()?;
session_headers.extend(headers);
session_headers
};
blyxxyz marked this conversation as resolved.
Show resolved Hide resolved
s.save_headers(&headers)?;

let mut cookie_jar = cookie_jar.lock().unwrap();
Expand Down
284 changes: 158 additions & 126 deletions src/session.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
use std::collections::HashMap;
use std::convert::TryFrom;
use std::convert::TryInto;
use std::ffi::OsString;
use std::fs;
use std::io::{self, Write};
Expand All @@ -16,9 +16,16 @@ use crate::utils::{config_dir, test_mode};
#[derive(Debug, Serialize, Deserialize)]
#[serde(untagged)]
enum Meta {
Xh { about: String, xh: String },
Httpie { httpie: String },
Other,
Xh {
about: String,
xh: String,
},
Httpie {
about: String,
help: String,
httpie: String,
},
Other(serde_json::Value),
}

impl Default for Meta {
Expand All @@ -39,7 +46,7 @@ struct Auth {

// Unlike xh, HTTPie serializes path, secure and expires with defaults of "/", false, and null respectively.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct Cookie {
struct Cookie {
value: String,
#[serde(skip_serializing_if = "Option::is_none")]
expires: Option<i64>,
Expand All @@ -49,18 +56,48 @@ pub struct Cookie {
secure: Option<bool>,
}

#[derive(Debug, Serialize, Deserialize)]
struct Header {
name: String,
value: String,
}

#[derive(Debug, Serialize, Deserialize)]
#[serde(untagged)]
enum Headers {
// old headers format kept for backward compatibility
Map(HashMap<String, String>),
// new header format that supports duplicate keys
List(Vec<Header>),
}

impl Default for Headers {
fn default() -> Self {
Headers::List(Vec::new())
}
}

#[derive(Debug, Default, Serialize, Deserialize)]
struct Content {
#[serde(rename = "__meta__")]
meta: Meta,
auth: Auth,
cookies: HashMap<String, Cookie>,
headers: HashMap<String, String>,
headers: Headers,
}

impl Content {
fn migrate(mut self) -> Self {
self.meta = Meta::default();
if let Headers::Map(headers) = self.headers {
self.headers = Headers::List(
headers
.into_iter()
.map(|(key, value)| Header { name: key, value })
.collect(),
);
}

self
}
}
Expand Down Expand Up @@ -98,19 +135,33 @@ impl Session {
}

pub fn headers(&self) -> Result<HeaderMap> {
Ok(HeaderMap::try_from(&self.content.headers)?)
match &self.content.headers {
Headers::Map(_) => unreachable!("headers should have been migrated to Headers::List"),
Headers::List(headers) => headers
.iter()
.map(|Header { name, value }| Ok((name.try_into()?, value.try_into()?)))
.collect(),
}
}

pub fn save_headers(&mut self, request_headers: &HeaderMap) -> Result<()> {
for (key, value) in request_headers.iter() {
pub fn save_headers(&mut self, headers: &HeaderMap) -> Result<()> {
let session_headers = match self.content.headers {
Headers::Map(_) => unreachable!("headers should have been migrated to Headers::List"),
Headers::List(ref mut headers) => headers,
};

session_headers.clear();

for (key, value) in headers.iter() {
let key = key.as_str();
// HTTPie ignores headers that are specific to a particular request e.g content-length
// see https://github.com/httpie/httpie/commit/e09b74021c9c955fd7c3bab11f22801aaf9dc1b8
// we will also ignore cookies as they are taken care of by save_cookies()
if key != "cookie" && !key.starts_with("content-") && !key.starts_with("if-") {
self.content
.headers
.insert(key.into(), value.to_str()?.into());
session_headers.push(Header {
name: key.into(),
value: value.to_str()?.into(),
});
}
}
Ok(())
Expand Down Expand Up @@ -243,122 +294,70 @@ fn path_from_url(url: &Url) -> Result<String> {
#[cfg(test)]
mod tests {
use super::*;
use crate::utils::random_string;
use anyhow::Result;

#[test]
fn can_read_httpie_session_file() -> Result<()> {
let mut path_to_session = std::env::temp_dir();
let file_name = random_string();
path_to_session.push(file_name);
fs::write(
&path_to_session,
indoc::indoc! {r#"
{
"__meta__": {
"about": "HTTPie session file",
"help": "https://httpie.org/doc#sessions",
"httpie": "2.3.0"
},
"auth": {
"password": null,
"type": null,
"username": null
},
"cookies": {
"__cfduid": {
"expires": 1620239688,
"path": "/",
"secure": false,
"value": "d090ada9c629fc7b8bbc6dba3dde1149d1617647688"
}
},
"headers": {
"hello": "world"
}
}
"#},
)?;
use anyhow::Result;
use reqwest::header::HeaderValue;

let session = Session::load_session(
&Url::parse("http://localhost")?,
path_to_session.into(),
false,
)?;
fn load_session_from_str(s: &str) -> Result<Session> {
Ok(Session {
content: serde_json::from_str::<Content>(s)?.migrate(),
path: PathBuf::new(),
read_only: false,
})
}

assert_eq!(
session.content.headers.get("hello"),
Some(&"world".to_string()),
);
#[test]
fn can_parse_old_httpie_session() -> Result<()> {
let session = load_session_from_str(indoc::indoc! {r#"
{
"__meta__": {
"about": "HTTPie session file",
"help": "https://httpie.org/doc#sessions",
"httpie": "2.3.0"
},
"auth": { "password": null, "type": null, "username": null },
"cookies": {
"baz": { "expires": null, "path": "/", "secure": false, "value": "quux" }
},
"headers": { "hello": "world" }
}
"#})?;

assert_eq!(
session.content.auth,
Auth {
auth_type: None,
raw_auth: None
},
session.headers()?.get("hello"),
Some(&HeaderValue::from_static("world")),
);
assert_eq!(session.cookies()[0].name_value(), ("baz", "quux"));
assert_eq!(session.cookies()[0].path(), Some("/"));
assert_eq!(session.cookies()[0].secure(), Some(false));
assert_eq!(session.content.auth, Auth::default());

let expected_cookie = serde_json::from_str::<Cookie>(
r#"
{
"expires": 1620239688,
"path": "/",
"secure": false,
"value": "d090ada9c629fc7b8bbc6dba3dde1149d1617647688"
}
"#,
)?;
assert_eq!(
session.content.cookies.get("__cfduid"),
Some(&expected_cookie)
);
Ok(())
}

#[test]
fn can_read_xh_session_file() -> Result<()> {
let mut path_to_session = std::env::temp_dir();
let file_name = random_string();
path_to_session.push(file_name);
fs::write(
&path_to_session,
indoc::indoc! {r#"
{
"__meta__": {
"about": "xh session file",
"httpie": "0.10.0"
},
"auth": {
"raw_auth": "secret-token",
"type": "bearer"
},
"cookies": {
"__cfduid": {
"expires": 1620239688,
"path": "/",
"secure": false,
"value": "d090ada9c629fc7b8bbc6dba3dde1149d1617647688"
}
},
"headers": {
"hello": "world"
}
}
"#},
)?;

let session = Session::load_session(
&Url::parse("http://localhost")?,
path_to_session.into(),
false,
)?;
fn can_parse_old_xh_session() -> Result<()> {
let session = load_session_from_str(indoc::indoc! {r#"
{
"__meta__": {
"about": "xh session file",
"xh": "0.0.0"
},
"auth": { "raw_auth": "secret-token", "type": "bearer" },
"cookies": {
"baz": { "expires": null, "path": "/", "secure": false, "value": "quux" }
},
"headers": { "hello": "world" }
}
"#})?;

assert_eq!(
session.content.headers.get("hello"),
Some(&"world".to_string()),
session.headers()?.get("hello"),
Some(&HeaderValue::from_static("world")),
);

assert_eq!(session.cookies()[0].name_value(), ("baz", "quux"));
assert_eq!(session.cookies()[0].path(), Some("/"));
assert_eq!(session.cookies()[0].secure(), Some(false));
assert_eq!(
session.content.auth,
Auth {
Expand All @@ -367,20 +366,53 @@ mod tests {
},
);

let expected_cookie = serde_json::from_str::<Cookie>(
r#"
{
"expires": 1620239688,
"path": "/",
"secure": false,
"value": "d090ada9c629fc7b8bbc6dba3dde1149d1617647688"
}
"#,
)?;
Ok(())
}

#[test]
fn can_parse_session_with_unknown_meta() {
load_session_from_str(indoc::indoc! {r#"
{
"__meta__": {},
"auth": { "raw_auth": "secret-token", "type": "bearer" },
"cookies": {
"baz": { "expires": null, "path": "/", "secure": false, "value": "quux" }
},
"headers": { "hello": "world" }
}
"#})
.unwrap();
}

#[test]
fn can_parse_session_with_new_style_headers() -> Result<()> {
let session = load_session_from_str(indoc::indoc! {r#"
{
"__meta__": {
"about": "HTTPie session file",
"help": "https://httpie.io/docs#sessions",
"httpie": "3.0.2"
},
"auth": {},
"cookies": {},
"headers": [
{ "name": "X-Data", "value": "value" },
{ "name": "X-Foo", "value": "bar" },
{ "name": "X-Foo", "value": "baz" }
]
}
"#})?;

let headers = session.headers()?;
assert_eq!(
session.content.cookies.get("__cfduid"),
Some(&expected_cookie)
headers.get("X-Data"),
Some(&HeaderValue::from_static("value"))
);

let mut x_foo_values = headers.get_all("X-Foo").iter();
assert_eq!(x_foo_values.next(), Some(&HeaderValue::from_static("bar")));
assert_eq!(x_foo_values.next(), Some(&HeaderValue::from_static("baz")));

Ok(())
}
}
Loading
Loading