diff --git a/src/all-hooks/package-submodule-blacklist b/src/all-hooks/package-submodule-blacklist index cd8386a..fbcc8cc 100755 --- a/src/all-hooks/package-submodule-blacklist +++ b/src/all-hooks/package-submodule-blacklist @@ -1,67 +1,119 @@ -#!/bin/bash -# -# A git server hook that rejects pushes containing blacklisted package submodules. - -set -euo pipefail - -BLACKLIST_FILE="_obs/hooks/package-submodule-blacklist.txt" - -hook_name_printed=false - -# Read the old and new commit SHAs from stdin. -while read -r old_rev new_rev ref_name; do - - # Skip on branch deletion. - [[ "$new_rev" =~ ^0+$ ]] && continue - - # Handle branch creation. - range="${old_rev}..${new_rev}" - blacklist_rev="$old_rev" - if [[ "$old_rev" =~ ^0+$ ]]; then - range="$new_rev" - blacklist_rev="$new_rev" - fi - - # Skip if blacklist file does not exist in this revision. - git cat-file -e "${blacklist_rev}:${BLACKLIST_FILE}" 2>/dev/null || continue - - [ "$hook_name_printed" = false ] && echo "Running hook: $(basename $0)" && hook_name_printed=true - - # Load blacklist file into an in-memory set. - declare -A blacklist_names=() - while IFS= read -r name; do - [ -n "$name" ] && blacklist_names["$name"]=1 - done < <(git show "${blacklist_rev}:${BLACKLIST_FILE}" 2>/dev/null || true) - [ "${#blacklist_names[@]}" -eq 0 ] && continue - - # Limit range to only new commits relative to repository history. - merge_base=$(git merge-base "${new_rev}" $(git for-each-ref --format='%(objectname)') 2>/dev/null | head -n1 || true) - [ -n "$merge_base" ] && range="${merge_base}..${new_rev}" - - # Walk each introduced commit and check changed submodule entries. - declare -A matches=() - while read -r commit; do - while IFS=$'\t' read -r meta path _rest; do - read -r _old_mode new_mode _old_sha _new_sha status <<< "${meta#:}" - [ "$new_mode" = "160000" ] || continue - case "$status" in - D*) continue ;; - esac - - name=${path##*/} - [ -n "${blacklist_names[$name]+x}" ] && matches["$name"]=1 - done < <(git diff-tree --root -r --no-commit-id "$commit") - done < <(git rev-list "${range}") - - if [ "${#matches[@]}" -gt 0 ]; then - echo "Commit/range: ${range}" >&2 - # Report unique blacklisted submodules found. - printf '%s\n' "${!matches[@]}" | sort | while read -r name; do - echo "ERROR: Blacklisted submodule: ${name}" >&2 - done - exit 1 - fi - -done - -exit 0 +#!/usr/bin/python3 + +import os +import subprocess +import sys + +if os.environ.get("PACKAGE_SUBMODULE_BLACKLIST_SKIP", None): + sys.exit(0) + +from osc.git_scm.store import GitStore +from osc.gitea_api.git import Git + + +BLACKLIST_FILE = ".git-workflow/hooks/package-submodule-blacklist.txt" + + +def check_revision(rev: str) -> bool: + """ + Check whether a revision contains blacklisted package submodules. + Return True on success, False on failure. + """ + success = True + + # we always need to instantiate a new GitStore because it uses ref to load the correct _manifest + store = GitStore(".", ref=rev, check=False) + git = store._git + + blacklist_names = set() + if git.is_bare: + try: + data = git._run_git(["cat-file", "blob", f"{rev}:{BLACKLIST_FILE}"], mute_stderr=True) + blacklist_names = {i.strip() for i in data.splitlines() if i.strip()} + except subprocess.CalledProcessError: + # blacklist file doesn't exist -> skip the check, report success + return success + else: + try: + with open(BLACKLIST_FILE, "r", encoding="utf-8") as f: + blacklist_names = {i.strip() for i in f.readlines() if i.strip()} + except FileNotFoundError: + # blacklist file doesn't exist -> skip the check, report success + return success + + manifest = store.manifest + if manifest is not None: + if git.is_bare: + directories = git.ls_tree(rev) + directories = [i["path"] for i in directories if i["file_type"] in ("directory", "submodule")] + package_paths = manifest.get_package_paths_bare_git(store.topdir, directories=directories) + else: + package_paths = manifest.get_package_paths(store.topdir, relative=True) + else: + directories = git.ls_tree(rev) + package_paths = [i["path"] for i in directories if i["file_type"] in ("directory", "submodule")] + + submodules = store._git.get_submodules(ref=rev) + submodule_names = set() + for key, value in submodules.items(): + if value["path"] not in package_paths: + continue + submodule_names.add(os.path.basename(key)) + + blacklist_names = {os.path.basename(i) for i in blacklist_names} + + matches = sorted(submodule_names & blacklist_names) + if matches: + print(f"Error in commit {rev}") + print("The following package submodule names are blacklisted: ") + for name in matches: + print(f" - {name}") + success = False + return success + + +def parse_prepush_githook_stdin(): + """ + Returns [(local_ref, local_sha, remote_ref, remote_sha)] from stdin. + Standard for pre-push hooks. + """ + result = [] + for line in sys.stdin: + parts = line.split() + if len(parts) == 4: + # pre-push: + local_ref, local_sha, remote_ref, remote_sha = parts + result.append((local_ref, local_sha, remote_ref, remote_sha)) + else: + raise RuntimeError(f"Unexpected line in githook stdin: {line}") + return result + + +def main(): + git = Git(".") + seen = set() + success = True + + # poor man's detection that we're in a pre-receive hook + is_pre_receive = bool(git.is_bare) + + if is_pre_receive: + rev_list = [(i[0], i[1]) for i in git.parse_githook_stdin()] + else: + rev_list = [(i[3], i[1]) for i in parse_prepush_githook_stdin()] + + for old_rev, new_rev in rev_list: + # we want to enable 'not_all' only in the pre-receive hook + commits = git.get_commit_range(old_rev, new_rev, not_all=is_pre_receive) + for sha in commits: + if sha in seen: + continue + success &= check_revision(sha) + seen.add(sha) + + if not success: + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/src/all-hooks/package-submodule-blacklist-client-side b/src/all-hooks/package-submodule-blacklist-client-side deleted file mode 100755 index 9925fbc..0000000 --- a/src/all-hooks/package-submodule-blacklist-client-side +++ /dev/null @@ -1,56 +0,0 @@ -#!/usr/bin/python3 - -""" -A git hook that reports an error if a package submodule in a project git is blacklisted. -""" - -import os -import sys - -sys.dont_write_bytecode = True - - -BLACKLIST_FILE = "_obs/hooks/package-submodule-blacklist.txt" -if not os.path.isfile(BLACKLIST_FILE): - # skip the checking repos without the configuration file - sys.exit(0) - - -def main(): - from osc.git_scm import GitStore - - store = GitStore(".", check=False) - if not store.is_project: - # the check is applicable only to projects - sys.exit(0) - - package_paths = store.manifest.get_package_paths(store.topdir, relative=True) - submodules = store._git.get_submodules() - - errors = [] - submodule_names = set() - for key, value in submodules.items(): - if value["path"] not in package_paths: - continue - if key != value["path"]: - errors.append(f"Submodule '{key}' has inconsistent path: {value['path']}") - submodule_names.add(os.path.basename(key)) - - if errors: - print("The following errors were found while processing the project:") - for error in errors: - print(f" - {error}") - - with open(BLACKLIST_FILE, "r", encoding="utf-8") as f: - blacklist_names = {i.strip() for i in f.readlines() if i.strip()} - - matches = sorted(submodule_names & blacklist_names) - if matches: - print("The following package submodule names are blacklisted: ") - for name in matches: - print(f" - {name}") - sys.exit(1) - - -if __name__ == "__main__": - main() diff --git a/src/git-obs/pre-push.d/package-submodule-blacklist b/src/git-obs/pre-push.d/package-submodule-blacklist new file mode 120000 index 0000000..5c89d99 --- /dev/null +++ b/src/git-obs/pre-push.d/package-submodule-blacklist @@ -0,0 +1 @@ +../../all-hooks/package-submodule-blacklist \ No newline at end of file diff --git a/src/git-obs/pre-push.d/package-submodule-blacklist-client-side b/src/git-obs/pre-push.d/package-submodule-blacklist-client-side deleted file mode 120000 index e4f71b0..0000000 --- a/src/git-obs/pre-push.d/package-submodule-blacklist-client-side +++ /dev/null @@ -1 +0,0 @@ -../../all-hooks/package-submodule-blacklist-client-side \ No newline at end of file diff --git a/tests/test_package_submodule_blacklist.py b/tests/test_package_submodule_blacklist.py new file mode 100644 index 0000000..4c170d4 --- /dev/null +++ b/tests/test_package_submodule_blacklist.py @@ -0,0 +1,223 @@ +import unittest +import tempfile +import shutil +import os +import subprocess + +BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +HOOKS_DIR = os.path.join(BASE_DIR, "src", "all-hooks") +HOOK_PATH = os.path.join(HOOKS_DIR, "package-submodule-blacklist") + +BLACKLIST_FILE = ".git-workflow/hooks/package-submodule-blacklist.txt" +# _manifest that declares "rpms" as a package subdirectory. +SUBDIRECTORY_MANIFEST = "packages: []\nsubdirectories:\n - rpms\n" +# Substring the hook prints when it rejects a submodule. +REJECTION_MESSAGE = "The following package submodule names are blacklisted" + + +class TestPackageSubmoduleBlacklist(unittest.TestCase): + # Repository setup ----------------------------------------------------- + + def setUp(self): + self.old_cwd = os.getcwd() + self.tmpdir = tempfile.mkdtemp(prefix="psb_test_") + + # Local development repository (pushes to the "factory" branch). + self.repo_path = self.create_new_repo(os.path.join(self.tmpdir, "repo"), branch="factory") + + # Bare remote where the pre-receive hook runs. The hook is installed + # per test via install_hook() so tests can push before it is active. + self.bare_repo_path = self.create_new_repo(os.path.join(self.tmpdir, "bare_repo.git"), bare=True) + self.run_git(["remote", "add", "origin", self.bare_repo_path]) + + def tearDown(self): + os.chdir(self.old_cwd) + shutil.rmtree(self.tmpdir) + + def create_new_repo(self, path, bare=False, branch="main"): + os.makedirs(path, exist_ok=True) + args = ["init", "-q", "-b", branch] + if bare: + args.append("--bare") + self.run_git(args, cwd=path) + if not bare: + self.run_git(["config", "user.email", "test@example.com"], cwd=path) + self.run_git(["config", "user.name", "Test User"], cwd=path) + return path + + def install_hook(self): + hook = os.path.join(self.bare_repo_path, "hooks", "pre-receive") + shutil.copy2(HOOK_PATH, hook) + # A non-executable hook is skipped silently by git, which would let the + # "allow" tests pass without ever exercising the hook. Fail loudly instead. + self.assertTrue(os.access(hook, os.X_OK), "pre-receive hook is not executable") + + # Git helpers ---------------------------------------------------------- + + def run_git(self, args, cwd=None, env=None): + # Isolate git from any ambient user/system configuration so results are reproducible. + env = { + **os.environ, + **(env or {}), + "HOME": self.tmpdir, # ignore ~/.gitconfig + "GIT_CONFIG_NOSYSTEM": "1", # ignore /etc/gitconfig + } + return subprocess.check_output( + ["git"] + args, cwd=cwd or self.repo_path, encoding="utf-8", stderr=subprocess.STDOUT, env=env + ) + + def create_commit(self, files, msg="Commit"): + for path, content in files.items(): + full_path = os.path.join(self.repo_path, path) + os.makedirs(os.path.dirname(full_path), exist_ok=True) + with open(full_path, "w") as f: + f.write(content) + self.run_git(["add", path]) + self.run_git(["commit", "-m", msg, "--allow-empty"]) + + def add_submodule(self, name, path): + sub_repo_path = os.path.join(self.tmpdir, name) + if not os.path.exists(sub_repo_path): + self.create_new_repo(sub_repo_path) + with open(os.path.join(sub_repo_path, "file"), "w") as f: + f.write("data") + self.run_git(["add", "file"], cwd=sub_repo_path) + self.run_git(["commit", "-m", "Initial"], cwd=sub_repo_path) + + self.run_git(["-c", "protocol.file.allow=always", "submodule", "add", sub_repo_path, path]) + self.run_git(["commit", "-m", f"Add submodule {name}"]) + + def remove_submodule(self, path): + self.run_git(["submodule", "deinit", "-f", path]) + self.run_git(["rm", "-f", path]) + self.run_git(["commit", "-m", f"Remove submodule {path}"]) + + # Assertions ----------------------------------------------------------- + + def push(self, env=None): + return self.run_git(["push", "origin", "factory"], env=env) + + def local_head(self): + return self.run_git(["rev-parse", "HEAD"]).strip() + + def remote_head(self): + return self.run_git(["rev-parse", "refs/heads/factory"], cwd=self.bare_repo_path).strip() + + def assert_push_allowed(self): + self.assertNotIn(REJECTION_MESSAGE, self.push()) + # Confirm the commit actually landed on the remote, not just that push didn't error. + self.assertEqual(self.remote_head(), self.local_head()) + + def assert_push_rejected(self, name): + with self.assertRaises(subprocess.CalledProcessError) as cm: + self.push() + self.assertIn(REJECTION_MESSAGE, cm.exception.output) + self.assertIn(name, cm.exception.output) + + # Tests ---------------------------------------------------------------- + + def test_package_simple_allow(self): + # Package files only, no blacklist -> push allowed. + self.install_hook() + self.create_commit({ + "pkg1.spec": "Name: pkg1\nVersion: 1\n", + "pkg1.changes": "- Initial Version\n", + }) + + self.assert_push_allowed() + + def test_project_blacklist_new_reject(self): + # Blacklisted submodule, no _manifest/_config files -> push rejected. + self.install_hook() + self.create_commit({BLACKLIST_FILE: "blacklisted-submodule\n"}) + self.add_submodule("blacklisted-submodule", "blacklisted-submodule") + + self.assert_push_rejected("blacklisted-submodule") + + def test_project_blacklist_pre_exists_reject(self): + # Blacklist already on the remote; later adding a blacklisted submodule + # under a manifest subdirectory -> push rejected. + self.install_hook() + self.create_commit({ + BLACKLIST_FILE: "blacklisted-submodule\n", + "_manifest": SUBDIRECTORY_MANIFEST, + }) + self.push() + + self.add_submodule("blacklisted-submodule", "rpms/blacklisted-submodule") + + self.assert_push_rejected("blacklisted-submodule") + + def test_project_config_allow(self): + # Project with _config, submodule not in the blacklist -> push allowed. + self.install_hook() + self.create_commit({ + BLACKLIST_FILE: "blacklisted-submodule\n", + "_config": "# OBS Project Config\n", + }) + self.add_submodule("allowed-submodule", "allowed-submodule") + + self.assert_push_allowed() + + def test_project_config_reject(self): + # Project with _config, blacklisted submodule -> push rejected. + self.install_hook() + self.create_commit({ + BLACKLIST_FILE: "blacklisted-submodule\n", + "_config": "# OBS Project Config\n", + }) + self.add_submodule("blacklisted-submodule", "blacklisted-submodule") + + self.assert_push_rejected("blacklisted-submodule") + + def test_project_manifest_allow(self): + # Project with _manifest, submodule not in the blacklist -> push allowed. + self.install_hook() + self.create_commit({ + BLACKLIST_FILE: "blacklisted-submodule\n", + "_manifest": SUBDIRECTORY_MANIFEST, + }) + self.add_submodule("allowed-submodule", "rpms/allowed-submodule") + + self.assert_push_allowed() + + def test_project_manifest_reject(self): + # Project with _manifest, blacklisted submodule -> push rejected. + self.install_hook() + self.create_commit({ + BLACKLIST_FILE: "blacklisted-submodule\n", + "_manifest": SUBDIRECTORY_MANIFEST, + }) + self.add_submodule("blacklisted-submodule", "rpms/blacklisted-submodule") + + self.assert_push_rejected("blacklisted-submodule") + + def test_project_manifest_submodule_delete_allow(self): + # Removing an already-pushed blacklisted submodule -> push allowed, + # because the new revision no longer contains it. + self.create_commit({ + BLACKLIST_FILE: "blacklisted-submodule\n", + "_manifest": SUBDIRECTORY_MANIFEST, + }) + self.add_submodule("blacklisted-submodule", "rpms/blacklisted-submodule") + self.push() + + self.install_hook() + self.remove_submodule("rpms/blacklisted-submodule") + + self.assert_push_allowed() + + def test_skip_env_bypasses_blacklist(self): + # Blacklisted submodule pushed with the bypass env var set -> push allowed. + # Same setup as the reject tests, so this also proves the hook actually runs. + self.install_hook() + self.create_commit({BLACKLIST_FILE: "blacklisted-submodule\n"}) + self.add_submodule("blacklisted-submodule", "blacklisted-submodule") + + output = self.push(env={"PACKAGE_SUBMODULE_BLACKLIST_SKIP": "1"}) + self.assertNotIn(REJECTION_MESSAGE, output) + self.assertEqual(self.remote_head(), self.local_head()) + + +if __name__ == "__main__": + unittest.main()