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

feat(xcat-dep): manifest-driven apt repo completeness + signature gate, auto-run after assemble

Adds a real gate on the ASSEMBLED apt repo, per codename x arch, using
debs-manifest.conf as the single source of truth, layered pure/testable:

- BuildUtils: verify_repo_packages(\%expected,\%present) (MISSING/VERSION),
  verify_repo_signature(\%expected,\%observed) (UNSIGNED/WRONGKEY), and
  parse_packages_index($text) -- all PURE and unit-tested (happy+sad, no dpkg-deb).
- sbuild-all.pl does the IO via one sub verify_assembled_repo: parses each published
  binary-<arch>/Packages (resolving arch-suffixed names like xcat-genesis-base-<arch>,
  reducing to upstream via deb_upstream_version to compare against the manifest pin),
  runs gpg --verify on each dists/<cn>/InRelease and extracts the signer fingerprint,
  then delegates to the two pure deciders and dies listing every [<cn>/<arch>] problem.
- Runs AUTOMATICALLY at the end of assemble_apt (once Packages + signed Release exist);
  suppressible with --no-verify-repo; skipped under --dry-run. Also a standalone,
  lock-free, build-free '--verify-repo=<apt_dir>' mode using the script's --manifest/
  --dists/--gpg-key-id/--gpg-home. Replaces the coarse pool-global hard-coded check.

prove t/sbuild-all.t: 90/90 (was 71). Smoke-tested: complete tree passes; dropped pkg
-> MISSING; wrong version -> VERSION; missing index -> MISSING-INDEX; all die nonzero.

