use std::path::PathBuf;
use std::process::ExitCode;
use std::time::{Duration, Instant};
use anyhow::{Context, Result};
use bytes::Bytes;
use clap::Parser;
use eggfetch_core::cookie::CookieJar;
use eggfetch_core::{
AuthScheme, Client, HttpVersionPolicy, Method, Multipart, NoProxy, Proxy, RedirectPolicy,
RetryPolicy, Timeout, TlsConfig,
};
use futures_util::StreamExt;
use serde_json::{json, Value};
use std::io::IsTerminal;
use tokio::io::AsyncWriteExt;
#[allow(clippy::too_many_lines, clippy::cast_precision_loss)]
async fn run(cli: Cli) -> Result<()> {
if let Some(shell) = cli.generate_completion {
let mut cmd = <Cli as clap::CommandFactory>::command();
let bin_name = "eggfetch";
let writer = std::io::stdout();
match shell {
Shell::Bash => clap_complete::generate(
clap_complete::shells::Bash,
&mut cmd,
bin_name,
&mut writer.lock(),
),
Shell::Zsh => clap_complete::generate(
clap_complete::shells::Zsh,
&mut cmd,
bin_name,
&mut writer.lock(),
),
Shell::Fish => clap_complete::generate(
clap_complete::shells::Fish,
&mut cmd,
bin_name,
&mut writer.lock(),
),
Shell::PowerShell => clap_complete::generate(
clap_complete::shells::PowerShell,
&mut cmd,
bin_name,
&mut writer.lock(),
),
Shell::Elvish => clap_complete::generate(
clap_complete::shells::Elvish,
&mut cmd,
bin_name,
&mut writer.lock(),
),
}
return Ok(());
}
let method_str = detect_method(&cli);
let method = Method::from_bytes(method_str.as_bytes())
.with_context(|| format!("invalid HTTP method: {method_str}"))?;
let follow = cli.follow && !cli.no_follow;
let mut redirect_policy = RedirectPolicy::new(follow, cli.max_redirects);
if cli.no_downgrade {
redirect_policy =
redirect_policy.with_downgrade(eggfetch_core::RedirectDowngradePolicy::Deny);
}
let mut client_builder = Client::builder().redirect_policy(redirect_policy);
if let Some(ref auth_str) = cli.auth {
let (user, pass) = auth_str
.split_once(':')
.context("auth must be in USER:PASS format")?;
let scheme = AuthScheme::basic(user, pass)?;
client_builder = client_builder.auth(scheme);
}
if let Some(ref token) = cli.bearer {
let scheme = AuthScheme::bearer(token)?;
client_builder = client_builder.auth(scheme);
}
if !cli.cookie.is_empty() {
let jar = CookieJar::new();
for c in &cli.cookie {
let (name, value) = parse_query(c)?;
jar.set_default_cookie(name.to_owned(), value.to_owned())
.map_err(|e| anyhow::anyhow!("invalid --cookie '{c}': {e}"))?;
}
client_builder = client_builder.cookie_jar(jar);
}
if let Some(ref path) = cli.cookie_jar {
let content = tokio::fs::read_to_string(path)
.await
.with_context(|| format!("failed to read cookie jar: {}", path.display()))?;
let jar = CookieJar::new();
for line in content.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
if let Some((name, value)) = line.split_once('=') {
if let Err(e) =
jar.set_default_cookie(name.trim().to_owned(), value.trim().to_owned())
{
let name: String = name.trim().chars().take(64).collect();
eprintln!("Warning: skipping invalid cookie jar line for cookie '{name}': {e}");
}
} else if !line.is_empty() {
eprintln!(
"Warning: skipping unrecognized cookie jar line ({} bytes)",
line.len()
);
}
}
client_builder = client_builder.cookie_jar(jar);
}
if cli.proxy.is_none() && (cli.proxy_auth.is_some() || cli.no_proxy.is_some()) {
anyhow::bail!("--proxy-auth and --no-proxy require --proxy");
}
if let Some(ref proxy_url) = cli.proxy {
let mut proxy = Proxy::all(proxy_url)?;
if let Some(ref proxy_auth_str) = cli.proxy_auth {
let (user, pass) = proxy_auth_str
.split_once(':')
.context("proxy-auth must be in USER:PASS format")?;
proxy = proxy.auth(eggfetch_core::ProxyAuth::basic(user, pass)?);
}
if let Some(ref no_proxy_str) = cli.no_proxy {
let bypass = NoProxy::parse(no_proxy_str)?;
proxy = proxy.no_proxy(bypass);
}
client_builder = client_builder.proxy(proxy);
}
let mut tls_builder = TlsConfig::builder();
if cli.no_verify {
tls_builder = tls_builder.danger_accept_invalid_certs(true);
}
if let Some(ref ca_path) = cli.cacert {
tls_builder = tls_builder.ca_certificate_path(ca_path)?;
}
if let (Some(ref cert_path), Some(ref key_path)) = (&cli.cert, &cli.key) {
tls_builder = tls_builder.client_cert_path(cert_path, key_path)?;
}
client_builder = client_builder.tls_config(tls_builder.build());
let protocol_count = [cli.http1, cli.http2, cli.http3]
.into_iter()
.filter(|enabled| *enabled)
.count();
if protocol_count > 1 {
anyhow::bail!("--http1, --http2, and --http3 are mutually exclusive");
}
if cli.cert.is_some() != cli.key.is_some() {
anyhow::bail!("--cert and --key must be provided together for mTLS");
}
let http_version_policy = if cli.http2 {
HttpVersionPolicy::Http2Only
} else if cli.http3 {
HttpVersionPolicy::Http3Only
} else if cli.http1 {
HttpVersionPolicy::Http1Only
} else {
HttpVersionPolicy::Auto { allow_http3: false }
};
client_builder = client_builder.http_version_policy(http_version_policy);
if let Some(max_size) = cli.max_body_size {
client_builder = client_builder.max_decoded_body_size(max_size);
}
if let Some(ratio) = cli.max_decompression_ratio {
client_builder = client_builder.max_decompression_ratio(ratio);
}
if let Some(attempts) = cli.retry {
let mut retry_builder = RetryPolicy::builder().max_attempts(attempts);
if let Some(delay) = cli.retry_delay {
retry_builder = retry_builder
.initial_delay(Duration::from_secs(delay))
.backoff_factor(1.0);
}
client_builder = client_builder.retry(retry_builder.build());
}
let client = client_builder.build();
let mut req_builder = client.request(method, &cli.url)?;
for h in &cli.header {
let (name, value) = parse_header(h)?;
req_builder = req_builder.header(name, value);
}
for q in &cli.query {
let (key, value) = parse_query(q)?;
req_builder = req_builder.query(key, value);
}
let mut request_timeout = cli.timeout.map(Timeout::from_secs).unwrap_or_default();
if let Some(t) = cli.connect_timeout {
request_timeout.connect = Some(Duration::from_secs(t));
}
if let Some(t) = cli.total_timeout {
request_timeout.total = Some(Duration::from_secs(t));
}
if let Some(t) = cli.read_timeout {
request_timeout.read = Some(Duration::from_secs(t));
}
if cli.timeout.is_some()
|| cli.connect_timeout.is_some()
|| cli.total_timeout.is_some()
|| cli.read_timeout.is_some()
{
req_builder = req_builder.timeout(request_timeout);
}
let has_form = !cli.form.is_empty();
let has_files = !cli.file.is_empty();
let has_body = cli.body.is_some();
let has_body_file = cli.body_file.is_some();
let has_json = cli.json.is_some();
let body_count = [has_form, has_files, has_body, has_body_file, has_json]
.iter()
.filter(|&&x| x)
.count();
if body_count > 1 && !(has_form && has_files && body_count == 2) {
anyhow::bail!(
"--body/--body-file/--json are mutually exclusive with --form/--file; --form and --file may be combined for multipart"
);
}
if has_json {
let Some(json_str) = cli.json.as_deref() else {
anyhow::bail!("--json body is missing");
};
let _value: Value = serde_json::from_str(json_str).context("invalid JSON body string")?;
req_builder = req_builder
.header("content-type", "application/json")
.body(json_str);
} else if has_body {
let Some(body) = cli.body else {
anyhow::bail!("--body is missing");
};
req_builder = req_builder.body(body);
} else if has_body_file {
let Some(body_file) = cli.body_file.as_deref() else {
anyhow::bail!("--body-file is missing");
};
let body_bytes = read_body_file(body_file).await?;
req_builder = req_builder.body(body_bytes);
} else if has_files && has_form {
let mut multipart = Multipart::new();
for f in &cli.form {
let (name, value) = parse_form(f)?;
multipart = multipart.text(&name, &value)?;
}
for f in &cli.file {
let (name, path, filename) = parse_file_part(f)?;
let data = tokio::fs::read(&path)
.await
.with_context(|| format!("failed to read file: {path}"))?;
let fname = filename.unwrap_or_else(|| {
std::path::Path::new(&path)
.file_name()
.map_or_else(|| name.clone(), |n| n.to_string_lossy().into_owned())
});
let mime = guess_mime(&path);
multipart = multipart.bytes(&name, &fname, mime, Bytes::from(data))?;
}
req_builder = req_builder.body(multipart.into_body());
} else if has_files {
let mut multipart = Multipart::new();
for f in &cli.file {
let (name, path, filename) = parse_file_part(f)?;
let data = tokio::fs::read(&path)
.await
.with_context(|| format!("failed to read file: {path}"))?;
let fname = filename.unwrap_or_else(|| {
std::path::Path::new(&path)
.file_name()
.map_or_else(|| name.clone(), |n| n.to_string_lossy().into_owned())
});
let mime = guess_mime(&path);
multipart = multipart.bytes(&name, &fname, mime, Bytes::from(data))?;
}
req_builder = req_builder.body(multipart.into_body());
} else if has_form {
let mut params = Vec::new();
for f in &cli.form {
let (key, value) = parse_form(f)?;
params.push(format!(
"{}={}",
percent_encode(&key),
percent_encode(&value)
));
}
let form_body = params.join("&");
req_builder = req_builder
.header("content-type", "application/x-www-form-urlencoded")
.body(form_body);
}
if cli.no_compress {
req_builder = req_builder.decompress(false);
}
let start = Instant::now();
#[allow(clippy::large_futures)]
let send_result = req_builder.send().await;
match send_result {
Ok(mut response) => {
let elapsed = start.elapsed();
let status = response.status();
let is_success = status.is_success();
if cli.json_output || cli.ndjson {
let body_result = response.bytes().await;
let body_len = body_result.as_ref().ok().map(bytes::Bytes::len);
let mut json_errors: Vec<String> = Vec::new();
if let Err(ref e) = body_result {
json_errors.push(crate::output::redact_credentials(&e.to_string()));
}
let body_b64 = if cli.base64 {
body_result.as_ref().ok().map(|b| base64_encode(b))
} else {
None
};
let mut json_val = build_json_response(
&response,
elapsed,
body_len,
cli.base64,
body_b64.as_deref(),
&json_errors,
);
if !cli.json_output {
if let Some(obj) = json_val.as_object_mut() {
obj.remove("history");
}
}
if cli.json_output {
let output = serde_json::to_string_pretty(&json_val)?;
if let Some(ref path) = cli.output {
let mut f = create_output_file(path, cli.no_clobber).await?;
tokio::io::AsyncWriteExt::write_all(&mut f, output.as_bytes()).await?;
} else {
println!("{output}");
}
} else {
let mut lines: Vec<Value> = Vec::new();
for entry in response.history() {
lines.push(json!({
"type": "redirect",
"status": entry.status().as_u16(),
"url": safe_url_for_display(entry.url().as_str()),
"version": version_string(entry.version()),
}));
}
lines.push(json_val);
if let Some(ref path) = cli.output {
let output: String = lines
.iter()
.map(|l| serde_json::to_string(l).unwrap_or_default())
.collect::<Vec<_>>()
.join("\n");
let mut f = create_output_file(path, cli.no_clobber).await?;
tokio::io::AsyncWriteExt::write_all(
&mut f,
format!("{output}\n").as_bytes(),
)
.await?;
} else {
for line in &lines {
println!("{}", serde_json::to_string(line)?);
}
}
}
if let Err(e) = body_result {
return Err(e.into());
}
if cli.check_status && !is_success {
return Err(StatusError(status.as_u16()).into());
}
return Ok(());
}
if cli.include || cli.verbose {
if !response.history().is_empty() {
eprintln!(
"\n--- Redirect History ({} hops) ---",
response.history().len()
);
for (i, entry) in response.history().iter().enumerate() {
eprintln!(
" Hop {}: {} {} {}",
i + 1,
entry.status(),
entry.url().path(),
version_string(entry.version()),
);
eprint!("{}", format_headers(entry.headers(), cli.verbose, true));
}
eprintln!();
}
eprint!(
"< HTTP/{} {} {}\r\n",
version_string(response.version()),
status.as_u16(),
status.canonical_reason().unwrap_or("")
);
eprint!("{}", format_headers(response.headers(), cli.verbose, true));
eprintln!("\n--- Response time: {:.3}s ---\n", elapsed.as_secs_f64());
}
if cli.headers_only {
let header_str = format!(
"HTTP/{} {} {}\r\n{}",
version_string(response.version()),
status.as_u16(),
status.canonical_reason().unwrap_or(""),
format_headers(response.headers(), false, true)
);
if let Some(ref path) = cli.output {
let mut f = create_output_file(path, cli.no_clobber).await?;
tokio::io::AsyncWriteExt::write_all(&mut f, header_str.as_bytes()).await?;
} else {
print!("{header_str}");
}
if cli.check_status && !is_success {
return Err(StatusError(status.as_u16()).into());
}
return Ok(());
}
if cli.no_body {
if cli.check_status && !is_success {
return Err(StatusError(status.as_u16()).into());
}
return Ok(());
}
let mut output_file: Option<tokio::fs::File> = None;
let mut output_path: Option<PathBuf> = None;
if let Some(ref path) = cli.output {
output_path = Some(path.clone());
let f = create_output_file(path, cli.no_clobber).await?;
output_file = Some(f);
} else if cli.download {
let filename = derive_filename(&response).unwrap_or_else(|| "download".to_owned());
let path = std::path::Path::new(&filename);
if cli.no_clobber && path.exists() {
anyhow::bail!(
"output file already exists: {} (remove --no-clobber to allow overwrite)",
path.display()
);
}
if path.exists() {
let stem = path.file_stem().unwrap_or_default();
let ext = path
.extension()
.map(|e| format!(".{}", e.to_string_lossy()))
.unwrap_or_default();
let mut created: Option<(PathBuf, tokio::fs::File)> = None;
for counter in 1u64.. {
let candidate = format!("{} ({}){}", stem.to_string_lossy(), counter, ext);
match tokio::fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&candidate)
.await
{
Ok(f) => {
created = Some((PathBuf::from(candidate), f));
break;
}
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {}
Err(e) => {
anyhow::bail!("failed to create: {candidate} ({e})");
}
}
}
match created {
Some((new_path, f)) => {
output_path = Some(new_path);
output_file = Some(f);
}
None => anyhow::bail!(
"could not find an unused filename for download after u64::MAX attempts"
),
}
} else {
let f = tokio::fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(path)
.await
.with_context(|| format!("failed to create: {}", path.display()))?;
output_file = Some(f);
}
}
if let Some(ref path) = output_path {
eprintln!("Saving to: {}", path.display());
}
let mut stream = response.bytes_stream()?;
if std::io::stdout().is_terminal() {
if let Some(ct) = response.headers().get("content-type") {
if let Ok(ct_str) = ct.to_str() {
if is_binary_content_type(ct_str) {
eprintln!("Warning: binary content type detected; writing to terminal may produce garbled output");
}
}
}
}
let mut total_bytes: usize = 0;
while let Some(chunk_result) = stream.next().await {
let chunk = chunk_result?;
total_bytes += chunk.len();
if let Some(ref mut f) = output_file {
f.write_all(&chunk).await?;
} else {
let mut stdout = tokio::io::stdout();
stdout.write_all(&chunk).await?;
stdout.flush().await?;
}
}
if let Some(mut f) = output_file {
f.flush().await?;
}
if cli.verbose {
eprintln!();
eprintln!("Status: {}", status.as_u16());
eprintln!("Body: {total_bytes} bytes");
eprintln!("Time: {:.3}s", elapsed.as_secs_f64());
}
if cli.check_status && !is_success {
return Err(StatusError(status.as_u16()).into());
}
Ok(())
}
Err(err) => Err(err.into()),
}
}
#[tokio::main]
async fn main() -> ExitCode {
let cli = Cli::parse();
let result = tokio::select! {
result = run(cli) => result,
_ = tokio::signal::ctrl_c() => {
eprintln!("\nInterrupted");
return ExitCode::from(130u8);
}
};
match result {
Ok(()) => ExitCode::from(EXIT_SUCCESS),
Err(err) => {
if err.chain().any(|cause| {
cause
.downcast_ref::<std::io::Error>()
.is_some_and(|io| io.kind() == std::io::ErrorKind::Interrupted)
}) {
return ExitCode::from(130);
}
if let Some(eggfetch_err) = err.downcast_ref::<eggfetch_core::Error>() {
let code = map_error_to_exit_code(eggfetch_err);
eprintln!(
"Error: {}",
crate::output::redact_credentials(&eggfetch_err.to_string())
);
ExitCode::from(code)
} else if err.downcast_ref::<std::io::Error>().is_some() {
eprintln!("I/O error: {err}");
ExitCode::from(EXIT_IO)
} else {
eprintln!(
"Error: {}",
crate::output::redact_credentials(&err.to_string())
);
ExitCode::from(map_unknown_error_to_exit_code(&err))
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_header_valid() {
let (name, value) = parse_header("Content-Type: application/json").unwrap();
assert_eq!(name, "Content-Type");
assert_eq!(value, "application/json");
}
#[test]
fn parse_header_empty_value() {
let (name, value) = parse_header("X-Custom:").unwrap();
assert_eq!(name, "X-Custom");
assert_eq!(value, "");
}
#[test]
fn parse_header_no_colon() {
assert!(parse_header("BadHeader").is_err());
}
#[test]
fn parse_query_valid() {
let (key, value) = parse_query("q=hello world").unwrap();
assert_eq!(key, "q");
assert_eq!(value, "hello world");
}
#[test]
fn parse_query_no_equals() {
assert!(parse_query("badquery").is_err());
}
#[test]
fn parse_form_valid() {
let (key, value) = parse_form("field=value").unwrap();
assert_eq!(key, "field");
assert_eq!(value, "value");
}
#[test]
fn parse_file_part_basic() {
let (name, path, filename) = parse_file_part("file=@/tmp/test.txt").unwrap();
assert_eq!(name, "file");
assert_eq!(path, "/tmp/test.txt");
assert!(filename.is_none());
}
#[test]
fn parse_file_part_with_filename() {
let (name, path, filename) = parse_file_part("file=@/tmp/test.txt:custom.txt").unwrap();
assert_eq!(name, "file");
assert_eq!(path, "/tmp/test.txt");
assert_eq!(filename.as_deref(), Some("custom.txt"));
}
#[test]
fn parse_file_part_no_at() {
assert!(parse_file_part("file=/tmp/test.txt").is_err());
}
#[test]
fn parse_file_part_windows_path() {
let (name, path, filename) = parse_file_part("file=@C:\\Users\\runner\\file.txt").unwrap();
assert_eq!(name, "file");
assert_eq!(path, "C:\\Users\\runner\\file.txt");
assert!(filename.is_none());
}
#[test]
fn parse_file_part_windows_path_with_filename() {
let (name, path, filename) =
parse_file_part("file=@C:\\Users\\runner\\file.txt:custom.txt").unwrap();
assert_eq!(name, "file");
assert_eq!(path, "C:\\Users\\runner\\file.txt");
assert_eq!(filename.as_deref(), Some("custom.txt"));
}
#[test]
fn detect_method_default_get() {
let cli = Cli {
method: None,
url: "http://example.com".to_owned(),
header: vec![],
query: vec![],
form: vec![],
file: vec![],
body: None,
body_file: None,
json: None,
output: None,
download: false,
no_clobber: false,
include: false,
headers_only: false,
no_body: false,
json_output: false,
ndjson: false,
base64: false,
timeout: None,
connect_timeout: None,
total_timeout: None,
read_timeout: None,
follow: true,
no_follow: false,
max_redirects: 20,
no_downgrade: false,
auth: None,
bearer: None,
cookie: vec![],
cookie_jar: None,
proxy: None,
proxy_auth: None,
no_proxy: None,
no_verify: false,
cacert: None,
cert: None,
key: None,
retry: None,
retry_delay: None,
http1: false,
http2: false,
http3: false,
no_compress: false,
max_body_size: None,
max_decompression_ratio: None,
check_status: false,
verbose: false,
generate_completion: None,
};
assert_eq!(detect_method(&cli), "GET");
}
#[test]
fn detect_method_body_implies_post() {
let cli = Cli {
method: None,
url: "http://example.com".to_owned(),
header: vec![],
query: vec![],
form: vec![],
file: vec![],
body: Some("data".to_owned()),
body_file: None,
json: None,
output: None,
download: false,
no_clobber: false,
include: false,
headers_only: false,
no_body: false,
json_output: false,
ndjson: false,
base64: false,
timeout: None,
connect_timeout: None,
total_timeout: None,
read_timeout: None,
follow: true,
no_follow: false,
max_redirects: 20,
no_downgrade: false,
auth: None,
bearer: None,
cookie: vec![],
cookie_jar: None,
proxy: None,
proxy_auth: None,
no_proxy: None,
no_verify: false,
cacert: None,
cert: None,
key: None,
retry: None,
retry_delay: None,
http1: false,
http2: false,
http3: false,
no_compress: false,
max_body_size: None,
max_decompression_ratio: None,
check_status: false,
verbose: false,
generate_completion: None,
};
assert_eq!(detect_method(&cli), "POST");
}
#[test]
fn detect_method_explicit() {
let cli = Cli {
method: Some("PUT".to_owned()),
url: "http://example.com".to_owned(),
header: vec![],
query: vec![],
form: vec![],
file: vec![],
body: None,
body_file: None,
json: None,
output: None,
download: false,
no_clobber: false,
include: false,
headers_only: false,
no_body: false,
json_output: false,
ndjson: false,
base64: false,
timeout: None,
connect_timeout: None,
total_timeout: None,
read_timeout: None,
follow: true,
no_follow: false,
max_redirects: 20,
no_downgrade: false,
auth: None,
bearer: None,
cookie: vec![],
cookie_jar: None,
proxy: None,
proxy_auth: None,
no_proxy: None,
no_verify: false,
cacert: None,
cert: None,
key: None,
retry: None,
retry_delay: None,
http1: false,
http2: false,
http3: false,
no_compress: false,
max_body_size: None,
max_decompression_ratio: None,
check_status: false,
verbose: false,
generate_completion: None,
};
assert_eq!(detect_method(&cli), "PUT");
}
#[test]
fn version_string_works() {
assert_eq!(version_string(http::Version::HTTP_11), "1.1");
assert_eq!(version_string(http::Version::HTTP_10), "1.0");
assert_eq!(version_string(http::Version::HTTP_2), "2");
assert_eq!(version_string(http::Version::HTTP_3), "3");
}
#[test]
fn exit_code_mapping() {
use eggfetch_core::Error;
assert_eq!(
map_error_to_exit_code(&Error::InvalidUrl("test".into())),
EXIT_USAGE
);
assert_eq!(
map_error_to_exit_code(&Error::Connect("test".into())),
EXIT_CONNECT
);
assert_eq!(
map_error_to_exit_code(&Error::Timeout {
phase: eggfetch_core::TimeoutPhase::Read,
elapsed: Duration::from_secs(5),
}),
EXIT_TIMEOUT
);
assert_eq!(
map_error_to_exit_code(&Error::Protocol("test".into())),
EXIT_PROTOCOL
);
assert_eq!(
map_error_to_exit_code(&Error::Tls("test".into())),
EXIT_CONNECT
);
assert_eq!(
map_error_to_exit_code(&Error::Io(std::sync::Arc::new(std::io::Error::other(
"test"
)))),
EXIT_IO
);
assert_eq!(
map_error_to_exit_code(&Error::DecodedBodyTooLarge),
EXIT_PROTOCOL
);
assert_eq!(
map_error_to_exit_code(&Error::InvalidProxyUrl("test".into())),
EXIT_USAGE
);
assert_eq!(
map_error_to_exit_code(&Error::ProxyConnect("test".into())),
EXIT_CONNECT
);
}
#[test]
fn unknown_error_mapping_uses_types_not_substrings() {
let status: anyhow::Error = StatusError(500).into();
assert_eq!(map_unknown_error_to_exit_code(&status), EXIT_STATUS);
let io: anyhow::Error = anyhow::Error::new(std::io::Error::other("boom"))
.context("failed to create output file: /tmp/x");
assert_eq!(map_unknown_error_to_exit_code(&io), EXIT_IO);
let decoy = anyhow::anyhow!("status report unavailable");
assert_eq!(map_unknown_error_to_exit_code(&decoy), EXIT_USAGE);
let usage = anyhow::anyhow!("--http1, --http2, and --http3 are mutually exclusive");
assert_eq!(map_unknown_error_to_exit_code(&usage), EXIT_USAGE);
}
#[test]
fn is_secret_header_detection() {
assert!(is_secret_header("authorization"));
assert!(is_secret_header("Authorization"));
assert!(is_secret_header("proxy-authorization"));
assert!(is_secret_header("cookie"));
assert!(is_secret_header("Cookie"));
assert!(is_secret_header("set-cookie"));
assert!(!is_secret_header("content-type"));
assert!(!is_secret_header("host"));
}
#[test]
fn format_header_value_redaction() {
assert_eq!(
format_header_value("authorization", "Bearer secret123", true),
"Bearer <redacted>"
);
assert_eq!(
format_header_value("authorization", "Basic dXNlcjpwYXNz", true),
"Basic <redacted>"
);
assert_eq!(
format_header_value("cookie", "session=abc123", true),
"<redacted>"
);
assert_eq!(
format_header_value("content-type", "application/json", true),
"application/json"
);
assert_eq!(
format_header_value("authorization", "Bearer secret123", false),
"Bearer secret123"
);
}
#[test]
fn base64_encode_works() {
assert_eq!(base64_encode(b""), "");
assert_eq!(base64_encode(b"f"), "Zg==");
assert_eq!(base64_encode(b"fo"), "Zm8=");
assert_eq!(base64_encode(b"foo"), "Zm9v");
assert_eq!(base64_encode(b"foobar"), "Zm9vYmFy");
}
#[test]
fn follow_default_behavior() {
let cli = Cli {
method: None,
url: "http://example.com".to_owned(),
header: vec![],
query: vec![],
form: vec![],
file: vec![],
body: None,
body_file: None,
json: None,
output: None,
download: false,
no_clobber: false,
include: false,
headers_only: false,
no_body: false,
json_output: false,
ndjson: false,
base64: false,
timeout: None,
connect_timeout: None,
total_timeout: None,
read_timeout: None,
follow: true,
no_follow: false,
max_redirects: 20,
no_downgrade: false,
auth: None,
bearer: None,
cookie: vec![],
cookie_jar: None,
proxy: None,
proxy_auth: None,
no_proxy: None,
no_verify: false,
cacert: None,
cert: None,
key: None,
retry: None,
retry_delay: None,
http1: false,
http2: false,
http3: false,
no_compress: false,
max_body_size: None,
max_decompression_ratio: None,
check_status: false,
verbose: false,
generate_completion: None,
};
let follow = cli.follow && !cli.no_follow;
assert!(follow, "default should follow redirects");
}
#[test]
fn no_follow_overrides_follow() {
let cli = Cli::try_parse_from(["eggfetch", "http://example.com", "--no-follow"]).unwrap();
assert!(cli.no_follow);
let follow = cli.follow && !cli.no_follow;
assert!(!follow, "--no-follow should override --follow");
}
#[test]
fn safe_url_for_display_strips_secrets() {
assert_eq!(
output::safe_url_for_display("https://user:pass@example.com/path?token=abc#frag"),
"https://example.com/path"
);
assert_eq!(
output::safe_url_for_display("https://example.com/search?q=secret"),
"https://example.com/search"
);
assert_eq!(
output::safe_url_for_display("https://example.com/plain"),
"https://example.com/plain"
);
}
#[test]
fn sanitize_filename_strips_path_components() {
assert_eq!(
sanitize_filename("../../etc/passwd").as_deref(),
Some("passwd")
);
assert_eq!(
sanitize_filename("..\\..\\win\\secret").as_deref(),
Some("secret")
);
assert_eq!(
sanitize_filename("report.pdf").as_deref(),
Some("report.pdf")
);
}
#[test]
fn sanitize_filename_rejects_dot_names() {
assert_eq!(sanitize_filename(""), None);
assert_eq!(sanitize_filename("."), None);
assert_eq!(sanitize_filename(".."), None);
}
#[test]
fn sanitize_filename_rejects_invalid_device_names_and_nul() {
assert_eq!(sanitize_filename("report\0.txt"), None);
assert_eq!(sanitize_filename("CON"), None);
assert_eq!(sanitize_filename("con.txt"), None);
assert_eq!(sanitize_filename("COM1.log"), None);
assert_eq!(sanitize_filename("LPT9"), None);
assert_eq!(sanitize_filename("COM10"), Some("COM10".to_owned()));
}
}
mod args;
pub(crate) use args::{Cli, Shell};
mod errors;
pub(crate) use errors::{
map_error_to_exit_code, map_unknown_error_to_exit_code, StatusError, EXIT_IO, EXIT_SUCCESS,
};
mod input;
pub(crate) use input::{
detect_method, guess_mime, parse_file_part, parse_form, parse_header, parse_query,
percent_encode, read_body_file,
};
mod output;
pub(crate) use output::{
base64_encode, build_json_response, format_headers, is_binary_content_type,
safe_url_for_display, version_string,
};
mod files;
pub(crate) use files::{create_output_file, derive_filename};
#[cfg(test)]
use errors::{EXIT_CONNECT, EXIT_PROTOCOL, EXIT_STATUS, EXIT_TIMEOUT, EXIT_USAGE};
#[cfg(test)]
use files::sanitize_filename;
#[cfg(test)]
use output::{format_header_value, is_secret_header};