diff --git a/docs/advanced-features.md b/docs/advanced-features.md index e8b73dee..017eb252 100644 --- a/docs/advanced-features.md +++ b/docs/advanced-features.md @@ -96,8 +96,9 @@ output identifies the resolver, the address and record counts, and the lookup duration. UDP inspection advertises EDNS(0). It retries a truncated UDP response with TCP. -If the TCP retry fails, `fetch` warns that the results are incomplete and exits -with a nonzero status. +If the TCP retry fails, or any other record-type query fails, `fetch` preserves +successful records, warns that the results are incomplete, and exits with a +nonzero status. ### Configuration File diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 0525b2ff..6222c7fe 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -476,8 +476,9 @@ record counts, and the lookup duration. With an explicit `--dns-server`, the output can include A, AAAA, CNAME, TXT, MX, NS, SOA, SRV, CAA, SVCB, and HTTPS records with their TTLs. All `--dns-server` transports are supported. UDP inspection advertises EDNS(0). -It retries a truncated UDP response with TCP. If the TCP retry fails, `fetch` -warns that the results are incomplete and exits with a nonzero status. +It retries a truncated UDP response with TCP. If the TCP retry fails, or any +other record-type query fails, `fetch` preserves successful records, warns that +the results are incomplete, and exits with a nonzero status. Request-only CLI flags have no effect with `--inspect-dns`. They cause a warning that `fetch` does not send an HTTP request. Configuration-file defaults do not diff --git a/src/dns/inspect.rs b/src/dns/inspect.rs index 0b92d77a..917a9b3e 100644 --- a/src/dns/inspect.rs +++ b/src/dns/inspect.rs @@ -106,6 +106,11 @@ struct QueryError { message: String, } +struct QueryFailure { + query_type: QueryType, + error: QueryError, +} + impl QueryError { fn other(err: impl ToString) -> Self { Self { @@ -392,8 +397,8 @@ async fn lookup( .run(async { Ok::<_, FetchError>(join_all(futures).await) }) .await?; - let mut first_err = None; - let mut truncated_types = Vec::new(); + let mut first_err: Option = None; + let mut failures = Vec::new(); let mut seen: HashMap = HashMap::new(); for (query_type, result) in results { match result { @@ -411,35 +416,47 @@ async fn lookup( records.push(record); } } - Err(err) if err.kind == QueryErrorKind::Truncated => { - truncated_types.push(query_type.label); + Err(err) => { if first_err.is_none() { - first_err = Some(err); + first_err = Some(err.message.clone()); } + failures.push(QueryFailure { + query_type, + error: err, + }); } - Err(err) if first_err.is_none() => { - first_err = Some(err); - } - Err(_) => {} } } out.duration = start.elapsed(); + let truncated_types: Vec<_> = failures + .iter() + .filter(|failure| failure.error.kind == QueryErrorKind::Truncated) + .map(|failure| failure.query_type.label) + .collect(); if !truncated_types.is_empty() { out.warnings.push(truncated_warning(&truncated_types)); + } + let other_failures: Vec<_> = failures + .iter() + .filter(|failure| failure.error.kind != QueryErrorKind::Truncated) + .collect(); + if !other_failures.is_empty() { + out.warnings.push(partial_failure_warning(&other_failures)); + } + if !failures.is_empty() { out.exit_code = 1; - return Ok(out); } - if record_count(&out) > 0 { + + // Preserve the existing truncated-response behavior even when no complete + // record was returned. Other failures remain fatal when they leave no + // records to render. + if record_count(&out) > 0 || !truncated_types.is_empty() { return Ok(out); } let host = core::escape_terminal_text(host); if let Some(err) = first_err { - return Err(format!( - "lookup {host}: {}", - core::escape_terminal_text(&err.message) - ) - .into()); + return Err(format!("lookup {host}: {}", core::escape_terminal_text(&err)).into()); } Err(format!("lookup {host}: no DNS records found").into()) } @@ -720,6 +737,21 @@ fn truncated_warning(types: &[&'static str]) -> String { ) } +fn partial_failure_warning(failures: &[&QueryFailure]) -> String { + let details = failures + .iter() + .map(|failure| { + format!( + "{} ({})", + failure.query_type.label, + core::escape_terminal_text(&failure.error.message) + ) + }) + .collect::>() + .join(", "); + format!("DNS queries for {details} failed; results are incomplete") +} + pub(crate) fn ignored_inspection_flags(cli: &Cli) -> Vec<&'static str> { let mut ignored = Vec::new(); crate::inspection::append_shared_ignored_request_flags(cli, &mut ignored); @@ -858,6 +890,44 @@ mod tests { task.abort(); } + #[tokio::test] + async fn test_inspect_doh_partial_failure_preserves_records_and_exits_nonzero() { + let (server_url, task) = start_test_server(|request| { + match request + .uri() + .query() + .unwrap_or_default() + .split('&') + .find_map(|part| part.strip_prefix("type=")) + .unwrap_or_default() + { + "A" => http::Response::new( + r#"{"Status":0,"Answer":[{"type":1,"data":"192.0.2.1","TTL":60}]}"#.to_string(), + ), + "AAAA" => http::Response::new(r#"{"Status":3}"#.to_string()), + _ => http::Response::new(r#"{"Status":0}"#.to_string()), + } + }) + .await; + + let (out, code) = inspect_with_code( + &Url::parse("https://example.com").unwrap(), + Some(server_url.as_str()), + ) + .await + .unwrap(); + + assert_eq!(code, 1); + assert!(out.contains("192.0.2.1 (TTL 1m)")); + assert!( + out.contains("warning: DNS queries for AAAA"), + "output: {out}" + ); + assert!(out.contains("NXDomain"), "output: {out}"); + assert!(out.contains("results are incomplete"), "output: {out}"); + task.abort(); + } + #[tokio::test] async fn test_inspect_doh_escapes_txt_control_sequences() { let (server_url, task) = start_test_server(|request| {