Files
Scoop/lib/autoupdate.ps1

617 lines
22 KiB
PowerShell

# Must included with 'json.ps1'
function format_hash([String] $hash) {
$hash = $hash.toLower()
switch ($hash.Length) {
32 { $hash = "md5:$hash" } # md5
40 { $hash = "sha1:$hash" } # sha1
64 { $hash = $hash } # sha256
128 { $hash = "sha512:$hash" } # sha512
default { $hash = $null }
}
return $hash
}
function find_hash_in_rdf([String] $url, [String] $basename) {
$xml = $null
try {
# Download and parse RDF XML file
$wc = New-Object Net.Webclient
$wc.Headers.Add('Referer', (strip_filename $url))
$wc.Headers.Add('User-Agent', (Get-UserAgent))
$data = $wc.DownloadData($url)
[xml]$xml = (Get-Encoding($wc)).GetString($data)
} catch [System.Net.WebException] {
Write-Host $_ -ForegroundColor DarkRed
Write-Host "URL $url is not valid" -ForegroundColor DarkRed
return $null
}
# Find file content
$digest = $xml.RDF.Content | Where-Object { [String]$_.about -eq $basename }
return format_hash $digest.sha256
}
function find_hash_in_textfile([String] $url, [Hashtable] $substitutions, [String] $regex) {
$hashfile = $null
$templates = @{
'$md5' = '([a-fA-F0-9]{32})'
'$sha1' = '([a-fA-F0-9]{40})'
'$sha256' = '([a-fA-F0-9]{64})'
'$sha512' = '([a-fA-F0-9]{128})'
'$checksum' = '([a-fA-F0-9]{32,128})'
'$base64' = '([a-zA-Z0-9+\/=]{24,88})'
}
try {
$wc = New-Object Net.Webclient
$wc.Headers.Add('Referer', (strip_filename $url))
$wc.Headers.Add('User-Agent', (Get-UserAgent))
$data = $wc.DownloadData($url)
$ms = New-Object System.IO.MemoryStream
$ms.Write($data, 0, $data.Length)
$ms.Seek(0, 0) | Out-Null
if ($data[0] -eq 0x1F -and $data[1] -eq 0x8B) {
$ms = New-Object System.IO.Compression.GZipStream($ms, [System.IO.Compression.CompressionMode]::Decompress)
}
$hashfile = (New-Object System.IO.StreamReader($ms, (Get-Encoding $wc))).ReadToEnd()
} catch [system.net.webexception] {
Write-Host $_ -ForegroundColor DarkRed
Write-Host "URL $url is not valid" -ForegroundColor DarkRed
return
}
if ($regex.Length -eq 0) {
$regex = '^\s*([a-fA-F0-9]+)\s*$'
}
$regex = substitute $regex $templates $false
$regex = substitute $regex $substitutions $true
if ($hashfile -match $regex) {
debug $regex
$hash = $matches[1] -replace '\s', ''
}
# convert base64 encoded hash values
if ($hash -match '^(?:[A-Za-z0-9+\/]{4})*(?:[A-Za-z0-9+\/]{2}==|[A-Za-z0-9+\/]{3}=|[A-Za-z0-9+\/]{4})$') {
$base64 = $matches[0]
if (!($hash -match '^[a-fA-F0-9]+$') -and $hash.Length -notin @(32, 40, 64, 128)) {
try {
$hash = ([System.Convert]::FromBase64String($base64) | ForEach-Object { $_.ToString('x2') }) -join ''
} catch {
$hash = $hash
}
}
}
# find hash with filename in $hashfile
if ($hash.Length -eq 0) {
$filenameRegex = "([a-fA-F0-9]{32,128})[\x20\t]+.*`$basename(?:\s|$)|`$basename[\x20\t]+.*?([a-fA-F0-9]{32,128})"
$filenameRegex = substitute $filenameRegex $substitutions $true
if ($hashfile -match $filenameRegex) {
debug $filenameRegex
$hash = $matches[1]
}
$metalinkRegex = '<hash[^>]+>([a-fA-F0-9]{64})'
if ($hashfile -match $metalinkRegex) {
debug $metalinkRegex
$hash = $matches[1]
}
}
return format_hash $hash
}
function find_hash_in_json([String] $url, [Hashtable] $substitutions, [String] $jsonpath) {
$json = $null
try {
$wc = New-Object Net.Webclient
$wc.Headers.Add('Referer', (strip_filename $url))
$wc.Headers.Add('User-Agent', (Get-UserAgent))
$data = $wc.DownloadData($url)
$ms = New-Object System.IO.MemoryStream
$ms.Write($data, 0, $data.Length)
$ms.Seek(0, 0) | Out-Null
if ($data[0] -eq 0x1F -and $data[1] -eq 0x8B) {
$ms = New-Object System.IO.Compression.GZipStream($ms, [System.IO.Compression.CompressionMode]::Decompress)
}
$json = (New-Object System.IO.StreamReader($ms, (Get-Encoding $wc))).ReadToEnd()
} catch [System.Net.WebException] {
Write-Host $_ -ForegroundColor DarkRed
Write-Host "URL $url is not valid" -ForegroundColor DarkRed
return
}
debug $jsonpath
$hash = json_path $json $jsonpath $substitutions
if (!$hash) {
$hash = json_path_legacy $json $jsonpath $substitutions
}
return format_hash $hash
}
function find_hash_in_xml([String] $url, [Hashtable] $substitutions, [String] $xpath) {
$xml = $null
try {
$wc = New-Object Net.Webclient
$wc.Headers.Add('Referer', (strip_filename $url))
$wc.Headers.Add('User-Agent', (Get-UserAgent))
$data = $wc.DownloadData($url)
$ms = New-Object System.IO.MemoryStream
$ms.Write($data, 0, $data.Length)
$ms.Seek(0, 0) | Out-Null
if ($data[0] -eq 0x1F -and $data[1] -eq 0x8B) {
$ms = New-Object System.IO.Compression.GZipStream($ms, [System.IO.Compression.CompressionMode]::Decompress)
}
$xml = [xml]((New-Object System.IO.StreamReader($ms, (Get-Encoding $wc))).ReadToEnd())
} catch [system.net.webexception] {
Write-Host $_ -ForegroundColor DarkRed
Write-Host "URL $url is not valid" -ForegroundColor DarkRed
return
}
# Replace placeholders
if ($substitutions) {
$xpath = substitute $xpath $substitutions
}
# Find all `significant namespace declarations` from the XML file
$nsList = $xml.SelectNodes('//namespace::*[not(. = ../../namespace::*)]')
# Then add them into the NamespaceManager
$nsmgr = New-Object System.Xml.XmlNamespaceManager($xml.NameTable)
$nsList | ForEach-Object {
$nsmgr.AddNamespace($_.LocalName, $_.Value)
}
debug $xpath
debug $nsmgr
# Getting hash from XML, using XPath
$hash = $xml.SelectSingleNode($xpath, $nsmgr).'#text'
return format_hash $hash
}
function find_hash_in_headers([String] $url) {
$hash = $null
try {
$req = [System.Net.WebRequest]::Create($url)
$req.Referer = (strip_filename $url)
$req.AllowAutoRedirect = $false
$req.UserAgent = (Get-UserAgent)
$req.Timeout = 2000
$req.Method = 'HEAD'
$res = $req.GetResponse()
if (([int]$res.StatusCode -ge 300) -and ([int]$res.StatusCode -lt 400)) {
if ($res.Headers['Digest'] -match 'SHA-256=([^,]+)' -or $res.Headers['Digest'] -match 'SHA=([^,]+)' -or $res.Headers['Digest'] -match 'MD5=([^,]+)') {
$hash = ([System.Convert]::FromBase64String($matches[1]) | ForEach-Object { $_.ToString('x2') }) -join ''
debug $hash
}
}
$res.Close()
} catch [System.Net.WebException] {
Write-Host $_ -ForegroundColor DarkRed
Write-Host "URL $url is not valid" -ForegroundColor DarkRed
return
}
return format_hash $hash
}
function get_hash_for_app([String] $app, $config, [String] $version, [String] $url, [Hashtable] $substitutions) {
$hash = $null
$hashmode = $config.mode
$basename = [System.Web.HttpUtility]::UrlDecode((url_remote_filename($url)))
$substitutions = $substitutions.Clone()
$substitutions.Add('$url', (strip_fragment $url))
$substitutions.Add('$baseurl', (strip_filename (strip_fragment $url)).TrimEnd('/'))
$substitutions.Add('$basename', $basename)
$substitutions.Add('$urlNoExt', (strip_ext (strip_fragment $url)))
$substitutions.Add('$basenameNoExt', (strip_ext $basename))
debug $substitutions
$hashfile_url = substitute $config.url $substitutions
debug $hashfile_url
if ($hashfile_url) {
Write-Host 'Searching hash for ' -ForegroundColor DarkYellow -NoNewline
Write-Host $basename -ForegroundColor Green -NoNewline
Write-Host ' in ' -ForegroundColor DarkYellow -NoNewline
Write-Host $hashfile_url -ForegroundColor Green
}
if ($hashmode.Length -eq 0 -and $config.url.Length -ne 0) {
$hashmode = 'extract'
}
$jsonpath = ''
if ($config.jp) {
$jsonpath = $config.jp
$hashmode = 'json'
}
if ($config.jsonpath) {
$jsonpath = $config.jsonpath
$hashmode = 'json'
}
$regex = ''
if ($config.find) {
$regex = $config.find
}
if ($config.regex) {
$regex = $config.regex
}
$xpath = ''
if ($config.xpath) {
$xpath = $config.xpath
$hashmode = 'xpath'
}
if (!$hashfile_url -and $url -match '^(?:.*fosshub.com\/).*(?:\/|\?dwl=)(?<filename>.*)$') {
$hashmode = 'fosshub'
}
if (!$hashfile_url -and $url -match '(?:downloads\.)?sourceforge.net\/projects?\/(?<project>[^\/]+)\/(?:files\/)?(?<file>.*)') {
$hashmode = 'sourceforge'
}
switch ($hashmode) {
'extract' {
$hash = find_hash_in_textfile $hashfile_url $substitutions $regex
}
'json' {
$hash = find_hash_in_json $hashfile_url $substitutions $jsonpath
}
'xpath' {
$hash = find_hash_in_xml $hashfile_url $substitutions $xpath
}
'rdf' {
$hash = find_hash_in_rdf $hashfile_url $basename
}
'metalink' {
$hash = find_hash_in_headers $url
if (!$hash) {
$hash = find_hash_in_textfile "$url.meta4" $substitutions
}
}
'fosshub' {
$hash = find_hash_in_textfile $url $substitutions ($matches.filename + '.*?"sha256":"([a-fA-F0-9]{64})"')
}
'sourceforge' {
# change the URL because downloads.sourceforge.net doesn't have checksums
$hashfile_url = (strip_filename (strip_fragment "https://sourceforge.net/projects/$($matches['project'])/files/$($matches['file'])")).TrimEnd('/')
$hash = find_hash_in_textfile $hashfile_url $substitutions '"$basename":.*?"sha1":\s*"([a-fA-F0-9]{40})"'
}
}
if ($hash) {
# got one!
Write-Host 'Found: ' -ForegroundColor DarkYellow -NoNewline
Write-Host $hash -ForegroundColor Green -NoNewline
Write-Host ' using ' -ForegroundColor DarkYellow -NoNewline
Write-Host "$((Get-Culture).TextInfo.ToTitleCase($hashmode)) Mode" -ForegroundColor Green
return $hash
} elseif ($hashfile_url) {
Write-Host -f DarkYellow "Could not find hash in $hashfile_url"
}
Write-Host 'Downloading ' -ForegroundColor DarkYellow -NoNewline
Write-Host $basename -ForegroundColor Green -NoNewline
Write-Host ' to compute hashes!' -ForegroundColor DarkYellow
try {
Invoke-CachedDownload $app $version $url $null $null $true
} catch [system.net.webexception] {
Write-Host $_ -ForegroundColor DarkRed
Write-Host "URL $url is not valid" -ForegroundColor DarkRed
return $null
}
$file = cache_path $app $version $url
$hash = (Get-FileHash -Path $file -Algorithm SHA256).Hash.ToLower()
Write-Host 'Computed hash: ' -ForegroundColor DarkYellow -NoNewline
Write-Host $hash -ForegroundColor Green
return $hash
}
function Update-ManifestProperty {
<#
.SYNOPSIS
Update propert(y|ies) in manifest
.DESCRIPTION
Update selected propert(y|ies) to given version in manifest.
.PARAMETER Manifest
Manifest to be updated
.PARAMETER Property
Selected propert(y|ies) to be updated
.PARAMETER AppName
Software name
.PARAMETER Version
Given software version
.PARAMETER Substitutions
Hashtable of internal substitutable variables
.OUTPUTS
System.Boolean
Flag that indicate if there are any changed properties
#>
[CmdletBinding(SupportsShouldProcess = $true)]
[OutputType([Boolean])]
param (
[Parameter(Mandatory = $true, Position = 1)]
[PSCustomObject]
$Manifest,
[Parameter(ValueFromPipeline = $true, Position = 2)]
[String[]]
$Property,
[String]
$AppName,
[String]
$Version,
[Alias('Matches')]
[HashTable]
$Substitutions
)
begin {
$hasManifestChanged = $false
}
process {
foreach ($currentProperty in $Property) {
if ($currentProperty -eq 'hash') {
# Update hash
if ($Manifest.hash) {
# Global
$newURL = substitute $Manifest.autoupdate.url $Substitutions
$newHash = HashHelper -AppName $AppName -Version $Version -HashExtraction $Manifest.autoupdate.hash -URL $newURL -Substitutions $Substitutions
$Manifest.hash, $hasPropertyChanged = PropertyHelper -Property $Manifest.hash -Value $newHash
$hasManifestChanged = $hasManifestChanged -or $hasPropertyChanged
} else {
# Arch-spec
$Manifest.architecture | Get-Member -MemberType NoteProperty | ForEach-Object {
$arch = $_.Name
$newURL = substitute (arch_specific 'url' $Manifest.autoupdate $arch) $Substitutions
$newHash = HashHelper -AppName $AppName -Version $Version -HashExtraction (arch_specific 'hash' $Manifest.autoupdate $arch) -URL $newURL -Substitutions $Substitutions
$Manifest.architecture.$arch.hash, $hasPropertyChanged = PropertyHelper -Property $Manifest.architecture.$arch.hash -Value $newHash
$hasManifestChanged = $hasManifestChanged -or $hasPropertyChanged
}
}
} elseif ($Manifest.$currentProperty -and $Manifest.autoupdate.$currentProperty) {
# Update other property (global)
$autoupdateProperty = $Manifest.autoupdate.$currentProperty
$newValue = substitute $autoupdateProperty $Substitutions
if (($autoupdateProperty.GetType().Name -eq 'Object[]') -and ($autoupdateProperty.Length -eq 1)) {
# Make sure it's an array
$newValue = , $newValue
}
$Manifest.$currentProperty, $hasPropertyChanged = PropertyHelper -Property $Manifest.$currentProperty -Value $newValue
$hasManifestChanged = $hasManifestChanged -or $hasPropertyChanged
} elseif ($Manifest.architecture) {
# Update other property (arch-spec)
$Manifest.architecture | Get-Member -MemberType NoteProperty | ForEach-Object {
$arch = $_.Name
if ($Manifest.architecture.$arch.$currentProperty -and ($Manifest.autoupdate.architecture.$arch.$currentProperty -or $Manifest.autoupdate.$currentProperty)) {
$autoupdateProperty = @(arch_specific $currentProperty $Manifest.autoupdate $arch)
$newValue = substitute $autoupdateProperty $Substitutions
if (($autoupdateProperty.GetType().Name -eq 'Object[]') -and ($autoupdateProperty.Length -eq 1)) {
# Make sure it's an array
$newValue = , $newValue
}
$Manifest.architecture.$arch.$currentProperty, $hasPropertyChanged = PropertyHelper -Property $Manifest.architecture.$arch.$currentProperty -Value $newValue
$hasManifestChanged = $hasManifestChanged -or $hasPropertyChanged
}
}
}
}
}
end {
if ($Version -ne '' -and $Manifest.version -ne $Version) {
$Manifest.version = $Version
$hasManifestChanged = $true
}
return $hasManifestChanged
}
}
function Get-VersionSubstitution {
param (
[String]
$Version,
[Hashtable]
$CustomMatches
)
$firstPart = $Version.Split('-') | Select-Object -First 1
$lastPart = $Version.Split('-') | Select-Object -Last 1
$versionVariables = @{
'$version' = $Version
'$dotVersion' = ($Version -replace '[._-]', '.')
'$underscoreVersion' = ($Version -replace '[._-]', '_')
'$dashVersion' = ($Version -replace '[._-]', '-')
'$cleanVersion' = ($Version -replace '[._-]', '')
'$majorVersion' = $firstPart.Split('.') | Select-Object -First 1
'$minorVersion' = $firstPart.Split('.') | Select-Object -Skip 1 -First 1
'$patchVersion' = $firstPart.Split('.') | Select-Object -Skip 2 -First 1
'$buildVersion' = $firstPart.Split('.') | Select-Object -Skip 3 -First 1
'$preReleaseVersion' = $lastPart
}
if ($Version -match '(?<head>\d+\.\d+(?:\.\d+)?)(?<tail>.*)') {
$versionVariables.Add('$matchHead', $Matches['head'])
$versionVariables.Add('$matchTail', $Matches['tail'])
}
if ($CustomMatches) {
$CustomMatches.GetEnumerator() | ForEach-Object {
if ($_.Name -ne '0') {
$versionVariables.Add('$match' + (Get-Culture).TextInfo.ToTitleCase($_.Name), $_.Value)
}
}
}
return $versionVariables
}
function Invoke-AutoUpdate {
param (
[String]
$AppName,
[String]
$Path,
[PSObject]
$Manifest,
[String]
$Version,
[Hashtable]
$CustomMatches
)
Write-Host "Autoupdating $AppName" -ForegroundColor DarkCyan
$substitutions = Get-VersionSubstitution $Version $CustomMatches
# update properties
$updatedProperties = @(@($Manifest.autoupdate.PSObject.Properties.Name) -ne 'architecture')
if ($Manifest.autoupdate.architecture) {
$updatedProperties += $Manifest.autoupdate.architecture.PSObject.Properties | ForEach-Object { $_.Value.PSObject.Properties.Name }
}
if ($updatedProperties -contains 'url') {
$updatedProperties += 'hash'
}
$updatedProperties = $updatedProperties | Select-Object -Unique
debug [$updatedProperties]
$hasChanged = Update-ManifestProperty -Manifest $Manifest -Property $updatedProperties -AppName $AppName -Version $Version -Substitutions $substitutions
if ($hasChanged) {
# write file
Write-Host "Writing updated $AppName manifest" -ForegroundColor DarkGreen
# Accept unusual Unicode characters
# 'Set-Content -Encoding ASCII' don't works in PowerShell 5
# Wait for 'UTF8NoBOM' Encoding in PowerShell 7
# $Manifest | ConvertToPrettyJson | Set-Content -Path (Join-Path $Path "$AppName.json") -Encoding UTF8NoBOM
[System.IO.File]::WriteAllLines($Path, (ConvertToPrettyJson $Manifest))
# notes
$note = "`nUpdating note:"
if ($Manifest.autoupdate.note) {
$note += "`nno-arch: $($Manifest.autoupdate.note)"
$hasNote = $true
}
if ($Manifest.autoupdate.architecture) {
'64bit', '32bit', 'arm64' | ForEach-Object {
if ($Manifest.autoupdate.architecture.$_.note) {
$note += "`n$_-arch: $($Manifest.autoupdate.architecture.$_.note)"
$hasNote = $true
}
}
}
if ($hasNote) {
Write-Host $note -ForegroundColor DarkYellow
}
} else {
# This if-else branch may not be in use.
Write-Host "No updates for $AppName" -ForegroundColor DarkGray
}
}
## Helper Functions
function PropertyHelper {
<#
.SYNOPSIS
Helper of updating property
.DESCRIPTION
Update manifest property (String, Array or PSCustomObject).
.PARAMETER Property
Property to be updated
.PARAMETER Value
New property values
Update line by line
.OUTPUTS
System.Object[]
The first element is new property, the second element is change flag
#>
param (
[Object]$Property,
[Object]$Value
)
$hasChanged = $false
if (@($Property).Length -lt @($Value).Length) {
$Property = $Value
$hasChanged = $true
} else {
switch ($Property.GetType().Name) {
'String' {
$Value = $Value -as [String]
if ($null -ne $Value) {
$Property = $Value
$hasChanged = $true
}
}
'Object[]' {
$Value = @($Value)
for ($i = 0; $i -lt $Value.Length; $i++) {
$Property[$i], $hasItemChanged = PropertyHelper -Property $Property[$i] -Value $Value[$i]
$hasChanged = $hasChanged -or $hasItemChanged
}
}
'PSCustomObject' {
if ($Value -is [PSObject]) {
foreach ($name in $Property.PSObject.Properties.Name) {
if ($Value.$name) {
$Property.$name, $hasItemChanged = PropertyHelper -Property $Property.$name -Value $Value.$name
$hasChanged = $hasChanged -or $hasItemChanged
}
}
}
}
}
}
return $Property, $hasChanged
}
function HashHelper {
<#
.SYNOPSIS
Helper of getting file hash(es)
.DESCRIPTION
Extract or calculate file hash(es).
If hash extraction templates are less then URLs, the last template will be reused for the rest URLs.
.PARAMETER AppName
Software name
.PARAMETER Version
Given software version
.PARAMETER HashExtraction
Hash extraction template(s)
.PARAMETER URL
New download URL(s), used to calculate hash locally (fallback)
.PARAMETER Substitutions
Hashtable of internal substitutable variables
.OUTPUTS
System.String
Hash value (single URL)
System.String[]
Hash values (multi URLs)
#>
param (
[String]
$AppName,
[String]
$Version,
[PSObject[]]
$HashExtraction,
[String[]]
$URL,
[HashTable]
$Substitutions
)
$hash = @()
for ($i = 0; $i -lt $URL.Length; $i++) {
if ($null -eq $HashExtraction) {
$currentHashExtraction = $null
} else {
$currentHashExtraction = $HashExtraction[$i], $HashExtraction[-1] | Select-Object -First 1
}
$hash += get_hash_for_app $AppName $currentHashExtraction $Version $URL[$i] $Substitutions
if ($null -eq $hash[$i]) {
throw "Could not update $AppName, hash for $(url_remote_filename $URL[$i]) failed!"
}
}
return $hash
}