BackupWindowsToLinux.psm1

#Region '.\_PrefixCode.ps1' -1

# Code in here will be prepended to the top of the compiled .psm1 file.
#EndRegion '.\_PrefixCode.ps1' 2
#Region '.\Classes\LinuxBackupSession.ps1' -1

class LinuxBackupSession {
    # Connection properties
    [string]$Source
    [string]$Destination
    [string]$HostName
    [string]$UserName
    [string]$KeyFile
    [int]$Port
    [string[]]$ExcludePatterns
    [bool]$DryRun
    [bool]$DeleteOrphaned
    [string]$VerbosePreference

    # Internal state
    [string]$sshTarget
    [array]$sshArgs
    [int]$sourceLength
    [hashtable]$remoteIndex
    [hashtable]$createdDirs
    [hashtable]$stats
    [System.Text.Encoding]$previousOutputEncoding

    # Constructor
    LinuxBackupSession(
        [string]$Source,
        [string]$Destination,
        [string]$HostName,
        [string]$UserName,
        [string]$KeyFile,
        [int]$Port,
        [string[]]$ExcludePatterns,
        [bool]$DryRun,
        [bool]$DeleteOrphaned,
        [string]$VerbosePreference
    ) {
        $this.Source = $Source
        $this.Destination = $Destination
        $this.HostName = $HostName
        $this.UserName = $UserName
        $this.KeyFile = $KeyFile
        $this.Port = $Port
        $this.ExcludePatterns = $ExcludePatterns
        $this.DryRun = $DryRun
        $this.DeleteOrphaned = $DeleteOrphaned
        $this.VerbosePreference = $VerbosePreference

        $this.remoteIndex = @{}
        $this.createdDirs = @{}
        $this.stats = @{
            Total = 0
            Uploaded = 0
            Skipped = 0
            Failed = 0
            BytesTransferred = 0
        }
    }

    # Helper method to escape special characters for bash shell
    [string] ConvertToBashEscapedString([string]$InputString) {
        # Escape single quotes by ending quote, adding escaped quote, and starting quote again
        return $InputString -replace "'", "'\''"
    }

    # Test if OpenSSH client is installed
    [void] TestOpenSSHClient() {
        $sshExe = Get-Command ssh -ErrorAction SilentlyContinue
        $scpExe = Get-Command scp -ErrorAction SilentlyContinue

        if (-not $sshExe -or -not $scpExe) {
            Write-Host "Tip: Install via Settings > Apps > Optional Features > OpenSSH Client" -ForegroundColor Yellow
            Write-Host " Or (as Administrator): Add-WindowsCapability -Online -Name OpenSSH.Client~~~~0.0.1.0" -ForegroundColor Yellow
            throw "OpenSSH client (ssh/scp) not found on this system"
        }
    }

