From 68f17873bbb311ddcb484dccf9ad04175641631d Mon Sep 17 00:00:00 2001 From: Daniel Hilst <392820+dhilst@users.noreply.github.com> Date: Wed, 12 Aug 2026 15:30:53 -0300 Subject: [PATCH] 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-/Packages (resolving arch-suffixed names like xcat-genesis-base-, reducing to upstream via deb_upstream_version to compare against the manifest pin), runs gpg --verify on each dists//InRelease and extracts the signer fingerprint, then delegates to the two pure deciders and dies listing every [/] 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=' 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> --- BuildUtils.pm | 104 +++++++++++++++++++++++++ sbuild-all.pl | 200 ++++++++++++++++++++++++++++++++++++++++++++++++- t/sbuild-all.t | 83 ++++++++++++++++++++ 3 files changed, 386 insertions(+), 1 deletion(-) diff --git a/BuildUtils.pm b/BuildUtils.pm index 3f753bb..b8acad0 100644 --- a/BuildUtils.pm +++ b/BuildUtils.pm @@ -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 (manifest requires )" -- required package not present at all +# "VERSION : repo has , manifest pins " -- 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 => , jammy => , ... }) +# %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 (expected )" -- no valid signature at all +# "WRONGKEY : signed by , expected " -- 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). # --------------------------------------------------------------------------------------------------- diff --git a/sbuild-all.pl b/sbuild-all.pl index cf6c0be..d177b43 100755 --- a/sbuild-all.pl +++ b/sbuild-all.pl @@ -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=) 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= +$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= 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-/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-/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 ^- (the arch-suffixed xcat-genesis-base -> xcat-genesis-base-). +# 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-/Packages -> %present; gpg --verify of each dists//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 [/]-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//.../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/C from validated stag CarchE> (Architecture:all packages land in every arch index) and gpg-signs C/C. Skipped with C<--skip-createrepo>. +=item Verify (published-repo gate) + +After assembly, a manifest-driven gate asserts -- per codename E arch, against the B +CarchE/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 is validly gpg-signed by the expected key (C, or detached C). Any +missing package, version mismatch, missing index, or unsigned/wrong-key signature fails the run. The +pure decisions (C for completeness, C +for the signer) and the index parsing (C) 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=Eapt_dirE>. + =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<< = >> + +Standalone mode: verify an already-assembled apt tree at C<< >> (completeness + Release +signatures) using this script's manifest resolution (C<--manifest> or the default +C), 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 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. diff --git a/t/sbuild-all.t b/t/sbuild-all.t index 8b54e31..1225cdd 100644 --- a/t/sbuild-all.t +++ b/t/sbuild-all.t @@ -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 ..."'); + + # 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 : repo has , manifest pins "'); + + # 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 (expected )'); + ok((grep { $_ eq "UNSIGNED noble (expected $KEY)" } @u), "empty observed -> UNSIGNED (expected )"); + + # 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 : signed by , expected '); +} + +# ---- 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');