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"
$ImageDatadir = if ($env:LOCALAPPDATA) { $env:LOCALAPPDATA } else { Join-Path -Path "$HOME" -ChildPath ".local/share" }
[Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage()]
$base_Image_directory = [DirectoryInfo]::new(@($ImageDatadir, "Wsl", "RootFS") -join [Path]::DirectorySeparatorChar)
[Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage()]
$image_split_regex = [regex]::new('^((?<prefix>\w+)\.)?(?<name>.+?)(\.rootfs)?\.tar\.(g|x)z$')

class UnknownDistributionException : System.SystemException {
    UnknownDistributionException([string] $Distribution, [string]$Release, [string]$Type) : base("Unknown image with OS $Distribution and Release $Release and type $Type.") {
    }
}


class WslImage: System.IComparable {


    [Guid]$Id
    [Guid]$SourceId
    [string]$Name

    [WslImageState]$State
    [WslImageType]$Type

    [System.DateTime]$CreationDate
    [System.DateTime]$UpdateDate

    [System.Uri]$Url
    [bool]$Configured
    [string]$Username = "root"
    [int]$Uid = 0

    [string]$Distribution
    [string]$Release = "unknown"

    [string]$LocalFileName
    [long]$Size

    [System.Uri]$DigestUrl
    [string]$DigestAlgorithm = 'SHA256'
    [string]$DigestType = 'sums'
    [string]$FileHash

    [WslImageSource]$Source

    static [DirectoryInfo]$BasePath = $base_Image_directory
    static [regex]$ImageSplitRegex = $image_split_regex


    [void]InitFromObject([PSCustomObject]$conf) {
        $dist_lower = $conf.Name.ToLower()

        $typeString = if ($conf.Type) { $conf.Type } else { 'Builtin' }

        if ($conf.Id) {
            $this.Id = [Guid]$conf.Id
        }

        if ($conf.ImageSourceId) {
            $this.SourceId = [Guid]$conf.ImageSourceId
        }

        $this.Type = [WslImageType]$typeString
        $this.Configured = $conf.Configured
        $this.Distribution = if ($conf.Distribution -and "" -ne $conf.Distribution) { $conf.Distribution } elseif ($conf.Os -and "" -ne $conf.Os) { $conf.Os } else { (Get-Culture).TextInfo.ToTitleCase($dist_lower) }
        $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
        $DigestSource = if ($conf.Hash) { $conf.Hash } elseif ($conf.HashSource) { $conf.HashSource } else { $null }
        if ($DigestSource) {
            $this.DigestUrl = [System.Uri]$DigestSource.Url
            $this.DigestAlgorithm = $DigestSource.Algorithm
            $this.DigestType = $DigestSource.Type
        }
        if ($conf.Digest) {
            $this.FileHash = $conf.Digest
        }
        if ($conf.FileHash) {
            $this.FileHash = $conf.FileHash
        }

        $this.Username = if ($conf.Username) { $conf.Username } elseif ($this.Configured) { $this.Distribution.ToLower() } else { 'root' }
        $this.Uid = $conf.Uid

        if ($conf.State) {
            $this.State = [WslImageState]$conf.State
        } else {
            $this.State = [WslImageState]::NotDownloaded
        }

        if ($conf.CreationDate) {
            $this.CreationDate = [System.DateTime]$conf.CreationDate
        } else {
            $this.CreationDate = [System.DateTime]::Now
        }

        if ($conf.UpdateDate) {
            $this.UpdateDate = [System.DateTime]$conf.UpdateDate
        } else {
            $this.UpdateDate = [System.DateTime]::Now
        }

        if ($conf.Size) {
            $this.Size = [long]$conf.Size
        }
    }

    WslImage([PSCustomObject]$conf) {
        $this.InitFromObject($conf)
    }

    [void]UpdateFromSource() {
        if ($null -ne $this.Source) {
            $this.DigestAlgorithm = $this.Source.DigestAlgorithm
            $this.DigestUrl = $this.Source.DigestUrl
            $this.DigestType = $this.Source.DigestSource
            $this.FileHash = $this.Source.Digest
            $this.LocalFileName = $this.Source.LocalFilename
            $this.Size = $this.Source.Size
            $this.Url = $this.Source.Url
            $this.Release = $this.Source.Release
            $this.State = if ($this.IsAvailableLocally) { [WslImageState]::Synced } else { [WslImageState]::NotDownloaded }
        }
    }

