diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000..fe8b2b8 Binary files /dev/null and b/.DS_Store differ diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..b3b97a6 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,40 @@ +--- +name: Bug report +about: Report a reproducible issue in PowerScp +title: "" +labels: bug +assignees: "" +--- + +## Summary + +Describe the bug clearly and concisely. + +## Steps to reproduce + +1. Import the module +2. Run the command or script +3. Observe the incorrect behavior + +## Expected behavior + +What you expected to happen. + +## Actual behavior + +What happened instead. + +## Environment + +- OS: +- PowerShell version: +- Module version / commit: +- Command or workflow involved: + +## Logs or output + +Paste any relevant output, redacted logs, or error messages. + +## Additional context + +Add screenshots, sample paths, or notes that help explain the issue. diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..90b9719 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,5 @@ +blank_issues_enabled: false +contact_links: + - name: Project discussion + url: https://github.com/PsCustomObject/PowerScp/discussions + about: Ask general questions or discuss ideas outside of a bug or feature report. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..5a15a02 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,27 @@ +--- +name: Feature request +about: Suggest an enhancement or new capability for PowerScp +title: "" +labels: enhancement +assignees: "" +--- + +## Suggested enhancement + +Describe the feature or improvement you would like to see. + +## Why this would help + +Explain the problem it solves or the workflow it improves. + +## Proposed behavior + +Describe the intended command, parameter, or workflow. + +## Compatibility considerations + +Note any impact on PowerShell versions, WinSCP compatibility, or existing transfer behavior. + +## Examples + +Provide sample usage or expected results if relevant. diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..9e5519b --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,20 @@ +## Summary + +Describe the change and the problem it solves. + +## Scope + +- What changed +- Why the change was needed +- Any compatibility or migration considerations + +## Validation + +- [ ] Relevant tests run +- [ ] Module imports successfully +- [ ] Documentation updated where needed +- [ ] No unrelated files changed + +## Notes + +Add any additional context, concerns, or follow-up work here. diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index d943400..f492e95 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -1,5 +1,8 @@ name: PowerShell tests on: [push, pull_request] +env: + POWERSHELL_TELEMETRY_OPTOUT: '1' + POWERSHELL_UPDATECHECK: 'Off' permissions: contents: read jobs: @@ -21,4 +24,6 @@ jobs: } Install-Module Pester -RequiredVersion 5.7.1 -Force -Scope CurrentUser -SkipPublisherCheck Import-Module Pester -RequiredVersion 5.7.1 + Install-Module PSScriptAnalyzer -RequiredVersion 1.24.0 -Force -Scope CurrentUser + ./scripts/Test-StaticAnalysis.ps1 Invoke-Pester ./tests -CI -Output Detailed diff --git a/CHANGELOG.md b/CHANGELOG.md index 9590d7f..3616856 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,17 @@ # PowerScp change history +## 1.2.1 - 2026-10-05 + +- Split public commands and private helpers into individual files, preserving the command interface and session lifecycle. + +- Treat rename destinations as exact names and reject existing directories. +- Preserve forced replacement targets in sibling backups; restore on failure when safe, otherwise report recovery paths. +- Reject directory-over-file replacement and directory sources for download renaming. +- Validate renamed downloads against Windows filename rules. +- Require Binary mode for content writes to preserve exact bytes. +- Dispose tracked sessions when the module is removed or force-reimported. +- Expand command help, regression tests, static analysis and opt-in live SFTP coverage. + ## 1.2.0 - 2026-10-05 - Compare practical features against the WinSCP 6.3.6.0 Gallery package and document command mappings and deliberate differences. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..041d16d --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,102 @@ +# Contributing to PowerScp + +Thanks for your interest in improving PowerScp. + +This project is a PowerShell module built around practical file-transfer automation. Contributions should keep the project focused on secure, readable, and reliable remote file operations without unnecessary scope expansion. + +## Project scope + +Please keep changes aligned with the module’s purpose: + +- PowerShell 5.1 and PowerShell 7 compatibility where appropriate +- WinSCP-backed transfer operations for common remote file workflows +- secure session handling and explicit connection validation +- clear, maintainable scripting behavior that helps automation teams work faster and more safely + +Avoid unrelated feature drift or large rewrites that move the project away from its core transfer-focused purpose. + +## Repository structure + +- `PowerScp.psd1` and `PowerScp.psm1` contain the module manifest and exported functionality. +- `docs/` contains design notes, reviews and testing guidance. +- `bin/` contains bundled WinSCP assets. +- `tests/` contains the project test suite. +- `scripts/` contains repository support scripts. +- `Staging/` contains staged or transitional work. + +## Development setup + +Requirements: + +- PowerShell 5.1 or PowerShell 7 +- WinSCP assembly dependencies bundled with the repo +- Pester 5.7.1 or later for test execution + +Typical workflow: + +```powershell +Import-Module Pester -RequiredVersion 5.7.1 +Import-Module ./PowerScp.psd1 -Force -ErrorAction Stop +Invoke-Pester ./tests +``` + +## Contribution workflow + +1. Create a focused branch for the work. +2. Keep changes scoped to one improvement or fix. +3. Preserve existing behavior unless the change intentionally updates documented semantics. +4. Validate the relevant tests and import behavior. +5. Update documentation when usage or compatibility changes. + +## Coding expectations + +- Prefer explicit, readable PowerShell over clever shortcuts. +- Keep session, connection and transfer behavior easy to reason about. +- Preserve secure defaults and validation-first patterns. +- Avoid silently swallowing operational failures. +- Add comments only when they clarify intent or non-obvious logic. +- Keep public cmdlet behavior predictable and consistent with the rest of the module. + +## Testing + +Run the project tests before submitting changes: + +```powershell +Invoke-Pester ./tests +``` + +If a change affects transfer logic, session handling, protocol behavior, path validation or security-sensitive workflows, add or update tests wherever practical. + +## Pull requests + +Keep pull requests focused and specific. + +Please include: + +- a short summary of the change +- the reason it was needed +- any compatibility or migration impact +- validation performed + +## Reporting bugs + +When filing an issue, include: + +- the command or script that failed +- expected behavior +- actual behavior +- operating system and PowerShell version +- repository commit or branch +- redacted sample input and output if relevant + +## Security and sensitive issues + +For security-sensitive issues, do not open a public issue. See [SECURITY.md](./SECURITY.md) for the appropriate reporting path. + +## Documentation + +If a change affects usage, protocol behavior, compatibility, or security assumptions, update the relevant documentation in the repo. + +## License + +By contributing, you agree that your contributions will be licensed under the project’s MIT license. diff --git a/PowerScp.psd1 b/PowerScp.psd1 index 4bc2d06..686a506 100644 --- a/PowerScp.psd1 +++ b/PowerScp.psd1 @@ -1,6 +1,6 @@ @{ RootModule = 'PowerScp.psm1' - ModuleVersion = '1.2.0' + ModuleVersion = '1.2.1' GUID = '3c42657a-e9fa-4358-a934-170727493e6c' Author = 'PsCustomObject - Daniele Catanesi' CompanyName = 'https://PsCustomObject.github.io' diff --git a/PowerScp.psm1 b/PowerScp.psm1 index e5209b4..fd81cf9 100644 --- a/PowerScp.psm1 +++ b/PowerScp.psm1 @@ -1,933 +1,48 @@ # Load the build appropriate to the PowerShell runtime before resolving WinSCP types. $assemblyPath = Join-Path $PSScriptRoot 'lib/WinSCPnet.dll' -if ($PSEdition -eq 'Core') { + +if ($PSEdition -eq 'Core') +{ $assemblyPath = Join-Path $PSScriptRoot 'lib/netstandard2.0/WinSCPnet.dll' } -if (!(Test-Path -LiteralPath $assemblyPath)) { throw "Missing WinSCP assembly: $assemblyPath. See README.md for dependency setup." } -Add-Type -Path $assemblyPath -ErrorAction Stop -function Assert-ScpPlatform { - if ([Environment]::OSVersion.Platform -ne [PlatformID]::Win32NT) { - throw 'WinSCP transfers require Windows. PowerShell 7 is supported on Windows.' - } -} -function Assert-ScpSession { - param($Session) - if ($null -eq $Session -or !$Session.Opened) { throw 'The WinSCP Session is not in an open state' } +if (!(Test-Path -LiteralPath $assemblyPath)) +{ + throw "Missing WinSCP assembly: $assemblyPath. See README.md for dependency setup." } -$script:ScpSessions = @{} -function Format-StringPath { - [CmdletBinding()] - [OutputType([string])] - param([Parameter(Mandatory, ValueFromPipeline)][string[]]$Path) - process { foreach ($item in $Path) { $item.Replace('\', '/') } } -} -function New-ScpSessionObject { - param([string]$SessionLogPath, [string]$DebugLogPath, [int]$DebugLevel = 0, - [timespan]$ReconnectTime = [timespan]::FromSeconds(120)) - Assert-ScpPlatform - $session = New-Object WinSCP.Session - $session.ExecutablePath = Join-Path $PSScriptRoot 'bin/WinSCP.exe' - $session.ReconnectTime = $ReconnectTime - $session.DebugLogLevel = $DebugLevel - if ($SessionLogPath) { $session.SessionLogPath = $SessionLogPath } - if ($DebugLogPath) { $session.DebugLogPath = $DebugLogPath } - $session -} -function New-ScpSessionOptions { - <# .SYNOPSIS - Build reusable connection options without opening a connection. - .DESCRIPTION - Credentials are a username/password for most protocols or an access key/secret - for S3. S3 uses TLS by default. SessionUrl accepts WinSCP session URLs; avoid - embedding passwords in URLs. Explicit parameters override parsed URL settings. - .PARAMETER S3CredentialsFromEnvironment - Let WinSCP read AWS environment variables or its supported AWS credential files. - .PARAMETER Scan - Build options for fingerprint scanning without requiring a trusted SSH key. - #> - [CmdletBinding()] - [OutputType([WinSCP.SessionOptions])] - param( - [Alias('Host','HostName')][string]$RemoteHost, - [string]$UserName, [AllowEmptyString()][string]$UserPassword, - [Alias('Credential')][pscredential]$Credentials, - [WinSCP.Protocol]$Protocol = 'Scp', - [Alias('Port','PortNumber')][ValidateRange(0,65535)][int]$ServerPort = 0, - [Alias('Timeout')][timespan]$ConnectionTimeOut = [timespan]::FromSeconds(15), - [switch]$NoSshKeyCheck, [switch]$NoTlsCheck, [string[]]$SshHostKeyFingerprint, - [WinSCP.SshHostKeyPolicy]$SshHostKeyPolicy = 'Check', - [Alias('SshPrivateKeyPath')][string]$SshKeyPath, [string]$SshKeyPassword, - [securestring]$SecurePrivateKeyPassphrase, [switch]$NoSSHKeyPassword, - [WinSCP.FtpMode]$FtpMode = 'Passive', [WinSCP.FtpSecure]$FtpSecure = 'None', - [switch]$WebDavSecure, [Alias('RootPath')][string]$WebDavRoot, - [bool]$Secure, [string]$TlsHostCertificateFingerprint, - [string]$TlsClientCertificatePath, [hashtable]$RawSettings, - [string]$SessionUrl, [switch]$Scan, - [ValidateNotNullOrEmpty()][string]$S3Bucket, - [ValidateNotNullOrEmpty()][string]$S3Region, - [securestring]$S3SessionToken, - [ValidateSet('VirtualHost','Path')][string]$S3UrlStyle, - [switch]$S3CredentialsFromEnvironment, [string]$S3Profile - ) - if ($Credentials -and ($PSBoundParameters.ContainsKey('UserName') -or $PSBoundParameters.ContainsKey('UserPassword'))) { - throw 'Use Credentials or UserName/UserPassword, not both.' - } - if ($SshKeyPassword -and $SecurePrivateKeyPassphrase) { throw 'Use one private-key passphrase parameter.' } - if ($ConnectionTimeOut -le [timespan]::Zero) { throw 'ConnectionTimeOut must be positive.' } - $options = New-Object WinSCP.SessionOptions - if ($SessionUrl) { $options.ParseUrl($SessionUrl) } - if (!$SessionUrl -or $PSBoundParameters.ContainsKey('Protocol')) { $options.Protocol = $Protocol } - # Set protocol before hostname: the assembly supplies the AWS endpoint for S3. - if ($RemoteHost) { $options.HostName = $RemoteHost } - if (!$SessionUrl -or $PSBoundParameters.ContainsKey('ServerPort')) { $options.PortNumber = $ServerPort } - if (!$SessionUrl -or $PSBoundParameters.ContainsKey('ConnectionTimeOut')) { $options.Timeout = $ConnectionTimeOut } - if ($Credentials) { $options.UserName = $Credentials.UserName; $options.SecurePassword = $Credentials.Password } - else { - if (!$SessionUrl -or $PSBoundParameters.ContainsKey('UserName')) { $options.UserName = $UserName } - if ($PSBoundParameters.ContainsKey('UserPassword')) { $options.Password = $UserPassword } - } - if ($PSBoundParameters.ContainsKey('SshHostKeyPolicy')) { $options.SshHostKeyPolicy = $SshHostKeyPolicy } - if ($NoSshKeyCheck) { - if ($PSBoundParameters.ContainsKey('SshHostKeyPolicy') -and $SshHostKeyPolicy -ne 'GiveUpSecurityAndAcceptAny') { - throw 'NoSshKeyCheck conflicts with SshHostKeyPolicy.' - } - $options.SshHostKeyPolicy = 'GiveUpSecurityAndAcceptAny' - } - elseif ($PSBoundParameters.ContainsKey('NoSshKeyCheck')) { $options.SshHostKeyPolicy = 'Check' } - if ($PSBoundParameters.ContainsKey('NoTlsCheck')) { $options.GiveUpSecurityAndAcceptAnyTlsHostCertificate = [bool]$NoTlsCheck } - if ($SshHostKeyFingerprint) { $options.SshHostKeyFingerprint = $SshHostKeyFingerprint -join ';' } - if (!$Scan -and $options.Protocol -in @('Scp','Sftp') -and $options.SshHostKeyPolicy -eq 'Check' -and !$options.SshHostKeyFingerprint) { - throw 'Specify SshHostKeyFingerprint or choose an explicit SshHostKeyPolicy.' - } - if ($SshKeyPassword -and !$SshKeyPath) { throw 'SshKeyPassword requires SshKeyPath.' } - if ($SshKeyPath) { $options.SshPrivateKeyPath = (Resolve-Path -LiteralPath $SshKeyPath -ErrorAction Stop).ProviderPath } - if ($SshKeyPassword) { $options.PrivateKeyPassphrase = $SshKeyPassword } - if ($SecurePrivateKeyPassphrase) { $options.SecurePrivateKeyPassphrase = $SecurePrivateKeyPassphrase } - if ($TlsClientCertificatePath) { $options.TlsClientCertificatePath = (Resolve-Path -LiteralPath $TlsClientCertificatePath -ErrorAction Stop).ProviderPath } - if ($TlsHostCertificateFingerprint) { $options.TlsHostCertificateFingerprint = $TlsHostCertificateFingerprint } - if (($PSBoundParameters.ContainsKey('FtpMode') -or $PSBoundParameters.ContainsKey('FtpSecure')) -and $options.Protocol -ne 'Ftp') { - throw 'FtpMode and FtpSecure require Protocol Ftp.' - } - if ($options.Protocol -eq 'Ftp') { - if (!$SessionUrl -or $PSBoundParameters.ContainsKey('FtpMode')) { $options.FtpMode = $FtpMode } - if (!$SessionUrl -or $PSBoundParameters.ContainsKey('FtpSecure')) { $options.FtpSecure = $FtpSecure } - } - if ($WebDavSecure -and $options.Protocol -ne 'Webdav') { throw 'WebDavSecure requires Protocol Webdav.' } - if (($WebDavRoot -or $PSBoundParameters.ContainsKey('Secure')) -and $options.Protocol -notin @('Webdav','S3')) { - throw 'RootPath and Secure require Protocol Webdav or S3.' - } - if ($PSBoundParameters.ContainsKey('Secure')) { $options.Secure = $Secure } - elseif ($PSBoundParameters.ContainsKey('WebDavSecure')) { $options.Secure = [bool]$WebDavSecure } - elseif ($options.Protocol -eq 'S3' -and (!$SessionUrl -or $PSBoundParameters.ContainsKey('Protocol'))) { $options.Secure = $true } - if ($WebDavSecure -and $PSBoundParameters.ContainsKey('Secure') -and !$Secure) { throw 'WebDavSecure conflicts with Secure false.' } - if ($WebDavRoot) { $options.RootPath = Format-StringPath $WebDavRoot } - $settings = @{} - foreach ($key in $RawSettings.Keys) { $settings[$key] = [string]$RawSettings[$key] } - $s3Parameters = @('S3Bucket','S3Region','S3SessionToken','S3UrlStyle','S3CredentialsFromEnvironment','S3Profile') - if (@($PSBoundParameters.Keys | Where-Object { $_ -in $s3Parameters }).Count -and $options.Protocol -ne 'S3') { - throw 'S3 settings require Protocol S3.' - } - if ($S3Bucket) { - if ($S3Bucket -match '[/\\]') { throw 'S3Bucket must be a bucket name, without a path.' } - if ($WebDavRoot) { throw 'Use S3Bucket or RootPath, not both.' } - $options.RootPath = '/' + $S3Bucket - } - if ($S3Region) { $settings['S3DefaultRegion'] = $S3Region } - if ($S3UrlStyle) { $settings['S3UrlStyle'] = [string][int]($S3UrlStyle -eq 'Path') } - if ($S3Profile) { - if ($PSBoundParameters.ContainsKey('S3CredentialsFromEnvironment') -and !$S3CredentialsFromEnvironment) { throw 'S3Profile requires environment credential lookup.' } - $S3CredentialsFromEnvironment = $true - $settings['S3Profile'] = $S3Profile - } - if ($S3CredentialsFromEnvironment -and ($Credentials -or $options.UserName -or $options.Password)) { - throw 'Use AWS environment/profile credentials or explicit access keys, not both.' - } - if ($PSBoundParameters.ContainsKey('S3CredentialsFromEnvironment') -or $S3Profile) { - $settings['S3CredentialsEnv'] = [string][int][bool]$S3CredentialsFromEnvironment - } - if ($S3SessionToken) { - # WinSCP raw settings require a string. Clear the temporary plaintext copy afterwards. - $token = [System.Net.NetworkCredential]::new('', $S3SessionToken).Password - try { $options.AddRawSettings('S3SessionToken', $token) } finally { $token = $null } - $settings.Remove('S3SessionToken') - } - foreach ($key in $settings.Keys) { $options.AddRawSettings([string]$key, $settings[$key]) } - $options -} -function Get-HostFingerPrint { - <# .SYNOPSIS - Scan a fingerprint; verify it independently before trusting it. - #> - [CmdletBinding(DefaultParameterSetName='Connection')] - param( - [Parameter(Mandatory,ValueFromPipeline,ParameterSetName='Options')][WinSCP.SessionOptions]$SessionOptions, - [Parameter(Mandatory,ParameterSetName='Connection')][Alias('Host','Server','RemoteServer')][string]$RemoteHost, - [Parameter(ParameterSetName='Connection')][string]$UserName, - [Parameter(ParameterSetName='Connection')][Alias('UserPassword')][string]$Password, - [Parameter(ParameterSetName='Connection')][pscredential]$Credentials, - [Parameter(ParameterSetName='Connection')][ValidateRange(0,65535)][int]$PortNumber=0, - [Parameter(ParameterSetName='Connection')][timespan]$ConnectionTimeOut=[timespan]::FromSeconds(15), - [ValidateSet('SHA-256','MD5')][string]$Algorithm='SHA-256', - [Parameter(ParameterSetName='Connection')][WinSCP.Protocol]$Protocol='Scp', - [Parameter(ParameterSetName='Connection')][WinSCP.FtpSecure]$FtpSecure='None', - [Parameter(ParameterSetName='Connection')][switch]$WebDavSecure - ) - process { - if ($PSCmdlet.ParameterSetName -eq 'Options') { $options = $SessionOptions } - else { - $arguments = @{ RemoteHost=$RemoteHost; ServerPort=$PortNumber; Protocol=$Protocol; ConnectionTimeOut=$ConnectionTimeOut; Scan=$true } - if ($Credentials) { $arguments.Credentials=$Credentials } - else { - if ($UserName) { $arguments.UserName=$UserName } - if ($PSBoundParameters.ContainsKey('Password')) { $arguments.UserPassword=$Password } - } - foreach ($key in @('FtpSecure','WebDavSecure')) { if ($PSBoundParameters.ContainsKey($key)) { $arguments[$key]=$PSBoundParameters[$key] } } - $options = New-ScpSessionOptions @arguments - } - $session = New-ScpSessionObject - try { $session.ScanFingerprint($options,$Algorithm) } finally { $session.Dispose() } - } -} -function New-ScpSession { - <# .SYNOPSIS - Open a session from connection parameters or reusable SessionOptions. - .PARAMETER Name - Optional name for retrieving this session with Get-ScpSession. Names must be unique. - #> - [CmdletBinding(SupportsShouldProcess,DefaultParameterSetName='Connection')] - [OutputType([WinSCP.Session])] - param( - [Parameter(Mandatory,ValueFromPipeline,ParameterSetName='Options')] - [Alias('SessionOption')][WinSCP.SessionOptions]$SessionOptions, - [Parameter(ParameterSetName='Connection')][Alias('Host','HostName')][string]$RemoteHost, - [Parameter(ParameterSetName='Connection')][string]$UserName, - [Parameter(ParameterSetName='Connection')][AllowEmptyString()][string]$UserPassword, - [Parameter(ParameterSetName='Connection')][Alias('Credential')][pscredential]$Credentials, - [Parameter(ParameterSetName='Connection')][Alias('ConnectionProtocol')][WinSCP.Protocol]$Protocol='Scp', - [Parameter(ParameterSetName='Connection')][Alias('Port','RemoteHostPort')][ValidateRange(0,65535)][int]$ServerPort=0, - [Parameter(ParameterSetName='Connection')][timespan]$ConnectionTimeOut=[timespan]::FromSeconds(15), - [Parameter(ParameterSetName='Connection')][Alias('GiveUpSecurityAndAcceptAnySshHostKey','AnySshKey','SshCheck','AcceptAnySshKey')][switch]$NoSshKeyCheck, - [Parameter(ParameterSetName='Connection')][Alias('GiveUpSecurityAndAcceptAnyTlsHostCertificate','AnyTlsCertificte','AcceptAnyCertificate')][switch]$NoTlsCheck, - [Parameter(ParameterSetName='Connection')][string[]]$SshHostKeyFingerprint, - [Parameter(ParameterSetName='Connection')][WinSCP.SshHostKeyPolicy]$SshHostKeyPolicy='Check', - [Parameter(ParameterSetName='Connection')][Alias('SshPrivateKey','SshPrivateKeyPath','SsheKeyPath')][string]$SshKeyPath, - [Parameter(ParameterSetName='Connection')][string]$SshKeyPassword, - [Parameter(ParameterSetName='Connection')][securestring]$SecurePrivateKeyPassphrase, - [Parameter(ParameterSetName='Connection')][switch]$NoSSHKeyPassword, - [Parameter(ParameterSetName='Connection')][WinSCP.FtpMode]$FtpMode='Passive', - [Parameter(ParameterSetName='Connection')][Alias('FtpSecureMode','SecureFtpMode')][WinSCP.FtpSecure]$FtpSecure='None', - [Parameter(ParameterSetName='Connection')][switch]$WebDavSecure, - [Parameter(ParameterSetName='Connection')][Alias('RootPath')][string]$WebDavRoot, - [Parameter(ParameterSetName='Connection')][bool]$Secure, - [Parameter(ParameterSetName='Connection')][string]$TlsHostCertificateFingerprint, - [Parameter(ParameterSetName='Connection')][string]$TlsClientCertificatePath, - [Parameter(ParameterSetName='Connection')][hashtable]$RawSettings, - [Parameter(ParameterSetName='Connection')][string]$SessionUrl, - [Parameter(ParameterSetName='Connection')][string]$S3Bucket, - [Parameter(ParameterSetName='Connection')][string]$S3Region, - [Parameter(ParameterSetName='Connection')][securestring]$S3SessionToken, - [Parameter(ParameterSetName='Connection')][ValidateSet('VirtualHost','Path')][string]$S3UrlStyle, - [Parameter(ParameterSetName='Connection')][switch]$S3CredentialsFromEnvironment, - [Parameter(ParameterSetName='Connection')][string]$S3Profile, - [string]$Name, - [string]$SessionLogPath, [string]$DebugLogPath, - [Alias('DebugLogLevel')][ValidateRange(-1,2)][int]$DebugLevel=0, - [timespan]$ReconnectTime=[timespan]::FromSeconds(120), - [string]$XmlLogPath, [switch]$XmlLogPreserve, [hashtable]$RawConfiguration - ) - process { - if ($Name -and $script:ScpSessions.ContainsKey($Name)) { throw "A session named '$Name' already exists. Remove it first." } - if ($PSCmdlet.ParameterSetName -eq 'Options') { $options = $SessionOptions } - else { - $arguments = @{} - $optionParameters = (Get-Command New-ScpSessionOptions).Parameters.Keys - foreach ($key in $PSBoundParameters.Keys) { - if ($key -in $optionParameters -and $key -notin @('WhatIf','Confirm','Verbose','Debug','ErrorAction','WarningAction','InformationAction','ErrorVariable','WarningVariable','InformationVariable','OutVariable','OutBuffer','PipelineVariable','ProgressAction')) { - $arguments[$key] = $PSBoundParameters[$key] - } - } - $options = New-ScpSessionOptions @arguments - } - if (!$options.HostName) { throw 'RemoteHost is required except when Protocol S3 supplies the AWS endpoint.' } - if ($PSCmdlet.ShouldProcess($options.HostName,'Open WinSCP session')) { - $sessionObject = New-ScpSessionObject -SessionLogPath $SessionLogPath -DebugLogPath $DebugLogPath -DebugLevel $DebugLevel -ReconnectTime $ReconnectTime - try { - if ($XmlLogPath) { $sessionObject.XmlLogPath = $XmlLogPath } - $sessionObject.XmlLogPreserve = [bool]$XmlLogPreserve - foreach ($key in $RawConfiguration.Keys) { $sessionObject.AddRawConfiguration([string]$key,[string]$RawConfiguration[$key]) } - $sessionObject.Open($options) - if (!$Name) { $sessionName = [guid]::NewGuid().ToString() } else { $sessionName = $Name } - $sessionObject | Add-Member -NotePropertyName ScpSessionName -NotePropertyValue $sessionName -Force - $sessionObject | Add-Member -NotePropertyName RemoteHost -NotePropertyValue $options.HostName -Force - $script:ScpSessions[$sessionName] = $sessionObject - $sessionObject - } - catch { $sessionObject.Dispose(); $PSCmdlet.ThrowTerminatingError($_) } - } - } -} +Add-Type -Path $assemblyPath -ErrorAction Stop -function Test-ScpSession { - <# .SYNOPSIS - Return whether a WinSCP session is open. - #> - [CmdletBinding()] - [OutputType([bool])] - param([Parameter(Mandatory,ValueFromPipeline)][AllowNull()][WinSCP.Session]$Session) - process { $null -ne $Session -and $Session.Opened } -} -function Remove-ScpSession { - <# .SYNOPSIS - Dispose a session; disposed sessions cannot be reused. - #> - [CmdletBinding(SupportsShouldProcess)] - param([Parameter(Mandatory,ValueFromPipeline)][WinSCP.Session]$Session) - process { if ($PSCmdlet.ShouldProcess('WinSCP session','Dispose')) { - $Session.Dispose() - foreach ($key in @($script:ScpSessions.Keys)) { - if ([object]::ReferenceEquals($script:ScpSessions[$key],$Session)) { $script:ScpSessions.Remove($key) } - } - $true - } } -} -function Test-ScpPath { - <# .SYNOPSIS - Test existence of a literal remote file or directory. - #> - [CmdletBinding()] - [OutputType([bool])] - param([Parameter(Mandatory,ValueFromPipeline)][WinSCP.Session]$Session, - [Parameter(Mandatory)][ValidateNotNullOrEmpty()][string[]]$RemotePath) - process { - Assert-ScpSession $Session - foreach ($path in $RemotePath) { $Session.FileExists((Format-StringPath $path)) } - } -} -function Get-ScpItemType { - <# .SYNOPSIS - Return metadata for literal remote paths, optionally filtering their names. - #> - [CmdletBinding()] - [OutputType([WinSCP.RemoteFileInfo])] - param([Parameter(Mandatory,ValueFromPipeline)][WinSCP.Session]$Session, - [Parameter(Mandatory)][ValidateNotNullOrEmpty()][string[]]$RemotePath, - [string]$Filter) - process { - Assert-ScpSession $Session - foreach ($path in $RemotePath) { - $item = $Session.GetFileInfo((Format-StringPath $path)) - if (!$Filter -or $item.Name -like $Filter) { $item } - } - } -} -function Get-ScpChildItem { - <# .SYNOPSIS - List remote directory contents, with optional recursion and file filtering. - .PARAMETER Depth - Maximum subdirectory levels. Zero means unlimited when Recurse is set. - #> - [CmdletBinding()] - [OutputType([WinSCP.RemoteFileInfo])] - param([Parameter(Mandatory,ValueFromPipeline)][WinSCP.Session]$Session, - [ValidateNotNullOrEmpty()][string[]]$RemotePath=@('.'), - [string]$Filter = '*', [switch]$Recurse, - [ValidateRange(0,2147483647)][int]$Depth = 0, [Alias('File')][switch]$FilesOnly, - [Alias('Directory')][switch]$DirectoriesOnly, [switch]$Name) - process { - Assert-ScpSession $Session - if ($FilesOnly -and $DirectoriesOnly) { throw 'Use FilesOnly or DirectoriesOnly, not both.' } - if ($Depth -gt 0 -and !$Recurse) { throw 'Depth requires Recurse.' } - foreach ($path in $RemotePath) { - $path = Format-StringPath $path - $options = [WinSCP.EnumerationOptions]::None - if ($Recurse) { $options = $options -bor [WinSCP.EnumerationOptions]::AllDirectories } - if (!$FilesOnly) { $options = $options -bor [WinSCP.EnumerationOptions]::MatchDirectories } - if ($Recurse -and $Depth -gt 0) { $root = $Session.GetFileInfo($path).FullName.TrimEnd('/') + '/' } - foreach ($item in $Session.EnumerateRemoteFiles($path, $Filter, $options)) { - if ($FilesOnly -and $item.IsDirectory) { continue } - if ($DirectoriesOnly -and !$item.IsDirectory) { continue } - if ($Recurse -and $Depth -gt 0) { - $relative = $item.FullName.Substring($root.Length) - if (($relative.Trim('/').Split('/').Length - 1) -gt $Depth) { continue } - } - if ($Name) { $item.Name } else { $item } - } - } - } -} -function Get-ScpItem { - <# .SYNOPSIS - List remote items. Retains the legacy enumeration behavior of Get-ScpItem. - #> - [CmdletBinding()] - param([Parameter(Mandatory,ValueFromPipeline)][WinSCP.Session]$Session, - [string[]]$RemotePath=@('.'), [string]$Filter='*', - [switch]$Recurse, [ValidateRange(0,2147483647)][int]$Depth=0, [switch]$FilesOnly, [switch]$DirectoriesOnly, [switch]$Name, [switch]$LiteralPath) - process { - if ($LiteralPath) { - if ($Recurse -or $Depth -or $FilesOnly -or $DirectoriesOnly -or $Name) { throw 'LiteralPath metadata lookup cannot be combined with listing switches.' } - Get-ScpItemType -Session $Session -RemotePath $RemotePath -Filter $Filter - } else { - $arguments = @{} - foreach ($key in $PSBoundParameters.Keys) { if ($key -ne 'LiteralPath') { $arguments[$key]=$PSBoundParameters[$key] } } - $arguments.Session=$Session - Get-ScpChildItem @arguments - } - } -} -function Get-ScpItemCheckSum { - <# .SYNOPSIS - Calculate a remote file checksum using a server-supported algorithm. - #> - [CmdletBinding()] - param([Parameter(Mandatory,ValueFromPipeline)][WinSCP.Session]$Session, - [Parameter(Mandatory)][Alias('RemotePath')][ValidateNotNullOrEmpty()][string[]]$ItemName, - [ValidateSet('md2','md5','sha-1','sha-224','sha-256','sha-384','sha-512','shake128','shake256')][string]$HashAlgorithm='sha-256') - process { - Assert-ScpSession $Session - foreach ($path in $ItemName) { $Session.CalculateFileChecksum($HashAlgorithm,(Format-StringPath $path)) } - } -} -function New-ScpTransferOptions { - <# .SYNOPSIS - Construct reusable WinSCP transfer options. - .PARAMETER Permissions - Unix octal permissions, such as 644, 755 or 0755. Each digit must be 0 through 7. - #> - [CmdletBinding()] - [OutputType([WinSCP.TransferOptions])] - param([ValidateRange(0,2147483647)][int]$SpeedLimit=0, [string]$FileMask, - [ValidatePattern('^[0-7]{3,4}$')][string]$Permissions, - [ValidateSet('Overwrite','Resume','Append')][string]$OverWriteMode='Overwrite', - [bool]$PreserveTimeStamp=$true, - [ValidateSet('Automatic','Binary','Ascii','Text')][string]$TransferMode='Binary', - [hashtable]$RawSettings, - [WinSCP.FilePermissions]$FilePermissions, - [WinSCP.TransferResumeSupport]$ResumeSupport) - if ($FilePermissions -and $PSBoundParameters.ContainsKey('Permissions')) { throw 'Use Permissions or FilePermissions, not both.' } - $options = New-Object WinSCP.TransferOptions - $options.SpeedLimit = $SpeedLimit - $options.FileMask = $FileMask - $options.OverwriteMode = $OverWriteMode - $options.PreserveTimestamp = $PreserveTimeStamp - if ($TransferMode -eq 'Text') { $TransferMode = 'Ascii' } - $options.TransferMode = $TransferMode - if ($PSBoundParameters.ContainsKey('Permissions')) { - $options.FilePermissions = New-Object WinSCP.FilePermissions - $options.FilePermissions.Octal = $Permissions - } - if ($FilePermissions) { $options.FilePermissions = $FilePermissions } - if ($ResumeSupport) { $options.ResumeSupport = $ResumeSupport } - foreach ($key in $RawSettings.Keys) { $options.AddRawSettings([string]$key,[string]$RawSettings[$key]) } - $options -} -function Resolve-ScpTransferOptions { - param([System.Collections.IDictionary]$Parameters) - if (($Parameters.Keys -contains 'TransferOptions')) { return $Parameters['TransferOptions'] } - $arguments = @{} - foreach ($key in @('SpeedLimit','FileMask','Permissions','OverWriteMode','PreserveTimeStamp','TransferMode')) { - if (($Parameters.Keys -contains $key)) { $arguments[$key] = $Parameters[$key] } - } - New-ScpTransferOptions @arguments -} -function New-ScpDirectory { - <# .SYNOPSIS - Create remote directories. Force creates missing parents. - #> - [CmdletBinding(SupportsShouldProcess)] - param([Parameter(Mandatory,ValueFromPipeline)][WinSCP.Session]$Session, - [Parameter(Mandatory)][ValidateNotNullOrEmpty()][string[]]$RemotePath, - [switch]$Force, [switch]$SuppressOutput) - process { - Assert-ScpSession $Session - foreach ($path in $RemotePath) { - $path = Format-StringPath $path - try { - if ($Session.FileExists($path)) { - if (!$Session.GetFileInfo($path).IsDirectory) { throw "Remote path is a file: $path" } - if (!$SuppressOutput) { $true } - continue - } - if ($PSCmdlet.ShouldProcess($path,'Create remote directory')) { - if ($Force) { - $parent = [WinSCP.RemotePath]::GetDirectoryName($path.TrimEnd('/')) - if ($parent -and $parent -ne $path -and !$Session.FileExists($parent)) { - New-ScpDirectory -Session $Session -RemotePath $parent -Force -SuppressOutput -ErrorAction Stop - } - } - $Session.CreateDirectory($path) - if (!$SuppressOutput) { $true } - } - } catch { $PSCmdlet.WriteError($_) } - } - } -} -function Send-ScpItem { - <# .SYNOPSIS - Upload literal local files or directories to a remote destination directory. - .PARAMETER TransferFilesOnly - Flatten all files from a local directory tree into the destination. Duplicate names are rejected. - .PARAMETER Remove - Delete local source files after a successful transfer. Disabled by default. - #> - [CmdletBinding(SupportsShouldProcess,DefaultParameterSetName='RuntimeTransferOptions')] - param( - [Parameter(Mandatory)][ValidateNotNullOrEmpty()][string[]]$LocalPath, - [Parameter(Mandatory)][ValidateNotNullOrEmpty()][string]$RemotePath, - [Parameter(Mandatory,ValueFromPipeline)][WinSCP.Session]$Session, - [Parameter(Mandatory,ParameterSetName='TransferOptionsObject')][ValidateNotNull()][WinSCP.TransferOptions]$TransferOptions, - [Parameter(ParameterSetName='RuntimeTransferOptions')][ValidateRange(0,2147483647)][int]$SpeedLimit=0, - [Parameter(ParameterSetName='RuntimeTransferOptions')][string]$FileMask, - [Parameter(ParameterSetName='RuntimeTransferOptions')][ValidatePattern('^[0-7]{3,4}$')][string]$Permissions, - [Parameter(ParameterSetName='RuntimeTransferOptions')][ValidateSet('Overwrite','Resume','Append')][string]$OverWriteMode='Overwrite', - [Parameter(ParameterSetName='RuntimeTransferOptions')][bool]$PreserveTimeStamp=$true, - [Parameter(ParameterSetName='RuntimeTransferOptions')][ValidateSet('Automatic','Binary','Ascii','Text')][string]$TransferMode='Binary', - [switch]$TransferFilesOnly, [switch]$Remove, [string]$DestinationFileName - ) - process { - Assert-ScpSession $Session - $options = Resolve-ScpTransferOptions $PSBoundParameters - $destination = (Format-StringPath $RemotePath).TrimEnd('/') + '/' - $sources = @(foreach ($path in $LocalPath) { - $item = Get-Item -LiteralPath $path -ErrorAction Stop - if ($item.PSProvider.Name -ne 'FileSystem') { throw 'LocalPath must use the FileSystem provider.' } - if ($TransferFilesOnly -and $item.PSIsContainer) { Get-ChildItem -LiteralPath $item.FullName -File -Recurse -ErrorAction Stop } - else { $item } - }) - if ($DestinationFileName) { - Assert-ScpLeafName $DestinationFileName - if ($TransferFilesOnly -or $sources.Count -ne 1 -or $sources[0].PSIsContainer) { throw 'DestinationFileName requires one local file without TransferFilesOnly.' } - $target = [WinSCP.RemotePath]::EscapeOperationMask([WinSCP.RemotePath]::Combine($destination,$DestinationFileName)) - } else { $target = $destination } - if ($TransferFilesOnly) { - $duplicates = $sources | Group-Object Name | Where-Object Count -gt 1 - if ($duplicates) { throw 'TransferFilesOnly would overwrite duplicate filenames in the flattened destination.' } - } - foreach ($item in $sources) { - $action = 'Upload' - if ($Remove) { $action = 'Upload and remove local source' } - if ($PSCmdlet.ShouldProcess("$($item.FullName) -> $target", $action)) { - New-ScpDirectory -Session $Session -RemotePath $destination -Force -SuppressOutput -ErrorAction Stop - $result = $Session.PutFiles([WinSCP.RemotePath]::EscapeFileMask($item.FullName),$target,[bool]$Remove,$options) - $result.Check() - $result - } - } - } -} -function Receive-ScpItem { - <# .SYNOPSIS - Download remote file masks into an existing local directory. - .PARAMETER Remove - Remove remote sources after successful download. Disabled by default. - #> - [CmdletBinding(SupportsShouldProcess)] - param([Parameter(Mandatory,ValueFromPipeline)][WinSCP.Session]$Session, - [Parameter(Mandatory)][ValidateNotNullOrEmpty()][string[]]$RemotePath, - [Parameter(Mandatory)][ValidateNotNullOrEmpty()][string]$LocalPath, - [WinSCP.TransferOptions]$TransferOptions=(New-ScpTransferOptions), [switch]$Remove, - [switch]$LiteralPath, [string]$DestinationFileName) - process { - Assert-ScpSession $Session - $directory = Get-Item -LiteralPath $LocalPath -ErrorAction Stop - if (!$directory.PSIsContainer -or $directory.PSProvider.Name -ne 'FileSystem') { throw 'LocalPath must be an existing filesystem directory.' } - $destination = $directory.FullName.TrimEnd([char[]]'\/') + [IO.Path]::DirectorySeparatorChar - if ($DestinationFileName) { - if (!$LiteralPath -or $RemotePath.Count -ne 1) { throw 'DestinationFileName requires one literal remote file.' } - Assert-ScpLeafName $DestinationFileName - $destination = Join-Path $directory.FullName $DestinationFileName - } - foreach ($path in $RemotePath) { - $source = Format-StringPath $path - if ($LiteralPath) { $source = [WinSCP.RemotePath]::EscapeFileMask($source) } - $action = 'Download' - if ($Remove) { $action = 'Download and remove remote source' } - if ($PSCmdlet.ShouldProcess("$path -> $destination",$action)) { - $result = $Session.GetFiles($source,$destination,[bool]$Remove,$TransferOptions) - $result.Check() - $result - } - } - } -} -function Remove-ScpItem { - <# .SYNOPSIS - Remove remote files or directories. Paths are literal unless UseFileMask is set. - #> - [CmdletBinding(SupportsShouldProcess,ConfirmImpact='High')] - param([Parameter(Mandatory,ValueFromPipeline)][ValidateNotNullOrEmpty()][string[]]$RemotePath, - [Parameter(Mandatory)][WinSCP.Session]$Session, [switch]$UseFileMask) - process { - Assert-ScpSession $Session - foreach ($path in $RemotePath) { - $path = Format-StringPath $path - $mask = $path - if (!$UseFileMask) { $mask = [WinSCP.RemotePath]::EscapeFileMask($path) } - if ($PSCmdlet.ShouldProcess($path,'Remove remote item')) { - $result = $Session.RemoveFiles($mask) - $result.Check() - $result - } - } - } -} -function Move-ScpItem { - <# .SYNOPSIS - Move remote items; Force permits replacement of existing files and PassThru returns metadata. - #> - [CmdletBinding(SupportsShouldProcess)] - param([Parameter(Mandatory,ValueFromPipeline)][WinSCP.Session]$Session, - [Parameter(Mandatory)][ValidateNotNullOrEmpty()][string[]]$RemotePath, - [Parameter(Mandatory)][ValidateNotNullOrEmpty()][string]$Destination, - [switch]$Force, [switch]$PassThru) - process { - Assert-ScpSession $Session - foreach ($path in $RemotePath) { - if ($PSCmdlet.ShouldProcess("$path -> $Destination",'Move remote item')) { - if ($RemotePath.Count -gt 1 -and (!$Session.FileExists($Destination) -or !$Session.GetFileInfo($Destination).IsDirectory)) { - throw 'Multiple source items require an existing destination directory.' - } - Invoke-ScpRelocation -Session $Session -RemotePath $path -Destination $Destination -Copy:$false -Force:$Force -PassThru:$PassThru - } - } - } -} -function Copy-ScpItem { - <# .SYNOPSIS - Copy remote items; Force permits replacement of existing files and PassThru returns metadata. - #> - [CmdletBinding(SupportsShouldProcess)] - param([Parameter(Mandatory,ValueFromPipeline)][WinSCP.Session]$Session, - [Parameter(Mandatory)][ValidateNotNullOrEmpty()][string[]]$RemotePath, - [Parameter(Mandatory)][ValidateNotNullOrEmpty()][string]$Destination, - [switch]$Force, [switch]$PassThru) - process { - Assert-ScpSession $Session - foreach ($path in $RemotePath) { - if ($PSCmdlet.ShouldProcess("$path -> $Destination",'Copy remote item')) { - if ($RemotePath.Count -gt 1 -and (!$Session.FileExists($Destination) -or !$Session.GetFileInfo($Destination).IsDirectory)) { - throw 'Multiple source items require an existing destination directory.' - } - Invoke-ScpRelocation -Session $Session -RemotePath $path -Destination $Destination -Copy:$true -Force:$Force -PassThru:$PassThru - } - } - } -} -function Invoke-ScpCommand { - <# .SYNOPSIS - Execute a command on a server supporting shell commands. - #> - [CmdletBinding(SupportsShouldProcess)] - param([Parameter(Mandatory,ValueFromPipeline)][WinSCP.Session]$Session, - [Parameter(Mandatory)][ValidateNotNullOrEmpty()][string[]]$Command) - process { - Assert-ScpSession $Session - foreach ($item in $Command) { - if ($PSCmdlet.ShouldProcess($item,'Execute remote command')) { - $result = $Session.ExecuteCommand($item) - $result.Check() - $result - } - } - } -} -function Sync-ScpDirectory { - <# .SYNOPSIS - Synchronize local and remote directories. Removal requires the explicit Remove switch. - .PARAMETER Mode - Remote uploads changes; Local downloads changes; Both synchronizes in both directions. - #> - [CmdletBinding(SupportsShouldProcess)] - param([Parameter(Mandatory,ValueFromPipeline)][WinSCP.Session]$Session, - [Parameter(Mandatory)][ValidateNotNullOrEmpty()][string]$LocalPath, - [Parameter(Mandatory)][ValidateNotNullOrEmpty()][string]$RemotePath, - [ValidateSet('Local','Remote','Both')][string]$Mode='Remote', - [switch]$Remove, [switch]$Mirror, - [WinSCP.SynchronizationCriteria]$Criteria=[WinSCP.SynchronizationCriteria]::Time, - [WinSCP.TransferOptions]$TransferOptions=(New-ScpTransferOptions)) - process { - Assert-ScpSession $Session - if ($Mode -eq 'Both' -and ($Remove -or $Mirror)) { throw 'Remove and Mirror cannot be used with Mode Both.' } - $directory = Get-Item -LiteralPath $LocalPath -ErrorAction Stop - if (!$directory.PSIsContainer -or $directory.PSProvider.Name -ne 'FileSystem') { throw 'LocalPath must be an existing filesystem directory.' } - if ($PSCmdlet.ShouldProcess("$LocalPath <-> $RemotePath", "Synchronize ($Mode, Remove=$Remove, Mirror=$Mirror)")) { - $result = $Session.SynchronizeDirectories([WinSCP.SynchronizationMode]$Mode,$directory.FullName,(Format-StringPath $RemotePath),[bool]$Remove,[bool]$Mirror,$Criteria,$TransferOptions) - $result.Check() - $result - } - } -} -function Start-WinScpConsole { - <# .SYNOPSIS - Launch the bundled WinSCP console and wait for it to exit. - #> - [CmdletBinding(SupportsShouldProcess)] - param() - Assert-ScpPlatform - $path = Join-Path $PSScriptRoot 'bin/WinSCP.exe' - if ($PSCmdlet.ShouldProcess($path,'Start console')) { Start-Process -FilePath $path -ArgumentList '/console' -Wait } -} +# Function files have their own PSScriptRoot; retain the module root for bundled binaries. +$script:ModuleRoot = $PSScriptRoot -function Get-ScpSession { - <# .SYNOPSIS - Retrieve sessions opened by this module, optionally by their assigned name. - #> - [CmdletBinding()] - [OutputType([WinSCP.Session])] - param([string]$Name, [switch]$OpenedOnly) - if ($Name) { - if (!$script:ScpSessions.ContainsKey($Name)) { throw "No session named '$Name' exists in this module instance." } - $sessions = @($script:ScpSessions[$Name]) - } else { $sessions = @($script:ScpSessions.Values) } - foreach ($session in $sessions) { if (!$OpenedOnly -or $session.Opened) { $session } } -} -function Close-ScpSession { - <# .SYNOPSIS - Close a connection without disposing its Session object. - .DESCRIPTION - The returned object can be reopened through its Open method. Remove-ScpSession - disposes it permanently and removes it from the module's session list. - #> - [CmdletBinding(SupportsShouldProcess)] - param([Parameter(Mandatory,ValueFromPipeline)][WinSCP.Session]$Session) - process { if ($PSCmdlet.ShouldProcess('WinSCP session','Close connection')) { $Session.Close() } } -} -function ConvertTo-ScpEscapedString { - <# .SYNOPSIS - Escape literal paths for use inside WinSCP file masks. - #> - [CmdletBinding()] - [OutputType([string])] - param([Parameter(Mandatory,ValueFromPipeline)][AllowEmptyString()][string[]]$Path) - process { foreach ($item in $Path) { [WinSCP.RemotePath]::EscapeFileMask($item) } } -} -function New-ScpItemPermission { - <# .SYNOPSIS - Create Unix permissions from octal, numeric or symbolic notation, or individual flags. - .PARAMETER Numeric - Decimal bitmask, for example 420 for octal 644. - #> - [CmdletBinding(DefaultParameterSetName='Octal')] - [OutputType([WinSCP.FilePermissions])] - param( - [Parameter(Mandatory,ParameterSetName='Octal')][ValidatePattern('^[0-7]{3,4}$')][string]$Octal, - [Parameter(Mandatory,ParameterSetName='Numeric')][ValidateRange(0,4095)][int]$Numeric, - [Parameter(Mandatory,ParameterSetName='Text')][ValidateNotNullOrEmpty()][string]$Text, - [Parameter(ParameterSetName='Flags')][switch]$UserRead, - [Parameter(ParameterSetName='Flags')][switch]$UserWrite, - [Parameter(ParameterSetName='Flags')][switch]$UserExecute, - [Parameter(ParameterSetName='Flags')][switch]$GroupRead, - [Parameter(ParameterSetName='Flags')][switch]$GroupWrite, - [Parameter(ParameterSetName='Flags')][switch]$GroupExecute, - [Parameter(ParameterSetName='Flags')][switch]$OtherRead, - [Parameter(ParameterSetName='Flags')][switch]$OtherWrite, - [Parameter(ParameterSetName='Flags')][switch]$OtherExecute, - [Parameter(ParameterSetName='Flags')][switch]$SetUid, - [Parameter(ParameterSetName='Flags')][switch]$SetGid, - [Parameter(ParameterSetName='Flags')][switch]$Sticky - ) - $permissions = New-Object WinSCP.FilePermissions - if ($PSCmdlet.ParameterSetName -eq 'Flags') { - $permissions.Numeric = 0 - foreach ($key in $PSBoundParameters.Keys) { - if ($key -in @('UserRead','UserWrite','UserExecute','GroupRead','GroupWrite','GroupExecute','OtherRead','OtherWrite','OtherExecute','SetUid','SetGid','Sticky')) { - $permissions.$key = [bool]$PSBoundParameters[$key] - } - } - } else { $permissions.($PSCmdlet.ParameterSetName) = $PSBoundParameters[$PSCmdlet.ParameterSetName] } - $permissions -} -function New-ScpTransferResumeSupport { - <# .SYNOPSIS - Configure automatic resume and uploads through temporary filenames. - .PARAMETER Threshold - Minimum size in KB. Threshold selects Smart mode; combine it only with State Smart. - #> - [CmdletBinding()] - [OutputType([WinSCP.TransferResumeSupport])] - param([WinSCP.TransferResumeSupportState]$State='Default', - [ValidateRange(0,2147483647)][int]$Threshold) - if ($PSBoundParameters.ContainsKey('Threshold') -and $PSBoundParameters.ContainsKey('State') -and $State -ne 'Smart') { - throw 'Threshold can only be combined with State Smart.' - } - $resume = New-Object WinSCP.TransferResumeSupport - $resume.State = $State - if ($PSBoundParameters.ContainsKey('Threshold')) { $resume.Threshold = $Threshold } - $resume -} -function Assert-ScpLeafName { - param([string]$Name) - if (!$Name -or $Name -in @('.','..') -or $Name -match '[/\\]' -or $Name.IndexOf([char]0) -ge 0) { - throw 'The new name must be a single filename, without a directory path.' - } -} -function Rename-ScpItem { - <# .SYNOPSIS - Rename a remote item within its current directory. - #> - [CmdletBinding(SupportsShouldProcess)] - param([Parameter(Mandatory,ValueFromPipeline)][WinSCP.Session]$Session, - [Parameter(Mandatory)][ValidateNotNullOrEmpty()][string]$RemotePath, - [Parameter(Mandatory)][ValidateNotNullOrEmpty()][string]$NewName, - [switch]$Force, [switch]$PassThru) - process { - Assert-ScpSession $Session - Assert-ScpLeafName $NewName - $source = Format-StringPath $RemotePath - $parent = [WinSCP.RemotePath]::GetDirectoryName($source) - $destination = [WinSCP.RemotePath]::Combine($parent,$NewName) - if ($PSCmdlet.ShouldProcess("$source -> $destination",'Rename remote item')) { - Invoke-ScpRelocation -Session $Session -RemotePath $source -Destination $destination -Force:$Force -PassThru:$PassThru - } - } -} -function Invoke-ScpRelocation { - param([WinSCP.Session]$Session, [string]$RemotePath, [string]$Destination, - [switch]$Copy, [switch]$Force, [switch]$PassThru) - $source = Format-StringPath $RemotePath - $target = Format-StringPath $Destination - $sourceInfo = $Session.GetFileInfo($source) - if ($Copy -and $sourceInfo.IsDirectory) { throw 'Remote copy supports files only.' } - if ($Session.FileExists($target) -and $Session.GetFileInfo($target).IsDirectory) { - $target = [WinSCP.RemotePath]::Combine($target,$sourceInfo.Name) - } - if ($source -ceq $target) { throw 'Source and destination refer to the same item.' } - if ($Session.FileExists($target)) { - $targetInfo = $Session.GetFileInfo($target) - if ($sourceInfo.FullName -ceq $targetInfo.FullName) { throw 'Source and destination refer to the same item.' } - if (!$Force) { throw "Destination already exists: $target. Use Force to replace a file." } - if ($targetInfo.IsDirectory) { throw 'Force cannot replace an existing directory.' } - $Session.RemoveFile($target) - } - if ($Copy) { $Session.DuplicateFile($source,$target) } - else { $Session.MoveFile($source,$target) } - if ($PassThru) { $Session.GetFileInfo($target) } -} -function Write-ScpBytes { - # Unique temporary file supports file creation on all protocols, including S3. - param([WinSCP.Session]$Session, [string]$RemotePath, [byte[]]$Bytes, - [WinSCP.TransferOptions]$TransferOptions) - if ($RemotePath.EndsWith('/') -or [WinSCP.RemotePath]::GetFileName($RemotePath) -in @('','.','..')) { - throw 'A content write requires a file path, not a directory path.' - } - if ($Session.FileExists($RemotePath) -and $Session.GetFileInfo($RemotePath).IsDirectory) { - throw 'Cannot replace a directory with file content.' - } - $temporaryPath = [IO.Path]::GetTempFileName() - try { - [IO.File]::WriteAllBytes($temporaryPath,$Bytes) - $result = $Session.PutFiles([WinSCP.RemotePath]::EscapeFileMask($temporaryPath),[WinSCP.RemotePath]::EscapeOperationMask($RemotePath),$false,$TransferOptions) - $result.Check() - $result - } finally { [IO.File]::Delete($temporaryPath) } -} -function New-ScpItem { - <# .SYNOPSIS - Create a remote directory or a file containing UTF-8 text. - .DESCRIPTION - Existing files require Force before replacement. Missing parents are created - for directories with Force. File parents must already exist. - #> - [CmdletBinding(SupportsShouldProcess)] - param([Parameter(Mandatory,ValueFromPipeline)][WinSCP.Session]$Session, - [Parameter(Mandatory)][ValidateNotNullOrEmpty()][string[]]$RemotePath, - [ValidateSet('File','Directory')][string]$ItemType='File', - [AllowEmptyString()][string]$Value='', [switch]$Force, - [WinSCP.TransferOptions]$TransferOptions=(New-ScpTransferOptions)) - process { - Assert-ScpSession $Session - foreach ($path in $RemotePath) { - $path = Format-StringPath $path - if ($PSCmdlet.ShouldProcess($path,"Create remote $ItemType")) { - if ($ItemType -eq 'Directory') { - if ($PSBoundParameters.ContainsKey('Value')) { throw 'Value applies to files only.' } - New-ScpDirectory -Session $Session -RemotePath $path -Force:$Force -SuppressOutput -ErrorAction Stop - } else { - if ($Session.FileExists($path)) { - if (!$Force) { throw "Remote item already exists: $path. Use Force to replace it." } - if ($Session.GetFileInfo($path).IsDirectory) { throw 'Cannot replace a directory with a file.' } - } - $options = Resolve-ScpContentTransferOptions $TransferOptions - Write-ScpBytes -Session $Session -RemotePath $path -Bytes ([Text.UTF8Encoding]::new($false).GetBytes($Value)) -TransferOptions $options | Out-Null - } - $Session.GetFileInfo($path) - } - } - } -} -function Resolve-ScpContentTransferOptions { - param([WinSCP.TransferOptions]$TransferOptions) - if ($TransferOptions.OverwriteMode -ne 'Overwrite') { throw 'Content writes require Overwrite mode.' } - if ($TransferOptions.FileMask) { throw 'Content writes do not accept a FileMask.' } - # Resume/temporary settings on a fresh unique local file are not needed. - $TransferOptions -} -function Get-ScpContent { - <# .SYNOPSIS - Read a remote text file over SFTP or FTP/FTPS, without a local temporary file. - .PARAMETER Raw - Return the entire file as one string instead of separate lines. - .PARAMETER Encoding - Text encoding name. UTF-8 is the default; a byte order mark is detected on read. - #> - [CmdletBinding()] - param([Parameter(Mandatory,ValueFromPipeline)][WinSCP.Session]$Session, - [Parameter(Mandatory)][ValidateNotNullOrEmpty()][string[]]$RemotePath, - [string]$Encoding='utf-8', [switch]$Raw) - process { - Assert-ScpSession $Session - $textEncoding = [Text.Encoding]::GetEncoding($Encoding) - foreach ($path in $RemotePath) { - $stream = $Session.GetFile((Format-StringPath $path),(New-ScpTransferOptions)) - $reader = $null - try { - $reader = [IO.StreamReader]::new($stream,$textEncoding,$true) - if ($Raw) { $reader.ReadToEnd() } - else { while (!$reader.EndOfStream) { $reader.ReadLine() } } - } finally { if ($reader) { $reader.Dispose() } else { $stream.Dispose() } } +$script:ScpSessions = @{} # Tracks open sessions for this module instance. + +# Release resources when Remove-Module or Import-Module -Force unloads this instance. +$ExecutionContext.SessionState.Module.OnRemove = +{ + foreach ($session in @($script:ScpSessions.Values)) + { + try + { + $session.Dispose() } - } -} -function Set-ScpContent { - <# .SYNOPSIS - Replace a remote file with text, using UTF-8 without a BOM by default. - .DESCRIPTION - Works through ordinary file transfer on all protocols. It does not add a newline. - #> - [CmdletBinding(SupportsShouldProcess)] - param([Parameter(Mandatory,ValueFromPipeline)][WinSCP.Session]$Session, - [Parameter(Mandatory)][ValidateNotNullOrEmpty()][string]$RemotePath, - [Parameter(Mandatory)][AllowEmptyString()][string]$Value, - [string]$Encoding='utf-8', - [WinSCP.TransferOptions]$TransferOptions=(New-ScpTransferOptions)) - process { - Assert-ScpSession $Session - $options = Resolve-ScpContentTransferOptions $TransferOptions - $bytes = [Text.Encoding]::GetEncoding($Encoding).GetBytes($Value) - if ($PSCmdlet.ShouldProcess($RemotePath,'Replace remote file content')) { - Write-ScpBytes -Session $Session -RemotePath (Format-StringPath $RemotePath) -Bytes $bytes -TransferOptions $options + catch + { + Write-Warning "Could not dispose a tracked WinSCP session: $_" } } + + $script:ScpSessions.Clear() } -function Compare-ScpDirectory { - <# .SYNOPSIS - Return the changes a directory synchronization would make, without transferring files. - #> - [CmdletBinding()] - param([Parameter(Mandatory,ValueFromPipeline)][WinSCP.Session]$Session, - [Parameter(Mandatory)][ValidateNotNullOrEmpty()][string]$LocalPath, - [Parameter(Mandatory)][ValidateNotNullOrEmpty()][string]$RemotePath, - [ValidateSet('Local','Remote','Both')][string]$Mode='Remote', - [switch]$Remove, [switch]$Mirror, - [WinSCP.SynchronizationCriteria]$Criteria=[WinSCP.SynchronizationCriteria]::Time, - [WinSCP.TransferOptions]$TransferOptions=(New-ScpTransferOptions)) - process { - Assert-ScpSession $Session - if ($Mode -eq 'Both' -and ($Remove -or $Mirror)) { throw 'Remove and Mirror cannot be used with Mode Both.' } - $directory = Get-Item -LiteralPath $LocalPath -ErrorAction Stop - if (!$directory.PSIsContainer -or $directory.PSProvider.Name -ne 'FileSystem') { throw 'LocalPath must be an existing filesystem directory.' } - $Session.CompareDirectories([WinSCP.SynchronizationMode]$Mode,$directory.FullName,(Format-StringPath $RemotePath),[bool]$Remove,[bool]$Mirror,$Criteria,$TransferOptions) + +# Load helpers before the public commands. The manifest controls command exports. +foreach ($folder in @('Private', 'Public')) +{ + $functionPath = Join-Path $PSScriptRoot $folder + + foreach ($file in @(Get-ChildItem -LiteralPath $functionPath -Filter '*.ps1' -File | Sort-Object Name)) + { + . $file.FullName } } diff --git a/Private/Assert-ScpLeafName.ps1 b/Private/Assert-ScpLeafName.ps1 new file mode 100644 index 0000000..2e41acf --- /dev/null +++ b/Private/Assert-ScpLeafName.ps1 @@ -0,0 +1,13 @@ +function Assert-ScpLeafName +{ + param + ( + [string] + $Name + ) + + if (!$Name -or $Name -in @('.', '..') -or $Name -match '[/\\]' -or $Name.IndexOf([char]0) -ge 0) + { + throw 'The new name must be a single filename, without a directory path.' + } +} diff --git a/Private/Assert-ScpLocalLeafName.ps1 b/Private/Assert-ScpLocalLeafName.ps1 new file mode 100644 index 0000000..cb76bac --- /dev/null +++ b/Private/Assert-ScpLocalLeafName.ps1 @@ -0,0 +1,17 @@ +function Assert-ScpLocalLeafName +{ + param + ( + [string] + $Name + ) + + # Downloads run on Windows even when offline tests run elsewhere. + Assert-ScpLeafName $Name + + if ($Name -match '[<>:"|?*\x00-\x1f]' -or $Name -match '[ .]$' -or + $Name -match '^(?i:CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])(?:\.|$)') + { + throw 'DestinationFileName must be a valid Windows filename without wildcard characters, device names or alternate data streams.' + } +} diff --git a/Private/Assert-ScpPlatform.ps1 b/Private/Assert-ScpPlatform.ps1 new file mode 100644 index 0000000..1b8b5ad --- /dev/null +++ b/Private/Assert-ScpPlatform.ps1 @@ -0,0 +1,7 @@ +function Assert-ScpPlatform +{ + if ([Environment]::OSVersion.Platform -ne [PlatformID]::Win32NT) + { + throw 'WinSCP transfers require Windows. PowerShell 7 is supported on Windows.' + } +} diff --git a/Private/Assert-ScpSession.ps1 b/Private/Assert-ScpSession.ps1 new file mode 100644 index 0000000..3013b06 --- /dev/null +++ b/Private/Assert-ScpSession.ps1 @@ -0,0 +1,12 @@ +function Assert-ScpSession +{ + param + ( + $Session + ) + + if ($null -eq $Session -or !$Session.Opened) + { + throw 'The WinSCP Session is not in an open state' + } +} diff --git a/Private/Invoke-ScpRelocation.ps1 b/Private/Invoke-ScpRelocation.ps1 new file mode 100644 index 0000000..642359e --- /dev/null +++ b/Private/Invoke-ScpRelocation.ps1 @@ -0,0 +1,140 @@ +function Invoke-ScpRelocation +{ + [CmdletBinding()] + param + ( + [WinSCP.Session] + $Session, + + [string] + $RemotePath, + + [string] + $Destination, + + [switch] + $Copy, + + [switch] + $Force, + + [switch] + $PassThru, + + [switch] + $ExactDestination + ) + + $source = Format-StringPath $RemotePath + $target = Format-StringPath $Destination + $sourceInfo = $Session.GetFileInfo($source) + + if ($Copy -and $sourceInfo.IsDirectory) + { + throw 'Remote copy supports files only.' + } + + # Move and copy can target a directory; rename requires the exact destination name. + if (!$ExactDestination -and $Session.FileExists($target) -and $Session.GetFileInfo($target).IsDirectory) + { + $target = [WinSCP.RemotePath]::Combine($target, $sourceInfo.Name) + } + + if ($source -ceq $target) + { + throw 'Source and destination refer to the same item.' + } + + $backup = $null + + if ($Session.FileExists($target)) + { + $targetInfo = $Session.GetFileInfo($target) + + if ($sourceInfo.FullName -ceq $targetInfo.FullName) + { + throw 'Source and destination refer to the same item.' + } + + if ($targetInfo.IsDirectory) + { + throw 'Cannot replace an existing destination directory.' + } + + if (!$Force) + { + throw "Destination already exists: $target. Use Force to replace a file." + } + + if ($sourceInfo.IsDirectory) + { + throw 'Cannot replace a file with a directory.' + } + + $parent = [WinSCP.RemotePath]::GetDirectoryName($target) + + do + { + $backup = [WinSCP.RemotePath]::Combine($parent, ('.powerscp-backup-' + [guid]::NewGuid().ToString('N'))) + } + + while ($Session.FileExists($backup)) + + # Preserve the old destination until the replacement has completed. + $Session.MoveFile($target, $backup) + } + + try + { + if ($Copy) + { + $Session.DuplicateFile($source, $target) + } + else + { + $Session.MoveFile($source, $target) + } + } + catch + { + $operationError = $_ + + if ($backup) + { + try + { + # Do not destroy a partial target or another client's new file. + if ($Session.FileExists($target)) + { + throw "Destination now exists: $target" + } + + $Session.MoveFile($backup, $target) + } + catch + { + throw "Replacement failed: $($operationError.Exception.Message). Restoration failed: $($_.Exception.Message). Original destination retained at '$backup'; recover it manually." + } + } + + $PSCmdlet.ThrowTerminatingError($operationError) + } + + # Backup cleanup failure must not turn a completed replacement into a failed operation. + if ($backup) + { + try + { + $Session.RemoveFile($backup) + } + catch + { + Write-Warning "Replacement succeeded, but original destination remains at '$backup': $_" + } + } + + if ($PassThru) + { + $Session.GetFileInfo($target) + } +} diff --git a/Private/New-ScpSessionObject.ps1 b/Private/New-ScpSessionObject.ps1 new file mode 100644 index 0000000..06d56e1 --- /dev/null +++ b/Private/New-ScpSessionObject.ps1 @@ -0,0 +1,39 @@ +function New-ScpSessionObject +{ + [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', Justification = 'Constructs an in-memory options or unopened session object; public mutations implement ShouldProcess.')] + [CmdletBinding()] + param + ( + [string] + $SessionLogPath, + + [string] + $DebugLogPath, + + [int] + $DebugLevel = 0, + + [timespan] + $ReconnectTime = [timespan]::FromSeconds(120) + ) + + Assert-ScpPlatform + + $session = New-Object WinSCP.Session + + $session.ExecutablePath = Join-Path $script:ModuleRoot 'bin/WinSCP.exe' + $session.ReconnectTime = $ReconnectTime + $session.DebugLogLevel = $DebugLevel + + if ($SessionLogPath) + { + $session.SessionLogPath = $SessionLogPath + } + + if ($DebugLogPath) + { + $session.DebugLogPath = $DebugLogPath + } + + $session +} diff --git a/Private/Resolve-ScpContentTransferOption.ps1 b/Private/Resolve-ScpContentTransferOption.ps1 new file mode 100644 index 0000000..1983fc8 --- /dev/null +++ b/Private/Resolve-ScpContentTransferOption.ps1 @@ -0,0 +1,26 @@ +function Resolve-ScpContentTransferOption +{ + param + ( + [WinSCP.TransferOptions] + $TransferOptions + ) + + if ($TransferOptions.OverwriteMode -ne 'Overwrite') + { + throw 'Content writes require Overwrite mode.' + } + + if ($TransferOptions.FileMask) + { + throw 'Content writes do not accept a FileMask.' + } + + if ($TransferOptions.TransferMode -ne 'Binary') + { + throw 'Content writes require Binary transfer mode to preserve exact bytes.' + } + + # Resume/temporary settings on a fresh unique local file are not needed. + $TransferOptions +} diff --git a/Private/Resolve-ScpTransferOption.ps1 b/Private/Resolve-ScpTransferOption.ps1 new file mode 100644 index 0000000..87c8b12 --- /dev/null +++ b/Private/Resolve-ScpTransferOption.ps1 @@ -0,0 +1,27 @@ +function Resolve-ScpTransferOption +{ + param + ( + [System.Collections.IDictionary] + $Parameters + ) + + # A supplied options object takes precedence over individual transfer settings. + if (($Parameters.Keys -contains 'TransferOptions')) + { + return $Parameters['TransferOptions'] + } + + $arguments = @{} + + # Forward only bound values so omitted parameters retain the factory defaults. + foreach ($key in @('SpeedLimit', 'FileMask', 'Permissions', 'OverWriteMode', 'PreserveTimeStamp', 'TransferMode')) + { + if (($Parameters.Keys -contains $key)) + { + $arguments[$key] = $Parameters[$key] + } + } + + New-ScpTransferOptions @arguments +} diff --git a/Private/Write-ScpByte.ps1 b/Private/Write-ScpByte.ps1 new file mode 100644 index 0000000..18d4cd1 --- /dev/null +++ b/Private/Write-ScpByte.ps1 @@ -0,0 +1,45 @@ +function Write-ScpByte +{ + # Unique temporary file supports file creation on all protocols, including S3. + param + ( + [WinSCP.Session] + $Session, + + [string] + $RemotePath, + + [byte[]] + $Bytes, + + [WinSCP.TransferOptions] + $TransferOptions + ) + + if ($RemotePath.EndsWith('/') -or [WinSCP.RemotePath]::GetFileName($RemotePath) -in @('', '.', '..')) + { + throw 'A content write requires a file path, not a directory path.' + } + + if ($Session.FileExists($RemotePath) -and $Session.GetFileInfo($RemotePath).IsDirectory) + { + throw 'Cannot replace a directory with file content.' + } + + $temporaryPath = [IO.Path]::GetTempFileName() + + try + { + [IO.File]::WriteAllBytes($temporaryPath, $Bytes) + + # Escape source masks and destination rename masks separately to preserve literal filenames. + $result = $Session.PutFiles([WinSCP.RemotePath]::EscapeFileMask($temporaryPath), [WinSCP.RemotePath]::EscapeOperationMask($RemotePath), $false, $TransferOptions) + + $result.Check() + $result + } + finally + { + [IO.File]::Delete($temporaryPath) + } +} diff --git a/Public/Close-ScpSession.ps1 b/Public/Close-ScpSession.ps1 new file mode 100644 index 0000000..a393a5b --- /dev/null +++ b/Public/Close-ScpSession.ps1 @@ -0,0 +1,27 @@ +function Close-ScpSession +{ + <# + .SYNOPSIS + Close a connection without disposing its Session object. + + .DESCRIPTION + The returned object can be reopened through its Open method. Remove-ScpSession + disposes it permanently and removes it from the module's session list. + #> + + [CmdletBinding(SupportsShouldProcess)] + param + ( + [Parameter(Mandatory, ValueFromPipeline)] + [WinSCP.Session] + $Session + ) + + process + { + if ($PSCmdlet.ShouldProcess('WinSCP session', 'Close connection')) + { + $Session.Close() + } + } +} diff --git a/Public/Compare-ScpDirectory.ps1 b/Public/Compare-ScpDirectory.ps1 new file mode 100644 index 0000000..1a70a8f --- /dev/null +++ b/Public/Compare-ScpDirectory.ps1 @@ -0,0 +1,64 @@ +function Compare-ScpDirectory +{ + <# + .SYNOPSIS + Return the changes a directory synchronization would make, without transferring files. + + .EXAMPLE + Compare-ScpDirectory -Session $session -LocalPath './data' -RemotePath '/data' -Mode Remote + Returns planned synchronization differences without modifying either directory. + #> + + [CmdletBinding()] + param + ( + [Parameter(Mandatory, ValueFromPipeline)] + [WinSCP.Session] + $Session, + + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] + $LocalPath, + + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] + $RemotePath, + + [ValidateSet('Local', 'Remote', 'Both')] + [string] + $Mode = 'Remote', + + [switch] + $Remove, + + [switch] + $Mirror, + + [WinSCP.SynchronizationCriteria] + $Criteria = [WinSCP.SynchronizationCriteria]::Time, + + [WinSCP.TransferOptions] + $TransferOptions = (New-ScpTransferOptions) + ) + + process + { + Assert-ScpSession $Session + + if ($Mode -eq 'Both' -and ($Remove -or $Mirror)) + { + throw 'Remove and Mirror cannot be used with Mode Both.' + } + + $directory = Get-Item -LiteralPath $LocalPath -ErrorAction Stop + + if (!$directory.PSIsContainer -or $directory.PSProvider.Name -ne 'FileSystem') + { + throw 'LocalPath must be an existing filesystem directory.' + } + + $Session.CompareDirectories([WinSCP.SynchronizationMode]$Mode, $directory.FullName, (Format-StringPath $RemotePath), [bool]$Remove, [bool]$Mirror, $Criteria, $TransferOptions) + } +} diff --git a/Public/ConvertTo-ScpEscapedString.ps1 b/Public/ConvertTo-ScpEscapedString.ps1 new file mode 100644 index 0000000..cb9b79e --- /dev/null +++ b/Public/ConvertTo-ScpEscapedString.ps1 @@ -0,0 +1,25 @@ +function ConvertTo-ScpEscapedString +{ + <# + .SYNOPSIS + Escape literal paths for use inside WinSCP file masks. + #> + + [CmdletBinding()] + [OutputType([string])] + param + ( + [Parameter(Mandatory, ValueFromPipeline)] + [AllowEmptyString()] + [string[]] + $Path + ) + + process + { + foreach ($item in $Path) + { + [WinSCP.RemotePath]::EscapeFileMask($item) + } + } +} diff --git a/Public/Copy-ScpItem.ps1 b/Public/Copy-ScpItem.ps1 new file mode 100644 index 0000000..d81608f --- /dev/null +++ b/Public/Copy-ScpItem.ps1 @@ -0,0 +1,59 @@ +function Copy-ScpItem +{ + <# + .SYNOPSIS + Copy remote items; Force permits replacement of existing files and PassThru returns metadata. + + .DESCRIPTION + Copies files on the server where the protocol/server supports it. Directories + are not supported. Force preserves an existing target using a sibling backup + before replacement; it requires server rename and delete permissions as well + as copying support. Failed restoration reports the backup path for recovery. + + .EXAMPLE + Copy-ScpItem -Session $session -RemotePath '/incoming/report.csv' -Destination '/archive/report.csv' -Force + Replaces an archived file while preserving the old file until copying succeeds. + #> + + [CmdletBinding(SupportsShouldProcess)] + param + ( + [Parameter(Mandatory, ValueFromPipeline)] + [WinSCP.Session] + $Session, + + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string[]] + $RemotePath, + + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] + $Destination, + + [switch] + $Force, + + [switch] + $PassThru + ) + + process + { + Assert-ScpSession $Session + + foreach ($path in $RemotePath) + { + if ($PSCmdlet.ShouldProcess("$path -> $Destination", 'Copy remote item')) + { + if ($RemotePath.Count -gt 1 -and (!$Session.FileExists($Destination) -or !$Session.GetFileInfo($Destination).IsDirectory)) + { + throw 'Multiple source items require an existing destination directory.' + } + + Invoke-ScpRelocation -Session $Session -RemotePath $path -Destination $Destination -Copy:$true -Force:$Force -PassThru:$PassThru + } + } + } +} diff --git a/Public/Format-StringPath.ps1 b/Public/Format-StringPath.ps1 new file mode 100644 index 0000000..4fd1e56 --- /dev/null +++ b/Public/Format-StringPath.ps1 @@ -0,0 +1,19 @@ +function Format-StringPath +{ + [CmdletBinding()] + [OutputType([string])] + param + ( + [Parameter(Mandatory, ValueFromPipeline)] + [string[]] + $Path + ) + + process + { + foreach ($item in $Path) + { + $item.Replace('\', '/') + } + } +} diff --git a/Public/Get-HostFingerPrint.ps1 b/Public/Get-HostFingerPrint.ps1 new file mode 100644 index 0000000..639843d --- /dev/null +++ b/Public/Get-HostFingerPrint.ps1 @@ -0,0 +1,116 @@ +function Get-HostFingerPrint +{ + <# + .SYNOPSIS + Scan a fingerprint; verify it independently before trusting it. + #> + + [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingUsernameAndPasswordParams', '', Justification = 'Legacy username/password parameters are retained for compatibility; PSCredential is the recommended alternative.')] + [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingPlainTextForPassword', '', Justification = 'Legacy string passwords are retained for compatibility; use Credentials and SecurePrivateKeyPassphrase instead.')] + [CmdletBinding(DefaultParameterSetName = 'Connection')] + param + ( + [Parameter(Mandatory, ValueFromPipeline, ParameterSetName = 'Options')] + [WinSCP.SessionOptions] + $SessionOptions, + + [Parameter(Mandatory, ParameterSetName = 'Connection')] + [Alias('Host', 'Server', 'RemoteServer')] + [string] + $RemoteHost, + + [Parameter(ParameterSetName = 'Connection')] + [string] + $UserName, + + [Parameter(ParameterSetName = 'Connection')] + [Alias('UserPassword')] + [string] + $Password, + + [Parameter(ParameterSetName = 'Connection')] + [pscredential] + $Credentials, + + [Parameter(ParameterSetName = 'Connection')] + [ValidateRange(0, 65535)] + [int] + $PortNumber = 0, + + [Parameter(ParameterSetName = 'Connection')] + [timespan] + $ConnectionTimeOut = [timespan]::FromSeconds(15), + + [ValidateSet('SHA-256', 'MD5')] + [string] + $Algorithm = 'SHA-256', + + [Parameter(ParameterSetName = 'Connection')] + [WinSCP.Protocol] + $Protocol = 'Scp', + + [Parameter(ParameterSetName = 'Connection')] + [WinSCP.FtpSecure] + $FtpSecure = 'None', + + [Parameter(ParameterSetName = 'Connection')] + [switch] + $WebDavSecure + ) + + process + { + if ($PSCmdlet.ParameterSetName -eq 'Options') + { + $options = $SessionOptions + } + else + { + $arguments = @{ + RemoteHost = $RemoteHost + ServerPort = $PortNumber + Protocol = $Protocol + ConnectionTimeOut = $ConnectionTimeOut + Scan = $true + } + + if ($Credentials) + { + $arguments.Credentials = $Credentials + } + else + { + if ($UserName) + { + $arguments.UserName = $UserName + } + + if ($PSBoundParameters.ContainsKey('Password')) + { + $arguments.UserPassword = $Password + } + } + + foreach ($key in @('FtpSecure', 'WebDavSecure')) + { + if ($PSBoundParameters.ContainsKey($key)) + { + $arguments[$key] = $PSBoundParameters[$key] + } + } + + $options = New-ScpSessionOptions @arguments + } + + $session = New-ScpSessionObject + + try + { + $session.ScanFingerprint($options, $Algorithm) + } + finally + { + $session.Dispose() + } + } +} diff --git a/Public/Get-ScpChildItem.ps1 b/Public/Get-ScpChildItem.ps1 new file mode 100644 index 0000000..759e408 --- /dev/null +++ b/Public/Get-ScpChildItem.ps1 @@ -0,0 +1,113 @@ +function Get-ScpChildItem +{ + <# + .SYNOPSIS + List remote directory contents, with optional recursion and file filtering. + + .PARAMETER Depth + Maximum subdirectory levels. Zero means unlimited when Recurse is set. + #> + + [CmdletBinding()] + [OutputType([WinSCP.RemoteFileInfo])] + param + ( + [Parameter(Mandatory, ValueFromPipeline)] + [WinSCP.Session] + $Session, + + [ValidateNotNullOrEmpty()] + [string[]] + $RemotePath = @('.'), + + [string] + $Filter = '*', + + [switch] + $Recurse, + + [ValidateRange(0, 2147483647)] + [int] + $Depth = 0, + + [Alias('File')] + [switch] + $FilesOnly, + + [Alias('Directory')] + [switch] + $DirectoriesOnly, + + [switch] + $Name + ) + + process + { + Assert-ScpSession $Session + + if ($FilesOnly -and $DirectoriesOnly) + { + throw 'Use FilesOnly or DirectoriesOnly, not both.' + } + + if ($Depth -gt 0 -and !$Recurse) + { + throw 'Depth requires Recurse.' + } + + foreach ($path in $RemotePath) + { + $path = Format-StringPath $path + $options = [WinSCP.EnumerationOptions]::None + + if ($Recurse) + { + $options = $options -bor [WinSCP.EnumerationOptions]::AllDirectories + } + + if (!$FilesOnly) + { + $options = $options -bor [WinSCP.EnumerationOptions]::MatchDirectories + } + + if ($Recurse -and $Depth -gt 0) + { + $root = $Session.GetFileInfo($path).FullName.TrimEnd('/') + '/' + } + + foreach ($item in $Session.EnumerateRemoteFiles($path, $Filter, $options)) + { + if ($FilesOnly -and $item.IsDirectory) + { + continue + } + + if ($DirectoriesOnly -and !$item.IsDirectory) + { + continue + } + + if ($Recurse -and $Depth -gt 0) + { + # Count parent segments relative to the root; immediate children have depth zero. + $relative = $item.FullName.Substring($root.Length) + + if (($relative.Trim('/').Split('/').Length - 1) -gt $Depth) + { + continue + } + } + + if ($Name) + { + $item.Name + } + else + { + $item + } + } + } + } +} diff --git a/Public/Get-ScpContent.ps1 b/Public/Get-ScpContent.ps1 new file mode 100644 index 0000000..a641dc9 --- /dev/null +++ b/Public/Get-ScpContent.ps1 @@ -0,0 +1,78 @@ +function Get-ScpContent +{ + <# + .SYNOPSIS + Read a remote text file over SFTP or FTP/FTPS, without a local temporary file. + + .PARAMETER Raw + Return the entire file as one string instead of separate lines. + + .PARAMETER Encoding + Text encoding name. UTF-8 is the default; a byte order mark is detected on read. + + .EXAMPLE + Get-ScpContent -Session $session -RemotePath '/config/settings.json' -Raw + Reads the entire remote text file and disposes its download stream. + #> + + [CmdletBinding()] + param + ( + [Parameter(Mandatory, ValueFromPipeline)] + [WinSCP.Session] + $Session, + + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string[]] + $RemotePath, + + [string] + $Encoding = 'utf-8', + + [switch] + $Raw + ) + + process + { + Assert-ScpSession $Session + $textEncoding = [Text.Encoding]::GetEncoding($Encoding) + + foreach ($path in $RemotePath) + { + $stream = $Session.GetFile((Format-StringPath $path), (New-ScpTransferOptions)) + $reader = $null + + try + { + # Detect a byte order mark before falling back to the requested encoding. + $reader = [IO.StreamReader]::new($stream, $textEncoding, $true) + + if ($Raw) + { + $reader.ReadToEnd() + } + else + { + while (!$reader.EndOfStream) + { + $reader.ReadLine() + } + } + } + finally + { + # Disposing the reader closes its stream; close the stream directly if construction failed. + if ($reader) + { + $reader.Dispose() + } + else + { + $stream.Dispose() + } + } + } + } +} diff --git a/Public/Get-ScpItem.ps1 b/Public/Get-ScpItem.ps1 new file mode 100644 index 0000000..dba9346 --- /dev/null +++ b/Public/Get-ScpItem.ps1 @@ -0,0 +1,68 @@ +function Get-ScpItem +{ + <# + .SYNOPSIS + List remote items. Retains the legacy enumeration behavior of Get-ScpItem. + #> + + [CmdletBinding()] + param + ( + [Parameter(Mandatory, ValueFromPipeline)] + [WinSCP.Session] + $Session, + + [string[]] + $RemotePath = @('.'), + + [string] + $Filter = '*', + + [switch] + $Recurse, + + [ValidateRange(0, 2147483647)] + [int] + $Depth = 0, + + [switch] + $FilesOnly, + + [switch] + $DirectoriesOnly, + + [switch] + $Name, + + [switch] + $LiteralPath + ) + + process + { + if ($LiteralPath) + { + if ($Recurse -or $Depth -or $FilesOnly -or $DirectoriesOnly -or $Name) + { + throw 'LiteralPath metadata lookup cannot be combined with listing switches.' + } + + Get-ScpItemType -Session $Session -RemotePath $RemotePath -Filter $Filter + } + else + { + $arguments = @{} + + foreach ($key in $PSBoundParameters.Keys) + { + if ($key -ne 'LiteralPath') + { + $arguments[$key] = $PSBoundParameters[$key] + } + } + + $arguments.Session = $Session + Get-ScpChildItem @arguments + } + } +} diff --git a/Public/Get-ScpItemCheckSum.ps1 b/Public/Get-ScpItemCheckSum.ps1 new file mode 100644 index 0000000..e6e03e2 --- /dev/null +++ b/Public/Get-ScpItemCheckSum.ps1 @@ -0,0 +1,35 @@ +function Get-ScpItemCheckSum +{ + <# + .SYNOPSIS + Calculate a remote file checksum using a server-supported algorithm. + #> + + [CmdletBinding()] + param + ( + [Parameter(Mandatory, ValueFromPipeline)] + [WinSCP.Session] + $Session, + + [Parameter(Mandatory)] + [Alias('RemotePath')] + [ValidateNotNullOrEmpty()] + [string[]] + $ItemName, + + [ValidateSet('md2', 'md5', 'sha-1', 'sha-224', 'sha-256', 'sha-384', 'sha-512', 'shake128', 'shake256')] + [string] + $HashAlgorithm = 'sha-256' + ) + + process + { + Assert-ScpSession $Session + + foreach ($path in $ItemName) + { + $Session.CalculateFileChecksum($HashAlgorithm, (Format-StringPath $path)) + } + } +} diff --git a/Public/Get-ScpItemType.ps1 b/Public/Get-ScpItemType.ps1 new file mode 100644 index 0000000..3acd87c --- /dev/null +++ b/Public/Get-ScpItemType.ps1 @@ -0,0 +1,39 @@ +function Get-ScpItemType +{ + <# + .SYNOPSIS + Return metadata for literal remote paths, optionally filtering their names. + #> + + [CmdletBinding()] + [OutputType([WinSCP.RemoteFileInfo])] + param + ( + [Parameter(Mandatory, ValueFromPipeline)] + [WinSCP.Session] + $Session, + + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string[]] + $RemotePath, + + [string] + $Filter + ) + + process + { + Assert-ScpSession $Session + + foreach ($path in $RemotePath) + { + $item = $Session.GetFileInfo((Format-StringPath $path)) + + if (!$Filter -or $item.Name -like $Filter) + { + $item + } + } + } +} diff --git a/Public/Get-ScpSession.ps1 b/Public/Get-ScpSession.ps1 new file mode 100644 index 0000000..72030c2 --- /dev/null +++ b/Public/Get-ScpSession.ps1 @@ -0,0 +1,40 @@ +function Get-ScpSession +{ + <# + .SYNOPSIS + Retrieve sessions opened by this module, optionally by their assigned name. + #> + + [CmdletBinding()] + [OutputType([WinSCP.Session])] + param + ( + [string] + $Name, + + [switch] + $OpenedOnly + ) + + if ($Name) + { + if (!$script:ScpSessions.ContainsKey($Name)) + { + throw "No session named '$Name' exists in this module instance." + } + + $sessions = @($script:ScpSessions[$Name]) + } + else + { + $sessions = @($script:ScpSessions.Values) + } + + foreach ($session in $sessions) + { + if (!$OpenedOnly -or $session.Opened) + { + $session + } + } +} diff --git a/Public/Invoke-ScpCommand.ps1 b/Public/Invoke-ScpCommand.ps1 new file mode 100644 index 0000000..49ac8ac --- /dev/null +++ b/Public/Invoke-ScpCommand.ps1 @@ -0,0 +1,37 @@ +function Invoke-ScpCommand +{ + <# + .SYNOPSIS + Execute a command on a server supporting shell commands. + #> + + [CmdletBinding(SupportsShouldProcess)] + [OutputType([WinSCP.CommandExecutionResult])] + param + ( + [Parameter(Mandatory, ValueFromPipeline)] + [WinSCP.Session] + $Session, + + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string[]] + $Command + ) + + process + { + Assert-ScpSession $Session + + foreach ($item in $Command) + { + if ($PSCmdlet.ShouldProcess($item, 'Execute remote command')) + { + $result = $Session.ExecuteCommand($item) + + $result.Check() + $result + } + } + } +} diff --git a/Public/Move-ScpItem.ps1 b/Public/Move-ScpItem.ps1 new file mode 100644 index 0000000..8325eb1 --- /dev/null +++ b/Public/Move-ScpItem.ps1 @@ -0,0 +1,59 @@ +function Move-ScpItem +{ + <# + .SYNOPSIS + Move remote items; Force permits replacement of existing files and PassThru returns metadata. + + .DESCRIPTION + An existing destination directory receives the source's original name. Multiple + sources require an existing destination directory. Force replaces files only; + the previous target is preserved for restoration if replacement fails. A partial + target prevents automatic restoration; the error reports the retained backup. + + .EXAMPLE + Move-ScpItem -Session $session -RemotePath '/incoming/report.csv' -Destination '/archive' -PassThru + Moves a file into an existing archive directory and returns metadata. + #> + + [CmdletBinding(SupportsShouldProcess)] + param + ( + [Parameter(Mandatory, ValueFromPipeline)] + [WinSCP.Session] + $Session, + + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string[]] + $RemotePath, + + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] + $Destination, + + [switch] + $Force, + + [switch] + $PassThru + ) + + process + { + Assert-ScpSession $Session + + foreach ($path in $RemotePath) + { + if ($PSCmdlet.ShouldProcess("$path -> $Destination", 'Move remote item')) + { + if ($RemotePath.Count -gt 1 -and (!$Session.FileExists($Destination) -or !$Session.GetFileInfo($Destination).IsDirectory)) + { + throw 'Multiple source items require an existing destination directory.' + } + + Invoke-ScpRelocation -Session $Session -RemotePath $path -Destination $Destination -Copy:$false -Force:$Force -PassThru:$PassThru + } + } + } +} diff --git a/Public/New-ScpDirectory.ps1 b/Public/New-ScpDirectory.ps1 new file mode 100644 index 0000000..257bbc5 --- /dev/null +++ b/Public/New-ScpDirectory.ps1 @@ -0,0 +1,80 @@ +function New-ScpDirectory +{ + <# + .SYNOPSIS + Create remote directories. Force creates missing parents. + #> + + [CmdletBinding(SupportsShouldProcess)] + [OutputType([bool])] + param + ( + [Parameter(Mandatory, ValueFromPipeline)] + [WinSCP.Session] + $Session, + + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string[]] + $RemotePath, + + [switch] + $Force, + + [switch] + $SuppressOutput + ) + + process + { + Assert-ScpSession $Session + + foreach ($path in $RemotePath) + { + $path = Format-StringPath $path + + try + { + if ($Session.FileExists($path)) + { + if (!$Session.GetFileInfo($path).IsDirectory) + { + throw "Remote path is a file: $path" + } + + if (!$SuppressOutput) + { + $true + } + + continue + } + + if ($PSCmdlet.ShouldProcess($path, 'Create remote directory')) + { + if ($Force) + { + $parent = [WinSCP.RemotePath]::GetDirectoryName($path.TrimEnd('/')) + + if ($parent -and $parent -ne $path -and !$Session.FileExists($parent)) + { + # Create parents first and suppress their output so only the requested path reports success. + New-ScpDirectory -Session $Session -RemotePath $parent -Force -SuppressOutput -ErrorAction Stop + } + } + + $Session.CreateDirectory($path) + + if (!$SuppressOutput) + { + $true + } + } + } + catch + { + $PSCmdlet.WriteError($_) + } + } + } +} diff --git a/Public/New-ScpItem.ps1 b/Public/New-ScpItem.ps1 new file mode 100644 index 0000000..eb80936 --- /dev/null +++ b/Public/New-ScpItem.ps1 @@ -0,0 +1,90 @@ +function New-ScpItem +{ + <# + .SYNOPSIS + Create a remote directory or a file containing UTF-8 text. + + .DESCRIPTION + Existing files require Force before replacement. Missing parents are created + for directories with Force. File parents must already exist. + + .PARAMETER TransferOptions + Content writes require Binary transfer mode, Overwrite mode and no FileMask. + These constraints prevent text conversion and accidental filtering. + + .EXAMPLE + New-ScpItem -Session $session -RemotePath '/incoming/ready.flag' + Creates an empty remote file. An existing file requires Force. + #> + + [CmdletBinding(SupportsShouldProcess)] + param + ( + [Parameter(Mandatory, ValueFromPipeline)] + [WinSCP.Session] + $Session, + + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string[]] + $RemotePath, + + [ValidateSet('File', 'Directory')] + [string] + $ItemType = 'File', + + [AllowEmptyString()] + [string] + $Value = '', + + [switch] + $Force, + + [WinSCP.TransferOptions] + $TransferOptions = (New-ScpTransferOptions) + ) + + process + { + Assert-ScpSession $Session + + foreach ($path in $RemotePath) + { + $path = Format-StringPath $path + + if ($PSCmdlet.ShouldProcess($path, "Create remote $ItemType")) + { + if ($ItemType -eq 'Directory') + { + if ($PSBoundParameters.ContainsKey('Value')) + { + throw 'Value applies to files only.' + } + + New-ScpDirectory -Session $Session -RemotePath $path -Force:$Force -SuppressOutput -ErrorAction Stop + } + else + { + if ($Session.FileExists($path)) + { + if (!$Force) + { + throw "Remote item already exists: $path. Use Force to replace it." + } + + if ($Session.GetFileInfo($path).IsDirectory) + { + throw 'Cannot replace a directory with a file.' + } + } + + $options = Resolve-ScpContentTransferOption $TransferOptions + + Write-ScpByte -Session $Session -RemotePath $path -Bytes ([Text.UTF8Encoding]::new($false).GetBytes($Value)) -TransferOptions $options | Out-Null + } + + $Session.GetFileInfo($path) + } + } + } +} diff --git a/Public/New-ScpItemPermission.ps1 b/Public/New-ScpItemPermission.ps1 new file mode 100644 index 0000000..2c7220d --- /dev/null +++ b/Public/New-ScpItemPermission.ps1 @@ -0,0 +1,101 @@ +function New-ScpItemPermission +{ + <# + .SYNOPSIS + Create Unix permissions from octal, numeric or symbolic notation, or individual flags. + + .PARAMETER Numeric + Decimal bitmask, for example 420 for octal 644. + #> + + [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', Justification = 'Constructs an in-memory options or unopened session object; public mutations implement ShouldProcess.')] + [CmdletBinding(DefaultParameterSetName = 'Octal')] + [OutputType([WinSCP.FilePermissions])] + param + ( + [Parameter(Mandatory, ParameterSetName = 'Octal')] + [ValidatePattern('^[0-7]{3,4}$')] + [string] + $Octal, + + [Parameter(Mandatory, ParameterSetName = 'Numeric')] + [ValidateRange(0, 4095)] + [int] + $Numeric, + + [Parameter(Mandatory, ParameterSetName = 'Text')] + [ValidateNotNullOrEmpty()] + [string] + $Text, + + [Parameter(ParameterSetName = 'Flags')] + [switch] + $UserRead, + + [Parameter(ParameterSetName = 'Flags')] + [switch] + $UserWrite, + + [Parameter(ParameterSetName = 'Flags')] + [switch] + $UserExecute, + + [Parameter(ParameterSetName = 'Flags')] + [switch] + $GroupRead, + + [Parameter(ParameterSetName = 'Flags')] + [switch] + $GroupWrite, + + [Parameter(ParameterSetName = 'Flags')] + [switch] + $GroupExecute, + + [Parameter(ParameterSetName = 'Flags')] + [switch] + $OtherRead, + + [Parameter(ParameterSetName = 'Flags')] + [switch] + $OtherWrite, + + [Parameter(ParameterSetName = 'Flags')] + [switch] + $OtherExecute, + + [Parameter(ParameterSetName = 'Flags')] + [switch] + $SetUid, + + [Parameter(ParameterSetName = 'Flags')] + [switch] + $SetGid, + + [Parameter(ParameterSetName = 'Flags')] + [switch] + $Sticky + ) + + $permissions = New-Object WinSCP.FilePermissions + + if ($PSCmdlet.ParameterSetName -eq 'Flags') + { + $permissions.Numeric = 0 + + foreach ($key in $PSBoundParameters.Keys) + { + if ($key -in @('UserRead', 'UserWrite', 'UserExecute', 'GroupRead', 'GroupWrite', 'GroupExecute', 'OtherRead', 'OtherWrite', 'OtherExecute', 'SetUid', 'SetGid', 'Sticky')) + { + $permissions.$key = [bool]$PSBoundParameters[$key] + } + } + } + else + { + # Parameter set names mirror the WinSCP permission properties (Octal, Numeric and Text). + $permissions.($PSCmdlet.ParameterSetName) = $PSBoundParameters[$PSCmdlet.ParameterSetName] + } + + $permissions +} diff --git a/Public/New-ScpSession.ps1 b/Public/New-ScpSession.ps1 new file mode 100644 index 0000000..b8e2e54 --- /dev/null +++ b/Public/New-ScpSession.ps1 @@ -0,0 +1,265 @@ +function New-ScpSession +{ + <# + .SYNOPSIS + Open a session from connection parameters or reusable SessionOptions. + + .PARAMETER Name + Optional name for retrieving this session with Get-ScpSession. Names must be unique. + + .DESCRIPTION + Opens and registers a connection. Windows is required. Use verified fingerprints + for SSH connections and dispose sessions in a finally block. Module removal + also disposes tracked sessions. WhatIf builds options without opening a connection. + + .EXAMPLE + $session = New-ScpSession -RemoteHost sftp.example.org -Protocol Sftp -Credentials (Get-Credential) -SshHostKeyFingerprint $verifiedFingerprint + Opens an SFTP connection using a fingerprint verified with the server administrator. + #> + + [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingUsernameAndPasswordParams', '', Justification = 'Legacy username/password parameters are retained for compatibility; PSCredential is the recommended alternative.')] + [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingPlainTextForPassword', '', Justification = 'Legacy string passwords are retained for compatibility; use Credentials and SecurePrivateKeyPassphrase instead.')] + [CmdletBinding(SupportsShouldProcess, DefaultParameterSetName = 'Connection')] + [OutputType([WinSCP.Session])] + param + ( + [Parameter(Mandatory, ValueFromPipeline, ParameterSetName = 'Options')] + [Alias('SessionOption')] + [WinSCP.SessionOptions] + $SessionOptions, + + [Parameter(ParameterSetName = 'Connection')] + [Alias('Host', 'HostName')] + [string] + $RemoteHost, + + [Parameter(ParameterSetName = 'Connection')] + [string] + $UserName, + + [Parameter(ParameterSetName = 'Connection')] + [AllowEmptyString()] + [string] + $UserPassword, + + [Parameter(ParameterSetName = 'Connection')] + [Alias('Credential')] + [pscredential] + $Credentials, + + [Parameter(ParameterSetName = 'Connection')] + [Alias('ConnectionProtocol')] + [WinSCP.Protocol] + $Protocol = 'Scp', + + [Parameter(ParameterSetName = 'Connection')] + [Alias('Port', 'RemoteHostPort')] + [ValidateRange(0, 65535)] + [int] + $ServerPort = 0, + + [Parameter(ParameterSetName = 'Connection')] + [timespan] + $ConnectionTimeOut = [timespan]::FromSeconds(15), + + [Parameter(ParameterSetName = 'Connection')] + [Alias('GiveUpSecurityAndAcceptAnySshHostKey', 'AnySshKey', 'SshCheck', 'AcceptAnySshKey')] + [switch] + $NoSshKeyCheck, + + [Parameter(ParameterSetName = 'Connection')] + [Alias('GiveUpSecurityAndAcceptAnyTlsHostCertificate', 'AnyTlsCertificte', 'AcceptAnyCertificate')] + [switch] + $NoTlsCheck, + + [Parameter(ParameterSetName = 'Connection')] + [string[]] + $SshHostKeyFingerprint, + + [Parameter(ParameterSetName = 'Connection')] + [WinSCP.SshHostKeyPolicy] + $SshHostKeyPolicy = 'Check', + + [Parameter(ParameterSetName = 'Connection')] + [Alias('SshPrivateKey', 'SshPrivateKeyPath', 'SsheKeyPath')] + [string] + $SshKeyPath, + + [Parameter(ParameterSetName = 'Connection')] + [string] + $SshKeyPassword, + + [Parameter(ParameterSetName = 'Connection')] + [securestring] + $SecurePrivateKeyPassphrase, + + [Parameter(ParameterSetName = 'Connection')] + [switch] + $NoSSHKeyPassword, + + [Parameter(ParameterSetName = 'Connection')] + [WinSCP.FtpMode] + $FtpMode = 'Passive', + + [Parameter(ParameterSetName = 'Connection')] + [Alias('FtpSecureMode', 'SecureFtpMode')] + [WinSCP.FtpSecure] + $FtpSecure = 'None', + + [Parameter(ParameterSetName = 'Connection')] + [switch] + $WebDavSecure, + + [Parameter(ParameterSetName = 'Connection')] + [Alias('RootPath')] + [string] + $WebDavRoot, + + [Parameter(ParameterSetName = 'Connection')] + [bool] + $Secure, + + [Parameter(ParameterSetName = 'Connection')] + [string] + $TlsHostCertificateFingerprint, + + [Parameter(ParameterSetName = 'Connection')] + [string] + $TlsClientCertificatePath, + + [Parameter(ParameterSetName = 'Connection')] + [hashtable] + $RawSettings, + + [Parameter(ParameterSetName = 'Connection')] + [string] + $SessionUrl, + + [Parameter(ParameterSetName = 'Connection')] + [string] + $S3Bucket, + + [Parameter(ParameterSetName = 'Connection')] + [string] + $S3Region, + + [Parameter(ParameterSetName = 'Connection')] + [securestring] + $S3SessionToken, + + [Parameter(ParameterSetName = 'Connection')] + [ValidateSet('VirtualHost', 'Path')] + [string] + $S3UrlStyle, + + [Parameter(ParameterSetName = 'Connection')] + [switch] + $S3CredentialsFromEnvironment, + + [Parameter(ParameterSetName = 'Connection')] + [string] + $S3Profile, + + [string] + $Name, + + [string] + $SessionLogPath, + + [string] + $DebugLogPath, + + [Alias('DebugLogLevel')] + [ValidateRange(-1, 2)] + [int] + $DebugLevel = 0, + + [timespan] + $ReconnectTime = [timespan]::FromSeconds(120), + + [string] + $XmlLogPath, + + [switch] + $XmlLogPreserve, + + [hashtable] + $RawConfiguration + ) + + process + { + if ($Name -and $script:ScpSessions.ContainsKey($Name)) + { + throw "A session named '$Name' already exists. Remove it first." + } + + if ($PSCmdlet.ParameterSetName -eq 'Options') + { + $options = $SessionOptions + } + else + { + $arguments = @{} + $optionParameters = (Get-Command New-ScpSessionOptions).Parameters.Keys + + # Forward connection settings while keeping common parameters on this command. + foreach ($key in $PSBoundParameters.Keys) + { + if ($key -in $optionParameters -and $key -notin @('WhatIf', 'Confirm', 'Verbose', 'Debug', 'ErrorAction', 'WarningAction', 'InformationAction', 'ErrorVariable', 'WarningVariable', 'InformationVariable', 'OutVariable', 'OutBuffer', 'PipelineVariable', 'ProgressAction')) + { + $arguments[$key] = $PSBoundParameters[$key] + } + } + + $options = New-ScpSessionOptions @arguments + } + + if (!$options.HostName) + { + throw 'RemoteHost is required except when Protocol S3 supplies the AWS endpoint.' + } + + if ($PSCmdlet.ShouldProcess($options.HostName, 'Open WinSCP session')) + { + $sessionObject = New-ScpSessionObject -SessionLogPath $SessionLogPath -DebugLogPath $DebugLogPath -DebugLevel $DebugLevel -ReconnectTime $ReconnectTime + + try + { + if ($XmlLogPath) + { + $sessionObject.XmlLogPath = $XmlLogPath + } + + $sessionObject.XmlLogPreserve = [bool]$XmlLogPreserve + foreach ($key in $RawConfiguration.Keys) + { + $sessionObject.AddRawConfiguration([string]$key, [string]$RawConfiguration[$key]) + } + + $sessionObject.Open($options) + if (!$Name) + { + $sessionName = [guid]::NewGuid().ToString() + } + else + { + $sessionName = $Name + } + + $sessionObject | Add-Member -NotePropertyName ScpSessionName -NotePropertyValue $sessionName -Force + $sessionObject | Add-Member -NotePropertyName RemoteHost -NotePropertyValue $options.HostName -Force + + # Track only successfully opened sessions for lookup and module unload cleanup. + $script:ScpSessions[$sessionName] = $sessionObject + $sessionObject + } + catch + { + # Opening can fail after resources have been allocated; dispose before rethrowing. + $sessionObject.Dispose() + $PSCmdlet.ThrowTerminatingError($_) + } + } + } +} diff --git a/Public/New-ScpSessionOptions.ps1 b/Public/New-ScpSessionOptions.ps1 new file mode 100644 index 0000000..7754c43 --- /dev/null +++ b/Public/New-ScpSessionOptions.ps1 @@ -0,0 +1,398 @@ +function New-ScpSessionOptions +{ + <# + .SYNOPSIS + Build reusable connection options without opening a connection. + + .DESCRIPTION + Credentials are a username/password for most protocols or an access key/secret + for S3. S3 uses TLS by default. SessionUrl accepts WinSCP session URLs; avoid + embedding passwords in URLs. Explicit parameters override parsed URL settings. + + .PARAMETER S3CredentialsFromEnvironment + Let WinSCP read AWS environment variables or its supported AWS credential files. + + .PARAMETER Scan + Build options for fingerprint scanning without requiring a trusted SSH key. + #> + + [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingUsernameAndPasswordParams', '', Justification = 'Legacy username/password parameters are retained for compatibility; PSCredential is the recommended alternative.')] + [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingPlainTextForPassword', '', Justification = 'Legacy string passwords are retained for compatibility; use Credentials and SecurePrivateKeyPassphrase instead.')] + [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', Justification = 'Constructs an in-memory options or unopened session object; public mutations implement ShouldProcess.')] + [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseSingularNouns', '', Justification = 'Established public API mirrors the WinSCP SessionOptions and TransferOptions type names.')] + [CmdletBinding()] + [OutputType([WinSCP.SessionOptions])] + param + ( + [Alias('Host', 'HostName')] + [string] + $RemoteHost, + + [string] + $UserName, + + [AllowEmptyString()] + [string] + $UserPassword, + + [Alias('Credential')] + [pscredential] + $Credentials, + + [WinSCP.Protocol] + $Protocol = 'Scp', + + [Alias('Port', 'PortNumber')] + [ValidateRange(0, 65535)] + [int] + $ServerPort = 0, + + [Alias('Timeout')] + [timespan] + $ConnectionTimeOut = [timespan]::FromSeconds(15), + + [switch] + $NoSshKeyCheck, + + [switch] + $NoTlsCheck, + + [string[]] + $SshHostKeyFingerprint, + + [WinSCP.SshHostKeyPolicy] + $SshHostKeyPolicy = 'Check', + + [Alias('SshPrivateKeyPath')] + [string] + $SshKeyPath, + + [string] + $SshKeyPassword, + + [securestring] + $SecurePrivateKeyPassphrase, + + [switch] + $NoSSHKeyPassword, + + [WinSCP.FtpMode] + $FtpMode = 'Passive', + + [WinSCP.FtpSecure] + $FtpSecure = 'None', + + [switch] + $WebDavSecure, + + [Alias('RootPath')] + [string] + $WebDavRoot, + + [bool] + $Secure, + + [string] + $TlsHostCertificateFingerprint, + + [string] + $TlsClientCertificatePath, + + [hashtable] + $RawSettings, + + [string] + $SessionUrl, + + [switch] + $Scan, + + [ValidateNotNullOrEmpty()] + [string] + $S3Bucket, + + [ValidateNotNullOrEmpty()] + [string] + $S3Region, + + [securestring] + $S3SessionToken, + + [ValidateSet('VirtualHost', 'Path')] + [string] + $S3UrlStyle, + + [switch] + $S3CredentialsFromEnvironment, + + [string] + $S3Profile + ) + + if ($Credentials -and ($PSBoundParameters.ContainsKey('UserName') -or $PSBoundParameters.ContainsKey('UserPassword'))) + { + throw 'Use Credentials or UserName/UserPassword, not both.' + } + + if ($SshKeyPassword -and $SecurePrivateKeyPassphrase) + { + throw 'Use one private-key passphrase parameter.' + } + + if ($ConnectionTimeOut -le [timespan]::Zero) + { + throw 'ConnectionTimeOut must be positive.' + } + + $options = New-Object WinSCP.SessionOptions + + # Parse the URL first; explicitly bound parameters can then override its values. + if ($SessionUrl) + { + $options.ParseUrl($SessionUrl) + } + + if (!$SessionUrl -or $PSBoundParameters.ContainsKey('Protocol')) + { + $options.Protocol = $Protocol + } + + # Set protocol before hostname: the assembly supplies the AWS endpoint for S3. + if ($RemoteHost) + { + $options.HostName = $RemoteHost + } + + if (!$SessionUrl -or $PSBoundParameters.ContainsKey('ServerPort')) + { + $options.PortNumber = $ServerPort + } + + if (!$SessionUrl -or $PSBoundParameters.ContainsKey('ConnectionTimeOut')) + { + $options.Timeout = $ConnectionTimeOut + } + + if ($Credentials) + { + $options.UserName = $Credentials.UserName + $options.SecurePassword = $Credentials.Password + } + else + { + if (!$SessionUrl -or $PSBoundParameters.ContainsKey('UserName')) + { + $options.UserName = $UserName + } + + if ($PSBoundParameters.ContainsKey('UserPassword')) + { + $options.Password = $UserPassword + } + } + + if ($PSBoundParameters.ContainsKey('SshHostKeyPolicy')) + { + $options.SshHostKeyPolicy = $SshHostKeyPolicy + } + + if ($NoSshKeyCheck) + { + if ($PSBoundParameters.ContainsKey('SshHostKeyPolicy') -and $SshHostKeyPolicy -ne 'GiveUpSecurityAndAcceptAny') + { + throw 'NoSshKeyCheck conflicts with SshHostKeyPolicy.' + } + + $options.SshHostKeyPolicy = 'GiveUpSecurityAndAcceptAny' + } + elseif ($PSBoundParameters.ContainsKey('NoSshKeyCheck')) + { + $options.SshHostKeyPolicy = 'Check' + } + + # Check whether the switch was bound so an explicit false value is applied too. + if ($PSBoundParameters.ContainsKey('NoTlsCheck')) + { + $options.GiveUpSecurityAndAcceptAnyTlsHostCertificate = [bool]$NoTlsCheck + } + + if ($SshHostKeyFingerprint) + { + $options.SshHostKeyFingerprint = $SshHostKeyFingerprint -join ';' + } + + if (!$Scan -and $options.Protocol -in @('Scp', 'Sftp') -and $options.SshHostKeyPolicy -eq 'Check' -and !$options.SshHostKeyFingerprint) + { + throw 'Specify SshHostKeyFingerprint or choose an explicit SshHostKeyPolicy.' + } + + if ($SshKeyPassword -and !$SshKeyPath) + { + throw 'SshKeyPassword requires SshKeyPath.' + } + + if ($SshKeyPath) + { + $options.SshPrivateKeyPath = (Resolve-Path -LiteralPath $SshKeyPath -ErrorAction Stop).ProviderPath + } + + if ($SshKeyPassword) + { + $options.PrivateKeyPassphrase = $SshKeyPassword + } + + if ($SecurePrivateKeyPassphrase) + { + $options.SecurePrivateKeyPassphrase = $SecurePrivateKeyPassphrase + } + + if ($TlsClientCertificatePath) + { + $options.TlsClientCertificatePath = (Resolve-Path -LiteralPath $TlsClientCertificatePath -ErrorAction Stop).ProviderPath + } + + if ($TlsHostCertificateFingerprint) + { + $options.TlsHostCertificateFingerprint = $TlsHostCertificateFingerprint + } + + if (($PSBoundParameters.ContainsKey('FtpMode') -or $PSBoundParameters.ContainsKey('FtpSecure')) -and $options.Protocol -ne 'Ftp') + { + throw 'FtpMode and FtpSecure require Protocol Ftp.' + } + + if ($options.Protocol -eq 'Ftp') + { + if (!$SessionUrl -or $PSBoundParameters.ContainsKey('FtpMode')) + { + $options.FtpMode = $FtpMode + } + + if (!$SessionUrl -or $PSBoundParameters.ContainsKey('FtpSecure')) + { + $options.FtpSecure = $FtpSecure + } + } + + if ($WebDavSecure -and $options.Protocol -ne 'Webdav') + { + throw 'WebDavSecure requires Protocol Webdav.' + } + + if (($WebDavRoot -or $PSBoundParameters.ContainsKey('Secure')) -and $options.Protocol -notin @('Webdav', 'S3')) + { + throw 'RootPath and Secure require Protocol Webdav or S3.' + } + + if ($PSBoundParameters.ContainsKey('Secure')) + { + $options.Secure = $Secure + } + elseif ($PSBoundParameters.ContainsKey('WebDavSecure')) + { + $options.Secure = [bool]$WebDavSecure + } + elseif ($options.Protocol -eq 'S3' -and (!$SessionUrl -or $PSBoundParameters.ContainsKey('Protocol'))) + { + $options.Secure = $true + } + + if ($WebDavSecure -and $PSBoundParameters.ContainsKey('Secure') -and !$Secure) + { + throw 'WebDavSecure conflicts with Secure false.' + } + + if ($WebDavRoot) + { + $options.RootPath = Format-StringPath $WebDavRoot + } + + # Copy raw settings before applying named S3 options, which take precedence. + $settings = @{} + + foreach ($key in $RawSettings.Keys) + { + $settings[$key] = [string]$RawSettings[$key] + } + + $s3Parameters = @('S3Bucket', 'S3Region', 'S3SessionToken', 'S3UrlStyle', 'S3CredentialsFromEnvironment', 'S3Profile') + + if (@($PSBoundParameters.Keys | Where-Object ` + { + $_ -in $s3Parameters + }).Count -and $options.Protocol -ne 'S3') + { + throw 'S3 settings require Protocol S3.' + } + + if ($S3Bucket) + { + if ($S3Bucket -match '[/\\]') + { + throw 'S3Bucket must be a bucket name, without a path.' + } + + if ($WebDavRoot) + { + throw 'Use S3Bucket or RootPath, not both.' + } + + $options.RootPath = '/' + $S3Bucket + } + + if ($S3Region) + { + $settings['S3DefaultRegion'] = $S3Region + } + + if ($S3UrlStyle) + { + # WinSCP expects the URL style as a numeric string: 1 for path, 0 for virtual host. + $settings['S3UrlStyle'] = [string][int]($S3UrlStyle -eq 'Path') + } + + if ($S3Profile) + { + if ($PSBoundParameters.ContainsKey('S3CredentialsFromEnvironment') -and !$S3CredentialsFromEnvironment) + { + throw 'S3Profile requires environment credential lookup.' + } + + $S3CredentialsFromEnvironment = $true + $settings['S3Profile'] = $S3Profile + } + + if ($S3CredentialsFromEnvironment -and ($Credentials -or $options.UserName -or $options.Password)) + { + throw 'Use AWS environment/profile credentials or explicit access keys, not both.' + } + + if ($PSBoundParameters.ContainsKey('S3CredentialsFromEnvironment') -or $S3Profile) + { + $settings['S3CredentialsEnv'] = [string][int][bool]$S3CredentialsFromEnvironment + } + + if ($S3SessionToken) + { + # WinSCP raw settings require a string. Clear the temporary plaintext copy afterwards. + $token = [System.Net.NetworkCredential]::new('', $S3SessionToken).Password + + try + { + $options.AddRawSettings('S3SessionToken', $token) + } + finally + { + $token = $null + } + + $settings.Remove('S3SessionToken') + } + + foreach ($key in $settings.Keys) + { + $options.AddRawSettings([string]$key, $settings[$key]) + } + + $options +} diff --git a/Public/New-ScpTransferOptions.ps1 b/Public/New-ScpTransferOptions.ps1 new file mode 100644 index 0000000..31a5210 --- /dev/null +++ b/Public/New-ScpTransferOptions.ps1 @@ -0,0 +1,90 @@ +function New-ScpTransferOptions +{ + <# + .SYNOPSIS + Construct reusable WinSCP transfer options. + + .PARAMETER Permissions + Unix octal permissions, such as 644, 755 or 0755. Each digit must be 0 through 7. + #> + + [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', Justification = 'Constructs an in-memory options or unopened session object; public mutations implement ShouldProcess.')] + [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseSingularNouns', '', Justification = 'Established public API mirrors the WinSCP SessionOptions and TransferOptions type names.')] + [CmdletBinding()] + [OutputType([WinSCP.TransferOptions])] + param + ( + [ValidateRange(0, 2147483647)] + [int] + $SpeedLimit = 0, + + [string] + $FileMask, + + [ValidatePattern('^[0-7]{3,4}$')] + [string] + $Permissions, + + [ValidateSet('Overwrite', 'Resume', 'Append')] + [string] + $OverWriteMode = 'Overwrite', + + [bool] + $PreserveTimeStamp = $true, + + [ValidateSet('Automatic', 'Binary', 'Ascii', 'Text')] + [string] + $TransferMode = 'Binary', + + [hashtable] + $RawSettings, + + [WinSCP.FilePermissions] + $FilePermissions, + + [WinSCP.TransferResumeSupport] + $ResumeSupport + ) + + if ($FilePermissions -and $PSBoundParameters.ContainsKey('Permissions')) + { + throw 'Use Permissions or FilePermissions, not both.' + } + + $options = New-Object WinSCP.TransferOptions + + $options.SpeedLimit = $SpeedLimit + $options.FileMask = $FileMask + $options.OverwriteMode = $OverWriteMode + $options.PreserveTimestamp = $PreserveTimeStamp + + if ($TransferMode -eq 'Text') + { + $TransferMode = 'Ascii' + } + + $options.TransferMode = $TransferMode + + if ($PSBoundParameters.ContainsKey('Permissions')) + { + $options.FilePermissions = New-Object WinSCP.FilePermissions + $options.FilePermissions.Octal = $Permissions + } + + if ($FilePermissions) + { + $options.FilePermissions = $FilePermissions + } + + if ($ResumeSupport) + { + $options.ResumeSupport = $ResumeSupport + } + + foreach ($key in $RawSettings.Keys) + { + $options.AddRawSettings([string]$key, [string]$RawSettings[$key]) + } + + $options +} diff --git a/Public/New-ScpTransferResumeSupport.ps1 b/Public/New-ScpTransferResumeSupport.ps1 new file mode 100644 index 0000000..5568602 --- /dev/null +++ b/Public/New-ScpTransferResumeSupport.ps1 @@ -0,0 +1,39 @@ +function New-ScpTransferResumeSupport +{ + <# + .SYNOPSIS + Configure automatic resume and uploads through temporary filenames. + + .PARAMETER Threshold + Minimum size in KB. Threshold selects Smart mode; combine it only with State Smart. + #> + + [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', Justification = 'Constructs an in-memory options or unopened session object; public mutations implement ShouldProcess.')] + [CmdletBinding()] + [OutputType([WinSCP.TransferResumeSupport])] + param + ( + [WinSCP.TransferResumeSupportState] + $State = 'Default', + + [ValidateRange(0, 2147483647)] + [int] + $Threshold + ) + + if ($PSBoundParameters.ContainsKey('Threshold') -and $PSBoundParameters.ContainsKey('State') -and $State -ne 'Smart') + { + throw 'Threshold can only be combined with State Smart.' + } + + $resume = New-Object WinSCP.TransferResumeSupport + + $resume.State = $State + + if ($PSBoundParameters.ContainsKey('Threshold')) + { + $resume.Threshold = $Threshold + } + + $resume +} diff --git a/Public/Receive-ScpItem.ps1 b/Public/Receive-ScpItem.ps1 new file mode 100644 index 0000000..948f34d --- /dev/null +++ b/Public/Receive-ScpItem.ps1 @@ -0,0 +1,115 @@ +function Receive-ScpItem +{ + <# + .SYNOPSIS + Download remote file masks into an existing local directory. + + .PARAMETER Remove + Remove remote sources after successful download. Disabled by default. + + .DESCRIPTION + Remote paths are WinSCP masks unless LiteralPath is set. LocalPath must be an + existing filesystem directory. Sources are retained unless Remove is set. + + .PARAMETER LiteralPath + Escapes remote mask characters so a specific file or directory is selected. + + .PARAMETER DestinationFileName + A valid Windows leaf filename. Requires one literal remote file, not a directory. + + .EXAMPLE + Receive-ScpItem -Session $session -RemotePath '/outgoing/*.csv' -LocalPath './downloads' + Downloads matching CSV files without removing remote sources. + + .EXAMPLE + Receive-ScpItem -Session $session -RemotePath '/report[1].txt' -LiteralPath -LocalPath './downloads' -DestinationFileName 'saved.txt' + Downloads a literal bracket filename under a new local name. + #> + + [CmdletBinding(SupportsShouldProcess)] + [OutputType([WinSCP.TransferOperationResult])] + param + ( + [Parameter(Mandatory, ValueFromPipeline)] + [WinSCP.Session] + $Session, + + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string[]] + $RemotePath, + + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] + $LocalPath, + + [WinSCP.TransferOptions] + $TransferOptions = (New-ScpTransferOptions), + + [switch] + $Remove, + + [switch] + $LiteralPath, + + [string] + $DestinationFileName + ) + + process + { + Assert-ScpSession $Session + $directory = Get-Item -LiteralPath $LocalPath -ErrorAction Stop + + if (!$directory.PSIsContainer -or $directory.PSProvider.Name -ne 'FileSystem') + { + throw 'LocalPath must be an existing filesystem directory.' + } + + # A trailing separator tells WinSCP to preserve source filenames inside this directory. + $destination = $directory.FullName.TrimEnd([char[]]'\/') + [IO.Path]::DirectorySeparatorChar + + if ($DestinationFileName) + { + if (!$LiteralPath -or $RemotePath.Count -ne 1) + { + throw 'DestinationFileName requires one literal remote file.' + } + + Assert-ScpLeafName $DestinationFileName + Assert-ScpLocalLeafName $DestinationFileName + $destination = Join-Path $directory.FullName $DestinationFileName + } + + foreach ($path in $RemotePath) + { + $source = Format-StringPath $path + + if ($DestinationFileName -and $Session.GetFileInfo($source).IsDirectory) + { + throw 'DestinationFileName requires a remote file, not a directory.' + } + + if ($LiteralPath) + { + $source = [WinSCP.RemotePath]::EscapeFileMask($source) + } + + $action = 'Download' + + if ($Remove) + { + $action = 'Download and remove remote source' + } + + if ($PSCmdlet.ShouldProcess("$path -> $destination", $action)) + { + $result = $Session.GetFiles($source, $destination, [bool]$Remove, $TransferOptions) + + $result.Check() + $result + } + } + } +} diff --git a/Public/Remove-ScpItem.ps1 b/Public/Remove-ScpItem.ps1 new file mode 100644 index 0000000..686c47c --- /dev/null +++ b/Public/Remove-ScpItem.ps1 @@ -0,0 +1,48 @@ +function Remove-ScpItem +{ + <# + .SYNOPSIS + Remove remote files or directories. Paths are literal unless UseFileMask is set. + #> + + [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'High')] + [OutputType([WinSCP.RemovalOperationResult])] + param + ( + [Parameter(Mandatory, ValueFromPipeline)] + [ValidateNotNullOrEmpty()] + [string[]] + $RemotePath, + + [Parameter(Mandatory)] + [WinSCP.Session] + $Session, + + [switch] + $UseFileMask + ) + + process + { + Assert-ScpSession $Session + + foreach ($path in $RemotePath) + { + $path = Format-StringPath $path + $mask = $path + + if (!$UseFileMask) + { + $mask = [WinSCP.RemotePath]::EscapeFileMask($path) + } + + if ($PSCmdlet.ShouldProcess($path, 'Remove remote item')) + { + $result = $Session.RemoveFiles($mask) + + $result.Check() + $result + } + } + } +} diff --git a/Public/Remove-ScpSession.ps1 b/Public/Remove-ScpSession.ps1 new file mode 100644 index 0000000..111c3a3 --- /dev/null +++ b/Public/Remove-ScpSession.ps1 @@ -0,0 +1,35 @@ +function Remove-ScpSession +{ + <# + .SYNOPSIS + Dispose a session; disposed sessions cannot be reused. + #> + + [CmdletBinding(SupportsShouldProcess)] + [OutputType([bool])] + param + ( + [Parameter(Mandatory, ValueFromPipeline)] + [WinSCP.Session] + $Session + ) + + process + { + if ($PSCmdlet.ShouldProcess('WinSCP session', 'Dispose')) + { + $Session.Dispose() + + # Snapshot keys before removing entries; match the object rather than its assigned name. + foreach ($key in @($script:ScpSessions.Keys)) + { + if ([object]::ReferenceEquals($script:ScpSessions[$key], $Session)) + { + $script:ScpSessions.Remove($key) + } + } + + $true + } + } +} diff --git a/Public/Rename-ScpItem.ps1 b/Public/Rename-ScpItem.ps1 new file mode 100644 index 0000000..2b55029 --- /dev/null +++ b/Public/Rename-ScpItem.ps1 @@ -0,0 +1,58 @@ +function Rename-ScpItem +{ + <# + .SYNOPSIS + Rename a remote item within its current directory. + + .DESCRIPTION + NewName is an exact leaf name, never a destination directory. Existing + directories are rejected. Force permits file replacement with backup recovery. + + .PARAMETER Force + Preserves an existing target file under a unique sibling backup name, then + replaces it. On failure, restores it if the target is absent; otherwise reports + the retained backup path. This process is not atomic and needs server rename support. + + .EXAMPLE + Rename-ScpItem -Session $session -RemotePath '/incoming/report.tmp' -NewName 'report.csv' -PassThru + Renames within the same directory and returns the resulting metadata. + #> + + [CmdletBinding(SupportsShouldProcess)] + param + ( + [Parameter(Mandatory, ValueFromPipeline)] + [WinSCP.Session] + $Session, + + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] + $RemotePath, + + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] + $NewName, + + [switch] + $Force, + + [switch] + $PassThru + ) + + process + { + Assert-ScpSession $Session + Assert-ScpLeafName $NewName + $source = Format-StringPath $RemotePath + $parent = [WinSCP.RemotePath]::GetDirectoryName($source) + $destination = [WinSCP.RemotePath]::Combine($parent, $NewName) + + if ($PSCmdlet.ShouldProcess("$source -> $destination", 'Rename remote item')) + { + Invoke-ScpRelocation -Session $Session -RemotePath $source -Destination $destination -ExactDestination -Force:$Force -PassThru:$PassThru + } + } +} diff --git a/Public/Send-ScpItem.ps1 b/Public/Send-ScpItem.ps1 new file mode 100644 index 0000000..1284a87 --- /dev/null +++ b/Public/Send-ScpItem.ps1 @@ -0,0 +1,164 @@ +function Send-ScpItem +{ + <# + .SYNOPSIS + Upload literal local files or directories to a remote destination directory. + + .PARAMETER TransferFilesOnly + Flatten all files from a local directory tree into the destination. Duplicate names are rejected. + + .PARAMETER Remove + Delete local source files after a successful transfer. Disabled by default. + + .DESCRIPTION + Local paths are literal. RemotePath is a directory; missing parents are created. + Source removal is opt-in. Failed operations terminate. WhatIf prevents both + transfer and remote directory creation. TransferFilesOnly flattens directory + trees and rejects duplicate names before transferring. + + .PARAMETER DestinationFileName + Renames a single local file during upload. Supply the destination directory + separately through RemotePath. Cannot be combined with TransferFilesOnly. + + .EXAMPLE + Send-ScpItem -Session $session -LocalPath './report[1].csv' -RemotePath '/incoming' -WhatIf + Previews an upload without interpreting brackets as a local wildcard. + + .EXAMPLE + Send-ScpItem -Session $session -LocalPath './report.csv' -RemotePath '/incoming' -DestinationFileName 'ready.csv' + Uploads one file under a new remote name, retaining the local source. + #> + + [CmdletBinding(SupportsShouldProcess, DefaultParameterSetName = 'RuntimeTransferOptions')] + [OutputType([WinSCP.TransferOperationResult])] + param + ( + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string[]] + $LocalPath, + + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] + $RemotePath, + + [Parameter(Mandatory, ValueFromPipeline)] + [WinSCP.Session] + $Session, + + [Parameter(Mandatory, ParameterSetName = 'TransferOptionsObject')] + [ValidateNotNull()] + [WinSCP.TransferOptions] + $TransferOptions, + + [Parameter(ParameterSetName = 'RuntimeTransferOptions')] + [ValidateRange(0, 2147483647)] + [int] + $SpeedLimit = 0, + + [Parameter(ParameterSetName = 'RuntimeTransferOptions')] + [string] + $FileMask, + + [Parameter(ParameterSetName = 'RuntimeTransferOptions')] + [ValidatePattern('^[0-7]{3,4}$')] + [string] + $Permissions, + + [Parameter(ParameterSetName = 'RuntimeTransferOptions')] + [ValidateSet('Overwrite', 'Resume', 'Append')] + [string] + $OverWriteMode = 'Overwrite', + + [Parameter(ParameterSetName = 'RuntimeTransferOptions')] + [bool] + $PreserveTimeStamp = $true, + + [Parameter(ParameterSetName = 'RuntimeTransferOptions')] + [ValidateSet('Automatic', 'Binary', 'Ascii', 'Text')] + [string] + $TransferMode = 'Binary', + + [switch] + $TransferFilesOnly, + + [switch] + $Remove, + + [string] + $DestinationFileName + ) + + process + { + Assert-ScpSession $Session + $options = Resolve-ScpTransferOption $PSBoundParameters + $destination = (Format-StringPath $RemotePath).TrimEnd('/') + '/' + $sources = @(foreach ($path in $LocalPath) + { + $item = Get-Item -LiteralPath $path -ErrorAction Stop + + if ($item.PSProvider.Name -ne 'FileSystem') + { + throw 'LocalPath must use the FileSystem provider.' + } + + if ($TransferFilesOnly -and $item.PSIsContainer) + { + Get-ChildItem -LiteralPath $item.FullName -File -Recurse -ErrorAction Stop + } + else + { + $item + } + }) + + if ($DestinationFileName) + { + Assert-ScpLeafName $DestinationFileName + + if ($TransferFilesOnly -or $sources.Count -ne 1 -or $sources[0].PSIsContainer) + { + throw 'DestinationFileName requires one local file without TransferFilesOnly.' + } + + $target = [WinSCP.RemotePath]::EscapeOperationMask([WinSCP.RemotePath]::Combine($destination, $DestinationFileName)) + } + else + { + $target = $destination + } + + # Flattening removes parent directories, so detect filename collisions before any upload. + if ($TransferFilesOnly) + { + $duplicates = $sources | Group-Object Name | Where-Object Count -GT 1 + + if ($duplicates) + { + throw 'TransferFilesOnly would overwrite duplicate filenames in the flattened destination.' + } + } + + foreach ($item in $sources) + { + $action = 'Upload' + + if ($Remove) + { + $action = 'Upload and remove local source' + } + + if ($PSCmdlet.ShouldProcess("$($item.FullName) -> $target", $action)) + { + New-ScpDirectory -Session $Session -RemotePath $destination -Force -SuppressOutput -ErrorAction Stop + $result = $Session.PutFiles([WinSCP.RemotePath]::EscapeFileMask($item.FullName), $target, [bool]$Remove, $options) + + # WinSCP records transfer failures in the result; raise them before returning it. + $result.Check() + $result + } + } + } +} diff --git a/Public/Set-ScpContent.ps1 b/Public/Set-ScpContent.ps1 new file mode 100644 index 0000000..e893cdf --- /dev/null +++ b/Public/Set-ScpContent.ps1 @@ -0,0 +1,54 @@ +function Set-ScpContent +{ + <# + .SYNOPSIS + Replace a remote file with text, using UTF-8 without a BOM by default. + + .DESCRIPTION + Works through ordinary file transfer on all protocols. It does not add a newline. + + .PARAMETER TransferOptions + Requires Binary transfer mode, Overwrite mode and no FileMask. Local temporary + files are removed even when transfer fails. File parents must already exist. + + .EXAMPLE + Set-ScpContent -Session $session -RemotePath '/config/settings.json' -Value $json + Replaces text using UTF-8 without a BOM or an added newline. + #> + + [CmdletBinding(SupportsShouldProcess)] + param + ( + [Parameter(Mandatory, ValueFromPipeline)] + [WinSCP.Session] + $Session, + + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] + $RemotePath, + + [Parameter(Mandatory)] + [AllowEmptyString()] + [string] + $Value, + + [string] + $Encoding = 'utf-8', + + [WinSCP.TransferOptions] + $TransferOptions = (New-ScpTransferOptions) + ) + + process + { + Assert-ScpSession $Session + $options = Resolve-ScpContentTransferOption $TransferOptions + $bytes = [Text.Encoding]::GetEncoding($Encoding).GetBytes($Value) + + if ($PSCmdlet.ShouldProcess($RemotePath, 'Replace remote file content')) + { + Write-ScpByte -Session $Session -RemotePath (Format-StringPath $RemotePath) -Bytes $bytes -TransferOptions $options + } + } +} diff --git a/Public/Start-WinScpConsole.ps1 b/Public/Start-WinScpConsole.ps1 new file mode 100644 index 0000000..081ffab --- /dev/null +++ b/Public/Start-WinScpConsole.ps1 @@ -0,0 +1,18 @@ +function Start-WinScpConsole +{ + <# + .SYNOPSIS + Launch the bundled WinSCP console and wait for it to exit. + #> + + [CmdletBinding(SupportsShouldProcess)] + param() + + Assert-ScpPlatform + $path = Join-Path $script:ModuleRoot 'bin/WinSCP.exe' + + if ($PSCmdlet.ShouldProcess($path, 'Start console')) + { + Start-Process -FilePath $path -ArgumentList '/console' -Wait + } +} diff --git a/Public/Sync-ScpDirectory.ps1 b/Public/Sync-ScpDirectory.ps1 new file mode 100644 index 0000000..b49424b --- /dev/null +++ b/Public/Sync-ScpDirectory.ps1 @@ -0,0 +1,78 @@ +function Sync-ScpDirectory +{ + <# + .SYNOPSIS + Synchronize local and remote directories. Removal requires the explicit Remove switch. + + .PARAMETER Mode + Remote uploads changes; Local downloads changes; Both synchronizes in both directions. + + .DESCRIPTION + LocalPath must exist. Remove and Mirror are invalid with Both. WhatIf describes + the operation; use Compare-ScpDirectory to inspect individual planned changes. + + .EXAMPLE + Sync-ScpDirectory -Session $session -LocalPath './data' -RemotePath '/data' -Mode Remote -WhatIf + Previews an upload synchronization without transferring or deleting files. + #> + + [CmdletBinding(SupportsShouldProcess)] + [OutputType([WinSCP.SynchronizationResult])] + param + ( + [Parameter(Mandatory, ValueFromPipeline)] + [WinSCP.Session] + $Session, + + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] + $LocalPath, + + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] + $RemotePath, + + [ValidateSet('Local', 'Remote', 'Both')] + [string] + $Mode = 'Remote', + + [switch] + $Remove, + + [switch] + $Mirror, + + [WinSCP.SynchronizationCriteria] + $Criteria = [WinSCP.SynchronizationCriteria]::Time, + + [WinSCP.TransferOptions] + $TransferOptions = (New-ScpTransferOptions) + ) + + process + { + Assert-ScpSession $Session + + if ($Mode -eq 'Both' -and ($Remove -or $Mirror)) + { + throw 'Remove and Mirror cannot be used with Mode Both.' + } + + $directory = Get-Item -LiteralPath $LocalPath -ErrorAction Stop + + if (!$directory.PSIsContainer -or $directory.PSProvider.Name -ne 'FileSystem') + { + throw 'LocalPath must be an existing filesystem directory.' + } + + if ($PSCmdlet.ShouldProcess("$LocalPath <-> $RemotePath", "Synchronize ($Mode, Remove=$Remove, Mirror=$Mirror)")) + { + $result = $Session.SynchronizeDirectories([WinSCP.SynchronizationMode]$Mode, $directory.FullName, (Format-StringPath $RemotePath), [bool]$Remove, [bool]$Mirror, $Criteria, $TransferOptions) + + $result.Check() + $result + } + } +} diff --git a/Public/Test-ScpPath.ps1 b/Public/Test-ScpPath.ps1 new file mode 100644 index 0000000..8365ad2 --- /dev/null +++ b/Public/Test-ScpPath.ps1 @@ -0,0 +1,31 @@ +function Test-ScpPath +{ + <# + .SYNOPSIS + Test existence of a literal remote file or directory. + #> + + [CmdletBinding()] + [OutputType([bool])] + param + ( + [Parameter(Mandatory, ValueFromPipeline)] + [WinSCP.Session] + $Session, + + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string[]] + $RemotePath + ) + + process + { + Assert-ScpSession $Session + + foreach ($path in $RemotePath) + { + $Session.FileExists((Format-StringPath $path)) + } + } +} diff --git a/Public/Test-ScpSession.ps1 b/Public/Test-ScpSession.ps1 new file mode 100644 index 0000000..a93010c --- /dev/null +++ b/Public/Test-ScpSession.ps1 @@ -0,0 +1,22 @@ +function Test-ScpSession +{ + <# + .SYNOPSIS + Return whether a WinSCP session is open. + #> + + [CmdletBinding()] + [OutputType([bool])] + param + ( + [Parameter(Mandatory, ValueFromPipeline)] + [AllowNull()] + [WinSCP.Session] + $Session + ) + + process + { + $null -ne $Session -and $Session.Opened + } +} diff --git a/README.md b/README.md index 1c8b11b..0f30d90 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,48 @@ # PowerScp -PowerScp is a PowerShell module for transferring files and managing them on remote servers. It uses [WinSCP](https://winscp.net/) to handle SFTP, SCP, FTP, FTPS, WebDAV, WebDAVS and S3 connections. +[![PowerShell 5.1+](https://img.shields.io/badge/PowerShell-5.1%20%7C%207-5391FE?logo=powershell)](https://github.com/PowerShell/PowerShell) +[![WinSCP](https://img.shields.io/badge/WinSCP-integrated-2E7D32)](https://winscp.net/) +[![License: MIT](https://img.shields.io/badge/License-MIT-green.svg)](./LICENSE) -The project started with a simple need: upload files to an SFTP server from PowerShell. It has since grown to cover downloads, directory listings, checksums, synchronization and other tasks that come up when writing transfer scripts. +PowerScp is a PowerShell module for moving files reliably between local and remote systems. It uses [WinSCP](https://winscp.net/) to handle SFTP, SCP, FTP, FTPS, WebDAV, WebDAVS and S3 connections. + +This project started with a very practical problem: moving files to a remote server from PowerShell often turns into a pile of brittle copy/paste logic. PowerScp is built to make that workflow more predictable, reusable, and easier to reason about in real automation work. + +## Why it matters + +File transfer work is one of the most common places where automation breaks: mismatched paths, unclear sessions, weak validation, and fragile scripts that are difficult to maintain. PowerScp is designed to make those transfers more predictable, safer, and easier to trust in real operational work. + +## Why this project exists + +PowerScp exists to reduce the friction in one of the most common operational tasks: moving files between systems in a safe and repeatable way. Whether the job is a scheduled upload, a report handoff, a remote sync, or a file inventory, the goal is the same: make remote transfer scripting clearer, more controlled, and easier to trust. + +PowerScp is designed for operational environments where secure and controlled file transfer matters. It is built to support repeatable automation around remote file movement, validation, and synchronization, with explicit session handling and secure connection checks intended for real-world scripting workflows. + +## Portfolio highlights + +- PowerShell-first file transfer tooling for real automation workflows +- support for SFTP, SCP, FTP, FTPS, WebDAV, WebDAVS and S3 +- explicit session setup, fingerprint validation and secure connection settings +- remote listing, synchronization, content reads and file operations in one toolkit +- practical operational focus rather than abstract plumbing + +## Use cases + +PowerScp is useful in the kind of work that comes up in administration, operations and automation projects: + +- uploading reports or generated files to a remote server +- synchronizing a local folder with a remote directory +- retrieving nightly exports or backup artifacts +- validating remote content before processing it further +- scripting secure file movement without rewriting raw WinSCP code each time + +## Project philosophy + +PowerScp values explicit connection settings, secure defaults, predictable behavior, and practical automation over clever abstractions. The goal is to make remote file work feel straightforward, reviewable, and dependable when it matters most. + +## Project status + +PowerScp is an active, practical PowerShell module built around real transfer workflows rather than abstract wrappers. It is designed to be useful in day-to-day automation and infrastructure work, while keeping connection behavior explicit and easy to review. ## What you need @@ -12,6 +52,35 @@ WinSCP 6.5.7 is included in the repository. The module loads the .NET Framework If you update WinSCP yourself, keep the executable and both assemblies on the same version, then restart PowerShell. The [WinSCP installation documentation](https://winscp.net/eng/docs/library_install) explains the two assembly builds. Details about the bundled files and their licenses are in [bin/README.md](bin/README.md). +## Documentation map + +- [CHANGELOG.md](./CHANGELOG.md) +- [docs/TESTING.md](./docs/TESTING.md) +- [docs/REVIEW.md](./docs/REVIEW.md) +- [docs/FEATURE-COMPARISON.md](./docs/FEATURE-COMPARISON.md) +- [bin/README.md](./bin/README.md) + +## Help wanted + +PowerScp is a practical project and there is room for useful contributions. If you have experience with PowerShell automation, SFTP or other transfer workflows, or test automation around remote systems, I would welcome help with: + +- improving documentation and examples +- testing transfer scenarios on different platforms and protocols +- validating edge cases around connection handling and file synchronization +- reviewing edge conditions and reliability improvements + +If you are interested in helping, please open an issue or start a discussion with a short description of the scenario you want to test or improve. + +## At a glance + +| Focus area | What it covers | +| --- | --- | +| Connection handling | SFTP, SCP, FTP, FTPS, WebDAV, WebDAVS and S3 sessions | +| File operations | upload, download, rename, copy, move and delete | +| Remote discovery | directory listings, metadata checks and path validation | +| Synchronization | compare and sync local/remote folders | +| Operational safety | explicit session cleanup, fingerprints, and secure defaults | + ## Getting started Import the module from the project folder: @@ -51,7 +120,7 @@ Closing the session in a `finally` block releases its resources even when an ope Uploading a directory keeps its folder structure. If you use `-TransferFilesOnly`, the module collects files from the entire directory tree and puts them directly into the destination directory. It rejects duplicate filenames before uploading, since flattening those files would cause them to overwrite one another. -`Receive-ScpItem` downloads files into an existing local directory. Its remote paths can use WinSCP file masks, such as `/outgoing/*.csv`. Add `-LiteralPath` when a filename contains mask characters. To rename one file during transfer, use `-DestinationFileName`; downloads also require `-LiteralPath` for this option. +`Receive-ScpItem` downloads files into an existing local directory. Its remote paths can use WinSCP file masks, such as `/outgoing/*.csv`. Add `-LiteralPath` when a filename contains mask characters. To rename one file during transfer, use `-DestinationFileName`; downloads also require `-LiteralPath` for this option, a remote file source and a valid Windows destination filename. Uploads and downloads keep the source files by default. Use `-Remove` when you deliberately want to delete each source after a successful transfer. @@ -116,7 +185,7 @@ $session = $options | New-ScpSession -Name 'archive' Get-ScpSession -Name 'archive' ``` -A name helps when you have several connections open. Names are local to this module instance, and reimporting the module clears its session list. Keep the session reference and close it in `finally` as in the first example. `Remove-ScpSession` disposes it and removes it from the list. `Close-ScpSession` closes the connection while leaving the object available for an explicit `$session.Open($options)` later. +A name helps when you have several connections open. Names are local to this module instance. Removing or force-reimporting the module disposes tracked sessions and clears its list. Keep the session reference and close it in `finally` as in the first example. `Remove-ScpSession` disposes it and removes it from the list. `Close-ScpSession` closes the connection while leaving the object available for an explicit `$session.Open($options)` later. `New-ScpSessionOptions` also accepts `-SessionUrl`, for example `ftpes://user@example.org:2121/`. Pass credentials separately rather than putting passwords into URLs. `-SshHostKeyPolicy AcceptNew` offers WinSCP's trust-on-first-use behavior; strict fingerprint checking remains the default. Use `-SecurePrivateKeyPassphrase` for a SecureString key or client-certificate passphrase, and `-TlsClientCertificatePath` for a client certificate. @@ -171,9 +240,9 @@ New-ScpItem -Session $session -RemotePath '/config/settings.json' -Value $json - Rename-ScpItem -Session $session -RemotePath '/incoming/report.tmp' -NewName 'report.csv' ``` -`Set-ScpContent` replaces a file's text. It writes UTF-8 without a byte order mark by default and does not add a newline. `Get-ScpContent` returns lines, or the whole file with `-Raw`. Reading uses WinSCP streaming, which supports SFTP and FTP/FTPS only. Creation and content writes use regular transfers and also work with other protocols supported by the server. File parents must already exist. +`Set-ScpContent` replaces a file's text. It writes UTF-8 without a byte order mark by default and does not add a newline. Content writes require Binary transfer mode, Overwrite mode and no FileMask. `Get-ScpContent` returns lines, or the whole file with `-Raw`. Reading uses WinSCP streaming, which supports SFTP and FTP/FTPS only. Creation and content writes use regular transfers and also work with other protocols supported by the server. File parents must already exist. -Move, copy and rename refuse to overwrite an existing item unless you pass `-Force`. Force replaces files only; it will not delete an existing destination directory. Replacement removes the old target before the operation, so it is not atomic. Use `-PassThru` to retrieve the resulting metadata. +Move, copy and rename refuse to overwrite an existing item unless you pass `-Force`. Force replaces files only; it will not delete an existing destination directory. Forced replacement moves the old target to a unique sibling backup before the operation. If replacement fails and the destination is absent, the original is restored. If a partial target exists or restoration fails, the error reports the retained backup path for manual recovery. Successful replacement removes the backup; a cleanup failure emits a warning with its path. This is not atomic, requires rename/delete permissions, and does not lock out other clients. Rename treats the new name as an exact destination and rejects an existing directory. Use `-PassThru` to retrieve the resulting metadata. ### Permissions and resumable uploads @@ -227,6 +296,12 @@ Version 1.2 adds the commands and connection settings described above. Move/copy The [code review notes](docs/REVIEW.md) describe the earlier fixes. The [feature comparison](docs/FEATURE-COMPARISON.md) maps PowerScp to the WinSCP Gallery module and explains the features chosen for 1.2. +## Module organization + +Each exported command has its own file in `Public/`. Internal helpers live in `Private/`. `PowerScp.psm1` loads the WinSCP assembly, manages session cleanup and loads the function files. The manifest keeps the explicit list of exported commands. + +When working on a command, edit its function file. Keep shared implementation details in `Private/`, and add a command to the manifest only when it should be part of the public interface. + ## Running the tests The test suite uses Pester 5.7.1: @@ -239,3 +314,5 @@ Invoke-Pester ./tests -CI The tests check module loading, connection settings, transfer options and operation behavior. They use the real WinSCP assemblies, with simulated sessions for operations that would otherwise need a server. Since WinSCP's session class cannot be mocked directly, those tests use a temporary copy of the module with the session type annotations removed. The Windows CI workflow runs the tests in Windows PowerShell 5.1 and the runner's installed PowerShell 7. Local verification has been performed on PowerShell 7.6.6 on macOS. Windows CI and live transfers have not yet been verified for this revision, so testing against your own servers is still needed before a production release. + +Static analysis and opt-in live SFTP verification are described in [docs/TESTING.md](docs/TESTING.md). diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..eb9027f --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,31 @@ +# Security Policy + +## Reporting a vulnerability + +Please do not open a public issue for security-sensitive problems. + +Instead, use the project’s private reporting path and provide the following information: + +- a clear description of the issue +- the affected command, script, or workflow +- expected and actual behavior +- operating system and PowerShell version +- steps to reproduce +- any relevant redacted sample input or output + +If a report includes credentials, secrets, SSH keys, or other sensitive operational data, redact that information before sharing it. + +## Supported versions + +This project is currently in active development, and support expectations should be reviewed before broad production adoption. Security fixes are prioritized based on impact and maintenance needs. + +## Security expectations + +- Keep credentials and secrets out of examples and issue reports. +- Verify remote host fingerprints before trusting connections. +- Treat transport, TLS, and SSH configuration as security-sensitive decisions. +- Prefer explicit, reviewable transfer logic over hidden defaults. + +## Disclosure guidance + +Please allow a reasonable time for a fix to be evaluated and prepared before public disclosure. diff --git a/Staging/Send-ScpItem.ps1 b/Staging/Send-ScpItem.ps1 deleted file mode 100644 index 437b0dd..0000000 --- a/Staging/Send-ScpItem.ps1 +++ /dev/null @@ -1,86 +0,0 @@ -function Send-ScpItem -{ -<# - .SYNOPSIS - A brief description of the Send-ScpItem function. - - .DESCRIPTION - A detailed description of the Send-ScpItem function. - - .PARAMETER LocalPath - A description of the LocalPath parameter. - - .PARAMETER RemotePath - A description of the RemotePath parameter. - - .PARAMETER Session - A description of the Session parameter. - - .PARAMETER OverWriteMode - Specifies behavior when overwriting files on remote destination. - - Note that not all options apply to all protocols. - - .PARAMETER SpeedLimit - A description of the SpeedLimit parameter. - - .EXAMPLE - PS C:\> Send-ScpItem -LocalPath 'value1' -RemotePath 'value2' -Session $Session - - .NOTES - Additional information about the function. -#> - - [CmdletBinding()] - param - ( - [Parameter(Mandatory = $true)] - [string[]] - $LocalPath, - [Parameter(Mandatory = $true)] - [string] - $RemotePath, - [Parameter(Mandatory = $true, - ValueFromPipeline = $true)] - [WinSCP.Session] - $Session, - [ValidateSet('Overwrite', 'Resume', 'Append', IgnoreCase = $true)] - [string] - $OverWriteMode = 'Overwrite', - [int] - $SpeedLimit - ) - - begin - { - # Get arguments from pipeline - $sessionValueFromPipeLine = $PSBoundParameters.ContainsKey('Session') - } - process - { - # Validate local paths - foreach ($path in $LocalPath) - { - - - if (!(Test-Path $path)) - { - Write-Warning -Message "Cannot find path $path because it does not exist" - - continue - } - else - { - if ($RemotePath.EndsWith('/') -eq $false) - { - # Check if path is a directory - [bool]$isDirectory = Get-ScpChildItem -RemotePath $RemotePath - - #TODO: If not dir we should skip or overwrite? - #TODO: If dir but missing trailing / we should add it - #TODO: If dir does not exist we should create it - } - } - } - } -} \ No newline at end of file diff --git a/docs/TESTING.md b/docs/TESTING.md new file mode 100644 index 0000000..bfcc9c7 --- /dev/null +++ b/docs/TESTING.md @@ -0,0 +1,73 @@ +# Verification + +Run from the repository root in Windows PowerShell 5.1 or PowerShell 7. The offline +suite can also run in PowerShell 7 on Linux/macOS; live WinSCP operations require +Windows. Keep the bundled WinSCP executable and assemblies at matching versions. + +## Offline regression tests and static analysis + +```powershell +Install-Module Pester -RequiredVersion 5.7.1 -Scope CurrentUser +Install-Module PSScriptAnalyzer -RequiredVersion 1.24.0 -Scope CurrentUser +Import-Module Pester -RequiredVersion 5.7.1 +./scripts/Test-StaticAnalysis.ps1 +Invoke-Pester ./tests -CI +``` + +Static analysis checks the shipping module and manifest and fails on any +unsuppressed diagnostic. Function-scoped exceptions document existing public +plural option names, legacy plaintext credential parameters and in-memory factories. +Use PSCredential and SecurePrivateKeyPassphrase for new code. No security or +ShouldProcess rules are disabled globally. + +The Windows workflow runs these checks with Windows PowerShell 5.1 and the +runner's PowerShell 7. The offline session doubles exercise wrapper control flow, +not real transfer protocols or server behavior. Live tests are skipped unless +explicitly configured. + +## Live SFTP verification + +Use a dedicated writable test root on an SFTP server and an account that supports +upload, download, chmod, rename, delete and remote copying. Some SFTP servers do +not support remote copy; that case must be evaluated for your deployment rather +than silently ignored. Independently verify the SSH host fingerprint. + +On Windows, set the following values for your own test server: + +```powershell +$env:POWERSCP_LIVE_TESTS = '1' +$env:POWERSCP_SFTP_HOST = 'sftp.example.org' +$env:POWERSCP_SFTP_USER = 'powerscp-test' +$env:POWERSCP_SFTP_KEY = 'C:\keys\test.ppk' +$env:POWERSCP_SFTP_FINGERPRINT = '' +$env:POWERSCP_SFTP_ROOT = '/powerscp-test' +# Optional: $env:POWERSCP_SFTP_PORT = '2222' +Import-Module Pester -RequiredVersion 5.7.1 +Invoke-Pester ./tests/integration -CI -Output Detailed +``` + +The test key must be usable without an interactive passphrase prompt. Nothing +embeds a password in source or logs. Tests create a unique `powerscp-` child +under the configured root and remove only that child on completion. Test cleanup +also disposes the session; inspect leftover test directories after interrupted runs. + +These tests perform real uploads, downloads, source deletion, synchronization +comparison, permissions, text writes and remote replacement. Source deletion is +limited to temporary test files. They cover bracket filenames, exact UTF-8/line +ending round trips, recursive directory uploads and WhatIf behavior. + +Offline tests cover replacement failures and unsafe restoration without needing +to deliberately break a real server. They cannot prove atomicity or concurrency +safety: forced replacement has no server-wide lock. A target is backed up under a +unique `.powerscp-backup-` sibling before replacement. On failure it is +restored only if the destination is absent. Otherwise the backup is retained and +its path is reported. If backup cleanup fails after successful replacement, a +warning reports where the previous file remains. This strategy requires rename +permissions and can fail on servers where copying is allowed but renaming is not. + +## Validation of the 1.2.1 changes + +PowerShell 7.4.13 on Linux: 61 offline tests passed, zero failed; four live tests +were skipped. PSScriptAnalyzer 1.24.0 reported no unsuppressed diagnostics. Windows 5.1, +Windows PowerShell 7 and real SFTP transfers must still be checked using the +workflow and live suite. A configured workflow is not evidence of a passing run. diff --git a/scripts/Test-StaticAnalysis.ps1 b/scripts/Test-StaticAnalysis.ps1 new file mode 100644 index 0000000..810b94d --- /dev/null +++ b/scripts/Test-StaticAnalysis.ps1 @@ -0,0 +1,31 @@ +# Fail on every diagnostic in the shipping module and manifest. Intentional legacy +# API exceptions are documented as narrowly scoped attributes on those functions. +[CmdletBinding()] +param() +$ErrorActionPreference = 'Stop' +Import-Module PSScriptAnalyzer -RequiredVersion 1.24.0 -ErrorAction Stop +$root = Split-Path $PSScriptRoot -Parent +# Resolve result types consistently even when analysis is run before the tests. +$assembly = 'lib/WinSCPnet.dll' +if ($PSEdition -eq 'Core') { $assembly = 'lib/netstandard2.0/WinSCPnet.dll' } +Add-Type -Path (Join-Path $root $assembly) -ErrorAction Stop +$files = @( + Get-Item -LiteralPath (Join-Path $root 'PowerScp.psm1'), (Join-Path $root 'PowerScp.psd1') + + foreach ($folder in @('Private', 'Public')) + { + Get-ChildItem -LiteralPath (Join-Path $root $folder) -Filter '*.ps1' -File + } +) + +$results = @( + foreach ($file in $files) + { + Invoke-ScriptAnalyzer -Path $file.FullName + } +) +if ($results.Count) { + $results | Format-Table RuleName,Severity,Line,Message -Wrap + throw "Static analysis found $($results.Count) diagnostic(s)." +} +Write-Output 'Static analysis passed with no unsuppressed diagnostics.' diff --git a/tests/Features.Tests.ps1 b/tests/Features.Tests.ps1 index 57de762..7ddbb4f 100644 --- a/tests/Features.Tests.ps1 +++ b/tests/Features.Tests.ps1 @@ -1,4 +1,4 @@ -BeforeDiscovery { Import-Module (Join-Path (Split-Path $PSScriptRoot -Parent) 'PowerScp.psd1') -Force } +BeforeDiscovery { Import-Module (Join-Path (Split-Path $PSScriptRoot -Parent) 'PowerScp.psd1') -Force } BeforeAll { Import-Module (Join-Path (Split-Path $PSScriptRoot -Parent) 'PowerScp.psd1') -Force } Describe 'Reusable settings and protocol support' { It 'keeps S3 encrypted for AWS and custom endpoints' { @@ -112,10 +112,8 @@ Describe 'Session creation and registration' { Describe 'Remote administration operations' { BeforeAll { $root=Split-Path $PSScriptRoot -Parent - $source=Get-Content (Join-Path $root 'PowerScp.psm1') -Raw - $source=$source.Substring($source.IndexOf('function Assert-ScpPlatform')).Replace('[WinSCP.Session]','[object]') - $adapter=Join-Path $TestDrive 'PowerScpFeatureHarness.psm1' - Set-Content $adapter $source -Encoding utf8 + . (Join-Path $PSScriptRoot 'Helpers/New-PowerScpTestAdapter.ps1') + $adapter = New-PowerScpTestAdapter -SourceRoot $root -Destination (Join-Path $TestDrive 'PowerScpFeatureHarness') -Name 'PowerScpFeatureHarness' Import-Module $adapter -Force function New-FeatureSession { $fake=[pscustomobject]@{ Opened=$true; Calls=[collections.generic.list[object]]::new(); Files=@{}; Bytes=$null; TempPath=$null; Fail=$false; Stream=$null } @@ -243,4 +241,103 @@ Describe 'Remote administration operations' { { Get-ScpSession -Name archive } | Should -Throw '*No session*' } + It 'rejects rename to an existing directory without moving the source' { + $fake.Files['/data/archive']=[pscustomobject]@{FullName='/data/archive';Name='archive';IsDirectory=$true} + { Rename-ScpItem -Session $fake -RemotePath '/data/report.txt' -NewName archive -Force } | Should -Throw '*directory*' + @($fake.Calls | Where-Object { $_[0] -in @('Move','Remove') }).Count | Should -Be 0 + } + It 'requires binary mode for exact content writes' { + foreach ($mode in @('Ascii','Automatic')) { + { Set-ScpContent -Session $fake -RemotePath '/text.txt' -Value "one`ntwo" -TransferOptions (New-ScpTransferOptions -TransferMode $mode) } | Should -Throw '*Binary*' + { New-ScpItem -Session $fake -RemotePath '/text.txt' -Value text -TransferOptions (New-ScpTransferOptions -TransferMode $mode) } | Should -Throw '*Binary*' + } + @($fake.Calls | Where-Object { $_[0] -eq 'Put' }).Count | Should -Be 0 + } + It 'rejects directory sources and invalid Windows download names' { + $fake.Files['/dir']=[pscustomobject]@{FullName='/dir';Name='dir';IsDirectory=$true} + { Receive-ScpItem -Session $fake -RemotePath '/dir' -LocalPath $TestDrive -LiteralPath -DestinationFileName saved.txt } | Should -Throw '*remote file*' + foreach ($name in @('bad*.txt','bad?.txt','file:stream','CON.txt','trailing.','trailing ','bad|name')) { + { Receive-ScpItem -Session $fake -RemotePath '/file' -LocalPath $TestDrive -LiteralPath -DestinationFileName $name } | Should -Throw '*Windows filename*' + } + @($fake.Calls | Where-Object { $_[0] -eq 'Get' }).Count | Should -Be 0 + } + It 'restores the original destination when replacement fails' { + $fake.Files['/dest.txt']=[pscustomobject]@{FullName='/dest.txt';Name='dest.txt';IsDirectory=$false} + $fake | Add-Member ScriptMethod MoveFile { + param($source,$target) + $this.Calls.Add(@('Move',$source,$target)) + $this.Files[$target]=$this.Files[$source] + $this.Files.Remove($source) + } -Force + $fake | Add-Member ScriptMethod DuplicateFile { param($source,$target) throw 'Copy failed' } -Force + { Copy-ScpItem -Session $fake -RemotePath '/source.txt' -Destination '/dest.txt' -Force } | Should -Throw '*Copy failed*' + $fake.Files.ContainsKey('/dest.txt') | Should -BeTrue + @($fake.Files.Keys | Where-Object { $_ -like '*powerscp-backup*' }).Count | Should -Be 0 + @($fake.Calls | Where-Object { $_[0] -eq 'Remove' }).Count | Should -Be 0 + } + It 'retains the backup and partial target if restoration is unsafe' { + $fake.Files['/dest.txt']=[pscustomobject]@{FullName='/dest.txt';Name='dest.txt';IsDirectory=$false} + $fake | Add-Member ScriptMethod MoveFile { + param($source,$target) + $this.Files[$target]=$this.Files[$source]; $this.Files.Remove($source) + } -Force + $fake | Add-Member ScriptMethod DuplicateFile { + param($source,$target) + $this.Files[$target]=[pscustomobject]@{FullName=$target;IsDirectory=$false} + throw 'Partial copy failed' + } -Force + { Copy-ScpItem -Session $fake -RemotePath '/source.txt' -Destination '/dest.txt' -Force } | Should -Throw '*recover it manually*' + $fake.Files.ContainsKey('/dest.txt') | Should -BeTrue + @($fake.Files.Keys | Where-Object { $_ -like '*powerscp-backup*' }).Count | Should -Be 1 + } + It 'rejects directory-over-file replacement before preserving or deleting the target' { + $fake.Files['/source']=[pscustomobject]@{FullName='/source';Name='source';IsDirectory=$true} + $fake.Files['/dest']=[pscustomobject]@{FullName='/dest';Name='dest';IsDirectory=$false} + { Move-ScpItem -Session $fake -RemotePath '/source' -Destination '/dest' -Force } | Should -Throw '*file with a directory*' + @($fake.Calls | Where-Object { $_[0] -in @('Move','Remove') }).Count | Should -Be 0 + } + It 'disposes tracked sessions when the module is removed' { + & (Get-Module PowerScpFeatureHarness) { param($session) $script:ScpSessions['tracked']=$session } $fake + Remove-Module PowerScpFeatureHarness + $fake.Opened | Should -BeFalse + Import-Module $adapter -Force + } + + It 'removes the backup only after successful replacement' { + $original=[pscustomobject]@{FullName='/dest.txt';Name='dest.txt';IsDirectory=$false;Content='old'} + $replacement=[pscustomobject]@{FullName='/source.txt';Name='source.txt';IsDirectory=$false;Content='new'} + $fake.Files['/dest.txt']=$original + $fake.Files['/source.txt']=$replacement + $fake | Add-Member ScriptMethod MoveFile { + param($source,$target) + $this.Calls.Add(@('Move',$source,$target)); $this.Files[$target]=$this.Files[$source]; $this.Files.Remove($source) + } -Force + $fake | Add-Member ScriptMethod DuplicateFile { + param($source,$target) + $this.Calls.Add(@('Copy',$source,$target)); $this.Files[$target]=$this.Files[$source] + } -Force + $fake | Add-Member ScriptMethod RemoveFile { + param($path) $this.Calls.Add(@('Remove',$path)); $this.Files.Remove($path) + } -Force + Copy-ScpItem -Session $fake -RemotePath '/source.txt' -Destination '/dest.txt' -Force + $fake.Files['/dest.txt'].Content | Should -Be new + @($fake.Files.Keys | Where-Object { $_ -like '*powerscp-backup*' }).Count | Should -Be 0 + @($fake.Calls | Where-Object { $_[0] -in @('Move','Copy','Remove') } | ForEach-Object { $_[0] }) | Should -Be @('Move','Copy','Remove') + } + It 'does not attempt replacement when the original cannot be backed up' { + $fake.Files['/dest.txt']=[pscustomobject]@{FullName='/dest.txt';Name='dest.txt';IsDirectory=$false} + $fake | Add-Member ScriptMethod MoveFile { param($source,$target) throw 'Backup denied' } -Force + { Copy-ScpItem -Session $fake -RemotePath '/source.txt' -Destination '/dest.txt' -Force } | Should -Throw '*Backup denied*' + $fake.Files.ContainsKey('/dest.txt') | Should -BeTrue + @($fake.Calls | Where-Object { $_[0] -in @('Copy','Remove') }).Count | Should -Be 0 + } + It 'reports retained originals when successful replacement cannot clean its backup' { + $fake.Files['/dest.txt']=[pscustomobject]@{FullName='/dest.txt';Name='dest.txt';IsDirectory=$false} + $fake | Add-Member ScriptMethod RemoveFile { param($path) throw 'Cleanup denied' } -Force + $warnings=@() + Copy-ScpItem -Session $fake -RemotePath '/source.txt' -Destination '/dest.txt' -Force -WarningVariable warnings -WarningAction SilentlyContinue + $warnings.Count | Should -Be 1 + $warnings[0].ToString() | Should -Match 'Replacement succeeded.*powerscp-backup-' + } + } diff --git a/tests/Helpers/New-PowerScpTestAdapter.ps1 b/tests/Helpers/New-PowerScpTestAdapter.ps1 new file mode 100644 index 0000000..b385810 --- /dev/null +++ b/tests/Helpers/New-PowerScpTestAdapter.ps1 @@ -0,0 +1,32 @@ +function New-PowerScpTestAdapter +{ + param + ( + [string] $SourceRoot, + [string] $Destination, + [string] $Name + ) + + New-Item -ItemType Directory -Path $Destination -Force | Out-Null + + # Keep the real loader and assemblies; only relax the sealed session type for fakes. + Copy-Item -LiteralPath (Join-Path $SourceRoot 'lib') -Destination $Destination -Recurse + + foreach ($folder in @('Private', 'Public')) + { + $target = Join-Path $Destination $folder + New-Item -ItemType Directory -Path $target -Force | Out-Null + + foreach ($file in Get-ChildItem -LiteralPath (Join-Path $SourceRoot $folder) -Filter '*.ps1' -File) + { + $source = Get-Content -LiteralPath $file.FullName -Raw + $source = $source.Replace('[WinSCP.Session]', '[object]') + Set-Content -LiteralPath (Join-Path $target $file.Name) -Value $source -Encoding UTF8 + } + } + + $adapter = Join-Path $Destination "$Name.psm1" + Copy-Item -LiteralPath (Join-Path $SourceRoot 'PowerScp.psm1') -Destination $adapter + + return $adapter +} diff --git a/tests/PowerScp.Tests.ps1 b/tests/PowerScp.Tests.ps1 index e8175f2..0e48a16 100644 --- a/tests/PowerScp.Tests.ps1 +++ b/tests/PowerScp.Tests.ps1 @@ -1,4 +1,4 @@ -BeforeDiscovery { Import-Module (Join-Path (Split-Path $PSScriptRoot -Parent) 'PowerScp.psd1') -Force -ErrorAction Stop } +BeforeDiscovery { Import-Module (Join-Path (Split-Path $PSScriptRoot -Parent) 'PowerScp.psd1') -Force -ErrorAction Stop } BeforeAll { $script:root = Split-Path $PSScriptRoot -Parent Import-Module (Join-Path $root 'PowerScp.psd1') -Force -ErrorAction Stop @@ -6,7 +6,7 @@ BeforeAll { Describe 'Public module contract' { It 'imports the manifest and exports only public commands' { $manifest = Test-ModuleManifest (Join-Path $root 'PowerScp.psd1') -ErrorAction Stop - $manifest.Version | Should -Be '1.2.0' + $manifest.Version | Should -Be '1.2.1' @(Get-Command -Module PowerScp).Count | Should -Be 31 Get-Command Assert-ScpSession -ErrorAction SilentlyContinue | Should -BeNullOrEmpty } @@ -97,11 +97,8 @@ Describe 'Connection options' { # function bodies. Public type binding is covered above with the real assembly. Describe 'Transfer and enumeration regressions' { BeforeAll { - $source = Get-Content (Join-Path $root 'PowerScp.psm1') -Raw - $source = $source.Substring($source.IndexOf('function Assert-ScpPlatform')) - $source = $source.Replace('[WinSCP.Session]','[object]') - $adapter = Join-Path $TestDrive 'PowerScpHarness.psm1' - Set-Content $adapter $source -Encoding utf8 + . (Join-Path $PSScriptRoot 'Helpers/New-PowerScpTestAdapter.ps1') + $adapter = New-PowerScpTestAdapter -SourceRoot $root -Destination (Join-Path $TestDrive 'PowerScpHarness') -Name 'PowerScpHarness' Import-Module $adapter -Force function New-FakeSession { $fake = [pscustomobject]@{ Opened=$true; Calls=[collections.generic.list[object]]::new(); Items=@(); Exists=$true; Fail=$false } diff --git a/tests/integration/Sftp.Tests.ps1 b/tests/integration/Sftp.Tests.ps1 new file mode 100644 index 0000000..7b2f3ae --- /dev/null +++ b/tests/integration/Sftp.Tests.ps1 @@ -0,0 +1,78 @@ +# Opt-in only. The root must be a dedicated writable test directory on the server. +BeforeDiscovery { + $enabled = $env:POWERSCP_LIVE_TESTS -eq '1' -and [Environment]::OSVersion.Platform -eq [PlatformID]::Win32NT +} +Describe 'Live SFTP transfers' -Tag Integration -Skip:(!$enabled) { + BeforeAll { + Import-Module (Join-Path (Split-Path (Split-Path $PSScriptRoot -Parent) -Parent) 'PowerScp.psd1') -Force -ErrorAction Stop + foreach ($name in @('POWERSCP_SFTP_HOST','POWERSCP_SFTP_USER','POWERSCP_SFTP_KEY','POWERSCP_SFTP_FINGERPRINT','POWERSCP_SFTP_ROOT')) { + if (![Environment]::GetEnvironmentVariable($name)) { throw "Required environment variable: $name" } + } + $arguments = @{ + RemoteHost=$env:POWERSCP_SFTP_HOST; UserName=$env:POWERSCP_SFTP_USER + SshKeyPath=$env:POWERSCP_SFTP_KEY; SshHostKeyFingerprint=$env:POWERSCP_SFTP_FINGERPRINT + Protocol='Sftp'; ErrorAction='Stop' + } + if ($env:POWERSCP_SFTP_PORT) { $arguments.ServerPort=[int]$env:POWERSCP_SFTP_PORT } + $session=New-ScpSession @arguments + $runRoot=$env:POWERSCP_SFTP_ROOT.TrimEnd('/') + '/powerscp-' + [guid]::NewGuid().ToString('N') + New-ScpDirectory -Session $session -RemotePath $runRoot -ErrorAction Stop | Out-Null + } + AfterAll { + if ($session) { + try { + if ($runRoot) { Remove-ScpItem -Session $session -RemotePath $runRoot -Confirm:$false -ErrorAction Stop | Out-Null } + } finally { Remove-ScpSession -Session $session -Confirm:$false | Out-Null } + } + } + BeforeEach { + $remote=$runRoot + '/' + [guid]::NewGuid().ToString('N') + New-ScpDirectory -Session $session -RemotePath $remote -ErrorAction Stop | Out-Null + $local=Join-Path $TestDrive ([guid]::NewGuid().ToString('N')) + New-Item -ItemType Directory -Path $local | Out-Null + } + It 'round trips bracket filenames and exact UTF-8 bytes with permissions' { + $file=Join-Path $local 'report[1].txt' + $bytes=[Text.UTF8Encoding]::new($false).GetBytes("caffè`none`r`ntwo") + [IO.File]::WriteAllBytes($file,$bytes) + Send-ScpItem -Session $session -LocalPath $file -RemotePath $remote -Permissions 600 | Out-Null + (Get-ScpItemType -Session $session -RemotePath "$remote/report[1].txt").FilePermissions.Octal | Should -Be '600' + $downloads=New-Item -ItemType Directory -Path (Join-Path $local downloads) + Receive-ScpItem -Session $session -RemotePath "$remote/report[1].txt" -LiteralPath -LocalPath $downloads.FullName -DestinationFileName 'saved[1].txt' | Out-Null + [Convert]::ToBase64String([IO.File]::ReadAllBytes((Join-Path $downloads.FullName 'saved[1].txt'))) | Should -Be ([Convert]::ToBase64String($bytes)) + } + It 'keeps folder structure and previews synchronization without mutation' { + $tree=New-Item -ItemType Directory -Path (Join-Path $local tree) + $child=New-Item -ItemType Directory -Path (Join-Path $tree.FullName child) + [IO.File]::WriteAllText((Join-Path $child.FullName file.txt),'contents') + Send-ScpItem -Session $session -LocalPath $tree.FullName -RemotePath $remote -WhatIf + Test-ScpPath -Session $session -RemotePath "$remote/tree" | Should -BeFalse + Send-ScpItem -Session $session -LocalPath $tree.FullName -RemotePath $remote | Out-Null + Test-ScpPath -Session $session -RemotePath "$remote/tree/child/file.txt" | Should -BeTrue + [IO.File]::WriteAllText((Join-Path $tree.FullName new.txt),'new') + @(Compare-ScpDirectory -Session $session -LocalPath $tree.FullName -RemotePath "$remote/tree").Count | Should -BeGreaterThan 0 + Sync-ScpDirectory -Session $session -LocalPath $tree.FullName -RemotePath "$remote/tree" -WhatIf + Test-ScpPath -Session $session -RemotePath "$remote/tree/new.txt" | Should -BeFalse + } + It 'requires explicit source removal and safely replaces and renames files' { + $file=Join-Path $local source.txt + [IO.File]::WriteAllText($file,'new') + Send-ScpItem -Session $session -LocalPath $file -RemotePath $remote | Out-Null + Test-Path -LiteralPath $file | Should -BeTrue + New-ScpItem -Session $session -RemotePath "$remote/dest.txt" -Value old | Out-Null + Copy-ScpItem -Session $session -RemotePath "$remote/source.txt" -Destination "$remote/dest.txt" -Force + Get-ScpContent -Session $session -RemotePath "$remote/dest.txt" -Raw | Should -Be new + Rename-ScpItem -Session $session -RemotePath "$remote/dest.txt" -NewName renamed.txt + Test-ScpPath -Session $session -RemotePath "$remote/renamed.txt" | Should -BeTrue + Send-ScpItem -Session $session -LocalPath $file -RemotePath $remote -Remove | Out-Null + Test-Path -LiteralPath $file | Should -BeFalse + } + It 'writes exact content and refuses rename into an existing directory' { + $text="caffè`none`r`ntwo" + Set-ScpContent -Session $session -RemotePath "$remote/content.txt" -Value $text | Out-Null + Get-ScpContent -Session $session -RemotePath "$remote/content.txt" -Raw | Should -Be $text + New-ScpDirectory -Session $session -RemotePath "$remote/archive" | Out-Null + { Rename-ScpItem -Session $session -RemotePath "$remote/content.txt" -NewName archive -Force } | Should -Throw '*directory*' + Test-ScpPath -Session $session -RemotePath "$remote/content.txt" | Should -BeTrue + } +}