Make all module_utils and plugin_utils private (#887)

* Add leading underscore. Remove deprecated module utils.

* Document module and plugin utils as private. Add changelog fragment.

* Convert relative to absolute imports.

* Remove unnecessary imports.
This commit is contained in:
Felix Fontein
2025-05-11 19:17:58 +02:00
committed by GitHub
parent f758d94fba
commit a5a4e022ba
146 changed files with 678 additions and 465 deletions

View File

@@ -0,0 +1,123 @@
# Copyright (c) 2020, Jordan Borean <jborean93@gmail.com>
# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt)
# SPDX-License-Identifier: GPL-3.0-or-later
from __future__ import annotations
import base64
import re
import subprocess
import pytest
from ansible_collections.community.crypto.plugins.module_utils._crypto._asn1 import (
serialize_asn1_string_as_der,
)
TEST_CASES: list[tuple[str, bytes]] = [
("UTF8:Hello World", b"\x0c\x0b\x48\x65\x6c\x6c\x6f\x20\x57\x6f\x72\x6c\x64"),
(
"EXPLICIT:10,UTF8:Hello World",
b"\xaa\x0d\x0c\x0b\x48\x65\x6c\x6c\x6f\x20\x57\x6f\x72\x6c\x64",
),
(
"EXPLICIT:12U,UTF8:Hello World",
b"\x0c\x0b\x48\x65\x6c\x6c\x6f\x20\x57\x6f\x72\x6c\x64",
),
(
"EXPLICIT:10A,UTF8:Hello World",
b"\x6a\x0d\x0c\x0b\x48\x65\x6c\x6c\x6f\x20\x57\x6f\x72\x6c\x64",
),
(
"EXPLICIT:10P,UTF8:Hello World",
b"\xea\x0d\x0c\x0b\x48\x65\x6c\x6c\x6f\x20\x57\x6f\x72\x6c\x64",
),
(
"EXPLICIT:10C,UTF8:Hello World",
b"\xaa\x0d\x0c\x0b\x48\x65\x6c\x6c\x6f\x20\x57\x6f\x72\x6c\x64",
),
(
"EXPLICIT:1024P,UTF8:Hello World",
b"\xff\x88\x00\x0d\x0c\x0b\x48\x65\x6c\x6c\x6f\x20\x57\x6f\x72\x6c\x64",
),
(
"IMPLICIT:10,UTF8:Hello World",
b"\x8a\x0b\x48\x65\x6c\x6c\x6f\x20\x57\x6f\x72\x6c\x64",
),
(
"IMPLICIT:12U,UTF8:Hello World",
b"\x0c\x0b\x48\x65\x6c\x6c\x6f\x20\x57\x6f\x72\x6c\x64",
),
(
"IMPLICIT:10A,UTF8:Hello World",
b"\x4a\x0b\x48\x65\x6c\x6c\x6f\x20\x57\x6f\x72\x6c\x64",
),
(
"IMPLICIT:10P,UTF8:Hello World",
b"\xca\x0b\x48\x65\x6c\x6c\x6f\x20\x57\x6f\x72\x6c\x64",
),
(
"IMPLICIT:10C,UTF8:Hello World",
b"\x8a\x0b\x48\x65\x6c\x6c\x6f\x20\x57\x6f\x72\x6c\x64",
),
(
"IMPLICIT:1024P,UTF8:Hello World",
b"\xdf\x88\x00\x0b\x48\x65\x6c\x6c\x6f\x20\x57\x6f\x72\x6c\x64",
),
# Tests large data lengths, special logic for the length octet encoding.
("UTF8:" + ("A" * 600), b"\x0c\x82\x02\x58" + (b"\x41" * 600)),
# This isn't valid with openssl asn1parse but has been validated against an ASN.1 parser. OpenSSL seems to read the
# data u"café" encoded as UTF-8 bytes b"caf\xc3\xa9", decodes that internally with latin-1 (or similar variant) as
# u"café" then encodes that to UTF-8 b"caf\xc3\x83\xc2\xa9" for the UTF8String. Ultimately openssl is wrong here
# so we keep our assertion happening.
("UTF8:café", b"\x0c\x05\x63\x61\x66\xc3\xa9"),
]
@pytest.mark.parametrize("value, expected", TEST_CASES)
def test_serialize_asn1_string_as_der(value: str, expected: bytes) -> None:
actual = serialize_asn1_string_as_der(value)
print(f"{value} | {base64.b16encode(actual).decode()}")
assert actual == expected
@pytest.mark.parametrize(
"value",
[
"invalid",
"EXPLICIT,UTF:value",
],
)
def test_serialize_asn1_string_as_der_invalid_format(value: str) -> None:
expected = (
"The ASN.1 serialized string must be in the format [modifier,]type[:value]"
)
with pytest.raises(ValueError, match=re.escape(expected)):
serialize_asn1_string_as_der(value)
def test_serialize_asn1_string_as_der_invalid_type() -> None:
expected = 'The ASN.1 serialized string is not a known type "OID", only UTF8 types are supported'
with pytest.raises(ValueError, match=re.escape(expected)):
serialize_asn1_string_as_der("OID:1.2.3.4")
@pytest.mark.skip() # This is to just to build the test case assertions and shouldn't run normally.
@pytest.mark.parametrize("value, expected", TEST_CASES)
def test_test_cases(value: str, expected: bytes, tmp_path) -> None:
test_file = tmp_path / "test.der"
subprocess.run(
["openssl", "asn1parse", "-genstr", value, "-noout", "-out", test_file],
check=True,
)
with open(test_file, mode="rb") as fd:
b_data = fd.read()
hex_str = base64.b16encode(b_data).decode().lower()
value = "\\x".join([hex_str[i : i + 2] for i in range(0, len(hex_str), 2)])
print(f"{value} | \\x{value}")
# This is a know edge case where openssl asn1parse does not work properly.
if value != "UTF8:café":
assert b_data == expected

View File

@@ -0,0 +1,259 @@
# Copyright (c) 2020, Jordan Borean <jborean93@gmail.com>
# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt)
# SPDX-License-Identifier: GPL-3.0-or-later
from __future__ import annotations
import re
import typing as t
import cryptography
import pytest
from ansible_collections.community.crypto.plugins.module_utils._crypto.basic import (
OpenSSLObjectError,
)
from ansible_collections.community.crypto.plugins.module_utils._crypto.cryptography_support import (
_adjust_idn,
_parse_dn,
_parse_dn_component,
cryptography_get_name,
)
from ansible_collections.community.crypto.plugins.module_utils._version import (
LooseVersion,
)
from cryptography.x509 import NameAttribute, OtherName, oid
@pytest.mark.parametrize(
"unicode, idna, cycled_unicode",
[
("..", "..", None),
("foo.com", "foo.com", None),
(".foo.com.", ".foo.com.", None),
("*.foo.com", "*.foo.com", None),
("straße", "xn--strae-oqa", None),
("ffóò.ḃâŗ.çøṁ", "xn--ff-3jad.xn--2ca8uh37e.xn--7ca8a981n", "ffóò.ḃâŗ.çøṁ"),
("*.☺.", "*.xn--74h.", None),
],
)
def test_adjust_idn(unicode: str, idna: str, cycled_unicode: str | None) -> None:
if cycled_unicode is None:
cycled_unicode = unicode
result = _adjust_idn(unicode, "ignore")
print(result, unicode)
assert result == unicode
result = _adjust_idn(idna, "ignore")
print(result, idna)
assert result == idna
result = _adjust_idn(unicode, "unicode")
print(result, unicode)
assert result == unicode
result = _adjust_idn(idna, "unicode")
print(result, cycled_unicode)
assert result == cycled_unicode
result = _adjust_idn(unicode, "idna")
print(result, idna)
assert result == idna
result = _adjust_idn(idna, "idna")
print(result, idna)
assert result == idna
@pytest.mark.parametrize(
"value, idn_rewrite, message",
[
("bar", "foo", re.escape('Invalid value for idn_rewrite: "foo"')),
],
)
def test_adjust_idn_fail_valueerror(value: str, idn_rewrite: str, message: str) -> None:
with pytest.raises(ValueError, match=message):
idn_rewrite_: t.Literal["ignore", "idna", "unicode"] = idn_rewrite # type: ignore
_adjust_idn(value, idn_rewrite_)
@pytest.mark.parametrize(
"value, idn_rewrite, message",
[
(
"xn--a",
"unicode",
"""^Error while transforming part u?"xn\\-\\-a" of IDNA DNS name u?"xn\\-\\-a" to Unicode\\."""
""" IDNA2008 transformation resulted in "Codepoint U\\+0080 at position 1 of u?'\\\\x80' not allowed","""
""" IDNA2003 transformation resulted in "(decoding with 'idna' codec failed"""
""" \\(UnicodeError: |'idna' codec can't decode byte 0x78 in position 0: )?Invalid character u?'\\\\x80'\\)?"\\.$""",
),
],
)
def test_adjust_idn_fail_user_error(value: str, idn_rewrite: str, message: str) -> None:
with pytest.raises(OpenSSLObjectError, match=message):
idn_rewrite_: t.Literal["ignore", "idna", "unicode"] = idn_rewrite # type: ignore
_adjust_idn(value, idn_rewrite_)
def test_cryptography_get_name_invalid_prefix() -> None:
with pytest.raises(
OpenSSLObjectError, match="^Cannot parse Subject Alternative Name"
):
cryptography_get_name("fake:value")
def test_cryptography_get_name_other_name_no_oid() -> None:
with pytest.raises(
OpenSSLObjectError, match="Cannot parse Subject Alternative Name otherName"
):
cryptography_get_name("otherName:value")
def test_cryptography_get_name_other_name_utfstring() -> None:
actual = cryptography_get_name("otherName:1.3.6.1.4.1.311.20.2.3;UTF8:Hello World")
assert isinstance(actual, OtherName)
assert actual.type_id.dotted_string == "1.3.6.1.4.1.311.20.2.3"
assert actual.value == b"\x0c\x0bHello World"
@pytest.mark.parametrize(
"name, options, expected",
[
(b"CN=x ", {}, (NameAttribute(oid.NameOID.COMMON_NAME, "x "), b"")),
(b"CN=\\ ", {}, (NameAttribute(oid.NameOID.COMMON_NAME, " "), b"")),
(b"CN=\\#", {}, (NameAttribute(oid.NameOID.COMMON_NAME, "#"), b"")),
(b"CN=#402032", {}, (NameAttribute(oid.NameOID.COMMON_NAME, "@ 2"), b"")),
(b"CN = x ", {}, (NameAttribute(oid.NameOID.COMMON_NAME, "x "), b"")),
(b"CN = x\\, ", {}, (NameAttribute(oid.NameOID.COMMON_NAME, "x, "), b"")),
(b"CN = x\\40 ", {}, (NameAttribute(oid.NameOID.COMMON_NAME, "x@ "), b"")),
(
b"CN = \\ , / ",
{},
(NameAttribute(oid.NameOID.COMMON_NAME, " "), b", / "),
),
(
b"CN = \\ , / ",
{"sep": b"/"},
(NameAttribute(oid.NameOID.COMMON_NAME, " , "), b"/ "),
),
(
b"CN = \\ , / ",
{"decode_remainder": False},
(NameAttribute(oid.NameOID.COMMON_NAME, "\\ , / "), b""),
),
# Some examples from https://datatracker.ietf.org/doc/html/rfc4514#section-4:
(
b'CN=James \\"Jim\\" Smith\\, III',
{},
(NameAttribute(oid.NameOID.COMMON_NAME, 'James "Jim" Smith, III'), b""),
),
(
b"CN=Before\\0dAfter",
{},
(NameAttribute(oid.NameOID.COMMON_NAME, "Before\x0dAfter"), b""),
),
(
b"1.3.6.1.4.1.1466.0=#04024869",
{},
(
NameAttribute(oid.ObjectIdentifier("1.3.6.1.4.1.1466.0"), "\x04\x02Hi"),
b"",
),
),
(
b"CN=Lu\\C4\\8Di\\C4\\87",
{},
(NameAttribute(oid.NameOID.COMMON_NAME, "Lučić"), b""),
),
],
)
def test_parse_dn_component(
name: bytes, options: dict[str, t.Any], expected: tuple[NameAttribute, bytes]
) -> None:
result = _parse_dn_component(name, **options)
print(result, expected)
assert result == expected
# Cryptography < 2.9 does not allow empty strings
# (https://github.com/pyca/cryptography/commit/87b2749c52e688c809f1861e55d958c64147493c)
# Cryptoraphy 43.0.0+ also doesn't allow this anymore
if (
LooseVersion("2.9")
<= LooseVersion(cryptography.__version__)
< LooseVersion("43.0.0")
):
@pytest.mark.parametrize(
"name, options, expected",
[
(b"CN=", {}, (NameAttribute(oid.NameOID.COMMON_NAME, ""), b"")),
(b"CN= ", {}, (NameAttribute(oid.NameOID.COMMON_NAME, ""), b"")),
],
)
def test_parse_dn_component_not_py26(
name: bytes, options: dict[str, t.Any], expected: tuple[NameAttribute, bytes]
) -> None:
result = _parse_dn_component(name, **options)
print(result, expected)
assert result == expected
@pytest.mark.parametrize(
"name, options, message",
[
(b"CN=\\0", {}, 'Hex escape sequence "\\0" incomplete at end of string'),
(b"CN=\\0,", {}, 'Hex escape sequence "\\0," has invalid second letter'),
(b"CN=#0,", {}, 'Invalid hex sequence entry "0,"'),
],
)
def test_parse_dn_component_failure(
name: bytes, options: dict[str, t.Any], message: str
) -> None:
with pytest.raises(OpenSSLObjectError, match=f"^{re.escape(message)}$"):
_parse_dn_component(name, **options)
@pytest.mark.parametrize(
"name, expected",
[
(b"CN=foo", [NameAttribute(oid.NameOID.COMMON_NAME, "foo")]),
(
b"CN=foo,CN=bar",
[
NameAttribute(oid.NameOID.COMMON_NAME, "foo"),
NameAttribute(oid.NameOID.COMMON_NAME, "bar"),
],
),
(
b"CN = foo , CN = bar",
[
NameAttribute(oid.NameOID.COMMON_NAME, "foo "),
NameAttribute(oid.NameOID.COMMON_NAME, "bar"),
],
),
],
)
def test_parse_dn(name: bytes, expected: list[NameAttribute]) -> None:
result = _parse_dn(name)
print(result, expected)
assert result == expected
@pytest.mark.parametrize(
"name, message",
[
(
b"CN=\\0",
"Error while parsing distinguished name 'CN=\\\\0': Hex escape sequence \"\\0\" incomplete at end of string",
),
(
b"CN=x,",
"Error while parsing distinguished name 'CN=x,': unexpected end of string",
),
],
)
def test_parse_dn_failure(name: bytes, message: str):
with pytest.raises(OpenSSLObjectError, match=f"^{re.escape(message)}$"):
_parse_dn(name)

View File

@@ -0,0 +1,131 @@
# Copyright (c) 2024, Felix Fontein <felix@fontein.de>
# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt)
# SPDX-License-Identifier: GPL-3.0-or-later
from __future__ import annotations
import pytest
from ansible_collections.community.crypto.plugins.module_utils._crypto.math import (
binary_exp_mod,
convert_bytes_to_int,
convert_int_to_bytes,
convert_int_to_hex,
quick_is_not_prime,
simple_gcd,
)
@pytest.mark.parametrize(
"f, e, m, result",
[
(0, 0, 5, 1),
(0, 1, 5, 0),
(2, 1, 5, 2),
(2, 2, 5, 4),
(2, 3, 5, 3),
(2, 10, 5, 4),
],
)
def test_binary_exp_mod(f: int, e: int, m: int, result: int) -> None:
value = binary_exp_mod(f, e, m)
print(value)
assert value == result
@pytest.mark.parametrize(
"a, b, result",
[
(0, -123, -123),
(0, 123, 123),
(-123, 0, -123),
(123, 0, 123),
(-123, 1, 1),
(123, 1, 1),
(1, -123, -1),
(1, 123, 1),
(1024, 10, 2),
],
)
def test_simple_gcd(a: int, b: int, result: int) -> None:
value = simple_gcd(a, b)
print(value)
assert value == result
@pytest.mark.parametrize(
"n, result",
[
(-2, True),
(0, True),
(1, True),
(2, False),
(3, False),
(4, True),
(5, False),
(6, True),
(7, False),
(8, True),
(9, True),
(10, True),
(211, False), # the smallest prime number >= 200
],
)
def test_quick_is_not_prime(n: int, result: bool) -> None:
value = quick_is_not_prime(n)
print(value)
assert value == result
@pytest.mark.parametrize(
"no, count, result",
[
(0, None, b""),
(0, 1, b"\x00"),
(0, 2, b"\x00\x00"),
(1, None, b"\x01"),
(1, 2, b"\x00\x01"),
(255, None, b"\xff"),
(256, None, b"\x01\x00"),
],
)
def test_convert_int_to_bytes(no: int, count: int | None, result: bytes) -> None:
value = convert_int_to_bytes(no, count=count)
print(value)
assert value == result
@pytest.mark.parametrize(
"no, digits, result",
[
(0, None, "0"),
(1, None, "1"),
(16, None, "10"),
(1, 3, "001"),
(255, None, "ff"),
(256, None, "100"),
(256, 2, "100"),
(256, 3, "100"),
(256, 4, "0100"),
],
)
def test_convert_int_to_hex(no: int, digits: int | None, result: str) -> None:
value = convert_int_to_hex(no, digits=digits)
print(value)
assert value == result
@pytest.mark.parametrize(
"data, result",
[
(b"", 0),
(b"\x00", 0),
(b"\x00\x01", 1),
(b"\x01", 1),
(b"\xff", 255),
(b"\x01\x00", 256),
],
)
def test_convert_bytes_to_int(data: bytes, result: int) -> None:
value = convert_bytes_to_int(data)
print(value)
assert value == result

View File

@@ -0,0 +1,72 @@
# Copyright (c) 2023, Felix Fontein <felix@fontein.de>
# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt)
# SPDX-License-Identifier: GPL-3.0-or-later
from __future__ import annotations
import typing as t
import pytest
from ansible_collections.community.crypto.plugins.module_utils._crypto.pem import (
extract_first_pem,
identify_pem_format,
identify_private_key_format,
split_pem_list,
)
PEM_TEST_CASES: list[
tuple[bytes, list[str], bool, t.Literal["raw", "pkcs1", "pkcs8", "unknown-pem"]]
] = [
(b"", [], False, "raw"),
(b"random stuff\nblabla", [], False, "raw"),
(b"-----BEGIN PRIVATE KEY-----", [], False, "raw"),
(
b"-----BEGIN PRIVATE KEY-----\n-----END PRIVATE KEY-----",
["-----BEGIN PRIVATE KEY-----\n-----END PRIVATE KEY-----"],
True,
"pkcs8",
),
(
b"foo=bar\n# random stuff\n-----BEGIN RSA PRIVATE KEY-----\nblabla\n-----END RSA PRIVATE KEY-----\nmore stuff\n",
["-----BEGIN RSA PRIVATE KEY-----\nblabla\n-----END RSA PRIVATE KEY-----\n"],
True,
"pkcs1",
),
(
b"foo=bar\n# random stuff\n-----BEGIN CERTIFICATE-----\nblabla\n-----END CERTIFICATE-----\nmore stuff\n"
b"\n-----BEGIN CERTIFICATE-----\nfoobar\n-----END CERTIFICATE-----",
[
"-----BEGIN CERTIFICATE-----\nblabla\n-----END CERTIFICATE-----\n",
"-----BEGIN CERTIFICATE-----\nfoobar\n-----END CERTIFICATE-----",
],
True,
"unknown-pem",
),
(
b"-----BEGINCERTIFICATE-----\n-----BEGIN CERTIFICATE-----\n-----BEGINCERTIFICATE-----\n-----END CERTIFICATE-----\n-----BEGINCERTIFICATE-----\n",
[
"-----BEGIN CERTIFICATE-----\n-----BEGINCERTIFICATE-----\n-----END CERTIFICATE-----\n",
],
True,
"unknown-pem",
),
]
@pytest.mark.parametrize("data, pems, is_pem, private_key_type", PEM_TEST_CASES)
def test_pem_handling(
data: bytes,
pems: list[str],
is_pem: bool,
private_key_type: t.Literal["raw", "pkcs1", "pkcs8", "unknown-pem"],
):
assert identify_pem_format(data) == is_pem
assert identify_private_key_format(data) == private_key_type
try:
text = data.decode("utf-8")
assert split_pem_list(text) == pems
first_pem = pems[0] if pems else None
assert extract_first_pem(text) == first_pem
except UnicodeDecodeError:
pass