add Upload-File function to Tools.ps1

This commit is contained in:
2026-09-30 07:45:01 -04:00
parent 745902f8c2
commit c74fe82d36
3 changed files with 419 additions and 1 deletions
+26 -1
View File
@@ -6,4 +6,29 @@ Most scripts here require the Tools.ps1 script. You can dot source that script l
You can then run any of the scripts using the Run-Script function:
Run-Script -LivePSScript Script-Name
If you're using an RMM tool, the scripts in the /rmm directory are all that's needed to hook the main script in this directory.
If you're using an RMM tool, the scripts in the /rmm directory are all that's needed to hook the main script in this directory.
## Uploading files
After dot-sourcing `Tools.ps1`, scripts can upload a completed file to the Emberkom FileDrop:
```powershell
Upload-File -Path 'C:\ProgramData\Emberkom\Output\report.zip'
# Inside Get-DSAManifest.ps1, after successful collection:
Upload-File -Path $DsaResultPath -Subject "DSA manifest from ${Env:COMPUTERNAME}"
```
The default destination is `https://xfer.emberkom.com/filedrop/cmd`. The sender defaults to the computer's hostname plus its primary DNS suffix, followed by `@ek-upload.net` (for example, `pc01.example.com@ek-upload.net`). Without a DNS suffix, it uses the hostname. Override these with `-FileDropUrl` and `-From`; `-Subject` and `-Message` are also optional.
`Upload-File` uses the [LiquidFiles FileDrop API](https://docs.liquidfiles.com/api/v4.3/filedrop/) to obtain a temporary token, upload the file, and submit the message that triggers FileDrop delivery and its configured email notification. It returns a receipt with the path, destination, sender, attachment ID, byte count, and server status. It retains the local file and throws on failure.
Uploads use [binary chunks](https://docs.liquidfiles.com/api/v4.3/attachments/chunks.html) with a default 10 MiB buffer, so large files do not need to fit in memory. The FileDrop's size and extension restrictions are checked before upload. `-ChunkSizeMB`, `-RetryCount`, and `-TimeoutSeconds` control chunk size, retries, and each request's timeout; `-Verbose` displays transfer details.
Allow the RMM job to run for the entire upload. The function waits for completion and retries failed chunks within that run; it does not resume across process restarts. LiquidFiles tokens expire after 24 hours. Final submission is attempted once: if its response is lost, check the FileDrop before rerunning to avoid duplicate notifications.
Offline tests (no uploads or notifications):
```powershell
powershell.exe -NoProfile -File .\tests\Upload-File.Tests.ps1
```
+219
View File
@@ -209,6 +209,225 @@ Function Download-File {
Return $File
}
# Upload a file to a LiquidFiles Filedrop and submit its notification message.
Function Upload-File {
<#
.SYNOPSIS
Uploads a file to the Emberkom LiquidFiles Filedrop.
.DESCRIPTION
Uses the LiquidFiles 3.7+ JSON API with bounded binary chunks, including in
32-bit Windows PowerShell 5. The local file is retained. Returns a receipt
only after the Filedrop accepts the final message for delivery.
Uploads run synchronously. Allow enough time in the RMM for the entire
transfer; the temporary Filedrop API key expires after 24 hours. Failed
chunks are retried within this call, but restarting the script starts a
new transfer. Final message submission is not retried automatically,
because a lost response could otherwise cause duplicate notifications.
.PARAMETER Path
Literal path of one completed file. Also accepts -File or a pipeline path.
.PARAMETER From
Optional sender override. Defaults to the computer's fully qualified host
name at ek-upload.net (or its host name when no DNS suffix is configured).
.PARAMETER ChunkSizeMB
Maximum upload buffer size in MiB; defaults to 10 regardless of file size.
.PARAMETER TimeoutSeconds
Timeout for each HTTP request, not for the entire transfer.
.EXAMPLE
Upload-File -Path $DsaResultPath
.EXAMPLE
Upload-File -File 'C:\Reports\report.zip' -Subject 'Diagnostic report' -Verbose
.LINK
https://docs.liquidfiles.com/api/v4.3/filedrop/
.LINK
https://docs.liquidfiles.com/api/v4.3/attachments/chunks.html
#>
[CmdletBinding()]
param(
[Parameter(Mandatory=$true,ValueFromPipeline=$true,ValueFromPipelineByPropertyName=$true)]
[Alias('File','FullName')][ValidateNotNullOrEmpty()][string]$Path,
[ValidateNotNullOrEmpty()][string]$FileDropUrl = 'https://xfer.emberkom.com/filedrop/cmd',
[string]$From,
[string]$Subject,
[string]$Message,
[ValidateRange(1,100)][int]$ChunkSizeMB = 10,
[ValidateRange(1,3600)][int]$TimeoutSeconds = 900,
[ValidateRange(0,10)][int]$RetryCount = 3
)
PROCESS {
$UploadUri = [uri]$FileDropUrl
If ( !$UploadUri.IsAbsoluteUri -or $UploadUri.Scheme -ne 'https' -or
$UploadUri.UserInfo -or $UploadUri.Query -or $UploadUri.Fragment ) {
Throw 'FileDropUrl must be an absolute HTTPS Filedrop URL without credentials, query, or fragment.'
}
$UploadBaseUrl = $UploadUri.AbsoluteUri.TrimEnd('/')
$UploadFile = Get-Item -LiteralPath $Path -Force -ErrorAction Stop
If ( $UploadFile -isnot [System.IO.FileInfo] ) { Throw 'Upload-File requires a file, not a directory.' }
$UploadHostInfo = [System.Net.NetworkInformation.IPGlobalProperties]::GetIPGlobalProperties()
$UploadHostName = $UploadHostInfo.HostName
If ( [string]::IsNullOrWhiteSpace($UploadHostName) ) { $UploadHostName = [Environment]::MachineName }
$UploadDnsSuffix = $UploadHostInfo.DomainName.Trim('.')
If ( $UploadDnsSuffix -and !$UploadHostName.EndsWith(".${UploadDnsSuffix}", [StringComparison]::OrdinalIgnoreCase) ) {
$UploadHostName = "${UploadHostName}.${UploadDnsSuffix}"
}
$UploadSender = $From
If ( [string]::IsNullOrWhiteSpace($UploadSender) ) { $UploadSender = $UploadHostName.ToLowerInvariant() + '@ek-upload.net' }
Try { $UploadSender = ([System.Net.Mail.MailAddress]::new($UploadSender)).Address }
Catch { Throw 'From must be a valid sender email address.' }
$UploadSubject = $Subject
If ( [string]::IsNullOrWhiteSpace($UploadSubject) ) { $UploadSubject = "File upload from ${UploadHostName}: $($UploadFile.Name)" }
$UploadMessage = $Message
If ( [string]::IsNullOrWhiteSpace($UploadMessage) ) { $UploadMessage = "Uploaded by ${UploadHostName}." }
Add-Type -AssemblyName System.Net.Http -ErrorAction Stop
$UploadClient = $null
$UploadHandler = $null
$UploadStream = $null
$UploadApiKey = $null
# Keep the same HTTP client (and cookies) for every chunk in this session.
Function Invoke-FileDropRequest {
param(
[string]$Method,
[string]$Uri,
[byte[]]$Data,
[int]$Count = 0,
[string]$ContentType,
[int]$Retries = $RetryCount
)
For ( $UploadAttempt = 0; $UploadAttempt -le $Retries; $UploadAttempt++ ) {
$UploadRequest = $null
$UploadResponse = $null
$UploadStatus = 0
Try {
$UploadRequest = [System.Net.Http.HttpRequestMessage]::new([System.Net.Http.HttpMethod]::new($Method), $Uri)
If ( $Method -eq 'POST' ) {
$UploadRequest.Content = [System.Net.Http.ByteArrayContent]::new($Data, 0, $Count)
If ( $ContentType ) {
$UploadRequest.Content.Headers.ContentType = [System.Net.Http.Headers.MediaTypeHeaderValue]::Parse($ContentType)
}
}
$UploadResponse = $UploadClient.SendAsync($UploadRequest).GetAwaiter().GetResult()
$UploadStatus = [int]$UploadResponse.StatusCode
$UploadResponseText = $UploadResponse.Content.ReadAsStringAsync().GetAwaiter().GetResult()
If ( !$UploadResponse.IsSuccessStatusCode ) {
# Do not include authentication tokens in errors or RMM logs.
If ( $UploadApiKey ) { $UploadResponseText = $UploadResponseText.Replace($UploadApiKey, '[redacted]') }
If ( $UploadResponseText.Length -gt 1000 ) { $UploadResponseText = $UploadResponseText.Substring(0, 1000) }
Throw "Filedrop returned HTTP ${UploadStatus}: ${UploadResponseText}"
}
If ( [string]::IsNullOrWhiteSpace($UploadResponseText) ) { Return $null }
$UploadResponseData = ConvertFrom-Json -InputObject $UploadResponseText -ErrorAction Stop
If ( $UploadResponseData.errors ) { Throw ('Filedrop rejected the request: ' + ($UploadResponseData.errors -join '; ')) }
Return $UploadResponseData
}
Catch {
$UploadCanRetry = $UploadStatus -eq 0 -or $UploadStatus -in @(408,429,500,502,503,504)
If ( !$UploadCanRetry -or $UploadAttempt -ge $Retries ) { Throw }
Write-Verbose "Retrying Filedrop request after a connection error or HTTP ${UploadStatus}."
}
Finally {
If ( $null -ne $UploadResponse ) { $UploadResponse.Dispose() }
If ( $null -ne $UploadRequest ) { $UploadRequest.Dispose() }
}
Start-Sleep -Seconds ([Math]::Min(30, [Math]::Pow(2, $UploadAttempt + 1)))
}
}
Try {
# Deny writes while reading so retried chunks always contain the same bytes.
$UploadStream = [System.IO.File]::Open($UploadFile.FullName, [System.IO.FileMode]::Open,
[System.IO.FileAccess]::Read, [System.IO.FileShare]::Read)
[long]$UploadLength = $UploadStream.Length
$UploadHandler = [System.Net.Http.HttpClientHandler]::new()
$UploadHandler.AllowAutoRedirect = $false
$UploadClient = New-Object -TypeName System.Net.Http.HttpClient -ArgumentList $UploadHandler
$UploadClient.Timeout = [TimeSpan]::FromSeconds($TimeoutSeconds)
$UploadClient.DefaultRequestHeaders.Accept.ParseAdd('application/json')
$UploadInfo = (Invoke-FileDropRequest -Method GET -Uri $UploadBaseUrl).filedrop
$UploadApiKey = [string]$UploadInfo.api_key
If ( [string]::IsNullOrWhiteSpace($UploadApiKey) ) {
Throw 'The Filedrop did not provide an API key. Check its URL, password, and email-validation requirements.'
}
If ( $UploadInfo.max_upload_size -gt 0 -and $UploadLength -gt ([long]$UploadInfo.max_upload_size * 1MB) ) {
Throw "The file exceeds the Filedrop limit of $($UploadInfo.max_upload_size) MiB."
}
$UploadExtension = $UploadFile.Extension.TrimStart('.').ToLowerInvariant()
$UploadPermitted = $UploadInfo.permitted_extensions
If ( !$UploadPermitted ) { $UploadPermitted = $UploadInfo.limited_extensions }
$UploadAllowedExtensions = @(([string]$UploadPermitted -split '[,\s]+') | ForEach-Object { $_.TrimStart('.') } | Where-Object { $_ })
$UploadBlockedExtensions = @(([string]$UploadInfo.blocked_extensions -split '[,\s]+') | ForEach-Object { $_.TrimStart('.') } | Where-Object { $_ })
If ( ($UploadAllowedExtensions.Count -gt 0 -and $UploadExtension -notin $UploadAllowedExtensions) -or
$UploadExtension -in $UploadBlockedExtensions ) {
Throw "The Filedrop does not permit the file extension '$UploadExtension'."
}
$UploadAuth = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($UploadApiKey + ':x'))
$UploadClient.DefaultRequestHeaders.Authorization = [System.Net.Http.Headers.AuthenticationHeaderValue]::new('Basic', $UploadAuth)
[int]$UploadChunkSize = $ChunkSizeMB * 1MB
[long]$UploadChunks = [Math]::Max(1, [Math]::Ceiling($UploadLength / [double]$UploadChunkSize))
$UploadBuffer = [byte[]]::new([int][Math]::Min($UploadChunkSize, [Math]::Max(1, $UploadLength)))
$UploadEncodedName = [uri]::EscapeDataString($UploadFile.Name)
$UploadAttachment = $null
Write-Verbose "Uploading $($UploadFile.Name) ($UploadLength bytes) in $UploadChunks chunk(s) as ${UploadSender}."
For ( [long]$UploadChunk = 0; $UploadChunk -lt $UploadChunks; $UploadChunk++ ) {
[int]$UploadBytesNeeded = [Math]::Min($UploadChunkSize, $UploadLength - $UploadStream.Position)
[int]$UploadBytesRead = 0
While ( $UploadBytesRead -lt $UploadBytesNeeded ) {
$UploadRead = $UploadStream.Read($UploadBuffer, $UploadBytesRead, $UploadBytesNeeded - $UploadBytesRead)
If ( $UploadRead -eq 0 ) { Throw 'The local file ended before all expected bytes were read.' }
$UploadBytesRead += $UploadRead
}
$UploadChunkUrl = "${UploadBaseUrl}/attachments/upload?filename=${UploadEncodedName}&chunk=${UploadChunk}&chunks=${UploadChunks}"
$UploadChunkResult = Invoke-FileDropRequest -Method POST -Uri $UploadChunkUrl -Data $UploadBuffer -Count $UploadBytesRead
If ( $UploadChunkResult.attachment.id ) { $UploadAttachment = $UploadChunkResult.attachment }
Write-Progress -Activity "Uploading $($UploadFile.Name)" -Status "Chunk $($UploadChunk + 1) of ${UploadChunks}" `
-PercentComplete ([int](100 * ($UploadChunk + 1) / $UploadChunks))
}
If ( !$UploadAttachment.id ) { Throw 'The upload did not return an attachment ID; the Filedrop message was not submitted.' }
If ( $null -ne $UploadAttachment.size -and [long]$UploadAttachment.size -ne $UploadLength ) {
Throw 'The uploaded attachment size does not match the local file; the Filedrop message was not submitted.'
}
If ( $UploadAttachment.content_blocked ) { Throw 'LiquidFiles blocked this attachment; the Filedrop message was not submitted.' }
$UploadBody = @{ message = @{
from = $UploadSender
subject = $UploadSubject
message = $UploadMessage
attachments = @([string]$UploadAttachment.id)
} } | ConvertTo-Json -Depth 4 -Compress
$UploadBodyBytes = [Text.Encoding]::UTF8.GetBytes($UploadBody)
Try {
$UploadReceipt = Invoke-FileDropRequest -Method POST -Uri $UploadBaseUrl -Data $UploadBodyBytes `
-Count $UploadBodyBytes.Length -ContentType 'application/json; charset=utf-8' -Retries 0
If ( [string]::IsNullOrWhiteSpace([string]$UploadReceipt.message.status) ) {
Throw 'The server did not return a Filedrop submission confirmation.'
}
}
Catch {
Throw "Filedrop submission was not confirmed. Check the Filedrop before retrying to avoid duplicate notifications. $($_.Exception.Message)"
}
[pscustomobject]@{
Path = $UploadFile.FullName
FileDropUrl = $UploadBaseUrl
From = $UploadSender
AttachmentId = [string]$UploadAttachment.id
Size = $UploadLength
Status = [string]$UploadReceipt.message.status
}
}
Finally {
Write-Progress -Activity "Uploading $($UploadFile.Name)" -Completed
If ( $null -ne $UploadStream ) { $UploadStream.Dispose() }
If ( $null -ne $UploadClient ) { $UploadClient.Dispose() }
ElseIf ( $null -ne $UploadHandler ) { $UploadHandler.Dispose() }
$UploadApiKey = $null
}
}
}
# Install a program from a provided path
Function Install-Program {
param(
+174
View File
@@ -0,0 +1,174 @@
#Requires -Version 5.0
# Offline protocol tests. No network requests, production setup, or notifications.
$ErrorActionPreference = 'Stop'
$ProgressPreference = 'SilentlyContinue'
Add-Type -AssemblyName System.Net.Http
$ToolsPath = Join-Path (Split-Path $PSScriptRoot -Parent) 'Tools.ps1'
$ParseTokens = $null
$ParseErrors = $null
$ToolsAst = [System.Management.Automation.Language.Parser]::ParseFile($ToolsPath, [ref]$ParseTokens, [ref]$ParseErrors)
If ( $ParseErrors.Count ) { Throw ($ParseErrors | Out-String) }
$Definition = $ToolsAst.Find({
param($Node)
$Node -is [System.Management.Automation.Language.FunctionDefinitionAst] -and $Node.Name -eq 'Upload-File'
}, $true)
. ([scriptblock]::Create($Definition.Extent.Text))
If ( !('FileDropTestHandler' -as [type]) ) {
Add-Type -ReferencedAssemblies System.Net.Http -TypeDefinition @'
using System;
using System.Collections.Generic;
using System.Net;
using System.Net.Http;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
public class FileDropTestHandler : HttpMessageHandler {
public string Mode;
public int InfoRequests, UploadRequests, Submissions;
public string Submission, Filename;
public long ReceivedSize;
public List<byte[]> Parts = new List<byte[]>();
public Dictionary<int, int> Attempts = new Dictionary<int, int>();
public FileDropTestHandler(string mode) { Mode = mode; }
private HttpResponseMessage Json(int status, string body) {
return new HttpResponseMessage((HttpStatusCode)status) {
Content = new StringContent(body, Encoding.UTF8, "application/json")
};
}
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken token) {
if (request.Method == HttpMethod.Get) {
InfoRequests++;
if (request.Headers.Authorization != null) throw new Exception("Unexpected authentication on initial request.");
if (Mode == "info-error") return Task.FromResult(Json(401, "{\"errors\":[\"Authentication required\"]}"));
string limit = Mode == "too-large" ? "1" : "102400";
string extensions = Mode == "extension" ? "pdf" : "zip";
return Task.FromResult(Json(200, "{\"filedrop\":{\"api_key\":\"test-token\",\"max_upload_size\":" + limit +
",\"permitted_extensions\":\"" + extensions + "\",\"blocked_extensions\":\"\"}}"));
}
if (request.Headers.Authorization == null || request.Headers.Authorization.ToString() != "Basic dGVzdC10b2tlbjp4")
throw new Exception("Incorrect FileDrop authentication.");
byte[] data = request.Content.ReadAsByteArrayAsync().GetAwaiter().GetResult();
if (request.RequestUri.AbsolutePath.EndsWith("/attachments/upload")) {
UploadRequests++;
if (request.Content.Headers.ContentType != null) throw new Exception("Binary upload must allow server content detection.");
var query = new Dictionary<string, string>();
foreach (string part in request.RequestUri.Query.TrimStart('?').Split('&')) {
string[] pair = part.Split(new char[] { '=' }, 2);
query[pair[0]] = Uri.UnescapeDataString(pair[1]);
}
int chunk = Int32.Parse(query["chunk"]), chunks = Int32.Parse(query["chunks"]);
Filename = query["filename"];
if (!Attempts.ContainsKey(chunk)) Attempts[chunk] = 0;
Attempts[chunk]++;
if (Mode == "permanent") return Task.FromResult(Json(413, "{\"errors\":[\"Rejected\"]}"));
if (Mode == "json-error") return Task.FromResult(Json(200, "{\"errors\":[\"Rejected\"]}"));
if ((Mode == "retry" && chunk == 1 && Attempts[chunk] == 1) || Mode == "exhausted")
return Task.FromResult(Json(503, "{\"errors\":[\"Temporarily unavailable\"]}"));
if (Mode == "lost-chunk" && chunk == 1 && Attempts[chunk] == 1)
throw new HttpRequestException("Simulated connection loss.");
if (chunk != Parts.Count) throw new Exception("Unexpected chunk ordering.");
Parts.Add(data);
ReceivedSize += data.Length;
if (chunk + 1 != chunks || Mode == "no-id") return Task.FromResult(Json(200, "{\"chunk\":true}"));
long size = ReceivedSize + (Mode == "bad-size" ? 1 : 0);
return Task.FromResult(Json(200, "{\"attachment\":{\"id\":\"test-attachment\",\"size\":" + size +
",\"content_blocked\":" + (Mode == "blocked" ? "true" : "false") + "}}"));
}
Submissions++;
Submission = Encoding.UTF8.GetString(data);
if (request.Content.Headers.ContentType.MediaType != "application/json") throw new Exception("Submission must be JSON.");
if (Mode == "submission-failure") return Task.FromResult(Json(503, "{\"errors\":[\"Unavailable\"]}"));
if (Mode == "submission-lost") throw new HttpRequestException("Simulated lost submission response.");
if (Mode == "no-confirmation") return Task.FromResult(Json(200, "{}"));
return Task.FromResult(Json(200, "{\"message\":{\"status\":\"Filedrop message sent successfully\"}}"));
}
}
'@
}
# Replace only the HTTP transport, keeping production request/response handling.
Function New-Object {
param([string]$TypeName, [object[]]$ArgumentList)
If ( $TypeName -ne 'System.Net.Http.HttpClient' ) { Throw "Unexpected object: ${TypeName}" }
$ArgumentList[0].Dispose()
Return [System.Net.Http.HttpClient]::new($script:TestHandler)
}
Function Start-Sleep { param($Seconds) } # Keep retry tests fast.
Function Assert-True { param([bool]$Condition, [string]$Description) If (!$Condition) { Throw $Description } }
$TestRoot = Join-Path ([IO.Path]::GetTempPath()) ('filedrop-tests-' + [guid]::NewGuid().ToString('N'))
$null = New-Item -Path $TestRoot -ItemType Directory
Try {
# Spaces, URL metacharacters, and a Unicode name; more than two full chunks.
$TestFile = Join-Path $TestRoot ('report [1] & ' + [char]0xE9 + '.zip')
$TestBytes = [byte[]]::new(2MB + 37)
[Random]::new(73).NextBytes($TestBytes)
[IO.File]::WriteAllBytes($TestFile, $TestBytes)
$TestMessage = 'Machine diagnostics "quoted" ' + [char]0xE9
Foreach ($Mode in @('success', 'retry', 'lost-chunk')) {
$script:TestHandler = [FileDropTestHandler]::new($Mode)
$Result = @(Upload-File -Path $TestFile -ChunkSizeMB 1 -Message $TestMessage)
Assert-True ($Result.Count -eq 1 -and $Result[0].AttachmentId -eq 'test-attachment') 'Expected one structured receipt.'
Assert-True ($TestHandler.Parts.Count -eq 3 -and $TestHandler.Submissions -eq 1) 'Expected three chunks and one submission.'
Assert-True ($TestHandler.Filename -ceq [IO.Path]::GetFileName($TestFile)) 'Filename did not round trip through URL encoding.'
$Rebuilt = [IO.MemoryStream]::new()
Try {
Foreach ($Part in $TestHandler.Parts) { $Rebuilt.Write($Part, 0, $Part.Length) }
$Hash = [Security.Cryptography.SHA256]::Create()
Try {
$ExpectedHash = [Convert]::ToBase64String($Hash.ComputeHash($TestBytes))
$ActualHash = [Convert]::ToBase64String($Hash.ComputeHash($Rebuilt.ToArray()))
Assert-True ($ExpectedHash -eq $ActualHash) 'Reassembled upload does not match the source.'
} Finally { $Hash.Dispose() }
} Finally { $Rebuilt.Dispose() }
$Sent = ($TestHandler.Submission | ConvertFrom-Json).message
Assert-True ($Sent.message -ceq $TestMessage) 'Submission text was corrupted.'
Assert-True ($Sent.attachments.Count -eq 1 -and $Sent.attachments[0] -eq 'test-attachment') 'Incorrect attachment list.'
$HostInfo = [Net.NetworkInformation.IPGlobalProperties]::GetIPGlobalProperties()
$ExpectedHost = $HostInfo.HostName
If ($HostInfo.DomainName -and !$ExpectedHost.EndsWith('.' + $HostInfo.DomainName, [StringComparison]::OrdinalIgnoreCase)) {
$ExpectedHost += '.' + $HostInfo.DomainName
}
Assert-True ($Sent.from -eq ($ExpectedHost.ToLowerInvariant() + '@ek-upload.net')) 'Incorrect default sender.'
If ($Mode -ne 'success') { Assert-True ($TestHandler.Attempts[1] -eq 2) 'Failed chunk was not retried.' }
Write-Host "PASS: $Mode; exact bytes, filename, sender, metadata, receipt."
}
Foreach ($Mode in @('info-error','too-large','extension','permanent','json-error','exhausted','no-id','bad-size','blocked',
'submission-failure','submission-lost','no-confirmation')) {
$script:TestHandler = [FileDropTestHandler]::new($Mode)
$Caught = $false
Try { $null = Upload-File -Path $TestFile -ChunkSizeMB 1 -RetryCount 1 }
Catch { $Caught = $true }
Assert-True $Caught "Expected a terminating error for ${Mode}."
If ($Mode -in @('info-error','too-large','extension')) { Assert-True ($TestHandler.UploadRequests -eq 0) 'Validation must precede upload.' }
If ($Mode -in @('permanent','json-error')) { Assert-True ($TestHandler.UploadRequests -eq 1) 'Permanent error must not retry.' }
If ($Mode -eq 'exhausted') { Assert-True ($TestHandler.UploadRequests -eq 2) 'Retry limit was not enforced.' }
$ExpectedSubmissions = If ($Mode -in @('submission-failure','submission-lost','no-confirmation')) { 1 } Else { 0 }
Assert-True ($TestHandler.Submissions -eq $ExpectedSubmissions) 'Submission was retried or sent despite upload failure.'
Write-Host "PASS: $Mode; correct failure and submission behavior."
}
$EmptyFile = Join-Path $TestRoot 'empty.zip'
[IO.File]::WriteAllBytes($EmptyFile, [byte[]]@())
$script:TestHandler = [FileDropTestHandler]::new('success')
$EmptyResult = Upload-File -File $EmptyFile -From 'override@example.com'
Assert-True ($EmptyResult.Size -eq 0 -and $EmptyResult.From -eq 'override@example.com') 'Empty file or sender override failed.'
Assert-True ($TestHandler.UploadRequests -eq 1) 'Empty file must still upload once.'
# Confirm the source can be opened exclusively after failure and success.
$CheckStream = [IO.File]::Open($TestFile, [IO.FileMode]::Open, [IO.FileAccess]::ReadWrite, [IO.FileShare]::None)
$CheckStream.Dispose()
Write-Host "PASS: empty file, sender override, and file handles released."
Write-Host "All Upload-File tests passed on PowerShell $($PSVersionTable.PSVersion), $([IntPtr]::Size * 8)-bit."
}
Finally {
$ResolvedTestRoot = (Resolve-Path -LiteralPath $TestRoot).Path
$ResolvedTempRoot = [IO.Path]::GetFullPath([IO.Path]::GetTempPath()).TrimEnd('\') + '\'
If (!$ResolvedTestRoot.StartsWith($ResolvedTempRoot, [StringComparison]::OrdinalIgnoreCase) -or
(Split-Path $ResolvedTestRoot -Leaf) -notlike 'filedrop-tests-*') { Throw 'Unexpected cleanup directory.' }
Remove-Item -LiteralPath $ResolvedTestRoot -Recurse -Force
}