refactor!: port shell scripts to Python package
Bash scripts were untestable and duplicated device/LUKS/mount logic; the lim/ package centralizes it behind one subprocess wrapper and a YAML image catalog (single point of truth). BREAKING CHANGE: scripts/*.sh removed. Use `lim --type <cmd>`; new types mount/umount/single-boot/raid1-boot/lock/unlock/import/export replace direct script calls. --extra is deprecated and ignored. - distributions.yml + lim/catalog.py hold the image catalog (PyYAML) - pytest suite: 102 tests with mocked subprocess (tests/unit) and a 250-line max file-length guard (tests/lint) - ruff strict (select ALL), GitHub Actions CI, Dependabot; Travis gone - Makefile: install (symlink ~/.local/bin/lim) and test targets - fixes over bash: SUDO_USER-aware chown, mmcblk/nvme partition paths, sha512 checksum support, whole-pipeline failure detection, blkid UUID fallback for pre-mounted images, conditional fstab seeding for PARTUUID/LABEL images, clean errors for missing binaries
This commit is contained in:
100
lim/image/verify.py
Normal file
100
lim/image/verify.py
Normal file
@@ -0,0 +1,100 @@
|
||||
"""Image integrity (checksums) and authenticity (GPG signatures) checks."""
|
||||
|
||||
import hashlib
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
from lim import runner, ui
|
||||
from lim.errors import LimError
|
||||
|
||||
ALGORITHM_BY_DIGEST_LENGTH = {
|
||||
32: "md5",
|
||||
40: "sha1",
|
||||
64: "sha256",
|
||||
128: "sha512",
|
||||
}
|
||||
|
||||
|
||||
def url_exists(url: str) -> bool:
|
||||
return runner.succeeds(["wget", "-q", "--method=HEAD", url])
|
||||
|
||||
|
||||
def resolve_checksum(download_url: str) -> str | None:
|
||||
"""Try to fetch a published checksum next to the image download."""
|
||||
for extension in ("sha1", "sha512", "md5"):
|
||||
checksum_url = f"{download_url}.{extension}"
|
||||
ui.info(
|
||||
"Image Checksum is not defined. "
|
||||
f"Try to download image signature from {checksum_url}."
|
||||
)
|
||||
if url_exists(checksum_url):
|
||||
content = runner.output(["wget", checksum_url, "-q", "-O", "-"])
|
||||
checksum = content.split()[0] if content.split() else ""
|
||||
if checksum:
|
||||
ui.info(f"Defined image_checksum as {checksum}")
|
||||
return checksum
|
||||
ui.warning(f"No checksum found under {checksum_url}.")
|
||||
return None
|
||||
|
||||
|
||||
def verify_checksum(image_path: str | Path, checksum: str | None) -> None:
|
||||
"""Verify the image against an md5/sha1/sha256/sha512 hex checksum."""
|
||||
if not checksum:
|
||||
ui.warning("No checksum is defined. Skipping checksum verification.")
|
||||
return
|
||||
algorithm = ALGORITHM_BY_DIGEST_LENGTH.get(len(checksum))
|
||||
if algorithm is None:
|
||||
raise LimError(
|
||||
f"Checksum '{checksum}' has no recognized digest length "
|
||||
"(expected md5, sha1, sha256 or sha512)."
|
||||
)
|
||||
ui.info(f"Checking {algorithm} checksum...")
|
||||
digest = hashlib.new(algorithm)
|
||||
with Path(image_path).open("rb") as handle:
|
||||
while chunk := handle.read(1024 * 1024):
|
||||
digest.update(chunk)
|
||||
if digest.hexdigest() != checksum.lower():
|
||||
raise LimError("Verification failed. HINT: Force the download of the image.")
|
||||
ui.info(f"{algorithm} checksum verified.")
|
||||
|
||||
|
||||
def verify_signature(download_url: str, image_path: str | Path, image_folder: Path) -> None:
|
||||
"""Best-effort GPG signature verification of the downloaded image."""
|
||||
ui.info("Note: Checksums verify integrity but do not confirm authenticity.")
|
||||
ui.info(
|
||||
"Proceeding to signature verification, "
|
||||
"which ensures the file comes from a trusted source."
|
||||
)
|
||||
signature_url = f"{download_url}.sig"
|
||||
ui.info(f"Attempting to download the image signature from: {signature_url}")
|
||||
if not url_exists(signature_url):
|
||||
ui.warning(f"No signature found under {signature_url}.")
|
||||
return
|
||||
|
||||
signature_path = image_folder / (Path(str(image_path)).name + ".sig")
|
||||
if not runner.succeeds(["wget", "-q", "-O", str(signature_path), signature_url]):
|
||||
ui.warning("Failed to download the signature file.")
|
||||
return
|
||||
|
||||
ui.info("Extract the key ID from the signature file")
|
||||
verification = runner.output(
|
||||
["gpg", "--status-fd", "1", "--verify", str(signature_path), str(image_path)],
|
||||
check=False,
|
||||
)
|
||||
missing_keys = re.findall(r"NO_PUBKEY (\S+)", verification)
|
||||
if missing_keys:
|
||||
key_id = missing_keys[-1]
|
||||
if runner.succeeds(["gpg", "--list-keys", key_id]):
|
||||
ui.info(f"Key {key_id} already in keyring.")
|
||||
else:
|
||||
ui.info("Import the public key")
|
||||
runner.run(
|
||||
["gpg", "--keyserver", "keyserver.ubuntu.com", "--recv-keys", key_id],
|
||||
check=False,
|
||||
)
|
||||
|
||||
ui.info("Verify the signature")
|
||||
if runner.succeeds(["gpg", "--verify", str(signature_path), str(image_path)]):
|
||||
ui.info("Signature verification succeeded.")
|
||||
else:
|
||||
ui.warning("Signature verification failed.")
|
||||
Reference in New Issue
Block a user