From 9480dfe780cc8c9bf9596ef97e165a4788ccd98a Mon Sep 17 00:00:00 2001 From: Daniel Hilst <392820+dhilst@users.noreply.github.com> Date: Fri, 21 Aug 2026 20:53:08 -0300 Subject: [PATCH] feat(xcat-dep): EVR-constraint gate + rpmkeys checksig + repo_gpgcheck Three review follow-ups on the repo validation gate: 1. Full EPOCH:VERSION-RELEASE validation. The gate compared only %{VERSION}, so xCAT-genesis-base=2.* accepted a pre-2.18 (2.17.x) genesis even though xCAT-genesis-scripts Requires >= 2:2.18.0, and it could not enforce release floors like perl-IO-Stty >= 0.04-5. Manifest pins now also accept an EVR constraint (>=, >, <=, <, = followed by [epoch:]version[-release]); the built rpm's full EVR is compared with rpm's own algorithm (rpm.vercmp via the lua binding, injected into the pure evr_cmp, which composes epoch/version/release). genesis-base is pinned >= 2:2.18.0 and perl-IO-Stty >= 0.04-5. rpm_evr also catches release-level stale-artifact accumulation that rpm_version (VERSION dedup) missed. 2. RPM-native crypto verification. The per-rpm gate extracted the header signer id but did not verify digests/signatures. It now also runs `rpmkeys --checksig` against an isolated keyring holding only the signing key (exported from the gpg home), so every rpm's header/payload digests AND the signature-by-this-key are cryptographically verified; the signer-id origin check is kept alongside. 3. repo_gpgcheck. The generated xcat-dep.repo set only gpgcheck=1; add repo_gpgcheck=1 (mirroring gpgcheck) so clients enforce the detached repomd.xml.asc signature that sign_and_index_repo already produces. Validated: unit tests for parse_evr/evr_constraint_ok (rpm's real vercmp; the reviewer's 2.17.9-rejected, 0.04-4-rejected, epoch-enforced cases) + checksig verdict; and `--verify-repo` over a real signed rh8/x86_64 cell passes (12 packages EVR-satisfied, every rpm checksig-verified). Signed-off-by: Daniel Hilst <392820+dhilst@users.noreply.github.com> --- MockBuildUtils.pm | 94 +++++++++++++++++++++++++++++++++++++++--- mockbuild-all.pl | 91 ++++++++++++++++++++++++++++++++++++++-- packages-manifest.conf | 59 ++++++++++++++------------ t/mockbuild-all.t | 59 ++++++++++++++++++++++++++ 4 files changed, 268 insertions(+), 35 deletions(-) diff --git a/MockBuildUtils.pm b/MockBuildUtils.pm index 7755450..07d0876 100644 --- a/MockBuildUtils.pm +++ b/MockBuildUtils.pm @@ -16,6 +16,7 @@ our @EXPORT_OK = qw( sh_quote print_step version_matches required_pkgs have_rpm read_manifest verify_repo_packages verify_repo_signature verify_rpm_signatures + parse_evr evr_cmp evr_constraint_ok parse_pin rpmkeys_checksig_problem rpm_version rpm_release rpm_sigmd5 rpm_is_signed restamp_release_line cross_copy_genesis finalize_xcat_dep bump_dep_release_suffix build_mock_uniqueext @@ -72,21 +73,104 @@ sub required_pkgs { # Uses version_matches (same semantics as the in-line manifest pin loop), so a '*' or glob pin is # accepted exactly as there. No file/manifest I/O here -- the disk layer builds %present and passes # both hashes in, keeping this unit-testable in isolation. +# parse_evr($s): split an EVR string "[epoch:]version[-release]" into ($epoch, $version, $release). +# epoch defaults to '0' when absent or '(none)'; release is undef when the string carries none (so a +# release-less constraint compares version-only). Neither version nor release may contain '-', so the +# single '-' cleanly separates them. +sub parse_evr { + my ($s) = @_; + $s = '' unless defined $s; + my ($epoch, $rest); + if ($s =~ /^\s*(\d+):(.*)$/) { ($epoch, $rest) = ($1, $2); } + else { ($epoch, $rest) = ('0', $s); } + $epoch = '0' if !defined $epoch || $epoch eq '' || lc($epoch) eq '(none)'; + my ($ver, $rel) = split /-/, $rest, 2; + return ($epoch, $ver, $rel); # $rel undef when no release given +} + +# evr_cmp($got, $want, $vercmp): compare two EVRs with rpm's labelCompare semantics -- epoch first +# (numeric), then version, then release -- returning -1/0/1 (got vs want). $vercmp->($a,$b) is an +# injected rpm-native segment comparator (rpm's rpmvercmp) returning -1/0/1, so this stays pure and +# unit-testable. Release is compared only when the CONSTRAINT specifies one (rpm's EVR semantics: a +# version-only requirement ignores the built release). +sub evr_cmp { + my ($got, $want, $vercmp) = @_; + my ($ge, $gv, $gr) = parse_evr($got); + my ($we, $wv, $wr) = parse_evr($want); + return (($ge <=> $we) <=> 0) if ($ge <=> $we) != 0; # epoch: numeric + my $c = $vercmp->($gv, $wv); + return $c if $c; + return 0 unless defined $wr && $wr ne ''; # constraint release-agnostic + $gr = '' unless defined $gr; + return $vercmp->($gr, $wr); +} + +# evr_constraint_ok($got, $op, $want, $vercmp): does the observed EVR satisfy " "? +sub evr_constraint_ok { + my ($got, $op, $want, $vercmp) = @_; + my $c = evr_cmp($got, $want, $vercmp); + return $c >= 0 if $op eq '>='; + return $c > 0 if $op eq '>'; + return $c <= 0 if $op eq '<='; + return $c < 0 if $op eq '<'; + return $c == 0 if $op eq '=' || $op eq '=='; + return undef; # unknown operator +} + +# parse_pin($pin): classify a manifest version pin. +# '*' -> ('any') +# ' ' (>=,>,<=,<,=) -> ('evr', $op, $evr) full EPOCH:VERSION-RELEASE constraint +# glob or exact version -> ('version') %{VERSION}-only match (version_matches) +sub parse_pin { + my ($pin) = @_; + return ('any') if !defined($pin) || $pin eq '*'; + return ('evr', $1, $2) if $pin =~ /^\s*(>=|<=|==|=|>|<)\s*(\S+)\s*$/; + return ('version'); +} + sub verify_repo_packages { - my ($expected, $present) = @_; + my ($expected, $present_ver, $present_evr, $vercmp) = @_; + $present_evr //= $present_ver; my @problems; for my $pkg (sort keys %$expected) { my $pin = $expected->{$pkg}; - my $got = $present->{$pkg}; - if (!defined $got) { + my $got_ver = $present_ver->{$pkg}; + if (!defined $got_ver) { push @problems, "MISSING $pkg (manifest requires " . (defined($pin) ? $pin : '*') . ")"; - } elsif (!version_matches($got, $pin)) { - push @problems, "VERSION $pkg: repo has $got, manifest pins $pin"; + next; } + my ($kind, $op, $want) = parse_pin($pin); + if ($kind eq 'evr') { + my $got_evr = $present_evr->{$pkg} // $got_ver; + if (!$vercmp) { + push @problems, "EVR $pkg: no EVR comparator available to check '$op $want'"; + } elsif (!evr_constraint_ok($got_evr, $op, $want, $vercmp)) { + push @problems, "EVR $pkg: repo has $got_evr, manifest requires $op $want"; + } + } elsif ($kind eq 'version') { # VERSION glob/exact (unchanged) + push @problems, "VERSION $pkg: repo has $got_ver, manifest pins $pin" + if !version_matches($got_ver, $pin); + } + # 'any' -> accept } return @problems; } +# rpmkeys_checksig_problem($name, $rc, $out): pure verdict for one `rpmkeys --checksig -v` run against +# an isolated keyring holding only the signing key. A clean rpm exits 0 and every digest/signature +# line reads OK; a tampered digest reads NOT OK; an rpm signed by another key (or unsigned) reads +# NOKEY. Return a problem string (or empty list) so the gate is testable without rpm. +sub rpmkeys_checksig_problem { + my ($name, $rc, $out) = @_; + $out = '' unless defined $out; + return () if ($rc // 0) == 0 && $out !~ /NOT OK|NOKEY|MISSING KEYS/i; + my $why = $out =~ /NOT OK/i ? 'digest/signature NOT OK' + : $out =~ /NOKEY/i ? 'NOKEY (unsigned or signed by an unaccepted key)' + : $out =~ /MISSING KEYS/i ? 'MISSING KEYS' + : "rpmkeys --checksig failed (rc=" . ($rc // '?') . ")"; + return "BADSIG rpm $name: $why"; +} + # verify_repo_signature: the PURE signature-decision layer of the repo gate. Given %expected # { unit => expected signing-key identity } and %observed { unit => key that ACTUALLY signed (a # string the script extracts from gpg), or undef/'' when unsigned / verification failed }, return a diff --git a/mockbuild-all.pl b/mockbuild-all.pl index 3bb85d3..e544ef6 100755 --- a/mockbuild-all.pl +++ b/mockbuild-all.pl @@ -8,6 +8,7 @@ use File::Basename qw(dirname basename); use File::Copy qw(copy); use File::Find qw(find); use File::Path qw(make_path remove_tree); +use File::Temp qw(tempdir); use Getopt::Long qw(GetOptions); use Parallel::ForkManager; use POSIX qw(strftime); @@ -17,7 +18,7 @@ use MockBuildUtils qw(sh_quote print_step version_matches required_pkgs read_manifest verify_repo_packages verify_repo_signature verify_rpm_signatures rpm_version rpm_release rpm_sigmd5 restamp_release_line cross_copy_genesis finalize_xcat_dep bump_dep_release_suffix - build_mock_uniqueext); + build_mock_uniqueext rpmkeys_checksig_problem); # --- Mount-namespace isolation: guard the host cgroup against mock teardown propagation ---------- # mock mounts /sys/fs/cgroup into every build chroot. On these systemd build hosts every mount is @@ -889,6 +890,9 @@ sub write_dep_repo_metadata { my $baseurl = "https://xcat.org/files/xcat/repos/yum/devel/xcat-dep/rh$rel/$arch"; my $gpgcheck = $gpg_sign ? 1 : 0; my $gpgkey_line = $gpg_sign ? "gpgkey=$baseurl/repodata/repomd.xml.key" : "# gpgkey="; + # repo_gpgcheck=1 makes clients verify the DETACHED repomd.xml signature (repomd.xml.asc) against + # gpgkey before trusting the metadata -- sign_and_index_repo produces both, so enforce it. Mirrors + # gpgcheck: off when the repo is unsigned. open my $r, '>', "$dir/xcat-dep.repo" or die "Cannot write $dir/xcat-dep.repo: $!\n"; print {$r} <<"EOF"; [xcat-dep] @@ -896,6 +900,7 @@ name=xCAT 2 dependencies (rh$rel $arch) baseurl=$baseurl enabled=1 gpgcheck=$gpgcheck +repo_gpgcheck=$gpgcheck $gpgkey_line EOF close $r; @@ -1265,6 +1270,74 @@ sub rpm_signer_keyid { return undef; } +# rpm_evr: the single distinct EPOCH:VERSION-RELEASE of package $name's binary rpm(s) in $dir (epoch +# defaults to 0 when the header carries none), or undef if none match. Mirrors rpm_version's dedup: +# more than one distinct EVR means a stale artifact was not cleaned before the build (a version pin +# could then pass against the wrong rpm). genesis's x86_64 + ppc64 rpms share one EVR, so a normal +# pair is a single entry. +sub rpm_evr { + my ($dir, $name) = @_; + my $glob = ($name eq 'xCAT-genesis-base') + ? "$dir/xCAT-genesis-base-*.rpm" + : "$dir/${name}-*.rpm"; + my %evrs; + for my $f (sort glob($glob)) { + next if $f =~ /\.src\.rpm$/ || $f =~ /-debug(?:info|source)-/; + my $n = `rpm -qp --qf '%{name}' ${\ sh_quote($f)} 2>/dev/null`; + my $match = ($name eq 'xCAT-genesis-base') + ? ($n =~ /^xCAT-genesis-base-/) : ($n eq $name); + next unless $match; + my $evr = `rpm -qp --qf '%{epochnum}:%{version}-%{release}' ${\ sh_quote($f)} 2>/dev/null`; + chomp $evr; + $evrs{$evr} = 1 if $evr ne ''; + } + return undef unless %evrs; + die "Multiple EVRs of $name present in $dir: " . join(', ', sort keys %evrs) + . " (stale artifact not cleaned before the build)\n" if keys(%evrs) > 1; + my ($evr) = keys %evrs; + return $evr; +} + +# rpm_vercmp_segment: ONE rpmvercmp segment comparison via rpm's own lua binding, returning -1/0/1. +# Used as the injected comparator for MockBuildUtils::evr_cmp so the EVR gate uses rpm's canonical +# version algorithm (epoch/release composition is done in evr_cmp). Long-bracket the args so any +# version char (. _ ~ ^ +) passes through literally; rpm versions never contain the ]==] sequence. +sub rpm_vercmp_segment { + my ($a, $b) = @_; + $a = '' unless defined $a; + $b = '' unless defined $b; + my $out = `rpm --eval '%{lua:print(rpm.vercmp([==[$a]==],[==[$b]==]))}' 2>/dev/null`; + chomp $out; + die "FATAL: rpm.vercmp gave no result for '$a' vs '$b'\n" unless $out =~ /^-?\d+$/; + return $out <=> 0; +} + +# verify_rpms_checksig: cryptographically verify EVERY binary rpm in $dir with `rpmkeys --checksig` +# against an ISOLATED keyring holding only the signing key. This is the RPM-native integrity + origin +# check: it verifies each rpm's header/payload digests AND that the signature is by this key (NOKEY / +# NOT OK => a real failure, since the key IS imported). Returns @problems. +sub verify_rpms_checksig { + my ($dir, $keyname, $home) = @_; + my @rpms = grep { !/\.src\.rpm$/ } glob("$dir/*.rpm"); + return () unless @rpms; + require_command('rpmkeys'); + require_command('gpg'); + my $tmpdb = tempdir('rpmkeys-XXXXXXXX', TMPDIR => 1, CLEANUP => 1); + my $h = ($home ne '') ? ' --homedir ' . sh_quote($home) : ''; + my $keyfile = "$tmpdb/pubkey.asc"; + system("gpg$h --batch --yes -a --export " . sh_quote($keyname) . ' > ' . sh_quote($keyfile) . ' 2>/dev/null'); + return ("SIGKEY: cannot export public key '$keyname' for rpmkeys --checksig") if !-s $keyfile; + my $dbopt = '--dbpath ' . sh_quote($tmpdb); + system("rpmkeys $dbopt --import " . sh_quote($keyfile) . ' >/dev/null 2>&1') == 0 + or return ("SIGKEY: rpmkeys --import of '$keyname' into the temp keyring failed"); + my @problems; + for my $rpm (@rpms) { + my $out = `rpmkeys $dbopt --checksig -v ${\ sh_quote($rpm)} 2>&1`; + push @problems, rpmkeys_checksig_problem(basename($rpm), $? >> 8, $out); + } + return @problems; +} + # repomd_observed_signer: run gpg --verify on the detached repomd signature and extract the identity # of the key that actually signed it, as a primary-key fingerprint (the last field of the VALIDSIG # status line). Returns '' when the .asc is absent or verification fails (both read as "unsigned"). @@ -1302,9 +1375,14 @@ sub verify_target_repo { die "FATAL: no manifest section for target '$tgt' in $manifest\n" if !%req; # Skip flags default 0 -> the full required set. A package whose builder was skipped is not required. my @names = required_pkgs([sort keys %req], $skip_genesis, $skip_perl, $skip_xcat_dep); - my %present = repo_present_versions($dir, \@names); + my %present = repo_present_versions($dir, \@names); + # Full EPOCH:VERSION-RELEASE per package, so a manifest EVR constraint (e.g. genesis-base + # '>= 2:2.18.0', which %{VERSION}-only matching cannot enforce -- 2.* would accept a pre-2.18 + # genesis) is checked with rpm's own version algorithm (PR #62 review). rpm_vercmp_segment is + # rpm's rpmvercmp; evr_cmp composes epoch/version/release around it. + my %present_evr = map { $_ => rpm_evr($dir, $_) } @names; my %expected = map { $_ => $req{$_} } @names; - my @problems = verify_repo_packages(\%expected, \%present); + my @problems = verify_repo_packages(\%expected, \%present, \%present_evr, \&rpm_vercmp_segment); # Signature gate: the IO (gpg) lives here; the decision is the pure verify_repo_signature. The # pipeline always signs, so a signed repo's repomd MUST be signed by --gpg-key-name. We resolve @@ -1330,6 +1408,11 @@ sub verify_target_repo { # metadata -- is signed by this key (rpm reports the signing subkey id; accept any id of # the key). Closes the "approves a repo DNF later rejects" gap (PR #62 review #4). require_command('rpm'); + # (a) RPM-native crypto verification: rpmkeys --checksig against an isolated keyring + # holding only this key verifies every rpm's digests AND that the signature is by the key. + push @problems, verify_rpms_checksig($dir, $gpg_key_name, $gpg_home); + # (b) Explicit signer-id origin check kept alongside: assert each rpm's header signature + # key id is one of this key's ids (primary/subkey). my $accept = gpg_key_ids($gpg_key_name, $gpg_home); if (!%$accept) { push @problems, "SIGKEY: cannot list key ids for '$gpg_key_name' to verify per-rpm signatures"; @@ -1352,7 +1435,7 @@ sub verify_target_repo { die "FATAL: repo INCOMPLETE for $tgt at $dir (" . scalar(@problems) . " problem(s))\n"; } print "[verify-repo] $tgt complete: " . scalar(@names) - . " required packages present + version-pinned, every rpm signed, in $dir\n"; + . " required packages present + EVR-satisfied, repomd + every rpm checksig-verified, in $dir\n"; return 1; } diff --git a/packages-manifest.conf b/packages-manifest.conf index 1669a87..955813b 100644 --- a/packages-manifest.conf +++ b/packages-manifest.conf @@ -1,22 +1,29 @@ # Per-target required xcat-dep package manifest. # # One [section] per mockbuild-all target (matches --target). Each entry is -# = +# = # where is the builder/package name (the dep builder name, the perl -# package name, or xCAT-genesis-base) and is one of: -# - an exact Version (e.g. 1.8.18) -- the build must produce exactly it; -# - a shell-style glob (e.g. 2.*) -- the built Version must match it (* and ?); -# - '*' -- any version is accepted. -# Only the Version is matched, never the Release (which carries the per-EL dist -# tag elN and the genesis snap). Bump an exact pin here when the -# corresponding in-tree source Version is bumped. +# package name, or xCAT-genesis-base) and is one of: +# - an exact Version (e.g. 1.8.18) -- the built %{VERSION} must equal it; +# - a shell-style glob (e.g. 2.*) -- the built %{VERSION} must match it (* and ?); +# - '*' -- any version is accepted; +# - an EVR constraint (e.g. >= 2:2.18.0, >= 0.04-5, = 1.8.18) -- an operator +# (>=, >, <=, <, =) followed by an [epoch:]version[-release]. The built rpm's +# full EPOCH:VERSION-RELEASE is compared with rpm's own version algorithm +# (rpm.vercmp; epoch numeric, then version, then release; release ignored when +# the constraint omits it). Use this to enforce a minimum that a %{VERSION} +# glob cannot -- e.g. a release floor (perl-IO-Stty >= 0.04-5) or an Epoch. +# A bare version pin matches %{VERSION} only (Release carries the per-EL dist tag +# elN and the snap, so it is not pinned there). Bump a pin here when the +# corresponding in-tree source is bumped. # -# xCAT-genesis-base is pinned as 2.* (not an exact version) on purpose: its -# Version is NOT owned by xcat-dep -- it is whatever xcat-core the genesis build -# compiles against (XCAT_CORE_REF), so it walks with the paired core (2.18.x, -# 2.19.x, ...). 2.* asserts "a 2.x genesis" without coupling the manifest to one -# core release. (xCAT-genesis-scripts Requires xCAT-genesis-base >= 2:2.18.0 -- a -# minimum with Epoch 2 -- so any 2.x genesis-base installs against a 2.18+ core.) +# xCAT-genesis-base is pinned as '>= 2:2.18.0' (an EVR floor, not an exact version): +# its Version is NOT owned by xcat-dep -- it is whatever xcat-core the genesis build +# compiles against (XCAT_CORE_REF), so it walks with the paired core (2.18.x, 2.19.x, +# ...). The floor still walks (accepts any 2.18+ genesis) but, unlike the old '2.*' +# glob, REJECTS a pre-2.18 genesis -- xCAT-genesis-scripts Requires xCAT-genesis-base +# >= 2:2.18.0 (Epoch 2), which a 2.17.x genesis would violate. genesis-base carries +# Epoch 2, so the epoch in the constraint is enforced too. # # mockbuild-all.pl reads this file and, per target, builds ONLY the listed # packages -- a package not listed for a target is not built for it. Any listed @@ -45,9 +52,9 @@ syslinux-xcat=6.03 xnba-undi=1.21.1 perl-HTML-Form=6.07 perl-HTTP-Async=0.30 -perl-IO-Stty=0.04 +perl-IO-Stty=>= 0.04-5 perl-Net-HTTPS-NB=0.14 -xCAT-genesis-base=2.* +xCAT-genesis-base=>= 2:2.18.0 [alma+epel-8-ppc64le] conserver-xcat=8.2.1 @@ -59,9 +66,9 @@ syslinux-xcat=6.03 xnba-undi=1.21.1 perl-HTML-Form=6.07 perl-HTTP-Async=0.30 -perl-IO-Stty=0.04 +perl-IO-Stty=>= 0.04-5 perl-Net-HTTPS-NB=0.14 -xCAT-genesis-base=2.* +xCAT-genesis-base=>= 2:2.18.0 [alma+epel-9-x86_64] conserver-xcat=8.2.1 @@ -72,10 +79,10 @@ ipmitool-xcat=1.8.18 syslinux-xcat=6.03 xnba-undi=1.21.1 perl-HTTP-Async=0.30 -perl-IO-Stty=0.04 +perl-IO-Stty=>= 0.04-5 perl-Net-HTTPS-NB=0.14 perl-Sys-Virt=11.10.0 -xCAT-genesis-base=2.* +xCAT-genesis-base=>= 2:2.18.0 [alma+epel-9-ppc64le] conserver-xcat=8.2.1 @@ -86,10 +93,10 @@ ipmitool-xcat=1.8.18 syslinux-xcat=6.03 xnba-undi=1.21.1 perl-HTTP-Async=0.30 -perl-IO-Stty=0.04 +perl-IO-Stty=>= 0.04-5 perl-Net-HTTPS-NB=0.14 perl-Sys-Virt=11.10.0 -xCAT-genesis-base=2.* +xCAT-genesis-base=>= 2:2.18.0 [alma+epel-10-x86_64] conserver-xcat=8.2.1 @@ -101,11 +108,11 @@ syslinux-xcat=6.03 xnba-undi=1.21.1 perl-Crypt-SSLeay=0.72 perl-HTTP-Async=0.30 -perl-IO-Stty=0.04 +perl-IO-Stty=>= 0.04-5 perl-Net-HTTPS-NB=0.14 perl-Net-Telnet=3.04 perl-Sys-Virt=11.10.0 -xCAT-genesis-base=2.* +xCAT-genesis-base=>= 2:2.18.0 [alma+epel-10-ppc64le] conserver-xcat=8.2.1 @@ -117,8 +124,8 @@ syslinux-xcat=6.03 xnba-undi=1.21.1 perl-Crypt-SSLeay=0.72 perl-HTTP-Async=0.30 -perl-IO-Stty=0.04 +perl-IO-Stty=>= 0.04-5 perl-Net-HTTPS-NB=0.14 perl-Net-Telnet=3.04 perl-Sys-Virt=11.10.0 -xCAT-genesis-base=2.* +xCAT-genesis-base=>= 2:2.18.0 diff --git a/t/mockbuild-all.t b/t/mockbuild-all.t index 5b038de..382cf2a 100644 --- a/t/mockbuild-all.t +++ b/t/mockbuild-all.t @@ -13,6 +13,7 @@ use File::Basename qw(basename); use MockBuildUtils qw(required_pkgs version_matches rpm_sigmd5 rpm_version rpm_release rpm_is_signed restamp_release_line cross_copy_genesis finalize_xcat_dep read_manifest verify_repo_packages verify_repo_signature verify_rpm_signatures + parse_evr evr_constraint_ok parse_pin rpmkeys_checksig_problem bump_dep_release_suffix build_mock_uniqueext); # Run a printing sub with STDOUT muted so its progress lines do not pollute TAP. @@ -357,6 +358,64 @@ is(rpm_release(tempdir(CLEANUP => 1), 'nonexistent-pkg'), undef, 'rpm_release is 'verify_rpm_signatures: wrong key reported as WRONGKEY rpm : signed by , expected one of ...'); } +# ---- EVR constraints: full EPOCH:VERSION-RELEASE validation (PR #62 review) ------------------- +# rpm's own version algorithm, via its lua rpm.vercmp binding, is the injected segment comparator -- +# the same primitive mockbuild-all passes in production, so these assert the real rpm semantics. +my $vercmp = sub { + my ($a, $b) = @_; + my $o = `rpm --eval '%{lua:print(rpm.vercmp([==[$a]==],[==[$b]==]))}' 2>/dev/null`; + chomp $o; return $o <=> 0; +}; +{ + is_deeply([parse_evr('2:2.18.0-5')], ['2','2.18.0','5'], 'parse_evr: epoch:version-release'); + is_deeply([parse_evr('2.18.0')], ['0','2.18.0',undef], 'parse_evr: bare version -> epoch 0, no release'); + is_deeply([parse_evr('0.04-5.el8')], ['0','0.04','5.el8'], 'parse_evr: release kept whole'); + + is_deeply([parse_pin('>= 2:2.18.0')], ['evr','>=','2:2.18.0'], 'parse_pin: EVR operator constraint'); + is_deeply([parse_pin('2.*')], ['version'], 'parse_pin: glob stays a version pin'); + is_deeply([parse_pin('*')], ['any'], 'parse_pin: * is any'); + + # the reviewer's cases: genesis-base >= 2:2.18.0 rejects a pre-2.18 (Epoch 2) genesis... + ok( evr_constraint_ok('2:2.19.0-snap202607211907', '>=', '2:2.18.0', $vercmp), + 'EVR: 2:2.19.0 satisfies >= 2:2.18.0'); + ok(!evr_constraint_ok('2:2.17.9-snap', '>=', '2:2.18.0', $vercmp), + 'EVR: 2:2.17.9 REJECTED by >= 2:2.18.0 (2.* would have wrongly accepted it)'); + ok(!evr_constraint_ok('0:2.18.0-1', '>=', '2:2.18.0', $vercmp), + 'EVR: epoch enforced -- 0:2.18.0 rejected by >= 2:2.18.0'); + # ...and a release floor perl-IO-Stty >= 0.04-5. + ok( evr_constraint_ok('0.04-5.el8.snap202607221225.13', '>=', '0.04-5', $vercmp), + 'EVR: 0.04-5.el8.snap... satisfies release floor >= 0.04-5'); + ok(!evr_constraint_ok('0.04-4.el8', '>=', '0.04-5', $vercmp), + 'EVR: 0.04-4 REJECTED by release floor >= 0.04-5 (VERSION-only match would have passed)'); + + # end-to-end through the gate: EVR pin honored, with the got EVR supplied separately from %{VERSION}. + my @okp = verify_repo_packages( + { 'xCAT-genesis-base' => '>= 2:2.18.0' }, + { 'xCAT-genesis-base' => '2.19.0' }, + { 'xCAT-genesis-base' => '2:2.19.0-snap202607211907' }, $vercmp); + is_deeply(\@okp, [], 'gate: EVR-satisfying genesis passes'); + my @badp = verify_repo_packages( + { 'xCAT-genesis-base' => '>= 2:2.18.0' }, + { 'xCAT-genesis-base' => '2.17.0' }, + { 'xCAT-genesis-base' => '2:2.17.0-snap' }, $vercmp); + is(scalar(@badp), 1, 'gate: pre-2.18 genesis yields exactly one problem'); + like($badp[0], qr/^EVR xCAT-genesis-base: repo has 2:2\.17\.0-snap, manifest requires >= 2:2\.18\.0$/, + 'gate: EVR failure names the observed EVR and the requirement'); +} + +# ---- rpmkeys --checksig verdict (pure) ------------------------------------------------------- +{ + is_deeply([rpmkeys_checksig_problem('a.rpm', 0, + "Header V4 RSA/SHA256 Signature, key ID cb60ad43: OK\nPayload SHA256 digest: OK\n")], [], + 'checksig: all-OK rpm -> no problem'); + my @nok = rpmkeys_checksig_problem('b.rpm', 1, "Header SHA256 digest: NOT OK\n"); + like($nok[0], qr/^BADSIG rpm b\.rpm: digest\/signature NOT OK$/, 'checksig: NOT OK flagged'); + my @nokey = rpmkeys_checksig_problem('c.rpm', 1, "Header V4 RSA/SHA256 Signature, key ID deadbeef: NOKEY\n"); + like($nokey[0], qr/^BADSIG rpm c\.rpm: NOKEY/, 'checksig: NOKEY (unaccepted/unsigned) flagged'); + my @rc = rpmkeys_checksig_problem('d.rpm', 2, ""); + like($rc[0], qr/rc=2/, 'checksig: non-zero exit with no marker still flagged'); +} + # ---- build_mock_uniqueext: distinct per target so concurrent mock roots never collide --------- # (PR #62 review) A long (timestamp) run id must not tail-truncate away the leading EL/arch token: # for the 7-char "ppc64le" arch that dropped the EL digit, so alma+epel-{8,9,10}-ppc64le collapsed to