From a9e63edfa8ec37195275b448efe195323dd2d4f8 Mon Sep 17 00:00:00 2001 From: Levente Polyak Date: Sun, 24 Oct 2021 22:08:50 +0200 Subject: [PATCH] feature(keyringctl): adding ci command to verify newly added certs Currently only newly added certificates will be checked against the expectations as existing keys are not all fully compatible with those assumptions. New certificates are determined by using $CI_MERGE_REQUEST_DIFF_BASE_SHA as the base, --- README.md | 1 + libkeyringctl/ci.py | 34 +++++++++++++++++++++++++ libkeyringctl/cli.py | 9 +++++++ libkeyringctl/git.py | 55 ++++++++++++++++++++++++++++++++++++++++ libkeyringctl/keyring.py | 27 +++++++++++++++++++- 5 files changed, 125 insertions(+), 1 deletion(-) create mode 100644 libkeyringctl/ci.py create mode 100644 libkeyringctl/git.py diff --git a/README.md b/README.md index aa7b61a..446564d 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,7 @@ from the provided data structure and to install it: Optional: * hopenpgp-tools (verify) * sq-keyring-linter (verify) +* git (ci) ## Usage diff --git a/libkeyringctl/ci.py b/libkeyringctl/ci.py new file mode 100644 index 0000000..ebdbae9 --- /dev/null +++ b/libkeyringctl/ci.py @@ -0,0 +1,34 @@ +# SPDX-License-Identifier: GPL-3.0-or-later + +from os import environ +from pathlib import Path +from typing import List + +from .git import git_changed_files +from .keyring import get_parent_cert_paths +from .keyring import verify + + +def ci(working_dir: Path, keyring_root: Path, project_root: Path) -> None: + """Verify certificates against modern expectations using sq-keyring-linter and hokey + + Currently only newly added certificates will be checked against the expectations as existing + keys are not all fully compatible with those assumptions. + New certificates are determined by using $CI_MERGE_REQUEST_DIFF_BASE_SHA as the base, + + Parameters + ---------- + working_dir: A directory to use for temporary files + keyring_root: The keyring root directory to look up username shorthand sources + project_root: Path to the root of the git repository + """ + + ci_merge_request_diff_base = environ.get("CI_MERGE_REQUEST_DIFF_BASE_SHA") + created, deleted, changed = git_changed_files( + git_path=project_root, base=f"{ci_merge_request_diff_base}", paths=[Path("keyring")] + ) + + added_certificates: List[Path] = list(get_parent_cert_paths(paths=created)) + + if added_certificates: + verify(working_dir=working_dir, keyring_root=keyring_root, sources=added_certificates) diff --git a/libkeyringctl/cli.py b/libkeyringctl/cli.py index 162c9b5..91cf890 100644 --- a/libkeyringctl/cli.py +++ b/libkeyringctl/cli.py @@ -8,6 +8,7 @@ from pathlib import Path from tempfile import TemporaryDirectory from tempfile import mkdtemp +from .ci import ci from .keyring import Username from .keyring import build from .keyring import convert @@ -114,6 +115,11 @@ verify_parser.add_argument( ) verify_parser.set_defaults(lint_hokey=True, lint_sq_keyring=True) +ci_parser = subcommands.add_parser( + "ci", + help="ci command to verify certain aspects and expectations in pipelines", +) + def main() -> None: # noqa: ignore=C901 args = parser.parse_args() @@ -123,6 +129,7 @@ def main() -> None: # noqa: ignore=C901 # temporary working directory that gets auto cleaned with TemporaryDirectory(prefix="arch-keyringctl-") as tempdir: + project_root = Path(".").absolute() keyring_root = Path("keyring").absolute() working_dir = Path(tempdir) debug(f"Working directory: {working_dir}") @@ -190,6 +197,8 @@ def main() -> None: # noqa: ignore=C901 lint_hokey=args.lint_hokey, lint_sq_keyring=args.lint_sq_keyring, ) + elif "ci" == args.subcommand: + ci(working_dir=working_dir, keyring_root=keyring_root, project_root=project_root) else: parser.print_help() diff --git a/libkeyringctl/git.py b/libkeyringctl/git.py new file mode 100644 index 0000000..22c6b50 --- /dev/null +++ b/libkeyringctl/git.py @@ -0,0 +1,55 @@ +# SPDX-License-Identifier: GPL-3.0-or-later + +from pathlib import Path +from typing import List +from typing import Optional +from typing import Tuple + +from .util import system + + +def git_changed_files( + git_path: Optional[Path], base: Optional[str], paths: Optional[List[Path]] = None +) -> Tuple[List[Path], List[Path], List[Path]]: + """Returns lists of created, deleted and changed files based on diff stats related to a base commit + and optional paths. + + Parameters + ---------- + git_path: Path to the git repository, current directory by default + base: Optional base rev or current index by default + paths: Optional list of paths to take into account, unfiltered by default + + Returns + ------- + Lists of created, deleted and changed paths + """ + cmd = ["git"] + if git_path: + cmd += ["-C", str(git_path)] + cmd += ["--no-pager", "diff", "--color=never", "--summary", "--numstat"] + if base: + cmd += [base] + if paths: + cmd += ["--"] + cmd += [str(path) for path in paths] + + result: str = system(cmd) + + created: List[Path] = [] + deleted: List[Path] = [] + changed: List[Path] = [] + + for line in result.splitlines(): + line = line.strip() + if line.startswith("create"): + created.append(Path(line.split(maxsplit=3)[3])) + continue + if line.startswith("delete"): + deleted.append(Path(line.split(maxsplit=3)[3])) + continue + changed.append(Path(line.split(maxsplit=2)[2])) + + changed = [path for path in changed if path not in created and path not in deleted] + + return created, deleted, changed diff --git a/libkeyringctl/keyring.py b/libkeyringctl/keyring.py index d66ed36..8138b0d 100644 --- a/libkeyringctl/keyring.py +++ b/libkeyringctl/keyring.py @@ -57,7 +57,7 @@ def get_cert_paths(paths: Iterable[Path]) -> Set[Path]: Returns ------- - The list of paths to certificates + A set of paths to certificates """ # depth first search certificate paths @@ -73,6 +73,31 @@ def get_cert_paths(paths: Iterable[Path]) -> Set[Path]: return cert_paths +def get_parent_cert_paths(paths: Iterable[Path]) -> Set[Path]: + """Walks a list of paths upwards and resolves all discovered parent certificate paths + + Parameters + ---------- + paths: A list of paths to walk and resolve to certificate paths. + + Returns + ------- + A set of paths to certificates + """ + + # depth first search certificate paths + cert_paths: Set[Path] = set() + visit: List[Path] = list(paths) + while visit: + node = visit.pop().parent + # this level contains a certificate, abort depth search + if "keyring" == node.parent.parent.parent.name: + cert_paths.add(node) + continue + visit.append(node) + return cert_paths + + def transform_username_to_keyring_path(keyring_dir: Path, paths: List[Path]) -> None: """Mutates the input sources by transforming passed usernames to keyring paths