Public/FileTransfer/Copy-VmFiles.ps1

<#
.SYNOPSIS
    Copies host files to a Hyper-V VM via the host file server and SSH.
 
.DESCRIPTION
    For each entry in -Entries, stages the host file via Add-VmFileServerFile
    and runs (under sudo on the VM): mkdir -p of the parent target dir, curl
    -fsSL -o of the staged URL, then chown / chmod to the requested values.
 
    By default the per-entry remote script first reconciles against the VM:
    it computes sha256sum on the target and stat -c '%U:%G %a' for owner +
    mode, compares against the host-computed SHA-256 and the requested owner
    and mode, and exits 0 with no writes when all three match. Any mismatch
    falls through to the existing mkdir/curl/chown/chmod sequence. Both the
    reconcile and the write share a single SSH round-trip per entry.
 
    Pass -NoSkipUnchanged to force the always-write path (e.g. recovering
    from out-of-band tampering where the reconcile would otherwise skip).
 
    This is a pure transport primitive. It has no opinion on where the
    entries came from or what they semantically represent - the caller
    owns the schema and any policy around what is allowed in their
    context.
 
    Each entry is processed in its own SSH round-trip so a per-entry error
    message can name both source and target. Errors abort - downstream
    entries are not attempted, mirroring the behaviour of "set -e" in a
    shell script.
 
.PARAMETER SshClient
    A live Renci.SshNet.SshClient. The caller owns the client's lifecycle.
    Connecting and disposing is NOT the responsibility of this function.
 
.PARAMETER Server
    A file-server handle returned by Start-VmFileServer or supplied by
    Invoke-WithVmFileServer's scriptblock. The handle's BaseUrl is used
    by Add-VmFileServerFile to construct the per-entry URL.
 
.PARAMETER Entries
    An array of entry descriptors. Each entry MUST expose:
      - Source : a host path that already exists.
      - Target : an absolute Linux path on the VM.
    Each entry MAY expose:
      - Owner : a chown argument string. Defaults to 'root:root'. Pass
                 'user' or 'user:group' as you would to chown directly.
      - Mode : a chmod argument string. Defaults to '0644'.
 
    Hashtables and PSCustomObjects both work. Validation of these fields
    against any caller-specific schema is the caller's responsibility;
    Assert-VmFilesField in this module provides the shared shape checks.
 
.PARAMETER NoSkipUnchanged
    Forces the always-write path - every entry runs mkdir -p + curl + chown
    + chmod regardless of whether the VM-side state already matches. Off by
    default; the skip-unchanged path produces the same observable state at
    lower cost, so callers only need this switch to force a re-write.
 
.EXAMPLE
    Invoke-WithVmFileServer -VmIpAddress '10.10.0.50' -ScriptBlock {
        param($server)
        $sshClient = New-VmSshClient -IpAddress '10.10.0.50' -Username 'admin' -Password 'secret'
        try {
            Copy-VmFiles -SshClient $sshClient -Server $server -Entries @(
                @{ Source = 'C:\jars\foo.jar'; Target = '/opt/lib/foo.jar' },
                @{ Source = 'C:\seed.json'; Target = '/var/data/seed.json'; Owner = 'app'; Mode = '0640' }
            )
        }
        finally {
            if ($sshClient.IsConnected) { $sshClient.Disconnect() }
            $sshClient.Dispose()
        }
    }
 
