From 717f6dc5b17ac483e5a475dd6ed4fb6e559f16b1 Mon Sep 17 00:00:00 2001 From: Daniel Hilst <392820+dhilst@users.noreply.github.com> Date: Thu, 1 Oct 2026 18:01:38 -0300 Subject: [PATCH 1/3] fix(xcat-dep): no openEuler target builds, for three independent reasons openeuler-20.03sp4-x86_64 and openeuler-22.03sp4-x86_64 build nothing. Both exit 3 at "Could not find included config file: /etc/mock/templates/openeuler-20.03-sp4.tpl", and both targets report that every build step failed. mock-core-configs names its openEuler templates by release alone, openeuler-20.03.tpl and openeuler-22.03.tpl, and each already sets releasever to the newest service pack. The two configurations spelled the service pack into the template name, which matches no file in any mock package. openeuler-24.03sp4-x86_64.cfg already includes openeuler-24.03.tpl, which is why that target builds. The ppc64le target fails before any package is built, for two further reasons. rpmkeys --dbpath cannot import the publisher key: rpm takes an fcntl transaction lock on /.rpm.lock, the staging directory is NFS mounted local_lock=none, and that lock answers errno 524 there. The keyring database moves to local temporary storage, keyed on the staging directory so two targets of one run do not share it. The staged source RPMs and the gpg home do not move. A verified publisher RPM is rejected as unreadable: reject_elf_payload read the exit status of rpm2cpio as the verdict on the payload, and on rpm 4.19 rpm2cpio writes the whole payload and still exits 1 with nothing on stderr. The cpio walk moves into scan_cpio_for_elf, which returns its own answer. A truncated stream never reaches the trailer and still dies, which is the case the exit status stood in for, and verify_input has already matched the sha256 and run rpmkeys --checksig before this point. Each fix has a test that fails without it. openeuler_mock_template_include.t reads the configurations only. openeuler_native_trust_dbpath.t and openeuler_native_cpio_scan.t run no rpmkeys and no rpm2cpio: the second builds its cpio streams in Perl and refuses an ELF member, a truncated stream, data after the trailer and a stream that is not cpio, each by name. Signed-off-by: Daniel Hilst <392820+dhilst@users.noreply.github.com> --- .github/workflows/genesis-openembedded.yml | 3 + lib/XCAT/NativeInputs.pm | 68 +++++++++++++--------- mock-configs/openeuler-20.03sp4-x86_64.cfg | 2 +- mock-configs/openeuler-22.03sp4-x86_64.cfg | 2 +- native/fixtures/mock-configs.py | 16 ++--- native/mock-configs.t | 7 --- t/openeuler_mock_template_include.t | 37 ++++++++++++ t/openeuler_native_cpio_scan.t | 53 +++++++++++++++++ t/openeuler_native_trust_dbpath.t | 27 +++++++++ 9 files changed, 168 insertions(+), 47 deletions(-) create mode 100644 t/openeuler_mock_template_include.t create mode 100644 t/openeuler_native_cpio_scan.t create mode 100644 t/openeuler_native_trust_dbpath.t diff --git a/.github/workflows/genesis-openembedded.yml b/.github/workflows/genesis-openembedded.yml index 49c60f0..98ad09a 100644 --- a/.github/workflows/genesis-openembedded.yml +++ b/.github/workflows/genesis-openembedded.yml @@ -77,6 +77,9 @@ jobs: prove -v t/riscv64_perl_cell.t sudo -E prove -v -It/lib t/common-repo-gate.t prove -v t/openeuler.t + prove -v t/openeuler_mock_template_include.t + prove -v -Ilib t/openeuler_native_trust_dbpath.t + prove -v -Ilib t/openeuler_native_cpio_scan.t # Ubuntu's AppArmor policy blocks these unprivileged test namespaces. - name: Run native packaging tests diff --git a/lib/XCAT/NativeInputs.pm b/lib/XCAT/NativeInputs.pm index df78564..3d4a3fa 100644 --- a/lib/XCAT/NativeInputs.pm +++ b/lib/XCAT/NativeInputs.pm @@ -9,10 +9,12 @@ use Exporter qw(import); use File::Basename qw(basename); use File::Copy qw(copy); use File::Path qw(make_path); +use File::Spec (); use File::Temp qw(tempdir); use JSON::PP; -our @EXPORT_OK = qw(load_inputs stage_inputs verify_input rpm_identity validate_outputs publisher_trust); +our @EXPORT_OK = qw(load_inputs stage_inputs verify_input rpm_identity validate_outputs publisher_trust + trust_dbpath scan_cpio_for_elf); sub read_file { my ($path) = @_; @@ -168,35 +170,38 @@ sub read_exact { return $data; } +sub scan_cpio_for_elf { + my ($fh) = @_; + while (1) { + my $header = read_exact($fh, 110); + die "Invalid RPM cpio header\n" unless $header =~ /\A07070[12][0-9A-Fa-f]{104}\z/; + my @fields = map { hex($_) } $header =~ /\A.{6}(.{8})(.{8})(.{8})(.{8})(.{8})(.{8})(.{8})(.{8})(.{8})(.{8})(.{8})(.{8})(.{8})\z/s; + my ($size, $namesize) = @fields[6, 11]; + die "Invalid RPM cpio filename\n" unless $namesize > 0 && $namesize <= 1048576; + my $name = read_exact($fh, $namesize); + die "Invalid RPM cpio filename terminator\n" unless $name =~ s/\0\z//; + read_exact($fh, (4 - (110 + $namesize) % 4) % 4); + last if $name eq 'TRAILER!!!' && $size == 0; + my $prefix = read_exact($fh, $size < 4 ? $size : 4); + die "ELF payload in publisher noarch RPM: $name\n" if $prefix eq "\x7fELF"; + $size -= length($prefix); + while ($size) { my $count = $size < 65536 ? $size : 65536; read_exact($fh, $count); $size -= $count; } + read_exact($fh, (4 - $fields[6] % 4) % 4); + } + my $tail; + while (read($fh, $tail, 65536)) { die "Unexpected data after RPM cpio trailer\n" if $tail =~ /[^\0]/; } + return 1; +} + sub reject_elf_payload { my ($path) = @_; open my $fh, '-|', 'rpm2cpio', $path or die "Cannot read RPM payload: $!\n"; binmode $fh; - my $error; - eval { - while (1) { - my $header = read_exact($fh, 110); - die "Invalid RPM cpio header\n" unless $header =~ /\A07070[12][0-9A-Fa-f]{104}\z/; - my @fields = map { hex($_) } $header =~ /\A.{6}(.{8})(.{8})(.{8})(.{8})(.{8})(.{8})(.{8})(.{8})(.{8})(.{8})(.{8})(.{8})(.{8})\z/s; - my ($size, $namesize) = @fields[6, 11]; - die "Invalid RPM cpio filename\n" unless $namesize > 0 && $namesize <= 1048576; - my $name = read_exact($fh, $namesize); - die "Invalid RPM cpio filename terminator\n" unless $name =~ s/\0\z//; - read_exact($fh, (4 - (110 + $namesize) % 4) % 4); - last if $name eq 'TRAILER!!!' && $size == 0; - my $prefix = read_exact($fh, $size < 4 ? $size : 4); - die "ELF payload in publisher noarch RPM: $name\n" if $prefix eq "\x7fELF"; - $size -= length($prefix); - while ($size) { my $count = $size < 65536 ? $size : 65536; read_exact($fh, $count); $size -= $count; } - read_exact($fh, (4 - $fields[6] % 4) % 4); - } - my $tail; - while (read($fh, $tail, 65536)) { die "Unexpected data after RPM cpio trailer\n" if $tail =~ /[^\0]/; } - 1; - } or $error = $@; - my $closed = close($fh); + my ($scanned, $error); + eval { $scanned = scan_cpio_for_elf($fh); 1 } or $error = $@; + close $fh; die $error if $error; - die "rpm2cpio failed: $path\n" unless $closed; + die "RPM payload did not end in a cpio trailer: $path\n" unless $scanned; } sub verify_input { @@ -218,9 +223,16 @@ sub verify_input { return $id; } +sub trust_dbpath { + my ($work) = @_; + my $root = $ENV{XCAT_DEP_TRUST_TMP} || File::Spec->tmpdir(); + return File::Spec->catdir($root, 'xcat-dep-trust-' . substr(Digest::SHA::sha256_hex($work), 0, 16)); +} + sub publisher_trust { my ($plan, $work) = @_; - make_path("$work/trust", "$work/gnupg"); + my $trust = trust_dbpath($work); + make_path($trust, "$work/gnupg"); chmod 0700, "$work/gnupg"; my $listing = capture('gpg', '--homedir', "$work/gnupg", '--batch', '--with-colons', '--show-keys', $plan->{publisher_key}); my @primary; @@ -231,8 +243,8 @@ sub publisher_trust { if ($pub && $fields[0] eq 'fpr') { push @primary, $fields[9]; $pub = 0; } } die "Publisher public key fingerprint mismatch\n" unless @primary == 1 && $primary[0] eq $plan->{publisher_fingerprint}; - run('rpmkeys', '--dbpath', "$work/trust", '--import', $plan->{publisher_key}); - return "$work/trust"; + run('rpmkeys', '--dbpath', $trust, '--import', $plan->{publisher_key}); + return $trust; } sub stage_inputs { diff --git a/mock-configs/openeuler-20.03sp4-x86_64.cfg b/mock-configs/openeuler-20.03sp4-x86_64.cfg index f6ada6a..4813716 100644 --- a/mock-configs/openeuler-20.03sp4-x86_64.cfg +++ b/mock-configs/openeuler-20.03sp4-x86_64.cfg @@ -1,4 +1,4 @@ -include('templates/openeuler-20.03-sp4.tpl') +include('templates/openeuler-20.03.tpl') config_opts['root'] = 'openeuler-20.03sp4-x86_64' config_opts['target_arch'] = 'x86_64' config_opts['legal_host_arches'] = ('x86_64',) diff --git a/mock-configs/openeuler-22.03sp4-x86_64.cfg b/mock-configs/openeuler-22.03sp4-x86_64.cfg index 4261f65..12ec4a2 100644 --- a/mock-configs/openeuler-22.03sp4-x86_64.cfg +++ b/mock-configs/openeuler-22.03sp4-x86_64.cfg @@ -1,4 +1,4 @@ -include('templates/openeuler-22.03-sp4.tpl') +include('templates/openeuler-22.03.tpl') config_opts['root'] = 'openeuler-22.03sp4-x86_64' config_opts['target_arch'] = 'x86_64' config_opts['legal_host_arches'] = ('x86_64',) diff --git a/native/fixtures/mock-configs.py b/native/fixtures/mock-configs.py index 3c3c9bf..905d00c 100644 --- a/native/fixtures/mock-configs.py +++ b/native/fixtures/mock-configs.py @@ -9,16 +9,12 @@ from mockbuild.config import load_config source = Path(sys.argv[1]).resolve() with tempfile.TemporaryDirectory() as directory: config_path = Path(directory) - (config_path / 'templates').mkdir() - absent = [] - for parent in ('openeuler-20.03-sp4.tpl', 'openeuler-22.03-sp4.tpl', 'openeuler-24.03.tpl'): - source_template = Path('/etc/mock/templates') / parent - if not source_template.is_file(): - absent.append(parent) - continue - shutil.copyfile(source_template, config_path / 'templates' / parent) - shutil.copyfile(source / 'templates/openeuler-lts-xcat.tpl', config_path / 'templates/openeuler-lts-xcat.tpl') - result = {'_absent_templates': absent} + templates = config_path / 'templates' + templates.mkdir() + for installed in Path('/etc/mock/templates').glob('*.tpl'): + shutil.copyfile(installed, templates / installed.name) + shutil.copyfile(source / 'templates/openeuler-lts-xcat.tpl', templates / 'openeuler-lts-xcat.tpl') + result = {} for wrapper in sorted(source.glob('openeuler-*.cfg')): try: config = load_config(str(config_path), str(wrapper)) diff --git a/native/mock-configs.t b/native/mock-configs.t index 2fc43cd..123d5c5 100644 --- a/native/mock-configs.t +++ b/native/mock-configs.t @@ -21,18 +21,11 @@ open(my $pipe, '-|', 'python3', "$RealBin/fixtures/mock-configs.py", "$RealBin/. my $json = do {local $/; <$pipe>}; close($pipe) or die "native mock config loader failed: $?"; my $configs = decode_json($json); -my $absent = delete $configs->{_absent_templates} || []; -diag("base template absent on this host, its configs are skipped: @$absent") if @$absent; for my $cell (@cells) { my ($version, $release, $releasever, $arch) = @$cell; my $target = "openeuler-$version-$arch"; my $config = $configs->{$target}; if ($config->{error}) { - my ($missing) = $config->{error} =~ m{templates/([^/\s]+\.tpl)}; - if (defined($missing) && grep { $_ eq $missing } @$absent) { - note("$target skipped: $missing is not installed on this host"); - next; - } fail("$target loads: $config->{error}"); next; } diff --git a/t/openeuler_mock_template_include.t b/t/openeuler_mock_template_include.t new file mode 100644 index 0000000..e1b6b95 --- /dev/null +++ b/t/openeuler_mock_template_include.t @@ -0,0 +1,37 @@ +#!/usr/bin/perl +use strict; +use warnings; +use Test::More; +use FindBin qw($RealBin); +use File::Basename qw(basename); +use File::Slurper qw(read_text); + +my @cfg = sort glob("$RealBin/../mock-configs/openeuler-*.cfg"); + +cmp_ok(scalar @cfg, '>=', 2, 'mock-configs/ carries openEuler configurations') + or die "no mock-configs/openeuler-*.cfg under $RealBin/.. -- this test reads nothing\n"; + +my %shipped = map { basename($_) => 1 } glob("$RealBin/../mock-configs/templates/*.tpl"); +ok($shipped{'openeuler-lts-xcat.tpl'}, 'the xCAT openEuler template is shipped here'); + +for my $path (@cfg) { + my $name = basename($path); + my $text = read_text($path); + + my ($release) = $text =~ /openeuler_repository_release'\]\s*=\s*'([^']+)'/; + ok(defined $release, "$name: declares openeuler_repository_release") or next; + + my @inc = $text =~ /include\('templates\/([^']+)'\)/g; + cmp_ok(scalar @inc, '>=', 1, "$name: includes at least one template"); + + my ($majmin) = $release =~ /\A(\d+\.\d+)/; + ok(defined $majmin, "$name: its release starts with a major.minor ($release)") or next; + + for my $t (@inc) { + next if $shipped{$t}; # shipped beside the cfg, so it always resolves + is($t, "openeuler-$majmin.tpl", + "$name: its mock-core-configs template is openeuler-$majmin.tpl, not '$t'"); + } +} + +done_testing(); diff --git a/t/openeuler_native_cpio_scan.t b/t/openeuler_native_cpio_scan.t new file mode 100644 index 0000000..e370543 --- /dev/null +++ b/t/openeuler_native_cpio_scan.t @@ -0,0 +1,53 @@ +#!/usr/bin/perl +use strict; +use warnings; +use Test::More; +use FindBin qw($RealBin); +use lib "$RealBin/../lib"; +use XCAT::NativeInputs qw(scan_cpio_for_elf); + +sub member { + my ($name, $data) = @_; + my $n = length($name) + 1; + my $rec = '070701' + . join('', map { sprintf '%08X', $_ } + (1, 0100644, 0, 0, 1, 0, length($data), 0, 0, 0, 0, $n, 0)) + . "$name\0"; + $rec .= "\0" x ((4 - (110 + $n) % 4) % 4); + $rec .= $data; + $rec .= "\0" x ((4 - length($data) % 4) % 4); + return $rec; +} +sub trailer { + my $n = length('TRAILER!!!') + 1; + my $rec = '070701' . join('', map { sprintf '%08X', $_ } (0) x 13); + substr($rec, 6 + 11 * 8, 8) = sprintf '%08X', $n; + $rec .= "TRAILER!!!\0"; + $rec .= "\0" x ((4 - (110 + $n) % 4) % 4); + return $rec; +} +sub scan { + my ($bytes) = @_; + open my $fh, '<', \$bytes or die "in-memory open: $!"; + binmode $fh; + my $r = eval { scan_cpio_for_elf($fh) }; + return $@ unless defined $r; + return $r ? 'OK' : "FALSY VERDICT: '$r'"; +} + +is(scan(member('./usr/share/perl5/CGI.pm', "package CGI;\n1;\n") . trailer()), 'OK', + 'a noarch payload of plain files is accepted'); + +like(scan(member('./usr/bin/thing', "\x7fELF\x02\x01\x01\x00") . trailer()), + qr/ELF payload in publisher noarch RPM: \.\/usr\/bin\/thing/, + 'an ELF member is refused, and the message names the file'); + +like(scan(substr(member('./usr/share/doc/README', 'x' x 4096) . trailer(), 0, 200)), + qr/Truncated RPM payload/, 'a stream that ends before its trailer is refused'); + +like(scan(member('./a', 'x') . trailer() . 'trailing junk'), + qr/Unexpected data after RPM cpio trailer/, 'data after the trailer is refused'); + +like(scan('N' x 200), qr/Invalid RPM cpio header/, 'a stream that is not cpio is refused'); + +done_testing(); diff --git a/t/openeuler_native_trust_dbpath.t b/t/openeuler_native_trust_dbpath.t new file mode 100644 index 0000000..568f39a --- /dev/null +++ b/t/openeuler_native_trust_dbpath.t @@ -0,0 +1,27 @@ +#!/usr/bin/perl +use strict; +use warnings; +use Test::More; +use File::Spec (); +use FindBin qw($RealBin); +use lib "$RealBin/../lib"; +use XCAT::NativeInputs qw(trust_dbpath); + +my $root = File::Spec->catdir(File::Spec->rootdir, 'local-scratch'); +local $ENV{XCAT_DEP_TRUST_TMP} = $root; + +my $staging = File::Spec->catdir(File::Spec->rootdir, qw(opt xcat-ci-shared builds oe262 2 native-inputs)); +my $db = trust_dbpath($staging); + +ok(defined $db && length $db, 'trust_dbpath answers for a staging directory') or done_testing, exit; + +unlike($db, qr/\Q$staging\E/, + 'the keyring is not inside the staging tree, where the rpm lock answers errno 524'); +like($db, qr/^\Q$root\E\b/, 'the keyring goes under the local root'); + +my $other = File::Spec->catdir(File::Spec->rootdir, qw(opt xcat-ci-shared builds oe262 3 native-inputs)); +isnt(trust_dbpath($other), $db, 'two staging directories get two databases'); + +is(trust_dbpath($staging), $db, 'the same staging directory always gets the same database'); + +done_testing(); From 268beca4c76fdf6f30e787d2725ac502d36efc61 Mon Sep 17 00:00:00 2001 From: Daniel Hilst <392820+dhilst@users.noreply.github.com> Date: Fri, 2 Oct 2026 13:07:50 -0300 Subject: [PATCH 2/3] test(xcat-dep): trust_dbpath reuses a path another user can hold trust_dbpath answers the same name under the local root for one staging directory, and does not create it. A directory or symlink that another user puts at that name is then used as the publisher keyring. The test plants a symlink at the path the first call answered. It expects a second call to answer a fresh 0700 directory under the root, and the XCAT_DEP_TRUST_TMP override to still pick the root. Signed-off-by: Daniel Hilst <392820+dhilst@users.noreply.github.com> --- t/openeuler_native_trust_dbpath.t | 43 +++++++++++++++++++++++-------- 1 file changed, 32 insertions(+), 11 deletions(-) diff --git a/t/openeuler_native_trust_dbpath.t b/t/openeuler_native_trust_dbpath.t index 568f39a..62153b8 100644 --- a/t/openeuler_native_trust_dbpath.t +++ b/t/openeuler_native_trust_dbpath.t @@ -3,25 +3,46 @@ use strict; use warnings; use Test::More; use File::Spec (); +use File::Temp qw(tempdir); use FindBin qw($RealBin); use lib "$RealBin/../lib"; use XCAT::NativeInputs qw(trust_dbpath); -my $root = File::Spec->catdir(File::Spec->rootdir, 'local-scratch'); -local $ENV{XCAT_DEP_TRUST_TMP} = $root; +my $scratch = tempdir('trust-dbpath-XXXXXX', TMPDIR => 1, CLEANUP => 1); +my $root = File::Spec->catdir($scratch, 'local-root'); +my $foreign = File::Spec->catdir($scratch, 'foreign'); +mkdir $root or die "mkdir $root: $!"; +mkdir $foreign or die "mkdir $foreign: $!"; -my $staging = File::Spec->catdir(File::Spec->rootdir, qw(opt xcat-ci-shared builds oe262 2 native-inputs)); -my $db = trust_dbpath($staging); +sub fresh_private_dir_under { + my ($db, $under, $what) = @_; + ok(defined $db && length $db, "$what: trust_dbpath answers") or return; + like($db, qr/^\Q$under\E\//, "$what: the keyring goes under the local root"); + ok(-d $db && !-l $db, "$what: the keyring is a real directory, not a symlink"); + is((lstat $db)[2] & 07777, 0700, "$what: the keyring directory is private"); + opendir my $dh, $db or return fail("$what: cannot read $db"); + is_deeply([grep { !/^\.\.?\z/ } readdir $dh], [], "$what: the keyring directory starts empty"); +} -ok(defined $db && length $db, 'trust_dbpath answers for a staging directory') or done_testing, exit; +{ + local $ENV{XCAT_DEP_TRUST_TMP} = $root; + my $first = trust_dbpath(); + fresh_private_dir_under($first, $root, 'first call'); -unlike($db, qr/\Q$staging\E/, - 'the keyring is not inside the staging tree, where the rpm lock answers errno 524'); -like($db, qr/^\Q$root\E\b/, 'the keyring goes under the local root'); + # Another user takes over the path the first call answered. + rmdir $first if -d $first && !-l $first; + symlink $foreign, $first or die "symlink $first: $!"; -my $other = File::Spec->catdir(File::Spec->rootdir, qw(opt xcat-ci-shared builds oe262 3 native-inputs)); -isnt(trust_dbpath($other), $db, 'two staging directories get two databases'); + my $second = trust_dbpath(); + isnt($second, $first, 'a path held by someone else is not reused'); + fresh_private_dir_under($second, $root, 'second call'); +} -is(trust_dbpath($staging), $db, 'the same staging directory always gets the same database'); +{ + my $other = File::Spec->catdir($scratch, 'other-root'); + mkdir $other or die "mkdir $other: $!"; + local $ENV{XCAT_DEP_TRUST_TMP} = $other; + fresh_private_dir_under(trust_dbpath(), $other, 'XCAT_DEP_TRUST_TMP override'); +} done_testing(); From 779403852c75fd47fabf2d96fe1929ad5032d804 Mon Sep 17 00:00:00 2001 From: Daniel Hilst <392820+dhilst@users.noreply.github.com> Date: Fri, 2 Oct 2026 13:11:51 -0300 Subject: [PATCH 3/3] fix(xcat-dep): publisher keyring can reuse a directory the build did not create trust_dbpath named the keyring after a hash of the staging directory and did not create it. A directory or symlink that another user put at that predictable name under the local root became the rpmkeys --dbpath for the publisher key. trust_dbpath now creates a fresh 0700 directory with tempdir under the same root, so XCAT_DEP_TRUST_TMP still selects it. CLEANUP removes it when the creating process exits. stage_inputs and build_native_inputs run in that process, so the keyring stays in place for every verify_input of the run. Signed-off-by: Daniel Hilst <392820+dhilst@users.noreply.github.com> --- lib/XCAT/NativeInputs.pm | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/XCAT/NativeInputs.pm b/lib/XCAT/NativeInputs.pm index 3d4a3fa..065c136 100644 --- a/lib/XCAT/NativeInputs.pm +++ b/lib/XCAT/NativeInputs.pm @@ -224,14 +224,14 @@ sub verify_input { } sub trust_dbpath { - my ($work) = @_; my $root = $ENV{XCAT_DEP_TRUST_TMP} || File::Spec->tmpdir(); - return File::Spec->catdir($root, 'xcat-dep-trust-' . substr(Digest::SHA::sha256_hex($work), 0, 16)); + # The keyring lives until this process exits, after the last verify_input of the run. + return tempdir('xcat-dep-trust-XXXXXX', DIR => $root, CLEANUP => 1); } sub publisher_trust { my ($plan, $work) = @_; - my $trust = trust_dbpath($work); + my $trust = trust_dbpath(); make_path($trust, "$work/gnupg"); chmod 0700, "$work/gnupg"; my $listing = capture('gpg', '--homedir', "$work/gnupg", '--batch', '--with-colons', '--show-keys', $plan->{publisher_key});