Move port and interface validation to the CLI layer

This commit is contained in:
Jakub Roztocil 2024-10-26 18:55:26 +02:00
parent 0eab08a655
commit 4cea2e80af
6 changed files with 201 additions and 158 deletions

View File

@ -259,7 +259,7 @@ PARSED_DEFAULT_FORMAT_OPTIONS = parse_format_options(
) )
def response_charset_type(encoding: str) -> str: def response_charset_arg_type(encoding: str) -> str:
try: try:
''.encode(encoding) ''.encode(encoding)
except LookupError: except LookupError:
@ -268,8 +268,17 @@ def response_charset_type(encoding: str) -> str:
return encoding return encoding
def response_mime_type(mime_type: str) -> str: def response_mime_arg_type(mime_type: str) -> str:
if mime_type.count('/') != 1: if mime_type.count('/') != 1:
raise argparse.ArgumentTypeError( raise argparse.ArgumentTypeError(
f'{mime_type!r} doesnt look like a mime type; use type/subtype') f'{mime_type!r} doesnt look like a mime type; use type/subtype')
return mime_type return mime_type
def interface_arg_type(interface: str) -> str:
import ipaddress
try:
ipaddress.ip_interface(interface)
except ValueError as e:
raise argparse.ArgumentTypeError(str(e))
return interface

View File