    WslImage([PSCustomObject]$conf, [WslImageSource]$Source) {
        $this.InitFromObject($conf)
        $this.Source = $Source
    }

    [string] ToString() {
        return $this.DistributionName
    }

    [int] CompareTo([object] $obj) {
        $other = [WslImage]$obj
        return $this.LocalFileName.CompareTo($other.LocalFileName)
    }

    [string] GetFileSize()
    {
        if ($this.IsAvailableLocally) {
            return Format-FileSize -Bytes $this.File.Length
        }
        return Format-FileSize -Bytes $this.Size
    }

    [PSCustomObject]ToObject() {
       return ([PSCustomObject]@{
            Id                = $this.Id.ToString()
            SourceId          = $this.SourceId.ToString()
            Name              = $this.Name
            Os                = $this.Distribution
            Distribution      = $this.Distribution
            Release           = $this.Release
            Type              = $this.Type.ToString()
            State             = $this.State.ToString()
            Url               = $this.Url.AbsoluteUri
            Configured        = $this.Configured
            HashSource        = $this.GetHashSource()
            FileHash          = $this.FileHash
            Username          = if ($null -eq $this.Username) { $this.Distribution.ToLower() } else { $this.Username }
            Uid               = $this.Uid
            Size              = $this.Size
            LocalFileName    = $this.LocalFileName
            CreationDate      = $this.CreationDate.ToString("yyyy-MM-dd HH:mm:ss")
            UpdateDate        = $this.UpdateDate.ToString("yyyy-MM-dd HH:mm:ss")
            # TODO: Checksums
        } | Remove-NullProperties)
    }

    [bool] UpdateHashIfNeeded() {
        if ($this.IsAvailableLocally) {
            $oldHash = $this.FileHash
            $this.FileHash = Invoke-GetFileHash -Path $this.File.FullName -Algorithm $this.DigestAlgorithm
            if ($oldHash -ne $this.FileHash) {
                return $true;
            }
        }
        return $false;
    }

    [bool]RefreshState() {
        $result = $false
        if ($this.State -eq [WslImageState]::NotDownloaded -and $this.IsAvailableLocally) {
            $this.State = [WslImageState]::Synced
            $this.UpdateHashIfNeeded() | Out-Null
            $result = $true
        }
        if ($null -ne $this.Source -and $this.FileHash -ne $this.Source.Digest) {
            if ($this.State -eq [WslImageState]::Synced) {
                $this.State = [WslImageState]::Outdated
                $result = $true
            } else { # Not downloaded, so just update from source
                $this.UpdateFromSource()
                $result = $true
            }
        }
        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
    }

    [PSCustomObject]GetHashSource() {
        $hashSource = $null
        if ($this.Type -eq [WslImageType]::Docker -or $this.Type -eq [WslImageType]::Builtin) {
            $hashSource = [PSCustomObject]@{
                Url       = $this.Url.AbsoluteUri
                Type      = 'docker'
                Algorithm = 'SHA256'
                Mandatory = $true
            }
        } elseif ($null -ne $this.DigestUrl) {
            $hashSource = [PSCustomObject]@{
                Url       = $this.DigestUrl.AbsoluteUri
                Algorithm = $this.DigestAlgorithm
                Type      = $this.DigestType
                Mandatory = $false
            }
        } elseif ($this.Type -eq [WslImageType]::Local -and $null -ne $this.Url) {
            $hashSource = [PSCustomObject]@{
                Url       = $this.Url.AbsoluteUri
                Algorithm = 'SHA256'
                Type      = 'sums'
                Mandatory = $false
            }
        }
        return $hashSource
    }

    [void]DownloadAndCheckFile() {
        if ($this.IsAvailableLocally -and -not $this.Outdated) {
            return
        }
        $Destination = $this.File
        $Uri = $this.Url
        $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 {
                # FIXME: This should be OnlineHash
                $expected = if ($this.Outdated) { $this.OnlineHash } else { $this.FileHash }
                Sync-File $Uri $temp
            }

            $actual = Invoke-GetFileHash -Path $temp.FullName -Algorithm $this.DigestAlgorithm
            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
            $this.FileHash = $actual
            $this.State = [WslImageState]::Synced
            # TODO: Should persist state
        }
        finally {
            Remove-Item $temp -Force -ErrorAction SilentlyContinue
        }
        Write-Verbose "Downloaded image $Uri to $Destination"
    }
}