diff --git a/.github/workflows/genesis-openembedded.yml b/.github/workflows/genesis-openembedded.yml index cfc8319..f3ffdf6 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 bats t/verify-xcatsn-install.bats # Ubuntu's AppArmor policy blocks these unprivileged test namespaces. diff --git a/lib/XCAT/NativeInputs.pm b/lib/XCAT/NativeInputs.pm index df78564..065c136 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 $root = $ENV{XCAT_DEP_TRUST_TMP} || File::Spec->tmpdir(); + # 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) = @_; - make_path("$work/trust", "$work/gnupg"); + 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}); 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..62153b8 --- /dev/null +++ b/t/openeuler_native_trust_dbpath.t @@ -0,0 +1,48 @@ +#!/usr/bin/perl +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 $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: $!"; + +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"); +} + +{ + local $ENV{XCAT_DEP_TRUST_TMP} = $root; + my $first = trust_dbpath(); + fresh_private_dir_under($first, $root, 'first call'); + + # 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 $second = trust_dbpath(); + isnt($second, $first, 'a path held by someone else is not reused'); + fresh_private_dir_under($second, $root, 'second call'); +} + +{ + 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();