.NOTES
    All on-VM commands are issued under sudo so the function can satisfy
    any combination of Owner/Mode regardless of which user the SSH client
    authenticated as. The caller is responsible for ensuring that user
    has password-less sudo (cloud-init's default admin user does).
#>

function Copy-VmFiles {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [object] $SshClient,

        [Parameter(Mandatory)]
        [object] $Server,

        [Parameter(Mandatory)]
        [object[]] $Entries,

        [switch] $NoSkipUnchanged
    )

    foreach ($entry in $Entries) {
        $source = $entry.Source
        $target = $entry.Target
        # Defaults match the system-level read-only file shape. Callers
        # override per-entry by setting Owner / Mode on the entry object.
        # The existence probe must be shape-aware: hashtables expose keys
        # via ContainsKey (NOT via PSObject.Properties); PSCustomObjects
        # do the opposite. A plain `$entry.Owner` would also throw under
        # Set-StrictMode -Version Latest when the property is absent on a
        # PSCustomObject (Pester unit tests enable strict mode).
        $hasOwner = if ($entry -is [hashtable]) { $entry.ContainsKey('Owner') }
                    else { $null -ne $entry.PSObject.Properties['Owner'] }
        $hasMode  = if ($entry -is [hashtable]) { $entry.ContainsKey('Mode') }
                    else { $null -ne $entry.PSObject.Properties['Mode'] }
        $owner = if ($hasOwner -and $entry.Owner) { $entry.Owner } else { 'root:root' }
        $mode  = if ($hasMode  -and $entry.Mode)  { $entry.Mode  } else { '0644' }

        $url = Add-VmFileServerFile -Server $Server -LocalPath $source

        # PS-side $target / $url / $owner / $mode / $hash interpolate at
        # construction; backtick-prefixed shell variables stay literal so
        # the running shell dereferences its own copies. mkdir -p is a
        # no-op when the parent already exists; curl -fsSL follows
        # redirects, fails on HTTP errors, stays silent on success.
        if ($NoSkipUnchanged) {
            # Byte-for-byte the pre-change shape. No reconcile, no hash.
            $script = @"
set -e
target='$target'
url='$url'
owner='$owner'
mode='$mode'
sudo mkdir -p "`$(dirname "`$target")"
sudo curl -fsSL -o "`$target" "`$url"
sudo chown "`$owner" "`$target"
sudo chmod "`$mode" "`$target"
"@

        }
        else {
            # Host-side hash is the one piece the VM cannot derive on its
            # own; the rest of the reconcile (remote hash + owner + mode)
            # happens in the same SSH round-trip that would do the write,
            # so an unchanged entry pays one round-trip total. Lowercased
            # to match sha256sum's hex output for a direct string compare.
            $hash = (Get-FileHash -Path $source -Algorithm SHA256).Hash.ToLowerInvariant()

            # sha256sum / stat run under sudo since the target may be
            # root-owned. 2>/dev/null + the awk pipeline / "|| true"
            # absorb the "file missing" exit code so the reconcile block
            # falls through to the write path on first run instead of
            # tripping set -e. Mode is compared as an octal NUMBER via
            # $((8#...)) so '0644' (entry) matches '644' (stat output).
            $script = @"
set -e
target='$target'
url='$url'
owner='$owner'
mode='$mode'
expected_hash='$hash'
actual_hash="`$(sudo sha256sum "`$target" 2>/dev/null | awk '{print `$1}')"
actual_meta="`$(sudo stat -c '%U:%G %a' "`$target" 2>/dev/null || true)"
actual_owner="`${actual_meta%% *}"
actual_mode="`${actual_meta##* }"
if [ -n "`$actual_hash" ] && [ "`$actual_hash" = "`$expected_hash" ] && [ "`$actual_owner" = "`$owner" ] && [ "`$((8#`${actual_mode:-0}))" = "`$((8#`$mode))" ]; then
    exit 0
fi
sudo mkdir -p "`$(dirname "`$target")"
sudo curl -fsSL -o "`$target" "`$url"
sudo chown "`$owner" "`$target"
sudo chmod "`$mode" "`$target"
"@

        }

        # Windows PowerShell here-strings use CRLF; remote bash interprets
        # the trailing \r as part of the token (e.g. "set -e\r" -> invalid
        # option "-", "root:root\r" -> invalid group). Normalise to LF.
        $script = $script -replace "`r`n", "`n"

        $result = Invoke-SshClientCommand -SshClient $SshClient -Command $script
        if ($result.ExitStatus -ne 0) {
            throw ("Copy-VmFiles failed (source: $source, target: $target, " +
                "exit $($result.ExitStatus)). " +
                "stdout: $($result.Output) stderr: $($result.Error)")
        }
    }
}