Files
Scoop/lib/autoupdate.ps1
2022-10-14 18:21:52 +08:00

581 lines
21 KiB
PowerShell

# Must included with 'json.ps1'
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 -f darkred $_
write-host -f darkred "URL $url is not valid"
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)
$hashfile = (Get-Encoding($wc)).GetString($data)
} catch [system.net.webexception] {
write-host -f darkred $_
write-host -f darkred "URL $url is not valid"
return
}
if ($regex.Length -eq 0) {
$regex = '^\s*([a-fA-F0-9]+)\s*$'
}
$regex = substitute $regex $templates $false
$regex = substitute $regex $substitutions $true
debug $regex
if ($hashfile -match $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(?:[\x20\t]+\d+)?"
$filenameRegex = substitute $filenameRegex $substitutions $true
if ($hashfile -match $filenameRegex) {
$hash = $matches[1]
}
$metalinkRegex = "<hash[^>]+>([a-fA-F0-9]{64})"
if ($hashfile -match $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)
$json = (Get-Encoding($wc)).GetString($data)
} catch [system.net.webexception] {
write-host -f darkred $_
write-host -f darkred "URL $url is not valid"
return
}
$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)
$xml = [xml]((Get-Encoding($wc)).GetString($data))
} catch [system.net.webexception] {
write-host -f darkred $_
write-host -f darkred "URL $url is not valid"
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)
}
# 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 -f darkred $_
write-host -f darkred "URL $url is not valid"
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 -f DarkYellow 'Searching hash for ' -NoNewline
write-host -f Green $basename -NoNewline
write-host -f DarkYellow ' in ' -NoNewline
write-host -f Green $hashfile_url
}
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 -f DarkYellow 'Found: ' -NoNewline
write-host -f Green $hash -NoNewline
write-host -f DarkYellow ' using ' -NoNewline
write-host -f Green "$((Get-Culture).TextInfo.ToTitleCase($hashmode)) Mode"
return $hash
} elseif ($hashfile_url) {
write-host -f DarkYellow "Could not find hash in $hashfile_url"
}
write-host -f DarkYellow 'Downloading ' -NoNewline
write-host -f Green $basename -NoNewline
write-host -f DarkYellow ' to compute hashes!'
try {
Invoke-CachedDownload $app $version $url $null $null $true
} catch [system.net.webexception] {
write-host -f darkred $_
write-host -f darkred "URL $url is not valid"
return $null
}
$file = fullpath (cache_path $app $version $url)
$hash = (Get-FileHash -Path $file -Algorithm SHA256).Hash.ToLower()
write-host -f DarkYellow 'Computed hash: ' -NoNewline
write-host -f Green $hash
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.Set_Item('$matchHead', $Matches['head'])
$versionVariables.Set_Item('$matchTail', $Matches['tail'])
}
if($CustomMatches) {
$CustomMatches.GetEnumerator() | ForEach-Object {
if($_.Name -ne "0") {
$versionVariables.Set_Item('$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
}