2
0
mirror of https://github.com/xcat2/xcat-dep.git synced 2026-09-12 04:26:25 +00:00

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>
This commit is contained in:
Daniel Hilst
2026-08-21 20:53:08 -03:00
parent a447caccea
commit 9480dfe780
4 changed files with 268 additions and 35 deletions
+89 -5
View File
@@ -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 "<op> <want>"?
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')
# '<op> <evr>' (>=,>,<=,<,=) -> ('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
+87 -4
View File
@@ -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;
}
+33 -26
View File
@@ -1,22 +1,29 @@
# Per-target required xcat-dep package manifest.
#
# One [section] per mockbuild-all target (matches --target). Each entry is
# <package>=<version-pin>
# <package>=<pin>
# where <package> is the builder/package name (the dep builder name, the perl
# package name, or xCAT-genesis-base) and <version-pin> 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<timestamp>). Bump an exact pin here when the
# corresponding in-tree source Version is bumped.
# package name, or xCAT-genesis-base) and <pin> 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<timestamp>, 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
+59
View File
@@ -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 <name>: signed by <obs>, 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