    # Validate and normalize the source path
    [void] ValidateAndNormalizeSource() {
        if (-not (Test-Path $this.Source)) {
            throw "Source path '$($this.Source)' does not exist"
        }

        # Normalize paths - handle both local and UNC paths
        $this.Source = (Resolve-Path $this.Source).Path

        # Remove PowerShell provider prefix if present
        if ($this.Source -match '^[^:]+::[A-Z]:\\') {
            $this.Source = $this.Source -replace '^[^:]+::', ''
        }
        elseif ($this.Source -match '^[^:]+::\\\\') {
            $this.Source = $this.Source -replace '^[^:]+::', ''
        }

        # Ensure trailing backslash for consistent string operations
        if (-not $this.Source.EndsWith('\')) {
            $this.Source += '\'
        }

        $this.sourceLength = $this.Source.Length

        if ($this.VerbosePreference -eq 'Continue') {
            Write-Host "Normalized source path: $($this.Source)" -ForegroundColor Gray
            Write-Host "Source length: $($this.sourceLength)" -ForegroundColor Gray
        }
    }

    # Normalize the destination path
    [void] NormalizeDestination() {
        $this.Destination = $this.Destination.Replace('\', '/')
        if (-not $this.Destination.EndsWith('/')) {
            $this.Destination += '/'
        }
    }

    # Initialize SSH connection parameters
    [void] InitializeSSHConnection() {
        $this.sshTarget = "$($this.UserName)@$($this.HostName)"
        $this.sshArgs = @("-p", $this.Port, "-o", "StrictHostKeyChecking=no")

        if ($this.KeyFile) {
            if (-not (Test-Path $this.KeyFile)) {
                throw "Key file not found: $($this.KeyFile)"
            }
            $this.sshArgs += @("-i", $this.KeyFile)
            Write-Host "Authentication: SSH key ($($this.KeyFile))" -ForegroundColor Cyan
        }
        else {
            Write-Host "Authentication: Default SSH keys (~/.ssh/id_rsa, id_ed25519, etc.)" -ForegroundColor Cyan
            Write-Host "Note: For automated/scheduled backups, use -KeyFile parameter" -ForegroundColor Yellow
        }
    }

    # Test SSH connection to remote host
    [void] TestSSHConnection() {
        Write-Host "Testing SSH connection..." -ForegroundColor Cyan
        $testCmd = "echo 'Connection successful'"
        $sshTestArgs = $this.sshArgs + @($this.sshTarget, $testCmd)

        $testResult = & ssh @sshTestArgs
        if ($LASTEXITCODE -ne 0) {
            Write-Host "Troubleshooting tips:" -ForegroundColor Yellow
            Write-Host " 1. Verify hostname/IP is correct and reachable" -ForegroundColor Yellow
            Write-Host " 2. Ensure SSH key is set up: ssh-copy-id $($this.UserName)@$($this.HostName)" -ForegroundColor Yellow
            Write-Host " 3. Test manually: ssh -i `"$($this.KeyFile)`" $($this.UserName)@$($this.HostName)" -ForegroundColor Yellow
            throw "SSH connection failed (exit code: $LASTEXITCODE): $testResult"
        }
        Write-Host "Connected successfully!" -ForegroundColor Green
    }

    # Create the destination directory on remote host
    [void] CreateDestinationDirectory() {
        Write-Host "Verifying destination directory..." -ForegroundColor Cyan
        $escapedDestination = $this.ConvertToBashEscapedString($this.Destination)
        $mkdirCmd = "export LC_ALL=C.UTF-8; mkdir -p '$escapedDestination'"
        $sshMkdirArgs = $this.sshArgs + @($this.sshTarget, $mkdirCmd)
        $null = & ssh @sshMkdirArgs
    }

    # Get list of local files to transfer
    [array] GetLocalFiles() {
        Write-Host "Scanning source files..." -ForegroundColor Cyan
        $localFiles = Get-ChildItem -Path $this.Source -Recurse -File

        if ($this.ExcludePatterns.Count -gt 0) {
            $originalCount = $localFiles.Count

            foreach ($pattern in $this.ExcludePatterns) {
                $localFiles = $localFiles | Where-Object {
                    if ($_.FullName.StartsWith($this.Source, [System.StringComparison]::OrdinalIgnoreCase)) {
                        $relativePath = $_.FullName.Substring($this.sourceLength).Replace('\', '/')
                    }
                    else {
                        $relativePath = ""
                    }

                    $fileNameMatch = $_.Name -like $pattern
                    $pathMatch = $relativePath -like "*/$pattern/*" -or $relativePath -like "$pattern/*" -or $relativePath -like "*/$pattern"
                    $fullPathMatch = $relativePath -like $pattern -or $relativePath -like "*/$pattern"

                    -not ($fileNameMatch -or $pathMatch -or $fullPathMatch)
                }
            }

            $excludedCount = $originalCount - $localFiles.Count
            if ($excludedCount -gt 0) {
                Write-Host "Excluded $excludedCount file(s) based on patterns" -ForegroundColor Yellow
            }
        }

        Write-Host "Found $($localFiles.Count) file(s) to process" -ForegroundColor Cyan
        Write-Host ""

        return $localFiles
    }

    # Build index of remote files
    [void] BuildRemoteIndex() {
        Write-Host "Building remote file index..." -ForegroundColor Cyan
        $this.remoteIndex = @{}

        $escapedDestination = $this.ConvertToBashEscapedString($this.Destination)
        $listCmd = "export LC_ALL=C.UTF-8; find '$escapedDestination' -type f -printf '%P\t%s\t%T@\n' 2>/dev/null || true"
        $sshListArgs = $this.sshArgs + @($this.sshTarget, $listCmd)
        $remoteList = & ssh @sshListArgs

        if ($remoteList) {
            foreach ($line in $remoteList) {
                if ($line -and $line.Trim()) {
                    $parts = $line -split "`t"
                    if ($parts.Count -eq 3) {
                        $this.remoteIndex[$parts[0]] = @{
                            Size = [long]$parts[1]
                            ModTime = [double]$parts[2]
                        }
                    }
                }
            }
        }

        Write-Host "Remote index contains $($this.remoteIndex.Count) file(s)" -ForegroundColor Cyan
        Write-Host ""
    }

