diff --git a/src/Microsoft.PowerShell.Commands.Utility/commands/utility/WebCmdlet/Common/ContentHelper.Common.cs b/src/Microsoft.PowerShell.Commands.Utility/commands/utility/WebCmdlet/Common/ContentHelper.Common.cs index bb31366f79..9eca5ce187 100644 --- a/src/Microsoft.PowerShell.Commands.Utility/commands/utility/WebCmdlet/Common/ContentHelper.Common.cs +++ b/src/Microsoft.PowerShell.Commands.Utility/commands/utility/WebCmdlet/Common/ContentHelper.Common.cs @@ -9,7 +9,7 @@ using System.Management.Automation; using System.Net.Http; using System.Net.Http.Headers; using System.Text; - +using Humanizer; using Microsoft.Win32; namespace Microsoft.PowerShell.Commands @@ -21,8 +21,15 @@ namespace Microsoft.PowerShell.Commands // ContentType may not exist in response header. Return null if not. internal static string? GetContentType(HttpResponseMessage response) => response.Content.Headers.ContentType?.MediaType; + internal static string? GetContentType(HttpRequestMessage request) => request.Content?.Headers.ContentType?.MediaType; + internal static Encoding GetDefaultEncoding() => Encoding.UTF8; + internal static string GetFriendlyContentLength(long? length) => + length.HasValue + ? $"{length.Value.Bytes().Humanize()} ({length.Value:#,0} bytes)" + : "unknown size"; + internal static StringBuilder GetRawContentHeader(HttpResponseMessage response) { StringBuilder raw = new(); @@ -133,10 +140,13 @@ namespace Microsoft.PowerShell.Commands || contentType.Equals("application/xml-external-parsed-entity", StringComparison.OrdinalIgnoreCase) || contentType.Equals("application/xml-dtd", StringComparison.OrdinalIgnoreCase) || contentType.EndsWith("+xml", StringComparison.OrdinalIgnoreCase); - + return isXml; } + internal static bool IsTextBasedContentType([NotNullWhen(true)] string? contentType) + => IsText(contentType) || IsJson(contentType) || IsXml(contentType); + #endregion Internal Methods } } diff --git a/src/Microsoft.PowerShell.Commands.Utility/commands/utility/WebCmdlet/Common/InvokeRestMethodCommand.Common.cs b/src/Microsoft.PowerShell.Commands.Utility/commands/utility/WebCmdlet/Common/InvokeRestMethodCommand.Common.cs index 20a2a980d7..eafdaadbed 100644 --- a/src/Microsoft.PowerShell.Commands.Utility/commands/utility/WebCmdlet/Common/InvokeRestMethodCommand.Common.cs +++ b/src/Microsoft.PowerShell.Commands.Utility/commands/utility/WebCmdlet/Common/InvokeRestMethodCommand.Common.cs @@ -99,18 +99,22 @@ namespace Microsoft.PowerShell.Commands string? characterSet = WebResponseHelper.GetCharacterSet(response); string str = StreamHelper.DecodeStream(responseStream, characterSet, out Encoding encoding, perReadTimeout, _cancelToken.Token); - string encodingVerboseName; + string friendlyName = "unknown"; + string encodingWebName = "unknown"; + string encodingPage = encoding.CodePage == -1 ? "unknown" : encoding.CodePage.ToString(); try { - encodingVerboseName = encoding.HeaderName; + // NOTE: These are getter methods that may possibly throw a NotSupportedException exception, + // hence the try/catch + encodingWebName = encoding.WebName; + friendlyName = encoding.EncodingName; } catch { - encodingVerboseName = string.Empty; } - // NOTE: Tests use this verbose output to verify the encoding. - WriteVerbose($"Content encoding: {encodingVerboseName}"); + // NOTE: Tests use this debug output to verify the encoding. + WriteDebug($"WebResponse content encoding: {encodingWebName} ({friendlyName}) CodePage: {encodingPage}"); // Determine the response type RestReturnType returnType = CheckReturnType(response); @@ -140,7 +144,7 @@ namespace Microsoft.PowerShell.Commands responseStream.Position = 0; } - + if (ShouldSaveToOutFile) { string outFilePath = WebResponseHelper.GetOutFilePath(response, _qualifiedOutFile); diff --git a/src/Microsoft.PowerShell.Commands.Utility/commands/utility/WebCmdlet/Common/WebRequestPSCmdlet.Common.cs b/src/Microsoft.PowerShell.Commands.Utility/commands/utility/WebCmdlet/Common/WebRequestPSCmdlet.Common.cs index 47895a88c0..3b8ff2e337 100644 --- a/src/Microsoft.PowerShell.Commands.Utility/commands/utility/WebCmdlet/Common/WebRequestPSCmdlet.Common.cs +++ b/src/Microsoft.PowerShell.Commands.Utility/commands/utility/WebCmdlet/Common/WebRequestPSCmdlet.Common.cs @@ -93,6 +93,11 @@ namespace Microsoft.PowerShell.Commands { #region Fields + /// + /// Used to prefix the headers in debug and verbose messaging. + /// + internal const string DebugHeaderPrefix = "--- "; + /// /// Cancellation token source. /// @@ -1280,40 +1285,27 @@ namespace Microsoft.PowerShell.Commands _cancelToken = new CancellationTokenSource(); try { - long requestContentLength = request.Content is null ? 0 : request.Content.Headers.ContentLength.Value; + if (IsWriteVerboseEnabled()) + { + WriteWebRequestVerboseInfo(currentRequest); + } - string reqVerboseMsg = string.Format( - CultureInfo.CurrentCulture, - WebCmdletStrings.WebMethodInvocationVerboseMsg, - request.Version, - request.Method, - requestContentLength); - - WriteVerbose(reqVerboseMsg); - - string reqDebugMsg = string.Format( - CultureInfo.CurrentCulture, - WebCmdletStrings.WebRequestDebugMsg, - request.ToString()); - - WriteDebug(reqDebugMsg); + if (IsWriteDebugEnabled()) + { + WriteWebRequestDebugInfo(currentRequest); + } response = client.SendAsync(currentRequest, HttpCompletionOption.ResponseHeadersRead, _cancelToken.Token).GetAwaiter().GetResult(); - string contentType = ContentHelper.GetContentType(response); - long? contentLength = response.Content.Headers.ContentLength; - string respVerboseMsg = contentLength is null - ? string.Format(CultureInfo.CurrentCulture, WebCmdletStrings.WebResponseNoSizeVerboseMsg, response.Version, contentType) - : string.Format(CultureInfo.CurrentCulture, WebCmdletStrings.WebResponseVerboseMsg, response.Version, contentLength, contentType); + if (IsWriteVerboseEnabled()) + { + WriteWebResponseVerboseInfo(response); + } - WriteVerbose(respVerboseMsg); - - string resDebugMsg = string.Format( - CultureInfo.CurrentCulture, - WebCmdletStrings.WebResponseDebugMsg, - response.ToString()); - - WriteDebug(resDebugMsg); + if (IsWriteDebugEnabled()) + { + WriteWebResponseDebugInfo(response); + } } catch (TaskCanceledException ex) { @@ -1437,13 +1429,206 @@ namespace Microsoft.PowerShell.Commands { ArgumentNullException.ThrowIfNull(response); } - #endregion Virtual Methods #region Helper Methods - +#nullable enable internal static TimeSpan ConvertTimeoutSecondsToTimeSpan(int timeout) => timeout > 0 ? TimeSpan.FromSeconds(timeout) : Timeout.InfiniteTimeSpan; + private void WriteWebRequestVerboseInfo(HttpRequestMessage request) + { + try + { + // Typical Basic Example: 'WebRequest: v1.1 POST https://httpstat.us/200 with query length 6' + StringBuilder verboseBuilder = new(128); + + // "Redact" the query string from verbose output, the details will be visible in Debug output + string uriWithoutQuery = request.RequestUri?.GetLeftPart(UriPartial.Path) ?? string.Empty; + verboseBuilder.Append($"WebRequest: v{request.Version} {request.Method} {uriWithoutQuery}"); + if (request.RequestUri?.Query is not null && request.RequestUri.Query.Length > 1) + { + verboseBuilder.Append($" with query length {request.RequestUri.Query.Length - 1}"); + } + + string? requestContentType = ContentHelper.GetContentType(request); + if (requestContentType is not null) + { + verboseBuilder.Append($" with {requestContentType} payload"); + } + + long? requestContentLength = request.Content?.Headers?.ContentLength; + if (requestContentLength is not null) + { + verboseBuilder.Append($" with body size {ContentHelper.GetFriendlyContentLength(requestContentLength)}"); + } + if (OutFile is not null) + { + verboseBuilder.Append($" output to {QualifyFilePath(OutFile)}"); + } + + WriteVerbose(verboseBuilder.ToString().Trim()); + } + catch (Exception ex) + { + // Just in case there are any edge cases we missed, we don't break workflows with an exception + WriteVerbose($"Failed to Write WebRequest Verbose Info: {ex} {ex.StackTrace}"); + } + } + + private void WriteWebRequestDebugInfo(HttpRequestMessage request) + { + try + { + // Typical basic example: + // WebRequest Detail + // ---QUERY + // test = 5 + // --- HEADERS + // User - Agent: Mozilla / 5.0, (Linux;Ubuntu 24.04.2 LTS;en - US), PowerShell / 7.6.0 + StringBuilder debugBuilder = new("WebRequest Detail" + Environment.NewLine, 512); + + if (!string.IsNullOrEmpty(request.RequestUri?.Query)) + { + debugBuilder.Append(DebugHeaderPrefix).AppendLine("QUERY"); + string[] queryParams = request.RequestUri.Query.TrimStart('?').Split('&'); + debugBuilder + .AppendJoin(Environment.NewLine, queryParams) + .AppendLine() + .AppendLine(); + } + + debugBuilder.Append(DebugHeaderPrefix).AppendLine("HEADERS"); + + foreach (var headerSet in new HttpHeaders?[] { request.Headers, request.Content?.Headers }) + { + if (headerSet is null) + { + continue; + } + + debugBuilder.AppendLine(headerSet.ToString()); + } + + if (request.Content is not null) + { + debugBuilder + .Append(DebugHeaderPrefix).AppendLine("BODY") + .AppendLine(request.Content switch + { + StringContent stringContent => stringContent + .ReadAsStringAsync(_cancelToken.Token) + .GetAwaiter().GetResult(), + MultipartFormDataContent multipartContent => "=> Multipart Form Content" + + Environment.NewLine + + multipartContent.ReadAsStringAsync(_cancelToken.Token) + .GetAwaiter().GetResult(), + ByteArrayContent byteContent => InFile is not null + ? "[Binary content: " + + ContentHelper.GetFriendlyContentLength(byteContent.Headers.ContentLength) + + "]" + : byteContent.ReadAsStringAsync(_cancelToken.Token).GetAwaiter().GetResult(), + StreamContent streamContent => + "[Stream content: " + ContentHelper.GetFriendlyContentLength(streamContent.Headers.ContentLength) + "]", + _ => "[Unknown content type]", + }) + .AppendLine(); + } + + WriteDebug(debugBuilder.ToString().Trim()); + } + catch (Exception ex) + { + // Just in case there are any edge cases we missed, we don't break workflows with an exception + WriteVerbose($"Failed to Write WebRequest Debug Info: {ex} {ex.StackTrace}"); + } + } + + private void WriteWebResponseVerboseInfo(HttpResponseMessage response) + { + try + { + // Typical basic example: WebResponse: 200 OK with text/plain payload body size 6 B (6 bytes) + StringBuilder verboseBuilder = new(128); + verboseBuilder.Append($"WebResponse: {(int)response.StatusCode} {response.ReasonPhrase ?? response.StatusCode.ToString()}"); + + string? responseContentType = ContentHelper.GetContentType(response); + if (responseContentType is not null) + { + verboseBuilder.Append($" with {responseContentType} payload"); + } + + long? responseContentLength = response.Content?.Headers?.ContentLength; + if (responseContentLength is not null) + { + verboseBuilder.Append($" with body size {ContentHelper.GetFriendlyContentLength(responseContentLength)}"); + } + + WriteVerbose(verboseBuilder.ToString().Trim()); + } + catch (Exception ex) + { + // Just in case there are any edge cases we missed, we don't break workflows with an exception + WriteVerbose($"Failed to Write WebResponse Verbose Info: {ex} {ex.StackTrace}"); + } + } + + private void WriteWebResponseDebugInfo(HttpResponseMessage response) + { + try + { + // Typical basic example + // WebResponse Detail + // --- HEADERS + // Date: Fri, 09 May 2025 18:06:44 GMT + // Server: Kestrel + // Set-Cookie: ARRAffinity=ee0b467f95b53d8dcfe48aeeb4173f93cf819be6e4721f434341647f4695039d;Path=/;HttpOnly;Secure;Domain=httpstat.us, ARRAffinitySameSite=ee0b467f95b53d8dcfe48aeeb4173f93cf819be6e4721f434341647f4695039d;Path=/;HttpOnly;SameSite=None;Secure;Domain=httpstat.us + // Strict-Transport-Security: max-age=2592000 + // Request-Context: appId=cid-v1:3548b0f5-7f75-492f-82bb-b6eb0e864e53 + // Content-Length: 6 + // Content-Type: text/plain + // --- BODY + // 200 OK + StringBuilder debugBuilder = new("WebResponse Detail" + Environment.NewLine, 512); + + debugBuilder.Append(DebugHeaderPrefix).AppendLine("HEADERS"); + + foreach (var headerSet in new HttpHeaders?[] { response.Headers, response.Content?.Headers }) + { + if (headerSet is null) + { + continue; + } + + debugBuilder.AppendLine(headerSet.ToString()); + } + + if (response.Content is not null) + { + debugBuilder.Append(DebugHeaderPrefix).AppendLine("BODY"); + + if (ContentHelper.IsTextBasedContentType(ContentHelper.GetContentType(response))) + { + debugBuilder.AppendLine( + response.Content.ReadAsStringAsync(_cancelToken.Token) + .GetAwaiter().GetResult()); + } + else + { + string friendlyContentLength = ContentHelper.GetFriendlyContentLength( + response.Content?.Headers?.ContentLength); + debugBuilder.AppendLine($"[Binary content: {friendlyContentLength}]"); + } + } + + WriteDebug(debugBuilder.ToString().Trim()); + } + catch (Exception ex) + { + // Just in case there are any edge cases we missed, we don't break workflows with an exception + WriteVerbose($"Failed to Write WebResponse Debug Info: {ex} {ex.StackTrace}"); + } + } + private Uri PrepareUri(Uri uri) { uri = CheckProtocol(uri); @@ -1478,6 +1663,7 @@ namespace Microsoft.PowerShell.Commands return uri.IsAbsoluteUri ? uri : new Uri("http://" + uri.OriginalString); } +#nullable restore private string QualifyFilePath(string path) => PathUtils.ResolveFilePath(filePath: path, command: this, isLiteralPath: true); diff --git a/src/Microsoft.PowerShell.Commands.Utility/resources/WebCmdletStrings.resx b/src/Microsoft.PowerShell.Commands.Utility/resources/WebCmdletStrings.resx index fb704891b8..cb080d3701 100644 --- a/src/Microsoft.PowerShell.Commands.Utility/resources/WebCmdletStrings.resx +++ b/src/Microsoft.PowerShell.Commands.Utility/resources/WebCmdletStrings.resx @@ -234,21 +234,9 @@ Following rel link {0} - - Requested HTTP/{0} {1} with {2}-byte payload - The remote server indicated it could not resume downloading. The local file will be overwritten. - - Received HTTP/{0} {1}-byte response of content type {2} - - - Request {0} - - - Response {0} - Received HTTP/{0} response of content type {1} of unknown size diff --git a/test/powershell/Modules/Microsoft.PowerShell.Utility/WebCmdlets.Tests.ps1 b/test/powershell/Modules/Microsoft.PowerShell.Utility/WebCmdlets.Tests.ps1 index 2690c75fa9..1ddb9f10ba 100644 --- a/test/powershell/Modules/Microsoft.PowerShell.Utility/WebCmdlets.Tests.ps1 +++ b/test/powershell/Modules/Microsoft.PowerShell.Utility/WebCmdlets.Tests.ps1 @@ -284,7 +284,7 @@ function ExecuteWebRequest { return $result } -[string] $verboseEncodingPrefix = 'Content encoding: ' +[string] $debugEncodingPrefix = 'WebResponse content encoding: ' # This function calls Invoke-WebRequest with the given uri and # parses the verbose output to determine the encoding used for the content. function ExecuteRestMethod { @@ -297,37 +297,39 @@ function ExecuteRestMethod { $UseBasicParsing ) $result = @{Output = $null; Error = $null; Encoding = $null; Content = $null} - $verbosePreferenceSave = $VerbosePreference - $VerbosePreference = 'Continue' + $debugPreferenceSave = $DebugPreference + $DebugPreference = 'Continue' try { - $verboseFile = Join-Path $TestDrive -ChildPath ExecuteRestMethod.verbose.txt - $result.Output = Invoke-RestMethod -Uri $Uri -UseBasicParsing:$UseBasicParsing.IsPresent -Verbose 4>$verboseFile + $debugFile = Join-Path $TestDrive -ChildPath ExecuteRestMethod.debug.txt + $result.Output = Invoke-RestMethod -Uri $Uri -UseBasicParsing:$UseBasicParsing.IsPresent 5>$debugFile $result.Content = $result.Output - if (Test-Path -Path $verboseFile) { - $result.Verbose = Get-Content -Path $verboseFile - foreach ($item in $result.Verbose) { + # Invoke-RestMethod does not return encoding as part of an object, only in debug output, so we parse the debug output for the purposes of verifying the test. + # This debug output is defined in InvokeRestMethodCommand.Common.cs ProcessResponse() + if (Test-Path -Path $debugFile) { + $result.Debug = Get-Content -Path $debugFile + foreach ($item in $result.Debug) { $line = $item.Trim() - if ($line.StartsWith($verboseEncodingPrefix)) { - $encodingName = $item.SubString($verboseEncodingPrefix.Length).Trim() + if ($line.StartsWith($debugEncodingPrefix)) { + $encodingName = [int]::Parse($item.SubString($EncodingPrefix.Length).Split('CodePage: ')[1].Trim()) $result.Encoding = [System.Text.Encoding]::GetEncoding($encodingName) break } } - if ($result.Encoding -eq $null) { - throw "Encoding not found in verbose output. Lines: $($result.Verbose.Count) Content:$($result.Verbose)" + if ($null -eq $result.Encoding) { + throw "Encoding not found in debug output. Lines: $($result.Debug.Count) Content:$($result.Debug)" } } - if ($result.Verbose -eq $null) { - throw "No verbose output was found" + if ($null -eq $result.Debug) { + throw "No debug output was found" } } catch { $result.Error = $_ | Select-Object * | Out-String } finally { - $VerbosePreference = $verbosePreferenceSave - if (Test-Path -Path $verboseFile) { - Remove-Item -Path $verboseFile -ErrorAction SilentlyContinue + $DebugPreference = $debugPreferenceSave + if (Test-Path -Path $debugFile) { + Remove-Item -Path $debugFile -ErrorAction SilentlyContinue } }