Wsl-Image/Wsl-Image.Types.ps1
using namespace System.IO; # The base URLs for Incus images [Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage()] $base_incus_url = "https://images.linuxcontainers.org/images" [Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage()] $base_Image_directory = [DirectoryInfo]::new("$env:LOCALAPPDATA\Wsl\RootFS") [Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage()] $image_split_regex = [regex]::new('^((?<prefix>\w+)\.)?(?<name>.+?)(\.rootfs)?\.tar\.(g|x)z$') class UnknownIncusDistributionException : System.SystemException { UnknownIncusDistributionException([string] $Os, [string]$Release) : base("Unknown Incus image with OS $Os and Release $Release. Check $base_incus_url.") { } } enum WslImageState { NotDownloaded Synced Outdated } enum WslImageType { Builtin Incus Local Uri } [Flags()] enum WslImageSource { Local = 1 Builtins = 2 Incus = 4 All = 7 } class WslImageHash { [System.Uri]$Url [string]$Algorithm = 'SHA256' [string]$Type = 'sums' hidden [hashtable]$Hashes = @{} [bool]$Mandatory = $true [void]Retrieve() { if ($this.Type -ne 'docker') { Progress "Getting checksums from $($this.Url)..." try { $content = Sync-String $this.Url if ($this.Type -eq 'sums') { ForEach ($line in $($content -split "`n")) { if ([bool]$line) { $item = $line -split '\s+' $filename = $item[1] -replace '^\*', '' $this.Hashes[$filename] = $item[0] } } } else { $filename = $this.Url.Segments[-1] -replace '\.\w+$', '' $this.Hashes[$filename] = $content.Trim() } } catch [System.Net.WebException] { if ($this.Mandatory) { throw $_ } } } } [string]GetExpectedHash([System.Uri]$Uri) { if ($this.Type -eq 'docker') { $Registry = $Uri.Host $Repository = $Uri.AbsolutePath.Trim('/') $Tag = $Uri.Fragment.TrimStart('#') $layer = Get-DockerImageManifest -Registry $Registry -Image $Repository -Tag $Tag return $layer.digest -split ':' | Select-Object -Last 1 } else { $Filename = $Uri.Segments[-1] if ($this.Hashes.ContainsKey($Filename)) { return $this.Hashes[$Filename] } } return $null } [string]DownloadAndCheckFile([System.Uri]$Uri, [FileInfo]$Destination) { $Filename = $Uri.Segments[-1] if ($Uri.Scheme -ne 'docker' -and !($this.Hashes.ContainsKey($Filename)) -and $this.Mandatory) { throw [WslImageDownloadException]::new("Missing hash for $Uri -> $Destination") } $temp = [FileInfo]::new($Destination.FullName + '.tmp') try { if ($Uri.Scheme -eq 'docker') { $Registry = $Uri.Host $Image = $Uri.AbsolutePath.Trim('/') $Tag = $Uri.Fragment.TrimStart('#') $expected = Get-DockerImage -Registry $Registry -Image $Image -Tag $Tag -DestinationFile $temp.FullName } else { $expected = $this.Hashes[$Filename] Sync-File $Uri $temp } $actual = (Get-FileHash -Path $temp.FullName -Algorithm $this.Algorithm).Hash if (($null -ne $expected) -and ($expected -ne $actual)) { Remove-Item -Path $temp.FullName -Force throw [WslImageDownloadException]::new("Bad hash for $Uri -> $Destination : expected $expected, got $actual") } Move-Item $temp.FullName $Destination.FullName -Force return $actual } finally { Remove-Item $temp -Force -ErrorAction SilentlyContinue } } } class WslImage: System.IComparable { [void]initFromBuiltin([PSCustomObject]$conf) { $dist_lower = $conf.Name.ToLower() $typeString = if ($conf.Type) { $conf.Type } else { 'Builtin' } $this.Type = [WslImageType]$typeString $this.Configured = $conf.Configured $this.Os = $conf.Os $this.Name = $dist_lower $this.Release = $conf.Release $this.Url = [System.Uri]$conf.Url $this.LocalFileName = if ($conf.LocalFileName) { $conf.LocalFileName } else { "docker.$($dist_lower).rootfs.tar.gz" } # TODO: Should be the same everywhere if ($conf.Hash) { $this.HashSource = [WslImageHash]($conf.Hash) } else { if ($conf.HashSource) { $this.HashSource = [WslImageHash]($conf.HashSource) } } $this.Username = $conf.Username $this.Uid = $conf.Uid if ($this.IsAvailableLocally) { $this.State = [WslImageState]::Synced $this.UpdateHashIfNeeded(); $this.WriteMetadata(); } } WslImage([PSCustomObject]$conf) { $this.initFromBuiltin($conf) } [void] init([string]$Name) { $this.Url = [System.Uri]$Name $dist_lower = $Name.ToLower() $dist_title = (Get-Culture).TextInfo.ToTitleCase($dist_lower) $this.Name = $dist_title # When the name is not an absolute URI, we try to find the file with the appropriate name if (-not $this.Url.IsAbsoluteUri) { # If the name is the name of a builtin, we use that $found = Get-WslBuiltinImage | Where-Object { $_.Name -eq $dist_title } if ($found) { $this.initFromBuiltin($found) return } # Try to find a file with the name $candidates = @([WslImage]::BasePath.EnumerateFiles("*.rootfs.tar.gz") | Where-Object { $_.Name -imatch [WslImage]::ImageSplitRegex -and (` ($matches['name'] -eq 'rootfs' -and $matches['prefix'] -eq $dist_lower) -or ` ($matches['name'] -eq $dist_lower) ) }) if ($candidates.Count -eq 1) { $this.InitFromFile($candidates[0]) return } elseif ($candidates.Count -gt 1) { throw [WslImageException]::new("Multiple candidates for $($Name): " + ($candidates | ForEach-Object { $_.Name } | Sort-Object) -join ', ') } # At this point, the only possibility is an unknown builtin $this.Url = [System.Uri]::new("docker://ghcr.io/antoinemartin/powershell-wsl-manager/$dist_lower#latest") $this.LocalFileName = "docker.$dist_lower.rootfs.tar.gz" } if ($this.Url.IsAbsoluteUri) { # We have a URI, either because it comes like that or because this is a builtin $this.Type = [WslImageType]::Uri switch ($this.Url.Scheme) { 'incus' { $_Os = $this.Url.Host $_Release = $this.Url.Fragment.TrimStart('#') $builtins = Get-WslBuiltinImage -Source Incus | Where-Object { $_.Os -eq $_Os -and $_.Release -eq $_Release } if ($builtins) { $this.initFromBuiltin($builtins[0]) return } else { throw [UnknownIncusDistributionException]::new($_Os, $_Release) } } 'docker' { $dist_lower = $this.Url.Segments[-1].ToLower() $dist_title = (Get-Culture).TextInfo.ToTitleCase($dist_lower) $this.HashSource = [WslImageHash]@{ Type = 'docker' } if ($this.Url.AbsolutePath -match '^/antoinemartin/powershell-wsl-manager') { $found = Get-WslBuiltinImage | Where-Object {$_.Name -eq $dist_title} if ($found) { $this.initFromBuiltin($found) return } } $Registry = $this.Url.Host $Tag = $this.Url.Fragment.TrimStart('#') $Repository = $this.Url.AbsolutePath.Trim('/') $manifest = Get-DockerImageManifest -Registry $Registry -Image $Repository -Tag $Tag # Default local filename $this.Name = $dist_lower $this.Os = ($this.Name -split "[-. ]")[0] $this.Release = $Tag # try to get more accurate information from the Image Labels try { $this.Release = $manifest.config.Labels['org.opencontainers.image.version'] $this.Os = (Get-Culture).TextInfo.ToTitleCase($manifest.config.Labels['org.opencontainers.image.flavor']) $this.Username = if ($this.Configured) { $this.Os } else { 'root' } if ($manifest.config.Labels.ContainsKey('com.kaweezle.wsl.rootfs.configured')) { $this.Configured = $manifest.config.Labels['com.kaweezle.wsl.rootfs.configured'] -eq 'true' } if ($manifest.config.Labels.ContainsKey('com.kaweezle.wsl.rootfs.uid')) { $this.Uid = [int]$manifest.config.Labels['com.kaweezle.wsl.rootfs.uid'] } else { # We do this because configured might have changed $this.Uid = if ($this.Configured) { 1000 } else { 0 } } if ($manifest.config.Labels.ContainsKey('com.kaweezle.wsl.rootfs.username')) { $this.Username = $manifest.config.Labels['com.kaweezle.wsl.rootfs.username'] } else { $this.Username = if ($this.Configured) { $this.Os } else { 'root' } } } catch { Information "Failed to get image labels from $($this.Url). Using defaults: $($this.Os) $($this.Release)" # Do nothing } $this.LocalFileName = "docker." + $this.Name + ".rootfs.tar.gz" } Default { $this.HashSource = [WslImageHash]@{ Url = [System.Uri]::new($this.Url, "SHA256SUMS") Type = 'sums' Algorithm = 'SHA256' Mandatory = $false } $this.LocalFileName = $this.Url.Segments[-1] $this.Os = ($this.LocalFileName -split "[-. ]")[0] $this.Name = $this.Os $this.Username = 'root' $this.Uid = 0 $this.Configured = $false } } if ($this.IsAvailableLocally) { $this.State = [WslImageState]::Synced $this.ReadMetaData() } else { $this.State = [WslImageState]::NotDownloaded } } else { # If the file is already present, take it throw [UnknownWslImageException]::new($Name) } } WslImage([string]$Name) { $this.init($Name) } [void]initFromFile([FileInfo]$File) { $this.LocalFileName = $File.Name $this.State = [WslImageState]::Synced if (!($this.ReadMetaData())) { if ($File.Name -imatch [WslImage]::ImageSplitRegex) { $this.Name = if ($matches['name'] -eq 'rootfs') { $matches['prefix'] } else { $matches['name'] } switch ($Matches['prefix']) { { $_ -in 'miniwsl', 'docker' } { $this.Configured = $true $this.Type = [WslImageType]::Builtin $this.Os = (Get-Culture).TextInfo.ToTitleCase($this.Name) $distributionKey = (Get-Culture).TextInfo.ToTitleCase($this.Name) $found = Get-WslBuiltinImage | Where-Object { $_.Name -ieq $distributionKey } if ($found) { $this.initFromBuiltin($found) } else { Write-Warning "Did not find builtin image: $($this.Name)" } } 'incus' { $this.Configured = $false $this.Type = [WslImageType]::Incus $this.Os, $this.Release = $this.Name -Split '_' $found = Get-WslBuiltinImage -Source Incus | Where-Object { $_.Os -eq $this.Os -and $_.Release -eq $this.Release } if ($found) { $this.initFromBuiltin($found) } } Default { $this.Os = (Get-Culture).TextInfo.ToTitleCase($this.Name) $found = Get-WslBuiltinImage | Where-Object { $_.Name -eq $this.Os } if ($found) { $this.initFromBuiltin(@($found)[0]) } else { # Ensure we have a tar.gz file $this.Type = [WslImageType]::Local $this.Configured = $false $this.Url = [System.Uri]::new($File.FullName).AbsoluteUri if ($this.LocalFileName -notmatch '\.tar(\.gz)?$') { $this.Os = (Get-Culture).TextInfo.ToTitleCase($this.Name) $this.Release = "unknown" } else { try { # Get os-release from the tar.gz file $osRelease = Invoke-Tar -xOf $File.FullName etc/os-release usr/lib/os-release $osRelease = $osRelease -replace '=\s*"(.*?)"', '=$1' $osRelease = $osRelease | ConvertFrom-StringData if ($osRelease.ID) { $this.Os = (Get-Culture).TextInfo.ToTitleCase($osRelease.ID) } if ($osRelease.BUILD_ID) { $this.Release = $osRelease.BUILD_ID } if ($osRelease.VERSION_ID) { $this.Release = $osRelease.VERSION_ID } } catch { # Clean up temp directory $this.Os = (Get-Culture).TextInfo.ToTitleCase($this.Name) $this.Release = "unknown" } } } } } $this.WriteMetadata() } else { throw [UnknownWslImageException]::new($File.Name) } } else { # In case the JSON file doesn't contain the name if (-not $this.Name -and $File.Name -imatch [WslImage]::ImageSplitRegex) { $this.Name = if ($matches['name'] -eq 'rootfs') { $matches['prefix'] } else { $matches['name'] } } } } WslImage([FileInfo]$File) { $this.InitFromFile($File) } [string] ToString() { return $this.OsName } [int] CompareTo([object] $obj) { $other = [WslImage]$obj return $this.LocalFileName.CompareTo($other.LocalFileName) } [PSCustomObject]ToObject() { return ([PSCustomObject]@{ Name = $this.Name Os = $this.Os Release = $this.Release Type = $this.Type.ToString() State = $this.State.ToString() Url = $this.Url Configured = $this.Configured HashSource = $this.HashSource FileHash = $this.FileHash Username = if ($null -eq $this.Username) { $this.Os } else { $this.Username } Uid = $this.Uid # TODO: Checksums } | Remove-NullProperties) } [void]WriteMetadata() { $this.ToObject() | ConvertTo-Json | Set-Content -Path "$($this.File.FullName).json" } [bool] UpdateHashIfNeeded() { if (!$this.FileHash) { if (!$this.HashSource) { $this.HashSource = [WslImageHash]@{ Algorithm = 'SHA256' } } $this.FileHash = (Get-FileHash -Path $this.File.FullName -Algorithm $this.HashSource.Algorithm).Hash return $true; } return $false; } [WslImage]RefreshState() { $this.State = if ($this.IsAvailableLocally) { [WslImageState]::Synced } else { [WslImageState]::NotDownloaded } return $this } [bool]ReadMetaData() { $metadata_filename = "$($this.File.FullName).json" $result = $false if (Test-Path $metadata_filename) { $metadata = Get-Content $metadata_filename | ConvertFrom-Json | Convert-PSObjectToHashtable $this.Os = $metadata.Os $this.Release = $metadata.Release $this.Type = [WslImageType]($metadata.Type) if ($metadata.ContainsKey('Username')) { $this.Username = $metadata.Username } else { $this.Username = $this.Os } if ($metadata.ContainsKey('Uid')) { $this.Uid = $metadata.Uid } if (!$this.Url) { $this.Url = $metadata.Url } $this.Configured = $metadata.Configured if ($metadata.HashSource -and !$this.HashSource) { $this.HashSource = [WslImageHash]($metadata.HashSource) } if ($metadata.FileHash) { $this.FileHash = $metadata.FileHash } $this.State = if ($this.IsAvailableLocally) { [WslImageState]::Synced } else { [WslImageState]::NotDownloaded } $result = $true } # FIXME: This should be done elsewhere if ($this.UpdateHashIfNeeded()) { $this.WriteMetadata(); } return $result } [bool]Delete() { if ($this.IsAvailableLocally) { Remove-Item -Path $this.File.FullName Remove-Item -Path "$($this.File.FullName).json" -ErrorAction SilentlyContinue $this.State = [WslImageState]::NotDownloaded return $true } return $false } static [WslImage[]] LocalFileSystems() { $path = [WslImage]::BasePath $files = $path.GetFiles("*.tar.gz") $local = [WslImage[]]( $files | ForEach-Object { [WslImage]::new($_) }) return $local } [WslImageHash]GetHashSource() { if ($this.Type -eq [WslImageType]::Local -and $null -ne $this.Url) { $source = [WslImageHash]@{ Url = $this.Url Algorithm = 'SHA256' Type = 'sums' Mandatory = $false } return $source } elseif ($this.HashSource) { $hashUrl = $this.HashSource.Url if ($null -ne $hashUrl -and [WslImage]::HashSources.ContainsKey($hashUrl)) { return [WslImage]::HashSources[$hashUrl] } else { $source = [WslImageHash]($this.HashSource) $source.Retrieve() if ($null -ne $hashUrl) { [WslImage]::HashSources[$hashUrl] = $source } return $source } } return $null } [string]$Name [System.Uri]$Url [WslImageState]$State [WslImageType]$Type [bool]$Configured [string]$Username = "root" [int]$Uid = 0 [string]$Os [string]$Release = "unknown" [string]$LocalFileName [PSCustomObject]$HashSource [string]$FileHash [hashtable]$Properties = @{} static [DirectoryInfo]$BasePath = $base_Image_directory static [regex]$ImageSplitRegex = $image_split_regex # This is indexed by the URL static [hashtable]$HashSources = @{} } |