dovecot: security backports, new versioning scheme

- Fix dovecot package download URLs for new
  [release](https://github.com/chatmail/dovecot/releases/tag/upstream%2F2.3.21%2Bdfsg1-3%2Bchatmail2)
  with
    - debian-security backport for 12 CVEs
    - distro-specific suffix (+deb{release}u1), simplified primary URL
      path: remove codename subdirectory
- Use VERSION_ID from os-release as deb_release instead of codename
  mapping, reorder hash-dict to match Github release page
- Remove redundant parsing/validation, let function validate against hash dict
- Update test expectations
This commit is contained in:
j4n
2026-08-10 10:26:22 +02:00
parent 6b872446e1
commit 623ab2aefb
2 changed files with 54 additions and 66 deletions
+29 -27
View File
@@ -14,22 +14,22 @@ from cmdeploy.basedeploy import (
is_in_container,
)
DOVECOT_ARCHIVE_VERSION = "2.3.21+dfsg1-3"
DOVECOT_ARCHIVE_VERSION = "2.3.21+dfsg1-3+chatmail2"
DOVECOT_PACKAGE_VERSION = f"1:{DOVECOT_ARCHIVE_VERSION}"
DOVECOT_SHA256 = {
("amd64", "bookworm", "core"): "dd060706f52a306fa863d874717210b9fe10536c824afe1790eec247ded5b27d",
("arm64", "bookworm", "core"): "e7548e8a82929722e973629ecc40fcfa886894cef3db88f23535149e7f730dc9",
("amd64", "bookworm", "imapd"): "8d8dc6fc00bbb6cdb25d345844f41ce2f1c53f764b79a838eb2a03103eebfa86",
("arm64", "bookworm", "imapd"): "178fa877ddd5df9930e8308b518f4b07df10e759050725f8217a0c1fb3fd707f",
("amd64", "bookworm", "lmtpd"): "2f69ba5e35363de50962d42cccbfe4ed8495265044e244007d7ccddad77513ab",
("arm64", "bookworm", "lmtpd"): "89f52fb36524f5877a177dff4a713ba771fd3f91f22ed0af7238d495e143b38f",
("amd64", "trixie", "core"): "406d3781ed81e0913c472077dcf62cb1106e3855983efa6e44ddf43b4b0c9be1",
("arm64", "trixie", "core"): "c75b0d9df11a77d07ebd8522920380c167fa47330ddefebe10575d99d0ecdf7f",
("amd64", "trixie", "imapd"): "8d8dc6fc00bbb6cdb25d345844f41ce2f1c53f764b79a838eb2a03103eebfa86",
("arm64", "trixie", "imapd"): "178fa877ddd5df9930e8308b518f4b07df10e759050725f8217a0c1fb3fd707f",
("amd64", "trixie", "lmtpd"): "2f69ba5e35363de50962d42cccbfe4ed8495265044e244007d7ccddad77513ab",
("arm64", "trixie", "lmtpd"): "89f52fb36524f5877a177dff4a713ba771fd3f91f22ed0af7238d495e143b38f",
("amd64", 12, "core"): "ac3977264d9b9a6fcec53fd3f5cdd2a79ca8aa0324de530c07e535008540826e",
("arm64", 12, "core"): "21626c9c9b52cbdcf1a17b5c09e3c4043e69aa371bf83cc2fcb3b7ddaecdc109",
("amd64", 13, "core"): "47c242ef23c17e700ac19d52d82c9fdb2ebd757d8beb3a7f6781d2de59f87bd0",
("arm64", 13, "core"): "c14c53f112c875f698c4cb6e5870c605cd0a9dd98d35a66e94ceb1827f8020a3",
("amd64", 12, "imapd"): "92a7ab5fc7dc32886a0c34404f919f1335d397b48c467e0c1ef77e56978f60ea",
("arm64", 12, "imapd"): "9369fd566fec4df109ef23debf34ea0417ae85beb29cbe7de619d4d1f31b120c",
("amd64", 13, "imapd"): "e38cc1266455f937ed62f971ea859c47e1a99247841ed0ad946963b524cfdbc5",
("arm64", 13, "imapd"): "11d97dabf23171b37f8b1335dfdb81d408f8b95391aea6d4066aecc9fde01dfe",
("amd64", 12, "lmtpd"): "dc3de473789969f7dd3504ac8783da5e42a446d2d7a305a4e9d7081a6dfe71ab",
("arm64", 12, "lmtpd"): "ae2cbd6c5c43f6d8e2172997b055448f4c79238e2f99cd9ab9200a7d9f548908",
("amd64", 13, "lmtpd"): "833b243e28c7baff141ecf37456e310f5d836e7944a3b9f2fe5074adf0d6a418",
("arm64", 13, "lmtpd"): "55af47a121ba7e23966b20ddaab2dff7feba4b34677864e045e31a702afa180d",
}
@@ -43,13 +43,12 @@ class DovecotDeployer(Deployer):
def install(self):
arch = host.get_fact(Arch)
codename = (host.get_fact(Command, "grep '^VERSION_CODENAME=' /etc/os-release | cut -d= -f2") or "").strip()
if codename not in {key[1] for key in DOVECOT_SHA256}:
raise ValueError(f"Unsupported Debian codename: {codename!r}")
version_line = (host.get_fact(Command, "grep '^VERSION_ID=' /etc/os-release") or "").strip()
deb_release = int(version_line.split("=", 1)[1].strip('"'))
with blocked_service_startup():
debs = []
for pkg in ("core", "imapd", "lmtpd"):
deb, changed = _download_dovecot_package(pkg, arch, codename)
deb, changed = _download_dovecot_package(pkg, arch, deb_release)
self.need_restart |= changed
if deb:
debs.append(deb)
@@ -105,29 +104,32 @@ def _pick_url(primary, fallback):
return fallback
def _download_dovecot_package(package: str, arch: str, codename: str) -> tuple[str | None, bool]:
def _download_dovecot_package(package: str, arch: str, deb_release: int) -> tuple[str | None, bool]:
"""Download a dovecot .deb if needed, return (path, changed)."""
arch = "amd64" if arch == "x86_64" else arch
arch = "arm64" if arch == "aarch64" else arch
pkg_name = f"dovecot-{package}"
sha256 = DOVECOT_SHA256.get((arch, codename, package))
if sha256 is None:
if (arch, deb_release, package) not in DOVECOT_SHA256:
op = apt.packages(packages=[pkg_name])
return None, bool(getattr(op, "changed", False))
sha256 = DOVECOT_SHA256[(arch, deb_release, package)]
installed_versions = host.get_fact(DebPackages).get(pkg_name, [])
if DOVECOT_PACKAGE_VERSION in installed_versions:
return None, False
url_version = DOVECOT_ARCHIVE_VERSION.replace("+", "%2B")
deb_base = f"{pkg_name}_{url_version}_{arch}.deb"
primary_url = f"https://download.delta.chat/dovecot/{codename}/{url_version}/{deb_base}"
upstream_version = DOVECOT_ARCHIVE_VERSION.rsplit("-", 1)[0].replace("+", "%2B")
fallback_deb = f"{pkg_name}_{url_version}_{arch}_{codename}.deb"
fallback_url = f"https://github.com/chatmail/dovecot/releases/download/upstream%2F{upstream_version}/{fallback_deb}"
# Primary URL: flat structure with distro suffix in filename
primary_deb = f"{pkg_name}_{DOVECOT_ARCHIVE_VERSION}+deb{deb_release}u1_{arch}.deb"
primary_url = f"https://download.delta.chat/dovecot/{primary_deb}"
# GitHub release files: escaped + in tag and filename
url_version_escaped = DOVECOT_ARCHIVE_VERSION.replace("+", "%2B")
fallback_deb = f"{pkg_name}_{url_version_escaped}%2Bdeb{deb_release}u1_{arch}.deb"
fallback_url = (
f"https://github.com/chatmail/dovecot/releases/download/upstream%2F{url_version_escaped}/{fallback_deb}"
)
url = _pick_url(primary_url, fallback_url)
deb_filename = f"/root/{deb_base}"
deb_filename = f"/root/{primary_deb}"
files.download(
name=f"Download {pkg_name}",
@@ -23,9 +23,7 @@ def make_host(*fact_pairs):
def get_fact(cls, *args):
if cls not in facts:
registered = ", ".join(c.__name__ for c in facts)
raise LookupError(
f"unexpected get_fact({cls.__name__}); only registered: {registered}"
)
raise LookupError(f"unexpected get_fact({cls.__name__}); only registered: {registered}")
return facts[cls]
return SimpleNamespace(get_fact=get_fact)
@@ -83,9 +81,7 @@ def test_download_dovecot_package_skips_epoch_matched_install(monkeypatch):
lambda **kwargs: downloads.append(kwargs),
)
deb, changed = dovecot_deployer._download_dovecot_package(
"core", "amd64", codename="bookworm"
)
deb, changed = dovecot_deployer._download_dovecot_package("core", "amd64", deb_release=12)
assert deb is None, f"expected no deb path when version matches, got {deb!r}"
assert changed is False, "should not flag changed when version already installed"
@@ -112,14 +108,11 @@ def test_download_dovecot_package_uses_archive_version_for_url_and_filename(
lambda **kwargs: downloads.append(kwargs),
)
deb, changed = dovecot_deployer._download_dovecot_package(
"core", "amd64", codename="bookworm"
)
deb, changed = dovecot_deployer._download_dovecot_package("core", "amd64", deb_release=12)
archive_version = dovecot_deployer.DOVECOT_ARCHIVE_VERSION.replace("+", "%2B")
expected_deb = f"/root/dovecot-core_{archive_version}_amd64.deb"
expected_deb = f"/root/dovecot-core_{dovecot_deployer.DOVECOT_ARCHIVE_VERSION}+deb12u1_amd64.deb"
# Verify the returned path uses archive version, not package version (with epoch)
# Verify the returned path uses archive version with distro suffix, not package version (with epoch)
assert changed is True, "should flag changed when package not yet installed"
assert deb == expected_deb, f"deb path mismatch: {deb!r} != {expected_deb!r}"
assert dovecot_deployer.DOVECOT_PACKAGE_VERSION not in deb, (
@@ -144,7 +137,7 @@ def test_install_skips_dpkg_path_when_epoch_matched_packages_present(
},
),
(dovecot_deployer.Arch, "x86_64"),
(Command, "bookworm"),
(Command, 'VERSION_ID="12"'),
),
)
downloads = []
@@ -158,20 +151,16 @@ def test_install_skips_dpkg_path_when_epoch_matched_packages_present(
assert downloads == [], "should not download when all packages epoch-matched"
assert track_shell == [], "should not run dpkg when all packages epoch-matched"
assert deployer.need_restart is False, (
"need_restart should be False when nothing changed"
)
assert deployer.need_restart is False, "need_restart should be False when nothing changed"
def test_install_unsupported_arch_falls_back_to_apt(
deployer, patch_blocked, mock_files_put, track_shell, monkeypatch
):
def test_install_unsupported_arch_falls_back_to_apt(deployer, patch_blocked, mock_files_put, track_shell, monkeypatch):
monkeypatch.setattr(
dovecot_deployer,
"host",
make_host(
(dovecot_deployer.Arch, "riscv64"),
(Command, "bookworm"),
(Command, 'VERSION_ID="12"'),
),
)
apt_calls = []
@@ -192,9 +181,7 @@ def test_install_unsupported_arch_falls_back_to_apt(
f"expected apt install of core/imapd/lmtpd, got {actual_pkgs}"
)
assert track_shell == [], "should not run dpkg for unsupported arch"
assert deployer.need_restart is True, (
"need_restart should be True when apt installed a package"
)
assert deployer.need_restart is True, "need_restart should be True when apt installed a package"
def test_install_runs_dpkg_when_packages_need_download(
@@ -206,7 +193,7 @@ def test_install_runs_dpkg_when_packages_need_download(
make_host(
(dovecot_deployer.DebPackages, {}),
(dovecot_deployer.Arch, "x86_64"),
(Command, "bookworm"),
(Command, 'VERSION_ID="12"'),
),
)
monkeypatch.setattr(
@@ -222,9 +209,7 @@ def test_install_runs_dpkg_when_packages_need_download(
deployer.install()
assert len(track_shell) == 1, (
f"expected one server.shell() call for dpkg install, got {len(track_shell)}"
)
assert len(track_shell) == 1, f"expected one server.shell() call for dpkg install, got {len(track_shell)}"
cmds = track_shell[0]["commands"]
assert len(cmds) == 1, f"expected single apt-get install command, got: {cmds}"
assert "apt-get install -y" in cmds[0]
@@ -232,9 +217,7 @@ def test_install_runs_dpkg_when_packages_need_download(
assert '-o Dpkg::Options::="--force-confold"' in cmds[0]
assert "--allow-downgrades" in cmds[0]
assert ".deb" in cmds[0]
assert deployer.need_restart is True, (
"need_restart should be True after dpkg install"
)
assert deployer.need_restart is True, "need_restart should be True after dpkg install"
def test_pick_url_falls_back_on_primary_error(monkeypatch):
@@ -243,22 +226,25 @@ def test_pick_url_falls_back_on_primary_error(monkeypatch):
monkeypatch.setattr(dovecot_deployer.urllib.request, "urlopen", raise_error)
result = dovecot_deployer._pick_url("http://primary", "http://fallback")
assert result == "http://fallback", (
f"should fall back when primary fails, got {result!r}"
)
assert result == "http://fallback", f"should fall back when primary fails, got {result!r}"
def test_install_fails_on_unsupported_debian_version(
deployer, patch_blocked, monkeypatch
):
def test_install_fails_on_unsupported_debian_version(deployer, patch_blocked, monkeypatch):
monkeypatch.setattr(
dovecot_deployer,
"host",
make_host(
(dovecot_deployer.Arch, "x86_64"),
(Command, "sid"),
(Command, 'VERSION_ID="99"'),
),
)
with pytest.raises(ValueError, match="Unsupported Debian codename"):
deployer.install()
# Mock apt.packages to handle unsupported release gracefully
def fake_apt(**kwargs):
return SimpleNamespace(changed=False)
monkeypatch.setattr(dovecot_deployer.apt, "packages", fake_apt)
# Should not error, just skip the dovecot-specific packages and use apt
deployer.install()
assert deployer.need_restart is False, "unsupported release should not trigger restart"