mirror of
https://github.com/d9pouces/id_configurator.git
synced 2026-11-09 01:47:21 +01:00
feat: update APT module to support inline ASCII-armored GPG keys in Signed-By field
Signed-off-by: d9pouces <github@19pouces.net>
This commit is contained in:
+51
-36
@@ -26,13 +26,23 @@ def _yes_no(value: bool) -> str:
|
|||||||
return "yes" if value else "no"
|
return "yes" if value else "no"
|
||||||
|
|
||||||
|
|
||||||
|
def _fold_deb822_multiline(field: str, value: str) -> str:
|
||||||
|
"""Render `field` as a DEB822 folded multi-line field (sources.list(5)):
|
||||||
|
each of `value`'s own lines indented by one space, with a truly blank
|
||||||
|
line represented as a lone `.` - exactly how an ASCII-armored GPG key
|
||||||
|
block is embedded directly in a `Signed-By` field, verified against a
|
||||||
|
real `apt-get update` run against a genuinely signed repository."""
|
||||||
|
folded = "\n".join(f" {line}" if line else " ." for line in value.splitlines())
|
||||||
|
return f"{field}:\n{folded}"
|
||||||
|
|
||||||
|
|
||||||
class AptSource(BaseModel):
|
class AptSource(BaseModel):
|
||||||
"""A single APT source, in the DEB822 format (see sources.list(5)).
|
"""A single APT source, in the DEB822 format (see sources.list(5)).
|
||||||
|
|
||||||
Two ways to tell APT which key to trust this source with:
|
Two ways to tell APT which key to trust this source with:
|
||||||
- `signed_by`: the ASCII-armored (.asc) *content* of a GPG public key.
|
- `signed_by`: the ASCII-armored (.asc) *content* of a GPG public key,
|
||||||
The `apt` module dearmors it into a dedicated keyring and writes that
|
folded inline as a multi-line `Signed-By` field - no separate keyring
|
||||||
keyring's path as the rendered `Signed-By` field.
|
file, no `gpg` binary needed to apply this configuration.
|
||||||
- `signed_by_file`: the *path* to a keyring already expected to exist on
|
- `signed_by_file`: the *path* to a keyring already expected to exist on
|
||||||
the target system, written to the rendered `Signed-By` field verbatim.
|
the target system, written to the rendered `Signed-By` field verbatim.
|
||||||
If both are set, `signed_by` takes precedence.
|
If both are set, `signed_by` takes precedence.
|
||||||
@@ -66,61 +76,66 @@ class AptSource(BaseModel):
|
|||||||
date_max_future: str | None = Field(default=None, alias="date-max-future")
|
date_max_future: str | None = Field(default=None, alias="date-max-future")
|
||||||
description: str | None = None
|
description: str | None = None
|
||||||
|
|
||||||
def to_deb822(self, signed_by_path: str | None = None) -> str:
|
def to_deb822(
|
||||||
|
self,
|
||||||
|
signed_by_armored_key: str | None = None,
|
||||||
|
signed_by_path: str | None = None,
|
||||||
|
) -> str:
|
||||||
"""Render this source as a DEB822 stanza (as used in
|
"""Render this source as a DEB822 stanza (as used in
|
||||||
/etc/apt/sources.list.d/*.sources).
|
/etc/apt/sources.list.d/*.sources).
|
||||||
|
|
||||||
`signed_by_path` is the value actually written as the `Signed-By`
|
Neither `self.signed_by` nor `self.signed_by_file` is ever rendered
|
||||||
field, if given - resolved by the caller (the `apt` module) from
|
directly - the caller (the `apt` module) resolves which one applies
|
||||||
`self.signed_by`/`self.signed_by_file`, neither of which is ever
|
and passes the result in: `signed_by_armored_key` is folded inline
|
||||||
rendered here directly.
|
as a multi-line `Signed-By` field (see `_fold_deb822_multiline`);
|
||||||
|
`signed_by_path` is written as a plain single-line `Signed-By`
|
||||||
|
field. If both are given, `signed_by_armored_key` takes precedence.
|
||||||
"""
|
"""
|
||||||
fields = [
|
lines = [
|
||||||
("Types", " ".join(self.types)),
|
f"Types: {' '.join(self.types)}",
|
||||||
("URIs", " ".join(self.uris)),
|
f"URIs: {' '.join(self.uris)}",
|
||||||
("Suites", " ".join(self.suites)),
|
f"Suites: {' '.join(self.suites)}",
|
||||||
]
|
]
|
||||||
if self.components:
|
if self.components:
|
||||||
fields.append(("Components", " ".join(self.components)))
|
lines.append(f"Components: {' '.join(self.components)}")
|
||||||
if self.architectures:
|
if self.architectures:
|
||||||
fields.append(("Architectures", " ".join(self.architectures)))
|
lines.append(f"Architectures: {' '.join(self.architectures)}")
|
||||||
if self.languages:
|
if self.languages:
|
||||||
fields.append(("Languages", " ".join(self.languages)))
|
lines.append(f"Languages: {' '.join(self.languages)}")
|
||||||
if self.targets:
|
if self.targets:
|
||||||
fields.append(("Targets", " ".join(self.targets)))
|
lines.append(f"Targets: {' '.join(self.targets)}")
|
||||||
if signed_by_path:
|
if signed_by_armored_key:
|
||||||
fields.append(("Signed-By", signed_by_path))
|
lines.append(_fold_deb822_multiline("Signed-By", signed_by_armored_key))
|
||||||
|
elif signed_by_path:
|
||||||
|
lines.append(f"Signed-By: {signed_by_path}")
|
||||||
if self.trusted is not None:
|
if self.trusted is not None:
|
||||||
fields.append(("Trusted", _yes_no(self.trusted)))
|
lines.append(f"Trusted: {_yes_no(self.trusted)}")
|
||||||
if self.pdiffs is not None:
|
if self.pdiffs is not None:
|
||||||
fields.append(("PDiffs", _yes_no(self.pdiffs)))
|
lines.append(f"PDiffs: {_yes_no(self.pdiffs)}")
|
||||||
if self.by_hash is not None:
|
if self.by_hash is not None:
|
||||||
fields.append(("By-Hash", _yes_no(self.by_hash)))
|
lines.append(f"By-Hash: {_yes_no(self.by_hash)}")
|
||||||
if self.allow_insecure is not None:
|
if self.allow_insecure is not None:
|
||||||
fields.append(("Allow-Insecure", _yes_no(self.allow_insecure)))
|
lines.append(f"Allow-Insecure: {_yes_no(self.allow_insecure)}")
|
||||||
if self.allow_weak is not None:
|
if self.allow_weak is not None:
|
||||||
fields.append(("Allow-Weak", _yes_no(self.allow_weak)))
|
lines.append(f"Allow-Weak: {_yes_no(self.allow_weak)}")
|
||||||
if self.allow_downgrade_to_insecure is not None:
|
if self.allow_downgrade_to_insecure is not None:
|
||||||
fields.append(
|
lines.append(
|
||||||
(
|
f"Allow-Downgrade-To-Insecure: {_yes_no(self.allow_downgrade_to_insecure)}"
|
||||||
"Allow-Downgrade-To-Insecure",
|
|
||||||
_yes_no(self.allow_downgrade_to_insecure),
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
if self.check_valid_until is not None:
|
if self.check_valid_until is not None:
|
||||||
fields.append(("Check-Valid-Until", _yes_no(self.check_valid_until)))
|
lines.append(f"Check-Valid-Until: {_yes_no(self.check_valid_until)}")
|
||||||
if self.valid_until_min:
|
if self.valid_until_min:
|
||||||
fields.append(("Valid-Until-Min", self.valid_until_min))
|
lines.append(f"Valid-Until-Min: {self.valid_until_min}")
|
||||||
if self.valid_until_max:
|
if self.valid_until_max:
|
||||||
fields.append(("Valid-Until-Max", self.valid_until_max))
|
lines.append(f"Valid-Until-Max: {self.valid_until_max}")
|
||||||
if self.check_date is not None:
|
if self.check_date is not None:
|
||||||
fields.append(("Check-Date", _yes_no(self.check_date)))
|
lines.append(f"Check-Date: {_yes_no(self.check_date)}")
|
||||||
if self.date_max_future:
|
if self.date_max_future:
|
||||||
fields.append(("Date-Max-Future", self.date_max_future))
|
lines.append(f"Date-Max-Future: {self.date_max_future}")
|
||||||
if self.description:
|
if self.description:
|
||||||
fields.append(("Description", self.description))
|
lines.append(f"Description: {self.description}")
|
||||||
fields.append(("Enabled", _yes_no(self.enabled)))
|
lines.append(f"Enabled: {_yes_no(self.enabled)}")
|
||||||
return "\n".join(f"{key}: {value}" for key, value in fields) + "\n"
|
return "\n".join(lines) + "\n"
|
||||||
|
|
||||||
|
|
||||||
class AptConfig(BaseModel):
|
class AptConfig(BaseModel):
|
||||||
|
|||||||
@@ -7,27 +7,19 @@ longer takes effect, and the sources declared in the configuration are added
|
|||||||
as new, enabled `.sources` files.
|
as new, enabled `.sources` files.
|
||||||
|
|
||||||
Each source can be trusted two ways: `signed_by` holds the ASCII-armored
|
Each source can be trusted two ways: `signed_by` holds the ASCII-armored
|
||||||
content of a GPG public key, which is converted with `gpg --dearmor` into a
|
content of a GPG public key, folded inline as a multi-line `Signed-By`
|
||||||
binary keyring dedicated to this one source, written to
|
field (per sources.list(5) - verified empirically against a real,
|
||||||
`/etc/apt/keyrings/<name>.gpg` - it is that path, not the key's own content,
|
genuinely signed repository, so no `gpg` binary or separate keyring file
|
||||||
that ends up in the rendered `.sources` file's `Signed-By` field.
|
is needed at all to apply this configuration). `signed_by_file` instead
|
||||||
`signed_by_file` instead names a keyring already expected to exist on the
|
names a keyring already expected to exist on the target system, used
|
||||||
target system, used verbatim as `Signed-By` (no file is written for it); if
|
verbatim as `Signed-By`; if both are given, `signed_by` takes precedence.
|
||||||
both are given, `signed_by` takes precedence. `gpg` is only used here for
|
|
||||||
the armored-to-binary format conversion; the actual signature verification
|
|
||||||
of the repository itself is left to APT when it fetches the repository's
|
|
||||||
Release/InRelease file.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
import shutil
|
|
||||||
import subprocess
|
|
||||||
import tempfile
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from id_configurator.config import AptSource
|
|
||||||
from id_configurator.context import Context
|
from id_configurator.context import Context
|
||||||
from id_configurator.modules.base import Module, register, sanitize_filename
|
from id_configurator.modules.base import Module, register, sanitize_filename
|
||||||
|
|
||||||
@@ -36,7 +28,6 @@ logger = logging.getLogger(__name__)
|
|||||||
_APT_DIR = "etc/apt"
|
_APT_DIR = "etc/apt"
|
||||||
_SOURCES_LIST = "etc/apt/sources.list"
|
_SOURCES_LIST = "etc/apt/sources.list"
|
||||||
_SOURCES_LIST_D = "etc/apt/sources.list.d"
|
_SOURCES_LIST_D = "etc/apt/sources.list.d"
|
||||||
_KEYRINGS_DIR = "etc/apt/keyrings"
|
|
||||||
|
|
||||||
|
|
||||||
def _disable_list_content(content: str) -> str:
|
def _disable_list_content(content: str) -> str:
|
||||||
@@ -59,28 +50,6 @@ def _disable_deb822_content(content: str) -> str:
|
|||||||
return "\n\n".join(disabled_stanzas) + "\n"
|
return "\n\n".join(disabled_stanzas) + "\n"
|
||||||
|
|
||||||
|
|
||||||
def _dearmor_gpg_key(name: str, content: bytes) -> bytes:
|
|
||||||
"""Convert an ASCII-armored GPG public key into apt's binary keyring format."""
|
|
||||||
with tempfile.TemporaryDirectory() as gnupg_home:
|
|
||||||
path = shutil.which("gpg")
|
|
||||||
if path is not None:
|
|
||||||
result = subprocess.run(
|
|
||||||
[path, "--homedir", gnupg_home, "--batch", "--dearmor"],
|
|
||||||
input=content,
|
|
||||||
capture_output=True,
|
|
||||||
check=False,
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
raise RuntimeError(
|
|
||||||
"gpg is not available; the GNUPG package must be installed in the environment running id-configurator"
|
|
||||||
)
|
|
||||||
if result.returncode != 0:
|
|
||||||
raise RuntimeError(
|
|
||||||
f"invalid GPG key for apt source {name}: {result.stderr.decode('utf-8', 'replace')}"
|
|
||||||
)
|
|
||||||
return result.stdout
|
|
||||||
|
|
||||||
|
|
||||||
@register
|
@register
|
||||||
class AptModule(Module):
|
class AptModule(Module):
|
||||||
name = "apt"
|
name = "apt"
|
||||||
@@ -119,22 +88,14 @@ class AptModule(Module):
|
|||||||
)
|
)
|
||||||
logger.info("disabled existing apt source %s", entry.name)
|
logger.info("disabled existing apt source %s", entry.name)
|
||||||
|
|
||||||
def _resolve_signed_by(self, context: Context, source: AptSource) -> str | None:
|
@staticmethod
|
||||||
if source.signed_by:
|
def _write_configured_sources(context: Context) -> None:
|
||||||
keyring = _dearmor_gpg_key(source.name, source.signed_by.encode("utf-8"))
|
|
||||||
relative = f"{_KEYRINGS_DIR}/{sanitize_filename(source.name)}.gpg"
|
|
||||||
context.write_file(relative, keyring)
|
|
||||||
logger.info("installed apt keyring for source %s", source.name)
|
|
||||||
return f"/{relative}"
|
|
||||||
if source.signed_by_file:
|
|
||||||
return source.signed_by_file
|
|
||||||
return None
|
|
||||||
|
|
||||||
def _write_configured_sources(self, context: Context) -> None:
|
|
||||||
for source in context.config.configurations.apt.sources:
|
for source in context.config.configurations.apt.sources:
|
||||||
signed_by_path = self._resolve_signed_by(context, source)
|
|
||||||
context.write_file(
|
context.write_file(
|
||||||
f"{_SOURCES_LIST_D}/{sanitize_filename(source.name)}.sources",
|
f"{_SOURCES_LIST_D}/{sanitize_filename(source.name)}.sources",
|
||||||
source.to_deb822(signed_by_path).encode("utf-8"),
|
source.to_deb822(
|
||||||
|
signed_by_armored_key=source.signed_by,
|
||||||
|
signed_by_path=source.signed_by_file,
|
||||||
|
).encode("utf-8"),
|
||||||
)
|
)
|
||||||
logger.info("added apt source %s", source.name)
|
logger.info("added apt source %s", source.name)
|
||||||
|
|||||||
@@ -125,10 +125,9 @@ options:
|
|||||||
type: str
|
type: str
|
||||||
description:
|
description:
|
||||||
- ASCII-armored content of a GPG public key to trust this
|
- ASCII-armored content of a GPG public key to trust this
|
||||||
source with. The `apt` module dearmors it into a dedicated
|
source with. Folded inline as a multi-line Signed-By field
|
||||||
keyring and writes that keyring's path as the rendered
|
(no separate keyring file or gpg binary needed). Takes
|
||||||
Signed-By field. Takes precedence over signed_by_file if
|
precedence over signed_by_file if both are set.
|
||||||
both are set.
|
|
||||||
signed_by_file:
|
signed_by_file:
|
||||||
type: str
|
type: str
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
FROM debian:trixie-slim AS target
|
FROM debian:trixie-slim AS target
|
||||||
FROM interdiode/id-configurator:latest AS configuration
|
FROM interdiode/id-configurator:latest AS configuration
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends gnupg \
|
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
|
||||||
COPY --from=target / /src/
|
COPY --from=target / /src/
|
||||||
COPY --from=generated-config Apt.yaml /config.yaml
|
COPY --from=generated-config Apt.yaml /config.yaml
|
||||||
RUN id-configurator apply --config /config.yaml --src /src --dst /dst
|
RUN id-configurator apply --config /config.yaml --src /src --dst /dst
|
||||||
|
|||||||
+22
-77
@@ -1,14 +1,13 @@
|
|||||||
import subprocess
|
|
||||||
from unittest.mock import MagicMock
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from id_configurator.config import Configuration
|
from id_configurator.config import Configuration
|
||||||
from id_configurator.context import Context
|
from id_configurator.context import Context
|
||||||
from id_configurator.modules.apt import AptModule
|
from id_configurator.modules.apt import AptModule
|
||||||
|
|
||||||
KEY_CONTENT = "fake-armored-gpg-key"
|
KEY_CONTENT = (
|
||||||
KEYRING_BYTES = b"fake-dearmored-keyring-bytes"
|
"-----BEGIN PGP PUBLIC KEY BLOCK-----\n"
|
||||||
|
"\n"
|
||||||
|
"mQENBGqh08IBCAC0\n"
|
||||||
|
"-----END PGP PUBLIC KEY BLOCK-----\n"
|
||||||
|
)
|
||||||
|
|
||||||
SOURCE_CONTENT = {
|
SOURCE_CONTENT = {
|
||||||
"uuid": "12345678-1234-5678-1234-567812345678",
|
"uuid": "12345678-1234-5678-1234-567812345678",
|
||||||
@@ -52,16 +51,6 @@ def make_config_with(source_extra: dict, name: str = "interdiode") -> Configurat
|
|||||||
return Configuration.model_validate(data)
|
return Configuration.model_validate(data)
|
||||||
|
|
||||||
|
|
||||||
def _mock_gpg_dearmor_success(monkeypatch, output: bytes = KEYRING_BYTES) -> MagicMock:
|
|
||||||
mock_run = MagicMock(
|
|
||||||
return_value=subprocess.CompletedProcess(
|
|
||||||
args=[], returncode=0, stdout=output, stderr=b""
|
|
||||||
)
|
|
||||||
)
|
|
||||||
monkeypatch.setattr("id_configurator.modules.apt.subprocess.run", mock_run)
|
|
||||||
return mock_run
|
|
||||||
|
|
||||||
|
|
||||||
def test_is_relevant_false_without_src(tmp_path) -> None:
|
def test_is_relevant_false_without_src(tmp_path) -> None:
|
||||||
context = Context(config=make_config(), dst=tmp_path / "dst", src=None)
|
context = Context(config=make_config(), dst=tmp_path / "dst", src=None)
|
||||||
assert AptModule().is_relevant(context) is False
|
assert AptModule().is_relevant(context) is False
|
||||||
@@ -81,10 +70,7 @@ def test_is_relevant_true_with_apt_dir(tmp_path) -> None:
|
|||||||
assert AptModule().is_relevant(context) is True
|
assert AptModule().is_relevant(context) is True
|
||||||
|
|
||||||
|
|
||||||
def test_apply_disables_existing_sources_and_adds_configured_ones(
|
def test_apply_disables_existing_sources_and_adds_configured_ones(tmp_path) -> None:
|
||||||
tmp_path, monkeypatch
|
|
||||||
) -> None:
|
|
||||||
mock_run = _mock_gpg_dearmor_success(monkeypatch)
|
|
||||||
src = tmp_path / "src"
|
src = tmp_path / "src"
|
||||||
apt_dir = src / "etc/apt"
|
apt_dir = src / "etc/apt"
|
||||||
sources_list_d = apt_dir / "sources.list.d"
|
sources_list_d = apt_dir / "sources.list.d"
|
||||||
@@ -133,22 +119,21 @@ def test_apply_disables_existing_sources_and_adds_configured_ones(
|
|||||||
|
|
||||||
assert not (dst / "etc/apt/sources.list.d/ignored.txt").exists()
|
assert not (dst / "etc/apt/sources.list.d/ignored.txt").exists()
|
||||||
|
|
||||||
keyring = (dst / "etc/apt/keyrings/interdiode.gpg").read_bytes()
|
|
||||||
assert keyring == KEYRING_BYTES
|
|
||||||
mock_run.assert_called_once()
|
|
||||||
assert mock_run.call_args.kwargs["input"] == KEY_CONTENT.encode("utf-8")
|
|
||||||
|
|
||||||
configured_source = (dst / "etc/apt/sources.list.d/interdiode.sources").read_text(
|
configured_source = (dst / "etc/apt/sources.list.d/interdiode.sources").read_text(
|
||||||
encoding="utf-8"
|
encoding="utf-8"
|
||||||
)
|
)
|
||||||
assert configured_source == (
|
assert configured_source == (
|
||||||
"Types: deb\nURIs: http://example.invalid/debian\nSuites: stable\n"
|
"Types: deb\nURIs: http://example.invalid/debian\nSuites: stable\n"
|
||||||
"Signed-By: /etc/apt/keyrings/interdiode.gpg\nEnabled: yes\n"
|
"Signed-By:\n"
|
||||||
|
" -----BEGIN PGP PUBLIC KEY BLOCK-----\n"
|
||||||
|
" .\n"
|
||||||
|
" mQENBGqh08IBCAC0\n"
|
||||||
|
" -----END PGP PUBLIC KEY BLOCK-----\n"
|
||||||
|
"Enabled: yes\n"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_apply_without_existing_top_level_sources_list(tmp_path, monkeypatch) -> None:
|
def test_apply_without_existing_top_level_sources_list(tmp_path) -> None:
|
||||||
_mock_gpg_dearmor_success(monkeypatch)
|
|
||||||
src = tmp_path / "src"
|
src = tmp_path / "src"
|
||||||
(src / "etc/apt").mkdir(parents=True)
|
(src / "etc/apt").mkdir(parents=True)
|
||||||
dst = tmp_path / "dst"
|
dst = tmp_path / "dst"
|
||||||
@@ -160,8 +145,7 @@ def test_apply_without_existing_top_level_sources_list(tmp_path, monkeypatch) ->
|
|||||||
assert (dst / "etc/apt/sources.list.d/interdiode.sources").exists()
|
assert (dst / "etc/apt/sources.list.d/interdiode.sources").exists()
|
||||||
|
|
||||||
|
|
||||||
def test_apply_uses_signed_by_file_when_no_key_content(tmp_path, monkeypatch) -> None:
|
def test_apply_uses_signed_by_file_when_no_key_content(tmp_path) -> None:
|
||||||
mock_run = _mock_gpg_dearmor_success(monkeypatch)
|
|
||||||
dst = tmp_path / "dst"
|
dst = tmp_path / "dst"
|
||||||
context = Context(
|
context = Context(
|
||||||
config=make_config_with({"signed-by-file": "/etc/apt/keyrings/existing.gpg"}),
|
config=make_config_with({"signed-by-file": "/etc/apt/keyrings/existing.gpg"}),
|
||||||
@@ -171,8 +155,6 @@ def test_apply_uses_signed_by_file_when_no_key_content(tmp_path, monkeypatch) ->
|
|||||||
|
|
||||||
AptModule().apply(context)
|
AptModule().apply(context)
|
||||||
|
|
||||||
mock_run.assert_not_called()
|
|
||||||
assert not (dst / "etc/apt/keyrings").exists()
|
|
||||||
configured_source = (dst / "etc/apt/sources.list.d/interdiode.sources").read_text(
|
configured_source = (dst / "etc/apt/sources.list.d/interdiode.sources").read_text(
|
||||||
encoding="utf-8"
|
encoding="utf-8"
|
||||||
)
|
)
|
||||||
@@ -182,10 +164,7 @@ def test_apply_uses_signed_by_file_when_no_key_content(tmp_path, monkeypatch) ->
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_apply_signed_by_takes_precedence_over_signed_by_file(
|
def test_apply_signed_by_takes_precedence_over_signed_by_file(tmp_path) -> None:
|
||||||
tmp_path, monkeypatch
|
|
||||||
) -> None:
|
|
||||||
mock_run = _mock_gpg_dearmor_success(monkeypatch)
|
|
||||||
dst = tmp_path / "dst"
|
dst = tmp_path / "dst"
|
||||||
context = Context(
|
context = Context(
|
||||||
config=make_config_with(
|
config=make_config_with(
|
||||||
@@ -200,25 +179,19 @@ def test_apply_signed_by_takes_precedence_over_signed_by_file(
|
|||||||
|
|
||||||
AptModule().apply(context)
|
AptModule().apply(context)
|
||||||
|
|
||||||
mock_run.assert_called_once()
|
|
||||||
assert (dst / "etc/apt/keyrings/interdiode.gpg").read_bytes() == KEYRING_BYTES
|
|
||||||
configured_source = (dst / "etc/apt/sources.list.d/interdiode.sources").read_text(
|
configured_source = (dst / "etc/apt/sources.list.d/interdiode.sources").read_text(
|
||||||
encoding="utf-8"
|
encoding="utf-8"
|
||||||
)
|
)
|
||||||
assert "Signed-By: /etc/apt/keyrings/interdiode.gpg\n" in configured_source
|
assert "Signed-By:\n -----BEGIN PGP PUBLIC KEY BLOCK-----" in configured_source
|
||||||
|
assert "existing.gpg" not in configured_source
|
||||||
|
|
||||||
|
|
||||||
def test_apply_without_signed_by_writes_no_keyring_or_field(
|
def test_apply_without_signed_by_writes_no_field(tmp_path) -> None:
|
||||||
tmp_path, monkeypatch
|
|
||||||
) -> None:
|
|
||||||
mock_run = _mock_gpg_dearmor_success(monkeypatch)
|
|
||||||
dst = tmp_path / "dst"
|
dst = tmp_path / "dst"
|
||||||
context = Context(config=make_config_with({}), dst=dst, src=None)
|
context = Context(config=make_config_with({}), dst=dst, src=None)
|
||||||
|
|
||||||
AptModule().apply(context)
|
AptModule().apply(context)
|
||||||
|
|
||||||
mock_run.assert_not_called()
|
|
||||||
assert not (dst / "etc/apt/keyrings").exists()
|
|
||||||
configured_source = (dst / "etc/apt/sources.list.d/interdiode.sources").read_text(
|
configured_source = (dst / "etc/apt/sources.list.d/interdiode.sources").read_text(
|
||||||
encoding="utf-8"
|
encoding="utf-8"
|
||||||
)
|
)
|
||||||
@@ -227,8 +200,7 @@ def test_apply_without_signed_by_writes_no_keyring_or_field(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_apply_sanitizes_source_name_with_slash(tmp_path, monkeypatch) -> None:
|
def test_apply_sanitizes_source_name_with_slash(tmp_path) -> None:
|
||||||
mock_run = _mock_gpg_dearmor_success(monkeypatch)
|
|
||||||
dst = tmp_path / "dst"
|
dst = tmp_path / "dst"
|
||||||
context = Context(
|
context = Context(
|
||||||
config=make_config_with({"signed-by": KEY_CONTENT}, name="../../evil"),
|
config=make_config_with({"signed-by": KEY_CONTENT}, name="../../evil"),
|
||||||
@@ -238,33 +210,6 @@ def test_apply_sanitizes_source_name_with_slash(tmp_path, monkeypatch) -> None:
|
|||||||
|
|
||||||
AptModule().apply(context)
|
AptModule().apply(context)
|
||||||
|
|
||||||
keyring = dst / "etc/apt/keyrings/.._.._evil.gpg"
|
|
||||||
assert keyring.read_bytes() == KEYRING_BYTES
|
|
||||||
source_file = dst / "etc/apt/sources.list.d/.._.._evil.sources"
|
source_file = dst / "etc/apt/sources.list.d/.._.._evil.sources"
|
||||||
assert "Signed-By: /etc/apt/keyrings/.._.._evil.gpg" in source_file.read_text(
|
assert source_file.exists()
|
||||||
encoding="utf-8"
|
assert not (dst / "etc/apt/sources.list.d/evil.sources").exists()
|
||||||
)
|
|
||||||
mock_run.assert_called_once()
|
|
||||||
|
|
||||||
|
|
||||||
def test_resolve_signed_by_raises_when_gpg_not_on_path(tmp_path, monkeypatch) -> None:
|
|
||||||
monkeypatch.setattr("id_configurator.modules.apt.shutil.which", lambda _: None)
|
|
||||||
dst = tmp_path / "dst"
|
|
||||||
context = Context(config=make_config(), dst=dst, src=None)
|
|
||||||
|
|
||||||
with pytest.raises(RuntimeError, match="gpg is not available"):
|
|
||||||
AptModule().apply(context)
|
|
||||||
|
|
||||||
|
|
||||||
def test_resolve_signed_by_raises_on_invalid_key(tmp_path, monkeypatch) -> None:
|
|
||||||
mock_run = MagicMock(
|
|
||||||
return_value=subprocess.CompletedProcess(
|
|
||||||
args=[], returncode=2, stdout=b"", stderr=b"gpg: no valid data found"
|
|
||||||
)
|
|
||||||
)
|
|
||||||
monkeypatch.setattr("id_configurator.modules.apt.subprocess.run", mock_run)
|
|
||||||
dst = tmp_path / "dst"
|
|
||||||
context = Context(config=make_config(), dst=dst, src=None)
|
|
||||||
|
|
||||||
with pytest.raises(RuntimeError, match="invalid GPG key for apt source interdiode"):
|
|
||||||
AptModule().apply(context)
|
|
||||||
|
|||||||
@@ -226,3 +226,39 @@ def test_apt_source_to_deb822_full() -> None:
|
|||||||
"Description: InterDiode APT repository",
|
"Description: InterDiode APT repository",
|
||||||
"Enabled: no",
|
"Enabled: no",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_apt_source_to_deb822_folds_inline_signed_by_key() -> None:
|
||||||
|
source = AptSource(
|
||||||
|
name="interdiode", uris=["http://example.invalid/debian"], suites=["stable"]
|
||||||
|
)
|
||||||
|
armored_key = (
|
||||||
|
"-----BEGIN PGP PUBLIC KEY BLOCK-----\n"
|
||||||
|
"\n"
|
||||||
|
"mQENBGqh08IBCAC0\n"
|
||||||
|
"-----END PGP PUBLIC KEY BLOCK-----\n"
|
||||||
|
)
|
||||||
|
rendered = source.to_deb822(signed_by_armored_key=armored_key)
|
||||||
|
assert rendered == (
|
||||||
|
"Types: deb\n"
|
||||||
|
"URIs: http://example.invalid/debian\n"
|
||||||
|
"Suites: stable\n"
|
||||||
|
"Signed-By:\n"
|
||||||
|
" -----BEGIN PGP PUBLIC KEY BLOCK-----\n"
|
||||||
|
" .\n"
|
||||||
|
" mQENBGqh08IBCAC0\n"
|
||||||
|
" -----END PGP PUBLIC KEY BLOCK-----\n"
|
||||||
|
"Enabled: yes\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_apt_source_to_deb822_signed_by_armored_key_takes_precedence() -> None:
|
||||||
|
source = AptSource(
|
||||||
|
name="interdiode", uris=["http://example.invalid/debian"], suites=["stable"]
|
||||||
|
)
|
||||||
|
rendered = source.to_deb822(
|
||||||
|
signed_by_armored_key="-----BEGIN PGP PUBLIC KEY BLOCK-----\nabc\n-----END PGP PUBLIC KEY BLOCK-----\n",
|
||||||
|
signed_by_path="/etc/apt/keyrings/unused.gpg",
|
||||||
|
)
|
||||||
|
assert "Signed-By:\n" in rendered
|
||||||
|
assert "unused.gpg" not in rendered
|
||||||
|
|||||||
Reference in New Issue
Block a user