install.ps1: report checksum-verified layout mismatch separately from unsafe archive

Refs #1218

Signed-off-by: Mukkara Rithvik Reddy <rithvikreddymukkara@gmail.com>
This commit is contained in:
Mukkara Rithvik Reddy committed 2026-10-22 16:41:03 +05:30
1 parent 10ab613de3
commit 2809f88d6e
2 files changed
+58 -3

No files matched your search

+32 -3
View File
@@ -277,8 +277,20 @@ try {
# Validate the zip namespace before extraction. Windows paths are # Validate the zip namespace before extraction. Windows paths are
# case-insensitive, so two entries that differ only in case are ambiguous and # case-insensitive, so two entries that differ only in case are ambiguous and
# must never be allowed to overwrite each other. The official five entries are # must never be allowed to overwrite each other. The official four entries are
# required at the archive root with their exact release names. # required at the archive root with their exact release names.
#
# Two failure classes are reported differently (both still exit 1; nothing is
# relaxed). An unsafe entry path or a duplicate/case-conflicting entry is a
# hostile archive shape. A well-formed archive whose root file SET differs from
# $WindowsArchiveNames already passed checksum verification above, so it is
# the published artifact for $BaseUrl -- the usual cause is this script being
# newer or older than that release (e.g. install.ps1 run from main ahead of
# the latest release, #1218). Calling that "unsafe" sends the user hunting for
# tampering that is not there. Every entry is screened for the hostile class
# BEFORE any layout verdict, so a bad path can never hide behind a layout
# message just because an unexpected entry happened to come first.
$LayoutMismatch = $false
try { try {
Add-Type -AssemblyName System.IO.Compression.FileSystem Add-Type -AssemblyName System.IO.Compression.FileSystem
$zip = [System.IO.Compression.ZipFile]::OpenRead("$TmpDir\$Archive") $zip = [System.IO.Compression.ZipFile]::OpenRead("$TmpDir\$Archive")
@@ -286,6 +298,7 @@ try {
$seen = New-Object 'System.Collections.Generic.HashSet[string]' ([System.StringComparer]::OrdinalIgnoreCase) $seen = New-Object 'System.Collections.Generic.HashSet[string]' ([System.StringComparer]::OrdinalIgnoreCase)
$archiveCounts = @{} $archiveCounts = @{}
foreach ($archiveName in $WindowsArchiveNames) { $archiveCounts[$archiveName] = 0 } foreach ($archiveName in $WindowsArchiveNames) { $archiveCounts[$archiveName] = 0 }
$layoutProblem = $null
foreach ($entry in $zip.Entries) { foreach ($entry in $zip.Entries) {
$entryName = $entry.FullName.Replace('\', '/') $entryName = $entry.FullName.Replace('\', '/')
$isDirectory = $entryName.EndsWith('/') $isDirectory = $entryName.EndsWith('/')
@@ -304,10 +317,16 @@ try {
throw "duplicate or case-conflicting zip entry: $($entry.FullName)" throw "duplicate or case-conflicting zip entry: $($entry.FullName)"
} }
if (-not ($WindowsArchiveNames -ccontains $entryName) -or $isDirectory) { if (-not ($WindowsArchiveNames -ccontains $entryName) -or $isDirectory) {
throw "archive contains an unexpected root entry: $($entry.FullName)" if ($null -eq $layoutProblem) {
$layoutProblem = "archive contains an unexpected root entry: $($entry.FullName)"
}
continue
} }
$archiveCounts[$entryName] = $archiveCounts[$entryName] + 1 $archiveCounts[$entryName] = $archiveCounts[$entryName] + 1
} }
# Every entry passed the hostile-shape checks; only now judge the layout.
$LayoutMismatch = $true
if ($null -ne $layoutProblem) { throw $layoutProblem }
foreach ($archiveName in $WindowsArchiveNames) { foreach ($archiveName in $WindowsArchiveNames) {
if ($archiveCounts[$archiveName] -ne 1) { if ($archiveCounts[$archiveName] -ne 1) {
throw "archive must contain exactly one $archiveName" throw "archive must contain exactly one $archiveName"
@@ -316,11 +335,21 @@ try {
if ($seen.Count -ne $WindowsArchiveNames.Count) { if ($seen.Count -ne $WindowsArchiveNames.Count) {
throw "archive does not match the exact Windows release allowlist" throw "archive does not match the exact Windows release allowlist"
} }
$LayoutMismatch = $false
} finally { } finally {
$zip.Dispose() $zip.Dispose()
} }
} catch { } catch {
Write-Host "error: unsafe or incomplete release archive: $_" -ForegroundColor Red if ($LayoutMismatch) {
Write-Host "error: release archive layout does not match this installer: $_" -ForegroundColor Red
Write-Host " The archive matched the checksum published with it at $BaseUrl, so it is"
Write-Host " the genuine release artifact, built for a different installer version."
Write-Host " This installer expects: $($WindowsArchiveNames -join ', ')"
Write-Host " Fix: run the install.ps1 shipped inside that same release archive, or"
Write-Host " re-run this script after the next release is published."
} else {
Write-Host "error: unsafe release archive: $_" -ForegroundColor Red
}
Remove-Item -Recurse -Force $TmpDir -ErrorAction SilentlyContinue Remove-Item -Recurse -Force $TmpDir -ErrorAction SilentlyContinue
exit 1 exit 1
} }
+26
View File
@@ -335,6 +335,32 @@ for relative, needles in exact_archive_guards.items():
"allowlist", "allowlist",
) )
# install.ps1 reports two different archive failures differently (#1218). A
# checksum-verified archive whose root file set differs from the allowlist is a
# release/installer version skew, not tampering, and must say so; a hostile
# entry (unsafe path, duplicate or case conflict) stays "unsafe". Both exit 1.
# The layout verdict may only be reached after EVERY entry cleared the hostile
# checks, or an unexpected entry listed first would mask a later "../" entry.
installer_archive = read("install.ps1")
require(
"unsafe or incomplete release archive" not in installer_archive
and "error: unsafe release archive: " in installer_archive
and "error: release archive layout does not match this installer: " in installer_archive,
"install.ps1 must report a checksum-verified layout mismatch separately from an unsafe "
"archive (#1218)",
)
hostile_last = max(
installer_archive.find('throw "unsafe zip entry path:'),
installer_archive.find('throw "duplicate or case-conflicting zip entry:'),
)
layout_verdict = installer_archive.find("$LayoutMismatch = $true")
require(
hostile_last != -1 and layout_verdict > hostile_last
and installer_archive.count("$LayoutMismatch = $true") == 1,
"install.ps1 must screen every zip entry for unsafe paths and duplicates before it "
"classifies an archive as a layout mismatch (#1218)",
)
# A portable mutation refusal must point to the owning package manager. # A portable mutation refusal must point to the owning package manager.
guidance_contracts = { guidance_contracts = {
"pkg/npm/bin.js": ( "pkg/npm/bin.js": (