Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@ metadata/update/DNS/TLS inspection modes, and executes requests via `src/http`.
- Rust `--timing` enables DNS pre-resolution timing and wraps reqwest's connector service so the waterfall includes DNS, TCP, TTFB, and Body phases. reqwest does not currently expose a separate TLS handshake duration, so Rust reports the combined TCP/TLS connector phase as TCP timing.
- Rust response bodies written to terminal stdout are routed through `less -FIRX` unless `--no-pager` or `no-pager = true` disables the pager; image responses, piped stdout, and output-file writes bypass the pager.
- Custom/pre-resolved DNS observes timeout budgets before the reqwest client is built: `--connect-timeout` bounds DNS resolution when set, otherwise DNS uses the remaining `--timeout` budget, and DoH lookup clients receive the same budget.
- Custom UDP DNS uses random query IDs and applies a 5s per-query receive timeout when no request/connect timeout is available, so unresponsive UDP resolvers cannot hang indefinitely.
- GitHub Actions run Cargo fmt, clippy, unit tests, and the Rust integration suite. Release builds Cargo archives named for the self-updater, and Dependabot tracks Cargo and Actions. Release builds set `FETCH_VERSION` from the release tag so the compiled binary reports the published version; `Cargo.toml` intentionally remains `0.0.0` unless crate publishing becomes a goal. Local builds derive `FETCH_VERSION` from a matching `v*` git tag, then `git describe`, then `v0.0.0-dev`.
- Rust default config discovery on Windows honors `XDG_CONFIG_HOME/fetch/config` and `HOME/.config/fetch/config` before falling back to `AppData/fetch/config`; Windows mTLS integration fixtures use RSA test certificates to stay compatible with reqwest/rustls platform verification.
- `--copy` tees decoded response bodies to the system clipboard for both stdout and output-file responses, using platform clipboard commands (`pbcopy`, `wl-copy`, `xclip`, `xsel`, or `clip.exe`) and skipping clipboard writes when the decoded body exceeds 1 MiB.
Expand Down
33 changes: 19 additions & 14 deletions src/dns/inspect.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
use std::collections::HashMap;
use std::io::IsTerminal;
use std::net::{IpAddr, SocketAddr};
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
use std::time::{Duration, Instant};

use futures_util::future::join_all;
use reqwest::header::{ACCEPT, USER_AGENT};
Expand All @@ -11,6 +11,7 @@ use url::Url;

use crate::cli::Cli;
use crate::core::{self, Printer, Sequence};
use crate::dns::util::{dns_query_id, udp_dns_timeout};
use crate::error::{FetchError, write_error_with_color, write_warning_with_color};

const DNS_TYPE_A: u16 = 1;
Expand Down Expand Up @@ -132,7 +133,7 @@ pub async fn execute(cli: &Cli) -> Result<i32, FetchError> {
let inspected = if let Some(timeout) = timeout {
match tokio::time::timeout(
timeout,
inspect_with_color(&url, cli.dns_server.as_deref(), use_color),
inspect_with_color(&url, cli.dns_server.as_deref(), use_color, Some(timeout)),
)
.await
{
Expand All @@ -143,7 +144,7 @@ pub async fn execute(cli: &Cli) -> Result<i32, FetchError> {
))),
}
} else {
inspect_with_color(&url, cli.dns_server.as_deref(), use_color).await
inspect_with_color(&url, cli.dns_server.as_deref(), use_color, None).await
};

match inspected {
Expand All @@ -160,13 +161,14 @@ pub async fn execute(cli: &Cli) -> Result<i32, FetchError> {

#[cfg(test)]
async fn inspect(url: &Url, dns_server: Option<&str>) -> Result<String, FetchError> {
inspect_with_color(url, dns_server, false).await
inspect_with_color(url, dns_server, false, None).await
}

async fn inspect_with_color(
url: &Url,
dns_server: Option<&str>,
use_color: bool,
timeout: Option<Duration>,
) -> Result<String, FetchError> {
let host = url
.host_str()
Expand All @@ -185,14 +187,15 @@ async fn inspect_with_color(
));
}

let result = lookup(host, target, start).await?;
let result = lookup(host, target, start, timeout).await?;
Ok(render_with_color(&result, use_color))
}