    # Check if a file needs to be transferred
    [hashtable] CheckFileNeedsTransfer([System.IO.FileInfo]$file, [string]$relativePath) {
        $needsTransfer = $true
        $reason = "new file"

        if ($this.remoteIndex.ContainsKey($relativePath)) {
            $remoteFile = $this.remoteIndex[$relativePath]

            if ($file.Length -eq $remoteFile.Size) {
                $localModTime = [double]($file.LastWriteTimeUtc - [DateTime]'1970-01-01').TotalSeconds
                if ([Math]::Abs($localModTime - $remoteFile.ModTime) -lt 2) {
                    $needsTransfer = $false
                }
                else {
                    $reason = "modified (time)"
                }
            }
            else {
                $reason = "modified (size)"
            }
        }

        return @{
            NeedsTransfer = $needsTransfer
            Reason = $reason
        }
    }

    # Create a remote directory (cached to avoid redundant calls)
    [void] EnsureRemoteDirectory([string]$remoteDir) {
        if (-not $this.createdDirs.ContainsKey($remoteDir)) {
            if ($this.VerbosePreference -eq 'Continue') {
                Write-Host " Creating directory: $remoteDir" -ForegroundColor Gray
            }

            $doubleQuoteEscaped = $remoteDir -replace '([\$`"\\])', '\$1'
            $mkdirCmd = "export LC_ALL=C.UTF-8; mkdir -p `"$doubleQuoteEscaped`""
            $sshMkdirArgs = $this.sshArgs + @($this.sshTarget, $mkdirCmd)
            $mkdirResult = & ssh @sshMkdirArgs

            if ($LASTEXITCODE -ne 0) {
                Write-Host " Warning: mkdir failed for $remoteDir : $mkdirResult" -ForegroundColor Yellow
            }
            else {
                $this.createdDirs[$remoteDir] = $true
            }
        }
    }

    # Upload a file to the remote host
    [bool] UploadFile([System.IO.FileInfo]$file, [string]$remotePath) {
        $uploadSuccess = $false
        $fullCommandLength = $file.FullName.Length + $remotePath.Length + $this.sshTarget.Length + 200
        $useDirectSCP = $fullCommandLength -lt 2000

        if ($useDirectSCP) {
            try {
                $scpArgs = @("-P", $this.Port, "-o", "StrictHostKeyChecking=no")
                if ($this.KeyFile) {
                    $scpArgs += @("-i", $this.KeyFile)
                }
                $scpArgs += @($file.FullName, "$($this.sshTarget):${remotePath}")

                $scpResult = & scp @scpArgs
                if ($LASTEXITCODE -eq 0) {
                    $uploadSuccess = $true
                }
                else {
                    Write-Host " Direct SCP failed (exit code: $LASTEXITCODE), falling back to temp file method" -ForegroundColor Yellow
                    if ($this.VerbosePreference -eq 'Continue') {
                        Write-Host " SCP error: $scpResult" -ForegroundColor Gray
                    }
                }
            }
            catch {
                Write-Host " Direct SCP exception: $_, falling back to temp file method" -ForegroundColor Yellow
            }
        }
        else {
            Write-Host " Path too long ($fullCommandLength chars), using temp file method" -ForegroundColor Yellow
        }

        if (-not $uploadSuccess) {
            Write-Host " Using temp file method" -ForegroundColor Yellow

            $localTempFile = [System.IO.Path]::GetTempFileName()

            try {
                Copy-Item -Path $file.FullName -Destination $localTempFile -Force

                $remoteTempFile = "/tmp/psbackup_$(Get-Random).tmp"

                $scpTempArgs = @("-P", $this.Port, "-o", "StrictHostKeyChecking=no")
                if ($this.KeyFile) {
                    $scpTempArgs += @("-i", $this.KeyFile)
                }
                $scpTempArgs += @($localTempFile, "$($this.sshTarget):${remoteTempFile}")

                $null = & scp @scpTempArgs

                if ($LASTEXITCODE -eq 0) {
                    $doubleQuoteEscaped = $remotePath -replace '([\$`"\\])', '\$1'
                    $moveCmd = "export LC_ALL=C.UTF-8; mv '$remoteTempFile' `"$doubleQuoteEscaped`""
                    $sshMoveArgs = $this.sshArgs + @($this.sshTarget, $moveCmd)
                    $moveResult = & ssh @sshMoveArgs

                    if ($LASTEXITCODE -eq 0) {
                        $uploadSuccess = $true
                    }
                    else {
                        $rmCmd = "rm -f '$remoteTempFile'"
                        $sshRmArgs = $this.sshArgs + @($this.sshTarget, $rmCmd)
                        $null = & ssh @sshRmArgs

                        Write-Host " Move failed" -ForegroundColor Red
                        Write-Host " Remote path: $remotePath" -ForegroundColor Red
                        Write-Host " Error: $moveResult" -ForegroundColor Red
                        throw "Failed to move temp file to final destination"
                    }
                }
                else {
                    throw "Failed to upload temp file"
                }
            }
            finally {
                if (Test-Path $localTempFile) {
                    Remove-Item $localTempFile -Force -ErrorAction SilentlyContinue
                }
            }
        }

        return $uploadSuccess
    }

    # Set the modification time on the remote file
    [void] SetRemoteModificationTime([System.IO.FileInfo]$file, [string]$remotePath) {
        $doubleQuoteEscaped = $remotePath -replace '([\$`"\\])', '\$1'
        $unixTime = [int64]($file.LastWriteTimeUtc - [DateTime]'1970-01-01').TotalSeconds
        $touchCmd = "export LC_ALL=C.UTF-8; touch -d '@$unixTime' `"$doubleQuoteEscaped`""
        $sshTouchArgs = $this.sshArgs + @($this.sshTarget, $touchCmd)
        $touchResult = & ssh @sshTouchArgs

        if ($LASTEXITCODE -ne 0) {
            Write-Host " Warning: touch failed: $touchResult" -ForegroundColor Yellow
        }
    }

    # Process all files for backup
    [void] ProcessFiles([array]$localFiles) {
        $this.stats.Total = $localFiles.Count

        $counter = 0
        foreach ($file in $localFiles) {
            $counter++

            if ($file.FullName.StartsWith($this.Source, [System.StringComparison]::OrdinalIgnoreCase)) {
                $relativePath = $file.FullName.Substring($this.sourceLength).Replace('\', '/')
            }
            else {
                Write-Warning "File path doesn't match source path format."
                Write-Warning " File: $($file.FullName)"
                Write-Warning " Source: $($this.Source)"
                continue
            }
            $remotePath = $this.Destination + $relativePath

            $lastSlash = $remotePath.LastIndexOf('/')
            if ($lastSlash -gt 0) {
                $remoteDir = $remotePath.Substring(0, $lastSlash)
            }
            else {
                $remoteDir = $this.Destination.TrimEnd('/')
            }

            $transferCheck = $this.CheckFileNeedsTransfer($file, $relativePath)

            $progressPercent = [int](($counter / $this.stats.Total) * 100)
            Write-Progress -Activity "Syncing files" -Status "$counter of $($this.stats.Total)" -PercentComplete $progressPercent

            if ($transferCheck.NeedsTransfer) {
                $fileSizeMB = [math]::Round($file.Length / 1MB, 2)
                Write-Host "[$counter/$($this.stats.Total)] Uploading: $relativePath ($fileSizeMB MB) - $($transferCheck.Reason)" -ForegroundColor Green

                if (-not $this.DryRun) {
                    try {
                        $this.EnsureRemoteDirectory($remoteDir)

                        $uploadSuccess = $this.UploadFile($file, $remotePath)

                        if (-not $uploadSuccess) {
                            throw "File upload failed"
                        }

                        $this.SetRemoteModificationTime($file, $remotePath)

                        $this.stats.Uploaded++
                        $this.stats.BytesTransferred += $file.Length
                    }
                    catch {
                        Write-Host " ERROR: $_" -ForegroundColor Red
                        $this.stats.Failed++
                    }
                }
                else {
                    $this.stats.Uploaded++
                }
            }
            else {
                if ($this.VerbosePreference -eq 'Continue') {
                    Write-Host "[$counter/$($this.stats.Total)] Skipped: $relativePath (unchanged)" -ForegroundColor Gray
                }
                $this.stats.Skipped++
            }
        }

        Write-Progress -Activity "Syncing files" -Completed
    }

    # Delete orphaned files on remote
    [void] DeleteOrphanedFiles([array]$localFiles) {
        Write-Host "`nChecking for orphaned files on remote..." -ForegroundColor Cyan
        $localRelativePaths = $localFiles | ForEach-Object {
            if ($_.FullName.StartsWith($this.Source, [System.StringComparison]::OrdinalIgnoreCase)) {
                $_.FullName.Substring($this.sourceLength).Replace('\', '/')
            }
        }
        $orphanedFiles = $this.remoteIndex.Keys | Where-Object { $_ -notin $localRelativePaths }

        if ($orphanedFiles.Count -gt 0) {
            Write-Host "Found $($orphanedFiles.Count) orphaned file(s)" -ForegroundColor Yellow

            foreach ($orphan in $orphanedFiles) {
                $remotePath = $this.Destination + $orphan
                Write-Host "Deleting file: $orphan" -ForegroundColor Yellow

                if (-not $this.DryRun) {
                    $doubleQuoteEscaped = $remotePath -replace '([\$`"\\])', '\$1'
                    $rmCmd = "export LC_ALL=C.UTF-8; rm -f `"$doubleQuoteEscaped`""
                    $sshRmArgs = $this.sshArgs + @($this.sshTarget, $rmCmd)
                    $result = & ssh @sshRmArgs

                    if ($LASTEXITCODE -ne 0) {
                        Write-Host " Warning: Failed to delete $orphan : $result" -ForegroundColor Red
                    }
                }
            }
        }
    }

    # Delete orphaned directories on remote
    [void] DeleteOrphanedDirectories([array]$localFiles) {
        Write-Host "Checking for orphaned directories on remote..." -ForegroundColor Cyan

        $localDirs = Get-ChildItem -Path $this.Source -Recurse -Directory | ForEach-Object {
            if ($_.FullName.StartsWith($this.Source, [System.StringComparison]::OrdinalIgnoreCase)) {
                $_.FullName.Substring($this.sourceLength).Replace('\', '/')
            }
        }

        $escapedDestination = $this.ConvertToBashEscapedString($this.Destination)
        $listDirsCmd = "export LC_ALL=C.UTF-8; find '$escapedDestination' -mindepth 1 -type d -printf '%P\n' 2>/dev/null || true"
        $sshListDirsArgs = $this.sshArgs + @($this.sshTarget, $listDirsCmd)
        $remoteDirs = & ssh @sshListDirsArgs

        if ($remoteDirs) {
            $orphanedDirs = @()
            foreach ($remoteDir in $remoteDirs) {
                $remoteDir = $remoteDir.Trim()
                if ($remoteDir -and ($remoteDir -notin $localDirs)) {
                    $orphanedDirs += $remoteDir
                }
            }

            if ($orphanedDirs.Count -gt 0) {
                Write-Host "Found $($orphanedDirs.Count) orphaned director(ies)" -ForegroundColor Yellow

                $orphanedDirs = $orphanedDirs | Sort-Object { ($_ -split '/').Count } -Descending

                foreach ($orphanDir in $orphanedDirs) {
                    $remoteDirPath = $this.Destination + $orphanDir
                    Write-Host "Deleting directory: $orphanDir" -ForegroundColor Yellow

                    if (-not $this.DryRun) {
                        $doubleQuoteEscaped = $remoteDirPath -replace '([\$`"\\])', '\$1'
                        $rmCmd = "export LC_ALL=C.UTF-8; rm -rf `"$doubleQuoteEscaped`""
                        $sshRmArgs = $this.sshArgs + @($this.sshTarget, $rmCmd)
                        $result = & ssh @sshRmArgs

                        if ($LASTEXITCODE -ne 0) {
                            Write-Host " Warning: Failed to delete directory $orphanDir : $result" -ForegroundColor Red
                        }
                    }
                }
            }
        }
    }

    # Display configuration summary
    [void] ShowConfiguration() {
        Write-Host "=== Backup Configuration ===" -ForegroundColor Cyan
        Write-Host "Source: $($this.Source)"
        Write-Host "Destination: $($this.HostName):$($this.Destination)"
        Write-Host "User: $($this.UserName)"
        Write-Host "Port: $($this.Port)"
        if ($this.DryRun) {
            Write-Host "Mode: DRY RUN (no files will be transferred)" -ForegroundColor Yellow
        }
        Write-Host ""
    }

    # Display summary statistics
    [void] ShowSummary() {
        Write-Host "`n=== Backup Summary ===" -ForegroundColor Cyan
        Write-Host "Total files: $($this.stats.Total)"
        Write-Host "Uploaded: $($this.stats.Uploaded)" -ForegroundColor Green
        Write-Host "Skipped: $($this.stats.Skipped)" -ForegroundColor Gray
        Write-Host "Failed: $($this.stats.Failed)" -ForegroundColor $(if ($this.stats.Failed -gt 0) { 'Red' } else { 'Gray' })
        Write-Host "Data transferred: $([math]::Round($this.stats.BytesTransferred / 1MB, 2)) MB"

        if ($this.DryRun) {
            Write-Host "`nThis was a DRY RUN - no files were actually transferred" -ForegroundColor Yellow
        }
    }

    # Main execution method
    [void] Execute() {
        $this.previousOutputEncoding = [Console]::OutputEncoding
        [Console]::OutputEncoding = [System.Text.Encoding]::UTF8

        try {
            $this.TestOpenSSHClient()
            $this.ValidateAndNormalizeSource()

            $this.NormalizeDestination()
            $this.ShowConfiguration()

            $this.InitializeSSHConnection()
            $this.TestSSHConnection()

            $this.CreateDestinationDirectory()

            $localFiles = $this.GetLocalFiles()
            $this.BuildRemoteIndex()

            $this.ProcessFiles($localFiles)

            if ($this.DeleteOrphaned) {
                $this.DeleteOrphanedFiles($localFiles)
                $this.DeleteOrphanedDirectories($localFiles)
            }

            $this.ShowSummary()
        }
        catch {
            Write-Host "ERROR: $_" -ForegroundColor Red
        }
        finally {
            [Console]::OutputEncoding = $this.previousOutputEncoding
        }
    }
}
#EndRegion '.\Classes\LinuxBackupSession.ps1' 584
#Region '.\Public\Sync-ToLinux.ps1' -1

function Sync-ToLinux {
    <#
    .SYNOPSIS
        Synchronizes files from Windows to a remote Linux system using OpenSSH
    .PARAMETER Source
        Local Windows path to backup (e.g., "C:\Data")
    .PARAMETER Destination
        Remote Linux path (e.g., "/home/user/backups")
    .PARAMETER HostName
        Remote Linux hostname or IP address
    .PARAMETER UserName
        SSH username for remote system
    .PARAMETER KeyFile
        Path to SSH private key file for key-based authentication (required for automated backups)
    .PARAMETER Port
        SSH port (default: 22)
    .PARAMETER ExcludePatterns
        Array of wildcard patterns to exclude files and directories
    .PARAMETER DryRun
        Show what would be transferred without actually transferring
    .PARAMETER DeleteOrphaned
        Delete files and directories on remote that no longer exist locally
    .PARAMETER Verbose
        Show detailed progress information
    .EXAMPLE
        Sync-ToLinux -Source "C:\Documents" -Destination "/backup/docs" -HostName "192.168.1.100" -UserName "admin" -KeyFile "$env:USERPROFILE\.ssh\id_rsa"
    .EXAMPLE
        Sync-ToLinux -Source "C:\Projects" -Destination "/backup" -HostName "server.com" -UserName "admin" -KeyFile "$env:USERPROFILE\.ssh\id_rsa" -Verbose
    .EXAMPLE
        Sync-ToLinux -Source "C:\Code" -Destination "/backup/code" -HostName "server.com" -UserName "admin" -KeyFile "$env:USERPROFILE\.ssh\backup_key" -ExcludePatterns @("node_modules", ".git", "*.tmp")
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory=$true)]
        [string]$Source,

        [Parameter(Mandatory=$true)]
        [string]$Destination,

        [Parameter(Mandatory=$true)]
        [string]$HostName,

        [Parameter(Mandatory=$true)]
        [string]$UserName,

        [string]$KeyFile,

        [int]$Port = 22,

        [string[]]$ExcludePatterns = @(),

        [switch]$DryRun,

        [switch]$DeleteOrphaned
    )

    $session = [LinuxBackupSession]::new(
        $Source,
        $Destination,
        $HostName,
        $UserName,
        $KeyFile,
        $Port,
        $ExcludePatterns,
        $DryRun.IsPresent,
        $DeleteOrphaned.IsPresent,
        $VerbosePreference
    )

    $session.Execute()
}
#EndRegion '.\Public\Sync-ToLinux.ps1' 72