Public/Symlinks/Remove-VmSymlink.ps1
|
<#
.SYNOPSIS Removes a symbolic link from a Hyper-V VM under sudo. .DESCRIPTION Single-round-trip primitive that removes the symlink at <Path>. Idempotent: no-op when <Path> does not exist. Refuses to delete anything that is not a symlink (regular file, directory, other) so the cmdlet cannot be used to silently wipe real data through a typo in <Path>. The conflict refusal mirrors New-VmSymlink's contract on the install side. 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 is validated host-side before any SSH call: must be a non-empty absolute path with no `..` segments, no NUL byte, and no single quote (the remote script embeds it inside a single-quoted bash assignment). .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 of the symlink to remove. .EXAMPLE Remove-VmSymlink -SshClient $ssh -Path '/usr/local/bin/foo' .NOTES The on-VM commands run under sudo so the function can remove links in privileged locations regardless of which user the SSH client authenticated as. The caller is responsible for ensuring that user has password-less sudo. #> function Remove-VmSymlink { [CmdletBinding()] param( [Parameter(Mandatory)] [object] $SshClient, [Parameter(Mandatory)] [string] $Path ) # Host-side path validation. <Path> embeds into a single-quoted # bash assignment in the emitted script, so a single quote 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. if ([string]::IsNullOrEmpty($Path)) { throw "Remove-VmSymlink: -Path must be a non-empty string." } if (-not $Path.StartsWith('/')) { throw "Remove-VmSymlink: -Path '$Path' must be an absolute path (start with '/')." } if ($Path.Contains([char]0)) { throw "Remove-VmSymlink: -Path contains a NUL byte." } if ($Path.Contains("'")) { throw "Remove-VmSymlink: -Path '$Path' contains a single quote, which is not allowed." } if ($Path.Split('/') -contains '..') { throw "Remove-VmSymlink: -Path '$Path' 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' if [ ! -e "`$path" ] && [ ! -L "`$path" ]; then exit 0 fi if [ ! -L "`$path" ]; then if [ -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 rm "`$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 ("Remove-VmSymlink: conflict at '$Path' on VM $vmHost - " + "$($result.Error.Trim()) (refusing to remove a non-symlink).") } throw ("Remove-VmSymlink failed (vm: $vmHost, path: $Path, " + "exit $($result.ExitStatus)). stdout: $($result.Output) stderr: $($result.Error)") } |