browser4-cli 4.14.0-rc.4 → 4.14.0-rc.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -55,11 +55,41 @@ SCRIPT_DIR=""
55
55
  # ----------------------------------------------
56
56
 
57
57
  say() { if [[ "$SILENT" != true ]]; then echo -e "$*"; fi; }
58
+ # Informational note for helpers whose stdout is captured by the caller --
59
+ # anything these functions print on stdout ends up inside the captured value.
60
+ note() { if [[ "$SILENT" != true ]]; then echo -e " [i] $*" >&2; fi; }
58
61
  step() { say " -> $*"; }
59
62
  ok() { say " [v] $*"; }
60
63
  warn() { say " [!] $*" >&2; }
61
64
  die() { echo "ERROR: $*" >&2; exit 1; }
62
65
 
66
+ # Validate the value that must follow an option. A missing value and a value
67
+ # that is really the next option are both rejected: `--version --dry-run` used
68
+ # to install the literal tag "--dry-run" (and `--install-dir --silent` created
69
+ # a directory called "--silent"), which fails much later with a confusing 404.
70
+ # Runs in the current shell so `die` really stops the installer -- a `die`
71
+ # inside `$( ... )` only kills the subshell and the install would continue.
72
+ validate_value() {
73
+ local flag="$1" value="${2:-}"
74
+ if [[ -z "$value" ]]; then
75
+ die "$flag requires a value"
76
+ fi
77
+ if [[ "$value" == -* ]]; then
78
+ die "$flag requires a value (got '$value', which looks like another option)"
79
+ fi
80
+ }
81
+
82
+ # Release tags on GitHub are prefixed with "v" (v4.13.18). Accepting a bare
83
+ # "4.13.18" and fixing it up front is friendlier than a 404 from the CDN.
84
+ normalize_tag() {
85
+ local tag="$1"
86
+ if [[ "$tag" =~ ^[0-9]+\.[0-9]+\.[0-9]+ ]]; then
87
+ note "Using release tag 'v$tag' (release tags are prefixed with 'v')"
88
+ tag="v$tag"
89
+ fi
90
+ printf '%s' "$tag"
91
+ }
92
+
63
93
  color_cyan='\033[0;36m'
64
94
  color_green='\033[0;32m'
65
95
  color_yellow='\033[0;33m'
@@ -157,13 +187,12 @@ EOF
157
187
  while [[ $# -gt 0 ]]; do
158
188
  case "$1" in
159
189
  --version|-v)
160
- shift; [[ -z "${1:-}" ]] && die "--version requires a value"
161
- VERSION="$1"; shift ;;
190
+ shift; validate_value "--version" "${1:-}"; VERSION=$(normalize_tag "$1"); shift ;;
162
191
  --install-dir|-d)
163
- shift; [[ -z "${1:-}" ]] && die "--install-dir requires a value"
164
- INSTALL_DIR="$1"; shift ;;
192
+ shift; validate_value "--install-dir" "${1:-}"; INSTALL_DIR="$1"; shift ;;
165
193
  --source)
166
- shift; [[ -z "${1:-}" ]] && die "--source requires a value"
194
+ shift
195
+ validate_value "--source" "${1:-}"
167
196
  if [[ "$1" != "github" && "$1" != "oss" ]]; then
168
197
  die "--source must be 'github' or 'oss'"
169
198
  fi
