diff --git a/lib/charms/operator_libs_linux/v0/apt.py b/lib/charms/operator_libs_linux/v0/apt.py index f36cc09f..a58dc44c 100644 --- a/lib/charms/operator_libs_linux/v0/apt.py +++ b/lib/charms/operator_libs_linux/v0/apt.py @@ -127,6 +127,7 @@ VALID_SOURCE_TYPES = ("deb", "deb-src") OPTIONS_MATCHER = re.compile(r"\[.*?\]") +_GPG_KEY_DIR = "/etc/apt/trusted.gpg.d/" class Error(Exception): @@ -894,7 +895,7 @@ def import_key(key: str) -> str: key_bytes = key.encode("utf-8") key_name = DebianRepository._get_keyid_by_gpg_key(key_bytes) key_gpg = DebianRepository._dearmor_gpg_key(key_bytes) - gpg_key_filename = "/etc/apt/trusted.gpg.d/{}.gpg".format(key_name) + gpg_key_filename = os.path.join(_GPG_KEY_DIR, "{}.gpg".format(key_name)) DebianRepository._write_apt_gpg_keyfile( key_name=gpg_key_filename, key_material=key_gpg ) @@ -915,7 +916,7 @@ def import_key(key: str) -> str: key_asc = DebianRepository._get_key_by_keyid(key) # write the key in GPG format so that apt-key list shows it key_gpg = DebianRepository._dearmor_gpg_key(key_asc.encode("utf-8")) - gpg_key_filename = "/etc/apt/trusted.gpg.d/{}.gpg".format(key) + gpg_key_filename = os.path.join(_GPG_KEY_DIR, "{}.gpg".format(key)) DebianRepository._write_apt_gpg_keyfile(key_name=gpg_key_filename, key_material=key_gpg) return gpg_key_filename @@ -1272,7 +1273,7 @@ class RepositoryMapping(Mapping[str, DebianRepository]): _sources_subdir = "sources.list.d" _default_list_name = "sources.list" _default_sources_name = "ubuntu.sources" - _last_errors: Iterable[Error] = () + _last_errors: Tuple[Error, ...] = () def __init__(self): self._repository_map: Dict[str, DebianRepository] = {} @@ -1537,7 +1538,7 @@ def _add_apt_repository( raise ValueError("{repo}.enabled is {value}".format(repo=repo, value=repo.enabled)) line = repo._to_line() # pyright: ignore[reportPrivateUsage] cmd = [ - "apt-add-repository", + "add-apt-repository", "--yes", "--sourceslist=" + line, ] @@ -1597,9 +1598,10 @@ def repositories(self) -> Tuple[DebianRepository, ...]: return self._repositories def get_gpg_key_filename(self) -> str: - """Return the path to the GPG key for this repository. + """Return the path to the GPG key for this stanza. Import the key first, if the key itself was provided in the stanza. + Return an empty string if no filename or key was provided. """ if self._gpg_key_filename: return self._gpg_key_filename diff --git a/tests/unit/data/individual-files/bad-stanza-components-missing-without-exact-path.sources b/tests/unit/data/individual-files/bad-stanza-components-missing-without-exact-path.sources new file mode 100644 index 00000000..a8d58999 --- /dev/null +++ b/tests/unit/data/individual-files/bad-stanza-components-missing-without-exact-path.sources @@ -0,0 +1,4 @@ +Types: deb +URIs: http://nz.archive.ubuntu.com/ubuntu/ +Suites: noble noble-updates noble-backports +# this is an error, must have at least one component if suites isn't an exact path \ No newline at end of file diff --git a/tests/unit/data/individual-files/bad-stanza-components-present-with-exact-path.sources b/tests/unit/data/individual-files/bad-stanza-components-present-with-exact-path.sources new file mode 100644 index 00000000..b0cd1ec7 --- /dev/null +++ b/tests/unit/data/individual-files/bad-stanza-components-present-with-exact-path.sources @@ -0,0 +1,4 @@ +Types: deb +URIs: http://nz.archive.ubuntu.com/ubuntu/ +Suites: an/exact/path/ +Components: main # this is an error, can't have components with an exact path \ No newline at end of file diff --git a/tests/unit/data/individual-files/bad-stanza-enabled-bad.sources b/tests/unit/data/individual-files/bad-stanza-enabled-bad.sources new file mode 100644 index 00000000..2d707b53 --- /dev/null +++ b/tests/unit/data/individual-files/bad-stanza-enabled-bad.sources @@ -0,0 +1,5 @@ +Types: deb +URIs: http://nz.archive.ubuntu.com/ubuntu/ +Suites: noble noble-updates noble-backports +Components: main restricted universe multiverse +Enabled: bad \ No newline at end of file diff --git a/tests/unit/data/individual-files/bad-stanza-missing-required-keys.sources b/tests/unit/data/individual-files/bad-stanza-missing-required-keys.sources new file mode 100644 index 00000000..6eca1805 --- /dev/null +++ b/tests/unit/data/individual-files/bad-stanza-missing-required-keys.sources @@ -0,0 +1,3 @@ +# a comment + +Foo: Bar # this is a separate (malformed) entry \ No newline at end of file diff --git a/tests/unit/data/individual-files/good-stanza-comments.sources b/tests/unit/data/individual-files/good-stanza-comments.sources new file mode 100644 index 00000000..1f4972ae --- /dev/null +++ b/tests/unit/data/individual-files/good-stanza-comments.sources @@ -0,0 +1,9 @@ +Components: main restricted universe multiverse # Components first! I don't think deb822 cares about ordering +Types: deb deb-src # this could be one or both of these options +URIs: http://nz.archive.ubuntu.com/ubuntu/ http://archive.ubuntu.com/ubuntu/ + # there can be multiple space separated URIs + # sources are specified in priority order + # apt does some de-duplication of sources after parsing too +#Signed-By: /usr/share/keyrings/ubuntu-archive-keyring.gpg +# let's make this insecure! (jk, just testing parsing) +Suites: noble noble-updates noble-backports \ No newline at end of file diff --git a/tests/unit/data/individual-files/good-stanza-enabled-no.sources b/tests/unit/data/individual-files/good-stanza-enabled-no.sources new file mode 100644 index 00000000..9c48d4e2 --- /dev/null +++ b/tests/unit/data/individual-files/good-stanza-enabled-no.sources @@ -0,0 +1,5 @@ +Types: deb +URIs: http://nz.archive.ubuntu.com/ubuntu/ +Suites: noble noble-updates noble-backports +Components: main restricted universe multiverse +Enabled: no \ No newline at end of file diff --git a/tests/unit/data/individual-files/good-stanza-exact-path.sources b/tests/unit/data/individual-files/good-stanza-exact-path.sources new file mode 100644 index 00000000..6b2a587a --- /dev/null +++ b/tests/unit/data/individual-files/good-stanza-exact-path.sources @@ -0,0 +1,3 @@ +Types: deb +URIs: http://nz.archive.ubuntu.com/ubuntu/ +Suites: an/exact/path/ \ No newline at end of file diff --git a/tests/unit/data/individual-files/good-stanza-noble-main-etc.sources b/tests/unit/data/individual-files/good-stanza-noble-main-etc.sources new file mode 100644 index 00000000..777a4f4c --- /dev/null +++ b/tests/unit/data/individual-files/good-stanza-noble-main-etc.sources @@ -0,0 +1,5 @@ +Types: deb +URIs: http://nz.archive.ubuntu.com/ubuntu/ +Suites: noble noble-updates noble-backports +Components: main restricted universe multiverse +Signed-By: /usr/share/keyrings/ubuntu-archive-keyring.gpg \ No newline at end of file diff --git a/tests/unit/data/individual-files/good-stanza-noble-security.sources b/tests/unit/data/individual-files/good-stanza-noble-security.sources new file mode 100644 index 00000000..c1dcc762 --- /dev/null +++ b/tests/unit/data/individual-files/good-stanza-noble-security.sources @@ -0,0 +1,5 @@ +Types: deb +URIs: http://security.ubuntu.com/ubuntu +Suites: noble-security +Components: main restricted universe multiverse +Signed-By: /usr/share/keyrings/ubuntu-archive-keyring.gpg \ No newline at end of file diff --git a/tests/unit/data/individual-files/good-stanza-with-gpg-key.sources b/tests/unit/data/individual-files/good-stanza-with-gpg-key.sources new file mode 100644 index 00000000..9bce7f16 --- /dev/null +++ b/tests/unit/data/individual-files/good-stanza-with-gpg-key.sources @@ -0,0 +1,34 @@ +Types: deb +URIs: https://ppa.launchpadcontent.net/inkscape.dev/stable/ubuntu/ +Suites: noble +Components: main +Signed-By: + -----BEGIN PGP PUBLIC KEY BLOCK----- + . + mQINBGY0ViQBEACsQsdIRXzyEkk38x2oDt1yQ/Kt3dsiDKJFNLbs/xiDHrgIW6cU + 1wZB0pfb3lCyG3/ZH5uvR0arCSHJvCvkdCFTqqkZndSA+/pXreCSLeP8CNawf/RM + 3cNbdJlE8jXzaX2qzSEC9FDNqu4cQHIHR7xMAbSCPW8rxKvRCWmkZccfndDuQyN2 + vg3b2x9DWKS3DBRffivglF3yT49OuLemG5qJHujKOmNJZ32JoRniIivsuk1CCS1U + NDK6xWkr13aNe056QhVAh2iPF6MRE85zail+mQxt4LAgl/aLR0JSDSpWkbQH7kbu + 5cJVan8nYF9HelVJ3QuMwdz3VQn4YVO2Wc8s0YfnNdQttYaUx3lz35Fct6CaOqjP + pgsZ4467lmB9ut74G+wDCQXmT232hsBkTz4D0DyVPB/ziBgGWUKti0AWNT/3kFww + 2VM/80XADaDz0Sc/Hzi/cr9ZrbW3drMwoVvKtfMtOT7FMbeuPRWZKYZnDbvNm62e + ToKVudE0ijfsksxbcHKmGdvWntCxlinp0i39Jfz6y54pppjmbHRQgasmqm2EQLfA + RUNW/zB7gX80KTUCGjcLOTPajBIN5ZfjVITetryAFjv7fnK0qpf2OpqwF832W4V/ + S3GZtErupPktYG77Z9jOUxcJeEGYjWbVlbit8mTKDRdQUXOeOO6TzB4RcwARAQAB + tCVMYXVuY2hwYWQgUFBBIGZvciBJbmtzY2FwZSBEZXZlbG9wZXJziQJOBBMBCgA4 + FiEEVr3/0vHJaz0VdeO4XJoLhs0vyzgFAmY0ViQCGwMFCwkIBwIGFQoJCAsCBBYC + AwECHgECF4AACgkQXJoLhs0vyzh3RBAAo7Hee8i2I4n03/iq58lqml/OVJH9ZEle + amk3e0wsiVS0QdT/zB8/AMVDB1obazBfrHKJP9Ck+JKH0uxaGRxYBohTbO3Y3sBO + qRHz5VLcFzuyk7AA53AZkNx8Zbv6D0O4JTCPDJn9Gwbd/PpnvJm9Ri+zEiVPhXNu + oSBryGM09un2Yvi0DA+ulouSKTy9dkbI1R7flPZ2M/mKT8Lk0n1pJu5FvgPC4E6R + PT0Njw9+k/iHtP76U4SqHJZZx2I/TGlXMD1memyTK4adWZeGLaAiFadsoeJsDoDE + MkHFxFkr9n0E4wJhRGgL0OxDWugJemkUvHbzXFNUaeX5Spw/aO7r1CtTh8lyqiom + 4ebAkURjESRFOFzcsM7nyQnmt2OgQkEShSL3NrDMkj1+3+FgQtd8sbeVpmpGBq87 + J3iq0YMsUysWq8DJSz4RnBTfeGlJWJkl3XxB9VbG3BqqbN9fRp+ccgZ51g5/DEA/ + q8jYS7Ur5bAlSoeA4M3SvKSlQM8+aT35dteFzejvo1N+2n0E0KoymuRsDBdzby0z + lJDKe244L5D6UPJo9YTmtE4kG/fGNZ5/JdRA+pbe7f/z84HVcJ3ziGdF/Nio/D8k + uFjZP2M/mxC7j+WnmKAruqmY+5vkAEqUPTobsloDjT3B+z0rzWk8FG/G5KFccsBO + 2ekz6IVTXVA= + =VF33 + -----END PGP PUBLIC KEY BLOCK----- diff --git a/tests/unit/data/individual-files/stanzas-fully-commented-out.sources b/tests/unit/data/individual-files/stanzas-fully-commented-out.sources new file mode 100644 index 00000000..791814d7 --- /dev/null +++ b/tests/unit/data/individual-files/stanzas-fully-commented-out.sources @@ -0,0 +1,13 @@ +#a fully commented out stanza +#Types: deb # with an inline comment too +#URIs: http://security.ubuntu.com/ubuntu +#Suites: noble-security +#Components: main restricted universe multiverse +#Signed-By: /usr/share/keyrings/ubuntu-archive-keyring.gpg + +#another fully commented out stanza +#Types: deb-src # with an inline comment too +#URIs: http://security.ubuntu.com/ubuntu +#Suites: noble-security +#Components: main restricted universe multiverse +#Signed-By: /usr/share/keyrings/ubuntu-archive-keyring.gpg \ No newline at end of file diff --git a/tests/unit/data/individual-files/stanzas-noble.sources b/tests/unit/data/individual-files/stanzas-noble.sources new file mode 100644 index 00000000..021ad293 --- /dev/null +++ b/tests/unit/data/individual-files/stanzas-noble.sources @@ -0,0 +1,11 @@ +Types: deb +URIs: http://nz.archive.ubuntu.com/ubuntu/ +Suites: noble noble-updates noble-backports +Components: main restricted universe multiverse +Signed-By: /usr/share/keyrings/ubuntu-archive-keyring.gpg + +Types: deb +URIs: http://security.ubuntu.com/ubuntu +Suites: noble-security +Components: main restricted universe multiverse +Signed-By: /usr/share/keyrings/ubuntu-archive-keyring.gpg \ No newline at end of file diff --git a/tests/unit/data/individual-files/stanzas-one-good-one-bad-comments.sources b/tests/unit/data/individual-files/stanzas-one-good-one-bad-comments.sources new file mode 100644 index 00000000..0b2195a5 --- /dev/null +++ b/tests/unit/data/individual-files/stanzas-one-good-one-bad-comments.sources @@ -0,0 +1,15 @@ +# a good entry that defines one repository +Types: deb +URIs: http://nz.archive.ubuntu.com/ubuntu/ +Suites: noble +Components: main + +Foo: Bar # this is a separate (malformed) entry + +# this is fully commented out and will be skipped entirely +#Types: deb +#URIs: http://security.ubuntu.com/ubuntu +#Suites: noble-security +#Components: main restricted universe multiverse +#Signed-By: /usr/share/keyrings/ubuntu-archive-keyring.gpg +## disable security updates while we're at it \ No newline at end of file diff --git a/tests/unit/test_deb822.py b/tests/unit/test_deb822.py index 3ee16337..1a2a2a8e 100644 --- a/tests/unit/test_deb822.py +++ b/tests/unit/test_deb822.py @@ -3,339 +3,369 @@ # pyright: reportPrivateUsage=false -import typing -import unittest +import itertools +import tempfile from pathlib import Path -from unittest.mock import ANY, mock_open, patch +from unittest.mock import patch +import pytest from charms.operator_libs_linux.v0 import apt TEST_DATA_DIR = Path(__file__).parent / "data" FAKE_APT_DIRS = TEST_DATA_DIR / "fake-apt-dirs" +SOURCES_DIR = TEST_DATA_DIR / "individual-files" + + +@pytest.fixture +def repo_mapping(): + with patch.object( + apt.RepositoryMapping, + "_apt_dir", + str(FAKE_APT_DIRS / "empty"), + ): + repository_mapping = apt.RepositoryMapping() + return repository_mapping + + +def test_init_no_files(): + with patch.object( + apt.RepositoryMapping, + "_apt_dir", + str(FAKE_APT_DIRS / "empty"), + ): + repository_mapping = apt.RepositoryMapping() + assert not repository_mapping._repository_map + + +def test_init_with_good_sources_list(): + with patch.object( + apt.RepositoryMapping, + "_apt_dir", + str(FAKE_APT_DIRS / "bionic"), + ): + repository_mapping = apt.RepositoryMapping() + assert repository_mapping._repository_map + + +def test_init_with_bad_sources_list_no_fallback(): + with patch.object( + apt.RepositoryMapping, + "_apt_dir", + str(FAKE_APT_DIRS / "noble-no-sources"), + ): + with pytest.raises(apt.InvalidSourceError): + apt.RepositoryMapping() + + +def test_init_with_bad_sources_list_fallback_ok(): + with patch.object( + apt.RepositoryMapping, + "_apt_dir", + str(FAKE_APT_DIRS / "noble"), + ): + repository_mapping = apt.RepositoryMapping() + assert repository_mapping._repository_map + + +def test_init_with_bad_ubuntu_sources(): + with patch.object( + apt.RepositoryMapping, + "_apt_dir", + str(FAKE_APT_DIRS / "noble-empty-sources"), + ): + with pytest.raises(apt.InvalidSourceError): + apt.RepositoryMapping() + + +def test_init_with_third_party_inkscape_source(): + with patch.object( + apt.RepositoryMapping, + "_apt_dir", + str(FAKE_APT_DIRS / "noble-with-inkscape"), + ): + repository_mapping = apt.RepositoryMapping() + assert repository_mapping._repository_map + + +def test_init_w_comments(): + with patch.object( + apt.RepositoryMapping, + "_apt_dir", + str(FAKE_APT_DIRS / "noble-with-comments-etc"), + ): + repository_mapping = apt.RepositoryMapping() + assert repository_mapping._repository_map + + +def test_deb822_format_equivalence(): + """Mock file opening to initialise a RepositoryMapping from deb822 and one-line-style. + + They should be equivalent with the sample data being used. + """ + with patch.object( + apt.RepositoryMapping, + "_apt_dir", + str(FAKE_APT_DIRS / "noble"), + ): + repos_deb822 = apt.RepositoryMapping() + + with patch.object( + apt.RepositoryMapping, + "_apt_dir", + str(FAKE_APT_DIRS / "noble-in-one-per-line-format"), + ): + repos_one_per_line = apt.RepositoryMapping() + + list_keys = sorted(repos_one_per_line._repository_map.keys()) + sources_keys = sorted(repos_deb822._repository_map.keys()) + assert sources_keys == list_keys + + for list_key, sources_key in zip(list_keys, sources_keys): + list_repo = repos_one_per_line[list_key] + sources_repo = repos_deb822[sources_key] + assert list_repo.enabled == sources_repo.enabled + assert list_repo.repotype == sources_repo.repotype + assert list_repo.uri == sources_repo.uri + assert list_repo.release == sources_repo.release + assert list_repo.groups == sources_repo.groups + assert list_repo.gpg_key == sources_repo.gpg_key + assert ( + list_repo.options # pyright: ignore[reportUnknownMemberType] + == sources_repo.options # pyright: ignore[reportUnknownMemberType] + ) -ubuntu_sources_deb822 = """ -Types: deb -URIs: http://nz.archive.ubuntu.com/ubuntu/ -Suites: noble noble-updates noble-backports -Components: main restricted universe multiverse -Signed-By: /usr/share/keyrings/ubuntu-archive-keyring.gpg - -Types: deb -URIs: http://security.ubuntu.com/ubuntu -Suites: noble-security -Components: main restricted universe multiverse -Signed-By: /usr/share/keyrings/ubuntu-archive-keyring.gpg -""" - -ubuntu_sources_deb822_with_comments = """ -Components: main restricted universe multiverse # this lib doesn't care about order -Types: deb # this could include deb-src as well or instead -URIs: http://nz.archive.ubuntu.com/ubuntu/ - # there can be multiple space separated URIs - # sources are specified in priority order - # apt does some de-duplication of sources after parsing too -#Signed-By: /usr/share/keyrings/ubuntu-archive-keyring.gpg -# let's make this insecure! (jk, just testing parsing) -Suites: noble noble-updates noble-backports - -Foo: Bar # this is a separate (malformed) entry - -#Types: deb -#URIs: http://security.ubuntu.com/ubuntu -#Suites: noble-security -#Components: main restricted universe multiverse -#Signed-By: /usr/share/keyrings/ubuntu-archive-keyring.gpg -## disable security updates while we're at it -""" - -ubuntu_sources_one_line = """ -deb [signed-by=/usr/share/keyrings/ubuntu-archive-keyring.gpg] http://nz.archive.ubuntu.com/ubuntu/ noble main restricted universe multiverse -deb [signed-by=/usr/share/keyrings/ubuntu-archive-keyring.gpg] http://nz.archive.ubuntu.com/ubuntu/ noble-updates main restricted universe multiverse -deb [signed-by=/usr/share/keyrings/ubuntu-archive-keyring.gpg] http://nz.archive.ubuntu.com/ubuntu/ noble-backports main restricted universe multiverse -deb [signed-by=/usr/share/keyrings/ubuntu-archive-keyring.gpg] http://security.ubuntu.com/ubuntu noble-security main restricted universe multiverse -""" - - -class TestRepositoryMappingDeb822Behaviour(unittest.TestCase): - def test_iter_deb822_paragraphs_ubuntu_sources(self): - lines = ubuntu_sources_deb822.strip().split("\n") - stanzas = list(apt._iter_deb822_stanzas(lines)) - assert len(stanzas) == 2 - stanza_1, stanza_2 = stanzas - assert len(stanza_1) == 5 - assert len(stanza_2) == 5 - line_numbers = [n for stanza in stanzas for n, _line in stanza] - assert len(set(line_numbers)) == len(line_numbers) # unique line numbers - - def test_iter_deb822_paragraphs_ubuntu_sources_w_comments(self): - lines = ubuntu_sources_deb822_with_comments.strip().split("\n") - stanzas = list(apt._iter_deb822_stanzas(lines)) - assert len(stanzas) == 2 - stanza_1, stanza_2 = stanzas - assert len(stanza_1) == 4 - assert len(stanza_2) == 1 - line_numbers = [n for stanza in stanzas for n, _line in stanza] - assert len(set(line_numbers)) == len(line_numbers) # unique line numbers - - def test_get_deb822_options_ubuntu_sources(self): - lines = ubuntu_sources_deb822.strip().split("\n") - paras = list(apt._iter_deb822_stanzas(lines)) - opts = [apt._deb822_stanza_to_options(p) for p in paras] - opts_0, opts_1 = opts - opts_0_options, _opts_0_line_numbers = opts_0 - opts_1_options, _opts_1_line_numbers = opts_1 - assert opts_0_options == { - "Types": "deb", - "URIs": "http://nz.archive.ubuntu.com/ubuntu/", - "Components": "main restricted universe multiverse", - "Suites": "noble noble-updates noble-backports", - "Signed-By": "/usr/share/keyrings/ubuntu-archive-keyring.gpg", - } - assert opts_1_options == { - "Types": "deb", - "URIs": "http://security.ubuntu.com/ubuntu", - "Components": "main restricted universe multiverse", - "Suites": "noble-security", - "Signed-By": "/usr/share/keyrings/ubuntu-archive-keyring.gpg", - } - - def test_get_deb822_options_w_comments(self): - lines = ubuntu_sources_deb822_with_comments.strip().split("\n") - paras = list(apt._iter_deb822_stanzas(lines)) - opts = [apt._deb822_stanza_to_options(p) for p in paras] - opts_0, opts_1 = opts - opts_0_options, _opts_0_line_numbers = opts_0 - opts_1_options, _opts_1_line_numbers = opts_1 - assert opts_0_options == { - "Components": "main restricted universe multiverse", - "Types": "deb", - "URIs": "http://nz.archive.ubuntu.com/ubuntu/", - "Suites": "noble noble-updates noble-backports", - } - assert opts_1_options == {"Foo": "Bar"} - - def test_parse_deb822_paragraph_ubuntu_sources(self): - lines = ubuntu_sources_deb822.strip().split("\n") - main, security = apt._iter_deb822_stanzas(lines) - repos = apt._Deb822Stanza(main).repositories - assert len(repos) == 3 - for repo, suite in zip(repos, ("noble", "noble-updates", "noble-backports")): - assert repo.enabled - assert repo.repotype == "deb" - assert repo.uri == "http://nz.archive.ubuntu.com/ubuntu/" - assert repo.release == suite - assert repo.groups == ["main", "restricted", "universe", "multiverse"] - assert repo.filename == "" - assert repo.gpg_key == "/usr/share/keyrings/ubuntu-archive-keyring.gpg" - repos = apt._Deb822Stanza(security).repositories - assert len(repos) == 1 - [repo] = repos - assert repo.enabled - assert repo.repotype == "deb" - assert repo.uri == "http://security.ubuntu.com/ubuntu" - assert repo.release == "noble-security" - assert repo.groups == ["main", "restricted", "universe", "multiverse"] - assert repo.filename == "" - assert repo.gpg_key == "/usr/share/keyrings/ubuntu-archive-keyring.gpg" +def test_load_deb822_missing_components(repo_mapping: apt.RepositoryMapping): + with pytest.raises(apt.InvalidSourceError): + repo_mapping.load_deb822( + str(SOURCES_DIR / "bad-stanza-components-missing-without-exact-path.sources") + ) + assert len(repo_mapping._last_errors) == 1 + [error] = repo_mapping._last_errors + assert isinstance(error, apt.MissingRequiredKeyError) + assert error.key == "Components" - def test_parse_deb822_paragraph_w_comments(self): - lines = ubuntu_sources_deb822_with_comments.strip().split("\n") - ok_stanza, bad_stanza = apt._iter_deb822_stanzas(lines) - repos = apt._Deb822Stanza(ok_stanza).repositories - assert len(repos) == 3 - for repo, suite in zip(repos, ("noble", "noble-updates", "noble-backports")): - assert repo.enabled - assert repo.repotype == "deb" - assert repo.uri == "http://nz.archive.ubuntu.com/ubuntu/" - assert repo.release == suite - assert repo.groups == ["main", "restricted", "universe", "multiverse"] - assert repo.filename == "" - assert repo.gpg_key == "" - with self.assertRaises(apt.InvalidSourceError): - apt._Deb822Stanza(bad_stanza) - - def test_parse_deb822_lines_ubuntu_sources(self): - lines = ubuntu_sources_deb822.strip().split("\n") - repos, errors = apt.RepositoryMapping._parse_deb822_lines(lines) - assert len(repos) == 4 - assert not errors - - def test_parse_deb822_lines_w_comments(self): - lines = ubuntu_sources_deb822_with_comments.strip().split("\n") - repos, errors = apt.RepositoryMapping._parse_deb822_lines(lines) - assert len(repos) == 3 - assert len(errors) == 1 - [error] = errors - assert isinstance(error, apt.InvalidSourceError) - - def test_init_no_files(self): - with patch.object( - apt.RepositoryMapping, - "_apt_dir", - str(FAKE_APT_DIRS / "empty"), - ): - repository_mapping = apt.RepositoryMapping() - assert not repository_mapping._repository_map - def test_init_with_good_sources_list(self): - with patch.object( - apt.RepositoryMapping, - "_apt_dir", - str(FAKE_APT_DIRS / "bionic"), - ): - repository_mapping = apt.RepositoryMapping() - assert repository_mapping._repository_map +def test_load_deb822_components_with_exact_path(repo_mapping: apt.RepositoryMapping): + with pytest.raises(apt.InvalidSourceError): + repo_mapping.load_deb822( + str(SOURCES_DIR / "bad-stanza-components-present-with-exact-path.sources") + ) + assert len(repo_mapping._last_errors) == 1 + [error] = repo_mapping._last_errors + assert isinstance(error, apt.BadValueError) + assert error.key == "Components" - def test_init_with_bad_sources_list_no_fallback(self): - with patch.object( - apt.RepositoryMapping, - "_apt_dir", - str(FAKE_APT_DIRS / "noble-no-sources"), - ): - with self.assertRaises(apt.InvalidSourceError): - apt.RepositoryMapping() - def test_init_with_bad_sources_list_fallback_ok(self): - with patch.object( - apt.RepositoryMapping, - "_apt_dir", - str(FAKE_APT_DIRS / "noble"), - ): - repository_mapping = apt.RepositoryMapping() - assert repository_mapping._repository_map +def test_load_deb822_bad_enabled_value(repo_mapping: apt.RepositoryMapping): + with pytest.raises(apt.InvalidSourceError): + repo_mapping.load_deb822( + str(SOURCES_DIR / "bad-stanza-enabled-bad.sources") + ) + assert len(repo_mapping._last_errors) == 1 + [error] = repo_mapping._last_errors + assert isinstance(error, apt.BadValueError) + assert error.key == "Enabled" - def test_init_with_bad_ubuntu_sources(self): - with patch.object( - apt.RepositoryMapping, - "_apt_dir", - str(FAKE_APT_DIRS / "noble-empty-sources"), - ): - with self.assertRaises(apt.InvalidSourceError): - apt.RepositoryMapping() - def test_init_with_third_party_inkscape_source(self): - with patch.object( - apt.RepositoryMapping, - "_apt_dir", - str(FAKE_APT_DIRS / "noble-with-inkscape"), - ): - repository_mapping = apt.RepositoryMapping() - assert repository_mapping._repository_map +def test_load_deb822_missing_required_keys(repo_mapping: apt.RepositoryMapping): + with pytest.raises(apt.InvalidSourceError): + repo_mapping.load_deb822( + str(SOURCES_DIR / "bad-stanza-missing-required-keys.sources") + ) + assert len(repo_mapping._last_errors) == 1 + [error] = repo_mapping._last_errors + assert isinstance(error, apt.MissingRequiredKeyError) + assert error.key in ("Types", "URIs", "Suites") + + +def test_load_deb822_comments(repo_mapping: apt.RepositoryMapping): + filename = str(SOURCES_DIR / "good-stanza-comments.sources") + repo_mapping.load_deb822(filename) + repotypes = ("deb", "deb-src") + uris = ("http://nz.archive.ubuntu.com/ubuntu/", "http://archive.ubuntu.com/ubuntu/") + suites = ("noble", "noble-updates", "noble-backports") + assert len(repo_mapping) == len(repotypes) * len(uris) * len(suites) + for repo, (repotype, uri, suite) in zip( + repo_mapping, itertools.product(repotypes, uris, suites) + ): + assert isinstance(repo, apt.DebianRepository) + assert repo.enabled + assert repo.repotype == repotype + assert repo.uri == uri + assert repo.release == suite + assert repo.groups == ["main", "restricted", "universe", "multiverse"] + assert repo.filename == filename + assert repo.gpg_key == "" + + +def test_load_deb822_enabled_no(repo_mapping: apt.RepositoryMapping): + filename = str(SOURCES_DIR / "good-stanza-enabled-no.sources") + repo_mapping.load_deb822(filename) + for repo in repo_mapping: + assert isinstance(repo, apt.DebianRepository) + assert not repo.enabled + + +def test_load_deb822_exact_path(repo_mapping: apt.RepositoryMapping): + filename = str(SOURCES_DIR / "good-stanza-exact-path.sources") + repo_mapping.load_deb822(filename) + [repo] = repo_mapping + assert isinstance(repo, apt.DebianRepository) + assert repo.uri + assert "/" in repo.release + assert not repo.groups + + +def test_load_deb822_fully_commented_out_stanzas(repo_mapping: apt.RepositoryMapping): + with pytest.raises(apt.InvalidSourceError): + repo_mapping.load_deb822(str(SOURCES_DIR / "stanzas-fully-commented-out.sources")) + assert not repo_mapping._repository_map + assert not repo_mapping._last_errors # no individual errors, just no good entries + + +def test_load_deb822_one_good_stanza_one_bad(repo_mapping: apt.RepositoryMapping): + repo_mapping.load_deb822(str(SOURCES_DIR / "stanzas-one-good-one-bad-comments.sources")) + repos = repo_mapping._repository_map.values() + errors = repo_mapping._last_errors + assert len(repos) == 1 # one good stanza defines one repository + assert len(errors) == 1 # one stanza was bad + [error] = errors + assert isinstance(error, apt.MissingRequiredKeyError) + + +def test_load_deb822_ubuntu_sources(repo_mapping: apt.RepositoryMapping): + assert not repo_mapping._repository_map + repo_mapping.load_deb822(str(SOURCES_DIR / "stanzas-noble.sources")) + assert sorted(repo_mapping._repository_map.keys()) == [ + "deb-http://nz.archive.ubuntu.com/ubuntu/-noble", + "deb-http://nz.archive.ubuntu.com/ubuntu/-noble-backports", + "deb-http://nz.archive.ubuntu.com/ubuntu/-noble-updates", + "deb-http://security.ubuntu.com/ubuntu-noble-security", + ] + assert not repo_mapping._last_errors + + +def test_load_deb822_with_gpg_key(repo_mapping: apt.RepositoryMapping): + filename = str(SOURCES_DIR / "good-stanza-with-gpg-key.sources") + repo_mapping.load_deb822(filename) + [repo] = repo_mapping + assert isinstance(repo, apt.DebianRepository) + # DebianRepository.gpg_key is expected to be a string file path + # the inkscape sources file provides the key inline + # in this case the library imports the key to a file on first access + with tempfile.TemporaryDirectory() as tmpdir: + assert not list(Path(tmpdir).iterdir()) + with patch.object(apt, "_GPG_KEY_DIR", tmpdir): + inkscape_key_file = repo.gpg_key + key_paths = list(Path(tmpdir).iterdir()) + assert len(key_paths) == 1 + [key_path] = key_paths + assert Path(inkscape_key_file).name == key_path.name + assert Path(inkscape_key_file).parent == key_path.parent + # the filename is cached for subsequent access + with tempfile.TemporaryDirectory() as tmpdir: + assert not list(Path(tmpdir).iterdir()) + with patch.object(apt, "_GPG_KEY_DIR", tmpdir): + inkscape_key_file_cached = repo.gpg_key + assert not list(Path(tmpdir).iterdir()) + assert inkscape_key_file == inkscape_key_file_cached + + +def test_load_deb822_stanza_ubuntu_main_etc(repo_mapping: apt.RepositoryMapping): + filename = str(SOURCES_DIR / "good-stanza-noble-main-etc.sources") + repo_mapping.load_deb822(filename) + assert len(repo_mapping) == 3 + for repo, suite in zip(repo_mapping, ("noble", "noble-updates", "noble-backports")): + assert isinstance(repo, apt.DebianRepository) + assert repo.enabled + assert repo.repotype == "deb" + assert repo.uri == "http://nz.archive.ubuntu.com/ubuntu/" + assert repo.release == suite + assert repo.groups == ["main", "restricted", "universe", "multiverse"] + assert repo.filename == filename + assert repo.gpg_key == "/usr/share/keyrings/ubuntu-archive-keyring.gpg" - def test_load_deb822_ubuntu_sources(self): - with patch.object( - apt.RepositoryMapping, - "_apt_dir", - str(FAKE_APT_DIRS / "empty"), - ): - repository_mapping = apt.RepositoryMapping() - assert not repository_mapping._repository_map - - with patch("builtins.open", new_callable=mock_open, read_data=ubuntu_sources_deb822): - repository_mapping.load_deb822("") - assert sorted(repository_mapping._repository_map.keys()) == [ - "deb-http://nz.archive.ubuntu.com/ubuntu/-noble", - "deb-http://nz.archive.ubuntu.com/ubuntu/-noble-backports", - "deb-http://nz.archive.ubuntu.com/ubuntu/-noble-updates", - "deb-http://security.ubuntu.com/ubuntu-noble-security", - ] - - def test_load_deb822_w_comments(self): - with patch.object( - apt.RepositoryMapping, - "_apt_dir", - str(FAKE_APT_DIRS / "noble-with-comments-etc"), - ): - repository_mapping = apt.RepositoryMapping() - # TODO: split cases into separate files and test load_deb822 instead - # this will make things a lot more understandable and maintainable - - assert sorted(repository_mapping._repository_map.keys()) == [ - "deb-http://archive.ubuntu.com/ubuntu/-noble", - "deb-http://archive.ubuntu.com/ubuntu/-noble-backports", - "deb-http://archive.ubuntu.com/ubuntu/-noble-updates", - "deb-http://nz.archive.ubuntu.com/ubuntu/-an/exact/path/", - "deb-http://nz.archive.ubuntu.com/ubuntu/-noble", - "deb-http://nz.archive.ubuntu.com/ubuntu/-noble-backports", - "deb-http://nz.archive.ubuntu.com/ubuntu/-noble-updates", - "deb-src-http://archive.ubuntu.com/ubuntu/-noble", - "deb-src-http://archive.ubuntu.com/ubuntu/-noble-backports", - "deb-src-http://archive.ubuntu.com/ubuntu/-noble-updates", - "deb-src-http://nz.archive.ubuntu.com/ubuntu/-noble", - "deb-src-http://nz.archive.ubuntu.com/ubuntu/-noble-backports", - "deb-src-http://nz.archive.ubuntu.com/ubuntu/-noble-updates", - ] - errors = tuple(repository_mapping._last_errors) - assert len(errors) == 4 - ( - missing_types, - components_not_ommitted, - components_not_present, - bad_enabled_value, - ) = errors - assert isinstance(missing_types, apt.MissingRequiredKeyError) - assert missing_types.key == "Types" - assert isinstance(components_not_ommitted, apt.BadValueError) - assert components_not_ommitted.key == "Components" - assert components_not_ommitted.value == "main" - assert isinstance(components_not_present, apt.MissingRequiredKeyError) - assert components_not_present.key == "Components" - assert isinstance(bad_enabled_value, apt.BadValueError) - assert bad_enabled_value.key == "Enabled" - assert bad_enabled_value.value == "bad" - - def test_init_with_deb822(self): - """Mock file opening to initialise a RepositoryMapping from deb822 and one-line-style. - - They should be equivalent with the sample data being used. - """ - with patch.object( - apt.RepositoryMapping, - "_apt_dir", - str(FAKE_APT_DIRS / "noble"), - ): - repos_deb822 = apt.RepositoryMapping() - with patch.object( - apt.RepositoryMapping, - "_apt_dir", - str(FAKE_APT_DIRS / "noble-in-one-per-line-format"), - ): - repos_one_per_line = apt.RepositoryMapping() - - list_keys = sorted(repos_one_per_line._repository_map.keys()) - sources_keys = sorted(repos_deb822._repository_map.keys()) - assert sources_keys == list_keys - - for list_key, sources_key in zip(list_keys, sources_keys): - list_repo = repos_one_per_line[list_key] - sources_repo = repos_deb822[sources_key] - assert list_repo.enabled == sources_repo.enabled - assert list_repo.repotype == sources_repo.repotype - assert list_repo.uri == sources_repo.uri - assert list_repo.release == sources_repo.release - assert list_repo.groups == sources_repo.groups - assert list_repo.gpg_key == sources_repo.gpg_key - assert ( - list_repo.options # pyright: ignore[reportUnknownMemberType] - == sources_repo.options # pyright: ignore[reportUnknownMemberType] - ) - - def test_disable_with_deb822(self): +def test_load_deb822_stanza_ubuntu_security(repo_mapping: apt.RepositoryMapping): + filename = str(SOURCES_DIR / "good-stanza-noble-security.sources") + repo_mapping.load_deb822(filename) + assert len(repo_mapping) == 1 + [repo] = repo_mapping + assert isinstance(repo, apt.DebianRepository) + assert repo.enabled + assert repo.repotype == "deb" + assert repo.uri == "http://security.ubuntu.com/ubuntu" + assert repo.release == "noble-security" + assert repo.groups == ["main", "restricted", "universe", "multiverse"] + assert repo.filename == filename + assert repo.gpg_key == "/usr/share/keyrings/ubuntu-archive-keyring.gpg" + + +def test_disable_with_deb822(repo_mapping: apt.RepositoryMapping): + repo = apt.DebianRepository( + enabled=True, + repotype="deb", + uri="http://nz.archive.ubuntu.com/ubuntu/", + release="noble", + groups=["main", "restricted"], + ) + repo._deb822_stanza = apt._Deb822Stanza(numbered_lines=[]) + with pytest.raises(NotImplementedError): + repo_mapping.disable(repo) + + +def test_add_with_deb822(repo_mapping: apt.RepositoryMapping): + with (SOURCES_DIR / "good-stanza-exact-path.sources").open() as f: + repos, errors = repo_mapping._parse_deb822_lines(f) + assert len(repos) == 1 + assert not errors + [repo] = repos + identifier = repo._get_identifier() + # call add with update_cache=True + with patch.object(apt.subprocess, "run") as mock_run_1: with patch.object( apt.RepositoryMapping, "_apt_dir", str(FAKE_APT_DIRS / "empty"), ): - repository_mapping = apt.RepositoryMapping() - repo = apt.DebianRepository( - enabled=True, - repotype="deb", - uri="http://nz.archive.ubuntu.com/ubuntu/", - release="noble", - groups=["main", "restricted"], - ) - repo._deb822_stanza = apt._Deb822Stanza(numbered_lines=[]) - with self.assertRaises(NotImplementedError): - repository_mapping.disable(repo) + r1 = repo_mapping.add(repo, update_cache=True) + assert r1 is not repo_mapping + assert identifier not in repo_mapping # because it's the old mapping + assert identifier not in r1 # because we mocked out the subprocess call + mock_run_1.assert_called_once_with( + [ + "add-apt-repository", + "--yes", + "--sourceslist=deb http://nz.archive.ubuntu.com/ubuntu/ an/exact/path/ ", + ], + check=True, + capture_output=True, + ) + # call add with update_cache=False + with patch.object(apt.subprocess, "run") as mock_run_2: + r2 = repo_mapping.add(repo) # update_cache=False by default + assert r2 is repo_mapping + assert identifier in repo_mapping + mock_run_2.assert_called_once_with( + [ + "add-apt-repository", + "--yes", + "--sourceslist=deb http://nz.archive.ubuntu.com/ubuntu/ an/exact/path/ ", + "--no-update", + ], + check=True, + capture_output=True, + ) + # we re-raise CalledProcessError after logging + error = apt.CalledProcessError(1, 'cmd') + error.stdout = error.stderr = b'' + with patch.object(apt.logger, "error") as mock_logging_error: + with patch.object(apt.subprocess, "run", side_effect=error): + with pytest.raises(apt.CalledProcessError): + repo_mapping.add(repo) + mock_logging_error.assert_called_once() + # call add with a disabled repository + repo._enabled = False + with pytest.raises(ValueError): + repo_mapping.add(repo)