App-Ariza.git | t/ | 11-native.rakutest
use v6.d;
use Test;
use App::Ariza::Native;
use App::Ariza::Platform;
use App::Ariza::Tools;
use App::Ariza::Versions;
plan 32;
sub tmp-dir(--> IO::Path) {
my $dir = $*TMPDIR.add("ariza-native-{$*PID}-{(^1_000_000).pick}");
$dir.mkdir;
$dir;
}
sub versions(--> App::Ariza::Versions) { App::Ariza::Versions.load }
#| A file shaped enough like a shared library for staging to work on it:
#| bytes, plus SQLCipher's own NUL-terminated version constant where the
#| version probe will find it.
sub fake-library(IO::Path $path, Str :$version = '4.14.0' --> IO::Path) {
ensure-dir($path.parent);
$path.spurt("fake payload\x[00]$version\x[00]trailing\x[00]");
$path
}
#| The same, but with an ELF magic number in front of it, so
#| `binary-format` calls it an ELF and the Linux self-containment pass
#| treats it as something worth walking. Nothing here parses ELF
#| structure — `ldd` and `patchelf` are seams in every test that uses one.
sub fake-elf(IO::Path $path, Str :$version = '4.14.0' --> IO::Path) {
ensure-dir($path.parent);
$path.spurt(buf8.new(0x7F, 0x45, 0x4C, 0x46)
~ "fake payload\x[00]$version\x[00]trailing\x[00]".encode('latin-1'));
$path
}
#| A real, if minimal, PE file: DOS header, PE signature, COFF header,
#| optional header (PE32+ by default, PE32 with C<:pe32>), one C<.rdata>
#| section, and inside it an import descriptor array naming C<@imports>.
#|
#| Hand-packed rather than downloaded, because C<t/> has to pass in a
#| tunnel — and because a fixture assembled from parts can be malformed
#| on purpose, which no real DLL can. C<xt/03-pe-imports.rakutest> runs
#| the same parser over the genuine article.
#|
#| The layout is the standard one: C<e_lfanew> at 0x3C points at 0x80,
#| the section's bytes start at 0x400 and are mapped at RVA 0x1000, so
#| every RVA in the import table has to travel through the section table
#| to become a file offset — which is the part of the parser worth
#| exercising.
sub pe-bytes(@imports, Bool :$pe32 = False, Int :$ndirs = 16,
Int :$magic, Int :$sections = 1 --> Buf) {
my sub put16(@b, Int $at, Int $n) {
@b[$at] = $n +& 0xFF; @b[$at + 1] = ($n +> 8) +& 0xFF;
}
my sub put32(@b, Int $at, Int $n) {
@b[$at] = $n +& 0xFF; @b[$at + 1] = ($n +> 8) +& 0xFF;
@b[$at + 2] = ($n +> 16) +& 0xFF; @b[$at + 3] = ($n +> 24) +& 0xFF;
}
my $dir-at = $pe32 ?? 96 !! 112; # data directories, within the
my $count-at = $pe32 ?? 92 !! 108; # optional header
my $opt-size = $dir-at + 8 * $ndirs;
my $pe-off = 0x80;
my $coff = $pe-off + 4;
my $opt = $coff + 20;
my $sec-tab = $opt + $opt-size;
my $raw = 0x400; # section bytes start here
my $sec-rva = 0x1000; # and are mapped here
# The section: one 20-byte descriptor per import, an all-zero one to
# end the array, then the NUL-terminated names they point at.
my $names-at = (@imports.elems + 1) * 20;
my @sec = 0 xx $names-at;
my $cursor = $names-at;
for @imports.kv -> $i, $name {
put32(@sec, $i * 20 + 12, $sec-rva + $cursor); # Name RVA
put32(@sec, $i * 20 + 16, 0x3000 + $i); # FirstThunk
@sec.push($_) for $name.encode('ascii').list;
@sec.push(0);
$cursor += $name.chars + 1;
}
my @b = 0 xx ($raw + @sec.elems);
@b[0] = 0x4D; @b[1] = 0x5A; # "MZ"
put32(@b, 0x3C, $pe-off);
@b[$pe-off] = 0x50; @b[$pe-off + 1] = 0x45; # "PE\0\0"
put16(@b, $coff, 0x8664); # Machine
put16(@b, $coff + 2, $sections);
put16(@b, $coff + 16, $opt-size);
put16(@b, $opt, $magic // ($pe32 ?? 0x010B !! 0x020B));
put32(@b, $opt + $count-at, $ndirs);
if $ndirs >= 2 {
put32(@b, $opt + $dir-at + 8, $sec-rva); # import table RVA
put32(@b, $opt + $dir-at + 12, $names-at); # and its size
}
my $name = '.rdata';
@b[$sec-tab + $_] = $name.substr($_, 1).ord for ^$name.chars;
put32(@b, $sec-tab + 8, @sec.elems); # VirtualSize
put32(@b, $sec-tab + 12, $sec-rva); # VirtualAddress
put32(@b, $sec-tab + 16, @sec.elems); # SizeOfRawData
put32(@b, $sec-tab + 20, $raw); # PointerToRawData
@b[$raw + $_] = @sec[$_] for ^@sec.elems;
buf8.new(@b)
}
#| The same, written to a file.
sub fake-pe(IO::Path $path, @imports, |c --> IO::Path) {
ensure-dir($path.parent);
$path.spurt(pe-bytes(@imports, |c));
$path
}
#| Run a block with STDERR captured, and hand back what it said. Every
#| advisory-pin warning goes there, and a test that lets it through is a
#| test transcript nobody reads.
sub notes-from(&block --> Str) {
my $file = $*TMPDIR.add("ariza-native-err-{$*PID}-{(^1_000_000).pick}");
{
temp $*ERR = open $file, :w;
LEAVE $*ERR.close;
block();
}
LEAVE $file.unlink;
$file.slurp
}
#| A `try-run` stand-in: maps a command line, joined with spaces, to the
#| `(exitcode, stdout, stderr)` it should answer with. An unlisted
#| command is "no such command", exactly as `try-run` reports one.
sub fake-run(%answers) {
-> @cmd, |c {
%answers{@cmd.join(' ')} // (-1, '', "could not run '{@cmd.head}'")
}
}
#| A `try-run` stand-in for the Linux tools, which are asked about files
#| in a temporary directory whose name no fixture can know in advance:
#| `ldd`, `readelf` and `patchelf --print-rpath` answer per B<basename>,
#| from %answers; everything else answers the way a working machine does.
#|
#| Every command line is appended to @calls, so a test can assert that
#| `patchelf --set-rpath` was run on each staged file — the step whose
#| absence is invisible until a user's machine loads the wrong OpenSSL.
sub elf-run(%answers, :@calls, :$patchelf = True, :$readelf = True) {
-> @cmd, |c {
@calls.push(@cmd.join(' '));
my $base = @cmd > 1 ?? @cmd[*-1].IO.basename !! '';
given @cmd[0] {
when 'which' | 'where' {
my $found = do given @cmd[1] {
when 'patchelf' { $patchelf }
when 'readelf' | 'eu-readelf' { $readelf }
default { True }
};
$found ?? (0, "/usr/bin/{@cmd[1]}\n", '') !! (1, '', '')
}
when 'ldd' {
(0, %answers{"ldd $base"} // "\tlibc.so.6 => /lib64/libc.so.6 (0x1)\n", '')
}
when 'readelf' | 'eu-readelf' {
(0, %answers{"readelf $base"} // '', '')
}
when 'patchelf' {
@cmd[1] eq '--print-rpath'
?? (0, (%answers{"rpath $base"} // '$ORIGIN') ~ "\n", '')
!! (0, '', '')
}
default { (-1, '', "could not run '{@cmd.head}'") }
}
}
}
# Real `otool -L` output, verbatim: a Homebrew libsqlcipher before ariza
# has touched it. The first line is otool's banner; the second is the
# library's own install name; the rest are its dependencies.
my constant DIRTY-MACHO = q:to/OUT/;
/opt/homebrew/opt/sqlcipher/lib/libsqlcipher.0.dylib:
/opt/homebrew/opt/sqlcipher/lib/libsqlcipher.dylib (compatibility version 9.0.0, current version 9.6.0)
/usr/lib/libz.1.dylib (compatibility version 1.0.0, current version 1.2.12)
/opt/homebrew/opt/openssl@3/lib/libcrypto.3.dylib (compatibility version 3.0.0, current version 3.0.0)
/usr/lib/libSystem.B.dylib (compatibility version 1.0.0, current version 1356.0.0)
OUT
my constant CLEAN-MACHO = q:to/OUT/;
libsqlcipher.0.dylib:
@rpath/libsqlcipher.0.dylib (compatibility version 9.0.0, current version 9.6.0)
/usr/lib/libz.1.dylib (compatibility version 1.0.0, current version 1.2.12)
@loader_path/libcrypto.3.dylib (compatibility version 3.0.0, current version 3.0.0)
/usr/lib/libSystem.B.dylib (compatibility version 1.0.0, current version 1356.0.0)
/System/Library/Frameworks/CoreFoundation.framework/Versions/A/CoreFoundation
OUT
my constant CLEAN-ELF = q:to/OUT/;
Dynamic section at offset 0x1d000 contains 27 entries:
Tag Type Name/Value
0x0000000000000001 (NEEDED) Shared library: [libcrypto.so.3]
0x0000000000000001 (NEEDED) Shared library: [libc.so.6]
0x000000000000001d (RUNPATH) Library runpath: [$ORIGIN:$ORIGIN/../lib]
OUT
my constant DIRTY-ELF = q:to/OUT/;
Dynamic section at offset 0x1d000 contains 27 entries:
Tag Type Name/Value
0x0000000000000001 (NEEDED) Shared library: [/home/build/lib/libcrypto.so.3]
0x0000000000000001 (NEEDED) Shared library: [libc.so.6]
0x000000000000000f (RPATH) Library rpath: [/home/build/lib:$ORIGIN]
OUT
subtest 'macho-strays reads otool output', {
plan 5;
is-deeply macho-strays(CLEAN-MACHO), (),
'a self-contained library has nothing to report';
my @strays = macho-strays(DIRTY-MACHO);
is +@strays, 2, 'the Homebrew paths are found';
ok @strays.first(/'openssl@3'/),
'including the OpenSSL that exists on the build machine and nowhere else';
ok @strays.first(/'libsqlcipher.dylib'/),
'and the absolute install name, which is copied into everything that links it';
nok @strays.first(/'libSystem'/),
'while /usr/lib is left alone — those really are on every Mac';
};
subtest 'elf-strays reads readelf output', {
plan 4;
is-deeply elf-strays(CLEAN-ELF), (),
'plain sonames plus $ORIGIN runpaths are exactly right';
my @strays = elf-strays(DIRTY-ELF);
ok @strays.first(/'/home/build/lib/libcrypto.so.3'/),
'a NEEDED entry with a slash is an absolute path baked into the binary';
ok @strays.first({ $_ eq '/home/build/lib' }),
'and an RPATH entry that is not $ORIGIN-relative points at the build machine';
nok @strays.first(/'libc.so.6'/), 'bare sonames are fine';
};
subtest 'what SQLCipher is called, per platform', {
plan 5;
is App::Ariza::Native.sqlcipher-layout('macos-arm64')<lib>,
'libsqlcipher.0.dylib', 'macOS opens the versioned dylib name';
is App::Ariza::Native.sqlcipher-layout('linux-x86_64-musl')<lib>,
'libsqlcipher.so.0', 'Linux the soname — musl included, since the'
~ ' library now comes off the machine itself';
is App::Ariza::Native.sqlcipher-layout('windows-arm64')<alias>, Str,
'Windows asks for exactly one name, so there is no alias to make';
is-deeply App::Ariza::Native.sqlcipher-slugs.List, known-slugs().List,
'every platform ariza can name is a platform it can stage SQLCipher for';
throws-like { App::Ariza::Native.sqlcipher-layout('linux-riscv64-glibc') },
Exception, message => /'does not know what SQLCipher is called' .* 'macos-arm64'/,
'and a slug it has never heard of is named, with the known set listed';
};
subtest 'where the library is staged, and why macOS differs', {
plan 4;
my $b = '/b'.IO;
# macOS: rakudo/lib is on the interpreter's own LC_RPATH, so
# `is native('sqlcipher')` resolves with no launcher involvement.
is App::Ariza::Native.sqlcipher-dir($b, :slug<macos-arm64>).absolute,
'/b/rakudo/lib'.IO.absolute, 'macOS stages into rakudo/lib';
is App::Ariza::Native.sqlcipher-rel(:slug<macos-arm64>),
'rakudo/lib/libsqlcipher.0.dylib', 'and records that relative path';
is App::Ariza::Native.sqlcipher-dir($b, :slug<linux-x86_64-glibc>).absolute,
'/b/native/sqlcipher'.IO.absolute,
'Linux has no implicit path, so it gets an explicit directory';
is App::Ariza::Native.sqlcipher-rel(:slug<windows-x86_64>),
'native/sqlcipher/sqlcipher.dll', 'as does Windows';
};
subtest 'the archive override beats everything, and a missing one is named', {
plan 5;
my $dir = tmp-dir;
LEAVE { rm-rf($dir) }
my $archive = $dir.add('libsqlcipher-linux-x86_64.tar.gz');
$archive.spurt('not really an archive, but it has bytes');
# Everything that could disagree, disagreeing at once: a directory
# named in the environment, a brew that would answer, and a host slug
# that is not the one being built.
my $keg = fake-library($dir.add('keg/lib/libsqlcipher.so.0')).parent;
my %got = App::Ariza::Native.sqlcipher-source(
:slug<linux-x86_64-glibc>, :$archive,
:host-slug<macos-arm64>,
:env(%( SQLCIPHER_LIB_DIR => $keg.absolute )),
:run(fake-run(%())));
is %got<kind>, 'archive', 'the file the operator named is what is used';
is %got<path>.absolute, $archive.absolute, 'that exact file';
ok %got<origin>.contains('local archive: libsqlcipher-linux-x86_64.tar.gz'),
'and the manifest will say where it came from';
is %got<version>, Str,
'an archive says nothing about its version; the staged library will';
throws-like {
App::Ariza::Native.sqlcipher-source(:slug<macos-arm64>,
:archive($dir.add('absent.tar.gz')))
}, Exception, message => /'no SQLCipher archive at'/,
'and pointing --sqlcipher-archive at nothing fails rather than'
~ ' quietly falling back to this machine\'s library';
};
subtest 'SQLCIPHER_LIB_DIR names a directory, and a wrong one is fatal', {
plan 4;
my $dir = tmp-dir;
LEAVE { rm-rf($dir) }
my $lib = fake-library($dir.add('vendored/sqlcipher.dll'));
my %got = App::Ariza::Native.sqlcipher-source(:slug<windows-x86_64>,
:host-slug<macos-arm64>,
:env(%( SQLCIPHER_LIB_DIR => $lib.parent.absolute )));
is %got<kind>, 'library', 'the library in it is taken directly';
is %got<path>.absolute, $lib.resolve.absolute,
'by name, per platform, resolved to the real file';
ok %got<origin>.contains('SQLCIPHER_LIB_DIR'),
'recorded as having been named by hand';
# Falling through to a different library here would stage something
# the operator explicitly ruled out.
throws-like {
App::Ariza::Native.sqlcipher-source(:slug<windows-x86_64>,
:host-slug<windows-x86_64>,
:env(%( SQLCIPHER_LIB_DIR => $dir.add('empty').absolute )))
}, Exception, message => /'SQLCIPHER_LIB_DIR' .* 'holds no sqlcipher.dll'/,
'a directory without the library is an error, not a fall-through';
};
subtest 'macOS asks Homebrew, and takes the keg\'s real file', {
plan 4;
my $dir = tmp-dir;
LEAVE { rm-rf($dir) }
# A keg as Homebrew lays one out: a versioned Cellar directory, and a
# `.0.dylib` pointing at the real file, whose own name carries
# SQLite's version rather than SQLCipher's.
my $keg = $dir.add('Cellar/sqlcipher/4.14.0');
my $real = fake-library($keg.add('lib/libsqlcipher.3.51.3.dylib'));
my $link = $keg.add('lib/libsqlcipher.0.dylib');
my ($code, $, $) = try-run(['ln', '-s', $real.basename, $link.absolute]);
my $symlinked = $code == 0 && $link.l;
fake-library($link) unless $symlinked;
my %got = App::Ariza::Native.sqlcipher-source(:slug<macos-arm64>,
:host-slug<macos-arm64>, :env(%()),
:run(fake-run(%( 'brew --prefix sqlcipher' => (0, $keg.absolute ~ "\n", '') ))));
is %got<kind>, 'library', 'an installed keg is used in place';
# Branching on whether this filesystem has symlinks at all rather
# than skipping: on Windows there are none to chase and the library
# is a plain file, which is the correct answer there.
$symlinked
?? is %got<path>.basename, $real.basename,
'chasing the symlink, so what is copied is bytes and not a'
~ ' link into a Cellar the user does not have'
!! is %got<path>.basename, $link.basename,
'and where there are no symlinks, the file itself';
is %got<version>, '4.14.0',
'the keg directory names the version the filename does not';
ok %got<origin>.contains('homebrew keg'), 'and says so in the manifest';
};
subtest 'macOS with no keg fetches the bottle; with no brew it says so', {
plan 5;
my $dir = tmp-dir;
LEAVE { rm-rf($dir) }
my $bottle = $dir.add(
'abc123--sqlcipher--4.17.0.arm64_tahoe.bottle.tar.gz');
$bottle.spurt('a bottle, for naming purposes');
my %answers =
'brew --prefix sqlcipher' => (0, $dir.add('opt/sqlcipher').absolute, ''),
'brew fetch --formula sqlcipher' => (0, 'Downloaded', ''),
'brew --cache --formula sqlcipher' => (0, $bottle.absolute, ''),
;
my %got = App::Ariza::Native.sqlcipher-source(:slug<macos-arm64>,
:host-slug<macos-arm64>, :env(%()), :run(fake-run(%answers)));
is %got<kind>, 'archive', 'the bottle is an archive to unpack';
is %got<path>.absolute, $bottle.absolute, 'the one brew --cache names';
is %got<version>, '4.17.0',
'whose filename names the version brew would have installed';
ok %got<origin>.contains('homebrew bottle'), 'recorded as such';
# No brew at all: `brew --prefix` cannot be spawned.
throws-like {
App::Ariza::Native.sqlcipher-source(:slug<macos-arm64>,
:host-slug<macos-arm64>, :env(%()), :run(fake-run(%())))
}, Exception,
message => /'brew install sqlcipher' .* '--sqlcipher-archive'/,
'and with no Homebrew the death names both remedies';
};
subtest 'Linux takes what ldconfig resolves, then the standard directories', {
plan 5;
my $dir = tmp-dir;
LEAVE { rm-rf($dir) }
my $lib = fake-library($dir.add('usr/lib/x86_64-linux-gnu/libsqlcipher.so.0'));
my $ldconfig = qq:to/OUT/;
\t2 libs found in cache `/etc/ld.so.cache'
\tlibsqlcipher.so.0 (libc6,x86-64) => {$lib.absolute}
\tlibc.so.6 (libc6,x86-64) => /lib/x86_64-linux-gnu/libc.so.6
OUT
my %got = App::Ariza::Native.sqlcipher-source(:slug<linux-x86_64-glibc>,
:host-slug<linux-x86_64-glibc>, :env(%()),
:run(fake-run(%( 'ldconfig -p' => (0, $ldconfig, '') ))));
is %got<path>.absolute, $lib.resolve.absolute,
'the loader\'s own answer is the authority on what will be found';
ok %got<origin>.contains('ldconfig'), 'and is recorded as such';
# /sbin is not on every user's PATH, which is exactly when the
# absolute path has to be tried too.
my %sbin = App::Ariza::Native.sqlcipher-source(:slug<linux-x86_64-glibc>,
:host-slug<linux-x86_64-glibc>, :env(%()),
:run(fake-run(%( '/sbin/ldconfig -p' => (0, $ldconfig, '') ))));
is %sbin<path>.absolute, $lib.resolve.absolute,
'ldconfig is also tried by path';
# No ldconfig at all — a container without one, a stale cache.
my %probed = App::Ariza::Native.sqlcipher-source(:slug<linux-aarch64-musl>,
:host-slug<linux-aarch64-musl>, :env(%()), :run(fake-run(%())),
:search([$dir.add('nowhere').absolute, $lib.parent.absolute]));
is %probed<path>.absolute, $lib.resolve.absolute,
'and the standard library directories are probed in order after it';
throws-like {
App::Ariza::Native.sqlcipher-source(:slug<linux-x86_64-glibc>,
:host-slug<linux-x86_64-glibc>, :env(%()), :run(fake-run(%())),
:search([$dir.add('nowhere').absolute]))
}, Exception,
message => /'apt install libsqlcipher0' .* 'dnf install sqlcipher`' .* '--sqlcipher-archive'/,
'a machine without it is told what to install, not just refused — and'
~ ' the Fedora/RHEL remedy names the real package, `sqlcipher`, not the'
~ ' nonexistent `sqlcipher-libs`';
};
subtest 'a renamed EPEL soname is accepted, but the exact name always wins', {
plan 8;
my $dir = tmp-dir;
LEAVE { rm-rf($dir) }
# EPEL's `sqlcipher` package: no libsqlcipher.so.0 anywhere on the
# machine, but a libsqlcipher-<version>.so.0 ldconfig already knows
# about — the exact bug this module used to fail on every RHEL-family
# host over.
#
# :search sandboxes the standard-directory fallback the same way the
# subtest above does: !find-system-lib's :glob pass reads @dirs off
# the real filesystem regardless of the :run mock, and every call
# below exercises that pass. Without this, whatever the real machine
# actually has installed under /usr/local/lib -- xxt/'s own proof
# script builds a real SQLCipher there -- can out-rank these fixtures
# in the newest-wins comparison and silently answer for them instead.
my $renamed = fake-library($dir.add('usr/lib64/libsqlcipher-3.34.1.so.0'));
my $ldconfig = qq:to/OUT/;
\t1 libs found in cache `/etc/ld.so.cache'
\tlibsqlcipher-3.34.1.so.0 (libc6,x86-64) => {$renamed.absolute}
\tlibc.so.6 (libc6,x86-64) => /lib/x86_64-linux-gnu/libc.so.6
OUT
my %got = App::Ariza::Native.sqlcipher-source(:slug<linux-x86_64-glibc>,
:host-slug<linux-x86_64-glibc>, :env(%()),
:run(fake-run(%( 'ldconfig -p' => (0, $ldconfig, '') ))),
:search([$dir.add('nowhere').absolute]));
is %got<kind>, 'library',
'the renamed soname is taken in place of the canonical one';
is %got<path>.absolute, $renamed.resolve.absolute,
'the file ldconfig actually reports';
ok %got<origin>.contains('staged as libsqlcipher.so.0'),
'the manifest says which canonical name it will be staged under —'
~ ' stage-sqlcipher always copies to that name regardless of what'
~ ' the source file was called';
ok %got<origin>.contains('found as libsqlcipher-3.34.1.so.0'),
'and which file on the machine it actually came from';
# The exact canonical name wins outright when it is there too, even
# with a newer-looking versioned file sitting right beside it.
my $canonical = fake-library($dir.add('usr/lib64/libsqlcipher.so.0'));
my $both = qq:to/OUT/;
\t2 libs found in cache `/etc/ld.so.cache'
\tlibsqlcipher.so.0 (libc6,x86-64) => {$canonical.absolute}
\tlibsqlcipher-9.9.9.so.0 (libc6,x86-64) => {$renamed.absolute}
OUT
my %exact = App::Ariza::Native.sqlcipher-source(:slug<linux-x86_64-glibc>,
:host-slug<linux-x86_64-glibc>, :env(%()),
:run(fake-run(%( 'ldconfig -p' => (0, $both, '') ))),
:search([$dir.add('nowhere').absolute]));
is %exact<path>.absolute, $canonical.resolve.absolute,
'the exact canonical name is preferred over any versioned one';
nok %exact<origin>.contains('staged as'),
'and nothing is said about a rename, because there was not one';
# Two versioned candidates and no canonical name at all: the newest
# wins, by a numeric comparison rather than a string one — a plain
# sort would put -3.9. ahead of -3.34.
my $old = fake-library($dir.add('usr/lib64b/libsqlcipher-3.9.0.so.0'));
my $versioned = qq:to/OUT/;
\tlibsqlcipher-3.9.0.so.0 (libc6,x86-64) => {$old.absolute}
\tlibsqlcipher-3.34.1.so.0 (libc6,x86-64) => {$renamed.absolute}
OUT
my %newest = App::Ariza::Native.sqlcipher-source(:slug<linux-x86_64-glibc>,
:host-slug<linux-x86_64-glibc>, :env(%()),
:run(fake-run(%( 'ldconfig -p' => (0, $versioned, '') ))),
:search([$dir.add('nowhere').absolute]));
is %newest<path>.absolute, $renamed.resolve.absolute,
'and among several versioned candidates the newest wins, numerically';
# Naming is one thing; what actually lands in the bundle is another —
# stage-sqlcipher copies to %l<lib> regardless of what sourced it, so
# the EPEL file is staged under the canonical name it was found for.
my $bundle = ensure-dir($dir.add('bundle'));
App::Ariza::Native.stage-sqlcipher(:bundle-dir($bundle),
:slug<linux-x86_64-glibc>, :versions(versions()),
:host-slug<linux-x86_64-glibc>, :host-kernel<linux>, :env(%()),
:run(fake-run(%( 'ldconfig -p' => (0, $ldconfig, '') ))),
:search([$dir.add('nowhere').absolute]));
ok $bundle.add('native/sqlcipher/libsqlcipher.so.0').f,
'stage-sqlcipher writes the copy under the canonical name, not the'
~ ' EPEL one it was found as';
};
subtest 'Windows looks in MSYS2 and vcpkg, and names every remedy otherwise', {
plan 8;
my $dir = tmp-dir;
LEAVE { rm-rf($dir) }
my $lib = fake-library($dir.add('vcpkg/installed/x64-windows/bin/sqlcipher.dll'));
my %got = App::Ariza::Native.sqlcipher-source(:slug<windows-x86_64>,
:host-slug<windows-x86_64>,
:env(%( VCPKG_ROOT => $dir.add('vcpkg').absolute )));
is %got<path>.absolute, $lib.resolve.absolute,
'VCPKG_ROOT/installed/<triplet>/bin';
ok %got<origin>.contains('vcpkg'), 'recorded as a vcpkg tree';
# MSYS2's UCRT environment, which is the one ariza's own CI installs:
# a different prefix, and a different name for the same library.
my $ucrt = fake-library($dir.add('msys64/ucrt64/bin/libsqlcipher-0.dll'));
my %msys = App::Ariza::Native.sqlcipher-source(:slug<windows-x86_64>,
:host-slug<windows-x86_64>,
:env(%( MSYSTEM_PREFIX => $dir.add('msys64/ucrt64').absolute )));
is %msys<path>.absolute, $ucrt.resolve.absolute,
'MSYSTEM_PREFIX/bin, which is where a mingw-w64-ucrt package lands';
ok %msys<origin>.contains('msys2'), 'recorded as an MSYS2 environment';
# MSYSTEM_PREFIX comes first: a machine with both should build the
# UCRT library, because the MSVC one imports a runtime Windows does
# not ship. (The audit refuses to ship that one either way.)
my %both = App::Ariza::Native.sqlcipher-source(:slug<windows-x86_64>,
:host-slug<windows-x86_64>,
:env(%( VCPKG_ROOT => $dir.add('vcpkg').absolute,
MSYSTEM_PREFIX => $dir.add('msys64/ucrt64').absolute )));
is %both<path>.absolute, $ucrt.resolve.absolute,
'and with both installed, the UCRT build wins';
# :search sandboxes the probe, exactly as it does on Linux: no real
# machine's C:\msys64 or vcpkg tree can answer for a test.
is App::Ariza::Native.sqlcipher-source(:slug<windows-x86_64>,
:host-slug<windows-x86_64>, :env(%()),
:search([$dir.add('msys64/ucrt64/bin').absolute]))<path>.absolute,
$ucrt.resolve.absolute,
'and :search replaces the directories probed outright';
throws-like {
App::Ariza::Native.sqlcipher-source(:slug<windows-arm64>,
:host-slug<windows-arm64>, :env(%()))
}, Exception, message => /'SQLCIPHER_LIB_DIR' .* '--sqlcipher-archive'/,
'and with neither installed, every way of supplying one is named';
throws-like {
App::Ariza::Native.sqlcipher-source(:slug<windows-arm64>,
:host-slug<windows-arm64>, :env(%()))
}, Exception, message => /'pacman -S mingw-w64-ucrt-x86_64-sqlcipher'/,
'the prebuilt UCRT package first, since it is the one that does not'
~ ' drag in the Visual C++ redistributable';
};
subtest 'Windows accepts either package manager\'s name for the library', {
plan 8;
my $dir = tmp-dir;
LEAVE { rm-rf($dir) }
# MSYS2 ships `libsqlcipher-0.dll`; nothing there is called
# `sqlcipher.dll` at all.
my $bin = $dir.add('ucrt64/bin');
my $mingw = fake-library($bin.add('libsqlcipher-0.dll'));
my %got = App::Ariza::Native.sqlcipher-source(:slug<windows-x86_64>,
:host-slug<macos-arm64>,
:env(%( SQLCIPHER_LIB_DIR => $bin.absolute )));
is %got<path>.absolute, $mingw.resolve.absolute,
'a libsqlcipher*.dll answers where the canonical name is absent';
ok %got<origin>.contains('staged as sqlcipher.dll'),
'the manifest says which name it will be staged under';
ok %got<origin>.contains('found as libsqlcipher-0.dll'),
'and which file on the machine it actually came from';
# The vcpkg name wins outright when both are there, even though the
# other one sorts higher.
my $canonical = fake-library($bin.add('sqlcipher.dll'));
my %exact = App::Ariza::Native.sqlcipher-source(:slug<windows-x86_64>,
:host-slug<macos-arm64>,
:env(%( SQLCIPHER_LIB_DIR => $bin.absolute )));
is %exact<path>.absolute, $canonical.resolve.absolute,
'the canonical name is preferred over any variant beside it';
nok %exact<origin>.contains('staged as'),
'and nothing is said about a rename, because there was not one';
# Two variants and no canonical name: newest wins, numerically — a
# string sort would put -3.9. ahead of -3.34.
my $vdir = $dir.add('versioned');
fake-library($vdir.add('libsqlcipher-3.9.0.dll'));
my $newest = fake-library($vdir.add('libsqlcipher-3.34.1.dll'));
my %newest = App::Ariza::Native.sqlcipher-source(:slug<windows-x86_64>,
:host-slug<macos-arm64>,
:env(%( SQLCIPHER_LIB_DIR => $vdir.absolute )));
is %newest<path>.absolute, $newest.resolve.absolute,
'among several variants the newest wins, numerically';
# The Windows loader does not care about case and neither does this.
my $shouty = $dir.add('shouty');
my $upper = fake-library($shouty.add('LIBSQLCIPHER-0.DLL'));
is App::Ariza::Native.sqlcipher-source(:slug<windows-x86_64>,
:host-slug<macos-arm64>,
:env(%( SQLCIPHER_LIB_DIR => $shouty.absolute )))<path>.absolute,
$upper.resolve.absolute,
'whatever case the filesystem spelled it in';
throws-like {
App::Ariza::Native.sqlcipher-source(:slug<windows-x86_64>,
:host-slug<windows-x86_64>,
:env(%( SQLCIPHER_LIB_DIR => ensure-dir($dir.add('empty')).absolute )))
}, Exception, message => /'holds no sqlcipher.dll' .* 'libsqlcipher*.dll'/,
'and a directory with neither name says so, naming both';
};
subtest 'a mingw-named library is staged under the canonical one', {
plan 4;
my $dir = tmp-dir;
LEAVE { rm-rf($dir) }
# MSYS2's ucrt64/bin: the package's whole runtime closure in one
# directory, exactly as vcpkg's `installed/<triplet>/bin` is.
my $bin = $dir.add('ucrt64/bin');
fake-pe($bin.add('libsqlcipher-0.dll'),
<libcrypto-3-x64.dll libwinpthread-1.dll ucrtbase.dll KERNEL32.dll>);
fake-pe($bin.add('libcrypto-3-x64.dll'), ['ucrtbase.dll']);
fake-pe($bin.add('libwinpthread-1.dll'), ['KERNEL32.dll']);
my $bundle = ensure-dir($dir.add('bundle'));
my %staged;
notes-from({
%staged = App::Ariza::Native.stage-sqlcipher(:bundle-dir($bundle),
:slug<windows-x86_64>, :versions(versions()),
:host-slug<macos-arm64>, :host-kernel<darwin>,
:env(%( SQLCIPHER_LIB_DIR => $bin.absolute )));
});
my $dest = $bundle.add('native/sqlcipher');
ok $dest.add('sqlcipher.dll').f,
'the copy is written under the name the launcher and DBDish ask'
~ ' for, which LoadLibrary resolves by leaf name — the module\'s own'
~ ' internal name is no more consulted than DT_SONAME is by dlopen';
nok $dest.add('libsqlcipher-0.dll').e,
'and not under the one MSYS2 gave it';
is-deeply %staged<staged>.map(*.basename).sort.List,
<libcrypto-3-x64.dll libwinpthread-1.dll sqlcipher.dll>,
'the mingw runtime travels with it, ucrtbase.dll does not';
is App::Ariza::Native.audit(:bundle-dir($bundle), :slug<windows-x86_64>,
:extra(%staged<staged>))<checked>, 3,
'and the audit finds every one of them self-contained';
};
subtest 'the Visual C++ runtime is not Windows, and the audit knows it', {
plan 11;
ok pe-redist-dll('VCRUNTIME140.dll'), 'vcruntime is the redistributable';
ok pe-redist-dll('vcruntime140_1.dll'), 'in either of its halves';
ok pe-redist-dll('msvcp140.dll'), 'as is the C++ library';
ok pe-redist-dll('concrt140.dll'), 'the concurrency runtime';
ok pe-redist-dll('vcomp140.dll'), 'and OpenMP';
nok pe-redist-dll('ucrtbase.dll'),
'while the UCRT is part of Windows 10 and later, which is exactly'
~ ' why a UCRT-built library is the fix rather than the problem';
nok pe-redist-dll('libcrypto-3-x64.dll'),
'and a library that is nobody\'s runtime is judged the ordinary way';
# Still on the skiplist: nothing copies it in, because it is
# Microsoft's to redistribute and not ariza's.
ok pe-system-dll('VCRUNTIME140.dll'),
'the family stays on PE-SYSTEM-DLLS, so no pass copies one in';
my @found = pe-strays('vcruntime140.dll => not found', :inside(['/b']));
is +@found, 1, 'an import of it that the bundle does not answer for is a finding';
ok @found.head.contains('Redistributable')
&& @found.head.contains('mingw-w64-ucrt-x86_64'),
'and the finding carries the clean-machine consequence and the fix,'
~ ' because the reader has just watched the bundle work on a machine'
~ ' that has the redistributable installed';
is-deeply pe-strays("vcruntime140.dll => C:\\b\\native\\sqlcipher\\vcruntime140.dll",
:inside(['C:/b'])).List, (),
'one the bundle does carry is no finding at all — the check is'
~ ' "not in the bundle", not "never imported"';
};
subtest 'an MSVC-built library fails the audit rather than the user\'s machine', {
plan 4;
my $dir = tmp-dir;
LEAVE { rm-rf($dir) }
# vcpkg's own layout, with what an MSVC build actually imports.
my $bin = $dir.add('vcpkg/installed/x64-windows/bin');
fake-pe($bin.add('sqlcipher.dll'),
<libcrypto-3-x64.dll VCRUNTIME140.dll KERNEL32.dll>);
fake-pe($bin.add('libcrypto-3-x64.dll'), ['KERNEL32.dll']);
my $bundle = ensure-dir($dir.add('bundle'));
my %staged;
notes-from({
%staged = App::Ariza::Native.stage-sqlcipher(:bundle-dir($bundle),
:slug<windows-x86_64>, :versions(versions()),
:host-slug<macos-arm64>, :host-kernel<darwin>,
:env(%( SQLCIPHER_LIB_DIR => $bin.absolute )));
});
nok $bundle.add('native/sqlcipher/VCRUNTIME140.dll').e,
'staging leaves the redistributable alone, as it always has';
throws-like {
App::Ariza::Native.audit(:bundle-dir($bundle), :slug<windows-x86_64>,
:extra(%staged<staged>))
}, Exception, message => /'VCRUNTIME140.dll' .* 'Redistributable'/,
'and the audit then refuses the bundle, naming the DLL';
throws-like {
App::Ariza::Native.audit(:bundle-dir($bundle), :slug<windows-x86_64>,
:extra(%staged<staged>))
}, Exception, message => /'mingw-w64-ucrt-x86_64'/,
'and the UCRT-built library that makes the question go away';
# Carrying it satisfies the audit: the check is containment, not
# abstinence. Nobody has to like it for it to be true.
fake-pe($bundle.add('native/sqlcipher/VCRUNTIME140.dll'), ['KERNEL32.dll']);
lives-ok {
App::Ariza::Native.audit(:bundle-dir($bundle), :slug<windows-x86_64>,
:extra(%staged<staged>))
}, 'a bundle that does carry it passes';
};
subtest 'a system library is only offered for the machine it is on', {
plan 3;
my $dir = tmp-dir;
LEAVE { rm-rf($dir) }
# A macOS keg is not a Linux library, and an x86_64 one is not an
# arm64 one: a cross-build that took either would produce an artefact
# that fails at dlopen on every machine it was named for.
throws-like {
App::Ariza::Native.sqlcipher-source(:slug<linux-x86_64-glibc>,
:host-slug<macos-arm64>, :env(%()))
}, Exception,
message => /'cannot be taken from this machine' .* 'macos-arm64'/,
'building for another platform refuses the package-manager path';
throws-like {
App::Ariza::Native.sqlcipher-source(:slug<macos-x86_64>,
:host-slug<macos-arm64>, :env(%()))
}, Exception, message => /'--sqlcipher-archive'/,
'naming the way to supply one instead';
# An unnameable host is not this machine either.
throws-like {
App::Ariza::Native.sqlcipher-source(:slug<macos-arm64>,
:host-slug(Str), :env(%()))
}, Exception, message => /'cannot name'/,
'and a machine ariza cannot name says that, rather than guessing';
};
subtest 'the version is read out of the staged library, and the pin is advisory', {
plan 10;
my $dir = tmp-dir;
LEAVE { rm-rf($dir) }
my $lib = fake-library($dir.add('src/libsqlcipher.so.0'), :version<4.17.0>);
my $bundle = ensure-dir($dir.add('bundle'));
my %staged;
my $said = notes-from({
%staged = App::Ariza::Native.stage-sqlcipher(:bundle-dir($bundle),
:slug<linux-x86_64-glibc>, :versions(versions()),
:host-slug<linux-x86_64-glibc>,
:env(%( SQLCIPHER_LIB_DIR => $lib.parent.absolute )));
});
is %staged<version>, '4.17.0',
'what the library says about itself, not what the pin hoped for';
is %staged<pinned>, versions().sqlcipher, 'the pin travels alongside it';
ok $said.contains("sqlcipher 4.17.0 staged, pin says {versions().sqlcipher}"),
'a mismatch is announced';
ok $bundle.add('native/sqlcipher/libsqlcipher.so.0').f,
'and the build carries on and stages it anyway — the machine\'s'
~ ' package manager decides, not versions.toml';
ok $bundle.add('native/sqlcipher/libsqlcipher.so').e,
'with the second name the loader may ask for';
is %staged<sha256>, sha256-file($lib),
'the digest is of the library as it came off the machine';
is %staged<rel>, 'native/sqlcipher/libsqlcipher.so.0',
'and the manifest gets the bundle-relative path';
# The matching case has nothing to say.
my $quiet = notes-from({
App::Ariza::Native.stage-sqlcipher(:bundle-dir($dir.add('bundle2')),
:slug<linux-x86_64-glibc>, :versions(versions()),
:host-slug<linux-x86_64-glibc>,
:env(%( SQLCIPHER_LIB_DIR =>
fake-library($dir.add('src2/libsqlcipher.so.0'),
:version(versions().sqlcipher)).parent.absolute )));
});
is $quiet.trim, '', 'a library that matches the pin says nothing at all';
# A library whose bytes name no version is staged too — but nobody is
# left thinking ariza knows what went in.
my %mute;
my $mumbled = notes-from({
my $anon = $dir.add('src3/libsqlcipher.so.0');
ensure-dir($anon.parent);
$anon.spurt("no version constant here at all\x[00]");
%mute = App::Ariza::Native.stage-sqlcipher(:bundle-dir($dir.add('bundle3')),
:slug<linux-x86_64-glibc>, :versions(versions()),
:host-slug<linux-x86_64-glibc>,
:env(%( SQLCIPHER_LIB_DIR => $anon.parent.absolute )));
});
is %mute<version>, Str, 'an unreadable version is not filled in from the pin';
ok $mumbled.contains('does not say what version it is'),
'and the build says so rather than shipping a number it invented';
};
subtest 'a library whose bytes do not name a version is not guessed at', {
plan 4;
my $dir = tmp-dir;
LEAVE { rm-rf($dir) }
is sqlcipher-version-of(fake-library($dir.add('one/libsqlcipher.so.0'))),
'4.14.0', 'one candidate is the answer';
$dir.add('two.so').spurt("payload\x[00]with no version at all\x[00]");
is sqlcipher-version-of($dir.add('two.so')), Str,
'none is "I do not know" rather than a guess';
# Two plausible versions is exactly when a guess would be wrong.
$dir.add('three.so').spurt("\x[00]4.14.0\x[00]padding\x[00]9.9.9\x[00]");
is sqlcipher-version-of($dir.add('three.so')), Str,
'and so is more than one';
is sqlcipher-version-of($dir.add('absent.so')), Str,
'a file that is not there is not an exception either — the'
~ ' caller has a pin to fall back on';
};
subtest 'the audit counts, skips, and passes a clean tree', {
plan 4;
my $dir = tmp-dir;
LEAVE { rm-rf($dir) }
my $lib = ensure-dir($dir.add('native/Notcurses-Native/tag/lib'));
$lib.add('libnotcurses.dylib').spurt('binary');
$lib.add('libnotcurses.dylib.srchash').spurt('deadbeef');
my %clean = App::Ariza::Native.audit(:bundle-dir($dir), :slug<macos-arm64>,
:inspect(-> $f {
$f.basename.ends-with('.dylib') ?? CLEAN-MACHO !! Str
}));
is %clean<checked>, 1, 'the Mach-O is checked';
is %clean<skipped>, 1, 'the text file beside it is skipped, not failed';
is-deeply %clean<findings>.List, (), 'and nothing is wrong with it';
is App::Ariza::Native.audit(:bundle-dir($dir.add('nothing-here')),
:slug<macos-arm64>, :inspect(-> $ { Str }))<checked>, 0,
'a bundle with no native payload audits vacuously rather than dying';
};
subtest 'the audit fails loudly, naming file and dependency', {
plan 5;
my $dir = tmp-dir;
LEAVE { rm-rf($dir) }
my $lib = ensure-dir($dir.add('native/sqlcipher'));
$lib.add('libsqlcipher.so.0').spurt('binary');
throws-like {
App::Ariza::Native.audit(:bundle-dir($dir), :slug<macos-arm64>,
:inspect(-> $ { DIRTY-MACHO }))
}, Exception, message => /'load' .* 'libraries from outside it'/,
'a stray dependency fails the build';
throws-like {
App::Ariza::Native.audit(:bundle-dir($dir), :slug<macos-arm64>,
:inspect(-> $ { DIRTY-MACHO }))
}, Exception, message => /'libsqlcipher.so.0'/, 'the offending file is named';
throws-like {
App::Ariza::Native.audit(:bundle-dir($dir), :slug<macos-arm64>,
:inspect(-> $ { DIRTY-MACHO }))
}, Exception, message => /'openssl@3'/, 'and the offending dependency';
# The Linux and Windows verdicts, from a Mac, because the tool call
# is a seam and the verdict functions are pure.
throws-like {
App::Ariza::Native.audit(:bundle-dir($dir), :slug<linux-x86_64-glibc>,
:inspect(-> $ { DIRTY-ELF }))
}, Exception, message => /'/home/build/lib'/, 'the ELF verdict is reachable too';
lives-ok {
App::Ariza::Native.audit(:bundle-dir($dir), :slug<linux-x86_64-glibc>,
:inspect(-> $ { CLEAN-ELF }))
}, 'and a clean ELF passes';
};
subtest 'the ELF skiplist keeps the loader out and OpenSSL in', {
plan 8;
ok elf-system-lib('libc.so.6'), 'the C library stays dynamic';
ok elf-system-lib('libc.musl-x86_64.so.1'),
'under musl\'s name for it as well';
ok elf-system-lib('ld-musl-aarch64.so.1'), 'as does musl\'s loader';
ok elf-system-lib('libstdc++.so.6'),
'and the compiler runtime, whose glob has a + in it';
ok elf-system-lib('/lib64/ld-linux-x86-64.so.2'),
'a path is judged by its leaf name';
# The one deliberate difference from bundle-elf.sh: SQLCipher without
# its own OpenSSL is SQLCipher that opens the user's database with
# whatever version their machine happens to have, or not at all.
nok elf-system-lib('libcrypto.so.3'), 'libcrypto is bundled, not borrowed';
nok elf-system-lib('libssl.so.3'), 'and so is libssl';
nok elf-system-lib('libz.so.1'), 'and anything else it drags in';
};
subtest 'ldd output is read as soname => path', {
plan 5;
my $out = qq:to/OUT/;
\tlinux-vdso.so.1 (0x00007ffd51dfe000)
\tlibcrypto.so.3 => /lib64/libcrypto.so.3 (0x00007f2c0a000000)
\tlibmissing.so.1 => not found
\t/lib64/ld-linux-x86-64.so.2 (0x00007f2c0b000000)
OUT
my @deps = ldd-deps($out);
is +@deps, 2, 'only the "name => path" lines carry a pair to act on';
is @deps[0].key, 'libcrypto.so.3', 'the soname the loader asked for';
is @deps[0].value, '/lib64/libcrypto.so.3',
'and where it found it, with the load address stripped';
is @deps[1].value, Str,
'"not found" is the undefined Str, not a path to copy';
is-deeply ldd-deps("\tstatically linked\n").List, (),
'a file with nothing to resolve resolves nothing';
};
subtest 'the ELF audit asks the loader too, when it is standing on one', {
plan 9;
my $static = q:to/OUT/.trim;
0x0000000000000001 (NEEDED) Shared library: [libcrypto.so.3]
0x0000000000000001 (NEEDED) Shared library: [libc.so.6]
0x000000000000001d (RUNPATH) Library runpath: [$ORIGIN]
OUT
sub report(Str $rpath, Str $ldd --> Str) {
($static, ELF-RPATH-SECTION, $rpath, ELF-LDD-SECTION, $ldd).join("\n")
}
my $inside = "\tlibcrypto.so.3 => /b/native/sqlcipher/libcrypto.so.3 (0x1)\n"
~ "\tlibc.so.6 => /lib64/libc.so.6 (0x2)";
is-deeply elf-strays(report('$ORIGIN', $inside), :inside(['/b'])).List, (),
'a bundled dependency beside its library, and libc from the system,'
~ ' is exactly right';
# The hole this whole pass exists to close: `NEEDED libcrypto.so.3`
# with no slash in it is perfectly legal ELF, passes every static
# check, and loads the build machine's OpenSSL.
my @outside = elf-strays(
report('$ORIGIN', "\tlibcrypto.so.3 => /lib64/libcrypto.so.3 (0x1)"),
:inside(['/b']));
is-deeply @outside.List, ('libcrypto.so.3 => /lib64/libcrypto.so.3',),
'while the same library resolving to /lib64 is a finding, though'
~ ' nothing in the file itself says so';
ok elf-strays(report('$ORIGIN', "\tlibcrypto.so.3 => not found"),
:inside(['/b'])).first(/'not found'/),
'and a dependency the user\'s loader cannot find at all is one too';
ok elf-strays(report('/opt/build/lib', $inside), :inside(['/b']))
.first('/opt/build/lib'),
'an rpath pointing at the build machine is reported once, in the'
~ ' same words the static check would have used';
ok elf-strays(report('', $inside), :inside(['/b'])).first(/'no rpath'/),
'and an empty one, on a file with a non-system NEEDED, means'
~ ' nothing tells the loader to look beside it';
is-deeply elf-strays(
($static.lines[1], ELF-RPATH-SECTION, '', ELF-LDD-SECTION, '').join("\n")
).List, (),
'a file needing only libc is allowed to have no rpath at all —'
~ ' there is nothing beside it to find';
# macOS cross-inspecting an ELF: patchelf and ldd answer for the
# machine they run on, so from a Mac there are no such sections.
is-deeply elf-strays($static).List, (),
'and a report with neither section is the static half, which is'
~ ' all a Mac can honestly say about an ELF';
# The Windows failure this normalisation fixes: `ldd`-style output
# resolves to a native-separator path (`C:\...\native\sqlcipher\...`)
# while `:@inside` is forward-slashed, and a byte-for-byte prefix
# check calls a bundled dependency a stray.
my $win-inside = "\tlibcrypto.so.3 => C:\\b\\native\\sqlcipher\\libcrypto.so.3 (0x1)\n"
~ "\tlibc.so.6 => /lib64/libc.so.6 (0x2)";
is-deeply elf-strays(report('$ORIGIN', $win-inside), :inside(['C:/b'])).List, (),
'a dependency resolved with native (backslash) separators, against'
~ ' a forward-slashed :@inside prefix, still counts as contained';
# The same normalisation must not blur a genuine escape: a backslash
# path pointing well outside the bundle is still a finding.
my @win-outside = elf-strays(
report('$ORIGIN', "\tlibcrypto.so.3 => C:\\Windows\\System32\\libcrypto.so.3 (0x1)"),
:inside(['C:/b']));
is-deeply @win-outside.List,
('libcrypto.so.3 => C:\\Windows\\System32\\libcrypto.so.3',),
'and a backslash-separated dependency resolving outside :@inside'
~ ' is still reported, separators aside';
};
subtest 'formats are read from the file, not from `file`', {
plan 6;
my $dir = tmp-dir;
LEAVE { rm-rf($dir) }
is binary-format(fake-elf($dir.add('libsqlcipher.so.0'))), 'ELF',
'ELF by its magic number';
$dir.add('libfoo.dylib').spurt(buf8.new(0xCF, 0xFA, 0xED, 0xFE, 1, 2));
is binary-format($dir.add('libfoo.dylib')), 'Mach-O', 'Mach-O by its';
$dir.add('foo.dll').spurt('MZ' ~ "\0" x 8);
is binary-format($dir.add('foo.dll')), 'PE', 'and PE by "MZ"';
# The reason this is four bytes rather than a call to `file`:
# manylinux images do not ship one, and a format probe that answers
# "not a binary" when its helper is missing turns the audit into a
# loop that checks nothing and passes.
$dir.add('libnotcurses.so.3.srchash').spurt('deadbeef');
is binary-format($dir.add('libnotcurses.so.3.srchash')), Str,
'a sidecar beside a library is not a binary';
$dir.add('tiny').spurt('ab');
is binary-format($dir.add('tiny')), Str, 'nor is a file too short to say';
is binary-format($dir.add('absent')), Str, 'nor is one that is not there';
};
subtest 'Linux staging copies its dependencies in and points at them', {
plan 10;
my $dir = tmp-dir;
LEAVE { rm-rf($dir) }
# A machine's libraries: the distro libsqlcipher, the OpenSSL it
# names, and the zlib OpenSSL names in turn.
my $sys = $dir.add('usr/lib64');
my $src = fake-elf($sys.add('libsqlcipher.so.0'), :version(versions().sqlcipher));
fake-elf($sys.add('libcrypto.so.3'));
fake-elf($sys.add('libz.so.1'));
my %ldd =
"ldd libsqlcipher.so.0" =>
"\tlibcrypto.so.3 => {$sys.add('libcrypto.so.3').absolute} (0x1)\n"
~ "\tlibc.so.6 => /lib64/libc.so.6 (0x2)\n",
"ldd libcrypto.so.3" =>
"\tlibz.so.1 => {$sys.add('libz.so.1').absolute} (0x3)\n"
~ "\tlibpthread.so.0 => /lib64/libpthread.so.0 (0x4)\n",
;
my @calls;
my $bundle = ensure-dir($dir.add('bundle'));
my %staged;
my $said = notes-from({
%staged = App::Ariza::Native.stage-sqlcipher(:bundle-dir($bundle),
:slug<linux-x86_64-glibc>, :versions(versions()),
:host-slug<linux-x86_64-glibc>, :host-kernel<linux>,
:env(%( SQLCIPHER_LIB_DIR => $sys.absolute )),
:run(elf-run(%ldd, :@calls)));
});
my $dest = $bundle.add('native/sqlcipher');
ok $dest.add('libcrypto.so.3').f,
'the OpenSSL the library names is copied in beside it — a bare'
~ ' NEEDED would have resolved from the user\'s machine';
ok $dest.add('libz.so.1').f,
'and the walk recurses, so its own dependencies come too';
nok $dest.add('libc.so.6').e, 'while libc stays dynamic';
nok $dest.add('libpthread.so.0').e, 'as does the rest of the skiplist';
my @rpath = @calls.grep(*.starts-with('patchelf --set-rpath $ORIGIN'));
is +@rpath, 3,
'every staged ELF is told to look beside itself — the library and'
~ ' both copies';
ok @rpath.first(*.ends-with($dest.add('libz.so.1').absolute)),
'including the one that arrived through the recursion';
is-deeply %staged<staged>.map(*.basename).sort.List,
<libcrypto.so.3 libsqlcipher.so.0 libz.so.1>,
'and the manifest hears about all three';
is %staged<version>, versions().sqlcipher,
'patching does not disturb the version constant in the bytes';
is $said.trim, '', 'a clean run of the pass says nothing';
# `ldd` is the loader: if it cannot find something, no amount of
# copying will, and shipping the bundle anyway is the bug.
throws-like {
App::Ariza::Native.stage-sqlcipher(:bundle-dir($dir.add('bundle2')),
:slug<linux-x86_64-glibc>, :versions(versions()),
:host-slug<linux-x86_64-glibc>, :host-kernel<linux>,
:env(%( SQLCIPHER_LIB_DIR => $sys.absolute )),
:run(elf-run(%( "ldd libsqlcipher.so.0" =>
"\tlibcrypto.so.3 => not found\n" ))));
}, Exception, message => /'needs libcrypto.so.3' .* 'cannot find'/,
'a dependency the loader cannot resolve fails the build, naming it';
};
subtest 'the Linux pass refuses to pretend, off Linux and without patchelf', {
plan 6;
my $dir = tmp-dir;
LEAVE { rm-rf($dir) }
my $sys = $dir.add('usr/lib64');
fake-elf($sys.add('libsqlcipher.so.0'));
# A Mac has none of the Linux libraries to resolve against, and
# neither ldd nor patchelf to ask. Silence here would produce exactly
# the bundle this pass exists to prevent.
my @calls;
my $said = notes-from({
App::Ariza::Native.stage-sqlcipher(:bundle-dir($dir.add('cross')),
:slug<linux-x86_64-glibc>, :versions(versions()),
:host-slug<linux-x86_64-glibc>, :host-kernel<darwin>,
:env(%( SQLCIPHER_LIB_DIR => $sys.absolute )),
:run(elf-run(%(), :@calls)));
});
ok $said.contains('staged on a darwin host'),
'staging an ELF anywhere else says so, loudly';
ok $said.contains('--sqlcipher-archive'),
'and names the way to do it properly';
is +@calls, 0, 'and asks that machine no questions it cannot answer';
ok $dir.add('cross/native/sqlcipher/libsqlcipher.so.0').f,
'the library is still staged — the warning is not a refusal';
throws-like {
App::Ariza::Native.stage-sqlcipher(:bundle-dir($dir.add('nopatchelf')),
:slug<linux-x86_64-glibc>, :versions(versions()),
:host-slug<linux-x86_64-glibc>, :host-kernel<linux>,
:env(%( SQLCIPHER_LIB_DIR => $sys.absolute )),
:run(elf-run(%(), :patchelf(False))));
}, Exception, message => /'needs patchelf' .* 'apk add patchelf'/,
'and a Linux machine without patchelf is told what to install,'
~ ' before the build gets far enough to ship something broken';
# The fake libraries every other test in this file stages are text
# files. There is nothing in one to walk, and the audit skips it for
# the same reason, so the pass must not go looking for tools.
my @quiet;
App::Ariza::Native.stage-sqlcipher(:bundle-dir($dir.add('plain')),
:slug<linux-x86_64-glibc>, :versions(versions()),
:host-slug<linux-x86_64-glibc>, :host-kernel<linux>,
:env(%( SQLCIPHER_LIB_DIR =>
fake-library($dir.add('plain-src/libsqlcipher.so.0'),
:version(versions().sqlcipher)).parent.absolute )),
:run(elf-run(%(), :calls(@quiet), :patchelf(False))));
is +@quiet, 0, 'a staged file that is not an ELF has no dependencies to walk';
};
subtest 'the Linux audit, on Linux, asks patchelf and a clean-env ldd', {
plan 6;
my $dir = tmp-dir;
LEAVE { rm-rf($dir) }
my $dest = ensure-dir($dir.add('native/sqlcipher'));
fake-elf($dest.add('libsqlcipher.so.0'));
fake-elf($dest.add('libcrypto.so.3'));
$dest.add('libsqlcipher.so.0.srchash').spurt('deadbeef');
my $needed = " 0x0000000000000001 (NEEDED) Shared library: [libcrypto.so.3]\n"
~ " 0x000000000000001d (RUNPATH) Library runpath: [\$ORIGIN]";
my %answers =
"readelf libsqlcipher.so.0" => $needed,
"readelf libcrypto.so.3" => '',
"ldd libsqlcipher.so.0" =>
"\tlibcrypto.so.3 => {$dest.add('libcrypto.so.3').absolute} (0x1)\n"
~ "\tlibc.so.6 => /lib64/libc.so.6 (0x2)\n",
;
my @calls;
my %ok = App::Ariza::Native.audit(:bundle-dir($dir), :slug<linux-x86_64-glibc>,
:host-kernel<linux>, :run(elf-run(%answers, :@calls)));
is %ok<checked>, 2, 'both ELFs are checked';
is %ok<skipped>, 1, 'and the sidecar beside them is skipped';
ok @calls.first(*.starts-with('patchelf --print-rpath')),
'the rpath is read back from the file, not assumed';
ok @calls.first(*.starts-with('ldd ')),
'and the loader is asked where each dependency actually lands';
# The same tree, with the OpenSSL resolving off the machine: nothing
# in the file changed, and this is the only check that can tell.
%answers{"ldd libsqlcipher.so.0"} =
"\tlibcrypto.so.3 => /lib64/libcrypto.so.3 (0x1)\n";
throws-like {
App::Ariza::Native.audit(:bundle-dir($dir), :slug<linux-x86_64-glibc>,
:host-kernel<linux>, :run(elf-run(%answers)))
}, Exception, message => /'libcrypto.so.3 => /lib64/libcrypto.so.3'/,
'a dependency resolving outside the bundle fails the audit, with'
~ ' the path it resolved to';
# From a Mac the same tree is inspectable but not runnable, so the
# audit does the static half and does not invent the rest.
my @mac;
lives-ok {
App::Ariza::Native.audit(:bundle-dir($dir), :slug<linux-x86_64-glibc>,
:host-kernel<darwin>, :run(elf-run(%answers, :calls(@mac))))
}, 'and cross-inspecting from a Mac runs neither tool';
};
subtest 'a PE\'s imports are read out of its bytes, on any host', {
plan 10;
my $dir = tmp-dir;
LEAVE { rm-rf($dir) }
is-deeply pe-imports(fake-pe($dir.add('sqlcipher.dll'),
<libcrypto-3-x64.dll KERNEL32.dll libz.dll>)).List,
<libcrypto-3-x64.dll KERNEL32.dll libz.dll>,
'every name in the import table, in the order it lists them —'
~ ' through the section table, since the descriptors point at RVAs';
# PE32 and PE32+ differ only in where the data directories start, and
# a 32-bit build of anything is a PE32.
is-deeply pe-imports(fake-pe($dir.add('thirtytwo.dll'),
<libcrypto-3.dll KERNEL32.dll>, :pe32)).List,
<libcrypto-3.dll KERNEL32.dll>, 'PE32 as well as PE32+';
is-deeply pe-imports(fake-pe($dir.add('twice.dll'),
<libz.dll libz.dll>)).List, ('libz.dll',),
'a DLL named by two descriptors is one dependency';
is-deeply pe-imports(fake-pe($dir.add('nothing.dll'), ())).List, (),
'a PE that imports nothing has nothing to report';
is-deeply pe-imports(fake-pe($dir.add('nodirs.dll'), ['libz.dll'],
:ndirs(1))).List, (),
'nor has one with no import entry in its data directories at all';
# Every malformed shape names the file, because the caller is either
# staging its dependencies or auditing them and a quiet "no imports"
# would pass both.
$dir.add('text.dll').spurt('this is not a PE file');
throws-like { pe-imports($dir.add('text.dll')) }, Exception,
message => /'text.dll' .* 'does not begin with "MZ"'/,
'a file that is not a PE is named, and so is what is wrong with it';
$dir.add('stub.dll').spurt('MZ' ~ "\0" x 200);
throws-like { pe-imports($dir.add('stub.dll')) }, Exception,
message => /'stub.dll' .* 'PE\\0\\0'/,
'as is a DOS stub with no PE header behind it';
my $whole = pe-bytes(['libz.dll']);
$dir.add('cut.dll').spurt($whole.subbuf(0, $whole.elems - 30));
throws-like { pe-imports($dir.add('cut.dll')) }, Exception,
message => /'cut.dll' .* 'import descriptor'/,
'and a truncated import table, rather than being read as "no more'
~ ' imports"';
throws-like {
pe-imports(fake-pe($dir.add('alien.dll'), ['libz.dll'], :magic(0x0107)))
}, Exception, message => /'alien.dll' .* '0x0107' .* 'PE32+'/,
'an optional header that is neither PE32 nor PE32+ says which it'
~ ' found and which two it wanted';
throws-like { pe-imports($dir.add('absent.dll')) }, Exception,
message => /'absent.dll' .* 'not a file'/,
'and a file that is not there is an error, not an empty answer —'
~ ' unlike the version probe, nobody has a pin to fall back on';
};
subtest 'the PE skiplist keeps Windows out and OpenSSL in', {
plan 9;
ok pe-system-dll('KERNEL32.dll'), 'Windows\' own DLLs stay dynamic';
ok pe-system-dll('kernel32.dll'),
'matched the way the loader matches them, without regard to case';
ok pe-system-dll('api-ms-win-crt-runtime-l1-1-0.dll'),
'and the API sets, which are not files on disk at all';
ok pe-system-dll('VCRUNTIME140_1.dll'),
'as does the Visual C++ redistributable, which is a prerequisite';
ok pe-system-dll('MSVCP140.dll'), 'both halves of it';
ok pe-system-dll('gdi32.dll'),
'and the wider Win32 surface the notcurses pack actually imports';
# The same deliberate difference the ELF skiplist has: SQLCipher
# without its own OpenSSL is SQLCipher that opens the user's database
# with whatever their machine has, or not at all.
nok pe-system-dll('libcrypto-3-x64.dll'),
'OpenSSL is bundled, not borrowed';
nok pe-system-dll('zlib1.dll'), 'and anything else a DLL drags in';
nok pe-system-dll('libwinpthread-1.dll'),
'including the runtime a MinGW-built payload carries with it';
};
subtest 'pe-strays judges a resolved report, as the ELF audit does', {
plan 8;
my $inside = "libcrypto-3-x64.dll => /b/native/sqlcipher/libcrypto-3-x64.dll";
is-deeply pe-strays($inside, :inside(['/b'])).List, (),
'a dependency present in the bundle closure is exactly right';
is-deeply pe-strays("libcrypto-3-x64.dll => C:\\Windows\\System32\\libcrypto-3-x64.dll",
:inside(['C:/b'])).List,
('libcrypto-3-x64.dll => C:\\Windows\\System32\\libcrypto-3-x64.dll',),
'and one resolved outside it is a finding, with the path it went to';
is-deeply pe-strays("libcrypto-3-x64.dll => not found", :inside(['/b'])).List,
('libcrypto-3-x64.dll => not found',),
'a dependency absent from the staged adjacency closure is a finding too';
# The Windows spelling of the same containment question the ELF audit
# asks: a native-separator path against a forward-slashed prefix.
is-deeply pe-strays("libcrypto-3-x64.dll => C:\\b\\native\\sqlcipher\\libcrypto-3-x64.dll",
:inside(['C:/b'])).List, (),
'separators are normalised on both sides before the prefix check,'
~ ' so a backslash path inside a forward-slashed bundle is contained';
is-deeply pe-strays("KERNEL32.dll => not found", :inside(['/b'])).List, (),
'a system DLL is not judged at all, wherever it did or did not resolve';
is-deeply pe-strays('empty file').List, ('empty file',),
'a line that is not a dependency is a finding in its own words';
is-deeply pe-strays("libz.dll => /elsewhere/libz.dll").List, (),
'with no :@inside there is nothing to judge containment against';
is-deeply pe-strays("libz.dll => not found").List,
('libz.dll => not found',),
'and "not found" is still a finding, which needs no bundle to know';
};
subtest 'Windows staging copies in every DLL the library imports', {
plan 9;
my $dir = tmp-dir;
LEAVE { rm-rf($dir) }
# A vcpkg `installed/<triplet>/bin`: the port's whole runtime closure
# in one directory, which is the contract this pass relies on.
my $bin = $dir.add('vcpkg/installed/x64-windows/bin');
fake-pe($bin.add('sqlcipher.dll'),
<LIBCRYPTO-3-X64.DLL KERNEL32.dll api-ms-win-crt-heap-l1-1-0.dll>);
fake-pe($bin.add('libcrypto-3-x64.dll'), <zlib1.dll ADVAPI32.dll>);
fake-pe($bin.add('zlib1.dll'), ['KERNEL32.dll']);
fake-pe($bin.add('unrelated.dll'), ['KERNEL32.dll']);
my $bundle = ensure-dir($dir.add('bundle'));
my %staged;
# A hand-packed PE carries no version constant, so the advisory-pin
# warning fires; captured rather than left in the transcript.
notes-from({
%staged = App::Ariza::Native.stage-sqlcipher(:bundle-dir($bundle),
:slug<windows-x86_64>, :versions(versions()),
# Deliberately a Mac: reading imports needs no Windows, so the
# Windows pass is the one that cross-builds completely.
:host-slug<macos-arm64>, :host-kernel<darwin>,
:env(%( SQLCIPHER_LIB_DIR => $bin.absolute )));
});
my $dest = $bundle.add('native/sqlcipher');
ok $dest.add('LIBCRYPTO-3-X64.DLL').f,
'the OpenSSL the DLL imports is copied in beside it, found in the'
~ ' source directory whatever case the import table spelled it in';
ok $dest.add('zlib1.dll').f,
'and the walk recurses, so its own imports come too';
nok $dest.add('KERNEL32.dll').e, 'while Windows\' own DLLs stay dynamic';
nok $dest.add('api-ms-win-crt-heap-l1-1-0.dll').e,
'as do the API sets, which are not files anywhere';
nok $dest.add('unrelated.dll').e,
'and a DLL nothing imports is left in the vcpkg tree it came from';
is-deeply %staged<staged>.map(*.basename.lc).sort.List,
<libcrypto-3-x64.dll sqlcipher.dll zlib1.dll>,
'the manifest hears about the library and both copies';
# Everything staged has to survive the audit that follows it.
is App::Ariza::Native.audit(:bundle-dir($bundle), :slug<windows-x86_64>,
:extra(%staged<staged>))<checked>, 3,
'and the audit finds every one of them self-contained';
# There is nothing honest to do with an import the source directory
# does not hold: it is not on the machine to copy.
fake-pe($dir.add('sparse/sqlcipher.dll'), ['libcrypto-3-x64.dll']);
throws-like {
App::Ariza::Native.stage-sqlcipher(:bundle-dir($dir.add('bundle2')),
:slug<windows-x86_64>, :versions(versions()),
:host-slug<macos-arm64>, :host-kernel<darwin>,
:env(%( SQLCIPHER_LIB_DIR => $dir.add('sparse').absolute )));
}, Exception,
message => /'imports libcrypto-3-x64.dll' .* 'sparse' .* 'PE-SYSTEM-DLLS'/,
'a missing import names itself, the directory searched, and the'
~ ' one other thing it could have been';
# The fake libraries the rest of this file stages are text files.
# There is nothing in one to walk, and the audit skips it for the
# same reason.
lives-ok {
App::Ariza::Native.stage-sqlcipher(:bundle-dir($dir.add('plain')),
:slug<windows-x86_64>, :versions(versions()),
:host-slug<macos-arm64>, :host-kernel<darwin>,
:env(%( SQLCIPHER_LIB_DIR =>
fake-library($dir.add('plain-src/sqlcipher.dll')).parent.absolute )));
}, 'a staged file that is not a PE has no imports to walk';
};
subtest 'the Windows audit resolves imports, and is no longer a presence check', {
plan 7;
my $dir = tmp-dir;
LEAVE { rm-rf($dir) }
my $sql = ensure-dir($dir.add('native/sqlcipher'));
fake-pe($sql.add('sqlcipher.dll'), <libcrypto-3-x64.dll KERNEL32.dll>);
fake-pe($sql.add('libcrypto-3-x64.dll'), ['KERNEL32.dll']);
$sql.add('notes.txt').spurt('not a dll');
my %r = App::Ariza::Native.audit(:bundle-dir($dir), :slug<windows-x86_64>);
is %r<checked>, 2, 'every PE in the bundle is checked';
is %r<skipped>, 1, 'and the text file beside them is skipped, not failed';
is-deeply %r<findings>.List, (),
'a DLL whose imports all sit beside it passes';
# The hole this closes: sqlcipher.dll alone in the bundle is a
# perfectly good PE, but has an incomplete import closure. The audit
# proves the DLL adjacency that launch-time PATH will expose.
$sql.add('libcrypto-3-x64.dll').unlink;
throws-like {
App::Ariza::Native.audit(:bundle-dir($dir), :slug<windows-x86_64>)
}, Exception, message => /'sqlcipher.dll' .* 'libcrypto-3-x64.dll => not found'/,
'and one whose OpenSSL is not in the bundle fails, naming both';
# A DLL that is not a PE at all, which the old presence check could
# not tell from a real one.
$sql.add('libcrypto-3-x64.dll').spurt('definitely not a DLL');
throws-like {
App::Ariza::Native.audit(:bundle-dir($dir), :slug<windows-x86_64>)
}, Exception, message => /'not a PE file'/,
'a file named like a DLL that is not one is a finding, not a skip';
$sql.add('libcrypto-3-x64.dll').spurt('');
throws-like {
App::Ariza::Native.audit(:bundle-dir($dir), :slug<windows-x86_64>)
}, Exception, message => /'empty file'/,
'and an empty one still is, as it always was';
# A PE the parser cannot read stops the audit rather than passing it:
# the whole point is that nothing goes unchecked.
$sql.add('libcrypto-3-x64.dll').spurt('MZ' ~ "\0" x 200);
throws-like {
App::Ariza::Native.audit(:bundle-dir($dir), :slug<windows-x86_64>)
}, Exception, message => /'cannot read the imports'/,
'a PE that cannot be parsed fails loudly rather than silently'
~ ' counting as checked';
};