@@ -316,7 +345,7 @@ get_default_install_dir() {
316
345
 
317
346
  check_commands() {
318
347
  local missing=()
319
- for cmd in curl mktemp stat awk grep ln mkdir chmod; do
348
+ for cmd in curl mktemp stat awk grep ln mkdir chmod od cp mv tail cat; do
320
349
  if ! command -v "$cmd" >/dev/null 2>&1; then
321
350
  missing+=("$cmd")
322
351
  fi
@@ -326,6 +355,21 @@ check_commands() {
326
355
  fi
327
356
  }
328
357
 
358
+ # A downloaded file must really be a native executable, not an HTML error page
359
+ # or a wrong-architecture body that happens to be large. Magic bytes are the
360
+ # cheapest check that needs no network and no published checksum file.
361
+ verify_binary_magic() {
362
+ local path="$1"
363
+ local magic
364
+ magic=$(od -An -tx1 -N4 "$path" 2>/dev/null | tr -d ' \n')
365
+ case "$magic" in
366
+ 7f454c46) return 0 ;; # ELF (linux glibc/musl)
367
+ 4d5a*) return 0 ;; # PE / MZ (windows)
368
+ feedface|feedfacf|cefaedfe|cffaedfe|cafebabe) return 0 ;; # Mach-O, incl. fat
369
+ *) return 1 ;;
370
+ esac
371
+ }
372
+
329
373
  get_download_urls() {
330
374
  local binary_name="$1"
331
375
  local version_tag="$2"
@@ -374,8 +418,15 @@ download_file() {
374
418
  curl_stderr=$(mktemp)
375
419
  local http_code curl_exit
376
420
 
421
+ # Only GitHub needs (and only GitHub may receive) the token: the same header
422
+ # must never be sent to the Aliyun OSS mirror.
423
+ local -a auth_args=()
424
+ if [[ -n "${GITHUB_TOKEN:-}" ]] && [[ "$url" == https://github.com/* ]]; then
425
+ auth_args=(-H "Authorization: Bearer $GITHUB_TOKEN")
426
+ fi
427
+
377
428
  http_code=$(curl -sSfL -w "%{http_code}" -o "$dest" \
378
- ${GITHUB_TOKEN:+-H "Authorization: Bearer $GITHUB_TOKEN"} \
429
+ ${auth_args[@]+"${auth_args[@]}"} \
379
430
  "$url" 2>"$curl_stderr")
380
431
  curl_exit=$?
381
432
 
@@ -398,6 +449,11 @@ download_file() {
398
449
 
399
450
  # Sanity check: binary must be > 100 KB
400
451
  if [[ "$size" -gt 102400 ]]; then
452
+ if ! verify_binary_magic "$dest"; then
453
+ warn "Downloaded file is not a native executable (bad magic bytes) -- refusing it"
454
+ rm -f "$dest"
455
+ return 1
456
+ fi
401
457
  local size_mb
402
458
  size_mb=$(awk "BEGIN { printf \"%.1f\", $size / 1048576 }")
403
459
  ok "Downloaded ${size_mb} MB"
@@ -439,7 +495,11 @@ add_to_shell_rc() {
439
495
  rc_file="$HOME/.profile"
440
496
  fi
441
497
 
442
- if grep -qF "$dir" "$rc_file" 2>/dev/null; then
498
+ local path_line="export PATH=\"$dir:\$PATH\""
499
+
500
+ # Idempotency: only PATH assignment lines count, so an unrelated mention of the
501
+ # directory (a comment, another tool's variable) does not suppress the entry.
502
+ if grep -E '^[[:space:]]*(export[[:space:]]+)?PATH=' "$rc_file" 2>/dev/null | grep -qF "$dir"; then
443
503
  ok "PATH entry already in $rc_file"
444
504
  return
445
505
  fi
@@ -454,23 +514,25 @@ add_to_shell_rc() {
454
514
  fi
455
515
  fi
456
516
 
457
- # Use a lock file to prevent concurrent append races (best-effort).
517
+ # Serialize concurrent installs when the platform has flock (Linux). macOS
518
+ # ships without it, and waiting for a lock that can never be taken only made
519
+ # every install pause and print a bogus warning.
458
520
  local lock_file="${rc_file}.browser4-install.lock"
459
- local lock_fd=9
460
- # Wait up to 10 seconds for another install process to release the lock.
461
- local waited=0
462
- while ! (umask 0002 && command -v flock >/dev/null 2>&1 && flock -n 9 2>/dev/null); do
463
- if [[ $waited -ge 10 ]]; then
464
- warn "Could not acquire lock on ${rc_file} after 10s; appending anyway"
465
- break
466
- fi
467
- sleep 0.5
468
- waited=$((waited + 1))
469
- done 9>"$lock_file"
521
+ if command -v flock >/dev/null 2>&1; then
522
+ local waited=0
523
+ while ! flock -n 9 2>/dev/null; do
524
+ if [[ $waited -ge 20 ]]; then
525
+ warn "Could not acquire lock on ${rc_file} after 10s; appending anyway"
526
+ break
527
+ fi
528
+ sleep 0.5
529
+ waited=$((waited + 1))
530
+ done 9>"$lock_file"
531
+ fi
470
532
 
471
533
  {
472
534
  echo "# browser4-cli"
473
- echo "export PATH=\"$dir:\$PATH\""
535
+ echo "$path_line"
474
536
  } >> "$rc_file"
475
537
 
476
538
  # Clean up (flock auto-releases when fd 9 is closed)
@@ -511,7 +573,7 @@ create_symlinks() {
511
573
  local b4_exists=false
512
574
  local b4_is_ours=false
513
575
 
514
- # Check in install dir first — anything here is ours
576
+ # Check in install dir first - anything here is ours
515
577
  if [[ -e "$short_path" ]] || [[ -L "$short_path" ]]; then
516
578
  b4_exists=true
517
579
  b4_is_ours=true
@@ -620,7 +682,6 @@ install_backend() {
620
682
  # ----------------------------------------------
621
683
 
622
684
  main() {
623
- check_commands
624
685
  header
625
686
 
626
687
  # Locate ourselves on disk (only works when run as a file, not piped)
@@ -663,9 +724,9 @@ main() {
663
724
  step "Local binary: not found alongside script"
664
725
  fi
665
726
 
666
- # Check for already-installed binary
727
+ # Check for already-installed binary (honour an explicit --install-dir)
667
728
  local default_dir existing_path
668
- default_dir=$(get_default_install_dir)
729
+ default_dir="${INSTALL_DIR:-$(get_default_install_dir)}"
669
730
  existing_path="${default_dir}/${binary_name}"
670
731
  if [[ -f "$existing_path" ]]; then
671
732
  step "Already installed: $existing_path"
@@ -690,6 +751,10 @@ main() {
690
751
  step "Platform: $platform_key"
691
752
  step "Binary: $binary_name"
692
753
 
754
+ # Everything below downloads, unpacks or writes: check the tooling now, so
755
+ # `--locate` still works on a box that has none of it.
756
+ check_commands
757
+
693
758
  # Install directory
694
759
  if [[ -z "$INSTALL_DIR" ]]; then
695
760
  INSTALL_DIR=$(get_default_install_dir)
@@ -699,10 +764,15 @@ main() {
699
764
 
700
765
  # Ensure install directory exists
701
766
  if [[ ! -d "$INSTALL_DIR" ]]; then
702
- if [[ "$DRY_RUN" != true ]]; then
703
- mkdir -p "$INSTALL_DIR"
767
+ if [[ -e "$INSTALL_DIR" ]]; then
768
+ die "Install path exists but is not a directory: $INSTALL_DIR"
769
+ fi
770
+ if [[ "$DRY_RUN" == true ]]; then
771
+ step "[DRY-RUN] Would create directory: $INSTALL_DIR"
772
+ else
773
+ mkdir -p "$INSTALL_DIR" || die "Could not create install directory: $INSTALL_DIR"
774
+ step "Created directory: $INSTALL_DIR"
704
775
  fi
705
- step "Created directory: $INSTALL_DIR"
706
776
  fi
707
777
 
708
778
  local binary_path="${INSTALL_DIR}/${binary_name}"
@@ -174,17 +174,32 @@ cat > "$TEST_PAGE" <<'HTML'
174
174
  </body></html>
175
175
  HTML
176
176
 
177
- python3 -m http.server "$TEST_PORT" --bind 127.0.0.1 > /dev/null 2>&1 &
177
+ # Serve the fixture from $TEMP_DIR: without --directory, python http.server
178
+ # serves the *current working directory* (the CI workspace / repo checkout),
179
+ # so every run browsed Python's 404 "Error response" page instead of the
180
+ # fixture. Backends that tolerate type/click on a missing element let that
181
+ # pass silently; the loud "no element found for selector [#input1]" failure
182
+ # exposed the harness bug on all three platforms.
183
+ python3 -m http.server "$TEST_PORT" --bind 127.0.0.1 --directory "$TEMP_DIR" > /dev/null 2>&1 &
178
184
  HTTP_PID=$!
179
185
  TEST_URL="http://127.0.0.1:$TEST_PORT/test.html"
180
186
 
181
- # Wait for HTTP server to be ready
187
+ # Wait for the HTTP server to actually serve the fixture. curl -f fails on
188
+ # 4xx/5xx, so a server serving the wrong directory (or not yet ready) cannot
189
+ # silently hand the browser an error page — earlier runs typed into a 404
190
+ # page whenever the interactive commands tolerated a missing element.
191
+ SERVING_OK=""
182
192
  for i in $(seq 1 10); do
183
- if curl -s -o /dev/null "$TEST_URL" 2>/dev/null; then
193
+ if curl -sf -o /dev/null "$TEST_URL" 2>/dev/null; then
194
+ SERVING_OK=1
184
195
  break
185
196
  fi
186
197
  sleep 0.5
187
198
  done
199
+ if [ -z "$SERVING_OK" ]; then
200
+ info "FATAL: test page not served at $TEST_URL (python http.server not serving $TEMP_DIR?)"
201
+ exit 1
202
+ fi
188
203
 
189
204
  # ---------------------------------------------------------------------------
190
205
  # step 3: determine server URL (let CLI auto-start the server)
@@ -37,6 +37,21 @@ function Test($name, [ScriptBlock]$block) {
37
37
  }
38
38
  }
39
39
 
40
+ # ── Harness self-test ──
41
+ # An assertion that throws MUST be counted as a failure. Without this check a
42
+ # harness that silently swallows failures looks perfectly green, which is
43
+ # exactly how this suite's PowerShell sibling (the bash one) stayed green while
44
+ # running no assertions at all.
45
+ $selfTestFail = $script:fail
46
+ Write-Host " (one deliberate FAIL follows -- the harness self-test)" -ForegroundColor DarkGray
47
+ Test "__harness self-test (must be reported as a failure)" { throw "expected failure" }
48
+ if ($script:fail -ne ($selfTestFail + 1)) {
49
+ Write-Host " FATAL the harness does not record failures -- aborting" -ForegroundColor Red
50
+ exit 1
51
+ }
52
+ $script:fail = $selfTestFail
53
+ Write-Host " OK harness self-test (a throwing assertion is counted as a failure)" -ForegroundColor DarkGray
54
+
40
55
  function RunScript([string]$scriptArgs, [ref]$exitCode) {
41
56
  # Use -Command to control stderr redirect (2> path must come before script args,
42
57
  # which -File would pass as literal arguments to the script).
@@ -52,7 +67,15 @@ function RunScript([string]$scriptArgs, [ref]$exitCode) {
52
67
  $out = $proc.StandardOutput.ReadToEnd()
53
68
  $proc.WaitForExit()
54
69
  $exitCode.Value = $proc.ExitCode
55
- $err = if (Test-Path $tmpErr) { Get-Content $tmpErr -Raw -ErrorAction SilentlyContinue; Remove-Item $tmpErr -Force -ErrorAction SilentlyContinue } else { "" }
70
+ # Never leave $err as AutomationNull: `$null -eq $err` is true for it, but
71
+ # `$err -notmatch 'x'` returns an empty array, which is falsey -- an
72
+ # assertion written that way can never fail.
73
+ $err = ""
74
+ if (Test-Path $tmpErr) {
75
+ $err = Get-Content $tmpErr -Raw -ErrorAction SilentlyContinue
76
+ Remove-Item $tmpErr -Force -ErrorAction SilentlyContinue
77
+ }
78
+ if ($null -eq $err) { $err = "" }
56
79
  return [PSCustomObject]@{ Output = $out; Error = $err; ExitCode = $proc.ExitCode }
57
80
  }
58
81
 
@@ -217,10 +240,24 @@ Test "-SkipLocal flag accepted" {
217
240
  if ($ec4 -ne 0) { throw "Exit code: $ec4, output: $($r4.Output)" }
218
241
  }
219
242
 
220
- Test "-Force rejected (replaced by -SkipIfInstalled)" {
221
- $ec5 = 0; $r5 = RunScript -scriptArgs "-Force -DryRun" -exitCode ([ref]$ec5)
222
- if ($r5.Error -notmatch 'Force') {
223
- throw "-Force should be rejected, got: $($r5.Error)"
243
+ Test "-Force overrides -SkipIfInstalled, and is a live parameter" {
244
+ $tmp = Join-Path ([System.IO.Path]::GetTempPath()) "b4-force-$([System.IO.Path]::GetRandomFileName())"
245
+ New-Item -ItemType Directory -Path $tmp -Force | Out-Null
246
+ foreach ($n in @('browser4-cli-win32-x64.exe', 'browser4-cli-linux-x64', 'browser4-cli-darwin-arm64')) {
247
+ Set-Content -Path (Join-Path $tmp $n) -Value "dummy" -Encoding Ascii
248
+ }
249
+ try {
250
+ $ecA = 0; $rA = RunScript -scriptArgs "-SkipIfInstalled -DryRun -SkipBackend -AddToPath:`$false -InstallDir `"$tmp`"" -exitCode ([ref]$ecA)
251
+ if ($rA.Output -notmatch 'Binary already installed') {
252
+ throw "Expected the -SkipIfInstalled skip path, got: $($rA.Output)"
253
+ }
254
+ $ecB = 0; $rB = RunScript -scriptArgs "-SkipIfInstalled -Force -DryRun -SkipBackend -AddToPath:`$false -InstallDir `"$tmp`"" -exitCode ([ref]$ecB)
255
+ if ($ecB -ne 0) { throw "-Force must be accepted, exit code: $ecB" }
256
+ if ($rB.Output -match 'Binary already installed') {
257
+ throw "-Force must override -SkipIfInstalled, got: $($rB.Output)"
258
+ }
259
+ } finally {
260
+ Remove-Item $tmp -Recurse -Force -ErrorAction SilentlyContinue
224
261
  }
225
262
  }
226
263
 
@@ -236,8 +273,8 @@ Test "-Source github accepted" {
236
273
 
237
274
  Test "-Source invalid rejected" {
238
275
  $ec8 = 0; $r8 = RunScript -scriptArgs "-Source invalid -DryRun" -exitCode ([ref]$ec8)
239
- if ($r8.Error -notmatch 'Source|invalid|parameter') {
240
- throw "-Source invalid should be rejected, got: $($r8.Error)"
276
+ if ($ec8 -eq 0) {
277
+ throw "-Source invalid must fail, output: $($r8.Output)"
241
278
  }
242
279
  }
243
280
 
@@ -246,9 +283,33 @@ Test "-Silent flag accepted" {
246
283
  if ($ec9 -ne 0) { throw "Exit code: $ec9, output: $($r9.Output)" }
247
284
  }
248
285
 
249
- Test "-Version flag accepted" {
250
- $ec10 = 0; $r10 = RunScript -scriptArgs "-Version v4.11.0 -DryRun" -exitCode ([ref]$ec10)
286
+ Test "-Version reaches the download URLs" {
287
+ $ec10 = 0; $r10 = RunScript -scriptArgs "-Version v4.11.0 -DryRun -AddToPath:`$false" -exitCode ([ref]$ec10)
251
288
  if ($ec10 -ne 0) { throw "Exit code: $ec10, output: $($r10.Output)" }
289
+ if ($r10.Output -notmatch 'releases/download/v4\.11\.0/') {
290
+ throw "The tag never reached the URLs: $($r10.Output)"
291
+ }
292
+ }
293
+
294
+ Test "-Version accepts a bare semver and prefixes it with v" {
295
+ $ec11 = 0; $r11 = RunScript -scriptArgs "-Version 4.11.0 -DryRun -AddToPath:`$false" -exitCode ([ref]$ec11)
296
+ if ($ec11 -ne 0) { throw "Exit code: $ec11, output: $($r11.Output)" }
297
+ if ($r11.Output -notmatch 'releases/download/v4\.11\.0/') {
298
+ throw "Bare semver was not normalised to v4.11.0: $($r11.Output)"
299
+ }
300
+ }
301
+
302
+ Test "-Version rejects a path-traversal tag" {
303
+ $ec12 = 0; $r12 = RunScript -scriptArgs "-Version ../../evil -DryRun" -exitCode ([ref]$ec12)
304
+ if ($ec12 -eq 0) { throw "Traversal tag must be rejected, output: $($r12.Output)" }
305
+ }
306
+
307
+ Test "-DryRun does not probe the installed CLI" {
308
+ $ec13 = 0; $r13 = RunScript -scriptArgs "-DryRun -AddToPath:`$false" -exitCode ([ref]$ec13)
309
+ if ($ec13 -ne 0) { throw "Exit code: $ec13, output: $($r13.Output)" }
310
+ if ($r13.Output -match 'Backend already installed') {
311
+ throw "-DryRun ran 'status' on the installed CLI: $($r13.Output)"
312
+ }
252
313
  }
253
314
 
254
315
  Write-Host ""
@@ -256,10 +317,25 @@ Write-Host ""
256
317
  # ── Functions via dot-source ──
257
318
  Write-Host "--- Functions ---" -ForegroundColor Cyan
258
319
 
259
- # Strip trailing Main call and dot-source for function-level tests
260
- $scriptContent = Get-Content $installScript -Raw
261
- $scriptContent = $scriptContent -replace '\r?\nMain\s*$', ''
262
- $scriptContent = $scriptContent -replace '\$ErrorActionPreference\s*=\s*"Stop"', ''
320
+ # Strip the trailing `Main` call and dot-source for function-level tests.
321
+ # Strip it by AST extent, never by regex: if a line is ever appended after
322
+ # `Main`, a regex silently stops matching and this suite would perform a REAL
323
+ # install and edit the user PATH.
324
+ $installAst = [System.Management.Automation.Language.Parser]::ParseFile($installScript, [ref]$null, [ref]$null)
325
+ $mainCall = @($installAst.FindAll({
326
+ param($node)
327
+ $node -is [System.Management.Automation.Language.CommandAst] -and
328
+ $node.GetCommandName() -eq 'Main' -and
329
+ $node.Extent.Text.Trim() -eq 'Main'
330
+ }, $true)) | Select-Object -Last 1
331
+ if (-not $mainCall) {
332
+ throw "Could not locate the top-level 'Main' invocation in $installScript -- refusing to dot-source the installer"
333
+ }
334
+ $allLines = Get-Content $installScript
335
+ $scriptContent = ($allLines[0..($mainCall.Extent.StartLineNumber - 2)] -join "`n") -replace '\$ErrorActionPreference\s*=\s*"Stop"', ''
336
+ if ($scriptContent -match '(?m)^\s*Main\s*$') {
337
+ throw "The stripped copy still invokes Main -- refusing to dot-source the installer"
338
+ }
263
339
  $sb = [ScriptBlock]::Create($scriptContent)
264
340
 
265
341
  & {
@@ -313,6 +389,31 @@ $sb = [ScriptBlock]::Create($scriptContent)
313
389
  if ($result -ne $null) { throw "Expected null, got: $result" }
314
390
  }
315
391
 
392
+ Test "Find-LocalBinary finds a bundled binary and enforces the 100 KB gate" {
393
+ # The bundled-binary path is what a sideload install uses; it used to be
394
+ # untestable because the dot-sourced copy never had a usable ScriptDir.
395
+ $tmp = Join-Path ([System.IO.Path]::GetTempPath()) "b4-local-$([System.IO.Path]::GetRandomFileName())"
396
+ New-Item -ItemType Directory -Path $tmp -Force | Out-Null
397
+ try {
398
+ $name = Get-BinaryName -PlatformKey (Get-PlatformKey)
399
+ [System.IO.File]::WriteAllBytes((Join-Path $tmp $name), (New-Object byte[] 204800))
400
+ $savedScriptDir = $ScriptDir
401
+ try {
402
+ $ScriptDir = $tmp
403
+ $found = Find-LocalBinary -BinaryName $name
404
+ if (-not $found) { throw "A 200 KB bundled binary was not found in $tmp" }
405
+ [System.IO.File]::WriteAllBytes((Join-Path $tmp 'browser4-cli-too-small'), (New-Object byte[] 10))
406
+ if (Find-LocalBinary -BinaryName 'browser4-cli-too-small') {
407
+ throw "A 10-byte file passed the 100 KB gate"
408
+ }
409
+ } finally {
410
+ $ScriptDir = $savedScriptDir
411
+ }
412
+ } finally {
413
+ Remove-Item $tmp -Recurse -Force -ErrorAction SilentlyContinue
414
+ }
415
+ }
416
+
316
417
  Test "Test-LocalBinary returns false for empty string" {
317
418
  if (Test-LocalBinary -Path "") { throw "Should be false" }
318
419
  }
@@ -331,9 +432,43 @@ $sb = [ScriptBlock]::Create($scriptContent)
331
432
  if ($action -ne "upgrade") { throw "Expected 'upgrade', got: $action" }
332
433
  }
333
434
 
334
- Test "Get-BackendAction defaults to upgrade on empty status" {
435
+ Test "Get-BackendAction defaults to install on empty status" {
436
+ # An empty status means "no CLI answer" (fresh machine, or `status`
437
+ # failed). Defaulting to upgrade would run `upgrade` on a machine that
438
+ # has nothing to upgrade.
335
439
  $action = Get-BackendAction -StatusOutput ""
336
- if ($action -ne "upgrade") { throw "Expected 'upgrade', got: $action" }
440
+ if ($action -ne "install") { throw "Expected 'install', got: $action" }
441
+ }
442
+
443
+ Test "Test-BinaryMagic accepts ELF, PE and Mach-O" {
444
+ $tmp = [System.IO.Path]::GetTempFileName()
445
+ try {
446
+ $cases = @{
447
+ 'elf' = [byte[]]@(0x7f, 0x45, 0x4c, 0x46, 0x02, 0x01)
448
+ 'pe' = [byte[]]@(0x4d, 0x5a, 0x90, 0x00, 0x03)
449
+ 'macho' = [byte[]]@(0xcf, 0xfa, 0xed, 0xfe, 0x07)
450
+ }
451
+ foreach ($name in $cases.Keys) {
452
+ [System.IO.File]::WriteAllBytes($tmp, $cases[$name])
453
+ if (-not (Test-BinaryMagic -Path $tmp)) { throw "Rejected a valid $name header" }
454
+ }
455
+ } finally {
456
+ Remove-Item $tmp -Force -ErrorAction SilentlyContinue
457
+ }
458
+ }
459
+
460
+ Test "Test-BinaryMagic rejects an HTML error page" {
461
+ $tmp = [System.IO.Path]::GetTempFileName()
462
+ try {
463
+ [System.IO.File]::WriteAllText($tmp, "<html><body>404 Not Found</body></html>")
464
+ if (Test-BinaryMagic -Path $tmp) { throw "An HTML body was accepted as a binary" }
465
+ } finally {
466
+ Remove-Item $tmp -Force -ErrorAction SilentlyContinue
467
+ }
468
+ }
469
+
470
+ Test "Test-BinaryMagic rejects a missing file" {
471
+ if (Test-BinaryMagic -Path "/nonexistent/binary-xyz") { throw "A missing file was accepted" }
337
472
  }
338
473
  }
339
474