mirror of
https://github.com/nushell/nushell.git
synced 2026-10-28 08:54:52 +08:00
fix: support IPv6 zone IDs in http commands (#19213)
Fixes #9065 ## Description `http get 'http://[fe80::13%eth0]'` (and the other `http *` commands) fail because `url::Url::parse` does not support IPv6 zone IDs (e.g. `%eth0`), per the WHATWG URL standard. `curl` handles these URLs fine. The fix now validates and drives requests with `http::Uri`, the same parser ureq uses, so nushell and ureq agree on which URLs are valid. 1. **`crates/nu-command/src/network/http/client.rs`** - `http_parse_url` validates with `http::Uri` instead of `url::Url` (which rejects zone IDs). It returns the raw URL string; the `strip_ipv6_zone_id` helper and the returned `Url` are gone. - A new `authority_is_well_formed` check rejects URLs that `http::Uri` would otherwise accept leniently, e.g. trailing garbage after an IPv6 literal (`[fe80::13%eth0]foo`) or non-numeric/out-of-range ports (`host:abc`, `host:99999`). These would silently send the request to a different host or port. - `request_set_host_header` sets the `Host` header without the zone ID (matching curl) when the authority contains one, so strict servers don't reject the request. A user-provided `Host` header is left untouched. - `transform_response_using_content_type` derives the `text/plain` file extension from `http::Uri::path()` so zone-ID URLs no longer fail after the request has been sent. 2. **`crates/nu-command/src/network/http/resolver.rs`** - `uri.host()` returns IPv6 addresses wrapped in square brackets (e.g. `[fe80::13%eth0]`), which `dns_lookup::getaddrinfo` cannot resolve. The brackets are stripped before resolution, so `getaddrinfo` handles the zone ID natively (converting the interface name to a scope ID). - The RFC 6874 `%25` form (e.g. `[fe80::1%25lo0]`) is decoded to a literal `%` before `getaddrinfo`. - `host` stays an `Option` and is passed to `getaddrinfo` as-is. ## User-facing changes (Release notes) `http get`, `http post`, and the other `http *` commands now accept **`http://`** URLs with IPv6 literals and link-local zone IDs, e.g. `http get 'http://[fe80::13%eth0]'`. Plain IPv6 literals (e.g. `http get 'http://[::1]:8080/a.txt'`) work as well. Previously these URLs failed to parse or resolve. Note: `https://` URLs with a zone ID are **not** supported. ureq builds the rustls `ServerName` from the bracket-stripped host, and `fe80::13%eth0` is neither a valid IP nor a valid DNS name, so TLS handshakes fail. This is a ureq/rustls limitation and cannot be fixed from nushell. ## Tests - `cargo check -p nu-command` passes - `cargo test -p nu-command --lib network::http` — 20 tests pass, including: - `test_http_parse_url_with_ipv6_zone_id` - `test_http_parse_url_rejects_invalid_ipv6_with_zone_id` - `test_authority_is_well_formed` - `test_request_set_host_header_strips_zone_id` - `test_request_set_host_header_leaves_other_urls_alone` - `cargo test -p nu-command --test tests -- commands::network::http` — 87 tests pass, including the new `http_get_ipv6_literal`, which binds `[::1]` and makes a real `text/plain` request through `DnsLookupResolver`. - `cargo fmt --all -- --check`, `cargo clippy -p nu-command --all-targets -D warnings` and `typos` are clean. ## Review feedback All findings from the review have been addressed: 1. **Text/plain responses fail after the request is sent** — `transform_response_using_content_type` now uses `http::Uri::path()` instead of `url::Url::parse`. 2. **Zone ID ends up in the `Host` header** — `request_set_host_header` strips it (like curl); a user-set `Host` header is respected. 3. **Validate with `http::Uri` instead of strip-and-reparse** — done; `strip_ipv6_zone_id` and the returned `Url` are removed. URLs that ureq rejects (e.g. `http://[fe80::1%lo 0]:18913/`) are now rejected up front by `http_parse_url`. 4. **RFC 6874 `%25` form can't resolve** — the resolver decodes `%25` to `%` before `getaddrinfo`. 5. **No test makes a real request to an IPv6 literal** — added `http_get_ipv6_literal`. 6. **Keep `host` as an `Option`** — done. 7. **HTTPS zone-ID URLs still fail** — a ureq/rustls limitation; the release notes above are now scoped to `http://`. --------- Co-authored-by: Jarvis-AI-42 <skyfireitdiy@gmail.com>
This commit is contained in:
1 parent
af58d1426b
commit
687d7bcac5
11 files changed
+276
-35
No files matched your search
@@ -69,3 +69,5 @@ tarpaulin-report.html
|
||||
|
||||
# Splashboard
|
||||
.splashboard.toml
|
||||
|
||||
.jarvis/
|
||||
@@ -41,7 +41,6 @@ use ureq::{
|
||||
typestate::{WithBody, WithoutBody},
|
||||
unversioned::transport::{ConnectProxyConnector, Connector, SocksConnector},
|
||||
};
|
||||
use url::Url;
|
||||
|
||||
#[cfg(feature = "native-tls")]
|
||||
use ureq::unversioned::transport::NativeTlsConnector;
|
||||
@@ -255,7 +254,7 @@ pub fn http_parse_url(
|
||||
call: &Call,
|
||||
span: Span,
|
||||
raw_url: Value,
|
||||
) -> Result<Spanned<(String, Url)>, ShellError> {
|
||||
) -> Result<Spanned<String>, ShellError> {
|
||||
let url_span = raw_url.span();
|
||||
let mut requested_url = raw_url.coerce_into_string()?;
|
||||
if requested_url.starts_with(':') {
|
||||
@@ -264,19 +263,62 @@ pub fn http_parse_url(
|
||||
requested_url = format!("http://{requested_url}");
|
||||
}
|
||||
|
||||
let url = match url::Url::parse(&requested_url) {
|
||||
Ok(u) => u,
|
||||
Err(_e) => {
|
||||
return Err(ShellError::UnsupportedInput {
|
||||
msg: "Incomplete or incorrect URL. Expected a full URL, e.g., https://www.example.com".to_string(),
|
||||
input: format!("value: '{requested_url:?}'"),
|
||||
msg_span: call.head,
|
||||
input_span: span,
|
||||
});
|
||||
}
|
||||
let invalid_url = || ShellError::UnsupportedInput {
|
||||
msg: "Incomplete or incorrect URL. Expected a full URL, e.g., https://www.example.com"
|
||||
.to_string(),
|
||||
input: format!("value: '{requested_url:?}'"),
|
||||
msg_span: call.head,
|
||||
input_span: span,
|
||||
};
|
||||
|
||||
Ok((requested_url, url).into_spanned(url_span))
|
||||
// Validate with `http::Uri`, the same parser ureq uses to send the request.
|
||||
// Unlike `url::Url`, it accepts IPv6 zone IDs (e.g. `http://[fe80::13%eth0]`),
|
||||
// so nushell and ureq agree on which URLs are valid.
|
||||
match requested_url.parse::<http::Uri>() {
|
||||
Ok(uri) if authority_is_well_formed(&uri) => {}
|
||||
_ => return Err(invalid_url()),
|
||||
}
|
||||
|
||||
Ok(requested_url.into_spanned(url_span))
|
||||
}
|
||||
|
||||
/// Check that the authority of a parsed URI is well formed.
|
||||
///
|
||||
/// `http::Uri` is lenient: it silently drops trailing garbage after an IPv6
|
||||
/// literal's closing bracket (e.g. `[fe80::13%eth0]foo`) and non-numeric or
|
||||
/// out-of-range ports (e.g. `host:abc`, `host:99999`). Accepting those would
|
||||
/// silently send the request to a different host or port than the user typed,
|
||||
/// so they are rejected instead.
|
||||
fn authority_is_well_formed(uri: &http::Uri) -> bool {
|
||||
let Some(authority) = uri.authority() else {
|
||||
return true;
|
||||
};
|
||||
// Ignore any userinfo (`user:pass@`).
|
||||
let host_port = authority
|
||||
.as_str()
|
||||
.rsplit_once('@')
|
||||
.map_or(authority.as_str(), |(_, host_port)| host_port);
|
||||
// Everything after the host must be empty or `:<port>`.
|
||||
let rest = if let Some(after_bracket) = host_port.strip_prefix('[') {
|
||||
match after_bracket.split_once(']') {
|
||||
Some((_, rest)) => rest,
|
||||
None => return false,
|
||||
}
|
||||
} else {
|
||||
match host_port.find(':') {
|
||||
Some(idx) => &host_port[idx..],
|
||||
None => return true,
|
||||
}
|
||||
};
|
||||
if rest.is_empty() {
|
||||
return true;
|
||||
}
|
||||
match rest.strip_prefix(':') {
|
||||
None => false,
|
||||
// `host:` has an empty port, which `url::Url` accepts.
|
||||
Some("") => true,
|
||||
Some(port) => port.parse::<u16>().is_ok(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn http_parse_redirect_mode(mode: Option<Spanned<String>>) -> Result<RedirectMode, ShellError> {
|
||||
@@ -886,6 +928,54 @@ pub fn request_add_custom_headers<B>(
|
||||
Ok(request)
|
||||
}
|
||||
|
||||
/// Set the `Host` header for a request whose URL contains an IPv6 zone ID.
|
||||
///
|
||||
/// ureq derives the `Host` header from the URI authority, which includes the
|
||||
/// zone ID (e.g. `[fe80::13%eth0]`). The zone ID is only meaningful on the
|
||||
/// client machine, so sending it would make strict servers reject the request.
|
||||
/// curl strips it, and so do we. A `Host` header set by the user is left alone.
|
||||
pub fn request_set_host_header<B>(
|
||||
requested_url: &str,
|
||||
request: RequestBuilder<B>,
|
||||
) -> RequestBuilder<B> {
|
||||
// Respect a `Host` header the user set explicitly.
|
||||
if request
|
||||
.headers_ref()
|
||||
.is_some_and(|headers| headers.contains_key("host"))
|
||||
{
|
||||
return request;
|
||||
}
|
||||
let Ok(uri) = requested_url.parse::<http::Uri>() else {
|
||||
return request;
|
||||
};
|
||||
let Some(authority) = uri.authority() else {
|
||||
return request;
|
||||
};
|
||||
let host = authority.host();
|
||||
if !host.contains('%') {
|
||||
return request;
|
||||
}
|
||||
let host = host
|
||||
.strip_prefix('[')
|
||||
.and_then(|inner| inner.strip_suffix(']'))
|
||||
.unwrap_or(host);
|
||||
let host = match host.split_once('%') {
|
||||
Some((addr, _)) => addr,
|
||||
None => host,
|
||||
};
|
||||
// Match ureq: omit the port when it is the scheme's default.
|
||||
let default_port = match uri.scheme_str() {
|
||||
Some("https") => Some(443),
|
||||
Some("http") => Some(80),
|
||||
_ => None,
|
||||
};
|
||||
let value = match authority.port() {
|
||||
Some(port) if Some(port.as_u16()) != default_port => format!("[{host}]:{port}"),
|
||||
_ => format!("[{host}]"),
|
||||
};
|
||||
request.header("Host", &value)
|
||||
}
|
||||
|
||||
fn handle_status_error(span: Span, requested_url: &str, response: &mut Response) -> ShellError {
|
||||
let msg = if response.header("content-type") == Some("application/json") {
|
||||
// We use a json response as a heuristic to mean the body will contain a relevant error message.
|
||||
@@ -999,15 +1089,19 @@ fn transform_response_using_content_type(
|
||||
.expect("Failed to parse content type, and failed to default to text/plain");
|
||||
|
||||
let ext = match (content_type.type_(), content_type.subtype()) {
|
||||
(mime::TEXT, mime::PLAIN) => url::Url::parse(requested_url)
|
||||
// Use `http::Uri` (the parser ureq uses) so that URLs `url::Url` cannot
|
||||
// parse, such as IPv6 zone IDs, still resolve to the right extension.
|
||||
(mime::TEXT, mime::PLAIN) => requested_url
|
||||
.parse::<http::Uri>()
|
||||
.map_err(|err| {
|
||||
LabeledError::new(err.to_string())
|
||||
.with_help("cannot parse")
|
||||
.with_label(format!("Cannot parse URL: {requested_url}"), span)
|
||||
})?
|
||||
.path_segments()
|
||||
.and_then(|mut segments| segments.next_back())
|
||||
.filter(|&name| !name.is_empty())
|
||||
.path()
|
||||
.rsplit('/')
|
||||
.next()
|
||||
.filter(|name| !name.is_empty())
|
||||
.and_then(|name| {
|
||||
PathBuf::from(name)
|
||||
.extension()
|
||||
@@ -1405,4 +1499,110 @@ mod test {
|
||||
assert!(expanded.is_absolute());
|
||||
assert!(!expanded.to_string_lossy().contains('~'));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_http_parse_url_with_ipv6_zone_id() {
|
||||
let call = Call::new(Span::test_data());
|
||||
let span = Span::test_data();
|
||||
let raw_url = Value::test_string("http://[fe80::13%eth0]");
|
||||
|
||||
let parsed = http_parse_url(&call, span, raw_url).unwrap();
|
||||
// The requested URL is preserved verbatim, including the zone ID, because
|
||||
// it is what ureq uses to send the request.
|
||||
assert_eq!(parsed.item, "http://[fe80::13%eth0]");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_http_parse_url_rejects_invalid_ipv6_with_zone_id() {
|
||||
let call = Call::new(Span::test_data());
|
||||
let span = Span::test_data();
|
||||
// A URL with a zone ID but invalid characters after the bracket must be
|
||||
// rejected, not silently rewritten (e.g. to localhost).
|
||||
for bad in [
|
||||
"http://[fe80::13%eth0]foo",
|
||||
"http://[fe80::13%eth0]:abc",
|
||||
"http://[fe80::13%eth0",
|
||||
] {
|
||||
let raw_url = Value::test_string(bad);
|
||||
assert!(
|
||||
http_parse_url(&call, span, raw_url).is_err(),
|
||||
"expected {bad:?} to be rejected"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_authority_is_well_formed() {
|
||||
let valid = [
|
||||
"http://example.com",
|
||||
"http://example.com:8080",
|
||||
"http://example.com:080",
|
||||
"http://example.com:",
|
||||
"http://example.com:0",
|
||||
"http://example.com:65535",
|
||||
"http://user:pass@example.com:8080/",
|
||||
"http://[fe80::13%eth0]",
|
||||
"http://[fe80::13%eth0]:8080",
|
||||
"http://[fe80::13%eth0]:080",
|
||||
"http://[fe80::13%eth0]:",
|
||||
"http://[fe80::13%eth0]/a.txt",
|
||||
"http://user:pass@[fe80::13%eth0]:8080/",
|
||||
];
|
||||
for url in valid {
|
||||
let uri = url.parse::<http::Uri>().unwrap();
|
||||
assert!(
|
||||
authority_is_well_formed(&uri),
|
||||
"expected {url:?} to be well formed"
|
||||
);
|
||||
}
|
||||
|
||||
let invalid = [
|
||||
"http://example.com:abc",
|
||||
"http://example.com:99999",
|
||||
"http://example.com:65536",
|
||||
"http://[fe80::13%eth0]foo",
|
||||
"http://[fe80::13%eth0]:abc",
|
||||
"http://[fe80::13%eth0]:99999",
|
||||
];
|
||||
for url in invalid {
|
||||
let uri = url.parse::<http::Uri>().unwrap();
|
||||
assert!(
|
||||
!authority_is_well_formed(&uri),
|
||||
"expected {url:?} to be rejected"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_request_set_host_header_strips_zone_id() {
|
||||
let request = ureq::Agent::config_builder()
|
||||
.build()
|
||||
.new_agent()
|
||||
.get("http://[fe80::13%eth0]:8080/");
|
||||
let request = request_set_host_header("http://[fe80::13%eth0]:8080/", request);
|
||||
|
||||
assert_eq!(
|
||||
request
|
||||
.headers_ref()
|
||||
.and_then(|headers| headers.get("host"))
|
||||
.map(|v| v.to_str().unwrap()),
|
||||
Some("[fe80::13]:8080")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_request_set_host_header_leaves_other_urls_alone() {
|
||||
let request = ureq::Agent::config_builder()
|
||||
.build()
|
||||
.new_agent()
|
||||
.get("http://example.com:8080/");
|
||||
let request = request_set_host_header("http://example.com:8080/", request);
|
||||
|
||||
assert!(
|
||||
request
|
||||
.headers_ref()
|
||||
.and_then(|headers| headers.get("host"))
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -2,8 +2,8 @@ use crate::network::http::client::{
|
||||
HttpBody, RedirectMode, RequestFlags, RequestMetadata, add_unix_socket_flag,
|
||||
check_response_redirection, expand_unix_socket_path, http_client, http_client_pool,
|
||||
http_parse_redirect_mode, http_parse_url, request_add_authorization_header,
|
||||
request_add_custom_headers, request_handle_response, request_set_timeout, send_request,
|
||||
send_request_no_body,
|
||||
request_add_custom_headers, request_handle_response, request_set_host_header,
|
||||
request_set_timeout, send_request, send_request_no_body,
|
||||
};
|
||||
use nu_engine::command_prelude::*;
|
||||
|
||||
@@ -222,7 +222,7 @@ fn helper(
|
||||
) -> Result<PipelineData, ShellError> {
|
||||
let span = args.url.span();
|
||||
let Spanned {
|
||||
item: (requested_url, _),
|
||||
item: requested_url,
|
||||
span: request_span,
|
||||
} = http_parse_url(call, span, args.url)?;
|
||||
let redirect_mode = http_parse_redirect_mode(args.redirect)?;
|
||||
@@ -245,6 +245,7 @@ fn helper(
|
||||
request = request_set_timeout(args.timeout, request)?;
|
||||
request = request_add_authorization_header(args.user, args.password, request);
|
||||
request = request_add_custom_headers(args.headers, request)?;
|
||||
request = request_set_host_header(&requested_url, request);
|
||||
let (response, request_headers) = match args.data {
|
||||
None => send_request_no_body(request, request_span, call.head, engine_state.signals()),
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ use crate::network::http::client::{
|
||||
RequestFlags, RequestMetadata, add_unix_socket_flag, check_response_redirection,
|
||||
expand_unix_socket_path, http_client, http_client_pool, http_parse_redirect_mode,
|
||||
http_parse_url, request_add_authorization_header, request_add_custom_headers,
|
||||
request_handle_response, request_set_timeout, send_request_no_body,
|
||||
request_handle_response, request_set_host_header, request_set_timeout, send_request_no_body,
|
||||
};
|
||||
use nu_engine::command_prelude::*;
|
||||
|
||||
@@ -199,7 +199,7 @@ fn helper(
|
||||
) -> Result<PipelineData, ShellError> {
|
||||
let span = args.url.span();
|
||||
let Spanned {
|
||||
item: (requested_url, _),
|
||||
item: requested_url,
|
||||
span: request_span,
|
||||
} = http_parse_url(call, span, args.url)?;
|
||||
let redirect_mode = http_parse_redirect_mode(args.redirect)?;
|
||||
@@ -223,6 +223,7 @@ fn helper(
|
||||
request = request_set_timeout(args.timeout, request)?;
|
||||
request = request_add_authorization_header(args.user, args.password, request);
|
||||
request = request_add_custom_headers(args.headers, request)?;
|
||||
request = request_set_host_header(&requested_url, request);
|
||||
let (response, request_headers) =
|
||||
send_request_no_body(request, request_span, call.head, engine_state.signals());
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ use crate::network::http::client::{
|
||||
expand_unix_socket_path, extract_response_headers, handle_response_status, headers_to_nu,
|
||||
http_client, http_client_pool, http_parse_redirect_mode, http_parse_url,
|
||||
request_add_authorization_header, request_add_custom_headers, request_handle_response,
|
||||
request_set_timeout, send_request_no_body,
|
||||
request_set_host_header, request_set_timeout, send_request_no_body,
|
||||
};
|
||||
use nu_engine::command_prelude::*;
|
||||
use nu_protocol::Signals;
|
||||
@@ -176,7 +176,7 @@ fn helper(
|
||||
) -> Result<PipelineData, ShellError> {
|
||||
let span = args.url.span();
|
||||
let Spanned {
|
||||
item: (requested_url, _),
|
||||
item: requested_url,
|
||||
span: request_span,
|
||||
} = http_parse_url(call, span, args.url)?;
|
||||
let redirect_mode = http_parse_redirect_mode(args.redirect)?;
|
||||
@@ -200,6 +200,7 @@ fn helper(
|
||||
request = request_set_timeout(args.timeout, request)?;
|
||||
request = request_add_authorization_header(args.user, args.password, request);
|
||||
request = request_add_custom_headers(args.headers, request)?;
|
||||
request = request_set_host_header(&requested_url, request);
|
||||
|
||||
let (response, request_headers) =
|
||||
send_request_no_body(request, request_span, call.head, signals);
|
||||
|
||||
@@ -3,7 +3,7 @@ use crate::network::http::client::{
|
||||
discard_response_body, expand_unix_socket_path, extract_response_headers,
|
||||
handle_response_status, headers_to_nu, http_client, http_client_pool, http_parse_url,
|
||||
request_add_authorization_header, request_add_custom_headers, request_handle_response,
|
||||
request_set_timeout, send_request_no_body,
|
||||
request_set_host_header, request_set_timeout, send_request_no_body,
|
||||
};
|
||||
use nu_engine::command_prelude::*;
|
||||
|
||||
@@ -172,7 +172,7 @@ fn helper(
|
||||
) -> Result<PipelineData, ShellError> {
|
||||
let span = args.url.span();
|
||||
let Spanned {
|
||||
item: (requested_url, _),
|
||||
item: requested_url,
|
||||
span: request_span,
|
||||
} = http_parse_url(call, span, args.url)?;
|
||||
let redirect_mode = RedirectMode::Follow;
|
||||
@@ -195,6 +195,7 @@ fn helper(
|
||||
request = request_set_timeout(args.timeout, request)?;
|
||||
request = request_add_authorization_header(args.user, args.password, request);
|
||||
request = request_add_custom_headers(args.headers, request)?;
|
||||
request = request_set_host_header(&requested_url, request);
|
||||
|
||||
let (response, request_headers) =
|
||||
send_request_no_body(request, request_span, call.head, engine_state.signals());
|
||||
|
||||
@@ -2,7 +2,7 @@ use crate::network::http::client::{
|
||||
HttpBody, RequestFlags, RequestMetadata, add_unix_socket_flag, check_response_redirection,
|
||||
expand_unix_socket_path, http_client, http_client_pool, http_parse_redirect_mode,
|
||||
http_parse_url, request_add_authorization_header, request_add_custom_headers,
|
||||
request_handle_response, request_set_timeout, send_request,
|
||||
request_handle_response, request_set_host_header, request_set_timeout, send_request,
|
||||
};
|
||||
use nu_engine::command_prelude::*;
|
||||
use nu_protocol::shell_error::generic::GenericError;
|
||||
@@ -223,7 +223,7 @@ fn helper(
|
||||
) -> Result<PipelineData, ShellError> {
|
||||
let span = args.url.span();
|
||||
let Spanned {
|
||||
item: (requested_url, _),
|
||||
item: requested_url,
|
||||
span: request_span,
|
||||
} = http_parse_url(call, span, args.url)?;
|
||||
let redirect_mode = http_parse_redirect_mode(args.redirect)?;
|
||||
@@ -247,6 +247,7 @@ fn helper(
|
||||
request = request_set_timeout(args.timeout, request)?;
|
||||
request = request_add_authorization_header(args.user, args.password, request);
|
||||
request = request_add_custom_headers(args.headers, request)?;
|
||||
request = request_set_host_header(&requested_url, request);
|
||||
|
||||
let (response, request_headers) = send_request(
|
||||
engine_state,
|
||||
|
||||
@@ -2,7 +2,7 @@ use crate::network::http::client::{
|
||||
HttpBody, RequestFlags, RequestMetadata, add_unix_socket_flag, check_response_redirection,
|
||||
expand_unix_socket_path, http_client, http_client_pool, http_parse_redirect_mode,
|
||||
http_parse_url, request_add_authorization_header, request_add_custom_headers,
|
||||
request_handle_response, request_set_timeout, send_request,
|
||||
request_handle_response, request_set_host_header, request_set_timeout, send_request,
|
||||
};
|
||||
use nu_engine::command_prelude::*;
|
||||
use nu_protocol::shell_error::generic::GenericError;
|
||||
@@ -247,7 +247,7 @@ fn helper(
|
||||
) -> Result<PipelineData, ShellError> {
|
||||
let span = args.url.span();
|
||||
let Spanned {
|
||||
item: (requested_url, _),
|
||||
item: requested_url,
|
||||
span: request_span,
|
||||
} = http_parse_url(call, span, args.url)?;
|
||||
let redirect_mode = http_parse_redirect_mode(args.redirect)?;
|
||||
@@ -271,6 +271,7 @@ fn helper(
|
||||
request = request_set_timeout(args.timeout, request)?;
|
||||
request = request_add_authorization_header(args.user, args.password, request);
|
||||
request = request_add_custom_headers(args.headers, request)?;
|
||||
request = request_set_host_header(&requested_url, request);
|
||||
|
||||
let (response, request_headers) = send_request(
|
||||
engine_state,
|
||||
|
||||
@@ -2,7 +2,7 @@ use crate::network::http::client::{
|
||||
HttpBody, RequestFlags, RequestMetadata, add_unix_socket_flag, check_response_redirection,
|
||||
expand_unix_socket_path, http_client, http_client_pool, http_parse_redirect_mode,
|
||||
http_parse_url, request_add_authorization_header, request_add_custom_headers,
|
||||
request_handle_response, request_set_timeout, send_request,
|
||||
request_handle_response, request_set_host_header, request_set_timeout, send_request,
|
||||
};
|
||||
use nu_engine::command_prelude::*;
|
||||
use nu_protocol::shell_error::generic::GenericError;
|
||||
@@ -228,7 +228,7 @@ fn helper(
|
||||
) -> Result<PipelineData, ShellError> {
|
||||
let span = args.url.span();
|
||||
let Spanned {
|
||||
item: (requested_url, _),
|
||||
item: requested_url,
|
||||
span: request_span,
|
||||
} = http_parse_url(call, span, args.url)?;
|
||||
let redirect_mode = http_parse_redirect_mode(args.redirect)?;
|
||||
@@ -252,6 +252,7 @@ fn helper(
|
||||
request = request_set_timeout(args.timeout, request)?;
|
||||
request = request_add_authorization_header(args.user, args.password, request);
|
||||
request = request_add_custom_headers(args.headers, request)?;
|
||||
request = request_set_host_header(&requested_url, request);
|
||||
|
||||
let (response, request_headers) = send_request(
|
||||
engine_state,
|
||||
|
||||
@@ -20,7 +20,18 @@ impl Resolver for DnsLookupResolver {
|
||||
config: &Config,
|
||||
_timeout: NextTimeout,
|
||||
) -> Result<ResolvedSocketAddrs, ureq::Error> {
|
||||
let host = uri.host();
|
||||
// `uri.host()` returns IPv6 addresses wrapped in square brackets (e.g.
|
||||
// `[fe80::13%eth0]`), which `getaddrinfo` cannot resolve. Strip the brackets
|
||||
// so that zone IDs (e.g. `%eth0`) are handled correctly by the resolver.
|
||||
// RFC 6874 encodes the zone ID separator as `%25`; `getaddrinfo` expects a
|
||||
// literal `%`, so decode it as well.
|
||||
let host = uri.host().map(|h| {
|
||||
let h = h
|
||||
.strip_prefix('[')
|
||||
.and_then(|inner| inner.strip_suffix(']'))
|
||||
.unwrap_or(h);
|
||||
h.replacen("%25", "%", 1)
|
||||
});
|
||||
// Determine the port: use explicit port if provided, otherwise derive from scheme
|
||||
let port = uri.port_u16().unwrap_or_else(|| match uri.scheme_str() {
|
||||
Some("https") => 443,
|
||||
@@ -30,7 +41,7 @@ impl Resolver for DnsLookupResolver {
|
||||
// Pass None as service to avoid "Service not supported for this socket type" errors
|
||||
// in certain environments (e.g., Docker containers on some Linux distributions).
|
||||
// We'll set the port manually on each resolved address.
|
||||
let addr_info_iter = dns_lookup::getaddrinfo(host, None, None)
|
||||
let addr_info_iter = dns_lookup::getaddrinfo(host.as_deref(), None, None)
|
||||
.map_err(|err| ureq::Error::Other(Box::new(LookupError(err))))?;
|
||||
|
||||
let ip_family = config.ip_family();
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use mockito::Server;
|
||||
use mockito::{Server, ServerOpts};
|
||||
use nu_protocol::shell_error;
|
||||
use nu_test_support::prelude::*;
|
||||
use std::{thread, time::Duration};
|
||||
@@ -168,6 +168,27 @@ fn http_get_follows_redirect() -> Result {
|
||||
.expect_value_eq("bar")
|
||||
}
|
||||
|
||||
// IPv6 literals are wrapped in brackets in URLs (e.g. `http://[::1]:8080/`).
|
||||
// `url::Url` handles the brackets, but the resolver used to pass the bracketed
|
||||
// host to `getaddrinfo`, which cannot resolve it. This exercises the resolver
|
||||
// and the `text/plain` extension lookup with a real request to an IPv6 literal.
|
||||
#[test]
|
||||
fn http_get_ipv6_literal() -> Result {
|
||||
let mut server = Server::new_with_opts(ServerOpts {
|
||||
host: "::1",
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
let _mock = server
|
||||
.mock("GET", "/a.txt")
|
||||
.with_header("content-type", "text/plain")
|
||||
.with_body("hello")
|
||||
.create();
|
||||
|
||||
let code = format!("http get {url}/a.txt", url = server.url());
|
||||
test().run(code).expect_value_eq("hello")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn http_get_redirect_mode_manual() -> Result {
|
||||
let mut server = Server::new();
|
||||
|
||||
Reference in new issue
Block a user