async fn lookup(
host: &str,
target: ResolverTarget,
start: Instant,
timeout: Option<Duration>,
) -> Result<Inspection, FetchError> {
let mut out = Inspection {
host: host.to_string(),
Expand All @@ -216,13 +219,14 @@ async fn lookup(
return Ok(out);
}

let udp_timeout = udp_dns_timeout(timeout);
let futures = INSPECT_TYPES.iter().copied().map(|query_type| {
let target = target.clone();
async move {
match target {
ResolverTarget::Doh { url, .. } => lookup_doh_records(&url, host, query_type).await,
ResolverTarget::Udp { addr, .. } => {
lookup_udp_records(&addr, host, query_type).await
lookup_udp_records(&addr, host, query_type, udp_timeout).await
}
ResolverTarget::Default { .. } => unreachable!("default resolver handled earlier"),
}
Expand Down Expand Up @@ -345,6 +349,7 @@ async fn lookup_udp_records(
server_addr: &str,
host: &str,
query_type: QueryType,
timeout: Duration,
) -> Result<Vec<Record>, FetchError> {
let id = dns_query_id();
let raw = build_dns_query(id, host, query_type.dns_type)?;
Expand All @@ -358,17 +363,14 @@ async fn lookup_udp_records(
socket.send(&raw).await?;

let mut buf = vec![0u8; 4096];
let n = socket.recv(&mut buf).await?;
let n = match tokio::time::timeout(timeout, socket.recv(&mut buf)).await {
Ok(Ok(n)) => n,
Ok(Err(err)) => return Err(err.into()),
Err(_) => return Err("DNS lookup timed out".into()),
};
parse_dns_response(&buf[..n], id)
}

fn dns_query_id() -> u16 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|duration| duration.subsec_nanos() as u16)
.unwrap_or_default()
}

fn build_dns_query(id: u16, host: &str, dns_type: u16) -> Result<Vec<u8>, FetchError> {
let mut raw = Vec::with_capacity(512);
raw.extend_from_slice(&id.to_be_bytes());
Expand Down Expand Up @@ -1361,6 +1363,7 @@ mod tests {
url: server_url,
},
Instant::now(),
None,
)
.await
.unwrap();
Expand Down Expand Up @@ -1400,6 +1403,7 @@ mod tests {
url: server_url,
},
Instant::now(),
None,
)
.await
.unwrap();
Expand All @@ -1422,6 +1426,7 @@ mod tests {
doh_type: "A",
dns_type: DNS_TYPE_A,
},
Duration::from_secs(1),
)
.await
.unwrap();
Expand Down
1 change: 1 addition & 0 deletions src/dns/mod.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
pub mod doh;
pub mod inspect;
pub mod resolver;
pub(crate) mod util;
55 changes: 36 additions & 19 deletions src/dns/resolver.rs
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
use std::fmt;
use std::net::{IpAddr, SocketAddr};
use std::time::{SystemTime, UNIX_EPOCH};
use std::time::Duration;

use tokio::net::UdpSocket;

use crate::dns::util::{dns_query_id, udp_dns_timeout};

const DNS_TYPE_A: u16 = 1;
const DNS_TYPE_AAAA: u16 = 28;
const DNS_CLASS_IN: u16 = 1;
Expand All @@ -25,13 +27,18 @@ pub struct DnsRecord {
pub ttl: Option<u32>,
}

pub async fn lookup_udp(server_addr: &str, host: &str) -> Result<Vec<IpAddr>, ResolverError> {
pub async fn lookup_udp(
server_addr: &str,
host: &str,
timeout: Option<Duration>,
) -> Result<Vec<IpAddr>, ResolverError> {
if let Ok(ip) = host.parse::<IpAddr>() {
return Ok(vec![ip]);
}

let a = lookup_udp_type(server_addr, host, DNS_TYPE_A).await;
let aaaa = lookup_udp_type(server_addr, host, DNS_TYPE_AAAA).await;
let timeout = udp_dns_timeout(timeout);
let a = lookup_udp_type(server_addr, host, DNS_TYPE_A, timeout).await;
let aaaa = lookup_udp_type(server_addr, host, DNS_TYPE_AAAA, timeout).await;

let mut addrs = Vec::new();
if let Ok(records) = &a {
Expand All @@ -53,6 +60,7 @@ pub async fn lookup_udp_type(
server_addr: &str,
host: &str,
dns_type: u16,
timeout: Duration,
) -> Result<Vec<DnsRecord>, ResolverError> {
let id = dns_query_id();
let raw = build_dns_query(id, host, dns_type)?;
Expand All @@ -74,10 +82,11 @@ pub async fn lookup_udp_type(
.map_err(|err| ResolverError(err.to_string()))?;

let mut buf = vec![0u8; 4096];
let n = socket
.recv(&mut buf)
.await
.map_err(|err| ResolverError(err.to_string()))?;
let n = match tokio::time::timeout(timeout, socket.recv(&mut buf)).await {
Ok(Ok(n)) => n,
Ok(Err(err)) => return Err(ResolverError(err.to_string())),
Err(_) => return Err(ResolverError("DNS lookup timed out".to_string())),
};
parse_dns_response(&buf[..n], id)
}

Expand All @@ -103,13 +112,6 @@ fn dns_server_value_error(server: &str) -> ResolverError {
))
}

fn dns_query_id() -> u16 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|duration| duration.subsec_nanos() as u16)
.unwrap_or_default()
}

fn build_dns_query(id: u16, host: &str, dns_type: u16) -> Result<Vec<u8>, ResolverError> {
let mut raw = Vec::with_capacity(512);
raw.extend_from_slice(&id.to_be_bytes());
Expand Down Expand Up @@ -298,7 +300,7 @@ mod tests {
async fn lookup_udp_returns_a_and_aaaa() {
let (addr, stop) = start_udp_server(DnsServerMode::Success);

let addrs = lookup_udp(&addr, "example.com").await.unwrap();
let addrs = lookup_udp(&addr, "example.com", None).await.unwrap();

assert_eq!(
addrs.iter().map(ToString::to_string).collect::<Vec<_>>(),
Expand All @@ -311,7 +313,7 @@ mod tests {
async fn lookup_udp_type_returns_ttl() {
let (addr, stop) = start_udp_server(DnsServerMode::Success);

let records = lookup_udp_type(&addr, "example.com", DNS_TYPE_A)
let records = lookup_udp_type(&addr, "example.com", DNS_TYPE_A, Duration::from_secs(1))
.await
.unwrap();

Expand All @@ -323,7 +325,7 @@ mod tests {

#[tokio::test]
async fn lookup_udp_ip_literal_skips_server() {
let addrs = lookup_udp("127.0.0.1:9", "127.0.0.1").await.unwrap();
let addrs = lookup_udp("127.0.0.1:9", "127.0.0.1", None).await.unwrap();

assert_eq!(addrs, ["127.0.0.1".parse::<IpAddr>().unwrap()]);
}
Expand All @@ -332,12 +334,27 @@ mod tests {
async fn lookup_udp_nxdomain_mentions_rcode() {
let (addr, stop) = start_udp_server(DnsServerMode::NxDomain);

let err = lookup_udp(&addr, "missing.example").await.unwrap_err();
let err = lookup_udp(&addr, "missing.example", None)
.await
.unwrap_err();

assert!(err.to_string().contains("NXDomain"));
stop();
}

#[tokio::test]
async fn lookup_udp_type_times_out_waiting_for_response() {
let socket = StdUdpSocket::bind("127.0.0.1:0").unwrap();
let addr = socket.local_addr().unwrap().to_string();

let err = lookup_udp_type(&addr, "example.com", DNS_TYPE_A, Duration::from_millis(10))
.await
.unwrap_err();

assert_eq!(err.to_string(), "DNS lookup timed out");
drop(socket);
}

#[test]
fn normalize_udp_dns_server_matches_go_parser() {
assert_eq!(
Expand Down
11 changes: 11 additions & 0 deletions src/dns/util.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
use std::time::Duration;

pub(crate) const DEFAULT_UDP_DNS_TIMEOUT: Duration = Duration::from_secs(5);

pub(crate) fn dns_query_id() -> u16 {
rand::random::<u16>()
}

pub(crate) fn udp_dns_timeout(timeout: Option<Duration>) -> Duration {
timeout.unwrap_or(DEFAULT_UDP_DNS_TIMEOUT)
}
2 changes: 1 addition & 1 deletion src/http/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -453,7 +453,7 @@ async fn resolve_dns_for_client_inner(
} else {
let server_addr = crate::dns::resolver::normalize_udp_dns_server(dns_server)
.map_err(|err| FetchError::Message(err.to_string()))?;
crate::dns::resolver::lookup_udp(&server_addr, host)
crate::dns::resolver::lookup_udp(&server_addr, host, timeout)
.await
.map_err(|err| FetchError::Runtime(format!("lookup {host}: {err}")))?
};
Expand Down
Loading