@ -5,30 +5,50 @@ import textwrap
from argparse import FileType from argparse import FileType
from httpie import __doc__, __version__ from httpie import __doc__, __version__
from httpie.cli.argtypes import (KeyValueArgType, SessionNameValidator, from httpie.output.formatters.colors import (
SSLCredentials, readable_file_arg, AUTO_STYLE,
response_charset_type, response_mime_type) BUNDLED_STYLES,
from httpie.cli.constants import (BASE_OUTPUT_OPTIONS, DEFAULT_FORMAT_OPTIONS, DEFAULT_STYLE,
OUT_REQ_BODY, OUT_REQ_HEAD, OUT_RESP_BODY, get_available_styles,
OUT_RESP_HEAD, OUT_RESP_META, OUTPUT_OPTIONS, )
OUTPUT_OPTIONS_DEFAULT, PRETTY_MAP,
PRETTY_STDOUT_TTY_ONLY,
SEPARATOR_GROUP_ALL_ITEMS, SEPARATOR_PROXY,
SORTED_FORMAT_OPTIONS_STRING,
UNSORTED_FORMAT_OPTIONS_STRING, RequestType)
from httpie.cli.options import ParserSpec, Qualifiers, to_argparse
from httpie.output.formatters.colors import (AUTO_STYLE, DEFAULT_STYLE, BUNDLED_STYLES,
get_available_styles)
from httpie.plugins.builtin import BuiltinAuthPlugin from httpie.plugins.builtin import BuiltinAuthPlugin
from httpie.plugins.registry import plugin_manager from httpie.plugins.registry import plugin_manager
from httpie.ssl_ import AVAILABLE_SSL_VERSION_ARG_MAPPING, DEFAULT_SSL_CIPHERS_STRING from httpie.ssl_ import AVAILABLE_SSL_VERSION_ARG_MAPPING, DEFAULT_SSL_CIPHERS_STRING
from .argtypes import (
KeyValueArgType,
SSLCredentials,
SessionNameValidator,
interface_arg_type,
readable_file_arg,
response_charset_arg_type,
response_mime_arg_type,
)
from .constants import (
BASE_OUTPUT_OPTIONS,
DEFAULT_FORMAT_OPTIONS,
OUTPUT_OPTIONS,
OUTPUT_OPTIONS_DEFAULT,
OUT_REQ_BODY,
OUT_REQ_HEAD,
OUT_RESP_BODY,
OUT_RESP_HEAD,
OUT_RESP_META,
PRETTY_MAP,
PRETTY_STDOUT_TTY_ONLY,
RequestType,
SEPARATOR_GROUP_ALL_ITEMS,
SEPARATOR_PROXY,
SORTED_FORMAT_OPTIONS_STRING,
UNSORTED_FORMAT_OPTIONS_STRING,
)
from .options import ParserSpec, Qualifiers, to_argparse
from .ports import local_port_arg_type
# Man pages are static (built when making a release). # Man pages are static (built when making a release).
# We use this check to not include generated, system-specific information there (e.g., default --ciphers). # We use this check to not include generated, system-specific information there (e.g., default --ciphers).
IS_MAN_PAGE = bool(os.environ.get('HTTPIE_BUILDING_MAN_PAGES')) IS_MAN_PAGE = bool(os.environ.get('HTTPIE_BUILDING_MAN_PAGES'))
options = ParserSpec( options = ParserSpec(
'http', 'http',
description=f'{__doc__.strip()} <https://httpie.io>', description=f'{__doc__.strip()} <https://httpie.io>',
@ -349,7 +369,7 @@ output_processing.add_argument(
output_processing.add_argument( output_processing.add_argument(
'--response-charset', '--response-charset',
metavar='ENCODING', metavar='ENCODING',
type=response_charset_type, type=response_charset_arg_type,
short_help='Override the response encoding for terminal display purposes.', short_help='Override the response encoding for terminal display purposes.',
help=""" help="""
Override the response encoding for terminal display purposes, e.g.: Override the response encoding for terminal display purposes, e.g.:
@ -362,7 +382,7 @@ output_processing.add_argument(
output_processing.add_argument( output_processing.add_argument(
'--response-mime', '--response-mime',
metavar='MIME_TYPE', metavar='MIME_TYPE',
type=response_mime_type, type=response_mime_arg_type,
short_help='Override the response mime type for coloring and formatting for the terminal.', short_help='Override the response mime type for coloring and formatting for the terminal.',
help=""" help="""
Override the response mime type for coloring and formatting for the terminal, e.g.: Override the response mime type for coloring and formatting for the terminal, e.g.:
@ -894,12 +914,14 @@ network.add_argument(
) )
network.add_argument( network.add_argument(
"--interface", "--interface",
default=None, type=interface_arg_type,
default='0.0.0.0',
short_help="Bind to a specific network interface.", short_help="Bind to a specific network interface.",
) )
network.add_argument( network.add_argument(
"--local-port", "--local-port",
default=None, type=local_port_arg_type,
default=0,
short_help="Set the local port to be used for the outgoing request.", short_help="Set the local port to be used for the outgoing request.",
help=""" help="""
It can be either a port range (e.g. "11221-14555") or a single port. It can be either a port range (e.g. "11221-14555") or a single port.

50
httpie/cli/ports.py Normal file
View File

@ -0,0 +1,50 @@
import argparse
from random import randint
from typing import Tuple
MIN_PORT = 0
MAX_PORT = 65535
OUTSIDE_VALID_PORT_RANGE_ERROR = f'outside valid port range {MIN_PORT}-{MAX_PORT}'
def local_port_arg_type(port: str) -> int:
port = parse_local_port_arg(port)
if isinstance(port, tuple):
port = randint(*port)
return port
def parse_local_port_arg(port: str) -> int | Tuple[int, int]:
if '-' in port[1:]: # Dont treat negative port as range.
return _clean_port_range(port)
return _clean_port(port)
def _clean_port_range(port_range: str) -> Tuple[int, int]:
"""
We allow two digits separated by a hyphen to represent a port range.
The parsing is done so that even negative numbers get parsed correctly, allowing us to
give a more specific outside-range error message.
"""
sep_pos = port_range.find('-', 1)
start, end = port_range[:sep_pos], port_range[sep_pos + 1:]
start = _clean_port(start)
end = _clean_port(end)
if start > end:
raise argparse.ArgumentTypeError(f'{port_range!r} is not a valid port range')
return start, end
def _clean_port(port: str) -> int:
try:
port = int(port)
except ValueError:
raise argparse.ArgumentTypeError(f'{port!r} is not a number')
if not (MIN_PORT <= port <= MAX_PORT):
raise argparse.ArgumentTypeError(
f'{port!r} is {OUTSIDE_VALID_PORT_RANGE_ERROR}'
)
return port

View File

@ -5,7 +5,6 @@ import json
import sys import sys
import typing import typing
from pathlib import Path from pathlib import Path
from random import randint
from time import monotonic from time import monotonic
from typing import Any, Dict, Callable, Iterable from typing import Any, Dict, Callable, Iterable
from urllib.parse import urlparse, urlunparse from urllib.parse import urlparse, urlunparse
@ -68,48 +67,10 @@ def collect_messages(
) )
send_kwargs = make_send_kwargs(args) send_kwargs = make_send_kwargs(args)
send_kwargs_mergeable_from_env = make_send_kwargs_mergeable_from_env(args) send_kwargs_mergeable_from_env = make_send_kwargs_mergeable_from_env(args)
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:
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:
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) parsed_url = parse_url(args.url)
resolver = args.resolver or None resolver = args.resolver or None
# we want to make sure every ".localhost" host resolve to loopback # We want to make sure every ".localhost" host resolve to loopback
if parsed_url.host and parsed_url.host.endswith(".localhost"): if parsed_url.host and parsed_url.host.endswith(".localhost"):
ensure_resolver = f"in-memory://default/?hosts={parsed_url.host}:127.0.0.1&hosts={parsed_url.host}:[::1]" ensure_resolver = f"in-memory://default/?hosts={parsed_url.host}:127.0.0.1&hosts={parsed_url.host}:[::1]"
@ -157,7 +118,7 @@ def collect_messages(
resolver=resolver, resolver=resolver,
disable_ipv6=args.ipv4, disable_ipv6=args.ipv4,
disable_ipv4=args.ipv6, disable_ipv4=args.ipv6,
source_address=source_address, source_address=(args.interface, args.local_port),
quic_cache=env.config.quic_file, quic_cache=env.config.quic_file,
happy_eyeballs=args.happy_eyeballs, happy_eyeballs=args.happy_eyeballs,
) )

View File

@ -32,9 +32,9 @@ class TestBinaryRequestData:
class TestBinaryResponseData: class TestBinaryResponseData:
"""local httpbin crash due to an unfixed bug. # Local httpbin crashes due to an unfixed bug — it is merged but not yet released.
See https://github.com/psf/httpbin/pull/41 # <https://github.com/psf/httpbin/pull/41>
It is merged but not yet released.""" # TODO: switch to the local `httpbin` fixture when the fix is released.
def test_binary_suppresses_when_terminal(self, remote_httpbin): def test_binary_suppresses_when_terminal(self, remote_httpbin):
r = http('GET', remote_httpbin + '/bytes/1024?seed=1') r = http('GET', remote_httpbin + '/bytes/1024?seed=1')

View File

@ -1,121 +1,122 @@
import argparse
import pytest
from httpie.cli.ports import (
MAX_PORT,
MIN_PORT,
OUTSIDE_VALID_PORT_RANGE_ERROR,
local_port_arg_type,
parse_local_port_arg,
)
from .utils import HTTP_OK, http from .utils import HTTP_OK, http
def test_ensure_interface_parameter(httpbin): def test_non_existent_interface_arg(httpbin):
"""We ensure that HTTPie properly wire interface by passing an interface that """We ensure that HTTPie properly wire interface by passing an interface that does not exist. thus, we expect an error."""
does not exist. thus, we expect an error."""
r = http( r = http(
"--interface=1.1.1.1", '--interface=1.1.1.1',
httpbin + "/get", httpbin + '/get',
tolerate_error_exit_status=True tolerate_error_exit_status=True
) )
assert r.exit_status != 0 assert r.exit_status != 0
assert "assign requested address" in r.stderr or "The requested address is not valid in its context" in r.stderr assert (
'assign requested address' in r.stderr
or 'The requested address is not valid in its context' in r.stderr
)
def test_ensure_local_port_parameter(httpbin): @pytest.mark.parametrize(['local_port_arg', 'expected_output'], [
"""We ensure that HTTPie properly wire local-port by passing a port that # Single ports — valid
does not exist. thus, we expect an error.""" ('0', 0),
('-0', 0),
(str(MAX_PORT), MAX_PORT),
('8000', 8000),
# Single ports — invalid
(f'{MIN_PORT - 1}', OUTSIDE_VALID_PORT_RANGE_ERROR),
(f'{MAX_PORT + 1}', OUTSIDE_VALID_PORT_RANGE_ERROR),
('-', 'not a number'),
('AAA', 'not a number'),
(' ', 'not a number'),
# Port ranges — valid
(f'{MIN_PORT}-{MAX_PORT}', (MIN_PORT, MAX_PORT)),
('3000-8000', (3000, 8000)),
('-0-8000', (0, 8000)),
('0-0', (0, 0)),
# Port ranges — invalid
(f'2-1', 'not a valid port range'),
(f'2-', 'not a number'),
(f'2-A', 'not a number'),
(f'A-A', 'not a number'),
(f'A-2', 'not a number'),
(f'-10-1', OUTSIDE_VALID_PORT_RANGE_ERROR),
(f'1--1', OUTSIDE_VALID_PORT_RANGE_ERROR),
(f'-10--1', OUTSIDE_VALID_PORT_RANGE_ERROR),
(f'1-{MAX_PORT + 1}', OUTSIDE_VALID_PORT_RANGE_ERROR),
])
def test_parse_local_port_arg(local_port_arg, expected_output):
expected_error = expected_output if isinstance(expected_output, str) else None
if not expected_error:
assert parse_local_port_arg(local_port_arg) == expected_output
else:
with pytest.raises(argparse.ArgumentTypeError, match=expected_error):
parse_local_port_arg(local_port_arg)
def test_local_port_arg_type():
assert local_port_arg_type('1') == 1
assert local_port_arg_type('1-1') == 1
assert local_port_arg_type('1-3') in {1, 2, 3}
def test_invoke_with_out_of_range_local_port_arg(httpbin):
# An addition to the unittest tests
r = http( r = http(
"--local-port=70000", '--local-port=70000',
httpbin + "/get", httpbin + '/get',
tolerate_error_exit_status=True tolerate_error_exit_status=True
) )
assert r.exit_status != 0 assert r.exit_status != 0
assert "port must be 0-65535" in r.stderr assert OUTSIDE_VALID_PORT_RANGE_ERROR in r.stderr
def test_ensure_interface_and_port_parameters(httpbin): @pytest.mark.parametrize('interface_arg', [
'',
'-',
'10.25.a.u',
'abc',
'localhost',
])
def test_invalid_interface_arg(httpbin, interface_arg):
r = http( r = http(
"--interface=0.0.0.0", # it's valid, setting 0.0.0.0 means "take the default" here. '--interface',
"--local-port=0", # this will automatically pick a free port in range 1024-65535 interface_arg,
httpbin + "/get", httpbin + '/get',
)
assert r.exit_status == 0
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, tolerate_error_exit_status=True,
) )
assert f"'{interface_arg}' does not appear to be an IPv4 or IPv6" in r.stderr
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): def test_force_ipv6_on_unsupported_system(remote_httpbin):
from httpie.compat import urllib3 from httpie.compat import urllib3
orig_has_ipv6 = urllib3.util.connection.HAS_IPV6
urllib3.util.connection.HAS_IPV6 = False urllib3.util.connection.HAS_IPV6 = False
r = http( try:
"-6", # invalid port r = http(
remote_httpbin + "/get", "-6", # invalid port
tolerate_error_exit_status=True, remote_httpbin + "/get",
) tolerate_error_exit_status=True,
urllib3.util.connection.HAS_IPV6 = True )
finally:
urllib3.util.connection.HAS_IPV6 = orig_has_ipv6
assert 'Unable to force IPv6 because your system lack IPv6 support.' in r.stderr assert 'Unable to force IPv6 because your system lack IPv6 support.' in r.stderr
def test_force_both_ipv6_and_ipv4(remote_httpbin): def test_force_both_ipv6_and_ipv4(remote_httpbin):
r = http( r = http(
"-6", # force IPv6 '-6', # force IPv6
"-4", # force IPv4 '-4', # force IPv4
remote_httpbin + "/get", remote_httpbin + '/get',
tolerate_error_exit_status=True, tolerate_error_exit_status=True,
) )
@ -124,9 +125,9 @@ def test_force_both_ipv6_and_ipv4(remote_httpbin):
def test_happy_eyeballs(remote_httpbin_secure): def test_happy_eyeballs(remote_httpbin_secure):
r = http( r = http(
"--heb", # this will automatically and concurrently try IPv6 and IPv4 endpoints '--heb', # this will automatically and concurrently try IPv6 and IPv4 endpoints
"--verify=no", '--verify=no',
remote_httpbin_secure + "/get", remote_httpbin_secure + '/get',
) )
assert r.exit_status == 0 assert r.exit_status == 0