App-Ariza.git | t/ | 19-installer-update.rakutest
use v6.d;
use Test;
use JSON::Fast;
use App::Ariza::Config;
use App::Ariza::Installer;
use App::Ariza::Platform;
use App::Ariza::Tools;
sub temp-dir(Str $leaf --> IO::Path) {
ensure-dir($*TMPDIR.add("ariza-update-installer-{$*PID}-{(^1_000_000).pick}").add($leaf))
}
my $scratch = temp-dir('root');
END { rm-rf($scratch) }
my $app = ensure-dir($scratch.add('app'));
$app.add('ariza.toml').spurt(q:to/TOML/);
[app]
name = "App::ExampleApp"
exec = "exampleapp"
display = "Example App"
[bundle]
platforms = ["macos-arm64", "macos-x86_64", "linux-x86_64-glibc", "linux-aarch64-glibc", "windows-x86_64", "windows-arm64"]
warm = ["--version"]
[installer]
repo = "example-org/App-ExampleApp"
TOML
my $config = App::Ariza::Config.load($app);
my $script = $scratch.add('install.sh');
$script.spurt(App::Ariza::Installer.snapshot(:$config, :family<posix>));
$script.chmod(0o755);
subtest 'snapshot rendering is the published installer implementation', {
plan 3;
is App::Ariza::Installer.snapshot(:$config, :family<posix>), $script.slurp,
'POSIX snapshot renders from the install template';
ok App::Ariza::Installer.snapshot(:$config, :family<windows>)
.contains('[string] $ArizaUpdateCandidate'),
'Windows snapshot exposes the frozen private parameter';
throws-like { App::Ariza::Installer.snapshot(:$config, :family<plan9>) },
Exception, message => /"no installer snapshot family 'plan9'"/,
'unknown snapshot families fail closed';
}
subtest 'private syntax and public escape hatches are separated', {
plan 8;
my $text = $script.slurp;
ok $text.contains('--ariza-update-candidate'), 'candidate argument is present';
ok $text.contains('--ariza-handoff'), 'handoff argument is present';
ok $text.contains('--ariza-nonce'), 'nonce argument is present';
ok $text.contains('--ariza-expected-current'), 'expected-current argument is present';
ok $text.contains('public version/insecure options are forbidden'),
'private mode rejects public policy controls';
ok $text.contains('protocol=1\nnonce=%s\ncandidate=%s\n'),
'handoff is the bounded line protocol, without an executable path';
ok $text.contains('ariza_validate_private_manifest'),
'the extracted bundle identity is validated';
ok $text.contains('[ "$ARIZA_PRIVATE" -eq 1 ] && exit 0'),
'private installs return before public warm-up and reporting';
}
subtest 'private exact-candidate install commits and retains exact pre-switch', {
plan 11;
if $*DISTRO.is-win || !have-command('sh') {
skip-rest 'POSIX private installer execution needs sh';
}
else {
my $home = ensure-dir($scratch.add('home'));
my $data-home = ensure-dir($scratch.add('data'));
my $root = ensure-dir($data-home.add('exampleapp'));
my $versions = ensure-dir($root.add('versions'));
my $old = ensure-dir($versions.add('1.0.0'));
ensure-dir($old.add('bin')).add('exampleapp').spurt("#!/bin/sh\nexit 0\n");
$old.add('bin/exampleapp').chmod(0o755);
run-checked(['ln', '-s', 'versions/1.0.0', $root.add('current').absolute], :what<ln>);
# A stale newest-by-mtime directory proves retention is identity-based.
my $stale = ensure-dir($versions.add('9.9.9'));
ensure-dir($stale.add('bin')).add('exampleapp').spurt("#!/bin/sh\nexit 0\n");
$stale.add('bin/exampleapp').chmod(0o755);
my $candidate = '1.1.0';
my $slug = current-slug();
my $name = "exampleapp-$candidate-$slug";
my $pack = ensure-dir($scratch.add('pack'));
my $top = ensure-dir($pack.add($name));
ensure-dir($top.add('bin')).add('exampleapp').spurt(
q:to/SH/);
#!/bin/sh
printf ran >"${ARIZA_TEST_WARM_MARKER:?}"
SH
$top.add('bin/exampleapp').chmod(0o755);
ensure-dir($top.add('libexec/ariza'));
$top.add('libexec/ariza/update.raku').spurt("say 'update'\n");
$top.add('libexec/ariza/install.sh').spurt("#!/bin/sh\n");
ensure-dir($top.add('site/bin')).add('application').spurt("say 'app'\n");
$top.add('ariza-manifest.json').spurt(to-json({
'ariza-manifest' => 1,
app => { name => 'App::ExampleApp', exec => 'exampleapp', version => $candidate },
updates => {
protocol => 1, enabled => True,
repository => 'example-org/App-ExampleApp',
coordinator => 'libexec/ariza/update.raku',
installer => 'libexec/ariza/install.sh',
'application-target' => 'site/bin/application',
},
}, :sorted-keys) ~ "\n");
my $archive = $scratch.add("$name.tar.gz");
run-checked(['tar', '-c', '-z', '-f', $archive.absolute,
'-C', $pack.absolute, $name], :what<tar>);
my $digest = $scratch.add("$name.tar.gz.sha256");
$digest.spurt(sha256-file($archive) ~ " $name.tar.gz\n");
my $mock = ensure-dir($scratch.add('mock-bin'));
my $curl = $mock.add('curl');
$curl.spurt(qq:to/SH/);
#!/bin/sh
dest=''
url=''
while [ \$# -gt 0 ]; do
case \$1 in
-o) shift; dest=\$1 ;;
http*) url=\$1 ;;
esac
shift
done
case \$url in
*.sha256) cp '{ $digest.absolute }' "\$dest" ;;
*) cp '{ $archive.absolute }' "\$dest" ;;
esac
SH
$curl.chmod(0o755);
my $handoff-dir = ensure-dir($root.add('.ariza/update-v1'));
my $nonce = 'a' x 64;
my $challenge = ensure-dir($handoff-dir.add("handoff-{$*PID}-$nonce"));
my $handoff = $challenge.add('result');
my $warm-marker = $scratch.add('private-warmed');
my %env =
HOME => $home.absolute,
XDG_DATA_HOME => $data-home.absolute,
PATH => "{$mock.absolute}:/usr/bin:/bin",
ARIZA_TEST_WARM_MARKER => $warm-marker.absolute,
;
my ($code, $out, $err) = try-run([
'sh', $script.absolute,
'--ariza-update-candidate', $candidate,
'--ariza-handoff', $handoff.absolute,
'--ariza-nonce', $nonce,
'--ariza-expected-current', $old.absolute,
], :%env);
is $code, 0, 'private installer commits successfully' or diag $err ~ $out;
ok $out.contains('sha256 verified'), 'mandatory checksum was verified';
is $root.add('current').resolve.basename, $candidate, 'current switched to candidate';
is $root.add('previous').resolve.absolute, $old.resolve.absolute,
'previous is the exact physical pre-switch bundle';
is-deeply $versions.dir.map(*.basename).sort.List, ('1.0.0', '1.1.0'),
'retention is exactly new plus exact pre-switch, not newest-by-mtime';
is $handoff.slurp,
"protocol=1\nnonce=$nonce\ncandidate=$candidate\n",
'authenticated handoff record has the exact protocol bytes';
nok $handoff.slurp.contains($root.absolute), 'handoff contains no target path';
nok $warm-marker.e, 'private install never warms the application';
nok $root.add('.ariza/update-v1/transaction').e,
'transaction journal is cleared after commit';
ok $root.add('current').l && $root.add('previous').l,
'both managed pointers are links';
ok $err eq '' || !$err.contains('error'), 'no installer error was reported' or diag $err;
}
}
subtest 'private argument boundary fails before download or mutation', {
plan 8;
if $*DISTRO.is-win || !have-command('sh') {
skip-rest 'POSIX private installer execution needs sh';
}
else {
my $home = ensure-dir($scratch.add('negative-home'));
my $data-home = ensure-dir($scratch.add('negative-data'));
my $root = ensure-dir($data-home.add('exampleapp'));
my $versions = ensure-dir($root.add('versions'));
my $old = ensure-dir($versions.add('2.0.0'));
ensure-dir($old.add('bin')).add('exampleapp').spurt("#!/bin/sh\nexit 0\n");
$old.add('bin/exampleapp').chmod(0o755);
run-checked(['ln', '-s', 'versions/2.0.0', $root.add('current').absolute], :what<ln>);
my $state = ensure-dir($root.add('.ariza/update-v1'));
my %env = HOME => $home.absolute, XDG_DATA_HOME => $data-home.absolute,
PATH => '/usr/bin:/bin';
sub attempt(Str $candidate, Str $nonce = 'b' x 64,
IO::Path $handoff = $state.add('handoff.bad') --> List) {
try-run(['sh', $script.absolute,
'--ariza-update-candidate', $candidate,
'--ariza-handoff', $handoff.absolute,
'--ariza-nonce', $nonce,
'--ariza-expected-current', $old.absolute], :%env)
}
my ($vcode, $, $verr) = attempt('v2.1.0');
isnt $vcode, 0, 'v-prefixed private candidate is rejected';
ok $verr.contains('bare ASCII x.y.z'), 'candidate error names the exact grammar';
my ($ncode, $, $nerr) = attempt('2.1.0', 'B' x 64);
isnt $ncode, 0, 'uppercase nonce is rejected';
ok $nerr.contains('64 lowercase hexadecimal'), 'nonce error names the exact grammar';
my ($hcode, $, $herr) = attempt('2.1.0', 'b' x 64,
$root.add('handoff-outside-state'));
isnt $hcode, 0, 'handoff outside update-v1 state is rejected';
ok $herr.contains('inside update-v1 state'), 'handoff containment is explained';
is $root.add('current').resolve.absolute, $old.resolve.absolute,
'all argument failures leave current untouched';
nok $state.add('handoff.bad').e, 'and none can forge a handoff record';
}
}
subtest 'Windows private updater executes natively', {
plan 22;
my $powershell = $*DISTRO.is-win
?? (have-command('powershell.exe') ?? 'powershell.exe'
!! (have-command('pwsh') ?? 'pwsh' !! ''))
!! '';
if !$powershell {
skip-rest 'Windows private installer runtime needs powershell.exe or pwsh on Windows';
}
else {
my $win = ensure-dir($scratch.add('windows-native'));
my $installer = $win.add('install.ps1');
$installer.spurt(App::Ariza::Installer.snapshot(
:$config, :family<windows>));
my $archive-helper = $win.add('make-archive.ps1');
$archive-helper.spurt(q:to/POWERSHELL/);
param([string] $Top, [string] $Archive)
$tar = Get-Command tar.exe -ErrorAction SilentlyContinue
if (-not $tar) { throw 'Windows tar.exe is required for the installer fixture' }
$parent = Split-Path -Parent $Top
$leaf = Split-Path -Leaf $Top
& $tar.Path -c -z -f $Archive -C $parent $leaf
if ($LASTEXITCODE -ne 0) { throw "tar.exe failed with exit $LASTEXITCODE" }
POWERSHELL
my $junction-helper = $win.add('junction.ps1');
$junction-helper.spurt(q:to/POWERSHELL/);
param([string] $Link, [string] $Target)
New-Item -ItemType Junction -Path $Link -Target $Target -Force | Out-Null
POWERSHELL
my $target-helper = $win.add('junction-target.ps1');
$target-helper.spurt(q:to/POWERSHELL/);
param([string] $Link)
$item = Get-Item -LiteralPath $Link -Force
$target = [string] $item.Target
[IO.Path]::GetFileName($target.TrimEnd('\'))
POWERSHELL
my $unlink-helper = $win.add('unlink-junctions.ps1');
$unlink-helper.spurt(q:to/POWERSHELL/);
param([string[]] $Links)
foreach ($link in $Links) {
if (Test-Path -LiteralPath $link) {
$item = Get-Item -LiteralPath $link -Force
if ($item.LinkType) { $item.Delete() }
}
}
POWERSHELL
my $invoke = $win.add('invoke-private.ps1');
$invoke.spurt(q:to/POWERSHELL/);
[CmdletBinding()]
param(
[string] $Installer,
[string] $Candidate,
[string] $Handoff,
[string] $Nonce,
[string] $ExpectedCurrent
)
function global:Invoke-WebRequest {
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)] [string] $Uri,
[Parameter(Mandatory = $true)] [string] $OutFile,
[switch] $UseBasicParsing
)
[IO.File]::AppendAllText($env:ARIZA_TEST_DOWNLOAD_MARKER, "$Uri`n")
$source = if ($Uri.EndsWith('.sha256')) {
$env:ARIZA_TEST_DIGEST
}
else {
$env:ARIZA_TEST_ARCHIVE
}
[IO.File]::Copy($source, $OutFile, $true)
}
& $Installer `
-ArizaUpdateCandidate $Candidate `
-ArizaHandoff $Handoff `
-ArizaNonce $Nonce `
-ArizaExpectedCurrent $ExpectedCurrent
if (-not $?) { exit 1 }
exit 0
POWERSHELL
sub ps-run(IO::Path $file, *@args --> List) {
my %env = %*ENV;
%env<PROCESSOR_ARCHITECTURE> //= 'AMD64';
try-run([
$powershell, '-NoProfile', '-NonInteractive',
'-ExecutionPolicy', 'Bypass', '-File', $file.absolute, |@args,
], :%env)
}
my $arch = (%*ENV<PROCESSOR_ARCHITECTURE> // 'AMD64').uc;
my $slug = $arch eq 'ARM64' ?? 'windows-arm64' !! 'windows-x86_64';
my $candidate = '3.1.0';
my $name = "exampleapp-$candidate-$slug";
my $pack = ensure-dir($win.add('pack'));
my $top = ensure-dir($pack.add($name));
ensure-dir($top.add('bin')).add('exampleapp.cmd').spurt(
"\@echo off\r\n> \"%ARIZA_TEST_WARM_MARKER%\" echo ran\r\n");
ensure-dir($top.add('libexec/ariza'));
$top.add('libexec/ariza/update.raku').spurt("say 'update'\n");
$top.add('libexec/ariza/install.ps1').spurt("# local snapshot\r\n");
ensure-dir($top.add('site')).add('application.raku').spurt("say 'app'\n");
$top.add('ariza-manifest.json').spurt(to-json({
'ariza-manifest' => 1,
app => {
name => 'App::ExampleApp', exec => 'exampleapp',
version => $candidate,
},
updates => {
protocol => 1, enabled => True,
repository => 'example-org/App-ExampleApp',
coordinator => 'libexec/ariza/update.raku',
installer => 'libexec/ariza/install.ps1',
'application-target' => 'site/application.raku',
},
}, :sorted-keys) ~ "\n");
my $archive = $win.add("$name.tar.gz");
my ($archive-code, $, $archive-err) = ps-run(
$archive-helper, $top.absolute, $archive.absolute);
die "PowerShell could not create the Windows installer fixture: $archive-err"
if $archive-code != 0;
my $digest = $win.add("$name.tar.gz.sha256");
$digest.spurt(sha256-file($archive) ~ " $name.tar.gz\n");
sub installed-root(Str $leaf --> List) {
my $local = ensure-dir($win.add($leaf).add('local-app-data'));
my $root = ensure-dir($local.add('Example App'));
my $versions = ensure-dir($root.add('versions'));
($local, $root, $versions)
}
my ($local, $root, $versions) = installed-root('success');
my $old = ensure-dir($versions.add('3.0.0'));
ensure-dir($old.add('bin')).add('exampleapp.cmd').spurt("\@echo off\r\n");
my $stale = ensure-dir($versions.add('9.9.9'));
ensure-dir($stale.add('bin')).add('exampleapp.cmd').spurt("\@echo off\r\n");
my ($link-code, $, $link-err) = ps-run(
$junction-helper, $root.add('current').absolute, $old.absolute);
die "PowerShell could not create the managed current junction: $link-err"
if $link-code != 0;
my $state = ensure-dir($root.add('.ariza/update-v1'));
my $nonce = 'c' x 64;
my $challenge = ensure-dir($state.add("handoff-{$*PID}-$nonce"));
my $handoff = $challenge.add('result');
my $download-marker = $win.add('success-downloads');
my $warm-marker = $win.add('success-warmed');
my %success-env = %*ENV;
%success-env<LOCALAPPDATA> = $local.absolute.Str;
%success-env<PROCESSOR_ARCHITECTURE> = $arch;
%success-env<ARIZA_TEST_WARM_MARKER> = $warm-marker.absolute.Str;
%success-env<ARIZA_TEST_ARCHIVE> = $archive.absolute.Str;
%success-env<ARIZA_TEST_DIGEST> = $digest.absolute.Str;
%success-env<ARIZA_TEST_DOWNLOAD_MARKER> = $download-marker.absolute.Str;
%success-env<EXAMPLEAPP_BUNDLE_URL>:delete;
my ($code, $out, $err) = try-run([
$powershell, '-NoProfile', '-NonInteractive',
'-ExecutionPolicy', 'Bypass', '-File', $invoke.absolute,
'-Installer', $installer.absolute,
'-Candidate', $candidate,
'-Handoff', $handoff.absolute,
'-Nonce', $nonce,
'-ExpectedCurrent', $old.absolute,
], :env(%success-env));
is $code, 0, 'PowerShell private installer commits successfully'
or diag $err ~ $out;
ok $out.contains('sha256 verified'),
'Windows runtime verifies the mandatory checksum' or diag $out ~ $err;
my ($cur-code, $cur-out, $cur-err) = ps-run(
$target-helper, $root.add('current').absolute);
is $cur-code == 0 ?? $cur-out.trim !! $cur-err.trim, $candidate,
'current is the candidate junction target';
my ($prev-code, $prev-out, $prev-err) = ps-run(
$target-helper, $root.add('previous').absolute);
is $prev-code == 0 ?? $prev-out.trim !! $prev-err.trim, '3.0.0',
'previous is the exact physical pre-switch junction target';
is-deeply $versions.dir.map(*.basename).sort.List, ('3.0.0', '3.1.0'),
'Windows retention is exactly new plus exact pre-switch';
my $handoff-text = $handoff.f ?? $handoff.slurp !! '<missing handoff>';
is $handoff-text, "protocol=1\nnonce=$nonce\ncandidate=$candidate\n",
'Windows handoff bytes match protocol v1 exactly';
nok $handoff-text.contains($root.absolute),
'Windows handoff contains no executable or bundle path';
nok $warm-marker.e, 'private Windows install does not warm the app';
nok $state.add('transaction').e, 'successful Windows commit clears its journal';
is $download-marker.f ?? $download-marker.slurp.lines.elems !! -1, 2,
'both mocked downloads ran locally with no network request';
# Repack the same structurally valid candidate with the wrong repository.
# It reaches the manifest boundary after download, checksum, and extract,
# then must fail before any managed pointer changes.
$top.add('ariza-manifest.json').spurt(
$top.add('ariza-manifest.json').slurp.subst(
'example-org/App-ExampleApp', 'attacker/Wrong-App'));
my $bad-manifest-archive = $win.add("{$name}-bad-manifest.tar.gz");
my ($bad-archive-code, $, $bad-archive-err) = ps-run(
$archive-helper, $top.absolute, $bad-manifest-archive.absolute);
die "PowerShell could not create the bad-manifest fixture: $bad-archive-err"
if $bad-archive-code != 0;
my $bad-manifest-digest = $win.add("{$name}-bad-manifest.tar.gz.sha256");
$bad-manifest-digest.spurt(
sha256-file($bad-manifest-archive)
~ " {$name}-bad-manifest.tar.gz\n");
my ($manifest-local, $manifest-root, $manifest-versions) =
installed-root('invalid-manifest');
my $manifest-old = ensure-dir($manifest-versions.add('5.0.0'));
ensure-dir($manifest-old.add('bin')).add('exampleapp.cmd')
.spurt("\@echo off\r\n");
my ($manifest-link-code, $, $manifest-link-err) = ps-run(
$junction-helper, $manifest-root.add('current').absolute,
$manifest-old.absolute);
die "PowerShell could not create the manifest-test junction: $manifest-link-err"
if $manifest-link-code != 0;
my $manifest-state = ensure-dir($manifest-root.add('.ariza/update-v1'));
my $manifest-challenge = ensure-dir(
$manifest-state.add("handoff-{$*PID}-manifest"));
my $manifest-handoff = $manifest-challenge.add('result');
my %manifest-env = %*ENV;
%manifest-env<LOCALAPPDATA> = $manifest-local.absolute.Str;
%manifest-env<PROCESSOR_ARCHITECTURE> = $arch;
%manifest-env<ARIZA_TEST_ARCHIVE> = $bad-manifest-archive.absolute.Str;
%manifest-env<ARIZA_TEST_DIGEST> = $bad-manifest-digest.absolute.Str;
%manifest-env<ARIZA_TEST_DOWNLOAD_MARKER> =
$win.add('manifest-downloads').absolute.Str;
%manifest-env<EXAMPLEAPP_BUNDLE_URL>:delete;
my ($manifest-code, $manifest-out, $manifest-err) = try-run([
$powershell, '-NoProfile', '-NonInteractive',
'-ExecutionPolicy', 'Bypass', '-File', $invoke.absolute,
'-Installer', $installer.absolute,
'-Candidate', $candidate,
'-Handoff', $manifest-handoff.absolute,
'-Nonce', ('e' x 64),
'-ExpectedCurrent', $manifest-old.absolute,
], :env(%manifest-env));
isnt $manifest-code, 0, 'mismatched Windows update manifest is rejected';
ok ($manifest-out ~ $manifest-err).contains(
'invalid update protocol metadata'),
'manifest rejection names the failed protocol boundary';
my ($manifest-cur-code, $manifest-cur-out, $manifest-cur-err) = ps-run(
$target-helper, $manifest-root.add('current').absolute);
is $manifest-cur-code == 0
?? $manifest-cur-out.trim
!! $manifest-cur-err.trim,
'5.0.0', 'manifest rejection leaves current unchanged';
is-deeply $manifest-versions.dir.map(*.basename).sort.List, ('5.0.0',),
'manifest rejection never installs the candidate directory';
nok $manifest-handoff.e,
'manifest rejection cannot emit an authenticated handoff';
my ($bad-local, $bad-root, $bad-versions) = installed-root('invalid');
my $bad-old = ensure-dir($bad-versions.add('4.0.0'));
ensure-dir($bad-old.add('bin')).add('exampleapp.cmd').spurt("\@echo off\r\n");
my $bad-stale = ensure-dir($bad-versions.add('8.8.8'));
ensure-dir($bad-stale.add('bin')).add('exampleapp.cmd').spurt("\@echo off\r\n");
my ($bad-link-code, $, $bad-link-err) = ps-run(
$junction-helper, $bad-root.add('current').absolute, $bad-old.absolute);
die "PowerShell could not create the negative-test junction: $bad-link-err"
if $bad-link-code != 0;
my $bad-state = ensure-dir($bad-root.add('.ariza/update-v1'));
my $bad-challenge = ensure-dir($bad-state.add("handoff-{$*PID}-bad"));
my $bad-handoff = $bad-challenge.add('result');
my $bad-download = $win.add('invalid-downloads');
my %bad-env = %*ENV;
%bad-env<LOCALAPPDATA> = $bad-local.absolute.Str;
%bad-env<PROCESSOR_ARCHITECTURE> = $arch;
%bad-env<ARIZA_TEST_ARCHIVE> = $archive.absolute.Str;
%bad-env<ARIZA_TEST_DIGEST> = $digest.absolute.Str;
%bad-env<ARIZA_TEST_DOWNLOAD_MARKER> = $bad-download.absolute.Str;
%bad-env<EXAMPLEAPP_BUNDLE_URL>:delete;
my ($bad-code, $bad-out, $bad-err) = try-run([
$powershell, '-NoProfile', '-NonInteractive',
'-ExecutionPolicy', 'Bypass', '-File', $invoke.absolute,
'-Installer', $installer.absolute,
'-Candidate', 'v4.1.0',
'-Handoff', $bad-handoff.absolute,
'-Nonce', ('d' x 64),
'-ExpectedCurrent', $bad-old.absolute,
], :env(%bad-env));
isnt $bad-code, 0, 'invalid Windows private candidate is rejected';
ok ($bad-out ~ $bad-err).contains('bare ASCII x.y.z'),
'Windows rejection names the exact candidate grammar';
my ($bad-cur-code, $bad-cur-out, $bad-cur-err) = ps-run(
$target-helper, $bad-root.add('current').absolute);
is $bad-cur-code == 0 ?? $bad-cur-out.trim !! $bad-cur-err.trim, '4.0.0',
'argument rejection leaves current unchanged';
is-deeply $bad-versions.dir.map(*.basename).sort.List, ('4.0.0', '8.8.8'),
'argument rejection leaves every installed version unchanged';
nok $bad-handoff.e, 'argument rejection cannot emit a handoff';
nok $bad-download.e, 'argument rejection occurs before any download';
ok !$bad-root.add('previous').e,
'argument rejection creates no previous junction';
# Remove junction objects before the shared recursive scratch cleanup.
ps-run($unlink-helper,
$root.add('current').absolute, $root.add('previous').absolute,
$manifest-root.add('current').absolute,
$bad-root.add('current').absolute);
}
}
done-testing;