Skip to content

Commit

Permalink
Vendor python-hostname
Browse files Browse the repository at this point in the history
Will be needed for validating the docker image string hostname, if one
exists
  • Loading branch information
MrCreosote committed Aug 9, 2024
1 parent 2f0966a commit b526b37
Show file tree
Hide file tree
Showing 7 changed files with 266 additions and 1 deletion.
2 changes: 1 addition & 1 deletion cdmtaskservice/git_commit.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,4 @@

# This file will be overwritten during the Docker build to contain the current git commit.

GIT_COMMIT = "fake_commit"
GIT_COMMIT = "fake_commit"

Check warning on line 7 in cdmtaskservice/git_commit.py

View check run for this annotation

Codecov / codecov/patch

cdmtaskservice/git_commit.py#L7

Added line #L7 was not covered by tests
11 changes: 11 additions & 0 deletions cdmtaskservice/vendored/hostname/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# Vendored `python-hostname` package

This is vendored code from the [python-hostname](https://github.com/jpgoldberg/python-hostname)
repo, commit 461d80edbad4d58f71968fbdb7884df28d78c7c2, version 0.0.5.

There is no pypi release for this package, so vendoring seems like the easiest way to include it
in the codebase. As such, imports have been modified to work in the vendored namespace.

If the package is ever released on PyPi, this vendoring should be obliterated.

There is currently no licensing information for the package.
2 changes: 2 additions & 0 deletions cdmtaskservice/vendored/hostname/__about__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
# Our version number source of truth for both docs and project
__version__ = "0.0.5"
185 changes: 185 additions & 0 deletions cdmtaskservice/vendored/hostname/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
from typing import Any, TypeGuard

import cdmtaskservice.vendored.hostname.exception as exc

import dns.name
import dns.exception

from . import __about__ # noqa: F401


class Hostname(str):
"""A string that is also a valid hostname."""

_dnsname: dns.name.Name
_flags: dict[str, bool]
_labels: list[bytes]

DEFAULT_FLAGS: dict[str, bool] = {
"allow_underscore": False,
"allow_empty": False,
"allow_idna": True,
}

def __new__(cls, value: Any, **kwargs: bool) -> "Hostname":
# explicitly only pass value to the str constructor
return str.__new__(cls, value)

def __init__(self, candidate: str, **kwargs: bool):
"""
Initializes a Hostname if candidate is valid
"""
if not isinstance(candidate, str):
raise exc.NotAStringError

self._flags = self.DEFAULT_FLAGS.copy()
for k, v in kwargs.items():
if k not in self.DEFAULT_FLAGS:
raise TypeError(f'Unknown option "{k}"')
if not isinstance(v, bool):
raise TypeError(f'"{k}" must be True or False')
self._flags[k] = v

# Ideally, I'd be able to separate initializating from validation,
# but I need the dns.Name check before I can create IDNA encoded labels
# If I call idna early, i can lose information on why a hostname fails.
self._validate_hostname()

def _validate_hostname(self) -> None:
"""raises exception if self isn't valid. Also sets _labels"""

if not (self.isascii() or self._flags["allow_idna"]):
raise exc.NotASCIIError

try:
self._dnsname = dns.name.from_text(self)
except dns.exception.DNSException as e:
raise exc.DomainNameException(dns_exception=e) from e

# We need a mutatable list of the labels to deal with root
self._labels: list[bytes] = [e for e in self._dnsname.labels]
# Exclude the root "" label for our checks
if self._labels[-1] == b"":
self._labels.pop()

# Reject empty hostname unless allowed
if len(self._labels) == 0:
if not self._flags["allow_empty"]:
raise exc.NoLabelError
else:
return

for idx, label in enumerate(self._labels):
self._validate_label(idx, label)

def _validate_label(self, index: int, label: bytes) -> bool:
"""For a valid dns label, s, is valid hostname label.
Raises exeptions of various failures
"""

# Valid dns labels are already ASCII and meet length
# requirements.
#
# Labels are composed of letters, digits and hyphens
# A hyphen cannot be either the first or the last
# character.
#
# Note that it is very easy to the the regular expression
# for RFC 952 wrong. And if we can avoid importing the
# regular expression package all together, that is even
# better.
#
# I am taking this from Bob Halley's suggestion on GitHub
#
# https://github.com/rthalley/dnspython/issues/1019#issuecomment-1837247696
#
# Remember kids, don't play with character ranges this
# way unless you have already checked that you are using
# 7-bit ASCII.

# We will be legitimately be making some ASCII assumptions
LOWER_A = ord("a")
LOWER_Z = ord("z")
UPPER_A = ord("A")
UPPER_Z = ord("Z")
DIGIT_0 = ord("0")
DIGIT_9 = ord("9")
HYPHEN = ord("-")
UNDERSCORE = ord("_")
NO_SUCH_BYTE = -1

first: bool = True if index == 0 else False
last: bool = True if index == len(self._labels) - 1 else False

# Even allow_underscore only allows it in the first label
# underHack will be the ord value for the underscore when that is
# allowed or an int that will never be a byte.
if self._flags["allow_underscore"] and first:
underHack = UNDERSCORE
else:
underHack = NO_SUCH_BYTE

# Last (most significant) label cannot be all digits
if last:
if all(b >= DIGIT_0 and b <= DIGIT_9 for b in label):
raise exc.DigitOnlyError

# Hyphens cannot be at start or end of label
if label.startswith(b"-") or label.endswith(b"-"):
raise exc.BadHyphenError

for c in label:
if not (
(c >= LOWER_A and c <= LOWER_Z)
or (c >= UPPER_A and c <= UPPER_Z)
or (c >= DIGIT_0 and c <= DIGIT_9)
or c == HYPHEN
or c == underHack
):
if c == UNDERSCORE:
raise exc.UnderscoreError
else:
raise exc.BadCharacterError

return True

@property
def dnsname(self) -> dns.name.Name:
"""Returns a :class:`dns.name.Name`."""
return self._dnsname

@property
def flags(self) -> dict[str, bool]:
"""Returns the flags used when validating this hostname"""
return self._flags

@property
def labels(self) -> list[bytes]:
"""Returns a list of labels, ordered from leftmost to rightmost.
Returned list never includes the DNS root label ``""``.
If you want the full DNS labels use :func:`dnsname()`
and use the :attr:`dns.name.Name.labels` for that list.
"""

return self._labels


def is_hostname(candidate: Any, **kwargs: bool) -> TypeGuard[Hostname]:
"""retruns True iff candidate is a standards complient Internet hostname.
:rtype: bool
``**kwargs`` can be any of
- :code:`allow_idna` (default :py:const:`True`),
- ``allow_underscore`` (default |False|),
- ``allow_empty`` (default |False|)
"""

try:
Hostname(candidate, **kwargs)
except exc.HostnameException:
return False
return True
64 changes: 64 additions & 0 deletions cdmtaskservice/vendored/hostname/exception.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
# This is intended to wrap dns.exception

import dns.exception
import dns.name


class HostnameException(dns.exception.DNSException):
"""A generic exception abstraction"""

def __init__(self, *args, **kwargs) -> None: # type: ignore[no-untyped-def]
super().__init__(*args, **kwargs) # type: ignore[no-untyped-call]


class UnderscoreError(HostnameException):
"""An underscore appeared in a label it shouldn't have."""


class NotASCIIError(HostnameException):
"""Non-ASCII when ALLOW_IDNA is not set"""


class NotAStringError(HostnameException):
"""Must be a string"""


class BadCharacterError(HostnameException):
"""A forbidden character was found in a label"""


class DigitOnlyError(HostnameException):
"""The rightmost label contains only digits"""


class NoLabelError(HostnameException):
"""Hostnames must have at least 1 label"""


class BadHyphenError(HostnameException):
"""A hyphen is used at the beginning or end of a label"""


# Looking at how dnspython handles INDA exceptions and doing
# that here wrt to DNS errors
class DomainNameException(HostnameException):
"""DNS Parsing raised an exception"""

supp_kwargs = {"dns_exception"}
fmt = "DNS syntax error: {dns_exception}"

def __init__(self, *args, **kwargs) -> None: # type: ignore[no-untyped-def]
super().__init__(*args, **kwargs)


# lifted from dnspython.dns.exception
class INDAException(HostnameException):
"""IDNA processing raised an exception."""

supp_kwargs = {"idna_exception"}
fmt = "IDNA processing exception: {idna_exception}"

# We do this as otherwise mypy complains about unexpected keyword argument
# idna_exception
def __init__(self, *args, **kwargs) -> None: # type: ignore[no-untyped-def]
super().__init__(*args, **kwargs)
Empty file.
3 changes: 3 additions & 0 deletions codecov.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
ignore:
- "cdmtaskservice/vendored"

0 comments on commit b526b37

Please sign in to comment.