Public/Symlinks/New-VmSymlink.ps1
|
<#
.SYNOPSIS Creates or reconciles a symbolic link on a Hyper-V VM under sudo. .DESCRIPTION Single-round-trip primitive that ensures <Path> is a symlink pointing at <Target>. Idempotent on a matching link; throws when <Path> exists as anything else (regular file, directory, symlink to a different target). The conflict refusal is intentional: silently replacing a real file with a symlink is the worst class of bug (data loss with no audit trail), so the cmdlet is a primitive and leaves the "what now" routing to the caller. The remote script runs under `set -e` and uses exit code 65 (EX_DATAERR from sysexits) to signal a conflict back to the host layer, which surfaces it as a PowerShell exception naming the path and the observed file type. Path and target are validated host-side before any SSH call: both must be non-empty absolute paths with no `..` segments, no NUL byte, and no single quote (the remote script embeds both inside single-quoted bash assignments). .PARAMETER SshClient A live Renci.SshNet.SshClient. The caller owns the client's lifecycle - this function neither connects nor disposes it. .PARAMETER Path Absolute path on the VM where the symlink should exist. .PARAMETER Target Absolute path on the VM that the symlink should point at. .EXAMPLE New-VmSymlink -SshClient $ssh -Path '/usr/local/bin/foo' -Target '/opt/foo/bin/foo' .NOTES The on-VM commands run under sudo so the function can write to privileged locations regardless of which user the SSH client authenticated as. The caller is responsible for ensuring that user has password-less sudo. #> function New-VmSymlink { [CmdletBinding()] param( [Parameter(Mandatory)] [object] $SshClient, [Parameter(Mandatory)] [string] $Path, [Parameter(Mandatory)] [string] $Target ) # Host-side path validation. Both ends embed into a single-quoted # bash assignment in the emitted script, so a single quote in either # would break out of the quoting and let the value be interpreted as # shell syntax. NUL and `..` are rejected for the usual reasons # (truncation in C-string parsers, traversal). The validator # intentionally runs before any SSH call so malformed input never # touches the wire. foreach ($pair in @(@{ Name = 'Path'; Value = $Path }, @{ Name = 'Target'; Value = $Target })) { $name = $pair.Name $value = $pair.Value if ([string]::IsNullOrEmpty($value)) { throw "New-VmSymlink: -$name must be a non-empty string." } if (-not $value.StartsWith('/')) { throw "New-VmSymlink: -$name '$value' must be an absolute path (start with '/')." } if ($value.Contains([char]0)) { throw "New-VmSymlink: -$name contains a NUL byte." } if ($value.Contains("'")) { throw "New-VmSymlink: -$name '$value' contains a single quote, which is not allowed." } $segments = $value.Split('/') if ($segments -contains '..') { throw "New-VmSymlink: -$name '$value' contains a '..' segment." } } $vmHost = if ($SshClient.PSObject.Properties['ConnectionInfo'] -and $SshClient.ConnectionInfo) { $SshClient.ConnectionInfo.Host } else { '(unknown)' } # EX_DATAERR from sysexits.h - used as the conflict signal so the # host layer can distinguish "wrong file type at Path" from generic # remote failures (which surface as their own exit codes). $conflictExitCode = 65 $script = @" set -e path='$Path' target='$Target' if [ -L "`$path" ] && [ "`$(readlink "`$path")" = "`$target" ]; then exit 0 fi if [ -e "`$path" ] || [ -L "`$path" ]; then if [ -L "`$path" ]; then echo "exists as symlink to `$(readlink "`$path")" >&2 elif [ -d "`$path" ]; then echo "exists as directory" >&2 elif [ -f "`$path" ]; then echo "exists as regular file" >&2 else echo "exists as other (not a regular file, directory, or symlink)" >&2 fi exit $conflictExitCode fi sudo ln -s "`$target" "`$path" "@ # Windows PowerShell here-strings use CRLF; remote bash interprets # the trailing \r as part of the token. Normalise to LF, same as # the rest of the module. $script = $script -replace "`r`n", "`n" $result = Invoke-SshClientCommand -SshClient $SshClient -Command $script if ($result.ExitStatus -eq 0) { return } if ($result.ExitStatus -eq $conflictExitCode) { throw ("New-VmSymlink: conflict at '$Path' on VM $vmHost - " + "$($result.Error.Trim()). Target was '$Target'.") } throw ("New-VmSymlink failed (vm: $vmHost, path: $Path, target: $Target, " + "exit $($result.ExitStatus)). stdout: $($result.Output) stderr: $($result.Error)") } |