unsloth/tests/python/test_virustotal_scan.py
Daniel Han 5a5bf64130
Reduce antivirus false positives in the desktop installers (#8586)
* Windows setup: install uv from a pinned release instead of running remote script text

studio/setup.ps1 piped astral's install.ps1 straight into Invoke-Expression. That
download-and-execute shape is the single construct AMSI providers and cloud ML
scanners score hardest, and install.ps1 already replaced it with a pinned-SHA-256
archive download. Port the same implementation across.

Progress goes to the pipeline rather than the console, so the quiet path swallows
it exactly as it swallowed astral's installer output and the printed lines around
the call site are unchanged.

* Windows: stop pairing a hidden window with a bypassed execution policy

The Studio shortcut launched launch-studio.ps1 with -WindowStyle Hidden and
-ExecutionPolicy Bypass on the same command line. That pair is what Microsoft's
own detections key on, and studio/src-tauri/src/install.rs already refuses it for
the app's own launch of install.ps1.

The installer writes launch-studio.ps1 itself, so the file carries no
mark-of-the-web and RemoteSigned loads it. The hidden window is unchanged, so the
shortcut behaves exactly as before. The generated launcher's own child launch
moves to RemoteSigned for the same reason: it runs an inline -Command against an
executable, where no script file is loaded and the two policies are equivalent.

Also refresh a stale comment in studio/setup.ps1 that attributed the PSModulePath
fix to astral's uv installer, which no longer runs in-process.

* Installers: keep download-and-run command lines out of the shipped script text

AMSI scans install.ps1 in full before a single line of it runs, and generic
script classifiers read install.sh the same way inside the Linux bundle. Both
headers rehearsed the piped web one-liner five times over, plus a scriptblock
form and an execution-policy bypass, none of which anything in the scripts reads
and all of which the README already documents.

Point at the README instead and reword the in-body comments that quoted the
one-liner as shorthand. Every printed line is untouched: the remediation text the
installers show users still spells out the command in full.

Same treatment for scripts/uninstall.ps1's header.

* Windows: resolve process image paths with one Win32_Process query

install.ps1's venv-holder probe opened a handle to every running PID through
inline C# compiled at runtime. Opening a handle per process is a shape AV
heuristics score hard, and it bought nothing: Win32_Process reports
ExecutablePath for exactly the processes those handles could be opened against,
and answers for all of them in a single query instead of once per PID.

The remaining file-canonicalisation imports stay -- handle-based resolution of
linked ancestors has no faithful Windows PowerShell 5.1 equivalent, and it runs
on security-relevant paths.

Falls back to the per-process .Path when the query is unavailable, so a degraded
WMI repository degrades exactly as the old code did on a process it could not
open.

* Desktop: say who blocked the install when AMSI stops the script

PowerShell hands the whole top-level script block to AMSI while compiling it, so
a security product's verdict arrives as a parse error over the entire file before
install.ps1 runs a statement: no [TAURI:ERROR] marker, no phase log, and a stderr
tail the user cannot act on. unsloth#8523 shows what that looks like in the UI --
"Installation failed: + FullyQualifiedErrorId : ScriptContainedMaliciousContent".

Recognise the two stable error ids on either stream and append what the user
actually needs: nothing was installed, nothing was changed, it is a false
positive, update definitions and retry, do not turn off endpoint protection. The
raw id stays in the message, because the diagnostics report and any vendor
submission both need it.

Matches the id, never the message text, which is localized, and tolerates the
cmdlet suffix the Invoke-Expression form carries.

* Desktop: ship each bundle only the installer it can run

resolve_install_script picks install.sh on unix and install.ps1 everywhere else,
but the shared Tauri config bundled both into every target. The Linux AppImage
therefore carried 280 KB of Windows PowerShell it can never execute -- and it is
the largest script body a generic classifier walking the squashfs reads, which is
where Microsoft's Trojan:Script/Wacatac.B!ml verdict on 0.1.701-beta landed.

Move the resource map into the per-platform configs. The clean-machine job
already fails when a Linux bundle ships no install.sh; it now also fails when one
ships install.ps1, so the split cannot silently regress in either direction.

The .deb scanned clean with the same payload, so this is surface reduction rather
than a proven fix for that verdict.

* POSIX installers: install uv from a pinned release before falling back

install.sh downloaded astral's install.sh to a temp file, ran it and deleted the
file; studio/setup.sh piped it straight into a shell. Both are, shape for shape,
what a dropper does, and generic ML script classifiers score them accordingly --
the 0.1.701-beta Linux AppImage came back Trojan:Script/Wacatac.B!ml while the
.deb carrying the same scripts came back clean.

Fetch the pinned release archive and verify a hardcoded SHA-256 instead, matching
what install.ps1 already does on Windows. Only the four mainstream targets are
pinned: musl, armv7 and any host without a digest tool keep the path they have
today, because guessing a target triple wrong would break the install outright
and that costs far more than the heuristic score of the fallback.

Destination, PATH handling and every printed line are unchanged, so a host that
takes either path ends up in the same state it did before.

* tests: pin the installer shapes antivirus heuristics score

One file collecting what was removed, so it cannot drift back: no remote script
run in-process, no encoded or base64 payload, no hidden window paired with a
bypassed execution policy, no handle opened against another process, and no new
runtime-compiled native import outside an allowlist that carries a reason for
each entry that stays.

The last test is the other half of the contract. Hardening must not change what a
user sees, so the remediation lines the installers print -- which still spell out
the web one-liner in full -- are asserted verbatim. Removing the one-liner from
comments is the point; removing it from what the user is told to run would be a
regression.

Runs on the existing discovery-based pytest step, no workflow list to update.

* release: emit a false-positive submission packet for whatever gets flagged

The build job assembles a Microsoft submission packet, but only for the Windows
-setup.exe. The detection that actually arrived on 0.1.701-beta was
Trojan:Script/Wacatac.B!ml on the Linux AppImage, so nothing was produced for the
one asset that needed it.

The VirusTotal job already knows which assets were flagged and by which engines,
so put the packet there: hash, size and both portals, for every flagged asset
whatever platform it came from, with a note that clearance is per hash and per
vendor. Engine names are not repeated -- they are third-party text and already
appear escaped under Flagging engines.

The gate stays advisory; this only makes acting on it take seconds.

* Revert "Windows: resolve process image paths with one Win32_Process query"

This reverts commit 7897865c9.

tests/python/test_windows_installer_concurrency_guard.py bans Get-CimInstance
and $process.Path from Get-RunningStudioVenvProcesses outright, and requires the
native image-path lookup. That contract came out of #7764, which closed a set of
races where the installer inferred "in use" from something other than a confirmed
executable identity and blocked installs that should have proceeded.

Win32_Process.ExecutablePath does answer the same question, but a wrongly blocked
install costs far more than the heuristic weight of three native imports. Record
the imports in the AV-shapes allowlist with that reasoning instead, and keep the
ban on the process-memory APIs, which the installer has no use for.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Tighten the comments added by this branch

Opening comment-reduction pass over the PR diff: same intent, fewer lines. Cut
hardest on the prose that restated the PR description rather than explaining the
code next to it. Comments and docstrings only, verified with comment_tools.py
check --strip-docstrings across every Python file in the diff.

* Drop an unused helper from the uv pinned-release test

* Fix three review findings on the installer hardening

Stray-resource check aborted the step it was meant to assert. grep exits 1 when
it selects nothing, and under this step's set -o pipefail plus the runner's
bash -e that kills the assignment outright, so every correctly split .deb failed
clean-machine CI before reaching the check. Both lookups take || true now: no
match is the passing case for the stray one, and for install.sh it was swallowing
the explicit annotation in favour of a bare exit 1.

studio/setup.sh skipped astral's XDG_DATA_HOME/../bin destination tier, which
install.sh, install.ps1 and studio/setup.ps1 all honour. A host that configured
an XDG location got uv under ~/.local/bin instead, where no later shell looks for
it. The session PATH prepend hid it at install time.

The AMSI guidance claimed nothing was changed even when the block landed on the
nested studio/setup.ps1, which install.ps1 launches through the same inherited
pipes after the venv, PyTorch and the packages are already on disk. Split the
wording on whether a [TAURI:STEP] marker has been seen: a pre-start block
produces none, so the reassurance is only given where it is true.

* Key the submission packet on the flagged count, not the engine list

stats and results are separate fields of the same VirusTotal response, so an
asset can carry a flagged count with no readable results map. The summary table
reports that asset and the packet skipped it, which is exactly the one that needs
a packet. Select on stats.flagged and keep the engine list for the Flagging
engines section, which is correctly keyed on having engines to name.

* Drop the bundle stray-resource assertion from clean-machine CI

That job downloads a published release, never a bundle built from the branch, so
asserting the new resource split there turns every run red until a release ships
with it. The split is a property of the Tauri config, and
tests/studio/test_tauri_installer_resource_contract.py already enforces it at the
right layer.

The || true on the install.sh lookup stays: it is what lets the explicit
annotation print instead of the step dying on grep's exit 1 under pipefail.

* Cover the uv host matrix and repeat application in the pinned-release test

The pinned path picks an archive per host triple, and a wrong pick installs a
binary that cannot execute, which is worse than not installing at all. Drive
_uv_pinned_asset over 20 host combinations and require each one to return its
own triple or decline to the fallback.

Also run the installer three times over one HOME and require an identical tree,
and require a stale uv at the destination to be replaced rather than joined by a
second copy: the installer is re-run on every upgrade and every repair.

* Pick the pinned uv archive off a positive libc check, not the absence of musl

An independent audit pass found the Linux selector accepts any host whose ldd
output does not say musl. That is not the same question astral's installer asks:
it checks a minimum glibc and drops to its musl-static archive below it, so
three hosts that worked before this branch now get a GNU binary that cannot exec,
and the helper reports success so the fallback never runs.

  aarch64 with glibc below 2.28 (Ubuntu 18.04)
  x86_64 with glibc below 2.17 (RHEL 6)
  a musl image with no ldd at all, where the probe simply finds nothing

Read the version instead, from ldd or getconf, and require it to clear astral's
floor for the triple. Anything unreadable declines to the fallback. Also ask the
userland for its bitness rather than trusting uname on a 64-bit kernel running a
32-bit userland, and follow astral in reading hw.optional.arm64 so a translated
shell under Rosetta 2 still gets the native macOS build.

Three more from the same pass:

Report success only when the destination uv is executable. A copy onto a busy or
read-only destination could leave a file that is not, and reporting success there
skipped the fallback. Nothing is unwound on the failure path on purpose: the
fallback installs over whatever is at the destination, and deleting there would
take out a working uv the host already had.

Clear the mark of the web on the launcher we author. WriteAllText replaces the
unnamed data stream and leaves other NTFS streams alone, so a launch-studio.ps1
that somehow carried one would keep it across the rewrite, and RemoteSigned
refuses a marked unsigned script.

Store the security-block kind and resolve its wording in message(). stdout and
stderr are read by independent threads, so a [TAURI:STEP] written before a block
can be observed after it, and freezing the wording at observation time could tell
a user nothing was changed on a run that had already installed PyTorch. Also
require the error id to appear as the value of a FullyQualifiedErrorId field, so
a scanner log that merely names it cannot attach antivirus guidance to whatever
fails next.

The host matrix in the shell test grows to 28 rows covering every case above, and
removing the new gate fails 8 of them. install.rs gains two tests: 37 pass.

* Replace a symlinked uv destination instead of writing through it

Three from the review on the previous head.

cp onto a destination that is a symlink follows the link, so installing over
`~/.local/bin/uv -> /opt/homebrew/bin/uv` rewrote the Homebrew binary in place
and left the link pointing at a file another package manager owns. Stage next to
the destination and rename over it: rename replaces the link itself, and it is
atomic, so a concurrent reader never sees a half-written uv either. The staging
file is removed when the rename fails, so a failed run leaves no debris.

Verify the Windows copy the same way the shell scripts now do. Copy-Item is
non-terminating under the caller's ErrorActionPreference, so a locked or
ACL-denied destination let execution reach `$haveUv = $true` and the function
reported success over whatever was already there. Compare the destination against
the archive we just verified, so a stale uv.exe cannot pass for the one we meant
to install. install.ps1 carried the same shape and gets the same treatment.

Point the header links at the heading that exists. The README has no "Install
Unsloth Studio"; it is "Unsloth Studio (web UI)", whose anchor is
#unsloth-studio-web-ui.

Three test cases cover the symlink: the file behind the link is untouched, the
link itself is replaced, and no staging file survives. Reverting the fix fails
two of them.

* Fail the build when the uv pin drifts from a version floor

Before the pin, astral's endpoint always delivered the newest uv, so raising
UV_MIN_VERSION was safe on its own. It is not any more: a floor above the pin
means a host with no uv gets 0.12.1 installed and then judged too old by the same
script that installed it, on the one path where the pin is what runs.

Two checks. All four installers must name the same uv, or which version a machine
ends up with depends on which script reached it first. And the pin must clear
every floor in the tree (UV_MIN_VERSION, UV_OFFLINE_MIN_VERSION, $UvMinVersion).
Raising a floor past the pin fails the first, bumping one installer's pin alone
fails the second.

* Write the shell profile entry the pinned uv path no longer gets for free

The P1 here is a real regression and it took a second look to see why.

install.sh decides whether to add ~/.local/bin to the user's shell profile with
`case ":$PATH:"`, near the end of the run. By then this process has prepended
that directory twice, once for the uv bootstrap and once for the venv, so the
guard answers yes for a login shell that would answer no and the profile line is
never written. That was survivable while astral's installer ran, because it wrote
its own profile line and its env file. The pinned path writes neither, so on a
fresh account whose login PATH lacks ~/.local/bin the install succeeds, the
current shell works, and the next terminal cannot find `unsloth` or `uv`.

Snapshot the inherited PATH before anything prepends to it and test the guard
against that.

Two more from the same review.

Honour a configured uv mirror exclusively. UV_INSTALLER_GHE_BASE_URL and
UV_INSTALLER_GITHUB_BASE_URL already win outright in both PowerShell installers
and in astral's own; the shell path ignored them and tried the public hosts
first. A restricted network sets one precisely because those hosts are
unreachable, and download() has no timeout, so it would hang rather than reach
the fallback.

Do not let the twin of an earlier clear erase a later AMSI verdict.
Clear-TauriInstallError writes one logical clear to BOTH streams
(install.ps1:198) and independent threads read them, so a block observed between
a clear and its own twin was discarded by the twin. Ignore a clear identical to
the one just processed; a genuine later recovery carries different text and still
clears. Two tests cover both directions, 39 install tests pass.

* Stage the uv copy under a per-process name

An audit pass reproduced a race I introduced with the symlink fix. Both POSIX
helpers staged through a fixed destination-side name, so two installers targeting
one directory shared it:

  A finishes copying the staging file
  B opens the same path with truncation
  A renames that inode into place as uv
  B keeps writing through its open descriptor, which is now the published uv

The published uv was observable at zero bytes until B resumed, which makes the
claim in the comment about a concurrent reader flatly wrong. install.ps1 is
covered by its named mutex, but nothing serialises the POSIX helpers, and
studio/setup.sh runs standalone on every studio update.

mktemp in the destination directory instead. Each rename then publishes a file no
other process can still be writing, which is what the atomicity argument needed
all along. The loser cleans up its own staging file and declines, so the caller
falls back rather than reporting a success it did not achieve.

* Keep a default install as quiet as it was when a uv mirror misbehaves

Two console regressions from the audit pass, both on paths the install still
recovers from.

download() runs curl -LsSf, and -S deliberately prints its own errors. The
fallback ran under run_maybe_quiet, so a failed download printed nothing before;
the pinned attempts run outside that wrapper, so an unreachable mirror now put
two curl: (N) lines on the console of a default install that then succeeded.
Redirect stderr on the speculative attempts only, leaving download() untouched
for every other caller.

[TAURI:WARN] is a marker level install.sh has never emitted, and the app forwards
unknown markers to its progress UI verbatim (install.rs:639), so a digest
mismatch would have surfaced as raw text in the desktop window. Make it a verbose
only stderr line: the next mirror or the fallback still runs, so a default
install has nothing to say here.

Printed-string diff against the merge base is back to additions inside $(...)
capture plus that one verbose-gated line, with nothing removed or changed.

* Ask the installed uv whether it runs before skipping the fallback

The libc gate reads a glibc version from ldd or getconf and treats that as proof
a GNU binary will execute. It is not. A stripped NixOS-derived image without
nix-ld reports a glibc version through getconf while its loader lives in the Nix
store, so the pinned x86_64 uv asks for /lib64/ld-linux-x86-64.so.2 and gets
nothing. Every static check passed, so the helper reported success, the astral
fallback was skipped, and the first real uv call failed with No such file or
directory. astral's installer fails its own glibc probe on that host and ships
the fully static musl archive, which runs. The user went from a working uv to
none.

The archive is digest-verified astral uv by the time it is placed, so ask it:
run --version and require it to succeed. One exec closes the whole class rather
than this one host, covering a wrong triple, a loader that is not where the
binary looks, and a destination we could not really write.

A test drives an archive whose uv cannot execute and requires the helper to
decline; removing the exec check fails it.

* Pair every clear with its twin, not just the previous one

install.ps1 clears after each recovered step, so a lagging reader can be several
clears behind when a block lands. With clears A then B on one stream and A's twin
arriving on the other after the verdict, asking only whether this is the message
just seen answers no, and the delayed twin discarded the verdict the guidance
exists to explain.

Each logical clear emits exactly two markers, so count unpaired ones by message:
the first sighting is the clear, the next pairs with it. A test drives the A, B,
verdict, A', B' ordering; 40 install tests pass.

* Validate the staged uv before it replaces a working one

My own exec check was on the wrong side of the rename. The sequence that bites:
a host has a uv good enough for UV_OFFLINE_MIN_VERSION but below UV_MIN_VERSION,
so the block runs with _uv_present_before true; the pinned path renames over that
working binary; the --version check then fails because the loader is missing or
the destination is mounted noexec; the fallback download also fails. The
installer neither restores the old uv nor reports that none is available, and
every later command runs the broken one.

Test the staging file instead, before the rename. It sits on the destination
filesystem, so it answers the noexec question too, and a binary that cannot run
here never gets to replace one that could.

Two tests: a working incumbent uv survives an archive whose uv cannot execute,
and the rejected staging file is cleaned up. Moving the check back after the
rename fails the first.

* Stop the AMSI guidance claiming more than it knows

Two of these are honesty defects in text a blocked user reads.

"nothing was changed on this machine" is false. Rust starts a diagnostics
attempt and its phase log before PowerShell is ever spawned, and spawn_script can
create ~/.unsloth first, so a pre-start block has already written to disk. The
honest claim is that no installation step ran.

"This is a false positive" is not something the classifier can know. It proves
the output carries a PowerShell error id and nothing about the script's
integrity, and install.ps1 can sit in a user-writable directory, so a locally
modified copy can earn a genuine verdict. Telling someone to report a correct
detection to their vendor is worse than telling them to reinstall from an
official package first and only escalate if an unmodified copy is still blocked.

Two smaller ones from the same pass. The matcher tested for the field name and
the id independently, so a line naming both in prose qualified; it now requires
the id to follow the colon and end at a comma or whitespace, which is what the
comment always claimed. And the clear-pairing map is bounded: legitimate
producers use a small fixed label set, and child output must not be able to grow
it without limit.

42 install tests pass.

* Honour astral's download override, and stop Unblock-File asking

Three from the second audit round.

Unblock-File declares SupportsShouldProcess at the default Medium impact, so a
profile that sets $ConfirmPreference to Medium or Low gets a prompt from the line
I added, even for a launcher that never carried the stream. -ErrorAction does not
suppress a ShouldProcess prompt, and a noninteractive host turns it into an error
that skips shortcut setup entirely. -Confirm:$false.

UV_DOWNLOAD_URL and its older alias INSTALLER_DOWNLOAD_URL outrank the mirror
variables in astral's installer, and the merge-base path inherited that because it
ran astral's script. All four implementations now honour them first and
exclusively. My earlier comment argued they point at a version the pin would
reject, but that reasoning had it backwards: a host sets one because it cannot
reach the public endpoints, so ignoring it meant public egress first and, with no
timeout on the download, a hang instead of a fallback. The pin still applies, so a
source serving a different build fails the digest and the caller falls back to
astral's installer, which honours the same variable.

chmod 0755 on the staging file rather than +x. cp gives it the umask default and
+x then adds execute only where the umask allowed read, so a umask of 077 left uv
unusable for every other account on a shared machine. astral ships them 0755.

Four checks pin the override precedence across all four installers and the mode
across both shell ones, with the behaviour verified against a stubbed downloader.

* Validate uv before it replaces an incumbent on Windows, and bound the probe

install.ps1 and studio/setup.ps1 copied the extracted uv.exe straight over the
destination and only asked whether it ran afterwards. A host with a working older
uv and a policy (AppLocker, WDAC, endpoint protection) that refuses the new one
was left with neither. Run the extracted binary where it landed first, then keep
a copy of the incumbent across the publish and restore it if the published copy
will not run, since Windows has no atomic replace for a file that may be open.

The probe itself is bounded: Start-Process with a 20s WaitForExit and redirected
streams, and on POSIX no stdin plus a 20s ceiling where timeout exists. A binary
this installer just downloaded must not be able to hang an unattended install by
prompting or by never exiting.

install.sh and studio/setup.sh also published the pinned uvx after rejecting the
pinned uv, leaving a pairing that is never built or tested. A uv that fails to
stage, copy or run now abandons the whole placement.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Verify each uv mirror, and persist PATH when the account has no rc file

Both PowerShell installers checked the archive digest once, after the download
loop had already broken out. A captive portal or a proxy answering 200 with its
own body is a successful download by every measure Invoke-WebRequest has, so the
first mirror consumed the only attempt and the second, healthy one was never
tried. The digest now decides whether a mirror counts as served.

install.sh picked a shell profile from .zshrc, .bashrc or .profile and did
nothing when none existed. A fresh account has none: astral's installer used to
create its own PATH setup there, the pinned path does not, so the next terminal
resolved neither unsloth nor uv. Fall back to creating ~/.profile, which every
POSIX login shell reads. The existing content guard keeps it written once.

* Remove the install.sh a Windows upgrade would otherwise keep forever

Windows bundles now carry only install.ps1, but NSIS writes the current resource
manifest and deletes nothing, and the uninstaller deletes only what is in that
manifest. An in-place upgrade from a release that bundled both installers left
install.sh in $INSTDIR permanently, which also made the non-recursive
RMDir "$INSTDIR" fail at uninstall. The pre-install and pre-uninstall hooks now
delete it, so the population most likely to upgrade actually gets the split.

Also silence the speculative mktemp -d in the pinned uv path: its failure falls
back to astral's installer, so an unusable TMPDIR printed a line the user could
not act on and that the merge base did not print.

* Remove the pinned uv temporaries when an install is interrupted

The pinned path unpacks a 40 MB archive into a work directory and stages the
binary next to the destination, but only cleaned both up when the helper returned
normally. A Ctrl-C in between left the archive behind and left a staging file
inside a directory that is on PATH. Both paths are now published to the exit and
signal traps as they are created and cleared when the helper releases them.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Persist the PATH the way each shell actually reads it, and fail a half-published pair

Four follow-ups from review:

A uvx that the archive carried but that could not be staged or renamed left uv
published next to a stale or missing uvx and still reported success, skipping the
fallback that would have installed both. Either half failing now fails the
placement, in install.sh and studio/setup.sh.

studio/setup.sh had none of the interrupt cleanup install.sh gained: a Ctrl-C
left the unpacked archive behind and a staging file inside a directory on PATH.
It now owns HUP, INT, TERM and EXIT for the duration of the pinned install and
hands them back on the way out.

fish sources none of the POSIX rc files, so the ~/.profile fallback was a no-op
for a fish user. The persistence helper writes a conf.d drop-in with
fish_add_path there, and honours ZDOTDIR for zsh.

UV_INSTALL_DIR, UV_UNMANAGED_INSTALL, XDG_BIN_HOME and XDG_DATA_HOME can put uv
somewhere other than ~/.local/bin, and astral's installer wrote a PATH line for
whichever it picked. The pinned path now persists its own destination too, with
UV_NO_MODIFY_PATH honoured as astral honours it.

* Make the Windows uv publish a real transaction, and quote persisted paths

The companion copies ran bare: under install.ps1's Stop preference a locked or
ACL-denied destination threw past the rollback and left a mismatched set with the
backups still on disk, and under setup.ps1's Continue preference it kept a stale
companion and reported success. Both now copy under -ErrorAction Stop inside the
transaction, so any failure unwinds like the others.

A failed restore also used to delete the backup anyway, which is the one path in
this block that could leave the host with less than it started with: the two
things that make a restore fail, an open incumbent and a denied ACL, are the same
two that made the replace risky. The backup is now kept and named.

fish takes an unquoted path with a space as two directories, neither of which
exists, so the drop-in single-quotes it; and the rc line is written inside double
quotes, so a uv directory holding a dollar or a backtick is escaped. The second
test caught a doubled backslash in the escaper itself.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Tighten the comments added by this branch

Comments only, no code touched: 161 comment lines become 106 across install.sh,
install.ps1, studio/setup.sh, studio/setup.ps1 and the NSIS hooks. Each one keeps
the reason it was written for, said once.

Verified with the PowerShell AST parser, sh -n and bash -n, the 50-check uv
pinned release suite and 114 installer tests, and by confirming the diff contains
no non-comment line.

* Tighten the install.rs comments too

Comments only: 35 lines become 27, each keeping the reason it was written for.
42 install tests pass and the diff contains no non-comment line.

* Abort on a companion that cannot be backed up, and pair clears by stream

A uvx.exe that could not be copied aside, because it is locked or its ACL denies
reads, was skipped and the new uv.exe published anyway, so the function reported
success with a mismatched pair and the fallback never ran. Any backup failure now
fails the placement and runs the rollback, in install.ps1 and studio/setup.ps1.

The ERROR_CLEAR pairing keyed only on the message, so two real clears of one label
on one stream were taken for a clear and its twin. That happens:
_install_torch_default_index emits its recovery during the install and again
during the ROCm repair. A verdict landing between them was then erased by the
genuinely later clear arriving on the other stream. The map is keyed by stream as
well, so only the opposite stream's copy can consume a pending marker.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Do not fail an install because the uv probe could not get an answer

Three clean-machine CI legs that pass on main failed on this branch: arm64 and
two Windows containers, all three with winget unavailable, which is the only
condition under which the pinned fallback runs. Each downloaded the right asset,
passed the digest, and then failed the probe. Start-Process -NoNewWindow with
redirected streams does not behave in a container or on the arm64 image the way
it does in a desktop session, and a boolean probe reported that as a broken
binary and aborted the install.

The probe is now tri-state. Only the binary answering non-zero is a failure. A
launch that throws or a wait that times out is inconclusive, and since the digest
already proved the bytes are astral's pinned release, an inconclusive probe
publishes as the pre-pin code did. Every path prints why, with the captured
stderr and the exit code, so the next occurrence is not opaque.

Also from review: the POSIX path now stages both binaries and publishes them
together with the incumbents saved aside, so a failed uvx rename restores the
uv it replaced instead of leaving a new uv beside a stale uvx; the Windows
rollback records the destination before the copy that can truncate it; and
UV_UNMANAGED_INSTALL suppresses the profile write, as it does for astral.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Give setup.sh the same pair publish and PATH persistence as install.sh

studio/setup.sh published uv and then uvx one after the other, so a failed uvx
rename left a new uv beside the host's stale one, and the remote fallback can be
unavailable. It now stages both, validates uv, and publishes the two renames back
to back with the incumbents saved aside, restoring them if the second fails.

setup.sh is also run directly for local and Colab setup, where astral's installer
used to write the profile line for whichever destination it chose. Without one the
PATH export died with that shell and every later run reinstalled uv. It now
persists its own destination, with fish handled on its own terms and both of
astral's opt-outs honoured.

* Treat an empty uv exit code as no verdict, not as a failure

The arm64 clean-machine leg still failed on the tri-state probe, and the
diagnostic that came with it said why: "uv --version exited ." with no number.
WaitForExit(ms) can return before the exit code is cached, so ExitCode was empty
and an empty value is not 0, which read a working uv as broken.

The parameterless WaitForExit settles it and returns at once because the process
has already exited, and a code that is still missing is inconclusive rather than
a failure, which is the same rule the launch and timeout paths already follow.
Verified against pwsh that a real non-zero exit and a real launch failure still
classify as failed and unknown respectively.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Eight review fixes across the uv publish and PATH persistence

The fish escaper in studio/setup.sh reached sed as an invalid expression, so a
fish user running setup directly would have had setup killed under set -e right
after uv was published. It now matches the one in install.sh, and the test runs
both escapers rather than reading them.

An incumbent that cannot be hard-linked or copied cannot be restored either, so
publishing over it would be a one-way move. Both shells now decline. Writing that
test turned up that my own rollback deleted both incumbents when nothing had been
published, since the no-predecessor branch cannot tell the two cases apart; the
rollback is now reached only after a publish was attempted.

A rollback with no predecessor removes the binary it published, rather than
leaving half a pair the host never had.

A signal between the two renames left the undo copy as the only reference to the
incumbent, and the handler deleted it. It restores it now, in both shells.

setup.sh prepended ~/.local/bin unconditionally after a successful pinned
install, so a stale uv there could shadow a custom UV_INSTALL_DIR destination and
the rest of setup would run the wrong one. That prepend is now only for astral's
installer, which is what writes there.

PATH entries are compared literally rather than as case patterns, so a
destination holding *, ? or [ is not mistaken for an unrelated entry.

On Windows, a .unsloth-old left behind by a failed restore is the only copy of a
working uv, and the next run reused that exact name. It takes a distinct one.

* Keep the pinned uv first on PATH, and only count an active profile entry

install.sh prepends ~/.local/bin after the uv bootstrap, and astral's env file
does too, so a custom UV_INSTALL_DIR destination was pushed behind a stale uv
sitting in the home directory and every bare uv below picked the wrong one. The
pinned destination goes back in front. setup.sh had the same shape and was fixed
in defd2292a.

The profile check treated any occurrence of the destination text as proof the
PATH entry was already there, so a commented-out old export, or /opt/uv-old when
the destination is /opt/uv, suppressed the write and left the next shell without
uv. Comments are stripped and the directory has to appear as a whole entry.

* Gate the NSIS tidy-up, and remove an orphan uv on signal

The pre-install hook runs before the user can still cancel, and $INSTDIR can be a
directory they picked in the GUI, so deleting install.sh there could take a file
that was never ours. Both hooks now only act where our own executable already is.

A signal between the two renames restored a predecessor but did nothing when
there was none, leaving a 0.12.1 uv beside whatever uvx the machine had. It now
removes what it published, which is what the ordinary rollback already does.

* Write the uv PATH entry to every startup file astral's installer wired

astral's uv installer wires ~/.profile, each of .bashrc, .bash_profile and
.bash_login that exists, .zshrc or .zshenv under ZDOTDIR, and a fish drop-in
under ~/.config. Replacing that installer with a pinned archive meant the PATH
entry only reached the one file for whichever shell happened to be running, so a
bash user whose .bash_profile does not source .bashrc, a /bin/sh login, or anyone
who later switched shells would have no uv on PATH where they used to.

Both POSIX installers now write the same set, once each, with the existing
whole-entry check keeping a re-run idempotent. Files that do not exist are not
created, apart from ~/.profile, which astral creates too.

* Cut the uv publish back to what the common case needs

The rollback machinery that grew over the review rounds covered cases a user is
very unlikely to meet: an incumbent that cannot be hard-linked, a signal landing
between two renames, a restore that itself fails, a second installer racing the
first. It was 281 net lines, and every finding in the last two rounds was in it
rather than in the hardening.

What stays is what the common case needs. POSIX stages both binaries, runs the
staged uv, and publishes the pair with two renames; a failure anywhere before
them leaves the destination untouched, and the caller falls back to astral's
installer exactly as before. Windows probes the extracted uv.exe before touching
the destination, then copies the three under -ErrorAction Stop and re-checks the
digest at the destination.

The staging files are still removed on a signal, since they live in a directory
that is on PATH. 64 shell checks and 114 installer tests cover the rest.

* Match the exact fish entry, and let a UNC launcher load

The fish drop-in is the only thing that puts uv on a fish user's PATH, since fish
reads none of the POSIX files, and its check treated any occurrence of the
directory as proof: /opt/uv-old suppressed /opt/uv. It now matches the exact
fish_add_path line it would write.

A launcher on a UNC share is a remote script to PowerShell, and RemoteSigned
refuses an unsigned one, so a roaming profile got a shortcut that exits without
starting Studio. That case, and only that case, uses Bypass, and drops
-WindowStyle Hidden with it so the pair the detections key on never appears.

* Wire every startup file on a DEFAULT install too, and give setup.ps1 a fallback

The all-profile PATH write was gated on the uv destination differing from
~/.local/bin, which is exactly where a normal install puts it, so every ordinary
machine still got the single-file write the shim path has always done. Three
independent audits found this. The gate is gone, and the idempotency check now
also matches the $HOME-relative spelling the shim block writes, so the default
case does not end up with two lines for one directory.

studio/setup.ps1 replaced astral's installer with the pinned archive and had
nothing to fall back to. A failed pinned install therefore left UseUv false and
silently ran torch, bitsandbytes, Triton and the rest through pip: a different
resolver, not just a different download. winget is the fallback, as install.ps1
already does, rather than the remote script this branch exists to remove.

* Read the pinned install's real result, and three narrower publish guards

The winget fallback I added last round read Invoke-SetupCommand's return value,
which is [int]$LASTEXITCODE rather than the function's $true, so it fired on
every run: a redundant managed install, and a second copy on a machine that asked
for UV_UNMANAGED_INSTALL. The function records its own success on the script
scope and the fallback reads that.

A directory named uv at the destination looked like a published binary: mv moves
into it and reports success, and a searchable directory passes -x, so the install
reported success and the first later uv call failed instead. Both shells refuse a
directory target, as the installer already does for its own shim.

The PATH idempotency pattern escaped only part of the ERE metacharacter set, so a
destination holding + ( or | did not match itself and every reinstall appended
another block to every profile.

* Restrict the profile duplicate check to PATH lines, and cover mapped drives for PR #8586

* Match only PATH-setting lines in the profile duplicate check for PR #8586

* Tighten the installer comments added by PR #8586

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: danielhanchen <danielhanchen@users.noreply.github.com>
2026-08-13 07:02:18 -07:00

1172 lines
48 KiB
Python

# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"""Unit tests for the advisory VirusTotal release asset scan.
The scan is a sweep of the bundles `publish-release` uploaded, run in the
`virustotal-scan` job after it. Those bundles are attached to a draft on the
default dispatch and to a published release otherwise, which is why neither the
job nor the summary heading claims a publication. It is not a gate and cannot
hold a release back; Defender in the build job is the fail-closed check.
Offline by design: every test injects a fake transport, so the suite never spends
the account's 500/day quota and never uploads a build. The two behaviours worth
protecting are the ones a release depends on:
- a missing API key must skip, never fail, or a contributor without the org
secret cannot publish at all,
- the bundles are 41-46 MB, over the 32 MB cap on `POST /files`, so the upload
must go through `GET /files/upload_url`. A regression to the plain endpoint
would fail on every asset.
"""
from __future__ import annotations
import fnmatch
import importlib.util
import itertools
import pathlib
import shlex
import sys
import time
import pytest
REPO_ROOT = pathlib.Path(__file__).resolve().parents[2]
MODULE_PATH = REPO_ROOT / "scripts" / "virustotal_scan.py"
def _load_module():
spec = importlib.util.spec_from_file_location("virustotal_scan", MODULE_PATH)
if spec is None or spec.loader is None:
pytest.skip(f"cannot import {MODULE_PATH}")
module = importlib.util.module_from_spec(spec)
sys.modules["virustotal_scan"] = module
spec.loader.exec_module(module)
return module
vt = _load_module()
class FakeTransport:
"""Records every call and replays a queued (status, body) per URL fragment."""
def __init__(self, routes: dict[str, tuple[int, bytes]]):
self.routes = routes
self.calls: list[tuple[str, str, dict, int]] = []
self.timeouts: list[float | None] = []
def __call__(
self,
method,
url,
headers,
body,
timeout = None,
):
self.timeouts.append(timeout)
self.calls.append((method, url, headers, len(body or b"")))
for fragment, response in self.routes.items():
if fragment in url:
return response
raise AssertionError(f"unrouted request: {method} {url}")
def _client(routes):
transport = FakeTransport(routes)
client = vt.VirusTotalClient(
"fake-key",
transport = transport,
request_interval = 0.0,
sleep = lambda _seconds: None,
)
return client, transport
class TestParseStats:
def test_missing_keys_default_to_zero(self):
stats = vt.parse_stats({"malicious": 2})
assert (stats.malicious, stats.suspicious, stats.undetected) == (2, 0, 0)
def test_non_dict_is_tolerated(self):
assert vt.parse_stats(None) == vt.ScanStats()
assert vt.parse_stats([1, 2]) == vt.ScanStats()
def test_confirmed_timeout_folds_into_timeout(self):
assert vt.parse_stats({"timeout": 1, "confirmed-timeout": 2}).timeout == 3
def test_booleans_are_not_counted_as_ints(self):
# bool is a subclass of int; True must not silently become 1 detection.
assert vt.parse_stats({"malicious": True}).malicious == 0
def test_flagged_sums_malicious_and_suspicious(self):
assert vt.parse_stats({"malicious": 3, "suspicious": 4}).flagged == 7
class TestParseDetections:
def test_only_malicious_and_suspicious_are_reported(self):
names = vt.parse_detections(
{
"AlphaAV": {"category": "malicious", "result": "Trojan.Gen"},
"BetaAV": {"category": "undetected"},
"GammaAV": {"category": "suspicious", "result": None},
"DeltaAV": {"category": "harmless"},
}
)
assert names == ["AlphaAV (Trojan.Gen)", "GammaAV"]
def test_non_dict_is_tolerated(self):
assert vt.parse_detections("nope") == []
class TestThreshold:
def _reports(self, flagged):
return [vt.FileReport(name = "a.exe", stats = vt.ScanStats(malicious = flagged))]
def test_zero_threshold_is_advisory_only(self):
# The shipped default. Detections must never fail the release.
assert vt.exceeds_threshold(self._reports(50), 0) is False
assert vt.exceeds_threshold(self._reports(50), -1) is False
def test_positive_threshold_fails_at_or_above(self):
assert vt.exceeds_threshold(self._reports(3), 3) is True
assert vt.exceeds_threshold(self._reports(2), 3) is False
def test_rows_without_stats_never_trip_the_gate(self):
assert vt.exceeds_threshold([vt.FileReport(name = "a.exe")], 1) is False
class TestSelectScanTargets:
def test_sig_sidecars_are_skipped(self, tmp_path):
for name in (
"Unsloth-Desktop-0_1_1-Windows.exe",
"Unsloth-Desktop-0_1_1-Windows.exe.sig",
"Unsloth-Desktop-0_1_1-Linux.AppImage",
"Unsloth-Desktop-0_1_1-Linux.AppImage.sig",
):
(tmp_path / name).write_bytes(b"x")
names = [path.name for path in vt.collect_paths([tmp_path])]
assert names == [
"Unsloth-Desktop-0_1_1-Linux.AppImage",
"Unsloth-Desktop-0_1_1-Windows.exe",
]
def test_directories_are_expanded_and_files_passed_through(self, tmp_path):
(tmp_path / "a.dmg").write_bytes(b"x")
assert [p.name for p in vt.collect_paths([tmp_path / "a.dmg"])] == ["a.dmg"]
class TestMissingKey:
def test_missing_key_skips_without_failing(self, tmp_path, monkeypatch, capsys):
monkeypatch.delenv(vt.API_KEY_ENV, raising = False)
(tmp_path / "a.exe").write_bytes(b"x")
summary = tmp_path / "summary.md"
rc = vt.main([str(tmp_path), "--output-markdown", str(summary)])
assert rc == 0
assert "Skipped: no API key" in summary.read_text()
out = capsys.readouterr().out
assert "skipping the scan" in out
# The message spells VT_API_KEY out literally to avoid a CodeQL
# false positive, so pin that it still matches the constant.
assert vt.API_KEY_ENV in out
def test_whitespace_only_key_is_treated_as_missing(self, tmp_path, monkeypatch):
monkeypatch.setenv(vt.API_KEY_ENV, " ")
(tmp_path / "a.exe").write_bytes(b"x")
assert vt.main([str(tmp_path)]) == 0
class TestLargeFileUploadFlow:
def test_upload_uses_the_signed_url_not_the_32mb_endpoint(self, tmp_path):
bundle = tmp_path / "big.exe"
bundle.write_bytes(b"payload")
signed = "https://upload.virustotal.example/receive?sig=secret"
client, transport = _client(
{
"/files/upload_url": (200, b'{"data": "' + signed.encode() + b'"}'),
"upload.virustotal.example": (200, b'{"data": {"id": "analysis-1"}}'),
}
)
assert client.upload(bundle) == "analysis-1"
methods_urls = [(m, u) for m, u, _h, _n in transport.calls]
assert methods_urls[0] == ("GET", f"{vt.API_ROOT}/files/upload_url")
assert methods_urls[1][0] == "POST"
assert methods_urls[1][1] == signed
# The plain 32 MB-capped endpoint must never be used for a bundle.
assert all(u.rstrip("/") != f"{vt.API_ROOT}/files" for _m, u in methods_urls)
def test_upload_body_is_multipart_with_the_file_field(self, tmp_path):
bundle = tmp_path / "big.exe"
bundle.write_bytes(b"payload")
body, content_type = vt._build_multipart(bundle)
assert content_type.startswith("multipart/form-data; boundary=")
assert b'name="file"' in body
assert b'filename="big.exe"' in body
assert b"payload" in body
def test_api_key_is_sent_as_a_header_never_in_the_url(self, tmp_path):
client, transport = _client({"/files/": (200, b"{}")})
client.lookup_hash("a" * 64)
_method, url, headers, _n = transport.calls[0]
assert headers["x-apikey"] == "fake-key"
assert "fake-key" not in url
class TestHashLookupFirst:
def test_known_hash_short_circuits_the_upload(self, tmp_path):
bundle = tmp_path / "known.exe"
bundle.write_bytes(b"payload")
client, transport = _client(
{
"/files/": (
200,
b'{"data": {"attributes": {"last_analysis_stats": '
b'{"malicious": 1}, "last_analysis_results": '
b'{"AlphaAV": {"category": "malicious", "result": "X"}}}}}',
),
}
)
report = vt.scan_file(client, bundle, deadline = float("inf"))
assert report.source == "known to VirusTotal (no upload)"
assert report.stats.malicious == 1
assert report.detections == ["AlphaAV (X)"]
# Exactly one call: the lookup. No upload_url, no upload, no polling.
assert len(transport.calls) == 1
def test_unknown_hash_falls_through_to_upload(self, tmp_path):
bundle = tmp_path / "new.exe"
bundle.write_bytes(b"payload")
client, transport = _client(
{
"/files/upload_url": (200, b'{"data": "https://up.example/x"}'),
"up.example": (200, b'{"data": {"id": "an-1"}}'),
"/analyses/": (
200,
b'{"data": {"attributes": {"status": "completed", '
b'"stats": {"malicious": 0}, "results": {}}}}',
),
"/files/": (404, b"{}"),
}
)
report = vt.scan_file(client, bundle, deadline = float("inf"))
assert report.source == "uploaded"
assert report.stats.malicious == 0
class TestFailureDegradation:
def test_transport_failure_degrades_to_a_note_not_an_exception(self, tmp_path):
bundle = tmp_path / "a.exe"
bundle.write_bytes(b"payload")
client, _transport = _client({"/files/": (500, b"")})
report = vt.scan_file(client, bundle, deadline = float("inf"))
assert report.source == "unavailable"
assert report.note
assert report.stats is None
def test_redact_url_strips_the_signed_query_string(self):
assert vt._redact_url("https://up.example/x?sig=secret") == "https://up.example/x"
class TestSignedUrlMasking:
"""The signed upload URL is a credential and is NOT a registered GitHub secret,
so the runner will not mask it unless we register it with ::add-mask::."""
def test_upload_registers_the_signed_url_with_add_mask(self, tmp_path, monkeypatch, capsys):
monkeypatch.setenv("GITHUB_ACTIONS", "true")
bundle = tmp_path / "big.exe"
bundle.write_bytes(b"payload")
signed = "https://upload.virustotal.example/receive?sig=secret-credential"
client, _transport = _client(
{
"/files/upload_url": (200, b'{"data": "' + signed.encode() + b'"}'),
"upload.virustotal.example": (200, b'{"data": {"id": "an-1"}}'),
}
)
client.upload(bundle)
out = capsys.readouterr().out
assert f"::add-mask::{signed}" in out
# Masking must happen before the URL is used, not after.
assert out.index("::add-mask::") == 0
def test_no_workflow_commands_off_the_runner(self, tmp_path, monkeypatch, capsys):
monkeypatch.delenv("GITHUB_ACTIONS", raising = False)
bundle = tmp_path / "big.exe"
bundle.write_bytes(b"payload")
client, _transport = _client(
{
"/files/upload_url": (200, b'{"data": "https://up.example/x?sig=s"}'),
"up.example": (200, b'{"data": {"id": "an-1"}}'),
}
)
client.upload(bundle)
assert "::add-mask::" not in capsys.readouterr().out
def test_empty_value_is_not_registered(self, monkeypatch, capsys):
monkeypatch.setenv("GITHUB_ACTIONS", "true")
vt._mask_in_actions("")
assert capsys.readouterr().out == ""
class TestSingleUseUploadUrl:
"""A signed upload URL is single use, so replaying one can only ever be
rejected. A failed upload must go back for a fresh URL instead."""
def test_upload_post_is_not_retried_on_the_same_url(self, tmp_path):
bundle = tmp_path / "big.exe"
bundle.write_bytes(b"payload")
seen_upload_urls = []
class Transport:
def __init__(self):
self.posts = 0
def __call__(
self,
method,
url,
headers,
body,
timeout = None,
):
if url.endswith("/files/upload_url"):
token = f"https://up.example/{len(seen_upload_urls)}"
seen_upload_urls.append(token)
return 200, b'{"data": "' + token.encode() + b'"}'
self.posts += 1
if self.posts == 1:
return 500, b"" # server-side blip on the first signed URL
return 200, b'{"data": {"id": "an-2"}}'
transport = Transport()
client = vt.VirusTotalClient(
"k", transport = transport, request_interval = 0.0, sleep = lambda _s: None
)
assert client.upload(bundle) == "an-2"
# Two distinct signed URLs were fetched: the failed POST was not replayed.
assert len(seen_upload_urls) == 2
assert transport.posts == 2
def test_max_attempts_one_disables_retry(self, tmp_path):
calls = []
def transport(
method,
url,
headers,
body,
timeout = None,
):
calls.append(url)
return 500, b""
client = vt.VirusTotalClient(
"k", transport = transport, request_interval = 0.0, sleep = lambda _s: None
)
with pytest.raises(RuntimeError):
client.request("POST", "https://up.example/x", max_attempts = 1)
assert len(calls) == 1
class TestDeadlineEnforcement:
"""One attempt can block for the full socket timeout, so the deadline has to be
checked BEFORE a request, not after, or the step timeout kills the process
before any summary is written."""
def test_request_checks_deadline_before_issuing(self):
calls = []
def transport(
method,
url,
headers,
body,
timeout = None,
):
calls.append(url)
return 200, b"{}"
client = vt.VirusTotalClient(
"k",
transport = transport,
request_interval = 0.0,
sleep = lambda _s: None,
clock = lambda: 1000.0,
)
with pytest.raises(TimeoutError):
client.request("GET", "https://api.example/x", deadline = 999.0)
assert calls == []
def test_wait_for_analysis_stops_at_the_deadline(self):
def transport(
method,
url,
headers,
body,
timeout = None,
):
return 200, b'{"data": {"attributes": {"status": "queued"}}}'
now = [0.0]
client = vt.VirusTotalClient(
"k",
transport = transport,
request_interval = 0.0,
sleep = lambda _s: None,
clock = lambda: now[0],
)
with pytest.raises(TimeoutError):
now[0] = 100.0
client.wait_for_analysis("an-1", deadline = 50.0)
def test_scan_file_reports_a_timeout_row_rather_than_raising(self, tmp_path):
bundle = tmp_path / "a.exe"
bundle.write_bytes(b"payload")
client = vt.VirusTotalClient(
"k",
transport = lambda *a: (200, b"{}"),
request_interval = 0.0,
sleep = lambda _s: None,
clock = lambda: 1000.0,
)
report = vt.scan_file(client, bundle, deadline = 0.0)
assert report.source == "timed out"
assert report.note
def test_summary_is_still_written_when_every_asset_times_out(self, tmp_path, monkeypatch):
monkeypatch.setenv(vt.API_KEY_ENV, "k")
(tmp_path / "a.exe").write_bytes(b"x")
summary = tmp_path / "s.md"
rc = vt.main(
[
str(tmp_path),
"--output-markdown",
str(summary),
"--timeout-seconds",
"0",
"--request-interval",
"0",
]
)
assert rc == 0
assert vt.SUMMARY_HEADING in summary.read_text()
class TestRenderMarkdown:
def test_advisory_footer_when_threshold_disabled(self):
text = vt.render_markdown(
[vt.FileReport(name = "a.exe", stats = vt.ScanStats(), sha256 = "ab")], 0
)
assert "Advisory only" in text
assert "never fail the release" in text
def test_threshold_footer_when_enabled(self):
text = vt.render_markdown([vt.FileReport(name = "a.exe", stats = vt.ScanStats())], 4)
assert "Failure threshold: 4" in text
def test_flagging_engines_are_listed(self):
text = vt.render_markdown(
[
vt.FileReport(
name = "a.exe", stats = vt.ScanStats(malicious = 1), detections = ["AlphaAV (Trojan)"]
)
],
0,
)
assert "Flagging engines" in text
assert "AlphaAV (Trojan)" in text
def test_a_flagged_asset_gets_a_submission_packet(self):
# The build job only ever assembled a packet for the Windows -setup.exe, so the one
# detection that actually arrived -- Trojan:Script/Wacatac.B!ml on the Linux AppImage --
# produced nothing to submit.
text = vt.render_markdown(
[
vt.FileReport(
name = "Unsloth-Desktop-Linux.AppImage",
sha256 = "e3aa9b36",
size = 46193144,
stats = vt.ScanStats(malicious = 1, undetected = 62),
detections = ["Microsoft (Trojan:Script/Wacatac.B!ml)"],
)
],
0,
)
assert "False-positive submission packet" in text
assert "Unsloth-Desktop-Linux.AppImage" in text
assert "e3aa9b36" in text
assert "46193144 bytes" in text
assert "wdsi/filesubmission" in text
def test_a_flagged_asset_with_no_readable_engine_list_still_gets_a_packet(self):
# stats and results are separate fields of the same response. The table reports the
# count, so the packet has to key on the same thing or it skips the one asset that
# needs one.
text = vt.render_markdown(
[
vt.FileReport(
name = "a.exe",
sha256 = "ab",
size = 10,
stats = vt.ScanStats(malicious = 1, undetected = 60),
detections = [],
)
],
0,
)
assert "False-positive submission packet" in text
assert "Flagging engines" not in text
def test_a_clean_run_gets_no_submission_packet(self):
text = vt.render_markdown(
[vt.FileReport(name = "a.exe", sha256 = "ab", stats = vt.ScanStats(undetected = 60))], 0
)
assert "False-positive submission packet" not in text
class TestFailClosedOnMalformedLookup:
"""A 200 whose body does not parse must not be read as 'never seen'.
Returning None there is indistinguishable from a 404 and uploads the bundle,
which is an unnecessary disclosure of an unreleased build.
"""
def test_malformed_200_does_not_upload(self, tmp_path):
bundle = tmp_path / "draft.exe"
bundle.write_bytes(b"unreleased build")
client, transport = _client(
{
"/files/upload_url": (200, b'{"data": "https://up.example/x"}'),
"up.example": (200, b'{"data": {"id": "an-1"}}'),
"/files/": (200, b"<html>proxy error page</html>"),
}
)
report = vt.scan_file(client, bundle, deadline = float("inf"))
assert report.source == "unavailable"
assert "malformed" in report.note
assert not any("up.example" in url for _m, url, _h, _n in transport.calls)
def test_malformed_200_is_distinguishable_from_404(self, tmp_path):
client, _ = _client({"/files/": (200, b"not json")})
with pytest.raises(RuntimeError, match = "malformed"):
client.lookup_hash("a" * 64)
client, _ = _client({"/files/": (404, b"{}")})
assert client.lookup_hash("a" * 64) is None
class TestDeadlineIsNotOverrunByThrottling:
"""Pacing sleeps between the deadline check and the network call."""
def _clocked_client(
self,
routes,
interval = 20.0,
):
now = [1000.0]
transport = FakeTransport(routes)
def sleep(seconds):
now[0] += seconds
client = vt.VirusTotalClient(
"k",
transport = transport,
request_interval = interval,
sleep = sleep,
clock = lambda: now[0],
)
return client, transport, now
def test_transport_never_starts_after_the_deadline(self):
client, transport, now = self._clocked_client({"x.example": (200, b"{}")})
client._last_request_at = now[0] # force a full interval of pacing
deadline = now[0] + 5.0 # less budget than the pacing needs
with pytest.raises(TimeoutError, match = "pacing"):
client.request("GET", "https://x.example/y", deadline = deadline)
assert transport.calls == []
def test_throttle_sleep_is_capped_by_the_deadline(self):
client, _transport, now = self._clocked_client({"x.example": (200, b"{}")})
client._last_request_at = now[0]
deadline = now[0] + 5.0
client._throttle(deadline)
# Capped at the 5s of remaining budget, not the full 20s interval.
assert now[0] == pytest.approx(1005.0)
def test_a_request_with_budget_left_still_proceeds(self):
client, transport, now = self._clocked_client({"x.example": (200, b"{}")})
client._last_request_at = now[0]
status, _payload = client.request("GET", "https://x.example/y", deadline = now[0] + 600.0)
assert status == 200
assert len(transport.calls) == 1
class TestSocketBudgetIsClampedToTheDeadline:
"""The per-call socket timeout has to respect the scan deadline.
Otherwise a call that starts just before the deadline still blocks for the
full socket timeout and eats the cushion the step needs to write its summary.
"""
def test_socket_timeout_is_clamped_to_remaining_budget(self):
client, transport = _client({"x.example": (200, b"{}")})
client.request("GET", "https://x.example/y", deadline = time.monotonic() + 30.0)
assert transport.timeouts[0] <= 30.0
def test_socket_timeout_is_the_default_when_budget_is_large(self):
client, transport = _client({"x.example": (200, b"{}")})
client.request("GET", "https://x.example/y", deadline = time.monotonic() + 100000.0)
assert transport.timeouts[0] == vt._SOCKET_TIMEOUT
def test_socket_timeout_without_a_deadline_is_the_default(self):
client, transport = _client({"x.example": (200, b"{}")})
client.request("GET", "https://x.example/y")
assert transport.timeouts[0] == vt._SOCKET_TIMEOUT
def test_clamp_never_goes_to_zero_or_negative(self):
# A non-positive urlopen timeout would fail instantly rather than try.
client, transport = _client({"x.example": (200, b"{}")})
client.request("GET", "https://x.example/y", deadline = time.monotonic() + 0.001)
assert transport.timeouts[0] >= 1.0
class TestMalformedUploadAcknowledgement:
"""An accepted upload whose ack did not parse is a failed attempt.
Raising straight out reports the asset unavailable after we already paid the
disclosure cost of sending the bundle.
"""
def test_malformed_ack_retries_with_a_fresh_signed_url(self, tmp_path):
bundle = tmp_path / "big.exe"
bundle.write_bytes(b"payload")
state = {"n": 0}
def transport(
method,
url,
headers,
body,
timeout = None,
):
if "/files/upload_url" in url:
state["n"] += 1
return (200, b'{"data": "https://up.example/%d"}' % state["n"])
# First ack is unparseable, second is well formed.
if state["n"] == 1:
return (200, b"not json")
return (200, b'{"data": {"id": "an-2"}}')
client = vt.VirusTotalClient(
"k", transport = transport, request_interval = 0.0, sleep = lambda _s: None
)
assert client.upload(bundle) == "an-2"
assert state["n"] == 2 # a second, fresh signed URL was fetched
def test_malformed_ack_on_the_last_attempt_raises(self, tmp_path):
bundle = tmp_path / "big.exe"
bundle.write_bytes(b"payload")
def transport(
method,
url,
headers,
body,
timeout = None,
):
if "/files/upload_url" in url:
return (200, b'{"data": "https://up.example/x"}')
return (200, b"not json")
client = vt.VirusTotalClient(
"k", transport = transport, request_interval = 0.0, sleep = lambda _s: None
)
with pytest.raises(RuntimeError, match = "analysis id"):
client.upload(bundle)
class TestNoCompletedAnalysis:
"""A known hash with no finished analysis must not read as clean. Reporting
zero detections when no engine ran is the worst outcome available here."""
def _report(
self,
attributes,
completed = False,
):
report = vt.FileReport(name = "a.exe")
vt._record(report, "known to VirusTotal (no upload)", *attributes, completed = completed)
return report
def test_a_completed_analysis_is_trusted_without_engine_counts(self):
# The upload path polls until status == "completed", so a stats dict is
# authoritative there even if the counts are all zero.
report = self._report(({"malicious": 0}, {}), completed = True)
assert report.stats is not None
assert report.source == "known to VirusTotal (no upload)"
def test_a_completed_analysis_still_needs_a_stats_object(self):
assert self._report((None, {}), completed = True).stats is None
def test_missing_stats_is_not_reported_as_clean(self):
report = self._report((None, None))
assert report.stats is None
assert report.source == "no completed analysis"
assert "unscanned rather than clean" in report.note
def test_all_zero_stats_is_not_reported_as_clean(self):
# A stats dict where no engine reported anything means nothing ran.
report = self._report(({"malicious": 0, "undetected": 0}, {}))
assert report.stats is None
assert report.source == "no completed analysis"
def test_a_real_verdict_is_kept(self):
report = self._report(
(
{"malicious": 0, "undetected": 70},
{"AlphaAV": {"category": "undetected"}},
)
)
assert report.stats is not None
assert report.stats.undetected == 70
assert report.source == "known to VirusTotal (no upload)"
assert report.note == ""
def test_an_unanalysed_row_never_trips_the_gate(self):
# stats=None rows are ignored by the threshold, so this stays advisory.
assert vt.exceeds_threshold([self._report((None, None))], 1) is False
class TestMarkdownEscaping:
"""The summary is a second sink for third-party text, appended to
$GITHUB_STEP_SUMMARY and rendered as Markdown."""
def _summary(self, report):
return vt.render_markdown([report], 0)
def test_a_newline_cannot_break_out_of_a_table_row(self):
report = vt.FileReport(
name = "a.exe",
stats = vt.ScanStats(malicious = 1, undetected = 1),
detections = ["Evil\n| fake | row |"],
)
body = self._summary(report)
bullet = [line for line in body.splitlines() if "Evil" in line]
# The newline is flattened, so the detection stays on its own bullet.
assert len(bullet) == 1
assert "\\|" in bullet[0]
assert "| fake | row |" not in body
def test_html_is_neutralised(self):
report = vt.FileReport(name = "a.exe", note = "<img src=x onerror=alert(1)>")
body = self._summary(report)
assert "&lt;img" in body
assert "<img" not in body
def test_a_backtick_cannot_close_the_code_span(self):
report = vt.FileReport(name = "a`.exe")
assert "`a'.exe`" in self._summary(report)
def test_clean_text_renders_unchanged(self):
report = vt.FileReport(
name = "a.exe",
stats = vt.ScanStats(undetected = 70),
detections = ["AlphaAV (Trojan.Gen)"],
)
assert "- `a.exe`: AlphaAV (Trojan.Gen)" in self._summary(report)
class TestAnnotationEscaping:
"""Engine names, detection labels and error strings are third-party data.
Actions truncates an annotation at the first newline, which would drop the
engine list exactly when the scan is trying to alert a maintainer."""
def test_percent_is_escaped_before_the_newlines(self):
# Order matters: escaping % last would double-encode %0A into %250A.
assert vt._gha_escape("100%\nnext") == "100%25%0Anext"
assert vt._gha_escape("a\r\nb") == "a%0D%0Ab"
def test_clean_text_is_untouched(self):
assert vt._gha_escape("AlphaAV (Trojan.Gen)") == "AlphaAV (Trojan.Gen)"
def test_detection_annotation_stays_on_one_line(self, capsys):
report = vt.FileReport(
name = "a.exe",
stats = vt.ScanStats(malicious = 1),
detections = ["Evil\nAV (Tro%jan)"],
)
vt._emit(report)
annotation = [
line for line in capsys.readouterr().out.splitlines() if line.startswith("::warning")
]
assert len(annotation) == 1
assert "Evil%0AAV (Tro%25jan)" in annotation[0]
def test_note_annotation_is_escaped(self, capsys):
vt._emit(vt.FileReport(name = "a.exe", note = "HTTP 500\r\nbody: 50%"))
annotation = [
line for line in capsys.readouterr().out.splitlines() if line.startswith("::warning")
]
assert len(annotation) == 1
assert "HTTP 500%0D%0Abody: 50%25" in annotation[0]
class TestRetryBackoffRespectsTheDeadline:
"""Retry sleeps grow exponentially, so a late 429 could otherwise sleep well
past --timeout-seconds before the loop notices and writes its summary."""
def _client(self, status, now, slept, interval):
def transport(
method,
url,
headers,
body,
timeout = None,
):
return status, b""
# The retry backoff is seeded from the request interval.
return vt.VirusTotalClient(
"k",
transport = transport,
request_interval = interval,
sleep = slept.append,
clock = lambda: now[0],
)
@pytest.mark.parametrize("status", [429, 503])
def test_backoff_never_sleeps_past_the_deadline(self, status):
slept = []
client = self._client(status, [0.0], slept, interval = 20.0)
# 5s of budget left, but an uncapped backoff would sleep 20s, then 40s.
with pytest.raises((RuntimeError, TimeoutError)):
client.request("GET", "https://api.example/x", deadline = 5.0)
assert slept, "expected the retry path to sleep at all"
assert max(slept) <= 5.0, slept
def test_no_remaining_budget_means_no_sleep_at_all(self):
slept = []
client = self._client(429, [10.0], slept, interval = 20.0)
with pytest.raises((RuntimeError, TimeoutError)):
client.request("GET", "https://api.example/x", deadline = 10.0)
assert slept == []
def test_backoff_is_unbounded_when_no_deadline_is_set(self):
slept = []
client = self._client(429, [0.0], slept, interval = 2.0)
with pytest.raises(RuntimeError):
client.request("GET", "https://api.example/x")
# Full exponential backoff is preserved when there is no budget to respect.
assert {2.0, 4.0, 8.0} <= set(slept), slept
class TestWorkflowOrdering:
"""The scan is a post-publish sweep, and the wiring that makes it run must hold.
There is no pre-publish gate here and there never was one that could block a
release: the scan is advisory by design (Defender in the build job is the
fail-closed check for Windows), and since #8194 it runs in its own
`virustotal-scan` job after `publish-release` rather than inline before the
upload. The bundles are already public by the time it runs.
What is still worth pinning is that the sweep cannot be quietly lost. A
deleted job, a dropped `needs`, an `if:` that never fires, a missing script
checkout or a `|| true` around the invocation would each leave the release
scanned by nothing while the workflow stayed green. The tests below assert
each of those against the workflow YAML.
"""
def _workflow(self):
yaml = pytest.importorskip("yaml")
workflow = REPO_ROOT / ".github" / "workflows" / "release-desktop.yml"
return yaml.safe_load(workflow.read_text(encoding = "utf-8"))
def _publish_step_list(self):
return self._workflow()["jobs"]["publish-release"]["steps"]
def _publish_steps(self):
return [step.get("name") for step in self._publish_step_list()]
def _publish_step_map(self):
return {step.get("name"): step for step in self._publish_step_list()}
def _scan_job(self):
jobs = self._workflow()["jobs"]
assert "virustotal-scan" in jobs, (
"the virustotal-scan job is gone; the release would ship unscanned by "
"anything but Defender"
)
return jobs["virustotal-scan"]
def _scan_step_map(self):
return {step.get("name"): step for step in self._scan_job()["steps"]}
def _scan_step_names(self):
return [step.get("name") for step in self._scan_job()["steps"]]
@staticmethod
def _runner_temp(path):
"""Normalise the three spellings of the runner temp dir to one token.
`with:` uses `${{ runner.temp }}` and `run:` uses `$RUNNER_TEMP`, so two
paths can name one directory and still compare unequal.
"""
normalised = " ".join(str(path).split())
for spelling in ("${{ runner.temp }}", "${RUNNER_TEMP}", "$RUNNER_TEMP"):
normalised = normalised.replace(spelling, "<RUNNER_TEMP>")
return normalised.rstrip("/")
def _scan_script_argv(self):
"""The argv the `VirusTotal scan` step hands to virustotal_scan.py."""
run = self._scan_step_map()["VirusTotal scan"]["run"]
command = run.replace("\\\n", " ")
line = next(
text for text in command.split("\n") if "python3 scripts/virustotal_scan.py" in text
)
argv = shlex.split(line)
return argv[argv.index("scripts/virustotal_scan.py") + 1 :]
def test_the_scan_is_its_own_job_gated_on_publish_release(self):
# Pins the post-publish ordering rather than merely tolerating it: the
# job must exist and must be downstream of publish-release, so dropping
# either the job or the `needs` turns this red.
job = self._scan_job()
assert job["needs"] == ["publish-release"]
def test_the_scan_job_is_not_conditioned_away(self):
# `needs:` alone carries GitHub's default `success()` gating, so whether
# the scan runs is decided by publish-release and nothing else. The job
# therefore carries no `if:` at all, and this rejects every one rather
# than trying to sort the safe conditions from the unsafe.
#
# Sorting them does not work. A job-level `if:` fails in both directions:
# `always()` or `success() || inputs.scan_anyway` sends build artifacts
# to a third party after a publish that failed, while `${{ false }}` or
# `success() && <anything falsey>` silently skips the sweep after a
# publish that succeeded. Any rule permissive enough to admit an
# arbitrary trailing predicate admits the second kind, so the contract
# is simply that reaching this job is `needs:`'s decision alone.
job = self._scan_job()
assert "if" not in job, (
f"virustotal-scan carries `if: {job.get('if')}`; a job-level condition "
"either runs the scan without a successful publish-release or skips it "
"after one, and `needs:` already gates it correctly"
)
# Nor may the individual steps be skipped, except the summary, which is
# `if: always()` precisely so the evidence survives a failed scan.
for step in job["steps"]:
condition = step.get("if")
if step.get("name") == "Publish VirusTotal summary":
assert condition == "always()"
else:
assert condition is None, step.get("name")
def test_the_scan_scans_the_bundles_that_were_published(self):
# The job has no build outputs of its own, so it re-downloads the very
# artifacts the build matrix uploaded and publish-release shipped. A
# pattern that matched nothing would scan an empty directory and still
# report success.
build = self._workflow()["jobs"]["build"]
upload_names = {
step.get("with", {}).get("name")
for step in build["steps"]
if step.get("uses", "").startswith("actions/upload-artifact@")
}
assert "desktop-release-${{ matrix.artifact }}" in upload_names
download = self._scan_step_map()["Download published assets"]
assert download["uses"].startswith("actions/download-artifact@")
assert download["with"]["merge-multiple"] is True
# Tie the scan's input to publish-release's own download rather than to
# a literal repeated in both places: if publish ever ships a different
# artifact set, a scan still pulling the old pattern leaves the shipped
# installers unscanned and still reports a clean sweep.
publish_download = next(
step
for step in self._publish_step_list()
if step.get("uses", "").startswith("actions/download-artifact@")
)
for key in ("pattern", "merge-multiple"):
assert download["with"][key] == publish_download["with"][key], (
key,
download["with"].get(key),
publish_download["with"].get(key),
)
assert self._runner_temp(download["with"]["path"]) == self._runner_temp(
publish_download["with"]["path"]
), (download["with"]["path"], publish_download["with"]["path"])
# And the scan has to be pointed at that same directory. The script takes
# its target as an argument, so comparing only the two download steps
# lets a repointed argument scan an empty directory and report clean.
argv = self._scan_script_argv()
scan_paths = list(itertools.takewhile(lambda argument: not argument.startswith("-"), argv))
assert scan_paths, argv
assert [self._runner_temp(path) for path in scan_paths] == [
self._runner_temp(download["with"]["path"])
], (argv, download["with"]["path"])
# And that shared pattern has to match what the matrix actually uploads,
# or both jobs would agree on a set that does not exist.
template = "desktop-release-${{ matrix.artifact }}"
for entry in build["strategy"]["matrix"]["include"]:
artifact = template.replace("${{ matrix.artifact }}", entry["artifact"])
assert fnmatch.fnmatch(artifact, download["with"]["pattern"]), (
artifact,
download["with"]["pattern"],
)
names = self._scan_step_names()
assert names.index("Download published assets") < names.index("VirusTotal scan")
def test_the_publish_job_no_longer_runs_the_scan(self):
# #8194 moved the scan out wholesale. Re-inlining it would put ~9 minutes
# back into the critical path of every release for a check that cannot
# block one, and would leave two scans burning the same 4/min quota.
for step in self._publish_step_list():
assert "virustotal" not in (step.get("name") or "").lower()
assert "virustotal_scan.py" not in (step.get("run") or "")
def test_nothing_slow_sits_between_validation_and_the_upload(self):
# The v{version} release already exists and is published, so the window
# worth minimising is now between validating its state and the assets
# landing on it. Nothing slow may be inserted between the two; the scan
# used to sit there and is why the window existed at all.
names = self._publish_steps()
validate = names.index("Validate versioned release state")
assert names[validate + 1] == "Generate versioned updater metadata"
assert names[validate + 2] == "Publish versioned release assets"
def test_release_notes_are_written_unconditionally(self):
# Validation writes the notes; the metadata step consumes that same file
# before the assets land on the release.
steps = self._publish_step_map()
assert "desktop-release-notes.md" in steps["Validate versioned release state"]["run"]
metadata = steps["Generate versioned updater metadata"]["run"]
assert "desktop-release-notes.md" in metadata
def test_a_missing_release_stops_the_publish(self):
# Nothing is created here any more, so an absent release is a dispatch
# mistake: say how to fix it instead of publishing into thin air.
run = self._publish_step_map()["Validate versioned release state"]["run"]
assert "gh release create" not in run
missing = run.split("does not exist.", 1)[1]
assert "Tag main and publish it first" in missing
assert "exit 1" in missing
def test_every_public_mutation_is_gated_on_a_real_release(self):
yaml = pytest.importorskip("yaml")
workflow = REPO_ROOT / ".github" / "workflows" / "release-desktop.yml"
data = yaml.safe_load(workflow.read_text(encoding = "utf-8"))
steps = data["jobs"]["publish-release"]["steps"]
by_name = {step.get("name"): step for step in steps}
assert by_name["Validate versioned release state"]["id"] == "versioned_release_state"
assert "Create versioned release" not in by_name
# Validation runs on every dispatch; only a non-draft run touches the release.
for name in ("Publish versioned release assets", "Publish versioned updater metadata"):
assert by_name[name]["if"] == "${{ !inputs.draft }}"
def test_the_scan_step_does_not_swallow_its_own_failure(self):
# The advisory posture is a property of the job, not of the step. The job
# carries `continue-on-error` so a missing secret or a VirusTotal outage
# cannot retroactively fail a release that already published; the step
# must still surface its exit status, or a broken invocation reads as a
# clean scan.
step = self._scan_step_map()["VirusTotal scan"]
assert "continue-on-error" not in step
run = step["run"]
assert "set -euo pipefail" in run
invocation = run.split("python3 scripts/virustotal_scan.py", 1)[1]
for swallow in ("|| true", "|| :", "exit 0", "; true"):
assert swallow not in invocation, swallow
# The script signals a detection by returning 1 from main() once
# `--fail-threshold` is met (see TestThreshold), so the workflow must not
# pin the threshold to something the script treats as "never fail" while
# claiming to gate. It passes no threshold at all today, which leaves the
# script's advisory default in force and the verdict in the annotations.
assert "--fail-threshold" not in run
def test_the_advisory_escape_hatch_is_confined_to_the_scan_job(self):
# `continue-on-error` anywhere else would let a genuine release failure
# pass as success. Exactly one in the file, on virustotal-scan itself.
jobs = self._workflow()["jobs"]
assert self._scan_job()["continue-on-error"] is True
tolerant_jobs = [name for name, job in jobs.items() if "continue-on-error" in job]
assert tolerant_jobs == ["virustotal-scan"]
# free-capacity only asks CI to release runners and is allowed to fail;
# every job that touches a bundle must not be.
tolerant_steps = [
(name, step.get("name"))
for name, job in jobs.items()
if name != "free-capacity"
for step in job.get("steps", [])
if "continue-on-error" in step
]
assert tolerant_steps == []
def test_the_scan_job_makes_the_scan_script_available(self):
# The job publishes nothing and so has no source tree of its own; the
# sparse checkout is the only thing that puts scripts/virustotal_scan.py
# on disk. Assert the mechanism, not a step name: a checkout that stops
# fetching the script leaves the scan unable to run at all.
checkouts = [
step
for step in self._scan_job()["steps"]
if step.get("uses", "").startswith("actions/checkout@")
]
assert len(checkouts) == 1
checkout = checkouts[0]
assert checkout["with"]["sparse-checkout"] == "scripts/virustotal_scan.py"
assert checkout["with"]["persist-credentials"] is False
names = self._scan_step_names()
assert names.index(checkout["name"]) < names.index("VirusTotal scan")
# And if it ever does not, the scan step says so loudly and exits 1
# rather than reporting a clean sweep of nothing.
guard = self._scan_step_map()["VirusTotal scan"]["run"]
assert "if [ ! -f scripts/virustotal_scan.py ]; then" in guard
assert "exit 1" in guard.split("if [ ! -f scripts/virustotal_scan.py ]; then", 1)[1]
def test_the_scan_verdict_is_always_reported(self):
# continue-on-error means nobody is forced to look at the job result, so
# the step summary is the report. It must be written even when the scan
# itself failed, which is exactly the case worth reading.
summary = self._scan_step_map()["Publish VirusTotal summary"]
assert summary["if"] == "always()"
assert "$GITHUB_STEP_SUMMARY" in summary["run"]
assert "virustotal-summary.md" in summary["run"]
def test_the_placeholder_summary_matches_the_real_one(self):
# The placeholder stands in when the scan produced no summary, so a
# heading that drifts from the script's renders as a second, unrelated
# section instead of the report the reader came for.
summary = self._scan_step_map()["Publish VirusTotal summary"]
assert vt.SUMMARY_HEADING in summary["run"], summary["run"]
def test_the_summary_heading_holds_for_a_validation_only_run_too(self):
# `inputs.draft` defaults to true, and every uploading step is gated on
# it, so the ordinary dispatch validates and publishes nothing. A heading
# calling this a post-publish scan would tell a release operator the
# opposite of what happened, so the wording has to cover both.
workflow = self._workflow()
draft = workflow.get("on", workflow.get(True))["workflow_dispatch"]["inputs"]["draft"]
assert draft["default"] is True
upload = self._publish_step_map()["Publish versioned release assets"]
assert upload["if"] == "${{ !inputs.draft }}"
heading = vt.SUMMARY_HEADING.lower()
for claim in ("post-publish", "published", "pre-flight"):
assert claim not in heading, (vt.SUMMARY_HEADING, claim)