From bb4ce6e71565ca6344965c6c0f9985658c006b71 Mon Sep 17 00:00:00 2001 From: Ahmed TAHRI Date: Tue, 22 Oct 2024 18:17:09 +0200 Subject: [PATCH] improve error messages upon invalid args/values in new flags --- httpie/client.py | 56 +++++++++++++++++++++++++++- tests/test_h2n3.py | 37 +++++++++++++++++++ tests/test_network.py | 84 ++++++++++++++++++++++++++++++++++++++++++ tests/test_resolver.py | 34 +++++++++++++++++ 4 files changed, 209 insertions(+), 2 deletions(-) diff --git a/httpie/client.py b/httpie/client.py index 63c5bd7262..a2ede7bb38 100644 --- a/httpie/client.py +++ b/httpie/client.py @@ -9,6 +9,7 @@ from time import monotonic from typing import Any, Dict, Callable, Iterable from urllib.parse import urlparse, urlunparse +import ipaddress import niquests @@ -71,12 +72,38 @@ def collect_messages( source_address = None if args.interface: + # automatically raises ValueError upon invalid IP + ipaddress.ip_address(args.interface) + source_address = (args.interface, 0) if args.local_port: + if '-' not in args.local_port: - source_address = (args.interface or "0.0.0.0", int(args.local_port)) + try: + parsed_port = int(args.local_port) + except ValueError: + raise ValueError(f'"{args.local_port}" is not a valid port number.') + + source_address = (args.interface or "0.0.0.0", parsed_port) else: - min_port, max_port = args.local_port.split('-', 1) + if args.local_port.count('-') != 1: + raise ValueError(f'"{args.local_port}" is not a valid port range. i.e. we accept value like "25441-65540".') + + try: + min_port, max_port = args.local_port.split('-', 1) + except ValueError: + raise ValueError(f'The port range you gave in input "{args.local_port}" is not a valid range.') + + if min_port == "": + raise ValueError("Negative port number are all invalid values.") + if max_port == "": + raise ValueError('Port range requires both start and end ports to be specified. e.g. "25441-65540".') + + try: + min_port, max_port = int(min_port), int(max_port) + except ValueError: + raise ValueError(f'Either "{min_port}" or/and "{max_port}" is an invalid port number.') + source_address = (args.interface or "0.0.0.0", randint(int(min_port), int(max_port))) parsed_url = parse_url(args.url) @@ -91,6 +118,20 @@ def collect_messages( else: resolver = [ensure_resolver, "system://"] + force_opt_count = [args.force_http1, args.force_http2, args.force_http3].count(True) + disable_opt_count = [args.disable_http1, args.disable_http2, args.disable_http3].count(True) + + if force_opt_count > 1: + raise ValueError( + 'You may only force one of --http1, --http2 or --http3. Use --disable-http1, ' + '--disable-http2 or --disable-http3 instead if you prefer the excluding logic.' + ) + elif force_opt_count == 1 and disable_opt_count: + raise ValueError( + 'You cannot both force a http protocol version and disable some other. e.g. ' + '--http2 already force HTTP/2, do not use --disable-http1 at the same time.' + ) + if args.force_http1: args.disable_http1 = False args.disable_http2 = True @@ -245,11 +286,22 @@ def build_requests_session( if quic_cache is not None: requests_session.quic_cache_layer = QuicCapabilityCache(quic_cache) + if urllib3.util.connection.HAS_IPV6 is False and disable_ipv4 is True: + raise ValueError('Unable to force IPv6 because your system lack IPv6 support.') + if disable_ipv4 and disable_ipv6: + raise ValueError('Unable to force both IPv4 and IPv6, omit the flags to allow both. The flags "-6" and "-4" are meant to force one of them.') + if resolver: resolver_rebuilt = [] for r in resolver: # assume it is the in-memory resolver if "://" not in r: + if ":" not in r or r.count(':') != 1: + raise ValueError("The manual resolver for a specific host requires to be formatted like 'hostname:ip'. e.g. 'pie.dev:1.1.1.1'.") + hostname, override_ip = r.split(':') + if hostname.strip() == "" or override_ip.strip() == "": + raise ValueError("The manual resolver for a specific host requires to be formatted like 'hostname:ip'. e.g. 'pie.dev:1.1.1.1'.") + ipaddress.ip_address(override_ip) r = f"in-memory://default/?hosts={r}" resolver_rebuilt.append(r) resolver = resolver_rebuilt diff --git a/tests/test_h2n3.py b/tests/test_h2n3.py index 44d8444650..b22a1cd819 100644 --- a/tests/test_h2n3.py +++ b/tests/test_h2n3.py @@ -46,6 +46,43 @@ def test_force_http3(remote_httpbin_secure): assert HTTP_OK in r +def test_force_multiple_error(remote_httpbin_secure): + r = http( + "--verify=no", + '--http3', + '--http2', + remote_httpbin_secure + '/get', + tolerate_error_exit_status=True, + ) + + assert 'You may only force one of --http1, --http2 or --http3.' in r.stderr + + +def test_disable_all_error_https(remote_httpbin_secure): + r = http( + "--verify=no", + '--disable-http1', + '--disable-http2', + '--disable-http3', + remote_httpbin_secure + '/get', + tolerate_error_exit_status=True, + ) + + assert 'You disabled every supported protocols.' in r.stderr + + +def test_disable_all_error_http(remote_httpbin): + r = http( + "--verify=no", + '--disable-http1', + '--disable-http2', + remote_httpbin + '/get', + tolerate_error_exit_status=True, + ) + + assert 'No compatible protocol are enabled to emit request. You currently are connected using TCP Unencrypted and must have HTTP/1.1 or/and HTTP/2 enabled to pursue.' in r.stderr + + @pytest.fixture def with_quic_cache_persistent(tmp_path): env = PersistentMockEnvironment() diff --git a/tests/test_network.py b/tests/test_network.py index 0b5624d4e0..0085e8469c 100644 --- a/tests/test_network.py +++ b/tests/test_network.py @@ -38,6 +38,90 @@ def test_ensure_interface_and_port_parameters(httpbin): assert HTTP_OK in r +def test_invalid_interface_given(httpbin): + r = http( + "--interface=10.25.a.u", # invalid IP + httpbin + "/get", + tolerate_error_exit_status=True, + ) + + assert "'10.25.a.u' does not appear to be an IPv4 or IPv6 address" in r.stderr + + r = http( + "--interface=abc", # invalid IP + httpbin + "/get", + tolerate_error_exit_status=True, + ) + + assert "'abc' does not appear to be an IPv4 or IPv6 address" in r.stderr + + +def test_invalid_local_port_given(httpbin): + r = http( + "--local-port=127.0.0.1", # invalid port + httpbin + "/get", + tolerate_error_exit_status=True, + ) + + assert '"127.0.0.1" is not a valid port number.' in r.stderr + + r = http( + "--local-port=a8", # invalid port + httpbin + "/get", + tolerate_error_exit_status=True, + ) + + assert '"a8" is not a valid port number.' in r.stderr + + r = http( + "--local-port=-8", # invalid port + httpbin + "/get", + tolerate_error_exit_status=True, + ) + + assert 'Negative port number are all invalid values.' in r.stderr + + r = http( + "--local-port=a-8", # invalid port range + httpbin + "/get", + tolerate_error_exit_status=True, + ) + + assert 'Either "a" or/and "8" is an invalid port number.' in r.stderr + + r = http( + "--local-port=5555-", # invalid port range + httpbin + "/get", + tolerate_error_exit_status=True, + ) + + assert 'Port range requires both start and end ports to be specified.' in r.stderr + + +def test_force_ipv6_on_unsupported_system(remote_httpbin): + from httpie.compat import urllib3 + urllib3.util.connection.HAS_IPV6 = False + r = http( + "-6", # invalid port + remote_httpbin + "/get", + tolerate_error_exit_status=True, + ) + urllib3.util.connection.HAS_IPV6 = True + + assert 'Unable to force IPv6 because your system lack IPv6 support.' in r.stderr + + +def test_force_both_ipv6_and_ipv4(remote_httpbin): + r = http( + "-6", # force IPv6 + "-4", # force IPv4 + remote_httpbin + "/get", + tolerate_error_exit_status=True, + ) + + assert 'Unable to force both IPv4 and IPv6, omit the flags to allow both.' in r.stderr + + def test_happy_eyeballs(remote_httpbin_secure): r = http( "--heb", # this will automatically and concurrently try IPv6 and IPv4 endpoints diff --git a/tests/test_resolver.py b/tests/test_resolver.py index eebd0cf0ca..f3a6bc6e05 100644 --- a/tests/test_resolver.py +++ b/tests/test_resolver.py @@ -32,3 +32,37 @@ def test_ensure_override_resolver_used(remote_httpbin): ) assert "Request timed out" in r.stderr or "A socket operation was attempted to an unreachable network" in r.stderr + + +def test_invalid_override_resolver(): + r = http( + "--resolver=pie.dev:abc", # we do this nonsense on purpose + "pie.dev/get", + tolerate_error_exit_status=True + ) + + assert "'abc' does not appear to be an IPv4 or IPv6 address" in r.stderr + + r = http( + "--resolver=abc", # we do this nonsense on purpose + "pie.dev/get", + tolerate_error_exit_status=True + ) + + assert "The manual resolver for a specific host requires to be formatted like" in r.stderr + + r = http( + "--resolver=pie.dev:127.0.0", # we do this nonsense on purpose + "pie.dev/get", + tolerate_error_exit_status=True + ) + + assert "'127.0.0' does not appear to be an IPv4 or IPv6 address" in r.stderr + + r = http( + "--resolver=doz://example.com", # we do this nonsense on purpose + "pie.dev/get", + tolerate_error_exit_status=True + ) + + assert "'doz' is not a valid ProtocolResolver" in r.stderr