Signed-off-by: Daniel Hilst <392820+dhilst@users.noreply.github.com>
This commit is contained in:
Daniel Hilst
2026-08-12 15:30:53 -03:00
parent c05311b362
commit 68f17873bb
3 changed files with 386 additions and 1 deletions
+104
View File
@@ -24,6 +24,7 @@ use MIME::Base64 qw(encode_base64);
our @EXPORT_OK = qw(
sh_quote print_step
version_matches required_pkgs read_manifest standard_options
verify_repo_packages verify_repo_signature parse_packages_index
codename_to_version version_to_codename known_codenames
chroot_name chroot_sources_list
control_field genesis_deb_control
@@ -139,6 +140,109 @@ sub standard_options {
);
}
# ---------------------------------------------------------------------------------------------------
# Published-repo completeness gate (PURE decision + PURE index parsing; no I/O, no manifest parsing).
# These are the unit-testable core of the manifest-driven completeness gate: the disk/name-resolution
# layer (sbuild-all.pl) feeds them plain hashes/strings so the DECISION stays testable without a repo.
# ---------------------------------------------------------------------------------------------------
# verify_repo_packages(\%expected, \%present) -> @problems
# %expected : pkg name => manifest version pin ('*' = any)
# %present : pkg name => the actual (upstream) version found in the repo, or undef/absent if not found
# Returns human-readable problem strings (empty list = the repo is complete for this set):
# "MISSING <pkg> (manifest requires <pin>)" -- required package not present at all
# "VERSION <pkg>: repo has <got>, manifest pins <pin>" -- present but the pin is not satisfied
# The pin semantics are exactly BuildUtils::version_matches (the same check validate_manifest uses on
# freshly-built debs), so a repo that "validates on build" and a repo that "validates on publish" agree.
# Pure: no I/O, deterministic ordering (problems returned in sorted package-name order).
sub verify_repo_packages {
my ($expected, $present) = @_;
my @problems;
for my $pkg (sort keys %$expected) {
my $pin = $expected->{$pkg};
my $got = $present->{$pkg};
if (!defined $got) {
push @problems, "MISSING $pkg (manifest requires $pin)";
next;
}
push @problems, "VERSION $pkg: repo has $got, manifest pins $pin"
unless version_matches($got, $pin);
}
return @problems;
}
# verify_repo_signature(\%expected, \%observed) -> @problems
# %expected : unit => the expected signing-key identity (for Ubuntu the unit is the codename:
# { focal => <key>, jammy => <key>, ... })
# %observed : unit => the key that ACTUALLY signed the unit's metadata (the string the IO layer
# extracts from gpg), or undef/'' when the unit is unsigned / verification failed
# Returns human-readable problem strings (empty list = every unit is signed by the expected key):
# "UNSIGNED <unit> (expected <key>)" -- no valid signature at all
# "WRONGKEY <unit>: signed by <observed>, expected <key>" -- signed, but by a different key
# Pure: a string comparison only -- NO gpg call, no I/O (the IO layer runs gpg and passes %observed in).
# Deterministic (problems returned in sorted unit order).
sub verify_repo_signature {
my ($expected, $observed) = @_;
my @problems;
for my $unit (sort keys %$expected) {
my $exp = $expected->{$unit};
my $obs = $observed->{$unit};
if (!defined $obs || $obs eq '') {
push @problems, "UNSIGNED $unit (expected $exp)";
next;
}
push @problems, "WRONGKEY $unit: signed by $obs, expected $exp"
unless $obs eq $exp;
}
return @problems;
}
# _dpkg_available / _dpkg_ver_gt: a pure version-ORDERING oracle used only to break duplicate-stanza
# ties in parse_packages_index. dpkg is asked to compare two version STRINGS (no repo/disk/manifest
# I/O, deterministic); when the dpkg binary is absent the caller falls back to last-wins. Duplicate
# package stanzas essentially never occur in an apt-ftparchive-generated index (one stanza per
# package), so this path is defensive.
my $DPKG_AVAILABLE;
sub _dpkg_available {
return $DPKG_AVAILABLE if defined $DPKG_AVAILABLE;
$DPKG_AVAILABLE = (system('command -v dpkg >/dev/null 2>&1') == 0) ? 1 : 0;
return $DPKG_AVAILABLE;
}
sub _dpkg_ver_gt {
my ($a, $b) = @_;
return system('dpkg', '--compare-versions', $a, 'gt', $b) == 0 ? 1 : 0;
}
# parse_packages_index($text) -> \%{ package_name => version }
# Parse a Debian 'Packages' index: RFC822 stanzas separated by blank line(s); each carries a
# 'Package:' and a 'Version:'. Returns name => version (the FULL Debian version verbatim, epoch +
# revision included -- the caller strips to the upstream part with deb_upstream_version). A stanza
# lacking either field is skipped; malformed/empty input yields an empty hash.
# DUP-NAME CHOICE: if a name appears in more than one stanza, keep the HIGHEST by dpkg version order
# when dpkg is available, else LAST-WINS (the last stanza's version). Pure: no file/repo I/O (the only
# subprocess is dpkg as a version-comparison oracle for the rare duplicate).
sub parse_packages_index {
my ($text) = @_;
my %map;
return \%map unless defined $text && $text ne '';
for my $stanza (split /\n\n+/, $text) {
next unless $stanza =~ /\S/;
my ($name) = $stanza =~ /^Package:[ \t]*(\S+)/m;
my ($ver) = $stanza =~ /^Version:[ \t]*(\S+)/m;
next unless defined $name && defined $ver;
if (exists $map{$name}) {
if (_dpkg_available()) {
$map{$name} = $ver if _dpkg_ver_gt($ver, $map{$name}); # keep the highest
} else {
$map{$name} = $ver; # last-wins fallback
}
} else {
$map{$name} = $ver;
}
}
return \%map;
}
# ---------------------------------------------------------------------------------------------------
# Chroot provisioning helpers (absorbed from mk-dep-chroots.sh; sbuild-all.pl auto-inits on first run).
# ---------------------------------------------------------------------------------------------------
+199 -1
View File
@@ -37,9 +37,10 @@ use Fcntl qw(:flock);
use FindBin qw($RealBin);
use lib $RealBin;
use BuildUtils qw(sh_quote print_step version_matches required_pkgs read_manifest standard_options
verify_repo_packages verify_repo_signature parse_packages_index
codename_to_version known_codenames chroot_name chroot_sources_list
control_field genesis_deb_control
deb_field deb_version deb_hash cross_copy_genesis_deb);
deb_field deb_version deb_upstream_version deb_hash cross_copy_genesis_deb);
my $script_dir = abs_path(dirname(__FILE__));
my $repo_root = $script_dir;
@@ -62,6 +63,12 @@ my $parallel_targets = 0;
my ($skip_build, $skip_install, $skip_genesis, $skip_xcat_dep) = (0,0,0,0);
my ($skip_createrepo, $skip_tarball) = (0,0);
my $dry_run = 0;
# Completeness+signature gate on the PUBLISHED apt index (what apt clients see). $verify_repo_arg set
# (--verify-repo=<apt_dir>) runs the gate STANDALONE against that assembled apt dir and exits (no lock,
# no build). $no_verify_repo suppresses the AUTOMATIC post-assembly gate that otherwise runs at the end
# of assemble_apt. Default: automatic gate ON.
my $verify_repo_arg = '';
my $no_verify_repo = 0;
my $gpg_sign = 0;
my $gpg_key_id = 'xcat@megware.com';
my $gpg_home = '';
@@ -134,6 +141,8 @@ $spec{'genesis-deb=s'} = \@genesis_debs;
$spec{'genesis-rpm=s'} = \$genesis_rpm;
$spec{'genesis-rpm-ppc=s'} = \$genesis_rpm_ppc;
$spec{'require-ppc-genesis!'} = \$require_ppc_genesis;
$spec{'verify-repo=s'} = \$verify_repo_arg; # standalone gate: --verify-repo=<apt_dir>
$spec{'no-verify-repo!'} = \$no_verify_repo; # suppress the automatic post-assembly gate
$spec{'output=s'} = \$output_root; # --output alias
$spec{'help|h'} = sub { pod2usage(-verbose => 1, -exitval => 0); };
$spec{'man'} = sub { pod2usage(-verbose => 2, -exitval => 0); };
@@ -177,6 +186,19 @@ my $snap_ts = strftime("%Y%m%d%H%M", gmtime($build_timestamp));
$ENV{SOURCE_DATE_EPOCH} = $build_timestamp; # deterministic mtimes across the whole run
my %MANIFEST = read_manifest($manifest);
# Standalone gate: --verify-repo=<apt_dir> checks an already-assembled apt tree (completeness +
# Release signatures) using THIS script's manifest resolution (--manifest or the default
# debs-manifest.conf), --dists, and --gpg-key-id/--gpg-home, then exits. It takes NO run lock and does
# NOT build. Dispatched here (after manifest + @dist_list are resolved) so it never trips the
# build-only per-target section check below and never reaches the lock/build phases.
if (length $verify_repo_arg) {
die "FATAL: --verify-repo apt dir not found: $verify_repo_arg\n" unless -d $verify_repo_arg;
print_step('Standalone repo verification (no build, no lock)');
verify_assembled_repo(\%MANIFEST, abs_path($verify_repo_arg), \@dist_list);
exit 0;
}
for my $cn (@dist_list) {
my $tgt = "$cn-$arch";
die "FATAL: no manifest section [$tgt] in $manifest\n" unless $MANIFEST{$tgt};
@@ -506,6 +528,150 @@ sub validate_manifest {
print " all targets satisfy the manifest (packages present, version pins matched)\n";
}
# ---------------------------------------------------------------------------------------------------
# Phase: manifest-driven completeness + signature GATE on the PUBLISHED apt index (what apt clients
# actually see). This replaces the coarse, pool-global, hard-coded Jenkinsfile check: the manifest is
# the single source of truth and the assertion is made per codename x arch against the PUBLISHED
# binary-<arch>/Packages index (not the staging pool). The pure DECISIONS (verify_repo_packages for
# completeness, verify_repo_signature for the signer) and the index PARSING (parse_packages_index) live
# in BuildUtils.pm and are unit-tested; this disk/IO layer only reads the index, resolves manifest names
# to index keys, and drives gpg for the signature check.
# ---------------------------------------------------------------------------------------------------
# repo_present_from_index($idx, @names): parse the PUBLISHED $idx (a binary-<arch>/Packages file) and
# resolve each required manifest @names against it, returning %present = (reqname => upstream-version |
# undef). Name-resolution mirrors deb_version/validate_manifest: try an EXACT index key first (most
# packages -- ipmitool-xcat, goconserver, grub2-xcat, the Architecture:all boot bits keep their plain
# names), else a key matching ^<name>- (the arch-suffixed xcat-genesis-base -> xcat-genesis-base-<arch>).
# The published Version carries epoch+revision, so it is reduced to the UPSTREAM part (what the manifest
# pins) via deb_upstream_version before it reaches the pure comparator.
sub repo_present_from_index {
my ($idx, @names) = @_;
my %present = map { $_ => undef } @names;
my $text = do { local $/; open my $fh, '<', $idx or die "FATAL: cannot read $idx: $!\n"; <$fh> };
my $parsed = parse_packages_index($text);
for my $name (@names) {
my $full;
if (exists $parsed->{$name}) {
$full = $parsed->{$name}; # exact index key
} else {
my ($k) = sort grep { /^\Q$name\E-/ } keys %$parsed; # arch-suffixed (genesis)
$full = $parsed->{$k} if defined $k;
}
$present{$name} = defined $full ? deb_upstream_version($full) : undef;
}
return %present;
}
# resolve_expected_key(): the expected signing-key IDENTITY that --gpg-key-id names, resolved (via the
# pipeline's GNUPGHOME) to the primary-key fingerprint so it can be compared against what gpg reports as
# the actual signer. Falls back to the raw --gpg-key-id string when it cannot be resolved to a
# fingerprint (then the gate degrades to presence-only -- see sig_observed_key).
sub resolve_expected_key {
my $g = $gpg_home ? "GNUPGHOME=" . sh_quote($gpg_home) . " " : '';
my $out = `${g}gpg --list-keys --with-colons ${\ sh_quote($gpg_key_id)} 2>/dev/null`;
for my $line (split /\n/, ($out // '')) {
return $1 if $line =~ /^fpr:::::::::([0-9A-Fa-f]+):/; # first fpr = primary key fingerprint
}
return $gpg_key_id;
}
# sig_observed_key($adir, $cn, $expected_key, $expected_is_fpr): run gpg --verify on the codename's
# Release signature (clearsigned InRelease preferred, detached Release.gpg + Release fallback) and return
# the key that ACTUALLY signed it -- undef when unsigned or verification FAILS, so verify_repo_signature
# reports UNSIGNED. On success the primary-key fingerprint is extracted from the VALIDSIG status line
# (GOODSIG keyid fallback); when a real fingerprint can be extracted AND the expected key resolved to a
# fingerprint, that real fingerprint is returned so a mismatch surfaces as WRONGKEY. Otherwise the gate
# degrades to presence-only (returns the expected key on success) rather than emit a spurious WRONGKEY.
sub sig_observed_key {
my ($adir, $cn, $expected_key, $expected_is_fpr) = @_;
my $g = $gpg_home ? "GNUPGHOME=" . sh_quote($gpg_home) . " " : '';
my $inrel = "$adir/dists/$cn/InRelease";
my $rel = "$adir/dists/$cn/Release";
my $relgpg = "$adir/dists/$cn/Release.gpg";
my $cmd;
if (-f $inrel) {
$cmd = "${g}gpg --status-fd=1 --verify " . sh_quote($inrel) . " 2>/dev/null";
} elsif (-f $relgpg && -f $rel) {
$cmd = "${g}gpg --status-fd=1 --verify " . sh_quote($relgpg) . " " . sh_quote($rel) . " 2>/dev/null";
} else {
return undef; # no signature file at all -> UNSIGNED
}
my $out = `$cmd`;
return undef if ($? >> 8) != 0; # gpg --verify FAILED -> treat as unsigned/bad
my $obs_fpr = '';
for my $line (split /\n/, ($out // '')) {
if ($line =~ /^\[GNUPG:\]\s+VALIDSIG\s+(.*)$/) {
my @f = split /\s+/, $1;
$obs_fpr = $f[-1]; # last field = primary key fingerprint
last;
}
}
if ($obs_fpr eq '') {
for my $line (split /\n/, ($out // '')) {
if ($line =~ /^\[GNUPG:\]\s+GOODSIG\s+(\S+)/) { $obs_fpr = $1; last; }
}
}
return ($expected_is_fpr && $obs_fpr ne '') ? $obs_fpr : $expected_key;
}
# verify_assembled_repo($manifest_href, $apt_dir, $dists_aref): the ONE completeness+signature gate,
# shared by the automatic post-assembly run (end of assemble_apt) and the standalone --verify-repo mode.
# It is the IO layer: it PARSES the repository (parse_packages_index of each published
# binary-<arch>/Packages -> %present; gpg --verify of each dists/<cn>/InRelease -> %observed signer),
# PARSES the manifest (-> %expected pkg pins per cell) and resolves the GPG key (--gpg-key-id -> expected
# signer), then delegates the DECISION to the pure verify_repo_packages (per codename x arch) and
# verify_repo_signature (per codename). Package problems are [<cn>/<arch>]-prefixed; the pure signature
# problems already carry the codename unit. Any problem dies non-zero.
sub verify_assembled_repo {
my ($man, $adir, $dists) = @_;
my @all;
my $sig_enabled = ($gpg_home ne '' || $gpg_sign);
my $expected_key = $sig_enabled ? resolve_expected_key() : '';
my $expected_is_fpr = ($expected_key =~ /^[0-9A-Fa-f]{16,}$/) ? 1 : 0;
print " apt-dir: $adir\n";
print " signature check: " . ($sig_enabled
? "on (expected key $expected_key" . ($expected_is_fpr ? '' : ' [unresolved -> presence-only]') . ")"
: "SKIPPED (no --gpg-home/--gpg-sign)") . "\n";
my (%exp_sig, %obs_sig);
for my $cn (@$dists) {
# completeness: manifest (source of truth) vs the PUBLISHED index, per codename x arch.
for my $a (qw(amd64 ppc64el)) {
my $tgt = "$cn-$a";
my $req = $man->{$tgt};
unless ($req && %$req) {
print " [$cn/$a] no manifest section [$tgt] -- skipping (codename does not target this arch)\n";
next;
}
my @names = required_pkgs([sort keys %$req], $skip_genesis, $skip_xcat_dep);
my $idx = "$adir/dists/$cn/main/binary-$a/Packages";
unless (-f $idx) {
push @all, "[$cn/$a] MISSING-INDEX $cn/$a (no $idx)";
next;
}
my %present = repo_present_from_index($idx, @names);
my %pins = map { $_ => $req->{$_} } @names;
push @all, map { "[$cn/$a] $_" } verify_repo_packages(\%pins, \%present);
}
# signature IO: record the expected + observed signer for this codename (decided in bulk below).
if ($sig_enabled) {
$exp_sig{$cn} = $expected_key;
$obs_sig{$cn} = sig_observed_key($adir, $cn, $expected_key, $expected_is_fpr);
}
}
push @all, verify_repo_signature(\%exp_sig, \%obs_sig) if $sig_enabled;
if (@all) {
print "$_\n" for @all;
die "FATAL: apt repo INCOMPLETE (" . scalar(@all) . " problem(s))\n";
}
print "[verify-repo] complete: all required packages present + version-pinned"
. ($sig_enabled ? " + Release signatures valid (key $expected_key)" : "")
. " for [" . join(' ', @$dists) . "] x {amd64,ppc64el}\n";
return;
}
# ---------------------------------------------------------------------------------------------------
# Phase: assemble + sign the apt repo (absorbed build-apt-repo.sh; promote-on-success)
# ---------------------------------------------------------------------------------------------------
@@ -601,6 +767,13 @@ sub assemble_apt {
if (-f $keysrc) { copy($keysrc, "$apt_dir/xcat-dep.asc"); }
else { run("${g}gpg --armor --export " . sh_quote($gpg_key_id) . " > " . sh_quote("$apt_dir/xcat-dep.asc"), nofail => 1); }
}
# Automatic post-assembly GATE on the just-published index (completeness + Release signatures).
# Runs once every codename's dists/<cn>/.../Packages + signed Release are written. Suppressed with
# --no-verify-repo (iteration/debug); skipped under --dry-run (nothing was published).
unless ($no_verify_repo || $dry_run) {
print_step('Verify published apt repo (post-assembly completeness + signature gate)');
verify_assembled_repo(\%MANIFEST, $apt_dir, \@dist_list);
}
}
# ---------------------------------------------------------------------------------------------------
@@ -702,6 +875,19 @@ Wipes+repopulates each codename's published C<pool>/C<dists> from validated stag
C<binary-E<lt>archE<gt>> (Architecture:all packages land in every arch index) and gpg-signs
C<Release>/C<InRelease>. Skipped with C<--skip-createrepo>.
=item Verify (published-repo gate)
After assembly, a manifest-driven gate asserts -- per codename E<times> arch, against the B<published>
C<binary-E<lt>archE<gt>/Packages> index apt clients actually see (not the staging pool) -- that every
manifest-required package is present at its pinned upstream version, and that each codename's
C<Release> is validly gpg-signed by the expected key (C<InRelease>, or detached C<Release.gpg>). Any
missing package, version mismatch, missing index, or unsigned/wrong-key signature fails the run. The
pure decisions (C<BuildUtils::verify_repo_packages> for completeness, C<BuildUtils::verify_repo_signature>
for the signer) and the index parsing (C<BuildUtils::parse_packages_index>) are unit-tested; this
script's IO layer parses the repository + resolves the gpg key and feeds those pure deciders. This
automatic gate is suppressed with C<--no-verify-repo>; the same check runs standalone against an
already-assembled tree via C<--verify-repo=E<lt>apt_dirE<gt>>.
=item Tarball
A repo tarball build artifact (the deployable offline FRS dep bundle is produced by the pipeline's
@@ -773,6 +959,18 @@ the default gives 8 concurrent build streams for a 4-codename matrix (4 per host
Skip the corresponding phase(s). C<--skip-build --skip-genesis> gives an assemble-only run.
=item B<--verify-repo> C<< =<apt_dir> >>
Standalone mode: verify an already-assembled apt tree at C<< <apt_dir> >> (completeness + Release
signatures) using this script's manifest resolution (C<--manifest> or the default
C<debs-manifest.conf>), C<--dists>, and C<--gpg-key-id>/C<--gpg-home>, then exit. Takes no run lock and
builds nothing.
=item B<--no-verify-repo>
Suppress the B<automatic> post-assembly completeness+signature gate (for iteration/debug). The gate is
ON by default.
=item B<--dry-run>
Print the planned actions without executing them.
+83
View File
@@ -13,6 +13,7 @@ use File::Temp qw(tempdir);
use File::Path qw(make_path);
use File::Basename qw(basename);
use BuildUtils qw(required_pkgs version_matches read_manifest standard_options
verify_repo_packages verify_repo_signature parse_packages_index
codename_to_version version_to_codename known_codenames
chroot_name chroot_sources_list
control_field genesis_deb_control
@@ -117,6 +118,88 @@ CTRL
unlike($c, qr/^Replaces:/m, 'no Replaces invented when the maintained control is absent');
}
# ---- verify_repo_packages: PURE completeness decision (no I/O; manifest = source of truth) -------
{
my %req = ('ipmitool-xcat' => '1.8.18', 'goconserver' => '0.3.3', 'xcat-genesis-base' => '*');
# happy: every required package present at a matching version (incl a '*' pin) -> no problems.
my %ok = ('ipmitool-xcat' => '1.8.18', 'goconserver' => '0.3.3', 'xcat-genesis-base' => '2.18.0');
is_deeply([verify_repo_packages(\%req, \%ok)], [],
'all present + version-matching (incl * pin) -> no problems');
# missing: a required package absent -> exactly one MISSING problem.
my %miss = ('ipmitool-xcat' => '1.8.18', 'xcat-genesis-base' => '2.18.0'); # goconserver absent
my @m = verify_repo_packages(\%req, \%miss);
is(scalar(@m), 1, 'a missing required package -> one problem');
like($m[0], qr/^MISSING goconserver /, 'missing package -> "MISSING <pkg> ..."');
# version: present but the pin is not satisfied -> exactly one VERSION problem.
my %ver = ('ipmitool-xcat' => '1.8.17', 'goconserver' => '0.3.3', 'xcat-genesis-base' => '2.18.0');
my @v = verify_repo_packages(\%req, \%ver);
is(scalar(@v), 1, 'a version-pin mismatch -> one problem');
like($v[0], qr/^VERSION ipmitool-xcat: repo has 1\.8\.17, manifest pins 1\.8\.18$/,
'mismatch -> "VERSION <pkg>: repo has <got>, manifest pins <pin>"');
# combined: one missing AND one mismatched -> two problems (one MISSING + one VERSION).
my %both = ('ipmitool-xcat' => '1.8.17', 'xcat-genesis-base' => '2.18.0'); # goconserver absent too
my @b = verify_repo_packages(\%req, \%both);
is(scalar(@b), 2, 'combined missing + mismatch -> two problems');
ok((grep { /^MISSING goconserver/ } @b), 'combined carries the MISSING problem');
ok((grep { /^VERSION ipmitool-xcat/ } @b), 'combined carries the VERSION problem');
}
# ---- verify_repo_signature: PURE signer decision (no gpg; IO layer passes %observed in) ----------
{
my $KEY = 'C55A3A47C780A856'; # the expected signing key identity (a unit)
my %exp = (focal => $KEY, jammy => $KEY, noble => $KEY);
# happy: every codename signed by the expected key -> no problems.
is_deeply([verify_repo_signature(\%exp, { focal => $KEY, jammy => $KEY, noble => $KEY })], [],
'all codenames signed by the expected key -> no problems');
# unsigned: undef AND '' both count as unsigned/failed -> UNSIGNED.
my @u = verify_repo_signature(\%exp, { focal => $KEY, jammy => undef, noble => '' });
is(scalar(@u), 2, 'two unsigned/failed codenames -> two problems');
ok((grep { $_ eq "UNSIGNED jammy (expected $KEY)" } @u), 'undef observed -> UNSIGNED <unit> (expected <key>)');
ok((grep { $_ eq "UNSIGNED noble (expected $KEY)" } @u), "empty observed -> UNSIGNED <unit> (expected <key>)");
# wrongkey: signed, but by a different key -> WRONGKEY (no false UNSIGNED for the good ones).
my @w = verify_repo_signature(\%exp, { focal => $KEY, jammy => 'DEADBEEFDEADBEEF', noble => $KEY });
is_deeply(\@w, ["WRONGKEY jammy: signed by DEADBEEFDEADBEEF, expected $KEY"],
'a different signer -> WRONGKEY <unit>: signed by <observed>, expected <key>');
}
# ---- parse_packages_index: PURE apt 'Packages' index parser (name => full version) --------------
{
my $idx = <<'PKG';
Package: ipmitool-xcat
Version: 1.8.18-4snap202608101400
Architecture: amd64
Description: fixture
Package: xcat-genesis-base-amd64
Version: 2.18.0-snap202608101400.5
Architecture: all
Description: genesis netboot image
Package: goconserver
Version: 2:0.3.3-snap202608101400.57
Architecture: amd64
Description: fixture
PKG
my $m = parse_packages_index($idx);
is($m->{'ipmitool-xcat'}, '1.8.18-4snap202608101400', 'first stanza name => version');
is($m->{'xcat-genesis-base-amd64'}, '2.18.0-snap202608101400.5',
'arch-suffixed genesis name parsed (across a blank-line separator)');
is($m->{'goconserver'}, '2:0.3.3-snap202608101400.57', 'epoch version kept verbatim');
is(scalar(keys %$m), 3, 'exactly the three stanzas parsed');
# malformed / empty input -> empty hash (a stanza lacking Package:/Version: is skipped).
is_deeply(parse_packages_index(''), {}, 'empty input -> empty hash');
is_deeply(parse_packages_index("garbage without fields\nno colon here\n"), {},
'malformed input (no Package:/Version:) -> empty hash');
}
# ---- deb_upstream_version: strip epoch + debian revision ----------------------------------------
is(deb_upstream_version('1.8.18-4'), '1.8.18', 'strip -revision');
is(deb_upstream_version('2:0.3.3-snap202608101400.57'), '0.3.3', 'strip epoch and -revision');